Autosave: 20260617-162107
This commit is contained in:
parent
e84665755d
commit
6da4cb9f42
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,4 @@
|
||||
import React, { ReactElement, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import React, { ReactElement, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import Head from 'next/head'
|
||||
import Link from 'next/link'
|
||||
import axios from 'axios'
|
||||
@ -113,6 +113,7 @@ type PublicPlace = {
|
||||
radius_zone?: RadiusZone
|
||||
live_status?: LiveStatus
|
||||
ai_recommendation?: AiRecommendation
|
||||
external_source?: string
|
||||
offerings?: Offering[]
|
||||
offerings_summary?: OfferingSummary
|
||||
category?: Category | null
|
||||
@ -149,13 +150,12 @@ type SearchMeta = {
|
||||
geo_score_formula: Record<string, number>
|
||||
trending?: TrendingMeta
|
||||
recommendation_engine?: string
|
||||
expanded_for_nearest?: boolean
|
||||
external_sources?: string[]
|
||||
external_source_errors?: string[]
|
||||
}
|
||||
|
||||
const DEFAULT_LOCATION: LocationState = {
|
||||
lat: 0.5071,
|
||||
lng: 101.4478,
|
||||
label: 'Pekanbaru, Riau',
|
||||
}
|
||||
const LOCATION_REQUIRED_MESSAGE = 'Aktifkan izin lokasi browser agar GeoSeek memakai lokasi Anda, bukan titik default.'
|
||||
|
||||
const DEFAULT_RADIUS_KM = 5
|
||||
const GLOBAL_RADIUS_KM = 20038
|
||||
@ -169,7 +169,6 @@ const fallbackRadiusZones: RadiusZone[] = [
|
||||
{ value: GLOBAL_RADIUS_KM, label: 'Global Zone', range: '500+ Km' },
|
||||
]
|
||||
|
||||
const categoryFallbacks = ['Toko Interior', 'Cafe Melayu', 'Bengkel Mobil']
|
||||
|
||||
const priceLabels: Record<string, string> = {
|
||||
budget: 'Ramah kantong',
|
||||
@ -178,6 +177,152 @@ const priceLabels: Record<string, string> = {
|
||||
unknown: 'Tanya harga',
|
||||
}
|
||||
|
||||
const mainAdvantages = [
|
||||
{
|
||||
title: 'Crowd-Sourced Data',
|
||||
icon: mdiDatabaseSearchOutline,
|
||||
description: 'Data dan kondisi jalan diperkuat dari laporan pengguna secara langsung, sehingga informasi lapangan terasa lebih hidup dan aktual.',
|
||||
bullets: ['Macet', 'Kecelakaan', 'Polisi', 'Jalan rusak', 'Jalan ditutup'],
|
||||
},
|
||||
{
|
||||
title: 'Rute Dinamis',
|
||||
icon: mdiNavigationVariantOutline,
|
||||
description: 'Jika ada kemacetan atau hambatan, sistem dapat membantu mencari jalur alternatif tercepat agar perjalanan tetap efisien.',
|
||||
bullets: ['Deteksi hambatan', 'Alternatif rute', 'Prioritas waktu tempuh'],
|
||||
},
|
||||
{
|
||||
title: 'Update Sangat Cepat',
|
||||
icon: mdiClockOutline,
|
||||
description: 'Informasi lalu lintas diperbarui cepat dari aktivitas dan laporan pengguna, sering kali lebih responsif untuk kebutuhan perjalanan harian.',
|
||||
bullets: ['Real-time intent', 'Laporan pengguna', 'Kondisi terbaru'],
|
||||
},
|
||||
{
|
||||
title: 'Cocok untuk Pengemudi',
|
||||
icon: mdiPackageVariantClosed,
|
||||
description: 'Dirancang untuk membantu mobilitas pengemudi mobil, taksi, ojek online, dan kurir yang membutuhkan keputusan rute cepat.',
|
||||
bullets: ['Mobil', 'Taksi', 'Ojek online', 'Kurir'],
|
||||
},
|
||||
]
|
||||
|
||||
const comparisonAdvantages = [
|
||||
{
|
||||
title: 'Mesin Pencari Hyperlocal',
|
||||
icon: mdiMapMarkerRadiusOutline,
|
||||
description: 'GeoSeek fokus pada produk, jasa, UMKM, dan transaksi lokal, bukan sekadar peta dan petunjuk arah.',
|
||||
points: ['Produk lokal', 'Jasa lokal', 'UMKM sekitar', 'Transaksi langsung'],
|
||||
},
|
||||
{
|
||||
title: 'Prioritas Jarak Nyata',
|
||||
icon: mdiMapMarkerDistance,
|
||||
description: 'Hasil pencarian diprioritaskan berdasarkan radius terdekat dari pengguna agar bisnis sekitar lebih mudah ditemukan.',
|
||||
points: ['Radius terdekat', 'Jarak aktual', 'Peluang bisnis sekitar', 'Relevansi lokasi'],
|
||||
},
|
||||
{
|
||||
title: 'Katalog Produk Lengkap',
|
||||
icon: mdiPackageVariantClosed,
|
||||
description: 'Pengguna bisa melihat produk, harga, stok real-time, promo, diskon, dan ketersediaan barang dari toko terdekat.',
|
||||
points: ['Harga', 'Stok real-time', 'Promo', 'Diskon'],
|
||||
},
|
||||
{
|
||||
title: 'Direktori Jasa Terverifikasi',
|
||||
icon: mdiShieldCheckOutline,
|
||||
description: 'GeoSeek mendukung pencarian tukang, servis AC, bengkel, dokter, notaris, guru privat, dan layanan lain dengan data yang jelas.',
|
||||
points: ['Harga jasa', 'Jadwal', 'Rating', 'Status tersedia'],
|
||||
},
|
||||
{
|
||||
title: 'Cakupan Sampai RT/RW',
|
||||
icon: mdiCrosshairsGps,
|
||||
description: 'Pencarian dapat diarahkan sampai tingkat RT, RW, dusun, desa, kelurahan, dan kecamatan untuk menemukan usaha rumahan.',
|
||||
points: ['RT/RW', 'Dusun/desa', 'Kelurahan', 'Kecamatan'],
|
||||
},
|
||||
{
|
||||
title: 'Transaksi dalam Satu Platform',
|
||||
icon: mdiCash,
|
||||
description: 'GeoSeek dapat menggabungkan marketplace lokal, QRIS, transfer bank, e-wallet, booking, reservasi, kurir, dan pengiriman.',
|
||||
points: ['Marketplace', 'QRIS', 'Booking', 'Kurir lokal'],
|
||||
},
|
||||
{
|
||||
title: 'Dashboard Bisnis Lokal',
|
||||
icon: mdiChartTimelineVariant,
|
||||
description: 'Pelaku usaha dapat memantau pencarian, klik, panggilan, kunjungan, serta tren produk dan jasa untuk mengambil keputusan.',
|
||||
points: ['Jumlah pencarian', 'Klik & panggilan', 'Kunjungan', 'Tren produk/jasa'],
|
||||
},
|
||||
]
|
||||
|
||||
const smartInputOptions = [
|
||||
{
|
||||
id: 'traffic',
|
||||
label: 'Laporan Jalan',
|
||||
title: 'Macet, kecelakaan, polisi, jalan rusak, banjir, atau penutupan jalan',
|
||||
outputTitle: 'Draft laporan jalan',
|
||||
placeholder: 'Contoh: Jalan Ahmad Yani macet karena truk mogok, lajur kiri tertutup, arah pusat kota padat.',
|
||||
tags: ['Deteksi lokasi', 'Kategori otomatis', 'Validasi warga', 'Update rute'],
|
||||
},
|
||||
{
|
||||
id: 'business',
|
||||
label: 'Data UMKM',
|
||||
title: 'Input toko, warung, bengkel, jasa rumahan, atau usaha lingkungan',
|
||||
outputTitle: 'Draft profil usaha',
|
||||
placeholder: 'Contoh: Warung Bu Sari jual beras 5 kg, telur, minyak, buka 06.00-21.00, bisa QRIS dan antar sekitar RT.',
|
||||
tags: ['Profil otomatis', 'Jam buka', 'Radius layanan', 'Kontak cepat'],
|
||||
},
|
||||
{
|
||||
id: 'stock',
|
||||
label: 'Stok & Promo',
|
||||
title: 'Update harga, stok real-time, diskon, produk ready, atau barang habis',
|
||||
outputTitle: 'Draft update stok',
|
||||
placeholder: 'Contoh: Stok gas 3 kg tersedia 20 tabung, harga Rp22.000, promo air galon beli 2 gratis ongkir radius 2 km.',
|
||||
tags: ['Harga', 'Stok real-time', 'Promo', 'Notifikasi pembeli'],
|
||||
},
|
||||
{
|
||||
id: 'service',
|
||||
label: 'Jasa & Booking',
|
||||
title: 'Input jadwal tukang, servis AC, bengkel, dokter, guru privat, atau reservasi',
|
||||
outputTitle: 'Draft layanan jasa',
|
||||
placeholder: 'Contoh: Servis AC tersedia hari ini jam 15.00-18.00, estimasi mulai Rp75.000, teknisi bersertifikat, area 5 km.',
|
||||
tags: ['Jadwal', 'Harga jasa', 'Booking online', 'Status tersedia'],
|
||||
},
|
||||
]
|
||||
|
||||
const automationInputFeatures = [
|
||||
{
|
||||
title: 'Input Cepat Anti Ribet',
|
||||
icon: mdiRobotHappyOutline,
|
||||
description: 'Pengguna atau pelaku usaha cukup menulis laporan singkat. GeoSeek mengubahnya menjadi data terstruktur yang siap dicari.',
|
||||
points: ['Teks bebas', 'Template cepat', 'Auto kategori', 'Auto radius'],
|
||||
},
|
||||
{
|
||||
title: 'Auto Update Stok & Status',
|
||||
icon: mdiPackageVariantClosed,
|
||||
description: 'Stok produk, promo, jam buka, dan status layanan dapat diperbarui cepat agar hasil pencarian selalu relevan.',
|
||||
points: ['Ready stock', 'Barang habis', 'Promo aktif', 'Buka/tutup'],
|
||||
},
|
||||
{
|
||||
title: 'Laporan Jalan Sekali Tap',
|
||||
icon: mdiNavigationVariantOutline,
|
||||
description: 'Seperti kekuatan laporan komunitas, tetapi diperluas untuk rute, hambatan, bisnis, produk, dan jasa sekitar.',
|
||||
points: ['Macet', 'Kecelakaan', 'Banjir', 'Jalan ditutup'],
|
||||
},
|
||||
{
|
||||
title: 'Validasi Komunitas + Skor',
|
||||
icon: mdiShieldCheckOutline,
|
||||
description: 'Input bisa diberi sinyal kepercayaan dari pengguna sekitar, rating, aktivitas terbaru, dan histori interaksi.',
|
||||
points: ['Verifikasi warga', 'Anti spam', 'GeoScore', 'Riwayat update'],
|
||||
},
|
||||
{
|
||||
title: 'Booking, Order, dan Kurir Otomatis',
|
||||
icon: mdiCash,
|
||||
description: 'Dari pencarian, pengguna dapat lanjut ke booking, pesanan, pembayaran digital, dan pengiriman lokal tanpa pindah aplikasi.',
|
||||
points: ['Booking', 'QRIS', 'E-wallet', 'Kurir lokal'],
|
||||
},
|
||||
{
|
||||
title: 'Notifikasi Peluang Lokal',
|
||||
icon: mdiTrendingUp,
|
||||
description: 'GeoSeek dapat memberi insight otomatis untuk pemilik usaha ketika ada tren pencarian, produk laris, atau kebutuhan jasa naik.',
|
||||
points: ['Tren pencarian', 'Produk laris', 'Jasa dicari', 'Saran update'],
|
||||
},
|
||||
]
|
||||
|
||||
const indexedLevels = [
|
||||
{
|
||||
level: 'Level 1',
|
||||
@ -227,8 +372,8 @@ const topLocationKeywords: KeywordChip[] = [
|
||||
{ label: 'Lokasi Terdekat', query: 'lokasi terdekat', score: '9.9/10' },
|
||||
{ label: 'Buka Sekarang', query: 'buka sekarang', score: '9.8/10' },
|
||||
{ label: '24 Jam', query: '24 jam', score: '9.8/10' },
|
||||
{ label: 'Di Kota', query: 'di Pekanbaru', score: '9.7/10' },
|
||||
{ label: 'Di Kecamatan', query: 'di kecamatan Sukajadi', score: '9.7/10' },
|
||||
{ label: 'Di Kota Saya', query: 'di kota saya', score: '9.7/10' },
|
||||
{ label: 'Di Kecamatan Saya', query: 'di kecamatan saya', score: '9.7/10' },
|
||||
{ label: 'Dalam Radius 5 Km', query: 'dalam radius 5 km', score: '9.6/10', radiusKm: 5 },
|
||||
]
|
||||
|
||||
@ -296,8 +441,8 @@ const requiredLocationKeywords: KeywordChip[] = [
|
||||
{ label: 'Radius 1 Km', query: 'radius 1 km', radiusKm: 1 },
|
||||
{ label: 'Radius 5 Km', query: 'radius 5 km', radiusKm: 5 },
|
||||
{ label: 'Radius 10 Km', query: 'radius 10 km', radiusKm: 10 },
|
||||
{ label: 'Dalam Kota', query: 'dalam kota Pekanbaru' },
|
||||
{ label: 'Dalam Kecamatan', query: 'dalam kecamatan Sukajadi' },
|
||||
{ label: 'Dalam Kota Saya', query: 'dalam kota saya' },
|
||||
{ label: 'Dalam Kecamatan Saya', query: 'dalam kecamatan saya' },
|
||||
{ label: 'Dalam Kelurahan', query: 'dalam kelurahan' },
|
||||
{ label: 'Dalam Desa', query: 'dalam desa' },
|
||||
{ label: 'Populer', query: 'populer' },
|
||||
@ -336,7 +481,7 @@ const topTrafficKeywords: KeywordChip[] = [
|
||||
const hyperlocalPatternLevels: PatternLevel[] = [
|
||||
{
|
||||
level: 'Level 1',
|
||||
formula: '[Kategori] + Terdekat',
|
||||
formula: '[Jenis usaha] + Terdekat',
|
||||
examples: [
|
||||
{ label: 'Hotel terdekat', query: 'hotel terdekat' },
|
||||
{ label: 'ATM terdekat', query: 'ATM terdekat' },
|
||||
@ -345,7 +490,7 @@ const hyperlocalPatternLevels: PatternLevel[] = [
|
||||
},
|
||||
{
|
||||
level: 'Level 2',
|
||||
formula: '[Kategori] + Dekat Saya',
|
||||
formula: '[Jenis usaha] + Dekat Saya',
|
||||
examples: [
|
||||
{ label: 'Restoran dekat saya', query: 'restoran dekat saya', category: 'cafe-melayu' },
|
||||
{ label: 'Apotek dekat saya', query: 'apotek dekat saya', category: 'toko-herbal-kesehatan' },
|
||||
@ -353,7 +498,7 @@ const hyperlocalPatternLevels: PatternLevel[] = [
|
||||
},
|
||||
{
|
||||
level: 'Level 3',
|
||||
formula: '[Kategori] + Kecamatan',
|
||||
formula: '[Jenis usaha] + Kecamatan',
|
||||
examples: [
|
||||
{ label: 'Klinik Cibinong', query: 'klinik Cibinong', category: 'toko-herbal-kesehatan' },
|
||||
{ label: 'Bengkel Citeureup', query: 'bengkel Citeureup', category: 'bengkel-mobil' },
|
||||
@ -361,7 +506,7 @@ const hyperlocalPatternLevels: PatternLevel[] = [
|
||||
},
|
||||
{
|
||||
level: 'Level 4',
|
||||
formula: '[Kategori] + Kota',
|
||||
formula: '[Jenis usaha] + Kota',
|
||||
examples: [
|
||||
{ label: 'Hotel Bogor', query: 'hotel Bogor' },
|
||||
{ label: 'Wisata Bandung', query: 'wisata Bandung' },
|
||||
@ -369,7 +514,7 @@ const hyperlocalPatternLevels: PatternLevel[] = [
|
||||
},
|
||||
{
|
||||
level: 'Level 5',
|
||||
formula: '[Kategori] + Provinsi',
|
||||
formula: '[Jenis usaha] + Provinsi',
|
||||
examples: [
|
||||
{ label: 'Wisata Jawa Barat', query: 'wisata Jawa Barat' },
|
||||
{ label: 'Hotel Bali', query: 'hotel Bali' },
|
||||
@ -407,9 +552,7 @@ const scoreLabels: Record<keyof GeoScoreBreakdown, string> = {
|
||||
|
||||
export default function Starter() {
|
||||
const [query, setQuery] = useState('')
|
||||
const [category, setCategory] = useState('')
|
||||
const [radiusKm, setRadiusKm] = useState(DEFAULT_RADIUS_KM)
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [places, setPlaces] = useState<PublicPlace[]>([])
|
||||
const [searchMeta, setSearchMeta] = useState<SearchMeta>({
|
||||
count: 0,
|
||||
@ -422,25 +565,28 @@ export default function Starter() {
|
||||
geo_score_formula: { relevance: 40, distance: 25, reputation: 15, activity: 10, interaction: 10 },
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [categoriesLoading, setCategoriesLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [location, setLocation] = useState<LocationState>(DEFAULT_LOCATION)
|
||||
const [locationStatus, setLocationStatus] = useState('')
|
||||
|
||||
const activeCategoryName = useMemo(() => {
|
||||
return categories.find((item) => item.slug === category || item.id === category)?.name || 'Semua kategori'
|
||||
}, [categories, category])
|
||||
const [location, setLocation] = useState<LocationState | null>(null)
|
||||
const [locationReady, setLocationReady] = useState(false)
|
||||
const [locationStatus, setLocationStatus] = useState('Mengambil lokasi Anda...')
|
||||
const [smartInputType, setSmartInputType] = useState(smartInputOptions[0].id)
|
||||
const [smartInputText, setSmartInputText] = useState(smartInputOptions[0].placeholder)
|
||||
const hasRequestedLocation = useRef(false)
|
||||
|
||||
const radiusZones = searchMeta.radius_zones?.length ? searchMeta.radius_zones : fallbackRadiusZones
|
||||
const selectedZone = searchMeta.radius_zone || radiusZones.find((item) => item.value === radiusKm) || fallbackRadiusZones[1]
|
||||
const featuredPlaces = places.slice(0, 3)
|
||||
const categoryChips = categories.length ? categories : categoryFallbacks.map((name) => ({ id: name, name, slug: name }))
|
||||
const formulaEntries = Object.entries(searchMeta.geo_score_formula || {})
|
||||
const hasActiveLocation = locationReady && Boolean(location)
|
||||
const openNow = searchMeta.trending?.live?.open_now || places.filter((place) => place.live_status?.status === 'open').length
|
||||
const selectedSmartInput = smartInputOptions.find((item) => item.id === smartInputType) || smartInputOptions[0]
|
||||
const smartInputWords = smartInputText.trim().split(/\s+/).filter(Boolean)
|
||||
const smartInputSummary = smartInputWords.length
|
||||
? `${selectedSmartInput.outputTitle}: ${smartInputWords.slice(0, 9).join(' ')}${smartInputWords.length > 9 ? '...' : ''}`
|
||||
: 'Isi input untuk membuat draft otomatis'
|
||||
|
||||
const fetchPlaces = useCallback(async (
|
||||
nextQuery: string,
|
||||
nextCategory: string,
|
||||
nextLocation: LocationState,
|
||||
nextRadiusKm: number,
|
||||
) => {
|
||||
@ -451,7 +597,6 @@ export default function Starter() {
|
||||
const response = await axios.get('/public/places', {
|
||||
params: {
|
||||
q: nextQuery,
|
||||
category: nextCategory,
|
||||
lat: nextLocation.lat,
|
||||
lng: nextLocation.lng,
|
||||
radiusKm: nextRadiusKm,
|
||||
@ -471,6 +616,9 @@ export default function Starter() {
|
||||
geo_score_formula: response.data?.geo_score_formula || { relevance: 40, distance: 25, reputation: 15, activity: 10, interaction: 10 },
|
||||
trending: response.data?.trending,
|
||||
recommendation_engine: response.data?.recommendation_engine,
|
||||
expanded_for_nearest: Boolean(response.data?.expanded_for_nearest),
|
||||
external_sources: Array.isArray(response.data?.external_sources) ? response.data.external_sources : [],
|
||||
external_source_errors: Array.isArray(response.data?.external_source_errors) ? response.data.external_source_errors : [],
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Gagal memuat GeoSeek public search', err)
|
||||
@ -480,43 +628,22 @@ export default function Starter() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
setCategoriesLoading(true)
|
||||
const requestCurrentLocation = useCallback(() => {
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const response = await axios.get('/public/places/categories')
|
||||
setCategories(Array.isArray(response.data?.rows) ? response.data.rows : [])
|
||||
} catch (err) {
|
||||
console.error('Gagal memuat kategori publik GeoSeek', err)
|
||||
setCategories([])
|
||||
} finally {
|
||||
setCategoriesLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadCategories()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = window.setTimeout(() => {
|
||||
fetchPlaces(query, category, location, radiusKm)
|
||||
}, 420)
|
||||
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [query, category, radiusKm, location, fetchPlaces])
|
||||
|
||||
const handleSearch = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
await fetchPlaces(query, category, location, radiusKm)
|
||||
}
|
||||
|
||||
const useCurrentLocation = () => {
|
||||
if (!navigator.geolocation) {
|
||||
setLocationStatus('Browser tidak mendukung lokasi')
|
||||
if (typeof navigator === 'undefined' || !navigator.geolocation) {
|
||||
setLocation(null)
|
||||
setLocationReady(false)
|
||||
setPlaces([])
|
||||
setLoading(false)
|
||||
setLocationStatus('Browser tidak mendukung lokasi. Gunakan browser/perangkat yang mengizinkan akses lokasi.')
|
||||
setError(LOCATION_REQUIRED_MESSAGE)
|
||||
return
|
||||
}
|
||||
|
||||
setLocation(null)
|
||||
setLocationReady(false)
|
||||
setPlaces([])
|
||||
setLocationStatus('Mengambil lokasi Anda...')
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
@ -526,23 +653,59 @@ export default function Starter() {
|
||||
label: 'Lokasi saya',
|
||||
}
|
||||
setLocation(nextLocation)
|
||||
setLocationReady(true)
|
||||
setLocationStatus('Lokasi aktif: lokasi saya')
|
||||
setError('')
|
||||
},
|
||||
(geoError) => {
|
||||
console.error('Izin lokasi ditolak atau gagal', geoError)
|
||||
setLocationStatus('Lokasi tidak diizinkan')
|
||||
setLocation(null)
|
||||
setLocationReady(false)
|
||||
setPlaces([])
|
||||
setLoading(false)
|
||||
setLocationStatus('Lokasi belum aktif. Izinkan akses lokasi browser, lalu klik “Gunakan lokasi saya”.')
|
||||
setError(LOCATION_REQUIRED_MESSAGE)
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 8000 },
|
||||
{ enableHighAccuracy: true, timeout: 8000, maximumAge: 60000 },
|
||||
)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (hasRequestedLocation.current) {
|
||||
return
|
||||
}
|
||||
|
||||
hasRequestedLocation.current = true
|
||||
requestCurrentLocation()
|
||||
}, [requestCurrentLocation])
|
||||
|
||||
useEffect(() => {
|
||||
if (!locationReady || !location) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const timeout = window.setTimeout(() => {
|
||||
fetchPlaces(query, location, radiusKm)
|
||||
}, 420)
|
||||
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [query, radiusKm, location, locationReady, fetchPlaces])
|
||||
|
||||
const handleSearch = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
|
||||
if (!locationReady || !location) {
|
||||
setError(LOCATION_REQUIRED_MESSAGE)
|
||||
setLocationStatus('Lokasi belum aktif. Klik “Gunakan lokasi saya” dan izinkan akses lokasi browser.')
|
||||
return
|
||||
}
|
||||
|
||||
await fetchPlaces(query, location, radiusKm)
|
||||
}
|
||||
|
||||
const applyKeyword = (keyword: KeywordChip) => {
|
||||
setQuery(keyword.query)
|
||||
|
||||
if (keyword.category !== undefined) {
|
||||
setCategory(keyword.category)
|
||||
}
|
||||
|
||||
if (keyword.radiusKm) {
|
||||
setRadiusKm(keyword.radiusKm)
|
||||
}
|
||||
@ -552,6 +715,20 @@ export default function Starter() {
|
||||
}, 160)
|
||||
}
|
||||
|
||||
const buildPlaceHref = (place: PublicPlace) => {
|
||||
if (place.external_source === 'openstreetmap' && place.google_maps_url) {
|
||||
return place.google_maps_url
|
||||
}
|
||||
|
||||
if (!location) {
|
||||
return '#search'
|
||||
}
|
||||
|
||||
return `/tempat/${place.id}?lat=${location.lat}&lng=${location.lng}&radiusKm=${radiusKm}&q=${encodeURIComponent(query)}`
|
||||
}
|
||||
|
||||
const shouldOpenExternalPlace = (place: PublicPlace) => place.external_source === 'openstreetmap' && Boolean(place.google_maps_url)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
@ -578,6 +755,9 @@ export default function Starter() {
|
||||
</Link>
|
||||
<div className='hidden items-center gap-6 text-sm text-white/80 md:flex'>
|
||||
<a href='#search' className='hover:text-white'>Live Search</a>
|
||||
<a href='#advantages' className='hover:text-white'>Keunggulan</a>
|
||||
<a href='#comparison' className='hover:text-white'>GeoSeek vs Maps</a>
|
||||
<a href='#automation' className='hover:text-white'>Auto Input</a>
|
||||
<a href='#keywords' className='hover:text-white'>Kata Kunci</a>
|
||||
<a href='#geoscore' className='hover:text-white'>GeoScore</a>
|
||||
<a href='#trending' className='hover:text-white'>Trending</a>
|
||||
@ -600,44 +780,32 @@ export default function Starter() {
|
||||
</p>
|
||||
|
||||
<form id='search' onSubmit={handleSearch} className='mt-8 rounded-[2rem] border border-white/15 bg-white p-3 text-[#17231B] shadow-2xl shadow-black/20'>
|
||||
<div className='grid gap-3 lg:grid-cols-[1.2fr_0.78fr_auto]'>
|
||||
<div className='grid gap-3 lg:grid-cols-[1fr_auto]'>
|
||||
<label className='relative block'>
|
||||
<span className='sr-only'>Kata kunci pencarian</span>
|
||||
<BaseIcon path={mdiMagnify} className='pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-[#6A7A70]' size={22} />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder='Cari “nasi goreng”, “jasa AC”, “servis oli”, “kitchen set”...'
|
||||
placeholder='Cari “SPBU terdekat”, “nasi goreng dekat saya”, “jasa AC”, “servis oli”...'
|
||||
className='h-14 w-full rounded-2xl border border-[#E0D6C3] bg-[#FCFAF5] pl-12 pr-4 text-sm outline-none transition focus:border-[#2CA58D] focus:ring-4 focus:ring-[#2CA58D]/15'
|
||||
/>
|
||||
</label>
|
||||
<label className='block'>
|
||||
<span className='sr-only'>Kategori</span>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(event) => setCategory(event.target.value)}
|
||||
className='h-14 w-full rounded-2xl border border-[#E0D6C3] bg-[#FCFAF5] px-4 text-sm outline-none transition focus:border-[#2CA58D] focus:ring-4 focus:ring-[#2CA58D]/15'
|
||||
>
|
||||
<option value=''>Semua kategori</option>
|
||||
{categories.map((item) => (
|
||||
<option key={item.id} value={item.slug || item.id}>{item.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type='submit'
|
||||
className='h-14 rounded-2xl bg-[#F26A4B] px-7 font-black text-white shadow-lg shadow-[#F26A4B]/30 transition hover:bg-[#E85A39] focus:outline-none focus:ring-4 focus:ring-[#F26A4B]/30'
|
||||
disabled={!hasActiveLocation || loading}
|
||||
className='h-14 rounded-2xl bg-[#F26A4B] px-7 font-black text-white shadow-lg shadow-[#F26A4B]/30 transition hover:bg-[#E85A39] focus:outline-none focus:ring-4 focus:ring-[#F26A4B]/30 disabled:cursor-not-allowed disabled:opacity-60'
|
||||
>
|
||||
Cari
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='mt-4 flex flex-col gap-3 px-2 pb-1 text-sm text-[#6A7A70] lg:flex-row lg:items-center lg:justify-between'>
|
||||
<button type='button' onClick={useCurrentLocation} className='inline-flex items-center gap-2 font-semibold text-[#087F6D] hover:text-[#073B3A]'>
|
||||
<button type='button' onClick={requestCurrentLocation} className='inline-flex items-center gap-2 font-semibold text-[#087F6D] hover:text-[#073B3A]'>
|
||||
<BaseIcon path={mdiCrosshairsGps} size={18} />
|
||||
Gunakan lokasi saya
|
||||
</button>
|
||||
<span>{locationStatus ? `${locationStatus} • ` : ''}Filter: {activeCategoryName} • Live search aktif</span>
|
||||
<span>{locationStatus || 'Lokasi belum aktif'} • Kategori dikosongkan • {hasActiveLocation ? 'Live search aktif' : 'Menunggu izin lokasi'}</span>
|
||||
</div>
|
||||
|
||||
<div className='mt-4 flex flex-wrap gap-2 px-2 pb-2'>
|
||||
@ -655,24 +823,8 @@ export default function Starter() {
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className='mt-6 flex flex-wrap gap-3'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setCategory('')}
|
||||
className={`rounded-full px-4 py-2 text-sm font-bold transition ${category === '' ? 'bg-white text-[#073B3A]' : 'bg-white/10 text-white/80 hover:bg-white/20'}`}
|
||||
>
|
||||
Semua
|
||||
</button>
|
||||
{categoryChips.map((item) => (
|
||||
<button
|
||||
type='button'
|
||||
key={item.id}
|
||||
onClick={() => setCategory(item.slug || item.id)}
|
||||
className={`rounded-full px-4 py-2 text-sm font-bold transition ${category === (item.slug || item.id) ? 'bg-[#F2A541] text-[#073B3A]' : 'bg-white/10 text-white/80 hover:bg-white/20'}`}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
))}
|
||||
<div className='mt-6 inline-flex rounded-full border border-white/15 bg-white/10 px-4 py-2 text-sm font-bold text-white/80'>
|
||||
Kategori pencarian dikosongkan otomatis — hasil ditentukan dari kata kunci dan jarak.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -684,7 +836,7 @@ export default function Starter() {
|
||||
<p className='text-xs font-black uppercase tracking-[0.25em] text-[#087F6D]'>Live Nearby Map</p>
|
||||
<h2 className='text-2xl font-black'>{selectedZone.label}</h2>
|
||||
</div>
|
||||
<span className='rounded-full bg-[#073B3A] px-3 py-1 text-xs font-bold text-white'>{location.label}</span>
|
||||
<span className='rounded-full bg-[#073B3A] px-3 py-1 text-xs font-bold text-white'>{location?.label || 'Lokasi saya belum aktif'}</span>
|
||||
</div>
|
||||
<div className='relative h-80 overflow-hidden rounded-[1.6rem] bg-[#D9E7D8]'>
|
||||
<div className='absolute inset-0 opacity-40 [background-image:linear-gradient(#ffffff_1px,transparent_1px),linear-gradient(90deg,#ffffff_1px,transparent_1px)] [background-size:38px_38px]' />
|
||||
@ -697,7 +849,9 @@ export default function Starter() {
|
||||
{featuredPlaces.map((place, index) => (
|
||||
<Link
|
||||
key={place.id}
|
||||
href={`/tempat/${place.id}?lat=${location.lat}&lng=${location.lng}&radiusKm=${radiusKm}&q=${encodeURIComponent(query)}`}
|
||||
href={buildPlaceHref(place)}
|
||||
target={shouldOpenExternalPlace(place) ? '_blank' : undefined}
|
||||
rel={shouldOpenExternalPlace(place) ? 'noreferrer' : undefined}
|
||||
className={`absolute rounded-2xl bg-white px-3 py-2 text-xs font-bold shadow-xl transition hover:-translate-y-1 ${index === 0 ? 'left-8 top-10' : index === 1 ? 'right-7 top-28' : 'bottom-10 left-20'}`}
|
||||
>
|
||||
<span className='block max-w-[140px] truncate'>{place.name}</span>
|
||||
@ -717,16 +871,196 @@ export default function Starter() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id='advantages' className='mx-auto max-w-7xl px-6 py-16 lg:px-8'>
|
||||
<div className='mb-8 flex flex-col gap-4 md:flex-row md:items-end md:justify-between'>
|
||||
<div className='max-w-3xl'>
|
||||
<p className='text-sm font-black uppercase tracking-[0.25em] text-[#087F6D]'>Keunggulan Utama</p>
|
||||
<h2 className='mt-2 text-4xl font-black tracking-tight'>Navigasi hyperlocal yang cepat, dinamis, dan berbasis pengguna.</h2>
|
||||
<p className='mt-4 leading-7 text-[#5D6B62]'>GeoSeek menonjolkan kekuatan data komunitas, update cepat, dan rekomendasi rute yang relevan untuk kebutuhan mobilitas harian.</p>
|
||||
</div>
|
||||
<div className='rounded-[2rem] bg-[#073B3A] p-5 text-sm font-bold text-white shadow-xl shadow-[#073B3A]/10'>
|
||||
<span className='block text-3xl font-black text-[#F2A541]'>{mainAdvantages.length}</span>
|
||||
kelebihan utama untuk pengguna jalan
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-5 md:grid-cols-2 xl:grid-cols-4'>
|
||||
{mainAdvantages.map((advantage) => (
|
||||
<article key={advantage.title} className='flex h-full flex-col rounded-[2rem] bg-white p-6 shadow-sm transition hover:-translate-y-1 hover:shadow-2xl hover:shadow-[#073B3A]/10'>
|
||||
<div className='inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-[#E8F6F1] text-[#087F6D]'>
|
||||
<BaseIcon path={advantage.icon} size={26} />
|
||||
</div>
|
||||
<h3 className='mt-5 text-2xl font-black'>{advantage.title}</h3>
|
||||
<p className='mt-3 flex-1 text-sm leading-6 text-[#5D6B62]'>{advantage.description}</p>
|
||||
<div className='mt-5 flex flex-wrap gap-2'>
|
||||
{advantage.bullets.map((bullet) => (
|
||||
<span key={bullet} className='rounded-full bg-[#F7F2E8] px-3 py-1 text-xs font-black text-[#073B3A]'>{bullet}</span>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id='comparison' className='bg-white/55 py-16'>
|
||||
<div className='mx-auto max-w-7xl px-6 lg:px-8'>
|
||||
<div className='grid gap-8 lg:grid-cols-[0.82fr_1.18fr] lg:items-start'>
|
||||
<div className='rounded-[2.5rem] bg-[#073B3A] p-8 text-white shadow-2xl shadow-[#073B3A]/15'>
|
||||
<p className='text-sm font-black uppercase tracking-[0.25em] text-[#F2A541]'>GeoSeek vs Google Maps</p>
|
||||
<h2 className='mt-3 text-4xl font-black tracking-tight'>Bukan hanya mencari lokasi, tapi menemukan produk, jasa, dan transaksi lokal terdekat.</h2>
|
||||
<p className='mt-5 leading-7 text-white/75'>
|
||||
Google Maps kuat untuk peta, navigasi, dan informasi bisnis. GeoSeek melengkapinya sebagai mesin pencari hyperlocal yang memprioritaskan jarak nyata, katalog produk, jasa terverifikasi, dan aksi transaksi dalam satu ekosistem lokal.
|
||||
</p>
|
||||
|
||||
<div className='mt-7 grid gap-3 sm:grid-cols-2 lg:grid-cols-1'>
|
||||
<div className='rounded-[1.5rem] bg-white/10 p-4'>
|
||||
<span className='block text-3xl font-black text-[#F2A541]'>RT/RW</span>
|
||||
<span className='text-sm font-bold text-white/75'>cakupan pencarian lingkungan mikro</span>
|
||||
</div>
|
||||
<div className='rounded-[1.5rem] bg-white/10 p-4'>
|
||||
<span className='block text-3xl font-black text-[#F2A541]'>Produk + Jasa</span>
|
||||
<span className='text-sm font-bold text-white/75'>dengan harga, stok, jadwal, dan status tersedia</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-5 md:grid-cols-2'>
|
||||
{comparisonAdvantages.map((advantage, index) => (
|
||||
<article
|
||||
key={advantage.title}
|
||||
className={`rounded-[2rem] p-6 shadow-sm transition hover:-translate-y-1 hover:shadow-2xl hover:shadow-[#073B3A]/10 ${index === comparisonAdvantages.length - 1 ? 'bg-[#F2A541] text-[#073B3A] md:col-span-2' : 'bg-white'}`}
|
||||
>
|
||||
<div className={`inline-flex h-12 w-12 items-center justify-center rounded-2xl ${index === comparisonAdvantages.length - 1 ? 'bg-[#073B3A] text-white' : 'bg-[#E8F6F1] text-[#087F6D]'}`}>
|
||||
<BaseIcon path={advantage.icon} size={26} />
|
||||
</div>
|
||||
<h3 className='mt-5 text-2xl font-black'>{advantage.title}</h3>
|
||||
<p className={`mt-3 text-sm leading-6 ${index === comparisonAdvantages.length - 1 ? 'text-[#073B3A]/80' : 'text-[#5D6B62]'}`}>{advantage.description}</p>
|
||||
<div className='mt-5 flex flex-wrap gap-2'>
|
||||
{advantage.points.map((point) => (
|
||||
<span
|
||||
key={point}
|
||||
className={`rounded-full px-3 py-1 text-xs font-black ${index === comparisonAdvantages.length - 1 ? 'bg-[#073B3A]/10 text-[#073B3A]' : 'bg-[#F7F2E8] text-[#073B3A]'}`}
|
||||
>
|
||||
{point}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id='automation' className='bg-[#073B3A] py-16 text-white'>
|
||||
<div className='mx-auto max-w-7xl px-6 lg:px-8'>
|
||||
<div className='grid gap-8 lg:grid-cols-[0.9fr_1.1fr] lg:items-start'>
|
||||
<div>
|
||||
<p className='text-sm font-black uppercase tracking-[0.25em] text-[#F2A541]'>Otomatisasi & Input Cerdas</p>
|
||||
<h2 className='mt-3 text-4xl font-black tracking-tight'>Input sekali, GeoSeek mengubahnya menjadi data lokal yang bisa dicari, diverifikasi, dan ditransaksikan.</h2>
|
||||
<p className='mt-5 leading-7 text-white/75'>
|
||||
Google Maps kuat untuk peta dan Waze kuat untuk laporan jalan. GeoSeek naik level dengan input otomatis untuk lalu lintas, UMKM, stok produk, jasa, booking, dan transaksi lokal dalam satu ekosistem hyperlocal.
|
||||
</p>
|
||||
|
||||
<div className='mt-8 grid gap-4 sm:grid-cols-3'>
|
||||
<div className='rounded-[1.5rem] bg-white/10 p-5'>
|
||||
<span className='block text-3xl font-black text-[#F2A541]'>1x</span>
|
||||
<span className='text-sm font-bold text-white/75'>input cepat dari warga atau pemilik usaha</span>
|
||||
</div>
|
||||
<div className='rounded-[1.5rem] bg-white/10 p-5'>
|
||||
<span className='block text-3xl font-black text-[#F2A541]'>Auto</span>
|
||||
<span className='text-sm font-bold text-white/75'>kategori, tag, radius, dan status tersedia</span>
|
||||
</div>
|
||||
<div className='rounded-[1.5rem] bg-white/10 p-5'>
|
||||
<span className='block text-3xl font-black text-[#F2A541]'>Live</span>
|
||||
<span className='text-sm font-bold text-white/75'>draft siap validasi komunitas dan dashboard bisnis</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='rounded-[2.5rem] bg-white p-6 text-[#17231B] shadow-2xl shadow-black/20'>
|
||||
<div className='flex items-start justify-between gap-4'>
|
||||
<div>
|
||||
<p className='text-xs font-black uppercase tracking-[0.22em] text-[#F26A4B]'>Demo Input Pintar</p>
|
||||
<h3 className='mt-2 text-2xl font-black'>Coba alur otomatisasi GeoSeek</h3>
|
||||
</div>
|
||||
<div className='inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-[#E8F6F1] text-[#087F6D]'>
|
||||
<BaseIcon path={mdiRobotHappyOutline} size={28} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-5 flex flex-wrap gap-2'>
|
||||
{smartInputOptions.map((option) => (
|
||||
<button
|
||||
type='button'
|
||||
key={option.id}
|
||||
onClick={() => {
|
||||
setSmartInputType(option.id)
|
||||
setSmartInputText(option.placeholder)
|
||||
}}
|
||||
className={`rounded-full px-4 py-2 text-xs font-black transition ${option.id === selectedSmartInput.id ? 'bg-[#073B3A] text-white shadow-lg shadow-[#073B3A]/20' : 'bg-[#F7F2E8] text-[#073B3A] hover:bg-[#E8F6F1]'}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className='mt-5 rounded-2xl bg-[#F7F2E8] p-4 text-sm font-semibold leading-6 text-[#5D6B62]'>{selectedSmartInput.title}</p>
|
||||
|
||||
<label className='mt-4 block'>
|
||||
<span className='mb-2 block text-sm font-black text-[#073B3A]'>Tulis laporan, stok, produk, jasa, atau promo</span>
|
||||
<textarea
|
||||
value={smartInputText}
|
||||
onChange={(event) => setSmartInputText(event.target.value)}
|
||||
rows={5}
|
||||
className='w-full rounded-2xl border border-[#E0D6C3] bg-[#FCFAF5] p-4 text-sm leading-6 outline-none transition focus:border-[#2CA58D] focus:ring-4 focus:ring-[#2CA58D]/15'
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className='mt-4 rounded-[1.75rem] border border-[#E0D6C3] bg-[#FCFAF5] p-5'>
|
||||
<div className='flex items-center gap-2 text-xs font-black uppercase tracking-[0.2em] text-[#087F6D]'>
|
||||
<BaseIcon path={mdiFire} size={18} />
|
||||
Preview otomatis
|
||||
</div>
|
||||
<p className='mt-3 text-lg font-black text-[#073B3A]'>{smartInputSummary}</p>
|
||||
<div className='mt-4 flex flex-wrap gap-2'>
|
||||
{selectedSmartInput.tags.map((tag) => (
|
||||
<span key={tag} className='rounded-full bg-[#E8F6F1] px-3 py-1 text-xs font-black text-[#087F6D]'>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-8 grid gap-5 md:grid-cols-2 xl:grid-cols-3'>
|
||||
{automationInputFeatures.map((feature) => (
|
||||
<article key={feature.title} className='group rounded-[2rem] bg-white/10 p-6 shadow-sm transition hover:-translate-y-1 hover:bg-white hover:text-[#17231B] hover:shadow-2xl hover:shadow-black/20'>
|
||||
<div className='inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-[#F2A541] text-[#073B3A]'>
|
||||
<BaseIcon path={feature.icon} size={26} />
|
||||
</div>
|
||||
<h3 className='mt-5 text-2xl font-black'>{feature.title}</h3>
|
||||
<p className='mt-3 text-sm leading-6 text-white/75 group-hover:text-[#5D6B62]'>{feature.description}</p>
|
||||
<div className='mt-5 flex flex-wrap gap-2'>
|
||||
{feature.points.map((point) => (
|
||||
<span key={point} className='rounded-full bg-white/10 px-3 py-1 text-xs font-black text-[#F2A541] group-hover:bg-[#F7F2E8] group-hover:text-[#073B3A]'>{point}</span>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id='keywords' className='mx-auto max-w-7xl px-6 py-16 lg:px-8'>
|
||||
<div className='mb-8 flex flex-col gap-4 md:flex-row md:items-end md:justify-between'>
|
||||
<div className='max-w-3xl'>
|
||||
<p className='text-sm font-black uppercase tracking-[0.25em] text-[#087F6D]'>Keyword Engine GeoSeek</p>
|
||||
<h2 className='mt-2 text-4xl font-black tracking-tight'>Kata kunci lokasi yang paling banyak dicari.</h2>
|
||||
<p className='mt-4 leading-7 text-[#5D6B62]'>Klik chip untuk langsung live search. GeoSeek mengenali intent seperti terdekat, dekat saya, near me, buka sekarang, 24 jam, radius, kategori, kota, kecamatan, rating, populer, murah, terbaru, dan terverifikasi.</p>
|
||||
<p className='mt-4 leading-7 text-[#5D6B62]'>Klik chip untuk langsung live search. GeoSeek mengenali intent seperti terdekat, dekat saya, near me, buka sekarang, 24 jam, radius, jenis usaha, kota, kecamatan, rating, populer, murah, terbaru, dan terverifikasi.</p>
|
||||
</div>
|
||||
<div className='rounded-[2rem] bg-white p-5 text-sm font-bold text-[#5D6B62] shadow-sm'>
|
||||
<span className='block text-3xl font-black text-[#F26A4B]'>{totalKeywordChips}</span>
|
||||
keyword siap klik + sinonim kategori
|
||||
keyword siap klik + sinonim usaha
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -754,7 +1088,7 @@ export default function Starter() {
|
||||
<BaseIcon path={mdiStorefrontOutline} size={26} />
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-xs font-black uppercase tracking-[0.2em] text-[#F2A541]'>Kombinasi kategori</p>
|
||||
<p className='text-xs font-black uppercase tracking-[0.2em] text-[#F2A541]'>Kombinasi kata kunci</p>
|
||||
<h3 className='text-2xl font-black'>Kuliner, kesehatan, otomotif, belanja, wisata</h3>
|
||||
</div>
|
||||
</div>
|
||||
@ -845,7 +1179,7 @@ export default function Starter() {
|
||||
<div>
|
||||
<p className='text-sm font-black uppercase tracking-[0.25em] text-[#087F6D]'>Live Search Results</p>
|
||||
<h2 className='mt-2 text-4xl font-black tracking-tight'>Konten paling dekat, relevan, dan berguna.</h2>
|
||||
<p className='mt-3 max-w-3xl text-[#5D6B62]'>Radius aktif: <strong>{selectedZone.range} · {selectedZone.label}</strong>. Ditemukan {searchMeta.count} hasil dari {searchMeta.total_candidates} kandidat terindeks.</p>
|
||||
<p className='mt-3 max-w-3xl text-[#5D6B62]'>Lokasi: <strong>{location?.label || 'Belum aktif — izinkan lokasi browser'}</strong>. Radius aktif: <strong>{selectedZone.range} · {selectedZone.label}</strong>. Ditemukan {searchMeta.count} hasil dari {searchMeta.total_candidates} kandidat terindeks.{searchMeta.expanded_for_nearest ? ' Radius awal kosong, jadi hasil diperluas ke kandidat terdekat dan tetap diurutkan berdasarkan jarak.' : ''}</p>
|
||||
</div>
|
||||
<BaseButton href='/login' label='Kelola listing UMKM' color='info' roundedFull className='font-bold' />
|
||||
</div>
|
||||
@ -874,7 +1208,23 @@ export default function Starter() {
|
||||
<div className='rounded-[2rem] border border-[#F26A4B]/30 bg-white p-8 text-[#A23A24]'>{error}</div>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
{searchMeta.external_source_errors?.length ? (
|
||||
<div className='mb-6 rounded-[2rem] border border-[#F2A541]/40 bg-white p-5 text-sm font-bold text-[#9A6500]'>
|
||||
{searchMeta.external_source_errors.join(' ')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!hasActiveLocation ? (
|
||||
<div className='rounded-[2rem] border border-dashed border-[#087F6D]/40 bg-white p-10 text-center'>
|
||||
<h3 className='text-2xl font-black'>Aktifkan Lokasi Saya dulu</h3>
|
||||
<p className='mx-auto mt-3 max-w-xl text-[#5D6B62]'>GeoSeek hanya memakai lokasi pengguna yang sedang mencari. Pencarian berjalan setelah browser mengirim koordinat lokasi Anda.</p>
|
||||
<div className='mt-6'>
|
||||
<button type='button' onClick={requestCurrentLocation} className='rounded-full bg-[#073B3A] px-6 py-3 font-black text-white transition hover:bg-[#087F6D]'>
|
||||
Gunakan lokasi saya
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className='grid gap-5 md:grid-cols-3'>
|
||||
{[0, 1, 2].map((item) => (
|
||||
<div key={item} className='h-96 animate-pulse rounded-[2rem] bg-white/70' />
|
||||
@ -898,7 +1248,7 @@ export default function Starter() {
|
||||
<div className='mt-5 flex items-start justify-between gap-4'>
|
||||
<div>
|
||||
<h3 className='text-2xl font-black tracking-tight'>{place.name}</h3>
|
||||
<p className='mt-2 text-xs font-bold uppercase tracking-wide text-[#087F6D]'>{place.radius_zone?.label || selectedZone.label}</p>
|
||||
<p className='mt-2 text-xs font-bold uppercase tracking-wide text-[#087F6D]'>{place.distance_km !== null && place.distance_km !== undefined ? `Jarak ${place.distance_km} km dari lokasi saya` : place.radius_zone?.label || selectedZone.label}</p>
|
||||
</div>
|
||||
<div className='text-right'>
|
||||
<p className='text-xs font-bold text-[#6A7A70]'>GeoScore</p>
|
||||
@ -946,8 +1296,13 @@ export default function Starter() {
|
||||
|
||||
<p className='mt-4 text-sm text-[#5D6B62]'>{place.address || [place.city, place.province].filter(Boolean).join(', ')}</p>
|
||||
<div className='mt-auto pt-5'>
|
||||
<Link href={`/tempat/${place.id}?lat=${location.lat}&lng=${location.lng}&radiusKm=${radiusKm}&q=${encodeURIComponent(query)}`} className='inline-flex w-full items-center justify-center gap-2 rounded-2xl bg-[#073B3A] px-4 py-3 font-black text-white transition group-hover:bg-[#087F6D]'>
|
||||
Lihat detail, stok & arah <BaseIcon path={mdiArrowRight} size={18} />
|
||||
<Link
|
||||
href={buildPlaceHref(place)}
|
||||
target={shouldOpenExternalPlace(place) ? '_blank' : undefined}
|
||||
rel={shouldOpenExternalPlace(place) ? 'noreferrer' : undefined}
|
||||
className='inline-flex w-full items-center justify-center gap-2 rounded-2xl bg-[#073B3A] px-4 py-3 font-black text-white transition group-hover:bg-[#087F6D]'
|
||||
>
|
||||
{place.external_source === 'openstreetmap' ? 'Buka rute di Maps' : 'Lihat detail, stok & arah'} <BaseIcon path={mdiArrowRight} size={18} />
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
@ -963,7 +1318,6 @@ export default function Starter() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{categoriesLoading ? <p className='mt-6 text-sm text-[#6A7A70]'>Memuat kategori...</p> : null}
|
||||
</section>
|
||||
|
||||
<section id='trending' className='bg-[#073B3A] px-6 py-16 text-white lg:px-8'>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user