40275-vm/backend/src/routes/publicPlaces.js
2026-06-17 16:21:04 +00:00

2099 lines
67 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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: '01 Km',
description: 'Paling cocok untuk jalan kaki dan kebutuhan sangat dekat.',
},
{
value: 5,
label: 'Neighborhood Zone',
range: '15 Km',
description: 'Default GeoSeek: sekitar rumah, kantor, dan lingkungan sekitar.',
},
{
value: 25,
label: 'City Zone',
range: '525 Km',
description: 'Menjangkau satu kota untuk pilihan yang lebih banyak.',
},
{
value: 100,
label: 'Regional Zone',
range: '25100 Km',
description: 'Area regional atau kabupaten/kota sekitar.',
},
{
value: 500,
label: 'Provincial Zone',
range: '100500 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;