Autosave: 20260618-085709
This commit is contained in:
parent
ecb978b657
commit
39d56bcef3
BIN
artifacts/geoseek-map-aligned.png
Normal file
BIN
artifacts/geoseek-map-aligned.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 375 KiB |
@ -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') -
|
||||
|
||||
@ -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<Record<GeoSeekModuleKey, string>> = {
|
||||
home: 'produk jasa umkm lokal',
|
||||
search: 'produk jasa umkm lokal',
|
||||
@ -612,7 +615,7 @@ const ResultCard = ({
|
||||
GeoScore {item.geoScore}
|
||||
</span>
|
||||
<span className="rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-700 dark:bg-amber-900/40 dark:text-amber-200">
|
||||
{item.distanceKm.toFixed(1)} km
|
||||
{item.distanceKm.toFixed(1)} km • {item.distancePriority.label}
|
||||
</span>
|
||||
{item.open && (
|
||||
<span className="rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-700 dark:bg-green-900/40 dark:text-green-200">
|
||||
@ -654,6 +657,7 @@ const ResultCard = ({
|
||||
<div className="w-full rounded-2xl bg-slate-50 p-4 dark:bg-dark-800 lg:w-72">
|
||||
<p className="text-sm font-bold text-gray-900 dark:text-white">Rincian GeoScore</p>
|
||||
<div className="mt-3 space-y-2 text-xs text-gray-600 dark:text-gray-300">
|
||||
<div className="flex justify-between gap-3"><span>Prioritas jarak</span><span className="text-right">{item.distancePriority.label}</span></div>
|
||||
<div className="flex justify-between"><span>Jarak 60%</span><span>{item.distanceScore}</span></div>
|
||||
<div className="flex justify-between"><span>Relevansi 20%</span><span>{item.relevanceScore}</span></div>
|
||||
<div className="flex justify-between"><span>Rating 10%</span><span>{item.ratingScore}</span></div>
|
||||
@ -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<GeoSeekItem[]>([]);
|
||||
@ -697,6 +701,7 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) {
|
||||
const [apiMeta, setApiMeta] = useState<GeoSeekApiMeta>(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) {
|
||||
<h3 className="mt-2 text-2xl font-bold text-gray-900 dark:text-white">{profileName}</h3>
|
||||
<div className="mt-4 space-y-3 text-sm text-gray-600 dark:text-gray-300">
|
||||
<div className="rounded-2xl bg-blue-50 p-4 dark:bg-blue-900/20">
|
||||
Radius aktif <strong>{radiusKm} km</strong> dari {resolvedLocation.label}
|
||||
Radius aktif <strong>{radiusKm} km</strong> ({activeDistancePriority.label}) dari {resolvedLocation.label}
|
||||
<p className="mt-1 text-xs">{resolvedLocation.source === 'browser' ? 'Sumber: GPS browser' : 'Sumber: fallback backend demo'}{resolvedLocation.accuracyMeters ? ` • akurasi ±${resolvedLocation.accuracyMeters} m` : ''}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-emerald-50 p-4 dark:bg-emerald-900/20">
|
||||
@ -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) => (
|
||||
<option key={radius} value={radius}>{radius} km</option>
|
||||
))}
|
||||
{radiusOptions.map((radius) => {
|
||||
const priority = getDistancePriority(radius);
|
||||
|
||||
return (
|
||||
<option key={radius} value={radius}>
|
||||
{radius} km — {priority.range} • {priority.label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</label>
|
||||
<div className="flex items-end">
|
||||
@ -1061,7 +1072,7 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) {
|
||||
</CardBox>
|
||||
|
||||
<div className="mb-6 grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||
<StatCard label="Hasil Prioritas" value={results.length} help="Masuk radius dan relevansi" iconPath={icon.mdiMagnify} />
|
||||
<StatCard label="Hasil Prioritas" value={results.length} help="Diurutkan dari jarak terdekat" iconPath={icon.mdiMagnify} />
|
||||
<StatCard label="Rata-rata GeoScore" value={insight.averageGeoScore} help="Dari modul aktif" iconPath={icon.mdiChartTimelineVariant} />
|
||||
<StatCard label="Promo Aktif" value={insight.promos} help="Siap disebar otomatis" iconPath={icon.mdiTicketPercentOutline} />
|
||||
<StatCard label="Booking/Kurir" value={insight.bookingReady + insight.deliveryReady} help="Aksi cepat tersedia" iconPath={icon.mdiCalendar} />
|
||||
@ -1317,7 +1328,7 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) {
|
||||
<BaseIcon path={icon.mdiMagnify} size={42} className="mx-auto text-gray-400" />
|
||||
<h3 className="mt-4 text-xl font-bold text-gray-900 dark:text-white">Tidak ada hasil di radius ini</h3>
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">Coba ubah kata kunci, izinkan GPS, atau perluas radius pencarian.</p>
|
||||
<BaseButton color="info" label="Perluas ke 10 km" className="mt-4" onClick={() => setRadiusKm(10)} />
|
||||
<BaseButton color="info" label="Perluas ke 20 km" className="mt-4" onClick={() => setRadiusKm(20)} />
|
||||
</CardBox>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -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<GeoSeekDistancePriority & { maxKm: number }> = [
|
||||
{
|
||||
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) => {
|
||||
|
||||
@ -1314,7 +1314,7 @@ export default function Starter() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='relative'>
|
||||
<div className='relative lg:self-end lg:mb-[102px]'>
|
||||
<div className='absolute -inset-4 rounded-[2.5rem] bg-gradient-to-br from-[#F2A541]/40 via-white/5 to-[#2CA58D]/30 blur-xl' />
|
||||
<div className='relative rounded-[2rem] border border-white/15 bg-[#FDF8EE] p-5 text-[#17231B] shadow-2xl'>
|
||||
<div className='mb-4 flex items-center justify-between gap-4'>
|
||||
|
||||
@ -124,7 +124,7 @@ const priceLabels: Record<string, string> = {
|
||||
|
||||
const scoreLabels: Record<keyof GeoScoreBreakdown, string> = {
|
||||
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() {
|
||||
|
||||
<div className='p-8'>
|
||||
<div className='grid gap-4 md:grid-cols-4'>
|
||||
<InfoCard label='Jarak' value={place.distance_km !== null && place.distance_km !== undefined ? `${place.distance_km} km` : 'Aktifkan lokasi'} icon={mdiCrosshairsGps} />
|
||||
<InfoCard label='Prioritas jarak' value={place.distance_km !== null && place.distance_km !== undefined ? `${place.distance_km} km · ${place.radius_zone?.label || 'Jarak aktif'}` : 'Aktifkan lokasi'} icon={mdiCrosshairsGps} />
|
||||
<InfoCard label='Rating' value={`${place.rating_average || '-'} (${place.rating_count || 0})`} icon={mdiStar} />
|
||||
<InfoCard label='Live' value={`${place.live_status?.label || '-'} · ${place.live_status?.crowd || '-'}`} icon={mdiClockOutline} />
|
||||
<InfoCard label='Estimasi' value={formatRupiah(place.average_price)} icon={mdiCash} />
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user