Compare commits

...

9 Commits

Author SHA1 Message Date
Flatlogic Bot
5d3fe9d7a2 GEOSEEK 2.0 2026-06-20 00:48:54 +00:00
Flatlogic Bot
1c09ee751e Autosave: 20260618-104218 2026-06-18 10:42:13 +00:00
Flatlogic Bot
39d56bcef3 Autosave: 20260618-085709 2026-06-18 08:57:04 +00:00
Flatlogic Bot
ecb978b657 Autosave: 20260618-041215 2026-06-18 04:12:14 +00:00
Flatlogic Bot
05b4206387 GEOSEEK 3.0 2026-06-18 02:02:31 +00:00
Flatlogic Bot
9fb349ab84 Autosave: 20260618-001727 2026-06-18 00:17:23 +00:00
Flatlogic Bot
638119614f Autosave: 20260617-171936 2026-06-17 17:19:33 +00:00
Flatlogic Bot
6da4cb9f42 Autosave: 20260617-162107 2026-06-17 16:21:04 +00:00
Flatlogic Bot
e84665755d Autosave: 20260617-143144 2026-06-17 14:31:41 +00:00
47 changed files with 1015756 additions and 684 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

View File

@ -1,13 +1,11 @@
const config = require('../config'); const config = require('../config');
const providers = config.providers; const providers = config.providers;
const helpers = require('../helpers');
const db = require('../db/models');
const passport = require('passport'); const passport = require('passport');
const JWTstrategy = require('passport-jwt').Strategy; const JWTstrategy = require('passport-jwt').Strategy;
const ExtractJWT = require('passport-jwt').ExtractJwt; const ExtractJWT = require('passport-jwt').ExtractJwt;
const GoogleStrategy = require('passport-google-oauth2').Strategy; const GoogleStrategy = require('passport-google-oauth2').Strategy;
const MicrosoftStrategy = require('passport-microsoft').Strategy; const MicrosoftStrategy = require('passport-microsoft').Strategy;
const AuthService = require('../services/auth');
const UsersDBApi = require('../db/api/users'); const UsersDBApi = require('../db/api/users');
@ -56,13 +54,7 @@ passport.use(new MicrosoftStrategy({
)); ));
function socialStrategy(email, profile, provider, done) { function socialStrategy(email, profile, provider, done) {
db.users.findOrCreate({where: {email, provider}}).then(([user, created]) => { AuthService.socialSignin(email, profile, provider)
const body = { .then((token) => done(null, { token }))
id: user.id, .catch((error) => done(error));
email: user.email,
name: profile.displayName,
};
const token = helpers.jwtSign({user: body});
return done(null, {token});
});
} }

View File

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

View File

@ -0,0 +1,133 @@
'use strict';
const isMissingTableError = (err) => {
const message = String(err && err.message);
return message.includes('No description found')
|| message.includes('does not exist')
|| message.includes('Cannot read properties of undefined');
};
module.exports = {
up: async (queryInterface, Sequelize) => {
const transaction = await queryInterface.sequelize.transaction();
try {
try {
await queryInterface.describeTable('place_offerings');
await transaction.commit();
return;
} catch (err) {
if (!isMissingTableError(err)) {
throw err;
}
}
await queryInterface.createTable('place_offerings', {
id: {
type: Sequelize.DataTypes.UUID,
defaultValue: Sequelize.DataTypes.UUIDV4,
primaryKey: true,
},
placeId: {
type: Sequelize.DataTypes.UUID,
allowNull: false,
references: {
key: 'id',
model: 'places',
},
},
name: {
type: Sequelize.DataTypes.TEXT,
allowNull: false,
},
description: {
type: Sequelize.DataTypes.TEXT,
},
offering_type: {
type: Sequelize.DataTypes.ENUM('product', 'service'),
allowNull: false,
defaultValue: 'product',
},
price: {
type: Sequelize.DataTypes.DECIMAL,
},
stock_status: {
type: Sequelize.DataTypes.ENUM('in_stock', 'limited', 'out_of_stock', 'by_request'),
allowNull: false,
defaultValue: 'by_request',
},
stock_quantity: {
type: Sequelize.DataTypes.INTEGER,
},
is_verified: {
type: Sequelize.DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
is_active: {
type: Sequelize.DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
},
last_stock_update: {
type: Sequelize.DataTypes.DATE,
},
createdAt: {
type: Sequelize.DataTypes.DATE,
},
updatedAt: {
type: Sequelize.DataTypes.DATE,
},
deletedAt: {
type: Sequelize.DataTypes.DATE,
},
importHash: {
type: Sequelize.DataTypes.STRING(255),
allowNull: true,
unique: true,
},
}, { transaction });
await queryInterface.addIndex('place_offerings', ['placeId'], {
transaction,
});
await queryInterface.addIndex('place_offerings', ['offering_type', 'stock_status'], {
transaction,
});
await queryInterface.addIndex('place_offerings', ['is_active'], {
transaction,
});
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
},
down: async (queryInterface) => {
const transaction = await queryInterface.sequelize.transaction();
try {
let tableExists = true;
try {
await queryInterface.describeTable('place_offerings');
} catch (err) {
if (!isMissingTableError(err)) {
throw err;
}
tableExists = false;
}
if (tableExists) {
await queryInterface.dropTable('place_offerings', { transaction });
}
await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_place_offerings_offering_type";', { transaction });
await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_place_offerings_stock_status";', { transaction });
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
},
};

View File

@ -0,0 +1,94 @@
'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;
}
},
};

