diff --git a/artifacts/geoseek-map-aligned.png b/artifacts/geoseek-map-aligned.png new file mode 100644 index 0000000..a43d8da Binary files /dev/null and b/artifacts/geoseek-map-aligned.png differ diff --git a/backend/src/routes/publicPlaces.js b/backend/src/routes/publicPlaces.js index a2bbfa2..48b8e87 100644 --- a/backend/src/routes/publicPlaces.js +++ b/backend/src/routes/publicPlaces.js @@ -18,54 +18,59 @@ const OSM_PROFILE_MATCH_LIMIT = 6; const MAX_OSM_EXPANSION_RADIUS_KM = 150; const GEO_SCORE_FORMULA = { - relevance: 40, - distance: 25, - reputation: 15, - activity: 10, - interaction: 10, + distance: 60, + relevance: 20, + reputation: 10, + activity: 5, + interaction: 5, }; const RADIUS_ZONES = [ { value: 1, - label: 'Walking Zone', - range: '0–1 Km', - description: 'Paling cocok untuk jalan kaki dan kebutuhan sangat dekat.', + label: 'Sangat dekat (jalan kaki)', + range: '0–1 km', + description: 'Prioritas tertinggi untuk hasil yang bisa ditempuh dengan jalan kaki.', }, { value: 5, - label: 'Neighborhood Zone', - range: '1–5 Km', - description: - 'Default GeoSeek: sekitar rumah, kantor, dan lingkungan sekitar.', + label: 'Dekat', + range: '1–5 km', + description: 'Masih dekat dari lokasi aktif untuk kebutuhan harian sekitar.', }, { - value: 25, - label: 'City Zone', - range: '5–25 Km', - description: 'Menjangkau satu kota untuk pilihan yang lebih banyak.', + value: 10, + label: 'Agak dekat', + range: '5–10 km', + description: 'Masih mudah dijangkau, biasanya perlu kendaraan singkat.', + }, + { + value: 20, + label: 'Sedang', + range: '10–20 km', + description: 'Jarak menengah dan ditampilkan setelah hasil yang lebih dekat.', }, { value: 100, - label: 'Regional Zone', - range: '25–100 Km', - description: 'Area regional atau kabupaten/kota sekitar.', + label: 'Jauh', + range: '20–100 km', + description: 'Di luar prioritas jarak utama, digunakan saat radius diperluas.', }, { value: 500, - label: 'Provincial Zone', - range: '100–500 Km', + label: 'Sangat jauh', + range: '100–500 km', description: 'Skala provinsi untuk pencarian yang lebih luas.', }, { value: GLOBAL_RADIUS_KM, - label: 'Global Zone', - range: '500+ Km', + label: 'Nasional/Global', + range: '500+ km', description: 'Skala nasional/global ketika lokasi lokal tidak cukup.', }, ]; -const DISTANCE_BUCKETS = [0.5, 1, 3, 5, 25, 100, 500, GLOBAL_RADIUS_KM]; +const DISTANCE_BUCKETS = [1, 5, 10, 20, 100, 500, GLOBAL_RADIUS_KM]; const STOCK_STATUS_LABELS = { in_stock: 'Tersedia', @@ -996,6 +1001,21 @@ const compareDistance = (a, b) => { return 0; }; +const getDistancePriorityRank = (distanceKm) => { + if (distanceKm === null || distanceKm === undefined) return Number.MAX_SAFE_INTEGER; + if (distanceKm <= 1) return 0; + if (distanceKm <= 5) return 1; + if (distanceKm <= 10) return 2; + if (distanceKm <= 20) return 3; + return 4; +}; + +const compareDistancePriority = (a, b) => { + const rankDiff = getDistancePriorityRank(a.distance_km) - getDistancePriorityRank(b.distance_km); + if (rankDiff !== 0) return rankDiff; + + return compareDistance(a, b); +}; const dedupePlacesById = (places) => { const placeMap = new Map(); @@ -1039,6 +1059,9 @@ const compareAveragePrice = (a, b) => { }; const sortScoredPlaces = (a, b, hasQuery, intent = {}) => { + const distancePriorityDiff = compareDistancePriority(a.place, b.place); + if (distancePriorityDiff !== 0) return distancePriorityDiff; + if (intent.openNow || intent.twentyFourHour) { const openDiff = Number(b.place.live_status?.status === 'open') - diff --git a/frontend/src/components/GeoSeek/GeoSeekProWorkspace.tsx b/frontend/src/components/GeoSeek/GeoSeekProWorkspace.tsx index 6348785..e65e7cb 100644 --- a/frontend/src/components/GeoSeek/GeoSeekProWorkspace.tsx +++ b/frontend/src/components/GeoSeek/GeoSeekProWorkspace.tsx @@ -25,6 +25,7 @@ import { filterGeoSeekItems, getBusinessInsights, getCheckoutSummary, + getDistancePriority, GeoSeekScoredItem, GeoSeekSmartDraft, } from '../../data/geoseekAutomation'; @@ -163,6 +164,8 @@ const emptyApiMeta: GeoSeekApiMeta = { expandedForNearest: false, }; +const radiusOptions = [1, 5, 10, 20]; + const apiQueryByModule: Partial> = { home: 'produk jasa umkm lokal', search: 'produk jasa umkm lokal', @@ -612,7 +615,7 @@ const ResultCard = ({ GeoScore {item.geoScore} - {item.distanceKm.toFixed(1)} km + {item.distanceKm.toFixed(1)} km • {item.distancePriority.label} {item.open && ( @@ -654,6 +657,7 @@ const ResultCard = ({

Rincian GeoScore

+
Prioritas jarak{item.distancePriority.label}
Jarak 60%{item.distanceScore}
Relevansi 20%{item.relevanceScore}
Rating 10%{item.ratingScore}
@@ -682,7 +686,7 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) { const normalizedModuleKey = getModule(moduleKey).key; const activeModule = getModule(normalizedModuleKey); const [query, setQuery] = useState(getDefaultQuery(activeModule.key)); - const [radiusKm, setRadiusKm] = useState(3); + const [radiusKm, setRadiusKm] = useState(5); const [smartInput, setSmartInput] = useState(sampleSmartInputs[0]); const [actionStatus, setActionStatus] = useState('Sistem otomasi siap digunakan. Pilih aksi cepat atau jalankan Smart Input.'); const [publishedItems, setPublishedItems] = useState([]); @@ -697,6 +701,7 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) { const [apiMeta, setApiMeta] = useState(emptyApiMeta); const [searchRequestVersion, setSearchRequestVersion] = useState(0); const { currentUser } = useAppSelector((state) => state.auth); + const activeDistancePriority = useMemo(() => getDistancePriority(radiusKm), [radiusKm]); const hasBackendItemsForModule = useMemo( () => apiItems.some((item) => activeModule.includeTypes.includes(item.type)), @@ -720,7 +725,7 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) { const allModuleResults = useMemo( () => filterGeoSeekItems({ query: '', - radiusKm: 10, + radiusKm: 20, includeTypes: activeModule.includeTypes, moduleKey: activeModule.key, extraItems: liveExtraItems, @@ -909,7 +914,7 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) { useEffect(() => { setQuery(getDefaultQuery(activeModule.key)); - setRadiusKm(3); + setRadiusKm(5); setActionStatus(`Menu ${activeModule.menuLabel} siap. GeoSeek memuat data backend nyata dan memakai fallback demo jika data sekitar belum tersedia.`); }, [activeModule.key, activeModule.menuLabel]); @@ -956,7 +961,7 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) {

{profileName}

- Radius aktif {radiusKm} km dari {resolvedLocation.label} + Radius aktif {radiusKm} km ({activeDistancePriority.label}) dari {resolvedLocation.label}

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

@@ -1013,9 +1018,15 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) { onChange={(event) => setRadiusKm(Number(event.target.value))} className="h-12 w-full rounded-2xl border border-gray-200 bg-white px-4 text-gray-900 outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-200 dark:border-dark-700 dark:bg-dark-800 dark:text-white" > - {[1, 2, 3, 5, 10].map((radius) => ( - - ))} + {radiusOptions.map((radius) => { + const priority = getDistancePriority(radius); + + return ( + + ); + })}
@@ -1061,7 +1072,7 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) {
- + @@ -1317,7 +1328,7 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) {

Tidak ada hasil di radius ini

Coba ubah kata kunci, izinkan GPS, atau perluas radius pencarian.

- setRadiusKm(10)} /> + setRadiusKm(20)} /> )}
diff --git a/frontend/src/data/geoseekAutomation.ts b/frontend/src/data/geoseekAutomation.ts index b18f8b5..652f091 100644 --- a/frontend/src/data/geoseekAutomation.ts +++ b/frontend/src/data/geoseekAutomation.ts @@ -1,9 +1,17 @@ import { GeoSeekItem, GeoSeekItemType, geoSeekItems } from './geoseek'; +export type GeoSeekDistancePriority = { + rank: number; + label: string; + range: string; + description: string; +}; + export type GeoSeekScoredItem = GeoSeekItem & { geoScore: number; relevanceScore: number; distanceScore: number; + distancePriority: GeoSeekDistancePriority; ratingScore: number; activityContribution: number; automationNotes: string[]; @@ -32,6 +40,53 @@ export type GeoSeekCartItem = { const normalize = (value: string) => value.toLowerCase().trim(); +const distancePriorityTiers: Array = [ + { + rank: 0, + maxKm: 1, + label: 'Sangat dekat (jalan kaki)', + range: '0–1 km', + description: 'Prioritas tertinggi untuk kebutuhan yang bisa ditempuh dengan jalan kaki.', + }, + { + rank: 1, + maxKm: 5, + label: 'Dekat', + range: '1–5 km', + description: 'Masih dekat dari lokasi aktif dan cocok untuk kebutuhan harian sekitar.', + }, + { + rank: 2, + maxKm: 10, + label: 'Agak dekat', + range: '5–10 km', + description: 'Masih mudah dijangkau, biasanya perlu kendaraan singkat.', + }, + { + rank: 3, + maxKm: 20, + label: 'Sedang', + range: '10–20 km', + description: 'Jarak menengah; ditampilkan setelah hasil yang lebih dekat.', + }, + { + rank: 4, + maxKm: Number.POSITIVE_INFINITY, + label: 'Jauh', + range: '20+ km', + description: 'Di luar prioritas jarak utama dan hanya muncul jika radius diperluas.', + }, +]; + +export const getDistancePriority = (distanceKm?: number | null): GeoSeekDistancePriority => { + const normalizedDistance = + typeof distanceKm === 'number' && Number.isFinite(distanceKm) + ? Math.max(distanceKm, 0) + : Number.POSITIVE_INFINITY; + + return distancePriorityTiers.find((tier) => normalizedDistance <= tier.maxKm) || distancePriorityTiers[distancePriorityTiers.length - 1]; +}; + export const currency = (value?: number) => { if (typeof value !== 'number') return 'Hubungi penjual'; @@ -72,7 +127,7 @@ export const calculateRelevanceScore = (item: GeoSeekItem, query: string) => { }; export const calculateGeoScore = (item: GeoSeekItem, query: string) => { - const maxRadius = 10; + const maxRadius = 20; const distanceScore = Math.max(0, Math.round((1 - Math.min(item.distanceKm, maxRadius) / maxRadius) * 100)); const relevanceScore = calculateRelevanceScore(item, query); const ratingScore = Math.round((item.rating / 5) * 100); @@ -132,6 +187,7 @@ export const filterGeoSeekItems = ({ .map((item) => ({ ...item, ...calculateGeoScore(item, normalizedQuery), + distancePriority: getDistancePriority(item.distanceKm), automationNotes: getAutomationNotes(item, moduleKey), })) .filter((item) => { @@ -149,7 +205,18 @@ export const filterGeoSeekItems = ({ return normalizedQuery.split(/\s+/).some((word) => searchable.includes(word)); }) - .sort((a, b) => b.geoScore - a.geoScore); + .sort((a, b) => { + const priorityDiff = a.distancePriority.rank - b.distancePriority.rank; + if (priorityDiff !== 0) return priorityDiff; + + const distanceDiff = a.distanceKm - b.distanceKm; + if (Math.abs(distanceDiff) > 0.05) return distanceDiff; + + const geoScoreDiff = b.geoScore - a.geoScore; + if (geoScoreDiff !== 0) return geoScoreDiff; + + return b.relevanceScore - a.relevanceScore; + }); }; const getFirstCurrencyValue = (text: string) => { diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index ce55cdb..4ee9b9a 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -1314,7 +1314,7 @@ export default function Starter() {
-
+
diff --git a/frontend/src/pages/tempat/[placeId].tsx b/frontend/src/pages/tempat/[placeId].tsx index dbc365f..49ba6c3 100644 --- a/frontend/src/pages/tempat/[placeId].tsx +++ b/frontend/src/pages/tempat/[placeId].tsx @@ -124,7 +124,7 @@ const priceLabels: Record = { const scoreLabels: Record = { relevance: 'Relevansi kata kunci', - distance: 'Jarak', + distance: 'Jarak / prioritas jarak', reputation: 'Rating/reputasi', activity: 'Aktivitas terbaru', interaction: 'Interaksi pengguna', @@ -157,7 +157,7 @@ export default function PlaceDetailPage() { const queryText = Array.isArray(q) ? q[0] : q const topOfferings = place?.offerings_summary?.top_available || [] - const formulaEntries = Object.entries(place?.geo_score_formula || { relevance: 40, distance: 25, reputation: 15, activity: 10, interaction: 10 }) + const formulaEntries = Object.entries(place?.geo_score_formula || { distance: 60, relevance: 20, reputation: 10, activity: 5, interaction: 5 }) useEffect(() => { if (!placeId || Array.isArray(placeId)) return @@ -255,7 +255,7 @@ export default function PlaceDetailPage() {
- +