diff --git a/backend/src/db/migrations/20260617123000-create-place-offerings.js b/backend/src/db/migrations/20260617123000-create-place-offerings.js
new file mode 100644
index 0000000..50c843b
--- /dev/null
+++ b/backend/src/db/migrations/20260617123000-create-place-offerings.js
@@ -0,0 +1,133 @@
+'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');
+};
+
+module.exports = {
+ up: async (queryInterface, Sequelize) => {
+ const transaction = await queryInterface.sequelize.transaction();
+
+ try {
+ try {
+ await queryInterface.describeTable('place_offerings');
+ await transaction.commit();
+ return;
+ } catch (err) {
+ if (!isMissingTableError(err)) {
+ throw err;
+ }
+ }
+
+ await queryInterface.createTable('place_offerings', {
+ id: {
+ type: Sequelize.DataTypes.UUID,
+ defaultValue: Sequelize.DataTypes.UUIDV4,
+ primaryKey: true,
+ },
+ placeId: {
+ type: Sequelize.DataTypes.UUID,
+ allowNull: false,
+ references: {
+ key: 'id',
+ model: 'places',
+ },
+ },
+ name: {
+ type: Sequelize.DataTypes.TEXT,
+ allowNull: false,
+ },
+ description: {
+ type: Sequelize.DataTypes.TEXT,
+ },
+ offering_type: {
+ type: Sequelize.DataTypes.ENUM('product', 'service'),
+ allowNull: false,
+ defaultValue: 'product',
+ },
+ price: {
+ type: Sequelize.DataTypes.DECIMAL,
+ },
+ stock_status: {
+ type: Sequelize.DataTypes.ENUM('in_stock', 'limited', 'out_of_stock', 'by_request'),
+ allowNull: false,
+ defaultValue: 'by_request',
+ },
+ stock_quantity: {
+ type: Sequelize.DataTypes.INTEGER,
+ },
+ is_verified: {
+ type: Sequelize.DataTypes.BOOLEAN,
+ allowNull: false,
+ defaultValue: false,
+ },
+ is_active: {
+ type: Sequelize.DataTypes.BOOLEAN,
+ allowNull: false,
+ defaultValue: true,
+ },
+ last_stock_update: {
+ type: Sequelize.DataTypes.DATE,
+ },
+ createdAt: {
+ type: Sequelize.DataTypes.DATE,
+ },
+ updatedAt: {
+ type: Sequelize.DataTypes.DATE,
+ },
+ deletedAt: {
+ type: Sequelize.DataTypes.DATE,
+ },
+ importHash: {
+ type: Sequelize.DataTypes.STRING(255),
+ allowNull: true,
+ unique: true,
+ },
+ }, { transaction });
+
+ await queryInterface.addIndex('place_offerings', ['placeId'], {
+ transaction,
+ });
+ await queryInterface.addIndex('place_offerings', ['offering_type', 'stock_status'], {
+ transaction,
+ });
+ await queryInterface.addIndex('place_offerings', ['is_active'], {
+ transaction,
+ });
+
+ await transaction.commit();
+ } catch (err) {
+ await transaction.rollback();
+ throw err;
+ }
+ },
+
+ down: async (queryInterface) => {
+ const transaction = await queryInterface.sequelize.transaction();
+
+ try {
+ let tableExists = true;
+ try {
+ await queryInterface.describeTable('place_offerings');
+ } catch (err) {
+ if (!isMissingTableError(err)) {
+ throw err;
+ }
+ tableExists = false;
+ }
+
+ if (tableExists) {
+ await queryInterface.dropTable('place_offerings', { transaction });
+ }
+ await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_place_offerings_offering_type";', { transaction });
+ await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_place_offerings_stock_status";', { transaction });
+ await transaction.commit();
+ } catch (err) {
+ await transaction.rollback();
+ throw err;
+ }
+ },
+};
diff --git a/backend/src/db/models/place_offerings.js b/backend/src/db/models/place_offerings.js
new file mode 100644
index 0000000..19c7dc7
--- /dev/null
+++ b/backend/src/db/models/place_offerings.js
@@ -0,0 +1,80 @@
+module.exports = function(sequelize, DataTypes) {
+ const place_offerings = sequelize.define(
+ 'place_offerings',
+ {
+ id: {
+ type: DataTypes.UUID,
+ defaultValue: DataTypes.UUIDV4,
+ primaryKey: true,
+ },
+ name: {
+ type: DataTypes.TEXT,
+ allowNull: false,
+ },
+ description: {
+ type: DataTypes.TEXT,
+ },
+ offering_type: {
+ type: DataTypes.ENUM,
+ values: ['product', 'service'],
+ allowNull: false,
+ defaultValue: 'product',
+ },
+ price: {
+ type: DataTypes.DECIMAL,
+ },
+ stock_status: {
+ type: DataTypes.ENUM,
+ values: ['in_stock', 'limited', 'out_of_stock', 'by_request'],
+ allowNull: false,
+ defaultValue: 'by_request',
+ },
+ stock_quantity: {
+ type: DataTypes.INTEGER,
+ },
+ is_verified: {
+ type: DataTypes.BOOLEAN,
+ allowNull: false,
+ defaultValue: false,
+ },
+ is_active: {
+ type: DataTypes.BOOLEAN,
+ allowNull: false,
+ defaultValue: true,
+ },
+ last_stock_update: {
+ type: DataTypes.DATE,
+ },
+ importHash: {
+ type: DataTypes.STRING(255),
+ allowNull: true,
+ unique: true,
+ },
+ },
+ {
+ timestamps: true,
+ paranoid: true,
+ freezeTableName: true,
+ },
+ );
+
+ place_offerings.associate = (db) => {
+ db.place_offerings.belongsTo(db.places, {
+ as: 'place',
+ foreignKey: {
+ name: 'placeId',
+ },
+ constraints: false,
+ });
+
+ db.place_offerings.belongsTo(db.users, {
+ as: 'createdBy',
+ });
+
+ db.place_offerings.belongsTo(db.users, {
+ as: 'updatedBy',
+ });
+ };
+
+ return place_offerings;
+};
diff --git a/backend/src/db/models/places.js b/backend/src/db/models/places.js
index 817b10b..2775f34 100644
--- a/backend/src/db/models/places.js
+++ b/backend/src/db/models/places.js
@@ -1,8 +1,3 @@
-const config = require('../../config');
-const providers = config.providers;
-const crypto = require('crypto');
-const bcrypt = require('bcrypt');
-const moment = require('moment');
module.exports = function(sequelize, DataTypes) {
const places = sequelize.define(
@@ -227,6 +222,15 @@ is_verified: {
});
+ db.places.hasMany(db.place_offerings, {
+ as: 'offerings',
+ foreignKey: {
+ name: 'placeId',
+ },
+ constraints: false,
+ });
+
+
db.places.hasMany(db.reviews, {
as: 'reviews_place',
foreignKey: {
diff --git a/backend/src/db/seeders/20260617120000-public-search-quality-data.js b/backend/src/db/seeders/20260617120000-public-search-quality-data.js
new file mode 100644
index 0000000..a53f58c
--- /dev/null
+++ b/backend/src/db/seeders/20260617120000-public-search-quality-data.js
@@ -0,0 +1,1010 @@
+const CATEGORY_IMPORT_PREFIX = 'public-search-category:';
+const PLACE_IMPORT_PREFIX = 'public-search-place:';
+const OFFERING_IMPORT_PREFIX = 'public-search-offering:';
+
+const categories = [
+ {
+ id: '11111111-1111-4111-8111-111111111111',
+ name: 'Toko Interior',
+ slug: 'toko-interior',
+ description: 'Toko perabot, furniture, kitchen set, dekorasi, dan jasa desain interior.',
+ color_hex: '#2563EB',
+ icon_name: 'mdiSofa',
+ },
+ {
+ id: '22222222-2222-4222-8222-222222222222',
+ name: 'Cafe Melayu',
+ slug: 'cafe-melayu',
+ description: 'Cafe, kedai kopi, dan kuliner khas Melayu atau Riau.',
+ color_hex: '#16A34A',
+ icon_name: 'mdiCoffeeOutline',
+ },
+ {
+ id: '33333333-3333-4333-8333-333333333333',
+ name: 'Bengkel Mobil',
+ slug: 'bengkel-mobil',
+ description: 'Bengkel servis mobil, ganti oli, tune up, mesin, ban, dan kaki-kaki.',
+ color_hex: '#DC2626',
+ icon_name: 'mdiCarWrench',
+ },
+ {
+ id: '44444444-4444-4444-8444-444444444444',
+ name: 'Jasa AC & Elektronik',
+ slug: 'jasa-ac-elektronik',
+ description: 'Teknisi AC, cuci AC, isi freon, servis elektronik rumah, dan booking jasa datang ke lokasi.',
+ color_hex: '#0891B2',
+ icon_name: 'mdiAirConditioner',
+ },
+ {
+ id: '55555555-5555-4555-8555-555555555555',
+ name: 'Toko Herbal & Kesehatan',
+ slug: 'toko-herbal-kesehatan',
+ description: 'Toko herbal, jamu, madu, produk kesehatan alami, dan edukasi hidup sehat lokal.',
+ color_hex: '#65A30D',
+ icon_name: 'mdiLeaf',
+ },
+];
+
+const places = [
+ {
+ id: 'aaaaaaaa-1111-4111-8111-aaaaaaaa1111',
+ name: 'Riau Interior Studio',
+ categorySlug: 'toko-interior',
+ short_description: 'Toko interior, desain ruangan, custom furniture, dan kitchen set dekat pusat kota.',
+ full_description: 'Melayani desain interior rumah, kitchen set, lemari, partisi, sofa custom, dan konsultasi gratis. Cocok untuk renovasi rumah, kantor kecil, dan ruang usaha.',
+ address: 'Jl. Riau No. 45, Pekanbaru',
+ city: 'Pekanbaru',
+ province: 'Riau',
+ postal_code: '28112',
+ latitude: 0.5199,
+ longitude: 101.4512,
+ phone_number: '+62761234567',
+ whatsapp_number: '+6281270001111',
+ email: 'cs@riauinteriorstudio.id',
+ website_url: 'https://riauinteriorstudio.id',
+ google_maps_url: 'https://maps.google.com/?q=0.5199,101.4512',
+ price_level: 'budget',
+ average_price: 2500000,
+ rating_average: 4.6,
+ rating_count: 128,
+ is_verified: true,
+ },
+ {
+ id: 'aaaaaaaa-2222-4222-8222-aaaaaaaa2222',
+ name: 'Nusantara Mebel & Dekor',
+ categorySlug: 'toko-interior',
+ short_description: 'Mebel, sofa, lemari, meja makan, dan dekorasi interior siap pakai.',
+ full_description: 'Pilihan furniture rumah dan kantor dengan layanan antar. Tersedia perabot minimalis, dekorasi dinding, sofa, lemari, dan meja custom.',
+ address: 'Jl. Tuanku Tambusai No. 88, Pekanbaru',
+ city: 'Pekanbaru',
+ province: 'Riau',
+ postal_code: '28282',
+ latitude: 0.5206,
+ longitude: 101.439,
+ phone_number: '+62761771088',
+ whatsapp_number: '+6281270004444',
+ email: 'halo@nusantaramebel.id',
+ website_url: 'https://nusantaramebel.id',
+ google_maps_url: 'https://maps.google.com/?q=0.5206,101.4390',
+ price_level: 'midrange',
+ average_price: 1800000,
+ rating_average: 4.5,
+ rating_count: 96,
+ is_verified: true,
+ },
+ {
+ id: 'aaaaaaaa-3333-4333-8333-aaaaaaaa3333',
+ name: 'Dapur Indah Kitchen Set',
+ categorySlug: 'toko-interior',
+ short_description: 'Spesialis kitchen set, kabinet dapur, lemari, dan desain interior dapur.',
+ full_description: 'Jasa ukur lokasi, desain 3D sederhana, produksi kitchen set, kabinet bawah tangga, dan lemari pakaian custom dengan pilihan material budget sampai premium.',
+ address: 'Jl. Paus No. 31, Pekanbaru',
+ city: 'Pekanbaru',
+ province: 'Riau',
+ postal_code: '28124',
+ latitude: 0.4967,
+ longitude: 101.4562,
+ phone_number: '+62761818031',
+ whatsapp_number: '+6281270005555',
+ email: 'order@dapurindah.id',
+ website_url: 'https://dapurindah.id',
+ google_maps_url: 'https://maps.google.com/?q=0.4967,101.4562',
+ price_level: 'premium',
+ average_price: 6500000,
+ rating_average: 4.7,
+ rating_count: 74,
+ is_verified: true,
+ },
+ {
+ id: 'bbbbbbbb-1111-4111-8111-bbbbbbbb1111',
+ name: 'Kopi Selasih Melayu',
+ categorySlug: 'cafe-melayu',
+ short_description: 'Cafe khas Melayu dengan kopi saring, teh tarik, roti jala, dan kue tradisional.',
+ full_description: 'Menu unggulan: kopi saring, teh tarik, roti jala kari, bolu kemojo, lempeng kelapa, dan camilan khas Riau. Suasana cocok untuk keluarga dan kerja santai.',
+ address: 'Jl. Sudirman No. 120, Pekanbaru',
+ city: 'Pekanbaru',
+ province: 'Riau',
+ postal_code: '28116',
+ latitude: 0.5095,
+ longitude: 101.4549,
+ phone_number: '+62761876543',
+ whatsapp_number: '+6281270002222',
+ email: 'hello@kopiselasih.co',
+ website_url: 'https://kopiselasih.co',
+ google_maps_url: 'https://maps.google.com/?q=0.5095,101.4549',
+ price_level: 'midrange',
+ average_price: 35000,
+ rating_average: 4.4,
+ rating_count: 342,
+ is_verified: true,
+ },
+ {
+ id: 'bbbbbbbb-2222-4222-8222-bbbbbbbb2222',
+ name: 'Kedai Laksamana Riau',
+ categorySlug: 'cafe-melayu',
+ short_description: 'Kedai kopi dan kuliner Melayu: laksa, nasi lemak, teh tarik, dan kopi kampung.',
+ full_description: 'Tempat makan santai dengan menu Melayu Riau, kopi kampung, teh tarik, laksa, nasi lemak, dan paket sarapan. Lokasi mudah dijangkau dari pusat kota.',
+ address: 'Jl. Diponegoro No. 17, Pekanbaru',
+ city: 'Pekanbaru',
+ province: 'Riau',
+ postal_code: '28131',
+ latitude: 0.5142,
+ longitude: 101.4485,
+ phone_number: '+62761771717',
+ whatsapp_number: '+6281270006666',
+ email: 'kedai@laksamanariau.id',
+ website_url: 'https://laksamanariau.id',
+ google_maps_url: 'https://maps.google.com/?q=0.5142,101.4485',
+ price_level: 'budget',
+ average_price: 28000,
+ rating_average: 4.6,
+ rating_count: 188,
+ is_verified: true,
+ },
+ {
+ id: 'bbbbbbbb-3333-4333-8333-bbbbbbbb3333',
+ name: 'Warung Roti Jala Mak Long',
+ categorySlug: 'cafe-melayu',
+ short_description: 'Warung makan Melayu rumahan dengan roti jala kari, kue tradisional, dan kopi saring.',
+ full_description: 'Pilihan sarapan dan makan siang khas Melayu: roti jala, laksa, lempeng, kue talam, dan kopi saring. Area parkir motor tersedia.',
+ address: 'Jl. Kuantan Raya No. 9, Pekanbaru',
+ city: 'Pekanbaru',
+ province: 'Riau',
+ postal_code: '28143',
+ latitude: 0.5023,
+ longitude: 101.4617,
+ phone_number: '+62761770009',
+ whatsapp_number: '+6281270007777',
+ email: 'maklong@rotijala.id',
+ website_url: 'https://rotijala.id',
+ google_maps_url: 'https://maps.google.com/?q=0.5023,101.4617',
+ price_level: 'budget',
+ average_price: 22000,
+ rating_average: 4.3,
+ rating_count: 139,
+ is_verified: false,
+ },
+ {
+ id: 'cccccccc-1111-4111-8111-cccccccc1111',
+ name: 'Bengkel Mobil Mandiri Jaya',
+ categorySlug: 'bengkel-mobil',
+ short_description: 'Bengkel mobil umum dengan servis berkala, ganti oli, tune up, dan diagnosa cepat.',
+ full_description: 'Layanan: servis berkala, ganti oli, tune up, kaki-kaki, rem, aki, dan diagnosa mesin. Estimasi biaya dijelaskan sebelum pengerjaan.',
+ address: 'Jl. Arifin Ahmad No. 18, Pekanbaru',
+ city: 'Pekanbaru',
+ province: 'Riau',
+ postal_code: '28125',
+ latitude: 0.4989,
+ longitude: 101.4231,
+ phone_number: '+62761123456',
+ whatsapp_number: '+6281270003333',
+ email: 'service@mandirijaya-auto.id',
+ website_url: 'https://mandirijaya-auto.id',
+ google_maps_url: 'https://maps.google.com/?q=0.4989,101.4231',
+ price_level: 'unknown',
+ average_price: 400000,
+ rating_average: 4.5,
+ rating_count: 211,
+ is_verified: true,
+ },
+ {
+ id: 'cccccccc-2222-4222-8222-cccccccc2222',
+ name: 'AutoCare Hangtuah',
+ categorySlug: 'bengkel-mobil',
+ short_description: 'Servis mobil, spooring balancing, ganti oli, aki, rem, dan ban.',
+ full_description: 'Bengkel otomotif untuk perawatan mobil harian. Menangani ganti oli, tune up ringan, rem, aki, ban, spooring balancing, dan pengecekan mesin.',
+ address: 'Jl. Hang Tuah No. 66, Pekanbaru',
+ city: 'Pekanbaru',
+ province: 'Riau',
+ postal_code: '28155',
+ latitude: 0.5262,
+ longitude: 101.463,
+ phone_number: '+62761770666',
+ whatsapp_number: '+6281270008888',
+ email: 'booking@autocarehangtuah.id',
+ website_url: 'https://autocarehangtuah.id',
+ google_maps_url: 'https://maps.google.com/?q=0.5262,101.4630',
+ price_level: 'midrange',
+ average_price: 350000,
+ rating_average: 4.7,
+ rating_count: 167,
+ is_verified: true,
+ },
+ {
+ id: 'cccccccc-3333-4333-8333-cccccccc3333',
+ name: 'Bengkel Kaki-Kaki Tuah Karya',
+ categorySlug: 'bengkel-mobil',
+ short_description: 'Spesialis kaki-kaki mobil, shockbreaker, bushing, rem, dan pengecekan bunyi.',
+ full_description: 'Bengkel mobil untuk keluhan kaki-kaki, stir getar, rem, shockbreaker, bearing, dan bunyi pada kolong mobil. Tersedia konsultasi awal.',
+ address: 'Jl. Soekarno Hatta No. 210, Pekanbaru',
+ city: 'Pekanbaru',
+ province: 'Riau',
+ postal_code: '28294',
+ latitude: 0.4928,
+ longitude: 101.4377,
+ phone_number: '+62761770210',
+ whatsapp_number: '+6281270009999',
+ email: 'admin@tuahkarya-auto.id',
+ website_url: 'https://tuahkarya-auto.id',
+ google_maps_url: 'https://maps.google.com/?q=0.4928,101.4377',
+ price_level: 'midrange',
+ average_price: 450000,
+ rating_average: 4.4,
+ rating_count: 118,
+ is_verified: false,
+ },
+ {
+ id: 'ddddcccc-1111-4111-8111-ddddcccc1111',
+ name: 'Sejuk Mandiri Service AC',
+ categorySlug: 'jasa-ac-elektronik',
+ short_description: 'Jasa AC terdekat untuk cuci AC, isi freon, bongkar pasang, dan servis AC rumah.',
+ full_description: 'Teknisi AC panggilan untuk rumah, kos, toko, dan kantor kecil. Menangani cuci AC, isi freon, bocor halus, kapasitor, remote, dan perawatan berkala dengan jadwal booking.',
+ address: 'Jl. Durian No. 52, Pekanbaru',
+ city: 'Pekanbaru',
+ province: 'Riau',
+ postal_code: '28124',
+ latitude: 0.5118,
+ longitude: 101.4441,
+ phone_number: '+62761770352',
+ whatsapp_number: '+6281270010101',
+ email: 'booking@sejukmandiri.id',
+ website_url: 'https://sejukmandiri.id',
+ google_maps_url: 'https://maps.google.com/?q=0.5118,101.4441',
+ price_level: 'budget',
+ average_price: 95000,
+ rating_average: 4.8,
+ rating_count: 204,
+ is_verified: true,
+ },
+ {
+ id: 'ddddcccc-2222-4222-8222-ddddcccc2222',
+ name: 'AC Cepat Panam',
+ categorySlug: 'jasa-ac-elektronik',
+ short_description: 'Teknisi AC dan elektronik panggilan untuk servis cepat area kota dan kampus.',
+ full_description: 'Layanan panggilan cuci AC, tambah freon, perbaikan AC tidak dingin, instalasi unit baru, dan cek elektronik rumah. Bisa booking jadwal pagi, siang, atau sore.',
+ address: 'Jl. HR Soebrantas No. 144, Pekanbaru',
+ city: 'Pekanbaru',
+ province: 'Riau',
+ postal_code: '28293',
+ latitude: 0.4666,
+ longitude: 101.3989,
+ phone_number: '+62761770444',
+ whatsapp_number: '+6281270010202',
+ email: 'halo@accepatpanam.id',
+ website_url: 'https://accepatpanam.id',
+ google_maps_url: 'https://maps.google.com/?q=0.4666,101.3989',
+ price_level: 'midrange',
+ average_price: 125000,
+ rating_average: 4.5,
+ rating_count: 89,
+ is_verified: true,
+ },
+ {
+ id: 'eeeecccc-1111-4111-8111-eeeecccc1111',
+ name: 'Herbal Sehat Riau',
+ categorySlug: 'toko-herbal-kesehatan',
+ short_description: 'Toko herbal terdekat dengan madu hutan, jamu tradisional, minyak herbal, dan edukasi sehat.',
+ full_description: 'Menyediakan produk herbal lokal dan Nusantara seperti madu hutan, jamu kunyit asem, temulawak, minyak kayu putih, teh herbal, serta edukasi penggunaan produk secara bijak.',
+ address: 'Jl. Hang Lekir No. 27, Pekanbaru',
+ city: 'Pekanbaru',
+ province: 'Riau',
+ postal_code: '28131',
+ latitude: 0.5079,
+ longitude: 101.4527,
+ phone_number: '+62761770527',
+ whatsapp_number: '+6281270010303',
+ email: 'info@herbalsehatriau.id',
+ website_url: 'https://herbalsehatriau.id',
+ google_maps_url: 'https://maps.google.com/?q=0.5079,101.4527',
+ price_level: 'budget',
+ average_price: 65000,
+ rating_average: 4.7,
+ rating_count: 156,
+ is_verified: true,
+ },
+ {
+ id: 'eeeecccc-2222-4222-8222-eeeecccc2222',
+ name: 'Kedai Jamu Nusantara',
+ categorySlug: 'toko-herbal-kesehatan',
+ short_description: 'Jamu siap minum, racikan herbal, madu, dan minuman kesehatan lokal.',
+ full_description: 'Kedai jamu dan produk herbal harian dengan menu kunyit asem, beras kencur, wedang jahe, madu, dan paket edukasi herbal untuk keluarga.',
+ address: 'Jl. Pattimura No. 63, Pekanbaru',
+ city: 'Pekanbaru',
+ province: 'Riau',
+ postal_code: '28132',
+ latitude: 0.5156,
+ longitude: 101.4595,
+ phone_number: '+62761770663',
+ whatsapp_number: '+6281270010404',
+ email: 'kedai@jamunusantara.id',
+ website_url: 'https://jamunusantara.id',
+ google_maps_url: 'https://maps.google.com/?q=0.5156,101.4595',
+ price_level: 'budget',
+ average_price: 25000,
+ rating_average: 4.4,
+ rating_count: 97,
+ is_verified: false,
+ },
+];
+
+const offerings = [
+ {
+ id: 'dddddddd-1111-4111-8111-dddddddd1111',
+ placeId: 'aaaaaaaa-1111-4111-8111-aaaaaaaa1111',
+ name: 'Kitchen set minimalis custom',
+ description: 'Produk custom untuk dapur rumah, termasuk ukur lokasi dan desain awal.',
+ offering_type: 'product',
+ price: 4500000,
+ stock_status: 'by_request',
+ stock_quantity: null,
+ is_verified: true,
+ },
+ {
+ id: 'dddddddd-1112-4112-8112-dddddddd1112',
+ placeId: 'aaaaaaaa-1111-4111-8111-aaaaaaaa1111',
+ name: 'Jasa desain interior ruang usaha',
+ description: 'Konsultasi layout, warna, dan material untuk cafe, toko, atau kantor kecil.',
+ offering_type: 'service',
+ price: 750000,
+ stock_status: 'by_request',
+ stock_quantity: null,
+ is_verified: true,
+ },
+ {
+ id: 'dddddddd-1113-4113-8113-dddddddd1113',
+ placeId: 'aaaaaaaa-1111-4111-8111-aaaaaaaa1111',
+ name: 'Partisi ruangan kayu',
+ description: 'Partisi dekoratif untuk rumah dan kantor.',
+ offering_type: 'product',
+ price: 1200000,
+ stock_status: 'limited',
+ stock_quantity: 4,
+ is_verified: true,
+ },
+ {
+ id: 'dddddddd-2221-4221-8221-dddddddd2221',
+ placeId: 'aaaaaaaa-2222-4222-8222-aaaaaaaa2222',
+ name: 'Sofa ruang tamu ready stock',
+ description: 'Sofa minimalis dua dan tiga dudukan, tersedia pengiriman lokal.',
+ offering_type: 'product',
+ price: 2200000,
+ stock_status: 'in_stock',
+ stock_quantity: 7,
+ is_verified: true,
+ },
+ {
+ id: 'dddddddd-2222-4222-8222-dddddddd2222',
+ placeId: 'aaaaaaaa-2222-4222-8222-aaaaaaaa2222',
+ name: 'Lemari pakaian sliding',
+ description: 'Lemari sliding siap pakai untuk kamar minimalis.',
+ offering_type: 'product',
+ price: 1850000,
+ stock_status: 'limited',
+ stock_quantity: 3,
+ is_verified: true,
+ },
+ {
+ id: 'dddddddd-2223-4223-8223-dddddddd2223',
+ placeId: 'aaaaaaaa-2222-4222-8222-aaaaaaaa2222',
+ name: 'Meja makan kayu jati',
+ description: 'Meja makan keluarga dengan opsi kursi tambahan.',
+ offering_type: 'product',
+ price: 2800000,
+ stock_status: 'in_stock',
+ stock_quantity: 5,
+ is_verified: true,
+ },
+ {
+ id: 'dddddddd-3331-4331-8331-dddddddd3331',
+ placeId: 'aaaaaaaa-3333-4333-8333-aaaaaaaa3333',
+ name: 'Kabinet dapur bawah tangga',
+ description: 'Kabinet custom untuk memaksimalkan ruang kecil.',
+ offering_type: 'product',
+ price: 5200000,
+ stock_status: 'by_request',
+ stock_quantity: null,
+ is_verified: true,
+ },
+ {
+ id: 'dddddddd-3332-4332-8332-dddddddd3332',
+ placeId: 'aaaaaaaa-3333-4333-8333-aaaaaaaa3333',
+ name: 'Jasa ukur dan desain 3D dapur',
+ description: 'Survey lokasi, ukuran, dan gambar 3D sederhana sebelum produksi.',
+ offering_type: 'service',
+ price: 350000,
+ stock_status: 'by_request',
+ stock_quantity: null,
+ is_verified: true,
+ },
+ {
+ id: 'dddddddd-3333-4333-8333-dddddddd3333',
+ placeId: 'aaaaaaaa-3333-4333-8333-aaaaaaaa3333',
+ name: 'Top table granit kitchen set',
+ description: 'Material top table untuk kitchen set premium.',
+ offering_type: 'product',
+ price: 1750000,
+ stock_status: 'limited',
+ stock_quantity: 2,
+ is_verified: true,
+ },
+ {
+ id: 'eeeeeeee-1111-4111-8111-eeeeeeee1111',
+ placeId: 'bbbbbbbb-1111-4111-8111-bbbbbbbb1111',
+ name: 'Kopi saring Melayu',
+ description: 'Kopi saring panas khas Melayu, tersedia setiap hari.',
+ offering_type: 'product',
+ price: 18000,
+ stock_status: 'in_stock',
+ stock_quantity: 40,
+ is_verified: true,
+ },
+ {
+ id: 'eeeeeeee-1112-4112-8112-eeeeeeee1112',
+ placeId: 'bbbbbbbb-1111-4111-8111-bbbbbbbb1111',
+ name: 'Teh tarik rempah',
+ description: 'Teh tarik hangat dengan aroma rempah ringan.',
+ offering_type: 'product',
+ price: 16000,
+ stock_status: 'in_stock',
+ stock_quantity: 35,
+ is_verified: true,
+ },
+ {
+ id: 'eeeeeeee-1113-4113-8113-eeeeeeee1113',
+ placeId: 'bbbbbbbb-1111-4111-8111-bbbbbbbb1111',
+ name: 'Roti jala kari ayam',
+ description: 'Menu Melayu favorit untuk sarapan dan makan sore.',
+ offering_type: 'product',
+ price: 28000,
+ stock_status: 'limited',
+ stock_quantity: 9,
+ is_verified: true,
+ },
+ {
+ id: 'eeeeeeee-2221-4221-8221-eeeeeeee2221',
+ placeId: 'bbbbbbbb-2222-4222-8222-bbbbbbbb2222',
+ name: 'Laksa Melayu Riau',
+ description: 'Laksa kuah gurih khas Melayu.',
+ offering_type: 'product',
+ price: 26000,
+ stock_status: 'in_stock',
+ stock_quantity: 28,
+ is_verified: true,
+ },
+ {
+ id: 'eeeeeeee-2222-4222-8222-eeeeeeee2222',
+ placeId: 'bbbbbbbb-2222-4222-8222-bbbbbbbb2222',
+ name: 'Nasi lemak ayam sambal',
+ description: 'Paket nasi lemak dengan ayam dan sambal.',
+ offering_type: 'product',
+ price: 30000,
+ stock_status: 'in_stock',
+ stock_quantity: 22,
+ is_verified: true,
+ },
+ {
+ id: 'eeeeeeee-2223-4223-8223-eeeeeeee2223',
+ placeId: 'bbbbbbbb-2222-4222-8222-bbbbbbbb2222',
+ name: 'Paket booking sarapan kantor',
+ description: 'Pesanan paket sarapan Melayu untuk tim kecil.',
+ offering_type: 'service',
+ price: 250000,
+ stock_status: 'by_request',
+ stock_quantity: null,
+ is_verified: true,
+ },
+ {
+ id: 'eeeeeeee-3331-4331-8331-eeeeeeee3331',
+ placeId: 'bbbbbbbb-3333-4333-8333-bbbbbbbb3333',
+ name: 'Roti jala kari daging',
+ description: 'Roti jala rumahan dengan kari daging.',
+ offering_type: 'product',
+ price: 30000,
+ stock_status: 'limited',
+ stock_quantity: 6,
+ is_verified: false,
+ },
+ {
+ id: 'eeeeeeee-3332-4332-8332-eeeeeeee3332',
+ placeId: 'bbbbbbbb-3333-4333-8333-bbbbbbbb3333',
+ name: 'Kue talam Melayu',
+ description: 'Kue tradisional untuk makan di tempat atau bungkus.',
+ offering_type: 'product',
+ price: 5000,
+ stock_status: 'in_stock',
+ stock_quantity: 45,
+ is_verified: false,
+ },
+ {
+ id: 'eeeeeeee-3333-4333-8333-eeeeeeee3333',
+ placeId: 'bbbbbbbb-3333-4333-8333-bbbbbbbb3333',
+ name: 'Kopi saring kampung',
+ description: 'Kopi saring sederhana ala warung Melayu.',
+ offering_type: 'product',
+ price: 12000,
+ stock_status: 'in_stock',
+ stock_quantity: 30,
+ is_verified: false,
+ },
+ {
+ id: 'ffffffff-1111-4111-8111-ffffffff1111',
+ placeId: 'cccccccc-1111-4111-8111-cccccccc1111',
+ name: 'Ganti oli mobil 10W-40',
+ description: 'Paket oli, filter, dan pengecekan ringan.',
+ offering_type: 'service',
+ price: 380000,
+ stock_status: 'in_stock',
+ stock_quantity: 18,
+ is_verified: true,
+ },
+ {
+ id: 'ffffffff-1112-4112-8112-ffffffff1112',
+ placeId: 'cccccccc-1111-4111-8111-cccccccc1111',
+ name: 'Servis berkala 10.000 km',
+ description: 'Pemeriksaan mesin, oli, rem, aki, dan cairan kendaraan.',
+ offering_type: 'service',
+ price: 550000,
+ stock_status: 'by_request',
+ stock_quantity: null,
+ is_verified: true,
+ },
+ {
+ id: 'ffffffff-1113-4113-8113-ffffffff1113',
+ placeId: 'cccccccc-1111-4111-8111-cccccccc1111',
+ name: 'Aki mobil NS60',
+ description: 'Aki mobil untuk kendaraan harian populer.',
+ offering_type: 'product',
+ price: 780000,
+ stock_status: 'limited',
+ stock_quantity: 5,
+ is_verified: true,
+ },
+ {
+ id: 'ffffffff-2221-4221-8221-ffffffff2221',
+ placeId: 'cccccccc-2222-4222-8222-cccccccc2222',
+ name: 'Spooring balancing',
+ description: 'Layanan spooring dan balancing ban mobil.',
+ offering_type: 'service',
+ price: 250000,
+ stock_status: 'by_request',
+ stock_quantity: null,
+ is_verified: true,
+ },
+ {
+ id: 'ffffffff-2222-4222-8222-ffffffff2222',
+ placeId: 'cccccccc-2222-4222-8222-cccccccc2222',
+ name: 'Ban mobil ring 15',
+ description: 'Ban ring 15 untuk city car dan MPV kecil.',
+ offering_type: 'product',
+ price: 720000,
+ stock_status: 'limited',
+ stock_quantity: 4,
+ is_verified: true,
+ },
+ {
+ id: 'ffffffff-2223-4223-8223-ffffffff2223',
+ placeId: 'cccccccc-2222-4222-8222-cccccccc2222',
+ name: 'Tune up ringan',
+ description: 'Tune up ringan untuk performa mesin harian.',
+ offering_type: 'service',
+ price: 320000,
+ stock_status: 'by_request',
+ stock_quantity: null,
+ is_verified: true,
+ },
+ {
+ id: 'ffffffff-3331-4331-8331-ffffffff3331',
+ placeId: 'cccccccc-3333-4333-8333-cccccccc3333',
+ name: 'Perbaikan kaki-kaki mobil',
+ description: 'Cek dan perbaikan bunyi kaki-kaki, bushing, dan bearing.',
+ offering_type: 'service',
+ price: 450000,
+ stock_status: 'by_request',
+ stock_quantity: null,
+ is_verified: false,
+ },
+ {
+ id: 'ffffffff-3332-4332-8332-ffffffff3332',
+ placeId: 'cccccccc-3333-4333-8333-cccccccc3333',
+ name: 'Shockbreaker depan',
+ description: 'Komponen shockbreaker depan untuk beberapa model populer.',
+ offering_type: 'product',
+ price: 900000,
+ stock_status: 'limited',
+ stock_quantity: 2,
+ is_verified: false,
+ },
+ {
+ id: 'ffffffff-3333-4333-8333-ffffffff3333',
+ placeId: 'cccccccc-3333-4333-8333-cccccccc3333',
+ name: 'Cek rem dan bunyi kolong',
+ description: 'Diagnosa awal rem, kaki-kaki, dan bunyi pada kolong mobil.',
+ offering_type: 'service',
+ price: 100000,
+ stock_status: 'by_request',
+ stock_quantity: null,
+ is_verified: false,
+ },
+ {
+ id: '99999999-1111-4111-8111-999999991111',
+ placeId: 'ddddcccc-1111-4111-8111-ddddcccc1111',
+ name: 'Cuci AC 1 PK',
+ description: 'Jasa cuci AC rumah 0.5 sampai 1 PK dengan jadwal panggilan.',
+ offering_type: 'service',
+ price: 85000,
+ stock_status: 'by_request',
+ stock_quantity: null,
+ is_verified: true,
+ },
+ {
+ id: '99999999-1112-4112-8112-999999991112',
+ placeId: 'ddddcccc-1111-4111-8111-ddddcccc1111',
+ name: 'Isi freon AC R32',
+ description: 'Tambah freon AC R32 setelah pengecekan tekanan dan kebocoran ringan.',
+ offering_type: 'service',
+ price: 180000,
+ stock_status: 'by_request',
+ stock_quantity: null,
+ is_verified: true,
+ },
+ {
+ id: '99999999-1113-4113-8113-999999991113',
+ placeId: 'ddddcccc-1111-4111-8111-ddddcccc1111',
+ name: 'Kapasitor AC 25uF',
+ description: 'Sparepart kapasitor AC untuk beberapa model populer.',
+ offering_type: 'product',
+ price: 65000,
+ stock_status: 'limited',
+ stock_quantity: 6,
+ is_verified: true,
+ },
+ {
+ id: '99999999-2221-4221-8221-999999992221',
+ placeId: 'ddddcccc-2222-4222-8222-ddddcccc2222',
+ name: 'Bongkar pasang AC',
+ description: 'Jasa bongkar pasang AC unit lama atau pindah lokasi.',
+ offering_type: 'service',
+ price: 350000,
+ stock_status: 'by_request',
+ stock_quantity: null,
+ is_verified: true,
+ },
+ {
+ id: '99999999-2222-4222-8222-999999992222',
+ placeId: 'ddddcccc-2222-4222-8222-ddddcccc2222',
+ name: 'Service AC tidak dingin',
+ description: 'Diagnosa AC tidak dingin, cek freon, kipas, sensor, dan kebocoran ringan.',
+ offering_type: 'service',
+ price: 120000,
+ stock_status: 'by_request',
+ stock_quantity: null,
+ is_verified: true,
+ },
+ {
+ id: '88888888-1111-4111-8111-888888881111',
+ placeId: 'eeeecccc-1111-4111-8111-eeeecccc1111',
+ name: 'Madu hutan Riau 250 ml',
+ description: 'Madu hutan lokal ukuran 250 ml, stok diperbarui harian.',
+ offering_type: 'product',
+ price: 75000,
+ stock_status: 'in_stock',
+ stock_quantity: 18,
+ is_verified: true,
+ },
+ {
+ id: '88888888-1112-4112-8112-888888881112',
+ placeId: 'eeeecccc-1111-4111-8111-eeeecccc1111',
+ name: 'Teh herbal serai jahe',
+ description: 'Teh herbal siap seduh berbahan serai dan jahe.',
+ offering_type: 'product',
+ price: 42000,
+ stock_status: 'in_stock',
+ stock_quantity: 24,
+ is_verified: true,
+ },
+ {
+ id: '88888888-1113-4113-8113-888888881113',
+ placeId: 'eeeecccc-1111-4111-8111-eeeecccc1111',
+ name: 'Edukasi produk herbal keluarga',
+ description: 'Sesi edukasi singkat untuk memilih produk herbal secara bijak.',
+ offering_type: 'service',
+ price: 50000,
+ stock_status: 'by_request',
+ stock_quantity: null,
+ is_verified: true,
+ },
+ {
+ id: '88888888-2221-4221-8221-888888882221',
+ placeId: 'eeeecccc-2222-4222-8222-eeeecccc2222',
+ name: 'Jamu kunyit asem botol',
+ description: 'Jamu kunyit asem siap minum ukuran botol harian.',
+ offering_type: 'product',
+ price: 18000,
+ stock_status: 'in_stock',
+ stock_quantity: 30,
+ is_verified: false,
+ },
+ {
+ id: '88888888-2222-4222-8222-888888882222',
+ placeId: 'eeeecccc-2222-4222-8222-eeeecccc2222',
+ name: 'Wedang jahe merah',
+ description: 'Minuman jahe merah hangat untuk kebutuhan harian.',
+ offering_type: 'product',
+ price: 15000,
+ stock_status: 'limited',
+ stock_quantity: 10,
+ is_verified: false,
+ },
+];
+
+const upsertCategory = async (queryInterface, category, transaction) => {
+ await queryInterface.sequelize.query(
+ `
+ UPDATE place_categories
+ SET
+ name = :name,
+ slug = :slug,
+ description = :description,
+ color_hex = :color_hex,
+ icon_name = :icon_name,
+ is_active = true,
+ "updatedAt" = NOW()
+ WHERE (slug = :slug OR LOWER(name) = LOWER(:name))
+ AND "deletedAt" IS NULL;
+
+ INSERT INTO place_categories (
+ id, name, slug, description, color_hex, icon_name, is_active,
+ "createdAt", "updatedAt", "deletedAt", "importHash"
+ )
+ SELECT
+ :id, :name, :slug, :description, :color_hex, :icon_name, true,
+ NOW(), NOW(), NULL, :importHash
+ WHERE NOT EXISTS (
+ SELECT 1 FROM place_categories
+ WHERE (slug = :slug OR LOWER(name) = LOWER(:name))
+ AND "deletedAt" IS NULL
+ );
+ `,
+ {
+ replacements: {
+ ...category,
+ importHash: `${CATEGORY_IMPORT_PREFIX}${category.slug}`,
+ },
+ transaction,
+ },
+ );
+};
+
+const findCategoryId = async (queryInterface, Sequelize, slug, transaction) => {
+ const rows = await queryInterface.sequelize.query(
+ `
+ SELECT id FROM place_categories
+ WHERE slug = :slug AND "deletedAt" IS NULL
+ ORDER BY "createdAt" ASC
+ LIMIT 1;
+ `,
+ {
+ replacements: { slug },
+ transaction,
+ type: Sequelize.QueryTypes.SELECT,
+ },
+ );
+
+ if (!rows.length) {
+ throw new Error(`Kategori ${slug} tidak ditemukan saat memperbaiki data pencarian publik.`);
+ }
+
+ return rows[0].id;
+};
+
+
+const findPlaceId = async (queryInterface, Sequelize, name, transaction) => {
+ const rows = await queryInterface.sequelize.query(
+ `
+ SELECT id FROM places
+ WHERE LOWER(name) = LOWER(:name) AND "deletedAt" IS NULL
+ ORDER BY "createdAt" ASC
+ LIMIT 1;
+ `,
+ {
+ replacements: { name },
+ transaction,
+ type: Sequelize.QueryTypes.SELECT,
+ },
+ );
+
+ if (!rows.length) {
+ throw new Error(`Tempat ${name} tidak ditemukan saat menautkan produk/jasa pencarian publik.`);
+ }
+
+ return rows[0].id;
+};
+
+const upsertPlace = async (queryInterface, place, categoryId, transaction) => {
+ await queryInterface.sequelize.query(
+ `
+ UPDATE places
+ SET
+ "categoryId" = :categoryId,
+ short_description = :short_description,
+ full_description = :full_description,
+ address = :address,
+ city = :city,
+ province = :province,
+ postal_code = :postal_code,
+ latitude = :latitude,
+ longitude = :longitude,
+ phone_number = :phone_number,
+ whatsapp_number = :whatsapp_number,
+ email = :email,
+ website_url = :website_url,
+ google_maps_url = :google_maps_url,
+ price_level = :price_level,
+ average_price = :average_price,
+ rating_average = :rating_average,
+ rating_count = :rating_count,
+ status = 'published',
+ is_verified = :is_verified,
+ "updatedAt" = NOW()
+ WHERE LOWER(name) = LOWER(:name)
+ AND "deletedAt" IS NULL;
+
+ INSERT INTO places (
+ id, name, "categoryId", short_description, full_description, address, city, province,
+ postal_code, latitude, longitude, phone_number, whatsapp_number, email, website_url,
+ google_maps_url, price_level, average_price, rating_average, rating_count, status,
+ is_verified, "createdAt", "updatedAt", "deletedAt", "importHash"
+ )
+ SELECT
+ :id, :name, :categoryId, :short_description, :full_description, :address, :city, :province,
+ :postal_code, :latitude, :longitude, :phone_number, :whatsapp_number, :email, :website_url,
+ :google_maps_url, :price_level, :average_price, :rating_average, :rating_count, 'published',
+ :is_verified, NOW(), NOW(), NULL, :importHash
+ WHERE NOT EXISTS (
+ SELECT 1 FROM places
+ WHERE LOWER(name) = LOWER(:name)
+ AND "deletedAt" IS NULL
+ );
+ `,
+ {
+ replacements: {
+ ...place,
+ categoryId,
+ importHash: `${PLACE_IMPORT_PREFIX}${place.id}`,
+ },
+ transaction,
+ },
+ );
+};
+
+
+const upsertOffering = async (queryInterface, offering, transaction) => {
+ await queryInterface.sequelize.query(
+ `
+ UPDATE place_offerings
+ SET
+ "placeId" = :placeId,
+ name = :name,
+ description = :description,
+ offering_type = :offering_type,
+ price = :price,
+ stock_status = :stock_status,
+ stock_quantity = :stock_quantity,
+ is_verified = :is_verified,
+ is_active = true,
+ last_stock_update = COALESCE(:last_stock_update, NOW()),
+ "updatedAt" = NOW()
+ WHERE "importHash" = :importHash
+ AND "deletedAt" IS NULL;
+
+ INSERT INTO place_offerings (
+ id, "placeId", name, description, offering_type, price, stock_status,
+ stock_quantity, is_verified, is_active, last_stock_update,
+ "createdAt", "updatedAt", "deletedAt", "importHash"
+ )
+ SELECT
+ :id, :placeId, :name, :description, :offering_type, :price, :stock_status,
+ :stock_quantity, :is_verified, true, COALESCE(:last_stock_update, NOW()),
+ NOW(), NOW(), NULL, :importHash
+ WHERE NOT EXISTS (
+ SELECT 1 FROM place_offerings
+ WHERE "importHash" = :importHash
+ AND "deletedAt" IS NULL
+ );
+ `,
+ {
+ replacements: {
+ ...offering,
+ last_stock_update: offering.last_stock_update || null,
+ importHash: `${OFFERING_IMPORT_PREFIX}${offering.id}`,
+ },
+ transaction,
+ },
+ );
+};
+
+module.exports = {
+ up: async (queryInterface, Sequelize) => {
+ const transaction = await queryInterface.sequelize.transaction();
+
+ try {
+ for (const category of categories) {
+ await upsertCategory(queryInterface, category, transaction);
+ }
+
+ const actualPlaceIds = {};
+ for (const place of places) {
+ const categoryId = await findCategoryId(queryInterface, Sequelize, place.categorySlug, transaction);
+ await upsertPlace(queryInterface, place, categoryId, transaction);
+ actualPlaceIds[place.id] = await findPlaceId(queryInterface, Sequelize, place.name, transaction);
+ }
+
+ for (const offering of offerings) {
+ await upsertOffering(queryInterface, {
+ ...offering,
+ placeId: actualPlaceIds[offering.placeId] || offering.placeId,
+ }, transaction);
+ }
+
+ await transaction.commit();
+ } catch (err) {
+ await transaction.rollback();
+ throw err;
+ }
+ },
+
+ down: async (queryInterface) => {
+ const transaction = await queryInterface.sequelize.transaction();
+
+ try {
+ await queryInterface.sequelize.query(
+ 'DELETE FROM place_offerings WHERE "importHash" LIKE :prefix;',
+ {
+ replacements: { prefix: `${OFFERING_IMPORT_PREFIX}%` },
+ transaction,
+ },
+ );
+
+ await queryInterface.sequelize.query(
+ 'DELETE FROM places WHERE "importHash" LIKE :prefix;',
+ {
+ replacements: { prefix: `${PLACE_IMPORT_PREFIX}%` },
+ transaction,
+ },
+ );
+
+ await queryInterface.sequelize.query(
+ 'DELETE FROM place_categories WHERE "importHash" LIKE :prefix;',
+ {
+ replacements: { prefix: `${CATEGORY_IMPORT_PREFIX}%` },
+ transaction,
+ },
+ );
+
+ await transaction.commit();
+ } catch (err) {
+ await transaction.rollback();
+ throw err;
+ }
+ },
+};
diff --git a/backend/src/index.js b/backend/src/index.js
index 4f99184..05ad21e 100644
--- a/backend/src/index.js
+++ b/backend/src/index.js
@@ -6,7 +6,6 @@ const passport = require('passport');
const path = require('path');
const fs = require('fs');
const bodyParser = require('body-parser');
-const db = require('./db/models');
const config = require('./config');
const swaggerUI = require('swagger-ui-express');
const swaggerJsDoc = require('swagger-jsdoc');
@@ -16,6 +15,7 @@ const fileRoutes = require('./routes/file');
const searchRoutes = require('./routes/search');
const sqlRoutes = require('./routes/sql');
const pexelsRoutes = require('./routes/pexels');
+const publicPlacesRoutes = require('./routes/publicPlaces');
const openaiRoutes = require('./routes/openai');
@@ -100,6 +100,7 @@ app.use(bodyParser.json());
app.use('/api/auth', authRoutes);
app.use('/api/file', fileRoutes);
app.use('/api/pexels', pexelsRoutes);
+app.use('/api/public', publicPlacesRoutes);
app.enable('trust proxy');
diff --git a/backend/src/routes/publicPlaces.js b/backend/src/routes/publicPlaces.js
new file mode 100644
index 0000000..e0edc40
--- /dev/null
+++ b/backend/src/routes/publicPlaces.js
@@ -0,0 +1,1154 @@
+const express = require('express');
+const db = require('../db/models');
+const wrapAsync = require('../helpers').wrapAsync;
+const commonErrorHandler = require('../helpers').commonErrorHandler;
+
+const router = express.Router();
+const Sequelize = db.Sequelize;
+const Op = Sequelize.Op;
+
+const DEFAULT_LIMIT = 24;
+const MAX_LIMIT = 60;
+const CANDIDATE_LIMIT = 500;
+const DEFAULT_RADIUS_KM = 5;
+const GLOBAL_RADIUS_KM = 20038;
+
+const GEO_SCORE_FORMULA = {
+ relevance: 40,
+ distance: 25,
+ reputation: 15,
+ activity: 10,
+ interaction: 10,
+};
+
+const RADIUS_ZONES = [
+ {
+ value: 1,
+ label: 'Walking Zone',
+ range: '0–1 Km',
+ description: 'Paling cocok untuk jalan kaki dan kebutuhan sangat dekat.',
+ },
+ {
+ value: 5,
+ label: 'Neighborhood Zone',
+ range: '1–5 Km',
+ description: 'Default GeoSeek: sekitar rumah, kantor, dan lingkungan sekitar.',
+ },
+ {
+ value: 25,
+ label: 'City Zone',
+ range: '5–25 Km',
+ description: 'Menjangkau satu kota untuk pilihan yang lebih banyak.',
+ },
+ {
+ value: 100,
+ label: 'Regional Zone',
+ range: '25–100 Km',
+ description: 'Area regional atau kabupaten/kota sekitar.',
+ },
+ {
+ value: 500,
+ label: 'Provincial Zone',
+ range: '100–500 Km',
+ description: 'Skala provinsi untuk pencarian yang lebih luas.',
+ },
+ {
+ value: GLOBAL_RADIUS_KM,
+ label: 'Global Zone',
+ range: '500+ Km',
+ description: 'Skala nasional/global ketika lokasi lokal tidak cukup.',
+ },
+];
+
+const DISTANCE_BUCKETS = [0.5, 1, 3, 5, 25, 100, 500, GLOBAL_RADIUS_KM];
+
+const STOCK_STATUS_LABELS = {
+ in_stock: 'Tersedia',
+ limited: 'Stok terbatas',
+ out_of_stock: 'Habis',
+ by_request: 'By request',
+};
+
+const STOP_WORDS = new Set([
+ 'ada',
+ 'aku',
+ 'atau',
+ 'buka',
+ 'cari',
+ 'dan',
+ 'dekat',
+ 'desa',
+ 'di',
+ 'dengan',
+ 'direkomendasikan',
+ 'dalam',
+ 'enak',
+ 'ini',
+ 'itu',
+ 'jam',
+ 'ke',
+ 'kecamatan',
+ 'kelurahan',
+ 'km',
+ 'kota',
+ 'lokasi',
+ 'me',
+ 'murah',
+ 'near',
+ 'paling',
+ 'populer',
+ 'radius',
+ 'ramai',
+ 'rating',
+ 'rumah',
+ 'saya',
+ 'sekarang',
+ 'sekitar',
+ 'terbaru',
+ 'terdekat',
+ 'terlaris',
+ 'tertinggi',
+ 'terverifikasi',
+ 'untuk',
+ 'yang',
+ '10',
+ '24',
+]);
+
+const SYNONYMS = {
+ ac: ['air', 'conditioner', 'pendingin', 'service', 'servis', 'jasa'],
+ apotek: ['farmasi', 'obat', 'kesehatan', 'herbal', 'jamu', 'madu'],
+ atm: ['bank', 'uang', 'tunai', 'lokal', 'toko', 'umkm'],
+ aki: ['baterai', 'mobil', 'bengkel'],
+ artikel: ['blog', 'berita', 'website', 'konten'],
+ auto: ['automotif', 'otomotif', 'mobil', 'kendaraan', 'bengkel'],
+ ban: ['roda', 'spooring', 'balancing', 'mobil', 'bengkel'],
+ belanja: ['toko', 'produk', 'marketplace', 'jual', 'barang'],
+ cuci: ['mobil', 'bengkel', 'otomotif', 'auto', 'kendaraan'],
+ dokter: ['klinik', 'kesehatan', 'herbal', 'sehat', 'obat'],
+ bengkel: ['servis', 'service', 'otomotif', 'automotif', 'auto', 'mobil', 'kendaraan', 'oli', 'tune', 'mesin', 'kaki'],
+ booking: ['reservasi', 'jadwal', 'pesan', 'jasa'],
+ cafe: ['kafe', 'kopi', 'coffee', 'kedai', 'warung', 'kuliner', 'makan', 'minum'],
+ clinic: ['klinik', 'kesehatan', 'dokter', 'obat'],
+ coffee: ['cafe', 'kafe', 'kopi', 'kedai'],
+ dapur: ['kitchen', 'kabinet', 'interior', 'lemari'],
+ dekor: ['dekorasi', 'interior', 'furniture', 'furnitur', 'mebel', 'perabot'],
+ desain: ['design', 'interior', 'renovasi', 'dekorasi', 'jasa'],
+ furniture: ['furnitur', 'mebel', 'perabot', 'sofa', 'lemari', 'interior'],
+ furnitur: ['furniture', 'mebel', 'perabot', 'sofa', 'lemari', 'interior'],
+ herbal: ['sehat', 'kesehatan', 'alami', 'jamu', 'madu', 'temulawak'],
+ hotel: ['penginapan', 'wisata', 'travel', 'lokal', 'kuliner', 'cafe'],
+ interior: ['furniture', 'furnitur', 'mebel', 'perabot', 'sofa', 'lemari', 'kitchen', 'set', 'dekorasi', 'desain'],
+ jasa: ['layanan', 'service', 'servis', 'booking'],
+ kaki: ['shockbreaker', 'bushing', 'bearing', 'rem', 'bengkel', 'mobil'],
+ kafe: ['cafe', 'kopi', 'coffee', 'kedai', 'warung', 'kuliner', 'makan', 'minum'],
+ kabinet: ['lemari', 'kitchen', 'dapur', 'interior'],
+ kedai: ['cafe', 'kafe', 'kopi', 'warung', 'kuliner'],
+ kitchen: ['dapur', 'kabinet', 'interior', 'furniture', 'furnitur', 'lemari'],
+ kopi: ['cafe', 'kafe', 'coffee', 'kedai', 'warung', 'melayu', 'saring'],
+ kuliner: ['cafe', 'kafe', 'kedai', 'warung', 'makan', 'minum'],
+ klinik: ['clinic', 'dokter', 'kesehatan', 'herbal', 'sehat', 'obat'],
+ laksa: ['melayu', 'makan', 'kuliner', 'kedai'],
+ lemari: ['kabinet', 'furniture', 'furnitur', 'mebel', 'perabot', 'interior'],
+ makan: ['kuliner', 'cafe', 'kafe', 'kedai', 'warung'],
+ marketplace: ['produk', 'stok', 'jual', 'toko', 'belanja'],
+ mall: ['belanja', 'toko', 'marketplace', 'produk', 'kuliner'],
+ minimarket: ['belanja', 'toko', 'marketplace', 'produk'],
+ mebel: ['furniture', 'furnitur', 'perabot', 'sofa', 'lemari', 'interior'],
+ melayu: ['riau', 'tradisional', 'kopi', 'roti', 'jala', 'laksa', 'teh', 'tarik', 'saring'],
+ mobil: ['bengkel', 'servis', 'service', 'otomotif', 'auto', 'kendaraan', 'oli', 'mesin'],
+ nasi: ['makan', 'kuliner', 'melayu', 'lemak'],
+ obat: ['apotek', 'farmasi', 'kesehatan', 'herbal', 'jamu'],
+ oli: ['pelumas', 'ganti', 'servis', 'service', 'bengkel', 'mobil'],
+ otomotif: ['automotif', 'auto', 'bengkel', 'mobil', 'kendaraan', 'servis', 'service'],
+ pembayaran: ['bayar', 'digital', 'cashless', 'qris'],
+ pantai: ['wisata', 'travel', 'penginapan', 'hotel', 'lokal'],
+ pasar: ['belanja', 'toko', 'marketplace', 'produk'],
+ perabot: ['furniture', 'furnitur', 'mebel', 'interior', 'sofa', 'lemari'],
+ produk: ['barang', 'stok', 'ready', 'tersedia'],
+ penginapan: ['hotel', 'wisata', 'travel', 'lokal'],
+ rem: ['brake', 'bengkel', 'mobil', 'kaki'],
+ restoran: ['restaurant', 'kuliner', 'makan', 'warung', 'cafe', 'kafe', 'kedai', 'nasi'],
+ restaurant: ['restoran', 'kuliner', 'makan', 'warung', 'cafe', 'kafe', 'kedai'],
+ roti: ['jala', 'melayu', 'kuliner', 'makan'],
+ saring: ['kopi', 'melayu', 'kedai'],
+ sekolah: ['pendidikan', 'kursus', 'belajar'],
+ service: ['servis', 'bengkel', 'mobil', 'otomotif', 'auto', 'oli', 'tune', 'mesin'],
+ sakit: ['kesehatan', 'klinik', 'dokter', 'obat', 'herbal'],
+ servis: ['service', 'bengkel', 'mobil', 'otomotif', 'auto', 'oli', 'tune', 'mesin'],
+ shockbreaker: ['shock', 'kaki', 'mobil', 'bengkel'],
+ sofa: ['furniture', 'furnitur', 'mebel', 'interior', 'ruang', 'tamu'],
+ spbu: ['bbm', 'bensin', 'otomotif', 'mobil', 'bengkel', 'auto'],
+ spooring: ['balancing', 'ban', 'bengkel', 'mobil'],
+ stok: ['stock', 'tersedia', 'ready', 'produk', 'barang'],
+ tersedia: ['ready', 'stok', 'stock', 'in stock'],
+ tambal: ['ban', 'bengkel', 'otomotif', 'mobil', 'roda'],
+ toko: ['store', 'shop', 'belanja', 'jual', 'produk'],
+ tempat: ['lokal', 'wisata', 'kuliner', 'toko', 'umkm'],
+ tune: ['servis', 'service', 'mesin', 'bengkel', 'mobil'],
+ umkm: ['usaha', 'lokal', 'toko', 'jasa', 'produk'],
+ warung: ['kedai', 'cafe', 'kafe', 'kopi', 'kuliner', 'makan'],
+ wisata: ['travel', 'hotel', 'penginapan', 'pantai', 'lokal', 'kuliner'],
+};
+
+const parseCoordinate = (value, label) => {
+ if (value === undefined || value === null || value === '') {
+ return null;
+ }
+
+ const number = Number(value);
+ if (!Number.isFinite(number)) {
+ const error = new Error(`${label} harus berupa angka.`);
+ error.code = 400;
+ throw error;
+ }
+
+ return number;
+};
+
+const parseRadius = (value) => {
+ if (value === undefined || value === null || value === '') {
+ return DEFAULT_RADIUS_KM;
+ }
+
+ const number = Number(value);
+ if (!Number.isFinite(number) || number <= 0) {
+ const error = new Error('Radius harus berupa angka lebih dari 0 km.');
+ error.code = 400;
+ throw error;
+ }
+
+ return Math.min(number, GLOBAL_RADIUS_KM);
+};
+
+const clampScore = (value) => Math.max(0, Math.min(100, Number(value) || 0));
+
+const toNumber = (value) => {
+ if (value === null || value === undefined || value === '') {
+ return null;
+ }
+
+ const number = Number(value);
+ return Number.isFinite(number) ? number : null;
+};
+
+const normalizeText = (value) => String(value || '')
+ .toLowerCase()
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .replace(/[^a-z0-9]+/g, ' ')
+ .trim();
+
+const parseRadiusFromQueryText = (query) => {
+ const normalized = normalizeText(query);
+ const radiusMatch = normalized.match(/(?:dalam\s+)?radius\s+(\d+(?:[.,]\d+)?)\s*km/) || normalized.match(/\b(\d+(?:[.,]\d+)?)\s*km\b/);
+
+ if (!radiusMatch) {
+ return null;
+ }
+
+ const number = Number(String(radiusMatch[1]).replace(',', '.'));
+ if (!Number.isFinite(number) || number <= 0) {
+ return null;
+ }
+
+ return Math.min(number, GLOBAL_RADIUS_KM);
+};
+
+const hasAnyPhrase = (text, phrases) => phrases.some((phrase) => text.includes(phrase));
+
+const analyzeHyperlocalIntent = (query) => {
+ const normalized = normalizeText(query);
+
+ return {
+ nearby: hasAnyPhrase(normalized, [
+ 'terdekat',
+ 'dekat saya',
+ 'near me',
+ 'sekitar saya',
+ 'lokasi terdekat',
+ 'dalam radius',
+ 'radius',
+ ]),
+ openNow: hasAnyPhrase(normalized, ['buka sekarang', 'buka sek', 'sedang buka']),
+ twentyFourHour: /\b24\s*jam\b/.test(normalized) || normalized.includes('24jam'),
+ highestRating: hasAnyPhrase(normalized, ['rating tertinggi', 'terbaik', 'bintang tertinggi']),
+ popular: hasAnyPhrase(normalized, ['populer', 'paling ramai', 'ramai', 'trafik tertinggi']),
+ cheap: hasAnyPhrase(normalized, ['paling murah', 'termurah', 'murah']),
+ bestSeller: hasAnyPhrase(normalized, ['terlaris', 'best seller', 'laris']),
+ newest: hasAnyPhrase(normalized, ['terbaru', 'baru update', 'update terbaru']),
+ recommended: hasAnyPhrase(normalized, ['direkomendasikan', 'recommended', 'rekomendasi']),
+ verified: hasAnyPhrase(normalized, ['terverifikasi', 'verified']),
+ requestedRadiusKm: parseRadiusFromQueryText(query),
+ };
+};
+
+const tokenizeQuery = (value) => normalizeText(value)
+ .split(/\s+/)
+ .filter((token) => token.length > 1 && !STOP_WORDS.has(token));
+
+const includesToken = (text, token) => {
+ if (!text || !token) {
+ return false;
+ }
+
+ if (token.includes(' ')) {
+ return text.includes(token);
+ }
+
+ const words = text.split(/\s+/).filter(Boolean);
+ return words.some((word) => word === token
+ || (token.length >= 4 && word.startsWith(token))
+ || (token.length >= 5 && word.includes(token)));
+};
+
+const scoreField = (field, token, weight) => (includesToken(field, token) ? weight : 0);
+
+const isAvailableOffering = (offering) => ['in_stock', 'limited', 'by_request'].includes(offering.stock_status);
+
+const buildOfferingText = (offerings) => offerings
+ .map((offering) => [
+ offering.name,
+ offering.description,
+ offering.offering_type === 'service' ? 'jasa layanan service servis booking' : 'produk barang stok ready marketplace',
+ STOCK_STATUS_LABELS[offering.stock_status],
+ ].filter(Boolean).join(' '))
+ .join(' ');
+
+const scoreOfferingTokens = (offerings, tokens, tokenWeight, availableWeight) => {
+ let score = 0;
+
+ offerings.forEach((offering) => {
+ const offeringText = normalizeText([
+ offering.name,
+ offering.description,
+ offering.offering_type,
+ STOCK_STATUS_LABELS[offering.stock_status],
+ ].filter(Boolean).join(' '));
+ const matches = tokens.filter((token) => includesToken(offeringText, token)).length;
+
+ if (!matches) {
+ return;
+ }
+
+ score += matches * tokenWeight;
+
+ if (isAvailableOffering(offering)) {
+ score += availableWeight;
+ }
+
+ if (offering.is_verified) {
+ score += 5;
+ }
+
+ if (offering.stock_status === 'out_of_stock') {
+ score -= 25;
+ }
+ });
+
+ return score;
+};
+
+const scoreTokenInSearchFields = (fields, token, weights) => scoreField(fields.name, token, weights.name)
+ + scoreField(fields.category, token, weights.category)
+ + scoreField(fields.offerings, token, weights.offerings)
+ + scoreField(fields.description, token, weights.description)
+ + scoreField(fields.location, token, weights.location);
+
+const calculateRawRelevanceScore = (place, query) => {
+ const originalTokens = tokenizeQuery(query);
+
+ if (!originalTokens.length) {
+ return 62 + Math.min(calculateInventoryScore(place) * 0.18, 18);
+ }
+
+ const normalizedQuery = normalizeText(query);
+ const offerings = Array.isArray(place.offerings) ? place.offerings : [];
+ const fields = {
+ name: normalizeText(place.name),
+ category: normalizeText([
+ place.category?.name,
+ place.category?.slug,
+ place.category?.description,
+ ].filter(Boolean).join(' ')),
+ description: normalizeText([
+ place.short_description,
+ place.full_description,
+ ].filter(Boolean).join(' ')),
+ location: normalizeText([
+ place.address,
+ place.city,
+ place.province,
+ ].filter(Boolean).join(' ')),
+ offerings: normalizeText(buildOfferingText(offerings)),
+ };
+
+ const exactWeights = {
+ name: 22,
+ category: 18,
+ offerings: 26,
+ description: 10,
+ location: 4,
+ };
+ const synonymWeights = {
+ name: 9,
+ category: 7,
+ offerings: 11,
+ description: 4,
+ location: 2,
+ };
+
+ let score = 0;
+
+ if (normalizedQuery) {
+ score += scoreField(fields.name, normalizedQuery, 80);
+ score += scoreField(fields.category, normalizedQuery, 70);
+ score += scoreField(fields.offerings, normalizedQuery, 86);
+ score += scoreField(fields.description, normalizedQuery, 46);
+ score += scoreField(fields.location, normalizedQuery, 16);
+ }
+
+ let originalMatches = 0;
+ const matchedSearchTerms = new Set();
+
+ originalTokens.forEach((token) => {
+ const tokenScore = scoreTokenInSearchFields(fields, token, exactWeights);
+
+ if (tokenScore > 0) {
+ originalMatches += 1;
+ matchedSearchTerms.add(token);
+ score += tokenScore;
+ }
+ });
+
+ score += scoreOfferingTokens(offerings, originalTokens, 9, 12);
+
+ let synonymScore = 0;
+ const usedSynonyms = new Set();
+
+ originalTokens.forEach((originalToken) => {
+ let tokenSynonymScore = 0;
+
+ (SYNONYMS[originalToken] || []).forEach((token) => {
+ if (usedSynonyms.has(token)) {
+ return;
+ }
+
+ usedSynonyms.add(token);
+ tokenSynonymScore += scoreTokenInSearchFields(fields, token, synonymWeights);
+ tokenSynonymScore += scoreOfferingTokens(offerings, [token], 4, 5);
+ });
+
+ if (tokenSynonymScore > 0) {
+ matchedSearchTerms.add(originalToken);
+ synonymScore += tokenSynonymScore;
+ }
+ });
+
+ if (originalTokens.length > 1 && matchedSearchTerms.size < originalTokens.length) {
+ return 0;
+ }
+
+ if (originalMatches === 0) {
+ if (synonymScore < 16) {
+ return 0;
+ }
+
+ score = synonymScore * 0.72;
+ } else {
+ score += synonymScore;
+ }
+
+ if (score <= 0) {
+ return 0;
+ }
+
+ if (matchedSearchTerms.size === originalTokens.length) {
+ score += 24;
+ }
+
+ return score;
+};
+
+const calculateInventoryScore = (place) => {
+ const offerings = Array.isArray(place.offerings) ? place.offerings : [];
+ const availableOfferings = offerings.filter(isAvailableOffering).length;
+ const verifiedOfferings = offerings.filter((offering) => offering.is_verified).length;
+ const products = offerings.filter((offering) => offering.offering_type === 'product').length;
+ const services = offerings.filter((offering) => offering.offering_type === 'service').length;
+
+ return clampScore((availableOfferings * 12) + (verifiedOfferings * 8) + (products * 3) + (services * 3));
+};
+
+const calculateIntentBoost = (place, intent, radiusKm) => {
+ let boost = 0;
+
+ if (intent.openNow && place.live_status?.status === 'open') {
+ boost += 34;
+ }
+
+ if (intent.twentyFourHour && place.live_status?.status === 'open') {
+ boost += 16;
+ }
+
+ if (intent.nearby && place.distance_km !== null && place.distance_km !== undefined) {
+ const safeRadius = radiusKm || intent.requestedRadiusKm || DEFAULT_RADIUS_KM;
+ boost += clampScore(18 * (1 - Math.min(place.distance_km / safeRadius, 1)));
+ }
+
+ if (intent.highestRating && Number(place.rating_average || 0) >= 4.5) {
+ boost += 24;
+ }
+
+ if (intent.popular || intent.bestSeller) {
+ boost += clampScore(Math.log10(Number(place.rating_count || 0) + 1) * 12);
+ }
+
+ if (intent.cheap) {
+ if (place.price_level === 'budget') {
+ boost += 26;
+ } else if (place.price_level === 'midrange') {
+ boost += 12;
+ }
+ }
+
+ if (intent.newest) {
+ boost += calculateActivityComponent(place) * 0.22;
+ }
+
+ if (intent.recommended) {
+ boost += 14;
+ }
+
+ if (intent.verified && place.is_verified) {
+ boost += 28;
+ }
+
+ return boost;
+};
+
+const calculateRelevanceComponent = (place, query, radiusKm) => {
+ const intent = analyzeHyperlocalIntent(query);
+ const semanticTokens = tokenizeQuery(query);
+ const rawRelevance = calculateRawRelevanceScore(place, query);
+
+ if (rawRelevance <= 0 && semanticTokens.length > 0) {
+ return 0;
+ }
+
+ const raw = rawRelevance + calculateIntentBoost(place, intent, radiusKm);
+
+ if (raw <= 0) {
+ return 0;
+ }
+
+ return clampScore(raw * 0.72 + calculateInventoryScore(place) * 0.22);
+};
+
+const calculateDistanceComponent = (place, radiusKm) => {
+ if (place.distance_km === null || place.distance_km === undefined) {
+ return 58;
+ }
+
+ const safeRadius = radiusKm || DEFAULT_RADIUS_KM;
+ if (safeRadius >= GLOBAL_RADIUS_KM) {
+ return clampScore(100 - Math.min(place.distance_km / 100, 80));
+ }
+
+ return clampScore(100 * (1 - Math.min(place.distance_km / safeRadius, 1)));
+};
+
+const calculateReputationComponent = (place) => {
+ const rating = Number(place.rating_average || 0);
+ const ratingCount = Number(place.rating_count || 0);
+ const ratingScore = clampScore((rating / 5) * 84);
+ const volumeScore = clampScore(Math.log10(ratingCount + 1) * 10);
+ const verifiedScore = place.is_verified ? 8 : 0;
+
+ return clampScore(ratingScore + volumeScore + verifiedScore);
+};
+
+const getMostRecentActivityDate = (place) => {
+ const dates = [];
+
+ if (place.updatedAt) {
+ dates.push(new Date(place.updatedAt));
+ }
+
+ (place.offerings || []).forEach((offering) => {
+ if (offering.last_stock_update) {
+ dates.push(new Date(offering.last_stock_update));
+ }
+ });
+
+ return dates
+ .filter((date) => !Number.isNaN(date.getTime()))
+ .sort((a, b) => b.getTime() - a.getTime())[0] || null;
+};
+
+const calculateActivityComponent = (place) => {
+ const latestDate = getMostRecentActivityDate(place);
+
+ if (!latestDate) {
+ return 42;
+ }
+
+ const days = (Date.now() - latestDate.getTime()) / (1000 * 60 * 60 * 24);
+
+ if (days <= 1) return 100;
+ if (days <= 7) return 92;
+ if (days <= 30) return 76;
+ if (days <= 90) return 58;
+ return 40;
+};
+
+const calculateInteractionComponent = (place) => {
+ const ratingCount = Number(place.rating_count || 0);
+ const offerings = Array.isArray(place.offerings) ? place.offerings : [];
+ const availableOfferings = offerings.filter(isAvailableOffering).length;
+ const contactScore = [place.phone_number, place.whatsapp_number, place.website_url, place.google_maps_url]
+ .filter(Boolean).length * 5;
+ const engagementScore = Math.log10(ratingCount + 1) * 26;
+ const inventoryScore = Math.min(availableOfferings * 4, 18);
+
+ return clampScore(contactScore + engagementScore + inventoryScore);
+};
+
+const calculateGeoScore = (place, query, radiusKm) => {
+ const components = {
+ relevance: calculateRelevanceComponent(place, query, radiusKm),
+ distance: calculateDistanceComponent(place, radiusKm),
+ reputation: calculateReputationComponent(place),
+ activity: calculateActivityComponent(place),
+ interaction: calculateInteractionComponent(place),
+ };
+ const weighted = (components.relevance * GEO_SCORE_FORMULA.relevance
+ + components.distance * GEO_SCORE_FORMULA.distance
+ + components.reputation * GEO_SCORE_FORMULA.reputation
+ + components.activity * GEO_SCORE_FORMULA.activity
+ + components.interaction * GEO_SCORE_FORMULA.interaction) / 100;
+
+ return {
+ value: Number(clampScore(weighted).toFixed(2)),
+ components: Object.fromEntries(
+ Object.entries(components).map(([key, value]) => [key, Number(value.toFixed(2))]),
+ ),
+ formula: GEO_SCORE_FORMULA,
+ };
+};
+
+const compareDistance = (a, b) => {
+ if (a.distance_km !== null && b.distance_km !== null) return a.distance_km - b.distance_km;
+ if (a.distance_km !== null) return -1;
+ if (b.distance_km !== null) return 1;
+ return 0;
+};
+
+const compareLatestActivity = (a, b) => {
+ const dateA = getMostRecentActivityDate(a);
+ const dateB = getMostRecentActivityDate(b);
+
+ if (dateA && dateB) {
+ return dateB.getTime() - dateA.getTime();
+ }
+
+ if (dateA) return -1;
+ if (dateB) return 1;
+ return 0;
+};
+
+const compareAveragePrice = (a, b) => {
+ const priceA = Number(a.average_price || 0);
+ const priceB = Number(b.average_price || 0);
+
+ if (priceA && priceB) {
+ return priceA - priceB;
+ }
+
+ if (priceA) return -1;
+ if (priceB) return 1;
+ return 0;
+};
+
+const sortScoredPlaces = (a, b, hasQuery, intent = {}) => {
+ if (intent.openNow || intent.twentyFourHour) {
+ const openDiff = Number(b.place.live_status?.status === 'open') - Number(a.place.live_status?.status === 'open');
+ if (openDiff !== 0) return openDiff;
+ }
+
+ if (intent.verified) {
+ const verifiedDiff = Number(b.place.is_verified) - Number(a.place.is_verified);
+ if (verifiedDiff !== 0) return verifiedDiff;
+ }
+
+ if (intent.highestRating) {
+ const ratingDiff = (b.place.rating_average || 0) - (a.place.rating_average || 0);
+ if (Math.abs(ratingDiff) >= 0.05) return ratingDiff;
+ }
+
+ if (intent.popular || intent.bestSeller) {
+ const popularityDiff = (b.place.rating_count || 0) - (a.place.rating_count || 0);
+ if (popularityDiff !== 0) return popularityDiff;
+ }
+
+ if (intent.cheap) {
+ const priceDiff = compareAveragePrice(a.place, b.place);
+ if (priceDiff !== 0) return priceDiff;
+ }
+
+ if (intent.newest) {
+ const latestDiff = compareLatestActivity(a.place, b.place);
+ if (latestDiff !== 0) return latestDiff;
+ }
+
+ if (intent.nearby && !hasQuery) {
+ const distanceDiff = compareDistance(a.place, b.place);
+ if (distanceDiff !== 0) return distanceDiff;
+ }
+
+ if (hasQuery) {
+ const relevanceDiff = b.geoScore.components.relevance - a.geoScore.components.relevance;
+ if (Math.abs(relevanceDiff) >= 4) return relevanceDiff;
+ }
+
+ const geoScoreDiff = b.geoScore.value - a.geoScore.value;
+
+ if (Math.abs(geoScoreDiff) > (hasQuery ? 2 : 5)) {
+ return geoScoreDiff;
+ }
+
+ const ratingDiff = (b.place.rating_average || 0) - (a.place.rating_average || 0);
+ if (Math.abs(ratingDiff) >= 0.2) {
+ return ratingDiff;
+ }
+
+ const distanceDiff = compareDistance(a.place, b.place);
+ if (distanceDiff !== 0) {
+ return distanceDiff;
+ }
+
+ if (Boolean(a.place.is_verified) !== Boolean(b.place.is_verified)) {
+ return a.place.is_verified ? -1 : 1;
+ }
+
+ return String(a.place.name || '').localeCompare(String(b.place.name || ''));
+};
+
+const getLocalHour = () => {
+ try {
+ const value = new Intl.DateTimeFormat('en-US', {
+ hour: 'numeric',
+ hour12: false,
+ timeZone: 'Asia/Jakarta',
+ }).format(new Date());
+
+ return Number(value);
+ } catch (err) {
+ console.error('Gagal membaca timezone Asia/Jakarta untuk live status', err);
+ return (new Date().getUTCHours() + 7) % 24;
+ }
+};
+
+const isBetweenHour = (hour, start, end) => hour >= start && hour < end;
+
+const calculateLiveStatus = (place) => {
+ const hour = getLocalHour();
+ const slug = place.category?.slug || '';
+ let open = isBetweenHour(hour, 8, 21);
+ let busy = false;
+
+ if (slug.includes('cafe')) {
+ open = isBetweenHour(hour, 7, 22);
+ busy = isBetweenHour(hour, 7, 9) || isBetweenHour(hour, 12, 14) || isBetweenHour(hour, 19, 21);
+ } else if (slug.includes('bengkel')) {
+ open = isBetweenHour(hour, 8, 17);
+ busy = isBetweenHour(hour, 9, 11) || isBetweenHour(hour, 14, 16);
+ } else if (slug.includes('interior')) {
+ open = isBetweenHour(hour, 9, 18);
+ busy = isBetweenHour(hour, 10, 12) || isBetweenHour(hour, 15, 17);
+ }
+
+ const activityDate = getMostRecentActivityDate(place);
+ const updatedToday = activityDate
+ ? ((Date.now() - activityDate.getTime()) / (1000 * 60 * 60 * 24)) <= 1
+ : false;
+
+ return {
+ status: open ? 'open' : 'closed',
+ label: open ? 'Buka sekarang' : 'Tutup sekarang',
+ crowd: open ? (busy ? 'Ramai' : 'Sepi') : 'Tutup',
+ updated_label: updatedToday ? 'Update stok hari ini' : 'Update berkala',
+ source: 'Simulasi live search MVP berbasis jam lokal dan aktivitas data.',
+ };
+};
+
+const getRadiusZone = (distanceKm, selectedRadiusKm) => {
+ const value = distanceKm === null || distanceKm === undefined ? selectedRadiusKm : distanceKm;
+ const zone = RADIUS_ZONES.find((item) => value <= item.value) || RADIUS_ZONES[RADIUS_ZONES.length - 1];
+
+ return zone;
+};
+
+const toPublicOffering = (offering) => ({
+ id: offering.id,
+ name: offering.name,
+ description: offering.description,
+ offering_type: offering.offering_type,
+ price: toNumber(offering.price),
+ stock_status: offering.stock_status,
+ stock_label: STOCK_STATUS_LABELS[offering.stock_status] || 'Info stok',
+ stock_quantity: offering.stock_quantity,
+ is_verified: Boolean(offering.is_verified),
+ last_stock_update: offering.last_stock_update,
+});
+
+const summarizeOfferings = (offerings) => {
+ const activeOfferings = Array.isArray(offerings) ? offerings : [];
+ const availableOfferings = activeOfferings.filter(isAvailableOffering);
+
+ return {
+ total: activeOfferings.length,
+ available: availableOfferings.length,
+ products: activeOfferings.filter((offering) => offering.offering_type === 'product').length,
+ services: activeOfferings.filter((offering) => offering.offering_type === 'service').length,
+ verified: activeOfferings.filter((offering) => offering.is_verified).length,
+ top_available: availableOfferings.slice(0, 3),
+ };
+};
+
+const calculateDistanceKm = (lat1, lon1, lat2, lon2) => {
+ const earthRadiusKm = 6371;
+ const toRadians = (value) => (value * Math.PI) / 180;
+ const dLat = toRadians(lat2 - lat1);
+ const dLon = toRadians(lon2 - lon1);
+ const a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
+ + Math.cos(toRadians(lat1))
+ * Math.cos(toRadians(lat2))
+ * Math.sin(dLon / 2)
+ * Math.sin(dLon / 2);
+
+ return earthRadiusKm * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+};
+
+const toPublicPlace = (place, origin) => {
+ const plain = place.get({ plain: true });
+ const latitude = toNumber(plain.latitude);
+ const longitude = toNumber(plain.longitude);
+ const distanceKm = origin && latitude !== null && longitude !== null
+ ? calculateDistanceKm(origin.lat, origin.lng, latitude, longitude)
+ : null;
+ const offerings = (plain.offerings || []).map(toPublicOffering);
+ const publicPlace = {
+ id: plain.id,
+ name: plain.name,
+ short_description: plain.short_description,
+ full_description: plain.full_description,
+ address: plain.address,
+ city: plain.city,
+ province: plain.province,
+ latitude,
+ longitude,
+ phone_number: plain.phone_number,
+ whatsapp_number: plain.whatsapp_number,
+ website_url: plain.website_url,
+ google_maps_url: plain.google_maps_url,
+ price_level: plain.price_level,
+ average_price: toNumber(plain.average_price),
+ rating_average: toNumber(plain.rating_average),
+ rating_count: plain.rating_count || 0,
+ status: plain.status,
+ is_verified: Boolean(plain.is_verified),
+ updatedAt: plain.updatedAt,
+ category: plain.category ? {
+ id: plain.category.id,
+ name: plain.category.name,
+ slug: plain.category.slug,
+ color_hex: plain.category.color_hex,
+ description: plain.category.description,
+ } : null,
+ distance_km: distanceKm === null ? null : Number(distanceKm.toFixed(2)),
+ offerings,
+ offerings_summary: summarizeOfferings(offerings),
+ };
+
+ publicPlace.live_status = calculateLiveStatus(publicPlace);
+
+ return publicPlace;
+};
+
+const buildCategoryWhere = (category) => {
+ if (!category) {
+ return { is_active: true };
+ }
+
+ const categoryFilters = [
+ { slug: category },
+ { name: { [Op.iLike]: `%${category}%` } },
+ ];
+
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(category)) {
+ categoryFilters.unshift({ id: category });
+ }
+
+ return {
+ is_active: true,
+ [Op.or]: categoryFilters,
+ };
+};
+
+const offeringsInclude = {
+ model: db.place_offerings,
+ as: 'offerings',
+ attributes: [
+ 'id',
+ 'name',
+ 'description',
+ 'offering_type',
+ 'price',
+ 'stock_status',
+ 'stock_quantity',
+ 'is_verified',
+ 'last_stock_update',
+ ],
+ where: { is_active: true },
+ required: false,
+ separate: true,
+ order: [
+ ['is_verified', 'DESC'],
+ ['stock_status', 'ASC'],
+ ['name', 'ASC'],
+ ],
+};
+
+const buildDistanceBuckets = (places) => DISTANCE_BUCKETS.map((maxKm) => {
+ const count = places.filter((place) => place.distance_km !== null && place.distance_km <= maxKm).length;
+ const zone = getRadiusZone(maxKm, maxKm);
+
+ return {
+ radius_km: maxKm,
+ label: maxKm === GLOBAL_RADIUS_KM ? '500+ km' : `${maxKm} km`,
+ zone_label: zone.label,
+ count,
+ };
+});
+
+const buildAiRecommendation = (place, query) => {
+ const topOffering = place.offerings_summary?.top_available?.[0];
+ const reasonParts = [];
+
+ if (topOffering) {
+ reasonParts.push(`${topOffering.name} ${topOffering.stock_label.toLowerCase()}`);
+ }
+
+ if (place.is_verified) {
+ reasonParts.push('tempat terverifikasi');
+ }
+
+ if (place.distance_km !== null && place.distance_km !== undefined) {
+ reasonParts.push(`${place.distance_km} km dari titik Anda`);
+ }
+
+ if (place.rating_average) {
+ reasonParts.push(`rating ${place.rating_average}`);
+ }
+
+ return {
+ label: query ? `Cocok untuk “${query}”` : 'Rekomendasi lokal',
+ reason: reasonParts.length
+ ? `Direkomendasikan karena ${reasonParts.join(', ')}.`
+ : 'Direkomendasikan berdasarkan kategori, reputasi, aktivitas data, dan kedekatan lokasi.',
+ source: 'AI Recommendation MVP berbasis GeoScore dan sinyal lokal.',
+ };
+};
+
+const buildTrending = (places) => {
+ const categoryMap = new Map();
+ const availableOfferings = [];
+ let openNow = 0;
+
+ places.forEach((place) => {
+ const categoryName = place.category?.name || 'Tempat lokal';
+ categoryMap.set(categoryName, (categoryMap.get(categoryName) || 0) + 1);
+
+ if (place.live_status?.status === 'open') {
+ openNow += 1;
+ }
+
+ (place.offerings || [])
+ .filter(isAvailableOffering)
+ .forEach((offering) => {
+ availableOfferings.push({
+ name: offering.name,
+ place_name: place.name,
+ stock_label: offering.stock_label,
+ type: offering.offering_type,
+ });
+ });
+ });
+
+ return {
+ local_topics: Array.from(categoryMap.entries())
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, 4)
+ .map(([name, count]) => ({ name, count, label: `${count} listing aktif` })),
+ products: availableOfferings.slice(0, 6),
+ events: [
+ 'Promo lokal dan event sekitar siap diindeks dari panel admin.',
+ 'Crawler berita/video/PDF bisa ditambahkan sebagai tahap berikutnya.',
+ ],
+ live: {
+ open_now: openNow,
+ total: places.length,
+ },
+ };
+};
+
+const loadPublicPlaces = async (category) => db.places.findAll({
+ where: {
+ status: 'published',
+ },
+ include: [
+ {
+ model: db.place_categories,
+ as: 'category',
+ attributes: ['id', 'name', 'slug', 'description', 'color_hex'],
+ where: buildCategoryWhere(category),
+ required: true,
+ },
+ offeringsInclude,
+ ],
+ limit: CANDIDATE_LIMIT,
+ order: [
+ ['is_verified', 'DESC'],
+ ['rating_average', 'DESC'],
+ ['name', 'ASC'],
+ ],
+});
+
+router.get('/places/categories', wrapAsync(async (req, res) => {
+ const categories = await db.place_categories.findAll({
+ attributes: ['id', 'name', 'slug', 'description', 'color_hex', 'is_active'],
+ where: { is_active: true },
+ order: [['name', 'ASC']],
+ });
+
+ res.status(200).send({ rows: categories });
+}));
+
+router.get('/places/:id', wrapAsync(async (req, res) => {
+ const lat = parseCoordinate(req.query.lat, 'Latitude');
+ const lng = parseCoordinate(req.query.lng, 'Longitude');
+ const query = String(req.query.q || '').trim();
+ const queryIntent = analyzeHyperlocalIntent(query);
+ const radiusKm = queryIntent.requestedRadiusKm || parseRadius(req.query.radiusKm);
+ const origin = lat !== null && lng !== null ? { lat, lng } : null;
+
+ const place = await db.places.findOne({
+ where: {
+ id: req.params.id,
+ status: 'published',
+ },
+ include: [
+ {
+ model: db.place_categories,
+ as: 'category',
+ attributes: ['id', 'name', 'slug', 'description', 'color_hex'],
+ where: { is_active: true },
+ required: true,
+ },
+ offeringsInclude,
+ ],
+ });
+
+ if (!place) {
+ const error = new Error('Tempat tidak ditemukan. Pastikan tempat sudah berstatus published.');
+ error.code = 404;
+ throw error;
+ }
+
+ const publicPlace = toPublicPlace(place, origin);
+ const geoScore = calculateGeoScore(publicPlace, query, radiusKm);
+
+ res.status(200).send({
+ ...publicPlace,
+ search_score: geoScore.value,
+ geo_score: geoScore.value,
+ geo_score_breakdown: geoScore.components,
+ geo_score_formula: GEO_SCORE_FORMULA,
+ radius_zone: getRadiusZone(publicPlace.distance_km, radiusKm),
+ query_intent: queryIntent,
+ ai_recommendation: buildAiRecommendation(publicPlace, query),
+ });
+}));
+
+router.get('/places', wrapAsync(async (req, res) => {
+ const query = String(req.query.q || '').trim();
+ const category = String(req.query.category || '').trim();
+ const lat = parseCoordinate(req.query.lat, 'Latitude');
+ const lng = parseCoordinate(req.query.lng, 'Longitude');
+ const queryIntent = analyzeHyperlocalIntent(query);
+ const radiusKm = queryIntent.requestedRadiusKm || parseRadius(req.query.radiusKm);
+ const origin = lat !== null && lng !== null ? { lat, lng } : null;
+ const rawLimit = Number(req.query.limit || DEFAULT_LIMIT);
+ const limit = Number.isFinite(rawLimit)
+ ? Math.min(Math.max(Math.round(rawLimit), 1), MAX_LIMIT)
+ : DEFAULT_LIMIT;
+ const hasQuery = tokenizeQuery(query).length > 0;
+
+ const places = await loadPublicPlaces(category);
+ const publicPlaces = places.map((place) => toPublicPlace(place, origin));
+ const radiusFilteredPlaces = origin
+ ? publicPlaces.filter((place) => place.distance_km !== null && place.distance_km <= radiusKm)
+ : publicPlaces;
+ const scoredRows = radiusFilteredPlaces
+ .map((place) => {
+ const geoScore = calculateGeoScore(place, query, radiusKm);
+
+ return {
+ place,
+ geoScore,
+ };
+ })
+ .filter((item) => !hasQuery || item.geoScore.components.relevance > 0)
+ .sort((a, b) => sortScoredPlaces(a, b, hasQuery, queryIntent));
+
+ const rows = scoredRows
+ .slice(0, limit)
+ .map((item) => ({
+ ...item.place,
+ search_score: item.geoScore.value,
+ geo_score: item.geoScore.value,
+ geo_score_breakdown: item.geoScore.components,
+ radius_zone: getRadiusZone(item.place.distance_km, radiusKm),
+ ai_recommendation: buildAiRecommendation(item.place, query),
+ }));
+
+ res.status(200).send({
+ rows,
+ count: scoredRows.length,
+ radius_km: origin ? radiusKm : null,
+ radius_zone: getRadiusZone(null, radiusKm),
+ query_intent: queryIntent,
+ radius_zones: RADIUS_ZONES,
+ distance_buckets: buildDistanceBuckets(publicPlaces),
+ filtered_by_radius: Boolean(origin),
+ total_candidates: publicPlaces.length,
+ geo_score_formula: GEO_SCORE_FORMULA,
+ trending: buildTrending(rows.length ? rows : publicPlaces.slice(0, limit)),
+ recommendation_engine: 'rules_based_geo_score_mvp',
+ });
+}));
+
+router.get('/trending', wrapAsync(async (req, res) => {
+ const lat = parseCoordinate(req.query.lat, 'Latitude');
+ const lng = parseCoordinate(req.query.lng, 'Longitude');
+ const origin = lat !== null && lng !== null ? { lat, lng } : null;
+ const places = await loadPublicPlaces('');
+ const publicPlaces = places.map((place) => toPublicPlace(place, origin));
+
+ res.status(200).send(buildTrending(publicPlaces));
+}));
+
+router.use('/', commonErrorHandler);
+
+module.exports = router;
diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx
index 72935e6..fcbd9b9 100644
--- a/frontend/src/components/NavBarItem.tsx
+++ b/frontend/src/components/NavBarItem.tsx
@@ -1,6 +1,5 @@
-import React, {useEffect, useRef} from 'react'
+import React, { useEffect, useRef, useState } from 'react'
import Link from 'next/link'
-import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon'
diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx
index 1b9907d..73d8391 100644
--- a/frontend/src/layouts/Authenticated.tsx
+++ b/frontend/src/layouts/Authenticated.tsx
@@ -1,5 +1,4 @@
-import React, { ReactNode, useEffect } from 'react'
-import { useState } from 'react'
+import React, { ReactNode, useEffect, useState } from 'react'
import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside'
diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx
index 9c34e90..9737669 100644
--- a/frontend/src/pages/index.tsx
+++ b/frontend/src/pages/index.tsx
@@ -1,166 +1,1131 @@
+import React, { ReactElement, useCallback, useEffect, useMemo, useState } from 'react'
+import Head from 'next/head'
+import Link from 'next/link'
+import axios from 'axios'
+import {
+ mdiArrowRight,
+ mdiCash,
+ mdiChartTimelineVariant,
+ mdiClockOutline,
+ mdiCrosshairsGps,
+ mdiDatabaseSearchOutline,
+ mdiFire,
+ mdiLayersTripleOutline,
+ mdiMagnify,
+ mdiMapMarkerDistance,
+ mdiMapMarkerRadiusOutline,
+ mdiMapSearchOutline,
+ mdiNavigationVariantOutline,
+ mdiPackageVariantClosed,
+ mdiRadar,
+ mdiRobotHappyOutline,
+ mdiShieldCheckOutline,
+ mdiStar,
+ mdiStorefrontOutline,
+ mdiTextSearch,
+ mdiTrendingUp,
+} from '@mdi/js'
+import BaseButton from '../components/BaseButton'
+import BaseIcon from '../components/BaseIcon'
+import LayoutGuest from '../layouts/Guest'
+import { getPageTitle } from '../config'
-import React, { useEffect, useState } from 'react';
-import type { ReactElement } from 'react';
-import Head from 'next/head';
-import Link from 'next/link';
-import BaseButton from '../components/BaseButton';
-import CardBox from '../components/CardBox';
-import SectionFullScreen from '../components/SectionFullScreen';
-import LayoutGuest from '../layouts/Guest';
-import BaseDivider from '../components/BaseDivider';
-import BaseButtons from '../components/BaseButtons';
-import { getPageTitle } from '../config';
-import { useAppSelector } from '../stores/hooks';
-import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
-import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
-
-
-export default function Starter() {
- const [illustrationImage, setIllustrationImage] = useState({
- src: undefined,
- photographer: undefined,
- photographer_url: undefined,
- })
- const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
- const [contentType, setContentType] = useState('video');
- const [contentPosition, setContentPosition] = useState('right');
- const textColor = useAppSelector((state) => state.style.linkColor);
-
- const title = 'Pencari Tempat Terdekat'
-
- // Fetch Pexels image/video
- useEffect(() => {
- async function fetchData() {
- const image = await getPexelsImage();
- const video = await getPexelsVideo();
- setIllustrationImage(image);
- setIllustrationVideo(video);
- }
- fetchData();
- }, []);
-
- const imageBlock = (image) => (
-
- );
-
- const videoBlock = (video) => {
- if (video?.video_files?.length > 0) {
- return (
-
-
-
- Your browser does not support the video tag.
-
-
-
)
- }
- };
-
- return (
-
-
-
{getPageTitle('Starter Page')}
-
-
-
-
- {contentType === 'image' && contentPosition !== 'background'
- ? imageBlock(illustrationImage)
- : null}
- {contentType === 'video' && contentPosition !== 'background'
- ? videoBlock(illustrationVideo)
- : null}
-
-
-
-
-
© 2026 {title} . All rights reserved
-
- Privacy Policy
-
-
-
-
- );
+type Category = {
+ id: string
+ name: string
+ slug: string
+ description?: string
+ color_hex?: string
}
-Starter.getLayout = function getLayout(page: ReactElement) {
- return {page} ;
-};
+type Offering = {
+ id: string
+ name: string
+ description?: string
+ offering_type: 'product' | 'service'
+ price?: number | null
+ stock_status: 'in_stock' | 'limited' | 'out_of_stock' | 'by_request'
+ stock_label: string
+ stock_quantity?: number | null
+ is_verified?: boolean
+}
+type OfferingSummary = {
+ total: number
+ available: number
+ products: number
+ services: number
+ verified: number
+ top_available: Offering[]
+}
+
+type GeoScoreBreakdown = {
+ relevance: number
+ distance: number
+ reputation: number
+ activity: number
+ interaction: number
+}
+
+type RadiusZone = {
+ value: number
+ label: string
+ range: string
+ description?: string
+}
+
+type LiveStatus = {
+ status: 'open' | 'closed'
+ label: string
+ crowd: string
+ updated_label: string
+ source?: string
+}
+
+type AiRecommendation = {
+ label: string
+ reason: string
+ source?: string
+}
+
+type PublicPlace = {
+ id: string
+ name: string
+ short_description?: string
+ address?: string
+ city?: string
+ province?: string
+ latitude?: number | null
+ longitude?: number | null
+ phone_number?: string
+ whatsapp_number?: string
+ google_maps_url?: string
+ website_url?: string
+ price_level?: string
+ average_price?: number | null
+ rating_average?: number | null
+ rating_count?: number
+ is_verified?: boolean
+ distance_km?: number | null
+ search_score?: number
+ geo_score?: number
+ geo_score_breakdown?: GeoScoreBreakdown
+ radius_zone?: RadiusZone
+ live_status?: LiveStatus
+ ai_recommendation?: AiRecommendation
+ offerings?: Offering[]
+ offerings_summary?: OfferingSummary
+ category?: Category | null
+}
+
+type LocationState = {
+ lat: number
+ lng: number
+ label: string
+}
+
+type DistanceBucket = {
+ radius_km: number
+ label: string
+ zone_label: string
+ count: number
+}
+
+type TrendingMeta = {
+ local_topics: { name: string; count: number; label: string }[]
+ products: { name: string; place_name: string; stock_label: string; type: string }[]
+ events: string[]
+ live: { open_now: number; total: number }
+}
+
+type SearchMeta = {
+ count: number
+ radius_km?: number | null
+ radius_zone?: RadiusZone | null
+ radius_zones: RadiusZone[]
+ distance_buckets: DistanceBucket[]
+ filtered_by_radius: boolean
+ total_candidates: number
+ geo_score_formula: Record
+ trending?: TrendingMeta
+ recommendation_engine?: string
+}
+
+const DEFAULT_LOCATION: LocationState = {
+ lat: 0.5071,
+ lng: 101.4478,
+ label: 'Pekanbaru, Riau',
+}
+
+const DEFAULT_RADIUS_KM = 5
+const GLOBAL_RADIUS_KM = 20038
+
+const fallbackRadiusZones: RadiusZone[] = [
+ { value: 1, label: 'Walking Zone', range: '0–1 Km' },
+ { value: 5, label: 'Neighborhood Zone', range: '1–5 Km' },
+ { value: 25, label: 'City Zone', range: '5–25 Km' },
+ { value: 100, label: 'Regional Zone', range: '25–100 Km' },
+ { value: 500, label: 'Provincial Zone', range: '100–500 Km' },
+ { value: GLOBAL_RADIUS_KM, label: 'Global Zone', range: '500+ Km' },
+]
+
+const categoryFallbacks = ['Toko Interior', 'Cafe Melayu', 'Bengkel Mobil']
+
+const priceLabels: Record = {
+ budget: 'Ramah kantong',
+ midrange: 'Menengah',
+ premium: 'Premium',
+ unknown: 'Tanya harga',
+}
+
+const indexedLevels = [
+ {
+ level: 'Level 1',
+ title: 'Web & Marketplace',
+ icon: mdiTextSearch,
+ items: ['Website', 'Blog', 'Portal berita', 'Marketplace'],
+ },
+ {
+ level: 'Level 2',
+ title: 'Media & Dokumen',
+ icon: mdiLayersTripleOutline,
+ items: ['Video', 'Gambar', 'Podcast', 'PDF'],
+ },
+ {
+ level: 'Level 3',
+ title: 'Hyperlocal Places',
+ icon: mdiStorefrontOutline,
+ items: ['UMKM', 'Restoran', 'Bengkel', 'Klinik', 'Sekolah', 'Tempat ibadah', 'Wisata'],
+ },
+]
+
+type KeywordChip = {
+ label: string
+ query: string
+ score?: string
+ category?: string
+ radiusKm?: number
+}
+
+type KeywordGroup = {
+ title: string
+ description: string
+ keywords: KeywordChip[]
+}
+
+type PatternLevel = {
+ level: string
+ formula: string
+ examples: KeywordChip[]
+}
+
+const topLocationKeywords: KeywordChip[] = [
+ { label: 'Terdekat', query: 'terdekat', score: '10/10' },
+ { label: 'Dekat Saya', query: 'dekat saya', score: '10/10' },
+ { label: 'Near Me', query: 'near me', score: '10/10' },
+ { label: 'Sekitar Saya', query: 'sekitar saya', score: '9.9/10' },
+ { label: 'Lokasi Terdekat', query: 'lokasi terdekat', score: '9.9/10' },
+ { label: 'Buka Sekarang', query: 'buka sekarang', score: '9.8/10' },
+ { label: '24 Jam', query: '24 jam', score: '9.8/10' },
+ { label: 'Di Kota', query: 'di Pekanbaru', score: '9.7/10' },
+ { label: 'Di Kecamatan', query: 'di kecamatan Sukajadi', score: '9.7/10' },
+ { label: 'Dalam Radius 5 Km', query: 'dalam radius 5 km', score: '9.6/10', radiusKm: 5 },
+]
+
+const categoryKeywordGroups: KeywordGroup[] = [
+ {
+ title: 'Kuliner',
+ description: 'Intent restoran, cafe, warung makan, kuliner, dan makan enak.',
+ keywords: [
+ { label: 'Restoran terdekat', query: 'restoran terdekat', category: 'cafe-melayu' },
+ { label: 'Cafe terdekat', query: 'cafe terdekat', category: 'cafe-melayu' },
+ { label: 'Warung makan terdekat', query: 'warung makan terdekat', category: 'cafe-melayu' },
+ { label: 'Kuliner terdekat', query: 'kuliner terdekat', category: 'cafe-melayu' },
+ { label: 'Makan enak dekat saya', query: 'makan enak dekat saya', category: 'cafe-melayu' },
+ ],
+ },
+ {
+ title: 'Kesehatan',
+ description: 'Intent apotek, klinik, rumah sakit, dokter, dan layanan 24 jam.',
+ keywords: [
+ { label: 'Apotek terdekat', query: 'apotek terdekat', category: 'toko-herbal-kesehatan' },
+ { label: 'Klinik terdekat', query: 'klinik terdekat', category: 'toko-herbal-kesehatan' },
+ { label: 'Rumah sakit terdekat', query: 'rumah sakit terdekat', category: 'toko-herbal-kesehatan' },
+ { label: 'Dokter terdekat', query: 'dokter terdekat', category: 'toko-herbal-kesehatan' },
+ { label: 'Apotek 24 jam terdekat', query: 'apotek 24 jam terdekat', category: 'toko-herbal-kesehatan' },
+ ],
+ },
+ {
+ title: 'Otomotif',
+ description: 'Intent bengkel, tambal ban, cuci mobil, SPBU, dan servis kendaraan.',
+ keywords: [
+ { label: 'Bengkel terdekat', query: 'bengkel terdekat', category: 'bengkel-mobil' },
+ { label: 'Tambal ban terdekat', query: 'tambal ban terdekat', category: 'bengkel-mobil' },
+ { label: 'Cuci mobil terdekat', query: 'cuci mobil terdekat', category: 'bengkel-mobil' },
+ { label: 'SPBU terdekat', query: 'SPBU terdekat', category: 'bengkel-mobil' },
+ ],
+ },
+ {
+ title: 'Belanja',
+ description: 'Intent toko, minimarket, pasar, mall, produk, dan stok ready.',
+ keywords: [
+ { label: 'Toko terdekat', query: 'toko terdekat' },
+ { label: 'Minimarket terdekat', query: 'minimarket terdekat' },
+ { label: 'Pasar terdekat', query: 'pasar terdekat' },
+ { label: 'Mall terdekat', query: 'mall terdekat' },
+ ],
+ },
+ {
+ title: 'Wisata',
+ description: 'Intent tempat wisata, pantai, hotel, dan penginapan.',
+ keywords: [
+ { label: 'Tempat wisata terdekat', query: 'tempat wisata terdekat' },
+ { label: 'Pantai terdekat', query: 'pantai terdekat' },
+ { label: 'Hotel terdekat', query: 'hotel terdekat' },
+ { label: 'Penginapan terdekat', query: 'penginapan terdekat' },
+ ],
+ },
+]
+
+const requiredLocationKeywords: KeywordChip[] = [
+ { label: 'Terdekat', query: 'terdekat' },
+ { label: 'Dekat Saya', query: 'dekat saya' },
+ { label: 'Sekitar Saya', query: 'sekitar saya' },
+ { label: 'Buka Sekarang', query: 'buka sekarang' },
+ { label: '24 Jam', query: '24 jam' },
+ { label: 'Radius 1 Km', query: 'radius 1 km', radiusKm: 1 },
+ { label: 'Radius 5 Km', query: 'radius 5 km', radiusKm: 5 },
+ { label: 'Radius 10 Km', query: 'radius 10 km', radiusKm: 10 },
+ { label: 'Dalam Kota', query: 'dalam kota Pekanbaru' },
+ { label: 'Dalam Kecamatan', query: 'dalam kecamatan Sukajadi' },
+ { label: 'Dalam Kelurahan', query: 'dalam kelurahan' },
+ { label: 'Dalam Desa', query: 'dalam desa' },
+ { label: 'Populer', query: 'populer' },
+ { label: 'Rating Tertinggi', query: 'rating tertinggi' },
+ { label: 'Paling Ramai', query: 'paling ramai' },
+ { label: 'Paling Murah', query: 'paling murah' },
+ { label: 'Terlaris', query: 'terlaris' },
+ { label: 'Terbaru', query: 'terbaru' },
+ { label: 'Direkomendasikan', query: 'direkomendasikan' },
+ { label: 'Terverifikasi', query: 'terverifikasi' },
+]
+
+const topTrafficKeywords: KeywordChip[] = [
+ { label: 'Terdekat', query: 'terdekat' },
+ { label: 'Dekat Saya', query: 'dekat saya' },
+ { label: 'Near Me', query: 'near me' },
+ { label: 'Buka Sekarang', query: 'buka sekarang' },
+ { label: '24 Jam', query: '24 jam' },
+ { label: 'Restoran Terdekat', query: 'restoran terdekat', category: 'cafe-melayu' },
+ { label: 'Cafe Terdekat', query: 'cafe terdekat', category: 'cafe-melayu' },
+ { label: 'Warung Makan Terdekat', query: 'warung makan terdekat', category: 'cafe-melayu' },
+ { label: 'Kuliner Terdekat', query: 'kuliner terdekat', category: 'cafe-melayu' },
+ { label: 'Makan Enak Dekat Saya', query: 'makan enak dekat saya', category: 'cafe-melayu' },
+ { label: 'Apotek Terdekat', query: 'apotek terdekat', category: 'toko-herbal-kesehatan' },
+ { label: 'Klinik Terdekat', query: 'klinik terdekat', category: 'toko-herbal-kesehatan' },
+ { label: 'Rumah Sakit Terdekat', query: 'rumah sakit terdekat', category: 'toko-herbal-kesehatan' },
+ { label: 'Dokter Terdekat', query: 'dokter terdekat', category: 'toko-herbal-kesehatan' },
+ { label: 'Apotek 24 Jam Terdekat', query: 'apotek 24 jam terdekat', category: 'toko-herbal-kesehatan' },
+ { label: 'Bengkel Terdekat', query: 'bengkel terdekat', category: 'bengkel-mobil' },
+ { label: 'Tambal Ban Terdekat', query: 'tambal ban terdekat', category: 'bengkel-mobil' },
+ { label: 'Cuci Mobil Terdekat', query: 'cuci mobil terdekat', category: 'bengkel-mobil' },
+ { label: 'SPBU Terdekat', query: 'SPBU terdekat', category: 'bengkel-mobil' },
+ { label: 'Toko Terdekat', query: 'toko terdekat' },
+]
+
+const hyperlocalPatternLevels: PatternLevel[] = [
+ {
+ level: 'Level 1',
+ formula: '[Kategori] + Terdekat',
+ examples: [
+ { label: 'Hotel terdekat', query: 'hotel terdekat' },
+ { label: 'ATM terdekat', query: 'ATM terdekat' },
+ { label: 'Bengkel terdekat', query: 'bengkel terdekat', category: 'bengkel-mobil' },
+ ],
+ },
+ {
+ level: 'Level 2',
+ formula: '[Kategori] + Dekat Saya',
+ examples: [
+ { label: 'Restoran dekat saya', query: 'restoran dekat saya', category: 'cafe-melayu' },
+ { label: 'Apotek dekat saya', query: 'apotek dekat saya', category: 'toko-herbal-kesehatan' },
+ ],
+ },
+ {
+ level: 'Level 3',
+ formula: '[Kategori] + Kecamatan',
+ examples: [
+ { label: 'Klinik Cibinong', query: 'klinik Cibinong', category: 'toko-herbal-kesehatan' },
+ { label: 'Bengkel Citeureup', query: 'bengkel Citeureup', category: 'bengkel-mobil' },
+ ],
+ },
+ {
+ level: 'Level 4',
+ formula: '[Kategori] + Kota',
+ examples: [
+ { label: 'Hotel Bogor', query: 'hotel Bogor' },
+ { label: 'Wisata Bandung', query: 'wisata Bandung' },
+ ],
+ },
+ {
+ level: 'Level 5',
+ formula: '[Kategori] + Provinsi',
+ examples: [
+ { label: 'Wisata Jawa Barat', query: 'wisata Jawa Barat' },
+ { label: 'Hotel Bali', query: 'hotel Bali' },
+ ],
+ },
+]
+
+const totalKeywordChips = topLocationKeywords.length
+ + requiredLocationKeywords.length
+ + topTrafficKeywords.length
+ + categoryKeywordGroups.reduce((total, group) => total + group.keywords.length, 0)
+ + hyperlocalPatternLevels.reduce((total, pattern) => total + pattern.examples.length, 0)
+
+const formatRupiah = (value?: number | null) => {
+ if (!value) return 'Tanya harga'
+ return new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', maximumFractionDigits: 0 }).format(value)
+}
+
+const formatRadius = (value: number) => (value >= GLOBAL_RADIUS_KM ? '500+ km' : `${value} km`)
+
+const stockColor = (status: string) => {
+ if (status === 'in_stock') return 'bg-[#E8F6F1] text-[#087F6D]'
+ if (status === 'limited') return 'bg-[#FFF3D7] text-[#9A6500]'
+ if (status === 'out_of_stock') return 'bg-[#FFE7E0] text-[#A23A24]'
+ return 'bg-[#F7F2E8] text-[#5D6B62]'
+}
+
+const scoreLabels: Record = {
+ relevance: 'Relevansi kata kunci',
+ distance: 'Jarak',
+ reputation: 'Rating/reputasi',
+ activity: 'Aktivitas terbaru',
+ interaction: 'Interaksi pengguna',
+}
+
+export default function Starter() {
+ const [query, setQuery] = useState('')
+ const [category, setCategory] = useState('')
+ const [radiusKm, setRadiusKm] = useState(DEFAULT_RADIUS_KM)
+ const [categories, setCategories] = useState([])
+ const [places, setPlaces] = useState([])
+ const [searchMeta, setSearchMeta] = useState({
+ count: 0,
+ radius_km: DEFAULT_RADIUS_KM,
+ radius_zone: fallbackRadiusZones[1],
+ radius_zones: fallbackRadiusZones,
+ distance_buckets: [],
+ filtered_by_radius: true,
+ total_candidates: 0,
+ geo_score_formula: { relevance: 40, distance: 25, reputation: 15, activity: 10, interaction: 10 },
+ })
+ const [loading, setLoading] = useState(false)
+ const [categoriesLoading, setCategoriesLoading] = useState(true)
+ const [error, setError] = useState('')
+ const [location, setLocation] = useState(DEFAULT_LOCATION)
+ const [locationStatus, setLocationStatus] = useState('')
+
+ const activeCategoryName = useMemo(() => {
+ return categories.find((item) => item.slug === category || item.id === category)?.name || 'Semua kategori'
+ }, [categories, category])
+
+ const radiusZones = searchMeta.radius_zones?.length ? searchMeta.radius_zones : fallbackRadiusZones
+ const selectedZone = searchMeta.radius_zone || radiusZones.find((item) => item.value === radiusKm) || fallbackRadiusZones[1]
+ const featuredPlaces = places.slice(0, 3)
+ const categoryChips = categories.length ? categories : categoryFallbacks.map((name) => ({ id: name, name, slug: name }))
+ const formulaEntries = Object.entries(searchMeta.geo_score_formula || {})
+ const openNow = searchMeta.trending?.live?.open_now || places.filter((place) => place.live_status?.status === 'open').length
+
+ const fetchPlaces = useCallback(async (
+ nextQuery: string,
+ nextCategory: string,
+ nextLocation: LocationState,
+ nextRadiusKm: number,
+ ) => {
+ setLoading(true)
+ setError('')
+
+ try {
+ const response = await axios.get('/public/places', {
+ params: {
+ q: nextQuery,
+ category: nextCategory,
+ lat: nextLocation.lat,
+ lng: nextLocation.lng,
+ radiusKm: nextRadiusKm,
+ limit: 18,
+ },
+ })
+
+ setPlaces(Array.isArray(response.data?.rows) ? response.data.rows : [])
+ setSearchMeta({
+ count: Number(response.data?.count || 0),
+ radius_km: response.data?.radius_km,
+ radius_zone: response.data?.radius_zone || null,
+ radius_zones: Array.isArray(response.data?.radius_zones) ? response.data.radius_zones : fallbackRadiusZones,
+ distance_buckets: Array.isArray(response.data?.distance_buckets) ? response.data.distance_buckets : [],
+ filtered_by_radius: Boolean(response.data?.filtered_by_radius),
+ total_candidates: Number(response.data?.total_candidates || 0),
+ geo_score_formula: response.data?.geo_score_formula || { relevance: 40, distance: 25, reputation: 15, activity: 10, interaction: 10 },
+ trending: response.data?.trending,
+ recommendation_engine: response.data?.recommendation_engine,
+ })
+ } catch (err) {
+ console.error('Gagal memuat GeoSeek public search', err)
+ setError('Maaf, pencarian GeoSeek belum bisa dimuat. Coba ulang beberapa saat lagi.')
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ const loadCategories = async () => {
+ setCategoriesLoading(true)
+
+ try {
+ const response = await axios.get('/public/places/categories')
+ setCategories(Array.isArray(response.data?.rows) ? response.data.rows : [])
+ } catch (err) {
+ console.error('Gagal memuat kategori publik GeoSeek', err)
+ setCategories([])
+ } finally {
+ setCategoriesLoading(false)
+ }
+ }
+
+ loadCategories()
+ }, [])
+
+ useEffect(() => {
+ const timeout = window.setTimeout(() => {
+ fetchPlaces(query, category, location, radiusKm)
+ }, 420)
+
+ return () => window.clearTimeout(timeout)
+ }, [query, category, radiusKm, location, fetchPlaces])
+
+ const handleSearch = async (event: React.FormEvent) => {
+ event.preventDefault()
+ await fetchPlaces(query, category, location, radiusKm)
+ }
+
+ const useCurrentLocation = () => {
+ if (!navigator.geolocation) {
+ setLocationStatus('Browser tidak mendukung lokasi')
+ return
+ }
+
+ setLocationStatus('Mengambil lokasi Anda...')
+ navigator.geolocation.getCurrentPosition(
+ (position) => {
+ const nextLocation = {
+ lat: position.coords.latitude,
+ lng: position.coords.longitude,
+ label: 'Lokasi saya',
+ }
+ setLocation(nextLocation)
+ setLocationStatus('Lokasi aktif: lokasi saya')
+ },
+ (geoError) => {
+ console.error('Izin lokasi ditolak atau gagal', geoError)
+ setLocationStatus('Lokasi tidak diizinkan')
+ },
+ { enableHighAccuracy: true, timeout: 8000 },
+ )
+ }
+
+ const applyKeyword = (keyword: KeywordChip) => {
+ setQuery(keyword.query)
+
+ if (keyword.category !== undefined) {
+ setCategory(keyword.category)
+ }
+
+ if (keyword.radiusKm) {
+ setRadiusKm(keyword.radiusKm)
+ }
+
+ window.setTimeout(() => {
+ document.getElementById('results')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
+ }, 160)
+ }
+
+ return (
+ <>
+
+ {getPageTitle('GeoSeek 2.0 Hyperlocal Search')}
+
+
+
+
+
+
+
+
+
+
+ GS
+ GeoSeek 2.0
+
+
+
+
+
+
+
+
+
+ Konten paling dekat • paling relevan • paling berguna saat ini
+
+
+ Hyperlocal search ecosystem untuk UMKM, produk, stok, dan jasa sekitar.
+
+
+ GeoSeek tidak hanya mengurutkan berdasarkan jarak. Ranking memakai GeoScore: relevansi kata kunci, jarak, reputasi, aktivitas terbaru, dan interaksi pengguna.
+
+
+
+
+
+ setCategory('')}
+ className={`rounded-full px-4 py-2 text-sm font-bold transition ${category === '' ? 'bg-white text-[#073B3A]' : 'bg-white/10 text-white/80 hover:bg-white/20'}`}
+ >
+ Semua
+
+ {categoryChips.map((item) => (
+ setCategory(item.slug || item.id)}
+ className={`rounded-full px-4 py-2 text-sm font-bold transition ${category === (item.slug || item.id) ? 'bg-[#F2A541] text-[#073B3A]' : 'bg-white/10 text-white/80 hover:bg-white/20'}`}
+ >
+ {item.name}
+
+ ))}
+
+
+
+
+
+
+
+
+
Live Nearby Map
+
{selectedZone.label}
+
+
{location.label}
+
+
+
+
+
+
+
+
+
+ {featuredPlaces.map((place, index) => (
+
+
{place.name}
+
{place.distance_km !== null && place.distance_km !== undefined ? `${place.distance_km} km` : 'Lihat detail'}
+
GeoScore {place.geo_score || place.search_score || '-'}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Keyword Engine GeoSeek
+
Kata kunci lokasi yang paling banyak dicari.
+
Klik chip untuk langsung live search. GeoSeek mengenali intent seperti terdekat, dekat saya, near me, buka sekarang, 24 jam, radius, kategori, kota, kecamatan, rating, populer, murah, terbaru, dan terverifikasi.
+
+
+ {totalKeywordChips}
+ keyword siap klik + sinonim kategori
+
+
+
+
+
+
+
+
+
+
+
Urut skor pencarian
+
Kata kunci lokasi utama
+
+
+
+ {topLocationKeywords.map((keyword) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
Kombinasi kategori
+
Kuliner, kesehatan, otomotif, belanja, wisata
+
+
+
+ {categoryKeywordGroups.map((group) => (
+
+ ))}
+
+
+
+
+
+
+
+ Keyword wajib GeoSeek
+
+
+ {requiredLocationKeywords.map((keyword) => (
+
+ ))}
+
+
+
+
+
+ Top 20 keyword trafik tertinggi
+
+
+ {topTrafficKeywords.map((keyword, index) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
Pola hyperlocal
+
Level 1 sampai Level 5 keyword lokasi
+
+
+
+ {hyperlocalPatternLevels.map((pattern) => (
+
+
{pattern.level}
+
{pattern.formula}
+
+ {pattern.examples.map((keyword) => (
+
+ ))}
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
Formula Ranking Baru
+
GeoScore tidak hanya jarak.
+
Toko yang sangat dekat belum tentu menang jika rating rendah, stok tidak aktif, dan data jarang diperbarui.
+
+
+ {formulaEntries.map(([key, value]) => (
+
+
+
{scoreLabels[key as keyof GeoScoreBreakdown] || key}
+
{value}%
+
+
+
+ ))}
+
+
+
+
+
+
+
Live Search Results
+
Konten paling dekat, relevan, dan berguna.
+
Radius aktif: {selectedZone.range} · {selectedZone.label} . Ditemukan {searchMeta.count} hasil dari {searchMeta.total_candidates} kandidat terindeks.
+
+
+
+
+ {searchMeta.distance_buckets.length ? (
+
+
+ Search by Distance
+
+
+ {searchMeta.distance_buckets.map((bucket) => (
+ setRadiusKm(bucket.radius_km)}
+ className={`rounded-full px-4 py-2 text-xs font-black transition ${radiusKm === bucket.radius_km ? 'bg-[#073B3A] text-white' : 'bg-[#F7F2E8] text-[#5D6B62] hover:bg-[#E8F6F1]'}`}
+ >
+ {bucket.label} · {bucket.count} hasil
+
+ ))}
+
+
+ ) : null}
+
+ {error ? (
+ {error}
+ ) : null}
+
+ {loading ? (
+
+ {[0, 1, 2].map((item) => (
+
+ ))}
+
+ ) : places.length ? (
+
+ {places.map((place) => (
+
+
+
+ {place.category?.name || 'Tempat'}
+
+ {place.is_verified ? (
+
+ Verified
+
+ ) : null}
+
+
+
+
+
{place.name}
+
{place.radius_zone?.label || selectedZone.label}
+
+
+
GeoScore
+
{place.geo_score || place.search_score || '-'}
+
+
+
+ {place.short_description || 'Detail tempat akan tampil di sini setelah admin melengkapinya.'}
+
+
+
+
+
+
+
+
+ {place.geo_score_breakdown ? (
+
+
Breakdown GeoScore
+ {(Object.entries(place.geo_score_breakdown) as [keyof GeoScoreBreakdown, number][]).map(([key, value]) => (
+
+ ))}
+
+ ) : null}
+
+ {place.offerings_summary?.top_available?.length ? (
+
+
Produk / jasa tersedia
+
+ {place.offerings_summary.top_available.map((offering) => (
+
+ {offering.name} · {offering.stock_label}
+
+ ))}
+
+
+ ) : null}
+
+
+
+ {place.ai_recommendation?.label || 'AI Recommendation'}
+
+
{place.ai_recommendation?.reason || 'Rekomendasi dibuat dari sinyal lokal dan GeoScore.'}
+
+
+ {place.address || [place.city, place.province].filter(Boolean).join(', ')}
+
+
+ Lihat detail, stok & arah
+
+
+
+ ))}
+
+ ) : (
+
+
Belum ada hasil dalam radius ini
+
Perbesar radius ke City/Regional Zone, coba kata kunci seperti “cafe melayu”, “servis oli”, “kitchen set”, atau minta admin menambahkan listing baru.
+
+
+
+
+ )}
+
+ {categoriesLoading ? Memuat kategori...
: null}
+
+
+
+
+
+
+
Hyperlocal Trending
+
Yang sedang berguna di sekitar Anda.
+
+
+ {openNow} buka sekarang dari {places.length || searchMeta.trending?.live?.total || 0} hasil
+
+
+
+
+ `${item.name} · ${item.label}`)}
+ fallback='Kategori lokal akan muncul setelah data terindeks.'
+ />
+ `${item.name} di ${item.place_name} · ${item.stock_label}`)}
+ fallback='Produk/stok UMKM akan muncul setelah listing diperbarui.'
+ />
+
+
+
+
+
+
+
+
Data yang diindeks
+
Fondasi menuju Hyperlocal Search Ecosystem.
+
MVP saat ini sudah mengindeks tempat, kategori, produk, jasa, stok, rating, dan lokasi. Struktur UI disiapkan untuk memperluas ke website, media, PDF, crawler, PostGIS, dan Elasticsearch.
+
+
+ {indexedLevels.map((level) => (
+
+
+
+
+
{level.level}
+
{level.title}
+
+ {level.items.map((item) => (
+ {item}
+ ))}
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
+
+const KeywordButton = ({
+ keyword,
+ onClick,
+ showScore = false,
+ compact = false,
+ rank,
+}: {
+ keyword: KeywordChip
+ onClick: (keyword: KeywordChip) => void
+ showScore?: boolean
+ compact?: boolean
+ rank?: number
+}) => (
+ onClick(keyword)}
+ className={`group inline-flex items-center gap-2 rounded-full border border-[#E0D6C3] bg-white px-3 font-black text-[#073B3A] shadow-sm transition hover:-translate-y-0.5 hover:border-[#2CA58D] hover:bg-[#E8F6F1] ${compact ? 'py-1.5 text-xs' : 'py-2 text-sm'}`}
+ title={`Cari: ${keyword.query}`}
+ >
+ {rank ? {rank} : null}
+ {keyword.label}
+ {showScore && keyword.score ? {keyword.score} : null}
+
+)
+
+const KeywordGroupCard = ({ group, onKeywordClick }: { group: KeywordGroup; onKeywordClick: (keyword: KeywordChip) => void }) => (
+
+
{group.title}
+
{group.description}
+
+ {group.keywords.map((keyword) => (
+ onKeywordClick(keyword)}
+ className='rounded-full bg-white px-3 py-1.5 text-xs font-black text-[#073B3A] transition hover:-translate-y-0.5 hover:bg-[#F2A541]'
+ title={`Cari: ${keyword.query}`}
+ >
+ {keyword.label}
+
+ ))}
+
+
+)
+
+const MetricCard = ({ value, label }: { value: string; label: string }) => (
+
+)
+
+const InfoTile = ({ label, value, icon }: { label: string; value: string; icon: string }) => (
+
+)
+
+const ScoreBar = ({ label, value }: { label: string; value: number }) => (
+
+
+ {label}
+ {Math.round(value)}
+
+
+
+)
+
+const TrendingCard = ({ icon, title, items, fallback }: { icon: string; title: string; items: string[]; fallback: string }) => (
+
+
+
{title}
+
+ {(items.length ? items : [fallback]).map((item) => (
+
{item}
+ ))}
+
+
+)
+
+const FeatureBox = ({ icon, title, text }: { icon: string; title: string; text: string }) => (
+
+)
+
+Starter.getLayout = function getLayout(page: ReactElement) {
+ return {page}
+}
diff --git a/frontend/src/pages/tempat/[placeId].tsx b/frontend/src/pages/tempat/[placeId].tsx
new file mode 100644
index 0000000..dbc365f
--- /dev/null
+++ b/frontend/src/pages/tempat/[placeId].tsx
@@ -0,0 +1,425 @@
+import React, { ReactElement, useEffect, useMemo, useState } from 'react'
+import Head from 'next/head'
+import Link from 'next/link'
+import { useRouter } from 'next/router'
+import axios from 'axios'
+import {
+ mdiArrowLeft,
+ mdiCash,
+ mdiChartTimelineVariant,
+ mdiClockOutline,
+ mdiCrosshairsGps,
+ mdiMapMarkerRadiusOutline,
+ mdiNavigationVariantOutline,
+ mdiOpenInNew,
+ mdiPackageVariantClosed,
+ mdiPhoneOutline,
+ mdiRobotHappyOutline,
+ mdiShieldCheckOutline,
+ mdiStar,
+ mdiWeb,
+ mdiWhatsapp,
+} from '@mdi/js'
+import BaseButton from '../../components/BaseButton'
+import BaseIcon from '../../components/BaseIcon'
+import LayoutGuest from '../../layouts/Guest'
+import { getPageTitle } from '../../config'
+
+type Category = {
+ id: string
+ name: string
+ slug: string
+ description?: string
+ color_hex?: string
+}
+
+type Offering = {
+ id: string
+ name: string
+ description?: string
+ offering_type: 'product' | 'service'
+ price?: number | null
+ stock_status: 'in_stock' | 'limited' | 'out_of_stock' | 'by_request'
+ stock_label: string
+ stock_quantity?: number | null
+ is_verified?: boolean
+}
+
+type OfferingSummary = {
+ total: number
+ available: number
+ products: number
+ services: number
+ verified: number
+ top_available: Offering[]
+}
+
+type GeoScoreBreakdown = {
+ relevance: number
+ distance: number
+ reputation: number
+ activity: number
+ interaction: number
+}
+
+type RadiusZone = {
+ value: number
+ label: string
+ range: string
+ description?: string
+}
+
+type LiveStatus = {
+ status: 'open' | 'closed'
+ label: string
+ crowd: string
+ updated_label: string
+ source?: string
+}
+
+type AiRecommendation = {
+ label: string
+ reason: string
+ source?: string
+}
+
+type PublicPlace = {
+ id: string
+ name: string
+ short_description?: string
+ full_description?: string
+ address?: string
+ city?: string
+ province?: string
+ latitude?: number | null
+ longitude?: number | null
+ phone_number?: string
+ whatsapp_number?: string
+ website_url?: string
+ google_maps_url?: string
+ price_level?: string
+ average_price?: number | null
+ rating_average?: number | null
+ rating_count?: number
+ is_verified?: boolean
+ distance_km?: number | null
+ search_score?: number
+ geo_score?: number
+ geo_score_breakdown?: GeoScoreBreakdown
+ geo_score_formula?: Record
+ radius_zone?: RadiusZone
+ live_status?: LiveStatus
+ ai_recommendation?: AiRecommendation
+ offerings?: Offering[]
+ offerings_summary?: OfferingSummary
+ category?: Category | null
+}
+
+const priceLabels: Record = {
+ budget: 'Ramah kantong',
+ midrange: 'Menengah',
+ premium: 'Premium',
+ unknown: 'Tanya harga',
+}
+
+const scoreLabels: Record = {
+ relevance: 'Relevansi kata kunci',
+ distance: 'Jarak',
+ reputation: 'Rating/reputasi',
+ activity: 'Aktivitas terbaru',
+ interaction: 'Interaksi pengguna',
+}
+
+const formatRupiah = (value?: number | null) => {
+ if (!value) return 'Tanya tempat'
+ return new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', maximumFractionDigits: 0 }).format(value)
+}
+
+const stockColor = (status: string) => {
+ if (status === 'in_stock') return 'bg-[#E8F6F1] text-[#087F6D]'
+ if (status === 'limited') return 'bg-[#FFF3D7] text-[#9A6500]'
+ if (status === 'out_of_stock') return 'bg-[#FFE7E0] text-[#A23A24]'
+ return 'bg-[#F7F2E8] text-[#5D6B62]'
+}
+
+export default function PlaceDetailPage() {
+ const router = useRouter()
+ const { placeId, lat, lng, radiusKm, q } = router.query
+ const [place, setPlace] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+
+ const mapsUrl = useMemo(() => {
+ if (place?.google_maps_url) return place.google_maps_url
+ if (place?.latitude && place?.longitude) return `https://maps.google.com/?q=${place.latitude},${place.longitude}`
+ return `https://maps.google.com/?q=${encodeURIComponent(place?.address || place?.name || '')}`
+ }, [place])
+
+ const queryText = Array.isArray(q) ? q[0] : q
+ const topOfferings = place?.offerings_summary?.top_available || []
+ const formulaEntries = Object.entries(place?.geo_score_formula || { relevance: 40, distance: 25, reputation: 15, activity: 10, interaction: 10 })
+
+ useEffect(() => {
+ if (!placeId || Array.isArray(placeId)) return
+
+ const fetchPlace = async () => {
+ setLoading(true)
+ setError('')
+
+ try {
+ const response = await axios.get(`/public/places/${placeId}`, {
+ params: {
+ lat: Array.isArray(lat) ? lat[0] : lat,
+ lng: Array.isArray(lng) ? lng[0] : lng,
+ radiusKm: Array.isArray(radiusKm) ? radiusKm[0] : radiusKm,
+ q: queryText,
+ },
+ })
+ setPlace(response.data)
+ } catch (err) {
+ console.error('Gagal memuat detail tempat publik GeoSeek', err)
+ setError('Tempat tidak ditemukan atau belum bisa dimuat.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ fetchPlace()
+ }, [placeId, lat, lng, radiusKm, queryText])
+
+ return (
+ <>
+
+ {getPageTitle(place?.name || 'Detail GeoSeek')}
+
+
+
+
+
+
+
GS
+ GeoSeek 2.0
+
+
+
+
+
+
+
+
+
+
+ Kembali ke GeoSeek
+
+
+ {loading ? (
+
+ ) : error || !place ? (
+
+
Detail belum tersedia
+
{error || 'Tempat tidak ditemukan.'}
+
+
+
+
+ ) : (
+
+
+
+
+
+
+
+ {place.category?.name || 'Tempat'}
+
+
{place.name}
+
{place.short_description || 'Tempat lokal yang bisa ditemukan berdasarkan lokasi dan kategori.'}
+
+
+ {place.is_verified ? (
+
+ Verified
+
+ ) : null}
+
+
GeoScore
+
{place.geo_score || place.search_score || '-'}
+
{place.radius_zone?.range} · {place.radius_zone?.label}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Tentang tempat ini
+
+ {place.full_description || place.short_description || 'Admin belum menambahkan deskripsi lengkap untuk tempat ini.'}
+
+
+
+
Alamat & arah
+
{place.address || [place.city, place.province].filter(Boolean).join(', ') || 'Alamat belum tersedia'}
+
+ Buka arah di Maps
+
+
+
+
+
+
+
+ Breakdown GeoScore
+
+ {place.geo_score_breakdown ? (
+ (Object.entries(place.geo_score_breakdown) as [keyof GeoScoreBreakdown, number][]).map(([key, value]) => (
+
formulaKey === key)?.[1] || 0}%`} value={value} />
+ ))
+ ) : (
+ Breakdown belum tersedia.
+ )}
+
+
+
+
+ AI Recommendation
+
+
{place.ai_recommendation?.label || 'Rekomendasi lokal'}
+
{place.ai_recommendation?.reason || 'Rekomendasi dibuat dari sinyal lokal dan GeoScore.'}
+
{place.ai_recommendation?.source || 'GeoScore rules-based MVP'}
+
+
+
+
+
+
+
Produk, stok & jasa
+
Produk {place.offerings_summary?.products || 0} · Jasa {place.offerings_summary?.services || 0} · Tersedia {place.offerings_summary?.available || 0}
+
+
+ {place.live_status?.updated_label || 'Update berkala'}
+
+
+
+
+ {(place.offerings || []).length ? (place.offerings || []).map((offering) => (
+
+
+
+
{offering.name}
+
{offering.description || 'Detail belum tersedia.'}
+
+ {offering.is_verified ?
: null}
+
+
+ {offering.stock_label}
+ {offering.offering_type === 'service' ? 'Jasa' : 'Produk'}
+ {formatRupiah(offering.price)}
+
+
+ )) : (
+
Belum ada produk atau jasa. Pemilik listing dapat menambahkannya dari panel admin.
+ )}
+
+
+
+
+
+
+
+
Kontak cepat
+
+ {place.whatsapp_number ? (
+
+ WhatsApp
+
+ ) : null}
+ {place.phone_number ? (
+
+ {place.phone_number}
+
+ ) : null}
+ {place.website_url ? (
+
+ Website
+
+ ) : null}
+ {!place.whatsapp_number && !place.phone_number && !place.website_url ? (
+
Kontak belum tersedia. Admin bisa menambahkannya dari panel admin.
+ ) : null}
+
+
+
+
+
Ringkasan sinyal lokal
+
+
+
+
+
+
+
+
+
+
+
Untuk pemilik data
+
Masuk ke panel admin untuk memperbarui detail, kontak, kategori, rating, status tempat, produk, jasa, dan stok.
+
+
+
+
+
+
+ )}
+
+
+ >
+ )
+}
+
+const InfoCard = ({ label, value, icon }: { label: string; value: string; icon?: string }) => (
+
+
+ {icon ? : null}
+ {label}
+
+
{value}
+
+)
+
+const ScoreBar = ({ label, value }: { label: string; value: number }) => (
+
+
+ {label}
+ {Math.round(value)}
+
+
+
+)
+
+const SignalRow = ({ icon, label, value }: { icon: string; label: string; value: string }) => (
+
+)
+
+PlaceDetailPage.getLayout = function getLayout(page: ReactElement) {
+ return {page}
+}