GEOSEEK 2.0

This commit is contained in:
Flatlogic Bot 2026-06-20 00:48:54 +00:00
parent 1c09ee751e
commit 5d3fe9d7a2
11 changed files with 2754 additions and 88 deletions

View File

@ -1,7 +1,6 @@
const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
const Utils = require('../utils');
@ -9,6 +8,88 @@ const Utils = require('../utils');
const Sequelize = db.Sequelize;
const Op = Sequelize.Op;
const hasOwn = (data, key) => Object.prototype.hasOwnProperty.call(data || {}, key);
const firstProvided = (data, keys) => {
for (const key of keys) {
if (hasOwn(data, key) && data[key] !== undefined) {
return data[key];
}
}
return undefined;
};
const buildPlacesPayload = (data = {}, includeDefaults = false) => {
const payload = {};
const set = (field, keys, defaultValue) => {
const value = firstProvided(data, keys);
if (value !== undefined) {
payload[field] = value;
} else if (includeDefaults) {
payload[field] = defaultValue;
}
};
set('name', ['name', 'nama'], null);
set('nama', ['nama', 'name'], null);
set('kategori', ['kategori'], null);
set('subkategori', ['subkategori'], null);
set('short_description', ['short_description'], null);
set('full_description', ['full_description'], null);
set('address', ['address', 'alamat'], null);
set('alamat', ['alamat', 'address'], null);
set('kelurahan', ['kelurahan'], null);
set('kecamatan', ['kecamatan'], null);
set('city', ['city', 'kota'], null);
set('kota', ['kota', 'city'], null);
set('province', ['province', 'provinsi'], null);
set('provinsi', ['provinsi', 'province'], null);
set('postal_code', ['postal_code', 'kode_pos'], null);
set('kode_pos', ['kode_pos', 'postal_code'], null);
set('latitude', ['latitude'], null);
set('longitude', ['longitude'], null);
set('phone_number', ['phone_number', 'telepon'], null);
set('telepon', ['telepon', 'phone_number'], null);
set('whatsapp_number', ['whatsapp_number', 'whatsapp'], null);
set('whatsapp', ['whatsapp', 'whatsapp_number'], null);
set('email', ['email'], null);
set('website_url', ['website_url', 'website'], null);
set('website', ['website', 'website_url'], null);
set('google_maps_url', ['google_maps_url'], null);
set('jam_buka', ['jam_buka'], null);
set('price_level', ['price_level'], null);
set('average_price', ['average_price'], null);
set('rating_average', ['rating_average', 'rating'], null);
set('rating', ['rating', 'rating_average'], 0);
set('rating_count', ['rating_count', 'review_count'], null);
set('review_count', ['review_count', 'rating_count'], 0);
set('status', ['status'], 'aktif');
set('is_verified', ['is_verified', 'verified'], false);
set('verified', ['verified', 'is_verified'], false);
set('featured', ['featured'], false);
set('popularitas', ['popularitas'], 0);
set('created_at', ['created_at'], undefined);
set('updated_at', ['updated_at'], undefined);
set('sumber', ['sumber', 'source'], null);
set('external_id', ['external_id', 'externalId'], null);
set('external_type', ['external_type', 'externalType'], null);
set('raw_source_data', ['raw_source_data', 'rawSourceData'], null);
set('last_synced_at', ['last_synced_at', 'lastSyncedAt'], undefined);
if (!includeDefaults) {
return payload;
}
Object.keys(payload).forEach((field) => {
if (payload[field] === undefined) {
delete payload[field];
}
});
return payload;
};
module.exports = class PlacesDBApi {
@ -122,6 +203,7 @@ module.exports = class PlacesDBApi {
,
...buildPlacesPayload(data, true),
importHash: data.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
@ -266,6 +348,7 @@ module.exports = class PlacesDBApi {
,
...buildPlacesPayload(item, true),
importHash: item.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
@ -365,6 +448,8 @@ module.exports = class PlacesDBApi {
if (data.is_verified !== undefined) updatePayload.is_verified = data.is_verified;
Object.assign(updatePayload, buildPlacesPayload(data, false));
updatePayload.updated_at = new Date();
updatePayload.updatedById = currentUser.id;
await places.update(updatePayload, {transaction});
@ -538,9 +623,6 @@ module.exports = class PlacesDBApi {
offset = currentPage * limit;
const orderBy = null;
const transaction = (options && options.transaction) || undefined;
let include = [
@ -557,7 +639,8 @@ module.exports = class PlacesDBApi {
}
},
]
} : {},
} : undefined,
required: Boolean(filter.category),
},
@ -574,7 +657,8 @@ module.exports = class PlacesDBApi {
}
},
]
} : {},
} : undefined,
required: Boolean(filter.owner),
},
@ -728,6 +812,35 @@ module.exports = class PlacesDBApi {
};
}
const indonesianTextFilters = [
'nama',
'kategori',
'subkategori',
'alamat',
'kelurahan',
'kecamatan',
'kota',
'provinsi',
'kode_pos',
'telepon',
'whatsapp',
'website',
'jam_buka',
];
for (const field of indonesianTextFilters) {
if (filter[field]) {
where = {
...where,
[Op.and]: Utils.ilike(
'places',
field,
filter[field],
),
};
}
}
@ -853,6 +966,38 @@ module.exports = class PlacesDBApi {
}
}
const applyRangeFilter = (field, range) => {
if (!range) {
return;
}
const [start, end] = range;
if (start !== undefined && start !== null && start !== '') {
where = {
...where,
[field]: {
...where[field],
[Op.gte]: start,
},
};
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
[field]: {
...where[field],
[Op.lte]: end,
},
};
}
};
applyRangeFilter('rating', filter.ratingRange);
applyRangeFilter('review_count', filter.review_countRange);
applyRangeFilter('popularitas', filter.popularitasRange);
if (filter.active !== undefined) {
where = {
@ -883,6 +1028,20 @@ module.exports = class PlacesDBApi {
};
}
if (filter.verified !== undefined) {
where = {
...where,
verified: filter.verified === true || filter.verified === 'true',
};
}
if (filter.featured !== undefined) {
where = {
...where,
featured: filter.featured === true || filter.featured === 'true',
};
}
@ -962,12 +1121,17 @@ module.exports = class PlacesDBApi {
'name',
query,
),
Utils.ilike(
'places',
'nama',
query,
),
],
};
}
const records = await db.places.findAll({
attributes: [ 'id', 'name' ],
attributes: [ 'id', 'name', 'nama' ],
where,
limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined,
@ -976,7 +1140,7 @@ module.exports = class PlacesDBApi {
return records.map((record) => ({
id: record.id,
label: record.name,
label: record.name || record.nama,
}));
}

View File

@ -0,0 +1,226 @@
'use strict';
const isMissingTableError = (err) => {
const message = String(err && err.message);
return message.includes('No description found')
|| message.includes('does not exist')
|| message.includes('Cannot read properties of undefined');
};
const requestedColumns = (Sequelize) => ({
nama: {
type: Sequelize.DataTypes.STRING(255),
},
kategori: {
type: Sequelize.DataTypes.STRING(100),
},
subkategori: {
type: Sequelize.DataTypes.STRING(100),
},
alamat: {
type: Sequelize.DataTypes.TEXT,
},
kelurahan: {
type: Sequelize.DataTypes.STRING(100),
},
kecamatan: {
type: Sequelize.DataTypes.STRING(100),
},
kota: {
type: Sequelize.DataTypes.STRING(100),
},
provinsi: {
type: Sequelize.DataTypes.STRING(100),
},
kode_pos: {
type: Sequelize.DataTypes.STRING(20),
},
telepon: {
type: Sequelize.DataTypes.STRING(100),
},
whatsapp: {
type: Sequelize.DataTypes.STRING(100),
},
website: {
type: Sequelize.DataTypes.STRING(255),
},
jam_buka: {
type: Sequelize.DataTypes.STRING(255),
},
rating: {
type: Sequelize.DataTypes.DECIMAL(2, 1),
defaultValue: 0,
},
review_count: {
type: Sequelize.DataTypes.INTEGER,
defaultValue: 0,
},
verified: {
type: Sequelize.DataTypes.BOOLEAN,
defaultValue: false,
},
featured: {
type: Sequelize.DataTypes.BOOLEAN,
defaultValue: false,
},
popularitas: {
type: Sequelize.DataTypes.INTEGER,
defaultValue: 0,
},
created_at: {
type: Sequelize.DataTypes.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
updated_at: {
type: Sequelize.DataTypes.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
});
module.exports = {
async up(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
let table;
try {
table = await queryInterface.describeTable('places');
} catch (error) {
if (!isMissingTableError(error)) {
throw error;
}
await transaction.commit();
return;
}
const columns = requestedColumns(Sequelize);
for (const [columnName, definition] of Object.entries(columns)) {
if (!table[columnName]) {
await queryInterface.addColumn('places', columnName, definition, { transaction });
}
}
if (table.latitude) {
await queryInterface.sequelize.query(
'ALTER TABLE "places" ALTER COLUMN "latitude" TYPE DOUBLE PRECISION USING "latitude"::double precision;',
{ transaction },
);
}
if (table.longitude) {
await queryInterface.sequelize.query(
'ALTER TABLE "places" ALTER COLUMN "longitude" TYPE DOUBLE PRECISION USING "longitude"::double precision;',
{ transaction },
);
}
if (table.status) {
await queryInterface.sequelize.query(
`ALTER TABLE "places"
ALTER COLUMN "status" DROP DEFAULT,
ALTER COLUMN "status" TYPE VARCHAR(50) USING "status"::text,
ALTER COLUMN "status" SET DEFAULT 'aktif';`,
{ transaction },
);
} else {
await queryInterface.addColumn(
'places',
'status',
{
type: Sequelize.DataTypes.STRING(50),
defaultValue: 'aktif',
},
{ transaction },
);
}
await queryInterface.sequelize.query(
`UPDATE "places"
SET
nama = COALESCE(nama, name),
alamat = COALESCE(alamat, address),
kota = COALESCE(kota, city),
provinsi = COALESCE(provinsi, province),
kode_pos = COALESCE(kode_pos, postal_code),
telepon = COALESCE(telepon, phone_number),
whatsapp = COALESCE(whatsapp, whatsapp_number),
website = COALESCE(website, website_url),
rating = COALESCE(rating, ROUND(LEAST(GREATEST(COALESCE(rating_average, 0), 0), 9.9)::numeric, 1), 0),
review_count = COALESCE(review_count, rating_count, 0),
verified = COALESCE(verified, is_verified, false),
updated_at = COALESCE(updated_at, "updatedAt", NOW()),
created_at = COALESCE(created_at, "createdAt", NOW())
WHERE "deletedAt" IS NULL;`,
{ transaction },
);
const indexes = await queryInterface.showIndex('places');
const hasIndex = (name) => indexes.some((index) => index.name === name);
if (!hasIndex('places_kota_kategori_idx')) {
await queryInterface.addIndex('places', ['kota', 'kategori'], {
name: 'places_kota_kategori_idx',
transaction,
});
}
if (!hasIndex('places_status_featured_idx')) {
await queryInterface.addIndex('places', ['status', 'featured'], {
name: 'places_status_featured_idx',
transaction,
});
}
if (!hasIndex('places_popularitas_idx')) {
await queryInterface.addIndex('places', ['popularitas'], {
name: 'places_popularitas_idx',
transaction,
});
}
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
async down(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
let table;
try {
table = await queryInterface.describeTable('places');
} catch (error) {
if (!isMissingTableError(error)) {
throw error;
}
await transaction.commit();
return;
}
const indexes = await queryInterface.showIndex('places');
const removeIndexIfExists = async (name) => {
if (indexes.some((index) => index.name === name)) {
await queryInterface.removeIndex('places', name, { transaction });
}
};
await removeIndexIfExists('places_popularitas_idx');
await removeIndexIfExists('places_status_featured_idx');
await removeIndexIfExists('places_kota_kategori_idx');
for (const columnName of Object.keys(requestedColumns(Sequelize)).reverse()) {
if (table[columnName]) {
await queryInterface.removeColumn('places', columnName, { transaction });
}
}
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
};

View File

@ -0,0 +1,116 @@
'use strict';
const isMissingTableError = (err) => {
const message = String(err && err.message);
return message.includes('No description found')
|| message.includes('does not exist')
|| message.includes('Cannot read properties of undefined');
};
const metadataColumns = (Sequelize) => ({
sumber: {
type: Sequelize.DataTypes.STRING(100),
},
external_id: {
type: Sequelize.DataTypes.STRING(255),
},
external_type: {
type: Sequelize.DataTypes.STRING(50),
},
raw_source_data: {
type: Sequelize.DataTypes.JSONB,
},
last_synced_at: {
type: Sequelize.DataTypes.DATE,
},
});
module.exports = {
async up(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
let table;
try {
table = await queryInterface.describeTable('places');
} catch (error) {
if (!isMissingTableError(error)) {
throw error;
}
await transaction.commit();
return;
}
const columns = metadataColumns(Sequelize);
for (const [columnName, definition] of Object.entries(columns)) {
if (!table[columnName]) {
await queryInterface.addColumn('places', columnName, definition, { transaction });
}
}
const indexes = await queryInterface.showIndex('places');
const hasIndex = (name) => indexes.some((index) => index.name === name);
if (!hasIndex('places_source_external_unique_idx')) {
await queryInterface.addIndex('places', ['sumber', 'external_type', 'external_id'], {
name: 'places_source_external_unique_idx',
unique: true,
where: {
deletedAt: null,
},
transaction,
});
}
if (!hasIndex('places_sumber_idx')) {
await queryInterface.addIndex('places', ['sumber'], {
name: 'places_sumber_idx',
transaction,
});
}
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
async down(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
let table;
try {
table = await queryInterface.describeTable('places');
} catch (error) {
if (!isMissingTableError(error)) {
throw error;
}
await transaction.commit();
return;
}
const indexes = await queryInterface.showIndex('places');
const removeIndexIfExists = async (name) => {
if (indexes.some((index) => index.name === name)) {
await queryInterface.removeIndex('places', name, { transaction });
}
};
await removeIndexIfExists('places_sumber_idx');
await removeIndexIfExists('places_source_external_unique_idx');
for (const columnName of Object.keys(metadataColumns(Sequelize)).reverse()) {
if (table[columnName]) {
await queryInterface.removeColumn('places', columnName, { transaction });
}
}
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
};

View File

@ -14,6 +14,27 @@ name: {
},
nama: {
type: DataTypes.STRING(255),
},
kategori: {
type: DataTypes.STRING(100),
},
subkategori: {
type: DataTypes.STRING(100),
},
short_description: {
@ -35,6 +56,27 @@ address: {
},
alamat: {
type: DataTypes.TEXT,
},
kelurahan: {
type: DataTypes.STRING(100),
},
kecamatan: {
type: DataTypes.STRING(100),
},
city: {
@ -42,6 +84,13 @@ city: {
},
kota: {
type: DataTypes.STRING(100),
},
province: {
@ -49,6 +98,13 @@ province: {
},
provinsi: {
type: DataTypes.STRING(100),
},
postal_code: {
@ -56,17 +112,24 @@ postal_code: {
},
kode_pos: {
type: DataTypes.STRING(20),
},
latitude: {
type: DataTypes.DECIMAL,
type: DataTypes.DOUBLE,
},
longitude: {
type: DataTypes.DECIMAL,
type: DataTypes.DOUBLE,
@ -77,6 +140,13 @@ phone_number: {
},
telepon: {
type: DataTypes.STRING(100),
},
whatsapp_number: {
@ -84,6 +154,13 @@ whatsapp_number: {
},
whatsapp: {
type: DataTypes.STRING(100),
},
email: {
@ -98,6 +175,13 @@ website_url: {
},
website: {
type: DataTypes.STRING(255),
},
google_maps_url: {
@ -105,6 +189,13 @@ google_maps_url: {
},
jam_buka: {
type: DataTypes.STRING(255),
},
price_level: {
@ -141,6 +232,14 @@ rating_average: {
},
rating: {
type: DataTypes.DECIMAL(2, 1),
defaultValue: 0,
},
rating_count: {
@ -148,25 +247,22 @@ rating_count: {
},
review_count: {
type: DataTypes.INTEGER,
defaultValue: 0,
},
status: {
type: DataTypes.ENUM,
type: DataTypes.STRING(50),
defaultValue: 'aktif',
values: [
"draft",
"published",
"archived"
],
},
is_verified: {
@ -177,6 +273,81 @@ is_verified: {
},
verified: {
type: DataTypes.BOOLEAN,
defaultValue: false,
},
featured: {
type: DataTypes.BOOLEAN,
defaultValue: false,
},
popularitas: {
type: DataTypes.INTEGER,
defaultValue: 0,
},
created_at: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW,
},
updated_at: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW,
},
sumber: {
type: DataTypes.STRING(100),
},
external_id: {
type: DataTypes.STRING(255),
},
external_type: {
type: DataTypes.STRING(50),
},
raw_source_data: {
type: DataTypes.JSONB,
},
last_synced_at: {
type: DataTypes.DATE,
},
importHash: {

View File

@ -16,6 +16,7 @@ const searchRoutes = require('./routes/search');
const sqlRoutes = require('./routes/sql');
const pexelsRoutes = require('./routes/pexels');
const publicPlacesRoutes = require('./routes/publicPlaces');
const geoseekCollectorRoutes = require('./routes/geoseekCollector');
const openaiRoutes = require('./routes/openai');
@ -114,6 +115,8 @@ app.use('/api/place_categories', passport.authenticate('jwt', {session: false}),
app.use('/api/places', passport.authenticate('jwt', {session: false}), placesRoutes);
app.use('/api/geoseek-collector', passport.authenticate('jwt', {session: false}), geoseekCollectorRoutes);
app.use('/api/place_opening_hours', passport.authenticate('jwt', {session: false}), place_opening_hoursRoutes);
app.use('/api/place_features', passport.authenticate('jwt', {session: false}), place_featuresRoutes);

View File

@ -0,0 +1,24 @@
const express = require('express');
const GeoSeekCollectorService = require('../services/geoseekCollector');
const wrapAsync = require('../helpers').wrapAsync;
const { checkPermissions } = require('../middlewares/check-permissions');
const router = express.Router();
router.get('/osm/amenities', checkPermissions('READ_PLACES'), wrapAsync(async (req, res) => {
res.status(200).send({
source: 'OpenStreetMap',
amenities: GeoSeekCollectorService.supportedAmenities(),
});
}));
router.post('/osm', checkPermissions('CREATE_PLACES'), wrapAsync(async (req, res) => {
const payload = await GeoSeekCollectorService.collectOpenStreetMap(
req.body || {},
req.currentUser,
);
res.status(200).send(payload);
}));
module.exports = router;

View File

@ -29,6 +29,15 @@ router.use(checkCrudPermissions('places'));
* name:
* type: string
* default: name
* nama:
* type: string
* default: nama
* kategori:
* type: string
* default: kategori
* subkategori:
* type: string
* default: subkategori
* short_description:
* type: string
* default: short_description
@ -38,34 +47,77 @@ router.use(checkCrudPermissions('places'));
* address:
* type: string
* default: address
* alamat:
* type: string
* default: alamat
* kelurahan:
* type: string
* default: kelurahan
* kecamatan:
* type: string
* default: kecamatan
* city:
* type: string
* default: city
* kota:
* type: string
* default: kota
* province:
* type: string
* default: province
* provinsi:
* type: string
* default: provinsi
* postal_code:
* type: string
* default: postal_code
* kode_pos:
* type: string
* default: kode_pos
* phone_number:
* type: string
* default: phone_number
* telepon:
* type: string
* default: telepon
* whatsapp_number:
* type: string
* default: whatsapp_number
* whatsapp:
* type: string
* default: whatsapp
* email:
* type: string
* default: email
* website_url:
* type: string
* default: website_url
* website:
* type: string
* default: website
* google_maps_url:
* type: string
* default: google_maps_url
* jam_buka:
* type: string
* default: jam_buka
* rating_count:
* type: integer
* format: int64
* review_count:
* type: integer
* format: int64
* status:
* type: string
* default: aktif
* verified:
* type: boolean
* featured:
* type: boolean
* popularitas:
* type: integer
* format: int64
* latitude:
* type: integer
@ -79,6 +131,9 @@ router.use(checkCrudPermissions('places'));
* rating_average:
* type: integer
* format: int64
* rating:
* type: number
* format: float
*
*
@ -127,8 +182,8 @@ router.use(checkCrudPermissions('places'));
router.post('/', wrapAsync(async (req, res) => {
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`;
const link = new URL(referer);
await PlacesService.create(req.body.data, req.currentUser, true, link.host);
const payload = true;
const placeData = req.body.data || req.body;
const payload = await PlacesService.create(placeData, req.currentUser, true, link.host);
res.status(200).send(payload);
}));
@ -224,7 +279,9 @@ router.post('/bulk-import', wrapAsync(async (req, res) => {
* description: Some server error
*/
router.put('/:id', wrapAsync(async (req, res) => {
await PlacesService.update(req.body.data, req.body.id, req.currentUser);
const placeData = req.body.data || req.body;
const placeId = req.body.id || req.params.id;
await PlacesService.update(placeData, placeId, req.currentUser);
const payload = true;
res.status(200).send(payload);
}));
@ -300,7 +357,8 @@ router.delete('/:id', wrapAsync(async (req, res) => {
* description: Some server error
*/
router.post('/deleteByIds', wrapAsync(async (req, res) => {
await PlacesService.deleteByIds(req.body.data, req.currentUser);
const ids = req.body.data || req.body.ids;
await PlacesService.deleteByIds(ids, req.currentUser);
const payload = true;
res.status(200).send(payload);
}));
@ -338,9 +396,10 @@ router.get('/', wrapAsync(async (req, res) => {
req.query, { currentUser }
);
if (filetype && filetype === 'csv') {
const fields = ['id','name','short_description','full_description','address','city','province','postal_code','phone_number','whatsapp_number','email','website_url','google_maps_url',
'rating_count',
'latitude','longitude','average_price','rating_average',
const fields = ['id','name','nama','kategori','subkategori','short_description','full_description','address','alamat','kelurahan','kecamatan','city','kota','province','provinsi','postal_code','kode_pos','phone_number','telepon','whatsapp_number','whatsapp','email','website_url','website','google_maps_url','jam_buka',
'rating_count','review_count','status','is_verified','verified','featured','popularitas',
'sumber','external_id','external_type','last_synced_at',
'latitude','longitude','average_price','rating_average','rating',
];
const opts = { fields };

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,462 @@
const axios = require('axios');
const db = require('../db/models');
const OVERPASS_URL = 'https://overpass-api.de/api/interpreter';
const OSM_CATEGORY_LABELS = {
restaurant: 'Restoran',
cafe: 'Cafe',
hospital: 'Rumah Sakit',
pharmacy: 'Apotek',
bank: 'Bank',
atm: 'ATM',
fuel: 'SPBU',
school: 'Sekolah',
university: 'Kampus',
hotel: 'Hotel',
};
const DEFAULT_CITIES = [
'Jakarta',
'Bandung',
'Surabaya',
'Medan',
'Semarang',
'Makassar',
'Palembang',
'Yogyakarta',
'Bekasi',
'Depok',
];
const CONTENT_FIELDS = [
'name',
'nama',
'kategori',
'subkategori',
'short_description',
'full_description',
'address',
'alamat',
'kelurahan',
'kecamatan',
'city',
'kota',
'province',
'provinsi',
'postal_code',
'kode_pos',
'latitude',
'longitude',
'phone_number',
'telepon',
'whatsapp_number',
'whatsapp',
'email',
'website_url',
'website',
'google_maps_url',
'jam_buka',
'status',
'is_verified',
'verified',
'featured',
'popularitas',
];
const METADATA_FIELDS = [
'sumber',
'external_id',
'external_type',
'raw_source_data',
'last_synced_at',
];
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const normalizeList = (value, fallback) => {
if (Array.isArray(value)) {
const list = value.map((item) => String(item).trim()).filter(Boolean);
return list.length ? list : fallback;
}
if (typeof value === 'string') {
const list = value.split(',').map((item) => item.trim()).filter(Boolean);
return list.length ? list : fallback;
}
return fallback;
};
const toPositiveInteger = (value, fallback, max) => {
const number = Number(value);
if (!Number.isFinite(number) || number < 0) {
return fallback;
}
const integer = Math.floor(number);
if (max === undefined) {
return integer;
}
return Math.min(integer, max);
};
const toBoolean = (value, fallback = false) => {
if (value === undefined || value === null) {
return fallback;
}
return value === true || value === 'true' || value === '1' || value === 1;
};
const titleCase = (value) => String(value)
.split('_')
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
const amenityKeyFromInput = (value) => {
const normalized = String(value).trim().toLowerCase();
if (!normalized) {
return null;
}
if (OSM_CATEGORY_LABELS[normalized]) {
return normalized;
}
const matched = Object.entries(OSM_CATEGORY_LABELS).find((entry) => (
entry[1].toLowerCase() === normalized
));
return matched ? matched[0] : normalized.replace(/\s+/g, '_');
};
const normalizeAmenities = (params) => {
const requested = normalizeList(
params.amenities || params.categories,
Object.keys(OSM_CATEGORY_LABELS),
);
return [...new Set(requested.map(amenityKeyFromInput).filter(Boolean))];
};
const escapeOverpassString = (value) => String(value)
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"');
const escapeRegex = (value) => String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const buildOverpassQuery = (city, amenities, timeoutSeconds) => {
const escapedCity = escapeOverpassString(city);
const amenityRegex = amenities.map(escapeRegex).join('|');
const selector = amenityRegex ? `["amenity"~"^(${amenityRegex})$"]` : '["amenity"]';
return `
[out:json][timeout:${timeoutSeconds}];
area["name"="${escapedCity}"]->.searchArea;
(
node${selector}(area.searchArea);
way${selector}(area.searchArea);
relation${selector}(area.searchArea);
);
out center tags;
`;
};
const getTag = (tags, keys) => {
for (const key of keys) {
const value = tags[key];
if (value !== undefined && value !== null && String(value).trim() !== '') {
return String(value).trim();
}
}
return null;
};
const buildAddress = (tags) => {
const fullAddress = getTag(tags, ['addr:full', 'contact:address']);
if (fullAddress) {
return fullAddress;
}
return [
getTag(tags, ['addr:street']),
getTag(tags, ['addr:housenumber']),
getTag(tags, ['addr:neighbourhood', 'addr:suburb']),
].filter(Boolean).join(', ') || null;
};
const normalizeOsmElement = (element, city, amenities, includeUnnamed) => {
const tags = element.tags || {};
const amenity = tags.amenity;
if (!amenity || !amenities.includes(amenity)) {
return { reason: 'unsupported_amenity' };
}
const latitude = element.lat !== undefined ? element.lat : element.center && element.center.lat;
const longitude = element.lon !== undefined ? element.lon : element.center && element.center.lon;
if (latitude === undefined || latitude === null || longitude === undefined || longitude === null) {
return { reason: 'missing_coordinates' };
}
const nama = getTag(tags, ['name', 'brand', 'operator']);
if (!nama && !includeUnnamed) {
return { reason: 'missing_name' };
}
const alamat = buildAddress(tags);
const kota = getTag(tags, ['addr:city', 'is_in:city']) || city;
const provinsi = getTag(tags, ['addr:province', 'addr:state']);
const kodePos = getTag(tags, ['addr:postcode']);
const telepon = getTag(tags, ['phone', 'contact:phone']);
const whatsapp = getTag(tags, ['whatsapp', 'contact:whatsapp']);
const website = getTag(tags, ['website', 'contact:website', 'url']);
const email = getTag(tags, ['email', 'contact:email']);
const jamBuka = getTag(tags, ['opening_hours']);
const displayName = nama || `${titleCase(amenity)} ${city}`;
const now = new Date();
return {
place: {
name: displayName,
nama: displayName,
kategori: OSM_CATEGORY_LABELS[amenity] || titleCase(amenity),
subkategori: amenity,
short_description: `${OSM_CATEGORY_LABELS[amenity] || titleCase(amenity)} dari OpenStreetMap`,
address: alamat,
alamat,
city: kota,
kota,
province: provinsi,
provinsi,
postal_code: kodePos,
kode_pos: kodePos,
latitude,
longitude,
phone_number: telepon,
telepon,
whatsapp_number: whatsapp,
whatsapp,
email,
website_url: website,
website,
jam_buka: jamBuka,
status: 'aktif',
is_verified: false,
verified: false,
featured: false,
popularitas: 0,
sumber: 'OpenStreetMap',
external_id: String(element.id),
external_type: element.type,
raw_source_data: element,
last_synced_at: now,
},
};
};
const compactForUpdate = (payload, currentUser) => {
const updatePayload = {};
for (const field of CONTENT_FIELDS) {
const value = payload[field];
if (value !== undefined && value !== null && value !== '') {
updatePayload[field] = value;
}
}
for (const field of METADATA_FIELDS) {
updatePayload[field] = payload[field];
}
updatePayload.updated_at = new Date();
updatePayload.updatedById = currentUser && currentUser.id ? currentUser.id : null;
return updatePayload;
};
module.exports = class GeoSeekCollectorService {
static supportedAmenities() {
return Object.entries(OSM_CATEGORY_LABELS).map(([value, label]) => ({ value, label }));
}
static async fetchOpenStreetMapCity(city, amenities, options) {
const timeoutSeconds = options.timeoutSeconds;
const timeoutMs = timeoutSeconds * 1000 + 15000;
const query = buildOverpassQuery(city, amenities, timeoutSeconds);
try {
const response = await axios.get(options.overpassUrl, {
params: { data: query },
timeout: timeoutMs,
maxBodyLength: Infinity,
maxContentLength: Infinity,
});
const data = response.data || {};
if (!Array.isArray(data.elements)) {
console.error('Invalid OpenStreetMap Overpass response', { city, data });
throw new Error(`Invalid OpenStreetMap Overpass response for ${city}`);
}
return data.elements;
} catch (error) {
console.error('OpenStreetMap Overpass request failed', {
city,
query,
status: error.response && error.response.status,
response: error.response && error.response.data,
message: error.message,
});
throw error;
}
}
static async upsertPlace(payload, currentUser, transaction) {
const where = {
sumber: payload.sumber,
external_type: payload.external_type,
external_id: payload.external_id,
};
const existing = await db.places.findOne({ where, transaction });
if (existing) {
await existing.update(compactForUpdate(payload, currentUser), { transaction });
return 'updated';
}
try {
await db.places.create({
...payload,
createdById: currentUser && currentUser.id ? currentUser.id : null,
updatedById: currentUser && currentUser.id ? currentUser.id : null,
}, { transaction });
return 'inserted';
} catch (error) {
if (error.name !== 'SequelizeUniqueConstraintError') {
throw error;
}
const duplicate = await db.places.findOne({ where, transaction });
if (!duplicate) {
throw error;
}
await duplicate.update(compactForUpdate(payload, currentUser), { transaction });
return 'updated';
}
}
static async collectOpenStreetMap(params = {}, currentUser) {
const cities = normalizeList(params.cities || params.kota, DEFAULT_CITIES);
const amenities = normalizeAmenities(params);
const includeUnnamed = toBoolean(params.includeUnnamed, false);
const dryRun = toBoolean(params.dryRun, false);
const delayMs = toPositiveInteger(params.delayMs, 3000, 30000);
const timeoutSeconds = toPositiveInteger(params.timeoutSeconds, 60, 180) || 60;
const limitPerCity = toPositiveInteger(params.limitPerCity, 0, 5000);
const overpassUrl = params.overpassUrl || OVERPASS_URL;
const summary = {
success: true,
source: 'OpenStreetMap',
dryRun,
citiesRequested: cities,
amenities,
inserted: 0,
updated: 0,
skipped: 0,
skippedByReason: {},
fetched: 0,
processed: 0,
cityResults: [],
};
for (let index = 0; index < cities.length; index += 1) {
const city = cities[index];
const elements = await GeoSeekCollectorService.fetchOpenStreetMapCity(city, amenities, {
overpassUrl,
timeoutSeconds,
});
summary.fetched += elements.length;
const cityResult = {
city,
fetched: elements.length,
processed: 0,
inserted: 0,
updated: 0,
skipped: 0,
skippedByReason: {},
preview: [],
};
const places = [];
for (const element of elements) {
const normalized = normalizeOsmElement(element, city, amenities, includeUnnamed);
if (!normalized.place) {
const reason = normalized.reason || 'unknown';
cityResult.skipped += 1;
cityResult.skippedByReason[reason] = (cityResult.skippedByReason[reason] || 0) + 1;
continue;
}
places.push(normalized.place);
}
const selectedPlaces = limitPerCity > 0 ? places.slice(0, limitPerCity) : places;
const limited = places.length - selectedPlaces.length;
if (limited > 0) {
cityResult.skipped += limited;
cityResult.skippedByReason.limit_per_city = limited;
}
if (dryRun) {
cityResult.processed = selectedPlaces.length;
cityResult.preview = selectedPlaces.slice(0, 10).map((place) => ({
nama: place.nama,
kategori: place.kategori,
kota: place.kota,
latitude: place.latitude,
longitude: place.longitude,
external_id: place.external_id,
external_type: place.external_type,
}));
} else {
await db.sequelize.transaction(async (transaction) => {
for (const place of selectedPlaces) {
const action = await GeoSeekCollectorService.upsertPlace(place, currentUser, transaction);
if (action === 'inserted') {
cityResult.inserted += 1;
} else {
cityResult.updated += 1;
}
cityResult.processed += 1;
}
});
}
summary.inserted += cityResult.inserted;
summary.updated += cityResult.updated;
summary.skipped += cityResult.skipped;
summary.processed += cityResult.processed;
for (const [reason, count] of Object.entries(cityResult.skippedByReason)) {
summary.skippedByReason[reason] = (summary.skippedByReason[reason] || 0) + count;
}
summary.cityResults.push(cityResult);
if (delayMs > 0 && index < cities.length - 1) {
await sleep(delayMs);
}
}
return summary;
}
};

View File

@ -3,8 +3,6 @@ const PlacesDBApi = require('../db/api/places');
const processFile = require("../middlewares/upload");
const ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser');
const axios = require('axios');
const config = require('../config');
const stream = require('stream');
@ -15,7 +13,7 @@ module.exports = class PlacesService {
static async create(data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await PlacesDBApi.create(
const createdPlaces = await PlacesDBApi.create(
data,
{
currentUser,
@ -24,13 +22,14 @@ module.exports = class PlacesService {
);
await transaction.commit();
return createdPlaces;
} catch (error) {
await transaction.rollback();
throw error;
}
};
}
static async bulkImport(req, res, sendInvitationEmails = true, host) {
static async bulkImport(req, res) {
const transaction = await db.sequelize.transaction();
try {
@ -95,7 +94,7 @@ module.exports = class PlacesService {
await transaction.rollback();
throw error;
}
};
}
static async deleteByIds(ids, currentUser) {
const transaction = await db.sequelize.transaction();

View File

@ -131,6 +131,16 @@ type LocationState = {
label: string;
};
type SearchOrigin = {
lat: number;
lng: number;
label: string;
source: 'browser' | 'query_keyword' | 'default_local_keyword' | string;
matched_keyword?: string;
note?: string;
};
type DistanceBucket = {
radius_km: number;
label: string;
@ -165,6 +175,7 @@ type SearchMeta = {
expanded_for_nearest?: boolean;
external_sources?: string[];
external_source_errors?: string[];
search_origin?: SearchOrigin | null;
};
const LOCATION_REQUIRED_MESSAGE =
@ -173,6 +184,136 @@ const LOCATION_REQUIRED_MESSAGE =
const DEFAULT_RADIUS_KM = 5;
const GLOBAL_RADIUS_KM = 20038;
const keywordLocationPhrases = [
'maps',
'google maps',
'maps google',
'peta',
'google earth',
'waze',
'street view',
'google maps satellite',
'peta indonesia',
'cuaca',
'cuaca hari ini',
'prakiraan cuaca',
'bmkg',
'ramalan cuaca',
'cuaca esok hari',
'terdekat',
'dekat saya',
'near me',
'jakarta',
'bogor',
'bekasi',
'tangerang',
'depok',
'cikarang',
'karawang',
'bandung',
'cafe',
'restoran',
'kuliner',
'rumah makan',
'tempat makan',
'warkop',
'kedai kopi',
'hotel',
'penginapan',
'villa',
'homestay',
'kos',
'kost',
'kontrakan',
'apartemen',
'pom bensin',
'spbu',
'pertamini',
'bengkel',
'tambal ban',
'atm',
'bank',
'minimarket',
'indomaret',
'alfamart',
'supermarket',
'toko kelontong',
'pasar',
'mall',
'pusat perbelanjaan',
'toko obat',
'apotek',
'rumah sakit',
'puskesmas',
'klinik',
'dokter',
'salon',
'barbershop',
'spa',
'pijat',
'gym',
'fitness',
'taman',
'wisata',
'masjid',
'mushola',
'gereja',
'pura',
'vihara',
'sekolah',
'kampus',
'universitas',
'stasiun',
'halte',
'terminal',
'bandara',
'band',
];
const normalizeSearchText = (value: string) =>
value
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, ' ')
.trim();
const canSearchWithKeywordOrigin = (value: string) => {
const normalized = normalizeSearchText(value);
if (!normalized) {
return false;
}
return keywordLocationPhrases.some((phrase) =>
normalized.includes(normalizeSearchText(phrase)),
);
};
const toSearchOrigin = (value: unknown): SearchOrigin | null => {
if (!value || typeof value !== 'object') {
return null;
}
const origin = value as Partial<SearchOrigin>;
const lat = Number(origin.lat);
const lng = Number(origin.lng);
if (!Number.isFinite(lat) || !Number.isFinite(lng) || !origin.label) {
return null;
}
return {
lat,
lng,
label: String(origin.label),
source: origin.source || 'query_keyword',
matched_keyword: origin.matched_keyword,
note: origin.note,
};
};
const fallbackRadiusZones: RadiusZone[] = [
{ value: 1, label: 'Walking Zone', range: '01 Km' },
{ value: 5, label: 'Neighborhood Zone', range: '15 Km' },
@ -518,6 +659,28 @@ const topLocationKeywords: KeywordChip[] = [
];
const categoryKeywordGroups: KeywordGroup[] = [
{
title: 'Maps, Navigasi & Cuaca',
description:
'Intent maps, peta, navigasi, street view, peta Indonesia, dan keyword cuaca/BMKG yang diarahkan ke data lokasi publik.',
keywords: [
{ label: 'Maps', query: 'maps' },
{ label: 'Google Maps', query: 'google maps' },
{ label: 'Maps Google', query: 'maps google' },
{ label: 'Peta', query: 'peta' },
{ label: 'Google Earth', query: 'google earth' },
{ label: 'Waze', query: 'waze' },
{ label: 'Street View', query: 'street view' },
{ label: 'Google Maps Satellite', query: 'google maps satellite' },
{ label: 'Peta Indonesia', query: 'peta indonesia', radiusKm: 100 },
{ label: 'Cuaca', query: 'cuaca' },
{ label: 'Cuaca Hari Ini', query: 'cuaca hari ini' },
{ label: 'Prakiraan Cuaca', query: 'prakiraan cuaca' },
{ label: 'BMKG', query: 'BMKG' },
{ label: 'Ramalan Cuaca', query: 'ramalan cuaca' },
{ label: 'Cuaca Esok Hari', query: 'cuaca esok hari' },
],
},
{
title: 'Kuliner',
description:
@ -525,15 +688,33 @@ const categoryKeywordGroups: KeywordGroup[] = [
keywords: [
{ label: 'Makanan terdekat', query: 'makanan terdekat' },
{ label: 'Restoran terdekat', query: 'restoran terdekat' },
{ label: 'Kuliner terdekat', query: 'kuliner terdekat' },
{ label: 'Rumah makan terdekat', query: 'rumah makan terdekat' },
{ label: 'Tempat makan terdekat', query: 'tempat makan terdekat' },
{ label: 'Cafe terdekat', query: 'cafe terdekat' },
{ label: 'Cafe aesthetic', query: 'cafe aesthetic' },
{ label: 'Tempat nongkrong', query: 'tempat nongkrong terdekat' },
{ label: 'Warung makan terdekat', query: 'warung makan terdekat' },
{ label: 'Warkop terdekat', query: 'warkop terdekat' },
{ label: 'Kedai kopi terdekat', query: 'kedai kopi terdekat' },
{ label: 'Warteg terdekat', query: 'warteg terdekat' },
{ label: 'Bakso terdekat', query: 'bakso terdekat' },
{ label: 'Soto terdekat', query: 'soto terdekat' },
{ label: 'Ayam goreng terdekat', query: 'ayam goreng terdekat' },
{ label: 'Seafood terdekat', query: 'seafood terdekat' },
{ label: 'Kuliner malam', query: 'kuliner malam' },
{ label: 'Cemilan unik', query: 'cemilan unik' },
{ label: 'Jajanan pasar modern', query: 'jajanan pasar modern' },
{ label: 'Makanan viral murah', query: 'makanan viral murah' },
{ label: 'Ide jualan makanan', query: 'ide jualan makanan' },
{ label: 'Makanan pedas', query: 'makanan pedas' },
{ label: 'Catering terdekat', query: 'catering terdekat' },
{ label: 'Katering sehat', query: 'katering sehat' },
{ label: 'Minuman terdekat', query: 'minuman terdekat' },
{ label: 'Minuman kekinian', query: 'minuman kekinian' },
{ label: 'Es kopi susu', query: 'es kopi susu' },
{ label: 'Boba drink', query: 'boba drink' },
{ label: 'Resep minuman segar', query: 'resep minuman segar' },
],
},
{
@ -551,6 +732,11 @@ const categoryKeywordGroups: KeywordGroup[] = [
{ label: 'Skincare terdekat', query: 'toko skincare terdekat' },
{ label: 'Herbal terdekat', query: 'toko herbal terdekat' },
{ label: 'Obat terdekat', query: 'toko obat terdekat' },
{ label: 'Indomaret terdekat', query: 'indomaret terdekat' },
{ label: 'Alfamart terdekat', query: 'alfamart terdekat' },
{ label: 'Supermarket terdekat', query: 'supermarket terdekat' },
{ label: 'Pusat perbelanjaan', query: 'pusat perbelanjaan terdekat' },
{ label: 'Toko baju terdekat', query: 'toko baju terdekat' },
{ label: 'Furniture terdekat', query: 'toko furniture terdekat' },
{
label: 'Peralatan rumah tangga',
@ -584,6 +770,8 @@ const categoryKeywordGroups: KeywordGroup[] = [
{ label: 'Tambal ban', query: 'tambal ban terdekat' },
{ label: 'Cuci mobil', query: 'cuci mobil terdekat' },
{ label: 'SPBU terdekat', query: 'SPBU terdekat' },
{ label: 'Pom bensin', query: 'pom bensin terdekat' },
{ label: 'Pertamini', query: 'pertamini terdekat' },
],
},
{
@ -594,8 +782,11 @@ const categoryKeywordGroups: KeywordGroup[] = [
{ label: 'Rumah sakit terdekat', query: 'rumah sakit terdekat' },
{ label: 'Klinik terdekat', query: 'klinik terdekat' },
{ label: 'Apotek terdekat', query: 'apotek terdekat' },
{ label: 'Apotek 24 jam', query: 'apotek 24 jam terdekat' },
{ label: 'Puskesmas terdekat', query: 'puskesmas terdekat' },
{ label: 'Dokter terdekat', query: 'dokter terdekat' },
{ label: 'Dokter gigi', query: 'dokter gigi terdekat' },
{ label: 'Bidan terdekat', query: 'bidan terdekat' },
{ label: 'Laboratorium', query: 'laboratorium terdekat' },
{ label: 'Alat kesehatan', query: 'alat kesehatan terdekat' },
{ label: 'Pet shop', query: 'pet shop terdekat' },
@ -662,6 +853,10 @@ const categoryKeywordGroups: KeywordGroup[] = [
keywords: [
{ label: 'Bank terdekat', query: 'bank terdekat' },
{ label: 'ATM terdekat', query: 'ATM terdekat' },
{ label: 'ATM BCA', query: 'atm bca terdekat' },
{ label: 'ATM BRI', query: 'atm bri terdekat' },
{ label: 'ATM Mandiri', query: 'atm mandiri terdekat' },
{ label: 'ATM BNI', query: 'atm bni terdekat' },
{ label: 'Koperasi terdekat', query: 'koperasi terdekat' },
{ label: 'Pegadaian terdekat', query: 'pegadaian terdekat' },
],
@ -687,6 +882,9 @@ const categoryKeywordGroups: KeywordGroup[] = [
keywords: [
{ label: 'Salon terdekat', query: 'salon terdekat' },
{ label: 'Barbershop terdekat', query: 'barbershop terdekat' },
{ label: 'Pangkas rambut', query: 'pangkas rambut terdekat' },
{ label: 'Spa terdekat', query: 'spa terdekat' },
{ label: 'Pijat terdekat', query: 'pijat terdekat' },
{ label: 'Skincare terdekat', query: 'skincare terdekat' },
{ label: 'Kosmetik terdekat', query: 'kosmetik terdekat' },
],
@ -725,12 +923,57 @@ const categoryKeywordGroups: KeywordGroup[] = [
description: 'Intent tempat wisata, hotel, penginapan, villa, dan pantai.',
keywords: [
{ label: 'Tempat wisata', query: 'tempat wisata terdekat' },
{ label: 'Wisata alam', query: 'wisata alam terdekat' },
{ label: 'Taman terdekat', query: 'taman terdekat' },
{ label: 'Alun-alun', query: 'alun alun terdekat' },
{ label: 'Museum terdekat', query: 'museum terdekat' },
{ label: 'Kebun binatang', query: 'kebun binatang terdekat' },
{ label: 'Hotel terdekat', query: 'hotel terdekat' },
{ label: 'Penginapan terdekat', query: 'penginapan terdekat' },
{ label: 'Villa terdekat', query: 'villa terdekat' },
{ label: 'Homestay terdekat', query: 'homestay terdekat' },
{ label: 'Pantai terdekat', query: 'pantai terdekat' },
],
},
{
title: 'Ibadah, Pendidikan & Transportasi',
description:
'Intent masjid, mushola, gereja, pura, vihara, sekolah, kampus, tempat les, stasiun, halte, terminal, dan bandara.',
keywords: [
{ label: 'Masjid terdekat', query: 'masjid terdekat' },
{ label: 'Mushola terdekat', query: 'mushola terdekat' },
{ label: 'Gereja terdekat', query: 'gereja terdekat' },
{ label: 'Pura terdekat', query: 'pura terdekat' },
{ label: 'Vihara terdekat', query: 'vihara terdekat' },
{ label: 'Sekolah terdekat', query: 'sekolah terdekat' },
{ label: 'SD terdekat', query: 'sd terdekat' },
{ label: 'SMP terdekat', query: 'smp terdekat' },
{ label: 'SMA terdekat', query: 'sma terdekat' },
{ label: 'Kampus terdekat', query: 'kampus terdekat' },
{ label: 'Universitas terdekat', query: 'universitas terdekat' },
{ label: 'Pesantren terdekat', query: 'pesantren terdekat' },
{ label: 'LPK terdekat', query: 'lpk terdekat' },
{ label: 'Tempat les', query: 'tempat les terdekat' },
{ label: 'Stasiun terdekat', query: 'stasiun terdekat' },
{ label: 'Stasiun kereta', query: 'stasiun kereta terdekat' },
{ label: 'Halte terdekat', query: 'halte terdekat' },
{ label: 'Halte busway', query: 'halte busway terdekat' },
{ label: 'Terminal terdekat', query: 'terminal terdekat' },
{ label: 'Bandara', query: 'bandara terdekat' },
],
},
{
title: 'Olahraga & Rekreasi Harian',
description:
'Intent gym, tempat fitness, kolam renang, lapangan futsal, taman, dan aktivitas rekreasi sekitar.',
keywords: [
{ label: 'Gym terdekat', query: 'gym terdekat' },
{ label: 'Tempat fitness', query: 'tempat fitness terdekat' },
{ label: 'Kolam renang', query: 'kolam renang terdekat' },
{ label: 'Lapangan futsal', query: 'lapangan futsal terdekat' },
{ label: 'Taman terdekat', query: 'taman terdekat' },
],
},
{
title: 'Pemerintahan & BUMN',
description:
@ -979,6 +1222,19 @@ export default function Starter() {
const featuredPlaces = places.slice(0, 3);
const formulaEntries = Object.entries(searchMeta.geo_score_formula || {});
const hasActiveLocation = locationReady && Boolean(location);
const canUseKeywordLocation = canSearchWithKeywordOrigin(query);
const canSubmitSearch = hasActiveLocation || canUseKeywordLocation;
const activeSearchOrigin =
location ||
(searchMeta.search_origin
? {
lat: searchMeta.search_origin.lat,
lng: searchMeta.search_origin.lng,
label: searchMeta.search_origin.label,
}
: null);
const activeLocationLabel =
activeSearchOrigin?.label || 'Belum aktif — izinkan lokasi browser';
const openNow =
searchMeta.trending?.live?.open_now ||
places.filter((place) => place.live_status?.status === 'open').length;
@ -993,22 +1249,36 @@ export default function Starter() {
const fetchPlaces = useCallback(
async (
nextQuery: string,
nextLocation: LocationState,
nextLocation: LocationState | null,
nextRadiusKm: number,
) => {
setLoading(true);
setError('');
try {
const response = await axios.get('/public/places', {
params: {
q: nextQuery,
lat: nextLocation.lat,
lng: nextLocation.lng,
radiusKm: nextRadiusKm,
limit: 30,
},
});
const params: {
q: string;
radiusKm: number;
limit: number;
lat?: number;
lng?: number;
} = {
q: nextQuery,
radiusKm: nextRadiusKm,
limit: 30,
};
if (nextLocation) {
params.lat = nextLocation.lat;
params.lng = nextLocation.lng;
}
const response = await axios.get('/public/places', { params });
const responseSearchOrigin = toSearchOrigin(response.data?.search_origin);
if (!nextLocation && responseSearchOrigin) {
setLocationStatus(`Lokasi dari kata kunci: ${responseSearchOrigin.label}`);
}
setPlaces(Array.isArray(response.data?.rows) ? response.data.rows : []);
setSearchMeta({
@ -1042,6 +1312,7 @@ export default function Starter() {
)
? response.data.external_source_errors
: [],
search_origin: responseSearchOrigin,
});
} catch (err) {
console.error('Gagal memuat GeoSeek public search', err);
@ -1125,10 +1396,10 @@ export default function Starter() {
const handleSearch = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!locationReady || !location) {
if (!canSubmitSearch) {
setError(LOCATION_REQUIRED_MESSAGE);
setLocationStatus(
'Lokasi belum aktif. Klik “Gunakan lokasi saya” dan izinkan akses lokasi browser.',
'Lokasi belum aktif. Klik “Gunakan lokasi saya” atau ketik keyword local SEO/kota seperti “kuliner Jakarta”, “maps”, “SPBU terdekat”, atau “cuaca”.',
);
return;
}
@ -1155,11 +1426,11 @@ export default function Starter() {
return place.google_maps_url;
}
if (!location) {
if (!activeSearchOrigin) {
return '#search';
}
return `/tempat/${place.id}?lat=${location.lat}&lng=${location.lng}&radiusKm=${radiusKm}&q=${encodeURIComponent(query)}`;
return `/tempat/${place.id}?lat=${activeSearchOrigin.lat}&lng=${activeSearchOrigin.lng}&radiusKm=${radiusKm}&q=${encodeURIComponent(query)}`;
};
const shouldOpenExternalPlace = (place: PublicPlace) =>
@ -1261,13 +1532,13 @@ export default function Starter() {
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder='Cari “warteg terdekat”, “toko bahan bangunan”, “service laptop”, “smartphone”, “Samsat”, “SPBU terdekat”...'
placeholder='Cari “maps”, “cuaca”, “kuliner Jakarta”, “cafe aesthetic”, “SPBU terdekat”, “ATM BCA”, “stasiun terdekat”...'
className='h-14 w-full rounded-2xl border border-[#E0D6C3] bg-[#FCFAF5] pl-12 pr-4 text-sm outline-none transition focus:border-[#2CA58D] focus:ring-4 focus:ring-[#2CA58D]/15'
/>
</label>
<button
type='submit'
disabled={!hasActiveLocation || loading}
disabled={!canSubmitSearch || loading}
className='h-14 rounded-2xl bg-[#F26A4B] px-7 font-black text-white shadow-lg shadow-[#F26A4B]/30 transition hover:bg-[#E85A39] focus:outline-none focus:ring-4 focus:ring-[#F26A4B]/30 disabled:cursor-not-allowed disabled:opacity-60'
>
Cari
@ -1288,7 +1559,9 @@ export default function Starter() {
dikosongkan {' '}
{hasActiveLocation
? 'Live search aktif'
: 'Menunggu izin lokasi'}
: canUseKeywordLocation
? 'Bisa cari dari keyword/kota'
: 'Menunggu izin lokasi'}
</span>
</div>
@ -1309,8 +1582,8 @@ export default function Starter() {
<div className='mt-6 inline-flex rounded-full border border-white/15 bg-white/10 px-4 py-2 text-sm font-bold text-white/80'>
Kategori pencarian dikosongkan otomatis hasil ditentukan
dari kata kunci dan jarak. GeoSeek mengejar minimal 10 hasil
terdekat bila data tersedia.
dari kata kunci dan jarak. Jika lokasi browser belum aktif,
keyword local SEO/kota memakai titik awal dari backend.
</div>
</div>
@ -1327,7 +1600,7 @@ export default function Starter() {
</h2>
</div>
<span className='rounded-full bg-[#073B3A] px-3 py-1 text-xs font-bold text-white'>
{location?.label || 'Lokasi saya belum aktif'}
{activeLocationLabel}
</span>
</div>
<div className='relative h-80 overflow-hidden rounded-[1.6rem] bg-[#D9E7D8]'>
@ -1394,7 +1667,7 @@ export default function Starter() {
<p className='mt-3 max-w-3xl text-[#5D6B62]'>
Lokasi:{' '}
<strong>
{location?.label || 'Belum aktif — izinkan lokasi browser'}
{activeLocationLabel}
</strong>
. Radius aktif:{' '}
<strong>
@ -1405,6 +1678,12 @@ export default function Starter() {
{searchMeta.expanded_for_nearest
? ` Radius awal ${formatRadius(radiusKm)} belum memenuhi target minimal 10 hasil, jadi GeoSeek memperluas pencarian sampai ${formatRadius(searchMeta.effective_radius_km || radiusKm)} dan tetap mengurutkan berdasarkan jarak.`
: ''}
{searchMeta.search_origin?.source === 'query_keyword'
? ` Lokasi dipilih dari kata kunci: ${searchMeta.search_origin.label}.`
: ''}
{searchMeta.search_origin?.source === 'default_local_keyword'
? ` ${searchMeta.search_origin.note || 'Lokasi browser tidak aktif, GeoSeek memakai titik awal SEO lokal.'}`
: ''}
</p>
</div>
<BaseButton
@ -1453,13 +1732,21 @@ export default function Starter() {
</div>
) : null}
{!hasActiveLocation ? (
{loading ? (
<div className='grid gap-5 md:grid-cols-3'>
{[0, 1, 2].map((item) => (
<div
key={item}
className='h-96 animate-pulse rounded-[2rem] bg-white/70'
/>
))}
</div>
) : !activeSearchOrigin ? (
<div className='rounded-[2rem] border border-dashed border-[#087F6D]/40 bg-white p-10 text-center'>
<h3 className='text-2xl font-black'>Aktifkan Lokasi Saya dulu</h3>
<p className='mx-auto mt-3 max-w-xl text-[#5D6B62]'>
GeoSeek hanya memakai lokasi pengguna yang sedang mencari.
Pencarian berjalan setelah browser mengirim koordinat lokasi
Anda.
GeoSeek bisa memakai lokasi browser, kata kunci kota, atau
titik awal SEO lokal untuk menampilkan hasil pencarian.
</p>
<div className='mt-6'>
<button
@ -1471,15 +1758,6 @@ export default function Starter() {
</button>
</div>
</div>
) : loading ? (
<div className='grid gap-5 md:grid-cols-3'>
{[0, 1, 2].map((item) => (
<div
key={item}
className='h-96 animate-pulse rounded-[2rem] bg-white/70'
/>
))}
</div>
) : places.length ? (
<div className='grid gap-5 md:grid-cols-2 xl:grid-cols-3'>
{places.map((place) => (