From 9fb349ab8499f8221c9e1ebb1bcd16b88928f48a Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Thu, 18 Jun 2026 00:17:23 +0000 Subject: [PATCH] Autosave: 20260618-001727 --- backend/src/routes/publicPlaces.js | 1414 +++++++---- .../GeoSeek/GeoSeekProWorkspace.tsx | 501 +++- frontend/src/data/geoseekAutomation.ts | 4 +- frontend/src/pages/index.tsx | 2169 +++++++++-------- 4 files changed, 2694 insertions(+), 1394 deletions(-) diff --git a/backend/src/routes/publicPlaces.js b/backend/src/routes/publicPlaces.js index d71a9c2..e5cd5f4 100644 --- a/backend/src/routes/publicPlaces.js +++ b/backend/src/routes/publicPlaces.js @@ -33,7 +33,8 @@ const RADIUS_ZONES = [ value: 5, label: 'Neighborhood Zone', range: '1–5 Km', - description: 'Default GeoSeek: sekitar rumah, kantor, dan lingkungan sekitar.', + description: + 'Default GeoSeek: sekitar rumah, kantor, dan lingkungan sekitar.', }, { value: 25, @@ -127,9 +128,30 @@ const SYNONYMS = { 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'], + 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'], + cafe: [ + 'kafe', + 'kopi', + 'coffee', + 'kedai', + 'warung', + 'kuliner', + 'makan', + 'minum', + ], clinic: ['klinik', 'kesehatan', 'dokter', 'obat'], coffee: ['cafe', 'kafe', 'kopi', 'kedai'], dapur: ['kitchen', 'kabinet', 'interior', 'lemari'], @@ -139,10 +161,30 @@ const SYNONYMS = { 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'], + 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'], + 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'], @@ -156,12 +198,39 @@ const SYNONYMS = { 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'], + 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'], + otomotif: [ + 'automotif', + 'auto', + 'bengkel', + 'mobil', + 'kendaraan', + 'servis', + 'service', + ], pembayaran: ['bayar', 'digital', 'cashless', 'qris'], pantai: ['wisata', 'travel', 'penginapan', 'hotel', 'lokal'], pasar: ['belanja', 'toko', 'marketplace', 'produk'], @@ -169,14 +238,49 @@ const SYNONYMS = { 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'], + 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'], + service: [ + 'servis', + 'bengkel', + 'mobil', + 'otomotif', + 'auto', + 'oli', + 'tune', + 'mesin', + ], sakit: ['kesehatan', 'klinik', 'dokter', 'obat', 'herbal'], - servis: ['service', 'bengkel', 'mobil', 'otomotif', 'auto', 'oli', 'tune', 'mesin'], + 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'], @@ -219,7 +323,9 @@ const requireSearchOrigin = (lat, lng) => { return { lat, lng }; } - const error = new Error('Lokasi pengguna wajib dikirim. Izinkan akses lokasi browser dan kirim lat/lng.'); + const error = new Error( + 'Lokasi pengguna wajib dikirim. Izinkan akses lokasi browser dan kirim lat/lng.', + ); error.code = 400; throw error; }; @@ -250,16 +356,19 @@ const toNumber = (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 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/); + const radiusMatch = + normalized.match(/(?:dalam\s+)?radius\s+(\d+(?:[.,]\d+)?)\s*km/) || + normalized.match(/\b(\d+(?:[.,]\d+)?)\s*km\b/); if (!radiusMatch) { return null; @@ -273,7 +382,8 @@ const parseRadiusFromQueryText = (query) => { return Math.min(number, GLOBAL_RADIUS_KM); }; -const hasAnyPhrase = (text, phrases) => phrases.some((phrase) => text.includes(phrase)); +const hasAnyPhrase = (text, phrases) => + phrases.some((phrase) => text.includes(phrase)); const analyzeHyperlocalIntent = (query) => { const normalized = normalizeText(query); @@ -288,14 +398,36 @@ const analyzeHyperlocalIntent = (query) => { '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']), + 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']), + newest: hasAnyPhrase(normalized, [ + 'terbaru', + 'baru update', + 'update terbaru', + ]), + recommended: hasAnyPhrase(normalized, [ + 'direkomendasikan', + 'recommended', + 'rekomendasi', + ]), verified: hasAnyPhrase(normalized, ['terverifikasi', 'verified']), requestedRadiusKm: parseRadiusFromQueryText(query), }; @@ -324,14 +456,13 @@ const isFuelSearchQuery = (query) => { return true; } - return normalized - .split(/\s+/) - .some((token) => FUEL_QUERY_TOKENS.has(token)); + 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 tokenizeQuery = (value) => + normalizeText(value) + .split(/\s+/) + .filter((token) => token.length > 1 && !STOP_WORDS.has(token)); const includesToken = (text, token) => { if (!text || !token) { @@ -343,35 +474,58 @@ const includesToken = (text, 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))); + 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 scoreField = (field, token, weight) => + includesToken(field, token) ? weight : 0; -const isAvailableOffering = (offering) => ['in_stock', 'limited', 'by_request'].includes(offering.stock_status); +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 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) => { +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; + 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; @@ -395,11 +549,12 @@ const scoreOfferingTokens = (offerings, tokens, tokenWeight, availableWeight) => 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 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); @@ -412,20 +567,19 @@ const calculateRawRelevanceScore = (place, 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(' ')), + 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)), }; @@ -481,7 +635,11 @@ const calculateRawRelevanceScore = (place, query) => { } usedSynonyms.add(token); - tokenSynonymScore += scoreTokenInSearchFields(fields, token, synonymWeights); + tokenSynonymScore += scoreTokenInSearchFields( + fields, + token, + synonymWeights, + ); tokenSynonymScore += scoreOfferingTokens(offerings, [token], 4, 5); }); @@ -491,7 +649,10 @@ const calculateRawRelevanceScore = (place, query) => { } }); - if (originalTokens.length > 1 && matchedSearchTerms.size < originalTokens.length) { + if ( + originalTokens.length > 1 && + matchedSearchTerms.size < originalTokens.length + ) { return 0; } @@ -519,11 +680,22 @@ const calculateRawRelevanceScore = (place, query) => { 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; + 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)); + return clampScore( + availableOfferings * 12 + + verifiedOfferings * 8 + + products * 3 + + services * 3, + ); }; const calculateIntentBoost = (place, intent, radiusKm) => { @@ -537,8 +709,13 @@ const calculateIntentBoost = (place, intent, radiusKm) => { boost += 16; } - if (intent.nearby && place.distance_km !== null && place.distance_km !== undefined) { - const safeRadius = radiusKm || intent.requestedRadiusKm || DEFAULT_RADIUS_KM; + 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))); } @@ -627,9 +804,11 @@ const getMostRecentActivityDate = (place) => { } }); - return dates - .filter((date) => !Number.isNaN(date.getTime())) - .sort((a, b) => b.getTime() - a.getTime())[0] || null; + return ( + dates + .filter((date) => !Number.isNaN(date.getTime())) + .sort((a, b) => b.getTime() - a.getTime())[0] || null + ); }; const calculateActivityComponent = (place) => { @@ -652,8 +831,13 @@ 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 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); @@ -668,23 +852,29 @@ const calculateGeoScore = (place, query, radiusKm) => { 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; + 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))]), + 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 && 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; @@ -718,22 +908,27 @@ const compareAveragePrice = (a, b) => { 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'); + 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); + 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); + 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); + const popularityDiff = + (b.place.rating_count || 0) - (a.place.rating_count || 0); if (popularityDiff !== 0) return popularityDiff; } @@ -753,7 +948,8 @@ const sortScoredPlaces = (a, b, hasQuery, intent = {}) => { } if (hasQuery) { - const relevanceDiff = b.geoScore.components.relevance - a.geoScore.components.relevance; + const relevanceDiff = + b.geoScore.components.relevance - a.geoScore.components.relevance; if (Math.abs(relevanceDiff) >= 4) return relevanceDiff; } @@ -763,7 +959,8 @@ const sortScoredPlaces = (a, b, hasQuery, intent = {}) => { return geoScoreDiff; } - const ratingDiff = (b.place.rating_average || 0) - (a.place.rating_average || 0); + const ratingDiff = + (b.place.rating_average || 0) - (a.place.rating_average || 0); if (Math.abs(ratingDiff) >= 0.2) { return ratingDiff; } @@ -805,7 +1002,10 @@ const calculateLiveStatus = (place) => { if (slug.includes('cafe')) { open = isBetweenHour(hour, 7, 22); - busy = isBetweenHour(hour, 7, 9) || isBetweenHour(hour, 12, 14) || isBetweenHour(hour, 19, 21); + 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); @@ -816,7 +1016,7 @@ const calculateLiveStatus = (place) => { const activityDate = getMostRecentActivityDate(place); const updatedToday = activityDate - ? ((Date.now() - activityDate.getTime()) / (1000 * 60 * 60 * 24)) <= 1 + ? (Date.now() - activityDate.getTime()) / (1000 * 60 * 60 * 24) <= 1 : false; return { @@ -829,8 +1029,13 @@ const calculateLiveStatus = (place) => { }; 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]; + 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; }; @@ -855,8 +1060,12 @@ const summarizeOfferings = (offerings) => { 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, + 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), }; @@ -867,11 +1076,12 @@ const calculateDistanceKm = (lat1, lon1, lat2, lon2) => { 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); + 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)); }; @@ -886,13 +1096,13 @@ const OSM_SEARCH_PROFILES = [ { id: 'fuel', matchTerms: Array.from(FUEL_QUERY_TOKENS), - filters: [ - { key: 'amenity', value: 'fuel' }, - ], + 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', + 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', @@ -915,8 +1125,10 @@ const OSM_SEARCH_PROFILES = [ { 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.', + 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', @@ -934,7 +1146,16 @@ const OSM_SEARCH_PROFILES = [ }, { id: 'restaurant', - matchTerms: ['restoran', 'restaurant', 'kuliner', 'makan', 'makanan', 'warung', 'nasi', 'food'], + matchTerms: [ + 'restoran', + 'restaurant', + 'kuliner', + 'makan', + 'makanan', + 'warung', + 'nasi', + 'food', + ], filters: [ { key: 'amenity', value: 'restaurant' }, { key: 'amenity', value: 'fast_food' }, @@ -942,9 +1163,12 @@ const OSM_SEARCH_PROFILES = [ { 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', + 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', @@ -961,7 +1185,21 @@ const OSM_SEARCH_PROFILES = [ }, { id: 'automotive', - matchTerms: ['bengkel', 'service', 'servis', 'mobil', 'otomotif', 'automotif', 'auto', 'oli', 'ban', 'tambal', 'cuci', 'spooring', 'balancing'], + 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' }, @@ -970,9 +1208,12 @@ const OSM_SEARCH_PROFILES = [ { 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', + 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', @@ -997,7 +1238,8 @@ const OSM_SEARCH_PROFILES = [ ], defaultName: 'Apotek / Farmasi', shortDescription: 'Apotek atau farmasi dari OpenStreetMap.', - fullDescription: 'Data apotek/farmasi diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', + 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', @@ -1015,7 +1257,19 @@ const OSM_SEARCH_PROFILES = [ }, { id: 'healthcare', - matchTerms: ['klinik', 'clinic', 'dokter', 'doctor', 'rumah sakit', 'sakit', 'rs', 'hospital', 'kesehatan', 'gigi', 'dentist'], + matchTerms: [ + 'klinik', + 'clinic', + 'dokter', + 'doctor', + 'rumah sakit', + 'sakit', + 'rs', + 'hospital', + 'kesehatan', + 'gigi', + 'dentist', + ], filters: [ { key: 'amenity', value: 'clinic' }, { key: 'amenity', value: 'hospital' }, @@ -1027,9 +1281,12 @@ const OSM_SEARCH_PROFILES = [ { 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', + 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', @@ -1046,7 +1303,17 @@ const OSM_SEARCH_PROFILES = [ }, { id: 'hotel', - matchTerms: ['hotel', 'penginapan', 'hostel', 'guesthouse', 'guest house', 'villa', 'resort', 'motel', 'homestay'], + matchTerms: [ + 'hotel', + 'penginapan', + 'hostel', + 'guesthouse', + 'guest house', + 'villa', + 'resort', + 'motel', + 'homestay', + ], filters: [ { key: 'tourism', value: 'hotel' }, { key: 'tourism', value: 'guest_house' }, @@ -1057,8 +1324,10 @@ const OSM_SEARCH_PROFILES = [ ], 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', + 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', @@ -1075,7 +1344,17 @@ const OSM_SEARCH_PROFILES = [ }, { id: 'tourism', - matchTerms: ['wisata', 'travel', 'taman', 'pantai', 'museum', 'rekreasi', 'attraction', 'tourism', 'liburan'], + matchTerms: [ + 'wisata', + 'travel', + 'taman', + 'pantai', + 'museum', + 'rekreasi', + 'attraction', + 'tourism', + 'liburan', + ], filters: [ { key: 'tourism', value: 'attraction' }, { key: 'tourism', value: 'museum' }, @@ -1087,9 +1366,12 @@ const OSM_SEARCH_PROFILES = [ { 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', + 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', @@ -1104,6 +1386,150 @@ const OSM_SEARCH_PROFILES = [ }, maxRadiusKm: 50, }, + { + id: 'government-office', + matchTerms: [ + 'kantor', + 'pemerintah', + 'pemerintahan', + 'pemda', + 'instansi', + 'layanan publik', + 'bpn', + 'atr', + 'agraria', + 'pertanahan', + 'camat', + 'kecamatan', + 'kelurahan', + 'desa', + 'samsat', + 'dukcapil', + 'disdukcapil', + 'pajak', + 'kpp', + 'pengadilan', + 'kejaksaan', + 'polisi', + 'polsek', + 'polres', + ], + filters: [ + { key: 'office', value: 'government' }, + { key: 'government' }, + { key: 'amenity', value: 'townhall' }, + { key: 'amenity', value: 'courthouse' }, + { key: 'amenity', value: 'police' }, + { key: 'office', value: 'administrative' }, + { key: 'office', value: 'tax' }, + ], + defaultName: 'Kantor Pemerintahan', + shortDescription: + 'Kantor pemerintahan, layanan publik, atau instansi dari OpenStreetMap.', + fullDescription: + 'Data kantor pemerintahan diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', + keywords: + 'kantor pemerintah pemerintahan layanan publik instansi pemda administrasi publik', + category: { + id: 'openstreetmap-government-office', + name: 'Kantor Pemerintahan', + slug: 'kantor-pemerintahan', + color_hex: '#0F766E', + description: 'Kantor layanan publik dan pemerintahan dari OpenStreetMap.', + }, + offering: { + name: 'Layanan publik', + description: 'Layanan mengikuti informasi resmi lokasi terkait.', + offering_type: 'service', + }, + maxRadiusKm: 100, + }, + { + id: 'bumn-office', + matchTerms: [ + 'bumn', + 'bumd', + 'kantor bumn', + 'kantor bumd', + 'kantor pos', + 'pos indonesia', + 'kantor pln', + 'pln', + 'kantor pdam', + 'pdam', + 'kantor telkom', + 'telkom', + 'kantor bpjs', + 'bpjs', + 'kantor bri', + 'bri', + 'kantor bni', + 'bni', + 'bank mandiri', + 'kantor mandiri', + 'mandiri', + 'kantor btn', + 'btn', + 'pegadaian', + 'kantor pegadaian', + 'pertamina', + ], + filters: [ + { key: 'amenity', value: 'post_office' }, + { key: 'office', value: 'utility' }, + { key: 'office', value: 'telecommunication' }, + { + conditions: [ + { key: 'amenity', value: 'fuel' }, + { key: 'brand', value: 'Pertamina' }, + ], + }, + { + conditions: [ + { key: 'amenity', value: 'bank' }, + { key: 'brand', value: 'BRI' }, + ], + }, + { + conditions: [ + { key: 'amenity', value: 'bank' }, + { key: 'brand', value: 'BNI' }, + ], + }, + { + conditions: [ + { key: 'amenity', value: 'bank' }, + { key: 'brand', value: 'Bank Mandiri' }, + ], + }, + { + conditions: [ + { key: 'amenity', value: 'bank' }, + { key: 'brand', value: 'BTN' }, + ], + }, + ], + defaultName: 'Kantor BUMN / BUMD', + shortDescription: + 'Kantor BUMN/BUMD, layanan publik perusahaan negara/daerah, atau outlet terkait dari OpenStreetMap.', + fullDescription: + 'Data kantor BUMN/BUMD diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', + keywords: + 'kantor bumn bumd layanan publik perusahaan negara daerah utilitas telekomunikasi perbankan pos energi', + category: { + id: 'openstreetmap-bumn-office', + name: 'Kantor BUMN / BUMD', + slug: 'kantor-bumn-bumd', + color_hex: '#155E75', + description: 'Kantor dan layanan BUMN/BUMD dari OpenStreetMap.', + }, + offering: { + name: 'Layanan BUMN / BUMD', + description: 'Layanan mengikuti informasi resmi lokasi terkait.', + offering_type: 'service', + }, + maxRadiusKm: 100, + }, { id: 'bank-atm', matchTerms: ['atm', 'bank', 'tunai', 'uang', 'cash'], @@ -1113,7 +1539,8 @@ const OSM_SEARCH_PROFILES = [ ], defaultName: 'ATM / Bank', shortDescription: 'ATM atau bank dari OpenStreetMap.', - fullDescription: 'Data ATM/bank diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.', + 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', @@ -1131,7 +1558,26 @@ const OSM_SEARCH_PROFILES = [ }, { id: 'retail', - matchTerms: ['minimarket', 'supermarket', 'toko', 'belanja', 'pasar', 'mall', 'market', 'store', 'baju', 'pakaian', 'elektronik', 'hp', 'handphone', 'furniture', 'furnitur', 'mebel', 'laundry', 'salon'], + 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' }, @@ -1155,9 +1601,12 @@ const OSM_SEARCH_PROFILES = [ { 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', + 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', @@ -1174,7 +1623,17 @@ const OSM_SEARCH_PROFILES = [ }, { id: 'education', - matchTerms: ['sekolah', 'kampus', 'universitas', 'kuliah', 'pendidikan', 'kursus', 'belajar', 'tk', 'college'], + matchTerms: [ + 'sekolah', + 'kampus', + 'universitas', + 'kuliah', + 'pendidikan', + 'kursus', + 'belajar', + 'tk', + 'college', + ], filters: [ { key: 'amenity', value: 'school' }, { key: 'amenity', value: 'college' }, @@ -1184,9 +1643,12 @@ const OSM_SEARCH_PROFILES = [ { 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', + 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', @@ -1213,8 +1675,10 @@ const OSM_SEARCH_PROFILES = [ }, ], 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.', + 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', @@ -1243,8 +1707,10 @@ const OSM_SEARCH_PROFILES = [ ], 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', + 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', @@ -1261,14 +1727,21 @@ const OSM_SEARCH_PROFILES = [ }, { id: 'worship', - matchTerms: ['ibadah', 'tempat ibadah', 'pura', 'vihara', 'kelenteng', 'klenteng'], - filters: [ - { key: 'amenity', value: 'place_of_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', + 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', @@ -1292,8 +1765,10 @@ const OSM_SEARCH_PROFILES = [ { 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.', + 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', @@ -1335,6 +1810,19 @@ const OSM_TAG_VALUE_KEYWORDS = { fitness_centre: 'gym fitness fitnes olahraga', food_court: 'food court kuliner makanan restoran', fuel: 'spbu bbm bensin pom bahan bakar', + government: + 'pemerintah pemerintahan kantor instansi layanan publik pemda administrasi publik', + townhall: + 'kantor pemerintah balai kota kecamatan kelurahan desa pemda layanan publik', + courthouse: 'pengadilan kejaksaan hukum layanan publik kantor pemerintah', + police: 'polisi polsek polres polda layanan publik kantor pemerintah', + post_office: 'kantor pos pos indonesia bumn kirim paket surat logistik', + tax: 'pajak kpp kantor pajak layanan publik npwp samsat', + administrative: + 'administrasi kantor pemerintah layanan publik administrasi publik', + utility: 'pln pdam utilitas listrik air bumn bumd layanan pelanggan', + telecommunication: + 'telkom telekomunikasi bumn layanan pelanggan internet telepon', furniture: 'furniture furnitur mebel perabot toko', guest_house: 'penginapan guesthouse guest house hotel', hairdresser: 'salon potong rambut kecantikan', @@ -1363,19 +1851,21 @@ const OSM_TAG_VALUE_KEYWORDS = { zoo: 'kebun binatang zoo wisata rekreasi', }; -const normalizeOsmTagValue = (value) => normalizeText(String(value || '').replace(/_/g, ' ')); +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}"]`; - } +const buildOsmFilterCondition = (filter) => + getOsmFilterConditions(filter) + .map((condition) => { + if (condition.value === undefined || condition.value === null) { + return `["${condition.key}"]`; + } - return `["${condition.key}"="${condition.value}"]`; - }) - .join(''); + return `["${condition.key}"="${condition.value}"]`; + }) + .join(''); const osmTagMatchesCondition = (tags, condition) => { const tagValue = tags[condition.key]; @@ -1388,11 +1878,15 @@ const osmTagMatchesCondition = (tags, condition) => { return true; } - return normalizeOsmTagValue(tagValue) === normalizeOsmTagValue(condition.value); + return ( + normalizeOsmTagValue(tagValue) === normalizeOsmTagValue(condition.value) + ); }; -const osmTagMatchesFilter = (tags, filter) => getOsmFilterConditions(filter) - .every((condition) => osmTagMatchesCondition(tags, condition)); +const osmTagMatchesFilter = (tags, filter) => + getOsmFilterConditions(filter).every((condition) => + osmTagMatchesCondition(tags, condition), + ); const textMatchesOsmTerm = (normalizedText, tokens, term) => { const normalizedTerm = normalizeText(term); @@ -1405,7 +1899,9 @@ const textMatchesOsmTerm = (normalizedText, tokens, term) => { return normalizedText.includes(normalizedTerm); } - return tokens.has(normalizedTerm) || includesToken(normalizedText, normalizedTerm); + return ( + tokens.has(normalizedTerm) || includesToken(normalizedText, normalizedTerm) + ); }; const buildExpandedOsmTokens = (tokens) => { @@ -1433,9 +1929,11 @@ const getOsmSearchProfiles = (query, category) => { const directTokens = new Set(tokenizeQuery(searchText)); const expandedTokens = buildExpandedOsmTokens(directTokens); - const exactProfiles = OSM_SEARCH_PROFILES.filter((profile) => ( - profile.matchTerms.some((term) => textMatchesOsmTerm(normalized, directTokens, term)) - )); + const exactProfiles = OSM_SEARCH_PROFILES.filter((profile) => + profile.matchTerms.some((term) => + textMatchesOsmTerm(normalized, directTokens, term), + ), + ); if (exactProfiles.length) { return exactProfiles.slice(0, 4); @@ -1445,15 +1943,21 @@ const getOsmSearchProfiles = (query, category) => { return OSM_SEARCH_PROFILES.filter((profile) => profile.id === 'fuel'); } - return OSM_SEARCH_PROFILES - .filter((profile) => profile.matchTerms.some((term) => textMatchesOsmTerm(normalized, expandedTokens, term))) - .slice(0, 4); + return 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; + return ( + profiles.find((profile) => + profile.filters.some((filter) => osmTagMatchesFilter(tags, filter)), + ) || null + ); }; const buildOsmTagKeywordText = (tags = {}) => { @@ -1478,7 +1982,12 @@ const buildOsmTagKeywordText = (tags = {}) => { return []; } - return [normalizedValue, OSM_TAG_VALUE_KEYWORDS[value] || OSM_TAG_VALUE_KEYWORDS[normalizedValue] || '']; + return [ + normalizedValue, + OSM_TAG_VALUE_KEYWORDS[value] || + OSM_TAG_VALUE_KEYWORDS[normalizedValue] || + '', + ]; }) .filter(Boolean) .join(' '); @@ -1531,15 +2040,19 @@ const getOsmCoordinate = (element) => { 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 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 buildOsmMapsUrl = (latitude, longitude) => + `https://www.google.com/maps/dir/?api=1&destination=${latitude},${longitude}`; const toOsmPlace = (element, origin, profile) => { const coordinate = getOsmCoordinate(element); @@ -1551,13 +2064,20 @@ const toOsmPlace = (element, origin, profile) => { const tags = element.tags || {}; const latitude = coordinate.lat; const longitude = coordinate.lng; - const distanceKm = calculateDistanceKm(origin.lat, origin.lng, latitude, longitude); + 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 osmKeywords = [profile.keywords, buildOsmTagKeywordText(tags)] + .filter(Boolean) + .join(' '); const offerings = buildOsmOfferings(profile); return { @@ -1569,9 +2089,12 @@ const toOsmPlace = (element, origin, profile) => { profile.fullDescription, tags.cuisine ? `Jenis kuliner: ${tags.cuisine}.` : '', osmKeywords ? `Kata kunci lokasi: ${osmKeywords}.` : '', - ].filter(Boolean).join(' '), + ] + .filter(Boolean) + .join(' '), address: address || [latitude, longitude].join(', '), - city: tags['addr:city'] || tags['addr:district'] || tags['addr:county'] || '', + city: + tags['addr:city'] || tags['addr:district'] || tags['addr:county'] || '', province: tags['addr:province'] || tags['addr:state'] || '', latitude, longitude, @@ -1595,7 +2118,12 @@ const toOsmPlace = (element, origin, profile) => { }; }; -const buildOsmOverpassQuery = (profiles, radiusMeters, origin, overpassLimit) => { +const buildOsmOverpassQuery = ( + profiles, + radiusMeters, + origin, + overpassLimit, +) => { const clauses = []; const seenClauses = new Set(); @@ -1623,7 +2151,10 @@ const buildOsmOverpassQuery = (profiles, radiusMeters, origin, 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); + const maxProfileRadiusKm = profiles.reduce( + (maxRadiusKm, profile) => Math.max(maxRadiusKm, profile.maxRadiusKm || 25), + 25, + ); return Math.min(requestedRadiusKm, maxProfileRadiusKm); }; @@ -1642,31 +2173,38 @@ 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, + data: + typeof err.response?.data === 'string' + ? err.response.data.slice(0, 500) + : err.response?.data, }); -const postOverpassQuery = async (body, endpointIndex = 0, previousErrors = []) => { +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', - }, + 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)]; + const overpassErrors = [ + ...previousErrors, + summarizeOverpassError(err, endpoint), + ]; - if (endpointIndex < OVERPASS_ENDPOINTS.length - 1 && isRetriableOverpassError(err)) { + if ( + endpointIndex < OVERPASS_ENDPOINTS.length - 1 && + isRetriableOverpassError(err) + ) { return postOverpassQuery(body, endpointIndex + 1, overpassErrors); } @@ -1688,14 +2226,24 @@ const fetchOsmPlaces = async (origin, query, category, radiusKm, limit) => { 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 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 elements = Array.isArray(response.data?.elements) + ? response.data.elements + : []; const placeMap = new Map(); elements.forEach((element) => { @@ -1718,14 +2266,14 @@ const fetchOsmPlaces = async (origin, query, category, radiusKm, limit) => { .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 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, @@ -1748,13 +2296,15 @@ const toPublicPlace = (place, origin) => { 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, + 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), @@ -1775,7 +2325,11 @@ const buildCategoryWhere = (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)) { + 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 }); } @@ -1809,24 +2363,29 @@ const offeringsInclude = { ], }; -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); +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, - }; -}); + 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()}`); + reasonParts.push( + `${topOffering.name} ${topOffering.stock_label.toLowerCase()}`, + ); } if (place.is_verified) { @@ -1863,23 +2422,25 @@ const buildTrending = (places) => { 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, - }); + (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` })), + .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.', @@ -1892,49 +2453,9 @@ const buildTrending = (places) => { }; }; -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({ +const loadPublicPlaces = async (category) => + db.places.findAll({ where: { - id: req.params.id, status: 'published', }, include: [ @@ -1942,113 +2463,183 @@ router.get('/places/:id', wrapAsync(async (req, res) => { model: db.place_categories, as: 'category', attributes: ['id', 'name', 'slug', 'description', 'color_hex'], - where: { is_active: true }, + where: buildCategoryWhere(category), required: true, }, offeringsInclude, ], + limit: CANDIDATE_LIMIT, + order: [ + ['is_verified', 'DESC'], + ['rating_average', 'DESC'], + ['name', 'ASC'], + ], }); - if (!place) { - const error = new Error('Tempat tidak ditemukan. Pastikan tempat sudah berstatus published.'); - error.code = 404; - throw error; - } +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']], + }); - const publicPlace = toPublicPlace(place, origin); - const geoScore = calculateGeoScore(publicPlace, query, radiusKm); + res.status(200).send({ rows: categories }); + }), +); - 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/: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; -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 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, + ], + }); - 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, + if (!place) { + const error = new Error( + 'Tempat tidak ditemukan. Pastikan tempat sudah berstatus published.', ); - } 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.'); + error.code = 404; + throw error; } - } - 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); + const publicPlace = toPublicPlace(place, origin); + const geoScore = calculateGeoScore(publicPlace, 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)); + 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), + }); + }), +); - let scoredRows = buildScoredRows(radiusFilteredPlaces); - let expandedForNearest = false; +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; - 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 - )); + const externalSourceErrors = []; + const places = await loadPublicPlaces(category); + const publicPlaces = places.map((place) => toPublicPlace(place, origin)); + let externalPlaces = []; - scoredRows = buildScoredRows(expandedPlaces); - expandedForNearest = scoredRows.length > 0; - } + if (origin && (hasQuery || category)) { + try { + const externalRadiusKm = queryIntent.nearby + ? Math.min(Math.max(radiusKm * 2, 10), 25) + : radiusKm; - const rows = scoredRows - .slice(0, limit) - .map((item) => ({ + 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, @@ -2056,42 +2647,47 @@ router.get('/places', wrapAsync(async (req, res) => { 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; + 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', - }); -})); + 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)); +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)); -})); + res.status(200).send(buildTrending(publicPlaces)); + }), +); router.use('/', commonErrorHandler); diff --git a/frontend/src/components/GeoSeek/GeoSeekProWorkspace.tsx b/frontend/src/components/GeoSeek/GeoSeekProWorkspace.tsx index 707c723..6348785 100644 --- a/frontend/src/components/GeoSeek/GeoSeekProWorkspace.tsx +++ b/frontend/src/components/GeoSeek/GeoSeekProWorkspace.tsx @@ -1,7 +1,8 @@ import * as icon from '@mdi/js'; +import axios from 'axios'; import Head from 'next/head'; import Link from 'next/link'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import BaseButton from '../BaseButton'; import BaseButtons from '../BaseButtons'; import BaseIcon from '../BaseIcon'; @@ -47,6 +48,89 @@ type QuickAction = { iconPath: string; }; +type GeoSeekLocationSource = 'browser' | 'demo'; + +type GeoSeekResolvedLocation = { + label: string; + latitude: number; + longitude: number; + source: GeoSeekLocationSource; + accuracyMeters?: number; +}; + +type PublicPlaceOffering = { + id?: string; + name?: string; + description?: string; + offering_type?: string; + price?: number | string | null; + stock_status?: string; + stock_label?: string; + stock_quantity?: number | string | null; + is_verified?: boolean; +}; + +type PublicPlaceRow = { + id?: string; + name?: string; + short_description?: string; + full_description?: string; + address?: string; + city?: string; + province?: string; + latitude?: number | string | null; + longitude?: number | string | null; + average_price?: number | string | null; + rating_average?: number | string | null; + rating_count?: number | string | null; + is_verified?: boolean; + distance_km?: number | string | null; + geo_score?: number | string | null; + category?: { + name?: string; + slug?: string; + description?: string; + } | null; + offerings?: PublicPlaceOffering[]; + offerings_summary?: { + products?: number; + services?: number; + total?: number; + available?: number; + }; + live_status?: { + status?: string; + label?: string; + }; + external_source?: string; +}; + +type PublicPlacesResponse = { + rows?: PublicPlaceRow[]; + count?: number; + radius_km?: number | null; + radius_zone?: { + label?: string; + range?: string; + description?: string; + } | null; + filtered_by_radius?: boolean; + expanded_for_nearest?: boolean; + external_sources?: string[]; + external_source_errors?: string[]; + total_candidates?: number; +}; + +type GeoSeekApiMeta = { + count: number; + totalCandidates: number; + radiusZoneLabel: string; + externalSources: string[]; + externalSourceErrors: string[]; + filteredByRadius: boolean; + expandedForNearest: boolean; +}; + const typeLabels: Record = { place: 'Tempat', product: 'Produk', @@ -62,6 +146,207 @@ const typeLabels: Record = { courier: 'Kurir', }; +const backendDemoGeoSeekLocation: GeoSeekResolvedLocation = { + label: 'Pekanbaru Data Demo', + latitude: 0.5095, + longitude: 101.4549, + source: 'demo', +}; + +const emptyApiMeta: GeoSeekApiMeta = { + count: 0, + totalCandidates: 0, + radiusZoneLabel: 'Radius aktif', + externalSources: [], + externalSourceErrors: [], + filteredByRadius: false, + expandedForNearest: false, +}; + +const apiQueryByModule: Partial> = { + home: 'produk jasa umkm lokal', + search: 'produk jasa umkm lokal', + map: '', + products: 'produk stok toko', + services: 'jasa servis booking', + umkm: '', + culinary: 'kuliner cafe warung', + health: 'kesehatan apotek klinik', + property: 'properti ruko rumah', + automotive: 'bengkel otomotif servis', + tourism: 'wisata hotel lokal', + events: 'event bazar lokal', + promos: 'promo diskon lokal', + marketplace: 'produk marketplace lokal', + booking: 'jasa booking lokal', + courier: 'kurir antar lokal', + 'business-dashboard': '', + profile: '', +}; + +const getApiQuery = (query: string, moduleKey: GeoSeekModuleKey) => query.trim() || apiQueryByModule[moduleKey] || ''; + +const toOptionalNumber = (value: unknown) => { + if (value === null || value === undefined || value === '') return undefined; + + const number = Number(value); + + return Number.isFinite(number) ? number : undefined; +}; + +const normalizeTagText = (value: unknown) => String(value || '') + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, ' ') + .trim(); + +const createTags = (values: unknown[], fallbackTags: string[] = ['lokal', 'geoseek']) => { + const tags = values + .flatMap((value) => normalizeTagText(value).split(/\s+/)) + .filter((word) => word.length > 2 && !['dan', 'atau', 'yang', 'untuk', 'dari'].includes(word)); + + return Array.from(new Set([...tags, ...fallbackTags])).slice(0, 8); +}; + +const getPublicPlaceArea = (place: PublicPlaceRow) => [place.city, place.province].filter(Boolean).join(', ') || 'Area sekitar'; + +const getPublicPlaceAddress = (place: PublicPlaceRow) => [place.address, place.city, place.province].filter(Boolean).join(', ') || 'Alamat mengikuti data lokasi'; + +const getPublicPlaceCategory = (place: PublicPlaceRow) => place.category?.name || 'UMKM Lokal'; + +const getPublicPlaceDescription = (place: PublicPlaceRow) => place.short_description || place.full_description || 'Bisnis lokal dari database GeoSeek.'; + +const getPublicPlaceType = (place: PublicPlaceRow): GeoSeekItemType => { + const haystack = normalizeTagText([ + place.name, + place.category?.name, + place.category?.slug, + place.category?.description, + place.short_description, + place.full_description, + ].join(' ')); + + if (/kuliner|cafe|kopi|restoran|warung|kedai|makan|food/.test(haystack)) return 'culinary'; + if (/kesehatan|apotek|klinik|herbal|obat|dokter|health/.test(haystack)) return 'health'; + if (/bengkel|otomotif|mobil|motor|servis|service|auto/.test(haystack)) return 'automotive'; + if (/properti|ruko|rumah|tanah|kios|property/.test(haystack)) return 'property'; + if (/wisata|hotel|travel|tour/.test(haystack)) return 'tourism'; + if (/event|bazar|agenda/.test(haystack)) return 'event'; + + return 'business'; +}; + +const getOfferingType = (offering: PublicPlaceOffering): GeoSeekItemType => (offering.offering_type === 'product' ? 'product' : 'service'); + +const getOfferingStock = (offering: PublicPlaceOffering) => { + const quantity = toOptionalNumber(offering.stock_quantity); + + if (quantity !== undefined) return Math.round(quantity); + if (offering.stock_status === 'limited') return 8; + if (offering.stock_status === 'in_stock') return 24; + + return undefined; +}; + +const getApiActivityScore = (place: PublicPlaceRow, offering?: PublicPlaceOffering) => { + const geoScore = toOptionalNumber(place.geo_score); + const baseScore = geoScore ?? (place.is_verified ? 86 : 72); + const verifiedBonus = offering?.is_verified ? 8 : 0; + + return Math.max(45, Math.min(100, Math.round(baseScore + verifiedBonus))); +}; + +const publicPlaceToGeoSeekItems = (place: PublicPlaceRow): GeoSeekItem[] => { + const placeId = place.id || `place-${place.name || 'unknown'}`; + const category = getPublicPlaceCategory(place); + const distanceKm = toOptionalNumber(place.distance_km) ?? 0; + const rating = toOptionalNumber(place.rating_average) ?? 4.2; + const reviews = Math.round(toOptionalNumber(place.rating_count) ?? 0); + const latitude = toOptionalNumber(place.latitude) ?? backendDemoGeoSeekLocation.latitude; + const longitude = toOptionalNumber(place.longitude) ?? backendDemoGeoSeekLocation.longitude; + const offerings = Array.isArray(place.offerings) ? place.offerings : []; + const hasProducts = offerings.some((offering) => offering.offering_type === 'product'); + const hasServices = offerings.some((offering) => offering.offering_type === 'service'); + const open = place.live_status?.status !== 'closed'; + const baseTags = createTags([ + place.name, + category, + place.category?.slug, + place.short_description, + place.full_description, + offerings.map((offering) => offering.name).join(' '), + ], ['backend', 'umkm', 'lokal']); + + const businessItem: GeoSeekItem = { + id: `api-place-${placeId}`, + type: getPublicPlaceType(place), + name: place.name || 'Bisnis Lokal GeoSeek', + businessName: place.name || 'Bisnis Lokal GeoSeek', + category, + description: getPublicPlaceDescription(place), + address: getPublicPlaceAddress(place), + area: getPublicPlaceArea(place), + latitude, + longitude, + distanceKm, + price: toOptionalNumber(place.average_price), + rating, + reviews, + activityScore: getApiActivityScore(place), + tags: baseTags, + open, + promo: place.is_verified ? 'Bisnis terverifikasi di data GeoSeek' : undefined, + etaMinutes: hasServices ? Math.max(12, Math.min(60, Math.round(distanceKm * 5 + 15))) : undefined, + bookingAvailable: hasServices, + deliveryAvailable: hasProducts, + }; + + const offeringItems = offerings.map((offering, index): GeoSeekItem => { + const type = getOfferingType(offering); + const stock = getOfferingStock(offering); + const stockLabel = offering.stock_label || offering.stock_status || 'Info stok tersedia'; + + return { + id: `api-offering-${placeId}-${offering.id || index}`, + type, + name: offering.name || (type === 'product' ? 'Produk lokal' : 'Jasa lokal'), + businessName: businessItem.businessName, + category: `${typeLabels[type]} • ${category}`, + description: offering.description || getPublicPlaceDescription(place), + address: businessItem.address, + area: businessItem.area, + latitude, + longitude, + distanceKm, + price: toOptionalNumber(offering.price) ?? toOptionalNumber(place.average_price), + stock, + rating, + reviews, + activityScore: getApiActivityScore(place, offering), + tags: createTags([offering.name, offering.description, category, stockLabel, place.name], [type, 'backend', 'lokal']), + open, + promo: offering.stock_status === 'limited' ? `Stok terbatas: ${stockLabel}` : undefined, + etaMinutes: type === 'service' ? Math.max(12, Math.min(60, Math.round(distanceKm * 5 + 15))) : undefined, + bookingAvailable: type === 'service', + deliveryAvailable: type === 'product', + }; + }); + + return [businessItem, ...offeringItems]; +}; + +const normalizePublicPlaceItems = (rows: PublicPlaceRow[]) => { + const seen = new Set(); + + return rows.flatMap(publicPlaceToGeoSeekItems).filter((item) => { + if (seen.has(item.id)) return false; + + seen.add(item.id); + return true; + }); +}; + const moduleIconMap: Partial> = { home: icon.mdiViewDashboardOutline, search: icon.mdiMagnify, @@ -148,7 +433,12 @@ const getDraftItemType = (category: string, moduleKey: GeoSeekModuleKey): GeoSee return 'business'; }; -const createPublishedItem = (input: string, draft: GeoSeekSmartDraft, moduleKey: GeoSeekModuleKey): GeoSeekItem => { +const createPublishedItem = ( + input: string, + draft: GeoSeekSmartDraft, + moduleKey: GeoSeekModuleKey, + origin: { latitude: number; longitude: number } = defaultGeoSeekLocation, +): GeoSeekItem => { const type = getDraftItemType(draft.category, moduleKey); const distanceKm = Math.min(2.8, Number((0.7 + (draft.itemName.length % 12) / 10).toFixed(1))); const supportsBooking = ['service', 'health', 'property', 'tourism', 'culinary'].includes(type); @@ -163,8 +453,8 @@ const createPublishedItem = (input: string, draft: GeoSeekSmartDraft, moduleKey: description: `Item lokal dari Smart Input: ${input}`, address: draft.location, area: draft.location, - latitude: defaultGeoSeekLocation.latitude + distanceKm / 1000, - longitude: defaultGeoSeekLocation.longitude + distanceKm / 900, + latitude: origin.latitude + distanceKm / 1000, + longitude: origin.longitude + distanceKm / 900, distanceKm, price: draft.price, stock: draft.stock, @@ -280,9 +570,9 @@ const getItemActionLabel = (moduleKey: GeoSeekModuleKey, item: GeoSeekScoredItem return 'Aktifkan Otomasi'; }; -const getMapPosition = (item: GeoSeekScoredItem, index: number) => { - const left = Math.min(88, Math.max(8, 50 + (item.longitude - defaultGeoSeekLocation.longitude) * 1800 + index * 2)); - const top = Math.min(82, Math.max(10, 50 + (item.latitude - defaultGeoSeekLocation.latitude) * -1800 + index * 3)); +const getMapPosition = (item: GeoSeekScoredItem, index: number, origin: { latitude: number; longitude: number } = defaultGeoSeekLocation) => { + const left = Math.min(88, Math.max(8, 50 + (item.longitude - origin.longitude) * 1800 + index * 2)); + const top = Math.min(82, Math.max(10, 50 + (item.latitude - origin.latitude) * -1800 + index * 3)); return { left: `${left}%`, top: `${top}%` }; }; @@ -398,17 +688,33 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) { const [publishedItems, setPublishedItems] = useState([]); const [actionHistory, setActionHistory] = useState([]); const [isLocalStateReady, setIsLocalStateReady] = useState(false); + const [resolvedLocation, setResolvedLocation] = useState(backendDemoGeoSeekLocation); + const [isResolvingLocation, setIsResolvingLocation] = useState(true); + const [locationStatus, setLocationStatus] = useState('Mengambil lokasi browser untuk pencarian radius GeoSeek...'); + const [apiItems, setApiItems] = useState([]); + const [isLoadingPlaces, setIsLoadingPlaces] = useState(false); + const [apiError, setApiError] = useState(''); + const [apiMeta, setApiMeta] = useState(emptyApiMeta); + const [searchRequestVersion, setSearchRequestVersion] = useState(0); const { currentUser } = useAppSelector((state) => state.auth); + const hasBackendItemsForModule = useMemo( + () => apiItems.some((item) => activeModule.includeTypes.includes(item.type)), + [activeModule.includeTypes, apiItems], + ); + + const liveExtraItems = useMemo(() => [...apiItems, ...publishedItems], [apiItems, publishedItems]); + const results = useMemo( () => filterGeoSeekItems({ query, radiusKm, includeTypes: activeModule.includeTypes, moduleKey: activeModule.key, - extraItems: publishedItems, + extraItems: liveExtraItems, + baseItems: hasBackendItemsForModule ? [] : undefined, }), - [activeModule.includeTypes, activeModule.key, publishedItems, query, radiusKm], + [activeModule.includeTypes, activeModule.key, hasBackendItemsForModule, liveExtraItems, query, radiusKm], ); const allModuleResults = useMemo( @@ -417,9 +723,10 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) { radiusKm: 10, includeTypes: activeModule.includeTypes, moduleKey: activeModule.key, - extraItems: publishedItems, + extraItems: liveExtraItems, + baseItems: hasBackendItemsForModule ? [] : undefined, }), - [activeModule.includeTypes, activeModule.key, publishedItems], + [activeModule.includeTypes, activeModule.key, hasBackendItemsForModule, liveExtraItems], ); const smartDraft = useMemo(() => createSmartDraft(smartInput), [smartInput]); @@ -429,6 +736,43 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) { const actionSet = getActionSet(activeModule.key); const mapItems = (results.length ? results : allModuleResults).slice(0, 8); + const requestCurrentLocation = useCallback(() => { + setIsResolvingLocation(true); + setLocationStatus('Mengambil lokasi browser untuk pencarian radius GeoSeek...'); + + if (typeof navigator === 'undefined' || !navigator.geolocation) { + setResolvedLocation(backendDemoGeoSeekLocation); + setLocationStatus('Browser tidak mendukung GPS. GeoSeek memakai lokasi demo backend Pekanbaru.'); + setIsResolvingLocation(false); + return; + } + + navigator.geolocation.getCurrentPosition( + (position) => { + setResolvedLocation({ + label: 'Lokasi browser Anda', + latitude: position.coords.latitude, + longitude: position.coords.longitude, + source: 'browser', + accuracyMeters: Math.round(position.coords.accuracy), + }); + setLocationStatus('GPS aktif. Hasil GeoSeek dihitung dari lokasi browser Anda.'); + setIsResolvingLocation(false); + }, + (error) => { + console.warn('GeoSeek tidak mendapat izin/lokasi browser, memakai fallback demo backend:', error); + setResolvedLocation(backendDemoGeoSeekLocation); + setLocationStatus('GPS belum diizinkan. GeoSeek memakai lokasi demo backend Pekanbaru agar data nyata tetap tampil.'); + setIsResolvingLocation(false); + }, + { + enableHighAccuracy: true, + maximumAge: 60000, + timeout: 8000, + }, + ); + }, []); + const recordAction = (message: string, label = activeModule.menuLabel) => { setActionStatus(message); setActionHistory((previousHistory) => [ @@ -444,7 +788,7 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) { }; const publishSmartDraft = () => { - const newItem = createPublishedItem(smartInput, smartDraft, activeModule.key); + const newItem = createPublishedItem(smartInput, smartDraft, activeModule.key, resolvedLocation); setPublishedItems((previousItems) => [ newItem, @@ -501,10 +845,72 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) { } }, [actionHistory, isLocalStateReady, publishedItems]); + useEffect(() => { + requestCurrentLocation(); + }, [requestCurrentLocation]); + + useEffect(() => { + let isActive = true; + + const timeoutId = window.setTimeout(async () => { + setIsLoadingPlaces(true); + setApiError(''); + + try { + const apiQuery = getApiQuery(query, activeModule.key); + const response = await axios.get('/public/places', { + params: { + q: apiQuery, + lat: resolvedLocation.latitude, + lng: resolvedLocation.longitude, + radiusKm, + limit: 36, + }, + }); + + if (!isActive) return; + + const rows = Array.isArray(response.data?.rows) ? response.data.rows : []; + const externalSourceErrors = Array.isArray(response.data?.external_source_errors) + ? response.data.external_source_errors + : []; + const radiusZoneParts = [response.data?.radius_zone?.label, response.data?.radius_zone?.range].filter(Boolean); + + setApiItems(normalizePublicPlaceItems(rows)); + setApiMeta({ + count: Number(response.data?.count || rows.length), + totalCandidates: Number(response.data?.total_candidates || rows.length), + radiusZoneLabel: radiusZoneParts.join(' • ') || 'Radius aktif', + externalSources: Array.isArray(response.data?.external_sources) ? response.data.external_sources : [], + externalSourceErrors, + filteredByRadius: Boolean(response.data?.filtered_by_radius), + expandedForNearest: Boolean(response.data?.expanded_for_nearest), + }); + setApiError(externalSourceErrors.length ? externalSourceErrors.join(' ') : ''); + } catch (error) { + if (!isActive) return; + + console.error('Gagal memuat data backend GeoSeek Pro:', error); + setApiItems([]); + setApiMeta(emptyApiMeta); + setApiError('Data backend GeoSeek belum bisa dimuat. UI memakai fallback demo lokal.'); + } finally { + if (isActive) { + setIsLoadingPlaces(false); + } + } + }, 350); + + return () => { + isActive = false; + window.clearTimeout(timeoutId); + }; + }, [activeModule.key, query, radiusKm, resolvedLocation.latitude, resolvedLocation.longitude, searchRequestVersion]); + useEffect(() => { setQuery(getDefaultQuery(activeModule.key)); setRadiusKm(3); - setActionStatus(`Menu ${activeModule.menuLabel} siap. Data demo otomatis dimuat dan diurutkan dengan GeoScore.`); + setActionStatus(`Menu ${activeModule.menuLabel} siap. GeoSeek memuat data backend nyata dan memakai fallback demo jika data sekitar belum tersedia.`); }, [activeModule.key, activeModule.menuLabel]); const profileName = [currentUser?.firstName, currentUser?.lastName].filter(Boolean).join(' ') || currentUser?.email || 'Pengguna GeoSeek'; @@ -527,8 +933,8 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) {

GeoSeek Pro Automation

{activeModule.subtitle}

- Lokasi demo: {defaultGeoSeekLocation.label}. Semua menu kini punya fungsi awal: pencarian radius, GeoScore otomatis, - draft input pintar, simulasi peta, marketplace, booking, kurir, dan insight bisnis. + Lokasi aktif: {resolvedLocation.label}. GeoSeek kini mengambil data nyata dari backend `/public/places`, + memakai GPS browser bila diizinkan, dan tetap punya fallback demo agar pencarian radius, produk, jasa, UMKM, peta, booking, kurir, dan insight bisnis selalu bisa dicoba.

{quickPrompts.slice(0, 4).map((prompt) => ( @@ -550,16 +956,19 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) {

{profileName}

- Radius aktif {radiusKm} km dari {defaultGeoSeekLocation.label} + Radius aktif {radiusKm} km dari {resolvedLocation.label} +

{resolvedLocation.source === 'browser' ? 'Sumber: GPS browser' : 'Sumber: fallback backend demo'}{resolvedLocation.accuracyMeters ? ` • akurasi ±${resolvedLocation.accuracyMeters} m` : ''}

- {results.length} hasil masuk radius dan modul {activeModule.menuLabel} + {results.length} hasil untuk modul {activeModule.menuLabel} +

{apiItems.length ? `${apiItems.length} item backend dinormalisasi` : 'Fallback demo lokal aktif'}

- Draft lokal tersimpan {publishedItems.length} item + Kandidat backend {apiMeta.totalCandidates} • draft lokal {publishedItems.length} +

{apiMeta.radiusZoneLabel}

- Status: {actionStatus} + Status: {isLoadingPlaces ? 'Memuat data backend GeoSeek...' : actionStatus}
@@ -587,7 +996,7 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) { })}
-
+
+
+
+
+
+ Lokasi: {locationStatus} +
+
+ Data: {isLoadingPlaces ? 'memuat backend...' : apiItems.length ? `${apiItems.length} item backend aktif` : 'fallback demo aktif'} +
+
+ Zona: {apiMeta.radiusZoneLabel} +
+
+ {(apiError || apiMeta.externalSources.length > 0 || apiMeta.expandedForNearest) && ( +
+ {apiError || `Sumber eksternal aktif: ${apiMeta.externalSources.join(', ')}`} + {apiMeta.expandedForNearest ? ' Radius diperluas otomatis untuk mencari hasil terdekat.' : ''} +
+ )}
@@ -626,7 +1065,7 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) { - +
@@ -769,7 +1208,7 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) {
-
+
- {locationStatus || 'Lokasi belum aktif'} • Kategori dikosongkan •{' '} - {hasActiveLocation ? 'Live search aktif' : 'Menunggu izin lokasi'} + {locationStatus || 'Lokasi belum aktif'} • Kategori + dikosongkan •{' '} + {hasActiveLocation + ? 'Live search aktif' + : 'Menunggu izin lokasi'}
-
+
{radiusZones.map((zone) => (
-
- Kategori pencarian dikosongkan otomatis — hasil ditentukan dari kata kunci dan - jarak. +
+ Kategori pencarian dikosongkan otomatis — hasil ditentukan + dari kata kunci dan jarak.
-
-
-
-
+
+
+
+
-

+

Live Nearby Map

-

{selectedZone.label}

+

+ {selectedZone.label} +

- + {location?.label || 'Lokasi saya belum aktif'}
-
-
-
-
-
-
+
+
+
+
+
+
{featuredPlaces.map((place, index) => ( - {place.name} - - {place.distance_km !== null && place.distance_km !== undefined + + {place.name} + + + {place.distance_km !== null && + place.distance_km !== undefined ? `${place.distance_km} km` : 'Lihat detail'} - - GeoScore {place.geo_score || place.search_score || '-'} + + GeoScore{' '} + {place.geo_score || place.search_score || '-'} ))}
-
- - - +
+ + +
@@ -1185,47 +1298,54 @@ export default function Starter() {
-
-
+
+
-

+

Live Search Results

-

+

Konten paling dekat, relevan, dan berguna.

-

- Lokasi: {location?.label || 'Belum aktif — izinkan lokasi browser'} +

+ Lokasi:{' '} + + {location?.label || 'Belum aktif — izinkan lokasi browser'} + . Radius aktif:{' '} {selectedZone.range} · {selectedZone.label} - . Ditemukan {searchMeta.count} hasil dari {searchMeta.total_candidates} kandidat - terindeks. + . Ditemukan {searchMeta.count} hasil dari{' '} + {searchMeta.total_candidates} kandidat terindeks. {searchMeta.expanded_for_nearest ? ' Radius awal kosong, jadi hasil diperluas ke kandidat terdekat dan tetap diurutkan berdasarkan jarak.' : ''}

{searchMeta.distance_buckets.length ? ( -
-
- Search - by Distance +
+
+ {' '} + Search by Distance
-
+
{searchMeta.distance_buckets.map((bucket) => (
) : loading ? ( -
+
{[0, 1, 2].map((item) => ( -
+
))}
) : places.length ? ( -
+
{places.map((place) => (
-
+
{place.is_verified ? ( - - Verified + + {' '} + Verified ) : null}
-
+
-

{place.name}

-

- {place.distance_km !== null && place.distance_km !== undefined +

+ {place.name} +

+

+ {place.distance_km !== null && + place.distance_km !== undefined ? `Jarak ${place.distance_km} km dari lokasi saya` : place.radius_zone?.label || selectedZone.label}

-
-

GeoScore

-

+

+

+ GeoScore +

+

{place.geo_score || place.search_score || '-'}

-

+

{place.short_description || 'Detail tempat akan tampil di sini setelah admin melengkapinya.'}

-
+
{place.geo_score_breakdown ? ( -
-

+

+

Breakdown GeoScore

{( @@ -1355,49 +1486,62 @@ export default function Starter() { number, ][] ).map(([key, value]) => ( - + ))}
) : null} {place.offerings_summary?.top_available?.length ? ( -
-

+

+

Produk / jasa tersedia

-
- {place.offerings_summary.top_available.map((offering) => ( - - {offering.name} · {offering.stock_label} - - ))} +
+ {place.offerings_summary.top_available.map( + (offering) => ( + + {offering.name} · {offering.stock_label} + + ), + )}
) : null} -
-
+
+
{' '} {place.ai_recommendation?.label || 'AI Recommendation'}
-

+

{place.ai_recommendation?.reason || 'Rekomendasi dibuat dari sinyal lokal dan GeoScore.'}

-

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

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

-
+
{place.external_source === 'openstreetmap' ? 'Buka rute di Maps' @@ -1409,439 +1553,67 @@ export default function Starter() { ))}
) : ( -
-

Belum ada hasil dalam radius ini

-

- Perbesar radius ke City/Regional Zone, coba kata kunci seperti “cafe melayu”, - “servis oli”, “kitchen set”, atau minta admin menambahkan listing baru. +

+

+ Belum ada hasil dalam radius ini +

+

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

-
- +
+
)}
-
-
-
-

- Keunggulan Utama -

-

- Navigasi hyperlocal yang cepat, dinamis, dan berbasis pengguna. -

-

- GeoSeek dibentuk sebagai aplikasi navigasi hyperlocal: pengguna mencari kebutuhan - sekitar, melihat hasil live berdasarkan radius, lalu langsung memilih rute, - WhatsApp, booking, atau kurir lokal dari satu layar. -

-
-
-
- Live - hasil berubah mengikuti query dan radius -
-
- GeoScore - ranking dari jarak, relevansi, reputasi -
-
- - {mainAdvantages.length}x - - fitur inti untuk mobilitas lokal -
-
-
- -
- {mainAdvantages.map((advantage) => ( -
-
-
- -
-
- - {advantage.metric} - - - {advantage.metricLabel} - -
-
-

{advantage.title}

-

- {advantage.description} -

-
- {advantage.bullets.map((bullet) => ( - - {bullet} - - ))} -
-
- ))} -
- -
-
-

- Siap dipakai sebagai aplikasi -

-

- Mulai dari box cari, radius lokasi, hasil live, sampai aksi lokal. -

-

- Alur pengguna dibuat ringkas: cari kebutuhan → pilih hasil terdekat → buka - rute/kontak/booking → simpan aktivitas lokal. -

-
-
- - Coba Live Search - - - Buka GeoSeek Pro - -
-
-
- -
-
-
-
-

- GeoSeek vs Google Maps -

-

- Bukan hanya mencari lokasi, tapi menemukan produk, jasa, dan transaksi lokal - terdekat. -

-

- Google Maps kuat untuk peta, navigasi, dan informasi bisnis. GeoSeek melengkapinya - sebagai mesin pencari hyperlocal yang memprioritaskan jarak nyata, katalog produk, - jasa terverifikasi, dan aksi transaksi dalam satu ekosistem lokal. -

- -
-
- RT/RW - - cakupan pencarian lingkungan mikro - -
-
- Produk + Jasa - - dengan harga, stok, jadwal, dan status tersedia - -
-
- -
-

- Peran dalam aplikasi -

-
- {comparisonMatrix.map((row) => ( -
-

{row.label}

-
-
- - Maps - -

{row.maps}

-
-
- - GeoSeek - -

{row.geoseek}

-
-
-
- ))} -
-
-
- -
- {comparisonAdvantages.map((advantage, index) => ( -
-
- -
-

{advantage.title}

-

- {advantage.description} -

-
- {advantage.points.map((point) => ( - - {point} - - ))} -
-
- ))} -
-
- -
-
-
-

- Ekosistem GeoSeek -

-

- Dari pencarian RT/RW sampai transaksi dan dashboard bisnis. -

-

- Alur aplikasi dibuat untuk kebutuhan nyata warga dan UMKM: cari kebutuhan - terdekat, lihat data produk/jasa yang tersedia, lakukan aksi transaksi, lalu - pemilik usaha mendapat insight performa lokal. -

-
- {dashboardMetrics.map((item) => ( -
- - {item.metric} - - - {item.label} - -
- ))} -
-
- -
- {ecosystemSteps.map((step) => ( -
-
-
- -
-
-

{step.title}

-

- {step.description} -

-
-
-
- {step.tags.map((tag) => ( - - {tag} - - ))} -
-
- ))} -
-
-
-
-
- -
-
-
-
-

- Otomatisasi & Input Cerdas -

-

- Input sekali, GeoSeek mengubahnya menjadi data lokal yang bisa dicari, - diverifikasi, dan ditransaksikan. -

-

- Google Maps kuat untuk peta dan Waze kuat untuk laporan jalan. GeoSeek naik level - dengan input otomatis untuk lalu lintas, UMKM, stok produk, jasa, booking, dan - transaksi lokal dalam satu ekosistem hyperlocal. -

- -
-
- 1x - - input cepat dari warga atau pemilik usaha - -
-
- Auto - - kategori, tag, radius, dan status tersedia - -
-
- Live - - draft siap validasi komunitas dan dashboard bisnis - -
-
-
- -
-
-
-

- Demo Input Pintar -

-

Coba alur otomatisasi GeoSeek

-
-
- -
-
- -
- {smartInputOptions.map((option) => ( - - ))} -
- -

- {selectedSmartInput.title} -

- -