View File

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

View File

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

View File

@ -0,0 +1,80 @@
module.exports = function(sequelize, DataTypes) {
const place_offerings = sequelize.define(
'place_offerings',
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
name: {
type: DataTypes.TEXT,
allowNull: false,
},
description: {
type: DataTypes.TEXT,
},
offering_type: {
type: DataTypes.ENUM,
values: ['product', 'service'],
allowNull: false,
defaultValue: 'product',
},
price: {
type: DataTypes.DECIMAL,
},
stock_status: {
type: DataTypes.ENUM,
values: ['in_stock', 'limited', 'out_of_stock', 'by_request'],
allowNull: false,
defaultValue: 'by_request',
},
stock_quantity: {
type: DataTypes.INTEGER,
},
is_verified: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
is_active: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
},
last_stock_update: {
type: DataTypes.DATE,
},
importHash: {
type: DataTypes.STRING(255),
allowNull: true,
unique: true,
},
},
{
timestamps: true,
paranoid: true,
freezeTableName: true,
},
);
place_offerings.associate = (db) => {
db.place_offerings.belongsTo(db.places, {
as: 'place',
foreignKey: {
name: 'placeId',
},
constraints: false,
});
db.place_offerings.belongsTo(db.users, {
as: 'createdBy',
});
db.place_offerings.belongsTo(db.users, {
as: 'updatedBy',
});
};
return place_offerings;
};

View File

