Compare commits

..

30 Commits

Author SHA1 Message Date
arifal 88353d5d06 install 2026-05-22 15:38:08 +07:00
arifal 0e7bb07772 0.16.12 2026-05-22 15:17:59 +07:00
arifal 3268a6284e update lib 2026-05-22 15:17:40 +07:00
arifal 73406d0b86 0.16.11 2026-05-22 14:53:07 +07:00
arifal 4c449a572a fix upload image trip 2026-05-22 14:52:22 +07:00
arifal 9022f983a2 0.16.10 2026-05-21 15:31:33 +07:00
arifal 6b8f9dec5d fix warning class style 2026-05-21 15:30:53 +07:00
arifal e6a032e8e0 0.16.9 2026-05-21 12:20:49 +07:00
arifal 81a0c2c6c8 fix oauth google sign 2026-05-21 12:20:28 +07:00
arifal 03887fb1cd 0.16.8 2026-05-21 11:59:32 +07:00
arifal f84d0e3726 fix ui style 2026-05-21 11:59:02 +07:00
arifal 22e66ce493 0.16.7 2026-05-20 16:53:15 +07:00
arifal d4e5d6be38 seeder 2026-05-20 16:52:54 +07:00
arifal b4d39d86ae 0.16.6 2026-05-20 16:08:52 +07:00
arifal ef7aa528d4 add loading and optimize query using cache and pwa 2026-05-20 16:08:29 +07:00
arifal 5d095151e4 0.16.5 2026-05-20 15:26:45 +07:00
arifal db71159613 0.16.4 2026-05-20 15:25:48 +07:00
arifal cb03967deb fix email sender all flow 2026-05-20 15:25:32 +07:00
arifal 306396ae43 0.16.3 2026-05-20 13:34:03 +07:00
arifal b836d08b10 fix date picker on all filter and field using date 2026-05-20 13:33:29 +07:00
arifal 57f7764bf5 0.16.2 2026-05-20 13:16:50 +07:00
arifal da217c2946 fix race condition issue 2026-05-20 13:16:25 +07:00
arifal 43ea725107 0.16.1 2026-05-18 20:55:26 +07:00
arifal 1200bf85c2 cron setup 2026-05-18 20:54:59 +07:00
arifal d5842b984b 0.16.0 2026-05-18 20:47:31 +07:00
arifal bf5c97c442 email service and template using resend 2026-05-18 20:47:05 +07:00
arifal f0ce22bbb8 0.15.0 2026-05-18 20:25:54 +07:00
arifal bc4973a594 admin roadmap done, reupload request, submission history, manual override 2026-05-18 20:25:21 +07:00
arifal b844ebdfac 0.14.0 2026-05-18 20:09:51 +07:00
arifal ea63f56e97 admin roadmap csv export, adminactionlog, global search 2026-05-18 20:09:22 +07:00
125 changed files with 9043 additions and 2573 deletions
+2 -1
View File
@@ -10,7 +10,8 @@
"PowerShell(npx prisma generate 2>&1)", "PowerShell(npx prisma generate 2>&1)",
"PowerShell(npx tsc --noEmit 2>&1)", "PowerShell(npx tsc --noEmit 2>&1)",
"PowerShell(npx eslint server/services/refund.service.ts server/repositories/refund.repo.ts features/refund app/admin/refunds 2>&1)", "PowerShell(npx eslint server/services/refund.service.ts server/repositories/refund.repo.ts features/refund app/admin/refunds 2>&1)",
"PowerShell(npx eslint server lib features app 2>&1)" "PowerShell(npx eslint server lib features app 2>&1)",
"Bash(npx eslint *)"
] ]
} }
} }
+24
View File
@@ -12,6 +12,9 @@ KYC_ENCRYPTION_KEY=
KYC_NIK_PEPPER= KYC_NIK_PEPPER=
# Absolute path for private KYC uploads (default: <cwd>/uploads/private) # Absolute path for private KYC uploads (default: <cwd>/uploads/private)
KYC_UPLOAD_DIR= KYC_UPLOAD_DIR=
# Absolute path for public trip image uploads (default: <cwd>/uploads/trips)
# Pakai volume persisten — file di sini harus selamat saat redeploy/restart.
TRIP_UPLOAD_DIR=
GOOGLE_CLIENT_ID="xxxxxxxx" GOOGLE_CLIENT_ID="xxxxxxxx"
GOOGLE_CLIENT_SECRET="xxxxxxxx" GOOGLE_CLIENT_SECRET="xxxxxxxx"
@@ -36,3 +39,24 @@ NEXT_PUBLIC_MIDTRANS_IS_PRODUCTION=false
# openssl rand -hex 32 # openssl rand -hex 32
# Setup detail: lihat docs/CRON_SETUP.md # Setup detail: lihat docs/CRON_SETUP.md
CRON_SECRET= CRON_SECRET=
# === Admin alerting (opsional) ===
# Discord webhook URL untuk push notif saat cron FAILED. Tanpa env, `notifyAdmins`
# no-op — admin tetap bisa cek manual di /admin/system. Cara setup:
# 1. Discord channel internal → Edit Channel → Integrations → Webhooks → New
# 2. Copy "Webhook URL", paste di sini
# Format: https://discord.com/api/webhooks/<id>/<token>
ADMIN_ALERT_WEBHOOK_URL=
# === Email notifications (Resend) ===
# API key Resend untuk kirim email transaksional (KYC, refund, payment, suspend).
# Tanpa env, sync send di-skip dan semua email di-queue di DB (status PENDING).
# Setelah env di-set, cron `/api/cron/process-email-jobs` akan drain queue.
# Sign up: https://resend.com → API Keys
RESEND_API_KEY=
# Email sender — format RFC 5322 "Display Name <email@domain>".
# Domain harus diverifikasi di Resend dashboard (SPF + DKIM).
# Default `onboarding@resend.dev` cocok untuk dev/testing.
EMAIL_FROM="SeTrip <onboarding@resend.dev>"
+2 -1
View File
@@ -36,7 +36,8 @@ yarn-error.log*
.env.development .env.development
.env.local .env.local
# private uploads (KYC: KTP / liveness). Never serve directly. # runtime uploads KYC (encrypted, private) & trip images (public, served via
# /api/trip-images). User data, not source: keep out of git, back up separately.
/uploads/ /uploads/
# vercel # vercel
-107
View File
@@ -1,107 +0,0 @@
# Setrip — Admin Audit & Investigation Roadmap
> **Status keseluruhan:** 🚧 Partial — Phase 1 delivered, Phase 2-4 pending.
Admin perlu **mencari** lintas entity (booking/payment/refund/user/trip) dan **export** untuk compliance + investigasi dispute.
> **Skenario nyata:** auditor bertanya "tunjukkan semua refund yang di-approve admin X di bulan Juni 2026 dengan total lebih dari Rp 5 juta". Saat ini admin harus query DB manual atau ambil screenshot satu-satu. Tidak ada cara cari berdasarkan kombinasi reviewer + tanggal + nominal.
---
## Baseline
- ✅ Data audit sudah ada di schema: `Refund.reviewedBy/reviewedAt/adminNote`, `Payout.processedBy/processedAt/adminNote`, `OrganizerVerification.reviewedBy/reviewedAt/rejectionReason`.
- ✅ Existing list pages (`/admin/refunds`, `/admin/payouts`, `/admin/verifications`) sudah grouping by status tab.
- ❌ Tidak ada filter date range / reviewer / amount / reason.
- ❌ Tidak ada kolom "reviewer email" di list — harus klik detail.
- ❌ Tidak ada global search (cari berdasarkan email user, order id, trip id).
- ❌ Tidak ada CSV export.
- ❌ Tidak ada audit log untuk action admin di entity lain (User suspension, Trip force-cancel, Verification reopen).
---
## Phase 1 — Filter & Search Enhancements ✅ DELIVERED
| # | 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` (date range + reviewer dropdown + optional reason) | ✅ | [features/admin/components/admin-filter-bar.tsx](features/admin/components/admin-filter-bar.tsx) |
| 1.7 | Repo helper: filter params di `refundRepo.listByStatus`, `payoutRepo.listByStatus`, `organizerRepo.listByStatus` | ✅ | `server/repositories/*.ts` |
| 1.8 | Helper `listAdminEmails()` untuk dropdown reviewer | ✅ | [lib/admin.ts](lib/admin.ts) |
**Tindakan manual:** tidak ada.
> _Catatan: reviewer email column di list belum ditambah — info sudah ada di refund/payout/verification card detail (`Diproses oleh ...`)._ Bisa ditambah saat dibutuhkan untuk skim cepat._
---
## Phase 2 — Global Search ⏳
Satu search box yang resolve ke entity detail page paling relevan.
**Keputusan asumsi:**
- Input string user, prefix-based dispatch:
- Email format (`@`) → user search → redirect ke `/admin/users/[id]`
- Mulai `midtrans-` / `manual-` → payment lookup by `externalOrderId``/admin/bookings/[bookingId]`
- Mulai `cm` (cuid pattern) + length 25 → coba lookup berurutan: trip → booking → user
- Else: full-text search di trip title/destination
- Pakai server action atau route handler `/api/admin/search` — return list hasil + jenis entity.
- UI: searchbar di admin layout (top-right) yang dropdown hasil.
| # | Item | Status | File |
|---|---|---|---|
| 2.1 | `adminSearchService.resolve(query)` — dispatch ke repo lookup yang tepat | ⏳ | `server/services/admin-search.service.ts` |
| 2.2 | Route handler `/api/admin/search?q=...` (GET, guard isAdmin) | ⏳ | `app/api/admin/search/route.ts` |
| 2.3 | Component `AdminSearchBar` di admin layout — debounced, dropdown hasil | ⏳ | `features/admin/components/admin-search-bar.tsx` |
| 2.4 | Page `/admin/search?q=...` untuk full results kalau dropdown limit terlampaui | ⏳ | `app/admin/search/page.tsx` |
**Tindakan manual:** tidak ada.
---
## Phase 3 — CSV Export ⏳
Export untuk laporan keuangan & compliance.
**Keputusan asumsi:**
- Stream CSV via route handler — jangan load semua ke memory.
- Pakai filter yang sama dengan list page — admin pakai URL filter lalu klik "Export".
- Header CSV: human-readable bahasa Indonesia (mis. "Tanggal Approve", "Email Peserta", "Nominal Refund").
- Tidak ada Excel/xlsx — CSV cukup, mudah dibuka di Sheets/Excel.
| # | Item | Status | File |
|---|---|---|---|
| 3.1 | Helper `lib/csv.ts``streamCsv(headers, rows)` return Response | ⏳ | `lib/csv.ts` |
| 3.2 | Route `/api/admin/export/refunds` — pakai filter dari query string | ⏳ | `app/api/admin/export/refunds/route.ts` |
| 3.3 | Route `/api/admin/export/payouts` | ⏳ | `app/api/admin/export/payouts/route.ts` |
| 3.4 | Route `/api/admin/export/verifications` (tanpa NIK / KTP — hanya metadata) | ⏳ | `app/api/admin/export/verifications/route.ts` |
| 3.5 | Tombol "Export CSV" di tiap admin list page | ⏳ | semua `app/admin/*/page.tsx` |
**Tindakan manual:**
1. Test export di staging — pastikan tidak leak data sensitif (NIK harus tetap encrypted/excluded).
2. Update kebijakan privasi: data export hanya untuk internal compliance.
---
## Phase 4 — Generic Admin Audit Log ⏳
Tabel `AdminActionLog` untuk action di entity yang belum punya audit field (User suspend, Trip force-cancel, Verification reopen, dst).
**Keputusan asumsi:**
- Single tabel polymorphic: `AdminActionLog { adminId, action, entityType, entityId, payload Json?, createdAt }`.
- Append-only, never update/delete.
- Service helper `auditLog.record(...)` dipanggil eksplisit di setiap action admin (tidak via Prisma middleware — terlalu magic).
- View page `/admin/audit-log` dengan filter `adminId`, `entityType`, `action`, date range.
| # | Item | Status | File |
|---|---|---|---|
| 4.1 | Model `AdminActionLog` + migration | ⏳ | [prisma/schema.prisma](prisma/schema.prisma) |
| 4.2 | Helper `auditLog.record({ adminId, action, entityType, entityId, payload? })` | ⏳ | `server/services/audit-log.service.ts` |
| 4.3 | Wire `auditLog.record` di semua admin server action existing (refund approve/reject/mark, payout markPaid, verification approve/reject) | ⏳ | `features/*/actions.ts` |
| 4.4 | Page `/admin/audit-log` dengan filter + pagination | ⏳ | `app/admin/audit-log/page.tsx` |
**Tindakan manual:** tidak ada.
+72 -43
View File
@@ -1,75 +1,104 @@
# Setrip — Admin Roadmap (Index) # Setrip — Admin Roadmap (Index) · ✅ ALL DELIVERED
Status implementasi kemampuan admin agar admin **dapat mengontrol seluruh aplikasi saat ada insiden**, bukan hanya read-only dashboard. Status implementasi kemampuan admin agar admin **dapat mengontrol seluruh aplikasi saat ada insiden**, bukan hanya read-only dashboard.
> **Prinsip:** admin adalah safety net terakhir saat sistem otomatis gagal atau ada bad actor. Setiap action admin harus auditable (siapa, kapan, alasan), idempotent, dan terbatas hanya untuk admin yang terdaftar di `ADMIN_EMAILS`. > **Prinsip:** admin adalah safety net terakhir saat sistem otomatis gagal atau ada bad actor. Setiap action admin harus auditable (siapa, kapan, alasan), idempotent, dan terbatas hanya untuk admin yang terdaftar di `ADMIN_EMAILS`.
> **Status:** 6 dari 6 roadmap area selesai (4 fully, 2 dengan minor skip yang dijelaskan di archive masing-masing). Detail lengkap per area di [docs/archive/](docs/archive/).
--- ---
## Baseline (yang BISA admin lakukan sekarang) ## Baseline yang BISA admin lakukan sekarang
| Area | Fungsi | File | | Area | Fungsi | File |
|---|---|---| |---|---|---|
| Dashboard | View count: verifikasi PENDING, refund per status, payout per status | [app/admin/page.tsx](app/admin/page.tsx) | | **Dashboard** | View count: verifikasi PENDING, refund per status, payout per status | [app/admin/page.tsx](app/admin/page.tsx) |
| **Global search** | Search bar di sidebar — by email, order_id, cuid, fuzzy trip/user | [features/admin/components/admin-search-bar.tsx](features/admin/components/admin-search-bar.tsx) |
| **Trips** | List + search + detail; force-cancel dengan auto-refund (admin intervention) | [app/admin/trips/](app/admin/trips/) | | **Trips** | List + search + detail; force-cancel dengan auto-refund (admin intervention) | [app/admin/trips/](app/admin/trips/) |
| **Users** | List + search + filter (active/suspended); detail dengan trip + booking history; suspend/unsuspend | [app/admin/users/](app/admin/users/) | | **Users** | List + search + filter; detail dengan trip + booking history; suspend/unsuspend; manual verify (override KYC); analytics | [app/admin/users/](app/admin/users/) |
| **Bookings detail** | Timeline lintas Payment + Refund + Payout, raw callback viewer, Midtrans reconcile | [app/admin/bookings/[id]/page.tsx](app/admin/bookings/[id]/page.tsx) | | **Bookings detail** | Timeline lintas Payment + Refund + Payout, raw callback viewer, Midtrans reconcile | [app/admin/bookings/[id]/page.tsx](app/admin/bookings/[id]/page.tsx) |
| Verifikasi KYC | Approve / Reject / **Reopen REJECTED**; filter date range + reviewer | [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx) | | **Verifikasi KYC** | Approve / Reject / Reopen REJECTED / Request re-upload (lebih lembut dari reject) / Manual override; filter date range + reviewer; CSV export | [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx) |
| Refund | Create manual, approve, reject, mark SUCCEEDED/FAILED; filter date/reviewer/reason; link ke booking timeline | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) | | **Refund** | Create manual, approve, reject, mark SUCCEEDED/FAILED; filter date/reviewer/reason; link ke booking timeline; CSV export | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) |
| Payout | View per status, mark PAID; filter date/processor; link ke booking timeline | [app/admin/payouts/page.tsx](app/admin/payouts/page.tsx) | | **Payout** | View per status, mark PAID; filter date/processor; link ke booking timeline; CSV export | [app/admin/payouts/page.tsx](app/admin/payouts/page.tsx) |
| **System Health** | Status cron jobs (last run, last success, 7d stats), 20 recent runs, health badge | [app/admin/system/page.tsx](app/admin/system/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. **Aksi mutating yang diblokir untuk suspended user:** sign-in (NextAuth), `createTripAction`, `joinTripAction`. Trip public list otomatis sembunyikan organizer suspended.
**Audit trail otomatis:** semua aksi admin (suspend, force-cancel, reconcile, approve/reject/reopen/request-reupload/manual-override verification, create/decide refund, mark payout PAID) tercatat di `AdminActionLog` via `auditLog.record()`.
Auth admin: env `ADMIN_EMAILS` → cek di [lib/admin.ts](lib/admin.ts), dipassing ke session via [lib/auth.ts](lib/auth.ts). Auth admin: env `ADMIN_EMAILS` → cek di [lib/admin.ts](lib/admin.ts), dipassing ke session via [lib/auth.ts](lib/auth.ts).
--- ---
## Roadmap per area ## Roadmap per area — status final
| Roadmap | Prioritas | Status | File | | Roadmap | Prioritas | Status | Archive |
|---|---|---|---| |---|---|---|---|
| Trip Operations (search, view, cancel manual) | 🔴 HIGH | ✅ **Delivered** | [docs/archive/ADMIN_TRIP_OPS_ROADMAP.md](docs/archive/ADMIN_TRIP_OPS_ROADMAP.md) | | Trip Operations | 🔴 HIGH | ✅ Delivered | [ADMIN_TRIP_OPS_ROADMAP.md](docs/archive/ADMIN_TRIP_OPS_ROADMAP.md) |
| Payment Operations (booking detail, reconcile, dispute) | 🔴 HIGH | ✅ **Delivered** | [docs/archive/ADMIN_PAYMENT_OPS_ROADMAP.md](docs/archive/ADMIN_PAYMENT_OPS_ROADMAP.md) | | Payment Operations | 🔴 HIGH | ✅ Delivered | [ADMIN_PAYMENT_OPS_ROADMAP.md](docs/archive/ADMIN_PAYMENT_OPS_ROADMAP.md) |
| Audit & Investigation (search, filter, export) | 🔴 HIGH | 🚧 Phase 1 done · Phase 2-4 pending | [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md) | | Audit & Investigation | 🔴 HIGH | ✅ Delivered | [ADMIN_AUDIT_ROADMAP.md](docs/archive/ADMIN_AUDIT_ROADMAP.md) |
| User Management (search, suspend/ban) | 🟡 MEDIUM | ✅ **Delivered** | [docs/archive/ADMIN_USER_MGMT_ROADMAP.md](docs/archive/ADMIN_USER_MGMT_ROADMAP.md) | | User Management | 🟡 MEDIUM | ✅ Delivered | [ADMIN_USER_MGMT_ROADMAP.md](docs/archive/ADMIN_USER_MGMT_ROADMAP.md) |
| Verification (reopen, re-upload request) | 🟡 MEDIUM | 🚧 Phase 1 done · Phase 2-4 deferred | [docs/archive/ADMIN_VERIFICATION_ROADMAP.md](docs/archive/ADMIN_VERIFICATION_ROADMAP.md) | | Verification | 🟡 MEDIUM | ✅ Delivered | [ADMIN_VERIFICATION_ROADMAP.md](docs/archive/ADMIN_VERIFICATION_ROADMAP.md) |
| System Health (cron monitor, stale state alerts) | 🟡 MEDIUM | 🚧 Phase 1-2 done · Phase 3-4 deferred | [docs/archive/ADMIN_SYSTEM_HEALTH_ROADMAP.md](docs/archive/ADMIN_SYSTEM_HEALTH_ROADMAP.md) | | System Health | 🟡 MEDIUM | ✅ Delivered | [ADMIN_SYSTEM_HEALTH_ROADMAP.md](docs/archive/ADMIN_SYSTEM_HEALTH_ROADMAP.md) |
**Legend status:** ⏳ belum mulai · 🚧 partial · ✅ selesai **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).
--- ---
## Sisa pekerjaan ## Tindakan manual setelah deploy versi final
Hampir semua kapabilitas dasar admin sudah delivered. Yang tersisa hanya enhancement non-blocking:
**Audit Phase 2-4** (lihat [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md)):
- Phase 2 — Global Search (admin search bar resolve email/order_id/cuid)
- Phase 3 — CSV Export untuk refunds/payouts/verifications
- Phase 4 — Generic `AdminActionLog` model untuk audit action lintas entity
**Lainnya yang di-defer** (di archive masing-masing):
- Verification: re-upload request flow, verification history, manual override
- System Health: stale state alerts (Payment AWAITING > 25h, Payout HELD overdue), external alerting (Discord webhook)
- User Mgmt: bulk analytics dashboard
---
## Tindakan manual setelah deploy versi terakhir
```bash ```bash
# Apply 3 migration baru: add_trip_admin_cancel, add_user_suspension, add_cron_run # Apply 5 migration baru (urutan time-stamp tidak masalah, prisma resolve):
# - 20260518150000_add_trip_admin_cancel
# - 20260518160000_add_user_suspension
# - 20260518170000_add_cron_run
# - 20260518180000_add_admin_action_log
# - 20260518190000_verification_enhancements
npx prisma migrate deploy npx prisma migrate deploy
# Restart Next.js / PM2 supaya Prisma client baru ter-load # (Opsional) Set env Discord webhook untuk alert cron failed
echo 'ADMIN_ALERT_WEBHOOK_URL=https://discord.com/api/webhooks/...' >> .env
# Restart Next.js / PM2 supaya Prisma client + env baru ter-load
pm2 restart setrip --update-env pm2 restart setrip --update-env
``` ```
Brief admin tentang kapabilitas baru: Brief admin tentang kapabilitas baru (lihat archive masing-masing untuk detail SOP):
- **Force-cancel trip** di `/admin/trips/[id]` — pakai saat organizer unreachable / dispute, reason wajib min 10 char.
- **Reconcile Midtrans** di `/admin/bookings/[id]` — pakai saat peserta lapor "sudah bayar tapi status belum update". Idempotent. - **Global search** di sidebar — ketik email, order_id, atau cuid; auto-detect ke detail page yang tepat.
- **Suspend user** di `/admin/users/[id]`pakai untuk scam/harassment. Suspended user diblokir sign-in dan aksi mutatif. - **Force-cancel trip** di `/admin/trips/[id]`saat organizer unreachable / dispute.
- **Reopen verification** di `/admin/verifications` (tab REJECTED) — saat organizer kirim ulang foto via email/WA. - **Reconcile Midtrans** di `/admin/bookings/[id]` — saat peserta lapor "sudah bayar tapi status belum update".
- **System status** di `/admin/system` — cek setiap pagi, pastikan cron jalan (🟢 OK). - **Suspend user** di `/admin/users/[id]` — untuk scam/harassment.
- **Filter date range + reviewer** di refunds/payouts/verifications — untuk investigasi & compliance. - **Manual verify** di `/admin/users/[id]` — partner trusted, bypass KYC, ter-flag jelas.
- **Reopen verification** di REJECTED card — organizer kirim ulang foto via email/WA.
- **Request re-upload** di PENDING card — lebih lembut dari reject; organizer dapat banner di `/verify`.
- **System status** di `/admin/system` — cek setiap pagi, lihat alert stale + cron health.
- **Discord alert** otomatis saat cron FAILED (kalau `ADMIN_ALERT_WEBHOOK_URL` di-set).
- **Audit log** di `/admin/audit-log` — bukti compliance untuk audit eksternal.
- **CSV export** di refunds/payouts/verifications — laporan keuangan/compliance.
- **User stats** di `/admin/users/stats` — total user, signup per minggu.
---
## File-file penting yang ditambahkan / diubah
**Service & helper:**
- [server/services/audit-log.service.ts](server/services/audit-log.service.ts) — log polymorphic
- [server/services/system-health.service.ts](server/services/system-health.service.ts) — stale state detection
- [server/services/admin-search.service.ts](server/services/admin-search.service.ts) — search dispatcher
- [server/services/user.service.ts](server/services/user.service.ts) — suspend/unsuspend
- [lib/cron-runner.ts](lib/cron-runner.ts) — `runCron()` wrapper
- [lib/admin-notify.ts](lib/admin-notify.ts) — Discord webhook helper
- [lib/auth-guards.ts](lib/auth-guards.ts) — `requireActiveUser()`
- [lib/csv.ts](lib/csv.ts) — CSV builder
**Model baru:** `AdminActionLog`, `CronRun` + 5 migration baru di `prisma/migrations/`.
**Admin UI baru:** `/admin/trips`, `/admin/users`, `/admin/bookings/[id]`, `/admin/system`, `/admin/audit-log`, `/admin/users/stats`.
**API routes baru:** `/api/admin/search`, `/api/admin/export/{refunds,payouts,verifications}`.
+165
View File
@@ -0,0 +1,165 @@
# Setrip — Email Notifications Roadmap
Status implementasi notifikasi email transaksional ke user & organizer. Pakai pola yang sama dengan refund/admin roadmap: per-phase checklist, idempotent, auditable.
> **Prinsip:**
> - **Transactional only di MVP** — KYC, refund, payment, account moderation. Marketing/reminder belakangan.
> - **Idempotent** — webhook retry / cron rerun tidak boleh double-send. Pakai `idempotencyKey` unique constraint di `EmailSent`.
> - **Non-blocking** — server action utama tidak boleh gagal kalau email gateway down. Pattern: try send sync; kalau gagal, enqueue `EmailJob` untuk retry cron.
> - **Audit-friendly** — semua email tercatat (sent atau queued) supaya admin bisa cek "kenapa user X belum dapat email Y?".
> - **Unsubscribe-aware** — transactional email (refund, payment, suspend) tetap dikirim. Marketing (reminder, social signal) opt-in dengan unsubscribe link.
**Progress (per 2026-05-20):** PR-E1, E2, E3, E5 ✅ — foundation, transactional email, notifikasi event Phase 2, dan admin email log + retry selesai. PR-E4 ⏳ (marketing/reminder) sengaja ditunda — belum dibutuhkan.
---
## Baseline (kondisi sekarang)
- ❌ Tidak ada email service terintegrasi.
- ❌ Tidak ada template engine.
- ❌ User & organizer hanya tahu state via UI — kalau tidak buka app, miss event penting (refund cair, KYC approve, dst).
---
## Provider choice
**Resend** — alasan:
- Free tier 3000 email/bulan + 100/hari (cukup untuk MVP)
- React Email native (kalau mau upgrade dari plain HTML)
- API simple (POST `/emails`)
- DNS setup ringan (SPF + DKIM auto)
Alternatif: AWS SES (paling murah at scale), SendGrid, Mailgun. **Decision:** Resend untuk MVP, evaluate ulang saat lebih dari 50k email/bulan.
**Library dependency:** SKIP — pakai `fetch` ke `https://api.resend.com/emails` directly. Lebih ringkas, satu-satunya dependency-free option.
---
## PR-E1 — Foundation: schema + service + cron (MVP wajib) ✅
**Tujuan:** infrastructure kirim email yang idempotent + retry-able, tanpa nge-block server action.
**Keputusan asumsi:**
- 2 model baru:
- `EmailSent` — append-only log dengan `idempotencyKey @unique`. Cek di sini sebelum kirim → cegah double-send.
- `EmailJob` — retry queue untuk send yang gagal sync. Status `PENDING/PROCESSING/SUCCESS/FAILED`, attempt counter, max 5 retry exponential.
- Service `emailService.send({ to, template, data, idempotencyKey })`:
1. Cek `EmailSent` by idempotencyKey → kalau exist, return early.
2. Render template ke `{ subject, html }`.
3. POST ke Resend.
4. Sukses → insert `EmailSent` row.
5. Gagal → insert `EmailJob` row (status PENDING, attempts=1).
- Cron `/api/cron/process-email-jobs` setiap 5 menit — pick PENDING + FAILED (attempts<5), retry, mark SUCCESS atau bump attempts.
- Caller pattern: `void emailService.send(...)` (fire-and-forget) supaya tidak nge-block server action. Try/catch internal sudah handle error.
| # | Item | Status | File |
|---|---|---|---|
| E1.1 | Model `EmailSent { idempotencyKey @unique, to, template, sentAt, providerMessageId? }` + migration | ✅ | [prisma/schema.prisma](prisma/schema.prisma) |
| E1.2 | Model `EmailJob { idempotencyKey, to, subject, html, status, attempts, lastError?, scheduledAt }` + migration | ✅ | [prisma/schema.prisma](prisma/schema.prisma) |
| E1.3 | Service `lib/email/send.ts` — Resend client (raw fetch) + idempotency + enqueue on failure | ✅ | `lib/email/send.ts` |
| E1.4 | Template registry — function per template return `{ subject, html }` | ✅ | `lib/email/templates.ts` |
| E1.5 | Cron route `/api/cron/process-email-jobs` pakai `runCron` helper | ✅ | `app/api/cron/process-email-jobs/route.ts` |
| E1.6 | Env: `RESEND_API_KEY`, `EMAIL_FROM` (mis. `"SeTrip <no-reply@setrip.id>"`) | ✅ | `.env.example` |
**Tindakan manual ops:**
1. Buat akun Resend, verify domain `setrip.id` (atau pakai `onboarding@resend.dev` di dev).
2. Set DNS SPF + DKIM record di provider domain.
3. Generate API key, set env `RESEND_API_KEY` + `EMAIL_FROM` di production.
4. Daftarkan cron baru di system crontab: `*/5 * * * * curl ... /api/cron/process-email-jobs`.
---
## PR-E2 — Phase 1: Wire transactional emails (MVP wajib) ✅
**Tujuan:** kirim email untuk event paling kritis (KYC, refund, payment, suspend) — yang kalau miss bikin user kehilangan uang atau bingung permanen.
| # | Trigger | Penerima | Template | Wire point | Status |
|---|---|---|---|---|---|
| E2.1 | KYC `VERIFICATION_APPROVE` | User | `kyc_approved` | `reviewVerificationAction` | ✅ |
| E2.2 | KYC `VERIFICATION_REJECT` | User | `kyc_rejected` (sertakan reason) | `reviewVerificationAction` | ✅ |
| E2.3 | KYC re-upload request | User | `kyc_reupload_request` (fields + note) | `requestReuploadAction` | ✅ |
| E2.4 | Refund created (admin atau auto-trigger) | User | `refund_created` (amount + reason) | `createRefundAction` + `tripService.closeTrip` (loop semua peserta PAID) | ✅ |
| E2.5 | Refund SUCCEEDED | User | `refund_succeeded` (amount, cek rekening) | `decideRefundAction` (decision=SUCCEEDED) | ✅ |
| E2.6 | Refund FAILED | User | `refund_failed` (alasan + langkah next) | `decideRefundAction` (decision=FAILED) | ✅ |
| E2.7 | Midtrans webhook PAID | User | `payment_paid` (terima kasih + detail booking) | `paymentService.applyGatewayStatus` (di branch PAID success) | ✅ |
| E2.8 | Booking di-approve organizer (status → AWAITING_PAY) | User | `booking_approved` (link bayar + deadline) | `confirmParticipantAction` | ✅ |
| E2.9 | Account suspend | User | `account_suspended` (reason + email support) | `suspendUserAction` | ✅ |
> ️ **E2.4** — jalur admin (`createRefundAction`) kirim `refund_created`. Untuk auto-refund saat trip dibatalkan, peserta dikabari lewat email `trip_cancelled_organizer` / `trip_cancelled_admin` (E3.4/E3.5) yang sudah memuat blok nominal refund — satu email konsolidasi, bukan dua.
**Format idempotencyKey:**
- `kyc_approved-<verificationId>`
- `refund_succeeded-<refundId>`
- `payment_paid-<paymentId>`
- `booking_approved-<bookingId>`
- `account_suspended-<userId>-<suspendedAt>` (allow re-suspend kalau diulang)
**Tindakan manual ops:**
1. Test setiap template di staging — render via Resend "Send test" atau preview HTML lokal.
2. Pastikan `EMAIL_FROM` domain match SPF/DKIM supaya tidak masuk spam.
---
## PR-E3 — Phase 2: UX enhancement (post-MVP) ✅
Email yang berguna tapi tidak critical kalau miss.
| # | Trigger | Penerima | Template | Wire point | Status |
|---|---|---|---|---|---|
| E3.1 | User join trip (PENDING) | Organizer | `join_request` (siapa, link approve) | `joinTripAction` | ✅ |
| E3.2 | Organizer reject join | User | `join_rejected` (singkat) | `rejectParticipantAction` | ✅ |
| E3.3 | Payment EXPIRED / FAILED | User | `payment_expired` (suruh start ulang) | `paymentService.applyGatewayStatus` | ✅ |
| E3.4 | Trip CLOSED (organizer cancel) | Semua peserta aktif | `trip_cancelled_organizer` (batch) | `tripService.closeTrip` (organizer actor) | ✅ |
| E3.5 | Trip CLOSED (admin force-cancel) | Semua peserta + organizer | `trip_cancelled_admin` (reason) | `tripService.closeTrip` (admin actor) | ✅ |
| E3.6 | Payout RELEASED | Organizer | `payout_released` (ETA transfer dari admin) | `payoutService.releaseEligible` | ✅ |
| E3.7 | Payout PAID | Organizer | `payout_paid` (cek rekening + adminNote) | `markPayoutPaidAction` | ✅ |
| E3.8 | Account unsuspended | User | `account_unsuspended` | `unsuspendUserAction` | ✅ |
| E3.9 | KYC submitted | User | `kyc_submitted` (ETA review) | `submitVerificationAction` | ✅ |
| E3.10 | KYC manual override | User | `kyc_manual_override` (admin verified) | `manualOverrideVerificationAction` | ✅ |
| E3.11 | KYC reopened (admin) | User | `kyc_reopened` (boleh submit ulang) | `reopenVerificationAction` | ✅ |
**Wire point email:** semua dikirim `void emailService.send(...)` (fire-and-forget, idempotent). Untuk batch trip-cancelled, `closeTrip` mengembalikan daftar penerima + nominal refund; email dikirim oleh action setelah transaksi commit (bukan di dalam tx).
---
## PR-E4 — Phase 3: Marketing / reminder (post-MVP, opt-in) ⏳
Email engagement — perlu user preference + unsubscribe link.
| # | Trigger | Penerima | Template | Wire point |
|---|---|---|---|---|
| E4.1 | Welcome email saat signup | User | `welcome` | NextAuth `events.signIn` first time |
| E4.2 | Reminder H-3 keberangkatan | User | `trip_reminder_h3` (meeting point + itinerary) | Cron daily |
| E4.3 | Reminder H-1 keberangkatan | User | `trip_reminder_h1` | Cron daily |
| E4.4 | Trip selesai → minta review (H+1) | User CONFIRMED | `review_prompt` | Cron daily |
| E4.5 | Review baru diterima | Organizer | `new_review` | `createReviewAction` |
| E4.6 | Trip jadi FULL | Organizer | `trip_full` | `tripService.joinTrip` (saat FULL transition) |
**Prerequisite:** tabel `UserEmailPreference` dengan kategori `marketing` / `reminders` + unsubscribe token. Skip sampai Phase 4.
---
## PR-E5 — Admin UI: email log + queue retry (post-MVP) ✅
Visibilitas admin untuk troubleshoot "kenapa user X tidak dapat email?".
| # | Item | Status | File |
|---|---|---|---|
| E5.1 | Page `/admin/emails` — list `EmailSent` + `EmailJob`, filter recipient + template, status lewat tab | ✅ | `app/admin/emails/page.tsx` |
| E5.2 | Tombol "Kirim ulang" untuk EmailJob gagal/antri — retry sync langsung | ✅ | `features/email/components/email-row-actions.tsx` |
| E5.3 | Tombol "Resend" untuk EmailSent — key turunan `#resend-<ts>`, butuh `EmailSent.html` | ✅ | `features/email/actions.ts` |
| E5.4 | Stats card di `/admin/system` + `/admin/emails`: antri, gagal 24 jam, perlu aksi manual | ✅ | `app/admin/system/page.tsx` |
**Tindakan manual ops:**
1. Run migration `20260520000000_add_email_sent_html` (kolom `EmailSent.html`) di staging → production. Tanpa ini, resend (E5.3) tidak tersedia untuk email yang dikirim sebelum migration.
2. Tambahkan `/admin/emails` ke admin nav — sudah dilakukan di `components/admin/admin-sidebar.tsx`.
> ️ Deviasi minor dari rencana awal: filter tanggal tidak diimplementasikan (list dibatasi 100 baris terbaru); filter status diwujudkan sebagai tab (Gagal / Antrian / Terkirim).
---
## Skip / never (eksplisit)
- ❌ SMS / WhatsApp — beda regulatory, beda cost. Stick to email.
- ❌ Push notification (browser/mobile) — perlu PWA setup terpisah.
- ❌ In-app inbox — komplexitas tinggi, low ROI di MVP. Email cukup.
+26 -1
View File
@@ -94,7 +94,7 @@ Alur ini menggambarkan satu peserta dari pertama kali mendaftar sampai pembayara
### 5. Ringkasan peran data ### 5. Ringkasan peran data
| Konsep | Penyimpanan | | Konsep | Penyimpanan |
|--------|-------------| | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Trip | `Trip`: judul, gunung, lokasi, tanggal, kuota, harga, status trip (`OPEN` / `FULL` / …), meeting point, itinerary, termasuk/tidak termasuk, relasi ke organizer | | Trip | `Trip`: judul, gunung, lokasi, tanggal, kuota, harga, status trip (`OPEN` / `FULL` / …), meeting point, itinerary, termasuk/tidak termasuk, relasi ke organizer |
| Peserta | `TripParticipant` unik per `(tripId, userId)`: status **`PENDING`** / **`CONFIRMED`** / **`CANCELLED`**, serta **`markedPaidAt`** & **`paymentConfirmedAt`** untuk alur bayar manual | | Peserta | `TripParticipant` unik per `(tripId, userId)`: status **`PENDING`** / **`CONFIRMED`** / **`CANCELLED`**, serta **`markedPaidAt`** & **`paymentConfirmedAt`** untuk alur bayar manual |
| Organizer (kepercayaan) | `OrganizerVerification` (1-1 ke `User`) berisi KTP, foto liveness (memegang kertas "SETRIP"), rekening, dan status (`PENDING` / `APPROVED` / `REJECTED`); badge **Verified Organizer** muncul ketika `status === "APPROVED"` (helper `lib/trust.ts → isVerifiedOrganizer`). Agregat rating & jumlah trip dihitung dari ulasan & trip. | | Organizer (kepercayaan) | `OrganizerVerification` (1-1 ke `User`) berisi KTP, foto liveness (memegang kertas "SETRIP"), rekening, dan status (`PENDING` / `APPROVED` / `REJECTED`); badge **Verified Organizer** muncul ketika `status === "APPROVED"` (helper `lib/trust.ts → isVerifiedOrganizer`). Agregat rating & jumlah trip dihitung dari ulasan & trip. |
@@ -128,3 +128,28 @@ Buka [http://localhost:3000](http://localhost:3000).
- [Next.js Documentation](https://nextjs.org/docs) - [Next.js Documentation](https://nextjs.org/docs)
- [Prisma Documentation](https://www.prisma.io/docs) - [Prisma Documentation](https://www.prisma.io/docs)
# 1. Install SEMUA dep (termasuk dev) — deterministik dari lockfile.
# --include=dev memaksa dev terpasang walau NODE_ENV=production ter-export.
npm ci --include=dev
# 2. Prisma: generate client + apply migrasi
npx prisma generate
npx prisma migrate deploy
# 3. Build (butuh devDependencies)
npm run build
# 4. (Opsional) ramping-kan node_modules — buang dev SETELAH build selesai
npm prune --omit=dev
# 5. Jalankan
pm2 start ecosystem.config.js --env production
# atau restart: pm2 restart setrip --update-env
+22 -20
View File
@@ -4,6 +4,8 @@ Status implementasi sistem refund yang dapat dipercaya dan auditable — dari sc
> **Prinsip:** refund = financial event, bukan flag boolean. Setiap rupiah yang keluar harus auditable, idempotent, dan punya state machine eksplisit. Untuk MVP: tetap simple, tapi anticipate partial refund + async gateway dari hari pertama. > **Prinsip:** refund = financial event, bukan flag boolean. Setiap rupiah yang keluar harus auditable, idempotent, dan punya state machine eksplisit. Untuk MVP: tetap simple, tapi anticipate partial refund + async gateway dari hari pertama.
**Progress (per 2026-05-20):** PR-R1, R2, R3 ✅ — MVP refund (schema + service, organizer-cancel auto-refund, self-service user cancel) selesai. PR-R4 / R5 / R6 ⏳ post-MVP belum dikerjakan.
--- ---
## Audit state sekarang (baseline) ## Audit state sekarang (baseline)
@@ -34,7 +36,7 @@ File baseline: [prisma/schema.prisma](prisma/schema.prisma), [server/services/bo
--- ---
## PR-R1 — Refund Schema + Service Stub (foundation) ## PR-R1 — Refund Schema + Service Stub (foundation)
Foundation. Bikin entity `Refund` + service stub yang cuma create record `PENDING` (belum panggil gateway). UI admin minimal: list + manual mark succeeded. Tanpa ini, semua PR berikutnya tidak bisa jalan. Foundation. Bikin entity `Refund` + service stub yang cuma create record `PENDING` (belum panggil gateway). UI admin minimal: list + manual mark succeeded. Tanpa ini, semua PR berikutnya tidak bisa jalan.
@@ -48,14 +50,14 @@ Foundation. Bikin entity `Refund` + service stub yang cuma create record `PENDIN
| # | Item | Status | File | | # | Item | Status | File |
|---|---|---|---| |---|---|---|---|
| R1.1 | Model `Refund` + enum `RefundReason`, `RefundStatus`, `RefundInitiator` | | [prisma/schema.prisma](prisma/schema.prisma) | | R1.1 | Model `Refund` + enum `RefundReason`, `RefundStatus`, `RefundInitiator` | | [prisma/schema.prisma](prisma/schema.prisma) |
| R1.2 | Tambah `BookingStatus.PARTIALLY_REFUNDED` | | [prisma/schema.prisma](prisma/schema.prisma) | | R1.2 | Tambah `BookingStatus.PARTIALLY_REFUNDED` | | [prisma/schema.prisma](prisma/schema.prisma) |
| R1.3 | Migration `add_refund_model` | | `prisma/migrations/` | | R1.3 | Migration `add_refund_model` | | `prisma/migrations/` |
| R1.4 | `refundRepo` (create, findById, findByBooking, listPending, updateStatus) | | `server/repositories/refund.repo.ts` | | R1.4 | `refundRepo` (create, findById, findByBooking, listPending, updateStatus) | | `server/repositories/refund.repo.ts` |
| R1.5 | `refundService.requestRefund(input)` — create Refund PENDING + validasi `amount <= payment.amount - totalRefunded` | | `server/services/refund.service.ts` | | R1.5 | `refundService.requestRefund(input)` — create Refund PENDING + validasi `amount <= payment.amount - totalRefunded` | | `server/services/refund.service.ts` |
| R1.6 | `refundService.approveRefund(refundId, adminId)` — PENDING → APPROVED | | `server/services/refund.service.ts` | | R1.6 | `refundService.approveRefund(refundId, adminId)` — PENDING → APPROVED | | `server/services/refund.service.ts` |
| R1.7 | `refundService.markSucceededManual(refundId, adminId)` — APPROVED → SUCCEEDED, update Booking/Payment status. Untuk MVP refund manual transfer (non-Midtrans channel). | | `server/services/refund.service.ts` | | R1.7 | `refundService.markSucceededManual(refundId, adminId)` — APPROVED → SUCCEEDED, update Booking/Payment status. Untuk MVP refund manual transfer (non-Midtrans channel). | | `server/services/refund.service.ts` |
| R1.8 | UI admin `/admin/refunds` — list PENDING + tombol approve & mark-succeeded | | `app/admin/refunds/page.tsx` | | R1.8 | UI admin `/admin/refunds` — list PENDING + tombol approve & mark-succeeded | | `app/admin/refunds/page.tsx` |
**Tindakan manual:** **Tindakan manual:**
1. Run migration di staging → smoke test → run di production. 1. Run migration di staging → smoke test → run di production.
@@ -63,7 +65,7 @@ Foundation. Bikin entity `Refund` + service stub yang cuma create record `PENDIN
--- ---
## PR-R2 — Auto-Trigger Refund saat Trip CLOSED (organizer cancel) ## PR-R2 — Auto-Trigger Refund saat Trip CLOSED (organizer cancel)
Saat organizer batalkan trip (`status = CLOSED`), semua peserta dengan `Booking.status = PAID` otomatis di-create `Refund` record dengan `reason = ORGANIZER_CANCELLED`, `initiatedBy = SYSTEM`, full amount. Saat organizer batalkan trip (`status = CLOSED`), semua peserta dengan `Booking.status = PAID` otomatis di-create `Refund` record dengan `reason = ORGANIZER_CANCELLED`, `initiatedBy = SYSTEM`, full amount.
@@ -75,16 +77,16 @@ Saat organizer batalkan trip (`status = CLOSED`), semua peserta dengan `Booking.
| # | Item | Status | File | | # | Item | Status | File |
|---|---|---|---| |---|---|---|---|
| R2.1 | `tripService.closeTrip(tripId, organizerId)` — set status CLOSED + auto-create refunds | | [server/services/trip.service.ts](server/services/trip.service.ts) | | R2.1 | `tripService.closeTrip(tripId, organizerId)` — set status CLOSED + auto-create refunds | | [server/services/trip.service.ts](server/services/trip.service.ts) |
| R2.2 | Auto-approve untuk SYSTEM refund dengan reason ORGANIZER_CANCELLED | | `server/services/refund.service.ts` | | R2.2 | Auto-approve untuk SYSTEM refund dengan reason ORGANIZER_CANCELLED | | `server/services/refund.service.ts` |
| R2.3 | UI organizer: tombol "Batalkan trip" di dashboard organizer dengan confirmation modal | | `features/trip/components/cancel-trip-button.tsx` | | R2.3 | UI organizer: tombol "Batalkan trip" di dashboard organizer dengan confirmation modal | | `features/trip/components/cancel-trip-button.tsx` |
| R2.4 | Server action `cancelTripAction` | | `features/trip/actions.ts` | | R2.4 | Server action `cancelTripAction` | | `features/trip/actions.ts` |
**Tindakan manual:** tidak ada. **Tindakan manual:** tidak ada.
--- ---
## PR-R3 — Self-Service User Cancel dengan Refund Window ## PR-R3 — Self-Service User Cancel dengan Refund Window
User bisa cancel booking sendiri, refund dihitung berdasarkan kebijakan default (hardcoded dulu, jangan polymorphic). User bisa cancel booking sendiri, refund dihitung berdasarkan kebijakan default (hardcoded dulu, jangan polymorphic).
@@ -100,11 +102,11 @@ User bisa cancel booking sendiri, refund dihitung berdasarkan kebijakan default
| # | Item | Status | File | | # | Item | Status | File |
|---|---|---|---| |---|---|---|---|
| R3.1 | `lib/refund-policy.ts``calculateRefundAmount(bookingAmount, daysUntilDeparture)` | | `lib/refund-policy.ts` | | R3.1 | `lib/refund-policy.ts``calculateRefundAmount(bookingAmount, daysUntilDeparture)` | | `lib/refund-policy.ts` |
| R3.2 | `refundService.requestUserCancellation(bookingId, userId)` — pakai policy + create Refund PENDING | | `server/services/refund.service.ts` | | R3.2 | `refundService.requestUserCancellation(bookingId, userId)` — pakai policy + create Refund PENDING | | `server/services/refund.service.ts` |
| R3.3 | UI user di trip detail: tombol "Cancel booking" + modal preview refund amount | | `features/booking/components/cancel-booking-button.tsx` | | R3.3 | UI user di trip detail: tombol "Cancel booking" + modal preview refund amount | | `features/booking/components/cancel-booking-button.tsx` |
| R3.4 | Server action `cancelBookingAction` | | `features/booking/actions.ts` | | R3.4 | Server action `cancelBookingAction` | | `features/booking/actions.ts` |
| R3.5 | Display refund policy di trip detail (di blok info atau accordion) — transparency | | [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/page.tsx) | | R3.5 | Display refund policy di trip detail (di blok info atau accordion) — transparency | | [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/page.tsx) |
**Tindakan manual:** **Tindakan manual:**
1. Tulis copy kebijakan refund untuk halaman Terms & Privacy. 1. Tulis copy kebijakan refund untuk halaman Terms & Privacy.
+198
View File
@@ -0,0 +1,198 @@
# 🎨 SeTrip — UI Style Guide
Panduan visual untuk membuat tampilan SeTrip terasa **natural, manusiawi, dan tidak "AI-generated"** — tanpa mengorbankan SEO.
> Prinsip utama: **clean, calm, earthy.** SeTrip itu social-companion platform ("pergi bareng, bukan sendiri"), bukan marketplace booking. UI harus terasa hangat & tenang, bukan ramai & promosi.
---
## 1. Filosofi Desain
| Hindari (kesan AI-generated) | Gunakan (kesan natural) |
| --- | --- |
| ❌ Gradient berlebihan | ✅ Background putih bersih / `neutral-50` |
| ❌ Neumorphism | ✅ Soft green / earthy tone |
| ❌ Glassmorphism ekstrem | ✅ Border tipis 1px + shadow lembut |
| ❌ Icon 3D / emoji sebagai UI icon | ✅ Stroke icon tipis (lucide-react) |
| ❌ Card mengambang dengan blur tebal | ✅ Simple rounded card, datar, jelas |
| ❌ Warna saturasi tinggi di mana-mana | ✅ 1 warna aksen, sisanya netral |
**Tiga kata kunci:** *bersih · tenang · jujur.* Kalau sebuah elemen terasa "ingin pamer", kemungkinan besar perlu disederhanakan.
---
## 2. Warna
Token warna sudah tersedia di [app/globals.css](app/globals.css) — **gunakan token, jangan hardcode hex.**
| Peran | Token | Catatan |
| --- | --- | --- |
| Aksi utama / brand | `primary-600` (#16A34A) | Hijau gunung — earthy, tidak neon |
| Hover aksi utama | `primary-700` | Hindari `primary-500` (terlalu terang) untuk hover tombol |
| Aksen sekunder | `secondary-600` (#0EA5E9) | Pakai hemat — info, link, badge vibe |
| Teks utama | `neutral-800` | |
| Teks sekunder | `neutral-500` | |
| Border | `neutral-200` | Selalu 1px |
| Background halaman | `neutral-50` | |
| Surface / card | `white` | |
### Aturan warna
- **Satu aksen per layar.** Hijau adalah bintangnya. Biru hanya bumbu.
- **Maksimal 1 area gradient per halaman**, dan harus halus (mis. hero). Sisanya warna solid.
- Surface = putih solid. Jangan pakai `bg-white/80 + backdrop-blur` untuk card biasa.
- Earthy tone tambahan diperbolehkan sebagai background section (`primary-50`, `amber-50`) tapi jangan dijadikan blok besar warna-warni.
---
## 3. Sistem Ikon — lucide-react
`lucide-react` sudah terpasang. **Stroke icon = wajah baru SeTrip.**
### Aturan ikon
- **Stroke icon, bukan filled.** Lucide default sudah stroke — jangan ganti `fill`.
- Ukuran konsisten: `16` (inline teks), `20` (tombol/list), `24` (header section).
- Ketebalan stroke seragam: `strokeWidth={1.75}` (default lucide `2` sedikit terlalu tebal untuk gaya clean ini).
- Warna ikut teks: `text-neutral-500` untuk netral, `text-primary-600` untuk aktif.
- **Jangan** beri ikon background bulat berwarna + emoji di dalamnya (pola lama). Cukup ikon polos, atau ikon di atas lingkaran `neutral-100` yang sangat soft bila perlu penekanan.
```tsx
import { Mountain } from "lucide-react";
// inline
<Mountain size={16} strokeWidth={1.75} className="text-neutral-500" />
// di tombol
<Plus size={20} strokeWidth={1.75} />
```
### Pemetaan ikon per fitur
| Fitur | Ikon lucide |
| --- | --- |
| Trip | `Mountain` |
| Group / peserta | `Users` |
| Organizer | `BadgeCheck` |
| Verified | `ShieldCheck` |
| Payment | `Wallet` |
| Meeting Point | `MapPinned` |
| Chat | `MessageCircle` |
| Review / rating | `Star` |
| Profil | `UserRound` |
Saran tambahan yang konsisten dengan set di atas:
| Konteks | Ikon lucide |
| --- | --- |
| Tanggal / jadwal | `CalendarDays` |
| Lokasi umum | `MapPin` |
| Buat trip (FAB & CTA) | `Plus` |
| Cari / filter | `Search`, `SlidersHorizontal` |
| Menu mobile | `Menu` / `X` |
| Kategori (jelajah) | `Compass` |
| Sedang ramai / populer | `Flame` atau `TrendingUp` |
| Harga | `Tag` |
> **Catatan emoji kategori:** `categoryMeta()` di [lib/activity-category.ts](lib/activity-category.ts) masih memakai emoji (🏔️🏕️🤿). Boleh dipertahankan **hanya** di konten data trip (terasa playful & manusiawi di tempat itu), tapi **elemen UI/chrome** (navbar, header section, tombol, badge status) harus pakai stroke icon.
---
## 4. Komponen
### Card
```
✅ rounded-2xl · border border-neutral-200 · bg-white
✅ hover: shadow lembut + translate-y-0.5 (sudah dipakai di TripCard — pertahankan)
❌ jangan: shadow tebal default, blur, gradient border
```
### Tombol
| Jenis | Style |
| --- | --- |
| Primer | `bg-primary-600 hover:bg-primary-700 text-white rounded-xl` |
| Sekunder | `border border-neutral-200 text-neutral-700 hover:bg-neutral-50` |
| Ghost | `text-neutral-600 hover:bg-neutral-100` |
- Shadow tombol seperlunya. `shadow-lg shadow-primary-600/25` boleh untuk **satu** CTA utama per layar, jangan semua tombol.
- `hover:scale-105` cukup untuk CTA hero saja — jangan di semua tombol (terasa "demo template").
- Sertakan ikon lucide bila memperjelas aksi (mis. `Plus` untuk "Buat Trip").
### Badge / pill
- `rounded-full`, teks kecil, warna soft (`primary-50`/`primary-700`).
- Status pakai warna semantik solid lembut, bukan transparan + blur.
### Header section
Pola lama: kotak berwarna + emoji. Pola baru:
```tsx
<div className="flex items-center gap-2.5">
<Compass size={20} strokeWidth={1.75} className="text-primary-600" />
<div>
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">Jelajah per Kategori</h2>
<p className="text-xs text-neutral-500">Hiking, diving, konser, sampai retreat</p>
</div>
</div>
```
---
## 5. Yang Perlu Dirombak di Codebase
Temuan konkret dari kode saat ini:
| Lokasi | Masalah | Aksi |
| --- | --- | --- |
| [app/(public)/page.tsx](app/(public)/page.tsx) | Header section pakai kotak warna + emoji (✨🔥🏔️🤝), badge hero pakai emoji 🤝 | Ganti ke stroke icon (`Compass`, `Flame`, `Mountain`, `Users`) |
| [app/(public)/page.tsx](app/(public)/page.tsx#L110) | Hero gradient 3 warna (`from-primary-900 via-neutral-900 to-secondary-900`) | Sederhanakan jadi overlay solid `neutral-900/80` atau gradient 2 warna halus |
| [app/(public)/page.tsx](app/(public)/page.tsx#L386) | FAB pakai teks `"+"` | Ganti `<Plus size={24} />` |
| [app/(public)/page.tsx](app/(public)/page.tsx#L153) | Stat "100% Seru" terasa filler/AI | Ganti metrik nyata (jumlah peserta, organizer terverifikasi) atau hapus |
| [components/shared/navbar.tsx](components/shared/navbar.tsx#L112) | Hamburger pakai inline SVG manual | Ganti `Menu` / `X` dari lucide |
| [components/shared/navbar.tsx](components/shared/navbar.tsx#L13) | `bg-white/90 backdrop-blur-md` | Boleh dipertahankan (tipis, wajar untuk sticky nav) — jangan ditebalkan |
| [features/trip/components/trip-card.tsx](features/trip/components/trip-card.tsx) | Avatar fallback & meta info bisa diperkuat dengan ikon stroke (`Users`, `CalendarDays`, `MapPin`) | Tambah ikon kecil di baris meta |
Prioritas: **homepage dulu** (paling sering dilihat & paling kuat kesan AI-nya), lalu navbar, lalu komponen kartu.
---
## 6. SEO — Wajib Dijaga
Perubahan visual **tidak boleh** menurunkan SEO. Aturan:
- **Ikon lucide = inline SVG**, ringan & tidak memblokir render. Aman untuk Core Web Vitals.
- **Ikon dekoratif** (hiasan di samping teks) harus `aria-hidden`. Lucide perlu di-set manual:
```tsx
<Mountain size={16} aria-hidden className="text-neutral-500" />
```
- **Ikon yang berdiri sendiri sebagai tombol** (mis. tombol menu) wajib punya label:
```tsx
<button aria-label="Buka menu"><Menu size={20} aria-hidden /></button>
```
- **Jangan ubah teks jadi gambar.** Heading, slogan, deskripsi harus tetap teks HTML.
- **Pertahankan hirarki heading:** satu `<h1>` per halaman, `<h2>` untuk section. Jangan turunkan jadi `<div>` saat merapikan visual.
- **Pertahankan metadata & JSON-LD** di [app/layout.tsx](app/layout.tsx) dan [app/(public)/page.tsx](app/(public)/page.tsx) — structured data, OpenGraph, canonical jangan disentuh saat refactor UI.
- **Komponen tetap Server Component** kalau memungkinkan. Jangan tambah `"use client"` cuma untuk render ikon — lucide jalan di server.
- **Gambar:** terus pakai `next/image` dengan `alt` deskriptif dan `priority` untuk LCP (cover hero & kartu pertama).
- **Kontras warna** minimal AA: stroke icon `neutral-500` di atas putih sudah memenuhi; jangan pakai `neutral-300` untuk ikon/teks penting.
---
## 7. Checklist Implementasi
- [ ] Ganti semua emoji di chrome UI (navbar, header section, tombol, FAB) → stroke icon lucide
- [ ] Standarkan `size` (16/20/24) & `strokeWidth={1.75}` di seluruh ikon
- [ ] Sederhanakan gradient hero homepage jadi maksimal 2 warna / overlay solid
- [ ] Ganti hamburger SVG manual di navbar → `Menu`/`X`
- [ ] Tinjau metrik "100% Seru" — ganti angka nyata atau hapus
- [ ] Pastikan ikon dekoratif `aria-hidden`, ikon-tombol punya `aria-label`
- [ ] Pastikan struktur heading `h1`/`h2` tetap utuh setelah refactor
- [ ] Jalankan Lighthouse — skor SEO & Accessibility tidak turun
- [ ] Verifikasi tidak ada `"use client"` baru yang ditambahkan hanya demi ikon
---
*Acuan token: [app/globals.css](app/globals.css) · Acuan brand: [lib/site.ts](lib/site.ts)*
+14 -6
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { Lock, Clock, CircleAlert } from "lucide-react";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { organizerService } from "@/server/services/organizer.service"; import { organizerService } from "@/server/services/organizer.service";
@@ -11,8 +12,13 @@ export default async function CreateTripPage() {
return ( return (
<div className="flex min-h-[calc(100vh-3.5rem)] items-center justify-center px-4"> <div className="flex min-h-[calc(100vh-3.5rem)] items-center justify-center px-4">
<div className="text-center"> <div className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary-50 text-3xl"> <div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary-50">
🔒 <Lock
size={28}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
</div> </div>
<p className="mb-4 text-neutral-500"> <p className="mb-4 text-neutral-500">
Kamu harus login untuk membuat trip. Kamu harus login untuk membuat trip.
@@ -57,8 +63,9 @@ function VerificationBanner({
if (status === "PENDING") { if (status === "PENDING") {
return ( return (
<div className="mb-5 rounded-2xl border border-amber-200 bg-amber-50 p-4 sm:p-5"> <div className="mb-5 rounded-2xl border border-amber-200 bg-amber-50 p-4 sm:p-5">
<p className="text-sm font-bold text-amber-800"> <p className="flex items-center gap-1.5 text-sm font-bold text-amber-800">
Verifikasi sedang diproses <Clock size={15} strokeWidth={2} aria-hidden />
Verifikasi sedang diproses
</p> </p>
<p className="mt-1 text-sm text-neutral-700"> <p className="mt-1 text-sm text-neutral-700">
Pengajuan verifikasi-mu masih ditinjau admin. Sementara menunggu, kamu Pengajuan verifikasi-mu masih ditinjau admin. Sementara menunggu, kamu
@@ -73,8 +80,9 @@ function VerificationBanner({
<div className="mb-5 rounded-2xl border border-amber-200 bg-amber-50 p-4 sm:p-5"> <div className="mb-5 rounded-2xl border border-amber-200 bg-amber-50 p-4 sm:p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex-1"> <div className="flex-1">
<p className="text-sm font-bold text-amber-800"> <p className="flex items-center gap-1.5 text-sm font-bold text-amber-800">
{isRejected ? "Verifikasi ditolak" : "Belum terverifikasi"} <CircleAlert size={15} strokeWidth={2} aria-hidden />
{isRejected ? "Verifikasi ditolak" : "Belum terverifikasi"}
</p> </p>
<p className="mt-1 text-sm text-neutral-700"> <p className="mt-1 text-sm text-neutral-700">
{isRejected {isRejected
+17
View File
@@ -0,0 +1,17 @@
/**
* Skeleton generik untuk route group `(public)` — fallback streaming bagi
* halaman yang tidak punya `loading.tsx` sendiri (beranda, profil, dll).
*/
export default function Loading() {
return (
<div className="mx-auto max-w-5xl px-4 py-10">
<div className="h-8 w-1/2 animate-pulse rounded-xl bg-neutral-200" />
<div className="mt-4 space-y-3">
<div className="h-4 w-full animate-pulse rounded bg-neutral-100" />
<div className="h-4 w-5/6 animate-pulse rounded bg-neutral-100" />
<div className="h-4 w-2/3 animate-pulse rounded bg-neutral-100" />
</div>
<div className="mt-8 h-64 animate-pulse rounded-2xl bg-neutral-100" />
</div>
);
}
+13 -1
View File
@@ -1,4 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Masuk", title: "Masuk",
@@ -8,6 +11,15 @@ export const metadata: Metadata = {
robots: { index: false, follow: true }, robots: { index: false, follow: true },
}; };
export default function LoginLayout({ children }: { children: React.ReactNode }) { export default async function LoginLayout({
children,
}: {
children: React.ReactNode;
}) {
// User yang sudah login tidak boleh mengakses halaman login lagi.
const session = await getServerSession(authOptions);
if (session?.user) {
redirect(session.user.isAdmin ? "/admin" : "/");
}
return children; return children;
} }
+10 -7
View File
@@ -38,13 +38,16 @@ function LoginForm() {
if (result?.error) { if (result?.error) {
setError(result.error); setError(result.error);
} else { } else {
const rawCallback = searchParams.get("callbackUrl"); const callbackPath = safeInternalPath(searchParams.get("callbackUrl"));
let next = safeInternalPath(rawCallback);
// Tanpa callbackUrl eksplisit, arahkan admin ke dashboard /admin.
if (!rawCallback) {
const session = await getSession(); const session = await getSession();
if (session?.user?.isAdmin) next = "/admin"; // 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();
} }
@@ -84,7 +87,7 @@ function LoginForm() {
</div> </div>
{/* Card */} {/* Card */}
<div className="rounded-2xl border border-white/10 bg-white/95 p-6 shadow-2xl backdrop-blur-sm"> <div className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-2xl">
{error && ( {error && (
<div className="mb-4 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600"> <div className="mb-4 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600">
{error} {error}
+95 -64
View File
@@ -8,6 +8,15 @@ import { profileRepo } from "@/server/repositories/profile.repo";
import { TripCard } from "@/features/trip/components/trip-card"; import { TripCard } from "@/features/trip/components/trip-card";
import { siteConfig, siteUrl, absoluteUrl } from "@/lib/site"; import { siteConfig, siteUrl, absoluteUrl } from "@/lib/site";
import { ACTIVITY_CATEGORIES, categoryMeta } from "@/lib/activity-category"; import { ACTIVITY_CATEGORIES, categoryMeta } from "@/lib/activity-category";
import {
Compass,
Flame,
Mountain,
Handshake,
Tent,
Plus,
type LucideIcon,
} from "lucide-react";
type OpenTrip = Awaited<ReturnType<typeof tripService.getOpenTrips>>[number]; type OpenTrip = Awaited<ReturnType<typeof tripService.getOpenTrips>>[number];
@@ -44,6 +53,9 @@ export default async function HomePage() {
const now = new Date(); const now = new Date();
const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
// Social proof: total orang yang sudah gabung di seluruh open trip.
const joinerCount = trips.reduce((sum, t) => sum + t._count.participants, 0);
const upcomingTrips = trips const upcomingTrips = trips
.filter((t) => new Date(t.date) <= nextWeek) .filter((t) => new Date(t.date) <= nextWeek)
.slice(0, 3); .slice(0, 3);
@@ -107,12 +119,17 @@ export default async function HomePage() {
className="object-cover opacity-10 brightness-150" className="object-cover opacity-10 brightness-150"
priority priority
/> />
<div className="absolute inset-0 bg-linear-to-br from-primary-900/85 via-neutral-900/75 to-secondary-900/80" /> <div className="absolute inset-0 bg-linear-to-br from-neutral-900/90 to-primary-900/80" />
<div className="relative mx-auto max-w-4xl px-4 pb-10 pt-8 text-center sm:pb-14 sm:pt-12 lg:pb-16 lg:pt-14"> <div className="relative mx-auto max-w-4xl px-4 pb-10 pt-8 text-center sm:pb-14 sm:pt-12 lg:pb-16 lg:pt-14">
{/* Brand badge */} {/* Brand badge */}
<div className="mb-4 inline-flex items-center gap-1.5 rounded-full border border-primary-400/30 bg-primary-600/20 px-3 py-1 sm:mb-6 sm:gap-2 sm:px-4 sm:py-1.5"> <div className="mb-4 inline-flex items-center gap-1.5 rounded-full border border-primary-400/30 bg-primary-600/20 px-3 py-1 sm:mb-6 sm:gap-2 sm:px-4 sm:py-1.5">
<span className="text-xs sm:text-sm">🤝</span> <Handshake
size={14}
strokeWidth={1.75}
aria-hidden
className="text-primary-300"
/>
<span className="text-xs font-medium text-primary-300 sm:text-sm"> <span className="text-xs font-medium text-primary-300 sm:text-sm">
Cari teman trip & aktivitas Cari teman trip & aktivitas
</span> </span>
@@ -150,8 +167,12 @@ export default async function HomePage() {
</div> </div>
<div className="h-8 w-px bg-neutral-700 sm:h-10" /> <div className="h-8 w-px bg-neutral-700 sm:h-10" />
<div> <div>
<p className="text-xl font-bold text-white sm:text-2xl">100%</p> <p className="text-xl font-bold text-white sm:text-2xl">
<p className="text-[11px] text-neutral-400 sm:text-xs">Seru</p> {joinerCount}
</p>
<p className="text-[11px] text-neutral-400 sm:text-xs">
Sudah Gabung
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -161,19 +182,11 @@ export default async function HomePage() {
<div className="mx-auto max-w-6xl px-4 py-6 space-y-8 sm:py-8 sm:space-y-10 lg:py-10 lg:space-y-12"> <div className="mx-auto max-w-6xl px-4 py-6 space-y-8 sm:py-8 sm:space-y-10 lg:py-10 lg:space-y-12">
{/* Jelajah per kategori */} {/* Jelajah per kategori */}
<section> <section>
<div className="mb-4 flex items-center gap-3 sm:mb-5"> <SectionHeading
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-secondary-100 text-base sm:h-9 sm:w-9 sm:text-lg"> icon={Compass}
title="Jelajah per Kategori"
</div> subtitle="Hiking, diving, konser, sampai retreat"
<div> />
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
Jelajah per Kategori
</h2>
<p className="text-[11px] text-neutral-500 sm:text-xs">
Hiking, diving, konser, sampai retreat
</p>
</div>
</div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{ACTIVITY_CATEGORIES.map((c) => { {ACTIVITY_CATEGORIES.map((c) => {
const m = categoryMeta(c); const m = categoryMeta(c);
@@ -194,19 +207,11 @@ export default async function HomePage() {
{/* Trip Terdekat */} {/* Trip Terdekat */}
{upcomingTrips.length > 0 && ( {upcomingTrips.length > 0 && (
<section> <section>
<div className="mb-4 flex items-center gap-3 sm:mb-5"> <SectionHeading
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-100 text-base sm:h-9 sm:w-9 sm:text-lg"> icon={Flame}
🔥 title="Trip Terdekat"
</div> subtitle="Berangkat dalam 7 hari ke depan"
<div> />
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
Trip Terdekat
</h2>
<p className="text-[11px] text-neutral-500 sm:text-xs">
Berangkat dalam 7 hari ke depan
</p>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{upcomingTrips.slice(0, 3).map((trip, i) => ( {upcomingTrips.slice(0, 3).map((trip, i) => (
<TripCard <TripCard
@@ -239,32 +244,29 @@ export default async function HomePage() {
{/* Open Trip */} {/* Open Trip */}
<section> <section>
<div className="mb-4 flex items-center justify-between sm:mb-5"> <SectionHeading
<div className="flex items-center gap-3"> icon={Mountain}
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-secondary-100 text-base sm:h-9 sm:w-9 sm:text-lg"> title="Open Trip"
🏔 subtitle="Pilih trip, ketemu teman baru"
</div> action={
<div>
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
Open Trip
</h2>
<p className="hidden text-xs text-neutral-500 sm:block">
Pilih trip, ketemu teman baru
</p>
</div>
</div>
<Link <Link
href="/trips" href="/trips"
className="rounded-lg bg-secondary-50 px-2.5 py-1 text-xs font-medium text-secondary-600 hover:bg-secondary-100 sm:px-3 sm:py-1.5 sm:text-sm" className="shrink-0 rounded-lg bg-secondary-50 px-2.5 py-1 text-xs font-medium text-secondary-600 hover:bg-secondary-100 sm:px-3 sm:py-1.5 sm:text-sm"
> >
Lihat semua Lihat semua
</Link> </Link>
</div> }
/>
{latestTrips.length === 0 ? ( {latestTrips.length === 0 ? (
<div className="rounded-2xl border-2 border-dashed border-neutral-200 bg-white p-8 text-center sm:p-14"> <div className="rounded-2xl border-2 border-dashed border-neutral-200 bg-white p-8 text-center sm:p-14">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 text-2xl sm:h-16 sm:w-16 sm:text-3xl"> <div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 sm:h-16 sm:w-16">
🏕 <Tent
size={26}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
</div> </div>
<p className="mb-1 text-base font-bold text-neutral-800 sm:text-lg"> <p className="mb-1 text-base font-bold text-neutral-800 sm:text-lg">
Belum ada trip tersedia Belum ada trip tersedia
@@ -312,19 +314,11 @@ export default async function HomePage() {
{/* Lagi Ramai — social proof, bukan price proof */} {/* Lagi Ramai — social proof, bukan price proof */}
{buzzingTrips.length > 0 && ( {buzzingTrips.length > 0 && (
<section> <section>
<div className="mb-4 flex items-center gap-3 sm:mb-5"> <SectionHeading
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-100 text-base sm:h-9 sm:w-9 sm:text-lg"> icon={Handshake}
🤝 title="Lagi Ramai"
</div> subtitle="Banyak yang sudah gabung — kamu nggak bakal jalan sendirian"
<div> />
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
Lagi Ramai
</h2>
<p className="text-[11px] text-neutral-500 sm:text-xs">
Banyak yang sudah gabung kamu nggak bakal jalan sendirian
</p>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{buzzingTrips.map((trip) => ( {buzzingTrips.map((trip) => (
<TripCard <TripCard
@@ -383,11 +377,48 @@ export default async function HomePage() {
{/* ========== FAB ========== */} {/* ========== FAB ========== */}
<Link <Link
href="/create-trip" href="/create-trip"
className="fixed bottom-4 right-4 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-primary-600 text-xl font-bold text-white shadow-xl shadow-primary-600/30 transition-all hover:scale-110 hover:bg-primary-500 active:scale-95 sm:bottom-6 sm:right-6 sm:h-14 sm:w-14 sm:text-2xl" className="fixed bottom-4 right-4 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-primary-600 text-white shadow-xl shadow-primary-600/30 transition-all hover:scale-110 hover:bg-primary-500 active:scale-95 sm:bottom-6 sm:right-6 sm:h-14 sm:w-14"
title="Buat Trip" aria-label="Buat Trip"
> >
+ <Plus size={24} strokeWidth={2} aria-hidden />
</Link> </Link>
</div> </div>
); );
} }
/** Heading section homepage — ikon stroke + judul, opsional aksi di kanan. */
function SectionHeading({
icon: Icon,
title,
subtitle,
action,
}: {
icon: LucideIcon;
title: string;
subtitle?: string;
action?: React.ReactNode;
}) {
return (
<div className="mb-4 flex items-center justify-between gap-3 sm:mb-5">
<div className="flex items-center gap-2.5">
<Icon
size={22}
strokeWidth={1.75}
aria-hidden
className="shrink-0 text-primary-600"
/>
<div>
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
{title}
</h2>
{subtitle && (
<p className="text-[11px] text-neutral-500 sm:text-xs">
{subtitle}
</p>
)}
</div>
</div>
{action}
</div>
);
}
+8 -2
View File
@@ -5,6 +5,7 @@ import { UserCard } from "@/features/profile/components/user-card";
import { PeopleFilter } from "@/features/profile/components/people-filter"; import { PeopleFilter } from "@/features/profile/components/people-filter";
import { isVibe, vibeLabel } from "@/lib/vibe"; import { isVibe, vibeLabel } from "@/lib/vibe";
import { siteConfig } from "@/lib/site"; import { siteConfig } from "@/lib/site";
import { Users } from "lucide-react";
interface PeoplePageProps { interface PeoplePageProps {
searchParams: Promise<{ searchParams: Promise<{
@@ -68,8 +69,13 @@ export default async function PeoplePage({ searchParams }: PeoplePageProps) {
{people.length === 0 ? ( {people.length === 0 ? (
<div className="rounded-2xl border-2 border-dashed border-neutral-200 bg-white p-8 text-center sm:p-14"> <div className="rounded-2xl border-2 border-dashed border-neutral-200 bg-white p-8 text-center sm:p-14">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 text-2xl sm:h-16 sm:w-16 sm:text-3xl"> <div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 sm:h-16 sm:w-16">
🔍 <Users
size={26}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
</div> </div>
<p className="mb-1 text-base font-bold text-neutral-800 sm:text-lg"> <p className="mb-1 text-base font-bold text-neutral-800 sm:text-lg">
{hasFilters {hasFilters
+18 -3
View File
@@ -1,12 +1,19 @@
import Link from "next/link"; import Link from "next/link";
import { ShieldCheck, CircleCheck } from "lucide-react";
export default function PrivacyPage() { export default function PrivacyPage() {
return ( return (
<div className="mx-auto max-w-3xl px-4 py-8 sm:py-12"> <div className="mx-auto max-w-3xl px-4 py-8 sm:py-12">
<article className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm sm:p-10"> <article className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm sm:p-10">
<header className="mb-8 border-b border-neutral-200 pb-6"> <header className="mb-8 border-b border-neutral-200 pb-6">
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl"> <h1 className="flex items-center gap-2 text-2xl font-bold text-neutral-900 sm:text-3xl">
🔒 Kebijakan Privasi SeTrip <ShieldCheck
size={28}
strokeWidth={1.75}
aria-hidden
className="shrink-0 text-primary-600"
/>
Kebijakan Privasi SeTrip
</h1> </h1>
<p className="mt-2 text-sm text-neutral-500"> <p className="mt-2 text-sm text-neutral-500">
Terakhir diperbarui: 2026-04-27 Terakhir diperbarui: 2026-04-27
@@ -205,7 +212,15 @@ export default function PrivacyPage() {
</section> </section>
<section className="rounded-xl bg-neutral-50 p-5"> <section className="rounded-xl bg-neutral-50 p-5">
<h2 className="mb-2 text-lg font-bold text-neutral-900"> Persetujuan</h2> <h2 className="mb-2 flex items-center gap-1.5 text-lg font-bold text-neutral-900">
<CircleCheck
size={18}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
Persetujuan
</h2>
<p className="mb-2"> <p className="mb-2">
Dengan menggunakan SeTrip, Anda menyatakan bahwa: Dengan menggunakan SeTrip, Anda menyatakan bahwa:
</p> </p>
+8 -5
View File
@@ -10,6 +10,7 @@ import { TripCard } from "@/features/trip/components/trip-card";
import { ProfileTripRow } from "@/features/profile/components/profile-trip-row"; import { ProfileTripRow } from "@/features/profile/components/profile-trip-row";
import { ProfileEditor } from "@/features/profile/components/profile-editor"; import { ProfileEditor } from "@/features/profile/components/profile-editor";
import { EarningsSection } from "@/features/payout/components/earnings-section"; import { EarningsSection } from "@/features/payout/components/earnings-section";
import { Plus, ChevronRight } from "lucide-react";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Profil Saya", title: "Profil Saya",
@@ -81,9 +82,10 @@ export default async function ProfilePage() {
</div> </div>
<Link <Link
href="/create-trip" href="/create-trip"
className="shrink-0 rounded-xl bg-primary-600 px-4 py-2.5 text-center text-sm font-semibold text-white shadow-md hover:bg-primary-700" className="inline-flex shrink-0 items-center justify-center gap-1.5 rounded-xl bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white shadow-md hover:bg-primary-700"
> >
+ Buat trip <Plus size={16} strokeWidth={2} aria-hidden />
Buat trip
</Link> </Link>
</div> </div>
@@ -133,13 +135,14 @@ export default async function ProfilePage() {
endDate={t.endDate} endDate={t.endDate}
rightSlot={ rightSlot={
<span <span
className={ className={`inline-flex items-center gap-0.5 ${
hasReview hasReview
? "text-secondary-700" ? "text-secondary-700"
: "font-bold text-amber-800" : "font-bold text-amber-800"
} }`}
> >
{hasReview ? "Ubah ulasan" : "Beri ulasan"} {hasReview ? "Ubah ulasan" : "Beri ulasan"}
<ChevronRight size={14} strokeWidth={2} aria-hidden />
</span> </span>
} }
/> />
+13 -1
View File
@@ -1,4 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Daftar Akun", title: "Daftar Akun",
@@ -7,6 +10,15 @@ export const metadata: Metadata = {
alternates: { canonical: "/register" }, alternates: { canonical: "/register" },
}; };
export default function RegisterLayout({ children }: { children: React.ReactNode }) { export default async function RegisterLayout({
children,
}: {
children: React.ReactNode;
}) {
// User yang sudah login tidak boleh mengakses halaman daftar lagi.
const session = await getServerSession(authOptions);
if (session?.user) {
redirect(session.user.isAdmin ? "/admin" : "/");
}
return children; return children;
} }
+1 -1
View File
@@ -77,7 +77,7 @@ export default function RegisterPage() {
</div> </div>
{/* Card */} {/* Card */}
<div className="rounded-2xl border border-white/10 bg-white/95 p-6 shadow-2xl backdrop-blur-sm"> <div className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-2xl">
{error && ( {error && (
<div className="mb-4 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600"> <div className="mb-4 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600">
{error} {error}
+18 -3
View File
@@ -1,12 +1,19 @@
import Link from "next/link"; import Link from "next/link";
import { FileText, CircleCheck } from "lucide-react";
export default function TermsPage() { export default function TermsPage() {
return ( return (
<div className="mx-auto max-w-3xl px-4 py-8 sm:py-12"> <div className="mx-auto max-w-3xl px-4 py-8 sm:py-12">
<article className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm sm:p-10"> <article className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm sm:p-10">
<header className="mb-8 border-b border-neutral-200 pb-6"> <header className="mb-8 border-b border-neutral-200 pb-6">
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl"> <h1 className="flex items-center gap-2 text-2xl font-bold text-neutral-900 sm:text-3xl">
📜 Syarat &amp; Ketentuan SeTrip <FileText
size={28}
strokeWidth={1.75}
aria-hidden
className="shrink-0 text-primary-600"
/>
Syarat &amp; Ketentuan SeTrip
</h1> </h1>
<p className="mt-2 text-sm text-neutral-500"> <p className="mt-2 text-sm text-neutral-500">
Terakhir diperbarui: 2026-04-27 Terakhir diperbarui: 2026-04-27
@@ -262,7 +269,15 @@ export default function TermsPage() {
</section> </section>
<section className="rounded-xl bg-neutral-50 p-5"> <section className="rounded-xl bg-neutral-50 p-5">
<h2 className="mb-2 text-lg font-bold text-neutral-900"> Persetujuan</h2> <h2 className="mb-2 flex items-center gap-1.5 text-lg font-bold text-neutral-900">
<CircleCheck
size={18}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
Persetujuan
</h2>
<p className="mb-2"> <p className="mb-2">
Dengan menggunakan SeTrip, Anda menyatakan bahwa: Dengan menggunakan SeTrip, Anda menyatakan bahwa:
</p> </p>
+31
View File
@@ -0,0 +1,31 @@
/** Skeleton halaman detail trip — tampil instan saat data masih di-fetch. */
export default function Loading() {
return (
<div className="mx-auto max-w-3xl px-4 py-4 sm:py-8">
<div className="mb-3 h-4 w-40 animate-pulse rounded bg-neutral-200 sm:mb-4" />
<div className="overflow-hidden rounded-2xl border border-neutral-200 bg-white shadow-sm">
<div className="h-44 animate-pulse bg-neutral-200 sm:h-56 lg:h-72" />
<div className="border-b border-neutral-100 px-4 py-4 sm:px-6">
<div className="h-6 w-2/3 animate-pulse rounded bg-neutral-200" />
<div className="mt-2 h-4 w-1/3 animate-pulse rounded bg-neutral-100" />
</div>
<div className="space-y-5 p-4 sm:space-y-6 sm:p-6">
<div className="grid grid-cols-2 gap-2 sm:gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="h-16 animate-pulse rounded-xl bg-neutral-100 sm:h-[72px]"
/>
))}
</div>
<div className="h-24 animate-pulse rounded-xl bg-neutral-100" />
<div className="h-32 animate-pulse rounded-xl bg-neutral-100" />
<div className="h-12 animate-pulse rounded-xl bg-neutral-200" />
</div>
</div>
</div>
);
}
+10 -2
View File
@@ -2,7 +2,7 @@ import { ImageResponse } from "next/og";
import { tripService } from "@/server/services/trip.service"; import { tripService } from "@/server/services/trip.service";
import { formatRupiah } from "@/lib/utils"; import { formatRupiah } from "@/lib/utils";
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates"; import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
import { siteConfig } from "@/lib/site"; import { siteConfig, siteUrl } from "@/lib/site";
export const alt = `${siteConfig.name} — Open Trip & Aktivitas Bareng`; export const alt = `${siteConfig.name} — Open Trip & Aktivitas Bareng`;
export const size = { width: 1200, height: 630 }; export const size = { width: 1200, height: 630 };
@@ -43,7 +43,15 @@ export default async function TripOgImage({
); );
} }
const cover = trip.images[0]?.url; // Satori (ImageResponse) mem-fetch gambar server-side dan butuh URL absolut.
// Foto trip baru disimpan sebagai path relatif `/api/trip-images/...` —
// prefix dengan origin. Foto lama (URL eksternal absolut) dipakai apa adanya.
const coverRaw = trip.images[0]?.url;
const cover = coverRaw
? coverRaw.startsWith("http")
? coverRaw
: `${siteUrl}${coverRaw}`
: undefined;
const dateLabel = formatTripCalendarDateRangeLong(trip.date, trip.endDate); const dateLabel = formatTripCalendarDateRangeLong(trip.date, trip.endDate);
const price = formatRupiah(trip.price); const price = formatRupiah(trip.price);
+56 -15
View File
@@ -28,6 +28,14 @@ import {
isTripDepartureDayPast, isTripDepartureDayPast,
} from "@/lib/trip-dates"; } from "@/lib/trip-dates";
import { previewRefund } from "@/lib/refund-policy"; import { previewRefund } from "@/lib/refund-policy";
import {
MapPin,
CalendarDays,
Wallet,
UserRound,
Zap,
Users,
} from "lucide-react";
export async function generateMetadata({ export async function generateMetadata({
params, params,
@@ -309,8 +317,13 @@ export default async function TripDetailPage({
{/* Info Grid */} {/* Info Grid */}
<div className="grid grid-cols-2 gap-2 sm:gap-3"> <div className="grid grid-cols-2 gap-2 sm:gap-3">
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4"> <div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-secondary-100 text-sm sm:h-10 sm:w-10 sm:text-lg"> <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-secondary-100 sm:h-10 sm:w-10">
📍 <MapPin
size={18}
strokeWidth={1.75}
aria-hidden
className="text-secondary-700"
/>
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Lokasi</p> <p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Lokasi</p>
@@ -319,8 +332,13 @@ export default async function TripDetailPage({
</div> </div>
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4"> <div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-secondary-100 text-sm sm:h-10 sm:w-10 sm:text-lg"> <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-secondary-100 sm:h-10 sm:w-10">
📅 <CalendarDays
size={18}
strokeWidth={1.75}
aria-hidden
className="text-secondary-700"
/>
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Tanggal</p> <p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Tanggal</p>
@@ -331,8 +349,13 @@ export default async function TripDetailPage({
</div> </div>
<div className="flex items-center gap-2 rounded-xl bg-primary-50 p-3 sm:gap-3 sm:p-4"> <div className="flex items-center gap-2 rounded-xl bg-primary-50 p-3 sm:gap-3 sm:p-4">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary-100 text-sm sm:h-10 sm:w-10 sm:text-lg"> <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary-100 sm:h-10 sm:w-10">
💰 <Wallet
size={18}
strokeWidth={1.75}
aria-hidden
className="text-primary-700"
/>
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-[10px] font-medium text-primary-600 sm:text-xs">Harga</p> <p className="text-[10px] font-medium text-primary-600 sm:text-xs">Harga</p>
@@ -343,8 +366,13 @@ export default async function TripDetailPage({
</div> </div>
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4"> <div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-200 text-sm sm:h-10 sm:w-10 sm:text-lg"> <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-200 sm:h-10 sm:w-10">
👤 <UserRound
size={18}
strokeWidth={1.75}
aria-hidden
className="text-neutral-600"
/>
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Organizer</p> <p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Organizer</p>
@@ -372,8 +400,9 @@ export default async function TripDetailPage({
Peserta Peserta
</span> </span>
{spotsLeft > 0 && spotsLeft <= 3 && ( {spotsLeft > 0 && spotsLeft <= 3 && (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-bold text-amber-800 sm:text-[11px]"> <span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-bold text-amber-800 sm:text-[11px]">
Tinggal {spotsLeft} spot! <Zap size={11} strokeWidth={2} aria-hidden />
Tinggal {spotsLeft} spot!
</span> </span>
)} )}
{spotsLeft <= 0 && ( {spotsLeft <= 0 && (
@@ -418,8 +447,14 @@ export default async function TripDetailPage({
)} )}
</p> </p>
{confirmedCount > 0 && ( {confirmedCount > 0 && (
<p className="mt-2 text-[11px] text-neutral-600 sm:text-xs"> <p className="mt-2 flex flex-wrap items-center gap-x-1 gap-y-0.5 text-[11px] text-neutral-600 sm:text-xs">
<span aria-hidden>👥</span> Sudah join:{" "} <Users
size={13}
strokeWidth={1.75}
aria-hidden
className="text-neutral-400"
/>
Sudah join:{" "}
<span className="font-medium text-neutral-800"> <span className="font-medium text-neutral-800">
{confirmedParticipants {confirmedParticipants
.slice(0, 3) .slice(0, 3)
@@ -547,7 +582,7 @@ export default async function TripDetailPage({
Belum ada peserta yang dikonfirmasi.{" "} Belum ada peserta yang dikonfirmasi.{" "}
{pendingParticipants.length > 0 {pendingParticipants.length > 0
? "Cek permintaan join di atas untuk menyetujui peserta." ? "Cek permintaan join di atas untuk menyetujui peserta."
: "Jadilah yang pertama mendaftar! 🎒"} : "Jadilah yang pertama mendaftar!"}
</p> </p>
) : ( ) : (
<ul className="grid gap-2 sm:grid-cols-2"> <ul className="grid gap-2 sm:grid-cols-2">
@@ -578,8 +613,14 @@ export default async function TripDetailPage({
{p.user.name} {p.user.name}
</p> </p>
{city && ( {city && (
<p className="truncate text-[11px] text-neutral-500 sm:text-xs"> <p className="flex items-center gap-1 truncate text-[11px] text-neutral-500 sm:text-xs">
📍 {city} <MapPin
size={11}
strokeWidth={1.75}
aria-hidden
className="shrink-0"
/>
{city}
</p> </p>
)} )}
{interests.length > 0 && ( {interests.length > 0 && (
+69 -16
View File
@@ -11,6 +11,15 @@ import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
import { isFreeTrip } from "@/lib/trip-pricing"; import { isFreeTrip } from "@/lib/trip-pricing";
import { categoryMeta } from "@/lib/activity-category"; import { categoryMeta } from "@/lib/activity-category";
import { MidtransPayButton } from "@/features/booking/components/midtrans-pay-button"; import { MidtransPayButton } from "@/features/booking/components/midtrans-pay-button";
import {
ArrowLeft,
CalendarDays,
MapPin,
PartyPopper,
CircleCheck,
Clock,
Check,
} from "lucide-react";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Detail Pembayaran", title: "Detail Pembayaran",
@@ -82,8 +91,15 @@ export default async function PaymentPage({ params, searchParams }: PageProps) {
<h1 className="mt-0.5 truncate text-base font-bold text-neutral-900 sm:text-lg"> <h1 className="mt-0.5 truncate text-base font-bold text-neutral-900 sm:text-lg">
{trip.title} {trip.title}
</h1> </h1>
<p className="mt-1 text-xs text-neutral-500 sm:text-sm"> <p className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-neutral-500 sm:text-sm">
📅 {dateRange} · 📍 {trip.location} <span className="inline-flex items-center gap-1">
<CalendarDays size={13} strokeWidth={1.75} aria-hidden />
{dateRange}
</span>
<span className="inline-flex items-center gap-1">
<MapPin size={13} strokeWidth={1.75} aria-hidden />
{trip.location}
</span>
</p> </p>
<p className="mt-1 text-xs text-neutral-500 sm:text-sm"> <p className="mt-1 text-xs text-neutral-500 sm:text-sm">
Organizer:{" "} Organizer:{" "}
@@ -102,8 +118,12 @@ export default async function PaymentPage({ params, searchParams }: PageProps) {
return ( return (
<div className="mx-auto max-w-2xl px-4 py-6 sm:py-10"> <div className="mx-auto max-w-2xl px-4 py-6 sm:py-10">
<div className="mb-4 flex items-center gap-2 text-xs text-neutral-500 sm:text-sm"> <div className="mb-4 flex items-center gap-2 text-xs text-neutral-500 sm:text-sm">
<Link href={`/trips/${trip.id}`} className="hover:text-primary-600"> <Link
Kembali ke trip href={`/trips/${trip.id}`}
className="inline-flex items-center gap-1 hover:text-primary-600"
>
<ArrowLeft size={14} strokeWidth={1.75} aria-hidden />
Kembali ke trip
</Link> </Link>
</div> </div>
@@ -170,8 +190,13 @@ function FreeTripSection({
}) { }) {
return ( return (
<section className="rounded-2xl border border-emerald-200 bg-emerald-50/60 p-6 text-center shadow-sm sm:p-8"> <section className="rounded-2xl border border-emerald-200 bg-emerald-50/60 p-6 text-center shadow-sm sm:p-8">
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-emerald-100 text-2xl"> <div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-emerald-100">
🎉 <PartyPopper
size={26}
strokeWidth={1.75}
aria-hidden
className="text-emerald-600"
/>
</div> </div>
<h2 className="mb-1 text-lg font-bold text-emerald-900 sm:text-xl"> <h2 className="mb-1 text-lg font-bold text-emerald-900 sm:text-xl">
Trip ini gratis Trip ini gratis
@@ -184,10 +209,28 @@ function FreeTripSection({
<p className="text-[11px] font-semibold uppercase tracking-wide text-emerald-700"> <p className="text-[11px] font-semibold uppercase tracking-wide text-emerald-700">
Status keikutsertaan Status keikutsertaan
</p> </p>
<p className="text-sm font-bold text-neutral-800"> <p className="flex items-center gap-1.5 text-sm font-bold text-neutral-800">
{bookingStatus === "PAID" {bookingStatus === "PAID" ? (
? "✅ Terkonfirmasi sebagai peserta" <>
: "⏳ Menunggu persetujuan organizer"} <CircleCheck
size={15}
strokeWidth={2}
aria-hidden
className="text-emerald-600"
/>
Terkonfirmasi sebagai peserta
</>
) : (
<>
<Clock
size={15}
strokeWidth={2}
aria-hidden
className="text-amber-600"
/>
Menunggu persetujuan organizer
</>
)}
</p> </p>
</div> </div>
@@ -250,10 +293,15 @@ function PaidTripSection({
{canPay && <MidtransPayButton tripId={tripId} />} {canPay && <MidtransPayButton tripId={tripId} />}
{isFullyPaid && ( {isFullyPaid && (
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900 sm:p-5"> <div className="flex items-start gap-2 rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900 sm:p-5">
<CircleCheck
size={16}
strokeWidth={2}
aria-hidden
className="mt-0.5 shrink-0 text-emerald-600"
/>
<p> <p>
Pembayaran kamu sudah terkonfirmasi. Sampai jumpa di trip Pembayaran kamu sudah terkonfirmasi. Sampai jumpa di trip bareng{" "}
bareng{" "}
<span className="font-semibold">{organizerName}</span>! <span className="font-semibold">{organizerName}</span>!
</p> </p>
</div> </div>
@@ -262,9 +310,10 @@ function PaidTripSection({
<div className="text-center"> <div className="text-center">
<Link <Link
href={`/trips/${tripId}`} href={`/trips/${tripId}`}
className="text-sm text-neutral-500 hover:text-primary-600" className="inline-flex items-center gap-1 text-sm text-neutral-500 hover:text-primary-600"
> >
Kembali ke detail trip <ArrowLeft size={14} strokeWidth={1.75} aria-hidden />
Kembali ke detail trip
</Link> </Link>
</div> </div>
</div> </div>
@@ -298,7 +347,11 @@ function PaymentTimeline({
: "bg-neutral-200 text-neutral-500" : "bg-neutral-200 text-neutral-500"
}`} }`}
> >
{s.done ? "✓" : i + 1} {s.done ? (
<Check size={12} strokeWidth={3} aria-hidden />
) : (
i + 1
)}
</span> </span>
<span <span
className={`text-sm ${ className={`text-sm ${
+29
View File
@@ -0,0 +1,29 @@
/** Skeleton daftar trip — tampil instan saat list masih di-fetch. */
export default function Loading() {
return (
<div className="mx-auto max-w-6xl px-4 py-6 sm:py-8">
<div className="mb-6 h-8 w-56 animate-pulse rounded-lg bg-neutral-200 sm:mb-8" />
<div className="mb-6 h-40 animate-pulse rounded-2xl bg-neutral-100" />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="overflow-hidden rounded-2xl border border-neutral-200 bg-white"
>
<div className="h-40 animate-pulse bg-neutral-200" />
<div className="space-y-3 p-4">
<div className="h-5 w-3/4 animate-pulse rounded bg-neutral-200" />
<div className="h-4 w-1/2 animate-pulse rounded bg-neutral-100" />
<div className="h-4 w-2/3 animate-pulse rounded bg-neutral-100" />
<div className="mt-3 flex items-center justify-between border-t border-neutral-100 pt-3">
<div className="h-5 w-20 animate-pulse rounded bg-neutral-200" />
<div className="h-4 w-16 animate-pulse rounded bg-neutral-100" />
</div>
</div>
</div>
))}
</div>
</div>
);
}
+24 -5
View File
@@ -11,6 +11,7 @@ import { siteConfig } from "@/lib/site";
import { categoryLabel, isActivityCategory } from "@/lib/activity-category"; import { categoryLabel, isActivityCategory } from "@/lib/activity-category";
import { isVibe } from "@/lib/vibe"; import { isVibe } from "@/lib/vibe";
import type { GroupSize } from "@/server/repositories/trip.repo"; import type { GroupSize } from "@/server/repositories/trip.repo";
import { Plus, Search, Tent } from "lucide-react";
const GROUP_SIZES: GroupSize[] = ["SMALL", "MEDIUM", "LARGE"]; const GROUP_SIZES: GroupSize[] = ["SMALL", "MEDIUM", "LARGE"];
function isGroupSize(value: unknown): value is GroupSize { function isGroupSize(value: unknown): value is GroupSize {
@@ -98,9 +99,10 @@ export default async function TripsPage({ searchParams }: TripsPageProps) {
</div> </div>
<Link <Link
href="/create-trip" href="/create-trip"
className="w-full rounded-xl bg-primary-600 px-4 py-2.5 text-center text-sm font-semibold text-white shadow-md shadow-primary-600/20 hover:bg-primary-700 sm:w-auto" className="inline-flex w-full items-center justify-center gap-1.5 rounded-xl bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white shadow-md shadow-primary-600/20 hover:bg-primary-700 sm:w-auto"
> >
+ Buat Trip <Plus size={16} strokeWidth={2} aria-hidden />
Buat Trip
</Link> </Link>
</div> </div>
@@ -113,8 +115,22 @@ export default async function TripsPage({ searchParams }: TripsPageProps) {
{trips.length === 0 ? ( {trips.length === 0 ? (
<div className="rounded-2xl border-2 border-dashed border-neutral-200 bg-white p-8 text-center sm:p-14"> <div className="rounded-2xl border-2 border-dashed border-neutral-200 bg-white p-8 text-center sm:p-14">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 text-2xl sm:h-16 sm:w-16 sm:text-3xl"> <div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 sm:h-16 sm:w-16">
{hasFilters ? "🔍" : "🏕️"} {hasFilters ? (
<Search
size={26}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
) : (
<Tent
size={26}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
)}
</div> </div>
<p className="mb-1 text-base font-bold text-neutral-800 sm:text-lg"> <p className="mb-1 text-base font-bold text-neutral-800 sm:text-lg">
{hasFilters {hasFilters
@@ -137,7 +153,7 @@ export default async function TripsPage({ searchParams }: TripsPageProps) {
</div> </div>
) : ( ) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{trips.map((trip) => ( {trips.map((trip, index) => (
<TripCard <TripCard
key={trip.id} key={trip.id}
id={trip.id} id={trip.id}
@@ -154,6 +170,9 @@ export default async function TripsPage({ searchParams }: TripsPageProps) {
organizerName={trip.organizer.name} organizerName={trip.organizer.name}
status={trip.status} status={trip.status}
coverImage={trip.images[0]?.url} coverImage={trip.images[0]?.url}
// Baris pertama (3 kartu) di atas fold — muat segera supaya
// tidak jadi LCP yang lambat.
priority={index < 3}
isVerifiedOrganizer={ isVerifiedOrganizer={
trip.organizer.organizerVerification?.status === "APPROVED" trip.organizer.organizerVerification?.status === "APPROVED"
} }
+8 -5
View File
@@ -11,6 +11,7 @@ import { OrganizerStatsPanel } from "@/features/profile/components/organizer-sta
import { OrganizerReviewsList } from "@/features/review/components/organizer-reviews-list"; import { OrganizerReviewsList } from "@/features/review/components/organizer-reviews-list";
import { siteConfig } from "@/lib/site"; import { siteConfig } from "@/lib/site";
import { vibeMeta } from "@/lib/vibe"; import { vibeMeta } from "@/lib/vibe";
import { BadgeCheck, MapPin, AtSign } from "lucide-react";
interface PageProps { interface PageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@@ -86,10 +87,11 @@ export default async function PublicProfilePage({ params }: PageProps) {
</h1> </h1>
{isVerifiedOrganizer && ( {isVerifiedOrganizer && (
<span <span
className="inline-flex items-center gap-0.5 rounded-full bg-primary-100 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-primary-800" className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-primary-800"
title="Identitas organizer telah diverifikasi (KTP & rekening)" title="Identitas organizer telah diverifikasi (KTP & rekening)"
> >
Verified Organizer <BadgeCheck size={12} strokeWidth={2} aria-hidden />
Verified Organizer
</span> </span>
)} )}
</div> </div>
@@ -97,7 +99,8 @@ export default async function PublicProfilePage({ params }: PageProps) {
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-neutral-500"> <div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-neutral-500">
{profile?.city && ( {profile?.city && (
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
📍 {profile.city} <MapPin size={13} strokeWidth={1.75} aria-hidden />
{profile.city}
</span> </span>
)} )}
<span className="text-xs">Bergabung sejak {memberSince}</span> <span className="text-xs">Bergabung sejak {memberSince}</span>
@@ -141,8 +144,8 @@ export default async function PublicProfilePage({ params }: PageProps) {
rel="noopener noreferrer nofollow" rel="noopener noreferrer nofollow"
className="mt-3 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700" className="mt-3 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700"
> >
<span>📸</span> <AtSign size={15} strokeWidth={1.75} aria-hidden />
<span>@{profile.instagram}</span> <span>{profile.instagram}</span>
</a> </a>
)} )}
</div> </div>
+70 -9
View File
@@ -1,11 +1,29 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { Clock, RefreshCw, CircleX, ArrowLeft } from "lucide-react";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { organizerService } from "@/server/services/organizer.service"; import { organizerService } from "@/server/services/organizer.service";
import { VerifyForm } from "@/features/organizer/components/verify-form"; import { VerifyForm } from "@/features/organizer/components/verify-form";
import { VerifiedBadge } from "@/components/shared/verified-badge"; import { VerifiedBadge } from "@/components/shared/verified-badge";
function reuploadFieldLabel(field: string): string {
switch (field) {
case "ktpImage":
return "Foto KTP";
case "liveness":
return "Foto liveness (pegang kertas SETRIP)";
case "nik":
return "NIK";
case "bankInfo":
return "Info rekening";
case "address":
return "Alamat";
default:
return field;
}
}
export default async function VerifyPage() { export default async function VerifyPage() {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session?.user) { if (!session?.user) {
@@ -53,10 +71,11 @@ export default async function VerifyPage() {
</div> </div>
)} )}
{verification?.status === "PENDING" && ( {verification?.status === "PENDING" && !verification.reuploadRequested && (
<div className="mb-6 rounded-2xl border border-amber-200 bg-amber-50 p-5"> <div className="mb-6 rounded-2xl border border-amber-200 bg-amber-50 p-5">
<p className="mb-1 text-sm font-bold text-amber-800"> <p className="mb-1 flex items-center gap-1.5 text-sm font-bold text-amber-800">
Menunggu review admin <Clock size={15} strokeWidth={2} aria-hidden />
Menunggu review admin
</p> </p>
<p className="text-sm text-neutral-700"> <p className="text-sm text-neutral-700">
Pengajuanmu sedang diproses. Kami akan memberitahu via email setelah selesai. Pengajuanmu sedang diproses. Kami akan memberitahu via email setelah selesai.
@@ -64,9 +83,47 @@ export default async function VerifyPage() {
</div> </div>
)} )}
{verification?.reuploadRequested && (
<div className="mb-6 rounded-2xl border-2 border-amber-400 bg-amber-50 p-5">
<p className="mb-1 flex items-center gap-1.5 text-sm font-bold text-amber-900">
<RefreshCw size={15} strokeWidth={2} aria-hidden />
Admin minta kamu upload ulang
</p>
{verification.reuploadNote && (
<p className="mb-3 text-sm text-neutral-700">
<span className="font-semibold">Catatan admin:</span>{" "}
{verification.reuploadNote}
</p>
)}
{verification.reuploadFields.length > 0 && (
<div className="mb-3">
<p className="mb-1 text-xs font-semibold text-amber-900">
Field yang perlu di-upload ulang:
</p>
<ul className="ml-4 list-disc text-xs text-neutral-700">
{verification.reuploadFields.map((f) => (
<li key={f}>
<span className="font-semibold">
{reuploadFieldLabel(f)}
</span>
</li>
))}
</ul>
</div>
)}
<p className="text-xs text-neutral-700">
Submit ulang form di bawah dengan data/foto yang sudah diperbaiki.
Setelah submit, banner ini hilang otomatis.
</p>
</div>
)}
{verification?.status === "REJECTED" && ( {verification?.status === "REJECTED" && (
<div className="mb-6 rounded-2xl border border-red-200 bg-red-50 p-5"> <div className="mb-6 rounded-2xl border border-red-200 bg-red-50 p-5">
<p className="mb-1 text-sm font-bold text-red-800"> Pengajuan ditolak</p> <p className="mb-1 flex items-center gap-1.5 text-sm font-bold text-red-800">
<CircleX size={15} strokeWidth={2} aria-hidden />
Pengajuan ditolak
</p>
{verification.rejectionReason && ( {verification.rejectionReason && (
<p className="text-sm text-neutral-700"> <p className="text-sm text-neutral-700">
<span className="font-semibold">Alasan:</span>{" "} <span className="font-semibold">Alasan:</span>{" "}
@@ -79,13 +136,17 @@ export default async function VerifyPage() {
</div> </div>
)} )}
{verification?.status !== "APPROVED" && verification?.status !== "PENDING" && ( {(verification?.status !== "APPROVED" &&
<VerifyForm initial={initial} /> (verification?.status !== "PENDING" ||
)} verification?.reuploadRequested)) && <VerifyForm initial={initial} />}
<p className="mt-6 text-center text-sm text-neutral-500"> <p className="mt-6 text-center text-sm text-neutral-500">
<Link href="/profile" className="hover:text-primary-600"> <Link
Kembali ke profil href="/profile"
className="inline-flex items-center gap-1 hover:text-primary-600"
>
<ArrowLeft size={14} strokeWidth={1.75} aria-hidden />
Kembali ke profil
</Link> </Link>
</p> </p>
</div> </div>
+243
View File
@@ -0,0 +1,243 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { isAdminEmail, listAdminEmails } from "@/lib/admin";
import { prisma } from "@/lib/prisma";
import type { Prisma } from "@/app/generated/prisma/client";
import { AdminFilterBar } from "@/features/admin/components/admin-filter-bar";
const ENTITY_TYPES = [
"Refund",
"Payout",
"Trip",
"User",
"OrganizerVerification",
"Payment",
] as const;
interface PageProps {
searchParams: Promise<{
entityType?: string;
action?: string;
reviewer?: string;
dateFrom?: string;
dateTo?: string;
}>;
}
function parseDate(value: string | undefined): Date | undefined {
if (!value) return undefined;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? undefined : d;
}
export default async function AdminAuditLogPage({ searchParams }: PageProps) {
const session = await getServerSession(authOptions);
if (!session?.user) redirect("/login?callbackUrl=/admin/audit-log");
if (!isAdminEmail(session.user.email)) {
return (
<div className="mx-auto max-w-2xl px-4 py-12 text-center">
<p className="text-sm text-neutral-600">
Halaman ini hanya untuk admin SeTrip.
</p>
</div>
);
}
const params = await searchParams;
const dateFrom = parseDate(params.dateFrom);
const dateTo = parseDate(params.dateTo);
const where: Prisma.AdminActionLogWhereInput = {};
if (params.entityType && ENTITY_TYPES.includes(params.entityType as never)) {
where.entityType = params.entityType;
}
if (params.action) {
where.action = { contains: params.action, mode: "insensitive" };
}
if (params.reviewer) {
where.adminEmail = params.reviewer;
}
if (dateFrom || dateTo) {
where.createdAt = {
...(dateFrom && { gte: dateFrom }),
...(dateTo && { lte: dateTo }),
};
}
const logs = await prisma.adminActionLog.findMany({
where,
orderBy: { createdAt: "desc" },
take: 200,
});
return (
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
<header className="mb-6">
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
Audit Log
</h1>
<p className="mt-1 text-sm text-neutral-500">
Catatan semua aksi admin lintas entity (refund, payout, trip cancel,
user suspend, dst). Append-only. Maksimal 200 baris terbaru per query
pakai filter untuk drill-down.
</p>
</header>
<AdminFilterBar
action="/admin/audit-log"
values={{
dateFrom: params.dateFrom,
dateTo: params.dateTo,
reviewer: params.reviewer,
}}
reviewerOptions={listAdminEmails()}
reviewerLabel="Admin"
/>
<form method="get" className="mb-4 grid gap-3 sm:grid-cols-2">
<input type="hidden" name="dateFrom" value={params.dateFrom ?? ""} />
<input type="hidden" name="dateTo" value={params.dateTo ?? ""} />
<input type="hidden" name="reviewer" value={params.reviewer ?? ""} />
<div>
<label
htmlFor="filter-entity"
className="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
>
Entity type
</label>
<select
id="filter-entity"
name="entityType"
defaultValue={params.entityType ?? ""}
className="w-full rounded-lg border border-neutral-200 bg-white px-2 py-1.5 text-sm text-neutral-800 focus:border-primary-400"
>
<option value="">Semua</option>
{ENTITY_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
<div>
<label
htmlFor="filter-action"
className="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
>
Action (contains)
</label>
<div className="flex gap-2">
<input
id="filter-action"
name="action"
defaultValue={params.action ?? ""}
placeholder="mis. REFUND, SUSPEND, CANCEL"
className="flex-1 rounded-lg border border-neutral-200 bg-white px-2 py-1.5 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-primary-400"
/>
<button
type="submit"
className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-primary-700"
>
Cari
</button>
</div>
</div>
</form>
{logs.length === 0 ? (
<div className="rounded-2xl border border-dashed border-neutral-300 bg-white p-10 text-center">
<p className="text-sm text-neutral-500">
Tidak ada audit log yang cocok dengan filter ini.
</p>
</div>
) : (
<div className="overflow-x-auto rounded-2xl border border-neutral-200 bg-white shadow-sm">
<table className="min-w-full divide-y divide-neutral-100 text-sm">
<thead className="bg-neutral-50 text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
<tr>
<th className="px-3 py-2 text-left">Waktu</th>
<th className="px-3 py-2 text-left">Admin</th>
<th className="px-3 py-2 text-left">Action</th>
<th className="px-3 py-2 text-left">Entity</th>
<th className="px-3 py-2 text-left">Entity ID</th>
<th className="px-3 py-2 text-left">Payload</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100 text-xs text-neutral-700">
{logs.map((row) => (
<tr key={row.id}>
<td className="whitespace-nowrap px-3 py-2 text-neutral-500">
{row.createdAt.toLocaleString("id-ID", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</td>
<td className="whitespace-nowrap px-3 py-2">
{row.adminEmail}
{!row.adminId && (
<span className="ml-1 text-[10px] text-amber-700">
(deleted)
</span>
)}
</td>
<td className="whitespace-nowrap px-3 py-2">
<span className="rounded bg-primary-50 px-1.5 py-0.5 font-mono text-[11px] font-semibold text-primary-800">
{row.action}
</span>
</td>
<td className="whitespace-nowrap px-3 py-2 font-medium">
{row.entityType}
</td>
<td className="px-3 py-2">
<EntityIdLink
entityType={row.entityType}
entityId={row.entityId}
/>
</td>
<td className="px-3 py-2 text-neutral-500">
{row.payload ? (
<code className="block max-w-md overflow-x-auto rounded bg-neutral-50 px-2 py-1 font-mono text-[10px]">
{JSON.stringify(row.payload)}
</code>
) : (
"—"
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
function EntityIdLink({
entityType,
entityId,
}: {
entityType: string;
entityId: string;
}) {
const short = `${entityId.slice(0, 8)}`;
let href: string | null = null;
if (entityType === "Trip") href = `/admin/trips/${entityId}`;
if (entityType === "User") href = `/admin/users/${entityId}`;
if (href) {
return (
<Link
href={href}
className="font-mono text-[11px] text-secondary-700 hover:text-secondary-900"
>
{short}
</Link>
);
}
return <span className="font-mono text-[11px]">{short}</span>;
}
+31 -7
View File
@@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { ArrowLeft, CalendarDays, CircleAlert, MapPin } from "lucide-react";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin"; import { isAdminEmail } from "@/lib/admin";
import { bookingRepo } from "@/server/repositories/booking.repo"; import { bookingRepo } from "@/server/repositories/booking.repo";
@@ -69,8 +70,12 @@ export default async function AdminBookingDetailPage({ params }: PageProps) {
return ( return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:py-12"> <div className="mx-auto max-w-4xl px-4 py-8 sm:py-12">
<div className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-neutral-500"> <div className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-neutral-500">
<Link href="/admin" className="hover:text-primary-600"> <Link
Dashboard href="/admin"
className="inline-flex items-center gap-1 hover:text-primary-600"
>
<ArrowLeft size={14} strokeWidth={2} aria-hidden />
Dashboard
</Link> </Link>
<Link <Link
href={`/admin/trips/${booking.tripId}`} href={`/admin/trips/${booking.tripId}`}
@@ -87,9 +92,22 @@ export default async function AdminBookingDetailPage({ params }: PageProps) {
<h1 className="mt-0.5 text-xl font-bold text-neutral-900 sm:text-2xl"> <h1 className="mt-0.5 text-xl font-bold text-neutral-900 sm:text-2xl">
{booking.trip.title} {booking.trip.title}
</h1> </h1>
<p className="mt-1 text-sm text-neutral-500"> <p className="mt-1 flex flex-wrap items-center gap-1 text-sm text-neutral-500">
📅 {formatTripCalendarDateRangeLong(booking.trip.date, booking.trip.endDate)}{" "} <CalendarDays
· 📍 {booking.trip.destination}, {booking.trip.location} size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0"
/>
{formatTripCalendarDateRangeLong(booking.trip.date, booking.trip.endDate)}
<span aria-hidden>·</span>
<MapPin
size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0"
/>
{booking.trip.destination}, {booking.trip.location}
</p> </p>
<div className="mt-4 grid gap-3 sm:grid-cols-2"> <div className="mt-4 grid gap-3 sm:grid-cols-2">
@@ -300,8 +318,14 @@ function PaymentEventCard({
</p> </p>
)} )}
{payment.rejectionReason && ( {payment.rejectionReason && (
<p className="text-red-700"> <p className="flex items-center gap-1 text-red-700">
{payment.rejectionReason} <CircleAlert
size={14}
strokeWidth={2}
aria-hidden
className="shrink-0"
/>
{payment.rejectionReason}
</p> </p>
)} )}
</div> </div>
+17
View File
@@ -0,0 +1,17 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Admin · Email Log",
description:
"Halaman admin untuk memantau pengiriman email — antrian, kegagalan, dan retry.",
alternates: { canonical: "/admin/emails" },
robots: { index: false, follow: false },
};
export default function AdminEmailsLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}
+346
View File
@@ -0,0 +1,346 @@
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { emailRepo } from "@/server/repositories/email.repo";
import {
RetryEmailButton,
ResendEmailButton,
} from "@/features/email/components/email-row-actions";
type Tab = "failed" | "queue" | "sent";
const TABS: { key: Tab; label: string }[] = [
{ key: "failed", label: "Gagal" },
{ key: "queue", label: "Antrian" },
{ key: "sent", label: "Terkirim" },
];
interface PageProps {
searchParams: Promise<{ tab?: string; to?: string; template?: string }>;
}
export default async function AdminEmailsPage({ searchParams }: PageProps) {
const session = await getServerSession(authOptions);
if (!session?.user) redirect("/login?callbackUrl=/admin/emails");
if (!isAdminEmail(session.user.email)) {
return (
<div className="mx-auto max-w-2xl px-4 py-12 text-center">
<p className="text-sm text-neutral-600">
Halaman ini hanya untuk admin SeTrip.
</p>
</div>
);
}
const params = await searchParams;
const tab: Tab = TABS.some((t) => t.key === params.tab)
? (params.tab as Tab)
: "failed";
const filters = {
to: params.to?.trim() || undefined,
template: params.template?.trim() || undefined,
};
const stats = await emailRepo.stats();
const jobs =
tab === "sent"
? []
: await emailRepo.listJobs(
tab === "failed" ? ["FAILED"] : ["PENDING", "PROCESSING"],
filters
);
const sent = tab === "sent" ? await emailRepo.listSent(filters) : [];
return (
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
<header className="mb-6">
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
Email Log
</h1>
<p className="mt-1 text-sm text-neutral-500">
Pantau pengiriman email transaksional. Email yang gagal dikirim bisa
di-retry manual; email terkirim bisa di-resend kalau peserta lapor
tidak menerima.
</p>
</header>
{/* Kartu ringkasan */}
<div className="mb-6 grid gap-3 sm:grid-cols-3">
<StatCard
label="Antri dikirim"
value={stats.queued}
tone={stats.queued > 0 ? "amber" : "ok"}
hint="Job menunggu cron / retry"
/>
<StatCard
label="Gagal 24 jam"
value={stats.failed24h}
tone={stats.failed24h > 0 ? "red" : "ok"}
hint="Job gagal dalam sehari terakhir"
/>
<StatCard
label="Perlu aksi manual"
value={stats.deadLetter}
tone={stats.deadLetter > 0 ? "red" : "ok"}
hint="Gagal & habis 5 attempt — cron berhenti retry"
/>
</div>
{/* Tabs */}
<div className="mb-4 flex flex-wrap gap-2">
{TABS.map((t) => (
<a
key={t.key}
href={`/admin/emails?tab=${t.key}`}
className={`rounded-full px-4 py-1.5 text-sm font-semibold transition-colors ${
tab === t.key
? "bg-primary-600 text-white"
: "bg-neutral-100 text-neutral-600 hover:bg-neutral-200"
}`}
>
{t.label}
</a>
))}
</div>
{/* Filter */}
<form
method="get"
action="/admin/emails"
className="mb-4 flex flex-wrap items-end gap-2 rounded-2xl border border-neutral-200 bg-white p-3 shadow-sm"
>
<input type="hidden" name="tab" value={tab} />
<div className="min-w-[180px] flex-1">
<label
htmlFor="filter-to"
className="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
>
Penerima (email)
</label>
<input
id="filter-to"
name="to"
defaultValue={params.to ?? ""}
placeholder="user@email.com"
className="w-full rounded-lg border border-neutral-200 bg-neutral-50 px-2 py-1.5 text-sm text-neutral-800 focus:border-primary-400 focus:bg-white"
/>
</div>
<div className="min-w-[160px] flex-1">
<label
htmlFor="filter-template"
className="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
>
Template
</label>
<input
id="filter-template"
name="template"
defaultValue={params.template ?? ""}
placeholder="mis. refund_succeeded"
className="w-full rounded-lg border border-neutral-200 bg-neutral-50 px-2 py-1.5 text-sm text-neutral-800 focus:border-primary-400 focus:bg-white"
/>
</div>
<button
type="submit"
className="rounded-lg bg-primary-600 px-4 py-1.5 text-sm font-semibold text-white hover:bg-primary-700"
>
Cari
</button>
{(filters.to || filters.template) && (
<a
href={`/admin/emails?tab=${tab}`}
className="rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-500 hover:bg-neutral-50"
>
Reset
</a>
)}
</form>
{tab === "sent" ? (
<SentTable rows={sent} />
) : (
<JobTable rows={jobs} tab={tab} />
)}
</div>
);
}
function StatCard({
label,
value,
tone,
hint,
}: {
label: string;
value: number;
tone: "ok" | "amber" | "red";
hint: string;
}) {
const cls =
tone === "red"
? "border-red-200 bg-red-50/60"
: tone === "amber"
? "border-amber-200 bg-amber-50/60"
: "border-emerald-200 bg-emerald-50/50";
const valueCls =
tone === "red"
? "text-red-700"
: tone === "amber"
? "text-amber-700"
: "text-emerald-700";
return (
<div className={`rounded-2xl border p-4 shadow-sm ${cls}`}>
<p className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
{label}
</p>
<p className={`mt-1 text-2xl font-bold ${valueCls}`}>{value}</p>
<p className="mt-0.5 text-[11px] text-neutral-500">{hint}</p>
</div>
);
}
function JobTable({
rows,
tab,
}: {
rows: Awaited<ReturnType<typeof emailRepo.listJobs>>;
tab: "failed" | "queue";
}) {
if (rows.length === 0) {
return (
<EmptyState
message={
tab === "failed"
? "Tidak ada email gagal — semua pengiriman lancar. 🎉"
: "Tidak ada email yang sedang antri."
}
/>
);
}
return (
<div className="overflow-x-auto rounded-2xl border border-neutral-200 bg-white shadow-sm">
<table className="min-w-full divide-y divide-neutral-100 text-sm">
<thead className="bg-neutral-50 text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
<tr>
<th className="px-3 py-2 text-left">Penerima</th>
<th className="px-3 py-2 text-left">Template</th>
<th className="px-3 py-2 text-left">Status</th>
<th className="px-3 py-2 text-left">Attempt</th>
<th className="px-3 py-2 text-left">
{tab === "failed" ? "Error terakhir" : "Dijadwalkan"}
</th>
<th className="px-3 py-2 text-left">Aksi</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100 text-xs text-neutral-700">
{rows.map((r) => (
<tr key={r.id}>
<td className="px-3 py-2">{r.to}</td>
<td className="px-3 py-2 font-mono">{r.template}</td>
<td className="px-3 py-2">
<EmailBadge value={r.status} />
</td>
<td className="px-3 py-2">
{r.attempts}
{r.attempts >= 5 && (
<span className="ml-1 text-[10px] font-semibold text-red-600">
(mati)
</span>
)}
</td>
<td className="px-3 py-2 text-neutral-500">
{tab === "failed"
? r.lastError
? truncate(r.lastError, 90)
: "—"
: formatDateTime(r.scheduledAt)}
</td>
<td className="px-3 py-2">
<RetryEmailButton jobId={r.id} />
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function SentTable({
rows,
}: {
rows: Awaited<ReturnType<typeof emailRepo.listSent>>;
}) {
if (rows.length === 0) {
return <EmptyState message="Belum ada email terkirim yang cocok." />;
}
return (
<div className="overflow-x-auto rounded-2xl border border-neutral-200 bg-white shadow-sm">
<table className="min-w-full divide-y divide-neutral-100 text-sm">
<thead className="bg-neutral-50 text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
<tr>
<th className="px-3 py-2 text-left">Penerima</th>
<th className="px-3 py-2 text-left">Template</th>
<th className="px-3 py-2 text-left">Subject</th>
<th className="px-3 py-2 text-left">Terkirim</th>
<th className="px-3 py-2 text-left">Aksi</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100 text-xs text-neutral-700">
{rows.map((r) => (
<tr key={r.id}>
<td className="px-3 py-2">{r.to}</td>
<td className="px-3 py-2 font-mono">{r.template}</td>
<td className="px-3 py-2">{truncate(r.subject, 60)}</td>
<td className="px-3 py-2 text-neutral-500">
{formatDateTime(r.sentAt)}
</td>
<td className="px-3 py-2">
<ResendEmailButton emailSentId={r.id} disabled={!r.html} />
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function EmptyState({ message }: { message: string }) {
return (
<div className="rounded-2xl border border-dashed border-neutral-300 bg-white p-10 text-center">
<p className="text-sm text-neutral-500">{message}</p>
</div>
);
}
function EmailBadge({ value }: { value: string }) {
const cls =
value === "SUCCESS"
? "bg-emerald-100 text-emerald-800"
: value === "FAILED"
? "bg-red-100 text-red-800"
: "bg-amber-100 text-amber-800";
return (
<span
className={`inline-block rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${cls}`}
>
{value}
</span>
);
}
function formatDateTime(d: Date): string {
return d.toLocaleString("id-ID", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function truncate(s: string, max: number): string {
return s.length > max ? `${s.slice(0, max)}` : s;
}
+7 -1
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { Lock } from "lucide-react";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { AdminSidebar } from "@/components/admin/admin-sidebar"; import { AdminSidebar } from "@/components/admin/admin-sidebar";
@@ -31,7 +32,12 @@ export default async function AdminLayout({
return ( return (
<div className="flex min-h-screen items-center justify-center bg-neutral-50 px-4"> <div className="flex min-h-screen items-center justify-center bg-neutral-50 px-4">
<div className="max-w-md rounded-2xl border border-neutral-200 bg-white p-8 text-center shadow-sm"> <div className="max-w-md rounded-2xl border border-neutral-200 bg-white p-8 text-center shadow-sm">
<p className="text-2xl">🔒</p> <Lock
size={28}
strokeWidth={1.75}
aria-hidden
className="mx-auto text-neutral-500"
/>
<h1 className="mt-2 text-base font-bold text-neutral-900"> <h1 className="mt-2 text-base font-bold text-neutral-900">
Halaman khusus admin Halaman khusus admin
</h1> </h1>
+4 -2
View File
@@ -1,6 +1,7 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { ChevronRight } from "lucide-react";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { organizerRepo } from "@/server/repositories/organizer.repo"; import { organizerRepo } from "@/server/repositories/organizer.repo";
import { refundRepo } from "@/server/repositories/refund.repo"; import { refundRepo } from "@/server/repositories/refund.repo";
@@ -155,8 +156,9 @@ export default async function AdminDashboardPage() {
> >
{s.label} {s.label}
</span> </span>
<span className="text-xs text-neutral-400 group-hover:text-primary-600"> <span className="inline-flex items-center gap-1 text-xs text-neutral-400 group-hover:text-primary-600">
Buka Buka
<ChevronRight size={14} strokeWidth={2} aria-hidden />
</span> </span>
</div> </div>
<p className="mt-3 text-3xl font-bold text-neutral-900">{s.value}</p> <p className="mt-3 text-3xl font-bold text-neutral-900">{s.value}</p>
+13 -1
View File
@@ -4,6 +4,7 @@ import { authOptions } from "@/lib/auth";
import { isAdminEmail, listAdminEmails } from "@/lib/admin"; import { isAdminEmail, listAdminEmails } from "@/lib/admin";
import { payoutRepo } from "@/server/repositories/payout.repo"; import { payoutRepo } from "@/server/repositories/payout.repo";
import { AdminFilterBar } from "@/features/admin/components/admin-filter-bar"; import { AdminFilterBar } from "@/features/admin/components/admin-filter-bar";
import { ExportCsvLink } from "@/features/admin/components/export-csv-link";
import { import {
PayoutReviewCard, PayoutReviewCard,
type PayoutCardData, type PayoutCardData,
@@ -81,9 +82,15 @@ export default async function AdminPayoutsPage({ searchParams }: PageProps) {
processedBy: p.processedBy, processedBy: p.processedBy,
})); }));
const exportQuery = new URLSearchParams({ status: tab });
if (params.dateFrom) exportQuery.set("dateFrom", params.dateFrom);
if (params.dateTo) exportQuery.set("dateTo", params.dateTo);
if (params.reviewer) exportQuery.set("reviewer", params.reviewer);
return ( return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:py-12"> <div className="mx-auto max-w-4xl px-4 py-8 sm:py-12">
<header className="mb-6"> <header className="mb-6 flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl"> <h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
Payout Organizer Payout Organizer
</h1> </h1>
@@ -92,6 +99,11 @@ export default async function AdminPayoutsPage({ searchParams }: PageProps) {
status <strong>Siap transfer</strong>, admin transfer manual ke status <strong>Siap transfer</strong>, admin transfer manual ke
rekening organizer lalu tandai sudah dibayar. rekening organizer lalu tandai sudah dibayar.
</p> </p>
</div>
<ExportCsvLink
href="/api/admin/export/payouts"
query={exportQuery.toString()}
/>
</header> </header>
<AdminFilterBar <AdminFilterBar
+14 -1
View File
@@ -5,6 +5,7 @@ import { isAdminEmail, listAdminEmails } from "@/lib/admin";
import { refundRepo } from "@/server/repositories/refund.repo"; import { refundRepo } from "@/server/repositories/refund.repo";
import { CreateRefundForm } from "@/features/refund/components/create-refund-form"; import { CreateRefundForm } from "@/features/refund/components/create-refund-form";
import { AdminFilterBar } from "@/features/admin/components/admin-filter-bar"; import { AdminFilterBar } from "@/features/admin/components/admin-filter-bar";
import { ExportCsvLink } from "@/features/admin/components/export-csv-link";
import { import {
RefundReviewCard, RefundReviewCard,
type RefundCardData, type RefundCardData,
@@ -112,9 +113,16 @@ export default async function AdminRefundsPage({ searchParams }: PageProps) {
}, },
})); }));
const exportQuery = new URLSearchParams({ status: tab });
if (params.dateFrom) exportQuery.set("dateFrom", params.dateFrom);
if (params.dateTo) exportQuery.set("dateTo", params.dateTo);
if (params.reviewer) exportQuery.set("reviewer", params.reviewer);
if (reason) exportQuery.set("reason", reason);
return ( 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">
<div>
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl"> <h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
Review Refund Manual Review Refund Manual
</h1> </h1>
@@ -122,6 +130,11 @@ export default async function AdminRefundsPage({ searchParams }: PageProps) {
Tinjau laporan refund dari peserta dan organizer. Setiap refund harus Tinjau laporan refund dari peserta dan organizer. Setiap refund harus
melalui approval admin sebelum dieksekusi. melalui approval admin sebelum dieksekusi.
</p> </p>
</div>
<ExportCsvLink
href="/api/admin/export/refunds"
query={exportQuery.toString()}
/>
</header> </header>
<CreateRefundForm /> <CreateRefundForm />
+169 -8
View File
@@ -1,8 +1,17 @@
import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import {
ArrowUpRight,
CircleAlert,
CircleCheck,
CircleX,
} from "lucide-react";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin"; import { isAdminEmail } from "@/lib/admin";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { systemHealthService } from "@/server/services/system-health.service";
import { emailRepo } from "@/server/repositories/email.repo";
interface JobSummary { interface JobSummary {
jobName: string; jobName: string;
@@ -53,7 +62,11 @@ async function getJobSummary(jobName: string): Promise<JobSummary> {
} }
// Daftar cron yang dipantau. Tambah entry baru saat menambah cron route handler. // Daftar cron yang dipantau. Tambah entry baru saat menambah cron route handler.
const TRACKED_JOBS = ["auto-complete-trips"] as const; const TRACKED_JOBS = [
"auto-complete-trips",
"process-email-jobs",
"cleanup-trip-images",
] as const;
function healthOf(summary: JobSummary): "ok" | "stale" | "failed" { function healthOf(summary: JobSummary): "ok" | "stale" | "failed" {
if (summary.lastRun?.status === "FAILED") return "failed"; if (summary.lastRun?.status === "FAILED") return "failed";
@@ -78,11 +91,22 @@ export default async function AdminSystemPage() {
); );
} }
const summaries = await Promise.all(TRACKED_JOBS.map(getJobSummary)); const [summaries, recentRuns, stale, emailStats] = await Promise.all([
const recentRuns = await prisma.cronRun.findMany({ Promise.all(TRACKED_JOBS.map(getJobSummary)),
prisma.cronRun.findMany({
orderBy: { startedAt: "desc" }, orderBy: { startedAt: "desc" },
take: 20, take: 20,
}); }),
systemHealthService.detectStale(),
emailRepo.stats(),
]);
const hasAnyStale =
stale.stalePaymentsCount > 0 ||
stale.awaitingPayPastDepartureCount > 0 ||
stale.overduePayoutsCount > 0 ||
stale.stuckRefundsCount > 0 ||
emailStats.deadLetter > 0;
return ( return (
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12"> <div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
@@ -96,6 +120,72 @@ export default async function AdminSystemPage() {
</p> </p>
</header> </header>
{hasAnyStale && (
<section className="mb-6 rounded-2xl border border-amber-300 bg-amber-50 p-4 sm:p-5">
<h2 className="mb-2 flex items-center gap-1.5 text-sm font-bold text-amber-900">
<CircleAlert size={16} strokeWidth={2} aria-hidden />
Stale State Alerts
</h2>
<ul className="space-y-1 text-xs text-amber-900">
{stale.stalePaymentsCount > 0 && (
<li>
<strong>{stale.stalePaymentsCount}</strong> Payment MIDTRANS
AWAITING &gt; 25 jam webhook mungkin tertunda. Cek manual lalu
reconcile.
</li>
)}
{stale.awaitingPayPastDepartureCount > 0 && (
<li>
<strong>{stale.awaitingPayPastDepartureCount}</strong> Booking
AWAITING_PAY tapi trip sudah lewat tanggal berangkat peserta
lupa bayar, butuh cleanup.
</li>
)}
{stale.overduePayoutsCount > 0 && (
<li>
<strong>{stale.overduePayoutsCount}</strong> Payout HELD lewat
heldUntil &gt; 1 hari cron release mungkin tidak jalan, cek
cron history di bawah.{" "}
<Link
href="/admin/payouts?tab=HELD"
className="inline-flex items-center gap-1 font-semibold text-amber-700 hover:underline"
>
Lihat HELD
<ArrowUpRight size={14} strokeWidth={2} aria-hidden />
</Link>
</li>
)}
{stale.stuckRefundsCount > 0 && (
<li>
<strong>{stale.stuckRefundsCount}</strong> Refund APPROVED
&gt; 7 hari belum di-process.{" "}
<Link
href="/admin/refunds?tab=APPROVED"
className="inline-flex items-center gap-1 font-semibold text-amber-700 hover:underline"
>
Lihat APPROVED
<ArrowUpRight size={14} strokeWidth={2} aria-hidden />
</Link>
</li>
)}
{emailStats.deadLetter > 0 && (
<li>
<strong>{emailStats.deadLetter}</strong> email gagal kirim &
sudah habis 5 attempt cron berhenti retry, perlu retry
manual.{" "}
<Link
href="/admin/emails?tab=failed"
className="inline-flex items-center gap-1 font-semibold text-amber-700 hover:underline"
>
Lihat email gagal
<ArrowUpRight size={14} strokeWidth={2} aria-hidden />
</Link>
</li>
)}
</ul>
</section>
)}
<section className="mb-8"> <section className="mb-8">
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500"> <h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
Cron Jobs Cron Jobs
@@ -111,16 +201,23 @@ export default async function AdminSystemPage() {
: "border-red-200 bg-red-50/50"; : "border-red-200 bg-red-50/50";
const badge = const badge =
health === "ok" health === "ok"
? { label: "🟢 OK", cls: "bg-emerald-100 text-emerald-800" } ? {
label: "OK",
icon: CircleCheck,
cls: "bg-emerald-100 text-emerald-800",
}
: health === "stale" : health === "stale"
? { ? {
label: "🟡 STALE", label: "STALE",
icon: CircleAlert,
cls: "bg-amber-100 text-amber-800", cls: "bg-amber-100 text-amber-800",
} }
: { : {
label: "🔴 FAILED", label: "FAILED",
icon: CircleX,
cls: "bg-red-100 text-red-800", cls: "bg-red-100 text-red-800",
}; };
const BadgeIcon = badge.icon;
return ( return (
<div <div
key={s.jobName} key={s.jobName}
@@ -136,8 +233,9 @@ export default async function AdminSystemPage() {
</p> </p>
</div> </div>
<span <span
className={`rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${badge.cls}`} 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} {badge.label}
</span> </span>
</div> </div>
@@ -176,6 +274,38 @@ export default async function AdminSystemPage() {
</div> </div>
</section> </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> <section>
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500"> <h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
Recent Runs (20 terakhir) Recent Runs (20 terakhir)
@@ -241,6 +371,37 @@ function truncate(s: string, max: number): string {
return s.length > max ? `${s.slice(0, max)}` : s; 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 }) { function StatusBadge({ value }: { value: string }) {
const cls = const cls =
value === "SUCCESS" value === "SUCCESS"
+26 -7
View File
@@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { ArrowLeft, CalendarDays, MapPin } from "lucide-react";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin"; import { isAdminEmail } from "@/lib/admin";
import { tripService } from "@/server/services/trip.service"; import { tripService } from "@/server/services/trip.service";
@@ -67,8 +68,12 @@ export default async function AdminTripDetailPage({ params }: PageProps) {
return ( return (
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12"> <div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
<div className="mb-4 text-xs text-neutral-500"> <div className="mb-4 text-xs text-neutral-500">
<Link href="/admin/trips" className="hover:text-primary-600"> <Link
Kembali ke list trips href="/admin/trips"
className="inline-flex items-center gap-1 hover:text-primary-600"
>
<ArrowLeft size={14} strokeWidth={2} aria-hidden />
Kembali ke list trips
</Link> </Link>
</div> </div>
@@ -84,9 +89,22 @@ export default async function AdminTripDetailPage({ params }: PageProps) {
<h1 className="text-xl font-bold text-neutral-900 sm:text-2xl"> <h1 className="text-xl font-bold text-neutral-900 sm:text-2xl">
{trip.title} {trip.title}
</h1> </h1>
<p className="mt-1 text-sm text-neutral-500"> <p className="mt-1 flex flex-wrap items-center gap-1 text-sm text-neutral-500">
📅 {formatTripCalendarDateRangeLong(trip.date, trip.endDate)} · <CalendarDays
📍 {trip.destination}, {trip.location} size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0"
/>
{formatTripCalendarDateRangeLong(trip.date, trip.endDate)}
<span aria-hidden>·</span>
<MapPin
size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0"
/>
{trip.destination}, {trip.location}
</p> </p>
<p className="mt-1 text-xs text-neutral-500"> <p className="mt-1 text-xs text-neutral-500">
Organizer:{" "} Organizer:{" "}
@@ -220,8 +238,9 @@ export default async function AdminTripDetailPage({ params }: PageProps) {
{p.user.name} {p.user.name}
</Link> </Link>
{p.user.profile?.city && ( {p.user.profile?.city && (
<span className="ml-2 text-[11px] text-neutral-500"> <span className="ml-2 inline-flex items-center gap-1 text-[11px] text-neutral-500">
📍 {p.user.profile.city} <MapPin size={12} strokeWidth={2} aria-hidden />
{p.user.profile.city}
</span> </span>
)} )}
</div> </div>
+17 -3
View File
@@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { CalendarDays, MapPin } from "lucide-react";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin"; import { isAdminEmail } from "@/lib/admin";
import { tripRepo } from "@/server/repositories/trip.repo"; import { tripRepo } from "@/server/repositories/trip.repo";
@@ -127,9 +128,22 @@ export default async function AdminTripsPage({ searchParams }: PageProps) {
<h2 className="truncate text-base font-bold text-neutral-900 sm:text-lg"> <h2 className="truncate text-base font-bold text-neutral-900 sm:text-lg">
{t.title} {t.title}
</h2> </h2>
<p className="mt-1 truncate text-xs text-neutral-500 sm:text-sm"> <p className="mt-1 flex items-center gap-1 truncate text-xs text-neutral-500 sm:text-sm">
📅 {formatTripCalendarDateRangeLong(t.date, t.endDate)} <CalendarDays
{" · "}📍 {t.location} size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0"
/>
{formatTripCalendarDateRangeLong(t.date, t.endDate)}
<span aria-hidden>·</span>
<MapPin
size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0"
/>
{t.location}
</p> </p>
<p className="mt-1 text-xs text-neutral-500 sm:text-sm"> <p className="mt-1 text-xs text-neutral-500 sm:text-sm">
Organizer:{" "} Organizer:{" "}
+26 -9
View File
@@ -2,11 +2,13 @@ import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { ArrowLeft, ArrowUpRight, Ban, Check } from "lucide-react";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin"; import { isAdminEmail } from "@/lib/admin";
import { userRepo } from "@/server/repositories/user.repo"; import { userRepo } from "@/server/repositories/user.repo";
import { formatRupiah } from "@/lib/utils"; import { formatRupiah } from "@/lib/utils";
import { SuspendUserButton } from "@/features/admin/components/suspend-user-button"; import { SuspendUserButton } from "@/features/admin/components/suspend-user-button";
import { ManualVerifyButton } from "@/features/admin/components/manual-verify-button";
interface PageProps { interface PageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@@ -37,8 +39,12 @@ export default async function AdminUserDetailPage({ params }: PageProps) {
return ( return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:py-12"> <div className="mx-auto max-w-4xl px-4 py-8 sm:py-12">
<div className="mb-4 text-xs text-neutral-500"> <div className="mb-4 text-xs text-neutral-500">
<Link href="/admin/users" className="hover:text-primary-600"> <Link
Kembali ke list users 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> </Link>
</div> </div>
@@ -74,8 +80,9 @@ export default async function AdminUserDetailPage({ params }: PageProps) {
</span> </span>
)} )}
{user.organizerVerification?.status === "APPROVED" && ( {user.organizerVerification?.status === "APPROVED" && (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-emerald-800"> <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">
Verified Organizer <Check size={12} strokeWidth={2.5} aria-hidden />
Verified Organizer
</span> </span>
)} )}
</div> </div>
@@ -121,8 +128,9 @@ export default async function AdminUserDetailPage({ params }: PageProps) {
{user.suspended && ( {user.suspended && (
<section className="mb-6 rounded-2xl border border-red-300 bg-red-50 p-4 sm:p-5"> <section className="mb-6 rounded-2xl border border-red-300 bg-red-50 p-4 sm:p-5">
<h2 className="text-sm font-bold text-red-900"> <h2 className="flex items-center gap-1.5 text-sm font-bold text-red-900">
Akun ditangguhkan <Ban size={16} strokeWidth={2} aria-hidden />
Akun ditangguhkan
</h2> </h2>
<p className="mt-1 text-xs text-red-900/80"> <p className="mt-1 text-xs text-red-900/80">
{user.suspendedReason ?? "Tidak ada alasan tercatat."} {user.suspendedReason ?? "Tidak ada alasan tercatat."}
@@ -154,10 +162,18 @@ export default async function AdminUserDetailPage({ params }: PageProps) {
</h2> </h2>
{isSelf ? ( {isSelf ? (
<p className="text-xs text-neutral-500"> <p className="text-xs text-neutral-500">
Tidak bisa suspend akun sendiri. Tidak bisa suspend / modifikasi akun sendiri.
</p> </p>
) : ( ) : (
<div className="flex flex-wrap items-start gap-3">
<SuspendUserButton userId={user.id} isSuspended={user.suspended} /> <SuspendUserButton userId={user.id} isSuspended={user.suspended} />
{!user.organizerVerification && (
<ManualVerifyButton
userId={user.id}
defaultBankAccountName={user.name}
/>
)}
</div>
)} )}
</section> </section>
@@ -235,9 +251,10 @@ export default async function AdminUserDetailPage({ params }: PageProps) {
{" · "} {" · "}
<Link <Link
href={`/admin/verifications?tab=${user.organizerVerification.status}`} href={`/admin/verifications?tab=${user.organizerVerification.status}`}
className="text-secondary-700 hover:text-secondary-900" className="inline-flex items-center gap-1 text-secondary-700 hover:text-secondary-900"
> >
Buka di /admin/verifications Buka di /admin/verifications
<ArrowUpRight size={14} strokeWidth={2} aria-hidden />
</Link> </Link>
</p> </p>
{user.organizerVerification.rejectionReason && ( {user.organizerVerification.rejectionReason && (
+14 -3
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { Check, ChartColumn } from "lucide-react";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin"; import { isAdminEmail } from "@/lib/admin";
import { userRepo } from "@/server/repositories/user.repo"; import { userRepo } from "@/server/repositories/user.repo";
@@ -44,7 +45,8 @@ export default async function AdminUsersPage({ searchParams }: PageProps) {
return ( return (
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12"> <div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
<header className="mb-6"> <header className="mb-6 flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl"> <h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
User Management User Management
</h1> </h1>
@@ -52,6 +54,14 @@ export default async function AdminUsersPage({ searchParams }: PageProps) {
Cari user, lihat history booking & trip, dan suspend akun yang Cari user, lihat history booking & trip, dan suspend akun yang
melakukan abuse (scam, harassment, TOS violation). melakukan abuse (scam, harassment, TOS violation).
</p> </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> </header>
<form method="get" className="mb-4 flex gap-2"> <form method="get" className="mb-4 flex gap-2">
@@ -139,8 +149,9 @@ export default async function AdminUsersPage({ searchParams }: PageProps) {
</span> </span>
)} )}
{u.organizerVerification?.status === "APPROVED" && ( {u.organizerVerification?.status === "APPROVED" && (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-emerald-800"> <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">
Organizer <Check size={12} strokeWidth={2.5} aria-hidden />
Organizer
</span> </span>
)} )}
</div> </div>
+207
View File
@@ -0,0 +1,207 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { ArrowLeft } from "lucide-react";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { prisma } from "@/lib/prisma";
const DAY_MS = 24 * 60 * 60 * 1000;
interface WeeklyBucket {
weekStart: Date;
label: string;
count: number;
}
function thirtyDaysAgoDate(): Date {
return new Date(Date.now() - 30 * DAY_MS);
}
async function getSignupsPerWeek(weeks = 8): Promise<WeeklyBucket[]> {
const now = new Date();
const startMs = now.getTime() - weeks * 7 * DAY_MS;
const startDate = new Date(startMs);
const users = await prisma.user.findMany({
where: { createdAt: { gte: startDate } },
select: { createdAt: true },
});
// Bucketize per week (Senin sebagai start, supaya konsisten dengan kalender Indonesia).
const buckets: WeeklyBucket[] = [];
for (let i = weeks - 1; i >= 0; i--) {
const bucketStart = new Date(now.getTime() - (i + 1) * 7 * DAY_MS);
bucketStart.setUTCHours(0, 0, 0, 0);
const bucketEnd = new Date(bucketStart.getTime() + 7 * DAY_MS);
const count = users.filter(
(u) => u.createdAt >= bucketStart && u.createdAt < bucketEnd
).length;
buckets.push({
weekStart: bucketStart,
label: bucketStart.toLocaleDateString("id-ID", {
day: "2-digit",
month: "short",
}),
count,
});
}
return buckets;
}
export default async function AdminUserStatsPage() {
const session = await getServerSession(authOptions);
if (!session?.user) redirect("/login?callbackUrl=/admin/users/stats");
if (!isAdminEmail(session.user.email)) {
return (
<div className="mx-auto max-w-2xl px-4 py-12 text-center">
<p className="text-sm text-neutral-600">
Halaman ini hanya untuk admin SeTrip.
</p>
</div>
);
}
const thirtyDaysAgo = thirtyDaysAgoDate();
const [
totalUsers,
suspendedUsers,
verifiedOrganizers,
activeOrganizers30d,
paidParticipants30d,
weekly,
] = await Promise.all([
prisma.user.count(),
prisma.user.count({ where: { suspended: true } }),
prisma.organizerVerification.count({ where: { status: "APPROVED" } }),
prisma.user.count({
where: {
trips: { some: { createdAt: { gte: thirtyDaysAgo } } },
},
}),
prisma.user.count({
where: {
bookings: {
some: {
status: "PAID",
createdAt: { gte: thirtyDaysAgo },
},
},
},
}),
getSignupsPerWeek(8),
]);
const maxWeeklyCount = Math.max(1, ...weekly.map((w) => w.count));
return (
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
<div className="mb-4 text-xs text-neutral-500">
<Link
href="/admin/users"
className="inline-flex items-center gap-1 hover:text-primary-600"
>
<ArrowLeft size={14} strokeWidth={2} aria-hidden />
Kembali ke list users
</Link>
</div>
<header className="mb-6">
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
User Analytics
</h1>
<p className="mt-1 text-sm text-neutral-500">
Snapshot pertumbuhan user. Real-time read langsung dari DB tidak
ada cache, refresh halaman untuk angka terbaru.
</p>
</header>
<section className="mb-8 grid gap-3 sm:grid-cols-3">
<StatCard label="Total Users" value={totalUsers} />
<StatCard
label="Suspended"
value={suspendedUsers}
accent="red"
/>
<StatCard
label="Verified Organizers"
value={verifiedOrganizers}
accent="emerald"
/>
<StatCard
label="Organizer Aktif (30 hari)"
value={activeOrganizers30d}
accent="secondary"
sub="Bikin trip baru"
/>
<StatCard
label="Peserta Aktif (30 hari)"
value={paidParticipants30d}
accent="primary"
sub="Booking PAID"
/>
</section>
<section className="rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-6">
<h2 className="mb-1 text-sm font-bold text-neutral-900">
Signup per Minggu (8 minggu terakhir)
</h2>
<p className="mb-4 text-xs text-neutral-500">
Tiap bar = 1 minggu (mulai hari ini mundur). Angka di atas bar = total
signup minggu itu.
</p>
<div className="flex h-48 items-end gap-2">
{weekly.map((w) => {
const heightPct = (w.count / maxWeeklyCount) * 100;
return (
<div
key={w.weekStart.toISOString()}
className="flex flex-1 flex-col items-center gap-1"
>
<span className="text-[10px] font-semibold text-neutral-700">
{w.count}
</span>
<div
className="w-full rounded-t-md bg-primary-500/80"
style={{ height: `${Math.max(heightPct, 2)}%` }}
title={`${w.count} signup minggu ${w.label}`}
/>
<span className="text-[10px] text-neutral-500">{w.label}</span>
</div>
);
})}
</div>
</section>
</div>
);
}
function StatCard({
label,
value,
sub,
accent = "neutral",
}: {
label: string;
value: number;
sub?: string;
accent?: "neutral" | "primary" | "secondary" | "emerald" | "red";
}) {
const map = {
neutral: "text-neutral-800",
primary: "text-primary-700",
secondary: "text-secondary-700",
emerald: "text-emerald-700",
red: "text-red-700",
};
return (
<div className="rounded-xl border border-neutral-200 bg-white p-4 shadow-sm">
<p className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
{label}
</p>
<p className={`mt-0.5 text-2xl font-bold ${map[accent]}`}>{value}</p>
{sub && <p className="text-[11px] text-neutral-500">{sub}</p>}
</div>
);
}
+13 -1
View File
@@ -5,6 +5,7 @@ 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 { 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";
@@ -69,9 +70,15 @@ 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">
<div>
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl"> <h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
Review Verifikasi Organizer Review Verifikasi Organizer
</h1> </h1>
@@ -79,6 +86,11 @@ export default async function AdminVerificationsPage({ searchParams }: PageProps
Periksa data KTP, foto liveness (memegang kertas SETRIP), dan rekening Periksa data KTP, foto liveness (memegang kertas SETRIP), dan rekening
sebelum menyetujui. sebelum menyetujui.
</p> </p>
</div>
<ExportCsvLink
href="/api/admin/export/verifications"
query={exportQuery.toString()}
/>
</header> </header>
<AdminFilterBar <AdminFilterBar
+96
View File
@@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { payoutRepo } from "@/server/repositories/payout.repo";
import { buildCsv, csvDateJakarta, csvResponse } from "@/lib/csv";
import type { PayoutStatus } from "@/app/generated/prisma/enums";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const VALID_STATUS = new Set<PayoutStatus>([
"HELD",
"RELEASED",
"PAID",
"CANCELLED",
]);
function parseDate(value: string | null): Date | undefined {
if (!value) return undefined;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? undefined : d;
}
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user || !isAdminEmail(session.user.email)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const params = req.nextUrl.searchParams;
const statusParam = params.get("status");
if (!statusParam || !VALID_STATUS.has(statusParam as PayoutStatus)) {
return NextResponse.json(
{ error: "status param wajib (HELD/RELEASED/PAID/CANCELLED)" },
{ status: 400 }
);
}
const status = statusParam as PayoutStatus;
const rows = await payoutRepo.listByStatus(status, {
dateFrom: parseDate(params.get("dateFrom")),
dateTo: parseDate(params.get("dateTo")),
processorEmail: params.get("reviewer") || undefined,
});
const csv = buildCsv(
[
"Payout ID",
"Status",
"Nominal (IDR)",
"Currency",
"Held until",
"Released at",
"Paid at",
"Cancelled at",
"Processor email",
"Admin note",
"Dibuat",
"Organizer nama",
"Organizer email",
"Bank nama",
"Bank rekening",
"Bank atas nama",
"Trip ID",
"Trip judul",
"Booking ID",
"Peserta nama",
],
rows.map((p) => [
p.id,
p.status,
p.amount,
p.currency,
csvDateJakarta(p.heldUntil),
csvDateJakarta(p.releasedAt),
csvDateJakarta(p.paidAt),
csvDateJakarta(p.cancelledAt),
p.processedBy?.email ?? "",
p.adminNote ?? "",
csvDateJakarta(p.createdAt),
p.organizer.name,
p.organizer.email,
p.bankName ?? "",
p.bankAccountNumber ?? "",
p.bankAccountName ?? "",
p.trip.id,
p.trip.title,
p.booking.id,
p.booking.user.name,
])
);
const today = new Date().toISOString().slice(0, 10);
return csvResponse(`payouts-${status}-${today}.csv`, csv);
}
+114
View File
@@ -0,0 +1,114 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { refundRepo } from "@/server/repositories/refund.repo";
import { buildCsv, csvDateJakarta, csvResponse } from "@/lib/csv";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const VALID_STATUS = new Set([
"PENDING",
"APPROVED",
"REJECTED",
"PROCESSING",
"SUCCEEDED",
"FAILED",
]);
const VALID_REASON = new Set([
"USER_CANCELLATION",
"ORGANIZER_CANCELLED",
"TRIP_ISSUE",
"ADMIN_ADJUSTMENT",
"DISPUTE_RESOLVED",
]);
function parseDate(value: string | null): Date | undefined {
if (!value) return undefined;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? undefined : d;
}
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user || !isAdminEmail(session.user.email)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const params = req.nextUrl.searchParams;
const statusParam = params.get("status");
const status =
statusParam && VALID_STATUS.has(statusParam)
? (statusParam as
| "PENDING"
| "APPROVED"
| "REJECTED"
| "PROCESSING"
| "SUCCEEDED"
| "FAILED")
: undefined;
const reasonParam = params.get("reason");
const reason =
reasonParam && VALID_REASON.has(reasonParam)
? (reasonParam as
| "USER_CANCELLATION"
| "ORGANIZER_CANCELLED"
| "TRIP_ISSUE"
| "ADMIN_ADJUSTMENT"
| "DISPUTE_RESOLVED")
: undefined;
const rows = await refundRepo.listByStatus(status, {
dateFrom: parseDate(params.get("dateFrom")),
dateTo: parseDate(params.get("dateTo")),
reviewerEmail: params.get("reviewer") || undefined,
reason,
});
const csv = buildCsv(
[
"Refund ID",
"Status",
"Reason",
"Nominal (IDR)",
"Dilaporkan oleh",
"Catatan laporan",
"Catatan admin",
"Dibuat",
"Reviewed at",
"Succeeded at",
"Failed at",
"Reviewer email",
"Booking ID",
"Peserta nama",
"Peserta email",
"Trip ID",
"Trip judul",
"Trip tanggal",
],
rows.map((r) => [
r.id,
r.status,
r.reason,
r.amount,
r.reportedBy,
r.reportNote,
r.adminNote ?? "",
csvDateJakarta(r.createdAt),
csvDateJakarta(r.reviewedAt),
csvDateJakarta(r.succeededAt),
csvDateJakarta(r.failedAt),
r.reviewedBy?.email ?? "",
r.booking.id,
r.booking.user.name,
r.booking.user.email,
r.booking.trip.id,
r.booking.trip.title,
csvDateJakarta(r.booking.trip.date),
])
);
const today = new Date().toISOString().slice(0, 10);
return csvResponse(`refunds-${status ?? "all"}-${today}.csv`, csv);
}
@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { organizerRepo } from "@/server/repositories/organizer.repo";
import { buildCsv, csvDateJakarta, csvResponse } from "@/lib/csv";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const VALID_STATUS = new Set(["PENDING", "APPROVED", "REJECTED"]);
function parseDate(value: string | null): Date | undefined {
if (!value) return undefined;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? undefined : d;
}
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user || !isAdminEmail(session.user.email)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const params = req.nextUrl.searchParams;
const statusParam = params.get("status");
const status =
statusParam && VALID_STATUS.has(statusParam)
? (statusParam as "PENDING" | "APPROVED" | "REJECTED")
: undefined;
const rows = await organizerRepo.listByStatus(status, {
dateFrom: parseDate(params.get("dateFrom")),
dateTo: parseDate(params.get("dateTo")),
reviewerEmail: params.get("reviewer") || undefined,
});
// SENGAJA tidak ekspor: NIK plaintext (encrypted), ktpImageKey, livenessKey,
// bankAccountNumber. Export ini hanya untuk metadata audit — KYC sensitive
// info tetap di DB & cuma diakses lewat admin UI dengan auth gate.
const csv = buildCsv(
[
"Verification ID",
"Status",
"Nama (KTP)",
"User nama",
"User email",
"Bank nama",
"Bank atas nama",
"Dibuat",
"Reviewed at",
"Verified at",
"Rejection reason",
"Reviewer email",
],
rows.map((v) => [
v.id,
v.status,
v.fullName,
v.user.name,
v.user.email,
v.bankName,
v.bankAccountName,
csvDateJakarta(v.createdAt),
csvDateJakarta(v.reviewedAt),
csvDateJakarta(v.verifiedAt),
v.rejectionReason ?? "",
v.reviewedBy?.email ?? "",
])
);
const today = new Date().toISOString().slice(0, 10);
return csvResponse(`verifications-${status ?? "all"}-${today}.csv`, csv);
}
+19
View File
@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { adminSearchService } from "@/server/services/admin-search.service";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user || !isAdminEmail(session.user.email)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const q = req.nextUrl.searchParams.get("q") ?? "";
const hits = await adminSearchService.resolve(q, 10);
return NextResponse.json({ hits });
}
+74
View File
@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from "next/server";
import { runCron } from "@/lib/cron-runner";
import { prisma } from "@/lib/prisma";
import {
deleteTripImage,
listTripImageNames,
tripImageMtime,
TRIP_IMAGE_URL_PREFIX,
} from "@/lib/trip-image-storage";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/** File yang lebih tua dari ini & tak direferensikan DB dianggap yatim. */
const ORPHAN_AGE_MS = 24 * 60 * 60 * 1000;
/**
* Cron — hapus file gambar trip yatim.
*
* Form create-trip multi-step mengunggah foto SEBELUM trip tersimpan; kalau
* user menutup form di tengah jalan, file menggantung di disk tanpa pernah
* jadi `TripImage`. Sweep ini menghapus file >24 jam yang tidak direferensikan
* `TripImage` mana pun. Idempotent — aman dijalankan berulang.
*
* Trigger: lihat docs/CRON_SETUP.md. Header wajib `Authorization: Bearer ${CRON_SECRET}`.
*/
export async function GET(req: NextRequest) {
const secret = process.env.CRON_SECRET;
if (!secret) {
console.error("[cron/cleanup-trip-images] CRON_SECRET tidak di-set");
return NextResponse.json(
{ error: "Server misconfigured" },
{ status: 500 }
);
}
if (req.headers.get("authorization") !== `Bearer ${secret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const outcome = await runCron("cleanup-trip-images", async () => {
const names = await listTripImageNames();
if (names.length === 0) return { scanned: 0, deleted: 0 };
const referenced = await prisma.tripImage.findMany({
where: { url: { startsWith: TRIP_IMAGE_URL_PREFIX } },
select: { url: true },
});
const referencedNames = new Set(
referenced.map((r) => r.url.slice(TRIP_IMAGE_URL_PREFIX.length))
);
const now = Date.now();
let deleted = 0;
for (const name of names) {
if (referencedNames.has(name)) continue;
const mtime = await tripImageMtime(name);
// File baru di-upload tapi trip belum tersimpan → beri tenggang 24 jam.
if (!mtime || now - mtime.getTime() < ORPHAN_AGE_MS) continue;
await deleteTripImage(name);
deleted++;
}
return { scanned: names.length, deleted };
});
if (!outcome.ok) {
console.error("[cron/cleanup-trip-images] gagal", outcome.error);
return NextResponse.json(
{ error: "Gagal menjalankan cleanup" },
{ status: 500 }
);
}
console.log("[cron/cleanup-trip-images] selesai", outcome.payload);
return NextResponse.json({ ok: true, ...outcome.payload });
}
+36
View File
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { emailService } from "@/lib/email/send";
import { runCron } from "@/lib/cron-runner";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Cron — proses retry queue email (jobs status PENDING/FAILED dengan
* attempts<5 dan scheduledAt sudah lewat).
*
* Trigger setiap 5 menit via system crontab — lihat docs/CRON_SETUP.md.
* Header wajib: `Authorization: Bearer ${CRON_SECRET}`.
*/
export async function GET(req: NextRequest) {
const secret = process.env.CRON_SECRET;
if (!secret) {
return NextResponse.json(
{ error: "Server misconfigured (CRON_SECRET)" },
{ status: 500 }
);
}
const authHeader = req.headers.get("authorization");
if (authHeader !== `Bearer ${secret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const outcome = await runCron("process-email-jobs", async () => {
return emailService.processQueue(50);
});
if (!outcome.ok) {
return NextResponse.json({ error: outcome.error }, { status: 500 });
}
return NextResponse.json({ ok: true, ...outcome.payload });
}
+38
View File
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { isValidTripImageName, readTripImage } from "@/lib/trip-image-storage";
export const runtime = "nodejs";
interface RouteCtx {
params: Promise<{ name: string }>;
}
/**
* Sajikan gambar trip dari disk lokal. Publik — gambar trip memang tampil ke
* semua pengunjung. Di-cache `immutable` selama setahun: nama file
* content-addressed (hex acak), jadi konten untuk satu nama tidak pernah
* berubah. Beban render = baca file kecil dari disk, tanpa fetch eksternal.
*/
export async function GET(_req: NextRequest, ctx: RouteCtx) {
const { name } = await ctx.params;
if (!isValidTripImageName(name)) {
return NextResponse.json({ error: "Tidak ditemukan" }, { status: 404 });
}
let data: Buffer;
try {
data = await readTripImage(name);
} catch {
return NextResponse.json({ error: "Tidak ditemukan" }, { status: 404 });
}
return new NextResponse(new Uint8Array(data), {
status: 200,
headers: {
"Content-Type": "image/webp",
"Content-Length": String(data.length),
"Cache-Control": "public, max-age=31536000, immutable",
"X-Content-Type-Options": "nosniff",
},
});
}
+73
View File
@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { requireActiveUser } from "@/lib/auth-guards";
import {
ALLOWED_TRIP_IMAGE_MIME,
MAX_TRIP_IMAGE_UPLOAD_BYTES,
processAndSaveTripImage,
} from "@/lib/trip-image-storage";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Upload satu foto trip. Dipanggil dari form create-trip saat user memilih
* file — gambar langsung dikompres & disimpan, route mengembalikan URL publik
* yang nanti ikut disubmit bersama data trip.
*
* File yatim (di-upload tapi trip batal dibuat) dibersihkan cron
* `/api/cron/cleanup-trip-images`.
*/
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
await requireActiveUser(session.user.id);
} catch (err) {
return NextResponse.json(
{ error: (err as Error).message },
{ status: 403 }
);
}
let form: FormData;
try {
form = await req.formData();
} catch {
return NextResponse.json(
{ error: "Body bukan multipart/form-data" },
{ status: 400 }
);
}
const file = form.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "File wajib diisi" }, { status: 400 });
}
if (!ALLOWED_TRIP_IMAGE_MIME.has(file.type)) {
return NextResponse.json(
{ error: "Hanya menerima JPG, PNG, atau WebP" },
{ status: 415 }
);
}
if (file.size > MAX_TRIP_IMAGE_UPLOAD_BYTES) {
return NextResponse.json(
{ error: "Ukuran file maksimal 12MB" },
{ status: 413 }
);
}
try {
const buf = Buffer.from(await file.arrayBuffer());
const saved = await processAndSaveTripImage(buf);
return NextResponse.json({ url: saved.url, size: saved.size });
} catch (err) {
return NextResponse.json(
{ error: (err as Error).message || "Gagal memproses gambar" },
{ status: 400 }
);
}
}
+53
View File
@@ -64,6 +64,16 @@ select:focus {
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1) !important; box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1) !important;
} }
/* Input wrapper isi penuh lebar parent + popper di atas konten lain */
.react-datepicker-wrapper,
.react-datepicker__input-container {
width: 100% !important;
}
.react-datepicker-popper {
z-index: 50 !important;
}
.react-datepicker__header { .react-datepicker__header {
background: #f9fafb !important; background: #f9fafb !important;
border-bottom: 1px solid #e5e7eb !important; border-bottom: 1px solid #e5e7eb !important;
@@ -132,3 +142,46 @@ select:focus {
.react-datepicker__close-icon:hover::after { .react-datepicker__close-icon:hover::after {
background-color: #16a34a !important; background-color: #16a34a !important;
} }
/* Dropdown bulan / tahun (mode select) */
.react-datepicker__month-select,
.react-datepicker__year-select {
border: 1px solid #e5e7eb !important;
border-radius: 0.5rem !important;
padding: 2px 4px !important;
font-size: 0.8125rem !important;
background: #fff !important;
}
/* Pemilih jam (showTimeSelectOnly) */
.react-datepicker__time-container {
width: 96px !important;
}
.react-datepicker__time-container
.react-datepicker__time
.react-datepicker__time-box {
width: 96px !important;
}
.react-datepicker-time__header {
font-weight: 700 !important;
color: #1f2937 !important;
font-size: 0.8125rem !important;
}
.react-datepicker__time-list-item {
font-size: 0.8125rem !important;
color: #1f2937 !important;
}
.react-datepicker__time-list-item:hover {
background: #dcfce7 !important;
color: #15803d !important;
}
.react-datepicker__time-list-item--selected {
background: #16a34a !important;
color: #fff !important;
font-weight: 700 !important;
}
+31
View File
@@ -0,0 +1,31 @@
import type { MetadataRoute } from "next";
import { siteConfig } from "@/lib/site";
/**
* Web app manifest — dideteksi otomatis oleh Next App Router (`<link
* rel="manifest">` di-inject). Mendukung "Add to Home Screen" di mobile.
*/
export default function manifest(): MetadataRoute.Manifest {
return {
name: `${siteConfig.name}${siteConfig.slogan}`,
short_name: siteConfig.name,
description: siteConfig.description,
start_url: "/",
display: "standalone",
background_color: "#f9fafb",
theme_color: "#16a34a",
icons: [
{
src: "/images/SeTrip.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
{
src: "/SeTrip.ico",
sizes: "any",
type: "image/x-icon",
},
],
};
}
+8 -1
View File
@@ -7,7 +7,14 @@ export default function robots(): MetadataRoute.Robots {
{ {
userAgent: "*", userAgent: "*",
allow: "/", allow: "/",
disallow: ["/api/", "/profile", "/create-trip"], disallow: [
"/api/",
"/admin",
"/profile",
"/create-trip",
"/verify",
"/trips/*/payment",
],
}, },
], ],
sitemap: absoluteUrl("/sitemap.xml"), sitemap: absoluteUrl("/sitemap.xml"),
+6
View File
@@ -24,6 +24,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
changeFrequency: "hourly", changeFrequency: "hourly",
priority: 0.9, priority: 0.9,
}, },
{
url: absoluteUrl("/people"),
lastModified: now,
changeFrequency: "daily",
priority: 0.7,
},
{ {
url: absoluteUrl("/register"), url: absoluteUrl("/register"),
lastModified: now, lastModified: now,
+35 -18
View File
@@ -5,15 +5,33 @@ import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import Image from "next/image"; import Image from "next/image";
import { signOut } from "next-auth/react"; import { signOut } from "next-auth/react";
import {
ArrowLeft,
ArrowUpRight,
Banknote,
Compass,
IdCard,
LayoutDashboard,
Mail,
Menu,
ScrollText,
Settings,
Users,
X,
type LucideIcon,
} from "lucide-react";
import { AdminSearchBar } from "@/features/admin/components/admin-search-bar";
const NAV_ITEMS: { href: string; label: string; icon: string }[] = [ const NAV_ITEMS: { href: string; label: string; icon: LucideIcon }[] = [
{ href: "/admin", label: "Dashboard", icon: "📊" }, { href: "/admin", label: "Dashboard", icon: LayoutDashboard },
{ href: "/admin/trips", label: "Trips", icon: "🧭" }, { href: "/admin/trips", label: "Trips", icon: Compass },
{ href: "/admin/users", label: "Users", icon: "👥" }, { href: "/admin/users", label: "Users", icon: Users },
{ href: "/admin/verifications", label: "Verifikasi", icon: "🪪" }, { href: "/admin/verifications", label: "Verifikasi", icon: IdCard },
{ href: "/admin/refunds", label: "Refund", icon: "↩️" }, { href: "/admin/refunds", label: "Refund", icon: ArrowLeft },
{ href: "/admin/payouts", label: "Payout", icon: "💸" }, { href: "/admin/payouts", label: "Payout", icon: Banknote },
{ href: "/admin/system", label: "System", icon: "⚙️" }, { href: "/admin/emails", label: "Email", icon: Mail },
{ href: "/admin/audit-log", label: "Audit Log", icon: ScrollText },
{ href: "/admin/system", label: "System", icon: Settings },
]; ];
interface AdminSidebarProps { interface AdminSidebarProps {
@@ -48,13 +66,9 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
aria-expanded={open} aria-expanded={open}
> >
{open ? ( {open ? (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> <X size={20} strokeWidth={2} aria-hidden />
<path d="M5 5l10 10M15 5L5 15" />
</svg>
) : ( ) : (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> <Menu size={20} strokeWidth={2} aria-hidden />
<path d="M3 5h14M3 10h14M3 15h14" />
</svg>
)} )}
</button> </button>
</header> </header>
@@ -93,12 +107,17 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
</div> </div>
</div> </div>
<div className="border-b border-neutral-100 p-3">
<AdminSearchBar />
</div>
<nav className="flex-1 overflow-y-auto p-3"> <nav className="flex-1 overflow-y-auto p-3">
<ul className="space-y-1"> <ul className="space-y-1">
{NAV_ITEMS.map((item) => { {NAV_ITEMS.map((item) => {
const isActive = const isActive =
pathname === item.href || pathname === item.href ||
(item.href !== "/admin" && pathname?.startsWith(item.href)); (item.href !== "/admin" && pathname?.startsWith(item.href));
const Icon = item.icon;
return ( return (
<li key={item.href}> <li key={item.href}>
<Link <Link
@@ -110,9 +129,7 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
: "text-neutral-700 hover:bg-neutral-100" : "text-neutral-700 hover:bg-neutral-100"
}`} }`}
> >
<span aria-hidden className="text-base"> <Icon size={20} strokeWidth={1.75} aria-hidden />
{item.icon}
</span>
<span>{item.label}</span> <span>{item.label}</span>
</Link> </Link>
</li> </li>
@@ -129,7 +146,7 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
className="flex items-center gap-3 rounded-lg px-3 py-2 text-xs font-medium text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700" className="flex items-center gap-3 rounded-lg px-3 py-2 text-xs font-medium text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700"
> >
<span aria-hidden></span> <ArrowUpRight size={16} strokeWidth={1.75} aria-hidden />
<span>Lihat situs publik</span> <span>Lihat situs publik</span>
</Link> </Link>
</li> </li>
+255
View File
@@ -0,0 +1,255 @@
"use client";
/**
* Komponen pemilih tanggal & jam bersama — satu-satunya tempat aplikasi
* memakai `react-datepicker`. Semua field tanggal/jam (form & filter) harus
* lewat sini supaya tampilan + locale konsisten.
*
* - `DateField` → satu tanggal (controlled atau uncontrolled untuk form).
* - `DateRangeField` → rentang tanggal (berangkatpulang, filter).
* - `TimeField` → jam "HH:mm" (itinerary).
*
* Tema visual di-override di `app/globals.css` (blok `.react-datepicker`).
*/
import { useState } from "react";
import ReactDatePicker, { registerLocale } from "react-datepicker";
import { id as idLocale } from "date-fns/locale";
import "react-datepicker/dist/react-datepicker.css";
import {
formatLocalCalendarYmd,
localCalendarDateFromYmd,
} from "@/lib/trip-dates";
import { isValidTimeFormat } from "@/lib/itinerary";
registerLocale("id", idLocale);
const FIELD_CLS =
"w-full rounded-xl border border-neutral-200 bg-neutral-50 py-2.5 pl-9 pr-3 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-primary-500 focus:bg-white";
function CalendarIcon() {
return (
<span className="pointer-events-none absolute left-3 top-1/2 z-10 -translate-y-1/2 text-neutral-400">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4"
>
<path
fillRule="evenodd"
d="M5.75 2a.75.75 0 01.75.75V4h7V2.75a.75.75 0 011.5 0V4h.25A2.75 2.75 0 0118 6.75v8.5A2.75 2.75 0 0115.25 18H4.75A2.75 2.75 0 012 15.25v-8.5A2.75 2.75 0 014.75 4H5V2.75A.75.75 0 015.75 2zm-1 5.5c-.69 0-1.25.56-1.25 1.25v6.5c0 .69.56 1.25 1.25 1.25h10.5c.69 0 1.25-.56 1.25-1.25v-6.5c0-.69-.56-1.25-1.25-1.25H4.75z"
clipRule="evenodd"
/>
</svg>
</span>
);
}
function ClockIcon() {
return (
<span className="pointer-events-none absolute left-3 top-1/2 z-10 -translate-y-1/2 text-neutral-400">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm.75-13a.75.75 0 00-1.5 0v5c0 .284.16.544.415.67l3 1.5a.75.75 0 00.67-1.34L10.75 9.54V5z"
clipRule="evenodd"
/>
</svg>
</span>
);
}
interface DateFieldProps {
/** Mode controlled. Kalau `undefined`, komponen jalan uncontrolled. */
value?: Date | null;
/** Nilai awal untuk mode uncontrolled (mis. di dalam form GET/POST biasa). */
defaultValue?: Date | null;
/**
* Nilai awal uncontrolled berupa `YYYY-MM-DD`. Dipakai saat parent adalah
* Server Component (mis. filter admin) — string di-parse di browser supaya
* tidak ada pergeseran timezone server↔klien.
*/
defaultValueYmd?: string;
onChange?: (date: Date | null) => void;
/** Kalau diisi, render hidden input `YYYY-MM-DD` supaya ikut ter-submit form. */
name?: string;
id?: string;
minDate?: Date;
maxDate?: Date;
placeholder?: string;
disabled?: boolean;
required?: boolean;
/** Dropdown bulan + tahun — cocok untuk tanggal lahir. */
withMonthYearDropdown?: boolean;
}
/** Pemilih satu tanggal. */
export function DateField({
value,
defaultValue = null,
defaultValueYmd,
onChange,
name,
id,
minDate,
maxDate,
placeholder = "Pilih tanggal...",
disabled = false,
required = false,
withMonthYearDropdown = false,
}: DateFieldProps) {
const isControlled = value !== undefined;
const [internal, setInternal] = useState<Date | null>(
() => (defaultValueYmd ? localCalendarDateFromYmd(defaultValueYmd) : defaultValue)
);
const current = isControlled ? value : internal;
function handleChange(date: Date | null) {
if (!isControlled) setInternal(date);
onChange?.(date);
}
// Default rentang masuk akal untuk picker bulan/tahun (mis. tanggal lahir).
const effectiveMin =
minDate ??
(withMonthYearDropdown
? new Date(new Date().getFullYear() - 120, 0, 1)
: undefined);
return (
<div className="relative">
<CalendarIcon />
<ReactDatePicker
id={id}
selected={current ?? null}
onChange={handleChange}
locale="id"
dateFormat="dd MMM yyyy"
minDate={effectiveMin}
maxDate={maxDate}
disabled={disabled}
required={required}
placeholderText={placeholder}
isClearable={!required && !disabled}
showMonthDropdown={withMonthYearDropdown}
showYearDropdown={withMonthYearDropdown}
dropdownMode="select"
className={FIELD_CLS}
wrapperClassName="w-full"
/>
{name && (
<input
type="hidden"
name={name}
value={current ? formatLocalCalendarYmd(current) : ""}
/>
)}
</div>
);
}
interface DateRangeFieldProps {
startDate: Date | null;
endDate: Date | null;
onChange: (start: Date | null, end: Date | null) => void;
minDate?: Date;
placeholder?: string;
id?: string;
}
/** Pemilih rentang tanggal (berangkatpulang, filter). */
export function DateRangeField({
startDate,
endDate,
onChange,
minDate,
placeholder = "Pilih tanggal...",
id,
}: DateRangeFieldProps) {
return (
<div className="relative">
<CalendarIcon />
<ReactDatePicker
id={id}
selectsRange
startDate={startDate}
endDate={endDate}
onChange={(dates) => {
const [start, end] = dates as [Date | null, Date | null];
onChange(start, end);
}}
locale="id"
dateFormat="dd MMM yyyy"
minDate={minDate}
isClearable
placeholderText={placeholder}
className={FIELD_CLS}
wrapperClassName="w-full"
/>
</div>
);
}
function timeStringToDate(value: string): Date | null {
if (!isValidTimeFormat(value)) return null;
const [h, m] = value.split(":").map(Number);
const d = new Date();
d.setHours(h, m, 0, 0);
return d;
}
function dateToTimeString(d: Date): string {
const h = String(d.getHours()).padStart(2, "0");
const m = String(d.getMinutes()).padStart(2, "0");
return `${h}:${m}`;
}
interface TimeFieldProps {
/** Jam dalam format "HH:mm", atau "" kalau kosong. */
value: string;
onChange: (value: string) => void;
id?: string;
placeholder?: string;
disabled?: boolean;
/** Tampilkan tombol clear (untuk jam opsional, mis. jam selesai). */
clearable?: boolean;
}
/** Pemilih jam "HH:mm" 24-jam dengan interval 15 menit. */
export function TimeField({
value,
onChange,
id,
placeholder = "--:--",
disabled = false,
clearable = false,
}: TimeFieldProps) {
return (
<div className="relative">
<ClockIcon />
<ReactDatePicker
id={id}
selected={timeStringToDate(value)}
onChange={(d: Date | null) => onChange(d ? dateToTimeString(d) : "")}
showTimeSelect
showTimeSelectOnly
timeIntervals={15}
timeCaption="Jam"
dateFormat="HH:mm"
timeFormat="HH:mm"
locale="id"
disabled={disabled}
isClearable={clearable && !disabled}
placeholderText={placeholder}
className={FIELD_CLS}
wrapperClassName="w-full"
/>
</div>
);
}
+3 -22
View File
@@ -4,6 +4,7 @@ import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { useSession, signOut } from "next-auth/react"; import { useSession, signOut } from "next-auth/react";
import { Menu, X } from "lucide-react";
export function Navbar() { export function Navbar() {
const { data: session } = useSession(); const { data: session } = useSession();
@@ -109,29 +110,9 @@ export function Navbar() {
aria-label="Toggle menu" aria-label="Toggle menu"
> >
{menuOpen ? ( {menuOpen ? (
<svg <X size={20} strokeWidth={1.75} aria-hidden />
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M5 5l10 10M15 5L5 15" />
</svg>
) : ( ) : (
<svg <Menu size={20} strokeWidth={1.75} aria-hidden />
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M3 5h14M3 10h14M3 15h14" />
</svg>
)} )}
</button> </button>
</div> </div>
+8 -11
View File
@@ -1,23 +1,20 @@
import { BadgeCheck } from "lucide-react";
type Size = "sm" | "md"; type Size = "sm" | "md";
export function VerifiedBadge({ size = "sm" }: { size?: Size }) { export function VerifiedBadge({ size = "sm" }: { size?: Size }) {
const cls = const cls =
size === "md" size === "md" ? "px-2.5 py-1 text-xs" : "px-2 py-0.5 text-[10px]";
? "px-2.5 py-1 text-xs"
: "px-2 py-0.5 text-[10px]";
return ( return (
<span <span
className={`inline-flex items-center gap-1 rounded-full bg-primary-50 font-semibold text-primary-700 ring-1 ring-primary-200 ${cls}`} className={`inline-flex items-center gap-1 rounded-full bg-primary-50 font-semibold text-primary-700 ring-1 ring-primary-200 ${cls}`}
title="Organizer terverifikasi SeTrip" title="Organizer terverifikasi SeTrip"
> >
<svg <BadgeCheck
viewBox="0 0 16 16" size={size === "md" ? 14 : 12}
fill="currentColor" strokeWidth={1.75}
className={size === "md" ? "h-3.5 w-3.5" : "h-3 w-3"} aria-hidden
aria-hidden="true" />
>
<path d="M8 0l2.09 1.74L12.86 1.5l.64 2.78 2.5 1.5-1.5 2.5.5 2.86-2.78.64-1.5 2.5-2.72-.59L5.5 14.5 4 12 1.5 11.36 2 8.5.5 6 3 4.5l.64-2.78 2.77.24L8 0zm-1.07 9.4l4.6-4.6-1.06-1.06-3.54 3.54-1.41-1.42-1.06 1.06 2.47 2.48z" />
</svg>
Verified Verified
</span> </span>
); );
+175 -21
View File
@@ -2,13 +2,23 @@
Aplikasi punya beberapa endpoint cron yang harus di-trigger periodik dari luar. Karena deploy pakai PM2 di VPS Linux (bukan Vercel), trigger schedule pakai **system crontab** — zero dependency, OS-native. Aplikasi punya beberapa endpoint cron yang harus di-trigger periodik dari luar. Karena deploy pakai PM2 di VPS Linux (bukan Vercel), trigger schedule pakai **system crontab** — zero dependency, OS-native.
> **Audit trail otomatis:** semua cron yang di-wrap `runCron()` helper auto-log ke tabel `CronRun` (start/finish/error). Cek hasilnya real-time di `/admin/system` — link "System" di sidebar admin. Tidak perlu tail log untuk monitoring rutin.
---
## Daftar cron job ## Daftar cron job
| Endpoint | Schedule | Tujuan | | # | Endpoint | Schedule | Frekuensi | Tujuan |
|---|---|---| |---|---|---|---|---|
| `GET /api/cron/auto-complete-trips` | `0 18 * * *` (18:00 UTC = 01:00 WIB) | Flip trip yang sudah lewat tanggal selesai dari `OPEN`/`FULL` ke `COMPLETED`. | | 1 | `GET /api/cron/auto-complete-trips` | `0 18 * * *` | Daily 01:00 WIB (18:00 UTC) | Flip trip yang sudah lewat tanggal selesai dari `OPEN`/`FULL` ke `COMPLETED`. Setelah itu, release payout HELD yang sudah lewat `heldUntil`. |
| 2 | `GET /api/cron/process-email-jobs` | `*/5 * * * *` | Setiap 5 menit | Drain retry queue email — pick `EmailJob` status `PENDING`/`FAILED` (attempts<5), retry via Resend dengan exponential backoff. |
| 3 | `GET /api/cron/cleanup-trip-images` | `30 18 * * *` | Daily 01:30 WIB (18:30 UTC) | Hapus file gambar trip yatim — foto yang di-upload di form create-trip tapi trip-nya batal dibuat. Hanya file >24 jam yang tak direferensikan `TripImage`. |
## Setup di server Semua cron pakai pola yang sama: header `Authorization: Bearer ${CRON_SECRET}`, idempotent, auto-log ke `CronRun`. Tambah cron baru = tambah baris di tabel ini + tabel `TRACKED_JOBS` di [app/admin/system/page.tsx](../app/admin/system/page.tsx).
---
## Setup di server (one-time)
### 1. Set `CRON_SECRET` di env production ### 1. Set `CRON_SECRET` di env production
@@ -30,7 +40,17 @@ Restart PM2 supaya proses re-load env:
pm2 restart setrip --update-env pm2 restart setrip --update-env
``` ```
### 2. Daftarkan crontab ### 2. Set env opsional untuk fitur lain yang di-trigger cron
| Env | Dibutuhkan oleh | Akibat kalau kosong |
|---|---|---|
| `RESEND_API_KEY` | `process-email-jobs` cron | Email tetap di-queue di DB; cron skip dengan warning. Set nanti dan cron akan auto-drain queue. |
| `EMAIL_FROM` | `process-email-jobs` cron | Pakai default `SeTrip <onboarding@resend.dev>` (cocok untuk dev/test). |
| `ADMIN_ALERT_WEBHOOK_URL` | `runCron` (semua cron) | Tidak ada Discord push notif saat cron FAILED. Admin tetap bisa cek manual di `/admin/system`. |
Semua env ini ada di [.env.example](../.env.example) dengan instruksi setup masing-masing.
### 3. Daftarkan crontab
Edit crontab user yang punya akses ke env (biasanya user yang menjalankan PM2): Edit crontab user yang punya akses ke env (biasanya user yang menjalankan PM2):
@@ -38,11 +58,19 @@ Edit crontab user yang punya akses ke env (biasanya user yang menjalankan PM2):
crontab -e crontab -e
``` ```
Tambah baris (ganti `https://your-domain.com` dan `<CRON_SECRET>` sesuai env yang di-set di step 1): Tambah baris berikut (ganti `https://your-domain.com` dan `<CRON_SECRET>` sesuai env yang di-set di step 1):
```cron ```cron
# Setrip — auto-complete trips harian (jam 01:00 WIB) # === Setrip cron jobs ===
# 1. Auto-complete trip + release payout (daily 01:00 WIB)
0 18 * * * curl -fsS -H "Authorization: Bearer <CRON_SECRET>" https://your-domain.com/api/cron/auto-complete-trips >> /var/log/setrip-cron.log 2>&1 0 18 * * * curl -fsS -H "Authorization: Bearer <CRON_SECRET>" https://your-domain.com/api/cron/auto-complete-trips >> /var/log/setrip-cron.log 2>&1
# 2. Drain email retry queue (setiap 5 menit)
*/5 * * * * curl -fsS -H "Authorization: Bearer <CRON_SECRET>" https://your-domain.com/api/cron/process-email-jobs >> /var/log/setrip-cron.log 2>&1
# 3. Bersihkan gambar trip yatim (daily 01:30 WIB)
30 18 * * * curl -fsS -H "Authorization: Bearer <CRON_SECRET>" https://your-domain.com/api/cron/cleanup-trip-images >> /var/log/setrip-cron.log 2>&1
``` ```
Verifikasi crontab tersimpan: Verifikasi crontab tersimpan:
@@ -51,57 +79,183 @@ Verifikasi crontab tersimpan:
crontab -l crontab -l
``` ```
### 3. Siapkan file log ### 4. Siapkan file log
```bash ```bash
sudo touch /var/log/setrip-cron.log sudo touch /var/log/setrip-cron.log
sudo chown $(whoami) /var/log/setrip-cron.log sudo chown $(whoami) /var/log/setrip-cron.log
``` ```
## Test manual Optional — logrotate supaya log tidak menggemuk:
```bash
sudo nano /etc/logrotate.d/setrip-cron
```
Isi:
```
/var/log/setrip-cron.log {
weekly
rotate 4
compress
missingok
notifempty
}
```
---
## Test manual (sanity check setelah deploy)
Sebelum tunggu schedule, panggil endpoint langsung untuk verifikasi: Sebelum tunggu schedule, panggil endpoint langsung untuk verifikasi:
```bash ```bash
# Test cron 1
curl -fsS -H "Authorization: Bearer $CRON_SECRET" https://your-domain.com/api/cron/auto-complete-trips curl -fsS -H "Authorization: Bearer $CRON_SECRET" https://your-domain.com/api/cron/auto-complete-trips
# Test cron 2
curl -fsS -H "Authorization: Bearer $CRON_SECRET" https://your-domain.com/api/cron/process-email-jobs
``` ```
**Expected response:** **Expected response per cron:**
- Belum ada trip yang lewat: `{"ok":true,"completed":0,"ids":[]}` | Cron | Sukses kosong | Sukses ada pekerjaan |
- Ada trip yang lewat: `{"ok":true,"completed":2,"ids":["clx...","cly..."]}` |---|---|---|
| `auto-complete-trips` | `{"ok":true,"completed":0,"ids":[],"payoutsReleased":[]}` | `{"ok":true,"completed":2,"ids":["clx...","cly..."],"payoutsReleased":["..."]}` |
| `process-email-jobs` | `{"ok":true,"picked":0,"succeeded":0,"failed":0}` | `{"ok":true,"picked":5,"succeeded":5,"failed":0}` |
| `cleanup-trip-images` | `{"ok":true,"scanned":0,"deleted":0}` | `{"ok":true,"scanned":12,"deleted":3}` |
**Kalau dapat 401:** `CRON_SECRET` di env tidak match dengan header. Cek `pm2 env <id>`. **Error response:**
- **401** — `CRON_SECRET` di env tidak match dengan header. Cek `pm2 env <id>`.
- **500 "Server misconfigured"** — `CRON_SECRET` belum di-set di env.
- **500 lain** — cek log app atau `/admin/system` untuk detail error.
**Kalau dapat 500:** `CRON_SECRET` belum di-set di env. ---
## Monitoring ## Monitoring
Tail log cron: ### Cara utama: `/admin/system` (admin panel)
Buka [/admin/system](/admin/system) — tampilkan:
- **Per-job summary card**: last run, last success, count 7 hari, error count 7 hari, health badge (🟢 OK / 🟡 STALE / 🔴 FAILED).
- **Recent runs table**: 20 cron run terakhir lintas semua job (waktu, status, payload, error).
- **Stale state alerts**: banner kuning kalau ada Payment AWAITING > 25h / Payout HELD overdue / Refund APPROVED > 7d.
Cek setiap pagi sebelum mulai kerja — kalau semua 🟢 OK, tidak ada incident.
### Cara backup: tail log file
```bash ```bash
tail -f /var/log/setrip-cron.log tail -f /var/log/setrip-cron.log
``` ```
Cek log app PM2 (untuk `console.log` dari endpoint): PM2 log untuk `console.log` dari endpoint:
```bash ```bash
pm2 logs setrip --lines 100 | grep cron pm2 logs setrip --lines 100 | grep cron
``` ```
### Discord push notif (opsional)
Kalau env `ADMIN_ALERT_WEBHOOK_URL` di-set ke Discord webhook URL, `runCron` otomatis kirim 🚨 message saat cron FAILED — admin bisa react langsung tanpa harus cek dashboard.
Cara setup: Discord channel → Edit Channel → Integrations → Webhooks → New → copy URL → set di env.
---
## Troubleshooting ## Troubleshooting
**Cron jalan tapi tidak ada efek di DB:** **Cron jalan tapi tidak ada efek di DB:**
- Cek `pm2 logs setrip` untuk error. - Cek `/admin/system` — kalau status SUCCESS dengan `payload: { completed: 0 }`, memang tidak ada pekerjaan saat ini.
- Verifikasi waktu server: `date` (output harus UTC kalau pakai schedule UTC). - Cek `pm2 logs setrip` untuk error runtime.
- Verifikasi waktu server: `date -u` (output harus UTC kalau pakai schedule UTC).
**Cron tidak jalan sama sekali:** **Cron tidak jalan sama sekali:**
- Cek service cron aktif: `systemctl status cron` (Debian/Ubuntu) atau `systemctl status crond` (RHEL/CentOS). - Cek service cron aktif: `systemctl status cron` (Debian/Ubuntu) atau `systemctl status crond` (RHEL/CentOS).
- Cek crontab terdaftar di user yang benar: `sudo crontab -u $(whoami) -l`. - Cek crontab terdaftar di user yang benar: `sudo crontab -u $(whoami) -l`.
- Cek `/admin/system` — kalau "Last run" jauh dari ekspektasi (mis. > 25 jam untuk daily cron), schedule mungkin tidak ke-trigger.
**`process-email-jobs` SUCCESS tapi email tidak terkirim:**
- Cek `RESEND_API_KEY` di env. Tanpa env, cron return early dengan warning di log.
- Cek dashboard Resend untuk delivery status / bounce.
- Cek tabel `EmailJob` di DB: status `PENDING`/`FAILED` + `lastError` field.
**`auto-complete-trips` SUCCESS tapi trip masih OPEN:**
- Cek `Trip.endDate` (kalau ada) atau `Trip.date` — harus lewat cutoff (`utcStartOfDay(now)`).
- Trip dengan status `CLOSED` sengaja tidak di-touch (organizer eksplisit batalkan).
**Secret bocor:** **Secret bocor:**
- Generate ulang `CRON_SECRET`, update di `.env` + crontab line, restart PM2. - Generate ulang `CRON_SECRET`, update di `.env` + semua baris crontab, restart PM2.
## Hari kalau pindah ke Vercel / PaaS lain **Cron FAILED berturut-turut:**
- `/admin/system` akan tampilkan badge 🔴 FAILED.
- Kalau env `ADMIN_ALERT_WEBHOOK_URL` di-set, Discord channel akan dapat notif.
- Klik "Last error" di card cron untuk lihat stack trace, atau cek tabel `CronRun.errorMessage` langsung.
Tinggal hapus crontab line + bikin `vercel.json` (atau equivalent platform). Endpoint sudah platform-agnostic — proteksinya sama (header `Authorization: Bearer <CRON_SECRET>`). ---
## Saat menambah cron baru (developer note)
Checklist:
1. Buat route handler di `app/api/cron/<name>/route.ts` dengan pola standar (CRON_SECRET check + `runCron(jobName, fn)` wrapper).
2. Tambah entry di tabel **Daftar cron job** di doc ini.
3. Tambah baris di `TRACKED_JOBS` di [app/admin/system/page.tsx](../app/admin/system/page.tsx) supaya muncul di health card.
4. Brief ops: tambah baris di server crontab dengan schedule yang sesuai.
Pattern minimal cron handler:
```ts
// app/api/cron/<name>/route.ts
import { NextRequest, NextResponse } from "next/server";
import { runCron } from "@/lib/cron-runner";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
const secret = process.env.CRON_SECRET;
if (!secret) {
return NextResponse.json({ error: "Server misconfigured" }, { status: 500 });
}
if (req.headers.get("authorization") !== `Bearer ${secret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const outcome = await runCron("<jobName>", async () => {
// ... actual work; return value masuk ke CronRun.payload
return { processedCount: 0 };
});
if (!outcome.ok) {
return NextResponse.json({ error: outcome.error }, { status: 500 });
}
return NextResponse.json({ ok: true, ...outcome.payload });
}
```
---
## Kalau pindah ke Vercel / PaaS lain
Bikin `vercel.json` di root:
```json
{
"crons": [
{
"path": "/api/cron/auto-complete-trips",
"schedule": "0 18 * * *"
},
{
"path": "/api/cron/process-email-jobs",
"schedule": "*/5 * * * *"
}
]
}
```
Vercel Cron otomatis kirim header `Authorization: Bearer <VERCEL_CRON_SECRET>` — sesuaikan logic auth check di route handler (atau pakai env yang sama). Endpoint sudah platform-agnostic — tidak ada code change yang diperlukan.
> **Catatan:** Vercel Cron free tier limit 2 cron/project + minimum schedule 1 jam. Untuk `process-email-jobs` yang 5 menit, perlu upgrade Vercel Pro atau pertahankan VPS untuk cron.
+84
View File
@@ -0,0 +1,84 @@
# Setrip — Admin Audit & Investigation Roadmap (ARCHIVED — DELIVERED 2026-05-18)
Admin perlu **mencari** lintas entity (booking/payment/refund/user/trip) dan **export** untuk compliance + investigasi dispute.
---
## Status delivery
| Phase | Status | Catatan |
|---|---|---|
| Phase 1 — Filter & Search Enhancements | ✅ Delivered | Filter date range + reviewer di refunds/payouts/verifications via `AdminFilterBar` reusable. Reason filter di refunds. |
| Phase 2 — Global Search | ✅ Delivered | Search bar di sidebar admin dispatch by pattern (email/order_id/cuid/fuzzy). Endpoint `/api/admin/search`. |
| Phase 3 — CSV Export | ✅ Delivered | 3 endpoint export (refunds/payouts/verifications) dengan UTF-8 BOM untuk Excel. Tombol "⬇️ Export CSV" di tiap halaman list. |
| Phase 4 — Generic Admin Audit Log | ✅ Delivered | Model `AdminActionLog` (polymorphic, append-only). Helper `auditLog.record()` di-wire ke semua admin server action. Halaman `/admin/audit-log` dengan filter. |
---
## Phase 1 — Filter & Search Enhancements ✅
| # | Item | Status | File |
|---|---|---|---|
| 1.1 | Filter date range (`dateFrom`, `dateTo`) di `/admin/refunds` | ✅ | [app/admin/refunds/page.tsx](../../app/admin/refunds/page.tsx) |
| 1.2 | Filter `reviewer` (admin email dropdown) di `/admin/refunds` | ✅ | [app/admin/refunds/page.tsx](../../app/admin/refunds/page.tsx) |
| 1.3 | Filter `reason` di `/admin/refunds` | ✅ | [app/admin/refunds/page.tsx](../../app/admin/refunds/page.tsx) |
| 1.4 | Filter date range + `processor` di `/admin/payouts` | ✅ | [app/admin/payouts/page.tsx](../../app/admin/payouts/page.tsx) |
| 1.5 | Filter date range + `reviewer` di `/admin/verifications` | ✅ | [app/admin/verifications/page.tsx](../../app/admin/verifications/page.tsx) |
| 1.6 | Komponen reusable `AdminFilterBar` | ✅ | [features/admin/components/admin-filter-bar.tsx](../../features/admin/components/admin-filter-bar.tsx) |
| 1.7 | Filter params di `refundRepo`/`payoutRepo`/`organizerRepo` `listByStatus` | ✅ | `server/repositories/*.ts` |
| 1.8 | Helper `listAdminEmails()` untuk dropdown reviewer | ✅ | [lib/admin.ts](../../lib/admin.ts) |
---
## Phase 2 — Global Search ✅
| # | Item | Status | File |
|---|---|---|---|
| 2.1 | `adminSearchService.resolve(q)` — dispatch by pattern (email exact, order_id prefix, cuid, fuzzy) | ✅ | [server/services/admin-search.service.ts](../../server/services/admin-search.service.ts) |
| 2.2 | Route handler `/api/admin/search?q=...` (guard isAdmin) | ✅ | [app/api/admin/search/route.ts](../../app/api/admin/search/route.ts) |
| 2.3 | Component `AdminSearchBar` — debounced 250ms, dropdown hasil dengan type badge | ✅ | [features/admin/components/admin-search-bar.tsx](../../features/admin/components/admin-search-bar.tsx) |
| 2.4 | Wire di admin sidebar (di bawah logo header) | ✅ | [components/admin/admin-sidebar.tsx](../../components/admin/admin-sidebar.tsx) |
| 2.5 | Page `/admin/search?q=...` full results | ⏳ | Skip — dropdown limit 10 hit cukup; jarang butuh full page. |
---
## Phase 3 — CSV Export ✅
| # | Item | Status | File |
|---|---|---|---|
| 3.1 | Helper `lib/csv.ts``buildCsv`, `escapeCsvCell`, `csvResponse` dengan UTF-8 BOM | ✅ | [lib/csv.ts](../../lib/csv.ts) |
| 3.2 | Route `/api/admin/export/refunds` — pakai filter dari query string | ✅ | [app/api/admin/export/refunds/route.ts](../../app/api/admin/export/refunds/route.ts) |
| 3.3 | Route `/api/admin/export/payouts` | ✅ | [app/api/admin/export/payouts/route.ts](../../app/api/admin/export/payouts/route.ts) |
| 3.4 | Route `/api/admin/export/verifications` — TANPA NIK/KTP key/bank account number (privasi) | ✅ | [app/api/admin/export/verifications/route.ts](../../app/api/admin/export/verifications/route.ts) |
| 3.5 | Komponen `ExportCsvLink` + tombol di tiap admin list page (filter preserved) | ✅ | [features/admin/components/export-csv-link.tsx](../../features/admin/components/export-csv-link.tsx) |
**Tindakan manual:** test di staging dulu — pastikan tidak ada data sensitif yang ter-leak (NIK plaintext, foto KYC key, dst).
---
## Phase 4 — Generic Admin Audit Log ✅
| # | Item | Status | File |
|---|---|---|---|
| 4.1 | Model `AdminActionLog` (polymorphic, append-only) + migration | ✅ | [prisma/schema.prisma](../../prisma/schema.prisma) + `prisma/migrations/20260518180000_add_admin_action_log/` |
| 4.2 | Helper `auditLog.record({ admin, action, entityType, entityId, payload? })` | ✅ | [server/services/audit-log.service.ts](../../server/services/audit-log.service.ts) |
| 4.3 | Wire di semua admin server action: refund approve/reject/mark/create, payout markPaid, verification approve/reject/reopen, trip admin-cancel, payment reconcile, user suspend/unsuspend | ✅ | `features/*/actions.ts` |
| 4.4 | Page `/admin/audit-log` dengan filter (date range, admin email, entity type, action contains) + pagination basic (max 200) | ✅ | [app/admin/audit-log/page.tsx](../../app/admin/audit-log/page.tsx) |
| 4.5 | Link "Audit Log" di sidebar | ✅ | [components/admin/admin-sidebar.tsx](../../components/admin/admin-sidebar.tsx) |
**Daftar action yang ter-log:**
| Action | Entity | Source |
|---|---|---|
| `USER_SUSPEND` / `USER_UNSUSPEND` | User | [features/admin/actions.ts](../../features/admin/actions.ts) |
| `TRIP_ADMIN_CANCEL` | Trip | [features/trip/actions.ts](../../features/trip/actions.ts) |
| `PAYMENT_RECONCILE` | Payment (orderId) | [features/booking/actions.ts](../../features/booking/actions.ts) |
| `VERIFICATION_APPROVE` / `VERIFICATION_REJECT` / `VERIFICATION_REOPEN` | OrganizerVerification | [features/organizer/actions.ts](../../features/organizer/actions.ts) |
| `REFUND_CREATE` / `REFUND_APPROVE` / `REFUND_REJECT` / `REFUND_SUCCEEDED` / `REFUND_FAILED` | Refund | [features/refund/actions.ts](../../features/refund/actions.ts) |
| `PAYOUT_MARK_PAID` | Payout | [features/payout/actions.ts](../../features/payout/actions.ts) |
`adminId` nullable + `adminEmail` snapshot — log entry tetap auditable kalau admin dihapus.
**Tindakan manual ops:**
1. Apply migration: `npx prisma migrate deploy`.
2. Brief admin: setiap aksi mereka di panel akan tercatat di `/admin/audit-log` dengan email mereka — pakai sebagai bukti compliance saat audit eksternal.
+34 -16
View File
@@ -1,6 +1,6 @@
# Setrip — Admin System Health Roadmap (ARCHIVED — DELIVERED 2026-05-18, partial) # Setrip — Admin System Health Roadmap (ARCHIVED — DELIVERED 2026-05-18)
Admin perlu visibilitas atas job otomatis (cron). Phase 1 (cron log foundation) delivered. Phase 2 (status page) bonus. Admin perlu visibilitas atas job otomatis (cron) dan alert untuk state stale.
--- ---
@@ -9,9 +9,9 @@ Admin perlu visibilitas atas job otomatis (cron). Phase 1 (cron log foundation)
| Phase | Status | Catatan | | Phase | Status | Catatan |
|---|---|---| |---|---|---|
| Phase 1 — Cron Run Log | ✅ Delivered | Model `CronRun`, helper `runCron()`, wire ke cron existing. | | Phase 1 — Cron Run Log | ✅ Delivered | Model `CronRun`, helper `runCron()`, wire ke cron existing. |
| Phase 2 — System Status Page | ✅ Delivered (bonus) | `/admin/system` tampilkan summary cron + 20 run terakhir + health badge (ok/stale/failed). | | Phase 2 — System Status Page | ✅ Delivered | `/admin/system` tampilkan summary cron + 20 run terakhir + health badge. |
| Phase 3 — Stale State Alerts | Deferred | Belum perlu sampai ada incident. | | Phase 3 — Stale State Alerts | Delivered | `systemHealthService.detectStale()` cek 4 kategori (Payment AWAITING > 25h, AWAITING_PAY past departure, Payout HELD overdue, Refund APPROVED > 7d). Banner di `/admin/system`. |
| Phase 4 — External Alerting (Discord) | Deferred | Skip kecuali admin sering miss banner. | | Phase 4 — Discord Webhook Notify | Delivered | `notifyAdmins()` POST ke `ADMIN_ALERT_WEBHOOK_URL`. Trigger otomatis saat cron FAILED via `runCron`. |
--- ---
@@ -22,27 +22,45 @@ Admin perlu visibilitas atas job otomatis (cron). Phase 1 (cron log foundation)
| 1.1 | Model `CronRun` + enum `CronRunStatus` + migration | ✅ | [prisma/schema.prisma](../../prisma/schema.prisma) + `prisma/migrations/20260518170000_add_cron_run/` | | 1.1 | Model `CronRun` + enum `CronRunStatus` + migration | ✅ | [prisma/schema.prisma](../../prisma/schema.prisma) + `prisma/migrations/20260518170000_add_cron_run/` |
| 1.2 | Helper `runCron(jobName, fn)` — auto create RUNNING row → SUCCESS/FAILED | ✅ | [lib/cron-runner.ts](../../lib/cron-runner.ts) | | 1.2 | Helper `runCron(jobName, fn)` — auto create RUNNING row → SUCCESS/FAILED | ✅ | [lib/cron-runner.ts](../../lib/cron-runner.ts) |
| 1.3 | Wire `runCron` di `auto-complete-trips` cron | ✅ | [app/api/cron/auto-complete-trips/route.ts](../../app/api/cron/auto-complete-trips/route.ts) | | 1.3 | Wire `runCron` di `auto-complete-trips` cron | ✅ | [app/api/cron/auto-complete-trips/route.ts](../../app/api/cron/auto-complete-trips/route.ts) |
| 1.4 | Wire `runCron` di cron payout release | ⏳ | Defer — `releaseEligible` saat ini di-call dari cron yang sama, sudah ter-wrap. |
| 1.5 | Wire `runCron` di cron lain (refund sweep, dst) saat ditambah | ⏳ | Tidak ada cron lain saat ini. |
--- ---
## Phase 2 — System Status Page ✅ (bonus) ## Phase 2 — System Status Page ✅
| # | Item | Status | File | | # | Item | Status | File |
|---|---|---|---| |---|---|---|---|
| 2.1 | Per-job summary: last run, last success, count 7d, error count 7d | ✅ | [app/admin/system/page.tsx](../../app/admin/system/page.tsx) | | 2.1 | Per-job summary: last run, last success, count 7d, error count 7d | ✅ | [app/admin/system/page.tsx](../../app/admin/system/page.tsx) |
| 2.2 | 20 cron run terakhir di table bawah | ✅ | [app/admin/system/page.tsx](../../app/admin/system/page.tsx) | | 2.2 | 20 cron run terakhir di table bawah | ✅ | [app/admin/system/page.tsx](../../app/admin/system/page.tsx) |
| 2.3 | Health badge (🟢 OK < 25 jam, 🟡 STALE, 🔴 FAILED) | ✅ | [app/admin/system/page.tsx](../../app/admin/system/page.tsx) | | 2.3 | Health badge (🟢 OK < 25h, 🟡 STALE, 🔴 FAILED) | ✅ | [app/admin/system/page.tsx](../../app/admin/system/page.tsx) |
| 2.4 | Link "System" di admin navbar | ✅ | [components/admin/admin-sidebar.tsx](../../components/admin/admin-sidebar.tsx) | | 2.4 | Link "System" di admin navbar | ✅ | [components/admin/admin-sidebar.tsx](../../components/admin/admin-sidebar.tsx) |
**Tindakan manual ops:**
1. Apply migration: `npx prisma migrate deploy`.
2. Setelah cron berikutnya jalan, cek `/admin/system` untuk lihat entry pertama.
3. Saat menambah cron route handler baru, daftarkan jobName di `TRACKED_JOBS` di `app/admin/system/page.tsx`.
--- ---
## Phase 3-4 ⏳ (deferred) ## Phase 3 — Stale State Alerts ✅
Stale state alerts + Discord webhook. Lihat versi awal di [ADMIN_ROADMAP.md](../../ADMIN_ROADMAP.md). | # | Item | Status | File |
|---|---|---|---|
| 3.1 | `systemHealthService.detectStale()` return 4 count | ✅ | [server/services/system-health.service.ts](../../server/services/system-health.service.ts) |
| 3.2 | Banner alerts kuning di `/admin/system` kalau ada count > 0 | ✅ | [app/admin/system/page.tsx](../../app/admin/system/page.tsx) |
| 3.3 | Link tiap alert ke filtered list page yang relevan | ✅ (untuk Payout HELD & Refund APPROVED) | [app/admin/system/page.tsx](../../app/admin/system/page.tsx) |
**Threshold draft (review setelah jalan 1-2 minggu):**
- Payment MIDTRANS `AWAITING` createdAt > 25 jam — suspect webhook tertunda
- Booking `AWAITING_PAY` dengan trip.date < today — peserta lupa bayar
- Payout `HELD` dengan heldUntil > 1 hari lewat — cron release tidak jalan
- Refund `APPROVED` reviewedAt > 7 hari — admin lupa process
---
## Phase 4 — Discord Webhook Notify ✅
| # | Item | Status | File |
|---|---|---|---|
| 4.1 | Helper `notifyAdmins(message)` — POST ke Discord webhook URL dari env | ✅ | [lib/admin-notify.ts](../../lib/admin-notify.ts) |
| 4.2 | Trigger notify di `runCron` saat FAILED (fire-and-forget) | ✅ | [lib/cron-runner.ts](../../lib/cron-runner.ts) |
| 4.3 | Trigger notify dari `systemHealthService.detectStale` rate-limited | ⏳ | Skip — admin sudah lihat banner di `/admin/system` saat buka pagi. Push notif harian baru worth it kalau admin sering miss; bisa ditambah belakangan. |
**Tindakan manual ops:**
1. Apply migration (sudah di Phase 1).
2. (Opsional) Buat Discord channel internal + webhook URL → set env `ADMIN_ALERT_WEBHOOK_URL` di PM2/server. Tanpa env, `notifyAdmins` no-op.
3. Test alert: trigger cron secara sengaja fail (mis. matikan DB sebentar) → cek Discord channel menerima 🚨 message.
+8 -4
View File
@@ -1,4 +1,4 @@
# Setrip — Admin User Management Roadmap (ARCHIVED — DELIVERED 2026-05-18) # Setrip — Admin User Management Roadmap (ARCHIVED — DELIVERED 2026-05-18, fully done)
Admin perlu bisa cari user dan **suspend** akun yang melakukan abuse (scam, harassment, fake review). Admin perlu bisa cari user dan **suspend** akun yang melakukan abuse (scam, harassment, fake review).
@@ -12,7 +12,7 @@ Admin perlu bisa cari user dan **suspend** akun yang melakukan abuse (scam, hara
|---|---|---| |---|---|---|
| Phase 1 — User List & Detail | ✅ Delivered | Search by email/name, filter tab (ALL/ACTIVE/SUSPENDED), stats (trip dibuat, booking, total spent). | | Phase 1 — User List & Detail | ✅ Delivered | Search by email/name, filter tab (ALL/ACTIVE/SUSPENDED), stats (trip dibuat, booking, total spent). |
| Phase 2 — User Suspension | ✅ Delivered | Schema baru `User.suspended`, auth gate sign-in + helper `requireActiveUser` di mutating actions, trip public list otomatis sembunyikan organizer suspended. | | Phase 2 — User Suspension | ✅ Delivered | Schema baru `User.suspended`, auth gate sign-in + helper `requireActiveUser` di mutating actions, trip public list otomatis sembunyikan organizer suspended. |
| Phase 3 — User Analytics | Deferred | Skip MVP — tidak ada use case konkret. | | Phase 3 — User Analytics | Delivered | Page `/admin/users/stats` dengan stats card (total/suspended/verified-organizer/active-organizer-30d/paid-participant-30d) + bar chart signup per minggu (8 minggu terakhir, inline SVG-free). |
--- ---
@@ -50,6 +50,10 @@ Admin perlu bisa cari user dan **suspend** akun yang melakukan abuse (scam, hara
--- ---
## Phase 3 — User Analytics ⏳ (deferred) ## Phase 3 — User Analytics
Skip sampai growth team minta. Lihat versi awal di [ADMIN_ROADMAP.md](../../ADMIN_ROADMAP.md). | # | Item | Status | File |
|---|---|---|---|
| 3.1 | Stats: total users, suspended, verified organizers, active organizer 30d (bikin trip), paid participant 30d | ✅ | [app/admin/users/stats/page.tsx](../../app/admin/users/stats/page.tsx) |
| 3.2 | Bar chart signup per minggu (8 minggu terakhir, pakai inline div height % — no chart library) | ✅ | [app/admin/users/stats/page.tsx](../../app/admin/users/stats/page.tsx) |
| 3.3 | Link "📊 Stats" di header `/admin/users` | ✅ | [app/admin/users/page.tsx](../../app/admin/users/page.tsx) |
+48 -16
View File
@@ -1,6 +1,6 @@
# Setrip — Admin Verification Roadmap (ARCHIVED — DELIVERED 2026-05-18, partial) # Setrip — Admin Verification Roadmap (ARCHIVED — DELIVERED 2026-05-18)
Enhancement KYC organizer verification: reopen REJECTED, request re-upload, audit override. Enhancement KYC organizer verification: reopen REJECTED, request re-upload, history, manual override.
--- ---
@@ -8,27 +8,59 @@ Enhancement KYC organizer verification: reopen REJECTED, request re-upload, audi
| Phase | Status | Catatan | | Phase | Status | Catatan |
|---|---|---| |---|---|---|
| Phase 1 — Reopen Rejected | ✅ Delivered | Tombol "Buka kembali ke PENDING" di REJECTED card dengan note wajib min 10 char. | | Phase 1 — Reopen Rejected | ✅ Delivered | Tombol "Buka kembali ke PENDING" di REJECTED card dengan note wajib. |
| Phase 2 — Re-upload Request | Deferred | Butuh schema + organizer-side UI; skip MVP. | | Phase 2 — Re-upload Request | Delivered | Admin pilih checkbox field (KTP/liveness/NIK/bank/alamat) + note. Organizer dapat banner kuning di `/verify` dengan highlight field yang diminta. Auto-clear saat submit ulang. |
| Phase 3 — Verification History | Deferred | Skip. | | Phase 3 — Submission History | Delivered | Field `submissionCount` di-bump tiap submit ulang. `previousRejections` JSON array menyimpan rejection lama (waktu + reason + submission ke-N) sebelum overwrite. |
| Phase 4 — Manual Override | Deferred | Skip. | | Phase 4 — Manual Override | Delivered | Admin verify user tanpa upload KYC (partner trusted). Flag `isManualOverride = true` untuk audit transparansi. UI di `/admin/users/[id]`. |
--- ---
## Phase 1 — Reopen Rejected Verification ## Phase 1 — Reopen Rejected ✅
| # | Item | Status | File | | # | Item | Status | File |
|---|---|---|---| |---|---|---|---|
| 1.1 | `organizerService.reopenVerification(verifId, adminId, note)` — set PENDING, clear review fields, simpan note di rejectionReason | ✅ | [server/services/organizer.service.ts](../../server/services/organizer.service.ts) | | 1.1 | `organizerService.reopenVerification` | ✅ | [server/services/organizer.service.ts](../../server/services/organizer.service.ts) |
| 1.2 | `organizerRepo.reopen(id, note)` | ✅ | [server/repositories/organizer.repo.ts](../../server/repositories/organizer.repo.ts) | | 1.2 | `organizerRepo.reopen` | ✅ | [server/repositories/organizer.repo.ts](../../server/repositories/organizer.repo.ts) |
| 1.3 | Server action `reopenVerificationAction` (guard isAdmin) | ✅ | [features/organizer/actions.ts](../../features/organizer/actions.ts) | | 1.3 | `reopenVerificationAction` (guard isAdmin + audit log) | ✅ | [features/organizer/actions.ts](../../features/organizer/actions.ts) |
| 1.4 | UI: tombol "🔄 Buka kembali ke PENDING" di REJECTED card + textarea note wajib | ✅ | [features/organizer/components/review-card.tsx](../../features/organizer/components/review-card.tsx) | | 1.4 | UI tombol di REJECTED card | ✅ | [features/organizer/components/review-card.tsx](../../features/organizer/components/review-card.tsx) |
**Tindakan manual ops:**
1. Brief admin: koordinasi dengan organizer dulu via email/WA sebelum reopen (pastikan organizer siap submit ulang foto/data). Note wajib menjelaskan alasan reopen untuk audit trail.
--- ---
## Phase 2-4 ⏳ (deferred) ## Phase 2 — Re-upload Request ✅
Lihat versi awal di [ADMIN_ROADMAP.md](../../ADMIN_ROADMAP.md). Akan diangkat kembali kalau ada kebutuhan konkret (banyak re-upload, partnership program butuh manual override, dst). | # | Item | Status | File |
|---|---|---|---|
| 2.1 | Migration: `reuploadRequested`, `reuploadFields String[]`, `reuploadNote` | ✅ | `prisma/migrations/20260518190000_verification_enhancements/` |
| 2.2 | `organizerService.requestReupload(verifId, adminId, fields, note)` + `REUPLOAD_FIELDS` enum-like const | ✅ | [server/services/organizer.service.ts](../../server/services/organizer.service.ts) |
| 2.3 | `organizerRepo.requestReupload` (set status PENDING + flag + clear review) | ✅ | [server/repositories/organizer.repo.ts](../../server/repositories/organizer.repo.ts) |
| 2.4 | `requestReuploadAction` (guard isAdmin + audit log) | ✅ | [features/organizer/actions.ts](../../features/organizer/actions.ts) |
| 2.5 | UI admin di PENDING card: tombol "🔄 Minta re-upload" + multi-checkbox field + note | ✅ | [features/organizer/components/review-card.tsx](../../features/organizer/components/review-card.tsx) |
| 2.6 | Banner kuning di `/verify` saat `reuploadRequested = true` + list field yang diminta | ✅ | [app/(public)/verify/page.tsx](../../app/(public)/verify/page.tsx) |
| 2.7 | Auto-clear flag saat organizer submit ulang (logic di `submitVerification`) | ✅ | [server/services/organizer.service.ts](../../server/services/organizer.service.ts) |
---
## Phase 3 — Submission History ✅
| # | Item | Status | File |
|---|---|---|---|
| 3.1 | Migration: `submissionCount Int @default(1)`, `previousRejections Json?` | ✅ | `prisma/migrations/20260518190000_verification_enhancements/` |
| 3.2 | Bump `submissionCount` + archive rejection lama saat submit ulang (helper `buildArchivedRejections`) | ✅ | [server/services/organizer.service.ts](../../server/services/organizer.service.ts) |
> _Catatan: snapshot full data (Phase 3.1 di roadmap awal) di-skip — `submissionCount` + `previousRejections` (array waktu/reason) cukup untuk audit "berapa kali verify, apa reason ditolak sebelumnya". UI history detail bisa ditambah saat ada permintaan konkret._
---
## Phase 4 — Manual Override ✅
| # | Item | Status | File |
|---|---|---|---|
| 4.1 | Migration: `isManualOverride Boolean @default(false)`, `manualOverrideById`, `manualOverrideNote` | ✅ | `prisma/migrations/20260518190000_verification_enhancements/` |
| 4.2 | `organizerService.manualOverrideVerification(input)` — bikin row APPROVED dengan placeholder KYC | ✅ | [server/services/organizer.service.ts](../../server/services/organizer.service.ts) |
| 4.3 | `organizerRepo.createManualOverride` | ✅ | [server/repositories/organizer.repo.ts](../../server/repositories/organizer.repo.ts) |
| 4.4 | `manualOverrideVerificationAction` (guard isAdmin + audit log) | ✅ | [features/organizer/actions.ts](../../features/organizer/actions.ts) |
| 4.5 | UI: `ManualVerifyButton` di `/admin/users/[id]` (hanya tampil kalau user belum punya verification) | ✅ | [features/admin/components/manual-verify-button.tsx](../../features/admin/components/manual-verify-button.tsx) |
**Tindakan manual ops:**
1. Apply migration: `npx prisma migrate deploy`.
2. Brief admin: re-upload request lebih lembut dari reject (organizer tidak perlu ulang dari nol). Manual override hanya untuk partner trusted dengan ref konkret di note (mis. nomor kontrak).
+54
View File
@@ -5,6 +5,9 @@ import { revalidatePath } from "next/cache";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin"; import { isAdminEmail } from "@/lib/admin";
import { userService } from "@/server/services/user.service"; import { userService } from "@/server/services/user.service";
import { auditLog } from "@/server/services/audit-log.service";
import { emailService } from "@/lib/email/send";
import { userRepo } from "@/server/repositories/user.repo";
export async function suspendUserAction(userId: string, reason: string) { export async function suspendUserAction(userId: string, reason: string) {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
@@ -21,6 +24,17 @@ export async function suspendUserAction(userId: string, reason: string) {
adminId: session.user.id, adminId: session.user.id,
reason, reason,
}); });
await auditLog.record({
admin: { id: session.user.id, email: session.user.email },
action: "USER_SUSPEND",
entityType: "User",
entityId: userId,
payload: { reason: reason.trim() },
});
// Notif email user — due process: kasih tahu alasan + cara appeal.
void notifySuspended(userId, reason.trim());
revalidatePath("/admin/users"); revalidatePath("/admin/users");
revalidatePath(`/admin/users/${userId}`); revalidatePath(`/admin/users/${userId}`);
return { success: true as const }; return { success: true as const };
@@ -29,6 +43,36 @@ export async function suspendUserAction(userId: string, reason: string) {
} }
} }
async function notifySuspended(userId: string, reason: string) {
const target = await userRepo.findById(userId);
if (!target) return;
await emailService.send({
to: target.email,
// Suspend bisa di-trigger berulang — sertakan timestamp supaya tiap suspend
// baru dapat email baru.
idempotencyKey: `account_suspended-${userId}-${Date.now()}`,
template: {
template: "account_suspended",
data: { userName: target.name, reason },
},
});
}
/** E3.8 — kabari user kalau penangguhan akunnya sudah dicabut. */
async function notifyUnsuspended(userId: string) {
const target = await userRepo.findById(userId);
if (!target) return;
await emailService.send({
to: target.email,
// Sertakan timestamp supaya tiap siklus suspend→unsuspend dapat email baru.
idempotencyKey: `account_unsuspended-${userId}-${Date.now()}`,
template: {
template: "account_unsuspended",
data: { userName: target.name },
},
});
}
export async function unsuspendUserAction(userId: string) { export async function unsuspendUserAction(userId: string) {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session?.user) { if (!session?.user) {
@@ -40,6 +84,16 @@ export async function unsuspendUserAction(userId: string) {
try { try {
await userService.unsuspendUser({ userId, adminId: session.user.id }); await userService.unsuspendUser({ userId, adminId: session.user.id });
await auditLog.record({
admin: { id: session.user.id, email: session.user.email },
action: "USER_UNSUSPEND",
entityType: "User",
entityId: userId,
});
// Notif email user — kabari akun sudah aktif kembali.
void notifyUnsuspended(userId);
revalidatePath("/admin/users"); revalidatePath("/admin/users");
revalidatePath(`/admin/users/${userId}`); revalidatePath(`/admin/users/${userId}`);
return { success: true as const }; return { success: true as const };
@@ -1,3 +1,5 @@
import { DateField } from "@/components/shared/date-picker";
interface AdminFilterBarProps { interface AdminFilterBarProps {
/** URL base (mis. `/admin/refunds`) yang menerima query params. */ /** URL base (mis. `/admin/refunds`) yang menerima query params. */
action: string; action: string;
@@ -45,12 +47,11 @@ export function AdminFilterBar({
> >
Dari tanggal Dari tanggal
</label> </label>
<input <DateField
id="filter-dateFrom" id="filter-dateFrom"
name="dateFrom" name="dateFrom"
type="date" defaultValueYmd={values.dateFrom}
defaultValue={values.dateFrom ?? ""} placeholder="Dari tanggal"
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>
@@ -61,12 +62,11 @@ export function AdminFilterBar({
> >
Sampai tanggal Sampai tanggal
</label> </label>
<input <DateField
id="filter-dateTo" id="filter-dateTo"
name="dateTo" name="dateTo"
type="date" defaultValueYmd={values.dateTo}
defaultValue={values.dateTo ?? ""} placeholder="Sampai tanggal"
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>
@@ -0,0 +1,131 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
interface Hit {
type: "user" | "trip" | "booking";
id: string;
title: string;
subtitle: string;
href: string;
}
/**
* Search bar global untuk admin sidebar. Debounced 250ms supaya tidak spam
* server. Hits dispatch berdasarkan pola input — lihat
* `adminSearchService.resolve` di server.
*/
export function AdminSearchBar() {
const [query, setQuery] = useState("");
const [hits, setHits] = useState<Hit[]>([]);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const wrapperRef = useRef<HTMLDivElement | null>(null);
// Debounced fetch — guard inside async block supaya tidak setState langsung
// di effect synchronous (react-hooks/set-state-in-effect).
useEffect(() => {
const q = query.trim();
const controller = new AbortController();
const timer = setTimeout(() => {
if (q.length < 2) {
setHits([]);
setLoading(false);
return;
}
setLoading(true);
fetch(`/api/admin/search?q=${encodeURIComponent(q)}`, {
signal: controller.signal,
})
.then((res) => (res.ok ? res.json() : { hits: [] }))
.then((json: { hits: Hit[] }) => {
setHits(json.hits ?? []);
})
.catch(() => setHits([]))
.finally(() => setLoading(false));
}, 250);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [query]);
// Close dropdown on outside click
useEffect(() => {
function onClick(e: MouseEvent) {
if (
wrapperRef.current &&
!wrapperRef.current.contains(e.target as Node)
) {
setOpen(false);
}
}
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, []);
return (
<div ref={wrapperRef} className="relative">
<input
type="search"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
placeholder="Cari email, ID, order_id, judul..."
className="w-full rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 text-xs text-neutral-800 placeholder:text-neutral-400 focus:border-primary-400 focus:bg-white"
/>
{open && query.trim().length >= 2 && (
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-80 overflow-y-auto rounded-xl border border-neutral-200 bg-white shadow-xl">
{loading && (
<p className="px-3 py-2 text-[11px] text-neutral-500">Mencari...</p>
)}
{!loading && hits.length === 0 && (
<p className="px-3 py-2 text-[11px] text-neutral-500">
Tidak ada hasil.
</p>
)}
{!loading && hits.length > 0 && (
<ul className="py-1">
{hits.map((h) => (
<li key={`${h.type}-${h.id}`}>
<Link
href={h.href}
onClick={() => {
setOpen(false);
setQuery("");
}}
className="flex items-center gap-2 px-3 py-2 hover:bg-neutral-50"
>
<span
className={`rounded-full px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wide ${
h.type === "user"
? "bg-primary-100 text-primary-700"
: h.type === "trip"
? "bg-secondary-100 text-secondary-700"
: "bg-amber-100 text-amber-700"
}`}
>
{h.type}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-semibold text-neutral-800">
{h.title}
</p>
<p className="truncate text-[10px] text-neutral-500">
{h.subtitle}
</p>
</div>
</Link>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,31 @@
import { Download } from "lucide-react";
interface ExportCsvLinkProps {
/** URL endpoint export, mis. `/api/admin/export/refunds`. */
href: string;
/** Query string current filter (tanpa leading `?`). */
query?: string;
label?: string;
}
/**
* Tombol download CSV — anchor biasa supaya browser tangani download via
* `Content-Disposition: attachment` header dari server.
*/
export function ExportCsvLink({
href,
query,
label = "Export CSV",
}: ExportCsvLinkProps) {
const url = query ? `${href}?${query}` : href;
return (
<a
href={url}
className="inline-flex items-center gap-1.5 rounded-xl border border-neutral-200 bg-white px-3 py-1.5 text-xs font-semibold text-neutral-700 hover:bg-neutral-50"
download
>
<Download size={16} strokeWidth={2} aria-hidden />
<span>{label}</span>
</a>
);
}
@@ -0,0 +1,141 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Lock } from "lucide-react";
import { manualOverrideVerificationAction } from "@/features/organizer/actions";
interface ManualVerifyButtonProps {
userId: string;
defaultBankAccountName: string;
}
export function ManualVerifyButton({
userId,
defaultBankAccountName,
}: ManualVerifyButtonProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [note, setNote] = useState("");
const [bankName, setBankName] = useState("");
const [bankAccountNumber, setBankAccountNumber] = useState("");
const [bankAccountName, setBankAccountName] = useState(defaultBankAccountName);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function handleSubmit() {
setLoading(true);
setError("");
const res = await manualOverrideVerificationAction({
userId,
note,
bankName,
bankAccountNumber,
bankAccountName,
});
setLoading(false);
if ("error" in res && res.error) {
setError(res.error);
return;
}
setOpen(false);
setNote("");
setBankName("");
setBankAccountNumber("");
router.refresh();
}
if (!open) {
return (
<button
type="button"
onClick={() => setOpen(true)}
className="inline-flex items-center gap-1.5 rounded-xl border border-secondary-300 bg-white px-4 py-2 text-sm font-bold text-secondary-700 hover:bg-secondary-50"
>
<Lock size={18} strokeWidth={2} aria-hidden />
Manual verify (tanpa KYC)
</button>
);
}
return (
<div className="space-y-3 rounded-xl border border-secondary-200 bg-secondary-50/60 p-4">
<p className="text-xs text-secondary-900">
Manual override: bikin verifikasi APPROVED tanpa upload KYC. Pakai HANYA
untuk partner trusted referral atau kasus khusus. Ter-flag jelas di
admin UI sebagai &quot;manual override&quot;.
</p>
<div>
<label className="mb-1 block text-xs font-semibold text-secondary-900">
Alasan / referensi (min 10 char)
</label>
<textarea
rows={2}
value={note}
onChange={(e) => setNote(e.target.value)}
maxLength={500}
placeholder="contoh: Partner referral dari acara X, kontrak signed #PR-2026-15."
className="w-full rounded-xl border border-secondary-200 bg-white px-3 py-2 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-secondary-400"
/>
</div>
<div className="grid gap-2 sm:grid-cols-3">
<input
type="text"
value={bankName}
onChange={(e) => setBankName(e.target.value)}
placeholder="Nama bank"
className="rounded-lg border border-secondary-200 bg-white px-3 py-1.5 text-sm focus:border-secondary-400"
/>
<input
type="text"
value={bankAccountNumber}
onChange={(e) => setBankAccountNumber(e.target.value)}
placeholder="Nomor rekening"
className="rounded-lg border border-secondary-200 bg-white px-3 py-1.5 text-sm focus:border-secondary-400"
/>
<input
type="text"
value={bankAccountName}
onChange={(e) => setBankAccountName(e.target.value)}
placeholder="Atas nama"
className="rounded-lg border border-secondary-200 bg-white px-3 py-1.5 text-sm focus:border-secondary-400"
/>
</div>
{error && (
<div className="rounded-lg bg-red-100 px-3 py-2 text-xs font-medium text-red-700">
{error}
</div>
)}
<div className="flex gap-2">
<button
type="button"
onClick={handleSubmit}
disabled={
loading ||
note.trim().length < 10 ||
!bankName.trim() ||
!bankAccountNumber.trim() ||
!bankAccountName.trim()
}
className="rounded-xl bg-secondary-600 px-4 py-2 text-sm font-bold text-white hover:bg-secondary-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? "Memproses..." : "Konfirmasi Manual Verify"}
</button>
<button
type="button"
onClick={() => {
setOpen(false);
setNote("");
}}
className="rounded-xl border border-neutral-200 bg-white px-4 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-50"
>
Batal
</button>
</div>
</div>
);
}
+8
View File
@@ -81,6 +81,14 @@ export async function adminReconcileMidtransAction(orderId: string) {
} }
return { error: "Status pembayaran tidak cocok dengan tagihan" }; return { error: "Status pembayaran tidak cocok dengan tagihan" };
} }
const { auditLog } = await import("@/server/services/audit-log.service");
await auditLog.record({
admin: { id: session.user.id, email: session.user.email },
action: "PAYMENT_RECONCILE",
entityType: "Payment",
entityId: orderId,
payload: { outcome: result.status },
});
return { success: true as const, status: result.status }; return { success: true as const, status: result.status };
} catch (err) { } catch (err) {
return { error: (err as Error).message }; return { error: (err as Error).message };
@@ -2,6 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Check } from "lucide-react";
import { adminReconcileMidtransAction } from "@/features/booking/actions"; import { adminReconcileMidtransAction } from "@/features/booking/actions";
interface AdminReconcileButtonProps { interface AdminReconcileButtonProps {
@@ -45,8 +46,9 @@ export function AdminReconcileButton({
{loading ? "Reconciling..." : "Reconcile Midtrans"} {loading ? "Reconciling..." : "Reconcile Midtrans"}
</button> </button>
{status && ( {status && (
<span className="text-[11px] font-medium text-emerald-700"> <span className="inline-flex items-center gap-1 text-[11px] font-medium text-emerald-700">
{reconcileOutcomeLabel(status)} <Check size={12} strokeWidth={2.5} aria-hidden />
{reconcileOutcomeLabel(status)}
</span> </span>
)} )}
{error && ( {error && (
@@ -2,6 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { CircleAlert } from "lucide-react";
import { cancelBookingWithRefundAction } from "@/features/booking/actions"; import { cancelBookingWithRefundAction } from "@/features/booking/actions";
import { formatRupiah } from "@/lib/utils"; import { formatRupiah } from "@/lib/utils";
@@ -112,9 +113,17 @@ export function CancelBookingButton({ tripId, preview }: CancelBookingButtonProp
Tier: {preview.tierLabel} Tier: {preview.tierLabel}
</p> </p>
{noRefund ? ( {noRefund ? (
<p className="mt-2 text-xs text-red-700"> <p className="mt-2 flex items-start gap-1.5 text-xs text-red-700">
Di luar window refund uang tidak dikembalikan. Booking akan <CircleAlert
size={14}
strokeWidth={2}
aria-hidden
className="mt-0.5 shrink-0"
/>
<span>
Di luar window refund uang tidak dikembalikan. Booking akan
di-cancel langsung. di-cancel langsung.
</span>
</p> </p>
) : ( ) : (
<p className="mt-2 text-xs text-neutral-600"> <p className="mt-2 text-xs text-neutral-600">
+13 -2
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Check, Copy } from "lucide-react";
interface CopyButtonProps { interface CopyButtonProps {
value: string; value: string;
@@ -24,9 +25,19 @@ export function CopyButton({ value, label = "Salin" }: CopyButtonProps) {
<button <button
type="button" type="button"
onClick={handleClick} onClick={handleClick}
className="rounded-lg border border-neutral-200 bg-white px-2.5 py-1 text-xs font-medium text-neutral-600 transition-colors hover:bg-neutral-50" className="inline-flex items-center gap-1 rounded-lg border border-neutral-200 bg-white px-2.5 py-1 text-xs font-medium text-neutral-600 transition-colors hover:bg-neutral-50"
> >
{copied ? "✓ Tersalin" : label} {copied ? (
<>
<Check size={13} strokeWidth={2.5} aria-hidden className="text-emerald-600" />
Tersalin
</>
) : (
<>
<Copy size={13} strokeWidth={1.75} aria-hidden />
{label}
</>
)}
</button> </button>
); );
} }
+57
View File
@@ -0,0 +1,57 @@
"use server";
import { getServerSession } from "next-auth";
import { revalidatePath } from "next/cache";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { emailService } from "@/lib/email/send";
import { auditLog } from "@/server/services/audit-log.service";
async function requireAdmin() {
const session = await getServerSession(authOptions);
if (!session?.user || !isAdminEmail(session.user.email)) {
return null;
}
return session.user;
}
/** E5.2 — admin retry satu EmailJob yang gagal/antri, kirim ulang langsung. */
export async function retryEmailJobAction(jobId: string) {
const admin = await requireAdmin();
if (!admin) return { error: "Tidak memiliki akses admin" };
if (!jobId) return { error: "jobId tidak valid" };
const result = await emailService.retryJob(jobId);
if (!result.ok) {
return { error: result.error ?? "Gagal mengirim ulang email" };
}
await auditLog.record({
admin: { id: admin.id, email: admin.email },
action: "EMAIL_JOB_RETRY",
entityType: "EmailJob",
entityId: jobId,
});
revalidatePath("/admin/emails");
revalidatePath("/admin/system");
return { success: true as const };
}
/** E5.3 — admin resend email yang sudah pernah terkirim (mis. user lapor tidak terima). */
export async function resendEmailAction(emailSentId: string) {
const admin = await requireAdmin();
if (!admin) return { error: "Tidak memiliki akses admin" };
if (!emailSentId) return { error: "emailSentId tidak valid" };
const result = await emailService.resendEmail(emailSentId);
if (!result.ok) {
return { error: result.error ?? "Gagal mengirim ulang email" };
}
await auditLog.record({
admin: { id: admin.id, email: admin.email },
action: "EMAIL_RESEND",
entityType: "EmailSent",
entityId: emailSentId,
});
revalidatePath("/admin/emails");
return { success: true as const };
}
@@ -0,0 +1,110 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Check } from "lucide-react";
import {
retryEmailJobAction,
resendEmailAction,
} from "@/features/email/actions";
const BTN_CLS =
"rounded-lg border border-primary-200 bg-primary-50 px-2.5 py-1 text-[11px] font-semibold text-primary-700 transition-colors hover:bg-primary-100 disabled:cursor-not-allowed disabled:opacity-50";
/** E5.2 — tombol kirim ulang untuk satu EmailJob (antri / gagal). */
export function RetryEmailButton({ jobId }: { jobId: string }) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function handleRetry() {
setLoading(true);
setError("");
const res = await retryEmailJobAction(jobId);
setLoading(false);
if ("error" in res) {
setError(res.error ?? "Gagal");
return;
}
router.refresh();
}
return (
<div>
<button
type="button"
onClick={handleRetry}
disabled={loading}
className={BTN_CLS}
>
{loading ? "Mengirim…" : "Kirim ulang"}
</button>
{error && (
<p className="mt-1 max-w-50 text-[10px] text-red-600">{error}</p>
)}
</div>
);
}
/** E5.3 — tombol resend untuk email yang sudah terkirim. */
export function ResendEmailButton({
emailSentId,
disabled,
}: {
emailSentId: string;
disabled?: boolean;
}) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [done, setDone] = useState(false);
async function handleResend() {
setLoading(true);
setError("");
const res = await resendEmailAction(emailSentId);
setLoading(false);
if ("error" in res) {
setError(res.error ?? "Gagal");
return;
}
setDone(true);
router.refresh();
}
if (disabled) {
return (
<span
className="text-[10px] text-neutral-400"
title="Body email lama tidak tersimpan"
>
</span>
);
}
return (
<div>
<button
type="button"
onClick={handleResend}
disabled={loading || done}
className={`${BTN_CLS} inline-flex items-center gap-1`}
>
{loading ? (
"Mengirim…"
) : done ? (
<>
<Check size={12} strokeWidth={2.5} aria-hidden />
Terkirim
</>
) : (
"Resend"
)}
</button>
{error && (
<p className="mt-1 max-w-50 text-[10px] text-red-600">{error}</p>
)}
</div>
);
}
+226 -1
View File
@@ -4,7 +4,16 @@ import { getServerSession } from "next-auth";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin"; import { isAdminEmail } from "@/lib/admin";
import { organizerService } from "@/server/services/organizer.service"; import {
isReuploadField,
organizerService,
type ReuploadField,
} from "@/server/services/organizer.service";
import { auditLog } from "@/server/services/audit-log.service";
import { emailService } from "@/lib/email/send";
import { organizerRepo } from "@/server/repositories/organizer.repo";
import { userRepo } from "@/server/repositories/user.repo";
import { prisma } from "@/lib/prisma";
import { submitVerificationSchema, reviewVerificationSchema } from "./schemas"; import { submitVerificationSchema, reviewVerificationSchema } from "./schemas";
export async function submitVerificationAction(formData: FormData) { export async function submitVerificationAction(formData: FormData) {
@@ -35,6 +44,7 @@ export async function submitVerificationAction(formData: FormData) {
...result.data, ...result.data,
birthDate: new Date(result.data.birthDate), birthDate: new Date(result.data.birthDate),
}); });
void notifyKycSubmitted(session.user.id);
revalidatePath("/verify"); revalidatePath("/verify");
revalidatePath("/profile"); revalidatePath("/profile");
revalidatePath("/admin/verifications"); revalidatePath("/admin/verifications");
@@ -68,6 +78,23 @@ export async function reviewVerificationAction(formData: FormData) {
rejectionReason: result.data.rejectionReason, rejectionReason: result.data.rejectionReason,
reviewerId: session.user.id, reviewerId: session.user.id,
}); });
await auditLog.record({
admin: { id: session.user.id, email: session.user.email },
action:
result.data.decision === "APPROVED"
? "VERIFICATION_APPROVE"
: "VERIFICATION_REJECT",
entityType: "OrganizerVerification",
entityId: result.data.verificationId,
payload:
result.data.decision === "REJECTED"
? { rejectionReason: result.data.rejectionReason ?? null }
: undefined,
});
// Notif email — fire and forget, jangan blok response.
void notifyVerificationDecision(result.data.verificationId, result.data.decision, result.data.rejectionReason);
revalidatePath("/admin/verifications"); revalidatePath("/admin/verifications");
revalidatePath("/verify"); revalidatePath("/verify");
revalidatePath("/profile"); revalidatePath("/profile");
@@ -77,6 +104,89 @@ export async function reviewVerificationAction(formData: FormData) {
} }
} }
async function notifyVerificationDecision(
verificationId: string,
decision: "APPROVED" | "REJECTED",
rejectionReason?: string
) {
const verification = await organizerRepo.findById(verificationId);
if (!verification) return;
const user = await userRepo.findById(verification.userId);
if (!user) return;
if (decision === "APPROVED") {
await emailService.send({
to: user.email,
idempotencyKey: `kyc_approved-${verificationId}`,
template: {
template: "kyc_approved",
data: { userName: user.name },
},
});
} else {
await emailService.send({
to: user.email,
// submissionCount supaya kalau reject berulang masing-masing dapat email.
idempotencyKey: `kyc_rejected-${verificationId}-${verification.submissionCount}`,
template: {
template: "kyc_rejected",
data: {
userName: user.name,
rejectionReason: rejectionReason ?? "(tidak ada alasan tercatat)",
},
},
});
}
}
/** E3.9 — kabari user kalau pengajuan verifikasi-nya sudah masuk antrian. */
async function notifyKycSubmitted(userId: string) {
const verification = await prisma.organizerVerification.findUnique({
where: { userId },
select: { submissionCount: true },
});
const user = await userRepo.findById(userId);
if (!verification || !user) return;
await emailService.send({
to: user.email,
idempotencyKey: `kyc_submitted-${userId}-${verification.submissionCount}`,
template: {
template: "kyc_submitted",
data: { userName: user.name },
},
});
}
/** E3.11 — kabari user kalau pengajuan verifikasi-nya dibuka kembali admin. */
async function notifyKycReopened(verificationId: string) {
const verification = await organizerRepo.findById(verificationId);
if (!verification) return;
const user = await userRepo.findById(verification.userId);
if (!user) return;
await emailService.send({
to: user.email,
idempotencyKey: `kyc_reopened-${verificationId}-${verification.submissionCount}`,
template: {
template: "kyc_reopened",
data: { userName: user.name },
},
});
}
/** E3.10 — kabari user kalau admin verifikasi dia secara manual override. */
async function notifyKycManualOverride(userId: string, verificationId: string) {
const user = await userRepo.findById(userId);
if (!user) return;
await emailService.send({
to: user.email,
idempotencyKey: `kyc_manual_override-${verificationId}`,
template: {
template: "kyc_manual_override",
data: { userName: user.name },
},
});
}
/** /**
* Admin reopen pengajuan REJECTED ke PENDING — supaya organizer bisa * Admin reopen pengajuan REJECTED ke PENDING — supaya organizer bisa
* di-review ulang tanpa drop & recreate row. Note wajib min 10 char untuk audit. * di-review ulang tanpa drop & recreate row. Note wajib min 10 char untuk audit.
@@ -96,6 +206,14 @@ export async function reopenVerificationAction(
adminId: session.user.id, adminId: session.user.id,
note, note,
}); });
void notifyKycReopened(verificationId);
await auditLog.record({
admin: { id: session.user.id, email: session.user.email },
action: "VERIFICATION_REOPEN",
entityType: "OrganizerVerification",
entityId: verificationId,
payload: { note: note.trim() },
});
revalidatePath("/admin/verifications"); revalidatePath("/admin/verifications");
revalidatePath("/verify"); revalidatePath("/verify");
revalidatePath("/profile"); revalidatePath("/profile");
@@ -104,3 +222,110 @@ export async function reopenVerificationAction(
return { error: (err as Error).message }; return { error: (err as Error).message };
} }
} }
/**
* Phase 2: admin minta organizer upload ulang field tertentu — daripada
* reject penuh, set flag `reuploadRequested` + daftar field + note.
*/
export async function requestReuploadAction(
verificationId: string,
fields: string[],
note: string
) {
const session = await getServerSession(authOptions);
if (!session?.user || !isAdminEmail(session.user.email)) {
return { error: "Tidak memiliki akses admin" };
}
const valid = fields.filter(isReuploadField) as ReuploadField[];
try {
await organizerService.requestReupload({
verificationId,
adminId: session.user.id,
fields: valid,
note,
});
await auditLog.record({
admin: { id: session.user.id, email: session.user.email },
action: "VERIFICATION_REQUEST_REUPLOAD",
entityType: "OrganizerVerification",
entityId: verificationId,
payload: { fields: valid, note: note.trim() },
});
// Notif email organizer — urgent, action required.
void notifyReuploadRequest(verificationId, valid, note.trim());
revalidatePath("/admin/verifications");
revalidatePath("/verify");
return { success: true as const };
} catch (err) {
return { error: (err as Error).message };
}
}
async function notifyReuploadRequest(
verificationId: string,
fields: ReuploadField[],
note: string
) {
const verification = await organizerRepo.findById(verificationId);
if (!verification) return;
const user = await userRepo.findById(verification.userId);
if (!user) return;
await emailService.send({
to: user.email,
// Allow re-trigger kalau admin minta lagi setelah submit ulang.
idempotencyKey: `kyc_reupload_request-${verificationId}-${verification.submissionCount}`,
template: {
template: "kyc_reupload_request",
data: {
userName: user.name,
fields,
note,
},
},
});
}
/**
* Phase 4: admin verify user tanpa upload KYC (partner trusted referral).
* Bikin row APPROVED dengan flag `isManualOverride = true`.
*/
export async function manualOverrideVerificationAction(input: {
userId: string;
note: string;
bankName: string;
bankAccountNumber: string;
bankAccountName: string;
}) {
const session = await getServerSession(authOptions);
if (!session?.user || !isAdminEmail(session.user.email)) {
return { error: "Tidak memiliki akses admin" };
}
try {
const result = await organizerService.manualOverrideVerification({
userId: input.userId,
adminId: session.user.id,
note: input.note,
bankName: input.bankName,
bankAccountNumber: input.bankAccountNumber,
bankAccountName: input.bankAccountName,
});
void notifyKycManualOverride(input.userId, result.id);
await auditLog.record({
admin: { id: session.user.id, email: session.user.email },
action: "VERIFICATION_MANUAL_OVERRIDE",
entityType: "OrganizerVerification",
entityId: result.id,
payload: { userId: input.userId, note: input.note.trim() },
});
revalidatePath("/admin/verifications");
revalidatePath(`/admin/users/${input.userId}`);
revalidatePath("/verify");
return { success: true as const, verificationId: result.id };
} catch (err) {
return { error: (err as Error).message };
}
}
+143 -22
View File
@@ -2,11 +2,21 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { CircleCheck, CircleX, RefreshCw } from "lucide-react";
import { import {
reopenVerificationAction, reopenVerificationAction,
requestReuploadAction,
reviewVerificationAction, reviewVerificationAction,
} from "@/features/organizer/actions"; } from "@/features/organizer/actions";
const REUPLOAD_FIELD_LABELS: { value: string; label: string }[] = [
{ value: "ktpImage", label: "Foto KTP" },
{ value: "liveness", label: "Foto liveness (pegang kertas SETRIP)" },
{ value: "nik", label: "NIK" },
{ value: "bankInfo", label: "Info rekening" },
{ value: "address", label: "Alamat" },
];
type Verification = { type Verification = {
id: string; id: string;
fullName: string; fullName: string;
@@ -37,11 +47,41 @@ export function ReviewCard({ verification }: { verification: Verification }) {
const router = useRouter(); const router = useRouter();
const [showReject, setShowReject] = useState(false); const [showReject, setShowReject] = useState(false);
const [showReopen, setShowReopen] = useState(false); const [showReopen, setShowReopen] = useState(false);
const [showReupload, setShowReupload] = useState(false);
const [rejectionReason, setRejectionReason] = useState(""); const [rejectionReason, setRejectionReason] = useState("");
const [reopenNote, setReopenNote] = useState(""); const [reopenNote, setReopenNote] = useState("");
const [reuploadNote, setReuploadNote] = useState("");
const [reuploadFields, setReuploadFields] = useState<string[]>([]);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
function toggleReuploadField(value: string) {
setReuploadFields((prev) =>
prev.includes(value)
? prev.filter((v) => v !== value)
: [...prev, value]
);
}
async function requestReupload() {
setError("");
setLoading(true);
const result = await requestReuploadAction(
verification.id,
reuploadFields,
reuploadNote
);
setLoading(false);
if ("error" in result && result.error) {
setError(result.error);
return;
}
setShowReupload(false);
setReuploadFields([]);
setReuploadNote("");
router.refresh();
}
async function decide(decision: "APPROVED" | "REJECTED") { async function decide(decision: "APPROVED" | "REJECTED") {
setError(""); setError("");
setLoading(true); setLoading(true);
@@ -141,9 +181,10 @@ export function ReviewCard({ verification }: { verification: Verification }) {
type="button" type="button"
onClick={() => setShowReopen(true)} onClick={() => setShowReopen(true)}
disabled={loading} disabled={loading}
className="rounded-xl border border-amber-300 bg-white px-4 py-2 text-sm font-bold text-amber-700 hover:bg-amber-50 disabled:opacity-50" className="inline-flex items-center gap-1.5 rounded-xl border border-amber-300 bg-white px-4 py-2 text-sm font-bold text-amber-700 hover:bg-amber-50 disabled:opacity-50"
> >
🔄 Buka kembali ke PENDING <RefreshCw size={18} strokeWidth={2} aria-hidden />
Buka kembali ke PENDING
</button> </button>
) : ( ) : (
<div className="space-y-2 rounded-xl border border-amber-200 bg-amber-50/60 p-3"> <div className="space-y-2 rounded-xl border border-amber-200 bg-amber-50/60 p-3">
@@ -192,26 +233,7 @@ export function ReviewCard({ verification }: { verification: Verification }) {
{error} {error}
</div> </div>
)} )}
{!showReject ? ( {showReject ? (
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => decide("APPROVED")}
disabled={loading}
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50"
>
Setujui
</button>
<button
type="button"
onClick={() => setShowReject(true)}
disabled={loading}
className="rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50 disabled:opacity-50"
>
Tolak
</button>
</div>
) : (
<div className="space-y-2"> <div className="space-y-2">
<textarea <textarea
value={rejectionReason} value={rejectionReason}
@@ -241,6 +263,105 @@ export function ReviewCard({ verification }: { verification: Verification }) {
</button> </button>
</div> </div>
</div> </div>
) : showReupload ? (
<div className="space-y-3 rounded-xl border border-amber-200 bg-amber-50/60 p-3">
<div>
<label className="mb-1 block text-xs font-semibold text-amber-900">
Field yang perlu di-upload ulang (min 1)
</label>
<div className="flex flex-wrap gap-2">
{REUPLOAD_FIELD_LABELS.map((f) => {
const checked = reuploadFields.includes(f.value);
return (
<label
key={f.value}
className={`cursor-pointer rounded-full border px-3 py-1 text-xs font-medium ${
checked
? "border-amber-600 bg-amber-600 text-white"
: "border-amber-200 bg-white text-amber-800 hover:bg-amber-100"
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleReuploadField(f.value)}
className="sr-only"
/>
{f.label}
</label>
);
})}
</div>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-amber-900">
Catatan untuk organizer (min 10 char akan dilihat user)
</label>
<textarea
value={reuploadNote}
onChange={(e) => setReuploadNote(e.target.value)}
rows={2}
maxLength={500}
placeholder="contoh: Foto KTP terlalu buram, tolong foto ulang dengan pencahayaan lebih baik."
className="w-full rounded-xl border border-amber-200 bg-white px-3 py-2 text-sm focus:border-amber-400"
/>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={requestReupload}
disabled={
loading ||
reuploadFields.length === 0 ||
reuploadNote.trim().length < 10
}
className="rounded-xl bg-amber-600 px-4 py-2 text-sm font-bold text-white hover:bg-amber-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? "Memproses..." : "Kirim Permintaan"}
</button>
<button
type="button"
onClick={() => {
setShowReupload(false);
setReuploadFields([]);
setReuploadNote("");
}}
className="rounded-xl border border-neutral-200 bg-white px-4 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-50"
>
Batal
</button>
</div>
</div>
) : (
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => decide("APPROVED")}
disabled={loading}
className="inline-flex items-center gap-1.5 rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50"
>
<CircleCheck size={18} strokeWidth={2} aria-hidden />
Setujui
</button>
<button
type="button"
onClick={() => setShowReupload(true)}
disabled={loading}
className="inline-flex items-center gap-1.5 rounded-xl border border-amber-300 bg-white px-4 py-2 text-sm font-bold text-amber-700 hover:bg-amber-50 disabled:opacity-50"
>
<RefreshCw size={18} strokeWidth={2} aria-hidden />
Minta re-upload
</button>
<button
type="button"
onClick={() => setShowReject(true)}
disabled={loading}
className="inline-flex items-center gap-1.5 rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50 disabled:opacity-50"
>
<CircleX size={18} strokeWidth={2} aria-hidden />
Tolak
</button>
</div>
)} )}
</div> </div>
)} )}
+59 -16
View File
@@ -2,7 +2,9 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { IdCard, Image as ImageIcon, Landmark, Check } from "lucide-react";
import { submitVerificationAction } from "@/features/organizer/actions"; import { submitVerificationAction } from "@/features/organizer/actions";
import { DateField } from "@/components/shared/date-picker";
type Initial = { type Initial = {
fullName: string; fullName: string;
@@ -21,23 +23,31 @@ type UploadKind = "ktp" | "liveness";
const ACCEPT_MIME = "image/jpeg,image/png,image/webp"; const ACCEPT_MIME = "image/jpeg,image/png,image/webp";
const MAX_BYTES = 5 * 1024 * 1024; const MAX_BYTES = 5 * 1024 * 1024;
function toYmd(d: Date): string {
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
const day = String(d.getUTCDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
export function VerifyForm({ initial }: { initial: Initial }) { export function VerifyForm({ initial }: { initial: Initial }) {
const router = useRouter(); const router = useRouter();
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [ktpKey, setKtpKey] = useState(initial?.ktpImageKey ?? ""); const [ktpKey, setKtpKey] = useState(initial?.ktpImageKey ?? "");
const [livenessKey, setLivenessKey] = useState(initial?.livenessKey ?? ""); const [livenessKey, setLivenessKey] = useState(initial?.livenessKey ?? "");
// `birthDate` dari DB tersimpan sebagai tengah malam UTC — baca pakai getter
// UTC supaya hari kalender yang tampil di picker tidak bergeser.
const [birthDate, setBirthDate] = useState<Date | null>(
initial
? new Date(
initial.birthDate.getUTCFullYear(),
initial.birthDate.getUTCMonth(),
initial.birthDate.getUTCDate()
)
: null
);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();
setError(""); setError("");
if (!birthDate) {
setError("Tanggal lahir wajib diisi");
return;
}
if (!ktpKey || !livenessKey) { if (!ktpKey || !livenessKey) {
setError("Foto KTP dan foto memegang kertas SETRIP wajib diunggah"); setError("Foto KTP dan foto memegang kertas SETRIP wajib diunggah");
return; return;
@@ -70,7 +80,15 @@ export function VerifyForm({ initial }: { initial: Initial }) {
)} )}
<section> <section>
<h2 className="mb-3 text-base font-bold text-neutral-900">📇 Data KTP</h2> <h2 className="mb-3 flex items-center gap-2 text-base font-bold text-neutral-900">
<IdCard
size={18}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
Data KTP
</h2>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<label className="mb-1.5 block text-sm font-semibold text-neutral-700"> <label className="mb-1.5 block text-sm font-semibold text-neutral-700">
@@ -102,15 +120,21 @@ export function VerifyForm({ initial }: { initial: Initial }) {
/> />
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-semibold text-neutral-700"> <label
htmlFor="birthDate"
className="mb-1.5 block text-sm font-semibold text-neutral-700"
>
Tanggal Lahir Tanggal Lahir
</label> </label>
<input <DateField
id="birthDate"
name="birthDate" name="birthDate"
type="date" value={birthDate}
onChange={setBirthDate}
maxDate={new Date()}
withMonthYearDropdown
required required
defaultValue={initial ? toYmd(new Date(initial.birthDate)) : ""} placeholder="Pilih tanggal lahir"
className={inputCls}
/> />
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
@@ -130,7 +154,15 @@ export function VerifyForm({ initial }: { initial: Initial }) {
</section> </section>
<section> <section>
<h2 className="mb-3 text-base font-bold text-neutral-900">🖼 Foto</h2> <h2 className="mb-3 flex items-center gap-2 text-base font-bold text-neutral-900">
<ImageIcon
size={18}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
Foto
</h2>
<p className="mb-3 text-xs text-neutral-500"> <p className="mb-3 text-xs text-neutral-500">
Foto disimpan terenkripsi di server SeTrip dan hanya bisa dilihat oleh Foto disimpan terenkripsi di server SeTrip dan hanya bisa dilihat oleh
tim admin saat review. Maks 5MB, JPG/PNG/WebP. tim admin saat review. Maks 5MB, JPG/PNG/WebP.
@@ -163,7 +195,15 @@ export function VerifyForm({ initial }: { initial: Initial }) {
</section> </section>
<section> <section>
<h2 className="mb-3 text-base font-bold text-neutral-900">🏦 Rekening Bank</h2> <h2 className="mb-3 flex items-center gap-2 text-base font-bold text-neutral-900">
<Landmark
size={18}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
Rekening Bank
</h2>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div> <div>
<label className="mb-1.5 block text-sm font-semibold text-neutral-700"> <label className="mb-1.5 block text-sm font-semibold text-neutral-700">
@@ -297,7 +337,10 @@ function FileUpload({
/> />
</label> </label>
{value && !busy && ( {value && !busy && (
<span className="text-xs text-neutral-500"> Terunggah</span> <span className="inline-flex items-center gap-1 text-xs text-emerald-600">
<Check size={13} strokeWidth={2.5} aria-hidden />
Terunggah
</span>
)} )}
</div> </div>
{previewUrl && ( {previewUrl && (
+30
View File
@@ -5,6 +5,9 @@ import { revalidatePath } from "next/cache";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin"; import { isAdminEmail } from "@/lib/admin";
import { payoutService } from "@/server/services/payout.service"; import { payoutService } from "@/server/services/payout.service";
import { payoutRepo } from "@/server/repositories/payout.repo";
import { auditLog } from "@/server/services/audit-log.service";
import { emailService } from "@/lib/email/send";
import { payoutMarkPaidSchema } from "./schemas"; import { payoutMarkPaidSchema } from "./schemas";
async function requireAdmin() { async function requireAdmin() {
@@ -33,6 +36,14 @@ export async function markPayoutPaidAction(formData: FormData) {
adminId: admin.id, adminId: admin.id,
adminNote: parsed.data.adminNote, adminNote: parsed.data.adminNote,
}); });
void notifyPayoutPaid(parsed.data.payoutId, parsed.data.adminNote);
await auditLog.record({
admin: { id: admin.id, email: admin.email },
action: "PAYOUT_MARK_PAID",
entityType: "Payout",
entityId: parsed.data.payoutId,
payload: { adminNote: parsed.data.adminNote },
});
revalidatePath("/admin/payouts"); revalidatePath("/admin/payouts");
revalidatePath("/admin"); revalidatePath("/admin");
revalidatePath("/profile"); revalidatePath("/profile");
@@ -41,3 +52,22 @@ export async function markPayoutPaidAction(formData: FormData) {
return { error: (err as Error).message }; return { error: (err as Error).message };
} }
} }
/** E3.7 — kabari organizer kalau payout-nya sudah ditransfer admin. */
async function notifyPayoutPaid(payoutId: string, adminNote: string) {
const payout = await payoutRepo.findById(payoutId);
if (!payout) return;
await emailService.send({
to: payout.organizer.email,
idempotencyKey: `payout_paid-${payout.id}`,
template: {
template: "payout_paid",
data: {
organizerName: payout.organizer.name,
tripTitle: payout.trip.title,
amount: payout.amount,
adminNote,
},
},
});
}
@@ -3,6 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ArrowRight, Banknote, CircleAlert } from "lucide-react";
import { markPayoutPaidAction } from "@/features/payout/actions"; import { markPayoutPaidAction } from "@/features/payout/actions";
import { formatRupiah } from "@/lib/utils"; import { formatRupiah } from "@/lib/utils";
@@ -95,9 +96,10 @@ export function PayoutReviewCard({ payout }: { payout: PayoutCardData }) {
</p> </p>
<Link <Link
href={`/admin/bookings/${payout.booking.id}`} href={`/admin/bookings/${payout.booking.id}`}
className="mt-1 inline-block text-[11px] font-semibold text-secondary-700 hover:text-secondary-900" className="mt-1 inline-flex items-center gap-1 text-[11px] font-semibold text-secondary-700 hover:text-secondary-900"
> >
Lihat timeline booking <ArrowRight size={14} strokeWidth={2} aria-hidden />
Lihat timeline booking
</Link> </Link>
</div> </div>
<StatusPill status={payout.status} /> <StatusPill status={payout.status} />
@@ -142,9 +144,18 @@ export function PayoutReviewCard({ payout }: { payout: PayoutCardData }) {
</p> </p>
</div> </div>
) : ( ) : (
<p className="text-amber-700"> <p className="flex gap-1.5 text-amber-700">
Organizer belum menyelesaikan verifikasi (KYC) tidak ada rekening <CircleAlert
snapshot. Hubungi organizer untuk konfirmasi rekening sebelum transfer. size={16}
strokeWidth={1.75}
aria-hidden
className="mt-0.5 shrink-0"
/>
<span>
Organizer belum menyelesaikan verifikasi (KYC) tidak ada
rekening snapshot. Hubungi organizer untuk konfirmasi rekening
sebelum transfer.
</span>
</p> </p>
)} )}
</div> </div>
@@ -212,9 +223,10 @@ export function PayoutReviewCard({ payout }: { payout: PayoutCardData }) {
type="button" type="button"
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
disabled={loading} disabled={loading}
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50" className="inline-flex items-center gap-1.5 rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50"
> >
💸 Tandai sudah ditransfer ke organizer <Banknote size={18} strokeWidth={2} aria-hidden />
Tandai sudah ditransfer ke organizer
</button> </button>
)} )}
</div> </div>
@@ -1,3 +1,4 @@
import { BadgeCheck, Star } from "lucide-react";
import type { OrganizerTrust } from "@/server/services/trust.service"; import type { OrganizerTrust } from "@/server/services/trust.service";
interface OrganizerStatsPanelProps { interface OrganizerStatsPanelProps {
@@ -47,7 +48,8 @@ export function OrganizerStatsPanel({ trust }: OrganizerStatsPanelProps) {
className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-0.5 text-[11px] font-bold uppercase tracking-wide text-primary-800" className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-0.5 text-[11px] font-bold uppercase tracking-wide text-primary-800"
title="Identitas organizer telah diverifikasi (KTP & rekening)" title="Identitas organizer telah diverifikasi (KTP & rekening)"
> >
Verified Organizer <BadgeCheck size={12} strokeWidth={2} aria-hidden />
Verified Organizer
</span> </span>
)} )}
{isTripLeader && ( {isTripLeader && (
@@ -83,7 +85,21 @@ export function OrganizerStatsPanel({ trust }: OrganizerStatsPanelProps) {
/> />
<Stat <Stat
label="Rating" label="Rating"
value={avgRating != null ? `${avgRating}` : "—"} value={
avgRating != null ? (
<span className="inline-flex items-center gap-1">
{avgRating}
<Star
size={14}
strokeWidth={2}
fill="currentColor"
aria-hidden
/>
</span>
) : (
"—"
)
}
subtitle={ subtitle={
reviewCount > 0 reviewCount > 0
? `${reviewCount} ulasan` ? `${reviewCount} ulasan`
@@ -107,8 +123,15 @@ export function OrganizerStatsPanel({ trust }: OrganizerStatsPanelProps) {
key={star} key={star}
className="flex items-center gap-2 text-xs" className="flex items-center gap-2 text-xs"
> >
<span className="w-8 shrink-0 font-medium text-neutral-600"> <span className="flex w-8 shrink-0 items-center gap-0.5 font-medium text-neutral-600">
{star} {star}
<Star
size={11}
strokeWidth={2}
fill="currentColor"
aria-hidden
className="text-amber-500"
/>
</span> </span>
<div className="h-2 flex-1 overflow-hidden rounded-full bg-neutral-100"> <div className="h-2 flex-1 overflow-hidden rounded-full bg-neutral-100">
<div <div
@@ -138,7 +161,7 @@ const TONE_CLASSES = {
interface StatProps { interface StatProps {
label: string; label: string;
value: string; value: React.ReactNode;
subtitle?: string; subtitle?: string;
tone: keyof typeof TONE_CLASSES; tone: keyof typeof TONE_CLASSES;
} }
@@ -2,6 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ArrowUpRight } from "lucide-react";
import { updateProfileAction } from "@/features/profile/actions"; import { updateProfileAction } from "@/features/profile/actions";
import { LIMITS } from "@/lib/limits"; import { LIMITS } from "@/lib/limits";
import { VIBES, vibeMeta } from "@/lib/vibe"; import { VIBES, vibeMeta } from "@/lib/vibe";
@@ -102,9 +103,10 @@ export function ProfileEditor({ userId, initial }: ProfileEditorProps) {
href={`/u/${userId}`} href={`/u/${userId}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="rounded-lg border border-neutral-200 px-3 py-1.5 text-xs font-medium text-neutral-600 hover:bg-neutral-50" className="inline-flex items-center gap-1 rounded-lg border border-neutral-200 px-3 py-1.5 text-xs font-medium text-neutral-600 hover:bg-neutral-50"
> >
Lihat publik Lihat publik
<ArrowUpRight size={13} strokeWidth={1.75} aria-hidden />
</a> </a>
<button <button
type="button" type="button"
@@ -324,9 +326,10 @@ export function ProfileEditor({ userId, initial }: ProfileEditorProps) {
href={`/u/${userId}`} href={`/u/${userId}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="rounded-xl border border-neutral-200 px-4 py-2.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50" className="inline-flex items-center gap-1 rounded-xl border border-neutral-200 px-4 py-2.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
> >
Lihat publik Lihat publik
<ArrowUpRight size={14} strokeWidth={1.75} aria-hidden />
</a> </a>
</div> </div>
</form> </form>
+7 -4
View File
@@ -1,5 +1,6 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { MapPin, BadgeCheck } from "lucide-react";
import { vibeMeta } from "@/lib/vibe"; import { vibeMeta } from "@/lib/vibe";
import type { Vibe } from "@/app/generated/prisma/enums"; import type { Vibe } from "@/app/generated/prisma/enums";
@@ -48,17 +49,19 @@ export function UserCard({
{name} {name}
</p> </p>
{profile?.city && ( {profile?.city && (
<p className="truncate text-[11px] text-neutral-500 sm:text-xs"> <p className="flex items-center gap-1 truncate text-[11px] text-neutral-500 sm:text-xs">
📍 {profile.city} <MapPin size={11} strokeWidth={1.75} aria-hidden className="shrink-0" />
{profile.city}
</p> </p>
)} )}
<div className="mt-1 flex flex-wrap gap-1"> <div className="mt-1 flex flex-wrap gap-1">
{isVerifiedOrganizer && ( {isVerifiedOrganizer && (
<span <span
className="inline-flex items-center gap-0.5 rounded-full bg-primary-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-primary-800" className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-primary-800"
title="Organizer terverifikasi" title="Organizer terverifikasi"
> >
Organizer <BadgeCheck size={11} strokeWidth={2} aria-hidden />
Organizer
</span> </span>
)} )}
{profile?.vibe && ( {profile?.vibe && (
+103 -1
View File
@@ -5,6 +5,9 @@ import { revalidatePath } from "next/cache";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin"; import { isAdminEmail } from "@/lib/admin";
import { refundService } from "@/server/services/refund.service"; import { refundService } from "@/server/services/refund.service";
import { auditLog } from "@/server/services/audit-log.service";
import { emailService } from "@/lib/email/send";
import { prisma } from "@/lib/prisma";
import { createRefundSchema, refundDecisionSchema } from "./schemas"; import { createRefundSchema, refundDecisionSchema } from "./schemas";
async function requireAdmin() { async function requireAdmin() {
@@ -31,7 +34,7 @@ export async function createRefundAction(formData: FormData) {
} }
try { try {
await refundService.requestRefund({ const refund = await refundService.requestRefund({
bookingId: parsed.data.bookingId, bookingId: parsed.data.bookingId,
reason: parsed.data.reason, reason: parsed.data.reason,
reportedBy: parsed.data.reportedBy, reportedBy: parsed.data.reportedBy,
@@ -39,6 +42,20 @@ export async function createRefundAction(formData: FormData) {
amount: parsed.data.amount, amount: parsed.data.amount,
initiatedByAdminId: admin.id, initiatedByAdminId: admin.id,
}); });
await auditLog.record({
admin: { id: admin.id, email: admin.email },
action: "REFUND_CREATE",
entityType: "Refund",
entityId: refund.id,
payload: {
bookingId: parsed.data.bookingId,
amount: parsed.data.amount,
reason: parsed.data.reason,
},
});
void notifyRefundCreated(refund.id);
revalidatePath("/admin/refunds"); revalidatePath("/admin/refunds");
return { success: true }; return { success: true };
} catch (err) { } catch (err) {
@@ -46,6 +63,77 @@ export async function createRefundAction(formData: FormData) {
} }
} }
async function notifyRefundCreated(refundId: string) {
const refund = await prisma.refund.findUnique({
where: { id: refundId },
include: {
booking: {
include: {
user: { select: { email: true, name: true } },
trip: { select: { title: true } },
},
},
},
});
if (!refund) return;
await emailService.send({
to: refund.booking.user.email,
idempotencyKey: `refund_created-${refund.id}`,
template: {
template: "refund_created",
data: {
userName: refund.booking.user.name,
tripTitle: refund.booking.trip.title,
amount: refund.amount,
reason: refund.reason,
},
},
});
}
async function notifyRefundDecision(
refundId: string,
decision: "SUCCEEDED" | "FAILED",
adminNote: string
) {
const refund = await prisma.refund.findUnique({
where: { id: refundId },
include: {
booking: {
include: {
user: { select: { email: true, name: true } },
trip: { select: { title: true } },
},
},
},
});
if (!refund) return;
await emailService.send({
to: refund.booking.user.email,
idempotencyKey: `refund_${decision.toLowerCase()}-${refund.id}`,
template:
decision === "SUCCEEDED"
? {
template: "refund_succeeded",
data: {
userName: refund.booking.user.name,
tripTitle: refund.booking.trip.title,
amount: refund.amount,
adminNote,
},
}
: {
template: "refund_failed",
data: {
userName: refund.booking.user.name,
tripTitle: refund.booking.trip.title,
amount: refund.amount,
adminNote,
},
},
});
}
export async function decideRefundAction(formData: FormData) { export async function decideRefundAction(formData: FormData) {
const admin = await requireAdmin(); const admin = await requireAdmin();
if (!admin) return { error: "Tidak memiliki akses admin" }; if (!admin) return { error: "Tidak memiliki akses admin" };
@@ -91,6 +179,20 @@ export async function decideRefundAction(formData: FormData) {
adminNote: adminNote!, adminNote: adminNote!,
}); });
} }
await auditLog.record({
admin: { id: admin.id, email: admin.email },
action: `REFUND_${decision}`,
entityType: "Refund",
entityId: refundId,
payload: adminNote ? { adminNote } : undefined,
});
// Notif email user kalau decision final (SUCCEEDED/FAILED) — APPROVE/REJECT
// intermediate, refund_created sudah dikirim sebelumnya.
if (decision === "SUCCEEDED" || decision === "FAILED") {
void notifyRefundDecision(refundId, decision, adminNote ?? "");
}
revalidatePath("/admin/refunds"); revalidatePath("/admin/refunds");
return { success: true }; return { success: true };
} catch (err) { } catch (err) {
@@ -1,3 +1,4 @@
import { LifeBuoy } from "lucide-react";
import { getRefundPolicyTiers } from "@/lib/refund-policy"; import { getRefundPolicyTiers } from "@/lib/refund-policy";
/** /**
@@ -9,7 +10,13 @@ export function RefundPolicySection() {
return ( return (
<details className="rounded-xl border border-neutral-200 bg-neutral-50/60 p-3 text-xs sm:text-sm"> <details className="rounded-xl border border-neutral-200 bg-neutral-50/60 p-3 text-xs sm:text-sm">
<summary className="cursor-pointer select-none font-semibold text-neutral-700"> <summary className="cursor-pointer select-none font-semibold text-neutral-700">
🛟 Kebijakan refund saat peserta cancel <LifeBuoy
size={15}
strokeWidth={1.75}
aria-hidden
className="mr-1.5 inline align-text-bottom"
/>
Kebijakan refund saat peserta cancel
</summary> </summary>
<div className="mt-2 space-y-2 text-neutral-600"> <div className="mt-2 space-y-2 text-neutral-600">
<p className="text-[11px] text-neutral-500 sm:text-xs"> <p className="text-[11px] text-neutral-500 sm:text-xs">
@@ -21,7 +28,7 @@ export function RefundPolicySection() {
{tiers.map((t) => ( {tiers.map((t) => (
<li key={t.minDaysBefore} className="flex items-baseline gap-2"> <li key={t.minDaysBefore} className="flex items-baseline gap-2">
<span <span
className={`inline-flex min-w-[3rem] justify-center rounded-full px-2 py-0.5 text-[10px] font-bold ${ className={`inline-flex min-w-12 justify-center rounded-full px-2 py-0.5 text-[10px] font-bold ${
t.refundPercentage >= 80 t.refundPercentage >= 80
? "bg-primary-100 text-primary-700" ? "bg-primary-100 text-primary-700"
: t.refundPercentage >= 50 : t.refundPercentage >= 50
@@ -3,6 +3,13 @@
import { useState } from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import {
ArrowRight,
Banknote,
CircleAlert,
CircleCheck,
CircleX,
} from "lucide-react";
import { decideRefundAction } from "@/features/refund/actions"; import { decideRefundAction } from "@/features/refund/actions";
import { formatRupiah } from "@/lib/utils"; import { formatRupiah } from "@/lib/utils";
@@ -129,9 +136,10 @@ export function RefundReviewCard({ refund }: { refund: RefundCardData }) {
</p> </p>
<Link <Link
href={`/admin/bookings/${refund.booking.id}`} href={`/admin/bookings/${refund.booking.id}`}
className="mt-1 inline-block text-[11px] font-semibold text-secondary-700 hover:text-secondary-900" className="mt-1 inline-flex items-center gap-1 text-[11px] font-semibold text-secondary-700 hover:text-secondary-900"
> >
Lihat timeline payment & refund <ArrowRight size={14} strokeWidth={2} aria-hidden />
Lihat timeline payment & refund
</Link> </Link>
</div> </div>
<Field <Field
@@ -211,17 +219,19 @@ export function RefundReviewCard({ refund }: { refund: RefundCardData }) {
type="button" type="button"
onClick={() => setOpenAction("APPROVE")} onClick={() => setOpenAction("APPROVE")}
disabled={loading} disabled={loading}
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50" className="inline-flex items-center gap-1.5 rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50"
> >
Setujui <CircleCheck size={18} strokeWidth={2} aria-hidden />
Setujui
</button> </button>
<button <button
type="button" type="button"
onClick={() => setOpenAction("REJECT")} onClick={() => setOpenAction("REJECT")}
disabled={loading} disabled={loading}
className="rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50 disabled:opacity-50" className="inline-flex items-center gap-1.5 rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50 disabled:opacity-50"
> >
Tolak <CircleX size={18} strokeWidth={2} aria-hidden />
Tolak
</button> </button>
</> </>
)} )}
@@ -231,17 +241,19 @@ export function RefundReviewCard({ refund }: { refund: RefundCardData }) {
type="button" type="button"
onClick={() => setOpenAction("SUCCEEDED")} onClick={() => setOpenAction("SUCCEEDED")}
disabled={loading} disabled={loading}
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50" className="inline-flex items-center gap-1.5 rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50"
> >
💸 Tandai sudah ditransfer <Banknote size={18} strokeWidth={2} aria-hidden />
Tandai sudah ditransfer
</button> </button>
<button <button
type="button" type="button"
onClick={() => setOpenAction("FAILED")} onClick={() => setOpenAction("FAILED")}
disabled={loading} disabled={loading}
className="rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50 disabled:opacity-50" className="inline-flex items-center gap-1.5 rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50 disabled:opacity-50"
> >
Tandai gagal <CircleAlert size={18} strokeWidth={2} aria-hidden />
Tandai gagal
</button> </button>
</> </>
)} )}
@@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { Star } from "lucide-react";
import type { OrganizerReviewItem } from "@/server/services/review.service"; import type { OrganizerReviewItem } from "@/server/services/review.service";
interface OrganizerReviewsListProps { interface OrganizerReviewsListProps {
@@ -62,11 +63,22 @@ export function OrganizerReviewsList({
> >
{r.user.name} {r.user.name}
</Link> </Link>
<span className="text-xs font-bold text-amber-600"> <span
{"★".repeat(r.rating)} className="flex shrink-0 items-center gap-0.5"
<span className="text-neutral-300"> aria-label={`Rating ${r.rating} dari 5`}
{"★".repeat(5 - r.rating)} >
</span> {[1, 2, 3, 4, 5].map((n) => (
<Star
key={n}
size={12}
strokeWidth={2}
fill="currentColor"
aria-hidden
className={
n <= r.rating ? "text-amber-500" : "text-neutral-200"
}
/>
))}
</span> </span>
</div> </div>
+164
View File
@@ -12,6 +12,9 @@ import { organizerService } from "@/server/services/organizer.service";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { tripStoredInstantFromYmd } from "@/lib/trip-dates"; import { tripStoredInstantFromYmd } from "@/lib/trip-dates";
import { requireActiveUser } from "@/lib/auth-guards"; import { requireActiveUser } from "@/lib/auth-guards";
import { auditLog } from "@/server/services/audit-log.service";
import { emailService } from "@/lib/email/send";
import { prisma } from "@/lib/prisma";
export async function createTripAction(formData: FormData) { export async function createTripAction(formData: FormData) {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
@@ -128,6 +131,7 @@ export async function joinTripAction(tripId: string) {
try { try {
await requireActiveUser(session.user.id); await requireActiveUser(session.user.id);
await tripService.joinTrip(tripId, session.user.id); await tripService.joinTrip(tripId, session.user.id);
void notifyJoinRequest(tripId, session.user.id);
revalidatePath(`/trips/${tripId}`); revalidatePath(`/trips/${tripId}`);
revalidatePath("/trips"); revalidatePath("/trips");
revalidatePath("/"); revalidatePath("/");
@@ -171,6 +175,9 @@ export async function confirmParticipantAction(
participantId, participantId,
session.user.id session.user.id
); );
void notifyBookingApproved(participantId);
revalidatePath(`/trips/${tripId}`); revalidatePath(`/trips/${tripId}`);
revalidatePath("/trips"); revalidatePath("/trips");
revalidatePath("/"); revalidatePath("/");
@@ -181,6 +188,85 @@ export async function confirmParticipantAction(
} }
} }
async function notifyBookingApproved(participantId: string) {
const participant = await prisma.tripParticipant.findUnique({
where: { id: participantId },
include: {
user: { select: { email: true, name: true } },
trip: { select: { id: true, title: true } },
booking: { select: { id: true, amount: true } },
},
});
if (!participant || !participant.booking) return;
await emailService.send({
to: participant.user.email,
idempotencyKey: `booking_approved-${participant.booking.id}`,
template: {
template: "booking_approved",
data: {
userName: participant.user.name,
tripTitle: participant.trip.title,
tripId: participant.trip.id,
amount: participant.booking.amount,
},
},
});
}
/** E3.1 — kabari organizer ada peserta baru yang mengajukan join. */
async function notifyJoinRequest(tripId: string, joinerId: string) {
const [trip, joiner] = await Promise.all([
prisma.trip.findUnique({
where: { id: tripId },
select: {
title: true,
organizer: { select: { email: true, name: true } },
},
}),
prisma.user.findUnique({
where: { id: joinerId },
select: { name: true },
}),
]);
if (!trip || !joiner) return;
await emailService.send({
to: trip.organizer.email,
idempotencyKey: `join_request-${tripId}-${joinerId}`,
template: {
template: "join_request",
data: {
organizerName: trip.organizer.name,
joinerName: joiner.name,
tripTitle: trip.title,
tripId,
},
},
});
}
/** E3.2 — kabari peserta kalau permintaan join-nya ditolak organizer. */
async function notifyJoinRejected(participantId: string) {
const participant = await prisma.tripParticipant.findUnique({
where: { id: participantId },
select: {
user: { select: { email: true, name: true } },
trip: { select: { title: true } },
},
});
if (!participant) return;
await emailService.send({
to: participant.user.email,
idempotencyKey: `join_rejected-${participantId}`,
template: {
template: "join_rejected",
data: {
userName: participant.user.name,
tripTitle: participant.trip.title,
},
},
});
}
export async function rejectParticipantAction( export async function rejectParticipantAction(
tripId: string, tripId: string,
participantId: string participantId: string
@@ -196,6 +282,7 @@ export async function rejectParticipantAction(
participantId, participantId,
session.user.id session.user.id
); );
void notifyJoinRejected(participantId);
revalidatePath(`/trips/${tripId}`); revalidatePath(`/trips/${tripId}`);
revalidatePath("/trips"); revalidatePath("/trips");
revalidatePath("/"); revalidatePath("/");
@@ -206,6 +293,66 @@ export async function rejectParticipantAction(
} }
} }
type CloseTripResult = Awaited<ReturnType<typeof tripService.closeTrip>>;
/**
* E3.4 / E3.5 (+ E2.4) — kabari semua peserta aktif kalau trip dibatalkan.
* Email organizer-cancel & admin-cancel beda template; admin-cancel juga
* mengabari organizer. Refund block ikut di email (nominal dari `notify`).
*/
function notifyTripCancelled(
tripId: string,
notify: CloseTripResult["notify"],
actor: { type: "ORGANIZER" } | { type: "ADMIN"; reason: string }
) {
for (const p of notify.participants) {
if (actor.type === "ORGANIZER") {
void emailService.send({
to: p.email,
idempotencyKey: `trip_cancelled_organizer-${tripId}-${p.userId}`,
template: {
template: "trip_cancelled_organizer",
data: {
userName: p.name,
tripTitle: notify.tripTitle,
refundAmount: p.refundAmount,
},
},
});
} else {
void emailService.send({
to: p.email,
idempotencyKey: `trip_cancelled_admin-${tripId}-${p.userId}`,
template: {
template: "trip_cancelled_admin",
data: {
userName: p.name,
tripTitle: notify.tripTitle,
reason: actor.reason,
refundAmount: p.refundAmount,
},
},
});
}
}
// Admin force-cancel → organizer juga dikabari (E3.5).
if (actor.type === "ADMIN") {
void emailService.send({
to: notify.organizer.email,
idempotencyKey: `trip_cancelled_admin-${tripId}-organizer`,
template: {
template: "trip_cancelled_admin",
data: {
userName: notify.organizer.name,
tripTitle: notify.tripTitle,
reason: actor.reason,
refundAmount: 0,
},
},
});
}
}
export async function cancelTripAction(tripId: string) { export async function cancelTripAction(tripId: string) {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session?.user) { if (!session?.user) {
@@ -217,6 +364,7 @@ export async function cancelTripAction(tripId: string) {
type: "ORGANIZER", type: "ORGANIZER",
userId: session.user.id, userId: session.user.id,
}); });
notifyTripCancelled(tripId, result.notify, { type: "ORGANIZER" });
revalidatePath(`/trips/${tripId}`); revalidatePath(`/trips/${tripId}`);
revalidatePath("/trips"); revalidatePath("/trips");
revalidatePath("/"); revalidatePath("/");
@@ -263,6 +411,22 @@ export async function adminCancelTripAction(tripId: string, reason: string) {
adminId: session.user.id, adminId: session.user.id,
reason: trimmedReason, reason: trimmedReason,
}); });
notifyTripCancelled(tripId, result.notify, {
type: "ADMIN",
reason: trimmedReason,
});
await auditLog.record({
admin: { id: session.user.id, email: session.user.email },
action: "TRIP_ADMIN_CANCEL",
entityType: "Trip",
entityId: tripId,
payload: {
reason: trimmedReason,
refundsCreated: result.refundsCreated.length,
cancelledBookings: result.cancelledBookings.length,
skippedBookings: result.skippedBookings.length,
},
});
revalidatePath(`/trips/${tripId}`); revalidatePath(`/trips/${tripId}`);
revalidatePath(`/admin/trips/${tripId}`); revalidatePath(`/admin/trips/${tripId}`);
revalidatePath("/admin/trips"); revalidatePath("/admin/trips");
@@ -2,6 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { CircleCheck } from "lucide-react";
import { adminCancelTripAction } from "@/features/trip/actions"; import { adminCancelTripAction } from "@/features/trip/actions";
interface AdminCancelTripButtonProps { interface AdminCancelTripButtonProps {
@@ -42,7 +43,10 @@ export function AdminCancelTripButton({ tripId }: AdminCancelTripButtonProps) {
if (result) { if (result) {
return ( return (
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900"> <div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900">
<p className="font-bold"> Trip berhasil dibatalkan.</p> <p className="flex items-center gap-1.5 font-bold">
<CircleCheck size={18} strokeWidth={2} aria-hidden />
Trip berhasil dibatalkan.
</p>
<ul className="mt-2 space-y-0.5 text-xs"> <ul className="mt-2 space-y-0.5 text-xs">
<li> {result.refundCount} booking PAID refund auto-dibuat</li> <li> {result.refundCount} booking PAID refund auto-dibuat</li>
<li> <li>
+57 -82
View File
@@ -2,10 +2,17 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import DatePicker from "react-datepicker"; import {
import "react-datepicker/dist/react-datepicker.css"; ArrowLeft,
ArrowRight,
Check,
X,
CircleAlert,
Users,
} from "lucide-react";
import { DateRangeField, TimeField } from "@/components/shared/date-picker";
import { createTripAction } from "@/features/trip/actions"; import { createTripAction } from "@/features/trip/actions";
import { ImageUrlInput } from "@/features/trip/components/image-url-input"; import { TripImageUpload } from "@/features/trip/components/trip-image-upload";
import { formatLocalCalendarYmd } from "@/lib/trip-dates"; import { formatLocalCalendarYmd } from "@/lib/trip-dates";
import { ACTIVITY_CATEGORIES, categoryMeta } from "@/lib/activity-category"; import { ACTIVITY_CATEGORIES, categoryMeta } from "@/lib/activity-category";
import { VIBES, vibeMeta } from "@/lib/vibe"; import { VIBES, vibeMeta } from "@/lib/vibe";
@@ -54,7 +61,7 @@ const INITIAL_STATE: FormState = {
itineraryDays: [], itineraryDays: [],
whatsIncluded: "", whatsIncluded: "",
whatsExcluded: "", whatsExcluded: "",
imageUrls: [""], imageUrls: [],
maxParticipants: "", maxParticipants: "",
priceDisplay: "", priceDisplay: "",
}; };
@@ -145,18 +152,11 @@ export function CreateTripForm({ isVerifiedOrganizer }: CreateTripFormProps) {
return null; return null;
} }
if (target === 3) { if (target === 3) {
const hasInvalidUrl = state.imageUrls // Foto divalidasi saat upload (route + komponen). Di sini cukup cek
.map((u) => u.trim()) // batas jumlah supaya tidak melampaui kapasitas.
.filter(Boolean) if (state.imageUrls.length > LIMITS.MAX_IMAGE_URLS) {
.some((u) => { return `Maksimal ${LIMITS.MAX_IMAGE_URLS} foto`;
try {
const parsed = new URL(u);
return parsed.protocol !== "http:" && parsed.protocol !== "https:";
} catch {
return true;
} }
});
if (hasInvalidUrl) return "Ada URL foto yang tidak valid (harus http/https)";
for (let d = 0; d < state.itineraryDays.length; d++) { for (let d = 0; d < state.itineraryDays.length; d++) {
const dayItems = state.itineraryDays[d]; const dayItems = state.itineraryDays[d];
@@ -331,6 +331,7 @@ export function CreateTripForm({ isVerifiedOrganizer }: CreateTripFormProps) {
whatsExcluded={state.whatsExcluded} whatsExcluded={state.whatsExcluded}
imageUrls={state.imageUrls} imageUrls={state.imageUrls}
onChange={update} onChange={update}
onError={setStepError}
/> />
)} )}
@@ -368,9 +369,10 @@ export function CreateTripForm({ isVerifiedOrganizer }: CreateTripFormProps) {
type="button" type="button"
onClick={goBack} onClick={goBack}
disabled={step === 1 || loading} disabled={step === 1 || loading}
className="rounded-xl border border-neutral-200 bg-white px-4 py-2.5 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40" className="inline-flex items-center gap-1 rounded-xl border border-neutral-200 bg-white px-4 py-2.5 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40"
> >
Kembali <ArrowLeft size={15} strokeWidth={2} aria-hidden />
Kembali
</button> </button>
{isLastStep ? ( {isLastStep ? (
@@ -389,9 +391,10 @@ export function CreateTripForm({ isVerifiedOrganizer }: CreateTripFormProps) {
<button <button
type="button" type="button"
onClick={goNext} onClick={goNext}
className="rounded-xl bg-primary-600 px-5 py-2.5 text-sm font-bold text-white shadow-lg shadow-primary-600/20 transition-colors hover:bg-primary-700" className="inline-flex items-center gap-1 rounded-xl bg-primary-600 px-5 py-2.5 text-sm font-bold text-white shadow-lg shadow-primary-600/20 transition-colors hover:bg-primary-700"
> >
Lanjut Lanjut
<ArrowRight size={15} strokeWidth={2} aria-hidden />
</button> </button>
)} )}
</div> </div>
@@ -443,7 +446,11 @@ function Stepper({
: "cursor-not-allowed" : "cursor-not-allowed"
}`} }`}
> >
{isCompleted ? "✓" : s.id} {isCompleted ? (
<Check size={14} strokeWidth={3} aria-hidden />
) : (
s.id
)}
</button> </button>
<span <span
className={`ml-2 hidden text-xs font-semibold sm:inline ${ className={`ml-2 hidden text-xs font-semibold sm:inline ${
@@ -685,6 +692,7 @@ function StepDetail({
whatsExcluded, whatsExcluded,
imageUrls, imageUrls,
onChange, onChange,
onError,
}: { }: {
meetingPoint: string; meetingPoint: string;
itineraryDays: ItineraryDays; itineraryDays: ItineraryDays;
@@ -692,6 +700,7 @@ function StepDetail({
whatsExcluded: string; whatsExcluded: string;
imageUrls: string[]; imageUrls: string[];
onChange: <K extends keyof FormState>(key: K, value: FormState[K]) => void; onChange: <K extends keyof FormState>(key: K, value: FormState[K]) => void;
onError: (msg: string) => void;
}) { }) {
return ( return (
<div className="space-y-5"> <div className="space-y-5">
@@ -764,9 +773,10 @@ function StepDetail({
</div> </div>
</div> </div>
<ImageUrlInput <TripImageUpload
value={imageUrls} value={imageUrls}
onChange={(urls) => onChange("imageUrls", urls)} onChange={(urls) => onChange("imageUrls", urls)}
onError={onError}
/> />
</div> </div>
); );
@@ -885,40 +895,35 @@ function ItineraryBuilder({
> >
<div className="flex flex-col gap-2 sm:flex-row sm:items-start"> <div className="flex flex-col gap-2 sm:flex-row sm:items-start">
<div className="flex shrink-0 gap-2"> <div className="flex shrink-0 gap-2">
<div> <div className="w-32">
<label className="mb-0.5 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"> <label
htmlFor={`itin-${dayIdx}-${itemIdx}-start`}
className="mb-0.5 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
>
Mulai Mulai
</label> </label>
<input <TimeField
type="time" id={`itin-${dayIdx}-${itemIdx}-start`}
value={item.startTime} value={item.startTime}
onChange={(e) => onChange={(v) =>
updateItem( updateItem(dayIdx, itemIdx, "startTime", v)
dayIdx,
itemIdx,
"startTime",
e.target.value
)
} }
className="w-30 rounded-lg border border-neutral-200 bg-white px-2 py-1.5 text-sm text-neutral-800 focus:border-primary-400"
/> />
</div> </div>
<div> <div className="w-32">
<label className="mb-0.5 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"> <label
htmlFor={`itin-${dayIdx}-${itemIdx}-end`}
className="mb-0.5 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
>
Selesai Selesai
</label> </label>
<input <TimeField
type="time" id={`itin-${dayIdx}-${itemIdx}-end`}
value={item.endTime} value={item.endTime}
onChange={(e) => onChange={(v) =>
updateItem( updateItem(dayIdx, itemIdx, "endTime", v)
dayIdx,
itemIdx,
"endTime",
e.target.value
)
} }
className="w-30 rounded-lg border border-neutral-200 bg-white px-2 py-1.5 text-sm text-neutral-800 focus:border-primary-400" clearable
/> />
</div> </div>
</div> </div>
@@ -948,7 +953,7 @@ function ItineraryBuilder({
aria-label="Hapus aktivitas" aria-label="Hapus aktivitas"
className="self-end rounded-lg px-2 py-1.5 text-neutral-400 hover:bg-red-50 hover:text-red-500 sm:self-center" className="self-end rounded-lg px-2 py-1.5 text-neutral-400 hover:bg-red-50 hover:text-red-500 sm:self-center"
> >
<X size={16} strokeWidth={2} aria-hidden />
</button> </button>
</div> </div>
</li> </li>
@@ -1021,37 +1026,13 @@ function StepSchedule({
<label className="mb-1.5 block text-sm font-semibold text-neutral-700"> <label className="mb-1.5 block text-sm font-semibold text-neutral-700">
Tanggal berangkat pulang Tanggal berangkat pulang
</label> </label>
<div className="relative"> <DateRangeField
<span className="absolute left-3 top-1/2 z-10 -translate-y-1/2 text-neutral-400">
<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>
<DatePicker
selectsRange
startDate={startDate} startDate={startDate}
endDate={endDate} endDate={endDate}
onChange={(dates) => { onChange={(s, e) => onDateChange(s, e)}
const [s, e] = dates as [Date | null, Date | null];
onDateChange(s, e);
}}
minDate={new Date()} minDate={new Date()}
placeholderText="Pilih tanggal..."
dateFormat="dd MMM yyyy"
isClearable
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 py-2.5 pl-9 pr-3 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-primary-500 focus:bg-white"
/> />
</div> </div>
</div>
<div> <div>
<label <label
@@ -1062,14 +1043,7 @@ function StepSchedule({
</label> </label>
<div className="relative"> <div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"> <span className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400">
<svg <Users size={16} strokeWidth={1.75} aria-hidden />
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4"
>
<path d="M7 8a3 3 0 100-6 3 3 0 000 6zM14.5 9a2.5 2.5 0 100-5 2.5 2.5 0 000 5zM1.615 16.428a1.224 1.224 0 01-.569-1.175 6.002 6.002 0 0111.908 0c.058.467-.172.92-.57 1.174A9.953 9.953 0 017 18a9.953 9.953 0 01-5.385-1.572zM14.5 16h-.106c.07-.297.088-.611.048-.933a7.47 7.47 0 00-1.588-3.755 4.502 4.502 0 015.874 2.636.818.818 0 01-.36.98A7.465 7.465 0 0114.5 16z" />
</svg>
</span> </span>
<input <input
id="maxParticipants" id="maxParticipants"
@@ -1114,8 +1088,9 @@ function StepSchedule({
/> />
</div> </div>
{blockedByVerification && ( {blockedByVerification && (
<p className="mt-2 text-xs font-medium text-amber-700"> <p className="mt-2 flex items-center gap-1.5 text-xs font-medium text-amber-700">
Trip berbayar butuh verifikasi organizer terlebih dahulu. <CircleAlert size={14} strokeWidth={2} aria-hidden />
Trip berbayar butuh verifikasi organizer terlebih dahulu.
</p> </p>
)} )}
</div> </div>
+165 -9
View File
@@ -1,7 +1,8 @@
"use client"; "use client";
import Image from "next/image"; import Image from "next/image";
import { useState } from "react"; import { useEffect, useState } from "react";
import { Mountain, Maximize2, X, ChevronLeft, ChevronRight } from "lucide-react";
interface TripImage { interface TripImage {
id: string; id: string;
@@ -11,11 +12,48 @@ interface TripImage {
export function ImageGallery({ images }: { images: TripImage[] }) { export function ImageGallery({ images }: { images: TripImage[] }) {
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const [lightboxOpen, setLightboxOpen] = useState(false);
const hasMultiple = images.length > 1;
function showPrev() {
setActiveIndex((i) => (i - 1 + images.length) % images.length);
}
function showNext() {
setActiveIndex((i) => (i + 1) % images.length);
}
// Saat lightbox terbuka: kunci scroll body + dukung keyboard (Esc tutup,
// panah kiri/kanan untuk ganti foto).
useEffect(() => {
if (!lightboxOpen) return;
function onKey(e: KeyboardEvent) {
// Logika prev/next di-inline (bukan panggil showPrev/showNext) supaya
// effect tidak bergantung pada fungsi yang dibuat ulang tiap render.
if (e.key === "Escape") setLightboxOpen(false);
else if (e.key === "ArrowLeft") {
setActiveIndex((i) => (i - 1 + images.length) % images.length);
} else if (e.key === "ArrowRight") {
setActiveIndex((i) => (i + 1) % images.length);
}
}
document.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [lightboxOpen, images.length]);
if (images.length === 0) { if (images.length === 0) {
return ( return (
<div className="flex h-44 items-center justify-center bg-linear-to-br from-primary-800 to-secondary-900 sm:h-56 lg:h-72"> <div className="flex h-44 items-center justify-center bg-neutral-100 sm:h-56 lg:h-72">
<span className="text-5xl sm:text-6xl">🏔</span> <Mountain
size={56}
strokeWidth={1.5}
aria-hidden
className="text-neutral-300"
/>
</div> </div>
); );
} }
@@ -24,18 +62,25 @@ export function ImageGallery({ images }: { images: TripImage[] }) {
return ( return (
<div> <div>
{/* Main Image */} {/* Main Image — klik untuk lihat ukuran penuh */}
<div className="relative h-44 bg-neutral-900 sm:h-56 lg:h-72"> <button
type="button"
onClick={() => setLightboxOpen(true)}
aria-label="Lihat foto ukuran penuh"
className="group relative block h-44 w-full cursor-zoom-in bg-neutral-900 sm:h-56 lg:h-72"
>
<Image <Image
src={activeImage.url} src={activeImage.url}
alt={activeImage.caption || "Foto trip"} alt={activeImage.caption || "Foto trip"}
fill fill
className="object-cover" // `object-contain` — tampilkan gambar utuh tanpa terpotong; rasio
// foto bebas, sisi yang tak terisi jadi bar gelap (bg-neutral-900).
className="object-contain"
sizes="(max-width: 768px) 100vw, 768px" sizes="(max-width: 768px) 100vw, 768px"
priority priority
/> />
{activeImage.caption && ( {activeImage.caption && (
<div className="absolute bottom-0 left-0 right-0 bg-linear-to-t from-black/60 to-transparent px-3 pb-2.5 pt-6 sm:px-4 sm:pb-3 sm:pt-8"> <div className="absolute bottom-0 left-0 right-0 bg-linear-to-t from-black/60 to-transparent px-3 pb-2.5 pt-6 text-left sm:px-4 sm:pb-3 sm:pt-8">
<p className="text-xs font-medium text-white sm:text-sm"> <p className="text-xs font-medium text-white sm:text-sm">
{activeImage.caption} {activeImage.caption}
</p> </p>
@@ -45,14 +90,20 @@ export function ImageGallery({ images }: { images: TripImage[] }) {
<div className="absolute right-2 top-2 rounded-full bg-black/50 px-2 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm sm:right-3 sm:top-3 sm:px-2.5 sm:py-1 sm:text-xs"> <div className="absolute right-2 top-2 rounded-full bg-black/50 px-2 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm sm:right-3 sm:top-3 sm:px-2.5 sm:py-1 sm:text-xs">
{activeIndex + 1} / {images.length} {activeIndex + 1} / {images.length}
</div> </div>
</div> {/* Petunjuk perbesar */}
<span className="absolute left-2 top-2 inline-flex items-center gap-1 rounded-full bg-black/50 px-2 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm transition-colors group-hover:bg-black/70 sm:left-3 sm:top-3 sm:px-2.5 sm:py-1 sm:text-xs">
<Maximize2 size={11} strokeWidth={2} aria-hidden />
Lihat penuh
</span>
</button>
{/* Thumbnails */} {/* Thumbnails */}
{images.length > 1 && ( {hasMultiple && (
<div className="flex gap-1 overflow-x-auto bg-neutral-100 p-1.5 sm:gap-1.5 sm:p-2"> <div className="flex gap-1 overflow-x-auto bg-neutral-100 p-1.5 sm:gap-1.5 sm:p-2">
{images.map((img, i) => ( {images.map((img, i) => (
<button <button
key={img.id} key={img.id}
type="button"
onClick={() => setActiveIndex(i)} onClick={() => setActiveIndex(i)}
className={`relative h-11 w-16 shrink-0 overflow-hidden rounded-md transition-all sm:h-14 sm:w-20 sm:rounded-lg ${ className={`relative h-11 w-16 shrink-0 overflow-hidden rounded-md transition-all sm:h-14 sm:w-20 sm:rounded-lg ${
i === activeIndex i === activeIndex
@@ -71,6 +122,111 @@ export function ImageGallery({ images }: { images: TripImage[] }) {
))} ))}
</div> </div>
)} )}
{/* Lightbox — penampil foto ukuran penuh */}
{lightboxOpen && (
<div
role="dialog"
aria-modal="true"
aria-label="Penampil foto trip"
className="fixed inset-0 z-50 flex flex-col bg-black/95"
>
{/* Top bar */}
<div className="flex items-center justify-between px-4 py-3 text-white">
<span className="text-sm font-medium tabular-nums">
{activeIndex + 1} / {images.length}
</span>
<button
type="button"
onClick={() => setLightboxOpen(false)}
aria-label="Tutup"
className="flex h-9 w-9 items-center justify-center rounded-full bg-white/10 transition-colors hover:bg-white/20"
>
<X size={20} strokeWidth={2} aria-hidden />
</button>
</div>
{/* Area gambar — klik latar untuk menutup */}
<div
className="relative flex-1"
onClick={() => setLightboxOpen(false)}
>
<div
className="relative h-full w-full"
onClick={(e) => e.stopPropagation()}
>
<Image
src={activeImage.url}
alt={activeImage.caption || "Foto trip"}
fill
className="object-contain"
sizes="100vw"
quality={90}
/>
</div>
{hasMultiple && (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
showPrev();
}}
aria-label="Foto sebelumnya"
className="absolute left-2 top-1/2 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/25 sm:left-4"
>
<ChevronLeft size={24} strokeWidth={2} aria-hidden />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
showNext();
}}
aria-label="Foto berikutnya"
className="absolute right-2 top-1/2 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/25 sm:right-4"
>
<ChevronRight size={24} strokeWidth={2} aria-hidden />
</button>
</>
)}
</div>
{activeImage.caption && (
<p className="px-4 py-3 text-center text-sm text-white/80">
{activeImage.caption}
</p>
)}
{/* Thumbnail strip di dalam lightbox */}
{hasMultiple && (
<div className="flex justify-center gap-1.5 overflow-x-auto px-4 pb-4">
{images.map((img, i) => (
<button
key={img.id}
type="button"
onClick={() => setActiveIndex(i)}
aria-label={`Lihat foto ${i + 1}`}
className={`relative h-12 w-16 shrink-0 overflow-hidden rounded-md transition-all ${
i === activeIndex
? "ring-2 ring-primary-400 ring-offset-1 ring-offset-black"
: "opacity-50 hover:opacity-100"
}`}
>
<Image
src={img.url}
alt=""
fill
className="object-cover"
sizes="64px"
/>
</button>
))}
</div>
)}
</div>
)}
</div> </div>
); );
} }
@@ -1,82 +0,0 @@
"use client";
import { LIMITS } from "@/lib/limits";
interface ImageUrlInputProps {
value: string[];
onChange: (urls: string[]) => void;
}
export function ImageUrlInput({ value, onChange }: ImageUrlInputProps) {
const urls = value.length > 0 ? value : [""];
const max = LIMITS.MAX_IMAGE_URLS;
function addField() {
if (urls.length < max) {
onChange([...urls, ""]);
}
}
function removeField(index: number) {
const next = urls.filter((_, i) => i !== index);
onChange(next.length > 0 ? next : [""]);
}
function updateField(index: number, next: string) {
const updated = [...urls];
updated[index] = next;
onChange(updated);
}
return (
<div>
<label className="mb-2 flex items-center justify-between">
<span className="text-sm font-semibold text-neutral-700">
Foto Trip (URL)
</span>
<span className="text-xs text-neutral-400">
{urls.length}/{max}
</span>
</label>
<div className="space-y-2">
{urls.map((url, i) => (
<div key={i} className="flex gap-2">
<input
type="url"
value={url}
onChange={(e) => updateField(i, e.target.value)}
className="flex-1 rounded-xl border border-neutral-200 bg-neutral-50 px-4 py-2.5 text-sm text-neutral-800 placeholder:text-neutral-400 focus:bg-white"
placeholder={
i === 0
? "URL foto utama (cover)"
: `URL foto ${i + 1} (opsional)`
}
/>
{urls.length > 1 && (
<button
type="button"
onClick={() => removeField(i)}
aria-label={`Hapus foto ${i + 1}`}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-neutral-200 text-neutral-400 hover:bg-red-50 hover:text-red-500"
>
</button>
)}
</div>
))}
</div>
{urls.length < max && (
<button
type="button"
onClick={addField}
className="mt-2 flex items-center gap-1 rounded-lg px-2 py-1 text-sm font-medium text-secondary-600 hover:bg-secondary-50"
>
+ Tambah foto
</button>
)}
<p className="mt-1.5 text-xs text-neutral-400">
Upload foto ke hosting (imgur, imgbb, dll) lalu paste URL-nya di sini.
</p>
</div>
);
}
@@ -143,7 +143,7 @@ export function JoinTripButton({
Kamu sudah{" "} Kamu sudah{" "}
<span className="font-semibold">terkonfirmasi</span> sebagai peserta <span className="font-semibold">terkonfirmasi</span> sebagai peserta
trip ini trip ini
{isFree && <span> trip gratis, tidak ada pembayaran 🎉</span>}. {isFree && <span> trip gratis, tidak ada pembayaran</span>}.
</div> </div>
)} )}
{needsPayment && ( {needsPayment && (
@@ -1,4 +1,5 @@
import Image from "next/image"; import Image from "next/image";
import { BadgeCheck, Star } from "lucide-react";
import type { OrganizerTrust } from "@/server/services/trust.service"; import type { OrganizerTrust } from "@/server/services/trust.service";
interface OrganizerTrustPanelProps { interface OrganizerTrustPanelProps {
@@ -13,7 +14,7 @@ export function OrganizerTrustPanel({
trust, trust,
}: OrganizerTrustPanelProps) { }: OrganizerTrustPanelProps) {
return ( return (
<div className="rounded-xl border border-neutral-200 bg-linear-to-br from-white to-neutral-50 p-4 sm:p-5"> <div className="rounded-xl border border-neutral-200 bg-white p-4 sm:p-5">
<h2 className="mb-3 text-xs font-bold text-neutral-700 sm:text-sm"> <h2 className="mb-3 text-xs font-bold text-neutral-700 sm:text-sm">
Organizer & kepercayaan Organizer & kepercayaan
</h2> </h2>
@@ -42,7 +43,8 @@ export function OrganizerTrustPanel({
className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-primary-800 sm:text-xs" className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-primary-800 sm:text-xs"
title="Identitas organizer telah diverifikasi (KTP & rekening)" title="Identitas organizer telah diverifikasi (KTP & rekening)"
> >
Verified Organizer <BadgeCheck size={12} strokeWidth={2} aria-hidden />
Verified Organizer
</span> </span>
)} )}
{trust.isTripLeader && ( {trust.isTripLeader && (
@@ -54,7 +56,7 @@ export function OrganizerTrustPanel({
</div> </div>
</div> </div>
<div className="flex flex-1 flex-wrap gap-3 border-t border-neutral-100 pt-3 sm:border-t-0 sm:border-l sm:pl-4 sm:pt-0"> <div className="flex flex-1 flex-wrap gap-3 border-t border-neutral-100 pt-3 sm:border-t-0 sm:border-l sm:pl-4 sm:pt-0">
<div className="min-w-[100px] rounded-lg bg-white/80 px-3 py-2 shadow-sm ring-1 ring-neutral-100"> <div className="min-w-25 rounded-lg bg-white/80 px-3 py-2 shadow-sm ring-1 ring-neutral-100">
<p className="text-[10px] font-medium text-neutral-500 sm:text-xs"> <p className="text-[10px] font-medium text-neutral-500 sm:text-xs">
Trip selesai Trip selesai
</p> </p>
@@ -67,7 +69,7 @@ export function OrganizerTrustPanel({
</p> </p>
)} )}
</div> </div>
<div className="min-w-[100px] rounded-lg bg-white/80 px-3 py-2 shadow-sm ring-1 ring-neutral-100"> <div className="min-w-25 rounded-lg bg-white/80 px-3 py-2 shadow-sm ring-1 ring-neutral-100">
<p className="text-[10px] font-medium text-neutral-500 sm:text-xs"> <p className="text-[10px] font-medium text-neutral-500 sm:text-xs">
Peserta dilayani Peserta dilayani
</p> </p>
@@ -75,12 +77,24 @@ export function OrganizerTrustPanel({
{trust.totalParticipantsServed} {trust.totalParticipantsServed}
</p> </p>
</div> </div>
<div className="min-w-[100px] rounded-lg bg-white/80 px-3 py-2 shadow-sm ring-1 ring-neutral-100"> <div className="min-w-25 rounded-lg bg-white/80 px-3 py-2 shadow-sm ring-1 ring-neutral-100">
<p className="text-[10px] font-medium text-neutral-500 sm:text-xs"> <p className="text-[10px] font-medium text-neutral-500 sm:text-xs">
Rating organizer Rating organizer
</p> </p>
<p className="text-lg font-bold text-amber-700"> <p className="flex items-center gap-1 text-lg font-bold text-amber-700">
{trust.avgRating != null ? `${trust.avgRating}` : "—"} {trust.avgRating != null ? (
<>
{trust.avgRating}
<Star
size={15}
strokeWidth={2}
fill="currentColor"
aria-hidden
/>
</>
) : (
"—"
)}
</p> </p>
{trust.reviewCount > 0 && ( {trust.reviewCount > 0 && (
<p className="text-[10px] text-neutral-400"> <p className="text-[10px] text-neutral-400">
+33 -8
View File
@@ -1,5 +1,12 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import {
MapPin,
CalendarDays,
UserRound,
BadgeCheck,
Sparkles,
} from "lucide-react";
import { formatRupiah } from "@/lib/utils"; import { formatRupiah } from "@/lib/utils";
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates"; import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
import { categoryMeta } from "@/lib/activity-category"; import { categoryMeta } from "@/lib/activity-category";
@@ -132,21 +139,38 @@ export function TripCard({
<div className="mt-3 space-y-1 text-sm text-neutral-600"> <div className="mt-3 space-y-1 text-sm text-neutral-600">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="text-xs text-secondary-500">📍</span> {location} <MapPin
size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0 text-neutral-400"
/>
<span className="truncate">{location}</span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="text-xs text-secondary-500">📅</span>{" "} <CalendarDays
{formatTripCalendarDateRangeLong(date, endDate)} size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0 text-neutral-400"
/>
<span>{formatTripCalendarDateRangeLong(date, endDate)}</span>
</div> </div>
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs text-secondary-500">👤</span>{" "} <UserRound
size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0 text-neutral-400"
/>
<span className="truncate">{organizerName}</span> <span className="truncate">{organizerName}</span>
{isVerifiedOrganizer && ( {isVerifiedOrganizer && (
<span <span
className="inline-flex items-center gap-0.5 rounded-full bg-primary-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-primary-800" className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-primary-800"
title="Identitas organizer telah diverifikasi (KTP & rekening)" title="Identitas organizer telah diverifikasi (KTP & rekening)"
> >
Verified <BadgeCheck size={11} strokeWidth={2} aria-hidden />
Verified
</span> </span>
)} )}
{isSmallGroup && ( {isSmallGroup && (
@@ -193,10 +217,11 @@ export function TripCard({
)} )}
{overlapCount > 0 && ( {overlapCount > 0 && (
<span <span
className="rounded-full bg-secondary-50 px-2 py-0.5 text-[11px] font-semibold text-secondary-700" className="inline-flex items-center gap-1 rounded-full bg-secondary-50 px-2 py-0.5 text-[11px] font-semibold text-secondary-700"
title="Peserta dengan minimal 1 minat sama dengan kamu" title="Peserta dengan minimal 1 minat sama dengan kamu"
> >
{overlapCount} peserta sama minat <Sparkles size={11} strokeWidth={2} aria-hidden />
{overlapCount} peserta sama minat
</span> </span>
)} )}
</div> </div>
+17 -44
View File
@@ -2,9 +2,12 @@
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import DatePicker from "react-datepicker"; import { Search } from "lucide-react";
import "react-datepicker/dist/react-datepicker.css"; import { DateRangeField } from "@/components/shared/date-picker";
import { formatLocalCalendarYmd } from "@/lib/trip-dates"; import {
formatLocalCalendarYmd,
localCalendarDateFromYmd,
} from "@/lib/trip-dates";
import { import {
ACTIVITY_CATEGORIES, ACTIVITY_CATEGORIES,
categoryMeta, categoryMeta,
@@ -43,12 +46,14 @@ export function TripFilter() {
isGroupSize(initialGroup) ? initialGroup : null isGroupSize(initialGroup) ? initialGroup : null
); );
const [query, setQuery] = useState(searchParams.get("q") ?? ""); const [query, setQuery] = useState(searchParams.get("q") ?? "");
const [startDate, setStartDate] = useState<Date | null>( const [startDate, setStartDate] = useState<Date | null>(() => {
searchParams.get("from") ? new Date(searchParams.get("from")!) : null const from = searchParams.get("from");
); return from ? localCalendarDateFromYmd(from) : null;
const [endDate, setEndDate] = useState<Date | null>( });
searchParams.get("to") ? new Date(searchParams.get("to")!) : null const [endDate, setEndDate] = useState<Date | null>(() => {
); const to = searchParams.get("to");
return to ? localCalendarDateFromYmd(to) : null;
});
function buildParams(overrides?: { function buildParams(overrides?: {
category?: ActivityCategory | null; category?: ActivityCategory | null;
@@ -259,18 +264,7 @@ export function TripFilter() {
</label> </label>
<div className="relative"> <div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"> <span className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400">
<svg <Search size={16} strokeWidth={1.75} aria-hidden />
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4"
>
<path
fillRule="evenodd"
d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
clipRule="evenodd"
/>
</svg>
</span> </span>
<input <input
type="text" type="text"
@@ -287,34 +281,13 @@ export function TripFilter() {
<label className="mb-1.5 block text-xs font-medium text-neutral-500"> <label className="mb-1.5 block text-xs font-medium text-neutral-500">
Tanggal Tanggal
</label> </label>
<div className="relative"> <DateRangeField
<span className="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>
<DatePicker
selectsRange
startDate={startDate} startDate={startDate}
endDate={endDate} endDate={endDate}
onChange={handleDateChange} onChange={(start, end) => handleDateChange([start, end])}
minDate={new Date()} minDate={new Date()}
placeholderText="Pilih tanggal..."
dateFormat="dd MMM yyyy"
isClearable
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 py-2.5 pl-9 pr-3 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-primary-500 focus:bg-white"
/> />
</div> </div>
</div>
{/* Buttons */} {/* Buttons */}
<div className="flex gap-2 sm:shrink-0"> <div className="flex gap-2 sm:shrink-0">
@@ -0,0 +1,175 @@
"use client";
import { useRef, useState } from "react";
import Image from "next/image";
import { ImagePlus, X, Loader2 } from "lucide-react";
import { LIMITS } from "@/lib/limits";
interface TripImageUploadProps {
/** URL gambar yang sudah terunggah (path `/api/trip-images/...`). */
value: string[];
onChange: (urls: string[]) => void;
/** Lapor error ke form (mis. file terlalu besar / gagal upload). */
onError?: (msg: string) => void;
}
const ACCEPT_MIME = "image/jpeg,image/png,image/webp";
/** Sinkron dengan MAX_TRIP_IMAGE_UPLOAD_BYTES di lib/trip-image-storage.ts. */
const MAX_BYTES = 12 * 1024 * 1024;
/**
* Pengganti input URL foto: user memilih file dari perangkatnya, tiap file
* langsung di-upload & dikompres server-side. Form hanya menyimpan URL hasil.
*/
export function TripImageUpload({
value,
onChange,
onError,
}: TripImageUploadProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [uploadingCount, setUploadingCount] = useState(0);
const max = LIMITS.MAX_IMAGE_URLS;
const usedSlots = value.length + uploadingCount;
const remaining = max - usedSlots;
async function uploadOne(file: File): Promise<string | null> {
if (!ACCEPT_MIME.split(",").includes(file.type)) {
onError?.(`"${file.name}" harus JPG, PNG, atau WebP`);
return null;
}
if (file.size > MAX_BYTES) {
onError?.(`"${file.name}" melebihi 12MB`);
return null;
}
const fd = new FormData();
fd.set("file", file);
try {
const res = await fetch("/api/upload/trip-image", {
method: "POST",
body: fd,
});
const json = await res.json();
if (!res.ok) {
onError?.(json.error ?? `Gagal mengunggah "${file.name}"`);
return null;
}
return json.url as string;
} catch {
onError?.(`Gagal mengunggah "${file.name}"`);
return null;
}
}
async function handlePick(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? []);
e.target.value = "";
if (files.length === 0) return;
if (remaining <= 0) {
onError?.(`Maksimal ${max} foto`);
return;
}
const picked = files.slice(0, remaining);
if (files.length > remaining) {
onError?.(`Hanya ${remaining} foto pertama diunggah (maks ${max})`);
}
setUploadingCount((c) => c + picked.length);
// `value` di-snapshot saat handler dibuat; upload sekuensial supaya urutan
// foto stabil, lalu hasil yang berhasil ditambahkan sekali di akhir.
const uploaded: string[] = [];
for (const file of picked) {
const url = await uploadOne(file);
setUploadingCount((c) => c - 1);
if (url) uploaded.push(url);
}
if (uploaded.length > 0) onChange([...value, ...uploaded]);
}
function removeAt(index: number) {
onChange(value.filter((_, i) => i !== index));
}
return (
<div>
<label className="mb-2 flex items-center justify-between">
<span className="text-sm font-semibold text-neutral-700">
Foto Trip
</span>
<span className="text-xs text-neutral-400">
{value.length}/{max}
</span>
</label>
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
{value.map((url, i) => (
<div
key={url}
className="group relative aspect-square overflow-hidden rounded-xl border border-neutral-200 bg-neutral-100"
>
<Image
src={url}
alt={i === 0 ? "Foto cover" : `Foto ${i + 1}`}
fill
className="object-cover"
sizes="(max-width: 640px) 33vw, 160px"
/>
{i === 0 && (
<span className="absolute left-1 top-1 rounded-md bg-primary-600/90 px-1.5 py-0.5 text-[10px] font-bold text-white">
Cover
</span>
)}
<button
type="button"
onClick={() => removeAt(i)}
aria-label={`Hapus foto ${i + 1}`}
className="absolute right-1 top-1 flex h-6 w-6 items-center justify-center rounded-full bg-black/55 text-white transition-colors hover:bg-red-600"
>
<X size={13} strokeWidth={2.5} aria-hidden />
</button>
</div>
))}
{Array.from({ length: uploadingCount }).map((_, i) => (
<div
key={`uploading-${i}`}
className="flex aspect-square items-center justify-center rounded-xl border border-dashed border-neutral-300 bg-neutral-50"
>
<Loader2
size={20}
strokeWidth={2}
aria-hidden
className="animate-spin text-neutral-400"
/>
</div>
))}
{remaining > 0 && (
<button
type="button"
onClick={() => inputRef.current?.click()}
className="flex aspect-square flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-neutral-300 bg-neutral-50/60 text-neutral-500 transition-colors hover:border-primary-400 hover:text-primary-600"
>
<ImagePlus size={20} strokeWidth={1.75} aria-hidden />
<span className="text-[11px] font-semibold">Tambah</span>
</button>
)}
</div>
<input
ref={inputRef}
type="file"
accept={ACCEPT_MIME}
multiple
onChange={handlePick}
className="sr-only"
/>
<p className="mt-1.5 text-xs text-neutral-400">
Unggah langsung dari galeri/kamera JPG, PNG, atau WebP, maks 12MB per
foto. Foto pertama jadi cover. Gambar besar otomatis dikompres tanpa
mengorbankan kualitas.
</p>
</div>
);
}
+10 -2
View File
@@ -9,13 +9,21 @@ import {
} from "@/lib/trip-dates"; } from "@/lib/trip-dates";
import { isValidTimeFormat, timeToMinutes } from "@/lib/itinerary"; import { isValidTimeFormat, timeToMinutes } from "@/lib/itinerary";
/**
* Foto trip sekarang adalah file yang diunggah ke server sendiri, bukan URL
* eksternal. Nilai yang valid hanya path terkelola `/api/trip-images/<hex>.webp`
* yang dihasilkan route upload — regex ini sengaja ketat supaya URL arbitrary
* (yang dulu sering tidak reachable dari server) tidak bisa lolos lagi.
*/
export const tripImageUrlsSchema = z export const tripImageUrlsSchema = z
.array( .array(
z z
.string() .string()
.trim() .trim()
.max(LIMITS.MAX_URL_LENGTH, "URL terlalu panjang") .regex(
.url("Setiap URL gambar harus valid (http/https)") /^\/api\/trip-images\/[a-f0-9]{32}\.webp$/,
"Foto trip tidak valid — silakan unggah ulang fotonya"
)
) )
.max(LIMITS.MAX_IMAGE_URLS, `Maksimal ${LIMITS.MAX_IMAGE_URLS} foto`); .max(LIMITS.MAX_IMAGE_URLS, `Maksimal ${LIMITS.MAX_IMAGE_URLS} foto`);
+25
View File
@@ -0,0 +1,25 @@
/**
* Push notif eksternal untuk admin saat ada cron FAILED atau alert critical.
* Saat ini support Discord webhook (paling simple). Fail silent — kalau env
* tidak di-set, no-op. Kalau request gagal, console.error tapi tidak throw.
*
* Env `ADMIN_ALERT_WEBHOOK_URL` — Discord channel webhook URL.
*/
export async function notifyAdmins(message: string): Promise<void> {
const webhookUrl = process.env.ADMIN_ALERT_WEBHOOK_URL;
if (!webhookUrl) return;
try {
await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
// Format Discord webhook: `content` adalah body message plaintext/markdown.
content: `🚨 **SeTrip Admin Alert**\n${message}`,
}),
});
} catch (err) {
console.error("[admin-notify] gagal kirim ke webhook", err);
}
}
+6
View File
@@ -1,4 +1,5 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { notifyAdmins } from "@/lib/admin-notify";
/** /**
* Wrapper untuk cron route handler — otomatis log start/finish/error ke * Wrapper untuk cron route handler — otomatis log start/finish/error ke
@@ -62,6 +63,11 @@ export async function runCron<T>(
}) })
.catch((e) => console.error(`[cron-runner] gagal update FAILED`, e)); .catch((e) => console.error(`[cron-runner] gagal update FAILED`, e));
} }
// Fire-and-forget — jangan blok response cron route handler. Notif gagal
// di-log sendiri di `notifyAdmins`.
void notifyAdmins(
`Cron \`${jobName}\` FAILED: ${message}`
);
return { ok: false, error: message }; return { ok: false, error: message };
} }
} }

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