40275-vm/frontend/src/components/GeoSeek/GeoSeekProWorkspace.tsx
2026-06-18 10:42:13 +00:00

1626 lines
73 KiB
TypeScript

import * as icon from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import Link from 'next/link';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import BaseButton from '../BaseButton';
import BaseButtons from '../BaseButtons';
import BaseIcon from '../BaseIcon';
import CardBox from '../CardBox';
import SectionMain from '../SectionMain';
import SectionTitleLineWithButton from '../SectionTitleLineWithButton';
import { getPageTitle } from '../../config';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { aiResponse } from '../../stores/openAiSlice';
import {
defaultGeoSeekLocation,
GeoSeekItem,
GeoSeekItemType,
geoSeekModules,
GeoSeekModuleKey,
} from '../../data/geoseek';
import {
createCartFromResults,
createSmartDraft,
currency,
filterGeoSeekItems,
getBusinessInsights,
getCheckoutSummary,
getDistancePriority,
GeoSeekScoredItem,
GeoSeekSmartDraft,
} from '../../data/geoseekAutomation';
type Props = {
moduleKey?: string;
};
type GeoSeekActionHistory = {
id: string;
moduleKey: GeoSeekModuleKey;
label: string;
message: string;
createdAt: string;
};
type QuickAction = {
label: string;
message: string;
color: 'info' | 'success' | 'warning' | 'contrast';
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;
};
type AiLocationPlan = {
allowed: boolean;
keyword: string;
radiusKm: number;
reason: string;
categoryHint?: string;
safetyNote?: string;
};
const typeLabels: Record<GeoSeekItemType, string> = {
place: 'Tempat',
product: 'Produk',
service: 'Jasa',
business: 'UMKM',
culinary: 'Kuliner',
health: 'Kesehatan',
property: 'Properti',
automotive: 'Otomotif',
tourism: 'Wisata',
event: 'Event',
promo: 'Promo',
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 radiusOptions = [1, 5, 10, 20];
const aiLocationExamples = [
'Cari ATM terdekat dari lokasi saya',
'Saya di Bogor, cari apotek buka sekarang radius 5 km',
'Cari bengkel motor yang dekat dan masih buka',
'Temukan tempat makan murah yang bisa antar',
];
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 normalizeAiRadius = (value: unknown) => {
const number = Number(value);
if (!Number.isFinite(number)) return 5;
if (number <= 1) return 1;
if (number <= 5) return 5;
if (number <= 10) return 10;
return 20;
};
const getAiResponseText = (response: any): string => {
const payload = response?.data || response;
if (typeof payload === 'string') return payload;
if (!payload || typeof payload !== 'object') return '';
if (Array.isArray(payload.output)) {
return payload.output
.flatMap((item) => (Array.isArray(item?.content) ? item.content : []))
.filter((block) => block?.type === 'output_text' && typeof block.text === 'string')
.map((block) => block.text)
.join('')
.trim();
}
if (typeof payload.text === 'string') return payload.text;
if (typeof payload.message === 'string') return payload.message;
return '';
};
const parseAiLocationPlan = (text: string, fallbackKeyword: string): AiLocationPlan => {
const stripped = text
.trim()
.replace(/^```json/i, '')
.replace(/^```/i, '')
.replace(/```$/i, '')
.trim();
const jsonText = stripped.startsWith('{') ? stripped : stripped.match(/\{[\s\S]*\}/)?.[0];
if (!jsonText) {
throw new Error('AI tidak mengembalikan JSON rencana lokasi.');
}
const parsed = JSON.parse(jsonText);
const allowed = parsed.allowed !== false;
const keyword = String(parsed.keyword || fallbackKeyword || '').replace(/\s+/g, ' ').trim().slice(0, 90);
const reason = String(parsed.reason || 'AI membuat rencana pencarian berdasarkan kalimat Anda.').trim();
const safetyNote = parsed.safetyNote ? String(parsed.safetyNote).trim() : undefined;
const categoryHint = parsed.categoryHint ? String(parsed.categoryHint).trim() : undefined;
if (allowed && !keyword) {
throw new Error('AI tidak mengembalikan keyword pencarian.');
}
return {
allowed,
keyword,
radiusKm: normalizeAiRadius(parsed.radiusKm),
reason,
categoryHint,
safetyNote,
};
};
const buildLocalLocationPlan = (input: string, fallbackRadiusKm: number): AiLocationPlan => {
const normalized = normalizeTagText(input);
if (/lacak|melacak|stalking|stalk|lokasi real time|lokasi realtime|chat pribadi|akun private|akun privat|sadap|bajak/.test(normalized)) {
return {
allowed: false,
keyword: '',
radiusKm: normalizeAiRadius(fallbackRadiusKm),
reason: 'Permintaan mengarah ke pelacakan pribadi atau data non-publik.',
safetyNote: 'GeoSeek hanya mencari lokasi, bisnis, produk, jasa, dan informasi publik. Pelacakan orang pribadi atau akun private tidak didukung.',
};
}
const explicitRadius = input.match(/(\d+(?:[,.]\d+)?)\s*(?:km|kilometer)/i)?.[1];
const radiusKm = explicitRadius
? normalizeAiRadius(explicitRadius.replace(',', '.'))
: /terdekat|dekat|sekitar|jalan kaki/.test(normalized)
? 1
: normalizeAiRadius(fallbackRadiusKm);
const keywordRules: Array<{ pattern: RegExp; keyword: string; categoryHint: string }> = [
{ pattern: /\batm\b|bank|tunai|tarik uang|setor/, keyword: 'atm bank tunai', categoryHint: 'Finansial' },
{ pattern: /apotek|obat|klinik|dokter|vitamin|kesehatan/, keyword: 'apotek klinik obat', categoryHint: 'Kesehatan' },
{ pattern: /bengkel|tambal ban|ban|motor|mobil|otomotif|servis|service/, keyword: 'bengkel otomotif servis', categoryHint: 'Otomotif' },
{ pattern: /makan|makanan|kuliner|restoran|warung|cafe|kopi|nasi|murah/, keyword: 'kuliner makanan murah', categoryHint: 'Kuliner' },
{ pattern: /hotel|penginapan|wisata|travel|tour/, keyword: 'hotel wisata', categoryHint: 'Wisata' },
{ pattern: /kurir|antar|kirim|ongkir|delivery/, keyword: 'kurir antar lokal', categoryHint: 'Kurir' },
{ pattern: /pupuk|tani|pertanian|bibit|organik/, keyword: 'pupuk pertanian', categoryHint: 'Pertanian' },
{ pattern: /toko|minimarket|market|supermarket|produk|belanja/, keyword: 'toko minimarket produk', categoryHint: 'Toko' },
];
const matchedRule = keywordRules.find((rule) => rule.pattern.test(normalized));
const fallbackKeyword = normalized
.split(/\s+/)
.filter((word) => word.length > 2 && !['cari', 'saya', 'dari', 'yang', 'dan', 'atau', 'untuk', 'lokasi', 'radius', 'dekat', 'terdekat'].includes(word))
.slice(0, 6)
.join(' ')
|| input.trim();
return {
allowed: true,
keyword: matchedRule?.keyword || fallbackKeyword,
radiusKm,
reason: 'Fallback lokal membaca intent pencarian saat AI proxy belum tersedia.',
categoryHint: matchedRule?.categoryHint || 'Pencarian umum',
safetyNote: 'AI proxy belum tersedia, jadi GeoSeek memakai parser lokal sementara.',
};
};
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,
map: icon.mdiMapSearchOutline,
products: icon.mdiShoppingOutline,
services: icon.mdiBriefcaseSearchOutline,
umkm: icon.mdiStore,
culinary: icon.mdiFoodForkDrink,
health: icon.mdiHospitalBoxOutline,
property: icon.mdiHomeCityOutline,
automotive: icon.mdiCarWrench,
tourism: icon.mdiBeach,
events: icon.mdiCalendar,
promos: icon.mdiTicketPercentOutline,
marketplace: icon.mdiCart,
booking: icon.mdiCalendar,
courier: icon.mdiTruck,
'business-dashboard': icon.mdiFinance,
profile: icon.mdiAccountCircle,
};
const moduleOptions = geoSeekModules.map((module) => ({
href: module.key === 'home' ? '/geoseek' : `/geoseek/${module.key}`,
key: module.key,
label: module.menuLabel,
iconPath: moduleIconMap[module.key] || icon.mdiTable,
}));
const quickPrompts = [
'pupuk organik stok dekat pasar',
'nasi goreng promo antar',
'servis ac booking cepat',
'tambal ban buka sekarang',
'apotek vitamin 24 jam',
'gratis ongkir radius 3 km',
];
const defaultQueryByModule: Partial<Record<GeoSeekModuleKey, string>> = {
home: 'pupuk organik',
search: 'pupuk organik',
map: '',
products: '',
services: '',
umkm: '',
culinary: '',
health: '',
property: '',
automotive: '',
tourism: '',
events: '',
promos: '',
marketplace: '',
booking: '',
courier: '',
'business-dashboard': '',
profile: '',
};
const getDefaultQuery = (moduleKey: GeoSeekModuleKey) => defaultQueryByModule[moduleKey] ?? '';
const localPublishedItemsKey = 'geoseek-pro-published-items';
const localActionHistoryKey = 'geoseek-pro-action-history';
const getActionTimestamp = () => new Intl.DateTimeFormat('id-ID', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date());
const getDraftItemType = (category: string, moduleKey: GeoSeekModuleKey): GeoSeekItemType => {
const normalizedCategory = category.toLowerCase();
if (moduleKey === 'map') return 'business';
if (moduleKey === 'products' || /produk|pertanian|tani|stok/.test(normalizedCategory)) return 'product';
if (moduleKey === 'culinary' || /kuliner|makan|minum|warung/.test(normalizedCategory)) return 'culinary';
if (moduleKey === 'health' || /kesehatan|obat|klinik|apotek/.test(normalizedCategory)) return 'health';
if (moduleKey === 'property' || /properti|ruko|rumah|tanah|kios/.test(normalizedCategory)) return 'property';
if (moduleKey === 'automotive' || /otomotif|bengkel|mobil|motor/.test(normalizedCategory)) return 'automotive';
if (moduleKey === 'tourism' || /wisata/.test(normalizedCategory)) return 'tourism';
if (moduleKey === 'events' || /event|bazar|agenda/.test(normalizedCategory)) return 'event';
if (moduleKey === 'promos' || /promo|diskon|voucher/.test(normalizedCategory)) return 'promo';
if (moduleKey === 'courier' || /kurir|antar|kirim|ongkir/.test(normalizedCategory)) return 'courier';
if (moduleKey === 'services' || moduleKey === 'booking' || /jasa|servis|booking/.test(normalizedCategory)) return 'service';
return 'business';
};
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);
const supportsDelivery = ['product', 'culinary', 'promo', 'courier'].includes(type);
return {
id: `gsk-local-${Date.now()}`,
type,
name: draft.itemName || draft.businessName,
businessName: draft.businessName,
category: draft.category,
description: `Item lokal dari Smart Input: ${input}`,
address: draft.location,
area: draft.location,
latitude: origin.latitude + distanceKm / 1000,
longitude: origin.longitude + distanceKm / 900,
distanceKm,
price: draft.price,
stock: draft.stock,
rating: 4.8,
reviews: 1,
activityScore: 95,
tags: Array.from(new Set([...draft.tags, 'smart-input', 'lokal'])),
open: draft.status !== 'Perlu review manual',
promo: draft.promo.startsWith('Promo aktif') ? draft.promo : undefined,
etaMinutes: supportsBooking || type === 'courier' ? 18 : undefined,
bookingAvailable: supportsBooking,
deliveryAvailable: supportsDelivery,
};
};
const sampleSmartInputs = [
'Warung Sari Rasa promo nasi goreng Rp15000 stok 42 dekat Alun-Alun buka sampai malam',
'Toko Tani Makmur menjual pupuk organik Rp38000 stok 64 dekat Pasar Tani gratis antar radius 2 km',
'Jaya Teknik buka jasa servis AC panggilan Rp85000 area Melati booking hari ini',
];
const getModule = (moduleKey?: string) => {
const matchedModule = geoSeekModules.find((module) => module.key === moduleKey);
return matchedModule || geoSeekModules[0];
};
const getActionSet = (moduleKey: GeoSeekModuleKey): QuickAction[] => {
const baseActions: QuickAction[] = [
{
label: 'Hitung GeoScore',
message: 'GeoScore otomatis dihitung ulang dari jarak, relevansi, rating, dan aktivitas.',
color: 'info',
iconPath: icon.mdiChartTimelineVariant,
},
{
label: 'Buat Rute',
message: 'Rute demo disiapkan dari lokasi pengguna ke hasil prioritas terdekat.',
color: 'success',
iconPath: icon.mdiMapMarker,
},
];
const moduleActions: Partial<Record<GeoSeekModuleKey, QuickAction[]>> = {
products: [
{
label: 'Auto Restock',
message: 'Sistem menandai produk stok rendah dan membuat rekomendasi restock.',
color: 'warning',
iconPath: icon.mdiPackageVariantClosed,
},
],
services: [
{
label: 'Booking Jasa',
message: 'Draft booking jasa dibuat dengan ETA dan penyedia terdekat.',
color: 'success',
iconPath: icon.mdiCalendar,
},
],
marketplace: [
{
label: 'Checkout Otomatis',
message: 'Keranjang, ongkir, diskon, dan metode bayar simulasi berhasil disiapkan.',
color: 'success',
iconPath: icon.mdiCart,
},
],
booking: [
{
label: 'Buat Jadwal',
message: 'Slot booking demo dibuat berdasarkan kategori, jarak, dan status buka.',
color: 'success',
iconPath: icon.mdiClockOutline,
},
],
courier: [
{
label: 'Dispatch Kurir',
message: 'Kurir terdekat dipilih otomatis dengan ETA dan estimasi ongkir.',
color: 'success',
iconPath: icon.mdiTruck,
},
],
promos: [
{
label: 'Sebar Promo',
message: 'Promo siap dikirim ke pengguna dalam radius yang dipilih.',
color: 'warning',
iconPath: icon.mdiTicketPercentOutline,
},
],
'business-dashboard': [
{
label: 'Buat Insight',
message: 'Insight stok, promo, booking, dan peluang bisnis berhasil diperbarui.',
color: 'contrast',
iconPath: icon.mdiFinance,
},
],
};
return [...baseActions, ...(moduleActions[moduleKey] || [])];
};
const getItemActionLabel = (moduleKey: GeoSeekModuleKey, item: GeoSeekScoredItem) => {
if (moduleKey === 'marketplace' || item.type === 'product' || item.type === 'culinary') return 'Tambah ke Keranjang';
if (moduleKey === 'booking' || item.bookingAvailable) return 'Booking Otomatis';
if (moduleKey === 'courier' || item.type === 'courier') return 'Pilih Kurir';
if (moduleKey === 'map') return 'Buka Rute';
if (moduleKey === 'promos' || item.type === 'promo') return 'Klaim Promo';
return 'Aktifkan Otomasi';
};
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}%` };
};
const StatCard = ({ label, value, help, iconPath }: { label: string; value: string | number; help: string; iconPath: string }) => (
<CardBox className="bg-white/80 dark:bg-dark-900">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">{label}</p>
<p className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">{value}</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">{help}</p>
</div>
<div className="rounded-2xl bg-blue-50 p-3 text-blue-600 dark:bg-blue-900/30 dark:text-blue-200">
<BaseIcon path={iconPath} size={24} />
</div>
</div>
</CardBox>
);
const ResultCard = ({
item,
moduleKey,
onAction,
}: {
item: GeoSeekScoredItem;
moduleKey: GeoSeekModuleKey;
onAction: (message: string) => void;
}) => (
<CardBox isHoverable className="border border-gray-100 bg-white/90 dark:border-dark-700 dark:bg-dark-900">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 flex-1">
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="rounded-full bg-blue-100 px-3 py-1 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
{typeLabels[item.type]}
</span>
<span className="rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200">
GeoScore {item.geoScore}
</span>
<span className="rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-700 dark:bg-amber-900/40 dark:text-amber-200">
{item.distanceKm.toFixed(1)} km {item.distancePriority.label}
</span>
{item.open && (
<span className="rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-700 dark:bg-green-900/40 dark:text-green-200">
Buka
</span>
)}
</div>
<h3 className="text-xl font-bold text-gray-900 dark:text-white">{item.name}</h3>
<p className="mt-1 text-sm font-medium text-gray-600 dark:text-gray-300">{item.businessName} {item.category}</p>
<p className="mt-3 text-sm leading-6 text-gray-600 dark:text-gray-300">{item.description}</p>
<div className="mt-4 grid gap-3 text-sm text-gray-600 dark:text-gray-300 md:grid-cols-2">
<div className="rounded-xl bg-gray-50 p-3 dark:bg-dark-800">
<span className="font-semibold text-gray-900 dark:text-white">Lokasi:</span> {item.address}, {item.area}
</div>
<div className="rounded-xl bg-gray-50 p-3 dark:bg-dark-800">
<span className="font-semibold text-gray-900 dark:text-white">Rating:</span> {item.rating.toFixed(1)} / 5 {item.reviews} ulasan
</div>
<div className="rounded-xl bg-gray-50 p-3 dark:bg-dark-800">
<span className="font-semibold text-gray-900 dark:text-white">Harga:</span> {currency(item.price)}
</div>
<div className="rounded-xl bg-gray-50 p-3 dark:bg-dark-800">
<span className="font-semibold text-gray-900 dark:text-white">Stok/ETA:</span>{' '}
{typeof item.stock === 'number' ? `${item.stock} tersedia` : item.etaMinutes ? `${item.etaMinutes} menit` : 'Tersedia sesuai permintaan'}
</div>
</div>
{item.promo && (
<div className="mt-4 rounded-2xl border border-orange-200 bg-orange-50 p-3 text-sm font-medium text-orange-700 dark:border-orange-900/60 dark:bg-orange-900/20 dark:text-orange-200">
{item.promo}
</div>
)}
<div className="mt-4 flex flex-wrap gap-2">
{item.tags.slice(0, 6).map((tag) => (
<span key={tag} className="rounded-full border border-gray-200 px-3 py-1 text-xs text-gray-500 dark:border-dark-700 dark:text-gray-300">
#{tag}
</span>
))}
</div>
</div>
<div className="w-full rounded-2xl bg-slate-50 p-4 dark:bg-dark-800 lg:w-72">
<p className="text-sm font-bold text-gray-900 dark:text-white">Rincian GeoScore</p>
<div className="mt-3 space-y-2 text-xs text-gray-600 dark:text-gray-300">
<div className="flex justify-between gap-3"><span>Prioritas jarak</span><span className="text-right">{item.distancePriority.label}</span></div>
<div className="flex justify-between"><span>Jarak 60%</span><span>{item.distanceScore}</span></div>
<div className="flex justify-between"><span>Relevansi 20%</span><span>{item.relevanceScore}</span></div>
<div className="flex justify-between"><span>Rating 10%</span><span>{item.ratingScore}</span></div>
<div className="flex justify-between"><span>Aktivitas 10%</span><span>{item.activityContribution}</span></div>
</div>
<div className="mt-4 space-y-2">
{item.automationNotes.map((note) => (
<div key={note} className="rounded-xl bg-white px-3 py-2 text-xs text-gray-600 shadow-sm dark:bg-dark-900 dark:text-gray-300">
{note}
</div>
))}
</div>
<BaseButton
color="info"
label={getItemActionLabel(moduleKey, item)}
icon={icon.mdiRobot}
className="mt-4 w-full"
onClick={() => onAction(`${getItemActionLabel(moduleKey, item)} untuk “${item.name}” berhasil dibuat sebagai simulasi otomatis.`)}
/>
</div>
</div>
</CardBox>
);
export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) {
const normalizedModuleKey = getModule(moduleKey).key;
const activeModule = getModule(normalizedModuleKey);
const [query, setQuery] = useState(getDefaultQuery(activeModule.key));
const [radiusKm, setRadiusKm] = useState(5);
const [aiLocationInput, setAiLocationInput] = useState(aiLocationExamples[0]);
const [aiLocationPlan, setAiLocationPlan] = useState<AiLocationPlan | null>(null);
const [aiLocationStatus, setAiLocationStatus] = useState('AI siap menerjemahkan kalimat bebas menjadi keyword dan radius pencarian GeoSeek.');
const [isAiLocationLoading, setIsAiLocationLoading] = useState(false);
const [smartInput, setSmartInput] = useState(sampleSmartInputs[0]);
const [actionStatus, setActionStatus] = useState('Sistem otomasi siap digunakan. Pilih aksi cepat atau jalankan Smart Input.');
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 dispatch = useAppDispatch();
const { currentUser } = useAppSelector((state) => state.auth);
const activeDistancePriority = useMemo(() => getDistancePriority(radiusKm), [radiusKm]);
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: liveExtraItems,
baseItems: hasBackendItemsForModule ? [] : undefined,
}),
[activeModule.includeTypes, activeModule.key, hasBackendItemsForModule, liveExtraItems, query, radiusKm],
);
const allModuleResults = useMemo(
() => filterGeoSeekItems({
query: '',
radiusKm: 20,
includeTypes: activeModule.includeTypes,
moduleKey: activeModule.key,
extraItems: liveExtraItems,
baseItems: hasBackendItemsForModule ? [] : undefined,
}),
[activeModule.includeTypes, activeModule.key, hasBackendItemsForModule, liveExtraItems],
);
const smartDraft = useMemo(() => createSmartDraft(smartInput), [smartInput]);
const insight = useMemo(() => getBusinessInsights(allModuleResults), [allModuleResults]);
const cartItems = useMemo(() => createCartFromResults(results), [results]);
const checkout = useMemo(() => getCheckoutSummary(cartItems), [cartItems]);
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) => [
{
id: `gsk-action-${Date.now()}`,
moduleKey: activeModule.key,
label,
message,
createdAt: getActionTimestamp(),
},
...previousHistory,
].slice(0, 8));
};
const publishSmartDraft = () => {
const newItem = createPublishedItem(smartInput, smartDraft, activeModule.key, resolvedLocation);
setPublishedItems((previousItems) => [
newItem,
...previousItems.filter((item) => item.name.toLowerCase() !== newItem.name.toLowerCase()),
].slice(0, 20));
setQuery('');
setRadiusKm((currentRadius) => Math.max(currentRadius, Math.ceil(newItem.distanceKm)));
recordAction(`Draft “${newItem.name}” dipublikasikan sebagai ${typeLabels[newItem.type]} lokal dan langsung masuk hasil GeoSeek.`, 'Publikasi Smart Input');
};
const runAiLocationFinder = async () => {
const trimmedInput = aiLocationInput.trim();
if (!trimmedInput) {
setAiLocationStatus('Tulis dulu kebutuhan lokasi Anda, misalnya “cari ATM terdekat”.');
return;
}
setIsAiLocationLoading(true);
setAiLocationStatus('AI sedang memahami kebutuhan lokasi dan menyiapkan keyword pencarian...');
const payload = {
input: [
{
role: 'system',
content: [
'Anda adalah AI Pencari Lokasi untuk GeoSeek.',
'Ubah kalimat bebas pengguna menjadi rencana pencarian tempat, produk, jasa, atau bisnis publik.',
'Jangan bantu melacak orang pribadi, akun private, chat WhatsApp pribadi, lokasi real-time personal, atau data non-publik.',
'Jika permintaan tidak aman atau bersifat pelacakan pribadi, balas JSON dengan allowed false dan safetyNote singkat.',
'Balas hanya JSON valid tanpa markdown.',
'Format: {"allowed":true,"keyword":"keyword 2-6 kata","radiusKm":5,"reason":"alasan singkat","categoryHint":"kategori opsional","safetyNote":"catatan opsional"}.',
'Pilih radiusKm hanya salah satu dari 1, 5, 10, atau 20.',
'Untuk permintaan ATM gunakan keyword yang jelas seperti "atm bank tunai".',
].join(' '),
},
{
role: 'user',
content: [
`Permintaan pengguna: ${trimmedInput}`,
`Modul GeoSeek aktif: ${activeModule.menuLabel}`,
`Lokasi aktif: ${resolvedLocation.label}`,
`Radius saat ini: ${radiusKm} km`,
'Buat rencana yang bisa dipakai untuk endpoint /public/places.',
].join('\n'),
},
],
options: { poll_interval: 3, poll_timeout: 120 },
};
try {
const response = await dispatch(aiResponse(payload)).unwrap();
const responseText = getAiResponseText(response);
const plan = parseAiLocationPlan(responseText, trimmedInput);
setAiLocationPlan(plan);
if (!plan.allowed) {
const safetyMessage = plan.safetyNote || 'GeoSeek hanya mendukung pencarian tempat, bisnis, produk, dan data publik.';
setAiLocationStatus(safetyMessage);
recordAction(`AI menolak permintaan karena alasan keamanan: ${safetyMessage}`, 'AI Pencari Lokasi');
return;
}
setQuery(plan.keyword);
setRadiusKm(plan.radiusKm);
setSearchRequestVersion((version) => version + 1);
setAiLocationStatus(`AI menjalankan pencarian “${plan.keyword}” radius ${plan.radiusKm} km. ${plan.reason}`);
recordAction(`AI menerjemahkan “${trimmedInput}” menjadi pencarian “${plan.keyword}” radius ${plan.radiusKm} km.`, 'AI Pencari Lokasi');
} catch (error) {
console.error('AI Pencari Lokasi gagal, memakai fallback lokal:', { error, input: trimmedInput, payload });
const fallbackPlan = buildLocalLocationPlan(trimmedInput, radiusKm);
setAiLocationPlan(fallbackPlan);
if (!fallbackPlan.allowed) {
const safetyMessage = fallbackPlan.safetyNote || 'GeoSeek hanya mendukung pencarian tempat, bisnis, produk, jasa, dan data publik.';
setAiLocationStatus(safetyMessage);
recordAction(`AI/fallback menolak permintaan karena alasan keamanan: ${safetyMessage}`, 'AI Pencari Lokasi');
} else {
setQuery(fallbackPlan.keyword);
setRadiusKm(fallbackPlan.radiusKm);
setSearchRequestVersion((version) => version + 1);
setAiLocationStatus(`AI proxy belum tersedia, fallback lokal menjalankan “${fallbackPlan.keyword}” radius ${fallbackPlan.radiusKm} km. ${fallbackPlan.reason}`);
recordAction(`Fallback AI menerjemahkan “${trimmedInput}” menjadi pencarian “${fallbackPlan.keyword}” radius ${fallbackPlan.radiusKm} km.`, 'AI Pencari Lokasi');
}
} finally {
setIsAiLocationLoading(false);
}
};
useEffect(() => {
try {
const storedItems = window.localStorage.getItem(localPublishedItemsKey);
const storedHistory = window.localStorage.getItem(localActionHistoryKey);
if (storedItems) {
const parsedItems = JSON.parse(storedItems);
if (Array.isArray(parsedItems)) {
setPublishedItems(parsedItems);
} else {
console.error('Data item lokal GeoSeek bukan array:', parsedItems);
setActionStatus('Data item lokal tidak valid, data demo tetap digunakan.');
}
}
if (storedHistory) {
const parsedHistory = JSON.parse(storedHistory);
if (Array.isArray(parsedHistory)) {
setActionHistory(parsedHistory);
} else {
console.error('Data riwayat GeoSeek bukan array:', parsedHistory);
setActionStatus('Data riwayat lokal tidak valid, riwayat baru tetap bisa dibuat.');
}
}
} catch (error) {
console.error('Gagal memuat data lokal GeoSeek:', error);
setActionStatus('Gagal memuat data lokal GeoSeek. Data demo tetap bisa digunakan.');
} finally {
setIsLocalStateReady(true);
}
}, []);
useEffect(() => {
if (!isLocalStateReady) return;
try {
window.localStorage.setItem(localPublishedItemsKey, JSON.stringify(publishedItems));
window.localStorage.setItem(localActionHistoryKey, JSON.stringify(actionHistory));
} catch (error) {
console.error('Gagal menyimpan data lokal GeoSeek:', error);
setActionStatus('Gagal menyimpan data lokal GeoSeek di browser.');
}
}, [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(5);
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';
return (
<>
<Head>
<title>{getPageTitle(activeModule.title)}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={moduleIconMap[activeModule.key] || icon.mdiViewDashboardOutline} title={activeModule.title} main>
<BaseButton href="/dashboard" color="whiteDark" label="Dashboard" icon={icon.mdiViewDashboardOutline} />
</SectionTitleLineWithButton>
<div className="mb-6 grid gap-4 lg:grid-cols-[1.35fr_0.65fr]">
<CardBox className="overflow-hidden bg-gradient-to-br from-blue-600 via-cyan-600 to-emerald-500 text-white">
<div className="relative">
<div className="absolute -right-10 -top-10 h-36 w-36 rounded-full bg-white/10 blur-2xl" />
<div className="absolute -bottom-12 left-20 h-40 w-40 rounded-full bg-lime-200/10 blur-2xl" />
<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 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) => (
<button
key={prompt}
type="button"
className="rounded-full bg-white/15 px-4 py-2 text-sm font-semibold text-white backdrop-blur transition hover:bg-white/25"
onClick={() => setQuery(prompt)}
>
{prompt}
</button>
))}
</div>
</div>
</CardBox>
<CardBox className="bg-white/90 dark:bg-dark-900">
<p className="text-sm text-gray-500 dark:text-gray-400">Profil aktif</p>
<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> ({activeDistancePriority.label}) 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 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">
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: {isLoadingPlaces ? 'Memuat data backend GeoSeek...' : actionStatus}
</div>
</div>
</CardBox>
</div>
<CardBox className="mb-6 bg-white/90 dark:bg-dark-900">
<div className="mb-4 flex flex-wrap gap-2">
{moduleOptions.map((option) => {
const isActive = option.key === activeModule.key;
return (
<Link
key={option.key}
href={option.href}
className={`inline-flex items-center gap-2 rounded-full border px-3 py-2 text-sm font-semibold transition ${
isActive
? 'border-blue-600 bg-blue-600 text-white shadow-lg shadow-blue-600/20'
: 'border-gray-200 bg-white text-gray-600 hover:border-blue-300 hover:text-blue-600 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-300'
}`}
>
<BaseIcon path={option.iconPath} size={16} />
{option.label}
</Link>
);
})}
</div>
<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
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={activeModule.searchPlaceholder}
className="h-12 w-full rounded-2xl border border-gray-200 bg-white px-4 text-gray-900 outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-200 dark:border-dark-700 dark:bg-dark-800 dark:text-white"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-bold text-gray-700 dark:text-gray-200">Radius</span>
<select
value={radiusKm}
onChange={(event) => setRadiusKm(Number(event.target.value))}
className="h-12 w-full rounded-2xl border border-gray-200 bg-white px-4 text-gray-900 outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-200 dark:border-dark-700 dark:bg-dark-800 dark:text-white"
>
{radiusOptions.map((radius) => {
const priority = getDistancePriority(radius);
return (
<option key={radius} value={radius}>
{radius} km {priority.range} {priority.label}
</option>
);
})}
</select>
</label>
<div className="flex items-end">
<BaseButton
color="info"
label={isLoadingPlaces ? 'Memuat' : 'Jalankan'}
icon={icon.mdiMagnify}
className="h-12 w-full"
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>
<CardBox className="mb-6 overflow-hidden border border-blue-100 bg-gradient-to-br from-white via-blue-50 to-emerald-50 dark:border-blue-900/40 dark:from-dark-900 dark:via-blue-950/30 dark:to-emerald-950/20">
<div className="mb-4 flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-sm font-bold uppercase tracking-[0.25em] text-blue-600 dark:text-blue-300">AI Pencari Lokasi</p>
<h3 className="mt-2 text-2xl font-black text-gray-900 dark:text-white">Cari tempat dengan bahasa bebas</h3>
<p className="mt-2 max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300">
Tulis kebutuhan seperti cari ATM terdekat atau apotek buka sekarang. AI akan mengubahnya menjadi keyword dan radius, lalu GeoSeek menjalankan pencarian lokasi publik.
</p>
</div>
<div className="rounded-2xl bg-blue-600 p-3 text-white shadow-lg shadow-blue-600/20">
<BaseIcon path={icon.mdiRobot} size={28} />
</div>
</div>
<div className="grid gap-4 lg:grid-cols-[1fr_260px]">
<label className="block">
<span className="mb-2 block text-sm font-bold text-gray-700 dark:text-gray-200">Perintah AI</span>
<textarea
value={aiLocationInput}
onChange={(event) => setAiLocationInput(event.target.value)}
placeholder="Contoh: cari ATM terdekat dari lokasi saya"
className="h-28 w-full rounded-2xl border border-blue-100 bg-white p-4 text-sm text-gray-900 outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-200 dark:border-dark-700 dark:bg-dark-800 dark:text-white"
/>
</label>
<div className="flex flex-col justify-end gap-3">
<BaseButton
color="info"
label={isAiLocationLoading ? 'AI memproses...' : 'Cari dengan AI'}
icon={icon.mdiMapSearchOutline}
className="h-12 w-full"
disabled={isAiLocationLoading}
onClick={() => {
runAiLocationFinder();
}}
/>
<p className="rounded-2xl bg-white/80 p-3 text-xs leading-5 text-gray-600 shadow-sm dark:bg-dark-800 dark:text-gray-300">
Aman: AI hanya mencari lokasi/tempat/bisnis publik, bukan melacak orang pribadi, chat, atau akun private.
</p>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{aiLocationExamples.map((example) => (
<button
key={example}
type="button"
className="rounded-full bg-white px-3 py-1.5 text-xs font-semibold text-gray-600 shadow-sm transition hover:bg-blue-600 hover:text-white dark:bg-dark-800 dark:text-gray-300"
onClick={() => setAiLocationInput(example)}
>
{example}
</button>
))}
</div>
<div className="mt-4 grid gap-3 text-sm md:grid-cols-3">
<div className="rounded-2xl bg-white/80 p-4 text-gray-600 shadow-sm dark:bg-dark-800 dark:text-gray-300 md:col-span-2">
<strong className="text-gray-900 dark:text-white">Status AI:</strong> {aiLocationStatus}
</div>
<div className="rounded-2xl bg-white/80 p-4 text-gray-600 shadow-sm dark:bg-dark-800 dark:text-gray-300">
<strong className="text-gray-900 dark:text-white">Rencana:</strong>{' '}
{aiLocationPlan?.allowed
? `${aiLocationPlan.keyword}${aiLocationPlan.radiusKm} km`
: aiLocationPlan?.safetyNote || 'Belum ada rencana AI'}
</div>
</div>
</CardBox>
<div className="mb-6 grid gap-4 md:grid-cols-2 xl:grid-cols-5">
<StatCard label="Hasil Prioritas" value={results.length} help="Diurutkan dari jarak terdekat" iconPath={icon.mdiMagnify} />
<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="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]">
<CardBox className="bg-white/90 dark:bg-dark-900">
<div className="mb-4 flex items-start justify-between gap-4">
<div>
<h3 className="text-xl font-bold text-gray-900 dark:text-white">{activeModule.automationTitle}</h3>
<p className="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-300">{activeModule.automationDescription}</p>
</div>
<div className="rounded-2xl bg-blue-50 p-3 text-blue-600 dark:bg-blue-900/30 dark:text-blue-200">
<BaseIcon path={icon.mdiRobot} size={24} />
</div>
</div>
<BaseButtons type="justify-start" mb="mb-0" className="gap-2" classAddon="mb-2 mr-2">
{actionSet.map((action) => (
<BaseButton
key={action.label}
color={action.color}
label={action.label}
icon={action.iconPath}
onClick={() => recordAction(action.message, action.label)}
/>
))}
</BaseButtons>
<div className="mt-4 rounded-2xl bg-slate-50 p-4 text-sm text-gray-600 dark:bg-dark-800 dark:text-gray-300">
<strong className="text-gray-900 dark:text-white">Status otomasi:</strong> {actionStatus}
</div>
<div className="mt-4 rounded-2xl border border-gray-200 p-4 dark:border-dark-700">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<p className="text-sm font-bold text-gray-900 dark:text-white">Riwayat aksi lokal</p>
<p className="text-xs text-gray-500 dark:text-gray-400">Order, booking, dispatch, dan publikasi tersimpan di browser.</p>
</div>
<BaseButton
color="whiteDark"
label="Reset"
icon={icon.mdiDeleteOutline}
small
disabled={!actionHistory.length && !publishedItems.length}
onClick={() => {
setActionHistory([]);
setPublishedItems([]);
setActionStatus('Data lokal GeoSeek dibersihkan. Data demo bawaan tetap tersedia.');
}}
/>
</div>
<div className="space-y-2">
{actionHistory.length ? actionHistory.slice(0, 4).map((history) => (
<div key={history.id} className="rounded-xl bg-gray-50 p-3 text-xs text-gray-600 dark:bg-dark-800 dark:text-gray-300">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-bold text-gray-900 dark:text-white">{history.label}</span>
<span>{history.createdAt}</span>
</div>
<p className="mt-1">{history.message}</p>
</div>
)) : (
<p className="rounded-xl bg-gray-50 p-3 text-xs text-gray-500 dark:bg-dark-800 dark:text-gray-400">
Belum ada aksi. Jalankan pencarian, publish draft, checkout, booking, atau kurir.
</p>
)}
</div>
</div>
</CardBox>
<CardBox className="bg-white/90 dark:bg-dark-900">
<div className="mb-4 flex items-start justify-between gap-4">
<div>
<h3 className="text-xl font-bold text-gray-900 dark:text-white">Smart Input / Auto Input</h3>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300">Tulis laporan bebas, sistem membuat draft data terstruktur.</p>
</div>
<BaseIcon path={icon.mdiRobot} size={28} className="text-emerald-500" />
</div>
<textarea
value={smartInput}
onChange={(event) => setSmartInput(event.target.value)}
className="h-28 w-full rounded-2xl border border-gray-200 bg-white p-4 text-sm text-gray-900 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-200 dark:border-dark-700 dark:bg-dark-800 dark:text-white"
/>
<div className="mt-3 flex flex-wrap gap-2">
{sampleSmartInputs.map((sample, index) => (
<button
key={sample}
type="button"
className="rounded-full bg-gray-100 px-3 py-1.5 text-xs font-semibold text-gray-600 hover:bg-emerald-100 hover:text-emerald-700 dark:bg-dark-800 dark:text-gray-300"
onClick={() => setSmartInput(sample)}
>
Contoh {index + 1}
</button>
))}
</div>
<div className="mt-4 grid gap-3 text-sm md:grid-cols-2">
<div className="rounded-2xl bg-emerald-50 p-4 dark:bg-emerald-900/20">
<p className="text-xs font-bold uppercase tracking-wider text-emerald-700 dark:text-emerald-200">Bisnis</p>
<p className="mt-1 font-semibold text-gray-900 dark:text-white">{smartDraft.businessName}</p>
</div>
<div className="rounded-2xl bg-blue-50 p-4 dark:bg-blue-900/20">
<p className="text-xs font-bold uppercase tracking-wider text-blue-700 dark:text-blue-200">Item</p>
<p className="mt-1 font-semibold text-gray-900 dark:text-white">{smartDraft.itemName}</p>
</div>
<div className="rounded-2xl bg-purple-50 p-4 dark:bg-purple-900/20">
<p className="text-xs font-bold uppercase tracking-wider text-purple-700 dark:text-purple-200">Kategori</p>
<p className="mt-1 font-semibold text-gray-900 dark:text-white">{smartDraft.category}</p>
</div>
<div className="rounded-2xl bg-orange-50 p-4 dark:bg-orange-900/20">
<p className="text-xs font-bold uppercase tracking-wider text-orange-700 dark:text-orange-200">Harga & Stok</p>
<p className="mt-1 font-semibold text-gray-900 dark:text-white">{currency(smartDraft.price)} {smartDraft.stock ?? 'stok belum diisi'}</p>
</div>
</div>
<div className="mt-4 rounded-2xl border border-gray-200 p-4 text-sm text-gray-600 dark:border-dark-700 dark:text-gray-300">
<p><strong>Lokasi:</strong> {smartDraft.location}</p>
<p><strong>Promo:</strong> {smartDraft.promo}</p>
<p><strong>Status:</strong> {smartDraft.status}</p>
<p><strong>Tag:</strong> {smartDraft.tags.map((tag) => `#${tag}`).join(' ')}</p>
</div>
<BaseButton
color="success"
label="Publikasikan Draft Simulasi"
icon={icon.mdiStore}
className="mt-4 w-full"
onClick={publishSmartDraft}
/>
</CardBox>
</div>
<div className="mb-6 grid gap-6 xl:grid-cols-[0.9fr_1.1fr]">
<CardBox className="bg-white/90 dark:bg-dark-900">
<div className="mb-4 flex items-center justify-between gap-4">
<div>
<h3 className="text-xl font-bold text-gray-900 dark:text-white">Peta Radius Simulasi</h3>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300">Pin otomatis dari hasil prioritas modul {activeModule.menuLabel}.</p>
</div>
<BaseIcon path={icon.mdiCrosshairsGps} size={28} className="text-blue-500" />
</div>
<div className="relative h-80 overflow-hidden rounded-3xl bg-gradient-to-br from-sky-100 via-emerald-100 to-lime-100 dark:from-slate-900 dark:via-cyan-950 dark:to-emerald-950">
<div className="absolute inset-6 rounded-full border-2 border-dashed border-blue-400/50" />
<div className="absolute left-1/2 top-1/2 z-10 flex -translate-x-1/2 -translate-y-1/2 items-center gap-2 rounded-full bg-blue-600 px-4 py-2 text-xs font-bold text-white shadow-xl">
<BaseIcon path={icon.mdiCrosshairsGps} size={16} />
Anda
</div>
{mapItems.map((item, index) => (
<button
key={item.id}
type="button"
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')}
>
<BaseIcon path={moduleIconMap[activeModule.key] || icon.mdiMapMarker} size={18} />
</button>
))}
</div>
<div className="mt-4 grid gap-2 text-sm text-gray-600 dark:text-gray-300">
{mapItems.slice(0, 4).map((item) => (
<div key={item.id} className="flex items-center justify-between rounded-2xl bg-gray-50 p-3 dark:bg-dark-800">
<span>{item.name}</span>
<span className="font-bold text-blue-600 dark:text-blue-300">{item.distanceKm.toFixed(1)} km</span>
</div>
))}
</div>
</CardBox>
<CardBox className="bg-white/90 dark:bg-dark-900">
<div className="mb-4 flex items-center justify-between gap-4">
<div>
<h3 className="text-xl font-bold text-gray-900 dark:text-white">Marketplace, Booking & Kurir</h3>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300">Simulasi transaksi lokal dengan keranjang, pembayaran, dan dispatch kurir.</p>
</div>
<BaseIcon path={icon.mdiCart} size={28} className="text-emerald-500" />
</div>
<div className="space-y-3">
{cartItems.length ? cartItems.map((item) => (
<div key={item.id} className="flex items-center justify-between gap-4 rounded-2xl bg-slate-50 p-4 dark:bg-dark-800">
<div>
<p className="font-semibold text-gray-900 dark:text-white">{item.name}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{item.businessName} Qty {item.quantity}</p>
</div>
<p className="font-bold text-gray-900 dark:text-white">{currency(item.price * item.quantity)}</p>
</div>
)) : (
<div className="rounded-2xl bg-slate-50 p-4 text-sm text-gray-500 dark:bg-dark-800 dark:text-gray-300">
Belum ada item berharga dalam hasil. Coba perluas radius atau ubah kata kunci.
</div>
)}
</div>
<div className="mt-4 rounded-2xl border border-gray-200 p-4 text-sm dark:border-dark-700">
<div className="flex justify-between"><span>Subtotal</span><strong>{currency(checkout.subtotal)}</strong></div>
<div className="mt-2 flex justify-between"><span>Ongkir lokal</span><strong>{currency(checkout.deliveryFee)}</strong></div>
<div className="mt-2 flex justify-between"><span>Biaya platform</span><strong>{currency(checkout.platformFee)}</strong></div>
<div className="mt-2 flex justify-between text-emerald-600"><span>Diskon otomatis</span><strong>-{currency(checkout.discount)}</strong></div>
<div className="mt-3 flex justify-between border-t border-gray-200 pt-3 text-lg dark:border-dark-700"><span>Total</span><strong>{currency(checkout.total)}</strong></div>
<p className="mt-3 text-xs text-gray-500 dark:text-gray-400">Metode bayar: {checkout.paymentMethods.join(', ')}</p>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-3">
<BaseButton color="info" label="QRIS" icon={icon.mdiCash} onClick={() => recordAction('Pembayaran QRIS simulasi siap dibuat.', 'QRIS')} />
<BaseButton color="success" label="Booking" icon={icon.mdiCalendar} onClick={() => recordAction('Draft booking otomatis berhasil dibuat.', 'Booking')} />
<BaseButton color="warning" label="Kurir" icon={icon.mdiTruck} onClick={() => recordAction('Kurir lokal terdekat berhasil dipilih.', 'Kurir')} />
</div>
</CardBox>
</div>
<CardBox className="mb-6 bg-white/90 dark:bg-dark-900">
<div className="mb-4 flex items-center justify-between gap-4">
<div>
<h3 className="text-xl font-bold text-gray-900 dark:text-white">Insight Dashboard Bisnis</h3>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300">Ringkasan otomatis untuk UMKM dan operator GeoSeek.</p>
</div>
<BaseIcon path={icon.mdiFinance} size={28} className="text-purple-500" />
</div>
<div className="grid gap-4 md:grid-cols-3 xl:grid-cols-6">
<div className="rounded-2xl bg-blue-50 p-4 dark:bg-blue-900/20"><p className="text-xs text-blue-700 dark:text-blue-200">Produk</p><p className="text-2xl font-bold">{insight.products}</p></div>
<div className="rounded-2xl bg-cyan-50 p-4 dark:bg-cyan-900/20"><p className="text-xs text-cyan-700 dark:text-cyan-200">Jasa</p><p className="text-2xl font-bold">{insight.services}</p></div>
<div className="rounded-2xl bg-orange-50 p-4 dark:bg-orange-900/20"><p className="text-xs text-orange-700 dark:text-orange-200">Promo</p><p className="text-2xl font-bold">{insight.promos}</p></div>
<div className="rounded-2xl bg-red-50 p-4 dark:bg-red-900/20"><p className="text-xs text-red-700 dark:text-red-200">Stok Rendah</p><p className="text-2xl font-bold">{insight.lowStock}</p></div>
<div className="rounded-2xl bg-emerald-50 p-4 dark:bg-emerald-900/20"><p className="text-xs text-emerald-700 dark:text-emerald-200">Booking</p><p className="text-2xl font-bold">{insight.bookingReady}</p></div>
<div className="rounded-2xl bg-lime-50 p-4 dark:bg-lime-900/20"><p className="text-xs text-lime-700 dark:text-lime-200">Kurir</p><p className="text-2xl font-bold">{insight.deliveryReady}</p></div>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{insight.recommendations.map((recommendation) => (
<div key={recommendation} className="rounded-2xl border border-gray-200 p-4 text-sm text-gray-600 dark:border-dark-700 dark:text-gray-300">
{recommendation}
</div>
))}
</div>
</CardBox>
<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} 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
</span>
</div>
<div className="space-y-5">
{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, izinkan GPS, atau perluas radius pencarian.</p>
<BaseButton color="info" label="Perluas ke 20 km" className="mt-4" onClick={() => setRadiusKm(20)} />
</CardBox>
)}
</div>
</SectionMain>
</>
);
}