Compare commits

...

3 Commits

Author SHA1 Message Date
Flatlogic Bot
e12c93f715 Autosave: 20260619-124833 2026-06-19 12:48:30 +00:00
Flatlogic Bot
f954e3c153 Autosave: 20260619-030652 2026-06-19 03:06:49 +00:00
Flatlogic Bot
0925ce59ce Autosave: 20260618-225950 2026-06-18 22:59:48 +00:00
40 changed files with 10098 additions and 1842 deletions

359
PROJECT_LOG.md Normal file
View File

@ -0,0 +1,359 @@
# Project Log Version — VORTA Universe
Tanggal pembaruan: **19 Juni 2026**
Status: **Development / VORTA Feed MVP Active**
## Ringkasan Project
**VORTA Universe** adalah konsep ekosistem digital dengan visi:
> **Trust • Connect • Grow**
Project ini diarahkan menjadi platform super-app yang menggabungkan workspace bisnis, koneksi sosial, marketplace, dompet digital, modul AI, dan sistem skor kepercayaan dalam satu pengalaman aplikasi.
Stack project aktif saat ini:
- **Frontend:** Next.js + Tailwind CSS
- **Backend:** Node.js / Express
- **Database:** PostgreSQL + Sequelize
- **Auth:** JWT + role/permission system
- **Mode saat ini:** Development environment dengan hot reload
> Catatan: kode Express + SQLite standalone yang pernah dikirim sebelumnya diperlakukan sebagai referensi konsep. Untuk project aktif ini, integrasi sebaiknya mengikuti struktur Next.js + PostgreSQL yang sudah berjalan.
---
## Versi Saat Ini
### v0.4.0-dev — VORTA Feed MVP
Tanggal: **19 Juni 2026**
#### Fitur yang Sudah Aktif
##### 1. Halaman Khusus VORTA Feed
Halaman authenticated baru tersedia di:
- `/vorta-feed`
Halaman ini memakai endpoint native project, bukan server SQLite standalone.
Fungsi aktif:
- Memuat feed dari PostgreSQL.
- Menampilkan statistik jumlah post.
- Menampilkan statistik jumlah komentar dan reply.
- Membuat post baru.
- Membuat komentar pada post.
- Membuat reply dengan `parent_id`.
- Refresh feed.
- Reset feed demo.
- Membersihkan draft UI tanpa menghapus data database.
##### 2. Storage Posts & Comments
MVP feed memakai tabel PostgreSQL yang sudah terintegrasi dengan Sequelize:
- `vorta_social_posts`
- `vorta_social_comments`
Relasi aktif:
- Post memiliki banyak comment.
- Comment menyimpan `post_id`.
- Reply comment memakai `parent_id`.
- Data terscope ke user/organization sesuai helper VORTA Universe.
##### 3. API Feed Aktif
Endpoint yang dipakai halaman VORTA Feed:
- `GET /vorta-universe/social-feed`
- `POST /vorta-universe/posts`
- `POST /vorta-universe/posts/:postId/comments`
- `POST /vorta-universe/social-feed/reset`
Semua request frontend memakai axios relative path agar tetap lewat base `/api` project.
##### 4. Sidebar Navigation
Sidebar grup **VORTA Universe** sekarang punya menu:
- **VORTA Feed**`/vorta-feed`
- **Feed Bisnis Dashboard**`/vorta-universe#vorta-social`
##### 5. Verifikasi v0.4.0-dev
Pemeriksaan yang sudah dilakukan:
- Frontend lint: **0 error**.
- Browser public domain: halaman `/vorta-feed` berhasil dibuka.
- Load feed PostgreSQL: berhasil.
- Create post: berhasil, statistik post naik.
- Create comment: berhasil, statistik komentar naik.
- Runtime error log frontend/backend: bersih setelah pengecekan.
---
## Riwayat Versi
### v0.3.0-dev — Front Page Interaction Release
Tanggal: **19 Juni 2026**
#### Fitur yang Sudah Aktif
##### 1. Halaman Muka VORTA Universe
Halaman muka sudah berfungsi sebagai landing page interaktif untuk memperkenalkan ekosistem VORTA.
Menu utama yang aktif:
- **Beranda**
- **Workspace**
- **Super-App**
- **Skor**
- **Laporan**
Setiap menu dapat membawa pengguna ke section yang sesuai dan memperbarui status aksi di halaman.
##### 2. Hero Action Buttons
Tombol utama di hero sudah memiliki fungsi:
- **Lihat Alur Super-App**
Mengarahkan pengguna ke section pilar / super-app.
- **Jalankan Demo Skor**
Mengisi simulator skor secara otomatis dan membawa pengguna ke form skor.
- **Masuk ke Admin**
Mengarahkan pengguna ke halaman login/admin.
##### 3. Workspace Modules
Kartu workspace sudah dapat diklik dan memperbarui modul aktif:
- **Chat bisnis**
- **Feed sosial**
- **Marketplace**
- **Dompet digital**
Setiap kartu mengubah status modul aktif dan mengarahkan pengguna ke section yang relevan.
##### 4. Super-App Pillars
Pilar super-app sudah memiliki interaksi preview dan link modul.
Pilar yang tersedia:
- **Mega Super-App**
- **Vorta Nexus**
- **Facta.AI / Commerce**
- **Vorta Synapse**
Fungsi aktif:
- Tombol **Preview di halaman ini**
- Highlight **Pilar aktif**
- Link ke halaman modul:
- `/mega-super-app`
- `/vorta-universe`
- `/vorta-commerce`
- `/vorta-synapse`
##### 5. Trust Score Simulator
Form skor kepercayaan sudah berjalan dengan fungsi:
- Validasi nama kosong
- Isi contoh cepat
- Reset formulir
- Submit laporan valid
- Laporan baru langsung muncul
- Detail laporan langsung terbuka setelah submit
##### 6. Reports Section
Bagian laporan sudah memiliki aksi:
- **Jalankan Contoh**
- **Bersihkan Laporan**
- Empty state dengan tombol **Buat Contoh Sekarang**
##### 7. Verifikasi Terakhir
Pemeriksaan terakhir yang sudah dilakukan:
- Frontend lint: **0 error**
- Browser public page: berhasil dibuka dan diuji
- Tombol demo skor: berjalan
- Kartu workspace: berjalan
- Preview pilar: berjalan
- Submit form skor: berjalan
- Link modul Synapse: berhasil dibuka
- Runtime error log frontend/backend: bersih setelah pengecekan terakhir
### v0.2.0-dev — Interactive Landing Foundation
Status: selesai
Perubahan utama:
- Landing page VORTA Universe dibuat sebagai pusat pengenalan ekosistem.
- Section workspace, super-app, skor, dan laporan mulai disusun.
- Struktur konten diarahkan ke konsep super-app digital.
- Tombol dan menu mulai disiapkan untuk interaksi pengguna.
### v0.1.0-dev — Initial Concept
Status: selesai
Perubahan utama:
- Konsep awal **VORTA Universe** dibuat.
- Visi utama ditetapkan: **Trust • Connect • Grow**.
- Ide fitur awal:
- Website utama
- Produk digital
- Lokasi / informasi perusahaan
- Kontak
- Login / admin
- Feed/posting sosial
- Komentar bertingkat
Catatan teknis:
- Konsep awal sempat ditulis dalam bentuk server Express + SQLite standalone.
- Untuk project aktif, konsep tersebut akan diadaptasi ke arsitektur yang sudah tersedia: Next.js + Express + PostgreSQL.
---
## Backlog / Roadmap Berikutnya
### Prioritas 1 — Penyempurnaan VORTA Feed
Status: MVP selesai pada `v0.4.0-dev`.
Lanjutan yang disarankan:
- Tambahkan edit/delete post.
- Tambahkan edit/delete komentar.
- Tambahkan like/reaction.
- Tambahkan upload gambar/video post.
- Tambahkan filter/pencarian feed.
- Tambahkan pagination atau infinite scroll.
- Tambahkan moderasi/admin review.
### Prioritas 2 — Public Website Content
Tujuan:
Memperkuat website utama agar siap menjadi halaman publik VORTA Universe.
Rencana fitur:
- Section Tentang Kami
- Section Produk
- Section Lokasi
- Section Hubungi Kami
- CTA menuju login/admin
- CTA menuju demo skor
- Konten SEO dasar
### Prioritas 3 — Super-App Module Pages
Tujuan:
Menyempurnakan halaman detail untuk setiap pilar.
Halaman target:
- Mega Super-App
- Vorta Nexus
- Vorta Commerce / Facta.AI
- Vorta Synapse
Rencana fitur:
- Deskripsi modul
- Use case
- Status fitur
- Tombol demo / simulasi
- Link antar modul
### Prioritas 4 — Trust Score Data Persistence
Tujuan:
Menyimpan laporan skor kepercayaan ke database, bukan hanya state halaman.
Rencana teknis:
- Buat entity Trust Reports
- Simpan nama, kategori, skor, catatan, dan status
- Tambahkan list laporan dari backend
- Tambahkan detail laporan
- Tambahkan filter dan pencarian
### Prioritas 5 — Admin Dashboard VORTA
Tujuan:
Membuat admin dashboard untuk mengelola data ekosistem.
Rencana fitur:
- Ringkasan statistik
- Jumlah laporan skor
- Jumlah post/feed
- Jumlah komentar
- Aktivitas terbaru
- Manajemen user jika dibutuhkan
---
## Catatan Integrasi Kode Express + SQLite
Kode standalone yang menggunakan:
```js
const sqlite3 = require('sqlite3').verbose();
const db = new sqlite3.Database('vorta.db');
```
Tidak akan ditempel langsung ke project aktif karena project ini sudah menggunakan PostgreSQL dan Sequelize.
Adaptasi yang benar:
| Konsep Lama | Adaptasi Project Aktif |
| --- | --- |
| Express standalone `/` | Next.js page di `frontend/src/pages` |
| SQLite `vorta.db` | PostgreSQL via Sequelize |
| `CREATE TABLE posts` manual | Sequelize migration + model |
| `CREATE TABLE comments` manual | Sequelize migration + model |
| HTML string di `res.send` | React component + Tailwind |
| Manual CSS dalam `<style>` | Tailwind + existing theme system |
---
## Definition of Done Versi Berikutnya
Versi berikutnya dapat dianggap selesai jika:
- Posts & comments sudah tersimpan di PostgreSQL
- Feed page bisa menampilkan daftar post
- Pengguna bisa membuat post baru
- Pengguna bisa memberi komentar
- Reply bertingkat berjalan minimal 1 level
- Halaman tetap lolos lint tanpa error
- Runtime log backend/frontend bersih setelah pengujian
---
## Catatan Pemilik Project
Nama project: **VORTA Universe**
Tagline: **Trust • Connect • Grow**
Arah produk: **Super-app ecosystem with trust, commerce, social, workspace, and AI modules**

View File

@ -0,0 +1,133 @@
module.exports = {
async up(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
const tables = await queryInterface.showAllTables({ transaction });
const tableNames = tables.map((table) => (typeof table === 'object' ? table.tableName : table));
if (!tableNames.includes('vorta_commerce_states')) {
await queryInterface.createTable(
'vorta_commerce_states',
{
id: {
type: Sequelize.DataTypes.UUID,
defaultValue: Sequelize.DataTypes.UUIDV4,
primaryKey: true,
},
userId: {
type: Sequelize.DataTypes.UUID,
references: {
model: 'users',
key: 'id',
},
},
storeId: {
type: Sequelize.DataTypes.UUID,
references: {
model: 'stores',
key: 'id',
},
},
organizationsId: {
type: Sequelize.DataTypes.UUID,
references: {
model: 'organizations',
key: 'id',
},
},
settings: {
type: Sequelize.DataTypes.JSONB,
allowNull: false,
defaultValue: {},
},
campaigns: {
type: Sequelize.DataTypes.JSONB,
allowNull: false,
defaultValue: [],
},
ledger: {
type: Sequelize.DataTypes.JSONB,
allowNull: false,
defaultValue: [],
},
product_meta: {
type: Sequelize.DataTypes.JSONB,
allowNull: false,
defaultValue: {},
},
customer_meta: {
type: Sequelize.DataTypes.JSONB,
allowNull: false,
defaultValue: {},
},
order_meta: {
type: Sequelize.DataTypes.JSONB,
allowNull: false,
defaultValue: {},
},
shipment_meta: {
type: Sequelize.DataTypes.JSONB,
allowNull: false,
defaultValue: {},
},
importHash: {
type: Sequelize.DataTypes.STRING(255),
allowNull: true,
unique: true,
},
createdById: {
type: Sequelize.DataTypes.UUID,
references: {
model: 'users',
key: 'id',
},
},
updatedById: {
type: Sequelize.DataTypes.UUID,
references: {
model: 'users',
key: 'id',
},
},
createdAt: {
type: Sequelize.DataTypes.DATE,
},
updatedAt: {
type: Sequelize.DataTypes.DATE,
},
deletedAt: {
type: Sequelize.DataTypes.DATE,
},
},
{ transaction },
);
}
await transaction.commit();
} catch (error) {
await transaction.rollback();
console.error('Failed to create vorta_commerce_states table:', error);
throw error;
}
},
async down(queryInterface) {
const transaction = await queryInterface.sequelize.transaction();
try {
const tables = await queryInterface.showAllTables({ transaction });
const tableNames = tables.map((table) => (typeof table === 'object' ? table.tableName : table));
if (tableNames.includes('vorta_commerce_states')) {
await queryInterface.dropTable('vorta_commerce_states', { transaction });
}
await transaction.commit();
} catch (error) {
await transaction.rollback();
console.error('Failed to drop vorta_commerce_states table:', error);
throw error;
}
},
};

View File

@ -0,0 +1,116 @@
module.exports = {
async up(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
const tables = await queryInterface.showAllTables({ transaction });
const tableNames = tables.map((table) => (typeof table === 'object' ? table.tableName : table));
if (!tableNames.includes('vorta_universe_states')) {
await queryInterface.createTable(
'vorta_universe_states',
{
id: {
type: Sequelize.DataTypes.UUID,
defaultValue: Sequelize.DataTypes.UUIDV4,
primaryKey: true,
},
userId: {
type: Sequelize.DataTypes.UUID,
references: {
model: 'users',
key: 'id',
},
},
organizationsId: {
type: Sequelize.DataTypes.UUID,
references: {
model: 'organizations',
key: 'id',
},
},
profile: {
type: Sequelize.DataTypes.JSONB,
allowNull: false,
defaultValue: {},
},
modules: {
type: Sequelize.DataTypes.JSONB,
allowNull: false,
defaultValue: [],
},
products: {
type: Sequelize.DataTypes.JSONB,
allowNull: false,
defaultValue: [],
},
ledger: {
type: Sequelize.DataTypes.JSONB,
allowNull: false,
defaultValue: [],
},
settings: {
type: Sequelize.DataTypes.JSONB,
allowNull: false,
defaultValue: {},
},
importHash: {
type: Sequelize.DataTypes.STRING(255),
allowNull: true,
unique: true,
},
createdById: {
type: Sequelize.DataTypes.UUID,
references: {
model: 'users',
key: 'id',
},
},
updatedById: {
type: Sequelize.DataTypes.UUID,
references: {
model: 'users',
key: 'id',
},
},
createdAt: {
type: Sequelize.DataTypes.DATE,
},
updatedAt: {
type: Sequelize.DataTypes.DATE,
},
deletedAt: {
type: Sequelize.DataTypes.DATE,
},
},
{ transaction },
);
}
await transaction.commit();
} catch (error) {
await transaction.rollback();
console.error('Failed to create vorta_universe_states table:', error);
throw error;
}
},
async down(queryInterface) {
const transaction = await queryInterface.sequelize.transaction();
try {
const tables = await queryInterface.showAllTables({ transaction });
const tableNames = tables.map((table) => (typeof table === 'object' ? table.tableName : table));
if (tableNames.includes('vorta_universe_states')) {
await queryInterface.dropTable('vorta_universe_states', { transaction });
}
await transaction.commit();
} catch (error) {
await transaction.rollback();
console.error('Failed to drop vorta_universe_states table:', error);
throw error;
}
},
};

View File

@ -0,0 +1,116 @@
module.exports = {
async up(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
const tables = await queryInterface.showAllTables({ transaction });
const tableNames = tables.map((table) => (typeof table === 'object' ? table.tableName : table));
if (!tableNames.includes('vorta_login_otps')) {
await queryInterface.createTable(
'vorta_login_otps',
{
id: {
type: Sequelize.DataTypes.UUID,
defaultValue: Sequelize.DataTypes.UUIDV4,
primaryKey: true,
},
userId: {
type: Sequelize.DataTypes.UUID,
references: {
model: 'users',
key: 'id',
},
},
organizationsId: {
type: Sequelize.DataTypes.UUID,
references: {
model: 'organizations',
key: 'id',
},
},
phone: {
type: Sequelize.DataTypes.STRING(40),
allowNull: false,
},
otpHash: {
type: Sequelize.DataTypes.STRING(255),
allowNull: false,
},
status: {
type: Sequelize.DataTypes.STRING(40),
allowNull: false,
defaultValue: 'pending',
},
attempts: {
type: Sequelize.DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
},
expiresAt: {
type: Sequelize.DataTypes.DATE,
allowNull: false,
},
verifiedAt: {
type: Sequelize.DataTypes.DATE,
},
importHash: {
type: Sequelize.DataTypes.STRING(255),
allowNull: true,
unique: true,
},
createdById: {
type: Sequelize.DataTypes.UUID,
references: {
model: 'users',
key: 'id',
},
},
updatedById: {
type: Sequelize.DataTypes.UUID,
references: {
model: 'users',
key: 'id',
},
},
createdAt: {
type: Sequelize.DataTypes.DATE,
},
updatedAt: {
type: Sequelize.DataTypes.DATE,
},
deletedAt: {
type: Sequelize.DataTypes.DATE,
},
},
{ transaction },
);
}
await transaction.commit();
} catch (error) {
await transaction.rollback();
console.error('Failed to create vorta_login_otps table:', error);
throw error;
}
},
async down(queryInterface) {
const transaction = await queryInterface.sequelize.transaction();
try {
const tables = await queryInterface.showAllTables({ transaction });
const tableNames = tables.map((table) => (typeof table === 'object' ? table.tableName : table));
if (tableNames.includes('vorta_login_otps')) {
await queryInterface.dropTable('vorta_login_otps', { transaction });
}
await transaction.commit();
} catch (error) {
await transaction.rollback();
console.error('Failed to drop vorta_login_otps table:', error);
throw error;
}
},
};

