Autosave: 20260617-171936

This commit is contained in:
Flatlogic Bot 2026-06-17 17:19:33 +00:00
parent 6da4cb9f42
commit 638119614f
7 changed files with 3149 additions and 528 deletions

View File

@ -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<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 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): 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<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) => {
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 }) => (
<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
</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"><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(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<GeoSeekItem[]>([]);
const [actionHistory, setActionHistory] = useState<GeoSeekActionHistory[]>([]);
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 (
<>
<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 demo: {defaultGeoSeekLocation.label}. Semua menu kini punya fungsi awal: pencarian radius, GeoScore otomatis,
draft input pintar, simulasi peta, marketplace, booking, kurir, dan insight bisnis.
</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> dari {defaultGeoSeekLocation.label}
</div>
<div className="rounded-2xl bg-emerald-50 p-4 dark:bg-emerald-900/20">
{results.length} hasil masuk radius dan modul <strong>{activeModule.menuLabel}</strong>
</div>
<div className="rounded-2xl bg-purple-50 p-4 dark:bg-purple-900/20">
Draft lokal tersimpan <strong>{publishedItems.length}</strong> item
</div>
<div className="rounded-2xl bg-orange-50 p-4 dark:bg-orange-900/20">
Status: {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 lg:grid-cols-[1fr_220px_180px]">
<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"
>
{[1, 2, 3, 5, 10].map((radius) => (
<option key={radius} value={radius}>{radius} km</option>
))}
</select>
</label>
<div className="flex items-end">
<BaseButton
color="info"
label="Jalankan"
icon={icon.mdiMagnify}
className="h-12 w-full"
onClick={() => recordAction(`Pencarian “${query || 'semua data'}” berhasil diurutkan otomatis dengan GeoScore.`, 'Pencarian')}
/>
</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="Masuk radius dan relevansi" 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="Draft Lokal" value={publishedItems.length} help="Tersimpan di browser" iconPath={icon.mdiStore} />
</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)}
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}</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">
{results.length ? results.map((item) => (
<ResultCard key={item.id} item={item} moduleKey={activeModule.key} onAction={(message) => recordAction(message, 'Aksi Item')} />
)) : (
<CardBox className="bg-white/90 text-center dark:bg-dark-900">
<BaseIcon path={icon.mdiMagnify} size={42} className="mx-auto text-gray-400" />
<h3 className="mt-4 text-xl font-bold text-gray-900 dark:text-white">Tidak ada hasil di radius ini</h3>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">Coba ubah kata kunci atau perluas radius pencarian.</p>
<BaseButton color="info" label="Perluas ke 10 km" className="mt-4" onClick={() => setRadiusKm(10)} />
</CardBox>
)}
</div>
</SectionMain>
</>
);
}

View File

