1339 lines
60 KiB
TypeScript
1339 lines
60 KiB
TypeScript
import * as icon from '@mdi/js';
|
|
import axios from 'axios';
|
|
import Head from 'next/head';
|
|
import Link from 'next/link';
|
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import BaseButton from '../BaseButton';
|
|
import BaseButtons from '../BaseButtons';
|
|
import BaseIcon from '../BaseIcon';
|
|
import CardBox from '../CardBox';
|
|
import SectionMain from '../SectionMain';
|
|
import SectionTitleLineWithButton from '../SectionTitleLineWithButton';
|
|
import { getPageTitle } from '../../config';
|
|
import { useAppSelector } from '../../stores/hooks';
|
|
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;
|
|
};
|
|
|
|
const typeLabels: Record<GeoSeekItemType, string> = {
|
|
place: 'Tempat',
|
|
product: 'Produk',
|
|
service: 'Jasa',
|
|
business: 'UMKM',
|
|
culinary: 'Kuliner',
|
|
health: 'Kesehatan',
|
|
property: 'Properti',
|
|
automotive: 'Otomotif',
|
|
tourism: 'Wisata',
|
|
event: 'Event',
|
|
promo: 'Promo',
|
|
courier: 'Kurir',
|
|
};
|
|
|
|
const backendDemoGeoSeekLocation: GeoSeekResolvedLocation = {
|
|
label: 'Pekanbaru Data Demo',
|
|
latitude: 0.5095,
|
|
longitude: 101.4549,
|
|
source: 'demo',
|
|
};
|
|
|
|
const emptyApiMeta: GeoSeekApiMeta = {
|
|
count: 0,
|
|
totalCandidates: 0,
|
|
radiusZoneLabel: 'Radius aktif',
|
|
externalSources: [],
|
|
externalSourceErrors: [],
|
|
filteredByRadius: false,
|
|
expandedForNearest: false,
|
|
};
|
|
|
|
const radiusOptions = [1, 5, 10, 20];
|
|
|
|
const apiQueryByModule: Partial<Record<GeoSeekModuleKey, string>> = {
|
|
home: 'produk jasa umkm lokal',
|
|
search: 'produk jasa umkm lokal',
|
|
map: '',
|
|
products: 'produk stok toko',
|
|
services: 'jasa servis booking',
|
|
umkm: '',
|
|
culinary: 'kuliner cafe warung',
|
|
health: 'kesehatan apotek klinik',
|
|
property: 'properti ruko rumah',
|
|
automotive: 'bengkel otomotif servis',
|
|
tourism: 'wisata hotel lokal',
|
|
events: 'event bazar lokal',
|
|
promos: 'promo diskon lokal',
|
|
marketplace: 'produk marketplace lokal',
|
|
booking: 'jasa booking lokal',
|
|
courier: 'kurir antar lokal',
|
|
'business-dashboard': '',
|
|
profile: '',
|
|
};
|
|
|
|
const getApiQuery = (query: string, moduleKey: GeoSeekModuleKey) => query.trim() || apiQueryByModule[moduleKey] || '';
|
|
|
|
const toOptionalNumber = (value: unknown) => {
|
|
if (value === null || value === undefined || value === '') return undefined;
|
|
|
|
const number = Number(value);
|
|
|
|
return Number.isFinite(number) ? number : undefined;
|
|
};
|
|
|
|
const normalizeTagText = (value: unknown) => String(value || '')
|
|
.toLowerCase()
|
|
.normalize('NFD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.replace(/[^a-z0-9]+/g, ' ')
|
|
.trim();
|
|
|
|
const createTags = (values: unknown[], fallbackTags: string[] = ['lokal', 'geoseek']) => {
|
|
const tags = values
|
|
.flatMap((value) => normalizeTagText(value).split(/\s+/))
|
|
.filter((word) => word.length > 2 && !['dan', 'atau', 'yang', 'untuk', 'dari'].includes(word));
|
|
|
|
return Array.from(new Set([...tags, ...fallbackTags])).slice(0, 8);
|
|
};
|
|
|
|
const getPublicPlaceArea = (place: PublicPlaceRow) => [place.city, place.province].filter(Boolean).join(', ') || 'Area sekitar';
|
|
|
|
const getPublicPlaceAddress = (place: PublicPlaceRow) => [place.address, place.city, place.province].filter(Boolean).join(', ') || 'Alamat mengikuti data lokasi';
|
|
|
|
const getPublicPlaceCategory = (place: PublicPlaceRow) => place.category?.name || 'UMKM Lokal';
|
|
|
|
const getPublicPlaceDescription = (place: PublicPlaceRow) => place.short_description || place.full_description || 'Bisnis lokal dari database GeoSeek.';
|
|
|
|
const getPublicPlaceType = (place: PublicPlaceRow): GeoSeekItemType => {
|
|
const haystack = normalizeTagText([
|
|
place.name,
|
|
place.category?.name,
|
|
place.category?.slug,
|
|
place.category?.description,
|
|
place.short_description,
|
|
place.full_description,
|
|
].join(' '));
|
|
|
|
if (/kuliner|cafe|kopi|restoran|warung|kedai|makan|food/.test(haystack)) return 'culinary';
|
|
if (/kesehatan|apotek|klinik|herbal|obat|dokter|health/.test(haystack)) return 'health';
|
|
if (/bengkel|otomotif|mobil|motor|servis|service|auto/.test(haystack)) return 'automotive';
|
|
if (/properti|ruko|rumah|tanah|kios|property/.test(haystack)) return 'property';
|
|
if (/wisata|hotel|travel|tour/.test(haystack)) return 'tourism';
|
|
if (/event|bazar|agenda/.test(haystack)) return 'event';
|
|
|
|
return 'business';
|
|
};
|
|
|
|
const getOfferingType = (offering: PublicPlaceOffering): GeoSeekItemType => (offering.offering_type === 'product' ? 'product' : 'service');
|
|
|
|
const getOfferingStock = (offering: PublicPlaceOffering) => {
|
|
const quantity = toOptionalNumber(offering.stock_quantity);
|
|
|
|
if (quantity !== undefined) return Math.round(quantity);
|
|
if (offering.stock_status === 'limited') return 8;
|
|
if (offering.stock_status === 'in_stock') return 24;
|
|
|
|
return undefined;
|
|
};
|
|
|
|
const getApiActivityScore = (place: PublicPlaceRow, offering?: PublicPlaceOffering) => {
|
|
const geoScore = toOptionalNumber(place.geo_score);
|
|
const baseScore = geoScore ?? (place.is_verified ? 86 : 72);
|
|
const verifiedBonus = offering?.is_verified ? 8 : 0;
|
|
|
|
return Math.max(45, Math.min(100, Math.round(baseScore + verifiedBonus)));
|
|
};
|
|
|
|
const publicPlaceToGeoSeekItems = (place: PublicPlaceRow): GeoSeekItem[] => {
|
|
const placeId = place.id || `place-${place.name || 'unknown'}`;
|
|
const category = getPublicPlaceCategory(place);
|
|
const distanceKm = toOptionalNumber(place.distance_km) ?? 0;
|
|
const rating = toOptionalNumber(place.rating_average) ?? 4.2;
|
|
const reviews = Math.round(toOptionalNumber(place.rating_count) ?? 0);
|
|
const latitude = toOptionalNumber(place.latitude) ?? backendDemoGeoSeekLocation.latitude;
|
|
const longitude = toOptionalNumber(place.longitude) ?? backendDemoGeoSeekLocation.longitude;
|
|
const offerings = Array.isArray(place.offerings) ? place.offerings : [];
|
|
const hasProducts = offerings.some((offering) => offering.offering_type === 'product');
|
|
const hasServices = offerings.some((offering) => offering.offering_type === 'service');
|
|
const open = place.live_status?.status !== 'closed';
|
|
const baseTags = createTags([
|
|
place.name,
|
|
category,
|
|
place.category?.slug,
|
|
place.short_description,
|
|
place.full_description,
|
|
offerings.map((offering) => offering.name).join(' '),
|
|
], ['backend', 'umkm', 'lokal']);
|
|
|
|
const businessItem: GeoSeekItem = {
|
|
id: `api-place-${placeId}`,
|
|
type: getPublicPlaceType(place),
|
|
name: place.name || 'Bisnis Lokal GeoSeek',
|
|
businessName: place.name || 'Bisnis Lokal GeoSeek',
|
|
category,
|
|
description: getPublicPlaceDescription(place),
|
|
address: getPublicPlaceAddress(place),
|
|
area: getPublicPlaceArea(place),
|
|
latitude,
|
|
longitude,
|
|
distanceKm,
|
|
price: toOptionalNumber(place.average_price),
|
|
rating,
|
|
reviews,
|
|
activityScore: getApiActivityScore(place),
|
|
tags: baseTags,
|
|
open,
|
|
promo: place.is_verified ? 'Bisnis terverifikasi di data GeoSeek' : undefined,
|
|
etaMinutes: hasServices ? Math.max(12, Math.min(60, Math.round(distanceKm * 5 + 15))) : undefined,
|
|
bookingAvailable: hasServices,
|
|
deliveryAvailable: hasProducts,
|
|
};
|
|
|
|
const offeringItems = offerings.map((offering, index): GeoSeekItem => {
|
|
const type = getOfferingType(offering);
|
|
const stock = getOfferingStock(offering);
|
|
const stockLabel = offering.stock_label || offering.stock_status || 'Info stok tersedia';
|
|
|
|
return {
|
|
id: `api-offering-${placeId}-${offering.id || index}`,
|
|
type,
|
|
name: offering.name || (type === 'product' ? 'Produk lokal' : 'Jasa lokal'),
|
|
businessName: businessItem.businessName,
|
|
category: `${typeLabels[type]} • ${category}`,
|
|
description: offering.description || getPublicPlaceDescription(place),
|
|
address: businessItem.address,
|
|
area: businessItem.area,
|
|
latitude,
|
|
longitude,
|
|
distanceKm,
|
|
price: toOptionalNumber(offering.price) ?? toOptionalNumber(place.average_price),
|
|
stock,
|
|
rating,
|
|
reviews,
|
|
activityScore: getApiActivityScore(place, offering),
|
|
tags: createTags([offering.name, offering.description, category, stockLabel, place.name], [type, 'backend', 'lokal']),
|
|
open,
|
|
promo: offering.stock_status === 'limited' ? `Stok terbatas: ${stockLabel}` : undefined,
|
|
etaMinutes: type === 'service' ? Math.max(12, Math.min(60, Math.round(distanceKm * 5 + 15))) : undefined,
|
|
bookingAvailable: type === 'service',
|
|
deliveryAvailable: type === 'product',
|
|
};
|
|
});
|
|
|
|
return [businessItem, ...offeringItems];
|
|
};
|
|
|
|
const normalizePublicPlaceItems = (rows: PublicPlaceRow[]) => {
|
|
const seen = new Set<string>();
|
|
|
|
return rows.flatMap(publicPlaceToGeoSeekItems).filter((item) => {
|
|
if (seen.has(item.id)) return false;
|
|
|
|
seen.add(item.id);
|
|
return true;
|
|
});
|
|
};
|
|
|
|
const moduleIconMap: Partial<Record<GeoSeekModuleKey, string>> = {
|
|
home: icon.mdiViewDashboardOutline,
|
|
search: icon.mdiMagnify,
|
|
map: icon.mdiMapSearchOutline,
|
|
products: icon.mdiShoppingOutline,
|
|
services: icon.mdiBriefcaseSearchOutline,
|
|
umkm: icon.mdiStore,
|
|
culinary: icon.mdiFoodForkDrink,
|
|
health: icon.mdiHospitalBoxOutline,
|
|
property: icon.mdiHomeCityOutline,
|
|
automotive: icon.mdiCarWrench,
|
|
tourism: icon.mdiBeach,
|
|
events: icon.mdiCalendar,
|
|
promos: icon.mdiTicketPercentOutline,
|
|
marketplace: icon.mdiCart,
|
|
booking: icon.mdiCalendar,
|
|
courier: icon.mdiTruck,
|
|
'business-dashboard': icon.mdiFinance,
|
|
profile: icon.mdiAccountCircle,
|
|
};
|
|
|
|
const moduleOptions = geoSeekModules.map((module) => ({
|
|
href: module.key === 'home' ? '/geoseek' : `/geoseek/${module.key}`,
|
|
key: module.key,
|
|
label: module.menuLabel,
|
|
iconPath: moduleIconMap[module.key] || icon.mdiTable,
|
|
}));
|
|
|
|
const quickPrompts = [
|
|
'pupuk organik stok dekat pasar',
|
|
'nasi goreng promo antar',
|
|
'servis ac booking cepat',
|
|
'tambal ban buka sekarang',
|
|
'apotek vitamin 24 jam',
|
|
'gratis ongkir radius 3 km',
|
|
];
|
|
|
|
const defaultQueryByModule: Partial<Record<GeoSeekModuleKey, string>> = {
|
|
home: 'pupuk organik',
|
|
search: 'pupuk organik',
|
|
map: '',
|
|
products: '',
|
|
services: '',
|
|
umkm: '',
|
|
culinary: '',
|
|
health: '',
|
|
property: '',
|
|
automotive: '',
|
|
tourism: '',
|
|
events: '',
|
|
promos: '',
|
|
marketplace: '',
|
|
booking: '',
|
|
courier: '',
|
|
'business-dashboard': '',
|
|
profile: '',
|
|
};
|
|
|
|
const getDefaultQuery = (moduleKey: GeoSeekModuleKey) => defaultQueryByModule[moduleKey] ?? '';
|
|
|
|
const localPublishedItemsKey = 'geoseek-pro-published-items';
|
|
const localActionHistoryKey = 'geoseek-pro-action-history';
|
|
|
|
const getActionTimestamp = () => new Intl.DateTimeFormat('id-ID', {
|
|
dateStyle: 'medium',
|
|
timeStyle: 'short',
|
|
}).format(new Date());
|
|
|
|
const getDraftItemType = (category: string, moduleKey: GeoSeekModuleKey): GeoSeekItemType => {
|
|
const normalizedCategory = category.toLowerCase();
|
|
|
|
if (moduleKey === 'map') return 'business';
|
|
if (moduleKey === 'products' || /produk|pertanian|tani|stok/.test(normalizedCategory)) return 'product';
|
|
if (moduleKey === 'culinary' || /kuliner|makan|minum|warung/.test(normalizedCategory)) return 'culinary';
|
|
if (moduleKey === 'health' || /kesehatan|obat|klinik|apotek/.test(normalizedCategory)) return 'health';
|
|
if (moduleKey === 'property' || /properti|ruko|rumah|tanah|kios/.test(normalizedCategory)) return 'property';
|
|
if (moduleKey === 'automotive' || /otomotif|bengkel|mobil|motor/.test(normalizedCategory)) return 'automotive';
|
|
if (moduleKey === 'tourism' || /wisata/.test(normalizedCategory)) return 'tourism';
|
|
if (moduleKey === 'events' || /event|bazar|agenda/.test(normalizedCategory)) return 'event';
|
|
if (moduleKey === 'promos' || /promo|diskon|voucher/.test(normalizedCategory)) return 'promo';
|
|
if (moduleKey === 'courier' || /kurir|antar|kirim|ongkir/.test(normalizedCategory)) return 'courier';
|
|
if (moduleKey === 'services' || moduleKey === 'booking' || /jasa|servis|booking/.test(normalizedCategory)) return 'service';
|
|
|
|
return 'business';
|
|
};
|
|
|
|
const createPublishedItem = (
|
|
input: string,
|
|
draft: GeoSeekSmartDraft,
|
|
moduleKey: GeoSeekModuleKey,
|
|
origin: { latitude: number; longitude: number } = defaultGeoSeekLocation,
|
|
): GeoSeekItem => {
|
|
const type = getDraftItemType(draft.category, moduleKey);
|
|
const distanceKm = Math.min(2.8, Number((0.7 + (draft.itemName.length % 12) / 10).toFixed(1)));
|
|
const supportsBooking = ['service', 'health', 'property', 'tourism', 'culinary'].includes(type);
|
|
const supportsDelivery = ['product', 'culinary', 'promo', 'courier'].includes(type);
|
|
|
|
return {
|
|
id: `gsk-local-${Date.now()}`,
|
|
type,
|
|
name: draft.itemName || draft.businessName,
|
|
businessName: draft.businessName,
|
|
category: draft.category,
|
|
description: `Item lokal dari Smart Input: ${input}`,
|
|
address: draft.location,
|
|
area: draft.location,
|
|
latitude: origin.latitude + distanceKm / 1000,
|
|
longitude: origin.longitude + distanceKm / 900,
|
|
distanceKm,
|
|
price: draft.price,
|
|
stock: draft.stock,
|
|
rating: 4.8,
|
|
reviews: 1,
|
|
activityScore: 95,
|
|
tags: Array.from(new Set([...draft.tags, 'smart-input', 'lokal'])),
|
|
open: draft.status !== 'Perlu review manual',
|
|
promo: draft.promo.startsWith('Promo aktif') ? draft.promo : undefined,
|
|
etaMinutes: supportsBooking || type === 'courier' ? 18 : undefined,
|
|
bookingAvailable: supportsBooking,
|
|
deliveryAvailable: supportsDelivery,
|
|
};
|
|
};
|
|
|
|
const sampleSmartInputs = [
|
|
'Warung Sari Rasa promo nasi goreng Rp15000 stok 42 dekat Alun-Alun buka sampai malam',
|
|
'Toko Tani Makmur menjual pupuk organik Rp38000 stok 64 dekat Pasar Tani gratis antar radius 2 km',
|
|
'Jaya Teknik buka jasa servis AC panggilan Rp85000 area Melati booking hari ini',
|
|
];
|
|
|
|
const getModule = (moduleKey?: string) => {
|
|
const matchedModule = geoSeekModules.find((module) => module.key === moduleKey);
|
|
|
|
return matchedModule || geoSeekModules[0];
|
|
};
|
|
|
|
const getActionSet = (moduleKey: GeoSeekModuleKey): QuickAction[] => {
|
|
const baseActions: QuickAction[] = [
|
|
{
|
|
label: 'Hitung GeoScore',
|
|
message: 'GeoScore otomatis dihitung ulang dari jarak, relevansi, rating, dan aktivitas.',
|
|
color: 'info',
|
|
iconPath: icon.mdiChartTimelineVariant,
|
|
},
|
|
{
|
|
label: 'Buat Rute',
|
|
message: 'Rute demo disiapkan dari lokasi pengguna ke hasil prioritas terdekat.',
|
|
color: 'success',
|
|
iconPath: icon.mdiMapMarker,
|
|
},
|
|
];
|
|
|
|
const moduleActions: Partial<Record<GeoSeekModuleKey, QuickAction[]>> = {
|
|
products: [
|
|
{
|
|
label: 'Auto Restock',
|
|
message: 'Sistem menandai produk stok rendah dan membuat rekomendasi restock.',
|
|
color: 'warning',
|
|
iconPath: icon.mdiPackageVariantClosed,
|
|
},
|
|
],
|
|
services: [
|
|
{
|
|
label: 'Booking Jasa',
|
|
message: 'Draft booking jasa dibuat dengan ETA dan penyedia terdekat.',
|
|
color: 'success',
|
|
iconPath: icon.mdiCalendar,
|
|
},
|
|
],
|
|
marketplace: [
|
|
{
|
|
label: 'Checkout Otomatis',
|
|
message: 'Keranjang, ongkir, diskon, dan metode bayar simulasi berhasil disiapkan.',
|
|
color: 'success',
|
|
iconPath: icon.mdiCart,
|
|
},
|
|
],
|
|
booking: [
|
|
{
|
|
label: 'Buat Jadwal',
|
|
message: 'Slot booking demo dibuat berdasarkan kategori, jarak, dan status buka.',
|
|
color: 'success',
|
|
iconPath: icon.mdiClockOutline,
|
|
},
|
|
],
|
|
courier: [
|
|
{
|
|
label: 'Dispatch Kurir',
|
|
message: 'Kurir terdekat dipilih otomatis dengan ETA dan estimasi ongkir.',
|
|
color: 'success',
|
|
iconPath: icon.mdiTruck,
|
|
},
|
|
],
|
|
promos: [
|
|
{
|
|
label: 'Sebar Promo',
|
|
message: 'Promo siap dikirim ke pengguna dalam radius yang dipilih.',
|
|
color: 'warning',
|
|
iconPath: icon.mdiTicketPercentOutline,
|
|
},
|
|
],
|
|
'business-dashboard': [
|
|
{
|
|
label: 'Buat Insight',
|
|
message: 'Insight stok, promo, booking, dan peluang bisnis berhasil diperbarui.',
|
|
color: 'contrast',
|
|
iconPath: icon.mdiFinance,
|
|
},
|
|
],
|
|
};
|
|
|
|
return [...baseActions, ...(moduleActions[moduleKey] || [])];
|
|
};
|
|
|
|
const getItemActionLabel = (moduleKey: GeoSeekModuleKey, item: GeoSeekScoredItem) => {
|
|
if (moduleKey === 'marketplace' || item.type === 'product' || item.type === 'culinary') return 'Tambah ke Keranjang';
|
|
if (moduleKey === 'booking' || item.bookingAvailable) return 'Booking Otomatis';
|
|
if (moduleKey === 'courier' || item.type === 'courier') return 'Pilih Kurir';
|
|
if (moduleKey === 'map') return 'Buka Rute';
|
|
if (moduleKey === 'promos' || item.type === 'promo') return 'Klaim Promo';
|
|
|
|
return 'Aktifkan Otomasi';
|
|
};
|
|
|
|
const getMapPosition = (item: GeoSeekScoredItem, index: number, origin: { latitude: number; longitude: number } = defaultGeoSeekLocation) => {
|
|
const left = Math.min(88, Math.max(8, 50 + (item.longitude - origin.longitude) * 1800 + index * 2));
|
|
const top = Math.min(82, Math.max(10, 50 + (item.latitude - origin.latitude) * -1800 + index * 3));
|
|
|
|
return { left: `${left}%`, top: `${top}%` };
|
|
};
|
|
|
|
const StatCard = ({ label, value, help, iconPath }: { label: string; value: string | number; help: string; iconPath: string }) => (
|
|
<CardBox className="bg-white/80 dark:bg-dark-900">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">{label}</p>
|
|
<p className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">{value}</p>
|
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">{help}</p>
|
|
</div>
|
|
<div className="rounded-2xl bg-blue-50 p-3 text-blue-600 dark:bg-blue-900/30 dark:text-blue-200">
|
|
<BaseIcon path={iconPath} size={24} />
|
|
</div>
|
|
</div>
|
|
</CardBox>
|
|
);
|
|
|
|
const ResultCard = ({
|
|
item,
|
|
moduleKey,
|
|
onAction,
|
|
}: {
|
|
item: GeoSeekScoredItem;
|
|
moduleKey: GeoSeekModuleKey;
|
|
onAction: (message: string) => void;
|
|
}) => (
|
|
<CardBox isHoverable className="border border-gray-100 bg-white/90 dark:border-dark-700 dark:bg-dark-900">
|
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="mb-3 flex flex-wrap items-center gap-2">
|
|
<span className="rounded-full bg-blue-100 px-3 py-1 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
|
|
{typeLabels[item.type]}
|
|
</span>
|
|
<span className="rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200">
|
|
GeoScore {item.geoScore}
|
|
</span>
|
|
<span className="rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-700 dark:bg-amber-900/40 dark:text-amber-200">
|
|
{item.distanceKm.toFixed(1)} km • {item.distancePriority.label}
|
|
</span>
|
|
{item.open && (
|
|
<span className="rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-700 dark:bg-green-900/40 dark:text-green-200">
|
|
Buka
|
|
</span>
|
|
)}
|
|
</div>
|
|
<h3 className="text-xl font-bold text-gray-900 dark:text-white">{item.name}</h3>
|
|
<p className="mt-1 text-sm font-medium text-gray-600 dark:text-gray-300">{item.businessName} • {item.category}</p>
|
|
<p className="mt-3 text-sm leading-6 text-gray-600 dark:text-gray-300">{item.description}</p>
|
|
<div className="mt-4 grid gap-3 text-sm text-gray-600 dark:text-gray-300 md:grid-cols-2">
|
|
<div className="rounded-xl bg-gray-50 p-3 dark:bg-dark-800">
|
|
<span className="font-semibold text-gray-900 dark:text-white">Lokasi:</span> {item.address}, {item.area}
|
|
</div>
|
|
<div className="rounded-xl bg-gray-50 p-3 dark:bg-dark-800">
|
|
<span className="font-semibold text-gray-900 dark:text-white">Rating:</span> {item.rating.toFixed(1)} / 5 • {item.reviews} ulasan
|
|
</div>
|
|
<div className="rounded-xl bg-gray-50 p-3 dark:bg-dark-800">
|
|
<span className="font-semibold text-gray-900 dark:text-white">Harga:</span> {currency(item.price)}
|
|
</div>
|
|
<div className="rounded-xl bg-gray-50 p-3 dark:bg-dark-800">
|
|
<span className="font-semibold text-gray-900 dark:text-white">Stok/ETA:</span>{' '}
|
|
{typeof item.stock === 'number' ? `${item.stock} tersedia` : item.etaMinutes ? `${item.etaMinutes} menit` : 'Tersedia sesuai permintaan'}
|
|
</div>
|
|
</div>
|
|
{item.promo && (
|
|
<div className="mt-4 rounded-2xl border border-orange-200 bg-orange-50 p-3 text-sm font-medium text-orange-700 dark:border-orange-900/60 dark:bg-orange-900/20 dark:text-orange-200">
|
|
{item.promo}
|
|
</div>
|
|
)}
|
|
<div className="mt-4 flex flex-wrap gap-2">
|
|
{item.tags.slice(0, 6).map((tag) => (
|
|
<span key={tag} className="rounded-full border border-gray-200 px-3 py-1 text-xs text-gray-500 dark:border-dark-700 dark:text-gray-300">
|
|
#{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="w-full rounded-2xl bg-slate-50 p-4 dark:bg-dark-800 lg:w-72">
|
|
<p className="text-sm font-bold text-gray-900 dark:text-white">Rincian GeoScore</p>
|
|
<div className="mt-3 space-y-2 text-xs text-gray-600 dark:text-gray-300">
|
|
<div className="flex justify-between gap-3"><span>Prioritas jarak</span><span className="text-right">{item.distancePriority.label}</span></div>
|
|
<div className="flex justify-between"><span>Jarak 60%</span><span>{item.distanceScore}</span></div>
|
|
<div className="flex justify-between"><span>Relevansi 20%</span><span>{item.relevanceScore}</span></div>
|
|
<div className="flex justify-between"><span>Rating 10%</span><span>{item.ratingScore}</span></div>
|
|
<div className="flex justify-between"><span>Aktivitas 10%</span><span>{item.activityContribution}</span></div>
|
|
</div>
|
|
<div className="mt-4 space-y-2">
|
|
{item.automationNotes.map((note) => (
|
|
<div key={note} className="rounded-xl bg-white px-3 py-2 text-xs text-gray-600 shadow-sm dark:bg-dark-900 dark:text-gray-300">
|
|
✓ {note}
|
|
</div>
|
|
))}
|
|
</div>
|
|
<BaseButton
|
|
color="info"
|
|
label={getItemActionLabel(moduleKey, item)}
|
|
icon={icon.mdiRobot}
|
|
className="mt-4 w-full"
|
|
onClick={() => onAction(`${getItemActionLabel(moduleKey, item)} untuk “${item.name}” berhasil dibuat sebagai simulasi otomatis.`)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</CardBox>
|
|
);
|
|
|
|
export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) {
|
|
const normalizedModuleKey = getModule(moduleKey).key;
|
|
const activeModule = getModule(normalizedModuleKey);
|
|
const [query, setQuery] = useState(getDefaultQuery(activeModule.key));
|
|
const [radiusKm, setRadiusKm] = useState(5);
|
|
const [smartInput, setSmartInput] = useState(sampleSmartInputs[0]);
|
|
const [actionStatus, setActionStatus] = useState('Sistem otomasi siap digunakan. Pilih aksi cepat atau jalankan Smart Input.');
|
|
const [publishedItems, setPublishedItems] = useState<GeoSeekItem[]>([]);
|
|
const [actionHistory, setActionHistory] = useState<GeoSeekActionHistory[]>([]);
|
|
const [isLocalStateReady, setIsLocalStateReady] = useState(false);
|
|
const [resolvedLocation, setResolvedLocation] = useState<GeoSeekResolvedLocation>(backendDemoGeoSeekLocation);
|
|
const [isResolvingLocation, setIsResolvingLocation] = useState(true);
|
|
const [locationStatus, setLocationStatus] = useState('Mengambil lokasi browser untuk pencarian radius GeoSeek...');
|
|
const [apiItems, setApiItems] = useState<GeoSeekItem[]>([]);
|
|
const [isLoadingPlaces, setIsLoadingPlaces] = useState(false);
|
|
const [apiError, setApiError] = useState('');
|
|
const [apiMeta, setApiMeta] = useState<GeoSeekApiMeta>(emptyApiMeta);
|
|
const [searchRequestVersion, setSearchRequestVersion] = useState(0);
|
|
const { 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');
|
|
};
|
|
|
|
useEffect(() => {
|
|
try {
|
|
const storedItems = window.localStorage.getItem(localPublishedItemsKey);
|
|
const storedHistory = window.localStorage.getItem(localActionHistoryKey);
|
|
|
|
if (storedItems) {
|
|
const parsedItems = JSON.parse(storedItems);
|
|
|
|
if (Array.isArray(parsedItems)) {
|
|
setPublishedItems(parsedItems);
|
|
} else {
|
|
console.error('Data item lokal GeoSeek bukan array:', parsedItems);
|
|
setActionStatus('Data item lokal tidak valid, data demo tetap digunakan.');
|
|
}
|
|
}
|
|
|
|
if (storedHistory) {
|
|
const parsedHistory = JSON.parse(storedHistory);
|
|
|
|
if (Array.isArray(parsedHistory)) {
|
|
setActionHistory(parsedHistory);
|
|
} else {
|
|
console.error('Data riwayat GeoSeek bukan array:', parsedHistory);
|
|
setActionStatus('Data riwayat lokal tidak valid, riwayat baru tetap bisa dibuat.');
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Gagal memuat data lokal GeoSeek:', error);
|
|
setActionStatus('Gagal memuat data lokal GeoSeek. Data demo tetap bisa digunakan.');
|
|
} finally {
|
|
setIsLocalStateReady(true);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!isLocalStateReady) return;
|
|
|
|
try {
|
|
window.localStorage.setItem(localPublishedItemsKey, JSON.stringify(publishedItems));
|
|
window.localStorage.setItem(localActionHistoryKey, JSON.stringify(actionHistory));
|
|
} catch (error) {
|
|
console.error('Gagal menyimpan data lokal GeoSeek:', error);
|
|
setActionStatus('Gagal menyimpan data lokal GeoSeek di browser.');
|
|
}
|
|
}, [actionHistory, isLocalStateReady, publishedItems]);
|
|
|
|
useEffect(() => {
|
|
requestCurrentLocation();
|
|
}, [requestCurrentLocation]);
|
|
|
|
useEffect(() => {
|
|
let isActive = true;
|
|
|
|
const timeoutId = window.setTimeout(async () => {
|
|
setIsLoadingPlaces(true);
|
|
setApiError('');
|
|
|
|
try {
|
|
const apiQuery = getApiQuery(query, activeModule.key);
|
|
const response = await axios.get<PublicPlacesResponse>('/public/places', {
|
|
params: {
|
|
q: apiQuery,
|
|
lat: resolvedLocation.latitude,
|
|
lng: resolvedLocation.longitude,
|
|
radiusKm,
|
|
limit: 36,
|
|
},
|
|
});
|
|
|
|
if (!isActive) return;
|
|
|
|
const rows = Array.isArray(response.data?.rows) ? response.data.rows : [];
|
|
const externalSourceErrors = Array.isArray(response.data?.external_source_errors)
|
|
? response.data.external_source_errors
|
|
: [];
|
|
const radiusZoneParts = [response.data?.radius_zone?.label, response.data?.radius_zone?.range].filter(Boolean);
|
|
|
|
setApiItems(normalizePublicPlaceItems(rows));
|
|
setApiMeta({
|
|
count: Number(response.data?.count || rows.length),
|
|
totalCandidates: Number(response.data?.total_candidates || rows.length),
|
|
radiusZoneLabel: radiusZoneParts.join(' • ') || 'Radius aktif',
|
|
externalSources: Array.isArray(response.data?.external_sources) ? response.data.external_sources : [],
|
|
externalSourceErrors,
|
|
filteredByRadius: Boolean(response.data?.filtered_by_radius),
|
|
expandedForNearest: Boolean(response.data?.expanded_for_nearest),
|
|
});
|
|
setApiError(externalSourceErrors.length ? externalSourceErrors.join(' ') : '');
|
|
} catch (error) {
|
|
if (!isActive) return;
|
|
|
|
console.error('Gagal memuat data backend GeoSeek Pro:', error);
|
|
setApiItems([]);
|
|
setApiMeta(emptyApiMeta);
|
|
setApiError('Data backend GeoSeek belum bisa dimuat. UI memakai fallback demo lokal.');
|
|
} finally {
|
|
if (isActive) {
|
|
setIsLoadingPlaces(false);
|
|
}
|
|
}
|
|
}, 350);
|
|
|
|
return () => {
|
|
isActive = false;
|
|
window.clearTimeout(timeoutId);
|
|
};
|
|
}, [activeModule.key, query, radiusKm, resolvedLocation.latitude, resolvedLocation.longitude, searchRequestVersion]);
|
|
|
|
useEffect(() => {
|
|
setQuery(getDefaultQuery(activeModule.key));
|
|
setRadiusKm(5);
|
|
setActionStatus(`Menu ${activeModule.menuLabel} siap. GeoSeek memuat data backend nyata dan memakai fallback demo jika data sekitar belum tersedia.`);
|
|
}, [activeModule.key, activeModule.menuLabel]);
|
|
|
|
const profileName = [currentUser?.firstName, currentUser?.lastName].filter(Boolean).join(' ') || currentUser?.email || 'Pengguna GeoSeek';
|
|
|
|
return (
|
|
<>
|
|
<Head>
|
|
<title>{getPageTitle(activeModule.title)}</title>
|
|
</Head>
|
|
<SectionMain>
|
|
<SectionTitleLineWithButton icon={moduleIconMap[activeModule.key] || icon.mdiViewDashboardOutline} title={activeModule.title} main>
|
|
<BaseButton href="/dashboard" color="whiteDark" label="Dashboard" icon={icon.mdiViewDashboardOutline} />
|
|
</SectionTitleLineWithButton>
|
|
|
|
<div className="mb-6 grid gap-4 lg:grid-cols-[1.35fr_0.65fr]">
|
|
<CardBox className="overflow-hidden bg-gradient-to-br from-blue-600 via-cyan-600 to-emerald-500 text-white">
|
|
<div className="relative">
|
|
<div className="absolute -right-10 -top-10 h-36 w-36 rounded-full bg-white/10 blur-2xl" />
|
|
<div className="absolute -bottom-12 left-20 h-40 w-40 rounded-full bg-lime-200/10 blur-2xl" />
|
|
<p className="text-sm font-semibold uppercase tracking-[0.35em] text-white/70">GeoSeek Pro Automation</p>
|
|
<h2 className="mt-4 max-w-3xl text-4xl font-black leading-tight md:text-5xl">{activeModule.subtitle}</h2>
|
|
<p className="mt-5 max-w-2xl text-sm leading-6 text-white/85">
|
|
Lokasi aktif: {resolvedLocation.label}. GeoSeek kini mengambil data nyata dari backend `/public/places`,
|
|
memakai GPS browser bila diizinkan, dan tetap punya fallback demo agar pencarian radius, produk, jasa, UMKM, peta, booking, kurir, dan insight bisnis selalu bisa dicoba.
|
|
</p>
|
|
<div className="mt-6 flex flex-wrap gap-3">
|
|
{quickPrompts.slice(0, 4).map((prompt) => (
|
|
<button
|
|
key={prompt}
|
|
type="button"
|
|
className="rounded-full bg-white/15 px-4 py-2 text-sm font-semibold text-white backdrop-blur transition hover:bg-white/25"
|
|
onClick={() => setQuery(prompt)}
|
|
>
|
|
{prompt}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</CardBox>
|
|
|
|
<CardBox className="bg-white/90 dark:bg-dark-900">
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">Profil aktif</p>
|
|
<h3 className="mt-2 text-2xl font-bold text-gray-900 dark:text-white">{profileName}</h3>
|
|
<div className="mt-4 space-y-3 text-sm text-gray-600 dark:text-gray-300">
|
|
<div className="rounded-2xl bg-blue-50 p-4 dark:bg-blue-900/20">
|
|
Radius aktif <strong>{radiusKm} km</strong> ({activeDistancePriority.label}) dari {resolvedLocation.label}
|
|
<p className="mt-1 text-xs">{resolvedLocation.source === 'browser' ? 'Sumber: GPS browser' : 'Sumber: fallback backend demo'}{resolvedLocation.accuracyMeters ? ` • akurasi ±${resolvedLocation.accuracyMeters} m` : ''}</p>
|
|
</div>
|
|
<div className="rounded-2xl bg-emerald-50 p-4 dark:bg-emerald-900/20">
|
|
{results.length} hasil untuk modul <strong>{activeModule.menuLabel}</strong>
|
|
<p className="mt-1 text-xs">{apiItems.length ? `${apiItems.length} item backend dinormalisasi` : 'Fallback demo lokal aktif'}</p>
|
|
</div>
|
|
<div className="rounded-2xl bg-purple-50 p-4 dark:bg-purple-900/20">
|
|
Kandidat backend <strong>{apiMeta.totalCandidates}</strong> • draft lokal <strong>{publishedItems.length}</strong>
|
|
<p className="mt-1 text-xs">{apiMeta.radiusZoneLabel}</p>
|
|
</div>
|
|
<div className="rounded-2xl bg-orange-50 p-4 dark:bg-orange-900/20">
|
|
Status: {isLoadingPlaces ? 'Memuat data backend GeoSeek...' : actionStatus}
|
|
</div>
|
|
</div>
|
|
</CardBox>
|
|
</div>
|
|
|
|
<CardBox className="mb-6 bg-white/90 dark:bg-dark-900">
|
|
<div className="mb-4 flex flex-wrap gap-2">
|
|
{moduleOptions.map((option) => {
|
|
const isActive = option.key === activeModule.key;
|
|
|
|
return (
|
|
<Link
|
|
key={option.key}
|
|
href={option.href}
|
|
className={`inline-flex items-center gap-2 rounded-full border px-3 py-2 text-sm font-semibold transition ${
|
|
isActive
|
|
? 'border-blue-600 bg-blue-600 text-white shadow-lg shadow-blue-600/20'
|
|
: 'border-gray-200 bg-white text-gray-600 hover:border-blue-300 hover:text-blue-600 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-300'
|
|
}`}
|
|
>
|
|
<BaseIcon path={option.iconPath} size={16} />
|
|
{option.label}
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="grid gap-4 xl:grid-cols-[1fr_180px_170px_170px]">
|
|
<label className="block">
|
|
<span className="mb-2 block text-sm font-bold text-gray-700 dark:text-gray-200">Pencarian otomatis</span>
|
|
<input
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
placeholder={activeModule.searchPlaceholder}
|
|
className="h-12 w-full rounded-2xl border border-gray-200 bg-white px-4 text-gray-900 outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-200 dark:border-dark-700 dark:bg-dark-800 dark:text-white"
|
|
/>
|
|
</label>
|
|
<label className="block">
|
|
<span className="mb-2 block text-sm font-bold text-gray-700 dark:text-gray-200">Radius</span>
|
|
<select
|
|
value={radiusKm}
|
|
onChange={(event) => setRadiusKm(Number(event.target.value))}
|
|
className="h-12 w-full rounded-2xl border border-gray-200 bg-white px-4 text-gray-900 outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-200 dark:border-dark-700 dark:bg-dark-800 dark:text-white"
|
|
>
|
|
{radiusOptions.map((radius) => {
|
|
const priority = getDistancePriority(radius);
|
|
|
|
return (
|
|
<option key={radius} value={radius}>
|
|
{radius} km — {priority.range} • {priority.label}
|
|
</option>
|
|
);
|
|
})}
|
|
</select>
|
|
</label>
|
|
<div className="flex items-end">
|
|
<BaseButton
|
|
color="info"
|
|
label={isLoadingPlaces ? 'Memuat' : 'Jalankan'}
|
|
icon={icon.mdiMagnify}
|
|
className="h-12 w-full"
|
|
onClick={() => {
|
|
setSearchRequestVersion((version) => version + 1);
|
|
recordAction(`Pencarian “${query || getApiQuery(query, activeModule.key)}” dikirim ke backend GeoSeek dan diurutkan dengan GeoScore.`, 'Pencarian');
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="flex items-end">
|
|
<BaseButton
|
|
color="success"
|
|
label={isResolvingLocation ? 'GPS...' : 'Gunakan GPS'}
|
|
icon={icon.mdiCrosshairsGps}
|
|
className="h-12 w-full"
|
|
disabled={isResolvingLocation}
|
|
onClick={requestCurrentLocation}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 grid gap-3 text-sm md:grid-cols-3">
|
|
<div className="rounded-2xl bg-slate-50 p-4 text-gray-600 dark:bg-dark-800 dark:text-gray-300">
|
|
<strong className="text-gray-900 dark:text-white">Lokasi:</strong> {locationStatus}
|
|
</div>
|
|
<div className="rounded-2xl bg-slate-50 p-4 text-gray-600 dark:bg-dark-800 dark:text-gray-300">
|
|
<strong className="text-gray-900 dark:text-white">Data:</strong> {isLoadingPlaces ? 'memuat backend...' : apiItems.length ? `${apiItems.length} item backend aktif` : 'fallback demo aktif'}
|
|
</div>
|
|
<div className="rounded-2xl bg-slate-50 p-4 text-gray-600 dark:bg-dark-800 dark:text-gray-300">
|
|
<strong className="text-gray-900 dark:text-white">Zona:</strong> {apiMeta.radiusZoneLabel}
|
|
</div>
|
|
</div>
|
|
{(apiError || apiMeta.externalSources.length > 0 || apiMeta.expandedForNearest) && (
|
|
<div className="mt-4 rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800 dark:border-amber-900/50 dark:bg-amber-900/20 dark:text-amber-200">
|
|
{apiError || `Sumber eksternal aktif: ${apiMeta.externalSources.join(', ')}`}
|
|
{apiMeta.expandedForNearest ? ' Radius diperluas otomatis untuk mencari hasil terdekat.' : ''}
|
|
</div>
|
|
)}
|
|
</CardBox>
|
|
|
|
<div className="mb-6 grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
|
<StatCard label="Hasil Prioritas" value={results.length} help="Diurutkan dari jarak terdekat" iconPath={icon.mdiMagnify} />
|
|
<StatCard label="Rata-rata GeoScore" value={insight.averageGeoScore} help="Dari modul aktif" iconPath={icon.mdiChartTimelineVariant} />
|
|
<StatCard label="Promo Aktif" value={insight.promos} help="Siap disebar otomatis" iconPath={icon.mdiTicketPercentOutline} />
|
|
<StatCard label="Booking/Kurir" value={insight.bookingReady + insight.deliveryReady} help="Aksi cepat tersedia" iconPath={icon.mdiCalendar} />
|
|
<StatCard label="Data Backend" value={apiItems.length || 'Demo'} help={apiItems.length ? 'Places + offerings nyata' : 'Fallback lokal'} iconPath={icon.mdiDatabaseSearch} />
|
|
</div>
|
|
|
|
<div className="mb-6 grid gap-6 xl:grid-cols-[1fr_0.9fr]">
|
|
<CardBox className="bg-white/90 dark:bg-dark-900">
|
|
<div className="mb-4 flex items-start justify-between gap-4">
|
|
<div>
|
|
<h3 className="text-xl font-bold text-gray-900 dark:text-white">{activeModule.automationTitle}</h3>
|
|
<p className="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-300">{activeModule.automationDescription}</p>
|
|
</div>
|
|
<div className="rounded-2xl bg-blue-50 p-3 text-blue-600 dark:bg-blue-900/30 dark:text-blue-200">
|
|
<BaseIcon path={icon.mdiRobot} size={24} />
|
|
</div>
|
|
</div>
|
|
<BaseButtons type="justify-start" mb="mb-0" className="gap-2" classAddon="mb-2 mr-2">
|
|
{actionSet.map((action) => (
|
|
<BaseButton
|
|
key={action.label}
|
|
color={action.color}
|
|
label={action.label}
|
|
icon={action.iconPath}
|
|
onClick={() => recordAction(action.message, action.label)}
|
|
/>
|
|
))}
|
|
</BaseButtons>
|
|
<div className="mt-4 rounded-2xl bg-slate-50 p-4 text-sm text-gray-600 dark:bg-dark-800 dark:text-gray-300">
|
|
<strong className="text-gray-900 dark:text-white">Status otomasi:</strong> {actionStatus}
|
|
</div>
|
|
<div className="mt-4 rounded-2xl border border-gray-200 p-4 dark:border-dark-700">
|
|
<div className="mb-3 flex items-center justify-between gap-3">
|
|
<div>
|
|
<p className="text-sm font-bold text-gray-900 dark:text-white">Riwayat aksi lokal</p>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">Order, booking, dispatch, dan publikasi tersimpan di browser.</p>
|
|
</div>
|
|
<BaseButton
|
|
color="whiteDark"
|
|
label="Reset"
|
|
icon={icon.mdiDeleteOutline}
|
|
small
|
|
disabled={!actionHistory.length && !publishedItems.length}
|
|
onClick={() => {
|
|
setActionHistory([]);
|
|
setPublishedItems([]);
|
|
setActionStatus('Data lokal GeoSeek dibersihkan. Data demo bawaan tetap tersedia.');
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{actionHistory.length ? actionHistory.slice(0, 4).map((history) => (
|
|
<div key={history.id} className="rounded-xl bg-gray-50 p-3 text-xs text-gray-600 dark:bg-dark-800 dark:text-gray-300">
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<span className="font-bold text-gray-900 dark:text-white">{history.label}</span>
|
|
<span>{history.createdAt}</span>
|
|
</div>
|
|
<p className="mt-1">{history.message}</p>
|
|
</div>
|
|
)) : (
|
|
<p className="rounded-xl bg-gray-50 p-3 text-xs text-gray-500 dark:bg-dark-800 dark:text-gray-400">
|
|
Belum ada aksi. Jalankan pencarian, publish draft, checkout, booking, atau kurir.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardBox>
|
|
|
|
<CardBox className="bg-white/90 dark:bg-dark-900">
|
|
<div className="mb-4 flex items-start justify-between gap-4">
|
|
<div>
|
|
<h3 className="text-xl font-bold text-gray-900 dark:text-white">Smart Input / Auto Input</h3>
|
|
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300">Tulis laporan bebas, sistem membuat draft data terstruktur.</p>
|
|
</div>
|
|
<BaseIcon path={icon.mdiRobot} size={28} className="text-emerald-500" />
|
|
</div>
|
|
<textarea
|
|
value={smartInput}
|
|
onChange={(event) => setSmartInput(event.target.value)}
|
|
className="h-28 w-full rounded-2xl border border-gray-200 bg-white p-4 text-sm text-gray-900 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-200 dark:border-dark-700 dark:bg-dark-800 dark:text-white"
|
|
/>
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
{sampleSmartInputs.map((sample, index) => (
|
|
<button
|
|
key={sample}
|
|
type="button"
|
|
className="rounded-full bg-gray-100 px-3 py-1.5 text-xs font-semibold text-gray-600 hover:bg-emerald-100 hover:text-emerald-700 dark:bg-dark-800 dark:text-gray-300"
|
|
onClick={() => setSmartInput(sample)}
|
|
>
|
|
Contoh {index + 1}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="mt-4 grid gap-3 text-sm md:grid-cols-2">
|
|
<div className="rounded-2xl bg-emerald-50 p-4 dark:bg-emerald-900/20">
|
|
<p className="text-xs font-bold uppercase tracking-wider text-emerald-700 dark:text-emerald-200">Bisnis</p>
|
|
<p className="mt-1 font-semibold text-gray-900 dark:text-white">{smartDraft.businessName}</p>
|
|
</div>
|
|
<div className="rounded-2xl bg-blue-50 p-4 dark:bg-blue-900/20">
|
|
<p className="text-xs font-bold uppercase tracking-wider text-blue-700 dark:text-blue-200">Item</p>
|
|
<p className="mt-1 font-semibold text-gray-900 dark:text-white">{smartDraft.itemName}</p>
|
|
</div>
|
|
<div className="rounded-2xl bg-purple-50 p-4 dark:bg-purple-900/20">
|
|
<p className="text-xs font-bold uppercase tracking-wider text-purple-700 dark:text-purple-200">Kategori</p>
|
|
<p className="mt-1 font-semibold text-gray-900 dark:text-white">{smartDraft.category}</p>
|
|
</div>
|
|
<div className="rounded-2xl bg-orange-50 p-4 dark:bg-orange-900/20">
|
|
<p className="text-xs font-bold uppercase tracking-wider text-orange-700 dark:text-orange-200">Harga & Stok</p>
|
|
<p className="mt-1 font-semibold text-gray-900 dark:text-white">{currency(smartDraft.price)} • {smartDraft.stock ?? 'stok belum diisi'}</p>
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 rounded-2xl border border-gray-200 p-4 text-sm text-gray-600 dark:border-dark-700 dark:text-gray-300">
|
|
<p><strong>Lokasi:</strong> {smartDraft.location}</p>
|
|
<p><strong>Promo:</strong> {smartDraft.promo}</p>
|
|
<p><strong>Status:</strong> {smartDraft.status}</p>
|
|
<p><strong>Tag:</strong> {smartDraft.tags.map((tag) => `#${tag}`).join(' ')}</p>
|
|
</div>
|
|
<BaseButton
|
|
color="success"
|
|
label="Publikasikan Draft Simulasi"
|
|
icon={icon.mdiStore}
|
|
className="mt-4 w-full"
|
|
onClick={publishSmartDraft}
|
|
/>
|
|
</CardBox>
|
|
</div>
|
|
|
|
<div className="mb-6 grid gap-6 xl:grid-cols-[0.9fr_1.1fr]">
|
|
<CardBox className="bg-white/90 dark:bg-dark-900">
|
|
<div className="mb-4 flex items-center justify-between gap-4">
|
|
<div>
|
|
<h3 className="text-xl font-bold text-gray-900 dark:text-white">Peta Radius Simulasi</h3>
|
|
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300">Pin otomatis dari hasil prioritas modul {activeModule.menuLabel}.</p>
|
|
</div>
|
|
<BaseIcon path={icon.mdiCrosshairsGps} size={28} className="text-blue-500" />
|
|
</div>
|
|
<div className="relative h-80 overflow-hidden rounded-3xl bg-gradient-to-br from-sky-100 via-emerald-100 to-lime-100 dark:from-slate-900 dark:via-cyan-950 dark:to-emerald-950">
|
|
<div className="absolute inset-6 rounded-full border-2 border-dashed border-blue-400/50" />
|
|
<div className="absolute left-1/2 top-1/2 z-10 flex -translate-x-1/2 -translate-y-1/2 items-center gap-2 rounded-full bg-blue-600 px-4 py-2 text-xs font-bold text-white shadow-xl">
|
|
<BaseIcon path={icon.mdiCrosshairsGps} size={16} />
|
|
Anda
|
|
</div>
|
|
{mapItems.map((item, index) => (
|
|
<button
|
|
key={item.id}
|
|
type="button"
|
|
style={getMapPosition(item, index, resolvedLocation)}
|
|
className="absolute z-20 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white p-2 text-blue-600 shadow-lg ring-4 ring-blue-500/20 transition hover:scale-110 dark:bg-dark-800 dark:text-blue-200"
|
|
title={item.name}
|
|
onClick={() => recordAction(`Pin “${item.name}” dipilih. Rute demo ${item.distanceKm.toFixed(1)} km siap dibuka.`, 'Rute Pin')}
|
|
>
|
|
<BaseIcon path={moduleIconMap[activeModule.key] || icon.mdiMapMarker} size={18} />
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="mt-4 grid gap-2 text-sm text-gray-600 dark:text-gray-300">
|
|
{mapItems.slice(0, 4).map((item) => (
|
|
<div key={item.id} className="flex items-center justify-between rounded-2xl bg-gray-50 p-3 dark:bg-dark-800">
|
|
<span>{item.name}</span>
|
|
<span className="font-bold text-blue-600 dark:text-blue-300">{item.distanceKm.toFixed(1)} km</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardBox>
|
|
|
|
<CardBox className="bg-white/90 dark:bg-dark-900">
|
|
<div className="mb-4 flex items-center justify-between gap-4">
|
|
<div>
|
|
<h3 className="text-xl font-bold text-gray-900 dark:text-white">Marketplace, Booking & Kurir</h3>
|
|
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300">Simulasi transaksi lokal dengan keranjang, pembayaran, dan dispatch kurir.</p>
|
|
</div>
|
|
<BaseIcon path={icon.mdiCart} size={28} className="text-emerald-500" />
|
|
</div>
|
|
<div className="space-y-3">
|
|
{cartItems.length ? cartItems.map((item) => (
|
|
<div key={item.id} className="flex items-center justify-between gap-4 rounded-2xl bg-slate-50 p-4 dark:bg-dark-800">
|
|
<div>
|
|
<p className="font-semibold text-gray-900 dark:text-white">{item.name}</p>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">{item.businessName} • Qty {item.quantity}</p>
|
|
</div>
|
|
<p className="font-bold text-gray-900 dark:text-white">{currency(item.price * item.quantity)}</p>
|
|
</div>
|
|
)) : (
|
|
<div className="rounded-2xl bg-slate-50 p-4 text-sm text-gray-500 dark:bg-dark-800 dark:text-gray-300">
|
|
Belum ada item berharga dalam hasil. Coba perluas radius atau ubah kata kunci.
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="mt-4 rounded-2xl border border-gray-200 p-4 text-sm dark:border-dark-700">
|
|
<div className="flex justify-between"><span>Subtotal</span><strong>{currency(checkout.subtotal)}</strong></div>
|
|
<div className="mt-2 flex justify-between"><span>Ongkir lokal</span><strong>{currency(checkout.deliveryFee)}</strong></div>
|
|
<div className="mt-2 flex justify-between"><span>Biaya platform</span><strong>{currency(checkout.platformFee)}</strong></div>
|
|
<div className="mt-2 flex justify-between text-emerald-600"><span>Diskon otomatis</span><strong>-{currency(checkout.discount)}</strong></div>
|
|
<div className="mt-3 flex justify-between border-t border-gray-200 pt-3 text-lg dark:border-dark-700"><span>Total</span><strong>{currency(checkout.total)}</strong></div>
|
|
<p className="mt-3 text-xs text-gray-500 dark:text-gray-400">Metode bayar: {checkout.paymentMethods.join(', ')}</p>
|
|
</div>
|
|
<div className="mt-4 grid gap-3 md:grid-cols-3">
|
|
<BaseButton color="info" label="QRIS" icon={icon.mdiCash} onClick={() => recordAction('Pembayaran QRIS simulasi siap dibuat.', 'QRIS')} />
|
|
<BaseButton color="success" label="Booking" icon={icon.mdiCalendar} onClick={() => recordAction('Draft booking otomatis berhasil dibuat.', 'Booking')} />
|
|
<BaseButton color="warning" label="Kurir" icon={icon.mdiTruck} onClick={() => recordAction('Kurir lokal terdekat berhasil dipilih.', 'Kurir')} />
|
|
</div>
|
|
</CardBox>
|
|
</div>
|
|
|
|
<CardBox className="mb-6 bg-white/90 dark:bg-dark-900">
|
|
<div className="mb-4 flex items-center justify-between gap-4">
|
|
<div>
|
|
<h3 className="text-xl font-bold text-gray-900 dark:text-white">Insight Dashboard Bisnis</h3>
|
|
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300">Ringkasan otomatis untuk UMKM dan operator GeoSeek.</p>
|
|
</div>
|
|
<BaseIcon path={icon.mdiFinance} size={28} className="text-purple-500" />
|
|
</div>
|
|
<div className="grid gap-4 md:grid-cols-3 xl:grid-cols-6">
|
|
<div className="rounded-2xl bg-blue-50 p-4 dark:bg-blue-900/20"><p className="text-xs text-blue-700 dark:text-blue-200">Produk</p><p className="text-2xl font-bold">{insight.products}</p></div>
|
|
<div className="rounded-2xl bg-cyan-50 p-4 dark:bg-cyan-900/20"><p className="text-xs text-cyan-700 dark:text-cyan-200">Jasa</p><p className="text-2xl font-bold">{insight.services}</p></div>
|
|
<div className="rounded-2xl bg-orange-50 p-4 dark:bg-orange-900/20"><p className="text-xs text-orange-700 dark:text-orange-200">Promo</p><p className="text-2xl font-bold">{insight.promos}</p></div>
|
|
<div className="rounded-2xl bg-red-50 p-4 dark:bg-red-900/20"><p className="text-xs text-red-700 dark:text-red-200">Stok Rendah</p><p className="text-2xl font-bold">{insight.lowStock}</p></div>
|
|
<div className="rounded-2xl bg-emerald-50 p-4 dark:bg-emerald-900/20"><p className="text-xs text-emerald-700 dark:text-emerald-200">Booking</p><p className="text-2xl font-bold">{insight.bookingReady}</p></div>
|
|
<div className="rounded-2xl bg-lime-50 p-4 dark:bg-lime-900/20"><p className="text-xs text-lime-700 dark:text-lime-200">Kurir</p><p className="text-2xl font-bold">{insight.deliveryReady}</p></div>
|
|
</div>
|
|
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
|
{insight.recommendations.map((recommendation) => (
|
|
<div key={recommendation} className="rounded-2xl border border-gray-200 p-4 text-sm text-gray-600 dark:border-dark-700 dark:text-gray-300">
|
|
✓ {recommendation}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardBox>
|
|
|
|
<div className="mb-4 flex items-center justify-between gap-4">
|
|
<div>
|
|
<h3 className="text-2xl font-bold text-gray-900 dark:text-white">Hasil {activeModule.menuLabel}</h3>
|
|
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
|
{activeModule.emptyHint} Sumber data: {hasBackendItemsForModule ? 'backend GeoSeek + draft lokal' : 'fallback demo lokal'}.
|
|
</p>
|
|
</div>
|
|
<span className="rounded-full bg-gray-100 px-4 py-2 text-sm font-bold text-gray-600 dark:bg-dark-800 dark:text-gray-300">
|
|
{results.length} hasil
|
|
</span>
|
|
</div>
|
|
|
|
<div className="space-y-5">
|
|
{isLoadingPlaces ? (
|
|
<CardBox className="bg-white/90 text-center dark:bg-dark-900">
|
|
<BaseIcon path={icon.mdiDatabaseSearch} size={42} className="mx-auto text-blue-500" />
|
|
<h3 className="mt-4 text-xl font-bold text-gray-900 dark:text-white">Memuat data GeoSeek nyata...</h3>
|
|
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">Mengambil places dan offerings dari backend berdasarkan lokasi, radius, dan modul aktif.</p>
|
|
</CardBox>
|
|
) : results.length ? results.map((item) => (
|
|
<ResultCard key={item.id} item={item} moduleKey={activeModule.key} onAction={(message) => recordAction(message, 'Aksi Item')} />
|
|
)) : (
|
|
<CardBox className="bg-white/90 text-center dark:bg-dark-900">
|
|
<BaseIcon path={icon.mdiMagnify} size={42} className="mx-auto text-gray-400" />
|
|
<h3 className="mt-4 text-xl font-bold text-gray-900 dark:text-white">Tidak ada hasil di radius ini</h3>
|
|
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">Coba ubah kata kunci, izinkan GPS, atau perluas radius pencarian.</p>
|
|
<BaseButton color="info" label="Perluas ke 20 km" className="mt-4" onClick={() => setRadiusKm(20)} />
|
|
</CardBox>
|
|
)}
|
|
</div>
|
|
</SectionMain>
|
|
</>
|
|
);
|
|
}
|