View File

@ -0,0 +1,144 @@
module.exports = {
async up(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
const tables = await queryInterface.showAllTables({ transaction });
const tableNames = tables.map((table) => (typeof table === 'object' ? table.tableName : table));
if (!tableNames.includes('vorta_social_posts')) {
await queryInterface.createTable(
'vorta_social_posts',
{
id: {
type: Sequelize.DataTypes.UUID,
defaultValue: Sequelize.DataTypes.UUIDV4,
primaryKey: true,
},
userId: {
type: Sequelize.DataTypes.UUID,
references: {
model: 'users',
key: 'id',
},
},
organizationsId: {
type: Sequelize.DataTypes.UUID,
references: {
model: 'organizations',
key: 'id',
},
},
user: {
type: Sequelize.DataTypes.STRING(255),
allowNull: false,
},
content: {
type: Sequelize.DataTypes.TEXT,
allowNull: false,
},
created_at: {
type: Sequelize.DataTypes.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
importHash: {
type: Sequelize.DataTypes.STRING(255),
allowNull: true,
unique: true,
},
},
{ transaction },
);
}
if (!tableNames.includes('vorta_social_comments')) {
await queryInterface.createTable(
'vorta_social_comments',
{
id: {
type: Sequelize.DataTypes.UUID,
defaultValue: Sequelize.DataTypes.UUIDV4,
primaryKey: true,
},
post_id: {
type: Sequelize.DataTypes.UUID,
allowNull: false,
references: {
model: 'vorta_social_posts',
key: 'id',
},
},
userId: {
type: Sequelize.DataTypes.UUID,
references: {
model: 'users',
key: 'id',
},
},
organizationsId: {
type: Sequelize.DataTypes.UUID,
references: {
model: 'organizations',
key: 'id',
},
},
user: {
type: Sequelize.DataTypes.STRING(255),
allowNull: false,
},
comment: {
type: Sequelize.DataTypes.TEXT,
allowNull: false,
},
parent_id: {
type: Sequelize.DataTypes.STRING(255),
allowNull: false,
defaultValue: '0',
},
created_at: {
type: Sequelize.DataTypes.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
importHash: {
type: Sequelize.DataTypes.STRING(255),
allowNull: true,
unique: true,
},
},
{ transaction },
);
}
await transaction.commit();
} catch (error) {
await transaction.rollback();
console.error('Failed to create VORTA social feed tables:', error);
throw error;
}
},
async down(queryInterface) {
const transaction = await queryInterface.sequelize.transaction();
try {
const tables = await queryInterface.showAllTables({ transaction });
const tableNames = tables.map((table) => (typeof table === 'object' ? table.tableName : table));
if (tableNames.includes('vorta_social_comments')) {
await queryInterface.dropTable('vorta_social_comments', { transaction });
}
if (tableNames.includes('vorta_social_posts')) {
await queryInterface.dropTable('vorta_social_posts', { transaction });
}
await transaction.commit();
} catch (error) {
await transaction.rollback();
console.error('Failed to drop VORTA social feed tables:', error);
throw error;
}
},
};

View File

@ -0,0 +1,93 @@
module.exports = function(sequelize, DataTypes) {
const vorta_commerce_states = sequelize.define(
'vorta_commerce_states',
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
settings: {
type: DataTypes.JSONB,
allowNull: false,
defaultValue: {},
},
campaigns: {
type: DataTypes.JSONB,
allowNull: false,
defaultValue: [],
},
ledger: {
type: DataTypes.JSONB,
allowNull: false,
defaultValue: [],
},
product_meta: {
type: DataTypes.JSONB,
allowNull: false,
defaultValue: {},
},
customer_meta: {
type: DataTypes.JSONB,
allowNull: false,
defaultValue: {},
},
order_meta: {
type: DataTypes.JSONB,
allowNull: false,
defaultValue: {},
},
shipment_meta: {
type: DataTypes.JSONB,
allowNull: false,
defaultValue: {},
},
importHash: {
type: DataTypes.STRING(255),
allowNull: true,
unique: true,
},
},
{
timestamps: true,
paranoid: true,
freezeTableName: true,
},
);
vorta_commerce_states.associate = (db) => {
db.vorta_commerce_states.belongsTo(db.users, {
as: 'user',
foreignKey: {
name: 'userId',
},
constraints: false,
});
db.vorta_commerce_states.belongsTo(db.stores, {
as: 'store',
foreignKey: {
name: 'storeId',
},
constraints: false,
});
db.vorta_commerce_states.belongsTo(db.organizations, {
as: 'organizations',
foreignKey: {
name: 'organizationsId',
},
constraints: false,
});
db.vorta_commerce_states.belongsTo(db.users, {
as: 'createdBy',
});
db.vorta_commerce_states.belongsTo(db.users, {
as: 'updatedBy',
});
};
return vorta_commerce_states;
};

View File

@ -0,0 +1,75 @@
module.exports = function(sequelize, DataTypes) {
const vorta_login_otps = sequelize.define(
'vorta_login_otps',
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
phone: {
type: DataTypes.STRING(40),
allowNull: false,
},
otpHash: {
type: DataTypes.STRING(255),
allowNull: false,
},
status: {
type: DataTypes.STRING(40),
allowNull: false,
defaultValue: 'pending',
},
attempts: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
},
expiresAt: {
type: DataTypes.DATE,
allowNull: false,
},
verifiedAt: {
type: DataTypes.DATE,
},
importHash: {
type: DataTypes.STRING(255),
allowNull: true,
unique: true,
},
},
{
timestamps: true,
paranoid: true,
freezeTableName: true,
},
);
vorta_login_otps.associate = (db) => {
db.vorta_login_otps.belongsTo(db.users, {
as: 'user',
foreignKey: {
name: 'userId',
},
constraints: false,
});
db.vorta_login_otps.belongsTo(db.organizations, {
as: 'organizations',
foreignKey: {
name: 'organizationsId',
},
constraints: false,
});
db.vorta_login_otps.belongsTo(db.users, {
as: 'createdBy',
});
db.vorta_login_otps.belongsTo(db.users, {
as: 'updatedBy',
});
};
return vorta_login_otps;
};

View File

@ -0,0 +1,71 @@
module.exports = function(sequelize, DataTypes) {
const vorta_social_comments = sequelize.define(
'vorta_social_comments',
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
post_id: {
type: DataTypes.UUID,
allowNull: false,
},
user: {
type: DataTypes.STRING(255),
allowNull: false,
},
comment: {
type: DataTypes.TEXT,
allowNull: false,
},
parent_id: {
type: DataTypes.STRING(255),
allowNull: false,
defaultValue: '0',
},
created_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
importHash: {
type: DataTypes.STRING(255),
allowNull: true,
unique: true,
},
},
{
timestamps: false,
freezeTableName: true,
},
);
vorta_social_comments.associate = (db) => {
db.vorta_social_comments.belongsTo(db.vorta_social_posts, {
as: 'post',
foreignKey: {
name: 'post_id',
},
constraints: false,
});
db.vorta_social_comments.belongsTo(db.users, {
as: 'account',
foreignKey: {
name: 'userId',
},
constraints: false,
});
db.vorta_social_comments.belongsTo(db.organizations, {
as: 'organizations',
foreignKey: {
name: 'organizationsId',
},
constraints: false,
});
};
return vorta_social_comments;
};

View File

@ -0,0 +1,62 @@
module.exports = function(sequelize, DataTypes) {
const vorta_social_posts = sequelize.define(
'vorta_social_posts',
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
user: {
type: DataTypes.STRING(255),
allowNull: false,
},
content: {
type: DataTypes.TEXT,
allowNull: false,
},
created_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
importHash: {
type: DataTypes.STRING(255),
allowNull: true,
unique: true,
},
},
{
timestamps: false,
freezeTableName: true,
},
);
vorta_social_posts.associate = (db) => {
db.vorta_social_posts.hasMany(db.vorta_social_comments, {
as: 'comments',
foreignKey: {
name: 'post_id',
},
constraints: false,
});
db.vorta_social_posts.belongsTo(db.users, {
as: 'account',
foreignKey: {
name: 'userId',
},
constraints: false,
});
db.vorta_social_posts.belongsTo(db.organizations, {
as: 'organizations',
foreignKey: {
name: 'organizationsId',
},
constraints: false,
});
};
return vorta_social_posts;
};

View File