@ -0,0 +1,642 @@
export type GeoSeekModuleKey =
| 'home'
| 'search'
| 'map'
| 'products'
| 'services'
| 'umkm'
| 'culinary'
| 'health'
| 'property'
| 'automotive'
| 'tourism'
| 'events'
| 'promos'
| 'marketplace'
| 'booking'
| 'courier'
| 'business-dashboard'
| 'profile';
export type GeoSeekItemType =
| 'place'
| 'product'
| 'service'
| 'business'
| 'culinary'
| 'health'
| 'property'
| 'automotive'
| 'tourism'
| 'event'
| 'promo'
| 'courier';
export type GeoSeekItem = {
id: string;
type: GeoSeekItemType;
name: string;
businessName: string;
category: string;
description: string;
address: string;
area: string;
latitude: number;
longitude: number;
distanceKm: number;
price?: number;
stock?: number;
rating: number;
reviews: number;
activityScore: number;
tags: string[];
open: boolean;
promo?: string;
etaMinutes?: number;
bookingAvailable?: boolean;
deliveryAvailable?: boolean;
};
export type GeoSeekModule = {
key: GeoSeekModuleKey;
title: string;
menuLabel: string;
subtitle: string;
searchPlaceholder: string;
emptyHint: string;
primaryType?: GeoSeekItemType;
includeTypes: GeoSeekItemType[];
automationTitle: string;
automationDescription: string;
};
export const geoSeekModules: GeoSeekModule[] = [
{
key: 'home',
title: 'Beranda GeoSeek Pro',
menuLabel: 'Beranda',
subtitle: 'Pusat kendali pencarian hyperlocal, marketplace, peta, booking, kurir, dan otomasi UMKM.',
searchPlaceholder: 'Cari produk, jasa, tempat, promo, atau kebutuhan lokal...',
emptyHint: 'Coba cari “pupuk organik”, “tambal ban”, “nasi goreng”, atau “kurir”.',
includeTypes: ['place', 'product', 'service', 'business', 'culinary', 'health', 'property', 'automotive', 'tourism', 'event', 'promo', 'courier'],
automationTitle: 'Otomasi lintas menu',
automationDescription: 'GeoSeek otomatis mengurutkan hasil dengan GeoScore, membaca stok/promo, dan menyiapkan aksi cepat seperti booking, order, rute, dan kurir.',
},
{
key: 'search',
title: 'Cari Hyperlocal',
menuLabel: 'Cari',
subtitle: 'Cari tempat, produk, jasa, dan promo berdasarkan radius serta skor relevansi lokal.',
searchPlaceholder: 'Contoh: pupuk organik dekat saya, bengkel buka, promo makan siang...',
emptyHint: 'Masukkan kata kunci untuk mencari semua data demo GeoSeek.',
includeTypes: ['place', 'product', 'service', 'business', 'culinary', 'health', 'property', 'automotive', 'tourism', 'event', 'promo', 'courier'],
automationTitle: 'Pencarian otomatis',
automationDescription: 'Setiap pencarian dihitung dengan rumus GeoScore = 60% jarak + 20% relevansi + 10% rating + 10% aktivitas.',
},
{
key: 'map',
title: 'Peta & Radius',
menuLabel: 'Peta',
subtitle: 'Simulasi peta lokal dengan pin hasil terdekat, radius, estimasi jarak, dan rute cepat.',
searchPlaceholder: 'Cari pin peta: UMKM, kuliner, kesehatan, wisata...',
emptyHint: 'Peta demo akan menampilkan pin yang masuk radius pencarian.',
includeTypes: ['place', 'business', 'culinary', 'health', 'tourism', 'event', 'promo', 'courier'],
automationTitle: 'Pin peta otomatis',
automationDescription: 'Pin otomatis diprioritaskan dari jarak terdekat, status buka, rating, dan aktivitas terbaru.',
},
{
key: 'products',
title: 'Produk Lokal',
menuLabel: 'Produk',
subtitle: 'Daftar produk UMKM dengan stok, harga, promo, dan rekomendasi order otomatis.',
searchPlaceholder: 'Cari produk: beras, pupuk, kopi, madu, keripik...',
emptyHint: 'Produk akan muncul dengan stok, harga, dan tombol order simulasi.',
primaryType: 'product',
includeTypes: ['product', 'promo'],
automationTitle: 'Auto update stok',
automationDescription: 'Produk menampilkan stok dan status promo. Sistem memberi rekomendasi restock saat stok mulai rendah.',
},
{
key: 'services',
title: 'Jasa Terdekat',
menuLabel: 'Jasa',
subtitle: 'Temukan penyedia jasa lokal yang bisa dibooking atau dipanggil ke lokasi pengguna.',
searchPlaceholder: 'Cari jasa: servis AC, laundry, tukang, bengkel, desain...',
emptyHint: 'Jasa tampil dengan ETA, rating, dan booking cepat.',
primaryType: 'service',
includeTypes: ['service'],
automationTitle: 'Booking jasa otomatis',
automationDescription: 'Sistem membuat draft permintaan booking, estimasi jadwal, dan prioritas penyedia terdekat.',
},
{
key: 'umkm',
title: 'Direktori UMKM',
menuLabel: 'UMKM',
subtitle: 'Etalase bisnis lokal lengkap dengan produk, rating, status buka, dan peluang promosi.',
searchPlaceholder: 'Cari UMKM: warung, toko tani, pengrajin, laundry...',
emptyHint: 'UMKM akan muncul lengkap dengan insight otomatis.',
primaryType: 'business',
includeTypes: ['business', 'product', 'promo'],
automationTitle: 'Profil bisnis otomatis',
automationDescription: 'Dari satu input singkat, GeoSeek dapat menyiapkan profil bisnis, produk, promo, dan status operasional.',
},
{
key: 'culinary',
title: 'Kuliner',
menuLabel: 'Kuliner',
subtitle: 'Cari makanan, minuman, promo makan, stok menu, dan jarak restoran/warung terdekat.',
searchPlaceholder: 'Cari kuliner: nasi goreng, kopi, bakso, sarapan...',
emptyHint: 'Kuliner tampil dengan harga, stok menu, promo, dan estimasi antar.',
primaryType: 'culinary',
includeTypes: ['culinary', 'promo'],
automationTitle: 'Menu & promo otomatis',
automationDescription: 'Sistem membaca menu, harga, stok porsi, dan promo untuk membantu order lebih cepat.',
},
{
key: 'health',
title: 'Kesehatan',
menuLabel: 'Kesehatan',
subtitle: 'Temukan klinik, apotek, layanan kesehatan, stok obat, dan booking antrean lokal.',
searchPlaceholder: 'Cari kesehatan: apotek, klinik, vitamin, konsultasi...',
emptyHint: 'Hasil kesehatan tampil dengan status buka dan booking jika tersedia.',
primaryType: 'health',
includeTypes: ['health', 'product', 'service'],
automationTitle: 'Antrean & stok otomatis',
automationDescription: 'GeoSeek menandai layanan yang bisa dibooking serta produk kesehatan yang stoknya tersedia.',
},
{
key: 'property',
title: 'Properti',
menuLabel: 'Properti',
subtitle: 'Cari kontrakan, kios, rumah, tanah, dan properti lokal berdasarkan radius dan kebutuhan.',
searchPlaceholder: 'Cari properti: kontrakan, kios, ruko, tanah...',
emptyHint: 'Properti tampil dengan harga, lokasi, dan kontak/booking survei simulasi.',
primaryType: 'property',
includeTypes: ['property'],
automationTitle: 'Survey properti otomatis',
automationDescription: 'Sistem menyiapkan draft jadwal survei, rute lokasi, dan prioritas properti paling relevan.',
},
{
key: 'automotive',
title: 'Otomotif',
menuLabel: 'Otomotif',
subtitle: 'Cari bengkel, tambal ban, cuci mobil, sparepart, dan bantuan kendaraan terdekat.',
searchPlaceholder: 'Cari otomotif: tambal ban, oli, bengkel, aki...',
emptyHint: 'Otomotif tampil dengan ETA, stok sparepart, dan status buka.',
primaryType: 'automotive',
includeTypes: ['automotive', 'service', 'product'],
automationTitle: 'Bantuan kendaraan otomatis',
automationDescription: 'GeoSeek memprioritaskan layanan terdekat yang buka dan bisa dipanggil ke lokasi.',
},
{
key: 'tourism',
title: 'Wisata',
menuLabel: 'Wisata',
subtitle: 'Rekomendasi wisata, aktivitas lokal, tiket, dan rute berdasarkan jarak serta rating.',
searchPlaceholder: 'Cari wisata: pantai, taman, museum, homestay...',
emptyHint: 'Wisata tampil dengan rating, jam buka, promo, dan rute.',
primaryType: 'tourism',
includeTypes: ['tourism', 'event', 'promo'],
automationTitle: 'Itinerary otomatis',
automationDescription: 'Sistem menyusun prioritas tempat dari jarak, rating, aktivitas, dan event terdekat.',
},
{
key: 'events',
title: 'Event Lokal',
menuLabel: 'Event',
subtitle: 'Temukan bazar, konser kecil, pelatihan, pasar malam, dan agenda komunitas lokal.',
searchPlaceholder: 'Cari event: bazar, pelatihan, konser, pasar malam...',
emptyHint: 'Event tampil dengan jadwal, lokasi, dan rekomendasi promosi.',
primaryType: 'event',
includeTypes: ['event', 'tourism', 'promo'],
automationTitle: 'Promosi event otomatis',
automationDescription: 'GeoSeek membaca lokasi dan minat pencarian untuk menaikkan event yang paling dekat dan aktif.',
},
{
key: 'promos',
title: 'Promo Lokal',
menuLabel: 'Promo',
subtitle: 'Kumpulan diskon, voucher, bundling, dan promo stok cepat dari bisnis sekitar.',
searchPlaceholder: 'Cari promo: diskon makan, gratis ongkir, bundle...',
emptyHint: 'Promo tampil dengan produk/bisnis terkait dan rekomendasi aksi cepat.',
primaryType: 'promo',
includeTypes: ['promo', 'product', 'culinary', 'service'],
automationTitle: 'Promo pintar',
automationDescription: 'Sistem menandai promo yang paling relevan berdasarkan keyword, radius, stok, dan aktivitas pengguna.',
},
{
key: 'marketplace',
title: 'Marketplace',
menuLabel: 'Marketplace',
subtitle: 'Simulasi belanja lokal: produk, jasa, keranjang, checkout, pembayaran, dan kurir.',
searchPlaceholder: 'Cari item marketplace: kopi, madu, laundry, servis...',
emptyHint: 'Marketplace menampilkan tombol tambah keranjang dan checkout simulasi.',
includeTypes: ['product', 'service', 'culinary', 'promo'],
automationTitle: 'Checkout otomatis',
automationDescription: 'GeoSeek membuat simulasi keranjang, estimasi ongkir, metode bayar QRIS/e-wallet, dan rekomendasi kurir.',
},
{
key: 'booking',
title: 'Booking',
menuLabel: 'Booking',
subtitle: 'Pesan jasa, antrean kesehatan, survei properti, meja kuliner, atau jadwal layanan lokal.',
searchPlaceholder: 'Cari layanan booking: klinik, servis AC, wisata, properti...',
emptyHint: 'Hasil booking tampil dengan slot dan draft jadwal otomatis.',
includeTypes: ['service', 'health', 'property', 'tourism', 'culinary'],
automationTitle: 'Jadwal otomatis',
automationDescription: 'Sistem membuat draft booking berdasarkan jarak, ETA, status buka, dan kategori kebutuhan.',
},
{
key: 'courier',
title: 'Kurir Lokal',
menuLabel: 'Kurir',
subtitle: 'Simulasi pengiriman lokal dengan estimasi ongkir, ETA, dan kurir terdekat.',
searchPlaceholder: 'Cari kurir atau area pengiriman...',
emptyHint: 'Kurir tampil dengan ETA, jarak, dan estimasi ongkir.',
primaryType: 'courier',
includeTypes: ['courier'],
automationTitle: 'Dispatch kurir otomatis',
automationDescription: 'GeoSeek memilih kurir berdasarkan jarak, aktivitas, rating, dan estimasi waktu jemput.',
},
{
key: 'business-dashboard',
title: 'Dashboard Bisnis',
menuLabel: 'Dashboard Bisnis',
subtitle: 'Panel UMKM untuk melihat produk, stok, promo, booking, order, dan peluang lokal otomatis.',
searchPlaceholder: 'Cari insight bisnis: stok rendah, promo aktif, order...',
emptyHint: 'Dashboard menampilkan insight operasional dari semua data demo.',
includeTypes: ['business', 'product', 'service', 'culinary', 'promo'],
automationTitle: 'Insight bisnis otomatis',
automationDescription: 'Sistem menghitung stok rendah, peluang promo, potensi order, dan rekomendasi update profil bisnis.',
},
{
key: 'profile',
title: 'Profil GeoSeek',
menuLabel: 'Profil',
subtitle: 'Profil pengguna dan preferensi lokasi untuk personalisasi hasil hyperlocal.',
searchPlaceholder: 'Cari aktivitas atau preferensi profil...',
emptyHint: 'Profil menampilkan preferensi lokasi, kategori favorit, dan simulasi data akun.',
includeTypes: ['business', 'product', 'service', 'promo'],
automationTitle: 'Personalisasi otomatis',
automationDescription: 'GeoSeek menggunakan preferensi kategori dan radius untuk menyusun rekomendasi yang lebih lokal.',
},
];
export const defaultGeoSeekLocation = {
label: 'Alun-Alun Kota Demo',
latitude: -7.797068,
longitude: 110.370529,
};
export const geoSeekItems: GeoSeekItem[] = [
{
id: 'gsk-001',
type: 'business',
name: 'Toko Tani Makmur',
businessName: 'Toko Tani Makmur',
category: 'UMKM Pertanian',
description: 'Toko perlengkapan tani dengan pupuk organik, bibit sayur, dan konsultasi lahan kecil.',
address: 'Jl. Pasar Tani No. 12',
area: 'Kecamatan Tengah',
latitude: -7.7928,
longitude: 110.3659,
distanceKm: 0.8,
rating: 4.8,
reviews: 142,
activityScore: 94,
tags: ['umkm', 'pupuk', 'bibit', 'pertanian', 'organik'],
open: true,
promo: 'Diskon 10% pupuk organik sampai Jumat',
bookingAvailable: true,
deliveryAvailable: true,
},
{
id: 'gsk-002',
type: 'product',
name: 'Pupuk Organik Granul 5 kg',
businessName: 'Toko Tani Makmur',
category: 'Produk Pertanian',
description: 'Pupuk organik siap pakai untuk sayur, cabai, dan tanaman pekarangan.',
address: 'Jl. Pasar Tani No. 12',
area: 'Kecamatan Tengah',
latitude: -7.7928,
longitude: 110.3659,
distanceKm: 0.8,
price: 38000,
stock: 64,
rating: 4.7,
reviews: 88,
activityScore: 91,
tags: ['produk', 'pupuk', 'organik', 'tani', 'stok'],
open: true,
promo: 'Bundling 3 pcs gratis antar radius 2 km',
deliveryAvailable: true,
},
{
id: 'gsk-003',
type: 'culinary',
name: 'Nasi Goreng Rempah Sari',
businessName: 'Warung Sari Rasa',
category: 'Kuliner Malam',
description: 'Nasi goreng rempah dengan topping ayam suwir dan telur, favorit warga sekitar.',
address: 'Jl. Alun-Alun Timur No. 3',
area: 'Pusat Kota',
latitude: -7.7977,
longitude: 110.371,
distanceKm: 0.2,
price: 15000,
stock: 42,
rating: 4.9,
reviews: 321,
activityScore: 98,
tags: ['kuliner', 'nasi goreng', 'makan malam', 'promo', 'antar'],
open: true,
promo: 'Gratis es teh untuk order sebelum 20.00',
etaMinutes: 14,
bookingAvailable: true,
deliveryAvailable: true,
},
{
id: 'gsk-004',
type: 'service',
name: 'Servis AC Panggilan Cepat',
businessName: 'Jaya Teknik Home Service',
category: 'Jasa Rumah',
description: 'Cuci AC, isi freon, perbaikan bocor, dan perawatan berkala dengan teknisi terverifikasi.',
address: 'Jl. Melati Selatan No. 8',
area: 'Kelurahan Melati',
latitude: -7.8032,
longitude: 110.3748,
distanceKm: 1.1,
price: 85000,
rating: 4.6,
reviews: 176,
activityScore: 86,
tags: ['jasa', 'servis ac', 'home service', 'booking', 'teknisi'],
open: true,
etaMinutes: 35,
bookingAvailable: true,
deliveryAvailable: false,
},
{
id: 'gsk-005',
type: 'health',
name: 'Apotek Sehat 24',
businessName: 'Apotek Sehat 24',
category: 'Apotek & Kesehatan',
description: 'Apotek dengan vitamin, obat umum, alat kesehatan, dan konsultasi ringan.',
address: 'Jl. Kesehatan Raya No. 21',
area: 'Kecamatan Utara',
latitude: -7.7899,
longitude: 110.3729,
distanceKm: 1.4,
price: 25000,
stock: 120,
rating: 4.7,
reviews: 210,
activityScore: 89,
tags: ['kesehatan', 'apotek', 'vitamin', 'obat', '24 jam'],
open: true,
promo: 'Paket vitamin keluarga hemat 15%',
etaMinutes: 20,
bookingAvailable: true,
deliveryAvailable: true,
},
{
id: 'gsk-006',
type: 'automotive',
name: 'Tambal Ban Siaga',
businessName: 'Bengkel Siaga Motor',
category: 'Otomotif Darurat',
description: 'Tambal ban, ganti oli, aki soak, dan bantuan motor mogok di area kota.',
address: 'Jl. Ring Road Barat KM 2',
area: 'Kecamatan Barat',
latitude: -7.7985,
longitude: 110.3552,
distanceKm: 1.9,
price: 20000,
stock: 32,
rating: 4.5,
reviews: 119,
activityScore: 83,
tags: ['otomotif', 'tambal ban', 'bengkel', 'oli', 'darurat'],
open: true,
etaMinutes: 18,
bookingAvailable: true,
deliveryAvailable: false,
},
{
id: 'gsk-007',
type: 'property',
name: 'Kios Strategis Dekat Pasar',
businessName: 'Properti Lokal Sentosa',
category: 'Sewa Kios',
description: 'Kios ukuran 3x4 meter dekat pasar pagi, cocok untuk kuliner, sayur, atau grosir kecil.',
address: 'Kompleks Pasar Pagi Blok B-7',
area: 'Kecamatan Tengah',
latitude: -7.7945,
longitude: 110.368,
distanceKm: 0.6,
price: 1800000,
rating: 4.4,
reviews: 36,
activityScore: 76,
tags: ['properti', 'kios', 'sewa', 'pasar', 'umkm'],
open: true,
bookingAvailable: true,
deliveryAvailable: false,
},
{
id: 'gsk-008',
type: 'tourism',
name: 'Kampung Heritage Kali Biru',
businessName: 'Pokdarwis Kali Biru',
category: 'Wisata Edukasi',
description: 'Wisata kampung, spot foto, kuliner lokal, dan tur sejarah bersama warga.',
address: 'Kampung Kali Biru RT 04',
area: 'Kecamatan Timur',
latitude: -7.8014,
longitude: 110.3832,
distanceKm: 2.2,
price: 10000,
rating: 4.8,
reviews: 264,
activityScore: 87,
tags: ['wisata', 'heritage', 'foto', 'kuliner', 'event'],
open: true,
promo: 'Paket tur keluarga akhir pekan',
bookingAvailable: true,
},
{
id: 'gsk-009',
type: 'event',
name: 'Bazar UMKM Jumat Ceria',
businessName: 'Komunitas UMKM Kota',
category: 'Event & Bazar',
description: 'Bazar produk lokal, kuliner malam, live music akustik, dan demo produk warga.',
address: 'Lapangan Alun-Alun Barat',
area: 'Pusat Kota',
latitude: -7.7963,
longitude: 110.3691,
distanceKm: 0.3,
rating: 4.7,
reviews: 95,
activityScore: 96,
tags: ['event', 'bazar', 'umkm', 'kuliner', 'promo'],
open: true,
promo: 'Booth gratis untuk 10 UMKM baru',
bookingAvailable: true,
},
{
id: 'gsk-010',
type: 'promo',
name: 'Gratis Ongkir Radius 3 km',
businessName: 'GeoKurir Lokal',
category: 'Promo Pengiriman',
description: 'Promo gratis ongkir untuk order produk dan kuliner lokal dengan minimum transaksi tertentu.',
address: 'Hub Kurir Alun-Alun',
area: 'Pusat Kota',
latitude: -7.797,
longitude: 110.37,
distanceKm: 0.1,
rating: 4.6,
reviews: 73,
activityScore: 93,
tags: ['promo', 'gratis ongkir', 'kurir', 'marketplace', 'order'],
open: true,
promo: 'Gratis ongkir min. Rp50.000',
etaMinutes: 12,
deliveryAvailable: true,
},
{
id: 'gsk-011',
type: 'courier',
name: 'GeoKurir Motor 01',
businessName: 'GeoKurir Lokal',
category: 'Kurir Instan',
description: 'Kurir motor lokal untuk makanan, dokumen, belanja pasar, dan paket kecil.',
address: 'Hub Kurir Alun-Alun',
area: 'Pusat Kota',
latitude: -7.797,
longitude: 110.37,
distanceKm: 0.1,
price: 9000,
rating: 4.8,
reviews: 184,
activityScore: 97,
tags: ['kurir', 'pengiriman', 'instant', 'antar', 'pickup'],
open: true,
etaMinutes: 9,
bookingAvailable: true,
deliveryAvailable: true,
},
{
id: 'gsk-012',
type: 'product',
name: 'Kopi Robusta Lereng 250 gr',
businessName: 'Roastery Bukit Sari',
category: 'Produk Minuman',
description: 'Kopi robusta lokal sangrai medium, cocok untuk manual brew dan tubruk.',
address: 'Jl. Bukit Sari No. 5',
area: 'Kecamatan Utara',
latitude: -7.7872,
longitude: 110.3664,
distanceKm: 1.7,
price: 47000,
stock: 18,
rating: 4.9,
reviews: 205,
activityScore: 88,
tags: ['produk', 'kopi', 'robusta', 'umkm', 'minuman'],
open: true,
promo: 'Beli 2 gratis drip bag',
deliveryAvailable: true,
},
{
id: 'gsk-013',
type: 'service',
name: 'Laundry Express 6 Jam',
businessName: 'Bersih Kilat Laundry',
category: 'Jasa Harian',
description: 'Laundry kiloan, sepatu, helm, dan antar jemput cucian untuk area pusat kota.',
address: 'Jl. Kenanga No. 14',
area: 'Kelurahan Kenanga',
latitude: -7.8062,
longitude: 110.3693,
distanceKm: 1.0,
price: 7000,
rating: 4.4,
reviews: 82,
activityScore: 81,
tags: ['jasa', 'laundry', 'express', 'antar jemput', 'booking'],
open: true,
promo: 'Diskon 20% pelanggan baru',
etaMinutes: 24,
bookingAvailable: true,
deliveryAvailable: true,
},
{
id: 'gsk-014',
type: 'place',
name: 'Co-Working Nusa Kreatif',
businessName: 'Nusa Kreatif Space',
category: 'Tempat Produktif',
description: 'Ruang kerja bersama, meeting room, kelas UMKM digital, dan internet cepat.',
address: 'Jl. Kreatif No. 2',
area: 'Pusat Kota',
latitude: -7.7956,
longitude: 110.3734,
distanceKm: 0.5,
price: 25000,
rating: 4.7,
reviews: 136,
activityScore: 84,
tags: ['tempat', 'coworking', 'event', 'meeting', 'umkm'],
open: true,
bookingAvailable: true,
},
{
id: 'gsk-015',
type: 'health',
name: 'Klinik Keluarga Melati',
businessName: 'Klinik Keluarga Melati',
category: 'Klinik Umum',
description: 'Konsultasi dokter umum, cek gula darah, vaksin, dan antrean online.',
address: 'Jl. Melati Utara No. 16',
area: 'Kelurahan Melati',
latitude: -7.8022,
longitude: 110.3794,
distanceKm: 1.5,
price: 50000,
rating: 4.6,
reviews: 157,
activityScore: 82,
tags: ['kesehatan', 'klinik', 'dokter', 'antrean', 'booking'],
open: true,
etaMinutes: 28,
bookingAvailable: true,
},
{
id: 'gsk-016',
type: 'promo',
name: 'Paket Sarapan Hemat',
businessName: 'Dapur Pagi Bu Rini',
category: 'Promo Kuliner',
description: 'Paket nasi kuning, teh hangat, dan gorengan untuk sarapan kantor atau sekolah.',
address: 'Jl. Sekolah No. 9',
area: 'Kecamatan Selatan',
latitude: -7.812,
longitude: 110.3718,
distanceKm: 1.9,
price: 12000,
stock: 35,
rating: 4.5,
reviews: 74,
activityScore: 85,
tags: ['promo', 'kuliner', 'sarapan', 'nasi kuning', 'antar'],
open: true,
promo: 'Pesan 5 paket gratis 1',
etaMinutes: 22,
deliveryAvailable: true,
},
];

