Compare commits
No commits in common. "ai-dev" and "master" have entirely different histories.
Binary file not shown.
|
Before Width: | Height: | Size: 375 KiB |
@ -1,11 +1,13 @@
|
||||
const config = require('../config');
|
||||
const providers = config.providers;
|
||||
const helpers = require('../helpers');
|
||||
const db = require('../db/models');
|
||||
|
||||
const passport = require('passport');
|
||||
const JWTstrategy = require('passport-jwt').Strategy;
|
||||
const ExtractJWT = require('passport-jwt').ExtractJwt;
|
||||
const GoogleStrategy = require('passport-google-oauth2').Strategy;
|
||||
const MicrosoftStrategy = require('passport-microsoft').Strategy;
|
||||
const AuthService = require('../services/auth');
|
||||
const UsersDBApi = require('../db/api/users');
|
||||
|
||||
|
||||
@ -54,7 +56,13 @@ passport.use(new MicrosoftStrategy({
|
||||
));
|
||||
|
||||
function socialStrategy(email, profile, provider, done) {
|
||||
AuthService.socialSignin(email, profile, provider)
|
||||
.then((token) => done(null, { token }))
|
||||
.catch((error) => done(error));
|
||||
db.users.findOrCreate({where: {email, provider}}).then(([user, created]) => {
|
||||
const body = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: profile.displayName,
|
||||
};
|
||||
const token = helpers.jwtSign({user: body});
|
||||
return done(null, {token});
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
|
||||
const db = require('../models');
|
||||
const FileDBApi = require('./file');
|
||||
const crypto = require('crypto');
|
||||
const Utils = require('../utils');
|
||||
|
||||
|
||||
@ -8,88 +9,6 @@ const Utils = require('../utils');
|
||||
const Sequelize = db.Sequelize;
|
||||
const Op = Sequelize.Op;
|
||||
|
||||
|
||||
const hasOwn = (data, key) => Object.prototype.hasOwnProperty.call(data || {}, key);
|
||||
|
||||
const firstProvided = (data, keys) => {
|
||||
for (const key of keys) {
|
||||
if (hasOwn(data, key) && data[key] !== undefined) {
|
||||
return data[key];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const buildPlacesPayload = (data = {}, includeDefaults = false) => {
|
||||
const payload = {};
|
||||
const set = (field, keys, defaultValue) => {
|
||||
const value = firstProvided(data, keys);
|
||||
if (value !== undefined) {
|
||||
payload[field] = value;
|
||||
} else if (includeDefaults) {
|
||||
payload[field] = defaultValue;
|
||||
}
|
||||
};
|
||||
|
||||
set('name', ['name', 'nama'], null);
|
||||
set('nama', ['nama', 'name'], null);
|
||||
set('kategori', ['kategori'], null);
|
||||
set('subkategori', ['subkategori'], null);
|
||||
set('short_description', ['short_description'], null);
|
||||
set('full_description', ['full_description'], null);
|
||||
set('address', ['address', 'alamat'], null);
|
||||
set('alamat', ['alamat', 'address'], null);
|
||||
set('kelurahan', ['kelurahan'], null);
|
||||
set('kecamatan', ['kecamatan'], null);
|
||||
set('city', ['city', 'kota'], null);
|
||||
set('kota', ['kota', 'city'], null);
|
||||
set('province', ['province', 'provinsi'], null);
|
||||
set('provinsi', ['provinsi', 'province'], null);
|
||||
set('postal_code', ['postal_code', 'kode_pos'], null);
|
||||
set('kode_pos', ['kode_pos', 'postal_code'], null);
|
||||
set('latitude', ['latitude'], null);
|
||||
set('longitude', ['longitude'], null);
|
||||
set('phone_number', ['phone_number', 'telepon'], null);
|
||||
set('telepon', ['telepon', 'phone_number'], null);
|
||||
set('whatsapp_number', ['whatsapp_number', 'whatsapp'], null);
|
||||
set('whatsapp', ['whatsapp', 'whatsapp_number'], null);
|
||||
set('email', ['email'], null);
|
||||
set('website_url', ['website_url', 'website'], null);
|
||||
set('website', ['website', 'website_url'], null);
|
||||
set('google_maps_url', ['google_maps_url'], null);
|
||||
set('jam_buka', ['jam_buka'], null);
|
||||
set('price_level', ['price_level'], null);
|
||||
set('average_price', ['average_price'], null);
|
||||
set('rating_average', ['rating_average', 'rating'], null);
|
||||
set('rating', ['rating', 'rating_average'], 0);
|
||||
set('rating_count', ['rating_count', 'review_count'], null);
|
||||
set('review_count', ['review_count', 'rating_count'], 0);
|
||||
set('status', ['status'], 'aktif');
|
||||
set('is_verified', ['is_verified', 'verified'], false);
|
||||
set('verified', ['verified', 'is_verified'], false);
|
||||
set('featured', ['featured'], false);
|
||||
set('popularitas', ['popularitas'], 0);
|
||||
set('created_at', ['created_at'], undefined);
|
||||
set('updated_at', ['updated_at'], undefined);
|
||||
set('sumber', ['sumber', 'source'], null);
|
||||
set('external_id', ['external_id', 'externalId'], null);
|
||||
set('external_type', ['external_type', 'externalType'], null);
|
||||
set('raw_source_data', ['raw_source_data', 'rawSourceData'], null);
|
||||
set('last_synced_at', ['last_synced_at', 'lastSyncedAt'], undefined);
|
||||
|
||||
if (!includeDefaults) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
Object.keys(payload).forEach((field) => {
|
||||
if (payload[field] === undefined) {
|
||||
delete payload[field];
|
||||
}
|
||||
});
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
module.exports = class PlacesDBApi {
|
||||
|
||||
|
||||
@ -203,7 +122,6 @@ module.exports = class PlacesDBApi {
|
||||
|
||||
,
|
||||
|
||||
...buildPlacesPayload(data, true),
|
||||
importHash: data.importHash || null,
|
||||
createdById: currentUser.id,
|
||||
updatedById: currentUser.id,
|
||||
@ -348,7 +266,6 @@ module.exports = class PlacesDBApi {
|
||||
|
||||
,
|
||||
|
||||
...buildPlacesPayload(item, true),
|
||||
importHash: item.importHash || null,
|
||||
createdById: currentUser.id,
|
||||
updatedById: currentUser.id,
|
||||
@ -448,8 +365,6 @@ module.exports = class PlacesDBApi {
|
||||
if (data.is_verified !== undefined) updatePayload.is_verified = data.is_verified;
|
||||
|
||||
|
||||
Object.assign(updatePayload, buildPlacesPayload(data, false));
|
||||
updatePayload.updated_at = new Date();
|
||||
updatePayload.updatedById = currentUser.id;
|
||||
|
||||
await places.update(updatePayload, {transaction});
|
||||
@ -623,6 +538,9 @@ module.exports = class PlacesDBApi {
|
||||
|
||||
offset = currentPage * limit;
|
||||
|
||||
const orderBy = null;
|
||||
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
let include = [
|
||||
|
||||
@ -639,8 +557,7 @@ module.exports = class PlacesDBApi {
|
||||
}
|
||||
},
|
||||
]
|
||||
} : undefined,
|
||||
required: Boolean(filter.category),
|
||||
} : {},
|
||||
|
||||
},
|
||||
|
||||
@ -657,8 +574,7 @@ module.exports = class PlacesDBApi {
|
||||
}
|
||||
},
|
||||
]
|
||||
} : undefined,
|
||||
required: Boolean(filter.owner),
|
||||
} : {},
|
||||
|
||||
},
|
||||
|
||||
@ -812,35 +728,6 @@ module.exports = class PlacesDBApi {
|
||||
};
|
||||
}
|
||||
|
||||
const indonesianTextFilters = [
|
||||
'nama',
|
||||
'kategori',
|
||||
'subkategori',
|
||||
'alamat',
|
||||
'kelurahan',
|
||||
'kecamatan',
|
||||
'kota',
|
||||
'provinsi',
|
||||
'kode_pos',
|
||||
'telepon',
|
||||
'whatsapp',
|
||||
'website',
|
||||
'jam_buka',
|
||||
];
|
||||
|
||||
for (const field of indonesianTextFilters) {
|
||||
if (filter[field]) {
|
||||
where = {
|
||||
...where,
|
||||
[Op.and]: Utils.ilike(
|
||||
'places',
|
||||
field,
|
||||
filter[field],
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@ -966,38 +853,6 @@ module.exports = class PlacesDBApi {
|
||||
}
|
||||
}
|
||||
|
||||
const applyRangeFilter = (field, range) => {
|
||||
if (!range) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [start, end] = range;
|
||||
|
||||
if (start !== undefined && start !== null && start !== '') {
|
||||
where = {
|
||||
...where,
|
||||
[field]: {
|
||||
...where[field],
|
||||
[Op.gte]: start,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (end !== undefined && end !== null && end !== '') {
|
||||
where = {
|
||||
...where,
|
||||
[field]: {
|
||||
...where[field],
|
||||
[Op.lte]: end,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
applyRangeFilter('rating', filter.ratingRange);
|
||||
applyRangeFilter('review_count', filter.review_countRange);
|
||||
applyRangeFilter('popularitas', filter.popularitasRange);
|
||||
|
||||
|
||||
if (filter.active !== undefined) {
|
||||
where = {
|
||||
@ -1028,20 +883,6 @@ module.exports = class PlacesDBApi {
|
||||
};
|
||||
}
|
||||
|
||||
if (filter.verified !== undefined) {
|
||||
where = {
|
||||
...where,
|
||||
verified: filter.verified === true || filter.verified === 'true',
|
||||
};
|
||||
}
|
||||
|
||||
if (filter.featured !== undefined) {
|
||||
where = {
|
||||
...where,
|
||||
featured: filter.featured === true || filter.featured === 'true',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@ -1121,17 +962,12 @@ module.exports = class PlacesDBApi {
|
||||
'name',
|
||||
query,
|
||||
),
|
||||
Utils.ilike(
|
||||
'places',
|
||||
'nama',
|
||||
query,
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const records = await db.places.findAll({
|
||||
attributes: [ 'id', 'name', 'nama' ],
|
||||
attributes: [ 'id', 'name' ],
|
||||
where,
|
||||
limit: limit ? Number(limit) : undefined,
|
||||
offset: offset ? Number(offset) : undefined,
|
||||
@ -1140,7 +976,7 @@ module.exports = class PlacesDBApi {
|
||||
|
||||
return records.map((record) => ({
|
||||
id: record.id,
|
||||
label: record.name || record.nama,
|
||||
label: record.name,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@ -1,133 +0,0 @@
|
||||
'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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -1,94 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
const transaction = await queryInterface.sequelize.transaction();
|
||||
|
||||
try {
|
||||
const table = await queryInterface.describeTable('users');
|
||||
|
||||
if (!table.emailOtpHash) {
|
||||
await queryInterface.addColumn(
|
||||
'users',
|
||||
'emailOtpHash',
|
||||
{
|
||||
type: Sequelize.DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
}
|
||||
|
||||
if (!table.emailOtpExpiresAt) {
|
||||
await queryInterface.addColumn(
|
||||
'users',
|
||||
'emailOtpExpiresAt',
|
||||
{
|
||||
type: Sequelize.DataTypes.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
}
|
||||
|
||||
if (!table.emailOtpLastSentAt) {
|
||||
await queryInterface.addColumn(
|
||||
'users',
|
||||
'emailOtpLastSentAt',
|
||||
{
|
||||
type: Sequelize.DataTypes.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
}
|
||||
|
||||
if (!table.emailOtpAttempts) {
|
||||
await queryInterface.addColumn(
|
||||
'users',
|
||||
'emailOtpAttempts',
|
||||
{
|
||||
type: Sequelize.DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
const transaction = await queryInterface.sequelize.transaction();
|
||||
|
||||
try {
|
||||
const table = await queryInterface.describeTable('users');
|
||||
|
||||
if (table.emailOtpAttempts) {
|
||||
await queryInterface.removeColumn('users', 'emailOtpAttempts', { transaction });
|
||||
}
|
||||
|
||||
if (table.emailOtpLastSentAt) {
|
||||
await queryInterface.removeColumn('users', 'emailOtpLastSentAt', { transaction });
|
||||
}
|
||||
|
||||
if (table.emailOtpExpiresAt) {
|
||||
await queryInterface.removeColumn('users', 'emailOtpExpiresAt', { transaction });
|
||||
}
|
||||
|
||||
if (table.emailOtpHash) {
|
||||
await queryInterface.removeColumn('users', 'emailOtpHash', { transaction });
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -1,226 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const isMissingTableError = (err) => {
|
||||
const message = String(err && err.message);
|
||||
return message.includes('No description found')
|
||||
|| message.includes('does not exist')
|
||||
|| message.includes('Cannot read properties of undefined');
|
||||
};
|
||||
|
||||
const requestedColumns = (Sequelize) => ({
|
||||
nama: {
|
||||
type: Sequelize.DataTypes.STRING(255),
|
||||
},
|
||||
kategori: {
|
||||
type: Sequelize.DataTypes.STRING(100),
|
||||
},
|
||||
subkategori: {
|
||||
type: Sequelize.DataTypes.STRING(100),
|
||||
},
|
||||
alamat: {
|
||||
type: Sequelize.DataTypes.TEXT,
|
||||
},
|
||||
kelurahan: {
|
||||
type: Sequelize.DataTypes.STRING(100),
|
||||
},
|
||||
kecamatan: {
|
||||
type: Sequelize.DataTypes.STRING(100),
|
||||
},
|
||||
kota: {
|
||||
type: Sequelize.DataTypes.STRING(100),
|
||||
},
|
||||
provinsi: {
|
||||
type: Sequelize.DataTypes.STRING(100),
|
||||
},
|
||||
kode_pos: {
|
||||
type: Sequelize.DataTypes.STRING(20),
|
||||
},
|
||||
telepon: {
|
||||
type: Sequelize.DataTypes.STRING(100),
|
||||
},
|
||||
whatsapp: {
|
||||
type: Sequelize.DataTypes.STRING(100),
|
||||
},
|
||||
website: {
|
||||
type: Sequelize.DataTypes.STRING(255),
|
||||
},
|
||||
jam_buka: {
|
||||
type: Sequelize.DataTypes.STRING(255),
|
||||
},
|
||||
rating: {
|
||||
type: Sequelize.DataTypes.DECIMAL(2, 1),
|
||||
defaultValue: 0,
|
||||
},
|
||||
review_count: {
|
||||
type: Sequelize.DataTypes.INTEGER,
|
||||
defaultValue: 0,
|
||||
},
|
||||
verified: {
|
||||
type: Sequelize.DataTypes.BOOLEAN,
|
||||
defaultValue: false,
|
||||
},
|
||||
featured: {
|
||||
type: Sequelize.DataTypes.BOOLEAN,
|
||||
defaultValue: false,
|
||||
},
|
||||
popularitas: {
|
||||
type: Sequelize.DataTypes.INTEGER,
|
||||
defaultValue: 0,
|
||||
},
|
||||
created_at: {
|
||||
type: Sequelize.DataTypes.DATE,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||
},
|
||||
updated_at: {
|
||||
type: Sequelize.DataTypes.DATE,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||
},
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
const transaction = await queryInterface.sequelize.transaction();
|
||||
|
||||
try {
|
||||
let table;
|
||||
try {
|
||||
table = await queryInterface.describeTable('places');
|
||||
} catch (error) {
|
||||
if (!isMissingTableError(error)) {
|
||||
throw error;
|
||||
}
|
||||
await transaction.commit();
|
||||
return;
|
||||
}
|
||||
|
||||
const columns = requestedColumns(Sequelize);
|
||||
for (const [columnName, definition] of Object.entries(columns)) {
|
||||
if (!table[columnName]) {
|
||||
await queryInterface.addColumn('places', columnName, definition, { transaction });
|
||||
}
|
||||
}
|
||||
|
||||
if (table.latitude) {
|
||||
await queryInterface.sequelize.query(
|
||||
'ALTER TABLE "places" ALTER COLUMN "latitude" TYPE DOUBLE PRECISION USING "latitude"::double precision;',
|
||||
{ transaction },
|
||||
);
|
||||
}
|
||||
|
||||
if (table.longitude) {
|
||||
await queryInterface.sequelize.query(
|
||||
'ALTER TABLE "places" ALTER COLUMN "longitude" TYPE DOUBLE PRECISION USING "longitude"::double precision;',
|
||||
{ transaction },
|
||||
);
|
||||
}
|
||||
|
||||
if (table.status) {
|
||||
await queryInterface.sequelize.query(
|
||||
`ALTER TABLE "places"
|
||||
ALTER COLUMN "status" DROP DEFAULT,
|
||||
ALTER COLUMN "status" TYPE VARCHAR(50) USING "status"::text,
|
||||
ALTER COLUMN "status" SET DEFAULT 'aktif';`,
|
||||
{ transaction },
|
||||
);
|
||||
} else {
|
||||
await queryInterface.addColumn(
|
||||
'places',
|
||||
'status',
|
||||
{
|
||||
type: Sequelize.DataTypes.STRING(50),
|
||||
defaultValue: 'aktif',
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
}
|
||||
|
||||
await queryInterface.sequelize.query(
|
||||
`UPDATE "places"
|
||||
SET
|
||||
nama = COALESCE(nama, name),
|
||||
alamat = COALESCE(alamat, address),
|
||||
kota = COALESCE(kota, city),
|
||||
provinsi = COALESCE(provinsi, province),
|
||||
kode_pos = COALESCE(kode_pos, postal_code),
|
||||
telepon = COALESCE(telepon, phone_number),
|
||||
whatsapp = COALESCE(whatsapp, whatsapp_number),
|
||||
website = COALESCE(website, website_url),
|
||||
rating = COALESCE(rating, ROUND(LEAST(GREATEST(COALESCE(rating_average, 0), 0), 9.9)::numeric, 1), 0),
|
||||
review_count = COALESCE(review_count, rating_count, 0),
|
||||
verified = COALESCE(verified, is_verified, false),
|
||||
updated_at = COALESCE(updated_at, "updatedAt", NOW()),
|
||||
created_at = COALESCE(created_at, "createdAt", NOW())
|
||||
WHERE "deletedAt" IS NULL;`,
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
const indexes = await queryInterface.showIndex('places');
|
||||
const hasIndex = (name) => indexes.some((index) => index.name === name);
|
||||
|
||||
if (!hasIndex('places_kota_kategori_idx')) {
|
||||
await queryInterface.addIndex('places', ['kota', 'kategori'], {
|
||||
name: 'places_kota_kategori_idx',
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasIndex('places_status_featured_idx')) {
|
||||
await queryInterface.addIndex('places', ['status', 'featured'], {
|
||||
name: 'places_status_featured_idx',
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasIndex('places_popularitas_idx')) {
|
||||
await queryInterface.addIndex('places', ['popularitas'], {
|
||||
name: 'places_popularitas_idx',
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
const transaction = await queryInterface.sequelize.transaction();
|
||||
|
||||
try {
|
||||
let table;
|
||||
try {
|
||||
table = await queryInterface.describeTable('places');
|
||||
} catch (error) {
|
||||
if (!isMissingTableError(error)) {
|
||||
throw error;
|
||||
}
|
||||
await transaction.commit();
|
||||
return;
|
||||
}
|
||||
|
||||
const indexes = await queryInterface.showIndex('places');
|
||||
const removeIndexIfExists = async (name) => {
|
||||
if (indexes.some((index) => index.name === name)) {
|
||||
await queryInterface.removeIndex('places', name, { transaction });
|
||||
}
|
||||
};
|
||||
|
||||
await removeIndexIfExists('places_popularitas_idx');
|
||||
await removeIndexIfExists('places_status_featured_idx');
|
||||
await removeIndexIfExists('places_kota_kategori_idx');
|
||||
|
||||
for (const columnName of Object.keys(requestedColumns(Sequelize)).reverse()) {
|
||||
if (table[columnName]) {
|
||||
await queryInterface.removeColumn('places', columnName, { transaction });
|
||||
}
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -1,116 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const isMissingTableError = (err) => {
|
||||
const message = String(err && err.message);
|
||||
return message.includes('No description found')
|
||||
|| message.includes('does not exist')
|
||||
|| message.includes('Cannot read properties of undefined');
|
||||
};
|
||||
|
||||
const metadataColumns = (Sequelize) => ({
|
||||
sumber: {
|
||||
type: Sequelize.DataTypes.STRING(100),
|
||||
},
|
||||
external_id: {
|
||||
type: Sequelize.DataTypes.STRING(255),
|
||||
},
|
||||
external_type: {
|
||||
type: Sequelize.DataTypes.STRING(50),
|
||||
},
|
||||
raw_source_data: {
|
||||
type: Sequelize.DataTypes.JSONB,
|
||||
},
|
||||
last_synced_at: {
|
||||
type: Sequelize.DataTypes.DATE,
|
||||
},
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
const transaction = await queryInterface.sequelize.transaction();
|
||||
|
||||
try {
|
||||
let table;
|
||||
try {
|
||||
table = await queryInterface.describeTable('places');
|
||||
} catch (error) {
|
||||
if (!isMissingTableError(error)) {
|
||||
throw error;
|
||||
}
|
||||
await transaction.commit();
|
||||
return;
|
||||
}
|
||||
|
||||
const columns = metadataColumns(Sequelize);
|
||||
for (const [columnName, definition] of Object.entries(columns)) {
|
||||
if (!table[columnName]) {
|
||||
await queryInterface.addColumn('places', columnName, definition, { transaction });
|
||||
}
|
||||
}
|
||||
|
||||
const indexes = await queryInterface.showIndex('places');
|
||||
const hasIndex = (name) => indexes.some((index) => index.name === name);
|
||||
|
||||
if (!hasIndex('places_source_external_unique_idx')) {
|
||||
await queryInterface.addIndex('places', ['sumber', 'external_type', 'external_id'], {
|
||||
name: 'places_source_external_unique_idx',
|
||||
unique: true,
|
||||
where: {
|
||||
deletedAt: null,
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasIndex('places_sumber_idx')) {
|
||||
await queryInterface.addIndex('places', ['sumber'], {
|
||||
name: 'places_sumber_idx',
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
const transaction = await queryInterface.sequelize.transaction();
|
||||
|
||||
try {
|
||||
let table;
|
||||
try {
|
||||
table = await queryInterface.describeTable('places');
|
||||
} catch (error) {
|
||||
if (!isMissingTableError(error)) {
|
||||
throw error;
|
||||
}
|
||||
await transaction.commit();
|
||||
return;
|
||||
}
|
||||
|
||||
const indexes = await queryInterface.showIndex('places');
|
||||
const removeIndexIfExists = async (name) => {
|
||||
if (indexes.some((index) => index.name === name)) {
|
||||
await queryInterface.removeIndex('places', name, { transaction });
|
||||
}
|
||||
};
|
||||
|
||||
await removeIndexIfExists('places_sumber_idx');
|
||||
await removeIndexIfExists('places_source_external_unique_idx');
|
||||
|
||||
for (const columnName of Object.keys(metadataColumns(Sequelize)).reverse()) {
|
||||
if (table[columnName]) {
|
||||
await queryInterface.removeColumn('places', columnName, { transaction });
|
||||
}
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -1,80 +0,0 @@
|
||||
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;
|
||||
};
|
||||
@ -1,3 +1,8 @@
|
||||
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(
|
||||
@ -14,27 +19,6 @@ name: {
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
nama: {
|
||||
type: DataTypes.STRING(255),
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
kategori: {
|
||||
type: DataTypes.STRING(100),
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
subkategori: {
|
||||
type: DataTypes.STRING(100),
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
short_description: {
|
||||
@ -56,27 +40,6 @@ address: {
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
alamat: {
|
||||
type: DataTypes.TEXT,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
kelurahan: {
|
||||
type: DataTypes.STRING(100),
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
kecamatan: {
|
||||
type: DataTypes.STRING(100),
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
city: {
|
||||
@ -84,13 +47,6 @@ city: {
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
kota: {
|
||||
type: DataTypes.STRING(100),
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
province: {
|
||||
@ -98,13 +54,6 @@ province: {
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
provinsi: {
|
||||
type: DataTypes.STRING(100),
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
postal_code: {
|
||||
@ -112,24 +61,17 @@ postal_code: {
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
kode_pos: {
|
||||
type: DataTypes.STRING(20),
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
latitude: {
|
||||
type: DataTypes.DOUBLE,
|
||||
type: DataTypes.DECIMAL,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
longitude: {
|
||||
type: DataTypes.DOUBLE,
|
||||
type: DataTypes.DECIMAL,
|
||||
|
||||
|
||||
|
||||
@ -140,13 +82,6 @@ phone_number: {
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
telepon: {
|
||||
type: DataTypes.STRING(100),
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
whatsapp_number: {
|
||||
@ -154,13 +89,6 @@ whatsapp_number: {
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
whatsapp: {
|
||||
type: DataTypes.STRING(100),
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
email: {
|
||||
@ -175,13 +103,6 @@ website_url: {
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
website: {
|
||||
type: DataTypes.STRING(255),
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
google_maps_url: {
|
||||
@ -189,13 +110,6 @@ google_maps_url: {
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
jam_buka: {
|
||||
type: DataTypes.STRING(255),
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
price_level: {
|
||||
@ -232,14 +146,6 @@ rating_average: {
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
rating: {
|
||||
type: DataTypes.DECIMAL(2, 1),
|
||||
defaultValue: 0,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
rating_count: {
|
||||
@ -247,22 +153,25 @@ rating_count: {
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
review_count: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
status: {
|
||||
type: DataTypes.STRING(50),
|
||||
defaultValue: 'aktif',
|
||||
type: DataTypes.ENUM,
|
||||
|
||||
|
||||
|
||||
values: [
|
||||
|
||||
"draft",
|
||||
|
||||
|
||||
"published",
|
||||
|
||||
|
||||
"archived"
|
||||
|
||||
],
|
||||
|
||||
},
|
||||
|
||||
is_verified: {
|
||||
@ -273,81 +182,6 @@ is_verified: {
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
verified: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
featured: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
popularitas: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
updated_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
sumber: {
|
||||
type: DataTypes.STRING(100),
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
external_id: {
|
||||
type: DataTypes.STRING(255),
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
external_type: {
|
||||
type: DataTypes.STRING(50),
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
raw_source_data: {
|
||||
type: DataTypes.JSONB,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
last_synced_at: {
|
||||
type: DataTypes.DATE,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
importHash: {
|
||||
@ -393,15 +227,6 @@ last_synced_at: {
|
||||
});
|
||||
|
||||
|
||||
db.places.hasMany(db.place_offerings, {
|
||||
as: 'offerings',
|
||||
foreignKey: {
|
||||
name: 'placeId',
|
||||
},
|
||||
constraints: false,
|
||||
});
|
||||
|
||||
|
||||
db.places.hasMany(db.reviews, {
|
||||
as: 'reviews_place',
|
||||
foreignKey: {
|
||||
|
||||
@ -2,6 +2,7 @@ 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 users = sequelize.define(
|
||||
@ -94,37 +95,6 @@ passwordResetTokenExpiresAt: {
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
emailOtpHash: {
|
||||
type: DataTypes.TEXT,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
emailOtpExpiresAt: {
|
||||
type: DataTypes.DATE,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
emailOtpLastSentAt: {
|
||||
type: DataTypes.DATE,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
emailOtpAttempts: {
|
||||
type: DataTypes.INTEGER,
|
||||
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
provider: {
|
||||
@ -259,8 +229,8 @@ provider: {
|
||||
};
|
||||
|
||||
|
||||
users.beforeCreate((users) => {
|
||||
trimStringFields(users);
|
||||
users.beforeCreate((users, options) => {
|
||||
users = trimStringFields(users);
|
||||
|
||||
if (users.provider !== providers.LOCAL && Object.values(providers).indexOf(users.provider) > -1) {
|
||||
users.emailVerified = true;
|
||||
@ -280,8 +250,8 @@ provider: {
|
||||
}
|
||||
});
|
||||
|
||||
users.beforeUpdate((users) => {
|
||||
trimStringFields(users);
|
||||
users.beforeUpdate((users, options) => {
|
||||
users = trimStringFields(users);
|
||||
});
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -6,6 +6,7 @@ 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');
|
||||
@ -15,8 +16,6 @@ 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 geoseekCollectorRoutes = require('./routes/geoseekCollector');
|
||||
|
||||
const openaiRoutes = require('./routes/openai');
|
||||
|
||||
@ -101,7 +100,6 @@ 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');
|
||||
|
||||
|
||||
@ -115,8 +113,6 @@ app.use('/api/place_categories', passport.authenticate('jwt', {session: false}),
|
||||
|
||||
app.use('/api/places', passport.authenticate('jwt', {session: false}), placesRoutes);
|
||||
|
||||
app.use('/api/geoseek-collector', passport.authenticate('jwt', {session: false}), geoseekCollectorRoutes);
|
||||
|
||||
app.use('/api/place_opening_hours', passport.authenticate('jwt', {session: false}), place_opening_hoursRoutes);
|
||||
|
||||
app.use('/api/place_features', passport.authenticate('jwt', {session: false}), place_featuresRoutes);
|
||||
|
||||
@ -84,12 +84,8 @@ router.get('/me', passport.authenticate('jwt', {session: false}), (req, res) =>
|
||||
throw new ForbiddenError();
|
||||
}
|
||||
|
||||
const payload = { ...req.currentUser };
|
||||
const payload = req.currentUser;
|
||||
delete payload.password;
|
||||
delete payload.emailOtpHash;
|
||||
delete payload.emailOtpExpiresAt;
|
||||
delete payload.emailOtpLastSentAt;
|
||||
delete payload.emailOtpAttempts;
|
||||
res.status(200).send(payload);
|
||||
});
|
||||
|
||||
@ -175,31 +171,12 @@ router.get('/email-configured', (req, res) => {
|
||||
res.status(200).send(payload);
|
||||
});
|
||||
|
||||
router.post('/otp/request', wrapAsync(async (req, res) => {
|
||||
const payload = await AuthService.requestEmailOtp(req.body.email);
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
|
||||
router.post('/otp/verify', wrapAsync(async (req, res) => {
|
||||
const payload = await AuthService.verifyEmailOtp(req.body.email, req.body.otp);
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
|
||||
router.get('/signin/google', (req, res, next) => {
|
||||
passport.authenticate("google", {
|
||||
scope: ["profile", "email"],
|
||||
state: req.query.app,
|
||||
callbackURL: oauthCallbackUrl(req, 'google'),
|
||||
})(req, res, next);
|
||||
passport.authenticate("google", {scope: ["profile", "email"], state: req.query.app})(req, res, next);
|
||||
});
|
||||
|
||||
router.get('/signin/google/callback', (req, res, next) => {
|
||||
passport.authenticate("google", {
|
||||
failureRedirect: "/login",
|
||||
session: false,
|
||||
callbackURL: oauthCallbackUrl(req, 'google'),
|
||||
})(req, res, next);
|
||||
},
|
||||
router.get('/signin/google/callback', passport.authenticate("google", {failureRedirect: "/login", session: false}),
|
||||
|
||||
function (req, res) {
|
||||
socialRedirect(res, req.query.state, req.user.token, config);
|
||||
}
|
||||
@ -208,18 +185,14 @@ router.get('/signin/google/callback', (req, res, next) => {
|
||||
router.get('/signin/microsoft', (req, res, next) => {
|
||||
passport.authenticate("microsoft", {
|
||||
scope: ["https://graph.microsoft.com/user.read openid"],
|
||||
state: req.query.app,
|
||||
callbackURL: oauthCallbackUrl(req, 'microsoft'),
|
||||
state: req.query.app
|
||||
})(req, res, next);
|
||||
});
|
||||
|
||||
router.get('/signin/microsoft/callback', (req, res, next) => {
|
||||
passport.authenticate("microsoft", {
|
||||
failureRedirect: "/login",
|
||||
session: false,
|
||||
callbackURL: oauthCallbackUrl(req, 'microsoft'),
|
||||
})(req, res, next);
|
||||
},
|
||||
router.get('/signin/microsoft/callback', passport.authenticate("microsoft", {
|
||||
failureRedirect: "/login",
|
||||
session: false
|
||||
}),
|
||||
function (req, res) {
|
||||
socialRedirect(res, req.query.state, req.user.token, config);
|
||||
}
|
||||
@ -227,29 +200,8 @@ router.get('/signin/microsoft/callback', (req, res, next) => {
|
||||
|
||||
router.use('/', require('../helpers').commonErrorHandler);
|
||||
|
||||
function oauthCallbackUrl(req, provider) {
|
||||
const forwardedProto = req.get('x-forwarded-proto');
|
||||
const forwardedHost = req.get('x-forwarded-host');
|
||||
const protocol = (forwardedProto || req.protocol || 'http').split(',')[0].trim();
|
||||
const host = (forwardedHost || req.get('host')).split(',')[0].trim();
|
||||
|
||||
return `${protocol}://${host}/api/auth/signin/${provider}/callback`;
|
||||
}
|
||||
|
||||
function socialRedirect(res, state, token, config) {
|
||||
let uiUrl = config.backUrl || config.uiUrl;
|
||||
|
||||
if (state) {
|
||||
try {
|
||||
const decodedState = decodeURIComponent(state);
|
||||
const parsedState = new URL(decodedState);
|
||||
uiUrl = parsedState.origin;
|
||||
} catch (error) {
|
||||
console.error('Invalid social redirect state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
res.redirect(`${uiUrl}/login?token=${encodeURIComponent(token)}`);
|
||||
res.redirect(config.uiUrl + "/login?token=" + token);
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@ -1,24 +0,0 @@
|
||||
const express = require('express');
|
||||
const GeoSeekCollectorService = require('../services/geoseekCollector');
|
||||
const wrapAsync = require('../helpers').wrapAsync;
|
||||
const { checkPermissions } = require('../middlewares/check-permissions');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/osm/amenities', checkPermissions('READ_PLACES'), wrapAsync(async (req, res) => {
|
||||
res.status(200).send({
|
||||
source: 'OpenStreetMap',
|
||||
amenities: GeoSeekCollectorService.supportedAmenities(),
|
||||
});
|
||||
}));
|
||||
|
||||
router.post('/osm', checkPermissions('CREATE_PLACES'), wrapAsync(async (req, res) => {
|
||||
const payload = await GeoSeekCollectorService.collectOpenStreetMap(
|
||||
req.body || {},
|
||||
req.currentUser,
|
||||
);
|
||||
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
@ -29,15 +29,6 @@ router.use(checkCrudPermissions('places'));
|
||||
* name:
|
||||
* type: string
|
||||
* default: name
|
||||
* nama:
|
||||
* type: string
|
||||
* default: nama
|
||||
* kategori:
|
||||
* type: string
|
||||
* default: kategori
|
||||
* subkategori:
|
||||
* type: string
|
||||
* default: subkategori
|
||||
* short_description:
|
||||
* type: string
|
||||
* default: short_description
|
||||
@ -47,77 +38,34 @@ router.use(checkCrudPermissions('places'));
|
||||
* address:
|
||||
* type: string
|
||||
* default: address
|
||||
* alamat:
|
||||
* type: string
|
||||
* default: alamat
|
||||
* kelurahan:
|
||||
* type: string
|
||||
* default: kelurahan
|
||||
* kecamatan:
|
||||
* type: string
|
||||
* default: kecamatan
|
||||
* city:
|
||||
* type: string
|
||||
* default: city
|
||||
* kota:
|
||||
* type: string
|
||||
* default: kota
|
||||
* province:
|
||||
* type: string
|
||||
* default: province
|
||||
* provinsi:
|
||||
* type: string
|
||||
* default: provinsi
|
||||
* postal_code:
|
||||
* type: string
|
||||
* default: postal_code
|
||||
* kode_pos:
|
||||
* type: string
|
||||
* default: kode_pos
|
||||
* phone_number:
|
||||
* type: string
|
||||
* default: phone_number
|
||||
* telepon:
|
||||
* type: string
|
||||
* default: telepon
|
||||
* whatsapp_number:
|
||||
* type: string
|
||||
* default: whatsapp_number
|
||||
* whatsapp:
|
||||
* type: string
|
||||
* default: whatsapp
|
||||
* email:
|
||||
* type: string
|
||||
* default: email
|
||||
* website_url:
|
||||
* type: string
|
||||
* default: website_url
|
||||
* website:
|
||||
* type: string
|
||||
* default: website
|
||||
* google_maps_url:
|
||||
* type: string
|
||||
* default: google_maps_url
|
||||
* jam_buka:
|
||||
* type: string
|
||||
* default: jam_buka
|
||||
|
||||
* rating_count:
|
||||
* type: integer
|
||||
* format: int64
|
||||
* review_count:
|
||||
* type: integer
|
||||
* format: int64
|
||||
* status:
|
||||
* type: string
|
||||
* default: aktif
|
||||
* verified:
|
||||
* type: boolean
|
||||
* featured:
|
||||
* type: boolean
|
||||
* popularitas:
|
||||
* type: integer
|
||||
* format: int64
|
||||
|
||||
* latitude:
|
||||
* type: integer
|
||||
@ -131,9 +79,6 @@ router.use(checkCrudPermissions('places'));
|
||||
* rating_average:
|
||||
* type: integer
|
||||
* format: int64
|
||||
* rating:
|
||||
* type: number
|
||||
* format: float
|
||||
|
||||
*
|
||||
*
|
||||
@ -182,8 +127,8 @@ router.use(checkCrudPermissions('places'));
|
||||
router.post('/', wrapAsync(async (req, res) => {
|
||||
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`;
|
||||
const link = new URL(referer);
|
||||
const placeData = req.body.data || req.body;
|
||||
const payload = await PlacesService.create(placeData, req.currentUser, true, link.host);
|
||||
await PlacesService.create(req.body.data, req.currentUser, true, link.host);
|
||||
const payload = true;
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
|
||||
@ -279,9 +224,7 @@ router.post('/bulk-import', wrapAsync(async (req, res) => {
|
||||
* description: Some server error
|
||||
*/
|
||||
router.put('/:id', wrapAsync(async (req, res) => {
|
||||
const placeData = req.body.data || req.body;
|
||||
const placeId = req.body.id || req.params.id;
|
||||
await PlacesService.update(placeData, placeId, req.currentUser);
|
||||
await PlacesService.update(req.body.data, req.body.id, req.currentUser);
|
||||
const payload = true;
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
@ -357,8 +300,7 @@ router.delete('/:id', wrapAsync(async (req, res) => {
|
||||
* description: Some server error
|
||||
*/
|
||||
router.post('/deleteByIds', wrapAsync(async (req, res) => {
|
||||
const ids = req.body.data || req.body.ids;
|
||||
await PlacesService.deleteByIds(ids, req.currentUser);
|
||||
await PlacesService.deleteByIds(req.body.data, req.currentUser);
|
||||
const payload = true;
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
@ -396,10 +338,9 @@ router.get('/', wrapAsync(async (req, res) => {
|
||||
req.query, { currentUser }
|
||||
);
|
||||
if (filetype && filetype === 'csv') {
|
||||
const fields = ['id','name','nama','kategori','subkategori','short_description','full_description','address','alamat','kelurahan','kecamatan','city','kota','province','provinsi','postal_code','kode_pos','phone_number','telepon','whatsapp_number','whatsapp','email','website_url','website','google_maps_url','jam_buka',
|
||||
'rating_count','review_count','status','is_verified','verified','featured','popularitas',
|
||||
'sumber','external_id','external_type','last_synced_at',
|
||||
'latitude','longitude','average_price','rating_average','rating',
|
||||
const fields = ['id','name','short_description','full_description','address','city','province','postal_code','phone_number','whatsapp_number','email','website_url','google_maps_url',
|
||||
'rating_count',
|
||||
'latitude','longitude','average_price','rating_average',
|
||||
|
||||
];
|
||||
const opts = { fields };
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,905 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { once } = require('events');
|
||||
|
||||
const WORKSPACE_ROOT = path.resolve(__dirname, '../../../..');
|
||||
const DEFAULT_ROWS = 1000000;
|
||||
const DEFAULT_OUTPUT = path.join(
|
||||
WORKSPACE_ROOT,
|
||||
'data/geoseek/generated/geo_places_1M.csv',
|
||||
);
|
||||
|
||||
const HEADER = [
|
||||
'id',
|
||||
'nama_tempat',
|
||||
'kategori',
|
||||
'subkategori',
|
||||
'alamat',
|
||||
'kecamatan',
|
||||
'kota',
|
||||
'provinsi',
|
||||
'latitude',
|
||||
'longitude',
|
||||
'no_telp',
|
||||
'jam_operasional',
|
||||
'rating',
|
||||
'jumlah_review',
|
||||
'sumber_data',
|
||||
'status_verifikasi',
|
||||
];
|
||||
|
||||
const CITY_TARGETS = [
|
||||
{
|
||||
kota: 'Jakarta',
|
||||
provinsi: 'DKI Jakarta',
|
||||
target: 200000,
|
||||
lat: -6.2,
|
||||
lng: 106.8167,
|
||||
phonePrefix: '021',
|
||||
districts: [
|
||||
'Menteng',
|
||||
'Gambir',
|
||||
'Tanah Abang',
|
||||
'Setiabudi',
|
||||
'Tebet',
|
||||
'Kebayoran Baru',
|
||||
'Cilandak',
|
||||
'Kebon Jeruk',
|
||||
'Taman Sari',
|
||||
'Kelapa Gading',
|
||||
],
|
||||
},
|
||||
{
|
||||
kota: 'Surabaya',
|
||||
provinsi: 'Jawa Timur',
|
||||
target: 100000,
|
||||
lat: -7.2575,
|
||||
lng: 112.7521,
|
||||
phonePrefix: '031',
|
||||
districts: [
|
||||
'Tegalsari',
|
||||
'Genteng',
|
||||
'Gubeng',
|
||||
'Wonokromo',
|
||||
'Sukolilo',
|
||||
'Rungkut',
|
||||
'Bubutan',
|
||||
'Kenjeran',
|
||||
],
|
||||
},
|
||||
{
|
||||
kota: 'Bandung',
|
||||
provinsi: 'Jawa Barat',
|
||||
target: 90000,
|
||||
lat: -6.9175,
|
||||
lng: 107.6191,
|
||||
phonePrefix: '022',
|
||||
districts: [
|
||||
'Coblong',
|
||||
'Sumur Bandung',
|
||||
'Bandung Wetan',
|
||||
'Regol',
|
||||
'Cicendo',
|
||||
'Sukajadi',
|
||||
'Batununggal',
|
||||
'Lengkong',
|
||||
],
|
||||
},
|
||||
{
|
||||
kota: 'Medan',
|
||||
provinsi: 'Sumatera Utara',
|
||||
target: 80000,
|
||||
lat: 3.5952,
|
||||
lng: 98.6722,
|
||||
phonePrefix: '061',
|
||||
districts: [
|
||||
'Medan Baru',
|
||||
'Medan Kota',
|
||||
'Medan Petisah',
|
||||
'Medan Timur',
|
||||
'Medan Selayang',
|
||||
'Medan Amplas',
|
||||
],
|
||||
},
|
||||
{
|
||||
kota: 'Bekasi',
|
||||
provinsi: 'Jawa Barat',
|
||||
target: 70000,
|
||||
lat: -6.2383,
|
||||
lng: 106.9756,
|
||||
phonePrefix: '021',
|
||||
districts: [
|
||||
'Bekasi Selatan',
|
||||
'Bekasi Timur',
|
||||
'Bekasi Barat',
|
||||
'Bekasi Utara',
|
||||
'Rawalumbu',
|
||||
'Jatiasih',
|
||||
],
|
||||
},
|
||||
{
|
||||
kota: 'Tangerang',
|
||||
provinsi: 'Banten',
|
||||
target: 70000,
|
||||
lat: -6.1783,
|
||||
lng: 106.6319,
|
||||
phonePrefix: '021',
|
||||
districts: [
|
||||
'Tangerang',
|
||||
'Cipondoh',
|
||||
'Karawaci',
|
||||
'Ciledug',
|
||||
'Pinang',
|
||||
'Benda',
|
||||
'Kelapa Dua',
|
||||
],
|
||||
},
|
||||
{
|
||||
kota: 'Semarang',
|
||||
provinsi: 'Jawa Tengah',
|
||||
target: 60000,
|
||||
lat: -6.9667,
|
||||
lng: 110.4167,
|
||||
phonePrefix: '024',
|
||||
districts: [
|
||||
'Semarang Tengah',
|
||||
'Semarang Selatan',
|
||||
'Semarang Timur',
|
||||
'Tembalang',
|
||||
'Banyumanik',
|
||||
'Pedurungan',
|
||||
],
|
||||
},
|
||||
{
|
||||
kota: 'Makassar',
|
||||
provinsi: 'Sulawesi Selatan',
|
||||
target: 50000,
|
||||
lat: -5.1477,
|
||||
lng: 119.4327,
|
||||
phonePrefix: '0411',
|
||||
districts: [
|
||||
'Ujung Pandang',
|
||||
'Panakkukang',
|
||||
'Rappocini',
|
||||
'Tamalanrea',
|
||||
'Biringkanaya',
|
||||
'Manggala',
|
||||
],
|
||||
},
|
||||
{
|
||||
kota: 'Palembang',
|
||||
provinsi: 'Sumatera Selatan',
|
||||
target: 45000,
|
||||
lat: -2.9761,
|
||||
lng: 104.7754,
|
||||
phonePrefix: '0711',
|
||||
districts: [
|
||||
'Ilir Barat I',
|
||||
'Ilir Timur I',
|
||||
'Bukit Kecil',
|
||||
'Kemuning',
|
||||
'Sukarami',
|
||||
'Seberang Ulu I',
|
||||
],
|
||||
},
|
||||
{
|
||||
kota: 'Depok',
|
||||
provinsi: 'Jawa Barat',
|
||||
target: 45000,
|
||||
lat: -6.4025,
|
||||
lng: 106.7942,
|
||||
phonePrefix: '021',
|
||||
districts: ['Beji', 'Pancoran Mas', 'Sukmajaya', 'Cimanggis', 'Sawangan'],
|
||||
},
|
||||
{
|
||||
kota: 'Bogor',
|
||||
provinsi: 'Jawa Barat',
|
||||
target: 40000,
|
||||
lat: -6.5971,
|
||||
lng: 106.806,
|
||||
phonePrefix: '0251',
|
||||
districts: [
|
||||
'Bogor Tengah',
|
||||
'Bogor Utara',
|
||||
'Bogor Selatan',
|
||||
'Bogor Timur',
|
||||
'Tanah Sareal',
|
||||
],
|
||||
},
|
||||
{
|
||||
kota: 'Yogyakarta',
|
||||
provinsi: 'DI Yogyakarta',
|
||||
target: 35000,
|
||||
lat: -7.7956,
|
||||
lng: 110.3695,
|
||||
phonePrefix: '0274',
|
||||
districts: [
|
||||
'Gedongtengen',
|
||||
'Gondomanan',
|
||||
'Kotagede',
|
||||
'Umbulharjo',
|
||||
'Gondokusuman',
|
||||
'Danurejan',
|
||||
],
|
||||
},
|
||||
{
|
||||
kota: 'Denpasar',
|
||||
provinsi: 'Bali',
|
||||
target: 30000,
|
||||
lat: -8.6705,
|
||||
lng: 115.2126,
|
||||
phonePrefix: '0361',
|
||||
districts: [
|
||||
'Denpasar Barat',
|
||||
'Denpasar Selatan',
|
||||
'Denpasar Timur',
|
||||
'Denpasar Utara',
|
||||
],
|
||||
},
|
||||
{
|
||||
kota: 'Malang',
|
||||
provinsi: 'Jawa Timur',
|
||||
target: 30000,
|
||||
lat: -7.9666,
|
||||
lng: 112.6326,
|
||||
phonePrefix: '0341',
|
||||
districts: ['Klojen', 'Blimbing', 'Lowokwaru', 'Sukun', 'Kedungkandang'],
|
||||
},
|
||||
{
|
||||
kota: 'Kota lain',
|
||||
provinsi: 'Nasional',
|
||||
target: 85000,
|
||||
lat: -2.5489,
|
||||
lng: 118.0149,
|
||||
phonePrefix: '021',
|
||||
districts: ['Pusat Kota', 'Kecamatan Utama', 'Kawasan Niaga'],
|
||||
},
|
||||
];
|
||||
|
||||
const OTHER_CITIES = [
|
||||
{
|
||||
kota: 'Batam',
|
||||
provinsi: 'Kepulauan Riau',
|
||||
lat: 1.1301,
|
||||
lng: 104.0529,
|
||||
phonePrefix: '0778',
|
||||
districts: ['Batam Kota', 'Sekupang', 'Lubuk Baja', 'Batu Aji'],
|
||||
},
|
||||
{
|
||||
kota: 'Pekanbaru',
|
||||
provinsi: 'Riau',
|
||||
lat: 0.5071,
|
||||
lng: 101.4478,
|
||||
phonePrefix: '0761',
|
||||
districts: ['Sukajadi', 'Tampan', 'Marpoyan Damai', 'Tenayan Raya'],
|
||||
},
|
||||
{
|
||||
kota: 'Padang',
|
||||
provinsi: 'Sumatera Barat',
|
||||
lat: -0.9471,
|
||||
lng: 100.4172,
|
||||
phonePrefix: '0751',
|
||||
districts: ['Padang Barat', 'Padang Timur', 'Koto Tangah', 'Lubuk Begalung'],
|
||||
},
|
||||
{
|
||||
kota: 'Balikpapan',
|
||||
provinsi: 'Kalimantan Timur',
|
||||
lat: -1.2379,
|
||||
lng: 116.8529,
|
||||
phonePrefix: '0542',
|
||||
districts: [
|
||||
'Balikpapan Kota',
|
||||
'Balikpapan Selatan',
|
||||
'Balikpapan Utara',
|
||||
],
|
||||
},
|
||||
{
|
||||
kota: 'Banjarmasin',
|
||||
provinsi: 'Kalimantan Selatan',
|
||||
lat: -3.3186,
|
||||
lng: 114.5944,
|
||||
phonePrefix: '0511',
|
||||
districts: ['Banjarmasin Tengah', 'Banjarmasin Timur', 'Banjarmasin Utara'],
|
||||
},
|
||||
{
|
||||
kota: 'Pontianak',
|
||||
provinsi: 'Kalimantan Barat',
|
||||
lat: -0.0263,
|
||||
lng: 109.3425,
|
||||
phonePrefix: '0561',
|
||||
districts: ['Pontianak Kota', 'Pontianak Selatan', 'Pontianak Timur'],
|
||||
},
|
||||
{
|
||||
kota: 'Manado',
|
||||
provinsi: 'Sulawesi Utara',
|
||||
lat: 1.4748,
|
||||
lng: 124.8421,
|
||||
phonePrefix: '0431',
|
||||
districts: ['Wenang', 'Malalayang', 'Mapanget', 'Tuminting'],
|
||||
},
|
||||
{
|
||||
kota: 'Mataram',
|
||||
provinsi: 'Nusa Tenggara Barat',
|
||||
lat: -8.5833,
|
||||
lng: 116.1167,
|
||||
phonePrefix: '0370',
|
||||
districts: ['Mataram', 'Cakranegara', 'Ampenan', 'Sekarbela'],
|
||||
},
|
||||
{
|
||||
kota: 'Ambon',
|
||||
provinsi: 'Maluku',
|
||||
lat: -3.6954,
|
||||
lng: 128.1814,
|
||||
phonePrefix: '0911',
|
||||
districts: ['Sirimau', 'Nusaniwe', 'Baguala'],
|
||||
},
|
||||
{
|
||||
kota: 'Jayapura',
|
||||
provinsi: 'Papua',
|
||||
lat: -2.5916,
|
||||
lng: 140.669,
|
||||
phonePrefix: '0967',
|
||||
districts: ['Jayapura Utara', 'Jayapura Selatan', 'Abepura', 'Heram'],
|
||||
},
|
||||
];
|
||||
|
||||
const CATEGORY_DEFINITIONS = [
|
||||
{
|
||||
kategori: 'Restoran',
|
||||
subcategories: ['Makanan Sunda', 'Nasi Padang', 'Bakso', 'Sate', 'Seafood'],
|
||||
target: 150000,
|
||||
prefixes: ['Warung', 'Rumah Makan', 'Dapur', 'Resto'],
|
||||
nouns: ['Nusantara', 'Sederhana', 'Ibu Sari', 'Pak Rudi', 'Selera Kota'],
|
||||
openingHours: ['07:00-22:00', '08:00-23:00', '10:00-22:00'],
|
||||
},
|
||||
{
|
||||
kategori: 'Cafe',
|
||||
subcategories: ['Coffee Shop', 'Kedai Kopi', 'Dessert Cafe', 'Tea House'],
|
||||
target: 50000,
|
||||
prefixes: ['Cafe', 'Kedai Kopi', 'Coffee House', 'Kopi'],
|
||||
nouns: ['Senja', 'Urban', 'Teras', 'Kota', 'Ruang Temu'],
|
||||
openingHours: ['08:00-23:00', '09:00-22:00', '10:00-24:00'],
|
||||
},
|
||||
{
|
||||
kategori: 'Hotel',
|
||||
subcategories: ['Hotel Bintang 3', 'Hotel Bintang 4', 'Hotel Bintang 5'],
|
||||
target: 40000,
|
||||
prefixes: ['Hotel', 'Grand Hotel', 'Royal Hotel', 'City Hotel'],
|
||||
nouns: ['Nusantara', 'Sentral', 'Prima', 'Indah', 'Metropolitan'],
|
||||
openingHours: ['24 Jam'],
|
||||
},
|
||||
{
|
||||
kategori: 'Rumah Sakit',
|
||||
subcategories: ['Rumah Sakit Umum', 'Rumah Sakit Ibu dan Anak'],
|
||||
target: 25000,
|
||||
prefixes: ['RS', 'Rumah Sakit', 'RSU'],
|
||||
nouns: ['Sehat Sentosa', 'Medika', 'Harapan', 'Bunda', 'Kasih Ibu'],
|
||||
openingHours: ['24 Jam'],
|
||||
},
|
||||
{
|
||||
kategori: 'Apotek',
|
||||
subcategories: ['Apotek Umum', 'Farmasi', 'Toko Obat'],
|
||||
target: 50000,
|
||||
prefixes: ['Apotek', 'Farmasi', 'Toko Obat'],
|
||||
nouns: ['Sehat', 'Keluarga', 'Medika', 'Prima', 'Sentosa'],
|
||||
openingHours: ['08:00-22:00', '07:00-21:00', '24 Jam'],
|
||||
},
|
||||
{
|
||||
kategori: 'ATM',
|
||||
subcategories: ['ATM Bank', 'ATM Center'],
|
||||
target: 25000,
|
||||
prefixes: ['ATM', 'ATM Center'],
|
||||
nouns: ['Mandiri', 'BRI', 'BNI', 'BCA', 'CIMB Niaga'],
|
||||
openingHours: ['24 Jam'],
|
||||
},
|
||||
{
|
||||
kategori: 'SPBU',
|
||||
subcategories: ['Pom Bensin', 'Stasiun Pengisian BBM'],
|
||||
target: 15000,
|
||||
prefixes: ['SPBU', 'Pom Bensin'],
|
||||
nouns: ['Pertamina', 'Shell', 'BP AKR', 'Vivo', 'Kota'],
|
||||
openingHours: ['24 Jam', '05:00-23:00'],
|
||||
},
|
||||
{
|
||||
kategori: 'Bengkel',
|
||||
subcategories: ['Bengkel Motor', 'Bengkel Mobil', 'Service AC Mobil'],
|
||||
target: 50000,
|
||||
prefixes: ['Bengkel', 'Auto Service', 'Service Motor'],
|
||||
nouns: ['Maju Jaya', 'Prima Motor', 'Karya Auto', 'Mandiri', 'Sentosa'],
|
||||
openingHours: ['08:00-17:00', '08:00-20:00'],
|
||||
},
|
||||
{
|
||||
kategori: 'Wisata',
|
||||
subcategories: ['Tempat Wisata', 'Taman', 'Museum', 'Pantai'],
|
||||
target: 50000,
|
||||
prefixes: ['Wisata', 'Taman', 'Museum', 'Kawasan'],
|
||||
nouns: ['Kota', 'Budaya', 'Bahari', 'Alam Indah', 'Heritage'],
|
||||
openingHours: ['08:00-17:00', '09:00-18:00', '24 Jam'],
|
||||
},
|
||||
{
|
||||
kategori: 'Mall',
|
||||
subcategories: ['Pusat Perbelanjaan', 'Trade Center'],
|
||||
target: 30000,
|
||||
prefixes: ['Mall', 'Plaza', 'Trade Center'],
|
||||
nouns: ['Sentral', 'City Square', 'Metropolitan', 'Garden', 'Junction'],
|
||||
openingHours: ['10:00-22:00'],
|
||||
},
|
||||
{
|
||||
kategori: 'Minimarket',
|
||||
subcategories: ['Toko Harian', 'Convenience Store'],
|
||||
target: 30000,
|
||||
prefixes: ['Minimarket', 'Toko Harian', 'Gerai'],
|
||||
nouns: ['Kita', 'Maju', 'Sejahtera', 'Keluarga', 'Hemat'],
|
||||
openingHours: ['07:00-22:00', '24 Jam'],
|
||||
},
|
||||
{
|
||||
kategori: 'Klinik',
|
||||
subcategories: ['Klinik Umum', 'Klinik Gigi', 'Klinik Kecantikan'],
|
||||
target: 15000,
|
||||
prefixes: ['Klinik', 'Klinik Pratama', 'Medical Center'],
|
||||
nouns: ['Sehat', 'Medika', 'Utama', 'Prima', 'Keluarga'],
|
||||
openingHours: ['08:00-20:00', '09:00-21:00'],
|
||||
},
|
||||
{
|
||||
kategori: 'Dokter',
|
||||
subcategories: ['Praktik Dokter', 'Dokter Gigi', 'Dokter Anak'],
|
||||
target: 15000,
|
||||
prefixes: ['Praktik Dokter', 'Dokter', 'Klinik Dokter'],
|
||||
nouns: ['dr Andi', 'dr Sari', 'dr Budi', 'dr Ratna', 'dr Hadi'],
|
||||
openingHours: ['16:00-21:00', '09:00-15:00'],
|
||||
},
|
||||
{
|
||||
kategori: 'Laundry',
|
||||
subcategories: ['Laundry Kiloan', 'Dry Cleaning', 'Laundry Sepatu'],
|
||||
target: 40000,
|
||||
prefixes: ['Laundry', 'Cuci Express', 'Dry Clean'],
|
||||
nouns: ['Cepat', 'Bersih', 'Wangi', 'Fresh', 'Kilat'],
|
||||
openingHours: ['07:00-21:00', '08:00-20:00'],
|
||||
},
|
||||
{
|
||||
kategori: 'Bank',
|
||||
subcategories: ['Kantor Bank', 'Cabang Bank'],
|
||||
target: 15000,
|
||||
prefixes: ['Bank', 'Kantor Cabang'],
|
||||
nouns: ['Mandiri', 'BRI', 'BNI', 'BCA', 'BTN'],
|
||||
openingHours: ['08:00-15:00'],
|
||||
},
|
||||
{
|
||||
kategori: 'Sekolah',
|
||||
subcategories: ['SD', 'SMP', 'SMA', 'SMK'],
|
||||
target: 50000,
|
||||
prefixes: ['Sekolah', 'SD', 'SMP', 'SMA', 'SMK'],
|
||||
nouns: ['Negeri', 'Harapan Bangsa', 'Bina Insani', 'Cendekia', 'Nusantara'],
|
||||
openingHours: ['07:00-15:00'],
|
||||
},
|
||||
{
|
||||
kategori: 'Kampus',
|
||||
subcategories: ['Perguruan Tinggi', 'Akademi', 'Politeknik'],
|
||||
target: 30000,
|
||||
prefixes: ['Universitas', 'Politeknik', 'Institut', 'Akademi'],
|
||||
nouns: ['Nusantara', 'Teknologi', 'Mandiri', 'Cendekia', 'Indonesia'],
|
||||
openingHours: ['07:00-18:00'],
|
||||
},
|
||||
{
|
||||
kategori: 'Masjid',
|
||||
subcategories: ['Masjid', 'Mushola', 'Masjid Raya'],
|
||||
target: 110000,
|
||||
prefixes: ['Masjid', 'Mushola'],
|
||||
nouns: ['Al Ikhlas', 'Al Hidayah', 'At Taqwa', 'Nurul Iman', 'Baiturrahman'],
|
||||
openingHours: ['24 Jam'],
|
||||
},
|
||||
{
|
||||
kategori: 'Gereja',
|
||||
subcategories: ['Gereja', 'Kapel'],
|
||||
target: 40000,
|
||||
prefixes: ['Gereja', 'Kapel'],
|
||||
nouns: ['Bethel', 'Kristus Raja', 'Immanuel', 'Maranatha', 'Santo Yosef'],
|
||||
openingHours: ['06:00-20:00'],
|
||||
},
|
||||
{
|
||||
kategori: 'Penginapan',
|
||||
subcategories: ['Guest House', 'Homestay', 'Losmen'],
|
||||
target: 25000,
|
||||
prefixes: ['Guest House', 'Homestay', 'Penginapan'],
|
||||
nouns: ['Nyaman', 'Asri', 'Indah', 'Keluarga', 'Transit'],
|
||||
openingHours: ['24 Jam'],
|
||||
},
|
||||
{
|
||||
kategori: 'Kost',
|
||||
subcategories: ['Kost Putra', 'Kost Putri', 'Kost Campur'],
|
||||
target: 30000,
|
||||
prefixes: ['Kost', 'Rumah Kost', 'Kost Eksklusif'],
|
||||
nouns: ['Nyaman', 'Dago', 'Mawar', 'Melati', 'Transit'],
|
||||
openingHours: ['08:00-20:00'],
|
||||
},
|
||||
{
|
||||
kategori: 'Pasar',
|
||||
subcategories: ['Pasar Tradisional', 'Pasar Modern', 'Pasar Grosir'],
|
||||
target: 25000,
|
||||
prefixes: ['Pasar', 'Pasar Modern', 'Pusat Grosir'],
|
||||
nouns: ['Sentral', 'Rakyat', 'Induk', 'Pagi', 'Kota'],
|
||||
openingHours: ['05:00-17:00', '06:00-18:00'],
|
||||
},
|
||||
{
|
||||
kategori: 'Rental Mobil',
|
||||
subcategories: ['Sewa Mobil', 'Rental Harian'],
|
||||
target: 15000,
|
||||
prefixes: ['Rental Mobil', 'Sewa Mobil', 'Rent Car'],
|
||||
nouns: ['Prima', 'Mandiri', 'Jaya', 'Travelink', 'Kota'],
|
||||
openingHours: ['08:00-21:00', '24 Jam'],
|
||||
},
|
||||
{
|
||||
kategori: 'Rental Motor',
|
||||
subcategories: ['Sewa Motor', 'Rental Harian'],
|
||||
target: 10000,
|
||||
prefixes: ['Rental Motor', 'Sewa Motor'],
|
||||
nouns: ['Cepat', 'Kita', 'Mandiri', 'Prima', 'Kota'],
|
||||
openingHours: ['08:00-21:00'],
|
||||
},
|
||||
{
|
||||
kategori: 'Salon',
|
||||
subcategories: ['Salon Kecantikan', 'Hair Studio'],
|
||||
target: 20000,
|
||||
prefixes: ['Salon', 'Beauty Studio', 'Hair Salon'],
|
||||
nouns: ['Cantik', 'Mawar', 'Ayu', 'Glow', 'Ratu'],
|
||||
openingHours: ['09:00-21:00'],
|
||||
},
|
||||
{
|
||||
kategori: 'Barbershop',
|
||||
subcategories: ['Pangkas Rambut', 'Mens Grooming'],
|
||||
target: 20000,
|
||||
prefixes: ['Barbershop', 'Pangkas Rambut', 'Urban Cut'],
|
||||
nouns: ['Keren', 'Rapi', 'Gentleman', 'Kota', 'Prime'],
|
||||
openingHours: ['10:00-22:00'],
|
||||
},
|
||||
{
|
||||
kategori: 'Gym',
|
||||
subcategories: ['Pusat Kebugaran', 'Fitness Center'],
|
||||
target: 15000,
|
||||
prefixes: ['Gym', 'Fitness Center', 'Fit Club'],
|
||||
nouns: ['Prima', 'Urban', 'Strong', 'Sehat', 'Active'],
|
||||
openingHours: ['06:00-22:00'],
|
||||
},
|
||||
{
|
||||
kategori: 'Kolam Renang',
|
||||
subcategories: ['Kolam Renang Umum', 'Waterpark'],
|
||||
target: 10000,
|
||||
prefixes: ['Kolam Renang', 'Waterpark'],
|
||||
nouns: ['Tirta', 'Bahagia', 'Keluarga', 'Segar', 'Aqua'],
|
||||
openingHours: ['07:00-18:00'],
|
||||
},
|
||||
{
|
||||
kategori: 'Toko Bangunan',
|
||||
subcategories: ['Material Bangunan', 'Perkakas', 'Cat'],
|
||||
target: 40000,
|
||||
prefixes: ['Toko Bangunan', 'Material', 'Depo Bangunan'],
|
||||
nouns: ['Maju Jaya', 'Mandiri', 'Sentosa', 'Karya', 'Prima'],
|
||||
openingHours: ['08:00-17:00'],
|
||||
},
|
||||
{
|
||||
kategori: 'Toko Elektronik',
|
||||
subcategories: ['Elektronik & Gadget', 'Komputer', 'Aksesoris HP'],
|
||||
target: 35000,
|
||||
prefixes: ['Toko Elektronik', 'Gadget Store', 'Computer Center'],
|
||||
nouns: ['Nusantara', 'Digital', 'Prima', 'Jaya', 'Mega'],
|
||||
openingHours: ['09:00-21:00'],
|
||||
},
|
||||
{
|
||||
kategori: 'Toko Pakaian',
|
||||
subcategories: ['Fashion', 'Butik', 'Distro'],
|
||||
target: 35000,
|
||||
prefixes: ['Toko Pakaian', 'Butik', 'Distro'],
|
||||
nouns: ['Cantik', 'Modis', 'Urban', 'Mawar', 'Kota'],
|
||||
openingHours: ['09:00-21:00'],
|
||||
},
|
||||
{
|
||||
kategori: 'Logistik',
|
||||
subcategories: ['Jasa Pengiriman', 'Cargo', 'Ekspedisi'],
|
||||
target: 20000,
|
||||
prefixes: ['Ekspedisi', 'Cargo', 'Logistik'],
|
||||
nouns: ['Cepat', 'Nusantara', 'Express', 'Kilat', 'Jaya'],
|
||||
openingHours: ['08:00-20:00'],
|
||||
},
|
||||
{
|
||||
kategori: 'Kurir',
|
||||
subcategories: ['Jasa Kurir Lokal', 'Same Day Delivery'],
|
||||
target: 10000,
|
||||
prefixes: ['Kurir', 'Delivery', 'Jasa Kurir'],
|
||||
nouns: ['Cepat', 'Kilat', 'Kota', 'Express', 'Mandiri'],
|
||||
openingHours: ['08:00-22:00'],
|
||||
},
|
||||
{
|
||||
kategori: 'Jasa',
|
||||
subcategories: ['Layanan Umum', 'Service AC', 'Jasa Kebersihan'],
|
||||
target: 30000,
|
||||
prefixes: ['Jasa', 'Layanan', 'Service'],
|
||||
nouns: ['Mandiri', 'Cepat', 'Profesional', 'Prima', 'Kota'],
|
||||
openingHours: ['08:00-18:00'],
|
||||
},
|
||||
{
|
||||
kategori: 'UMKM',
|
||||
subcategories: ['Usaha Lokal', 'Produk Rumahan', 'Kerajinan'],
|
||||
target: 75000,
|
||||
prefixes: ['UMKM', 'Toko', 'Produk Lokal'],
|
||||
nouns: ['Mandiri', 'Kreatif', 'Nusantara', 'Rumahan', 'Karya'],
|
||||
openingHours: ['08:00-21:00'],
|
||||
},
|
||||
];
|
||||
|
||||
const STREET_NAMES = [
|
||||
'Jl Sudirman',
|
||||
'Jl Ahmad Yani',
|
||||
'Jl Merdeka',
|
||||
'Jl Diponegoro',
|
||||
'Jl Gatot Subroto',
|
||||
'Jl Gajah Mada',
|
||||
'Jl Veteran',
|
||||
'Jl Pemuda',
|
||||
'Jl Pahlawan',
|
||||
'Jl Kartini',
|
||||
'Jl Imam Bonjol',
|
||||
'Jl Asia Afrika',
|
||||
'Jl Nusantara',
|
||||
'Jl Raya Kota',
|
||||
'Jl Pasar Baru',
|
||||
];
|
||||
|
||||
function resolveWorkspacePath(inputPath) {
|
||||
const resolvedPath = path.resolve(WORKSPACE_ROOT, inputPath);
|
||||
const relativePath = path.relative(WORKSPACE_ROOT, resolvedPath);
|
||||
|
||||
if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
||||
throw new Error('--output must stay inside the workspace root');
|
||||
}
|
||||
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
return argv.slice(2).reduce(
|
||||
(acc, item) => {
|
||||
if (item.startsWith('--rows=')) {
|
||||
acc.rows = Number(item.split('=')[1]);
|
||||
} else if (item.startsWith('--output=')) {
|
||||
acc.output = resolveWorkspacePath(item.split('=')[1]);
|
||||
} else if (item === '--help' || item === '-h') {
|
||||
acc.help = true;
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{ rows: DEFAULT_ROWS, output: DEFAULT_OUTPUT, help: false },
|
||||
);
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`GeoSeek CSV Generator\n\nUsage:\n node backend/src/scripts/geoseek/generateGeoPlacesCsv.js --rows=1000000 --output=data/geoseek/generated/geo_places_1M.csv\n\nNotes:\n - Generated rows are synthetic development seed data, not verified real POI.\n - The generator streams CSV output, so it can create large files without holding all rows in memory.\n`);
|
||||
}
|
||||
|
||||
function createSeededRandom(seed) {
|
||||
let value = seed % 2147483647;
|
||||
|
||||
if (value <= 0) {
|
||||
value += 2147483646;
|
||||
}
|
||||
|
||||
return () => {
|
||||
value = (value * 16807) % 2147483647;
|
||||
return (value - 1) / 2147483646;
|
||||
};
|
||||
}
|
||||
|
||||
const random = createSeededRandom(20260618);
|
||||
|
||||
function pick(items) {
|
||||
return items[Math.floor(random() * items.length)];
|
||||
}
|
||||
|
||||
function weightedPick(items, weightKey = 'target') {
|
||||
const totalWeight = items.reduce((sum, item) => sum + item[weightKey], 0);
|
||||
let cursor = random() * totalWeight;
|
||||
|
||||
for (const item of items) {
|
||||
cursor -= item[weightKey];
|
||||
if (cursor <= 0) {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
return items[items.length - 1];
|
||||
}
|
||||
|
||||
function allocateCounts(totalRows, targets) {
|
||||
const totalTarget = targets.reduce((sum, item) => sum + item.target, 0);
|
||||
const rawAllocations = targets.map((item) => {
|
||||
const exact = (item.target / totalTarget) * totalRows;
|
||||
|
||||
return {
|
||||
...item,
|
||||
count: Math.floor(exact),
|
||||
remainder: exact - Math.floor(exact),
|
||||
};
|
||||
});
|
||||
let remaining = totalRows - rawAllocations.reduce((sum, item) => sum + item.count, 0);
|
||||
const sortedByRemainder = [...rawAllocations].sort(
|
||||
(a, b) => b.remainder - a.remainder,
|
||||
);
|
||||
|
||||
for (let index = 0; remaining > 0; index += 1) {
|
||||
sortedByRemainder[index % sortedByRemainder.length].count += 1;
|
||||
remaining -= 1;
|
||||
}
|
||||
|
||||
return rawAllocations.map((item) => {
|
||||
const updated = sortedByRemainder.find(
|
||||
(allocation) => allocation.kota === item.kota,
|
||||
);
|
||||
|
||||
return {
|
||||
...item,
|
||||
count: updated.count,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function csvEscape(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const stringValue = String(value);
|
||||
|
||||
if (
|
||||
stringValue.includes(',') ||
|
||||
stringValue.includes('"') ||
|
||||
stringValue.includes('\n')
|
||||
) {
|
||||
return `"${stringValue.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
return stringValue;
|
||||
}
|
||||
|
||||
function makeCoordinates(city) {
|
||||
const latJitter = (random() - 0.5) * 0.16;
|
||||
const lngJitter = (random() - 0.5) * 0.16;
|
||||
|
||||
return {
|
||||
latitude: (city.lat + latJitter).toFixed(6),
|
||||
longitude: (city.lng + lngJitter).toFixed(6),
|
||||
};
|
||||
}
|
||||
|
||||
function makePhone(prefix) {
|
||||
const suffix = String(Math.floor(1000000 + random() * 8999999));
|
||||
return `${prefix}${suffix}`;
|
||||
}
|
||||
|
||||
function makeRating() {
|
||||
return (3.8 + random() * 1.1).toFixed(1);
|
||||
}
|
||||
|
||||
function makeReviewCount(category) {
|
||||
const highTrafficCategories = new Set([
|
||||
'Restoran',
|
||||
'Cafe',
|
||||
'Mall',
|
||||
'Wisata',
|
||||
'Hotel',
|
||||
'Rumah Sakit',
|
||||
]);
|
||||
const max = highTrafficCategories.has(category) ? 6000 : 1500;
|
||||
const min = highTrafficCategories.has(category) ? 80 : 10;
|
||||
|
||||
return Math.floor(min + random() * max);
|
||||
}
|
||||
|
||||
function resolveCity(cityTarget, rowOffset) {
|
||||
if (cityTarget.kota !== 'Kota lain') {
|
||||
return cityTarget;
|
||||
}
|
||||
|
||||
return OTHER_CITIES[rowOffset % OTHER_CITIES.length];
|
||||
}
|
||||
|
||||
function makePlaceRow(id, cityTarget, rowOffset) {
|
||||
const city = resolveCity(cityTarget, rowOffset);
|
||||
const category = weightedPick(CATEGORY_DEFINITIONS);
|
||||
const district = pick(city.districts);
|
||||
const street = pick(STREET_NAMES);
|
||||
const streetNumber = Math.floor(1 + random() * 299);
|
||||
const coords = makeCoordinates(city);
|
||||
const name = `${pick(category.prefixes)} ${pick(category.nouns)} ${city.kota}`;
|
||||
|
||||
return [
|
||||
id,
|
||||
name,
|
||||
category.kategori,
|
||||
pick(category.subcategories),
|
||||
`${street} No ${streetNumber}`,
|
||||
district,
|
||||
city.kota,
|
||||
city.provinsi,
|
||||
coords.latitude,
|
||||
coords.longitude,
|
||||
makePhone(city.phonePrefix),
|
||||
pick(category.openingHours),
|
||||
makeRating(),
|
||||
makeReviewCount(category.kategori),
|
||||
'GeoSeekSynthetic',
|
||||
'unverified',
|
||||
];
|
||||
}
|
||||
|
||||
async function writeLine(stream, values) {
|
||||
const line = `${values.map(csvEscape).join(',')}\n`;
|
||||
|
||||
if (!stream.write(line)) {
|
||||
await once(stream, 'drain');
|
||||
}
|
||||
}
|
||||
|
||||
async function generateCsv({ rows, output }) {
|
||||
if (!Number.isInteger(rows) || rows < 1) {
|
||||
throw new Error('--rows must be a positive integer');
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(output), { recursive: true });
|
||||
|
||||
const allocations = allocateCounts(rows, CITY_TARGETS);
|
||||
const stream = fs.createWriteStream(output, { encoding: 'utf8' });
|
||||
let id = 1;
|
||||
|
||||
await writeLine(stream, HEADER);
|
||||
|
||||
for (const cityAllocation of allocations) {
|
||||
for (let index = 0; index < cityAllocation.count; index += 1) {
|
||||
await writeLine(stream, makePlaceRow(id, cityAllocation, index));
|
||||
id += 1;
|
||||
}
|
||||
}
|
||||
|
||||
stream.end();
|
||||
await once(stream, 'finish');
|
||||
|
||||
return {
|
||||
output,
|
||||
rows: id - 1,
|
||||
rawCityTargetTotal: CITY_TARGETS.reduce((sum, item) => sum + item.target, 0),
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv);
|
||||
|
||||
if (args.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await generateCsv(args);
|
||||
console.log(
|
||||
`Generated ${result.rows.toLocaleString('id-ID')} rows: ${path.relative(
|
||||
WORKSPACE_ROOT,
|
||||
result.output,
|
||||
)}`,
|
||||
);
|
||||
console.log(
|
||||
`Raw city target total is ${result.rawCityTargetTotal.toLocaleString(
|
||||
'id-ID',
|
||||
)}; allocations were scaled to requested row count.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
allocateCounts,
|
||||
generateCsv,
|
||||
resolveWorkspacePath,
|
||||
};
|
||||
@ -2,269 +2,15 @@ const UsersDBApi = require('../db/api/users');
|
||||
const ValidationError = require('./notifications/errors/validation');
|
||||
const ForbiddenError = require('./notifications/errors/forbidden');
|
||||
const bcrypt = require('bcrypt');
|
||||
const crypto = require('crypto');
|
||||
const EmailAddressVerificationEmail = require('./email/list/addressVerification');
|
||||
const EmailOtpEmail = require('./email/list/emailOtp');
|
||||
const InvitationEmail = require("./email/list/invitation");
|
||||
const PasswordResetEmail = require('./email/list/passwordReset');
|
||||
const EmailSender = require('./email');
|
||||
const config = require('../config');
|
||||
const helpers = require('../helpers');
|
||||
const db = require('../db/models');
|
||||
|
||||
class Auth {
|
||||
static normalizeEmail(email) {
|
||||
if (!email || typeof email !== 'string' || !email.includes('@')) {
|
||||
throw new ValidationError('auth.invalidEmail');
|
||||
}
|
||||
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
static async findUserModelByEmail(email, transaction) {
|
||||
return db.users.findOne({
|
||||
where: {
|
||||
email: {
|
||||
[db.Sequelize.Op.iLike]: email,
|
||||
},
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
|
||||
static async ensureDefaultMemberRole(user, transaction) {
|
||||
const currentRole = await user.getApp_role({ transaction });
|
||||
|
||||
if (currentRole?.id) {
|
||||
return currentRole;
|
||||
}
|
||||
|
||||
const role = await db.roles.findOne({
|
||||
where: { name: config.roles?.user || 'Registered User' },
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (role?.id) {
|
||||
await user.setApp_role(role.id, { transaction });
|
||||
}
|
||||
|
||||
return role;
|
||||
}
|
||||
|
||||
static async createOtpUser(email, transaction) {
|
||||
const randomPassword = crypto.randomBytes(20).toString('hex');
|
||||
const hashedPassword = await bcrypt.hash(
|
||||
randomPassword,
|
||||
config.bcrypt.saltRounds,
|
||||
);
|
||||
|
||||
const user = await db.users.create(
|
||||
{
|
||||
email,
|
||||
firstName: email.split('@')[0],
|
||||
password: hashedPassword,
|
||||
provider: config.providers.LOCAL,
|
||||
emailVerified: false,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
await this.ensureDefaultMemberRole(user, transaction);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
static async requestEmailOtp(email) {
|
||||
const normalizedEmail = this.normalizeEmail(email);
|
||||
|
||||
if (!EmailSender.isConfigured) {
|
||||
throw new ValidationError('auth.emailOtp.emailNotConfigured');
|
||||
}
|
||||
|
||||
const transaction = await db.sequelize.transaction();
|
||||
|
||||
try {
|
||||
let user = await this.findUserModelByEmail(normalizedEmail, transaction);
|
||||
|
||||
if (user?.disabled) {
|
||||
throw new ValidationError('auth.userDisabled');
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
user = await this.createOtpUser(normalizedEmail, transaction);
|
||||
}
|
||||
|
||||
if (user.emailOtpLastSentAt) {
|
||||
const secondsSinceLastOtp = (Date.now() - new Date(user.emailOtpLastSentAt).getTime()) / 1000;
|
||||
|
||||
if (secondsSinceLastOtp < 60) {
|
||||
throw new ValidationError('auth.emailOtp.tooManyRequests');
|
||||
}
|
||||
}
|
||||
|
||||
const otp = String(crypto.randomInt(100000, 1000000));
|
||||
const expiresInMinutes = 10;
|
||||
const emailOtpHash = await bcrypt.hash(otp, config.bcrypt.saltRounds);
|
||||
|
||||
await user.update(
|
||||
{
|
||||
emailOtpHash,
|
||||
emailOtpExpiresAt: new Date(Date.now() + expiresInMinutes * 60 * 1000),
|
||||
emailOtpLastSentAt: new Date(),
|
||||
emailOtpAttempts: 0,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
const emailOtpEmail = new EmailOtpEmail(normalizedEmail, otp, expiresInMinutes);
|
||||
await new EmailSender(emailOtpEmail).send();
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
email: normalizedEmail,
|
||||
expiresInMinutes,
|
||||
};
|
||||
} catch (error) {
|
||||
if (!transaction.finished) {
|
||||
await transaction.rollback();
|
||||
}
|
||||
console.error('Email OTP request failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async verifyEmailOtp(email, otp) {
|
||||
const normalizedEmail = this.normalizeEmail(email);
|
||||
const normalizedOtp = String(otp || '').trim();
|
||||
|
||||
if (!/^\d{6}$/.test(normalizedOtp)) {
|
||||
throw new ValidationError('auth.emailOtp.invalid');
|
||||
}
|
||||
|
||||
const transaction = await db.sequelize.transaction();
|
||||
|
||||
try {
|
||||
const user = await this.findUserModelByEmail(normalizedEmail, transaction);
|
||||
|
||||
if (!user || user.disabled) {
|
||||
throw new ValidationError(user?.disabled ? 'auth.userDisabled' : 'auth.emailOtp.invalid');
|
||||
}
|
||||
|
||||
const attempts = Number(user.emailOtpAttempts || 0);
|
||||
|
||||
if (attempts >= 5) {
|
||||
throw new ValidationError('auth.emailOtp.tooManyAttempts');
|
||||
}
|
||||
|
||||
if (!user.emailOtpHash || !user.emailOtpExpiresAt || new Date(user.emailOtpExpiresAt).getTime() < Date.now()) {
|
||||
throw new ValidationError('auth.emailOtp.invalid');
|
||||
}
|
||||
|
||||
const otpMatches = await bcrypt.compare(normalizedOtp, user.emailOtpHash);
|
||||
|
||||
if (!otpMatches) {
|
||||
await user.update(
|
||||
{
|
||||
emailOtpAttempts: attempts + 1,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
await transaction.commit();
|
||||
throw new ValidationError('auth.emailOtp.invalid');
|
||||
}
|
||||
|
||||
await this.ensureDefaultMemberRole(user, transaction);
|
||||
|
||||
await user.update(
|
||||
{
|
||||
emailVerified: true,
|
||||
emailOtpHash: null,
|
||||
emailOtpExpiresAt: null,
|
||||
emailOtpAttempts: 0,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
const data = {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
},
|
||||
};
|
||||
|
||||
return helpers.jwtSign(data);
|
||||
} catch (error) {
|
||||
if (!transaction.finished) {
|
||||
await transaction.rollback();
|
||||
}
|
||||
console.error('Email OTP verification failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async socialSignin(email, profile, provider) {
|
||||
const normalizedEmail = this.normalizeEmail(email);
|
||||
const transaction = await db.sequelize.transaction();
|
||||
|
||||
try {
|
||||
let user = await this.findUserModelByEmail(normalizedEmail, transaction);
|
||||
|
||||
if (user?.disabled) {
|
||||
throw new ValidationError('auth.userDisabled');
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
const displayName = profile.displayName || profile.name?.givenName || normalizedEmail.split('@')[0];
|
||||
|
||||
user = await db.users.create(
|
||||
{
|
||||
email: normalizedEmail,
|
||||
firstName: displayName,
|
||||
provider,
|
||||
emailVerified: true,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
await this.ensureDefaultMemberRole(user, transaction);
|
||||
} else {
|
||||
const updatePayload = {
|
||||
emailVerified: true,
|
||||
};
|
||||
|
||||
if (!user.provider) {
|
||||
updatePayload.provider = provider;
|
||||
}
|
||||
|
||||
await user.update(updatePayload, { transaction });
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
const data = {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: profile.displayName,
|
||||
},
|
||||
};
|
||||
|
||||
return helpers.jwtSign(data);
|
||||
} catch (error) {
|
||||
if (!transaction.finished) {
|
||||
await transaction.rollback();
|
||||
}
|
||||
console.error('Social sign-in failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async signup(email, password, options = {}, host) {
|
||||
email = this.normalizeEmail(email);
|
||||
const user = await UsersDBApi.findBy({email});
|
||||
|
||||
const hashedPassword = await bcrypt.hash(
|
||||
@ -335,8 +81,7 @@ class Auth {
|
||||
return helpers.jwtSign(data);
|
||||
}
|
||||
|
||||
static async signin(email, password) {
|
||||
email = this.normalizeEmail(email);
|
||||
static async signin(email, password, options = {}) {
|
||||
const user = await UsersDBApi.findBy({email});
|
||||
|
||||
if (!user) {
|
||||
|
||||
@ -1,58 +0,0 @@
|
||||
const { getNotification } = require('../../notifications/helpers');
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
module.exports = class EmailOtpEmail {
|
||||
constructor(to, otp, expiresInMinutes) {
|
||||
this.to = to;
|
||||
this.otp = otp;
|
||||
this.expiresInMinutes = expiresInMinutes;
|
||||
}
|
||||
|
||||
get subject() {
|
||||
return `Kode OTP ${getNotification('app.title')}`;
|
||||
}
|
||||
|
||||
async html() {
|
||||
const appTitle = escapeHtml(getNotification('app.title'));
|
||||
const otp = escapeHtml(this.otp);
|
||||
const expiresInMinutes = escapeHtml(this.expiresInMinutes);
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
.email-container { max-width: 600px; margin: auto; background: #ffffff; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; font-family: Arial, sans-serif; }
|
||||
.email-header { background: #3498db; color: #ffffff; padding: 16px; text-align: center; font-size: 18px; font-weight: bold; }
|
||||
.email-body { padding: 20px; color: #1f2937; }
|
||||
.otp-code { display: inline-block; letter-spacing: 6px; font-size: 30px; font-weight: bold; background: #f3f4f6; border-radius: 8px; padding: 12px 18px; margin: 12px 0; }
|
||||
.email-footer { padding: 16px; background: #f7fafc; text-align: center; color: #4a5568; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="email-header">Kode OTP untuk ${appTitle}</div>
|
||||
<div class="email-body">
|
||||
<p>Halo,</p>
|
||||
<p>Gunakan kode berikut untuk masuk atau daftar sebagai Anggota:</p>
|
||||
<div class="otp-code">${otp}</div>
|
||||
<p>Kode ini berlaku selama ${expiresInMinutes} menit.</p>
|
||||
<p>Jika Anda tidak meminta kode ini, abaikan email ini.</p>
|
||||
</div>
|
||||
<div class="email-footer">
|
||||
Terima kasih,<br />Tim ${appTitle}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
};
|
||||
@ -1,462 +0,0 @@
|
||||
const axios = require('axios');
|
||||
const db = require('../db/models');
|
||||
|
||||
const OVERPASS_URL = 'https://overpass-api.de/api/interpreter';
|
||||
|
||||
const OSM_CATEGORY_LABELS = {
|
||||
restaurant: 'Restoran',
|
||||
cafe: 'Cafe',
|
||||
hospital: 'Rumah Sakit',
|
||||
pharmacy: 'Apotek',
|
||||
bank: 'Bank',
|
||||
atm: 'ATM',
|
||||
fuel: 'SPBU',
|
||||
school: 'Sekolah',
|
||||
university: 'Kampus',
|
||||
hotel: 'Hotel',
|
||||
};
|
||||
|
||||
const DEFAULT_CITIES = [
|
||||
'Jakarta',
|
||||
'Bandung',
|
||||
'Surabaya',
|
||||
'Medan',
|
||||
'Semarang',
|
||||
'Makassar',
|
||||
'Palembang',
|
||||
'Yogyakarta',
|
||||
'Bekasi',
|
||||
'Depok',
|
||||
];
|
||||
|
||||
const CONTENT_FIELDS = [
|
||||
'name',
|
||||
'nama',
|
||||
'kategori',
|
||||
'subkategori',
|
||||
'short_description',
|
||||
'full_description',
|
||||
'address',
|
||||
'alamat',
|
||||
'kelurahan',
|
||||
'kecamatan',
|
||||
'city',
|
||||
'kota',
|
||||
'province',
|
||||
'provinsi',
|
||||
'postal_code',
|
||||
'kode_pos',
|
||||
'latitude',
|
||||
'longitude',
|
||||
'phone_number',
|
||||
'telepon',
|
||||
'whatsapp_number',
|
||||
'whatsapp',
|
||||
'email',
|
||||
'website_url',
|
||||
'website',
|
||||
'google_maps_url',
|
||||
'jam_buka',
|
||||
'status',
|
||||
'is_verified',
|
||||
'verified',
|
||||
'featured',
|
||||
'popularitas',
|
||||
];
|
||||
|
||||
const METADATA_FIELDS = [
|
||||
'sumber',
|
||||
'external_id',
|
||||
'external_type',
|
||||
'raw_source_data',
|
||||
'last_synced_at',
|
||||
];
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const normalizeList = (value, fallback) => {
|
||||
if (Array.isArray(value)) {
|
||||
const list = value.map((item) => String(item).trim()).filter(Boolean);
|
||||
return list.length ? list : fallback;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const list = value.split(',').map((item) => item.trim()).filter(Boolean);
|
||||
return list.length ? list : fallback;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const toPositiveInteger = (value, fallback, max) => {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number) || number < 0) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const integer = Math.floor(number);
|
||||
if (max === undefined) {
|
||||
return integer;
|
||||
}
|
||||
|
||||
return Math.min(integer, max);
|
||||
};
|
||||
|
||||
const toBoolean = (value, fallback = false) => {
|
||||
if (value === undefined || value === null) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return value === true || value === 'true' || value === '1' || value === 1;
|
||||
};
|
||||
|
||||
const titleCase = (value) => String(value)
|
||||
.split('_')
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
|
||||
const amenityKeyFromInput = (value) => {
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (OSM_CATEGORY_LABELS[normalized]) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const matched = Object.entries(OSM_CATEGORY_LABELS).find((entry) => (
|
||||
entry[1].toLowerCase() === normalized
|
||||
));
|
||||
|
||||
return matched ? matched[0] : normalized.replace(/\s+/g, '_');
|
||||
};
|
||||
|
||||
const normalizeAmenities = (params) => {
|
||||
const requested = normalizeList(
|
||||
params.amenities || params.categories,
|
||||
Object.keys(OSM_CATEGORY_LABELS),
|
||||
);
|
||||
|
||||
return [...new Set(requested.map(amenityKeyFromInput).filter(Boolean))];
|
||||
};
|
||||
|
||||
const escapeOverpassString = (value) => String(value)
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"');
|
||||
|
||||
const escapeRegex = (value) => String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
const buildOverpassQuery = (city, amenities, timeoutSeconds) => {
|
||||
const escapedCity = escapeOverpassString(city);
|
||||
const amenityRegex = amenities.map(escapeRegex).join('|');
|
||||
const selector = amenityRegex ? `["amenity"~"^(${amenityRegex})$"]` : '["amenity"]';
|
||||
|
||||
return `
|
||||
[out:json][timeout:${timeoutSeconds}];
|
||||
area["name"="${escapedCity}"]->.searchArea;
|
||||
(
|
||||
node${selector}(area.searchArea);
|
||||
way${selector}(area.searchArea);
|
||||
relation${selector}(area.searchArea);
|
||||
);
|
||||
out center tags;
|
||||
`;
|
||||
};
|
||||
|
||||
const getTag = (tags, keys) => {
|
||||
for (const key of keys) {
|
||||
const value = tags[key];
|
||||
if (value !== undefined && value !== null && String(value).trim() !== '') {
|
||||
return String(value).trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const buildAddress = (tags) => {
|
||||
const fullAddress = getTag(tags, ['addr:full', 'contact:address']);
|
||||
if (fullAddress) {
|
||||
return fullAddress;
|
||||
}
|
||||
|
||||
return [
|
||||
getTag(tags, ['addr:street']),
|
||||
getTag(tags, ['addr:housenumber']),
|
||||
getTag(tags, ['addr:neighbourhood', 'addr:suburb']),
|
||||
].filter(Boolean).join(', ') || null;
|
||||
};
|
||||
|
||||
const normalizeOsmElement = (element, city, amenities, includeUnnamed) => {
|
||||
const tags = element.tags || {};
|
||||
const amenity = tags.amenity;
|
||||
|
||||
if (!amenity || !amenities.includes(amenity)) {
|
||||
return { reason: 'unsupported_amenity' };
|
||||
}
|
||||
|
||||
const latitude = element.lat !== undefined ? element.lat : element.center && element.center.lat;
|
||||
const longitude = element.lon !== undefined ? element.lon : element.center && element.center.lon;
|
||||
|
||||
if (latitude === undefined || latitude === null || longitude === undefined || longitude === null) {
|
||||
return { reason: 'missing_coordinates' };
|
||||
}
|
||||
|
||||
const nama = getTag(tags, ['name', 'brand', 'operator']);
|
||||
if (!nama && !includeUnnamed) {
|
||||
return { reason: 'missing_name' };
|
||||
}
|
||||
|
||||
const alamat = buildAddress(tags);
|
||||
const kota = getTag(tags, ['addr:city', 'is_in:city']) || city;
|
||||
const provinsi = getTag(tags, ['addr:province', 'addr:state']);
|
||||
const kodePos = getTag(tags, ['addr:postcode']);
|
||||
const telepon = getTag(tags, ['phone', 'contact:phone']);
|
||||
const whatsapp = getTag(tags, ['whatsapp', 'contact:whatsapp']);
|
||||
const website = getTag(tags, ['website', 'contact:website', 'url']);
|
||||
const email = getTag(tags, ['email', 'contact:email']);
|
||||
const jamBuka = getTag(tags, ['opening_hours']);
|
||||
const displayName = nama || `${titleCase(amenity)} ${city}`;
|
||||
const now = new Date();
|
||||
|
||||
return {
|
||||
place: {
|
||||
name: displayName,
|
||||
nama: displayName,
|
||||
kategori: OSM_CATEGORY_LABELS[amenity] || titleCase(amenity),
|
||||
subkategori: amenity,
|
||||
short_description: `${OSM_CATEGORY_LABELS[amenity] || titleCase(amenity)} dari OpenStreetMap`,
|
||||
address: alamat,
|
||||
alamat,
|
||||
city: kota,
|
||||
kota,
|
||||
province: provinsi,
|
||||
provinsi,
|
||||
postal_code: kodePos,
|
||||
kode_pos: kodePos,
|
||||
latitude,
|
||||
longitude,
|
||||
phone_number: telepon,
|
||||
telepon,
|
||||
whatsapp_number: whatsapp,
|
||||
whatsapp,
|
||||
email,
|
||||
website_url: website,
|
||||
website,
|
||||
jam_buka: jamBuka,
|
||||
status: 'aktif',
|
||||
is_verified: false,
|
||||
verified: false,
|
||||
featured: false,
|
||||
popularitas: 0,
|
||||
sumber: 'OpenStreetMap',
|
||||
external_id: String(element.id),
|
||||
external_type: element.type,
|
||||
raw_source_data: element,
|
||||
last_synced_at: now,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const compactForUpdate = (payload, currentUser) => {
|
||||
const updatePayload = {};
|
||||
|
||||
for (const field of CONTENT_FIELDS) {
|
||||
const value = payload[field];
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
updatePayload[field] = value;
|
||||
}
|
||||
}
|
||||
|
||||
for (const field of METADATA_FIELDS) {
|
||||
updatePayload[field] = payload[field];
|
||||
}
|
||||
|
||||
updatePayload.updated_at = new Date();
|
||||
updatePayload.updatedById = currentUser && currentUser.id ? currentUser.id : null;
|
||||
|
||||
return updatePayload;
|
||||
};
|
||||
|
||||
module.exports = class GeoSeekCollectorService {
|
||||
static supportedAmenities() {
|
||||
return Object.entries(OSM_CATEGORY_LABELS).map(([value, label]) => ({ value, label }));
|
||||
}
|
||||
|
||||
static async fetchOpenStreetMapCity(city, amenities, options) {
|
||||
const timeoutSeconds = options.timeoutSeconds;
|
||||
const timeoutMs = timeoutSeconds * 1000 + 15000;
|
||||
const query = buildOverpassQuery(city, amenities, timeoutSeconds);
|
||||
|
||||
try {
|
||||
const response = await axios.get(options.overpassUrl, {
|
||||
params: { data: query },
|
||||
timeout: timeoutMs,
|
||||
maxBodyLength: Infinity,
|
||||
maxContentLength: Infinity,
|
||||
});
|
||||
|
||||
const data = response.data || {};
|
||||
if (!Array.isArray(data.elements)) {
|
||||
console.error('Invalid OpenStreetMap Overpass response', { city, data });
|
||||
throw new Error(`Invalid OpenStreetMap Overpass response for ${city}`);
|
||||
}
|
||||
|
||||
return data.elements;
|
||||
} catch (error) {
|
||||
console.error('OpenStreetMap Overpass request failed', {
|
||||
city,
|
||||
query,
|
||||
status: error.response && error.response.status,
|
||||
response: error.response && error.response.data,
|
||||
message: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async upsertPlace(payload, currentUser, transaction) {
|
||||
const where = {
|
||||
sumber: payload.sumber,
|
||||
external_type: payload.external_type,
|
||||
external_id: payload.external_id,
|
||||
};
|
||||
|
||||
const existing = await db.places.findOne({ where, transaction });
|
||||
|
||||
if (existing) {
|
||||
await existing.update(compactForUpdate(payload, currentUser), { transaction });
|
||||
return 'updated';
|
||||
}
|
||||
|
||||
try {
|
||||
await db.places.create({
|
||||
...payload,
|
||||
createdById: currentUser && currentUser.id ? currentUser.id : null,
|
||||
updatedById: currentUser && currentUser.id ? currentUser.id : null,
|
||||
}, { transaction });
|
||||
return 'inserted';
|
||||
} catch (error) {
|
||||
if (error.name !== 'SequelizeUniqueConstraintError') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const duplicate = await db.places.findOne({ where, transaction });
|
||||
if (!duplicate) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await duplicate.update(compactForUpdate(payload, currentUser), { transaction });
|
||||
return 'updated';
|
||||
}
|
||||
}
|
||||
|
||||
static async collectOpenStreetMap(params = {}, currentUser) {
|
||||
const cities = normalizeList(params.cities || params.kota, DEFAULT_CITIES);
|
||||
const amenities = normalizeAmenities(params);
|
||||
const includeUnnamed = toBoolean(params.includeUnnamed, false);
|
||||
const dryRun = toBoolean(params.dryRun, false);
|
||||
const delayMs = toPositiveInteger(params.delayMs, 3000, 30000);
|
||||
const timeoutSeconds = toPositiveInteger(params.timeoutSeconds, 60, 180) || 60;
|
||||
const limitPerCity = toPositiveInteger(params.limitPerCity, 0, 5000);
|
||||
const overpassUrl = params.overpassUrl || OVERPASS_URL;
|
||||
|
||||
const summary = {
|
||||
success: true,
|
||||
source: 'OpenStreetMap',
|
||||
dryRun,
|
||||
citiesRequested: cities,
|
||||
amenities,
|
||||
inserted: 0,
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
skippedByReason: {},
|
||||
fetched: 0,
|
||||
processed: 0,
|
||||
cityResults: [],
|
||||
};
|
||||
|
||||
for (let index = 0; index < cities.length; index += 1) {
|
||||
const city = cities[index];
|
||||
const elements = await GeoSeekCollectorService.fetchOpenStreetMapCity(city, amenities, {
|
||||
overpassUrl,
|
||||
timeoutSeconds,
|
||||
});
|
||||
|
||||
summary.fetched += elements.length;
|
||||
|
||||
const cityResult = {
|
||||
city,
|
||||
fetched: elements.length,
|
||||
processed: 0,
|
||||
inserted: 0,
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
skippedByReason: {},
|
||||
preview: [],
|
||||
};
|
||||
|
||||
const places = [];
|
||||
for (const element of elements) {
|
||||
const normalized = normalizeOsmElement(element, city, amenities, includeUnnamed);
|
||||
if (!normalized.place) {
|
||||
const reason = normalized.reason || 'unknown';
|
||||
cityResult.skipped += 1;
|
||||
cityResult.skippedByReason[reason] = (cityResult.skippedByReason[reason] || 0) + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
places.push(normalized.place);
|
||||
}
|
||||
|
||||
const selectedPlaces = limitPerCity > 0 ? places.slice(0, limitPerCity) : places;
|
||||
const limited = places.length - selectedPlaces.length;
|
||||
if (limited > 0) {
|
||||
cityResult.skipped += limited;
|
||||
cityResult.skippedByReason.limit_per_city = limited;
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
cityResult.processed = selectedPlaces.length;
|
||||
cityResult.preview = selectedPlaces.slice(0, 10).map((place) => ({
|
||||
nama: place.nama,
|
||||
kategori: place.kategori,
|
||||
kota: place.kota,
|
||||
latitude: place.latitude,
|
||||
longitude: place.longitude,
|
||||
external_id: place.external_id,
|
||||
external_type: place.external_type,
|
||||
}));
|
||||
} else {
|
||||
await db.sequelize.transaction(async (transaction) => {
|
||||
for (const place of selectedPlaces) {
|
||||
const action = await GeoSeekCollectorService.upsertPlace(place, currentUser, transaction);
|
||||
if (action === 'inserted') {
|
||||
cityResult.inserted += 1;
|
||||
} else {
|
||||
cityResult.updated += 1;
|
||||
}
|
||||
cityResult.processed += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
summary.inserted += cityResult.inserted;
|
||||
summary.updated += cityResult.updated;
|
||||
summary.skipped += cityResult.skipped;
|
||||
summary.processed += cityResult.processed;
|
||||
for (const [reason, count] of Object.entries(cityResult.skippedByReason)) {
|
||||
summary.skippedByReason[reason] = (summary.skippedByReason[reason] || 0) + count;
|
||||
}
|
||||
summary.cityResults.push(cityResult);
|
||||
|
||||
if (delayMs > 0 && index < cities.length - 1) {
|
||||
await sleep(delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
};
|
||||
@ -26,12 +26,6 @@ const errors = {
|
||||
'Email verification link is invalid or has expired',
|
||||
error: `Email not recognized`,
|
||||
},
|
||||
emailOtp: {
|
||||
emailNotConfigured: 'Email OTP is not configured yet. Please configure SMTP email first.',
|
||||
invalid: 'OTP code is invalid or has expired',
|
||||
tooManyRequests: 'Please wait before requesting another OTP code',
|
||||
tooManyAttempts: 'Too many invalid OTP attempts. Please request a new code',
|
||||
},
|
||||
},
|
||||
|
||||
iam: {
|
||||
|
||||
@ -3,6 +3,8 @@ const PlacesDBApi = require('../db/api/places');
|
||||
const processFile = require("../middlewares/upload");
|
||||
const ValidationError = require('./notifications/errors/validation');
|
||||
const csv = require('csv-parser');
|
||||
const axios = require('axios');
|
||||
const config = require('../config');
|
||||
const stream = require('stream');
|
||||
|
||||
|
||||
@ -13,7 +15,7 @@ module.exports = class PlacesService {
|
||||
static async create(data, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
try {
|
||||
const createdPlaces = await PlacesDBApi.create(
|
||||
await PlacesDBApi.create(
|
||||
data,
|
||||
{
|
||||
currentUser,
|
||||
@ -22,14 +24,13 @@ module.exports = class PlacesService {
|
||||
);
|
||||
|
||||
await transaction.commit();
|
||||
return createdPlaces;
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
static async bulkImport(req, res) {
|
||||
static async bulkImport(req, res, sendInvitationEmails = true, host) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
|
||||
try {
|
||||
@ -94,7 +95,7 @@ module.exports = class PlacesService {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
static async deleteByIds(ids, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
|
||||
@ -1,46 +0,0 @@
|
||||
# GeoSeek Dataset Nasional
|
||||
|
||||
Folder ini adalah pondasi dataset GeoSeek untuk target **CSV 1.000.000 baris** dan import database skala nasional.
|
||||
|
||||
## File
|
||||
|
||||
- `geo_places_template.csv` — header CSV standar.
|
||||
- `geo_places_sample.csv` — data awal realistis kota besar Indonesia untuk demo/dev.
|
||||
- `geo_categories.csv` — kategori utama dan target distribusi kategori.
|
||||
- `geo_city_targets.csv` — target distribusi kota dari blueprint GeoSeek.
|
||||
- `geo_api_schema.json` — draft schema response API pencarian.
|
||||
- `geo_database.sql` — draft schema SQL mandiri untuk GeoSeek.
|
||||
|
||||
## Struktur CSV utama
|
||||
|
||||
```csv
|
||||
id,nama_tempat,kategori,subkategori,alamat,kecamatan,kota,provinsi,latitude,longitude,no_telp,jam_operasional,rating,jumlah_review,sumber_data,status_verifikasi
|
||||
```
|
||||
|
||||
## Generate CSV 1 juta baris
|
||||
|
||||
Script generator ada di:
|
||||
|
||||
```txt
|
||||
backend/src/scripts/geoseek/generateGeoPlacesCsv.js
|
||||
```
|
||||
|
||||
Contoh membuat 10.000 baris untuk uji cepat:
|
||||
|
||||
```bash
|
||||
node backend/src/scripts/geoseek/generateGeoPlacesCsv.js --rows=10000 --output=data/geoseek/generated/geo_places_10k.csv
|
||||
```
|
||||
|
||||
Contoh membuat 1.000.000 baris:
|
||||
|
||||
```bash
|
||||
node backend/src/scripts/geoseek/generateGeoPlacesCsv.js --rows=1000000 --output=data/geoseek/generated/geo_places_1M.csv
|
||||
```
|
||||
|
||||
> Catatan: target kota pada blueprint berjumlah 1.030.000 jika dijumlah mentah. Generator otomatis melakukan scaling proporsional agar hasil tepat sesuai `--rows`, misalnya tepat 1.000.000 baris.
|
||||
|
||||
## Status data
|
||||
|
||||
- `geo_places_sample.csv` adalah seed awal untuk development/demo dan masih perlu verifikasi sebelum dianggap data produksi.
|
||||
- CSV hasil generator memakai `sumber_data=GeoSeekSynthetic` dan `status_verifikasi=unverified`, karena data dibuat sintetis untuk load test, demo, dan pengembangan ranking/search.
|
||||
- Untuk produksi, gabungkan dengan sumber legal seperti data internal, data publik resmi, atau OpenStreetMap/penyedia POI lain dengan kolom sumber dan lisensi yang jelas.
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,43 +0,0 @@
|
||||
{
|
||||
"name": "GeoSeek Search API Schema",
|
||||
"version": "1.0.0",
|
||||
"description": "Schema respons pencarian GeoSeek berbasis dataset CSV Nasional.",
|
||||
"endpoint": "GET /api/geoseek/search",
|
||||
"query": {
|
||||
"q": "string, kata kunci seperti restoran/cafe/apotek",
|
||||
"lat": "number, opsional latitude user",
|
||||
"lng": "number, opsional longitude user",
|
||||
"kota": "string, opsional filter kota",
|
||||
"provinsi": "string, opsional filter provinsi",
|
||||
"kategori": "string, opsional filter kategori",
|
||||
"radius_km": "number, opsional radius pencarian"
|
||||
},
|
||||
"response": {
|
||||
"rows": [
|
||||
{
|
||||
"id": "number|string",
|
||||
"nama_tempat": "string",
|
||||
"kategori": "string",
|
||||
"subkategori": "string",
|
||||
"alamat": "string",
|
||||
"kecamatan": "string",
|
||||
"kota": "string",
|
||||
"provinsi": "string",
|
||||
"latitude": "number",
|
||||
"longitude": "number",
|
||||
"distance_meters": "number|null",
|
||||
"rating": "number|null",
|
||||
"jumlah_review": "number|null",
|
||||
"no_telp": "string|null",
|
||||
"jam_operasional": "string|null",
|
||||
"sumber_data": "string",
|
||||
"status_verifikasi": "verified|unverified|synthetic"
|
||||
}
|
||||
],
|
||||
"count": "number",
|
||||
"meta": {
|
||||
"q": "string",
|
||||
"geo_score_formula": "distance 40%, rating 25%, popularity 20%, freshness 10%, relevance 5%"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,36 +0,0 @@
|
||||
id,kategori,subkategori_default,target_total
|
||||
1,Restoran,Makanan Lokal,150000
|
||||
2,Cafe,Coffee Shop,50000
|
||||
3,Hotel,Hotel/Penginapan,40000
|
||||
4,Rumah Sakit,Rumah Sakit Umum,25000
|
||||
5,Apotek,Apotek Umum,50000
|
||||
6,ATM,ATM Bank,25000
|
||||
7,SPBU,Pom Bensin,15000
|
||||
8,Bengkel,Bengkel Motor/Mobil,50000
|
||||
9,Wisata,Tempat Wisata,50000
|
||||
10,Mall,Pusat Perbelanjaan,30000
|
||||
11,Minimarket,Toko Harian,30000
|
||||
12,Klinik,Klinik Umum,15000
|
||||
13,Dokter,Praktik Dokter,15000
|
||||
14,Laundry,Laundry Kiloan,40000
|
||||
15,Bank,Kantor Bank,15000
|
||||
16,Sekolah,Sekolah Dasar/Menengah,50000
|
||||
17,Kampus,Perguruan Tinggi,30000
|
||||
18,Masjid,Tempat Ibadah Islam,110000
|
||||
19,Gereja,Tempat Ibadah Kristen/Katolik,40000
|
||||
20,Penginapan,Guest House/Homestay,25000
|
||||
21,Kost,Rumah Kost,30000
|
||||
22,Pasar,Pasar Tradisional,25000
|
||||
23,Rental Mobil,Sewa Mobil,15000
|
||||
24,Rental Motor,Sewa Motor,10000
|
||||
25,Salon,Salon Kecantikan,20000
|
||||
26,Barbershop,Pangkas Rambut,20000
|
||||
27,Gym,Pusat Kebugaran,15000
|
||||
28,Kolam Renang,Fasilitas Olahraga Air,10000
|
||||
29,Toko Bangunan,Material Bangunan,40000
|
||||
30,Toko Elektronik,Elektronik & Gadget,35000
|
||||
31,Toko Pakaian,Fashion,35000
|
||||
32,Logistik,Jasa Pengiriman,20000
|
||||
33,Kurir,Jasa Kurir Lokal,10000
|
||||
34,Jasa,Layanan Umum,30000
|
||||
35,UMKM,Usaha Mikro Kecil Menengah,75000
|
||||
|
@ -1,16 +0,0 @@
|
||||
id,kota,provinsi,target_data,latitude,longitude
|
||||
1,Jakarta,DKI Jakarta,200000,-6.2000,106.8167
|
||||
2,Surabaya,Jawa Timur,100000,-7.2575,112.7521
|
||||
3,Bandung,Jawa Barat,90000,-6.9175,107.6191
|
||||
4,Medan,Sumatera Utara,80000,3.5952,98.6722
|
||||
5,Bekasi,Jawa Barat,70000,-6.2383,106.9756
|
||||
6,Tangerang,Banten,70000,-6.1783,106.6319
|
||||
7,Semarang,Jawa Tengah,60000,-6.9667,110.4167
|
||||
8,Makassar,Sulawesi Selatan,50000,-5.1477,119.4327
|
||||
9,Palembang,Sumatera Selatan,45000,-2.9761,104.7754
|
||||
10,Depok,Jawa Barat,45000,-6.4025,106.7942
|
||||
11,Bogor,Jawa Barat,40000,-6.5971,106.8060
|
||||
12,Yogyakarta,DI Yogyakarta,35000,-7.7956,110.3695
|
||||
13,Denpasar,Bali,30000,-8.6705,115.2126
|
||||
14,Malang,Jawa Timur,30000,-7.9666,112.6326
|
||||
15,Kota lain,Nasional,85000,-2.5489,118.0149
|
||||
|
@ -1,65 +0,0 @@
|
||||
-- GeoSeek database draft for PostgreSQL.
|
||||
-- This file is intentionally schema-only; do not run on production before adapting migrations.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS geo_categories (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
kategori TEXT NOT NULL UNIQUE,
|
||||
subkategori_default TEXT,
|
||||
target_total INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS geo_regions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
provinsi TEXT NOT NULL,
|
||||
kota TEXT NOT NULL,
|
||||
kecamatan TEXT,
|
||||
kelurahan TEXT,
|
||||
latitude NUMERIC(10, 7),
|
||||
longitude NUMERIC(10, 7),
|
||||
sumber_data TEXT DEFAULT 'GeoSeek',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS geo_places (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
nama_tempat TEXT NOT NULL,
|
||||
kategori TEXT NOT NULL,
|
||||
subkategori TEXT,
|
||||
alamat TEXT,
|
||||
kecamatan TEXT,
|
||||
kota TEXT,
|
||||
provinsi TEXT,
|
||||
latitude NUMERIC(10, 7),
|
||||
longitude NUMERIC(10, 7),
|
||||
no_telp TEXT,
|
||||
jam_operasional TEXT,
|
||||
rating NUMERIC(2, 1),
|
||||
jumlah_review INTEGER DEFAULT 0,
|
||||
sumber_data TEXT NOT NULL DEFAULT 'GeoSeek',
|
||||
status_verifikasi TEXT NOT NULL DEFAULT 'unverified',
|
||||
status TEXT NOT NULL DEFAULT 'aktif',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_geo_places_keyword
|
||||
ON geo_places USING GIN (to_tsvector('simple', coalesce(nama_tempat, '') || ' ' || coalesce(kategori, '') || ' ' || coalesce(subkategori, '') || ' ' || coalesce(alamat, '')));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_geo_places_location
|
||||
ON geo_places (latitude, longitude);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_geo_places_region
|
||||
ON geo_places (provinsi, kota, kecamatan);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS geo_reviews (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
place_id BIGINT NOT NULL REFERENCES geo_places(id) ON DELETE CASCADE,
|
||||
user_id UUID,
|
||||
rating NUMERIC(2, 1),
|
||||
comment TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
@ -1,81 +0,0 @@
|
||||
id,nama_tempat,kategori,subkategori,alamat,kecamatan,kota,provinsi,latitude,longitude,no_telp,jam_operasional,rating,jumlah_review,sumber_data,status_verifikasi
|
||||
1,Warung Nasi Sunda Ibu Ani,Restoran,Makanan Sunda,Jl Asia Afrika No 20,Sumur Bandung,Bandung,Jawa Barat,-6.9218,107.6072,022xxxxxxx,07:00-22:00,4.6,850,GeoSeek,verified
|
||||
2,Hotel Indonesia Kempinski,Hotel,Hotel Bintang 5,Jl MH Thamrin No 1,Menteng,Jakarta Pusat,DKI Jakarta,-6.1939,106.8230,021xxxxxxx,24 Jam,4.8,5200,GeoSeek,verified
|
||||
3,Rumah Sakit Cipto Mangunkusumo,Rumah Sakit,Rumah Sakit Umum,Jl Diponegoro No 71,Senen,Jakarta Pusat,DKI Jakarta,-6.1975,106.8447,021xxxxxxx,24 Jam,4.5,3200,GeoSeek,verified
|
||||
4,SPBU Pertamina Sudirman,SPBU,Pom Bensin,Jl Sudirman,Karet Tengsin,Jakarta Pusat,DKI Jakarta,-6.2088,106.8220,021xxxxxxx,24 Jam,4.4,900,GeoSeek,verified
|
||||
5,Masjid Raya Bandung,Masjid,Masjid Raya,Alun-Alun Bandung,Regol,Bandung,Jawa Barat,-6.9219,107.6070,,24 Jam,4.8,7000,GeoSeek,verified
|
||||
6,Cafe Braga Senja,Cafe,Coffee Shop,Jl Braga No 45,Sumur Bandung,Bandung,Jawa Barat,-6.9179,107.6099,022xxxxxxx,08:00-23:00,4.5,1180,GeoSeek,verified
|
||||
7,Apotek Merdeka Bandung,Apotek,Apotek Umum,Jl Merdeka No 10,Bandung Wetan,Bandung,Jawa Barat,-6.9107,107.6097,022xxxxxxx,08:00-22:00,4.4,420,GeoSeek,verified
|
||||
8,Trans Studio Mall Bandung,Mall,Pusat Perbelanjaan,Jl Gatot Subroto No 289,Batununggal,Bandung,Jawa Barat,-6.9254,107.6366,022xxxxxxx,10:00-22:00,4.6,24500,GeoSeek,verified
|
||||
9,Institut Teknologi Bandung,Kampus,Perguruan Tinggi,Jl Ganesha No 10,Coblong,Bandung,Jawa Barat,-6.8915,107.6107,022xxxxxxx,07:00-18:00,4.8,9800,GeoSeek,verified
|
||||
10,Pasar Baru Trade Center,Pasar,Pusat Grosir,Jl Otto Iskandar Dinata,Kb Jeruk,Bandung,Jawa Barat,-6.9176,107.6042,022xxxxxxx,08:00-17:00,4.3,6400,GeoSeek,verified
|
||||
11,Monumen Nasional,Wisata,Monumen,Jl Medan Merdeka Gambir,Gambir,Jakarta Pusat,DKI Jakarta,-6.1754,106.8272,021xxxxxxx,08:00-16:00,4.7,72000,GeoSeek,verified
|
||||
12,Grand Indonesia,Mall,Pusat Perbelanjaan,Jl MH Thamrin No 1,Menteng,Jakarta Pusat,DKI Jakarta,-6.1951,106.8205,021xxxxxxx,10:00-22:00,4.7,58000,GeoSeek,verified
|
||||
13,Stasiun Gambir,Logistik,Transportasi Publik,Jl Medan Merdeka Timur,Gambir,Jakarta Pusat,DKI Jakarta,-6.1766,106.8307,021xxxxxxx,24 Jam,4.5,36000,GeoSeek,verified
|
||||
14,Plaza Senayan,Mall,Pusat Perbelanjaan,Jl Asia Afrika No 8,Tanah Abang,Jakarta Pusat,DKI Jakarta,-6.2256,106.7993,021xxxxxxx,10:00-22:00,4.6,31000,GeoSeek,verified
|
||||
15,Pasar Tanah Abang,Pasar,Pasar Grosir,Jl KH Mas Mansyur,Tanah Abang,Jakarta Pusat,DKI Jakarta,-6.1889,106.8106,021xxxxxxx,08:00-16:00,4.3,42000,GeoSeek,verified
|
||||
16,Kota Tua Jakarta,Wisata,Kawasan Sejarah,Jl Taman Fatahillah,Taman Sari,Jakarta Barat,DKI Jakarta,-6.1352,106.8133,021xxxxxxx,08:00-22:00,4.6,65000,GeoSeek,verified
|
||||
17,Universitas Indonesia,Kampus,Perguruan Tinggi,Jl Margonda Raya,Beji,Depok,Jawa Barat,-6.3600,106.8272,021xxxxxxx,07:00-18:00,4.7,18500,GeoSeek,verified
|
||||
18,RS Universitas Indonesia,Rumah Sakit,Rumah Sakit Pendidikan,Jl Prof Dr Bahder Djohan,Beji,Depok,Jawa Barat,-6.3685,106.8294,021xxxxxxx,24 Jam,4.4,2100,GeoSeek,verified
|
||||
19,Margocity Mall,Mall,Pusat Perbelanjaan,Jl Margonda Raya No 358,Beji,Depok,Jawa Barat,-6.3735,106.8344,021xxxxxxx,10:00-22:00,4.6,27000,GeoSeek,verified
|
||||
20,Kebun Raya Bogor,Wisata,Kebun Raya,Jl Ir H Juanda No 13,Bogor Tengah,Bogor,Jawa Barat,-6.5976,106.7996,0251xxxxxxx,08:00-16:00,4.7,52000,GeoSeek,verified
|
||||
21,RS PMI Bogor,Rumah Sakit,Rumah Sakit Umum,Jl Pajajaran No 80,Bogor Tengah,Bogor,Jawa Barat,-6.5945,106.8068,0251xxxxxxx,24 Jam,4.5,3900,GeoSeek,verified
|
||||
22,Botani Square Mall,Mall,Pusat Perbelanjaan,Jl Raya Pajajaran,Tegallega,Bogor,Jawa Barat,-6.6002,106.8060,0251xxxxxxx,10:00-22:00,4.5,21000,GeoSeek,verified
|
||||
23,Tugu Pahlawan Surabaya,Wisata,Monumen,Jl Pahlawan, Bubutan,Surabaya,Jawa Timur,-7.2458,112.7378,031xxxxxxx,08:00-16:00,4.6,18000,GeoSeek,verified
|
||||
24,RSUD Dr Soetomo,Rumah Sakit,Rumah Sakit Umum,Jl Mayjen Prof Dr Moestopo,Gubeng,Surabaya,Jawa Timur,-7.2677,112.7580,031xxxxxxx,24 Jam,4.5,8600,GeoSeek,verified
|
||||
25,Tunjungan Plaza,Mall,Pusat Perbelanjaan,Jl Basuki Rahmat,Tegalsari,Surabaya,Jawa Timur,-7.2633,112.7406,031xxxxxxx,10:00-22:00,4.7,65000,GeoSeek,verified
|
||||
26,Warung Rawon Setan,Restoran,Rawon,Jl Embong Malang,Genteng,Surabaya,Jawa Timur,-7.2607,112.7387,031xxxxxxx,08:00-23:00,4.4,7200,GeoSeek,verified
|
||||
27,Jembatan Suramadu,Wisata,Ikon Kota,Kenjeran,Kenjeran,Surabaya,Jawa Timur,-7.1824,112.7787,,24 Jam,4.6,33000,GeoSeek,verified
|
||||
28,Universitas Airlangga,Kampus,Perguruan Tinggi,Jl Airlangga No 4,Gubeng,Surabaya,Jawa Timur,-7.2692,112.7593,031xxxxxxx,07:00-18:00,4.7,9800,GeoSeek,verified
|
||||
29,Hotel Majapahit Surabaya,Hotel,Hotel Heritage,Jl Tunjungan No 65,Genteng,Surabaya,Jawa Timur,-7.2592,112.7399,031xxxxxxx,24 Jam,4.7,9200,GeoSeek,verified
|
||||
30,Lawang Sewu,Wisata,Bangunan Bersejarah,Jl Pemuda,Sekayu,Semarang,Jawa Tengah,-6.9840,110.4108,024xxxxxxx,08:00-20:00,4.6,56000,GeoSeek,verified
|
||||
31,Simpang Lima Semarang,Wisata,Ruang Publik,Jl Simpang Lima,Pleburan,Semarang,Jawa Tengah,-6.9904,110.4229,,24 Jam,4.6,36000,GeoSeek,verified
|
||||
32,RSUP Dr Kariadi,Rumah Sakit,Rumah Sakit Umum,Jl Dr Sutomo,Randusaharjo,Semarang,Jawa Tengah,-6.9932,110.4076,024xxxxxxx,24 Jam,4.5,7600,GeoSeek,verified
|
||||
33,Paragon City Mall Semarang,Mall,Pusat Perbelanjaan,Jl Pemuda No 118,Sekayu,Semarang,Jawa Tengah,-6.9808,110.4149,024xxxxxxx,10:00-22:00,4.6,28000,GeoSeek,verified
|
||||
34,Universitas Diponegoro,Kampus,Perguruan Tinggi,Jl Prof Soedarto,Tembalang,Semarang,Jawa Tengah,-7.0506,110.4401,024xxxxxxx,07:00-18:00,4.7,12800,GeoSeek,verified
|
||||
35,Malioboro Yogyakarta,Wisata,Kawasan Belanja,Jl Malioboro,Gedongtengen,Yogyakarta,DI Yogyakarta,-7.7926,110.3658,,24 Jam,4.7,89000,GeoSeek,verified
|
||||
36,Keraton Yogyakarta,Wisata,Budaya,Jl Rotowijayan,Krton,Yogyakarta,DI Yogyakarta,-7.8053,110.3642,0274xxxxxxx,08:00-14:00,4.6,34000,GeoSeek,verified
|
||||
37,Universitas Gadjah Mada,Kampus,Perguruan Tinggi,Jl Bulaksumur,Depok,Sleman,DI Yogyakarta,-7.7707,110.3776,0274xxxxxxx,07:00-18:00,4.8,24000,GeoSeek,verified
|
||||
38,Pasar Beringharjo,Pasar,Pasar Tradisional,Jl Margo Mulyo,Gondomanan,Yogyakarta,DI Yogyakarta,-7.7986,110.3671,0274xxxxxxx,08:00-17:00,4.5,32000,GeoSeek,verified
|
||||
39,RSUP Dr Sardjito,Rumah Sakit,Rumah Sakit Umum,Jl Kesehatan No 1,Mlati,Sleman,DI Yogyakarta,-7.7683,110.3738,0274xxxxxxx,24 Jam,4.5,7100,GeoSeek,verified
|
||||
40,Maimun Palace,Wisata,Istana,Jl Brigjen Katamso,Aur,Medan,Sumatera Utara,3.5753,98.6830,061xxxxxxx,08:00-17:00,4.5,26000,GeoSeek,verified
|
||||
41,Masjid Raya Al Mashun,Masjid,Masjid Raya,Jl Sisingamangaraja,Matsum,Medan,Sumatera Utara,3.5757,98.6873,,24 Jam,4.8,30000,GeoSeek,verified
|
||||
42,Sun Plaza Medan,Mall,Pusat Perbelanjaan,Jl KH Zainul Arifin,Madras Hulu,Medan,Sumatera Utara,3.5838,98.6728,061xxxxxxx,10:00-22:00,4.6,29000,GeoSeek,verified
|
||||
43,RSUP H Adam Malik,Rumah Sakit,Rumah Sakit Umum,Jl Bunga Lau,Tuntungan,Medan,Sumatera Utara,3.5196,98.6080,061xxxxxxx,24 Jam,4.4,6000,GeoSeek,verified
|
||||
44,Kedai Kopi Medan Baru,Cafe,Kedai Kopi,Jl Abdullah Lubis,Medan Baru,Medan,Sumatera Utara,3.5856,98.6649,061xxxxxxx,07:00-23:00,4.5,2400,GeoSeek,verified
|
||||
45,Benteng Rotterdam,Wisata,Benteng Sejarah,Jl Ujung Pandang,Bulo Gading,Makassar,Sulawesi Selatan,-5.1340,119.4054,0411xxxxxxx,08:00-18:00,4.6,22000,GeoSeek,verified
|
||||
46,Pantai Losari,Wisata,Pantai,Jl Penghibur,Losari,Makassar,Sulawesi Selatan,-5.1436,119.4076,,24 Jam,4.6,54000,GeoSeek,verified
|
||||
47,Trans Studio Mall Makassar,Mall,Pusat Perbelanjaan,Jl Metro Tj Bunga,Maccini Sombala,Makassar,Sulawesi Selatan,-5.1570,119.3942,0411xxxxxxx,10:00-22:00,4.6,21000,GeoSeek,verified
|
||||
48,RSUP Dr Wahidin Sudirohusodo,Rumah Sakit,Rumah Sakit Umum,Jl Perintis Kemerdekaan,Tamalanrea,Makassar,Sulawesi Selatan,-5.1350,119.4895,0411xxxxxxx,24 Jam,4.4,5200,GeoSeek,verified
|
||||
49,Universitas Hasanuddin,Kampus,Perguruan Tinggi,Jl Perintis Kemerdekaan,Tamalanrea,Makassar,Sulawesi Selatan,-5.1306,119.4866,0411xxxxxxx,07:00-18:00,4.7,11800,GeoSeek,verified
|
||||
50,Jembatan Ampera,Wisata,Ikon Kota,Jl Lintas Sumatera,Seberang Ulu I,Palembang,Sumatera Selatan,-2.9917,104.7634,,24 Jam,4.7,48000,GeoSeek,verified
|
||||
51,Palembang Icon Mall,Mall,Pusat Perbelanjaan,Jl POM IX,Lorok Pakjo,Palembang,Sumatera Selatan,-2.9768,104.7416,0711xxxxxxx,10:00-22:00,4.6,16000,GeoSeek,verified
|
||||
52,RSUP Dr Mohammad Hoesin,Rumah Sakit,Rumah Sakit Umum,Jl Jenderal Sudirman,Kemuning,Palembang,Sumatera Selatan,-2.9563,104.7480,0711xxxxxxx,24 Jam,4.4,4700,GeoSeek,verified
|
||||
53,Pempek Pak Raden,Restoran,Pempek,Jl Radial,Bukit Kecil,Palembang,Sumatera Selatan,-2.9850,104.7495,0711xxxxxxx,08:00-22:00,4.5,7600,GeoSeek,verified
|
||||
54,Universitas Sriwijaya Kampus Palembang,Kampus,Perguruan Tinggi,Jl Srijaya Negara,Ilir Barat I,Palembang,Sumatera Selatan,-2.9786,104.7334,0711xxxxxxx,07:00-18:00,4.6,6200,GeoSeek,verified
|
||||
55,Bekasi Cyber Park,Mall,Pusat Elektronik,Jl KH Noer Ali,Bekasi Selatan,Bekasi,Jawa Barat,-6.2480,106.9922,021xxxxxxx,10:00-22:00,4.4,18000,GeoSeek,verified
|
||||
56,RSUD Kota Bekasi,Rumah Sakit,Rumah Sakit Umum,Jl Pramuka,Marga Jaya,Bekasi,Jawa Barat,-6.2388,107.0041,021xxxxxxx,24 Jam,4.3,4500,GeoSeek,verified
|
||||
57,Grand Metropolitan Bekasi,Mall,Pusat Perbelanjaan,Jl KH Noer Ali,Pekayon Jaya,Bekasi,Jawa Barat,-6.2554,106.9869,021xxxxxxx,10:00-22:00,4.6,24000,GeoSeek,verified
|
||||
58,Stasiun Bekasi,Logistik,Transportasi Publik,Jl Ir H Juanda,Marga Mulya,Bekasi,Jawa Barat,-6.2366,107.0018,021xxxxxxx,04:00-24:00,4.3,29000,GeoSeek,verified
|
||||
59,IKEA Alam Sutera,Toko & UMKM,Furniture,Jl Jalur Sutera Boulevard,Serpong Utara,Tangerang,Banten,-6.2236,106.6537,021xxxxxxx,10:00-22:00,4.7,55000,GeoSeek,verified
|
||||
60,Summarecon Mall Serpong,Mall,Pusat Perbelanjaan,Jl Boulevard Gading Serpong,Kelapa Dua,Tangerang,Banten,-6.2418,106.6287,021xxxxxxx,10:00-22:00,4.7,42000,GeoSeek,verified
|
||||
61,RS Siloam Lippo Village,Rumah Sakit,Rumah Sakit Umum,Jl Siloam No 6,Kelapa Dua,Tangerang,Banten,-6.2284,106.6037,021xxxxxxx,24 Jam,4.5,7200,GeoSeek,verified
|
||||
62,Bandara Soekarno Hatta,Logistik,Bandara,Jl Bandara Soekarno Hatta,Benda,Tangerang,Banten,-6.1256,106.6559,021xxxxxxx,24 Jam,4.6,98000,GeoSeek,verified
|
||||
63,Pantai Kuta,Wisata,Pantai,Jl Pantai Kuta,Kuta,Denpasar,Bali,-8.7185,115.1686,,24 Jam,4.6,92000,GeoSeek,verified
|
||||
64,Sanur Beach,Wisata,Pantai,Jl Danau Tamblingan,Denpasar Selatan,Denpasar,Bali,-8.6930,115.2625,,24 Jam,4.6,53000,GeoSeek,verified
|
||||
65,RSUP Prof Ngoerah,Rumah Sakit,Rumah Sakit Umum,Jl Diponegoro,Dauh Puri Klod,Denpasar,Bali,-8.6703,115.2125,0361xxxxxxx,24 Jam,4.5,6100,GeoSeek,verified
|
||||
66,Level 21 Mall Denpasar,Mall,Pusat Perbelanjaan,Jl Teuku Umar,Dauh Puri Klod,Denpasar,Bali,-8.6724,115.2111,0361xxxxxxx,10:00-22:00,4.5,17000,GeoSeek,verified
|
||||
67,Universitas Udayana Kampus Sudirman,Kampus,Perguruan Tinggi,Jl PB Sudirman,Dangin Puri Klod,Denpasar,Bali,-8.6713,115.2247,0361xxxxxxx,07:00-18:00,4.6,7800,GeoSeek,verified
|
||||
68,Alun-Alun Kota Malang,Wisata,Ruang Publik,Jl Merdeka Selatan,Klojen,Malang,Jawa Timur,-7.9826,112.6308,,24 Jam,4.6,41000,GeoSeek,verified
|
||||
69,Universitas Brawijaya,Kampus,Perguruan Tinggi,Jl Veteran,Ketawanggede,Malang,Jawa Timur,-7.9525,112.6139,0341xxxxxxx,07:00-18:00,4.7,16500,GeoSeek,verified
|
||||
70,RS Saiful Anwar Malang,Rumah Sakit,Rumah Sakit Umum,Jl Jaksa Agung Suprapto,Klojen,Malang,Jawa Timur,-7.9720,112.6326,0341xxxxxxx,24 Jam,4.4,6200,GeoSeek,verified
|
||||
71,Malang Town Square,Mall,Pusat Perbelanjaan,Jl Veteran,Penanggungan,Malang,Jawa Timur,-7.9558,112.6178,0341xxxxxxx,10:00-22:00,4.5,23000,GeoSeek,verified
|
||||
72,Kampung Warna Warni Jodipan,Wisata,Kampung Tematik,Jodipan,Blimbing,Malang,Jawa Timur,-7.9833,112.6378,,08:00-18:00,4.4,19000,GeoSeek,verified
|
||||
73,Apotek Kimia Farma Thamrin,Apotek,Apotek Umum,Jl MH Thamrin,Menteng,Jakarta Pusat,DKI Jakarta,-6.1906,106.8237,021xxxxxxx,08:00-22:00,4.3,1200,GeoSeek,unverified
|
||||
74,Bengkel Auto Sudirman,Bengkel,Bengkel Mobil,Jl Jenderal Sudirman,Karet Tengsin,Jakarta Pusat,DKI Jakarta,-6.2136,106.8186,021xxxxxxx,08:00-17:00,4.4,640,GeoSeek,unverified
|
||||
75,Laundry Cepat Dago,Laundry,Laundry Kiloan,Jl Ir H Djuanda,Dago,Bandung,Jawa Barat,-6.8844,107.6136,022xxxxxxx,07:00-21:00,4.5,520,GeoSeek,unverified
|
||||
76,Toko Bangunan Maju Jaya,Toko Bangunan,Material Bangunan,Jl Ahmad Yani,Cicadas,Bandung,Jawa Barat,-6.9085,107.6389,022xxxxxxx,08:00-17:00,4.4,760,GeoSeek,unverified
|
||||
77,Toko Elektronik Nusantara,Toko Elektronik,Elektronik & Gadget,Jl Gajah Mada,Glodok,Jakarta Barat,DKI Jakarta,-6.1455,106.8129,021xxxxxxx,09:00-18:00,4.3,2400,GeoSeek,unverified
|
||||
78,Barbershop Urban Cut,Barbershop,Pangkas Rambut,Jl Kemang Raya,Mampang Prapatan,Jakarta Selatan,DKI Jakarta,-6.2607,106.8160,021xxxxxxx,10:00-22:00,4.6,980,GeoSeek,unverified
|
||||
79,Gym Fit Nusantara,Gym,Pusat Kebugaran,Jl HR Rasuna Said,Setiabudi,Jakarta Selatan,DKI Jakarta,-6.2196,106.8324,021xxxxxxx,06:00-22:00,4.5,1500,GeoSeek,unverified
|
||||
80,Kost Nyaman Setiabudi,Kost,Rumah Kost,Jl Setiabudi Tengah,Setiabudi,Jakarta Selatan,DKI Jakarta,-6.2149,106.8298,0812xxxxxxx,08:00-20:00,4.2,210,GeoSeek,unverified
|
||||
|
@ -1 +0,0 @@
|
||||
id,nama_tempat,kategori,subkategori,alamat,kecamatan,kota,provinsi,latitude,longitude,no_telp,jam_operasional,rating,jumlah_review,sumber_data,status_verifikasi
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import React, {useEffect, useRef} 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'
|
||||
|
||||
@ -1,642 +0,0 @@
|
||||
export type GeoSeekModuleKey =
|
||||
| 'home'
|
||||
| 'search'
|
||||
| 'map'
|
||||
| 'products'
|
||||
| 'services'
|
||||
| 'umkm'
|
||||
| 'culinary'
|
||||
| 'health'
|
||||
| 'property'
|
||||
| 'automotive'
|
||||
| 'tourism'
|
||||
| 'events'
|
||||
| 'promos'
|
||||
| 'marketplace'
|
||||
| 'booking'
|
||||
| 'courier'
|
||||
| 'business-dashboard'
|
||||
| 'profile';
|
||||
|
||||
export type GeoSeekItemType =
|
||||
| 'place'
|
||||
| 'product'
|
||||
| 'service'
|
||||
| 'business'
|
||||
| 'culinary'
|
||||
| 'health'
|
||||
| 'property'
|
||||
| 'automotive'
|
||||
| 'tourism'
|
||||
| 'event'
|
||||
| 'promo'
|
||||
| 'courier';
|
||||
|
||||
export type GeoSeekItem = {
|
||||
id: string;
|
||||
type: GeoSeekItemType;
|
||||
name: string;
|
||||
businessName: string;
|
||||
category: string;
|
||||
description: string;
|
||||
address: string;
|
||||
area: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
distanceKm: number;
|
||||
price?: number;
|
||||
stock?: number;
|
||||
rating: number;
|
||||
reviews: number;
|
||||
activityScore: number;
|
||||
tags: string[];
|
||||
open: boolean;
|
||||
promo?: string;
|
||||
etaMinutes?: number;
|
||||
bookingAvailable?: boolean;
|
||||
deliveryAvailable?: boolean;
|
||||
};
|
||||
|
||||
export type GeoSeekModule = {
|
||||
key: GeoSeekModuleKey;
|
||||
title: string;
|
||||
menuLabel: string;
|
||||
subtitle: string;
|
||||
searchPlaceholder: string;
|
||||
emptyHint: string;
|
||||
primaryType?: GeoSeekItemType;
|
||||
includeTypes: GeoSeekItemType[];
|
||||
automationTitle: string;
|
||||
automationDescription: string;
|
||||
};
|
||||
|
||||
export const geoSeekModules: GeoSeekModule[] = [
|
||||
{
|
||||
key: 'home',
|
||||
title: 'Beranda GeoSeek Pro',
|
||||
menuLabel: 'Beranda',
|
||||
subtitle: 'Pusat kendali pencarian hyperlocal, marketplace, peta, booking, kurir, dan otomasi UMKM.',
|
||||
searchPlaceholder: 'Cari produk, jasa, tempat, promo, atau kebutuhan lokal...',
|
||||
emptyHint: 'Coba cari “pupuk organik”, “tambal ban”, “nasi goreng”, atau “kurir”.',
|
||||
includeTypes: ['place', 'product', 'service', 'business', 'culinary', 'health', 'property', 'automotive', 'tourism', 'event', 'promo', 'courier'],
|
||||
automationTitle: 'Otomasi lintas menu',
|
||||
automationDescription: 'GeoSeek otomatis mengurutkan hasil dengan GeoScore, membaca stok/promo, dan menyiapkan aksi cepat seperti booking, order, rute, dan kurir.',
|
||||
},
|
||||
{
|
||||
key: 'search',
|
||||
title: 'Cari Hyperlocal',
|
||||
menuLabel: 'Cari',
|
||||
subtitle: 'Cari tempat, produk, jasa, dan promo berdasarkan radius serta skor relevansi lokal.',
|
||||
searchPlaceholder: 'Contoh: pupuk organik dekat saya, bengkel buka, promo makan siang...',
|
||||
emptyHint: 'Masukkan kata kunci untuk mencari semua data demo GeoSeek.',
|
||||
includeTypes: ['place', 'product', 'service', 'business', 'culinary', 'health', 'property', 'automotive', 'tourism', 'event', 'promo', 'courier'],
|
||||
automationTitle: 'Pencarian otomatis',
|
||||
automationDescription: 'Setiap pencarian dihitung dengan rumus GeoScore = 60% jarak + 20% relevansi + 10% rating + 10% aktivitas.',
|
||||
},
|
||||
{
|
||||
key: 'map',
|
||||
title: 'Peta & Radius',
|
||||
menuLabel: 'Peta',
|
||||
subtitle: 'Simulasi peta lokal dengan pin hasil terdekat, radius, estimasi jarak, dan rute cepat.',
|
||||
searchPlaceholder: 'Cari pin peta: UMKM, kuliner, kesehatan, wisata...',
|
||||
emptyHint: 'Peta demo akan menampilkan pin yang masuk radius pencarian.',
|
||||
includeTypes: ['place', 'business', 'culinary', 'health', 'tourism', 'event', 'promo', 'courier'],
|
||||
automationTitle: 'Pin peta otomatis',
|
||||
automationDescription: 'Pin otomatis diprioritaskan dari jarak terdekat, status buka, rating, dan aktivitas terbaru.',
|
||||
},
|
||||
{
|
||||
key: 'products',
|
||||
title: 'Produk Lokal',
|
||||
menuLabel: 'Produk',
|
||||
subtitle: 'Daftar produk UMKM dengan stok, harga, promo, dan rekomendasi order otomatis.',
|
||||
searchPlaceholder: 'Cari produk: beras, pupuk, kopi, madu, keripik...',
|
||||
emptyHint: 'Produk akan muncul dengan stok, harga, dan tombol order simulasi.',
|
||||
primaryType: 'product',
|
||||
includeTypes: ['product', 'promo'],
|
||||
automationTitle: 'Auto update stok',
|
||||
automationDescription: 'Produk menampilkan stok dan status promo. Sistem memberi rekomendasi restock saat stok mulai rendah.',
|
||||
},
|
||||
{
|
||||
key: 'services',
|
||||
title: 'Jasa Terdekat',
|
||||
menuLabel: 'Jasa',
|
||||
subtitle: 'Temukan penyedia jasa lokal yang bisa dibooking atau dipanggil ke lokasi pengguna.',
|
||||
searchPlaceholder: 'Cari jasa: servis AC, laundry, tukang, bengkel, desain...',
|
||||
emptyHint: 'Jasa tampil dengan ETA, rating, dan booking cepat.',
|
||||
primaryType: 'service',
|
||||
includeTypes: ['service'],
|
||||
automationTitle: 'Booking jasa otomatis',
|
||||
automationDescription: 'Sistem membuat draft permintaan booking, estimasi jadwal, dan prioritas penyedia terdekat.',
|
||||
},
|
||||
{
|
||||
key: 'umkm',
|
||||
title: 'Direktori UMKM',
|
||||
menuLabel: 'UMKM',
|
||||
subtitle: 'Etalase bisnis lokal lengkap dengan produk, rating, status buka, dan peluang promosi.',
|
||||
searchPlaceholder: 'Cari UMKM: warung, toko tani, pengrajin, laundry...',
|
||||
emptyHint: 'UMKM akan muncul lengkap dengan insight otomatis.',
|
||||
primaryType: 'business',
|
||||
includeTypes: ['business', 'product', 'promo'],
|
||||
automationTitle: 'Profil bisnis otomatis',
|
||||
automationDescription: 'Dari satu input singkat, GeoSeek dapat menyiapkan profil bisnis, produk, promo, dan status operasional.',
|
||||
},
|
||||
{
|
||||
key: 'culinary',
|
||||
title: 'Kuliner',
|
||||
menuLabel: 'Kuliner',
|
||||
subtitle: 'Cari makanan, minuman, promo makan, stok menu, dan jarak restoran/warung terdekat.',
|
||||
searchPlaceholder: 'Cari kuliner: nasi goreng, kopi, bakso, sarapan...',
|
||||
emptyHint: 'Kuliner tampil dengan harga, stok menu, promo, dan estimasi antar.',
|
||||
primaryType: 'culinary',
|
||||
includeTypes: ['culinary', 'promo'],
|
||||
automationTitle: 'Menu & promo otomatis',
|
||||
automationDescription: 'Sistem membaca menu, harga, stok porsi, dan promo untuk membantu order lebih cepat.',
|
||||
},
|
||||
{
|
||||
key: 'health',
|
||||
title: 'Kesehatan',
|
||||
menuLabel: 'Kesehatan',
|
||||
subtitle: 'Temukan klinik, apotek, layanan kesehatan, stok obat, dan booking antrean lokal.',
|
||||
searchPlaceholder: 'Cari kesehatan: apotek, klinik, vitamin, konsultasi...',
|
||||
emptyHint: 'Hasil kesehatan tampil dengan status buka dan booking jika tersedia.',
|
||||
primaryType: 'health',
|
||||
includeTypes: ['health', 'product', 'service'],
|
||||
automationTitle: 'Antrean & stok otomatis',
|
||||
automationDescription: 'GeoSeek menandai layanan yang bisa dibooking serta produk kesehatan yang stoknya tersedia.',
|
||||
},
|
||||
{
|
||||
key: 'property',
|
||||
title: 'Properti',
|
||||
menuLabel: 'Properti',
|
||||
subtitle: 'Cari kontrakan, kios, rumah, tanah, dan properti lokal berdasarkan radius dan kebutuhan.',
|
||||
searchPlaceholder: 'Cari properti: kontrakan, kios, ruko, tanah...',
|
||||
emptyHint: 'Properti tampil dengan harga, lokasi, dan kontak/booking survei simulasi.',
|
||||
primaryType: 'property',
|
||||
includeTypes: ['property'],
|
||||
automationTitle: 'Survey properti otomatis',
|
||||
automationDescription: 'Sistem menyiapkan draft jadwal survei, rute lokasi, dan prioritas properti paling relevan.',
|
||||
},
|
||||
{
|
||||
key: 'automotive',
|
||||
title: 'Otomotif',
|
||||
menuLabel: 'Otomotif',
|
||||
subtitle: 'Cari bengkel, tambal ban, cuci mobil, sparepart, dan bantuan kendaraan terdekat.',
|
||||
searchPlaceholder: 'Cari otomotif: tambal ban, oli, bengkel, aki...',
|
||||
emptyHint: 'Otomotif tampil dengan ETA, stok sparepart, dan status buka.',
|
||||
primaryType: 'automotive',
|
||||
includeTypes: ['automotive', 'service', 'product'],
|
||||
automationTitle: 'Bantuan kendaraan otomatis',
|
||||
automationDescription: 'GeoSeek memprioritaskan layanan terdekat yang buka dan bisa dipanggil ke lokasi.',
|
||||
},
|
||||
{
|
||||
key: 'tourism',
|
||||
title: 'Wisata',
|
||||
menuLabel: 'Wisata',
|
||||
subtitle: 'Rekomendasi wisata, aktivitas lokal, tiket, dan rute berdasarkan jarak serta rating.',
|
||||
searchPlaceholder: 'Cari wisata: pantai, taman, museum, homestay...',
|
||||
emptyHint: 'Wisata tampil dengan rating, jam buka, promo, dan rute.',
|
||||
primaryType: 'tourism',
|
||||
includeTypes: ['tourism', 'event', 'promo'],
|
||||
automationTitle: 'Itinerary otomatis',
|
||||
automationDescription: 'Sistem menyusun prioritas tempat dari jarak, rating, aktivitas, dan event terdekat.',
|
||||
},
|
||||
{
|
||||
key: 'events',
|
||||
title: 'Event Lokal',
|
||||
menuLabel: 'Event',
|
||||
subtitle: 'Temukan bazar, konser kecil, pelatihan, pasar malam, dan agenda komunitas lokal.',
|
||||
searchPlaceholder: 'Cari event: bazar, pelatihan, konser, pasar malam...',
|
||||
emptyHint: 'Event tampil dengan jadwal, lokasi, dan rekomendasi promosi.',
|
||||
primaryType: 'event',
|
||||
includeTypes: ['event', 'tourism', 'promo'],
|
||||
automationTitle: 'Promosi event otomatis',
|
||||
automationDescription: 'GeoSeek membaca lokasi dan minat pencarian untuk menaikkan event yang paling dekat dan aktif.',
|
||||
},
|
||||
{
|
||||
key: 'promos',
|
||||
title: 'Promo Lokal',
|
||||
menuLabel: 'Promo',
|
||||
subtitle: 'Kumpulan diskon, voucher, bundling, dan promo stok cepat dari bisnis sekitar.',
|
||||
searchPlaceholder: 'Cari promo: diskon makan, gratis ongkir, bundle...',
|
||||
emptyHint: 'Promo tampil dengan produk/bisnis terkait dan rekomendasi aksi cepat.',
|
||||
primaryType: 'promo',
|
||||
includeTypes: ['promo', 'product', 'culinary', 'service'],
|
||||
automationTitle: 'Promo pintar',
|
||||
automationDescription: 'Sistem menandai promo yang paling relevan berdasarkan keyword, radius, stok, dan aktivitas pengguna.',
|
||||
},
|
||||
{
|
||||
key: 'marketplace',
|
||||
title: 'Marketplace',
|
||||
menuLabel: 'Marketplace',
|
||||
subtitle: 'Simulasi belanja lokal: produk, jasa, keranjang, checkout, pembayaran, dan kurir.',
|
||||
searchPlaceholder: 'Cari item marketplace: kopi, madu, laundry, servis...',
|
||||
emptyHint: 'Marketplace menampilkan tombol tambah keranjang dan checkout simulasi.',
|
||||
includeTypes: ['product', 'service', 'culinary', 'promo'],
|
||||
automationTitle: 'Checkout otomatis',
|
||||
automationDescription: 'GeoSeek membuat simulasi keranjang, estimasi ongkir, metode bayar QRIS/e-wallet, dan rekomendasi kurir.',
|
||||
},
|
||||
{
|
||||
key: 'booking',
|
||||
title: 'Booking',
|
||||
menuLabel: 'Booking',
|
||||
subtitle: 'Pesan jasa, antrean kesehatan, survei properti, meja kuliner, atau jadwal layanan lokal.',
|
||||
searchPlaceholder: 'Cari layanan booking: klinik, servis AC, wisata, properti...',
|
||||
emptyHint: 'Hasil booking tampil dengan slot dan draft jadwal otomatis.',
|
||||
includeTypes: ['service', 'health', 'property', 'tourism', 'culinary'],
|
||||
automationTitle: 'Jadwal otomatis',
|
||||
automationDescription: 'Sistem membuat draft booking berdasarkan jarak, ETA, status buka, dan kategori kebutuhan.',
|
||||
},
|
||||
{
|
||||
key: 'courier',
|
||||
title: 'Kurir Lokal',
|
||||
menuLabel: 'Kurir',
|
||||
subtitle: 'Simulasi pengiriman lokal dengan estimasi ongkir, ETA, dan kurir terdekat.',
|
||||
searchPlaceholder: 'Cari kurir atau area pengiriman...',
|
||||
emptyHint: 'Kurir tampil dengan ETA, jarak, dan estimasi ongkir.',
|
||||
primaryType: 'courier',
|
||||
includeTypes: ['courier'],
|
||||
automationTitle: 'Dispatch kurir otomatis',
|
||||
automationDescription: 'GeoSeek memilih kurir berdasarkan jarak, aktivitas, rating, dan estimasi waktu jemput.',
|
||||
},
|
||||
{
|
||||
key: 'business-dashboard',
|
||||
title: 'Dashboard Bisnis',
|
||||
menuLabel: 'Dashboard Bisnis',
|
||||
subtitle: 'Panel UMKM untuk melihat produk, stok, promo, booking, order, dan peluang lokal otomatis.',
|
||||
searchPlaceholder: 'Cari insight bisnis: stok rendah, promo aktif, order...',
|
||||
emptyHint: 'Dashboard menampilkan insight operasional dari semua data demo.',
|
||||
includeTypes: ['business', 'product', 'service', 'culinary', 'promo'],
|
||||
automationTitle: 'Insight bisnis otomatis',
|
||||
automationDescription: 'Sistem menghitung stok rendah, peluang promo, potensi order, dan rekomendasi update profil bisnis.',
|
||||
},
|
||||
{
|
||||
key: 'profile',
|
||||
title: 'Profil GeoSeek',
|
||||
menuLabel: 'Profil',
|
||||
subtitle: 'Profil pengguna dan preferensi lokasi untuk personalisasi hasil hyperlocal.',
|
||||
searchPlaceholder: 'Cari aktivitas atau preferensi profil...',
|
||||
emptyHint: 'Profil menampilkan preferensi lokasi, kategori favorit, dan simulasi data akun.',
|
||||
includeTypes: ['business', 'product', 'service', 'promo'],
|
||||
automationTitle: 'Personalisasi otomatis',
|
||||
automationDescription: 'GeoSeek menggunakan preferensi kategori dan radius untuk menyusun rekomendasi yang lebih lokal.',
|
||||
},
|
||||
];
|
||||
|
||||
export const defaultGeoSeekLocation = {
|
||||
label: 'Alun-Alun Kota Demo',
|
||||
latitude: -7.797068,
|
||||
longitude: 110.370529,
|
||||
};
|
||||
|
||||
export const geoSeekItems: GeoSeekItem[] = [
|
||||
{
|
||||
id: 'gsk-001',
|
||||
type: 'business',
|
||||
name: 'Toko Tani Makmur',
|
||||
businessName: 'Toko Tani Makmur',
|
||||
category: 'UMKM Pertanian',
|
||||
description: 'Toko perlengkapan tani dengan pupuk organik, bibit sayur, dan konsultasi lahan kecil.',
|
||||
address: 'Jl. Pasar Tani No. 12',
|
||||
area: 'Kecamatan Tengah',
|
||||
latitude: -7.7928,
|
||||
longitude: 110.3659,
|
||||
distanceKm: 0.8,
|
||||
rating: 4.8,
|
||||
reviews: 142,
|
||||
activityScore: 94,
|
||||
tags: ['umkm', 'pupuk', 'bibit', 'pertanian', 'organik'],
|
||||
open: true,
|
||||
promo: 'Diskon 10% pupuk organik sampai Jumat',
|
||||
bookingAvailable: true,
|
||||
deliveryAvailable: true,
|
||||
},
|
||||
{
|
||||
id: 'gsk-002',
|
||||
type: 'product',
|
||||
name: 'Pupuk Organik Granul 5 kg',
|
||||
businessName: 'Toko Tani Makmur',
|
||||
category: 'Produk Pertanian',
|
||||
description: 'Pupuk organik siap pakai untuk sayur, cabai, dan tanaman pekarangan.',
|
||||
address: 'Jl. Pasar Tani No. 12',
|
||||
area: 'Kecamatan Tengah',
|
||||
latitude: -7.7928,
|
||||
longitude: 110.3659,
|
||||
distanceKm: 0.8,
|
||||
price: 38000,
|
||||
stock: 64,
|
||||
rating: 4.7,
|
||||
reviews: 88,
|
||||
activityScore: 91,
|
||||
tags: ['produk', 'pupuk', 'organik', 'tani', 'stok'],
|
||||
open: true,
|
||||
promo: 'Bundling 3 pcs gratis antar radius 2 km',
|
||||
deliveryAvailable: true,
|
||||
},
|
||||
{
|
||||
id: 'gsk-003',
|
||||
type: 'culinary',
|
||||
name: 'Nasi Goreng Rempah Sari',
|
||||
businessName: 'Warung Sari Rasa',
|
||||
category: 'Kuliner Malam',
|
||||
description: 'Nasi goreng rempah dengan topping ayam suwir dan telur, favorit warga sekitar.',
|
||||
address: 'Jl. Alun-Alun Timur No. 3',
|
||||
area: 'Pusat Kota',
|
||||
latitude: -7.7977,
|
||||
longitude: 110.371,
|
||||
distanceKm: 0.2,
|
||||
price: 15000,
|
||||
stock: 42,
|
||||
rating: 4.9,
|
||||
reviews: 321,
|
||||
activityScore: 98,
|
||||
tags: ['kuliner', 'nasi goreng', 'makan malam', 'promo', 'antar'],
|
||||
open: true,
|
||||
promo: 'Gratis es teh untuk order sebelum 20.00',
|
||||
etaMinutes: 14,
|
||||
bookingAvailable: true,
|
||||
deliveryAvailable: true,
|
||||
},
|
||||
{
|
||||
id: 'gsk-004',
|
||||
type: 'service',
|
||||
name: 'Servis AC Panggilan Cepat',
|
||||
businessName: 'Jaya Teknik Home Service',
|
||||
category: 'Jasa Rumah',
|
||||
description: 'Cuci AC, isi freon, perbaikan bocor, dan perawatan berkala dengan teknisi terverifikasi.',
|
||||
address: 'Jl. Melati Selatan No. 8',
|
||||
area: 'Kelurahan Melati',
|
||||
latitude: -7.8032,
|
||||
longitude: 110.3748,
|
||||
distanceKm: 1.1,
|
||||
price: 85000,
|
||||
rating: 4.6,
|
||||
reviews: 176,
|
||||
activityScore: 86,
|
||||
tags: ['jasa', 'servis ac', 'home service', 'booking', 'teknisi'],
|
||||
open: true,
|
||||
etaMinutes: 35,
|
||||
bookingAvailable: true,
|
||||
deliveryAvailable: false,
|
||||
},
|
||||
{
|
||||
id: 'gsk-005',
|
||||
type: 'health',
|
||||
name: 'Apotek Sehat 24',
|
||||
businessName: 'Apotek Sehat 24',
|
||||
category: 'Apotek & Kesehatan',
|
||||
description: 'Apotek dengan vitamin, obat umum, alat kesehatan, dan konsultasi ringan.',
|
||||
address: 'Jl. Kesehatan Raya No. 21',
|
||||
area: 'Kecamatan Utara',
|
||||
latitude: -7.7899,
|
||||
longitude: 110.3729,
|
||||
distanceKm: 1.4,
|
||||
price: 25000,
|
||||
stock: 120,
|
||||
rating: 4.7,
|
||||
reviews: 210,
|
||||
activityScore: 89,
|
||||
tags: ['kesehatan', 'apotek', 'vitamin', 'obat', '24 jam'],
|
||||
open: true,
|
||||
promo: 'Paket vitamin keluarga hemat 15%',
|
||||
etaMinutes: 20,
|
||||
bookingAvailable: true,
|
||||
deliveryAvailable: true,
|
||||
},
|
||||
{
|
||||
id: 'gsk-006',
|
||||
type: 'automotive',
|
||||
name: 'Tambal Ban Siaga',
|
||||
businessName: 'Bengkel Siaga Motor',
|
||||
category: 'Otomotif Darurat',
|
||||
description: 'Tambal ban, ganti oli, aki soak, dan bantuan motor mogok di area kota.',
|
||||
address: 'Jl. Ring Road Barat KM 2',
|
||||
area: 'Kecamatan Barat',
|
||||
latitude: -7.7985,
|
||||
longitude: 110.3552,
|
||||
distanceKm: 1.9,
|
||||
price: 20000,
|
||||
stock: 32,
|
||||
rating: 4.5,
|
||||
reviews: 119,
|
||||
activityScore: 83,
|
||||
tags: ['otomotif', 'tambal ban', 'bengkel', 'oli', 'darurat'],
|
||||
open: true,
|
||||
etaMinutes: 18,
|
||||
bookingAvailable: true,
|
||||
deliveryAvailable: false,
|
||||
},
|
||||
{
|
||||
id: 'gsk-007',
|
||||
type: 'property',
|
||||
name: 'Kios Strategis Dekat Pasar',
|
||||
businessName: 'Properti Lokal Sentosa',
|
||||
category: 'Sewa Kios',
|
||||
description: 'Kios ukuran 3x4 meter dekat pasar pagi, cocok untuk kuliner, sayur, atau grosir kecil.',
|
||||
address: 'Kompleks Pasar Pagi Blok B-7',
|
||||
area: 'Kecamatan Tengah',
|
||||
latitude: -7.7945,
|
||||
longitude: 110.368,
|
||||
distanceKm: 0.6,
|
||||
price: 1800000,
|
||||
rating: 4.4,
|
||||
reviews: 36,
|
||||
activityScore: 76,
|
||||
tags: ['properti', 'kios', 'sewa', 'pasar', 'umkm'],
|
||||
open: true,
|
||||
bookingAvailable: true,
|
||||
deliveryAvailable: false,
|
||||
},
|
||||
{
|
||||
id: 'gsk-008',
|
||||
type: 'tourism',
|
||||
name: 'Kampung Heritage Kali Biru',
|
||||
businessName: 'Pokdarwis Kali Biru',
|
||||
category: 'Wisata Edukasi',
|
||||
description: 'Wisata kampung, spot foto, kuliner lokal, dan tur sejarah bersama warga.',
|
||||
address: 'Kampung Kali Biru RT 04',
|
||||
area: 'Kecamatan Timur',
|
||||
latitude: -7.8014,
|
||||
longitude: 110.3832,
|
||||
distanceKm: 2.2,
|
||||
price: 10000,
|
||||
rating: 4.8,
|
||||
reviews: 264,
|
||||
activityScore: 87,
|
||||
tags: ['wisata', 'heritage', 'foto', 'kuliner', 'event'],
|
||||
open: true,
|
||||
promo: 'Paket tur keluarga akhir pekan',
|
||||
bookingAvailable: true,
|
||||
},
|
||||
{
|
||||
id: 'gsk-009',
|
||||
type: 'event',
|
||||
name: 'Bazar UMKM Jumat Ceria',
|
||||
businessName: 'Komunitas UMKM Kota',
|
||||
category: 'Event & Bazar',
|
||||
description: 'Bazar produk lokal, kuliner malam, live music akustik, dan demo produk warga.',
|
||||
address: 'Lapangan Alun-Alun Barat',
|
||||
area: 'Pusat Kota',
|
||||
latitude: -7.7963,
|
||||
longitude: 110.3691,
|
||||
distanceKm: 0.3,
|
||||
rating: 4.7,
|
||||
reviews: 95,
|
||||
activityScore: 96,
|
||||
tags: ['event', 'bazar', 'umkm', 'kuliner', 'promo'],
|
||||
open: true,
|
||||
promo: 'Booth gratis untuk 10 UMKM baru',
|
||||
bookingAvailable: true,
|
||||
},
|
||||
{
|
||||
id: 'gsk-010',
|
||||
type: 'promo',
|
||||
name: 'Gratis Ongkir Radius 3 km',
|
||||
businessName: 'GeoKurir Lokal',
|
||||
category: 'Promo Pengiriman',
|
||||
description: 'Promo gratis ongkir untuk order produk dan kuliner lokal dengan minimum transaksi tertentu.',
|
||||
address: 'Hub Kurir Alun-Alun',
|
||||
area: 'Pusat Kota',
|
||||
latitude: -7.797,
|
||||
longitude: 110.37,
|
||||
distanceKm: 0.1,
|
||||
rating: 4.6,
|
||||
reviews: 73,
|
||||
activityScore: 93,
|
||||
tags: ['promo', 'gratis ongkir', 'kurir', 'marketplace', 'order'],
|
||||
open: true,
|
||||
promo: 'Gratis ongkir min. Rp50.000',
|
||||
etaMinutes: 12,
|
||||
deliveryAvailable: true,
|
||||
},
|
||||
{
|
||||
id: 'gsk-011',
|
||||
type: 'courier',
|
||||
name: 'GeoKurir Motor 01',
|
||||
businessName: 'GeoKurir Lokal',
|
||||
category: 'Kurir Instan',
|
||||
description: 'Kurir motor lokal untuk makanan, dokumen, belanja pasar, dan paket kecil.',
|
||||
address: 'Hub Kurir Alun-Alun',
|
||||
area: 'Pusat Kota',
|
||||
latitude: -7.797,
|
||||
longitude: 110.37,
|
||||
distanceKm: 0.1,
|
||||
price: 9000,
|
||||
rating: 4.8,
|
||||
reviews: 184,
|
||||
activityScore: 97,
|
||||
tags: ['kurir', 'pengiriman', 'instant', 'antar', 'pickup'],
|
||||
open: true,
|
||||
etaMinutes: 9,
|
||||
bookingAvailable: true,
|
||||
deliveryAvailable: true,
|
||||
},
|
||||
{
|
||||
id: 'gsk-012',
|
||||
type: 'product',
|
||||
name: 'Kopi Robusta Lereng 250 gr',
|
||||
businessName: 'Roastery Bukit Sari',
|
||||
category: 'Produk Minuman',
|
||||
description: 'Kopi robusta lokal sangrai medium, cocok untuk manual brew dan tubruk.',
|
||||
address: 'Jl. Bukit Sari No. 5',
|
||||
area: 'Kecamatan Utara',
|
||||
latitude: -7.7872,
|
||||
longitude: 110.3664,
|
||||
distanceKm: 1.7,
|
||||
price: 47000,
|
||||
stock: 18,
|
||||
rating: 4.9,
|
||||
reviews: 205,
|
||||
activityScore: 88,
|
||||
tags: ['produk', 'kopi', 'robusta', 'umkm', 'minuman'],
|
||||
open: true,
|
||||
promo: 'Beli 2 gratis drip bag',
|
||||
deliveryAvailable: true,
|
||||
},
|
||||
{
|
||||
id: 'gsk-013',
|
||||
type: 'service',
|
||||
name: 'Laundry Express 6 Jam',
|
||||
businessName: 'Bersih Kilat Laundry',
|
||||
category: 'Jasa Harian',
|
||||
description: 'Laundry kiloan, sepatu, helm, dan antar jemput cucian untuk area pusat kota.',
|
||||
address: 'Jl. Kenanga No. 14',
|
||||
area: 'Kelurahan Kenanga',
|
||||
latitude: -7.8062,
|
||||
longitude: 110.3693,
|
||||
distanceKm: 1.0,
|
||||
price: 7000,
|
||||
rating: 4.4,
|
||||
reviews: 82,
|
||||
activityScore: 81,
|
||||
tags: ['jasa', 'laundry', 'express', 'antar jemput', 'booking'],
|
||||
open: true,
|
||||
promo: 'Diskon 20% pelanggan baru',
|
||||
etaMinutes: 24,
|
||||
bookingAvailable: true,
|
||||
deliveryAvailable: true,
|
||||
},
|
||||
{
|
||||
id: 'gsk-014',
|
||||
type: 'place',
|
||||
name: 'Co-Working Nusa Kreatif',
|
||||
businessName: 'Nusa Kreatif Space',
|
||||
category: 'Tempat Produktif',
|
||||
description: 'Ruang kerja bersama, meeting room, kelas UMKM digital, dan internet cepat.',
|
||||
address: 'Jl. Kreatif No. 2',
|
||||
area: 'Pusat Kota',
|
||||
latitude: -7.7956,
|
||||
longitude: 110.3734,
|
||||
distanceKm: 0.5,
|
||||
price: 25000,
|
||||
rating: 4.7,
|
||||
reviews: 136,
|
||||
activityScore: 84,
|
||||
tags: ['tempat', 'coworking', 'event', 'meeting', 'umkm'],
|
||||
open: true,
|
||||
bookingAvailable: true,
|
||||
},
|
||||
{
|
||||
id: 'gsk-015',
|
||||
type: 'health',
|
||||
name: 'Klinik Keluarga Melati',
|
||||
businessName: 'Klinik Keluarga Melati',
|
||||
category: 'Klinik Umum',
|
||||
description: 'Konsultasi dokter umum, cek gula darah, vaksin, dan antrean online.',
|
||||
address: 'Jl. Melati Utara No. 16',
|
||||
area: 'Kelurahan Melati',
|
||||
latitude: -7.8022,
|
||||
longitude: 110.3794,
|
||||
distanceKm: 1.5,
|
||||
price: 50000,
|
||||
rating: 4.6,
|
||||
reviews: 157,
|
||||
activityScore: 82,
|
||||
tags: ['kesehatan', 'klinik', 'dokter', 'antrean', 'booking'],
|
||||
open: true,
|
||||
etaMinutes: 28,
|
||||
bookingAvailable: true,
|
||||
},
|
||||
{
|
||||
id: 'gsk-016',
|
||||
type: 'promo',
|
||||
name: 'Paket Sarapan Hemat',
|
||||
businessName: 'Dapur Pagi Bu Rini',
|
||||
category: 'Promo Kuliner',
|
||||
description: 'Paket nasi kuning, teh hangat, dan gorengan untuk sarapan kantor atau sekolah.',
|
||||
address: 'Jl. Sekolah No. 9',
|
||||
area: 'Kecamatan Selatan',
|
||||
latitude: -7.812,
|
||||
longitude: 110.3718,
|
||||
distanceKm: 1.9,
|
||||
price: 12000,
|
||||
stock: 35,
|
||||
rating: 4.5,
|
||||
reviews: 74,
|
||||
activityScore: 85,
|
||||
tags: ['promo', 'kuliner', 'sarapan', 'nasi kuning', 'antar'],
|
||||
open: true,
|
||||
promo: 'Pesan 5 paket gratis 1',
|
||||
etaMinutes: 22,
|
||||
deliveryAvailable: true,
|
||||
},
|
||||
];
|
||||
@ -1,383 +0,0 @@
|
||||
import { GeoSeekItem, GeoSeekItemType, geoSeekItems } from './geoseek';
|
||||
|
||||
export type GeoSeekDistancePriority = {
|
||||
rank: number;
|
||||
label: string;
|
||||
range: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type GeoSeekScoredItem = GeoSeekItem & {
|
||||
geoScore: number;
|
||||
relevanceScore: number;
|
||||
distanceScore: number;
|
||||
distancePriority: GeoSeekDistancePriority;
|
||||
ratingScore: number;
|
||||
activityContribution: number;
|
||||
automationNotes: string[];
|
||||
};
|
||||
|
||||
export type GeoSeekSmartDraft = {
|
||||
businessName: string;
|
||||
itemName: string;
|
||||
category: string;
|
||||
price?: number;
|
||||
stock?: number;
|
||||
location: string;
|
||||
promo: string;
|
||||
status: string;
|
||||
tags: string[];
|
||||
nextActions: string[];
|
||||
};
|
||||
|
||||
export type GeoSeekCartItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
businessName: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
};
|
||||
|
||||
const normalize = (value: string) => value.toLowerCase().trim();
|
||||
|
||||
const distancePriorityTiers: Array<GeoSeekDistancePriority & { maxKm: number }> = [
|
||||
{
|
||||
rank: 0,
|
||||
maxKm: 1,
|
||||
label: 'Sangat dekat (jalan kaki)',
|
||||
range: '0–1 km',
|
||||
description: 'Prioritas tertinggi untuk kebutuhan yang bisa ditempuh dengan jalan kaki.',
|
||||
},
|
||||
{
|
||||
rank: 1,
|
||||
maxKm: 5,
|
||||
label: 'Dekat',
|
||||
range: '1–5 km',
|
||||
description: 'Masih dekat dari lokasi aktif dan cocok untuk kebutuhan harian sekitar.',
|
||||
},
|
||||
{
|
||||
rank: 2,
|
||||
maxKm: 10,
|
||||
label: 'Agak dekat',
|
||||
range: '5–10 km',
|
||||
description: 'Masih mudah dijangkau, biasanya perlu kendaraan singkat.',
|
||||
},
|
||||
{
|
||||
rank: 3,
|
||||
maxKm: 20,
|
||||
label: 'Sedang',
|
||||
range: '10–20 km',
|
||||
description: 'Jarak menengah; ditampilkan setelah hasil yang lebih dekat.',
|
||||
},
|
||||
{
|
||||
rank: 4,
|
||||
maxKm: Number.POSITIVE_INFINITY,
|
||||
label: 'Jauh',
|
||||
range: '20+ km',
|
||||
description: 'Di luar prioritas jarak utama dan hanya muncul jika radius diperluas.',
|
||||
},
|
||||
];
|
||||
|
||||
export const getDistancePriority = (distanceKm?: number | null): GeoSeekDistancePriority => {
|
||||
const normalizedDistance =
|
||||
typeof distanceKm === 'number' && Number.isFinite(distanceKm)
|
||||
? Math.max(distanceKm, 0)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
|
||||
return distancePriorityTiers.find((tier) => normalizedDistance <= tier.maxKm) || distancePriorityTiers[distancePriorityTiers.length - 1];
|
||||
};
|
||||
|
||||
export const currency = (value?: number) => {
|
||||
if (typeof value !== 'number') return 'Hubungi penjual';
|
||||
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
style: 'currency',
|
||||
currency: 'IDR',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
export const getGeoSeekModule = (module?: string | string[]) => {
|
||||
const key = Array.isArray(module) ? module[0] : module;
|
||||
|
||||
return key || 'home';
|
||||
};
|
||||
|
||||
export const calculateRelevanceScore = (item: GeoSeekItem, query: string) => {
|
||||
const normalizedQuery = normalize(query);
|
||||
if (!normalizedQuery) return 72;
|
||||
|
||||
const searchable = normalize([
|
||||
item.name,
|
||||
item.businessName,
|
||||
item.category,
|
||||
item.description,
|
||||
item.area,
|
||||
item.tags.join(' '),
|
||||
item.promo || '',
|
||||
].join(' '));
|
||||
|
||||
const words = normalizedQuery.split(/\s+/).filter(Boolean);
|
||||
if (!words.length) return 72;
|
||||
|
||||
const matched = words.filter((word) => searchable.includes(word)).length;
|
||||
const exactBonus = searchable.includes(normalizedQuery) ? 18 : 0;
|
||||
|
||||
return Math.min(100, Math.round((matched / words.length) * 82 + exactBonus));
|
||||
};
|
||||
|
||||
export const calculateGeoScore = (item: GeoSeekItem, query: string) => {
|
||||
const maxRadius = 20;
|
||||
const distanceScore = Math.max(0, Math.round((1 - Math.min(item.distanceKm, maxRadius) / maxRadius) * 100));
|
||||
const relevanceScore = calculateRelevanceScore(item, query);
|
||||
const ratingScore = Math.round((item.rating / 5) * 100);
|
||||
const activityContribution = item.activityScore;
|
||||
const geoScore = Math.round(
|
||||
distanceScore * 0.6 + relevanceScore * 0.2 + ratingScore * 0.1 + activityContribution * 0.1,
|
||||
);
|
||||
|
||||
return {
|
||||
geoScore,
|
||||
relevanceScore,
|
||||
distanceScore,
|
||||
ratingScore,
|
||||
activityContribution,
|
||||
};
|
||||
};
|
||||
|
||||
export const getAutomationNotes = (item: GeoSeekItem, moduleKey: string) => {
|
||||
const notes: string[] = [];
|
||||
|
||||
if (item.open) notes.push('Buka sekarang');
|
||||
if (typeof item.stock === 'number' && item.stock <= 20) notes.push('Stok perlu dipantau/restock');
|
||||
if (typeof item.stock === 'number' && item.stock > 20) notes.push('Stok aman untuk order cepat');
|
||||
if (item.promo) notes.push('Promo aktif terdeteksi');
|
||||
if (item.bookingAvailable) notes.push('Bisa dibuat booking otomatis');
|
||||
if (item.deliveryAvailable) notes.push('Mendukung kurir/pengiriman lokal');
|
||||
if (item.etaMinutes) notes.push(`Estimasi respons ${item.etaMinutes} menit`);
|
||||
|
||||
if (moduleKey === 'marketplace') notes.push('Siap masuk keranjang dan checkout simulasi');
|
||||
if (moduleKey === 'map') notes.push('Pin prioritas peta berdasarkan radius');
|
||||
if (moduleKey === 'business-dashboard') notes.push('Masuk insight dashboard bisnis');
|
||||
|
||||
return notes.slice(0, 4);
|
||||
};
|
||||
|
||||
export const filterGeoSeekItems = ({
|
||||
query,
|
||||
radiusKm,
|
||||
includeTypes,
|
||||
moduleKey,
|
||||
extraItems = [],
|
||||
baseItems = geoSeekItems,
|
||||
}: {
|
||||
query: string;
|
||||
radiusKm: number;
|
||||
includeTypes: GeoSeekItemType[];
|
||||
moduleKey: string;
|
||||
extraItems?: GeoSeekItem[];
|
||||
baseItems?: GeoSeekItem[];
|
||||
}): GeoSeekScoredItem[] => {
|
||||
const normalizedQuery = normalize(query);
|
||||
const sourceItems = [...extraItems, ...baseItems];
|
||||
|
||||
return sourceItems
|
||||
.filter((item) => includeTypes.includes(item.type))
|
||||
.filter((item) => item.distanceKm <= radiusKm)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
...calculateGeoScore(item, normalizedQuery),
|
||||
distancePriority: getDistancePriority(item.distanceKm),
|
||||
automationNotes: getAutomationNotes(item, moduleKey),
|
||||
}))
|
||||
.filter((item) => {
|
||||
if (!normalizedQuery) return true;
|
||||
|
||||
const searchable = normalize([
|
||||
item.name,
|
||||
item.businessName,
|
||||
item.category,
|
||||
item.description,
|
||||
item.area,
|
||||
item.tags.join(' '),
|
||||
item.promo || '',
|
||||
].join(' '));
|
||||
|
||||
return normalizedQuery.split(/\s+/).some((word) => searchable.includes(word));
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const priorityDiff = a.distancePriority.rank - b.distancePriority.rank;
|
||||
if (priorityDiff !== 0) return priorityDiff;
|
||||
|
||||
const distanceDiff = a.distanceKm - b.distanceKm;
|
||||
if (Math.abs(distanceDiff) > 0.05) return distanceDiff;
|
||||
|
||||
const geoScoreDiff = b.geoScore - a.geoScore;
|
||||
if (geoScoreDiff !== 0) return geoScoreDiff;
|
||||
|
||||
return b.relevanceScore - a.relevanceScore;
|
||||
});
|
||||
};
|
||||
|
||||
const getFirstCurrencyValue = (text: string) => {
|
||||
const match = text.match(/(?:rp\s*)?([0-9][0-9.]{2,})(?:\s*(?:rb|ribu))?/i);
|
||||
if (!match) return undefined;
|
||||
|
||||
const raw = match[1].replace(/\./g, '');
|
||||
const number = Number(raw);
|
||||
if (Number.isNaN(number)) return undefined;
|
||||
|
||||
if (/rb|ribu/i.test(match[0]) && number < 1000) return number * 1000;
|
||||
|
||||
return number;
|
||||
};
|
||||
|
||||
const getStockValue = (text: string) => {
|
||||
const match = text.match(/(?:stok|stock|tersedia|ready)\s*(\d+)/i);
|
||||
if (!match) return undefined;
|
||||
|
||||
const number = Number(match[1]);
|
||||
return Number.isNaN(number) ? undefined : number;
|
||||
};
|
||||
|
||||
const guessCategory = (text: string) => {
|
||||
const normalized = normalize(text);
|
||||
|
||||
if (/nasi|kopi|bakso|mie|ayam|sarapan|kuliner|warung/.test(normalized)) return 'Kuliner';
|
||||
if (/pupuk|bibit|tani|sayur|organik/.test(normalized)) return 'Produk Pertanian';
|
||||
if (/ac|laundry|servis|jasa|teknisi|booking/.test(normalized)) return 'Jasa';
|
||||
if (/apotek|klinik|obat|vitamin|dokter/.test(normalized)) return 'Kesehatan';
|
||||
if (/ban|bengkel|oli|motor|mobil|aki/.test(normalized)) return 'Otomotif';
|
||||
if (/kios|rumah|kontrakan|tanah|ruko/.test(normalized)) return 'Properti';
|
||||
if (/wisata|tiket|tour|event|bazar/.test(normalized)) return 'Wisata & Event';
|
||||
if (/kurir|ongkir|antar|kirim|pickup/.test(normalized)) return 'Kurir';
|
||||
|
||||
return 'UMKM Lokal';
|
||||
};
|
||||
|
||||
const guessBusinessName = (text: string) => {
|
||||
const patterns = [
|
||||
/(?:umkm|toko|warung|kedai|apotek|bengkel|klinik|laundry|roastery)\s+([a-z0-9\s]{2,28})/i,
|
||||
/([a-z0-9\s]{2,28})\s+(?:buka|promo|menjual|ready|stok)/i,
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = text.match(pattern);
|
||||
if (match?.[0]) {
|
||||
return match[0]
|
||||
.replace(/\b(buka|promo|menjual|ready|stok)\b/gi, '')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
}
|
||||
|
||||
return 'Bisnis Lokal Baru';
|
||||
};
|
||||
|
||||
const guessItemName = (text: string) => {
|
||||
const normalized = text.replace(/\s+/g, ' ').trim();
|
||||
const productMatch = normalized.match(/(?:jual|menjual|promo|ready|stok)\s+([^,.]{3,42})/i);
|
||||
|
||||
if (productMatch?.[1]) {
|
||||
return productMatch[1]
|
||||
.replace(/\b(rp\s*[0-9.]+|stok\s*\d+|dekat\s+.+)$/i, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
return normalized.split(/[,.]/)[0].slice(0, 48) || 'Produk/Jasa Baru';
|
||||
};
|
||||
|
||||
const guessLocation = (text: string) => {
|
||||
const match = text.match(/(?:dekat|di|area|lokasi)\s+([^,.]{3,36})/i);
|
||||
|
||||
return match?.[1]?.trim() || 'Lokasi pengguna saat ini';
|
||||
};
|
||||
|
||||
export const createSmartDraft = (input: string): GeoSeekSmartDraft => {
|
||||
const cleaned = input.trim();
|
||||
const category = guessCategory(cleaned);
|
||||
const stock = getStockValue(cleaned);
|
||||
const price = getFirstCurrencyValue(cleaned);
|
||||
const tags = Array.from(
|
||||
new Set(
|
||||
cleaned
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s]/gi, ' ')
|
||||
.split(/\s+/)
|
||||
.filter((word) => word.length > 3)
|
||||
.slice(0, 8),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
businessName: guessBusinessName(cleaned),
|
||||
itemName: guessItemName(cleaned),
|
||||
category,
|
||||
price,
|
||||
stock,
|
||||
location: guessLocation(cleaned),
|
||||
promo: /promo|diskon|gratis|voucher|hemat/i.test(cleaned) ? 'Promo aktif dari input pengguna' : 'Belum ada promo khusus',
|
||||
status: /tutup|habis/i.test(cleaned) ? 'Perlu review manual' : 'Draft siap dipublikasikan',
|
||||
tags: tags.length ? tags : ['lokal', 'geoseek', 'umkm'],
|
||||
nextActions: [
|
||||
'Validasi lokasi dan radius bisnis',
|
||||
'Publikasikan ke menu terkait',
|
||||
'Buat notifikasi promo untuk warga sekitar',
|
||||
'Siapkan opsi booking/order/kurir jika relevan',
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
export const getBusinessInsights = (items: GeoSeekScoredItem[]) => {
|
||||
const products = items.filter((item) => item.type === 'product' || item.type === 'culinary');
|
||||
const services = items.filter((item) => item.type === 'service');
|
||||
const promos = items.filter((item) => Boolean(item.promo) || item.type === 'promo');
|
||||
const lowStock = products.filter((item) => typeof item.stock === 'number' && item.stock <= 20);
|
||||
const bookingReady = items.filter((item) => item.bookingAvailable);
|
||||
const deliveryReady = items.filter((item) => item.deliveryAvailable);
|
||||
|
||||
return {
|
||||
products: products.length,
|
||||
services: services.length,
|
||||
promos: promos.length,
|
||||
lowStock: lowStock.length,
|
||||
bookingReady: bookingReady.length,
|
||||
deliveryReady: deliveryReady.length,
|
||||
averageGeoScore: items.length ? Math.round(items.reduce((sum, item) => sum + item.geoScore, 0) / items.length) : 0,
|
||||
recommendations: [
|
||||
lowStock.length ? `${lowStock.length} item perlu restock hari ini.` : 'Stok utama masih aman.',
|
||||
promos.length ? `${promos.length} promo aktif siap didorong ke warga sekitar.` : 'Buat promo baru untuk menaikkan visibilitas.',
|
||||
deliveryReady.length ? 'Pengiriman lokal siap dipakai untuk checkout.' : 'Aktifkan kurir lokal untuk mempercepat transaksi.',
|
||||
bookingReady.length ? 'Booking otomatis tersedia untuk beberapa layanan.' : 'Tambahkan slot booking untuk jasa/layanan.',
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
export const createCartFromResults = (items: GeoSeekScoredItem[]): GeoSeekCartItem[] => items
|
||||
.filter((item) => typeof item.price === 'number')
|
||||
.slice(0, 3)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
businessName: item.businessName,
|
||||
price: item.price || 0,
|
||||
quantity: item.type === 'culinary' ? 2 : 1,
|
||||
}));
|
||||
|
||||
export const getCheckoutSummary = (cartItems: GeoSeekCartItem[]) => {
|
||||
const subtotal = cartItems.reduce((sum, item) => sum + item.price * item.quantity, 0);
|
||||
const deliveryFee = cartItems.length ? 9000 : 0;
|
||||
const platformFee = cartItems.length ? 1500 : 0;
|
||||
const discount = subtotal >= 50000 ? 9000 : 0;
|
||||
const total = Math.max(0, subtotal + deliveryFee + platformFee - discount);
|
||||
|
||||
return {
|
||||
subtotal,
|
||||
deliveryFee,
|
||||
platformFee,
|
||||
discount,
|
||||
total,
|
||||
paymentMethods: ['QRIS', 'Transfer Bank', 'E-Wallet'],
|
||||
status: cartItems.length ? 'Checkout simulasi siap diproses' : 'Tambahkan item untuk checkout',
|
||||
};
|
||||
};
|
||||
@ -1,4 +1,5 @@
|
||||
import React, { ReactNode, useEffect, useState } from 'react'
|
||||
import React, { ReactNode, useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||
import menuAside from '../menuAside'
|
||||
|
||||
@ -7,30 +7,6 @@ const menuAside: MenuAsideItem[] = [
|
||||
icon: icon.mdiViewDashboardOutline,
|
||||
label: 'Dashboard',
|
||||
},
|
||||
{
|
||||
label: 'GeoSeek Pro',
|
||||
icon: icon.mdiMapSearchOutline,
|
||||
menu: [
|
||||
{ href: '/geoseek', label: 'Beranda', icon: icon.mdiViewDashboardOutline },
|
||||
{ href: '/geoseek/search', label: 'Cari', icon: icon.mdiMagnify },
|
||||
{ href: '/geoseek/map', label: 'Peta', icon: icon.mdiMapMarker },
|
||||
{ href: '/geoseek/products', label: 'Produk', icon: icon.mdiShoppingOutline },
|
||||
{ href: '/geoseek/services', label: 'Jasa', icon: icon.mdiBriefcaseSearchOutline },
|
||||
{ href: '/geoseek/umkm', label: 'UMKM', icon: icon.mdiStore },
|
||||
{ href: '/geoseek/culinary', label: 'Kuliner', icon: icon.mdiFoodForkDrink },
|
||||
{ href: '/geoseek/health', label: 'Kesehatan', icon: icon.mdiHospitalBoxOutline },
|
||||
{ href: '/geoseek/property', label: 'Properti', icon: icon.mdiHomeCityOutline },
|
||||
{ href: '/geoseek/automotive', label: 'Otomotif', icon: icon.mdiCarWrench },
|
||||
{ href: '/geoseek/tourism', label: 'Wisata', icon: icon.mdiBeach },
|
||||
{ href: '/geoseek/events', label: 'Event', icon: icon.mdiCalendar },
|
||||
{ href: '/geoseek/promos', label: 'Promo', icon: icon.mdiTicketPercentOutline },
|
||||
{ href: '/geoseek/marketplace', label: 'Marketplace', icon: icon.mdiCart },
|
||||
{ href: '/geoseek/booking', label: 'Booking', icon: icon.mdiCalendar },
|
||||
{ href: '/geoseek/courier', label: 'Kurir', icon: icon.mdiTruck },
|
||||
{ href: '/geoseek/business-dashboard', label: 'Dashboard Bisnis', icon: icon.mdiFinance },
|
||||
{ href: '/geoseek/profile', label: 'Profil', icon: icon.mdiAccountCircle },
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
href: '/users/users-list',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,18 +0,0 @@
|
||||
import React from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import GeoSeekProWorkspace from '../../components/GeoSeek/GeoSeekProWorkspace';
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||
|
||||
const GeoSeekModulePage = () => {
|
||||
const router = useRouter();
|
||||
const moduleKey = Array.isArray(router.query.module) ? router.query.module[0] : router.query.module;
|
||||
|
||||
return <GeoSeekProWorkspace moduleKey={moduleKey || 'home'} />;
|
||||
};
|
||||
|
||||
GeoSeekModulePage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||
};
|
||||
|
||||
export default GeoSeekModulePage;
|
||||
@ -1,12 +0,0 @@
|
||||
import React from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import GeoSeekProWorkspace from '../../components/GeoSeek/GeoSeekProWorkspace';
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||
|
||||
const GeoSeekHomePage = () => <GeoSeekProWorkspace moduleKey="home" />;
|
||||
|
||||
GeoSeekHomePage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||
};
|
||||
|
||||
export default GeoSeekHomePage;
|
||||
File diff suppressed because it is too large
Load Diff
@ -16,14 +16,7 @@ import BaseDivider from '../components/BaseDivider';
|
||||
import BaseButtons from '../components/BaseButtons';
|
||||
import { useRouter } from 'next/router';
|
||||
import { getPageTitle } from '../config';
|
||||
import {
|
||||
findMe,
|
||||
loginUser,
|
||||
requestEmailOtp,
|
||||
resetAction,
|
||||
setAuthToken,
|
||||
verifyEmailOtp,
|
||||
} from '../stores/authSlice';
|
||||
import { findMe, loginUser, resetAction } from '../stores/authSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||
import Link from 'next/link';
|
||||
import {toast, ToastContainer} from "react-toastify";
|
||||
@ -44,17 +37,7 @@ export default function Login() {
|
||||
const [contentType, setContentType] = useState('video');
|
||||
const [contentPosition, setContentPosition] = useState('right');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [otpEmail, setOtpEmail] = useState('');
|
||||
const [otpCode, setOtpCode] = useState('');
|
||||
const [otpRequested, setOtpRequested] = useState(false);
|
||||
const {
|
||||
currentUser,
|
||||
isFetching,
|
||||
isRequestingOtp,
|
||||
errorMessage,
|
||||
token,
|
||||
notify:notifyState,
|
||||
} = useAppSelector(
|
||||
const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector(
|
||||
(state) => state.auth,
|
||||
);
|
||||
const [initialValues, setInitialValues] = React.useState({ email:'admin@flatlogic.com',
|
||||
@ -73,16 +56,6 @@ export default function Login() {
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
// Handle Google OAuth callback token
|
||||
useEffect(() => {
|
||||
const redirectToken = router.query.token;
|
||||
|
||||
if (typeof redirectToken === 'string') {
|
||||
dispatch(setAuthToken(redirectToken));
|
||||
router.replace('/login', undefined, { shallow: true });
|
||||
}
|
||||
}, [dispatch, router, router.query.token]);
|
||||
|
||||
// Fetch user data
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
@ -119,52 +92,6 @@ export default function Login() {
|
||||
await dispatch(loginUser(rest));
|
||||
};
|
||||
|
||||
const handleGoogleSignin = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = `/api/auth/signin/google?app=${encodeURIComponent(window.location.origin)}`;
|
||||
};
|
||||
|
||||
const handleRequestOtp = async () => {
|
||||
const email = otpEmail.trim();
|
||||
|
||||
if (!email) {
|
||||
notify('error', 'Masukkan email terlebih dahulu');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await dispatch(requestEmailOtp({ email }));
|
||||
|
||||
if (requestEmailOtp.fulfilled.match(result)) {
|
||||
setOtpRequested(true);
|
||||
notify('success', 'Kode OTP sudah dikirim ke email Anda');
|
||||
return;
|
||||
}
|
||||
|
||||
notify('error', String(result.payload || 'Gagal mengirim OTP'));
|
||||
};
|
||||
|
||||
const handleVerifyOtp = async () => {
|
||||
const email = otpEmail.trim();
|
||||
const otp = otpCode.trim();
|
||||
|
||||
if (!email || !otp) {
|
||||
notify('error', 'Masukkan email dan kode OTP');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await dispatch(verifyEmailOtp({ email, otp }));
|
||||
|
||||
if (verifyEmailOtp.fulfilled.match(result)) {
|
||||
notify('success', 'OTP berhasil diverifikasi');
|
||||
return;
|
||||
}
|
||||
|
||||
notify('error', String(result.payload || 'Kode OTP tidak valid'));
|
||||
};
|
||||
|
||||
const setLogin = (target: HTMLElement) => {
|
||||
setInitialValues(prev => ({
|
||||
...prev,
|
||||
@ -269,62 +196,6 @@ export default function Login() {
|
||||
</CardBox>
|
||||
|
||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
||||
<div className='mb-6 rounded-xl border border-blue-100 bg-blue-50/70 p-4 dark:border-dark-700 dark:bg-dark-800'>
|
||||
<h3 className='mb-2 text-lg font-semibold text-gray-800 dark:text-white'>Masuk / Daftar Anggota</h3>
|
||||
<p className='mb-4 text-sm text-gray-600 dark:text-gray-300'>
|
||||
User baru dari Google atau Email OTP otomatis dibuat sebagai Anggota. Admin tetap memakai role admin jika emailnya sudah terdaftar sebagai admin.
|
||||
</p>
|
||||
<BaseButtons type='justify-start' mb='mb-4'>
|
||||
<BaseButton
|
||||
className='w-full'
|
||||
label='Lanjutkan dengan Google'
|
||||
color='info'
|
||||
onClick={handleGoogleSignin}
|
||||
/>
|
||||
</BaseButtons>
|
||||
|
||||
<div className='rounded-lg bg-white/80 p-4 dark:bg-dark-900'>
|
||||
<FormField label='Email OTP' help='Masukkan email untuk menerima kode OTP'>
|
||||
<input
|
||||
type='email'
|
||||
value={otpEmail}
|
||||
onChange={(event) => setOtpEmail(event.target.value)}
|
||||
placeholder='nama@email.com'
|
||||
/>
|
||||
</FormField>
|
||||
{otpRequested && (
|
||||
<FormField label='Kode OTP' help='Masukkan 6 digit kode dari email'>
|
||||
<input
|
||||
inputMode='numeric'
|
||||
maxLength={6}
|
||||
value={otpCode}
|
||||
onChange={(event) => setOtpCode(event.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder='123456'
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
<BaseButtons type='justify-start'>
|
||||
<BaseButton
|
||||
label={isRequestingOtp ? 'Mengirim...' : otpRequested ? 'Kirim Ulang OTP' : 'Kirim OTP'}
|
||||
color='info'
|
||||
outline
|
||||
disabled={isRequestingOtp}
|
||||
onClick={handleRequestOtp}
|
||||
/>
|
||||
{otpRequested && (
|
||||
<BaseButton
|
||||
label={isFetching ? 'Memverifikasi...' : 'Verifikasi OTP'}
|
||||
color='success'
|
||||
disabled={isFetching}
|
||||
onClick={handleVerifyOtp}
|
||||
/>
|
||||
)}
|
||||
</BaseButtons>
|
||||
</div>
|
||||
|
||||
<BaseDivider />
|
||||
<p className='text-center text-xs text-gray-500'>Atau masuk dengan password admin/user yang sudah ada.</p>
|
||||
</div>
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
enableReinitialize
|
||||
|
||||
@ -12,76 +12,14 @@ import BaseDivider from '../components/BaseDivider';
|
||||
import BaseButtons from '../components/BaseButtons';
|
||||
import { useRouter } from 'next/router';
|
||||
import { getPageTitle } from '../config';
|
||||
import { requestEmailOtp, setAuthToken, verifyEmailOtp } from '../stores/authSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||
|
||||
import axios from "axios";
|
||||
|
||||
export default function Register() {
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [otpEmail, setOtpEmail] = React.useState('');
|
||||
const [otpCode, setOtpCode] = React.useState('');
|
||||
const [otpRequested, setOtpRequested] = React.useState(false);
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const { isFetching, isRequestingOtp } = useAppSelector((state) => state.auth);
|
||||
const notify = (type, msg) => toast( msg, {type, position: "bottom-center"});
|
||||
|
||||
React.useEffect(() => {
|
||||
const redirectToken = router.query.token;
|
||||
|
||||
if (typeof redirectToken === 'string') {
|
||||
dispatch(setAuthToken(redirectToken));
|
||||
router.replace('/dashboard');
|
||||
}
|
||||
}, [dispatch, router, router.query.token]);
|
||||
|
||||
const handleGoogleSignin = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = `/api/auth/signin/google?app=${encodeURIComponent(window.location.origin)}`;
|
||||
};
|
||||
|
||||
const handleRequestOtp = async () => {
|
||||
const email = otpEmail.trim();
|
||||
|
||||
if (!email) {
|
||||
notify('error', 'Masukkan email terlebih dahulu');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await dispatch(requestEmailOtp({ email }));
|
||||
|
||||
if (requestEmailOtp.fulfilled.match(result)) {
|
||||
setOtpRequested(true);
|
||||
notify('success', 'Kode OTP sudah dikirim ke email Anda');
|
||||
return;
|
||||
}
|
||||
|
||||
notify('error', String(result.payload || 'Gagal mengirim OTP'));
|
||||
};
|
||||
|
||||
const handleVerifyOtp = async () => {
|
||||
const email = otpEmail.trim();
|
||||
const otp = otpCode.trim();
|
||||
|
||||
if (!email || !otp) {
|
||||
notify('error', 'Masukkan email dan kode OTP');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await dispatch(verifyEmailOtp({ email, otp }));
|
||||
|
||||
if (verifyEmailOtp.fulfilled.match(result)) {
|
||||
notify('success', 'Akun Anggota berhasil dibuat/masuk');
|
||||
await router.push('/dashboard');
|
||||
return;
|
||||
}
|
||||
|
||||
notify('error', String(result.payload || 'Kode OTP tidak valid'));
|
||||
};
|
||||
|
||||
const handleSubmit = async (value) => {
|
||||
setLoading(true)
|
||||
@ -101,67 +39,11 @@ export default function Register() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Register')}</title>
|
||||
<title>{getPageTitle('Login')}</title>
|
||||
</Head>
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'>
|
||||
<div className='mb-6 rounded-xl border border-blue-100 bg-blue-50/70 p-4 dark:border-dark-700 dark:bg-dark-800'>
|
||||
<h2 className='mb-2 text-2xl font-semibold text-gray-800 dark:text-white'>Daftar Anggota</h2>
|
||||
<p className='mb-4 text-sm text-gray-600 dark:text-gray-300'>
|
||||
Pendaftar baru lewat Google atau Email OTP otomatis mendapat role Anggota. Admin dibuat/diubah manual oleh admin utama.
|
||||
</p>
|
||||
<BaseButtons type='justify-start' mb='mb-4'>
|
||||
<BaseButton
|
||||
className='w-full'
|
||||
label='Daftar / Masuk dengan Google'
|
||||
color='info'
|
||||
onClick={handleGoogleSignin}
|
||||
/>
|
||||
</BaseButtons>
|
||||
|
||||
<div className='rounded-lg bg-white/80 p-4 dark:bg-dark-900'>
|
||||
<FormField label='Email OTP' help='Masukkan email untuk menerima kode OTP'>
|
||||
<input
|
||||
type='email'
|
||||
value={otpEmail}
|
||||
onChange={(event) => setOtpEmail(event.target.value)}
|
||||
placeholder='nama@email.com'
|
||||
/>
|
||||
</FormField>
|
||||
{otpRequested && (
|
||||
<FormField label='Kode OTP' help='Masukkan 6 digit kode dari email'>
|
||||
<input
|
||||
inputMode='numeric'
|
||||
maxLength={6}
|
||||
value={otpCode}
|
||||
onChange={(event) => setOtpCode(event.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder='123456'
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
<BaseButtons type='justify-start'>
|
||||
<BaseButton
|
||||
label={isRequestingOtp ? 'Mengirim...' : otpRequested ? 'Kirim Ulang OTP' : 'Kirim OTP'}
|
||||
color='info'
|
||||
outline
|
||||
disabled={isRequestingOtp}
|
||||
onClick={handleRequestOtp}
|
||||
/>
|
||||
{otpRequested && (
|
||||
<BaseButton
|
||||
label={isFetching ? 'Memverifikasi...' : 'Verifikasi OTP'}
|
||||
color='success'
|
||||
disabled={isFetching}
|
||||
onClick={handleVerifyOtp}
|
||||
/>
|
||||
)}
|
||||
</BaseButtons>
|
||||
</div>
|
||||
|
||||
<BaseDivider />
|
||||
<p className='text-center text-xs text-gray-500'>Atau gunakan form password legacy di bawah ini.</p>
|
||||
</div>
|
||||
<Formik
|
||||
initialValues={{
|
||||
email: '',
|
||||
|
||||
@ -1,425 +0,0 @@
|
||||
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<string, number>
|
||||
radius_zone?: RadiusZone
|
||||
live_status?: LiveStatus
|
||||
ai_recommendation?: AiRecommendation
|
||||
offerings?: Offering[]
|
||||
offerings_summary?: OfferingSummary
|
||||
category?: Category | null
|
||||
}
|
||||
|
||||
const priceLabels: Record<string, string> = {
|
||||
budget: 'Ramah kantong',
|
||||
midrange: 'Menengah',
|
||||
premium: 'Premium',
|
||||
unknown: 'Tanya harga',
|
||||
}
|
||||
|
||||
const scoreLabels: Record<keyof GeoScoreBreakdown, string> = {
|
||||
relevance: 'Relevansi kata kunci',
|
||||
distance: 'Jarak / prioritas 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<PublicPlace | null>(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 || { distance: 60, relevance: 20, reputation: 10, activity: 5, interaction: 5 })
|
||||
|
||||
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 (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle(place?.name || 'Detail GeoSeek')}</title>
|
||||
</Head>
|
||||
|
||||
<main className='min-h-screen bg-[#F7F2E8] text-[#17231B]'>
|
||||
<section className='bg-[#073B3A] px-6 py-6 text-white lg:px-8'>
|
||||
<div className='mx-auto flex max-w-7xl items-center justify-between rounded-full border border-white/15 bg-white/10 px-5 py-3 backdrop-blur'>
|
||||
<Link href='/' className='flex items-center gap-3 font-black'>
|
||||
<span className='grid h-10 w-10 place-items-center rounded-full bg-[#F2A541] text-[#073B3A]'>GS</span>
|
||||
GeoSeek 2.0
|
||||
</Link>
|
||||
<div className='flex items-center gap-3'>
|
||||
<BaseButton href='/' label='Cari lagi' color='white' roundedFull className='font-bold' />
|
||||
<BaseButton href='/login' label='Admin / Login' color='warning' roundedFull className='border-0 font-bold text-[#073B3A]' />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className='mx-auto max-w-7xl px-6 py-10 lg:px-8'>
|
||||
<Link href='/' className='mb-6 inline-flex items-center gap-2 text-sm font-bold text-[#087F6D] hover:text-[#073B3A]'>
|
||||
<BaseIcon path={mdiArrowLeft} size={18} /> Kembali ke GeoSeek
|
||||
</Link>
|
||||
|
||||
{loading ? (
|
||||
<div className='grid gap-6 lg:grid-cols-[1fr_390px]'>
|
||||
<div className='h-[620px] animate-pulse rounded-[2rem] bg-white' />
|
||||
<div className='h-[620px] animate-pulse rounded-[2rem] bg-white' />
|
||||
</div>
|
||||
) : error || !place ? (
|
||||
<div className='rounded-[2rem] bg-white p-10 text-center shadow-sm'>
|
||||
<h1 className='text-3xl font-black'>Detail belum tersedia</h1>
|
||||
<p className='mx-auto mt-3 max-w-xl text-[#5D6B62]'>{error || 'Tempat tidak ditemukan.'}</p>
|
||||
<div className='mt-6'>
|
||||
<BaseButton href='/' label='Cari tempat lain' color='info' roundedFull />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='grid gap-6 lg:grid-cols-[1fr_390px]'>
|
||||
<article className='overflow-hidden rounded-[2.3rem] bg-white shadow-xl shadow-[#073B3A]/8'>
|
||||
<div className='relative min-h-[330px] bg-[#D9E7D8] p-8'>
|
||||
<div className='absolute inset-0 opacity-40 [background-image:linear-gradient(#ffffff_1px,transparent_1px),linear-gradient(90deg,#ffffff_1px,transparent_1px)] [background-size:42px_42px]' />
|
||||
<div className='relative z-10 flex flex-col gap-5 md:flex-row md:items-start md:justify-between'>
|
||||
<div>
|
||||
<span className='rounded-full px-3 py-1 text-xs font-black text-white' style={{ backgroundColor: place.category?.color_hex || '#087F6D' }}>
|
||||
{place.category?.name || 'Tempat'}
|
||||
</span>
|
||||
<h1 className='mt-5 max-w-3xl text-5xl font-black leading-tight tracking-tight'>{place.name}</h1>
|
||||
<p className='mt-4 max-w-2xl text-lg leading-8 text-[#385143]'>{place.short_description || 'Tempat lokal yang bisa ditemukan berdasarkan lokasi dan kategori.'}</p>
|
||||
</div>
|
||||
<div className='flex flex-col items-start gap-3 md:items-end'>
|
||||
{place.is_verified ? (
|
||||
<span className='inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-sm font-black text-[#087F6D] shadow-sm'>
|
||||
<BaseIcon path={mdiShieldCheckOutline} size={18} /> Verified
|
||||
</span>
|
||||
) : null}
|
||||
<div className='rounded-[1.4rem] bg-white p-4 text-right shadow-sm'>
|
||||
<p className='text-xs font-bold uppercase tracking-wide text-[#6A7A70]'>GeoScore</p>
|
||||
<p className='text-4xl font-black text-[#F26A4B]'>{place.geo_score || place.search_score || '-'}</p>
|
||||
<p className='text-xs font-bold text-[#087F6D]'>{place.radius_zone?.range} · {place.radius_zone?.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='p-8'>
|
||||
<div className='grid gap-4 md:grid-cols-4'>
|
||||
<InfoCard label='Prioritas jarak' value={place.distance_km !== null && place.distance_km !== undefined ? `${place.distance_km} km · ${place.radius_zone?.label || 'Jarak aktif'}` : 'Aktifkan lokasi'} icon={mdiCrosshairsGps} />
|
||||
<InfoCard label='Rating' value={`${place.rating_average || '-'} (${place.rating_count || 0})`} icon={mdiStar} />
|
||||
<InfoCard label='Live' value={`${place.live_status?.label || '-'} · ${place.live_status?.crowd || '-'}`} icon={mdiClockOutline} />
|
||||
<InfoCard label='Estimasi' value={formatRupiah(place.average_price)} icon={mdiCash} />
|
||||
</div>
|
||||
|
||||
<div className='mt-8 grid gap-8 lg:grid-cols-[1fr_0.9fr]'>
|
||||
<div>
|
||||
<h2 className='text-2xl font-black'>Tentang tempat ini</h2>
|
||||
<p className='mt-4 leading-8 text-[#5D6B62]'>
|
||||
{place.full_description || place.short_description || 'Admin belum menambahkan deskripsi lengkap untuk tempat ini.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className='rounded-[1.8rem] bg-[#F7F2E8] p-5'>
|
||||
<h2 className='text-xl font-black'>Alamat & arah</h2>
|
||||
<p className='mt-3 leading-7 text-[#5D6B62]'>{place.address || [place.city, place.province].filter(Boolean).join(', ') || 'Alamat belum tersedia'}</p>
|
||||
<a href={mapsUrl} target='_blank' rel='noreferrer' className='mt-5 inline-flex items-center gap-2 rounded-2xl bg-[#073B3A] px-4 py-3 font-black text-white transition hover:bg-[#087F6D]'>
|
||||
Buka arah di Maps <BaseIcon path={mdiOpenInNew} size={18} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-8 grid gap-6 lg:grid-cols-[0.95fr_1.05fr]'>
|
||||
<div className='rounded-[1.8rem] border border-[#E8DEC9] p-5'>
|
||||
<div className='mb-4 flex items-center gap-2 text-xl font-black'>
|
||||
<BaseIcon path={mdiChartTimelineVariant} size={24} className='text-[#087F6D]' /> Breakdown GeoScore
|
||||
</div>
|
||||
{place.geo_score_breakdown ? (
|
||||
(Object.entries(place.geo_score_breakdown) as [keyof GeoScoreBreakdown, number][]).map(([key, value]) => (
|
||||
<ScoreBar key={key} label={`${scoreLabels[key]} · ${formulaEntries.find(([formulaKey]) => formulaKey === key)?.[1] || 0}%`} value={value} />
|
||||
))
|
||||
) : (
|
||||
<p className='text-sm text-[#5D6B62]'>Breakdown belum tersedia.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='rounded-[1.8rem] border border-[#E8DEC9] p-5'>
|
||||
<div className='mb-4 flex items-center gap-2 text-xl font-black'>
|
||||
<BaseIcon path={mdiRobotHappyOutline} size={24} className='text-[#087F6D]' /> AI Recommendation
|
||||
</div>
|
||||
<p className='font-black text-[#087F6D]'>{place.ai_recommendation?.label || 'Rekomendasi lokal'}</p>
|
||||
<p className='mt-3 leading-7 text-[#5D6B62]'>{place.ai_recommendation?.reason || 'Rekomendasi dibuat dari sinyal lokal dan GeoScore.'}</p>
|
||||
<p className='mt-3 text-xs font-bold text-[#6A7A70]'>{place.ai_recommendation?.source || 'GeoScore rules-based MVP'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-8 rounded-[1.8rem] bg-[#F7F2E8] p-5'>
|
||||
<div className='flex flex-col gap-3 md:flex-row md:items-center md:justify-between'>
|
||||
<div>
|
||||
<h2 className='text-2xl font-black'>Produk, stok & jasa</h2>
|
||||
<p className='mt-1 text-sm text-[#5D6B62]'>Produk {place.offerings_summary?.products || 0} · Jasa {place.offerings_summary?.services || 0} · Tersedia {place.offerings_summary?.available || 0}</p>
|
||||
</div>
|
||||
<span className='inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-sm font-black text-[#087F6D]'>
|
||||
<BaseIcon path={mdiPackageVariantClosed} size={18} /> {place.live_status?.updated_label || 'Update berkala'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='mt-5 grid gap-3 md:grid-cols-2'>
|
||||
{(place.offerings || []).length ? (place.offerings || []).map((offering) => (
|
||||
<div key={offering.id} className='rounded-2xl bg-white p-4'>
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<div>
|
||||
<p className='font-black'>{offering.name}</p>
|
||||
<p className='mt-1 text-sm leading-6 text-[#5D6B62]'>{offering.description || 'Detail belum tersedia.'}</p>
|
||||
</div>
|
||||
{offering.is_verified ? <BaseIcon path={mdiShieldCheckOutline} size={20} className='text-[#087F6D]' /> : null}
|
||||
</div>
|
||||
<div className='mt-3 flex flex-wrap items-center gap-2'>
|
||||
<span className={`rounded-full px-3 py-1 text-xs font-black ${stockColor(offering.stock_status)}`}>{offering.stock_label}</span>
|
||||
<span className='rounded-full bg-[#F7F2E8] px-3 py-1 text-xs font-bold text-[#5D6B62]'>{offering.offering_type === 'service' ? 'Jasa' : 'Produk'}</span>
|
||||
<span className='rounded-full bg-[#F7F2E8] px-3 py-1 text-xs font-bold text-[#5D6B62]'>{formatRupiah(offering.price)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<p className='rounded-2xl bg-white p-4 text-sm text-[#5D6B62]'>Belum ada produk atau jasa. Pemilik listing dapat menambahkannya dari panel admin.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<aside className='space-y-5'>
|
||||
<div className='rounded-[2rem] bg-white p-6 shadow-sm'>
|
||||
<h2 className='text-2xl font-black'>Kontak cepat</h2>
|
||||
<div className='mt-5 space-y-3'>
|
||||
{place.whatsapp_number ? (
|
||||
<a href={`https://wa.me/${place.whatsapp_number.replace(/\D/g, '')}`} target='_blank' rel='noreferrer' className='flex items-center gap-3 rounded-2xl bg-[#E8F6F1] p-4 font-bold text-[#087F6D] hover:bg-[#D8EFE7]'>
|
||||
<BaseIcon path={mdiWhatsapp} size={22} /> WhatsApp
|
||||
</a>
|
||||
) : null}
|
||||
{place.phone_number ? (
|
||||
<a href={`tel:${place.phone_number}`} className='flex items-center gap-3 rounded-2xl bg-[#F7F2E8] p-4 font-bold text-[#17231B] hover:bg-[#EFE4CF]'>
|
||||
<BaseIcon path={mdiPhoneOutline} size={22} /> {place.phone_number}
|
||||
</a>
|
||||
) : null}
|
||||
{place.website_url ? (
|
||||
<a href={place.website_url} target='_blank' rel='noreferrer' className='flex items-center gap-3 rounded-2xl bg-[#F7F2E8] p-4 font-bold text-[#17231B] hover:bg-[#EFE4CF]'>
|
||||
<BaseIcon path={mdiWeb} size={22} /> Website
|
||||
</a>
|
||||
) : null}
|
||||
{!place.whatsapp_number && !place.phone_number && !place.website_url ? (
|
||||
<p className='rounded-2xl bg-[#F7F2E8] p-4 text-sm text-[#5D6B62]'>Kontak belum tersedia. Admin bisa menambahkannya dari panel admin.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='rounded-[2rem] bg-white p-6 shadow-sm'>
|
||||
<h2 className='text-2xl font-black'>Ringkasan sinyal lokal</h2>
|
||||
<div className='mt-5 space-y-3'>
|
||||
<SignalRow icon={mdiNavigationVariantOutline} label='Radius zone' value={`${place.radius_zone?.range || '-'} · ${place.radius_zone?.label || '-'}`} />
|
||||
<SignalRow icon={mdiClockOutline} label='Live status' value={`${place.live_status?.label || '-'} · ${place.live_status?.crowd || '-'}`} />
|
||||
<SignalRow icon={mdiPackageVariantClosed} label='Top stok' value={topOfferings[0]?.name || 'Belum ada'} />
|
||||
<SignalRow icon={mdiStar} label='Reputasi' value={`${place.rating_average || '-'} dari ${place.rating_count || 0} ulasan`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='rounded-[2rem] bg-[#073B3A] p-6 text-white shadow-xl shadow-[#073B3A]/20'>
|
||||
<BaseIcon path={mdiMapMarkerRadiusOutline} size={30} className='text-[#F2A541]' />
|
||||
<h2 className='mt-4 text-2xl font-black'>Untuk pemilik data</h2>
|
||||
<p className='mt-3 text-sm leading-6 text-white/75'>Masuk ke panel admin untuk memperbarui detail, kontak, kategori, rating, status tempat, produk, jasa, dan stok.</p>
|
||||
<div className='mt-5'>
|
||||
<BaseButton href='/login' label='Masuk admin' color='warning' roundedFull className='border-0 font-black text-[#073B3A]' />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const InfoCard = ({ label, value, icon }: { label: string; value: string; icon?: string }) => (
|
||||
<div className='rounded-2xl bg-[#F7F2E8] p-4'>
|
||||
<div className='flex items-center gap-2 text-xs font-bold uppercase tracking-wide text-[#6A7A70]'>
|
||||
{icon ? <BaseIcon path={icon} size={15} /> : null}
|
||||
{label}
|
||||
</div>
|
||||
<p className='mt-2 font-black'>{value}</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
const ScoreBar = ({ label, value }: { label: string; value: number }) => (
|
||||
<div className='mb-3 last:mb-0'>
|
||||
<div className='mb-1 flex items-center justify-between text-xs text-[#5D6B62]'>
|
||||
<span>{label}</span>
|
||||
<span className='font-black'>{Math.round(value)}</span>
|
||||
</div>
|
||||
<div className='h-2 overflow-hidden rounded-full bg-[#F7F2E8]'>
|
||||
<div className='h-full rounded-full bg-[#2CA58D]' style={{ width: `${Math.max(0, Math.min(100, value))}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const SignalRow = ({ icon, label, value }: { icon: string; label: string; value: string }) => (
|
||||
<div className='flex items-start gap-3 rounded-2xl bg-[#F7F2E8] p-4'>
|
||||
<BaseIcon path={icon} size={20} className='mt-0.5 text-[#087F6D]' />
|
||||
<div>
|
||||
<p className='text-xs font-bold uppercase tracking-wide text-[#6A7A70]'>{label}</p>
|
||||
<p className='mt-1 font-black'>{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
PlaceDetailPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>
|
||||
}
|
||||
@ -4,7 +4,6 @@ import jwt from 'jsonwebtoken';
|
||||
|
||||
interface MainState {
|
||||
isFetching: boolean;
|
||||
isRequestingOtp: boolean;
|
||||
errorMessage: string;
|
||||
currentUser: any;
|
||||
notify: any;
|
||||
@ -14,7 +13,6 @@ interface MainState {
|
||||
const initialState: MainState = {
|
||||
/* User */
|
||||
isFetching: false,
|
||||
isRequestingOtp: false,
|
||||
errorMessage: '',
|
||||
currentUser: null,
|
||||
token: '',
|
||||
@ -26,7 +24,6 @@ const initialState: MainState = {
|
||||
};
|
||||
|
||||
export const resetAction = createAction('auth/passwordReset/reset')
|
||||
export const setAuthToken = createAction<string>('auth/setAuthToken')
|
||||
|
||||
export const loginUser = createAsyncThunk(
|
||||
'auth/loginUser',
|
||||
@ -43,36 +40,6 @@ export const loginUser = createAsyncThunk(
|
||||
}
|
||||
);
|
||||
|
||||
export const requestEmailOtp = createAsyncThunk(
|
||||
'auth/requestEmailOtp',
|
||||
async (payload: { email: string }, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await axios.post('/auth/otp/request', payload);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (!error.response) {
|
||||
throw error;
|
||||
}
|
||||
return rejectWithValue(error.response.data);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const verifyEmailOtp = createAsyncThunk(
|
||||
'auth/verifyEmailOtp',
|
||||
async (payload: { email: string; otp: string }, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await axios.post('/auth/otp/verify', payload);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (!error.response) {
|
||||
throw error;
|
||||
}
|
||||
return rejectWithValue(error.response.data);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const passwordReset = createAsyncThunk(
|
||||
'auth/passwordReset',
|
||||
async (value: Record<string, string>, { rejectWithValue }) => {
|
||||
@ -99,17 +66,6 @@ export const findMe = createAsyncThunk('auth/findMe', async () => {
|
||||
return response.data;
|
||||
});
|
||||
|
||||
const persistAuthToken = (state: MainState, token: string) => {
|
||||
const user = jwt.decode(token);
|
||||
|
||||
state.errorMessage = '';
|
||||
state.token = token;
|
||||
state.isFetching = false;
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
axios.defaults.headers.common['Authorization'] = 'Bearer ' + token;
|
||||
};
|
||||
|
||||
export const authSlice = createSlice({
|
||||
name: 'auth',
|
||||
initialState,
|
||||
@ -127,42 +83,20 @@ export const authSlice = createSlice({
|
||||
state.isFetching = true;
|
||||
});
|
||||
builder.addCase(loginUser.fulfilled, (state, action) => {
|
||||
persistAuthToken(state, action.payload);
|
||||
const token = action.payload;
|
||||
const user = jwt.decode(token);
|
||||
|
||||
state.errorMessage = '';
|
||||
state.token = token;
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
axios.defaults.headers.common['Authorization'] = 'Bearer ' + token;
|
||||
});
|
||||
|
||||
builder.addCase(loginUser.rejected, (state, action) => {
|
||||
state.errorMessage = String(action.payload) || 'Something went wrong. Try again';
|
||||
state.isFetching = false;
|
||||
});
|
||||
|
||||
builder.addCase(requestEmailOtp.pending, (state) => {
|
||||
state.isRequestingOtp = true;
|
||||
state.errorMessage = '';
|
||||
});
|
||||
builder.addCase(requestEmailOtp.fulfilled, (state) => {
|
||||
state.isRequestingOtp = false;
|
||||
state.errorMessage = '';
|
||||
});
|
||||
builder.addCase(requestEmailOtp.rejected, (state, action) => {
|
||||
state.errorMessage = String(action.payload) || 'Failed to send OTP';
|
||||
state.isRequestingOtp = false;
|
||||
});
|
||||
|
||||
builder.addCase(verifyEmailOtp.pending, (state) => {
|
||||
state.isFetching = true;
|
||||
state.errorMessage = '';
|
||||
});
|
||||
builder.addCase(verifyEmailOtp.fulfilled, (state, action) => {
|
||||
persistAuthToken(state, action.payload);
|
||||
});
|
||||
builder.addCase(verifyEmailOtp.rejected, (state, action) => {
|
||||
state.errorMessage = String(action.payload) || 'Failed to verify OTP';
|
||||
state.isFetching = false;
|
||||
});
|
||||
|
||||
builder.addCase(setAuthToken, (state, action) => {
|
||||
persistAuthToken(state, action.payload);
|
||||
});
|
||||
builder.addCase(findMe.pending, () => {
|
||||
console.log('Pending findMe');
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user