@ -0,0 +1,75 @@
module.exports = function(sequelize, DataTypes) {
const vorta_universe_states = sequelize.define(
'vorta_universe_states',
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
profile: {
type: DataTypes.JSONB,
allowNull: false,
defaultValue: {},
},
modules: {
type: DataTypes.JSONB,
allowNull: false,
defaultValue: [],
},
products: {
type: DataTypes.JSONB,
allowNull: false,
defaultValue: [],
},
ledger: {
type: DataTypes.JSONB,
allowNull: false,
defaultValue: [],
},
settings: {
type: DataTypes.JSONB,
allowNull: false,
defaultValue: {},
},
importHash: {
type: DataTypes.STRING(255),
allowNull: true,
unique: true,
},
},
{
timestamps: true,
paranoid: true,
freezeTableName: true,
},
);
vorta_universe_states.associate = (db) => {
db.vorta_universe_states.belongsTo(db.users, {
as: 'user',
foreignKey: {
name: 'userId',
},
constraints: false,
});
db.vorta_universe_states.belongsTo(db.organizations, {
as: 'organizations',
foreignKey: {
name: 'organizationsId',
},
constraints: false,
});
db.vorta_universe_states.belongsTo(db.users, {
as: 'createdBy',
});
db.vorta_universe_states.belongsTo(db.users, {
as: 'updatedBy',
});
};
return vorta_universe_states;
};

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');
@ -20,6 +19,8 @@ const pexelsRoutes = require('./routes/pexels');
const organizationForAuthRoutes = require('./routes/organizationLogin'); const organizationForAuthRoutes = require('./routes/organizationLogin');
const openaiRoutes = require('./routes/openai'); const openaiRoutes = require('./routes/openai');
const vortaCommerceRoutes = require('./routes/vortaCommerce');
const vortaUniverseRoutes = require('./routes/vortaUniverse');
@ -245,6 +246,9 @@ app.use('/api/api_clients', passport.authenticate('jwt', {session: false}), api_
app.use('/api/audit_events', passport.authenticate('jwt', {session: false}), audit_eventsRoutes); app.use('/api/audit_events', passport.authenticate('jwt', {session: false}), audit_eventsRoutes);
app.use('/api/vorta-commerce', passport.authenticate('jwt', {session: false}), vortaCommerceRoutes);
app.use('/api/vorta-universe', passport.authenticate('jwt', {session: false}), vortaUniverseRoutes);
app.use( app.use(
'/api/openai', '/api/openai',
passport.authenticate('jwt', { session: false }), passport.authenticate('jwt', { session: false }),

View File

@ -0,0 +1,52 @@
const express = require('express');
const VortaCommerceService = require('../services/vortaCommerce');
const wrapAsync = require('../helpers').wrapAsync;
const router = express.Router();
router.get('/state', wrapAsync(async (req, res) => {
const payload = await VortaCommerceService.getState(req.currentUser);
res.status(200).send(payload);
}));
router.post('/orders/demo', wrapAsync(async (req, res) => {
const payload = await VortaCommerceService.createDemoOrder(req.currentUser);
res.status(200).send(payload);
}));
router.post('/inventory/restock', wrapAsync(async (req, res) => {
const payload = await VortaCommerceService.restockLowStock(req.currentUser);
res.status(200).send(payload);
}));
router.post('/payments/:paymentReference/capture', wrapAsync(async (req, res) => {
const payload = await VortaCommerceService.capturePayment(req.currentUser, req.params.paymentReference);
res.status(200).send(payload);
}));
router.post('/orders/:orderNumber/advance', wrapAsync(async (req, res) => {
const payload = await VortaCommerceService.advanceOrder(req.currentUser, req.params.orderNumber);
res.status(200).send(payload);
}));
router.post('/shipments/:trackingNumber/complete', wrapAsync(async (req, res) => {
const payload = await VortaCommerceService.completeShipment(req.currentUser, req.params.trackingNumber);
res.status(200).send(payload);
}));
router.post('/campaigns/:campaignId/launch', wrapAsync(async (req, res) => {
const payload = await VortaCommerceService.launchCampaign(req.currentUser, req.params.campaignId);
res.status(200).send(payload);
}));
router.post('/reports/close', wrapAsync(async (req, res) => {
const payload = await VortaCommerceService.closeDailyReport(req.currentUser);
res.status(200).send(payload);
}));
router.patch('/settings', wrapAsync(async (req, res) => {
const payload = await VortaCommerceService.updateSettings(req.currentUser, req.body.data || {});
res.status(200).send(payload);
}));
module.exports = router;

View File

@ -0,0 +1,71 @@
const express = require('express');
const VortaUniverseService = require('../services/vortaUniverse');
const wrapAsync = require('../helpers').wrapAsync;
const router = express.Router();
router.get('/state', wrapAsync(async (req, res) => {
const payload = await VortaUniverseService.getState(req.currentUser);
res.status(200).send(payload);
}));
router.post('/state/reset', wrapAsync(async (req, res) => {
const payload = await VortaUniverseService.resetState(req.currentUser);
res.status(200).send(payload);
}));
router.get('/social-feed', wrapAsync(async (req, res) => {
const payload = await VortaUniverseService.getSocialFeed(req.currentUser);
res.status(200).send(payload);
}));
router.post('/social-feed/reset', wrapAsync(async (req, res) => {
const payload = await VortaUniverseService.resetSocialFeed(req.currentUser);
res.status(200).send(payload);
}));
router.post('/posts', wrapAsync(async (req, res) => {
const payload = await VortaUniverseService.createSocialPost(req.currentUser, req.body.data || req.body || {});
res.status(200).send(payload);
}));
router.post('/posts/:postId/comments', wrapAsync(async (req, res) => {
const payload = await VortaUniverseService.createSocialComment(
req.currentUser,
req.params.postId,
req.body.data || req.body || {},
);
res.status(200).send(payload);
}));
router.post('/auth/otp/request', wrapAsync(async (req, res) => {
const payload = await VortaUniverseService.requestOtp(req.currentUser, req.body.data || req.body || {});
res.status(200).send(payload);
}));
router.post('/auth/otp/verify', wrapAsync(async (req, res) => {
const payload = await VortaUniverseService.verifyOtp(req.currentUser, req.body.data || req.body || {});
res.status(200).send(payload);
}));
router.patch('/trust-score', wrapAsync(async (req, res) => {
const payload = await VortaUniverseService.updateTrustScore(req.currentUser, req.body.data || req.body || {});
res.status(200).send(payload);
}));
router.post('/modules/:moduleId/run', wrapAsync(async (req, res) => {
const payload = await VortaUniverseService.runModule(req.currentUser, req.params.moduleId);
res.status(200).send(payload);
}));
router.post('/products', wrapAsync(async (req, res) => {
const payload = await VortaUniverseService.createProduct(req.currentUser, req.body.data || req.body || {});
res.status(200).send(payload);
}));
router.post('/products/demo', wrapAsync(async (req, res) => {
const payload = await VortaUniverseService.createDemoProduct(req.currentUser);
res.status(200).send(payload);
}));
module.exports = router;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,763 @@
const bcrypt = require('bcrypt');
const db = require('../db/models');
const DEFAULT_PROFILE = {
businessName: 'VORTA Nexus Demo',
trustScore: 50,
verified: false,
walletBalance: 1850000,
geoseekRadiusKm: 7,
aiQuota: 32,
phone: '',
phoneVerified: false,
};
const DEFAULT_SETTINGS = {
trustAutoSync: true,
marketplaceMode: 'Demo Aktif',
riskReview: 'Medium',
};
const DEFAULT_MODULES = [
{
id: 'business-chat',
icon: '💬',
name: 'Chat Bisnis',
description: 'Inbox komersial untuk buyer, seller, partner, dan tim internal.',
metric: '128 percakapan',
status: 'Online',
trustImpact: 2,
launches: 0,
},
{
id: 'marketplace',
icon: '🛒',
name: 'Marketplace',
description: 'Etalase produk, seller score, katalog cepat, dan aktivitas listing.',
metric: '24 listing aktif',
status: 'Online',
trustImpact: 3,
launches: 0,
},
{
id: 'wallet',
icon: '💳',
name: 'Wallet',
description: 'Saldo, pembayaran, reward, dan settlement untuk pengguna VORTA.',
metric: 'Rp1,85 jt saldo',
status: 'Online',
trustImpact: 2,
launches: 0,
},
{
id: 'geoseek',
icon: '🌍',
name: 'GeoSeek',
description: 'Pencarian peluang, mitra, dan kebutuhan berdasarkan lokasi.',
metric: '7 km radius',
status: 'Beta',
trustImpact: 1,
launches: 0,
},
{
id: 'vorta-ai',
icon: '🤖',
name: 'VORTA AI',
description: 'Asisten AI untuk ide bisnis, analisis produk, dan rekomendasi aksi.',
metric: '32 prompt tersisa',
status: 'Siap',
trustImpact: 2,
launches: 0,
},
{
id: 'trust-system',
icon: '⭐',
name: 'Trust System',
description: 'Nexus Trust Score, validasi profil, reputasi transaksi, dan sinyal risiko.',
metric: 'Score dasar 50',
status: 'Aktif',
trustImpact: 4,
launches: 0,
},
];
const DEFAULT_PRODUCTS = [
{
id: 'VU-PRD-001',
name: 'VORTA Starter Kit',
price: 149000,
seller: 'Nexus Seller Lab',
category: 'Digital Toolkit',
stock: 120,
},
{
id: 'VU-PRD-002',
name: 'GeoSeek Local Leads',
price: 99000,
seller: 'VORTA Data Market',
category: 'Lead Pack',
stock: 45,
},
{
id: 'VU-PRD-003',
name: 'Trust Boost Verification',
price: 199000,
seller: 'VORTA Trust Desk',
category: 'Verification',
stock: 18,
},
];
const DEFAULT_LEDGER = [
{
id: 'VU-LOG-1',
title: 'Nexus Trust Score aktif',
detail: 'Baseline score dimulai dari 50 dan tersimpan di database PostgreSQL.',
time: 'Baru saja',
tone: 'success',
},
{
id: 'VU-LOG-2',
title: 'Marketplace demo tersambung',
detail: 'Produk awal siap diuji tanpa membuat aplikasi Express/SQLite terpisah.',
time: 'Baru saja',
tone: 'info',
},
];
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
const cloneJson = (value) => JSON.parse(JSON.stringify(value));
const buildHttpError = (message, code = 400) => {
const error = new Error(message);
error.code = code;
return error;
};
const toNumber = (value, fallback = 0) => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
};
const getOrganizationId = (currentUser) => currentUser?.organizationId || currentUser?.organizationsId || null;
const makeImportHash = (scope, currentUser) => {
const userId = currentUser?.id || 'anonymous';
const organizationId = getOrganizationId(currentUser) || 'global';
return `vorta-universe-${organizationId}-${userId}-${scope}`;
};
const nowLabel = () => new Intl.DateTimeFormat('id-ID', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date());
const getDisplayName = (currentUser) => {
const firstName = currentUser?.firstName || '';
const lastName = currentUser?.lastName || '';
const fullName = `${firstName} ${lastName}`.trim();
return fullName || currentUser?.email || 'Founder VORTA';
};
const getScopedWhere = (currentUser) => ({
userId: currentUser?.id || null,
organizationsId: getOrganizationId(currentUser),
});
const normalizePhone = (phone) => String(phone || '').replace(/[^0-9+]/g, '').trim();
const makeOtp = () => String(Math.floor(100000 + Math.random() * 900000));
class VortaUniverseService {
static normalizeState(record) {
const profile = {
...cloneJson(DEFAULT_PROFILE),
...(record.profile || {}),
};
const modules = Array.isArray(record.modules) && record.modules.length
? record.modules
: cloneJson(DEFAULT_MODULES);
const products = Array.isArray(record.products) && record.products.length
? record.products
: cloneJson(DEFAULT_PRODUCTS);
const ledger = Array.isArray(record.ledger) && record.ledger.length
? record.ledger
: cloneJson(DEFAULT_LEDGER);
return {
profile: {
...profile,
trustScore: clamp(toNumber(profile.trustScore, DEFAULT_PROFILE.trustScore), 0, 100),
verified: Boolean(profile.verified || toNumber(profile.trustScore, 0) >= 80),
},
modules,
products,
ledger,
settings: {
...cloneJson(DEFAULT_SETTINGS),
...(record.settings || {}),
},
persistedAt: new Date().toISOString(),
};
}
static async ensureState(currentUser, transaction) {
const importHash = makeImportHash('state', currentUser);
const where = { importHash };
const existing = await db.vorta_universe_states.findOne({ where, transaction });
if (existing) {
return existing;
}
return db.vorta_universe_states.create(
{
userId: currentUser?.id || null,
organizationsId: getOrganizationId(currentUser),
profile: cloneJson(DEFAULT_PROFILE),
modules: cloneJson(DEFAULT_MODULES),
products: cloneJson(DEFAULT_PRODUCTS),
ledger: cloneJson(DEFAULT_LEDGER),
settings: cloneJson(DEFAULT_SETTINGS),
importHash,
createdById: currentUser?.id || null,
updatedById: currentUser?.id || null,
},
{ transaction },
);
}
static async getState(currentUser) {
const state = await db.sequelize.transaction(async (transaction) => this.ensureState(currentUser, transaction));
return this.normalizeState(state);
}
static async resetState(currentUser) {
await db.sequelize.transaction(async (transaction) => {
const state = await this.ensureState(currentUser, transaction);
await state.update(
{
profile: cloneJson(DEFAULT_PROFILE),
modules: cloneJson(DEFAULT_MODULES),
products: cloneJson(DEFAULT_PRODUCTS),
ledger: this.addLedgerItem(cloneJson(DEFAULT_LEDGER), {
title: 'State VORTA di-reset',
detail: 'Profile, module, produk, setting, dan ledger kembali ke data demo awal.',
tone: 'warning',
}),
settings: cloneJson(DEFAULT_SETTINGS),
updatedById: currentUser?.id || null,
},
{ transaction },
);
});
return this.getState(currentUser);
}
static addLedgerItem(ledger, item) {
return [
{
id: `VU-LOG-${Date.now()}`,
time: nowLabel(),
tone: 'info',
...item,
},
...(Array.isArray(ledger) ? ledger : []),
].slice(0, 12);
}
static async updateTrustScore(currentUser, payload) {
const requestedScore = toNumber(payload.score, Number.NaN);
if (!Number.isFinite(requestedScore)) {
throw buildHttpError('Score harus berupa angka 0 sampai 100.');
}
const nextScore = clamp(Math.round(requestedScore), 0, 100);
await db.sequelize.transaction(async (transaction) => {
const state = await this.ensureState(currentUser, transaction);
const current = this.normalizeState(state);
const previousScore = current.profile.trustScore;
await state.update(
{
profile: {
...current.profile,
trustScore: nextScore,
verified: nextScore >= 80,
},
ledger: this.addLedgerItem(current.ledger, {
title: 'Nexus Trust Score diperbarui',
detail: `Score berubah dari ${previousScore} ke ${nextScore}.`,
tone: nextScore >= previousScore ? 'success' : 'warning',
}),
updatedById: currentUser?.id || null,
},
{ transaction },
);
});
return this.getState(currentUser);
}
static async runModule(currentUser, moduleId) {
await db.sequelize.transaction(async (transaction) => {
const state = await this.ensureState(currentUser, transaction);
const current = this.normalizeState(state);
const target = current.modules.find((item) => item.id === moduleId);
if (!target) {
throw buildHttpError('Module VORTA Universe tidak ditemukan.', 404);
}
const modules = current.modules.map((item) => (
item.id === moduleId
? {
...item,
launches: toNumber(item.launches) + 1,
lastRun: new Date().toISOString(),
status: item.status === 'Beta' ? 'Beta Aktif' : 'Online',
}
: item
));
const nextScore = clamp(current.profile.trustScore + toNumber(target.trustImpact, 1), 0, 100);
await state.update(
{
profile: {
...current.profile,
trustScore: nextScore,
verified: nextScore >= 80,
},
modules,
ledger: this.addLedgerItem(current.ledger, {
title: `${target.name} dijalankan`,
detail: `Module ${target.name} aktif dan menambah ${toNumber(target.trustImpact, 1)} poin trust.`,
tone: 'success',
}),
updatedById: currentUser?.id || null,
},
{ transaction },
);
});
return this.getState(currentUser);
}
static buildProduct(payload, productNumber) {
const name = typeof payload.name === 'string' ? payload.name.trim() : '';
if (!name) {
throw buildHttpError('Nama produk wajib diisi.');
}
return {
id: payload.id || `VU-PRD-${String(productNumber).padStart(3, '0')}`,
name,
price: clamp(Math.round(toNumber(payload.price, 0)), 0, 999999999),
seller: payload.seller || 'VORTA Universe Seller',
category: payload.category || 'General',
stock: clamp(Math.round(toNumber(payload.stock, 1)), 0, 999999),
};
}
static async createProduct(currentUser, payload) {
await db.sequelize.transaction(async (transaction) => {
const state = await this.ensureState(currentUser, transaction);
const current = this.normalizeState(state);
const product = this.buildProduct(payload, current.products.length + 1);
await state.update(
{
products: [product, ...current.products].slice(0, 20),
ledger: this.addLedgerItem(current.ledger, {
title: 'Produk marketplace ditambahkan',
detail: `${product.name} dari ${product.seller} tersimpan di VORTA Universe.`,
tone: 'success',
}),
updatedById: currentUser?.id || null,
},
{ transaction },
);
});
return this.getState(currentUser);
}
static async addStateLedger(currentUser, item, transaction) {
const state = await this.ensureState(currentUser, transaction);
const current = this.normalizeState(state);
await state.update(
{
ledger: this.addLedgerItem(current.ledger, item),
updatedById: currentUser?.id || null,
},
{ transaction },
);
}
static serializeComment(comment) {
const row = comment.get ? comment.get({ plain: true }) : comment;
return {
id: row.id,
post_id: row.post_id,
user: row.user,
comment: row.comment,
parent_id: row.parent_id || '0',
created_at: row.created_at,
};
}
static serializePost(post, comments) {
const row = post.get ? post.get({ plain: true }) : post;
return {
id: row.id,
user: row.user,
content: row.content,
created_at: row.created_at,
comments,
};
}
static async ensureDefaultSocialFeed(currentUser, transaction) {
const where = getScopedWhere(currentUser);
const existingPost = await db.vorta_social_posts.findOne({ where, transaction });
if (existingPost) {
return;
}
const post = await db.vorta_social_posts.create(
{
...where,
user: 'VORTA System',
content: 'Selamat datang di feed VORTA Universe. Ini versi PostgreSQL dari prototype posts SQLite.',
importHash: makeImportHash('social-welcome-post', currentUser),
},
{ transaction },
);
await db.vorta_social_comments.create(
{
...where,
post_id: post.id,
user: getDisplayName(currentUser),
comment: 'Feed aktif: post, komentar, dan reply siap tersimpan permanen.',
parent_id: '0',
importHash: makeImportHash('social-welcome-comment', currentUser),
},
{ transaction },
);
}
static async buildSocialFeed(currentUser, transaction) {
const where = getScopedWhere(currentUser);
const posts = await db.vorta_social_posts.findAll({
where,
order: [['created_at', 'DESC']],
limit: 20,
transaction,
});
const postIds = posts.map((post) => post.id);
const comments = postIds.length
? await db.vorta_social_comments.findAll({
where: {
...where,
post_id: {
[db.Sequelize.Op.in]: postIds,
},
},
order: [['created_at', 'ASC']],
transaction,
})
: [];
const commentsByPost = comments.reduce((acc, comment) => {
const serialized = this.serializeComment(comment);
acc[serialized.post_id] = acc[serialized.post_id] || [];
acc[serialized.post_id].push(serialized);
return acc;
}, {});
return {
posts: posts.map((post) => this.serializePost(post, commentsByPost[post.id] || [])),
stats: {
postsCount: posts.length,
commentsCount: comments.length,
},
syncedAt: new Date().toISOString(),
};
}
static async getSocialFeed(currentUser) {
return db.sequelize.transaction(async (transaction) => {
await this.ensureDefaultSocialFeed(currentUser, transaction);
return this.buildSocialFeed(currentUser, transaction);
});
}
static async resetSocialFeed(currentUser) {
await db.sequelize.transaction(async (transaction) => {
const where = getScopedWhere(currentUser);
await db.vorta_social_comments.destroy({ where, transaction });
await db.vorta_social_posts.destroy({ where, transaction });
await this.ensureDefaultSocialFeed(currentUser, transaction);
await this.addStateLedger(currentUser, {
title: 'Social feed VORTA di-reset',
detail: 'Posts dan comments scoped user ini dikembalikan ke feed demo awal.',
tone: 'warning',
}, transaction);
});
return {
feed: await this.getSocialFeed(currentUser),
state: await this.getState(currentUser),
};
}
static async createSocialPost(currentUser, payload) {
const content = typeof payload.content === 'string' ? payload.content.trim() : '';
if (!content) {
throw buildHttpError('Konten post wajib diisi.');
}
await db.sequelize.transaction(async (transaction) => {
await this.ensureDefaultSocialFeed(currentUser, transaction);
const where = getScopedWhere(currentUser);
await db.vorta_social_posts.create(
{
...where,
user: payload.user || getDisplayName(currentUser),
content,
},
{ transaction },
);
await this.addStateLedger(currentUser, {
title: 'Post komunitas dibuat',
detail: 'Konten baru tersimpan di tabel vorta_social_posts PostgreSQL.',
tone: 'success',
}, transaction);
});
return {
feed: await this.getSocialFeed(currentUser),
state: await this.getState(currentUser),
};
}
static async createSocialComment(currentUser, postId, payload) {
const commentText = typeof payload.comment === 'string' ? payload.comment.trim() : '';
if (!commentText) {
throw buildHttpError('Komentar wajib diisi.');
}
await db.sequelize.transaction(async (transaction) => {
const where = getScopedWhere(currentUser);
const post = await db.vorta_social_posts.findOne({
where: {
...where,
id: postId,
},
transaction,
});
if (!post) {
throw buildHttpError('Post VORTA tidak ditemukan.', 404);
}
await db.vorta_social_comments.create(
{
...where,
post_id: post.id,
user: payload.user || getDisplayName(currentUser),
comment: commentText,
parent_id: payload.parent_id || '0',
},
{ transaction },
);
await this.addStateLedger(currentUser, {
title: 'Komentar komunitas dibuat',
detail: 'Komentar tersimpan di tabel vorta_social_comments PostgreSQL.',
tone: 'info',
}, transaction);
});
return {
feed: await this.getSocialFeed(currentUser),
state: await this.getState(currentUser),
};
}
static async requestOtp(currentUser, payload) {
const phone = normalizePhone(payload.phone);
if (!phone || phone.length < 8) {
throw buildHttpError('Nomor HP wajib diisi minimal 8 digit.');
}
const otp = makeOtp();
const otpHash = await bcrypt.hash(otp, 10);
const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
await db.sequelize.transaction(async (transaction) => {
const where = getScopedWhere(currentUser);
await db.vorta_login_otps.update(
{
status: 'superseded',
updatedById: currentUser?.id || null,
},
{
where: {
...where,
phone,
status: 'pending',
},
transaction,
},
);
await db.vorta_login_otps.create(
{
...where,
phone,
otpHash,
expiresAt,
importHash: `vorta-otp-${Date.now()}-${Math.random().toString(16).slice(2)}`,
createdById: currentUser?.id || null,
updatedById: currentUser?.id || null,
},
{ transaction },
);
await this.addStateLedger(currentUser, {
title: 'OTP development dibuat',
detail: `Kode OTP untuk ${phone} berlaku 5 menit dan tersimpan sebagai hash.`,
tone: 'info',
}, transaction);
});
return {
message: 'OTP dev berhasil dibuat. Untuk produksi, sambungkan endpoint ini ke SMS/WhatsApp provider.',
phone,
devOtp: otp,
expiresAt,
state: await this.getState(currentUser),
};
}
static async verifyOtp(currentUser, payload) {
const phone = normalizePhone(payload.phone);
const otp = String(payload.otp || '').trim();
if (!phone || !otp) {
throw buildHttpError('Nomor HP dan OTP wajib diisi.');
}
await db.sequelize.transaction(async (transaction) => {
const where = getScopedWhere(currentUser);
const record = await db.vorta_login_otps.findOne({
where: {
...where,
phone,
status: 'pending',
expiresAt: {
[db.Sequelize.Op.gt]: new Date(),
},
},
order: [['createdAt', 'DESC']],
transaction,
});
if (!record) {
throw buildHttpError('OTP tidak ditemukan atau sudah kedaluwarsa.', 400);
}
const isValid = await bcrypt.compare(otp, record.otpHash);
const attempts = toNumber(record.attempts, 0) + 1;
if (!isValid) {
await record.update(
{
attempts,
status: attempts >= 5 ? 'locked' : 'pending',
updatedById: currentUser?.id || null,
},
{ transaction },
);
throw buildHttpError('OTP salah. Silakan cek kode dan coba lagi.', 400);
}
await record.update(
{
attempts,
status: 'verified',
verifiedAt: new Date(),
updatedById: currentUser?.id || null,
},
{ transaction },
);
const state = await this.ensureState(currentUser, transaction);
const current = this.normalizeState(state);
const nextScore = clamp(current.profile.trustScore + 5, 0, 100);
await state.update(
{
profile: {
...current.profile,
phone,
phoneVerified: true,
trustScore: nextScore,
verified: nextScore >= 80,
},
ledger: this.addLedgerItem(current.ledger, {
title: 'OTP HP terverifikasi',
detail: `${phone} lolos verifikasi OTP dan menambah 5 poin trust.`,
tone: 'success',
}),
updatedById: currentUser?.id || null,
},
{ transaction },
);
});
return {
message: 'OTP valid. Nomor HP terverifikasi dan Trust Score naik +5.',
verified: true,
state: await this.getState(currentUser),
};
}
static async createDemoProduct(currentUser) {
const productIndex = Date.now().toString().slice(-4);
return this.createProduct(currentUser, {
id: `VU-PRD-${productIndex}`,
name: `Produk Demo Universe ${productIndex}`,
price: 125000 + (Number(productIndex) % 5) * 25000,
seller: 'VORTA Demo Seller',
category: 'Demo Product',
stock: 24 + (Number(productIndex) % 20),
});
}
}
module.exports = VortaUniverseService;

View File

@ -19,7 +19,7 @@ export default function AsideMenu({
<> <>
<AsideMenuLayer <AsideMenuLayer
menu={props.menu} menu={props.menu}
className={`${isAsideMobileExpanded ? 'left-0' : '-left-60 lg:left-0'} ${ className={`${isAsideMobileExpanded ? 'left-0' : '-left-80 lg:left-0'} ${
!isAsideLgActive ? 'lg:hidden xl:flex' : '' !isAsideLgActive ? 'lg:hidden xl:flex' : ''
}`} }`}
onAsideLgCloseClick={props.onAsideLgClose} onAsideLgCloseClick={props.onAsideLgClose}

View File

@ -46,7 +46,7 @@ const AsideMenuItem = ({ item, isDropdownList = false }: Props) => {
<BaseIcon path={item.icon} className={`flex-none mx-3 ${activeClassAddon}`} size="18" /> <BaseIcon path={item.icon} className={`flex-none mx-3 ${activeClassAddon}`} size="18" />
)} )}
<span <span
className={`grow text-ellipsis line-clamp-1 ${ className={`grow min-w-0 whitespace-normal break-words leading-snug text-black ${
item.menu ? '' : 'pr-12' item.menu ? '' : 'pr-12'
} ${activeClassAddon}`} } ${activeClassAddon}`}
> >
@ -63,13 +63,13 @@ const AsideMenuItem = ({ item, isDropdownList = false }: Props) => {
) )
const componentClass = [ const componentClass = [
'flex cursor-pointer py-1.5 ', 'flex cursor-pointer items-start py-2 text-black ',
isDropdownList ? 'px-6 text-sm' : '', isDropdownList ? 'px-6 text-sm' : '',
item.color item.color
? getButtonColor(item.color, false, true) ? getButtonColor(item.color, false, true)
: `${asideMenuItemStyle}`, : `${asideMenuItemStyle}`,
isLinkActive isLinkActive
? `text-black ${activeLinkColor} dark:text-white dark:bg-dark-800` ? `text-black ${activeLinkColor}`
: '', : '',
].join(' '); ].join(' ');

View File

