diff --git a/.env.example b/.env.example index f98b9a9..14549c1 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,8 @@ AUTH_2FA_MAX_ATTEMPTS=5 AUTH_2FA_WINDOW_MINUTES=10 AUTH_PASSWORD_RESET_MAX_ATTEMPTS=3 AUTH_PASSWORD_RESET_WINDOW_MINUTES=30 +MIDTRANS_ENV=sandbox +MIDTRANS_SERVER_KEY=SB-Mid-server-replace-with-real-key +MIDTRANS_CLIENT_KEY=SB-Mid-client-replace-with-real-key +MIDTRANS_MERCHANT_ID=G000000000 +MIDTRANS_ALLOWED_PAYMENT_TYPES=gopay,shopeepay,bank_transfer,credit_card diff --git a/PRODUCTION_CHECKLIST.md b/PRODUCTION_CHECKLIST.md index 1df3cd4..2397a22 100644 --- a/PRODUCTION_CHECKLIST.md +++ b/PRODUCTION_CHECKLIST.md @@ -86,7 +86,9 @@ Status yang dipakai: - [x] Webhook retry/replay dasar tersedia - [x] Callback URL production target sudah ditetapkan: `https://portal.bizone.id/api/webhooks/whatsapp` - [x] Health check production target sudah ditetapkan: `https://portal.bizone.id/api/health` +- [x] Midtrans notification target sudah ditetapkan: `https://portal.bizone.id/api/wallet/midtrans/notification` - [ ] Provider real test terhadap Meta +- [ ] Payment notification real test terhadap Midtrans sandbox/production - [ ] Failure handling terhadap response Meta nyata tervalidasi - [ ] Webhook observability yang lebih matang diff --git a/PRODUCTION_READINESS.md b/PRODUCTION_READINESS.md new file mode 100644 index 0000000..8762881 --- /dev/null +++ b/PRODUCTION_READINESS.md @@ -0,0 +1,424 @@ +# Production Readiness Checklist + +Dokumen ini merangkum kesiapan aplikasi BizOne Portal sebelum naik production live. + +Status saat ini: + +- Staging-ready: hampir siap +- Production-ready: belum +- Rekomendasi: jangan deploy production live sebelum semua item `Blocker` selesai + +## Blocker + +Item berikut wajib selesai sebelum production. + +### 1. Rotate semua secret yang pernah tersimpan di repo + +Status: Belum selesai + +Risiko: + +- Secret/password/token di file example atau dokumentasi harus dianggap bocor jika pernah masuk repo. +- Credential lama tidak boleh dipakai di production. + +Yang harus dilakukan: + +- Bersihkan credential asli dari `deploy/debian12/app.env.example`. +- Bersihkan credential asli dari `.env.example` jika ada. +- Bersihkan potongan secret/password dari `README.md` atau dokumentasi lain. +- Generate secret baru untuk production. +- Pakai secret baru hanya di `.env` server, bukan di repo. + +Secret yang wajib di-rotate: + +- `DATABASE_URL` password database +- `JWT_SECRET` +- `JWT_REFRESH_SECRET` +- `WEBHOOK_VERIFY_TOKEN` +- `WEBHOOK_SHARED_SECRET` +- `META_WEBHOOK_APP_SECRET` +- `MAIL_PASSWORD` + +Acceptance criteria: + +- Tidak ada credential asli di repo. +- Production `.env` hanya ada di server. +- Semua secret production berbeda dari secret yang pernah ditulis di repo. + +### 2. Jalankan build final backend + +Status: Belum dijalankan final setelah perubahan terbaru + +Command: + +```bash +cd backend +npm run db:generate +npm run build +``` + +Acceptance criteria: + +- Build backend sukses tanpa error TypeScript. +- Prisma client berhasil generate. +- `dist/main.js` tersedia dan bisa dijalankan. + +### 3. Jalankan build final frontend + +Status: Belum dijalankan final setelah perubahan UI terbaru + +Command: + +```bash +cd frontend +npm run build +``` + +Acceptance criteria: + +- Build Next.js sukses. +- Tidak ada TypeScript error. +- Tidak ada runtime import error. +- Tidak ada route yang gagal saat build. + +### 4. Jalankan migration deploy di database production/staging + +Status: Belum diverifikasi final + +Command: + +```bash +cd backend +npm run db:migrate:deploy +``` + +Acceptance criteria: + +- Migration selesai tanpa error. +- Tabel utama tersedia. +- Backend health check mengembalikan database `ok`. + +### 5. Smoke test end-to-end production flow + +Status: Belum selesai + +Minimal test: + +- Login admin berhasil. +- Ganti password admin berhasil. +- Aktifkan 2FA berhasil. +- Dashboard bisa dibuka. +- User management bisa load. +- Contacts bisa load, create, import/export minimal dicek. +- Conversations bisa load. +- WhatsApp API Setting bisa load dan save. +- Sensitive value tidak tampil plain text saat `NODE_ENV=production`. +- Webhook verification dari Meta berhasil. +- Webhook event masuk ke logs. +- Pesan inbound WhatsApp masuk ke Conversations. +- Balas pesan dari dashboard berhasil jika token Meta valid. +- Audit trail mencatat aksi penting. + +Acceptance criteria: + +- Semua flow utama di atas lolos tanpa error 500. +- Browser console tidak menunjukkan runtime error kritikal. +- Backend log tidak menunjukkan exception berulang. + +## High Priority + +Item berikut sangat disarankan selesai sebelum production, tapi bisa dipisah setelah blocker jika deployment sangat mendesak. + +### 1. Production nginx security headers masuk ke config repo + +Status: Belum masuk final config repo + +Tambahkan ke `deploy/debian12/nginx.portal.bizone.id.conf`: + +```nginx +add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; +add_header X-Frame-Options "SAMEORIGIN" always; +add_header X-Content-Type-Options "nosniff" always; +add_header Referrer-Policy "strict-origin-when-cross-origin" always; +add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; +``` + +Acceptance criteria: + +- `nginx -t` sukses. +- Header muncul di response HTTPS. + +### 2. Frontend Dockerfile production-ready + +Status: Belum siap + +Masalah: + +- `frontend/Dockerfile` masih menjalankan `npm run dev`. + +Opsi: + +- Jika deployment pakai systemd, Dockerfile ini tidak blocker. +- Jika deployment pakai Docker, wajib ubah ke production build. + +Acceptance criteria: + +- Docker image frontend menjalankan `next start`, bukan `next dev`. + +### 3. Dependency audit + +Status: Belum dijalankan + +Command: + +```bash +npm audit +cd backend && npm audit +cd ../frontend && npm audit +``` + +Acceptance criteria: + +- Tidak ada critical vulnerability. +- High vulnerability punya keputusan: fix, upgrade, atau accepted risk tertulis. + +### 4. Review penggunaan Next 15.0.0 dan React RC + +Status: Perlu review + +Risiko: + +- React RC dan Next awal versi major bisa punya issue kompatibilitas. + +Acceptance criteria: + +- Build final sukses. +- Smoke test UI sukses. +- Tidak ada issue runtime kritikal. +- Jika memungkinkan, upgrade ke versi stabil yang kompatibel. + +### 5. Backup PostgreSQL dan Redis + +Status: Belum ada strategi final + +Minimal PostgreSQL backup: + +```bash +pg_dump "$DATABASE_URL" > backup-$(date +%F).sql +``` + +Yang perlu disiapkan: + +- Backup harian. +- Retention minimal 7-14 hari. +- Lokasi backup di luar server utama jika memungkinkan. +- Restore test minimal satu kali. + +Acceptance criteria: + +- Backup otomatis berjalan. +- Restore procedure terdokumentasi. + +## Medium Priority + +Item berikut tidak selalu blocker, tapi penting untuk operasional production yang sehat. + +### 1. Monitoring uptime + +Status: Belum ada + +Endpoint yang dipantau: + +```text +https://portal.bizone.id/api/health +``` + +Acceptance criteria: + +- Alert aktif saat endpoint non-200. +- Alert aktif saat response terlalu lambat. + +### 2. Log retention + +Status: Belum dikunci + +Cek journald: + +```bash +sudo journalctl --disk-usage +``` + +Rekomendasi: + +- Batasi ukuran journald. +- Pastikan log backend/frontend bisa dibaca saat incident. + +### 3. Forced first-login password change + +Status: Belum ada secara code + +Risiko: + +- Seed admin default bisa lupa diganti. + +Acceptance criteria: + +- Admin bootstrap dipaksa ganti password saat login pertama, atau proses operasional mewajibkan rotate manual sebelum go-live. + +### 4. Role permission enforcement review + +Status: Perlu review final + +Yang perlu dicek: + +- User biasa tidak bisa akses halaman admin. +- Role non-admin tidak bisa mengubah settings, users, roles, audit trail. +- API backend menolak akses yang tidak sesuai role. + +Acceptance criteria: + +- Permission UI dan backend konsisten. + +### 5. Translation sweep + +Status: Sebagian besar halaman utama sudah dirapikan + +Perlu cek lagi: + +- Detail contact +- Detail campaign +- Detail template/builder edge case +- Auth pages forgot/reset/set password +- Notification center +- Global search modal + +Acceptance criteria: + +- Tidak ada string penting yang hardcoded di halaman utama production. + +## Done / Already Good + +Item berikut sudah terlihat cukup baik dari code saat ini. + +### 1. Env validation backend production + +Status: Sudah ada + +Catatan: + +- Production menolak secret pendek/placeholder. +- Production menolak `WEBHOOK_ALLOW_UNSIGNED=true`. +- Production mewajibkan URL HTTPS untuk origin/public API. + +### 2. Sensitive WhatsApp values tidak tampil di production + +Status: Sudah ada + +Catatan: + +- Backend hanya mengembalikan sensitive values saat `!isProduction`. +- Pastikan `NODE_ENV=production` benar di server. + +### 3. Cookie secure di production + +Status: Sudah ada + +Catatan: + +- Frontend cookie memakai `secure: process.env.NODE_ENV === 'production'`. +- `sameSite` memakai `lax`. + +### 4. CORS dibatasi via `FRONTEND_ORIGIN` + +Status: Sudah ada + +Catatan: + +- Backend membaca allowed origin dari env. + +### 5. Prisma migration deploy tersedia + +Status: Sudah ada + +Command: + +```bash +cd backend +npm run db:migrate:deploy +``` + +### 6. Systemd deployment tersedia + +Status: Sudah ada + +File: + +- `deploy/debian12/bizone-backend.service` +- `deploy/debian12/bizone-frontend.service` + +### 7. Nginx reverse proxy config tersedia + +Status: Ada, perlu hardening headers + +File: + +- `deploy/debian12/nginx.portal.bizone.id.conf` + +### 8. PostgreSQL dan Redis infra compose tersedia + +Status: Sudah ada + +File: + +- `deploy/debian12/docker-compose.infra.yml` + +## Go / No-Go Decision + +### Go staging + +Boleh jika: + +- Backend build sukses. +- Frontend build sukses. +- Migration deploy sukses. +- Staging `.env` aman. + +### Go production + +Boleh jika semua ini selesai: + +- Semua blocker selesai. +- Secret sudah di-rotate. +- Build backend/frontend final sukses. +- Migration production sukses. +- Smoke test end-to-end sukses. +- Nginx HTTPS aktif. +- Backup minimal sudah ada. + +### No-go production + +Jangan production jika salah satu ini masih terjadi: + +- Secret asli masih ada di repo. +- Build frontend/backend gagal. +- Migration gagal. +- Login/admin flow gagal. +- Webhook production gagal diverifikasi. +- Database belum punya backup. +- `NODE_ENV` bukan `production`. +- `WEBHOOK_ALLOW_UNSIGNED=true`. + +## Suggested production run order + +1. Bersihkan repo dari secret dan rotate semua credential. +2. Siapkan `.env` production di server. +3. Jalankan infra PostgreSQL/Redis. +4. Jalankan `npm install` root, backend, frontend. +5. Jalankan `npm run db:generate`. +6. Jalankan backend build. +7. Jalankan frontend build. +8. Jalankan migration deploy. +9. Restart systemd services. +10. Reload nginx. +11. Smoke test health, login, dashboard, webhook, conversation. +12. Aktifkan backup dan monitoring. diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index c0ec81d..e8de96d 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -14,6 +14,7 @@ import { UsersModule } from './users/users.module'; import { CampaignsModule } from './campaigns/campaigns.module'; import { ConversationsModule } from './conversations/conversations.module'; import { TemplatesModule } from './templates/templates.module'; +import { WalletModule } from './wallet/wallet.module'; @Module({ imports: [ @@ -32,6 +33,7 @@ import { TemplatesModule } from './templates/templates.module'; TemplatesModule, CampaignsModule, ConversationsModule, + WalletModule, ], }) export class AppModule {} diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index c3e045a..620f028 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Post, Req, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common'; import type { Request } from 'express'; import { AuthService } from './auth.service'; import { LoginDto } from './dto/login.dto'; @@ -10,6 +10,8 @@ import { RequestPasswordResetDto } from './dto/request-password-reset.dto'; import { CompletePasswordResetDto } from './dto/complete-password-reset.dto'; import { TwoFactorCodeDto } from './dto/two-factor-code.dto'; import { VerifyTwoFactorLoginDto } from './dto/verify-two-factor-login.dto'; +import { UpdateProfileDto } from './dto/update-profile.dto'; +import { ChangePasswordDto } from './dto/change-password.dto'; @Controller('auth') export class AuthController { @@ -82,6 +84,28 @@ export class AuthController { return this.authService.getCurrentSession(request.user, ipAddress); } + @UseGuards(AuthGuard) + @Patch('profile') + updateProfile( + @Req() request: Request & { user: AuthenticatedUser }, + @Body() body: UpdateProfileDto, + ) { + const forwarded = request.headers['x-forwarded-for']; + const ipAddress = typeof forwarded === 'string' ? forwarded.split(',')[0]?.trim() : request.ip; + return this.authService.updateProfile(request.user, body, ipAddress); + } + + @UseGuards(AuthGuard) + @Patch('profile/password') + changePassword( + @Req() request: Request & { user: AuthenticatedUser }, + @Body() body: ChangePasswordDto, + ) { + const forwarded = request.headers['x-forwarded-for']; + const ipAddress = typeof forwarded === 'string' ? forwarded.split(',')[0]?.trim() : request.ip; + return this.authService.changePassword(request.user, body.currentPassword, body.newPassword, ipAddress); + } + @UseGuards(AuthGuard) @Get('2fa/status') getTwoFactorStatus(@Req() request: Request & { user: AuthenticatedUser }) { diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 1ed5794..528301a 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -337,6 +337,9 @@ export class AuthService { name: true, email: true, status: true, + role: { + select: { name: true }, + }, lastLoginAt: true, refreshTokenExpiresAt: true, twoFactorEnabled: true, @@ -354,6 +357,7 @@ export class AuthService { name: user.name, email: user.email, status: user.status, + roleName: user.role?.name || 'Unassigned', lastLoginAt: user.lastLoginAt, twoFactorEnabled: user.twoFactorEnabled, twoFactorConfirmedAt: user.twoFactorConfirmedAt, @@ -368,6 +372,111 @@ export class AuthService { }; } + async updateProfile(authUser: AuthenticatedUser, dto: { name?: string }, ipAddress?: string) { + const current = await this.prisma.user.findUnique({ + where: { id: authUser.sub }, + select: { id: true, name: true, email: true }, + }); + + if (!current) { + throw new UnauthorizedException('User not found'); + } + + const name = dto.name !== undefined ? dto.name.trim() : current.name; + + if (!name) { + throw new HttpException('Name is required', HttpStatus.BAD_REQUEST); + } + + const user = await this.prisma.user.update({ + where: { id: current.id }, + data: { + name, + }, + include: { + role: { + select: { name: true }, + }, + }, + }); + + await this.prisma.auditLog.create({ + data: { + actorUserId: user.id, + actorName: user.name, + actorEmail: user.email, + actionType: 'Profile Updated', + module: 'Account Profile', + ipAddress: ipAddress || null, + severity: 'default', + details: `Updated profile for ${user.email}.`, + }, + }); + + const response = { + id: user.id, + name: user.name, + email: user.email, + status: user.status, + roleName: user.role?.name || 'Unassigned', + lastLoginAt: user.lastLoginAt, + twoFactorEnabled: user.twoFactorEnabled, + twoFactorConfirmedAt: user.twoFactorConfirmedAt, + }; + + return response; + } + + async changePassword(authUser: AuthenticatedUser, currentPassword: string, newPassword: string, ipAddress?: string) { + if (!hasMinimumPasswordLength(newPassword)) { + throw new HttpException('New password must be at least 8 characters', HttpStatus.BAD_REQUEST); + } + + const user = await this.prisma.user.findUnique({ + where: { id: authUser.sub }, + select: { + id: true, + name: true, + email: true, + passwordHash: true, + }, + }); + + if (!user || !user.passwordHash) { + throw new UnauthorizedException('User not found'); + } + + const isPasswordValid = await comparePassword(currentPassword, user.passwordHash); + if (!isPasswordValid) { + throw new UnauthorizedException('Current password is incorrect'); + } + + await this.prisma.user.update({ + where: { id: user.id }, + data: { + passwordHash: await hashPassword(newPassword), + sessionVersion: { increment: 1 }, + refreshTokenHash: null, + refreshTokenExpiresAt: null, + }, + }); + + await this.prisma.auditLog.create({ + data: { + actorUserId: user.id, + actorName: user.name, + actorEmail: user.email, + actionType: 'Password Changed', + module: 'Account Profile', + ipAddress: ipAddress || null, + severity: 'warning', + details: `Changed password for ${user.email}.`, + }, + }); + + return { success: true }; + } + async getTwoFactorStatus(userId: string) { const twoFactor = await this.getTwoFactorRecord(userId); if (!twoFactor) { diff --git a/backend/src/auth/dto/change-password.dto.ts b/backend/src/auth/dto/change-password.dto.ts new file mode 100644 index 0000000..10ed666 --- /dev/null +++ b/backend/src/auth/dto/change-password.dto.ts @@ -0,0 +1,9 @@ +import { IsString } from 'class-validator'; + +export class ChangePasswordDto { + @IsString() + currentPassword!: string; + + @IsString() + newPassword!: string; +} diff --git a/backend/src/auth/dto/update-profile.dto.ts b/backend/src/auth/dto/update-profile.dto.ts new file mode 100644 index 0000000..993a8da --- /dev/null +++ b/backend/src/auth/dto/update-profile.dto.ts @@ -0,0 +1,8 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class UpdateProfileDto { + @IsString() + @IsOptional() + name?: string; + +} diff --git a/backend/src/campaigns/campaign-worker.service.ts b/backend/src/campaigns/campaign-worker.service.ts index bf02b16..c56db73 100644 --- a/backend/src/campaigns/campaign-worker.service.ts +++ b/backend/src/campaigns/campaign-worker.service.ts @@ -1,4 +1,4 @@ -import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { Inject, Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import type { Job as BullJob, Worker } from 'bullmq'; import { JobsService } from '../jobs/jobs.service'; import { RedisQueueService } from '../jobs/redis-queue.service'; @@ -10,6 +10,7 @@ export class CampaignWorkerService implements OnModuleInit, OnModuleDestroy { constructor( private readonly jobsService: JobsService, + @Inject(RedisQueueService) private readonly redisQueueService: RedisQueueService, private readonly campaignsService: CampaignsService, ) {} diff --git a/backend/src/campaigns/campaigns.controller.ts b/backend/src/campaigns/campaigns.controller.ts index 606f5ab..44006ed 100644 --- a/backend/src/campaigns/campaigns.controller.ts +++ b/backend/src/campaigns/campaigns.controller.ts @@ -81,6 +81,12 @@ export class CampaignsController { return this.campaignsService.send(id, dto, request.user, request.ip); } + @Get(':id/estimate-cost') + @RequirePermission('campaigns', 'view') + estimateCost(@Param('id') id: string) { + return this.campaignsService.estimateCost(id); + } + @Get(':id/export') @RequirePermission('campaigns', 'view') async export( diff --git a/backend/src/campaigns/campaigns.module.ts b/backend/src/campaigns/campaigns.module.ts index 9a9387e..0a2bc4a 100644 --- a/backend/src/campaigns/campaigns.module.ts +++ b/backend/src/campaigns/campaigns.module.ts @@ -3,12 +3,13 @@ import { AuthModule } from '../auth/auth.module'; import { JobsModule } from '../jobs/jobs.module'; import { PrismaModule } from '../prisma/prisma.module'; import { TemplatesModule } from '../templates/templates.module'; +import { WalletModule } from '../wallet/wallet.module'; import { CampaignWorkerService } from './campaign-worker.service'; import { CampaignsController } from './campaigns.controller'; import { CampaignsService } from './campaigns.service'; @Module({ - imports: [PrismaModule, AuthModule, JobsModule, TemplatesModule], + imports: [PrismaModule, AuthModule, JobsModule, TemplatesModule, WalletModule], controllers: [CampaignsController], providers: [CampaignsService, CampaignWorkerService], exports: [CampaignsService], diff --git a/backend/src/campaigns/campaigns.service.ts b/backend/src/campaigns/campaigns.service.ts index 915bcdd..abd4670 100644 --- a/backend/src/campaigns/campaigns.service.ts +++ b/backend/src/campaigns/campaigns.service.ts @@ -7,6 +7,7 @@ import { normalizeText } from '../common/normalize'; import { JobsService } from '../jobs/jobs.service'; import { PrismaService } from '../prisma/prisma.service'; import { TemplatesService } from '../templates/templates.service'; +import { WalletService } from '../wallet/wallet.service'; import { CreateCampaignDto } from './dto/create-campaign.dto'; import { SendCampaignDto } from './dto/send-campaign.dto'; import { UpdateCampaignDto } from './dto/update-campaign.dto'; @@ -157,6 +158,7 @@ export class CampaignsService { private readonly prisma: PrismaService, private readonly jobsService: JobsService, private readonly templatesService: TemplatesService, + private readonly walletService: WalletService, ) {} async findAll() { @@ -410,6 +412,21 @@ export class CampaignsService { return { id: campaign.id, deleted: true }; } + async estimateCost(id: string) { + await this.ensureSeedData(); + const campaign = await this.prisma.campaign.findUnique({ where: { id } }); + + if (!campaign) { + throw new NotFoundException('Campaign not found'); + } + + return { + campaignId: campaign.id, + campaignName: campaign.name, + ...this.walletService.estimateBroadcastCost(campaign.totalRecipients), + }; + } + async send(id: string, dto: SendCampaignDto, user: AuthenticatedUser, ipAddress?: string) { await this.ensureSeedData(); const actor = await this.findActor(user.sub, user.email); @@ -419,6 +436,12 @@ export class CampaignsService { throw new NotFoundException('Campaign not found'); } + const walletCheck = await this.walletService.assertSufficientBalanceForBroadcast({ + id: campaign.id, + name: campaign.name, + totalRecipients: campaign.totalRecipients, + }); + const requestedMode = dto.mode === 'scheduled' ? 'scheduled' : 'now'; const requestedScheduleAt = dto.scheduledAt ? new Date(dto.scheduledAt) @@ -463,6 +486,7 @@ export class CampaignsService { campaignId: id, jobId: job.id, availableAt: availableAt.toISOString(), + walletCheck, } as Prisma.InputJsonValue, }, }); @@ -472,6 +496,7 @@ export class CampaignsService { jobId: job.id, status: shouldSchedule ? 'Scheduled' : 'Queued', availableAt: availableAt.toISOString(), + walletCheck, }; } @@ -914,6 +939,12 @@ export class CampaignsService { }, }); + const walletCharge = await this.walletService.chargeSuccessfulBroadcastInTransaction( + tx, + { id: campaign.id, name: campaign.name }, + deliveredCount, + ); + await tx.auditLog.create({ data: { actorUserId: null, @@ -922,7 +953,14 @@ export class CampaignsService { actionType: 'Campaign Delivered', module: 'Campaigns', severity: 'default', - details: `Processed campaign ${campaign.name} with ${totalRecipients} recipients.`, + details: `Processed campaign ${campaign.name} with ${totalRecipients} recipients and charged ${deliveredCount} successful messages.`, + metadataJson: { + campaignId: campaign.id, + totalRecipients, + deliveredCount, + failedCount, + walletCharge, + } as Prisma.InputJsonValue, }, }); }); diff --git a/backend/src/common/permission.guard.ts b/backend/src/common/permission.guard.ts index ff9fd08..5907f44 100644 --- a/backend/src/common/permission.guard.ts +++ b/backend/src/common/permission.guard.ts @@ -25,6 +25,7 @@ const fallbackRolePermissions: Record { const configJson = (storedConfig?.configJson as Record | null) ?? {}; + const sharedSecret = String(configJson.sharedSecret || config.webhookSharedSecret || ''); + const appSecret = String(configJson.appSecret || config.metaWebhookAppSecret || ''); + const accessToken = typeof configJson.accessToken === 'string' ? configJson.accessToken : ''; + const showSensitiveValues = !config.isProduction; return { provider: String(configJson.provider || storedConfig?.provider || 'meta'), webhookUrl: `${config.publicApiUrl.replace(/\/$/, '')}/api/webhooks/whatsapp`, verifyToken: String(configJson.webhookVerifyToken || config.webhookVerifyToken), status: storedConfig ? (storedConfig.isEnabled ? 'configured' : 'disabled') : 'not-configured', - hasSharedSecret: Boolean(configJson.sharedSecret || config.webhookSharedSecret), - hasAppSecret: Boolean(configJson.appSecret || config.metaWebhookAppSecret), - hasAccessToken: Boolean(configJson.accessToken), + hasSharedSecret: Boolean(sharedSecret), + hasAppSecret: Boolean(appSecret), + hasAccessToken: Boolean(accessToken), + showSensitiveValues, + sharedSecret: showSensitiveValues ? sharedSecret : '', + appSecret: showSensitiveValues ? appSecret : '', + accessToken: showSensitiveValues ? accessToken : '', phoneNumberId: typeof configJson.phoneNumberId === 'string' ? configJson.phoneNumberId : '', isEnabled: storedConfig?.isEnabled ?? true, subscriptions: Array.isArray(configJson.subscriptions) diff --git a/backend/src/jobs/redis-queue.service.ts b/backend/src/jobs/redis-queue.service.ts index df4f08d..028c620 100644 --- a/backend/src/jobs/redis-queue.service.ts +++ b/backend/src/jobs/redis-queue.service.ts @@ -5,13 +5,21 @@ import { getAppConfig } from '../config/env'; @Injectable() export class RedisQueueService implements OnModuleDestroy { - private readonly connection = new IORedis(getAppConfig().redisUrl, { - maxRetriesPerRequest: null, - }); + private readonly useMemoryStore = getAppConfig().redisUrl === 'memory'; + private readonly connection = this.useMemoryStore + ? null + : new IORedis(getAppConfig().redisUrl, { + maxRetriesPerRequest: null, + }); private readonly queues = new Map(); private readonly workers: Worker[] = []; + private readonly memoryCounters = new Map(); getQueue(name: string) { + if (!this.connection) { + return null; + } + const existing = this.queues.get(name); if (existing) { return existing; @@ -26,20 +34,55 @@ export class RedisQueueService implements OnModuleDestroy { async enqueue(name: string, jobName: string, data: Record, options?: JobsOptions) { const queue = this.getQueue(name); + if (!queue) { + return; + } + await queue.add(jobName, data, options); } async getCounter(key: string) { + if (!this.connection) { + const item = this.memoryCounters.get(key); + if (!item || item.expiresAt <= Date.now()) { + this.memoryCounters.delete(key); + return 0; + } + + return item.value; + } + const value = await this.connection.get(key); return value ? Number(value) : 0; } async getTtlSeconds(key: string) { + if (!this.connection) { + const item = this.memoryCounters.get(key); + if (!item || item.expiresAt <= Date.now()) { + this.memoryCounters.delete(key); + return 0; + } + + return Math.ceil((item.expiresAt - Date.now()) / 1000); + } + const ttl = await this.connection.ttl(key); return ttl > 0 ? ttl : 0; } async incrementCounter(key: string, ttlSeconds: number) { + if (!this.connection) { + const now = Date.now(); + const existing = this.memoryCounters.get(key); + const nextValue = existing && existing.expiresAt > now ? existing.value + 1 : 1; + this.memoryCounters.set(key, { + value: nextValue, + expiresAt: existing && existing.expiresAt > now ? existing.expiresAt : now + ttlSeconds * 1000, + }); + return nextValue; + } + const nextCount = await this.connection.incr(key); if (nextCount === 1) { await this.connection.expire(key, ttlSeconds); @@ -49,10 +92,19 @@ export class RedisQueueService implements OnModuleDestroy { } async deleteKey(key: string) { + if (!this.connection) { + this.memoryCounters.delete(key); + return; + } + await this.connection.del(key); } createWorker(name: string, processor: Processor) { + if (!this.connection) { + return null; + } + const worker = new Worker(name, processor, { connection: this.connection, concurrency: 5, @@ -64,6 +116,6 @@ export class RedisQueueService implements OnModuleDestroy { async onModuleDestroy() { await Promise.all(this.workers.map((worker) => worker.close())); await Promise.all(Array.from(this.queues.values()).map((queue) => queue.close())); - await this.connection.quit(); + await this.connection?.quit(); } } diff --git a/backend/src/logs/logs.controller.ts b/backend/src/logs/logs.controller.ts index bde2bf1..83f9a23 100644 --- a/backend/src/logs/logs.controller.ts +++ b/backend/src/logs/logs.controller.ts @@ -69,8 +69,10 @@ export class LogsController { @Query('actionType') actionType?: string, @Query('module') moduleName?: string, @Query('search') search?: string, + @Query('format') format?: string, ) { - const csv = await this.logsService.exportAuditTrailCsv( + const result = await this.logsService.exportAuditTrail( + format === 'xlsx' ? 'xlsx' : 'csv', range, actorName, actionType, @@ -78,9 +80,9 @@ export class LogsController { search, ); - response.setHeader('Content-Type', 'text/csv; charset=utf-8'); - response.setHeader('Content-Disposition', 'attachment; filename="audit-trail-export.csv"'); - response.send(csv); + response.setHeader('Content-Type', result.contentType); + response.setHeader('Content-Disposition', `attachment; filename="${result.fileName}"`); + response.send(result.buffer); } @Post('audit-trail') diff --git a/backend/src/logs/logs.service.ts b/backend/src/logs/logs.service.ts index 6b8ecf3..3e45aff 100644 --- a/backend/src/logs/logs.service.ts +++ b/backend/src/logs/logs.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; +import * as XLSX from 'xlsx'; import { AuthenticatedUser } from '../auth/auth.types'; import { JobsService } from '../jobs/jobs.service'; import { PrismaService } from '../prisma/prisma.service'; @@ -78,7 +79,8 @@ export class LogsService { }); } - async exportAuditTrailCsv( + async exportAuditTrail( + format: 'csv' | 'xlsx', range?: string, actorName?: string, actionType?: string, @@ -86,23 +88,41 @@ export class LogsService { search?: string, ) { const result = await this.getAuditTrail(1, 5000, range, actorName, actionType, moduleName, search); + const rows = result.items.map((item) => ({ + Timestamp: item.createdAt.toISOString(), + 'Admin User': item.actorName, + 'Admin Email': item.actorEmail || '', + 'Action Type': item.actionType, + Module: item.module, + 'IP Address': item.ipAddress || '', + Severity: item.severity, + Details: item.details, + })); + + if (format === 'xlsx') { + const workbook = XLSX.utils.book_new(); + const worksheet = XLSX.utils.json_to_sheet(rows); + XLSX.utils.book_append_sheet(workbook, worksheet, 'Audit Trail'); + + return { + fileName: 'audit-trail-export.xlsx', + contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + buffer: XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }) as Buffer, + }; + } + const header = ['Timestamp', 'Admin User', 'Admin Email', 'Action Type', 'Module', 'IP Address', 'Severity', 'Details']; - const rows = result.items.map((item) => - [ - item.createdAt.toISOString(), - item.actorName, - item.actorEmail || '', - item.actionType, - item.module, - item.ipAddress || '', - item.severity, - item.details, - ] - .map((cell) => `"${String(cell).replaceAll('"', '""')}"`) + const csvRows = rows.map((row) => + header + .map((key) => `"${String(row[key as keyof typeof row]).replaceAll('"', '""')}"`) .join(','), ); - return [header.join(','), ...rows].join('\n'); + return { + fileName: 'audit-trail-export.csv', + contentType: 'text/csv; charset=utf-8', + buffer: Buffer.from([header.join(','), ...csvRows].join('\n'), 'utf8'), + }; } async createAuditLog( diff --git a/backend/src/wallet/dto/create-manual-topup.dto.ts b/backend/src/wallet/dto/create-manual-topup.dto.ts new file mode 100644 index 0000000..232fada --- /dev/null +++ b/backend/src/wallet/dto/create-manual-topup.dto.ts @@ -0,0 +1,12 @@ +import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class CreateManualTopupDto { + @IsInt() + @Min(50000) + @Max(1000000000) + amountMinor!: number; + + @IsOptional() + @IsString() + description?: string; +} diff --git a/backend/src/wallet/dto/create-midtrans-topup.dto.ts b/backend/src/wallet/dto/create-midtrans-topup.dto.ts new file mode 100644 index 0000000..fabe628 --- /dev/null +++ b/backend/src/wallet/dto/create-midtrans-topup.dto.ts @@ -0,0 +1,12 @@ +import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class CreateMidtransTopupDto { + @IsInt() + @Min(50000) + @Max(1000000000) + amountMinor!: number; + + @IsOptional() + @IsString() + description?: string; +} diff --git a/backend/src/wallet/wallet.controller.ts b/backend/src/wallet/wallet.controller.ts new file mode 100644 index 0000000..ee0ec07 --- /dev/null +++ b/backend/src/wallet/wallet.controller.ts @@ -0,0 +1,55 @@ +import { Body, Controller, Get, Post, Query, Req, UseGuards } from '@nestjs/common'; +import type { Request } from 'express'; +import { AuthenticatedUser } from '../auth/auth.types'; +import { AuthGuard } from '../common/auth.guard'; +import { RequirePermission } from '../common/permission.decorator'; +import { PermissionGuard } from '../common/permission.guard'; +import { CreateMidtransTopupDto } from './dto/create-midtrans-topup.dto'; +import { CreateManualTopupDto } from './dto/create-manual-topup.dto'; +import { WalletService } from './wallet.service'; + +@UseGuards(AuthGuard, PermissionGuard) +@Controller('wallet') +export class WalletController { + constructor(private readonly walletService: WalletService) {} + + @Get() + @RequirePermission('settings', 'view') + getSummary(@Query('limit') limit?: string) { + return this.walletService.getSummary(limit ? Number(limit) : 20); + } + + @Get('estimate-broadcast') + @RequirePermission('campaigns', 'view') + estimateBroadcast(@Query('recipients') recipients?: string) { + return this.walletService.estimateBroadcastCost(Number(recipients || '0')); + } + + @Post('topups/manual') + @RequirePermission('settings', 'manage') + createManualTopup( + @Req() request: Request & { user: AuthenticatedUser }, + @Body() dto: CreateManualTopupDto, + ) { + return this.walletService.createManualTopup(dto.amountMinor, dto.description, request.user, request.ip); + } + + @Post('topups/midtrans') + @RequirePermission('settings', 'manage') + createMidtransTopup( + @Req() request: Request & { user: AuthenticatedUser }, + @Body() dto: CreateMidtransTopupDto, + ) { + return this.walletService.createMidtransTopup(dto.amountMinor, dto.description, request.user); + } +} + +@Controller('wallet/midtrans') +export class WalletMidtransWebhookController { + constructor(private readonly walletService: WalletService) {} + + @Post('notification') + handleNotification(@Body() payload: Record) { + return this.walletService.handleMidtransNotification(payload); + } +} diff --git a/backend/src/wallet/wallet.module.ts b/backend/src/wallet/wallet.module.ts new file mode 100644 index 0000000..bf48f7a --- /dev/null +++ b/backend/src/wallet/wallet.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { WalletController, WalletMidtransWebhookController } from './wallet.controller'; +import { WalletService } from './wallet.service'; + +@Module({ + imports: [AuthModule], + controllers: [WalletController, WalletMidtransWebhookController], + providers: [WalletService], + exports: [WalletService], +}) +export class WalletModule {} diff --git a/backend/src/wallet/wallet.service.ts b/backend/src/wallet/wallet.service.ts new file mode 100644 index 0000000..e56a157 --- /dev/null +++ b/backend/src/wallet/wallet.service.ts @@ -0,0 +1,512 @@ +import { BadRequestException, HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import type { Prisma } from '@prisma/client'; +import { createHash, randomUUID } from 'node:crypto'; +import { AuthenticatedUser } from '../auth/auth.types'; +import { PrismaService } from '../prisma/prisma.service'; + +const DEFAULT_OWNER_KEY = 'default-business-wallet'; +const DEFAULT_CURRENCY = 'IDR'; +const DEFAULT_MARKETING_RATE_MINOR = 500; +const MINIMUM_TOPUP_MINOR = 50000; +const MIDTRANS_PAYMENT_TYPES = ['qris', 'gopay', 'shopeepay', 'bank_transfer', 'credit_card']; + +@Injectable() +export class WalletService { + constructor(private readonly prisma: PrismaService) {} + + estimateBroadcastCost(recipientCount: number) { + const normalizedRecipients = Math.max(0, Math.floor(Number(recipientCount) || 0)); + const unitCostMinor = DEFAULT_MARKETING_RATE_MINOR; + const estimatedAmountMinor = normalizedRecipients * unitCostMinor; + + return { + currency: DEFAULT_CURRENCY, + recipientCount: normalizedRecipients, + unitCostMinor, + estimatedAmountMinor, + }; + } + + async getSummary(limit = 20) { + const wallet = await this.getOrCreateWallet(); + const transactions = await this.prisma.walletTransaction.findMany({ + where: { walletId: wallet.id }, + orderBy: { createdAt: 'desc' }, + take: Math.min(Math.max(limit, 1), 100), + }); + + return { + wallet: this.serializeWallet(wallet), + transactions: transactions.map((transaction) => ({ + id: transaction.id, + type: transaction.type, + direction: transaction.direction, + amountMinor: transaction.amountMinor, + balanceBeforeMinor: transaction.balanceBeforeMinor, + balanceAfterMinor: transaction.balanceAfterMinor, + status: transaction.status, + referenceType: transaction.referenceType, + referenceId: transaction.referenceId, + description: transaction.description, + createdAt: transaction.createdAt.toISOString(), + })), + pricing: { + broadcastMarketingUnitCostMinor: DEFAULT_MARKETING_RATE_MINOR, + currency: DEFAULT_CURRENCY, + }, + }; + } + + async createManualTopup(amountMinor: number, description: string | undefined, user: AuthenticatedUser, ipAddress?: string) { + const normalizedAmount = Math.floor(Number(amountMinor) || 0); + if (normalizedAmount < MINIMUM_TOPUP_MINOR) { + throw new BadRequestException('Top up amount must be at least Rp 50.000.'); + } + + const actor = await this.findActor(user.sub, user.email); + const wallet = await this.getOrCreateWallet(); + + const result = await this.prisma.$transaction(async (tx) => { + const current = await tx.wallet.findUnique({ where: { id: wallet.id } }); + if (!current) throw new BadRequestException('Wallet not found'); + + const nextBalance = current.balanceMinor + normalizedAmount; + const updated = await tx.wallet.update({ + where: { id: current.id }, + data: { balanceMinor: nextBalance }, + }); + + const transaction = await tx.walletTransaction.create({ + data: { + id: randomUUID(), + walletId: current.id, + type: 'manual_topup', + direction: 'credit', + amountMinor: normalizedAmount, + balanceBeforeMinor: current.balanceMinor, + balanceAfterMinor: nextBalance, + status: 'posted', + referenceType: 'manual_adjustment', + referenceId: randomUUID(), + description: description || 'Manual wallet top up', + metadataJson: { + actorUserId: actor.id, + actorEmail: actor.email, + ipAddress: ipAddress || null, + } as Prisma.InputJsonValue, + }, + }); + + await tx.auditLog.create({ + data: { + actorUserId: actor.id, + actorName: actor.name, + actorEmail: actor.email, + actionType: 'Wallet Manual Top Up', + module: 'Wallet', + ipAddress: ipAddress || null, + severity: 'default', + details: `Added Rp ${normalizedAmount.toLocaleString('id-ID')} to wallet.`, + metadataJson: { + walletId: current.id, + transactionId: transaction.id, + amountMinor: normalizedAmount, + } as Prisma.InputJsonValue, + }, + }); + + return { wallet: updated, transaction }; + }); + + return { + wallet: this.serializeWallet(result.wallet), + transaction: { + id: result.transaction.id, + amountMinor: result.transaction.amountMinor, + direction: result.transaction.direction, + type: result.transaction.type, + createdAt: result.transaction.createdAt.toISOString(), + }, + }; + } + + async createMidtransTopup(amountMinor: number, description: string | undefined, user: AuthenticatedUser) { + const normalizedAmount = Math.floor(Number(amountMinor) || 0); + if (normalizedAmount < MINIMUM_TOPUP_MINOR) { + throw new BadRequestException('Top up amount must be at least Rp 50.000.'); + } + + const serverKey = process.env.MIDTRANS_SERVER_KEY; + if (!serverKey) { + throw new BadRequestException('Midtrans server key is not configured.'); + } + + const actor = await this.findActor(user.sub, user.email); + const wallet = await this.getOrCreateWallet(); + const providerOrderId = `BIZONE-TOPUP-${Date.now()}-${randomUUID().slice(0, 8)}`; + const frontendOrigin = process.env.FRONTEND_ORIGIN || 'http://localhost:3000'; + const apiBaseUrl = this.getMidtransSnapBaseUrl(); + const enabledPayments = this.getMidtransEnabledPayments(); + + const order = await this.prisma.paymentOrder.create({ + data: { + id: randomUUID(), + walletId: wallet.id, + provider: 'midtrans', + providerOrderId, + amountMinor: normalizedAmount, + currency: DEFAULT_CURRENCY, + status: 'pending', + expiredAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + metadataJson: { + description: description || 'Wallet top up', + actorUserId: actor.id, + actorEmail: actor.email, + enabledPayments, + gatewayFeeChargedToCustomer: true, + } as Prisma.InputJsonValue, + }, + }); + + const response = await fetch(`${apiBaseUrl}/snap/v1/transactions`, { + method: 'POST', + headers: { + Authorization: `Basic ${Buffer.from(`${serverKey}:`).toString('base64')}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + transaction_details: { + order_id: providerOrderId, + gross_amount: normalizedAmount, + }, + enabled_payments: enabledPayments, + credit_card: { + secure: true, + }, + customer_details: { + first_name: actor.name || 'BizOne User', + email: actor.email || undefined, + }, + item_details: [ + { + id: 'wallet-topup', + price: normalizedAmount, + quantity: 1, + name: description || 'BizOne Wallet Top Up', + }, + ], + callbacks: { + finish: `${frontendOrigin}/dashboard/wallet`, + }, + expiry: { + unit: 'hour', + duration: 24, + }, + }), + }); + + const payload = await response.json().catch(() => ({})) as { token?: string; redirect_url?: string; error_messages?: string[] }; + if (!response.ok || !payload.token) { + await this.prisma.paymentOrder.update({ + where: { id: order.id }, + data: { + status: 'failed', + metadataJson: { + ...(order.metadataJson && typeof order.metadataJson === 'object' && !Array.isArray(order.metadataJson) ? order.metadataJson : {}), + midtransError: payload.error_messages || [`Midtrans request failed with status ${response.status}`], + } as Prisma.InputJsonValue, + }, + }); + throw new BadRequestException(payload.error_messages?.join(', ') || 'Failed to create Midtrans payment.'); + } + + const updated = await this.prisma.paymentOrder.update({ + where: { id: order.id }, + data: { + snapToken: payload.token, + redirectUrl: payload.redirect_url || null, + }, + }); + + return { + id: updated.id, + providerOrderId: updated.providerOrderId, + amountMinor: updated.amountMinor, + currency: updated.currency, + status: updated.status, + snapToken: updated.snapToken, + redirectUrl: updated.redirectUrl, + enabledPayments, + }; + } + + async handleMidtransNotification(payload: Record) { + const serverKey = process.env.MIDTRANS_SERVER_KEY; + if (!serverKey) { + throw new BadRequestException('Midtrans server key is not configured.'); + } + + const orderId = String(payload.order_id || ''); + const statusCode = String(payload.status_code || ''); + const grossAmount = String(payload.gross_amount || ''); + const signatureKey = String(payload.signature_key || ''); + const transactionStatus = String(payload.transaction_status || ''); + const fraudStatus = String(payload.fraud_status || ''); + const expectedSignature = createHash('sha512') + .update(`${orderId}${statusCode}${grossAmount}${serverKey}`) + .digest('hex'); + + if (!orderId || signatureKey !== expectedSignature) { + throw new BadRequestException('Invalid Midtrans notification signature.'); + } + + const paymentOrder = await this.prisma.paymentOrder.findUnique({ + where: { providerOrderId: orderId }, + }); + if (!paymentOrder) { + throw new BadRequestException('Payment order not found.'); + } + + const isSuccess = + transactionStatus === 'settlement' || + (transactionStatus === 'capture' && (!fraudStatus || fraudStatus === 'accept')); + const isFailed = ['cancel', 'deny', 'expire', 'failure'].includes(transactionStatus); + const nextStatus = isSuccess ? 'paid' : isFailed ? 'failed' : transactionStatus || 'pending'; + + if (!isSuccess) { + await this.prisma.paymentOrder.update({ + where: { id: paymentOrder.id }, + data: { + status: nextStatus, + metadataJson: { + ...(paymentOrder.metadataJson && typeof paymentOrder.metadataJson === 'object' && !Array.isArray(paymentOrder.metadataJson) ? paymentOrder.metadataJson : {}), + lastNotification: payload, + } as Prisma.InputJsonValue, + }, + }); + return { received: true, status: nextStatus }; + } + + const result = await this.prisma.$transaction(async (tx) => { + const currentOrder = await tx.paymentOrder.findUnique({ + where: { id: paymentOrder.id }, + }); + if (!currentOrder) throw new BadRequestException('Payment order not found.'); + if (currentOrder.status === 'paid') { + return { credited: false, alreadyPaid: true }; + } + + const wallet = await tx.wallet.findUnique({ where: { id: currentOrder.walletId } }); + if (!wallet) throw new BadRequestException('Wallet not found.'); + + const nextBalance = wallet.balanceMinor + currentOrder.amountMinor; + const updatedWallet = await tx.wallet.update({ + where: { id: wallet.id }, + data: { balanceMinor: nextBalance }, + }); + + const transaction = await tx.walletTransaction.create({ + data: { + id: randomUUID(), + walletId: wallet.id, + type: 'midtrans_topup', + direction: 'credit', + amountMinor: currentOrder.amountMinor, + balanceBeforeMinor: wallet.balanceMinor, + balanceAfterMinor: nextBalance, + status: 'posted', + referenceType: 'payment_order', + referenceId: currentOrder.id, + description: `Midtrans top up ${currentOrder.providerOrderId}`, + metadataJson: { + providerOrderId: currentOrder.providerOrderId, + notification: payload, + } as Prisma.InputJsonValue, + }, + }); + + await tx.paymentOrder.update({ + where: { id: currentOrder.id }, + data: { + status: 'paid', + paidAt: new Date(), + metadataJson: { + ...(currentOrder.metadataJson && typeof currentOrder.metadataJson === 'object' && !Array.isArray(currentOrder.metadataJson) ? currentOrder.metadataJson : {}), + walletTransactionId: transaction.id, + lastNotification: payload, + } as Prisma.InputJsonValue, + }, + }); + + return { credited: true, wallet: this.serializeWallet(updatedWallet), transactionId: transaction.id }; + }); + + return { received: true, status: 'paid', ...result }; + } + + async assertSufficientBalanceForBroadcast(campaign: { id: string; name: string; totalRecipients: number }) { + const estimate = this.estimateBroadcastCost(campaign.totalRecipients); + if (estimate.estimatedAmountMinor <= 0) { + return { sufficient: true, availableAmountMinor: 0, ...estimate }; + } + + const wallet = await this.getOrCreateWallet(); + const available = wallet.balanceMinor - wallet.heldMinor; + + if (available < estimate.estimatedAmountMinor) { + throw new HttpException( + { + message: 'Insufficient wallet balance for this broadcast.', + requiredAmountMinor: estimate.estimatedAmountMinor, + availableAmountMinor: available, + currency: wallet.currency, + }, + HttpStatus.PAYMENT_REQUIRED, + ); + } + + return { + sufficient: true, + availableAmountMinor: available, + ...estimate, + }; + } + + async chargeSuccessfulBroadcastInTransaction( + tx: Prisma.TransactionClient, + campaign: { id: string; name: string }, + successfulCount: number, + ) { + const estimate = this.estimateBroadcastCost(successfulCount); + if (estimate.estimatedAmountMinor <= 0) { + return { charged: false, reason: 'no_successful_messages', ...estimate }; + } + + const existingCharge = await tx.walletTransaction.findFirst({ + where: { + type: 'broadcast_charge', + referenceType: 'campaign', + referenceId: campaign.id, + status: 'posted', + }, + }); + + if (existingCharge) { + return { charged: false, alreadyCharged: true, transactionId: existingCharge.id, ...estimate }; + } + + const current = await tx.wallet.upsert({ + where: { ownerKey: DEFAULT_OWNER_KEY }, + update: {}, + create: { + id: randomUUID(), + ownerKey: DEFAULT_OWNER_KEY, + currency: DEFAULT_CURRENCY, + balanceMinor: 0, + heldMinor: 0, + status: 'active', + }, + }); + + const available = current.balanceMinor - current.heldMinor; + if (available < estimate.estimatedAmountMinor) { + throw new HttpException( + { + message: 'Insufficient wallet balance for successful broadcast delivery.', + requiredAmountMinor: estimate.estimatedAmountMinor, + availableAmountMinor: available, + currency: current.currency, + }, + HttpStatus.PAYMENT_REQUIRED, + ); + } + + const nextBalance = current.balanceMinor - estimate.estimatedAmountMinor; + const updated = await tx.wallet.update({ + where: { id: current.id }, + data: { balanceMinor: nextBalance }, + }); + + const transaction = await tx.walletTransaction.create({ + data: { + id: randomUUID(), + walletId: current.id, + type: 'broadcast_charge', + direction: 'debit', + amountMinor: estimate.estimatedAmountMinor, + balanceBeforeMinor: current.balanceMinor, + balanceAfterMinor: nextBalance, + status: 'posted', + referenceType: 'campaign', + referenceId: campaign.id, + description: `Broadcast success charge for ${campaign.name}`, + metadataJson: { + campaignId: campaign.id, + campaignName: campaign.name, + successfulMessageCount: estimate.recipientCount, + unitCostMinor: estimate.unitCostMinor, + } as Prisma.InputJsonValue, + }, + }); + + return { + charged: true, + transactionId: transaction.id, + wallet: this.serializeWallet(updated), + ...estimate, + }; + } + + private async getOrCreateWallet() { + return this.prisma.wallet.upsert({ + where: { ownerKey: DEFAULT_OWNER_KEY }, + update: {}, + create: { + id: randomUUID(), + ownerKey: DEFAULT_OWNER_KEY, + currency: DEFAULT_CURRENCY, + balanceMinor: 0, + heldMinor: 0, + status: 'active', + }, + }); + } + + private getMidtransSnapBaseUrl() { + return process.env.MIDTRANS_ENV === 'production' + ? 'https://app.midtrans.com' + : 'https://app.sandbox.midtrans.com'; + } + + private getMidtransEnabledPayments() { + const configured = (process.env.MIDTRANS_ALLOWED_PAYMENT_TYPES || '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + const selected = configured.length > 0 ? configured : MIDTRANS_PAYMENT_TYPES; + return selected.filter((item) => MIDTRANS_PAYMENT_TYPES.includes(item)); + } + + private serializeWallet(wallet: { id: string; ownerKey: string; currency: string; balanceMinor: number; heldMinor: number; status: string; updatedAt: Date }) { + return { + id: wallet.id, + ownerKey: wallet.ownerKey, + currency: wallet.currency, + balanceMinor: wallet.balanceMinor, + heldMinor: wallet.heldMinor, + availableBalanceMinor: wallet.balanceMinor - wallet.heldMinor, + status: wallet.status, + updatedAt: wallet.updatedAt.toISOString(), + }; + } + + private async findActor(userId: string, email?: string) { + const actor = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, name: true, email: true }, + }); + + return actor || { id: userId, name: email || 'System User', email: email || null }; + } +} diff --git a/backend/src/webhooks/webhook-worker.service.ts b/backend/src/webhooks/webhook-worker.service.ts index 12d094d..fccc323 100644 --- a/backend/src/webhooks/webhook-worker.service.ts +++ b/backend/src/webhooks/webhook-worker.service.ts @@ -1,4 +1,4 @@ -import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { Inject, Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import type { Job as BullJob, Worker } from 'bullmq'; import { JobsService } from '../jobs/jobs.service'; import { RedisQueueService } from '../jobs/redis-queue.service'; @@ -10,6 +10,7 @@ export class WebhookWorkerService implements OnModuleInit, OnModuleDestroy { constructor( private readonly jobsService: JobsService, + @Inject(RedisQueueService) private readonly redisQueueService: RedisQueueService, private readonly webhooksService: WebhooksService, ) {} diff --git a/deploy/debian12/README.md b/deploy/debian12/README.md index 8f8406b..a409646 100644 --- a/deploy/debian12/README.md +++ b/deploy/debian12/README.md @@ -16,6 +16,7 @@ Panduan ini menyiapkan `bizone-web` di server Debian 12 kosong dengan topologi b - Webhook verify URL Meta: `https://portal.bizone.id/api/webhooks/whatsapp` - Webhook event URL Meta: `https://portal.bizone.id/api/webhooks/whatsapp` - Alternate provider-specific webhook URL: `https://portal.bizone.id/api/webhooks/whatsapp/meta` +- Midtrans notification URL: `https://portal.bizone.id/api/wallet/midtrans/notification` - Webhook logs UI: `https://portal.bizone.id/dashboard/webhooks/logs` Untuk integrasi Meta, gunakan URL default berikut: @@ -28,6 +29,7 @@ Catatan penting: - Route backend memakai global prefix `/api`, jadi endpoint controller `GET /webhooks/whatsapp` menjadi `GET /api/webhooks/whatsapp`. - Di production, `nginx` mengekspos backend internal aplikasi lewat prefix `https://portal.bizone.id/backend-api`. - Prefix `/api/*` di browser dipakai oleh route handler Next.js untuk operasi dashboard seperti save contact, save user, export, dan aksi client-side lain. +- Karena prefix `/api/*` sebagian dipakai Next.js, nginx hanya meneruskan endpoint backend publik yang perlu dipanggil pihak luar: `/api/health`, `/api/webhooks/*`, dan `/api/wallet/midtrans/*`. - Jika Anda ingin verifikasi tanda tangan resmi dari Meta, isi `META_WEBHOOK_APP_SECRET`. - Bila `META_WEBHOOK_APP_SECRET` terisi, request ke `POST /api/webhooks/whatsapp/meta` menuntut header `x-hub-signature-256`. - Endpoint `POST /api/webhooks/whatsapp` tetap bisa dipakai untuk Meta bila Anda memilih verify token + shared secret non-Meta untuk test lain, tetapi untuk produksi Meta lebih aman menargetkan URL default callback dan menyimpan `META_WEBHOOK_APP_SECRET`. @@ -137,9 +139,10 @@ NODE_ENV=production FRONTEND_ORIGIN=https://portal.bizone.id PUBLIC_API_URL=https://portal.bizone.id INTERNAL_API_URL=http://127.0.0.1:3001/api -NEXT_PUBLIC_API_URL=https://portal.bizone.id/api +NEXT_PUBLIC_API_URL=https://portal.bizone.id/backend-api PORT=3001 WEBHOOK_ALLOW_UNSIGNED=false +MIDTRANS_ALLOWED_PAYMENT_TYPES=gopay,shopeepay,bank_transfer,credit_card ``` Generate secret aman: @@ -158,6 +161,12 @@ Gunakan hasil berbeda untuk: - `WEBHOOK_VERIFY_TOKEN` - `WEBHOOK_SHARED_SECRET` +Tambahkan credential provider sesuai dashboard masing-masing: + +- `META_WEBHOOK_APP_SECRET` dari Meta App Dashboard. +- `MIDTRANS_SERVER_KEY`, `MIDTRANS_CLIENT_KEY`, dan `MIDTRANS_MERCHANT_ID` dari Midtrans Dashboard. +- `MIDTRANS_ENV=sandbox` untuk sandbox key, atau `MIDTRANS_ENV=production` untuk production key. + Sebelum menjalankan command Prisma atau backend secara manual, export env ke shell aktif: ```bash @@ -292,7 +301,31 @@ Mapping internal saat ini: - `template_category_update` -> `template.updated` - `account_update` -> `account.updated` -## 12. Urutan test live yang saya sarankan +## 12. Data Midtrans yang harus Anda masukkan + +Di Midtrans Dashboard, set notification URL: + +```text +https://portal.bizone.id/api/wallet/midtrans/notification +``` + +Payment channel yang disarankan untuk wallet top up: + +- GoPay/QRIS +- ShopeePay +- Bank Transfer / Virtual Account +- Credit Card + +Env aplikasi: + +```dotenv +MIDTRANS_ENV=sandbox +MIDTRANS_ALLOWED_PAYMENT_TYPES=gopay,shopeepay,bank_transfer,credit_card +``` + +Gunakan `MIDTRANS_ENV=production` hanya jika key yang dipasang adalah production key. + +## 13. Urutan test live yang saya sarankan 1. Pastikan `https://portal.bizone.id/api/health` mengembalikan `200`. 2. Coba buka `https://portal.bizone.id`. @@ -303,8 +336,11 @@ Mapping internal saat ini: 7. Pastikan inbound message masuk ke inbox conversation. 8. Balas dari dashboard bila access token dan `phoneNumberId` sudah terisi. 9. Cek status `sent`, `delivered`, `read`, atau `failed` kembali masuk lewat webhook. +10. Buat top up wallet dari `Dashboard > Wallet`. +11. Selesaikan pembayaran sandbox Midtrans. +12. Pastikan notification Midtrans mengubah payment order menjadi `paid` dan saldo wallet bertambah. -## 13. Command update deploy berikutnya +## 14. Command update deploy berikutnya Setelah ada perubahan code: @@ -327,7 +363,7 @@ git config user.name "Wira Irawan" git config user.email "wira.irawan@gmail.com" ``` -## 14. Smoke check minimal +## 15. Smoke check minimal ```bash curl https://portal.bizone.id/api/health @@ -336,19 +372,3 @@ sudo systemctl is-active bizone-backend sudo systemctl is-active bizone-frontend docker compose -f /srv/bizone-web/deploy/debian12/docker-compose.infra.yml ps ``` -##+Q&xN$86LbSA +MAIL_PASSWORD=replace-with-real-smtp-password +MAIL_FROM=BizOne Portal AUTH_LOGIN_MAX_ATTEMPTS=5 AUTH_LOGIN_WINDOW_MINUTES=15 @@ -32,3 +32,9 @@ AUTH_2FA_MAX_ATTEMPTS=5 AUTH_2FA_WINDOW_MINUTES=10 AUTH_PASSWORD_RESET_MAX_ATTEMPTS=3 AUTH_PASSWORD_RESET_WINDOW_MINUTES=30 + +MIDTRANS_ENV=sandbox +MIDTRANS_SERVER_KEY=replace-with-real-midtrans-server-key +MIDTRANS_CLIENT_KEY=replace-with-real-midtrans-client-key +MIDTRANS_MERCHANT_ID=replace-with-real-midtrans-merchant-id +MIDTRANS_ALLOWED_PAYMENT_TYPES=gopay,shopeepay,bank_transfer,credit_card diff --git a/deploy/debian12/nginx.portal.bizone.id.conf b/deploy/debian12/nginx.portal.bizone.id.conf index d367c43..bd8080f 100644 --- a/deploy/debian12/nginx.portal.bizone.id.conf +++ b/deploy/debian12/nginx.portal.bizone.id.conf @@ -5,6 +5,11 @@ server { client_max_body_size 20m; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + location = /api/health { proxy_pass http://127.0.0.1:3001/api/health; proxy_http_version 1.1; @@ -23,6 +28,15 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + location /api/wallet/midtrans/ { + proxy_pass http://127.0.0.1:3001/api/wallet/midtrans/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location /backend-api/ { proxy_pass http://127.0.0.1:3001/api/; proxy_http_version 1.1; diff --git a/docs/production-server-checklist.md b/docs/production-server-checklist.md new file mode 100644 index 0000000..1c520b5 --- /dev/null +++ b/docs/production-server-checklist.md @@ -0,0 +1,350 @@ +# Production Server Checklist BizOne Portal + +Panduan ini berisi langkah production `1-7` yang perlu dilakukan di server sebelum aplikasi BizOne Portal dipakai live. + +Asumsi deployment: + +- Domain: `portal.bizone.id` +- App path: `/srv/bizone-web` +- Backend: `127.0.0.1:3001` +- Frontend: `127.0.0.1:3000` +- PostgreSQL dan Redis memakai Docker Compose dari `deploy/debian12/docker-compose.infra.yml` + +## 1. Bersihkan real secret dari repo dan rotate secret + +File berikut tidak boleh berisi credential asli: + +- `.env.example` +- `deploy/debian12/app.env.example` +- `README.md` + +Isi semua secret di file example cukup memakai placeholder: + +```dotenv +DATABASE_URL=postgresql://bizone:change-this-password@127.0.0.1:5432/wa_dashboard +JWT_SECRET=replace-with-32-plus-char-random-secret +JWT_REFRESH_SECRET=replace-with-32-plus-char-random-refresh-secret +WEBHOOK_VERIFY_TOKEN=replace-with-32-plus-char-random-token +WEBHOOK_SHARED_SECRET=replace-with-32-plus-char-random-secret +META_WEBHOOK_APP_SECRET=replace-with-real-meta-app-secret +MAIL_PASSWORD=replace-with-real-smtp-password +``` + +Di server, generate secret baru: + +```bash +openssl rand -hex 32 +openssl rand -hex 32 +openssl rand -hex 32 +openssl rand -hex 32 +openssl rand -hex 32 +``` + +Gunakan hasil berbeda untuk: + +- `JWT_SECRET` +- `JWT_REFRESH_SECRET` +- `WEBHOOK_VERIFY_TOKEN` +- `WEBHOOK_SHARED_SECRET` +- `META_WEBHOOK_APP_SECRET` + +Jika secret lama pernah masuk repo, anggap bocor dan jangan dipakai lagi. + +## 2. Siapkan `.env` production langsung di server + +Masuk server: + +```bash +ssh user@SERVER_IP +cd /srv/bizone-web +nano .env +``` + +Isi minimal seperti ini: + +```dotenv +NODE_ENV=production + +DATABASE_URL=postgresql://bizone:ISI_PASSWORD_DB_URL_ENCODED@127.0.0.1:5432/wa_dashboard +REDIS_URL=redis://127.0.0.1:6379 + +PORT=3001 +FRONTEND_ORIGIN=https://portal.bizone.id +PUBLIC_API_URL=https://portal.bizone.id +INTERNAL_API_URL=http://127.0.0.1:3001/api +NEXT_PUBLIC_API_URL=https://portal.bizone.id/backend-api + +JWT_SECRET=ISI_SECRET_BARU +JWT_EXPIRES_IN=1d +JWT_REFRESH_SECRET=ISI_REFRESH_SECRET_BARU +JWT_REFRESH_EXPIRES_IN=30d + +WEBHOOK_VERIFY_TOKEN=ISI_VERIFY_TOKEN_BARU +WEBHOOK_SHARED_SECRET=ISI_SHARED_SECRET_BARU +META_WEBHOOK_APP_SECRET=ISI_META_APP_SECRET_BARU +WEBHOOK_ALLOW_UNSIGNED=false + +MAIL_HOST=mail.bizone.id +MAIL_PORT=465 +MAIL_SECURE=true +MAIL_USER=no-reply@bizone.id +MAIL_PASSWORD=ISI_PASSWORD_SMTP +MAIL_FROM=BizOne Portal + +AUTH_LOGIN_MAX_ATTEMPTS=5 +AUTH_LOGIN_WINDOW_MINUTES=15 +AUTH_2FA_MAX_ATTEMPTS=5 +AUTH_2FA_WINDOW_MINUTES=10 +AUTH_PASSWORD_RESET_MAX_ATTEMPTS=3 +AUTH_PASSWORD_RESET_WINDOW_MINUTES=30 +``` + +Amankan permission file `.env`: + +```bash +sudo chown bizone:bizone /srv/bizone-web/.env +sudo chmod 600 /srv/bizone-web/.env +``` + +Load env untuk command manual: + +```bash +cd /srv/bizone-web +set -a +source .env +set +a +``` + +## 3. Jalankan build backend dan frontend + +Install dependency root untuk Prisma: + +```bash +cd /srv/bizone-web +npm install +``` + +Build backend: + +```bash +cd /srv/bizone-web/backend +npm install +npm run db:generate +npm run build +``` + +Build frontend: + +```bash +cd /srv/bizone-web/frontend +npm install +npm run build +``` + +Jika build gagal, jangan lanjut deploy. Bereskan error terlebih dahulu. + +## 4. Jalankan database migration deploy + +Pastikan PostgreSQL dan Redis hidup: + +```bash +cd /srv/bizone-web/deploy/debian12 +docker compose -f docker-compose.infra.yml up -d +docker compose -f docker-compose.infra.yml ps +``` + +Jalankan migration: + +```bash +cd /srv/bizone-web +set -a +source .env +set +a + +cd backend +npm run db:migrate:deploy +``` + +Jika database masih kosong dan butuh admin awal: + +```bash +npm run seed:admin +``` + +Setelah login pertama, langsung ganti password admin dan aktifkan 2FA. + +## 5. Tambahkan nginx security headers + +Edit config nginx: + +```bash +sudo nano /etc/nginx/sites-available/portal.bizone.id +``` + +Di dalam blok `server { ... }`, tambahkan: + +```nginx +add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; +add_header X-Frame-Options "SAMEORIGIN" always; +add_header X-Content-Type-Options "nosniff" always; +add_header Referrer-Policy "strict-origin-when-cross-origin" always; +add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; +``` + +Pastikan route penting tetap seperti ini: + +```nginx +location = /api/health { + proxy_pass http://127.0.0.1:3001/api/health; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} + +location /api/webhooks/ { + proxy_pass http://127.0.0.1:3001/api/webhooks/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} + +location /backend-api/ { + proxy_pass http://127.0.0.1:3001/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} + +location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; +} +``` + +Test dan reload nginx: + +```bash +sudo nginx -t +sudo systemctl reload nginx +``` + +## 6. Deploy via systemd + +Copy service files: + +```bash +sudo cp /srv/bizone-web/deploy/debian12/bizone-backend.service /etc/systemd/system/ +sudo cp /srv/bizone-web/deploy/debian12/bizone-frontend.service /etc/systemd/system/ +``` + +Reload systemd: + +```bash +sudo systemctl daemon-reload +``` + +Enable service: + +```bash +sudo systemctl enable bizone-backend +sudo systemctl enable bizone-frontend +``` + +Start atau restart service: + +```bash +sudo systemctl restart bizone-backend +sudo systemctl restart bizone-frontend +``` + +Cek status: + +```bash +sudo systemctl status bizone-backend +sudo systemctl status bizone-frontend +``` + +Lihat log kalau ada error: + +```bash +sudo journalctl -u bizone-backend -f +sudo journalctl -u bizone-frontend -f +``` + +## 7. Smoke test production + +Cek backend health: + +```bash +curl https://portal.bizone.id/api/health +curl https://portal.bizone.id/backend-api/health +``` + +Response ideal: + +```json +{"status":"ok","service":"wa-dashboard-backend","database":"ok"} +``` + +Cek frontend: + +```bash +curl -I https://portal.bizone.id +``` + +Cek service: + +```bash +sudo systemctl is-active bizone-backend +sudo systemctl is-active bizone-frontend +docker compose -f /srv/bizone-web/deploy/debian12/docker-compose.infra.yml ps +``` + +Test manual dari browser: + +1. Buka `https://portal.bizone.id`. +2. Login admin. +3. Ganti password admin default. +4. Aktifkan 2FA. +5. Buka dashboard. +6. Buka `Settings > WhatsApp API Setting`. +7. Pastikan secret tidak tampil sebagai plain text di production. +8. Buka `Webhook Logs`. +9. Test webhook verification dari Meta. +10. Kirim pesan WhatsApp ke nomor bisnis. +11. Pastikan pesan masuk di Conversations. +12. Balas dari dashboard jika token Meta dan phone number ID sudah benar. + +Meta callback production: + +```text +https://portal.bizone.id/api/webhooks/whatsapp +``` + +Verify token: + +```text +Pakai nilai WEBHOOK_VERIFY_TOKEN dari .env production. +``` + +## Catatan setelah smoke test + +Jika semua smoke test lolos, lanjutkan dengan hardening tambahan: + +- Setup backup PostgreSQL terjadwal. +- Setup monitor uptime ke `/api/health`. +- Setup log retention `journald`. +- Audit dependency dengan `npm audit` di root, backend, dan frontend. +- Rotate admin password dan wajibkan 2FA untuk akun admin. diff --git a/frontend/public/bizone.png b/frontend/public/bizone.png new file mode 100644 index 0000000..f7410c7 Binary files /dev/null and b/frontend/public/bizone.png differ diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..0cf16f9 Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/src/app/actions.ts b/frontend/src/app/actions.ts index 11e8422..f104478 100644 --- a/frontend/src/app/actions.ts +++ b/frontend/src/app/actions.ts @@ -407,3 +407,92 @@ export async function retryWebhookEventAction(_: FormState, formData: FormData): revalidatePath('/dashboard/settings/webhook-logs'); return { success: `requeued:${eventId}` }; } + +function setSessionCookies(payload: Record) { + const accessToken = typeof payload.access_token === 'string' ? payload.access_token : ''; + const refreshToken = typeof payload.refresh_token === 'string' ? payload.refresh_token : ''; + + return cookies().then((cookieStore) => { + if (accessToken) { + cookieStore.set(authCookieName, accessToken, { + httpOnly: true, + sameSite: 'lax', + secure: secureCookies, + path: '/', + maxAge: + typeof payload.access_token_max_age_seconds === 'number' + ? payload.access_token_max_age_seconds + : 60 * 60 * 24, + }); + } + + if (refreshToken) { + cookieStore.set(refreshCookieName, refreshToken, { + httpOnly: true, + sameSite: 'lax', + secure: secureCookies, + path: '/', + maxAge: + typeof payload.refresh_token_max_age_seconds === 'number' + ? payload.refresh_token_max_age_seconds + : 60 * 60 * 24 * 30, + }); + } + }); +} + +export async function updateProfileAction(_: FormState, formData: FormData): Promise { + const token = await requireServerAuthCookie(); + const name = String(formData.get('name') || '').trim(); + + const response = await fetch(`${API_URL}/auth/profile`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ name }), + cache: 'no-store', + }); + + const payload = await response.json(); + if (!response.ok) { + return { error: getErrorMessage(payload, 'Failed to update profile') }; + } + + await setSessionCookies(payload); + revalidatePath('/dashboard/profile'); + revalidatePath('/dashboard'); + return { success: 'Profile updated.' }; +} + +export async function updatePasswordAction(_: FormState, formData: FormData): Promise { + const token = await requireServerAuthCookie(); + const currentPassword = String(formData.get('currentPassword') || ''); + const newPassword = String(formData.get('newPassword') || ''); + const confirmPassword = String(formData.get('confirmPassword') || ''); + + if (newPassword !== confirmPassword) { + return { error: 'New password confirmation does not match.' }; + } + + const response = await fetch(`${API_URL}/auth/profile/password`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ currentPassword, newPassword }), + cache: 'no-store', + }); + + const payload = await response.json(); + if (!response.ok) { + return { error: getErrorMessage(payload, 'Failed to update password') }; + } + + const cookieStore = await cookies(); + cookieStore.delete(authCookieName); + cookieStore.delete(refreshCookieName); + return { success: 'Password updated. Please log in again with your new password.' }; +} diff --git a/frontend/src/app/api/audit-trail/export/route.ts b/frontend/src/app/api/audit-trail/export/route.ts index 3526850..7896d59 100644 --- a/frontend/src/app/api/audit-trail/export/route.ts +++ b/frontend/src/app/api/audit-trail/export/route.ts @@ -18,12 +18,19 @@ export async function GET(request: Request) { cache: 'no-store', }); - const csv = await response.text(); - return new Response(csv, { + const body = await response.arrayBuffer(); + const contentType = response.headers.get('Content-Type') || 'application/octet-stream'; + const disposition = + response.headers.get('Content-Disposition') || + (url.searchParams.get('format') === 'xlsx' + ? 'attachment; filename="audit-trail-export.xlsx"' + : 'attachment; filename="audit-trail-export.csv"'); + + return new Response(body, { status: response.status, headers: { - 'Content-Type': 'text/csv; charset=utf-8', - 'Content-Disposition': 'attachment; filename="audit-trail-export.csv"', + 'Content-Type': contentType, + 'Content-Disposition': disposition, }, }); } diff --git a/frontend/src/app/api/campaigns/[id]/estimate-cost/route.ts b/frontend/src/app/api/campaigns/[id]/estimate-cost/route.ts new file mode 100644 index 0000000..8f2452d --- /dev/null +++ b/frontend/src/app/api/campaigns/[id]/estimate-cost/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { authCookieName } from '../../../../../lib/auth'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'; + +export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { + const token = (await cookies()).get(authCookieName)?.value; + if (!token) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const { id } = await params; + const response = await fetch(`${API_URL}/campaigns/${id}/estimate-cost`, { + headers: { Authorization: `Bearer ${token}` }, + cache: 'no-store', + }); + const payload = await response.json(); + return NextResponse.json(payload, { status: response.status }); +} diff --git a/frontend/src/app/api/global-search/route.ts b/frontend/src/app/api/global-search/route.ts new file mode 100644 index 0000000..a52052f --- /dev/null +++ b/frontend/src/app/api/global-search/route.ts @@ -0,0 +1,138 @@ +import { NextResponse } from 'next/server'; +import { SERVER_API_URL as API_URL } from '../../../lib/server-api'; +import { getProxyAccessToken } from '../../../lib/proxy-auth'; + +type SearchResult = { + id: string; + type: string; + title: string; + subtitle: string; + href: string; + icon: string; +}; + +async function readJson(response: Response) { + if (!response.ok) { + return null; + } + + return response.json(); +} + +function includesNeedle(values: unknown[], needle: string) { + return values.some((value) => String(value ?? '').toLowerCase().includes(needle)); +} + +export async function GET(request: Request) { + const token = await getProxyAccessToken(); + if (!token) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const url = new URL(request.url); + const query = url.searchParams.get('q')?.trim() || ''; + const needle = query.toLowerCase(); + + if (query.length < 2) { + return NextResponse.json({ query, results: [] }); + } + + const headers = { Authorization: `Bearer ${token}` }; + const encodedQuery = encodeURIComponent(query); + + const [contacts, users, templates, conversations, campaigns, auditTrail] = await Promise.allSettled([ + fetch(`${API_URL}/contacts?search=${encodedQuery}&limit=5`, { headers, cache: 'no-store' }).then(readJson), + fetch(`${API_URL}/users?search=${encodedQuery}&limit=5`, { headers, cache: 'no-store' }).then(readJson), + fetch(`${API_URL}/templates?search=${encodedQuery}`, { headers, cache: 'no-store' }).then(readJson), + fetch(`${API_URL}/conversations?search=${encodedQuery}`, { headers, cache: 'no-store' }).then(readJson), + fetch(`${API_URL}/campaigns`, { headers, cache: 'no-store' }).then(readJson), + fetch(`${API_URL}/logs/audit-trail?search=${encodedQuery}&limit=5`, { headers, cache: 'no-store' }).then(readJson), + ]); + + const results: SearchResult[] = []; + + if (contacts.status === 'fulfilled' && contacts.value?.items) { + contacts.value.items.slice(0, 5).forEach((item: Record) => { + results.push({ + id: `contact-${item.id}`, + type: 'Contacts', + title: String(item.name || item.phoneNumber || 'Contact'), + subtitle: [item.phoneNumber, item.company, item.email].filter(Boolean).join(' | '), + href: `/dashboard/contacts/${item.id}`, + icon: 'contacts', + }); + }); + } + + if (users.status === 'fulfilled' && users.value?.items) { + users.value.items.slice(0, 5).forEach((item: Record) => { + results.push({ + id: `user-${item.id}`, + type: 'Users', + title: String(item.name || item.email || 'User'), + subtitle: [item.email, item.roleName, item.status].filter(Boolean).join(' | '), + href: '/dashboard/users', + icon: 'group', + }); + }); + } + + if (templates.status === 'fulfilled' && templates.value?.items) { + templates.value.items.slice(0, 5).forEach((item: Record) => { + results.push({ + id: `template-${item.id}`, + type: 'Templates', + title: String(item.name || 'Template'), + subtitle: [item.category, item.status, item.language].filter(Boolean).join(' | '), + href: `/dashboard/templates/${item.id}`, + icon: 'description', + }); + }); + } + + if (conversations.status === 'fulfilled' && Array.isArray(conversations.value)) { + conversations.value.slice(0, 5).forEach((item: Record) => { + results.push({ + id: `conversation-${item.contactId || item.id}`, + type: 'Conversations', + title: String(item.name || item.phoneNumber || 'Conversation'), + subtitle: [item.phoneNumber, item.lastMessage, item.status].filter(Boolean).join(' | '), + href: '/dashboard/conversations', + icon: 'chat', + }); + }); + } + + if (campaigns.status === 'fulfilled' && campaigns.value?.items) { + campaigns.value.items + .filter((item: Record) => + includesNeedle([item.name, item.code, item.status, item.templateName, item.audienceGroup], needle), + ) + .slice(0, 5) + .forEach((item: Record) => { + results.push({ + id: `campaign-${item.id}`, + type: 'Campaigns', + title: String(item.name || item.code || 'Campaign'), + subtitle: [item.code, item.status, item.audienceGroup].filter(Boolean).join(' | '), + href: `/dashboard/campaigns/${item.id}`, + icon: 'campaign', + }); + }); + } + + if (auditTrail.status === 'fulfilled' && auditTrail.value?.items) { + auditTrail.value.items.slice(0, 5).forEach((item: Record) => { + results.push({ + id: `audit-${item.id}`, + type: 'Audit Trail', + title: String(item.actionType || 'Audit Event'), + subtitle: [item.actorName, item.module, item.details].filter(Boolean).join(' | '), + href: '/dashboard/settings/audit-trail', + icon: 'history', + }); + }); + } + + return NextResponse.json({ query, results: results.slice(0, 20) }); +} diff --git a/frontend/src/app/api/wallet/route.ts b/frontend/src/app/api/wallet/route.ts new file mode 100644 index 0000000..db7c75d --- /dev/null +++ b/frontend/src/app/api/wallet/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { authCookieName } from '../../../lib/auth'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'; + +export async function GET() { + const token = (await cookies()).get(authCookieName)?.value; + if (!token) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const response = await fetch(`${API_URL}/wallet`, { + headers: { Authorization: `Bearer ${token}` }, + cache: 'no-store', + }); + const payload = await response.json(); + return NextResponse.json(payload, { status: response.status }); +} diff --git a/frontend/src/app/api/wallet/topups/route.ts b/frontend/src/app/api/wallet/topups/route.ts new file mode 100644 index 0000000..2721520 --- /dev/null +++ b/frontend/src/app/api/wallet/topups/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { authCookieName } from '../../../../lib/auth'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'; + +export async function POST(request: Request) { + const token = (await cookies()).get(authCookieName)?.value; + if (!token) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const provider = typeof body?.provider === 'string' ? body.provider : 'manual'; + const endpoint = provider === 'midtrans' ? 'midtrans' : 'manual'; + const response = await fetch(`${API_URL}/wallet/topups/${endpoint}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + amountMinor: body.amountMinor, + description: body.description, + }), + cache: 'no-store', + }); + const payload = await response.json(); + return NextResponse.json(payload, { status: response.status }); +} diff --git a/frontend/src/app/dashboard/campaigns/page.tsx b/frontend/src/app/dashboard/campaigns/page.tsx index a786585..6b6b773 100644 --- a/frontend/src/app/dashboard/campaigns/page.tsx +++ b/frontend/src/app/dashboard/campaigns/page.tsx @@ -2,14 +2,144 @@ import { CampaignsManagementBoard } from '../../../components/campaigns-manageme import { DashboardShell } from '../../../components/dashboard-shell'; import { requireAuthToken } from '../../../lib/auth'; import { fetchCampaigns } from '../../../lib/api'; +import { getLocale } from '../../../lib/i18n'; + +const campaignLabels = { + en: { + title: 'Campaign Management', + copy: 'Design, schedule, and track your WhatsApp broadcast performance.', + newCampaign: 'New Campaign', + createCampaign: 'Create Campaign', + launchBroadcast: 'Launch a new WhatsApp broadcast', + campaignName: 'Campaign Name', + audienceLabel: 'Audience Label', + audienceGroup: 'Audience Group', + totalRecipients: 'Total Recipients', + status: 'Status', + scheduledAt: 'Scheduled At', + templateName: 'Template Name', + language: 'Language', + messageTitle: 'Message Title', + messageBody: 'Message Body', + primaryButton: 'Primary Button', + secondaryButton: 'Secondary Button', + bannerImageUrl: 'Banner Image URL', + cancel: 'Cancel', + creating: 'Creating...', + failedCreate: 'Failed to create campaign', + totalMessages: 'Total Messages', + deliveryRate: 'Delivery Rate', + scheduled: 'Scheduled', + failedDelivery: 'Failed Delivery', + allCampaigns: 'All Campaigns', + sent: 'Sent', + draft: 'Draft', + failed: 'Failed', + searchPlaceholder: 'Search campaigns...', + sortNewest: 'Sort by: Newest', + sortOldest: 'Sort by: Oldest', + sortDelivery: 'Sort by: Best Delivery', + audience: 'Audience', + date: 'Date', + actions: 'Actions', + pendingSend: 'Pending send...', + retry: 'Retry', + actionsFor: 'Actions for', + previousPage: 'Previous page', + nextPage: 'Next page', + showing: 'Showing', + of: 'of', + campaigns: 'campaigns', + optimizationTitle: 'Campaign Performance Optimization', + optimizationCopyA: 'Based on your recent broadcasts, campaigns sent between', + optimizationCopyB: 'on Tuesdays have a', + optimizationCopyC: 'read rate. Consider scheduling your next template for this window.', + higherRate: '15% higher', + viewDetails: 'View Details', + templateHealth: 'Template Health', + healthCopy: 'High quality rating across all approved templates.', + namePlaceholder: 'Summer Launch Blast', + audiencePlaceholder: 'VIP Customer List', + groupPlaceholder: 'High-value segment', + templatePlaceholder: 'summer_promo_v3', + titlePlaceholder: 'Hi {{name}}, your private offer is ready', + bodyPlaceholder: 'Write the campaign body that will appear in the WhatsApp template preview.', + primaryPlaceholder: 'Shop Now', + secondaryPlaceholder: 'View Catalog', + }, + id: { + title: 'Manajemen Kampanye', + copy: 'Rancang, jadwalkan, dan pantau performa broadcast WhatsApp Anda.', + newCampaign: 'Kampanye Baru', + createCampaign: 'Buat Kampanye', + launchBroadcast: 'Jalankan broadcast WhatsApp baru', + campaignName: 'Nama Kampanye', + audienceLabel: 'Label Audiens', + audienceGroup: 'Grup Audiens', + totalRecipients: 'Total Penerima', + status: 'Status', + scheduledAt: 'Dijadwalkan Pada', + templateName: 'Nama Template', + language: 'Bahasa', + messageTitle: 'Judul Pesan', + messageBody: 'Isi Pesan', + primaryButton: 'Tombol Utama', + secondaryButton: 'Tombol Kedua', + bannerImageUrl: 'URL Gambar Banner', + cancel: 'Batal', + creating: 'Membuat...', + failedCreate: 'Gagal membuat kampanye', + totalMessages: 'Total Pesan', + deliveryRate: 'Rasio Terkirim', + scheduled: 'Terjadwal', + failedDelivery: 'Gagal Terkirim', + allCampaigns: 'Semua Kampanye', + sent: 'Terkirim', + draft: 'Draft', + failed: 'Gagal', + searchPlaceholder: 'Cari kampanye...', + sortNewest: 'Urutkan: Terbaru', + sortOldest: 'Urutkan: Terlama', + sortDelivery: 'Urutkan: Delivery Terbaik', + audience: 'Audiens', + date: 'Tanggal', + actions: 'Aksi', + pendingSend: 'Menunggu dikirim...', + retry: 'Coba ulang', + actionsFor: 'Aksi untuk', + previousPage: 'Halaman sebelumnya', + nextPage: 'Halaman berikutnya', + showing: 'Menampilkan', + of: 'dari', + campaigns: 'kampanye', + optimizationTitle: 'Optimasi Performa Kampanye', + optimizationCopyA: 'Berdasarkan broadcast terbaru, kampanye yang dikirim antara', + optimizationCopyB: 'pada hari Selasa memiliki', + optimizationCopyC: 'rasio dibaca. Pertimbangkan menjadwalkan template berikutnya pada waktu ini.', + higherRate: '15% lebih tinggi', + viewDetails: 'Lihat Detail', + templateHealth: 'Kesehatan Template', + healthCopy: 'Rating kualitas tinggi di seluruh template yang sudah disetujui.', + namePlaceholder: 'Promo Launching Musim Panas', + audiencePlaceholder: 'Daftar Pelanggan VIP', + groupPlaceholder: 'Segmen bernilai tinggi', + templatePlaceholder: 'promo_musim_panas_v3', + titlePlaceholder: 'Hai {{name}}, penawaran khusus Anda sudah siap', + bodyPlaceholder: 'Tulis isi kampanye yang akan muncul di preview template WhatsApp.', + primaryPlaceholder: 'Belanja Sekarang', + secondaryPlaceholder: 'Lihat Katalog', + }, +} as const; export default async function CampaignsPage() { const token = await requireAuthToken(); + const locale = await getLocale(); + const labels = campaignLabels[locale]; const campaignsData = await fetchCampaigns(token); return ( - - + + ); } diff --git a/frontend/src/app/dashboard/contacts/page.tsx b/frontend/src/app/dashboard/contacts/page.tsx index 20c0184..316178b 100644 --- a/frontend/src/app/dashboard/contacts/page.tsx +++ b/frontend/src/app/dashboard/contacts/page.tsx @@ -2,6 +2,88 @@ import { DashboardShell } from '../../../components/dashboard-shell'; import { ContactsDirectoryBoard } from '../../../components/contacts-directory-board'; import { requireAuthToken } from '../../../lib/auth'; import { fetchContactsDirectory } from '../../../lib/api'; +import { getLocale } from '../../../lib/i18n'; + +const contactLabels = { + en: { + title: 'Contacts Directory', + copy: 'Manage your customer database and communication segments.', + import: 'Import', + export: 'Export', + addContact: 'Add Contact', + importedPrefix: 'Imported', + importedSuffix: 'contacts successfully.', + failedImport: 'Failed to import contacts', + emptyCsv: 'CSV file is empty.', + invalidCsv: 'CSV does not contain valid contact rows.', + failedImportContact: 'Failed to import contact', + filters: 'Filters', + searchPlaceholder: 'Search contacts...', + allStatuses: 'All Statuses', + active: 'Active', + inactive: 'Inactive', + allTags: 'All Tags', + clearFilters: 'Clear all filters', + contactName: 'Contact Name', + phoneNumber: 'Phone Number', + status: 'Status', + tags: 'Tags', + lastMessage: 'Last Message', + showing: 'Showing', + of: 'of', + contacts: 'contacts', + rowsPerPage: 'Rows per page:', + newContact: 'New Contact', + addProfile: 'Add a new contact profile', + failedCreate: 'Failed to create contact', + fullName: 'Full Name', + emailAddress: 'Email Address', + company: 'Company', + notes: 'Notes', + cancel: 'Cancel', + saving: 'Saving...', + saveContact: 'Save Contact', + }, + id: { + title: 'Direktori Kontak', + copy: 'Kelola database pelanggan dan segmentasi komunikasi Anda.', + import: 'Import', + export: 'Export', + addContact: 'Tambah Kontak', + importedPrefix: 'Berhasil import', + importedSuffix: 'kontak.', + failedImport: 'Gagal import kontak', + emptyCsv: 'File CSV kosong.', + invalidCsv: 'CSV tidak berisi baris kontak yang valid.', + failedImportContact: 'Gagal import kontak', + filters: 'Filter', + searchPlaceholder: 'Cari kontak...', + allStatuses: 'Semua Status', + active: 'Aktif', + inactive: 'Nonaktif', + allTags: 'Semua Tag', + clearFilters: 'Hapus semua filter', + contactName: 'Nama Kontak', + phoneNumber: 'Nomor Telepon', + status: 'Status', + tags: 'Tag', + lastMessage: 'Pesan Terakhir', + showing: 'Menampilkan', + of: 'dari', + contacts: 'kontak', + rowsPerPage: 'Baris per halaman:', + newContact: 'Kontak Baru', + addProfile: 'Tambah profil kontak baru', + failedCreate: 'Gagal membuat kontak', + fullName: 'Nama Lengkap', + emailAddress: 'Alamat Email', + company: 'Perusahaan', + notes: 'Catatan', + cancel: 'Batal', + saving: 'Menyimpan...', + saveContact: 'Simpan Kontak', + }, +} as const; export default async function ContactsPage({ searchParams, @@ -15,6 +97,8 @@ export default async function ContactsPage({ }>; }) { const token = await requireAuthToken(); + const locale = await getLocale(); + const labels = contactLabels[locale]; const query = await searchParams; const page = Math.max(1, Number(query?.page || '1')); const limit = Math.max(1, Number(query?.limit || '10')); @@ -24,8 +108,8 @@ export default async function ContactsPage({ const data = await fetchContactsDirectory(token, { page, limit, search, status, tag }); return ( - - + + ); } diff --git a/frontend/src/app/dashboard/conversations/page.tsx b/frontend/src/app/dashboard/conversations/page.tsx index ca07709..98dc309 100644 --- a/frontend/src/app/dashboard/conversations/page.tsx +++ b/frontend/src/app/dashboard/conversations/page.tsx @@ -2,6 +2,104 @@ import { DashboardShell } from '../../../components/dashboard-shell'; import { ConversationsInbox } from '../../../components/conversations-inbox'; import { requireAuthToken } from '../../../lib/auth'; import { fetchConversationDetail, fetchConversations } from '../../../lib/api'; +import { getLocale } from '../../../lib/i18n'; + +const conversationLabels = { + en: { + title: 'Conversations', + searchPlaceholder: 'Search conversations...', + all: 'All', + active: 'Active', + pending: 'Pending', + unread: 'unread', + noMatch: 'No conversations match this filter.', + noSelected: 'No conversation selected', + online: 'Online', + offline: 'Offline', + loading: 'Loading conversation...', + quickReplies: 'Quick Replies', + templates: 'Templates', + messagePlaceholder: 'Type a message...', + unavailable: 'Unavailable', + assignedTo: 'Assigned to', + unassigned: 'Unassigned conversation', + contactDetails: 'Contact Details', + tags: 'Tags', + addTag: '+ Add Tag', + recentActivity: 'Recent Activity', + viewCrm: 'View Full CRM Profile', + assignConversation: 'Assign conversation', + editContact: 'Edit contact', + favoriteContact: 'Favorite contact', + blockContact: 'Block contact', + startVideoCall: 'Start video call', + startCall: 'Start call', + moreActions: 'More actions', + emoji: 'Emoji', + attachFile: 'Attach file', + sendMessage: 'Send message', + failedSend: 'Failed to send message', + justNow: 'Just now', + fallbackMessagePrefix: 'No active thread loaded for', + fallbackMessageSuffix: 'yet.', + quickReply1: 'Checking this for you now.', + quickReply2: 'Could you share your order ID?', + quickReply3: 'I have escalated this to our support team.', + template1Label: 'Activation Follow-up', + template1Body: 'Your activation request is being processed. We will share the update shortly.', + template2Label: 'Shipping Update', + template2Body: 'Your shipment is in transit and the tracking page will refresh within a few minutes.', + template3Label: 'Discount Clarification', + template3Body: 'The enterprise discount is still available for qualifying annual plans.', + }, + id: { + title: 'Percakapan', + searchPlaceholder: 'Cari percakapan...', + all: 'Semua', + active: 'Aktif', + pending: 'Tertunda', + unread: 'belum dibaca', + noMatch: 'Tidak ada percakapan yang cocok dengan filter ini.', + noSelected: 'Belum ada percakapan dipilih', + online: 'Online', + offline: 'Offline', + loading: 'Memuat percakapan...', + quickReplies: 'Balasan Cepat', + templates: 'Template', + messagePlaceholder: 'Ketik pesan...', + unavailable: 'Tidak tersedia', + assignedTo: 'Ditangani oleh', + unassigned: 'Percakapan belum ditugaskan', + contactDetails: 'Detail Kontak', + tags: 'Tag', + addTag: '+ Tambah Tag', + recentActivity: 'Aktivitas Terbaru', + viewCrm: 'Lihat Profil CRM Lengkap', + assignConversation: 'Assign percakapan', + editContact: 'Edit kontak', + favoriteContact: 'Favoritkan kontak', + blockContact: 'Blokir kontak', + startVideoCall: 'Mulai video call', + startCall: 'Mulai panggilan', + moreActions: 'Aksi lainnya', + emoji: 'Emoji', + attachFile: 'Lampirkan file', + sendMessage: 'Kirim pesan', + failedSend: 'Gagal mengirim pesan', + justNow: 'Baru saja', + fallbackMessagePrefix: 'Belum ada thread aktif untuk', + fallbackMessageSuffix: 'saat ini.', + quickReply1: 'Saya cek dulu untuk Anda.', + quickReply2: 'Bisa kirimkan ID order Anda?', + quickReply3: 'Saya sudah eskalasikan ini ke tim support.', + template1Label: 'Follow-up Aktivasi', + template1Body: 'Permintaan aktivasi Anda sedang diproses. Kami akan segera membagikan pembaruannya.', + template2Label: 'Update Pengiriman', + template2Body: 'Pengiriman Anda sedang berjalan dan halaman tracking akan diperbarui dalam beberapa menit.', + template3Label: 'Klarifikasi Diskon', + template3Body: 'Diskon enterprise masih tersedia untuk paket tahunan yang memenuhi syarat.', + }, +} as const; export default async function ConversationsPage({ searchParams, @@ -9,6 +107,8 @@ export default async function ConversationsPage({ searchParams?: Promise<{ q?: string }>; }) { const token = await requireAuthToken(); + const locale = await getLocale(); + const labels = conversationLabels[locale]; const resolvedSearchParams = (await searchParams) || {}; const search = resolvedSearchParams.q?.trim() || ''; const conversations = await fetchConversations(token, { filter: 'all', search }); @@ -19,13 +119,14 @@ export default async function ConversationsPage({ return ( +
+
+ Support Center +

