diff --git a/backend/src/routes/publicPlaces.js b/backend/src/routes/publicPlaces.js index e5cd5f4..a2bbfa2 100644 --- a/backend/src/routes/publicPlaces.js +++ b/backend/src/routes/publicPlaces.js @@ -9,10 +9,13 @@ const Sequelize = db.Sequelize; const Op = Sequelize.Op; const DEFAULT_LIMIT = 24; -const MAX_LIMIT = 60; +const MAX_LIMIT = 100; const CANDIDATE_LIMIT = 500; const DEFAULT_RADIUS_KM = 5; const GLOBAL_RADIUS_KM = 20038; +const MIN_NEAREST_RESULTS = 10; +const OSM_PROFILE_MATCH_LIMIT = 6; +const MAX_OSM_EXPANSION_RADIUS_KM = 150; const GEO_SCORE_FORMULA = { relevance: 40, @@ -303,6 +306,101 @@ const SYNONYMS = { wisata: ['travel', 'hotel', 'penginapan', 'pantai', 'lokal', 'kuliner'], }; + +Object.assign(SYNONYMS, { + aksesoris: ['fashion', 'toko', 'belanja', 'produk'], + aplikasi: ['website', 'digital', 'it', 'komputer', 'jasa'], + bayi: ['perlengkapan', 'toko', 'produk', 'belanja'], + barbershop: ['salon', 'hairdresser', 'kecantikan', 'potong', 'rambut'], + bazar: ['event', 'umkm', 'pasar', 'toko', 'lokal'], + bibit: ['tanaman', 'pertanian', 'pupuk', 'garden', 'toko'], + buku: ['bookstore', 'toko', 'stationery', 'sekolah'], + catering: ['makanan', 'kuliner', 'restoran', 'warung'], + cctv: ['kamera', 'elektronik', 'komputer', 'internet', 'jasa'], + digital: ['marketing', 'seo', 'website', 'aplikasi', 'jasa'], + ekspedisi: ['kurir', 'logistik', 'pengiriman', 'paket', 'pos'], + event: ['seminar', 'festival', 'bazar', 'organizer', 'venue'], + fashion: ['baju', 'pakaian', 'sepatu', 'tas', 'aksesoris', 'toko'], + freelance: ['lowongan', 'kerja', 'jasa', 'konsultan'], + grooming: ['pet', 'hewan', 'peliharaan', 'klinik', 'veterinary'], + hp: ['smartphone', 'handphone', 'ponsel', 'mobile', 'elektronik'], + internet: ['wifi', 'telkom', 'telekomunikasi', 'komputer', 'jasa'], + kamera: ['cctv', 'fotografer', 'videografer', 'elektronik'], + kecantikan: ['salon', 'barbershop', 'skincare', 'kosmetik', 'beauty'], + kerja: ['lowongan', 'freelance', 'magang', 'employment', 'jasa'], + komputer: ['laptop', 'printer', 'service', 'servis', 'elektronik', 'it'], + konsultan: ['jasa', 'bisnis', 'hukum', 'keuangan', 'pajak'], + kosmetik: ['skincare', 'beauty', 'kecantikan', 'toko'], + kurir: ['ekspedisi', 'logistik', 'pengiriman', 'paket', 'antar'], + laptop: ['komputer', 'printer', 'service', 'servis', 'elektronik'], + laundry: ['binatu', 'cuci', 'pakaian', 'jasa'], + lowongan: ['kerja', 'freelance', 'magang', 'employment'], + mainan: ['toys', 'anak', 'bayi', 'toko', 'produk'], + material: ['bangunan', 'hardware', 'semen', 'cat', 'keramik', 'toko'], + motor: ['bengkel', 'sparepart', 'dealer', 'otomotif', 'kendaraan'], + notaris: ['hukum', 'legal', 'pengacara', 'jasa'], + pajak: ['konsultan', 'akuntan', 'accountant', 'jasa'], + pakan: ['ternak', 'peternakan', 'hewan', 'feed', 'toko'], + pengacara: ['hukum', 'lawyer', 'notaris', 'jasa'], + pengiriman: ['kurir', 'ekspedisi', 'logistik', 'paket', 'pos'], + penjual: ['jual', 'toko', 'produk', 'umkm', 'seller'], + perhiasan: ['jewelry', 'emas', 'aksesoris', 'toko'], + printer: ['komputer', 'laptop', 'service', 'servis', 'elektronik'], + properti: ['rumah', 'tanah', 'ruko', 'apartemen', 'kost', 'kontrakan'], + pupuk: ['pertanian', 'bibit', 'tanaman', 'pestisida', 'toko'], + renovasi: ['bangunan', 'tukang', 'kontraktor', 'material', 'jasa'], + rental: ['sewa', 'mobil', 'motor', 'travel', 'jasa'], + ruko: ['properti', 'rumah', 'tanah', 'estate', 'agent'], + rumah: ['properti', 'renovasi', 'bangunan', 'dijual'], + samsat: ['pajak', 'kendaraan', 'pemerintah', 'layanan', 'publik'], + seo: ['digital', 'marketing', 'website', 'jasa'], + skincare: ['kosmetik', 'beauty', 'kecantikan', 'toko'], + smartphone: ['hp', 'handphone', 'ponsel', 'mobile', 'elektronik'], + sparepart: ['suku', 'cadang', 'motor', 'mobil', 'otomotif', 'bengkel'], + tas: ['fashion', 'bag', 'toko', 'aksesoris'], + ternak: ['peternakan', 'pakan', 'hewan', 'feed', 'toko'], + tukang: ['bangunan', 'renovasi', 'jasa', 'kontraktor'], + warteg: ['warung', 'tegal', 'restoran', 'kuliner', 'cafe', 'kafe', 'kedai'], + website: ['digital', 'aplikasi', 'seo', 'komputer', 'jasa'], + wedding: ['event', 'organizer', 'fotografer', 'videografer', 'jasa'], + wifi: ['internet', 'telkom', 'telekomunikasi', 'komputer', 'jasa'], +}); + + +const STRICT_CULINARY_QUERY_TOKENS = new Set([ + 'ayam', + 'bakso', + 'catering', + 'kuliner', + 'makanan', + 'minuman', + 'restoran', + 'soto', + 'seafood', + 'warteg', + 'warung', +]); + +const CULINARY_GUARD_TERMS = [ + 'ayam', + 'bakso', + 'catering', + 'food', + 'kedai', + 'kopi', + 'kue', + 'kuliner', + 'makanan', + 'minuman', + 'nasi', + 'restaurant', + 'restoran', + 'roti', + 'seafood', + 'soto', + 'warung', +]; + const parseCoordinate = (value, label) => { if (value === undefined || value === null || value === '') { return null; @@ -583,6 +681,24 @@ const calculateRawRelevanceScore = (place, query) => { offerings: normalizeText(buildOfferingText(offerings)), }; + const requiresCulinaryMatch = originalTokens.some((token) => + STRICT_CULINARY_QUERY_TOKENS.has(token), + ); + + if (requiresCulinaryMatch) { + const culinaryGuardText = [fields.name, fields.category, fields.offerings] + .filter(Boolean) + .join(' '); + + if ( + !CULINARY_GUARD_TERMS.some((term) => + includesToken(culinaryGuardText, term), + ) + ) { + return 0; + } + } + const exactWeights = { name: 22, category: 18, @@ -880,6 +996,22 @@ const compareDistance = (a, b) => { return 0; }; + +const dedupePlacesById = (places) => { + const placeMap = new Map(); + + places.filter(Boolean).forEach((place) => { + const key = place.id || `${place.external_source || 'local'}-${place.name}`; + const existingPlace = placeMap.get(key); + + if (!existingPlace || compareDistance(place, existingPlace) < 0) { + placeMap.set(key, place); + } + }); + + return Array.from(placeMap.values()); +}; + const compareLatestActivity = (a, b) => { const dateA = getMostRecentActivityDate(a); const dateB = getMostRecentActivityDate(b); @@ -1569,9 +1701,6 @@ const OSM_SEARCH_PROFILES = [ 'store', 'baju', 'pakaian', - 'elektronik', - 'hp', - 'handphone', 'furniture', 'furnitur', 'mebel', @@ -1606,7 +1735,7 @@ const OSM_SEARCH_PROFILES = [ 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', + 'toko belanja minimarket supermarket pasar mall market store baju pakaian fashion furniture furnitur mebel laundry salon produk barang', category: { id: 'openstreetmap-retail', name: 'Toko / Belanja', @@ -1786,6 +1915,484 @@ const OSM_SEARCH_PROFILES = [ }, ]; + +const mergeUniqueList = (...lists) => + Array.from(new Set(lists.flat().filter(Boolean))); + +const mergeUniqueFilters = (...lists) => { + const seen = new Set(); + const merged = []; + + lists.flat().forEach((filter) => { + const key = JSON.stringify(filter); + + if (seen.has(key)) { + return; + } + + seen.add(key); + merged.push(filter); + }); + + return merged; +}; + +const extendOsmProfile = (id, extension) => { + const profile = OSM_SEARCH_PROFILES.find((item) => item.id === id); + + if (!profile) { + return; + } + + profile.matchTerms = mergeUniqueList(profile.matchTerms, extension.matchTerms); + profile.filters = mergeUniqueFilters(profile.filters, extension.filters || []); + profile.keywords = [profile.keywords, extension.keywords] + .filter(Boolean) + .join(' '); + + if (extension.maxRadiusKm) { + profile.maxRadiusKm = Math.max(profile.maxRadiusKm || 0, extension.maxRadiusKm); + } +}; + +extendOsmProfile('restaurant', { + matchTerms: [ + 'warteg', + 'warung tegal', + 'bakso', + 'soto', + 'ayam goreng', + 'seafood', + 'catering', + 'makanan terdekat', + 'minuman', + 'makanan', + 'warung makan', + ], + filters: [ + { key: 'amenity', value: 'cafe' }, + { key: 'shop', value: 'beverages' }, + { key: 'shop', value: 'confectionery' }, + ], + keywords: + 'warteg warung tegal bakso soto ayam goreng seafood catering makanan minuman restoran kuliner', + maxRadiusKm: 100, +}); + +extendOsmProfile('cafe', { + matchTerms: ['minuman', 'kedai kopi', 'coffee shop', 'cafe terdekat'], + filters: [ + { key: 'shop', value: 'beverages' }, + { key: 'cuisine', value: 'coffee_shop' }, + ], + keywords: 'minuman beverage kedai kopi coffee shop', + maxRadiusKm: 75, +}); + +extendOsmProfile('automotive', { + matchTerms: [ + 'bengkel motor', + 'bengkel mobil', + 'sparepart', + 'sparepart motor', + 'sparepart mobil', + 'suku cadang', + 'dealer motor', + 'dealer mobil', + 'motor', + 'motorcycle', + ], + filters: [ + { key: 'shop', value: 'motorcycle' }, + { key: 'shop', value: 'motorcycle_repair' }, + { key: 'shop', value: 'truck_repair' }, + { key: 'shop', value: 'vehicle_parts' }, + ], + keywords: + 'bengkel motor bengkel mobil sparepart motor sparepart mobil dealer motor dealer mobil suku cadang', + maxRadiusKm: 100, +}); + +extendOsmProfile('pharmacy', { + matchTerms: ['herbal', 'jamu', 'alat kesehatan', 'alkes', 'toko obat'], + filters: [ + { key: 'shop', value: 'medical_supply' }, + { key: 'shop', value: 'herbalist' }, + ], + keywords: 'herbal jamu alat kesehatan alkes toko obat medical supply', + maxRadiusKm: 100, +}); + +extendOsmProfile('healthcare', { + matchTerms: ['laboratorium', 'lab kesehatan', 'dokter gigi', 'alat kesehatan'], + filters: [ + { key: 'healthcare', value: 'laboratory' }, + { key: 'amenity', value: 'laboratory' }, + { key: 'healthcare', value: 'medical_lab' }, + ], + keywords: 'laboratorium lab kesehatan dokter gigi medical laboratory alkes', + maxRadiusKm: 100, +}); + +extendOsmProfile('retail', { + matchTerms: [ + 'fashion', + 'sepatu', + 'tas', + 'kosmetik', + 'skincare', + 'furniture', + 'peralatan rumah tangga', + 'peralatan dapur', + 'perlengkapan bayi', + 'mainan anak', + 'buku', + 'kebutuhan kantor', + 'percetakan', + 'konveksi', + 'perhiasan', + 'jam tangan', + 'aksesoris', + 'umkm', + 'toko kelontong', + 'home industry', + ], + filters: [ + { key: 'shop', value: 'bag' }, + { key: 'shop', value: 'cosmetics' }, + { key: 'shop', value: 'houseware' }, + { key: 'shop', value: 'kitchen' }, + { key: 'shop', value: 'doityourself' }, + { key: 'shop', value: 'building_materials' }, + { key: 'shop', value: 'paint' }, + { key: 'shop', value: 'baby_goods' }, + { key: 'shop', value: 'toys' }, + { key: 'shop', value: 'copyshop' }, + { key: 'shop', value: 'tailor' }, + { key: 'shop', value: 'jewelry' }, + { key: 'shop', value: 'watches' }, + { key: 'shop', value: 'gift' }, + { key: 'shop', value: 'greengrocer' }, + ], + keywords: + 'fashion sepatu tas kosmetik skincare furniture alat rumah tangga alat dapur mainan buku percetakan konveksi perhiasan aksesoris umkm toko kelontong home industry', + maxRadiusKm: 100, +}); + +extendOsmProfile('bank-atm', { + matchTerms: ['koperasi', 'pegadaian', 'qris', 'e wallet', 'cashback'], + filters: [ + { key: 'amenity', value: 'money_transfer' }, + { key: 'office', value: 'financial' }, + { key: 'shop', value: 'pawnbroker' }, + ], + keywords: 'koperasi pegadaian qris e wallet pembayaran keuangan financial', + maxRadiusKm: 100, +}); + +const EXTRA_OSM_SEARCH_PROFILES = [ + { + id: 'building-services', + matchTerms: [ + 'material bangunan', + 'tukang bangunan', + 'renovasi rumah', + 'jasa kebersihan', + 'servis ac', + 'service ac', + 'ac', + 'kontraktor', + 'cat', + 'semen', + 'keramik', + 'hardware', + ], + filters: [ + { key: 'shop', value: 'hardware' }, + { key: 'shop', value: 'doityourself' }, + { key: 'shop', value: 'building_materials' }, + { key: 'shop', value: 'paint' }, + { key: 'craft', value: 'builder' }, + { key: 'craft', value: 'carpenter' }, + { key: 'craft', value: 'electrician' }, + { key: 'craft', value: 'plumber' }, + { key: 'craft', value: 'painter' }, + { key: 'craft', value: 'hvac' }, + { key: 'craft', value: 'cleaning' }, + ], + defaultName: 'Toko Bahan Bangunan / Jasa Rumah', + shortDescription: + 'Toko material, bahan bangunan, tukang, renovasi, kebersihan, atau servis AC dari OpenStreetMap.', + fullDescription: + 'Data bahan bangunan dan jasa rumah diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', + keywords: + 'toko bahan bangunan material bangunan hardware tukang bangunan renovasi rumah jasa kebersihan servis ac service ac kontraktor cat semen keramik', + category: { + id: 'openstreetmap-building-services', + name: 'Bangunan / Jasa Rumah', + slug: 'bangunan-jasa-rumah', + color_hex: '#B45309', + description: 'Toko material dan jasa rumah dari OpenStreetMap.', + }, + offering: { + name: 'Material / jasa rumah', + description: 'Ketersediaan material dan layanan mengikuti data lokasi.', + offering_type: 'service', + }, + maxRadiusKm: 100, + priority: 4, + }, + { + id: 'computer-internet', + matchTerms: [ + 'smartphone', + 'hp', + 'handphone', + 'elektronik', + 'laptop', + 'komputer', + 'cctv', + 'service laptop', + 'servis laptop', + 'service printer', + 'servis printer', + 'printer', + 'internet', + 'wifi', + 'pembuatan website', + 'pembuatan aplikasi', + 'digital marketing', + 'seo', + 'desain grafis', + ], + filters: [ + { key: 'shop', value: 'computer' }, + { key: 'shop', value: 'electronics' }, + { key: 'shop', value: 'mobile_phone' }, + { key: 'shop', value: 'telecommunication' }, + { key: 'shop', value: 'camera' }, + { key: 'craft', value: 'electronics_repair' }, + { key: 'office', value: 'it' }, + { key: 'office', value: 'telecommunication' }, + { key: 'office', value: 'advertising_agency' }, + ], + defaultName: 'Komputer / Internet / Digital', + shortDescription: + 'Service laptop/printer, toko komputer, CCTV, internet, website, aplikasi, SEO, atau digital marketing dari OpenStreetMap.', + fullDescription: + 'Data komputer/internet/digital diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', + keywords: + 'smartphone hp handphone elektronik laptop komputer cctv service laptop servis laptop service printer internet wifi website aplikasi digital marketing seo desain grafis', + category: { + id: 'openstreetmap-computer-internet', + name: 'Komputer / Internet', + slug: 'komputer-internet', + color_hex: '#4F46E5', + description: 'Komputer, internet, CCTV, dan jasa digital dari OpenStreetMap.', + }, + offering: { + name: 'Produk IT / jasa digital', + description: 'Produk dan layanan digital mengikuti data lokasi.', + offering_type: 'service', + }, + maxRadiusKm: 100, + priority: 5, + }, + { + id: 'agriculture-pet', + matchTerms: [ + 'pupuk', + 'bibit', + 'bibit tanaman', + 'pestisida', + 'alat pertanian', + 'pertanian', + 'peternakan', + 'pakan ternak', + 'pet shop', + 'klinik hewan', + 'grooming hewan', + 'hewan peliharaan', + ], + filters: [ + { key: 'shop', value: 'garden_centre' }, + { key: 'shop', value: 'agrarian' }, + { key: 'shop', value: 'farm' }, + { key: 'shop', value: 'pet' }, + { key: 'shop', value: 'animal_feed' }, + { key: 'amenity', value: 'veterinary' }, + { key: 'healthcare', value: 'veterinary' }, + { key: 'craft', value: 'gardener' }, + ], + defaultName: 'Pertanian / Pet Shop', + shortDescription: + 'Toko pupuk, bibit, alat pertanian, pakan ternak, pet shop, klinik hewan, atau grooming dari OpenStreetMap.', + fullDescription: + 'Data pertanian dan hewan peliharaan diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', + keywords: + 'pupuk bibit tanaman pestisida alat pertanian peternakan pakan ternak pet shop klinik hewan grooming hewan', + category: { + id: 'openstreetmap-agriculture-pet', + name: 'Pertanian / Hewan', + slug: 'pertanian-hewan', + color_hex: '#65A30D', + description: 'Pertanian, peternakan, dan hewan dari OpenStreetMap.', + }, + offering: { + name: 'Produk pertanian / hewan', + description: 'Produk dan layanan mengikuti informasi lokasi.', + offering_type: 'product', + }, + maxRadiusKm: 150, + priority: 5, + }, + { + id: 'professional-services', + matchTerms: [ + 'notaris', + 'pengacara', + 'guru privat', + 'les privat', + 'fotografer', + 'videografer', + 'wedding organizer', + 'event organizer', + 'jasa pajak', + 'konsultan bisnis', + 'konsultan hukum', + 'konsultan keuangan', + 'lowongan kerja', + 'freelance', + 'magang', + 'seminar', + 'festival', + 'bazar', + ], + filters: [ + { key: 'office', value: 'lawyer' }, + { key: 'office', value: 'notary' }, + { key: 'office', value: 'accountant' }, + { key: 'office', value: 'tax_advisor' }, + { key: 'office', value: 'consulting' }, + { key: 'office', value: 'employment_agency' }, + { key: 'office', value: 'educational_institution' }, + { key: 'craft', value: 'photographer' }, + { key: 'amenity', value: 'events_venue' }, + ], + defaultName: 'Jasa Profesional / Event', + shortDescription: + 'Notaris, pengacara, konsultan, guru privat, fotografer, wedding organizer, lowongan, atau event dari OpenStreetMap.', + fullDescription: + 'Data jasa profesional dan event diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', + keywords: + 'notaris pengacara guru privat les privat digital marketing seo website desain grafis fotografer videografer wedding organizer event organizer jasa pajak konsultan lowongan kerja freelance magang seminar festival bazar', + category: { + id: 'openstreetmap-professional-services', + name: 'Jasa Profesional / Event', + slug: 'jasa-profesional-event', + color_hex: '#7C3AED', + description: 'Jasa profesional, lowongan, dan event dari OpenStreetMap.', + }, + offering: { + name: 'Jasa profesional / event', + description: 'Layanan mengikuti informasi lokasi.', + offering_type: 'service', + }, + maxRadiusKm: 150, + priority: 4, + }, + { + id: 'logistics-rental-travel', + matchTerms: [ + 'rental mobil', + 'rental motor', + 'agen travel', + 'travel', + 'kurir', + 'ekspedisi', + 'logistik', + 'pengiriman', + 'pengiriman barang', + 'antar', + 'kirim', + ], + filters: [ + { key: 'amenity', value: 'car_rental' }, + { key: 'amenity', value: 'bicycle_rental' }, + { key: 'shop', value: 'rental' }, + { key: 'shop', value: 'motorcycle' }, + { key: 'tourism', value: 'travel_agency' }, + { key: 'office', value: 'logistics' }, + { key: 'office', value: 'courier' }, + { key: 'amenity', value: 'post_office' }, + ], + defaultName: 'Travel / Rental / Logistik', + shortDescription: + 'Rental mobil/motor, agen travel, kurir, ekspedisi, logistik, atau pengiriman barang dari OpenStreetMap.', + fullDescription: + 'Data travel/rental/logistik diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', + keywords: + 'rental mobil rental motor agen travel kurir ekspedisi logistik pengiriman barang antar kirim', + category: { + id: 'openstreetmap-logistics-rental-travel', + name: 'Travel / Logistik', + slug: 'travel-logistik', + color_hex: '#0891B2', + description: 'Travel, rental, dan logistik dari OpenStreetMap.', + }, + offering: { + name: 'Travel / rental / pengiriman', + description: 'Layanan mengikuti informasi lokasi.', + offering_type: 'service', + }, + maxRadiusKm: 150, + priority: 4, + }, + { + id: 'property-listings', + matchTerms: [ + 'properti', + 'rumah dijual', + 'tanah dijual', + 'ruko', + 'apartemen', + 'kost', + 'kos', + 'kontrakan', + 'rumah kontrakan', + 'sewa rumah', + ], + filters: [ + { key: 'office', value: 'estate_agent' }, + { key: 'tourism', value: 'apartment' }, + { key: 'tourism', value: 'guest_house' }, + ], + defaultName: 'Properti / Kost', + shortDescription: + 'Agen properti, rumah dijual, tanah dijual, ruko, apartemen, kost, atau kontrakan dari OpenStreetMap.', + fullDescription: + 'Data properti diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', + keywords: + 'properti rumah dijual tanah dijual ruko apartemen kost kos kontrakan sewa rumah estate agent', + category: { + id: 'openstreetmap-property-listings', + name: 'Properti / Kost', + slug: 'properti-kost', + color_hex: '#0F766E', + description: 'Properti dan kost dari OpenStreetMap.', + }, + offering: { + name: 'Listing properti', + description: 'Ketersediaan properti mengikuti informasi lokasi.', + offering_type: 'service', + }, + maxRadiusKm: 150, + priority: 4, + }, +]; + +OSM_SEARCH_PROFILES.push(...EXTRA_OSM_SEARCH_PROFILES); + const OSM_TAG_VALUE_KEYWORDS = { atm: 'atm bank tunai uang cash', bakery: 'roti bakery kue makanan kuliner', @@ -1851,6 +2458,66 @@ const OSM_TAG_VALUE_KEYWORDS = { zoo: 'kebun binatang zoo wisata rekreasi', }; + +Object.assign(OSM_TAG_VALUE_KEYWORDS, { + accountant: 'akuntan konsultan keuangan jasa pajak kantor jasa profesional', + advertising_agency: + 'digital marketing seo desain grafis website aplikasi jasa profesional', + agrarian: 'pupuk bibit pestisida alat pertanian toko pertanian', + animal_feed: 'pakan ternak peternakan hewan toko', + apartment: 'apartemen properti penginapan sewa', + apartments: 'apartemen properti sewa rumah kost', + baby_goods: 'perlengkapan bayi mainan anak toko produk bayi', + bag: 'tas fashion aksesoris toko belanja', + beverages: 'minuman beverage cafe warung kuliner', + building_materials: 'bahan bangunan material semen cat keramik hardware toko', + builder: 'tukang bangunan renovasi rumah kontraktor jasa', + camera: 'kamera cctv fotografer videografer elektronik', + car_rental: 'rental mobil sewa mobil travel kendaraan', + carpenter: 'tukang kayu renovasi rumah furniture jasa bangunan', + cleaning: 'jasa kebersihan cleaning service rumah kantor', + commercial: 'ruko properti toko kantor sewa jual', + confectionery: 'makanan kue minuman kuliner toko', + consulting: 'konsultan bisnis konsultan hukum konsultan keuangan jasa', + copyshop: 'percetakan fotocopy kebutuhan kantor printing', + courier: 'kurir ekspedisi logistik pengiriman barang paket', + doityourself: 'toko bahan bangunan hardware material alat rumah', + electrician: 'tukang listrik renovasi rumah jasa bangunan', + electronics_repair: 'service laptop service printer servis elektronik komputer', + employment_agency: 'lowongan kerja freelance magang employment', + events_venue: 'event seminar festival bazar wedding organizer venue', + farm: 'pertanian pupuk bibit pakan ternak toko', + financial: 'keuangan koperasi pegadaian bank pembayaran qris', + garden_centre: 'pupuk bibit tanaman pestisida alat pertanian toko', + gift: 'aksesoris hadiah mainan toko belanja', + greengrocer: 'makanan sayur buah toko kelontong warung', + herbalist: 'herbal jamu obat alami kesehatan apotek', + house: 'rumah properti rumah dijual kontrakan sewa', + houseware: 'peralatan rumah tangga alat dapur furniture toko', + hvac: 'servis ac service ac pendingin ruangan jasa rumah', + it: 'komputer laptop website aplikasi internet wifi digital', + jewelry: 'perhiasan emas aksesoris toko fashion', + kitchen: 'peralatan dapur kitchen alat rumah tangga toko', + laboratory: 'laboratorium lab kesehatan medis', + lawyer: 'pengacara konsultan hukum kantor hukum jasa profesional', + logistics: 'logistik ekspedisi kurir pengiriman barang paket', + medical_lab: 'laboratorium lab kesehatan medis', + medical_supply: 'alat kesehatan alkes kesehatan apotek', + notary: 'notaris hukum legal akta jasa profesional', + painter: 'tukang cat renovasi rumah jasa bangunan', + pawnbroker: 'pegadaian gadai keuangan koperasi', + pet: 'pet shop hewan peliharaan pakan hewan grooming', + plumber: 'tukang pipa renovasi rumah jasa bangunan', + rental: 'rental sewa mobil motor travel', + tailor: 'konveksi jahit fashion pakaian baju', + tax_advisor: 'jasa pajak konsultan pajak akuntan keuangan', + telecommunication: 'internet wifi telkom telekomunikasi komputer', + toys: 'mainan anak mainan bayi toko produk', + travel_agency: 'agen travel wisata rental mobil perjalanan', + veterinary: 'klinik hewan dokter hewan pet shop grooming hewan', + watches: 'jam tangan aksesoris fashion toko', +}); + const normalizeOsmTagValue = (value) => normalizeText(String(value || '').replace(/_/g, ' ')); @@ -1888,22 +2555,6 @@ const osmTagMatchesFilter = (tags, filter) => 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); @@ -1919,6 +2570,39 @@ const buildExpandedOsmTokens = (tokens) => { return expanded; }; +const scoreOsmProfileMatch = ( + profile, + normalizedText, + directTokens, + expandedTokens, +) => + profile.matchTerms.reduce((total, term) => { + const normalizedTerm = normalizeText(term); + + if (!normalizedTerm) { + return total; + } + + const termWordCount = normalizedTerm.split(/\s+/).filter(Boolean).length; + + if (normalizedTerm.includes(' ') && normalizedText.includes(normalizedTerm)) { + return total + 12 + termWordCount; + } + + if ( + directTokens.has(normalizedTerm) || + includesToken(normalizedText, normalizedTerm) + ) { + return total + 6; + } + + if (expandedTokens.has(normalizedTerm)) { + return total + 2; + } + + return total; + }, Number(profile.priority || 0)); + const getOsmSearchProfiles = (query, category) => { const searchText = [query, category].filter(Boolean).join(' '); const normalized = normalizeText(searchText); @@ -1929,25 +2613,29 @@ const getOsmSearchProfiles = (query, category) => { 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), + const matchedProfiles = OSM_SEARCH_PROFILES.map((profile) => ({ + profile, + score: scoreOsmProfileMatch( + profile, + normalized, + directTokens, + expandedTokens, ), - ); + })) + .filter((item) => item.score > 0) + .sort((a, b) => b.score - a.score); - if (exactProfiles.length) { - return exactProfiles.slice(0, 4); + if (matchedProfiles.length) { + return matchedProfiles + .slice(0, OSM_PROFILE_MATCH_LIMIT) + .map((item) => item.profile); } 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); + return []; }; const getOsmElementProfile = (element, profiles) => { @@ -2188,7 +2876,7 @@ const postOverpassQuery = async ( try { return await axios.post(endpoint, body.toString(), { - timeout: 10000, + timeout: 4500, headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', @@ -2568,12 +3256,43 @@ router.get( const externalSourceErrors = []; const places = await loadPublicPlaces(category); const publicPlaces = places.map((place) => toPublicPlace(place, origin)); + const minimumResultTarget = Math.min(MIN_NEAREST_RESULTS, limit); let externalPlaces = []; + let effectiveRadiusKm = radiusKm; + let expandedForNearest = false; + + const buildCombinedPlaces = () => + dedupePlacesById([...externalPlaces, ...publicPlaces]); + + const getPlacesWithinRadius = (candidatePlaces, currentRadiusKm) => + origin + ? candidatePlaces.filter( + (place) => + place.distance_km !== null && + place.distance_km !== undefined && + place.distance_km <= currentRadiusKm, + ) + : candidatePlaces; + + const buildScoredRows = (candidatePlaces, scoreRadiusKm = effectiveRadiusKm) => + candidatePlaces + .map((place) => { + const geoScore = calculateGeoScore(place, query, scoreRadiusKm); + + return { + place, + geoScore, + }; + }) + .filter( + (item) => !hasQuery || item.geoScore.components.relevance > 0, + ) + .sort((a, b) => sortScoredPlaces(a, b, hasQuery, queryIntent)); if (origin && (hasQuery || category)) { try { const externalRadiusKm = queryIntent.nearby - ? Math.min(Math.max(radiusKm * 2, 10), 25) + ? Math.min(Math.max(radiusKm * 5, 25), 50) : radiusKm; externalPlaces = await fetchOsmPlaces( @@ -2581,10 +3300,10 @@ router.get( query, category, externalRadiusKm, - limit, + Math.max(limit, minimumResultTarget), ); } catch (err) { - console.error('Gagal memuat data OpenStreetMap', { + console.log('Gagal memuat data OpenStreetMap', { query, category, message: err.message, @@ -2598,45 +3317,83 @@ router.get( } } - const combinedPlaces = [...externalPlaces, ...publicPlaces]; - const radiusFilteredPlaces = origin - ? combinedPlaces.filter( + let combinedPlaces = buildCombinedPlaces(); + let scoredRows = buildScoredRows( + getPlacesWithinRadius(combinedPlaces, effectiveRadiusKm), + effectiveRadiusKm, + ); + + if (origin && hasQuery && scoredRows.length < minimumResultTarget) { + const expansionRadii = [ + Math.min(Math.max(radiusKm * 10, 50), MAX_OSM_EXPANSION_RADIUS_KM), + ].filter((nextRadiusKm) => nextRadiusKm > effectiveRadiusKm); + + for (const nextRadiusKm of expansionRadii) { + if (!externalSourceErrors.length && (hasQuery || category)) { + try { + const widerExternalPlaces = await fetchOsmPlaces( + origin, + query, + category, + nextRadiusKm, + Math.max(limit, minimumResultTarget), + ); + + externalPlaces = dedupePlacesById([ + ...externalPlaces, + ...widerExternalPlaces, + ]); + combinedPlaces = buildCombinedPlaces(); + } catch (err) { + console.log('Gagal memperluas data OpenStreetMap', { + query, + category, + radiusKm: nextRadiusKm, + message: err.message, + status: err.response?.status, + data: err.response?.data, + overpass_errors: err.overpass_errors, + }); + externalSourceErrors.push( + 'Radius OpenStreetMap tidak bisa diperluas saat ini; hasil internal tetap ditampilkan jika tersedia.', + ); + } + } + + const expandedRows = buildScoredRows( + getPlacesWithinRadius(combinedPlaces, nextRadiusKm), + nextRadiusKm, + ); + + if (expandedRows.length > scoredRows.length) { + scoredRows = expandedRows; + effectiveRadiusKm = nextRadiusKm; + expandedForNearest = effectiveRadiusKm > radiusKm; + } + + if ( + scoredRows.length >= minimumResultTarget || + externalSourceErrors.length + ) { + break; + } + } + } + + if (origin && hasQuery && scoredRows.length < minimumResultTarget) { + const allDistanceRows = buildScoredRows( + combinedPlaces.filter( (place) => - place.distance_km !== null && place.distance_km <= radiusKm, - ) - : combinedPlaces; - const buildScoredRows = (candidatePlaces) => - candidatePlaces - .map((place) => { - const geoScore = calculateGeoScore(place, query, radiusKm); - - return { - place, - geoScore, - }; - }) - .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, + place.distance_km !== null && place.distance_km !== undefined, + ), + GLOBAL_RADIUS_KM, ); - scoredRows = buildScoredRows(expandedPlaces); - expandedForNearest = scoredRows.length > 0; + if (allDistanceRows.length > scoredRows.length) { + scoredRows = allDistanceRows; + effectiveRadiusKm = GLOBAL_RADIUS_KM; + expandedForNearest = effectiveRadiusKm > radiusKm; + } } const rows = scoredRows.slice(0, limit).map((item) => ({ @@ -2644,15 +3401,13 @@ router.get( search_score: item.geoScore.value, geo_score: item.geoScore.value, geo_score_breakdown: item.geoScore.components, - radius_zone: getRadiusZone(item.place.distance_km, radiusKm), + radius_zone: getRadiusZone(item.place.distance_km, effectiveRadiusKm), 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), + ? getPlacesWithinRadius( + combinedPlaces, + Math.max(effectiveRadiusKm, radiusKm, 100), ) : combinedPlaces; @@ -2660,7 +3415,8 @@ router.get( rows, count: scoredRows.length, radius_km: origin ? radiusKm : null, - radius_zone: getRadiusZone(null, radiusKm), + effective_radius_km: origin ? effectiveRadiusKm : null, + radius_zone: getRadiusZone(null, effectiveRadiusKm), query_intent: queryIntent, radius_zones: RADIUS_ZONES, distance_buckets: buildDistanceBuckets(nearbyIndexedPlaces), @@ -2671,7 +3427,7 @@ router.get( total_candidates: nearbyIndexedPlaces.length, geo_score_formula: GEO_SCORE_FORMULA, trending: buildTrending(rows), - recommendation_engine: 'rules_based_geo_score_mvp', + recommendation_engine: 'rules_based_geo_score_mvp_min_10_expansion', }); }), ); diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 6104712..ce55cdb 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -153,6 +153,7 @@ type TrendingMeta = { type SearchMeta = { count: number; radius_km?: number | null; + effective_radius_km?: number | null; radius_zone?: RadiusZone | null; radius_zones: RadiusZone[]; distance_buckets: DistanceBucket[]; @@ -520,103 +521,214 @@ const categoryKeywordGroups: KeywordGroup[] = [ { title: 'Kuliner', description: - 'Intent restoran, cafe, warung makan, kuliner, dan makan enak.', + 'Intent makanan, restoran, cafe, warung makan, warteg, bakso, soto, seafood, dan catering.', keywords: [ - { - label: 'Restoran terdekat', - query: 'restoran terdekat', - category: 'cafe-melayu', - }, - { - label: 'Cafe terdekat', - query: 'cafe terdekat', - category: 'cafe-melayu', - }, - { - label: 'Warung makan terdekat', - query: 'warung makan terdekat', - category: 'cafe-melayu', - }, - { - label: 'Kuliner terdekat', - query: 'kuliner terdekat', - category: 'cafe-melayu', - }, - { - label: 'Makan enak dekat saya', - query: 'makan enak dekat saya', - category: 'cafe-melayu', - }, + { label: 'Makanan terdekat', query: 'makanan terdekat' }, + { label: 'Restoran terdekat', query: 'restoran terdekat' }, + { label: 'Cafe terdekat', query: 'cafe terdekat' }, + { label: 'Warung makan terdekat', query: 'warung makan terdekat' }, + { label: 'Warteg terdekat', query: 'warteg terdekat' }, + { label: 'Bakso terdekat', query: 'bakso terdekat' }, + { label: 'Soto terdekat', query: 'soto terdekat' }, + { label: 'Ayam goreng terdekat', query: 'ayam goreng terdekat' }, + { label: 'Seafood terdekat', query: 'seafood terdekat' }, + { label: 'Catering terdekat', query: 'catering terdekat' }, + { label: 'Minuman terdekat', query: 'minuman terdekat' }, ], }, { - title: 'Kesehatan', + title: 'Produk & Toko', description: - 'Intent apotek, klinik, rumah sakit, dokter, dan layanan 24 jam.', + 'Intent penjual, toko, ready stock, elektronik, fashion, herbal, furniture, bayi, buku, perhiasan, dan kebutuhan kantor.', keywords: [ + { label: 'Smartphone terdekat', query: 'penjual smartphone terdekat' }, + { label: 'Laptop terdekat', query: 'penjual laptop terdekat' }, + { label: 'Elektronik terdekat', query: 'toko elektronik terdekat' }, + { label: 'Fashion terdekat', query: 'toko fashion terdekat' }, + { label: 'Sepatu terdekat', query: 'toko sepatu terdekat' }, + { label: 'Tas terdekat', query: 'toko tas terdekat' }, + { label: 'Kosmetik terdekat', query: 'toko kosmetik terdekat' }, + { label: 'Skincare terdekat', query: 'toko skincare terdekat' }, + { label: 'Herbal terdekat', query: 'toko herbal terdekat' }, + { label: 'Obat terdekat', query: 'toko obat terdekat' }, + { label: 'Furniture terdekat', query: 'toko furniture terdekat' }, { - label: 'Apotek terdekat', - query: 'apotek terdekat', - category: 'toko-herbal-kesehatan', - }, - { - label: 'Klinik terdekat', - query: 'klinik terdekat', - category: 'toko-herbal-kesehatan', - }, - { - label: 'Rumah sakit terdekat', - query: 'rumah sakit terdekat', - category: 'toko-herbal-kesehatan', - }, - { - label: 'Dokter terdekat', - query: 'dokter terdekat', - category: 'toko-herbal-kesehatan', - }, - { - label: 'Apotek 24 jam terdekat', - query: 'apotek 24 jam terdekat', - category: 'toko-herbal-kesehatan', + label: 'Peralatan rumah tangga', + query: 'peralatan rumah tangga terdekat', }, + { label: 'Peralatan dapur', query: 'peralatan dapur terdekat' }, + { label: 'Perlengkapan bayi', query: 'perlengkapan bayi terdekat' }, + { label: 'Mainan anak', query: 'mainan anak terdekat' }, + { label: 'Buku terdekat', query: 'toko buku terdekat' }, + { label: 'Kebutuhan kantor', query: 'kebutuhan kantor terdekat' }, + { label: 'Perhiasan terdekat', query: 'toko perhiasan terdekat' }, + { label: 'Jam tangan terdekat', query: 'toko jam tangan terdekat' }, + { label: 'Aksesoris terdekat', query: 'toko aksesoris terdekat' }, ], }, { - title: 'Otomotif', + title: 'Bangunan & Otomotif', description: - 'Intent bengkel, tambal ban, cuci mobil, SPBU, dan servis kendaraan.', + 'Intent toko bahan bangunan, renovasi, servis AC, bengkel, sparepart, dealer, tambal ban, dan cuci mobil.', keywords: [ - { - label: 'Bengkel terdekat', - query: 'bengkel terdekat', - category: 'bengkel-mobil', - }, - { - label: 'Tambal ban terdekat', - query: 'tambal ban terdekat', - category: 'bengkel-mobil', - }, - { - label: 'Cuci mobil terdekat', - query: 'cuci mobil terdekat', - category: 'bengkel-mobil', - }, - { - label: 'SPBU terdekat', - query: 'SPBU terdekat', - category: 'bengkel-mobil', - }, + { label: 'Toko bahan bangunan', query: 'toko bahan bangunan terdekat' }, + { label: 'Servis AC terdekat', query: 'servis ac terdekat' }, + { label: 'Tukang bangunan', query: 'tukang bangunan terdekat' }, + { label: 'Renovasi rumah', query: 'renovasi rumah terdekat' }, + { label: 'Jasa kebersihan', query: 'jasa kebersihan terdekat' }, + { label: 'Bengkel motor', query: 'bengkel motor terdekat' }, + { label: 'Bengkel mobil', query: 'bengkel mobil terdekat' }, + { label: 'Sparepart motor', query: 'sparepart motor terdekat' }, + { label: 'Sparepart mobil', query: 'sparepart mobil terdekat' }, + { label: 'Dealer motor', query: 'dealer motor terdekat' }, + { label: 'Tambal ban', query: 'tambal ban terdekat' }, + { label: 'Cuci mobil', query: 'cuci mobil terdekat' }, + { label: 'SPBU terdekat', query: 'SPBU terdekat' }, ], }, { - title: 'Belanja', + title: 'Kesehatan & Hewan', description: - 'Intent toko, minimarket, pasar, mall, produk, dan stok ready.', + 'Intent rumah sakit, klinik, apotek, dokter, laboratorium, alat kesehatan, pet shop, klinik hewan, dan grooming.', keywords: [ - { label: 'Toko terdekat', query: 'toko terdekat' }, - { label: 'Minimarket terdekat', query: 'minimarket terdekat' }, - { label: 'Pasar terdekat', query: 'pasar terdekat' }, - { label: 'Mall terdekat', query: 'mall terdekat' }, + { label: 'Rumah sakit terdekat', query: 'rumah sakit terdekat' }, + { label: 'Klinik terdekat', query: 'klinik terdekat' }, + { label: 'Apotek terdekat', query: 'apotek terdekat' }, + { label: 'Dokter terdekat', query: 'dokter terdekat' }, + { label: 'Dokter gigi', query: 'dokter gigi terdekat' }, + { label: 'Laboratorium', query: 'laboratorium terdekat' }, + { label: 'Alat kesehatan', query: 'alat kesehatan terdekat' }, + { label: 'Pet shop', query: 'pet shop terdekat' }, + { label: 'Klinik hewan', query: 'klinik hewan terdekat' }, + { label: 'Grooming hewan', query: 'grooming hewan terdekat' }, + ], + }, + { + title: 'Properti', + description: + 'Intent properti, rumah dijual, tanah dijual, ruko, apartemen, kost, dan kontrakan.', + keywords: [ + { label: 'Properti terdekat', query: 'properti terdekat' }, + { label: 'Rumah dijual', query: 'rumah dijual terdekat' }, + { label: 'Tanah dijual', query: 'tanah dijual terdekat' }, + { label: 'Ruko terdekat', query: 'ruko terdekat' }, + { label: 'Apartemen terdekat', query: 'apartemen terdekat' }, + { label: 'Kost terdekat', query: 'kost terdekat' }, + { label: 'Kontrakan terdekat', query: 'kontrakan terdekat' }, + ], + }, + { + title: 'Jasa Profesional & Digital', + description: + 'Intent notaris, pengacara, guru privat, digital marketing, website, aplikasi, desain, foto/video, pajak, dan konsultan.', + keywords: [ + { label: 'Notaris terdekat', query: 'notaris terdekat' }, + { label: 'Pengacara terdekat', query: 'pengacara terdekat' }, + { label: 'Guru privat', query: 'guru privat terdekat' }, + { label: 'Les privat', query: 'les privat terdekat' }, + { label: 'Digital marketing', query: 'digital marketing terdekat' }, + { label: 'SEO terdekat', query: 'jasa seo terdekat' }, + { label: 'Pembuatan website', query: 'pembuatan website terdekat' }, + { label: 'Pembuatan aplikasi', query: 'pembuatan aplikasi terdekat' }, + { label: 'Desain grafis', query: 'desain grafis terdekat' }, + { label: 'Fotografer', query: 'fotografer terdekat' }, + { label: 'Videografer', query: 'videografer terdekat' }, + { label: 'Wedding organizer', query: 'wedding organizer terdekat' }, + { label: 'Event organizer', query: 'event organizer terdekat' }, + { label: 'Jasa pajak', query: 'jasa pajak terdekat' }, + { label: 'Konsultan bisnis', query: 'konsultan bisnis terdekat' }, + { label: 'Konsultan hukum', query: 'konsultan hukum terdekat' }, + { label: 'Konsultan keuangan', query: 'konsultan keuangan terdekat' }, + { label: 'Percetakan', query: 'percetakan terdekat' }, + { label: 'Konveksi', query: 'konveksi terdekat' }, + ], + }, + { + title: 'Pertanian & Peternakan', + description: + 'Intent pupuk, bibit tanaman, pestisida, alat pertanian, peternakan, dan pakan ternak.', + keywords: [ + { label: 'Pupuk terdekat', query: 'pupuk terdekat' }, + { label: 'Bibit tanaman', query: 'bibit tanaman terdekat' }, + { label: 'Pestisida terdekat', query: 'pestisida terdekat' }, + { label: 'Alat pertanian', query: 'alat pertanian terdekat' }, + { label: 'Peternakan', query: 'peternakan terdekat' }, + { label: 'Pakan ternak', query: 'pakan ternak terdekat' }, + ], + }, + { + title: 'Keuangan', + description: 'Intent bank, ATM, koperasi, pegadaian, pembayaran, dan QRIS.', + keywords: [ + { label: 'Bank terdekat', query: 'bank terdekat' }, + { label: 'ATM terdekat', query: 'ATM terdekat' }, + { label: 'Koperasi terdekat', query: 'koperasi terdekat' }, + { label: 'Pegadaian terdekat', query: 'pegadaian terdekat' }, + ], + }, + { + title: 'Travel & Logistik', + description: + 'Intent rental mobil/motor, travel, agen travel, kurir, logistik, ekspedisi, dan pengiriman barang.', + keywords: [ + { label: 'Rental mobil', query: 'rental mobil terdekat' }, + { label: 'Rental motor', query: 'rental motor terdekat' }, + { label: 'Travel terdekat', query: 'travel terdekat' }, + { label: 'Agen travel', query: 'agen travel terdekat' }, + { label: 'Kurir terdekat', query: 'kurir terdekat' }, + { label: 'Logistik terdekat', query: 'logistik terdekat' }, + { label: 'Ekspedisi terdekat', query: 'ekspedisi terdekat' }, + { label: 'Pengiriman barang', query: 'pengiriman barang terdekat' }, + ], + }, + { + title: 'Kecantikan', + description: 'Intent salon, barbershop, skincare, kosmetik, dan beauty care.', + keywords: [ + { label: 'Salon terdekat', query: 'salon terdekat' }, + { label: 'Barbershop terdekat', query: 'barbershop terdekat' }, + { label: 'Skincare terdekat', query: 'skincare terdekat' }, + { label: 'Kosmetik terdekat', query: 'kosmetik terdekat' }, + ], + }, + { + title: 'Komputer & Internet', + description: + 'Intent service laptop, service printer, komputer, CCTV, internet, wifi, dan toko IT.', + keywords: [ + { label: 'Service laptop', query: 'service laptop terdekat' }, + { label: 'Service printer', query: 'service printer terdekat' }, + { label: 'Komputer terdekat', query: 'komputer terdekat' }, + { label: 'CCTV terdekat', query: 'cctv terdekat' }, + { label: 'Internet terdekat', query: 'internet terdekat' }, + { label: 'Wifi terdekat', query: 'wifi terdekat' }, + ], + }, + { + title: 'Kerja, Event & UMKM', + description: + 'Intent lowongan kerja, freelance, magang, seminar, festival, bazar, UMKM, toko kelontong, dan home industry.', + keywords: [ + { label: 'Lowongan kerja', query: 'lowongan kerja terdekat' }, + { label: 'Freelance', query: 'freelance terdekat' }, + { label: 'Magang', query: 'magang terdekat' }, + { label: 'Seminar', query: 'seminar terdekat' }, + { label: 'Festival', query: 'festival terdekat' }, + { label: 'Bazar', query: 'bazar terdekat' }, + { label: 'UMKM terdekat', query: 'umkm terdekat' }, + { label: 'Toko kelontong', query: 'toko kelontong terdekat' }, + { label: 'Home industry', query: 'home industry terdekat' }, + ], + }, + { + title: 'Wisata & Penginapan', + description: 'Intent tempat wisata, hotel, penginapan, villa, dan pantai.', + keywords: [ + { label: 'Tempat wisata', query: 'tempat wisata terdekat' }, + { label: 'Hotel terdekat', query: 'hotel terdekat' }, + { label: 'Penginapan terdekat', query: 'penginapan terdekat' }, + { label: 'Villa terdekat', query: 'villa terdekat' }, + { label: 'Pantai terdekat', query: 'pantai terdekat' }, ], }, { @@ -634,114 +746,84 @@ const categoryKeywordGroups: KeywordGroup[] = [ { label: 'Kantor BPJS terdekat', query: 'kantor bpjs terdekat' }, ], }, - { - title: 'Wisata', - description: 'Intent tempat wisata, pantai, hotel, dan penginapan.', - keywords: [ - { label: 'Tempat wisata terdekat', query: 'tempat wisata terdekat' }, - { label: 'Pantai terdekat', query: 'pantai terdekat' }, - { label: 'Hotel terdekat', query: 'hotel terdekat' }, - { label: 'Penginapan terdekat', query: 'penginapan terdekat' }, - ], - }, ]; const requiredLocationKeywords: KeywordChip[] = [ { label: 'Terdekat', query: 'terdekat' }, { label: 'Dekat Saya', query: 'dekat saya' }, + { label: 'Near Me', query: 'near me' }, { label: 'Sekitar Saya', query: 'sekitar saya' }, + { label: 'Lokasi Terdekat', query: 'lokasi terdekat' }, + { label: 'Alamat Penjual', query: 'alamat penjual terdekat' }, + { label: 'Lokasi Penjual', query: 'lokasi penjual terdekat' }, { label: 'Buka Sekarang', query: 'buka sekarang' }, { label: '24 Jam', query: '24 jam' }, - { 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 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' }, + { label: 'Murah', query: 'murah terdekat' }, + { label: 'Terbaik', query: 'terbaik terdekat' }, + { label: 'Terlaris', query: 'terlaris terdekat' }, + { label: 'Terpopuler', query: 'terpopuler terdekat' }, { label: 'Rating Tertinggi', query: 'rating tertinggi' }, - { label: 'Paling Ramai', query: 'paling ramai' }, - { label: 'Paling Murah', query: 'paling murah' }, - { label: 'Terlaris', query: 'terlaris' }, - { label: 'Terbaru', query: 'terbaru' }, - { label: 'Direkomendasikan', query: 'direkomendasikan' }, - { label: 'Terverifikasi', query: 'terverifikasi' }, + { label: 'Promo', query: 'promo terdekat' }, + { label: 'Diskon', query: 'diskon terdekat' }, + { label: 'Ready Stock', query: 'ready stock terdekat' }, + { label: 'Tersedia', query: 'tersedia terdekat' }, + { label: 'Terverifikasi', query: 'terverifikasi terdekat' }, + { label: 'Radius 1 Km', query: 'dalam radius 1 km', radiusKm: 1 }, + { label: 'Radius 5 Km', query: 'dalam radius 5 km', radiusKm: 5 }, + { label: 'Radius 10 Km', query: 'dalam radius 10 km', radiusKm: 10 }, + { label: 'RT/RW', query: 'sekitar rt rw' }, + { label: 'Dusun', query: 'sekitar dusun' }, + { label: 'Desa', query: 'sekitar desa' }, + { label: 'Kelurahan', query: 'sekitar kelurahan' }, + { label: 'Kecamatan', query: 'sekitar kecamatan' }, + { label: 'Kabupaten', query: 'sekitar kabupaten' }, + { label: 'Kota', query: 'sekitar kota' }, + { label: 'Provinsi', query: 'sekitar provinsi' }, + { label: 'Beli', query: 'beli terdekat' }, + { label: 'Pesan', query: 'pesan terdekat' }, + { label: 'Booking', query: 'booking terdekat' }, + { label: 'Reservasi', query: 'reservasi terdekat' }, + { label: 'Order', query: 'order terdekat' }, + { label: 'Antar', query: 'antar terdekat' }, + { label: 'Kirim', query: 'kirim terdekat' }, + { label: 'COD', query: 'cod terdekat' }, + { label: 'QRIS', query: 'qris terdekat' }, + { label: 'E-Wallet', query: 'e-wallet terdekat' }, + { label: 'Cashback', query: 'cashback terdekat' }, + { label: 'Gratis Ongkir', query: 'gratis ongkir terdekat' }, + { label: 'Same Day', query: 'same day terdekat' }, + { label: 'Instant', query: 'instant terdekat' }, ]; const topTrafficKeywords: KeywordChip[] = [ - { label: 'Terdekat', query: 'terdekat' }, - { label: 'Dekat Saya', query: 'dekat saya' }, - { label: 'Near Me', query: 'near me' }, - { label: 'Buka Sekarang', query: 'buka sekarang' }, - { label: '24 Jam', query: '24 jam' }, - { - label: 'Restoran Terdekat', - query: 'restoran terdekat', - category: 'cafe-melayu', - }, - { label: 'Cafe Terdekat', query: 'cafe terdekat', category: 'cafe-melayu' }, - { - label: 'Warung Makan Terdekat', - query: 'warung makan terdekat', - category: 'cafe-melayu', - }, - { - label: 'Kuliner Terdekat', - query: 'kuliner terdekat', - category: 'cafe-melayu', - }, - { - label: 'Makan Enak Dekat Saya', - query: 'makan enak dekat saya', - category: 'cafe-melayu', - }, - { - label: 'Apotek Terdekat', - query: 'apotek terdekat', - category: 'toko-herbal-kesehatan', - }, - { - label: 'Klinik Terdekat', - query: 'klinik terdekat', - category: 'toko-herbal-kesehatan', - }, - { - label: 'Rumah Sakit Terdekat', - query: 'rumah sakit terdekat', - category: 'toko-herbal-kesehatan', - }, - { - label: 'Dokter Terdekat', - query: 'dokter terdekat', - category: 'toko-herbal-kesehatan', - }, - { - label: 'Apotek 24 Jam Terdekat', - query: 'apotek 24 jam terdekat', - category: 'toko-herbal-kesehatan', - }, - { - label: 'Bengkel Terdekat', - query: 'bengkel terdekat', - category: 'bengkel-mobil', - }, - { - label: 'Tambal Ban Terdekat', - query: 'tambal ban terdekat', - category: 'bengkel-mobil', - }, - { - label: 'Cuci Mobil Terdekat', - query: 'cuci mobil terdekat', - category: 'bengkel-mobil', - }, - { label: 'SPBU Terdekat', query: 'SPBU terdekat', category: 'bengkel-mobil' }, + { label: 'Makanan Terdekat', query: 'makanan terdekat' }, + { label: 'Warteg Terdekat', query: 'warteg terdekat' }, + { label: 'Restoran Terdekat', query: 'restoran terdekat' }, + { label: 'Cafe Terdekat', query: 'cafe terdekat' }, + { label: 'Apotek Terdekat', query: 'apotek terdekat' }, + { label: 'Rumah Sakit Terdekat', query: 'rumah sakit terdekat' }, + { label: 'Bengkel Terdekat', query: 'bengkel terdekat' }, + { label: 'Hotel Terdekat', query: 'hotel terdekat' }, + { label: 'ATM Terdekat', query: 'ATM terdekat' }, + { label: 'SPBU Terdekat', query: 'SPBU terdekat' }, + { label: 'Toko Terdekat', query: 'toko terdekat' }, + { label: 'Toko Bahan Bangunan', query: 'toko bahan bangunan terdekat' }, + { label: 'Laundry Terdekat', query: 'laundry terdekat' }, + { label: 'Servis AC Terdekat', query: 'servis ac terdekat' }, + { label: 'Service Laptop', query: 'service laptop terdekat' }, + { label: 'Dokter Terdekat', query: 'dokter terdekat' }, + { label: 'Kost Terdekat', query: 'kost terdekat' }, + { label: 'Rumah Dijual', query: 'rumah dijual terdekat' }, + { label: 'Lowongan Kerja', query: 'lowongan kerja terdekat' }, + { label: 'Pupuk Terdekat', query: 'pupuk terdekat' }, + { label: 'Rental Mobil', query: 'rental mobil terdekat' }, + { label: 'Kurir Terdekat', query: 'kurir terdekat' }, + { label: 'Jasa Terdekat', query: 'jasa terdekat' }, + { label: 'Wifi Terdekat', query: 'wifi terdekat' }, { label: 'Kantor BPN Terdekat', query: 'kantor bpn terdekat' }, { label: 'Kantor Camat Terdekat', query: 'kantor camat terdekat' }, { label: 'Samsat Terdekat', query: 'samsat terdekat' }, { label: 'Kantor PLN Terdekat', query: 'kantor pln terdekat' }, - { label: 'Toko Terdekat', query: 'toko terdekat' }, ]; const hyperlocalPatternLevels: PatternLevel[] = [ @@ -924,7 +1006,7 @@ export default function Starter() { lat: nextLocation.lat, lng: nextLocation.lng, radiusKm: nextRadiusKm, - limit: 18, + limit: 30, }, }); @@ -932,6 +1014,7 @@ export default function Starter() { setSearchMeta({ count: Number(response.data?.count || 0), radius_km: response.data?.radius_km, + effective_radius_km: response.data?.effective_radius_km, radius_zone: response.data?.radius_zone || null, radius_zones: Array.isArray(response.data?.radius_zones) ? response.data.radius_zones @@ -1178,7 +1261,7 @@ export default function Starter() { setQuery(event.target.value)} - placeholder='Cari “kantor BPN”, “kantor camat”, “Samsat”, “SPBU terdekat”, “nasi goreng dekat saya”...' + placeholder='Cari “warteg terdekat”, “toko bahan bangunan”, “service laptop”, “smartphone”, “Samsat”, “SPBU terdekat”...' 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' /> @@ -1226,7 +1309,8 @@ export default function Starter() {
Kategori pencarian dikosongkan otomatis — hasil ditentukan - dari kata kunci dan jarak. + dari kata kunci dan jarak. GeoSeek mengejar minimal 10 hasil + terdekat bila data tersedia.
@@ -1319,7 +1403,7 @@ export default function Starter() { . 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.' + ? ` Radius awal ${formatRadius(radiusKm)} belum memenuhi target minimal 10 hasil, jadi GeoSeek memperluas pencarian sampai ${formatRadius(searchMeta.effective_radius_km || radiusKm)} dan tetap mengurutkan berdasarkan jarak.` : ''}

@@ -1526,10 +1610,24 @@ export default function Starter() {

-

- {place.address || - [place.city, place.province].filter(Boolean).join(', ')} -

+
+

+ Alamat / lokasi penjual +

+

+ {place.address || + [place.city, place.province].filter(Boolean).join(', ') || + (place.latitude && place.longitude + ? `${place.latitude}, ${place.longitude}` + : 'Alamat detail belum tersedia')} +

+

+ Sumber:{' '} + {place.external_source === 'openstreetmap' + ? 'OpenStreetMap' + : 'Database GeoSeek'} +

+

- Belum ada hasil dalam radius ini + Data asli belum ditemukan untuk kata kunci ini

-

- Perbesar radius ke City/Regional Zone, coba kata kunci seperti - “kantor BPN”, “kantor camat”, “Samsat”, “cafe melayu”, atau - minta admin menambahkan listing baru. +

+ GeoSeek sudah mencoba memperluas radius dan mencari dari database + GeoSeek + OpenStreetMap. Sistem tidak membuat alamat palsu; jika + sumber publik belum punya data, tambahkan listing resmi agar + pencarian seperti “warteg terdekat” atau “toko bahan bangunan” + tidak kosong.

-
+
+ @@ -1587,7 +1694,7 @@ export default function Starter() { 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, terverifikasi, kantor pemerintahan, dan BUMN/BUMD. + terbaru, terverifikasi, produk, jasa, properti, kantor pemerintahan, dan BUMN/BUMD.

@@ -1635,8 +1742,8 @@ export default function Starter() { Kombinasi kata kunci

- Kuliner, kesehatan, otomotif, belanja, wisata, pemerintahan - & BUMN + Produk, jasa, kuliner, properti, logistik, pemerintahan + & semua intent hyperlocal

@@ -1681,7 +1788,7 @@ export default function Starter() { size={22} className='text-[#087F6D]' />{' '} - Top 20 keyword trafik tertinggi + Keyword trafik tertinggi
{topTrafficKeywords.map((keyword, index) => (