View File

@ -0,0 +1,314 @@
import { GeoSeekItem, GeoSeekItemType, geoSeekItems } from './geoseek';
export type GeoSeekScoredItem = GeoSeekItem & {
geoScore: number;
relevanceScore: number;
distanceScore: number;
ratingScore: number;
activityContribution: number;
automationNotes: string[];
};
export type GeoSeekSmartDraft = {
businessName: string;
itemName: string;
category: string;
price?: number;
stock?: number;
location: string;
promo: string;
status: string;
tags: string[];
nextActions: string[];
};
export type GeoSeekCartItem = {
id: string;
name: string;
businessName: string;
price: number;
quantity: number;
};
const normalize = (value: string) => value.toLowerCase().trim();
export const currency = (value?: number) => {
if (typeof value !== 'number') return 'Hubungi penjual';
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
maximumFractionDigits: 0,
}).format(value);
};
export const getGeoSeekModule = (module?: string | string[]) => {
const key = Array.isArray(module) ? module[0] : module;
return key || 'home';
};
export const calculateRelevanceScore = (item: GeoSeekItem, query: string) => {
const normalizedQuery = normalize(query);
if (!normalizedQuery) return 72;
const searchable = normalize([
item.name,
item.businessName,
item.category,
item.description,
item.area,
item.tags.join(' '),
item.promo || '',
].join(' '));
const words = normalizedQuery.split(/\s+/).filter(Boolean);
if (!words.length) return 72;
const matched = words.filter((word) => searchable.includes(word)).length;
const exactBonus = searchable.includes(normalizedQuery) ? 18 : 0;
return Math.min(100, Math.round((matched / words.length) * 82 + exactBonus));
};
export const calculateGeoScore = (item: GeoSeekItem, query: string) => {
const maxRadius = 10;
const distanceScore = Math.max(0, Math.round((1 - Math.min(item.distanceKm, maxRadius) / maxRadius) * 100));
const relevanceScore = calculateRelevanceScore(item, query);
const ratingScore = Math.round((item.rating / 5) * 100);
const activityContribution = item.activityScore;
const geoScore = Math.round(
distanceScore * 0.6 + relevanceScore * 0.2 + ratingScore * 0.1 + activityContribution * 0.1,
);
return {
geoScore,
relevanceScore,
distanceScore,
ratingScore,
activityContribution,
};
};
export const getAutomationNotes = (item: GeoSeekItem, moduleKey: string) => {
const notes: string[] = [];
if (item.open) notes.push('Buka sekarang');
if (typeof item.stock === 'number' && item.stock <= 20) notes.push('Stok perlu dipantau/restock');
if (typeof item.stock === 'number' && item.stock > 20) notes.push('Stok aman untuk order cepat');
if (item.promo) notes.push('Promo aktif terdeteksi');
if (item.bookingAvailable) notes.push('Bisa dibuat booking otomatis');
if (item.deliveryAvailable) notes.push('Mendukung kurir/pengiriman lokal');
if (item.etaMinutes) notes.push(`Estimasi respons ${item.etaMinutes} menit`);
if (moduleKey === 'marketplace') notes.push('Siap masuk keranjang dan checkout simulasi');
if (moduleKey === 'map') notes.push('Pin prioritas peta berdasarkan radius');
if (moduleKey === 'business-dashboard') notes.push('Masuk insight dashboard bisnis');
return notes.slice(0, 4);
};
export const filterGeoSeekItems = ({
query,
radiusKm,
includeTypes,
moduleKey,
extraItems = [],
}: {
query: string;
radiusKm: number;
includeTypes: GeoSeekItemType[];
moduleKey: string;
extraItems?: GeoSeekItem[];
}): GeoSeekScoredItem[] => {
const normalizedQuery = normalize(query);
const sourceItems = [...extraItems, ...geoSeekItems];
return sourceItems
.filter((item) => includeTypes.includes(item.type))
.filter((item) => item.distanceKm <= radiusKm)
.map((item) => ({
...item,
...calculateGeoScore(item, normalizedQuery),
automationNotes: getAutomationNotes(item, moduleKey),
}))
.filter((item) => {
if (!normalizedQuery) return true;
const searchable = normalize([
item.name,
item.businessName,
item.category,
item.description,
item.area,
item.tags.join(' '),
item.promo || '',
].join(' '));
return normalizedQuery.split(/\s+/).some((word) => searchable.includes(word));
})
.sort((a, b) => b.geoScore - a.geoScore);
};
const getFirstCurrencyValue = (text: string) => {
const match = text.match(/(?:rp\s*)?([0-9][0-9.]{2,})(?:\s*(?:rb|ribu))?/i);
if (!match) return undefined;
const raw = match[1].replace(/\./g, '');
const number = Number(raw);
if (Number.isNaN(number)) return undefined;
if (/rb|ribu/i.test(match[0]) && number < 1000) return number * 1000;
return number;
};
const getStockValue = (text: string) => {
const match = text.match(/(?:stok|stock|tersedia|ready)\s*(\d+)/i);
if (!match) return undefined;
const number = Number(match[1]);
return Number.isNaN(number) ? undefined : number;
};
const guessCategory = (text: string) => {
const normalized = normalize(text);
if (/nasi|kopi|bakso|mie|ayam|sarapan|kuliner|warung/.test(normalized)) return 'Kuliner';
if (/pupuk|bibit|tani|sayur|organik/.test(normalized)) return 'Produk Pertanian';
if (/ac|laundry|servis|jasa|teknisi|booking/.test(normalized)) return 'Jasa';
if (/apotek|klinik|obat|vitamin|dokter/.test(normalized)) return 'Kesehatan';
if (/ban|bengkel|oli|motor|mobil|aki/.test(normalized)) return 'Otomotif';
if (/kios|rumah|kontrakan|tanah|ruko/.test(normalized)) return 'Properti';
if (/wisata|tiket|tour|event|bazar/.test(normalized)) return 'Wisata & Event';
if (/kurir|ongkir|antar|kirim|pickup/.test(normalized)) return 'Kurir';
return 'UMKM Lokal';
};
const guessBusinessName = (text: string) => {
const patterns = [
/(?:umkm|toko|warung|kedai|apotek|bengkel|klinik|laundry|roastery)\s+([a-z0-9\s]{2,28})/i,
/([a-z0-9\s]{2,28})\s+(?:buka|promo|menjual|ready|stok)/i,
];
for (const pattern of patterns) {
const match = text.match(pattern);
if (match?.[0]) {
return match[0]
.replace(/\b(buka|promo|menjual|ready|stok)\b/gi, '')
.trim()
.replace(/\s+/g, ' ');
}
}
return 'Bisnis Lokal Baru';
};
const guessItemName = (text: string) => {
const normalized = text.replace(/\s+/g, ' ').trim();
const productMatch = normalized.match(/(?:jual|menjual|promo|ready|stok)\s+([^,.]{3,42})/i);
if (productMatch?.[1]) {
return productMatch[1]
.replace(/\b(rp\s*[0-9.]+|stok\s*\d+|dekat\s+.+)$/i, '')
.trim();
}
return normalized.split(/[,.]/)[0].slice(0, 48) || 'Produk/Jasa Baru';
};
const guessLocation = (text: string) => {
const match = text.match(/(?:dekat|di|area|lokasi)\s+([^,.]{3,36})/i);
return match?.[1]?.trim() || 'Lokasi pengguna saat ini';
};
export const createSmartDraft = (input: string): GeoSeekSmartDraft => {
const cleaned = input.trim();
const category = guessCategory(cleaned);
const stock = getStockValue(cleaned);
const price = getFirstCurrencyValue(cleaned);
const tags = Array.from(
new Set(
cleaned
.toLowerCase()
.replace(/[^a-z0-9\s]/gi, ' ')
.split(/\s+/)
.filter((word) => word.length > 3)
.slice(0, 8),
),
);
return {
businessName: guessBusinessName(cleaned),
itemName: guessItemName(cleaned),
category,
price,
stock,
location: guessLocation(cleaned),
promo: /promo|diskon|gratis|voucher|hemat/i.test(cleaned) ? 'Promo aktif dari input pengguna' : 'Belum ada promo khusus',
status: /tutup|habis/i.test(cleaned) ? 'Perlu review manual' : 'Draft siap dipublikasikan',
tags: tags.length ? tags : ['lokal', 'geoseek', 'umkm'],
nextActions: [
'Validasi lokasi dan radius bisnis',
'Publikasikan ke menu terkait',
'Buat notifikasi promo untuk warga sekitar',
'Siapkan opsi booking/order/kurir jika relevan',
],
};
};
export const getBusinessInsights = (items: GeoSeekScoredItem[]) => {
const products = items.filter((item) => item.type === 'product' || item.type === 'culinary');
const services = items.filter((item) => item.type === 'service');
const promos = items.filter((item) => Boolean(item.promo) || item.type === 'promo');
const lowStock = products.filter((item) => typeof item.stock === 'number' && item.stock <= 20);
const bookingReady = items.filter((item) => item.bookingAvailable);
const deliveryReady = items.filter((item) => item.deliveryAvailable);
return {
products: products.length,
services: services.length,
promos: promos.length,
lowStock: lowStock.length,
bookingReady: bookingReady.length,
deliveryReady: deliveryReady.length,
averageGeoScore: items.length ? Math.round(items.reduce((sum, item) => sum + item.geoScore, 0) / items.length) : 0,
recommendations: [
lowStock.length ? `${lowStock.length} item perlu restock hari ini.` : 'Stok utama masih aman.',
promos.length ? `${promos.length} promo aktif siap didorong ke warga sekitar.` : 'Buat promo baru untuk menaikkan visibilitas.',
deliveryReady.length ? 'Pengiriman lokal siap dipakai untuk checkout.' : 'Aktifkan kurir lokal untuk mempercepat transaksi.',
bookingReady.length ? 'Booking otomatis tersedia untuk beberapa layanan.' : 'Tambahkan slot booking untuk jasa/layanan.',
],
};
};
export const createCartFromResults = (items: GeoSeekScoredItem[]): GeoSeekCartItem[] => items
.filter((item) => typeof item.price === 'number')
.slice(0, 3)
.map((item) => ({
id: item.id,
name: item.name,
businessName: item.businessName,
price: item.price || 0,
quantity: item.type === 'culinary' ? 2 : 1,
}));
export const getCheckoutSummary = (cartItems: GeoSeekCartItem[]) => {
const subtotal = cartItems.reduce((sum, item) => sum + item.price * item.quantity, 0);
const deliveryFee = cartItems.length ? 9000 : 0;
const platformFee = cartItems.length ? 1500 : 0;
const discount = subtotal >= 50000 ? 9000 : 0;
const total = Math.max(0, subtotal + deliveryFee + platformFee - discount);
return {
subtotal,
deliveryFee,
platformFee,
discount,
total,
paymentMethods: ['QRIS', 'Transfer Bank', 'E-Wallet'],
status: cartItems.length ? 'Checkout simulasi siap diproses' : 'Tambahkan item untuk checkout',
};
};

