diff --git a/frontend/src/components/GeoSeek/GeoSeekProWorkspace.tsx b/frontend/src/components/GeoSeek/GeoSeekProWorkspace.tsx new file mode 100644 index 0000000..707c723 --- /dev/null +++ b/frontend/src/components/GeoSeek/GeoSeekProWorkspace.tsx @@ -0,0 +1,880 @@ +import * as icon from '@mdi/js'; +import Head from 'next/head'; +import Link from 'next/link'; +import React, { 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 { useAppSelector } from '../../stores/hooks'; +import { + defaultGeoSeekLocation, + GeoSeekItem, + GeoSeekItemType, + geoSeekModules, + GeoSeekModuleKey, +} from '../../data/geoseek'; +import { + createCartFromResults, + createSmartDraft, + currency, + filterGeoSeekItems, + getBusinessInsights, + getCheckoutSummary, + 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; +}; + +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 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): 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: defaultGeoSeekLocation.latitude + distanceKm / 1000, + longitude: defaultGeoSeekLocation.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) => { + const left = Math.min(88, Math.max(8, 50 + (item.longitude - defaultGeoSeekLocation.longitude) * 1800 + index * 2)); + const top = Math.min(82, Math.max(10, 50 + (item.latitude - defaultGeoSeekLocation.latitude) * -1800 + index * 3)); + + 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.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

+
+
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(3); + 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 { currentUser } = useAppSelector((state) => state.auth); + + const results = useMemo( + () => filterGeoSeekItems({ + query, + radiusKm, + includeTypes: activeModule.includeTypes, + moduleKey: activeModule.key, + extraItems: publishedItems, + }), + [activeModule.includeTypes, activeModule.key, publishedItems, query, radiusKm], + ); + + const allModuleResults = useMemo( + () => filterGeoSeekItems({ + query: '', + radiusKm: 10, + includeTypes: activeModule.includeTypes, + moduleKey: activeModule.key, + extraItems: publishedItems, + }), + [activeModule.includeTypes, activeModule.key, publishedItems], + ); + + 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 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); + + 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'); + }; + + 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(() => { + setQuery(getDefaultQuery(activeModule.key)); + setRadiusKm(3); + setActionStatus(`Menu ${activeModule.menuLabel} siap. Data demo otomatis dimuat dan diurutkan dengan GeoScore.`); + }, [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 demo: {defaultGeoSeekLocation.label}. Semua menu kini punya fungsi awal: pencarian radius, GeoScore otomatis, + draft input pintar, simulasi peta, marketplace, booking, kurir, dan insight bisnis. +

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

Profil aktif

+

{profileName}

+
+
+ Radius aktif {radiusKm} km dari {defaultGeoSeekLocation.label} +
+
+ {results.length} hasil masuk radius dan modul {activeModule.menuLabel} +
+
+ Draft lokal tersimpan {publishedItems.length} item +
+
+ Status: {actionStatus} +
+
+
+
+ + +
+ {moduleOptions.map((option) => { + const isActive = option.key === activeModule.key; + + return ( + + + {option.label} + + ); + })} +
+ +
+ + +
+ recordAction(`Pencarian “${query || 'semua data'}” berhasil diurutkan otomatis dengan GeoScore.`, 'Pencarian')} + /> +
+
+
+ +
+ + + + + +
+ +
+ +
+
+

{activeModule.automationTitle}

+

{activeModule.automationDescription}

+
+
+ +
+
+ + {actionSet.map((action) => ( + recordAction(action.message, action.label)} + /> + ))} + +
+ Status otomasi: {actionStatus} +
+
+
+
+

Riwayat aksi lokal

+

Order, booking, dispatch, dan publikasi tersimpan di browser.

+
+ { + setActionHistory([]); + setPublishedItems([]); + setActionStatus('Data lokal GeoSeek dibersihkan. Data demo bawaan tetap tersedia.'); + }} + /> +
+
+ {actionHistory.length ? actionHistory.slice(0, 4).map((history) => ( +
+
+ {history.label} + {history.createdAt} +
+

{history.message}

+
+ )) : ( +

+ Belum ada aksi. Jalankan pencarian, publish draft, checkout, booking, atau kurir. +

+ )} +
+
+
+ + +
+
+

Smart Input / Auto Input

+

Tulis laporan bebas, sistem membuat draft data terstruktur.

+
+ +
+