Autosave: 20260618-001727
This commit is contained in:
parent
638119614f
commit
9fb349ab84
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,8 @@
|
||||
import * as icon from '@mdi/js';
|
||||
import axios from 'axios';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import BaseButton from '../BaseButton';
|
||||
import BaseButtons from '../BaseButtons';
|
||||
import BaseIcon from '../BaseIcon';
|
||||
@ -47,6 +48,89 @@ type QuickAction = {
|
||||
iconPath: string;
|
||||
};
|
||||
|
||||
type GeoSeekLocationSource = 'browser' | 'demo';
|
||||
|
||||
type GeoSeekResolvedLocation = {
|
||||
label: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
source: GeoSeekLocationSource;
|
||||
accuracyMeters?: number;
|
||||
};
|
||||
|
||||
type PublicPlaceOffering = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
offering_type?: string;
|
||||
price?: number | string | null;
|
||||
stock_status?: string;
|
||||
stock_label?: string;
|
||||
stock_quantity?: number | string | null;
|
||||
is_verified?: boolean;
|
||||
};
|
||||
|
||||
type PublicPlaceRow = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
short_description?: string;
|
||||
full_description?: string;
|
||||
address?: string;
|
||||
city?: string;
|
||||
province?: string;
|
||||
latitude?: number | string | null;
|
||||
longitude?: number | string | null;
|
||||
average_price?: number | string | null;
|
||||
rating_average?: number | string | null;
|
||||
rating_count?: number | string | null;
|
||||
is_verified?: boolean;
|
||||
distance_km?: number | string | null;
|
||||
geo_score?: number | string | null;
|
||||
category?: {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
description?: string;
|
||||
} | null;
|
||||
offerings?: PublicPlaceOffering[];
|
||||
offerings_summary?: {
|
||||
products?: number;
|
||||
services?: number;
|
||||
total?: number;
|
||||
available?: number;
|
||||
};
|
||||
live_status?: {
|
||||
status?: string;
|
||||
label?: string;
|
||||
};
|
||||
external_source?: string;
|
||||
};
|
||||
|
||||
type PublicPlacesResponse = {
|
||||
rows?: PublicPlaceRow[];
|
||||
count?: number;
|
||||
radius_km?: number | null;
|
||||
radius_zone?: {
|
||||
label?: string;
|
||||
range?: string;
|
||||
description?: string;
|
||||
} | null;
|
||||
filtered_by_radius?: boolean;
|
||||
expanded_for_nearest?: boolean;
|
||||
external_sources?: string[];
|
||||
external_source_errors?: string[];
|
||||
total_candidates?: number;
|
||||
};
|
||||
|
||||
type GeoSeekApiMeta = {
|
||||
count: number;
|
||||
totalCandidates: number;
|
||||
radiusZoneLabel: string;
|
||||
externalSources: string[];
|
||||
externalSourceErrors: string[];
|
||||
filteredByRadius: boolean;
|
||||
expandedForNearest: boolean;
|
||||
};
|
||||
|
||||
const typeLabels: Record<GeoSeekItemType, string> = {
|
||||
place: 'Tempat',
|
||||
product: 'Produk',
|
||||
@ -62,6 +146,207 @@ const typeLabels: Record<GeoSeekItemType, string> = {
|
||||
courier: 'Kurir',
|
||||
};
|
||||
|
||||
const backendDemoGeoSeekLocation: GeoSeekResolvedLocation = {
|
||||
label: 'Pekanbaru Data Demo',
|
||||
latitude: 0.5095,
|
||||
longitude: 101.4549,
|
||||
source: 'demo',
|
||||
};
|
||||
|
||||
const emptyApiMeta: GeoSeekApiMeta = {
|
||||
count: 0,
|
||||
totalCandidates: 0,
|
||||
radiusZoneLabel: 'Radius aktif',
|
||||
externalSources: [],
|
||||
externalSourceErrors: [],
|
||||
filteredByRadius: false,
|
||||
expandedForNearest: false,
|
||||
};
|
||||
|
||||
const apiQueryByModule: Partial<Record<GeoSeekModuleKey, string>> = {
|
||||
home: 'produk jasa umkm lokal',
|
||||
search: 'produk jasa umkm lokal',
|
||||
map: '',
|
||||
products: 'produk stok toko',
|
||||
services: 'jasa servis booking',
|
||||
umkm: '',
|
||||
culinary: 'kuliner cafe warung',
|
||||
health: 'kesehatan apotek klinik',
|
||||
property: 'properti ruko rumah',
|
||||
automotive: 'bengkel otomotif servis',
|
||||
tourism: 'wisata hotel lokal',
|
||||
events: 'event bazar lokal',
|
||||
promos: 'promo diskon lokal',
|
||||
marketplace: 'produk marketplace lokal',
|
||||
booking: 'jasa booking lokal',
|
||||
courier: 'kurir antar lokal',
|
||||
'business-dashboard': '',
|
||||
profile: '',
|
||||
};
|
||||
|
||||
const getApiQuery = (query: string, moduleKey: GeoSeekModuleKey) => query.trim() || apiQueryByModule[moduleKey] || '';
|
||||
|
||||
const toOptionalNumber = (value: unknown) => {
|
||||
if (value === null || value === undefined || value === '') return undefined;
|
||||
|
||||
const number = Number(value);
|
||||
|
||||
return Number.isFinite(number) ? number : undefined;
|
||||
};
|
||||
|
||||
const normalizeTagText = (value: unknown) => String(value || '')
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim();
|
||||
|
||||
const createTags = (values: unknown[], fallbackTags: string[] = ['lokal', 'geoseek']) => {
|
||||
const tags = values
|
||||
.flatMap((value) => normalizeTagText(value).split(/\s+/))
|
||||
.filter((word) => word.length > 2 && !['dan', 'atau', 'yang', 'untuk', 'dari'].includes(word));
|
||||
|
||||
return Array.from(new Set([...tags, ...fallbackTags])).slice(0, 8);
|
||||
};
|
||||
|
||||
const getPublicPlaceArea = (place: PublicPlaceRow) => [place.city, place.province].filter(Boolean).join(', ') || 'Area sekitar';
|
||||
|
||||
const getPublicPlaceAddress = (place: PublicPlaceRow) => [place.address, place.city, place.province].filter(Boolean).join(', ') || 'Alamat mengikuti data lokasi';
|
||||
|
||||
const getPublicPlaceCategory = (place: PublicPlaceRow) => place.category?.name || 'UMKM Lokal';
|
||||
|
||||
const getPublicPlaceDescription = (place: PublicPlaceRow) => place.short_description || place.full_description || 'Bisnis lokal dari database GeoSeek.';
|
||||
|
||||
const getPublicPlaceType = (place: PublicPlaceRow): GeoSeekItemType => {
|
||||
const haystack = normalizeTagText([
|
||||
place.name,
|
||||
place.category?.name,
|
||||
place.category?.slug,
|
||||
place.category?.description,
|
||||
place.short_description,
|
||||
place.full_description,
|
||||
].join(' '));
|
||||
|
||||
if (/kuliner|cafe|kopi|restoran|warung|kedai|makan|food/.test(haystack)) return 'culinary';
|
||||
if (/kesehatan|apotek|klinik|herbal|obat|dokter|health/.test(haystack)) return 'health';
|
||||
if (/bengkel|otomotif|mobil|motor|servis|service|auto/.test(haystack)) return 'automotive';
|
||||
if (/properti|ruko|rumah|tanah|kios|property/.test(haystack)) return 'property';
|
||||
if (/wisata|hotel|travel|tour/.test(haystack)) return 'tourism';
|
||||
if (/event|bazar|agenda/.test(haystack)) return 'event';
|
||||
|
||||
return 'business';
|
||||
};
|
||||
|
||||
const getOfferingType = (offering: PublicPlaceOffering): GeoSeekItemType => (offering.offering_type === 'product' ? 'product' : 'service');
|
||||
|
||||
const getOfferingStock = (offering: PublicPlaceOffering) => {
|
||||
const quantity = toOptionalNumber(offering.stock_quantity);
|
||||
|
||||
if (quantity !== undefined) return Math.round(quantity);
|
||||
if (offering.stock_status === 'limited') return 8;
|
||||
if (offering.stock_status === 'in_stock') return 24;
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getApiActivityScore = (place: PublicPlaceRow, offering?: PublicPlaceOffering) => {
|
||||
const geoScore = toOptionalNumber(place.geo_score);
|
||||
const baseScore = geoScore ?? (place.is_verified ? 86 : 72);
|
||||
const verifiedBonus = offering?.is_verified ? 8 : 0;
|
||||
|
||||
return Math.max(45, Math.min(100, Math.round(baseScore + verifiedBonus)));
|
||||
};
|
||||
|
||||
const publicPlaceToGeoSeekItems = (place: PublicPlaceRow): GeoSeekItem[] => {
|
||||
const placeId = place.id || `place-${place.name || 'unknown'}`;
|
||||
const category = getPublicPlaceCategory(place);
|
||||
const distanceKm = toOptionalNumber(place.distance_km) ?? 0;
|
||||
const rating = toOptionalNumber(place.rating_average) ?? 4.2;
|
||||
const reviews = Math.round(toOptionalNumber(place.rating_count) ?? 0);
|
||||
const latitude = toOptionalNumber(place.latitude) ?? backendDemoGeoSeekLocation.latitude;
|
||||
const longitude = toOptionalNumber(place.longitude) ?? backendDemoGeoSeekLocation.longitude;
|
||||
const offerings = Array.isArray(place.offerings) ? place.offerings : [];
|
||||
const hasProducts = offerings.some((offering) => offering.offering_type === 'product');
|
||||
const hasServices = offerings.some((offering) => offering.offering_type === 'service');
|
||||
const open = place.live_status?.status !== 'closed';
|
||||
const baseTags = createTags([
|
||||
place.name,
|
||||
category,
|
||||
place.category?.slug,
|
||||
place.short_description,
|
||||
place.full_description,
|
||||
offerings.map((offering) => offering.name).join(' '),
|
||||
], ['backend', 'umkm', 'lokal']);
|
||||
|
||||
const businessItem: GeoSeekItem = {
|
||||
id: `api-place-${placeId}`,
|
||||
type: getPublicPlaceType(place),
|
||||
name: place.name || 'Bisnis Lokal GeoSeek',
|
||||
businessName: place.name || 'Bisnis Lokal GeoSeek',
|
||||
category,
|
||||
description: getPublicPlaceDescription(place),
|
||||
address: getPublicPlaceAddress(place),
|
||||
area: getPublicPlaceArea(place),
|
||||
latitude,
|
||||
longitude,
|
||||
distanceKm,
|
||||
price: toOptionalNumber(place.average_price),
|
||||
rating,
|
||||
reviews,
|
||||
activityScore: getApiActivityScore(place),
|
||||
tags: baseTags,
|
||||
open,
|
||||
promo: place.is_verified ? 'Bisnis terverifikasi di data GeoSeek' : undefined,
|
||||
etaMinutes: hasServices ? Math.max(12, Math.min(60, Math.round(distanceKm * 5 + 15))) : undefined,
|
||||
bookingAvailable: hasServices,
|
||||
deliveryAvailable: hasProducts,
|
||||
};
|
||||
|
||||
const offeringItems = offerings.map((offering, index): GeoSeekItem => {
|
||||
const type = getOfferingType(offering);
|
||||
const stock = getOfferingStock(offering);
|
||||
const stockLabel = offering.stock_label || offering.stock_status || 'Info stok tersedia';
|
||||
|
||||
return {
|
||||
id: `api-offering-${placeId}-${offering.id || index}`,
|
||||
type,
|
||||
name: offering.name || (type === 'product' ? 'Produk lokal' : 'Jasa lokal'),
|
||||
businessName: businessItem.businessName,
|
||||
category: `${typeLabels[type]} • ${category}`,
|
||||
description: offering.description || getPublicPlaceDescription(place),
|
||||
address: businessItem.address,
|
||||
area: businessItem.area,
|
||||
latitude,
|
||||
longitude,
|
||||
distanceKm,
|
||||
price: toOptionalNumber(offering.price) ?? toOptionalNumber(place.average_price),
|
||||
stock,
|
||||
rating,
|
||||
reviews,
|
||||
activityScore: getApiActivityScore(place, offering),
|
||||
tags: createTags([offering.name, offering.description, category, stockLabel, place.name], [type, 'backend', 'lokal']),
|
||||
open,
|
||||
promo: offering.stock_status === 'limited' ? `Stok terbatas: ${stockLabel}` : undefined,
|
||||
etaMinutes: type === 'service' ? Math.max(12, Math.min(60, Math.round(distanceKm * 5 + 15))) : undefined,
|
||||
bookingAvailable: type === 'service',
|
||||
deliveryAvailable: type === 'product',
|
||||
};
|
||||
});
|
||||
|
||||
return [businessItem, ...offeringItems];
|
||||
};
|
||||
|
||||
const normalizePublicPlaceItems = (rows: PublicPlaceRow[]) => {
|
||||
const seen = new Set<string>();
|
||||
|
||||
return rows.flatMap(publicPlaceToGeoSeekItems).filter((item) => {
|
||||
if (seen.has(item.id)) return false;
|
||||
|
||||
seen.add(item.id);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const moduleIconMap: Partial<Record<GeoSeekModuleKey, string>> = {
|
||||
home: icon.mdiViewDashboardOutline,
|
||||
search: icon.mdiMagnify,
|
||||
@ -148,7 +433,12 @@ const getDraftItemType = (category: string, moduleKey: GeoSeekModuleKey): GeoSee
|
||||
return 'business';
|
||||
};
|
||||
|
||||
const createPublishedItem = (input: string, draft: GeoSeekSmartDraft, moduleKey: GeoSeekModuleKey): GeoSeekItem => {
|
||||
const createPublishedItem = (
|
||||
input: string,
|
||||
draft: GeoSeekSmartDraft,
|
||||
moduleKey: GeoSeekModuleKey,
|
||||
origin: { latitude: number; longitude: number } = defaultGeoSeekLocation,
|
||||
): GeoSeekItem => {
|
||||
const type = getDraftItemType(draft.category, moduleKey);
|
||||
const distanceKm = Math.min(2.8, Number((0.7 + (draft.itemName.length % 12) / 10).toFixed(1)));
|
||||
const supportsBooking = ['service', 'health', 'property', 'tourism', 'culinary'].includes(type);
|
||||
@ -163,8 +453,8 @@ const createPublishedItem = (input: string, draft: GeoSeekSmartDraft, moduleKey:
|
||||
description: `Item lokal dari Smart Input: ${input}`,
|
||||
address: draft.location,
|
||||
area: draft.location,
|
||||
latitude: defaultGeoSeekLocation.latitude + distanceKm / 1000,
|
||||
longitude: defaultGeoSeekLocation.longitude + distanceKm / 900,
|
||||
latitude: origin.latitude + distanceKm / 1000,
|
||||
longitude: origin.longitude + distanceKm / 900,
|
||||
distanceKm,
|
||||
price: draft.price,
|
||||
stock: draft.stock,
|
||||
@ -280,9 +570,9 @@ const getItemActionLabel = (moduleKey: GeoSeekModuleKey, item: GeoSeekScoredItem
|
||||
return 'Aktifkan Otomasi';
|
||||
};
|
||||
|
||||
const getMapPosition = (item: GeoSeekScoredItem, index: number) => {
|
||||
const left = Math.min(88, Math.max(8, 50 + (item.longitude - defaultGeoSeekLocation.longitude) * 1800 + index * 2));
|
||||
const top = Math.min(82, Math.max(10, 50 + (item.latitude - defaultGeoSeekLocation.latitude) * -1800 + index * 3));
|
||||
const getMapPosition = (item: GeoSeekScoredItem, index: number, origin: { latitude: number; longitude: number } = defaultGeoSeekLocation) => {
|
||||
const left = Math.min(88, Math.max(8, 50 + (item.longitude - origin.longitude) * 1800 + index * 2));
|
||||
const top = Math.min(82, Math.max(10, 50 + (item.latitude - origin.latitude) * -1800 + index * 3));
|
||||
|
||||
return { left: `${left}%`, top: `${top}%` };
|
||||
};
|
||||
@ -398,17 +688,33 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) {
|
||||
const [publishedItems, setPublishedItems] = useState<GeoSeekItem[]>([]);
|
||||
const [actionHistory, setActionHistory] = useState<GeoSeekActionHistory[]>([]);
|
||||
const [isLocalStateReady, setIsLocalStateReady] = useState(false);
|
||||
const [resolvedLocation, setResolvedLocation] = useState<GeoSeekResolvedLocation>(backendDemoGeoSeekLocation);
|
||||
const [isResolvingLocation, setIsResolvingLocation] = useState(true);
|
||||
const [locationStatus, setLocationStatus] = useState('Mengambil lokasi browser untuk pencarian radius GeoSeek...');
|
||||
const [apiItems, setApiItems] = useState<GeoSeekItem[]>([]);
|
||||
const [isLoadingPlaces, setIsLoadingPlaces] = useState(false);
|
||||
const [apiError, setApiError] = useState('');
|
||||
const [apiMeta, setApiMeta] = useState<GeoSeekApiMeta>(emptyApiMeta);
|
||||
const [searchRequestVersion, setSearchRequestVersion] = useState(0);
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
|
||||
const hasBackendItemsForModule = useMemo(
|
||||
() => apiItems.some((item) => activeModule.includeTypes.includes(item.type)),
|
||||
[activeModule.includeTypes, apiItems],
|
||||
);
|
||||
|
||||
const liveExtraItems = useMemo(() => [...apiItems, ...publishedItems], [apiItems, publishedItems]);
|
||||
|
||||
const results = useMemo(
|
||||
() => filterGeoSeekItems({
|
||||
query,
|
||||
radiusKm,
|
||||
includeTypes: activeModule.includeTypes,
|
||||
moduleKey: activeModule.key,
|
||||
extraItems: publishedItems,
|
||||
extraItems: liveExtraItems,
|
||||
baseItems: hasBackendItemsForModule ? [] : undefined,
|
||||
}),
|
||||
[activeModule.includeTypes, activeModule.key, publishedItems, query, radiusKm],
|
||||
[activeModule.includeTypes, activeModule.key, hasBackendItemsForModule, liveExtraItems, query, radiusKm],
|
||||
);
|
||||
|
||||
const allModuleResults = useMemo(
|
||||
@ -417,9 +723,10 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) {
|
||||
radiusKm: 10,
|
||||
includeTypes: activeModule.includeTypes,
|
||||
moduleKey: activeModule.key,
|
||||
extraItems: publishedItems,
|
||||
extraItems: liveExtraItems,
|
||||
baseItems: hasBackendItemsForModule ? [] : undefined,
|
||||
}),
|
||||
[activeModule.includeTypes, activeModule.key, publishedItems],
|
||||
[activeModule.includeTypes, activeModule.key, hasBackendItemsForModule, liveExtraItems],
|
||||
);
|
||||
|
||||
const smartDraft = useMemo(() => createSmartDraft(smartInput), [smartInput]);
|
||||
@ -429,6 +736,43 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) {
|
||||
const actionSet = getActionSet(activeModule.key);
|
||||
const mapItems = (results.length ? results : allModuleResults).slice(0, 8);
|
||||
|
||||
const requestCurrentLocation = useCallback(() => {
|
||||
setIsResolvingLocation(true);
|
||||
setLocationStatus('Mengambil lokasi browser untuk pencarian radius GeoSeek...');
|
||||
|
||||
if (typeof navigator === 'undefined' || !navigator.geolocation) {
|
||||
setResolvedLocation(backendDemoGeoSeekLocation);
|
||||
setLocationStatus('Browser tidak mendukung GPS. GeoSeek memakai lokasi demo backend Pekanbaru.');
|
||||
setIsResolvingLocation(false);
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
setResolvedLocation({
|
||||
label: 'Lokasi browser Anda',
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
source: 'browser',
|
||||
accuracyMeters: Math.round(position.coords.accuracy),
|
||||
});
|
||||
setLocationStatus('GPS aktif. Hasil GeoSeek dihitung dari lokasi browser Anda.');
|
||||
setIsResolvingLocation(false);
|
||||
},
|
||||
(error) => {
|
||||
console.warn('GeoSeek tidak mendapat izin/lokasi browser, memakai fallback demo backend:', error);
|
||||
setResolvedLocation(backendDemoGeoSeekLocation);
|
||||
setLocationStatus('GPS belum diizinkan. GeoSeek memakai lokasi demo backend Pekanbaru agar data nyata tetap tampil.');
|
||||
setIsResolvingLocation(false);
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
maximumAge: 60000,
|
||||
timeout: 8000,
|
||||
},
|
||||
);
|
||||
}, []);
|
||||
|
||||
const recordAction = (message: string, label = activeModule.menuLabel) => {
|
||||
setActionStatus(message);
|
||||
setActionHistory((previousHistory) => [
|
||||
@ -444,7 +788,7 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) {
|
||||
};
|
||||
|
||||
const publishSmartDraft = () => {
|
||||
const newItem = createPublishedItem(smartInput, smartDraft, activeModule.key);
|
||||
const newItem = createPublishedItem(smartInput, smartDraft, activeModule.key, resolvedLocation);
|
||||
|
||||
setPublishedItems((previousItems) => [
|
||||
newItem,
|
||||
@ -501,10 +845,72 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) {
|
||||
}
|
||||
}, [actionHistory, isLocalStateReady, publishedItems]);
|
||||
|
||||
useEffect(() => {
|
||||
requestCurrentLocation();
|
||||
}, [requestCurrentLocation]);
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
|
||||
const timeoutId = window.setTimeout(async () => {
|
||||
setIsLoadingPlaces(true);
|
||||
setApiError('');
|
||||
|
||||
try {
|
||||
const apiQuery = getApiQuery(query, activeModule.key);
|
||||
const response = await axios.get<PublicPlacesResponse>('/public/places', {
|
||||
params: {
|
||||
q: apiQuery,
|
||||
lat: resolvedLocation.latitude,
|
||||
lng: resolvedLocation.longitude,
|
||||
radiusKm,
|
||||
limit: 36,
|
||||
},
|
||||
});
|
||||
|
||||
if (!isActive) return;
|
||||
|
||||
const rows = Array.isArray(response.data?.rows) ? response.data.rows : [];
|
||||
const externalSourceErrors = Array.isArray(response.data?.external_source_errors)
|
||||
? response.data.external_source_errors
|
||||
: [];
|
||||
const radiusZoneParts = [response.data?.radius_zone?.label, response.data?.radius_zone?.range].filter(Boolean);
|
||||
|
||||
setApiItems(normalizePublicPlaceItems(rows));
|
||||
setApiMeta({
|
||||
count: Number(response.data?.count || rows.length),
|
||||
totalCandidates: Number(response.data?.total_candidates || rows.length),
|
||||
radiusZoneLabel: radiusZoneParts.join(' • ') || 'Radius aktif',
|
||||
externalSources: Array.isArray(response.data?.external_sources) ? response.data.external_sources : [],
|
||||
externalSourceErrors,
|
||||
filteredByRadius: Boolean(response.data?.filtered_by_radius),
|
||||
expandedForNearest: Boolean(response.data?.expanded_for_nearest),
|
||||
});
|
||||
setApiError(externalSourceErrors.length ? externalSourceErrors.join(' ') : '');
|
||||
} catch (error) {
|
||||
if (!isActive) return;
|
||||
|
||||
console.error('Gagal memuat data backend GeoSeek Pro:', error);
|
||||
setApiItems([]);
|
||||
setApiMeta(emptyApiMeta);
|
||||
setApiError('Data backend GeoSeek belum bisa dimuat. UI memakai fallback demo lokal.');
|
||||
} finally {
|
||||
if (isActive) {
|
||||
setIsLoadingPlaces(false);
|
||||
}
|
||||
}
|
||||
}, 350);
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [activeModule.key, query, radiusKm, resolvedLocation.latitude, resolvedLocation.longitude, searchRequestVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
setQuery(getDefaultQuery(activeModule.key));
|
||||
setRadiusKm(3);
|
||||
setActionStatus(`Menu ${activeModule.menuLabel} siap. Data demo otomatis dimuat dan diurutkan dengan GeoScore.`);
|
||||
setActionStatus(`Menu ${activeModule.menuLabel} siap. GeoSeek memuat data backend nyata dan memakai fallback demo jika data sekitar belum tersedia.`);
|
||||
}, [activeModule.key, activeModule.menuLabel]);
|
||||
|
||||
const profileName = [currentUser?.firstName, currentUser?.lastName].filter(Boolean).join(' ') || currentUser?.email || 'Pengguna GeoSeek';
|
||||
@ -527,8 +933,8 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) {
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.35em] text-white/70">GeoSeek Pro Automation</p>
|
||||
<h2 className="mt-4 max-w-3xl text-4xl font-black leading-tight md:text-5xl">{activeModule.subtitle}</h2>
|
||||
<p className="mt-5 max-w-2xl text-sm leading-6 text-white/85">
|
||||
Lokasi demo: {defaultGeoSeekLocation.label}. Semua menu kini punya fungsi awal: pencarian radius, GeoScore otomatis,
|
||||
draft input pintar, simulasi peta, marketplace, booking, kurir, dan insight bisnis.
|
||||
Lokasi aktif: {resolvedLocation.label}. GeoSeek kini mengambil data nyata dari backend `/public/places`,
|
||||
memakai GPS browser bila diizinkan, dan tetap punya fallback demo agar pencarian radius, produk, jasa, UMKM, peta, booking, kurir, dan insight bisnis selalu bisa dicoba.
|
||||
</p>
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
{quickPrompts.slice(0, 4).map((prompt) => (
|
||||
@ -550,16 +956,19 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) {
|
||||
<h3 className="mt-2 text-2xl font-bold text-gray-900 dark:text-white">{profileName}</h3>
|
||||
<div className="mt-4 space-y-3 text-sm text-gray-600 dark:text-gray-300">
|
||||
<div className="rounded-2xl bg-blue-50 p-4 dark:bg-blue-900/20">
|
||||
Radius aktif <strong>{radiusKm} km</strong> dari {defaultGeoSeekLocation.label}
|
||||
Radius aktif <strong>{radiusKm} km</strong> dari {resolvedLocation.label}
|
||||
<p className="mt-1 text-xs">{resolvedLocation.source === 'browser' ? 'Sumber: GPS browser' : 'Sumber: fallback backend demo'}{resolvedLocation.accuracyMeters ? ` • akurasi ±${resolvedLocation.accuracyMeters} m` : ''}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-emerald-50 p-4 dark:bg-emerald-900/20">
|
||||
{results.length} hasil masuk radius dan modul <strong>{activeModule.menuLabel}</strong>
|
||||
{results.length} hasil untuk modul <strong>{activeModule.menuLabel}</strong>
|
||||
<p className="mt-1 text-xs">{apiItems.length ? `${apiItems.length} item backend dinormalisasi` : 'Fallback demo lokal aktif'}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-purple-50 p-4 dark:bg-purple-900/20">
|
||||
Draft lokal tersimpan <strong>{publishedItems.length}</strong> item
|
||||
Kandidat backend <strong>{apiMeta.totalCandidates}</strong> • draft lokal <strong>{publishedItems.length}</strong>
|
||||
<p className="mt-1 text-xs">{apiMeta.radiusZoneLabel}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-orange-50 p-4 dark:bg-orange-900/20">
|
||||
Status: {actionStatus}
|
||||
Status: {isLoadingPlaces ? 'Memuat data backend GeoSeek...' : actionStatus}
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
@ -587,7 +996,7 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[1fr_220px_180px]">
|
||||
<div className="grid gap-4 xl:grid-cols-[1fr_180px_170px_170px]">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-bold text-gray-700 dark:text-gray-200">Pencarian otomatis</span>
|
||||
<input
|
||||
@ -612,13 +1021,43 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) {
|
||||
<div className="flex items-end">
|
||||
<BaseButton
|
||||
color="info"
|
||||
label="Jalankan"
|
||||
label={isLoadingPlaces ? 'Memuat' : 'Jalankan'}
|
||||
icon={icon.mdiMagnify}
|
||||
className="h-12 w-full"
|
||||
onClick={() => recordAction(`Pencarian “${query || 'semua data'}” berhasil diurutkan otomatis dengan GeoScore.`, 'Pencarian')}
|
||||
onClick={() => {
|
||||
setSearchRequestVersion((version) => version + 1);
|
||||
recordAction(`Pencarian “${query || getApiQuery(query, activeModule.key)}” dikirim ke backend GeoSeek dan diurutkan dengan GeoScore.`, 'Pencarian');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<BaseButton
|
||||
color="success"
|
||||
label={isResolvingLocation ? 'GPS...' : 'Gunakan GPS'}
|
||||
icon={icon.mdiCrosshairsGps}
|
||||
className="h-12 w-full"
|
||||
disabled={isResolvingLocation}
|
||||
onClick={requestCurrentLocation}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 text-sm md:grid-cols-3">
|
||||
<div className="rounded-2xl bg-slate-50 p-4 text-gray-600 dark:bg-dark-800 dark:text-gray-300">
|
||||
<strong className="text-gray-900 dark:text-white">Lokasi:</strong> {locationStatus}
|
||||
</div>
|
||||
<div className="rounded-2xl bg-slate-50 p-4 text-gray-600 dark:bg-dark-800 dark:text-gray-300">
|
||||
<strong className="text-gray-900 dark:text-white">Data:</strong> {isLoadingPlaces ? 'memuat backend...' : apiItems.length ? `${apiItems.length} item backend aktif` : 'fallback demo aktif'}
|
||||
</div>
|
||||
<div className="rounded-2xl bg-slate-50 p-4 text-gray-600 dark:bg-dark-800 dark:text-gray-300">
|
||||
<strong className="text-gray-900 dark:text-white">Zona:</strong> {apiMeta.radiusZoneLabel}
|
||||
</div>
|
||||
</div>
|
||||
{(apiError || apiMeta.externalSources.length > 0 || apiMeta.expandedForNearest) && (
|
||||
<div className="mt-4 rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800 dark:border-amber-900/50 dark:bg-amber-900/20 dark:text-amber-200">
|
||||
{apiError || `Sumber eksternal aktif: ${apiMeta.externalSources.join(', ')}`}
|
||||
{apiMeta.expandedForNearest ? ' Radius diperluas otomatis untuk mencari hasil terdekat.' : ''}
|
||||
</div>
|
||||
)}
|
||||
</CardBox>
|
||||
|
||||
<div className="mb-6 grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||
@ -626,7 +1065,7 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) {
|
||||
<StatCard label="Rata-rata GeoScore" value={insight.averageGeoScore} help="Dari modul aktif" iconPath={icon.mdiChartTimelineVariant} />
|
||||
<StatCard label="Promo Aktif" value={insight.promos} help="Siap disebar otomatis" iconPath={icon.mdiTicketPercentOutline} />
|
||||
<StatCard label="Booking/Kurir" value={insight.bookingReady + insight.deliveryReady} help="Aksi cepat tersedia" iconPath={icon.mdiCalendar} />
|
||||
<StatCard label="Draft Lokal" value={publishedItems.length} help="Tersimpan di browser" iconPath={icon.mdiStore} />
|
||||
<StatCard label="Data Backend" value={apiItems.length || 'Demo'} help={apiItems.length ? 'Places + offerings nyata' : 'Fallback lokal'} iconPath={icon.mdiDatabaseSearch} />
|
||||
</div>
|
||||
|
||||
<div className="mb-6 grid gap-6 xl:grid-cols-[1fr_0.9fr]">
|
||||
@ -769,7 +1208,7 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) {
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
style={getMapPosition(item, index)}
|
||||
style={getMapPosition(item, index, resolvedLocation)}
|
||||
className="absolute z-20 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white p-2 text-blue-600 shadow-lg ring-4 ring-blue-500/20 transition hover:scale-110 dark:bg-dark-800 dark:text-blue-200"
|
||||
title={item.name}
|
||||
onClick={() => recordAction(`Pin “${item.name}” dipilih. Rute demo ${item.distanceKm.toFixed(1)} km siap dibuka.`, 'Rute Pin')}
|
||||
@ -855,7 +1294,9 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) {
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-gray-900 dark:text-white">Hasil {activeModule.menuLabel}</h3>
|
||||
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300">{activeModule.emptyHint}</p>
|
||||
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
{activeModule.emptyHint} Sumber data: {hasBackendItemsForModule ? 'backend GeoSeek + draft lokal' : 'fallback demo lokal'}.
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-gray-100 px-4 py-2 text-sm font-bold text-gray-600 dark:bg-dark-800 dark:text-gray-300">
|
||||
{results.length} hasil
|
||||
@ -863,13 +1304,19 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
{results.length ? results.map((item) => (
|
||||
{isLoadingPlaces ? (
|
||||
<CardBox className="bg-white/90 text-center dark:bg-dark-900">
|
||||
<BaseIcon path={icon.mdiDatabaseSearch} size={42} className="mx-auto text-blue-500" />
|
||||
<h3 className="mt-4 text-xl font-bold text-gray-900 dark:text-white">Memuat data GeoSeek nyata...</h3>
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">Mengambil places dan offerings dari backend berdasarkan lokasi, radius, dan modul aktif.</p>
|
||||
</CardBox>
|
||||
) : results.length ? results.map((item) => (
|
||||
<ResultCard key={item.id} item={item} moduleKey={activeModule.key} onAction={(message) => recordAction(message, 'Aksi Item')} />
|
||||
)) : (
|
||||
<CardBox className="bg-white/90 text-center dark:bg-dark-900">
|
||||
<BaseIcon path={icon.mdiMagnify} size={42} className="mx-auto text-gray-400" />
|
||||
<h3 className="mt-4 text-xl font-bold text-gray-900 dark:text-white">Tidak ada hasil di radius ini</h3>
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">Coba ubah kata kunci atau perluas radius pencarian.</p>
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">Coba ubah kata kunci, izinkan GPS, atau perluas radius pencarian.</p>
|
||||
<BaseButton color="info" label="Perluas ke 10 km" className="mt-4" onClick={() => setRadiusKm(10)} />
|
||||
</CardBox>
|
||||
)}
|
||||
|
||||
@ -114,15 +114,17 @@ export const filterGeoSeekItems = ({
|
||||
includeTypes,
|
||||
moduleKey,
|
||||
extraItems = [],
|
||||
baseItems = geoSeekItems,
|
||||
}: {
|
||||
query: string;
|
||||
radiusKm: number;
|
||||
includeTypes: GeoSeekItemType[];
|
||||
moduleKey: string;
|
||||
extraItems?: GeoSeekItem[];
|
||||
baseItems?: GeoSeekItem[];
|
||||
}): GeoSeekScoredItem[] => {
|
||||
const normalizedQuery = normalize(query);
|
||||
const sourceItems = [...extraItems, ...geoSeekItems];
|
||||
const sourceItems = [...extraItems, ...baseItems];
|
||||
|
||||
return sourceItems
|
||||
.filter((item) => includeTypes.includes(item.type))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user