diff --git a/backend/src/routes/publicPlaces.js b/backend/src/routes/publicPlaces.js index e0edc40..d71a9c2 100644 --- a/backend/src/routes/publicPlaces.js +++ b/backend/src/routes/publicPlaces.js @@ -1,4 +1,5 @@ const express = require('express'); +const axios = require('axios'); const db = require('../db/models'); const wrapAsync = require('../helpers').wrapAsync; const commonErrorHandler = require('../helpers').commonErrorHandler; @@ -178,7 +179,14 @@ const SYNONYMS = { servis: ['service', 'bengkel', 'mobil', 'otomotif', 'auto', 'oli', 'tune', 'mesin'], shockbreaker: ['shock', 'kaki', 'mobil', 'bengkel'], sofa: ['furniture', 'furnitur', 'mebel', 'interior', 'ruang', 'tamu'], - spbu: ['bbm', 'bensin', 'otomotif', 'mobil', 'bengkel', 'auto'], + bbm: ['spbu', 'bensin', 'pom', 'pertamina', 'shell', 'vivo', 'fuel'], + bensin: ['spbu', 'bbm', 'pom', 'pertamina', 'shell', 'vivo', 'fuel'], + fuel: ['spbu', 'bbm', 'bensin', 'pom', 'gas', 'station'], + gas: ['spbu', 'bbm', 'bensin', 'pom', 'fuel', 'station'], + pertamina: ['spbu', 'bbm', 'bensin', 'pom', 'fuel'], + pom: ['spbu', 'bbm', 'bensin', 'pertamina', 'shell', 'vivo', 'fuel'], + shell: ['spbu', 'bbm', 'bensin', 'pom', 'fuel'], + spbu: ['bbm', 'bensin', 'pom', 'pertamina', 'shell', 'vivo', 'fuel'], spooring: ['balancing', 'ban', 'bengkel', 'mobil'], stok: ['stock', 'tersedia', 'ready', 'produk', 'barang'], tersedia: ['ready', 'stok', 'stock', 'in stock'], @@ -206,6 +214,16 @@ const parseCoordinate = (value, label) => { return number; }; +const requireSearchOrigin = (lat, lng) => { + if (lat !== null && lng !== null) { + return { lat, lng }; + } + + const error = new Error('Lokasi pengguna wajib dikirim. Izinkan akses lokasi browser dan kirim lat/lng.'); + error.code = 400; + throw error; +}; + const parseRadius = (value) => { if (value === undefined || value === null || value === '') { return DEFAULT_RADIUS_KM; @@ -283,6 +301,34 @@ const analyzeHyperlocalIntent = (query) => { }; }; +const FUEL_QUERY_TOKENS = new Set([ + 'spbu', + 'bbm', + 'bensin', + 'pom', + 'pertamina', + 'shell', + 'vivo', + 'fuel', + 'gas', +]); + +const isFuelSearchQuery = (query) => { + const normalized = normalizeText(query); + + if (!normalized) { + return false; + } + + if (normalized.includes('pom bensin') || normalized.includes('gas station')) { + return true; + } + + return normalized + .split(/\s+/) + .some((token) => FUEL_QUERY_TOKENS.has(token)); +}; + const tokenizeQuery = (value) => normalizeText(value) .split(/\s+/) .filter((token) => token.length > 1 && !STOP_WORDS.has(token)); @@ -701,7 +747,7 @@ const sortScoredPlaces = (a, b, hasQuery, intent = {}) => { if (latestDiff !== 0) return latestDiff; } - if (intent.nearby && !hasQuery) { + if (intent.nearby) { const distanceDiff = compareDistance(a.place, b.place); if (distanceDiff !== 0) return distanceDiff; } @@ -830,6 +876,849 @@ const calculateDistanceKm = (lat1, lon1, lat2, lon2) => { return earthRadiusKm * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); }; +const OVERPASS_ENDPOINTS = [ + 'https://z.overpass-api.de/api/interpreter', + 'https://overpass-api.de/api/interpreter', + 'https://maps.mail.ru/osm/tools/overpass/api/interpreter', +]; + +const OSM_SEARCH_PROFILES = [ + { + id: 'fuel', + matchTerms: Array.from(FUEL_QUERY_TOKENS), + filters: [ + { key: 'amenity', value: 'fuel' }, + ], + defaultName: 'SPBU / Pom Bensin', + shortDescription: 'SPBU/pom bensin terdekat dari data OpenStreetMap.', + fullDescription: 'Data SPBU diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', + keywords: 'spbu bbm bensin pom pertamina shell vivo fuel gas station bahan bakar', + category: { + id: 'openstreetmap-fuel', + name: 'SPBU / Pom Bensin', + slug: 'spbu-pom-bensin', + color_hex: '#D72638', + description: 'Stasiun pengisian bahan bakar dari OpenStreetMap.', + }, + offering: { + name: 'BBM / bahan bakar', + description: 'Ketersediaan mengikuti data lokasi SPBU.', + offering_type: 'product', + }, + maxRadiusKm: 50, + }, + { + id: 'cafe', + matchTerms: ['cafe', 'kafe', 'kopi', 'coffee', 'kedai'], + filters: [ + { key: 'amenity', value: 'cafe' }, + { key: 'shop', value: 'coffee' }, + ], + defaultName: 'Cafe / Kedai Kopi', + shortDescription: 'Cafe, kedai kopi, atau tempat nongkrong dari OpenStreetMap.', + fullDescription: 'Data cafe/kopi diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', + keywords: 'cafe kafe kopi coffee kedai nongkrong minum minuman kuliner', + category: { + id: 'openstreetmap-cafe', + name: 'Cafe / Kopi', + slug: 'cafe-kopi', + color_hex: '#8B5A2B', + description: 'Cafe dan kedai kopi dari OpenStreetMap.', + }, + offering: { + name: 'Menu cafe / kopi', + description: 'Menu dan layanan cafe mengikuti informasi lokasi.', + offering_type: 'service', + }, + maxRadiusKm: 25, + }, + { + id: 'restaurant', + matchTerms: ['restoran', 'restaurant', 'kuliner', 'makan', 'makanan', 'warung', 'nasi', 'food'], + filters: [ + { key: 'amenity', value: 'restaurant' }, + { key: 'amenity', value: 'fast_food' }, + { key: 'amenity', value: 'food_court' }, + { key: 'shop', value: 'bakery' }, + ], + defaultName: 'Restoran / Kuliner', + shortDescription: 'Tempat makan, restoran, warung, atau kuliner dari OpenStreetMap.', + fullDescription: 'Data restoran/kuliner diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', + keywords: 'restoran restaurant kuliner makan makanan warung nasi food court fast food roti bakery kue', + category: { + id: 'openstreetmap-restaurant', + name: 'Restoran / Kuliner', + slug: 'restoran-kuliner', + color_hex: '#F26A4B', + description: 'Tempat makan dan kuliner dari OpenStreetMap.', + }, + offering: { + name: 'Makanan / minuman', + description: 'Menu dan layanan kuliner mengikuti informasi lokasi.', + offering_type: 'service', + }, + maxRadiusKm: 25, + }, + { + id: 'automotive', + matchTerms: ['bengkel', 'service', 'servis', 'mobil', 'otomotif', 'automotif', 'auto', 'oli', 'ban', 'tambal', 'cuci', 'spooring', 'balancing'], + filters: [ + { key: 'shop', value: 'car_repair' }, + { key: 'shop', value: 'car_parts' }, + { key: 'shop', value: 'tyres' }, + { key: 'shop', value: 'car' }, + { key: 'amenity', value: 'car_wash' }, + ], + defaultName: 'Bengkel / Otomotif', + shortDescription: 'Bengkel, layanan otomotif, cuci mobil, atau toko ban dari OpenStreetMap.', + fullDescription: 'Data bengkel/otomotif diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', + keywords: 'bengkel service servis mobil kendaraan otomotif automotif auto oli ban tambal spooring balancing cuci mobil car repair tyres', + category: { + id: 'openstreetmap-automotive', + name: 'Bengkel / Otomotif', + slug: 'bengkel-otomotif', + color_hex: '#2563EB', + description: 'Bengkel dan layanan otomotif dari OpenStreetMap.', + }, + offering: { + name: 'Service kendaraan', + description: 'Layanan bengkel/otomotif mengikuti informasi lokasi.', + offering_type: 'service', + }, + maxRadiusKm: 25, + }, + { + id: 'pharmacy', + matchTerms: ['apotek', 'apotik', 'farmasi', 'obat', 'chemist'], + filters: [ + { key: 'amenity', value: 'pharmacy' }, + { key: 'healthcare', value: 'pharmacy' }, + { key: 'shop', value: 'chemist' }, + ], + defaultName: 'Apotek / Farmasi', + shortDescription: 'Apotek atau farmasi dari OpenStreetMap.', + fullDescription: 'Data apotek/farmasi diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', + keywords: 'apotek apotik farmasi obat kesehatan herbal chemist pharmacy', + category: { + id: 'openstreetmap-pharmacy', + name: 'Apotek / Farmasi', + slug: 'apotek-farmasi', + color_hex: '#0F766E', + description: 'Apotek dan farmasi dari OpenStreetMap.', + }, + offering: { + name: 'Obat / produk kesehatan', + description: 'Ketersediaan obat mengikuti informasi lokasi.', + offering_type: 'product', + }, + maxRadiusKm: 25, + }, + { + id: 'healthcare', + matchTerms: ['klinik', 'clinic', 'dokter', 'doctor', 'rumah sakit', 'sakit', 'rs', 'hospital', 'kesehatan', 'gigi', 'dentist'], + filters: [ + { key: 'amenity', value: 'clinic' }, + { key: 'amenity', value: 'hospital' }, + { key: 'amenity', value: 'doctors' }, + { key: 'amenity', value: 'dentist' }, + { key: 'healthcare', value: 'clinic' }, + { key: 'healthcare', value: 'hospital' }, + { key: 'healthcare', value: 'doctor' }, + { key: 'healthcare', value: 'dentist' }, + ], + defaultName: 'Klinik / Kesehatan', + shortDescription: 'Klinik, dokter, rumah sakit, atau layanan kesehatan dari OpenStreetMap.', + fullDescription: 'Data fasilitas kesehatan diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', + keywords: 'klinik clinic dokter doctor rumah sakit rs hospital kesehatan medis gigi dentist obat', + category: { + id: 'openstreetmap-healthcare', + name: 'Klinik / Kesehatan', + slug: 'klinik-kesehatan', + color_hex: '#087F6D', + description: 'Fasilitas kesehatan dari OpenStreetMap.', + }, + offering: { + name: 'Layanan kesehatan', + description: 'Layanan kesehatan mengikuti informasi lokasi.', + offering_type: 'service', + }, + maxRadiusKm: 50, + }, + { + id: 'hotel', + matchTerms: ['hotel', 'penginapan', 'hostel', 'guesthouse', 'guest house', 'villa', 'resort', 'motel', 'homestay'], + filters: [ + { key: 'tourism', value: 'hotel' }, + { key: 'tourism', value: 'guest_house' }, + { key: 'tourism', value: 'hostel' }, + { key: 'tourism', value: 'motel' }, + { key: 'tourism', value: 'apartment' }, + { key: 'tourism', value: 'chalet' }, + ], + defaultName: 'Hotel / Penginapan', + shortDescription: 'Hotel, hostel, atau penginapan dari OpenStreetMap.', + fullDescription: 'Data hotel/penginapan diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', + keywords: 'hotel penginapan hostel guesthouse guest house villa resort motel homestay travel wisata lodging accommodation', + category: { + id: 'openstreetmap-hotel', + name: 'Hotel / Penginapan', + slug: 'hotel-penginapan', + color_hex: '#7C3AED', + description: 'Hotel dan penginapan dari OpenStreetMap.', + }, + offering: { + name: 'Reservasi penginapan', + description: 'Ketersediaan kamar mengikuti informasi lokasi.', + offering_type: 'service', + }, + maxRadiusKm: 50, + }, + { + id: 'tourism', + matchTerms: ['wisata', 'travel', 'taman', 'pantai', 'museum', 'rekreasi', 'attraction', 'tourism', 'liburan'], + filters: [ + { key: 'tourism', value: 'attraction' }, + { key: 'tourism', value: 'museum' }, + { key: 'tourism', value: 'gallery' }, + { key: 'tourism', value: 'zoo' }, + { key: 'tourism', value: 'theme_park' }, + { key: 'tourism', value: 'viewpoint' }, + { key: 'leisure', value: 'park' }, + { key: 'leisure', value: 'garden' }, + ], + defaultName: 'Wisata / Rekreasi', + shortDescription: 'Tempat wisata, taman, museum, atau rekreasi dari OpenStreetMap.', + fullDescription: 'Data wisata/rekreasi diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', + keywords: 'wisata travel taman pantai museum rekreasi attraction tourism liburan park garden zoo viewpoint', + category: { + id: 'openstreetmap-tourism', + name: 'Wisata / Rekreasi', + slug: 'wisata-rekreasi', + color_hex: '#16A34A', + description: 'Tempat wisata dan rekreasi dari OpenStreetMap.', + }, + offering: { + name: 'Info kunjungan', + description: 'Informasi kunjungan mengikuti data lokasi.', + offering_type: 'service', + }, + maxRadiusKm: 50, + }, + { + id: 'bank-atm', + matchTerms: ['atm', 'bank', 'tunai', 'uang', 'cash'], + filters: [ + { key: 'amenity', value: 'atm' }, + { key: 'amenity', value: 'bank' }, + ], + defaultName: 'ATM / Bank', + shortDescription: 'ATM atau bank dari OpenStreetMap.', + fullDescription: 'Data ATM/bank diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', + keywords: 'atm bank tunai uang cash tarik setor pembayaran finansial', + category: { + id: 'openstreetmap-bank-atm', + name: 'ATM / Bank', + slug: 'atm-bank', + color_hex: '#0E7490', + description: 'ATM dan bank dari OpenStreetMap.', + }, + offering: { + name: 'Layanan perbankan', + description: 'Layanan ATM/bank mengikuti informasi lokasi.', + offering_type: 'service', + }, + maxRadiusKm: 25, + }, + { + id: 'retail', + matchTerms: ['minimarket', 'supermarket', 'toko', 'belanja', 'pasar', 'mall', 'market', 'store', 'baju', 'pakaian', 'elektronik', 'hp', 'handphone', 'furniture', 'furnitur', 'mebel', 'laundry', 'salon'], + filters: [ + { key: 'shop', value: 'convenience' }, + { key: 'shop', value: 'supermarket' }, + { key: 'shop', value: 'mall' }, + { key: 'shop', value: 'department_store' }, + { key: 'shop', value: 'general' }, + { key: 'shop', value: 'variety_store' }, + { key: 'shop', value: 'kiosk' }, + { key: 'shop', value: 'clothes' }, + { key: 'shop', value: 'shoes' }, + { key: 'shop', value: 'electronics' }, + { key: 'shop', value: 'mobile_phone' }, + { key: 'shop', value: 'computer' }, + { key: 'shop', value: 'hardware' }, + { key: 'shop', value: 'furniture' }, + { key: 'shop', value: 'books' }, + { key: 'shop', value: 'stationery' }, + { key: 'shop', value: 'beauty' }, + { key: 'shop', value: 'hairdresser' }, + { key: 'shop', value: 'laundry' }, + { key: 'amenity', value: 'marketplace' }, + ], + defaultName: 'Toko / Belanja', + shortDescription: 'Toko, minimarket, pasar, mall, atau layanan retail dari OpenStreetMap.', + fullDescription: 'Data toko/retail diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', + keywords: 'toko belanja minimarket supermarket pasar mall market store baju pakaian fashion elektronik hp handphone furniture furnitur mebel laundry salon produk barang', + category: { + id: 'openstreetmap-retail', + name: 'Toko / Belanja', + slug: 'toko-belanja', + color_hex: '#F59E0B', + description: 'Toko dan retail dari OpenStreetMap.', + }, + offering: { + name: 'Produk / layanan toko', + description: 'Produk dan layanan toko mengikuti informasi lokasi.', + offering_type: 'product', + }, + maxRadiusKm: 15, + }, + { + id: 'education', + matchTerms: ['sekolah', 'kampus', 'universitas', 'kuliah', 'pendidikan', 'kursus', 'belajar', 'tk', 'college'], + filters: [ + { key: 'amenity', value: 'school' }, + { key: 'amenity', value: 'college' }, + { key: 'amenity', value: 'university' }, + { key: 'amenity', value: 'kindergarten' }, + { key: 'amenity', value: 'language_school' }, + { key: 'office', value: 'educational_institution' }, + ], + defaultName: 'Sekolah / Pendidikan', + shortDescription: 'Sekolah, kampus, atau lembaga pendidikan dari OpenStreetMap.', + fullDescription: 'Data pendidikan diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', + keywords: 'sekolah kampus universitas kuliah pendidikan kursus belajar tk college university kindergarten', + category: { + id: 'openstreetmap-education', + name: 'Sekolah / Pendidikan', + slug: 'sekolah-pendidikan', + color_hex: '#1D4ED8', + description: 'Institusi pendidikan dari OpenStreetMap.', + }, + offering: { + name: 'Layanan pendidikan', + description: 'Layanan pendidikan mengikuti informasi lokasi.', + offering_type: 'service', + }, + maxRadiusKm: 25, + }, + { + id: 'mosque', + matchTerms: ['masjid', 'mushola', 'musala', 'surau'], + filters: [ + { + conditions: [ + { key: 'amenity', value: 'place_of_worship' }, + { key: 'religion', value: 'muslim' }, + ], + }, + ], + defaultName: 'Masjid / Mushola', + shortDescription: 'Masjid, mushola, atau tempat ibadah muslim dari OpenStreetMap.', + fullDescription: 'Data masjid/mushola diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', + keywords: 'masjid mushola musala surau ibadah muslim tempat ibadah', + category: { + id: 'openstreetmap-mosque', + name: 'Masjid / Mushola', + slug: 'masjid-mushola', + color_hex: '#059669', + description: 'Tempat ibadah muslim dari OpenStreetMap.', + }, + offering: { + name: 'Tempat ibadah', + description: 'Informasi tempat ibadah mengikuti data lokasi.', + offering_type: 'service', + }, + maxRadiusKm: 25, + }, + { + id: 'church', + matchTerms: ['gereja', 'church', 'katedral'], + filters: [ + { + conditions: [ + { key: 'amenity', value: 'place_of_worship' }, + { key: 'religion', value: 'christian' }, + ], + }, + ], + defaultName: 'Gereja', + shortDescription: 'Gereja atau tempat ibadah kristiani dari OpenStreetMap.', + fullDescription: 'Data gereja diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', + keywords: 'gereja church katedral ibadah kristen katolik protestan tempat ibadah', + category: { + id: 'openstreetmap-church', + name: 'Gereja', + slug: 'gereja', + color_hex: '#6366F1', + description: 'Gereja dari OpenStreetMap.', + }, + offering: { + name: 'Tempat ibadah', + description: 'Informasi tempat ibadah mengikuti data lokasi.', + offering_type: 'service', + }, + maxRadiusKm: 25, + }, + { + id: 'worship', + matchTerms: ['ibadah', 'tempat ibadah', 'pura', 'vihara', 'kelenteng', 'klenteng'], + filters: [ + { key: 'amenity', value: 'place_of_worship' }, + ], + defaultName: 'Tempat Ibadah', + shortDescription: 'Tempat ibadah dari OpenStreetMap.', + fullDescription: 'Data tempat ibadah diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', + keywords: 'ibadah tempat ibadah masjid mushola gereja pura vihara kelenteng klenteng worship', + category: { + id: 'openstreetmap-worship', + name: 'Tempat Ibadah', + slug: 'tempat-ibadah', + color_hex: '#475569', + description: 'Tempat ibadah dari OpenStreetMap.', + }, + offering: { + name: 'Tempat ibadah', + description: 'Informasi tempat ibadah mengikuti data lokasi.', + offering_type: 'service', + }, + maxRadiusKm: 25, + }, + { + id: 'fitness', + matchTerms: ['gym', 'fitness', 'olahraga', 'sport', 'fitnes'], + filters: [ + { key: 'leisure', value: 'fitness_centre' }, + { key: 'leisure', value: 'sports_centre' }, + { key: 'shop', value: 'sports' }, + ], + defaultName: 'Gym / Olahraga', + shortDescription: 'Gym, fitness center, atau fasilitas olahraga dari OpenStreetMap.', + fullDescription: 'Data gym/olahraga diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', + keywords: 'gym fitness fitnes olahraga sport sports centre pusat kebugaran', + category: { + id: 'openstreetmap-fitness', + name: 'Gym / Olahraga', + slug: 'gym-olahraga', + color_hex: '#DC2626', + description: 'Gym dan fasilitas olahraga dari OpenStreetMap.', + }, + offering: { + name: 'Layanan olahraga', + description: 'Layanan olahraga mengikuti informasi lokasi.', + offering_type: 'service', + }, + maxRadiusKm: 25, + }, +]; + +const OSM_TAG_VALUE_KEYWORDS = { + atm: 'atm bank tunai uang cash', + bakery: 'roti bakery kue makanan kuliner', + bank: 'bank atm uang tunai finansial', + cafe: 'cafe kafe kopi coffee kedai minuman', + car: 'mobil kendaraan otomotif auto', + car_parts: 'suku cadang sparepart mobil otomotif', + car_repair: 'bengkel service servis mobil kendaraan otomotif', + car_wash: 'cuci mobil kendaraan otomotif', + chemist: 'apotek farmasi obat kesehatan', + clothes: 'baju pakaian fashion toko belanja', + clinic: 'klinik kesehatan dokter medis', + coffee: 'kopi coffee cafe kafe kedai', + college: 'kampus kuliah pendidikan college', + computer: 'komputer laptop toko elektronik', + convenience: 'minimarket toko belanja kebutuhan harian', + dentist: 'dokter gigi klinik kesehatan', + department_store: 'mall toko belanja department store', + doctors: 'dokter klinik kesehatan medis', + electronics: 'elektronik hp handphone gadget toko', + fast_food: 'fast food makanan kuliner restoran', + fitness_centre: 'gym fitness fitnes olahraga', + food_court: 'food court kuliner makanan restoran', + fuel: 'spbu bbm bensin pom bahan bakar', + furniture: 'furniture furnitur mebel perabot toko', + guest_house: 'penginapan guesthouse guest house hotel', + hairdresser: 'salon potong rambut kecantikan', + hardware: 'toko bangunan hardware alat', + hospital: 'rumah sakit rs hospital kesehatan medis', + hostel: 'hostel penginapan hotel travel', + hotel: 'hotel penginapan travel wisata', + kindergarten: 'tk taman kanak kanak sekolah pendidikan', + laundry: 'laundry binatu cuci pakaian', + marketplace: 'pasar market toko belanja', + mobile_phone: 'hp handphone ponsel toko elektronik', + motel: 'motel hotel penginapan travel', + museum: 'museum wisata rekreasi edukasi', + park: 'taman wisata rekreasi', + pharmacy: 'apotek farmasi obat kesehatan', + place_of_worship: 'tempat ibadah masjid mushola gereja pura vihara worship', + restaurant: 'restoran restaurant makan makanan kuliner warung nasi', + school: 'sekolah pendidikan belajar', + shoes: 'sepatu toko fashion belanja', + sports: 'olahraga sport alat olahraga toko', + sports_centre: 'pusat olahraga sport gym fitness', + supermarket: 'supermarket minimarket toko belanja groceries', + tyres: 'ban tambal ban bengkel otomotif mobil', + university: 'universitas kampus kuliah pendidikan', + variety_store: 'toko serba ada belanja produk', + zoo: 'kebun binatang zoo wisata rekreasi', +}; + +const normalizeOsmTagValue = (value) => normalizeText(String(value || '').replace(/_/g, ' ')); + +const getOsmFilterConditions = (filter) => filter.conditions || [filter]; + +const buildOsmFilterCondition = (filter) => getOsmFilterConditions(filter) + .map((condition) => { + if (condition.value === undefined || condition.value === null) { + return `["${condition.key}"]`; + } + + return `["${condition.key}"="${condition.value}"]`; + }) + .join(''); + +const osmTagMatchesCondition = (tags, condition) => { + const tagValue = tags[condition.key]; + + if (tagValue === undefined || tagValue === null || tagValue === '') { + return false; + } + + if (condition.value === undefined || condition.value === null) { + return true; + } + + return normalizeOsmTagValue(tagValue) === normalizeOsmTagValue(condition.value); +}; + +const osmTagMatchesFilter = (tags, filter) => getOsmFilterConditions(filter) + .every((condition) => osmTagMatchesCondition(tags, condition)); + +const textMatchesOsmTerm = (normalizedText, tokens, term) => { + const normalizedTerm = normalizeText(term); + + if (!normalizedTerm) { + return false; + } + + if (normalizedTerm.includes(' ')) { + return normalizedText.includes(normalizedTerm); + } + + return tokens.has(normalizedTerm) || includesToken(normalizedText, normalizedTerm); +}; + +const buildExpandedOsmTokens = (tokens) => { + const expanded = new Set(tokens); + + tokens.forEach((token) => { + (SYNONYMS[token] || []).forEach((synonym) => { + normalizeText(synonym) + .split(/\s+/) + .filter(Boolean) + .forEach((synonymToken) => expanded.add(synonymToken)); + }); + }); + + return expanded; +}; + +const getOsmSearchProfiles = (query, category) => { + const searchText = [query, category].filter(Boolean).join(' '); + const normalized = normalizeText(searchText); + + if (!normalized) { + return []; + } + + const directTokens = new Set(tokenizeQuery(searchText)); + const expandedTokens = buildExpandedOsmTokens(directTokens); + const exactProfiles = OSM_SEARCH_PROFILES.filter((profile) => ( + profile.matchTerms.some((term) => textMatchesOsmTerm(normalized, directTokens, term)) + )); + + if (exactProfiles.length) { + return exactProfiles.slice(0, 4); + } + + if (isFuelSearchQuery(searchText)) { + return OSM_SEARCH_PROFILES.filter((profile) => profile.id === 'fuel'); + } + + return OSM_SEARCH_PROFILES + .filter((profile) => profile.matchTerms.some((term) => textMatchesOsmTerm(normalized, expandedTokens, term))) + .slice(0, 4); +}; + +const getOsmElementProfile = (element, profiles) => { + const tags = element.tags || {}; + + return profiles.find((profile) => profile.filters.some((filter) => osmTagMatchesFilter(tags, filter))) || null; +}; + +const buildOsmTagKeywordText = (tags = {}) => { + const relevantValues = [ + tags.amenity, + tags.shop, + tags.tourism, + tags.leisure, + tags.healthcare, + tags.office, + tags.religion, + tags.cuisine, + tags.brand, + tags.operator, + ]; + + return relevantValues + .flatMap((value) => { + const normalizedValue = normalizeOsmTagValue(value); + + if (!normalizedValue) { + return []; + } + + return [normalizedValue, OSM_TAG_VALUE_KEYWORDS[value] || OSM_TAG_VALUE_KEYWORDS[normalizedValue] || '']; + }) + .filter(Boolean) + .join(' '); +}; + +const buildOsmOffering = (profile) => ({ + id: `osm-${profile.id}-offering`, + name: profile.offering.name, + description: profile.offering.description, + offering_type: profile.offering.offering_type, + price: null, + stock_status: 'by_request', + stock_label: STOCK_STATUS_LABELS.by_request, + stock_quantity: null, + is_verified: false, +}); + +const buildOsmOfferings = (profile) => [buildOsmOffering(profile)]; + +const buildOsmLiveStatus = (tags = {}) => { + const openingHours = String(tags.opening_hours || '').trim(); + + if (openingHours.toLowerCase().includes('24/7')) { + return { + status: 'open', + label: 'Buka 24 jam', + crowd: 'Cek lokasi', + updated_label: 'Sumber OSM', + source: 'openstreetmap', + }; + } + + return { + status: 'open', + label: openingHours ? `Jam: ${openingHours}` : 'Cek jam operasional', + crowd: 'Cek lokasi', + updated_label: 'Sumber OSM', + source: 'openstreetmap', + }; +}; + +const getOsmCoordinate = (element) => { + const lat = toNumber(element.lat ?? element.center?.lat); + const lon = toNumber(element.lon ?? element.center?.lon); + + if (lat === null || lon === null) { + return null; + } + + return { lat, lng: lon }; +}; + +const buildOsmAddress = (tags = {}) => [ + tags['addr:housenumber'], + tags['addr:street'], + tags['addr:suburb'] || tags['addr:village'], + tags['addr:city'] || tags['addr:district'] || tags['addr:county'], + tags['addr:province'] || tags['addr:state'], +].filter(Boolean).join(', '); + +const buildOsmMapsUrl = (latitude, longitude) => `https://www.google.com/maps/dir/?api=1&destination=${latitude},${longitude}`; + +const toOsmPlace = (element, origin, profile) => { + const coordinate = getOsmCoordinate(element); + + if (!coordinate || !origin || !profile) { + return null; + } + + const tags = element.tags || {}; + const latitude = coordinate.lat; + const longitude = coordinate.lng; + const distanceKm = calculateDistanceKm(origin.lat, origin.lng, latitude, longitude); + const brandOrOperator = tags.brand || tags.operator || ''; + const name = tags.name || brandOrOperator || profile.defaultName; + const address = buildOsmAddress(tags); + const phoneNumber = tags.phone || tags['contact:phone'] || ''; + const websiteUrl = tags.website || tags['contact:website'] || ''; + const osmKeywords = [profile.keywords, buildOsmTagKeywordText(tags)].filter(Boolean).join(' '); + const offerings = buildOsmOfferings(profile); + + return { + id: `osm-${profile.id}-${element.type}-${element.id}`, + name, + short_description: `${profile.shortDescription}${brandOrOperator && brandOrOperator !== name ? ` · ${brandOrOperator}` : ''}`, + full_description: [ + tags.description, + profile.fullDescription, + tags.cuisine ? `Jenis kuliner: ${tags.cuisine}.` : '', + osmKeywords ? `Kata kunci lokasi: ${osmKeywords}.` : '', + ].filter(Boolean).join(' '), + address: address || [latitude, longitude].join(', '), + city: tags['addr:city'] || tags['addr:district'] || tags['addr:county'] || '', + province: tags['addr:province'] || tags['addr:state'] || '', + latitude, + longitude, + phone_number: phoneNumber, + whatsapp_number: '', + google_maps_url: buildOsmMapsUrl(latitude, longitude), + website_url: websiteUrl, + price_level: 'unknown', + average_price: null, + rating_average: null, + rating_count: 0, + status: 'published', + is_verified: false, + category: profile.category, + distance_km: Number(distanceKm.toFixed(2)), + offerings, + offerings_summary: summarizeOfferings(offerings), + live_status: buildOsmLiveStatus(tags), + external_source: 'openstreetmap', + external_search_match: true, + }; +}; + +const buildOsmOverpassQuery = (profiles, radiusMeters, origin, overpassLimit) => { + const clauses = []; + const seenClauses = new Set(); + + profiles.forEach((profile) => { + profile.filters.forEach((filter) => { + const condition = buildOsmFilterCondition(filter); + + const clause = `nwr${condition}(around:${radiusMeters},${origin.lat},${origin.lng});`; + + if (!seenClauses.has(clause)) { + seenClauses.add(clause); + clauses.push(clause); + } + }); + }); + + return ` + [out:json][timeout:8]; + ( + ${clauses.join('\n ')} + ); + out center tags ${overpassLimit}; + `; +}; + +const getOsmSearchRadiusKm = (profiles, radiusKm) => { + const requestedRadiusKm = Math.max(Number(radiusKm || DEFAULT_RADIUS_KM), 1); + const maxProfileRadiusKm = profiles.reduce((maxRadiusKm, profile) => Math.max(maxRadiusKm, profile.maxRadiusKm || 25), 25); + + return Math.min(requestedRadiusKm, maxProfileRadiusKm); +}; + +const isRetriableOverpassError = (err) => { + const status = err.response?.status; + + if (!status) { + return true; + } + + return [408, 429, 500, 502, 503, 504].includes(status); +}; + +const summarizeOverpassError = (err, endpoint) => ({ + endpoint, + message: err.message, + status: err.response?.status, + data: typeof err.response?.data === 'string' + ? err.response.data.slice(0, 500) + : err.response?.data, +}); + +const postOverpassQuery = async (body, endpointIndex = 0, previousErrors = []) => { + const endpoint = OVERPASS_ENDPOINTS[endpointIndex]; + + try { + return await axios.post( + endpoint, + body.toString(), + { + timeout: 10000, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', + 'User-Agent': 'GeoSeek/1.0', + }, + }, + ); + } catch (err) { + const overpassErrors = [...previousErrors, summarizeOverpassError(err, endpoint)]; + + if (endpointIndex < OVERPASS_ENDPOINTS.length - 1 && isRetriableOverpassError(err)) { + return postOverpassQuery(body, endpointIndex + 1, overpassErrors); + } + + err.overpass_errors = overpassErrors; + throw err; + } +}; + +const fetchOsmPlaces = async (origin, query, category, radiusKm, limit) => { + if (!origin) { + return []; + } + + const profiles = getOsmSearchProfiles(query, category); + + if (!profiles.length) { + return []; + } + + const searchRadiusKm = getOsmSearchRadiusKm(profiles, radiusKm); + const radiusMeters = Math.round(searchRadiusKm * 1000); + const overpassLimit = Math.min(Math.max((limit || DEFAULT_LIMIT) * 3, DEFAULT_LIMIT), MAX_LIMIT); + const overpassQuery = buildOsmOverpassQuery(profiles, radiusMeters, origin, overpassLimit); + const body = new URLSearchParams(); + body.set('data', overpassQuery); + + const response = await postOverpassQuery(body); + + const elements = Array.isArray(response.data?.elements) ? response.data.elements : []; + const placeMap = new Map(); + + elements.forEach((element) => { + const profile = getOsmElementProfile(element, profiles); + const place = toOsmPlace(element, origin, profile); + + if (!place) { + return; + } + + const existingPlace = placeMap.get(place.id); + + if (!existingPlace || compareDistance(place, existingPlace) < 0) { + placeMap.set(place.id, place); + } + }); + + return Array.from(placeMap.values()) + .sort((a, b) => compareDistance(a, b)) + .slice(0, overpassLimit); +}; + + const toPublicPlace = (place, origin) => { const plain = place.get({ plain: true }); const latitude = toNumber(plain.latitude); @@ -1088,19 +1977,49 @@ router.get('/places', wrapAsync(async (req, res) => { const lng = parseCoordinate(req.query.lng, 'Longitude'); const queryIntent = analyzeHyperlocalIntent(query); const radiusKm = queryIntent.requestedRadiusKm || parseRadius(req.query.radiusKm); - const origin = lat !== null && lng !== null ? { lat, lng } : null; + const origin = requireSearchOrigin(lat, lng); const rawLimit = Number(req.query.limit || DEFAULT_LIMIT); const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.round(rawLimit), 1), MAX_LIMIT) : DEFAULT_LIMIT; const hasQuery = tokenizeQuery(query).length > 0; + const externalSourceErrors = []; const places = await loadPublicPlaces(category); const publicPlaces = places.map((place) => toPublicPlace(place, origin)); + let externalPlaces = []; + + if (origin && (hasQuery || category)) { + try { + const externalRadiusKm = queryIntent.nearby + ? Math.min(Math.max(radiusKm * 2, 10), 25) + : radiusKm; + + externalPlaces = await fetchOsmPlaces( + origin, + query, + category, + externalRadiusKm, + limit, + ); + } catch (err) { + console.error('Gagal memuat data OpenStreetMap', { + query, + category, + message: err.message, + status: err.response?.status, + data: err.response?.data, + overpass_errors: err.overpass_errors, + }); + externalSourceErrors.push('Data OpenStreetMap gagal dimuat; hasil sekitar lokasi aktif tetap ditampilkan jika tersedia.'); + } + } + + const combinedPlaces = [...externalPlaces, ...publicPlaces]; const radiusFilteredPlaces = origin - ? publicPlaces.filter((place) => place.distance_km !== null && place.distance_km <= radiusKm) - : publicPlaces; - const scoredRows = radiusFilteredPlaces + ? combinedPlaces.filter((place) => place.distance_km !== null && place.distance_km <= radiusKm) + : combinedPlaces; + const buildScoredRows = (candidatePlaces) => candidatePlaces .map((place) => { const geoScore = calculateGeoScore(place, query, radiusKm); @@ -1109,9 +2028,24 @@ router.get('/places', wrapAsync(async (req, res) => { geoScore, }; }) - .filter((item) => !hasQuery || item.geoScore.components.relevance > 0) + .filter((item) => !hasQuery || item.geoScore.components.relevance > 0 || item.place.external_search_match) .sort((a, b) => sortScoredPlaces(a, b, hasQuery, queryIntent)); + let scoredRows = buildScoredRows(radiusFilteredPlaces); + let expandedForNearest = false; + + if (origin && queryIntent.nearby && hasQuery && scoredRows.length === 0) { + const expandedRadiusKm = Math.min(Math.max(radiusKm * 5, 25), 100); + const expandedPlaces = combinedPlaces.filter((place) => ( + place.distance_km !== null + && place.distance_km !== undefined + && place.distance_km <= expandedRadiusKm + )); + + scoredRows = buildScoredRows(expandedPlaces); + expandedForNearest = scoredRows.length > 0; + } + const rows = scoredRows .slice(0, limit) .map((item) => ({ @@ -1122,6 +2056,13 @@ router.get('/places', wrapAsync(async (req, res) => { radius_zone: getRadiusZone(item.place.distance_km, radiusKm), ai_recommendation: buildAiRecommendation(item.place, query), })); + const nearbyIndexedPlaces = origin + ? combinedPlaces.filter((place) => ( + place.distance_km !== null + && place.distance_km !== undefined + && place.distance_km <= Math.max(radiusKm, 100) + )) + : combinedPlaces; res.status(200).send({ rows, @@ -1130,11 +2071,14 @@ router.get('/places', wrapAsync(async (req, res) => { radius_zone: getRadiusZone(null, radiusKm), query_intent: queryIntent, radius_zones: RADIUS_ZONES, - distance_buckets: buildDistanceBuckets(publicPlaces), - filtered_by_radius: Boolean(origin), - total_candidates: publicPlaces.length, + distance_buckets: buildDistanceBuckets(nearbyIndexedPlaces), + filtered_by_radius: Boolean(origin) && !expandedForNearest, + expanded_for_nearest: expandedForNearest, + external_sources: externalPlaces.length ? ['openstreetmap'] : [], + external_source_errors: externalSourceErrors, + total_candidates: nearbyIndexedPlaces.length, geo_score_formula: GEO_SCORE_FORMULA, - trending: buildTrending(rows.length ? rows : publicPlaces.slice(0, limit)), + trending: buildTrending(rows), recommendation_engine: 'rules_based_geo_score_mvp', }); })); diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 9737669..1dabad4 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -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 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 = { budget: 'Ramah kantong', @@ -178,6 +177,152 @@ const priceLabels: Record = { 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 = { export default function Starter() { const [query, setQuery] = useState('') - const [category, setCategory] = useState('') const [radiusKm, setRadiusKm] = useState(DEFAULT_RADIUS_KM) - const [categories, setCategories] = useState([]) const [places, setPlaces] = useState([]) const [searchMeta, setSearchMeta] = useState({ 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(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(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) => { - 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) => { + 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 ( <> @@ -578,6 +755,9 @@ export default function Starter() {
Live Search + Keunggulan + GeoSeek vs Maps + Auto Input Kata Kunci GeoScore Trending @@ -600,44 +780,32 @@ export default function Starter() {

- {location.label} + {location?.label || 'Lokasi saya belum aktif'}
@@ -697,7 +849,9 @@ export default function Starter() { {featuredPlaces.map((place, index) => ( {place.name} @@ -717,16 +871,196 @@ export default function Starter() {
+
+
+
+

Keunggulan Utama

+

Navigasi hyperlocal yang cepat, dinamis, dan berbasis pengguna.

+

GeoSeek menonjolkan kekuatan data komunitas, update cepat, dan rekomendasi rute yang relevan untuk kebutuhan mobilitas harian.

+
+
+ {mainAdvantages.length} + kelebihan utama untuk pengguna jalan +
+
+ +
+ {mainAdvantages.map((advantage) => ( +
+
+ +
+

{advantage.title}

+

{advantage.description}

+
+ {advantage.bullets.map((bullet) => ( + {bullet} + ))} +
+
+ ))} +
+
+ +
+
+
+
+

GeoSeek vs Google Maps

+

Bukan hanya mencari lokasi, tapi menemukan produk, jasa, dan transaksi lokal terdekat.

+

+ 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. +

+ +
+
+ RT/RW + cakupan pencarian lingkungan mikro +
+
+ Produk + Jasa + dengan harga, stok, jadwal, dan status tersedia +
+
+
+ +
+ {comparisonAdvantages.map((advantage, index) => ( +
+
+ +
+

{advantage.title}

+

{advantage.description}

+
+ {advantage.points.map((point) => ( + + {point} + + ))} +
+
+ ))} +
+
+
+
+ +
+
+
+
+

Otomatisasi & Input Cerdas

+

Input sekali, GeoSeek mengubahnya menjadi data lokal yang bisa dicari, diverifikasi, dan ditransaksikan.

+

+ 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. +

+ +
+
+ 1x + input cepat dari warga atau pemilik usaha +
+
+ Auto + kategori, tag, radius, dan status tersedia +
+
+ Live + draft siap validasi komunitas dan dashboard bisnis +
+
+
+ +
+
+
+

Demo Input Pintar

+

Coba alur otomatisasi GeoSeek

+
+
+ +
+
+ +
+ {smartInputOptions.map((option) => ( + + ))} +
+ +

{selectedSmartInput.title}

+ +