From 05b420638749767b97047f12a2413b3a051225c9 Mon Sep 17 00:00:00 2001
From: Flatlogic Bot
Date: Thu, 18 Jun 2026 02:02:31 +0000
Subject: [PATCH] GEOSEEK 3.0
---
backend/src/routes/publicPlaces.js | 916 ++++++++++++++++++++++++++---
frontend/src/pages/index.tsx | 487 +++++++++------
2 files changed, 1133 insertions(+), 270 deletions(-)
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.
-
+
+ setRadiusKm(100)}
+ className='rounded-full bg-[#073B3A] px-5 py-3 text-sm font-black text-white transition hover:bg-[#087F6D]'
+ >
+ Perluas radius 100 km
+
@@ -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) => (