View File

@ -7,6 +7,30 @@ const menuAside: MenuAsideItem[] = [
icon: icon.mdiViewDashboardOutline,
label: 'Dashboard',
},
{
label: 'GeoSeek Pro',
icon: icon.mdiMapSearchOutline,
menu: [
{ href: '/geoseek', label: 'Beranda', icon: icon.mdiViewDashboardOutline },
{ href: '/geoseek/search', label: 'Cari', icon: icon.mdiMagnify },
{ href: '/geoseek/map', label: 'Peta', icon: icon.mdiMapMarker },
{ href: '/geoseek/products', label: 'Produk', icon: icon.mdiShoppingOutline },
{ href: '/geoseek/services', label: 'Jasa', icon: icon.mdiBriefcaseSearchOutline },
{ href: '/geoseek/umkm', label: 'UMKM', icon: icon.mdiStore },
{ href: '/geoseek/culinary', label: 'Kuliner', icon: icon.mdiFoodForkDrink },
{ href: '/geoseek/health', label: 'Kesehatan', icon: icon.mdiHospitalBoxOutline },
{ href: '/geoseek/property', label: 'Properti', icon: icon.mdiHomeCityOutline },
{ href: '/geoseek/automotive', label: 'Otomotif', icon: icon.mdiCarWrench },
{ href: '/geoseek/tourism', label: 'Wisata', icon: icon.mdiBeach },
{ href: '/geoseek/events', label: 'Event', icon: icon.mdiCalendar },
{ href: '/geoseek/promos', label: 'Promo', icon: icon.mdiTicketPercentOutline },
{ href: '/geoseek/marketplace', label: 'Marketplace', icon: icon.mdiCart },
{ href: '/geoseek/booking', label: 'Booking', icon: icon.mdiCalendar },
{ href: '/geoseek/courier', label: 'Kurir', icon: icon.mdiTruck },
{ href: '/geoseek/business-dashboard', label: 'Dashboard Bisnis', icon: icon.mdiFinance },
{ href: '/geoseek/profile', label: 'Profil', icon: icon.mdiAccountCircle },
],
},
{
href: '/users/users-list',

View File

@ -0,0 +1,18 @@
import React from 'react';
import type { ReactElement } from 'react';
import { useRouter } from 'next/router';
import GeoSeekProWorkspace from '../../components/GeoSeek/GeoSeekProWorkspace';
import LayoutAuthenticated from '../../layouts/Authenticated';
const GeoSeekModulePage = () => {
const router = useRouter();
const moduleKey = Array.isArray(router.query.module) ? router.query.module[0] : router.query.module;
return <GeoSeekProWorkspace moduleKey={moduleKey || 'home'} />;
};
GeoSeekModulePage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default GeoSeekModulePage;

View File

@ -0,0 +1,12 @@
import React from 'react';
import type { ReactElement } from 'react';
import GeoSeekProWorkspace from '../../components/GeoSeek/GeoSeekProWorkspace';
import LayoutAuthenticated from '../../layouts/Authenticated';
const GeoSeekHomePage = () => <GeoSeekProWorkspace moduleKey="home" />;
GeoSeekHomePage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default GeoSeekHomePage;

File diff suppressed because it is too large Load Diff