Compare commits

...

38 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
arifal c52b12daad 0.12.1 2026-05-18 19:26:28 +07:00
arifal 4bcb93e283 admin roadmap trips ops and payment ops 2026-05-18 19:25:32 +07:00
arifal e1966b69f1 0.12.0 2026-05-18 18:32:39 +07:00
arifal c4efe4453b -
- 
- 
- 
2026-05-18 18:31:16 +07:00
arifal b599d01eea 0.11.0 2026-05-12 00:05:42 +07:00
arifal 958514d575 create public layout and admin and fix escrow and refund 2026-05-12 00:05:30 +07:00
170 changed files with 16703 additions and 3687 deletions
+2 -1
View File
@@ -10,7 +10,8 @@
"PowerShell(npx prisma generate 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 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=
# Absolute path for private KYC uploads (default: <cwd>/uploads/private)
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_SECRET="xxxxxxxx"
@@ -36,3 +39,24 @@ NEXT_PUBLIC_MIDTRANS_IS_PRODUCTION=false
# openssl rand -hex 32
# Setup detail: lihat docs/CRON_SETUP.md
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>"
+6 -2
View File
@@ -31,9 +31,13 @@ yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
.env
.env.production
.env.development
.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/
# vercel
+104
View File
@@ -0,0 +1,104 @@
# Setrip — Admin Roadmap (Index) · ✅ ALL DELIVERED
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`.
> **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
| Area | Fungsi | File |
|---|---|---|
| **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/) |
| **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) |
| **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/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; 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).
---
## Roadmap per area — status final
| Roadmap | Prioritas | Status | Archive |
|---|---|---|---|
| Trip Operations | 🔴 HIGH | ✅ Delivered | [ADMIN_TRIP_OPS_ROADMAP.md](docs/archive/ADMIN_TRIP_OPS_ROADMAP.md) |
| Payment Operations | 🔴 HIGH | ✅ Delivered | [ADMIN_PAYMENT_OPS_ROADMAP.md](docs/archive/ADMIN_PAYMENT_OPS_ROADMAP.md) |
| Audit & Investigation | 🔴 HIGH | ✅ Delivered | [ADMIN_AUDIT_ROADMAP.md](docs/archive/ADMIN_AUDIT_ROADMAP.md) |
| User Management | 🟡 MEDIUM | ✅ Delivered | [ADMIN_USER_MGMT_ROADMAP.md](docs/archive/ADMIN_USER_MGMT_ROADMAP.md) |
| Verification | 🟡 MEDIUM | ✅ Delivered | [ADMIN_VERIFICATION_ROADMAP.md](docs/archive/ADMIN_VERIFICATION_ROADMAP.md) |
| System Health | 🟡 MEDIUM | ✅ Delivered | [ADMIN_SYSTEM_HEALTH_ROADMAP.md](docs/archive/ADMIN_SYSTEM_HEALTH_ROADMAP.md) |
**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).
---
## Tindakan manual setelah deploy versi final
```bash
# 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
# (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
```
Brief admin tentang kapabilitas baru (lihat archive masing-masing untuk detail SOP):
- **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}`.
+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.
+26 -1
View File
@@ -94,7 +94,7 @@ Alur ini menggambarkan satu peserta dari pertama kali mendaftar sampai pembayara
### 5. Ringkasan peran data
| Konsep | Penyimpanan |
|--------|-------------|
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 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 |
| 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. |
@@ -128,3 +128,28 @@ Buka [http://localhost:3000](http://localhost:3000).
- [Next.js Documentation](https://nextjs.org/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.
**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)
@@ -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.
@@ -48,14 +50,14 @@ Foundation. Bikin entity `Refund` + service stub yang cuma create record `PENDIN
| # | Item | Status | File |
|---|---|---|---|
| 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.3 | Migration `add_refund_model` | | `prisma/migrations/` |
| 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.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.8 | UI admin `/admin/refunds` — list PENDING + tombol approve & mark-succeeded | | `app/admin/refunds/page.tsx` |
| 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.3 | Migration `add_refund_model` | | `prisma/migrations/` |
| 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.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.8 | UI admin `/admin/refunds` — list PENDING + tombol approve & mark-succeeded | | `app/admin/refunds/page.tsx` |
**Tindakan manual:**
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.
@@ -75,16 +77,16 @@ Saat organizer batalkan trip (`status = CLOSED`), semua peserta dengan `Booking.
| # | 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.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.4 | Server action `cancelTripAction` | | `features/trip/actions.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.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` |
**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).
@@ -100,11 +102,11 @@ User bisa cancel booking sendiri, refund dihitung berdasarkan kebijakan default
| # | Item | Status | File |
|---|---|---|---|
| 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.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.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.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.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.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:**
1. Tulis copy kebijakan refund untuk halaman Terms & Privacy.
+16 -7
View File
@@ -59,19 +59,28 @@ Dengan menggunakan SeTrip, Anda menyatakan bahwa:
---
# 6. Pembayaran
# 6. Pembayaran & Escrow
- Pembayaran dilakukan sesuai metode yang tersedia di platform
- Dalam fase awal, pembayaran dapat dilakukan langsung kepada organizer
- SeTrip tidak menjamin keamanan transaksi yang dilakukan di luar platform
- Pembayaran dilakukan melalui metode yang tersedia di platform (Midtrans atau transfer manual yang dikonfirmasi organizer)
- **Uang peserta ditahan oleh SeTrip (escrow)** sejak pembayaran berhasil hingga trip selesai + 3 hari, baru kemudian diteruskan ke organizer
- Buffer 3 hari memberi waktu peserta dan organizer melaporkan masalah trip sebelum uang cair
- Pembayaran di luar platform tidak dijamin keamanannya oleh SeTrip — kami tidak dapat memediasi sengketa untuk transaksi off-platform
---
# 7. Pembatalan & Refund
- Kebijakan pembatalan ditentukan oleh organizer
- SeTrip tidak bertanggung jawab atas refund yang tidak diberikan oleh organizer
- Pengguna disarankan untuk memahami kebijakan sebelum melakukan pembayaran
**Saat peserta membatalkan booking sendiri** (kebijakan default platform):
- **≥ 7 hari** sebelum tanggal berangkat → refund **80%** dari nominal booking
- **36 hari** sebelum tanggal berangkat → refund **50%** dari nominal booking
- **< 3 hari** sebelum tanggal berangkat / setelah berangkat → **tidak ada refund**
**Saat organizer membatalkan trip:** peserta yang sudah bayar mendapat refund **100%**.
**Pengembalian dana** diproses manual oleh admin SeTrip — perlu 13 hari kerja sejak refund disetujui untuk uang masuk ke rekening kamu. Setiap pengajuan refund tercatat (tidak pernah dihapus) untuk audit trail.
Kebijakan di atas berlaku platform-wide; organizer tidak dapat menetapkan policy yang lebih ketat tanpa persetujuan tertulis dari SeTrip.
---
+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)*
@@ -1,4 +1,5 @@
import Link from "next/link";
import { Lock, Clock, CircleAlert } from "lucide-react";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { organizerService } from "@/server/services/organizer.service";
@@ -11,8 +12,13 @@ export default async function CreateTripPage() {
return (
<div className="flex min-h-[calc(100vh-3.5rem)] items-center justify-center px-4">
<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>
<p className="mb-4 text-neutral-500">
Kamu harus login untuk membuat trip.
@@ -57,8 +63,9 @@ function VerificationBanner({
if (status === "PENDING") {
return (
<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">
Verifikasi sedang diproses
<p className="flex items-center gap-1.5 text-sm font-bold text-amber-800">
<Clock size={15} strokeWidth={2} aria-hidden />
Verifikasi sedang diproses
</p>
<p className="mt-1 text-sm text-neutral-700">
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="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex-1">
<p className="text-sm font-bold text-amber-800">
{isRejected ? "Verifikasi ditolak" : "Belum terverifikasi"}
<p className="flex items-center gap-1.5 text-sm font-bold text-amber-800">
<CircleAlert size={15} strokeWidth={2} aria-hidden />
{isRejected ? "Verifikasi ditolak" : "Belum terverifikasi"}
</p>
<p className="mt-1 text-sm text-neutral-700">
{isRejected
+25
View File
@@ -0,0 +1,25 @@
import { Navbar } from "@/components/shared/navbar";
import { ProfileNudgeBanner } from "@/components/shared/profile-nudge-banner";
import { Footer } from "@/components/shared/footer";
/**
* Layout user-facing (semua halaman publik + dashboard organizer/peserta).
* Berisi navbar global, profile-nudge banner, dan footer.
*
* Tidak berlaku untuk halaman admin — admin punya layout sendiri di
* app/admin/layout.tsx dengan sidebar khusus.
*/
export default function PublicLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<Navbar />
<ProfileNudgeBanner />
<main className="flex-1">{children}</main>
<Footer />
</>
);
}
+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>
);
}
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export const metadata: Metadata = {
title: "Masuk",
description:
"Masuk ke akun SeTrip untuk gabung open trip & aktivitas bareng dan kelola perjalananmu.",
alternates: { canonical: "/login" },
robots: { index: false, follow: true },
};
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;
}
@@ -1,7 +1,7 @@
"use client";
import { useState, Suspense } from "react";
import { signIn } from "next-auth/react";
import { signIn, getSession } from "next-auth/react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
@@ -38,7 +38,16 @@ function LoginForm() {
if (result?.error) {
setError(result.error);
} else {
const next = safeInternalPath(searchParams.get("callbackUrl"));
const callbackPath = safeInternalPath(searchParams.get("callbackUrl"));
const session = await getSession();
// Admin selalu diarahkan ke dashboard /admin setelah login — kecuali
// callbackUrl memang menuju sub-halaman admin (deep link dari /admin/...).
// callbackUrl non-admin (mis. "/" sisa dari percobaan login Google) tidak
// boleh membuat admin "nyangkut" di halaman publik.
const next =
session?.user?.isAdmin && !callbackPath.startsWith("/admin")
? "/admin"
: callbackPath;
router.push(next);
router.refresh();
}
@@ -78,7 +87,7 @@ function LoginForm() {
</div>
{/* 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 && (
<div className="mb-4 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600">
{error}
+95 -64
View File
@@ -8,6 +8,15 @@ import { profileRepo } from "@/server/repositories/profile.repo";
import { TripCard } from "@/features/trip/components/trip-card";
import { siteConfig, siteUrl, absoluteUrl } from "@/lib/site";
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];
@@ -44,6 +53,9 @@ export default async function HomePage() {
const now = new Date();
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
.filter((t) => new Date(t.date) <= nextWeek)
.slice(0, 3);
@@ -107,12 +119,17 @@ export default async function HomePage() {
className="object-cover opacity-10 brightness-150"
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">
{/* 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">
<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">
Cari teman trip & aktivitas
</span>
@@ -150,8 +167,12 @@ export default async function HomePage() {
</div>
<div className="h-8 w-px bg-neutral-700 sm:h-10" />
<div>
<p className="text-xl font-bold text-white sm:text-2xl">100%</p>
<p className="text-[11px] text-neutral-400 sm:text-xs">Seru</p>
<p className="text-xl font-bold text-white sm:text-2xl">
{joinerCount}
</p>
<p className="text-[11px] text-neutral-400 sm:text-xs">
Sudah Gabung
</p>
</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">
{/* Jelajah per kategori */}
<section>
<div className="mb-4 flex items-center gap-3 sm:mb-5">
<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">
</div>
<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>
<SectionHeading
icon={Compass}
title="Jelajah per Kategori"
subtitle="Hiking, diving, konser, sampai retreat"
/>
<div className="flex flex-wrap gap-2">
{ACTIVITY_CATEGORIES.map((c) => {
const m = categoryMeta(c);
@@ -194,19 +207,11 @@ export default async function HomePage() {
{/* Trip Terdekat */}
{upcomingTrips.length > 0 && (
<section>
<div className="mb-4 flex items-center gap-3 sm:mb-5">
<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">
🔥
</div>
<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>
<SectionHeading
icon={Flame}
title="Trip Terdekat"
subtitle="Berangkat dalam 7 hari ke depan"
/>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{upcomingTrips.slice(0, 3).map((trip, i) => (
<TripCard
@@ -239,32 +244,29 @@ export default async function HomePage() {
{/* Open Trip */}
<section>
<div className="mb-4 flex items-center justify-between sm:mb-5">
<div className="flex items-center gap-3">
<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">
🏔
</div>
<div>
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
Open Trip
</h2>
<p className="hidden text-xs text-neutral-500 sm:block">
Pilih trip, ketemu teman baru
</p>
</div>
</div>
<SectionHeading
icon={Mountain}
title="Open Trip"
subtitle="Pilih trip, ketemu teman baru"
action={
<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"
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"
>
Lihat semua
</Link>
</div>
}
/>
{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="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>
<p className="mb-1 text-base font-bold text-neutral-800 sm:text-lg">
Belum ada trip tersedia
@@ -312,19 +314,11 @@ export default async function HomePage() {
{/* Lagi Ramai — social proof, bukan price proof */}
{buzzingTrips.length > 0 && (
<section>
<div className="mb-4 flex items-center gap-3 sm:mb-5">
<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">
🤝
</div>
<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>
<SectionHeading
icon={Handshake}
title="Lagi Ramai"
subtitle="Banyak yang sudah gabung — kamu nggak bakal jalan sendirian"
/>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{buzzingTrips.map((trip) => (
<TripCard
@@ -383,11 +377,48 @@ export default async function HomePage() {
{/* ========== FAB ========== */}
<Link
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"
title="Buat Trip"
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"
aria-label="Buat Trip"
>
+
<Plus size={24} strokeWidth={2} aria-hidden />
</Link>
</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>
);
}
@@ -5,6 +5,7 @@ import { UserCard } from "@/features/profile/components/user-card";
import { PeopleFilter } from "@/features/profile/components/people-filter";
import { isVibe, vibeLabel } from "@/lib/vibe";
import { siteConfig } from "@/lib/site";
import { Users } from "lucide-react";
interface PeoplePageProps {
searchParams: Promise<{
@@ -68,8 +69,13 @@ export default async function PeoplePage({ searchParams }: PeoplePageProps) {
{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="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>
<p className="mb-1 text-base font-bold text-neutral-800 sm:text-lg">
{hasFilters
@@ -1,12 +1,19 @@
import Link from "next/link";
import { ShieldCheck, CircleCheck } from "lucide-react";
export default function PrivacyPage() {
return (
<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">
<header className="mb-8 border-b border-neutral-200 pb-6">
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
🔒 Kebijakan Privasi SeTrip
<h1 className="flex items-center gap-2 text-2xl font-bold text-neutral-900 sm:text-3xl">
<ShieldCheck
size={28}
strokeWidth={1.75}
aria-hidden
className="shrink-0 text-primary-600"
/>
Kebijakan Privasi SeTrip
</h1>
<p className="mt-2 text-sm text-neutral-500">
Terakhir diperbarui: 2026-04-27
@@ -205,7 +212,15 @@ export default function PrivacyPage() {
</section>
<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">
Dengan menggunakan SeTrip, Anda menyatakan bahwa:
</p>
@@ -5,9 +5,12 @@ import Link from "next/link";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { profileService } from "@/server/services/profile.service";
import { payoutRepo } from "@/server/repositories/payout.repo";
import { TripCard } from "@/features/trip/components/trip-card";
import { ProfileTripRow } from "@/features/profile/components/profile-trip-row";
import { ProfileEditor } from "@/features/profile/components/profile-editor";
import { EarningsSection } from "@/features/payout/components/earnings-section";
import { Plus, ChevronRight } from "lucide-react";
export const metadata: Metadata = {
title: "Profil Saya",
@@ -20,9 +23,10 @@ export default async function ProfilePage() {
redirect("/login?callbackUrl=/profile");
}
const [data, ownProfile] = await Promise.all([
const [data, ownProfile, payouts] = await Promise.all([
profileService.getProfileDashboard(session.user.id),
profileService.getOwnProfile(session.user.id),
payoutRepo.listForOrganizer(session.user.id),
]);
const {
user,
@@ -78,12 +82,16 @@ export default async function ProfilePage() {
</div>
<Link
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>
</div>
{/* Pendapatan dari peserta (escrow payout) */}
<EarningsSection payouts={payouts} />
{/* Profil sosial publik */}
<div className="mb-6">
<ProfileEditor
@@ -127,13 +135,14 @@ export default async function ProfilePage() {
endDate={t.endDate}
rightSlot={
<span
className={
className={`inline-flex items-center gap-0.5 ${
hasReview
? "text-secondary-700"
: "font-bold text-amber-800"
}
}`}
>
{hasReview ? "Ubah ulasan" : "Beri ulasan"}
{hasReview ? "Ubah ulasan" : "Beri ulasan"}
<ChevronRight size={14} strokeWidth={2} aria-hidden />
</span>
}
/>
+24
View File
@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export const metadata: Metadata = {
title: "Daftar Akun",
description:
"Buat akun SeTrip gratis. Cari open trip & aktivitas bareng, gabung bareng, dan mulai petualanganmu.",
alternates: { canonical: "/register" },
};
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;
}
@@ -77,7 +77,7 @@ export default function RegisterPage() {
</div>
{/* 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 && (
<div className="mb-4 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600">
{error}
@@ -1,12 +1,19 @@
import Link from "next/link";
import { FileText, CircleCheck } from "lucide-react";
export default function TermsPage() {
return (
<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">
<header className="mb-8 border-b border-neutral-200 pb-6">
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
📜 Syarat &amp; Ketentuan SeTrip
<h1 className="flex items-center gap-2 text-2xl font-bold text-neutral-900 sm:text-3xl">
<FileText
size={28}
strokeWidth={1.75}
aria-hidden
className="shrink-0 text-primary-600"
/>
Syarat &amp; Ketentuan SeTrip
</h1>
<p className="mt-2 text-sm text-neutral-500">
Terakhir diperbarui: 2026-04-27
@@ -262,7 +269,15 @@ export default function TermsPage() {
</section>
<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">
Dengan menggunakan SeTrip, Anda menyatakan bahwa:
</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>
);
}
@@ -2,7 +2,7 @@ import { ImageResponse } from "next/og";
import { tripService } from "@/server/services/trip.service";
import { formatRupiah } from "@/lib/utils";
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 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 price = formatRupiah(trip.price);
@@ -17,7 +17,6 @@ import { CancelBookingButton } from "@/features/booking/components/cancel-bookin
import { OrganizerJoinRequests } from "@/features/trip/components/organizer-join-requests";
import { OrganizerTrustPanel } from "@/features/trip/components/organizer-trust-panel";
import { TripProgramBlock } from "@/features/trip/components/trip-program-block";
import { OrganizerPaymentQueue } from "@/features/booking/components/organizer-payment-queue";
import { ImageGallery } from "@/features/trip/components/image-gallery";
import { TripReviewSection } from "@/features/review/components/trip-review-section";
import { RefundPolicySection } from "@/features/refund/components/refund-policy-section";
@@ -29,6 +28,14 @@ import {
isTripDepartureDayPast,
} from "@/lib/trip-dates";
import { previewRefund } from "@/lib/refund-policy";
import {
MapPin,
CalendarDays,
Wallet,
UserRound,
Zap,
Users,
} from "lucide-react";
export async function generateMetadata({
params,
@@ -135,13 +142,6 @@ export default async function TripDetailPage({
const tripIsFree = isFreeTrip(trip);
// Antrian konfirmasi pembayaran: source dari Booking + Payment (B9).
// Hanya organizer yang butuh data ini, dan hanya untuk trip berbayar.
const paymentPendingBookings =
!tripIsFree && isOrganizer
? await bookingService.getAwaitingManualForTrip(trip.id)
: [];
// Booking peserta saat ini — dipakai untuk render CancelBookingButton vs
// tombol "Batal Ikut" biasa. Hanya untuk non-organizer yang ikut trip.
const myBooking =
@@ -317,8 +317,13 @@ export default async function TripDetailPage({
{/* Info Grid */}
<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">
<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>
<div className="min-w-0">
<p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Lokasi</p>
@@ -327,8 +332,13 @@ export default async function TripDetailPage({
</div>
<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>
<div className="min-w-0">
<p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Tanggal</p>
@@ -339,8 +349,13 @@ export default async function TripDetailPage({
</div>
<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>
<div className="min-w-0">
<p className="text-[10px] font-medium text-primary-600 sm:text-xs">Harga</p>
@@ -351,8 +366,13 @@ export default async function TripDetailPage({
</div>
<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>
<div className="min-w-0">
<p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Organizer</p>
@@ -380,8 +400,9 @@ export default async function TripDetailPage({
Peserta
</span>
{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]">
Tinggal {spotsLeft} spot!
<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]">
<Zap size={11} strokeWidth={2} aria-hidden />
Tinggal {spotsLeft} spot!
</span>
)}
{spotsLeft <= 0 && (
@@ -426,8 +447,14 @@ export default async function TripDetailPage({
)}
</p>
{confirmedCount > 0 && (
<p className="mt-2 text-[11px] text-neutral-600 sm:text-xs">
<span aria-hidden>👥</span> Sudah join:{" "}
<p className="mt-2 flex flex-wrap items-center gap-x-1 gap-y-0.5 text-[11px] text-neutral-600 sm:text-xs">
<Users
size={13}
strokeWidth={1.75}
aria-hidden
className="text-neutral-400"
/>
Sudah join:{" "}
<span className="font-medium text-neutral-800">
{confirmedParticipants
.slice(0, 3)
@@ -447,6 +474,13 @@ export default async function TripDetailPage({
<TripProgramBlock
meetingPoint={trip.meetingPoint}
itinerary={trip.itinerary}
itineraryItems={trip.itineraryItems.map((i) => ({
day: i.day,
startTime: i.startTime,
endTime: i.endTime,
activity: i.activity,
order: i.order,
}))}
whatsIncluded={trip.whatsIncluded}
whatsExcluded={trip.whatsExcluded}
/>
@@ -469,18 +503,6 @@ export default async function TripDetailPage({
pending={pendingParticipants.map((p) => ({
id: p.id,
user: p.user,
markedPaidAt: p.markedPaidAt,
}))}
/>
)}
{isOrganizer && paymentPendingBookings.length > 0 && (
<OrganizerPaymentQueue
tripId={trip.id}
items={paymentPendingBookings.map((b) => ({
id: b.participantId,
user: { name: b.user.name, image: b.user.image },
joinStatus: "CONFIRMED" as const,
}))}
/>
)}
@@ -498,15 +520,7 @@ export default async function TripDetailPage({
? currentParticipation.status
: null
}
participantPayment={
currentParticipation
? {
markedPaidAt: currentParticipation.markedPaidAt,
paymentConfirmedAt:
currentParticipation.paymentConfirmedAt,
}
: null
}
bookingStatus={myBooking?.status ?? null}
isFull={spotsLeft <= 0}
tripStatus={trip.status}
isDeparturePast={isDeparturePast}
@@ -568,7 +582,7 @@ export default async function TripDetailPage({
Belum ada peserta yang dikonfirmasi.{" "}
{pendingParticipants.length > 0
? "Cek permintaan join di atas untuk menyetujui peserta."
: "Jadilah yang pertama mendaftar! 🎒"}
: "Jadilah yang pertama mendaftar!"}
</p>
) : (
<ul className="grid gap-2 sm:grid-cols-2">
@@ -599,8 +613,14 @@ export default async function TripDetailPage({
{p.user.name}
</p>
{city && (
<p className="truncate text-[11px] text-neutral-500 sm:text-xs">
📍 {city}
<p className="flex items-center gap-1 truncate text-[11px] text-neutral-500 sm:text-xs">
<MapPin
size={11}
strokeWidth={1.75}
aria-hidden
className="shrink-0"
/>
{city}
</p>
)}
{interests.length > 0 && (
@@ -4,15 +4,22 @@ import { notFound, redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { tripService } from "@/server/services/trip.service";
import { organizerService } from "@/server/services/organizer.service";
import { bookingService } from "@/server/services/booking.service";
import { paymentService } from "@/server/services/payment.service";
import { formatRupiah } from "@/lib/utils";
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
import { isFreeTrip } from "@/lib/trip-pricing";
import { categoryMeta } from "@/lib/activity-category";
import { MarkPaidButton } from "@/features/booking/components/mark-paid-button";
import { MidtransPayButton } from "@/features/booking/components/midtrans-pay-button";
import { CopyButton } from "@/features/booking/components/copy-button";
import {
ArrowLeft,
CalendarDays,
MapPin,
PartyPopper,
CircleCheck,
Clock,
Check,
} from "lucide-react";
export const metadata: Metadata = {
title: "Detail Pembayaran",
@@ -21,9 +28,10 @@ export const metadata: Metadata = {
interface PageProps {
params: Promise<{ id: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}
export default async function PaymentPage({ params }: PageProps) {
export default async function PaymentPage({ params, searchParams }: PageProps) {
const { id } = await params;
const session = await getServerSession(authOptions);
if (!session?.user) {
@@ -37,11 +45,26 @@ export default async function PaymentPage({ params }: PageProps) {
notFound();
}
// Organizer trip-nya sendiri tidak butuh halaman pembayaran.
if (trip.organizerId === session.user.id) {
redirect(`/trips/${id}`);
}
// Saat user kembali dari Snap, Midtrans append `order_id` (+ status_code +
// transaction_status) ke finishUrl. Tarik status terkini dari Core API
// sebelum render supaya UI sinkron tanpa menunggu webhook — penting di dev
// (localhost) dan saat webhook tertunda.
const sp = await searchParams;
const orderIdParam = sp.order_id;
const orderId = Array.isArray(orderIdParam) ? orderIdParam[0] : orderIdParam;
if (orderId) {
try {
await paymentService.reconcileFromGateway(orderId, session.user.id);
} catch {
// Jangan blokir render kalau gateway tidak responsif — webhook tetap
// sumber kebenaran jangka panjang. Status di UI akan apa adanya dari DB.
}
}
const booking = await bookingService.getByTripAndUser(
trip.id,
session.user.id
@@ -51,15 +74,10 @@ export default async function PaymentPage({ params }: PageProps) {
return <NotJoinedNotice tripId={trip.id} title={trip.title} />;
}
const latestManualPayment = booking.payments.find(
(p) => p.provider === "MANUAL"
);
const tripIsFree = isFreeTrip(trip);
const catMeta = categoryMeta(trip.category);
const dateRange = formatTripCalendarDateRangeLong(trip.date, trip.endDate);
// Header info — sama untuk free vs paid
const tripHeader = (
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
<div className="flex items-start gap-3">
@@ -73,8 +91,15 @@ export default async function PaymentPage({ params }: PageProps) {
<h1 className="mt-0.5 truncate text-base font-bold text-neutral-900 sm:text-lg">
{trip.title}
</h1>
<p className="mt-1 text-xs text-neutral-500 sm:text-sm">
📅 {dateRange} · 📍 {trip.location}
<p className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-neutral-500 sm:text-sm">
<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 className="mt-1 text-xs text-neutral-500 sm:text-sm">
Organizer:{" "}
@@ -93,8 +118,12 @@ export default async function PaymentPage({ params }: PageProps) {
return (
<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">
<Link href={`/trips/${trip.id}`} className="hover:text-primary-600">
Kembali ke trip
<Link
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>
</div>
@@ -104,29 +133,19 @@ export default async function PaymentPage({ params }: PageProps) {
<p className="mb-5 text-sm text-neutral-500">
{tripIsFree
? "Trip ini gratis — kamu tidak perlu transfer apa-apa."
: "Transfer manual ke rekening organizer di bawah, lalu tandai sebagai sudah bayar."}
: "Bayar lewat Midtrans untuk mengamankan slot kamu. Pembayaran akan ter-konfirmasi otomatis."}
</p>
{tripHeader}
{tripIsFree ? (
<FreeTripSection
tripId={trip.id}
bookingStatus={booking.status}
/>
<FreeTripSection tripId={trip.id} bookingStatus={booking.status} />
) : (
<PaidTripSection
tripId={trip.id}
organizerId={trip.organizerId}
organizerName={trip.organizer.name}
price={trip.price}
bookingStatus={booking.status}
paymentMarkedAt={
latestManualPayment?.status === "AWAITING"
? latestManualPayment.updatedAt
: null
}
paymentPaidAt={latestManualPayment?.paidAt ?? null}
/>
)}
</div>
@@ -153,12 +172,7 @@ function NotJoinedNotice({ tripId, title }: { tripId: string; title: string }) {
);
}
function FreeTripSection({
tripId,
bookingStatus,
}: {
tripId: string;
bookingStatus:
type BookingStatus =
| "PENDING"
| "AWAITING_PAY"
| "PAID"
@@ -166,11 +180,23 @@ function FreeTripSection({
| "REFUNDED"
| "PARTIALLY_REFUNDED"
| "EXPIRED";
function FreeTripSection({
tripId,
bookingStatus,
}: {
tripId: string;
bookingStatus: BookingStatus;
}) {
return (
<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>
<h2 className="mb-1 text-lg font-bold text-emerald-900 sm:text-xl">
Trip ini gratis
@@ -183,10 +209,28 @@ function FreeTripSection({
<p className="text-[11px] font-semibold uppercase tracking-wide text-emerald-700">
Status keikutsertaan
</p>
<p className="text-sm font-bold text-neutral-800">
{bookingStatus === "PAID"
? "✅ Terkonfirmasi sebagai peserta"
: "⏳ Menunggu persetujuan organizer"}
<p className="flex items-center gap-1.5 text-sm font-bold text-neutral-800">
{bookingStatus === "PAID" ? (
<>
<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>
</div>
@@ -202,155 +246,74 @@ function FreeTripSection({
);
}
async function PaidTripSection({
function PaidTripSection({
tripId,
organizerId,
organizerName,
price,
bookingStatus,
paymentMarkedAt,
paymentPaidAt,
}: {
tripId: string;
organizerId: string;
organizerName: string;
price: number;
bookingStatus:
| "PENDING"
| "AWAITING_PAY"
| "PAID"
| "CANCELLED"
| "REFUNDED"
| "PARTIALLY_REFUNDED"
| "EXPIRED";
paymentMarkedAt: Date | null;
paymentPaidAt: Date | null;
bookingStatus: BookingStatus;
}) {
const verification = await organizerService.getStatusForUser(organizerId);
const bankAvailable = verification?.status === "APPROVED";
const isApproved = bookingStatus === "AWAITING_PAY" || bookingStatus === "PAID";
const isApproved =
bookingStatus === "AWAITING_PAY" || bookingStatus === "PAID";
const isPendingApproval = bookingStatus === "PENDING";
const hasMarkedPaid = !!paymentMarkedAt || !!paymentPaidAt;
const isFullyPaid = bookingStatus === "PAID";
const canMarkPaid = bookingStatus === "AWAITING_PAY" && !paymentMarkedAt;
const canPay = bookingStatus === "AWAITING_PAY";
return (
<div className="space-y-5">
<PaymentTimeline
approved={isApproved}
markedPaid={hasMarkedPaid}
confirmedPaid={isFullyPaid}
/>
<PaymentTimeline approved={isApproved} confirmedPaid={isFullyPaid} />
{!bankAvailable && (
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<p className="font-semibold">Rekening organizer belum tersedia</p>
<p className="mt-1 text-amber-800/90">
Organizer trip ini belum melengkapi data verifikasi (bank). Hubungi
organizer langsung lewat profilnya untuk koordinasi pembayaran.
</p>
</div>
)}
{bankAvailable && (
<section className="rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
<h3 className="mb-1 text-sm font-bold text-neutral-900 sm:text-base">
Transfer ke rekening organizer
<div className="flex items-baseline justify-between gap-3">
<h3 className="text-sm font-bold text-neutral-900 sm:text-base">
Total Pembayaran
</h3>
<p className="mb-4 text-xs text-neutral-500 sm:text-sm">
Pastikan nominal persis seperti tercantum supaya organizer mudah
mencocokkan.
<p className="text-lg font-bold text-primary-700 sm:text-xl">
{formatRupiah(price)}
</p>
<div className="space-y-3 rounded-xl bg-neutral-50 p-4 sm:p-5">
<BankRow
label="Bank"
value={verification.bankName}
copyable
/>
<BankRow
label="Nomor rekening"
value={verification.bankAccountNumber}
copyable
mono
/>
<BankRow
label="Atas nama"
value={verification.bankAccountName}
/>
<div className="mt-2 border-t border-neutral-200 pt-3">
<BankRow
label="Nominal transfer"
value={formatRupiah(price)}
strong
copyable
copyValue={String(price)}
/>
</div>
</div>
<ul className="mt-4 space-y-1.5 text-[11px] text-neutral-500 sm:text-xs">
<li> Transfer dengan nominal pas, jangan dibulatkan.</li>
<li> Simpan bukti transfer untuk jaga-jaga jika ada konfirmasi.</li>
<li>
Setelah transfer, tekan tombol <em>Saya sudah bayar</em> di bawah
supaya organizer tahu dan bisa konfirmasi.
</li>
</ul>
<p className="mt-1 text-xs text-neutral-500">
Pembayaran diproses oleh Midtrans (BCA VA, GoPay, QRIS, kartu, dll).
Dana ditahan SeTrip sampai trip selesai bukan transfer langsung
ke organizer.
</p>
</section>
)}
{isPendingApproval && (
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
Kamu belum disetujui organizer untuk ikut trip ini. Tunggu persetujuan
dulu sebelum transfer supaya tidak perlu refund kalau ditolak.
dulu sebelum bayar supaya tidak perlu refund kalau ditolak.
</div>
)}
{canMarkPaid && (
<div className="space-y-3">
{bankAvailable && (
<>
<MarkPaidButton tripId={tripId} />
<div className="flex items-center gap-3">
<span className="h-px flex-1 bg-neutral-200" />
<span className="text-[11px] font-semibold uppercase tracking-wide text-neutral-400">
atau
</span>
<span className="h-px flex-1 bg-neutral-200" />
</div>
</>
)}
<MidtransPayButton tripId={tripId} />
</div>
)}
{canPay && <MidtransPayButton tripId={tripId} />}
{hasMarkedPaid && (
<div className="rounded-2xl border border-neutral-200 bg-white p-4 text-sm text-neutral-600 shadow-sm sm:p-5">
{isFullyPaid ? (
{isFullyPaid && (
<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>
Pembayaran kamu sudah dikonfirmasi oleh{" "}
<span className="font-semibold text-neutral-800">
{organizerName}
</span>
. Sampai jumpa di trip!
Pembayaran kamu sudah terkonfirmasi. Sampai jumpa di trip bareng{" "}
<span className="font-semibold">{organizerName}</span>!
</p>
) : (
<p>
Kamu sudah menandai sudah bayar. Menunggu organizer mengecek
dan mengonfirmasi.
</p>
)}
</div>
)}
<div className="text-center">
<Link
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>
</div>
</div>
@@ -359,17 +322,14 @@ async function PaidTripSection({
function PaymentTimeline({
approved,
markedPaid,
confirmedPaid,
}: {
approved: boolean;
markedPaid: boolean;
confirmedPaid: boolean;
}) {
const steps = [
{ label: "Disetujui organizer", done: approved },
{ label: "Kamu menandai sudah bayar", done: markedPaid },
{ label: "Organizer konfirmasi pembayaran", done: confirmedPaid },
{ label: "Pembayaran terkonfirmasi Midtrans", done: confirmedPaid },
];
return (
@@ -387,7 +347,11 @@ function PaymentTimeline({
: "bg-neutral-200 text-neutral-500"
}`}
>
{s.done ? "✓" : i + 1}
{s.done ? (
<Check size={12} strokeWidth={3} aria-hidden />
) : (
i + 1
)}
</span>
<span
className={`text-sm ${
@@ -404,37 +368,3 @@ function PaymentTimeline({
</section>
);
}
function BankRow({
label,
value,
mono,
strong,
copyable,
copyValue,
}: {
label: string;
value: string;
mono?: boolean;
strong?: boolean;
copyable?: boolean;
copyValue?: string;
}) {
return (
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-wide text-neutral-500">
{label}
</p>
<p
className={`mt-0.5 truncate text-sm text-neutral-800 ${
mono ? "font-mono" : ""
} ${strong ? "text-base font-bold text-primary-700" : ""}`}
>
{value}
</p>
</div>
{copyable && <CopyButton value={copyValue ?? value} />}
</div>
);
}
+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>
);
}
@@ -11,6 +11,7 @@ import { siteConfig } from "@/lib/site";
import { categoryLabel, isActivityCategory } from "@/lib/activity-category";
import { isVibe } from "@/lib/vibe";
import type { GroupSize } from "@/server/repositories/trip.repo";
import { Plus, Search, Tent } from "lucide-react";
const GROUP_SIZES: GroupSize[] = ["SMALL", "MEDIUM", "LARGE"];
function isGroupSize(value: unknown): value is GroupSize {
@@ -98,9 +99,10 @@ export default async function TripsPage({ searchParams }: TripsPageProps) {
</div>
<Link
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>
</div>
@@ -113,8 +115,22 @@ export default async function TripsPage({ searchParams }: TripsPageProps) {
{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="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">
{hasFilters ? "🔍" : "🏕️"}
<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 ? (
<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>
<p className="mb-1 text-base font-bold text-neutral-800 sm:text-lg">
{hasFilters
@@ -137,7 +153,7 @@ export default async function TripsPage({ searchParams }: TripsPageProps) {
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{trips.map((trip) => (
{trips.map((trip, index) => (
<TripCard
key={trip.id}
id={trip.id}
@@ -154,6 +170,9 @@ export default async function TripsPage({ searchParams }: TripsPageProps) {
organizerName={trip.organizer.name}
status={trip.status}
coverImage={trip.images[0]?.url}
// Baris pertama (3 kartu) di atas fold — muat segera supaya
// tidak jadi LCP yang lambat.
priority={index < 3}
isVerifiedOrganizer={
trip.organizer.organizerVerification?.status === "APPROVED"
}
@@ -11,6 +11,7 @@ import { OrganizerStatsPanel } from "@/features/profile/components/organizer-sta
import { OrganizerReviewsList } from "@/features/review/components/organizer-reviews-list";
import { siteConfig } from "@/lib/site";
import { vibeMeta } from "@/lib/vibe";
import { BadgeCheck, MapPin, AtSign } from "lucide-react";
interface PageProps {
params: Promise<{ id: string }>;
@@ -86,10 +87,11 @@ export default async function PublicProfilePage({ params }: PageProps) {
</h1>
{isVerifiedOrganizer && (
<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)"
>
Verified Organizer
<BadgeCheck size={12} strokeWidth={2} aria-hidden />
Verified Organizer
</span>
)}
</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">
{profile?.city && (
<span className="inline-flex items-center gap-1">
📍 {profile.city}
<MapPin size={13} strokeWidth={1.75} aria-hidden />
{profile.city}
</span>
)}
<span className="text-xs">Bergabung sejak {memberSince}</span>
@@ -141,8 +144,8 @@ export default async function PublicProfilePage({ params }: PageProps) {
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"
>
<span>📸</span>
<span>@{profile.instagram}</span>
<AtSign size={15} strokeWidth={1.75} aria-hidden />
<span>{profile.instagram}</span>
</a>
)}
</div>
@@ -1,11 +1,29 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { Clock, RefreshCw, CircleX, ArrowLeft } from "lucide-react";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { organizerService } from "@/server/services/organizer.service";
import { VerifyForm } from "@/features/organizer/components/verify-form";
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() {
const session = await getServerSession(authOptions);
if (!session?.user) {
@@ -53,10 +71,11 @@ export default async function VerifyPage() {
</div>
)}
{verification?.status === "PENDING" && (
{verification?.status === "PENDING" && !verification.reuploadRequested && (
<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">
Menunggu review admin
<p className="mb-1 flex items-center gap-1.5 text-sm font-bold text-amber-800">
<Clock size={15} strokeWidth={2} aria-hidden />
Menunggu review admin
</p>
<p className="text-sm text-neutral-700">
Pengajuanmu sedang diproses. Kami akan memberitahu via email setelah selesai.
@@ -64,9 +83,47 @@ export default async function VerifyPage() {
</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" && (
<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 && (
<p className="text-sm text-neutral-700">
<span className="font-semibold">Alasan:</span>{" "}
@@ -79,13 +136,17 @@ export default async function VerifyPage() {
</div>
)}
{verification?.status !== "APPROVED" && verification?.status !== "PENDING" && (
<VerifyForm initial={initial} />
)}
{(verification?.status !== "APPROVED" &&
(verification?.status !== "PENDING" ||
verification?.reuploadRequested)) && <VerifyForm initial={initial} />}
<p className="mt-6 text-center text-sm text-neutral-500">
<Link href="/profile" className="hover:text-primary-600">
Kembali ke profil
<Link
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>
</p>
</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>;
}
+494
View File
@@ -0,0 +1,494 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { ArrowLeft, CalendarDays, CircleAlert, MapPin } from "lucide-react";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { bookingRepo } from "@/server/repositories/booking.repo";
import { formatRupiah } from "@/lib/utils";
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
import { AdminReconcileButton } from "@/features/booking/components/admin-reconcile-button";
import { RawCallbackViewer } from "@/features/booking/components/raw-callback-viewer";
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function AdminBookingDetailPage({ params }: PageProps) {
const session = await getServerSession(authOptions);
if (!session?.user) {
redirect("/login?callbackUrl=/admin/bookings");
}
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 booking = await bookingRepo.findByIdForAdmin(id);
if (!booking) notFound();
// Build chronological timeline lintas Payment + Refund + Payout.
type TimelineEvent =
| {
kind: "payment";
at: Date;
payment: (typeof booking.payments)[number];
}
| {
kind: "refund";
at: Date;
refund: (typeof booking.refunds)[number];
}
| {
kind: "payout";
at: Date;
payout: NonNullable<typeof booking.payout>;
};
const timeline: TimelineEvent[] = [];
for (const p of booking.payments) {
timeline.push({ kind: "payment", at: p.createdAt, payment: p });
}
for (const r of booking.refunds) {
timeline.push({ kind: "refund", at: r.createdAt, refund: r });
}
if (booking.payout) {
timeline.push({
kind: "payout",
at: booking.payout.createdAt,
payout: booking.payout,
});
}
timeline.sort((a, b) => a.at.getTime() - b.at.getTime());
return (
<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">
<Link
href="/admin"
className="inline-flex items-center gap-1 hover:text-primary-600"
>
<ArrowLeft size={14} strokeWidth={2} aria-hidden />
Dashboard
</Link>
<Link
href={`/admin/trips/${booking.tripId}`}
className="hover:text-primary-600"
>
Trip terkait
</Link>
</div>
<header className="mb-6 rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
<p className="text-[11px] font-semibold uppercase tracking-wide text-neutral-500">
Booking
</p>
<h1 className="mt-0.5 text-xl font-bold text-neutral-900 sm:text-2xl">
{booking.trip.title}
</h1>
<p className="mt-1 flex flex-wrap items-center gap-1 text-sm text-neutral-500">
<CalendarDays
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>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<FieldRow label="Peserta" value={booking.user.name} sub={booking.user.email} />
<FieldRow
label="Organizer"
value={booking.trip.organizer.name}
sub={booking.trip.organizer.email}
/>
<FieldRow
label="Nominal booking"
value={formatRupiah(booking.amount)}
strong
/>
<FieldRow label="Status booking" value={booking.status} badge />
</div>
<div className="mt-4 border-t border-neutral-100 pt-3 text-[11px] text-neutral-500">
<p>
Booking ID:{" "}
<code className="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-neutral-700">
{booking.id}
</code>
</p>
</div>
</header>
<section className="rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-6">
<h2 className="mb-4 text-sm font-bold text-neutral-900 sm:text-base">
Timeline Money Flow ({timeline.length} event)
</h2>
{timeline.length === 0 ? (
<p className="text-xs text-neutral-500">
Belum ada event payment / refund / payout untuk booking ini.
</p>
) : (
<ol className="space-y-4">
{timeline.map((ev, idx) => (
<li key={idx} className="border-l-2 border-neutral-200 pl-4">
{ev.kind === "payment" && (
<PaymentEventCard payment={ev.payment} />
)}
{ev.kind === "refund" && <RefundEventCard refund={ev.refund} />}
{ev.kind === "payout" && <PayoutEventCard payout={ev.payout} />}
</li>
))}
</ol>
)}
</section>
</div>
);
}
function FieldRow({
label,
value,
sub,
strong,
badge,
}: {
label: string;
value: string;
sub?: string;
strong?: boolean;
badge?: boolean;
}) {
return (
<div>
<p className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
{label}
</p>
{badge ? (
<span className="mt-0.5 inline-block rounded-full bg-neutral-200 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-neutral-700">
{value}
</span>
) : (
<p
className={`mt-0.5 text-neutral-800 ${
strong ? "text-base font-bold text-primary-700" : "text-sm font-semibold"
}`}
>
{value}
</p>
)}
{sub && (
<p className="text-[11px] text-neutral-500">{sub}</p>
)}
</div>
);
}
function EventHeader({
kind,
title,
at,
}: {
kind: "payment" | "refund" | "payout";
title: string;
at: Date;
}) {
const dotCls =
kind === "payment"
? "bg-secondary-500"
: kind === "refund"
? "bg-amber-500"
: "bg-emerald-500";
return (
<div className="mb-2 flex items-center gap-2">
<span className={`inline-block h-2 w-2 rounded-full ${dotCls}`} />
<p className="text-xs font-bold uppercase tracking-wide text-neutral-700">
{title}
</p>
<p className="text-[11px] text-neutral-400">
{at.toLocaleString("id-ID", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
</div>
);
}
function PaymentEventCard({
payment,
}: {
payment: {
id: string;
provider: string;
method: string | null;
amount: number;
status: string;
externalOrderId: string;
externalTxId: string | null;
snapToken: string | null;
expiresAt: Date | null;
paidAt: Date | null;
failedAt: Date | null;
rejectionReason: string | null;
rawCallback: unknown;
createdAt: Date;
updatedAt: Date;
};
}) {
const canReconcile = payment.provider === "MIDTRANS";
return (
<div>
<EventHeader
kind="payment"
title={`Payment ${payment.provider}`}
at={payment.createdAt}
/>
<div className="rounded-xl border border-neutral-200 bg-neutral-50/60 p-3 sm:p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1 space-y-1 text-xs text-neutral-700">
<p>
<span className="font-semibold">Order ID:</span>{" "}
<code className="rounded bg-white px-1.5 py-0.5 font-mono text-[11px]">
{payment.externalOrderId}
</code>
</p>
{payment.externalTxId && (
<p>
<span className="font-semibold">Transaction ID:</span>{" "}
<code className="rounded bg-white px-1.5 py-0.5 font-mono text-[11px]">
{payment.externalTxId}
</code>
</p>
)}
<p>
<span className="font-semibold">Nominal:</span>{" "}
{formatRupiah(payment.amount)}
</p>
<p>
<span className="font-semibold">Status:</span>{" "}
<StatusBadge value={payment.status} />
{payment.method && (
<span className="ml-2 text-neutral-500">
via <span className="font-medium">{payment.method}</span>
</span>
)}
</p>
{payment.expiresAt && (
<p className="text-neutral-500">
Expires:{" "}
{payment.expiresAt.toLocaleString("id-ID", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
)}
{payment.paidAt && (
<p className="text-emerald-700">
Paid at:{" "}
{payment.paidAt.toLocaleString("id-ID", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
)}
{payment.rejectionReason && (
<p className="flex items-center gap-1 text-red-700">
<CircleAlert
size={14}
strokeWidth={2}
aria-hidden
className="shrink-0"
/>
{payment.rejectionReason}
</p>
)}
</div>
{canReconcile && (
<AdminReconcileButton orderId={payment.externalOrderId} />
)}
</div>
<div className="mt-3 border-t border-neutral-200 pt-3">
<RawCallbackViewer payload={payment.rawCallback} />
</div>
</div>
</div>
);
}
function RefundEventCard({
refund,
}: {
refund: {
id: string;
amount: number;
reason: string;
status: string;
adminNote: string | null;
reportNote: string;
createdAt: Date;
reviewedAt: Date | null;
succeededAt: Date | null;
failedAt: Date | null;
reviewedBy: { id: string; name: string; email: string } | null;
};
}) {
return (
<div>
<EventHeader
kind="refund"
title={`Refund (${refund.reason})`}
at={refund.createdAt}
/>
<div className="rounded-xl border border-amber-200 bg-amber-50/60 p-3 sm:p-4">
<div className="space-y-1 text-xs text-neutral-700">
<p>
<span className="font-semibold">Nominal:</span>{" "}
{formatRupiah(refund.amount)}
</p>
<p>
<span className="font-semibold">Status:</span>{" "}
<StatusBadge value={refund.status} />
</p>
{refund.reportNote && (
<p className="text-neutral-600">
<span className="font-semibold">Report:</span> {refund.reportNote}
</p>
)}
{refund.adminNote && (
<p className="text-neutral-600">
<span className="font-semibold">Admin note:</span>{" "}
{refund.adminNote}
</p>
)}
{refund.reviewedBy && (
<p className="text-[11px] text-neutral-500">
Reviewed by {refund.reviewedBy.email}
{refund.reviewedAt && (
<>
{" "}
·{" "}
{refund.reviewedAt.toLocaleString("id-ID", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</>
)}
</p>
)}
</div>
</div>
</div>
);
}
function PayoutEventCard({
payout,
}: {
payout: {
id: string;
amount: number;
status: string;
heldUntil: Date;
releasedAt: Date | null;
paidAt: Date | null;
cancelledAt: Date | null;
adminNote: string | null;
createdAt: Date;
processedBy: { id: string; name: string; email: string } | null;
};
}) {
return (
<div>
<EventHeader kind="payout" title="Payout ke organizer" at={payout.createdAt} />
<div className="rounded-xl border border-emerald-200 bg-emerald-50/60 p-3 sm:p-4">
<div className="space-y-1 text-xs text-neutral-700">
<p>
<span className="font-semibold">Nominal:</span>{" "}
{formatRupiah(payout.amount)}
</p>
<p>
<span className="font-semibold">Status:</span>{" "}
<StatusBadge value={payout.status} />
</p>
<p className="text-neutral-600">
Held until:{" "}
{payout.heldUntil.toLocaleDateString("id-ID", {
day: "2-digit",
month: "short",
year: "numeric",
})}
</p>
{payout.paidAt && (
<p className="text-emerald-700">
Paid at:{" "}
{payout.paidAt.toLocaleString("id-ID", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
)}
{payout.adminNote && (
<p className="text-neutral-600">
<span className="font-semibold">Admin note:</span>{" "}
{payout.adminNote}
</p>
)}
{payout.processedBy && (
<p className="text-[11px] text-neutral-500">
Processed by {payout.processedBy.email}
</p>
)}
</div>
</div>
</div>
);
}
function StatusBadge({ value }: { value: string }) {
const finalStatuses = ["PAID", "SUCCEEDED", "RELEASED"];
const negativeStatuses = ["FAILED", "EXPIRED", "CANCELLED", "REJECTED"];
const cls = finalStatuses.includes(value)
? "bg-emerald-100 text-emerald-800"
: negativeStatuses.includes(value)
? "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>
);
}
+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;
}
+66
View File
@@ -0,0 +1,66 @@
import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { Lock } from "lucide-react";
import { authOptions } from "@/lib/auth";
import { AdminSidebar } from "@/components/admin/admin-sidebar";
export const metadata: Metadata = {
title: "Admin · SeTrip",
alternates: { canonical: "/admin" },
robots: { index: false, follow: false },
};
/**
* Layout admin — terpisah penuh dari layout user (navbar/footer publik tidak
* dipakai). Sidebar kiri jadi shell global untuk semua /admin/*.
*
* Auth gate di layout ini berlaku ke seluruh sub-page admin sehingga
* sub-page tidak perlu re-check (boleh disederhanakan di iterasi berikutnya).
*/
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getServerSession(authOptions);
if (!session?.user) {
redirect("/login?callbackUrl=/admin");
}
if (!session.user.isAdmin) {
return (
<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">
<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">
Halaman khusus admin
</h1>
<p className="mt-1 text-sm text-neutral-500">
Akun kamu tidak punya akses ke panel admin SeTrip.
</p>
<Link
href="/"
className="mt-4 inline-block rounded-xl bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700"
>
Kembali ke beranda
</Link>
</div>
</div>
);
}
return (
<div className="flex min-h-screen flex-col bg-neutral-50 lg:flex-row">
<AdminSidebar
user={{ name: session.user.name, email: session.user.email }}
/>
<main className="flex-1 min-w-0">{children}</main>
</div>
);
}
+373
View File
@@ -0,0 +1,373 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { getServerSession } from "next-auth";
import { ChevronRight } from "lucide-react";
import { authOptions } from "@/lib/auth";
import { organizerRepo } from "@/server/repositories/organizer.repo";
import { refundRepo } from "@/server/repositories/refund.repo";
import { payoutRepo } from "@/server/repositories/payout.repo";
const REFUND_REASON_LABEL: Record<string, string> = {
USER_CANCELLATION: "Peserta cancel",
ORGANIZER_CANCELLED: "Organizer cancel",
TRIP_ISSUE: "Masalah trip",
ADMIN_ADJUSTMENT: "Penyesuaian admin",
DISPUTE_RESOLVED: "Dispute selesai",
OTHER: "Lainnya",
};
function formatIDR(n: number) {
return `Rp${n.toLocaleString("id-ID")}`;
}
function timeAgo(d: Date) {
const diff = Date.now() - new Date(d).getTime();
const h = Math.floor(diff / 3600000);
if (h < 1) return `${Math.max(1, Math.floor(diff / 60000))} mnt lalu`;
if (h < 24) return `${h} jam lalu`;
const days = Math.floor(h / 24);
return `${days} hari lalu`;
}
export default async function AdminDashboardPage() {
const session = await getServerSession(authOptions);
if (!session?.user) redirect("/login?callbackUrl=/admin");
if (!session.user.isAdmin) {
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 [
pendingVerif,
approvedVerif,
rejectedVerif,
pendingRefund,
approvedRefund,
succeededRefund,
heldPayout,
releasedPayout,
paidPayout,
recentPendingVerif,
recentPendingRefund,
recentApprovedRefund,
recentReleasedPayout,
] = await Promise.all([
organizerRepo.countByStatus("PENDING"),
organizerRepo.countByStatus("APPROVED"),
organizerRepo.countByStatus("REJECTED"),
refundRepo.countByStatus("PENDING"),
refundRepo.countByStatus("APPROVED"),
refundRepo.countByStatus("SUCCEEDED"),
payoutRepo.countByStatus("HELD"),
payoutRepo.countByStatus("RELEASED"),
payoutRepo.countByStatus("PAID"),
organizerRepo.listRecent("PENDING", 3),
refundRepo.listRecent("PENDING", 3),
refundRepo.listRecent("APPROVED", 3),
payoutRepo.listRecent("RELEASED", 3),
]);
const stats: Array<{
label: string;
value: number;
hint: string;
href: string;
accent: "amber" | "blue" | "primary";
}> = [
{
label: "Verifikasi menunggu",
value: pendingVerif,
hint: "KYC organizer perlu ditinjau",
href: "/admin/verifications?tab=PENDING",
accent: "amber",
},
{
label: "Refund baru",
value: pendingRefund,
hint: "Perlu disetujui / ditolak",
href: "/admin/refunds?tab=PENDING",
accent: "amber",
},
{
label: "Refund siap transfer",
value: approvedRefund,
hint: "Refund APPROVED — transfer ke peserta lalu mark SUCCEEDED",
href: "/admin/refunds?tab=APPROVED",
accent: "blue",
},
{
label: "Payout siap transfer",
value: releasedPayout,
hint: "Escrow lepas — transfer ke organizer",
href: "/admin/payouts?tab=RELEASED",
accent: "blue",
},
];
const accentClasses: Record<typeof stats[number]["accent"], string> = {
amber: "bg-amber-50 text-amber-900 ring-amber-200",
blue: "bg-blue-50 text-blue-900 ring-blue-200",
primary: "bg-primary-50 text-primary-900 ring-primary-200",
};
const totalAttention =
pendingVerif + pendingRefund + approvedRefund + releasedPayout;
return (
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
<header className="mb-8">
<p className="text-xs font-semibold uppercase tracking-wide text-primary-600">
Admin
</p>
<h1 className="mt-1 text-2xl font-bold text-neutral-900 sm:text-3xl">
Dashboard
</h1>
<p className="mt-1 text-sm text-neutral-500">
Halo {session.user.name}.{" "}
{totalAttention > 0 ? (
<>
Ada <strong className="text-neutral-800">{totalAttention}</strong>{" "}
hal yang menunggu tindakan kamu.
</>
) : (
<>Tidak ada antrian pending semua sudah beres </>
)}
</p>
</header>
{/* Stats row */}
<section className="mb-8 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{stats.map((s) => (
<Link
key={s.label}
href={s.href}
className={`group rounded-2xl bg-white p-5 ring-1 transition-shadow hover:shadow-md ${
s.value > 0 ? "ring-neutral-200" : "ring-neutral-100"
}`}
>
<div className="flex items-center justify-between">
<span
className={`inline-flex rounded-full px-2.5 py-0.5 text-[11px] font-semibold ring-1 ${accentClasses[s.accent]}`}
>
{s.label}
</span>
<span className="inline-flex items-center gap-1 text-xs text-neutral-400 group-hover:text-primary-600">
Buka
<ChevronRight size={14} strokeWidth={2} aria-hidden />
</span>
</div>
<p className="mt-3 text-3xl font-bold text-neutral-900">{s.value}</p>
<p className="mt-1 text-xs text-neutral-500">{s.hint}</p>
</Link>
))}
</section>
{/* Pending Verifikasi */}
<section className="mb-8 rounded-2xl border border-neutral-200 bg-white">
<div className="flex items-center justify-between border-b border-neutral-100 px-5 py-4">
<div>
<h2 className="text-base font-bold text-neutral-800">
Verifikasi Organizer
</h2>
<p className="text-xs text-neutral-500">
{approvedVerif} disetujui · {rejectedVerif} ditolak ·{" "}
{pendingVerif} menunggu
</p>
</div>
<Link
href="/admin/verifications?tab=PENDING"
className="rounded-lg bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-700"
>
Tinjau pending ({pendingVerif})
</Link>
</div>
{recentPendingVerif.length === 0 ? (
<p className="px-5 py-6 text-sm text-neutral-500">
Tidak ada pengajuan menunggu review.
</p>
) : (
<ul className="divide-y divide-neutral-100">
{recentPendingVerif.map((v) => (
<li
key={v.id}
className="flex items-center justify-between gap-3 px-5 py-3"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-neutral-800">
{v.fullName}
</p>
<p className="truncate text-xs text-neutral-500">
{v.user.name} · {v.user.email} · {timeAgo(v.createdAt)}
</p>
</div>
<Link
href="/admin/verifications?tab=PENDING"
className="shrink-0 rounded-lg border border-neutral-200 px-3 py-1.5 text-xs font-medium text-neutral-700 hover:bg-neutral-50"
>
Buka
</Link>
</li>
))}
</ul>
)}
</section>
{/* Refund — Pending & Siap Transfer */}
<section className="mb-8 rounded-2xl border border-neutral-200 bg-white">
<div className="flex items-center justify-between border-b border-neutral-100 px-5 py-4">
<div>
<h2 className="text-base font-bold text-neutral-800">Refund</h2>
<p className="text-xs text-neutral-500">
{succeededRefund} selesai · {approvedRefund} siap transfer ·{" "}
{pendingRefund} baru
</p>
</div>
<Link
href="/admin/refunds?tab=PENDING"
className="rounded-lg bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-700"
>
Tinjau refund
</Link>
</div>
<div className="grid divide-neutral-100 sm:grid-cols-2 sm:divide-x">
<div className="px-5 py-4">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-amber-700">
Pending ({pendingRefund})
</p>
{recentPendingRefund.length === 0 ? (
<p className="text-sm text-neutral-500">
Tidak ada refund baru.
</p>
) : (
<ul className="space-y-2">
{recentPendingRefund.map((r) => (
<li key={r.id} className="text-sm">
<Link
href="/admin/refunds?tab=PENDING"
className="block rounded-lg p-2 -m-2 hover:bg-neutral-50"
>
<p className="font-semibold text-neutral-800">
{formatIDR(r.amount)} ·{" "}
<span className="font-normal text-neutral-500">
{REFUND_REASON_LABEL[r.reason] ?? r.reason}
</span>
</p>
<p className="truncate text-xs text-neutral-500">
{r.booking.user.name} · {r.booking.trip.title} ·{" "}
{timeAgo(r.createdAt)}
</p>
</Link>
</li>
))}
</ul>
)}
</div>
<div className="px-5 py-4">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-blue-700">
Siap transfer ({approvedRefund})
</p>
{recentApprovedRefund.length === 0 ? (
<p className="text-sm text-neutral-500">
Tidak ada refund siap transfer.
</p>
) : (
<ul className="space-y-2">
{recentApprovedRefund.map((r) => (
<li key={r.id} className="text-sm">
<Link
href="/admin/refunds?tab=APPROVED"
className="block rounded-lg p-2 -m-2 hover:bg-neutral-50"
>
<p className="font-semibold text-neutral-800">
{formatIDR(r.amount)} ·{" "}
<span className="font-normal text-neutral-500">
{REFUND_REASON_LABEL[r.reason] ?? r.reason}
</span>
</p>
<p className="truncate text-xs text-neutral-500">
{r.booking.user.name} · {r.booking.trip.title} ·{" "}
{timeAgo(r.createdAt)}
</p>
</Link>
</li>
))}
</ul>
)}
</div>
</div>
</section>
{/* Payout — escrow ke organizer */}
<section className="mb-8 rounded-2xl border border-neutral-200 bg-white">
<div className="flex items-center justify-between border-b border-neutral-100 px-5 py-4">
<div>
<h2 className="text-base font-bold text-neutral-800">
Payout Organizer (Escrow)
</h2>
<p className="text-xs text-neutral-500">
{paidPayout} dibayar · {releasedPayout} siap transfer ·{" "}
{heldPayout} ditahan
</p>
</div>
<Link
href="/admin/payouts?tab=RELEASED"
className="rounded-lg bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-700"
>
Transfer payout ({releasedPayout})
</Link>
</div>
{recentReleasedPayout.length === 0 ? (
<p className="px-5 py-6 text-sm text-neutral-500">
Tidak ada payout siap transfer.
</p>
) : (
<ul className="divide-y divide-neutral-100">
{recentReleasedPayout.map((p) => (
<li
key={p.id}
className="flex items-center justify-between gap-3 px-5 py-3"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-neutral-800">
{formatIDR(p.amount)} · {p.organizer.name}
</p>
<p className="truncate text-xs text-neutral-500">
{p.trip.title} ·{" "}
{p.releasedAt
? `release ${timeAgo(p.releasedAt)}`
: `hold sampai ${new Date(p.heldUntil).toLocaleDateString("id-ID")}`}
</p>
</div>
<Link
href="/admin/payouts?tab=RELEASED"
className="shrink-0 rounded-lg border border-neutral-200 px-3 py-1.5 text-xs font-medium text-neutral-700 hover:bg-neutral-50"
>
Buka
</Link>
</li>
))}
</ul>
)}
</section>
<section className="rounded-2xl border border-dashed border-neutral-300 bg-neutral-50 px-5 py-4 text-xs text-neutral-500">
<p className="mb-1">
<span className="font-semibold">Refund APPROVED:</span> admin transfer
manual ke peserta lalu tandai <span className="font-semibold">SUCCEEDED</span>.
</p>
<p>
<span className="font-semibold">Payout RELEASED:</span> escrow dilepas
karena trip sudah selesai + 3 hari. Admin transfer ke organizer lalu
tandai <span className="font-semibold">PAID</span>.
</p>
</section>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Admin · Payout Organizer",
description:
"Halaman admin untuk meneruskan uang escrow ke rekening organizer setelah trip selesai.",
alternates: { canonical: "/admin/payouts" },
robots: { index: false, follow: false },
};
export default function AdminPayoutsLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}
+152
View File
@@ -0,0 +1,152 @@
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { isAdminEmail, listAdminEmails } from "@/lib/admin";
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 {
PayoutReviewCard,
type PayoutCardData,
} from "@/features/payout/components/payout-review-card";
type Tab = "RELEASED" | "HELD" | "PAID" | "CANCELLED";
const TABS: { key: Tab; label: string }[] = [
{ key: "RELEASED", label: "Siap transfer" },
{ key: "HELD", label: "Ditahan (escrow)" },
{ key: "PAID", label: "Selesai" },
{ key: "CANCELLED", label: "Dibatalkan" },
];
interface PageProps {
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) {
const session = await getServerSession(authOptions);
if (!session?.user) redirect("/login?callbackUrl=/admin/payouts");
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)
: "RELEASED";
const rows = await payoutRepo.listByStatus(tab, {
dateFrom: parseDate(params.dateFrom),
dateTo: parseDate(params.dateTo),
processorEmail: params.reviewer || undefined,
});
const items: PayoutCardData[] = rows.map((p) => ({
id: p.id,
amount: p.amount,
currency: p.currency,
status: p.status,
heldUntil: p.heldUntil,
releasedAt: p.releasedAt,
paidAt: p.paidAt,
cancelledAt: p.cancelledAt,
bankName: p.bankName,
bankAccountNumber: p.bankAccountNumber,
bankAccountName: p.bankAccountName,
adminNote: p.adminNote,
createdAt: p.createdAt,
trip: p.trip,
organizer: p.organizer,
booking: {
id: p.booking.id,
amount: p.booking.amount,
status: p.booking.status,
user: p.booking.user,
},
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 (
<div className="mx-auto max-w-4xl 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">
Payout Organizer
</h1>
<p className="mt-1 text-sm text-neutral-500">
Uang peserta ditahan (escrow) sampai trip selesai + 3 hari. Setelah
status <strong>Siap transfer</strong>, admin transfer manual ke
rekening organizer lalu tandai sudah dibayar.
</p>
</div>
<ExportCsvLink
href="/api/admin/export/payouts"
query={exportQuery.toString()}
/>
</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">
{TABS.map((t) => (
<a
key={t.key}
href={`/admin/payouts?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>
{items.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 payout yang cocok dengan filter ini.
</p>
</div>
) : (
<div className="space-y-4">
{items.map((p) => (
<PayoutReviewCard key={p.id} payout={p} />
))}
</div>
)}
</div>
);
}
+66 -5
View File
@@ -1,9 +1,11 @@
import { redirect } from "next/navigation";
import { getServerSession } from "next-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 { 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 {
RefundReviewCard,
type RefundCardData,
@@ -19,8 +21,30 @@ const TABS: { key: Tab; label: string }[] = [
{ 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 {
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) {
@@ -40,8 +64,19 @@ export default async function AdminRefundsPage({ searchParams }: PageProps) {
const tab: Tab = TABS.some((t) => t.key === params.tab)
? (params.tab as Tab)
: "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) => ({
id: r.id,
amount: r.amount,
@@ -78,9 +113,16 @@ 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 (
<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">
<div>
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
Review Refund Manual
</h1>
@@ -88,10 +130,29 @@ export default async function AdminRefundsPage({ searchParams }: PageProps) {
Tinjau laporan refund dari peserta dan organizer. Setiap refund harus
melalui approval admin sebelum dieksekusi.
</p>
</div>
<ExportCsvLink
href="/api/admin/export/refunds"
query={exportQuery.toString()}
/>
</header>
<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">
{TABS.map((t) => (
<a
@@ -111,7 +172,7 @@ export default async function AdminRefundsPage({ searchParams }: PageProps) {
{items.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 refund pada status ini.
Tidak ada refund yang cocok dengan filter ini.
</p>
</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>
);
}
+316
View File
@@ -0,0 +1,316 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { ArrowLeft, CalendarDays, MapPin } from "lucide-react";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { tripService } from "@/server/services/trip.service";
import { formatRupiah } from "@/lib/utils";
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
import { categoryMeta } from "@/lib/activity-category";
import { groupItineraryByDay } from "@/lib/itinerary";
import { AdminCancelTripButton } from "@/features/trip/components/admin-cancel-trip-button";
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function AdminTripDetailPage({ params }: PageProps) {
const session = await getServerSession(authOptions);
if (!session?.user) {
redirect("/login?callbackUrl=/admin/trips");
}
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;
let trip;
try {
trip = await tripService.getTripById(id);
} catch {
notFound();
}
const cat = categoryMeta(trip.category);
const activeParticipants = trip.participants.filter(
(p) => p.status !== "CANCELLED"
);
const confirmedCount = activeParticipants.filter(
(p) => p.status === "CONFIRMED"
).length;
const pendingCount = activeParticipants.filter(
(p) => p.status === "PENDING"
).length;
const cancelledCount = trip.participants.length - activeParticipants.length;
const grouped = trip.itineraryItems.length
? groupItineraryByDay(
trip.itineraryItems.map((i) => ({
day: i.day,
startTime: i.startTime,
endTime: i.endTime,
activity: i.activity,
order: i.order,
}))
)
: null;
const canCancel = trip.status === "OPEN" || trip.status === "FULL";
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/trips"
className="inline-flex items-center gap-1 hover:text-primary-600"
>
<ArrowLeft size={14} strokeWidth={2} aria-hidden />
Kembali ke list trips
</Link>
</div>
<header className="mb-6 rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="mb-2 flex flex-wrap items-center gap-2 text-[11px]">
<span className="inline-flex items-center gap-1 rounded-full bg-neutral-100 px-2 py-0.5 font-semibold text-neutral-600">
<span aria-hidden>{cat.icon}</span> {cat.label}
</span>
<StatusBadge status={trip.status} />
</div>
<h1 className="text-xl font-bold text-neutral-900 sm:text-2xl">
{trip.title}
</h1>
<p className="mt-1 flex flex-wrap items-center gap-1 text-sm text-neutral-500">
<CalendarDays
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 className="mt-1 text-xs text-neutral-500">
Organizer:{" "}
<Link
href={`/u/${trip.organizer.id}`}
className="font-semibold text-neutral-700 hover:text-primary-600"
target="_blank"
>
{trip.organizer.name}
</Link>{" "}
<span className="text-neutral-400">({trip.organizer.email})</span>
</p>
<p className="mt-1 text-xs text-neutral-500">
Trip ID:{" "}
<code className="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-[11px] text-neutral-700">
{trip.id}
</code>
</p>
</div>
<div className="shrink-0 text-right">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
Harga
</p>
<p className="text-xl font-bold text-primary-700 sm:text-2xl">
{formatRupiah(trip.price)}
</p>
<p className="text-[11px] text-neutral-500">per orang</p>
</div>
</div>
</header>
<section className="mb-6 grid gap-3 sm:grid-cols-4">
<StatCard label="Kapasitas" value={String(trip.maxParticipants)} />
<StatCard
label="Confirmed"
value={String(confirmedCount)}
accent="emerald"
/>
<StatCard
label="Pending"
value={String(pendingCount)}
accent="amber"
/>
<StatCard
label="Cancelled"
value={String(cancelledCount)}
accent="neutral"
/>
</section>
{canCancel && (
<section className="mb-6 rounded-2xl border border-red-200 bg-red-50/60 p-4 sm:p-5">
<h2 className="text-sm font-bold text-red-900">
Intervensi Admin Cancel Trip
</h2>
<p className="mt-1 text-xs text-red-900/80">
Pakai hanya saat organizer unreachable, safety issue, atau dispute
tidak terselesaikan. Semua booking PAID akan auto-refund (full
amount). Booking PENDING/AWAITING_PAY langsung CANCELLED.
</p>
<div className="mt-3">
<AdminCancelTripButton tripId={trip.id} />
</div>
</section>
)}
{trip.description && (
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-5">
<h2 className="mb-2 text-xs font-bold uppercase tracking-wide text-neutral-500">
Deskripsi
</h2>
<p className="whitespace-pre-wrap text-sm text-neutral-700">
{trip.description}
</p>
</section>
)}
{grouped && (
<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">
Itinerary
</h2>
<div className="space-y-3">
{[...grouped.entries()].map(([day, items]) => (
<div
key={day}
className="rounded-lg border border-primary-100 bg-primary-50/40 p-3"
>
<p className="mb-1.5 text-xs font-bold text-primary-800">
Hari {day}
</p>
<ol className="space-y-1">
{items.map((item) => (
<li
key={item.order}
className="flex gap-3 text-xs text-neutral-700"
>
<span className="shrink-0 font-mono text-[11px] font-semibold text-primary-700">
{item.startTime}
{item.endTime ? `${item.endTime}` : ""}
</span>
<span>{item.activity}</span>
</li>
))}
</ol>
</div>
))}
</div>
</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">
Peserta ({activeParticipants.length})
</h2>
{trip.participants.length === 0 ? (
<p className="text-xs text-neutral-500">Belum ada peserta.</p>
) : (
<ul className="divide-y divide-neutral-100">
{trip.participants.map((p) => (
<li
key={p.id}
className="flex flex-wrap items-center justify-between gap-2 py-2.5"
>
<div className="min-w-0 flex-1">
<Link
href={`/u/${p.user.id}`}
target="_blank"
className="text-sm font-semibold text-neutral-800 hover:text-primary-600"
>
{p.user.name}
</Link>
{p.user.profile?.city && (
<span className="ml-2 inline-flex items-center gap-1 text-[11px] text-neutral-500">
<MapPin size={12} strokeWidth={2} aria-hidden />
{p.user.profile.city}
</span>
)}
</div>
<ParticipantStatusBadge status={p.status} />
</li>
))}
</ul>
)}
</section>
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const cls =
status === "OPEN"
? "bg-emerald-100 text-emerald-800"
: status === "FULL"
? "bg-amber-100 text-amber-800"
: status === "CLOSED"
? "bg-red-100 text-red-800"
: "bg-neutral-200 text-neutral-700";
return (
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${cls}`}
>
{status}
</span>
);
}
function ParticipantStatusBadge({ status }: { status: string }) {
const cls =
status === "CONFIRMED"
? "bg-emerald-100 text-emerald-800"
: status === "PENDING"
? "bg-amber-100 text-amber-800"
: "bg-neutral-200 text-neutral-600 line-through";
return (
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${cls}`}
>
{status}
</span>
);
}
function StatCard({
label,
value,
accent = "primary",
}: {
label: string;
value: string;
accent?: "primary" | "emerald" | "amber" | "neutral";
}) {
const map = {
primary: "text-primary-700",
emerald: "text-emerald-700",
amber: "text-amber-700",
neutral: "text-neutral-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-xl font-bold sm:text-2xl ${map[accent]}`}>
{value}
</p>
</div>
);
}
+196
View File
@@ -0,0 +1,196 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { CalendarDays, MapPin } from "lucide-react";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { tripRepo } from "@/server/repositories/trip.repo";
import { formatRupiah } from "@/lib/utils";
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
import { categoryMeta } from "@/lib/activity-category";
type Tab = "ALL" | "OPEN" | "FULL" | "CLOSED" | "COMPLETED";
const TABS: { key: Tab; label: string }[] = [
{ key: "ALL", label: "Semua" },
{ key: "OPEN", label: "Open" },
{ key: "FULL", label: "Penuh" },
{ key: "CLOSED", label: "Dibatalkan" },
{ key: "COMPLETED", label: "Selesai" },
];
interface PageProps {
searchParams: Promise<{ tab?: string; q?: string }>;
}
export default async function AdminTripsPage({ searchParams }: PageProps) {
const session = await getServerSession(authOptions);
if (!session?.user) redirect("/login?callbackUrl=/admin/trips");
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 trips = await tripRepo.searchForAdmin({
status: tab === "ALL" ? undefined : tab,
q: q || undefined,
});
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">
Trip Operations
</h1>
<p className="mt-1 text-sm text-neutral-500">
Cari trip, lihat detail, dan intervensi (cancel + auto-refund) saat
organizer unreachable.
</p>
</header>
<form method="get" className="mb-4 flex gap-2">
<input type="hidden" name="tab" value={tab} />
<input
name="q"
defaultValue={q}
placeholder="Cari judul, destinasi, lokasi, organizer..."
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/trips?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/trips?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>
{trips.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 trip yang cocok dengan "${q}".`
: "Tidak ada trip pada status ini."}
</p>
</div>
) : (
<ul className="space-y-3">
{trips.map((t) => {
const cat = categoryMeta(t.category);
return (
<li
key={t.id}
className="rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm transition-shadow hover:shadow-md sm:p-5"
>
<Link href={`/admin/trips/${t.id}`} className="block">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="mb-1 flex flex-wrap items-center gap-2 text-[11px]">
<span className="inline-flex items-center gap-1 rounded-full bg-neutral-100 px-2 py-0.5 font-semibold text-neutral-600">
<span aria-hidden>{cat.icon}</span> {cat.label}
</span>
<StatusBadge status={t.status} />
</div>
<h2 className="truncate text-base font-bold text-neutral-900 sm:text-lg">
{t.title}
</h2>
<p className="mt-1 flex items-center gap-1 truncate text-xs text-neutral-500 sm:text-sm">
<CalendarDays
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 className="mt-1 text-xs text-neutral-500 sm:text-sm">
Organizer:{" "}
<span className="font-semibold text-neutral-700">
{t.organizer.name}
</span>{" "}
<span className="text-neutral-400">
({t.organizer.email})
</span>
</p>
</div>
<div className="shrink-0 text-right">
<p className="text-sm font-bold text-primary-700 sm:text-base">
{formatRupiah(t.price)}
</p>
<p className="mt-1 text-[11px] text-neutral-500">
{t._count.participants}/{t.maxParticipants} peserta
</p>
<p className="text-[11px] text-emerald-700">
{t._count.bookings} PAID
</p>
</div>
</div>
</Link>
</li>
);
})}
</ul>
)}
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const cls =
status === "OPEN"
? "bg-emerald-100 text-emerald-800"
: status === "FULL"
? "bg-amber-100 text-amber-800"
: status === "CLOSED"
? "bg-red-100 text-red-800"
: "bg-neutral-200 text-neutral-700";
return (
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${cls}`}
>
{status}
</span>
);
}
+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>
);
}
+47 -5
View File
@@ -1,15 +1,28 @@
import { redirect } from "next/navigation";
import { getServerSession } from "next-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 { 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";
type Tab = "PENDING" | "APPROVED" | "REJECTED";
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) {
@@ -29,7 +42,11 @@ export default async function AdminVerificationsPage({ searchParams }: PageProps
const tab: Tab =
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) => ({
id: v.id,
fullName: v.fullName,
@@ -53,9 +70,15 @@ export default async function AdminVerificationsPage({ searchParams }: PageProps
{ 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 (
<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">
<div>
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
Review Verifikasi Organizer
</h1>
@@ -63,8 +86,25 @@ export default async function AdminVerificationsPage({ searchParams }: PageProps
Periksa data KTP, foto liveness (memegang kertas SETRIP), dan rekening
sebelum menyetujui.
</p>
</div>
<ExportCsvLink
href="/api/admin/export/verifications"
query={exportQuery.toString()}
/>
</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">
{tabs.map((t) => (
<a
@@ -83,7 +123,9 @@ export default async function AdminVerificationsPage({ searchParams }: PageProps
{items.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 data.</p>
<p className="text-sm text-neutral-500">
Tidak ada data yang cocok dengan filter ini.
</p>
</div>
) : (
<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 });
}
+14 -9
View File
@@ -1,5 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { tripService } from "@/server/services/trip.service";
import { payoutService } from "@/server/services/payout.service";
import { runCron } from "@/lib/cron-runner";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -29,22 +31,25 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const outcome = await runCron("auto-complete-trips", async () => {
const result = await tripService.autoCompletePastTrips();
console.log("[cron/auto-complete-trips] selesai", {
count: result.count,
ids: result.ids,
});
return NextResponse.json({
ok: true,
// Setelah trip COMPLETED, payout yang sudah lewat heldUntil di-release
// supaya admin bisa langsung transfer ke organizer. Idempotent.
const releaseResult = await payoutService.releaseEligible();
return {
completed: result.count,
ids: result.ids,
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(
{ error: "Gagal menjalankan auto-complete" },
{ 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;
}
/* 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 {
background: #f9fafb !important;
border-bottom: 1px solid #e5e7eb !important;
@@ -132,3 +142,46 @@ select:focus {
.react-datepicker__close-icon:hover::after {
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;
}
+1 -7
View File
@@ -1,8 +1,6 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { SessionProvider } from "@/components/providers/session-provider";
import { Navbar } from "@/components/shared/navbar";
import { ProfileNudgeBanner } from "@/components/shared/profile-nudge-banner";
import { siteConfig, siteUrl } from "@/lib/site";
import "./globals.css";
@@ -79,11 +77,7 @@ export default function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="flex min-h-full flex-col bg-neutral-50">
<SessionProvider>
<Navbar />
<ProfileNudgeBanner />
<main className="flex-1">{children}</main>
</SessionProvider>
<SessionProvider>{children}</SessionProvider>
</body>
</html>
);
-13
View File
@@ -1,13 +0,0 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Masuk",
description:
"Masuk ke akun SeTrip untuk gabung open trip & aktivitas bareng dan kelola perjalananmu.",
alternates: { canonical: "/login" },
robots: { index: false, follow: true },
};
export default function LoginLayout({ children }: { children: React.ReactNode }) {
return children;
}
+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",
},
],
};
}
-12
View File
@@ -1,12 +0,0 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Daftar Akun",
description:
"Buat akun SeTrip gratis. Cari open trip & aktivitas bareng, gabung bareng, dan mulai petualanganmu.",
alternates: { canonical: "/register" },
};
export default function RegisterLayout({ children }: { children: React.ReactNode }) {
return children;
}
+8 -1
View File
@@ -7,7 +7,14 @@ export default function robots(): MetadataRoute.Robots {
{
userAgent: "*",
allow: "/",
disallow: ["/api/", "/profile", "/create-trip"],
disallow: [
"/api/",
"/admin",
"/profile",
"/create-trip",
"/verify",
"/trips/*/payment",
],
},
],
sitemap: absoluteUrl("/sitemap.xml"),
+6
View File
@@ -24,6 +24,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
changeFrequency: "hourly",
priority: 0.9,
},
{
url: absoluteUrl("/people"),
lastModified: now,
changeFrequency: "daily",
priority: 0.7,
},
{
url: absoluteUrl("/register"),
lastModified: now,
+182
View File
@@ -0,0 +1,182 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import Image from "next/image";
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: LucideIcon }[] = [
{ href: "/admin", label: "Dashboard", icon: LayoutDashboard },
{ href: "/admin/trips", label: "Trips", icon: Compass },
{ href: "/admin/users", label: "Users", icon: Users },
{ href: "/admin/verifications", label: "Verifikasi", icon: IdCard },
{ 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 {
user: { name: string; email: string };
}
export function AdminSidebar({ user }: AdminSidebarProps) {
const pathname = usePathname();
const [open, setOpen] = useState(false);
return (
<>
{/* Mobile top bar */}
<header className="sticky top-0 z-30 flex h-14 items-center justify-between border-b border-neutral-200 bg-white px-4 lg:hidden">
<Link href="/admin" className="flex items-center gap-2">
<Image
src="/images/SeTrip.png"
alt=""
width={28}
height={28}
className="h-7 w-7 object-contain"
/>
<span className="text-sm font-bold text-neutral-800">
SeTrip <span className="text-primary-600">Admin</span>
</span>
</Link>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex h-9 w-9 items-center justify-center rounded-lg text-neutral-600 hover:bg-neutral-100"
aria-label="Toggle menu"
aria-expanded={open}
>
{open ? (
<X size={20} strokeWidth={2} aria-hidden />
) : (
<Menu size={20} strokeWidth={2} aria-hidden />
)}
</button>
</header>
{/* Mobile drawer backdrop */}
{open && (
<button
type="button"
aria-label="Tutup menu"
onClick={() => setOpen(false)}
className="fixed inset-0 z-30 bg-neutral-900/30 lg:hidden"
/>
)}
{/* Sidebar */}
<aside
className={`fixed inset-y-0 left-0 z-40 flex w-64 flex-col border-r border-neutral-200 bg-white transition-transform lg:sticky lg:top-0 lg:h-screen lg:translate-x-0 ${
open ? "translate-x-0" : "-translate-x-full"
}`}
>
<div className="flex h-16 items-center gap-2.5 border-b border-neutral-200 px-5">
<Image
src="/images/SeTrip.png"
alt=""
width={32}
height={32}
className="h-8 w-8 object-contain"
/>
<div className="leading-tight">
<p className="text-base font-bold text-neutral-800">
SeTrip
</p>
<p className="text-[10px] font-semibold uppercase tracking-wider text-primary-600">
Admin Panel
</p>
</div>
</div>
<div className="border-b border-neutral-100 p-3">
<AdminSearchBar />
</div>
<nav className="flex-1 overflow-y-auto p-3">
<ul className="space-y-1">
{NAV_ITEMS.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== "/admin" && pathname?.startsWith(item.href));
const Icon = item.icon;
return (
<li key={item.href}>
<Link
href={item.href}
onClick={() => setOpen(false)}
className={`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
isActive
? "bg-primary-600 text-white"
: "text-neutral-700 hover:bg-neutral-100"
}`}
>
<Icon size={20} strokeWidth={1.75} aria-hidden />
<span>{item.label}</span>
</Link>
</li>
);
})}
</ul>
<div className="my-4 border-t border-neutral-100" />
<ul className="space-y-1">
<li>
<Link
href="/"
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"
>
<ArrowUpRight size={16} strokeWidth={1.75} aria-hidden />
<span>Lihat situs publik</span>
</Link>
</li>
</ul>
</nav>
<div className="border-t border-neutral-100 p-3">
<div className="flex items-center gap-2 rounded-lg px-2 py-2">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary-600 text-xs font-bold text-white">
{user.name.charAt(0).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-semibold text-neutral-800">
{user.name}
</p>
<p className="truncate text-[10px] text-neutral-500">
{user.email}
</p>
</div>
<button
type="button"
onClick={() => signOut({ callbackUrl: "/" })}
className="rounded-lg px-2 py-1 text-[11px] font-medium text-neutral-500 hover:bg-red-50 hover:text-red-600"
title="Keluar"
>
Keluar
</button>
</div>
</div>
</aside>
</>
);
}
+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>
);
}
+82
View File
@@ -0,0 +1,82 @@
import Link from "next/link";
import { siteConfig } from "@/lib/site";
const LEGAL_LINKS = [
{ href: "/terms", label: "Syarat & Ketentuan" },
{ href: "/privacy", label: "Kebijakan Privasi" },
] as const;
const EXPLORE_LINKS = [
{ href: "/trips", label: "Open Trip" },
{ href: "/people", label: "Cari Teman" },
{ href: "/create-trip", label: "Buat Trip" },
] as const;
export function Footer() {
const year = new Date().getFullYear();
return (
<footer className="mt-12 border-t border-neutral-200 bg-white">
<div className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
<div className="grid gap-8 sm:grid-cols-3">
<div>
<Link href="/" className="inline-flex items-center gap-2">
<span className="text-lg font-bold text-neutral-800">
Se<span className="text-primary-600">Trip</span>
</span>
</Link>
<p className="mt-2 max-w-xs text-xs text-neutral-500">
{siteConfig.slogan} Gabung trip & aktivitas, kenal stranger jadi
travel buddies.
</p>
</div>
<div>
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-neutral-500">
Jelajah
</p>
<ul className="space-y-2 text-sm">
{EXPLORE_LINKS.map((l) => (
<li key={l.href}>
<Link
href={l.href}
className="text-neutral-600 hover:text-primary-700"
>
{l.label}
</Link>
</li>
))}
</ul>
</div>
<div>
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-neutral-500">
Kebijakan
</p>
<ul className="space-y-2 text-sm">
{LEGAL_LINKS.map((l) => (
<li key={l.href}>
<Link
href={l.href}
className="text-neutral-600 hover:text-primary-700"
>
{l.label}
</Link>
</li>
))}
</ul>
</div>
</div>
<div className="mt-8 flex flex-col items-start justify-between gap-2 border-t border-neutral-100 pt-4 text-xs text-neutral-500 sm:flex-row sm:items-center">
<p>
© {year} {siteConfig.name}. Pergi bareng, bukan sendiri.
</p>
<p className="text-[11px] text-neutral-400">
Pembayaran ditahan (escrow) sampai trip selesai · refund manual oleh
admin
</p>
</div>
</div>
</footer>
);
}
+3 -22
View File
@@ -4,6 +4,7 @@ import { useState } from "react";
import Link from "next/link";
import Image from "next/image";
import { useSession, signOut } from "next-auth/react";
import { Menu, X } from "lucide-react";
export function Navbar() {
const { data: session } = useSession();
@@ -109,29 +110,9 @@ export function Navbar() {
aria-label="Toggle menu"
>
{menuOpen ? (
<svg
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>
<X size={20} strokeWidth={1.75} aria-hidden />
) : (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M3 5h14M3 10h14M3 15h14" />
</svg>
<Menu size={20} strokeWidth={1.75} aria-hidden />
)}
</button>
</div>
+8 -11
View File
@@ -1,23 +1,20 @@
import { BadgeCheck } from "lucide-react";
type Size = "sm" | "md";
export function VerifiedBadge({ size = "sm" }: { size?: Size }) {
const cls =
size === "md"
? "px-2.5 py-1 text-xs"
: "px-2 py-0.5 text-[10px]";
size === "md" ? "px-2.5 py-1 text-xs" : "px-2 py-0.5 text-[10px]";
return (
<span
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"
>
<svg
viewBox="0 0 16 16"
fill="currentColor"
className={size === "md" ? "h-3.5 w-3.5" : "h-3 w-3"}
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>
<BadgeCheck
size={size === "md" ? 14 : 12}
strokeWidth={1.75}
aria-hidden
/>
Verified
</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.
> **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
| Endpoint | Schedule | 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`. |
| # | Endpoint | Schedule | Frekuensi | Tujuan |
|---|---|---|---|---|
| 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
@@ -30,7 +40,17 @@ Restart PM2 supaya proses re-load 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):
@@ -38,11 +58,19 @@ Edit crontab user yang punya akses ke env (biasanya user yang menjalankan PM2):
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
# 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
# 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:
@@ -51,57 +79,183 @@ Verifikasi crontab tersimpan:
crontab -l
```
### 3. Siapkan file log
### 4. Siapkan file log
```bash
sudo touch /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:
```bash
# Test cron 1
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":[]}`
- Ada trip yang lewat: `{"ok":true,"completed":2,"ids":["clx...","cly..."]}`
| Cron | Sukses kosong | Sukses ada pekerjaan |
|---|---|---|
| `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
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
tail -f /var/log/setrip-cron.log
```
Cek log app PM2 (untuk `console.log` dari endpoint):
PM2 log untuk `console.log` dari endpoint:
```bash
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
**Cron jalan tapi tidak ada efek di DB:**
- Cek `pm2 logs setrip` untuk error.
- Verifikasi waktu server: `date` (output harus UTC kalau pakai schedule UTC).
- Cek `/admin/system` — kalau status SUCCESS dengan `payload: { completed: 0 }`, memang tidak ada pekerjaan saat ini.
- Cek `pm2 logs setrip` untuk error runtime.
- Verifikasi waktu server: `date -u` (output harus UTC kalau pakai schedule UTC).
**Cron tidak jalan sama sekali:**
- 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 `/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:**
- 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.
+245
View File
@@ -0,0 +1,245 @@
# Release Workflow
Panduan rilis: commit perubahan, naikan versi, push. Konsisten dengan pola history repo (single feature commit + version commit terpisah).
---
## Aturan versi (semver untuk 0.x)
Project masih `0.x.y` — API belum stabil, semver yang dipakai:
| Tipe perubahan | Bump | Contoh |
|---|---|---|
| **MAJOR** `0.x.y → 1.0.0` | hanya saat siap rilis publik / API stabil | nanti |
| **MINOR** `0.11.0 → 0.12.0` | fitur baru, breaking change, schema/migration baru, removal API | midtrans-only flow, structured itinerary |
| **PATCH** `0.10.2 → 0.10.3` | bugfix, dependency upgrade, copy/UI tweaks tanpa schema | upgrade lib vulnerability, fix hydration |
**Aturan praktis:** kalau perlu jalankan `prisma migrate deploy` setelah pull → minor. Kalau cuma `git pull && pm2 restart` → patch.
---
## Pre-flight check (wajib sebelum commit)
```bash
# 1. Type check (filter cache stale Next.js)
npx tsc --noEmit 2>&1 | grep -v "\.next"
# 2. Lint
npm run lint
# 3. (Opsional) test seed kalau ubah schema/seed
npm run seed
```
Kalau ada error di TS atau ESLint, **jangan commit**. Fix dulu.
---
## Standard flow (rekomendasi)
Pola dari history repo: **1 commit fitur** + **1 commit versi** terpisah.
### 1. Verify status
```bash
git status
git diff --stat
```
Pastikan tidak ada file sensitif (`.env`, `*.key`, upload KYC, `app/generated/prisma/`) ter-track.
### 2. Stage perubahan
Default — semua perubahan logis:
```bash
git add -A
```
Kalau ada file yang sengaja dipisah commit-nya, pakai selective:
```bash
git add path/to/file1 path/to/file2
```
### 3. Commit fitur
Pakai pesan singkat, lowercase, deskriptif. Pola history:
-`midtrans-only payment + reconcile, structured itinerary items, admin roadmap`
-`add payment and integration with midtrans`
-`create public layout and admin and fix escrow and refund`
-`chore: remove generated prisma client from repository`
Prefix `chore:`, `fix:` boleh dipakai tapi tidak wajib. Yang penting: deskriptif & ringkas.
```bash
git commit -m "deskripsi singkat perubahan utama"
```
### 4. Bump versi
Edit manual `package.json` di field `"version"`, atau pakai npm:
```bash
# Bump tanpa auto-commit & tag (kita commit manual)
npm version 0.12.0 --no-git-tag-version
```
`--no-git-tag-version` penting — repo ini **tidak pakai git tag**, cuma commit dengan pesan = nomor versi.
### 5. Commit versi (terpisah)
```bash
git add package.json
git commit -m "0.12.0"
```
Pesan = nomor versi saja, tanpa prefix/kata lain. Konsisten dengan history (`0.11.0`, `0.10.3`, `0.10.2`, ...).
### 6. Push
```bash
git push origin main
```
---
## Post-deploy actions
Setelah merge ke main + auto-deploy / `git pull` di server:
### Wajib kalau ada migration baru
```bash
# Cek dulu migration belum applied
npx prisma migrate status
# Apply
npx prisma migrate deploy
# Restart PM2 supaya Prisma client re-load
pm2 restart setrip --update-env
```
### Wajib kalau ubah field di env
```bash
# Edit .env di server, lalu
pm2 restart setrip --update-env
```
### Opsional — seed (hanya untuk dev/staging, JANGAN production)
```bash
npm run seed
```
⚠️ **Production**: seed wipe seluruh data. Jangan dijalankan di production.
---
## Skenario umum
### A. Bug fix kecil (patch)
```bash
npx tsc --noEmit 2>&1 | grep -v "\.next" && npm run lint
git add path/to/fix
git commit -m "fix: deskripsi bug"
npm version patch --no-git-tag-version # 0.11.0 → 0.11.1
git add package.json
git commit -m "0.11.1"
git push origin main
```
### B. Fitur baru tanpa schema change (minor)
Sama dengan A, ganti `patch` jadi `minor`:
```bash
npm version minor --no-git-tag-version # 0.11.0 → 0.12.0
```
### C. Fitur baru DENGAN schema/migration (minor)
```bash
# 1. Buat migration
npx prisma migrate dev --name nama_migration
# 2. Smoke test
npm run seed
npx tsc --noEmit 2>&1 | grep -v "\.next"
# 3. Commit fitur + migration sekaligus
git add -A
git commit -m "deskripsi fitur"
# 4. Bump versi minor
npm version minor --no-git-tag-version
git add package.json
git commit -m "$(node -p "require('./package.json').version")"
git push origin main
# 5. Di production, setelah git pull:
npx prisma migrate deploy
pm2 restart setrip --update-env
```
### D. Multiple perubahan logis di branch yang sama (split commits)
Pisahkan jadi commit kecil per topik supaya history bersih:
```bash
# Commit 1: foundation (mis. schema + service)
git add prisma/ server/
git commit -m "add X service"
# Commit 2: UI yang konsumsi
git add features/ app/
git commit -m "wire X to UI"
# Commit 3: docs
git add docs/ *.md
git commit -m "docs: X usage guide"
# Commit 4: version bump
npm version minor --no-git-tag-version
git add package.json
git commit -m "$(node -p "require('./package.json').version")"
git push origin main
```
---
## Kesalahan umum & cara recovery
| Kesalahan | Recovery |
|---|---|
| Commit pesan typo, **belum push** | `git commit --amend -m "pesan baru"` |
| Commit pesan typo, **sudah push** | jangan amend (force-push hilangin history kolaborator). Bikin commit baru `git commit --allow-empty -m "fix: pesan sebelumnya typo"` atau biarkan |
| Lupa bump versi sebelum push | bikin commit versi baru di atasnya — bukan amend |
| Bump versi salah angka (mis. 0.12.0 padahal harusnya patch 0.11.1) | revisi `package.json`, bikin commit baru `chore: revert version to 0.11.1` |
| Commit termasuk file sensitif (`.env`, upload) | jangan push. `git reset --soft HEAD~1`, un-stage file sensitif, tambah ke `.gitignore`, commit ulang |
| Sudah push dengan file sensitif | rotate secret yang ke-leak, lalu pakai `git filter-repo` atau hubungi maintainer git history |
---
## Cheatsheet (one-liner)
Untuk update biasa (fitur kecil tanpa schema):
```bash
npx tsc --noEmit 2>&1 | grep -v "\.next" && npm run lint && \
git add -A && git commit -m "deskripsi" && \
npm version minor --no-git-tag-version && \
git add package.json && git commit -m "$(node -p "require('./package.json').version")" && \
git push origin main
```
Ganti `minor``patch` untuk bug fix. Jangan jalankan kalau ada step yang minta keputusan manual (mis. conflict, migration baru).
+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.
+51
View File
@@ -0,0 +1,51 @@
# Setrip — Admin Payment Operations Roadmap (ARCHIVED — DELIVERED 2026-05-18)
Admin perlu visibilitas + kontrol penuh atas alur uang: payment Midtrans, refund, payout. Saat webhook gagal atau status mismatch, admin harus bisa reconcile tanpa edit DB.
> **Skenario nyata:** webhook Midtrans drop di production. `Booking.status = AWAITING_PAY` padahal user sudah bayar (confirm email dari Midtrans). User komplain via WhatsApp. Saat ini admin harus query DB manual lalu update via Prisma Studio.
---
## Status delivery
| Phase | Status | Catatan |
|---|---|---|
| Phase 1 — Booking + Payment Detail View | ✅ Delivered | Timeline lintas Payment + Refund + Payout dengan raw callback viewer. |
| Phase 2 — Admin Midtrans Reconcile UI | ✅ Delivered | Tombol reconcile per Payment Midtrans, panggil Core API + apply state-machine. Bulk reconcile deferred. |
| Phase 3 — Dispute & Chargeback Tracking | ⏳ Deferred | Enum `DISPUTE_RESOLVED` sudah ada — admin bisa pakai existing flow refund. UI filter khusus bisa ditambah nanti. |
---
## Phase 1 — Booking + Payment Detail View ✅
| # | Item | Status | File |
|---|---|---|---|
| 1.1 | `bookingRepo.findByIdForAdmin(id)` — include payments (with raw), refunds, payout, trip, user | ✅ | [server/repositories/booking.repo.ts](../../server/repositories/booking.repo.ts) |
| 1.2 | Page `/admin/bookings/[id]` — header booking + timeline events | ✅ | [app/admin/bookings/[id]/page.tsx](../../app/admin/bookings/[id]/page.tsx) |
| 1.3 | Inline timeline (Payment + Refund + Payout) sorted by createdAt — implemented inline di page | ✅ | [app/admin/bookings/[id]/page.tsx](../../app/admin/bookings/[id]/page.tsx) |
| 1.4 | Component `RawCallbackViewer` — collapsible JSON pretty-printed | ✅ | [features/booking/components/raw-callback-viewer.tsx](../../features/booking/components/raw-callback-viewer.tsx) |
| 1.5 | Link "Lihat timeline" dari `/admin/refunds` ke `/admin/bookings/[id]` | ✅ | [features/refund/components/refund-review-card.tsx](../../features/refund/components/refund-review-card.tsx) |
| 1.6 | Link "Lihat timeline" dari `/admin/payouts` ke `/admin/bookings/[id]` | ✅ | [features/payout/components/payout-review-card.tsx](../../features/payout/components/payout-review-card.tsx) |
---
## Phase 2 — Admin Midtrans Reconciliation UI ✅
| # | Item | Status | File |
|---|---|---|---|
| 2.1 | `paymentService.adminReconcile(orderId)` — variant tanpa ownership check, reuse `applyGatewayStatus` helper | ✅ | [server/services/payment.service.ts](../../server/services/payment.service.ts) |
| 2.2 | Server action `adminReconcileMidtransAction(orderId)` (guard isAdmin) | ✅ | [features/booking/actions.ts](../../features/booking/actions.ts) |
| 2.3 | Component `AdminReconcileButton` per Payment Midtrans di timeline | ✅ | [features/booking/components/admin-reconcile-button.tsx](../../features/booking/components/admin-reconcile-button.tsx) |
| 2.4 | Tampilkan `Payment.rejectionReason` (amount mismatch log) di card payment | ✅ | [app/admin/bookings/[id]/page.tsx](../../app/admin/bookings/[id]/page.tsx) |
| 2.5 | Bulk reconcile: `/admin/payments/stale` — list Payment AWAITING > 6 jam | ⏳ | Deferred — admin bisa filter manual via list saat itu butuh, tidak ada incident concrete yang minta bulk. |
**Tindakan manual yang masih perlu dilakukan ops:**
1. Brief admin: kapan pakai tombol "Reconcile Midtrans" — saat peserta lapor "sudah bayar tapi status belum update".
2. Tombol idempotent — aman ditekan berkali-kali. Tidak menggandakan payment.
3. Pakai `RawCallbackViewer` untuk inspeksi error gateway / metadata transaksi saat investigasi dispute.
---
## Phase 3 — Dispute & Chargeback Tracking ⏳ (deferred)
Lihat versi awal di [ADMIN_ROADMAP.md](../../ADMIN_ROADMAP.md). Akan diangkat kembali kalau volume chargeback membesar.
@@ -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.
+52
View File
@@ -0,0 +1,52 @@
# Setrip — Admin Trip Operations Roadmap (ARCHIVED — DELIVERED 2026-05-18)
Admin perlu visibilitas penuh atas trip dan bisa intervensi (cancel + auto-refund) saat organizer unreachable atau ada masalah safety.
> **Skenario nyata:** peserta lapor trip berjalan tidak sesuai itinerary. Organizer tidak responsif. Hari berikutnya peserta minta refund. Saat ini admin harus refund satu-satu manual via `/admin/refunds` tanpa konteks trip atau cara cancel trip-nya.
---
## Status delivery
| Phase | Status | Catatan |
|---|---|---|
| Phase 1 — List + Detail View | ✅ Delivered | Filter dasar `status` + search `q`. Filter advanced (date range, organizer dropdown, kategori) belum dipakai — bisa ditambah di iterasi berikut kalau ada kebutuhan konkret. |
| Phase 2 — Admin Force-Cancel | ✅ Delivered | `tripService.closeTrip` di-refactor terima actor union (ORGANIZER \| ADMIN). Migration menambah `Trip.cancelledByAdminId` + `cancelledReason`. |
| Phase 3 — Trip Edit Override | ⏳ Deferred | Opsional, skip MVP. Evaluasi ulang kalau ada keluhan konkret. |
---
## Phase 1 — Trip List + Detail View ✅
| # | Item | Status | File |
|---|---|---|---|
| 1.1 | `tripRepo.searchForAdmin({ q?, status? })` | ✅ | [server/repositories/trip.repo.ts](../../server/repositories/trip.repo.ts) |
| 1.2 | Page `/admin/trips` — list + tab status (ALL/OPEN/FULL/CLOSED/COMPLETED) + search bar | ✅ | [app/admin/trips/page.tsx](../../app/admin/trips/page.tsx) |
| 1.3 | Filter: tanggal range, organizer dropdown, kategori | ⏳ | Deferred — search `q` sudah cover sebagian (search organizer name/email). |
| 1.4 | Page `/admin/trips/[id]` — full detail (trip core + itinerary items + participants) | ✅ | [app/admin/trips/[id]/page.tsx](../../app/admin/trips/[id]/page.tsx) |
| 1.5 | Stat cards: kapasitas, confirmed, pending, cancelled | ✅ | [app/admin/trips/[id]/page.tsx](../../app/admin/trips/[id]/page.tsx) |
| 1.6 | Link "Trips" di admin navbar | ✅ | [components/admin/admin-sidebar.tsx](../../components/admin/admin-sidebar.tsx) |
---
## Phase 2 — Admin Force-Cancel Trip ✅
| # | Item | Status | File |
|---|---|---|---|
| 2.1 | Migration: `cancelledByAdminId` (FK User, ON DELETE SET NULL) + `cancelledReason` | ✅ | `prisma/migrations/20260518150000_add_trip_admin_cancel/` |
| 2.2 | Refactor `tripService.closeTrip` terima `actor: { type: "ORGANIZER" \| "ADMIN", ... }` | ✅ | [server/services/trip.service.ts](../../server/services/trip.service.ts) |
| 2.3 | Server action `adminCancelTripAction(tripId, reason)` — guard isAdmin, reason min 10 char | ✅ | [features/trip/actions.ts](../../features/trip/actions.ts) |
| 2.4 | UI: tombol "Cancel Trip (Admin)" di `/admin/trips/[id]` dengan modal reason wajib + summary impact | ✅ | [features/trip/components/admin-cancel-trip-button.tsx](../../features/trip/components/admin-cancel-trip-button.tsx) |
| 2.5 | Badge "Dibatalkan admin" + reason di public trip detail | ⏳ | Deferred — kolom DB sudah ada, tinggal tambah UI saat ada kebutuhan transparansi. |
**Tindakan manual yang sudah dilakukan:** none — admin tinggal pakai.
**Tindakan manual yang masih perlu dilakukan ops:**
1. Jalankan migration di production: `npx prisma migrate deploy`.
2. Brief admin: kriteria reason yang valid (organizer unreachable >7 hari, dispute peserta tidak terselesaikan, safety issue). Reason wajib min 10 karakter untuk audit.
---
## Phase 3 — Trip Edit Override ⏳ (deferred)
Skip MVP. Lihat versi awal di [ADMIN_ROADMAP.md](../../ADMIN_ROADMAP.md) kalau perlu re-open.
+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>
);
}
+66 -32
View File
@@ -2,30 +2,12 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { tripService } from "@/server/services/trip.service";
import { paymentService } from "@/server/services/payment.service";
import { bookingService } from "@/server/services/booking.service";
import { refundService } from "@/server/services/refund.service";
import { absoluteUrl } from "@/lib/site";
import { revalidatePath } from "next/cache";
export async function markParticipantPaidAction(tripId: string) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return { error: "Kamu harus login terlebih dahulu" };
}
try {
await tripService.markParticipantPayment(tripId, session.user.id);
revalidatePath(`/trips/${tripId}`);
revalidatePath("/trips");
revalidatePath("/");
revalidatePath("/profile");
return { success: true };
} catch (err) {
return { error: (err as Error).message };
}
}
export type StartMidtransResponse =
| { error: string }
| {
@@ -33,6 +15,7 @@ export type StartMidtransResponse =
snapToken: string;
snapJsUrl: string;
clientKey: string;
orderId: string;
};
/**
@@ -58,39 +41,90 @@ export async function startMidtransPaymentAction(
const result = await paymentService.startMidtransPayment(
booking.id,
session.user.id
session.user.id,
{ finishUrl: absoluteUrl(`/trips/${tripId}/payment`) }
);
return {
success: true,
snapToken: result.snapToken,
snapJsUrl: result.snapJsUrl,
clientKey: result.clientKey,
orderId: result.orderId,
};
} catch (err) {
return { error: (err as Error).message };
}
}
export async function confirmParticipantPaymentAction(
tripId: string,
participantId: string
) {
/**
* Admin variant reconcile — skip ownership check, dipakai dari panel admin
* `/admin/bookings/[id]` saat investigasi.
*/
export async function adminReconcileMidtransAction(orderId: string) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return { error: "Kamu harus login terlebih dahulu" };
}
const { isAdminEmail } = await import("@/lib/admin");
if (!isAdminEmail(session.user.email)) {
return { error: "Hanya admin yang bisa melakukan aksi ini" };
}
if (!orderId || typeof orderId !== "string") {
return { error: "order_id tidak valid" };
}
try {
await tripService.confirmParticipantPayment(
tripId,
participantId,
const result = await paymentService.adminReconcile(orderId);
if (!result.ok) {
if (result.reason === "not_found") {
return { error: "Order tidak ditemukan di sistem" };
}
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 };
} catch (err) {
return { error: (err as Error).message };
}
}
/**
* Tarik status terkini dari Midtrans untuk satu order, lalu sinkron ke DB.
* Dipakai oleh payment page saat user kembali dari Snap (redirect bawa
* `?order_id=...`), dan oleh `MidtransPayButton` di callback `onSuccess`/
* `onPending`/`onClose` agar UI ter-update tanpa menunggu webhook.
*/
export async function reconcileMidtransPaymentAction(orderId: string) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return { error: "Kamu harus login terlebih dahulu" };
}
if (!orderId || typeof orderId !== "string") {
return { error: "order_id tidak valid" };
}
try {
const result = await paymentService.reconcileFromGateway(
orderId,
session.user.id
);
revalidatePath(`/trips/${tripId}`);
revalidatePath("/trips");
revalidatePath("/");
revalidatePath("/profile");
return { success: true };
if (!result.ok) {
if (result.reason === "forbidden") {
return { error: "Order ini bukan milikmu" };
}
if (result.reason === "not_found") {
return { error: "Order tidak ditemukan" };
}
return { error: "Status pembayaran tidak cocok dengan tagihan" };
}
return { success: true as const, status: result.status };
} catch (err) {
return { error: (err as Error).message };
}
@@ -0,0 +1,76 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Check } from "lucide-react";
import { adminReconcileMidtransAction } from "@/features/booking/actions";
interface AdminReconcileButtonProps {
orderId: string;
disabled?: boolean;
}
export function AdminReconcileButton({
orderId,
disabled,
}: AdminReconcileButtonProps) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState("");
async function handleClick() {
setLoading(true);
setError("");
setStatus(null);
const res = await adminReconcileMidtransAction(orderId);
setLoading(false);
if ("error" in res && res.error) {
setError(res.error);
return;
}
if ("status" in res && res.status) {
setStatus(res.status);
}
router.refresh();
}
return (
<div className="inline-flex flex-col items-end gap-1">
<button
type="button"
onClick={handleClick}
disabled={loading || disabled}
className="rounded-lg bg-secondary-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-secondary-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? "Reconciling..." : "Reconcile Midtrans"}
</button>
{status && (
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-emerald-700">
<Check size={12} strokeWidth={2.5} aria-hidden />
{reconcileOutcomeLabel(status)}
</span>
)}
{error && (
<span className="text-[11px] font-medium text-red-600">{error}</span>
)}
</div>
);
}
function reconcileOutcomeLabel(status: string): string {
switch (status) {
case "updated":
return "Status di-update";
case "skipped":
return "Sudah final (tidak ada perubahan)";
case "ignored":
return "Tidak dikenali (mungkin sudah dihapus)";
case "booking_conflict":
return "Gateway PAID tapi booking di state konflik — perlu review manual";
case "not_found":
return "Order tidak ditemukan di Midtrans";
default:
return status;
}
}
@@ -2,6 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { CircleAlert } from "lucide-react";
import { cancelBookingWithRefundAction } from "@/features/booking/actions";
import { formatRupiah } from "@/lib/utils";
@@ -112,9 +113,17 @@ export function CancelBookingButton({ tripId, preview }: CancelBookingButtonProp
Tier: {preview.tierLabel}
</p>
{noRefund ? (
<p className="mt-2 text-xs text-red-700">
Di luar window refund uang tidak dikembalikan. Booking akan
<p className="mt-2 flex items-start gap-1.5 text-xs text-red-700">
<CircleAlert
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 className="mt-2 text-xs text-neutral-600">
+13 -2
View File
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { Check, Copy } from "lucide-react";
interface CopyButtonProps {
value: string;
@@ -24,9 +25,19 @@ export function CopyButton({ value, label = "Salin" }: CopyButtonProps) {
<button
type="button"
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>
);
}
@@ -1,50 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { markParticipantPaidAction } from "@/features/booking/actions";
interface MarkPaidButtonProps {
tripId: string;
disabled?: boolean;
}
export function MarkPaidButton({ tripId, disabled }: MarkPaidButtonProps) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function handleClick() {
setLoading(true);
setError("");
const result = await markParticipantPaidAction(tripId);
setLoading(false);
if (result.error) {
setError(result.error);
return;
}
router.refresh();
}
return (
<div>
{error && (
<div className="mb-3 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600">
{error}
</div>
)}
<button
type="button"
onClick={handleClick}
disabled={loading || disabled}
className="w-full rounded-xl bg-primary-600 py-3 text-sm font-bold text-white shadow-lg shadow-primary-600/20 transition-colors hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? "Memproses..." : "Saya sudah bayar"}
</button>
<p className="mt-2 text-center text-[11px] text-neutral-500">
Tekan setelah kamu transfer ke rekening di atas. Organizer akan cek &
konfirmasi pembayaran kamu.
</p>
</div>
);
}
@@ -2,7 +2,10 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { startMidtransPaymentAction } from "@/features/booking/actions";
import {
reconcileMidtransPaymentAction,
startMidtransPaymentAction,
} from "@/features/booking/actions";
interface SnapCallbacks {
onSuccess?: (result: unknown) => void;
@@ -86,23 +89,25 @@ export function MidtransPayButton({ tripId }: MidtransPayButtonProps) {
return;
}
window.snap.pay(result.snapToken, {
onSuccess: () => {
// Webhook server akan tetap jadi sumber kebenaran. Refresh page untuk pull state baru.
const orderId = result.orderId;
// Tarik status terkini dari Midtrans server-side, lalu refresh halaman.
// Tidak menunggu webhook supaya UI ter-update saat webhook belum sampai
// (mis. di localhost) atau redirect flow di mana popup tidak dipakai.
async function reconcileAndRefresh() {
await reconcileMidtransPaymentAction(orderId);
router.refresh();
},
onPending: () => router.refresh(),
}
window.snap.pay(result.snapToken, {
onSuccess: reconcileAndRefresh,
onPending: reconcileAndRefresh,
onError: () => {
setError(
"Pembayaran gagal diproses. Coba lagi atau pakai metode lain."
);
router.refresh();
},
onClose: () => {
// User menutup popup tanpa menyelesaikan. Refresh saja, kalau status berubah
// (mis. user sudah bayar VA) callback dari Midtrans akan datang ke webhook.
router.refresh();
void reconcileAndRefresh();
},
onClose: reconcileAndRefresh,
});
setLoading(false);
@@ -1,102 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import { confirmParticipantPaymentAction } from "@/features/booking/actions";
export interface PaymentPendingParticipant {
id: string;
user: { name: string; image: string | null };
/** PENDING atau CONFIRMED (join) — keduanya bisa sudah tandai bayar */
joinStatus: "PENDING" | "CONFIRMED";
}
interface OrganizerPaymentQueueProps {
tripId: string;
items: PaymentPendingParticipant[];
}
export function OrganizerPaymentQueue({
tripId,
items,
}: OrganizerPaymentQueueProps) {
const router = useRouter();
const [loadingId, setLoadingId] = useState<string | null>(null);
const [error, setError] = useState("");
async function confirm(participantId: string) {
setLoadingId(participantId);
setError("");
const result = await confirmParticipantPaymentAction(tripId, participantId);
setLoadingId(null);
if (result.error) {
setError(result.error);
return;
}
router.refresh();
}
return (
<div className="rounded-xl border border-primary-200 bg-primary-50/60 p-4 sm:p-5">
<h2 className="text-sm font-bold text-primary-950 sm:text-base">
Konfirmasi pembayaran ({items.length})
</h2>
<p className="mt-1 text-xs text-primary-900/85 sm:text-sm">
Peserta sudah menandai pembayaran. Cek rekening atau bukti transfer,
lalu konfirmasi.
</p>
{error && (
<p className="mt-3 rounded-lg bg-red-50 px-3 py-2 text-xs font-medium text-red-700 sm:text-sm">
{error}
</p>
)}
<ul className="mt-4 space-y-3">
{items.map((p) => (
<li
key={p.id}
className="flex flex-col gap-3 rounded-xl border border-primary-100 bg-white/95 p-3 sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex min-w-0 items-center gap-3">
{p.user.image ? (
<Image
src={p.user.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">
{p.user.name.charAt(0).toUpperCase()}
</div>
)}
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-neutral-800">
{p.user.name}
</p>
<p className="text-xs text-primary-800/90">
Menunggu konfirmasi pembayaran
{p.joinStatus === "PENDING" && (
<span className="text-neutral-500">
{" "}
· belum disetujui ikut trip
</span>
)}
</p>
</div>
</div>
<button
type="button"
disabled={loadingId !== null}
onClick={() => confirm(p.id)}
className="shrink-0 rounded-lg bg-primary-600 px-4 py-2 text-xs font-semibold text-white shadow-sm hover:bg-primary-700 disabled:opacity-50 sm:text-sm"
>
{loadingId === p.id ? "Memproses…" : "Konfirmasi pembayaran"}
</button>
</li>
))}
</ul>
</div>
);
}
@@ -0,0 +1,36 @@
"use client";
import { useState } from "react";
interface RawCallbackViewerProps {
payload: unknown;
}
export function RawCallbackViewer({ payload }: RawCallbackViewerProps) {
const [open, setOpen] = useState(false);
if (payload == null) {
return (
<p className="text-[11px] italic text-neutral-400">
Belum ada callback dari gateway.
</p>
);
}
return (
<div>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="text-[11px] font-semibold text-secondary-700 hover:text-secondary-900"
>
{open ? "▼ Sembunyikan raw callback" : "▶ Lihat raw callback JSON"}
</button>
{open && (
<pre className="mt-2 max-h-96 overflow-auto rounded-lg border border-neutral-200 bg-neutral-50 p-3 text-[11px] leading-relaxed text-neutral-700">
{JSON.stringify(payload, null, 2)}
</pre>
)}
</div>
);
}
+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 { authOptions } from "@/lib/auth";
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";
export async function submitVerificationAction(formData: FormData) {
@@ -35,6 +44,7 @@ export async function submitVerificationAction(formData: FormData) {
...result.data,
birthDate: new Date(result.data.birthDate),
});
void notifyKycSubmitted(session.user.id);
revalidatePath("/verify");
revalidatePath("/profile");
revalidatePath("/admin/verifications");
@@ -68,6 +78,23 @@ export async function reviewVerificationAction(formData: FormData) {
rejectionReason: result.data.rejectionReason,
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("/verify");
revalidatePath("/profile");
@@ -76,3 +103,229 @@ export async function reviewVerificationAction(formData: FormData) {
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 { 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 = {
id: string;
@@ -33,10 +46,42 @@ function formatDate(d: Date): string {
export function ReviewCard({ verification }: { verification: Verification }) {
const router = useRouter();
const [showReject, setShowReject] = useState(false);
const [showReopen, setShowReopen] = useState(false);
const [showReupload, setShowReupload] = useState(false);
const [rejectionReason, setRejectionReason] = useState("");
const [reopenNote, setReopenNote] = useState("");
const [reuploadNote, setReuploadNote] = useState("");
const [reuploadFields, setReuploadFields] = useState<string[]>([]);
const [error, setError] = useState("");
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") {
setError("");
setLoading(true);
@@ -55,6 +100,20 @@ export function ReviewCard({ verification }: { verification: Verification }) {
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 (
<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">
@@ -110,6 +169,63 @@ export function ReviewCard({ verification }: { verification: Verification }) {
</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" && (
<div className="mt-5 border-t border-neutral-100 pt-4">
{error && (
@@ -117,26 +233,7 @@ export function ReviewCard({ verification }: { verification: Verification }) {
{error}
</div>
)}
{!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>
) : (
{showReject ? (
<div className="space-y-2">
<textarea
value={rejectionReason}
@@ -166,6 +263,105 @@ export function ReviewCard({ verification }: { verification: Verification }) {
</button>
</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>
)}
+59 -16
View File
@@ -2,7 +2,9 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { IdCard, Image as ImageIcon, Landmark, Check } from "lucide-react";
import { submitVerificationAction } from "@/features/organizer/actions";
import { DateField } from "@/components/shared/date-picker";
type Initial = {
fullName: string;
@@ -21,23 +23,31 @@ type UploadKind = "ktp" | "liveness";
const ACCEPT_MIME = "image/jpeg,image/png,image/webp";
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 }) {
const router = useRouter();
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [ktpKey, setKtpKey] = useState(initial?.ktpImageKey ?? "");
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>) {
e.preventDefault();
setError("");
if (!birthDate) {
setError("Tanggal lahir wajib diisi");
return;
}
if (!ktpKey || !livenessKey) {
setError("Foto KTP dan foto memegang kertas SETRIP wajib diunggah");
return;
@@ -70,7 +80,15 @@ export function VerifyForm({ initial }: { initial: Initial }) {
)}
<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="sm:col-span-2">
<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>
<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
</label>
<input
<DateField
id="birthDate"
name="birthDate"
type="date"
value={birthDate}
onChange={setBirthDate}
maxDate={new Date()}
withMonthYearDropdown
required
defaultValue={initial ? toYmd(new Date(initial.birthDate)) : ""}
className={inputCls}
placeholder="Pilih tanggal lahir"
/>
</div>
<div className="sm:col-span-2">
@@ -130,7 +154,15 @@ export function VerifyForm({ initial }: { initial: Initial }) {
</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">
Foto disimpan terenkripsi di server SeTrip dan hanya bisa dilihat oleh
tim admin saat review. Maks 5MB, JPG/PNG/WebP.
@@ -163,7 +195,15 @@ export function VerifyForm({ initial }: { initial: Initial }) {
</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>
<label className="mb-1.5 block text-sm font-semibold text-neutral-700">
@@ -297,7 +337,10 @@ function FileUpload({
/>
</label>
{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>
{previewUrl && (
+73
View File
@@ -0,0 +1,73 @@
"use server";
import { getServerSession } from "next-auth";
import { revalidatePath } from "next/cache";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
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";
async function requireAdmin() {
const session = await getServerSession(authOptions);
if (!session?.user || !isAdminEmail(session.user.email)) {
return null;
}
return session.user;
}
export async function markPayoutPaidAction(formData: FormData) {
const admin = await requireAdmin();
if (!admin) return { error: "Tidak memiliki akses admin" };
const parsed = payoutMarkPaidSchema.safeParse({
payoutId: formData.get("payoutId") as string,
adminNote: (formData.get("adminNote") as string) ?? "",
});
if (!parsed.success) {
return { error: parsed.error.issues[0].message };
}
try {
await payoutService.markPaid({
payoutId: parsed.data.payoutId,
adminId: admin.id,
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");
revalidatePath("/profile");
return { success: true };
} catch (err) {
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,
},
},
});
}

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