@ -3,10 +3,9 @@ import { mdiLogout, mdiClose } from '@mdi/js'
import BaseIcon from './BaseIcon' import BaseIcon from './BaseIcon'
import AsideMenuList from './AsideMenuList' import AsideMenuList from './AsideMenuList'
import { MenuAsideItem } from '../interfaces' import { MenuAsideItem } from '../interfaces'
import { useAppSelector } from '../stores/hooks' import { useAppDispatch, useAppSelector } from '../stores/hooks'
import Link from 'next/link'; import Link from 'next/link';
import { useAppDispatch } from '../stores/hooks';
import { createAsyncThunk } from '@reduxjs/toolkit'; import { createAsyncThunk } from '@reduxjs/toolkit';
import axios from 'axios'; import axios from 'axios';
@ -58,17 +57,17 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
return ( return (
<aside <aside
id='asideMenu' id='asideMenu'
className={`${className} zzz lg:py-2 lg:pl-2 w-60 fixed flex z-40 top-0 h-screen transition-position overflow-hidden`} className={`${className} zzz lg:py-2 lg:pl-2 w-80 fixed flex z-40 top-0 h-screen transition-position overflow-hidden`}
> >
<div <div
className={`flex-1 flex flex-col overflow-hidden dark:bg-dark-900 ${asideStyle} ${corners}`} className={`flex-1 flex flex-col overflow-hidden bg-white text-black ${asideStyle} ${corners}`}
> >
<div <div
className={`flex flex-row h-14 items-center justify-between ${asideBrandStyle}`} className={`flex flex-row h-14 items-center justify-between bg-white text-black ${asideBrandStyle}`}
> >
<div className="text-center flex-1 lg:text-left lg:pl-6 xl:text-center xl:pl-0"> <div className="text-center flex-1 lg:text-left lg:pl-6 xl:text-center xl:pl-0">
<b className="font-black">Vorta Universe MVP</b> <b className="font-black">VORTA UNIVERSE</b>
{organizationName && <p>{organizationName}</p>} {organizationName && <p>{organizationName}</p>}

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

@ -8,7 +8,7 @@ export const localStorageStyleKey = 'style'
export const containerMaxW = 'xl:max-w-full xl:mx-auto 2xl:mx-20' export const containerMaxW = 'xl:max-w-full xl:mx-auto 2xl:mx-20'
export const appTitle = 'created by Flatlogic generator!' export const appTitle = 'Ekosistem Kepercayaan Digital'
export const getPageTitle = (currentPageTitle: string) => `${currentPageTitle}${appTitle}` export const getPageTitle = (currentPageTitle: string) => `${currentPageTitle}${appTitle}`

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'
@ -86,18 +85,18 @@ export default function LayoutAuthenticated({
}, [router.events, dispatch]) }, [router.events, dispatch])
const layoutAsidePadding = 'xl:pl-60' const layoutAsidePadding = 'xl:pl-80'
return ( return (
<div className={`${darkMode ? 'dark' : ''} overflow-hidden lg:overflow-visible`}> <div className={`${darkMode ? 'dark' : ''} overflow-hidden lg:overflow-visible`}>
<div <div
className={`${layoutAsidePadding} ${ className={`${layoutAsidePadding} ${
isAsideMobileExpanded ? 'ml-60 lg:ml-0' : '' isAsideMobileExpanded ? 'ml-80 lg:ml-0' : ''
} pt-14 min-h-screen w-screen transition-position lg:w-auto ${bgColor} dark:bg-dark-800 dark:text-slate-100`} } pt-14 min-h-screen w-screen transition-position lg:w-auto ${bgColor} dark:bg-dark-800 dark:text-slate-100`}
> >
<NavBar <NavBar
menu={menuNavBar} menu={menuNavBar}
className={`${layoutAsidePadding} ${isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''}`} className={`${layoutAsidePadding} ${isAsideMobileExpanded ? 'ml-80 lg:ml-0' : ''}`}
> >
<NavBarItemPlain <NavBarItemPlain
display="flex lg:hidden" display="flex lg:hidden"

View File

@ -1,355 +1,550 @@
import * as icon from '@mdi/js'; import * as icon from '@mdi/js';
import { MenuAsideItem } from './interfaces' import { MenuAsideItem } from './interfaces';
const resolveMenuIcon = (name: string) => (
name in icon ? (icon[name as keyof typeof icon] as string) : icon.mdiViewDashboardOutline
);
const menuAside: MenuAsideItem[] = [ const menuAside: MenuAsideItem[] = [
{ {
href: '/dashboard', icon: resolveMenuIcon('mdiOrbitVariant'),
icon: icon.mdiViewDashboardOutline, label: 'VORTA Universe',
label: 'Dashboard', menu: [
{
href: '/vorta-universe#vorta-overview',
icon: resolveMenuIcon('mdiViewDashboardOutline'),
label: 'Dashboard',
},
{
href: '/vorta-universe#vorta-auth',
icon: resolveMenuIcon('mdiShieldKeyOutline'),
label: 'Login & OTP',
},
{
href: '/vorta-universe#vorta-modules',
icon: resolveMenuIcon('mdiApps'),
label: 'Modules',
},
{
href: '/vorta-feed',
icon: resolveMenuIcon('mdiPostOutline'),
label: 'VORTA Feed',
},
{
href: '/vorta-universe#vorta-social',
icon: resolveMenuIcon('mdiPostOutline'),
label: 'Feed Bisnis Dashboard',
},
{
href: '/vorta-universe#vorta-marketplace',
icon: resolveMenuIcon('mdiStorefrontOutline'),
label: 'Marketplace',
},
{
href: '/vorta-universe#vorta-settings',
icon: resolveMenuIcon('mdiCogOutline'),
label: 'Settings',
},
{
href: '/trust_profiles/trust_profiles-list',
icon: resolveMenuIcon('mdiShieldCheck'),
label: 'Nexus Score Entity',
permissions: 'READ_TRUST_PROFILES',
},
{
href: '/wallets/wallets-list',
icon: resolveMenuIcon('mdiWallet'),
label: 'Wallet Entity',
permissions: 'READ_WALLETS',
},
{
href: '/products/products-list',
icon: resolveMenuIcon('mdiPackageVariantClosed'),
label: 'Products Entity',
permissions: 'READ_PRODUCTS',
},
],
}, },
{ {
href: '/users/users-list', href: '/vorta-commerce',
label: 'Users', icon: resolveMenuIcon('mdiStorefrontOutline'),
// eslint-disable-next-line @typescript-eslint/ban-ts-comment label: '1. VORTA Commerce',
// @ts-ignore
icon: icon.mdiAccountGroup ?? icon.mdiTable,
permissions: 'READ_USERS'
}, },
{ {
href: '/roles/roles-list', href: '/vorta-synapse',
label: 'Roles', label: '2. Vorta Synapse',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment icon: resolveMenuIcon('mdiTransitConnectionVariant'),
// @ts-ignore
icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiTable,
permissions: 'READ_ROLES'
}, },
{ {
href: '/permissions/permissions-list', href: '/mega-super-app',
label: 'Permissions', label: '3. Mega Super-App',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment icon: resolveMenuIcon('mdiApps'),
// @ts-ignore
icon: icon.mdiShieldAccountOutline ?? icon.mdiTable,
permissions: 'READ_PERMISSIONS'
}, },
{ {
href: '/organizations/organizations-list', href: '/vorta-universe#vorta-overview',
label: 'Organizations', label: '4. Vorta Nexus',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment icon: resolveMenuIcon('mdiShieldCheck'),
// @ts-ignore
icon: icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_ORGANIZATIONS'
},
{
href: '/trust_profiles/trust_profiles-list',
label: 'Trust profiles',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiShieldCheck' in icon ? icon['mdiShieldCheck' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_TRUST_PROFILES'
},
{
href: '/identity_verifications/identity_verifications-list',
label: 'Identity verifications',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiCardAccountDetailsOutline' in icon ? icon['mdiCardAccountDetailsOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_IDENTITY_VERIFICATIONS'
},
{
href: '/wallets/wallets-list',
label: 'Wallets',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiWallet' in icon ? icon['mdiWallet' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_WALLETS'
},
{
href: '/wallet_transactions/wallet_transactions-list',
label: 'Wallet transactions',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiSwapHorizontal' in icon ? icon['mdiSwapHorizontal' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_WALLET_TRANSACTIONS'
},
{
href: '/chats/chats-list',
label: 'Chats',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiChat' in icon ? icon['mdiChat' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_CHATS'
},
{
href: '/chat_participants/chat_participants-list',
label: 'Chat participants',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiAccountGroup' in icon ? icon['mdiAccountGroup' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_CHAT_PARTICIPANTS'
},
{
href: '/messages/messages-list',
label: 'Messages',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiMessageTextOutline' in icon ? icon['mdiMessageTextOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_MESSAGES'
},
{
href: '/stores/stores-list',
label: 'Stores',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiStore' in icon ? icon['mdiStore' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_STORES'
},
{
href: '/addresses/addresses-list',
label: 'Addresses',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiMapMarker' in icon ? icon['mdiMapMarker' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_ADDRESSES'
},
{
href: '/product_categories/product_categories-list',
label: 'Product categories',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiShapeOutline' in icon ? icon['mdiShapeOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_PRODUCT_CATEGORIES'
},
{
href: '/products/products-list',
label: 'Products',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiPackageVariantClosed' in icon ? icon['mdiPackageVariantClosed' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_PRODUCTS'
},
{
href: '/orders/orders-list',
label: 'Orders',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiReceiptTextOutline' in icon ? icon['mdiReceiptTextOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_ORDERS'
},
{
href: '/order_items/order_items-list',
label: 'Order items',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiFormatListBulleted' in icon ? icon['mdiFormatListBulleted' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_ORDER_ITEMS'
},
{
href: '/payments/payments-list',
label: 'Payments',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiCreditCardOutline' in icon ? icon['mdiCreditCardOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_PAYMENTS'
},
{
href: '/product_reviews/product_reviews-list',
label: 'Product reviews',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiStarCircleOutline' in icon ? icon['mdiStarCircleOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_PRODUCT_REVIEWS'
},
{
href: '/shipments/shipments-list',
label: 'Shipments',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiTruckDeliveryOutline' in icon ? icon['mdiTruckDeliveryOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_SHIPMENTS'
},
{
href: '/posts/posts-list',
label: 'Posts',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiPlayCircleOutline' in icon ? icon['mdiPlayCircleOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_POSTS'
},
{
href: '/deep_dive_links/deep_dive_links-list',
label: 'Deep dive links',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiLinkVariant' in icon ? icon['mdiLinkVariant' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_DEEP_DIVE_LINKS'
},
{
href: '/citations/citations-list',
label: 'Citations',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiFileDocumentCheckOutline' in icon ? icon['mdiFileDocumentCheckOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_CITATIONS'
},
{
href: '/post_engagements/post_engagements-list',
label: 'Post engagements',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiThumbUpOutline' in icon ? icon['mdiThumbUpOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_POST_ENGAGEMENTS'
},
{
href: '/rewards/rewards-list',
label: 'Rewards',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiTrophyOutline' in icon ? icon['mdiTrophyOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_REWARDS'
},
{
href: '/forum_spaces/forum_spaces-list',
label: 'Forum spaces',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiForumOutline' in icon ? icon['mdiForumOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_FORUM_SPACES'
},
{
href: '/forum_threads/forum_threads-list',
label: 'Forum threads',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiMessageBulleted' in icon ? icon['mdiMessageBulleted' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_FORUM_THREADS'
},
{
href: '/forum_posts/forum_posts-list',
label: 'Forum posts',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiReplyOutline' in icon ? icon['mdiReplyOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_FORUM_POSTS'
},
{
href: '/job_companies/job_companies-list',
label: 'Job companies',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiOfficeBuildingOutline' in icon ? icon['mdiOfficeBuildingOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_JOB_COMPANIES'
},
{
href: '/job_listings/job_listings-list',
label: 'Job listings',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiBriefcaseOutline' in icon ? icon['mdiBriefcaseOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_JOB_LISTINGS'
},
{
href: '/job_applications/job_applications-list',
label: 'Job applications',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiFileAccountOutline' in icon ? icon['mdiFileAccountOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_JOB_APPLICATIONS'
},
{
href: '/user_credentials/user_credentials-list',
label: 'User credentials',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiCertificateOutline' in icon ? icon['mdiCertificateOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_USER_CREDENTIALS'
},
{
href: '/affiliate_programs/affiliate_programs-list',
label: 'Affiliate programs',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiAccountNetworkOutline' in icon ? icon['mdiAccountNetworkOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_AFFILIATE_PROGRAMS'
},
{
href: '/affiliate_memberships/affiliate_memberships-list',
label: 'Affiliate memberships',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiLinkBoxOutline' in icon ? icon['mdiLinkBoxOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_AFFILIATE_MEMBERSHIPS'
},
{
href: '/commissions/commissions-list',
label: 'Commissions',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiCashCheck' in icon ? icon['mdiCashCheck' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_COMMISSIONS'
},
{
href: '/geo_rules/geo_rules-list',
label: 'Geo rules',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiMapMarkerRadiusOutline' in icon ? icon['mdiMapMarkerRadiusOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_GEO_RULES'
}, },
{ {
href: '/facta_queries/facta_queries-list', href: '/facta_queries/facta_queries-list',
label: 'Facta queries', label: '5. Facta.Ai',
icon: resolveMenuIcon('mdiShieldSearchOutline'),
permissions: 'READ_FACTA_QUERIES',
},
{
href: '/users/users-list',
label: 'Pengguna',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
icon: 'mdiMagnify' in icon ? icon['mdiMagnify' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, icon: icon.mdiAccountGroup ?? icon.mdiTable,
permissions: 'READ_FACTA_QUERIES' permissions: 'READ_USERS',
},
{
href: '/roles/roles-list',
label: 'Peran',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiTable,
permissions: 'READ_ROLES',
},
{
href: '/permissions/permissions-list',
label: 'Izin Akses',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: icon.mdiShieldAccountOutline ?? icon.mdiTable,
permissions: 'READ_PERMISSIONS',
},
{
href: '/organizations/organizations-list',
label: 'Organisasi',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_ORGANIZATIONS',
},
{
href: '/trust_profiles/trust_profiles-list',
label: 'Profil Kepercayaan',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon:
'mdiShieldCheck' in icon
? icon['mdiShieldCheck' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_TRUST_PROFILES',
},
{
href: '/identity_verifications/identity_verifications-list',
label: 'Verifikasi Identitas',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon:
'mdiCardAccountDetailsOutline' in icon
? icon['mdiCardAccountDetailsOutline' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_IDENTITY_VERIFICATIONS',
},
{
href: '/wallets/wallets-list',
label: 'Dompet',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon:
'mdiWallet' in icon
? icon['mdiWallet' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_WALLETS',
},
{
href: '/wallet_transactions/wallet_transactions-list',
label: 'Transaksi Dompet',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon:
'mdiSwapHorizontal' in icon
? icon['mdiSwapHorizontal' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_WALLET_TRANSACTIONS',
},
{
href: '/chats/chats-list',
label: 'Percakapan',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon:
'mdiChat' in icon
? icon['mdiChat' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_CHATS',
},
{
href: '/chat_participants/chat_participants-list',
label: 'Peserta Percakapan',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon:
'mdiAccountGroup' in icon
? icon['mdiAccountGroup' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_CHAT_PARTICIPANTS',
},
{
href: '/messages/messages-list',
label: 'Pesan',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon:
'mdiMessageTextOutline' in icon
? icon['mdiMessageTextOutline' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_MESSAGES',
},
{
href: '/stores/stores-list',
label: 'Toko',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon:
'mdiStore' in icon
? icon['mdiStore' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_STORES',
},
{
href: '/addresses/addresses-list',
label: 'Alamat',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon:
'mdiMapMarker' in icon
? icon['mdiMapMarker' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_ADDRESSES',
},
{
href: '/product_categories/product_categories-list',
label: 'Kategori Produk',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon:
'mdiShapeOutline' in icon
? icon['mdiShapeOutline' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_PRODUCT_CATEGORIES',
},
{
href: '/products/products-list',
label: 'Produk',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon:
'mdiPackageVariantClosed' in icon
? icon['mdiPackageVariantClosed' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_PRODUCTS',
},
{
href: '/orders/orders-list',
label: 'Pesanan',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon:
'mdiReceiptTextOutline' in icon
? icon['mdiReceiptTextOutline' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_ORDERS',
},
{
href: '/order_items/order_items-list',
label: 'Item Pesanan',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon:
'mdiFormatListBulleted' in icon
? icon['mdiFormatListBulleted' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_ORDER_ITEMS',
},
{
href: '/payments/payments-list',
label: 'Pembayaran',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon:
'mdiCreditCardOutline' in icon
? icon['mdiCreditCardOutline' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_PAYMENTS',
},
{
href: '/product_reviews/product_reviews-list',
label: 'Ulasan Produk',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon:
'mdiStarCircleOutline' in icon
? icon['mdiStarCircleOutline' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_PRODUCT_REVIEWS',
},
{
href: '/shipments/shipments-list',
label: 'Pengiriman',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon:
'mdiTruckDeliveryOutline' in icon
? icon['mdiTruckDeliveryOutline' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_SHIPMENTS',
},
{
href: '/posts/posts-list',
label: 'Postingan',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon:
'mdiPlayCircleOutline' in icon
? icon['mdiPlayCircleOutline' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_POSTS',
},
{
href: '/deep_dive_links/deep_dive_links-list',
label: 'Tautan Deep Dive',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon:
'mdiLinkVariant' in icon
? icon['mdiLinkVariant' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_DEEP_DIVE_LINKS',
},
{
href: '/citations/citations-list',
label: 'Sitasi',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon:
'mdiFileDocumentCheckOutline' in icon
? icon['mdiFileDocumentCheckOutline' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_CITATIONS',
},
{
href: '/post_engagements/post_engagements-list',
label: 'Interaksi Postingan',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon:
'mdiThumbUpOutline' in icon
? icon['mdiThumbUpOutline' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_POST_ENGAGEMENTS',
},
{
href: '/rewards/rewards-list',
label: 'Hadiah',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon:
'mdiTrophyOutline' in icon
? icon['mdiTrophyOutline' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_REWARDS',
},
{
href: '/forum_spaces/forum_spaces-list',
label: 'Ruang Forum',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon:
'mdiForumOutline' in icon
? icon['mdiForumOutline' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_FORUM_SPACES',
},
{
href: '/forum_threads/forum_threads-list',
label: 'Topik Forum',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon:
'mdiMessageBulleted' in icon
? icon['mdiMessageBulleted' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_FORUM_THREADS',
},
{
href: '/forum_posts/forum_posts-list',
label: 'Posting Forum',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon:
'mdiReplyOutline' in icon
? icon['mdiReplyOutline' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_FORUM_POSTS',
},
{
href: '/job_companies/job_companies-list',
label: 'Perusahaan Lowongan',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon:
'mdiOfficeBuildingOutline' in icon
? icon['mdiOfficeBuildingOutline' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_JOB_COMPANIES',
},
{
href: '/job_listings/job_listings-list',
label: 'Daftar Lowongan',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon:
'mdiBriefcaseOutline' in icon
? icon['mdiBriefcaseOutline' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_JOB_LISTINGS',
},
{
href: '/job_applications/job_applications-list',
label: 'Lamaran Kerja',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon:
'mdiFileAccountOutline' in icon
? icon['mdiFileAccountOutline' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_JOB_APPLICATIONS',
},
{
href: '/user_credentials/user_credentials-list',
label: 'Kredensial Pengguna',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon:
'mdiCertificateOutline' in icon
? icon['mdiCertificateOutline' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_USER_CREDENTIALS',
},
{
href: '/affiliate_programs/affiliate_programs-list',
label: 'Program Afiliasi',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon:
'mdiAccountNetworkOutline' in icon
? icon['mdiAccountNetworkOutline' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_AFFILIATE_PROGRAMS',
},
{
href: '/affiliate_memberships/affiliate_memberships-list',
label: 'Keanggotaan Afiliasi',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon:
'mdiLinkBoxOutline' in icon
? icon['mdiLinkBoxOutline' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_AFFILIATE_MEMBERSHIPS',
},
{
href: '/commissions/commissions-list',
label: 'Komisi',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon:
'mdiCashCheck' in icon
? icon['mdiCashCheck' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_COMMISSIONS',
},
{
href: '/geo_rules/geo_rules-list',
label: 'Aturan Geo',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon:
'mdiMapMarkerRadiusOutline' in icon
? icon['mdiMapMarkerRadiusOutline' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_GEO_RULES',
},
{
href: '/facta_queries/facta_queries-list',
label: 'Facta.Ai Kueri',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon:
'mdiMagnify' in icon
? icon['mdiMagnify' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_FACTA_QUERIES',
}, },
{ {
href: '/facta_answers/facta_answers-list', href: '/facta_answers/facta_answers-list',
label: 'Facta answers', label: 'Facta.Ai Jawaban',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
icon: 'mdiShieldSearchOutline' in icon ? icon['mdiShieldSearchOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, icon:
permissions: 'READ_FACTA_ANSWERS' 'mdiShieldSearchOutline' in icon
? icon['mdiShieldSearchOutline' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_FACTA_ANSWERS',
}, },
{ {
href: '/subscriptions/subscriptions-list', href: '/subscriptions/subscriptions-list',
label: 'Subscriptions', label: 'Langganan',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
icon: 'mdiCrownOutline' in icon ? icon['mdiCrownOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, icon:
permissions: 'READ_SUBSCRIPTIONS' 'mdiCrownOutline' in icon
? icon['mdiCrownOutline' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_SUBSCRIPTIONS',
}, },
{ {
href: '/api_clients/api_clients-list', href: '/api_clients/api_clients-list',
label: 'Api clients', label: 'Klien API',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
icon: 'mdiApi' in icon ? icon['mdiApi' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, icon:
permissions: 'READ_API_CLIENTS' 'mdiApi' in icon
? icon['mdiApi' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_API_CLIENTS',
}, },
{ {
href: '/audit_events/audit_events-list', href: '/audit_events/audit_events-list',
label: 'Audit events', label: 'Peristiwa Audit',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
icon: 'mdiClipboardTextOutline' in icon ? icon['mdiClipboardTextOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, icon:
permissions: 'READ_AUDIT_EVENTS' 'mdiClipboardTextOutline' in icon
? icon['mdiClipboardTextOutline' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_AUDIT_EVENTS',
}, },
{ {
href: '/profile', href: '/profile',
label: 'Profile', label: 'Profil',
icon: icon.mdiAccountCircle, icon: icon.mdiAccountCircle,
}, },
{ {
href: '/api-docs', href: '/api-docs',
target: '_blank', target: '_blank',
label: 'Swagger API', label: 'Dokumentasi API',
icon: icon.mdiFileCode, icon: icon.mdiFileCode,
permissions: 'READ_API_DOCS' permissions: 'READ_API_DOCS',
}, },
] ];
export default menuAside export default menuAside;

View File

@ -10,8 +10,8 @@ import {
mdiThemeLightDark, mdiThemeLightDark,
mdiGithub, mdiGithub,
mdiVuejs, mdiVuejs,
} from '@mdi/js' } from '@mdi/js';
import { MenuNavBarItem } from './interfaces' import { MenuNavBarItem } from './interfaces';
const menuNavBar: MenuNavBarItem[] = [ const menuNavBar: MenuNavBarItem[] = [
{ {
@ -19,7 +19,7 @@ const menuNavBar: MenuNavBarItem[] = [
menu: [ menu: [
{ {
icon: mdiAccount, icon: mdiAccount,
label: 'My Profile', label: 'Profil Saya',
href: '/profile', href: '/profile',
}, },
{ {
@ -27,27 +27,25 @@ const menuNavBar: MenuNavBarItem[] = [
}, },
{ {
icon: mdiLogout, icon: mdiLogout,
label: 'Log Out', label: 'Keluar',
isLogout: true, isLogout: true,
}, },
], ],
}, },
{ {
icon: mdiThemeLightDark, icon: mdiThemeLightDark,
label: 'Light/Dark', label: 'Terang/Gelap',
isDesktopNoLabel: true, isDesktopNoLabel: true,
isToggleLightDark: true, isToggleLightDark: true,
}, },
{ {
icon: mdiLogout, icon: mdiLogout,
label: 'Log out', label: 'Keluar',
isDesktopNoLabel: true, isDesktopNoLabel: true,
isLogout: true, isLogout: true,
}, },
]
export const webPagesNavBar = [
]; ];
export default menuNavBar export const webPagesNavBar = [];
export default menuNavBar;

View File

@ -121,7 +121,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
setSteps(loginSteps); setSteps(loginSteps);
setStepName('loginSteps'); setStepName('loginSteps');
setStepsEnabled(true); setStepsEnabled(true);
}else if (router.pathname === '/dashboard' && !isCompleted('appSteps')) { }else if (router.pathname === '/vorta-commerce' && !isCompleted('appSteps')) {
setTimeout(() => { setTimeout(() => {
setSteps(appSteps); setSteps(appSteps);
setStepName('appSteps'); setStepName('appSteps');
@ -149,8 +149,8 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
setStepsEnabled(false); setStepsEnabled(false);
}; };
const title = 'Vorta Universe MVP' const title = 'VORTA UNIVERSE'
const description = "Unified super-app for verified identity, chat-to-commerce, and citation-backed search with a single wallet." const description = "VORTA Universe menyatukan VORTA Commerce, Vorta Synapse, Mega Super-App, Vorta Nexus, dan Facta.Ai."
const url = "https://flatlogic.com/" const url = "https://flatlogic.com/"
const image = "https://project-screens.s3.amazonaws.com/screenshots/40285/app-hero-20260618-210659.png" const image = "https://project-screens.s3.amazonaws.com/screenshots/40285/app-hero-20260618-210659.png"
const imageWidth = '1920' const imageWidth = '1920'

File diff suppressed because it is too large Load Diff

View File

@ -17,7 +17,7 @@ export default function Error() {
<SectionFullScreen bg="pinkRed"> <SectionFullScreen bg="pinkRed">
<CardBox <CardBox
className="w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12 shadow-2xl" className="w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12 shadow-2xl"
footer={<BaseButton href="/dashboard" label="Done" color="danger" />} footer={<BaseButton href="/vorta-commerce" label="Done" color="danger" />}
> >
<div className="space-y-3"> <div className="space-y-3">
<h1 className="text-2xl">Unhandled exception</h1> <h1 className="text-2xl">Unhandled exception</h1>

View File

@ -90,10 +90,10 @@ const Facta_queriesTablesPage = () => {
return ( return (
<> <>
<Head> <Head>
<title>{getPageTitle('Facta_queries')}</title> <title>{getPageTitle('5. Facta.Ai')}</title>
</Head> </Head>
<SectionMain> <SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Facta_queries" main> <SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="5. Facta.Ai" main>
{''} {''}
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'> <CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>

View File

@ -90,10 +90,10 @@ const Facta_queriesTablesPage = () => {
return ( return (
<> <>
<Head> <Head>
<title>{getPageTitle('Facta_queries')}</title> <title>{getPageTitle('5. Facta.Ai')}</title>
</Head> </Head>
<SectionMain> <SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Facta_queries" main> <SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="5. Facta.Ai" main>
{''} {''}
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'> <CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>

File diff suppressed because it is too large Load Diff

View File

@ -44,7 +44,7 @@ export default function Login() {
password: '9654d2ec', password: '9654d2ec',
remember: true }) remember: true })
const title = 'Vorta Universe MVP' const title = 'VORTA UNIVERSE'
// Fetch Pexels image/video // Fetch Pexels image/video
useEffect( () => { useEffect( () => {
@ -65,7 +65,7 @@ export default function Login() {
// Redirect to dashboard if user is logged in // Redirect to dashboard if user is logged in
useEffect(() => { useEffect(() => {
if (currentUser?.id) { if (currentUser?.id) {
router.push('/dashboard'); router.push('/vorta-commerce');
} }
}, [currentUser?.id, router]); }, [currentUser?.id, router]);
// Show error message if there is one // Show error message if there is one

View File

@ -0,0 +1,660 @@
import * as icon from '@mdi/js';
import Head from 'next/head';
import React, { ReactElement } from 'react';
import BaseButton from '../components/BaseButton';
import BaseIcon from '../components/BaseIcon';
import CardBox from '../components/CardBox';
import LayoutAuthenticated from '../layouts/Authenticated';
import SectionMain from '../components/SectionMain';
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks';
type BusinessMessage = {
id: number;
sender: string;
text: string;
time: string;
mine?: boolean;
};
type SocialPost = {
id: number;
author: string;
text: string;
tag: string;
likes: number;
};
type Product = {
id: number;
name: string;
seller: string;
price: number;
badge: string;
stock: number;
};
type CartItem = Product & {
qty: number;
};
type WalletTransaction = {
id: number;
label: string;
amount: number;
type: 'in' | 'out';
note: string;
};
type Activity = {
id: number;
module: string;
text: string;
time: string;
};
const fallbackIcon = icon.mdiViewDashboardOutline;
const resolveIcon = (name: string) =>
(name in icon ? icon[name as keyof typeof icon] : fallbackIcon) as string;
const formatCurrency = (value: number) =>
new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
maximumFractionDigits: 0,
}).format(value);
const MegaSuperAppPage = () => {
const { currentUser } = useAppSelector((state) => state.auth);
const iconsColor = useAppSelector((state) => state.style.iconsColor);
const [activeConversation, setActiveConversation] = React.useState('Mitra Grosir Bandung');
const [messageInput, setMessageInput] = React.useState('');
const [postInput, setPostInput] = React.useState('');
const [walletInput, setWalletInput] = React.useState('25000');
const [walletNote, setWalletNote] = React.useState('Top up saldo operasional');
const [balance, setBalance] = React.useState(1250000);
const [messages, setMessages] = React.useState<BusinessMessage[]>([
{
id: 1,
sender: 'Mitra Grosir Bandung',
text: 'Stok paket reseller sudah siap. Mau langsung dibuatkan invoice marketplace?',
time: '09:12',
},
{
id: 2,
sender: 'Anda',
text: 'Ya, kirim 12 paket. Pembayaran saya pakai dompet workspace.',
time: '09:15',
mine: true,
},
{
id: 3,
sender: 'Mitra Grosir Bandung',
text: 'Siap. Saya tautkan order ke chat ini supaya tracking reputasi ikut naik.',
time: '09:17',
},
]);
const [posts, setPosts] = React.useState<SocialPost[]>([
{
id: 1,
author: 'Komunitas Seller Lokal',
text: 'Promo bareng akhir pekan: bundling produk komunitas, cashback dompet, dan diskusi live di feed.',
tag: 'Kolaborasi',
likes: 42,
},
{
id: 2,
author: 'Rina Kopi Nusantara',
text: 'Baru upload katalog kopi 250gr. Pembeli dari chat bisnis bisa checkout tanpa pindah aplikasi.',
tag: 'Marketplace',
likes: 27,
},
]);
const products: Product[] = [
{
id: 1,
name: 'Paket Reseller Kopi 12 pcs',
seller: 'Kopi Nusantara',
price: 360000,
badge: 'Terhubung chat',
stock: 18,
},
{
id: 2,
name: 'Voucher Iklan Feed Komunitas',
seller: '01 Ads Lite',
price: 150000,
badge: 'Naikkan reputasi',
stock: 40,
},
{
id: 3,
name: 'Jasa Desain Katalog UMKM',
seller: 'Studio Pasar Sosial',
price: 275000,
badge: 'Escrow dompet',
stock: 7,
},
];
const [cart, setCart] = React.useState<CartItem[]>([
{ ...products[0], qty: 1 },
]);
const [transactions, setTransactions] = React.useState<WalletTransaction[]>([
{
id: 1,
label: 'Pembayaran order kopi',
amount: 360000,
type: 'out',
note: 'Marketplace • escrow aktif',
},
{
id: 2,
label: 'Cashback komunitas seller',
amount: 50000,
type: 'in',
note: 'Reward sosial',
},
{
id: 3,
label: 'Top up dompet',
amount: 500000,
type: 'in',
note: 'Bank transfer',
},
]);
const [activities, setActivities] = React.useState<Activity[]>([
{
id: 1,
module: 'Chat',
text: 'Negosiasi dengan Mitra Grosir Bandung ditautkan ke order #MSA-1201.',
time: 'Baru saja',
},
{
id: 2,
module: 'Marketplace',
text: '1 produk masuk keranjang dan siap dibayar dari Dompet 01.',
time: '3 menit lalu',
},
{
id: 3,
module: 'Sosial',
text: 'Posting komunitas seller mendapat 42 suka dan 8 calon pembeli.',
time: '12 menit lalu',
},
]);
const userName = currentUser?.firstName || currentUser?.email || 'Pengguna 01';
const cartTotal = cart.reduce((sum, item) => sum + item.price * item.qty, 0);
const projectedBalance = balance - cartTotal;
const addActivity = (module: string, text: string) => {
setActivities((current) => [
{
id: Date.now(),
module,
text,
time: 'Baru saja',
},
...current.slice(0, 5),
]);
};
const sendMessage = () => {
const trimmed = messageInput.trim();
if (!trimmed) {
return;
}
setMessages((current) => [
...current,
{
id: Date.now(),
sender: 'Anda',
text: trimmed,
time: 'Sekarang',
mine: true,
},
]);
setMessageInput('');
addActivity('Chat', `Pesan bisnis baru dikirim ke ${activeConversation}.`);
};
const publishPost = () => {
const trimmed = postInput.trim();
if (!trimmed) {
return;
}
setPosts((current) => [
{
id: Date.now(),
author: String(userName),
text: trimmed,
tag: 'Update Bisnis',
likes: 0,
},
...current,
]);
setPostInput('');
addActivity('Sosial', 'Update baru diterbitkan ke feed komunitas.');
};
const addToCart = (product: Product) => {
setCart((current) => {
const existing = current.find((item) => item.id === product.id);
if (existing) {
return current.map((item) =>
item.id === product.id ? { ...item, qty: item.qty + 1 } : item,
);
}
return [...current, { ...product, qty: 1 }];
});
addActivity('Marketplace', `${product.name} ditambahkan ke keranjang terpadu.`);
};
const checkoutCart = () => {
if (!cart.length || cartTotal > balance) {
return;
}
setBalance((current) => current - cartTotal);
setTransactions((current) => [
{
id: Date.now(),
label: 'Checkout marketplace terpadu',
amount: cartTotal,
type: 'out',
note: `${cart.length} item • escrow dompet`,
},
...current,
]);
setCart([]);
addActivity('Dompet', `Checkout ${formatCurrency(cartTotal)} berhasil memakai Dompet 01.`);
};
const addWalletTransaction = () => {
const amount = Number(walletInput);
const trimmedNote = walletNote.trim() || 'Top up dompet workspace';
if (!amount || amount <= 0) {
return;
}
setBalance((current) => current + amount);
setTransactions((current) => [
{
id: Date.now(),
label: 'Top up cepat',
amount,
type: 'in',
note: trimmedNote,
},
...current,
]);
setWalletInput('');
setWalletNote('');
addActivity('Dompet', `Saldo bertambah ${formatCurrency(amount)} dari top up cepat.`);
};
const conversations = [
{
name: 'Mitra Grosir Bandung',
status: 'Invoice marketplace siap',
unread: 2,
},
{
name: 'Komunitas Seller Lokal',
status: 'Diskusi promo bareng',
unread: 4,
},
{
name: 'Customer Prioritas',
status: 'Butuh update pengiriman',
unread: 1,
},
];
return (
<>
<Head>
<title>{getPageTitle('3. Mega Super-App')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={resolveIcon('mdiApps')}
title="3. Mega Super-App"
main
>
<BaseButton
href="/vorta-commerce"
icon={icon.mdiViewDashboardOutline}
label="Kembali ke Dasbor"
color="whiteDark"
/>
</SectionTitleLineWithButton>
<CardBox className="mb-6 overflow-hidden" hasComponentLayout>
<div className="grid grid-cols-1 gap-0 lg:grid-cols-5">
<div className="p-6 lg:col-span-3">
<div className="mb-3 inline-flex items-center rounded-full bg-blue-50 px-3 py-1 text-sm font-semibold text-blue-700 dark:bg-blue-900/30 dark:text-blue-200">
<BaseIcon path={resolveIcon('mdiFlashOutline')} className="mr-2" size={18} />
3. Mega Super-App · Chat Bisnis, Sosial, Marketplace, Dan Dompet Dalam Satu Ruang Kerja Ringan
</div>
<h2 className="mb-3 text-3xl font-bold leading-tight md:text-4xl">
Halo {userName}, kelola semua aktivitas dari satu ruang kerja.
</h2>
<p className="mb-5 max-w-3xl text-gray-600 dark:text-gray-300">
MVP ini menyatukan chat bisnis, feed komunitas, katalog marketplace,
keranjang, pembayaran dompet, dan aktivitas terpadu tanpa pindah halaman.
</p>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
{[
{ label: 'Chat aktif', value: conversations.length, iconName: 'mdiChatProcessingOutline' },
{ label: 'Posting sosial', value: posts.length, iconName: 'mdiPostOutline' },
{ label: 'Produk live', value: products.length, iconName: 'mdiStorefrontOutline' },
{ label: 'Saldo dompet', value: formatCurrency(balance), iconName: 'mdiWalletOutline' },
].map((item) => (
<div
key={item.label}
className="rounded-2xl border border-gray-100 bg-white/70 p-4 shadow-sm dark:border-dark-700 dark:bg-dark-800/60"
>
<BaseIcon path={resolveIcon(item.iconName)} className={`${iconsColor} mb-3`} size={26} />
<div className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{item.label}
</div>
<div className="mt-1 text-xl font-bold">{item.value}</div>
</div>
))}
</div>
</div>
<div className="border-t border-gray-100 bg-gray-50 p-6 dark:border-dark-700 dark:bg-dark-800/70 lg:col-span-2 lg:border-l lg:border-t-0">
<div className="mb-4 flex items-center justify-between">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Status workspace</p>
<h3 className="text-xl font-semibold">Siap operasional</h3>
</div>
<span className="rounded-full bg-green-100 px-3 py-1 text-sm font-semibold text-green-700 dark:bg-green-900/30 dark:text-green-200">
Live MVP
</span>
</div>
<div className="space-y-3">
{activities.slice(0, 4).map((activity) => (
<div key={activity.id} className="rounded-xl bg-white p-3 dark:bg-dark-900">
<div className="mb-1 flex items-center justify-between text-sm">
<span className="font-semibold text-blue-700 dark:text-blue-300">{activity.module}</span>
<span className="text-gray-400">{activity.time}</span>
</div>
<p className="text-sm text-gray-600 dark:text-gray-300">{activity.text}</p>
</div>
))}
</div>
</div>
</div>
</CardBox>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-3">
<CardBox className="xl:col-span-2" hasComponentLayout>
<div className="grid min-h-[620px] grid-cols-1 lg:grid-cols-5">
<aside className="border-b border-gray-100 p-4 dark:border-dark-700 lg:col-span-2 lg:border-b-0 lg:border-r">
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="text-xl font-semibold">Chat Bisnis</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">Percakapan, order, dan dompet tersambung.</p>
</div>
<BaseIcon path={resolveIcon('mdiChat')} className={iconsColor} size={30} />
</div>
<div className="space-y-3">
{conversations.map((conversation) => (
<button
key={conversation.name}
type="button"
onClick={() => setActiveConversation(conversation.name)}
className={`w-full rounded-2xl border p-3 text-left transition ${
activeConversation === conversation.name
? 'border-blue-300 bg-blue-50 dark:border-blue-800 dark:bg-blue-900/20'
: 'border-gray-100 bg-white hover:border-blue-200 dark:border-dark-700 dark:bg-dark-900'
}`}
>
<div className="flex items-center justify-between">
<span className="font-semibold">{conversation.name}</span>
<span className="rounded-full bg-orange-100 px-2 py-0.5 text-xs font-bold text-orange-700 dark:bg-orange-900/30 dark:text-orange-200">
{conversation.unread}
</span>
</div>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{conversation.status}</p>
</button>
))}
</div>
<div className="mt-5 rounded-2xl bg-gray-50 p-4 dark:bg-dark-800">
<p className="mb-2 text-sm font-semibold text-gray-600 dark:text-gray-300">Aksi cepat</p>
<div className="grid grid-cols-2 gap-2">
<BaseButton label="Buat Order" icon={resolveIcon('mdiReceiptTextPlusOutline')} color="info" small />
<BaseButton label="Kirim Invoice" icon={resolveIcon('mdiFileSendOutline')} color="success" small />
</div>
</div>
</aside>
<section className="flex flex-col lg:col-span-3">
<div className="border-b border-gray-100 p-4 dark:border-dark-700">
<p className="text-sm text-gray-500 dark:text-gray-400">Ruang chat aktif</p>
<h3 className="text-xl font-semibold">{activeConversation}</h3>
</div>
<div className="flex-1 space-y-3 bg-gray-50 p-4 dark:bg-dark-800/50">
{messages.map((message) => (
<div key={message.id} className={`flex ${message.mine ? 'justify-end' : 'justify-start'}`}>
<div
className={`max-w-[82%] rounded-2xl px-4 py-3 shadow-sm ${
message.mine
? 'bg-blue-600 text-white'
: 'bg-white text-gray-800 dark:bg-dark-900 dark:text-gray-100'
}`}
>
<div className={`mb-1 text-xs ${message.mine ? 'text-blue-100' : 'text-gray-400'}`}>
{message.sender} {message.time}
</div>
<p className="text-sm leading-relaxed">{message.text}</p>
</div>
</div>
))}
</div>
<div className="border-t border-gray-100 p-4 dark:border-dark-700">
<div className="flex gap-2">
<input
value={messageInput}
onChange={(event) => setMessageInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
sendMessage();
}
}}
className="h-11 flex-1 rounded-xl border border-gray-200 bg-white px-3 text-sm outline-none focus:border-blue-400 focus:ring dark:border-dark-700 dark:bg-dark-900"
placeholder="Tulis pesan, buat invoice, atau tautkan produk..."
/>
<BaseButton label="Kirim" icon={resolveIcon('mdiSend')} color="info" onClick={sendMessage} />
</div>
</div>
</section>
</div>
</CardBox>
<div className="space-y-6">
<CardBox hasComponentLayout>
<div className="p-5">
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="text-xl font-semibold">Dompet 01</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">Saldo, top up, checkout, dan escrow.</p>
</div>
<BaseIcon path={resolveIcon('mdiWalletOutline')} className={iconsColor} size={32} />
</div>
<div className="rounded-3xl bg-gradient-to-br from-blue-600 to-emerald-500 p-5 text-white shadow-lg">
<p className="text-sm opacity-80">Saldo tersedia</p>
<div className="mt-1 text-3xl font-bold">{formatCurrency(balance)}</div>
<p className="mt-3 text-sm opacity-90">Estimasi setelah checkout: {formatCurrency(projectedBalance)}</p>
</div>
<div className="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-2">
<input
value={walletInput}
onChange={(event) => setWalletInput(event.target.value)}
type="number"
min="0"
className="h-10 rounded-xl border border-gray-200 bg-white px-3 text-sm outline-none focus:border-blue-400 focus:ring dark:border-dark-700 dark:bg-dark-900"
placeholder="Nominal top up"
/>
<input
value={walletNote}
onChange={(event) => setWalletNote(event.target.value)}
className="h-10 rounded-xl border border-gray-200 bg-white px-3 text-sm outline-none focus:border-blue-400 focus:ring dark:border-dark-700 dark:bg-dark-900"
placeholder="Catatan"
/>
</div>
<BaseButton
label="Top Up Cepat"
icon={resolveIcon('mdiPlusCircleOutline')}
color="success"
className="mt-3 w-full"
onClick={addWalletTransaction}
/>
<div className="mt-5 space-y-3">
{transactions.slice(0, 4).map((transaction) => (
<div key={transaction.id} className="flex items-start justify-between rounded-2xl bg-gray-50 p-3 dark:bg-dark-800">
<div>
<p className="font-semibold">{transaction.label}</p>
<p className="text-sm text-gray-500 dark:text-gray-400">{transaction.note}</p>
</div>
<span className={`font-bold ${transaction.type === 'in' ? 'text-green-600' : 'text-red-500'}`}>
{transaction.type === 'in' ? '+' : '-'}{formatCurrency(transaction.amount)}
</span>
</div>
))}
</div>
</div>
</CardBox>
</div>
</div>
<div className="mt-6 grid grid-cols-1 gap-6 xl:grid-cols-2">
<CardBox hasComponentLayout>
<div className="p-5">
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="text-xl font-semibold">Feed Sosial Bisnis</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">Bangun komunitas dan reputasi sambil menjual.</p>
</div>
<BaseIcon path={resolveIcon('mdiAccountVoice')} className={iconsColor} size={32} />
</div>
<textarea
value={postInput}
onChange={(event) => setPostInput(event.target.value)}
className="h-24 w-full rounded-2xl border border-gray-200 bg-white p-3 text-sm outline-none focus:border-blue-400 focus:ring dark:border-dark-700 dark:bg-dark-900"
placeholder="Tulis update komunitas, promo sosial, atau cerita pelanggan..."
/>
<div className="mt-3 flex justify-end">
<BaseButton label="Posting ke Feed" icon={resolveIcon('mdiPostOutline')} color="info" onClick={publishPost} />
</div>
<div className="mt-5 space-y-4">
{posts.map((post) => (
<div key={post.id} className="rounded-2xl border border-gray-100 p-4 dark:border-dark-700">
<div className="mb-2 flex items-center justify-between">
<div>
<p className="font-semibold">{post.author}</p>
<span className="text-xs font-semibold uppercase tracking-wide text-blue-600 dark:text-blue-300">{post.tag}</span>
</div>
<span className="inline-flex items-center rounded-full bg-gray-50 px-3 py-1 text-sm dark:bg-dark-800">
<BaseIcon path={resolveIcon('mdiHeartOutline')} className="mr-1 text-red-500" size={16} />
{post.likes}
</span>
</div>
<p className="text-gray-600 dark:text-gray-300">{post.text}</p>
</div>
))}
</div>
</div>
</CardBox>
<CardBox hasComponentLayout>
<div className="p-5">
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="text-xl font-semibold">Marketplace Ringan</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">Produk, chat seller, keranjang, dan pembayaran dompet.</p>
</div>
<BaseIcon path={resolveIcon('mdiStorefrontOutline')} className={iconsColor} size={32} />
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3 xl:grid-cols-1 2xl:grid-cols-3">
{products.map((product) => (
<div key={product.id} className="rounded-2xl border border-gray-100 p-4 dark:border-dark-700">
<span className="mb-3 inline-flex rounded-full bg-orange-50 px-3 py-1 text-xs font-bold text-orange-700 dark:bg-orange-900/30 dark:text-orange-200">
{product.badge}
</span>
<h4 className="min-h-[48px] font-semibold">{product.name}</h4>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{product.seller} Stok {product.stock}</p>
<div className="mt-3 text-xl font-bold">{formatCurrency(product.price)}</div>
<BaseButton
label="Tambah"
icon={resolveIcon('mdiCartPlus')}
color="success"
small
className="mt-3 w-full"
onClick={() => addToCart(product)}
/>
</div>
))}
</div>
<div className="mt-5 rounded-3xl bg-gray-50 p-4 dark:bg-dark-800">
<div className="mb-3 flex items-center justify-between">
<h4 className="font-semibold">Keranjang Terpadu</h4>
<span className="text-sm text-gray-500 dark:text-gray-400">{cart.length} item</span>
</div>
{cart.length ? (
<div className="space-y-2">
{cart.map((item) => (
<div key={item.id} className="flex items-center justify-between text-sm">
<span>{item.name} × {item.qty}</span>
<span className="font-semibold">{formatCurrency(item.price * item.qty)}</span>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400">Keranjang kosong. Tambahkan produk untuk checkout.</p>
)}
<div className="mt-4 flex items-center justify-between border-t border-gray-200 pt-3 dark:border-dark-700">
<span className="font-semibold">Total</span>
<span className="text-xl font-bold">{formatCurrency(cartTotal)}</span>
</div>
<BaseButton
label={cartTotal > balance ? 'Saldo Tidak Cukup' : 'Bayar dari Dompet'}
icon={resolveIcon('mdiWalletPlusOutline')}
color={cartTotal > balance ? 'danger' : 'info'}
disabled={!cart.length || cartTotal > balance}
className="mt-3 w-full"
onClick={checkoutCart}
/>
</div>
</div>
</CardBox>
</div>
</SectionMain>
</>
);
};
MegaSuperAppPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default MegaSuperAppPage;

View File

@ -5,7 +5,7 @@ import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config'; import { getPageTitle } from '../config';
export default function PrivacyPolicy() { export default function PrivacyPolicy() {
const title = 'Vorta Universe MVP' const title = 'VORTA UNIVERSE'
const [projectUrl, setProjectUrl] = useState(''); const [projectUrl, setProjectUrl] = useState('');
useEffect(() => { useEffect(() => {

View File

@ -1,9 +1,7 @@
import React, { ReactElement, useEffect, useState } from 'react'; import React, { ReactElement, useEffect, useState } from 'react';
import Head from 'next/head'; import Head from 'next/head';
import 'react-datepicker/dist/react-datepicker.css'; import 'react-datepicker/dist/react-datepicker.css';
import { useAppDispatch } from '../stores/hooks'; import { useAppDispatch, useAppSelector } from '../stores/hooks';
import { useAppSelector } from '../stores/hooks';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import LayoutAuthenticated from '../layouts/Authenticated'; import LayoutAuthenticated from '../layouts/Authenticated';
@ -76,7 +74,7 @@ const SearchView = () => {
<BaseButton <BaseButton
color='info' color='info'
label='Back' label='Back'
onClick={() => router.push('/dashboard')} onClick={() => router.push('/vorta-commerce')}
/> />
</CardBox> </CardBox>
</SectionMain> </SectionMain>

View File

@ -5,7 +5,7 @@ import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config'; import { getPageTitle } from '../config';
export default function PrivacyPolicy() { export default function PrivacyPolicy() {
const title = 'Vorta Universe MVP'; const title = 'VORTA UNIVERSE';
const [projectUrl, setProjectUrl] = useState(''); const [projectUrl, setProjectUrl] = useState('');
useEffect(() => { useEffect(() => {

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,479 @@
import * as icon from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import React, { ReactElement, useEffect, useMemo, useState } from 'react';
import BaseButton from '../components/BaseButton';
import BaseIcon from '../components/BaseIcon';
import CardBox from '../components/CardBox';
import FormField from '../components/FormField';
import SectionMain from '../components/SectionMain';
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
import LayoutAuthenticated from '../layouts/Authenticated';
import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks';
type SocialComment = {
id: string;
post_id: string;
user: string;
comment: string;
parent_id: string;
created_at: string;
};
type SocialPost = {
id: string;
user: string;
content: string;
created_at: string;
comments: SocialComment[];
};
type SocialFeed = {
posts: SocialPost[];
stats: {
postsCount: number;
commentsCount: number;
};
syncedAt: string;
};
type SocialMutationResponse = {
feed: SocialFeed;
};
type NoticeTone = 'info' | 'success' | 'warning' | 'danger';
const resolveIcon = (name: string) => (
name in icon ? (icon[name as keyof typeof icon] as string) : icon.mdiViewDashboardOutline
);
const formatDateTime = (value?: string) => {
if (!value) {
return '-';
}
try {
return new Intl.DateTimeFormat('id-ID', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
} catch (error) {
console.error('Failed to format VORTA feed date:', error);
return value;
}
};
const VortaFeedPage = () => {
const { currentUser } = useAppSelector((state) => state.auth);
const [feed, setFeed] = useState<SocialFeed | null>(null);
const [postContent, setPostContent] = useState('');
const [commentDrafts, setCommentDrafts] = useState<Record<string, string>>({});
const [replyDrafts, setReplyDrafts] = useState<Record<string, string>>({});
const [activeReplyId, setActiveReplyId] = useState<string | null>(null);
const [notice, setNotice] = useState('');
const [noticeTone, setNoticeTone] = useState<NoticeTone>('info');
const [errorMessage, setErrorMessage] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const displayName = useMemo(() => {
const name = [currentUser?.firstName, currentUser?.lastName].filter(Boolean).join(' ').trim();
return name || currentUser?.email || 'VORTA Member';
}, [currentUser]);
const loadFeed = async () => {
setIsLoading(true);
setErrorMessage('');
try {
const response = await axios.get<SocialFeed>('/vorta-universe/social-feed');
setFeed(response.data);
setNotice(`Feed tersinkron dari PostgreSQL: ${formatDateTime(response.data.syncedAt)}`);
setNoticeTone('success');
} catch (error) {
console.error('Failed to load VORTA Feed:', error);
setErrorMessage('Gagal memuat VORTA Feed. Cek koneksi dan backend log untuk detail.');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadFeed();
}, []);
const runMutation = async (
action: () => Promise<{ data: SocialMutationResponse }>,
successMessage: string,
) => {
setIsSaving(true);
setErrorMessage('');
setNotice('');
try {
const response = await action();
setFeed(response.data.feed);
setNotice(successMessage);
setNoticeTone('success');
return true;
} catch (error) {
console.error('Failed to mutate VORTA Feed:', error);
setErrorMessage('Aksi feed gagal disimpan. Cek input dan backend log untuk detail.');
return false;
} finally {
setIsSaving(false);
}
};
const createPost = async () => {
if (!postContent.trim()) {
setErrorMessage('Isi post terlebih dahulu sebelum menerbitkan.');
return;
}
const saved = await runMutation(
() => axios.post<SocialMutationResponse>('/vorta-universe/posts', {
data: {
user: displayName,
content: postContent,
},
}),
'Post baru berhasil tersimpan di tabel vorta_social_posts.',
);
if (saved) {
setPostContent('');
}
};
const createComment = async (postId: string, parentId = '0') => {
const draftKey = parentId === '0' ? postId : parentId;
const draft = parentId === '0' ? commentDrafts[postId] : replyDrafts[parentId];
if (!draft?.trim()) {
setErrorMessage(parentId === '0' ? 'Isi komentar terlebih dahulu.' : 'Isi balasan terlebih dahulu.');
return;
}
const saved = await runMutation(
() => axios.post<SocialMutationResponse>(`/vorta-universe/posts/${postId}/comments`, {
data: {
user: displayName,
comment: draft,
parent_id: parentId,
},
}),
parentId === '0'
? 'Komentar baru berhasil tersimpan di tabel vorta_social_comments.'
: 'Balasan komentar berhasil tersimpan dengan parent_id.',
);
if (saved && parentId === '0') {
setCommentDrafts((drafts) => ({ ...drafts, [draftKey]: '' }));
}
if (saved && parentId !== '0') {
setReplyDrafts((drafts) => ({ ...drafts, [draftKey]: '' }));
setActiveReplyId(null);
}
};
const resetFeed = async () => {
const saved = await runMutation(
() => axios.post<SocialMutationResponse>('/vorta-universe/social-feed/reset'),
'Feed berhasil di-reset ke contoh awal VORTA.',
);
if (saved) {
setPostContent('');
setCommentDrafts({});
setReplyDrafts({});
setActiveReplyId(null);
}
};
const clearDrafts = () => {
setPostContent('');
setCommentDrafts({});
setReplyDrafts({});
setActiveReplyId(null);
setNotice('Semua draft di layar sudah dibersihkan. Data tersimpan tidak dihapus.');
setNoticeTone('info');
setErrorMessage('');
};
const posts = feed?.posts || [];
const commentsCount = feed?.stats.commentsCount || 0;
return (
<>
<Head>
<title>{getPageTitle('VORTA Feed')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={resolveIcon('mdiPostOutline')}
title="VORTA Feed"
main
>
<BaseButton
label={isLoading ? 'Memuat...' : 'Refresh'}
icon={resolveIcon('mdiRefresh')}
color="info"
onClick={loadFeed}
disabled={isLoading || isSaving}
/>
</SectionTitleLineWithButton>
<div className="mb-6 grid grid-cols-1 gap-4 lg:grid-cols-3">
<CardBox className="border border-emerald-500/30 bg-emerald-950/10">
<div className="flex items-center gap-3">
<BaseIcon path={resolveIcon('mdiDatabaseCheckOutline')} size={32} className="text-emerald-500" />
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Storage</p>
<h2 className="text-xl font-bold">PostgreSQL Native</h2>
</div>
</div>
</CardBox>
<CardBox>
<div className="flex items-center gap-3">
<BaseIcon path={resolveIcon('mdiPostOutline')} size={32} className="text-sky-500" />
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Posts</p>
<h2 className="text-xl font-bold">{posts.length}</h2>
</div>
</div>
</CardBox>
<CardBox>
<div className="flex items-center gap-3">
<BaseIcon path={resolveIcon('mdiCommentMultipleOutline')} size={32} className="text-amber-500" />
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Comments & Replies</p>
<h2 className="text-xl font-bold">{commentsCount}</h2>
</div>
</div>
</CardBox>
</div>
{(notice || errorMessage) && (
<div
className={`mb-6 rounded-xl border px-4 py-3 text-sm ${
errorMessage
? 'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-200'
: noticeTone === 'success'
? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200'
: noticeTone === 'warning'
? 'border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-200'
: 'border-sky-500/40 bg-sky-500/10 text-sky-700 dark:text-sky-200'
}`}
>
{errorMessage || notice}
</div>
)}
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,420px)_1fr]">
<div className="space-y-6">
<CardBox>
<div className="mb-4">
<p className="text-sm font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-300">
Create Post
</p>
<h2 className="text-2xl font-bold">Tulis update komunitas</h2>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Post tersimpan sebagai row baru di <strong>vorta_social_posts</strong> dan langsung muncul di feed.
</p>
</div>
<FormField label="Nama tampil" help="Diambil dari akun login, bisa ikut tersimpan sebagai user display feed.">
<input value={displayName} disabled />
</FormField>
<FormField label="Konten Post" hasTextareaHeight>
<textarea
value={postContent}
onChange={(event) => setPostContent(event.target.value)}
placeholder="Contoh: Update bisnis hari ini, promo komunitas, atau ide kolaborasi VORTA..."
/>
</FormField>
<div className="flex flex-wrap gap-2">
<BaseButton
label={isSaving ? 'Menyimpan...' : 'Terbitkan Post'}
icon={resolveIcon('mdiSend')}
color="success"
onClick={createPost}
disabled={isSaving || isLoading}
/>
<BaseButton
label="Bersihkan Draft"
icon={resolveIcon('mdiBroom')}
color="warning"
outline
onClick={clearDrafts}
disabled={isSaving || isLoading}
/>
<BaseButton
label="Reset Demo"
icon={resolveIcon('mdiRestore')}
color="danger"
outline
onClick={resetFeed}
disabled={isSaving || isLoading}
/>
</div>
</CardBox>
<CardBox>
<p className="text-sm font-semibold uppercase tracking-wide text-sky-600 dark:text-sky-300">
API aktif
</p>
<div className="mt-3 space-y-3 text-sm text-gray-600 dark:text-gray-300">
<p><strong>GET</strong> /vorta-universe/social-feed</p>
<p><strong>POST</strong> /vorta-universe/posts</p>
<p><strong>POST</strong> /vorta-universe/posts/:postId/comments</p>
<p><strong>POST</strong> /vorta-universe/social-feed/reset</p>
</div>
</CardBox>
</div>
<div className="space-y-5">
{isLoading && !feed && (
<CardBox>
<div className="py-10 text-center text-gray-500 dark:text-gray-400">Memuat VORTA Feed...</div>
</CardBox>
)}
{!isLoading && posts.length === 0 && (
<CardBox>
<div className="py-10 text-center">
<BaseIcon path={resolveIcon('mdiPostOutline')} size={48} className="mx-auto mb-3 text-gray-400" />
<h3 className="text-xl font-bold">Belum ada post</h3>
<p className="mt-2 text-gray-600 dark:text-gray-400">Buat post pertama untuk memulai feed komunitas.</p>
</div>
</CardBox>
)}
{posts.map((post) => {
const rootComments = post.comments.filter((comment) => comment.parent_id === '0');
const repliesByParent = post.comments.reduce<Record<string, SocialComment[]>>((acc, comment) => {
if (comment.parent_id !== '0') {
acc[comment.parent_id] = acc[comment.parent_id] || [];
acc[comment.parent_id].push(comment);
}
return acc;
}, {});
return (
<CardBox key={post.id} className="overflow-hidden">
<div className="mb-4 flex flex-col gap-3 border-b border-gray-200 pb-4 dark:border-dark-700 md:flex-row md:items-start md:justify-between">
<div>
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-emerald-500/15 text-lg font-bold text-emerald-600 dark:text-emerald-300">
{post.user.slice(0, 1).toUpperCase()}
</div>
<div>
<h3 className="font-bold">{post.user}</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">{formatDateTime(post.created_at)}</p>
</div>
</div>
<p className="mt-4 whitespace-pre-wrap text-gray-800 dark:text-gray-100">{post.content}</p>
</div>
<div className="rounded-full bg-sky-500/10 px-3 py-1 text-xs font-semibold text-sky-700 dark:text-sky-200">
{post.comments.length} komentar
</div>
</div>
<div className="space-y-4">
{rootComments.map((comment) => (
<div key={comment.id} className="rounded-xl border border-gray-200 p-3 dark:border-dark-700">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="font-semibold">{comment.user}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{formatDateTime(comment.created_at)}</div>
</div>
<p className="mt-2 whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-200">{comment.comment}</p>
{(repliesByParent[comment.id] || []).map((reply) => (
<div key={reply.id} className="mt-3 border-l-4 border-emerald-400 pl-3">
<div className="flex flex-col gap-1 text-xs text-gray-500 dark:text-gray-400 sm:flex-row sm:items-center sm:justify-between">
<span className="font-semibold text-gray-700 dark:text-gray-200">{reply.user}</span>
<span>{formatDateTime(reply.created_at)}</span>
</div>
<p className="mt-1 whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-200">{reply.comment}</p>
</div>
))}
{activeReplyId === comment.id ? (
<div className="mt-3 flex flex-col gap-2 md:flex-row">
<input
className="min-h-10 flex-1 rounded border border-gray-300 px-3 py-2 dark:border-dark-700 dark:bg-dark-800"
value={replyDrafts[comment.id] || ''}
onChange={(event) => setReplyDrafts((drafts) => ({
...drafts,
[comment.id]: event.target.value,
}))}
placeholder="Tulis balasan..."
/>
<BaseButton
label="Kirim Reply"
color="success"
small
onClick={() => createComment(post.id, comment.id)}
disabled={isSaving}
/>
<BaseButton
label="Batal"
color="warning"
small
outline
onClick={() => setActiveReplyId(null)}
disabled={isSaving}
/>
</div>
) : (
<button
type="button"
className="mt-3 text-sm font-semibold text-emerald-600 hover:underline dark:text-emerald-300"
onClick={() => setActiveReplyId(comment.id)}
>
Balas komentar
</button>
)}
</div>
))}
<div className="flex flex-col gap-2 border-t border-gray-200 pt-4 dark:border-dark-700 md:flex-row">
<input
className="min-h-11 flex-1 rounded border border-gray-300 px-3 py-2 dark:border-dark-700 dark:bg-dark-800"
value={commentDrafts[post.id] || ''}
onChange={(event) => setCommentDrafts((drafts) => ({
...drafts,
[post.id]: event.target.value,
}))}
placeholder="Tulis komentar untuk post ini..."
/>
<BaseButton
label="Komentar"
icon={resolveIcon('mdiCommentPlusOutline')}
color="info"
onClick={() => createComment(post.id)}
disabled={isSaving || isLoading}
/>
</div>
</div>
</CardBox>
);
})}
</div>
</div>
</SectionMain>
</>
);
};
VortaFeedPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default VortaFeedPage;

View File

@ -0,0 +1,800 @@
import * as icon from '@mdi/js';
import Head from 'next/head';
import React, { ReactElement } from 'react';
import BaseButton from '../components/BaseButton';
import BaseIcon from '../components/BaseIcon';
import CardBox from '../components/CardBox';
import LayoutAuthenticated from '../layouts/Authenticated';
import SectionMain from '../components/SectionMain';
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks';
type SynapseNode = {
id: number;
name: string;
city: string;
type: 'Creator Hub' | 'Fulfillment' | 'Retail Partner' | 'Drop Point';
health: number;
capacity: number;
latency: string;
};
type AffiliatePartner = {
id: number;
name: string;
tier: string;
sales: number;
commissionRate: number;
status: 'Aktif' | 'Butuh Follow Up' | 'Review Fraud';
};
type CreativeDrop = {
id: number;
title: string;
creator: string;
channel: string;
inventory: number;
sold: number;
margin: number;
};
type LogisticsJob = {
id: number;
code: string;
destination: string;
status: 'Routing' | 'Dikirim' | 'Terkirim' | 'Tahan QC';
eta: string;
courier: string;
risk: 'Rendah' | 'Sedang' | 'Tinggi';
};
type LedgerEvent = {
id: number;
module: string;
title: string;
detail: string;
time: string;
};
const fallbackIcon = icon.mdiViewDashboardOutline;
const resolveIcon = (name: string) =>
(name in icon ? icon[name as keyof typeof icon] : fallbackIcon) as string;
const formatCurrency = (value: number) =>
new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
maximumFractionDigits: 0,
}).format(value);
const formatNumber = (value: number) =>
new Intl.NumberFormat('id-ID', {
maximumFractionDigits: 0,
}).format(value);
const VortaSynapsePage = () => {
const { currentUser } = useAppSelector((state) => state.auth);
const iconsColor = useAppSelector((state) => state.style.iconsColor);
const [activeNodeId, setActiveNodeId] = React.useState(1);
const [campaignInput, setCampaignInput] = React.useState('Drop merchandise kreator lokal Batch 04');
const [selectedDropId, setSelectedDropId] = React.useState(1);
const [distributionBudget, setDistributionBudget] = React.useState('7500000');
const [commissionPool, setCommissionPool] = React.useState(18250000);
const [routingEfficiency, setRoutingEfficiency] = React.useState(86);
const [nodes, setNodes] = React.useState<SynapseNode[]>([
{
id: 1,
name: 'Jakarta Creator Hub',
city: 'Jakarta',
type: 'Creator Hub',
health: 96,
capacity: 78,
latency: '12 ms',
},
{
id: 2,
name: 'Bandung Micro Fulfillment',
city: 'Bandung',
type: 'Fulfillment',
health: 91,
capacity: 64,
latency: '18 ms',
},
{
id: 3,
name: 'Surabaya Affiliate Gate',
city: 'Surabaya',
type: 'Retail Partner',
health: 88,
capacity: 72,
latency: '24 ms',
},
{
id: 4,
name: 'Bali Pop-Up Drop Point',
city: 'Denpasar',
type: 'Drop Point',
health: 83,
capacity: 55,
latency: '31 ms',
},
]);
const [partners, setPartners] = React.useState<AffiliatePartner[]>([
{
id: 1,
name: 'Nara Studio Circle',
tier: 'Creator Lead',
sales: 146,
commissionRate: 14,
status: 'Aktif',
},
{
id: 2,
name: 'Kolektif Musik Indie',
tier: 'Community Partner',
sales: 92,
commissionRate: 11,
status: 'Aktif',
},
{
id: 3,
name: 'Micro Seller Timur',
tier: 'Regional Affiliate',
sales: 51,
commissionRate: 9,
status: 'Butuh Follow Up',
},
{
id: 4,
name: 'Promo Flash Network',
tier: 'Performance',
sales: 18,
commissionRate: 7,
status: 'Review Fraud',
},
]);
const [drops, setDrops] = React.useState<CreativeDrop[]>([
{
id: 1,
title: 'Hoodie Tur Konser Virtual',
creator: 'Aksara Nada',
channel: 'TikTok Shop + Komunitas',
inventory: 420,
sold: 287,
margin: 38,
},
{
id: 2,
title: 'Print Art Edisi 04',
creator: 'Maya Visual Lab',
channel: 'Marketplace Kolektor',
inventory: 180,
sold: 119,
margin: 44,
},
{
id: 3,
title: 'Paket Kelas Mini Creator',
creator: 'Ruang Cerita',
channel: 'Affiliate Academy',
inventory: 650,
sold: 312,
margin: 52,
},
]);
const [jobs, setJobs] = React.useState<LogisticsJob[]>([
{
id: 1,
code: 'VSP-0401',
destination: 'Jakarta Selatan',
status: 'Routing',
eta: '2 jam',
courier: 'Vorta Route AI',
risk: 'Rendah',
},
{
id: 2,
code: 'VSP-0402',
destination: 'Bandung Kota',
status: 'Dikirim',
eta: '5 jam',
courier: 'Synapse Express',
risk: 'Sedang',
},
{
id: 3,
code: 'VSP-0403',
destination: 'Surabaya Timur',
status: 'Tahan QC',
eta: '12 jam',
courier: 'Partner Fleet',
risk: 'Tinggi',
},
]);
const [ledger, setLedger] = React.useState<LedgerEvent[]>([
{
id: 1,
module: 'Distribusi',
title: 'Batch 04 disinkronkan',
detail: '287 unit hoodie dialokasikan dari Jakarta Creator Hub ke 3 node regional.',
time: 'Baru saja',
},
{
id: 2,
module: 'Afiliasi',
title: 'Komisi kreator dihitung',
detail: 'Pool komisi minggu ini siap dibagi ke 4 partner aktif.',
time: '8 menit lalu',
},
{
id: 3,
module: 'Logistik',
title: 'Rute risiko tinggi ditahan',
detail: 'Paket VSP-0403 masuk antrian QC sebelum dilanjutkan ke Surabaya Timur.',
time: '14 menit lalu',
},
]);
const userName = currentUser?.firstName || currentUser?.email || 'Operator Vorta';
const activeNode = nodes.find((node) => node.id === activeNodeId) || nodes[0];
const selectedDrop = drops.find((drop) => drop.id === selectedDropId) || drops[0];
const totalInventory = drops.reduce((sum, drop) => sum + drop.inventory, 0);
const totalSold = drops.reduce((sum, drop) => sum + drop.sold, 0);
const sellThrough = Math.round((totalSold / totalInventory) * 100);
const partnerSales = partners.reduce((sum, partner) => sum + partner.sales, 0);
const averageCommission = Math.round(
partners.reduce((sum, partner) => sum + partner.commissionRate, 0) / partners.length,
);
const openJobs = jobs.filter((job) => job.status !== 'Terkirim').length;
const addLedgerEvent = (module: string, title: string, detail: string) => {
setLedger((current) => [
{
id: Date.now(),
module,
title,
detail,
time: 'Baru saja',
},
...current.slice(0, 6),
]);
};
const launchCampaign = () => {
const trimmedCampaign = campaignInput.trim();
const budget = Number(distributionBudget);
if (!trimmedCampaign || !budget || budget <= 0) {
return;
}
const nextDrop: CreativeDrop = {
id: Date.now(),
title: trimmedCampaign,
creator: String(userName),
channel: `${activeNode.city} Synapse Node`,
inventory: Math.max(80, Math.round(budget / 50000)),
sold: 0,
margin: selectedDrop.margin,
};
setDrops((current) => [nextDrop, ...current]);
setSelectedDropId(nextDrop.id);
setCommissionPool((current) => current + Math.round(budget * 0.18));
setCampaignInput('');
setDistributionBudget('');
addLedgerEvent(
'Distribusi',
'Kampanye kreator diluncurkan',
`${nextDrop.title} masuk node ${activeNode.name} dengan estimasi stok ${formatNumber(nextDrop.inventory)} unit.`,
);
};
const optimizeRoute = () => {
setRoutingEfficiency((current) => Math.min(99, current + 3));
setNodes((current) =>
current.map((node) =>
node.id === activeNodeId
? {
...node,
health: Math.min(99, node.health + 2),
capacity: Math.max(35, node.capacity - 4),
}
: node,
),
);
setJobs((current) =>
current.map((job, index) =>
index === 0
? {
...job,
status: 'Dikirim',
eta: '90 menit',
risk: 'Rendah',
}
: job,
),
);
addLedgerEvent(
'Logistik',
'Rute pintar dioptimalkan',
`${activeNode.name} menurunkan ETA prioritas dan meningkatkan efisiensi rute menjadi ${Math.min(99, routingEfficiency + 3)}%.`,
);
};
const settleCommissions = () => {
if (commissionPool <= 0) {
return;
}
setPartners((current) =>
current.map((partner) => ({
...partner,
sales: partner.sales + Math.round(partner.sales * 0.06),
status: partner.status === 'Review Fraud' ? 'Butuh Follow Up' : 'Aktif',
})),
);
addLedgerEvent(
'Afiliasi',
'Settlement komisi diproses',
`${formatCurrency(commissionPool)} dialokasikan ke partner dengan rata-rata komisi ${averageCommission}%.`,
);
setCommissionPool(0);
};
const dispatchShipment = (jobId: number) => {
setJobs((current) =>
current.map((job) =>
job.id === jobId
? {
...job,
status: job.status === 'Terkirim' ? 'Terkirim' : 'Terkirim',
eta: 'Selesai',
risk: 'Rendah',
}
: job,
),
);
const completedJob = jobs.find((job) => job.id === jobId);
if (completedJob) {
addLedgerEvent(
'Logistik',
'Paket diselesaikan',
`${completedJob.code} ke ${completedJob.destination} ditandai terkirim dan siap settlement kreator.`,
);
}
};
const boostSelectedDrop = () => {
setDrops((current) =>
current.map((drop) =>
drop.id === selectedDropId
? {
...drop,
sold: Math.min(drop.inventory, drop.sold + 24),
margin: Math.min(70, drop.margin + 1),
}
: drop,
),
);
addLedgerEvent(
'Distribusi',
'Afiliasi boost diterapkan',
`${selectedDrop.title} mendapat 24 proyeksi penjualan baru lewat partner performa tinggi.`,
);
};
return (
<>
<Head>
<title>{getPageTitle('2. Vorta Synapse')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={resolveIcon('mdiTransitConnectionVariant')}
title="2. Vorta Synapse"
main
>
<BaseButton
href="/vorta-commerce"
icon={icon.mdiViewDashboardOutline}
label="Kembali ke Dasbor"
color="whiteDark"
/>
</SectionTitleLineWithButton>
<CardBox className="mb-6 overflow-hidden" hasComponentLayout>
<div className="grid grid-cols-1 gap-0 xl:grid-cols-5">
<div className="relative overflow-hidden p-6 xl:col-span-3">
<div className="absolute right-0 top-0 h-48 w-48 rounded-full bg-blue-200/30 blur-3xl dark:bg-blue-900/30" />
<div className="absolute bottom-0 right-28 h-36 w-36 rounded-full bg-green-200/30 blur-3xl dark:bg-green-900/30" />
<div className="relative">
<div className="mb-3 inline-flex items-center rounded-full bg-blue-50 px-3 py-1 text-sm font-semibold text-blue-700 dark:bg-blue-900/30 dark:text-blue-200">
<BaseIcon path={resolveIcon('mdiNetworkOutline')} className="mr-2" size={18} />
2. Vorta Synapse · Protokol Distribusi, Afiliasi, Dan Logistik Pintar Untuk Ekonomi Kreator
</div>
<h2 className="mb-3 text-3xl font-bold leading-tight md:text-4xl">
Halo {userName}, sinkronkan drop kreator dari kampanye sampai paket terkirim.
</h2>
<p className="mb-5 max-w-3xl text-gray-600 dark:text-gray-300">
Vorta Synapse menyatukan node distribusi, partner afiliasi, pool komisi,
alokasi stok, dan rute logistik adaptif agar kreator bisa menjual lebih luas
tanpa kehilangan kontrol operasional.
</p>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
{[
{ label: 'Sell-through', value: `${sellThrough}%`, iconName: 'mdiChartTimelineVariant' },
{ label: 'Penjualan afiliasi', value: formatNumber(partnerSales), iconName: 'mdiAccountMultipleCheckOutline' },
{ label: 'Komisi siap', value: formatCurrency(commissionPool), iconName: 'mdiCashSync' },
{ label: 'Job logistik', value: openJobs, iconName: 'mdiTruckFastOutline' },
].map((item) => (
<div
key={item.label}
className="rounded-2xl border border-gray-100 bg-white/70 p-4 shadow-sm dark:border-dark-700 dark:bg-dark-800/60"
>
<BaseIcon path={resolveIcon(item.iconName)} className={`${iconsColor} mb-3`} size={26} />
<div className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{item.label}
</div>
<div className="mt-1 text-xl font-bold">{item.value}</div>
</div>
))}
</div>
</div>
</div>
<div className="border-t border-gray-100 bg-gray-50 p-6 dark:border-dark-700 dark:bg-dark-800/70 xl:col-span-2 xl:border-l xl:border-t-0">
<div className="mb-4 flex items-center justify-between">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Synapse health</p>
<h3 className="text-xl font-semibold">{routingEfficiency}% route efficiency</h3>
</div>
<span className="rounded-full bg-green-100 px-3 py-1 text-sm font-semibold text-green-700 dark:bg-green-900/30 dark:text-green-200">
Protocol Live
</span>
</div>
<div className="space-y-3">
{ledger.slice(0, 4).map((event) => (
<div key={event.id} className="rounded-xl bg-white p-3 dark:bg-dark-900">
<div className="mb-1 flex items-center justify-between text-sm">
<span className="font-semibold text-blue-700 dark:text-blue-300">{event.module}</span>
<span className="text-gray-400">{event.time}</span>
</div>
<p className="font-semibold">{event.title}</p>
<p className="text-sm text-gray-600 dark:text-gray-300">{event.detail}</p>
</div>
))}
</div>
</div>
</div>
</CardBox>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-3">
<CardBox className="xl:col-span-2" hasComponentLayout>
<div className="grid min-h-[650px] grid-cols-1 lg:grid-cols-5">
<aside className="border-b border-gray-100 p-4 dark:border-dark-700 lg:col-span-2 lg:border-b-0 lg:border-r">
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="text-xl font-semibold">Synapse Nodes</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Pusat kreator, fulfillment, partner retail, dan drop point.
</p>
</div>
<BaseIcon path={resolveIcon('mdiAccessPointNetwork')} className={iconsColor} size={30} />
</div>
<div className="space-y-3">
{nodes.map((node) => (
<button
key={node.id}
type="button"
onClick={() => setActiveNodeId(node.id)}
className={`w-full rounded-2xl border p-3 text-left transition ${
activeNodeId === node.id
? 'border-blue-300 bg-blue-50 dark:border-blue-800 dark:bg-blue-900/20'
: 'border-gray-100 bg-white hover:border-blue-200 dark:border-dark-700 dark:bg-dark-900'
}`}
>
<div className="flex items-center justify-between">
<span className="font-semibold">{node.name}</span>
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-bold text-blue-700 dark:bg-blue-900/30 dark:text-blue-200">
{node.health}%
</span>
</div>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{node.city} {node.type} Latensi {node.latency}
</p>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-dark-800">
<div className="h-full rounded-full bg-blue-500" style={{ width: `${node.capacity}%` }} />
</div>
</button>
))}
</div>
</aside>
<section className="flex flex-col lg:col-span-3">
<div className="border-b border-gray-100 p-4 dark:border-dark-700">
<p className="text-sm text-gray-500 dark:text-gray-400">Node aktif</p>
<div className="flex flex-col justify-between gap-3 md:flex-row md:items-center">
<div>
<h3 className="text-2xl font-semibold">{activeNode.name}</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
{activeNode.type} di {activeNode.city} kapasitas {activeNode.capacity}% terpakai
</p>
</div>
<BaseButton
label="Optimalkan Rute"
icon={resolveIcon('mdiRoutes')}
color="info"
onClick={optimizeRoute}
/>
</div>
</div>
<div className="flex-1 bg-gray-50 p-4 dark:bg-dark-800/50">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="rounded-3xl bg-white p-4 dark:bg-dark-900">
<p className="text-sm text-gray-500 dark:text-gray-400">Health score</p>
<div className="mt-2 text-4xl font-bold text-green-600">{activeNode.health}%</div>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
Node siap menerima batch dan menjaga SLA fulfillment.
</p>
</div>
<div className="rounded-3xl bg-white p-4 dark:bg-dark-900">
<p className="text-sm text-gray-500 dark:text-gray-400">Routing efficiency</p>
<div className="mt-2 text-4xl font-bold text-blue-600">{routingEfficiency}%</div>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
Optimasi mempertimbangkan stok, risiko, jarak, dan kapasitas mitra.
</p>
</div>
<div className="rounded-3xl bg-white p-4 dark:bg-dark-900">
<p className="text-sm text-gray-500 dark:text-gray-400">Average commission</p>
<div className="mt-2 text-4xl font-bold text-orange-500">{averageCommission}%</div>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
Tarif dinamis mengikuti performa dan kualitas traffic partner.
</p>
</div>
</div>
<div className="mt-4 rounded-3xl bg-white p-4 dark:bg-dark-900">
<div className="mb-3 flex items-center justify-between">
<div>
<h4 className="font-semibold">Launcher kampanye kreator</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Buat batch distribusi baru dan isi pool afiliasi otomatis.
</p>
</div>
<BaseIcon path={resolveIcon('mdiRocketLaunchOutline')} className={iconsColor} size={28} />
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<input
value={campaignInput}
onChange={(event) => setCampaignInput(event.target.value)}
className="h-11 rounded-xl border border-gray-200 bg-white px-3 text-sm outline-none focus:border-blue-400 focus:ring dark:border-dark-700 dark:bg-dark-800 md:col-span-2"
placeholder="Nama drop atau kampanye kreator"
/>
<input
value={distributionBudget}
onChange={(event) => setDistributionBudget(event.target.value)}
className="h-11 rounded-xl border border-gray-200 bg-white px-3 text-sm outline-none focus:border-blue-400 focus:ring dark:border-dark-700 dark:bg-dark-800"
placeholder="Budget distribusi"
inputMode="numeric"
/>
</div>
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-gray-500 dark:text-gray-400">
Template margin mengikuti {selectedDrop.title} ({selectedDrop.margin}%).
</p>
<BaseButton
label="Luncurkan Batch"
icon={resolveIcon('mdiPlusCircleOutline')}
color="success"
onClick={launchCampaign}
/>
</div>
</div>
</div>
</section>
</div>
</CardBox>
<div className="space-y-6">
<CardBox hasComponentLayout>
<div className="p-5">
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="text-xl font-semibold">Komisi Afiliasi</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Pool komisi untuk kreator, komunitas, dan reseller.
</p>
</div>
<BaseIcon path={resolveIcon('mdiCashMultiple')} className={iconsColor} size={32} />
</div>
<div className="rounded-3xl bg-gray-50 p-4 dark:bg-dark-800">
<p className="text-sm text-gray-500 dark:text-gray-400">Pool siap settlement</p>
<div className="mt-1 text-3xl font-bold">{formatCurrency(commissionPool)}</div>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
Partner aktif: {partners.filter((partner) => partner.status === 'Aktif').length}/{partners.length}
</p>
<BaseButton
label="Settlement Komisi"
icon={resolveIcon('mdiBankTransferOut')}
color="info"
disabled={commissionPool <= 0}
className="mt-3 w-full"
onClick={settleCommissions}
/>
</div>
<div className="mt-4 space-y-3">
{partners.map((partner) => (
<div key={partner.id} className="rounded-2xl border border-gray-100 p-3 dark:border-dark-700">
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-semibold">{partner.name}</p>
<p className="text-sm text-gray-500 dark:text-gray-400">{partner.tier}</p>
</div>
<span
className={`rounded-full px-2 py-1 text-xs font-bold ${
partner.status === 'Aktif'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-200'
: partner.status === 'Review Fraud'
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-200'
: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-200'
}`}
>
{partner.status}
</span>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-sm">
<div className="rounded-xl bg-gray-50 p-2 dark:bg-dark-800">
<span className="text-gray-500 dark:text-gray-400">Sales</span>
<p className="font-semibold">{formatNumber(partner.sales)}</p>
</div>
<div className="rounded-xl bg-gray-50 p-2 dark:bg-dark-800">
<span className="text-gray-500 dark:text-gray-400">Rate</span>
<p className="font-semibold">{partner.commissionRate}%</p>
</div>
</div>
</div>
))}
</div>
</div>
</CardBox>
</div>
</div>
<div className="mt-6 grid grid-cols-1 gap-6 xl:grid-cols-2">
<CardBox hasComponentLayout>
<div className="p-5">
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="text-xl font-semibold">Drop Kreator</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Produk digital/fisik yang sedang didorong oleh protokol distribusi.
</p>
</div>
<BaseIcon path={resolveIcon('mdiPackageVariantClosed')} className={iconsColor} size={32} />
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3 xl:grid-cols-1 2xl:grid-cols-3">
{drops.map((drop) => {
const progress = Math.round((drop.sold / drop.inventory) * 100);
return (
<button
key={drop.id}
type="button"
onClick={() => setSelectedDropId(drop.id)}
className={`rounded-2xl border p-4 text-left transition ${
selectedDropId === drop.id
? 'border-blue-300 bg-blue-50 dark:border-blue-800 dark:bg-blue-900/20'
: 'border-gray-100 hover:border-blue-200 dark:border-dark-700'
}`}
>
<span className="mb-3 inline-flex rounded-full bg-orange-50 px-3 py-1 text-xs font-bold text-orange-700 dark:bg-orange-900/30 dark:text-orange-200">
Margin {drop.margin}%
</span>
<h4 className="min-h-[48px] font-semibold">{drop.title}</h4>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{drop.creator} {drop.channel}
</p>
<div className="mt-3 flex items-center justify-between text-sm">
<span>{formatNumber(drop.sold)} / {formatNumber(drop.inventory)} terjual</span>
<span className="font-semibold">{progress}%</span>
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-dark-800">
<div className="h-full rounded-full bg-green-500" style={{ width: `${progress}%` }} />
</div>
</button>
);
})}
</div>
<div className="mt-5 rounded-3xl bg-gray-50 p-4 dark:bg-dark-800">
<div className="mb-3 flex items-center justify-between">
<div>
<h4 className="font-semibold">Boost afiliasi terpilih</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">{selectedDrop.title}</p>
</div>
<BaseButton
label="Boost 24 Sales"
icon={resolveIcon('mdiTrendingUp')}
color="success"
small
onClick={boostSelectedDrop}
/>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">
Simulasi ini menambah proyeksi penjualan dan menaikkan margin drop pilihan.
</p>
</div>
</div>
</CardBox>
<CardBox hasComponentLayout>
<div className="p-5">
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="text-xl font-semibold">Logistik Pintar</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Queue pengiriman dengan ETA, risiko, dan aksi penyelesaian.
</p>
</div>
<BaseIcon path={resolveIcon('mdiTruckDeliveryOutline')} className={iconsColor} size={32} />
</div>
<div className="space-y-4">
{jobs.map((job) => (
<div key={job.id} className="rounded-2xl border border-gray-100 p-4 dark:border-dark-700">
<div className="flex flex-col justify-between gap-3 md:flex-row md:items-start">
<div>
<div className="flex flex-wrap items-center gap-2">
<h4 className="font-semibold">{job.code}</h4>
<span
className={`rounded-full px-2 py-1 text-xs font-bold ${
job.risk === 'Rendah'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-200'
: job.risk === 'Tinggi'
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-200'
: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-200'
}`}
>
Risiko {job.risk}
</span>
</div>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{job.destination} {job.courier}
</p>
</div>
<div className="text-left md:text-right">
<p className="font-semibold">{job.status}</p>
<p className="text-sm text-gray-500 dark:text-gray-400">ETA {job.eta}</p>
</div>
</div>
<BaseButton
label={job.status === 'Terkirim' ? 'Sudah Terkirim' : 'Tandai Terkirim'}
icon={resolveIcon('mdiCheckCircleOutline')}
color={job.status === 'Terkirim' ? 'success' : 'info'}
small
disabled={job.status === 'Terkirim'}
className="mt-3 w-full"
onClick={() => dispatchShipment(job.id)}
/>
</div>
))}
</div>
</div>
</CardBox>
</div>
</SectionMain>
</>
);
};
VortaSynapsePage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default VortaSynapsePage;

File diff suppressed because it is too large Load Diff

View File

@ -25,11 +25,11 @@ interface StyleObject {
} }
export const white: StyleObject = { export const white: StyleObject = {
aside: 'bg-white dark:text-white', aside: 'bg-white text-black',
asideScrollbars: 'aside-scrollbars-light', asideScrollbars: 'aside-scrollbars-light',
asideBrand: '', asideBrand: 'bg-white text-black',
asideMenuItem: 'text-gray-700 hover:bg-gray-100/70 dark:text-dark-500 dark:hover:text-white dark:hover:bg-dark-800', asideMenuItem: 'text-black hover:bg-gray-100',
asideMenuItemActive: 'font-bold text-black dark:text-white', asideMenuItemActive: 'font-bold text-black',
asideMenuDropdown: 'bg-gray-100/75', asideMenuDropdown: 'bg-gray-100/75',
navBarItemLabel: 'text-blue-600', navBarItemLabel: 'text-blue-600',
navBarItemLabelHover: 'hover:text-black', navBarItemLabelHover: 'hover:text-black',
@ -53,14 +53,14 @@ export const white: StyleObject = {
export const midnightBlueTheme: StyleObject = { export const midnightBlueTheme: StyleObject = {
aside: 'bg-midnightBlueTheme-800 text-midnightBlueTheme-text dark:text-white lg:rounded-lg', aside: 'bg-white text-black lg:rounded-lg',
asideScrollbars: 'aside-scrollbars-blue', asideScrollbars: 'aside-scrollbars-blue',
asideBrand: 'text-blue-500 bg-white', asideBrand: 'bg-white text-black',
asideMenuItem: asideMenuItem:
'text-midnightBlueTheme-text hover:text-white dark:text-dark-500 dark:hover:text-white dark:hover:bg-dark-800 dark:text-white', 'text-black hover:bg-gray-100',
asideMenuItemActive: 'font-bold text-white dark:text-white', asideMenuItemActive: 'font-bold text-black',
activeLinkColor: 'bg-midnightBlueTheme-buttonColor rounded-lg', activeLinkColor: 'bg-gray-100 rounded-lg',
asideMenuDropdown: 'bg-blue-700/50', asideMenuDropdown: 'bg-gray-100/75',
navBarItemLabel: 'text-primaryText', navBarItemLabel: 'text-primaryText',
iconsColor: 'text-midnightBlueTheme-iconsColor dark:text-blue-500', iconsColor: 'text-midnightBlueTheme-iconsColor dark:text-blue-500',
navBarItemLabelHover: 'hover:text-stone-400', navBarItemLabelHover: 'hover:text-stone-400',
@ -109,12 +109,12 @@ export const dataGridStyles = {
}; };
export const basic: StyleObject = { export const basic: StyleObject = {
aside: 'bg-gray-800', aside: 'bg-white text-black',
asideScrollbars: 'aside-scrollbars-gray', asideScrollbars: 'aside-scrollbars-gray',
asideBrand: 'bg-gray-900 text-white', asideBrand: 'bg-white text-black',
asideMenuItem: 'text-gray-300 hover:text-white', asideMenuItem: 'text-black hover:bg-gray-100',
asideMenuItemActive: 'font-bold text-white', asideMenuItemActive: 'font-bold text-black',
asideMenuDropdown: 'bg-gray-700/50', asideMenuDropdown: 'bg-gray-100/75',
navBarItemLabel: 'text-black', navBarItemLabel: 'text-black',
navBarItemLabelHover: 'hover:text-blue-500', navBarItemLabelHover: 'hover:text-blue-500',
navBarItemLabelActiveColor: 'text-blue-600', navBarItemLabelActiveColor: 'text-blue-600',

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB