Autosave: 20260618-085709

This commit is contained in:
Flatlogic Bot 2026-06-18 08:57:04 +00:00
parent ecb978b657
commit 39d56bcef3
6 changed files with 141 additions and 40 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

View File

@ -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: '01 Km',
description: 'Paling cocok untuk jalan kaki dan kebutuhan sangat dekat.',
label: 'Sangat dekat (jalan kaki)',
range: '01 km',
description: 'Prioritas tertinggi untuk hasil yang bisa ditempuh dengan jalan kaki.',
},
{
value: 5,
label: 'Neighborhood Zone',
range: '15 Km',
description:
'Default GeoSeek: sekitar rumah, kantor, dan lingkungan sekitar.',
label: 'Dekat',
range: '15 km',
description: 'Masih dekat dari lokasi aktif untuk kebutuhan harian sekitar.',
},
{
value: 25,
label: 'City Zone',
range: '525 Km',
description: 'Menjangkau satu kota untuk pilihan yang lebih banyak.',
value: 10,
label: 'Agak dekat',
range: '510 km',
description: 'Masih mudah dijangkau, biasanya perlu kendaraan singkat.',
},
{
value: 20,
label: 'Sedang',
range: '1020 km',
description: 'Jarak menengah dan ditampilkan setelah hasil yang lebih dekat.',
},
{
value: 100,
label: 'Regional Zone',
range: '25100 Km',
description: 'Area regional atau kabupaten/kota sekitar.',
label: 'Jauh',
range: '20100 km',
description: 'Di luar prioritas jarak utama, digunakan saat radius diperluas.',
},
{
value: 500,
label: 'Provincial Zone',
range: '100500 Km',
label: 'Sangat jauh',
range: '100500 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') -

View File

@ -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>

View File

@ -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: '01 km',
description: 'Prioritas tertinggi untuk kebutuhan yang bisa ditempuh dengan jalan kaki.',
},
{
rank: 1,
maxKm: 5,
label: 'Dekat',
range: '15 km',
description: 'Masih dekat dari lokasi aktif dan cocok untuk kebutuhan harian sekitar.',
},
{
rank: 2,
maxKm: 10,
label: 'Agak dekat',
range: '510 km',
description: 'Masih mudah dijangkau, biasanya perlu kendaraan singkat.',
},
{
rank: 3,
maxKm: 20,
label: 'Sedang',
range: '1020 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) => {

View File

@ -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'>

View File

@ -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} />