How can we help you today?

+

+ Search the knowledge base or browse common BizOne Enterprise topics for API, campaign, security, and + operational guidance. +

+
+
+ search + + +
+
+ +
+
+ Knowledge Base +

Browse by Category

+
+ + View all topics + +
+ +
+ {helpCategories.map((item) => ( + + {item.icon} +

{item.title}

+

{item.description}

+ + ))} +
+ +
+
+
+
+ star +

Popular Articles

+
+
+
+ {popularArticles.map((item) => ( + + {item.icon} +
+ {item.title} +

{item.description}

+
+ chevron_right + + ))} +
+
+ + +
+
+ ); +} diff --git a/frontend/src/app/dashboard/logs/page.tsx b/frontend/src/app/dashboard/logs/page.tsx index 478a7d3..64afb6c 100644 --- a/frontend/src/app/dashboard/logs/page.tsx +++ b/frontend/src/app/dashboard/logs/page.tsx @@ -8,6 +8,7 @@ import { analyticsLogs as fallbackAnalyticsLogs, analyticsWorkerHealth as fallbackWorkerHealth, } from '../../../lib/mock-data'; +import { getLocale } from '../../../lib/i18n'; type StatusTone = 'success' | 'warning' | 'error'; @@ -24,9 +25,114 @@ function payloadButtonClassName(tone: 'primary' | 'success' | 'error' | 'neutral return 'analytics-payload-button'; } -function formatTimestamp(value: string | null) { +const analyticsLabels = { + en: { + title: 'Admin Dashboard', + searchPlaceholder: 'Search system logs...', + operations: 'Operations', + heading: 'Activity Logs & Queue Monitor', + copy: 'Real-time surveillance of system processes, background jobs, and administrative actions.', + filterLogs: 'Filter Logs', + exportCsv: 'Export CSV', + queueMonitor: 'Queue Monitor', + live: 'Live', + fallback: 'Fallback', + pendingJobs: 'Pending Jobs', + processing: 'Processing', + failed24h: 'Failed (24H)', + workerHealth: 'Worker Health', + load: 'Load', + queueThroughput: 'Queue Throughput', + throughputA: 'System is currently processing', + throughputB: 'workload points per minute with', + throughputC: 'verified webhook traffic.', + technicalLogs: 'Technical Activity Logs', + total: 'Total:', + events: 'events', + timestamp: 'Timestamp', + action: 'Action', + userService: 'User / Service', + status: 'Status', + payload: 'Payload', + openPayload: 'Open payload for', + showing: 'Showing', + of: 'of', + logs: 'logs', + previousPage: 'Previous page', + nextPage: 'Next page', + liveTailMode: 'Live Tail Mode', + liveTailCopyA: 'Pulling live audit trail, queue jobs, and webhook activity from the backend. Current snapshot includes', + liveTailCopyB: 'jobs and', + liveTailCopyC: 'webhook events.', + launchConsole: 'Launch Debug Console', + apiLatency: 'API Latency', + dbConnections: 'DB Connections', + memoryUsage: 'Memory Usage', + degraded: 'Degraded', + active: 'Active', + down: 'Down', + backendLoad: 'backend-estimated load', + pending: 'Pending', + rejected: 'Rejected', + approved: 'Approved', + success: 'Success', + }, + id: { + title: 'Dashboard Admin', + searchPlaceholder: 'Cari log sistem...', + operations: 'Operasional', + heading: 'Log Aktivitas & Monitor Queue', + copy: 'Pantau proses sistem, background job, dan tindakan administratif secara real-time.', + filterLogs: 'Filter Log', + exportCsv: 'Export CSV', + queueMonitor: 'Monitor Queue', + live: 'Live', + fallback: 'Fallback', + pendingJobs: 'Job Tertunda', + processing: 'Diproses', + failed24h: 'Gagal (24J)', + workerHealth: 'Kesehatan Worker', + load: 'Load', + queueThroughput: 'Throughput Queue', + throughputA: 'Sistem sedang memproses', + throughputB: 'workload point per menit dengan', + throughputC: 'trafik webhook terverifikasi.', + technicalLogs: 'Log Aktivitas Teknis', + total: 'Total:', + events: 'event', + timestamp: 'Timestamp', + action: 'Aksi', + userService: 'User / Service', + status: 'Status', + payload: 'Payload', + openPayload: 'Buka payload untuk', + showing: 'Menampilkan', + of: 'dari', + logs: 'log', + previousPage: 'Halaman sebelumnya', + nextPage: 'Halaman berikutnya', + liveTailMode: 'Mode Live Tail', + liveTailCopyA: 'Mengambil audit trail, queue jobs, dan aktivitas webhook langsung dari backend. Snapshot saat ini mencakup', + liveTailCopyB: 'job dan', + liveTailCopyC: 'event webhook.', + launchConsole: 'Buka Debug Console', + apiLatency: 'Latensi API', + dbConnections: 'Koneksi DB', + memoryUsage: 'Penggunaan Memori', + degraded: 'Menurun', + active: 'Aktif', + down: 'Down', + backendLoad: 'estimasi load backend', + pending: 'Tertunda', + rejected: 'Ditolak', + approved: 'Disetujui', + success: 'Sukses', + }, +} as const; + +function formatTimestamp(value: string | null, labels: Record) { if (!value) { - return 'Pending'; + return labels.pending; } return new Date(value).toLocaleString('sv-SE', { @@ -61,22 +167,22 @@ function inferActorKind(name: string) { return { actorKind: 'user' as const, actorBadge: actorBadge(name) }; } -function mapAuditStatus(actionType: string, severity: 'default' | 'alert'): { label: string; tone: StatusTone } { +function mapAuditStatus(actionType: string, severity: 'default' | 'alert', labels: Record): { label: string; tone: StatusTone } { const lowered = actionType.toLowerCase(); if (severity === 'alert' || lowered.includes('fail') || lowered.includes('reject')) { - return { label: 'Rejected', tone: 'error' }; + return { label: labels.rejected, tone: 'error' }; } if (lowered.includes('retry') || lowered.includes('pending') || lowered.includes('queue')) { - return { label: 'Pending', tone: 'warning' }; + return { label: labels.pending, tone: 'warning' }; } if (lowered.includes('approve')) { - return { label: 'Approved', tone: 'success' }; + return { label: labels.approved, tone: 'success' }; } - return { label: 'Success', tone: 'success' }; + return { label: labels.success, tone: 'success' }; } function mapPayloadTone(tone: StatusTone): 'primary' | 'success' | 'error' | 'neutral' { @@ -87,6 +193,8 @@ function mapPayloadTone(tone: StatusTone): 'primary' | 'success' | 'error' | 'ne export default async function LogsPage() { const token = await requireAuthToken(); + const locale = await getLocale(); + const labels = analyticsLabels[locale]; const [summary, auditTrail] = await Promise.all([ fetchAnalyticsSummary(token), @@ -94,9 +202,9 @@ export default async function LogsPage() { ]); const queueStats = [ - { label: 'Pending Jobs', value: summary.queue.pendingJobs.toLocaleString('en-US'), tone: 'info', icon: 'schedule' }, - { label: 'Processing', value: summary.queue.processingJobs.toLocaleString('en-US'), tone: 'primary', icon: 'autorenew' }, - { label: 'Failed (24H)', value: summary.queue.failedJobs24h.toLocaleString('en-US'), tone: 'error', icon: 'error_outline' }, + { label: labels.pendingJobs, value: summary.queue.pendingJobs.toLocaleString('en-US'), tone: 'info', icon: 'schedule' }, + { label: labels.processing, value: summary.queue.processingJobs.toLocaleString('en-US'), tone: 'primary', icon: 'autorenew' }, + { label: labels.failed24h, value: summary.queue.failedJobs24h.toLocaleString('en-US'), tone: 'error', icon: 'error_outline' }, ] as const; const liveWorkerHealth = summary.workers.length > 0 ? summary.workers : fallbackWorkerHealth; @@ -104,9 +212,9 @@ export default async function LogsPage() { const liveLogs = auditTrail.items.length > 0 ? auditTrail.items.map((entry) => { - const status = mapAuditStatus(entry.actionType, entry.severity); + const status = mapAuditStatus(entry.actionType, entry.severity, labels); return { - timestamp: formatTimestamp(entry.createdAt), + timestamp: formatTimestamp(entry.createdAt, labels), action: entry.actionType.toUpperCase().replaceAll(' ', '_'), detail: entry.details, actor: entry.actorName, @@ -116,31 +224,34 @@ export default async function LogsPage() { payloadTone: mapPayloadTone(status.tone), }; }) - : fallbackAnalyticsLogs; + : fallbackAnalyticsLogs.map((log) => ({ + ...log, + status: log.statusTone === 'error' ? labels.rejected : log.statusTone === 'warning' ? labels.pending : labels.success, + })); const metricData = [ { - label: 'API Latency', + label: labels.apiLatency, value: `${summary.metrics.apiLatencyMs}ms`, - meta: summary.health.database === 'ok' ? 'Live' : 'Degraded', + meta: summary.health.database === 'ok' ? labels.live : labels.degraded, metaTone: summary.health.database === 'ok' ? 'success' : 'warning', icon: 'bar_chart', chartTone: 'bars', chartHeights: summary.metrics.apiLatencyBars.map((value) => `${value}%`), }, { - label: 'DB Connections', + label: labels.dbConnections, value: summary.metrics.databaseConnectionsEstimate.toLocaleString('en-US'), - meta: summary.health.database === 'ok' ? 'Active' : 'Down', + meta: summary.health.database === 'ok' ? labels.active : labels.down, metaTone: summary.health.database === 'ok' ? 'warning' : 'error', icon: 'database', chartTone: 'progress', progress: `${summary.metrics.databaseUsagePercent}%`, }, { - label: 'Memory Usage', + label: labels.memoryUsage, value: `${summary.metrics.memoryUsageGbEstimate}GB`, - meta: 'backend-estimated load', + meta: labels.backendLoad, metaTone: 'muted', icon: 'memory', chartTone: 'memory', @@ -151,26 +262,26 @@ export default async function LogsPage() { return (
-

Operations

-

Activity Logs & Queue Monitor

+

{labels.operations}

+

{labels.heading}

- Real-time surveillance of system processes, background jobs, and administrative actions. + {labels.copy}

@@ -179,10 +290,10 @@ export default async function LogsPage() {
+ +
+ ); +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index b492a6a..589c73c 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -3,22 +3,22 @@ :root { color-scheme: light; - --bg: #f8f9fa; - --bg-accent: radial-gradient(circle at top left, rgba(37, 211, 102, 0.12), transparent 28%), radial-gradient(circle at bottom right, rgba(0, 109, 47, 0.08), transparent 24%), linear-gradient(180deg, #f3fcef 0%, #f8f9fa 100%); - --surface: rgba(255, 255, 255, 0.9); + --bg: #faf8fd; + --bg-accent: radial-gradient(circle at top left, rgba(0, 170, 255, 0.12), transparent 32%), radial-gradient(circle at 80% 10%, rgba(0, 109, 178, 0.12), transparent 34%), linear-gradient(180deg, #f8fafd 0%, #faf8fd 100%); + --surface: #ffffff; --surface-strong: #ffffff; - --border: #bbcbb9; - --text: #151e16; - --muted: #3c4a3d; + --border: #e2e8f0; + --text: #1b1b1f; + --muted: #44474f; --muted-soft: #64748b; - --accent: #006d2f; - --accent-dark: #005523; - --accent-bright: #25d366; - --accent-surface: #e7f1e4; + --accent: #00a9f2; + --accent-dark: #001b44; + --accent-bright: #2dbcfe; + --accent-surface: #e5f5ff; --danger: #b42318; --success: #15703c; - --shadow: 0 18px 60px rgba(21, 30, 22, 0.08); - --shadow-soft: 0 4px 20px rgba(0, 0, 0, 0.05); + --shadow: 0 18px 45px rgba(23, 33, 53, 0.08); + --shadow-soft: 0 4px 20px rgba(15, 23, 42, 0.08); } * { @@ -1216,7 +1216,7 @@ button { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 14px; - align-items: end; + align-items: flex-end; } .chart-column { @@ -2098,6 +2098,14 @@ button { font-weight: 700; } +.audit-export-actions { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + margin-left: auto; +} + .audit-export-button.secondary { background: rgba(243, 252, 239, 0.92); } @@ -2233,7 +2241,7 @@ button { display: grid; grid-template-columns: auto repeat(4, minmax(0, 180px)) minmax(220px, 1fr) auto; gap: 16px; - align-items: end; + align-items: flex-end; } .audit-filter-title { @@ -7610,6 +7618,350 @@ button { } } +/* Audit trail spacing and locale control polish */ +.dashboard-content > .page-header, +.dashboard-content > .audit-kpi-grid, +.dashboard-content > .audit-filter-bar, +.dashboard-content > .audit-layout { + margin-bottom: 22px; +} + +.dashboard-content > .audit-layout { + margin-bottom: 0; +} + +.audit-export-button { + min-height: 48px; + border-radius: 8px; + border-color: #cbd5e1; + background: #ffffff; + box-shadow: none; +} + +.audit-export-actions { + align-self: flex-start; + flex-wrap: wrap; +} + +.audit-export-button.secondary { + background: #f7fbf2; +} + +.audit-kpi-grid { + gap: 20px; +} + +.audit-kpi-card, +.audit-table-card, +.audit-detail-card, +.audit-filter-bar { + border-radius: 12px; + border-color: #d7e0ea; + background: #ffffff; + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06); +} + +.audit-kpi-card { + min-height: 160px; + padding: 24px; +} + +.audit-filter-bar { + padding: 20px 22px; + grid-template-columns: minmax(110px, auto) repeat(4, minmax(150px, 1fr)) minmax(260px, 1.6fr) auto; + gap: 14px; +} + +.audit-filter-field select, +.audit-filter-search input { + min-height: 48px; + border-radius: 10px; + border-color: #cbd5e1; + background: #ffffff; +} + +.audit-reset-button { + min-height: 48px; + border-radius: 8px; + color: var(--bizone-cyan); +} + +.audit-layout { + grid-template-columns: minmax(0, 1.6fr) minmax(360px, 0.7fr); + gap: 20px; + align-items: start; +} + +.audit-table-card, +.audit-detail-card { + align-self: start; +} + +.audit-table th, +.audit-table td { + padding: 18px 20px; +} + +.audit-table th { + background: #f8fafc; +} + +.audit-table tbody tr.is-selected { + background: #f3f9ef; +} + +.audit-actions-cell button { + padding: 0; + background: transparent; + color: var(--bizone-cyan); + box-shadow: none; +} + +.audit-pagination { + background: #f8fafc; + border-top: 1px solid #e2e8f0; +} + +.audit-detail-card { + position: sticky; + top: 96px; + padding: 24px; +} + +.audit-detail-card .card-head { + align-items: flex-start; + margin-bottom: 8px; +} + +.audit-detail-card .detail-stack { + gap: 18px; +} + +.audit-detail-card .detail-stack div { + padding-bottom: 16px; + border-bottom: 1px solid #e2e8f0; +} + +.audit-detail-card .detail-stack div:last-child { + border-bottom: 0; + padding-bottom: 0; +} + +.audit-detail-card .detail-stack strong { + display: block; + overflow-wrap: anywhere; + line-height: 1.35; +} + +.dashboard-topbar-actions .language-switcher-compact { + height: 40px; + align-items: center; + padding: 4px; + border-color: #d7e0ea; + background: #eef7ea; +} + +.dashboard-topbar-actions .language-switcher-compact button { + min-width: 42px; + height: 32px; + padding: 0 10px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.dashboard-topbar-actions .language-switcher-compact button.is-active { + background: #ffffff; + color: var(--bizone-cyan); +} + +@media (max-width: 1400px) { + .audit-filter-bar { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .audit-filter-title, + .audit-reset-button { + align-self: end; + } + + .audit-layout { + grid-template-columns: 1fr; + } + + .audit-detail-card { + position: static; + } +} + +@media (max-width: 820px) { + .audit-kpi-grid, + .audit-filter-bar { + grid-template-columns: 1fr; + } + + .audit-table-card { + overflow-x: auto; + } + + .audit-table { + min-width: 860px; + } + + .audit-pagination { + align-items: flex-start; + flex-direction: column; + } +} + +.global-search-overlay { + position: fixed; + inset: 0; + z-index: 100; + display: grid; + place-items: start center; + padding: 92px 20px 20px; +} + +.global-search-backdrop { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + border-radius: 0; + background: rgba(15, 23, 42, 0.32); + box-shadow: none; +} + +.global-search-panel { + position: relative; + z-index: 1; + width: min(760px, 100%); + overflow: hidden; + border: 1px solid #d7e0ea; + border-radius: 14px; + background: #ffffff; + box-shadow: 0 24px 70px rgba(15, 23, 42, 0.24); +} + +.global-search-input-shell { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 18px; + border-bottom: 1px solid #e2e8f0; +} + +.global-search-input-shell > .material-symbols-outlined { + color: var(--bizone-cyan); +} + +.global-search-input-shell input { + flex: 1 1 auto; + min-width: 0; + border: 0; + outline: 0; + background: transparent; + color: var(--text); + font-size: 1rem; +} + +.global-search-input-shell button { + width: 36px; + height: 36px; + padding: 0; + border-radius: 8px; + background: #f8fafc; + color: #64748b; + box-shadow: none; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.global-search-results { + max-height: min(62vh, 560px); + overflow-y: auto; + padding: 10px; +} + +.global-search-results > p { + margin: 0; + padding: 22px; + color: #64748b; +} + +.global-search-result { + display: grid; + grid-template-columns: 42px minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + padding: 12px; + border-radius: 10px; +} + +.global-search-result:hover { + background: #f8fafc; +} + +.global-search-result-icon { + width: 42px; + height: 42px; + border-radius: 10px; + background: #e5f5ff; + color: var(--bizone-cyan); + display: inline-flex; + align-items: center; + justify-content: center; +} + +.global-search-result-copy { + min-width: 0; + display: grid; + gap: 3px; +} + +.global-search-result-copy strong, +.global-search-result-copy small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.global-search-result-copy strong { + color: var(--text); +} + +.global-search-result-copy small { + color: #64748b; +} + +.global-search-result-type { + padding: 6px 9px; + border-radius: 999px; + background: #eef7ea; + color: #475569; + font-size: 0.72rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +@media (max-width: 680px) { + .global-search-overlay { + padding: 74px 12px 12px; + } + + .global-search-result { + grid-template-columns: 38px minmax(0, 1fr); + } + + .global-search-result-type { + grid-column: 2; + justify-self: start; + } +} + @media (max-width: 900px) { .campaigns-header, .campaigns-table-toolbar, @@ -7785,7 +8137,7 @@ button { .campaign-detail-inline-tools { display: flex; - align-items: end; + align-items: flex-end; justify-content: space-between; gap: 16px; margin-top: 16px; @@ -8396,7 +8748,7 @@ button { .contacts-directory-header { display: flex; - align-items: end; + align-items: flex-end; justify-content: space-between; gap: 24px; } @@ -9136,3 +9488,2762 @@ button { grid-template-columns: 1fr; } } + +/* BizOne enterprise template refresh (2026) */ +:root { + --bizone-primary: #001b44; + --bizone-on-primary: #ffffff; + --bizone-cyan: #00a9f2; + --bizone-surface-2: #f8fafc; + --bizone-surface-soft: #f5f7fb; + --bizone-outline: #c5c6d0; + --bizone-outline-muted: #cbd5e1; +} + +.auth-page-enterprise { + background: var(--bg-accent); + align-content: center; + position: relative; + overflow: hidden; +} + +.auth-page-enterprise::before, +.auth-page-enterprise::after { + content: ""; + position: absolute; + width: 420px; + height: 420px; + border-radius: 999px; + pointer-events: none; + z-index: 0; +} + +.auth-page-enterprise::before { + top: -180px; + right: -120px; + background: radial-gradient(circle at center, rgba(45, 188, 254, 0.22), transparent 62%); +} + +.auth-page-enterprise::after { + bottom: -160px; + left: -140px; + background: radial-gradient(circle at center, rgba(0, 27, 68, 0.12), transparent 62%); +} + +.auth-page-login { + padding: 26px 18px; +} + +.auth-login-toolbar { + justify-content: flex-end; +} + +.auth-brand, +.two-factor-brand { + gap: 14px; +} + +.auth-brand-mark, +.two-factor-mark { + width: 78px; + height: 78px; + border-radius: 16px; + background: #ffffff; + border: 1px solid var(--bizone-outline-muted); + box-shadow: 0 12px 28px rgba(0, 21, 65, 0.14); + padding: 10px; +} + +.auth-brand-mark img, +.two-factor-mark img { + width: 100%; + height: 100%; + object-fit: contain; + border-radius: 10px; +} + +.auth-brand-copy h1, +.two-factor-brand h1 { + color: var(--bizone-primary); + letter-spacing: -0.01em; +} + +.auth-brand-copy p, +.two-factor-brand p { + color: var(--bizone-outline); + max-width: 32rem; +} + +.auth-card-enterprise, +.two-factor-card { + background: #fff; + border: 1px solid var(--bizone-outline-muted); + border-radius: 18px; + box-shadow: var(--shadow); + padding: 34px 36px; +} + +.enterprise-form, +.two-factor-card .invite-form { + gap: 18px; +} + +.auth-page .field-label { + color: var(--muted); + letter-spacing: 0.04em; +} + +.input-shell { + border-radius: 12px; + border: 1px solid #dbe2ee; +} + +.input-shell:focus-within { + border-color: var(--accent); + box-shadow: 0 0 0 4px rgba(0, 169, 242, 0.15); +} + +.input-icon { + left: 12px; + color: #7b879b; +} + +.input-shell input { + padding: 14px 92px 14px 42px; +} + +.text-link, +.auth-assist-row a, +.auth-footer .text-link { + color: var(--bizone-cyan); + font-weight: 700; +} + +.auth-submit { + border-radius: 12px; + background: linear-gradient(90deg, var(--bizone-primary), #00296f 46%, var(--bizone-cyan)); + box-shadow: 0 12px 30px rgba(0, 44, 109, 0.32); + transition: transform 0.2s ease, filter 0.2s ease; +} + +.auth-submit:hover { + transform: translateY(-1px); + filter: brightness(1.05); +} + +.auth-footer-links button { + color: #6b7280; +} + +.auth-page-symbol { + display: block; + opacity: 0.06; + color: var(--bizone-cyan); +} + +.dashboard-app { + display: grid; + grid-template-columns: 280px minmax(0, 1fr); + min-height: 100vh; + background: var(--bizone-surface-2); +} + +.dashboard-sidebar { + position: fixed; + inset: 0 auto 0 0; + width: 280px; + padding: 20px 14px; + background: linear-gradient(180deg, #001b44, #001b44 76%); + color: #ecf2ff; + border-right: 1px solid rgba(225, 232, 243, 0.12); +} + +.dashboard-brand { + display: flex; + align-items: center; + gap: 12px; + padding-bottom: 10px; + border-bottom: 1px solid rgba(230, 236, 245, 0.16); +} + +.dashboard-brand-logo { + width: 40px; + height: 40px; + border-radius: 12px; + padding: 5px; + background: #f8fbff; + flex: 0 0 auto; + display: grid; + place-items: center; +} + +.dashboard-brand-logo img { + width: 100%; + height: 100%; + object-fit: contain; + border-radius: 8px; +} + +.dashboard-brand h1 { + color: #ffffff; + font-size: 1.4rem; + margin: 0; +} + +.dashboard-brand p { + margin: 2px 0 0; + color: rgba(236, 242, 255, 0.78); + font-size: 0.86rem; +} + +.dashboard-primary-action { + margin: 14px 4px 20px; + border-radius: 12px; + background: linear-gradient(90deg, #0ea5e9, var(--bizone-cyan)); + color: #ffffff; + box-shadow: 0 12px 24px rgba(14, 165, 233, 0.35); +} + +.dashboard-sidebar-scroll { + height: calc(100vh - 184px); + overflow: auto; + padding: 0 4px 12px; +} + +.dashboard-nav-link, +.dashboard-subnav-link, +.dashboard-logout-button { + border-radius: 10px; + color: rgba(232, 238, 255, 0.87); +} + +.dashboard-nav-link:hover, +.dashboard-subnav-link:hover, +.dashboard-logout-button:hover { + background: rgba(255, 255, 255, 0.12); +} + +.dashboard-nav-link.is-active, +.dashboard-subnav-link.is-active { + background: rgba(45, 188, 254, 0.24); + border-left: 3px solid var(--bizone-cyan); + color: #ffffff; +} + +.dashboard-settings-link.is-active, +.dashboard-settings-link:hover { + background: rgba(255, 255, 255, 0.1); + color: #ffffff; +} + +.dashboard-main { + margin-left: 280px; +} + +.dashboard-topbar { + height: 74px; + padding: 0 26px; + gap: 14px; + border-bottom: 1px solid rgba(0, 0, 0, 0.07); + background: #ffffff; +} + +.dashboard-topbar-divider { + background: var(--bizone-outline); +} + +.dashboard-topbar-left h2 { + font-size: 1.34rem; + color: var(--text); +} + +.dashboard-searchbar, +.dashboard-search { + max-width: 460px; + border-radius: 11px; + border: 1px solid #e5e7eb; + background: #f8fafc; +} + +.dashboard-searchbar .material-symbols-outlined { + color: var(--bizone-cyan); +} + +.dashboard-icon-button { + width: 40px; + height: 40px; + border-radius: 10px; + border: 1px solid var(--bizone-outline-muted); + color: #4b5563; +} + +.dashboard-icon-button:hover { + background: #f8fbff; +} + +.dashboard-profile strong { + color: #1f2937; +} + +.dashboard-content { + padding: 30px 28px 38px; + gap: 18px; +} + +.dashboard-overview-layout { + grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr); + gap: 20px; +} + +.surface-card, +.dashboard-kpi-card, +.dashboard-volume-card, +.dashboard-campaigns-card, +.dashboard-funnel-card, +.dashboard-webhook-card, +.dashboard-alerts-card { + border-radius: 16px; + border: 1px solid var(--bizone-outline-muted); + background: #ffffff; + box-shadow: 0 12px 30px rgba(15, 23, 42, 0.06); +} + +.dashboard-stat-grid { + gap: 16px; +} + +.dashboard-kpi-icon { + background: rgba(0, 169, 242, 0.16); + color: var(--bizone-cyan); +} + +.dashboard-kpi-metric h3 { + color: var(--text); +} + +.dashboard-kpi-delta.success { + color: #0ea5e9; +} + +.dashboard-kpi-delta.warning { + color: #ef4444; +} + +.dashboard-kpi-delta { + color: #94a3b8; +} + +.dashboard-webhook-title h3, +.dashboard-table-head h3, +.dashboard-alerts-card h3, +.dashboard-funnel-card h3, +.dashboard-webhook-stats strong { + color: var(--text); +} + +.dashboard-endpoint-box { + border: 1px dashed var(--bizone-outline); +} + +.dashboard-endpoint-meta span:first-child { + color: #6b7280; +} + +.dashboard-endpoint-meta span:last-child { + color: #059669; + background: #ecfdf3; + border-radius: 999px; + padding: 2px 8px; +} + +.dashboard-outline-button, +.dashboard-link-button, +.dashboard-funnel-list .status-pill, +.dashboard-webhook-card .dashboard-live-dot { + border-radius: 11px; +} + +.dashboard-outline-button { + border: 1px solid var(--bizone-outline-muted); +} + +.dashboard-alert.danger { + border: 1px solid #fecaca; + background: #fef2f2; +} + +.dashboard-alert.info { + border: 1px solid #bae6fd; + background: #f0f9ff; +} + +@media (max-width: 1024px) { + .dashboard-app { + grid-template-columns: 1fr; + } + + .dashboard-sidebar { + position: static; + width: auto; + height: auto; + } + + .dashboard-sidebar-scroll { + height: auto; + max-height: none; + } + + .dashboard-main { + margin-left: 0; + } + + .dashboard-topbar { + padding: 0 18px; + } + + .dashboard-content, + .dashboard-topbar-left, + .dashboard-topbar-actions { + flex-direction: column; + align-items: flex-start; + } + + .dashboard-searchbar, + .dashboard-search { + width: 100%; + max-width: none; + } +} + +@media (max-width: 640px) { + .auth-container, + .two-factor-shell { + width: min(100%, 100%); + } + + .auth-card-enterprise, + .two-factor-card { + padding: 24px 18px; + } + + .auth-brand-copy h1, + .two-factor-brand h1 { + font-size: 1.75rem; + } + + .dashboard-overview-layout, + .dashboard-stat-grid { + grid-template-columns: 1fr; + } + + .dashboard-content { + padding: 18px; + } +} + +/* Dashboard overview layout cleanup */ +.dashboard-main { + min-width: 0; + background: #f8fafc; +} + +.dashboard-topbar { + min-width: 0; +} + +.dashboard-topbar-left { + min-width: 0; +} + +.dashboard-content { + display: block; + width: 100%; + min-width: 0; + padding: 28px; +} + +.dashboard-overview-layout { + display: grid !important; + grid-template-columns: minmax(0, 1fr) 350px !important; + align-items: start; + gap: 24px; + width: min(100%, 1500px); +} + +.dashboard-overview-main, +.dashboard-overview-side { + min-width: 0; + position: static; + display: grid; + align-content: start; + gap: 24px; +} + +.dashboard-stat-grid { + display: grid !important; + grid-template-columns: repeat(4, minmax(170px, 1fr)) !important; + gap: 16px; +} + +.dashboard-kpi-card { + min-height: 150px; + padding: 20px; +} + +.dashboard-kpi-top { + min-width: 0; +} + +.dashboard-kpi-metric { + flex-wrap: wrap; +} + +.dashboard-kpi-metric h3 { + font-size: 30px; + line-height: 38px; +} + +.dashboard-volume-card { + min-height: 390px; + overflow: hidden; +} + +.dashboard-line-chart { + min-height: 290px; + overflow: hidden; +} + +.dashboard-line-svg { + pointer-events: none; +} + +.dashboard-campaigns-card { + overflow: hidden; +} + +.dashboard-table { + table-layout: auto; +} + +.dashboard-overview-side { + grid-template-columns: minmax(0, 1fr); +} + +.dashboard-funnel-row { + margin-left: 0 !important; +} + +.dashboard-funnel-card, +.dashboard-webhook-card, +.dashboard-alerts-card { + width: 100%; + min-width: 0; +} + +.dashboard-webhook-card { + color: #1b1b1f; +} + +.dashboard-webhook-card * { + color: inherit; +} + +.dashboard-webhook-stats div span { + color: #64748b; +} + +.dashboard-endpoint-box code { + color: #1b1b1f; +} + +@media (max-width: 1280px) { + .dashboard-overview-layout { + grid-template-columns: 1fr !important; + } + + .dashboard-overview-side { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 980px) { + .dashboard-main { + margin-left: 0; + } + + .dashboard-content { + padding: 20px; + } + + .dashboard-stat-grid, + .dashboard-overview-side { + grid-template-columns: repeat(2, minmax(0, 1fr)) !important; + } +} + +@media (max-width: 680px) { + .dashboard-stat-grid, + .dashboard-overview-side { + grid-template-columns: 1fr !important; + } + + .dashboard-table { + display: block; + overflow-x: auto; + } +} + +/* Dashboard shell full-width pass */ +.dashboard-app { + min-height: 100vh; + background: #f8fafc; +} + +.dashboard-main { + width: calc(100vw - 280px); + max-width: none; + min-height: 100vh; + overflow-x: hidden; +} + +.dashboard-topbar { + position: sticky; + top: 0; + z-index: 40; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + height: 72px; + padding: 0 28px; + gap: 24px; + background: #ffffff; +} + +.dashboard-topbar-left { + flex: 1 1 auto; + min-width: 0; + display: flex; + align-items: center; + gap: 18px; +} + +.dashboard-topbar-left h2 { + flex: 0 0 auto; + max-width: 420px; + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dashboard-topbar-actions { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 14px; +} + +.dashboard-content { + width: 100%; + max-width: none; + min-height: calc(100vh - 72px); + padding: 32px 28px 48px; +} + +.page-header, +.page-section { + width: 100%; +} + +.page-header > div, +.page-section > div { + width: 100%; + max-width: 900px; +} + +.page-heading { + max-width: 760px; + margin: 0; + font-size: 44px; + line-height: 1.08; + letter-spacing: 0; +} + +.page-copy { + max-width: 760px; + margin: 12px 0 0; + line-height: 1.65; +} + +.dashboard-two-column { + width: 100%; + display: grid !important; + grid-template-columns: minmax(360px, 0.72fr) minmax(520px, 1.28fr); + gap: 24px; + align-items: start; +} + +.dashboard-two-column + .dashboard-two-column { + margin-top: 24px; +} + +.dashboard-two-column > *, +.settings-form-stack, +.settings-form-stack > *, +.surface-card { + min-width: 0; +} + +.settings-form-stack { + width: 100%; + gap: 20px; +} + +.form-stack { + gap: 16px; +} + +.form-stack input, +.form-stack select, +.form-stack textarea { + min-height: 48px; + border-color: #cbd5e1; + background: #ffffff; +} + +.metric-stack { + gap: 18px; +} + +.metric-stack div { + min-width: 0; + background: #f8fafc; + border: 1px solid #e2e8f0; +} + +.metric-stack strong { + overflow-wrap: anywhere; +} + +.dashboard-overview-layout { + width: 100%; + max-width: none; + grid-template-columns: minmax(0, 1fr) 360px !important; +} + +.dashboard-stat-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)) !important; +} + +@media (max-width: 1400px) { + .dashboard-two-column { + grid-template-columns: minmax(340px, 0.82fr) minmax(440px, 1.18fr); + } + + .dashboard-overview-layout { + grid-template-columns: 1fr !important; + } + + .dashboard-overview-side { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 1120px) { + .dashboard-sidebar { + position: static; + width: 100%; + height: auto; + } + + .dashboard-main { + width: 100%; + margin-left: 0; + } + + .dashboard-two-column, + .dashboard-overview-side, + .dashboard-stat-grid { + grid-template-columns: 1fr !important; + } + + .dashboard-topbar, + .dashboard-topbar-left, + .dashboard-topbar-actions { + height: auto; + align-items: flex-start; + } + + .dashboard-topbar { + flex-direction: column; + padding: 18px; + } + + .dashboard-topbar-left, + .dashboard-topbar-actions { + width: 100%; + flex-wrap: wrap; + } +} + +/* Help center */ +.help-hero { + position: relative; + overflow: hidden; + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 28px; + width: 100%; + padding: 44px; + border-radius: 18px; + background: + radial-gradient(circle at 12% 20%, rgba(45, 188, 254, 0.24), transparent 34%), + linear-gradient(135deg, #001b44 0%, #052b64 54%, #062452 100%); + color: #ffffff; + box-shadow: 0 24px 60px rgba(0, 27, 68, 0.18); +} + +.help-hero::after { + content: ''; + position: absolute; + inset: auto -80px -120px auto; + width: 320px; + height: 320px; + border-radius: 999px; + background: rgba(45, 188, 254, 0.18); +} + +.help-hero-copy { + position: relative; + z-index: 1; + max-width: 860px; + margin: 0 auto; + text-align: center; +} + +.help-hero .page-eyebrow { + color: #82cfff; +} + +.help-hero h1 { + margin: 10px 0 14px; + font-size: clamp(34px, 5vw, 56px); + line-height: 1.02; + letter-spacing: -0.04em; +} + +.help-hero p { + margin: 0; + color: rgba(255, 255, 255, 0.78); + font-size: 17px; + line-height: 1.7; +} + +.help-hero-search { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 14px; + width: min(760px, 100%); + margin: 0 auto; + padding: 10px 10px 10px 18px; + border-radius: 16px; + background: #ffffff; + box-shadow: 0 22px 42px rgba(0, 0, 0, 0.18); +} + +.help-hero-search .material-symbols-outlined { + color: #009fe3; + font-size: 30px; +} + +.help-hero-search input { + min-width: 0; + height: 48px; + border: 0; + outline: none; + color: #001b44; + font-size: 15px; +} + +.help-hero-search button { + height: 48px; + padding: 0 24px; + border: 0; + border-radius: 12px; + background: #001b44; + color: #ffffff; + font-weight: 800; + cursor: pointer; +} + +.help-section-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 20px; + margin-top: 34px; + margin-bottom: 18px; +} + +.help-section-head h2 { + margin: 6px 0 0; + font-size: 28px; + letter-spacing: -0.03em; +} + +.help-category-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 20px; +} + +.help-card { + display: block; + padding: 24px; + color: inherit; + text-decoration: none; + transition: transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease; +} + +.help-card:hover { + transform: translateY(-3px); + border-color: rgba(0, 159, 227, 0.38); + box-shadow: 0 18px 38px rgba(0, 27, 68, 0.1); +} + +.help-card-icon { + display: inline-grid; + place-items: center; + width: 52px; + height: 52px; + margin-bottom: 18px; + border-radius: 14px; + background: #eef8ef; + color: #009fe3; + border: 1px solid #d8ead6; + font-size: 28px; +} + +.help-card h3, +.help-articles-card h3, +.help-support-card h3, +.help-status-card h3 { + margin: 0; + color: #00020a; +} + +.help-card p { + margin: 10px 0 0; + color: #5b6b86; + line-height: 1.65; +} + +.help-bento-grid { + display: grid; + grid-template-columns: minmax(0, 1.55fr) minmax(320px, 0.75fr); + gap: 22px; + margin-top: 24px; +} + +.help-card-title-row { + display: flex; + align-items: center; + gap: 10px; +} + +.help-card-title-row .material-symbols-outlined { + color: #009fe3; +} + +.help-article-list { + display: grid; + gap: 10px; + margin-top: 18px; +} + +.help-article-link { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 16px; + padding: 16px; + border: 1px solid transparent; + border-radius: 14px; + color: inherit; + text-decoration: none; + transition: background 160ms ease, border-color 160ms ease; +} + +.help-article-link:hover { + background: #f8fafc; + border-color: #d8ead6; +} + +.help-article-link > .material-symbols-outlined:first-child { + color: #64748b; +} + +.help-article-link strong { + display: block; + color: #00020a; + font-size: 16px; +} + +.help-article-link p { + margin: 4px 0 0; + color: #64748b; + line-height: 1.5; +} + +.help-article-chevron { + color: #94a3b8; +} + +.help-support-stack { + display: grid; + gap: 22px; +} + +.help-support-card { + padding: 26px; + border-radius: 18px; + background: #001b44; + color: #ffffff; + box-shadow: 0 20px 48px rgba(0, 27, 68, 0.16); +} + +.help-support-card h3 { + color: #ffffff; + font-size: 26px; +} + +.help-support-card p { + margin: 12px 0 20px; + color: rgba(255, 255, 255, 0.76); + line-height: 1.65; +} + +.help-support-actions { + display: grid; + gap: 12px; +} + +.help-support-button { + display: flex; + align-items: center; + gap: 12px; + min-height: 54px; + padding: 14px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.1); + color: #ffffff; + text-decoration: none; + font-weight: 800; + transition: background 160ms ease, transform 160ms ease; +} + +.help-support-button:hover { + background: rgba(255, 255, 255, 0.18); + transform: translateY(-1px); +} + +.help-support-button small { + margin-left: auto; + padding: 5px 8px; + border-radius: 999px; + background: #2dbcfe; + color: #001b44; + font-size: 11px; + font-weight: 900; +} + +.help-status-card { + padding: 24px; +} + +.help-status-row { + display: flex; + align-items: center; + gap: 10px; + margin-top: 18px; + padding: 14px; + border-radius: 14px; + background: #f0f9ee; + color: #001b44; + font-weight: 700; +} + +.help-status-row span { + width: 10px; + height: 10px; + border-radius: 999px; + background: #22c55e; + box-shadow: 0 0 0 5px rgba(34, 197, 94, 0.14); +} + +@media (max-width: 1180px) { + .help-category-grid, + .help-bento-grid { + grid-template-columns: 1fr 1fr; + } + + .help-support-stack { + grid-column: 1 / -1; + } +} + +@media (max-width: 760px) { + .help-hero { + padding: 28px 20px; + } + + .help-hero-search { + grid-template-columns: auto minmax(0, 1fr); + } + + .help-hero-search button { + grid-column: 1 / -1; + width: 100%; + } + + .help-section-head, + .help-category-grid, + .help-bento-grid { + grid-template-columns: 1fr; + } + + .help-section-head { + display: grid; + } +} + +/* Header notification center */ +.notification-center { + position: relative; + display: inline-flex; +} + +.notification-trigger { + position: relative; +} + +.notification-badge { + position: absolute; + top: -5px; + right: -5px; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 19px; + height: 19px; + padding: 0 5px; + border: 2px solid #ffffff; + border-radius: 999px; + background: #009fe3; + color: #ffffff; + font-size: 11px; + font-weight: 900; + line-height: 1; +} + +.notification-panel { + position: absolute; + top: calc(100% + 12px); + right: 0; + z-index: 80; + width: min(390px, calc(100vw - 32px)); + overflow: hidden; + border: 1px solid #d7e3d2; + border-radius: 18px; + background: #ffffff; + box-shadow: 0 24px 64px rgba(0, 27, 68, 0.18); +} + +.notification-panel::before { + content: ''; + position: absolute; + top: -7px; + right: 22px; + width: 14px; + height: 14px; + transform: rotate(45deg); + border-left: 1px solid #d7e3d2; + border-top: 1px solid #d7e3d2; + background: #ffffff; +} + +.notification-panel-head { + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 18px 18px 14px; + border-bottom: 1px solid #e2e8f0; +} + +.notification-panel-head strong { + display: block; + color: #00020a; + font-size: 17px; +} + +.notification-panel-head p { + margin: 3px 0 0; + color: #64748b; + font-size: 13px; +} + +.notification-panel-head a { + flex: 0 0 auto; + color: #009fe3; + font-size: 13px; + font-weight: 900; + text-decoration: none; +} + +.notification-list { + display: grid; + max-height: 420px; + overflow-y: auto; + padding: 8px; +} + +.notification-item { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 13px; + padding: 13px; + border-radius: 14px; + color: inherit; + text-decoration: none; + transition: background 160ms ease, transform 160ms ease; +} + +.notification-item:hover { + background: #f8fafc; + transform: translateY(-1px); +} + +.notification-item.is-unread { + background: #f0f9ee; +} + +.notification-icon { + display: inline-grid; + place-items: center; + width: 40px; + height: 40px; + border-radius: 12px; + font-size: 22px; +} + +.notification-icon.message { + background: #e8f6ff; + color: #009fe3; +} + +.notification-icon.campaign { + background: #eef8ef; + color: #1f9d55; +} + +.notification-icon.warning { + background: #fff7ed; + color: #f97316; +} + +.notification-icon.security { + background: #eff6ff; + color: #2563eb; +} + +.notification-copy { + min-width: 0; +} + +.notification-copy strong { + display: block; + color: #00020a; + font-size: 14px; + line-height: 1.35; +} + +.notification-copy small { + display: block; + margin-top: 4px; + color: #64748b; + font-size: 12px; + line-height: 1.45; +} + +.notification-copy em { + display: block; + margin-top: 8px; + color: #009fe3; + font-size: 11px; + font-style: normal; + font-weight: 900; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +@media (max-width: 760px) { + .notification-panel { + position: fixed; + top: 78px; + right: 16px; + left: 16px; + width: auto; + } + + .notification-panel::before { + display: none; + } +} + +/* Profile menu and page */ +.profile-menu { + position: relative; +} + +.profile-menu-trigger { + border: 0; + cursor: pointer; +} + +.profile-menu-panel { + position: absolute; + top: calc(100% + 12px); + right: 0; + z-index: 85; + width: min(330px, calc(100vw - 32px)); + overflow: hidden; + border: 1px solid #d7e3d2; + border-radius: 18px; + background: #ffffff; + box-shadow: 0 24px 64px rgba(0, 27, 68, 0.18); +} + +.profile-menu-head { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 14px; + padding: 18px; + background: #f0f9ee; + border-bottom: 1px solid #d7e3d2; +} + +.profile-menu-head strong, +.profile-menu-head span, +.profile-menu-head small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.profile-menu-head strong { + color: #00020a; + font-size: 15px; +} + +.profile-menu-head span { + margin-top: 3px; + color: #536179; + font-size: 13px; +} + +.profile-menu-head small { + margin-top: 6px; + color: #009fe3; + font-size: 11px; + font-weight: 900; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.profile-menu-list { + display: grid; + padding: 8px; +} + +.profile-menu-list a, +.profile-menu-logout button { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + min-height: 46px; + padding: 12px; + border: 0; + border-radius: 12px; + background: transparent; + color: #001b44; + font: inherit; + font-weight: 800; + text-align: left; + text-decoration: none; + cursor: pointer; +} + +.profile-menu-list a:hover, +.profile-menu-logout button:hover { + background: #f8fafc; +} + +.profile-menu-list .material-symbols-outlined, +.profile-menu-logout .material-symbols-outlined { + color: #009fe3; + font-size: 21px; +} + +.profile-menu-logout { + padding: 8px; + border-top: 1px solid #e2e8f0; +} + +.profile-page-header { + margin-bottom: 24px; +} + +.profile-page-grid { + display: grid; + grid-template-columns: minmax(280px, 0.55fr) minmax(0, 1fr); + gap: 24px; + align-items: start; +} + +.profile-summary-card { + padding: 28px; + text-align: center; +} + +.profile-summary-avatar { + display: inline-grid; + place-items: center; + width: 86px; + height: 86px; + margin-bottom: 18px; + border-radius: 24px; + background: linear-gradient(135deg, #001b44, #009fe3); + color: #ffffff; + font-size: 34px; + font-weight: 900; + box-shadow: 0 18px 34px rgba(0, 27, 68, 0.18); +} + +.profile-summary-card h2 { + margin: 0; + color: #00020a; + font-size: 28px; +} + +.profile-summary-card p { + margin: 8px 0 0; + color: #64748b; +} + +.profile-summary-meta { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 8px; + margin-top: 18px; +} + +.profile-summary-meta span { + padding: 7px 10px; + border-radius: 999px; + background: #eef8ef; + color: #001b44; + font-size: 12px; + font-weight: 900; + text-transform: capitalize; +} + +.profile-session-list { + display: grid; + gap: 12px; + margin: 24px 0 0; + text-align: left; +} + +.profile-session-list div { + padding: 14px; + border: 1px solid #e2e8f0; + border-radius: 14px; + background: #f8fafc; +} + +.profile-session-list dt { + color: #64748b; + font-size: 12px; + font-weight: 900; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.profile-session-list dd { + margin: 6px 0 0; + color: #001b44; + font-weight: 800; +} + +.profile-form-stack { + display: grid; + gap: 22px; +} + +.profile-form-card { + display: grid; + gap: 16px; + padding: 26px; +} + +.profile-submit-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 50px; + padding: 0 22px; + border: 0; + border-radius: 14px; + background: linear-gradient(135deg, #001b44, #009fe3); + color: #ffffff; + font-weight: 900; + cursor: pointer; + box-shadow: 0 16px 34px rgba(0, 27, 68, 0.16); +} + +.profile-submit-button:disabled { + cursor: wait; + opacity: 0.68; +} + +@media (max-width: 980px) { + .profile-page-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 760px) { + .profile-menu-panel { + position: fixed; + top: 78px; + right: 16px; + left: 16px; + width: auto; + } +} + +.profile-readonly-field input[readonly] { + background: #f8fafc; + color: #64748b; + cursor: not-allowed; +} + +.profile-readonly-field small { + display: block; + margin-top: 8px; + color: #64748b; + font-size: 12px; + line-height: 1.45; +} + +.profile-menu .profile-menu-trigger { + min-height: 48px; + padding: 0 0 0 14px; + border: 0; + border-left: 1px solid #e2e8f0; + border-radius: 0; + background: transparent; + box-shadow: none; +} + +.profile-menu .profile-menu-trigger:hover { + background: transparent; +} + +.profile-menu .profile-menu-trigger strong { + color: #1f2937; +} + +.profile-menu .profile-menu-trigger span { + color: #64748b; +} + +.profile-menu .profile-menu-trigger .dashboard-avatar { + width: 42px; + height: 42px; + border: 1.5px solid #009fe3; + background: #ffffff; + color: #009fe3; +} + +/* Webhook logs spacing polish */ +.webhook-logs-layout { + display: grid; + grid-template-columns: minmax(420px, 0.82fr) minmax(520px, 1.18fr); + gap: 24px; + align-items: stretch; + margin-top: 24px; +} + +.webhook-logs-card { + min-width: 0; + padding: 20px; +} + +.webhook-logs-table-card { + overflow: hidden; +} + +.webhook-logs-table-card .data-table { + overflow-x: auto; +} + +.webhook-logs-table-card .data-row { + min-height: 54px; + padding: 0 0; +} + +.webhook-logs-detail-card { + display: flex; + align-items: stretch; + justify-content: stretch; + min-height: 94px; +} + +.webhook-logs-detail-card > p { + margin: 0; +} + +.webhook-empty-state { + display: grid; + place-items: center; + width: 100%; + min-height: 132px; + padding: 26px; + border: 1px dashed #cbd5e1; + border-radius: 16px; + background: #f8fafc; + text-align: center; +} + +.webhook-empty-state .material-symbols-outlined { + display: inline-grid; + place-items: center; + width: 46px; + height: 46px; + margin-bottom: 10px; + border-radius: 14px; + background: #eef8ef; + color: #009fe3; + font-size: 24px; +} + +.webhook-empty-state strong { + color: #001b44; + font-size: 16px; +} + +.webhook-empty-state p { + max-width: 560px; + margin: 8px 0 0; + color: #64748b; + line-height: 1.55; +} + +.webhook-queue-card { + margin-top: 24px; + padding: 20px; +} + +.webhook-queue-card pre, +.webhook-logs-detail-card pre { + max-width: 100%; + overflow: auto; + border-radius: 14px; +} + +@media (max-width: 1180px) { + .webhook-logs-layout { + grid-template-columns: 1fr; + } +} + +/* WhatsApp API settings layout */ +.whatsapp-settings-grid, +.whatsapp-notes-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 24px; + align-items: start; + width: 100%; + margin-top: 24px; +} + +.whatsapp-settings-grid > *, +.whatsapp-notes-grid > * { + min-width: 0; +} + +.whatsapp-settings-grid .settings-form-stack { + width: 100%; + display: grid; + gap: 24px; +} + +.whatsapp-settings-grid .surface-card, +.whatsapp-notes-grid .surface-card { + padding: 24px; +} + +.whatsapp-status-card .metric-stack { + gap: 18px; +} + +.whatsapp-status-card .metric-stack div { + min-height: 84px; + align-content: center; +} + +.whatsapp-status-card .metric-stack strong, +.whatsapp-status-card .metric-stack span { + overflow-wrap: anywhere; +} + +.whatsapp-notes-grid { + margin-top: 24px; +} + +.whatsapp-notes-grid h2 { + margin: 0 0 12px; + color: #00020a; +} + +.whatsapp-notes-grid p { + margin: 0; + color: #5b6b86; + line-height: 1.65; +} + +.whatsapp-notes-grid p + p { + margin-top: 12px; +} + +@media (max-width: 1180px) { + .whatsapp-settings-grid, + .whatsapp-notes-grid { + grid-template-columns: 1fr; + } +} + +.whatsapp-settings-grid .settings-checkbox, +.whatsapp-settings-grid .settings-subscription-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 18px; +} + +.whatsapp-settings-grid .settings-toggle { + justify-self: end; + align-self: center; +} + +.whatsapp-settings-grid .settings-checkbox .settings-toggle, +.whatsapp-settings-grid .settings-subscription-row .settings-toggle { + margin-left: auto; +} + +/* Roles & permissions polish */ +.roles-card-grid, +.roles-bottom-grid { + gap: 24px; + margin-top: 24px; +} + +.roles-create-panel, +.permission-matrix-card, +.roles-bottom-grid > .surface-card, +.roles-help-card { + border: 1px solid #d7e3ee; + box-shadow: 0 18px 42px rgba(0, 27, 68, 0.08); +} + +.roles-create-panel, +.permission-matrix-card { + margin-top: 24px; +} + +.role-card { + border-color: #d7e3ee; + background: #ffffff; + box-shadow: 0 14px 32px rgba(0, 27, 68, 0.07); +} + +.role-card.is-selected { + border-color: #009fe3; + box-shadow: 0 18px 42px rgba(0, 159, 227, 0.16); +} + +.role-icon, +.role-icon.tone-primary, +.role-icon.tone-secondary, +.role-icon.tone-tertiary, +.roles-audit-icon { + background: #e8f6ff; + color: #009fe3; +} + +.role-badge, +.role-badge.tone-primary, +.role-badge.tone-secondary, +.role-badge.tone-tertiary, +.role-badge.tone-editing { + background: #eef6ff; + color: #001b44; +} + +.permission-table th { + background: #f0f8ff; + color: #001b44; +} + +.permission-table tbody tr:nth-child(even) td { + background: #fbfdff; +} + +.permission-indicator.is-on { + background: #e8f6ff; + color: #0078b7; +} + +.permission-indicator.is-off { + background: #eef2f7; + color: #64748b; +} + +.toggle-switch input:checked + .toggle-track { + background: #009fe3; +} + +.roles-help-card { + background: linear-gradient(135deg, #001b44 0%, #052b64 62%, #009fe3 160%); + color: #ffffff; +} + +.roles-help-copy .card-kicker, +.roles-help-copy p { + color: rgba(255, 255, 255, 0.78); +} + +.roles-help-copy h3 { + color: #ffffff; +} + +.roles-help-button { + background: #ffffff; + color: #001b44; +} + +.roles-help-button:hover { + background: #e8f6ff; +} + +.roles-inline-link, +.role-inline-button, +.roles-create-button { + color: #009fe3; +} + +.roles-create-button, +.role-action-button:not(.secondary-button) { + background: #009fe3; + color: #ffffff; +} + +.roles-create-button:hover, +.role-action-button:not(.secondary-button):hover { + background: #0088cf; +} + +@media (max-width: 980px) { + .roles-bottom-grid, + .roles-card-grid { + grid-template-columns: 1fr; + } +} + +/* Remove remaining green tint from header controls */ +.dashboard-topbar .language-switcher-compact { + background: #f0f8ff; + border-color: #cfe4f5; +} + +.dashboard-topbar .language-switcher-compact button { + color: #001b44; +} + +.dashboard-topbar .language-switcher-compact button.is-active { + background: #ffffff; + color: #009fe3; + box-shadow: 0 8px 18px rgba(0, 27, 68, 0.08); +} + +.profile-menu-head { + background: #f0f8ff; + border-bottom-color: #cfe4f5; +} + +.profile-menu-panel { + border-color: #cfe4f5; +} + +.profile-menu-list a:hover, +.profile-menu-logout button:hover { + background: #f0f8ff; +} + +/* Users management polish */ +.users-board { + display: grid; + gap: 24px; +} + +.users-stats-grid, +.users-panel-layout, +.users-edit-layout, +.users-role-overview, +.users-table-shell { + margin-top: 0; +} + +.users-stats-grid, +.users-panel-layout, +.users-edit-layout, +.users-role-cards { + gap: 24px; +} + +.users-stat-card, +.users-form-card, +.users-summary-card, +.users-illustration-card, +.users-profile-card, +.users-security-card, +.users-danger-card, +.users-table-shell, +.users-role-overview, +.users-role-card { + border: 1px solid #d7e3ee; + box-shadow: 0 18px 42px rgba(0, 27, 68, 0.08); +} + +.users-stat-card.is-accent, +.users-hero-button, +.users-pagination-buttons button.is-active { + background: linear-gradient(135deg, #001b44, #009fe3); +} + +.users-stat-icon.tone-primary, +.users-stat-icon.tone-secondary, +.users-stat-icon.tone-contrast, +.users-profile-avatar, +.users-role-card-head.tone-admin, +.users-role-card-head.tone-editor, +.users-role-card-head.tone-agent { + background: #e8f6ff; + color: #009fe3; +} + +.users-avatar.tone-admin, +.users-avatar.tone-editor, +.users-avatar.tone-agent, +.users-role-pill.tone-admin, +.users-role-pill.tone-editor, +.users-role-pill.tone-agent { + background: #eef6ff; + color: #001b44; +} + +.users-panel-badge.tone-success, +.users-profile-meta strong.tone-success { + background: #e8f6ff; + color: #0078b7; +} + +.users-status-dot.tone-success { + background: #009fe3; +} + +.users-table thead th { + background: #f0f8ff; + color: #001b44; +} + +.users-table tbody tr:hover, +.users-actions button:hover, +.users-toolbar-button:hover { + background: #f0f8ff; +} + +.users-loading-bar { + background: linear-gradient(90deg, rgba(0, 159, 227, 0), #009fe3, rgba(0, 159, 227, 0)); +} + +.users-illustration-art { + background: radial-gradient(circle at 25% 25%, rgba(0, 159, 227, 0.24), transparent 34%), linear-gradient(135deg, #001b44, #009fe3); +} + +.users-feedback { + border-color: #cfe4f5; + background: #f0f8ff; + color: #001b44; +} + +.users-table-wrap { + border-color: #d7e3ee; +} + +/* Users page final theme alignment */ +.users-stat-copy { + color: #0078b7; +} + +.users-stat-icon { + background: #e8f6ff; + color: #009fe3; +} + +.users-panel-badge.tone-warning, +.users-profile-meta strong.tone-warning { + background: #fff7e6; + color: #b7791f; +} + +.users-status-dot.tone-warning { + background: #f59e0b; +} + +/* Campaigns page polish and BizOne theme alignment */ +.campaigns-page { + gap: 28px; +} + +.campaigns-header, +.campaigns-stats-grid, +.campaigns-table-card, +.campaigns-insight-grid { + margin-bottom: 0; +} + +.campaigns-stats-grid, +.campaigns-insight-grid { + gap: 24px; +} + +.campaigns-primary-button, +.campaigns-pagination-buttons button.is-active, +.campaigns-health-card { + background: linear-gradient(135deg, #001b44, #009fe3); + box-shadow: 0 16px 36px rgba(0, 27, 68, 0.16); +} + +.campaigns-stat-card, +.campaigns-table-card, +.campaigns-insight-card, +.campaigns-health-card, +.campaigns-create-modal { + border-color: #d7e3ee; + box-shadow: 0 18px 42px rgba(0, 27, 68, 0.08); +} + +.campaigns-stat-head > .material-symbols-outlined, +.campaigns-create-modal-head button, +.campaigns-search-field input, +.campaigns-sort-field select, +.campaigns-table thead tr, +.campaigns-progress-track, +.campaign-status-pill.is-draft, +.campaigns-insight-body button { + background: #f0f8ff; +} + +.campaigns-stat-head > .material-symbols-outlined, +.campaigns-stat-head > .material-symbols-outlined.is-secondary, +.campaigns-stat-card strong, +.campaigns-stat-card strong.is-secondary, +.campaigns-insight-body p strong, +.campaigns-date-cell.is-scheduled span { + color: #009fe3; +} + +.campaigns-stat-delta.is-positive, +.campaigns-insight-body p strong.is-success { + color: #0078b7; +} + +.campaigns-filter-pill { + color: #52647a; +} + +.campaigns-filter-pill:hover, +.campaigns-action-button:hover, +.campaigns-pagination-buttons button:hover:not(:disabled):not(.is-active), +.campaigns-table tbody tr:hover { + background: #f0f8ff; +} + +.campaigns-filter-pill.is-active { + background: #e8f6ff; + color: #001b44; +} + +.campaigns-create-form input, +.campaigns-create-form select, +.campaigns-create-form textarea { + border-color: #cbd8e6; + background: #ffffff; +} + +.campaigns-create-form input:focus, +.campaigns-create-form select:focus, +.campaigns-create-form textarea:focus, +.campaigns-search-field input:focus, +.campaigns-sort-field select:focus { + border-color: rgba(0, 159, 227, 0.5); + box-shadow: 0 0 0 3px rgba(0, 159, 227, 0.14); +} + +.campaign-status-pill.is-sent { + background: #e8f6ff; + color: #0078b7; +} + +.campaign-status-pill.is-draft { + color: #52647a; +} + +.campaigns-progress-bar { + background: #009fe3; +} + +.campaigns-insight-glow { + background: rgba(0, 159, 227, 0.16); +} + +/* Contacts and analytics polish with BizOne theme alignment */ +.contacts-directory-page, +.analytics-page { + gap: 28px; +} + +.contacts-filter-bar, +.contacts-table-card, +.contacts-modal-card, +.analytics-panel, +.analytics-table-card, +.analytics-metric-card, +.analytics-live-tail, +.analytics-throughput-card { + border-color: #d7e3ee; + box-shadow: 0 18px 42px rgba(0, 27, 68, 0.08); +} + +.contacts-primary-button, +.contacts-fab, +.contacts-page-button.is-active, +.analytics-console-button { + background: linear-gradient(135deg, #001b44, #009fe3); + box-shadow: 0 16px 36px rgba(0, 27, 68, 0.16); +} + +.contacts-secondary-button, +.contacts-page-button, +.analytics-ghost-button, +.analytics-pagination button { + border-color: #cbd8e6; +} + +.contacts-filter-input, +.contacts-filter-select, +.contacts-form-field input, +.contacts-form-field textarea, +.contacts-table thead, +.contacts-table-footer, +.analytics-queue-card, +.analytics-table-head span, +.analytics-table thead, +.analytics-progress-track, +.analytics-live-tail, +.analytics-payload-button:hover { + background: #f0f8ff; +} + +.contacts-filter-input:focus, +.contacts-filter-select:focus, +.contacts-form-field input:focus, +.contacts-form-field textarea:focus { + outline: none; + box-shadow: 0 0 0 3px rgba(0, 159, 227, 0.14); +} + +.contacts-clear-button, +.contacts-avatar, +.contact-detail-hero-avatar, +.contacts-table-footer strong, +.analytics-queue-card.is-primary .analytics-queue-icon, +.analytics-throughput-icon, +.analytics-live-tail-icon, +.analytics-metric-head .material-symbols-outlined, +.analytics-payload-button:hover, +.analytics-payload-button.is-primary:hover { + color: #009fe3; +} + +.contacts-avatar, +.contact-detail-hero-avatar, +.contacts-status-chip.is-active, +.contacts-tag-chip, +.analytics-live-chip, +.analytics-status-pill.is-success, +.analytics-actor-badge { + background: #e8f6ff; + color: #0078b7; +} + +.contacts-table tbody tr:hover, +.contacts-icon-button:hover, +.contacts-page-button:hover:not(:disabled), +.analytics-table tbody tr:hover { + background: #f0f8ff; +} + +.analytics-live-dot, +.analytics-progress-fill, +.analytics-progress-fill.is-success, +.analytics-mini-bars span.is-primary, +.analytics-mini-bars.is-memory span:last-child { + background: #009fe3; +} + +.analytics-progress-fill.is-warning { + background: #f59e0b; +} + +.analytics-worker-meta strong.is-success, +.analytics-metric-value span.is-success, +.contacts-form-success { + color: #0078b7; +} + +.analytics-throughput-card { + border-color: rgba(0, 159, 227, 0.2); + background: linear-gradient(180deg, rgba(0, 159, 227, 0.1), rgba(255, 255, 255, 0.95)); +} + +.analytics-mini-bars span, +.analytics-mini-bars.is-memory span { + background: #d9edf9; +} + +/* Conversations and templates polish with BizOne theme alignment */ +.conversations-layout, +.templates-card, +.templates-row-card, +.template-builder-card, +.templates-guidelines, +.template-builder-preview-toggle { + border-color: #d7e3ee; + box-shadow: 0 18px 42px rgba(0, 27, 68, 0.08); +} + +.conversations-sidebar, +.conversations-thread, +.conversations-profile, +.conversations-filter-tabs, +.conversations-thread-head, +.conversations-composer, +.conversations-profile-top, +.conversations-profile-footer { + border-color: #d7e3ee; +} + +.conversation-list-item:hover, +.conversation-list-item.is-active, +.conversations-thread-actions button:hover, +.conversations-profile-actions button:hover, +.conversations-composer-shell, +.conversations-composer-tools button.is-active, +.conversation-suggestion-chip:hover, +.conversations-tags span, +.templates-filter-apply, +.templates-preview-box, +.templates-row-icon, +.templates-row-actions button:hover, +.templates-overflow-button:hover, +.templates-edit-button:hover, +.template-builder-toolbar button:hover, +.template-builder-delete-button:hover, +.templates-guidelines, +.templates-guidelines-icon, +.template-builder-field input, +.template-builder-field select, +.template-builder-field textarea, +.template-builder-button-row, +.template-builder-add-button { + background: #f0f8ff; +} + +.conversations-filter-tabs button.is-active, +.conversation-avatar, +.conversation-avatar.is-profile, +.conversation-list-pill.is-success, +.conversations-tags button, +.templates-status-pill.is-approved, +.template-builder-preview-toggle button.is-active { + background: #e8f6ff; + color: #0078b7; +} + +.conversation-active-rail, +.conversation-bubble.is-outgoing, +.conversations-send-button, +.templates-create-button, +.template-builder-submit-button { + background: linear-gradient(135deg, #001b44, #009fe3); + box-shadow: 0 14px 30px rgba(0, 27, 68, 0.16); +} + +.conversation-avatar, +.conversation-list-topline span.is-recent, +.conversation-meta .material-symbols-outlined, +.conversations-thread-actions button:hover, +.conversations-profile-actions button:hover, +.conversation-suggestion-chip, +.conversations-tags button, +.templates-category, +.templates-edit-button, +.templates-row-actions button.is-primary, +.templates-row-meta span, +.template-builder-card-head .material-symbols-outlined, +.template-builder-inline-meta button, +.template-builder-add-button, +.template-builder-chat-bubble strong { + color: #009fe3; +} + +.conversations-online-dot, +.conversations-activity-item i.is-primary { + background: #009fe3; +} + +.conversation-list-pill.is-info { + background: #e8f6ff; + color: #0078b7; +} + +.conversation-list-pill.is-muted, +.conversation-system-divider span { + background: #eef4f9; + color: #52647a; +} + +.conversations-composer-tools button, +.conversations-profile-footer button, +.conversation-suggestion-chip, +.conversations-tags span, +.conversations-tags button, +.templates-filter-pill, +.templates-guidelines-button, +.template-builder-draft-button, +.template-builder-button-row, +.template-builder-add-button, +.template-builder-preview-toggle, +.template-builder-field input, +.template-builder-field select, +.template-builder-field textarea, +.template-builder-button-row input { + border-color: #cbd8e6; +} + +.template-builder-field input:focus, +.template-builder-field select:focus, +.template-builder-field textarea:focus, +.template-builder-button-row input:focus { + border-color: rgba(0, 159, 227, 0.5); + box-shadow: 0 0 0 3px rgba(0, 159, 227, 0.14); +} + +.templates-preview-fade { + background: linear-gradient(180deg, rgba(240, 248, 255, 0), #f0f8ff 90%); +} + +.templates-card:hover, +.templates-row-card:hover { + border-color: rgba(0, 159, 227, 0.32); +} + +.template-builder-phone-header, +.template-builder-mic-button { + background: #001b44; +} + +.template-builder-phone-screen { + background: linear-gradient(180deg, #eef7ff, #f7fbff); +} + +/* Conversation page panel spacing refinement */ +.conversations-layout { + gap: 22px; + border: 0; + background: transparent; + box-shadow: none; + border-radius: 0; +} + +.conversations-sidebar, +.conversations-thread, +.conversations-profile { + border: 1px solid #d7e3ee; + border-radius: 22px; + box-shadow: 0 18px 42px rgba(0, 27, 68, 0.08); + overflow: hidden; +} + +.conversations-sidebar, +.conversations-profile { + background: #ffffff; +} + +.conversations-thread { + background: #f8fbff; +} + +.conversations-filter-tabs, +.conversations-thread-head, +.conversations-composer, +.conversations-profile-top, +.conversations-profile-footer { + border-color: #d7e3ee; +} + +.conversations-thread-body { + background: linear-gradient(180deg, #f8fbff, #ffffff); +} + +/* Wallet page */ +.wallet-page { + display: grid; + gap: 28px; +} + +.wallet-hero, +.wallet-panel, +.wallet-table-card, +.wallet-kpi-card { + border: 1px solid #d7e3ee; + border-radius: 22px; + background: #ffffff; + box-shadow: 0 18px 42px rgba(0, 27, 68, 0.08); +} + +.wallet-hero { + padding: 28px; +} + +.wallet-hero h1 { + margin: 0; + font-family: "Plus Jakarta Sans", "Segoe UI", sans-serif; + font-size: clamp(2rem, 4vw, 3rem); + line-height: 1.08; + letter-spacing: -0.03em; +} + +.wallet-hero p:not(.page-eyebrow), +.wallet-panel p, +.wallet-midtrans-card p { + margin: 10px 0 0; + max-width: 760px; + color: #64748b; + line-height: 1.7; +} + +.wallet-kpi-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 22px; +} + +.wallet-kpi-card { + min-height: 150px; + padding: 22px; + display: grid; + align-content: space-between; + gap: 18px; +} + +.wallet-kpi-card.is-primary { + background: linear-gradient(135deg, #001b44, #009fe3); + color: #ffffff; +} + +.wallet-kpi-card span, +.wallet-kpi-card small { + color: #64748b; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.wallet-kpi-card.is-primary span, +.wallet-kpi-card.is-primary small { + color: rgba(255, 255, 255, 0.86); +} + +.wallet-kpi-card strong { + display: block; + color: #001b44; + font-family: "Plus Jakarta Sans", "Segoe UI", sans-serif; + font-size: clamp(1.5rem, 2.4vw, 2.15rem); + line-height: 1.1; +} + +.wallet-kpi-card.is-primary strong { + color: #ffffff; +} + +.wallet-layout { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(300px, 0.8fr); + gap: 24px; +} + +.wallet-panel { + padding: 24px; +} + +.wallet-panel h2, +.wallet-table-card h2, +.wallet-midtrans-card h2 { + margin: 0; + color: #001b44; + font-family: "Plus Jakarta Sans", "Segoe UI", sans-serif; + font-size: 20px; + line-height: 1.3; +} + +.wallet-topup-form { + margin-top: 22px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)) auto; + gap: 16px; + align-items: end; +} + +.wallet-topup-form label { + display: grid; + gap: 8px; +} + +.wallet-topup-presets { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.wallet-topup-presets button { + width: auto; + min-height: 40px; + padding: 0 16px; + border-radius: 999px; + border: 1px solid var(--border); + background: #ffffff; + color: var(--brand-ink); + font-size: 13px; + font-weight: 800; + box-shadow: none; +} + +.wallet-topup-presets button.is-active { + border-color: var(--brand-sky); + background: rgba(18, 166, 221, 0.12); + color: var(--brand-sky); +} + +.wallet-topup-form label span { + color: #52647a; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.wallet-topup-form input { + min-height: 48px; + border-radius: 14px; + border: 1px solid #cbd8e6; + background: #ffffff; + padding: 0 14px; +} + +.wallet-topup-form input:focus { + outline: none; + border-color: rgba(0, 159, 227, 0.5); + box-shadow: 0 0 0 3px rgba(0, 159, 227, 0.14); +} + +.wallet-topup-form button { + min-height: 48px; + border: 0; + border-radius: 14px; + background: linear-gradient(135deg, #001b44, #009fe3); + color: #ffffff; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 0 18px; + font-weight: 800; +} + +.wallet-topup-form .wallet-secondary-button { + background: #ffffff; + color: var(--brand-ink); + border: 1px solid var(--border); + box-shadow: none; +} + +.wallet-topup-form p { + grid-column: 1 / -1; + margin: 0; + color: #0078b7; + font-weight: 700; +} + +.wallet-midtrans-card .material-symbols-outlined { + width: 54px; + height: 54px; + display: grid; + place-items: center; + border-radius: 16px; + background: #e8f6ff; + color: #009fe3; + font-size: 28px; + margin-bottom: 20px; +} + +.wallet-table-card { + overflow: hidden; +} + +.wallet-table-card h2 { + padding: 22px 24px; + border-bottom: 1px solid #d7e3ee; +} + +.wallet-table-wrap { + overflow-x: auto; +} + +.wallet-table { + width: 100%; + border-collapse: collapse; + min-width: 760px; +} + +.wallet-table thead { + background: #f0f8ff; +} + +.wallet-table th, +.wallet-table td { + padding: 16px 24px; + border-bottom: 1px solid #e1e9f2; + text-align: left; +} + +.wallet-table th { + color: #52647a; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.wallet-table td strong, +.wallet-table td span { + display: block; +} + +.wallet-table td span { + margin-top: 4px; + color: #64748b; + font-size: 12px; +} + +.wallet-table td.is-credit { + color: #0078b7; + font-weight: 800; +} + +.wallet-table td.is-debit { + color: #b91c1c; + font-weight: 800; +} + +.wallet-empty { + text-align: center; + color: #64748b; +} + +@media (max-width: 1120px) { + .wallet-kpi-grid, + .wallet-layout, + .wallet-topup-form { + grid-template-columns: 1fr; + } +} + +.campaign-wallet-estimate { + display: flex; + align-items: center; + gap: 14px; + margin: 18px 0; + padding: 16px 18px; + border: 1px solid #d7e3ee; + border-radius: 16px; + background: #f0f8ff; + color: #001b44; +} + +.campaign-wallet-estimate .material-symbols-outlined { + width: 42px; + height: 42px; + display: grid; + place-items: center; + border-radius: 14px; + background: #e8f6ff; + color: #009fe3; +} + +.campaign-wallet-estimate strong, +.campaign-wallet-estimate p { + margin: 0; +} + +.campaign-wallet-estimate p { + margin-top: 4px; + color: #64748b; + font-size: 13px; +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 45b7297..ba1f317 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,24 +1,5 @@ -import Link from 'next/link'; -import { getDictionary } from '../lib/i18n'; +import { redirect } from 'next/navigation'; -export default async function HomePage() { - const dict = await getDictionary(); - - return ( -
-
-

{dict.common.appName}

-

{dict.home.title}

-

{dict.home.description}

-
- - {dict.login.submit} - - - {dict.common.openDashboard} - -
-
-
- ); +export default function HomePage() { + redirect('/login'); } diff --git a/frontend/src/components/audit-trail-board.tsx b/frontend/src/components/audit-trail-board.tsx index 7245f36..63242a7 100644 --- a/frontend/src/components/audit-trail-board.tsx +++ b/frontend/src/components/audit-trail-board.tsx @@ -3,40 +3,104 @@ import { useEffect, useMemo, useState } from 'react'; import { type AuditTrailEntry, seedAuditTrailEntries } from '../lib/audit-trail'; -function formatDate(value: string) { +const auditLabels = { + en: { + settings: 'Settings', + title: 'Audit Trail', + description: 'Monitor administrative actions, role changes, and system modifications from one place.', + exportExcel: 'Export to Excel', + exportCsv: 'Export Server CSV', + totalActions: 'Total Actions (Last 24H)', + securityAlerts: 'Security Alerts', + critical: 'Critical', + securityCopy: 'Failed login bursts and suspicious access activity are surfaced here.', + mostActiveAdmin: 'Most Active Admin', + actionsPerformed: 'actions performed', + filters: 'Filters', + dateRange: 'Date Range', + last24Hours: 'Last 24 Hours', + last7Days: 'Last 7 Days', + last30Days: 'Last 30 Days', + allTime: 'All Time', + adminUser: 'Admin User', + allAdmins: 'All Admins', + actionType: 'Action Type', + allActions: 'All Actions', + module: 'Module', + allModules: 'All Modules', + search: 'Search', + searchPlaceholder: 'Search logs, admin names...', + resetAll: 'Reset All', + timestamp: 'Timestamp', + ipAddress: 'IP Address', + actions: 'Actions', + viewDetails: 'View Details', + showing: 'Showing', + to: 'to', + of: 'of', + results: 'results', + show: 'Show', + rows: 'rows', + selectedEvent: 'Selected Event', + actor: 'Actor', + details: 'Details', + empty: 'No audit entries found.', + }, + id: { + settings: 'Pengaturan', + title: 'Audit Trail', + description: 'Pantau tindakan admin, perubahan peran, dan modifikasi sistem dari satu tempat.', + exportExcel: 'Ekspor ke Excel', + exportCsv: 'Ekspor CSV Server', + totalActions: 'Total Aksi (24 Jam)', + securityAlerts: 'Peringatan Keamanan', + critical: 'Kritis', + securityCopy: 'Aktivitas login gagal dan akses mencurigakan akan tampil di sini.', + mostActiveAdmin: 'Admin Paling Aktif', + actionsPerformed: 'aksi dilakukan', + filters: 'Filter', + dateRange: 'Rentang Tanggal', + last24Hours: '24 Jam Terakhir', + last7Days: '7 Hari Terakhir', + last30Days: '30 Hari Terakhir', + allTime: 'Semua Waktu', + adminUser: 'Admin', + allAdmins: 'Semua Admin', + actionType: 'Jenis Aksi', + allActions: 'Semua Aksi', + module: 'Modul', + allModules: 'Semua Modul', + search: 'Cari', + searchPlaceholder: 'Cari log atau nama admin...', + resetAll: 'Reset', + timestamp: 'Waktu', + ipAddress: 'Alamat IP', + actions: 'Aksi', + viewDetails: 'Lihat Detail', + showing: 'Menampilkan', + to: 'sampai', + of: 'dari', + results: 'hasil', + show: 'Tampilkan', + rows: 'baris', + selectedEvent: 'Event Terpilih', + actor: 'Aktor', + details: 'Detail', + empty: 'Tidak ada audit trail.', + }, +} as const; + +function formatDate(value: string, locale: 'en' | 'id') { const date = new Date(value); + const dateLocale = locale === 'id' ? 'id-ID' : 'en-US'; return { - day: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }), - time: date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }), + day: date.toLocaleDateString(dateLocale, { month: 'short', day: 'numeric', year: 'numeric' }), + time: date.toLocaleTimeString(dateLocale, { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }), }; } -function downloadCsv(rows: AuditTrailEntry[]) { - const header = ['Timestamp', 'Admin User', 'Action Type', 'Module', 'IP Address', 'Severity', 'Details']; - const csvRows = rows.map((row) => - [ - row.timestamp, - row.adminUser, - row.actionType, - row.module, - row.ipAddress, - row.severity, - row.details, - ] - .map((cell) => `"${String(cell).replaceAll('"', '""')}"`) - .join(','), - ); - - const blob = new Blob([[header.join(','), ...csvRows].join('\n')], { type: 'text/csv;charset=utf-8;' }); - const url = URL.createObjectURL(blob); - const anchor = document.createElement('a'); - anchor.href = url; - anchor.download = 'audit-trail-export.csv'; - anchor.click(); - URL.revokeObjectURL(url); -} - type Props = { + locale: 'en' | 'id'; initialEntries: AuditTrailEntry[]; initialTotal?: number; initialPage?: number; @@ -56,12 +120,14 @@ function buildVisiblePages(page: number, totalPages: number) { } export function AuditTrailBoard({ + locale, initialEntries, initialTotal, initialPage, initialPageSize, initialTotalPages, }: Props) { + const labels = auditLabels[locale]; const [allEntries] = useState(initialEntries.length > 0 ? initialEntries : seedAuditTrailEntries); const [entries, setEntries] = useState(initialEntries.length > 0 ? initialEntries : seedAuditTrailEntries); const [total, setTotal] = useState(initialTotal ?? initialEntries.length); @@ -182,38 +248,50 @@ export function AuditTrailBoard({ const visiblePages = useMemo(() => buildVisiblePages(page, totalPages), [page, totalPages]); const pageStart = entries.length === 0 ? 0 : (page - 1) * pageSize + 1; const pageEnd = entries.length === 0 ? 0 : (page - 1) * pageSize + entries.length; + const exportQuery = new URLSearchParams({ + ...(range !== 'all' ? { range } : {}), + ...(adminUser !== 'all' ? { user: adminUser } : {}), + ...(actionType !== 'all' ? { actionType } : {}), + ...(moduleName !== 'all' ? { module: moduleName } : {}), + ...(search.trim() ? { search: search.trim() } : {}), + }); return ( <>
-

Settings

-

Audit Trail

-

Monitor administrative actions, role changes, and system modifications from one place.

+

{labels.settings}

+

{labels.title}

+

{labels.description}

+
+ - - - download - Export Server CSV -
- Total Actions (Last 24H) + {labels.totalActions} history
@@ -227,26 +305,26 @@ export function AuditTrailBoard({
- Security Alerts + {labels.securityAlerts} security
{alertsCount.toString().padStart(2, '0')} - Critical + {labels.critical}
-

Failed login bursts and suspicious access activity are surfaced here.

+

{labels.securityCopy}

- Most Active Admin + {labels.mostActiveAdmin} person
{mostActiveAdmin.user.slice(0, 1)}
{mostActiveAdmin.user} - {mostActiveAdmin.count} actions performed + {mostActiveAdmin.count} {labels.actionsPerformed}
@@ -255,29 +333,29 @@ export function AuditTrailBoard({
filter_list - Filters + {labels.filters}
@@ -341,7 +419,7 @@ export function AuditTrailBoard({ setPage(1); }} > - Reset All + {labels.resetAll}
@@ -351,17 +429,17 @@ export function AuditTrailBoard({ - - - - - - + + + + + + {entries.map((entry) => { - const stamp = formatDate(entry.timestamp); + const stamp = formatDate(entry.timestamp, locale); const isSelected = entry.id === selectedEntry?.id; return ( {entry.ipAddress} @@ -402,10 +480,10 @@ export function AuditTrailBoard({
- Showing {pageStart} to {pageEnd} of {total} results + {labels.showing} {pageStart} {labels.to} {pageEnd} {labels.of} {total} {labels.results}
@@ -454,7 +532,7 @@ export function AuditTrailBoard({ <>
-

Selected Event

+

{labels.selectedEvent}

{selectedEntry.actionType}

@@ -464,24 +542,24 @@ export function AuditTrailBoard({
{selectedEntry.adminUser} - Actor + {labels.actor}
- {formatDate(selectedEntry.timestamp).day} {formatDate(selectedEntry.timestamp).time} - Timestamp + {formatDate(selectedEntry.timestamp, locale).day} {formatDate(selectedEntry.timestamp, locale).time} + {labels.timestamp}
{selectedEntry.ipAddress} - IP Address + {labels.ipAddress}
{selectedEntry.details} - Details + {labels.details}
) : ( -

No audit entries found.

+

{labels.empty}

)} diff --git a/frontend/src/components/campaign-detail-actions.tsx b/frontend/src/components/campaign-detail-actions.tsx index 53fca90..95ef89e 100644 --- a/frontend/src/components/campaign-detail-actions.tsx +++ b/frontend/src/components/campaign-detail-actions.tsx @@ -1,7 +1,7 @@ 'use client'; import { useRouter } from 'next/navigation'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; type Props = { campaign: { @@ -35,6 +35,12 @@ export function CampaignDetailActions({ campaign }: Props) { const [isScheduling, setIsScheduling] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const [message, setMessage] = useState(null); + const [costEstimate, setCostEstimate] = useState<{ + estimatedAmountMinor: number; + unitCostMinor: number; + recipientCount: number; + currency: string; + } | null>(null); const [isEditing, setIsEditing] = useState(false); const [scheduledAt, setScheduledAt] = useState(''); const [form, setForm] = useState({ @@ -50,6 +56,29 @@ export function CampaignDetailActions({ campaign }: Props) { bannerImageUrl: campaign.bannerImageUrl, }); + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const response = await fetch(`/api/campaigns/${campaign.id}/estimate-cost`, { + credentials: 'include', + cache: 'no-store', + }); + if (!response.ok) return; + const payload = await response.json(); + if (!cancelled) { + setCostEstimate(payload); + } + } catch { + // Estimate is helpful, but send still enforces wallet balance on the backend. + } + })(); + + return () => { + cancelled = true; + }; + }, [campaign.id]); + async function readPayload(response: Response) { const payload = await response.json().catch(() => ({})); if (!response.ok) { @@ -59,8 +88,29 @@ export function CampaignDetailActions({ campaign }: Props) { return payload; } + function formatMoney(value: number, currency = 'IDR') { + return new Intl.NumberFormat('id-ID', { + style: 'currency', + currency, + maximumFractionDigits: 0, + }).format(value); + } + return ( <> + {costEstimate ? ( +
+ account_balance_wallet +
+ Estimated wallet charge: {formatMoney(costEstimate.estimatedAmountMinor, costEstimate.currency)} +

+ {costEstimate.recipientCount.toLocaleString('id-ID')} recipients x{' '} + {formatMoney(costEstimate.unitCostMinor, costEstimate.currency)} per recipient. +

+
+
+ ) : null} +
@@ -175,8 +193,8 @@ export function CampaignsManagementBoard({ campaigns, metrics }: Props) {
event.stopPropagation()}>
-

Create Campaign

-

Launch a new WhatsApp broadcast

+

{labels.createCampaign}

+

{labels.launchBroadcast}

-
- -check_circle - Syntax Valid - - - {{1}} : Recipient Name - - - {{2}} : Ticket Topic - -
-
-
- -
-
-

Real-time Preview

- -
- -
-arrow_back -
-business -
-
-

Your Brand

-

Official Business Account

-
-
- -
- -
-
-

Hello Sarah Jenkins, thank you for choosing our services!

-

We've received your inquiry regarding Premium Support Plan. Our team will get back to you within 24 hours.

-

Best regards,
WhatsApp Admin Team

-
-10:45 AM -done_all -
-
-
-
- -
-
-sentiment_satisfied -
-attach_file -
-
-mic -
-
-
- -
-
-lightbulb -
-

Pro Tip

-

Using variables like {{1}} ensures personalized delivery which can increase engagement by up to 40%.

-
-
-
-
-
-
- -
-
-

Estimated Cost

-

$0.0084 (Standard Rate)

-
-
-

Estimated Reach

-

1 Recipient

-
-
-

Message Status

- - - Drafting - -
-
-

Character Count

-

184 / 4096

-
-
-
- - - - \ No newline at end of file diff --git a/stitch_bizone/draft_editor_whatsapp_business_admin/screen.png b/stitch_bizone/draft_editor_whatsapp_business_admin/screen.png deleted file mode 100644 index 7a8062b..0000000 Binary files a/stitch_bizone/draft_editor_whatsapp_business_admin/screen.png and /dev/null differ diff --git a/stitch_bizone/login_whatsapp_business_admin/screen.png b/stitch_bizone/login_whatsapp_business_admin/screen.png deleted file mode 100644 index 1e32da1..0000000 Binary files a/stitch_bizone/login_whatsapp_business_admin/screen.png and /dev/null differ diff --git a/stitch_bizone/messages_drafts_whatsapp_business_admin/code.html b/stitch_bizone/messages_drafts_whatsapp_business_admin/code.html deleted file mode 100644 index 0928471..0000000 --- a/stitch_bizone/messages_drafts_whatsapp_business_admin/code.html +++ /dev/null @@ -1,512 +0,0 @@ - - - - - -WhatsApp Business Admin Console - Message List - - - - - - - - - - - -
-
-
-search - -
-
-
-
-English -Bahasa -
-
- - -
-
-

Admin User

-

Super Admin

-
-Admin Profile Image -
-
-
-
- -
-
- -
-
-

Message Management

-

Monitor, track, and manage your WhatsApp business communications.

-
-
- - -
-
- -
-
-
-
-send -
-+12% vs last week -
-

Total Sent

-

24,592

-
-
-
-
-schedule -
-Next 24h -
-

Scheduled

-

1,208

-
-
-
-
-edit_note -
-Action required -
-

Drafts

-

45

-
-
- -
-
-
- - - - -
-
-
-
TimestampAdmin UserAction TypeModuleIP AddressActions{labels.timestamp}{labels.adminUser}{labels.actionType}{labels.module}{labels.ipAddress}{labels.actions}
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
RecipientMessage PreviewStatusTimestampActions
-
-
JS
-
-

John Smith

-

+62 812-3456-7890

-
-
-
-

Hello John! Just checking in on your recent order #8812. Let us know if you have any questions.

-
- - - Sent - - -

Oct 24, 2023

-

10:45 AM

-
- -
-
-
AM
-
-

Alice Miller

-

+62 819-0012-3344

-
-
-
-

Reminder: Your appointment is scheduled for tomorrow at 2:00 PM. Reply 1 to confirm.

-
- - - Scheduled - - -

Oct 26, 2023

-

09:00 AM

-
- -
-
-
RK
-
-

Robert King

-

+62 857-7788-9900

-
-
-
-

[No message content yet]

-
- - - Draft - - -

Oct 23, 2023

-

04:12 PM

-
- -
-
-
EW
-
-

Elena White

-

+62 811-2222-3333

-
-
-
-

Thank you for your feedback! Your 20% discount code is WELCOME20. Enjoy!

-
- - - Sent - - -

Oct 22, 2023

-

01:30 PM

-
- -
-
- -
-

Showing 1 to 10 of 2,492 messages

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

Quick Draft

-
-
- - -
-
- - -
-
- - -
-
-
- - -
-
- - -
-
-
-
-
- -
-
-
-
- -
-arrow_back -
-Recipient Avatar -
-
-

Customer Service

-

online

-
-
-videocam -call -more_vert -
-
- -
-
- Hello! How can I help you today? - 10:42 AM -
-
-

Just checking in on your recent order #8812. Let us know if you have any questions.

-
-10:45 AM -done_all -
-
-
- -
-
-insert_emoticon -Type a message -attach_file -photo_camera -
-
-mic -
-
-
-
-
-
- - - \ No newline at end of file diff --git a/stitch_bizone/messages_drafts_whatsapp_business_admin/screen.png b/stitch_bizone/messages_drafts_whatsapp_business_admin/screen.png deleted file mode 100644 index 18c0491..0000000 Binary files a/stitch_bizone/messages_drafts_whatsapp_business_admin/screen.png and /dev/null differ diff --git a/stitch_bizone/roles_permissions_whatsapp_business_admin/code.html b/stitch_bizone/roles_permissions_whatsapp_business_admin/code.html deleted file mode 100644 index 347ded5..0000000 --- a/stitch_bizone/roles_permissions_whatsapp_business_admin/code.html +++ /dev/null @@ -1,481 +0,0 @@ - - - - - -Roles & Permissions | WhatsApp Business Admin - - - - - - - - - - - -
- -
-
-
-search - -
- -
-
-
- - -
-
-
-
-

Admin User

-

Global Admin

-
-Admin Avatar -
-
-
- -
-
- -
-
-

Roles & Permissions

-

Configure access levels and granular permissions for your team members.

-
- -
- -
- -
-
-
-shield_person -
-Active -
-

Admin

-

Full access to all modules, including system settings and billing.

-
-group - 2 users assigned -
-
- -
-
-
-edit_document -
-Standard -
-

Editor

-

Can create templates and manage campaigns, but cannot change settings.

-
-group - 5 users assigned -
-
- -
-
-
-support_agent -
-Editing Now -
-

Agent

-

Limited access to view analytics and respond to customer conversations.

-
-group - 14 users assigned -
-
-
- -
-
-

Permission Matrix: Agent

-
- - -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Module / PermissionViewCreate/EditDeleteManage All
-
-campaign -Manage Campaigns -
-
- - - - - - - -
-
-monitoring -View Analytics -
-
- - - -
-
-settings -Edit Settings -
-
- - - - - - - -
-
-payments -Billing & Invoices -
-
- - - -
-
-
- -
-
-
-

Audit Log: Recent Changes

-View All -
-
-
-
-
-person_edit -
-
-

Admin updated 'Agent' permissions

-

Changed 'View Analytics' from OFF to ON

-
-
-2 mins ago -
-
-
-
-add_circle -
-
-

Created new role: 'Reporting Only'

-

Assigned to 0 users

-
-
-4 hours ago -
-
-
-
-
-

Need help?

-

Learn more about how to set up granular access for high-security enterprise environments.

- -
-lock_person -
-
-
-
-
- \ No newline at end of file diff --git a/stitch_bizone/roles_permissions_whatsapp_business_admin/screen.png b/stitch_bizone/roles_permissions_whatsapp_business_admin/screen.png deleted file mode 100644 index 391e256..0000000 Binary files a/stitch_bizone/roles_permissions_whatsapp_business_admin/screen.png and /dev/null differ diff --git a/stitch_bizone/system_logs_queue_whatsapp_business_admin/code.html b/stitch_bizone/system_logs_queue_whatsapp_business_admin/code.html deleted file mode 100644 index c8ea22d..0000000 --- a/stitch_bizone/system_logs_queue_whatsapp_business_admin/code.html +++ /dev/null @@ -1,509 +0,0 @@ - - - - - -WhatsApp Business Admin Console - Activity Logs - - - - - - - - - - - -
-
-

Admin Dashboard

-
-
-search - -
-
-
-
-English -Bahasa -
-
- - -Admin Profile Image -
-
-
- -
-
- -
-
-

Activity Logs & Queue Monitor

-

Real-time surveillance of system processes, background jobs, and administrative actions.

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

Queue Monitor

- - LIVE - -
-
- -
-
-

PENDING JOBS

-

1,284

-
-schedule -
- -
-
-

PROCESSING

-

42

-
-autorenew -
- -
-
-

FAILED (24H)

-

7

-
-error_outline -
-
-
-
Worker Health
-
-
-node-worker-01 -98% Load -
-
-
-
-
-node-worker-02 -12% Load -
-
-
-
-
-
-
- -
-
-bolt -
-

Queue Throughput

-

System is currently processing 840 messages per minute. Optimization recommended for peak hours.

-
-
-
-
- -
-
-
-

Technical Activity Logs

-
-Total: 45,290 events -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
TimestampActionUser / ServiceStatusPayload
2023-10-27 14:22:01 -
-BROADCAST_START -Marketing Campaign v2 -
-
-
-
AD
-admin_jane -
-
-Success - - -
2023-10-27 14:21:55 -
-WEBHOOK_RETRY -Endpoint: /api/v1/update -
-
-
-robot -QueueWorker_02 -
-
-Pending - - -
2023-10-27 14:21:48 -
-AUTH_FAILURE -Invalid Token Attempt -
-
-
-public -IP: 192.168.1.1 -
-
-Rejected - - -
2023-10-27 14:20:12 -
-TEMPLATE_CREATE -New template: Welcome_V2 -
-
-
-
MK
-mike_dev -
-
-Approved - - -
2023-10-27 14:19:44 -
-EXPORT_COMPLETE -User_Database_Daily.csv -
-
-
-settings_suggest -SystemScheduler -
-
-Success - - -
-
-
-Showing 5 of 45,290 logs -
- - -
-
-
-
-
- -
-
-
-
-terminal -
-
-

Live Tail Mode

-

Streaming real-time logs directly from the message processing engine. Use this for debugging active broadcast campaigns and webhook handshake failures.

-
-
- -
-
-
-
- -
-
-
-API LATENCY -bar_chart -
-
-124ms -↓ 12% -
-
-
-
-
-
-
-
-
-
-
-
-
-DB CONNECTIONS -database -
-
-84 -Active -
-
-
-
-
-
-
-
-
-MEMORY USAGE -memory -
-
-4.2GB -of 16GB -
-
-
-
-
-
-
-
-
-
-
-
-
-
- - - \ No newline at end of file diff --git a/stitch_bizone/system_logs_queue_whatsapp_business_admin/screen.png b/stitch_bizone/system_logs_queue_whatsapp_business_admin/screen.png deleted file mode 100644 index f85d287..0000000 Binary files a/stitch_bizone/system_logs_queue_whatsapp_business_admin/screen.png and /dev/null differ diff --git a/stitch_bizone/template_builder_whatsapp_business_admin/code.html b/stitch_bizone/template_builder_whatsapp_business_admin/code.html deleted file mode 100644 index fccb0bd..0000000 --- a/stitch_bizone/template_builder_whatsapp_business_admin/code.html +++ /dev/null @@ -1,405 +0,0 @@ - - - - - -WhatsApp Business - Template Builder - - - - - - - - - - - -
-
-

Admin Dashboard

-
-
-English -Bahasa -
-
-
- -
- - -
-Admin Profile Image -
-
-
-
- -
-
- -
-
-

Create Message Template

-

Design and submit your business messages for WhatsApp approval.

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

-edit_note - Basic Details -

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

-subject - Message Content -

-
- - -
-
-
-
- - -
-
- - -
-Variables detected: 2 - -
-
-
- - -
-
-
-
-

-smart_button - Buttons -

-
-
-ads_click -
-

Quick Reply: Opt-out

-

Label: Stop promotions

-
- -
- -
-
-
- -
-
-
- -
-9:41 -
-signal_cellular_4_bar -wifi -battery_full -
-
- -
- -
-arrow_back -
-Business Profile -
-
-

Your Business

-

online

-
-videocam -call -more_vert -
- -
- -
TODAY
- -
-
- -

- Hi [Alex], our Summer Sale is finally here! 🌴

- Get up to 50% OFF on all collections using code [SUMMER50] at checkout.

- Shop now: https://example.com/shop -

- -
-09:41 AM -done_all -
-
- - -
-
- -
-
-mood -Message -attach_file -photo_camera -
-
-mic -
-
-
-
- -
- - -
-
-
-
-
-
- - - \ No newline at end of file diff --git a/stitch_bizone/template_builder_whatsapp_business_admin/screen.png b/stitch_bizone/template_builder_whatsapp_business_admin/screen.png deleted file mode 100644 index 76ac7e5..0000000 Binary files a/stitch_bizone/template_builder_whatsapp_business_admin/screen.png and /dev/null differ diff --git a/stitch_bizone/template_list_whatsapp_business_admin/code.html b/stitch_bizone/template_list_whatsapp_business_admin/code.html deleted file mode 100644 index 5d64b07..0000000 --- a/stitch_bizone/template_list_whatsapp_business_admin/code.html +++ /dev/null @@ -1,392 +0,0 @@ - - - - - -WhatsApp Business Admin - Template List - - - - - - - - - - - -
- -
-
-

Admin Dashboard

-
-
-English -Bahasa -
-
-
-
-search - -
-
- - -Admin Profile Image -
-
-
- -
- -
-
-

Message Templates

-

Create and manage your WhatsApp message templates. All templates must be approved by WhatsApp before sending.

-
- -
- -
-
-Category: All -expand_more -
-
-Status: Approved -expand_more -
-
-Language: All -expand_more -
-
- Showing 12 Templates -
-
- -
- -
-
-
Approved
- -
-

order_confirmation_v2

-

UTILITY

-
-

"Hi {{1}}, thank you for your order #{{2}}! We've received your payment and will notify you when it ships..."

-
-
-
-
-schedule - Updated 2h ago -
-edit -
-
- -
-
-
Pending
- -
-

holiday_sale_promo

-

MARKETING

-
-

"🎉 Exclusive Holiday Sale! Get 30% OFF on all items using code FESTIVE30. Shop now at {{1}}..."

-
-
-
-
-schedule - Updated 1d ago -
-edit -
-
- -
-
-
Rejected
- -
-

account_recovery_otp

-

AUTHENTICATION

-
-

"Your recovery code is {{1}}. Do not share this with anyone. This code expires in 5 minutes."

-
-
-
-
-warning - Needs Review -
-edit -
-
- -
-
-mail -
-
-

shipping_update_express

-
-UTILITY - -"Good news! Your package is out for delivery..." -
-
- -
-
Approved
-
-
- - -
-
- -
-
-
Approved
- -
-

welcome_onboarding_v4

-

MARKETING

-
-

"Welcome to the community {{1}}! We're thrilled to have you here. To get started, please check out..."

-
-
-
-
-schedule - Updated 3d ago -
-edit -
-
- -
-
-
Approved
- -
-

appointment_remind_6h

-

UTILITY

-
-

"Reminder: Your appointment at {{1}} starts in 6 hours. If you need to reschedule, please call..."

-
-
-
-
-schedule - Updated 1w ago -
-edit -
-
-
- -
-
-contact_support -
-
-
Need help with Template Guidelines?
-

WhatsApp has strict policies on message content. Ensure your templates follow the Business Policy to avoid rejection and maintain a high quality rating.

-
-
- -
-
-
-
- \ No newline at end of file diff --git a/stitch_bizone/template_list_whatsapp_business_admin/screen.png b/stitch_bizone/template_list_whatsapp_business_admin/screen.png deleted file mode 100644 index d5850b2..0000000 Binary files a/stitch_bizone/template_list_whatsapp_business_admin/screen.png and /dev/null differ diff --git a/stitch_bizone/user_management_bizone_standard_layout/code.html b/stitch_bizone/user_management_bizone_standard_layout/code.html new file mode 100644 index 0000000..3fdb40e --- /dev/null +++ b/stitch_bizone/user_management_bizone_standard_layout/code.html @@ -0,0 +1,342 @@ + + + + + +User Management - BizOne Admin + + + + + + + + + + +
+
+Admin Dashboard +
+
+search + +
+
+
+
+ + +
+
+ + +Admin Profile +
+
+
+ +
+
+ +
+
+

User Management

+

Manage team access levels, roles, and security permissions.

+
+ +
+ +
+
+
+

Total Users

+

42

+

+trending_up + +3 this month +

+
+
+group +
+
+
+
+

Pending Invites

+

07

+

+schedule + Awaiting response +

+
+
+mail +
+
+
+
+

Active Sessions

+

18

+

+ Real-time activity +

+
+
+bolt +
+
+
+
+ +
+
+

Team Members

+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Name / EmailRoleLast ActiveStatusActions
+
+
JD
+
+

Jane Doe

+

jane.doe@company.com

+
+
+
+Admin +Just now +
+ +Active +
+
+
+ + +
+
+
+User +
+

Alex Miller

+

alex.m@company.com

+
+
+
+Editor +2 hours ago +
+ +Active +
+
+
+ + +
+
+
+ +
+

Showing 1 to 2 of 42 members

+
+ + + + +
+
+
+ +
+
+
Role Permissions
+

Quick overview of what each team member can access across the BizOne platform.

+
+
+
+
+verified_user +Admin +
+

Full system access, including billing, user management, and API settings.

+
+
+
+edit_note +Editor +
+

Manage broadcasts and templates, but cannot change system settings.

+
+
+
+support_agent +Agent +
+

Limited to managing chat conversations and contact lists only.

+
+
+
+
+
+ \ No newline at end of file diff --git a/stitch_bizone/user_management_bizone_standard_layout/screen.png b/stitch_bizone/user_management_bizone_standard_layout/screen.png new file mode 100644 index 0000000..785b314 Binary files /dev/null and b/stitch_bizone/user_management_bizone_standard_layout/screen.png differ diff --git a/stitch_bizone/users_roles_whatsapp_business_admin/code.html b/stitch_bizone/users_roles_whatsapp_business_admin/code.html deleted file mode 100644 index f5a82b9..0000000 --- a/stitch_bizone/users_roles_whatsapp_business_admin/code.html +++ /dev/null @@ -1,464 +0,0 @@ - - - - - -User Management - WhatsApp Business Admin - - - - - - - - - - - -
-
-Admin Dashboard -
-
-search - -
-
-
-
- - -
-
- - -Admin Profile Image -
-
-
- -
-
- -
-
-

User Management

-

Manage team access levels, roles, and security permissions.

-
- -
- -
-
-
-

TOTAL USERS

-

42

-

-trending_up - +3 this month -

-
-
-group -
-
-
-
-

PENDING INVITES

-

07

-

-schedule - Awaiting response -

-
-
-mail -
-
-
-
-

ACTIVE SESSIONS

-

18

-

- Current real-time activity -

-
-
-bolt -
- -
-
-
- -
-
-

Team Members

-
- - -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NAME / EMAILROLELAST ACTIVESTATUSACTIONS
-
-
JD
-
-

Jane Doe

-

jane.doe@company.com

-
-
-
-ADMIN -Just now -
- -Active -
-
-
- - -
-
-
-User -
-

Alex Miller

-

alex.m@company.com

-
-
-
-EDITOR -2 hours ago -
- -Active -
-
-
- - -
-
-
-
SK
-
-

Sarah Khan

-

s.khan@company.com

-
-
-
-AGENT -Pending invite -
- -Invited -
-
-
- - -
-
-
-
RW
-
-

Robert Wong

-

robert.wong@company.com

-
-
-
-AGENT -3 days ago -
- -Suspended -
-
-
- - -
-
-
- -
-

Showing 1 to 4 of 42 team members

-
- - - - - -
-
-
- -
-
-
Role Permissions
-

Quick overview of what each team member can access across the WhatsApp Business platform.

-
-
-
-
-verified_user -ADMIN -
-

Full system access, including billing, user management, and API settings.

-
-
-
-edit_note -EDITOR -
-

Manage broadcasts and message templates, but cannot change system settings.

-
-
-
-support_agent -AGENT -
-

Limited to managing active chat conversations and contact lists only.

-
-
-
-
-
- \ No newline at end of file diff --git a/stitch_bizone/users_roles_whatsapp_business_admin/screen.png b/stitch_bizone/users_roles_whatsapp_business_admin/screen.png deleted file mode 100644 index e65caff..0000000 Binary files a/stitch_bizone/users_roles_whatsapp_business_admin/screen.png and /dev/null differ diff --git a/stitch_bizone/webhook_logs_whatsapp_business_admin/code.html b/stitch_bizone/webhook_logs_whatsapp_business_admin/code.html deleted file mode 100644 index 8c359bd..0000000 --- a/stitch_bizone/webhook_logs_whatsapp_business_admin/code.html +++ /dev/null @@ -1,421 +0,0 @@ - - - - - -Webhook Logs & Activity Monitor - - - - - - - - - - - -
-
-

Admin Dashboard

-
-search - -
-
-
- - -
-Admin Avatar -
-
-
- -
- -
-
-

Webhook Logs

-

Real-time monitoring of incoming and outgoing events.

-
-
- - -
-
- -
- -
-
-SUCCESS RATE -check_circle -
-
-99.8% - -arrow_upward - 0.2% - -
-
-
-
-
- -
-
-AVG. RESPONSE TIME -speed -
-
-142ms - -arrow_downward - 12ms - -
-
-
-
-
- -
-
-TOTAL EVENTS (24H) -history -
-
-1,248,302 -
-

Peak load: 4.2k req/sec

-
-
- -
- -
-
-Activity Monitor -
- -LIVE UPDATING -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
TIMESTAMPEVENT TYPESTATUSLATENCY
2023-11-24 14:02:45.102 -message.sent - -200 OK -128ms -chevron_right -
2023-11-24 14:02:44.850 -message.delivered - -200 OK -92ms -chevron_right -
2023-11-24 14:02:42.311 -message.read - -400 Error -215ms -chevron_right -
2023-11-24 14:02:40.004 -message.sent - -200 OK -154ms -chevron_right -
2023-11-24 14:02:38.992 -message.sent - -200 OK -133ms -chevron_right -
2023-11-24 14:02:37.450 -message.delivered - -200 OK -110ms -chevron_right -
-
-
-Showing 1-50 of 1,248,302 logs -
- - -
-
-
- -
-
-
-

Payload Viewer

-

Event: 562ea1...3b21

-
-
- - -
-
-
-
{
-  "id": "ev_123456789",
-  "object": "event",
-  "api_version": "2023-11",
-  "created": 1700834565,
-  "data": {
-    "object": {
-      "id": "msg_abc123",
-      "to": "+1234567890",
-      "status": "sent",
-      "timestamp": "1700834564",
-      "context": {
-        "from": "+1098765432",
-        "id": "whatsapp_biz_001"
-      }
-    }
-  },
-  "type": "message.sent",
-  "request": {
-    "id": "req_987654321",
-    "idempotency_key": "550e8400-e29b-41d4-a716-446655440000"
-  }
-}
-
-
-
-RESPONSE PAYLOAD -200 OK -
-
- { "status": "success", "received": true } -
-
-
-
-
- \ No newline at end of file diff --git a/stitch_bizone/webhook_logs_whatsapp_business_admin/screen.png b/stitch_bizone/webhook_logs_whatsapp_business_admin/screen.png deleted file mode 100644 index 3019108..0000000 Binary files a/stitch_bizone/webhook_logs_whatsapp_business_admin/screen.png and /dev/null differ diff --git a/stitch_bizone/webhook_settings_bizone_standard_layout/code.html b/stitch_bizone/webhook_settings_bizone_standard_layout/code.html new file mode 100644 index 0000000..ba26f1c --- /dev/null +++ b/stitch_bizone/webhook_settings_bizone_standard_layout/code.html @@ -0,0 +1,403 @@ + + + + + +Webhook Settings - BizOne Admin + + + + + + + + + +
+ +
+
+

WhatsApp Business Admin

+
+
+
+notifications + +
+help +
+Admin Avatar + +expand_more +
+
+
+ +
+
+

Webhook Settings

+

Configure your endpoint URLs and subscribe to specific events.

+
+
+ +
+ +
+
+link +

Endpoint Configuration

+
+
+
+ +
+ +
+

All event notifications will be sent to this HTTPS endpoint.

+
+
+
+ + +
+
+ +
+ + +
+
+
+
+ + +
+
+
+ +
+
+notifications_active +

Event Subscriptions

+
+
+ +
+
+messages +

Get notified when a customer sends a text, image, or media message.

+
+ +
+ +
+
+message_deliveries +

Real-time status updates when your messages are delivered to recipients.

+
+ +
+ +
+
+message_read +

Confirmation when a customer has read the message sent from your business.

+
+ +
+ +
+
+account_update +

Alerts about changes to your WABA account, health, or policy status.

+
+ +
+ +
+
+template_category_update +

Receive updates when template categories are changed by Meta reviewers.

+
+ +
+
+
+
+ +
+
+
+

Health & Performance

+ + + Active + +
+
+
+

Average Latency

+
+142ms + +trending_down + 12% + +
+
+
+

Success Rate

+
+99.8% +Stable +
+
+
+

Peak Events (1h)

+
+1.2k +ev/min +
+
+
+
+
+

Recent Deliveries

+View Logs +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TimeEventStatus
14:02:11messages +200 OK +
14:01:58delivery +200 OK +
13:58:44read +200 OK +
13:55:02messages +200 OK +
+
+
+
+
+warning +
+

Retries Enabled

+

System will retry failed deliveries up to 5 times.

+
+
+
+
+ + +
+
+
+
+ \ No newline at end of file diff --git a/stitch_bizone/webhook_settings_bizone_standard_layout/screen.png b/stitch_bizone/webhook_settings_bizone_standard_layout/screen.png new file mode 100644 index 0000000..2c7eeee Binary files /dev/null and b/stitch_bizone/webhook_settings_bizone_standard_layout/screen.png differ