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 = { 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> = { 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(); return rows.flatMap(publicPlaceToGeoSeekItems).filter((item) => { if (seen.has(item.id)) return false; seen.add(item.id); return true; }); }; const moduleIconMap: Partial> = { 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> = { 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> = { 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 }) => (

{label}

{value}

{help}

); const ResultCard = ({ item, moduleKey, onAction, }: { item: GeoSeekScoredItem; moduleKey: GeoSeekModuleKey; onAction: (message: string) => void; }) => (
{typeLabels[item.type]} GeoScore {item.geoScore} {item.distanceKm.toFixed(1)} km • {item.distancePriority.label} {item.open && ( Buka )}

{item.name}

{item.businessName} • {item.category}

{item.description}

Lokasi: {item.address}, {item.area}
Rating: {item.rating.toFixed(1)} / 5 • {item.reviews} ulasan
Harga: {currency(item.price)}
Stok/ETA:{' '} {typeof item.stock === 'number' ? `${item.stock} tersedia` : item.etaMinutes ? `${item.etaMinutes} menit` : 'Tersedia sesuai permintaan'}
{item.promo && (
{item.promo}
)}
{item.tags.slice(0, 6).map((tag) => ( #{tag} ))}

Rincian GeoScore

Prioritas jarak{item.distancePriority.label}
Jarak 60%{item.distanceScore}
Relevansi 20%{item.relevanceScore}
Rating 10%{item.ratingScore}
Aktivitas 10%{item.activityContribution}
{item.automationNotes.map((note) => (
✓ {note}
))}
onAction(`${getItemActionLabel(moduleKey, item)} untuk “${item.name}” berhasil dibuat sebagai simulasi otomatis.`)} />
); 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(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([]); const [actionHistory, setActionHistory] = useState([]); const [isLocalStateReady, setIsLocalStateReady] = useState(false); const [resolvedLocation, setResolvedLocation] = useState(backendDemoGeoSeekLocation); const [isResolvingLocation, setIsResolvingLocation] = useState(true); const [locationStatus, setLocationStatus] = useState('Mengambil lokasi browser untuk pencarian radius GeoSeek...'); const [apiItems, setApiItems] = useState([]); const [isLoadingPlaces, setIsLoadingPlaces] = useState(false); const [apiError, setApiError] = useState(''); const [apiMeta, setApiMeta] = useState(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('/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 ( <> {getPageTitle(activeModule.title)}

GeoSeek Pro Automation

{activeModule.subtitle}

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.

{quickPrompts.slice(0, 4).map((prompt) => ( ))}

Profil aktif

{profileName}

Radius aktif {radiusKm} km ({activeDistancePriority.label}) dari {resolvedLocation.label}

{resolvedLocation.source === 'browser' ? 'Sumber: GPS browser' : 'Sumber: fallback backend demo'}{resolvedLocation.accuracyMeters ? ` • akurasi ±${resolvedLocation.accuracyMeters} m` : ''}

{results.length} hasil untuk modul {activeModule.menuLabel}

{apiItems.length ? `${apiItems.length} item backend dinormalisasi` : 'Fallback demo lokal aktif'}

Kandidat backend {apiMeta.totalCandidates} • draft lokal {publishedItems.length}

{apiMeta.radiusZoneLabel}

Status: {isLoadingPlaces ? 'Memuat data backend GeoSeek...' : actionStatus}
{moduleOptions.map((option) => { const isActive = option.key === activeModule.key; return ( {option.label} ); })}
{ setSearchRequestVersion((version) => version + 1); recordAction(`Pencarian “${query || getApiQuery(query, activeModule.key)}” dikirim ke backend GeoSeek dan diurutkan dengan GeoScore.`, 'Pencarian'); }} />
Lokasi: {locationStatus}
Data: {isLoadingPlaces ? 'memuat backend...' : apiItems.length ? `${apiItems.length} item backend aktif` : 'fallback demo aktif'}
Zona: {apiMeta.radiusZoneLabel}
{(apiError || apiMeta.externalSources.length > 0 || apiMeta.expandedForNearest) && (
{apiError || `Sumber eksternal aktif: ${apiMeta.externalSources.join(', ')}`} {apiMeta.expandedForNearest ? ' Radius diperluas otomatis untuk mencari hasil terdekat.' : ''}
)}

AI Pencari Lokasi

Cari tempat dengan bahasa bebas

Tulis kebutuhan seperti “cari ATM terdekat” atau “apotek buka sekarang”. AI akan mengubahnya menjadi keyword dan radius, lalu GeoSeek menjalankan pencarian lokasi publik.