Compare commits
49 Commits
ecd4dc2ef4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 88353d5d06 | |||
| 0e7bb07772 | |||
| 3268a6284e | |||
| 73406d0b86 | |||
| 4c449a572a | |||
| 9022f983a2 | |||
| 6b8f9dec5d | |||
| e6a032e8e0 | |||
| 81a0c2c6c8 | |||
| 03887fb1cd | |||
| f84d0e3726 | |||
| 22e66ce493 | |||
| d4e5d6be38 | |||
| b4d39d86ae | |||
| ef7aa528d4 | |||
| 5d095151e4 | |||
| db71159613 | |||
| cb03967deb | |||
| 306396ae43 | |||
| b836d08b10 | |||
| 57f7764bf5 | |||
| da217c2946 | |||
| 43ea725107 | |||
| 1200bf85c2 | |||
| d5842b984b | |||
| bf5c97c442 | |||
| f0ce22bbb8 | |||
| bc4973a594 | |||
| b844ebdfac | |||
| ea63f56e97 | |||
| 244a6da9bb | |||
| 6e02f2f0d7 | |||
| c52b12daad | |||
| 4bcb93e283 | |||
| e1966b69f1 | |||
| c4efe4453b | |||
| b599d01eea | |||
| 958514d575 | |||
| a07942c4b4 | |||
| 427bfc0447 | |||
| 54f4569107 | |||
| d2b0a780d5 | |||
| 22e1e8fbea | |||
| 11b2d45d20 | |||
| 744ee3446b | |||
| 9a163c4f13 | |||
| 54cd984a7e | |||
| 5e0232d909 | |||
| 68ffaf2f69 |
@@ -2,7 +2,16 @@
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"WebFetch(domain:unsplash.com)",
|
||||
"Bash(npx prisma *)"
|
||||
"Bash(npx prisma *)",
|
||||
"Bash(Get-ChildItem -Path \"c:\\\\development\\\\DIOS\\\\weekly-project\\\\setrip\" -Force)",
|
||||
"Bash(Select-Object Name, PSIsContainer)",
|
||||
"Bash(npx tsc *)",
|
||||
"Bash(echo \"exitcode=$?\")",
|
||||
"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)",
|
||||
"Bash(npx eslint *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
DATABASE_URL="postgresql://setrip_user:setrip_password@localhost:5432/setrip_db"
|
||||
NEXTAUTH_SECRET="3GaP/mqi1IYbafyLfyI54ouPRDE0IUK5vFqpKJQM5hg="
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
NEXT_PUBLIC_SITE_URL="https://arifal.imola.ai"
|
||||
ADMIN_EMAILS=admin@setrip.id
|
||||
|
||||
# 32-byte key (hex) for AES-256-GCM encryption of KYC data (NIK + KTP/liveness files)
|
||||
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
KYC_ENCRYPTION_KEY=
|
||||
# 32-byte hex secret used as HMAC pepper for NIK uniqueness lookup
|
||||
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
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"
|
||||
|
||||
# === Midtrans payment gateway (Phase C) ===
|
||||
# Server key dari dashboard Midtrans (sandbox: SB-Mid-server-..., production: Mid-server-...).
|
||||
# RAHASIA — server-side only, jangan commit nilai aslinya.
|
||||
MIDTRANS_SERVER_KEY=
|
||||
# Client key untuk init Snap.js di browser (sandbox: SB-Mid-client-..., production: Mid-client-...).
|
||||
# Aman diekspos via NEXT_PUBLIC_ — bukan rahasia.
|
||||
NEXT_PUBLIC_MIDTRANS_CLIENT_KEY=
|
||||
# 'true' untuk production, 'false' atau kosong untuk sandbox.
|
||||
# Dibaca di server (untuk Snap API endpoint) DAN client (untuk Snap.js URL).
|
||||
NEXT_PUBLIC_MIDTRANS_IS_PRODUCTION=false
|
||||
# Webhook URL di Midtrans dashboard harus diset ke: <NEXT_PUBLIC_SITE_URL>/api/webhooks/midtrans
|
||||
|
||||
|
||||
# === Cron jobs (auto-complete trip, dst) ===
|
||||
# Bearer token yang harus di-kirim cron eksternal (system crontab / Vercel Cron / dst)
|
||||
# saat memanggil endpoint `/api/cron/*`. Kalau kosong, endpoint hard-fail 500.
|
||||
# Generate ≥32-byte hex secret:
|
||||
# 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>"
|
||||
+9
-2
@@ -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
|
||||
@@ -42,3 +46,6 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
#prisma
|
||||
**/generated/prisma
|
||||
@@ -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}`.
|
||||
@@ -16,8 +16,8 @@
|
||||
## Forbidden
|
||||
|
||||
- Jangan query database langsung di component
|
||||
- Jangan buat arsitektur over-engineered
|
||||
- Jangan menambahkan dependency tanpa kebutuhan jelas
|
||||
- Jangan buat arsitektur over-engineered, tidak apa apa jika lebih baik untuk performance dan struktur yang baik
|
||||
- Jangan menambahkan dependency tanpa kebutuhan jelas, tambahkan jika memang dibutuhkan dan gunakan dependency yang aman
|
||||
|
||||
## Output Style
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -1,250 +0,0 @@
|
||||
# Setrip — Payment Roadmap
|
||||
|
||||
Rencana implementasi flow pembayaran: handle trip gratis, halaman detail payment, dan integrasi Midtrans.
|
||||
|
||||
> **Prinsip:** Sumber kebenaran pembayaran = record di tabel khusus, bukan timestamp di `TripParticipant`. Manual transfer & Midtrans = dua provider dari pipeline yang sama. Trip gratis = first-class case (bukan paid trip dengan amount 0).
|
||||
|
||||
---
|
||||
|
||||
## Audit state sekarang (baseline)
|
||||
|
||||
- Field `TripParticipant.markedPaidAt` + `paymentConfirmedAt` — dual-timestamp manual.
|
||||
- Flow: user klik "Saya sudah bayar" → organizer klik "Konfirmasi pembayaran". Tidak ada record transaksi terpisah, tidak ada bukti transfer, tidak ada amount snapshot.
|
||||
- Tidak ada halaman `/trips/[id]/payment`. Tombol bayar inline di trip detail.
|
||||
- **Bug**: trip gratis (`price === 0`) tetap melewati flow yang sama — peserta tetap harus klik "Saya sudah bayar" dan organizer tetap harus konfirmasi.
|
||||
|
||||
File terkait: [server/services/trip.service.ts](server/services/trip.service.ts) (`markParticipantPayment`, `confirmParticipantPayment`), [features/booking/actions.ts](features/booking/actions.ts), [features/booking/components/organizer-payment-queue.tsx](features/booking/components/organizer-payment-queue.tsx), [features/trip/components/join-trip-button.tsx](features/trip/components/join-trip-button.tsx), [server/repositories/participant.repo.ts](server/repositories/participant.repo.ts).
|
||||
|
||||
---
|
||||
|
||||
## PR A — Free trip handling + halaman detail payment (manual flow) ✅
|
||||
|
||||
Selesai. `tsc --noEmit` lulus. Tanpa schema baru, tanpa migration.
|
||||
|
||||
**Keputusan akses control:** halaman `/trips/[id]/payment` boleh diakses peserta apapun yang aktif (PENDING/CONFIRMED). Peserta PENDING bisa lihat nominal + rekening untuk persiapan, tapi diberi notice "tunggu approve dulu sebelum transfer". Organizer trip-nya sendiri di-redirect ke trip detail.
|
||||
|
||||
| # | Item | Status | File |
|
||||
|---|---|---|---|
|
||||
| A1 | Helper `lib/trip-pricing.ts` dengan `isFreeTrip` & `isPaidTrip` | ✅ | [lib/trip-pricing.ts](lib/trip-pricing.ts) |
|
||||
| A2 | Service guard: `markParticipantPayment` & `confirmParticipantPayment` reject trip gratis | ✅ | [server/services/trip.service.ts](server/services/trip.service.ts) |
|
||||
| A3 | UI gate di JoinTripButton: hide flow pembayaran kalau gratis | ✅ | [features/trip/components/join-trip-button.tsx](features/trip/components/join-trip-button.tsx) |
|
||||
| A4 | UI gate di trip detail: skip `OrganizerPaymentQueue` kalau gratis | ✅ | [app/trips/[id]/page.tsx](app/trips/[id]/page.tsx) |
|
||||
| A5 | Halaman `/trips/[id]/payment` (server component dengan akses kontrol) | ✅ | [app/trips/[id]/payment/page.tsx](app/trips/%5Bid%5D/payment/page.tsx) |
|
||||
| A6 | Konten halaman trip gratis: banner 🎉 + status keikutsertaan + CTA | ✅ | `FreeTripSection` di [page.tsx](app/trips/%5Bid%5D/payment/page.tsx) |
|
||||
| A7 | Konten halaman trip berbayar: timeline + rekening organizer + tombol "Saya sudah bayar" + status | ✅ | `PaidTripSection`, `PaymentTimeline`, `BankRow` di [page.tsx](app/trips/%5Bid%5D/payment/page.tsx) |
|
||||
| A8 | Link dari trip detail → `/trips/[id]/payment` (replace tombol inline) | ✅ | `showPaymentLink` di [join-trip-button.tsx](features/trip/components/join-trip-button.tsx) |
|
||||
| A9 | Metadata + `robots: noindex` halaman payment | ✅ | [page.tsx](app/trips/%5Bid%5D/payment/page.tsx) — `metadata.robots: { index: false, follow: false }` |
|
||||
| A+ | `MarkPaidButton` (client component) untuk action "Saya sudah bayar" | ✅ | [features/booking/components/mark-paid-button.tsx](features/booking/components/mark-paid-button.tsx) |
|
||||
| A+ | `CopyButton` (client component) untuk copy nomor rekening / nominal | ✅ | [features/booking/components/copy-button.tsx](features/booking/components/copy-button.tsx) |
|
||||
|
||||
**Tindakan manual:** tidak ada. Tidak ada migration di PR A.
|
||||
|
||||
**Catatan edge case yang sudah dihandle:**
|
||||
- Organizer trip-nya sendiri buka halaman → redirect ke trip detail.
|
||||
- User belum login → redirect ke login dengan `callbackUrl`.
|
||||
- User belum join / sudah cancel → tampil notice "kamu belum terdaftar".
|
||||
- Trip dengan organizer yang belum APPROVED verifikasinya → tampil notice "rekening belum tersedia, hubungi organizer langsung" (tidak crash).
|
||||
- Peserta PENDING di-warning "tunggu approve dulu sebelum transfer".
|
||||
- Tombol "Saya sudah bayar" hanya muncul untuk CONFIRMED + belum mark + bank tersedia.
|
||||
|
||||
---
|
||||
|
||||
## PR B — Refactor schema ke `Booking` + `Payment` (provider MANUAL only) ✅
|
||||
|
||||
Selesai. `tsc --noEmit` lulus. Pondasi untuk Midtrans sudah siap.
|
||||
|
||||
### Schema (target)
|
||||
|
||||
```prisma
|
||||
model Booking {
|
||||
id String @id @default(cuid())
|
||||
tripId String
|
||||
trip Trip @relation(fields: [tripId], references: [id])
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
participantId String @unique
|
||||
participant TripParticipant @relation(fields: [participantId], references: [id])
|
||||
|
||||
/// Snapshot harga saat booking dibuat — protect dari perubahan trip.price.
|
||||
amount Int
|
||||
currency String @default("IDR")
|
||||
status BookingStatus @default(PENDING)
|
||||
|
||||
payments Payment[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([tripId, status])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
enum BookingStatus {
|
||||
PENDING // join belum approve atau belum bayar
|
||||
AWAITING_PAY // approved, tinggal bayar
|
||||
PAID // lunas (manual confirm atau gateway settlement)
|
||||
CANCELLED
|
||||
REFUNDED
|
||||
EXPIRED
|
||||
}
|
||||
|
||||
model Payment {
|
||||
id String @id @default(cuid())
|
||||
bookingId String
|
||||
booking Booking @relation(fields: [bookingId], references: [id])
|
||||
|
||||
provider PaymentProvider
|
||||
/// order_id eksternal — untuk MANUAL referensi internal, untuk MIDTRANS dikirim ke gateway. Unik per attempt.
|
||||
externalOrderId String @unique
|
||||
externalTxId String?
|
||||
method String? // bca_va, gopay, qris, manual_transfer, dst
|
||||
amount Int
|
||||
status PaymentStatus @default(PENDING)
|
||||
|
||||
rawCallback Json?
|
||||
snapToken String?
|
||||
expiresAt DateTime?
|
||||
|
||||
paidAt DateTime?
|
||||
failedAt DateTime?
|
||||
rejectionReason String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([bookingId, status])
|
||||
@@index([provider, status])
|
||||
}
|
||||
|
||||
enum PaymentProvider {
|
||||
MANUAL
|
||||
MIDTRANS
|
||||
}
|
||||
|
||||
enum PaymentStatus {
|
||||
PENDING
|
||||
AWAITING
|
||||
PAID
|
||||
FAILED
|
||||
EXPIRED
|
||||
CANCELLED
|
||||
REFUNDED
|
||||
}
|
||||
```
|
||||
|
||||
### Tugas
|
||||
|
||||
| # | Item | Status | File |
|
||||
|---|---|---|---|
|
||||
| B1 | Schema: tambah `Booking`, `Payment`, 3 enum (`BookingStatus`, `PaymentProvider`, `PaymentStatus`) + relasi di `User`, `Trip`, `TripParticipant` | ✅ | [prisma/schema.prisma](prisma/schema.prisma) |
|
||||
| B2 | Migration `add_booking_payment` (CreateEnum + CreateTable + Index + FK) | ✅ | [prisma/migrations/20260508150000_add_booking_payment/migration.sql](prisma/migrations/20260508150000_add_booking_payment/migration.sql) |
|
||||
| B3 | Backfill script TS (idempotent, skip baris yang sudah punya Booking) | ✅ | [prisma/backfill-bookings.ts](prisma/backfill-bookings.ts) |
|
||||
| B4 | `bookingRepo` + `paymentRepo` | ✅ | [server/repositories/booking.repo.ts](server/repositories/booking.repo.ts), [server/repositories/payment.repo.ts](server/repositories/payment.repo.ts) |
|
||||
| B5 | `bookingService` — `markPaidManual`, `confirmPaidManual`, `getByTripAndUser`, `getAwaitingManualForTrip` (idempotent, transactional dengan retry serialisasi) | ✅ | [server/services/booking.service.ts](server/services/booking.service.ts) |
|
||||
| B6 | `tripService.markParticipantPayment` → delegate ke `bookingService.markPaidManual`. Tetap update `TripParticipant.markedPaidAt` untuk backcompat. | ✅ | [server/services/trip.service.ts](server/services/trip.service.ts) |
|
||||
| B7 | `tripService.confirmParticipantPayment` → delegate ke `bookingService.confirmPaidManual`. Tetap update `paymentConfirmedAt`. | ✅ | [server/services/trip.service.ts](server/services/trip.service.ts) |
|
||||
| B+ | `tripService.joinTrip` → upsert Booking PENDING (handle re-join dari CANCELLED) | ✅ | [server/services/trip.service.ts](server/services/trip.service.ts) |
|
||||
| B+ | `tripService.confirmParticipant` → transition Booking PENDING → AWAITING_PAY (paid) atau PAID (free) | ✅ | [server/services/trip.service.ts](server/services/trip.service.ts) |
|
||||
| B+ | `tripService.cancelJoin` & `rejectParticipant` → Booking → CANCELLED | ✅ | [server/services/trip.service.ts](server/services/trip.service.ts) |
|
||||
| B8 | Halaman `/trips/[id]/payment` baca dari Booking + Payment (bukan timestamp lama) | ✅ | [app/trips/[id]/payment/page.tsx](app/trips/%5Bid%5D/payment/page.tsx) |
|
||||
| B9 | `OrganizerPaymentQueue` di trip detail dapat data dari `bookingService.getAwaitingManualForTrip` | ✅ | [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/page.tsx) |
|
||||
| B10 | Deprecate `TripParticipant.markedPaidAt` + `paymentConfirmedAt` (komen `@deprecated`, tetap di-update untuk backcompat) | ✅ | [prisma/schema.prisma](prisma/schema.prisma) |
|
||||
| B11 | Index optimization (`@@index([tripId, status])` di Booking, `@@index([provider, status])` di Payment, `@@index([userId])` di Booking) | ✅ | [prisma/schema.prisma](prisma/schema.prisma) |
|
||||
|
||||
**Tindakan manual (urutan penting):**
|
||||
1. `npx prisma migrate deploy` — apply schema migration `20260508150000_add_booking_payment`.
|
||||
2. `npx tsx prisma/backfill-bookings.ts` — populate Booking + Payment dari `TripParticipant` lama. Idempotent, aman dijalankan ulang.
|
||||
3. Verifikasi: jumlah Booking aktif = jumlah TripParticipant aktif setelah backfill.
|
||||
|
||||
---
|
||||
|
||||
## PR C — Midtrans integration (Snap + webhook) ⏳
|
||||
|
||||
Tambah provider MIDTRANS ke pipeline yang sudah dibuat di PR B. Test di sandbox dulu.
|
||||
|
||||
### Persiapan akun & env
|
||||
|
||||
| Env | Keterangan |
|
||||
|---|---|
|
||||
| `MIDTRANS_SERVER_KEY` | Server key dari dashboard Midtrans (sandbox/production sesuai mode). Rahasia. |
|
||||
| `MIDTRANS_CLIENT_KEY` | Client key. Boleh di expose ke frontend (untuk Snap script). |
|
||||
| `MIDTRANS_IS_PRODUCTION` | `true`/`false` — pilih endpoint sandbox vs production. |
|
||||
| `MIDTRANS_NOTIFICATION_URL` | URL callback publik kita, mis. `https://setrip.id/api/webhooks/midtrans`. Didaftarkan di dashboard Midtrans. |
|
||||
|
||||
Tambah ke [env.example](env.example) dengan komentar.
|
||||
|
||||
### Tugas
|
||||
|
||||
| # | Item | Status | Catatan |
|
||||
|---|---|---|---|
|
||||
| C1 | Update [env.example](env.example) + dokumentasi env | ⏳ | 4 env baru. |
|
||||
| C2 | `lib/midtrans.ts` — client tipis: `createSnapTransaction`, `verifySignature`, `mapStatus` | ⏳ | Pakai `fetch` + `crypto.createHash('sha512')`. Tidak butuh dependency baru. |
|
||||
| C3 | Status mapping helper | ⏳ | `transaction_status` + `fraud_status` Midtrans → `PaymentStatus` internal. Tabel mapping ada di README PR ini. |
|
||||
| C4 | Service `paymentService.startMidtransPayment(bookingId)` | ⏳ | Bikin Payment row provider=MIDTRANS, kirim ke Midtrans, simpan `snapToken` + `expiresAt`. Kalau Booking sudah PAID → reject. |
|
||||
| C5 | Halaman payment: tombol "Bayar online (Midtrans)" untuk trip berbayar | ⏳ | Fallback "Transfer manual" tetap ada (provider MANUAL). User pilih sebelum lanjut. |
|
||||
| C6 | Frontend: load Snap script + invoke `window.snap.pay(token)` | ⏳ | Loaded conditional di halaman payment, bukan global. Pakai client key dari env publik. |
|
||||
| C7 | Webhook endpoint `app/api/webhooks/midtrans/route.ts` | ⏳ | POST. Verify signature (sha512). Lookup Payment by `externalOrderId`. Update idempotent. Selalu return 200. |
|
||||
| C8 | Booking status sync setelah webhook PAID | ⏳ | `Booking.status = PAID`. Sync `TripParticipant.paymentConfirmedAt` untuk kompatibilitas. Concurrency: gunakan DB transaction. |
|
||||
| C9 | Cron / scheduled job: expire Payment lama | ⏳ | Midtrans default expire 24 jam, tapi DB-side juga harus bersih supaya UI status akurat. Bisa dijalankan via Vercel cron atau manual scheduler. |
|
||||
| C10 | Anti-replay: skip kalau `Payment.status` sudah final (PAID/FAILED/EXPIRED) | ⏳ | Webhook bisa diretry oleh Midtrans. |
|
||||
| C11 | Logging callback mentah ke `Payment.rawCallback` (Json) | ⏳ | Audit & dispute. |
|
||||
| C12 | Test scenario di sandbox | ⏳ | Settlement BCA VA, gopay, deny (kartu fraud), expire, cancel. |
|
||||
| C13 | Status badge di halaman payment | ⏳ | Tampil real-time tanpa polling agresif (refresh manual atau interval longgar 10s). |
|
||||
| C14 | Email/in-app notification setelah PAID | ⏳ | Optional Phase ini, bisa Phase berikutnya. |
|
||||
|
||||
### Mapping `transaction_status` Midtrans → `PaymentStatus`
|
||||
|
||||
| Midtrans | Trigger lain | `PaymentStatus` |
|
||||
|---|---|---|
|
||||
| `capture` | `fraud_status === "accept"` | PAID |
|
||||
| `capture` | `fraud_status === "challenge"` | AWAITING (review manual di dashboard Midtrans) |
|
||||
| `settlement` | — | PAID |
|
||||
| `pending` | — | AWAITING |
|
||||
| `deny` | — | FAILED |
|
||||
| `expire` | — | EXPIRED |
|
||||
| `cancel` | — | CANCELLED |
|
||||
| `refund` / `partial_refund` | — | REFUNDED |
|
||||
|
||||
### Webhook checklist (security)
|
||||
|
||||
1. Verify signature: `sha512(order_id + status_code + gross_amount + SERVER_KEY) === signature_key`. Mismatch → 401, log.
|
||||
2. Cek `gross_amount` cocok dengan `payment.amount` — kalau tidak sama, log anomaly, jangan PAID.
|
||||
3. Lookup `Payment.externalOrderId === order_id`. Tidak ada → 200 OK + log (jangan biarkan Midtrans retry forever).
|
||||
4. Idempotent: kalau status sudah final, skip update tapi tetap return 200.
|
||||
5. Pakai DB transaction untuk update Payment + Booking + TripParticipant bersamaan.
|
||||
6. Selalu return 200 kalau request valid (mismatch signature → 401, sisanya → 200 + log).
|
||||
|
||||
### Edge cases yang gampang lupa
|
||||
|
||||
- **Quota race**: dua user bayar bersamaan untuk slot terakhir → slot harus di-hold saat Booking dibuat (status AWAITING_PAY masih hitung kuota), release otomatis saat Payment EXPIRED.
|
||||
- **Trip dibatalkan organizer setelah peserta bayar** → `Booking.status = REFUNDED` setelah dana balik. Implementasi refund Midtrans = PR terpisah (tidak di scope PR C ini).
|
||||
- **User retry pembayaran setelah gagal** → bikin Payment baru (bukan reuse), `externalOrderId` baru (`setrip-{bookingId}-{retry}`). Booking status tetap AWAITING_PAY.
|
||||
- **Webhook duplicate**: Midtrans bisa kirim notifikasi yang sama 2-3 kali. Idempotency key = `Payment.externalOrderId` + status terkini.
|
||||
- **Sandbox vs production**: simulator Midtrans akan kirim callback ke `MIDTRANS_NOTIFICATION_URL`. Pastikan URL sandbox bisa diakses publik (tunneling kalau dev lokal — ngrok / cloudflared).
|
||||
|
||||
---
|
||||
|
||||
## ❌ Anti-list (yang harus DITOLAK kalau muncul)
|
||||
|
||||
- **Halaman payment yang berubah jadi marketplace checkout**: upsell ("trip serupa lebih murah"), pricing comparison, "harga turun" — semua menarik framing OTA. Halaman payment fokus pada satu transaksi: lunas atau belum.
|
||||
- **Multi-method abstraksi prematur**: jangan bikin "PaymentProvider" generic untuk Stripe/Xendit/Doku sekaligus sebelum salah satu jalan. Mulai dari MANUAL + MIDTRANS, baru tambah kalau perlu.
|
||||
- **Auto-refund logic kompleks** sebelum manual refund di dashboard Midtrans dipakai. Refund jarang, manual cukup di Phase awal.
|
||||
- **Payment retry otomatis** dari sisi server. User harus eksplisit klik "bayar lagi" untuk attempt baru — supaya tidak ambigu siapa yang trigger.
|
||||
- **Multi-currency** sebelum ada permintaan eksplisit. `currency` di schema sudah default IDR, tapi tidak perlu UI selector.
|
||||
- **Saving credit card / tokenization** tanpa kebutuhan jelas. PCI scope naik drastis. Snap sudah handle tanpa simpan kartu di sisi kita.
|
||||
|
||||
---
|
||||
|
||||
## Saran phasing
|
||||
|
||||
PR berurutan. Setiap PR mandiri (siap di-deploy):
|
||||
|
||||
1. **PR A** — Free trip handling + halaman payment (manual). Cepat, low-risk, no migration. **Mulai dari sini.**
|
||||
2. **PR B** — Refactor ke `Booking` + `Payment`. Migration + backfill data lama. UI tetap mirip.
|
||||
3. **PR C** — Tambah Midtrans Snap. Test di sandbox dulu sebelum production.
|
||||
|
||||
Pertanyaan terbuka sebelum mulai:
|
||||
|
||||
1. **Akses halaman payment**: hanya peserta CONFIRMED, atau juga PENDING (yang belum disetujui organizer)? Saya rekomendasi CONFIRMED only — peserta PENDING belum perlu lihat detail bayar.
|
||||
2. **Snapshot amount di Booking**: kalau organizer ubah `trip.price` setelah booking dibuat, booking lama pakai harga lama atau baru? Saya rekomendasi tetap pakai snapshot lama (audit-friendly).
|
||||
3. **Manual + Midtrans co-exist**: user pilih satu provider per booking, atau bisa retry dengan provider berbeda? Saya rekomendasi pilih satu — kalau gagal di Midtrans, bisa cancel dan buat Payment baru dengan provider MANUAL.
|
||||
@@ -93,12 +93,12 @@ 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. |
|
||||
| Persetujuan T&C / Privasi | `User.acceptedTermsAndPrivacy` + `User.acceptedAt`, dicentang saat registrasi (link ke `/terms` & `/privacy`). |
|
||||
| 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. |
|
||||
| Persetujuan T&C / Privasi | `User.acceptedTermsAndPrivacy` + `User.acceptedAt`, dicentang saat registrasi (link ke `/terms` & `/privacy`). |
|
||||
|
||||
## Menjalankan secara lokal
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
# Setrip — Refund Roadmap
|
||||
|
||||
Status implementasi sistem refund yang dapat dipercaya dan auditable — dari schema, policy, sampai integrasi gateway.
|
||||
|
||||
> **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)
|
||||
|
||||
**Schema (~10% sudah ada):**
|
||||
- ✅ `BookingStatus.REFUNDED` & `PaymentStatus.REFUNDED` — enum value ada di [prisma/schema.prisma](prisma/schema.prisma).
|
||||
- ❌ Model `Refund` belum ada — refund saat ini cuma flag, tanpa entity tersendiri.
|
||||
- ❌ Tidak ada audit trail (siapa, kapan, alasan, approval).
|
||||
- ❌ Tidak bisa partial refund.
|
||||
- ❌ Tidak bisa multiple refund per booking (mis. refund deposit lalu sisa).
|
||||
- ❌ Tidak bisa bedakan refund vs chargeback (dispute bank).
|
||||
|
||||
**Service/UI (~0% sudah ada):**
|
||||
- ❌ Tidak ada `refundService`.
|
||||
- ❌ Tidak ada flow "organizer cancel trip → auto refund peserta PAID".
|
||||
- ❌ Tidak ada UI peserta untuk request cancel + refund.
|
||||
- ❌ Tidak ada UI admin untuk approve/eksekusi refund.
|
||||
- ❌ Tidak ada integrasi Midtrans Refund API.
|
||||
- ❌ Tidak ada reconciliation harian.
|
||||
|
||||
**Konteks pendukung yang sudah ada:**
|
||||
- ✅ `Booking` + `Payment` model dengan `amount` (Int, IDR — money math safe).
|
||||
- ✅ Midtrans webhook handler ([app/api/webhooks/midtrans/route.ts](app/api/webhooks/midtrans/route.ts)) — pola untuk refund webhook bisa di-mirror.
|
||||
- ✅ `bookingService` dengan transaksi serializable + retry — pola untuk `refundService`.
|
||||
- ✅ Cron infra (system crontab + `CRON_SECRET`, lihat [docs/CRON_SETUP.md](docs/CRON_SETUP.md)) — siap untuk reconciliation job.
|
||||
|
||||
File baseline: [prisma/schema.prisma](prisma/schema.prisma), [server/services/booking.service.ts](server/services/booking.service.ts), [app/api/webhooks/midtrans/route.ts](app/api/webhooks/midtrans/route.ts).
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
**Keputusan asumsi yang diusulkan:**
|
||||
- `Refund.amount` Int (IDR) — bisa < `payment.amount` untuk partial. Constraint: `SUM(refunds.amount WHERE status=SUCCEEDED) <= payment.amount` di-enforce di service layer.
|
||||
- `idempotencyKey` di-generate sekali saat Refund dibuat — dipakai saat panggil gateway nanti (R-4) supaya retry tidak double-refund.
|
||||
- `BookingStatus.REFUNDED` di-set sebagai **derived state** saat full refund SUCCEEDED. Untuk partial: tambah `BookingStatus.PARTIALLY_REFUNDED` (enum baru) — atau biarkan PAID + lihat Booking.refunds[]. **Saran: tambah PARTIALLY_REFUNDED** supaya filter list "refunded bookings" bisa pakai status saja, tidak perlu join.
|
||||
- `RefundReason` enum lengkap dari hari pertama — supaya laporan finance tidak butuh string parsing.
|
||||
- Approval admin **wajib** untuk semua refund di MVP (4-eyes principle). Auto-approve bisa di-relax di R-2 untuk SYSTEM refund.
|
||||
- Admin UI sederhana — list refund PENDING + tombol approve / mark-succeeded. Reuse pattern KYC verification UI yang sudah ada.
|
||||
|
||||
| # | 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` |
|
||||
|
||||
**Tindakan manual:**
|
||||
1. Run migration di staging → smoke test → run di production.
|
||||
2. Tambah `/admin/refunds` ke admin nav.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
**Keputusan asumsi yang diusulkan:**
|
||||
- Trigger di service `tripService.closeTrip()` (yang nge-set `status = CLOSED`). Pakai serializable transaction — close trip + create refunds atomic.
|
||||
- SYSTEM refund bisa **auto-approve** (skip admin approval) — karena policy clear: organizer cancel = 100% refund. Tapi eksekusi (mark succeeded) tetap manual atau via gateway (R-4), tidak skip.
|
||||
- Notifikasi peserta: kirim email/notif "Trip dibatalkan, refund Rp X sedang diproses". (Notification system di luar scope PR ini — assume sudah ada atau di-deferred.)
|
||||
- Edge case: peserta yang `AWAITING_PAY` (belum bayar) tidak perlu refund — cuma update `Booking.status = CANCELLED`. Yang `PAID` saja yang dapat refund.
|
||||
|
||||
| # | 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` |
|
||||
|
||||
**Tindakan manual:** tidak ada.
|
||||
|
||||
---
|
||||
|
||||
## PR-R3 — Self-Service User Cancel dengan Refund Window ✅
|
||||
|
||||
User bisa cancel booking sendiri, refund dihitung berdasarkan kebijakan default (hardcoded dulu, jangan polymorphic).
|
||||
|
||||
**Keputusan asumsi yang diusulkan:**
|
||||
- **Kebijakan default hardcoded** (akan jadi data-driven di R-5):
|
||||
- ≥7 hari sebelum berangkat → 80% refund (organizer ambil 20% admin fee)
|
||||
- 3-7 hari sebelum berangkat → 50% refund
|
||||
- <3 hari atau no-show → 0% refund (tetap create Refund record amount=0 untuk audit, atau skip — pilih skip lebih sederhana)
|
||||
- Konstanta di `lib/refund-policy.ts` — supaya satu sumber kebenaran, mudah diubah.
|
||||
- User refund **tidak auto-approve** — tetap butuh admin approval di MVP. Alasan: cegah abuse (spam cancel), dan validasi window calculation di sisi admin.
|
||||
- Setelah refund SUCCEEDED, slot di trip kembali tersedia (`status: FULL → OPEN` kalau participantCount turun).
|
||||
- UI user: tombol "Cancel & request refund" di trip detail (kalau status booking = PAID dan trip belum berangkat).
|
||||
|
||||
| # | 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) |
|
||||
|
||||
**Tindakan manual:**
|
||||
1. Tulis copy kebijakan refund untuk halaman Terms & Privacy.
|
||||
2. Tambah link kebijakan di footer.
|
||||
|
||||
---
|
||||
|
||||
## PR-R4 — Integrasi Midtrans Refund API (async, idempotent) ⏳
|
||||
|
||||
Sambungkan eksekusi refund ke Midtrans Refund API. Untuk channel yang support refund online (BCA VA, GoPay, dst). Channel manual transfer tetap mark-succeeded manual oleh admin.
|
||||
|
||||
**Keputusan asumsi yang diusulkan:**
|
||||
- Refund API Midtrans **async** — POST refund return `pending`, callback datang via webhook (extend [app/api/webhooks/midtrans/route.ts](app/api/webhooks/midtrans/route.ts) atau buat endpoint terpisah).
|
||||
- **Idempotency key** wajib di header request — pakai `Refund.idempotencyKey` yang sudah di-generate R-1.
|
||||
- State transition: APPROVED → PROCESSING (saat request dikirim) → SUCCEEDED/FAILED (via webhook).
|
||||
- Eksekusi via job queue (Vercel Cron daily) atau sync di server action — **saran cron** supaya retry-able kalau gateway down. Job: pick all APPROVED refunds, kirim ke Midtrans, update PROCESSING.
|
||||
- Validasi `Payment.method` — kalau `manual_transfer`, refund tetap manual (no gateway call). Skip Midtrans path.
|
||||
|
||||
| # | Item | Status | File |
|
||||
|---|---|---|---|
|
||||
| R4.1 | Helper `lib/midtrans-refund.ts` — POST refund dengan idempotency key | ⏳ | `lib/midtrans-refund.ts` |
|
||||
| R4.2 | `refundService.executeRefund(refundId)` — APPROVED → PROCESSING + call Midtrans | ⏳ | `server/services/refund.service.ts` |
|
||||
| R4.3 | Webhook handler refund — PROCESSING → SUCCEEDED/FAILED via callback | ⏳ | [app/api/webhooks/midtrans/route.ts](app/api/webhooks/midtrans/route.ts) |
|
||||
| R4.4 | Cron `/api/cron/execute-refunds` — pick APPROVED refunds, kirim ke gateway | ⏳ | `app/api/cron/execute-refunds/route.ts` |
|
||||
| R4.5 | Daftarkan cron `*/15 * * * *` (every 15 min) di system crontab | ⏳ | [docs/CRON_SETUP.md](docs/CRON_SETUP.md) |
|
||||
|
||||
**Tindakan manual:**
|
||||
1. Aktifkan Refund API di dashboard Midtrans (perlu request ke Midtrans support untuk channel tertentu).
|
||||
2. Test refund di sandbox dengan dummy transaction.
|
||||
3. Set webhook URL refund di Midtrans dashboard (kalau beda dari payment webhook).
|
||||
|
||||
---
|
||||
|
||||
## PR-R5 — Refund Policy Model (per-trip customization) ⏳
|
||||
|
||||
Pindah refund policy dari hardcoded ke data-driven. Organizer bisa pilih policy per-trip (mis. trip premium = strict cancellation).
|
||||
|
||||
**Keputusan asumsi yang diusulkan:**
|
||||
- Model `RefundPolicy` dengan tier predefined (FLEXIBLE, MODERATE, STRICT) — **bukan** field bebas. Mengikuti pola Airbnb. Mencegah organizer set policy yang aneh-aneh dan membingungkan peserta.
|
||||
- 1 Trip → 1 RefundPolicy (foreign key di Trip). Default ke MODERATE.
|
||||
- Setiap policy punya array `tiers` (JSON) berisi `{ minDaysBefore: number, refundPercentage: number }`.
|
||||
- Migration: existing trip default ke MODERATE.
|
||||
- UI organizer di create-trip form: dropdown pilih policy, dengan preview tier-nya.
|
||||
|
||||
| # | Item | Status | File |
|
||||
|---|---|---|---|
|
||||
| R5.1 | Model `RefundPolicy` + seed 3 tier (FLEXIBLE, MODERATE, STRICT) | ⏳ | [prisma/schema.prisma](prisma/schema.prisma) |
|
||||
| R5.2 | Foreign key `Trip.refundPolicyId` (default MODERATE) | ⏳ | [prisma/schema.prisma](prisma/schema.prisma) |
|
||||
| R5.3 | Migration + backfill existing trip ke MODERATE | ⏳ | `prisma/migrations/` |
|
||||
| R5.4 | Update `lib/refund-policy.ts` — `calculateRefundAmount` baca dari policy | ⏳ | `lib/refund-policy.ts` |
|
||||
| R5.5 | UI organizer create-trip: dropdown policy + preview | ⏳ | [features/trip/components/create-trip-form.tsx](features/trip/components/create-trip-form.tsx) |
|
||||
| R5.6 | UI trip detail: tampilkan policy aktif (link ke detail tier) | ⏳ | [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/page.tsx) |
|
||||
|
||||
**Tindakan manual:**
|
||||
1. Run migration + backfill.
|
||||
2. Update copy halaman terms — sebut 3 policy tier.
|
||||
|
||||
---
|
||||
|
||||
## PR-R6 — Reconciliation + Dispute Model (operational maturity) ⏳
|
||||
|
||||
Daily job match refund di DB vs settlement Midtrans. Model Dispute terpisah untuk chargeback/komplain pasca-refund.
|
||||
|
||||
**Keputusan asumsi yang diusulkan:**
|
||||
- Reconciliation job: pull settlement report dari Midtrans (Settlement API) → match dengan `refund.externalRefundId` → flag drift.
|
||||
- Dispute model **tertunda** sampai ada chargeback riil. Pakai `RefundReason.DISPUTE_RESOLVED` dulu di Refund model. Bikin model terpisah hanya kalau volume dispute > X per bulan.
|
||||
- Alert: kalau drift > 1% dari total refund harian, kirim notif ke admin (email/Slack).
|
||||
|
||||
| # | Item | Status | File |
|
||||
|---|---|---|---|
|
||||
| R6.1 | Helper `lib/midtrans-settlement.ts` — pull settlement report | ⏳ | `lib/midtrans-settlement.ts` |
|
||||
| R6.2 | Cron `/api/cron/reconcile-refunds` daily | ⏳ | `app/api/cron/reconcile-refunds/route.ts` |
|
||||
| R6.3 | UI admin `/admin/refunds/reconciliation` — drift report | ⏳ | `app/admin/refunds/reconciliation/page.tsx` |
|
||||
| R6.4 | (opsional, defer) Model `Dispute` + flow chargeback | ⏳ | [prisma/schema.prisma](prisma/schema.prisma) |
|
||||
|
||||
**Tindakan manual:**
|
||||
1. Set up alert channel (email atau Slack webhook).
|
||||
2. Tetapkan threshold drift (saran: 1%).
|
||||
|
||||
---
|
||||
|
||||
## ❌ Anti-list (yang harus DITOLAK kalau muncul)
|
||||
|
||||
- **Pakai `BookingStatus.REFUNDED` sebagai sumber kebenaran tanpa Refund model** — flag-only tidak bisa partial, tidak punya audit trail. Stuck di kasus pertama.
|
||||
- **Hapus Refund row kalau gagal** — never delete financial records. Set `status = FAILED` + log alasan. Audit trail wajib.
|
||||
- **Sync call ke Midtrans Refund API tanpa idempotency key** — kalau retry karena timeout atau network error → double-refund. Kerugian finansial nyata.
|
||||
- **Auto-execute refund tanpa approval admin di MVP** — fraud risk. Auto-approve OK untuk SYSTEM refund (organizer cancel = clear policy), tapi eksekusi tetap controlled. Bisa di-relax setelah volume + trust matang.
|
||||
- **Polymorphic refund policy dari awal** — lompat langsung ke data-driven sebelum hardcoded teruji = over-engineering. Phasing R-3 (hardcoded) lalu R-5 (data-driven) lebih sehat.
|
||||
- **Trigger refund di server action user-facing tanpa state machine** — user spam click → multiple refund request. Idempotency check via `(bookingId, status IN ('PENDING','APPROVED','PROCESSING'))` unique-ish.
|
||||
- **Refund partial dengan float math** — selalu integer (rupiah). Hitung % dengan `Math.floor(amount * percentage / 100)` supaya tidak ada sub-rupiah.
|
||||
- **Mention "kami akan refund X%" di UI tanpa lock policy** — kebijakan harus visible di trip detail SEBELUM user join, bukan kejutan saat cancel.
|
||||
- **Skip approval admin untuk refund di atas threshold (mis. > 5jt)** — fraud risk internal. 4-eyes principle wajib untuk nominal besar, walau policy clear.
|
||||
- **Bundle Refund + Dispute di model yang sama** — beda flow, beda inisiator (refund = merchant, dispute = bank). Separation of concern penting walau di MVP belum perlu Dispute model.
|
||||
|
||||
---
|
||||
|
||||
## Saran phasing
|
||||
|
||||
PR berurutan, masing-masing mandiri (siap di-deploy). **Don't bundle.**
|
||||
|
||||
1. **R-1** — Schema + service stub + UI admin. Foundation, blocker untuk semua PR berikut.
|
||||
2. **R-2** — Auto-trigger saat organizer CLOSED. Paling sering kepakai, low-complexity. **Mulai dari sini setelah R-1.**
|
||||
3. **R-3** — Self-service user cancel + hardcoded policy. Komplet flow user-side.
|
||||
4. **R-4** — Midtrans Refund API. Paling kompleks (async, idempotent, webhook). Bisa hidup tanpa ini selama admin willing manual transfer.
|
||||
5. **R-5** — Refund policy data-driven. Quality-of-life untuk organizer, bukan blocker.
|
||||
6. **R-6** — Reconciliation + dispute. Operational maturity, untuk volume yang lebih besar.
|
||||
|
||||
**Bobot effort kasar:** R-1 (M) → R-2 (M) → R-3 (M) → R-4 (L) → R-5 (M) → R-6 (L).
|
||||
|
||||
---
|
||||
|
||||
## Pertanyaan terbuka sebelum mulai R-1
|
||||
|
||||
1. **MVP scope** — mau prioritaskan **organizer-cancel** dulu (R-2, paling sering), atau langsung **user cancel dengan window** (R-3)? Saran: R-2 dulu — clear policy, low-complexity.
|
||||
2. **Approval flow** — semua refund butuh approval admin (lebih aman), atau auto-approve untuk SYSTEM refund? Saran: auto-approve SYSTEM, manual approve untuk USER + ADMIN_ADJUSTMENT.
|
||||
3. **Partial refund booking status** — tambah `BookingStatus.PARTIALLY_REFUNDED` (lebih eksplisit) atau biarkan tetap PAID + lihat `booking.refunds[]`? Saran: tambah enum baru — query-friendly.
|
||||
4. **Midtrans Refund API channel** — apakah anda sudah cek channel mana yang support online refund? BCA VA + GoPay biasanya support, manual_transfer pasti tidak. Cek dashboard sebelum mulai R-4.
|
||||
5. **Dispute model timing** — bikin Dispute model di R-1 (early separation) atau defer sampai R-6? Saran: defer — YAGNI sampai ada kasus chargeback riil.
|
||||
6. **Threshold approval admin** — ada nominal di atas mana refund wajib approval 2 admin (4-eyes)? Saran: > Rp 1jt butuh dual approval, < Rp 1jt single approval cukup.
|
||||
@@ -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
|
||||
- **3–6 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 1–3 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
<Link
|
||||
href="/trips"
|
||||
className="rounded-lg bg-secondary-50 px-2.5 py-1 text-xs font-medium text-secondary-600 hover:bg-secondary-100 sm:px-3 sm:py-1.5 sm:text-sm"
|
||||
>
|
||||
Lihat semua
|
||||
</Link>
|
||||
</div>
|
||||
<SectionHeading
|
||||
icon={Mountain}
|
||||
title="Open Trip"
|
||||
subtitle="Pilih trip, ketemu teman baru"
|
||||
action={
|
||||
<Link
|
||||
href="/trips"
|
||||
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>
|
||||
}
|
||||
/>
|
||||
|
||||
{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>
|
||||
}
|
||||
/>
|
||||
@@ -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 & 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 & 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>
|
||||
@@ -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);
|
||||
|
||||
@@ -6,17 +6,20 @@ import Image from "next/image";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { tripService } from "@/server/services/trip.service";
|
||||
import { bookingService } from "@/server/services/booking.service";
|
||||
import { bookingRepo } from "@/server/repositories/booking.repo";
|
||||
import { trustService } from "@/server/services/trust.service";
|
||||
import { formatRupiah } from "@/lib/utils";
|
||||
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
|
||||
import { siteConfig, siteUrl, absoluteUrl } from "@/lib/site";
|
||||
import { JoinTripButton } from "@/features/trip/components/join-trip-button";
|
||||
import { CancelTripButton } from "@/features/trip/components/cancel-trip-button";
|
||||
import { CancelBookingButton } from "@/features/booking/components/cancel-booking-button";
|
||||
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";
|
||||
import { categoryMeta } from "@/lib/activity-category";
|
||||
import { vibeMeta } from "@/lib/vibe";
|
||||
import { isFreeTrip } from "@/lib/trip-pricing";
|
||||
@@ -24,6 +27,15 @@ import {
|
||||
isPastTripLastDayForReview,
|
||||
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,
|
||||
@@ -130,12 +142,30 @@ 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 =
|
||||
session?.user && !isOrganizer && currentParticipation
|
||||
? await bookingService.getByTripAndUser(trip.id, session.user.id)
|
||||
: null;
|
||||
|
||||
// Untuk CancelTripButton: jumlah booking PAID/PARTIALLY_REFUNDED (yang akan
|
||||
// auto-refund). Hanya dihitung saat organizer mengakses trip yang masih
|
||||
// bisa dibatalkan.
|
||||
const canOrganizerCancel =
|
||||
isOrganizer &&
|
||||
(trip.status === "OPEN" || trip.status === "FULL") &&
|
||||
!isDeparturePast;
|
||||
const paidBookingCount = canOrganizerCancel
|
||||
? await bookingRepo.countSettledForTrip(trip.id)
|
||||
: 0;
|
||||
|
||||
// Preview refund untuk CancelBookingButton (server-side supaya konsisten
|
||||
// dengan service yang juga pakai policy yang sama).
|
||||
const refundPreview =
|
||||
myBooking && myBooking.status === "PAID" && !isDeparturePast
|
||||
? previewRefund(myBooking.amount, trip.date)
|
||||
: null;
|
||||
|
||||
const catMeta = categoryMeta(trip.category);
|
||||
|
||||
@@ -287,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>
|
||||
@@ -297,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>
|
||||
@@ -309,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>
|
||||
@@ -321,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>
|
||||
@@ -344,10 +394,23 @@ export default async function TripDetailPage({
|
||||
|
||||
{/* Participant Progress */}
|
||||
<div className="rounded-xl border border-neutral-200 p-3 sm:p-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-neutral-700 sm:text-sm">
|
||||
Peserta
|
||||
</span>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-neutral-700 sm:text-sm">
|
||||
Peserta
|
||||
</span>
|
||||
{spotsLeft > 0 && spotsLeft <= 3 && (
|
||||
<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 && (
|
||||
<span className="rounded-full bg-neutral-200 px-2 py-0.5 text-[10px] font-bold text-neutral-700 sm:text-[11px]">
|
||||
Penuh
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs font-bold text-neutral-800 sm:text-sm">
|
||||
{participantCount}{" "}
|
||||
<span className="font-normal text-neutral-400">
|
||||
@@ -383,11 +446,41 @@ export default async function TripDetailPage({
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{confirmedCount > 0 && (
|
||||
<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)
|
||||
.map((p) => p.user.name.split(" ")[0])
|
||||
.join(", ")}
|
||||
</span>
|
||||
{confirmedCount > 3 && (
|
||||
<span className="text-neutral-500">
|
||||
{" "}
|
||||
+{confirmedCount - 3} lainnya
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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}
|
||||
/>
|
||||
@@ -410,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,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
@@ -439,20 +520,37 @@ 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}
|
||||
hideCancelButton={!!refundPreview}
|
||||
/>
|
||||
|
||||
{/* Peserta PAID: cancel + request refund (lewat policy default). */}
|
||||
{refundPreview && (
|
||||
<CancelBookingButton
|
||||
tripId={trip.id}
|
||||
preview={{
|
||||
days: refundPreview.days,
|
||||
refundAmount: refundPreview.refundAmount,
|
||||
bookingAmount: refundPreview.bookingAmount,
|
||||
tierLabel: refundPreview.tier.label,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Organizer: batalkan trip (auto-refund peserta PAID). */}
|
||||
{canOrganizerCancel && (
|
||||
<CancelTripButton
|
||||
tripId={trip.id}
|
||||
paidParticipantCount={paidBookingCount}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Kebijakan refund — transparency sebelum user cancel. */}
|
||||
{!tripIsFree && <RefundPolicySection />}
|
||||
|
||||
<TripReviewSection
|
||||
tripId={trip.id}
|
||||
reviews={trip.reviews.map((r) => ({
|
||||
@@ -484,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">
|
||||
@@ -515,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,14 +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 { CopyButton } from "@/features/booking/components/copy-button";
|
||||
import { MidtransPayButton } from "@/features/booking/components/midtrans-pay-button";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CalendarDays,
|
||||
MapPin,
|
||||
PartyPopper,
|
||||
CircleCheck,
|
||||
Clock,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Detail Pembayaran",
|
||||
@@ -20,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) {
|
||||
@@ -36,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
|
||||
@@ -50,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">
|
||||
@@ -72,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:{" "}
|
||||
@@ -92,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>
|
||||
|
||||
@@ -103,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>
|
||||
@@ -152,17 +172,31 @@ function NotJoinedNotice({ tripId, title }: { tripId: string; title: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
type BookingStatus =
|
||||
| "PENDING"
|
||||
| "AWAITING_PAY"
|
||||
| "PAID"
|
||||
| "CANCELLED"
|
||||
| "REFUNDED"
|
||||
| "PARTIALLY_REFUNDED"
|
||||
| "EXPIRED";
|
||||
|
||||
function FreeTripSection({
|
||||
tripId,
|
||||
bookingStatus,
|
||||
}: {
|
||||
tripId: string;
|
||||
bookingStatus: "PENDING" | "AWAITING_PAY" | "PAID" | "CANCELLED" | "REFUNDED" | "EXPIRED";
|
||||
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
|
||||
@@ -175,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>
|
||||
|
||||
@@ -194,134 +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" | "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.
|
||||
<section className="rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
|
||||
<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="text-lg font-bold text-primary-700 sm:text-xl">
|
||||
{formatRupiah(price)}
|
||||
</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
|
||||
</h3>
|
||||
<p className="mb-4 text-xs text-neutral-500 sm:text-sm">
|
||||
Pastikan nominal persis seperti tercantum supaya organizer mudah
|
||||
mencocokkan.
|
||||
</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>
|
||||
</section>
|
||||
)}
|
||||
<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 && bankAvailable && (
|
||||
<MarkPaidButton tripId={tripId} />
|
||||
)}
|
||||
{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 ? (
|
||||
<p>
|
||||
✅ Pembayaran kamu sudah dikonfirmasi oleh{" "}
|
||||
<span className="font-semibold text-neutral-800">
|
||||
{organizerName}
|
||||
</span>
|
||||
. Sampai jumpa di trip!
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
⏳ Kamu sudah menandai sudah bayar. Menunggu organizer mengecek
|
||||
dan mengonfirmasi.
|
||||
</p>
|
||||
)}
|
||||
{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 terkonfirmasi. Sampai jumpa di trip bareng{" "}
|
||||
<span className="font-semibold">{organizerName}</span>!
|
||||
</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>
|
||||
@@ -330,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 (
|
||||
@@ -358,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 ${
|
||||
@@ -375,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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -3,10 +3,15 @@ import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { notFound } from "next/navigation";
|
||||
import { profileService } from "@/server/services/profile.service";
|
||||
import { trustService } from "@/server/services/trust.service";
|
||||
import { reviewService } from "@/server/services/review.service";
|
||||
import { TripCard } from "@/features/trip/components/trip-card";
|
||||
import { ProfileTripRow } from "@/features/profile/components/profile-trip-row";
|
||||
import { OrganizerStatsPanel } from "@/features/profile/components/organizer-stats-panel";
|
||||
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 }>;
|
||||
@@ -45,6 +50,16 @@ export default async function PublicProfilePage({ params }: PageProps) {
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
// Trust panel hanya relevan untuk user yang berperan organizer.
|
||||
// Hindari query Prisma yang nggak perlu untuk user yang murni peserta.
|
||||
const isOrganizerProfile = organizedTrips.length > 0 || isVerifiedOrganizer;
|
||||
const [organizerTrust, organizerReviews] = isOrganizerProfile
|
||||
? await Promise.all([
|
||||
trustService.getOrganizerTrust(user.id),
|
||||
reviewService.getReviewsByOrganizer(user.id),
|
||||
])
|
||||
: [null, []];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-6 sm:py-8">
|
||||
{/* Header */}
|
||||
@@ -72,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>
|
||||
@@ -83,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>
|
||||
@@ -127,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>
|
||||
@@ -156,6 +173,15 @@ export default async function PublicProfilePage({ params }: PageProps) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{organizerTrust && <OrganizerStatsPanel trust={organizerTrust} />}
|
||||
|
||||
{organizerTrust && organizerReviews.length > 0 && (
|
||||
<OrganizerReviewsList
|
||||
reviews={organizerReviews}
|
||||
totalCount={organizerTrust.reviewCount}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Empty profile hint */}
|
||||
{!profile && (
|
||||
<p className="mt-5 rounded-xl border border-dashed border-neutral-200 bg-neutral-50 px-4 py-3 text-center text-xs text-neutral-500">
|
||||
@@ -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>
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Admin · Refund Manual",
|
||||
description:
|
||||
"Halaman admin untuk meninjau laporan refund dari peserta dan organizer.",
|
||||
alternates: { canonical: "/admin/refunds" },
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function AdminRefundsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
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,
|
||||
} from "@/features/refund/components/refund-review-card";
|
||||
|
||||
type Tab = "PENDING" | "APPROVED" | "REJECTED" | "SUCCEEDED" | "FAILED";
|
||||
|
||||
const TABS: { key: Tab; label: string }[] = [
|
||||
{ key: "PENDING", label: "Pending" },
|
||||
{ key: "APPROVED", label: "Disetujui" },
|
||||
{ key: "SUCCEEDED", label: "Selesai" },
|
||||
{ key: "REJECTED", label: "Ditolak" },
|
||||
{ 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;
|
||||
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) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) redirect("/login?callbackUrl=/admin/refunds");
|
||||
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)
|
||||
: "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 items: RefundCardData[] = rows.map((r) => ({
|
||||
id: r.id,
|
||||
amount: r.amount,
|
||||
currency: r.currency,
|
||||
reason: r.reason,
|
||||
reportedBy: r.reportedBy,
|
||||
reportNote: r.reportNote,
|
||||
initiatedBy: r.initiatedBy,
|
||||
status: r.status,
|
||||
adminNote: r.adminNote,
|
||||
createdAt: r.createdAt,
|
||||
reviewedAt: r.reviewedAt,
|
||||
succeededAt: r.succeededAt,
|
||||
failedAt: r.failedAt,
|
||||
reviewedBy: r.reviewedBy,
|
||||
booking: {
|
||||
id: r.booking.id,
|
||||
amount: r.booking.amount,
|
||||
status: r.booking.status,
|
||||
trip: {
|
||||
id: r.booking.trip.id,
|
||||
title: r.booking.trip.title,
|
||||
date: r.booking.trip.date,
|
||||
},
|
||||
user: r.booking.user,
|
||||
payments: r.booking.payments.map((p) => ({
|
||||
id: p.id,
|
||||
provider: p.provider,
|
||||
method: p.method,
|
||||
amount: p.amount,
|
||||
status: p.status,
|
||||
paidAt: p.paidAt,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
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 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>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
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
|
||||
key={t.key}
|
||||
href={`/admin/refunds?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 refund yang cocok dengan filter ini.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{items.map((r) => (
|
||||
<RefundReviewCard key={r.id} refund={r} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 > 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 > 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
|
||||
> 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,18 +70,41 @@ 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">
|
||||
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||
Review Verifikasi Organizer
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
Periksa data KTP, foto liveness (memegang kertas SETRIP), dan rekening
|
||||
sebelum menyetujui.
|
||||
</p>
|
||||
<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>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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";
|
||||
|
||||
/**
|
||||
* Cron daily — flip trip yang sudah lewat tanggal selesai dari OPEN/FULL ke
|
||||
* COMPLETED. Idempotent: run berulang aman.
|
||||
*
|
||||
* Trigger via system crontab (lihat [docs/CRON_SETUP.md](../../../docs/CRON_SETUP.md))
|
||||
* atau cron service apapun. Header wajib: `Authorization: Bearer ${CRON_SECRET}`.
|
||||
*
|
||||
* Set env `CRON_SECRET` (random ≥32 char) di hosting. Kalau env tidak di-set,
|
||||
* endpoint hard-fail 500 supaya tidak accidentally jalan tanpa proteksi.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const secret = process.env.CRON_SECRET;
|
||||
if (!secret) {
|
||||
console.error("[cron/auto-complete-trips] CRON_SECRET tidak di-set");
|
||||
return NextResponse.json(
|
||||
{ error: "Server misconfigured" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const authHeader = req.headers.get("authorization");
|
||||
if (authHeader !== `Bearer ${secret}`) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const outcome = await runCron("auto-complete-trips", async () => {
|
||||
const result = await tripService.autoCompletePastTrips();
|
||||
// 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,
|
||||
};
|
||||
});
|
||||
|
||||
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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { midtransWebhookSchema } from "@/lib/midtrans";
|
||||
import { paymentService } from "@/server/services/payment.service";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Webhook callback dari Midtrans.
|
||||
*
|
||||
* Aturan response:
|
||||
* - Body bukan JSON / shape tidak valid → 400 (Midtrans tetap retry, tapi mereka pasti
|
||||
* kirim shape valid; 400 di sini = bug bukan dari Midtrans).
|
||||
* - Signature mismatch → 401 (Midtrans tidak retry untuk auth error).
|
||||
* - Sudah final / unknown order / amount mismatch → 200 OK + log
|
||||
* (kita tidak mau Midtrans retry forever untuk kasus yang server-side perlu manual review).
|
||||
* - Sukses update → 200 OK.
|
||||
*
|
||||
* URL ini harus didaftarkan di dashboard Midtrans:
|
||||
* `<NEXT_PUBLIC_SITE_URL>/api/webhooks/midtrans`.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Body bukan JSON valid" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = midtransWebhookSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
console.warn(
|
||||
"[midtrans-webhook] payload schema invalid",
|
||||
parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`)
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: "Payload schema invalid" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const body = parsed.data;
|
||||
|
||||
let outcome;
|
||||
try {
|
||||
outcome = await paymentService.handleMidtransWebhook(body);
|
||||
} catch (err) {
|
||||
console.error("[midtrans-webhook] gagal proses callback", err, {
|
||||
order_id: body.order_id,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "Gagal memproses callback" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!outcome.ok) {
|
||||
if (outcome.reason === "signature_mismatch") {
|
||||
console.warn("[midtrans-webhook] signature mismatch", {
|
||||
order_id: body.order_id,
|
||||
});
|
||||
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
|
||||
}
|
||||
if (outcome.reason === "amount_mismatch") {
|
||||
console.warn("[midtrans-webhook] amount mismatch", {
|
||||
order_id: body.order_id,
|
||||
gross_amount: body.gross_amount,
|
||||
});
|
||||
// Return 200 supaya Midtrans tidak retry; investigasi via log.
|
||||
return NextResponse.json({ status: "amount_mismatch_logged" });
|
||||
}
|
||||
}
|
||||
|
||||
if (outcome.ok && outcome.status === "booking_conflict") {
|
||||
console.warn(
|
||||
"[midtrans-webhook] PAID arrived for booking in conflict state — manual review required",
|
||||
{ order_id: body.order_id, transaction_id: body.transaction_id }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ status: outcome.ok ? outcome.status : "error" });
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
// @ts-nocheck
|
||||
/*
|
||||
* This file should be your main import to use Prisma-related types and utilities in a browser.
|
||||
* Use it to get access to models, enums, and input types.
|
||||
*
|
||||
* This file does not contain a `PrismaClient` class, nor several other helpers that are intended as server-side only.
|
||||
* See `client.ts` for the standard, server-side entry point.
|
||||
*
|
||||
* 🟢 You can import this file directly.
|
||||
*/
|
||||
|
||||
import * as Prisma from './internal/prismaNamespaceBrowser'
|
||||
export { Prisma }
|
||||
export * as $Enums from './enums'
|
||||
export * from './enums';
|
||||
/**
|
||||
* Model User
|
||||
*
|
||||
*/
|
||||
export type User = Prisma.UserModel
|
||||
/**
|
||||
* Model UserProfile
|
||||
* Profil sosial publik. Berisi info yang user pilih untuk dibagikan ke peserta lain
|
||||
* (bio, kota, minat, vibe). Tidak menyimpan data sensitif — KYC tetap di OrganizerVerification.
|
||||
*/
|
||||
export type UserProfile = Prisma.UserProfileModel
|
||||
/**
|
||||
* Model Account
|
||||
* Tabel link akun OAuth pihak ketiga (Google, dst). Diisi oleh PrismaAdapter NextAuth.
|
||||
* Session tidak pakai DB — kita pakai JWT, jadi Session/VerificationToken tidak perlu.
|
||||
*/
|
||||
export type Account = Prisma.AccountModel
|
||||
/**
|
||||
* Model OrganizerVerification
|
||||
*
|
||||
*/
|
||||
export type OrganizerVerification = Prisma.OrganizerVerificationModel
|
||||
/**
|
||||
* Model Trip
|
||||
*
|
||||
*/
|
||||
export type Trip = Prisma.TripModel
|
||||
/**
|
||||
* Model TripReview
|
||||
*
|
||||
*/
|
||||
export type TripReview = Prisma.TripReviewModel
|
||||
/**
|
||||
* Model TripImage
|
||||
*
|
||||
*/
|
||||
export type TripImage = Prisma.TripImageModel
|
||||
/**
|
||||
* Model TripParticipant
|
||||
*
|
||||
*/
|
||||
export type TripParticipant = Prisma.TripParticipantModel
|
||||
/**
|
||||
* Model Booking
|
||||
* Booking 1-1 ke TripParticipant. Lifecycle ikut peserta:
|
||||
* - join → Booking PENDING (menunggu approve organizer)
|
||||
* - organizer confirm → AWAITING_PAY (paid trip) atau PAID (free trip)
|
||||
* - peserta + organizer rampungkan pembayaran → PAID
|
||||
* - cancel/reject → CANCELLED
|
||||
* `amount` adalah snapshot harga saat booking dibuat — protect dari perubahan trip.price.
|
||||
*/
|
||||
export type Booking = Prisma.BookingModel
|
||||
/**
|
||||
* Model Payment
|
||||
* Satu attempt pembayaran. Satu Booking bisa punya banyak Payment kalau retry
|
||||
* (di Phase MIDTRANS nanti). Untuk MANUAL biasanya cukup 1 Payment.
|
||||
*/
|
||||
export type Payment = Prisma.PaymentModel
|
||||
@@ -1,101 +0,0 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
// @ts-nocheck
|
||||
/*
|
||||
* This file should be your main import to use Prisma. Through it you get access to all the models, enums, and input types.
|
||||
* If you're looking for something you can import in the client-side of your application, please refer to the `browser.ts` file instead.
|
||||
*
|
||||
* 🟢 You can import this file directly.
|
||||
*/
|
||||
|
||||
import * as process from 'node:process'
|
||||
import * as path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
globalThis['__dirname'] = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
import * as runtime from "@prisma/client/runtime/client"
|
||||
import * as $Enums from "./enums"
|
||||
import * as $Class from "./internal/class"
|
||||
import * as Prisma from "./internal/prismaNamespace"
|
||||
|
||||
export * as $Enums from './enums'
|
||||
export * from "./enums"
|
||||
/**
|
||||
* ## Prisma Client
|
||||
*
|
||||
* Type-safe database client for TypeScript
|
||||
* @example
|
||||
* ```
|
||||
* const prisma = new PrismaClient({
|
||||
* adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL })
|
||||
* })
|
||||
* // Fetch zero or more Users
|
||||
* const users = await prisma.user.findMany()
|
||||
* ```
|
||||
*
|
||||
* Read more in our [docs](https://pris.ly/d/client).
|
||||
*/
|
||||
export const PrismaClient = $Class.getPrismaClientClass()
|
||||
export type PrismaClient<LogOpts extends Prisma.LogLevel = never, OmitOpts extends Prisma.PrismaClientOptions["omit"] = Prisma.PrismaClientOptions["omit"], ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = $Class.PrismaClient<LogOpts, OmitOpts, ExtArgs>
|
||||
export { Prisma }
|
||||
|
||||
/**
|
||||
* Model User
|
||||
*
|
||||
*/
|
||||
export type User = Prisma.UserModel
|
||||
/**
|
||||
* Model UserProfile
|
||||
* Profil sosial publik. Berisi info yang user pilih untuk dibagikan ke peserta lain
|
||||
* (bio, kota, minat, vibe). Tidak menyimpan data sensitif — KYC tetap di OrganizerVerification.
|
||||
*/
|
||||
export type UserProfile = Prisma.UserProfileModel
|
||||
/**
|
||||
* Model Account
|
||||
* Tabel link akun OAuth pihak ketiga (Google, dst). Diisi oleh PrismaAdapter NextAuth.
|
||||
* Session tidak pakai DB — kita pakai JWT, jadi Session/VerificationToken tidak perlu.
|
||||
*/
|
||||
export type Account = Prisma.AccountModel
|
||||
/**
|
||||
* Model OrganizerVerification
|
||||
*
|
||||
*/
|
||||
export type OrganizerVerification = Prisma.OrganizerVerificationModel
|
||||
/**
|
||||
* Model Trip
|
||||
*
|
||||
*/
|
||||
export type Trip = Prisma.TripModel
|
||||
/**
|
||||
* Model TripReview
|
||||
*
|
||||
*/
|
||||
export type TripReview = Prisma.TripReviewModel
|
||||
/**
|
||||
* Model TripImage
|
||||
*
|
||||
*/
|
||||
export type TripImage = Prisma.TripImageModel
|
||||
/**
|
||||
* Model TripParticipant
|
||||
*
|
||||
*/
|
||||
export type TripParticipant = Prisma.TripParticipantModel
|
||||
/**
|
||||
* Model Booking
|
||||
* Booking 1-1 ke TripParticipant. Lifecycle ikut peserta:
|
||||
* - join → Booking PENDING (menunggu approve organizer)
|
||||
* - organizer confirm → AWAITING_PAY (paid trip) atau PAID (free trip)
|
||||
* - peserta + organizer rampungkan pembayaran → PAID
|
||||
* - cancel/reject → CANCELLED
|
||||
* `amount` adalah snapshot harga saat booking dibuat — protect dari perubahan trip.price.
|
||||
*/
|
||||
export type Booking = Prisma.BookingModel
|
||||
/**
|
||||
* Model Payment
|
||||
* Satu attempt pembayaran. Satu Booking bisa punya banyak Payment kalau retry
|
||||
* (di Phase MIDTRANS nanti). Untuk MANUAL biasanya cukup 1 Payment.
|
||||
*/
|
||||
export type Payment = Prisma.PaymentModel
|
||||
@@ -1,753 +0,0 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
// @ts-nocheck
|
||||
/*
|
||||
* This file exports various common sort, input & filter types that are not directly linked to a particular model.
|
||||
*
|
||||
* 🟢 You can import this file directly.
|
||||
*/
|
||||
|
||||
import type * as runtime from "@prisma/client/runtime/client"
|
||||
import * as $Enums from "./enums"
|
||||
import type * as Prisma from "./internal/prismaNamespace"
|
||||
|
||||
|
||||
export type StringFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
mode?: Prisma.QueryMode
|
||||
not?: Prisma.NestedStringFilter<$PrismaModel> | string
|
||||
}
|
||||
|
||||
export type StringNullableFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
mode?: Prisma.QueryMode
|
||||
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
|
||||
}
|
||||
|
||||
export type DateTimeNullableFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
|
||||
}
|
||||
|
||||
export type BoolFilter<$PrismaModel = never> = {
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
|
||||
}
|
||||
|
||||
export type DateTimeFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
|
||||
}
|
||||
|
||||
export type SortOrderInput = {
|
||||
sort: Prisma.SortOrder
|
||||
nulls?: Prisma.NullsOrder
|
||||
}
|
||||
|
||||
export type StringWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
mode?: Prisma.QueryMode
|
||||
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedStringFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedStringFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
mode?: Prisma.QueryMode
|
||||
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type DateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type BoolWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedBoolFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedBoolFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type EnumVibeNullableFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.Vibe | Prisma.EnumVibeFieldRefInput<$PrismaModel> | null
|
||||
in?: $Enums.Vibe[] | Prisma.ListEnumVibeFieldRefInput<$PrismaModel> | null
|
||||
notIn?: $Enums.Vibe[] | Prisma.ListEnumVibeFieldRefInput<$PrismaModel> | null
|
||||
not?: Prisma.NestedEnumVibeNullableFilter<$PrismaModel> | $Enums.Vibe | null
|
||||
}
|
||||
|
||||
export type EnumVibeNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.Vibe | Prisma.EnumVibeFieldRefInput<$PrismaModel> | null
|
||||
in?: $Enums.Vibe[] | Prisma.ListEnumVibeFieldRefInput<$PrismaModel> | null
|
||||
notIn?: $Enums.Vibe[] | Prisma.ListEnumVibeFieldRefInput<$PrismaModel> | null
|
||||
not?: Prisma.NestedEnumVibeNullableWithAggregatesFilter<$PrismaModel> | $Enums.Vibe | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumVibeNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumVibeNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type IntNullableFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
|
||||
}
|
||||
|
||||
export type IntNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntNullableWithAggregatesFilter<$PrismaModel> | number | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||
_sum?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type EnumVerificationStatusFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.VerificationStatus | Prisma.EnumVerificationStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.VerificationStatus[] | Prisma.ListEnumVerificationStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.VerificationStatus[] | Prisma.ListEnumVerificationStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumVerificationStatusFilter<$PrismaModel> | $Enums.VerificationStatus
|
||||
}
|
||||
|
||||
export type EnumVerificationStatusWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.VerificationStatus | Prisma.EnumVerificationStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.VerificationStatus[] | Prisma.ListEnumVerificationStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.VerificationStatus[] | Prisma.ListEnumVerificationStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumVerificationStatusWithAggregatesFilter<$PrismaModel> | $Enums.VerificationStatus
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumVerificationStatusFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumVerificationStatusFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type EnumActivityCategoryFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.ActivityCategory | Prisma.EnumActivityCategoryFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.ActivityCategory[] | Prisma.ListEnumActivityCategoryFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.ActivityCategory[] | Prisma.ListEnumActivityCategoryFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumActivityCategoryFilter<$PrismaModel> | $Enums.ActivityCategory
|
||||
}
|
||||
|
||||
export type IntFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntFilter<$PrismaModel> | number
|
||||
}
|
||||
|
||||
export type EnumTripStatusFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.TripStatus | Prisma.EnumTripStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.TripStatus[] | Prisma.ListEnumTripStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.TripStatus[] | Prisma.ListEnumTripStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumTripStatusFilter<$PrismaModel> | $Enums.TripStatus
|
||||
}
|
||||
|
||||
export type EnumActivityCategoryWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.ActivityCategory | Prisma.EnumActivityCategoryFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.ActivityCategory[] | Prisma.ListEnumActivityCategoryFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.ActivityCategory[] | Prisma.ListEnumActivityCategoryFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumActivityCategoryWithAggregatesFilter<$PrismaModel> | $Enums.ActivityCategory
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumActivityCategoryFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumActivityCategoryFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type IntWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
_sum?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type EnumTripStatusWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.TripStatus | Prisma.EnumTripStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.TripStatus[] | Prisma.ListEnumTripStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.TripStatus[] | Prisma.ListEnumTripStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumTripStatusWithAggregatesFilter<$PrismaModel> | $Enums.TripStatus
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumTripStatusFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumTripStatusFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type EnumParticipantStatusFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.ParticipantStatus | Prisma.EnumParticipantStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.ParticipantStatus[] | Prisma.ListEnumParticipantStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.ParticipantStatus[] | Prisma.ListEnumParticipantStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumParticipantStatusFilter<$PrismaModel> | $Enums.ParticipantStatus
|
||||
}
|
||||
|
||||
export type EnumParticipantStatusWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.ParticipantStatus | Prisma.EnumParticipantStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.ParticipantStatus[] | Prisma.ListEnumParticipantStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.ParticipantStatus[] | Prisma.ListEnumParticipantStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumParticipantStatusWithAggregatesFilter<$PrismaModel> | $Enums.ParticipantStatus
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumParticipantStatusFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumParticipantStatusFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type EnumBookingStatusFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.BookingStatus | Prisma.EnumBookingStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.BookingStatus[] | Prisma.ListEnumBookingStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.BookingStatus[] | Prisma.ListEnumBookingStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumBookingStatusFilter<$PrismaModel> | $Enums.BookingStatus
|
||||
}
|
||||
|
||||
export type EnumBookingStatusWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.BookingStatus | Prisma.EnumBookingStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.BookingStatus[] | Prisma.ListEnumBookingStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.BookingStatus[] | Prisma.ListEnumBookingStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumBookingStatusWithAggregatesFilter<$PrismaModel> | $Enums.BookingStatus
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumBookingStatusFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumBookingStatusFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type EnumPaymentProviderFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.PaymentProvider | Prisma.EnumPaymentProviderFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.PaymentProvider[] | Prisma.ListEnumPaymentProviderFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.PaymentProvider[] | Prisma.ListEnumPaymentProviderFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumPaymentProviderFilter<$PrismaModel> | $Enums.PaymentProvider
|
||||
}
|
||||
|
||||
export type EnumPaymentStatusFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.PaymentStatus | Prisma.EnumPaymentStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.PaymentStatus[] | Prisma.ListEnumPaymentStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.PaymentStatus[] | Prisma.ListEnumPaymentStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumPaymentStatusFilter<$PrismaModel> | $Enums.PaymentStatus
|
||||
}
|
||||
|
||||
export type JsonNullableFilter<$PrismaModel = never> =
|
||||
| Prisma.PatchUndefined<
|
||||
Prisma.Either<Required<JsonNullableFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonNullableFilterBase<$PrismaModel>>, 'path'>>,
|
||||
Required<JsonNullableFilterBase<$PrismaModel>>
|
||||
>
|
||||
| Prisma.OptionalFlat<Omit<Required<JsonNullableFilterBase<$PrismaModel>>, 'path'>>
|
||||
|
||||
export type JsonNullableFilterBase<$PrismaModel = never> = {
|
||||
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
path?: string[]
|
||||
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
|
||||
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
}
|
||||
|
||||
export type EnumPaymentProviderWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.PaymentProvider | Prisma.EnumPaymentProviderFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.PaymentProvider[] | Prisma.ListEnumPaymentProviderFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.PaymentProvider[] | Prisma.ListEnumPaymentProviderFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumPaymentProviderWithAggregatesFilter<$PrismaModel> | $Enums.PaymentProvider
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumPaymentProviderFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumPaymentProviderFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type EnumPaymentStatusWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.PaymentStatus | Prisma.EnumPaymentStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.PaymentStatus[] | Prisma.ListEnumPaymentStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.PaymentStatus[] | Prisma.ListEnumPaymentStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumPaymentStatusWithAggregatesFilter<$PrismaModel> | $Enums.PaymentStatus
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumPaymentStatusFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumPaymentStatusFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type JsonNullableWithAggregatesFilter<$PrismaModel = never> =
|
||||
| Prisma.PatchUndefined<
|
||||
Prisma.Either<Required<JsonNullableWithAggregatesFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonNullableWithAggregatesFilterBase<$PrismaModel>>, 'path'>>,
|
||||
Required<JsonNullableWithAggregatesFilterBase<$PrismaModel>>
|
||||
>
|
||||
| Prisma.OptionalFlat<Omit<Required<JsonNullableWithAggregatesFilterBase<$PrismaModel>>, 'path'>>
|
||||
|
||||
export type JsonNullableWithAggregatesFilterBase<$PrismaModel = never> = {
|
||||
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
path?: string[]
|
||||
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
|
||||
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedJsonNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedJsonNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedStringFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedStringFilter<$PrismaModel> | string
|
||||
}
|
||||
|
||||
export type NestedStringNullableFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
|
||||
}
|
||||
|
||||
export type NestedDateTimeNullableFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
|
||||
}
|
||||
|
||||
export type NestedBoolFilter<$PrismaModel = never> = {
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
|
||||
}
|
||||
|
||||
export type NestedDateTimeFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
|
||||
}
|
||||
|
||||
export type NestedStringWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedStringFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedStringFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedIntFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntFilter<$PrismaModel> | number
|
||||
}
|
||||
|
||||
export type NestedStringNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedIntNullableFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
|
||||
}
|
||||
|
||||
export type NestedDateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedBoolWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedBoolFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedBoolFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedEnumVibeNullableFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.Vibe | Prisma.EnumVibeFieldRefInput<$PrismaModel> | null
|
||||
in?: $Enums.Vibe[] | Prisma.ListEnumVibeFieldRefInput<$PrismaModel> | null
|
||||
notIn?: $Enums.Vibe[] | Prisma.ListEnumVibeFieldRefInput<$PrismaModel> | null
|
||||
not?: Prisma.NestedEnumVibeNullableFilter<$PrismaModel> | $Enums.Vibe | null
|
||||
}
|
||||
|
||||
export type NestedEnumVibeNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.Vibe | Prisma.EnumVibeFieldRefInput<$PrismaModel> | null
|
||||
in?: $Enums.Vibe[] | Prisma.ListEnumVibeFieldRefInput<$PrismaModel> | null
|
||||
notIn?: $Enums.Vibe[] | Prisma.ListEnumVibeFieldRefInput<$PrismaModel> | null
|
||||
not?: Prisma.NestedEnumVibeNullableWithAggregatesFilter<$PrismaModel> | $Enums.Vibe | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumVibeNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumVibeNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedIntNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntNullableWithAggregatesFilter<$PrismaModel> | number | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||
_sum?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedFloatNullableFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
|
||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
|
||||
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
|
||||
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedFloatNullableFilter<$PrismaModel> | number | null
|
||||
}
|
||||
|
||||
export type NestedEnumVerificationStatusFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.VerificationStatus | Prisma.EnumVerificationStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.VerificationStatus[] | Prisma.ListEnumVerificationStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.VerificationStatus[] | Prisma.ListEnumVerificationStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumVerificationStatusFilter<$PrismaModel> | $Enums.VerificationStatus
|
||||
}
|
||||
|
||||
export type NestedEnumVerificationStatusWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.VerificationStatus | Prisma.EnumVerificationStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.VerificationStatus[] | Prisma.ListEnumVerificationStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.VerificationStatus[] | Prisma.ListEnumVerificationStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumVerificationStatusWithAggregatesFilter<$PrismaModel> | $Enums.VerificationStatus
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumVerificationStatusFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumVerificationStatusFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedEnumActivityCategoryFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.ActivityCategory | Prisma.EnumActivityCategoryFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.ActivityCategory[] | Prisma.ListEnumActivityCategoryFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.ActivityCategory[] | Prisma.ListEnumActivityCategoryFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumActivityCategoryFilter<$PrismaModel> | $Enums.ActivityCategory
|
||||
}
|
||||
|
||||
export type NestedEnumTripStatusFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.TripStatus | Prisma.EnumTripStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.TripStatus[] | Prisma.ListEnumTripStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.TripStatus[] | Prisma.ListEnumTripStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumTripStatusFilter<$PrismaModel> | $Enums.TripStatus
|
||||
}
|
||||
|
||||
export type NestedEnumActivityCategoryWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.ActivityCategory | Prisma.EnumActivityCategoryFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.ActivityCategory[] | Prisma.ListEnumActivityCategoryFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.ActivityCategory[] | Prisma.ListEnumActivityCategoryFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumActivityCategoryWithAggregatesFilter<$PrismaModel> | $Enums.ActivityCategory
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumActivityCategoryFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumActivityCategoryFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedIntWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
_sum?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedFloatFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedFloatFilter<$PrismaModel> | number
|
||||
}
|
||||
|
||||
export type NestedEnumTripStatusWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.TripStatus | Prisma.EnumTripStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.TripStatus[] | Prisma.ListEnumTripStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.TripStatus[] | Prisma.ListEnumTripStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumTripStatusWithAggregatesFilter<$PrismaModel> | $Enums.TripStatus
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumTripStatusFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumTripStatusFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedEnumParticipantStatusFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.ParticipantStatus | Prisma.EnumParticipantStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.ParticipantStatus[] | Prisma.ListEnumParticipantStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.ParticipantStatus[] | Prisma.ListEnumParticipantStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumParticipantStatusFilter<$PrismaModel> | $Enums.ParticipantStatus
|
||||
}
|
||||
|
||||
export type NestedEnumParticipantStatusWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.ParticipantStatus | Prisma.EnumParticipantStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.ParticipantStatus[] | Prisma.ListEnumParticipantStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.ParticipantStatus[] | Prisma.ListEnumParticipantStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumParticipantStatusWithAggregatesFilter<$PrismaModel> | $Enums.ParticipantStatus
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumParticipantStatusFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumParticipantStatusFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedEnumBookingStatusFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.BookingStatus | Prisma.EnumBookingStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.BookingStatus[] | Prisma.ListEnumBookingStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.BookingStatus[] | Prisma.ListEnumBookingStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumBookingStatusFilter<$PrismaModel> | $Enums.BookingStatus
|
||||
}
|
||||
|
||||
export type NestedEnumBookingStatusWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.BookingStatus | Prisma.EnumBookingStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.BookingStatus[] | Prisma.ListEnumBookingStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.BookingStatus[] | Prisma.ListEnumBookingStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumBookingStatusWithAggregatesFilter<$PrismaModel> | $Enums.BookingStatus
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumBookingStatusFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumBookingStatusFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedEnumPaymentProviderFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.PaymentProvider | Prisma.EnumPaymentProviderFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.PaymentProvider[] | Prisma.ListEnumPaymentProviderFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.PaymentProvider[] | Prisma.ListEnumPaymentProviderFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumPaymentProviderFilter<$PrismaModel> | $Enums.PaymentProvider
|
||||
}
|
||||
|
||||
export type NestedEnumPaymentStatusFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.PaymentStatus | Prisma.EnumPaymentStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.PaymentStatus[] | Prisma.ListEnumPaymentStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.PaymentStatus[] | Prisma.ListEnumPaymentStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumPaymentStatusFilter<$PrismaModel> | $Enums.PaymentStatus
|
||||
}
|
||||
|
||||
export type NestedEnumPaymentProviderWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.PaymentProvider | Prisma.EnumPaymentProviderFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.PaymentProvider[] | Prisma.ListEnumPaymentProviderFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.PaymentProvider[] | Prisma.ListEnumPaymentProviderFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumPaymentProviderWithAggregatesFilter<$PrismaModel> | $Enums.PaymentProvider
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumPaymentProviderFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumPaymentProviderFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedEnumPaymentStatusWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.PaymentStatus | Prisma.EnumPaymentStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.PaymentStatus[] | Prisma.ListEnumPaymentStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.PaymentStatus[] | Prisma.ListEnumPaymentStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumPaymentStatusWithAggregatesFilter<$PrismaModel> | $Enums.PaymentStatus
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumPaymentStatusFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumPaymentStatusFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedJsonNullableFilter<$PrismaModel = never> =
|
||||
| Prisma.PatchUndefined<
|
||||
Prisma.Either<Required<NestedJsonNullableFilterBase<$PrismaModel>>, Exclude<keyof Required<NestedJsonNullableFilterBase<$PrismaModel>>, 'path'>>,
|
||||
Required<NestedJsonNullableFilterBase<$PrismaModel>>
|
||||
>
|
||||
| Prisma.OptionalFlat<Omit<Required<NestedJsonNullableFilterBase<$PrismaModel>>, 'path'>>
|
||||
|
||||
export type NestedJsonNullableFilterBase<$PrismaModel = never> = {
|
||||
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
path?: string[]
|
||||
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
|
||||
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
}
|
||||
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
// @ts-nocheck
|
||||
/*
|
||||
* This file exports all enum related types from the schema.
|
||||
*
|
||||
* 🟢 You can import this file directly.
|
||||
*/
|
||||
|
||||
export const Vibe = {
|
||||
CHILL: 'CHILL',
|
||||
BALANCED: 'BALANCED',
|
||||
HARDCORE: 'HARDCORE'
|
||||
} as const
|
||||
|
||||
export type Vibe = (typeof Vibe)[keyof typeof Vibe]
|
||||
|
||||
|
||||
export const VerificationStatus = {
|
||||
PENDING: 'PENDING',
|
||||
APPROVED: 'APPROVED',
|
||||
REJECTED: 'REJECTED'
|
||||
} as const
|
||||
|
||||
export type VerificationStatus = (typeof VerificationStatus)[keyof typeof VerificationStatus]
|
||||
|
||||
|
||||
export const TripStatus = {
|
||||
OPEN: 'OPEN',
|
||||
FULL: 'FULL',
|
||||
CLOSED: 'CLOSED',
|
||||
COMPLETED: 'COMPLETED'
|
||||
} as const
|
||||
|
||||
export type TripStatus = (typeof TripStatus)[keyof typeof TripStatus]
|
||||
|
||||
|
||||
export const ActivityCategory = {
|
||||
HIKING: 'HIKING',
|
||||
CAMPING: 'CAMPING',
|
||||
SNORKELING: 'SNORKELING',
|
||||
DIVING: 'DIVING',
|
||||
ISLAND_HOPPING: 'ISLAND_HOPPING',
|
||||
CITY_TRIP: 'CITY_TRIP',
|
||||
CULINARY: 'CULINARY',
|
||||
CONCERT: 'CONCERT',
|
||||
WORKSHOP: 'WORKSHOP',
|
||||
RETREAT: 'RETREAT'
|
||||
} as const
|
||||
|
||||
export type ActivityCategory = (typeof ActivityCategory)[keyof typeof ActivityCategory]
|
||||
|
||||
|
||||
export const ParticipantStatus = {
|
||||
PENDING: 'PENDING',
|
||||
CONFIRMED: 'CONFIRMED',
|
||||
CANCELLED: 'CANCELLED'
|
||||
} as const
|
||||
|
||||
export type ParticipantStatus = (typeof ParticipantStatus)[keyof typeof ParticipantStatus]
|
||||
|
||||
|
||||
export const BookingStatus = {
|
||||
PENDING: 'PENDING',
|
||||
AWAITING_PAY: 'AWAITING_PAY',
|
||||
PAID: 'PAID',
|
||||
CANCELLED: 'CANCELLED',
|
||||
REFUNDED: 'REFUNDED',
|
||||
EXPIRED: 'EXPIRED'
|
||||
} as const
|
||||
|
||||
export type BookingStatus = (typeof BookingStatus)[keyof typeof BookingStatus]
|
||||
|
||||
|
||||
export const PaymentProvider = {
|
||||
MANUAL: 'MANUAL',
|
||||
MIDTRANS: 'MIDTRANS'
|
||||
} as const
|
||||
|
||||
export type PaymentProvider = (typeof PaymentProvider)[keyof typeof PaymentProvider]
|
||||
|
||||
|
||||
export const PaymentStatus = {
|
||||
PENDING: 'PENDING',
|
||||
AWAITING: 'AWAITING',
|
||||
PAID: 'PAID',
|
||||
FAILED: 'FAILED',
|
||||
EXPIRED: 'EXPIRED',
|
||||
CANCELLED: 'CANCELLED',
|
||||
REFUNDED: 'REFUNDED'
|
||||
} as const
|
||||
|
||||
export type PaymentStatus = (typeof PaymentStatus)[keyof typeof PaymentStatus]
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -1,294 +0,0 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
// @ts-nocheck
|
||||
/*
|
||||
* WARNING: This is an internal file that is subject to change!
|
||||
*
|
||||
* 🛑 Under no circumstances should you import this file directly! 🛑
|
||||
*
|
||||
* All exports from this file are wrapped under a `Prisma` namespace object in the browser.ts file.
|
||||
* While this enables partial backward compatibility, it is not part of the stable public API.
|
||||
*
|
||||
* If you are looking for your Models, Enums, and Input Types, please import them from the respective
|
||||
* model files in the `model` directory!
|
||||
*/
|
||||
|
||||
import * as runtime from "@prisma/client/runtime/index-browser"
|
||||
|
||||
export type * from '../models'
|
||||
export type * from './prismaNamespace'
|
||||
|
||||
export const Decimal = runtime.Decimal
|
||||
|
||||
|
||||
export const NullTypes = {
|
||||
DbNull: runtime.NullTypes.DbNull as (new (secret: never) => typeof runtime.DbNull),
|
||||
JsonNull: runtime.NullTypes.JsonNull as (new (secret: never) => typeof runtime.JsonNull),
|
||||
AnyNull: runtime.NullTypes.AnyNull as (new (secret: never) => typeof runtime.AnyNull),
|
||||
}
|
||||
/**
|
||||
* Helper for filtering JSON entries that have `null` on the database (empty on the db)
|
||||
*
|
||||
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
|
||||
*/
|
||||
export const DbNull = runtime.DbNull
|
||||
|
||||
/**
|
||||
* Helper for filtering JSON entries that have JSON `null` values (not empty on the db)
|
||||
*
|
||||
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
|
||||
*/
|
||||
export const JsonNull = runtime.JsonNull
|
||||
|
||||
/**
|
||||
* Helper for filtering JSON entries that are `Prisma.DbNull` or `Prisma.JsonNull`
|
||||
*
|
||||
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
|
||||
*/
|
||||
export const AnyNull = runtime.AnyNull
|
||||
|
||||
|
||||
export const ModelName = {
|
||||
User: 'User',
|
||||
UserProfile: 'UserProfile',
|
||||
Account: 'Account',
|
||||
OrganizerVerification: 'OrganizerVerification',
|
||||
Trip: 'Trip',
|
||||
TripReview: 'TripReview',
|
||||
TripImage: 'TripImage',
|
||||
TripParticipant: 'TripParticipant',
|
||||
Booking: 'Booking',
|
||||
Payment: 'Payment'
|
||||
} as const
|
||||
|
||||
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
||||
|
||||
/*
|
||||
* Enums
|
||||
*/
|
||||
|
||||
export const TransactionIsolationLevel = runtime.makeStrictEnum({
|
||||
ReadUncommitted: 'ReadUncommitted',
|
||||
ReadCommitted: 'ReadCommitted',
|
||||
RepeatableRead: 'RepeatableRead',
|
||||
Serializable: 'Serializable'
|
||||
} as const)
|
||||
|
||||
export type TransactionIsolationLevel = (typeof TransactionIsolationLevel)[keyof typeof TransactionIsolationLevel]
|
||||
|
||||
|
||||
export const UserScalarFieldEnum = {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
email: 'email',
|
||||
password: 'password',
|
||||
image: 'image',
|
||||
emailVerified: 'emailVerified',
|
||||
acceptedTermsAndPrivacy: 'acceptedTermsAndPrivacy',
|
||||
acceptedAt: 'acceptedAt',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type UserScalarFieldEnum = (typeof UserScalarFieldEnum)[keyof typeof UserScalarFieldEnum]
|
||||
|
||||
|
||||
export const UserProfileScalarFieldEnum = {
|
||||
id: 'id',
|
||||
userId: 'userId',
|
||||
bio: 'bio',
|
||||
city: 'city',
|
||||
interests: 'interests',
|
||||
instagram: 'instagram',
|
||||
vibe: 'vibe',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type UserProfileScalarFieldEnum = (typeof UserProfileScalarFieldEnum)[keyof typeof UserProfileScalarFieldEnum]
|
||||
|
||||
|
||||
export const AccountScalarFieldEnum = {
|
||||
id: 'id',
|
||||
userId: 'userId',
|
||||
type: 'type',
|
||||
provider: 'provider',
|
||||
providerAccountId: 'providerAccountId',
|
||||
refresh_token: 'refresh_token',
|
||||
access_token: 'access_token',
|
||||
expires_at: 'expires_at',
|
||||
token_type: 'token_type',
|
||||
scope: 'scope',
|
||||
id_token: 'id_token',
|
||||
session_state: 'session_state'
|
||||
} as const
|
||||
|
||||
export type AccountScalarFieldEnum = (typeof AccountScalarFieldEnum)[keyof typeof AccountScalarFieldEnum]
|
||||
|
||||
|
||||
export const OrganizerVerificationScalarFieldEnum = {
|
||||
id: 'id',
|
||||
userId: 'userId',
|
||||
fullName: 'fullName',
|
||||
nikEncrypted: 'nikEncrypted',
|
||||
nikHash: 'nikHash',
|
||||
birthDate: 'birthDate',
|
||||
address: 'address',
|
||||
ktpImageKey: 'ktpImageKey',
|
||||
livenessKey: 'livenessKey',
|
||||
bankName: 'bankName',
|
||||
bankAccountNumber: 'bankAccountNumber',
|
||||
bankAccountName: 'bankAccountName',
|
||||
status: 'status',
|
||||
rejectionReason: 'rejectionReason',
|
||||
reviewedAt: 'reviewedAt',
|
||||
reviewedById: 'reviewedById',
|
||||
verifiedAt: 'verifiedAt',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type OrganizerVerificationScalarFieldEnum = (typeof OrganizerVerificationScalarFieldEnum)[keyof typeof OrganizerVerificationScalarFieldEnum]
|
||||
|
||||
|
||||
export const TripScalarFieldEnum = {
|
||||
id: 'id',
|
||||
title: 'title',
|
||||
description: 'description',
|
||||
category: 'category',
|
||||
destination: 'destination',
|
||||
location: 'location',
|
||||
meetingPoint: 'meetingPoint',
|
||||
itinerary: 'itinerary',
|
||||
whatsIncluded: 'whatsIncluded',
|
||||
whatsExcluded: 'whatsExcluded',
|
||||
date: 'date',
|
||||
endDate: 'endDate',
|
||||
maxParticipants: 'maxParticipants',
|
||||
price: 'price',
|
||||
vibe: 'vibe',
|
||||
status: 'status',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt',
|
||||
organizerId: 'organizerId'
|
||||
} as const
|
||||
|
||||
export type TripScalarFieldEnum = (typeof TripScalarFieldEnum)[keyof typeof TripScalarFieldEnum]
|
||||
|
||||
|
||||
export const TripReviewScalarFieldEnum = {
|
||||
id: 'id',
|
||||
rating: 'rating',
|
||||
comment: 'comment',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt',
|
||||
tripId: 'tripId',
|
||||
userId: 'userId'
|
||||
} as const
|
||||
|
||||
export type TripReviewScalarFieldEnum = (typeof TripReviewScalarFieldEnum)[keyof typeof TripReviewScalarFieldEnum]
|
||||
|
||||
|
||||
export const TripImageScalarFieldEnum = {
|
||||
id: 'id',
|
||||
url: 'url',
|
||||
caption: 'caption',
|
||||
order: 'order',
|
||||
tripId: 'tripId'
|
||||
} as const
|
||||
|
||||
export type TripImageScalarFieldEnum = (typeof TripImageScalarFieldEnum)[keyof typeof TripImageScalarFieldEnum]
|
||||
|
||||
|
||||
export const TripParticipantScalarFieldEnum = {
|
||||
id: 'id',
|
||||
status: 'status',
|
||||
createdAt: 'createdAt',
|
||||
markedPaidAt: 'markedPaidAt',
|
||||
paymentConfirmedAt: 'paymentConfirmedAt',
|
||||
tripId: 'tripId',
|
||||
userId: 'userId'
|
||||
} as const
|
||||
|
||||
export type TripParticipantScalarFieldEnum = (typeof TripParticipantScalarFieldEnum)[keyof typeof TripParticipantScalarFieldEnum]
|
||||
|
||||
|
||||
export const BookingScalarFieldEnum = {
|
||||
id: 'id',
|
||||
tripId: 'tripId',
|
||||
userId: 'userId',
|
||||
participantId: 'participantId',
|
||||
amount: 'amount',
|
||||
currency: 'currency',
|
||||
status: 'status',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type BookingScalarFieldEnum = (typeof BookingScalarFieldEnum)[keyof typeof BookingScalarFieldEnum]
|
||||
|
||||
|
||||
export const PaymentScalarFieldEnum = {
|
||||
id: 'id',
|
||||
bookingId: 'bookingId',
|
||||
provider: 'provider',
|
||||
externalOrderId: 'externalOrderId',
|
||||
externalTxId: 'externalTxId',
|
||||
method: 'method',
|
||||
amount: 'amount',
|
||||
status: 'status',
|
||||
rawCallback: 'rawCallback',
|
||||
snapToken: 'snapToken',
|
||||
expiresAt: 'expiresAt',
|
||||
paidAt: 'paidAt',
|
||||
failedAt: 'failedAt',
|
||||
rejectionReason: 'rejectionReason',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type PaymentScalarFieldEnum = (typeof PaymentScalarFieldEnum)[keyof typeof PaymentScalarFieldEnum]
|
||||
|
||||
|
||||
export const SortOrder = {
|
||||
asc: 'asc',
|
||||
desc: 'desc'
|
||||
} as const
|
||||
|
||||
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder]
|
||||
|
||||
|
||||
export const NullableJsonNullValueInput = {
|
||||
DbNull: DbNull,
|
||||
JsonNull: JsonNull
|
||||
} as const
|
||||
|
||||
export type NullableJsonNullValueInput = (typeof NullableJsonNullValueInput)[keyof typeof NullableJsonNullValueInput]
|
||||
|
||||
|
||||
export const QueryMode = {
|
||||
default: 'default',
|
||||
insensitive: 'insensitive'
|
||||
} as const
|
||||
|
||||
export type QueryMode = (typeof QueryMode)[keyof typeof QueryMode]
|
||||
|
||||
|
||||
export const NullsOrder = {
|
||||
first: 'first',
|
||||
last: 'last'
|
||||
} as const
|
||||
|
||||
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder]
|
||||
|
||||
|
||||
export const JsonNullValueFilter = {
|
||||
DbNull: DbNull,
|
||||
JsonNull: JsonNull,
|
||||
AnyNull: AnyNull
|
||||
} as const
|
||||
|
||||
export type JsonNullValueFilter = (typeof JsonNullValueFilter)[keyof typeof JsonNullValueFilter]
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
// @ts-nocheck
|
||||
/*
|
||||
* This is a barrel export file for all models and their related types.
|
||||
*
|
||||
* 🟢 You can import this file directly.
|
||||
*/
|
||||
export type * from './models/User'
|
||||
export type * from './models/UserProfile'
|
||||
export type * from './models/Account'
|
||||
export type * from './models/OrganizerVerification'
|
||||
export type * from './models/Trip'
|
||||
export type * from './models/TripReview'
|
||||
export type * from './models/TripImage'
|
||||
export type * from './models/TripParticipant'
|
||||
export type * from './models/Booking'
|
||||
export type * from './models/Payment'
|
||||
export type * from './commonInputTypes'
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (berangkat–pulang, 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 (berangkat–pulang, 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
# Cron Setup (PM2 / self-hosted Linux)
|
||||
|
||||
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 | 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`. |
|
||||
|
||||
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
|
||||
|
||||
Generate random secret 32 byte:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
Tambah ke file `.env` yang dibaca PM2 (atau yang pasti ter-load saat process boot):
|
||||
|
||||
```bash
|
||||
CRON_SECRET="<hasil-openssl-tadi>"
|
||||
```
|
||||
|
||||
Restart PM2 supaya proses re-load env:
|
||||
|
||||
```bash
|
||||
pm2 restart setrip --update-env
|
||||
```
|
||||
|
||||
### 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):
|
||||
|
||||
```bash
|
||||
crontab -e
|
||||
```
|
||||
|
||||
Tambah baris berikut (ganti `https://your-domain.com` dan `<CRON_SECRET>` sesuai env yang di-set di step 1):
|
||||
|
||||
```cron
|
||||
# === 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:
|
||||
|
||||
```bash
|
||||
crontab -l
|
||||
```
|
||||
|
||||
### 4. Siapkan file log
|
||||
|
||||
```bash
|
||||
sudo touch /var/log/setrip-cron.log
|
||||
sudo chown $(whoami) /var/log/setrip-cron.log
|
||||
```
|
||||
|
||||
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 per cron:**
|
||||
|
||||
| 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}` |
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
## Monitoring
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
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 `/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` + semua baris crontab, restart PM2.
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
@@ -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).
|
||||
@@ -0,0 +1,84 @@
|
||||
# Setrip — Admin Audit & Investigation Roadmap (ARCHIVED — DELIVERED 2026-05-18)
|
||||
|
||||
Admin perlu **mencari** lintas entity (booking/payment/refund/user/trip) dan **export** untuk compliance + investigasi dispute.
|
||||
|
||||
---
|
||||
|
||||
## Status delivery
|
||||
|
||||
| Phase | Status | Catatan |
|
||||
|---|---|---|
|
||||
| Phase 1 — Filter & Search Enhancements | ✅ Delivered | Filter date range + reviewer di refunds/payouts/verifications via `AdminFilterBar` reusable. Reason filter di refunds. |
|
||||
| Phase 2 — Global Search | ✅ Delivered | Search bar di sidebar admin dispatch by pattern (email/order_id/cuid/fuzzy). Endpoint `/api/admin/search`. |
|
||||
| Phase 3 — CSV Export | ✅ Delivered | 3 endpoint export (refunds/payouts/verifications) dengan UTF-8 BOM untuk Excel. Tombol "⬇️ Export CSV" di tiap halaman list. |
|
||||
| Phase 4 — Generic Admin Audit Log | ✅ Delivered | Model `AdminActionLog` (polymorphic, append-only). Helper `auditLog.record()` di-wire ke semua admin server action. Halaman `/admin/audit-log` dengan filter. |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Filter & Search Enhancements ✅
|
||||
|
||||
| # | Item | Status | File |
|
||||
|---|---|---|---|
|
||||
| 1.1 | Filter date range (`dateFrom`, `dateTo`) di `/admin/refunds` | ✅ | [app/admin/refunds/page.tsx](../../app/admin/refunds/page.tsx) |
|
||||
| 1.2 | Filter `reviewer` (admin email dropdown) di `/admin/refunds` | ✅ | [app/admin/refunds/page.tsx](../../app/admin/refunds/page.tsx) |
|
||||
| 1.3 | Filter `reason` di `/admin/refunds` | ✅ | [app/admin/refunds/page.tsx](../../app/admin/refunds/page.tsx) |
|
||||
| 1.4 | Filter date range + `processor` di `/admin/payouts` | ✅ | [app/admin/payouts/page.tsx](../../app/admin/payouts/page.tsx) |
|
||||
| 1.5 | Filter date range + `reviewer` di `/admin/verifications` | ✅ | [app/admin/verifications/page.tsx](../../app/admin/verifications/page.tsx) |
|
||||
| 1.6 | Komponen reusable `AdminFilterBar` | ✅ | [features/admin/components/admin-filter-bar.tsx](../../features/admin/components/admin-filter-bar.tsx) |
|
||||
| 1.7 | Filter params di `refundRepo`/`payoutRepo`/`organizerRepo` `listByStatus` | ✅ | `server/repositories/*.ts` |
|
||||
| 1.8 | Helper `listAdminEmails()` untuk dropdown reviewer | ✅ | [lib/admin.ts](../../lib/admin.ts) |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Global Search ✅
|
||||
|
||||
| # | Item | Status | File |
|
||||
|---|---|---|---|
|
||||
| 2.1 | `adminSearchService.resolve(q)` — dispatch by pattern (email exact, order_id prefix, cuid, fuzzy) | ✅ | [server/services/admin-search.service.ts](../../server/services/admin-search.service.ts) |
|
||||
| 2.2 | Route handler `/api/admin/search?q=...` (guard isAdmin) | ✅ | [app/api/admin/search/route.ts](../../app/api/admin/search/route.ts) |
|
||||
| 2.3 | Component `AdminSearchBar` — debounced 250ms, dropdown hasil dengan type badge | ✅ | [features/admin/components/admin-search-bar.tsx](../../features/admin/components/admin-search-bar.tsx) |
|
||||
| 2.4 | Wire di admin sidebar (di bawah logo header) | ✅ | [components/admin/admin-sidebar.tsx](../../components/admin/admin-sidebar.tsx) |
|
||||
| 2.5 | Page `/admin/search?q=...` full results | ⏳ | Skip — dropdown limit 10 hit cukup; jarang butuh full page. |
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — CSV Export ✅
|
||||
|
||||
| # | Item | Status | File |
|
||||
|---|---|---|---|
|
||||
| 3.1 | Helper `lib/csv.ts` — `buildCsv`, `escapeCsvCell`, `csvResponse` dengan UTF-8 BOM | ✅ | [lib/csv.ts](../../lib/csv.ts) |
|
||||
| 3.2 | Route `/api/admin/export/refunds` — pakai filter dari query string | ✅ | [app/api/admin/export/refunds/route.ts](../../app/api/admin/export/refunds/route.ts) |
|
||||
| 3.3 | Route `/api/admin/export/payouts` | ✅ | [app/api/admin/export/payouts/route.ts](../../app/api/admin/export/payouts/route.ts) |
|
||||
| 3.4 | Route `/api/admin/export/verifications` — TANPA NIK/KTP key/bank account number (privasi) | ✅ | [app/api/admin/export/verifications/route.ts](../../app/api/admin/export/verifications/route.ts) |
|
||||
| 3.5 | Komponen `ExportCsvLink` + tombol di tiap admin list page (filter preserved) | ✅ | [features/admin/components/export-csv-link.tsx](../../features/admin/components/export-csv-link.tsx) |
|
||||
|
||||
**Tindakan manual:** test di staging dulu — pastikan tidak ada data sensitif yang ter-leak (NIK plaintext, foto KYC key, dst).
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Generic Admin Audit Log ✅
|
||||
|
||||
| # | Item | Status | File |
|
||||
|---|---|---|---|
|
||||
| 4.1 | Model `AdminActionLog` (polymorphic, append-only) + migration | ✅ | [prisma/schema.prisma](../../prisma/schema.prisma) + `prisma/migrations/20260518180000_add_admin_action_log/` |
|
||||
| 4.2 | Helper `auditLog.record({ admin, action, entityType, entityId, payload? })` | ✅ | [server/services/audit-log.service.ts](../../server/services/audit-log.service.ts) |
|
||||
| 4.3 | Wire di semua admin server action: refund approve/reject/mark/create, payout markPaid, verification approve/reject/reopen, trip admin-cancel, payment reconcile, user suspend/unsuspend | ✅ | `features/*/actions.ts` |
|
||||
| 4.4 | Page `/admin/audit-log` dengan filter (date range, admin email, entity type, action contains) + pagination basic (max 200) | ✅ | [app/admin/audit-log/page.tsx](../../app/admin/audit-log/page.tsx) |
|
||||
| 4.5 | Link "Audit Log" di sidebar | ✅ | [components/admin/admin-sidebar.tsx](../../components/admin/admin-sidebar.tsx) |
|
||||
|
||||
**Daftar action yang ter-log:**
|
||||
|
||||
| Action | Entity | Source |
|
||||
|---|---|---|
|
||||
| `USER_SUSPEND` / `USER_UNSUSPEND` | User | [features/admin/actions.ts](../../features/admin/actions.ts) |
|
||||
| `TRIP_ADMIN_CANCEL` | Trip | [features/trip/actions.ts](../../features/trip/actions.ts) |
|
||||
| `PAYMENT_RECONCILE` | Payment (orderId) | [features/booking/actions.ts](../../features/booking/actions.ts) |
|
||||
| `VERIFICATION_APPROVE` / `VERIFICATION_REJECT` / `VERIFICATION_REOPEN` | OrganizerVerification | [features/organizer/actions.ts](../../features/organizer/actions.ts) |
|
||||
| `REFUND_CREATE` / `REFUND_APPROVE` / `REFUND_REJECT` / `REFUND_SUCCEEDED` / `REFUND_FAILED` | Refund | [features/refund/actions.ts](../../features/refund/actions.ts) |
|
||||
| `PAYOUT_MARK_PAID` | Payout | [features/payout/actions.ts](../../features/payout/actions.ts) |
|
||||
|
||||
`adminId` nullable + `adminEmail` snapshot — log entry tetap auditable kalau admin dihapus.
|
||||
|
||||
**Tindakan manual ops:**
|
||||
1. Apply migration: `npx prisma migrate deploy`.
|
||||
2. Brief admin: setiap aksi mereka di panel akan tercatat di `/admin/audit-log` dengan email mereka — pakai sebagai bukti compliance saat audit eksternal.
|
||||
@@ -0,0 +1,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.
|
||||
@@ -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.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user