Compare commits
58 Commits
2de8ac4086
...
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 | |||
| ecd4dc2ef4 | |||
| 2223a4630e | |||
| c9c4c0e683 | |||
| d4db13778a | |||
| ccb3437e82 | |||
| f5d86d2414 | |||
| 7f419638b5 | |||
| 3228ef712f | |||
| 63349a144d |
@@ -1,7 +1,17 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"WebFetch(domain:unsplash.com)"
|
"WebFetch(domain:unsplash.com)",
|
||||||
|
"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*
|
.pnpm-debug.log*
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env
|
||||||
|
.env.production
|
||||||
|
.env.development
|
||||||
|
.env.local
|
||||||
|
|
||||||
# private uploads (KYC: KTP / selfie). Never serve directly.
|
# runtime uploads — KYC (encrypted, private) & trip images (public, served via
|
||||||
|
# /api/trip-images). User data, not source: keep out of git, back up separately.
|
||||||
/uploads/
|
/uploads/
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
@@ -42,3 +46,6 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
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}`.
|
||||||
+1
-1
@@ -236,7 +236,7 @@ Alur data mengikuti pola yang sama: **UI (`app/`) → server actions (`features/
|
|||||||
|
|
||||||
### Verifikasi organizer (KYC ringan)
|
### Verifikasi organizer (KYC ringan)
|
||||||
|
|
||||||
- Model `OrganizerVerification` (1-1 ke `User`) menyimpan KTP (nama, NIK unik, tanggal lahir, alamat), URL foto KTP & selfie, data rekening bank, dan status `PENDING` / `APPROVED` / `REJECTED` + audit reviewer.
|
- Model `OrganizerVerification` (1-1 ke `User`) menyimpan KTP (nama, NIK unik, tanggal lahir, alamat), storage key foto KTP & foto liveness (user memegang kertas tulisan "SETRIP" sebagai bukti pengajuan), data rekening bank, dan status `PENDING` / `APPROVED` / `REJECTED` + audit reviewer.
|
||||||
- Alur: user submit di `/verify` (`features/organizer/`) → admin review di `/admin/verifications` → setujui/tolak.
|
- Alur: user submit di `/verify` (`features/organizer/`) → admin review di `/admin/verifications` → setujui/tolak.
|
||||||
- **Gate trip berbayar:** `createTripAction` menolak `price > 0` jika user belum `APPROVED` (`organizerService.isApproved`).
|
- **Gate trip berbayar:** `createTripAction` menolak `price > 0` jika user belum `APPROVED` (`organizerService.isApproved`).
|
||||||
- **Akses admin:** `lib/admin.ts → isAdminEmail()` membaca `ADMIN_EMAILS` (env, comma-separated).
|
- **Akses admin:** `lib/admin.ts → isAdminEmail()` membaca `ADMIN_EMAILS` (env, comma-separated).
|
||||||
|
|||||||
@@ -16,8 +16,8 @@
|
|||||||
## Forbidden
|
## Forbidden
|
||||||
|
|
||||||
- Jangan query database langsung di component
|
- Jangan query database langsung di component
|
||||||
- Jangan buat arsitektur over-engineered
|
- Jangan buat arsitektur over-engineered, tidak apa apa jika lebih baik untuk performance dan struktur yang baik
|
||||||
- Jangan menambahkan dependency tanpa kebutuhan jelas
|
- Jangan menambahkan dependency tanpa kebutuhan jelas, tambahkan jika memang dibutuhkan dan gunakan dependency yang aman
|
||||||
|
|
||||||
## Output Style
|
## 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.
|
||||||
@@ -23,7 +23,7 @@ Tanpa login, pengguna tetap bisa melihat daftar trip dan detail trip, tetapi tid
|
|||||||
|
|
||||||
Organizer **tidak** bisa join trip sendiri; di detail trip ditampilkan bahwa dia adalah organizer trip ini.
|
Organizer **tidak** bisa join trip sendiri; di detail trip ditampilkan bahwa dia adalah organizer trip ini.
|
||||||
|
|
||||||
**Verifikasi organizer (untuk trip berbayar).** Trip dengan harga > 0 hanya bisa dibuat oleh user yang sudah mengirim KTP, selfie, dan data rekening di `/verify` lalu disetujui admin di `/admin/verifications`. Trip gratis tidak butuh verifikasi. Organizer yang sudah disetujui tampil dengan badge **✅ Verified Organizer** di halaman detail trip.
|
**Verifikasi organizer (untuk trip berbayar).** Trip dengan harga > 0 hanya bisa dibuat oleh user yang sudah mengirim KTP, foto liveness (memegang kertas tulisan "SETRIP"), dan data rekening di `/verify` lalu disetujui admin di `/admin/verifications`. Trip gratis tidak butuh verifikasi. Organizer yang sudah disetujui tampil dengan badge **✅ Verified Organizer** di halaman detail trip.
|
||||||
|
|
||||||
### 3. Peserta: mencari trip
|
### 3. Peserta: mencari trip
|
||||||
|
|
||||||
@@ -93,12 +93,12 @@ Alur ini menggambarkan satu peserta dari pertama kali mendaftar sampai pembayara
|
|||||||
|
|
||||||
### 5. Ringkasan peran data
|
### 5. Ringkasan peran data
|
||||||
|
|
||||||
| Konsep | Penyimpanan |
|
| Konsep | Penyimpanan |
|
||||||
|--------|-------------|
|
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Trip | `Trip`: judul, gunung, lokasi, tanggal, kuota, harga, status trip (`OPEN` / `FULL` / …), meeting point, itinerary, termasuk/tidak termasuk, relasi ke organizer |
|
| Trip | `Trip`: judul, gunung, lokasi, tanggal, kuota, harga, status trip (`OPEN` / `FULL` / …), meeting point, itinerary, termasuk/tidak termasuk, relasi ke organizer |
|
||||||
| Peserta | `TripParticipant` unik per `(tripId, userId)`: status **`PENDING`** / **`CONFIRMED`** / **`CANCELLED`**, serta **`markedPaidAt`** & **`paymentConfirmedAt`** untuk alur bayar manual |
|
| Peserta | `TripParticipant` unik per `(tripId, userId)`: status **`PENDING`** / **`CONFIRMED`** / **`CANCELLED`**, serta **`markedPaidAt`** & **`paymentConfirmedAt`** untuk alur bayar manual |
|
||||||
| Organizer (kepercayaan) | `OrganizerVerification` (1-1 ke `User`) berisi KTP, selfie, rekening, dan status (`PENDING` / `APPROVED` / `REJECTED`); badge **Verified Organizer** muncul ketika `status === "APPROVED"` (helper `lib/trust.ts → isVerifiedOrganizer`). Agregat rating & jumlah trip dihitung dari ulasan & trip. |
|
| Organizer (kepercayaan) | `OrganizerVerification` (1-1 ke `User`) berisi KTP, foto liveness (memegang kertas "SETRIP"), rekening, dan status (`PENDING` / `APPROVED` / `REJECTED`); badge **Verified Organizer** muncul ketika `status === "APPROVED"` (helper `lib/trust.ts → isVerifiedOrganizer`). Agregat rating & jumlah trip dihitung dari ulasan & trip. |
|
||||||
| Persetujuan T&C / Privasi | `User.acceptedTermsAndPrivacy` + `User.acceptedAt`, dicentang saat registrasi (link ke `/terms` & `/privacy`). |
|
| Persetujuan T&C / Privasi | `User.acceptedTermsAndPrivacy` + `User.acceptedAt`, dicentang saat registrasi (link ke `/terms` & `/privacy`). |
|
||||||
|
|
||||||
## Menjalankan secara lokal
|
## Menjalankan secara lokal
|
||||||
|
|
||||||
@@ -128,3 +128,28 @@ Buka [http://localhost:3000](http://localhost:3000).
|
|||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs)
|
- [Next.js Documentation](https://nextjs.org/docs)
|
||||||
- [Prisma Documentation](https://www.prisma.io/docs)
|
- [Prisma Documentation](https://www.prisma.io/docs)
|
||||||
|
|
||||||
|
# 1. Install SEMUA dep (termasuk dev) — deterministik dari lockfile.
|
||||||
|
|
||||||
|
# --include=dev memaksa dev terpasang walau NODE_ENV=production ter-export.
|
||||||
|
|
||||||
|
npm ci --include=dev
|
||||||
|
|
||||||
|
# 2. Prisma: generate client + apply migrasi
|
||||||
|
|
||||||
|
npx prisma generate
|
||||||
|
npx prisma migrate deploy
|
||||||
|
|
||||||
|
# 3. Build (butuh devDependencies)
|
||||||
|
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# 4. (Opsional) ramping-kan node_modules — buang dev SETELAH build selesai
|
||||||
|
|
||||||
|
npm prune --omit=dev
|
||||||
|
|
||||||
|
# 5. Jalankan
|
||||||
|
|
||||||
|
pm2 start ecosystem.config.js --env production
|
||||||
|
|
||||||
|
# atau restart: pm2 restart setrip --update-env
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# Setrip — Social Repositioning Roadmap
|
||||||
|
|
||||||
|
Status implementasi reposisi dari "open trip pendakian" → "platform untuk menemukan teman aktivitas & trip bareng".
|
||||||
|
|
||||||
|
> **Prinsip pembeda:** Setrip bukan trip-marketplace. Trip adalah kendaraan untuk koneksi sosial (stranger → teman → circle). Setiap fitur dievaluasi: apakah memperkuat **find a companion**, atau hanya **book a trip**? Kalau cuma yang kedua → tolak.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase A — Quick wins (sinyal sosial dari data yang sudah ada) ✅
|
||||||
|
|
||||||
|
Selesai. `tsc --noEmit` lulus. Migration `20260508120000_add_profile_vibe` belum di-apply ke DB.
|
||||||
|
|
||||||
|
| # | Item | Status | File |
|
||||||
|
|---|---|---|---|
|
||||||
|
| A1 | Hapus ikon `🏔️` hardcoded di trip detail → ikon + label kategori dinamis | ✅ | [app/trips/[id]/page.tsx](app/trips/[id]/page.tsx) |
|
||||||
|
| A2 | Banner onboarding profil di layout (muncul kalau `UserProfile` kosong) | ✅ | [components/shared/profile-nudge-banner.tsx](components/shared/profile-nudge-banner.tsx), [app/layout.tsx](app/layout.tsx) |
|
||||||
|
| A3 | Confirmed-peserta dirombak: chip nama → kartu (avatar + kota + 3 tag minat) | ✅ | [server/repositories/trip.repo.ts](server/repositories/trip.repo.ts), [app/trips/[id]/page.tsx](app/trips/[id]/page.tsx) |
|
||||||
|
| A4 | Field `vibe` (CHILL/BALANCED/HARDCORE) di `UserProfile` + UI editor + badge di profil publik | ✅ | [prisma/schema.prisma](prisma/schema.prisma), [prisma/migrations/20260508120000_add_profile_vibe/migration.sql](prisma/migrations/20260508120000_add_profile_vibe/migration.sql), [lib/vibe.ts](lib/vibe.ts), [features/profile/schemas.ts](features/profile/schemas.ts), [features/profile/actions.ts](features/profile/actions.ts), [server/repositories/profile.repo.ts](server/repositories/profile.repo.ts), [server/repositories/user.repo.ts](server/repositories/user.repo.ts), [server/services/profile.service.ts](server/services/profile.service.ts), [features/profile/components/profile-editor.tsx](features/profile/components/profile-editor.tsx), [app/profile/page.tsx](app/profile/page.tsx), [app/u/[id]/page.tsx](app/u/[id]/page.tsx) |
|
||||||
|
|
||||||
|
**Tindakan manual:** jalankan `npx prisma migrate deploy` (atau `dev`) untuk apply migration `20260507185257_add_user_profile` + `20260508120000_add_profile_vibe`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase B — Discovery (people-first, bukan price-first) ✅
|
||||||
|
|
||||||
|
Selesai. `tsc --noEmit` lulus. Migration `20260508130000_add_trip_vibe` belum di-apply ke DB.
|
||||||
|
|
||||||
|
**Keputusan desain:** `Trip` reuse enum `Vibe` yang sama dengan `UserProfile` (alih-alih bikin `pace`/`level` baru) supaya matching peserta↔trip langsung selaras tanpa mapping.
|
||||||
|
|
||||||
|
| # | Item | Status | File |
|
||||||
|
|---|---|---|---|
|
||||||
|
| B1 | Halaman `/people` — daftar user dengan profil terisi | ✅ | [app/people/page.tsx](app/people/page.tsx), [server/repositories/user.repo.ts](server/repositories/user.repo.ts) (`findPeople`), [server/services/profile.service.ts](server/services/profile.service.ts) |
|
||||||
|
| B2 | Filter kota, interests, vibe di `/people` | ✅ | [features/profile/components/people-filter.tsx](features/profile/components/people-filter.tsx), [features/profile/components/user-card.tsx](features/profile/components/user-card.tsx) |
|
||||||
|
| B3 | Field `vibe` di `Trip` + tampil di trip detail & TripCard + filter di `/trips` | ✅ | [prisma/schema.prisma](prisma/schema.prisma), [prisma/migrations/20260508130000_add_trip_vibe/migration.sql](prisma/migrations/20260508130000_add_trip_vibe/migration.sql), [features/trip/schemas.ts](features/trip/schemas.ts), [features/trip/actions.ts](features/trip/actions.ts), [server/services/trip.service.ts](server/services/trip.service.ts), [features/trip/components/create-trip-form.tsx](features/trip/components/create-trip-form.tsx), [features/trip/components/trip-card.tsx](features/trip/components/trip-card.tsx), [app/trips/[id]/page.tsx](app/trips/[id]/page.tsx) |
|
||||||
|
| B4 | TripCard: 3 avatar peserta confirmed + counter `+N` | ✅ | [server/repositories/trip.repo.ts](server/repositories/trip.repo.ts) (include participants di `findOpen`), [features/trip/components/trip-card.tsx](features/trip/components/trip-card.tsx), [app/page.tsx](app/page.tsx), [app/trips/page.tsx](app/trips/page.tsx) |
|
||||||
|
| B5 | TripCard: badge "✨ X peserta sama minat" untuk user login | ✅ | [features/trip/components/trip-card.tsx](features/trip/components/trip-card.tsx) (compute overlap), homepage & `/trips` (fetch viewer interests) |
|
||||||
|
| B6 | Filter ukuran grup (Small ≤10 / Medium 11–20 / Large 21+) | ✅ | [server/repositories/trip.repo.ts](server/repositories/trip.repo.ts) (`GroupSize` filter), [features/trip/components/trip-filter.tsx](features/trip/components/trip-filter.tsx), [app/trips/page.tsx](app/trips/page.tsx) |
|
||||||
|
| B7 | Section "Budget Friendly" → "Lagi Ramai" (social proof) | ✅ | [app/page.tsx](app/page.tsx) — sort by `participantCount desc`, framing "kamu nggak bakal jalan sendirian" |
|
||||||
|
| B+ | Link `/people` di navbar (desktop + mobile) | ✅ | [components/shared/navbar.tsx](components/shared/navbar.tsx) |
|
||||||
|
|
||||||
|
**Tindakan manual:** jalankan `npx prisma migrate deploy` untuk apply migration `20260508130000_add_trip_vibe` (selain 2 migration Phase A yang masih pending).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Patch — KYC liveness photo rename (di luar fase social repositioning)
|
||||||
|
|
||||||
|
Mengubah foto liveness dari "selfie memegang KTP" (pola KYC standar) menjadi "memegang kertas tulisan SETRIP".
|
||||||
|
|
||||||
|
| Item | Status | File |
|
||||||
|
|---|---|---|
|
||||||
|
| Field `selfieKey` → `livenessKey` di `OrganizerVerification` | ✅ | [prisma/schema.prisma](prisma/schema.prisma), [prisma/migrations/20260508140000_rename_selfie_to_liveness/migration.sql](prisma/migrations/20260508140000_rename_selfie_to_liveness/migration.sql) |
|
||||||
|
| Storage kind `selfie` → `liveness` (path `liveness/<id>.<ext>`) | ✅ | [lib/secure-storage.ts](lib/secure-storage.ts) |
|
||||||
|
| Validasi + action + service + verify-form + review-card | ✅ | [features/organizer/schemas.ts](features/organizer/schemas.ts), [features/organizer/actions.ts](features/organizer/actions.ts), [server/services/organizer.service.ts](server/services/organizer.service.ts), [features/organizer/components/verify-form.tsx](features/organizer/components/verify-form.tsx), [features/organizer/components/review-card.tsx](features/organizer/components/review-card.tsx) |
|
||||||
|
| API routes `/api/upload/kyc` & `/api/files/kyc/[id]/[kind]` | ✅ | [app/api/upload/kyc/route.ts](app/api/upload/kyc/route.ts), [app/api/files/kyc/[id]/[kind]/route.ts](app/api/files/kyc/%5Bid%5D/%5Bkind%5D/route.ts) |
|
||||||
|
| Halaman verify, admin, seed, README, ARCHITECTURE | ✅ | [app/verify/page.tsx](app/verify/page.tsx), [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx), [app/create-trip/page.tsx](app/create-trip/page.tsx), [prisma/seed.ts](prisma/seed.ts), [README.md](README.md), [ARCHITECTURE.md](ARCHITECTURE.md) |
|
||||||
|
|
||||||
|
**Trade-off keamanan yang sudah dikomunikasikan:** pola "selfie + KTP" lebih kuat (membuktikan KTP fisik di tangan pemilik). Pola "selfie + kertas SETRIP" lebih lemah dari sisi binding KTP↔orang, tapi mengurangi paparan KTP user dan masih mencegah replay dari platform lain. Risiko fraud naik sedikit — tetap dipilih atas request user.
|
||||||
|
|
||||||
|
**Catatan migrasi data lama:** kolom DB di-rename, tapi nilai-nilai key lama masih punya prefix `selfie/` (mis. `selfie/abc.jpg`). Setelah migration di-apply, validasi schema menolak prefix lama → user dengan pengajuan PENDING perlu re-upload foto liveness baru. Folder fisik `uploads/private/selfie/` tidak dipakai lagi, bisa dihapus manual setelah konfirmasi tidak ada data aktif yang merujuk.
|
||||||
|
|
||||||
|
**Tindakan manual:** jalankan `npx prisma migrate deploy` untuk apply `20260508140000_rename_selfie_to_liveness` (sekarang total 4 migration pending kalau belum pernah deploy).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase C — Interaksi & continuity (separate, lebih besar) ⏳
|
||||||
|
|
||||||
|
Belum mulai. Setiap item bisa jadi PR terpisah karena perlu schema baru + UI substansial.
|
||||||
|
|
||||||
|
| # | Item | Status | Catatan |
|
||||||
|
|---|---|---|---|
|
||||||
|
| C1 | Model `TripMessage` — Q&A publik per trip (sebelum berangkat) | ⏳ pending | Schema + actions + UI di trip detail. Calon peserta tanya organizer tanpa keluar app. |
|
||||||
|
| C2 | Group chat untuk peserta CONFIRMED (post-confirmation) | ⏳ pending | Bisa pakai tabel `TripMessage` yang sama dengan flag `audience` (PUBLIC/CONFIRMED_ONLY). |
|
||||||
|
| C3 | Model `Connection` (follow / circle) antar user | ⏳ pending | Foundation untuk "from strangers → circle". Halaman "Circle saya". |
|
||||||
|
| C4 | Notifikasi: organizer punya pending join, peserta dapat balasan Q&A, dst | ⏳ pending | Bisa email dulu, in-app belakangan. |
|
||||||
|
| C5 | Post-trip continuity: tombol "follow buddies dari trip ini" + album foto bareng | ⏳ pending | Momen konversi stranger → circle terbesar saat ini terbuang. |
|
||||||
|
| C6 | Review user (bukan cuma trip) — reputasi peserta (no-show? kooperatif?) | ⏳ pending | Lengkapi trust layer. Anti-scam. |
|
||||||
|
| C7 | Onboarding flow wajib post-register (bukan banner) — minta minimal 3 interests + city + vibe sebelum bisa join trip | ⏳ pending | Banner Phase A2 cuma soft nudge. Hard-gate saat user pertama kali pencet "Join". |
|
||||||
|
| C8 | Referral / invite-with-link | ⏳ pending | Loop pertumbuhan komunitas. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❌ Anti-list (yang harus DITOLAK kalau muncul)
|
||||||
|
|
||||||
|
Fitur-fitur ini akan menarik Setrip ke arena OTA (Traveloka/Klook) yang tidak bisa dimenangkan:
|
||||||
|
|
||||||
|
- Booking hotel / tiket pesawat
|
||||||
|
- Tour massal tanpa interaksi (>30 orang, bus pariwisata)
|
||||||
|
- Mass listing dari travel agent (B2B aggregator)
|
||||||
|
- Filter & sort harga yang lebih agresif (price-low-to-high, dll.) — perkuat framing harga-dulu
|
||||||
|
- Affiliate/komisi dari pihak ketiga yang bukan organizer terverifikasi
|
||||||
|
- SEO-driven mass content untuk destinasi (artikel "10 Gunung Terbaik di Jawa") tanpa angle social
|
||||||
|
- Integrasi pembayaran kompleks (split-bill, escrow rumit) sebelum chat dasar (C1) ada — prioritas terbalik
|
||||||
|
|
||||||
|
Kalau muncul request ke arah ini, tanya: "ini meningkatkan kemungkinan dua orang asing kenalan, atau cuma memudahkan transaksi?" Kalau jawabannya yang kedua → tolak / tunda.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Konteks positioning (referensi cepat)
|
||||||
|
|
||||||
|
**Untuk siapa:** orang yang ingin pergi tapi tidak punya teman, ingin kenalan baru lewat aktivitas bareng.
|
||||||
|
|
||||||
|
**Bukan untuk:** orang yang sudah punya grup dan tinggal cari paket trip termurah.
|
||||||
|
|
||||||
|
**Categories yang valid** (semua harus punya: organizer, group kecil, interaksi sosial):
|
||||||
|
- Core: hiking, camping
|
||||||
|
- Natural expansion: snorkeling, diving, island hopping
|
||||||
|
- Social activity: city trip, kulineran, konser bareng
|
||||||
|
- Semi-professional: workshop, kelas outdoor, retreat
|
||||||
|
|
||||||
|
**Tagline:** "Pergi bareng, bukan sendiri" / "From strangers to travel buddies".
|
||||||
@@ -59,19 +59,28 @@ Dengan menggunakan SeTrip, Anda menyatakan bahwa:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 6. Pembayaran
|
# 6. Pembayaran & Escrow
|
||||||
|
|
||||||
- Pembayaran dilakukan sesuai metode yang tersedia di platform
|
- Pembayaran dilakukan melalui metode yang tersedia di platform (Midtrans atau transfer manual yang dikonfirmasi organizer)
|
||||||
- Dalam fase awal, pembayaran dapat dilakukan langsung kepada organizer
|
- **Uang peserta ditahan oleh SeTrip (escrow)** sejak pembayaran berhasil hingga trip selesai + 3 hari, baru kemudian diteruskan ke organizer
|
||||||
- SeTrip tidak menjamin keamanan transaksi yang dilakukan di luar platform
|
- 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
|
# 7. Pembatalan & Refund
|
||||||
|
|
||||||
- Kebijakan pembatalan ditentukan oleh organizer
|
**Saat peserta membatalkan booking sendiri** (kebijakan default platform):
|
||||||
- SeTrip tidak bertanggung jawab atas refund yang tidak diberikan oleh organizer
|
|
||||||
- Pengguna disarankan untuk memahami kebijakan sebelum melakukan pembayaran
|
- **≥ 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 Link from "next/link";
|
||||||
|
import { Lock, Clock, CircleAlert } from "lucide-react";
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
import { organizerService } from "@/server/services/organizer.service";
|
import { organizerService } from "@/server/services/organizer.service";
|
||||||
@@ -11,8 +12,13 @@ export default async function CreateTripPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-[calc(100vh-3.5rem)] items-center justify-center px-4">
|
<div className="flex min-h-[calc(100vh-3.5rem)] items-center justify-center px-4">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary-50 text-3xl">
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary-50">
|
||||||
🔒
|
<Lock
|
||||||
|
size={28}
|
||||||
|
strokeWidth={1.75}
|
||||||
|
aria-hidden
|
||||||
|
className="text-primary-600"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-4 text-neutral-500">
|
<p className="mb-4 text-neutral-500">
|
||||||
Kamu harus login untuk membuat trip.
|
Kamu harus login untuk membuat trip.
|
||||||
@@ -57,8 +63,9 @@ function VerificationBanner({
|
|||||||
if (status === "PENDING") {
|
if (status === "PENDING") {
|
||||||
return (
|
return (
|
||||||
<div className="mb-5 rounded-2xl border border-amber-200 bg-amber-50 p-4 sm:p-5">
|
<div className="mb-5 rounded-2xl border border-amber-200 bg-amber-50 p-4 sm:p-5">
|
||||||
<p className="text-sm font-bold text-amber-800">
|
<p className="flex items-center gap-1.5 text-sm font-bold text-amber-800">
|
||||||
⏳ Verifikasi sedang diproses
|
<Clock size={15} strokeWidth={2} aria-hidden />
|
||||||
|
Verifikasi sedang diproses
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-sm text-neutral-700">
|
<p className="mt-1 text-sm text-neutral-700">
|
||||||
Pengajuan verifikasi-mu masih ditinjau admin. Sementara menunggu, kamu
|
Pengajuan verifikasi-mu masih ditinjau admin. Sementara menunggu, kamu
|
||||||
@@ -73,13 +80,14 @@ function VerificationBanner({
|
|||||||
<div className="mb-5 rounded-2xl border border-amber-200 bg-amber-50 p-4 sm:p-5">
|
<div className="mb-5 rounded-2xl border border-amber-200 bg-amber-50 p-4 sm:p-5">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-bold text-amber-800">
|
<p className="flex items-center gap-1.5 text-sm font-bold text-amber-800">
|
||||||
⚠️ {isRejected ? "Verifikasi ditolak" : "Belum terverifikasi"}
|
<CircleAlert size={15} strokeWidth={2} aria-hidden />
|
||||||
|
{isRejected ? "Verifikasi ditolak" : "Belum terverifikasi"}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-sm text-neutral-700">
|
<p className="mt-1 text-sm text-neutral-700">
|
||||||
{isRejected
|
{isRejected
|
||||||
? "Pengajuan sebelumnya ditolak. Untuk membuat trip berbayar, perbaiki data dan ajukan ulang."
|
? "Pengajuan sebelumnya ditolak. Untuk membuat trip berbayar, perbaiki data dan ajukan ulang."
|
||||||
: "Untuk membuat trip berbayar, akun kamu perlu diverifikasi (KTP, selfie, & rekening). Trip gratis tidak butuh verifikasi."}
|
: "Untuk membuat trip berbayar, akun kamu perlu diverifikasi (KTP, foto memegang kertas SETRIP, & rekening). Trip gratis tidak butuh verifikasi."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
@@ -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";
|
"use client";
|
||||||
|
|
||||||
import { useState, Suspense } from "react";
|
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 { useRouter, useSearchParams } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
@@ -38,7 +38,16 @@ function LoginForm() {
|
|||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
setError(result.error);
|
setError(result.error);
|
||||||
} else {
|
} 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.push(next);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
@@ -78,7 +87,7 @@ function LoginForm() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Card */}
|
{/* Card */}
|
||||||
<div className="rounded-2xl border border-white/10 bg-white/95 p-6 shadow-2xl backdrop-blur-sm">
|
<div className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-2xl">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600">
|
<div className="mb-4 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600">
|
||||||
{error}
|
{error}
|
||||||
@@ -1,10 +1,33 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
import { tripService } from "@/server/services/trip.service";
|
import { tripService } from "@/server/services/trip.service";
|
||||||
|
import { profileRepo } from "@/server/repositories/profile.repo";
|
||||||
import { TripCard } from "@/features/trip/components/trip-card";
|
import { TripCard } from "@/features/trip/components/trip-card";
|
||||||
import { siteConfig, siteUrl, absoluteUrl } from "@/lib/site";
|
import { siteConfig, siteUrl, absoluteUrl } from "@/lib/site";
|
||||||
import { ACTIVITY_CATEGORIES, categoryMeta } from "@/lib/activity-category";
|
import { ACTIVITY_CATEGORIES, categoryMeta } from "@/lib/activity-category";
|
||||||
|
import {
|
||||||
|
Compass,
|
||||||
|
Flame,
|
||||||
|
Mountain,
|
||||||
|
Handshake,
|
||||||
|
Tent,
|
||||||
|
Plus,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
type OpenTrip = Awaited<ReturnType<typeof tripService.getOpenTrips>>[number];
|
||||||
|
|
||||||
|
function mapParticipants(trip: OpenTrip) {
|
||||||
|
return trip.participants.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.user.name,
|
||||||
|
image: p.user.image,
|
||||||
|
interests: p.user.profile?.interests ?? [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Cari Teman Trip & Aktivitas — Pergi Bareng, Bukan Sendiri",
|
title: "Cari Teman Trip & Aktivitas — Pergi Bareng, Bukan Sendiri",
|
||||||
@@ -18,11 +41,21 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
const trips = await tripService.getOpenTrips();
|
const session = await getServerSession(authOptions);
|
||||||
|
const [trips, viewerProfile] = await Promise.all([
|
||||||
|
tripService.getOpenTrips(),
|
||||||
|
session?.user?.id
|
||||||
|
? profileRepo.findByUserId(session.user.id)
|
||||||
|
: Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
const viewerInterests = viewerProfile?.interests ?? [];
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
// Social proof: total orang yang sudah gabung di seluruh open trip.
|
||||||
|
const joinerCount = trips.reduce((sum, t) => sum + t._count.participants, 0);
|
||||||
|
|
||||||
const upcomingTrips = trips
|
const upcomingTrips = trips
|
||||||
.filter((t) => new Date(t.date) <= nextWeek)
|
.filter((t) => new Date(t.date) <= nextWeek)
|
||||||
.slice(0, 3);
|
.slice(0, 3);
|
||||||
@@ -35,8 +68,10 @@ export default async function HomePage() {
|
|||||||
|
|
||||||
const shownIds = new Set([...upcomingIds, ...latestTrips.map((t) => t.id)]);
|
const shownIds = new Set([...upcomingIds, ...latestTrips.map((t) => t.id)]);
|
||||||
|
|
||||||
const budgetTrips = trips
|
// Section sosial: trip yang paling ramai joiner-nya (social proof, bukan price proof).
|
||||||
.filter((t) => !shownIds.has(t.id) && t.price <= 300000)
|
const buzzingTrips = trips
|
||||||
|
.filter((t) => !shownIds.has(t.id) && t._count.participants > 0)
|
||||||
|
.sort((a, b) => b._count.participants - a._count.participants)
|
||||||
.slice(0, 3);
|
.slice(0, 3);
|
||||||
|
|
||||||
const orgJsonLd = {
|
const orgJsonLd = {
|
||||||
@@ -84,12 +119,17 @@ export default async function HomePage() {
|
|||||||
className="object-cover opacity-10 brightness-150"
|
className="object-cover opacity-10 brightness-150"
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-linear-to-br from-primary-900/85 via-neutral-900/75 to-secondary-900/80" />
|
<div className="absolute inset-0 bg-linear-to-br from-neutral-900/90 to-primary-900/80" />
|
||||||
|
|
||||||
<div className="relative mx-auto max-w-4xl px-4 pb-10 pt-8 text-center sm:pb-14 sm:pt-12 lg:pb-16 lg:pt-14">
|
<div className="relative mx-auto max-w-4xl px-4 pb-10 pt-8 text-center sm:pb-14 sm:pt-12 lg:pb-16 lg:pt-14">
|
||||||
{/* Brand badge */}
|
{/* Brand badge */}
|
||||||
<div className="mb-4 inline-flex items-center gap-1.5 rounded-full border border-primary-400/30 bg-primary-600/20 px-3 py-1 sm:mb-6 sm:gap-2 sm:px-4 sm:py-1.5">
|
<div className="mb-4 inline-flex items-center gap-1.5 rounded-full border border-primary-400/30 bg-primary-600/20 px-3 py-1 sm:mb-6 sm:gap-2 sm:px-4 sm:py-1.5">
|
||||||
<span className="text-xs sm:text-sm">🤝</span>
|
<Handshake
|
||||||
|
size={14}
|
||||||
|
strokeWidth={1.75}
|
||||||
|
aria-hidden
|
||||||
|
className="text-primary-300"
|
||||||
|
/>
|
||||||
<span className="text-xs font-medium text-primary-300 sm:text-sm">
|
<span className="text-xs font-medium text-primary-300 sm:text-sm">
|
||||||
Cari teman trip & aktivitas
|
Cari teman trip & aktivitas
|
||||||
</span>
|
</span>
|
||||||
@@ -127,8 +167,12 @@ export default async function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="h-8 w-px bg-neutral-700 sm:h-10" />
|
<div className="h-8 w-px bg-neutral-700 sm:h-10" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xl font-bold text-white sm:text-2xl">100%</p>
|
<p className="text-xl font-bold text-white sm:text-2xl">
|
||||||
<p className="text-[11px] text-neutral-400 sm:text-xs">Seru</p>
|
{joinerCount}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-neutral-400 sm:text-xs">
|
||||||
|
Sudah Gabung
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,19 +182,11 @@ export default async function HomePage() {
|
|||||||
<div className="mx-auto max-w-6xl px-4 py-6 space-y-8 sm:py-8 sm:space-y-10 lg:py-10 lg:space-y-12">
|
<div className="mx-auto max-w-6xl px-4 py-6 space-y-8 sm:py-8 sm:space-y-10 lg:py-10 lg:space-y-12">
|
||||||
{/* Jelajah per kategori */}
|
{/* Jelajah per kategori */}
|
||||||
<section>
|
<section>
|
||||||
<div className="mb-4 flex items-center gap-3 sm:mb-5">
|
<SectionHeading
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-secondary-100 text-base sm:h-9 sm:w-9 sm:text-lg">
|
icon={Compass}
|
||||||
✨
|
title="Jelajah per Kategori"
|
||||||
</div>
|
subtitle="Hiking, diving, konser, sampai retreat"
|
||||||
<div>
|
/>
|
||||||
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
|
|
||||||
Jelajah per Kategori
|
|
||||||
</h2>
|
|
||||||
<p className="text-[11px] text-neutral-500 sm:text-xs">
|
|
||||||
Hiking, diving, konser, sampai retreat
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{ACTIVITY_CATEGORIES.map((c) => {
|
{ACTIVITY_CATEGORIES.map((c) => {
|
||||||
const m = categoryMeta(c);
|
const m = categoryMeta(c);
|
||||||
@@ -171,19 +207,11 @@ export default async function HomePage() {
|
|||||||
{/* Trip Terdekat */}
|
{/* Trip Terdekat */}
|
||||||
{upcomingTrips.length > 0 && (
|
{upcomingTrips.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<div className="mb-4 flex items-center gap-3 sm:mb-5">
|
<SectionHeading
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-100 text-base sm:h-9 sm:w-9 sm:text-lg">
|
icon={Flame}
|
||||||
🔥
|
title="Trip Terdekat"
|
||||||
</div>
|
subtitle="Berangkat dalam 7 hari ke depan"
|
||||||
<div>
|
/>
|
||||||
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
|
|
||||||
Trip Terdekat
|
|
||||||
</h2>
|
|
||||||
<p className="text-[11px] text-neutral-500 sm:text-xs">
|
|
||||||
Berangkat dalam 7 hari ke depan
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{upcomingTrips.slice(0, 3).map((trip, i) => (
|
{upcomingTrips.slice(0, 3).map((trip, i) => (
|
||||||
<TripCard
|
<TripCard
|
||||||
@@ -191,6 +219,7 @@ export default async function HomePage() {
|
|||||||
id={trip.id}
|
id={trip.id}
|
||||||
title={trip.title}
|
title={trip.title}
|
||||||
category={trip.category}
|
category={trip.category}
|
||||||
|
vibe={trip.vibe}
|
||||||
destination={trip.destination}
|
destination={trip.destination}
|
||||||
location={trip.location}
|
location={trip.location}
|
||||||
date={trip.date}
|
date={trip.date}
|
||||||
@@ -204,6 +233,8 @@ export default async function HomePage() {
|
|||||||
isVerifiedOrganizer={
|
isVerifiedOrganizer={
|
||||||
trip.organizer.organizerVerification?.status === "APPROVED"
|
trip.organizer.organizerVerification?.status === "APPROVED"
|
||||||
}
|
}
|
||||||
|
participants={mapParticipants(trip)}
|
||||||
|
viewerInterests={viewerInterests}
|
||||||
priority={i === 0}
|
priority={i === 0}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -213,32 +244,29 @@ export default async function HomePage() {
|
|||||||
|
|
||||||
{/* Open Trip */}
|
{/* Open Trip */}
|
||||||
<section>
|
<section>
|
||||||
<div className="mb-4 flex items-center justify-between sm:mb-5">
|
<SectionHeading
|
||||||
<div className="flex items-center gap-3">
|
icon={Mountain}
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-secondary-100 text-base sm:h-9 sm:w-9 sm:text-lg">
|
title="Open Trip"
|
||||||
🏔️
|
subtitle="Pilih trip, ketemu teman baru"
|
||||||
</div>
|
action={
|
||||||
<div>
|
<Link
|
||||||
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
|
href="/trips"
|
||||||
Open Trip
|
className="shrink-0 rounded-lg bg-secondary-50 px-2.5 py-1 text-xs font-medium text-secondary-600 hover:bg-secondary-100 sm:px-3 sm:py-1.5 sm:text-sm"
|
||||||
</h2>
|
>
|
||||||
<p className="hidden text-xs text-neutral-500 sm:block">
|
Lihat semua
|
||||||
Pilih trip, ketemu teman baru
|
</Link>
|
||||||
</p>
|
}
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href="/trips"
|
|
||||||
className="rounded-lg bg-secondary-50 px-2.5 py-1 text-xs font-medium text-secondary-600 hover:bg-secondary-100 sm:px-3 sm:py-1.5 sm:text-sm"
|
|
||||||
>
|
|
||||||
Lihat semua
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{latestTrips.length === 0 ? (
|
{latestTrips.length === 0 ? (
|
||||||
<div className="rounded-2xl border-2 border-dashed border-neutral-200 bg-white p-8 text-center sm:p-14">
|
<div className="rounded-2xl border-2 border-dashed border-neutral-200 bg-white p-8 text-center sm:p-14">
|
||||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 text-2xl sm:h-16 sm:w-16 sm:text-3xl">
|
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 sm:h-16 sm:w-16">
|
||||||
🏕️
|
<Tent
|
||||||
|
size={26}
|
||||||
|
strokeWidth={1.75}
|
||||||
|
aria-hidden
|
||||||
|
className="text-primary-600"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-1 text-base font-bold text-neutral-800 sm:text-lg">
|
<p className="mb-1 text-base font-bold text-neutral-800 sm:text-lg">
|
||||||
Belum ada trip tersedia
|
Belum ada trip tersedia
|
||||||
@@ -261,6 +289,7 @@ export default async function HomePage() {
|
|||||||
id={trip.id}
|
id={trip.id}
|
||||||
title={trip.title}
|
title={trip.title}
|
||||||
category={trip.category}
|
category={trip.category}
|
||||||
|
vibe={trip.vibe}
|
||||||
destination={trip.destination}
|
destination={trip.destination}
|
||||||
location={trip.location}
|
location={trip.location}
|
||||||
date={trip.date}
|
date={trip.date}
|
||||||
@@ -274,35 +303,30 @@ export default async function HomePage() {
|
|||||||
isVerifiedOrganizer={
|
isVerifiedOrganizer={
|
||||||
trip.organizer.organizerVerification?.status === "APPROVED"
|
trip.organizer.organizerVerification?.status === "APPROVED"
|
||||||
}
|
}
|
||||||
|
participants={mapParticipants(trip)}
|
||||||
|
viewerInterests={viewerInterests}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Budget Friendly */}
|
{/* Lagi Ramai — social proof, bukan price proof */}
|
||||||
{budgetTrips.length > 0 && (
|
{buzzingTrips.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<div className="mb-4 flex items-center gap-3 sm:mb-5">
|
<SectionHeading
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-100 text-base sm:h-9 sm:w-9 sm:text-lg">
|
icon={Handshake}
|
||||||
💸
|
title="Lagi Ramai"
|
||||||
</div>
|
subtitle="Banyak yang sudah gabung — kamu nggak bakal jalan sendirian"
|
||||||
<div>
|
/>
|
||||||
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
|
|
||||||
Budget Friendly
|
|
||||||
</h2>
|
|
||||||
<p className="text-[11px] text-neutral-500 sm:text-xs">
|
|
||||||
Trip di bawah Rp 300.000
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{budgetTrips.map((trip) => (
|
{buzzingTrips.map((trip) => (
|
||||||
<TripCard
|
<TripCard
|
||||||
key={trip.id}
|
key={trip.id}
|
||||||
id={trip.id}
|
id={trip.id}
|
||||||
title={trip.title}
|
title={trip.title}
|
||||||
category={trip.category}
|
category={trip.category}
|
||||||
|
vibe={trip.vibe}
|
||||||
destination={trip.destination}
|
destination={trip.destination}
|
||||||
location={trip.location}
|
location={trip.location}
|
||||||
date={trip.date}
|
date={trip.date}
|
||||||
@@ -316,6 +340,8 @@ export default async function HomePage() {
|
|||||||
isVerifiedOrganizer={
|
isVerifiedOrganizer={
|
||||||
trip.organizer.organizerVerification?.status === "APPROVED"
|
trip.organizer.organizerVerification?.status === "APPROVED"
|
||||||
}
|
}
|
||||||
|
participants={mapParticipants(trip)}
|
||||||
|
viewerInterests={viewerInterests}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -351,11 +377,48 @@ export default async function HomePage() {
|
|||||||
{/* ========== FAB ========== */}
|
{/* ========== FAB ========== */}
|
||||||
<Link
|
<Link
|
||||||
href="/create-trip"
|
href="/create-trip"
|
||||||
className="fixed bottom-4 right-4 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-primary-600 text-xl font-bold text-white shadow-xl shadow-primary-600/30 transition-all hover:scale-110 hover:bg-primary-500 active:scale-95 sm:bottom-6 sm:right-6 sm:h-14 sm:w-14 sm:text-2xl"
|
className="fixed bottom-4 right-4 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-primary-600 text-white shadow-xl shadow-primary-600/30 transition-all hover:scale-110 hover:bg-primary-500 active:scale-95 sm:bottom-6 sm:right-6 sm:h-14 sm:w-14"
|
||||||
title="Buat Trip"
|
aria-label="Buat Trip"
|
||||||
>
|
>
|
||||||
+
|
<Plus size={24} strokeWidth={2} aria-hidden />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Heading section homepage — ikon stroke + judul, opsional aksi di kanan. */
|
||||||
|
function SectionHeading({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
icon: LucideIcon;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
action?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mb-4 flex items-center justify-between gap-3 sm:mb-5">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<Icon
|
||||||
|
size={22}
|
||||||
|
strokeWidth={1.75}
|
||||||
|
aria-hidden
|
||||||
|
className="shrink-0 text-primary-600"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{subtitle && (
|
||||||
|
<p className="text-[11px] text-neutral-500 sm:text-xs">
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { profileService } from "@/server/services/profile.service";
|
||||||
|
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<{
|
||||||
|
city?: string;
|
||||||
|
interest?: string;
|
||||||
|
vibe?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
searchParams,
|
||||||
|
}: PeoplePageProps): Promise<Metadata> {
|
||||||
|
const { city, interest, vibe: vibeParam } = await searchParams;
|
||||||
|
const vibe = isVibe(vibeParam) ? vibeParam : undefined;
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (vibe) parts.push(`Vibe ${vibeLabel(vibe).toLowerCase()}`);
|
||||||
|
if (city) parts.push(`di ${city}`);
|
||||||
|
if (interest) parts.push(`#${interest.toLowerCase()}`);
|
||||||
|
const title = parts.length
|
||||||
|
? `Cari Teman ${parts.join(" ")}`
|
||||||
|
: "Cari Teman Aktivitas — Profil Anggota";
|
||||||
|
const description = `Telusuri profil anggota ${siteConfig.name} berdasarkan minat, kota, dan vibe. Temukan calon teman trip dengan ritme yang cocok sebelum gabung bareng.`;
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
alternates: { canonical: "/people" },
|
||||||
|
openGraph: { title, description, url: "/people" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PeoplePage({ searchParams }: PeoplePageProps) {
|
||||||
|
const params = await searchParams;
|
||||||
|
const vibe = isVibe(params.vibe) ? params.vibe : undefined;
|
||||||
|
const filters = {
|
||||||
|
city: params.city?.trim() || undefined,
|
||||||
|
interest: params.interest?.trim().toLowerCase() || undefined,
|
||||||
|
vibe,
|
||||||
|
};
|
||||||
|
const hasFilters = Boolean(filters.city || filters.interest || filters.vibe);
|
||||||
|
|
||||||
|
const people = await profileService.findPeople(filters);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-6xl px-4 py-6 sm:py-8">
|
||||||
|
<div className="mb-5 flex flex-col gap-2 sm:mb-6">
|
||||||
|
<h1 className="text-xl font-bold text-neutral-800 sm:text-2xl">
|
||||||
|
Cari Teman Aktivitas
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
{hasFilters
|
||||||
|
? `${people.length} orang ditemukan dengan filter di atas`
|
||||||
|
: `${people.length} anggota dengan profil sosial — kenali dulu sebelum gabung trip`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<PeopleFilter />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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 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
|
||||||
|
? "Belum ada anggota yang cocok"
|
||||||
|
: "Belum ada anggota dengan profil terisi"}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
{hasFilters
|
||||||
|
? "Coba longgarkan filter — kota, minat, atau vibe."
|
||||||
|
: "Setelah anggota lain mengisi profil, mereka akan muncul di sini."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{people.map((u) => (
|
||||||
|
<li key={u.id}>
|
||||||
|
<UserCard
|
||||||
|
id={u.id}
|
||||||
|
name={u.name}
|
||||||
|
image={u.image}
|
||||||
|
isVerifiedOrganizer={
|
||||||
|
u.organizerVerification?.status === "APPROVED"
|
||||||
|
}
|
||||||
|
profile={u.profile}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { ShieldCheck, CircleCheck } from "lucide-react";
|
||||||
|
|
||||||
export default function PrivacyPage() {
|
export default function PrivacyPage() {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-3xl px-4 py-8 sm:py-12">
|
<div className="mx-auto max-w-3xl px-4 py-8 sm:py-12">
|
||||||
<article className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm sm:p-10">
|
<article className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm sm:p-10">
|
||||||
<header className="mb-8 border-b border-neutral-200 pb-6">
|
<header className="mb-8 border-b border-neutral-200 pb-6">
|
||||||
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
|
<h1 className="flex items-center gap-2 text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||||
🔒 Kebijakan Privasi SeTrip
|
<ShieldCheck
|
||||||
|
size={28}
|
||||||
|
strokeWidth={1.75}
|
||||||
|
aria-hidden
|
||||||
|
className="shrink-0 text-primary-600"
|
||||||
|
/>
|
||||||
|
Kebijakan Privasi SeTrip
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm text-neutral-500">
|
<p className="mt-2 text-sm text-neutral-500">
|
||||||
Terakhir diperbarui: 2026-04-27
|
Terakhir diperbarui: 2026-04-27
|
||||||
@@ -205,7 +212,15 @@ export default function PrivacyPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-xl bg-neutral-50 p-5">
|
<section className="rounded-xl bg-neutral-50 p-5">
|
||||||
<h2 className="mb-2 text-lg font-bold text-neutral-900">✅ Persetujuan</h2>
|
<h2 className="mb-2 flex items-center gap-1.5 text-lg font-bold text-neutral-900">
|
||||||
|
<CircleCheck
|
||||||
|
size={18}
|
||||||
|
strokeWidth={1.75}
|
||||||
|
aria-hidden
|
||||||
|
className="text-primary-600"
|
||||||
|
/>
|
||||||
|
Persetujuan
|
||||||
|
</h2>
|
||||||
<p className="mb-2">
|
<p className="mb-2">
|
||||||
Dengan menggunakan SeTrip, Anda menyatakan bahwa:
|
Dengan menggunakan SeTrip, Anda menyatakan bahwa:
|
||||||
</p>
|
</p>
|
||||||
@@ -5,8 +5,12 @@ import Link from "next/link";
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
import { profileService } from "@/server/services/profile.service";
|
import { profileService } from "@/server/services/profile.service";
|
||||||
|
import { payoutRepo } from "@/server/repositories/payout.repo";
|
||||||
import { TripCard } from "@/features/trip/components/trip-card";
|
import { TripCard } from "@/features/trip/components/trip-card";
|
||||||
import { ProfileTripRow } from "@/features/profile/components/profile-trip-row";
|
import { ProfileTripRow } from "@/features/profile/components/profile-trip-row";
|
||||||
|
import { ProfileEditor } from "@/features/profile/components/profile-editor";
|
||||||
|
import { EarningsSection } from "@/features/payout/components/earnings-section";
|
||||||
|
import { Plus, ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Profil Saya",
|
title: "Profil Saya",
|
||||||
@@ -19,7 +23,11 @@ export default async function ProfilePage() {
|
|||||||
redirect("/login?callbackUrl=/profile");
|
redirect("/login?callbackUrl=/profile");
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await profileService.getProfileDashboard(session.user.id);
|
const [data, ownProfile, payouts] = await Promise.all([
|
||||||
|
profileService.getProfileDashboard(session.user.id),
|
||||||
|
profileService.getOwnProfile(session.user.id),
|
||||||
|
payoutRepo.listForOrganizer(session.user.id),
|
||||||
|
]);
|
||||||
const {
|
const {
|
||||||
user,
|
user,
|
||||||
isVerifiedOrganizer,
|
isVerifiedOrganizer,
|
||||||
@@ -74,12 +82,34 @@ export default async function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href="/create-trip"
|
href="/create-trip"
|
||||||
className="shrink-0 rounded-xl bg-primary-600 px-4 py-2.5 text-center text-sm font-semibold text-white shadow-md hover:bg-primary-700"
|
className="inline-flex shrink-0 items-center justify-center gap-1.5 rounded-xl bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white shadow-md hover:bg-primary-700"
|
||||||
>
|
>
|
||||||
+ Buat trip
|
<Plus size={16} strokeWidth={2} aria-hidden />
|
||||||
|
Buat trip
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pendapatan dari peserta (escrow payout) */}
|
||||||
|
<EarningsSection payouts={payouts} />
|
||||||
|
|
||||||
|
{/* Profil sosial publik */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<ProfileEditor
|
||||||
|
userId={user.id}
|
||||||
|
initial={
|
||||||
|
ownProfile
|
||||||
|
? {
|
||||||
|
bio: ownProfile.bio,
|
||||||
|
city: ownProfile.city,
|
||||||
|
interests: ownProfile.interests,
|
||||||
|
instagram: ownProfile.instagram,
|
||||||
|
vibe: ownProfile.vibe,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Trip selesai — akses ulasan (trip ini tidak muncul di Open Trip) */}
|
{/* Trip selesai — akses ulasan (trip ini tidak muncul di Open Trip) */}
|
||||||
{reviewable.length > 0 && (
|
{reviewable.length > 0 && (
|
||||||
<section className="mb-8 rounded-2xl border border-amber-200 bg-amber-50/60 p-4 sm:p-5">
|
<section className="mb-8 rounded-2xl border border-amber-200 bg-amber-50/60 p-4 sm:p-5">
|
||||||
@@ -105,13 +135,14 @@ export default async function ProfilePage() {
|
|||||||
endDate={t.endDate}
|
endDate={t.endDate}
|
||||||
rightSlot={
|
rightSlot={
|
||||||
<span
|
<span
|
||||||
className={
|
className={`inline-flex items-center gap-0.5 ${
|
||||||
hasReview
|
hasReview
|
||||||
? "text-secondary-700"
|
? "text-secondary-700"
|
||||||
: "font-bold text-amber-800"
|
: "font-bold text-amber-800"
|
||||||
}
|
}`}
|
||||||
>
|
>
|
||||||
{hasReview ? "Ubah ulasan →" : "Beri ulasan →"}
|
{hasReview ? "Ubah ulasan" : "Beri ulasan"}
|
||||||
|
<ChevronRight size={14} strokeWidth={2} aria-hidden />
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -148,6 +179,7 @@ export default async function ProfilePage() {
|
|||||||
id={trip.id}
|
id={trip.id}
|
||||||
title={trip.title}
|
title={trip.title}
|
||||||
category={trip.category}
|
category={trip.category}
|
||||||
|
vibe={trip.vibe}
|
||||||
destination={trip.destination}
|
destination={trip.destination}
|
||||||
location={trip.location}
|
location={trip.location}
|
||||||
date={trip.date}
|
date={trip.date}
|
||||||
@@ -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>
|
</div>
|
||||||
|
|
||||||
{/* Card */}
|
{/* Card */}
|
||||||
<div className="rounded-2xl border border-white/10 bg-white/95 p-6 shadow-2xl backdrop-blur-sm">
|
<div className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-2xl">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600">
|
<div className="mb-4 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600">
|
||||||
{error}
|
{error}
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { FileText, CircleCheck } from "lucide-react";
|
||||||
|
|
||||||
export default function TermsPage() {
|
export default function TermsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-3xl px-4 py-8 sm:py-12">
|
<div className="mx-auto max-w-3xl px-4 py-8 sm:py-12">
|
||||||
<article className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm sm:p-10">
|
<article className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm sm:p-10">
|
||||||
<header className="mb-8 border-b border-neutral-200 pb-6">
|
<header className="mb-8 border-b border-neutral-200 pb-6">
|
||||||
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
|
<h1 className="flex items-center gap-2 text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||||
📜 Syarat & Ketentuan SeTrip
|
<FileText
|
||||||
|
size={28}
|
||||||
|
strokeWidth={1.75}
|
||||||
|
aria-hidden
|
||||||
|
className="shrink-0 text-primary-600"
|
||||||
|
/>
|
||||||
|
Syarat & Ketentuan SeTrip
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm text-neutral-500">
|
<p className="mt-2 text-sm text-neutral-500">
|
||||||
Terakhir diperbarui: 2026-04-27
|
Terakhir diperbarui: 2026-04-27
|
||||||
@@ -262,7 +269,15 @@ export default function TermsPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-xl bg-neutral-50 p-5">
|
<section className="rounded-xl bg-neutral-50 p-5">
|
||||||
<h2 className="mb-2 text-lg font-bold text-neutral-900">✅ Persetujuan</h2>
|
<h2 className="mb-2 flex items-center gap-1.5 text-lg font-bold text-neutral-900">
|
||||||
|
<CircleCheck
|
||||||
|
size={18}
|
||||||
|
strokeWidth={1.75}
|
||||||
|
aria-hidden
|
||||||
|
className="text-primary-600"
|
||||||
|
/>
|
||||||
|
Persetujuan
|
||||||
|
</h2>
|
||||||
<p className="mb-2">
|
<p className="mb-2">
|
||||||
Dengan menggunakan SeTrip, Anda menyatakan bahwa:
|
Dengan menggunakan SeTrip, Anda menyatakan bahwa:
|
||||||
</p>
|
</p>
|
||||||
@@ -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 { tripService } from "@/server/services/trip.service";
|
||||||
import { formatRupiah } from "@/lib/utils";
|
import { formatRupiah } from "@/lib/utils";
|
||||||
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
|
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
|
||||||
import { siteConfig } from "@/lib/site";
|
import { siteConfig, siteUrl } from "@/lib/site";
|
||||||
|
|
||||||
export const alt = `${siteConfig.name} — Open Trip & Aktivitas Bareng`;
|
export const alt = `${siteConfig.name} — Open Trip & Aktivitas Bareng`;
|
||||||
export const size = { width: 1200, height: 630 };
|
export const size = { width: 1200, height: 630 };
|
||||||
@@ -43,7 +43,15 @@ export default async function TripOgImage({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cover = trip.images[0]?.url;
|
// Satori (ImageResponse) mem-fetch gambar server-side dan butuh URL absolut.
|
||||||
|
// Foto trip baru disimpan sebagai path relatif `/api/trip-images/...` —
|
||||||
|
// prefix dengan origin. Foto lama (URL eksternal absolut) dipakai apa adanya.
|
||||||
|
const coverRaw = trip.images[0]?.url;
|
||||||
|
const cover = coverRaw
|
||||||
|
? coverRaw.startsWith("http")
|
||||||
|
? coverRaw
|
||||||
|
: `${siteUrl}${coverRaw}`
|
||||||
|
: undefined;
|
||||||
const dateLabel = formatTripCalendarDateRangeLong(trip.date, trip.endDate);
|
const dateLabel = formatTripCalendarDateRangeLong(trip.date, trip.endDate);
|
||||||
const price = formatRupiah(trip.price);
|
const price = formatRupiah(trip.price);
|
||||||
|
|
||||||
@@ -2,23 +2,40 @@ import type { Metadata } from "next";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
import { tripService } from "@/server/services/trip.service";
|
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 { trustService } from "@/server/services/trust.service";
|
||||||
import { formatRupiah } from "@/lib/utils";
|
import { formatRupiah } from "@/lib/utils";
|
||||||
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
|
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
|
||||||
import { siteConfig, siteUrl, absoluteUrl } from "@/lib/site";
|
import { siteConfig, siteUrl, absoluteUrl } from "@/lib/site";
|
||||||
import { JoinTripButton } from "@/features/trip/components/join-trip-button";
|
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 { OrganizerJoinRequests } from "@/features/trip/components/organizer-join-requests";
|
||||||
import { OrganizerTrustPanel } from "@/features/trip/components/organizer-trust-panel";
|
import { OrganizerTrustPanel } from "@/features/trip/components/organizer-trust-panel";
|
||||||
import { TripProgramBlock } from "@/features/trip/components/trip-program-block";
|
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 { ImageGallery } from "@/features/trip/components/image-gallery";
|
||||||
import { TripReviewSection } from "@/features/review/components/trip-review-section";
|
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";
|
||||||
import {
|
import {
|
||||||
isPastTripLastDayForReview,
|
isPastTripLastDayForReview,
|
||||||
isTripDepartureDayPast,
|
isTripDepartureDayPast,
|
||||||
} from "@/lib/trip-dates";
|
} from "@/lib/trip-dates";
|
||||||
|
import { previewRefund } from "@/lib/refund-policy";
|
||||||
|
import {
|
||||||
|
MapPin,
|
||||||
|
CalendarDays,
|
||||||
|
Wallet,
|
||||||
|
UserRound,
|
||||||
|
Zap,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
@@ -123,9 +140,34 @@ export default async function TripDetailPage({
|
|||||||
) / 10
|
) / 10
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const paymentPendingParticipants = activeParticipants.filter(
|
const tripIsFree = isFreeTrip(trip);
|
||||||
(p) => p.markedPaidAt && !p.paymentConfirmedAt
|
|
||||||
);
|
// 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);
|
||||||
|
|
||||||
const tripUrl = absoluteUrl(`/trips/${trip.id}`);
|
const tripUrl = absoluteUrl(`/trips/${trip.id}`);
|
||||||
const eventStatus =
|
const eventStatus =
|
||||||
@@ -240,8 +282,21 @@ export default async function TripDetailPage({
|
|||||||
<h1 className="text-lg font-bold text-neutral-800 sm:text-xl">
|
<h1 className="text-lg font-bold text-neutral-800 sm:text-xl">
|
||||||
{trip.title}
|
{trip.title}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-0.5 flex items-center gap-1.5 text-sm text-neutral-500">
|
<p className="mt-0.5 flex flex-wrap items-center gap-1.5 text-sm text-neutral-500">
|
||||||
🏔️ {trip.destination}
|
<span aria-hidden>{catMeta.icon}</span>
|
||||||
|
<span className="rounded-full bg-neutral-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-neutral-600">
|
||||||
|
{catMeta.label}
|
||||||
|
</span>
|
||||||
|
{trip.vibe && (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-0.5 rounded-full bg-secondary-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-secondary-700"
|
||||||
|
title={vibeMeta(trip.vibe).description}
|
||||||
|
>
|
||||||
|
<span aria-hidden>{vibeMeta(trip.vibe).icon}</span>
|
||||||
|
<span>{vibeMeta(trip.vibe).label}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="truncate">{trip.destination}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
@@ -262,8 +317,13 @@ export default async function TripDetailPage({
|
|||||||
{/* Info Grid */}
|
{/* Info Grid */}
|
||||||
<div className="grid grid-cols-2 gap-2 sm:gap-3">
|
<div className="grid grid-cols-2 gap-2 sm:gap-3">
|
||||||
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4">
|
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4">
|
||||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-secondary-100 text-sm sm:h-10 sm:w-10 sm:text-lg">
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-secondary-100 sm:h-10 sm:w-10">
|
||||||
📍
|
<MapPin
|
||||||
|
size={18}
|
||||||
|
strokeWidth={1.75}
|
||||||
|
aria-hidden
|
||||||
|
className="text-secondary-700"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Lokasi</p>
|
<p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Lokasi</p>
|
||||||
@@ -272,8 +332,13 @@ export default async function TripDetailPage({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4">
|
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4">
|
||||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-secondary-100 text-sm sm:h-10 sm:w-10 sm:text-lg">
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-secondary-100 sm:h-10 sm:w-10">
|
||||||
📅
|
<CalendarDays
|
||||||
|
size={18}
|
||||||
|
strokeWidth={1.75}
|
||||||
|
aria-hidden
|
||||||
|
className="text-secondary-700"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Tanggal</p>
|
<p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Tanggal</p>
|
||||||
@@ -284,8 +349,13 @@ export default async function TripDetailPage({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 rounded-xl bg-primary-50 p-3 sm:gap-3 sm:p-4">
|
<div className="flex items-center gap-2 rounded-xl bg-primary-50 p-3 sm:gap-3 sm:p-4">
|
||||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary-100 text-sm sm:h-10 sm:w-10 sm:text-lg">
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary-100 sm:h-10 sm:w-10">
|
||||||
💰
|
<Wallet
|
||||||
|
size={18}
|
||||||
|
strokeWidth={1.75}
|
||||||
|
aria-hidden
|
||||||
|
className="text-primary-700"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-[10px] font-medium text-primary-600 sm:text-xs">Harga</p>
|
<p className="text-[10px] font-medium text-primary-600 sm:text-xs">Harga</p>
|
||||||
@@ -296,14 +366,22 @@ export default async function TripDetailPage({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4">
|
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4">
|
||||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-200 text-sm sm:h-10 sm:w-10 sm:text-lg">
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-200 sm:h-10 sm:w-10">
|
||||||
👤
|
<UserRound
|
||||||
|
size={18}
|
||||||
|
strokeWidth={1.75}
|
||||||
|
aria-hidden
|
||||||
|
className="text-neutral-600"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Organizer</p>
|
<p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Organizer</p>
|
||||||
<p className="truncate text-xs font-semibold text-neutral-800 sm:text-sm">
|
<Link
|
||||||
|
href={`/u/${trip.organizer.id}`}
|
||||||
|
className="truncate text-xs font-semibold text-neutral-800 hover:text-primary-700 sm:text-sm"
|
||||||
|
>
|
||||||
{trip.organizer.name}
|
{trip.organizer.name}
|
||||||
</p>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -316,10 +394,23 @@ export default async function TripDetailPage({
|
|||||||
|
|
||||||
{/* Participant Progress */}
|
{/* Participant Progress */}
|
||||||
<div className="rounded-xl border border-neutral-200 p-3 sm:p-4">
|
<div className="rounded-xl border border-neutral-200 p-3 sm:p-4">
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
<span className="text-xs font-semibold text-neutral-700 sm:text-sm">
|
<div className="flex items-center gap-2">
|
||||||
Peserta
|
<span className="text-xs font-semibold text-neutral-700 sm:text-sm">
|
||||||
</span>
|
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">
|
<span className="text-xs font-bold text-neutral-800 sm:text-sm">
|
||||||
{participantCount}{" "}
|
{participantCount}{" "}
|
||||||
<span className="font-normal text-neutral-400">
|
<span className="font-normal text-neutral-400">
|
||||||
@@ -355,11 +446,41 @@ export default async function TripDetailPage({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
<TripProgramBlock
|
<TripProgramBlock
|
||||||
meetingPoint={trip.meetingPoint}
|
meetingPoint={trip.meetingPoint}
|
||||||
itinerary={trip.itinerary}
|
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}
|
whatsIncluded={trip.whatsIncluded}
|
||||||
whatsExcluded={trip.whatsExcluded}
|
whatsExcluded={trip.whatsExcluded}
|
||||||
/>
|
/>
|
||||||
@@ -382,19 +503,6 @@ export default async function TripDetailPage({
|
|||||||
pending={pendingParticipants.map((p) => ({
|
pending={pendingParticipants.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
user: p.user,
|
user: p.user,
|
||||||
markedPaidAt: p.markedPaidAt,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isOrganizer && paymentPendingParticipants.length > 0 && (
|
|
||||||
<OrganizerPaymentQueue
|
|
||||||
tripId={trip.id}
|
|
||||||
items={paymentPendingParticipants.map((p) => ({
|
|
||||||
id: p.id,
|
|
||||||
user: p.user,
|
|
||||||
joinStatus:
|
|
||||||
p.status === "PENDING" ? ("PENDING" as const) : ("CONFIRMED" as const),
|
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -405,26 +513,44 @@ export default async function TripDetailPage({
|
|||||||
isLoggedIn={!!session?.user}
|
isLoggedIn={!!session?.user}
|
||||||
isOrganizer={isOrganizer}
|
isOrganizer={isOrganizer}
|
||||||
isJoined={!!currentParticipation}
|
isJoined={!!currentParticipation}
|
||||||
|
isFree={tripIsFree}
|
||||||
participationStatus={
|
participationStatus={
|
||||||
currentParticipation?.status === "PENDING" ||
|
currentParticipation?.status === "PENDING" ||
|
||||||
currentParticipation?.status === "CONFIRMED"
|
currentParticipation?.status === "CONFIRMED"
|
||||||
? currentParticipation.status
|
? currentParticipation.status
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
participantPayment={
|
bookingStatus={myBooking?.status ?? null}
|
||||||
currentParticipation
|
|
||||||
? {
|
|
||||||
markedPaidAt: currentParticipation.markedPaidAt,
|
|
||||||
paymentConfirmedAt:
|
|
||||||
currentParticipation.paymentConfirmedAt,
|
|
||||||
}
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
isFull={spotsLeft <= 0}
|
isFull={spotsLeft <= 0}
|
||||||
tripStatus={trip.status}
|
tripStatus={trip.status}
|
||||||
isDeparturePast={isDeparturePast}
|
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
|
<TripReviewSection
|
||||||
tripId={trip.id}
|
tripId={trip.id}
|
||||||
reviews={trip.reviews.map((r) => ({
|
reviews={trip.reviews.map((r) => ({
|
||||||
@@ -445,32 +571,81 @@ export default async function TripDetailPage({
|
|||||||
|
|
||||||
{/* Peserta yang sudah disetujui organizer (publik) */}
|
{/* Peserta yang sudah disetujui organizer (publik) */}
|
||||||
<div>
|
<div>
|
||||||
<h2 className="mb-3 text-xs font-bold text-neutral-700 sm:text-sm">
|
<h2 className="mb-1 text-xs font-bold text-neutral-700 sm:text-sm">
|
||||||
Peserta terkonfirmasi ({confirmedCount})
|
Peserta terkonfirmasi ({confirmedCount})
|
||||||
</h2>
|
</h2>
|
||||||
|
<p className="mb-3 text-[11px] text-neutral-500 sm:text-xs">
|
||||||
|
Kenalan dulu sebelum berangkat — klik kartu untuk lihat profil.
|
||||||
|
</p>
|
||||||
{confirmedCount === 0 ? (
|
{confirmedCount === 0 ? (
|
||||||
<p className="text-xs text-neutral-400 sm:text-sm">
|
<p className="text-xs text-neutral-400 sm:text-sm">
|
||||||
Belum ada peserta yang dikonfirmasi.{" "}
|
Belum ada peserta yang dikonfirmasi.{" "}
|
||||||
{pendingParticipants.length > 0
|
{pendingParticipants.length > 0
|
||||||
? "Cek permintaan join di atas untuk menyetujui peserta."
|
? "Cek permintaan join di atas untuk menyetujui peserta."
|
||||||
: "Jadilah yang pertama mendaftar! 🎒"}
|
: "Jadilah yang pertama mendaftar!"}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-wrap gap-1.5 sm:gap-2">
|
<ul className="grid gap-2 sm:grid-cols-2">
|
||||||
{confirmedParticipants.map((p) => (
|
{confirmedParticipants.map((p) => {
|
||||||
<div
|
const interests = p.user.profile?.interests ?? [];
|
||||||
key={p.id}
|
const city = p.user.profile?.city;
|
||||||
className="flex items-center gap-1.5 rounded-full bg-neutral-100 px-2.5 py-1 sm:gap-2 sm:px-3 sm:py-1.5"
|
return (
|
||||||
>
|
<li key={p.id}>
|
||||||
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-primary-600 text-[9px] font-bold text-white sm:h-6 sm:w-6 sm:text-[10px]">
|
<Link
|
||||||
{p.user.name.charAt(0).toUpperCase()}
|
href={`/u/${p.user.id}`}
|
||||||
</div>
|
className="flex items-start gap-3 rounded-xl border border-neutral-200 bg-white px-3 py-2.5 transition-colors hover:border-primary-300 hover:bg-primary-50/30"
|
||||||
<span className="text-xs font-medium text-neutral-700 sm:text-sm">
|
>
|
||||||
{p.user.name}
|
{p.user.image ? (
|
||||||
</span>
|
<Image
|
||||||
</div>
|
src={p.user.image}
|
||||||
))}
|
alt=""
|
||||||
</div>
|
width={40}
|
||||||
|
height={40}
|
||||||
|
className="h-10 w-10 shrink-0 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary-600 text-sm font-bold text-white">
|
||||||
|
{p.user.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-semibold text-neutral-800">
|
||||||
|
{p.user.name}
|
||||||
|
</p>
|
||||||
|
{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 && (
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{interests.slice(0, 3).map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="rounded-full bg-secondary-50 px-1.5 py-0.5 text-[10px] font-medium text-secondary-700"
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{interests.length > 3 && (
|
||||||
|
<span className="text-[10px] text-neutral-400">
|
||||||
|
+{interests.length - 3}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
import { tripService } from "@/server/services/trip.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 { 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",
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PaymentPage({ params, searchParams }: PageProps) {
|
||||||
|
const { id } = await params;
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user) {
|
||||||
|
redirect(`/login?callbackUrl=/trips/${id}/payment`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let trip;
|
||||||
|
try {
|
||||||
|
trip = await tripService.getTripById(id);
|
||||||
|
} catch {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!booking || booking.status === "CANCELLED") {
|
||||||
|
return <NotJoinedNotice tripId={trip.id} title={trip.title} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tripIsFree = isFreeTrip(trip);
|
||||||
|
const catMeta = categoryMeta(trip.category);
|
||||||
|
const dateRange = formatTripCalendarDateRangeLong(trip.date, trip.endDate);
|
||||||
|
|
||||||
|
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">
|
||||||
|
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-neutral-100 text-xl">
|
||||||
|
{catMeta.icon}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-neutral-500">
|
||||||
|
{catMeta.label}
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-0.5 truncate text-base font-bold text-neutral-900 sm:text-lg">
|
||||||
|
{trip.title}
|
||||||
|
</h1>
|
||||||
|
<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:{" "}
|
||||||
|
<Link
|
||||||
|
href={`/u/${trip.organizer.id}`}
|
||||||
|
className="font-medium text-neutral-700 hover:text-primary-600"
|
||||||
|
>
|
||||||
|
{trip.organizer.name}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
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="inline-flex items-center gap-1 hover:text-primary-600"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={14} strokeWidth={1.75} aria-hidden />
|
||||||
|
Kembali ke trip
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="mb-1 text-xl font-bold text-neutral-900 sm:text-2xl">
|
||||||
|
Detail Pembayaran
|
||||||
|
</h2>
|
||||||
|
<p className="mb-5 text-sm text-neutral-500">
|
||||||
|
{tripIsFree
|
||||||
|
? "Trip ini gratis — kamu tidak perlu transfer apa-apa."
|
||||||
|
: "Bayar lewat Midtrans untuk mengamankan slot kamu. Pembayaran akan ter-konfirmasi otomatis."}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{tripHeader}
|
||||||
|
|
||||||
|
{tripIsFree ? (
|
||||||
|
<FreeTripSection tripId={trip.id} bookingStatus={booking.status} />
|
||||||
|
) : (
|
||||||
|
<PaidTripSection
|
||||||
|
tripId={trip.id}
|
||||||
|
organizerName={trip.organizer.name}
|
||||||
|
price={trip.price}
|
||||||
|
bookingStatus={booking.status}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotJoinedNotice({ tripId, title }: { tripId: string; title: string }) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl px-4 py-12 text-center">
|
||||||
|
<h1 className="mb-2 text-xl font-bold text-neutral-900 sm:text-2xl">
|
||||||
|
Kamu belum terdaftar di trip ini
|
||||||
|
</h1>
|
||||||
|
<p className="mb-5 text-sm text-neutral-500">
|
||||||
|
Halaman pembayaran hanya tersedia untuk peserta trip{" "}
|
||||||
|
<span className="font-semibold text-neutral-700">{title}</span>.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href={`/trips/${tripId}`}
|
||||||
|
className="inline-block rounded-xl bg-primary-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-primary-700"
|
||||||
|
>
|
||||||
|
Lihat detail trip
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type BookingStatus =
|
||||||
|
| "PENDING"
|
||||||
|
| "AWAITING_PAY"
|
||||||
|
| "PAID"
|
||||||
|
| "CANCELLED"
|
||||||
|
| "REFUNDED"
|
||||||
|
| "PARTIALLY_REFUNDED"
|
||||||
|
| "EXPIRED";
|
||||||
|
|
||||||
|
function FreeTripSection({
|
||||||
|
tripId,
|
||||||
|
bookingStatus,
|
||||||
|
}: {
|
||||||
|
tripId: string;
|
||||||
|
bookingStatus: BookingStatus;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-2xl border border-emerald-200 bg-emerald-50/60 p-6 text-center shadow-sm sm:p-8">
|
||||||
|
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-emerald-100">
|
||||||
|
<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
|
||||||
|
</h2>
|
||||||
|
<p className="mb-5 text-sm text-emerald-900/80">
|
||||||
|
Tidak ada biaya yang perlu kamu transfer.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mx-auto inline-flex flex-col gap-1 rounded-xl border border-emerald-200 bg-white px-5 py-3 text-left">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-emerald-700">
|
||||||
|
Status keikutsertaan
|
||||||
|
</p>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<Link
|
||||||
|
href={`/trips/${tripId}`}
|
||||||
|
className="inline-block rounded-xl bg-emerald-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-emerald-700"
|
||||||
|
>
|
||||||
|
Kembali ke detail trip
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaidTripSection({
|
||||||
|
tripId,
|
||||||
|
organizerName,
|
||||||
|
price,
|
||||||
|
bookingStatus,
|
||||||
|
}: {
|
||||||
|
tripId: string;
|
||||||
|
organizerName: string;
|
||||||
|
price: number;
|
||||||
|
bookingStatus: BookingStatus;
|
||||||
|
}) {
|
||||||
|
const isApproved =
|
||||||
|
bookingStatus === "AWAITING_PAY" || bookingStatus === "PAID";
|
||||||
|
const isPendingApproval = bookingStatus === "PENDING";
|
||||||
|
const isFullyPaid = bookingStatus === "PAID";
|
||||||
|
const canPay = bookingStatus === "AWAITING_PAY";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<PaymentTimeline approved={isApproved} confirmedPaid={isFullyPaid} />
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<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 bayar — supaya tidak perlu refund kalau ditolak.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canPay && <MidtransPayButton tripId={tripId} />}
|
||||||
|
|
||||||
|
{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="inline-flex items-center gap-1 text-sm text-neutral-500 hover:text-primary-600"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={14} strokeWidth={1.75} aria-hidden />
|
||||||
|
Kembali ke detail trip
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaymentTimeline({
|
||||||
|
approved,
|
||||||
|
confirmedPaid,
|
||||||
|
}: {
|
||||||
|
approved: boolean;
|
||||||
|
confirmedPaid: boolean;
|
||||||
|
}) {
|
||||||
|
const steps = [
|
||||||
|
{ label: "Disetujui organizer", done: approved },
|
||||||
|
{ label: "Pembayaran terkonfirmasi Midtrans", done: confirmedPaid },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-5">
|
||||||
|
<h3 className="mb-3 text-xs font-bold text-neutral-700 sm:text-sm">
|
||||||
|
Status pembayaran
|
||||||
|
</h3>
|
||||||
|
<ol className="space-y-2.5">
|
||||||
|
{steps.map((s, i) => (
|
||||||
|
<li key={i} className="flex items-start gap-3">
|
||||||
|
<span
|
||||||
|
className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[11px] font-bold ${
|
||||||
|
s.done
|
||||||
|
? "bg-emerald-500 text-white"
|
||||||
|
: "bg-neutral-200 text-neutral-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.done ? (
|
||||||
|
<Check size={12} strokeWidth={3} aria-hidden />
|
||||||
|
) : (
|
||||||
|
i + 1
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-sm ${
|
||||||
|
s.done
|
||||||
|
? "font-semibold text-neutral-800"
|
||||||
|
: "text-neutral-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,22 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
import { tripService } from "@/server/services/trip.service";
|
import { tripService } from "@/server/services/trip.service";
|
||||||
|
import { profileRepo } from "@/server/repositories/profile.repo";
|
||||||
import { TripCard } from "@/features/trip/components/trip-card";
|
import { TripCard } from "@/features/trip/components/trip-card";
|
||||||
import { TripFilter } from "@/features/trip/components/trip-filter";
|
import { TripFilter } from "@/features/trip/components/trip-filter";
|
||||||
import { siteConfig } from "@/lib/site";
|
import { siteConfig } from "@/lib/site";
|
||||||
import { categoryLabel, isActivityCategory } from "@/lib/activity-category";
|
import { categoryLabel, isActivityCategory } from "@/lib/activity-category";
|
||||||
|
import { isVibe } from "@/lib/vibe";
|
||||||
|
import 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 {
|
||||||
|
return typeof value === "string" && (GROUP_SIZES as string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
interface TripsPageProps {
|
interface TripsPageProps {
|
||||||
searchParams: Promise<{
|
searchParams: Promise<{
|
||||||
@@ -13,6 +24,8 @@ interface TripsPageProps {
|
|||||||
from?: string;
|
from?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
|
vibe?: string;
|
||||||
|
groupSize?: string;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,19 +57,30 @@ export async function generateMetadata({
|
|||||||
export default async function TripsPage({ searchParams }: TripsPageProps) {
|
export default async function TripsPage({ searchParams }: TripsPageProps) {
|
||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
const category = isActivityCategory(params.category) ? params.category : undefined;
|
const category = isActivityCategory(params.category) ? params.category : undefined;
|
||||||
const hasFilters = Boolean(params.q || params.from || params.to || category);
|
const vibe = isVibe(params.vibe) ? params.vibe : undefined;
|
||||||
|
const groupSize = isGroupSize(params.groupSize) ? params.groupSize : undefined;
|
||||||
|
const hasFilters = Boolean(
|
||||||
|
params.q || params.from || params.to || category || vibe || groupSize
|
||||||
|
);
|
||||||
const filters = {
|
const filters = {
|
||||||
q: params.q,
|
q: params.q,
|
||||||
from: params.from,
|
from: params.from,
|
||||||
to: params.to,
|
to: params.to,
|
||||||
category,
|
category,
|
||||||
|
vibe,
|
||||||
|
groupSize,
|
||||||
};
|
};
|
||||||
|
|
||||||
const [trips, allTrips] = await Promise.all([
|
const session = await getServerSession(authOptions);
|
||||||
|
const [trips, allTrips, viewerProfile] = await Promise.all([
|
||||||
tripService.getOpenTrips(filters),
|
tripService.getOpenTrips(filters),
|
||||||
hasFilters ? tripService.getOpenTrips() : null,
|
hasFilters ? tripService.getOpenTrips() : null,
|
||||||
|
session?.user?.id
|
||||||
|
? profileRepo.findByUserId(session.user.id)
|
||||||
|
: Promise.resolve(null),
|
||||||
]);
|
]);
|
||||||
const totalCount = hasFilters ? allTrips!.length : trips.length;
|
const totalCount = hasFilters ? allTrips!.length : trips.length;
|
||||||
|
const viewerInterests = viewerProfile?.interests ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-6xl px-4 py-6 sm:py-8">
|
<div className="mx-auto max-w-6xl px-4 py-6 sm:py-8">
|
||||||
@@ -75,9 +99,10 @@ export default async function TripsPage({ searchParams }: TripsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href="/create-trip"
|
href="/create-trip"
|
||||||
className="w-full rounded-xl bg-primary-600 px-4 py-2.5 text-center text-sm font-semibold text-white shadow-md shadow-primary-600/20 hover:bg-primary-700 sm:w-auto"
|
className="inline-flex w-full items-center justify-center gap-1.5 rounded-xl bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white shadow-md shadow-primary-600/20 hover:bg-primary-700 sm:w-auto"
|
||||||
>
|
>
|
||||||
+ Buat Trip
|
<Plus size={16} strokeWidth={2} aria-hidden />
|
||||||
|
Buat Trip
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -90,8 +115,22 @@ export default async function TripsPage({ searchParams }: TripsPageProps) {
|
|||||||
|
|
||||||
{trips.length === 0 ? (
|
{trips.length === 0 ? (
|
||||||
<div className="rounded-2xl border-2 border-dashed border-neutral-200 bg-white p-8 text-center sm:p-14">
|
<div className="rounded-2xl border-2 border-dashed border-neutral-200 bg-white p-8 text-center sm:p-14">
|
||||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 text-2xl sm:h-16 sm:w-16 sm:text-3xl">
|
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 sm:h-16 sm:w-16">
|
||||||
{hasFilters ? "🔍" : "🏕️"}
|
{hasFilters ? (
|
||||||
|
<Search
|
||||||
|
size={26}
|
||||||
|
strokeWidth={1.75}
|
||||||
|
aria-hidden
|
||||||
|
className="text-primary-600"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Tent
|
||||||
|
size={26}
|
||||||
|
strokeWidth={1.75}
|
||||||
|
aria-hidden
|
||||||
|
className="text-primary-600"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-1 text-base font-bold text-neutral-800 sm:text-lg">
|
<p className="mb-1 text-base font-bold text-neutral-800 sm:text-lg">
|
||||||
{hasFilters
|
{hasFilters
|
||||||
@@ -114,12 +153,13 @@ export default async function TripsPage({ searchParams }: TripsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{trips.map((trip) => (
|
{trips.map((trip, index) => (
|
||||||
<TripCard
|
<TripCard
|
||||||
key={trip.id}
|
key={trip.id}
|
||||||
id={trip.id}
|
id={trip.id}
|
||||||
title={trip.title}
|
title={trip.title}
|
||||||
category={trip.category}
|
category={trip.category}
|
||||||
|
vibe={trip.vibe}
|
||||||
destination={trip.destination}
|
destination={trip.destination}
|
||||||
location={trip.location}
|
location={trip.location}
|
||||||
date={trip.date}
|
date={trip.date}
|
||||||
@@ -130,9 +170,19 @@ export default async function TripsPage({ searchParams }: TripsPageProps) {
|
|||||||
organizerName={trip.organizer.name}
|
organizerName={trip.organizer.name}
|
||||||
status={trip.status}
|
status={trip.status}
|
||||||
coverImage={trip.images[0]?.url}
|
coverImage={trip.images[0]?.url}
|
||||||
|
// Baris pertama (3 kartu) di atas fold — muat segera supaya
|
||||||
|
// tidak jadi LCP yang lambat.
|
||||||
|
priority={index < 3}
|
||||||
isVerifiedOrganizer={
|
isVerifiedOrganizer={
|
||||||
trip.organizer.organizerVerification?.status === "APPROVED"
|
trip.organizer.organizerVerification?.status === "APPROVED"
|
||||||
}
|
}
|
||||||
|
participants={trip.participants.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.user.name,
|
||||||
|
image: p.user.image,
|
||||||
|
interests: p.user.profile?.interests ?? [],
|
||||||
|
}))}
|
||||||
|
viewerInterests={viewerInterests}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
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 }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: PageProps): Promise<Metadata> {
|
||||||
|
const { id } = await params;
|
||||||
|
const data = await profileService.getPublicProfile(id);
|
||||||
|
if (!data) {
|
||||||
|
return { title: "Profil tidak ditemukan", robots: { index: false } };
|
||||||
|
}
|
||||||
|
const { user } = data;
|
||||||
|
const title = `${user.name} — Profil`;
|
||||||
|
const desc =
|
||||||
|
user.profile?.bio?.slice(0, 160) ||
|
||||||
|
`Lihat profil ${user.name} di ${siteConfig.name}: trip yang dibuat, trip yang diikuti, dan minat aktivitas.`;
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description: desc,
|
||||||
|
alternates: { canonical: `/u/${id}` },
|
||||||
|
openGraph: { title, description: desc, url: `/u/${id}` },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PublicProfilePage({ params }: PageProps) {
|
||||||
|
const { id } = await params;
|
||||||
|
const data = await profileService.getPublicProfile(id);
|
||||||
|
if (!data) notFound();
|
||||||
|
|
||||||
|
const { user, isVerifiedOrganizer, organizedTrips, joinedTrips } = data;
|
||||||
|
const profile = user.profile;
|
||||||
|
const memberSince = new Date(user.createdAt).toLocaleDateString("id-ID", {
|
||||||
|
month: "long",
|
||||||
|
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 */}
|
||||||
|
<section className="rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:gap-5">
|
||||||
|
<div className="relative h-20 w-20 shrink-0 overflow-hidden rounded-full bg-neutral-200 sm:h-24 sm:w-24">
|
||||||
|
{user.image ? (
|
||||||
|
<Image
|
||||||
|
src={user.image}
|
||||||
|
alt={user.name}
|
||||||
|
fill
|
||||||
|
sizes="96px"
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-2xl font-bold text-neutral-500">
|
||||||
|
{user.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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-800 sm:text-2xl">
|
||||||
|
{user.name}
|
||||||
|
</h1>
|
||||||
|
{isVerifiedOrganizer && (
|
||||||
|
<span
|
||||||
|
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)"
|
||||||
|
>
|
||||||
|
<BadgeCheck size={12} strokeWidth={2} aria-hidden />
|
||||||
|
Verified Organizer
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<MapPin size={13} strokeWidth={1.75} aria-hidden />
|
||||||
|
{profile.city}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs">Bergabung sejak {memberSince}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{profile?.bio && (
|
||||||
|
<p className="mt-3 whitespace-pre-line text-sm text-neutral-700">
|
||||||
|
{profile.bio}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{profile?.vibe && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full bg-primary-50 px-2.5 py-1 text-xs font-semibold text-primary-700"
|
||||||
|
title={vibeMeta(profile.vibe).description}
|
||||||
|
>
|
||||||
|
<span aria-hidden>{vibeMeta(profile.vibe).icon}</span>
|
||||||
|
<span>Vibe: {vibeMeta(profile.vibe).label}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{profile?.interests && profile.interests.length > 0 && (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||||
|
{profile.interests.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="rounded-full bg-secondary-50 px-2.5 py-0.5 text-xs font-medium text-secondary-700"
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{profile?.instagram && (
|
||||||
|
<a
|
||||||
|
href={`https://instagram.com/${profile.instagram}`}
|
||||||
|
target="_blank"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<AtSign size={15} strokeWidth={1.75} aria-hidden />
|
||||||
|
<span>{profile.instagram}</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 grid grid-cols-2 gap-3 border-t border-neutral-100 pt-4 text-center sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-bold text-primary-600">
|
||||||
|
{organizedTrips.length}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-neutral-500">Trip dibuat</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-bold text-secondary-600">
|
||||||
|
{joinedTrips.length}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-neutral-500">Trip diikuti</p>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 sm:col-span-1">
|
||||||
|
<p className="text-lg font-bold text-neutral-700">
|
||||||
|
{organizedTrips.length + joinedTrips.length}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-neutral-500">Total perjalanan</p>
|
||||||
|
</div>
|
||||||
|
</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">
|
||||||
|
{user.name} belum melengkapi profil sosial — bio, kota, & minat akan
|
||||||
|
muncul di sini setelah diisi.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Trip dibuat */}
|
||||||
|
{organizedTrips.length > 0 && (
|
||||||
|
<section className="mt-8">
|
||||||
|
<h2 className="mb-3 text-base font-bold text-neutral-800 sm:text-lg">
|
||||||
|
Trip yang dibuat ({organizedTrips.length})
|
||||||
|
</h2>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{organizedTrips.map((trip) => (
|
||||||
|
<TripCard
|
||||||
|
key={trip.id}
|
||||||
|
id={trip.id}
|
||||||
|
title={trip.title}
|
||||||
|
category={trip.category}
|
||||||
|
vibe={trip.vibe}
|
||||||
|
destination={trip.destination}
|
||||||
|
location={trip.location}
|
||||||
|
date={trip.date}
|
||||||
|
endDate={trip.endDate}
|
||||||
|
price={trip.price}
|
||||||
|
maxParticipants={trip.maxParticipants}
|
||||||
|
participantCount={trip._count.participants}
|
||||||
|
organizerName={user.name}
|
||||||
|
status={trip.status}
|
||||||
|
coverImage={trip.images[0]?.url}
|
||||||
|
isVerifiedOrganizer={isVerifiedOrganizer}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Trip diikuti */}
|
||||||
|
{joinedTrips.length > 0 && (
|
||||||
|
<section className="mt-8">
|
||||||
|
<h2 className="mb-3 text-base font-bold text-neutral-800 sm:text-lg">
|
||||||
|
Trip yang diikuti ({joinedTrips.length})
|
||||||
|
</h2>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{joinedTrips.map((trip) => (
|
||||||
|
<li key={trip.id}>
|
||||||
|
<ProfileTripRow
|
||||||
|
href={`/trips/${trip.id}`}
|
||||||
|
title={trip.title}
|
||||||
|
destination={trip.destination}
|
||||||
|
date={trip.date}
|
||||||
|
endDate={trip.endDate}
|
||||||
|
rightSlot={
|
||||||
|
<span className="text-neutral-500">
|
||||||
|
bareng{" "}
|
||||||
|
<Link
|
||||||
|
href={`/u/${trip.organizer.id}`}
|
||||||
|
className="font-medium text-neutral-700 hover:text-primary-600"
|
||||||
|
>
|
||||||
|
{trip.organizer.name}
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{organizedTrips.length === 0 && joinedTrips.length === 0 && (
|
||||||
|
<p className="mt-8 rounded-xl border border-dashed border-neutral-200 bg-white px-4 py-10 text-center text-sm text-neutral-500">
|
||||||
|
Belum ada trip yang dibuat atau diikuti.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,29 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { Clock, RefreshCw, CircleX, ArrowLeft } from "lucide-react";
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
import { organizerService } from "@/server/services/organizer.service";
|
import { organizerService } from "@/server/services/organizer.service";
|
||||||
import { VerifyForm } from "@/features/organizer/components/verify-form";
|
import { VerifyForm } from "@/features/organizer/components/verify-form";
|
||||||
import { VerifiedBadge } from "@/components/shared/verified-badge";
|
import { VerifiedBadge } from "@/components/shared/verified-badge";
|
||||||
|
|
||||||
|
function reuploadFieldLabel(field: string): string {
|
||||||
|
switch (field) {
|
||||||
|
case "ktpImage":
|
||||||
|
return "Foto KTP";
|
||||||
|
case "liveness":
|
||||||
|
return "Foto liveness (pegang kertas SETRIP)";
|
||||||
|
case "nik":
|
||||||
|
return "NIK";
|
||||||
|
case "bankInfo":
|
||||||
|
return "Info rekening";
|
||||||
|
case "address":
|
||||||
|
return "Alamat";
|
||||||
|
default:
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default async function VerifyPage() {
|
export default async function VerifyPage() {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
@@ -21,7 +39,7 @@ export default async function VerifyPage() {
|
|||||||
birthDate: verification.birthDate,
|
birthDate: verification.birthDate,
|
||||||
address: verification.address,
|
address: verification.address,
|
||||||
ktpImageKey: verification.ktpImageKey,
|
ktpImageKey: verification.ktpImageKey,
|
||||||
selfieKey: verification.selfieKey,
|
livenessKey: verification.livenessKey,
|
||||||
bankName: verification.bankName,
|
bankName: verification.bankName,
|
||||||
bankAccountNumber: verification.bankAccountNumber,
|
bankAccountNumber: verification.bankAccountNumber,
|
||||||
bankAccountName: verification.bankAccountName,
|
bankAccountName: verification.bankAccountName,
|
||||||
@@ -53,10 +71,11 @@ export default async function VerifyPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{verification?.status === "PENDING" && (
|
{verification?.status === "PENDING" && !verification.reuploadRequested && (
|
||||||
<div className="mb-6 rounded-2xl border border-amber-200 bg-amber-50 p-5">
|
<div className="mb-6 rounded-2xl border border-amber-200 bg-amber-50 p-5">
|
||||||
<p className="mb-1 text-sm font-bold text-amber-800">
|
<p className="mb-1 flex items-center gap-1.5 text-sm font-bold text-amber-800">
|
||||||
⏳ Menunggu review admin
|
<Clock size={15} strokeWidth={2} aria-hidden />
|
||||||
|
Menunggu review admin
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-neutral-700">
|
<p className="text-sm text-neutral-700">
|
||||||
Pengajuanmu sedang diproses. Kami akan memberitahu via email setelah selesai.
|
Pengajuanmu sedang diproses. Kami akan memberitahu via email setelah selesai.
|
||||||
@@ -64,9 +83,47 @@ export default async function VerifyPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{verification?.reuploadRequested && (
|
||||||
|
<div className="mb-6 rounded-2xl border-2 border-amber-400 bg-amber-50 p-5">
|
||||||
|
<p className="mb-1 flex items-center gap-1.5 text-sm font-bold text-amber-900">
|
||||||
|
<RefreshCw size={15} strokeWidth={2} aria-hidden />
|
||||||
|
Admin minta kamu upload ulang
|
||||||
|
</p>
|
||||||
|
{verification.reuploadNote && (
|
||||||
|
<p className="mb-3 text-sm text-neutral-700">
|
||||||
|
<span className="font-semibold">Catatan admin:</span>{" "}
|
||||||
|
{verification.reuploadNote}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{verification.reuploadFields.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<p className="mb-1 text-xs font-semibold text-amber-900">
|
||||||
|
Field yang perlu di-upload ulang:
|
||||||
|
</p>
|
||||||
|
<ul className="ml-4 list-disc text-xs text-neutral-700">
|
||||||
|
{verification.reuploadFields.map((f) => (
|
||||||
|
<li key={f}>
|
||||||
|
<span className="font-semibold">
|
||||||
|
{reuploadFieldLabel(f)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-neutral-700">
|
||||||
|
Submit ulang form di bawah dengan data/foto yang sudah diperbaiki.
|
||||||
|
Setelah submit, banner ini hilang otomatis.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{verification?.status === "REJECTED" && (
|
{verification?.status === "REJECTED" && (
|
||||||
<div className="mb-6 rounded-2xl border border-red-200 bg-red-50 p-5">
|
<div className="mb-6 rounded-2xl border border-red-200 bg-red-50 p-5">
|
||||||
<p className="mb-1 text-sm font-bold text-red-800">❌ Pengajuan ditolak</p>
|
<p className="mb-1 flex items-center gap-1.5 text-sm font-bold text-red-800">
|
||||||
|
<CircleX size={15} strokeWidth={2} aria-hidden />
|
||||||
|
Pengajuan ditolak
|
||||||
|
</p>
|
||||||
{verification.rejectionReason && (
|
{verification.rejectionReason && (
|
||||||
<p className="text-sm text-neutral-700">
|
<p className="text-sm text-neutral-700">
|
||||||
<span className="font-semibold">Alasan:</span>{" "}
|
<span className="font-semibold">Alasan:</span>{" "}
|
||||||
@@ -79,13 +136,17 @@ export default async function VerifyPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{verification?.status !== "APPROVED" && verification?.status !== "PENDING" && (
|
{(verification?.status !== "APPROVED" &&
|
||||||
<VerifyForm initial={initial} />
|
(verification?.status !== "PENDING" ||
|
||||||
)}
|
verification?.reuploadRequested)) && <VerifyForm initial={initial} />}
|
||||||
|
|
||||||
<p className="mt-6 text-center text-sm text-neutral-500">
|
<p className="mt-6 text-center text-sm text-neutral-500">
|
||||||
<Link href="/profile" className="hover:text-primary-600">
|
<Link
|
||||||
← Kembali ke profil
|
href="/profile"
|
||||||
|
className="inline-flex items-center gap-1 hover:text-primary-600"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={14} strokeWidth={1.75} aria-hidden />
|
||||||
|
Kembali ke profil
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -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 { redirect } from "next/navigation";
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
import { isAdminEmail } from "@/lib/admin";
|
import { isAdminEmail, listAdminEmails } from "@/lib/admin";
|
||||||
import { organizerRepo } from "@/server/repositories/organizer.repo";
|
import { organizerRepo } from "@/server/repositories/organizer.repo";
|
||||||
import { organizerService } from "@/server/services/organizer.service";
|
import { organizerService } from "@/server/services/organizer.service";
|
||||||
|
import { AdminFilterBar } from "@/features/admin/components/admin-filter-bar";
|
||||||
|
import { ExportCsvLink } from "@/features/admin/components/export-csv-link";
|
||||||
import { ReviewCard } from "@/features/organizer/components/review-card";
|
import { ReviewCard } from "@/features/organizer/components/review-card";
|
||||||
|
|
||||||
type Tab = "PENDING" | "APPROVED" | "REJECTED";
|
type Tab = "PENDING" | "APPROVED" | "REJECTED";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
searchParams: Promise<{ tab?: string }>;
|
searchParams: Promise<{
|
||||||
|
tab?: string;
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
reviewer?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDate(value: string | undefined): Date | undefined {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const d = new Date(value);
|
||||||
|
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function AdminVerificationsPage({ searchParams }: PageProps) {
|
export default async function AdminVerificationsPage({ searchParams }: PageProps) {
|
||||||
@@ -29,7 +42,11 @@ export default async function AdminVerificationsPage({ searchParams }: PageProps
|
|||||||
const tab: Tab =
|
const tab: Tab =
|
||||||
params.tab === "APPROVED" || params.tab === "REJECTED" ? params.tab : "PENDING";
|
params.tab === "APPROVED" || params.tab === "REJECTED" ? params.tab : "PENDING";
|
||||||
|
|
||||||
const rows = await organizerRepo.listByStatus(tab);
|
const rows = await organizerRepo.listByStatus(tab, {
|
||||||
|
dateFrom: parseDate(params.dateFrom),
|
||||||
|
dateTo: parseDate(params.dateTo),
|
||||||
|
reviewerEmail: params.reviewer || undefined,
|
||||||
|
});
|
||||||
const items = rows.map((v) => ({
|
const items = rows.map((v) => ({
|
||||||
id: v.id,
|
id: v.id,
|
||||||
fullName: v.fullName,
|
fullName: v.fullName,
|
||||||
@@ -53,17 +70,41 @@ export default async function AdminVerificationsPage({ searchParams }: PageProps
|
|||||||
{ key: "REJECTED", label: "Ditolak" },
|
{ key: "REJECTED", label: "Ditolak" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const exportQuery = new URLSearchParams({ status: tab });
|
||||||
|
if (params.dateFrom) exportQuery.set("dateFrom", params.dateFrom);
|
||||||
|
if (params.dateTo) exportQuery.set("dateTo", params.dateTo);
|
||||||
|
if (params.reviewer) exportQuery.set("reviewer", params.reviewer);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-4xl px-4 py-8 sm:py-12">
|
<div className="mx-auto max-w-4xl px-4 py-8 sm:py-12">
|
||||||
<header className="mb-6">
|
<header className="mb-6 flex flex-wrap items-start justify-between gap-3">
|
||||||
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
|
<div>
|
||||||
Review Verifikasi Organizer
|
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||||
</h1>
|
Review Verifikasi Organizer
|
||||||
<p className="mt-1 text-sm text-neutral-500">
|
</h1>
|
||||||
Periksa data KTP, selfie, dan rekening sebelum menyetujui.
|
<p className="mt-1 text-sm text-neutral-500">
|
||||||
</p>
|
Periksa data KTP, foto liveness (memegang kertas SETRIP), dan rekening
|
||||||
|
sebelum menyetujui.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ExportCsvLink
|
||||||
|
href="/api/admin/export/verifications"
|
||||||
|
query={exportQuery.toString()}
|
||||||
|
/>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<AdminFilterBar
|
||||||
|
action="/admin/verifications"
|
||||||
|
values={{
|
||||||
|
tab,
|
||||||
|
dateFrom: params.dateFrom,
|
||||||
|
dateTo: params.dateTo,
|
||||||
|
reviewer: params.reviewer,
|
||||||
|
}}
|
||||||
|
reviewerOptions={listAdminEmails()}
|
||||||
|
reviewerLabel="Reviewer"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="mb-6 flex gap-2">
|
<div className="mb-6 flex gap-2">
|
||||||
{tabs.map((t) => (
|
{tabs.map((t) => (
|
||||||
<a
|
<a
|
||||||
@@ -82,7 +123,9 @@ export default async function AdminVerificationsPage({ searchParams }: PageProps
|
|||||||
|
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<div className="rounded-2xl border border-dashed border-neutral-300 bg-white p-10 text-center">
|
<div className="rounded-2xl border border-dashed border-neutral-300 bg-white p-10 text-center">
|
||||||
<p className="text-sm text-neutral-500">Tidak ada data.</p>
|
<p className="text-sm text-neutral-500">
|
||||||
|
Tidak ada data yang cocok dengan filter ini.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ export async function GET(_req: NextRequest, ctx: RouteCtx) {
|
|||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = kind === "ktp" ? verification.ktpImageKey : verification.selfieKey;
|
const key = kind === "ktp" ? verification.ktpImageKey : verification.livenessKey;
|
||||||
if (!key) {
|
if (!key) {
|
||||||
return NextResponse.json({ error: "File belum diunggah" }, { status: 404 });
|
return NextResponse.json({ error: "File belum diunggah" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -28,7 +28,10 @@ export async function POST(req: NextRequest) {
|
|||||||
const file = form.get("file");
|
const file = form.get("file");
|
||||||
|
|
||||||
if (!isKycKind(kind)) {
|
if (!isKycKind(kind)) {
|
||||||
return NextResponse.json({ error: "kind harus 'ktp' atau 'selfie'" }, { status: 400 });
|
return NextResponse.json(
|
||||||
|
{ error: "kind harus 'ktp' atau 'liveness'" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (!(file instanceof File)) {
|
if (!(file instanceof File)) {
|
||||||
return NextResponse.json({ error: "File wajib diisi" }, { status: 400 });
|
return NextResponse.json({ error: "File wajib diisi" }, { status: 400 });
|
||||||
|
|||||||
@@ -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,55 +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 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
|
|
||||||
@@ -1,79 +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 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
|
|
||||||
@@ -1,542 +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 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 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 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>
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,53 +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 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]
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -1,221 +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',
|
|
||||||
Account: 'Account',
|
|
||||||
OrganizerVerification: 'OrganizerVerification',
|
|
||||||
Trip: 'Trip',
|
|
||||||
TripReview: 'TripReview',
|
|
||||||
TripImage: 'TripImage',
|
|
||||||
TripParticipant: 'TripParticipant'
|
|
||||||
} 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 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',
|
|
||||||
selfieKey: 'selfieKey',
|
|
||||||
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',
|
|
||||||
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 SortOrder = {
|
|
||||||
asc: 'asc',
|
|
||||||
desc: 'desc'
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder]
|
|
||||||
|
|
||||||
|
|
||||||
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]
|
|
||||||
|
|
||||||
@@ -1,18 +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/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 './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
@@ -64,6 +64,16 @@ select:focus {
|
|||||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1) !important;
|
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Input wrapper isi penuh lebar parent + popper di atas konten lain */
|
||||||
|
.react-datepicker-wrapper,
|
||||||
|
.react-datepicker__input-container {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-datepicker-popper {
|
||||||
|
z-index: 50 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.react-datepicker__header {
|
.react-datepicker__header {
|
||||||
background: #f9fafb !important;
|
background: #f9fafb !important;
|
||||||
border-bottom: 1px solid #e5e7eb !important;
|
border-bottom: 1px solid #e5e7eb !important;
|
||||||
@@ -132,3 +142,46 @@ select:focus {
|
|||||||
.react-datepicker__close-icon:hover::after {
|
.react-datepicker__close-icon:hover::after {
|
||||||
background-color: #16a34a !important;
|
background-color: #16a34a !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dropdown bulan / tahun (mode select) */
|
||||||
|
.react-datepicker__month-select,
|
||||||
|
.react-datepicker__year-select {
|
||||||
|
border: 1px solid #e5e7eb !important;
|
||||||
|
border-radius: 0.5rem !important;
|
||||||
|
padding: 2px 4px !important;
|
||||||
|
font-size: 0.8125rem !important;
|
||||||
|
background: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pemilih jam (showTimeSelectOnly) */
|
||||||
|
.react-datepicker__time-container {
|
||||||
|
width: 96px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-datepicker__time-container
|
||||||
|
.react-datepicker__time
|
||||||
|
.react-datepicker__time-box {
|
||||||
|
width: 96px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-datepicker-time__header {
|
||||||
|
font-weight: 700 !important;
|
||||||
|
color: #1f2937 !important;
|
||||||
|
font-size: 0.8125rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-datepicker__time-list-item {
|
||||||
|
font-size: 0.8125rem !important;
|
||||||
|
color: #1f2937 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-datepicker__time-list-item:hover {
|
||||||
|
background: #dcfce7 !important;
|
||||||
|
color: #15803d !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-datepicker__time-list-item--selected {
|
||||||
|
background: #16a34a !important;
|
||||||
|
color: #fff !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
}
|
||||||
|
|||||||
+1
-5
@@ -1,7 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import { SessionProvider } from "@/components/providers/session-provider";
|
import { SessionProvider } from "@/components/providers/session-provider";
|
||||||
import { Navbar } from "@/components/shared/navbar";
|
|
||||||
import { siteConfig, siteUrl } from "@/lib/site";
|
import { siteConfig, siteUrl } from "@/lib/site";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
@@ -78,10 +77,7 @@ export default function RootLayout({
|
|||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||||
>
|
>
|
||||||
<body className="flex min-h-full flex-col bg-neutral-50">
|
<body className="flex min-h-full flex-col bg-neutral-50">
|
||||||
<SessionProvider>
|
<SessionProvider>{children}</SessionProvider>
|
||||||
<Navbar />
|
|
||||||
<main className="flex-1">{children}</main>
|
|
||||||
</SessionProvider>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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: "*",
|
userAgent: "*",
|
||||||
allow: "/",
|
allow: "/",
|
||||||
disallow: ["/api/", "/profile", "/create-trip"],
|
disallow: [
|
||||||
|
"/api/",
|
||||||
|
"/admin",
|
||||||
|
"/profile",
|
||||||
|
"/create-trip",
|
||||||
|
"/verify",
|
||||||
|
"/trips/*/payment",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
sitemap: absoluteUrl("/sitemap.xml"),
|
sitemap: absoluteUrl("/sitemap.xml"),
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
changeFrequency: "hourly",
|
changeFrequency: "hourly",
|
||||||
priority: 0.9,
|
priority: 0.9,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
url: absoluteUrl("/people"),
|
||||||
|
lastModified: now,
|
||||||
|
changeFrequency: "daily",
|
||||||
|
priority: 0.7,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
url: absoluteUrl("/register"),
|
url: absoluteUrl("/register"),
|
||||||
lastModified: now,
|
lastModified: now,
|
||||||
|
|||||||
@@ -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 Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useSession, signOut } from "next-auth/react";
|
import { useSession, signOut } from "next-auth/react";
|
||||||
|
import { Menu, X } from "lucide-react";
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
@@ -35,6 +36,12 @@ export function Navbar() {
|
|||||||
>
|
>
|
||||||
Open Trip
|
Open Trip
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/people"
|
||||||
|
className="rounded-lg px-3 py-1.5 text-sm font-medium text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-neutral-800"
|
||||||
|
>
|
||||||
|
Cari Teman
|
||||||
|
</Link>
|
||||||
|
|
||||||
{session?.user ? (
|
{session?.user ? (
|
||||||
<>
|
<>
|
||||||
@@ -66,7 +73,7 @@ export function Navbar() {
|
|||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/profile"
|
href="/profile"
|
||||||
className="max-w-[140px] truncate text-sm font-medium text-neutral-700 hover:text-primary-600"
|
className="max-w-35 truncate text-sm font-medium text-neutral-700 hover:text-primary-600"
|
||||||
>
|
>
|
||||||
{session.user.name}
|
{session.user.name}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -103,13 +110,9 @@ export function Navbar() {
|
|||||||
aria-label="Toggle menu"
|
aria-label="Toggle menu"
|
||||||
>
|
>
|
||||||
{menuOpen ? (
|
{menuOpen ? (
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
<X size={20} strokeWidth={1.75} aria-hidden />
|
||||||
<path d="M5 5l10 10M15 5L5 15" />
|
|
||||||
</svg>
|
|
||||||
) : (
|
) : (
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
<Menu size={20} strokeWidth={1.75} aria-hidden />
|
||||||
<path d="M3 5h14M3 10h14M3 15h14" />
|
|
||||||
</svg>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,6 +128,13 @@ export function Navbar() {
|
|||||||
>
|
>
|
||||||
Open Trip
|
Open Trip
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/people"
|
||||||
|
onClick={() => setMenuOpen(false)}
|
||||||
|
className="rounded-lg px-3 py-2.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
Cari Teman
|
||||||
|
</Link>
|
||||||
|
|
||||||
{session?.user ? (
|
{session?.user ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
import { profileRepo } from "@/server/repositories/profile.repo";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server component banner: muncul di atas semua halaman ketika user sudah login
|
||||||
|
* tapi profil sosialnya kosong. Menjaga janji "kenalan dulu, gabung kemudian"
|
||||||
|
* dengan mendorong user mengisi minat/kota sebelum join trip.
|
||||||
|
*/
|
||||||
|
export async function ProfileNudgeBanner() {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) return null;
|
||||||
|
|
||||||
|
const profile = await profileRepo.findByUserId(session.user.id);
|
||||||
|
const hasMeaningfulProfile =
|
||||||
|
!!profile &&
|
||||||
|
(!!profile.bio?.trim() ||
|
||||||
|
!!profile.city?.trim() ||
|
||||||
|
profile.interests.length > 0);
|
||||||
|
|
||||||
|
if (hasMeaningfulProfile) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-amber-200 bg-amber-50">
|
||||||
|
<div className="mx-auto flex max-w-6xl flex-col items-start gap-2 px-4 py-2.5 text-xs sm:flex-row sm:items-center sm:justify-between sm:text-sm">
|
||||||
|
<p className="text-amber-900">
|
||||||
|
<span className="font-semibold">Lengkapi profil sosial kamu</span> —
|
||||||
|
bio, kota, dan minat. Calon teman trip akan lebih mudah kenal kamu
|
||||||
|
sebelum gabung bareng.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/profile"
|
||||||
|
className="shrink-0 rounded-lg bg-amber-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-amber-700"
|
||||||
|
>
|
||||||
|
Isi profil sekarang
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,23 +1,20 @@
|
|||||||
|
import { BadgeCheck } from "lucide-react";
|
||||||
|
|
||||||
type Size = "sm" | "md";
|
type Size = "sm" | "md";
|
||||||
|
|
||||||
export function VerifiedBadge({ size = "sm" }: { size?: Size }) {
|
export function VerifiedBadge({ size = "sm" }: { size?: Size }) {
|
||||||
const cls =
|
const cls =
|
||||||
size === "md"
|
size === "md" ? "px-2.5 py-1 text-xs" : "px-2 py-0.5 text-[10px]";
|
||||||
? "px-2.5 py-1 text-xs"
|
|
||||||
: "px-2 py-0.5 text-[10px]";
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center gap-1 rounded-full bg-primary-50 font-semibold text-primary-700 ring-1 ring-primary-200 ${cls}`}
|
className={`inline-flex items-center gap-1 rounded-full bg-primary-50 font-semibold text-primary-700 ring-1 ring-primary-200 ${cls}`}
|
||||||
title="Organizer terverifikasi SeTrip"
|
title="Organizer terverifikasi SeTrip"
|
||||||
>
|
>
|
||||||
<svg
|
<BadgeCheck
|
||||||
viewBox="0 0 16 16"
|
size={size === "md" ? 14 : 12}
|
||||||
fill="currentColor"
|
strokeWidth={1.75}
|
||||||
className={size === "md" ? "h-3.5 w-3.5" : "h-3 w-3"}
|
aria-hidden
|
||||||
aria-hidden="true"
|
/>
|
||||||
>
|
|
||||||
<path d="M8 0l2.09 1.74L12.86 1.5l.64 2.78 2.5 1.5-1.5 2.5.5 2.86-2.78.64-1.5 2.5-2.72-.59L5.5 14.5 4 12 1.5 11.36 2 8.5.5 6 3 4.5l.64-2.78 2.77.24L8 0zm-1.07 9.4l4.6-4.6-1.06-1.06-3.54 3.54-1.41-1.42-1.06 1.06 2.47 2.48z" />
|
|
||||||
</svg>
|
|
||||||
Verified
|
Verified
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
-136
@@ -1,136 +0,0 @@
|
|||||||
# Deploy Setrip dengan PM2
|
|
||||||
|
|
||||||
Panduan ini untuk menjalankan aplikasi **Next.js** (setrip) di server Linux/VPS menggunakan **PM2**. Pastikan **PostgreSQL** sudah tersedia dan URL-nya sesuai dengan variabel lingkungan aplikasi.
|
|
||||||
|
|
||||||
## Prasyarat
|
|
||||||
|
|
||||||
- Node.js **20.x** (disarankan, selaras dengan `@types/node` di proyek)
|
|
||||||
- npm atau pnpm/yarn (contoh di bawah memakai **npm**)
|
|
||||||
- PM2 terpasang global: `npm install -g pm2`
|
|
||||||
- Basis data PostgreSQL dan file `.env` di server (lihat bagian Lingkungan)
|
|
||||||
|
|
||||||
## File PM2
|
|
||||||
|
|
||||||
Konfigurasi PM2 ada di root repositori: **`ecosystem.config.js`** (nama ini disengaja).
|
|
||||||
|
|
||||||
### Jangan `pm2 start ecosystem.js` kecuali itu skrip Node
|
|
||||||
|
|
||||||
Jika Anda menjalankan `pm2 start ecosystem.js` pada file yang isinya hanya `module.exports = { apps: [...] }`, PM2 menganggapnya **skrip aplikasi biasa** dan menjalankannya dengan `node ecosystem.js`. Akibatnya:
|
|
||||||
|
|
||||||
- Nama proses di daftar PM2 jadi **`ecosystem`** (bukan `setrip`).
|
|
||||||
- Next.js **tidak** dijalankan lewat entri `apps` Anda.
|
|
||||||
|
|
||||||
Gunakan selalu:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pm2 start ecosystem.config.js --env production
|
|
||||||
```
|
|
||||||
|
|
||||||
Isi file menjalankan biner Next (`next start`) setelah build, mode **fork**, satu proses, **PORT** **3090**. Ubah `PORT` di file tersebut jika kebijakan port berubah.
|
|
||||||
|
|
||||||
### Berapa port yang dibutuhkan?
|
|
||||||
|
|
||||||
Untuk **trafik HTTP/HTTPS ke aplikasi Next.js**, cukup **satu port** yang didengarkan oleh `next start` — di setup ini **3090** (atau satu port lain yang Anda set).
|
|
||||||
|
|
||||||
**PostgreSQL** memakai port tersendiri (biasanya **5432**) di mesin tempat database berjalan. Itu bukan “port kedua untuk publik” dari aplikasi web: koneksi DB terjadi dari server aplikasi ke database (localhost atau jaringan internal). Di firewall publik Anda biasanya hanya membuka **80/443** (reverse proxy) atau **3090** jika diakses langsung tanpa proxy.
|
|
||||||
|
|
||||||
## Langkah deploy (pertama kali)
|
|
||||||
|
|
||||||
1. **Clone** repositori ke server (misalnya `/var/www/setrip`).
|
|
||||||
|
|
||||||
2. **Masuk** ke folder proyek dan pasang dependensi produksi:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /var/www/setrip
|
|
||||||
npm ci
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Lingkungan** — salin atau buat `.env` / `.env.production` di server (jangan commit rahasia ke git). Minimal sesuai kebutuhan aplikasi Anda, contoh:
|
|
||||||
|
|
||||||
- `DATABASE_URL` — koneksi PostgreSQL
|
|
||||||
- `NEXTAUTH_SECRET` — string acak yang kuat
|
|
||||||
- `NEXTAUTH_URL` — URL publik aplikasi (harus cocok dengan yang dibuka browser), misalnya `https://domain-anda.com` atau `http://host:3090` jika tanpa HTTPS dan akses langsung ke port tersebut
|
|
||||||
|
|
||||||
4. **Prisma** — generate client dan terapkan migrasi (jika memakai migrasi):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx prisma generate
|
|
||||||
npx prisma migrate deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Build** Next.js:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
6. **Mulai** dengan PM2:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pm2 start ecosystem.config.js --env production
|
|
||||||
```
|
|
||||||
|
|
||||||
Tanpa `--env production` tetap jalan; variabel default memakai blok `env` di file.
|
|
||||||
|
|
||||||
7. **Simpan** daftar proses agar bangkit lagi setelah reboot:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pm2 save
|
|
||||||
pm2 startup
|
|
||||||
```
|
|
||||||
|
|
||||||
Ikuti perintah yang dikeluarkan PM2 (biasanya menyalin satu baris `sudo env ...`).
|
|
||||||
|
|
||||||
## Perintah PM2 yang sering dipakai
|
|
||||||
|
|
||||||
| Perintah | Keterangan |
|
|
||||||
|----------|------------|
|
|
||||||
| `pm2 status` | Status semua aplikasi |
|
|
||||||
| `pm2 logs setrip` | Log aplikasi bernama `setrip` |
|
|
||||||
| `pm2 reload setrip` | Reload tanpa downtime (berguna setelah deploy baru) |
|
|
||||||
| `pm2 restart setrip` | Restart proses |
|
|
||||||
| `pm2 stop setrip` | Menghentikan aplikasi |
|
|
||||||
| `pm2 delete setrip` | Menghapus aplikasi dari daftar PM2 |
|
|
||||||
|
|
||||||
## Deploy ulang (update kode)
|
|
||||||
|
|
||||||
Di server, setelah `git pull` (atau salin artefak baru):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /var/www/setrip
|
|
||||||
npm ci
|
|
||||||
npx prisma generate
|
|
||||||
npx prisma migrate deploy
|
|
||||||
npm run build
|
|
||||||
pm2 reload setrip
|
|
||||||
```
|
|
||||||
|
|
||||||
Jika nama aplikasi di PM2 berbeda, ganti `setrip` dengan nama di `ecosystem.config.js` (`name`).
|
|
||||||
|
|
||||||
### Hapus proses PM2 yang salah (nama `ecosystem`)
|
|
||||||
|
|
||||||
Jika Anda pernah menjalankan `pm2 start ecosystem.js` dan muncul proses bernama `ecosystem`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pm2 stop ecosystem
|
|
||||||
pm2 delete ecosystem
|
|
||||||
```
|
|
||||||
|
|
||||||
Atau pakai id dari `pm2 status` (contoh id `9`):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pm2 stop 9
|
|
||||||
pm2 delete 9
|
|
||||||
```
|
|
||||||
|
|
||||||
Lalu mulai lagi dengan `pm2 start ecosystem.config.js --env production` dan `pm2 save`.
|
|
||||||
|
|
||||||
## Reverse proxy (opsional)
|
|
||||||
|
|
||||||
Agar bisa HTTPS dan port 80/443, letakkan **Nginx** (atau Caddy) di depan aplikasi yang mendengarkan di `127.0.0.1:3090`. Pastikan `NEXTAUTH_URL` memakai skema dan host yang sama dengan yang diakses pengguna.
|
|
||||||
|
|
||||||
## Pemecahan masalah
|
|
||||||
|
|
||||||
- **502 / tidak terhubung** — cek `pm2 logs setrip`, pastikan PostgreSQL dapat dijangkau dari server, dan `PORT` tidak bentrok dengan layanan lain.
|
|
||||||
- **Error Prisma** — pastikan `npx prisma generate` dijalankan setelah `npm ci` di setiap deploy, dan `DATABASE_URL` benar.
|
|
||||||
- **NextAuth** — `NEXTAUTH_URL` harus persis URL publik (termasuk `https://`).
|
|
||||||
@@ -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.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user