GEOSEEK 2.0
This commit is contained in:
parent
1c09ee751e
commit
5d3fe9d7a2
@ -1,7 +1,6 @@
|
|||||||
|
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const FileDBApi = require('./file');
|
const FileDBApi = require('./file');
|
||||||
const crypto = require('crypto');
|
|
||||||
const Utils = require('../utils');
|
const Utils = require('../utils');
|
||||||
|
|
||||||
|
|
||||||
@ -9,6 +8,88 @@ const Utils = require('../utils');
|
|||||||
const Sequelize = db.Sequelize;
|
const Sequelize = db.Sequelize;
|
||||||
const Op = Sequelize.Op;
|
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 {
|
module.exports = class PlacesDBApi {
|
||||||
|
|
||||||
|
|
||||||
@ -122,6 +203,7 @@ module.exports = class PlacesDBApi {
|
|||||||
|
|
||||||
,
|
,
|
||||||
|
|
||||||
|
...buildPlacesPayload(data, true),
|
||||||
importHash: data.importHash || null,
|
importHash: data.importHash || null,
|
||||||
createdById: currentUser.id,
|
createdById: currentUser.id,
|
||||||
updatedById: currentUser.id,
|
updatedById: currentUser.id,
|
||||||
@ -266,6 +348,7 @@ module.exports = class PlacesDBApi {
|
|||||||
|
|
||||||
,
|
,
|
||||||
|
|
||||||
|
...buildPlacesPayload(item, true),
|
||||||
importHash: item.importHash || null,
|
importHash: item.importHash || null,
|
||||||
createdById: currentUser.id,
|
createdById: currentUser.id,
|
||||||
updatedById: currentUser.id,
|
updatedById: currentUser.id,
|
||||||
@ -365,6 +448,8 @@ module.exports = class PlacesDBApi {
|
|||||||
if (data.is_verified !== undefined) updatePayload.is_verified = data.is_verified;
|
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;
|
updatePayload.updatedById = currentUser.id;
|
||||||
|
|
||||||
await places.update(updatePayload, {transaction});
|
await places.update(updatePayload, {transaction});
|
||||||
@ -538,9 +623,6 @@ module.exports = class PlacesDBApi {
|
|||||||
|
|
||||||
offset = currentPage * limit;
|
offset = currentPage * limit;
|
||||||
|
|
||||||
const orderBy = null;
|
|
||||||
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
let include = [
|
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) {
|
if (filter.active !== undefined) {
|
||||||
where = {
|
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',
|
'name',
|
||||||
query,
|
query,
|
||||||
),
|
),
|
||||||
|
Utils.ilike(
|
||||||
|
'places',
|
||||||
|
'nama',
|
||||||
|
query,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const records = await db.places.findAll({
|
const records = await db.places.findAll({
|
||||||
attributes: [ 'id', 'name' ],
|
attributes: [ 'id', 'name', 'nama' ],
|
||||||
where,
|
where,
|
||||||
limit: limit ? Number(limit) : undefined,
|
limit: limit ? Number(limit) : undefined,
|
||||||
offset: offset ? Number(offset) : undefined,
|
offset: offset ? Number(offset) : undefined,
|
||||||
@ -976,7 +1140,7 @@ module.exports = class PlacesDBApi {
|
|||||||
|
|
||||||
return records.map((record) => ({
|
return records.map((record) => ({
|
||||||
id: record.id,
|
id: record.id,
|
||||||
label: record.name,
|
label: record.name || record.nama,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -14,6 +14,27 @@ name: {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
nama: {
|
||||||
|
type: DataTypes.STRING(255),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
kategori: {
|
||||||
|
type: DataTypes.STRING(100),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
subkategori: {
|
||||||
|
type: DataTypes.STRING(100),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
short_description: {
|
short_description: {
|
||||||
@ -35,6 +56,27 @@ address: {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
alamat: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
kelurahan: {
|
||||||
|
type: DataTypes.STRING(100),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
kecamatan: {
|
||||||
|
type: DataTypes.STRING(100),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
city: {
|
city: {
|
||||||
@ -42,6 +84,13 @@ city: {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
kota: {
|
||||||
|
type: DataTypes.STRING(100),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
province: {
|
province: {
|
||||||
@ -49,6 +98,13 @@ province: {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
provinsi: {
|
||||||
|
type: DataTypes.STRING(100),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
postal_code: {
|
postal_code: {
|
||||||
@ -56,17 +112,24 @@ postal_code: {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
kode_pos: {
|
||||||
|
type: DataTypes.STRING(20),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
latitude: {
|
latitude: {
|
||||||
type: DataTypes.DECIMAL,
|
type: DataTypes.DOUBLE,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
longitude: {
|
longitude: {
|
||||||
type: DataTypes.DECIMAL,
|
type: DataTypes.DOUBLE,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -77,6 +140,13 @@ phone_number: {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
telepon: {
|
||||||
|
type: DataTypes.STRING(100),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
whatsapp_number: {
|
whatsapp_number: {
|
||||||
@ -84,6 +154,13 @@ whatsapp_number: {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
whatsapp: {
|
||||||
|
type: DataTypes.STRING(100),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
email: {
|
email: {
|
||||||
@ -98,6 +175,13 @@ website_url: {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
website: {
|
||||||
|
type: DataTypes.STRING(255),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
google_maps_url: {
|
google_maps_url: {
|
||||||
@ -105,6 +189,13 @@ google_maps_url: {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
jam_buka: {
|
||||||
|
type: DataTypes.STRING(255),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
price_level: {
|
price_level: {
|
||||||
@ -141,6 +232,14 @@ rating_average: {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
rating: {
|
||||||
|
type: DataTypes.DECIMAL(2, 1),
|
||||||
|
defaultValue: 0,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
rating_count: {
|
rating_count: {
|
||||||
@ -148,25 +247,22 @@ rating_count: {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
review_count: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
defaultValue: 0,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
status: {
|
status: {
|
||||||
type: DataTypes.ENUM,
|
type: DataTypes.STRING(50),
|
||||||
|
defaultValue: 'aktif',
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
values: [
|
|
||||||
|
|
||||||
"draft",
|
|
||||||
|
|
||||||
|
|
||||||
"published",
|
|
||||||
|
|
||||||
|
|
||||||
"archived"
|
|
||||||
|
|
||||||
],
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
is_verified: {
|
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: {
|
importHash: {
|
||||||
|
|||||||
@ -16,6 +16,7 @@ const searchRoutes = require('./routes/search');
|
|||||||
const sqlRoutes = require('./routes/sql');
|
const sqlRoutes = require('./routes/sql');
|
||||||
const pexelsRoutes = require('./routes/pexels');
|
const pexelsRoutes = require('./routes/pexels');
|
||||||
const publicPlacesRoutes = require('./routes/publicPlaces');
|
const publicPlacesRoutes = require('./routes/publicPlaces');
|
||||||
|
const geoseekCollectorRoutes = require('./routes/geoseekCollector');
|
||||||
|
|
||||||
const openaiRoutes = require('./routes/openai');
|
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/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_opening_hours', passport.authenticate('jwt', {session: false}), place_opening_hoursRoutes);
|
||||||
|
|
||||||
app.use('/api/place_features', passport.authenticate('jwt', {session: false}), place_featuresRoutes);
|
app.use('/api/place_features', passport.authenticate('jwt', {session: false}), place_featuresRoutes);
|
||||||
|
|||||||
24
backend/src/routes/geoseekCollector.js
Normal file
24
backend/src/routes/geoseekCollector.js
Normal 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;
|
||||||
@ -29,6 +29,15 @@ router.use(checkCrudPermissions('places'));
|
|||||||
* name:
|
* name:
|
||||||
* type: string
|
* type: string
|
||||||
* default: name
|
* default: name
|
||||||
|
* nama:
|
||||||
|
* type: string
|
||||||
|
* default: nama
|
||||||
|
* kategori:
|
||||||
|
* type: string
|
||||||
|
* default: kategori
|
||||||
|
* subkategori:
|
||||||
|
* type: string
|
||||||
|
* default: subkategori
|
||||||
* short_description:
|
* short_description:
|
||||||
* type: string
|
* type: string
|
||||||
* default: short_description
|
* default: short_description
|
||||||
@ -38,34 +47,77 @@ router.use(checkCrudPermissions('places'));
|
|||||||
* address:
|
* address:
|
||||||
* type: string
|
* type: string
|
||||||
* default: address
|
* default: address
|
||||||
|
* alamat:
|
||||||
|
* type: string
|
||||||
|
* default: alamat
|
||||||
|
* kelurahan:
|
||||||
|
* type: string
|
||||||
|
* default: kelurahan
|
||||||
|
* kecamatan:
|
||||||
|
* type: string
|
||||||
|
* default: kecamatan
|
||||||
* city:
|
* city:
|
||||||
* type: string
|
* type: string
|
||||||
* default: city
|
* default: city
|
||||||
|
* kota:
|
||||||
|
* type: string
|
||||||
|
* default: kota
|
||||||
* province:
|
* province:
|
||||||
* type: string
|
* type: string
|
||||||
* default: province
|
* default: province
|
||||||
|
* provinsi:
|
||||||
|
* type: string
|
||||||
|
* default: provinsi
|
||||||
* postal_code:
|
* postal_code:
|
||||||
* type: string
|
* type: string
|
||||||
* default: postal_code
|
* default: postal_code
|
||||||
|
* kode_pos:
|
||||||
|
* type: string
|
||||||
|
* default: kode_pos
|
||||||
* phone_number:
|
* phone_number:
|
||||||
* type: string
|
* type: string
|
||||||
* default: phone_number
|
* default: phone_number
|
||||||
|
* telepon:
|
||||||
|
* type: string
|
||||||
|
* default: telepon
|
||||||
* whatsapp_number:
|
* whatsapp_number:
|
||||||
* type: string
|
* type: string
|
||||||
* default: whatsapp_number
|
* default: whatsapp_number
|
||||||
|
* whatsapp:
|
||||||
|
* type: string
|
||||||
|
* default: whatsapp
|
||||||
* email:
|
* email:
|
||||||
* type: string
|
* type: string
|
||||||
* default: email
|
* default: email
|
||||||
* website_url:
|
* website_url:
|
||||||
* type: string
|
* type: string
|
||||||
* default: website_url
|
* default: website_url
|
||||||
|
* website:
|
||||||
|
* type: string
|
||||||
|
* default: website
|
||||||
* google_maps_url:
|
* google_maps_url:
|
||||||
* type: string
|
* type: string
|
||||||
* default: google_maps_url
|
* default: google_maps_url
|
||||||
|
* jam_buka:
|
||||||
|
* type: string
|
||||||
|
* default: jam_buka
|
||||||
|
|
||||||
* rating_count:
|
* rating_count:
|
||||||
* type: integer
|
* type: integer
|
||||||
* format: int64
|
* 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:
|
* latitude:
|
||||||
* type: integer
|
* type: integer
|
||||||
@ -79,6 +131,9 @@ router.use(checkCrudPermissions('places'));
|
|||||||
* rating_average:
|
* rating_average:
|
||||||
* type: integer
|
* type: integer
|
||||||
* format: int64
|
* format: int64
|
||||||
|
* rating:
|
||||||
|
* type: number
|
||||||
|
* format: float
|
||||||
|
|
||||||
*
|
*
|
||||||
*
|
*
|
||||||
@ -127,8 +182,8 @@ router.use(checkCrudPermissions('places'));
|
|||||||
router.post('/', wrapAsync(async (req, res) => {
|
router.post('/', wrapAsync(async (req, res) => {
|
||||||
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`;
|
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`;
|
||||||
const link = new URL(referer);
|
const link = new URL(referer);
|
||||||
await PlacesService.create(req.body.data, req.currentUser, true, link.host);
|
const placeData = req.body.data || req.body;
|
||||||
const payload = true;
|
const payload = await PlacesService.create(placeData, req.currentUser, true, link.host);
|
||||||
res.status(200).send(payload);
|
res.status(200).send(payload);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -224,7 +279,9 @@ router.post('/bulk-import', wrapAsync(async (req, res) => {
|
|||||||
* description: Some server error
|
* description: Some server error
|
||||||
*/
|
*/
|
||||||
router.put('/:id', wrapAsync(async (req, res) => {
|
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;
|
const payload = true;
|
||||||
res.status(200).send(payload);
|
res.status(200).send(payload);
|
||||||
}));
|
}));
|
||||||
@ -300,7 +357,8 @@ router.delete('/:id', wrapAsync(async (req, res) => {
|
|||||||
* description: Some server error
|
* description: Some server error
|
||||||
*/
|
*/
|
||||||
router.post('/deleteByIds', wrapAsync(async (req, res) => {
|
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;
|
const payload = true;
|
||||||
res.status(200).send(payload);
|
res.status(200).send(payload);
|
||||||
}));
|
}));
|
||||||
@ -338,9 +396,10 @@ router.get('/', wrapAsync(async (req, res) => {
|
|||||||
req.query, { currentUser }
|
req.query, { currentUser }
|
||||||
);
|
);
|
||||||
if (filetype && filetype === 'csv') {
|
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',
|
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',
|
'rating_count','review_count','status','is_verified','verified','featured','popularitas',
|
||||||
'latitude','longitude','average_price','rating_average',
|
'sumber','external_id','external_type','last_synced_at',
|
||||||
|
'latitude','longitude','average_price','rating_average','rating',
|
||||||
|
|
||||||
];
|
];
|
||||||
const opts = { fields };
|
const opts = { fields };
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
462
backend/src/services/geoseekCollector.js
Normal file
462
backend/src/services/geoseekCollector.js
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -3,8 +3,6 @@ const PlacesDBApi = require('../db/api/places');
|
|||||||
const processFile = require("../middlewares/upload");
|
const processFile = require("../middlewares/upload");
|
||||||
const ValidationError = require('./notifications/errors/validation');
|
const ValidationError = require('./notifications/errors/validation');
|
||||||
const csv = require('csv-parser');
|
const csv = require('csv-parser');
|
||||||
const axios = require('axios');
|
|
||||||
const config = require('../config');
|
|
||||||
const stream = require('stream');
|
const stream = require('stream');
|
||||||
|
|
||||||
|
|
||||||
@ -15,7 +13,7 @@ module.exports = class PlacesService {
|
|||||||
static async create(data, currentUser) {
|
static async create(data, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
await PlacesDBApi.create(
|
const createdPlaces = await PlacesDBApi.create(
|
||||||
data,
|
data,
|
||||||
{
|
{
|
||||||
currentUser,
|
currentUser,
|
||||||
@ -24,13 +22,14 @@ module.exports = class PlacesService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
|
return createdPlaces;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
static async bulkImport(req, res, sendInvitationEmails = true, host) {
|
static async bulkImport(req, res) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -95,7 +94,7 @@ module.exports = class PlacesService {
|
|||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
static async deleteByIds(ids, currentUser) {
|
static async deleteByIds(ids, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|||||||
@ -131,6 +131,16 @@ type LocationState = {
|
|||||||
label: string;
|
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 = {
|
type DistanceBucket = {
|
||||||
radius_km: number;
|
radius_km: number;
|
||||||
label: string;
|
label: string;
|
||||||
@ -165,6 +175,7 @@ type SearchMeta = {
|
|||||||
expanded_for_nearest?: boolean;
|
expanded_for_nearest?: boolean;
|
||||||
external_sources?: string[];
|
external_sources?: string[];
|
||||||
external_source_errors?: string[];
|
external_source_errors?: string[];
|
||||||
|
search_origin?: SearchOrigin | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const LOCATION_REQUIRED_MESSAGE =
|
const LOCATION_REQUIRED_MESSAGE =
|
||||||
@ -173,6 +184,136 @@ const LOCATION_REQUIRED_MESSAGE =
|
|||||||
const DEFAULT_RADIUS_KM = 5;
|
const DEFAULT_RADIUS_KM = 5;
|
||||||
const GLOBAL_RADIUS_KM = 20038;
|
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[] = [
|
const fallbackRadiusZones: RadiusZone[] = [
|
||||||
{ value: 1, label: 'Walking Zone', range: '0–1 Km' },
|
{ value: 1, label: 'Walking Zone', range: '0–1 Km' },
|
||||||
{ value: 5, label: 'Neighborhood Zone', range: '1–5 Km' },
|
{ value: 5, label: 'Neighborhood Zone', range: '1–5 Km' },
|
||||||
@ -518,6 +659,28 @@ const topLocationKeywords: KeywordChip[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const categoryKeywordGroups: KeywordGroup[] = [
|
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',
|
title: 'Kuliner',
|
||||||
description:
|
description:
|
||||||
@ -525,15 +688,33 @@ const categoryKeywordGroups: KeywordGroup[] = [
|
|||||||
keywords: [
|
keywords: [
|
||||||
{ label: 'Makanan terdekat', query: 'makanan terdekat' },
|
{ label: 'Makanan terdekat', query: 'makanan terdekat' },
|
||||||
{ label: 'Restoran terdekat', query: 'restoran 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 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: '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: 'Warteg terdekat', query: 'warteg terdekat' },
|
||||||
{ label: 'Bakso terdekat', query: 'bakso terdekat' },
|
{ label: 'Bakso terdekat', query: 'bakso terdekat' },
|
||||||
{ label: 'Soto terdekat', query: 'soto terdekat' },
|
{ label: 'Soto terdekat', query: 'soto terdekat' },
|
||||||
{ label: 'Ayam goreng terdekat', query: 'ayam goreng terdekat' },
|
{ label: 'Ayam goreng terdekat', query: 'ayam goreng terdekat' },
|
||||||
{ label: 'Seafood terdekat', query: 'seafood 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: 'Catering terdekat', query: 'catering terdekat' },
|
||||||
|
{ label: 'Katering sehat', query: 'katering sehat' },
|
||||||
{ label: 'Minuman terdekat', query: 'minuman terdekat' },
|
{ 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: 'Skincare terdekat', query: 'toko skincare terdekat' },
|
||||||
{ label: 'Herbal terdekat', query: 'toko herbal terdekat' },
|
{ label: 'Herbal terdekat', query: 'toko herbal terdekat' },
|
||||||
{ label: 'Obat terdekat', query: 'toko obat 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: 'Furniture terdekat', query: 'toko furniture terdekat' },
|
||||||
{
|
{
|
||||||
label: 'Peralatan rumah tangga',
|
label: 'Peralatan rumah tangga',
|
||||||
@ -584,6 +770,8 @@ const categoryKeywordGroups: KeywordGroup[] = [
|
|||||||
{ label: 'Tambal ban', query: 'tambal ban terdekat' },
|
{ label: 'Tambal ban', query: 'tambal ban terdekat' },
|
||||||
{ label: 'Cuci mobil', query: 'cuci mobil terdekat' },
|
{ label: 'Cuci mobil', query: 'cuci mobil terdekat' },
|
||||||
{ label: 'SPBU terdekat', query: 'SPBU 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: 'Rumah sakit terdekat', query: 'rumah sakit terdekat' },
|
||||||
{ label: 'Klinik terdekat', query: 'klinik terdekat' },
|
{ label: 'Klinik terdekat', query: 'klinik terdekat' },
|
||||||
{ label: 'Apotek terdekat', query: 'apotek 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 terdekat', query: 'dokter terdekat' },
|
||||||
{ label: 'Dokter gigi', query: 'dokter gigi terdekat' },
|
{ label: 'Dokter gigi', query: 'dokter gigi terdekat' },
|
||||||
|
{ label: 'Bidan terdekat', query: 'bidan terdekat' },
|
||||||
{ label: 'Laboratorium', query: 'laboratorium terdekat' },
|
{ label: 'Laboratorium', query: 'laboratorium terdekat' },
|
||||||
{ label: 'Alat kesehatan', query: 'alat kesehatan terdekat' },
|
{ label: 'Alat kesehatan', query: 'alat kesehatan terdekat' },
|
||||||
{ label: 'Pet shop', query: 'pet shop terdekat' },
|
{ label: 'Pet shop', query: 'pet shop terdekat' },
|
||||||
@ -662,6 +853,10 @@ const categoryKeywordGroups: KeywordGroup[] = [
|
|||||||
keywords: [
|
keywords: [
|
||||||
{ label: 'Bank terdekat', query: 'bank terdekat' },
|
{ label: 'Bank terdekat', query: 'bank terdekat' },
|
||||||
{ label: 'ATM terdekat', query: 'ATM 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: 'Koperasi terdekat', query: 'koperasi terdekat' },
|
||||||
{ label: 'Pegadaian terdekat', query: 'pegadaian terdekat' },
|
{ label: 'Pegadaian terdekat', query: 'pegadaian terdekat' },
|
||||||
],
|
],
|
||||||
@ -687,6 +882,9 @@ const categoryKeywordGroups: KeywordGroup[] = [
|
|||||||
keywords: [
|
keywords: [
|
||||||
{ label: 'Salon terdekat', query: 'salon terdekat' },
|
{ label: 'Salon terdekat', query: 'salon terdekat' },
|
||||||
{ label: 'Barbershop terdekat', query: 'barbershop 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: 'Skincare terdekat', query: 'skincare terdekat' },
|
||||||
{ label: 'Kosmetik terdekat', query: 'kosmetik terdekat' },
|
{ label: 'Kosmetik terdekat', query: 'kosmetik terdekat' },
|
||||||
],
|
],
|
||||||
@ -725,12 +923,57 @@ const categoryKeywordGroups: KeywordGroup[] = [
|
|||||||
description: 'Intent tempat wisata, hotel, penginapan, villa, dan pantai.',
|
description: 'Intent tempat wisata, hotel, penginapan, villa, dan pantai.',
|
||||||
keywords: [
|
keywords: [
|
||||||
{ label: 'Tempat wisata', query: 'tempat wisata terdekat' },
|
{ 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: 'Hotel terdekat', query: 'hotel terdekat' },
|
||||||
{ label: 'Penginapan terdekat', query: 'penginapan terdekat' },
|
{ label: 'Penginapan terdekat', query: 'penginapan terdekat' },
|
||||||
{ label: 'Villa terdekat', query: 'villa terdekat' },
|
{ label: 'Villa terdekat', query: 'villa terdekat' },
|
||||||
|
{ label: 'Homestay terdekat', query: 'homestay terdekat' },
|
||||||
{ label: 'Pantai terdekat', query: 'pantai 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',
|
title: 'Pemerintahan & BUMN',
|
||||||
description:
|
description:
|
||||||
@ -979,6 +1222,19 @@ export default function Starter() {
|
|||||||
const featuredPlaces = places.slice(0, 3);
|
const featuredPlaces = places.slice(0, 3);
|
||||||
const formulaEntries = Object.entries(searchMeta.geo_score_formula || {});
|
const formulaEntries = Object.entries(searchMeta.geo_score_formula || {});
|
||||||
const hasActiveLocation = locationReady && Boolean(location);
|
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 =
|
const openNow =
|
||||||
searchMeta.trending?.live?.open_now ||
|
searchMeta.trending?.live?.open_now ||
|
||||||
places.filter((place) => place.live_status?.status === 'open').length;
|
places.filter((place) => place.live_status?.status === 'open').length;
|
||||||
@ -993,22 +1249,36 @@ export default function Starter() {
|
|||||||
const fetchPlaces = useCallback(
|
const fetchPlaces = useCallback(
|
||||||
async (
|
async (
|
||||||
nextQuery: string,
|
nextQuery: string,
|
||||||
nextLocation: LocationState,
|
nextLocation: LocationState | null,
|
||||||
nextRadiusKm: number,
|
nextRadiusKm: number,
|
||||||
) => {
|
) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.get('/public/places', {
|
const params: {
|
||||||
params: {
|
q: string;
|
||||||
q: nextQuery,
|
radiusKm: number;
|
||||||
lat: nextLocation.lat,
|
limit: number;
|
||||||
lng: nextLocation.lng,
|
lat?: number;
|
||||||
radiusKm: nextRadiusKm,
|
lng?: number;
|
||||||
limit: 30,
|
} = {
|
||||||
},
|
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 : []);
|
setPlaces(Array.isArray(response.data?.rows) ? response.data.rows : []);
|
||||||
setSearchMeta({
|
setSearchMeta({
|
||||||
@ -1042,6 +1312,7 @@ export default function Starter() {
|
|||||||
)
|
)
|
||||||
? response.data.external_source_errors
|
? response.data.external_source_errors
|
||||||
: [],
|
: [],
|
||||||
|
search_origin: responseSearchOrigin,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Gagal memuat GeoSeek public search', err);
|
console.error('Gagal memuat GeoSeek public search', err);
|
||||||
@ -1125,10 +1396,10 @@ export default function Starter() {
|
|||||||
const handleSearch = async (event: React.FormEvent<HTMLFormElement>) => {
|
const handleSearch = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
if (!locationReady || !location) {
|
if (!canSubmitSearch) {
|
||||||
setError(LOCATION_REQUIRED_MESSAGE);
|
setError(LOCATION_REQUIRED_MESSAGE);
|
||||||
setLocationStatus(
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@ -1155,11 +1426,11 @@ export default function Starter() {
|
|||||||
return place.google_maps_url;
|
return place.google_maps_url;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!location) {
|
if (!activeSearchOrigin) {
|
||||||
return '#search';
|
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) =>
|
const shouldOpenExternalPlace = (place: PublicPlace) =>
|
||||||
@ -1261,13 +1532,13 @@ export default function Starter() {
|
|||||||
<input
|
<input
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
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'
|
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>
|
</label>
|
||||||
<button
|
<button
|
||||||
type='submit'
|
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'
|
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
|
Cari
|
||||||
@ -1288,7 +1559,9 @@ export default function Starter() {
|
|||||||
dikosongkan •{' '}
|
dikosongkan •{' '}
|
||||||
{hasActiveLocation
|
{hasActiveLocation
|
||||||
? 'Live search aktif'
|
? 'Live search aktif'
|
||||||
: 'Menunggu izin lokasi'}
|
: canUseKeywordLocation
|
||||||
|
? 'Bisa cari dari keyword/kota'
|
||||||
|
: 'Menunggu izin lokasi'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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'>
|
<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
|
Kategori pencarian dikosongkan otomatis — hasil ditentukan
|
||||||
dari kata kunci dan jarak. GeoSeek mengejar minimal 10 hasil
|
dari kata kunci dan jarak. Jika lokasi browser belum aktif,
|
||||||
terdekat bila data tersedia.
|
keyword local SEO/kota memakai titik awal dari backend.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1327,7 +1600,7 @@ export default function Starter() {
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<span className='rounded-full bg-[#073B3A] px-3 py-1 text-xs font-bold text-white'>
|
<span className='rounded-full bg-[#073B3A] px-3 py-1 text-xs font-bold text-white'>
|
||||||
{location?.label || 'Lokasi saya belum aktif'}
|
{activeLocationLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className='relative h-80 overflow-hidden rounded-[1.6rem] bg-[#D9E7D8]'>
|
<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]'>
|
<p className='mt-3 max-w-3xl text-[#5D6B62]'>
|
||||||
Lokasi:{' '}
|
Lokasi:{' '}
|
||||||
<strong>
|
<strong>
|
||||||
{location?.label || 'Belum aktif — izinkan lokasi browser'}
|
{activeLocationLabel}
|
||||||
</strong>
|
</strong>
|
||||||
. Radius aktif:{' '}
|
. Radius aktif:{' '}
|
||||||
<strong>
|
<strong>
|
||||||
@ -1405,6 +1678,12 @@ export default function Starter() {
|
|||||||
{searchMeta.expanded_for_nearest
|
{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.`
|
? ` 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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
@ -1453,13 +1732,21 @@ export default function Starter() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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'>
|
<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>
|
<h3 className='text-2xl font-black'>Aktifkan Lokasi Saya dulu</h3>
|
||||||
<p className='mx-auto mt-3 max-w-xl text-[#5D6B62]'>
|
<p className='mx-auto mt-3 max-w-xl text-[#5D6B62]'>
|
||||||
GeoSeek hanya memakai lokasi pengguna yang sedang mencari.
|
GeoSeek bisa memakai lokasi browser, kata kunci kota, atau
|
||||||
Pencarian berjalan setelah browser mengirim koordinat lokasi
|
titik awal SEO lokal untuk menampilkan hasil pencarian.
|
||||||
Anda.
|
|
||||||
</p>
|
</p>
|
||||||
<div className='mt-6'>
|
<div className='mt-6'>
|
||||||
<button
|
<button
|
||||||
@ -1471,15 +1758,6 @@ export default function Starter() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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 ? (
|
) : places.length ? (
|
||||||
<div className='grid gap-5 md:grid-cols-2 xl:grid-cols-3'>
|
<div className='grid gap-5 md:grid-cols-2 xl:grid-cols-3'>
|
||||||
{places.map((place) => (
|
{places.map((place) => (
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user