Compare commits

..

32 Commits

Author SHA1 Message Date
arifal 88353d5d06 install 2026-05-22 15:38:08 +07:00
arifal 0e7bb07772 0.16.12 2026-05-22 15:17:59 +07:00
arifal 3268a6284e update lib 2026-05-22 15:17:40 +07:00
arifal 73406d0b86 0.16.11 2026-05-22 14:53:07 +07:00
arifal 4c449a572a fix upload image trip 2026-05-22 14:52:22 +07:00
arifal 9022f983a2 0.16.10 2026-05-21 15:31:33 +07:00
arifal 6b8f9dec5d fix warning class style 2026-05-21 15:30:53 +07:00
arifal e6a032e8e0 0.16.9 2026-05-21 12:20:49 +07:00
arifal 81a0c2c6c8 fix oauth google sign 2026-05-21 12:20:28 +07:00
arifal 03887fb1cd 0.16.8 2026-05-21 11:59:32 +07:00
arifal f84d0e3726 fix ui style 2026-05-21 11:59:02 +07:00
arifal 22e66ce493 0.16.7 2026-05-20 16:53:15 +07:00
arifal d4e5d6be38 seeder 2026-05-20 16:52:54 +07:00
arifal b4d39d86ae 0.16.6 2026-05-20 16:08:52 +07:00
arifal ef7aa528d4 add loading and optimize query using cache and pwa 2026-05-20 16:08:29 +07:00
arifal 5d095151e4 0.16.5 2026-05-20 15:26:45 +07:00
arifal db71159613 0.16.4 2026-05-20 15:25:48 +07:00
arifal cb03967deb fix email sender all flow 2026-05-20 15:25:32 +07:00
arifal 306396ae43 0.16.3 2026-05-20 13:34:03 +07:00
arifal b836d08b10 fix date picker on all filter and field using date 2026-05-20 13:33:29 +07:00
arifal 57f7764bf5 0.16.2 2026-05-20 13:16:50 +07:00
arifal da217c2946 fix race condition issue 2026-05-20 13:16:25 +07:00
arifal 43ea725107 0.16.1 2026-05-18 20:55:26 +07:00
arifal 1200bf85c2 cron setup 2026-05-18 20:54:59 +07:00
arifal d5842b984b 0.16.0 2026-05-18 20:47:31 +07:00
arifal bf5c97c442 email service and template using resend 2026-05-18 20:47:05 +07:00
arifal f0ce22bbb8 0.15.0 2026-05-18 20:25:54 +07:00
arifal bc4973a594 admin roadmap done, reupload request, submission history, manual override 2026-05-18 20:25:21 +07:00
arifal b844ebdfac 0.14.0 2026-05-18 20:09:51 +07:00
arifal ea63f56e97 admin roadmap csv export, adminactionlog, global search 2026-05-18 20:09:22 +07:00
arifal 244a6da9bb 0.13.0 2026-05-18 19:46:02 +07:00
arifal 6e02f2f0d7 admin roadmap filter & search, user management, reopen rejected, system health 2026-05-18 19:45:14 +07:00
140 changed files with 10935 additions and 2791 deletions
+2 -1
View File
@@ -10,7 +10,8 @@
"PowerShell(npx prisma generate 2>&1)", "PowerShell(npx prisma generate 2>&1)",
"PowerShell(npx tsc --noEmit 2>&1)", "PowerShell(npx tsc --noEmit 2>&1)",
"PowerShell(npx eslint server/services/refund.service.ts server/repositories/refund.repo.ts features/refund app/admin/refunds 2>&1)", "PowerShell(npx eslint server/services/refund.service.ts server/repositories/refund.repo.ts features/refund app/admin/refunds 2>&1)",
"PowerShell(npx eslint server lib features app 2>&1)" "PowerShell(npx eslint server lib features app 2>&1)",
"Bash(npx eslint *)"
] ]
} }
} }
+24
View File
@@ -12,6 +12,9 @@ KYC_ENCRYPTION_KEY=
KYC_NIK_PEPPER= KYC_NIK_PEPPER=
# Absolute path for private KYC uploads (default: <cwd>/uploads/private) # Absolute path for private KYC uploads (default: <cwd>/uploads/private)
KYC_UPLOAD_DIR= KYC_UPLOAD_DIR=
# Absolute path for public trip image uploads (default: <cwd>/uploads/trips)
# Pakai volume persisten — file di sini harus selamat saat redeploy/restart.
TRIP_UPLOAD_DIR=
GOOGLE_CLIENT_ID="xxxxxxxx" GOOGLE_CLIENT_ID="xxxxxxxx"
GOOGLE_CLIENT_SECRET="xxxxxxxx" GOOGLE_CLIENT_SECRET="xxxxxxxx"
@@ -36,3 +39,24 @@ NEXT_PUBLIC_MIDTRANS_IS_PRODUCTION=false
# openssl rand -hex 32 # openssl rand -hex 32
# Setup detail: lihat docs/CRON_SETUP.md # Setup detail: lihat docs/CRON_SETUP.md
CRON_SECRET= CRON_SECRET=
# === Admin alerting (opsional) ===
# Discord webhook URL untuk push notif saat cron FAILED. Tanpa env, `notifyAdmins`
# no-op — admin tetap bisa cek manual di /admin/system. Cara setup:
# 1. Discord channel internal → Edit Channel → Integrations → Webhooks → New
# 2. Copy "Webhook URL", paste di sini
# Format: https://discord.com/api/webhooks/<id>/<token>
ADMIN_ALERT_WEBHOOK_URL=
# === Email notifications (Resend) ===
# API key Resend untuk kirim email transaksional (KYC, refund, payment, suspend).
# Tanpa env, sync send di-skip dan semua email di-queue di DB (status PENDING).
# Setelah env di-set, cron `/api/cron/process-email-jobs` akan drain queue.
# Sign up: https://resend.com → API Keys
RESEND_API_KEY=
# Email sender — format RFC 5322 "Display Name <email@domain>".
# Domain harus diverifikasi di Resend dashboard (SPF + DKIM).
# Default `onboarding@resend.dev` cocok untuk dev/testing.
EMAIL_FROM="SeTrip <onboarding@resend.dev>"
+2 -1
View File
@@ -36,7 +36,8 @@ yarn-error.log*
.env.development .env.development
.env.local .env.local
# private uploads (KYC: KTP / liveness). Never serve directly. # runtime uploads KYC (encrypted, private) & trip images (public, served via
# /api/trip-images). User data, not source: keep out of git, back up separately.
/uploads/ /uploads/
# vercel # vercel
-109
View File
@@ -1,109 +0,0 @@
# Setrip — Admin Audit & Investigation Roadmap
Admin perlu **mencari** lintas entity (booking/payment/refund/user/trip) dan **export** untuk compliance + investigasi dispute.
> **Skenario nyata:** auditor bertanya "tunjukkan semua refund yang di-approve admin X di bulan Juni 2026 dengan total lebih dari Rp 5 juta". Saat ini admin harus query DB manual atau ambil screenshot satu-satu. Tidak ada cara cari berdasarkan kombinasi reviewer + tanggal + nominal.
---
## Baseline
- ✅ Data audit sudah ada di schema: `Refund.reviewedBy/reviewedAt/adminNote`, `Payout.processedBy/processedAt/adminNote`, `OrganizerVerification.reviewedBy/reviewedAt/rejectionReason`.
- ✅ Existing list pages (`/admin/refunds`, `/admin/payouts`, `/admin/verifications`) sudah grouping by status tab.
- ❌ Tidak ada filter date range / reviewer / amount / reason.
- ❌ Tidak ada kolom "reviewer email" di list — harus klik detail.
- ❌ Tidak ada global search (cari berdasarkan email user, order id, trip id).
- ❌ Tidak ada CSV export.
- ❌ Tidak ada audit log untuk action admin di entity lain (User suspension, Trip force-cancel, Verification reopen).
---
## Phase 1 — Filter & Search Enhancements ⏳
Sebelum bikin audit log baru, perbaiki dulu kemampuan cari & filter di list yang sudah ada.
**Keputusan asumsi:**
- Pakai `searchParams` di Next.js — tidak perlu state client (server-render fast + shareable URL).
- Default date range: 30 hari terakhir, supaya page tidak load semua history.
- Reviewer dropdown sumber dari `ADMIN_EMAILS` env (sudah ada).
| # | Item | Status | File |
|---|---|---|---|
| 1.1 | Filter date range (`from`, `to`) di `/admin/refunds` | ⏳ | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) |
| 1.2 | Filter `reviewedBy` (admin email dropdown) di `/admin/refunds` | ⏳ | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) |
| 1.3 | Filter `reason` di `/admin/refunds` (lihat juga [ADMIN_PAYMENT_OPS_ROADMAP.md](ADMIN_PAYMENT_OPS_ROADMAP.md)) | ⏳ | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) |
| 1.4 | Filter date range + `processedBy` di `/admin/payouts` | ⏳ | [app/admin/payouts/page.tsx](app/admin/payouts/page.tsx) |
| 1.5 | Filter date range + `reviewedBy` di `/admin/verifications` | ⏳ | [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx) |
| 1.6 | Tampilkan kolom "reviewer email" + "reviewed at" di tabel/list (semua admin pages) | ⏳ | semua `app/admin/*/page.tsx` |
| 1.7 | Repo helper: tambah optional filter params di `refundRepo.listByStatus`, `payoutRepo.listByStatus`, `organizerRepo.listByStatus` | ⏳ | `server/repositories/*.ts` |
**Tindakan manual:** tidak ada.
---
## Phase 2 — Global Search ⏳
Satu search box yang resolve ke entity detail page paling relevan.
**Keputusan asumsi:**
- Input string user, prefix-based dispatch:
- Email format (`@`) → user search → redirect ke `/admin/users/[id]`
- Mulai `midtrans-` / `manual-` → payment lookup by `externalOrderId``/admin/bookings/[bookingId]`
- Mulai `cm` (cuid pattern) + length 25 → coba lookup berurutan: trip → booking → user
- Else: full-text search di trip title/destination
- Pakai server action atau route handler `/api/admin/search` — return list hasil + jenis entity.
- UI: searchbar di admin layout (top-right) yang dropdown hasil.
| # | Item | Status | File |
|---|---|---|---|
| 2.1 | `adminSearchService.resolve(query)` — dispatch ke repo lookup yang tepat | ⏳ | `server/services/admin-search.service.ts` |
| 2.2 | Route handler `/api/admin/search?q=...` (GET, guard isAdmin) | ⏳ | `app/api/admin/search/route.ts` |
| 2.3 | Component `AdminSearchBar` di admin layout — debounced, dropdown hasil | ⏳ | `features/admin/components/admin-search-bar.tsx` |
| 2.4 | Page `/admin/search?q=...` untuk full results kalau dropdown limit terlampaui | ⏳ | `app/admin/search/page.tsx` |
**Tindakan manual:** tidak ada.
---
## Phase 3 — CSV Export ⏳
Export untuk laporan keuangan & compliance.
**Keputusan asumsi:**
- Stream CSV via route handler — jangan load semua ke memory.
- Pakai filter yang sama dengan list page — admin pakai URL filter lalu klik "Export".
- Header CSV: human-readable bahasa Indonesia (mis. "Tanggal Approve", "Email Peserta", "Nominal Refund").
- Tidak ada Excel/xlsx — CSV cukup, mudah dibuka di Sheets/Excel.
| # | Item | Status | File |
|---|---|---|---|
| 3.1 | Helper `lib/csv.ts``streamCsv(headers, rows)` return Response | ⏳ | `lib/csv.ts` |
| 3.2 | Route `/api/admin/export/refunds` — pakai filter dari query string | ⏳ | `app/api/admin/export/refunds/route.ts` |
| 3.3 | Route `/api/admin/export/payouts` | ⏳ | `app/api/admin/export/payouts/route.ts` |
| 3.4 | Route `/api/admin/export/verifications` (tanpa NIK / KTP — hanya metadata) | ⏳ | `app/api/admin/export/verifications/route.ts` |
| 3.5 | Tombol "Export CSV" di tiap admin list page | ⏳ | semua `app/admin/*/page.tsx` |
**Tindakan manual:**
1. Test export di staging — pastikan tidak leak data sensitif (NIK harus tetap encrypted/excluded).
2. Update kebijakan privasi: data export hanya untuk internal compliance.
---
## Phase 4 — Generic Admin Audit Log ⏳
Tabel `AdminActionLog` untuk action di entity yang belum punya audit field (User suspend, Trip force-cancel, Verification reopen, dst).
**Keputusan asumsi:**
- Single tabel polymorphic: `AdminActionLog { adminId, action, entityType, entityId, payload Json?, createdAt }`.
- Append-only, never update/delete.
- Service helper `auditLog.record(...)` dipanggil eksplisit di setiap action admin (tidak via Prisma middleware — terlalu magic).
- View page `/admin/audit-log` dengan filter `adminId`, `entityType`, `action`, date range.
| # | Item | Status | File |
|---|---|---|---|
| 4.1 | Model `AdminActionLog` + migration | ⏳ | [prisma/schema.prisma](prisma/schema.prisma) |
| 4.2 | Helper `auditLog.record({ adminId, action, entityType, entityId, payload? })` | ⏳ | `server/services/audit-log.service.ts` |
| 4.3 | Wire `auditLog.record` di semua admin server action existing (refund approve/reject/mark, payout markPaid, verification approve/reject) | ⏳ | `features/*/actions.ts` |
| 4.4 | Page `/admin/audit-log` dengan filter + pagination | ⏳ | `app/admin/audit-log/page.tsx` |
**Tindakan manual:** tidak ada.
+74 -34
View File
@@ -1,64 +1,104 @@
# Setrip — Admin Roadmap (Index) # Setrip — Admin Roadmap (Index) · ✅ ALL DELIVERED
Status implementasi kemampuan admin agar admin **dapat mengontrol seluruh aplikasi saat ada insiden**, bukan hanya read-only dashboard. Status implementasi kemampuan admin agar admin **dapat mengontrol seluruh aplikasi saat ada insiden**, bukan hanya read-only dashboard.
> **Prinsip:** admin adalah safety net terakhir saat sistem otomatis gagal atau ada bad actor. Setiap action admin harus auditable (siapa, kapan, alasan), idempotent, dan terbatas hanya untuk admin yang terdaftar di `ADMIN_EMAILS`. > **Prinsip:** admin adalah safety net terakhir saat sistem otomatis gagal atau ada bad actor. Setiap action admin harus auditable (siapa, kapan, alasan), idempotent, dan terbatas hanya untuk admin yang terdaftar di `ADMIN_EMAILS`.
> **Status:** 6 dari 6 roadmap area selesai (4 fully, 2 dengan minor skip yang dijelaskan di archive masing-masing). Detail lengkap per area di [docs/archive/](docs/archive/).
--- ---
## Baseline (yang BISA admin lakukan sekarang) ## Baseline yang BISA admin lakukan sekarang
| Area | Fungsi | File | | Area | Fungsi | File |
|---|---|---| |---|---|---|
| Dashboard | View count: verifikasi PENDING, refund per status, payout per status | [app/admin/page.tsx](app/admin/page.tsx) | | **Dashboard** | View count: verifikasi PENDING, refund per status, payout per status | [app/admin/page.tsx](app/admin/page.tsx) |
| **Global search** | Search bar di sidebar — by email, order_id, cuid, fuzzy trip/user | [features/admin/components/admin-search-bar.tsx](features/admin/components/admin-search-bar.tsx) |
| **Trips** | List + search + detail; force-cancel dengan auto-refund (admin intervention) | [app/admin/trips/](app/admin/trips/) | | **Trips** | List + search + detail; force-cancel dengan auto-refund (admin intervention) | [app/admin/trips/](app/admin/trips/) |
| **Users** | List + search + filter; detail dengan trip + booking history; suspend/unsuspend; manual verify (override KYC); analytics | [app/admin/users/](app/admin/users/) |
| **Bookings detail** | Timeline lintas Payment + Refund + Payout, raw callback viewer, Midtrans reconcile | [app/admin/bookings/[id]/page.tsx](app/admin/bookings/[id]/page.tsx) | | **Bookings detail** | Timeline lintas Payment + Refund + Payout, raw callback viewer, Midtrans reconcile | [app/admin/bookings/[id]/page.tsx](app/admin/bookings/[id]/page.tsx) |
| Verifikasi KYC | Approve / Reject organizer (KTP, liveness, bank) | [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx) | | **Verifikasi KYC** | Approve / Reject / Reopen REJECTED / Request re-upload (lebih lembut dari reject) / Manual override; filter date range + reviewer; CSV export | [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx) |
| Refund | Create manual, approve, reject, mark SUCCEEDED, mark FAILED + link ke booking timeline | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) | | **Refund** | Create manual, approve, reject, mark SUCCEEDED/FAILED; filter date/reviewer/reason; link ke booking timeline; CSV export | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) |
| Payout | View per status, mark PAID setelah transfer manual + link ke booking timeline | [app/admin/payouts/page.tsx](app/admin/payouts/page.tsx) | | **Payout** | View per status, mark PAID; filter date/processor; link ke booking timeline; CSV export | [app/admin/payouts/page.tsx](app/admin/payouts/page.tsx) |
| **Audit Log** | View semua action admin lintas entity; filter by admin/entity/action/date | [app/admin/audit-log/page.tsx](app/admin/audit-log/page.tsx) |
| **System Health** | Status cron jobs (last run, health badge), 20 recent runs, **stale state alerts** (Payment AWAITING > 25h, Payout HELD overdue, Refund stuck), **Discord webhook** untuk cron FAILED | [app/admin/system/page.tsx](app/admin/system/page.tsx) |
**Aksi mutating yang diblokir untuk suspended user:** sign-in (NextAuth), `createTripAction`, `joinTripAction`. Trip public list otomatis sembunyikan organizer suspended.
**Audit trail otomatis:** semua aksi admin (suspend, force-cancel, reconcile, approve/reject/reopen/request-reupload/manual-override verification, create/decide refund, mark payout PAID) tercatat di `AdminActionLog` via `auditLog.record()`.
Auth admin: env `ADMIN_EMAILS` → cek di [lib/admin.ts](lib/admin.ts), dipassing ke session via [lib/auth.ts](lib/auth.ts). Auth admin: env `ADMIN_EMAILS` → cek di [lib/admin.ts](lib/admin.ts), dipassing ke session via [lib/auth.ts](lib/auth.ts).
--- ---
## Roadmap per area ## Roadmap per area — status final
| Roadmap | Prioritas | Status | File | | Roadmap | Prioritas | Status | Archive |
|---|---|---|---| |---|---|---|---|
| Trip Operations (search, view, cancel manual) | 🔴 HIGH | ✅ **Delivered** | [docs/archive/ADMIN_TRIP_OPS_ROADMAP.md](docs/archive/ADMIN_TRIP_OPS_ROADMAP.md) | | Trip Operations | 🔴 HIGH | ✅ Delivered | [ADMIN_TRIP_OPS_ROADMAP.md](docs/archive/ADMIN_TRIP_OPS_ROADMAP.md) |
| Payment Operations (booking detail, reconcile, dispute) | 🔴 HIGH | ✅ **Delivered** | [docs/archive/ADMIN_PAYMENT_OPS_ROADMAP.md](docs/archive/ADMIN_PAYMENT_OPS_ROADMAP.md) | | Payment Operations | 🔴 HIGH | ✅ Delivered | [ADMIN_PAYMENT_OPS_ROADMAP.md](docs/archive/ADMIN_PAYMENT_OPS_ROADMAP.md) |
| Audit & Investigation (search, filter, export) | 🔴 HIGH | ⏳ 0% | [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md) | | Audit & Investigation | 🔴 HIGH | ✅ Delivered | [ADMIN_AUDIT_ROADMAP.md](docs/archive/ADMIN_AUDIT_ROADMAP.md) |
| User Management (search, suspend/ban) | 🟡 MEDIUM | ⏳ 0% | [ADMIN_USER_MGMT_ROADMAP.md](ADMIN_USER_MGMT_ROADMAP.md) | | User Management | 🟡 MEDIUM | ✅ Delivered | [ADMIN_USER_MGMT_ROADMAP.md](docs/archive/ADMIN_USER_MGMT_ROADMAP.md) |
| Verification (reopen, re-upload request) | 🟡 MEDIUM | ⏳ 0% | [ADMIN_VERIFICATION_ROADMAP.md](ADMIN_VERIFICATION_ROADMAP.md) | | Verification | 🟡 MEDIUM | ✅ Delivered | [ADMIN_VERIFICATION_ROADMAP.md](docs/archive/ADMIN_VERIFICATION_ROADMAP.md) |
| System Health (cron monitor, stale state alerts) | 🟡 MEDIUM | ⏳ 0% | [ADMIN_SYSTEM_HEALTH_ROADMAP.md](ADMIN_SYSTEM_HEALTH_ROADMAP.md) | | System Health | 🟡 MEDIUM | ✅ Delivered | [ADMIN_SYSTEM_HEALTH_ROADMAP.md](docs/archive/ADMIN_SYSTEM_HEALTH_ROADMAP.md) |
**Legend status:** ⏳ belum mulai · 🚧 partial · ✅ selesai (lihat archive untuk detail delivery) **Minor item yang sengaja di-skip** (dengan justifikasi di archive):
- Audit Phase 2.5 — page `/admin/search` full-results (dropdown 10 hit cukup).
- Verification Phase 3.1 — snapshot full data per submission (counter + array rejection cukup).
- System Health Phase 4.3 — push notif harian dari stale alerts (admin sudah lihat banner di `/admin/system`).
- Trip Ops Phase 3 — trip edit override (skip MVP, evaluate ulang saat ada keluhan konkret).
--- ---
## Iterasi berikutnya (sisa HIGH + MEDIUM) ## Tindakan manual setelah deploy versi final
Setelah Trip Ops + Payment Ops, urutan berikutnya:
1. **Audit & Investigation** (HIGH) — filter date range, search global, CSV export. Penting untuk compliance & investigasi dispute.
2. **User Management** (MEDIUM) — search + suspend/ban. Butuh schema change (`User.suspended`).
3. **System Health** (MEDIUM) — cron monitor + stale state alerts. Butuh model baru (`CronRun`).
4. **Verification** (MEDIUM) — reopen REJECTED + re-upload request. Edge case rare tapi kecil scope.
---
## Tindakan manual setelah deploy
Untuk versi yang berisi delivery Trip Ops + Payment Ops:
```bash ```bash
# Apply migration baru (add_trip_admin_cancel) # Apply 5 migration baru (urutan time-stamp tidak masalah, prisma resolve):
# - 20260518150000_add_trip_admin_cancel
# - 20260518160000_add_user_suspension
# - 20260518170000_add_cron_run
# - 20260518180000_add_admin_action_log
# - 20260518190000_verification_enhancements
npx prisma migrate deploy npx prisma migrate deploy
# Restart Next.js / PM2 supaya Prisma client baru ter-load # (Opsional) Set env Discord webhook untuk alert cron failed
echo 'ADMIN_ALERT_WEBHOOK_URL=https://discord.com/api/webhooks/...' >> .env
# Restart Next.js / PM2 supaya Prisma client + env baru ter-load
pm2 restart setrip --update-env pm2 restart setrip --update-env
``` ```
Brief admin tentang dua kapabilitas baru: Brief admin tentang kapabilitas baru (lihat archive masing-masing untuk detail SOP):
- **Force-cancel trip** di `/admin/trips/[id]` — pakai saat organizer unreachable / dispute, reason wajib min 10 karakter.
- **Reconcile Midtrans** di `/admin/bookings/[id]` — pakai saat peserta lapor "sudah bayar tapi status belum update". Idempotent, aman diulang. - **Global search** di sidebar — ketik email, order_id, atau cuid; auto-detect ke detail page yang tepat.
- **Force-cancel trip** di `/admin/trips/[id]` — saat organizer unreachable / dispute.
- **Reconcile Midtrans** di `/admin/bookings/[id]` — saat peserta lapor "sudah bayar tapi status belum update".
- **Suspend user** di `/admin/users/[id]` — untuk scam/harassment.
- **Manual verify** di `/admin/users/[id]` — partner trusted, bypass KYC, ter-flag jelas.
- **Reopen verification** di REJECTED card — organizer kirim ulang foto via email/WA.
- **Request re-upload** di PENDING card — lebih lembut dari reject; organizer dapat banner di `/verify`.
- **System status** di `/admin/system` — cek setiap pagi, lihat alert stale + cron health.
- **Discord alert** otomatis saat cron FAILED (kalau `ADMIN_ALERT_WEBHOOK_URL` di-set).
- **Audit log** di `/admin/audit-log` — bukti compliance untuk audit eksternal.
- **CSV export** di refunds/payouts/verifications — laporan keuangan/compliance.
- **User stats** di `/admin/users/stats` — total user, signup per minggu.
---
## File-file penting yang ditambahkan / diubah
**Service & helper:**
- [server/services/audit-log.service.ts](server/services/audit-log.service.ts) — log polymorphic
- [server/services/system-health.service.ts](server/services/system-health.service.ts) — stale state detection
- [server/services/admin-search.service.ts](server/services/admin-search.service.ts) — search dispatcher
- [server/services/user.service.ts](server/services/user.service.ts) — suspend/unsuspend
- [lib/cron-runner.ts](lib/cron-runner.ts) — `runCron()` wrapper
- [lib/admin-notify.ts](lib/admin-notify.ts) — Discord webhook helper
- [lib/auth-guards.ts](lib/auth-guards.ts) — `requireActiveUser()`
- [lib/csv.ts](lib/csv.ts) — CSV builder
**Model baru:** `AdminActionLog`, `CronRun` + 5 migration baru di `prisma/migrations/`.
**Admin UI baru:** `/admin/trips`, `/admin/users`, `/admin/bookings/[id]`, `/admin/system`, `/admin/audit-log`, `/admin/users/stats`.
**API routes baru:** `/api/admin/search`, `/api/admin/export/{refunds,payouts,verifications}`.
-103
View File
@@ -1,103 +0,0 @@
# Setrip — Admin System Health Roadmap
Admin perlu visibilitas atas job otomatis (cron) dan deteksi state yang nyangkut (Payment stale, Payout overdue, Refund mandek).
> **Skenario nyata:** cron auto-complete trip crash karena env variable rusak. 50 trip yang sudah lewat tanggalnya tetap `OPEN` selama 3 hari sampai peserta komplain "kenapa belum bisa kasih review". Admin tidak punya cara cek status cron tanpa SSH ke server.
---
## Baseline
- ✅ Cron infra ada (system crontab + `CRON_SECRET`) — lihat [docs/CRON_SETUP.md](docs/CRON_SETUP.md).
- ✅ Cron jobs aktif: `app/api/cron/auto-complete-trips/route.ts`, payout release (lewat `payoutService`), kemungkinan refund timeout.
- ❌ Tidak ada log/audit per cron run (success/fail/error).
- ❌ Tidak ada page `/admin/system` untuk lihat status.
- ❌ Tidak ada alert deteksi state stale (Payment AWAITING > 24h, Payout HELD past `heldUntil`, Refund APPROVED > 7d).
---
## Phase 1 — Cron Run Log ⏳
Tabel `CronRun` yang dicatat setiap kali cron jalan. Foundation untuk semua observability.
**Keputusan asumsi:**
- Append-only model. Retention: keep all (tabel kecil, ~365 rows/year/cron). Cleanup nanti kalau perlu.
- Wrap existing cron handler dengan helper `runCron(name, fn)` yang otomatis log start/finish/error.
- Tidak pakai job library (BullMQ/Inngest) — overkill. Tetap pakai system cron + Next route handler.
| # | Item | Status | File |
|---|---|---|---|
| 1.1 | Model `CronRun { id, jobName, startedAt, finishedAt?, status (RUNNING/SUCCESS/FAILED), errorMessage?, payload? Json }` + migration | ⏳ | [prisma/schema.prisma](prisma/schema.prisma) |
| 1.2 | Helper `runCron(jobName, fn)` — wrap handler, otomatis create RUNNING row → SUCCESS/FAILED | ⏳ | `lib/cron-runner.ts` |
| 1.3 | Wire `runCron` di `app/api/cron/auto-complete-trips/route.ts` | ⏳ | `app/api/cron/auto-complete-trips/route.ts` |
| 1.4 | Wire `runCron` di cron payout release (kalau sudah ada — kalau belum, daftar sebagai gap) | ⏳ | TBD |
| 1.5 | Wire `runCron` di cron lain (refund sweep, dst) | ⏳ | TBD |
**Tindakan manual:** tidak ada.
---
## Phase 2 — System Status Page ⏳
Page `/admin/system` yang tampilkan kondisi terkini.
**Keputusan asumsi:**
- Tabel per cron job: last run, last success, total runs (7d), error count (7d).
- Refresh manual (tombol "Refresh") — bukan auto-poll. Cukup untuk admin.
- Health badge: 🟢 OK (last success < 25 jam untuk daily), 🟡 STALE (> 25 jam), 🔴 FAILED (last run = FAILED).
- Tampilkan 20 cron run terbaru di table bawah untuk drill-down.
| # | Item | Status | File |
|---|---|---|---|
| 2.1 | `cronRepo.getJobSummary(jobName)` — last run, last success, count 7d | ⏳ | `server/repositories/cron.repo.ts` |
| 2.2 | `cronRepo.listRecent(limit)` — 20 run terakhir lintas job | ⏳ | `server/repositories/cron.repo.ts` |
| 2.3 | Page `/admin/system` — tabel job summary + tabel recent runs | ⏳ | `app/admin/system/page.tsx` |
| 2.4 | Health badge logic (helper) | ⏳ | `lib/cron-health.ts` |
| 2.5 | Link "System" di admin navbar | ⏳ | [app/admin/layout.tsx](app/admin/layout.tsx) |
**Tindakan manual:**
1. Set ekspektasi SLA per cron (mis. `auto-complete-trips` harus jalan setiap hari sebelum jam 06:00 WIB).
2. Brief admin: cek `/admin/system` minimal sekali per hari pagi sebelum mulai kerja.
---
## Phase 3 — Stale State Alerts ⏳
Deteksi entity yang nyangkut di state non-final terlalu lama. Tampilkan sebagai banner di `/admin/system`.
**Keputusan asumsi:**
- Stale thresholds (review dengan stakeholder, ini draft):
- Payment status `PENDING` > 1 jam → suspect: gagal create Snap token, perlu manual cleanup
- Payment status `AWAITING` > 25 jam (lebih dari expiresAt) → suspect: webhook gagal, expire belum di-set, perlu reconcile
- Booking status `AWAITING_PAY` + trip date < today → suspect: peserta lupa bayar, butuh cleanup
- Payout status `HELD` + `heldUntil < now` > 1 hari → suspect: cron release tidak jalan, perlu trigger manual
- Refund status `APPROVED` > 7 hari → suspect: admin lupa proses, atau Midtrans refund gagal
- Compute via query parameter pada page load — tidak perlu materialized view.
- Setiap kategori tampilkan jumlah + link ke filtered list page yang relevan.
| # | Item | Status | File |
|---|---|---|---|
| 3.1 | `systemHealthService.detectStale()` return `{ stalePayments, expiredAwaiting, awaitingPayPastDeparture, overduePayouts, stuckRefunds }` | ⏳ | `server/services/system-health.service.ts` |
| 3.2 | Banner alerts di `/admin/system` kalau ada count > 0 | ⏳ | `app/admin/system/page.tsx` |
| 3.3 | Link tiap alert ke filtered list (pakai filter di [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md) Phase 1) | ⏳ | `app/admin/system/page.tsx` |
| 3.4 | Stat card di dashboard utama `/admin` kalau ada alert | ⏳ | [app/admin/page.tsx](app/admin/page.tsx) |
**Tindakan manual:**
1. Tuning threshold setelah jalan 1-2 minggu (false positive vs miss).
2. SOP per alert: action apa yang admin harus ambil saat banner muncul.
---
## Phase 4 — External Alerting (opsional) ⏳
Push notif ke channel eksternal (Discord/Telegram/email) saat ada cron FAILED atau stale state critical. Skip kecuali admin sering miss banner.
| # | Item | Status | File |
|---|---|---|---|
| 4.1 | Helper `notifyAdmins(message)` — POST ke Discord webhook URL dari env | ⏳ | `lib/admin-notify.ts` |
| 4.2 | Trigger notify di `runCron` saat FAILED | ⏳ | `lib/cron-runner.ts` |
| 4.3 | Trigger notify dari `systemHealthService.detectStale` (rate-limited, max 1x/hari per kategori) | ⏳ | `server/services/system-health.service.ts` |
**Tindakan manual:**
1. Buat channel Discord internal + webhook URL → set env `ADMIN_ALERT_WEBHOOK_URL`.
2. Test alert dengan trigger fake fail.
-81
View File
@@ -1,81 +0,0 @@
# Setrip — Admin User Management Roadmap
Admin perlu bisa cari user dan **suspend** akun yang melakukan abuse (scam, harassment, fake review).
> **Skenario nyata:** organizer scam berkali-kali bikin trip palsu pakai alias berbeda. Peserta lapor harassment dari user lain di grup WA trip. Saat ini admin cuma bisa refund korban — pelaku tetap bisa lanjut bikin trip baru / join trip lain.
---
## Baseline
-`userRepo.findByEmail()` dan `userRepo.findById()` ada di [server/repositories/user.repo.ts](server/repositories/user.repo.ts).
-`User` model lengkap dengan relasi ke `trips`, `participations`, `bookings`, `tripReviews`, `organizerVerification`, `payouts`.
- ❌ Tidak ada page `/admin/users`.
- ❌ Tidak ada field `suspended` di `User`.
- ❌ Tidak ada guard di auth/server actions yang reject suspended user.
- ❌ Tidak ada stats user (total signups, organizer aktif, peserta aktif).
---
## Phase 1 — User List & Detail View ⏳
Baseline visibility. Sama pola dengan trip ops — list + search + detail.
**Keputusan asumsi:**
- Search by email exact match dulu (paling sering dipakai admin saat ada laporan); kalau perlu, tambah name LIKE search nanti.
- Detail page tampilkan: profile, verification status, booking history (sebagai peserta), trip history (sebagai organizer), payout history (sebagai organizer), review yang dibuat & diterima.
- Sensitive info (password hash, OAuth tokens) **tidak** ditampilkan.
| # | Item | Status | File |
|---|---|---|---|
| 1.1 | `userRepo.searchForAdmin({ q?, role?, suspended? })` — q match email atau name (case insensitive) | ⏳ | [server/repositories/user.repo.ts](server/repositories/user.repo.ts) |
| 1.2 | Page `/admin/users` — list + search bar + filter (organizer/participant/suspended) | ⏳ | `app/admin/users/page.tsx` |
| 1.3 | Page `/admin/users/[id]` — profil + tabs (Bookings, Trips Dibuat, Reviews, Verification) | ⏳ | `app/admin/users/[id]/page.tsx` |
| 1.4 | Stats card di top: total bookings, total spent, total revenue (kalau organizer), verification status | ⏳ | `app/admin/users/[id]/page.tsx` |
| 1.5 | Link "Users" di admin navbar | ⏳ | [app/admin/layout.tsx](app/admin/layout.tsx) |
**Tindakan manual:** tidak ada.
---
## Phase 2 — User Suspension ⏳
Toggle suspend yang mencegah suspended user login + melakukan aksi mutatif.
**Keputusan asumsi:**
- Tambah 4 kolom di `User`: `suspended Boolean @default(false)`, `suspendedAt DateTime?`, `suspendedReason String?`, `suspendedBy String?` (FK User admin).
- **Block sign-in** di NextAuth callbacks (`signIn` callback return false kalau `user.suspended`). Untuk JWT session sudah aktif, cek `suspended` di `session` callback lalu invalidate.
- **Block mutating actions** via helper `requireActiveUser(session)` yang dipanggil di awal setiap server action mutating (joinTrip, createTrip, addReview, dst).
- Suspended user **tetap bisa** lihat data sendiri (refund history, dll) — tidak hard-delete supaya audit trail terjaga.
- Suspended organizer otomatis sembunyikan trip OPEN/FULL miliknya dari public list — tambah filter di `tripRepo.findOpen` (`organizer: { suspended: false }`).
- Unsuspend = toggle false + clear field — tetap simpan history via [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md) Phase 4.
| # | Item | Status | File |
|---|---|---|---|
| 2.1 | Migration: tambah `suspended`, `suspendedAt`, `suspendedReason`, `suspendedBy` di `User` | ⏳ | `prisma/migrations/` |
| 2.2 | `userService.suspendUser(userId, adminId, reason)` + `unsuspendUser(userId, adminId)` | ⏳ | `server/services/user.service.ts` |
| 2.3 | Block sign-in di NextAuth `signIn` callback | ⏳ | [lib/auth.ts](lib/auth.ts) |
| 2.4 | Helper `requireActiveUser(session)` throw kalau suspended | ⏳ | `lib/auth-guards.ts` |
| 2.5 | Wire `requireActiveUser` di semua mutating server action (joinTripAction, createTripAction, createReviewAction, dst) | ⏳ | `features/*/actions.ts` |
| 2.6 | Filter trip public list: organizer tidak suspended | ⏳ | [server/repositories/trip.repo.ts](server/repositories/trip.repo.ts) |
| 2.7 | UI: tombol "Suspend" / "Unsuspend" di `/admin/users/[id]` + modal reason wajib | ⏳ | `app/admin/users/[id]/page.tsx` |
| 2.8 | Badge "SUSPENDED" di user list + detail header (visual jelas) | ⏳ | `app/admin/users/[id]/page.tsx` |
| 2.9 | Server action `suspendUserAction` + `unsuspendUserAction` (guard isAdmin) | ⏳ | `features/admin/actions.ts` (baru) atau `features/user/actions.ts` |
**Tindakan manual:**
1. Brief admin: kriteria suspend (3 kategori: scam, harassment, repeated TOS violation). Hindari subjective suspend.
2. Tulis halaman info "Akun ditangguhkan" yang ditampilkan saat suspended user coba login (jelaskan kenapa & cara appeal via email).
3. Pertimbangkan suspended user di Midtrans webhook — saat ada payment masuk untuk suspended user's booking, tetap di-PAID (uang tetap diterima, refund proses normal).
---
## Phase 3 — User Analytics (low priority, skip MVP) ⏳
Dashboard stats untuk growth tracking. Skip sampai ada kebutuhan konkret.
| # | Item | Status | File |
|---|---|---|---|
| 3.1 | Stats endpoint: total user, signup per minggu (4 minggu terakhir), organizer aktif (yang punya OPEN/FULL trip), peserta aktif (booking PAID) | ⏳ | `app/admin/users/stats/page.tsx` |
| 3.2 | Chart sederhana (HTML/SVG inline, no chart library) | ⏳ | `app/admin/users/stats/page.tsx` |
**Tindakan manual:** tidak ada (skip phase ini).
-88
View File
@@ -1,88 +0,0 @@
# Setrip — Admin Verification Roadmap
Enhancement KYC organizer verification: reopen REJECTED, request re-upload, audit override.
> **Skenario nyata:** organizer terverifikasi dengan KTP buram → admin REJECT. Organizer foto ulang dan kirim via email. Admin sekarang harus edit DB manual karena `OrganizerVerification.status = REJECTED` tidak bisa kembali ke `PENDING` lewat UI.
---
## Baseline
- ✅ Approve / Reject ada di [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx).
-`OrganizerVerification` model lengkap dengan `reviewedBy`, `reviewedAt`, `rejectionReason`.
- ✅ NIK encrypted (decrypt via `organizerService.decryptNik` saat di-render).
- ❌ Tidak ada cara reopen REJECTED → kembali ke PENDING.
- ❌ Tidak ada flow "request re-upload" (admin minta organizer upload ulang field tertentu tanpa harus reject penuh).
- ❌ Tidak ada history per verification — kalau organizer ajukan ulang setelah reject, history sebelumnya hilang (di-overwrite).
---
## Phase 1 — Reopen Rejected Verification ⏳
Tombol di REJECTED detail untuk reset ke PENDING supaya admin/organizer bisa coba lagi tanpa harus drop & recreate.
**Keputusan asumsi:**
- Tidak hapus `rejectionReason` saat reopen — simpan untuk history (rename field jadi `lastRejectionReason`). Sebenarnya rejectionReason cuma string, kalau di-reopen lalu di-reject lagi otomatis di-overwrite. Untuk MVP cukup itu.
- Service method baru `reopenVerification(verifId, adminId, note)` — set `status = PENDING`, clear `reviewedBy/reviewedAt`, log via [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md) Phase 4 `auditLog.record`.
- UI: tombol "Buka kembali" di REJECTED card dengan modal note wajib.
- Tidak otomatis kirim notif ke organizer di MVP — admin coordinate via email/WA.
| # | Item | Status | File |
|---|---|---|---|
| 1.1 | `organizerService.reopenVerification(verifId, adminId, note)` — set PENDING + clear review fields | ⏳ | [server/services/organizer.service.ts](server/services/organizer.service.ts) |
| 1.2 | Server action `reopenVerificationAction(verifId, note)` (guard isAdmin) | ⏳ | [features/organizer/actions.ts](features/organizer/actions.ts) |
| 1.3 | UI: tombol "Buka kembali" di tab REJECTED dengan modal note | ⏳ | [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx) atau [features/organizer/components/review-card.tsx](features/organizer/components/review-card.tsx) |
| 1.4 | Tampilkan `lastRejectionReason` di tab PENDING juga (sebagai konteks "ini submission ke-N") | ⏳ | [features/organizer/components/review-card.tsx](features/organizer/components/review-card.tsx) |
**Tindakan manual:**
1. Brief admin: jangan reopen tanpa konfirmasi organizer sudah siap upload ulang. Note wajib menjelaskan alasan reopen.
---
## Phase 2 — Re-upload Request Flow ⏳
Daripada reject penuh, admin bisa request specific field di-update (KTP buram, foto liveness terlalu gelap).
**Keputusan asumsi:**
- Tambah `OrganizerVerification.reuploadRequested Boolean @default(false)` + `reuploadFields String[]` + `reuploadNote String?`.
- Saat di-request, set `status = PENDING` (atau status baru `NEEDS_REUPLOAD`). Pakai `PENDING` saja supaya tidak nambah enum (organizer-facing copy bedakan via `reuploadRequested` flag).
- Organizer page `/verify` baca flag → tampilkan banner kuning + highlight field yang di-request.
- Setelah organizer submit ulang, flag auto-clear → status tetap PENDING menunggu admin review.
| # | Item | Status | File |
|---|---|---|---|
| 2.1 | Migration: tambah `reuploadRequested`, `reuploadFields`, `reuploadNote` di `OrganizerVerification` | ⏳ | `prisma/migrations/` |
| 2.2 | `organizerService.requestReupload(verifId, adminId, fields[], note)` | ⏳ | [server/services/organizer.service.ts](server/services/organizer.service.ts) |
| 2.3 | Server action + UI tombol "Request re-upload" di admin detail (checkbox per field + textarea note) | ⏳ | [features/organizer/actions.ts](features/organizer/actions.ts) + admin page |
| 2.4 | Banner kuning di `/verify` saat `reuploadRequested = true` + highlight field-nya | ⏳ | [app/(public)/verify/page.tsx](app/(public)/verify/page.tsx) + [features/organizer/components/verify-form.tsx](features/organizer/components/verify-form.tsx) |
| 2.5 | Auto-clear `reuploadRequested` saat organizer submit ulang | ⏳ | [features/organizer/actions.ts](features/organizer/actions.ts) (di `submitVerificationAction`) |
**Tindakan manual:** tidak ada.
---
## Phase 3 — Verification History (opsional) ⏳
Kalau audit butuh trace "berapa kali organizer ini coba verify", tambah tabel history. Skip untuk MVP.
| # | Item | Status | File |
|---|---|---|---|
| 3.1 | Model `OrganizerVerificationHistory` (snapshot per submission) | ⏳ | [prisma/schema.prisma](prisma/schema.prisma) |
| 3.2 | Trigger create snapshot saat submit ulang | ⏳ | [server/services/organizer.service.ts](server/services/organizer.service.ts) |
| 3.3 | Tab "History" di admin verification detail | ⏳ | admin page |
**Tindakan manual:** tidak ada (skip phase).
---
## Phase 4 — Manual Override (super-low priority) ⏳
Admin verifikasi organizer tanpa upload (referral dari partner trusted). Skip kecuali ada use case nyata.
| # | Item | Status | File |
|---|---|---|---|
| 4.1 | `organizerService.adminCreateVerification(userId, adminId, note)` — buat row APPROVED langsung dengan flag `isManualOverride` | ⏳ | [server/services/organizer.service.ts](server/services/organizer.service.ts) |
| 4.2 | Migration: tambah `isManualOverride Boolean @default(false)` | ⏳ | `prisma/migrations/` |
| 4.3 | UI: tombol "Verify manually" di `/admin/users/[id]` (organizer tab) | ⏳ | `app/admin/users/[id]/page.tsx` |
**Tindakan manual:** tidak ada (skip phase, evaluate ulang setelah ada partnership program).
+165
View File
@@ -0,0 +1,165 @@
# Setrip — Email Notifications Roadmap
Status implementasi notifikasi email transaksional ke user & organizer. Pakai pola yang sama dengan refund/admin roadmap: per-phase checklist, idempotent, auditable.
> **Prinsip:**
> - **Transactional only di MVP** — KYC, refund, payment, account moderation. Marketing/reminder belakangan.
> - **Idempotent** — webhook retry / cron rerun tidak boleh double-send. Pakai `idempotencyKey` unique constraint di `EmailSent`.
> - **Non-blocking** — server action utama tidak boleh gagal kalau email gateway down. Pattern: try send sync; kalau gagal, enqueue `EmailJob` untuk retry cron.
> - **Audit-friendly** — semua email tercatat (sent atau queued) supaya admin bisa cek "kenapa user X belum dapat email Y?".
> - **Unsubscribe-aware** — transactional email (refund, payment, suspend) tetap dikirim. Marketing (reminder, social signal) opt-in dengan unsubscribe link.
**Progress (per 2026-05-20):** PR-E1, E2, E3, E5 ✅ — foundation, transactional email, notifikasi event Phase 2, dan admin email log + retry selesai. PR-E4 ⏳ (marketing/reminder) sengaja ditunda — belum dibutuhkan.
---
## Baseline (kondisi sekarang)
- ❌ Tidak ada email service terintegrasi.
- ❌ Tidak ada template engine.
- ❌ User & organizer hanya tahu state via UI — kalau tidak buka app, miss event penting (refund cair, KYC approve, dst).
---
## Provider choice
**Resend** — alasan:
- Free tier 3000 email/bulan + 100/hari (cukup untuk MVP)
- React Email native (kalau mau upgrade dari plain HTML)
- API simple (POST `/emails`)
- DNS setup ringan (SPF + DKIM auto)
Alternatif: AWS SES (paling murah at scale), SendGrid, Mailgun. **Decision:** Resend untuk MVP, evaluate ulang saat lebih dari 50k email/bulan.
**Library dependency:** SKIP — pakai `fetch` ke `https://api.resend.com/emails` directly. Lebih ringkas, satu-satunya dependency-free option.
---
## PR-E1 — Foundation: schema + service + cron (MVP wajib) ✅
**Tujuan:** infrastructure kirim email yang idempotent + retry-able, tanpa nge-block server action.
**Keputusan asumsi:**
- 2 model baru:
- `EmailSent` — append-only log dengan `idempotencyKey @unique`. Cek di sini sebelum kirim → cegah double-send.
- `EmailJob` — retry queue untuk send yang gagal sync. Status `PENDING/PROCESSING/SUCCESS/FAILED`, attempt counter, max 5 retry exponential.
- Service `emailService.send({ to, template, data, idempotencyKey })`:
1. Cek `EmailSent` by idempotencyKey → kalau exist, return early.
2. Render template ke `{ subject, html }`.
3. POST ke Resend.
4. Sukses → insert `EmailSent` row.
5. Gagal → insert `EmailJob` row (status PENDING, attempts=1).
- Cron `/api/cron/process-email-jobs` setiap 5 menit — pick PENDING + FAILED (attempts<5), retry, mark SUCCESS atau bump attempts.
- Caller pattern: `void emailService.send(...)` (fire-and-forget) supaya tidak nge-block server action. Try/catch internal sudah handle error.
| # | Item | Status | File |
|---|---|---|---|
| E1.1 | Model `EmailSent { idempotencyKey @unique, to, template, sentAt, providerMessageId? }` + migration | ✅ | [prisma/schema.prisma](prisma/schema.prisma) |
| E1.2 | Model `EmailJob { idempotencyKey, to, subject, html, status, attempts, lastError?, scheduledAt }` + migration | ✅ | [prisma/schema.prisma](prisma/schema.prisma) |
| E1.3 | Service `lib/email/send.ts` — Resend client (raw fetch) + idempotency + enqueue on failure | ✅ | `lib/email/send.ts` |
| E1.4 | Template registry — function per template return `{ subject, html }` | ✅ | `lib/email/templates.ts` |
| E1.5 | Cron route `/api/cron/process-email-jobs` pakai `runCron` helper | ✅ | `app/api/cron/process-email-jobs/route.ts` |
| E1.6 | Env: `RESEND_API_KEY`, `EMAIL_FROM` (mis. `"SeTrip <no-reply@setrip.id>"`) | ✅ | `.env.example` |
**Tindakan manual ops:**
1. Buat akun Resend, verify domain `setrip.id` (atau pakai `onboarding@resend.dev` di dev).
2. Set DNS SPF + DKIM record di provider domain.
3. Generate API key, set env `RESEND_API_KEY` + `EMAIL_FROM` di production.
4. Daftarkan cron baru di system crontab: `*/5 * * * * curl ... /api/cron/process-email-jobs`.
---
## PR-E2 — Phase 1: Wire transactional emails (MVP wajib) ✅
**Tujuan:** kirim email untuk event paling kritis (KYC, refund, payment, suspend) — yang kalau miss bikin user kehilangan uang atau bingung permanen.
| # | Trigger | Penerima | Template | Wire point | Status |
|---|---|---|---|---|---|
| E2.1 | KYC `VERIFICATION_APPROVE` | User | `kyc_approved` | `reviewVerificationAction` | ✅ |
| E2.2 | KYC `VERIFICATION_REJECT` | User | `kyc_rejected` (sertakan reason) | `reviewVerificationAction` | ✅ |
| E2.3 | KYC re-upload request | User | `kyc_reupload_request` (fields + note) | `requestReuploadAction` | ✅ |
| E2.4 | Refund created (admin atau auto-trigger) | User | `refund_created` (amount + reason) | `createRefundAction` + `tripService.closeTrip` (loop semua peserta PAID) | ✅ |
| E2.5 | Refund SUCCEEDED | User | `refund_succeeded` (amount, cek rekening) | `decideRefundAction` (decision=SUCCEEDED) | ✅ |
| E2.6 | Refund FAILED | User | `refund_failed` (alasan + langkah next) | `decideRefundAction` (decision=FAILED) | ✅ |
| E2.7 | Midtrans webhook PAID | User | `payment_paid` (terima kasih + detail booking) | `paymentService.applyGatewayStatus` (di branch PAID success) | ✅ |
| E2.8 | Booking di-approve organizer (status → AWAITING_PAY) | User | `booking_approved` (link bayar + deadline) | `confirmParticipantAction` | ✅ |
| E2.9 | Account suspend | User | `account_suspended` (reason + email support) | `suspendUserAction` | ✅ |
> ️ **E2.4** — jalur admin (`createRefundAction`) kirim `refund_created`. Untuk auto-refund saat trip dibatalkan, peserta dikabari lewat email `trip_cancelled_organizer` / `trip_cancelled_admin` (E3.4/E3.5) yang sudah memuat blok nominal refund — satu email konsolidasi, bukan dua.
**Format idempotencyKey:**
- `kyc_approved-<verificationId>`
- `refund_succeeded-<refundId>`
- `payment_paid-<paymentId>`
- `booking_approved-<bookingId>`
- `account_suspended-<userId>-<suspendedAt>` (allow re-suspend kalau diulang)
**Tindakan manual ops:**
1. Test setiap template di staging — render via Resend "Send test" atau preview HTML lokal.
2. Pastikan `EMAIL_FROM` domain match SPF/DKIM supaya tidak masuk spam.
---
## PR-E3 — Phase 2: UX enhancement (post-MVP) ✅
Email yang berguna tapi tidak critical kalau miss.
| # | Trigger | Penerima | Template | Wire point | Status |
|---|---|---|---|---|---|
| E3.1 | User join trip (PENDING) | Organizer | `join_request` (siapa, link approve) | `joinTripAction` | ✅ |
| E3.2 | Organizer reject join | User | `join_rejected` (singkat) | `rejectParticipantAction` | ✅ |
| E3.3 | Payment EXPIRED / FAILED | User | `payment_expired` (suruh start ulang) | `paymentService.applyGatewayStatus` | ✅ |
| E3.4 | Trip CLOSED (organizer cancel) | Semua peserta aktif | `trip_cancelled_organizer` (batch) | `tripService.closeTrip` (organizer actor) | ✅ |
| E3.5 | Trip CLOSED (admin force-cancel) | Semua peserta + organizer | `trip_cancelled_admin` (reason) | `tripService.closeTrip` (admin actor) | ✅ |
| E3.6 | Payout RELEASED | Organizer | `payout_released` (ETA transfer dari admin) | `payoutService.releaseEligible` | ✅ |
| E3.7 | Payout PAID | Organizer | `payout_paid` (cek rekening + adminNote) | `markPayoutPaidAction` | ✅ |
| E3.8 | Account unsuspended | User | `account_unsuspended` | `unsuspendUserAction` | ✅ |
| E3.9 | KYC submitted | User | `kyc_submitted` (ETA review) | `submitVerificationAction` | ✅ |
| E3.10 | KYC manual override | User | `kyc_manual_override` (admin verified) | `manualOverrideVerificationAction` | ✅ |
| E3.11 | KYC reopened (admin) | User | `kyc_reopened` (boleh submit ulang) | `reopenVerificationAction` | ✅ |
**Wire point email:** semua dikirim `void emailService.send(...)` (fire-and-forget, idempotent). Untuk batch trip-cancelled, `closeTrip` mengembalikan daftar penerima + nominal refund; email dikirim oleh action setelah transaksi commit (bukan di dalam tx).
---
## PR-E4 — Phase 3: Marketing / reminder (post-MVP, opt-in) ⏳
Email engagement — perlu user preference + unsubscribe link.
| # | Trigger | Penerima | Template | Wire point |
|---|---|---|---|---|
| E4.1 | Welcome email saat signup | User | `welcome` | NextAuth `events.signIn` first time |
| E4.2 | Reminder H-3 keberangkatan | User | `trip_reminder_h3` (meeting point + itinerary) | Cron daily |
| E4.3 | Reminder H-1 keberangkatan | User | `trip_reminder_h1` | Cron daily |
| E4.4 | Trip selesai → minta review (H+1) | User CONFIRMED | `review_prompt` | Cron daily |
| E4.5 | Review baru diterima | Organizer | `new_review` | `createReviewAction` |
| E4.6 | Trip jadi FULL | Organizer | `trip_full` | `tripService.joinTrip` (saat FULL transition) |
**Prerequisite:** tabel `UserEmailPreference` dengan kategori `marketing` / `reminders` + unsubscribe token. Skip sampai Phase 4.
---
## PR-E5 — Admin UI: email log + queue retry (post-MVP) ✅
Visibilitas admin untuk troubleshoot "kenapa user X tidak dapat email?".
| # | Item | Status | File |
|---|---|---|---|
| E5.1 | Page `/admin/emails` — list `EmailSent` + `EmailJob`, filter recipient + template, status lewat tab | ✅ | `app/admin/emails/page.tsx` |
| E5.2 | Tombol "Kirim ulang" untuk EmailJob gagal/antri — retry sync langsung | ✅ | `features/email/components/email-row-actions.tsx` |
| E5.3 | Tombol "Resend" untuk EmailSent — key turunan `#resend-<ts>`, butuh `EmailSent.html` | ✅ | `features/email/actions.ts` |
| E5.4 | Stats card di `/admin/system` + `/admin/emails`: antri, gagal 24 jam, perlu aksi manual | ✅ | `app/admin/system/page.tsx` |
**Tindakan manual ops:**
1. Run migration `20260520000000_add_email_sent_html` (kolom `EmailSent.html`) di staging → production. Tanpa ini, resend (E5.3) tidak tersedia untuk email yang dikirim sebelum migration.
2. Tambahkan `/admin/emails` ke admin nav — sudah dilakukan di `components/admin/admin-sidebar.tsx`.
> ️ Deviasi minor dari rencana awal: filter tanggal tidak diimplementasikan (list dibatasi 100 baris terbaru); filter status diwujudkan sebagai tab (Gagal / Antrian / Terkirim).
---
## Skip / never (eksplisit)
- ❌ SMS / WhatsApp — beda regulatory, beda cost. Stick to email.
- ❌ Push notification (browser/mobile) — perlu PWA setup terpisah.
- ❌ In-app inbox — komplexitas tinggi, low ROI di MVP. Email cukup.
+31 -6
View File
@@ -93,12 +93,12 @@ Alur ini menggambarkan satu peserta dari pertama kali mendaftar sampai pembayara
### 5. Ringkasan peran data ### 5. Ringkasan peran data
| Konsep | Penyimpanan | | Konsep | Penyimpanan |
|--------|-------------| | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Trip | `Trip`: judul, gunung, lokasi, tanggal, kuota, harga, status trip (`OPEN` / `FULL` / …), meeting point, itinerary, termasuk/tidak termasuk, relasi ke organizer | | Trip | `Trip`: judul, gunung, lokasi, tanggal, kuota, harga, status trip (`OPEN` / `FULL` / …), meeting point, itinerary, termasuk/tidak termasuk, relasi ke organizer |
| Peserta | `TripParticipant` unik per `(tripId, userId)`: status **`PENDING`** / **`CONFIRMED`** / **`CANCELLED`**, serta **`markedPaidAt`** & **`paymentConfirmedAt`** untuk alur bayar manual | | Peserta | `TripParticipant` unik per `(tripId, userId)`: status **`PENDING`** / **`CONFIRMED`** / **`CANCELLED`**, serta **`markedPaidAt`** & **`paymentConfirmedAt`** untuk alur bayar manual |
| Organizer (kepercayaan) | `OrganizerVerification` (1-1 ke `User`) berisi KTP, foto liveness (memegang kertas "SETRIP"), rekening, dan status (`PENDING` / `APPROVED` / `REJECTED`); badge **Verified Organizer** muncul ketika `status === "APPROVED"` (helper `lib/trust.ts → isVerifiedOrganizer`). Agregat rating & jumlah trip dihitung dari ulasan & trip. | | Organizer (kepercayaan) | `OrganizerVerification` (1-1 ke `User`) berisi KTP, foto liveness (memegang kertas "SETRIP"), rekening, dan status (`PENDING` / `APPROVED` / `REJECTED`); badge **Verified Organizer** muncul ketika `status === "APPROVED"` (helper `lib/trust.ts → isVerifiedOrganizer`). Agregat rating & jumlah trip dihitung dari ulasan & trip. |
| Persetujuan T&C / Privasi | `User.acceptedTermsAndPrivacy` + `User.acceptedAt`, dicentang saat registrasi (link ke `/terms` & `/privacy`). | | Persetujuan T&C / Privasi | `User.acceptedTermsAndPrivacy` + `User.acceptedAt`, dicentang saat registrasi (link ke `/terms` & `/privacy`). |
## Menjalankan secara lokal ## Menjalankan secara lokal
@@ -128,3 +128,28 @@ Buka [http://localhost:3000](http://localhost:3000).
- [Next.js Documentation](https://nextjs.org/docs) - [Next.js Documentation](https://nextjs.org/docs)
- [Prisma Documentation](https://www.prisma.io/docs) - [Prisma Documentation](https://www.prisma.io/docs)
# 1. Install SEMUA dep (termasuk dev) — deterministik dari lockfile.
# --include=dev memaksa dev terpasang walau NODE_ENV=production ter-export.
npm ci --include=dev
# 2. Prisma: generate client + apply migrasi
npx prisma generate
npx prisma migrate deploy
# 3. Build (butuh devDependencies)
npm run build
# 4. (Opsional) ramping-kan node_modules — buang dev SETELAH build selesai
npm prune --omit=dev
# 5. Jalankan
pm2 start ecosystem.config.js --env production
# atau restart: pm2 restart setrip --update-env
+22 -20
View File
@@ -4,6 +4,8 @@ Status implementasi sistem refund yang dapat dipercaya dan auditable — dari sc
> **Prinsip:** refund = financial event, bukan flag boolean. Setiap rupiah yang keluar harus auditable, idempotent, dan punya state machine eksplisit. Untuk MVP: tetap simple, tapi anticipate partial refund + async gateway dari hari pertama. > **Prinsip:** refund = financial event, bukan flag boolean. Setiap rupiah yang keluar harus auditable, idempotent, dan punya state machine eksplisit. Untuk MVP: tetap simple, tapi anticipate partial refund + async gateway dari hari pertama.
**Progress (per 2026-05-20):** PR-R1, R2, R3 ✅ — MVP refund (schema + service, organizer-cancel auto-refund, self-service user cancel) selesai. PR-R4 / R5 / R6 ⏳ post-MVP belum dikerjakan.
--- ---
## Audit state sekarang (baseline) ## Audit state sekarang (baseline)
@@ -34,7 +36,7 @@ File baseline: [prisma/schema.prisma](prisma/schema.prisma), [server/services/bo
--- ---
## PR-R1 — Refund Schema + Service Stub (foundation) ## PR-R1 — Refund Schema + Service Stub (foundation)
Foundation. Bikin entity `Refund` + service stub yang cuma create record `PENDING` (belum panggil gateway). UI admin minimal: list + manual mark succeeded. Tanpa ini, semua PR berikutnya tidak bisa jalan. Foundation. Bikin entity `Refund` + service stub yang cuma create record `PENDING` (belum panggil gateway). UI admin minimal: list + manual mark succeeded. Tanpa ini, semua PR berikutnya tidak bisa jalan.
@@ -48,14 +50,14 @@ Foundation. Bikin entity `Refund` + service stub yang cuma create record `PENDIN
| # | Item | Status | File | | # | Item | Status | File |
|---|---|---|---| |---|---|---|---|
| R1.1 | Model `Refund` + enum `RefundReason`, `RefundStatus`, `RefundInitiator` | | [prisma/schema.prisma](prisma/schema.prisma) | | R1.1 | Model `Refund` + enum `RefundReason`, `RefundStatus`, `RefundInitiator` | | [prisma/schema.prisma](prisma/schema.prisma) |
| R1.2 | Tambah `BookingStatus.PARTIALLY_REFUNDED` | | [prisma/schema.prisma](prisma/schema.prisma) | | R1.2 | Tambah `BookingStatus.PARTIALLY_REFUNDED` | | [prisma/schema.prisma](prisma/schema.prisma) |
| R1.3 | Migration `add_refund_model` | | `prisma/migrations/` | | R1.3 | Migration `add_refund_model` | | `prisma/migrations/` |
| R1.4 | `refundRepo` (create, findById, findByBooking, listPending, updateStatus) | | `server/repositories/refund.repo.ts` | | R1.4 | `refundRepo` (create, findById, findByBooking, listPending, updateStatus) | | `server/repositories/refund.repo.ts` |
| R1.5 | `refundService.requestRefund(input)` — create Refund PENDING + validasi `amount <= payment.amount - totalRefunded` | | `server/services/refund.service.ts` | | R1.5 | `refundService.requestRefund(input)` — create Refund PENDING + validasi `amount <= payment.amount - totalRefunded` | | `server/services/refund.service.ts` |
| R1.6 | `refundService.approveRefund(refundId, adminId)` — PENDING → APPROVED | | `server/services/refund.service.ts` | | R1.6 | `refundService.approveRefund(refundId, adminId)` — PENDING → APPROVED | | `server/services/refund.service.ts` |
| R1.7 | `refundService.markSucceededManual(refundId, adminId)` — APPROVED → SUCCEEDED, update Booking/Payment status. Untuk MVP refund manual transfer (non-Midtrans channel). | | `server/services/refund.service.ts` | | R1.7 | `refundService.markSucceededManual(refundId, adminId)` — APPROVED → SUCCEEDED, update Booking/Payment status. Untuk MVP refund manual transfer (non-Midtrans channel). | | `server/services/refund.service.ts` |
| R1.8 | UI admin `/admin/refunds` — list PENDING + tombol approve & mark-succeeded | | `app/admin/refunds/page.tsx` | | R1.8 | UI admin `/admin/refunds` — list PENDING + tombol approve & mark-succeeded | | `app/admin/refunds/page.tsx` |
**Tindakan manual:** **Tindakan manual:**
1. Run migration di staging → smoke test → run di production. 1. Run migration di staging → smoke test → run di production.
@@ -63,7 +65,7 @@ Foundation. Bikin entity `Refund` + service stub yang cuma create record `PENDIN
--- ---
## PR-R2 — Auto-Trigger Refund saat Trip CLOSED (organizer cancel) ## PR-R2 — Auto-Trigger Refund saat Trip CLOSED (organizer cancel)
Saat organizer batalkan trip (`status = CLOSED`), semua peserta dengan `Booking.status = PAID` otomatis di-create `Refund` record dengan `reason = ORGANIZER_CANCELLED`, `initiatedBy = SYSTEM`, full amount. Saat organizer batalkan trip (`status = CLOSED`), semua peserta dengan `Booking.status = PAID` otomatis di-create `Refund` record dengan `reason = ORGANIZER_CANCELLED`, `initiatedBy = SYSTEM`, full amount.
@@ -75,16 +77,16 @@ Saat organizer batalkan trip (`status = CLOSED`), semua peserta dengan `Booking.
| # | Item | Status | File | | # | Item | Status | File |
|---|---|---|---| |---|---|---|---|
| R2.1 | `tripService.closeTrip(tripId, organizerId)` — set status CLOSED + auto-create refunds | | [server/services/trip.service.ts](server/services/trip.service.ts) | | R2.1 | `tripService.closeTrip(tripId, organizerId)` — set status CLOSED + auto-create refunds | | [server/services/trip.service.ts](server/services/trip.service.ts) |
| R2.2 | Auto-approve untuk SYSTEM refund dengan reason ORGANIZER_CANCELLED | | `server/services/refund.service.ts` | | R2.2 | Auto-approve untuk SYSTEM refund dengan reason ORGANIZER_CANCELLED | | `server/services/refund.service.ts` |
| R2.3 | UI organizer: tombol "Batalkan trip" di dashboard organizer dengan confirmation modal | | `features/trip/components/cancel-trip-button.tsx` | | R2.3 | UI organizer: tombol "Batalkan trip" di dashboard organizer dengan confirmation modal | | `features/trip/components/cancel-trip-button.tsx` |
| R2.4 | Server action `cancelTripAction` | | `features/trip/actions.ts` | | R2.4 | Server action `cancelTripAction` | | `features/trip/actions.ts` |
**Tindakan manual:** tidak ada. **Tindakan manual:** tidak ada.
--- ---
## PR-R3 — Self-Service User Cancel dengan Refund Window ## PR-R3 — Self-Service User Cancel dengan Refund Window
User bisa cancel booking sendiri, refund dihitung berdasarkan kebijakan default (hardcoded dulu, jangan polymorphic). User bisa cancel booking sendiri, refund dihitung berdasarkan kebijakan default (hardcoded dulu, jangan polymorphic).
@@ -100,11 +102,11 @@ User bisa cancel booking sendiri, refund dihitung berdasarkan kebijakan default
| # | Item | Status | File | | # | Item | Status | File |
|---|---|---|---| |---|---|---|---|
| R3.1 | `lib/refund-policy.ts``calculateRefundAmount(bookingAmount, daysUntilDeparture)` | | `lib/refund-policy.ts` | | R3.1 | `lib/refund-policy.ts``calculateRefundAmount(bookingAmount, daysUntilDeparture)` | | `lib/refund-policy.ts` |
| R3.2 | `refundService.requestUserCancellation(bookingId, userId)` — pakai policy + create Refund PENDING | | `server/services/refund.service.ts` | | R3.2 | `refundService.requestUserCancellation(bookingId, userId)` — pakai policy + create Refund PENDING | | `server/services/refund.service.ts` |
| R3.3 | UI user di trip detail: tombol "Cancel booking" + modal preview refund amount | | `features/booking/components/cancel-booking-button.tsx` | | R3.3 | UI user di trip detail: tombol "Cancel booking" + modal preview refund amount | | `features/booking/components/cancel-booking-button.tsx` |
| R3.4 | Server action `cancelBookingAction` | | `features/booking/actions.ts` | | R3.4 | Server action `cancelBookingAction` | | `features/booking/actions.ts` |
| R3.5 | Display refund policy di trip detail (di blok info atau accordion) — transparency | | [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/page.tsx) | | R3.5 | Display refund policy di trip detail (di blok info atau accordion) — transparency | | [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/page.tsx) |
**Tindakan manual:** **Tindakan manual:**
1. Tulis copy kebijakan refund untuk halaman Terms & Privacy. 1. Tulis copy kebijakan refund untuk halaman Terms & Privacy.
+198
View File
@@ -0,0 +1,198 @@
# 🎨 SeTrip — UI Style Guide
Panduan visual untuk membuat tampilan SeTrip terasa **natural, manusiawi, dan tidak "AI-generated"** — tanpa mengorbankan SEO.
> Prinsip utama: **clean, calm, earthy.** SeTrip itu social-companion platform ("pergi bareng, bukan sendiri"), bukan marketplace booking. UI harus terasa hangat & tenang, bukan ramai & promosi.
---
## 1. Filosofi Desain
| Hindari (kesan AI-generated) | Gunakan (kesan natural) |
| --- | --- |
| ❌ Gradient berlebihan | ✅ Background putih bersih / `neutral-50` |
| ❌ Neumorphism | ✅ Soft green / earthy tone |
| ❌ Glassmorphism ekstrem | ✅ Border tipis 1px + shadow lembut |
| ❌ Icon 3D / emoji sebagai UI icon | ✅ Stroke icon tipis (lucide-react) |
| ❌ Card mengambang dengan blur tebal | ✅ Simple rounded card, datar, jelas |
| ❌ Warna saturasi tinggi di mana-mana | ✅ 1 warna aksen, sisanya netral |
**Tiga kata kunci:** *bersih · tenang · jujur.* Kalau sebuah elemen terasa "ingin pamer", kemungkinan besar perlu disederhanakan.
---
## 2. Warna
Token warna sudah tersedia di [app/globals.css](app/globals.css) — **gunakan token, jangan hardcode hex.**
| Peran | Token | Catatan |
| --- | --- | --- |
| Aksi utama / brand | `primary-600` (#16A34A) | Hijau gunung — earthy, tidak neon |
| Hover aksi utama | `primary-700` | Hindari `primary-500` (terlalu terang) untuk hover tombol |
| Aksen sekunder | `secondary-600` (#0EA5E9) | Pakai hemat — info, link, badge vibe |
| Teks utama | `neutral-800` | |
| Teks sekunder | `neutral-500` | |
| Border | `neutral-200` | Selalu 1px |
| Background halaman | `neutral-50` | |
| Surface / card | `white` | |
### Aturan warna
- **Satu aksen per layar.** Hijau adalah bintangnya. Biru hanya bumbu.
- **Maksimal 1 area gradient per halaman**, dan harus halus (mis. hero). Sisanya warna solid.
- Surface = putih solid. Jangan pakai `bg-white/80 + backdrop-blur` untuk card biasa.
- Earthy tone tambahan diperbolehkan sebagai background section (`primary-50`, `amber-50`) tapi jangan dijadikan blok besar warna-warni.
---
## 3. Sistem Ikon — lucide-react
`lucide-react` sudah terpasang. **Stroke icon = wajah baru SeTrip.**
### Aturan ikon
- **Stroke icon, bukan filled.** Lucide default sudah stroke — jangan ganti `fill`.
- Ukuran konsisten: `16` (inline teks), `20` (tombol/list), `24` (header section).
- Ketebalan stroke seragam: `strokeWidth={1.75}` (default lucide `2` sedikit terlalu tebal untuk gaya clean ini).
- Warna ikut teks: `text-neutral-500` untuk netral, `text-primary-600` untuk aktif.
- **Jangan** beri ikon background bulat berwarna + emoji di dalamnya (pola lama). Cukup ikon polos, atau ikon di atas lingkaran `neutral-100` yang sangat soft bila perlu penekanan.
```tsx
import { Mountain } from "lucide-react";
// inline
<Mountain size={16} strokeWidth={1.75} className="text-neutral-500" />
// di tombol
<Plus size={20} strokeWidth={1.75} />
```
### Pemetaan ikon per fitur
| Fitur | Ikon lucide |
| --- | --- |
| Trip | `Mountain` |
| Group / peserta | `Users` |
| Organizer | `BadgeCheck` |
| Verified | `ShieldCheck` |
| Payment | `Wallet` |
| Meeting Point | `MapPinned` |
| Chat | `MessageCircle` |
| Review / rating | `Star` |
| Profil | `UserRound` |
Saran tambahan yang konsisten dengan set di atas:
| Konteks | Ikon lucide |
| --- | --- |
| Tanggal / jadwal | `CalendarDays` |
| Lokasi umum | `MapPin` |
| Buat trip (FAB & CTA) | `Plus` |
| Cari / filter | `Search`, `SlidersHorizontal` |
| Menu mobile | `Menu` / `X` |
| Kategori (jelajah) | `Compass` |
| Sedang ramai / populer | `Flame` atau `TrendingUp` |
| Harga | `Tag` |
> **Catatan emoji kategori:** `categoryMeta()` di [lib/activity-category.ts](lib/activity-category.ts) masih memakai emoji (🏔️🏕️🤿). Boleh dipertahankan **hanya** di konten data trip (terasa playful & manusiawi di tempat itu), tapi **elemen UI/chrome** (navbar, header section, tombol, badge status) harus pakai stroke icon.
---
## 4. Komponen
### Card
```
✅ rounded-2xl · border border-neutral-200 · bg-white
✅ hover: shadow lembut + translate-y-0.5 (sudah dipakai di TripCard — pertahankan)
❌ jangan: shadow tebal default, blur, gradient border
```
### Tombol
| Jenis | Style |
| --- | --- |
| Primer | `bg-primary-600 hover:bg-primary-700 text-white rounded-xl` |
| Sekunder | `border border-neutral-200 text-neutral-700 hover:bg-neutral-50` |
| Ghost | `text-neutral-600 hover:bg-neutral-100` |
- Shadow tombol seperlunya. `shadow-lg shadow-primary-600/25` boleh untuk **satu** CTA utama per layar, jangan semua tombol.
- `hover:scale-105` cukup untuk CTA hero saja — jangan di semua tombol (terasa "demo template").
- Sertakan ikon lucide bila memperjelas aksi (mis. `Plus` untuk "Buat Trip").
### Badge / pill
- `rounded-full`, teks kecil, warna soft (`primary-50`/`primary-700`).
- Status pakai warna semantik solid lembut, bukan transparan + blur.
### Header section
Pola lama: kotak berwarna + emoji. Pola baru:
```tsx
<div className="flex items-center gap-2.5">
<Compass size={20} strokeWidth={1.75} className="text-primary-600" />
<div>
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">Jelajah per Kategori</h2>
<p className="text-xs text-neutral-500">Hiking, diving, konser, sampai retreat</p>
</div>
</div>
```
---
## 5. Yang Perlu Dirombak di Codebase
Temuan konkret dari kode saat ini:
| Lokasi | Masalah | Aksi |
| --- | --- | --- |
| [app/(public)/page.tsx](app/(public)/page.tsx) | Header section pakai kotak warna + emoji (✨🔥🏔️🤝), badge hero pakai emoji 🤝 | Ganti ke stroke icon (`Compass`, `Flame`, `Mountain`, `Users`) |
| [app/(public)/page.tsx](app/(public)/page.tsx#L110) | Hero gradient 3 warna (`from-primary-900 via-neutral-900 to-secondary-900`) | Sederhanakan jadi overlay solid `neutral-900/80` atau gradient 2 warna halus |
| [app/(public)/page.tsx](app/(public)/page.tsx#L386) | FAB pakai teks `"+"` | Ganti `<Plus size={24} />` |
| [app/(public)/page.tsx](app/(public)/page.tsx#L153) | Stat "100% Seru" terasa filler/AI | Ganti metrik nyata (jumlah peserta, organizer terverifikasi) atau hapus |
| [components/shared/navbar.tsx](components/shared/navbar.tsx#L112) | Hamburger pakai inline SVG manual | Ganti `Menu` / `X` dari lucide |
| [components/shared/navbar.tsx](components/shared/navbar.tsx#L13) | `bg-white/90 backdrop-blur-md` | Boleh dipertahankan (tipis, wajar untuk sticky nav) — jangan ditebalkan |
| [features/trip/components/trip-card.tsx](features/trip/components/trip-card.tsx) | Avatar fallback & meta info bisa diperkuat dengan ikon stroke (`Users`, `CalendarDays`, `MapPin`) | Tambah ikon kecil di baris meta |
Prioritas: **homepage dulu** (paling sering dilihat & paling kuat kesan AI-nya), lalu navbar, lalu komponen kartu.
---
## 6. SEO — Wajib Dijaga
Perubahan visual **tidak boleh** menurunkan SEO. Aturan:
- **Ikon lucide = inline SVG**, ringan & tidak memblokir render. Aman untuk Core Web Vitals.
- **Ikon dekoratif** (hiasan di samping teks) harus `aria-hidden`. Lucide perlu di-set manual:
```tsx
<Mountain size={16} aria-hidden className="text-neutral-500" />
```
- **Ikon yang berdiri sendiri sebagai tombol** (mis. tombol menu) wajib punya label:
```tsx
<button aria-label="Buka menu"><Menu size={20} aria-hidden /></button>
```
- **Jangan ubah teks jadi gambar.** Heading, slogan, deskripsi harus tetap teks HTML.
- **Pertahankan hirarki heading:** satu `<h1>` per halaman, `<h2>` untuk section. Jangan turunkan jadi `<div>` saat merapikan visual.
- **Pertahankan metadata & JSON-LD** di [app/layout.tsx](app/layout.tsx) dan [app/(public)/page.tsx](app/(public)/page.tsx) — structured data, OpenGraph, canonical jangan disentuh saat refactor UI.
- **Komponen tetap Server Component** kalau memungkinkan. Jangan tambah `"use client"` cuma untuk render ikon — lucide jalan di server.
- **Gambar:** terus pakai `next/image` dengan `alt` deskriptif dan `priority` untuk LCP (cover hero & kartu pertama).
- **Kontras warna** minimal AA: stroke icon `neutral-500` di atas putih sudah memenuhi; jangan pakai `neutral-300` untuk ikon/teks penting.
---
## 7. Checklist Implementasi
- [ ] Ganti semua emoji di chrome UI (navbar, header section, tombol, FAB) → stroke icon lucide
- [ ] Standarkan `size` (16/20/24) & `strokeWidth={1.75}` di seluruh ikon
- [ ] Sederhanakan gradient hero homepage jadi maksimal 2 warna / overlay solid
- [ ] Ganti hamburger SVG manual di navbar → `Menu`/`X`
- [ ] Tinjau metrik "100% Seru" — ganti angka nyata atau hapus
- [ ] Pastikan ikon dekoratif `aria-hidden`, ikon-tombol punya `aria-label`
- [ ] Pastikan struktur heading `h1`/`h2` tetap utuh setelah refactor
- [ ] Jalankan Lighthouse — skor SEO & Accessibility tidak turun
- [ ] Verifikasi tidak ada `"use client"` baru yang ditambahkan hanya demi ikon
---
*Acuan token: [app/globals.css](app/globals.css) · Acuan brand: [lib/site.ts](lib/site.ts)*
+14 -6
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { Lock, Clock, CircleAlert } from "lucide-react";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { organizerService } from "@/server/services/organizer.service"; import { organizerService } from "@/server/services/organizer.service";
@@ -11,8 +12,13 @@ export default async function CreateTripPage() {
return ( return (
<div className="flex min-h-[calc(100vh-3.5rem)] items-center justify-center px-4"> <div className="flex min-h-[calc(100vh-3.5rem)] items-center justify-center px-4">
<div className="text-center"> <div className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary-50 text-3xl"> <div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary-50">
🔒 <Lock
size={28}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
</div> </div>
<p className="mb-4 text-neutral-500"> <p className="mb-4 text-neutral-500">
Kamu harus login untuk membuat trip. Kamu harus login untuk membuat trip.
@@ -57,8 +63,9 @@ function VerificationBanner({
if (status === "PENDING") { if (status === "PENDING") {
return ( return (
<div className="mb-5 rounded-2xl border border-amber-200 bg-amber-50 p-4 sm:p-5"> <div className="mb-5 rounded-2xl border border-amber-200 bg-amber-50 p-4 sm:p-5">
<p className="text-sm font-bold text-amber-800"> <p className="flex items-center gap-1.5 text-sm font-bold text-amber-800">
Verifikasi sedang diproses <Clock size={15} strokeWidth={2} aria-hidden />
Verifikasi sedang diproses
</p> </p>
<p className="mt-1 text-sm text-neutral-700"> <p className="mt-1 text-sm text-neutral-700">
Pengajuan verifikasi-mu masih ditinjau admin. Sementara menunggu, kamu Pengajuan verifikasi-mu masih ditinjau admin. Sementara menunggu, kamu
@@ -73,8 +80,9 @@ function VerificationBanner({
<div className="mb-5 rounded-2xl border border-amber-200 bg-amber-50 p-4 sm:p-5"> <div className="mb-5 rounded-2xl border border-amber-200 bg-amber-50 p-4 sm:p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex-1"> <div className="flex-1">
<p className="text-sm font-bold text-amber-800"> <p className="flex items-center gap-1.5 text-sm font-bold text-amber-800">
{isRejected ? "Verifikasi ditolak" : "Belum terverifikasi"} <CircleAlert size={15} strokeWidth={2} aria-hidden />
{isRejected ? "Verifikasi ditolak" : "Belum terverifikasi"}
</p> </p>
<p className="mt-1 text-sm text-neutral-700"> <p className="mt-1 text-sm text-neutral-700">
{isRejected {isRejected
+17
View File
@@ -0,0 +1,17 @@
/**
* Skeleton generik untuk route group `(public)` — fallback streaming bagi
* halaman yang tidak punya `loading.tsx` sendiri (beranda, profil, dll).
*/
export default function Loading() {
return (
<div className="mx-auto max-w-5xl px-4 py-10">
<div className="h-8 w-1/2 animate-pulse rounded-xl bg-neutral-200" />
<div className="mt-4 space-y-3">
<div className="h-4 w-full animate-pulse rounded bg-neutral-100" />
<div className="h-4 w-5/6 animate-pulse rounded bg-neutral-100" />
<div className="h-4 w-2/3 animate-pulse rounded bg-neutral-100" />
</div>
<div className="mt-8 h-64 animate-pulse rounded-2xl bg-neutral-100" />
</div>
);
}
+13 -1
View File
@@ -1,4 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Masuk", title: "Masuk",
@@ -8,6 +11,15 @@ export const metadata: Metadata = {
robots: { index: false, follow: true }, robots: { index: false, follow: true },
}; };
export default function LoginLayout({ children }: { children: React.ReactNode }) { export default async function LoginLayout({
children,
}: {
children: React.ReactNode;
}) {
// User yang sudah login tidak boleh mengakses halaman login lagi.
const session = await getServerSession(authOptions);
if (session?.user) {
redirect(session.user.isAdmin ? "/admin" : "/");
}
return children; return children;
} }
+11 -8
View File
@@ -38,13 +38,16 @@ function LoginForm() {
if (result?.error) { if (result?.error) {
setError(result.error); setError(result.error);
} else { } else {
const rawCallback = searchParams.get("callbackUrl"); const callbackPath = safeInternalPath(searchParams.get("callbackUrl"));
let next = safeInternalPath(rawCallback); const session = await getSession();
// Tanpa callbackUrl eksplisit, arahkan admin ke dashboard /admin. // Admin selalu diarahkan ke dashboard /admin setelah login — kecuali
if (!rawCallback) { // callbackUrl memang menuju sub-halaman admin (deep link dari /admin/...).
const session = await getSession(); // callbackUrl non-admin (mis. "/" sisa dari percobaan login Google) tidak
if (session?.user?.isAdmin) next = "/admin"; // boleh membuat admin "nyangkut" di halaman publik.
} const next =
session?.user?.isAdmin && !callbackPath.startsWith("/admin")
? "/admin"
: callbackPath;
router.push(next); router.push(next);
router.refresh(); router.refresh();
} }
@@ -84,7 +87,7 @@ function LoginForm() {
</div> </div>
{/* Card */} {/* Card */}
<div className="rounded-2xl border border-white/10 bg-white/95 p-6 shadow-2xl backdrop-blur-sm"> <div className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-2xl">
{error && ( {error && (
<div className="mb-4 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600"> <div className="mb-4 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600">
{error} {error}
+100 -69
View File
@@ -8,6 +8,15 @@ import { profileRepo } from "@/server/repositories/profile.repo";
import { TripCard } from "@/features/trip/components/trip-card"; import { TripCard } from "@/features/trip/components/trip-card";
import { siteConfig, siteUrl, absoluteUrl } from "@/lib/site"; import { siteConfig, siteUrl, absoluteUrl } from "@/lib/site";
import { ACTIVITY_CATEGORIES, categoryMeta } from "@/lib/activity-category"; import { ACTIVITY_CATEGORIES, categoryMeta } from "@/lib/activity-category";
import {
Compass,
Flame,
Mountain,
Handshake,
Tent,
Plus,
type LucideIcon,
} from "lucide-react";
type OpenTrip = Awaited<ReturnType<typeof tripService.getOpenTrips>>[number]; type OpenTrip = Awaited<ReturnType<typeof tripService.getOpenTrips>>[number];
@@ -44,6 +53,9 @@ export default async function HomePage() {
const now = new Date(); const now = new Date();
const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
// Social proof: total orang yang sudah gabung di seluruh open trip.
const joinerCount = trips.reduce((sum, t) => sum + t._count.participants, 0);
const upcomingTrips = trips const upcomingTrips = trips
.filter((t) => new Date(t.date) <= nextWeek) .filter((t) => new Date(t.date) <= nextWeek)
.slice(0, 3); .slice(0, 3);
@@ -107,12 +119,17 @@ export default async function HomePage() {
className="object-cover opacity-10 brightness-150" className="object-cover opacity-10 brightness-150"
priority priority
/> />
<div className="absolute inset-0 bg-linear-to-br from-primary-900/85 via-neutral-900/75 to-secondary-900/80" /> <div className="absolute inset-0 bg-linear-to-br from-neutral-900/90 to-primary-900/80" />
<div className="relative mx-auto max-w-4xl px-4 pb-10 pt-8 text-center sm:pb-14 sm:pt-12 lg:pb-16 lg:pt-14"> <div className="relative mx-auto max-w-4xl px-4 pb-10 pt-8 text-center sm:pb-14 sm:pt-12 lg:pb-16 lg:pt-14">
{/* Brand badge */} {/* Brand badge */}
<div className="mb-4 inline-flex items-center gap-1.5 rounded-full border border-primary-400/30 bg-primary-600/20 px-3 py-1 sm:mb-6 sm:gap-2 sm:px-4 sm:py-1.5"> <div className="mb-4 inline-flex items-center gap-1.5 rounded-full border border-primary-400/30 bg-primary-600/20 px-3 py-1 sm:mb-6 sm:gap-2 sm:px-4 sm:py-1.5">
<span className="text-xs sm:text-sm">🤝</span> <Handshake
size={14}
strokeWidth={1.75}
aria-hidden
className="text-primary-300"
/>
<span className="text-xs font-medium text-primary-300 sm:text-sm"> <span className="text-xs font-medium text-primary-300 sm:text-sm">
Cari teman trip & aktivitas Cari teman trip & aktivitas
</span> </span>
@@ -150,8 +167,12 @@ export default async function HomePage() {
</div> </div>
<div className="h-8 w-px bg-neutral-700 sm:h-10" /> <div className="h-8 w-px bg-neutral-700 sm:h-10" />
<div> <div>
<p className="text-xl font-bold text-white sm:text-2xl">100%</p> <p className="text-xl font-bold text-white sm:text-2xl">
<p className="text-[11px] text-neutral-400 sm:text-xs">Seru</p> {joinerCount}
</p>
<p className="text-[11px] text-neutral-400 sm:text-xs">
Sudah Gabung
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -161,19 +182,11 @@ export default async function HomePage() {
<div className="mx-auto max-w-6xl px-4 py-6 space-y-8 sm:py-8 sm:space-y-10 lg:py-10 lg:space-y-12"> <div className="mx-auto max-w-6xl px-4 py-6 space-y-8 sm:py-8 sm:space-y-10 lg:py-10 lg:space-y-12">
{/* Jelajah per kategori */} {/* Jelajah per kategori */}
<section> <section>
<div className="mb-4 flex items-center gap-3 sm:mb-5"> <SectionHeading
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-secondary-100 text-base sm:h-9 sm:w-9 sm:text-lg"> icon={Compass}
title="Jelajah per Kategori"
</div> subtitle="Hiking, diving, konser, sampai retreat"
<div> />
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
Jelajah per Kategori
</h2>
<p className="text-[11px] text-neutral-500 sm:text-xs">
Hiking, diving, konser, sampai retreat
</p>
</div>
</div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{ACTIVITY_CATEGORIES.map((c) => { {ACTIVITY_CATEGORIES.map((c) => {
const m = categoryMeta(c); const m = categoryMeta(c);
@@ -194,19 +207,11 @@ export default async function HomePage() {
{/* Trip Terdekat */} {/* Trip Terdekat */}
{upcomingTrips.length > 0 && ( {upcomingTrips.length > 0 && (
<section> <section>
<div className="mb-4 flex items-center gap-3 sm:mb-5"> <SectionHeading
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-100 text-base sm:h-9 sm:w-9 sm:text-lg"> icon={Flame}
🔥 title="Trip Terdekat"
</div> subtitle="Berangkat dalam 7 hari ke depan"
<div> />
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
Trip Terdekat
</h2>
<p className="text-[11px] text-neutral-500 sm:text-xs">
Berangkat dalam 7 hari ke depan
</p>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{upcomingTrips.slice(0, 3).map((trip, i) => ( {upcomingTrips.slice(0, 3).map((trip, i) => (
<TripCard <TripCard
@@ -239,32 +244,29 @@ export default async function HomePage() {
{/* Open Trip */} {/* Open Trip */}
<section> <section>
<div className="mb-4 flex items-center justify-between sm:mb-5"> <SectionHeading
<div className="flex items-center gap-3"> icon={Mountain}
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-secondary-100 text-base sm:h-9 sm:w-9 sm:text-lg"> title="Open Trip"
🏔 subtitle="Pilih trip, ketemu teman baru"
</div> action={
<div> <Link
<h2 className="text-base font-bold text-neutral-800 sm:text-lg"> href="/trips"
Open Trip className="shrink-0 rounded-lg bg-secondary-50 px-2.5 py-1 text-xs font-medium text-secondary-600 hover:bg-secondary-100 sm:px-3 sm:py-1.5 sm:text-sm"
</h2> >
<p className="hidden text-xs text-neutral-500 sm:block"> Lihat semua
Pilih trip, ketemu teman baru </Link>
</p> }
</div> />
</div>
<Link
href="/trips"
className="rounded-lg bg-secondary-50 px-2.5 py-1 text-xs font-medium text-secondary-600 hover:bg-secondary-100 sm:px-3 sm:py-1.5 sm:text-sm"
>
Lihat semua
</Link>
</div>
{latestTrips.length === 0 ? ( {latestTrips.length === 0 ? (
<div className="rounded-2xl border-2 border-dashed border-neutral-200 bg-white p-8 text-center sm:p-14"> <div className="rounded-2xl border-2 border-dashed border-neutral-200 bg-white p-8 text-center sm:p-14">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 text-2xl sm:h-16 sm:w-16 sm:text-3xl"> <div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 sm:h-16 sm:w-16">
🏕 <Tent
size={26}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
</div> </div>
<p className="mb-1 text-base font-bold text-neutral-800 sm:text-lg"> <p className="mb-1 text-base font-bold text-neutral-800 sm:text-lg">
Belum ada trip tersedia Belum ada trip tersedia
@@ -312,19 +314,11 @@ export default async function HomePage() {
{/* Lagi Ramai — social proof, bukan price proof */} {/* Lagi Ramai — social proof, bukan price proof */}
{buzzingTrips.length > 0 && ( {buzzingTrips.length > 0 && (
<section> <section>
<div className="mb-4 flex items-center gap-3 sm:mb-5"> <SectionHeading
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-100 text-base sm:h-9 sm:w-9 sm:text-lg"> icon={Handshake}
🤝 title="Lagi Ramai"
</div> subtitle="Banyak yang sudah gabung — kamu nggak bakal jalan sendirian"
<div> />
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
Lagi Ramai
</h2>
<p className="text-[11px] text-neutral-500 sm:text-xs">
Banyak yang sudah gabung kamu nggak bakal jalan sendirian
</p>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{buzzingTrips.map((trip) => ( {buzzingTrips.map((trip) => (
<TripCard <TripCard
@@ -383,11 +377,48 @@ export default async function HomePage() {
{/* ========== FAB ========== */} {/* ========== FAB ========== */}
<Link <Link
href="/create-trip" href="/create-trip"
className="fixed bottom-4 right-4 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-primary-600 text-xl font-bold text-white shadow-xl shadow-primary-600/30 transition-all hover:scale-110 hover:bg-primary-500 active:scale-95 sm:bottom-6 sm:right-6 sm:h-14 sm:w-14 sm:text-2xl" className="fixed bottom-4 right-4 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-primary-600 text-white shadow-xl shadow-primary-600/30 transition-all hover:scale-110 hover:bg-primary-500 active:scale-95 sm:bottom-6 sm:right-6 sm:h-14 sm:w-14"
title="Buat Trip" aria-label="Buat Trip"
> >
+ <Plus size={24} strokeWidth={2} aria-hidden />
</Link> </Link>
</div> </div>
); );
} }
/** Heading section homepage — ikon stroke + judul, opsional aksi di kanan. */
function SectionHeading({
icon: Icon,
title,
subtitle,
action,
}: {
icon: LucideIcon;
title: string;
subtitle?: string;
action?: React.ReactNode;
}) {
return (
<div className="mb-4 flex items-center justify-between gap-3 sm:mb-5">
<div className="flex items-center gap-2.5">
<Icon
size={22}
strokeWidth={1.75}
aria-hidden
className="shrink-0 text-primary-600"
/>
<div>
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
{title}
</h2>
{subtitle && (
<p className="text-[11px] text-neutral-500 sm:text-xs">
{subtitle}
</p>
)}
</div>
</div>
{action}
</div>
);
}
+8 -2
View File
@@ -5,6 +5,7 @@ import { UserCard } from "@/features/profile/components/user-card";
import { PeopleFilter } from "@/features/profile/components/people-filter"; import { PeopleFilter } from "@/features/profile/components/people-filter";
import { isVibe, vibeLabel } from "@/lib/vibe"; import { isVibe, vibeLabel } from "@/lib/vibe";
import { siteConfig } from "@/lib/site"; import { siteConfig } from "@/lib/site";
import { Users } from "lucide-react";
interface PeoplePageProps { interface PeoplePageProps {
searchParams: Promise<{ searchParams: Promise<{
@@ -68,8 +69,13 @@ export default async function PeoplePage({ searchParams }: PeoplePageProps) {
{people.length === 0 ? ( {people.length === 0 ? (
<div className="rounded-2xl border-2 border-dashed border-neutral-200 bg-white p-8 text-center sm:p-14"> <div className="rounded-2xl border-2 border-dashed border-neutral-200 bg-white p-8 text-center sm:p-14">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 text-2xl sm:h-16 sm:w-16 sm:text-3xl"> <div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 sm:h-16 sm:w-16">
🔍 <Users
size={26}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
</div> </div>
<p className="mb-1 text-base font-bold text-neutral-800 sm:text-lg"> <p className="mb-1 text-base font-bold text-neutral-800 sm:text-lg">
{hasFilters {hasFilters
+18 -3
View File
@@ -1,12 +1,19 @@
import Link from "next/link"; import Link from "next/link";
import { ShieldCheck, CircleCheck } from "lucide-react";
export default function PrivacyPage() { export default function PrivacyPage() {
return ( return (
<div className="mx-auto max-w-3xl px-4 py-8 sm:py-12"> <div className="mx-auto max-w-3xl px-4 py-8 sm:py-12">
<article className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm sm:p-10"> <article className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm sm:p-10">
<header className="mb-8 border-b border-neutral-200 pb-6"> <header className="mb-8 border-b border-neutral-200 pb-6">
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl"> <h1 className="flex items-center gap-2 text-2xl font-bold text-neutral-900 sm:text-3xl">
🔒 Kebijakan Privasi SeTrip <ShieldCheck
size={28}
strokeWidth={1.75}
aria-hidden
className="shrink-0 text-primary-600"
/>
Kebijakan Privasi SeTrip
</h1> </h1>
<p className="mt-2 text-sm text-neutral-500"> <p className="mt-2 text-sm text-neutral-500">
Terakhir diperbarui: 2026-04-27 Terakhir diperbarui: 2026-04-27
@@ -205,7 +212,15 @@ export default function PrivacyPage() {
</section> </section>
<section className="rounded-xl bg-neutral-50 p-5"> <section className="rounded-xl bg-neutral-50 p-5">
<h2 className="mb-2 text-lg font-bold text-neutral-900"> Persetujuan</h2> <h2 className="mb-2 flex items-center gap-1.5 text-lg font-bold text-neutral-900">
<CircleCheck
size={18}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
Persetujuan
</h2>
<p className="mb-2"> <p className="mb-2">
Dengan menggunakan SeTrip, Anda menyatakan bahwa: Dengan menggunakan SeTrip, Anda menyatakan bahwa:
</p> </p>
+8 -5
View File
@@ -10,6 +10,7 @@ import { TripCard } from "@/features/trip/components/trip-card";
import { ProfileTripRow } from "@/features/profile/components/profile-trip-row"; import { ProfileTripRow } from "@/features/profile/components/profile-trip-row";
import { ProfileEditor } from "@/features/profile/components/profile-editor"; import { ProfileEditor } from "@/features/profile/components/profile-editor";
import { EarningsSection } from "@/features/payout/components/earnings-section"; import { EarningsSection } from "@/features/payout/components/earnings-section";
import { Plus, ChevronRight } from "lucide-react";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Profil Saya", title: "Profil Saya",
@@ -81,9 +82,10 @@ export default async function ProfilePage() {
</div> </div>
<Link <Link
href="/create-trip" href="/create-trip"
className="shrink-0 rounded-xl bg-primary-600 px-4 py-2.5 text-center text-sm font-semibold text-white shadow-md hover:bg-primary-700" className="inline-flex shrink-0 items-center justify-center gap-1.5 rounded-xl bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white shadow-md hover:bg-primary-700"
> >
+ Buat trip <Plus size={16} strokeWidth={2} aria-hidden />
Buat trip
</Link> </Link>
</div> </div>
@@ -133,13 +135,14 @@ export default async function ProfilePage() {
endDate={t.endDate} endDate={t.endDate}
rightSlot={ rightSlot={
<span <span
className={ className={`inline-flex items-center gap-0.5 ${
hasReview hasReview
? "text-secondary-700" ? "text-secondary-700"
: "font-bold text-amber-800" : "font-bold text-amber-800"
} }`}
> >
{hasReview ? "Ubah ulasan" : "Beri ulasan"} {hasReview ? "Ubah ulasan" : "Beri ulasan"}
<ChevronRight size={14} strokeWidth={2} aria-hidden />
</span> </span>
} }
/> />
+13 -1
View File
@@ -1,4 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Daftar Akun", title: "Daftar Akun",
@@ -7,6 +10,15 @@ export const metadata: Metadata = {
alternates: { canonical: "/register" }, alternates: { canonical: "/register" },
}; };
export default function RegisterLayout({ children }: { children: React.ReactNode }) { export default async function RegisterLayout({
children,
}: {
children: React.ReactNode;
}) {
// User yang sudah login tidak boleh mengakses halaman daftar lagi.
const session = await getServerSession(authOptions);
if (session?.user) {
redirect(session.user.isAdmin ? "/admin" : "/");
}
return children; return children;
} }
+1 -1
View File
@@ -77,7 +77,7 @@ export default function RegisterPage() {
</div> </div>
{/* Card */} {/* Card */}
<div className="rounded-2xl border border-white/10 bg-white/95 p-6 shadow-2xl backdrop-blur-sm"> <div className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-2xl">
{error && ( {error && (
<div className="mb-4 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600"> <div className="mb-4 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600">
{error} {error}
+18 -3
View File
@@ -1,12 +1,19 @@
import Link from "next/link"; import Link from "next/link";
import { FileText, CircleCheck } from "lucide-react";
export default function TermsPage() { export default function TermsPage() {
return ( return (
<div className="mx-auto max-w-3xl px-4 py-8 sm:py-12"> <div className="mx-auto max-w-3xl px-4 py-8 sm:py-12">
<article className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm sm:p-10"> <article className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm sm:p-10">
<header className="mb-8 border-b border-neutral-200 pb-6"> <header className="mb-8 border-b border-neutral-200 pb-6">
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl"> <h1 className="flex items-center gap-2 text-2xl font-bold text-neutral-900 sm:text-3xl">
📜 Syarat &amp; Ketentuan SeTrip <FileText
size={28}
strokeWidth={1.75}
aria-hidden
className="shrink-0 text-primary-600"
/>
Syarat &amp; Ketentuan SeTrip
</h1> </h1>
<p className="mt-2 text-sm text-neutral-500"> <p className="mt-2 text-sm text-neutral-500">
Terakhir diperbarui: 2026-04-27 Terakhir diperbarui: 2026-04-27
@@ -262,7 +269,15 @@ export default function TermsPage() {
</section> </section>
<section className="rounded-xl bg-neutral-50 p-5"> <section className="rounded-xl bg-neutral-50 p-5">
<h2 className="mb-2 text-lg font-bold text-neutral-900"> Persetujuan</h2> <h2 className="mb-2 flex items-center gap-1.5 text-lg font-bold text-neutral-900">
<CircleCheck
size={18}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
Persetujuan
</h2>
<p className="mb-2"> <p className="mb-2">
Dengan menggunakan SeTrip, Anda menyatakan bahwa: Dengan menggunakan SeTrip, Anda menyatakan bahwa:
</p> </p>
+31
View File
@@ -0,0 +1,31 @@
/** Skeleton halaman detail trip — tampil instan saat data masih di-fetch. */
export default function Loading() {
return (
<div className="mx-auto max-w-3xl px-4 py-4 sm:py-8">
<div className="mb-3 h-4 w-40 animate-pulse rounded bg-neutral-200 sm:mb-4" />
<div className="overflow-hidden rounded-2xl border border-neutral-200 bg-white shadow-sm">
<div className="h-44 animate-pulse bg-neutral-200 sm:h-56 lg:h-72" />
<div className="border-b border-neutral-100 px-4 py-4 sm:px-6">
<div className="h-6 w-2/3 animate-pulse rounded bg-neutral-200" />
<div className="mt-2 h-4 w-1/3 animate-pulse rounded bg-neutral-100" />
</div>
<div className="space-y-5 p-4 sm:space-y-6 sm:p-6">
<div className="grid grid-cols-2 gap-2 sm:gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="h-16 animate-pulse rounded-xl bg-neutral-100 sm:h-[72px]"
/>
))}
</div>
<div className="h-24 animate-pulse rounded-xl bg-neutral-100" />
<div className="h-32 animate-pulse rounded-xl bg-neutral-100" />
<div className="h-12 animate-pulse rounded-xl bg-neutral-200" />
</div>
</div>
</div>
);
}
+10 -2
View File
@@ -2,7 +2,7 @@ import { ImageResponse } from "next/og";
import { tripService } from "@/server/services/trip.service"; import { tripService } from "@/server/services/trip.service";
import { formatRupiah } from "@/lib/utils"; import { formatRupiah } from "@/lib/utils";
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates"; import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
import { siteConfig } from "@/lib/site"; import { siteConfig, siteUrl } from "@/lib/site";
export const alt = `${siteConfig.name} — Open Trip & Aktivitas Bareng`; export const alt = `${siteConfig.name} — Open Trip & Aktivitas Bareng`;
export const size = { width: 1200, height: 630 }; export const size = { width: 1200, height: 630 };
@@ -43,7 +43,15 @@ export default async function TripOgImage({
); );
} }
const cover = trip.images[0]?.url; // Satori (ImageResponse) mem-fetch gambar server-side dan butuh URL absolut.
// Foto trip baru disimpan sebagai path relatif `/api/trip-images/...` —
// prefix dengan origin. Foto lama (URL eksternal absolut) dipakai apa adanya.
const coverRaw = trip.images[0]?.url;
const cover = coverRaw
? coverRaw.startsWith("http")
? coverRaw
: `${siteUrl}${coverRaw}`
: undefined;
const dateLabel = formatTripCalendarDateRangeLong(trip.date, trip.endDate); const dateLabel = formatTripCalendarDateRangeLong(trip.date, trip.endDate);
const price = formatRupiah(trip.price); const price = formatRupiah(trip.price);
+56 -15
View File
@@ -28,6 +28,14 @@ import {
isTripDepartureDayPast, isTripDepartureDayPast,
} from "@/lib/trip-dates"; } from "@/lib/trip-dates";
import { previewRefund } from "@/lib/refund-policy"; import { previewRefund } from "@/lib/refund-policy";
import {
MapPin,
CalendarDays,
Wallet,
UserRound,
Zap,
Users,
} from "lucide-react";
export async function generateMetadata({ export async function generateMetadata({
params, params,
@@ -309,8 +317,13 @@ export default async function TripDetailPage({
{/* Info Grid */} {/* Info Grid */}
<div className="grid grid-cols-2 gap-2 sm:gap-3"> <div className="grid grid-cols-2 gap-2 sm:gap-3">
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4"> <div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-secondary-100 text-sm sm:h-10 sm:w-10 sm:text-lg"> <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-secondary-100 sm:h-10 sm:w-10">
📍 <MapPin
size={18}
strokeWidth={1.75}
aria-hidden
className="text-secondary-700"
/>
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Lokasi</p> <p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Lokasi</p>
@@ -319,8 +332,13 @@ export default async function TripDetailPage({
</div> </div>
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4"> <div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-secondary-100 text-sm sm:h-10 sm:w-10 sm:text-lg"> <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-secondary-100 sm:h-10 sm:w-10">
📅 <CalendarDays
size={18}
strokeWidth={1.75}
aria-hidden
className="text-secondary-700"
/>
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Tanggal</p> <p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Tanggal</p>
@@ -331,8 +349,13 @@ export default async function TripDetailPage({
</div> </div>
<div className="flex items-center gap-2 rounded-xl bg-primary-50 p-3 sm:gap-3 sm:p-4"> <div className="flex items-center gap-2 rounded-xl bg-primary-50 p-3 sm:gap-3 sm:p-4">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary-100 text-sm sm:h-10 sm:w-10 sm:text-lg"> <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary-100 sm:h-10 sm:w-10">
💰 <Wallet
size={18}
strokeWidth={1.75}
aria-hidden
className="text-primary-700"
/>
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-[10px] font-medium text-primary-600 sm:text-xs">Harga</p> <p className="text-[10px] font-medium text-primary-600 sm:text-xs">Harga</p>
@@ -343,8 +366,13 @@ export default async function TripDetailPage({
</div> </div>
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4"> <div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-200 text-sm sm:h-10 sm:w-10 sm:text-lg"> <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-200 sm:h-10 sm:w-10">
👤 <UserRound
size={18}
strokeWidth={1.75}
aria-hidden
className="text-neutral-600"
/>
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Organizer</p> <p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Organizer</p>
@@ -372,8 +400,9 @@ export default async function TripDetailPage({
Peserta Peserta
</span> </span>
{spotsLeft > 0 && spotsLeft <= 3 && ( {spotsLeft > 0 && spotsLeft <= 3 && (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-bold text-amber-800 sm:text-[11px]"> <span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-bold text-amber-800 sm:text-[11px]">
Tinggal {spotsLeft} spot! <Zap size={11} strokeWidth={2} aria-hidden />
Tinggal {spotsLeft} spot!
</span> </span>
)} )}
{spotsLeft <= 0 && ( {spotsLeft <= 0 && (
@@ -418,8 +447,14 @@ export default async function TripDetailPage({
)} )}
</p> </p>
{confirmedCount > 0 && ( {confirmedCount > 0 && (
<p className="mt-2 text-[11px] text-neutral-600 sm:text-xs"> <p className="mt-2 flex flex-wrap items-center gap-x-1 gap-y-0.5 text-[11px] text-neutral-600 sm:text-xs">
<span aria-hidden>👥</span> Sudah join:{" "} <Users
size={13}
strokeWidth={1.75}
aria-hidden
className="text-neutral-400"
/>
Sudah join:{" "}
<span className="font-medium text-neutral-800"> <span className="font-medium text-neutral-800">
{confirmedParticipants {confirmedParticipants
.slice(0, 3) .slice(0, 3)
@@ -547,7 +582,7 @@ export default async function TripDetailPage({
Belum ada peserta yang dikonfirmasi.{" "} Belum ada peserta yang dikonfirmasi.{" "}
{pendingParticipants.length > 0 {pendingParticipants.length > 0
? "Cek permintaan join di atas untuk menyetujui peserta." ? "Cek permintaan join di atas untuk menyetujui peserta."
: "Jadilah yang pertama mendaftar! 🎒"} : "Jadilah yang pertama mendaftar!"}
</p> </p>
) : ( ) : (
<ul className="grid gap-2 sm:grid-cols-2"> <ul className="grid gap-2 sm:grid-cols-2">
@@ -578,8 +613,14 @@ export default async function TripDetailPage({
{p.user.name} {p.user.name}
</p> </p>
{city && ( {city && (
<p className="truncate text-[11px] text-neutral-500 sm:text-xs"> <p className="flex items-center gap-1 truncate text-[11px] text-neutral-500 sm:text-xs">
📍 {city} <MapPin
size={11}
strokeWidth={1.75}
aria-hidden
className="shrink-0"
/>
{city}
</p> </p>
)} )}
{interests.length > 0 && ( {interests.length > 0 && (
+69 -16
View File
@@ -11,6 +11,15 @@ import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
import { isFreeTrip } from "@/lib/trip-pricing"; import { isFreeTrip } from "@/lib/trip-pricing";
import { categoryMeta } from "@/lib/activity-category"; import { categoryMeta } from "@/lib/activity-category";
import { MidtransPayButton } from "@/features/booking/components/midtrans-pay-button"; import { MidtransPayButton } from "@/features/booking/components/midtrans-pay-button";
import {
ArrowLeft,
CalendarDays,
MapPin,
PartyPopper,
CircleCheck,
Clock,
Check,
} from "lucide-react";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Detail Pembayaran", title: "Detail Pembayaran",
@@ -82,8 +91,15 @@ export default async function PaymentPage({ params, searchParams }: PageProps) {
<h1 className="mt-0.5 truncate text-base font-bold text-neutral-900 sm:text-lg"> <h1 className="mt-0.5 truncate text-base font-bold text-neutral-900 sm:text-lg">
{trip.title} {trip.title}
</h1> </h1>
<p className="mt-1 text-xs text-neutral-500 sm:text-sm"> <p className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-neutral-500 sm:text-sm">
📅 {dateRange} · 📍 {trip.location} <span className="inline-flex items-center gap-1">
<CalendarDays size={13} strokeWidth={1.75} aria-hidden />
{dateRange}
</span>
<span className="inline-flex items-center gap-1">
<MapPin size={13} strokeWidth={1.75} aria-hidden />
{trip.location}
</span>
</p> </p>
<p className="mt-1 text-xs text-neutral-500 sm:text-sm"> <p className="mt-1 text-xs text-neutral-500 sm:text-sm">
Organizer:{" "} Organizer:{" "}
@@ -102,8 +118,12 @@ export default async function PaymentPage({ params, searchParams }: PageProps) {
return ( return (
<div className="mx-auto max-w-2xl px-4 py-6 sm:py-10"> <div className="mx-auto max-w-2xl px-4 py-6 sm:py-10">
<div className="mb-4 flex items-center gap-2 text-xs text-neutral-500 sm:text-sm"> <div className="mb-4 flex items-center gap-2 text-xs text-neutral-500 sm:text-sm">
<Link href={`/trips/${trip.id}`} className="hover:text-primary-600"> <Link
Kembali ke trip href={`/trips/${trip.id}`}
className="inline-flex items-center gap-1 hover:text-primary-600"
>
<ArrowLeft size={14} strokeWidth={1.75} aria-hidden />
Kembali ke trip
</Link> </Link>
</div> </div>
@@ -170,8 +190,13 @@ function FreeTripSection({
}) { }) {
return ( return (
<section className="rounded-2xl border border-emerald-200 bg-emerald-50/60 p-6 text-center shadow-sm sm:p-8"> <section className="rounded-2xl border border-emerald-200 bg-emerald-50/60 p-6 text-center shadow-sm sm:p-8">
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-emerald-100 text-2xl"> <div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-emerald-100">
🎉 <PartyPopper
size={26}
strokeWidth={1.75}
aria-hidden
className="text-emerald-600"
/>
</div> </div>
<h2 className="mb-1 text-lg font-bold text-emerald-900 sm:text-xl"> <h2 className="mb-1 text-lg font-bold text-emerald-900 sm:text-xl">
Trip ini gratis Trip ini gratis
@@ -184,10 +209,28 @@ function FreeTripSection({
<p className="text-[11px] font-semibold uppercase tracking-wide text-emerald-700"> <p className="text-[11px] font-semibold uppercase tracking-wide text-emerald-700">
Status keikutsertaan Status keikutsertaan
</p> </p>
<p className="text-sm font-bold text-neutral-800"> <p className="flex items-center gap-1.5 text-sm font-bold text-neutral-800">
{bookingStatus === "PAID" {bookingStatus === "PAID" ? (
? "✅ Terkonfirmasi sebagai peserta" <>
: "⏳ Menunggu persetujuan organizer"} <CircleCheck
size={15}
strokeWidth={2}
aria-hidden
className="text-emerald-600"
/>
Terkonfirmasi sebagai peserta
</>
) : (
<>
<Clock
size={15}
strokeWidth={2}
aria-hidden
className="text-amber-600"
/>
Menunggu persetujuan organizer
</>
)}
</p> </p>
</div> </div>
@@ -250,10 +293,15 @@ function PaidTripSection({
{canPay && <MidtransPayButton tripId={tripId} />} {canPay && <MidtransPayButton tripId={tripId} />}
{isFullyPaid && ( {isFullyPaid && (
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900 sm:p-5"> <div className="flex items-start gap-2 rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900 sm:p-5">
<CircleCheck
size={16}
strokeWidth={2}
aria-hidden
className="mt-0.5 shrink-0 text-emerald-600"
/>
<p> <p>
Pembayaran kamu sudah terkonfirmasi. Sampai jumpa di trip Pembayaran kamu sudah terkonfirmasi. Sampai jumpa di trip bareng{" "}
bareng{" "}
<span className="font-semibold">{organizerName}</span>! <span className="font-semibold">{organizerName}</span>!
</p> </p>
</div> </div>
@@ -262,9 +310,10 @@ function PaidTripSection({
<div className="text-center"> <div className="text-center">
<Link <Link
href={`/trips/${tripId}`} href={`/trips/${tripId}`}
className="text-sm text-neutral-500 hover:text-primary-600" className="inline-flex items-center gap-1 text-sm text-neutral-500 hover:text-primary-600"
> >
Kembali ke detail trip <ArrowLeft size={14} strokeWidth={1.75} aria-hidden />
Kembali ke detail trip
</Link> </Link>
</div> </div>
</div> </div>
@@ -298,7 +347,11 @@ function PaymentTimeline({
: "bg-neutral-200 text-neutral-500" : "bg-neutral-200 text-neutral-500"
}`} }`}
> >
{s.done ? "✓" : i + 1} {s.done ? (
<Check size={12} strokeWidth={3} aria-hidden />
) : (
i + 1
)}
</span> </span>
<span <span
className={`text-sm ${ className={`text-sm ${
+29
View File
@@ -0,0 +1,29 @@
/** Skeleton daftar trip — tampil instan saat list masih di-fetch. */
export default function Loading() {
return (
<div className="mx-auto max-w-6xl px-4 py-6 sm:py-8">
<div className="mb-6 h-8 w-56 animate-pulse rounded-lg bg-neutral-200 sm:mb-8" />
<div className="mb-6 h-40 animate-pulse rounded-2xl bg-neutral-100" />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="overflow-hidden rounded-2xl border border-neutral-200 bg-white"
>
<div className="h-40 animate-pulse bg-neutral-200" />
<div className="space-y-3 p-4">
<div className="h-5 w-3/4 animate-pulse rounded bg-neutral-200" />
<div className="h-4 w-1/2 animate-pulse rounded bg-neutral-100" />
<div className="h-4 w-2/3 animate-pulse rounded bg-neutral-100" />
<div className="mt-3 flex items-center justify-between border-t border-neutral-100 pt-3">
<div className="h-5 w-20 animate-pulse rounded bg-neutral-200" />
<div className="h-4 w-16 animate-pulse rounded bg-neutral-100" />
</div>
</div>
</div>
))}
</div>
</div>
);
}
+24 -5
View File
@@ -11,6 +11,7 @@ import { siteConfig } from "@/lib/site";
import { categoryLabel, isActivityCategory } from "@/lib/activity-category"; import { categoryLabel, isActivityCategory } from "@/lib/activity-category";
import { isVibe } from "@/lib/vibe"; import { isVibe } from "@/lib/vibe";
import type { GroupSize } from "@/server/repositories/trip.repo"; import type { GroupSize } from "@/server/repositories/trip.repo";
import { Plus, Search, Tent } from "lucide-react";
const GROUP_SIZES: GroupSize[] = ["SMALL", "MEDIUM", "LARGE"]; const GROUP_SIZES: GroupSize[] = ["SMALL", "MEDIUM", "LARGE"];
function isGroupSize(value: unknown): value is GroupSize { function isGroupSize(value: unknown): value is GroupSize {
@@ -98,9 +99,10 @@ export default async function TripsPage({ searchParams }: TripsPageProps) {
</div> </div>
<Link <Link
href="/create-trip" href="/create-trip"
className="w-full rounded-xl bg-primary-600 px-4 py-2.5 text-center text-sm font-semibold text-white shadow-md shadow-primary-600/20 hover:bg-primary-700 sm:w-auto" className="inline-flex w-full items-center justify-center gap-1.5 rounded-xl bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white shadow-md shadow-primary-600/20 hover:bg-primary-700 sm:w-auto"
> >
+ Buat Trip <Plus size={16} strokeWidth={2} aria-hidden />
Buat Trip
</Link> </Link>
</div> </div>
@@ -113,8 +115,22 @@ export default async function TripsPage({ searchParams }: TripsPageProps) {
{trips.length === 0 ? ( {trips.length === 0 ? (
<div className="rounded-2xl border-2 border-dashed border-neutral-200 bg-white p-8 text-center sm:p-14"> <div className="rounded-2xl border-2 border-dashed border-neutral-200 bg-white p-8 text-center sm:p-14">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 text-2xl sm:h-16 sm:w-16 sm:text-3xl"> <div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 sm:h-16 sm:w-16">
{hasFilters ? "🔍" : "🏕️"} {hasFilters ? (
<Search
size={26}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
) : (
<Tent
size={26}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
)}
</div> </div>
<p className="mb-1 text-base font-bold text-neutral-800 sm:text-lg"> <p className="mb-1 text-base font-bold text-neutral-800 sm:text-lg">
{hasFilters {hasFilters
@@ -137,7 +153,7 @@ export default async function TripsPage({ searchParams }: TripsPageProps) {
</div> </div>
) : ( ) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{trips.map((trip) => ( {trips.map((trip, index) => (
<TripCard <TripCard
key={trip.id} key={trip.id}
id={trip.id} id={trip.id}
@@ -154,6 +170,9 @@ export default async function TripsPage({ searchParams }: TripsPageProps) {
organizerName={trip.organizer.name} organizerName={trip.organizer.name}
status={trip.status} status={trip.status}
coverImage={trip.images[0]?.url} coverImage={trip.images[0]?.url}
// Baris pertama (3 kartu) di atas fold — muat segera supaya
// tidak jadi LCP yang lambat.
priority={index < 3}
isVerifiedOrganizer={ isVerifiedOrganizer={
trip.organizer.organizerVerification?.status === "APPROVED" trip.organizer.organizerVerification?.status === "APPROVED"
} }
+8 -5
View File
@@ -11,6 +11,7 @@ import { OrganizerStatsPanel } from "@/features/profile/components/organizer-sta
import { OrganizerReviewsList } from "@/features/review/components/organizer-reviews-list"; import { OrganizerReviewsList } from "@/features/review/components/organizer-reviews-list";
import { siteConfig } from "@/lib/site"; import { siteConfig } from "@/lib/site";
import { vibeMeta } from "@/lib/vibe"; import { vibeMeta } from "@/lib/vibe";
import { BadgeCheck, MapPin, AtSign } from "lucide-react";
interface PageProps { interface PageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@@ -86,10 +87,11 @@ export default async function PublicProfilePage({ params }: PageProps) {
</h1> </h1>
{isVerifiedOrganizer && ( {isVerifiedOrganizer && (
<span <span
className="inline-flex items-center gap-0.5 rounded-full bg-primary-100 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-primary-800" className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-primary-800"
title="Identitas organizer telah diverifikasi (KTP & rekening)" title="Identitas organizer telah diverifikasi (KTP & rekening)"
> >
Verified Organizer <BadgeCheck size={12} strokeWidth={2} aria-hidden />
Verified Organizer
</span> </span>
)} )}
</div> </div>
@@ -97,7 +99,8 @@ export default async function PublicProfilePage({ params }: PageProps) {
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-neutral-500"> <div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-neutral-500">
{profile?.city && ( {profile?.city && (
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
📍 {profile.city} <MapPin size={13} strokeWidth={1.75} aria-hidden />
{profile.city}
</span> </span>
)} )}
<span className="text-xs">Bergabung sejak {memberSince}</span> <span className="text-xs">Bergabung sejak {memberSince}</span>
@@ -141,8 +144,8 @@ export default async function PublicProfilePage({ params }: PageProps) {
rel="noopener noreferrer nofollow" rel="noopener noreferrer nofollow"
className="mt-3 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700" className="mt-3 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700"
> >
<span>📸</span> <AtSign size={15} strokeWidth={1.75} aria-hidden />
<span>@{profile.instagram}</span> <span>{profile.instagram}</span>
</a> </a>
)} )}
</div> </div>
+70 -9
View File
@@ -1,11 +1,29 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { Clock, RefreshCw, CircleX, ArrowLeft } from "lucide-react";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { organizerService } from "@/server/services/organizer.service"; import { organizerService } from "@/server/services/organizer.service";
import { VerifyForm } from "@/features/organizer/components/verify-form"; import { VerifyForm } from "@/features/organizer/components/verify-form";
import { VerifiedBadge } from "@/components/shared/verified-badge"; import { VerifiedBadge } from "@/components/shared/verified-badge";
function reuploadFieldLabel(field: string): string {
switch (field) {
case "ktpImage":
return "Foto KTP";
case "liveness":
return "Foto liveness (pegang kertas SETRIP)";
case "nik":
return "NIK";
case "bankInfo":
return "Info rekening";
case "address":
return "Alamat";
default:
return field;
}
}
export default async function VerifyPage() { export default async function VerifyPage() {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session?.user) { if (!session?.user) {
@@ -53,10 +71,11 @@ export default async function VerifyPage() {
</div> </div>
)} )}
{verification?.status === "PENDING" && ( {verification?.status === "PENDING" && !verification.reuploadRequested && (
<div className="mb-6 rounded-2xl border border-amber-200 bg-amber-50 p-5"> <div className="mb-6 rounded-2xl border border-amber-200 bg-amber-50 p-5">
<p className="mb-1 text-sm font-bold text-amber-800"> <p className="mb-1 flex items-center gap-1.5 text-sm font-bold text-amber-800">
Menunggu review admin <Clock size={15} strokeWidth={2} aria-hidden />
Menunggu review admin
</p> </p>
<p className="text-sm text-neutral-700"> <p className="text-sm text-neutral-700">
Pengajuanmu sedang diproses. Kami akan memberitahu via email setelah selesai. Pengajuanmu sedang diproses. Kami akan memberitahu via email setelah selesai.
@@ -64,9 +83,47 @@ export default async function VerifyPage() {
</div> </div>
)} )}
{verification?.reuploadRequested && (
<div className="mb-6 rounded-2xl border-2 border-amber-400 bg-amber-50 p-5">
<p className="mb-1 flex items-center gap-1.5 text-sm font-bold text-amber-900">
<RefreshCw size={15} strokeWidth={2} aria-hidden />
Admin minta kamu upload ulang
</p>
{verification.reuploadNote && (
<p className="mb-3 text-sm text-neutral-700">
<span className="font-semibold">Catatan admin:</span>{" "}
{verification.reuploadNote}
</p>
)}
{verification.reuploadFields.length > 0 && (
<div className="mb-3">
<p className="mb-1 text-xs font-semibold text-amber-900">
Field yang perlu di-upload ulang:
</p>
<ul className="ml-4 list-disc text-xs text-neutral-700">
{verification.reuploadFields.map((f) => (
<li key={f}>
<span className="font-semibold">
{reuploadFieldLabel(f)}
</span>
</li>
))}
</ul>
</div>
)}
<p className="text-xs text-neutral-700">
Submit ulang form di bawah dengan data/foto yang sudah diperbaiki.
Setelah submit, banner ini hilang otomatis.
</p>
</div>
)}
{verification?.status === "REJECTED" && ( {verification?.status === "REJECTED" && (
<div className="mb-6 rounded-2xl border border-red-200 bg-red-50 p-5"> <div className="mb-6 rounded-2xl border border-red-200 bg-red-50 p-5">
<p className="mb-1 text-sm font-bold text-red-800"> Pengajuan ditolak</p> <p className="mb-1 flex items-center gap-1.5 text-sm font-bold text-red-800">
<CircleX size={15} strokeWidth={2} aria-hidden />
Pengajuan ditolak
</p>
{verification.rejectionReason && ( {verification.rejectionReason && (
<p className="text-sm text-neutral-700"> <p className="text-sm text-neutral-700">
<span className="font-semibold">Alasan:</span>{" "} <span className="font-semibold">Alasan:</span>{" "}
@@ -79,13 +136,17 @@ export default async function VerifyPage() {
</div> </div>
)} )}
{verification?.status !== "APPROVED" && verification?.status !== "PENDING" && ( {(verification?.status !== "APPROVED" &&
<VerifyForm initial={initial} /> (verification?.status !== "PENDING" ||
)} verification?.reuploadRequested)) && <VerifyForm initial={initial} />}
<p className="mt-6 text-center text-sm text-neutral-500"> <p className="mt-6 text-center text-sm text-neutral-500">
<Link href="/profile" className="hover:text-primary-600"> <Link
Kembali ke profil href="/profile"
className="inline-flex items-center gap-1 hover:text-primary-600"
>
<ArrowLeft size={14} strokeWidth={1.75} aria-hidden />
Kembali ke profil
</Link> </Link>
</p> </p>
</div> </div>
+243
View File
@@ -0,0 +1,243 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { isAdminEmail, listAdminEmails } from "@/lib/admin";
import { prisma } from "@/lib/prisma";
import type { Prisma } from "@/app/generated/prisma/client";
import { AdminFilterBar } from "@/features/admin/components/admin-filter-bar";
const ENTITY_TYPES = [
"Refund",
"Payout",
"Trip",
"User",
"OrganizerVerification",
"Payment",
] as const;
interface PageProps {
searchParams: Promise<{
entityType?: string;
action?: string;
reviewer?: string;
dateFrom?: string;
dateTo?: string;
}>;
}
function parseDate(value: string | undefined): Date | undefined {
if (!value) return undefined;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? undefined : d;
}
export default async function AdminAuditLogPage({ searchParams }: PageProps) {
const session = await getServerSession(authOptions);
if (!session?.user) redirect("/login?callbackUrl=/admin/audit-log");
if (!isAdminEmail(session.user.email)) {
return (
<div className="mx-auto max-w-2xl px-4 py-12 text-center">
<p className="text-sm text-neutral-600">
Halaman ini hanya untuk admin SeTrip.
</p>
</div>
);
}
const params = await searchParams;
const dateFrom = parseDate(params.dateFrom);
const dateTo = parseDate(params.dateTo);
const where: Prisma.AdminActionLogWhereInput = {};
if (params.entityType && ENTITY_TYPES.includes(params.entityType as never)) {
where.entityType = params.entityType;
}
if (params.action) {
where.action = { contains: params.action, mode: "insensitive" };
}
if (params.reviewer) {
where.adminEmail = params.reviewer;
}
if (dateFrom || dateTo) {
where.createdAt = {
...(dateFrom && { gte: dateFrom }),
...(dateTo && { lte: dateTo }),
};
}
const logs = await prisma.adminActionLog.findMany({
where,
orderBy: { createdAt: "desc" },
take: 200,
});
return (
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
<header className="mb-6">
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
Audit Log
</h1>
<p className="mt-1 text-sm text-neutral-500">
Catatan semua aksi admin lintas entity (refund, payout, trip cancel,
user suspend, dst). Append-only. Maksimal 200 baris terbaru per query
pakai filter untuk drill-down.
</p>
</header>
<AdminFilterBar
action="/admin/audit-log"
values={{
dateFrom: params.dateFrom,
dateTo: params.dateTo,
reviewer: params.reviewer,
}}
reviewerOptions={listAdminEmails()}
reviewerLabel="Admin"
/>
<form method="get" className="mb-4 grid gap-3 sm:grid-cols-2">
<input type="hidden" name="dateFrom" value={params.dateFrom ?? ""} />
<input type="hidden" name="dateTo" value={params.dateTo ?? ""} />
<input type="hidden" name="reviewer" value={params.reviewer ?? ""} />
<div>
<label
htmlFor="filter-entity"
className="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
>
Entity type
</label>
<select
id="filter-entity"
name="entityType"
defaultValue={params.entityType ?? ""}
className="w-full rounded-lg border border-neutral-200 bg-white px-2 py-1.5 text-sm text-neutral-800 focus:border-primary-400"
>
<option value="">Semua</option>
{ENTITY_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
<div>
<label
htmlFor="filter-action"
className="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
>
Action (contains)
</label>
<div className="flex gap-2">
<input
id="filter-action"
name="action"
defaultValue={params.action ?? ""}
placeholder="mis. REFUND, SUSPEND, CANCEL"
className="flex-1 rounded-lg border border-neutral-200 bg-white px-2 py-1.5 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-primary-400"
/>
<button
type="submit"
className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-primary-700"
>
Cari
</button>
</div>
</div>
</form>
{logs.length === 0 ? (
<div className="rounded-2xl border border-dashed border-neutral-300 bg-white p-10 text-center">
<p className="text-sm text-neutral-500">
Tidak ada audit log yang cocok dengan filter ini.
</p>
</div>
) : (
<div className="overflow-x-auto rounded-2xl border border-neutral-200 bg-white shadow-sm">
<table className="min-w-full divide-y divide-neutral-100 text-sm">
<thead className="bg-neutral-50 text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
<tr>
<th className="px-3 py-2 text-left">Waktu</th>
<th className="px-3 py-2 text-left">Admin</th>
<th className="px-3 py-2 text-left">Action</th>
<th className="px-3 py-2 text-left">Entity</th>
<th className="px-3 py-2 text-left">Entity ID</th>
<th className="px-3 py-2 text-left">Payload</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100 text-xs text-neutral-700">
{logs.map((row) => (
<tr key={row.id}>
<td className="whitespace-nowrap px-3 py-2 text-neutral-500">
{row.createdAt.toLocaleString("id-ID", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</td>
<td className="whitespace-nowrap px-3 py-2">
{row.adminEmail}
{!row.adminId && (
<span className="ml-1 text-[10px] text-amber-700">
(deleted)
</span>
)}
</td>
<td className="whitespace-nowrap px-3 py-2">
<span className="rounded bg-primary-50 px-1.5 py-0.5 font-mono text-[11px] font-semibold text-primary-800">
{row.action}
</span>
</td>
<td className="whitespace-nowrap px-3 py-2 font-medium">
{row.entityType}
</td>
<td className="px-3 py-2">
<EntityIdLink
entityType={row.entityType}
entityId={row.entityId}
/>
</td>
<td className="px-3 py-2 text-neutral-500">
{row.payload ? (
<code className="block max-w-md overflow-x-auto rounded bg-neutral-50 px-2 py-1 font-mono text-[10px]">
{JSON.stringify(row.payload)}
</code>
) : (
"—"
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
function EntityIdLink({
entityType,
entityId,
}: {
entityType: string;
entityId: string;
}) {
const short = `${entityId.slice(0, 8)}`;
let href: string | null = null;
if (entityType === "Trip") href = `/admin/trips/${entityId}`;
if (entityType === "User") href = `/admin/users/${entityId}`;
if (href) {
return (
<Link
href={href}
className="font-mono text-[11px] text-secondary-700 hover:text-secondary-900"
>
{short}
</Link>
);
}
return <span className="font-mono text-[11px]">{short}</span>;
}
+31 -7
View File
@@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { ArrowLeft, CalendarDays, CircleAlert, MapPin } from "lucide-react";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin"; import { isAdminEmail } from "@/lib/admin";
import { bookingRepo } from "@/server/repositories/booking.repo"; import { bookingRepo } from "@/server/repositories/booking.repo";
@@ -69,8 +70,12 @@ export default async function AdminBookingDetailPage({ params }: PageProps) {
return ( return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:py-12"> <div className="mx-auto max-w-4xl px-4 py-8 sm:py-12">
<div className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-neutral-500"> <div className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-neutral-500">
<Link href="/admin" className="hover:text-primary-600"> <Link
Dashboard href="/admin"
className="inline-flex items-center gap-1 hover:text-primary-600"
>
<ArrowLeft size={14} strokeWidth={2} aria-hidden />
Dashboard
</Link> </Link>
<Link <Link
href={`/admin/trips/${booking.tripId}`} href={`/admin/trips/${booking.tripId}`}
@@ -87,9 +92,22 @@ export default async function AdminBookingDetailPage({ params }: PageProps) {
<h1 className="mt-0.5 text-xl font-bold text-neutral-900 sm:text-2xl"> <h1 className="mt-0.5 text-xl font-bold text-neutral-900 sm:text-2xl">
{booking.trip.title} {booking.trip.title}
</h1> </h1>
<p className="mt-1 text-sm text-neutral-500"> <p className="mt-1 flex flex-wrap items-center gap-1 text-sm text-neutral-500">
📅 {formatTripCalendarDateRangeLong(booking.trip.date, booking.trip.endDate)}{" "} <CalendarDays
· 📍 {booking.trip.destination}, {booking.trip.location} size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0"
/>
{formatTripCalendarDateRangeLong(booking.trip.date, booking.trip.endDate)}
<span aria-hidden>·</span>
<MapPin
size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0"
/>
{booking.trip.destination}, {booking.trip.location}
</p> </p>
<div className="mt-4 grid gap-3 sm:grid-cols-2"> <div className="mt-4 grid gap-3 sm:grid-cols-2">
@@ -300,8 +318,14 @@ function PaymentEventCard({
</p> </p>
)} )}
{payment.rejectionReason && ( {payment.rejectionReason && (
<p className="text-red-700"> <p className="flex items-center gap-1 text-red-700">
{payment.rejectionReason} <CircleAlert
size={14}
strokeWidth={2}
aria-hidden
className="shrink-0"
/>
{payment.rejectionReason}
</p> </p>
)} )}
</div> </div>
+17
View File
@@ -0,0 +1,17 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Admin · Email Log",
description:
"Halaman admin untuk memantau pengiriman email — antrian, kegagalan, dan retry.",
alternates: { canonical: "/admin/emails" },
robots: { index: false, follow: false },
};
export default function AdminEmailsLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}
+346
View File
@@ -0,0 +1,346 @@
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { emailRepo } from "@/server/repositories/email.repo";
import {
RetryEmailButton,
ResendEmailButton,
} from "@/features/email/components/email-row-actions";
type Tab = "failed" | "queue" | "sent";
const TABS: { key: Tab; label: string }[] = [
{ key: "failed", label: "Gagal" },
{ key: "queue", label: "Antrian" },
{ key: "sent", label: "Terkirim" },
];
interface PageProps {
searchParams: Promise<{ tab?: string; to?: string; template?: string }>;
}
export default async function AdminEmailsPage({ searchParams }: PageProps) {
const session = await getServerSession(authOptions);
if (!session?.user) redirect("/login?callbackUrl=/admin/emails");
if (!isAdminEmail(session.user.email)) {
return (
<div className="mx-auto max-w-2xl px-4 py-12 text-center">
<p className="text-sm text-neutral-600">
Halaman ini hanya untuk admin SeTrip.
</p>
</div>
);
}
const params = await searchParams;
const tab: Tab = TABS.some((t) => t.key === params.tab)
? (params.tab as Tab)
: "failed";
const filters = {
to: params.to?.trim() || undefined,
template: params.template?.trim() || undefined,
};
const stats = await emailRepo.stats();
const jobs =
tab === "sent"
? []
: await emailRepo.listJobs(
tab === "failed" ? ["FAILED"] : ["PENDING", "PROCESSING"],
filters
);
const sent = tab === "sent" ? await emailRepo.listSent(filters) : [];
return (
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
<header className="mb-6">
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
Email Log
</h1>
<p className="mt-1 text-sm text-neutral-500">
Pantau pengiriman email transaksional. Email yang gagal dikirim bisa
di-retry manual; email terkirim bisa di-resend kalau peserta lapor
tidak menerima.
</p>
</header>
{/* Kartu ringkasan */}
<div className="mb-6 grid gap-3 sm:grid-cols-3">
<StatCard
label="Antri dikirim"
value={stats.queued}
tone={stats.queued > 0 ? "amber" : "ok"}
hint="Job menunggu cron / retry"
/>
<StatCard
label="Gagal 24 jam"
value={stats.failed24h}
tone={stats.failed24h > 0 ? "red" : "ok"}
hint="Job gagal dalam sehari terakhir"
/>
<StatCard
label="Perlu aksi manual"
value={stats.deadLetter}
tone={stats.deadLetter > 0 ? "red" : "ok"}
hint="Gagal & habis 5 attempt — cron berhenti retry"
/>
</div>
{/* Tabs */}
<div className="mb-4 flex flex-wrap gap-2">
{TABS.map((t) => (
<a
key={t.key}
href={`/admin/emails?tab=${t.key}`}
className={`rounded-full px-4 py-1.5 text-sm font-semibold transition-colors ${
tab === t.key
? "bg-primary-600 text-white"
: "bg-neutral-100 text-neutral-600 hover:bg-neutral-200"
}`}
>
{t.label}
</a>
))}
</div>
{/* Filter */}
<form
method="get"
action="/admin/emails"
className="mb-4 flex flex-wrap items-end gap-2 rounded-2xl border border-neutral-200 bg-white p-3 shadow-sm"
>
<input type="hidden" name="tab" value={tab} />
<div className="min-w-[180px] flex-1">
<label
htmlFor="filter-to"
className="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
>
Penerima (email)
</label>
<input
id="filter-to"
name="to"
defaultValue={params.to ?? ""}
placeholder="user@email.com"
className="w-full rounded-lg border border-neutral-200 bg-neutral-50 px-2 py-1.5 text-sm text-neutral-800 focus:border-primary-400 focus:bg-white"
/>
</div>
<div className="min-w-[160px] flex-1">
<label
htmlFor="filter-template"
className="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
>
Template
</label>
<input
id="filter-template"
name="template"
defaultValue={params.template ?? ""}
placeholder="mis. refund_succeeded"
className="w-full rounded-lg border border-neutral-200 bg-neutral-50 px-2 py-1.5 text-sm text-neutral-800 focus:border-primary-400 focus:bg-white"
/>
</div>
<button
type="submit"
className="rounded-lg bg-primary-600 px-4 py-1.5 text-sm font-semibold text-white hover:bg-primary-700"
>
Cari
</button>
{(filters.to || filters.template) && (
<a
href={`/admin/emails?tab=${tab}`}
className="rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-500 hover:bg-neutral-50"
>
Reset
</a>
)}
</form>
{tab === "sent" ? (
<SentTable rows={sent} />
) : (
<JobTable rows={jobs} tab={tab} />
)}
</div>
);
}
function StatCard({
label,
value,
tone,
hint,
}: {
label: string;
value: number;
tone: "ok" | "amber" | "red";
hint: string;
}) {
const cls =
tone === "red"
? "border-red-200 bg-red-50/60"
: tone === "amber"
? "border-amber-200 bg-amber-50/60"
: "border-emerald-200 bg-emerald-50/50";
const valueCls =
tone === "red"
? "text-red-700"
: tone === "amber"
? "text-amber-700"
: "text-emerald-700";
return (
<div className={`rounded-2xl border p-4 shadow-sm ${cls}`}>
<p className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
{label}
</p>
<p className={`mt-1 text-2xl font-bold ${valueCls}`}>{value}</p>
<p className="mt-0.5 text-[11px] text-neutral-500">{hint}</p>
</div>
);
}
function JobTable({
rows,
tab,
}: {
rows: Awaited<ReturnType<typeof emailRepo.listJobs>>;
tab: "failed" | "queue";
}) {
if (rows.length === 0) {
return (
<EmptyState
message={
tab === "failed"
? "Tidak ada email gagal — semua pengiriman lancar. 🎉"
: "Tidak ada email yang sedang antri."
}
/>
);
}
return (
<div className="overflow-x-auto rounded-2xl border border-neutral-200 bg-white shadow-sm">
<table className="min-w-full divide-y divide-neutral-100 text-sm">
<thead className="bg-neutral-50 text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
<tr>
<th className="px-3 py-2 text-left">Penerima</th>
<th className="px-3 py-2 text-left">Template</th>
<th className="px-3 py-2 text-left">Status</th>
<th className="px-3 py-2 text-left">Attempt</th>
<th className="px-3 py-2 text-left">
{tab === "failed" ? "Error terakhir" : "Dijadwalkan"}
</th>
<th className="px-3 py-2 text-left">Aksi</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100 text-xs text-neutral-700">
{rows.map((r) => (
<tr key={r.id}>
<td className="px-3 py-2">{r.to}</td>
<td className="px-3 py-2 font-mono">{r.template}</td>
<td className="px-3 py-2">
<EmailBadge value={r.status} />
</td>
<td className="px-3 py-2">
{r.attempts}
{r.attempts >= 5 && (
<span className="ml-1 text-[10px] font-semibold text-red-600">
(mati)
</span>
)}
</td>
<td className="px-3 py-2 text-neutral-500">
{tab === "failed"
? r.lastError
? truncate(r.lastError, 90)
: "—"
: formatDateTime(r.scheduledAt)}
</td>
<td className="px-3 py-2">
<RetryEmailButton jobId={r.id} />
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function SentTable({
rows,
}: {
rows: Awaited<ReturnType<typeof emailRepo.listSent>>;
}) {
if (rows.length === 0) {
return <EmptyState message="Belum ada email terkirim yang cocok." />;
}
return (
<div className="overflow-x-auto rounded-2xl border border-neutral-200 bg-white shadow-sm">
<table className="min-w-full divide-y divide-neutral-100 text-sm">
<thead className="bg-neutral-50 text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
<tr>
<th className="px-3 py-2 text-left">Penerima</th>
<th className="px-3 py-2 text-left">Template</th>
<th className="px-3 py-2 text-left">Subject</th>
<th className="px-3 py-2 text-left">Terkirim</th>
<th className="px-3 py-2 text-left">Aksi</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100 text-xs text-neutral-700">
{rows.map((r) => (
<tr key={r.id}>
<td className="px-3 py-2">{r.to}</td>
<td className="px-3 py-2 font-mono">{r.template}</td>
<td className="px-3 py-2">{truncate(r.subject, 60)}</td>
<td className="px-3 py-2 text-neutral-500">
{formatDateTime(r.sentAt)}
</td>
<td className="px-3 py-2">
<ResendEmailButton emailSentId={r.id} disabled={!r.html} />
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function EmptyState({ message }: { message: string }) {
return (
<div className="rounded-2xl border border-dashed border-neutral-300 bg-white p-10 text-center">
<p className="text-sm text-neutral-500">{message}</p>
</div>
);
}
function EmailBadge({ value }: { value: string }) {
const cls =
value === "SUCCESS"
? "bg-emerald-100 text-emerald-800"
: value === "FAILED"
? "bg-red-100 text-red-800"
: "bg-amber-100 text-amber-800";
return (
<span
className={`inline-block rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${cls}`}
>
{value}
</span>
);
}
function formatDateTime(d: Date): string {
return d.toLocaleString("id-ID", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function truncate(s: string, max: number): string {
return s.length > max ? `${s.slice(0, max)}` : s;
}
+7 -1
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { Lock } from "lucide-react";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { AdminSidebar } from "@/components/admin/admin-sidebar"; import { AdminSidebar } from "@/components/admin/admin-sidebar";
@@ -31,7 +32,12 @@ export default async function AdminLayout({
return ( return (
<div className="flex min-h-screen items-center justify-center bg-neutral-50 px-4"> <div className="flex min-h-screen items-center justify-center bg-neutral-50 px-4">
<div className="max-w-md rounded-2xl border border-neutral-200 bg-white p-8 text-center shadow-sm"> <div className="max-w-md rounded-2xl border border-neutral-200 bg-white p-8 text-center shadow-sm">
<p className="text-2xl">🔒</p> <Lock
size={28}
strokeWidth={1.75}
aria-hidden
className="mx-auto text-neutral-500"
/>
<h1 className="mt-2 text-base font-bold text-neutral-900"> <h1 className="mt-2 text-base font-bold text-neutral-900">
Halaman khusus admin Halaman khusus admin
</h1> </h1>
+4 -2
View File
@@ -1,6 +1,7 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { ChevronRight } from "lucide-react";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { organizerRepo } from "@/server/repositories/organizer.repo"; import { organizerRepo } from "@/server/repositories/organizer.repo";
import { refundRepo } from "@/server/repositories/refund.repo"; import { refundRepo } from "@/server/repositories/refund.repo";
@@ -155,8 +156,9 @@ export default async function AdminDashboardPage() {
> >
{s.label} {s.label}
</span> </span>
<span className="text-xs text-neutral-400 group-hover:text-primary-600"> <span className="inline-flex items-center gap-1 text-xs text-neutral-400 group-hover:text-primary-600">
Buka Buka
<ChevronRight size={14} strokeWidth={2} aria-hidden />
</span> </span>
</div> </div>
<p className="mt-3 text-3xl font-bold text-neutral-900">{s.value}</p> <p className="mt-3 text-3xl font-bold text-neutral-900">{s.value}</p>
+55 -13
View File
@@ -1,8 +1,10 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin"; import { isAdminEmail, listAdminEmails } from "@/lib/admin";
import { payoutRepo } from "@/server/repositories/payout.repo"; import { payoutRepo } from "@/server/repositories/payout.repo";
import { AdminFilterBar } from "@/features/admin/components/admin-filter-bar";
import { ExportCsvLink } from "@/features/admin/components/export-csv-link";
import { import {
PayoutReviewCard, PayoutReviewCard,
type PayoutCardData, type PayoutCardData,
@@ -18,7 +20,18 @@ const TABS: { key: Tab; label: string }[] = [
]; ];
interface PageProps { interface PageProps {
searchParams: Promise<{ tab?: string }>; searchParams: Promise<{
tab?: string;
dateFrom?: string;
dateTo?: string;
reviewer?: string;
}>;
}
function parseDate(value: string | undefined): Date | undefined {
if (!value) return undefined;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? undefined : d;
} }
export default async function AdminPayoutsPage({ searchParams }: PageProps) { export default async function AdminPayoutsPage({ searchParams }: PageProps) {
@@ -39,7 +52,11 @@ export default async function AdminPayoutsPage({ searchParams }: PageProps) {
? (params.tab as Tab) ? (params.tab as Tab)
: "RELEASED"; : "RELEASED";
const rows = await payoutRepo.listByStatus(tab); const rows = await payoutRepo.listByStatus(tab, {
dateFrom: parseDate(params.dateFrom),
dateTo: parseDate(params.dateTo),
processorEmail: params.reviewer || undefined,
});
const items: PayoutCardData[] = rows.map((p) => ({ const items: PayoutCardData[] = rows.map((p) => ({
id: p.id, id: p.id,
amount: p.amount, amount: p.amount,
@@ -65,19 +82,42 @@ export default async function AdminPayoutsPage({ searchParams }: PageProps) {
processedBy: p.processedBy, processedBy: p.processedBy,
})); }));
const exportQuery = new URLSearchParams({ status: tab });
if (params.dateFrom) exportQuery.set("dateFrom", params.dateFrom);
if (params.dateTo) exportQuery.set("dateTo", params.dateTo);
if (params.reviewer) exportQuery.set("reviewer", params.reviewer);
return ( return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:py-12"> <div className="mx-auto max-w-4xl px-4 py-8 sm:py-12">
<header className="mb-6"> <header className="mb-6 flex flex-wrap items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl"> <div>
Payout Organizer <h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
</h1> Payout Organizer
<p className="mt-1 text-sm text-neutral-500"> </h1>
Uang peserta ditahan (escrow) sampai trip selesai + 3 hari. Setelah <p className="mt-1 text-sm text-neutral-500">
status <strong>Siap transfer</strong>, admin transfer manual ke Uang peserta ditahan (escrow) sampai trip selesai + 3 hari. Setelah
rekening organizer lalu tandai sudah dibayar. status <strong>Siap transfer</strong>, admin transfer manual ke
</p> rekening organizer lalu tandai sudah dibayar.
</p>
</div>
<ExportCsvLink
href="/api/admin/export/payouts"
query={exportQuery.toString()}
/>
</header> </header>
<AdminFilterBar
action="/admin/payouts"
values={{
tab,
dateFrom: params.dateFrom,
dateTo: params.dateTo,
reviewer: params.reviewer,
}}
reviewerOptions={listAdminEmails()}
reviewerLabel="Processor"
/>
<div className="mb-6 flex flex-wrap gap-2"> <div className="mb-6 flex flex-wrap gap-2">
{TABS.map((t) => ( {TABS.map((t) => (
<a <a
@@ -96,7 +136,9 @@ export default async function AdminPayoutsPage({ searchParams }: PageProps) {
{items.length === 0 ? ( {items.length === 0 ? (
<div className="rounded-2xl border border-dashed border-neutral-300 bg-white p-10 text-center"> <div className="rounded-2xl border border-dashed border-neutral-300 bg-white p-10 text-center">
<p className="text-sm text-neutral-500">Tidak ada payout pada status ini.</p> <p className="text-sm text-neutral-500">
Tidak ada payout yang cocok dengan filter ini.
</p>
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
+73 -12
View File
@@ -1,9 +1,11 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin"; import { isAdminEmail, listAdminEmails } from "@/lib/admin";
import { refundRepo } from "@/server/repositories/refund.repo"; import { refundRepo } from "@/server/repositories/refund.repo";
import { CreateRefundForm } from "@/features/refund/components/create-refund-form"; import { CreateRefundForm } from "@/features/refund/components/create-refund-form";
import { AdminFilterBar } from "@/features/admin/components/admin-filter-bar";
import { ExportCsvLink } from "@/features/admin/components/export-csv-link";
import { import {
RefundReviewCard, RefundReviewCard,
type RefundCardData, type RefundCardData,
@@ -19,8 +21,30 @@ const TABS: { key: Tab; label: string }[] = [
{ key: "FAILED", label: "Gagal" }, { key: "FAILED", label: "Gagal" },
]; ];
const REASON_OPTIONS = [
{ value: "USER_CANCELLATION", label: "User cancel" },
{ value: "ORGANIZER_CANCELLED", label: "Organizer cancel" },
{ value: "TRIP_ISSUE", label: "Trip issue" },
{ value: "ADMIN_ADJUSTMENT", label: "Admin adjustment" },
{ value: "DISPUTE_RESOLVED", label: "Dispute resolved" },
] as const;
type ReasonValue = (typeof REASON_OPTIONS)[number]["value"];
interface PageProps { interface PageProps {
searchParams: Promise<{ tab?: string }>; searchParams: Promise<{
tab?: string;
dateFrom?: string;
dateTo?: string;
reviewer?: string;
reason?: string;
}>;
}
function parseDate(value: string | undefined): Date | undefined {
if (!value) return undefined;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? undefined : d;
} }
export default async function AdminRefundsPage({ searchParams }: PageProps) { export default async function AdminRefundsPage({ searchParams }: PageProps) {
@@ -40,8 +64,19 @@ export default async function AdminRefundsPage({ searchParams }: PageProps) {
const tab: Tab = TABS.some((t) => t.key === params.tab) const tab: Tab = TABS.some((t) => t.key === params.tab)
? (params.tab as Tab) ? (params.tab as Tab)
: "PENDING"; : "PENDING";
const reason: ReasonValue | undefined = REASON_OPTIONS.some(
(r) => r.value === params.reason
)
? (params.reason as ReasonValue)
: undefined;
const rows = await refundRepo.listByStatus(tab, {
dateFrom: parseDate(params.dateFrom),
dateTo: parseDate(params.dateTo),
reviewerEmail: params.reviewer || undefined,
reason,
});
const rows = await refundRepo.listByStatus(tab);
const items: RefundCardData[] = rows.map((r) => ({ const items: RefundCardData[] = rows.map((r) => ({
id: r.id, id: r.id,
amount: r.amount, amount: r.amount,
@@ -78,20 +113,46 @@ export default async function AdminRefundsPage({ searchParams }: PageProps) {
}, },
})); }));
const exportQuery = new URLSearchParams({ status: tab });
if (params.dateFrom) exportQuery.set("dateFrom", params.dateFrom);
if (params.dateTo) exportQuery.set("dateTo", params.dateTo);
if (params.reviewer) exportQuery.set("reviewer", params.reviewer);
if (reason) exportQuery.set("reason", reason);
return ( return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:py-12"> <div className="mx-auto max-w-4xl px-4 py-8 sm:py-12">
<header className="mb-6"> <header className="mb-6 flex flex-wrap items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl"> <div>
Review Refund Manual <h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
</h1> Review Refund Manual
<p className="mt-1 text-sm text-neutral-500"> </h1>
Tinjau laporan refund dari peserta dan organizer. Setiap refund harus <p className="mt-1 text-sm text-neutral-500">
melalui approval admin sebelum dieksekusi. Tinjau laporan refund dari peserta dan organizer. Setiap refund harus
</p> melalui approval admin sebelum dieksekusi.
</p>
</div>
<ExportCsvLink
href="/api/admin/export/refunds"
query={exportQuery.toString()}
/>
</header> </header>
<CreateRefundForm /> <CreateRefundForm />
<AdminFilterBar
action="/admin/refunds"
values={{
tab,
dateFrom: params.dateFrom,
dateTo: params.dateTo,
reviewer: params.reviewer,
reason: params.reason,
}}
reviewerOptions={listAdminEmails()}
reviewerLabel="Reviewer"
reasonOptions={[...REASON_OPTIONS]}
/>
<div className="mb-6 flex flex-wrap gap-2"> <div className="mb-6 flex flex-wrap gap-2">
{TABS.map((t) => ( {TABS.map((t) => (
<a <a
@@ -111,7 +172,7 @@ export default async function AdminRefundsPage({ searchParams }: PageProps) {
{items.length === 0 ? ( {items.length === 0 ? (
<div className="rounded-2xl border border-dashed border-neutral-300 bg-white p-10 text-center"> <div className="rounded-2xl border border-dashed border-neutral-300 bg-white p-10 text-center">
<p className="text-sm text-neutral-500"> <p className="text-sm text-neutral-500">
Tidak ada refund pada status ini. Tidak ada refund yang cocok dengan filter ini.
</p> </p>
</div> </div>
) : ( ) : (
+419
View File
@@ -0,0 +1,419 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import {
ArrowUpRight,
CircleAlert,
CircleCheck,
CircleX,
} from "lucide-react";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { prisma } from "@/lib/prisma";
import { systemHealthService } from "@/server/services/system-health.service";
import { emailRepo } from "@/server/repositories/email.repo";
interface JobSummary {
jobName: string;
lastRun: { at: Date; status: string; errorMessage: string | null } | null;
lastSuccess: Date | null;
totalRuns7d: number;
failedRuns7d: number;
}
async function getJobSummary(jobName: string): Promise<JobSummary> {
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const [lastRun, lastSuccessRow, totalRuns7d, failedRuns7d] =
await Promise.all([
prisma.cronRun.findFirst({
where: { jobName },
orderBy: { startedAt: "desc" },
select: { startedAt: true, status: true, errorMessage: true },
}),
prisma.cronRun.findFirst({
where: { jobName, status: "SUCCESS" },
orderBy: { startedAt: "desc" },
select: { startedAt: true },
}),
prisma.cronRun.count({
where: { jobName, startedAt: { gte: sevenDaysAgo } },
}),
prisma.cronRun.count({
where: {
jobName,
status: "FAILED",
startedAt: { gte: sevenDaysAgo },
},
}),
]);
return {
jobName,
lastRun: lastRun
? {
at: lastRun.startedAt,
status: lastRun.status,
errorMessage: lastRun.errorMessage,
}
: null,
lastSuccess: lastSuccessRow?.startedAt ?? null,
totalRuns7d,
failedRuns7d,
};
}
// Daftar cron yang dipantau. Tambah entry baru saat menambah cron route handler.
const TRACKED_JOBS = [
"auto-complete-trips",
"process-email-jobs",
"cleanup-trip-images",
] as const;
function healthOf(summary: JobSummary): "ok" | "stale" | "failed" {
if (summary.lastRun?.status === "FAILED") return "failed";
if (!summary.lastSuccess) return "stale";
const hoursSince =
(Date.now() - summary.lastSuccess.getTime()) / (1000 * 60 * 60);
// Asumsi cron daily — > 25 jam dianggap stale.
if (hoursSince > 25) return "stale";
return "ok";
}
export default async function AdminSystemPage() {
const session = await getServerSession(authOptions);
if (!session?.user) redirect("/login?callbackUrl=/admin/system");
if (!isAdminEmail(session.user.email)) {
return (
<div className="mx-auto max-w-2xl px-4 py-12 text-center">
<p className="text-sm text-neutral-600">
Halaman ini hanya untuk admin SeTrip.
</p>
</div>
);
}
const [summaries, recentRuns, stale, emailStats] = await Promise.all([
Promise.all(TRACKED_JOBS.map(getJobSummary)),
prisma.cronRun.findMany({
orderBy: { startedAt: "desc" },
take: 20,
}),
systemHealthService.detectStale(),
emailRepo.stats(),
]);
const hasAnyStale =
stale.stalePaymentsCount > 0 ||
stale.awaitingPayPastDepartureCount > 0 ||
stale.overduePayoutsCount > 0 ||
stale.stuckRefundsCount > 0 ||
emailStats.deadLetter > 0;
return (
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
<header className="mb-6">
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
System Health
</h1>
<p className="mt-1 text-sm text-neutral-500">
Status cron job otomatis. Refresh halaman ini setelah trigger cron
manual atau saat investigasi.
</p>
</header>
{hasAnyStale && (
<section className="mb-6 rounded-2xl border border-amber-300 bg-amber-50 p-4 sm:p-5">
<h2 className="mb-2 flex items-center gap-1.5 text-sm font-bold text-amber-900">
<CircleAlert size={16} strokeWidth={2} aria-hidden />
Stale State Alerts
</h2>
<ul className="space-y-1 text-xs text-amber-900">
{stale.stalePaymentsCount > 0 && (
<li>
<strong>{stale.stalePaymentsCount}</strong> Payment MIDTRANS
AWAITING &gt; 25 jam webhook mungkin tertunda. Cek manual lalu
reconcile.
</li>
)}
{stale.awaitingPayPastDepartureCount > 0 && (
<li>
<strong>{stale.awaitingPayPastDepartureCount}</strong> Booking
AWAITING_PAY tapi trip sudah lewat tanggal berangkat peserta
lupa bayar, butuh cleanup.
</li>
)}
{stale.overduePayoutsCount > 0 && (
<li>
<strong>{stale.overduePayoutsCount}</strong> Payout HELD lewat
heldUntil &gt; 1 hari cron release mungkin tidak jalan, cek
cron history di bawah.{" "}
<Link
href="/admin/payouts?tab=HELD"
className="inline-flex items-center gap-1 font-semibold text-amber-700 hover:underline"
>
Lihat HELD
<ArrowUpRight size={14} strokeWidth={2} aria-hidden />
</Link>
</li>
)}
{stale.stuckRefundsCount > 0 && (
<li>
<strong>{stale.stuckRefundsCount}</strong> Refund APPROVED
&gt; 7 hari belum di-process.{" "}
<Link
href="/admin/refunds?tab=APPROVED"
className="inline-flex items-center gap-1 font-semibold text-amber-700 hover:underline"
>
Lihat APPROVED
<ArrowUpRight size={14} strokeWidth={2} aria-hidden />
</Link>
</li>
)}
{emailStats.deadLetter > 0 && (
<li>
<strong>{emailStats.deadLetter}</strong> email gagal kirim &
sudah habis 5 attempt cron berhenti retry, perlu retry
manual.{" "}
<Link
href="/admin/emails?tab=failed"
className="inline-flex items-center gap-1 font-semibold text-amber-700 hover:underline"
>
Lihat email gagal
<ArrowUpRight size={14} strokeWidth={2} aria-hidden />
</Link>
</li>
)}
</ul>
</section>
)}
<section className="mb-8">
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
Cron Jobs
</h2>
<div className="grid gap-3 sm:grid-cols-2">
{summaries.map((s) => {
const health = healthOf(s);
const cls =
health === "ok"
? "border-emerald-200 bg-emerald-50/50"
: health === "stale"
? "border-amber-200 bg-amber-50/50"
: "border-red-200 bg-red-50/50";
const badge =
health === "ok"
? {
label: "OK",
icon: CircleCheck,
cls: "bg-emerald-100 text-emerald-800",
}
: health === "stale"
? {
label: "STALE",
icon: CircleAlert,
cls: "bg-amber-100 text-amber-800",
}
: {
label: "FAILED",
icon: CircleX,
cls: "bg-red-100 text-red-800",
};
const BadgeIcon = badge.icon;
return (
<div
key={s.jobName}
className={`rounded-2xl border p-4 shadow-sm sm:p-5 ${cls}`}
>
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
Job
</p>
<p className="font-mono text-sm font-bold text-neutral-800">
{s.jobName}
</p>
</div>
<span
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${badge.cls}`}
>
<BadgeIcon size={12} strokeWidth={2.25} aria-hidden />
{badge.label}
</span>
</div>
<dl className="mt-3 space-y-1 text-xs text-neutral-700">
<div>
<dt className="inline font-semibold">Last run:</dt>{" "}
<dd className="inline">
{s.lastRun
? `${formatDateTime(s.lastRun.at)} · ${s.lastRun.status}`
: "Belum pernah"}
</dd>
</div>
<div>
<dt className="inline font-semibold">Last success:</dt>{" "}
<dd className="inline">
{s.lastSuccess
? formatDateTime(s.lastSuccess)
: "Belum pernah"}
</dd>
</div>
<div>
<dt className="inline font-semibold">7 hari terakhir:</dt>{" "}
<dd className="inline">
{s.totalRuns7d} run, {s.failedRuns7d} failed
</dd>
</div>
{s.lastRun?.errorMessage && (
<div className="mt-2 rounded-lg bg-red-100 p-2 text-[11px] text-red-800">
Error terakhir: {s.lastRun.errorMessage}
</div>
)}
</dl>
</div>
);
})}
</div>
</section>
<section className="mb-8">
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
Email
</h2>
<div className="grid gap-3 sm:grid-cols-3">
<EmailStat
label="Antri dikirim"
value={emailStats.queued}
tone={emailStats.queued > 0 ? "amber" : "ok"}
/>
<EmailStat
label="Gagal 24 jam"
value={emailStats.failed24h}
tone={emailStats.failed24h > 0 ? "red" : "ok"}
/>
<EmailStat
label="Perlu aksi manual"
value={emailStats.deadLetter}
tone={emailStats.deadLetter > 0 ? "red" : "ok"}
/>
</div>
<p className="mt-2 text-xs text-neutral-500">
<Link
href="/admin/emails"
className="inline-flex items-center gap-1 font-semibold text-primary-600 hover:underline"
>
Buka Email Log
<ArrowUpRight size={14} strokeWidth={2} aria-hidden />
</Link>
</p>
</section>
<section>
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
Recent Runs (20 terakhir)
</h2>
{recentRuns.length === 0 ? (
<div className="rounded-2xl border border-dashed border-neutral-300 bg-white p-8 text-center">
<p className="text-sm text-neutral-500">
Belum ada cron run tercatat. Setelah cron berikutnya jalan, baris
pertama akan muncul di sini.
</p>
</div>
) : (
<div className="overflow-x-auto rounded-2xl border border-neutral-200 bg-white shadow-sm">
<table className="min-w-full divide-y divide-neutral-100 text-sm">
<thead className="bg-neutral-50 text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
<tr>
<th className="px-3 py-2 text-left">Job</th>
<th className="px-3 py-2 text-left">Started</th>
<th className="px-3 py-2 text-left">Finished</th>
<th className="px-3 py-2 text-left">Status</th>
<th className="px-3 py-2 text-left">Note</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100 text-xs text-neutral-700">
{recentRuns.map((r) => (
<tr key={r.id}>
<td className="px-3 py-2 font-mono">{r.jobName}</td>
<td className="px-3 py-2">{formatDateTime(r.startedAt)}</td>
<td className="px-3 py-2">
{r.finishedAt ? formatDateTime(r.finishedAt) : "—"}
</td>
<td className="px-3 py-2">
<StatusBadge value={r.status} />
</td>
<td className="px-3 py-2 text-neutral-500">
{r.errorMessage ??
(r.payload
? truncate(JSON.stringify(r.payload), 80)
: "—")}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
);
}
function formatDateTime(d: Date): string {
return d.toLocaleString("id-ID", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function truncate(s: string, max: number): string {
return s.length > max ? `${s.slice(0, max)}` : s;
}
function EmailStat({
label,
value,
tone,
}: {
label: string;
value: number;
tone: "ok" | "amber" | "red";
}) {
const cls =
tone === "red"
? "border-red-200 bg-red-50/60"
: tone === "amber"
? "border-amber-200 bg-amber-50/60"
: "border-emerald-200 bg-emerald-50/50";
const valueCls =
tone === "red"
? "text-red-700"
: tone === "amber"
? "text-amber-700"
: "text-emerald-700";
return (
<div className={`rounded-2xl border p-4 shadow-sm ${cls}`}>
<p className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
{label}
</p>
<p className={`mt-1 text-2xl font-bold ${valueCls}`}>{value}</p>
</div>
);
}
function StatusBadge({ value }: { value: string }) {
const cls =
value === "SUCCESS"
? "bg-emerald-100 text-emerald-800"
: value === "FAILED"
? "bg-red-100 text-red-800"
: "bg-amber-100 text-amber-800";
return (
<span
className={`inline-block rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${cls}`}
>
{value}
</span>
);
}
+26 -7
View File
@@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { ArrowLeft, CalendarDays, MapPin } from "lucide-react";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin"; import { isAdminEmail } from "@/lib/admin";
import { tripService } from "@/server/services/trip.service"; import { tripService } from "@/server/services/trip.service";
@@ -67,8 +68,12 @@ export default async function AdminTripDetailPage({ params }: PageProps) {
return ( return (
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12"> <div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
<div className="mb-4 text-xs text-neutral-500"> <div className="mb-4 text-xs text-neutral-500">
<Link href="/admin/trips" className="hover:text-primary-600"> <Link
Kembali ke list trips href="/admin/trips"
className="inline-flex items-center gap-1 hover:text-primary-600"
>
<ArrowLeft size={14} strokeWidth={2} aria-hidden />
Kembali ke list trips
</Link> </Link>
</div> </div>
@@ -84,9 +89,22 @@ export default async function AdminTripDetailPage({ params }: PageProps) {
<h1 className="text-xl font-bold text-neutral-900 sm:text-2xl"> <h1 className="text-xl font-bold text-neutral-900 sm:text-2xl">
{trip.title} {trip.title}
</h1> </h1>
<p className="mt-1 text-sm text-neutral-500"> <p className="mt-1 flex flex-wrap items-center gap-1 text-sm text-neutral-500">
📅 {formatTripCalendarDateRangeLong(trip.date, trip.endDate)} · <CalendarDays
📍 {trip.destination}, {trip.location} size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0"
/>
{formatTripCalendarDateRangeLong(trip.date, trip.endDate)}
<span aria-hidden>·</span>
<MapPin
size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0"
/>
{trip.destination}, {trip.location}
</p> </p>
<p className="mt-1 text-xs text-neutral-500"> <p className="mt-1 text-xs text-neutral-500">
Organizer:{" "} Organizer:{" "}
@@ -220,8 +238,9 @@ export default async function AdminTripDetailPage({ params }: PageProps) {
{p.user.name} {p.user.name}
</Link> </Link>
{p.user.profile?.city && ( {p.user.profile?.city && (
<span className="ml-2 text-[11px] text-neutral-500"> <span className="ml-2 inline-flex items-center gap-1 text-[11px] text-neutral-500">
📍 {p.user.profile.city} <MapPin size={12} strokeWidth={2} aria-hidden />
{p.user.profile.city}
</span> </span>
)} )}
</div> </div>
+17 -3
View File
@@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { CalendarDays, MapPin } from "lucide-react";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin"; import { isAdminEmail } from "@/lib/admin";
import { tripRepo } from "@/server/repositories/trip.repo"; import { tripRepo } from "@/server/repositories/trip.repo";
@@ -127,9 +128,22 @@ export default async function AdminTripsPage({ searchParams }: PageProps) {
<h2 className="truncate text-base font-bold text-neutral-900 sm:text-lg"> <h2 className="truncate text-base font-bold text-neutral-900 sm:text-lg">
{t.title} {t.title}
</h2> </h2>
<p className="mt-1 truncate text-xs text-neutral-500 sm:text-sm"> <p className="mt-1 flex items-center gap-1 truncate text-xs text-neutral-500 sm:text-sm">
📅 {formatTripCalendarDateRangeLong(t.date, t.endDate)} <CalendarDays
{" · "}📍 {t.location} size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0"
/>
{formatTripCalendarDateRangeLong(t.date, t.endDate)}
<span aria-hidden>·</span>
<MapPin
size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0"
/>
{t.location}
</p> </p>
<p className="mt-1 text-xs text-neutral-500 sm:text-sm"> <p className="mt-1 text-xs text-neutral-500 sm:text-sm">
Organizer:{" "} Organizer:{" "}
+366
View File
@@ -0,0 +1,366 @@
import Link from "next/link";
import Image from "next/image";
import { notFound, redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { ArrowLeft, ArrowUpRight, Ban, Check } from "lucide-react";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { userRepo } from "@/server/repositories/user.repo";
import { formatRupiah } from "@/lib/utils";
import { SuspendUserButton } from "@/features/admin/components/suspend-user-button";
import { ManualVerifyButton } from "@/features/admin/components/manual-verify-button";
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function AdminUserDetailPage({ params }: PageProps) {
const session = await getServerSession(authOptions);
if (!session?.user) redirect("/login?callbackUrl=/admin/users");
if (!isAdminEmail(session.user.email)) {
return (
<div className="mx-auto max-w-2xl px-4 py-12 text-center">
<p className="text-sm text-neutral-600">
Halaman ini hanya untuk admin SeTrip.
</p>
</div>
);
}
const { id } = await params;
const user = await userRepo.findByIdForAdmin(id);
if (!user) notFound();
const isSelf = user.id === session.user.id;
const totalSpent = user.bookings
.filter((b) => b.status === "PAID" || b.status === "PARTIALLY_REFUNDED")
.reduce((sum, b) => sum + b.amount, 0);
return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:py-12">
<div className="mb-4 text-xs text-neutral-500">
<Link
href="/admin/users"
className="inline-flex items-center gap-1 hover:text-primary-600"
>
<ArrowLeft size={14} strokeWidth={2} aria-hidden />
Kembali ke list users
</Link>
</div>
<header
className={`mb-6 rounded-2xl border p-5 shadow-sm sm:p-6 ${
user.suspended
? "border-red-300 bg-red-50/60"
: "border-neutral-200 bg-white"
}`}
>
<div className="flex flex-wrap items-start gap-4">
{user.image ? (
<Image
src={user.image}
alt=""
width={64}
height={64}
className="h-16 w-16 shrink-0 rounded-full object-cover"
/>
) : (
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-primary-600 text-xl font-bold text-white">
{user.name.charAt(0).toUpperCase()}
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-xl font-bold text-neutral-900 sm:text-2xl">
{user.name}
</h1>
{user.suspended && (
<span className="rounded-full bg-red-100 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-red-800">
Suspended
</span>
)}
{user.organizerVerification?.status === "APPROVED" && (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-emerald-800">
<Check size={12} strokeWidth={2.5} aria-hidden />
Verified Organizer
</span>
)}
</div>
<p className="mt-0.5 text-sm text-neutral-600">{user.email}</p>
<p className="mt-0.5 text-[11px] text-neutral-500">
User ID:{" "}
<code className="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-neutral-700">
{user.id}
</code>
</p>
<p className="mt-1 text-xs text-neutral-500">
Bergabung{" "}
{user.createdAt.toLocaleDateString("id-ID", {
day: "numeric",
month: "long",
year: "numeric",
})}
{user.acceptedAt && (
<>
{" "}
· Setuju T&C{" "}
{user.acceptedAt.toLocaleDateString("id-ID", {
day: "numeric",
month: "short",
year: "numeric",
})}
</>
)}
</p>
</div>
</div>
</header>
<section className="mb-6 grid gap-3 sm:grid-cols-3">
<StatCard label="Trip dibuat" value={String(user.trips.length)} />
<StatCard label="Booking aktif" value={String(user.bookings.length)} />
<StatCard
label="Total spent (PAID)"
value={formatRupiah(totalSpent)}
accent="emerald"
/>
</section>
{user.suspended && (
<section className="mb-6 rounded-2xl border border-red-300 bg-red-50 p-4 sm:p-5">
<h2 className="flex items-center gap-1.5 text-sm font-bold text-red-900">
<Ban size={16} strokeWidth={2} aria-hidden />
Akun ditangguhkan
</h2>
<p className="mt-1 text-xs text-red-900/80">
{user.suspendedReason ?? "Tidak ada alasan tercatat."}
</p>
{user.suspendedBy && (
<p className="mt-2 text-[11px] text-red-900/70">
Disuspend oleh {user.suspendedBy.email}
{user.suspendedAt && (
<>
{" "}
pada{" "}
{user.suspendedAt.toLocaleString("id-ID", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</>
)}
</p>
)}
</section>
)}
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-5">
<h2 className="mb-3 text-sm font-bold text-neutral-900">
Aksi Admin
</h2>
{isSelf ? (
<p className="text-xs text-neutral-500">
Tidak bisa suspend / modifikasi akun sendiri.
</p>
) : (
<div className="flex flex-wrap items-start gap-3">
<SuspendUserButton userId={user.id} isSuspended={user.suspended} />
{!user.organizerVerification && (
<ManualVerifyButton
userId={user.id}
defaultBankAccountName={user.name}
/>
)}
</div>
)}
</section>
{user.profile && (
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-5">
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
Profil Sosial
</h2>
<dl className="grid gap-3 text-sm sm:grid-cols-2">
{user.profile.bio && (
<div className="sm:col-span-2">
<dt className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
Bio
</dt>
<dd className="whitespace-pre-wrap text-neutral-700">
{user.profile.bio}
</dd>
</div>
)}
{user.profile.city && (
<div>
<dt className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
Kota
</dt>
<dd className="text-neutral-700">{user.profile.city}</dd>
</div>
)}
{user.profile.vibe && (
<div>
<dt className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
Vibe
</dt>
<dd className="text-neutral-700">{user.profile.vibe}</dd>
</div>
)}
{user.profile.interests.length > 0 && (
<div className="sm:col-span-2">
<dt className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
Minat
</dt>
<dd className="mt-0.5 flex flex-wrap gap-1.5">
{user.profile.interests.map((tag) => (
<span
key={tag}
className="rounded-full bg-secondary-50 px-2 py-0.5 text-[11px] font-medium text-secondary-700"
>
#{tag}
</span>
))}
</dd>
</div>
)}
{user.profile.instagram && (
<div>
<dt className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
Instagram
</dt>
<dd className="text-neutral-700">@{user.profile.instagram}</dd>
</div>
)}
</dl>
</section>
)}
{user.organizerVerification && (
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-5">
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
Verifikasi Organizer
</h2>
<p className="text-sm text-neutral-700">
Status:{" "}
<span className="font-semibold">
{user.organizerVerification.status}
</span>
{" · "}
<Link
href={`/admin/verifications?tab=${user.organizerVerification.status}`}
className="inline-flex items-center gap-1 text-secondary-700 hover:text-secondary-900"
>
Buka di /admin/verifications
<ArrowUpRight size={14} strokeWidth={2} aria-hidden />
</Link>
</p>
{user.organizerVerification.rejectionReason && (
<p className="mt-1 text-xs text-red-700">
Reason: {user.organizerVerification.rejectionReason}
</p>
)}
</section>
)}
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-5">
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
Trip yang dibuat ({user.trips.length})
</h2>
{user.trips.length === 0 ? (
<p className="text-xs text-neutral-500">
User ini belum pernah membuat trip.
</p>
) : (
<ul className="divide-y divide-neutral-100">
{user.trips.map((t) => (
<li key={t.id} className="py-2.5">
<Link
href={`/admin/trips/${t.id}`}
className="flex items-center justify-between gap-3 text-sm hover:text-primary-700"
>
<div className="min-w-0 flex-1">
<p className="truncate font-semibold text-neutral-800">
{t.title}
</p>
<p className="text-[11px] text-neutral-500">
{t.destination} ·{" "}
{t.date.toLocaleDateString("id-ID", {
day: "numeric",
month: "short",
year: "numeric",
})}{" "}
· {t.status}
</p>
</div>
<p className="shrink-0 text-xs font-semibold text-primary-700">
{formatRupiah(t.price)}
</p>
</Link>
</li>
))}
</ul>
)}
</section>
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-5">
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
Booking sebagai peserta ({user.bookings.length})
</h2>
{user.bookings.length === 0 ? (
<p className="text-xs text-neutral-500">Belum ada booking.</p>
) : (
<ul className="divide-y divide-neutral-100">
{user.bookings.map((b) => (
<li key={b.id} className="py-2.5">
<Link
href={`/admin/bookings/${b.id}`}
className="flex items-center justify-between gap-3 text-sm hover:text-primary-700"
>
<div className="min-w-0 flex-1">
<p className="truncate font-semibold text-neutral-800">
{b.trip.title}
</p>
<p className="text-[11px] text-neutral-500">
{b.trip.date.toLocaleDateString("id-ID", {
day: "numeric",
month: "short",
year: "numeric",
})}{" "}
· status: <span className="font-semibold">{b.status}</span>
</p>
</div>
<p className="shrink-0 text-xs font-semibold text-primary-700">
{formatRupiah(b.amount)}
</p>
</Link>
</li>
))}
</ul>
)}
</section>
</div>
);
}
function StatCard({
label,
value,
accent = "primary",
}: {
label: string;
value: string;
accent?: "primary" | "emerald";
}) {
const cls = accent === "emerald" ? "text-emerald-700" : "text-primary-700";
return (
<div className="rounded-xl border border-neutral-200 bg-white p-3 sm:p-4">
<p className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
{label}
</p>
<p className={`mt-0.5 text-lg font-bold sm:text-xl ${cls}`}>{value}</p>
</div>
);
}
+178
View File
@@ -0,0 +1,178 @@
import Link from "next/link";
import Image from "next/image";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { Check, ChartColumn } from "lucide-react";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { userRepo } from "@/server/repositories/user.repo";
type Tab = "ALL" | "ACTIVE" | "SUSPENDED";
const TABS: { key: Tab; label: string }[] = [
{ key: "ALL", label: "Semua" },
{ key: "ACTIVE", label: "Aktif" },
{ key: "SUSPENDED", label: "Suspended" },
];
interface PageProps {
searchParams: Promise<{ tab?: string; q?: string }>;
}
export default async function AdminUsersPage({ searchParams }: PageProps) {
const session = await getServerSession(authOptions);
if (!session?.user) redirect("/login?callbackUrl=/admin/users");
if (!isAdminEmail(session.user.email)) {
return (
<div className="mx-auto max-w-2xl px-4 py-12 text-center">
<p className="text-sm text-neutral-600">
Halaman ini hanya untuk admin SeTrip.
</p>
</div>
);
}
const params = await searchParams;
const tab: Tab = TABS.some((t) => t.key === params.tab)
? (params.tab as Tab)
: "ALL";
const q = (params.q ?? "").trim();
const users = await userRepo.searchForAdmin({
q: q || undefined,
suspended: tab === "SUSPENDED" ? true : tab === "ACTIVE" ? false : undefined,
});
return (
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
<header className="mb-6 flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
User Management
</h1>
<p className="mt-1 text-sm text-neutral-500">
Cari user, lihat history booking & trip, dan suspend akun yang
melakukan abuse (scam, harassment, TOS violation).
</p>
</div>
<Link
href="/admin/users/stats"
className="inline-flex items-center gap-1.5 rounded-xl border border-neutral-200 bg-white px-3 py-1.5 text-xs font-semibold text-neutral-700 hover:bg-neutral-50"
>
<ChartColumn size={16} strokeWidth={2} aria-hidden />
Stats
</Link>
</header>
<form method="get" className="mb-4 flex gap-2">
<input type="hidden" name="tab" value={tab} />
<input
name="q"
defaultValue={q}
placeholder="Cari email atau nama..."
className="flex-1 rounded-xl border border-neutral-200 bg-white px-4 py-2 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-primary-400"
/>
<button
type="submit"
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700"
>
Cari
</button>
{q && (
<Link
href={`/admin/users?tab=${tab}`}
className="rounded-xl border border-neutral-200 bg-white px-4 py-2 text-sm font-semibold text-neutral-600 hover:bg-neutral-50"
>
Reset
</Link>
)}
</form>
<div className="mb-6 flex flex-wrap gap-2">
{TABS.map((t) => (
<Link
key={t.key}
href={`/admin/users?tab=${t.key}${q ? `&q=${encodeURIComponent(q)}` : ""}`}
className={`rounded-full px-4 py-1.5 text-sm font-semibold transition-colors ${
tab === t.key
? "bg-primary-600 text-white"
: "bg-neutral-100 text-neutral-600 hover:bg-neutral-200"
}`}
>
{t.label}
</Link>
))}
</div>
{users.length === 0 ? (
<div className="rounded-2xl border border-dashed border-neutral-300 bg-white p-10 text-center">
<p className="text-sm text-neutral-500">
{q
? `Tidak ada user yang cocok dengan "${q}".`
: "Tidak ada user pada tab ini."}
</p>
</div>
) : (
<ul className="space-y-2">
{users.map((u) => (
<li
key={u.id}
className={`rounded-2xl border bg-white p-3 shadow-sm transition-shadow hover:shadow-md sm:p-4 ${
u.suspended ? "border-red-200" : "border-neutral-200"
}`}
>
<Link
href={`/admin/users/${u.id}`}
className="flex items-center gap-3"
>
{u.image ? (
<Image
src={u.image}
alt=""
width={40}
height={40}
className="h-10 w-10 shrink-0 rounded-full object-cover"
/>
) : (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary-600 text-sm font-bold text-white">
{u.name.charAt(0).toUpperCase()}
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="truncate text-sm font-semibold text-neutral-800">
{u.name}
</p>
{u.suspended && (
<span className="rounded-full bg-red-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-red-800">
Suspended
</span>
)}
{u.organizerVerification?.status === "APPROVED" && (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-emerald-800">
<Check size={12} strokeWidth={2.5} aria-hidden />
Organizer
</span>
)}
</div>
<p className="truncate text-xs text-neutral-500">{u.email}</p>
<p className="mt-0.5 text-[11px] text-neutral-400">
Bergabung{" "}
{u.createdAt.toLocaleDateString("id-ID", {
day: "numeric",
month: "short",
year: "numeric",
})}
{" · "}
{u._count.trips} trip dibuat, {u._count.participations}{" "}
booking
</p>
</div>
</Link>
</li>
))}
</ul>
)}
</div>
);
}
+207
View File
@@ -0,0 +1,207 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { ArrowLeft } from "lucide-react";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { prisma } from "@/lib/prisma";
const DAY_MS = 24 * 60 * 60 * 1000;
interface WeeklyBucket {
weekStart: Date;
label: string;
count: number;
}
function thirtyDaysAgoDate(): Date {
return new Date(Date.now() - 30 * DAY_MS);
}
async function getSignupsPerWeek(weeks = 8): Promise<WeeklyBucket[]> {
const now = new Date();
const startMs = now.getTime() - weeks * 7 * DAY_MS;
const startDate = new Date(startMs);
const users = await prisma.user.findMany({
where: { createdAt: { gte: startDate } },
select: { createdAt: true },
});
// Bucketize per week (Senin sebagai start, supaya konsisten dengan kalender Indonesia).
const buckets: WeeklyBucket[] = [];
for (let i = weeks - 1; i >= 0; i--) {
const bucketStart = new Date(now.getTime() - (i + 1) * 7 * DAY_MS);
bucketStart.setUTCHours(0, 0, 0, 0);
const bucketEnd = new Date(bucketStart.getTime() + 7 * DAY_MS);
const count = users.filter(
(u) => u.createdAt >= bucketStart && u.createdAt < bucketEnd
).length;
buckets.push({
weekStart: bucketStart,
label: bucketStart.toLocaleDateString("id-ID", {
day: "2-digit",
month: "short",
}),
count,
});
}
return buckets;
}
export default async function AdminUserStatsPage() {
const session = await getServerSession(authOptions);
if (!session?.user) redirect("/login?callbackUrl=/admin/users/stats");
if (!isAdminEmail(session.user.email)) {
return (
<div className="mx-auto max-w-2xl px-4 py-12 text-center">
<p className="text-sm text-neutral-600">
Halaman ini hanya untuk admin SeTrip.
</p>
</div>
);
}
const thirtyDaysAgo = thirtyDaysAgoDate();
const [
totalUsers,
suspendedUsers,
verifiedOrganizers,
activeOrganizers30d,
paidParticipants30d,
weekly,
] = await Promise.all([
prisma.user.count(),
prisma.user.count({ where: { suspended: true } }),
prisma.organizerVerification.count({ where: { status: "APPROVED" } }),
prisma.user.count({
where: {
trips: { some: { createdAt: { gte: thirtyDaysAgo } } },
},
}),
prisma.user.count({
where: {
bookings: {
some: {
status: "PAID",
createdAt: { gte: thirtyDaysAgo },
},
},
},
}),
getSignupsPerWeek(8),
]);
const maxWeeklyCount = Math.max(1, ...weekly.map((w) => w.count));
return (
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
<div className="mb-4 text-xs text-neutral-500">
<Link
href="/admin/users"
className="inline-flex items-center gap-1 hover:text-primary-600"
>
<ArrowLeft size={14} strokeWidth={2} aria-hidden />
Kembali ke list users
</Link>
</div>
<header className="mb-6">
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
User Analytics
</h1>
<p className="mt-1 text-sm text-neutral-500">
Snapshot pertumbuhan user. Real-time read langsung dari DB tidak
ada cache, refresh halaman untuk angka terbaru.
</p>
</header>
<section className="mb-8 grid gap-3 sm:grid-cols-3">
<StatCard label="Total Users" value={totalUsers} />
<StatCard
label="Suspended"
value={suspendedUsers}
accent="red"
/>
<StatCard
label="Verified Organizers"
value={verifiedOrganizers}
accent="emerald"
/>
<StatCard
label="Organizer Aktif (30 hari)"
value={activeOrganizers30d}
accent="secondary"
sub="Bikin trip baru"
/>
<StatCard
label="Peserta Aktif (30 hari)"
value={paidParticipants30d}
accent="primary"
sub="Booking PAID"
/>
</section>
<section className="rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-6">
<h2 className="mb-1 text-sm font-bold text-neutral-900">
Signup per Minggu (8 minggu terakhir)
</h2>
<p className="mb-4 text-xs text-neutral-500">
Tiap bar = 1 minggu (mulai hari ini mundur). Angka di atas bar = total
signup minggu itu.
</p>
<div className="flex h-48 items-end gap-2">
{weekly.map((w) => {
const heightPct = (w.count / maxWeeklyCount) * 100;
return (
<div
key={w.weekStart.toISOString()}
className="flex flex-1 flex-col items-center gap-1"
>
<span className="text-[10px] font-semibold text-neutral-700">
{w.count}
</span>
<div
className="w-full rounded-t-md bg-primary-500/80"
style={{ height: `${Math.max(heightPct, 2)}%` }}
title={`${w.count} signup minggu ${w.label}`}
/>
<span className="text-[10px] text-neutral-500">{w.label}</span>
</div>
);
})}
</div>
</section>
</div>
);
}
function StatCard({
label,
value,
sub,
accent = "neutral",
}: {
label: string;
value: number;
sub?: string;
accent?: "neutral" | "primary" | "secondary" | "emerald" | "red";
}) {
const map = {
neutral: "text-neutral-800",
primary: "text-primary-700",
secondary: "text-secondary-700",
emerald: "text-emerald-700",
red: "text-red-700",
};
return (
<div className="rounded-xl border border-neutral-200 bg-white p-4 shadow-sm">
<p className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
{label}
</p>
<p className={`mt-0.5 text-2xl font-bold ${map[accent]}`}>{value}</p>
{sub && <p className="text-[11px] text-neutral-500">{sub}</p>}
</div>
);
}
+54 -12
View File
@@ -1,15 +1,28 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin"; import { isAdminEmail, listAdminEmails } from "@/lib/admin";
import { organizerRepo } from "@/server/repositories/organizer.repo"; import { organizerRepo } from "@/server/repositories/organizer.repo";
import { organizerService } from "@/server/services/organizer.service"; import { organizerService } from "@/server/services/organizer.service";
import { AdminFilterBar } from "@/features/admin/components/admin-filter-bar";
import { ExportCsvLink } from "@/features/admin/components/export-csv-link";
import { ReviewCard } from "@/features/organizer/components/review-card"; import { ReviewCard } from "@/features/organizer/components/review-card";
type Tab = "PENDING" | "APPROVED" | "REJECTED"; type Tab = "PENDING" | "APPROVED" | "REJECTED";
interface PageProps { interface PageProps {
searchParams: Promise<{ tab?: string }>; searchParams: Promise<{
tab?: string;
dateFrom?: string;
dateTo?: string;
reviewer?: string;
}>;
}
function parseDate(value: string | undefined): Date | undefined {
if (!value) return undefined;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? undefined : d;
} }
export default async function AdminVerificationsPage({ searchParams }: PageProps) { export default async function AdminVerificationsPage({ searchParams }: PageProps) {
@@ -29,7 +42,11 @@ export default async function AdminVerificationsPage({ searchParams }: PageProps
const tab: Tab = const tab: Tab =
params.tab === "APPROVED" || params.tab === "REJECTED" ? params.tab : "PENDING"; params.tab === "APPROVED" || params.tab === "REJECTED" ? params.tab : "PENDING";
const rows = await organizerRepo.listByStatus(tab); const rows = await organizerRepo.listByStatus(tab, {
dateFrom: parseDate(params.dateFrom),
dateTo: parseDate(params.dateTo),
reviewerEmail: params.reviewer || undefined,
});
const items = rows.map((v) => ({ const items = rows.map((v) => ({
id: v.id, id: v.id,
fullName: v.fullName, fullName: v.fullName,
@@ -53,18 +70,41 @@ export default async function AdminVerificationsPage({ searchParams }: PageProps
{ key: "REJECTED", label: "Ditolak" }, { key: "REJECTED", label: "Ditolak" },
]; ];
const exportQuery = new URLSearchParams({ status: tab });
if (params.dateFrom) exportQuery.set("dateFrom", params.dateFrom);
if (params.dateTo) exportQuery.set("dateTo", params.dateTo);
if (params.reviewer) exportQuery.set("reviewer", params.reviewer);
return ( return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:py-12"> <div className="mx-auto max-w-4xl px-4 py-8 sm:py-12">
<header className="mb-6"> <header className="mb-6 flex flex-wrap items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl"> <div>
Review Verifikasi Organizer <h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
</h1> Review Verifikasi Organizer
<p className="mt-1 text-sm text-neutral-500"> </h1>
Periksa data KTP, foto liveness (memegang kertas SETRIP), dan rekening <p className="mt-1 text-sm text-neutral-500">
sebelum menyetujui. Periksa data KTP, foto liveness (memegang kertas SETRIP), dan rekening
</p> sebelum menyetujui.
</p>
</div>
<ExportCsvLink
href="/api/admin/export/verifications"
query={exportQuery.toString()}
/>
</header> </header>
<AdminFilterBar
action="/admin/verifications"
values={{
tab,
dateFrom: params.dateFrom,
dateTo: params.dateTo,
reviewer: params.reviewer,
}}
reviewerOptions={listAdminEmails()}
reviewerLabel="Reviewer"
/>
<div className="mb-6 flex gap-2"> <div className="mb-6 flex gap-2">
{tabs.map((t) => ( {tabs.map((t) => (
<a <a
@@ -83,7 +123,9 @@ export default async function AdminVerificationsPage({ searchParams }: PageProps
{items.length === 0 ? ( {items.length === 0 ? (
<div className="rounded-2xl border border-dashed border-neutral-300 bg-white p-10 text-center"> <div className="rounded-2xl border border-dashed border-neutral-300 bg-white p-10 text-center">
<p className="text-sm text-neutral-500">Tidak ada data.</p> <p className="text-sm text-neutral-500">
Tidak ada data yang cocok dengan filter ini.
</p>
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
+96
View File
@@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { payoutRepo } from "@/server/repositories/payout.repo";
import { buildCsv, csvDateJakarta, csvResponse } from "@/lib/csv";
import type { PayoutStatus } from "@/app/generated/prisma/enums";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const VALID_STATUS = new Set<PayoutStatus>([
"HELD",
"RELEASED",
"PAID",
"CANCELLED",
]);
function parseDate(value: string | null): Date | undefined {
if (!value) return undefined;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? undefined : d;
}
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user || !isAdminEmail(session.user.email)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const params = req.nextUrl.searchParams;
const statusParam = params.get("status");
if (!statusParam || !VALID_STATUS.has(statusParam as PayoutStatus)) {
return NextResponse.json(
{ error: "status param wajib (HELD/RELEASED/PAID/CANCELLED)" },
{ status: 400 }
);
}
const status = statusParam as PayoutStatus;
const rows = await payoutRepo.listByStatus(status, {
dateFrom: parseDate(params.get("dateFrom")),
dateTo: parseDate(params.get("dateTo")),
processorEmail: params.get("reviewer") || undefined,
});
const csv = buildCsv(
[
"Payout ID",
"Status",
"Nominal (IDR)",
"Currency",
"Held until",
"Released at",
"Paid at",
"Cancelled at",
"Processor email",
"Admin note",
"Dibuat",
"Organizer nama",
"Organizer email",
"Bank nama",
"Bank rekening",
"Bank atas nama",
"Trip ID",
"Trip judul",
"Booking ID",
"Peserta nama",
],
rows.map((p) => [
p.id,
p.status,
p.amount,
p.currency,
csvDateJakarta(p.heldUntil),
csvDateJakarta(p.releasedAt),
csvDateJakarta(p.paidAt),
csvDateJakarta(p.cancelledAt),
p.processedBy?.email ?? "",
p.adminNote ?? "",
csvDateJakarta(p.createdAt),
p.organizer.name,
p.organizer.email,
p.bankName ?? "",
p.bankAccountNumber ?? "",
p.bankAccountName ?? "",
p.trip.id,
p.trip.title,
p.booking.id,
p.booking.user.name,
])
);
const today = new Date().toISOString().slice(0, 10);
return csvResponse(`payouts-${status}-${today}.csv`, csv);
}
+114
View File
@@ -0,0 +1,114 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { refundRepo } from "@/server/repositories/refund.repo";
import { buildCsv, csvDateJakarta, csvResponse } from "@/lib/csv";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const VALID_STATUS = new Set([
"PENDING",
"APPROVED",
"REJECTED",
"PROCESSING",
"SUCCEEDED",
"FAILED",
]);
const VALID_REASON = new Set([
"USER_CANCELLATION",
"ORGANIZER_CANCELLED",
"TRIP_ISSUE",
"ADMIN_ADJUSTMENT",
"DISPUTE_RESOLVED",
]);
function parseDate(value: string | null): Date | undefined {
if (!value) return undefined;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? undefined : d;
}
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user || !isAdminEmail(session.user.email)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const params = req.nextUrl.searchParams;
const statusParam = params.get("status");
const status =
statusParam && VALID_STATUS.has(statusParam)
? (statusParam as
| "PENDING"
| "APPROVED"
| "REJECTED"
| "PROCESSING"
| "SUCCEEDED"
| "FAILED")
: undefined;
const reasonParam = params.get("reason");
const reason =
reasonParam && VALID_REASON.has(reasonParam)
? (reasonParam as
| "USER_CANCELLATION"
| "ORGANIZER_CANCELLED"
| "TRIP_ISSUE"
| "ADMIN_ADJUSTMENT"
| "DISPUTE_RESOLVED")
: undefined;
const rows = await refundRepo.listByStatus(status, {
dateFrom: parseDate(params.get("dateFrom")),
dateTo: parseDate(params.get("dateTo")),
reviewerEmail: params.get("reviewer") || undefined,
reason,
});
const csv = buildCsv(
[
"Refund ID",
"Status",
"Reason",
"Nominal (IDR)",
"Dilaporkan oleh",
"Catatan laporan",
"Catatan admin",
"Dibuat",
"Reviewed at",
"Succeeded at",
"Failed at",
"Reviewer email",
"Booking ID",
"Peserta nama",
"Peserta email",
"Trip ID",
"Trip judul",
"Trip tanggal",
],
rows.map((r) => [
r.id,
r.status,
r.reason,
r.amount,
r.reportedBy,
r.reportNote,
r.adminNote ?? "",
csvDateJakarta(r.createdAt),
csvDateJakarta(r.reviewedAt),
csvDateJakarta(r.succeededAt),
csvDateJakarta(r.failedAt),
r.reviewedBy?.email ?? "",
r.booking.id,
r.booking.user.name,
r.booking.user.email,
r.booking.trip.id,
r.booking.trip.title,
csvDateJakarta(r.booking.trip.date),
])
);
const today = new Date().toISOString().slice(0, 10);
return csvResponse(`refunds-${status ?? "all"}-${today}.csv`, csv);
}
@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { organizerRepo } from "@/server/repositories/organizer.repo";
import { buildCsv, csvDateJakarta, csvResponse } from "@/lib/csv";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const VALID_STATUS = new Set(["PENDING", "APPROVED", "REJECTED"]);
function parseDate(value: string | null): Date | undefined {
if (!value) return undefined;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? undefined : d;
}
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user || !isAdminEmail(session.user.email)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const params = req.nextUrl.searchParams;
const statusParam = params.get("status");
const status =
statusParam && VALID_STATUS.has(statusParam)
? (statusParam as "PENDING" | "APPROVED" | "REJECTED")
: undefined;
const rows = await organizerRepo.listByStatus(status, {
dateFrom: parseDate(params.get("dateFrom")),
dateTo: parseDate(params.get("dateTo")),
reviewerEmail: params.get("reviewer") || undefined,
});
// SENGAJA tidak ekspor: NIK plaintext (encrypted), ktpImageKey, livenessKey,
// bankAccountNumber. Export ini hanya untuk metadata audit — KYC sensitive
// info tetap di DB & cuma diakses lewat admin UI dengan auth gate.
const csv = buildCsv(
[
"Verification ID",
"Status",
"Nama (KTP)",
"User nama",
"User email",
"Bank nama",
"Bank atas nama",
"Dibuat",
"Reviewed at",
"Verified at",
"Rejection reason",
"Reviewer email",
],
rows.map((v) => [
v.id,
v.status,
v.fullName,
v.user.name,
v.user.email,
v.bankName,
v.bankAccountName,
csvDateJakarta(v.createdAt),
csvDateJakarta(v.reviewedAt),
csvDateJakarta(v.verifiedAt),
v.rejectionReason ?? "",
v.reviewedBy?.email ?? "",
])
);
const today = new Date().toISOString().slice(0, 10);
return csvResponse(`verifications-${status ?? "all"}-${today}.csv`, csv);
}
+19
View File
@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { adminSearchService } from "@/server/services/admin-search.service";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user || !isAdminEmail(session.user.email)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const q = req.nextUrl.searchParams.get("q") ?? "";
const hits = await adminSearchService.resolve(q, 10);
return NextResponse.json({ hits });
}
+10 -11
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { tripService } from "@/server/services/trip.service"; import { tripService } from "@/server/services/trip.service";
import { payoutService } from "@/server/services/payout.service"; import { payoutService } from "@/server/services/payout.service";
import { runCron } from "@/lib/cron-runner";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -30,27 +31,25 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }
try { const outcome = await runCron("auto-complete-trips", async () => {
const result = await tripService.autoCompletePastTrips(); const result = await tripService.autoCompletePastTrips();
// Setelah trip COMPLETED, payout yang sudah lewat heldUntil di-release // Setelah trip COMPLETED, payout yang sudah lewat heldUntil di-release
// supaya admin bisa langsung transfer ke organizer. Idempotent. // supaya admin bisa langsung transfer ke organizer. Idempotent.
const releaseResult = await payoutService.releaseEligible(); const releaseResult = await payoutService.releaseEligible();
console.log("[cron/auto-complete-trips] selesai", { return {
completed: result.count,
ids: result.ids,
payoutsReleased: releaseResult.releasedIds.length,
});
return NextResponse.json({
ok: true,
completed: result.count, completed: result.count,
ids: result.ids, ids: result.ids,
payoutsReleased: releaseResult.releasedIds, payoutsReleased: releaseResult.releasedIds,
}); };
} catch (err) { });
console.error("[cron/auto-complete-trips] gagal", err);
if (!outcome.ok) {
console.error("[cron/auto-complete-trips] gagal", outcome.error);
return NextResponse.json( return NextResponse.json(
{ error: "Gagal menjalankan auto-complete" }, { error: "Gagal menjalankan auto-complete" },
{ status: 500 } { status: 500 }
); );
} }
console.log("[cron/auto-complete-trips] selesai", outcome.payload);
return NextResponse.json({ ok: true, ...outcome.payload });
} }
+74
View File
@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from "next/server";
import { runCron } from "@/lib/cron-runner";
import { prisma } from "@/lib/prisma";
import {
deleteTripImage,
listTripImageNames,
tripImageMtime,
TRIP_IMAGE_URL_PREFIX,
} from "@/lib/trip-image-storage";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/** File yang lebih tua dari ini & tak direferensikan DB dianggap yatim. */
const ORPHAN_AGE_MS = 24 * 60 * 60 * 1000;
/**
* Cron — hapus file gambar trip yatim.
*
* Form create-trip multi-step mengunggah foto SEBELUM trip tersimpan; kalau
* user menutup form di tengah jalan, file menggantung di disk tanpa pernah
* jadi `TripImage`. Sweep ini menghapus file >24 jam yang tidak direferensikan
* `TripImage` mana pun. Idempotent — aman dijalankan berulang.
*
* Trigger: lihat docs/CRON_SETUP.md. Header wajib `Authorization: Bearer ${CRON_SECRET}`.
*/
export async function GET(req: NextRequest) {
const secret = process.env.CRON_SECRET;
if (!secret) {
console.error("[cron/cleanup-trip-images] CRON_SECRET tidak di-set");
return NextResponse.json(
{ error: "Server misconfigured" },
{ status: 500 }
);
}
if (req.headers.get("authorization") !== `Bearer ${secret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const outcome = await runCron("cleanup-trip-images", async () => {
const names = await listTripImageNames();
if (names.length === 0) return { scanned: 0, deleted: 0 };
const referenced = await prisma.tripImage.findMany({
where: { url: { startsWith: TRIP_IMAGE_URL_PREFIX } },
select: { url: true },
});
const referencedNames = new Set(
referenced.map((r) => r.url.slice(TRIP_IMAGE_URL_PREFIX.length))
);
const now = Date.now();
let deleted = 0;
for (const name of names) {
if (referencedNames.has(name)) continue;
const mtime = await tripImageMtime(name);
// File baru di-upload tapi trip belum tersimpan → beri tenggang 24 jam.
if (!mtime || now - mtime.getTime() < ORPHAN_AGE_MS) continue;
await deleteTripImage(name);
deleted++;
}
return { scanned: names.length, deleted };
});
if (!outcome.ok) {
console.error("[cron/cleanup-trip-images] gagal", outcome.error);
return NextResponse.json(
{ error: "Gagal menjalankan cleanup" },
{ status: 500 }
);
}
console.log("[cron/cleanup-trip-images] selesai", outcome.payload);
return NextResponse.json({ ok: true, ...outcome.payload });
}
+36
View File
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { emailService } from "@/lib/email/send";
import { runCron } from "@/lib/cron-runner";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Cron — proses retry queue email (jobs status PENDING/FAILED dengan
* attempts<5 dan scheduledAt sudah lewat).
*
* Trigger setiap 5 menit via system crontab — lihat docs/CRON_SETUP.md.
* Header wajib: `Authorization: Bearer ${CRON_SECRET}`.
*/
export async function GET(req: NextRequest) {
const secret = process.env.CRON_SECRET;
if (!secret) {
return NextResponse.json(
{ error: "Server misconfigured (CRON_SECRET)" },
{ status: 500 }
);
}
const authHeader = req.headers.get("authorization");
if (authHeader !== `Bearer ${secret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const outcome = await runCron("process-email-jobs", async () => {
return emailService.processQueue(50);
});
if (!outcome.ok) {
return NextResponse.json({ error: outcome.error }, { status: 500 });
}
return NextResponse.json({ ok: true, ...outcome.payload });
}
+38
View File
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { isValidTripImageName, readTripImage } from "@/lib/trip-image-storage";
export const runtime = "nodejs";
interface RouteCtx {
params: Promise<{ name: string }>;
}
/**
* Sajikan gambar trip dari disk lokal. Publik — gambar trip memang tampil ke
* semua pengunjung. Di-cache `immutable` selama setahun: nama file
* content-addressed (hex acak), jadi konten untuk satu nama tidak pernah
* berubah. Beban render = baca file kecil dari disk, tanpa fetch eksternal.
*/
export async function GET(_req: NextRequest, ctx: RouteCtx) {
const { name } = await ctx.params;
if (!isValidTripImageName(name)) {
return NextResponse.json({ error: "Tidak ditemukan" }, { status: 404 });
}
let data: Buffer;
try {
data = await readTripImage(name);
} catch {
return NextResponse.json({ error: "Tidak ditemukan" }, { status: 404 });
}
return new NextResponse(new Uint8Array(data), {
status: 200,
headers: {
"Content-Type": "image/webp",
"Content-Length": String(data.length),
"Cache-Control": "public, max-age=31536000, immutable",
"X-Content-Type-Options": "nosniff",
},
});
}
+73
View File
@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { requireActiveUser } from "@/lib/auth-guards";
import {
ALLOWED_TRIP_IMAGE_MIME,
MAX_TRIP_IMAGE_UPLOAD_BYTES,
processAndSaveTripImage,
} from "@/lib/trip-image-storage";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Upload satu foto trip. Dipanggil dari form create-trip saat user memilih
* file — gambar langsung dikompres & disimpan, route mengembalikan URL publik
* yang nanti ikut disubmit bersama data trip.
*
* File yatim (di-upload tapi trip batal dibuat) dibersihkan cron
* `/api/cron/cleanup-trip-images`.
*/
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
await requireActiveUser(session.user.id);
} catch (err) {
return NextResponse.json(
{ error: (err as Error).message },
{ status: 403 }
);
}
let form: FormData;
try {
form = await req.formData();
} catch {
return NextResponse.json(
{ error: "Body bukan multipart/form-data" },
{ status: 400 }
);
}
const file = form.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "File wajib diisi" }, { status: 400 });
}
if (!ALLOWED_TRIP_IMAGE_MIME.has(file.type)) {
return NextResponse.json(
{ error: "Hanya menerima JPG, PNG, atau WebP" },
{ status: 415 }
);
}
if (file.size > MAX_TRIP_IMAGE_UPLOAD_BYTES) {
return NextResponse.json(
{ error: "Ukuran file maksimal 12MB" },
{ status: 413 }
);
}
try {
const buf = Buffer.from(await file.arrayBuffer());
const saved = await processAndSaveTripImage(buf);
return NextResponse.json({ url: saved.url, size: saved.size });
} catch (err) {
return NextResponse.json(
{ error: (err as Error).message || "Gagal memproses gambar" },
{ status: 400 }
);
}
}
+53
View File
@@ -64,6 +64,16 @@ select:focus {
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1) !important; box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1) !important;
} }
/* Input wrapper isi penuh lebar parent + popper di atas konten lain */
.react-datepicker-wrapper,
.react-datepicker__input-container {
width: 100% !important;
}
.react-datepicker-popper {
z-index: 50 !important;
}
.react-datepicker__header { .react-datepicker__header {
background: #f9fafb !important; background: #f9fafb !important;
border-bottom: 1px solid #e5e7eb !important; border-bottom: 1px solid #e5e7eb !important;
@@ -132,3 +142,46 @@ select:focus {
.react-datepicker__close-icon:hover::after { .react-datepicker__close-icon:hover::after {
background-color: #16a34a !important; background-color: #16a34a !important;
} }
/* Dropdown bulan / tahun (mode select) */
.react-datepicker__month-select,
.react-datepicker__year-select {
border: 1px solid #e5e7eb !important;
border-radius: 0.5rem !important;
padding: 2px 4px !important;
font-size: 0.8125rem !important;
background: #fff !important;
}
/* Pemilih jam (showTimeSelectOnly) */
.react-datepicker__time-container {
width: 96px !important;
}
.react-datepicker__time-container
.react-datepicker__time
.react-datepicker__time-box {
width: 96px !important;
}
.react-datepicker-time__header {
font-weight: 700 !important;
color: #1f2937 !important;
font-size: 0.8125rem !important;
}
.react-datepicker__time-list-item {
font-size: 0.8125rem !important;
color: #1f2937 !important;
}
.react-datepicker__time-list-item:hover {
background: #dcfce7 !important;
color: #15803d !important;
}
.react-datepicker__time-list-item--selected {
background: #16a34a !important;
color: #fff !important;
font-weight: 700 !important;
}
+31
View File
@@ -0,0 +1,31 @@
import type { MetadataRoute } from "next";
import { siteConfig } from "@/lib/site";
/**
* Web app manifest — dideteksi otomatis oleh Next App Router (`<link
* rel="manifest">` di-inject). Mendukung "Add to Home Screen" di mobile.
*/
export default function manifest(): MetadataRoute.Manifest {
return {
name: `${siteConfig.name}${siteConfig.slogan}`,
short_name: siteConfig.name,
description: siteConfig.description,
start_url: "/",
display: "standalone",
background_color: "#f9fafb",
theme_color: "#16a34a",
icons: [
{
src: "/images/SeTrip.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
{
src: "/SeTrip.ico",
sizes: "any",
type: "image/x-icon",
},
],
};
}
+8 -1
View File
@@ -7,7 +7,14 @@ export default function robots(): MetadataRoute.Robots {
{ {
userAgent: "*", userAgent: "*",
allow: "/", allow: "/",
disallow: ["/api/", "/profile", "/create-trip"], disallow: [
"/api/",
"/admin",
"/profile",
"/create-trip",
"/verify",
"/trips/*/payment",
],
}, },
], ],
sitemap: absoluteUrl("/sitemap.xml"), sitemap: absoluteUrl("/sitemap.xml"),
+6
View File
@@ -24,6 +24,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
changeFrequency: "hourly", changeFrequency: "hourly",
priority: 0.9, priority: 0.9,
}, },
{
url: absoluteUrl("/people"),
lastModified: now,
changeFrequency: "daily",
priority: 0.7,
},
{ {
url: absoluteUrl("/register"), url: absoluteUrl("/register"),
lastModified: now, lastModified: now,
+35 -16
View File
@@ -5,13 +5,33 @@ import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import Image from "next/image"; import Image from "next/image";
import { signOut } from "next-auth/react"; import { signOut } from "next-auth/react";
import {
ArrowLeft,
ArrowUpRight,
Banknote,
Compass,
IdCard,
LayoutDashboard,
Mail,
Menu,
ScrollText,
Settings,
Users,
X,
type LucideIcon,
} from "lucide-react";
import { AdminSearchBar } from "@/features/admin/components/admin-search-bar";
const NAV_ITEMS: { href: string; label: string; icon: string }[] = [ const NAV_ITEMS: { href: string; label: string; icon: LucideIcon }[] = [
{ href: "/admin", label: "Dashboard", icon: "📊" }, { href: "/admin", label: "Dashboard", icon: LayoutDashboard },
{ href: "/admin/trips", label: "Trips", icon: "🧭" }, { href: "/admin/trips", label: "Trips", icon: Compass },
{ href: "/admin/verifications", label: "Verifikasi", icon: "🪪" }, { href: "/admin/users", label: "Users", icon: Users },
{ href: "/admin/refunds", label: "Refund", icon: "↩️" }, { href: "/admin/verifications", label: "Verifikasi", icon: IdCard },
{ href: "/admin/payouts", label: "Payout", icon: "💸" }, { href: "/admin/refunds", label: "Refund", icon: ArrowLeft },
{ href: "/admin/payouts", label: "Payout", icon: Banknote },
{ href: "/admin/emails", label: "Email", icon: Mail },
{ href: "/admin/audit-log", label: "Audit Log", icon: ScrollText },
{ href: "/admin/system", label: "System", icon: Settings },
]; ];
interface AdminSidebarProps { interface AdminSidebarProps {
@@ -46,13 +66,9 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
aria-expanded={open} aria-expanded={open}
> >
{open ? ( {open ? (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> <X size={20} strokeWidth={2} aria-hidden />
<path d="M5 5l10 10M15 5L5 15" />
</svg>
) : ( ) : (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> <Menu size={20} strokeWidth={2} aria-hidden />
<path d="M3 5h14M3 10h14M3 15h14" />
</svg>
)} )}
</button> </button>
</header> </header>
@@ -91,12 +107,17 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
</div> </div>
</div> </div>
<div className="border-b border-neutral-100 p-3">
<AdminSearchBar />
</div>
<nav className="flex-1 overflow-y-auto p-3"> <nav className="flex-1 overflow-y-auto p-3">
<ul className="space-y-1"> <ul className="space-y-1">
{NAV_ITEMS.map((item) => { {NAV_ITEMS.map((item) => {
const isActive = const isActive =
pathname === item.href || pathname === item.href ||
(item.href !== "/admin" && pathname?.startsWith(item.href)); (item.href !== "/admin" && pathname?.startsWith(item.href));
const Icon = item.icon;
return ( return (
<li key={item.href}> <li key={item.href}>
<Link <Link
@@ -108,9 +129,7 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
: "text-neutral-700 hover:bg-neutral-100" : "text-neutral-700 hover:bg-neutral-100"
}`} }`}
> >
<span aria-hidden className="text-base"> <Icon size={20} strokeWidth={1.75} aria-hidden />
{item.icon}
</span>
<span>{item.label}</span> <span>{item.label}</span>
</Link> </Link>
</li> </li>
@@ -127,7 +146,7 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
className="flex items-center gap-3 rounded-lg px-3 py-2 text-xs font-medium text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700" className="flex items-center gap-3 rounded-lg px-3 py-2 text-xs font-medium text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700"
> >
<span aria-hidden></span> <ArrowUpRight size={16} strokeWidth={1.75} aria-hidden />
<span>Lihat situs publik</span> <span>Lihat situs publik</span>
</Link> </Link>
</li> </li>
+255
View File
@@ -0,0 +1,255 @@
"use client";
/**
* Komponen pemilih tanggal & jam bersama — satu-satunya tempat aplikasi
* memakai `react-datepicker`. Semua field tanggal/jam (form & filter) harus
* lewat sini supaya tampilan + locale konsisten.
*
* - `DateField` → satu tanggal (controlled atau uncontrolled untuk form).
* - `DateRangeField` → rentang tanggal (berangkatpulang, filter).
* - `TimeField` → jam "HH:mm" (itinerary).
*
* Tema visual di-override di `app/globals.css` (blok `.react-datepicker`).
*/
import { useState } from "react";
import ReactDatePicker, { registerLocale } from "react-datepicker";
import { id as idLocale } from "date-fns/locale";
import "react-datepicker/dist/react-datepicker.css";
import {
formatLocalCalendarYmd,
localCalendarDateFromYmd,
} from "@/lib/trip-dates";
import { isValidTimeFormat } from "@/lib/itinerary";
registerLocale("id", idLocale);
const FIELD_CLS =
"w-full rounded-xl border border-neutral-200 bg-neutral-50 py-2.5 pl-9 pr-3 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-primary-500 focus:bg-white";
function CalendarIcon() {
return (
<span className="pointer-events-none absolute left-3 top-1/2 z-10 -translate-y-1/2 text-neutral-400">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4"
>
<path
fillRule="evenodd"
d="M5.75 2a.75.75 0 01.75.75V4h7V2.75a.75.75 0 011.5 0V4h.25A2.75 2.75 0 0118 6.75v8.5A2.75 2.75 0 0115.25 18H4.75A2.75 2.75 0 012 15.25v-8.5A2.75 2.75 0 014.75 4H5V2.75A.75.75 0 015.75 2zm-1 5.5c-.69 0-1.25.56-1.25 1.25v6.5c0 .69.56 1.25 1.25 1.25h10.5c.69 0 1.25-.56 1.25-1.25v-6.5c0-.69-.56-1.25-1.25-1.25H4.75z"
clipRule="evenodd"
/>
</svg>
</span>
);
}
function ClockIcon() {
return (
<span className="pointer-events-none absolute left-3 top-1/2 z-10 -translate-y-1/2 text-neutral-400">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm.75-13a.75.75 0 00-1.5 0v5c0 .284.16.544.415.67l3 1.5a.75.75 0 00.67-1.34L10.75 9.54V5z"
clipRule="evenodd"
/>
</svg>
</span>
);
}
interface DateFieldProps {
/** Mode controlled. Kalau `undefined`, komponen jalan uncontrolled. */
value?: Date | null;
/** Nilai awal untuk mode uncontrolled (mis. di dalam form GET/POST biasa). */
defaultValue?: Date | null;
/**
* Nilai awal uncontrolled berupa `YYYY-MM-DD`. Dipakai saat parent adalah
* Server Component (mis. filter admin) — string di-parse di browser supaya
* tidak ada pergeseran timezone server↔klien.
*/
defaultValueYmd?: string;
onChange?: (date: Date | null) => void;
/** Kalau diisi, render hidden input `YYYY-MM-DD` supaya ikut ter-submit form. */
name?: string;
id?: string;
minDate?: Date;
maxDate?: Date;
placeholder?: string;
disabled?: boolean;
required?: boolean;
/** Dropdown bulan + tahun — cocok untuk tanggal lahir. */
withMonthYearDropdown?: boolean;
}
/** Pemilih satu tanggal. */
export function DateField({
value,
defaultValue = null,
defaultValueYmd,
onChange,
name,
id,
minDate,
maxDate,
placeholder = "Pilih tanggal...",
disabled = false,
required = false,
withMonthYearDropdown = false,
}: DateFieldProps) {
const isControlled = value !== undefined;
const [internal, setInternal] = useState<Date | null>(
() => (defaultValueYmd ? localCalendarDateFromYmd(defaultValueYmd) : defaultValue)
);
const current = isControlled ? value : internal;
function handleChange(date: Date | null) {
if (!isControlled) setInternal(date);
onChange?.(date);
}
// Default rentang masuk akal untuk picker bulan/tahun (mis. tanggal lahir).
const effectiveMin =
minDate ??
(withMonthYearDropdown
? new Date(new Date().getFullYear() - 120, 0, 1)
: undefined);
return (
<div className="relative">
<CalendarIcon />
<ReactDatePicker
id={id}
selected={current ?? null}
onChange={handleChange}
locale="id"
dateFormat="dd MMM yyyy"
minDate={effectiveMin}
maxDate={maxDate}
disabled={disabled}
required={required}
placeholderText={placeholder}
isClearable={!required && !disabled}
showMonthDropdown={withMonthYearDropdown}
showYearDropdown={withMonthYearDropdown}
dropdownMode="select"
className={FIELD_CLS}
wrapperClassName="w-full"
/>
{name && (
<input
type="hidden"
name={name}
value={current ? formatLocalCalendarYmd(current) : ""}
/>
)}
</div>
);
}
interface DateRangeFieldProps {
startDate: Date | null;
endDate: Date | null;
onChange: (start: Date | null, end: Date | null) => void;
minDate?: Date;
placeholder?: string;
id?: string;
}
/** Pemilih rentang tanggal (berangkatpulang, filter). */
export function DateRangeField({
startDate,
endDate,
onChange,
minDate,
placeholder = "Pilih tanggal...",
id,
}: DateRangeFieldProps) {
return (
<div className="relative">
<CalendarIcon />
<ReactDatePicker
id={id}
selectsRange
startDate={startDate}
endDate={endDate}
onChange={(dates) => {
const [start, end] = dates as [Date | null, Date | null];
onChange(start, end);
}}
locale="id"
dateFormat="dd MMM yyyy"
minDate={minDate}
isClearable
placeholderText={placeholder}
className={FIELD_CLS}
wrapperClassName="w-full"
/>
</div>
);
}
function timeStringToDate(value: string): Date | null {
if (!isValidTimeFormat(value)) return null;
const [h, m] = value.split(":").map(Number);
const d = new Date();
d.setHours(h, m, 0, 0);
return d;
}
function dateToTimeString(d: Date): string {
const h = String(d.getHours()).padStart(2, "0");
const m = String(d.getMinutes()).padStart(2, "0");
return `${h}:${m}`;
}
interface TimeFieldProps {
/** Jam dalam format "HH:mm", atau "" kalau kosong. */
value: string;
onChange: (value: string) => void;
id?: string;
placeholder?: string;
disabled?: boolean;
/** Tampilkan tombol clear (untuk jam opsional, mis. jam selesai). */
clearable?: boolean;
}
/** Pemilih jam "HH:mm" 24-jam dengan interval 15 menit. */
export function TimeField({
value,
onChange,
id,
placeholder = "--:--",
disabled = false,
clearable = false,
}: TimeFieldProps) {
return (
<div className="relative">
<ClockIcon />
<ReactDatePicker
id={id}
selected={timeStringToDate(value)}
onChange={(d: Date | null) => onChange(d ? dateToTimeString(d) : "")}
showTimeSelect
showTimeSelectOnly
timeIntervals={15}
timeCaption="Jam"
dateFormat="HH:mm"
timeFormat="HH:mm"
locale="id"
disabled={disabled}
isClearable={clearable && !disabled}
placeholderText={placeholder}
className={FIELD_CLS}
wrapperClassName="w-full"
/>
</div>
);
}
+3 -22
View File
@@ -4,6 +4,7 @@ import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { useSession, signOut } from "next-auth/react"; import { useSession, signOut } from "next-auth/react";
import { Menu, X } from "lucide-react";
export function Navbar() { export function Navbar() {
const { data: session } = useSession(); const { data: session } = useSession();
@@ -109,29 +110,9 @@ export function Navbar() {
aria-label="Toggle menu" aria-label="Toggle menu"
> >
{menuOpen ? ( {menuOpen ? (
<svg <X size={20} strokeWidth={1.75} aria-hidden />
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M5 5l10 10M15 5L5 15" />
</svg>
) : ( ) : (
<svg <Menu size={20} strokeWidth={1.75} aria-hidden />
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M3 5h14M3 10h14M3 15h14" />
</svg>
)} )}
</button> </button>
</div> </div>
+8 -11
View File
@@ -1,23 +1,20 @@
import { BadgeCheck } from "lucide-react";
type Size = "sm" | "md"; type Size = "sm" | "md";
export function VerifiedBadge({ size = "sm" }: { size?: Size }) { export function VerifiedBadge({ size = "sm" }: { size?: Size }) {
const cls = const cls =
size === "md" size === "md" ? "px-2.5 py-1 text-xs" : "px-2 py-0.5 text-[10px]";
? "px-2.5 py-1 text-xs"
: "px-2 py-0.5 text-[10px]";
return ( return (
<span <span
className={`inline-flex items-center gap-1 rounded-full bg-primary-50 font-semibold text-primary-700 ring-1 ring-primary-200 ${cls}`} className={`inline-flex items-center gap-1 rounded-full bg-primary-50 font-semibold text-primary-700 ring-1 ring-primary-200 ${cls}`}
title="Organizer terverifikasi SeTrip" title="Organizer terverifikasi SeTrip"
> >
<svg <BadgeCheck
viewBox="0 0 16 16" size={size === "md" ? 14 : 12}
fill="currentColor" strokeWidth={1.75}
className={size === "md" ? "h-3.5 w-3.5" : "h-3 w-3"} aria-hidden
aria-hidden="true" />
>
<path d="M8 0l2.09 1.74L12.86 1.5l.64 2.78 2.5 1.5-1.5 2.5.5 2.86-2.78.64-1.5 2.5-2.72-.59L5.5 14.5 4 12 1.5 11.36 2 8.5.5 6 3 4.5l.64-2.78 2.77.24L8 0zm-1.07 9.4l4.6-4.6-1.06-1.06-3.54 3.54-1.41-1.42-1.06 1.06 2.47 2.48z" />
</svg>
Verified Verified
</span> </span>
); );
+175 -21
View File
@@ -2,13 +2,23 @@
Aplikasi punya beberapa endpoint cron yang harus di-trigger periodik dari luar. Karena deploy pakai PM2 di VPS Linux (bukan Vercel), trigger schedule pakai **system crontab** — zero dependency, OS-native. Aplikasi punya beberapa endpoint cron yang harus di-trigger periodik dari luar. Karena deploy pakai PM2 di VPS Linux (bukan Vercel), trigger schedule pakai **system crontab** — zero dependency, OS-native.
> **Audit trail otomatis:** semua cron yang di-wrap `runCron()` helper auto-log ke tabel `CronRun` (start/finish/error). Cek hasilnya real-time di `/admin/system` — link "System" di sidebar admin. Tidak perlu tail log untuk monitoring rutin.
---
## Daftar cron job ## Daftar cron job
| Endpoint | Schedule | Tujuan | | # | Endpoint | Schedule | Frekuensi | Tujuan |
|---|---|---| |---|---|---|---|---|
| `GET /api/cron/auto-complete-trips` | `0 18 * * *` (18:00 UTC = 01:00 WIB) | Flip trip yang sudah lewat tanggal selesai dari `OPEN`/`FULL` ke `COMPLETED`. | | 1 | `GET /api/cron/auto-complete-trips` | `0 18 * * *` | Daily 01:00 WIB (18:00 UTC) | Flip trip yang sudah lewat tanggal selesai dari `OPEN`/`FULL` ke `COMPLETED`. Setelah itu, release payout HELD yang sudah lewat `heldUntil`. |
| 2 | `GET /api/cron/process-email-jobs` | `*/5 * * * *` | Setiap 5 menit | Drain retry queue email — pick `EmailJob` status `PENDING`/`FAILED` (attempts<5), retry via Resend dengan exponential backoff. |
| 3 | `GET /api/cron/cleanup-trip-images` | `30 18 * * *` | Daily 01:30 WIB (18:30 UTC) | Hapus file gambar trip yatim — foto yang di-upload di form create-trip tapi trip-nya batal dibuat. Hanya file >24 jam yang tak direferensikan `TripImage`. |
## Setup di server Semua cron pakai pola yang sama: header `Authorization: Bearer ${CRON_SECRET}`, idempotent, auto-log ke `CronRun`. Tambah cron baru = tambah baris di tabel ini + tabel `TRACKED_JOBS` di [app/admin/system/page.tsx](../app/admin/system/page.tsx).
---
## Setup di server (one-time)
### 1. Set `CRON_SECRET` di env production ### 1. Set `CRON_SECRET` di env production
@@ -30,7 +40,17 @@ Restart PM2 supaya proses re-load env:
pm2 restart setrip --update-env pm2 restart setrip --update-env
``` ```
### 2. Daftarkan crontab ### 2. Set env opsional untuk fitur lain yang di-trigger cron
| Env | Dibutuhkan oleh | Akibat kalau kosong |
|---|---|---|
| `RESEND_API_KEY` | `process-email-jobs` cron | Email tetap di-queue di DB; cron skip dengan warning. Set nanti dan cron akan auto-drain queue. |
| `EMAIL_FROM` | `process-email-jobs` cron | Pakai default `SeTrip <onboarding@resend.dev>` (cocok untuk dev/test). |
| `ADMIN_ALERT_WEBHOOK_URL` | `runCron` (semua cron) | Tidak ada Discord push notif saat cron FAILED. Admin tetap bisa cek manual di `/admin/system`. |
Semua env ini ada di [.env.example](../.env.example) dengan instruksi setup masing-masing.
### 3. Daftarkan crontab
Edit crontab user yang punya akses ke env (biasanya user yang menjalankan PM2): Edit crontab user yang punya akses ke env (biasanya user yang menjalankan PM2):
@@ -38,11 +58,19 @@ Edit crontab user yang punya akses ke env (biasanya user yang menjalankan PM2):
crontab -e crontab -e
``` ```
Tambah baris (ganti `https://your-domain.com` dan `<CRON_SECRET>` sesuai env yang di-set di step 1): Tambah baris berikut (ganti `https://your-domain.com` dan `<CRON_SECRET>` sesuai env yang di-set di step 1):
```cron ```cron
# Setrip — auto-complete trips harian (jam 01:00 WIB) # === Setrip cron jobs ===
# 1. Auto-complete trip + release payout (daily 01:00 WIB)
0 18 * * * curl -fsS -H "Authorization: Bearer <CRON_SECRET>" https://your-domain.com/api/cron/auto-complete-trips >> /var/log/setrip-cron.log 2>&1 0 18 * * * curl -fsS -H "Authorization: Bearer <CRON_SECRET>" https://your-domain.com/api/cron/auto-complete-trips >> /var/log/setrip-cron.log 2>&1
# 2. Drain email retry queue (setiap 5 menit)
*/5 * * * * curl -fsS -H "Authorization: Bearer <CRON_SECRET>" https://your-domain.com/api/cron/process-email-jobs >> /var/log/setrip-cron.log 2>&1
# 3. Bersihkan gambar trip yatim (daily 01:30 WIB)
30 18 * * * curl -fsS -H "Authorization: Bearer <CRON_SECRET>" https://your-domain.com/api/cron/cleanup-trip-images >> /var/log/setrip-cron.log 2>&1
``` ```
Verifikasi crontab tersimpan: Verifikasi crontab tersimpan:
@@ -51,57 +79,183 @@ Verifikasi crontab tersimpan:
crontab -l crontab -l
``` ```
### 3. Siapkan file log ### 4. Siapkan file log
```bash ```bash
sudo touch /var/log/setrip-cron.log sudo touch /var/log/setrip-cron.log
sudo chown $(whoami) /var/log/setrip-cron.log sudo chown $(whoami) /var/log/setrip-cron.log
``` ```
## Test manual Optional — logrotate supaya log tidak menggemuk:
```bash
sudo nano /etc/logrotate.d/setrip-cron
```
Isi:
```
/var/log/setrip-cron.log {
weekly
rotate 4
compress
missingok
notifempty
}
```
---
## Test manual (sanity check setelah deploy)
Sebelum tunggu schedule, panggil endpoint langsung untuk verifikasi: Sebelum tunggu schedule, panggil endpoint langsung untuk verifikasi:
```bash ```bash
# Test cron 1
curl -fsS -H "Authorization: Bearer $CRON_SECRET" https://your-domain.com/api/cron/auto-complete-trips curl -fsS -H "Authorization: Bearer $CRON_SECRET" https://your-domain.com/api/cron/auto-complete-trips
# Test cron 2
curl -fsS -H "Authorization: Bearer $CRON_SECRET" https://your-domain.com/api/cron/process-email-jobs
``` ```
**Expected response:** **Expected response per cron:**
- Belum ada trip yang lewat: `{"ok":true,"completed":0,"ids":[]}` | Cron | Sukses kosong | Sukses ada pekerjaan |
- Ada trip yang lewat: `{"ok":true,"completed":2,"ids":["clx...","cly..."]}` |---|---|---|
| `auto-complete-trips` | `{"ok":true,"completed":0,"ids":[],"payoutsReleased":[]}` | `{"ok":true,"completed":2,"ids":["clx...","cly..."],"payoutsReleased":["..."]}` |
| `process-email-jobs` | `{"ok":true,"picked":0,"succeeded":0,"failed":0}` | `{"ok":true,"picked":5,"succeeded":5,"failed":0}` |
| `cleanup-trip-images` | `{"ok":true,"scanned":0,"deleted":0}` | `{"ok":true,"scanned":12,"deleted":3}` |
**Kalau dapat 401:** `CRON_SECRET` di env tidak match dengan header. Cek `pm2 env <id>`. **Error response:**
- **401** — `CRON_SECRET` di env tidak match dengan header. Cek `pm2 env <id>`.
- **500 "Server misconfigured"** — `CRON_SECRET` belum di-set di env.
- **500 lain** — cek log app atau `/admin/system` untuk detail error.
**Kalau dapat 500:** `CRON_SECRET` belum di-set di env. ---
## Monitoring ## Monitoring
Tail log cron: ### Cara utama: `/admin/system` (admin panel)
Buka [/admin/system](/admin/system) — tampilkan:
- **Per-job summary card**: last run, last success, count 7 hari, error count 7 hari, health badge (🟢 OK / 🟡 STALE / 🔴 FAILED).
- **Recent runs table**: 20 cron run terakhir lintas semua job (waktu, status, payload, error).
- **Stale state alerts**: banner kuning kalau ada Payment AWAITING > 25h / Payout HELD overdue / Refund APPROVED > 7d.
Cek setiap pagi sebelum mulai kerja — kalau semua 🟢 OK, tidak ada incident.
### Cara backup: tail log file
```bash ```bash
tail -f /var/log/setrip-cron.log tail -f /var/log/setrip-cron.log
``` ```
Cek log app PM2 (untuk `console.log` dari endpoint): PM2 log untuk `console.log` dari endpoint:
```bash ```bash
pm2 logs setrip --lines 100 | grep cron pm2 logs setrip --lines 100 | grep cron
``` ```
### Discord push notif (opsional)
Kalau env `ADMIN_ALERT_WEBHOOK_URL` di-set ke Discord webhook URL, `runCron` otomatis kirim 🚨 message saat cron FAILED — admin bisa react langsung tanpa harus cek dashboard.
Cara setup: Discord channel → Edit Channel → Integrations → Webhooks → New → copy URL → set di env.
---
## Troubleshooting ## Troubleshooting
**Cron jalan tapi tidak ada efek di DB:** **Cron jalan tapi tidak ada efek di DB:**
- Cek `pm2 logs setrip` untuk error. - Cek `/admin/system` — kalau status SUCCESS dengan `payload: { completed: 0 }`, memang tidak ada pekerjaan saat ini.
- Verifikasi waktu server: `date` (output harus UTC kalau pakai schedule UTC). - Cek `pm2 logs setrip` untuk error runtime.
- Verifikasi waktu server: `date -u` (output harus UTC kalau pakai schedule UTC).
**Cron tidak jalan sama sekali:** **Cron tidak jalan sama sekali:**
- Cek service cron aktif: `systemctl status cron` (Debian/Ubuntu) atau `systemctl status crond` (RHEL/CentOS). - Cek service cron aktif: `systemctl status cron` (Debian/Ubuntu) atau `systemctl status crond` (RHEL/CentOS).
- Cek crontab terdaftar di user yang benar: `sudo crontab -u $(whoami) -l`. - Cek crontab terdaftar di user yang benar: `sudo crontab -u $(whoami) -l`.
- Cek `/admin/system` — kalau "Last run" jauh dari ekspektasi (mis. > 25 jam untuk daily cron), schedule mungkin tidak ke-trigger.
**`process-email-jobs` SUCCESS tapi email tidak terkirim:**
- Cek `RESEND_API_KEY` di env. Tanpa env, cron return early dengan warning di log.
- Cek dashboard Resend untuk delivery status / bounce.
- Cek tabel `EmailJob` di DB: status `PENDING`/`FAILED` + `lastError` field.
**`auto-complete-trips` SUCCESS tapi trip masih OPEN:**
- Cek `Trip.endDate` (kalau ada) atau `Trip.date` — harus lewat cutoff (`utcStartOfDay(now)`).
- Trip dengan status `CLOSED` sengaja tidak di-touch (organizer eksplisit batalkan).
**Secret bocor:** **Secret bocor:**
- Generate ulang `CRON_SECRET`, update di `.env` + crontab line, restart PM2. - Generate ulang `CRON_SECRET`, update di `.env` + semua baris crontab, restart PM2.
## Hari kalau pindah ke Vercel / PaaS lain **Cron FAILED berturut-turut:**
- `/admin/system` akan tampilkan badge 🔴 FAILED.
- Kalau env `ADMIN_ALERT_WEBHOOK_URL` di-set, Discord channel akan dapat notif.
- Klik "Last error" di card cron untuk lihat stack trace, atau cek tabel `CronRun.errorMessage` langsung.
Tinggal hapus crontab line + bikin `vercel.json` (atau equivalent platform). Endpoint sudah platform-agnostic — proteksinya sama (header `Authorization: Bearer <CRON_SECRET>`). ---
## Saat menambah cron baru (developer note)
Checklist:
1. Buat route handler di `app/api/cron/<name>/route.ts` dengan pola standar (CRON_SECRET check + `runCron(jobName, fn)` wrapper).
2. Tambah entry di tabel **Daftar cron job** di doc ini.
3. Tambah baris di `TRACKED_JOBS` di [app/admin/system/page.tsx](../app/admin/system/page.tsx) supaya muncul di health card.
4. Brief ops: tambah baris di server crontab dengan schedule yang sesuai.
Pattern minimal cron handler:
```ts
// app/api/cron/<name>/route.ts
import { NextRequest, NextResponse } from "next/server";
import { runCron } from "@/lib/cron-runner";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
const secret = process.env.CRON_SECRET;
if (!secret) {
return NextResponse.json({ error: "Server misconfigured" }, { status: 500 });
}
if (req.headers.get("authorization") !== `Bearer ${secret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const outcome = await runCron("<jobName>", async () => {
// ... actual work; return value masuk ke CronRun.payload
return { processedCount: 0 };
});
if (!outcome.ok) {
return NextResponse.json({ error: outcome.error }, { status: 500 });
}
return NextResponse.json({ ok: true, ...outcome.payload });
}
```
---
## Kalau pindah ke Vercel / PaaS lain
Bikin `vercel.json` di root:
```json
{
"crons": [
{
"path": "/api/cron/auto-complete-trips",
"schedule": "0 18 * * *"
},
{
"path": "/api/cron/process-email-jobs",
"schedule": "*/5 * * * *"
}
]
}
```
Vercel Cron otomatis kirim header `Authorization: Bearer <VERCEL_CRON_SECRET>` — sesuaikan logic auth check di route handler (atau pakai env yang sama). Endpoint sudah platform-agnostic — tidak ada code change yang diperlukan.
> **Catatan:** Vercel Cron free tier limit 2 cron/project + minimum schedule 1 jam. Untuk `process-email-jobs` yang 5 menit, perlu upgrade Vercel Pro atau pertahankan VPS untuk cron.
+84
View File
@@ -0,0 +1,84 @@
# Setrip — Admin Audit & Investigation Roadmap (ARCHIVED — DELIVERED 2026-05-18)
Admin perlu **mencari** lintas entity (booking/payment/refund/user/trip) dan **export** untuk compliance + investigasi dispute.
---
## Status delivery
| Phase | Status | Catatan |
|---|---|---|
| Phase 1 — Filter & Search Enhancements | ✅ Delivered | Filter date range + reviewer di refunds/payouts/verifications via `AdminFilterBar` reusable. Reason filter di refunds. |
| Phase 2 — Global Search | ✅ Delivered | Search bar di sidebar admin dispatch by pattern (email/order_id/cuid/fuzzy). Endpoint `/api/admin/search`. |
| Phase 3 — CSV Export | ✅ Delivered | 3 endpoint export (refunds/payouts/verifications) dengan UTF-8 BOM untuk Excel. Tombol "⬇️ Export CSV" di tiap halaman list. |
| Phase 4 — Generic Admin Audit Log | ✅ Delivered | Model `AdminActionLog` (polymorphic, append-only). Helper `auditLog.record()` di-wire ke semua admin server action. Halaman `/admin/audit-log` dengan filter. |
---
## Phase 1 — Filter & Search Enhancements ✅
| # | Item | Status | File |
|---|---|---|---|
| 1.1 | Filter date range (`dateFrom`, `dateTo`) di `/admin/refunds` | ✅ | [app/admin/refunds/page.tsx](../../app/admin/refunds/page.tsx) |
| 1.2 | Filter `reviewer` (admin email dropdown) di `/admin/refunds` | ✅ | [app/admin/refunds/page.tsx](../../app/admin/refunds/page.tsx) |
| 1.3 | Filter `reason` di `/admin/refunds` | ✅ | [app/admin/refunds/page.tsx](../../app/admin/refunds/page.tsx) |
| 1.4 | Filter date range + `processor` di `/admin/payouts` | ✅ | [app/admin/payouts/page.tsx](../../app/admin/payouts/page.tsx) |
| 1.5 | Filter date range + `reviewer` di `/admin/verifications` | ✅ | [app/admin/verifications/page.tsx](../../app/admin/verifications/page.tsx) |
| 1.6 | Komponen reusable `AdminFilterBar` | ✅ | [features/admin/components/admin-filter-bar.tsx](../../features/admin/components/admin-filter-bar.tsx) |
| 1.7 | Filter params di `refundRepo`/`payoutRepo`/`organizerRepo` `listByStatus` | ✅ | `server/repositories/*.ts` |
| 1.8 | Helper `listAdminEmails()` untuk dropdown reviewer | ✅ | [lib/admin.ts](../../lib/admin.ts) |
---
## Phase 2 — Global Search ✅
| # | Item | Status | File |
|---|---|---|---|
| 2.1 | `adminSearchService.resolve(q)` — dispatch by pattern (email exact, order_id prefix, cuid, fuzzy) | ✅ | [server/services/admin-search.service.ts](../../server/services/admin-search.service.ts) |
| 2.2 | Route handler `/api/admin/search?q=...` (guard isAdmin) | ✅ | [app/api/admin/search/route.ts](../../app/api/admin/search/route.ts) |
| 2.3 | Component `AdminSearchBar` — debounced 250ms, dropdown hasil dengan type badge | ✅ | [features/admin/components/admin-search-bar.tsx](../../features/admin/components/admin-search-bar.tsx) |
| 2.4 | Wire di admin sidebar (di bawah logo header) | ✅ | [components/admin/admin-sidebar.tsx](../../components/admin/admin-sidebar.tsx) |
| 2.5 | Page `/admin/search?q=...` full results | ⏳ | Skip — dropdown limit 10 hit cukup; jarang butuh full page. |
---
## Phase 3 — CSV Export ✅
| # | Item | Status | File |
|---|---|---|---|
| 3.1 | Helper `lib/csv.ts``buildCsv`, `escapeCsvCell`, `csvResponse` dengan UTF-8 BOM | ✅ | [lib/csv.ts](../../lib/csv.ts) |
| 3.2 | Route `/api/admin/export/refunds` — pakai filter dari query string | ✅ | [app/api/admin/export/refunds/route.ts](../../app/api/admin/export/refunds/route.ts) |
| 3.3 | Route `/api/admin/export/payouts` | ✅ | [app/api/admin/export/payouts/route.ts](../../app/api/admin/export/payouts/route.ts) |
| 3.4 | Route `/api/admin/export/verifications` — TANPA NIK/KTP key/bank account number (privasi) | ✅ | [app/api/admin/export/verifications/route.ts](../../app/api/admin/export/verifications/route.ts) |
| 3.5 | Komponen `ExportCsvLink` + tombol di tiap admin list page (filter preserved) | ✅ | [features/admin/components/export-csv-link.tsx](../../features/admin/components/export-csv-link.tsx) |
**Tindakan manual:** test di staging dulu — pastikan tidak ada data sensitif yang ter-leak (NIK plaintext, foto KYC key, dst).
---
## Phase 4 — Generic Admin Audit Log ✅
| # | Item | Status | File |
|---|---|---|---|
| 4.1 | Model `AdminActionLog` (polymorphic, append-only) + migration | ✅ | [prisma/schema.prisma](../../prisma/schema.prisma) + `prisma/migrations/20260518180000_add_admin_action_log/` |
| 4.2 | Helper `auditLog.record({ admin, action, entityType, entityId, payload? })` | ✅ | [server/services/audit-log.service.ts](../../server/services/audit-log.service.ts) |
| 4.3 | Wire di semua admin server action: refund approve/reject/mark/create, payout markPaid, verification approve/reject/reopen, trip admin-cancel, payment reconcile, user suspend/unsuspend | ✅ | `features/*/actions.ts` |
| 4.4 | Page `/admin/audit-log` dengan filter (date range, admin email, entity type, action contains) + pagination basic (max 200) | ✅ | [app/admin/audit-log/page.tsx](../../app/admin/audit-log/page.tsx) |
| 4.5 | Link "Audit Log" di sidebar | ✅ | [components/admin/admin-sidebar.tsx](../../components/admin/admin-sidebar.tsx) |
**Daftar action yang ter-log:**
| Action | Entity | Source |
|---|---|---|
| `USER_SUSPEND` / `USER_UNSUSPEND` | User | [features/admin/actions.ts](../../features/admin/actions.ts) |
| `TRIP_ADMIN_CANCEL` | Trip | [features/trip/actions.ts](../../features/trip/actions.ts) |
| `PAYMENT_RECONCILE` | Payment (orderId) | [features/booking/actions.ts](../../features/booking/actions.ts) |
| `VERIFICATION_APPROVE` / `VERIFICATION_REJECT` / `VERIFICATION_REOPEN` | OrganizerVerification | [features/organizer/actions.ts](../../features/organizer/actions.ts) |
| `REFUND_CREATE` / `REFUND_APPROVE` / `REFUND_REJECT` / `REFUND_SUCCEEDED` / `REFUND_FAILED` | Refund | [features/refund/actions.ts](../../features/refund/actions.ts) |
| `PAYOUT_MARK_PAID` | Payout | [features/payout/actions.ts](../../features/payout/actions.ts) |
`adminId` nullable + `adminEmail` snapshot — log entry tetap auditable kalau admin dihapus.
**Tindakan manual ops:**
1. Apply migration: `npx prisma migrate deploy`.
2. Brief admin: setiap aksi mereka di panel akan tercatat di `/admin/audit-log` dengan email mereka — pakai sebagai bukti compliance saat audit eksternal.
@@ -0,0 +1,66 @@
# Setrip — Admin System Health Roadmap (ARCHIVED — DELIVERED 2026-05-18)
Admin perlu visibilitas atas job otomatis (cron) dan alert untuk state stale.
---
## Status delivery
| Phase | Status | Catatan |
|---|---|---|
| Phase 1 — Cron Run Log | ✅ Delivered | Model `CronRun`, helper `runCron()`, wire ke cron existing. |
| Phase 2 — System Status Page | ✅ Delivered | `/admin/system` tampilkan summary cron + 20 run terakhir + health badge. |
| Phase 3 — Stale State Alerts | ✅ Delivered | `systemHealthService.detectStale()` cek 4 kategori (Payment AWAITING > 25h, AWAITING_PAY past departure, Payout HELD overdue, Refund APPROVED > 7d). Banner di `/admin/system`. |
| Phase 4 — Discord Webhook Notify | ✅ Delivered | `notifyAdmins()` POST ke `ADMIN_ALERT_WEBHOOK_URL`. Trigger otomatis saat cron FAILED via `runCron`. |
---
## Phase 1 — Cron Run Log ✅
| # | Item | Status | File |
|---|---|---|---|
| 1.1 | Model `CronRun` + enum `CronRunStatus` + migration | ✅ | [prisma/schema.prisma](../../prisma/schema.prisma) + `prisma/migrations/20260518170000_add_cron_run/` |
| 1.2 | Helper `runCron(jobName, fn)` — auto create RUNNING row → SUCCESS/FAILED | ✅ | [lib/cron-runner.ts](../../lib/cron-runner.ts) |
| 1.3 | Wire `runCron` di `auto-complete-trips` cron | ✅ | [app/api/cron/auto-complete-trips/route.ts](../../app/api/cron/auto-complete-trips/route.ts) |
---
## Phase 2 — System Status Page ✅
| # | Item | Status | File |
|---|---|---|---|
| 2.1 | Per-job summary: last run, last success, count 7d, error count 7d | ✅ | [app/admin/system/page.tsx](../../app/admin/system/page.tsx) |
| 2.2 | 20 cron run terakhir di table bawah | ✅ | [app/admin/system/page.tsx](../../app/admin/system/page.tsx) |
| 2.3 | Health badge (🟢 OK < 25h, 🟡 STALE, 🔴 FAILED) | ✅ | [app/admin/system/page.tsx](../../app/admin/system/page.tsx) |
| 2.4 | Link "System" di admin navbar | ✅ | [components/admin/admin-sidebar.tsx](../../components/admin/admin-sidebar.tsx) |
---
## Phase 3 — Stale State Alerts ✅
| # | Item | Status | File |
|---|---|---|---|
| 3.1 | `systemHealthService.detectStale()` return 4 count | ✅ | [server/services/system-health.service.ts](../../server/services/system-health.service.ts) |
| 3.2 | Banner alerts kuning di `/admin/system` kalau ada count > 0 | ✅ | [app/admin/system/page.tsx](../../app/admin/system/page.tsx) |
| 3.3 | Link tiap alert ke filtered list page yang relevan | ✅ (untuk Payout HELD & Refund APPROVED) | [app/admin/system/page.tsx](../../app/admin/system/page.tsx) |
**Threshold draft (review setelah jalan 1-2 minggu):**
- Payment MIDTRANS `AWAITING` createdAt > 25 jam — suspect webhook tertunda
- Booking `AWAITING_PAY` dengan trip.date < today — peserta lupa bayar
- Payout `HELD` dengan heldUntil > 1 hari lewat — cron release tidak jalan
- Refund `APPROVED` reviewedAt > 7 hari — admin lupa process
---
## Phase 4 — Discord Webhook Notify ✅
| # | Item | Status | File |
|---|---|---|---|
| 4.1 | Helper `notifyAdmins(message)` — POST ke Discord webhook URL dari env | ✅ | [lib/admin-notify.ts](../../lib/admin-notify.ts) |
| 4.2 | Trigger notify di `runCron` saat FAILED (fire-and-forget) | ✅ | [lib/cron-runner.ts](../../lib/cron-runner.ts) |
| 4.3 | Trigger notify dari `systemHealthService.detectStale` rate-limited | ⏳ | Skip — admin sudah lihat banner di `/admin/system` saat buka pagi. Push notif harian baru worth it kalau admin sering miss; bisa ditambah belakangan. |
**Tindakan manual ops:**
1. Apply migration (sudah di Phase 1).
2. (Opsional) Buat Discord channel internal + webhook URL → set env `ADMIN_ALERT_WEBHOOK_URL` di PM2/server. Tanpa env, `notifyAdmins` no-op.
3. Test alert: trigger cron secara sengaja fail (mis. matikan DB sebentar) → cek Discord channel menerima 🚨 message.
+59
View File
@@ -0,0 +1,59 @@
# Setrip — Admin User Management Roadmap (ARCHIVED — DELIVERED 2026-05-18, fully done)
Admin perlu bisa cari user dan **suspend** akun yang melakukan abuse (scam, harassment, fake review).
> **Skenario nyata:** organizer scam berkali-kali bikin trip palsu pakai alias berbeda. Peserta lapor harassment dari user lain di grup WA trip.
---
## Status delivery
| Phase | Status | Catatan |
|---|---|---|
| Phase 1 — User List & Detail | ✅ Delivered | Search by email/name, filter tab (ALL/ACTIVE/SUSPENDED), stats (trip dibuat, booking, total spent). |
| Phase 2 — User Suspension | ✅ Delivered | Schema baru `User.suspended`, auth gate sign-in + helper `requireActiveUser` di mutating actions, trip public list otomatis sembunyikan organizer suspended. |
| Phase 3 — User Analytics | ✅ Delivered | Page `/admin/users/stats` dengan stats card (total/suspended/verified-organizer/active-organizer-30d/paid-participant-30d) + bar chart signup per minggu (8 minggu terakhir, inline SVG-free). |
---
## Phase 1 — User List & Detail ✅
| # | Item | Status | File |
|---|---|---|---|
| 1.1 | `userRepo.searchForAdmin({ q?, suspended? })` | ✅ | [server/repositories/user.repo.ts](../../server/repositories/user.repo.ts) |
| 1.2 | Page `/admin/users` — list + search + tab filter (ALL/ACTIVE/SUSPENDED) | ✅ | [app/admin/users/page.tsx](../../app/admin/users/page.tsx) |
| 1.3 | Page `/admin/users/[id]` — detail dengan trip dibuat + booking history + profile + verification | ✅ | [app/admin/users/[id]/page.tsx](../../app/admin/users/[id]/page.tsx) |
| 1.4 | Stats cards: trip dibuat, booking aktif, total spent (PAID) | ✅ | [app/admin/users/[id]/page.tsx](../../app/admin/users/[id]/page.tsx) |
| 1.5 | Link "Users" di admin navbar | ✅ | [components/admin/admin-sidebar.tsx](../../components/admin/admin-sidebar.tsx) |
---
## Phase 2 — User Suspension ✅
| # | Item | Status | File |
|---|---|---|---|
| 2.1 | Migration: `suspended Boolean`, `suspendedAt`, `suspendedReason`, `suspendedById` (FK User SET NULL) | ✅ | `prisma/migrations/20260518160000_add_user_suspension/` |
| 2.2 | `userService.suspendUser` + `unsuspendUser` (idempotent + cek tidak suspend diri sendiri + reason min 10 char) | ✅ | [server/services/user.service.ts](../../server/services/user.service.ts) |
| 2.3 | Block sign-in di NextAuth `signIn` callback (email-based, jalan untuk Credentials + OAuth) | ✅ | [lib/auth.ts](../../lib/auth.ts) |
| 2.4 | Helper `requireActiveUser(userId)` — lookup fresh dari DB | ✅ | [lib/auth-guards.ts](../../lib/auth-guards.ts) |
| 2.5 | Wire `requireActiveUser` di `createTripAction` + `joinTripAction` | ✅ | [features/trip/actions.ts](../../features/trip/actions.ts) |
| 2.6 | Filter trip public list: `organizer: { suspended: false }` di `findOpen` | ✅ | [server/repositories/trip.repo.ts](../../server/repositories/trip.repo.ts) |
| 2.7 | UI: tombol Suspend/Unsuspend di `/admin/users/[id]` dengan modal reason wajib | ✅ | [features/admin/components/suspend-user-button.tsx](../../features/admin/components/suspend-user-button.tsx) |
| 2.8 | Badge "SUSPENDED" di user list + detail header (red border accent) | ✅ | list & detail pages |
| 2.9 | Server actions `suspendUserAction` + `unsuspendUserAction` (guard isAdmin) | ✅ | [features/admin/actions.ts](../../features/admin/actions.ts) |
**Tindakan manual ops:**
1. Apply migration: `npx prisma migrate deploy`.
2. Brief admin: kriteria suspend (scam, harassment, repeated TOS violation). Reason wajib min 10 char.
3. Wire `requireActiveUser` ke action mutating lain saat dibuat (`createReviewAction`, dst).
4. Pertimbangkan: bikin halaman info "Akun ditangguhkan" untuk UX saat suspended user coba login.
---
## Phase 3 — User Analytics ✅
| # | Item | Status | File |
|---|---|---|---|
| 3.1 | Stats: total users, suspended, verified organizers, active organizer 30d (bikin trip), paid participant 30d | ✅ | [app/admin/users/stats/page.tsx](../../app/admin/users/stats/page.tsx) |
| 3.2 | Bar chart signup per minggu (8 minggu terakhir, pakai inline div height % — no chart library) | ✅ | [app/admin/users/stats/page.tsx](../../app/admin/users/stats/page.tsx) |
| 3.3 | Link "📊 Stats" di header `/admin/users` | ✅ | [app/admin/users/page.tsx](../../app/admin/users/page.tsx) |
@@ -0,0 +1,66 @@
# Setrip — Admin Verification Roadmap (ARCHIVED — DELIVERED 2026-05-18)
Enhancement KYC organizer verification: reopen REJECTED, request re-upload, history, manual override.
---
## Status delivery
| Phase | Status | Catatan |
|---|---|---|
| Phase 1 — Reopen Rejected | ✅ Delivered | Tombol "Buka kembali ke PENDING" di REJECTED card dengan note wajib. |
| Phase 2 — Re-upload Request | ✅ Delivered | Admin pilih checkbox field (KTP/liveness/NIK/bank/alamat) + note. Organizer dapat banner kuning di `/verify` dengan highlight field yang diminta. Auto-clear saat submit ulang. |
| Phase 3 — Submission History | ✅ Delivered | Field `submissionCount` di-bump tiap submit ulang. `previousRejections` JSON array menyimpan rejection lama (waktu + reason + submission ke-N) sebelum overwrite. |
| Phase 4 — Manual Override | ✅ Delivered | Admin verify user tanpa upload KYC (partner trusted). Flag `isManualOverride = true` untuk audit transparansi. UI di `/admin/users/[id]`. |
---
## Phase 1 — Reopen Rejected ✅
| # | Item | Status | File |
|---|---|---|---|
| 1.1 | `organizerService.reopenVerification` | ✅ | [server/services/organizer.service.ts](../../server/services/organizer.service.ts) |
| 1.2 | `organizerRepo.reopen` | ✅ | [server/repositories/organizer.repo.ts](../../server/repositories/organizer.repo.ts) |
| 1.3 | `reopenVerificationAction` (guard isAdmin + audit log) | ✅ | [features/organizer/actions.ts](../../features/organizer/actions.ts) |
| 1.4 | UI tombol di REJECTED card | ✅ | [features/organizer/components/review-card.tsx](../../features/organizer/components/review-card.tsx) |
---
## Phase 2 — Re-upload Request ✅
| # | Item | Status | File |
|---|---|---|---|
| 2.1 | Migration: `reuploadRequested`, `reuploadFields String[]`, `reuploadNote` | ✅ | `prisma/migrations/20260518190000_verification_enhancements/` |
| 2.2 | `organizerService.requestReupload(verifId, adminId, fields, note)` + `REUPLOAD_FIELDS` enum-like const | ✅ | [server/services/organizer.service.ts](../../server/services/organizer.service.ts) |
| 2.3 | `organizerRepo.requestReupload` (set status PENDING + flag + clear review) | ✅ | [server/repositories/organizer.repo.ts](../../server/repositories/organizer.repo.ts) |
| 2.4 | `requestReuploadAction` (guard isAdmin + audit log) | ✅ | [features/organizer/actions.ts](../../features/organizer/actions.ts) |
| 2.5 | UI admin di PENDING card: tombol "🔄 Minta re-upload" + multi-checkbox field + note | ✅ | [features/organizer/components/review-card.tsx](../../features/organizer/components/review-card.tsx) |
| 2.6 | Banner kuning di `/verify` saat `reuploadRequested = true` + list field yang diminta | ✅ | [app/(public)/verify/page.tsx](../../app/(public)/verify/page.tsx) |
| 2.7 | Auto-clear flag saat organizer submit ulang (logic di `submitVerification`) | ✅ | [server/services/organizer.service.ts](../../server/services/organizer.service.ts) |
---
## Phase 3 — Submission History ✅
| # | Item | Status | File |
|---|---|---|---|
| 3.1 | Migration: `submissionCount Int @default(1)`, `previousRejections Json?` | ✅ | `prisma/migrations/20260518190000_verification_enhancements/` |
| 3.2 | Bump `submissionCount` + archive rejection lama saat submit ulang (helper `buildArchivedRejections`) | ✅ | [server/services/organizer.service.ts](../../server/services/organizer.service.ts) |
> _Catatan: snapshot full data (Phase 3.1 di roadmap awal) di-skip — `submissionCount` + `previousRejections` (array waktu/reason) cukup untuk audit "berapa kali verify, apa reason ditolak sebelumnya". UI history detail bisa ditambah saat ada permintaan konkret._
---
## Phase 4 — Manual Override ✅
| # | Item | Status | File |
|---|---|---|---|
| 4.1 | Migration: `isManualOverride Boolean @default(false)`, `manualOverrideById`, `manualOverrideNote` | ✅ | `prisma/migrations/20260518190000_verification_enhancements/` |
| 4.2 | `organizerService.manualOverrideVerification(input)` — bikin row APPROVED dengan placeholder KYC | ✅ | [server/services/organizer.service.ts](../../server/services/organizer.service.ts) |
| 4.3 | `organizerRepo.createManualOverride` | ✅ | [server/repositories/organizer.repo.ts](../../server/repositories/organizer.repo.ts) |
| 4.4 | `manualOverrideVerificationAction` (guard isAdmin + audit log) | ✅ | [features/organizer/actions.ts](../../features/organizer/actions.ts) |
| 4.5 | UI: `ManualVerifyButton` di `/admin/users/[id]` (hanya tampil kalau user belum punya verification) | ✅ | [features/admin/components/manual-verify-button.tsx](../../features/admin/components/manual-verify-button.tsx) |
**Tindakan manual ops:**
1. Apply migration: `npx prisma migrate deploy`.
2. Brief admin: re-upload request lebih lembut dari reject (organizer tidak perlu ulang dari nol). Manual override hanya untuk partner trusted dengan ref konkret di note (mis. nomor kontrak).
+103
View File
@@ -0,0 +1,103 @@
"use server";
import { getServerSession } from "next-auth";
import { revalidatePath } from "next/cache";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { userService } from "@/server/services/user.service";
import { auditLog } from "@/server/services/audit-log.service";
import { emailService } from "@/lib/email/send";
import { userRepo } from "@/server/repositories/user.repo";
export async function suspendUserAction(userId: string, reason: string) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return { error: "Kamu harus login terlebih dahulu" };
}
if (!isAdminEmail(session.user.email)) {
return { error: "Hanya admin yang bisa melakukan aksi ini" };
}
try {
await userService.suspendUser({
userId,
adminId: session.user.id,
reason,
});
await auditLog.record({
admin: { id: session.user.id, email: session.user.email },
action: "USER_SUSPEND",
entityType: "User",
entityId: userId,
payload: { reason: reason.trim() },
});
// Notif email user — due process: kasih tahu alasan + cara appeal.
void notifySuspended(userId, reason.trim());
revalidatePath("/admin/users");
revalidatePath(`/admin/users/${userId}`);
return { success: true as const };
} catch (err) {
return { error: (err as Error).message };
}
}
async function notifySuspended(userId: string, reason: string) {
const target = await userRepo.findById(userId);
if (!target) return;
await emailService.send({
to: target.email,
// Suspend bisa di-trigger berulang — sertakan timestamp supaya tiap suspend
// baru dapat email baru.
idempotencyKey: `account_suspended-${userId}-${Date.now()}`,
template: {
template: "account_suspended",
data: { userName: target.name, reason },
},
});
}
/** E3.8 — kabari user kalau penangguhan akunnya sudah dicabut. */
async function notifyUnsuspended(userId: string) {
const target = await userRepo.findById(userId);
if (!target) return;
await emailService.send({
to: target.email,
// Sertakan timestamp supaya tiap siklus suspend→unsuspend dapat email baru.
idempotencyKey: `account_unsuspended-${userId}-${Date.now()}`,
template: {
template: "account_unsuspended",
data: { userName: target.name },
},
});
}
export async function unsuspendUserAction(userId: string) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return { error: "Kamu harus login terlebih dahulu" };
}
if (!isAdminEmail(session.user.email)) {
return { error: "Hanya admin yang bisa melakukan aksi ini" };
}
try {
await userService.unsuspendUser({ userId, adminId: session.user.id });
await auditLog.record({
admin: { id: session.user.id, email: session.user.email },
action: "USER_UNSUSPEND",
entityType: "User",
entityId: userId,
});
// Notif email user — kabari akun sudah aktif kembali.
void notifyUnsuspended(userId);
revalidatePath("/admin/users");
revalidatePath(`/admin/users/${userId}`);
return { success: true as const };
} catch (err) {
return { error: (err as Error).message };
}
}
@@ -0,0 +1,147 @@
import { DateField } from "@/components/shared/date-picker";
interface AdminFilterBarProps {
/** URL base (mis. `/admin/refunds`) yang menerima query params. */
action: string;
/** Nilai current dari searchParams. */
values: {
tab?: string;
dateFrom?: string;
dateTo?: string;
reviewer?: string;
reason?: string;
};
/** Daftar admin email untuk dropdown reviewer/processor. */
reviewerOptions: string[];
/** Label dropdown reviewer (mis. "Reviewer", "Processor"). */
reviewerLabel?: string;
/** Kalau diisi, tampilkan dropdown reason dengan opsi-opsi tersebut. */
reasonOptions?: { value: string; label: string }[];
}
/**
* Filter bar reusable untuk admin list pages. Pakai GET form supaya URL
* shareable dan tidak perlu state client.
*/
export function AdminFilterBar({
action,
values,
reviewerOptions,
reviewerLabel = "Reviewer",
reasonOptions,
}: AdminFilterBarProps) {
return (
<form
method="get"
action={action}
className="mb-4 rounded-2xl border border-neutral-200 bg-white p-3 shadow-sm sm:p-4"
>
{/* Preserve tab via hidden input */}
{values.tab && <input type="hidden" name="tab" value={values.tab} />}
<div className="grid gap-3 sm:grid-cols-4">
<div>
<label
htmlFor="filter-dateFrom"
className="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
>
Dari tanggal
</label>
<DateField
id="filter-dateFrom"
name="dateFrom"
defaultValueYmd={values.dateFrom}
placeholder="Dari tanggal"
/>
</div>
<div>
<label
htmlFor="filter-dateTo"
className="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
>
Sampai tanggal
</label>
<DateField
id="filter-dateTo"
name="dateTo"
defaultValueYmd={values.dateTo}
placeholder="Sampai tanggal"
/>
</div>
<div>
<label
htmlFor="filter-reviewer"
className="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
>
{reviewerLabel}
</label>
<select
id="filter-reviewer"
name="reviewer"
defaultValue={values.reviewer ?? ""}
className="w-full rounded-lg border border-neutral-200 bg-neutral-50 px-2 py-1.5 text-sm text-neutral-800 focus:border-primary-400 focus:bg-white"
>
<option value="">Semua</option>
{reviewerOptions.map((email) => (
<option key={email} value={email}>
{email}
</option>
))}
</select>
</div>
{reasonOptions ? (
<div>
<label
htmlFor="filter-reason"
className="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
>
Reason
</label>
<select
id="filter-reason"
name="reason"
defaultValue={values.reason ?? ""}
className="w-full rounded-lg border border-neutral-200 bg-neutral-50 px-2 py-1.5 text-sm text-neutral-800 focus:border-primary-400 focus:bg-white"
>
<option value="">Semua</option>
{reasonOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
) : (
<div className="flex items-end">
<button
type="submit"
className="w-full rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-primary-700"
>
Terapkan
</button>
</div>
)}
</div>
{reasonOptions && (
<div className="mt-3 flex gap-2">
<button
type="submit"
className="rounded-lg bg-primary-600 px-4 py-1.5 text-sm font-semibold text-white hover:bg-primary-700"
>
Terapkan
</button>
<a
href={`${action}${values.tab ? `?tab=${values.tab}` : ""}`}
className="rounded-lg border border-neutral-200 bg-white px-4 py-1.5 text-sm font-semibold text-neutral-600 hover:bg-neutral-50"
>
Reset
</a>
</div>
)}
</form>
);
}
@@ -0,0 +1,131 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
interface Hit {
type: "user" | "trip" | "booking";
id: string;
title: string;
subtitle: string;
href: string;
}
/**
* Search bar global untuk admin sidebar. Debounced 250ms supaya tidak spam
* server. Hits dispatch berdasarkan pola input — lihat
* `adminSearchService.resolve` di server.
*/
export function AdminSearchBar() {
const [query, setQuery] = useState("");
const [hits, setHits] = useState<Hit[]>([]);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const wrapperRef = useRef<HTMLDivElement | null>(null);
// Debounced fetch — guard inside async block supaya tidak setState langsung
// di effect synchronous (react-hooks/set-state-in-effect).
useEffect(() => {
const q = query.trim();
const controller = new AbortController();
const timer = setTimeout(() => {
if (q.length < 2) {
setHits([]);
setLoading(false);
return;
}
setLoading(true);
fetch(`/api/admin/search?q=${encodeURIComponent(q)}`, {
signal: controller.signal,
})
.then((res) => (res.ok ? res.json() : { hits: [] }))
.then((json: { hits: Hit[] }) => {
setHits(json.hits ?? []);
})
.catch(() => setHits([]))
.finally(() => setLoading(false));
}, 250);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [query]);
// Close dropdown on outside click
useEffect(() => {
function onClick(e: MouseEvent) {
if (
wrapperRef.current &&
!wrapperRef.current.contains(e.target as Node)
) {
setOpen(false);
}
}
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, []);
return (
<div ref={wrapperRef} className="relative">
<input
type="search"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
placeholder="Cari email, ID, order_id, judul..."
className="w-full rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 text-xs text-neutral-800 placeholder:text-neutral-400 focus:border-primary-400 focus:bg-white"
/>
{open && query.trim().length >= 2 && (
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-80 overflow-y-auto rounded-xl border border-neutral-200 bg-white shadow-xl">
{loading && (
<p className="px-3 py-2 text-[11px] text-neutral-500">Mencari...</p>
)}
{!loading && hits.length === 0 && (
<p className="px-3 py-2 text-[11px] text-neutral-500">
Tidak ada hasil.
</p>
)}
{!loading && hits.length > 0 && (
<ul className="py-1">
{hits.map((h) => (
<li key={`${h.type}-${h.id}`}>
<Link
href={h.href}
onClick={() => {
setOpen(false);
setQuery("");
}}
className="flex items-center gap-2 px-3 py-2 hover:bg-neutral-50"
>
<span
className={`rounded-full px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wide ${
h.type === "user"
? "bg-primary-100 text-primary-700"
: h.type === "trip"
? "bg-secondary-100 text-secondary-700"
: "bg-amber-100 text-amber-700"
}`}
>
{h.type}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-semibold text-neutral-800">
{h.title}
</p>
<p className="truncate text-[10px] text-neutral-500">
{h.subtitle}
</p>
</div>
</Link>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,31 @@
import { Download } from "lucide-react";
interface ExportCsvLinkProps {
/** URL endpoint export, mis. `/api/admin/export/refunds`. */
href: string;
/** Query string current filter (tanpa leading `?`). */
query?: string;
label?: string;
}
/**
* Tombol download CSV — anchor biasa supaya browser tangani download via
* `Content-Disposition: attachment` header dari server.
*/
export function ExportCsvLink({
href,
query,
label = "Export CSV",
}: ExportCsvLinkProps) {
const url = query ? `${href}?${query}` : href;
return (
<a
href={url}
className="inline-flex items-center gap-1.5 rounded-xl border border-neutral-200 bg-white px-3 py-1.5 text-xs font-semibold text-neutral-700 hover:bg-neutral-50"
download
>
<Download size={16} strokeWidth={2} aria-hidden />
<span>{label}</span>
</a>
);
}
@@ -0,0 +1,141 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Lock } from "lucide-react";
import { manualOverrideVerificationAction } from "@/features/organizer/actions";
interface ManualVerifyButtonProps {
userId: string;
defaultBankAccountName: string;
}
export function ManualVerifyButton({
userId,
defaultBankAccountName,
}: ManualVerifyButtonProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [note, setNote] = useState("");
const [bankName, setBankName] = useState("");
const [bankAccountNumber, setBankAccountNumber] = useState("");
const [bankAccountName, setBankAccountName] = useState(defaultBankAccountName);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function handleSubmit() {
setLoading(true);
setError("");
const res = await manualOverrideVerificationAction({
userId,
note,
bankName,
bankAccountNumber,
bankAccountName,
});
setLoading(false);
if ("error" in res && res.error) {
setError(res.error);
return;
}
setOpen(false);
setNote("");
setBankName("");
setBankAccountNumber("");
router.refresh();
}
if (!open) {
return (
<button
type="button"
onClick={() => setOpen(true)}
className="inline-flex items-center gap-1.5 rounded-xl border border-secondary-300 bg-white px-4 py-2 text-sm font-bold text-secondary-700 hover:bg-secondary-50"
>
<Lock size={18} strokeWidth={2} aria-hidden />
Manual verify (tanpa KYC)
</button>
);
}
return (
<div className="space-y-3 rounded-xl border border-secondary-200 bg-secondary-50/60 p-4">
<p className="text-xs text-secondary-900">
Manual override: bikin verifikasi APPROVED tanpa upload KYC. Pakai HANYA
untuk partner trusted referral atau kasus khusus. Ter-flag jelas di
admin UI sebagai &quot;manual override&quot;.
</p>
<div>
<label className="mb-1 block text-xs font-semibold text-secondary-900">
Alasan / referensi (min 10 char)
</label>
<textarea
rows={2}
value={note}
onChange={(e) => setNote(e.target.value)}
maxLength={500}
placeholder="contoh: Partner referral dari acara X, kontrak signed #PR-2026-15."
className="w-full rounded-xl border border-secondary-200 bg-white px-3 py-2 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-secondary-400"
/>
</div>
<div className="grid gap-2 sm:grid-cols-3">
<input
type="text"
value={bankName}
onChange={(e) => setBankName(e.target.value)}
placeholder="Nama bank"
className="rounded-lg border border-secondary-200 bg-white px-3 py-1.5 text-sm focus:border-secondary-400"
/>
<input
type="text"
value={bankAccountNumber}
onChange={(e) => setBankAccountNumber(e.target.value)}
placeholder="Nomor rekening"
className="rounded-lg border border-secondary-200 bg-white px-3 py-1.5 text-sm focus:border-secondary-400"
/>
<input
type="text"
value={bankAccountName}
onChange={(e) => setBankAccountName(e.target.value)}
placeholder="Atas nama"
className="rounded-lg border border-secondary-200 bg-white px-3 py-1.5 text-sm focus:border-secondary-400"
/>
</div>
{error && (
<div className="rounded-lg bg-red-100 px-3 py-2 text-xs font-medium text-red-700">
{error}
</div>
)}
<div className="flex gap-2">
<button
type="button"
onClick={handleSubmit}
disabled={
loading ||
note.trim().length < 10 ||
!bankName.trim() ||
!bankAccountNumber.trim() ||
!bankAccountName.trim()
}
className="rounded-xl bg-secondary-600 px-4 py-2 text-sm font-bold text-white hover:bg-secondary-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? "Memproses..." : "Konfirmasi Manual Verify"}
</button>
<button
type="button"
onClick={() => {
setOpen(false);
setNote("");
}}
className="rounded-xl border border-neutral-200 bg-white px-4 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-50"
>
Batal
</button>
</div>
</div>
);
}
@@ -0,0 +1,139 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import {
suspendUserAction,
unsuspendUserAction,
} from "@/features/admin/actions";
interface SuspendUserButtonProps {
userId: string;
isSuspended: boolean;
}
export function SuspendUserButton({
userId,
isSuspended,
}: SuspendUserButtonProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [reason, setReason] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function handleSuspend() {
setLoading(true);
setError("");
const res = await suspendUserAction(userId, reason);
setLoading(false);
if ("error" in res && res.error) {
setError(res.error);
return;
}
setOpen(false);
setReason("");
router.refresh();
}
async function handleUnsuspend() {
if (!confirm("Buka kembali akun ini? User akan langsung bisa login.")) {
return;
}
setLoading(true);
setError("");
const res = await unsuspendUserAction(userId);
setLoading(false);
if ("error" in res && res.error) {
setError(res.error);
return;
}
router.refresh();
}
if (isSuspended) {
return (
<div className="space-y-2">
<button
type="button"
onClick={handleUnsuspend}
disabled={loading}
className="rounded-xl border border-emerald-300 bg-white px-4 py-2 text-sm font-bold text-emerald-700 hover:bg-emerald-50 disabled:opacity-50"
>
{loading ? "Memproses..." : "Buka Suspend"}
</button>
{error && (
<div className="rounded-lg bg-red-50 px-3 py-2 text-xs font-medium text-red-700">
{error}
</div>
)}
</div>
);
}
if (!open) {
return (
<button
type="button"
onClick={() => setOpen(true)}
className="rounded-xl bg-red-600 px-4 py-2 text-sm font-bold text-white shadow-sm hover:bg-red-700"
>
Suspend User
</button>
);
}
return (
<div className="space-y-3 rounded-xl border border-red-200 bg-red-50/60 p-4">
<div>
<label
htmlFor="suspend-reason"
className="mb-1 block text-xs font-semibold text-red-900"
>
Alasan suspend (wajib min 10 karakter untuk audit)
</label>
<textarea
id="suspend-reason"
rows={3}
value={reason}
onChange={(e) => setReason(e.target.value)}
maxLength={500}
placeholder="contoh: User membuat 5 trip palsu dengan alias, lapor masuk dari peserta korban (ticket #123)."
className="w-full rounded-xl border border-red-200 bg-white px-3 py-2 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-red-400"
/>
<p className="mt-1 text-[11px] text-red-900/70">
{reason.trim().length}/500 karakter
</p>
</div>
{error && (
<div className="rounded-lg bg-red-100 px-3 py-2 text-xs font-medium text-red-700">
{error}
</div>
)}
<div className="flex gap-2">
<button
type="button"
onClick={handleSuspend}
disabled={loading || reason.trim().length < 10}
className="rounded-xl bg-red-600 px-4 py-2 text-sm font-bold text-white shadow-sm hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? "Memproses..." : "Konfirmasi Suspend"}
</button>
<button
type="button"
onClick={() => {
setOpen(false);
setReason("");
setError("");
}}
disabled={loading}
className="rounded-xl border border-neutral-200 bg-white px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50 disabled:opacity-50"
>
Batal
</button>
</div>
</div>
);
}
+8
View File
@@ -81,6 +81,14 @@ export async function adminReconcileMidtransAction(orderId: string) {
} }
return { error: "Status pembayaran tidak cocok dengan tagihan" }; return { error: "Status pembayaran tidak cocok dengan tagihan" };
} }
const { auditLog } = await import("@/server/services/audit-log.service");
await auditLog.record({
admin: { id: session.user.id, email: session.user.email },
action: "PAYMENT_RECONCILE",
entityType: "Payment",
entityId: orderId,
payload: { outcome: result.status },
});
return { success: true as const, status: result.status }; return { success: true as const, status: result.status };
} catch (err) { } catch (err) {
return { error: (err as Error).message }; return { error: (err as Error).message };
@@ -2,6 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Check } from "lucide-react";
import { adminReconcileMidtransAction } from "@/features/booking/actions"; import { adminReconcileMidtransAction } from "@/features/booking/actions";
interface AdminReconcileButtonProps { interface AdminReconcileButtonProps {
@@ -45,8 +46,9 @@ export function AdminReconcileButton({
{loading ? "Reconciling..." : "Reconcile Midtrans"} {loading ? "Reconciling..." : "Reconcile Midtrans"}
</button> </button>
{status && ( {status && (
<span className="text-[11px] font-medium text-emerald-700"> <span className="inline-flex items-center gap-1 text-[11px] font-medium text-emerald-700">
{reconcileOutcomeLabel(status)} <Check size={12} strokeWidth={2.5} aria-hidden />
{reconcileOutcomeLabel(status)}
</span> </span>
)} )}
{error && ( {error && (
@@ -2,6 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { CircleAlert } from "lucide-react";
import { cancelBookingWithRefundAction } from "@/features/booking/actions"; import { cancelBookingWithRefundAction } from "@/features/booking/actions";
import { formatRupiah } from "@/lib/utils"; import { formatRupiah } from "@/lib/utils";
@@ -112,9 +113,17 @@ export function CancelBookingButton({ tripId, preview }: CancelBookingButtonProp
Tier: {preview.tierLabel} Tier: {preview.tierLabel}
</p> </p>
{noRefund ? ( {noRefund ? (
<p className="mt-2 text-xs text-red-700"> <p className="mt-2 flex items-start gap-1.5 text-xs text-red-700">
Di luar window refund uang tidak dikembalikan. Booking akan <CircleAlert
di-cancel langsung. size={14}
strokeWidth={2}
aria-hidden
className="mt-0.5 shrink-0"
/>
<span>
Di luar window refund uang tidak dikembalikan. Booking akan
di-cancel langsung.
</span>
</p> </p>
) : ( ) : (
<p className="mt-2 text-xs text-neutral-600"> <p className="mt-2 text-xs text-neutral-600">
+13 -2
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Check, Copy } from "lucide-react";
interface CopyButtonProps { interface CopyButtonProps {
value: string; value: string;
@@ -24,9 +25,19 @@ export function CopyButton({ value, label = "Salin" }: CopyButtonProps) {
<button <button
type="button" type="button"
onClick={handleClick} onClick={handleClick}
className="rounded-lg border border-neutral-200 bg-white px-2.5 py-1 text-xs font-medium text-neutral-600 transition-colors hover:bg-neutral-50" className="inline-flex items-center gap-1 rounded-lg border border-neutral-200 bg-white px-2.5 py-1 text-xs font-medium text-neutral-600 transition-colors hover:bg-neutral-50"
> >
{copied ? "✓ Tersalin" : label} {copied ? (
<>
<Check size={13} strokeWidth={2.5} aria-hidden className="text-emerald-600" />
Tersalin
</>
) : (
<>
<Copy size={13} strokeWidth={1.75} aria-hidden />
{label}
</>
)}
</button> </button>
); );
} }
+57
View File
@@ -0,0 +1,57 @@
"use server";
import { getServerSession } from "next-auth";
import { revalidatePath } from "next/cache";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { emailService } from "@/lib/email/send";
import { auditLog } from "@/server/services/audit-log.service";
async function requireAdmin() {
const session = await getServerSession(authOptions);
if (!session?.user || !isAdminEmail(session.user.email)) {
return null;
}
return session.user;
}
/** E5.2 — admin retry satu EmailJob yang gagal/antri, kirim ulang langsung. */
export async function retryEmailJobAction(jobId: string) {
const admin = await requireAdmin();
if (!admin) return { error: "Tidak memiliki akses admin" };
if (!jobId) return { error: "jobId tidak valid" };
const result = await emailService.retryJob(jobId);
if (!result.ok) {
return { error: result.error ?? "Gagal mengirim ulang email" };
}
await auditLog.record({
admin: { id: admin.id, email: admin.email },
action: "EMAIL_JOB_RETRY",
entityType: "EmailJob",
entityId: jobId,
});
revalidatePath("/admin/emails");
revalidatePath("/admin/system");
return { success: true as const };
}
/** E5.3 — admin resend email yang sudah pernah terkirim (mis. user lapor tidak terima). */
export async function resendEmailAction(emailSentId: string) {
const admin = await requireAdmin();
if (!admin) return { error: "Tidak memiliki akses admin" };
if (!emailSentId) return { error: "emailSentId tidak valid" };
const result = await emailService.resendEmail(emailSentId);
if (!result.ok) {
return { error: result.error ?? "Gagal mengirim ulang email" };
}
await auditLog.record({
admin: { id: admin.id, email: admin.email },
action: "EMAIL_RESEND",
entityType: "EmailSent",
entityId: emailSentId,
});
revalidatePath("/admin/emails");
return { success: true as const };
}
@@ -0,0 +1,110 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Check } from "lucide-react";
import {
retryEmailJobAction,
resendEmailAction,
} from "@/features/email/actions";
const BTN_CLS =
"rounded-lg border border-primary-200 bg-primary-50 px-2.5 py-1 text-[11px] font-semibold text-primary-700 transition-colors hover:bg-primary-100 disabled:cursor-not-allowed disabled:opacity-50";
/** E5.2 — tombol kirim ulang untuk satu EmailJob (antri / gagal). */
export function RetryEmailButton({ jobId }: { jobId: string }) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function handleRetry() {
setLoading(true);
setError("");
const res = await retryEmailJobAction(jobId);
setLoading(false);
if ("error" in res) {
setError(res.error ?? "Gagal");
return;
}
router.refresh();
}
return (
<div>
<button
type="button"
onClick={handleRetry}
disabled={loading}
className={BTN_CLS}
>
{loading ? "Mengirim…" : "Kirim ulang"}
</button>
{error && (
<p className="mt-1 max-w-50 text-[10px] text-red-600">{error}</p>
)}
</div>
);
}
/** E5.3 — tombol resend untuk email yang sudah terkirim. */
export function ResendEmailButton({
emailSentId,
disabled,
}: {
emailSentId: string;
disabled?: boolean;
}) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [done, setDone] = useState(false);
async function handleResend() {
setLoading(true);
setError("");
const res = await resendEmailAction(emailSentId);
setLoading(false);
if ("error" in res) {
setError(res.error ?? "Gagal");
return;
}
setDone(true);
router.refresh();
}
if (disabled) {
return (
<span
className="text-[10px] text-neutral-400"
title="Body email lama tidak tersimpan"
>
</span>
);
}
return (
<div>
<button
type="button"
onClick={handleResend}
disabled={loading || done}
className={`${BTN_CLS} inline-flex items-center gap-1`}
>
{loading ? (
"Mengirim…"
) : done ? (
<>
<Check size={12} strokeWidth={2.5} aria-hidden />
Terkirim
</>
) : (
"Resend"
)}
</button>
{error && (
<p className="mt-1 max-w-50 text-[10px] text-red-600">{error}</p>
)}
</div>
);
}
+254 -1
View File
@@ -4,7 +4,16 @@ import { getServerSession } from "next-auth";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin"; import { isAdminEmail } from "@/lib/admin";
import { organizerService } from "@/server/services/organizer.service"; import {
isReuploadField,
organizerService,
type ReuploadField,
} from "@/server/services/organizer.service";
import { auditLog } from "@/server/services/audit-log.service";
import { emailService } from "@/lib/email/send";
import { organizerRepo } from "@/server/repositories/organizer.repo";
import { userRepo } from "@/server/repositories/user.repo";
import { prisma } from "@/lib/prisma";
import { submitVerificationSchema, reviewVerificationSchema } from "./schemas"; import { submitVerificationSchema, reviewVerificationSchema } from "./schemas";
export async function submitVerificationAction(formData: FormData) { export async function submitVerificationAction(formData: FormData) {
@@ -35,6 +44,7 @@ export async function submitVerificationAction(formData: FormData) {
...result.data, ...result.data,
birthDate: new Date(result.data.birthDate), birthDate: new Date(result.data.birthDate),
}); });
void notifyKycSubmitted(session.user.id);
revalidatePath("/verify"); revalidatePath("/verify");
revalidatePath("/profile"); revalidatePath("/profile");
revalidatePath("/admin/verifications"); revalidatePath("/admin/verifications");
@@ -68,6 +78,23 @@ export async function reviewVerificationAction(formData: FormData) {
rejectionReason: result.data.rejectionReason, rejectionReason: result.data.rejectionReason,
reviewerId: session.user.id, reviewerId: session.user.id,
}); });
await auditLog.record({
admin: { id: session.user.id, email: session.user.email },
action:
result.data.decision === "APPROVED"
? "VERIFICATION_APPROVE"
: "VERIFICATION_REJECT",
entityType: "OrganizerVerification",
entityId: result.data.verificationId,
payload:
result.data.decision === "REJECTED"
? { rejectionReason: result.data.rejectionReason ?? null }
: undefined,
});
// Notif email — fire and forget, jangan blok response.
void notifyVerificationDecision(result.data.verificationId, result.data.decision, result.data.rejectionReason);
revalidatePath("/admin/verifications"); revalidatePath("/admin/verifications");
revalidatePath("/verify"); revalidatePath("/verify");
revalidatePath("/profile"); revalidatePath("/profile");
@@ -76,3 +103,229 @@ export async function reviewVerificationAction(formData: FormData) {
return { error: (err as Error).message }; return { error: (err as Error).message };
} }
} }
async function notifyVerificationDecision(
verificationId: string,
decision: "APPROVED" | "REJECTED",
rejectionReason?: string
) {
const verification = await organizerRepo.findById(verificationId);
if (!verification) return;
const user = await userRepo.findById(verification.userId);
if (!user) return;
if (decision === "APPROVED") {
await emailService.send({
to: user.email,
idempotencyKey: `kyc_approved-${verificationId}`,
template: {
template: "kyc_approved",
data: { userName: user.name },
},
});
} else {
await emailService.send({
to: user.email,
// submissionCount supaya kalau reject berulang masing-masing dapat email.
idempotencyKey: `kyc_rejected-${verificationId}-${verification.submissionCount}`,
template: {
template: "kyc_rejected",
data: {
userName: user.name,
rejectionReason: rejectionReason ?? "(tidak ada alasan tercatat)",
},
},
});
}
}
/** E3.9 — kabari user kalau pengajuan verifikasi-nya sudah masuk antrian. */
async function notifyKycSubmitted(userId: string) {
const verification = await prisma.organizerVerification.findUnique({
where: { userId },
select: { submissionCount: true },
});
const user = await userRepo.findById(userId);
if (!verification || !user) return;
await emailService.send({
to: user.email,
idempotencyKey: `kyc_submitted-${userId}-${verification.submissionCount}`,
template: {
template: "kyc_submitted",
data: { userName: user.name },
},
});
}
/** E3.11 — kabari user kalau pengajuan verifikasi-nya dibuka kembali admin. */
async function notifyKycReopened(verificationId: string) {
const verification = await organizerRepo.findById(verificationId);
if (!verification) return;
const user = await userRepo.findById(verification.userId);
if (!user) return;
await emailService.send({
to: user.email,
idempotencyKey: `kyc_reopened-${verificationId}-${verification.submissionCount}`,
template: {
template: "kyc_reopened",
data: { userName: user.name },
},
});
}
/** E3.10 — kabari user kalau admin verifikasi dia secara manual override. */
async function notifyKycManualOverride(userId: string, verificationId: string) {
const user = await userRepo.findById(userId);
if (!user) return;
await emailService.send({
to: user.email,
idempotencyKey: `kyc_manual_override-${verificationId}`,
template: {
template: "kyc_manual_override",
data: { userName: user.name },
},
});
}
/**
* Admin reopen pengajuan REJECTED ke PENDING — supaya organizer bisa
* di-review ulang tanpa drop & recreate row. Note wajib min 10 char untuk audit.
*/
export async function reopenVerificationAction(
verificationId: string,
note: string
) {
const session = await getServerSession(authOptions);
if (!session?.user || !isAdminEmail(session.user.email)) {
return { error: "Tidak memiliki akses admin" };
}
try {
await organizerService.reopenVerification({
verificationId,
adminId: session.user.id,
note,
});
void notifyKycReopened(verificationId);
await auditLog.record({
admin: { id: session.user.id, email: session.user.email },
action: "VERIFICATION_REOPEN",
entityType: "OrganizerVerification",
entityId: verificationId,
payload: { note: note.trim() },
});
revalidatePath("/admin/verifications");
revalidatePath("/verify");
revalidatePath("/profile");
return { success: true as const };
} catch (err) {
return { error: (err as Error).message };
}
}
/**
* Phase 2: admin minta organizer upload ulang field tertentu — daripada
* reject penuh, set flag `reuploadRequested` + daftar field + note.
*/
export async function requestReuploadAction(
verificationId: string,
fields: string[],
note: string
) {
const session = await getServerSession(authOptions);
if (!session?.user || !isAdminEmail(session.user.email)) {
return { error: "Tidak memiliki akses admin" };
}
const valid = fields.filter(isReuploadField) as ReuploadField[];
try {
await organizerService.requestReupload({
verificationId,
adminId: session.user.id,
fields: valid,
note,
});
await auditLog.record({
admin: { id: session.user.id, email: session.user.email },
action: "VERIFICATION_REQUEST_REUPLOAD",
entityType: "OrganizerVerification",
entityId: verificationId,
payload: { fields: valid, note: note.trim() },
});
// Notif email organizer — urgent, action required.
void notifyReuploadRequest(verificationId, valid, note.trim());
revalidatePath("/admin/verifications");
revalidatePath("/verify");
return { success: true as const };
} catch (err) {
return { error: (err as Error).message };
}
}
async function notifyReuploadRequest(
verificationId: string,
fields: ReuploadField[],
note: string
) {
const verification = await organizerRepo.findById(verificationId);
if (!verification) return;
const user = await userRepo.findById(verification.userId);
if (!user) return;
await emailService.send({
to: user.email,
// Allow re-trigger kalau admin minta lagi setelah submit ulang.
idempotencyKey: `kyc_reupload_request-${verificationId}-${verification.submissionCount}`,
template: {
template: "kyc_reupload_request",
data: {
userName: user.name,
fields,
note,
},
},
});
}
/**
* Phase 4: admin verify user tanpa upload KYC (partner trusted referral).
* Bikin row APPROVED dengan flag `isManualOverride = true`.
*/
export async function manualOverrideVerificationAction(input: {
userId: string;
note: string;
bankName: string;
bankAccountNumber: string;
bankAccountName: string;
}) {
const session = await getServerSession(authOptions);
if (!session?.user || !isAdminEmail(session.user.email)) {
return { error: "Tidak memiliki akses admin" };
}
try {
const result = await organizerService.manualOverrideVerification({
userId: input.userId,
adminId: session.user.id,
note: input.note,
bankName: input.bankName,
bankAccountNumber: input.bankAccountNumber,
bankAccountName: input.bankAccountName,
});
void notifyKycManualOverride(input.userId, result.id);
await auditLog.record({
admin: { id: session.user.id, email: session.user.email },
action: "VERIFICATION_MANUAL_OVERRIDE",
entityType: "OrganizerVerification",
entityId: result.id,
payload: { userId: input.userId, note: input.note.trim() },
});
revalidatePath("/admin/verifications");
revalidatePath(`/admin/users/${input.userId}`);
revalidatePath("/verify");
return { success: true as const, verificationId: result.id };
} catch (err) {
return { error: (err as Error).message };
}
}
+217 -21
View File
@@ -2,7 +2,20 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { reviewVerificationAction } from "@/features/organizer/actions"; import { CircleCheck, CircleX, RefreshCw } from "lucide-react";
import {
reopenVerificationAction,
requestReuploadAction,
reviewVerificationAction,
} from "@/features/organizer/actions";
const REUPLOAD_FIELD_LABELS: { value: string; label: string }[] = [
{ value: "ktpImage", label: "Foto KTP" },
{ value: "liveness", label: "Foto liveness (pegang kertas SETRIP)" },
{ value: "nik", label: "NIK" },
{ value: "bankInfo", label: "Info rekening" },
{ value: "address", label: "Alamat" },
];
type Verification = { type Verification = {
id: string; id: string;
@@ -33,10 +46,42 @@ function formatDate(d: Date): string {
export function ReviewCard({ verification }: { verification: Verification }) { export function ReviewCard({ verification }: { verification: Verification }) {
const router = useRouter(); const router = useRouter();
const [showReject, setShowReject] = useState(false); const [showReject, setShowReject] = useState(false);
const [showReopen, setShowReopen] = useState(false);
const [showReupload, setShowReupload] = useState(false);
const [rejectionReason, setRejectionReason] = useState(""); const [rejectionReason, setRejectionReason] = useState("");
const [reopenNote, setReopenNote] = useState("");
const [reuploadNote, setReuploadNote] = useState("");
const [reuploadFields, setReuploadFields] = useState<string[]>([]);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
function toggleReuploadField(value: string) {
setReuploadFields((prev) =>
prev.includes(value)
? prev.filter((v) => v !== value)
: [...prev, value]
);
}
async function requestReupload() {
setError("");
setLoading(true);
const result = await requestReuploadAction(
verification.id,
reuploadFields,
reuploadNote
);
setLoading(false);
if ("error" in result && result.error) {
setError(result.error);
return;
}
setShowReupload(false);
setReuploadFields([]);
setReuploadNote("");
router.refresh();
}
async function decide(decision: "APPROVED" | "REJECTED") { async function decide(decision: "APPROVED" | "REJECTED") {
setError(""); setError("");
setLoading(true); setLoading(true);
@@ -55,6 +100,20 @@ export function ReviewCard({ verification }: { verification: Verification }) {
router.refresh(); router.refresh();
} }
async function reopen() {
setError("");
setLoading(true);
const result = await reopenVerificationAction(verification.id, reopenNote);
setLoading(false);
if ("error" in result && result.error) {
setError(result.error);
return;
}
setShowReopen(false);
setReopenNote("");
router.refresh();
}
return ( return (
<article className="rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6"> <article className="rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
<header className="mb-4 flex flex-wrap items-start justify-between gap-3 border-b border-neutral-100 pb-4"> <header className="mb-4 flex flex-wrap items-start justify-between gap-3 border-b border-neutral-100 pb-4">
@@ -110,6 +169,63 @@ export function ReviewCard({ verification }: { verification: Verification }) {
</p> </p>
)} )}
{verification.status === "REJECTED" && (
<div className="mt-5 border-t border-neutral-100 pt-4">
{error && (
<div className="mb-3 rounded-lg bg-red-50 px-3 py-2 text-xs text-red-600">
{error}
</div>
)}
{!showReopen ? (
<button
type="button"
onClick={() => setShowReopen(true)}
disabled={loading}
className="inline-flex items-center gap-1.5 rounded-xl border border-amber-300 bg-white px-4 py-2 text-sm font-bold text-amber-700 hover:bg-amber-50 disabled:opacity-50"
>
<RefreshCw size={18} strokeWidth={2} aria-hidden />
Buka kembali ke PENDING
</button>
) : (
<div className="space-y-2 rounded-xl border border-amber-200 bg-amber-50/60 p-3">
<label className="block text-xs font-semibold text-amber-900">
Catatan reopen (min 10 karakter akan disimpan di rejection
reason sebagai history)
</label>
<textarea
value={reopenNote}
onChange={(e) => setReopenNote(e.target.value)}
rows={2}
maxLength={500}
placeholder="contoh: Organizer kirim ulang foto KTP jelas via email, siap di-review ulang."
className="w-full rounded-xl border border-amber-200 bg-white px-3 py-2 text-sm focus:bg-white focus:border-amber-400"
/>
<div className="flex gap-2">
<button
type="button"
onClick={reopen}
disabled={loading || reopenNote.trim().length < 10}
className="rounded-xl bg-amber-600 px-4 py-2 text-sm font-bold text-white hover:bg-amber-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? "Memproses..." : "Konfirmasi Reopen"}
</button>
<button
type="button"
onClick={() => {
setShowReopen(false);
setReopenNote("");
}}
disabled={loading}
className="rounded-xl border border-neutral-200 bg-white px-4 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-50 disabled:opacity-50"
>
Batal
</button>
</div>
</div>
)}
</div>
)}
{verification.status === "PENDING" && ( {verification.status === "PENDING" && (
<div className="mt-5 border-t border-neutral-100 pt-4"> <div className="mt-5 border-t border-neutral-100 pt-4">
{error && ( {error && (
@@ -117,26 +233,7 @@ export function ReviewCard({ verification }: { verification: Verification }) {
{error} {error}
</div> </div>
)} )}
{!showReject ? ( {showReject ? (
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => decide("APPROVED")}
disabled={loading}
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50"
>
Setujui
</button>
<button
type="button"
onClick={() => setShowReject(true)}
disabled={loading}
className="rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50 disabled:opacity-50"
>
Tolak
</button>
</div>
) : (
<div className="space-y-2"> <div className="space-y-2">
<textarea <textarea
value={rejectionReason} value={rejectionReason}
@@ -166,6 +263,105 @@ export function ReviewCard({ verification }: { verification: Verification }) {
</button> </button>
</div> </div>
</div> </div>
) : showReupload ? (
<div className="space-y-3 rounded-xl border border-amber-200 bg-amber-50/60 p-3">
<div>
<label className="mb-1 block text-xs font-semibold text-amber-900">
Field yang perlu di-upload ulang (min 1)
</label>
<div className="flex flex-wrap gap-2">
{REUPLOAD_FIELD_LABELS.map((f) => {
const checked = reuploadFields.includes(f.value);
return (
<label
key={f.value}
className={`cursor-pointer rounded-full border px-3 py-1 text-xs font-medium ${
checked
? "border-amber-600 bg-amber-600 text-white"
: "border-amber-200 bg-white text-amber-800 hover:bg-amber-100"
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleReuploadField(f.value)}
className="sr-only"
/>
{f.label}
</label>
);
})}
</div>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-amber-900">
Catatan untuk organizer (min 10 char akan dilihat user)
</label>
<textarea
value={reuploadNote}
onChange={(e) => setReuploadNote(e.target.value)}
rows={2}
maxLength={500}
placeholder="contoh: Foto KTP terlalu buram, tolong foto ulang dengan pencahayaan lebih baik."
className="w-full rounded-xl border border-amber-200 bg-white px-3 py-2 text-sm focus:border-amber-400"
/>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={requestReupload}
disabled={
loading ||
reuploadFields.length === 0 ||
reuploadNote.trim().length < 10
}
className="rounded-xl bg-amber-600 px-4 py-2 text-sm font-bold text-white hover:bg-amber-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? "Memproses..." : "Kirim Permintaan"}
</button>
<button
type="button"
onClick={() => {
setShowReupload(false);
setReuploadFields([]);
setReuploadNote("");
}}
className="rounded-xl border border-neutral-200 bg-white px-4 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-50"
>
Batal
</button>
</div>
</div>
) : (
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => decide("APPROVED")}
disabled={loading}
className="inline-flex items-center gap-1.5 rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50"
>
<CircleCheck size={18} strokeWidth={2} aria-hidden />
Setujui
</button>
<button
type="button"
onClick={() => setShowReupload(true)}
disabled={loading}
className="inline-flex items-center gap-1.5 rounded-xl border border-amber-300 bg-white px-4 py-2 text-sm font-bold text-amber-700 hover:bg-amber-50 disabled:opacity-50"
>
<RefreshCw size={18} strokeWidth={2} aria-hidden />
Minta re-upload
</button>
<button
type="button"
onClick={() => setShowReject(true)}
disabled={loading}
className="inline-flex items-center gap-1.5 rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50 disabled:opacity-50"
>
<CircleX size={18} strokeWidth={2} aria-hidden />
Tolak
</button>
</div>
)} )}
</div> </div>
)} )}
+59 -16
View File
@@ -2,7 +2,9 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { IdCard, Image as ImageIcon, Landmark, Check } from "lucide-react";
import { submitVerificationAction } from "@/features/organizer/actions"; import { submitVerificationAction } from "@/features/organizer/actions";
import { DateField } from "@/components/shared/date-picker";
type Initial = { type Initial = {
fullName: string; fullName: string;
@@ -21,23 +23,31 @@ type UploadKind = "ktp" | "liveness";
const ACCEPT_MIME = "image/jpeg,image/png,image/webp"; const ACCEPT_MIME = "image/jpeg,image/png,image/webp";
const MAX_BYTES = 5 * 1024 * 1024; const MAX_BYTES = 5 * 1024 * 1024;
function toYmd(d: Date): string {
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
const day = String(d.getUTCDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
export function VerifyForm({ initial }: { initial: Initial }) { export function VerifyForm({ initial }: { initial: Initial }) {
const router = useRouter(); const router = useRouter();
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [ktpKey, setKtpKey] = useState(initial?.ktpImageKey ?? ""); const [ktpKey, setKtpKey] = useState(initial?.ktpImageKey ?? "");
const [livenessKey, setLivenessKey] = useState(initial?.livenessKey ?? ""); const [livenessKey, setLivenessKey] = useState(initial?.livenessKey ?? "");
// `birthDate` dari DB tersimpan sebagai tengah malam UTC — baca pakai getter
// UTC supaya hari kalender yang tampil di picker tidak bergeser.
const [birthDate, setBirthDate] = useState<Date | null>(
initial
? new Date(
initial.birthDate.getUTCFullYear(),
initial.birthDate.getUTCMonth(),
initial.birthDate.getUTCDate()
)
: null
);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();
setError(""); setError("");
if (!birthDate) {
setError("Tanggal lahir wajib diisi");
return;
}
if (!ktpKey || !livenessKey) { if (!ktpKey || !livenessKey) {
setError("Foto KTP dan foto memegang kertas SETRIP wajib diunggah"); setError("Foto KTP dan foto memegang kertas SETRIP wajib diunggah");
return; return;
@@ -70,7 +80,15 @@ export function VerifyForm({ initial }: { initial: Initial }) {
)} )}
<section> <section>
<h2 className="mb-3 text-base font-bold text-neutral-900">📇 Data KTP</h2> <h2 className="mb-3 flex items-center gap-2 text-base font-bold text-neutral-900">
<IdCard
size={18}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
Data KTP
</h2>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<label className="mb-1.5 block text-sm font-semibold text-neutral-700"> <label className="mb-1.5 block text-sm font-semibold text-neutral-700">
@@ -102,15 +120,21 @@ export function VerifyForm({ initial }: { initial: Initial }) {
/> />
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-semibold text-neutral-700"> <label
htmlFor="birthDate"
className="mb-1.5 block text-sm font-semibold text-neutral-700"
>
Tanggal Lahir Tanggal Lahir
</label> </label>
<input <DateField
id="birthDate"
name="birthDate" name="birthDate"
type="date" value={birthDate}
onChange={setBirthDate}
maxDate={new Date()}
withMonthYearDropdown
required required
defaultValue={initial ? toYmd(new Date(initial.birthDate)) : ""} placeholder="Pilih tanggal lahir"
className={inputCls}
/> />
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
@@ -130,7 +154,15 @@ export function VerifyForm({ initial }: { initial: Initial }) {
</section> </section>
<section> <section>
<h2 className="mb-3 text-base font-bold text-neutral-900">🖼 Foto</h2> <h2 className="mb-3 flex items-center gap-2 text-base font-bold text-neutral-900">
<ImageIcon
size={18}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
Foto
</h2>
<p className="mb-3 text-xs text-neutral-500"> <p className="mb-3 text-xs text-neutral-500">
Foto disimpan terenkripsi di server SeTrip dan hanya bisa dilihat oleh Foto disimpan terenkripsi di server SeTrip dan hanya bisa dilihat oleh
tim admin saat review. Maks 5MB, JPG/PNG/WebP. tim admin saat review. Maks 5MB, JPG/PNG/WebP.
@@ -163,7 +195,15 @@ export function VerifyForm({ initial }: { initial: Initial }) {
</section> </section>
<section> <section>
<h2 className="mb-3 text-base font-bold text-neutral-900">🏦 Rekening Bank</h2> <h2 className="mb-3 flex items-center gap-2 text-base font-bold text-neutral-900">
<Landmark
size={18}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
Rekening Bank
</h2>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div> <div>
<label className="mb-1.5 block text-sm font-semibold text-neutral-700"> <label className="mb-1.5 block text-sm font-semibold text-neutral-700">
@@ -297,7 +337,10 @@ function FileUpload({
/> />
</label> </label>
{value && !busy && ( {value && !busy && (
<span className="text-xs text-neutral-500"> Terunggah</span> <span className="inline-flex items-center gap-1 text-xs text-emerald-600">
<Check size={13} strokeWidth={2.5} aria-hidden />
Terunggah
</span>
)} )}
</div> </div>
{previewUrl && ( {previewUrl && (
+30
View File
@@ -5,6 +5,9 @@ import { revalidatePath } from "next/cache";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin"; import { isAdminEmail } from "@/lib/admin";
import { payoutService } from "@/server/services/payout.service"; import { payoutService } from "@/server/services/payout.service";
import { payoutRepo } from "@/server/repositories/payout.repo";
import { auditLog } from "@/server/services/audit-log.service";
import { emailService } from "@/lib/email/send";
import { payoutMarkPaidSchema } from "./schemas"; import { payoutMarkPaidSchema } from "./schemas";
async function requireAdmin() { async function requireAdmin() {
@@ -33,6 +36,14 @@ export async function markPayoutPaidAction(formData: FormData) {
adminId: admin.id, adminId: admin.id,
adminNote: parsed.data.adminNote, adminNote: parsed.data.adminNote,
}); });
void notifyPayoutPaid(parsed.data.payoutId, parsed.data.adminNote);
await auditLog.record({
admin: { id: admin.id, email: admin.email },
action: "PAYOUT_MARK_PAID",
entityType: "Payout",
entityId: parsed.data.payoutId,
payload: { adminNote: parsed.data.adminNote },
});
revalidatePath("/admin/payouts"); revalidatePath("/admin/payouts");
revalidatePath("/admin"); revalidatePath("/admin");
revalidatePath("/profile"); revalidatePath("/profile");
@@ -41,3 +52,22 @@ export async function markPayoutPaidAction(formData: FormData) {
return { error: (err as Error).message }; return { error: (err as Error).message };
} }
} }
/** E3.7 — kabari organizer kalau payout-nya sudah ditransfer admin. */
async function notifyPayoutPaid(payoutId: string, adminNote: string) {
const payout = await payoutRepo.findById(payoutId);
if (!payout) return;
await emailService.send({
to: payout.organizer.email,
idempotencyKey: `payout_paid-${payout.id}`,
template: {
template: "payout_paid",
data: {
organizerName: payout.organizer.name,
tripTitle: payout.trip.title,
amount: payout.amount,
adminNote,
},
},
});
}
@@ -3,6 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ArrowRight, Banknote, CircleAlert } from "lucide-react";
import { markPayoutPaidAction } from "@/features/payout/actions"; import { markPayoutPaidAction } from "@/features/payout/actions";
import { formatRupiah } from "@/lib/utils"; import { formatRupiah } from "@/lib/utils";
@@ -95,9 +96,10 @@ export function PayoutReviewCard({ payout }: { payout: PayoutCardData }) {
</p> </p>
<Link <Link
href={`/admin/bookings/${payout.booking.id}`} href={`/admin/bookings/${payout.booking.id}`}
className="mt-1 inline-block text-[11px] font-semibold text-secondary-700 hover:text-secondary-900" className="mt-1 inline-flex items-center gap-1 text-[11px] font-semibold text-secondary-700 hover:text-secondary-900"
> >
Lihat timeline booking <ArrowRight size={14} strokeWidth={2} aria-hidden />
Lihat timeline booking
</Link> </Link>
</div> </div>
<StatusPill status={payout.status} /> <StatusPill status={payout.status} />
@@ -142,9 +144,18 @@ export function PayoutReviewCard({ payout }: { payout: PayoutCardData }) {
</p> </p>
</div> </div>
) : ( ) : (
<p className="text-amber-700"> <p className="flex gap-1.5 text-amber-700">
Organizer belum menyelesaikan verifikasi (KYC) tidak ada rekening <CircleAlert
snapshot. Hubungi organizer untuk konfirmasi rekening sebelum transfer. size={16}
strokeWidth={1.75}
aria-hidden
className="mt-0.5 shrink-0"
/>
<span>
Organizer belum menyelesaikan verifikasi (KYC) tidak ada
rekening snapshot. Hubungi organizer untuk konfirmasi rekening
sebelum transfer.
</span>
</p> </p>
)} )}
</div> </div>
@@ -212,9 +223,10 @@ export function PayoutReviewCard({ payout }: { payout: PayoutCardData }) {
type="button" type="button"
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
disabled={loading} disabled={loading}
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50" className="inline-flex items-center gap-1.5 rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50"
> >
💸 Tandai sudah ditransfer ke organizer <Banknote size={18} strokeWidth={2} aria-hidden />
Tandai sudah ditransfer ke organizer
</button> </button>
)} )}
</div> </div>
@@ -1,3 +1,4 @@
import { BadgeCheck, Star } from "lucide-react";
import type { OrganizerTrust } from "@/server/services/trust.service"; import type { OrganizerTrust } from "@/server/services/trust.service";
interface OrganizerStatsPanelProps { interface OrganizerStatsPanelProps {
@@ -47,7 +48,8 @@ export function OrganizerStatsPanel({ trust }: OrganizerStatsPanelProps) {
className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-0.5 text-[11px] font-bold uppercase tracking-wide text-primary-800" className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-0.5 text-[11px] font-bold uppercase tracking-wide text-primary-800"
title="Identitas organizer telah diverifikasi (KTP & rekening)" title="Identitas organizer telah diverifikasi (KTP & rekening)"
> >
Verified Organizer <BadgeCheck size={12} strokeWidth={2} aria-hidden />
Verified Organizer
</span> </span>
)} )}
{isTripLeader && ( {isTripLeader && (
@@ -83,7 +85,21 @@ export function OrganizerStatsPanel({ trust }: OrganizerStatsPanelProps) {
/> />
<Stat <Stat
label="Rating" label="Rating"
value={avgRating != null ? `${avgRating}` : "—"} value={
avgRating != null ? (
<span className="inline-flex items-center gap-1">
{avgRating}
<Star
size={14}
strokeWidth={2}
fill="currentColor"
aria-hidden
/>
</span>
) : (
"—"
)
}
subtitle={ subtitle={
reviewCount > 0 reviewCount > 0
? `${reviewCount} ulasan` ? `${reviewCount} ulasan`
@@ -107,8 +123,15 @@ export function OrganizerStatsPanel({ trust }: OrganizerStatsPanelProps) {
key={star} key={star}
className="flex items-center gap-2 text-xs" className="flex items-center gap-2 text-xs"
> >
<span className="w-8 shrink-0 font-medium text-neutral-600"> <span className="flex w-8 shrink-0 items-center gap-0.5 font-medium text-neutral-600">
{star} {star}
<Star
size={11}
strokeWidth={2}
fill="currentColor"
aria-hidden
className="text-amber-500"
/>
</span> </span>
<div className="h-2 flex-1 overflow-hidden rounded-full bg-neutral-100"> <div className="h-2 flex-1 overflow-hidden rounded-full bg-neutral-100">
<div <div
@@ -138,7 +161,7 @@ const TONE_CLASSES = {
interface StatProps { interface StatProps {
label: string; label: string;
value: string; value: React.ReactNode;
subtitle?: string; subtitle?: string;
tone: keyof typeof TONE_CLASSES; tone: keyof typeof TONE_CLASSES;
} }
@@ -2,6 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ArrowUpRight } from "lucide-react";
import { updateProfileAction } from "@/features/profile/actions"; import { updateProfileAction } from "@/features/profile/actions";
import { LIMITS } from "@/lib/limits"; import { LIMITS } from "@/lib/limits";
import { VIBES, vibeMeta } from "@/lib/vibe"; import { VIBES, vibeMeta } from "@/lib/vibe";
@@ -102,9 +103,10 @@ export function ProfileEditor({ userId, initial }: ProfileEditorProps) {
href={`/u/${userId}`} href={`/u/${userId}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="rounded-lg border border-neutral-200 px-3 py-1.5 text-xs font-medium text-neutral-600 hover:bg-neutral-50" className="inline-flex items-center gap-1 rounded-lg border border-neutral-200 px-3 py-1.5 text-xs font-medium text-neutral-600 hover:bg-neutral-50"
> >
Lihat publik Lihat publik
<ArrowUpRight size={13} strokeWidth={1.75} aria-hidden />
</a> </a>
<button <button
type="button" type="button"
@@ -324,9 +326,10 @@ export function ProfileEditor({ userId, initial }: ProfileEditorProps) {
href={`/u/${userId}`} href={`/u/${userId}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="rounded-xl border border-neutral-200 px-4 py-2.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50" className="inline-flex items-center gap-1 rounded-xl border border-neutral-200 px-4 py-2.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
> >
Lihat publik Lihat publik
<ArrowUpRight size={14} strokeWidth={1.75} aria-hidden />
</a> </a>
</div> </div>
</form> </form>
+7 -4
View File
@@ -1,5 +1,6 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { MapPin, BadgeCheck } from "lucide-react";
import { vibeMeta } from "@/lib/vibe"; import { vibeMeta } from "@/lib/vibe";
import type { Vibe } from "@/app/generated/prisma/enums"; import type { Vibe } from "@/app/generated/prisma/enums";
@@ -48,17 +49,19 @@ export function UserCard({
{name} {name}
</p> </p>
{profile?.city && ( {profile?.city && (
<p className="truncate text-[11px] text-neutral-500 sm:text-xs"> <p className="flex items-center gap-1 truncate text-[11px] text-neutral-500 sm:text-xs">
📍 {profile.city} <MapPin size={11} strokeWidth={1.75} aria-hidden className="shrink-0" />
{profile.city}
</p> </p>
)} )}
<div className="mt-1 flex flex-wrap gap-1"> <div className="mt-1 flex flex-wrap gap-1">
{isVerifiedOrganizer && ( {isVerifiedOrganizer && (
<span <span
className="inline-flex items-center gap-0.5 rounded-full bg-primary-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-primary-800" className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-primary-800"
title="Organizer terverifikasi" title="Organizer terverifikasi"
> >
Organizer <BadgeCheck size={11} strokeWidth={2} aria-hidden />
Organizer
</span> </span>
)} )}
{profile?.vibe && ( {profile?.vibe && (
+103 -1
View File
@@ -5,6 +5,9 @@ import { revalidatePath } from "next/cache";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin"; import { isAdminEmail } from "@/lib/admin";
import { refundService } from "@/server/services/refund.service"; import { refundService } from "@/server/services/refund.service";
import { auditLog } from "@/server/services/audit-log.service";
import { emailService } from "@/lib/email/send";
import { prisma } from "@/lib/prisma";
import { createRefundSchema, refundDecisionSchema } from "./schemas"; import { createRefundSchema, refundDecisionSchema } from "./schemas";
async function requireAdmin() { async function requireAdmin() {
@@ -31,7 +34,7 @@ export async function createRefundAction(formData: FormData) {
} }
try { try {
await refundService.requestRefund({ const refund = await refundService.requestRefund({
bookingId: parsed.data.bookingId, bookingId: parsed.data.bookingId,
reason: parsed.data.reason, reason: parsed.data.reason,
reportedBy: parsed.data.reportedBy, reportedBy: parsed.data.reportedBy,
@@ -39,6 +42,20 @@ export async function createRefundAction(formData: FormData) {
amount: parsed.data.amount, amount: parsed.data.amount,
initiatedByAdminId: admin.id, initiatedByAdminId: admin.id,
}); });
await auditLog.record({
admin: { id: admin.id, email: admin.email },
action: "REFUND_CREATE",
entityType: "Refund",
entityId: refund.id,
payload: {
bookingId: parsed.data.bookingId,
amount: parsed.data.amount,
reason: parsed.data.reason,
},
});
void notifyRefundCreated(refund.id);
revalidatePath("/admin/refunds"); revalidatePath("/admin/refunds");
return { success: true }; return { success: true };
} catch (err) { } catch (err) {
@@ -46,6 +63,77 @@ export async function createRefundAction(formData: FormData) {
} }
} }
async function notifyRefundCreated(refundId: string) {
const refund = await prisma.refund.findUnique({
where: { id: refundId },
include: {
booking: {
include: {
user: { select: { email: true, name: true } },
trip: { select: { title: true } },
},
},
},
});
if (!refund) return;
await emailService.send({
to: refund.booking.user.email,
idempotencyKey: `refund_created-${refund.id}`,
template: {
template: "refund_created",
data: {
userName: refund.booking.user.name,
tripTitle: refund.booking.trip.title,
amount: refund.amount,
reason: refund.reason,
},
},
});
}
async function notifyRefundDecision(
refundId: string,
decision: "SUCCEEDED" | "FAILED",
adminNote: string
) {
const refund = await prisma.refund.findUnique({
where: { id: refundId },
include: {
booking: {
include: {
user: { select: { email: true, name: true } },
trip: { select: { title: true } },
},
},
},
});
if (!refund) return;
await emailService.send({
to: refund.booking.user.email,
idempotencyKey: `refund_${decision.toLowerCase()}-${refund.id}`,
template:
decision === "SUCCEEDED"
? {
template: "refund_succeeded",
data: {
userName: refund.booking.user.name,
tripTitle: refund.booking.trip.title,
amount: refund.amount,
adminNote,
},
}
: {
template: "refund_failed",
data: {
userName: refund.booking.user.name,
tripTitle: refund.booking.trip.title,
amount: refund.amount,
adminNote,
},
},
});
}
export async function decideRefundAction(formData: FormData) { export async function decideRefundAction(formData: FormData) {
const admin = await requireAdmin(); const admin = await requireAdmin();
if (!admin) return { error: "Tidak memiliki akses admin" }; if (!admin) return { error: "Tidak memiliki akses admin" };
@@ -91,6 +179,20 @@ export async function decideRefundAction(formData: FormData) {
adminNote: adminNote!, adminNote: adminNote!,
}); });
} }
await auditLog.record({
admin: { id: admin.id, email: admin.email },
action: `REFUND_${decision}`,
entityType: "Refund",
entityId: refundId,
payload: adminNote ? { adminNote } : undefined,
});
// Notif email user kalau decision final (SUCCEEDED/FAILED) — APPROVE/REJECT
// intermediate, refund_created sudah dikirim sebelumnya.
if (decision === "SUCCEEDED" || decision === "FAILED") {
void notifyRefundDecision(refundId, decision, adminNote ?? "");
}
revalidatePath("/admin/refunds"); revalidatePath("/admin/refunds");
return { success: true }; return { success: true };
} catch (err) { } catch (err) {
@@ -1,3 +1,4 @@
import { LifeBuoy } from "lucide-react";
import { getRefundPolicyTiers } from "@/lib/refund-policy"; import { getRefundPolicyTiers } from "@/lib/refund-policy";
/** /**
@@ -9,7 +10,13 @@ export function RefundPolicySection() {
return ( return (
<details className="rounded-xl border border-neutral-200 bg-neutral-50/60 p-3 text-xs sm:text-sm"> <details className="rounded-xl border border-neutral-200 bg-neutral-50/60 p-3 text-xs sm:text-sm">
<summary className="cursor-pointer select-none font-semibold text-neutral-700"> <summary className="cursor-pointer select-none font-semibold text-neutral-700">
🛟 Kebijakan refund saat peserta cancel <LifeBuoy
size={15}
strokeWidth={1.75}
aria-hidden
className="mr-1.5 inline align-text-bottom"
/>
Kebijakan refund saat peserta cancel
</summary> </summary>
<div className="mt-2 space-y-2 text-neutral-600"> <div className="mt-2 space-y-2 text-neutral-600">
<p className="text-[11px] text-neutral-500 sm:text-xs"> <p className="text-[11px] text-neutral-500 sm:text-xs">
@@ -21,7 +28,7 @@ export function RefundPolicySection() {
{tiers.map((t) => ( {tiers.map((t) => (
<li key={t.minDaysBefore} className="flex items-baseline gap-2"> <li key={t.minDaysBefore} className="flex items-baseline gap-2">
<span <span
className={`inline-flex min-w-[3rem] justify-center rounded-full px-2 py-0.5 text-[10px] font-bold ${ className={`inline-flex min-w-12 justify-center rounded-full px-2 py-0.5 text-[10px] font-bold ${
t.refundPercentage >= 80 t.refundPercentage >= 80
? "bg-primary-100 text-primary-700" ? "bg-primary-100 text-primary-700"
: t.refundPercentage >= 50 : t.refundPercentage >= 50
@@ -3,6 +3,13 @@
import { useState } from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import {
ArrowRight,
Banknote,
CircleAlert,
CircleCheck,
CircleX,
} from "lucide-react";
import { decideRefundAction } from "@/features/refund/actions"; import { decideRefundAction } from "@/features/refund/actions";
import { formatRupiah } from "@/lib/utils"; import { formatRupiah } from "@/lib/utils";
@@ -129,9 +136,10 @@ export function RefundReviewCard({ refund }: { refund: RefundCardData }) {
</p> </p>
<Link <Link
href={`/admin/bookings/${refund.booking.id}`} href={`/admin/bookings/${refund.booking.id}`}
className="mt-1 inline-block text-[11px] font-semibold text-secondary-700 hover:text-secondary-900" className="mt-1 inline-flex items-center gap-1 text-[11px] font-semibold text-secondary-700 hover:text-secondary-900"
> >
Lihat timeline payment & refund <ArrowRight size={14} strokeWidth={2} aria-hidden />
Lihat timeline payment & refund
</Link> </Link>
</div> </div>
<Field <Field
@@ -211,17 +219,19 @@ export function RefundReviewCard({ refund }: { refund: RefundCardData }) {
type="button" type="button"
onClick={() => setOpenAction("APPROVE")} onClick={() => setOpenAction("APPROVE")}
disabled={loading} disabled={loading}
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50" className="inline-flex items-center gap-1.5 rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50"
> >
Setujui <CircleCheck size={18} strokeWidth={2} aria-hidden />
Setujui
</button> </button>
<button <button
type="button" type="button"
onClick={() => setOpenAction("REJECT")} onClick={() => setOpenAction("REJECT")}
disabled={loading} disabled={loading}
className="rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50 disabled:opacity-50" className="inline-flex items-center gap-1.5 rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50 disabled:opacity-50"
> >
Tolak <CircleX size={18} strokeWidth={2} aria-hidden />
Tolak
</button> </button>
</> </>
)} )}
@@ -231,17 +241,19 @@ export function RefundReviewCard({ refund }: { refund: RefundCardData }) {
type="button" type="button"
onClick={() => setOpenAction("SUCCEEDED")} onClick={() => setOpenAction("SUCCEEDED")}
disabled={loading} disabled={loading}
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50" className="inline-flex items-center gap-1.5 rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50"
> >
💸 Tandai sudah ditransfer <Banknote size={18} strokeWidth={2} aria-hidden />
Tandai sudah ditransfer
</button> </button>
<button <button
type="button" type="button"
onClick={() => setOpenAction("FAILED")} onClick={() => setOpenAction("FAILED")}
disabled={loading} disabled={loading}
className="rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50 disabled:opacity-50" className="inline-flex items-center gap-1.5 rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50 disabled:opacity-50"
> >
Tandai gagal <CircleAlert size={18} strokeWidth={2} aria-hidden />
Tandai gagal
</button> </button>
</> </>
)} )}
@@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { Star } from "lucide-react";
import type { OrganizerReviewItem } from "@/server/services/review.service"; import type { OrganizerReviewItem } from "@/server/services/review.service";
interface OrganizerReviewsListProps { interface OrganizerReviewsListProps {
@@ -62,11 +63,22 @@ export function OrganizerReviewsList({
> >
{r.user.name} {r.user.name}
</Link> </Link>
<span className="text-xs font-bold text-amber-600"> <span
{"★".repeat(r.rating)} className="flex shrink-0 items-center gap-0.5"
<span className="text-neutral-300"> aria-label={`Rating ${r.rating} dari 5`}
{"★".repeat(5 - r.rating)} >
</span> {[1, 2, 3, 4, 5].map((n) => (
<Star
key={n}
size={12}
strokeWidth={2}
fill="currentColor"
aria-hidden
className={
n <= r.rating ? "text-amber-500" : "text-neutral-200"
}
/>
))}
</span> </span>
</div> </div>
+171
View File
@@ -11,12 +11,21 @@ import { tripService } from "@/server/services/trip.service";
import { organizerService } from "@/server/services/organizer.service"; import { organizerService } from "@/server/services/organizer.service";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { tripStoredInstantFromYmd } from "@/lib/trip-dates"; import { tripStoredInstantFromYmd } from "@/lib/trip-dates";
import { requireActiveUser } from "@/lib/auth-guards";
import { auditLog } from "@/server/services/audit-log.service";
import { emailService } from "@/lib/email/send";
import { prisma } from "@/lib/prisma";
export async function createTripAction(formData: FormData) { export async function createTripAction(formData: FormData) {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session?.user) { if (!session?.user) {
return { error: "Kamu harus login terlebih dahulu" }; return { error: "Kamu harus login terlebih dahulu" };
} }
try {
await requireActiveUser(session.user.id);
} catch (err) {
return { error: (err as Error).message };
}
const raw = { const raw = {
category: formData.get("category") as string, category: formData.get("category") as string,
@@ -120,7 +129,9 @@ export async function joinTripAction(tripId: string) {
} }
try { try {
await requireActiveUser(session.user.id);
await tripService.joinTrip(tripId, session.user.id); await tripService.joinTrip(tripId, session.user.id);
void notifyJoinRequest(tripId, session.user.id);
revalidatePath(`/trips/${tripId}`); revalidatePath(`/trips/${tripId}`);
revalidatePath("/trips"); revalidatePath("/trips");
revalidatePath("/"); revalidatePath("/");
@@ -164,6 +175,9 @@ export async function confirmParticipantAction(
participantId, participantId,
session.user.id session.user.id
); );
void notifyBookingApproved(participantId);
revalidatePath(`/trips/${tripId}`); revalidatePath(`/trips/${tripId}`);
revalidatePath("/trips"); revalidatePath("/trips");
revalidatePath("/"); revalidatePath("/");
@@ -174,6 +188,85 @@ export async function confirmParticipantAction(
} }
} }
async function notifyBookingApproved(participantId: string) {
const participant = await prisma.tripParticipant.findUnique({
where: { id: participantId },
include: {
user: { select: { email: true, name: true } },
trip: { select: { id: true, title: true } },
booking: { select: { id: true, amount: true } },
},
});
if (!participant || !participant.booking) return;
await emailService.send({
to: participant.user.email,
idempotencyKey: `booking_approved-${participant.booking.id}`,
template: {
template: "booking_approved",
data: {
userName: participant.user.name,
tripTitle: participant.trip.title,
tripId: participant.trip.id,
amount: participant.booking.amount,
},
},
});
}
/** E3.1 — kabari organizer ada peserta baru yang mengajukan join. */
async function notifyJoinRequest(tripId: string, joinerId: string) {
const [trip, joiner] = await Promise.all([
prisma.trip.findUnique({
where: { id: tripId },
select: {
title: true,
organizer: { select: { email: true, name: true } },
},
}),
prisma.user.findUnique({
where: { id: joinerId },
select: { name: true },
}),
]);
if (!trip || !joiner) return;
await emailService.send({
to: trip.organizer.email,
idempotencyKey: `join_request-${tripId}-${joinerId}`,
template: {
template: "join_request",
data: {
organizerName: trip.organizer.name,
joinerName: joiner.name,
tripTitle: trip.title,
tripId,
},
},
});
}
/** E3.2 — kabari peserta kalau permintaan join-nya ditolak organizer. */
async function notifyJoinRejected(participantId: string) {
const participant = await prisma.tripParticipant.findUnique({
where: { id: participantId },
select: {
user: { select: { email: true, name: true } },
trip: { select: { title: true } },
},
});
if (!participant) return;
await emailService.send({
to: participant.user.email,
idempotencyKey: `join_rejected-${participantId}`,
template: {
template: "join_rejected",
data: {
userName: participant.user.name,
tripTitle: participant.trip.title,
},
},
});
}
export async function rejectParticipantAction( export async function rejectParticipantAction(
tripId: string, tripId: string,
participantId: string participantId: string
@@ -189,6 +282,7 @@ export async function rejectParticipantAction(
participantId, participantId,
session.user.id session.user.id
); );
void notifyJoinRejected(participantId);
revalidatePath(`/trips/${tripId}`); revalidatePath(`/trips/${tripId}`);
revalidatePath("/trips"); revalidatePath("/trips");
revalidatePath("/"); revalidatePath("/");
@@ -199,6 +293,66 @@ export async function rejectParticipantAction(
} }
} }
type CloseTripResult = Awaited<ReturnType<typeof tripService.closeTrip>>;
/**
* E3.4 / E3.5 (+ E2.4) — kabari semua peserta aktif kalau trip dibatalkan.
* Email organizer-cancel & admin-cancel beda template; admin-cancel juga
* mengabari organizer. Refund block ikut di email (nominal dari `notify`).
*/
function notifyTripCancelled(
tripId: string,
notify: CloseTripResult["notify"],
actor: { type: "ORGANIZER" } | { type: "ADMIN"; reason: string }
) {
for (const p of notify.participants) {
if (actor.type === "ORGANIZER") {
void emailService.send({
to: p.email,
idempotencyKey: `trip_cancelled_organizer-${tripId}-${p.userId}`,
template: {
template: "trip_cancelled_organizer",
data: {
userName: p.name,
tripTitle: notify.tripTitle,
refundAmount: p.refundAmount,
},
},
});
} else {
void emailService.send({
to: p.email,
idempotencyKey: `trip_cancelled_admin-${tripId}-${p.userId}`,
template: {
template: "trip_cancelled_admin",
data: {
userName: p.name,
tripTitle: notify.tripTitle,
reason: actor.reason,
refundAmount: p.refundAmount,
},
},
});
}
}
// Admin force-cancel → organizer juga dikabari (E3.5).
if (actor.type === "ADMIN") {
void emailService.send({
to: notify.organizer.email,
idempotencyKey: `trip_cancelled_admin-${tripId}-organizer`,
template: {
template: "trip_cancelled_admin",
data: {
userName: notify.organizer.name,
tripTitle: notify.tripTitle,
reason: actor.reason,
refundAmount: 0,
},
},
});
}
}
export async function cancelTripAction(tripId: string) { export async function cancelTripAction(tripId: string) {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session?.user) { if (!session?.user) {
@@ -210,6 +364,7 @@ export async function cancelTripAction(tripId: string) {
type: "ORGANIZER", type: "ORGANIZER",
userId: session.user.id, userId: session.user.id,
}); });
notifyTripCancelled(tripId, result.notify, { type: "ORGANIZER" });
revalidatePath(`/trips/${tripId}`); revalidatePath(`/trips/${tripId}`);
revalidatePath("/trips"); revalidatePath("/trips");
revalidatePath("/"); revalidatePath("/");
@@ -256,6 +411,22 @@ export async function adminCancelTripAction(tripId: string, reason: string) {
adminId: session.user.id, adminId: session.user.id,
reason: trimmedReason, reason: trimmedReason,
}); });
notifyTripCancelled(tripId, result.notify, {
type: "ADMIN",
reason: trimmedReason,
});
await auditLog.record({
admin: { id: session.user.id, email: session.user.email },
action: "TRIP_ADMIN_CANCEL",
entityType: "Trip",
entityId: tripId,
payload: {
reason: trimmedReason,
refundsCreated: result.refundsCreated.length,
cancelledBookings: result.cancelledBookings.length,
skippedBookings: result.skippedBookings.length,
},
});
revalidatePath(`/trips/${tripId}`); revalidatePath(`/trips/${tripId}`);
revalidatePath(`/admin/trips/${tripId}`); revalidatePath(`/admin/trips/${tripId}`);
revalidatePath("/admin/trips"); revalidatePath("/admin/trips");
@@ -2,6 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { CircleCheck } from "lucide-react";
import { adminCancelTripAction } from "@/features/trip/actions"; import { adminCancelTripAction } from "@/features/trip/actions";
interface AdminCancelTripButtonProps { interface AdminCancelTripButtonProps {
@@ -42,7 +43,10 @@ export function AdminCancelTripButton({ tripId }: AdminCancelTripButtonProps) {
if (result) { if (result) {
return ( return (
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900"> <div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900">
<p className="font-bold"> Trip berhasil dibatalkan.</p> <p className="flex items-center gap-1.5 font-bold">
<CircleCheck size={18} strokeWidth={2} aria-hidden />
Trip berhasil dibatalkan.
</p>
<ul className="mt-2 space-y-0.5 text-xs"> <ul className="mt-2 space-y-0.5 text-xs">
<li> {result.refundCount} booking PAID refund auto-dibuat</li> <li> {result.refundCount} booking PAID refund auto-dibuat</li>
<li> <li>
+62 -87
View File
@@ -2,10 +2,17 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import DatePicker from "react-datepicker"; import {
import "react-datepicker/dist/react-datepicker.css"; ArrowLeft,
ArrowRight,
Check,
X,
CircleAlert,
Users,
} from "lucide-react";
import { DateRangeField, TimeField } from "@/components/shared/date-picker";
import { createTripAction } from "@/features/trip/actions"; import { createTripAction } from "@/features/trip/actions";
import { ImageUrlInput } from "@/features/trip/components/image-url-input"; import { TripImageUpload } from "@/features/trip/components/trip-image-upload";
import { formatLocalCalendarYmd } from "@/lib/trip-dates"; import { formatLocalCalendarYmd } from "@/lib/trip-dates";
import { ACTIVITY_CATEGORIES, categoryMeta } from "@/lib/activity-category"; import { ACTIVITY_CATEGORIES, categoryMeta } from "@/lib/activity-category";
import { VIBES, vibeMeta } from "@/lib/vibe"; import { VIBES, vibeMeta } from "@/lib/vibe";
@@ -54,7 +61,7 @@ const INITIAL_STATE: FormState = {
itineraryDays: [], itineraryDays: [],
whatsIncluded: "", whatsIncluded: "",
whatsExcluded: "", whatsExcluded: "",
imageUrls: [""], imageUrls: [],
maxParticipants: "", maxParticipants: "",
priceDisplay: "", priceDisplay: "",
}; };
@@ -145,18 +152,11 @@ export function CreateTripForm({ isVerifiedOrganizer }: CreateTripFormProps) {
return null; return null;
} }
if (target === 3) { if (target === 3) {
const hasInvalidUrl = state.imageUrls // Foto divalidasi saat upload (route + komponen). Di sini cukup cek
.map((u) => u.trim()) // batas jumlah supaya tidak melampaui kapasitas.
.filter(Boolean) if (state.imageUrls.length > LIMITS.MAX_IMAGE_URLS) {
.some((u) => { return `Maksimal ${LIMITS.MAX_IMAGE_URLS} foto`;
try { }
const parsed = new URL(u);
return parsed.protocol !== "http:" && parsed.protocol !== "https:";
} catch {
return true;
}
});
if (hasInvalidUrl) return "Ada URL foto yang tidak valid (harus http/https)";
for (let d = 0; d < state.itineraryDays.length; d++) { for (let d = 0; d < state.itineraryDays.length; d++) {
const dayItems = state.itineraryDays[d]; const dayItems = state.itineraryDays[d];
@@ -331,6 +331,7 @@ export function CreateTripForm({ isVerifiedOrganizer }: CreateTripFormProps) {
whatsExcluded={state.whatsExcluded} whatsExcluded={state.whatsExcluded}
imageUrls={state.imageUrls} imageUrls={state.imageUrls}
onChange={update} onChange={update}
onError={setStepError}
/> />
)} )}
@@ -368,9 +369,10 @@ export function CreateTripForm({ isVerifiedOrganizer }: CreateTripFormProps) {
type="button" type="button"
onClick={goBack} onClick={goBack}
disabled={step === 1 || loading} disabled={step === 1 || loading}
className="rounded-xl border border-neutral-200 bg-white px-4 py-2.5 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40" className="inline-flex items-center gap-1 rounded-xl border border-neutral-200 bg-white px-4 py-2.5 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40"
> >
Kembali <ArrowLeft size={15} strokeWidth={2} aria-hidden />
Kembali
</button> </button>
{isLastStep ? ( {isLastStep ? (
@@ -389,9 +391,10 @@ export function CreateTripForm({ isVerifiedOrganizer }: CreateTripFormProps) {
<button <button
type="button" type="button"
onClick={goNext} onClick={goNext}
className="rounded-xl bg-primary-600 px-5 py-2.5 text-sm font-bold text-white shadow-lg shadow-primary-600/20 transition-colors hover:bg-primary-700" className="inline-flex items-center gap-1 rounded-xl bg-primary-600 px-5 py-2.5 text-sm font-bold text-white shadow-lg shadow-primary-600/20 transition-colors hover:bg-primary-700"
> >
Lanjut Lanjut
<ArrowRight size={15} strokeWidth={2} aria-hidden />
</button> </button>
)} )}
</div> </div>
@@ -443,7 +446,11 @@ function Stepper({
: "cursor-not-allowed" : "cursor-not-allowed"
}`} }`}
> >
{isCompleted ? "✓" : s.id} {isCompleted ? (
<Check size={14} strokeWidth={3} aria-hidden />
) : (
s.id
)}
</button> </button>
<span <span
className={`ml-2 hidden text-xs font-semibold sm:inline ${ className={`ml-2 hidden text-xs font-semibold sm:inline ${
@@ -685,6 +692,7 @@ function StepDetail({
whatsExcluded, whatsExcluded,
imageUrls, imageUrls,
onChange, onChange,
onError,
}: { }: {
meetingPoint: string; meetingPoint: string;
itineraryDays: ItineraryDays; itineraryDays: ItineraryDays;
@@ -692,6 +700,7 @@ function StepDetail({
whatsExcluded: string; whatsExcluded: string;
imageUrls: string[]; imageUrls: string[];
onChange: <K extends keyof FormState>(key: K, value: FormState[K]) => void; onChange: <K extends keyof FormState>(key: K, value: FormState[K]) => void;
onError: (msg: string) => void;
}) { }) {
return ( return (
<div className="space-y-5"> <div className="space-y-5">
@@ -764,9 +773,10 @@ function StepDetail({
</div> </div>
</div> </div>
<ImageUrlInput <TripImageUpload
value={imageUrls} value={imageUrls}
onChange={(urls) => onChange("imageUrls", urls)} onChange={(urls) => onChange("imageUrls", urls)}
onError={onError}
/> />
</div> </div>
); );
@@ -885,40 +895,35 @@ function ItineraryBuilder({
> >
<div className="flex flex-col gap-2 sm:flex-row sm:items-start"> <div className="flex flex-col gap-2 sm:flex-row sm:items-start">
<div className="flex shrink-0 gap-2"> <div className="flex shrink-0 gap-2">
<div> <div className="w-32">
<label className="mb-0.5 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"> <label
htmlFor={`itin-${dayIdx}-${itemIdx}-start`}
className="mb-0.5 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
>
Mulai Mulai
</label> </label>
<input <TimeField
type="time" id={`itin-${dayIdx}-${itemIdx}-start`}
value={item.startTime} value={item.startTime}
onChange={(e) => onChange={(v) =>
updateItem( updateItem(dayIdx, itemIdx, "startTime", v)
dayIdx,
itemIdx,
"startTime",
e.target.value
)
} }
className="w-30 rounded-lg border border-neutral-200 bg-white px-2 py-1.5 text-sm text-neutral-800 focus:border-primary-400"
/> />
</div> </div>
<div> <div className="w-32">
<label className="mb-0.5 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"> <label
htmlFor={`itin-${dayIdx}-${itemIdx}-end`}
className="mb-0.5 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
>
Selesai Selesai
</label> </label>
<input <TimeField
type="time" id={`itin-${dayIdx}-${itemIdx}-end`}
value={item.endTime} value={item.endTime}
onChange={(e) => onChange={(v) =>
updateItem( updateItem(dayIdx, itemIdx, "endTime", v)
dayIdx,
itemIdx,
"endTime",
e.target.value
)
} }
className="w-30 rounded-lg border border-neutral-200 bg-white px-2 py-1.5 text-sm text-neutral-800 focus:border-primary-400" clearable
/> />
</div> </div>
</div> </div>
@@ -948,7 +953,7 @@ function ItineraryBuilder({
aria-label="Hapus aktivitas" aria-label="Hapus aktivitas"
className="self-end rounded-lg px-2 py-1.5 text-neutral-400 hover:bg-red-50 hover:text-red-500 sm:self-center" className="self-end rounded-lg px-2 py-1.5 text-neutral-400 hover:bg-red-50 hover:text-red-500 sm:self-center"
> >
<X size={16} strokeWidth={2} aria-hidden />
</button> </button>
</div> </div>
</li> </li>
@@ -1021,36 +1026,12 @@ function StepSchedule({
<label className="mb-1.5 block text-sm font-semibold text-neutral-700"> <label className="mb-1.5 block text-sm font-semibold text-neutral-700">
Tanggal berangkat pulang Tanggal berangkat pulang
</label> </label>
<div className="relative"> <DateRangeField
<span className="absolute left-3 top-1/2 z-10 -translate-y-1/2 text-neutral-400"> startDate={startDate}
<svg endDate={endDate}
xmlns="http://www.w3.org/2000/svg" onChange={(s, e) => onDateChange(s, e)}
viewBox="0 0 20 20" minDate={new Date()}
fill="currentColor" />
className="h-4 w-4"
>
<path
fillRule="evenodd"
d="M5.75 2a.75.75 0 01.75.75V4h7V2.75a.75.75 0 011.5 0V4h.25A2.75 2.75 0 0118 6.75v8.5A2.75 2.75 0 0115.25 18H4.75A2.75 2.75 0 012 15.25v-8.5A2.75 2.75 0 014.75 4H5V2.75A.75.75 0 015.75 2zm-1 5.5c-.69 0-1.25.56-1.25 1.25v6.5c0 .69.56 1.25 1.25 1.25h10.5c.69 0 1.25-.56 1.25-1.25v-6.5c0-.69-.56-1.25-1.25-1.25H4.75z"
clipRule="evenodd"
/>
</svg>
</span>
<DatePicker
selectsRange
startDate={startDate}
endDate={endDate}
onChange={(dates) => {
const [s, e] = dates as [Date | null, Date | null];
onDateChange(s, e);
}}
minDate={new Date()}
placeholderText="Pilih tanggal..."
dateFormat="dd MMM yyyy"
isClearable
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 py-2.5 pl-9 pr-3 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-primary-500 focus:bg-white"
/>
</div>
</div> </div>
<div> <div>
@@ -1062,14 +1043,7 @@ function StepSchedule({
</label> </label>
<div className="relative"> <div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"> <span className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400">
<svg <Users size={16} strokeWidth={1.75} aria-hidden />
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4"
>
<path d="M7 8a3 3 0 100-6 3 3 0 000 6zM14.5 9a2.5 2.5 0 100-5 2.5 2.5 0 000 5zM1.615 16.428a1.224 1.224 0 01-.569-1.175 6.002 6.002 0 0111.908 0c.058.467-.172.92-.57 1.174A9.953 9.953 0 017 18a9.953 9.953 0 01-5.385-1.572zM14.5 16h-.106c.07-.297.088-.611.048-.933a7.47 7.47 0 00-1.588-3.755 4.502 4.502 0 015.874 2.636.818.818 0 01-.36.98A7.465 7.465 0 0114.5 16z" />
</svg>
</span> </span>
<input <input
id="maxParticipants" id="maxParticipants"
@@ -1114,8 +1088,9 @@ function StepSchedule({
/> />
</div> </div>
{blockedByVerification && ( {blockedByVerification && (
<p className="mt-2 text-xs font-medium text-amber-700"> <p className="mt-2 flex items-center gap-1.5 text-xs font-medium text-amber-700">
Trip berbayar butuh verifikasi organizer terlebih dahulu. <CircleAlert size={14} strokeWidth={2} aria-hidden />
Trip berbayar butuh verifikasi organizer terlebih dahulu.
</p> </p>
)} )}
</div> </div>
+165 -9
View File
@@ -1,7 +1,8 @@
"use client"; "use client";
import Image from "next/image"; import Image from "next/image";
import { useState } from "react"; import { useEffect, useState } from "react";
import { Mountain, Maximize2, X, ChevronLeft, ChevronRight } from "lucide-react";
interface TripImage { interface TripImage {
id: string; id: string;
@@ -11,11 +12,48 @@ interface TripImage {
export function ImageGallery({ images }: { images: TripImage[] }) { export function ImageGallery({ images }: { images: TripImage[] }) {
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const [lightboxOpen, setLightboxOpen] = useState(false);
const hasMultiple = images.length > 1;
function showPrev() {
setActiveIndex((i) => (i - 1 + images.length) % images.length);
}
function showNext() {
setActiveIndex((i) => (i + 1) % images.length);
}
// Saat lightbox terbuka: kunci scroll body + dukung keyboard (Esc tutup,
// panah kiri/kanan untuk ganti foto).
useEffect(() => {
if (!lightboxOpen) return;
function onKey(e: KeyboardEvent) {
// Logika prev/next di-inline (bukan panggil showPrev/showNext) supaya
// effect tidak bergantung pada fungsi yang dibuat ulang tiap render.
if (e.key === "Escape") setLightboxOpen(false);
else if (e.key === "ArrowLeft") {
setActiveIndex((i) => (i - 1 + images.length) % images.length);
} else if (e.key === "ArrowRight") {
setActiveIndex((i) => (i + 1) % images.length);
}
}
document.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [lightboxOpen, images.length]);
if (images.length === 0) { if (images.length === 0) {
return ( return (
<div className="flex h-44 items-center justify-center bg-linear-to-br from-primary-800 to-secondary-900 sm:h-56 lg:h-72"> <div className="flex h-44 items-center justify-center bg-neutral-100 sm:h-56 lg:h-72">
<span className="text-5xl sm:text-6xl">🏔</span> <Mountain
size={56}
strokeWidth={1.5}
aria-hidden
className="text-neutral-300"
/>
</div> </div>
); );
} }
@@ -24,18 +62,25 @@ export function ImageGallery({ images }: { images: TripImage[] }) {
return ( return (
<div> <div>
{/* Main Image */} {/* Main Image — klik untuk lihat ukuran penuh */}
<div className="relative h-44 bg-neutral-900 sm:h-56 lg:h-72"> <button
type="button"
onClick={() => setLightboxOpen(true)}
aria-label="Lihat foto ukuran penuh"
className="group relative block h-44 w-full cursor-zoom-in bg-neutral-900 sm:h-56 lg:h-72"
>
<Image <Image
src={activeImage.url} src={activeImage.url}
alt={activeImage.caption || "Foto trip"} alt={activeImage.caption || "Foto trip"}
fill fill
className="object-cover" // `object-contain` — tampilkan gambar utuh tanpa terpotong; rasio
// foto bebas, sisi yang tak terisi jadi bar gelap (bg-neutral-900).
className="object-contain"
sizes="(max-width: 768px) 100vw, 768px" sizes="(max-width: 768px) 100vw, 768px"
priority priority
/> />
{activeImage.caption && ( {activeImage.caption && (
<div className="absolute bottom-0 left-0 right-0 bg-linear-to-t from-black/60 to-transparent px-3 pb-2.5 pt-6 sm:px-4 sm:pb-3 sm:pt-8"> <div className="absolute bottom-0 left-0 right-0 bg-linear-to-t from-black/60 to-transparent px-3 pb-2.5 pt-6 text-left sm:px-4 sm:pb-3 sm:pt-8">
<p className="text-xs font-medium text-white sm:text-sm"> <p className="text-xs font-medium text-white sm:text-sm">
{activeImage.caption} {activeImage.caption}
</p> </p>
@@ -45,14 +90,20 @@ export function ImageGallery({ images }: { images: TripImage[] }) {
<div className="absolute right-2 top-2 rounded-full bg-black/50 px-2 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm sm:right-3 sm:top-3 sm:px-2.5 sm:py-1 sm:text-xs"> <div className="absolute right-2 top-2 rounded-full bg-black/50 px-2 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm sm:right-3 sm:top-3 sm:px-2.5 sm:py-1 sm:text-xs">
{activeIndex + 1} / {images.length} {activeIndex + 1} / {images.length}
</div> </div>
</div> {/* Petunjuk perbesar */}
<span className="absolute left-2 top-2 inline-flex items-center gap-1 rounded-full bg-black/50 px-2 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm transition-colors group-hover:bg-black/70 sm:left-3 sm:top-3 sm:px-2.5 sm:py-1 sm:text-xs">
<Maximize2 size={11} strokeWidth={2} aria-hidden />
Lihat penuh
</span>
</button>
{/* Thumbnails */} {/* Thumbnails */}
{images.length > 1 && ( {hasMultiple && (
<div className="flex gap-1 overflow-x-auto bg-neutral-100 p-1.5 sm:gap-1.5 sm:p-2"> <div className="flex gap-1 overflow-x-auto bg-neutral-100 p-1.5 sm:gap-1.5 sm:p-2">
{images.map((img, i) => ( {images.map((img, i) => (
<button <button
key={img.id} key={img.id}
type="button"
onClick={() => setActiveIndex(i)} onClick={() => setActiveIndex(i)}
className={`relative h-11 w-16 shrink-0 overflow-hidden rounded-md transition-all sm:h-14 sm:w-20 sm:rounded-lg ${ className={`relative h-11 w-16 shrink-0 overflow-hidden rounded-md transition-all sm:h-14 sm:w-20 sm:rounded-lg ${
i === activeIndex i === activeIndex
@@ -71,6 +122,111 @@ export function ImageGallery({ images }: { images: TripImage[] }) {
))} ))}
</div> </div>
)} )}
{/* Lightbox — penampil foto ukuran penuh */}
{lightboxOpen && (
<div
role="dialog"
aria-modal="true"
aria-label="Penampil foto trip"
className="fixed inset-0 z-50 flex flex-col bg-black/95"
>
{/* Top bar */}
<div className="flex items-center justify-between px-4 py-3 text-white">
<span className="text-sm font-medium tabular-nums">
{activeIndex + 1} / {images.length}
</span>
<button
type="button"
onClick={() => setLightboxOpen(false)}
aria-label="Tutup"
className="flex h-9 w-9 items-center justify-center rounded-full bg-white/10 transition-colors hover:bg-white/20"
>
<X size={20} strokeWidth={2} aria-hidden />
</button>
</div>
{/* Area gambar — klik latar untuk menutup */}
<div
className="relative flex-1"
onClick={() => setLightboxOpen(false)}
>
<div
className="relative h-full w-full"
onClick={(e) => e.stopPropagation()}
>
<Image
src={activeImage.url}
alt={activeImage.caption || "Foto trip"}
fill
className="object-contain"
sizes="100vw"
quality={90}
/>
</div>
{hasMultiple && (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
showPrev();
}}
aria-label="Foto sebelumnya"
className="absolute left-2 top-1/2 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/25 sm:left-4"
>
<ChevronLeft size={24} strokeWidth={2} aria-hidden />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
showNext();
}}
aria-label="Foto berikutnya"
className="absolute right-2 top-1/2 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/25 sm:right-4"
>
<ChevronRight size={24} strokeWidth={2} aria-hidden />
</button>
</>
)}
</div>
{activeImage.caption && (
<p className="px-4 py-3 text-center text-sm text-white/80">
{activeImage.caption}
</p>
)}
{/* Thumbnail strip di dalam lightbox */}
{hasMultiple && (
<div className="flex justify-center gap-1.5 overflow-x-auto px-4 pb-4">
{images.map((img, i) => (
<button
key={img.id}
type="button"
onClick={() => setActiveIndex(i)}
aria-label={`Lihat foto ${i + 1}`}
className={`relative h-12 w-16 shrink-0 overflow-hidden rounded-md transition-all ${
i === activeIndex
? "ring-2 ring-primary-400 ring-offset-1 ring-offset-black"
: "opacity-50 hover:opacity-100"
}`}
>
<Image
src={img.url}
alt=""
fill
className="object-cover"
sizes="64px"
/>
</button>
))}
</div>
)}
</div>
)}
</div> </div>
); );
} }
@@ -1,82 +0,0 @@
"use client";
import { LIMITS } from "@/lib/limits";
interface ImageUrlInputProps {
value: string[];
onChange: (urls: string[]) => void;
}
export function ImageUrlInput({ value, onChange }: ImageUrlInputProps) {
const urls = value.length > 0 ? value : [""];
const max = LIMITS.MAX_IMAGE_URLS;
function addField() {
if (urls.length < max) {
onChange([...urls, ""]);
}
}
function removeField(index: number) {
const next = urls.filter((_, i) => i !== index);
onChange(next.length > 0 ? next : [""]);
}
function updateField(index: number, next: string) {
const updated = [...urls];
updated[index] = next;
onChange(updated);
}
return (
<div>
<label className="mb-2 flex items-center justify-between">
<span className="text-sm font-semibold text-neutral-700">
Foto Trip (URL)
</span>
<span className="text-xs text-neutral-400">
{urls.length}/{max}
</span>
</label>
<div className="space-y-2">
{urls.map((url, i) => (
<div key={i} className="flex gap-2">
<input
type="url"
value={url}
onChange={(e) => updateField(i, e.target.value)}
className="flex-1 rounded-xl border border-neutral-200 bg-neutral-50 px-4 py-2.5 text-sm text-neutral-800 placeholder:text-neutral-400 focus:bg-white"
placeholder={
i === 0
? "URL foto utama (cover)"
: `URL foto ${i + 1} (opsional)`
}
/>
{urls.length > 1 && (
<button
type="button"
onClick={() => removeField(i)}
aria-label={`Hapus foto ${i + 1}`}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-neutral-200 text-neutral-400 hover:bg-red-50 hover:text-red-500"
>
</button>
)}
</div>
))}
</div>
{urls.length < max && (
<button
type="button"
onClick={addField}
className="mt-2 flex items-center gap-1 rounded-lg px-2 py-1 text-sm font-medium text-secondary-600 hover:bg-secondary-50"
>
+ Tambah foto
</button>
)}
<p className="mt-1.5 text-xs text-neutral-400">
Upload foto ke hosting (imgur, imgbb, dll) lalu paste URL-nya di sini.
</p>
</div>
);
}
@@ -143,7 +143,7 @@ export function JoinTripButton({
Kamu sudah{" "} Kamu sudah{" "}
<span className="font-semibold">terkonfirmasi</span> sebagai peserta <span className="font-semibold">terkonfirmasi</span> sebagai peserta
trip ini trip ini
{isFree && <span> trip gratis, tidak ada pembayaran 🎉</span>}. {isFree && <span> trip gratis, tidak ada pembayaran</span>}.
</div> </div>
)} )}
{needsPayment && ( {needsPayment && (
@@ -1,4 +1,5 @@
import Image from "next/image"; import Image from "next/image";
import { BadgeCheck, Star } from "lucide-react";
import type { OrganizerTrust } from "@/server/services/trust.service"; import type { OrganizerTrust } from "@/server/services/trust.service";
interface OrganizerTrustPanelProps { interface OrganizerTrustPanelProps {
@@ -13,7 +14,7 @@ export function OrganizerTrustPanel({
trust, trust,
}: OrganizerTrustPanelProps) { }: OrganizerTrustPanelProps) {
return ( return (
<div className="rounded-xl border border-neutral-200 bg-linear-to-br from-white to-neutral-50 p-4 sm:p-5"> <div className="rounded-xl border border-neutral-200 bg-white p-4 sm:p-5">
<h2 className="mb-3 text-xs font-bold text-neutral-700 sm:text-sm"> <h2 className="mb-3 text-xs font-bold text-neutral-700 sm:text-sm">
Organizer & kepercayaan Organizer & kepercayaan
</h2> </h2>
@@ -42,7 +43,8 @@ export function OrganizerTrustPanel({
className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-primary-800 sm:text-xs" className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-primary-800 sm:text-xs"
title="Identitas organizer telah diverifikasi (KTP & rekening)" title="Identitas organizer telah diverifikasi (KTP & rekening)"
> >
Verified Organizer <BadgeCheck size={12} strokeWidth={2} aria-hidden />
Verified Organizer
</span> </span>
)} )}
{trust.isTripLeader && ( {trust.isTripLeader && (
@@ -54,7 +56,7 @@ export function OrganizerTrustPanel({
</div> </div>
</div> </div>
<div className="flex flex-1 flex-wrap gap-3 border-t border-neutral-100 pt-3 sm:border-t-0 sm:border-l sm:pl-4 sm:pt-0"> <div className="flex flex-1 flex-wrap gap-3 border-t border-neutral-100 pt-3 sm:border-t-0 sm:border-l sm:pl-4 sm:pt-0">
<div className="min-w-[100px] rounded-lg bg-white/80 px-3 py-2 shadow-sm ring-1 ring-neutral-100"> <div className="min-w-25 rounded-lg bg-white/80 px-3 py-2 shadow-sm ring-1 ring-neutral-100">
<p className="text-[10px] font-medium text-neutral-500 sm:text-xs"> <p className="text-[10px] font-medium text-neutral-500 sm:text-xs">
Trip selesai Trip selesai
</p> </p>
@@ -67,7 +69,7 @@ export function OrganizerTrustPanel({
</p> </p>
)} )}
</div> </div>
<div className="min-w-[100px] rounded-lg bg-white/80 px-3 py-2 shadow-sm ring-1 ring-neutral-100"> <div className="min-w-25 rounded-lg bg-white/80 px-3 py-2 shadow-sm ring-1 ring-neutral-100">
<p className="text-[10px] font-medium text-neutral-500 sm:text-xs"> <p className="text-[10px] font-medium text-neutral-500 sm:text-xs">
Peserta dilayani Peserta dilayani
</p> </p>
@@ -75,12 +77,24 @@ export function OrganizerTrustPanel({
{trust.totalParticipantsServed} {trust.totalParticipantsServed}
</p> </p>
</div> </div>
<div className="min-w-[100px] rounded-lg bg-white/80 px-3 py-2 shadow-sm ring-1 ring-neutral-100"> <div className="min-w-25 rounded-lg bg-white/80 px-3 py-2 shadow-sm ring-1 ring-neutral-100">
<p className="text-[10px] font-medium text-neutral-500 sm:text-xs"> <p className="text-[10px] font-medium text-neutral-500 sm:text-xs">
Rating organizer Rating organizer
</p> </p>
<p className="text-lg font-bold text-amber-700"> <p className="flex items-center gap-1 text-lg font-bold text-amber-700">
{trust.avgRating != null ? `${trust.avgRating}` : "—"} {trust.avgRating != null ? (
<>
{trust.avgRating}
<Star
size={15}
strokeWidth={2}
fill="currentColor"
aria-hidden
/>
</>
) : (
"—"
)}
</p> </p>
{trust.reviewCount > 0 && ( {trust.reviewCount > 0 && (
<p className="text-[10px] text-neutral-400"> <p className="text-[10px] text-neutral-400">
+33 -8
View File
@@ -1,5 +1,12 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import {
MapPin,
CalendarDays,
UserRound,
BadgeCheck,
Sparkles,
} from "lucide-react";
import { formatRupiah } from "@/lib/utils"; import { formatRupiah } from "@/lib/utils";
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates"; import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
import { categoryMeta } from "@/lib/activity-category"; import { categoryMeta } from "@/lib/activity-category";
@@ -132,21 +139,38 @@ export function TripCard({
<div className="mt-3 space-y-1 text-sm text-neutral-600"> <div className="mt-3 space-y-1 text-sm text-neutral-600">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="text-xs text-secondary-500">📍</span> {location} <MapPin
size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0 text-neutral-400"
/>
<span className="truncate">{location}</span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="text-xs text-secondary-500">📅</span>{" "} <CalendarDays
{formatTripCalendarDateRangeLong(date, endDate)} size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0 text-neutral-400"
/>
<span>{formatTripCalendarDateRangeLong(date, endDate)}</span>
</div> </div>
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs text-secondary-500">👤</span>{" "} <UserRound
size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0 text-neutral-400"
/>
<span className="truncate">{organizerName}</span> <span className="truncate">{organizerName}</span>
{isVerifiedOrganizer && ( {isVerifiedOrganizer && (
<span <span
className="inline-flex items-center gap-0.5 rounded-full bg-primary-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-primary-800" className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-primary-800"
title="Identitas organizer telah diverifikasi (KTP & rekening)" title="Identitas organizer telah diverifikasi (KTP & rekening)"
> >
Verified <BadgeCheck size={11} strokeWidth={2} aria-hidden />
Verified
</span> </span>
)} )}
{isSmallGroup && ( {isSmallGroup && (
@@ -193,10 +217,11 @@ export function TripCard({
)} )}
{overlapCount > 0 && ( {overlapCount > 0 && (
<span <span
className="rounded-full bg-secondary-50 px-2 py-0.5 text-[11px] font-semibold text-secondary-700" className="inline-flex items-center gap-1 rounded-full bg-secondary-50 px-2 py-0.5 text-[11px] font-semibold text-secondary-700"
title="Peserta dengan minimal 1 minat sama dengan kamu" title="Peserta dengan minimal 1 minat sama dengan kamu"
> >
{overlapCount} peserta sama minat <Sparkles size={11} strokeWidth={2} aria-hidden />
{overlapCount} peserta sama minat
</span> </span>
)} )}
</div> </div>

Some files were not shown because too many files have changed in this diff Show More