Autosave: 20260618-001727

This commit is contained in:
Flatlogic Bot 2026-06-18 00:17:23 +00:00
parent 638119614f
commit 9fb349ab84
4 changed files with 2694 additions and 1394 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,8 @@
import * as icon from '@mdi/js'; import * as icon from '@mdi/js';
import axios from 'axios';
import Head from 'next/head'; import Head from 'next/head';
import Link from 'next/link'; 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 BaseButton from '../BaseButton';
import BaseButtons from '../BaseButtons'; import BaseButtons from '../BaseButtons';
import BaseIcon from '../BaseIcon'; import BaseIcon from '../BaseIcon';
@ -47,6 +48,89 @@ type QuickAction = {
iconPath: string; 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> = { const typeLabels: Record<GeoSeekItemType, string> = {
place: 'Tempat', place: 'Tempat',
product: 'Produk', product: 'Produk',
@ -62,6 +146,207 @@ const typeLabels: Record<GeoSeekItemType, string> = {
courier: 'Kurir', 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>> = { const moduleIconMap: Partial<Record<GeoSeekModuleKey, string>> = {
home: icon.mdiViewDashboardOutline, home: icon.mdiViewDashboardOutline,
search: icon.mdiMagnify, search: icon.mdiMagnify,
@ -148,7 +433,12 @@ const getDraftItemType = (category: string, moduleKey: GeoSeekModuleKey): GeoSee
return 'business'; 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 type = getDraftItemType(draft.category, moduleKey);
const distanceKm = Math.min(2.8, Number((0.7 + (draft.itemName.length % 12) / 10).toFixed(1))); 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); 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}`, description: `Item lokal dari Smart Input: ${input}`,
address: draft.location, address: draft.location,
area: draft.location, area: draft.location,
latitude: defaultGeoSeekLocation.latitude + distanceKm / 1000, latitude: origin.latitude + distanceKm / 1000,
longitude: defaultGeoSeekLocation.longitude + distanceKm / 900, longitude: origin.longitude + distanceKm / 900,
distanceKm, distanceKm,
price: draft.price, price: draft.price,
stock: draft.stock, stock: draft.stock,
@ -280,9 +570,9 @@ const getItemActionLabel = (moduleKey: GeoSeekModuleKey, item: GeoSeekScoredItem
return 'Aktifkan Otomasi'; return 'Aktifkan Otomasi';
}; };
const getMapPosition = (item: GeoSeekScoredItem, index: number) => { const getMapPosition = (item: GeoSeekScoredItem, index: number, origin: { latitude: number; longitude: number } = defaultGeoSeekLocation) => {
const left = Math.min(88, Math.max(8, 50 + (item.longitude - defaultGeoSeekLocation.longitude) * 1800 + index * 2)); 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 - defaultGeoSeekLocation.latitude) * -1800 + index * 3)); const top = Math.min(82, Math.max(10, 50 + (item.latitude - origin.latitude) * -1800 + index * 3));
return { left: `${left}%`, top: `${top}%` }; return { left: `${left}%`, top: `${top}%` };
}; };
@ -398,17 +688,33 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) {
const [publishedItems, setPublishedItems] = useState<GeoSeekItem[]>([]); const [publishedItems, setPublishedItems] = useState<GeoSeekItem[]>([]);
const [actionHistory, setActionHistory] = useState<GeoSeekActionHistory[]>([]); const [actionHistory, setActionHistory] = useState<GeoSeekActionHistory[]>([]);
const [isLocalStateReady, setIsLocalStateReady] = useState(false); 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 { 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( const results = useMemo(
() => filterGeoSeekItems({ () => filterGeoSeekItems({
query, query,
radiusKm, radiusKm,
includeTypes: activeModule.includeTypes, includeTypes: activeModule.includeTypes,
moduleKey: activeModule.key, 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( const allModuleResults = useMemo(
@ -417,9 +723,10 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) {
radiusKm: 10, radiusKm: 10,
includeTypes: activeModule.includeTypes, includeTypes: activeModule.includeTypes,
moduleKey: activeModule.key, 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]); const smartDraft = useMemo(() => createSmartDraft(smartInput), [smartInput]);
@ -429,6 +736,43 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) {
const actionSet = getActionSet(activeModule.key); const actionSet = getActionSet(activeModule.key);
const mapItems = (results.length ? results : allModuleResults).slice(0, 8); 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) => { const recordAction = (message: string, label = activeModule.menuLabel) => {
setActionStatus(message); setActionStatus(message);
setActionHistory((previousHistory) => [ setActionHistory((previousHistory) => [
@ -444,7 +788,7 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) {
}; };
const publishSmartDraft = () => { const publishSmartDraft = () => {
const newItem = createPublishedItem(smartInput, smartDraft, activeModule.key); const newItem = createPublishedItem(smartInput, smartDraft, activeModule.key, resolvedLocation);
setPublishedItems((previousItems) => [ setPublishedItems((previousItems) => [
newItem, newItem,
@ -501,10 +845,72 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) {
} }
}, [actionHistory, isLocalStateReady, publishedItems]); }, [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(() => { useEffect(() => {
setQuery(getDefaultQuery(activeModule.key)); setQuery(getDefaultQuery(activeModule.key));
setRadiusKm(3); 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]); }, [activeModule.key, activeModule.menuLabel]);
const profileName = [currentUser?.firstName, currentUser?.lastName].filter(Boolean).join(' ') || currentUser?.email || 'Pengguna GeoSeek'; 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> <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> <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"> <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, Lokasi aktif: {resolvedLocation.label}. GeoSeek kini mengambil data nyata dari backend `/public/places`,
draft input pintar, simulasi peta, marketplace, booking, kurir, dan insight bisnis. 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> </p>
<div className="mt-6 flex flex-wrap gap-3"> <div className="mt-6 flex flex-wrap gap-3">
{quickPrompts.slice(0, 4).map((prompt) => ( {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> <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="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"> <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>
<div className="rounded-2xl bg-emerald-50 p-4 dark:bg-emerald-900/20"> <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>
<div className="rounded-2xl bg-purple-50 p-4 dark:bg-purple-900/20"> <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>
<div className="rounded-2xl bg-orange-50 p-4 dark:bg-orange-900/20"> <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>
</div> </div>
</CardBox> </CardBox>
@ -587,7 +996,7 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) {
})} })}
</div> </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"> <label className="block">
<span className="mb-2 block text-sm font-bold text-gray-700 dark:text-gray-200">Pencarian otomatis</span> <span className="mb-2 block text-sm font-bold text-gray-700 dark:text-gray-200">Pencarian otomatis</span>
<input <input
@ -612,13 +1021,43 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) {
<div className="flex items-end"> <div className="flex items-end">
<BaseButton <BaseButton
color="info" color="info"
label="Jalankan" label={isLoadingPlaces ? 'Memuat' : 'Jalankan'}
icon={icon.mdiMagnify} icon={icon.mdiMagnify}
className="h-12 w-full" 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> </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> </CardBox>
<div className="mb-6 grid gap-4 md:grid-cols-2 xl:grid-cols-5"> <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="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="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="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>
<div className="mb-6 grid gap-6 xl:grid-cols-[1fr_0.9fr]"> <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 <button
key={item.id} key={item.id}
type="button" 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" 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} title={item.name}
onClick={() => recordAction(`Pin “${item.name}” dipilih. Rute demo ${item.distanceKm.toFixed(1)} km siap dibuka.`, 'Rute Pin')} 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 className="mb-4 flex items-center justify-between gap-4">
<div> <div>
<h3 className="text-2xl font-bold text-gray-900 dark:text-white">Hasil {activeModule.menuLabel}</h3> <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> </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"> <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 {results.length} hasil
@ -863,13 +1304,19 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) {
</div> </div>
<div className="space-y-5"> <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')} /> <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"> <CardBox className="bg-white/90 text-center dark:bg-dark-900">
<BaseIcon path={icon.mdiMagnify} size={42} className="mx-auto text-gray-400" /> <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> <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)} /> <BaseButton color="info" label="Perluas ke 10 km" className="mt-4" onClick={() => setRadiusKm(10)} />
</CardBox> </CardBox>
)} )}

View File

@ -114,15 +114,17 @@ export const filterGeoSeekItems = ({
includeTypes, includeTypes,
moduleKey, moduleKey,
extraItems = [], extraItems = [],
baseItems = geoSeekItems,
}: { }: {
query: string; query: string;
radiusKm: number; radiusKm: number;
includeTypes: GeoSeekItemType[]; includeTypes: GeoSeekItemType[];
moduleKey: string; moduleKey: string;
extraItems?: GeoSeekItem[]; extraItems?: GeoSeekItem[];
baseItems?: GeoSeekItem[];
}): GeoSeekScoredItem[] => { }): GeoSeekScoredItem[] => {
const normalizedQuery = normalize(query); const normalizedQuery = normalize(query);
const sourceItems = [...extraItems, ...geoSeekItems]; const sourceItems = [...extraItems, ...baseItems];
return sourceItems return sourceItems
.filter((item) => includeTypes.includes(item.type)) .filter((item) => includeTypes.includes(item.type))

File diff suppressed because it is too large Load Diff