const express = require('express'); const axios = require('axios'); const db = require('../db/models'); const wrapAsync = require('../helpers').wrapAsync; const commonErrorHandler = require('../helpers').commonErrorHandler; const router = express.Router(); const Sequelize = db.Sequelize; const Op = Sequelize.Op; const DEFAULT_LIMIT = 24; const MAX_LIMIT = 60; const CANDIDATE_LIMIT = 500; const DEFAULT_RADIUS_KM = 5; const GLOBAL_RADIUS_KM = 20038; const GEO_SCORE_FORMULA = { relevance: 40, distance: 25, reputation: 15, activity: 10, interaction: 10, }; const RADIUS_ZONES = [ { value: 1, label: 'Walking Zone', range: '0–1 Km', description: 'Paling cocok untuk jalan kaki dan kebutuhan sangat dekat.', }, { value: 5, label: 'Neighborhood Zone', range: '1–5 Km', description: 'Default GeoSeek: sekitar rumah, kantor, dan lingkungan sekitar.', }, { value: 25, label: 'City Zone', range: '5–25 Km', description: 'Menjangkau satu kota untuk pilihan yang lebih banyak.', }, { value: 100, label: 'Regional Zone', range: '25–100 Km', description: 'Area regional atau kabupaten/kota sekitar.', }, { value: 500, label: 'Provincial Zone', range: '100–500 Km', description: 'Skala provinsi untuk pencarian yang lebih luas.', }, { value: GLOBAL_RADIUS_KM, label: 'Global Zone', range: '500+ Km', description: 'Skala nasional/global ketika lokasi lokal tidak cukup.', }, ]; const DISTANCE_BUCKETS = [0.5, 1, 3, 5, 25, 100, 500, GLOBAL_RADIUS_KM]; const STOCK_STATUS_LABELS = { in_stock: 'Tersedia', limited: 'Stok terbatas', out_of_stock: 'Habis', by_request: 'By request', }; const STOP_WORDS = new Set([ 'ada', 'aku', 'atau', 'buka', 'cari', 'dan', 'dekat', 'desa', 'di', 'dengan', 'direkomendasikan', 'dalam', 'enak', 'ini', 'itu', 'jam', 'ke', 'kecamatan', 'kelurahan', 'km', 'kota', 'lokasi', 'me', 'murah', 'near', 'paling', 'populer', 'radius', 'ramai', 'rating', 'rumah', 'saya', 'sekarang', 'sekitar', 'terbaru', 'terdekat', 'terlaris', 'tertinggi', 'terverifikasi', 'untuk', 'yang', '10', '24', ]); const SYNONYMS = { ac: ['air', 'conditioner', 'pendingin', 'service', 'servis', 'jasa'], apotek: ['farmasi', 'obat', 'kesehatan', 'herbal', 'jamu', 'madu'], atm: ['bank', 'uang', 'tunai', 'lokal', 'toko', 'umkm'], aki: ['baterai', 'mobil', 'bengkel'], artikel: ['blog', 'berita', 'website', 'konten'], auto: ['automotif', 'otomotif', 'mobil', 'kendaraan', 'bengkel'], ban: ['roda', 'spooring', 'balancing', 'mobil', 'bengkel'], belanja: ['toko', 'produk', 'marketplace', 'jual', 'barang'], cuci: ['mobil', 'bengkel', 'otomotif', 'auto', 'kendaraan'], dokter: ['klinik', 'kesehatan', 'herbal', 'sehat', 'obat'], bengkel: ['servis', 'service', 'otomotif', 'automotif', 'auto', 'mobil', 'kendaraan', 'oli', 'tune', 'mesin', 'kaki'], booking: ['reservasi', 'jadwal', 'pesan', 'jasa'], cafe: ['kafe', 'kopi', 'coffee', 'kedai', 'warung', 'kuliner', 'makan', 'minum'], clinic: ['klinik', 'kesehatan', 'dokter', 'obat'], coffee: ['cafe', 'kafe', 'kopi', 'kedai'], dapur: ['kitchen', 'kabinet', 'interior', 'lemari'], dekor: ['dekorasi', 'interior', 'furniture', 'furnitur', 'mebel', 'perabot'], desain: ['design', 'interior', 'renovasi', 'dekorasi', 'jasa'], furniture: ['furnitur', 'mebel', 'perabot', 'sofa', 'lemari', 'interior'], furnitur: ['furniture', 'mebel', 'perabot', 'sofa', 'lemari', 'interior'], herbal: ['sehat', 'kesehatan', 'alami', 'jamu', 'madu', 'temulawak'], hotel: ['penginapan', 'wisata', 'travel', 'lokal', 'kuliner', 'cafe'], interior: ['furniture', 'furnitur', 'mebel', 'perabot', 'sofa', 'lemari', 'kitchen', 'set', 'dekorasi', 'desain'], jasa: ['layanan', 'service', 'servis', 'booking'], kaki: ['shockbreaker', 'bushing', 'bearing', 'rem', 'bengkel', 'mobil'], kafe: ['cafe', 'kopi', 'coffee', 'kedai', 'warung', 'kuliner', 'makan', 'minum'], kabinet: ['lemari', 'kitchen', 'dapur', 'interior'], kedai: ['cafe', 'kafe', 'kopi', 'warung', 'kuliner'], kitchen: ['dapur', 'kabinet', 'interior', 'furniture', 'furnitur', 'lemari'], kopi: ['cafe', 'kafe', 'coffee', 'kedai', 'warung', 'melayu', 'saring'], kuliner: ['cafe', 'kafe', 'kedai', 'warung', 'makan', 'minum'], klinik: ['clinic', 'dokter', 'kesehatan', 'herbal', 'sehat', 'obat'], laksa: ['melayu', 'makan', 'kuliner', 'kedai'], lemari: ['kabinet', 'furniture', 'furnitur', 'mebel', 'perabot', 'interior'], makan: ['kuliner', 'cafe', 'kafe', 'kedai', 'warung'], marketplace: ['produk', 'stok', 'jual', 'toko', 'belanja'], mall: ['belanja', 'toko', 'marketplace', 'produk', 'kuliner'], minimarket: ['belanja', 'toko', 'marketplace', 'produk'], mebel: ['furniture', 'furnitur', 'perabot', 'sofa', 'lemari', 'interior'], melayu: ['riau', 'tradisional', 'kopi', 'roti', 'jala', 'laksa', 'teh', 'tarik', 'saring'], mobil: ['bengkel', 'servis', 'service', 'otomotif', 'auto', 'kendaraan', 'oli', 'mesin'], nasi: ['makan', 'kuliner', 'melayu', 'lemak'], obat: ['apotek', 'farmasi', 'kesehatan', 'herbal', 'jamu'], oli: ['pelumas', 'ganti', 'servis', 'service', 'bengkel', 'mobil'], otomotif: ['automotif', 'auto', 'bengkel', 'mobil', 'kendaraan', 'servis', 'service'], pembayaran: ['bayar', 'digital', 'cashless', 'qris'], pantai: ['wisata', 'travel', 'penginapan', 'hotel', 'lokal'], pasar: ['belanja', 'toko', 'marketplace', 'produk'], perabot: ['furniture', 'furnitur', 'mebel', 'interior', 'sofa', 'lemari'], produk: ['barang', 'stok', 'ready', 'tersedia'], penginapan: ['hotel', 'wisata', 'travel', 'lokal'], rem: ['brake', 'bengkel', 'mobil', 'kaki'], restoran: ['restaurant', 'kuliner', 'makan', 'warung', 'cafe', 'kafe', 'kedai', 'nasi'], restaurant: ['restoran', 'kuliner', 'makan', 'warung', 'cafe', 'kafe', 'kedai'], roti: ['jala', 'melayu', 'kuliner', 'makan'], saring: ['kopi', 'melayu', 'kedai'], sekolah: ['pendidikan', 'kursus', 'belajar'], service: ['servis', 'bengkel', 'mobil', 'otomotif', 'auto', 'oli', 'tune', 'mesin'], sakit: ['kesehatan', 'klinik', 'dokter', 'obat', 'herbal'], servis: ['service', 'bengkel', 'mobil', 'otomotif', 'auto', 'oli', 'tune', 'mesin'], shockbreaker: ['shock', 'kaki', 'mobil', 'bengkel'], sofa: ['furniture', 'furnitur', 'mebel', 'interior', 'ruang', 'tamu'], bbm: ['spbu', 'bensin', 'pom', 'pertamina', 'shell', 'vivo', 'fuel'], bensin: ['spbu', 'bbm', 'pom', 'pertamina', 'shell', 'vivo', 'fuel'], fuel: ['spbu', 'bbm', 'bensin', 'pom', 'gas', 'station'], gas: ['spbu', 'bbm', 'bensin', 'pom', 'fuel', 'station'], pertamina: ['spbu', 'bbm', 'bensin', 'pom', 'fuel'], pom: ['spbu', 'bbm', 'bensin', 'pertamina', 'shell', 'vivo', 'fuel'], shell: ['spbu', 'bbm', 'bensin', 'pom', 'fuel'], spbu: ['bbm', 'bensin', 'pom', 'pertamina', 'shell', 'vivo', 'fuel'], spooring: ['balancing', 'ban', 'bengkel', 'mobil'], stok: ['stock', 'tersedia', 'ready', 'produk', 'barang'], tersedia: ['ready', 'stok', 'stock', 'in stock'], tambal: ['ban', 'bengkel', 'otomotif', 'mobil', 'roda'], toko: ['store', 'shop', 'belanja', 'jual', 'produk'], tempat: ['lokal', 'wisata', 'kuliner', 'toko', 'umkm'], tune: ['servis', 'service', 'mesin', 'bengkel', 'mobil'], umkm: ['usaha', 'lokal', 'toko', 'jasa', 'produk'], warung: ['kedai', 'cafe', 'kafe', 'kopi', 'kuliner', 'makan'], wisata: ['travel', 'hotel', 'penginapan', 'pantai', 'lokal', 'kuliner'], }; const parseCoordinate = (value, label) => { if (value === undefined || value === null || value === '') { return null; } const number = Number(value); if (!Number.isFinite(number)) { const error = new Error(`${label} harus berupa angka.`); error.code = 400; throw error; } return number; }; const requireSearchOrigin = (lat, lng) => { if (lat !== null && lng !== null) { return { lat, lng }; } const error = new Error('Lokasi pengguna wajib dikirim. Izinkan akses lokasi browser dan kirim lat/lng.'); error.code = 400; throw error; }; const parseRadius = (value) => { if (value === undefined || value === null || value === '') { return DEFAULT_RADIUS_KM; } const number = Number(value); if (!Number.isFinite(number) || number <= 0) { const error = new Error('Radius harus berupa angka lebih dari 0 km.'); error.code = 400; throw error; } return Math.min(number, GLOBAL_RADIUS_KM); }; const clampScore = (value) => Math.max(0, Math.min(100, Number(value) || 0)); const toNumber = (value) => { if (value === null || value === undefined || value === '') { return null; } const number = Number(value); return Number.isFinite(number) ? number : null; }; const normalizeText = (value) => String(value || '') .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/[^a-z0-9]+/g, ' ') .trim(); const parseRadiusFromQueryText = (query) => { const normalized = normalizeText(query); const radiusMatch = normalized.match(/(?:dalam\s+)?radius\s+(\d+(?:[.,]\d+)?)\s*km/) || normalized.match(/\b(\d+(?:[.,]\d+)?)\s*km\b/); if (!radiusMatch) { return null; } const number = Number(String(radiusMatch[1]).replace(',', '.')); if (!Number.isFinite(number) || number <= 0) { return null; } return Math.min(number, GLOBAL_RADIUS_KM); }; const hasAnyPhrase = (text, phrases) => phrases.some((phrase) => text.includes(phrase)); const analyzeHyperlocalIntent = (query) => { const normalized = normalizeText(query); return { nearby: hasAnyPhrase(normalized, [ 'terdekat', 'dekat saya', 'near me', 'sekitar saya', 'lokasi terdekat', 'dalam radius', 'radius', ]), openNow: hasAnyPhrase(normalized, ['buka sekarang', 'buka sek', 'sedang buka']), twentyFourHour: /\b24\s*jam\b/.test(normalized) || normalized.includes('24jam'), highestRating: hasAnyPhrase(normalized, ['rating tertinggi', 'terbaik', 'bintang tertinggi']), popular: hasAnyPhrase(normalized, ['populer', 'paling ramai', 'ramai', 'trafik tertinggi']), cheap: hasAnyPhrase(normalized, ['paling murah', 'termurah', 'murah']), bestSeller: hasAnyPhrase(normalized, ['terlaris', 'best seller', 'laris']), newest: hasAnyPhrase(normalized, ['terbaru', 'baru update', 'update terbaru']), recommended: hasAnyPhrase(normalized, ['direkomendasikan', 'recommended', 'rekomendasi']), verified: hasAnyPhrase(normalized, ['terverifikasi', 'verified']), requestedRadiusKm: parseRadiusFromQueryText(query), }; }; const FUEL_QUERY_TOKENS = new Set([ 'spbu', 'bbm', 'bensin', 'pom', 'pertamina', 'shell', 'vivo', 'fuel', 'gas', ]); const isFuelSearchQuery = (query) => { const normalized = normalizeText(query); if (!normalized) { return false; } if (normalized.includes('pom bensin') || normalized.includes('gas station')) { return true; } return normalized .split(/\s+/) .some((token) => FUEL_QUERY_TOKENS.has(token)); }; const tokenizeQuery = (value) => normalizeText(value) .split(/\s+/) .filter((token) => token.length > 1 && !STOP_WORDS.has(token)); const includesToken = (text, token) => { if (!text || !token) { return false; } if (token.includes(' ')) { return text.includes(token); } const words = text.split(/\s+/).filter(Boolean); return words.some((word) => word === token || (token.length >= 4 && word.startsWith(token)) || (token.length >= 5 && word.includes(token))); }; const scoreField = (field, token, weight) => (includesToken(field, token) ? weight : 0); const isAvailableOffering = (offering) => ['in_stock', 'limited', 'by_request'].includes(offering.stock_status); const buildOfferingText = (offerings) => offerings .map((offering) => [ offering.name, offering.description, offering.offering_type === 'service' ? 'jasa layanan service servis booking' : 'produk barang stok ready marketplace', STOCK_STATUS_LABELS[offering.stock_status], ].filter(Boolean).join(' ')) .join(' '); const scoreOfferingTokens = (offerings, tokens, tokenWeight, availableWeight) => { let score = 0; offerings.forEach((offering) => { const offeringText = normalizeText([ offering.name, offering.description, offering.offering_type, STOCK_STATUS_LABELS[offering.stock_status], ].filter(Boolean).join(' ')); const matches = tokens.filter((token) => includesToken(offeringText, token)).length; if (!matches) { return; } score += matches * tokenWeight; if (isAvailableOffering(offering)) { score += availableWeight; } if (offering.is_verified) { score += 5; } if (offering.stock_status === 'out_of_stock') { score -= 25; } }); return score; }; const scoreTokenInSearchFields = (fields, token, weights) => scoreField(fields.name, token, weights.name) + scoreField(fields.category, token, weights.category) + scoreField(fields.offerings, token, weights.offerings) + scoreField(fields.description, token, weights.description) + scoreField(fields.location, token, weights.location); const calculateRawRelevanceScore = (place, query) => { const originalTokens = tokenizeQuery(query); if (!originalTokens.length) { return 62 + Math.min(calculateInventoryScore(place) * 0.18, 18); } const normalizedQuery = normalizeText(query); const offerings = Array.isArray(place.offerings) ? place.offerings : []; const fields = { name: normalizeText(place.name), category: normalizeText([ place.category?.name, place.category?.slug, place.category?.description, ].filter(Boolean).join(' ')), description: normalizeText([ place.short_description, place.full_description, ].filter(Boolean).join(' ')), location: normalizeText([ place.address, place.city, place.province, ].filter(Boolean).join(' ')), offerings: normalizeText(buildOfferingText(offerings)), }; const exactWeights = { name: 22, category: 18, offerings: 26, description: 10, location: 4, }; const synonymWeights = { name: 9, category: 7, offerings: 11, description: 4, location: 2, }; let score = 0; if (normalizedQuery) { score += scoreField(fields.name, normalizedQuery, 80); score += scoreField(fields.category, normalizedQuery, 70); score += scoreField(fields.offerings, normalizedQuery, 86); score += scoreField(fields.description, normalizedQuery, 46); score += scoreField(fields.location, normalizedQuery, 16); } let originalMatches = 0; const matchedSearchTerms = new Set(); originalTokens.forEach((token) => { const tokenScore = scoreTokenInSearchFields(fields, token, exactWeights); if (tokenScore > 0) { originalMatches += 1; matchedSearchTerms.add(token); score += tokenScore; } }); score += scoreOfferingTokens(offerings, originalTokens, 9, 12); let synonymScore = 0; const usedSynonyms = new Set(); originalTokens.forEach((originalToken) => { let tokenSynonymScore = 0; (SYNONYMS[originalToken] || []).forEach((token) => { if (usedSynonyms.has(token)) { return; } usedSynonyms.add(token); tokenSynonymScore += scoreTokenInSearchFields(fields, token, synonymWeights); tokenSynonymScore += scoreOfferingTokens(offerings, [token], 4, 5); }); if (tokenSynonymScore > 0) { matchedSearchTerms.add(originalToken); synonymScore += tokenSynonymScore; } }); if (originalTokens.length > 1 && matchedSearchTerms.size < originalTokens.length) { return 0; } if (originalMatches === 0) { if (synonymScore < 16) { return 0; } score = synonymScore * 0.72; } else { score += synonymScore; } if (score <= 0) { return 0; } if (matchedSearchTerms.size === originalTokens.length) { score += 24; } return score; }; const calculateInventoryScore = (place) => { const offerings = Array.isArray(place.offerings) ? place.offerings : []; const availableOfferings = offerings.filter(isAvailableOffering).length; const verifiedOfferings = offerings.filter((offering) => offering.is_verified).length; const products = offerings.filter((offering) => offering.offering_type === 'product').length; const services = offerings.filter((offering) => offering.offering_type === 'service').length; return clampScore((availableOfferings * 12) + (verifiedOfferings * 8) + (products * 3) + (services * 3)); }; const calculateIntentBoost = (place, intent, radiusKm) => { let boost = 0; if (intent.openNow && place.live_status?.status === 'open') { boost += 34; } if (intent.twentyFourHour && place.live_status?.status === 'open') { boost += 16; } if (intent.nearby && place.distance_km !== null && place.distance_km !== undefined) { const safeRadius = radiusKm || intent.requestedRadiusKm || DEFAULT_RADIUS_KM; boost += clampScore(18 * (1 - Math.min(place.distance_km / safeRadius, 1))); } if (intent.highestRating && Number(place.rating_average || 0) >= 4.5) { boost += 24; } if (intent.popular || intent.bestSeller) { boost += clampScore(Math.log10(Number(place.rating_count || 0) + 1) * 12); } if (intent.cheap) { if (place.price_level === 'budget') { boost += 26; } else if (place.price_level === 'midrange') { boost += 12; } } if (intent.newest) { boost += calculateActivityComponent(place) * 0.22; } if (intent.recommended) { boost += 14; } if (intent.verified && place.is_verified) { boost += 28; } return boost; }; const calculateRelevanceComponent = (place, query, radiusKm) => { const intent = analyzeHyperlocalIntent(query); const semanticTokens = tokenizeQuery(query); const rawRelevance = calculateRawRelevanceScore(place, query); if (rawRelevance <= 0 && semanticTokens.length > 0) { return 0; } const raw = rawRelevance + calculateIntentBoost(place, intent, radiusKm); if (raw <= 0) { return 0; } return clampScore(raw * 0.72 + calculateInventoryScore(place) * 0.22); }; const calculateDistanceComponent = (place, radiusKm) => { if (place.distance_km === null || place.distance_km === undefined) { return 58; } const safeRadius = radiusKm || DEFAULT_RADIUS_KM; if (safeRadius >= GLOBAL_RADIUS_KM) { return clampScore(100 - Math.min(place.distance_km / 100, 80)); } return clampScore(100 * (1 - Math.min(place.distance_km / safeRadius, 1))); }; const calculateReputationComponent = (place) => { const rating = Number(place.rating_average || 0); const ratingCount = Number(place.rating_count || 0); const ratingScore = clampScore((rating / 5) * 84); const volumeScore = clampScore(Math.log10(ratingCount + 1) * 10); const verifiedScore = place.is_verified ? 8 : 0; return clampScore(ratingScore + volumeScore + verifiedScore); }; const getMostRecentActivityDate = (place) => { const dates = []; if (place.updatedAt) { dates.push(new Date(place.updatedAt)); } (place.offerings || []).forEach((offering) => { if (offering.last_stock_update) { dates.push(new Date(offering.last_stock_update)); } }); return dates .filter((date) => !Number.isNaN(date.getTime())) .sort((a, b) => b.getTime() - a.getTime())[0] || null; }; const calculateActivityComponent = (place) => { const latestDate = getMostRecentActivityDate(place); if (!latestDate) { return 42; } const days = (Date.now() - latestDate.getTime()) / (1000 * 60 * 60 * 24); if (days <= 1) return 100; if (days <= 7) return 92; if (days <= 30) return 76; if (days <= 90) return 58; return 40; }; const calculateInteractionComponent = (place) => { const ratingCount = Number(place.rating_count || 0); const offerings = Array.isArray(place.offerings) ? place.offerings : []; const availableOfferings = offerings.filter(isAvailableOffering).length; const contactScore = [place.phone_number, place.whatsapp_number, place.website_url, place.google_maps_url] .filter(Boolean).length * 5; const engagementScore = Math.log10(ratingCount + 1) * 26; const inventoryScore = Math.min(availableOfferings * 4, 18); return clampScore(contactScore + engagementScore + inventoryScore); }; const calculateGeoScore = (place, query, radiusKm) => { const components = { relevance: calculateRelevanceComponent(place, query, radiusKm), distance: calculateDistanceComponent(place, radiusKm), reputation: calculateReputationComponent(place), activity: calculateActivityComponent(place), interaction: calculateInteractionComponent(place), }; const weighted = (components.relevance * GEO_SCORE_FORMULA.relevance + components.distance * GEO_SCORE_FORMULA.distance + components.reputation * GEO_SCORE_FORMULA.reputation + components.activity * GEO_SCORE_FORMULA.activity + components.interaction * GEO_SCORE_FORMULA.interaction) / 100; return { value: Number(clampScore(weighted).toFixed(2)), components: Object.fromEntries( Object.entries(components).map(([key, value]) => [key, Number(value.toFixed(2))]), ), formula: GEO_SCORE_FORMULA, }; }; const compareDistance = (a, b) => { if (a.distance_km !== null && b.distance_km !== null) return a.distance_km - b.distance_km; if (a.distance_km !== null) return -1; if (b.distance_km !== null) return 1; return 0; }; const compareLatestActivity = (a, b) => { const dateA = getMostRecentActivityDate(a); const dateB = getMostRecentActivityDate(b); if (dateA && dateB) { return dateB.getTime() - dateA.getTime(); } if (dateA) return -1; if (dateB) return 1; return 0; }; const compareAveragePrice = (a, b) => { const priceA = Number(a.average_price || 0); const priceB = Number(b.average_price || 0); if (priceA && priceB) { return priceA - priceB; } if (priceA) return -1; if (priceB) return 1; return 0; }; const sortScoredPlaces = (a, b, hasQuery, intent = {}) => { if (intent.openNow || intent.twentyFourHour) { const openDiff = Number(b.place.live_status?.status === 'open') - Number(a.place.live_status?.status === 'open'); if (openDiff !== 0) return openDiff; } if (intent.verified) { const verifiedDiff = Number(b.place.is_verified) - Number(a.place.is_verified); if (verifiedDiff !== 0) return verifiedDiff; } if (intent.highestRating) { const ratingDiff = (b.place.rating_average || 0) - (a.place.rating_average || 0); if (Math.abs(ratingDiff) >= 0.05) return ratingDiff; } if (intent.popular || intent.bestSeller) { const popularityDiff = (b.place.rating_count || 0) - (a.place.rating_count || 0); if (popularityDiff !== 0) return popularityDiff; } if (intent.cheap) { const priceDiff = compareAveragePrice(a.place, b.place); if (priceDiff !== 0) return priceDiff; } if (intent.newest) { const latestDiff = compareLatestActivity(a.place, b.place); if (latestDiff !== 0) return latestDiff; } if (intent.nearby) { const distanceDiff = compareDistance(a.place, b.place); if (distanceDiff !== 0) return distanceDiff; } if (hasQuery) { const relevanceDiff = b.geoScore.components.relevance - a.geoScore.components.relevance; if (Math.abs(relevanceDiff) >= 4) return relevanceDiff; } const geoScoreDiff = b.geoScore.value - a.geoScore.value; if (Math.abs(geoScoreDiff) > (hasQuery ? 2 : 5)) { return geoScoreDiff; } const ratingDiff = (b.place.rating_average || 0) - (a.place.rating_average || 0); if (Math.abs(ratingDiff) >= 0.2) { return ratingDiff; } const distanceDiff = compareDistance(a.place, b.place); if (distanceDiff !== 0) { return distanceDiff; } if (Boolean(a.place.is_verified) !== Boolean(b.place.is_verified)) { return a.place.is_verified ? -1 : 1; } return String(a.place.name || '').localeCompare(String(b.place.name || '')); }; const getLocalHour = () => { try { const value = new Intl.DateTimeFormat('en-US', { hour: 'numeric', hour12: false, timeZone: 'Asia/Jakarta', }).format(new Date()); return Number(value); } catch (err) { console.error('Gagal membaca timezone Asia/Jakarta untuk live status', err); return (new Date().getUTCHours() + 7) % 24; } }; const isBetweenHour = (hour, start, end) => hour >= start && hour < end; const calculateLiveStatus = (place) => { const hour = getLocalHour(); const slug = place.category?.slug || ''; let open = isBetweenHour(hour, 8, 21); let busy = false; if (slug.includes('cafe')) { open = isBetweenHour(hour, 7, 22); busy = isBetweenHour(hour, 7, 9) || isBetweenHour(hour, 12, 14) || isBetweenHour(hour, 19, 21); } else if (slug.includes('bengkel')) { open = isBetweenHour(hour, 8, 17); busy = isBetweenHour(hour, 9, 11) || isBetweenHour(hour, 14, 16); } else if (slug.includes('interior')) { open = isBetweenHour(hour, 9, 18); busy = isBetweenHour(hour, 10, 12) || isBetweenHour(hour, 15, 17); } const activityDate = getMostRecentActivityDate(place); const updatedToday = activityDate ? ((Date.now() - activityDate.getTime()) / (1000 * 60 * 60 * 24)) <= 1 : false; return { status: open ? 'open' : 'closed', label: open ? 'Buka sekarang' : 'Tutup sekarang', crowd: open ? (busy ? 'Ramai' : 'Sepi') : 'Tutup', updated_label: updatedToday ? 'Update stok hari ini' : 'Update berkala', source: 'Simulasi live search MVP berbasis jam lokal dan aktivitas data.', }; }; const getRadiusZone = (distanceKm, selectedRadiusKm) => { const value = distanceKm === null || distanceKm === undefined ? selectedRadiusKm : distanceKm; const zone = RADIUS_ZONES.find((item) => value <= item.value) || RADIUS_ZONES[RADIUS_ZONES.length - 1]; return zone; }; const toPublicOffering = (offering) => ({ id: offering.id, name: offering.name, description: offering.description, offering_type: offering.offering_type, price: toNumber(offering.price), stock_status: offering.stock_status, stock_label: STOCK_STATUS_LABELS[offering.stock_status] || 'Info stok', stock_quantity: offering.stock_quantity, is_verified: Boolean(offering.is_verified), last_stock_update: offering.last_stock_update, }); const summarizeOfferings = (offerings) => { const activeOfferings = Array.isArray(offerings) ? offerings : []; const availableOfferings = activeOfferings.filter(isAvailableOffering); return { total: activeOfferings.length, available: availableOfferings.length, products: activeOfferings.filter((offering) => offering.offering_type === 'product').length, services: activeOfferings.filter((offering) => offering.offering_type === 'service').length, verified: activeOfferings.filter((offering) => offering.is_verified).length, top_available: availableOfferings.slice(0, 3), }; }; const calculateDistanceKm = (lat1, lon1, lat2, lon2) => { const earthRadiusKm = 6371; const toRadians = (value) => (value * Math.PI) / 180; const dLat = toRadians(lat2 - lat1); const dLon = toRadians(lon2 - lon1); const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2); return earthRadiusKm * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); }; const OVERPASS_ENDPOINTS = [ 'https://z.overpass-api.de/api/interpreter', 'https://overpass-api.de/api/interpreter', 'https://maps.mail.ru/osm/tools/overpass/api/interpreter', ]; const OSM_SEARCH_PROFILES = [ { id: 'fuel', matchTerms: Array.from(FUEL_QUERY_TOKENS), filters: [ { key: 'amenity', value: 'fuel' }, ], defaultName: 'SPBU / Pom Bensin', shortDescription: 'SPBU/pom bensin terdekat dari data OpenStreetMap.', fullDescription: 'Data SPBU diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', keywords: 'spbu bbm bensin pom pertamina shell vivo fuel gas station bahan bakar', category: { id: 'openstreetmap-fuel', name: 'SPBU / Pom Bensin', slug: 'spbu-pom-bensin', color_hex: '#D72638', description: 'Stasiun pengisian bahan bakar dari OpenStreetMap.', }, offering: { name: 'BBM / bahan bakar', description: 'Ketersediaan mengikuti data lokasi SPBU.', offering_type: 'product', }, maxRadiusKm: 50, }, { id: 'cafe', matchTerms: ['cafe', 'kafe', 'kopi', 'coffee', 'kedai'], filters: [ { key: 'amenity', value: 'cafe' }, { key: 'shop', value: 'coffee' }, ], defaultName: 'Cafe / Kedai Kopi', shortDescription: 'Cafe, kedai kopi, atau tempat nongkrong dari OpenStreetMap.', fullDescription: 'Data cafe/kopi diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', keywords: 'cafe kafe kopi coffee kedai nongkrong minum minuman kuliner', category: { id: 'openstreetmap-cafe', name: 'Cafe / Kopi', slug: 'cafe-kopi', color_hex: '#8B5A2B', description: 'Cafe dan kedai kopi dari OpenStreetMap.', }, offering: { name: 'Menu cafe / kopi', description: 'Menu dan layanan cafe mengikuti informasi lokasi.', offering_type: 'service', }, maxRadiusKm: 25, }, { id: 'restaurant', matchTerms: ['restoran', 'restaurant', 'kuliner', 'makan', 'makanan', 'warung', 'nasi', 'food'], filters: [ { key: 'amenity', value: 'restaurant' }, { key: 'amenity', value: 'fast_food' }, { key: 'amenity', value: 'food_court' }, { key: 'shop', value: 'bakery' }, ], defaultName: 'Restoran / Kuliner', shortDescription: 'Tempat makan, restoran, warung, atau kuliner dari OpenStreetMap.', fullDescription: 'Data restoran/kuliner diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', keywords: 'restoran restaurant kuliner makan makanan warung nasi food court fast food roti bakery kue', category: { id: 'openstreetmap-restaurant', name: 'Restoran / Kuliner', slug: 'restoran-kuliner', color_hex: '#F26A4B', description: 'Tempat makan dan kuliner dari OpenStreetMap.', }, offering: { name: 'Makanan / minuman', description: 'Menu dan layanan kuliner mengikuti informasi lokasi.', offering_type: 'service', }, maxRadiusKm: 25, }, { id: 'automotive', matchTerms: ['bengkel', 'service', 'servis', 'mobil', 'otomotif', 'automotif', 'auto', 'oli', 'ban', 'tambal', 'cuci', 'spooring', 'balancing'], filters: [ { key: 'shop', value: 'car_repair' }, { key: 'shop', value: 'car_parts' }, { key: 'shop', value: 'tyres' }, { key: 'shop', value: 'car' }, { key: 'amenity', value: 'car_wash' }, ], defaultName: 'Bengkel / Otomotif', shortDescription: 'Bengkel, layanan otomotif, cuci mobil, atau toko ban dari OpenStreetMap.', fullDescription: 'Data bengkel/otomotif diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', keywords: 'bengkel service servis mobil kendaraan otomotif automotif auto oli ban tambal spooring balancing cuci mobil car repair tyres', category: { id: 'openstreetmap-automotive', name: 'Bengkel / Otomotif', slug: 'bengkel-otomotif', color_hex: '#2563EB', description: 'Bengkel dan layanan otomotif dari OpenStreetMap.', }, offering: { name: 'Service kendaraan', description: 'Layanan bengkel/otomotif mengikuti informasi lokasi.', offering_type: 'service', }, maxRadiusKm: 25, }, { id: 'pharmacy', matchTerms: ['apotek', 'apotik', 'farmasi', 'obat', 'chemist'], filters: [ { key: 'amenity', value: 'pharmacy' }, { key: 'healthcare', value: 'pharmacy' }, { key: 'shop', value: 'chemist' }, ], defaultName: 'Apotek / Farmasi', shortDescription: 'Apotek atau farmasi dari OpenStreetMap.', fullDescription: 'Data apotek/farmasi diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', keywords: 'apotek apotik farmasi obat kesehatan herbal chemist pharmacy', category: { id: 'openstreetmap-pharmacy', name: 'Apotek / Farmasi', slug: 'apotek-farmasi', color_hex: '#0F766E', description: 'Apotek dan farmasi dari OpenStreetMap.', }, offering: { name: 'Obat / produk kesehatan', description: 'Ketersediaan obat mengikuti informasi lokasi.', offering_type: 'product', }, maxRadiusKm: 25, }, { id: 'healthcare', matchTerms: ['klinik', 'clinic', 'dokter', 'doctor', 'rumah sakit', 'sakit', 'rs', 'hospital', 'kesehatan', 'gigi', 'dentist'], filters: [ { key: 'amenity', value: 'clinic' }, { key: 'amenity', value: 'hospital' }, { key: 'amenity', value: 'doctors' }, { key: 'amenity', value: 'dentist' }, { key: 'healthcare', value: 'clinic' }, { key: 'healthcare', value: 'hospital' }, { key: 'healthcare', value: 'doctor' }, { key: 'healthcare', value: 'dentist' }, ], defaultName: 'Klinik / Kesehatan', shortDescription: 'Klinik, dokter, rumah sakit, atau layanan kesehatan dari OpenStreetMap.', fullDescription: 'Data fasilitas kesehatan diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', keywords: 'klinik clinic dokter doctor rumah sakit rs hospital kesehatan medis gigi dentist obat', category: { id: 'openstreetmap-healthcare', name: 'Klinik / Kesehatan', slug: 'klinik-kesehatan', color_hex: '#087F6D', description: 'Fasilitas kesehatan dari OpenStreetMap.', }, offering: { name: 'Layanan kesehatan', description: 'Layanan kesehatan mengikuti informasi lokasi.', offering_type: 'service', }, maxRadiusKm: 50, }, { id: 'hotel', matchTerms: ['hotel', 'penginapan', 'hostel', 'guesthouse', 'guest house', 'villa', 'resort', 'motel', 'homestay'], filters: [ { key: 'tourism', value: 'hotel' }, { key: 'tourism', value: 'guest_house' }, { key: 'tourism', value: 'hostel' }, { key: 'tourism', value: 'motel' }, { key: 'tourism', value: 'apartment' }, { key: 'tourism', value: 'chalet' }, ], defaultName: 'Hotel / Penginapan', shortDescription: 'Hotel, hostel, atau penginapan dari OpenStreetMap.', fullDescription: 'Data hotel/penginapan diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', keywords: 'hotel penginapan hostel guesthouse guest house villa resort motel homestay travel wisata lodging accommodation', category: { id: 'openstreetmap-hotel', name: 'Hotel / Penginapan', slug: 'hotel-penginapan', color_hex: '#7C3AED', description: 'Hotel dan penginapan dari OpenStreetMap.', }, offering: { name: 'Reservasi penginapan', description: 'Ketersediaan kamar mengikuti informasi lokasi.', offering_type: 'service', }, maxRadiusKm: 50, }, { id: 'tourism', matchTerms: ['wisata', 'travel', 'taman', 'pantai', 'museum', 'rekreasi', 'attraction', 'tourism', 'liburan'], filters: [ { key: 'tourism', value: 'attraction' }, { key: 'tourism', value: 'museum' }, { key: 'tourism', value: 'gallery' }, { key: 'tourism', value: 'zoo' }, { key: 'tourism', value: 'theme_park' }, { key: 'tourism', value: 'viewpoint' }, { key: 'leisure', value: 'park' }, { key: 'leisure', value: 'garden' }, ], defaultName: 'Wisata / Rekreasi', shortDescription: 'Tempat wisata, taman, museum, atau rekreasi dari OpenStreetMap.', fullDescription: 'Data wisata/rekreasi diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', keywords: 'wisata travel taman pantai museum rekreasi attraction tourism liburan park garden zoo viewpoint', category: { id: 'openstreetmap-tourism', name: 'Wisata / Rekreasi', slug: 'wisata-rekreasi', color_hex: '#16A34A', description: 'Tempat wisata dan rekreasi dari OpenStreetMap.', }, offering: { name: 'Info kunjungan', description: 'Informasi kunjungan mengikuti data lokasi.', offering_type: 'service', }, maxRadiusKm: 50, }, { id: 'bank-atm', matchTerms: ['atm', 'bank', 'tunai', 'uang', 'cash'], filters: [ { key: 'amenity', value: 'atm' }, { key: 'amenity', value: 'bank' }, ], defaultName: 'ATM / Bank', shortDescription: 'ATM atau bank dari OpenStreetMap.', fullDescription: 'Data ATM/bank diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', keywords: 'atm bank tunai uang cash tarik setor pembayaran finansial', category: { id: 'openstreetmap-bank-atm', name: 'ATM / Bank', slug: 'atm-bank', color_hex: '#0E7490', description: 'ATM dan bank dari OpenStreetMap.', }, offering: { name: 'Layanan perbankan', description: 'Layanan ATM/bank mengikuti informasi lokasi.', offering_type: 'service', }, maxRadiusKm: 25, }, { id: 'retail', matchTerms: ['minimarket', 'supermarket', 'toko', 'belanja', 'pasar', 'mall', 'market', 'store', 'baju', 'pakaian', 'elektronik', 'hp', 'handphone', 'furniture', 'furnitur', 'mebel', 'laundry', 'salon'], filters: [ { key: 'shop', value: 'convenience' }, { key: 'shop', value: 'supermarket' }, { key: 'shop', value: 'mall' }, { key: 'shop', value: 'department_store' }, { key: 'shop', value: 'general' }, { key: 'shop', value: 'variety_store' }, { key: 'shop', value: 'kiosk' }, { key: 'shop', value: 'clothes' }, { key: 'shop', value: 'shoes' }, { key: 'shop', value: 'electronics' }, { key: 'shop', value: 'mobile_phone' }, { key: 'shop', value: 'computer' }, { key: 'shop', value: 'hardware' }, { key: 'shop', value: 'furniture' }, { key: 'shop', value: 'books' }, { key: 'shop', value: 'stationery' }, { key: 'shop', value: 'beauty' }, { key: 'shop', value: 'hairdresser' }, { key: 'shop', value: 'laundry' }, { key: 'amenity', value: 'marketplace' }, ], defaultName: 'Toko / Belanja', shortDescription: 'Toko, minimarket, pasar, mall, atau layanan retail dari OpenStreetMap.', fullDescription: 'Data toko/retail diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', keywords: 'toko belanja minimarket supermarket pasar mall market store baju pakaian fashion elektronik hp handphone furniture furnitur mebel laundry salon produk barang', category: { id: 'openstreetmap-retail', name: 'Toko / Belanja', slug: 'toko-belanja', color_hex: '#F59E0B', description: 'Toko dan retail dari OpenStreetMap.', }, offering: { name: 'Produk / layanan toko', description: 'Produk dan layanan toko mengikuti informasi lokasi.', offering_type: 'product', }, maxRadiusKm: 15, }, { id: 'education', matchTerms: ['sekolah', 'kampus', 'universitas', 'kuliah', 'pendidikan', 'kursus', 'belajar', 'tk', 'college'], filters: [ { key: 'amenity', value: 'school' }, { key: 'amenity', value: 'college' }, { key: 'amenity', value: 'university' }, { key: 'amenity', value: 'kindergarten' }, { key: 'amenity', value: 'language_school' }, { key: 'office', value: 'educational_institution' }, ], defaultName: 'Sekolah / Pendidikan', shortDescription: 'Sekolah, kampus, atau lembaga pendidikan dari OpenStreetMap.', fullDescription: 'Data pendidikan diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', keywords: 'sekolah kampus universitas kuliah pendidikan kursus belajar tk college university kindergarten', category: { id: 'openstreetmap-education', name: 'Sekolah / Pendidikan', slug: 'sekolah-pendidikan', color_hex: '#1D4ED8', description: 'Institusi pendidikan dari OpenStreetMap.', }, offering: { name: 'Layanan pendidikan', description: 'Layanan pendidikan mengikuti informasi lokasi.', offering_type: 'service', }, maxRadiusKm: 25, }, { id: 'mosque', matchTerms: ['masjid', 'mushola', 'musala', 'surau'], filters: [ { conditions: [ { key: 'amenity', value: 'place_of_worship' }, { key: 'religion', value: 'muslim' }, ], }, ], defaultName: 'Masjid / Mushola', shortDescription: 'Masjid, mushola, atau tempat ibadah muslim dari OpenStreetMap.', fullDescription: 'Data masjid/mushola diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', keywords: 'masjid mushola musala surau ibadah muslim tempat ibadah', category: { id: 'openstreetmap-mosque', name: 'Masjid / Mushola', slug: 'masjid-mushola', color_hex: '#059669', description: 'Tempat ibadah muslim dari OpenStreetMap.', }, offering: { name: 'Tempat ibadah', description: 'Informasi tempat ibadah mengikuti data lokasi.', offering_type: 'service', }, maxRadiusKm: 25, }, { id: 'church', matchTerms: ['gereja', 'church', 'katedral'], filters: [ { conditions: [ { key: 'amenity', value: 'place_of_worship' }, { key: 'religion', value: 'christian' }, ], }, ], defaultName: 'Gereja', shortDescription: 'Gereja atau tempat ibadah kristiani dari OpenStreetMap.', fullDescription: 'Data gereja diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', keywords: 'gereja church katedral ibadah kristen katolik protestan tempat ibadah', category: { id: 'openstreetmap-church', name: 'Gereja', slug: 'gereja', color_hex: '#6366F1', description: 'Gereja dari OpenStreetMap.', }, offering: { name: 'Tempat ibadah', description: 'Informasi tempat ibadah mengikuti data lokasi.', offering_type: 'service', }, maxRadiusKm: 25, }, { id: 'worship', matchTerms: ['ibadah', 'tempat ibadah', 'pura', 'vihara', 'kelenteng', 'klenteng'], filters: [ { key: 'amenity', value: 'place_of_worship' }, ], defaultName: 'Tempat Ibadah', shortDescription: 'Tempat ibadah dari OpenStreetMap.', fullDescription: 'Data tempat ibadah diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', keywords: 'ibadah tempat ibadah masjid mushola gereja pura vihara kelenteng klenteng worship', category: { id: 'openstreetmap-worship', name: 'Tempat Ibadah', slug: 'tempat-ibadah', color_hex: '#475569', description: 'Tempat ibadah dari OpenStreetMap.', }, offering: { name: 'Tempat ibadah', description: 'Informasi tempat ibadah mengikuti data lokasi.', offering_type: 'service', }, maxRadiusKm: 25, }, { id: 'fitness', matchTerms: ['gym', 'fitness', 'olahraga', 'sport', 'fitnes'], filters: [ { key: 'leisure', value: 'fitness_centre' }, { key: 'leisure', value: 'sports_centre' }, { key: 'shop', value: 'sports' }, ], defaultName: 'Gym / Olahraga', shortDescription: 'Gym, fitness center, atau fasilitas olahraga dari OpenStreetMap.', fullDescription: 'Data gym/olahraga diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', keywords: 'gym fitness fitnes olahraga sport sports centre pusat kebugaran', category: { id: 'openstreetmap-fitness', name: 'Gym / Olahraga', slug: 'gym-olahraga', color_hex: '#DC2626', description: 'Gym dan fasilitas olahraga dari OpenStreetMap.', }, offering: { name: 'Layanan olahraga', description: 'Layanan olahraga mengikuti informasi lokasi.', offering_type: 'service', }, maxRadiusKm: 25, }, ]; const OSM_TAG_VALUE_KEYWORDS = { atm: 'atm bank tunai uang cash', bakery: 'roti bakery kue makanan kuliner', bank: 'bank atm uang tunai finansial', cafe: 'cafe kafe kopi coffee kedai minuman', car: 'mobil kendaraan otomotif auto', car_parts: 'suku cadang sparepart mobil otomotif', car_repair: 'bengkel service servis mobil kendaraan otomotif', car_wash: 'cuci mobil kendaraan otomotif', chemist: 'apotek farmasi obat kesehatan', clothes: 'baju pakaian fashion toko belanja', clinic: 'klinik kesehatan dokter medis', coffee: 'kopi coffee cafe kafe kedai', college: 'kampus kuliah pendidikan college', computer: 'komputer laptop toko elektronik', convenience: 'minimarket toko belanja kebutuhan harian', dentist: 'dokter gigi klinik kesehatan', department_store: 'mall toko belanja department store', doctors: 'dokter klinik kesehatan medis', electronics: 'elektronik hp handphone gadget toko', fast_food: 'fast food makanan kuliner restoran', fitness_centre: 'gym fitness fitnes olahraga', food_court: 'food court kuliner makanan restoran', fuel: 'spbu bbm bensin pom bahan bakar', furniture: 'furniture furnitur mebel perabot toko', guest_house: 'penginapan guesthouse guest house hotel', hairdresser: 'salon potong rambut kecantikan', hardware: 'toko bangunan hardware alat', hospital: 'rumah sakit rs hospital kesehatan medis', hostel: 'hostel penginapan hotel travel', hotel: 'hotel penginapan travel wisata', kindergarten: 'tk taman kanak kanak sekolah pendidikan', laundry: 'laundry binatu cuci pakaian', marketplace: 'pasar market toko belanja', mobile_phone: 'hp handphone ponsel toko elektronik', motel: 'motel hotel penginapan travel', museum: 'museum wisata rekreasi edukasi', park: 'taman wisata rekreasi', pharmacy: 'apotek farmasi obat kesehatan', place_of_worship: 'tempat ibadah masjid mushola gereja pura vihara worship', restaurant: 'restoran restaurant makan makanan kuliner warung nasi', school: 'sekolah pendidikan belajar', shoes: 'sepatu toko fashion belanja', sports: 'olahraga sport alat olahraga toko', sports_centre: 'pusat olahraga sport gym fitness', supermarket: 'supermarket minimarket toko belanja groceries', tyres: 'ban tambal ban bengkel otomotif mobil', university: 'universitas kampus kuliah pendidikan', variety_store: 'toko serba ada belanja produk', zoo: 'kebun binatang zoo wisata rekreasi', }; const normalizeOsmTagValue = (value) => normalizeText(String(value || '').replace(/_/g, ' ')); const getOsmFilterConditions = (filter) => filter.conditions || [filter]; const buildOsmFilterCondition = (filter) => getOsmFilterConditions(filter) .map((condition) => { if (condition.value === undefined || condition.value === null) { return `["${condition.key}"]`; } return `["${condition.key}"="${condition.value}"]`; }) .join(''); const osmTagMatchesCondition = (tags, condition) => { const tagValue = tags[condition.key]; if (tagValue === undefined || tagValue === null || tagValue === '') { return false; } if (condition.value === undefined || condition.value === null) { return true; } return normalizeOsmTagValue(tagValue) === normalizeOsmTagValue(condition.value); }; const osmTagMatchesFilter = (tags, filter) => getOsmFilterConditions(filter) .every((condition) => osmTagMatchesCondition(tags, condition)); const textMatchesOsmTerm = (normalizedText, tokens, term) => { const normalizedTerm = normalizeText(term); if (!normalizedTerm) { return false; } if (normalizedTerm.includes(' ')) { return normalizedText.includes(normalizedTerm); } return tokens.has(normalizedTerm) || includesToken(normalizedText, normalizedTerm); }; const buildExpandedOsmTokens = (tokens) => { const expanded = new Set(tokens); tokens.forEach((token) => { (SYNONYMS[token] || []).forEach((synonym) => { normalizeText(synonym) .split(/\s+/) .filter(Boolean) .forEach((synonymToken) => expanded.add(synonymToken)); }); }); return expanded; }; const getOsmSearchProfiles = (query, category) => { const searchText = [query, category].filter(Boolean).join(' '); const normalized = normalizeText(searchText); if (!normalized) { return []; } const directTokens = new Set(tokenizeQuery(searchText)); const expandedTokens = buildExpandedOsmTokens(directTokens); const exactProfiles = OSM_SEARCH_PROFILES.filter((profile) => ( profile.matchTerms.some((term) => textMatchesOsmTerm(normalized, directTokens, term)) )); if (exactProfiles.length) { return exactProfiles.slice(0, 4); } if (isFuelSearchQuery(searchText)) { return OSM_SEARCH_PROFILES.filter((profile) => profile.id === 'fuel'); } return OSM_SEARCH_PROFILES .filter((profile) => profile.matchTerms.some((term) => textMatchesOsmTerm(normalized, expandedTokens, term))) .slice(0, 4); }; const getOsmElementProfile = (element, profiles) => { const tags = element.tags || {}; return profiles.find((profile) => profile.filters.some((filter) => osmTagMatchesFilter(tags, filter))) || null; }; const buildOsmTagKeywordText = (tags = {}) => { const relevantValues = [ tags.amenity, tags.shop, tags.tourism, tags.leisure, tags.healthcare, tags.office, tags.religion, tags.cuisine, tags.brand, tags.operator, ]; return relevantValues .flatMap((value) => { const normalizedValue = normalizeOsmTagValue(value); if (!normalizedValue) { return []; } return [normalizedValue, OSM_TAG_VALUE_KEYWORDS[value] || OSM_TAG_VALUE_KEYWORDS[normalizedValue] || '']; }) .filter(Boolean) .join(' '); }; const buildOsmOffering = (profile) => ({ id: `osm-${profile.id}-offering`, name: profile.offering.name, description: profile.offering.description, offering_type: profile.offering.offering_type, price: null, stock_status: 'by_request', stock_label: STOCK_STATUS_LABELS.by_request, stock_quantity: null, is_verified: false, }); const buildOsmOfferings = (profile) => [buildOsmOffering(profile)]; const buildOsmLiveStatus = (tags = {}) => { const openingHours = String(tags.opening_hours || '').trim(); if (openingHours.toLowerCase().includes('24/7')) { return { status: 'open', label: 'Buka 24 jam', crowd: 'Cek lokasi', updated_label: 'Sumber OSM', source: 'openstreetmap', }; } return { status: 'open', label: openingHours ? `Jam: ${openingHours}` : 'Cek jam operasional', crowd: 'Cek lokasi', updated_label: 'Sumber OSM', source: 'openstreetmap', }; }; const getOsmCoordinate = (element) => { const lat = toNumber(element.lat ?? element.center?.lat); const lon = toNumber(element.lon ?? element.center?.lon); if (lat === null || lon === null) { return null; } return { lat, lng: lon }; }; const buildOsmAddress = (tags = {}) => [ tags['addr:housenumber'], tags['addr:street'], tags['addr:suburb'] || tags['addr:village'], tags['addr:city'] || tags['addr:district'] || tags['addr:county'], tags['addr:province'] || tags['addr:state'], ].filter(Boolean).join(', '); const buildOsmMapsUrl = (latitude, longitude) => `https://www.google.com/maps/dir/?api=1&destination=${latitude},${longitude}`; const toOsmPlace = (element, origin, profile) => { const coordinate = getOsmCoordinate(element); if (!coordinate || !origin || !profile) { return null; } const tags = element.tags || {}; const latitude = coordinate.lat; const longitude = coordinate.lng; const distanceKm = calculateDistanceKm(origin.lat, origin.lng, latitude, longitude); const brandOrOperator = tags.brand || tags.operator || ''; const name = tags.name || brandOrOperator || profile.defaultName; const address = buildOsmAddress(tags); const phoneNumber = tags.phone || tags['contact:phone'] || ''; const websiteUrl = tags.website || tags['contact:website'] || ''; const osmKeywords = [profile.keywords, buildOsmTagKeywordText(tags)].filter(Boolean).join(' '); const offerings = buildOsmOfferings(profile); return { id: `osm-${profile.id}-${element.type}-${element.id}`, name, short_description: `${profile.shortDescription}${brandOrOperator && brandOrOperator !== name ? ` · ${brandOrOperator}` : ''}`, full_description: [ tags.description, profile.fullDescription, tags.cuisine ? `Jenis kuliner: ${tags.cuisine}.` : '', osmKeywords ? `Kata kunci lokasi: ${osmKeywords}.` : '', ].filter(Boolean).join(' '), address: address || [latitude, longitude].join(', '), city: tags['addr:city'] || tags['addr:district'] || tags['addr:county'] || '', province: tags['addr:province'] || tags['addr:state'] || '', latitude, longitude, phone_number: phoneNumber, whatsapp_number: '', google_maps_url: buildOsmMapsUrl(latitude, longitude), website_url: websiteUrl, price_level: 'unknown', average_price: null, rating_average: null, rating_count: 0, status: 'published', is_verified: false, category: profile.category, distance_km: Number(distanceKm.toFixed(2)), offerings, offerings_summary: summarizeOfferings(offerings), live_status: buildOsmLiveStatus(tags), external_source: 'openstreetmap', external_search_match: true, }; }; const buildOsmOverpassQuery = (profiles, radiusMeters, origin, overpassLimit) => { const clauses = []; const seenClauses = new Set(); profiles.forEach((profile) => { profile.filters.forEach((filter) => { const condition = buildOsmFilterCondition(filter); const clause = `nwr${condition}(around:${radiusMeters},${origin.lat},${origin.lng});`; if (!seenClauses.has(clause)) { seenClauses.add(clause); clauses.push(clause); } }); }); return ` [out:json][timeout:8]; ( ${clauses.join('\n ')} ); out center tags ${overpassLimit}; `; }; const getOsmSearchRadiusKm = (profiles, radiusKm) => { const requestedRadiusKm = Math.max(Number(radiusKm || DEFAULT_RADIUS_KM), 1); const maxProfileRadiusKm = profiles.reduce((maxRadiusKm, profile) => Math.max(maxRadiusKm, profile.maxRadiusKm || 25), 25); return Math.min(requestedRadiusKm, maxProfileRadiusKm); }; const isRetriableOverpassError = (err) => { const status = err.response?.status; if (!status) { return true; } return [408, 429, 500, 502, 503, 504].includes(status); }; const summarizeOverpassError = (err, endpoint) => ({ endpoint, message: err.message, status: err.response?.status, data: typeof err.response?.data === 'string' ? err.response.data.slice(0, 500) : err.response?.data, }); const postOverpassQuery = async (body, endpointIndex = 0, previousErrors = []) => { const endpoint = OVERPASS_ENDPOINTS[endpointIndex]; try { return await axios.post( endpoint, body.toString(), { timeout: 10000, headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', 'User-Agent': 'GeoSeek/1.0', }, }, ); } catch (err) { const overpassErrors = [...previousErrors, summarizeOverpassError(err, endpoint)]; if (endpointIndex < OVERPASS_ENDPOINTS.length - 1 && isRetriableOverpassError(err)) { return postOverpassQuery(body, endpointIndex + 1, overpassErrors); } err.overpass_errors = overpassErrors; throw err; } }; const fetchOsmPlaces = async (origin, query, category, radiusKm, limit) => { if (!origin) { return []; } const profiles = getOsmSearchProfiles(query, category); if (!profiles.length) { return []; } const searchRadiusKm = getOsmSearchRadiusKm(profiles, radiusKm); const radiusMeters = Math.round(searchRadiusKm * 1000); const overpassLimit = Math.min(Math.max((limit || DEFAULT_LIMIT) * 3, DEFAULT_LIMIT), MAX_LIMIT); const overpassQuery = buildOsmOverpassQuery(profiles, radiusMeters, origin, overpassLimit); const body = new URLSearchParams(); body.set('data', overpassQuery); const response = await postOverpassQuery(body); const elements = Array.isArray(response.data?.elements) ? response.data.elements : []; const placeMap = new Map(); elements.forEach((element) => { const profile = getOsmElementProfile(element, profiles); const place = toOsmPlace(element, origin, profile); if (!place) { return; } const existingPlace = placeMap.get(place.id); if (!existingPlace || compareDistance(place, existingPlace) < 0) { placeMap.set(place.id, place); } }); return Array.from(placeMap.values()) .sort((a, b) => compareDistance(a, b)) .slice(0, overpassLimit); }; const toPublicPlace = (place, origin) => { const plain = place.get({ plain: true }); const latitude = toNumber(plain.latitude); const longitude = toNumber(plain.longitude); const distanceKm = origin && latitude !== null && longitude !== null ? calculateDistanceKm(origin.lat, origin.lng, latitude, longitude) : null; const offerings = (plain.offerings || []).map(toPublicOffering); const publicPlace = { id: plain.id, name: plain.name, short_description: plain.short_description, full_description: plain.full_description, address: plain.address, city: plain.city, province: plain.province, latitude, longitude, phone_number: plain.phone_number, whatsapp_number: plain.whatsapp_number, website_url: plain.website_url, google_maps_url: plain.google_maps_url, price_level: plain.price_level, average_price: toNumber(plain.average_price), rating_average: toNumber(plain.rating_average), rating_count: plain.rating_count || 0, status: plain.status, is_verified: Boolean(plain.is_verified), updatedAt: plain.updatedAt, category: plain.category ? { id: plain.category.id, name: plain.category.name, slug: plain.category.slug, color_hex: plain.category.color_hex, description: plain.category.description, } : null, distance_km: distanceKm === null ? null : Number(distanceKm.toFixed(2)), offerings, offerings_summary: summarizeOfferings(offerings), }; publicPlace.live_status = calculateLiveStatus(publicPlace); return publicPlace; }; const buildCategoryWhere = (category) => { if (!category) { return { is_active: true }; } const categoryFilters = [ { slug: category }, { name: { [Op.iLike]: `%${category}%` } }, ]; if (/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(category)) { categoryFilters.unshift({ id: category }); } return { is_active: true, [Op.or]: categoryFilters, }; }; const offeringsInclude = { model: db.place_offerings, as: 'offerings', attributes: [ 'id', 'name', 'description', 'offering_type', 'price', 'stock_status', 'stock_quantity', 'is_verified', 'last_stock_update', ], where: { is_active: true }, required: false, separate: true, order: [ ['is_verified', 'DESC'], ['stock_status', 'ASC'], ['name', 'ASC'], ], }; const buildDistanceBuckets = (places) => DISTANCE_BUCKETS.map((maxKm) => { const count = places.filter((place) => place.distance_km !== null && place.distance_km <= maxKm).length; const zone = getRadiusZone(maxKm, maxKm); return { radius_km: maxKm, label: maxKm === GLOBAL_RADIUS_KM ? '500+ km' : `${maxKm} km`, zone_label: zone.label, count, }; }); const buildAiRecommendation = (place, query) => { const topOffering = place.offerings_summary?.top_available?.[0]; const reasonParts = []; if (topOffering) { reasonParts.push(`${topOffering.name} ${topOffering.stock_label.toLowerCase()}`); } if (place.is_verified) { reasonParts.push('tempat terverifikasi'); } if (place.distance_km !== null && place.distance_km !== undefined) { reasonParts.push(`${place.distance_km} km dari titik Anda`); } if (place.rating_average) { reasonParts.push(`rating ${place.rating_average}`); } return { label: query ? `Cocok untuk “${query}”` : 'Rekomendasi lokal', reason: reasonParts.length ? `Direkomendasikan karena ${reasonParts.join(', ')}.` : 'Direkomendasikan berdasarkan kategori, reputasi, aktivitas data, dan kedekatan lokasi.', source: 'AI Recommendation MVP berbasis GeoScore dan sinyal lokal.', }; }; const buildTrending = (places) => { const categoryMap = new Map(); const availableOfferings = []; let openNow = 0; places.forEach((place) => { const categoryName = place.category?.name || 'Tempat lokal'; categoryMap.set(categoryName, (categoryMap.get(categoryName) || 0) + 1); if (place.live_status?.status === 'open') { openNow += 1; } (place.offerings || []) .filter(isAvailableOffering) .forEach((offering) => { availableOfferings.push({ name: offering.name, place_name: place.name, stock_label: offering.stock_label, type: offering.offering_type, }); }); }); return { local_topics: Array.from(categoryMap.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, 4) .map(([name, count]) => ({ name, count, label: `${count} listing aktif` })), products: availableOfferings.slice(0, 6), events: [ 'Promo lokal dan event sekitar siap diindeks dari panel admin.', 'Crawler berita/video/PDF bisa ditambahkan sebagai tahap berikutnya.', ], live: { open_now: openNow, total: places.length, }, }; }; const loadPublicPlaces = async (category) => db.places.findAll({ where: { status: 'published', }, include: [ { model: db.place_categories, as: 'category', attributes: ['id', 'name', 'slug', 'description', 'color_hex'], where: buildCategoryWhere(category), required: true, }, offeringsInclude, ], limit: CANDIDATE_LIMIT, order: [ ['is_verified', 'DESC'], ['rating_average', 'DESC'], ['name', 'ASC'], ], }); router.get('/places/categories', wrapAsync(async (req, res) => { const categories = await db.place_categories.findAll({ attributes: ['id', 'name', 'slug', 'description', 'color_hex', 'is_active'], where: { is_active: true }, order: [['name', 'ASC']], }); res.status(200).send({ rows: categories }); })); router.get('/places/:id', wrapAsync(async (req, res) => { const lat = parseCoordinate(req.query.lat, 'Latitude'); const lng = parseCoordinate(req.query.lng, 'Longitude'); const query = String(req.query.q || '').trim(); const queryIntent = analyzeHyperlocalIntent(query); const radiusKm = queryIntent.requestedRadiusKm || parseRadius(req.query.radiusKm); const origin = lat !== null && lng !== null ? { lat, lng } : null; const place = await db.places.findOne({ where: { id: req.params.id, status: 'published', }, include: [ { model: db.place_categories, as: 'category', attributes: ['id', 'name', 'slug', 'description', 'color_hex'], where: { is_active: true }, required: true, }, offeringsInclude, ], }); if (!place) { const error = new Error('Tempat tidak ditemukan. Pastikan tempat sudah berstatus published.'); error.code = 404; throw error; } const publicPlace = toPublicPlace(place, origin); const geoScore = calculateGeoScore(publicPlace, query, radiusKm); res.status(200).send({ ...publicPlace, search_score: geoScore.value, geo_score: geoScore.value, geo_score_breakdown: geoScore.components, geo_score_formula: GEO_SCORE_FORMULA, radius_zone: getRadiusZone(publicPlace.distance_km, radiusKm), query_intent: queryIntent, ai_recommendation: buildAiRecommendation(publicPlace, query), }); })); router.get('/places', wrapAsync(async (req, res) => { const query = String(req.query.q || '').trim(); const category = String(req.query.category || '').trim(); const lat = parseCoordinate(req.query.lat, 'Latitude'); const lng = parseCoordinate(req.query.lng, 'Longitude'); const queryIntent = analyzeHyperlocalIntent(query); const radiusKm = queryIntent.requestedRadiusKm || parseRadius(req.query.radiusKm); const origin = requireSearchOrigin(lat, lng); const rawLimit = Number(req.query.limit || DEFAULT_LIMIT); const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.round(rawLimit), 1), MAX_LIMIT) : DEFAULT_LIMIT; const hasQuery = tokenizeQuery(query).length > 0; const externalSourceErrors = []; const places = await loadPublicPlaces(category); const publicPlaces = places.map((place) => toPublicPlace(place, origin)); let externalPlaces = []; if (origin && (hasQuery || category)) { try { const externalRadiusKm = queryIntent.nearby ? Math.min(Math.max(radiusKm * 2, 10), 25) : radiusKm; externalPlaces = await fetchOsmPlaces( origin, query, category, externalRadiusKm, limit, ); } catch (err) { console.error('Gagal memuat data OpenStreetMap', { query, category, message: err.message, status: err.response?.status, data: err.response?.data, overpass_errors: err.overpass_errors, }); externalSourceErrors.push('Data OpenStreetMap gagal dimuat; hasil sekitar lokasi aktif tetap ditampilkan jika tersedia.'); } } const combinedPlaces = [...externalPlaces, ...publicPlaces]; const radiusFilteredPlaces = origin ? 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 )); scoredRows = buildScoredRows(expandedPlaces); expandedForNearest = scoredRows.length > 0; } const rows = scoredRows .slice(0, limit) .map((item) => ({ ...item.place, search_score: item.geoScore.value, geo_score: item.geoScore.value, geo_score_breakdown: item.geoScore.components, radius_zone: getRadiusZone(item.place.distance_km, radiusKm), ai_recommendation: buildAiRecommendation(item.place, query), })); const nearbyIndexedPlaces = origin ? combinedPlaces.filter((place) => ( place.distance_km !== null && place.distance_km !== undefined && place.distance_km <= Math.max(radiusKm, 100) )) : combinedPlaces; res.status(200).send({ rows, count: scoredRows.length, radius_km: origin ? radiusKm : null, radius_zone: getRadiusZone(null, radiusKm), query_intent: queryIntent, radius_zones: RADIUS_ZONES, distance_buckets: buildDistanceBuckets(nearbyIndexedPlaces), filtered_by_radius: Boolean(origin) && !expandedForNearest, expanded_for_nearest: expandedForNearest, external_sources: externalPlaces.length ? ['openstreetmap'] : [], external_source_errors: externalSourceErrors, total_candidates: nearbyIndexedPlaces.length, geo_score_formula: GEO_SCORE_FORMULA, trending: buildTrending(rows), recommendation_engine: 'rules_based_geo_score_mvp', }); })); router.get('/trending', wrapAsync(async (req, res) => { const lat = parseCoordinate(req.query.lat, 'Latitude'); const lng = parseCoordinate(req.query.lng, 'Longitude'); const origin = lat !== null && lng !== null ? { lat, lng } : null; const places = await loadPublicPlaces(''); const publicPlaces = places.map((place) => toPublicPlace(place, origin)); res.status(200).send(buildTrending(publicPlaces)); })); router.use('/', commonErrorHandler); module.exports = router;