@ -1,8 +1,3 @@
const config = require('../../config');
const providers = config.providers;
const crypto = require('crypto');
const bcrypt = require('bcrypt');
const moment = require('moment');
module.exports = function(sequelize, DataTypes) { module.exports = function(sequelize, DataTypes) {
const places = sequelize.define( const places = sequelize.define(
@ -19,6 +14,27 @@ name: {
},
nama: {
type: DataTypes.STRING(255),
},
kategori: {
type: DataTypes.STRING(100),
},
subkategori: {
type: DataTypes.STRING(100),
}, },
short_description: { short_description: {
@ -40,6 +56,27 @@ address: {
},
alamat: {
type: DataTypes.TEXT,
},
kelurahan: {
type: DataTypes.STRING(100),
},
kecamatan: {
type: DataTypes.STRING(100),
}, },
city: { city: {
@ -47,6 +84,13 @@ city: {
},
kota: {
type: DataTypes.STRING(100),
}, },
province: { province: {
@ -54,6 +98,13 @@ province: {
},
provinsi: {
type: DataTypes.STRING(100),
}, },
postal_code: { postal_code: {
@ -61,17 +112,24 @@ postal_code: {
},
kode_pos: {
type: DataTypes.STRING(20),
}, },
latitude: { latitude: {
type: DataTypes.DECIMAL, type: DataTypes.DOUBLE,
}, },
longitude: { longitude: {
type: DataTypes.DECIMAL, type: DataTypes.DOUBLE,
@ -82,6 +140,13 @@ phone_number: {
},
telepon: {
type: DataTypes.STRING(100),
}, },
whatsapp_number: { whatsapp_number: {
@ -89,6 +154,13 @@ whatsapp_number: {
},
whatsapp: {
type: DataTypes.STRING(100),
}, },
email: { email: {
@ -103,6 +175,13 @@ website_url: {
},
website: {
type: DataTypes.STRING(255),
}, },
google_maps_url: { google_maps_url: {
@ -110,6 +189,13 @@ google_maps_url: {
},
jam_buka: {
type: DataTypes.STRING(255),
}, },
price_level: { price_level: {
@ -146,6 +232,14 @@ rating_average: {
},
rating: {
type: DataTypes.DECIMAL(2, 1),
defaultValue: 0,
}, },
rating_count: { rating_count: {
@ -153,25 +247,22 @@ rating_count: {
},
review_count: {
type: DataTypes.INTEGER,
defaultValue: 0,
}, },
status: { status: {
type: DataTypes.ENUM, type: DataTypes.STRING(50),
defaultValue: 'aktif',
values: [
"draft",
"published",
"archived"
],
}, },
is_verified: { is_verified: {
@ -182,6 +273,81 @@ is_verified: {
},
verified: {
type: DataTypes.BOOLEAN,
defaultValue: false,
},
featured: {
type: DataTypes.BOOLEAN,
defaultValue: false,
},
popularitas: {
type: DataTypes.INTEGER,
defaultValue: 0,
},
created_at: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW,
},
updated_at: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW,
},
sumber: {
type: DataTypes.STRING(100),
},
external_id: {
type: DataTypes.STRING(255),
},
external_type: {
type: DataTypes.STRING(50),
},
raw_source_data: {
type: DataTypes.JSONB,
},
last_synced_at: {
type: DataTypes.DATE,
}, },
importHash: { importHash: {
@ -227,6 +393,15 @@ is_verified: {
}); });
db.places.hasMany(db.place_offerings, {
as: 'offerings',
foreignKey: {
name: 'placeId',
},
constraints: false,
});
db.places.hasMany(db.reviews, { db.places.hasMany(db.reviews, {
as: 'reviews_place', as: 'reviews_place',
foreignKey: { foreignKey: {

View File

@ -2,7 +2,6 @@ const config = require('../../config');
const providers = config.providers; const providers = config.providers;
const crypto = require('crypto'); const crypto = require('crypto');
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const moment = require('moment');
module.exports = function(sequelize, DataTypes) { module.exports = function(sequelize, DataTypes) {
const users = sequelize.define( const users = sequelize.define(
@ -95,6 +94,37 @@ passwordResetTokenExpiresAt: {
},
emailOtpHash: {
type: DataTypes.TEXT,
},
emailOtpExpiresAt: {
type: DataTypes.DATE,
},
emailOtpLastSentAt: {
type: DataTypes.DATE,
},
emailOtpAttempts: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
}, },
provider: { provider: {
@ -229,8 +259,8 @@ provider: {
}; };
users.beforeCreate((users, options) => { users.beforeCreate((users) => {
users = trimStringFields(users); trimStringFields(users);
if (users.provider !== providers.LOCAL && Object.values(providers).indexOf(users.provider) > -1) { if (users.provider !== providers.LOCAL && Object.values(providers).indexOf(users.provider) > -1) {
users.emailVerified = true; users.emailVerified = true;
@ -250,8 +280,8 @@ provider: {
} }
}); });
users.beforeUpdate((users, options) => { users.beforeUpdate((users) => {
users = trimStringFields(users); trimStringFields(users);
}); });

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,6 @@ const passport = require('passport');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const bodyParser = require('body-parser'); const bodyParser = require('body-parser');
const db = require('./db/models');
const config = require('./config'); const config = require('./config');
const swaggerUI = require('swagger-ui-express'); const swaggerUI = require('swagger-ui-express');
const swaggerJsDoc = require('swagger-jsdoc'); const swaggerJsDoc = require('swagger-jsdoc');
@ -16,6 +15,8 @@ const fileRoutes = require('./routes/file');
const searchRoutes = require('./routes/search'); const searchRoutes = require('./routes/search');
const sqlRoutes = require('./routes/sql'); const sqlRoutes = require('./routes/sql');
const pexelsRoutes = require('./routes/pexels'); const pexelsRoutes = require('./routes/pexels');
const publicPlacesRoutes = require('./routes/publicPlaces');
const geoseekCollectorRoutes = require('./routes/geoseekCollector');
const openaiRoutes = require('./routes/openai'); const openaiRoutes = require('./routes/openai');
@ -100,6 +101,7 @@ app.use(bodyParser.json());
app.use('/api/auth', authRoutes); app.use('/api/auth', authRoutes);
app.use('/api/file', fileRoutes); app.use('/api/file', fileRoutes);
app.use('/api/pexels', pexelsRoutes); app.use('/api/pexels', pexelsRoutes);
app.use('/api/public', publicPlacesRoutes);
app.enable('trust proxy'); app.enable('trust proxy');
@ -113,6 +115,8 @@ app.use('/api/place_categories', passport.authenticate('jwt', {session: false}),
app.use('/api/places', passport.authenticate('jwt', {session: false}), placesRoutes); app.use('/api/places', passport.authenticate('jwt', {session: false}), placesRoutes);
app.use('/api/geoseek-collector', passport.authenticate('jwt', {session: false}), geoseekCollectorRoutes);
app.use('/api/place_opening_hours', passport.authenticate('jwt', {session: false}), place_opening_hoursRoutes); app.use('/api/place_opening_hours', passport.authenticate('jwt', {session: false}), place_opening_hoursRoutes);
app.use('/api/place_features', passport.authenticate('jwt', {session: false}), place_featuresRoutes); app.use('/api/place_features', passport.authenticate('jwt', {session: false}), place_featuresRoutes);

View File

@ -84,8 +84,12 @@ router.get('/me', passport.authenticate('jwt', {session: false}), (req, res) =>
throw new ForbiddenError(); throw new ForbiddenError();
} }
const payload = req.currentUser; const payload = { ...req.currentUser };
delete payload.password; delete payload.password;
delete payload.emailOtpHash;
delete payload.emailOtpExpiresAt;
delete payload.emailOtpLastSentAt;
delete payload.emailOtpAttempts;
res.status(200).send(payload); res.status(200).send(payload);
}); });
@ -171,12 +175,31 @@ router.get('/email-configured', (req, res) => {
res.status(200).send(payload); 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) => { router.get('/signin/google', (req, res, next) => {
passport.authenticate("google", {scope: ["profile", "email"], state: req.query.app})(req, res, next); passport.authenticate("google", {
scope: ["profile", "email"],
state: req.query.app,
callbackURL: oauthCallbackUrl(req, 'google'),
})(req, res, next);
}); });
router.get('/signin/google/callback', passport.authenticate("google", {failureRedirect: "/login", session: false}), router.get('/signin/google/callback', (req, res, next) => {
passport.authenticate("google", {
failureRedirect: "/login",
session: false,
callbackURL: oauthCallbackUrl(req, 'google'),
})(req, res, next);
},
function (req, res) { function (req, res) {
socialRedirect(res, req.query.state, req.user.token, config); socialRedirect(res, req.query.state, req.user.token, config);
} }
@ -185,14 +208,18 @@ router.get('/signin/google/callback', passport.authenticate("google", {failureRe
router.get('/signin/microsoft', (req, res, next) => { router.get('/signin/microsoft', (req, res, next) => {
passport.authenticate("microsoft", { passport.authenticate("microsoft", {
scope: ["https://graph.microsoft.com/user.read openid"], scope: ["https://graph.microsoft.com/user.read openid"],
state: req.query.app state: req.query.app,
callbackURL: oauthCallbackUrl(req, 'microsoft'),
})(req, res, next); })(req, res, next);
}); });
router.get('/signin/microsoft/callback', passport.authenticate("microsoft", { router.get('/signin/microsoft/callback', (req, res, next) => {
passport.authenticate("microsoft", {
failureRedirect: "/login", failureRedirect: "/login",
session: false session: false,
}), callbackURL: oauthCallbackUrl(req, 'microsoft'),
})(req, res, next);
},
function (req, res) { function (req, res) {
socialRedirect(res, req.query.state, req.user.token, config); socialRedirect(res, req.query.state, req.user.token, config);
} }
@ -200,8 +227,29 @@ router.get('/signin/microsoft/callback', passport.authenticate("microsoft", {
router.use('/', require('../helpers').commonErrorHandler); 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) { function socialRedirect(res, state, token, config) {
res.redirect(config.uiUrl + "/login?token=" + token); 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)}`);
} }
module.exports = router; module.exports = router;

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,905 @@
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,
};

View File

@ -2,15 +2,269 @@ const UsersDBApi = require('../db/api/users');
const ValidationError = require('./notifications/errors/validation'); const ValidationError = require('./notifications/errors/validation');
const ForbiddenError = require('./notifications/errors/forbidden'); const ForbiddenError = require('./notifications/errors/forbidden');
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const crypto = require('crypto');
const EmailAddressVerificationEmail = require('./email/list/addressVerification'); const EmailAddressVerificationEmail = require('./email/list/addressVerification');
const EmailOtpEmail = require('./email/list/emailOtp');
const InvitationEmail = require("./email/list/invitation"); const InvitationEmail = require("./email/list/invitation");
const PasswordResetEmail = require('./email/list/passwordReset'); const PasswordResetEmail = require('./email/list/passwordReset');
const EmailSender = require('./email'); const EmailSender = require('./email');
const config = require('../config'); const config = require('../config');
const helpers = require('../helpers'); const helpers = require('../helpers');
const db = require('../db/models');
class Auth { 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) { static async signup(email, password, options = {}, host) {
email = this.normalizeEmail(email);
const user = await UsersDBApi.findBy({email}); const user = await UsersDBApi.findBy({email});
const hashedPassword = await bcrypt.hash( const hashedPassword = await bcrypt.hash(
@ -81,7 +335,8 @@ class Auth {
return helpers.jwtSign(data); return helpers.jwtSign(data);
} }
static async signin(email, password, options = {}) { static async signin(email, password) {
email = this.normalizeEmail(email);
const user = await UsersDBApi.findBy({email}); const user = await UsersDBApi.findBy({email});
if (!user) { if (!user) {

View File

@ -0,0 +1,58 @@
const { getNotification } = require('../../notifications/helpers');
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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>
`;
}
};

View File

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

View File

@ -26,6 +26,12 @@ const errors = {
'Email verification link is invalid or has expired', 'Email verification link is invalid or has expired',
error: `Email not recognized`, 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: { iam: {

View File

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

46
data/geoseek/README.md Normal file
View File

@ -0,0 +1,46 @@
# 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

View File

@ -0,0 +1,43 @@
{
"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%"
}
}
}

View File

@ -0,0 +1,36 @@
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 id kategori subkategori_default target_total
2 1 Restoran Makanan Lokal 150000
3 2 Cafe Coffee Shop 50000
4 3 Hotel Hotel/Penginapan 40000
5 4 Rumah Sakit Rumah Sakit Umum 25000
6 5 Apotek Apotek Umum 50000
7 6 ATM ATM Bank 25000
8 7 SPBU Pom Bensin 15000
9 8 Bengkel Bengkel Motor/Mobil 50000
10 9 Wisata Tempat Wisata 50000
11 10 Mall Pusat Perbelanjaan 30000
12 11 Minimarket Toko Harian 30000
13 12 Klinik Klinik Umum 15000
14 13 Dokter Praktik Dokter 15000
15 14 Laundry Laundry Kiloan 40000
16 15 Bank Kantor Bank 15000
17 16 Sekolah Sekolah Dasar/Menengah 50000
18 17 Kampus Perguruan Tinggi 30000
19 18 Masjid Tempat Ibadah Islam 110000
20 19 Gereja Tempat Ibadah Kristen/Katolik 40000
21 20 Penginapan Guest House/Homestay 25000
22 21 Kost Rumah Kost 30000
23 22 Pasar Pasar Tradisional 25000
24 23 Rental Mobil Sewa Mobil 15000
25 24 Rental Motor Sewa Motor 10000
26 25 Salon Salon Kecantikan 20000
27 26 Barbershop Pangkas Rambut 20000
28 27 Gym Pusat Kebugaran 15000
29 28 Kolam Renang Fasilitas Olahraga Air 10000
30 29 Toko Bangunan Material Bangunan 40000
31 30 Toko Elektronik Elektronik & Gadget 35000
32 31 Toko Pakaian Fashion 35000
33 32 Logistik Jasa Pengiriman 20000
34 33 Kurir Jasa Kurir Lokal 10000
35 34 Jasa Layanan Umum 30000
36 35 UMKM Usaha Mikro Kecil Menengah 75000

View File

@ -0,0 +1,16 @@
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 id kota provinsi target_data latitude longitude
2 1 Jakarta DKI Jakarta 200000 -6.2000 106.8167
3 2 Surabaya Jawa Timur 100000 -7.2575 112.7521
4 3 Bandung Jawa Barat 90000 -6.9175 107.6191
5 4 Medan Sumatera Utara 80000 3.5952 98.6722
6 5 Bekasi Jawa Barat 70000 -6.2383 106.9756
7 6 Tangerang Banten 70000 -6.1783 106.6319
8 7 Semarang Jawa Tengah 60000 -6.9667 110.4167
9 8 Makassar Sulawesi Selatan 50000 -5.1477 119.4327
10 9 Palembang Sumatera Selatan 45000 -2.9761 104.7754
11 10 Depok Jawa Barat 45000 -6.4025 106.7942
12 11 Bogor Jawa Barat 40000 -6.5971 106.8060
13 12 Yogyakarta DI Yogyakarta 35000 -7.7956 110.3695
14 13 Denpasar Bali 30000 -8.6705 115.2126
15 14 Malang Jawa Timur 30000 -7.9666 112.6326
16 15 Kota lain Nasional 85000 -2.5489 118.0149

View File

@ -0,0 +1,65 @@
-- 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()
);

View File

@ -0,0 +1,81 @@
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 id nama_tempat kategori subkategori alamat kecamatan kota provinsi latitude longitude no_telp jam_operasional rating jumlah_review sumber_data status_verifikasi
2 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
3 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
4 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
5 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
6 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
7 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
8 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
9 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
10 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
11 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
12 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
13 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
14 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
15 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
16 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
17 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
18 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
19 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
20 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
21 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
22 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
23 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
24 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
25 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
26 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
27 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
28 27 Jembatan Suramadu Wisata Ikon Kota Kenjeran Kenjeran Surabaya Jawa Timur -7.1824 112.7787 24 Jam 4.6 33000 GeoSeek verified
29 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
30 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
31 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
32 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
33 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
34 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
35 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
36 35 Malioboro Yogyakarta Wisata Kawasan Belanja Jl Malioboro Gedongtengen Yogyakarta DI Yogyakarta -7.7926 110.3658 24 Jam 4.7 89000 GeoSeek verified
37 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
38 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
39 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
40 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
41 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
42 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
43 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
44 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
45 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
46 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
47 46 Pantai Losari Wisata Pantai Jl Penghibur Losari Makassar Sulawesi Selatan -5.1436 119.4076 24 Jam 4.6 54000 GeoSeek verified
48 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
49 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
50 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
51 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
52 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
53 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
54 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
55 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
56 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
57 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
58 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
59 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
60 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
61 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
62 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
63 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
64 63 Pantai Kuta Wisata Pantai Jl Pantai Kuta Kuta Denpasar Bali -8.7185 115.1686 24 Jam 4.6 92000 GeoSeek verified
65 64 Sanur Beach Wisata Pantai Jl Danau Tamblingan Denpasar Selatan Denpasar Bali -8.6930 115.2625 24 Jam 4.6 53000 GeoSeek verified
66 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
67 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
68 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
69 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
70 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
71 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
72 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
73 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
74 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
75 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
76 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
77 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
78 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
79 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
80 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
81 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

View File

@ -0,0 +1 @@
id,nama_tempat,kategori,subkategori,alamat,kecamatan,kota,provinsi,latitude,longitude,no_telp,jam_operasional,rating,jumlah_review,sumber_data,status_verifikasi
1 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

View File

@ -1,6 +1,5 @@
import React, {useEffect, useRef} from 'react' import React, { useEffect, useRef, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider' import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon' import BaseIcon from './BaseIcon'

View File

@ -0,0 +1,642 @@
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,
},
];

View File

@ -0,0 +1,383 @@
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: '01 km',
description: 'Prioritas tertinggi untuk kebutuhan yang bisa ditempuh dengan jalan kaki.',
},
{
rank: 1,
maxKm: 5,
label: 'Dekat',
range: '15 km',
description: 'Masih dekat dari lokasi aktif dan cocok untuk kebutuhan harian sekitar.',
},
{
rank: 2,
maxKm: 10,
label: 'Agak dekat',
range: '510 km',
description: 'Masih mudah dijangkau, biasanya perlu kendaraan singkat.',
},
{
rank: 3,
maxKm: 20,
label: 'Sedang',
range: '1020 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',
};
};

View File

@ -1,5 +1,4 @@
import React, { ReactNode, useEffect } from 'react' import React, { ReactNode, useEffect, useState } from 'react'
import { useState } from 'react'
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside' import menuAside from '../menuAside'

View File

@ -7,6 +7,30 @@ const menuAside: MenuAsideItem[] = [
icon: icon.mdiViewDashboardOutline, icon: icon.mdiViewDashboardOutline,
label: 'Dashboard', 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', href: '/users/users-list',

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,18 @@
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;

View File

@ -0,0 +1,12 @@
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

View File

@ -16,7 +16,14 @@ import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons'; import BaseButtons from '../components/BaseButtons';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { getPageTitle } from '../config'; import { getPageTitle } from '../config';
import { findMe, loginUser, resetAction } from '../stores/authSlice'; import {
findMe,
loginUser,
requestEmailOtp,
resetAction,
setAuthToken,
verifyEmailOtp,
} from '../stores/authSlice';
import { useAppDispatch, useAppSelector } from '../stores/hooks'; import { useAppDispatch, useAppSelector } from '../stores/hooks';
import Link from 'next/link'; import Link from 'next/link';
import {toast, ToastContainer} from "react-toastify"; import {toast, ToastContainer} from "react-toastify";
@ -37,7 +44,17 @@ export default function Login() {
const [contentType, setContentType] = useState('video'); const [contentType, setContentType] = useState('video');
const [contentPosition, setContentPosition] = useState('right'); const [contentPosition, setContentPosition] = useState('right');
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector( const [otpEmail, setOtpEmail] = useState('');
const [otpCode, setOtpCode] = useState('');
const [otpRequested, setOtpRequested] = useState(false);
const {
currentUser,
isFetching,
isRequestingOtp,
errorMessage,
token,
notify:notifyState,
} = useAppSelector(
(state) => state.auth, (state) => state.auth,
); );
const [initialValues, setInitialValues] = React.useState({ email:'admin@flatlogic.com', const [initialValues, setInitialValues] = React.useState({ email:'admin@flatlogic.com',
@ -56,6 +73,16 @@ export default function Login() {
} }
fetchData(); 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 // Fetch user data
useEffect(() => { useEffect(() => {
if (token) { if (token) {
@ -92,6 +119,52 @@ export default function Login() {
await dispatch(loginUser(rest)); 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) => { const setLogin = (target: HTMLElement) => {
setInitialValues(prev => ({ setInitialValues(prev => ({
...prev, ...prev,
@ -196,6 +269,62 @@ export default function Login() {
</CardBox> </CardBox>
<CardBox className='w-full md:w-3/5 lg:w-2/3'> <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 <Formik
initialValues={initialValues} initialValues={initialValues}
enableReinitialize enableReinitialize

View File

@ -12,14 +12,76 @@ import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons'; import BaseButtons from '../components/BaseButtons';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { getPageTitle } from '../config'; import { getPageTitle } from '../config';
import { requestEmailOtp, setAuthToken, verifyEmailOtp } from '../stores/authSlice';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
import axios from "axios"; import axios from "axios";
export default function Register() { export default function Register() {
const [loading, setLoading] = React.useState(false); 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 router = useRouter();
const dispatch = useAppDispatch();
const { isFetching, isRequestingOtp } = useAppSelector((state) => state.auth);
const notify = (type, msg) => toast( msg, {type, position: "bottom-center"}); 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) => { const handleSubmit = async (value) => {
setLoading(true) setLoading(true)
@ -39,11 +101,67 @@ export default function Register() {
return ( return (
<> <>
<Head> <Head>
<title>{getPageTitle('Login')}</title> <title>{getPageTitle('Register')}</title>
</Head> </Head>
<SectionFullScreen bg='violet'> <SectionFullScreen bg='violet'>
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'> <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 <Formik
initialValues={{ initialValues={{
email: '', email: '',

View File

@ -0,0 +1,425 @@
import React, { ReactElement, useEffect, useMemo, useState } from 'react'
import Head from 'next/head'
import Link from 'next/link'
import { useRouter } from 'next/router'
import axios from 'axios'
import {
mdiArrowLeft,
mdiCash,
mdiChartTimelineVariant,
mdiClockOutline,
mdiCrosshairsGps,
mdiMapMarkerRadiusOutline,
mdiNavigationVariantOutline,
mdiOpenInNew,
mdiPackageVariantClosed,
mdiPhoneOutline,
mdiRobotHappyOutline,
mdiShieldCheckOutline,
mdiStar,
mdiWeb,
mdiWhatsapp,
} from '@mdi/js'
import BaseButton from '../../components/BaseButton'
import BaseIcon from '../../components/BaseIcon'
import LayoutGuest from '../../layouts/Guest'
import { getPageTitle } from '../../config'
type Category = {
id: string
name: string
slug: string
description?: string
color_hex?: string
}
type Offering = {
id: string
name: string
description?: string
offering_type: 'product' | 'service'
price?: number | null
stock_status: 'in_stock' | 'limited' | 'out_of_stock' | 'by_request'
stock_label: string
stock_quantity?: number | null
is_verified?: boolean
}
type OfferingSummary = {
total: number
available: number
products: number
services: number
verified: number
top_available: Offering[]
}
type GeoScoreBreakdown = {
relevance: number
distance: number
reputation: number
activity: number
interaction: number
}
type RadiusZone = {
value: number
label: string
range: string
description?: string
}
type LiveStatus = {
status: 'open' | 'closed'
label: string
crowd: string
updated_label: string
source?: string
}
type AiRecommendation = {
label: string
reason: string
source?: string
}
type PublicPlace = {
id: string
name: string
short_description?: string
full_description?: string
address?: string
city?: string
province?: string
latitude?: number | null
longitude?: number | null
phone_number?: string
whatsapp_number?: string
website_url?: string
google_maps_url?: string
price_level?: string
average_price?: number | null
rating_average?: number | null
rating_count?: number
is_verified?: boolean
distance_km?: number | null
search_score?: number
geo_score?: number
geo_score_breakdown?: GeoScoreBreakdown
geo_score_formula?: Record<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>
}

View File

@ -4,6 +4,7 @@ import jwt from 'jsonwebtoken';
interface MainState { interface MainState {
isFetching: boolean; isFetching: boolean;
isRequestingOtp: boolean;
errorMessage: string; errorMessage: string;
currentUser: any; currentUser: any;
notify: any; notify: any;
@ -13,6 +14,7 @@ interface MainState {
const initialState: MainState = { const initialState: MainState = {
/* User */ /* User */
isFetching: false, isFetching: false,
isRequestingOtp: false,
errorMessage: '', errorMessage: '',
currentUser: null, currentUser: null,
token: '', token: '',
@ -24,6 +26,7 @@ const initialState: MainState = {
}; };
export const resetAction = createAction('auth/passwordReset/reset') export const resetAction = createAction('auth/passwordReset/reset')
export const setAuthToken = createAction<string>('auth/setAuthToken')
export const loginUser = createAsyncThunk( export const loginUser = createAsyncThunk(
'auth/loginUser', 'auth/loginUser',
@ -40,6 +43,36 @@ 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( export const passwordReset = createAsyncThunk(
'auth/passwordReset', 'auth/passwordReset',
async (value: Record<string, string>, { rejectWithValue }) => { async (value: Record<string, string>, { rejectWithValue }) => {
@ -66,6 +99,17 @@ export const findMe = createAsyncThunk('auth/findMe', async () => {
return response.data; 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({ export const authSlice = createSlice({
name: 'auth', name: 'auth',
initialState, initialState,
@ -83,20 +127,42 @@ export const authSlice = createSlice({
state.isFetching = true; state.isFetching = true;
}); });
builder.addCase(loginUser.fulfilled, (state, action) => { builder.addCase(loginUser.fulfilled, (state, action) => {
const token = action.payload; persistAuthToken(state, 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) => { builder.addCase(loginUser.rejected, (state, action) => {
state.errorMessage = String(action.payload) || 'Something went wrong. Try again'; state.errorMessage = String(action.payload) || 'Something went wrong. Try again';
state.isFetching = false; 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, () => { builder.addCase(findMe.pending, () => {
console.log('Pending findMe'); console.log('Pending findMe');
}); });