2099 lines
67 KiB
JavaScript
2099 lines
67 KiB
JavaScript
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;
|