From e84665755da70b92ab1c83ec9772a1e157e5c986 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Wed, 17 Jun 2026 14:31:41 +0000 Subject: [PATCH] Autosave: 20260617-143144 --- .../20260617123000-create-place-offerings.js | 133 ++ backend/src/db/models/place_offerings.js | 80 + backend/src/db/models/places.js | 14 +- ...260617120000-public-search-quality-data.js | 1010 +++++++++++++ backend/src/index.js | 3 +- backend/src/routes/publicPlaces.js | 1154 +++++++++++++++ frontend/src/components/NavBarItem.tsx | 3 +- frontend/src/layouts/Authenticated.tsx | 3 +- frontend/src/pages/index.tsx | 1289 ++++++++++++++--- frontend/src/pages/tempat/[placeId].tsx | 425 ++++++ 10 files changed, 3942 insertions(+), 172 deletions(-) create mode 100644 backend/src/db/migrations/20260617123000-create-place-offerings.js create mode 100644 backend/src/db/models/place_offerings.js create mode 100644 backend/src/db/seeders/20260617120000-public-search-quality-data.js create mode 100644 backend/src/routes/publicPlaces.js create mode 100644 frontend/src/pages/tempat/[placeId].tsx 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) => ( -
-
- - Photo by {image?.photographer} on Pexels - -
-
- ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- -
- - Video by {video.user.name} on Pexels - -
-
) - } - }; - - return ( -
- - {getPageTitle('Starter Page')} - - - -
- {contentType === 'image' && contentPosition !== 'background' - ? imageBlock(illustrationImage) - : null} - {contentType === 'video' && contentPosition !== 'background' - ? videoBlock(illustrationVideo) - : null} -
- - - -
-

This is a React.js/Node.js app generated by the Flatlogic Web App Generator

-

For guides and documentation please check - your local README.md and the Flatlogic documentation

-
- - - - - -
-
-
-
-
-

© 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')} + + + +
+
+
+
+
+
+
+ +
+ + +
+
+
+ + 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. +

+ + + +
+ + {categoryChips.map((item) => ( + + ))} +
+
+ +
+
+
+
+
+

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) => ( + + ))} +
+
+ ) : 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} +
+ + + +
+
+

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 +}) => ( + +) + +const KeywordGroupCard = ({ group, onKeywordClick }: { group: KeywordGroup; onKeywordClick: (keyword: KeywordChip) => void }) => ( +
+

{group.title}

+

{group.description}

+
+ {group.keywords.map((keyword) => ( + + ))} +
+
+) + +const MetricCard = ({ value, label }: { value: string; label: string }) => ( +
+

{value}

+

{label}

+
+) + +const InfoTile = ({ label, value, icon }: { label: string; value: string; icon: string }) => ( +
+

{label}

+

{value}

+
+) + +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 }) => ( +
+ +

{title}

+

{text}

+
+) + +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.

+ )} +
+
+
+
+ + +
+ )} +
+
+ + ) +} + +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 }) => ( +
+ +
+

{label}

+

{value}

+
+
+) + +PlaceDetailPage.getLayout = function getLayout(page: ReactElement) { + return {page} +}