Compare commits

..

28 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
109 changed files with 7740 additions and 2442 deletions
+2 -1
View File
@@ -10,7 +10,8 @@
"PowerShell(npx prisma generate 2>&1)",
"PowerShell(npx tsc --noEmit 2>&1)",
"PowerShell(npx eslint server/services/refund.service.ts server/repositories/refund.repo.ts features/refund app/admin/refunds 2>&1)",
"PowerShell(npx eslint server lib features app 2>&1)"
"PowerShell(npx eslint server lib features app 2>&1)",
"Bash(npx eslint *)"
]
}
}
+24
View File
@@ -12,6 +12,9 @@ KYC_ENCRYPTION_KEY=
KYC_NIK_PEPPER=
# Absolute path for private KYC uploads (default: <cwd>/uploads/private)
KYC_UPLOAD_DIR=
# Absolute path for public trip image uploads (default: <cwd>/uploads/trips)
# Pakai volume persisten — file di sini harus selamat saat redeploy/restart.
TRIP_UPLOAD_DIR=
GOOGLE_CLIENT_ID="xxxxxxxx"
GOOGLE_CLIENT_SECRET="xxxxxxxx"
@@ -36,3 +39,24 @@ NEXT_PUBLIC_MIDTRANS_IS_PRODUCTION=false
# openssl rand -hex 32
# Setup detail: lihat docs/CRON_SETUP.md
CRON_SECRET=
# === Admin alerting (opsional) ===
# Discord webhook URL untuk push notif saat cron FAILED. Tanpa env, `notifyAdmins`
# no-op — admin tetap bisa cek manual di /admin/system. Cara setup:
# 1. Discord channel internal → Edit Channel → Integrations → Webhooks → New
# 2. Copy "Webhook URL", paste di sini
# Format: https://discord.com/api/webhooks/<id>/<token>
ADMIN_ALERT_WEBHOOK_URL=
# === Email notifications (Resend) ===
# API key Resend untuk kirim email transaksional (KYC, refund, payment, suspend).
# Tanpa env, sync send di-skip dan semua email di-queue di DB (status PENDING).
# Setelah env di-set, cron `/api/cron/process-email-jobs` akan drain queue.
# Sign up: https://resend.com → API Keys
RESEND_API_KEY=
# Email sender — format RFC 5322 "Display Name <email@domain>".
# Domain harus diverifikasi di Resend dashboard (SPF + DKIM).
# Default `onboarding@resend.dev` cocok untuk dev/testing.
EMAIL_FROM="SeTrip <onboarding@resend.dev>"
+2 -1
View File
@@ -36,7 +36,8 @@ yarn-error.log*
.env.development
.env.local
# private uploads (KYC: KTP / liveness). Never serve directly.
# runtime uploads KYC (encrypted, private) & trip images (public, served via
# /api/trip-images). User data, not source: keep out of git, back up separately.
/uploads/
# vercel
+62 -43
View File
@@ -1,9 +1,11 @@
# 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.
> **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
@@ -13,73 +15,90 @@ Status implementasi kemampuan admin agar admin **dapat mengontrol seluruh aplika
| **Dashboard** | View count: verifikasi PENDING, refund per status, payout per status | [app/admin/page.tsx](app/admin/page.tsx) |
| **Global search** | Search bar di sidebar — by email, order_id, cuid, fuzzy trip/user | [features/admin/components/admin-search-bar.tsx](features/admin/components/admin-search-bar.tsx) |
| **Trips** | List + search + detail; force-cancel dengan auto-refund (admin intervention) | [app/admin/trips/](app/admin/trips/) |
| **Users** | List + search + filter (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) |
| **Verifikasi KYC** | Approve / Reject / Reopen REJECTED; filter date range + reviewer; CSV export | [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; CSV export | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) |
| **Payout** | View per status, mark PAID; filter date/processor; link ke booking timeline; CSV export | [app/admin/payouts/page.tsx](app/admin/payouts/page.tsx) |
| **Audit Log** | View semua action admin lintas entity (refund, payout, trip cancel, suspend, dst); 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, 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.
**Audit trail otomatis:** semua aksi admin (suspend, force-cancel, reconcile, approve/reject verification/refund, mark payout PAID, reopen verification) tercatat di `AdminActionLog` via `auditLog.record()`.
**Audit trail otomatis:** semua aksi admin (suspend, force-cancel, reconcile, approve/reject/reopen/request-reupload/manual-override verification, create/decide refund, mark payout PAID) tercatat di `AdminActionLog` via `auditLog.record()`.
Auth admin: env `ADMIN_EMAILS` → cek di [lib/admin.ts](lib/admin.ts), dipassing ke session via [lib/auth.ts](lib/auth.ts).
---
## Roadmap per area
## Roadmap per area — status final
| Roadmap | Prioritas | Status | File |
| Roadmap | Prioritas | Status | Archive |
|---|---|---|---|
| Trip Operations | 🔴 HIGH | ✅ **Delivered** | [docs/archive/ADMIN_TRIP_OPS_ROADMAP.md](docs/archive/ADMIN_TRIP_OPS_ROADMAP.md) |
| Payment Operations | 🔴 HIGH | ✅ **Delivered** | [docs/archive/ADMIN_PAYMENT_OPS_ROADMAP.md](docs/archive/ADMIN_PAYMENT_OPS_ROADMAP.md) |
| Audit & Investigation | 🔴 HIGH | ✅ **Delivered** | [docs/archive/ADMIN_AUDIT_ROADMAP.md](docs/archive/ADMIN_AUDIT_ROADMAP.md) |
| User Management | 🟡 MEDIUM | ✅ **Delivered** | [docs/archive/ADMIN_USER_MGMT_ROADMAP.md](docs/archive/ADMIN_USER_MGMT_ROADMAP.md) |
| Verification | 🟡 MEDIUM | 🚧 Phase 1 done · 2-4 deferred | [docs/archive/ADMIN_VERIFICATION_ROADMAP.md](docs/archive/ADMIN_VERIFICATION_ROADMAP.md) |
| System Health | 🟡 MEDIUM | 🚧 Phase 1-2 done · 3-4 deferred | [docs/archive/ADMIN_SYSTEM_HEALTH_ROADMAP.md](docs/archive/ADMIN_SYSTEM_HEALTH_ROADMAP.md) |
| Trip Operations | 🔴 HIGH | ✅ Delivered | [ADMIN_TRIP_OPS_ROADMAP.md](docs/archive/ADMIN_TRIP_OPS_ROADMAP.md) |
| Payment Operations | 🔴 HIGH | ✅ Delivered | [ADMIN_PAYMENT_OPS_ROADMAP.md](docs/archive/ADMIN_PAYMENT_OPS_ROADMAP.md) |
| Audit & Investigation | 🔴 HIGH | ✅ Delivered | [ADMIN_AUDIT_ROADMAP.md](docs/archive/ADMIN_AUDIT_ROADMAP.md) |
| User Management | 🟡 MEDIUM | ✅ Delivered | [ADMIN_USER_MGMT_ROADMAP.md](docs/archive/ADMIN_USER_MGMT_ROADMAP.md) |
| Verification | 🟡 MEDIUM | ✅ Delivered | [ADMIN_VERIFICATION_ROADMAP.md](docs/archive/ADMIN_VERIFICATION_ROADMAP.md) |
| System Health | 🟡 MEDIUM | ✅ Delivered | [ADMIN_SYSTEM_HEALTH_ROADMAP.md](docs/archive/ADMIN_SYSTEM_HEALTH_ROADMAP.md) |
**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 (semua deferred — low priority)
- **Verification Phase 2** Re-upload request flow (butuh schema + organizer-side UI)
- **Verification Phase 3** Verification history (audit trail multi-submission)
- **Verification Phase 4** Manual override (admin verify tanpa upload, untuk referral)
- **System Health Phase 3** Stale state alerts (Payment AWAITING > 25h, Payout HELD overdue)
- **System Health Phase 4** External alerting (Discord webhook)
- **User Mgmt Phase 3** Bulk analytics dashboard
Tidak ada yang blocking. Diangkat saat ada incident konkret atau permintaan stakeholder.
---
## Tindakan manual setelah deploy
Untuk versi yang berisi delivery 6 roadmap admin:
## Tindakan manual setelah deploy versi final
```bash
# Apply 4 migration baru
npx prisma migrate deploy
# 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
# 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
```
Brief admin tentang kapabilitas baru:
Brief admin tentang kapabilitas baru (lihat archive masing-masing untuk detail SOP):
- **Global search** di sidebar — ketik email, order_id, atau cuid; auto-detect ke detail page yang tepat.
- **Force-cancel trip** di `/admin/trips/[id]` 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.
- **Suspend user** di `/admin/users/[id]` pakai untuk scam/harassment. Suspended user diblokir sign-in dan aksi mutatif.
- **Reopen verification** di `/admin/verifications` (tab REJECTED) — saat organizer kirim ulang foto via email/WA.
- **System status** di `/admin/system` — cek setiap pagi, pastikan cron jalan (🟢 OK).
- **Audit log** di `/admin/audit-log` — bukti compliance saat audit eksternal; semua aksi admin tercatat dengan email + payload.
- **CSV export** di refunds/payouts/verifications — download untuk laporan keuangan/compliance.
- **Filter date range + reviewer** di refunds/payouts/verifications — untuk investigasi.
- **Force-cancel trip** di `/admin/trips/[id]` — saat organizer unreachable / dispute.
- **Reconcile Midtrans** di `/admin/bookings/[id]` — saat peserta lapor "sudah bayar tapi status belum update".
- **Suspend user** di `/admin/users/[id]` — untuk scam/harassment.
- **Manual verify** di `/admin/users/[id]` — partner trusted, bypass KYC, ter-flag jelas.
- **Reopen verification** di REJECTED card — organizer kirim ulang foto via email/WA.
- **Request re-upload** di PENDING card — lebih lembut dari reject; organizer dapat banner di `/verify`.
- **System status** di `/admin/system` — cek setiap pagi, lihat alert stale + cron health.
- **Discord alert** otomatis saat cron FAILED (kalau `ADMIN_ALERT_WEBHOOK_URL` di-set).
- **Audit log** di `/admin/audit-log` — bukti compliance untuk audit eksternal.
- **CSV export** di refunds/payouts/verifications — laporan keuangan/compliance.
- **User stats** di `/admin/users/stats` — total user, signup per minggu.
---
## File-file penting yang ditambahkan / diubah
**Service & helper:**
- [server/services/audit-log.service.ts](server/services/audit-log.service.ts) — log polymorphic
- [server/services/system-health.service.ts](server/services/system-health.service.ts) — stale state detection
- [server/services/admin-search.service.ts](server/services/admin-search.service.ts) — search dispatcher
- [server/services/user.service.ts](server/services/user.service.ts) — suspend/unsuspend
- [lib/cron-runner.ts](lib/cron-runner.ts) — `runCron()` wrapper
- [lib/admin-notify.ts](lib/admin-notify.ts) — Discord webhook helper
- [lib/auth-guards.ts](lib/auth-guards.ts) — `requireActiveUser()`
- [lib/csv.ts](lib/csv.ts) — CSV builder
**Model baru:** `AdminActionLog`, `CronRun` + 5 migration baru di `prisma/migrations/`.
**Admin UI baru:** `/admin/trips`, `/admin/users`, `/admin/bookings/[id]`, `/admin/system`, `/admin/audit-log`, `/admin/users/stats`.
**API routes baru:** `/api/admin/search`, `/api/admin/export/{refunds,payouts,verifications}`.
+165
View File
@@ -0,0 +1,165 @@
# Setrip — Email Notifications Roadmap
Status implementasi notifikasi email transaksional ke user & organizer. Pakai pola yang sama dengan refund/admin roadmap: per-phase checklist, idempotent, auditable.
> **Prinsip:**
> - **Transactional only di MVP** — KYC, refund, payment, account moderation. Marketing/reminder belakangan.
> - **Idempotent** — webhook retry / cron rerun tidak boleh double-send. Pakai `idempotencyKey` unique constraint di `EmailSent`.
> - **Non-blocking** — server action utama tidak boleh gagal kalau email gateway down. Pattern: try send sync; kalau gagal, enqueue `EmailJob` untuk retry cron.
> - **Audit-friendly** — semua email tercatat (sent atau queued) supaya admin bisa cek "kenapa user X belum dapat email Y?".
> - **Unsubscribe-aware** — transactional email (refund, payment, suspend) tetap dikirim. Marketing (reminder, social signal) opt-in dengan unsubscribe link.
**Progress (per 2026-05-20):** PR-E1, E2, E3, E5 ✅ — foundation, transactional email, notifikasi event Phase 2, dan admin email log + retry selesai. PR-E4 ⏳ (marketing/reminder) sengaja ditunda — belum dibutuhkan.
---
## Baseline (kondisi sekarang)
- ❌ Tidak ada email service terintegrasi.
- ❌ Tidak ada template engine.
- ❌ User & organizer hanya tahu state via UI — kalau tidak buka app, miss event penting (refund cair, KYC approve, dst).
---
## Provider choice
**Resend** — alasan:
- Free tier 3000 email/bulan + 100/hari (cukup untuk MVP)
- React Email native (kalau mau upgrade dari plain HTML)
- API simple (POST `/emails`)
- DNS setup ringan (SPF + DKIM auto)
Alternatif: AWS SES (paling murah at scale), SendGrid, Mailgun. **Decision:** Resend untuk MVP, evaluate ulang saat lebih dari 50k email/bulan.
**Library dependency:** SKIP — pakai `fetch` ke `https://api.resend.com/emails` directly. Lebih ringkas, satu-satunya dependency-free option.
---
## PR-E1 — Foundation: schema + service + cron (MVP wajib) ✅
**Tujuan:** infrastructure kirim email yang idempotent + retry-able, tanpa nge-block server action.
**Keputusan asumsi:**
- 2 model baru:
- `EmailSent` — append-only log dengan `idempotencyKey @unique`. Cek di sini sebelum kirim → cegah double-send.
- `EmailJob` — retry queue untuk send yang gagal sync. Status `PENDING/PROCESSING/SUCCESS/FAILED`, attempt counter, max 5 retry exponential.
- Service `emailService.send({ to, template, data, idempotencyKey })`:
1. Cek `EmailSent` by idempotencyKey → kalau exist, return early.
2. Render template ke `{ subject, html }`.
3. POST ke Resend.
4. Sukses → insert `EmailSent` row.
5. Gagal → insert `EmailJob` row (status PENDING, attempts=1).
- Cron `/api/cron/process-email-jobs` setiap 5 menit — pick PENDING + FAILED (attempts<5), retry, mark SUCCESS atau bump attempts.
- Caller pattern: `void emailService.send(...)` (fire-and-forget) supaya tidak nge-block server action. Try/catch internal sudah handle error.
| # | Item | Status | File |
|---|---|---|---|
| E1.1 | Model `EmailSent { idempotencyKey @unique, to, template, sentAt, providerMessageId? }` + migration | ✅ | [prisma/schema.prisma](prisma/schema.prisma) |
| E1.2 | Model `EmailJob { idempotencyKey, to, subject, html, status, attempts, lastError?, scheduledAt }` + migration | ✅ | [prisma/schema.prisma](prisma/schema.prisma) |
| E1.3 | Service `lib/email/send.ts` — Resend client (raw fetch) + idempotency + enqueue on failure | ✅ | `lib/email/send.ts` |
| E1.4 | Template registry — function per template return `{ subject, html }` | ✅ | `lib/email/templates.ts` |
| E1.5 | Cron route `/api/cron/process-email-jobs` pakai `runCron` helper | ✅ | `app/api/cron/process-email-jobs/route.ts` |
| E1.6 | Env: `RESEND_API_KEY`, `EMAIL_FROM` (mis. `"SeTrip <no-reply@setrip.id>"`) | ✅ | `.env.example` |
**Tindakan manual ops:**
1. Buat akun Resend, verify domain `setrip.id` (atau pakai `onboarding@resend.dev` di dev).
2. Set DNS SPF + DKIM record di provider domain.
3. Generate API key, set env `RESEND_API_KEY` + `EMAIL_FROM` di production.
4. Daftarkan cron baru di system crontab: `*/5 * * * * curl ... /api/cron/process-email-jobs`.
---
## PR-E2 — Phase 1: Wire transactional emails (MVP wajib) ✅
**Tujuan:** kirim email untuk event paling kritis (KYC, refund, payment, suspend) — yang kalau miss bikin user kehilangan uang atau bingung permanen.
| # | Trigger | Penerima | Template | Wire point | Status |
|---|---|---|---|---|---|
| E2.1 | KYC `VERIFICATION_APPROVE` | User | `kyc_approved` | `reviewVerificationAction` | ✅ |
| E2.2 | KYC `VERIFICATION_REJECT` | User | `kyc_rejected` (sertakan reason) | `reviewVerificationAction` | ✅ |
| E2.3 | KYC re-upload request | User | `kyc_reupload_request` (fields + note) | `requestReuploadAction` | ✅ |
| E2.4 | Refund created (admin atau auto-trigger) | User | `refund_created` (amount + reason) | `createRefundAction` + `tripService.closeTrip` (loop semua peserta PAID) | ✅ |
| E2.5 | Refund SUCCEEDED | User | `refund_succeeded` (amount, cek rekening) | `decideRefundAction` (decision=SUCCEEDED) | ✅ |
| E2.6 | Refund FAILED | User | `refund_failed` (alasan + langkah next) | `decideRefundAction` (decision=FAILED) | ✅ |
| E2.7 | Midtrans webhook PAID | User | `payment_paid` (terima kasih + detail booking) | `paymentService.applyGatewayStatus` (di branch PAID success) | ✅ |
| E2.8 | Booking di-approve organizer (status → AWAITING_PAY) | User | `booking_approved` (link bayar + deadline) | `confirmParticipantAction` | ✅ |
| E2.9 | Account suspend | User | `account_suspended` (reason + email support) | `suspendUserAction` | ✅ |
> ️ **E2.4** — jalur admin (`createRefundAction`) kirim `refund_created`. Untuk auto-refund saat trip dibatalkan, peserta dikabari lewat email `trip_cancelled_organizer` / `trip_cancelled_admin` (E3.4/E3.5) yang sudah memuat blok nominal refund — satu email konsolidasi, bukan dua.
**Format idempotencyKey:**
- `kyc_approved-<verificationId>`
- `refund_succeeded-<refundId>`
- `payment_paid-<paymentId>`
- `booking_approved-<bookingId>`
- `account_suspended-<userId>-<suspendedAt>` (allow re-suspend kalau diulang)
**Tindakan manual ops:**
1. Test setiap template di staging — render via Resend "Send test" atau preview HTML lokal.
2. Pastikan `EMAIL_FROM` domain match SPF/DKIM supaya tidak masuk spam.
---
## PR-E3 — Phase 2: UX enhancement (post-MVP) ✅
Email yang berguna tapi tidak critical kalau miss.
| # | Trigger | Penerima | Template | Wire point | Status |
|---|---|---|---|---|---|
| E3.1 | User join trip (PENDING) | Organizer | `join_request` (siapa, link approve) | `joinTripAction` | ✅ |
| E3.2 | Organizer reject join | User | `join_rejected` (singkat) | `rejectParticipantAction` | ✅ |
| E3.3 | Payment EXPIRED / FAILED | User | `payment_expired` (suruh start ulang) | `paymentService.applyGatewayStatus` | ✅ |
| E3.4 | Trip CLOSED (organizer cancel) | Semua peserta aktif | `trip_cancelled_organizer` (batch) | `tripService.closeTrip` (organizer actor) | ✅ |
| E3.5 | Trip CLOSED (admin force-cancel) | Semua peserta + organizer | `trip_cancelled_admin` (reason) | `tripService.closeTrip` (admin actor) | ✅ |
| E3.6 | Payout RELEASED | Organizer | `payout_released` (ETA transfer dari admin) | `payoutService.releaseEligible` | ✅ |
| E3.7 | Payout PAID | Organizer | `payout_paid` (cek rekening + adminNote) | `markPayoutPaidAction` | ✅ |
| E3.8 | Account unsuspended | User | `account_unsuspended` | `unsuspendUserAction` | ✅ |
| E3.9 | KYC submitted | User | `kyc_submitted` (ETA review) | `submitVerificationAction` | ✅ |
| E3.10 | KYC manual override | User | `kyc_manual_override` (admin verified) | `manualOverrideVerificationAction` | ✅ |
| E3.11 | KYC reopened (admin) | User | `kyc_reopened` (boleh submit ulang) | `reopenVerificationAction` | ✅ |
**Wire point email:** semua dikirim `void emailService.send(...)` (fire-and-forget, idempotent). Untuk batch trip-cancelled, `closeTrip` mengembalikan daftar penerima + nominal refund; email dikirim oleh action setelah transaksi commit (bukan di dalam tx).
---
## PR-E4 — Phase 3: Marketing / reminder (post-MVP, opt-in) ⏳
Email engagement — perlu user preference + unsubscribe link.
| # | Trigger | Penerima | Template | Wire point |
|---|---|---|---|---|
| E4.1 | Welcome email saat signup | User | `welcome` | NextAuth `events.signIn` first time |
| E4.2 | Reminder H-3 keberangkatan | User | `trip_reminder_h3` (meeting point + itinerary) | Cron daily |
| E4.3 | Reminder H-1 keberangkatan | User | `trip_reminder_h1` | Cron daily |
| E4.4 | Trip selesai → minta review (H+1) | User CONFIRMED | `review_prompt` | Cron daily |
| E4.5 | Review baru diterima | Organizer | `new_review` | `createReviewAction` |
| E4.6 | Trip jadi FULL | Organizer | `trip_full` | `tripService.joinTrip` (saat FULL transition) |
**Prerequisite:** tabel `UserEmailPreference` dengan kategori `marketing` / `reminders` + unsubscribe token. Skip sampai Phase 4.
---
## PR-E5 — Admin UI: email log + queue retry (post-MVP) ✅
Visibilitas admin untuk troubleshoot "kenapa user X tidak dapat email?".
| # | Item | Status | File |
|---|---|---|---|
| E5.1 | Page `/admin/emails` — list `EmailSent` + `EmailJob`, filter recipient + template, status lewat tab | ✅ | `app/admin/emails/page.tsx` |
| E5.2 | Tombol "Kirim ulang" untuk EmailJob gagal/antri — retry sync langsung | ✅ | `features/email/components/email-row-actions.tsx` |
| E5.3 | Tombol "Resend" untuk EmailSent — key turunan `#resend-<ts>`, butuh `EmailSent.html` | ✅ | `features/email/actions.ts` |
| E5.4 | Stats card di `/admin/system` + `/admin/emails`: antri, gagal 24 jam, perlu aksi manual | ✅ | `app/admin/system/page.tsx` |
**Tindakan manual ops:**
1. Run migration `20260520000000_add_email_sent_html` (kolom `EmailSent.html`) di staging → production. Tanpa ini, resend (E5.3) tidak tersedia untuk email yang dikirim sebelum migration.
2. Tambahkan `/admin/emails` ke admin nav — sudah dilakukan di `components/admin/admin-sidebar.tsx`.
> ️ Deviasi minor dari rencana awal: filter tanggal tidak diimplementasikan (list dibatasi 100 baris terbaru); filter status diwujudkan sebagai tab (Gagal / Antrian / Terkirim).
---
## Skip / never (eksplisit)
- ❌ SMS / WhatsApp — beda regulatory, beda cost. Stick to email.
- ❌ Push notification (browser/mobile) — perlu PWA setup terpisah.
- ❌ In-app inbox — komplexitas tinggi, low ROI di MVP. Email cukup.
+31 -6
View File
@@ -93,12 +93,12 @@ Alur ini menggambarkan satu peserta dari pertama kali mendaftar sampai pembayara
### 5. Ringkasan peran data
| Konsep | Penyimpanan |
|--------|-------------|
| Trip | `Trip`: judul, gunung, lokasi, tanggal, kuota, harga, status trip (`OPEN` / `FULL` / …), meeting point, itinerary, termasuk/tidak termasuk, relasi ke organizer |
| Peserta | `TripParticipant` unik per `(tripId, userId)`: status **`PENDING`** / **`CONFIRMED`** / **`CANCELLED`**, serta **`markedPaidAt`** & **`paymentConfirmedAt`** untuk alur bayar manual |
| Organizer (kepercayaan) | `OrganizerVerification` (1-1 ke `User`) berisi KTP, foto liveness (memegang kertas "SETRIP"), rekening, dan status (`PENDING` / `APPROVED` / `REJECTED`); badge **Verified Organizer** muncul ketika `status === "APPROVED"` (helper `lib/trust.ts → isVerifiedOrganizer`). Agregat rating & jumlah trip dihitung dari ulasan & trip. |
| Persetujuan T&C / Privasi | `User.acceptedTermsAndPrivacy` + `User.acceptedAt`, dicentang saat registrasi (link ke `/terms` & `/privacy`). |
| Konsep | Penyimpanan |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Trip | `Trip`: judul, gunung, lokasi, tanggal, kuota, harga, status trip (`OPEN` / `FULL` / …), meeting point, itinerary, termasuk/tidak termasuk, relasi ke organizer |
| Peserta | `TripParticipant` unik per `(tripId, userId)`: status **`PENDING`** / **`CONFIRMED`** / **`CANCELLED`**, serta **`markedPaidAt`** & **`paymentConfirmedAt`** untuk alur bayar manual |
| Organizer (kepercayaan) | `OrganizerVerification` (1-1 ke `User`) berisi KTP, foto liveness (memegang kertas "SETRIP"), rekening, dan status (`PENDING` / `APPROVED` / `REJECTED`); badge **Verified Organizer** muncul ketika `status === "APPROVED"` (helper `lib/trust.ts → isVerifiedOrganizer`). Agregat rating & jumlah trip dihitung dari ulasan & trip. |
| Persetujuan T&C / Privasi | `User.acceptedTermsAndPrivacy` + `User.acceptedAt`, dicentang saat registrasi (link ke `/terms` & `/privacy`). |
## Menjalankan secara lokal
@@ -128,3 +128,28 @@ Buka [http://localhost:3000](http://localhost:3000).
- [Next.js Documentation](https://nextjs.org/docs)
- [Prisma Documentation](https://www.prisma.io/docs)
# 1. Install SEMUA dep (termasuk dev) — deterministik dari lockfile.
# --include=dev memaksa dev terpasang walau NODE_ENV=production ter-export.
npm ci --include=dev
# 2. Prisma: generate client + apply migrasi
npx prisma generate
npx prisma migrate deploy
# 3. Build (butuh devDependencies)
npm run build
# 4. (Opsional) ramping-kan node_modules — buang dev SETELAH build selesai
npm prune --omit=dev
# 5. Jalankan
pm2 start ecosystem.config.js --env production
# atau restart: pm2 restart setrip --update-env
+22 -20
View File
@@ -4,6 +4,8 @@ Status implementasi sistem refund yang dapat dipercaya dan auditable — dari sc
> **Prinsip:** refund = financial event, bukan flag boolean. Setiap rupiah yang keluar harus auditable, idempotent, dan punya state machine eksplisit. Untuk MVP: tetap simple, tapi anticipate partial refund + async gateway dari hari pertama.
**Progress (per 2026-05-20):** PR-R1, R2, R3 ✅ — MVP refund (schema + service, organizer-cancel auto-refund, self-service user cancel) selesai. PR-R4 / R5 / R6 ⏳ post-MVP belum dikerjakan.
---
## Audit state sekarang (baseline)
@@ -34,7 +36,7 @@ File baseline: [prisma/schema.prisma](prisma/schema.prisma), [server/services/bo
---
## PR-R1 — Refund Schema + Service Stub (foundation)
## PR-R1 — Refund Schema + Service Stub (foundation)
Foundation. Bikin entity `Refund` + service stub yang cuma create record `PENDING` (belum panggil gateway). UI admin minimal: list + manual mark succeeded. Tanpa ini, semua PR berikutnya tidak bisa jalan.
@@ -48,14 +50,14 @@ Foundation. Bikin entity `Refund` + service stub yang cuma create record `PENDIN
| # | Item | Status | File |
|---|---|---|---|
| R1.1 | Model `Refund` + enum `RefundReason`, `RefundStatus`, `RefundInitiator` | | [prisma/schema.prisma](prisma/schema.prisma) |
| R1.2 | Tambah `BookingStatus.PARTIALLY_REFUNDED` | | [prisma/schema.prisma](prisma/schema.prisma) |
| R1.3 | Migration `add_refund_model` | | `prisma/migrations/` |
| R1.4 | `refundRepo` (create, findById, findByBooking, listPending, updateStatus) | | `server/repositories/refund.repo.ts` |
| R1.5 | `refundService.requestRefund(input)` — create Refund PENDING + validasi `amount <= payment.amount - totalRefunded` | | `server/services/refund.service.ts` |
| R1.6 | `refundService.approveRefund(refundId, adminId)` — PENDING → APPROVED | | `server/services/refund.service.ts` |
| R1.7 | `refundService.markSucceededManual(refundId, adminId)` — APPROVED → SUCCEEDED, update Booking/Payment status. Untuk MVP refund manual transfer (non-Midtrans channel). | | `server/services/refund.service.ts` |
| R1.8 | UI admin `/admin/refunds` — list PENDING + tombol approve & mark-succeeded | | `app/admin/refunds/page.tsx` |
| R1.1 | Model `Refund` + enum `RefundReason`, `RefundStatus`, `RefundInitiator` | | [prisma/schema.prisma](prisma/schema.prisma) |
| R1.2 | Tambah `BookingStatus.PARTIALLY_REFUNDED` | | [prisma/schema.prisma](prisma/schema.prisma) |
| R1.3 | Migration `add_refund_model` | | `prisma/migrations/` |
| R1.4 | `refundRepo` (create, findById, findByBooking, listPending, updateStatus) | | `server/repositories/refund.repo.ts` |
| R1.5 | `refundService.requestRefund(input)` — create Refund PENDING + validasi `amount <= payment.amount - totalRefunded` | | `server/services/refund.service.ts` |
| R1.6 | `refundService.approveRefund(refundId, adminId)` — PENDING → APPROVED | | `server/services/refund.service.ts` |
| R1.7 | `refundService.markSucceededManual(refundId, adminId)` — APPROVED → SUCCEEDED, update Booking/Payment status. Untuk MVP refund manual transfer (non-Midtrans channel). | | `server/services/refund.service.ts` |
| R1.8 | UI admin `/admin/refunds` — list PENDING + tombol approve & mark-succeeded | | `app/admin/refunds/page.tsx` |
**Tindakan manual:**
1. Run migration di staging → smoke test → run di production.
@@ -63,7 +65,7 @@ Foundation. Bikin entity `Refund` + service stub yang cuma create record `PENDIN
---
## PR-R2 — Auto-Trigger Refund saat Trip CLOSED (organizer cancel)
## PR-R2 — Auto-Trigger Refund saat Trip CLOSED (organizer cancel)
Saat organizer batalkan trip (`status = CLOSED`), semua peserta dengan `Booking.status = PAID` otomatis di-create `Refund` record dengan `reason = ORGANIZER_CANCELLED`, `initiatedBy = SYSTEM`, full amount.
@@ -75,16 +77,16 @@ Saat organizer batalkan trip (`status = CLOSED`), semua peserta dengan `Booking.
| # | Item | Status | File |
|---|---|---|---|
| R2.1 | `tripService.closeTrip(tripId, organizerId)` — set status CLOSED + auto-create refunds | | [server/services/trip.service.ts](server/services/trip.service.ts) |
| R2.2 | Auto-approve untuk SYSTEM refund dengan reason ORGANIZER_CANCELLED | | `server/services/refund.service.ts` |
| R2.3 | UI organizer: tombol "Batalkan trip" di dashboard organizer dengan confirmation modal | | `features/trip/components/cancel-trip-button.tsx` |
| R2.4 | Server action `cancelTripAction` | | `features/trip/actions.ts` |
| R2.1 | `tripService.closeTrip(tripId, organizerId)` — set status CLOSED + auto-create refunds | | [server/services/trip.service.ts](server/services/trip.service.ts) |
| R2.2 | Auto-approve untuk SYSTEM refund dengan reason ORGANIZER_CANCELLED | | `server/services/refund.service.ts` |
| R2.3 | UI organizer: tombol "Batalkan trip" di dashboard organizer dengan confirmation modal | | `features/trip/components/cancel-trip-button.tsx` |
| R2.4 | Server action `cancelTripAction` | | `features/trip/actions.ts` |
**Tindakan manual:** tidak ada.
---
## PR-R3 — Self-Service User Cancel dengan Refund Window
## PR-R3 — Self-Service User Cancel dengan Refund Window
User bisa cancel booking sendiri, refund dihitung berdasarkan kebijakan default (hardcoded dulu, jangan polymorphic).
@@ -100,11 +102,11 @@ User bisa cancel booking sendiri, refund dihitung berdasarkan kebijakan default
| # | Item | Status | File |
|---|---|---|---|
| R3.1 | `lib/refund-policy.ts``calculateRefundAmount(bookingAmount, daysUntilDeparture)` | | `lib/refund-policy.ts` |
| R3.2 | `refundService.requestUserCancellation(bookingId, userId)` — pakai policy + create Refund PENDING | | `server/services/refund.service.ts` |
| R3.3 | UI user di trip detail: tombol "Cancel booking" + modal preview refund amount | | `features/booking/components/cancel-booking-button.tsx` |
| R3.4 | Server action `cancelBookingAction` | | `features/booking/actions.ts` |
| R3.5 | Display refund policy di trip detail (di blok info atau accordion) — transparency | | [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/page.tsx) |
| R3.1 | `lib/refund-policy.ts``calculateRefundAmount(bookingAmount, daysUntilDeparture)` | | `lib/refund-policy.ts` |
| R3.2 | `refundService.requestUserCancellation(bookingId, userId)` — pakai policy + create Refund PENDING | | `server/services/refund.service.ts` |
| R3.3 | UI user di trip detail: tombol "Cancel booking" + modal preview refund amount | | `features/booking/components/cancel-booking-button.tsx` |
| R3.4 | Server action `cancelBookingAction` | | `features/booking/actions.ts` |
| R3.5 | Display refund policy di trip detail (di blok info atau accordion) — transparency | | [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/page.tsx) |
**Tindakan manual:**
1. Tulis copy kebijakan refund untuk halaman Terms & Privacy.
+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 { Lock, Clock, CircleAlert } from "lucide-react";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { organizerService } from "@/server/services/organizer.service";
@@ -11,8 +12,13 @@ export default async function CreateTripPage() {
return (
<div className="flex min-h-[calc(100vh-3.5rem)] items-center justify-center px-4">
<div className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary-50 text-3xl">
🔒
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary-50">
<Lock
size={28}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
</div>
<p className="mb-4 text-neutral-500">
Kamu harus login untuk membuat trip.
@@ -57,8 +63,9 @@ function VerificationBanner({
if (status === "PENDING") {
return (
<div className="mb-5 rounded-2xl border border-amber-200 bg-amber-50 p-4 sm:p-5">
<p className="text-sm font-bold text-amber-800">
Verifikasi sedang diproses
<p className="flex items-center gap-1.5 text-sm font-bold text-amber-800">
<Clock size={15} strokeWidth={2} aria-hidden />
Verifikasi sedang diproses
</p>
<p className="mt-1 text-sm text-neutral-700">
Pengajuan verifikasi-mu masih ditinjau admin. Sementara menunggu, kamu
@@ -73,8 +80,9 @@ function VerificationBanner({
<div className="mb-5 rounded-2xl border border-amber-200 bg-amber-50 p-4 sm:p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex-1">
<p className="text-sm font-bold text-amber-800">
{isRejected ? "Verifikasi ditolak" : "Belum terverifikasi"}
<p className="flex items-center gap-1.5 text-sm font-bold text-amber-800">
<CircleAlert size={15} strokeWidth={2} aria-hidden />
{isRejected ? "Verifikasi ditolak" : "Belum terverifikasi"}
</p>
<p className="mt-1 text-sm text-neutral-700">
{isRejected
+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 { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export const metadata: Metadata = {
title: "Masuk",
@@ -8,6 +11,15 @@ export const metadata: Metadata = {
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;
}
+11 -8
View File
@@ -38,13 +38,16 @@ function LoginForm() {
if (result?.error) {
setError(result.error);
} else {
const rawCallback = searchParams.get("callbackUrl");
let next = safeInternalPath(rawCallback);
// Tanpa callbackUrl eksplisit, arahkan admin ke dashboard /admin.
if (!rawCallback) {
const session = await getSession();
if (session?.user?.isAdmin) next = "/admin";
}
const callbackPath = safeInternalPath(searchParams.get("callbackUrl"));
const session = await getSession();
// Admin selalu diarahkan ke dashboard /admin setelah login — kecuali
// callbackUrl memang menuju sub-halaman admin (deep link dari /admin/...).
// callbackUrl non-admin (mis. "/" sisa dari percobaan login Google) tidak
// boleh membuat admin "nyangkut" di halaman publik.
const next =
session?.user?.isAdmin && !callbackPath.startsWith("/admin")
? "/admin"
: callbackPath;
router.push(next);
router.refresh();
}
@@ -84,7 +87,7 @@ function LoginForm() {
</div>
{/* Card */}
<div className="rounded-2xl border border-white/10 bg-white/95 p-6 shadow-2xl backdrop-blur-sm">
<div className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-2xl">
{error && (
<div className="mb-4 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600">
{error}
+100 -69
View File
@@ -8,6 +8,15 @@ import { profileRepo } from "@/server/repositories/profile.repo";
import { TripCard } from "@/features/trip/components/trip-card";
import { siteConfig, siteUrl, absoluteUrl } from "@/lib/site";
import { ACTIVITY_CATEGORIES, categoryMeta } from "@/lib/activity-category";
import {
Compass,
Flame,
Mountain,
Handshake,
Tent,
Plus,
type LucideIcon,
} from "lucide-react";
type OpenTrip = Awaited<ReturnType<typeof tripService.getOpenTrips>>[number];
@@ -44,6 +53,9 @@ export default async function HomePage() {
const now = new Date();
const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
// Social proof: total orang yang sudah gabung di seluruh open trip.
const joinerCount = trips.reduce((sum, t) => sum + t._count.participants, 0);
const upcomingTrips = trips
.filter((t) => new Date(t.date) <= nextWeek)
.slice(0, 3);
@@ -107,12 +119,17 @@ export default async function HomePage() {
className="object-cover opacity-10 brightness-150"
priority
/>
<div className="absolute inset-0 bg-linear-to-br from-primary-900/85 via-neutral-900/75 to-secondary-900/80" />
<div className="absolute inset-0 bg-linear-to-br from-neutral-900/90 to-primary-900/80" />
<div className="relative mx-auto max-w-4xl px-4 pb-10 pt-8 text-center sm:pb-14 sm:pt-12 lg:pb-16 lg:pt-14">
{/* Brand badge */}
<div className="mb-4 inline-flex items-center gap-1.5 rounded-full border border-primary-400/30 bg-primary-600/20 px-3 py-1 sm:mb-6 sm:gap-2 sm:px-4 sm:py-1.5">
<span className="text-xs sm:text-sm">🤝</span>
<Handshake
size={14}
strokeWidth={1.75}
aria-hidden
className="text-primary-300"
/>
<span className="text-xs font-medium text-primary-300 sm:text-sm">
Cari teman trip & aktivitas
</span>
@@ -150,8 +167,12 @@ export default async function HomePage() {
</div>
<div className="h-8 w-px bg-neutral-700 sm:h-10" />
<div>
<p className="text-xl font-bold text-white sm:text-2xl">100%</p>
<p className="text-[11px] text-neutral-400 sm:text-xs">Seru</p>
<p className="text-xl font-bold text-white sm:text-2xl">
{joinerCount}
</p>
<p className="text-[11px] text-neutral-400 sm:text-xs">
Sudah Gabung
</p>
</div>
</div>
</div>
@@ -161,19 +182,11 @@ export default async function HomePage() {
<div className="mx-auto max-w-6xl px-4 py-6 space-y-8 sm:py-8 sm:space-y-10 lg:py-10 lg:space-y-12">
{/* Jelajah per kategori */}
<section>
<div className="mb-4 flex items-center gap-3 sm:mb-5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-secondary-100 text-base sm:h-9 sm:w-9 sm:text-lg">
</div>
<div>
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
Jelajah per Kategori
</h2>
<p className="text-[11px] text-neutral-500 sm:text-xs">
Hiking, diving, konser, sampai retreat
</p>
</div>
</div>
<SectionHeading
icon={Compass}
title="Jelajah per Kategori"
subtitle="Hiking, diving, konser, sampai retreat"
/>
<div className="flex flex-wrap gap-2">
{ACTIVITY_CATEGORIES.map((c) => {
const m = categoryMeta(c);
@@ -194,19 +207,11 @@ export default async function HomePage() {
{/* Trip Terdekat */}
{upcomingTrips.length > 0 && (
<section>
<div className="mb-4 flex items-center gap-3 sm:mb-5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-100 text-base sm:h-9 sm:w-9 sm:text-lg">
🔥
</div>
<div>
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
Trip Terdekat
</h2>
<p className="text-[11px] text-neutral-500 sm:text-xs">
Berangkat dalam 7 hari ke depan
</p>
</div>
</div>
<SectionHeading
icon={Flame}
title="Trip Terdekat"
subtitle="Berangkat dalam 7 hari ke depan"
/>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{upcomingTrips.slice(0, 3).map((trip, i) => (
<TripCard
@@ -239,32 +244,29 @@ export default async function HomePage() {
{/* Open Trip */}
<section>
<div className="mb-4 flex items-center justify-between sm:mb-5">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-secondary-100 text-base sm:h-9 sm:w-9 sm:text-lg">
🏔
</div>
<div>
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
Open Trip
</h2>
<p className="hidden text-xs text-neutral-500 sm:block">
Pilih trip, ketemu teman baru
</p>
</div>
</div>
<Link
href="/trips"
className="rounded-lg bg-secondary-50 px-2.5 py-1 text-xs font-medium text-secondary-600 hover:bg-secondary-100 sm:px-3 sm:py-1.5 sm:text-sm"
>
Lihat semua
</Link>
</div>
<SectionHeading
icon={Mountain}
title="Open Trip"
subtitle="Pilih trip, ketemu teman baru"
action={
<Link
href="/trips"
className="shrink-0 rounded-lg bg-secondary-50 px-2.5 py-1 text-xs font-medium text-secondary-600 hover:bg-secondary-100 sm:px-3 sm:py-1.5 sm:text-sm"
>
Lihat semua
</Link>
}
/>
{latestTrips.length === 0 ? (
<div className="rounded-2xl border-2 border-dashed border-neutral-200 bg-white p-8 text-center sm:p-14">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 text-2xl sm:h-16 sm:w-16 sm:text-3xl">
🏕
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 sm:h-16 sm:w-16">
<Tent
size={26}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
</div>
<p className="mb-1 text-base font-bold text-neutral-800 sm:text-lg">
Belum ada trip tersedia
@@ -312,19 +314,11 @@ export default async function HomePage() {
{/* Lagi Ramai — social proof, bukan price proof */}
{buzzingTrips.length > 0 && (
<section>
<div className="mb-4 flex items-center gap-3 sm:mb-5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-100 text-base sm:h-9 sm:w-9 sm:text-lg">
🤝
</div>
<div>
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
Lagi Ramai
</h2>
<p className="text-[11px] text-neutral-500 sm:text-xs">
Banyak yang sudah gabung kamu nggak bakal jalan sendirian
</p>
</div>
</div>
<SectionHeading
icon={Handshake}
title="Lagi Ramai"
subtitle="Banyak yang sudah gabung — kamu nggak bakal jalan sendirian"
/>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{buzzingTrips.map((trip) => (
<TripCard
@@ -383,11 +377,48 @@ export default async function HomePage() {
{/* ========== FAB ========== */}
<Link
href="/create-trip"
className="fixed bottom-4 right-4 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-primary-600 text-xl font-bold text-white shadow-xl shadow-primary-600/30 transition-all hover:scale-110 hover:bg-primary-500 active:scale-95 sm:bottom-6 sm:right-6 sm:h-14 sm:w-14 sm:text-2xl"
title="Buat Trip"
className="fixed bottom-4 right-4 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-primary-600 text-white shadow-xl shadow-primary-600/30 transition-all hover:scale-110 hover:bg-primary-500 active:scale-95 sm:bottom-6 sm:right-6 sm:h-14 sm:w-14"
aria-label="Buat Trip"
>
+
<Plus size={24} strokeWidth={2} aria-hidden />
</Link>
</div>
);
}
/** Heading section homepage — ikon stroke + judul, opsional aksi di kanan. */
function SectionHeading({
icon: Icon,
title,
subtitle,
action,
}: {
icon: LucideIcon;
title: string;
subtitle?: string;
action?: React.ReactNode;
}) {
return (
<div className="mb-4 flex items-center justify-between gap-3 sm:mb-5">
<div className="flex items-center gap-2.5">
<Icon
size={22}
strokeWidth={1.75}
aria-hidden
className="shrink-0 text-primary-600"
/>
<div>
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
{title}
</h2>
{subtitle && (
<p className="text-[11px] text-neutral-500 sm:text-xs">
{subtitle}
</p>
)}
</div>
</div>
{action}
</div>
);
}
+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 { isVibe, vibeLabel } from "@/lib/vibe";
import { siteConfig } from "@/lib/site";
import { Users } from "lucide-react";
interface PeoplePageProps {
searchParams: Promise<{
@@ -68,8 +69,13 @@ export default async function PeoplePage({ searchParams }: PeoplePageProps) {
{people.length === 0 ? (
<div className="rounded-2xl border-2 border-dashed border-neutral-200 bg-white p-8 text-center sm:p-14">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 text-2xl sm:h-16 sm:w-16 sm:text-3xl">
🔍
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 sm:h-16 sm:w-16">
<Users
size={26}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
</div>
<p className="mb-1 text-base font-bold text-neutral-800 sm:text-lg">
{hasFilters
+18 -3
View File
@@ -1,12 +1,19 @@
import Link from "next/link";
import { ShieldCheck, CircleCheck } from "lucide-react";
export default function PrivacyPage() {
return (
<div className="mx-auto max-w-3xl px-4 py-8 sm:py-12">
<article className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm sm:p-10">
<header className="mb-8 border-b border-neutral-200 pb-6">
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
🔒 Kebijakan Privasi SeTrip
<h1 className="flex items-center gap-2 text-2xl font-bold text-neutral-900 sm:text-3xl">
<ShieldCheck
size={28}
strokeWidth={1.75}
aria-hidden
className="shrink-0 text-primary-600"
/>
Kebijakan Privasi SeTrip
</h1>
<p className="mt-2 text-sm text-neutral-500">
Terakhir diperbarui: 2026-04-27
@@ -205,7 +212,15 @@ export default function PrivacyPage() {
</section>
<section className="rounded-xl bg-neutral-50 p-5">
<h2 className="mb-2 text-lg font-bold text-neutral-900"> Persetujuan</h2>
<h2 className="mb-2 flex items-center gap-1.5 text-lg font-bold text-neutral-900">
<CircleCheck
size={18}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
Persetujuan
</h2>
<p className="mb-2">
Dengan menggunakan SeTrip, Anda menyatakan bahwa:
</p>
+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 { ProfileEditor } from "@/features/profile/components/profile-editor";
import { EarningsSection } from "@/features/payout/components/earnings-section";
import { Plus, ChevronRight } from "lucide-react";
export const metadata: Metadata = {
title: "Profil Saya",
@@ -81,9 +82,10 @@ export default async function ProfilePage() {
</div>
<Link
href="/create-trip"
className="shrink-0 rounded-xl bg-primary-600 px-4 py-2.5 text-center text-sm font-semibold text-white shadow-md hover:bg-primary-700"
className="inline-flex shrink-0 items-center justify-center gap-1.5 rounded-xl bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white shadow-md hover:bg-primary-700"
>
+ Buat trip
<Plus size={16} strokeWidth={2} aria-hidden />
Buat trip
</Link>
</div>
@@ -133,13 +135,14 @@ export default async function ProfilePage() {
endDate={t.endDate}
rightSlot={
<span
className={
className={`inline-flex items-center gap-0.5 ${
hasReview
? "text-secondary-700"
: "font-bold text-amber-800"
}
}`}
>
{hasReview ? "Ubah ulasan" : "Beri ulasan"}
{hasReview ? "Ubah ulasan" : "Beri ulasan"}
<ChevronRight size={14} strokeWidth={2} aria-hidden />
</span>
}
/>
+13 -1
View File
@@ -1,4 +1,7 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export const metadata: Metadata = {
title: "Daftar Akun",
@@ -7,6 +10,15 @@ export const metadata: Metadata = {
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;
}
+1 -1
View File
@@ -77,7 +77,7 @@ export default function RegisterPage() {
</div>
{/* Card */}
<div className="rounded-2xl border border-white/10 bg-white/95 p-6 shadow-2xl backdrop-blur-sm">
<div className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-2xl">
{error && (
<div className="mb-4 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600">
{error}
+18 -3
View File
@@ -1,12 +1,19 @@
import Link from "next/link";
import { FileText, CircleCheck } from "lucide-react";
export default function TermsPage() {
return (
<div className="mx-auto max-w-3xl px-4 py-8 sm:py-12">
<article className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm sm:p-10">
<header className="mb-8 border-b border-neutral-200 pb-6">
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
📜 Syarat &amp; Ketentuan SeTrip
<h1 className="flex items-center gap-2 text-2xl font-bold text-neutral-900 sm:text-3xl">
<FileText
size={28}
strokeWidth={1.75}
aria-hidden
className="shrink-0 text-primary-600"
/>
Syarat &amp; Ketentuan SeTrip
</h1>
<p className="mt-2 text-sm text-neutral-500">
Terakhir diperbarui: 2026-04-27
@@ -262,7 +269,15 @@ export default function TermsPage() {
</section>
<section className="rounded-xl bg-neutral-50 p-5">
<h2 className="mb-2 text-lg font-bold text-neutral-900"> Persetujuan</h2>
<h2 className="mb-2 flex items-center gap-1.5 text-lg font-bold text-neutral-900">
<CircleCheck
size={18}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
Persetujuan
</h2>
<p className="mb-2">
Dengan menggunakan SeTrip, Anda menyatakan bahwa:
</p>
+31
View File
@@ -0,0 +1,31 @@
/** Skeleton halaman detail trip — tampil instan saat data masih di-fetch. */
export default function Loading() {
return (
<div className="mx-auto max-w-3xl px-4 py-4 sm:py-8">
<div className="mb-3 h-4 w-40 animate-pulse rounded bg-neutral-200 sm:mb-4" />
<div className="overflow-hidden rounded-2xl border border-neutral-200 bg-white shadow-sm">
<div className="h-44 animate-pulse bg-neutral-200 sm:h-56 lg:h-72" />
<div className="border-b border-neutral-100 px-4 py-4 sm:px-6">
<div className="h-6 w-2/3 animate-pulse rounded bg-neutral-200" />
<div className="mt-2 h-4 w-1/3 animate-pulse rounded bg-neutral-100" />
</div>
<div className="space-y-5 p-4 sm:space-y-6 sm:p-6">
<div className="grid grid-cols-2 gap-2 sm:gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="h-16 animate-pulse rounded-xl bg-neutral-100 sm:h-[72px]"
/>
))}
</div>
<div className="h-24 animate-pulse rounded-xl bg-neutral-100" />
<div className="h-32 animate-pulse rounded-xl bg-neutral-100" />
<div className="h-12 animate-pulse rounded-xl bg-neutral-200" />
</div>
</div>
</div>
);
}
+10 -2
View File
@@ -2,7 +2,7 @@ import { ImageResponse } from "next/og";
import { tripService } from "@/server/services/trip.service";
import { formatRupiah } from "@/lib/utils";
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
import { siteConfig } from "@/lib/site";
import { siteConfig, siteUrl } from "@/lib/site";
export const alt = `${siteConfig.name} — Open Trip & Aktivitas Bareng`;
export const size = { width: 1200, height: 630 };
@@ -43,7 +43,15 @@ export default async function TripOgImage({
);
}
const cover = trip.images[0]?.url;
// Satori (ImageResponse) mem-fetch gambar server-side dan butuh URL absolut.
// Foto trip baru disimpan sebagai path relatif `/api/trip-images/...` —
// prefix dengan origin. Foto lama (URL eksternal absolut) dipakai apa adanya.
const coverRaw = trip.images[0]?.url;
const cover = coverRaw
? coverRaw.startsWith("http")
? coverRaw
: `${siteUrl}${coverRaw}`
: undefined;
const dateLabel = formatTripCalendarDateRangeLong(trip.date, trip.endDate);
const price = formatRupiah(trip.price);
+56 -15
View File
@@ -28,6 +28,14 @@ import {
isTripDepartureDayPast,
} from "@/lib/trip-dates";
import { previewRefund } from "@/lib/refund-policy";
import {
MapPin,
CalendarDays,
Wallet,
UserRound,
Zap,
Users,
} from "lucide-react";
export async function generateMetadata({
params,
@@ -309,8 +317,13 @@ export default async function TripDetailPage({
{/* Info Grid */}
<div className="grid grid-cols-2 gap-2 sm:gap-3">
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-secondary-100 text-sm sm:h-10 sm:w-10 sm:text-lg">
📍
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-secondary-100 sm:h-10 sm:w-10">
<MapPin
size={18}
strokeWidth={1.75}
aria-hidden
className="text-secondary-700"
/>
</span>
<div className="min-w-0">
<p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Lokasi</p>
@@ -319,8 +332,13 @@ export default async function TripDetailPage({
</div>
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-secondary-100 text-sm sm:h-10 sm:w-10 sm:text-lg">
📅
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-secondary-100 sm:h-10 sm:w-10">
<CalendarDays
size={18}
strokeWidth={1.75}
aria-hidden
className="text-secondary-700"
/>
</span>
<div className="min-w-0">
<p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Tanggal</p>
@@ -331,8 +349,13 @@ export default async function TripDetailPage({
</div>
<div className="flex items-center gap-2 rounded-xl bg-primary-50 p-3 sm:gap-3 sm:p-4">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary-100 text-sm sm:h-10 sm:w-10 sm:text-lg">
💰
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary-100 sm:h-10 sm:w-10">
<Wallet
size={18}
strokeWidth={1.75}
aria-hidden
className="text-primary-700"
/>
</span>
<div className="min-w-0">
<p className="text-[10px] font-medium text-primary-600 sm:text-xs">Harga</p>
@@ -343,8 +366,13 @@ export default async function TripDetailPage({
</div>
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-200 text-sm sm:h-10 sm:w-10 sm:text-lg">
👤
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-200 sm:h-10 sm:w-10">
<UserRound
size={18}
strokeWidth={1.75}
aria-hidden
className="text-neutral-600"
/>
</span>
<div className="min-w-0">
<p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Organizer</p>
@@ -372,8 +400,9 @@ export default async function TripDetailPage({
Peserta
</span>
{spotsLeft > 0 && spotsLeft <= 3 && (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-bold text-amber-800 sm:text-[11px]">
Tinggal {spotsLeft} spot!
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-bold text-amber-800 sm:text-[11px]">
<Zap size={11} strokeWidth={2} aria-hidden />
Tinggal {spotsLeft} spot!
</span>
)}
{spotsLeft <= 0 && (
@@ -418,8 +447,14 @@ export default async function TripDetailPage({
)}
</p>
{confirmedCount > 0 && (
<p className="mt-2 text-[11px] text-neutral-600 sm:text-xs">
<span aria-hidden>👥</span> Sudah join:{" "}
<p className="mt-2 flex flex-wrap items-center gap-x-1 gap-y-0.5 text-[11px] text-neutral-600 sm:text-xs">
<Users
size={13}
strokeWidth={1.75}
aria-hidden
className="text-neutral-400"
/>
Sudah join:{" "}
<span className="font-medium text-neutral-800">
{confirmedParticipants
.slice(0, 3)
@@ -547,7 +582,7 @@ export default async function TripDetailPage({
Belum ada peserta yang dikonfirmasi.{" "}
{pendingParticipants.length > 0
? "Cek permintaan join di atas untuk menyetujui peserta."
: "Jadilah yang pertama mendaftar! 🎒"}
: "Jadilah yang pertama mendaftar!"}
</p>
) : (
<ul className="grid gap-2 sm:grid-cols-2">
@@ -578,8 +613,14 @@ export default async function TripDetailPage({
{p.user.name}
</p>
{city && (
<p className="truncate text-[11px] text-neutral-500 sm:text-xs">
📍 {city}
<p className="flex items-center gap-1 truncate text-[11px] text-neutral-500 sm:text-xs">
<MapPin
size={11}
strokeWidth={1.75}
aria-hidden
className="shrink-0"
/>
{city}
</p>
)}
{interests.length > 0 && (
+69 -16
View File
@@ -11,6 +11,15 @@ import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
import { isFreeTrip } from "@/lib/trip-pricing";
import { categoryMeta } from "@/lib/activity-category";
import { MidtransPayButton } from "@/features/booking/components/midtrans-pay-button";
import {
ArrowLeft,
CalendarDays,
MapPin,
PartyPopper,
CircleCheck,
Clock,
Check,
} from "lucide-react";
export const metadata: Metadata = {
title: "Detail Pembayaran",
@@ -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">
{trip.title}
</h1>
<p className="mt-1 text-xs text-neutral-500 sm:text-sm">
📅 {dateRange} · 📍 {trip.location}
<p className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-neutral-500 sm:text-sm">
<span className="inline-flex items-center gap-1">
<CalendarDays size={13} strokeWidth={1.75} aria-hidden />
{dateRange}
</span>
<span className="inline-flex items-center gap-1">
<MapPin size={13} strokeWidth={1.75} aria-hidden />
{trip.location}
</span>
</p>
<p className="mt-1 text-xs text-neutral-500 sm:text-sm">
Organizer:{" "}
@@ -102,8 +118,12 @@ export default async function PaymentPage({ params, searchParams }: PageProps) {
return (
<div className="mx-auto max-w-2xl px-4 py-6 sm:py-10">
<div className="mb-4 flex items-center gap-2 text-xs text-neutral-500 sm:text-sm">
<Link href={`/trips/${trip.id}`} className="hover:text-primary-600">
Kembali ke trip
<Link
href={`/trips/${trip.id}`}
className="inline-flex items-center gap-1 hover:text-primary-600"
>
<ArrowLeft size={14} strokeWidth={1.75} aria-hidden />
Kembali ke trip
</Link>
</div>
@@ -170,8 +190,13 @@ function FreeTripSection({
}) {
return (
<section className="rounded-2xl border border-emerald-200 bg-emerald-50/60 p-6 text-center shadow-sm sm:p-8">
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-emerald-100 text-2xl">
🎉
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-emerald-100">
<PartyPopper
size={26}
strokeWidth={1.75}
aria-hidden
className="text-emerald-600"
/>
</div>
<h2 className="mb-1 text-lg font-bold text-emerald-900 sm:text-xl">
Trip ini gratis
@@ -184,10 +209,28 @@ function FreeTripSection({
<p className="text-[11px] font-semibold uppercase tracking-wide text-emerald-700">
Status keikutsertaan
</p>
<p className="text-sm font-bold text-neutral-800">
{bookingStatus === "PAID"
? "✅ Terkonfirmasi sebagai peserta"
: "⏳ Menunggu persetujuan organizer"}
<p className="flex items-center gap-1.5 text-sm font-bold text-neutral-800">
{bookingStatus === "PAID" ? (
<>
<CircleCheck
size={15}
strokeWidth={2}
aria-hidden
className="text-emerald-600"
/>
Terkonfirmasi sebagai peserta
</>
) : (
<>
<Clock
size={15}
strokeWidth={2}
aria-hidden
className="text-amber-600"
/>
Menunggu persetujuan organizer
</>
)}
</p>
</div>
@@ -250,10 +293,15 @@ function PaidTripSection({
{canPay && <MidtransPayButton tripId={tripId} />}
{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>
Pembayaran kamu sudah terkonfirmasi. Sampai jumpa di trip
bareng{" "}
Pembayaran kamu sudah terkonfirmasi. Sampai jumpa di trip bareng{" "}
<span className="font-semibold">{organizerName}</span>!
</p>
</div>
@@ -262,9 +310,10 @@ function PaidTripSection({
<div className="text-center">
<Link
href={`/trips/${tripId}`}
className="text-sm text-neutral-500 hover:text-primary-600"
className="inline-flex items-center gap-1 text-sm text-neutral-500 hover:text-primary-600"
>
Kembali ke detail trip
<ArrowLeft size={14} strokeWidth={1.75} aria-hidden />
Kembali ke detail trip
</Link>
</div>
</div>
@@ -298,7 +347,11 @@ function PaymentTimeline({
: "bg-neutral-200 text-neutral-500"
}`}
>
{s.done ? "✓" : i + 1}
{s.done ? (
<Check size={12} strokeWidth={3} aria-hidden />
) : (
i + 1
)}
</span>
<span
className={`text-sm ${
+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 { isVibe } from "@/lib/vibe";
import type { GroupSize } from "@/server/repositories/trip.repo";
import { Plus, Search, Tent } from "lucide-react";
const GROUP_SIZES: GroupSize[] = ["SMALL", "MEDIUM", "LARGE"];
function isGroupSize(value: unknown): value is GroupSize {
@@ -98,9 +99,10 @@ export default async function TripsPage({ searchParams }: TripsPageProps) {
</div>
<Link
href="/create-trip"
className="w-full rounded-xl bg-primary-600 px-4 py-2.5 text-center text-sm font-semibold text-white shadow-md shadow-primary-600/20 hover:bg-primary-700 sm:w-auto"
className="inline-flex w-full items-center justify-center gap-1.5 rounded-xl bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white shadow-md shadow-primary-600/20 hover:bg-primary-700 sm:w-auto"
>
+ Buat Trip
<Plus size={16} strokeWidth={2} aria-hidden />
Buat Trip
</Link>
</div>
@@ -113,8 +115,22 @@ export default async function TripsPage({ searchParams }: TripsPageProps) {
{trips.length === 0 ? (
<div className="rounded-2xl border-2 border-dashed border-neutral-200 bg-white p-8 text-center sm:p-14">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 text-2xl sm:h-16 sm:w-16 sm:text-3xl">
{hasFilters ? "🔍" : "🏕️"}
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 sm:h-16 sm:w-16">
{hasFilters ? (
<Search
size={26}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
) : (
<Tent
size={26}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
)}
</div>
<p className="mb-1 text-base font-bold text-neutral-800 sm:text-lg">
{hasFilters
@@ -137,7 +153,7 @@ export default async function TripsPage({ searchParams }: TripsPageProps) {
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{trips.map((trip) => (
{trips.map((trip, index) => (
<TripCard
key={trip.id}
id={trip.id}
@@ -154,6 +170,9 @@ export default async function TripsPage({ searchParams }: TripsPageProps) {
organizerName={trip.organizer.name}
status={trip.status}
coverImage={trip.images[0]?.url}
// Baris pertama (3 kartu) di atas fold — muat segera supaya
// tidak jadi LCP yang lambat.
priority={index < 3}
isVerifiedOrganizer={
trip.organizer.organizerVerification?.status === "APPROVED"
}
+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 { siteConfig } from "@/lib/site";
import { vibeMeta } from "@/lib/vibe";
import { BadgeCheck, MapPin, AtSign } from "lucide-react";
interface PageProps {
params: Promise<{ id: string }>;
@@ -86,10 +87,11 @@ export default async function PublicProfilePage({ params }: PageProps) {
</h1>
{isVerifiedOrganizer && (
<span
className="inline-flex items-center gap-0.5 rounded-full bg-primary-100 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-primary-800"
className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-primary-800"
title="Identitas organizer telah diverifikasi (KTP & rekening)"
>
Verified Organizer
<BadgeCheck size={12} strokeWidth={2} aria-hidden />
Verified Organizer
</span>
)}
</div>
@@ -97,7 +99,8 @@ export default async function PublicProfilePage({ params }: PageProps) {
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-neutral-500">
{profile?.city && (
<span className="inline-flex items-center gap-1">
📍 {profile.city}
<MapPin size={13} strokeWidth={1.75} aria-hidden />
{profile.city}
</span>
)}
<span className="text-xs">Bergabung sejak {memberSince}</span>
@@ -141,8 +144,8 @@ export default async function PublicProfilePage({ params }: PageProps) {
rel="noopener noreferrer nofollow"
className="mt-3 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700"
>
<span>📸</span>
<span>@{profile.instagram}</span>
<AtSign size={15} strokeWidth={1.75} aria-hidden />
<span>{profile.instagram}</span>
</a>
)}
</div>
+70 -9
View File
@@ -1,11 +1,29 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { Clock, RefreshCw, CircleX, ArrowLeft } from "lucide-react";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { organizerService } from "@/server/services/organizer.service";
import { VerifyForm } from "@/features/organizer/components/verify-form";
import { VerifiedBadge } from "@/components/shared/verified-badge";
function reuploadFieldLabel(field: string): string {
switch (field) {
case "ktpImage":
return "Foto KTP";
case "liveness":
return "Foto liveness (pegang kertas SETRIP)";
case "nik":
return "NIK";
case "bankInfo":
return "Info rekening";
case "address":
return "Alamat";
default:
return field;
}
}
export default async function VerifyPage() {
const session = await getServerSession(authOptions);
if (!session?.user) {
@@ -53,10 +71,11 @@ export default async function VerifyPage() {
</div>
)}
{verification?.status === "PENDING" && (
{verification?.status === "PENDING" && !verification.reuploadRequested && (
<div className="mb-6 rounded-2xl border border-amber-200 bg-amber-50 p-5">
<p className="mb-1 text-sm font-bold text-amber-800">
Menunggu review admin
<p className="mb-1 flex items-center gap-1.5 text-sm font-bold text-amber-800">
<Clock size={15} strokeWidth={2} aria-hidden />
Menunggu review admin
</p>
<p className="text-sm text-neutral-700">
Pengajuanmu sedang diproses. Kami akan memberitahu via email setelah selesai.
@@ -64,9 +83,47 @@ export default async function VerifyPage() {
</div>
)}
{verification?.reuploadRequested && (
<div className="mb-6 rounded-2xl border-2 border-amber-400 bg-amber-50 p-5">
<p className="mb-1 flex items-center gap-1.5 text-sm font-bold text-amber-900">
<RefreshCw size={15} strokeWidth={2} aria-hidden />
Admin minta kamu upload ulang
</p>
{verification.reuploadNote && (
<p className="mb-3 text-sm text-neutral-700">
<span className="font-semibold">Catatan admin:</span>{" "}
{verification.reuploadNote}
</p>
)}
{verification.reuploadFields.length > 0 && (
<div className="mb-3">
<p className="mb-1 text-xs font-semibold text-amber-900">
Field yang perlu di-upload ulang:
</p>
<ul className="ml-4 list-disc text-xs text-neutral-700">
{verification.reuploadFields.map((f) => (
<li key={f}>
<span className="font-semibold">
{reuploadFieldLabel(f)}
</span>
</li>
))}
</ul>
</div>
)}
<p className="text-xs text-neutral-700">
Submit ulang form di bawah dengan data/foto yang sudah diperbaiki.
Setelah submit, banner ini hilang otomatis.
</p>
</div>
)}
{verification?.status === "REJECTED" && (
<div className="mb-6 rounded-2xl border border-red-200 bg-red-50 p-5">
<p className="mb-1 text-sm font-bold text-red-800"> Pengajuan ditolak</p>
<p className="mb-1 flex items-center gap-1.5 text-sm font-bold text-red-800">
<CircleX size={15} strokeWidth={2} aria-hidden />
Pengajuan ditolak
</p>
{verification.rejectionReason && (
<p className="text-sm text-neutral-700">
<span className="font-semibold">Alasan:</span>{" "}
@@ -79,13 +136,17 @@ export default async function VerifyPage() {
</div>
)}
{verification?.status !== "APPROVED" && verification?.status !== "PENDING" && (
<VerifyForm initial={initial} />
)}
{(verification?.status !== "APPROVED" &&
(verification?.status !== "PENDING" ||
verification?.reuploadRequested)) && <VerifyForm initial={initial} />}
<p className="mt-6 text-center text-sm text-neutral-500">
<Link href="/profile" className="hover:text-primary-600">
Kembali ke profil
<Link
href="/profile"
className="inline-flex items-center gap-1 hover:text-primary-600"
>
<ArrowLeft size={14} strokeWidth={1.75} aria-hidden />
Kembali ke profil
</Link>
</p>
</div>
+31 -7
View File
@@ -1,6 +1,7 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { ArrowLeft, CalendarDays, CircleAlert, MapPin } from "lucide-react";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { bookingRepo } from "@/server/repositories/booking.repo";
@@ -69,8 +70,12 @@ export default async function AdminBookingDetailPage({ params }: PageProps) {
return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:py-12">
<div className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-neutral-500">
<Link href="/admin" className="hover:text-primary-600">
Dashboard
<Link
href="/admin"
className="inline-flex items-center gap-1 hover:text-primary-600"
>
<ArrowLeft size={14} strokeWidth={2} aria-hidden />
Dashboard
</Link>
<Link
href={`/admin/trips/${booking.tripId}`}
@@ -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">
{booking.trip.title}
</h1>
<p className="mt-1 text-sm text-neutral-500">
📅 {formatTripCalendarDateRangeLong(booking.trip.date, booking.trip.endDate)}{" "}
· 📍 {booking.trip.destination}, {booking.trip.location}
<p className="mt-1 flex flex-wrap items-center gap-1 text-sm text-neutral-500">
<CalendarDays
size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0"
/>
{formatTripCalendarDateRangeLong(booking.trip.date, booking.trip.endDate)}
<span aria-hidden>·</span>
<MapPin
size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0"
/>
{booking.trip.destination}, {booking.trip.location}
</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
@@ -300,8 +318,14 @@ function PaymentEventCard({
</p>
)}
{payment.rejectionReason && (
<p className="text-red-700">
{payment.rejectionReason}
<p className="flex items-center gap-1 text-red-700">
<CircleAlert
size={14}
strokeWidth={2}
aria-hidden
className="shrink-0"
/>
{payment.rejectionReason}
</p>
)}
</div>
+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 { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { Lock } from "lucide-react";
import { authOptions } from "@/lib/auth";
import { AdminSidebar } from "@/components/admin/admin-sidebar";
@@ -31,7 +32,12 @@ export default async function AdminLayout({
return (
<div className="flex min-h-screen items-center justify-center bg-neutral-50 px-4">
<div className="max-w-md rounded-2xl border border-neutral-200 bg-white p-8 text-center shadow-sm">
<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">
Halaman khusus admin
</h1>
+4 -2
View File
@@ -1,6 +1,7 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { getServerSession } from "next-auth";
import { ChevronRight } from "lucide-react";
import { authOptions } from "@/lib/auth";
import { organizerRepo } from "@/server/repositories/organizer.repo";
import { refundRepo } from "@/server/repositories/refund.repo";
@@ -155,8 +156,9 @@ export default async function AdminDashboardPage() {
>
{s.label}
</span>
<span className="text-xs text-neutral-400 group-hover:text-primary-600">
Buka
<span className="inline-flex items-center gap-1 text-xs text-neutral-400 group-hover:text-primary-600">
Buka
<ChevronRight size={14} strokeWidth={2} aria-hidden />
</span>
</div>
<p className="mt-3 text-3xl font-bold text-neutral-900">{s.value}</p>
+171 -10
View File
@@ -1,8 +1,17 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import {
ArrowUpRight,
CircleAlert,
CircleCheck,
CircleX,
} from "lucide-react";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { prisma } from "@/lib/prisma";
import { systemHealthService } from "@/server/services/system-health.service";
import { emailRepo } from "@/server/repositories/email.repo";
interface JobSummary {
jobName: string;
@@ -53,7 +62,11 @@ async function getJobSummary(jobName: string): Promise<JobSummary> {
}
// 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" {
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 recentRuns = await prisma.cronRun.findMany({
orderBy: { startedAt: "desc" },
take: 20,
});
const [summaries, recentRuns, stale, emailStats] = await Promise.all([
Promise.all(TRACKED_JOBS.map(getJobSummary)),
prisma.cronRun.findMany({
orderBy: { startedAt: "desc" },
take: 20,
}),
systemHealthService.detectStale(),
emailRepo.stats(),
]);
const hasAnyStale =
stale.stalePaymentsCount > 0 ||
stale.awaitingPayPastDepartureCount > 0 ||
stale.overduePayoutsCount > 0 ||
stale.stuckRefundsCount > 0 ||
emailStats.deadLetter > 0;
return (
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
@@ -96,6 +120,72 @@ export default async function AdminSystemPage() {
</p>
</header>
{hasAnyStale && (
<section className="mb-6 rounded-2xl border border-amber-300 bg-amber-50 p-4 sm:p-5">
<h2 className="mb-2 flex items-center gap-1.5 text-sm font-bold text-amber-900">
<CircleAlert size={16} strokeWidth={2} aria-hidden />
Stale State Alerts
</h2>
<ul className="space-y-1 text-xs text-amber-900">
{stale.stalePaymentsCount > 0 && (
<li>
<strong>{stale.stalePaymentsCount}</strong> Payment MIDTRANS
AWAITING &gt; 25 jam webhook mungkin tertunda. Cek manual lalu
reconcile.
</li>
)}
{stale.awaitingPayPastDepartureCount > 0 && (
<li>
<strong>{stale.awaitingPayPastDepartureCount}</strong> Booking
AWAITING_PAY tapi trip sudah lewat tanggal berangkat peserta
lupa bayar, butuh cleanup.
</li>
)}
{stale.overduePayoutsCount > 0 && (
<li>
<strong>{stale.overduePayoutsCount}</strong> Payout HELD lewat
heldUntil &gt; 1 hari cron release mungkin tidak jalan, cek
cron history di bawah.{" "}
<Link
href="/admin/payouts?tab=HELD"
className="inline-flex items-center gap-1 font-semibold text-amber-700 hover:underline"
>
Lihat HELD
<ArrowUpRight size={14} strokeWidth={2} aria-hidden />
</Link>
</li>
)}
{stale.stuckRefundsCount > 0 && (
<li>
<strong>{stale.stuckRefundsCount}</strong> Refund APPROVED
&gt; 7 hari belum di-process.{" "}
<Link
href="/admin/refunds?tab=APPROVED"
className="inline-flex items-center gap-1 font-semibold text-amber-700 hover:underline"
>
Lihat APPROVED
<ArrowUpRight size={14} strokeWidth={2} aria-hidden />
</Link>
</li>
)}
{emailStats.deadLetter > 0 && (
<li>
<strong>{emailStats.deadLetter}</strong> email gagal kirim &
sudah habis 5 attempt cron berhenti retry, perlu retry
manual.{" "}
<Link
href="/admin/emails?tab=failed"
className="inline-flex items-center gap-1 font-semibold text-amber-700 hover:underline"
>
Lihat email gagal
<ArrowUpRight size={14} strokeWidth={2} aria-hidden />
</Link>
</li>
)}
</ul>
</section>
)}
<section className="mb-8">
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
Cron Jobs
@@ -111,16 +201,23 @@ export default async function AdminSystemPage() {
: "border-red-200 bg-red-50/50";
const badge =
health === "ok"
? { label: "🟢 OK", cls: "bg-emerald-100 text-emerald-800" }
? {
label: "OK",
icon: CircleCheck,
cls: "bg-emerald-100 text-emerald-800",
}
: health === "stale"
? {
label: "🟡 STALE",
label: "STALE",
icon: CircleAlert,
cls: "bg-amber-100 text-amber-800",
}
: {
label: "🔴 FAILED",
label: "FAILED",
icon: CircleX,
cls: "bg-red-100 text-red-800",
};
const BadgeIcon = badge.icon;
return (
<div
key={s.jobName}
@@ -136,8 +233,9 @@ export default async function AdminSystemPage() {
</p>
</div>
<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}
</span>
</div>
@@ -176,6 +274,38 @@ export default async function AdminSystemPage() {
</div>
</section>
<section className="mb-8">
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
Email
</h2>
<div className="grid gap-3 sm:grid-cols-3">
<EmailStat
label="Antri dikirim"
value={emailStats.queued}
tone={emailStats.queued > 0 ? "amber" : "ok"}
/>
<EmailStat
label="Gagal 24 jam"
value={emailStats.failed24h}
tone={emailStats.failed24h > 0 ? "red" : "ok"}
/>
<EmailStat
label="Perlu aksi manual"
value={emailStats.deadLetter}
tone={emailStats.deadLetter > 0 ? "red" : "ok"}
/>
</div>
<p className="mt-2 text-xs text-neutral-500">
<Link
href="/admin/emails"
className="inline-flex items-center gap-1 font-semibold text-primary-600 hover:underline"
>
Buka Email Log
<ArrowUpRight size={14} strokeWidth={2} aria-hidden />
</Link>
</p>
</section>
<section>
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
Recent Runs (20 terakhir)
@@ -241,6 +371,37 @@ function truncate(s: string, max: number): string {
return s.length > max ? `${s.slice(0, max)}` : s;
}
function EmailStat({
label,
value,
tone,
}: {
label: string;
value: number;
tone: "ok" | "amber" | "red";
}) {
const cls =
tone === "red"
? "border-red-200 bg-red-50/60"
: tone === "amber"
? "border-amber-200 bg-amber-50/60"
: "border-emerald-200 bg-emerald-50/50";
const valueCls =
tone === "red"
? "text-red-700"
: tone === "amber"
? "text-amber-700"
: "text-emerald-700";
return (
<div className={`rounded-2xl border p-4 shadow-sm ${cls}`}>
<p className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
{label}
</p>
<p className={`mt-1 text-2xl font-bold ${valueCls}`}>{value}</p>
</div>
);
}
function StatusBadge({ value }: { value: string }) {
const cls =
value === "SUCCESS"
+26 -7
View File
@@ -1,6 +1,7 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { ArrowLeft, CalendarDays, MapPin } from "lucide-react";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { tripService } from "@/server/services/trip.service";
@@ -67,8 +68,12 @@ export default async function AdminTripDetailPage({ params }: PageProps) {
return (
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
<div className="mb-4 text-xs text-neutral-500">
<Link href="/admin/trips" className="hover:text-primary-600">
Kembali ke list trips
<Link
href="/admin/trips"
className="inline-flex items-center gap-1 hover:text-primary-600"
>
<ArrowLeft size={14} strokeWidth={2} aria-hidden />
Kembali ke list trips
</Link>
</div>
@@ -84,9 +89,22 @@ export default async function AdminTripDetailPage({ params }: PageProps) {
<h1 className="text-xl font-bold text-neutral-900 sm:text-2xl">
{trip.title}
</h1>
<p className="mt-1 text-sm text-neutral-500">
📅 {formatTripCalendarDateRangeLong(trip.date, trip.endDate)} ·
📍 {trip.destination}, {trip.location}
<p className="mt-1 flex flex-wrap items-center gap-1 text-sm text-neutral-500">
<CalendarDays
size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0"
/>
{formatTripCalendarDateRangeLong(trip.date, trip.endDate)}
<span aria-hidden>·</span>
<MapPin
size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0"
/>
{trip.destination}, {trip.location}
</p>
<p className="mt-1 text-xs text-neutral-500">
Organizer:{" "}
@@ -220,8 +238,9 @@ export default async function AdminTripDetailPage({ params }: PageProps) {
{p.user.name}
</Link>
{p.user.profile?.city && (
<span className="ml-2 text-[11px] text-neutral-500">
📍 {p.user.profile.city}
<span className="ml-2 inline-flex items-center gap-1 text-[11px] text-neutral-500">
<MapPin size={12} strokeWidth={2} aria-hidden />
{p.user.profile.city}
</span>
)}
</div>
+17 -3
View File
@@ -1,6 +1,7 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { CalendarDays, MapPin } from "lucide-react";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { tripRepo } from "@/server/repositories/trip.repo";
@@ -127,9 +128,22 @@ export default async function AdminTripsPage({ searchParams }: PageProps) {
<h2 className="truncate text-base font-bold text-neutral-900 sm:text-lg">
{t.title}
</h2>
<p className="mt-1 truncate text-xs text-neutral-500 sm:text-sm">
📅 {formatTripCalendarDateRangeLong(t.date, t.endDate)}
{" · "}📍 {t.location}
<p className="mt-1 flex items-center gap-1 truncate text-xs text-neutral-500 sm:text-sm">
<CalendarDays
size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0"
/>
{formatTripCalendarDateRangeLong(t.date, t.endDate)}
<span aria-hidden>·</span>
<MapPin
size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0"
/>
{t.location}
</p>
<p className="mt-1 text-xs text-neutral-500 sm:text-sm">
Organizer:{" "}
+27 -10
View File
@@ -2,11 +2,13 @@ import Link from "next/link";
import Image from "next/image";
import { notFound, redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { ArrowLeft, ArrowUpRight, Ban, Check } from "lucide-react";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { userRepo } from "@/server/repositories/user.repo";
import { formatRupiah } from "@/lib/utils";
import { SuspendUserButton } from "@/features/admin/components/suspend-user-button";
import { ManualVerifyButton } from "@/features/admin/components/manual-verify-button";
interface PageProps {
params: Promise<{ id: string }>;
@@ -37,8 +39,12 @@ export default async function AdminUserDetailPage({ params }: PageProps) {
return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:py-12">
<div className="mb-4 text-xs text-neutral-500">
<Link href="/admin/users" className="hover:text-primary-600">
Kembali ke list users
<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>
@@ -74,8 +80,9 @@ export default async function AdminUserDetailPage({ params }: PageProps) {
</span>
)}
{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">
Verified Organizer
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-emerald-800">
<Check size={12} strokeWidth={2.5} aria-hidden />
Verified Organizer
</span>
)}
</div>
@@ -121,8 +128,9 @@ export default async function AdminUserDetailPage({ params }: PageProps) {
{user.suspended && (
<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">
Akun ditangguhkan
<h2 className="flex items-center gap-1.5 text-sm font-bold text-red-900">
<Ban size={16} strokeWidth={2} aria-hidden />
Akun ditangguhkan
</h2>
<p className="mt-1 text-xs text-red-900/80">
{user.suspendedReason ?? "Tidak ada alasan tercatat."}
@@ -154,10 +162,18 @@ export default async function AdminUserDetailPage({ params }: PageProps) {
</h2>
{isSelf ? (
<p className="text-xs text-neutral-500">
Tidak bisa suspend akun sendiri.
Tidak bisa suspend / modifikasi akun sendiri.
</p>
) : (
<SuspendUserButton userId={user.id} isSuspended={user.suspended} />
<div className="flex flex-wrap items-start gap-3">
<SuspendUserButton userId={user.id} isSuspended={user.suspended} />
{!user.organizerVerification && (
<ManualVerifyButton
userId={user.id}
defaultBankAccountName={user.name}
/>
)}
</div>
)}
</section>
@@ -235,9 +251,10 @@ export default async function AdminUserDetailPage({ params }: PageProps) {
{" · "}
<Link
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>
</p>
{user.organizerVerification.rejectionReason && (
+21 -10
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import Image from "next/image";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { Check, ChartColumn } from "lucide-react";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { userRepo } from "@/server/repositories/user.repo";
@@ -44,14 +45,23 @@ export default async function AdminUsersPage({ searchParams }: PageProps) {
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">
User Management
</h1>
<p className="mt-1 text-sm text-neutral-500">
Cari user, lihat history booking & trip, dan suspend akun yang
melakukan abuse (scam, harassment, TOS violation).
</p>
<header className="mb-6 flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
User Management
</h1>
<p className="mt-1 text-sm text-neutral-500">
Cari user, lihat history booking & trip, dan suspend akun yang
melakukan abuse (scam, harassment, TOS violation).
</p>
</div>
<Link
href="/admin/users/stats"
className="inline-flex items-center gap-1.5 rounded-xl border border-neutral-200 bg-white px-3 py-1.5 text-xs font-semibold text-neutral-700 hover:bg-neutral-50"
>
<ChartColumn size={16} strokeWidth={2} aria-hidden />
Stats
</Link>
</header>
<form method="get" className="mb-4 flex gap-2">
@@ -139,8 +149,9 @@ export default async function AdminUsersPage({ searchParams }: PageProps) {
</span>
)}
{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">
Organizer
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-emerald-800">
<Check size={12} strokeWidth={2.5} aria-hidden />
Organizer
</span>
)}
</div>
+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>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from "next/server";
import { runCron } from "@/lib/cron-runner";
import { prisma } from "@/lib/prisma";
import {
deleteTripImage,
listTripImageNames,
tripImageMtime,
TRIP_IMAGE_URL_PREFIX,
} from "@/lib/trip-image-storage";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/** File yang lebih tua dari ini & tak direferensikan DB dianggap yatim. */
const ORPHAN_AGE_MS = 24 * 60 * 60 * 1000;
/**
* Cron — hapus file gambar trip yatim.
*
* Form create-trip multi-step mengunggah foto SEBELUM trip tersimpan; kalau
* user menutup form di tengah jalan, file menggantung di disk tanpa pernah
* jadi `TripImage`. Sweep ini menghapus file >24 jam yang tidak direferensikan
* `TripImage` mana pun. Idempotent — aman dijalankan berulang.
*
* Trigger: lihat docs/CRON_SETUP.md. Header wajib `Authorization: Bearer ${CRON_SECRET}`.
*/
export async function GET(req: NextRequest) {
const secret = process.env.CRON_SECRET;
if (!secret) {
console.error("[cron/cleanup-trip-images] CRON_SECRET tidak di-set");
return NextResponse.json(
{ error: "Server misconfigured" },
{ status: 500 }
);
}
if (req.headers.get("authorization") !== `Bearer ${secret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const outcome = await runCron("cleanup-trip-images", async () => {
const names = await listTripImageNames();
if (names.length === 0) return { scanned: 0, deleted: 0 };
const referenced = await prisma.tripImage.findMany({
where: { url: { startsWith: TRIP_IMAGE_URL_PREFIX } },
select: { url: true },
});
const referencedNames = new Set(
referenced.map((r) => r.url.slice(TRIP_IMAGE_URL_PREFIX.length))
);
const now = Date.now();
let deleted = 0;
for (const name of names) {
if (referencedNames.has(name)) continue;
const mtime = await tripImageMtime(name);
// File baru di-upload tapi trip belum tersimpan → beri tenggang 24 jam.
if (!mtime || now - mtime.getTime() < ORPHAN_AGE_MS) continue;
await deleteTripImage(name);
deleted++;
}
return { scanned: names.length, deleted };
});
if (!outcome.ok) {
console.error("[cron/cleanup-trip-images] gagal", outcome.error);
return NextResponse.json(
{ error: "Gagal menjalankan cleanup" },
{ status: 500 }
);
}
console.log("[cron/cleanup-trip-images] selesai", outcome.payload);
return NextResponse.json({ ok: true, ...outcome.payload });
}
+36
View File
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { emailService } from "@/lib/email/send";
import { runCron } from "@/lib/cron-runner";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Cron — proses retry queue email (jobs status PENDING/FAILED dengan
* attempts<5 dan scheduledAt sudah lewat).
*
* Trigger setiap 5 menit via system crontab — lihat docs/CRON_SETUP.md.
* Header wajib: `Authorization: Bearer ${CRON_SECRET}`.
*/
export async function GET(req: NextRequest) {
const secret = process.env.CRON_SECRET;
if (!secret) {
return NextResponse.json(
{ error: "Server misconfigured (CRON_SECRET)" },
{ status: 500 }
);
}
const authHeader = req.headers.get("authorization");
if (authHeader !== `Bearer ${secret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const outcome = await runCron("process-email-jobs", async () => {
return emailService.processQueue(50);
});
if (!outcome.ok) {
return NextResponse.json({ error: outcome.error }, { status: 500 });
}
return NextResponse.json({ ok: true, ...outcome.payload });
}
+38
View File
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { isValidTripImageName, readTripImage } from "@/lib/trip-image-storage";
export const runtime = "nodejs";
interface RouteCtx {
params: Promise<{ name: string }>;
}
/**
* Sajikan gambar trip dari disk lokal. Publik — gambar trip memang tampil ke
* semua pengunjung. Di-cache `immutable` selama setahun: nama file
* content-addressed (hex acak), jadi konten untuk satu nama tidak pernah
* berubah. Beban render = baca file kecil dari disk, tanpa fetch eksternal.
*/
export async function GET(_req: NextRequest, ctx: RouteCtx) {
const { name } = await ctx.params;
if (!isValidTripImageName(name)) {
return NextResponse.json({ error: "Tidak ditemukan" }, { status: 404 });
}
let data: Buffer;
try {
data = await readTripImage(name);
} catch {
return NextResponse.json({ error: "Tidak ditemukan" }, { status: 404 });
}
return new NextResponse(new Uint8Array(data), {
status: 200,
headers: {
"Content-Type": "image/webp",
"Content-Length": String(data.length),
"Cache-Control": "public, max-age=31536000, immutable",
"X-Content-Type-Options": "nosniff",
},
});
}
+73
View File
@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { requireActiveUser } from "@/lib/auth-guards";
import {
ALLOWED_TRIP_IMAGE_MIME,
MAX_TRIP_IMAGE_UPLOAD_BYTES,
processAndSaveTripImage,
} from "@/lib/trip-image-storage";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Upload satu foto trip. Dipanggil dari form create-trip saat user memilih
* file — gambar langsung dikompres & disimpan, route mengembalikan URL publik
* yang nanti ikut disubmit bersama data trip.
*
* File yatim (di-upload tapi trip batal dibuat) dibersihkan cron
* `/api/cron/cleanup-trip-images`.
*/
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
await requireActiveUser(session.user.id);
} catch (err) {
return NextResponse.json(
{ error: (err as Error).message },
{ status: 403 }
);
}
let form: FormData;
try {
form = await req.formData();
} catch {
return NextResponse.json(
{ error: "Body bukan multipart/form-data" },
{ status: 400 }
);
}
const file = form.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "File wajib diisi" }, { status: 400 });
}
if (!ALLOWED_TRIP_IMAGE_MIME.has(file.type)) {
return NextResponse.json(
{ error: "Hanya menerima JPG, PNG, atau WebP" },
{ status: 415 }
);
}
if (file.size > MAX_TRIP_IMAGE_UPLOAD_BYTES) {
return NextResponse.json(
{ error: "Ukuran file maksimal 12MB" },
{ status: 413 }
);
}
try {
const buf = Buffer.from(await file.arrayBuffer());
const saved = await processAndSaveTripImage(buf);
return NextResponse.json({ url: saved.url, size: saved.size });
} catch (err) {
return NextResponse.json(
{ error: (err as Error).message || "Gagal memproses gambar" },
{ status: 400 }
);
}
}
+53
View File
@@ -64,6 +64,16 @@ select:focus {
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1) !important;
}
/* Input wrapper isi penuh lebar parent + popper di atas konten lain */
.react-datepicker-wrapper,
.react-datepicker__input-container {
width: 100% !important;
}
.react-datepicker-popper {
z-index: 50 !important;
}
.react-datepicker__header {
background: #f9fafb !important;
border-bottom: 1px solid #e5e7eb !important;
@@ -132,3 +142,46 @@ select:focus {
.react-datepicker__close-icon:hover::after {
background-color: #16a34a !important;
}
/* Dropdown bulan / tahun (mode select) */
.react-datepicker__month-select,
.react-datepicker__year-select {
border: 1px solid #e5e7eb !important;
border-radius: 0.5rem !important;
padding: 2px 4px !important;
font-size: 0.8125rem !important;
background: #fff !important;
}
/* Pemilih jam (showTimeSelectOnly) */
.react-datepicker__time-container {
width: 96px !important;
}
.react-datepicker__time-container
.react-datepicker__time
.react-datepicker__time-box {
width: 96px !important;
}
.react-datepicker-time__header {
font-weight: 700 !important;
color: #1f2937 !important;
font-size: 0.8125rem !important;
}
.react-datepicker__time-list-item {
font-size: 0.8125rem !important;
color: #1f2937 !important;
}
.react-datepicker__time-list-item:hover {
background: #dcfce7 !important;
color: #15803d !important;
}
.react-datepicker__time-list-item--selected {
background: #16a34a !important;
color: #fff !important;
font-weight: 700 !important;
}
+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: "*",
allow: "/",
disallow: ["/api/", "/profile", "/create-trip"],
disallow: [
"/api/",
"/admin",
"/profile",
"/create-trip",
"/verify",
"/trips/*/payment",
],
},
],
sitemap: absoluteUrl("/sitemap.xml"),
+6
View File
@@ -24,6 +24,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
changeFrequency: "hourly",
priority: 0.9,
},
{
url: absoluteUrl("/people"),
lastModified: now,
changeFrequency: "daily",
priority: 0.7,
},
{
url: absoluteUrl("/register"),
lastModified: now,
+30 -19
View File
@@ -5,17 +5,33 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import Image from "next/image";
import { signOut } from "next-auth/react";
import {
ArrowLeft,
ArrowUpRight,
Banknote,
Compass,
IdCard,
LayoutDashboard,
Mail,
Menu,
ScrollText,
Settings,
Users,
X,
type LucideIcon,
} from "lucide-react";
import { AdminSearchBar } from "@/features/admin/components/admin-search-bar";
const NAV_ITEMS: { href: string; label: string; icon: string }[] = [
{ href: "/admin", label: "Dashboard", icon: "📊" },
{ href: "/admin/trips", label: "Trips", icon: "🧭" },
{ href: "/admin/users", label: "Users", icon: "👥" },
{ href: "/admin/verifications", label: "Verifikasi", icon: "🪪" },
{ href: "/admin/refunds", label: "Refund", icon: "↩️" },
{ href: "/admin/payouts", label: "Payout", icon: "💸" },
{ href: "/admin/audit-log", label: "Audit Log", icon: "📜" },
{ href: "/admin/system", label: "System", icon: "⚙️" },
const NAV_ITEMS: { href: string; label: string; icon: LucideIcon }[] = [
{ href: "/admin", label: "Dashboard", icon: LayoutDashboard },
{ href: "/admin/trips", label: "Trips", icon: Compass },
{ href: "/admin/users", label: "Users", icon: Users },
{ href: "/admin/verifications", label: "Verifikasi", icon: IdCard },
{ href: "/admin/refunds", label: "Refund", icon: ArrowLeft },
{ href: "/admin/payouts", label: "Payout", icon: Banknote },
{ href: "/admin/emails", label: "Email", icon: Mail },
{ href: "/admin/audit-log", label: "Audit Log", icon: ScrollText },
{ href: "/admin/system", label: "System", icon: Settings },
];
interface AdminSidebarProps {
@@ -50,13 +66,9 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
aria-expanded={open}
>
{open ? (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M5 5l10 10M15 5L5 15" />
</svg>
<X size={20} strokeWidth={2} aria-hidden />
) : (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M3 5h14M3 10h14M3 15h14" />
</svg>
<Menu size={20} strokeWidth={2} aria-hidden />
)}
</button>
</header>
@@ -105,6 +117,7 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
const isActive =
pathname === item.href ||
(item.href !== "/admin" && pathname?.startsWith(item.href));
const Icon = item.icon;
return (
<li key={item.href}>
<Link
@@ -116,9 +129,7 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
: "text-neutral-700 hover:bg-neutral-100"
}`}
>
<span aria-hidden className="text-base">
{item.icon}
</span>
<Icon size={20} strokeWidth={1.75} aria-hidden />
<span>{item.label}</span>
</Link>
</li>
@@ -135,7 +146,7 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
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"
>
<span aria-hidden></span>
<ArrowUpRight size={16} strokeWidth={1.75} aria-hidden />
<span>Lihat situs publik</span>
</Link>
</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 Image from "next/image";
import { useSession, signOut } from "next-auth/react";
import { Menu, X } from "lucide-react";
export function Navbar() {
const { data: session } = useSession();
@@ -109,29 +110,9 @@ export function Navbar() {
aria-label="Toggle menu"
>
{menuOpen ? (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M5 5l10 10M15 5L5 15" />
</svg>
<X size={20} strokeWidth={1.75} aria-hidden />
) : (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M3 5h14M3 10h14M3 15h14" />
</svg>
<Menu size={20} strokeWidth={1.75} aria-hidden />
)}
</button>
</div>
+8 -11
View File
@@ -1,23 +1,20 @@
import { BadgeCheck } from "lucide-react";
type Size = "sm" | "md";
export function VerifiedBadge({ size = "sm" }: { size?: Size }) {
const cls =
size === "md"
? "px-2.5 py-1 text-xs"
: "px-2 py-0.5 text-[10px]";
size === "md" ? "px-2.5 py-1 text-xs" : "px-2 py-0.5 text-[10px]";
return (
<span
className={`inline-flex items-center gap-1 rounded-full bg-primary-50 font-semibold text-primary-700 ring-1 ring-primary-200 ${cls}`}
title="Organizer terverifikasi SeTrip"
>
<svg
viewBox="0 0 16 16"
fill="currentColor"
className={size === "md" ? "h-3.5 w-3.5" : "h-3 w-3"}
aria-hidden="true"
>
<path d="M8 0l2.09 1.74L12.86 1.5l.64 2.78 2.5 1.5-1.5 2.5.5 2.86-2.78.64-1.5 2.5-2.72-.59L5.5 14.5 4 12 1.5 11.36 2 8.5.5 6 3 4.5l.64-2.78 2.77.24L8 0zm-1.07 9.4l4.6-4.6-1.06-1.06-3.54 3.54-1.41-1.42-1.06 1.06 2.47 2.48z" />
</svg>
<BadgeCheck
size={size === "md" ? 14 : 12}
strokeWidth={1.75}
aria-hidden
/>
Verified
</span>
);
+175 -21
View File
@@ -2,13 +2,23 @@
Aplikasi punya beberapa endpoint cron yang harus di-trigger periodik dari luar. Karena deploy pakai PM2 di VPS Linux (bukan Vercel), trigger schedule pakai **system crontab** — zero dependency, OS-native.
> **Audit trail otomatis:** semua cron yang di-wrap `runCron()` helper auto-log ke tabel `CronRun` (start/finish/error). Cek hasilnya real-time di `/admin/system` — link "System" di sidebar admin. Tidak perlu tail log untuk monitoring rutin.
---
## Daftar cron job
| Endpoint | Schedule | Tujuan |
|---|---|---|
| `GET /api/cron/auto-complete-trips` | `0 18 * * *` (18:00 UTC = 01:00 WIB) | Flip trip yang sudah lewat tanggal selesai dari `OPEN`/`FULL` ke `COMPLETED`. |
| # | Endpoint | Schedule | Frekuensi | Tujuan |
|---|---|---|---|---|
| 1 | `GET /api/cron/auto-complete-trips` | `0 18 * * *` | Daily 01:00 WIB (18:00 UTC) | Flip trip yang sudah lewat tanggal selesai dari `OPEN`/`FULL` ke `COMPLETED`. Setelah itu, release payout HELD yang sudah lewat `heldUntil`. |
| 2 | `GET /api/cron/process-email-jobs` | `*/5 * * * *` | Setiap 5 menit | Drain retry queue email — pick `EmailJob` status `PENDING`/`FAILED` (attempts<5), retry via Resend dengan exponential backoff. |
| 3 | `GET /api/cron/cleanup-trip-images` | `30 18 * * *` | Daily 01:30 WIB (18:30 UTC) | Hapus file gambar trip yatim — foto yang di-upload di form create-trip tapi trip-nya batal dibuat. Hanya file >24 jam yang tak direferensikan `TripImage`. |
## Setup di server
Semua cron pakai pola yang sama: header `Authorization: Bearer ${CRON_SECRET}`, idempotent, auto-log ke `CronRun`. Tambah cron baru = tambah baris di tabel ini + tabel `TRACKED_JOBS` di [app/admin/system/page.tsx](../app/admin/system/page.tsx).
---
## Setup di server (one-time)
### 1. Set `CRON_SECRET` di env production
@@ -30,7 +40,17 @@ Restart PM2 supaya proses re-load env:
pm2 restart setrip --update-env
```
### 2. Daftarkan crontab
### 2. Set env opsional untuk fitur lain yang di-trigger cron
| Env | Dibutuhkan oleh | Akibat kalau kosong |
|---|---|---|
| `RESEND_API_KEY` | `process-email-jobs` cron | Email tetap di-queue di DB; cron skip dengan warning. Set nanti dan cron akan auto-drain queue. |
| `EMAIL_FROM` | `process-email-jobs` cron | Pakai default `SeTrip <onboarding@resend.dev>` (cocok untuk dev/test). |
| `ADMIN_ALERT_WEBHOOK_URL` | `runCron` (semua cron) | Tidak ada Discord push notif saat cron FAILED. Admin tetap bisa cek manual di `/admin/system`. |
Semua env ini ada di [.env.example](../.env.example) dengan instruksi setup masing-masing.
### 3. Daftarkan crontab
Edit crontab user yang punya akses ke env (biasanya user yang menjalankan PM2):
@@ -38,11 +58,19 @@ Edit crontab user yang punya akses ke env (biasanya user yang menjalankan PM2):
crontab -e
```
Tambah baris (ganti `https://your-domain.com` dan `<CRON_SECRET>` sesuai env yang di-set di step 1):
Tambah baris berikut (ganti `https://your-domain.com` dan `<CRON_SECRET>` sesuai env yang di-set di step 1):
```cron
# Setrip — auto-complete trips harian (jam 01:00 WIB)
# === Setrip cron jobs ===
# 1. Auto-complete trip + release payout (daily 01:00 WIB)
0 18 * * * curl -fsS -H "Authorization: Bearer <CRON_SECRET>" https://your-domain.com/api/cron/auto-complete-trips >> /var/log/setrip-cron.log 2>&1
# 2. Drain email retry queue (setiap 5 menit)
*/5 * * * * curl -fsS -H "Authorization: Bearer <CRON_SECRET>" https://your-domain.com/api/cron/process-email-jobs >> /var/log/setrip-cron.log 2>&1
# 3. Bersihkan gambar trip yatim (daily 01:30 WIB)
30 18 * * * curl -fsS -H "Authorization: Bearer <CRON_SECRET>" https://your-domain.com/api/cron/cleanup-trip-images >> /var/log/setrip-cron.log 2>&1
```
Verifikasi crontab tersimpan:
@@ -51,57 +79,183 @@ Verifikasi crontab tersimpan:
crontab -l
```
### 3. Siapkan file log
### 4. Siapkan file log
```bash
sudo touch /var/log/setrip-cron.log
sudo chown $(whoami) /var/log/setrip-cron.log
```
## Test manual
Optional — logrotate supaya log tidak menggemuk:
```bash
sudo nano /etc/logrotate.d/setrip-cron
```
Isi:
```
/var/log/setrip-cron.log {
weekly
rotate 4
compress
missingok
notifempty
}
```
---
## Test manual (sanity check setelah deploy)
Sebelum tunggu schedule, panggil endpoint langsung untuk verifikasi:
```bash
# Test cron 1
curl -fsS -H "Authorization: Bearer $CRON_SECRET" https://your-domain.com/api/cron/auto-complete-trips
# Test cron 2
curl -fsS -H "Authorization: Bearer $CRON_SECRET" https://your-domain.com/api/cron/process-email-jobs
```
**Expected response:**
**Expected response per cron:**
- Belum ada trip yang lewat: `{"ok":true,"completed":0,"ids":[]}`
- Ada trip yang lewat: `{"ok":true,"completed":2,"ids":["clx...","cly..."]}`
| Cron | Sukses kosong | Sukses ada pekerjaan |
|---|---|---|
| `auto-complete-trips` | `{"ok":true,"completed":0,"ids":[],"payoutsReleased":[]}` | `{"ok":true,"completed":2,"ids":["clx...","cly..."],"payoutsReleased":["..."]}` |
| `process-email-jobs` | `{"ok":true,"picked":0,"succeeded":0,"failed":0}` | `{"ok":true,"picked":5,"succeeded":5,"failed":0}` |
| `cleanup-trip-images` | `{"ok":true,"scanned":0,"deleted":0}` | `{"ok":true,"scanned":12,"deleted":3}` |
**Kalau dapat 401:** `CRON_SECRET` di env tidak match dengan header. Cek `pm2 env <id>`.
**Error response:**
- **401** — `CRON_SECRET` di env tidak match dengan header. Cek `pm2 env <id>`.
- **500 "Server misconfigured"** — `CRON_SECRET` belum di-set di env.
- **500 lain** — cek log app atau `/admin/system` untuk detail error.
**Kalau dapat 500:** `CRON_SECRET` belum di-set di env.
---
## Monitoring
Tail log cron:
### Cara utama: `/admin/system` (admin panel)
Buka [/admin/system](/admin/system) — tampilkan:
- **Per-job summary card**: last run, last success, count 7 hari, error count 7 hari, health badge (🟢 OK / 🟡 STALE / 🔴 FAILED).
- **Recent runs table**: 20 cron run terakhir lintas semua job (waktu, status, payload, error).
- **Stale state alerts**: banner kuning kalau ada Payment AWAITING > 25h / Payout HELD overdue / Refund APPROVED > 7d.
Cek setiap pagi sebelum mulai kerja — kalau semua 🟢 OK, tidak ada incident.
### Cara backup: tail log file
```bash
tail -f /var/log/setrip-cron.log
```
Cek log app PM2 (untuk `console.log` dari endpoint):
PM2 log untuk `console.log` dari endpoint:
```bash
pm2 logs setrip --lines 100 | grep cron
```
### Discord push notif (opsional)
Kalau env `ADMIN_ALERT_WEBHOOK_URL` di-set ke Discord webhook URL, `runCron` otomatis kirim 🚨 message saat cron FAILED — admin bisa react langsung tanpa harus cek dashboard.
Cara setup: Discord channel → Edit Channel → Integrations → Webhooks → New → copy URL → set di env.
---
## Troubleshooting
**Cron jalan tapi tidak ada efek di DB:**
- Cek `pm2 logs setrip` untuk error.
- Verifikasi waktu server: `date` (output harus UTC kalau pakai schedule UTC).
- Cek `/admin/system` — kalau status SUCCESS dengan `payload: { completed: 0 }`, memang tidak ada pekerjaan saat ini.
- Cek `pm2 logs setrip` untuk error runtime.
- Verifikasi waktu server: `date -u` (output harus UTC kalau pakai schedule UTC).
**Cron tidak jalan sama sekali:**
- Cek service cron aktif: `systemctl status cron` (Debian/Ubuntu) atau `systemctl status crond` (RHEL/CentOS).
- Cek crontab terdaftar di user yang benar: `sudo crontab -u $(whoami) -l`.
- Cek `/admin/system` — kalau "Last run" jauh dari ekspektasi (mis. > 25 jam untuk daily cron), schedule mungkin tidak ke-trigger.
**`process-email-jobs` SUCCESS tapi email tidak terkirim:**
- Cek `RESEND_API_KEY` di env. Tanpa env, cron return early dengan warning di log.
- Cek dashboard Resend untuk delivery status / bounce.
- Cek tabel `EmailJob` di DB: status `PENDING`/`FAILED` + `lastError` field.
**`auto-complete-trips` SUCCESS tapi trip masih OPEN:**
- Cek `Trip.endDate` (kalau ada) atau `Trip.date` — harus lewat cutoff (`utcStartOfDay(now)`).
- Trip dengan status `CLOSED` sengaja tidak di-touch (organizer eksplisit batalkan).
**Secret bocor:**
- Generate ulang `CRON_SECRET`, update di `.env` + crontab line, restart PM2.
- Generate ulang `CRON_SECRET`, update di `.env` + semua baris crontab, restart PM2.
## Hari kalau pindah ke Vercel / PaaS lain
**Cron FAILED berturut-turut:**
- `/admin/system` akan tampilkan badge 🔴 FAILED.
- Kalau env `ADMIN_ALERT_WEBHOOK_URL` di-set, Discord channel akan dapat notif.
- Klik "Last error" di card cron untuk lihat stack trace, atau cek tabel `CronRun.errorMessage` langsung.
Tinggal hapus crontab line + bikin `vercel.json` (atau equivalent platform). Endpoint sudah platform-agnostic — proteksinya sama (header `Authorization: Bearer <CRON_SECRET>`).
---
## Saat menambah cron baru (developer note)
Checklist:
1. Buat route handler di `app/api/cron/<name>/route.ts` dengan pola standar (CRON_SECRET check + `runCron(jobName, fn)` wrapper).
2. Tambah entry di tabel **Daftar cron job** di doc ini.
3. Tambah baris di `TRACKED_JOBS` di [app/admin/system/page.tsx](../app/admin/system/page.tsx) supaya muncul di health card.
4. Brief ops: tambah baris di server crontab dengan schedule yang sesuai.
Pattern minimal cron handler:
```ts
// app/api/cron/<name>/route.ts
import { NextRequest, NextResponse } from "next/server";
import { runCron } from "@/lib/cron-runner";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
const secret = process.env.CRON_SECRET;
if (!secret) {
return NextResponse.json({ error: "Server misconfigured" }, { status: 500 });
}
if (req.headers.get("authorization") !== `Bearer ${secret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const outcome = await runCron("<jobName>", async () => {
// ... actual work; return value masuk ke CronRun.payload
return { processedCount: 0 };
});
if (!outcome.ok) {
return NextResponse.json({ error: outcome.error }, { status: 500 });
}
return NextResponse.json({ ok: true, ...outcome.payload });
}
```
---
## Kalau pindah ke Vercel / PaaS lain
Bikin `vercel.json` di root:
```json
{
"crons": [
{
"path": "/api/cron/auto-complete-trips",
"schedule": "0 18 * * *"
},
{
"path": "/api/cron/process-email-jobs",
"schedule": "*/5 * * * *"
}
]
}
```
Vercel Cron otomatis kirim header `Authorization: Bearer <VERCEL_CRON_SECRET>` — sesuaikan logic auth check di route handler (atau pakai env yang sama). Endpoint sudah platform-agnostic — tidak ada code change yang diperlukan.
> **Catatan:** Vercel Cron free tier limit 2 cron/project + minimum schedule 1 jam. Untuk `process-email-jobs` yang 5 menit, perlu upgrade Vercel Pro atau pertahankan VPS untuk cron.
+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 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 3 — Stale State Alerts | Deferred | Belum perlu sampai ada incident. |
| Phase 4 — External Alerting (Discord) | Deferred | Skip kecuali admin sering miss banner. |
| Phase 2 — System Status Page | ✅ Delivered | `/admin/system` tampilkan summary cron + 20 run terakhir + health badge. |
| Phase 3 — Stale State Alerts | Delivered | `systemHealthService.detectStale()` cek 4 kategori (Payment AWAITING > 25h, AWAITING_PAY past departure, Payout HELD overdue, Refund APPROVED > 7d). Banner di `/admin/system`. |
| Phase 4 — Discord Webhook Notify | Delivered | `notifyAdmins()` POST ke `ADMIN_ALERT_WEBHOOK_URL`. Trigger otomatis saat cron FAILED via `runCron`. |
---
@@ -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.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.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 |
|---|---|---|---|
| 2.1 | Per-job summary: last run, last success, count 7d, error count 7d | ✅ | [app/admin/system/page.tsx](../../app/admin/system/page.tsx) |
| 2.2 | 20 cron run terakhir di table bawah | ✅ | [app/admin/system/page.tsx](../../app/admin/system/page.tsx) |
| 2.3 | Health badge (🟢 OK < 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) |
**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).
@@ -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 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 1 — Reopen Rejected | ✅ Delivered | Tombol "Buka kembali ke PENDING" di REJECTED card dengan note wajib min 10 char. |
| Phase 2 — Re-upload Request | Deferred | Butuh schema + organizer-side UI; skip MVP. |
| Phase 3 — Verification History | Deferred | Skip. |
| Phase 4 — Manual Override | Deferred | Skip. |
| Phase 1 — Reopen Rejected | ✅ Delivered | Tombol "Buka kembali ke PENDING" di REJECTED card dengan note wajib. |
| Phase 2 — Re-upload Request | Delivered | Admin pilih checkbox field (KTP/liveness/NIK/bank/alamat) + note. Organizer dapat banner kuning di `/verify` dengan highlight field yang diminta. Auto-clear saat submit ulang. |
| Phase 3 — Submission History | Delivered | Field `submissionCount` di-bump tiap submit ulang. `previousRejections` JSON array menyimpan rejection lama (waktu + reason + submission ke-N) sebelum overwrite. |
| Phase 4 — Manual Override | Delivered | Admin verify user tanpa upload KYC (partner trusted). Flag `isManualOverride = true` untuk audit transparansi. UI di `/admin/users/[id]`. |
---
## Phase 1 — Reopen Rejected Verification
## Phase 1 — Reopen Rejected ✅
| # | 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.2 | `organizerRepo.reopen(id, note)` | ✅ | [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.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) |
**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.
| 1.1 | `organizerService.reopenVerification` | ✅ | [server/services/organizer.service.ts](../../server/services/organizer.service.ts) |
| 1.2 | `organizerRepo.reopen` | ✅ | [server/repositories/organizer.repo.ts](../../server/repositories/organizer.repo.ts) |
| 1.3 | `reopenVerificationAction` (guard isAdmin + audit log) | ✅ | [features/organizer/actions.ts](../../features/organizer/actions.ts) |
| 1.4 | UI tombol di REJECTED card | ✅ | [features/organizer/components/review-card.tsx](../../features/organizer/components/review-card.tsx) |
---
## Phase 2-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).
+40
View File
@@ -6,6 +6,8 @@ import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { userService } from "@/server/services/user.service";
import { auditLog } from "@/server/services/audit-log.service";
import { emailService } from "@/lib/email/send";
import { userRepo } from "@/server/repositories/user.repo";
export async function suspendUserAction(userId: string, reason: string) {
const session = await getServerSession(authOptions);
@@ -29,6 +31,10 @@ export async function suspendUserAction(userId: string, reason: string) {
entityId: userId,
payload: { reason: reason.trim() },
});
// Notif email user — due process: kasih tahu alasan + cara appeal.
void notifySuspended(userId, reason.trim());
revalidatePath("/admin/users");
revalidatePath(`/admin/users/${userId}`);
return { success: true as const };
@@ -37,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) {
const session = await getServerSession(authOptions);
if (!session?.user) {
@@ -54,6 +90,10 @@ export async function unsuspendUserAction(userId: string) {
entityType: "User",
entityId: userId,
});
// Notif email user — kabari akun sudah aktif kembali.
void notifyUnsuspended(userId);
revalidatePath("/admin/users");
revalidatePath(`/admin/users/${userId}`);
return { success: true as const };
@@ -1,3 +1,5 @@
import { DateField } from "@/components/shared/date-picker";
interface AdminFilterBarProps {
/** URL base (mis. `/admin/refunds`) yang menerima query params. */
action: string;
@@ -45,12 +47,11 @@ export function AdminFilterBar({
>
Dari tanggal
</label>
<input
<DateField
id="filter-dateFrom"
name="dateFrom"
type="date"
defaultValue={values.dateFrom ?? ""}
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"
defaultValueYmd={values.dateFrom}
placeholder="Dari tanggal"
/>
</div>
@@ -61,12 +62,11 @@ export function AdminFilterBar({
>
Sampai tanggal
</label>
<input
<DateField
id="filter-dateTo"
name="dateTo"
type="date"
defaultValue={values.dateTo ?? ""}
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"
defaultValueYmd={values.dateTo}
placeholder="Sampai tanggal"
/>
</div>
@@ -1,3 +1,5 @@
import { Download } from "lucide-react";
interface ExportCsvLinkProps {
/** URL endpoint export, mis. `/api/admin/export/refunds`. */
href: string;
@@ -22,7 +24,7 @@ export function ExportCsvLink({
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
>
<span aria-hidden></span>
<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>
);
}
@@ -2,6 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Check } from "lucide-react";
import { adminReconcileMidtransAction } from "@/features/booking/actions";
interface AdminReconcileButtonProps {
@@ -45,8 +46,9 @@ export function AdminReconcileButton({
{loading ? "Reconciling..." : "Reconcile Midtrans"}
</button>
{status && (
<span className="text-[11px] font-medium text-emerald-700">
{reconcileOutcomeLabel(status)}
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-emerald-700">
<Check size={12} strokeWidth={2.5} aria-hidden />
{reconcileOutcomeLabel(status)}
</span>
)}
{error && (
@@ -2,6 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { CircleAlert } from "lucide-react";
import { cancelBookingWithRefundAction } from "@/features/booking/actions";
import { formatRupiah } from "@/lib/utils";
@@ -112,9 +113,17 @@ export function CancelBookingButton({ tripId, preview }: CancelBookingButtonProp
Tier: {preview.tierLabel}
</p>
{noRefund ? (
<p className="mt-2 text-xs text-red-700">
Di luar window refund uang tidak dikembalikan. Booking akan
di-cancel langsung.
<p className="mt-2 flex items-start gap-1.5 text-xs text-red-700">
<CircleAlert
size={14}
strokeWidth={2}
aria-hidden
className="mt-0.5 shrink-0"
/>
<span>
Di luar window refund uang tidak dikembalikan. Booking akan
di-cancel langsung.
</span>
</p>
) : (
<p className="mt-2 text-xs text-neutral-600">
+13 -2
View File
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { Check, Copy } from "lucide-react";
interface CopyButtonProps {
value: string;
@@ -24,9 +25,19 @@ export function CopyButton({ value, label = "Salin" }: CopyButtonProps) {
<button
type="button"
onClick={handleClick}
className="rounded-lg border border-neutral-200 bg-white px-2.5 py-1 text-xs font-medium text-neutral-600 transition-colors hover:bg-neutral-50"
className="inline-flex items-center gap-1 rounded-lg border border-neutral-200 bg-white px-2.5 py-1 text-xs font-medium text-neutral-600 transition-colors hover:bg-neutral-50"
>
{copied ? "✓ Tersalin" : label}
{copied ? (
<>
<Check size={13} strokeWidth={2.5} aria-hidden className="text-emerald-600" />
Tersalin
</>
) : (
<>
<Copy size={13} strokeWidth={1.75} aria-hidden />
{label}
</>
)}
</button>
);
}
+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>
);
}
+205 -1
View File
@@ -4,8 +4,16 @@ import { getServerSession } from "next-auth";
import { revalidatePath } from "next/cache";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { organizerService } from "@/server/services/organizer.service";
import {
isReuploadField,
organizerService,
type ReuploadField,
} from "@/server/services/organizer.service";
import { auditLog } from "@/server/services/audit-log.service";
import { emailService } from "@/lib/email/send";
import { organizerRepo } from "@/server/repositories/organizer.repo";
import { userRepo } from "@/server/repositories/user.repo";
import { prisma } from "@/lib/prisma";
import { submitVerificationSchema, reviewVerificationSchema } from "./schemas";
export async function submitVerificationAction(formData: FormData) {
@@ -36,6 +44,7 @@ export async function submitVerificationAction(formData: FormData) {
...result.data,
birthDate: new Date(result.data.birthDate),
});
void notifyKycSubmitted(session.user.id);
revalidatePath("/verify");
revalidatePath("/profile");
revalidatePath("/admin/verifications");
@@ -82,6 +91,10 @@ export async function reviewVerificationAction(formData: FormData) {
? { rejectionReason: result.data.rejectionReason ?? null }
: undefined,
});
// Notif email — fire and forget, jangan blok response.
void notifyVerificationDecision(result.data.verificationId, result.data.decision, result.data.rejectionReason);
revalidatePath("/admin/verifications");
revalidatePath("/verify");
revalidatePath("/profile");
@@ -91,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
* di-review ulang tanpa drop & recreate row. Note wajib min 10 char untuk audit.
@@ -110,6 +206,7 @@ export async function reopenVerificationAction(
adminId: session.user.id,
note,
});
void notifyKycReopened(verificationId);
await auditLog.record({
admin: { id: session.user.id, email: session.user.email },
action: "VERIFICATION_REOPEN",
@@ -125,3 +222,110 @@ export async function reopenVerificationAction(
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 { useRouter } from "next/navigation";
import { CircleCheck, CircleX, RefreshCw } from "lucide-react";
import {
reopenVerificationAction,
requestReuploadAction,
reviewVerificationAction,
} from "@/features/organizer/actions";
const REUPLOAD_FIELD_LABELS: { value: string; label: string }[] = [
{ value: "ktpImage", label: "Foto KTP" },
{ value: "liveness", label: "Foto liveness (pegang kertas SETRIP)" },
{ value: "nik", label: "NIK" },
{ value: "bankInfo", label: "Info rekening" },
{ value: "address", label: "Alamat" },
];
type Verification = {
id: string;
fullName: string;
@@ -37,11 +47,41 @@ export function ReviewCard({ verification }: { verification: Verification }) {
const router = useRouter();
const [showReject, setShowReject] = useState(false);
const [showReopen, setShowReopen] = useState(false);
const [showReupload, setShowReupload] = useState(false);
const [rejectionReason, setRejectionReason] = useState("");
const [reopenNote, setReopenNote] = useState("");
const [reuploadNote, setReuploadNote] = useState("");
const [reuploadFields, setReuploadFields] = useState<string[]>([]);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
function toggleReuploadField(value: string) {
setReuploadFields((prev) =>
prev.includes(value)
? prev.filter((v) => v !== value)
: [...prev, value]
);
}
async function requestReupload() {
setError("");
setLoading(true);
const result = await requestReuploadAction(
verification.id,
reuploadFields,
reuploadNote
);
setLoading(false);
if ("error" in result && result.error) {
setError(result.error);
return;
}
setShowReupload(false);
setReuploadFields([]);
setReuploadNote("");
router.refresh();
}
async function decide(decision: "APPROVED" | "REJECTED") {
setError("");
setLoading(true);
@@ -141,9 +181,10 @@ export function ReviewCard({ verification }: { verification: Verification }) {
type="button"
onClick={() => setShowReopen(true)}
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>
) : (
<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}
</div>
)}
{!showReject ? (
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => decide("APPROVED")}
disabled={loading}
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50"
>
Setujui
</button>
<button
type="button"
onClick={() => setShowReject(true)}
disabled={loading}
className="rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50 disabled:opacity-50"
>
Tolak
</button>
</div>
) : (
{showReject ? (
<div className="space-y-2">
<textarea
value={rejectionReason}
@@ -241,6 +263,105 @@ export function ReviewCard({ verification }: { verification: Verification }) {
</button>
</div>
</div>
) : showReupload ? (
<div className="space-y-3 rounded-xl border border-amber-200 bg-amber-50/60 p-3">
<div>
<label className="mb-1 block text-xs font-semibold text-amber-900">
Field yang perlu di-upload ulang (min 1)
</label>
<div className="flex flex-wrap gap-2">
{REUPLOAD_FIELD_LABELS.map((f) => {
const checked = reuploadFields.includes(f.value);
return (
<label
key={f.value}
className={`cursor-pointer rounded-full border px-3 py-1 text-xs font-medium ${
checked
? "border-amber-600 bg-amber-600 text-white"
: "border-amber-200 bg-white text-amber-800 hover:bg-amber-100"
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleReuploadField(f.value)}
className="sr-only"
/>
{f.label}
</label>
);
})}
</div>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-amber-900">
Catatan untuk organizer (min 10 char akan dilihat user)
</label>
<textarea
value={reuploadNote}
onChange={(e) => setReuploadNote(e.target.value)}
rows={2}
maxLength={500}
placeholder="contoh: Foto KTP terlalu buram, tolong foto ulang dengan pencahayaan lebih baik."
className="w-full rounded-xl border border-amber-200 bg-white px-3 py-2 text-sm focus:border-amber-400"
/>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={requestReupload}
disabled={
loading ||
reuploadFields.length === 0 ||
reuploadNote.trim().length < 10
}
className="rounded-xl bg-amber-600 px-4 py-2 text-sm font-bold text-white hover:bg-amber-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? "Memproses..." : "Kirim Permintaan"}
</button>
<button
type="button"
onClick={() => {
setShowReupload(false);
setReuploadFields([]);
setReuploadNote("");
}}
className="rounded-xl border border-neutral-200 bg-white px-4 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-50"
>
Batal
</button>
</div>
</div>
) : (
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => decide("APPROVED")}
disabled={loading}
className="inline-flex items-center gap-1.5 rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50"
>
<CircleCheck size={18} strokeWidth={2} aria-hidden />
Setujui
</button>
<button
type="button"
onClick={() => setShowReupload(true)}
disabled={loading}
className="inline-flex items-center gap-1.5 rounded-xl border border-amber-300 bg-white px-4 py-2 text-sm font-bold text-amber-700 hover:bg-amber-50 disabled:opacity-50"
>
<RefreshCw size={18} strokeWidth={2} aria-hidden />
Minta re-upload
</button>
<button
type="button"
onClick={() => setShowReject(true)}
disabled={loading}
className="inline-flex items-center gap-1.5 rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50 disabled:opacity-50"
>
<CircleX size={18} strokeWidth={2} aria-hidden />
Tolak
</button>
</div>
)}
</div>
)}
+59 -16
View File
@@ -2,7 +2,9 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { IdCard, Image as ImageIcon, Landmark, Check } from "lucide-react";
import { submitVerificationAction } from "@/features/organizer/actions";
import { DateField } from "@/components/shared/date-picker";
type Initial = {
fullName: string;
@@ -21,23 +23,31 @@ type UploadKind = "ktp" | "liveness";
const ACCEPT_MIME = "image/jpeg,image/png,image/webp";
const MAX_BYTES = 5 * 1024 * 1024;
function toYmd(d: Date): string {
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
const day = String(d.getUTCDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
export function VerifyForm({ initial }: { initial: Initial }) {
const router = useRouter();
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [ktpKey, setKtpKey] = useState(initial?.ktpImageKey ?? "");
const [livenessKey, setLivenessKey] = useState(initial?.livenessKey ?? "");
// `birthDate` dari DB tersimpan sebagai tengah malam UTC — baca pakai getter
// UTC supaya hari kalender yang tampil di picker tidak bergeser.
const [birthDate, setBirthDate] = useState<Date | null>(
initial
? new Date(
initial.birthDate.getUTCFullYear(),
initial.birthDate.getUTCMonth(),
initial.birthDate.getUTCDate()
)
: null
);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError("");
if (!birthDate) {
setError("Tanggal lahir wajib diisi");
return;
}
if (!ktpKey || !livenessKey) {
setError("Foto KTP dan foto memegang kertas SETRIP wajib diunggah");
return;
@@ -70,7 +80,15 @@ export function VerifyForm({ initial }: { initial: Initial }) {
)}
<section>
<h2 className="mb-3 text-base font-bold text-neutral-900">📇 Data KTP</h2>
<h2 className="mb-3 flex items-center gap-2 text-base font-bold text-neutral-900">
<IdCard
size={18}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
Data KTP
</h2>
<div className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="mb-1.5 block text-sm font-semibold text-neutral-700">
@@ -102,15 +120,21 @@ export function VerifyForm({ initial }: { initial: Initial }) {
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-semibold text-neutral-700">
<label
htmlFor="birthDate"
className="mb-1.5 block text-sm font-semibold text-neutral-700"
>
Tanggal Lahir
</label>
<input
<DateField
id="birthDate"
name="birthDate"
type="date"
value={birthDate}
onChange={setBirthDate}
maxDate={new Date()}
withMonthYearDropdown
required
defaultValue={initial ? toYmd(new Date(initial.birthDate)) : ""}
className={inputCls}
placeholder="Pilih tanggal lahir"
/>
</div>
<div className="sm:col-span-2">
@@ -130,7 +154,15 @@ export function VerifyForm({ initial }: { initial: Initial }) {
</section>
<section>
<h2 className="mb-3 text-base font-bold text-neutral-900">🖼 Foto</h2>
<h2 className="mb-3 flex items-center gap-2 text-base font-bold text-neutral-900">
<ImageIcon
size={18}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
Foto
</h2>
<p className="mb-3 text-xs text-neutral-500">
Foto disimpan terenkripsi di server SeTrip dan hanya bisa dilihat oleh
tim admin saat review. Maks 5MB, JPG/PNG/WebP.
@@ -163,7 +195,15 @@ export function VerifyForm({ initial }: { initial: Initial }) {
</section>
<section>
<h2 className="mb-3 text-base font-bold text-neutral-900">🏦 Rekening Bank</h2>
<h2 className="mb-3 flex items-center gap-2 text-base font-bold text-neutral-900">
<Landmark
size={18}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
Rekening Bank
</h2>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-semibold text-neutral-700">
@@ -297,7 +337,10 @@ function FileUpload({
/>
</label>
{value && !busy && (
<span className="text-xs text-neutral-500"> Terunggah</span>
<span className="inline-flex items-center gap-1 text-xs text-emerald-600">
<Check size={13} strokeWidth={2.5} aria-hidden />
Terunggah
</span>
)}
</div>
{previewUrl && (
+22
View File
@@ -5,7 +5,9 @@ import { revalidatePath } from "next/cache";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { payoutService } from "@/server/services/payout.service";
import { payoutRepo } from "@/server/repositories/payout.repo";
import { auditLog } from "@/server/services/audit-log.service";
import { emailService } from "@/lib/email/send";
import { payoutMarkPaidSchema } from "./schemas";
async function requireAdmin() {
@@ -34,6 +36,7 @@ export async function markPayoutPaidAction(formData: FormData) {
adminId: admin.id,
adminNote: parsed.data.adminNote,
});
void notifyPayoutPaid(parsed.data.payoutId, parsed.data.adminNote);
await auditLog.record({
admin: { id: admin.id, email: admin.email },
action: "PAYOUT_MARK_PAID",
@@ -49,3 +52,22 @@ export async function markPayoutPaidAction(formData: FormData) {
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 Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowRight, Banknote, CircleAlert } from "lucide-react";
import { markPayoutPaidAction } from "@/features/payout/actions";
import { formatRupiah } from "@/lib/utils";
@@ -95,9 +96,10 @@ export function PayoutReviewCard({ payout }: { payout: PayoutCardData }) {
</p>
<Link
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>
</div>
<StatusPill status={payout.status} />
@@ -142,9 +144,18 @@ export function PayoutReviewCard({ payout }: { payout: PayoutCardData }) {
</p>
</div>
) : (
<p className="text-amber-700">
Organizer belum menyelesaikan verifikasi (KYC) tidak ada rekening
snapshot. Hubungi organizer untuk konfirmasi rekening sebelum transfer.
<p className="flex gap-1.5 text-amber-700">
<CircleAlert
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>
)}
</div>
@@ -212,9 +223,10 @@ export function PayoutReviewCard({ payout }: { payout: PayoutCardData }) {
type="button"
onClick={() => setOpen(true)}
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>
)}
</div>
@@ -1,3 +1,4 @@
import { BadgeCheck, Star } from "lucide-react";
import type { OrganizerTrust } from "@/server/services/trust.service";
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"
title="Identitas organizer telah diverifikasi (KTP & rekening)"
>
Verified Organizer
<BadgeCheck size={12} strokeWidth={2} aria-hidden />
Verified Organizer
</span>
)}
{isTripLeader && (
@@ -83,7 +85,21 @@ export function OrganizerStatsPanel({ trust }: OrganizerStatsPanelProps) {
/>
<Stat
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={
reviewCount > 0
? `${reviewCount} ulasan`
@@ -107,8 +123,15 @@ export function OrganizerStatsPanel({ trust }: OrganizerStatsPanelProps) {
key={star}
className="flex items-center gap-2 text-xs"
>
<span className="w-8 shrink-0 font-medium text-neutral-600">
{star}
<span className="flex w-8 shrink-0 items-center gap-0.5 font-medium text-neutral-600">
{star}
<Star
size={11}
strokeWidth={2}
fill="currentColor"
aria-hidden
className="text-amber-500"
/>
</span>
<div className="h-2 flex-1 overflow-hidden rounded-full bg-neutral-100">
<div
@@ -138,7 +161,7 @@ const TONE_CLASSES = {
interface StatProps {
label: string;
value: string;
value: React.ReactNode;
subtitle?: string;
tone: keyof typeof TONE_CLASSES;
}
@@ -2,6 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { ArrowUpRight } from "lucide-react";
import { updateProfileAction } from "@/features/profile/actions";
import { LIMITS } from "@/lib/limits";
import { VIBES, vibeMeta } from "@/lib/vibe";
@@ -102,9 +103,10 @@ export function ProfileEditor({ userId, initial }: ProfileEditorProps) {
href={`/u/${userId}`}
target="_blank"
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>
<button
type="button"
@@ -324,9 +326,10 @@ export function ProfileEditor({ userId, initial }: ProfileEditorProps) {
href={`/u/${userId}`}
target="_blank"
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>
</div>
</form>
+7 -4
View File
@@ -1,5 +1,6 @@
import Image from "next/image";
import Link from "next/link";
import { MapPin, BadgeCheck } from "lucide-react";
import { vibeMeta } from "@/lib/vibe";
import type { Vibe } from "@/app/generated/prisma/enums";
@@ -48,17 +49,19 @@ export function UserCard({
{name}
</p>
{profile?.city && (
<p className="truncate text-[11px] text-neutral-500 sm:text-xs">
📍 {profile.city}
<p className="flex items-center gap-1 truncate text-[11px] text-neutral-500 sm:text-xs">
<MapPin size={11} strokeWidth={1.75} aria-hidden className="shrink-0" />
{profile.city}
</p>
)}
<div className="mt-1 flex flex-wrap gap-1">
{isVerifiedOrganizer && (
<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"
>
Organizer
<BadgeCheck size={11} strokeWidth={2} aria-hidden />
Organizer
</span>
)}
{profile?.vibe && (
+83
View File
@@ -6,6 +6,8 @@ import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
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";
async function requireAdmin() {
@@ -51,6 +53,9 @@ export async function createRefundAction(formData: FormData) {
reason: parsed.data.reason,
},
});
void notifyRefundCreated(refund.id);
revalidatePath("/admin/refunds");
return { success: true };
} catch (err) {
@@ -58,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) {
const admin = await requireAdmin();
if (!admin) return { error: "Tidak memiliki akses admin" };
@@ -110,6 +186,13 @@ export async function decideRefundAction(formData: FormData) {
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");
return { success: true };
} catch (err) {
@@ -1,3 +1,4 @@
import { LifeBuoy } from "lucide-react";
import { getRefundPolicyTiers } from "@/lib/refund-policy";
/**
@@ -9,7 +10,13 @@ export function RefundPolicySection() {
return (
<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">
🛟 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>
<div className="mt-2 space-y-2 text-neutral-600">
<p className="text-[11px] text-neutral-500 sm:text-xs">
@@ -21,7 +28,7 @@ export function RefundPolicySection() {
{tiers.map((t) => (
<li key={t.minDaysBefore} className="flex items-baseline gap-2">
<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
? "bg-primary-100 text-primary-700"
: t.refundPercentage >= 50
@@ -3,6 +3,13 @@
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
ArrowRight,
Banknote,
CircleAlert,
CircleCheck,
CircleX,
} from "lucide-react";
import { decideRefundAction } from "@/features/refund/actions";
import { formatRupiah } from "@/lib/utils";
@@ -129,9 +136,10 @@ export function RefundReviewCard({ refund }: { refund: RefundCardData }) {
</p>
<Link
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>
</div>
<Field
@@ -211,17 +219,19 @@ export function RefundReviewCard({ refund }: { refund: RefundCardData }) {
type="button"
onClick={() => setOpenAction("APPROVE")}
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
type="button"
onClick={() => setOpenAction("REJECT")}
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>
</>
)}
@@ -231,17 +241,19 @@ export function RefundReviewCard({ refund }: { refund: RefundCardData }) {
type="button"
onClick={() => setOpenAction("SUCCEEDED")}
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
type="button"
onClick={() => setOpenAction("FAILED")}
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>
</>
)}
@@ -1,5 +1,6 @@
import Link from "next/link";
import Image from "next/image";
import { Star } from "lucide-react";
import type { OrganizerReviewItem } from "@/server/services/review.service";
interface OrganizerReviewsListProps {
@@ -62,11 +63,22 @@ export function OrganizerReviewsList({
>
{r.user.name}
</Link>
<span className="text-xs font-bold text-amber-600">
{"★".repeat(r.rating)}
<span className="text-neutral-300">
{"★".repeat(5 - r.rating)}
</span>
<span
className="flex shrink-0 items-center gap-0.5"
aria-label={`Rating ${r.rating} dari 5`}
>
{[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>
</div>
+151
View File
@@ -13,6 +13,8 @@ import { revalidatePath } from "next/cache";
import { tripStoredInstantFromYmd } from "@/lib/trip-dates";
import { requireActiveUser } from "@/lib/auth-guards";
import { auditLog } from "@/server/services/audit-log.service";
import { emailService } from "@/lib/email/send";
import { prisma } from "@/lib/prisma";
export async function createTripAction(formData: FormData) {
const session = await getServerSession(authOptions);
@@ -129,6 +131,7 @@ export async function joinTripAction(tripId: string) {
try {
await requireActiveUser(session.user.id);
await tripService.joinTrip(tripId, session.user.id);
void notifyJoinRequest(tripId, session.user.id);
revalidatePath(`/trips/${tripId}`);
revalidatePath("/trips");
revalidatePath("/");
@@ -172,6 +175,9 @@ export async function confirmParticipantAction(
participantId,
session.user.id
);
void notifyBookingApproved(participantId);
revalidatePath(`/trips/${tripId}`);
revalidatePath("/trips");
revalidatePath("/");
@@ -182,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(
tripId: string,
participantId: string
@@ -197,6 +282,7 @@ export async function rejectParticipantAction(
participantId,
session.user.id
);
void notifyJoinRejected(participantId);
revalidatePath(`/trips/${tripId}`);
revalidatePath("/trips");
revalidatePath("/");
@@ -207,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) {
const session = await getServerSession(authOptions);
if (!session?.user) {
@@ -218,6 +364,7 @@ export async function cancelTripAction(tripId: string) {
type: "ORGANIZER",
userId: session.user.id,
});
notifyTripCancelled(tripId, result.notify, { type: "ORGANIZER" });
revalidatePath(`/trips/${tripId}`);
revalidatePath("/trips");
revalidatePath("/");
@@ -264,6 +411,10 @@ export async function adminCancelTripAction(tripId: string, reason: string) {
adminId: session.user.id,
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",
@@ -2,6 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { CircleCheck } from "lucide-react";
import { adminCancelTripAction } from "@/features/trip/actions";
interface AdminCancelTripButtonProps {
@@ -42,7 +43,10 @@ export function AdminCancelTripButton({ tripId }: AdminCancelTripButtonProps) {
if (result) {
return (
<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">
<li> {result.refundCount} booking PAID refund auto-dibuat</li>
<li>
+62 -87
View File
@@ -2,10 +2,17 @@
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
import {
ArrowLeft,
ArrowRight,
Check,
X,
CircleAlert,
Users,
} from "lucide-react";
import { DateRangeField, TimeField } from "@/components/shared/date-picker";
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 { ACTIVITY_CATEGORIES, categoryMeta } from "@/lib/activity-category";
import { VIBES, vibeMeta } from "@/lib/vibe";
@@ -54,7 +61,7 @@ const INITIAL_STATE: FormState = {
itineraryDays: [],
whatsIncluded: "",
whatsExcluded: "",
imageUrls: [""],
imageUrls: [],
maxParticipants: "",
priceDisplay: "",
};
@@ -145,18 +152,11 @@ export function CreateTripForm({ isVerifiedOrganizer }: CreateTripFormProps) {
return null;
}
if (target === 3) {
const hasInvalidUrl = state.imageUrls
.map((u) => u.trim())
.filter(Boolean)
.some((u) => {
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)";
// Foto divalidasi saat upload (route + komponen). Di sini cukup cek
// batas jumlah supaya tidak melampaui kapasitas.
if (state.imageUrls.length > LIMITS.MAX_IMAGE_URLS) {
return `Maksimal ${LIMITS.MAX_IMAGE_URLS} foto`;
}
for (let d = 0; d < state.itineraryDays.length; d++) {
const dayItems = state.itineraryDays[d];
@@ -331,6 +331,7 @@ export function CreateTripForm({ isVerifiedOrganizer }: CreateTripFormProps) {
whatsExcluded={state.whatsExcluded}
imageUrls={state.imageUrls}
onChange={update}
onError={setStepError}
/>
)}
@@ -368,9 +369,10 @@ export function CreateTripForm({ isVerifiedOrganizer }: CreateTripFormProps) {
type="button"
onClick={goBack}
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>
{isLastStep ? (
@@ -389,9 +391,10 @@ export function CreateTripForm({ isVerifiedOrganizer }: CreateTripFormProps) {
<button
type="button"
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>
)}
</div>
@@ -443,7 +446,11 @@ function Stepper({
: "cursor-not-allowed"
}`}
>
{isCompleted ? "✓" : s.id}
{isCompleted ? (
<Check size={14} strokeWidth={3} aria-hidden />
) : (
s.id
)}
</button>
<span
className={`ml-2 hidden text-xs font-semibold sm:inline ${
@@ -685,6 +692,7 @@ function StepDetail({
whatsExcluded,
imageUrls,
onChange,
onError,
}: {
meetingPoint: string;
itineraryDays: ItineraryDays;
@@ -692,6 +700,7 @@ function StepDetail({
whatsExcluded: string;
imageUrls: string[];
onChange: <K extends keyof FormState>(key: K, value: FormState[K]) => void;
onError: (msg: string) => void;
}) {
return (
<div className="space-y-5">
@@ -764,9 +773,10 @@ function StepDetail({
</div>
</div>
<ImageUrlInput
<TripImageUpload
value={imageUrls}
onChange={(urls) => onChange("imageUrls", urls)}
onError={onError}
/>
</div>
);
@@ -885,40 +895,35 @@ function ItineraryBuilder({
>
<div className="flex flex-col gap-2 sm:flex-row sm:items-start">
<div className="flex shrink-0 gap-2">
<div>
<label className="mb-0.5 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
<div className="w-32">
<label
htmlFor={`itin-${dayIdx}-${itemIdx}-start`}
className="mb-0.5 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
>
Mulai
</label>
<input
type="time"
<TimeField
id={`itin-${dayIdx}-${itemIdx}-start`}
value={item.startTime}
onChange={(e) =>
updateItem(
dayIdx,
itemIdx,
"startTime",
e.target.value
)
onChange={(v) =>
updateItem(dayIdx, itemIdx, "startTime", v)
}
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>
<label className="mb-0.5 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
<div className="w-32">
<label
htmlFor={`itin-${dayIdx}-${itemIdx}-end`}
className="mb-0.5 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
>
Selesai
</label>
<input
type="time"
<TimeField
id={`itin-${dayIdx}-${itemIdx}-end`}
value={item.endTime}
onChange={(e) =>
updateItem(
dayIdx,
itemIdx,
"endTime",
e.target.value
)
onChange={(v) =>
updateItem(dayIdx, itemIdx, "endTime", v)
}
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>
@@ -948,7 +953,7 @@ function ItineraryBuilder({
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"
>
<X size={16} strokeWidth={2} aria-hidden />
</button>
</div>
</li>
@@ -1021,36 +1026,12 @@ function StepSchedule({
<label className="mb-1.5 block text-sm font-semibold text-neutral-700">
Tanggal berangkat pulang
</label>
<div className="relative">
<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}
endDate={endDate}
onChange={(dates) => {
const [s, e] = dates as [Date | null, Date | null];
onDateChange(s, e);
}}
minDate={new Date()}
placeholderText="Pilih tanggal..."
dateFormat="dd MMM yyyy"
isClearable
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 py-2.5 pl-9 pr-3 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-primary-500 focus:bg-white"
/>
</div>
<DateRangeField
startDate={startDate}
endDate={endDate}
onChange={(s, e) => onDateChange(s, e)}
minDate={new Date()}
/>
</div>
<div>
@@ -1062,14 +1043,7 @@ function StepSchedule({
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -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 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>
<Users size={16} strokeWidth={1.75} aria-hidden />
</span>
<input
id="maxParticipants"
@@ -1114,8 +1088,9 @@ function StepSchedule({
/>
</div>
{blockedByVerification && (
<p className="mt-2 text-xs font-medium text-amber-700">
Trip berbayar butuh verifikasi organizer terlebih dahulu.
<p className="mt-2 flex items-center gap-1.5 text-xs font-medium text-amber-700">
<CircleAlert size={14} strokeWidth={2} aria-hidden />
Trip berbayar butuh verifikasi organizer terlebih dahulu.
</p>
)}
</div>
+165 -9
View File
@@ -1,7 +1,8 @@
"use client";
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 {
id: string;
@@ -11,11 +12,48 @@ interface TripImage {
export function ImageGallery({ images }: { images: TripImage[] }) {
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) {
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">
<span className="text-5xl sm:text-6xl">🏔</span>
<div className="flex h-44 items-center justify-center bg-neutral-100 sm:h-56 lg:h-72">
<Mountain
size={56}
strokeWidth={1.5}
aria-hidden
className="text-neutral-300"
/>
</div>
);
}
@@ -24,18 +62,25 @@ export function ImageGallery({ images }: { images: TripImage[] }) {
return (
<div>
{/* Main Image */}
<div className="relative h-44 bg-neutral-900 sm:h-56 lg:h-72">
{/* Main Image — klik untuk lihat ukuran penuh */}
<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
src={activeImage.url}
alt={activeImage.caption || "Foto trip"}
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"
priority
/>
{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">
{activeImage.caption}
</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">
{activeIndex + 1} / {images.length}
</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 */}
{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">
{images.map((img, i) => (
<button
key={img.id}
type="button"
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 ${
i === activeIndex
@@ -71,6 +122,111 @@ export function ImageGallery({ images }: { images: TripImage[] }) {
))}
</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>
);
}
@@ -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{" "}
<span className="font-semibold">terkonfirmasi</span> sebagai peserta
trip ini
{isFree && <span> trip gratis, tidak ada pembayaran 🎉</span>}.
{isFree && <span> trip gratis, tidak ada pembayaran</span>}.
</div>
)}
{needsPayment && (
@@ -1,4 +1,5 @@
import Image from "next/image";
import { BadgeCheck, Star } from "lucide-react";
import type { OrganizerTrust } from "@/server/services/trust.service";
interface OrganizerTrustPanelProps {
@@ -13,7 +14,7 @@ export function OrganizerTrustPanel({
trust,
}: OrganizerTrustPanelProps) {
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">
Organizer & kepercayaan
</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"
title="Identitas organizer telah diverifikasi (KTP & rekening)"
>
Verified Organizer
<BadgeCheck size={12} strokeWidth={2} aria-hidden />
Verified Organizer
</span>
)}
{trust.isTripLeader && (
@@ -54,7 +56,7 @@ export function OrganizerTrustPanel({
</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="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">
Trip selesai
</p>
@@ -67,7 +69,7 @@ export function OrganizerTrustPanel({
</p>
)}
</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">
Peserta dilayani
</p>
@@ -75,12 +77,24 @@ export function OrganizerTrustPanel({
{trust.totalParticipantsServed}
</p>
</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">
Rating organizer
</p>
<p className="text-lg font-bold text-amber-700">
{trust.avgRating != null ? `${trust.avgRating}` : "—"}
<p className="flex items-center gap-1 text-lg font-bold text-amber-700">
{trust.avgRating != null ? (
<>
{trust.avgRating}
<Star
size={15}
strokeWidth={2}
fill="currentColor"
aria-hidden
/>
</>
) : (
"—"
)}
</p>
{trust.reviewCount > 0 && (
<p className="text-[10px] text-neutral-400">
+33 -8
View File
@@ -1,5 +1,12 @@
import Image from "next/image";
import Link from "next/link";
import {
MapPin,
CalendarDays,
UserRound,
BadgeCheck,
Sparkles,
} from "lucide-react";
import { formatRupiah } from "@/lib/utils";
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
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="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 className="flex items-center gap-1.5">
<span className="text-xs text-secondary-500">📅</span>{" "}
{formatTripCalendarDateRangeLong(date, endDate)}
<CalendarDays
size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0 text-neutral-400"
/>
<span>{formatTripCalendarDateRangeLong(date, endDate)}</span>
</div>
<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>
{isVerifiedOrganizer && (
<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)"
>
Verified
<BadgeCheck size={11} strokeWidth={2} aria-hidden />
Verified
</span>
)}
{isSmallGroup && (
@@ -193,10 +217,11 @@ export function TripCard({
)}
{overlapCount > 0 && (
<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"
>
{overlapCount} peserta sama minat
<Sparkles size={11} strokeWidth={2} aria-hidden />
{overlapCount} peserta sama minat
</span>
)}
</div>
+21 -48
View File
@@ -2,9 +2,12 @@
import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
import { formatLocalCalendarYmd } from "@/lib/trip-dates";
import { Search } from "lucide-react";
import { DateRangeField } from "@/components/shared/date-picker";
import {
formatLocalCalendarYmd,
localCalendarDateFromYmd,
} from "@/lib/trip-dates";
import {
ACTIVITY_CATEGORIES,
categoryMeta,
@@ -43,12 +46,14 @@ export function TripFilter() {
isGroupSize(initialGroup) ? initialGroup : null
);
const [query, setQuery] = useState(searchParams.get("q") ?? "");
const [startDate, setStartDate] = useState<Date | null>(
searchParams.get("from") ? new Date(searchParams.get("from")!) : null
);
const [endDate, setEndDate] = useState<Date | null>(
searchParams.get("to") ? new Date(searchParams.get("to")!) : null
);
const [startDate, setStartDate] = useState<Date | null>(() => {
const from = searchParams.get("from");
return from ? localCalendarDateFromYmd(from) : null;
});
const [endDate, setEndDate] = useState<Date | null>(() => {
const to = searchParams.get("to");
return to ? localCalendarDateFromYmd(to) : null;
});
function buildParams(overrides?: {
category?: ActivityCategory | null;
@@ -259,18 +264,7 @@ export function TripFilter() {
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -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="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>
<Search size={16} strokeWidth={1.75} aria-hidden />
</span>
<input
type="text"
@@ -287,33 +281,12 @@ export function TripFilter() {
<label className="mb-1.5 block text-xs font-medium text-neutral-500">
Tanggal
</label>
<div className="relative">
<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}
endDate={endDate}
onChange={handleDateChange}
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>
<DateRangeField
startDate={startDate}
endDate={endDate}
onChange={(start, end) => handleDateChange([start, end])}
minDate={new Date()}
/>
</div>
{/* Buttons */}
@@ -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";
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
.array(
z
.string()
.trim()
.max(LIMITS.MAX_URL_LENGTH, "URL terlalu panjang")
.url("Setiap URL gambar harus valid (http/https)")
.regex(
/^\/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`);
+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 { notifyAdmins } from "@/lib/admin-notify";
/**
* 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));
}
// 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 };
}
}
+328
View File
@@ -0,0 +1,328 @@
import { Resend } from "resend";
import { prisma } from "@/lib/prisma";
import { renderEmail, type EmailTemplate } from "@/lib/email/templates";
/**
* Email sender — idempotent, dengan fallback retry queue.
*
* Flow:
* 1. Cek `EmailSent` by `idempotencyKey`. Kalau sudah terkirim, skip (return).
* 2. Render template → `{ subject, html }`.
* 3. Try sync send via Resend.
* 4. Sukses → insert `EmailSent`.
* 5. Gagal → insert `EmailJob` (cron retry).
*
* Caller pattern: `void emailService.send(...)` (fire-and-forget). Service ini
* tidak throw — semua error di-handle internal supaya server action tidak gagal.
*/
interface SendInput {
to: string;
idempotencyKey: string;
template: EmailTemplate;
}
let _resend: Resend | null = null;
function getResend(): Resend | null {
const apiKey = process.env.RESEND_API_KEY;
if (!apiKey) return null;
if (!_resend) _resend = new Resend(apiKey);
return _resend;
}
function emailFrom(): string {
return process.env.EMAIL_FROM ?? "SeTrip <onboarding@resend.dev>";
}
export const emailService = {
async send(input: SendInput): Promise<void> {
try {
// 1. Idempotency check
const existing = await prisma.emailSent.findUnique({
where: { idempotencyKey: input.idempotencyKey },
select: { id: true },
});
if (existing) return;
// 2. Render
const rendered = renderEmail(input.template);
// 3. Try sync send
const resend = getResend();
if (!resend) {
// Env tidak di-set — enqueue saja supaya tetap ter-log.
await enqueueJob(input, rendered);
console.warn(
"[email] RESEND_API_KEY tidak di-set, email di-queue:",
input.template.template,
input.to
);
return;
}
try {
const result = await resend.emails.send({
from: emailFrom(),
to: input.to,
subject: rendered.subject,
html: rendered.html,
});
if (result.error) {
throw new Error(result.error.message ?? "Resend send failed");
}
// 4. Mark sent
await prisma.emailSent.create({
data: {
idempotencyKey: input.idempotencyKey,
to: input.to,
template: input.template.template,
subject: rendered.subject,
html: rendered.html,
providerMessageId: result.data?.id ?? null,
},
});
} catch (err) {
// 5. Enqueue retry
await enqueueJob(input, rendered);
console.error(
"[email] sync send failed, queued for retry:",
input.template.template,
input.to,
err
);
}
} catch (err) {
// Catch-all — jangan biarkan email error ngerusak action utama.
console.error("[email] unexpected error:", err);
}
},
/**
* Process pending/failed jobs di queue. Dipanggil dari cron handler.
* Max 5 attempts dengan exponential backoff (5min × 2^attempts).
*/
async processQueue(limit = 50): Promise<{
picked: number;
succeeded: number;
failed: number;
}> {
const now = new Date();
const jobs = await prisma.emailJob.findMany({
where: {
status: { in: ["PENDING", "FAILED"] },
attempts: { lt: 5 },
scheduledAt: { lte: now },
},
orderBy: { scheduledAt: "asc" },
take: limit,
});
let succeeded = 0;
let failed = 0;
const resend = getResend();
if (!resend) {
console.warn("[email] processQueue: RESEND_API_KEY tidak di-set, skip");
return { picked: jobs.length, succeeded: 0, failed: 0 };
}
for (const job of jobs) {
// Re-check idempotency — bisa jadi email sudah terkirim oleh sync attempt sejak job di-enqueue.
const alreadySent = await prisma.emailSent.findUnique({
where: { idempotencyKey: job.idempotencyKey },
select: { id: true },
});
if (alreadySent) {
await prisma.emailJob.update({
where: { id: job.id },
data: { status: "SUCCESS", lastAttemptAt: now },
});
succeeded++;
continue;
}
// Mark PROCESSING (best-effort lock)
await prisma.emailJob.update({
where: { id: job.id },
data: { status: "PROCESSING", attempts: job.attempts + 1, lastAttemptAt: now },
});
try {
const result = await resend.emails.send({
from: emailFrom(),
to: job.to,
subject: job.subject,
html: job.html,
});
if (result.error) throw new Error(result.error.message ?? "Resend failed");
await prisma.$transaction([
prisma.emailSent.create({
data: {
idempotencyKey: job.idempotencyKey,
to: job.to,
template: job.template,
subject: job.subject,
html: job.html,
providerMessageId: result.data?.id ?? null,
},
}),
prisma.emailJob.update({
where: { id: job.id },
data: { status: "SUCCESS" },
}),
]);
succeeded++;
} catch (err) {
const nextAttempt = job.attempts + 1;
const backoffMin = Math.min(60, 5 * Math.pow(2, nextAttempt - 1));
await prisma.emailJob.update({
where: { id: job.id },
data: {
status: "FAILED",
lastError: err instanceof Error ? err.message : String(err),
scheduledAt: new Date(now.getTime() + backoffMin * 60 * 1000),
},
});
failed++;
}
}
return { picked: jobs.length, succeeded, failed };
},
/**
* Admin "Retry now" untuk satu EmailJob — kirim ulang langsung tanpa
* menunggu cron. Idempotent: kalau email sudah tercatat terkirim, job
* ditandai SUCCESS tanpa kirim ulang.
*/
async retryJob(jobId: string): Promise<{ ok: boolean; error?: string }> {
const job = await prisma.emailJob.findUnique({ where: { id: jobId } });
if (!job) return { ok: false, error: "Email job tidak ditemukan" };
const alreadySent = await prisma.emailSent.findUnique({
where: { idempotencyKey: job.idempotencyKey },
select: { id: true },
});
if (alreadySent) {
await prisma.emailJob.update({
where: { id: jobId },
data: { status: "SUCCESS", lastAttemptAt: new Date() },
});
return { ok: true };
}
const resend = getResend();
if (!resend) return { ok: false, error: "RESEND_API_KEY belum di-set" };
const now = new Date();
try {
const result = await resend.emails.send({
from: emailFrom(),
to: job.to,
subject: job.subject,
html: job.html,
});
if (result.error) throw new Error(result.error.message ?? "Resend failed");
await prisma.$transaction([
prisma.emailSent.create({
data: {
idempotencyKey: job.idempotencyKey,
to: job.to,
template: job.template,
subject: job.subject,
html: job.html,
providerMessageId: result.data?.id ?? null,
},
}),
prisma.emailJob.update({
where: { id: jobId },
data: {
status: "SUCCESS",
attempts: job.attempts + 1,
lastAttemptAt: now,
},
}),
]);
return { ok: true };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await prisma.emailJob.update({
where: { id: jobId },
data: {
status: "FAILED",
attempts: job.attempts + 1,
lastAttemptAt: now,
lastError: message,
},
});
return { ok: false, error: message };
}
},
/**
* Admin "Resend" untuk EmailSent yang sudah pernah terkirim — mis. user
* lapor tidak menerima. Pakai idempotencyKey turunan supaya tidak bentrok
* dengan email asli. Butuh `html` tersimpan (row lama tidak bisa di-resend).
*/
async resendEmail(
emailSentId: string
): Promise<{ ok: boolean; error?: string }> {
const original = await prisma.emailSent.findUnique({
where: { id: emailSentId },
});
if (!original) return { ok: false, error: "Email tidak ditemukan" };
if (!original.html) {
return {
ok: false,
error:
"Body email lama tidak tersimpan — tidak bisa di-resend (dikirim sebelum fitur ini ada).",
};
}
const resend = getResend();
if (!resend) return { ok: false, error: "RESEND_API_KEY belum di-set" };
try {
const result = await resend.emails.send({
from: emailFrom(),
to: original.to,
subject: original.subject,
html: original.html,
});
if (result.error) throw new Error(result.error.message ?? "Resend failed");
await prisma.emailSent.create({
data: {
idempotencyKey: `${original.idempotencyKey}#resend-${Date.now()}`,
to: original.to,
template: original.template,
subject: original.subject,
html: original.html,
providerMessageId: result.data?.id ?? null,
},
});
return { ok: true };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
},
};
async function enqueueJob(
input: SendInput,
rendered: { subject: string; html: string }
): Promise<void> {
await prisma.emailJob.create({
data: {
idempotencyKey: input.idempotencyKey,
to: input.to,
template: input.template.template,
subject: rendered.subject,
html: rendered.html,
status: "PENDING",
},
});
}
+624
View File
@@ -0,0 +1,624 @@
import { siteUrl } from "@/lib/site";
import { formatRupiah } from "@/lib/utils";
/**
* Registry semua template email. Setiap entry punya:
* - param shape (kontrak data yang harus dipassing caller)
* - render function → `{ subject, html }`
*
* Style: plain HTML inline-style untuk kompatibilitas client email
* (Outlook, Gmail mobile, dst). Hindari CSS modern (flexbox, grid, var).
*/
const PRIMARY = "#0d9488"; // primary-600
const NEUTRAL_700 = "#404040";
const NEUTRAL_500 = "#737373";
const RED_600 = "#dc2626";
const AMBER_600 = "#d97706";
const EMERALD_600 = "#059669";
function shell(opts: { title: string; bodyHtml: string; ctaLabel?: string; ctaUrl?: string }): string {
const cta = opts.ctaLabel && opts.ctaUrl
? `<p style="margin: 24px 0;">
<a href="${opts.ctaUrl}" style="display:inline-block;background:${PRIMARY};color:#ffffff;text-decoration:none;padding:12px 24px;border-radius:8px;font-weight:600;font-size:14px;">${opts.ctaLabel}</a>
</p>`
: "";
return `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>${opts.title}</title></head>
<body style="margin:0;padding:0;background:#f5f5f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:${NEUTRAL_700};">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f5f5f5;padding:24px 0;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:12px;padding:32px;max-width:90%;">
<tr><td>
<p style="margin:0 0 8px;font-size:12px;font-weight:bold;color:${PRIMARY};letter-spacing:1px;">SETRIP</p>
${opts.bodyHtml}
${cta}
<hr style="border:none;border-top:1px solid #e5e5e5;margin:24px 0;">
<p style="margin:0;font-size:11px;color:${NEUTRAL_500};">
Email ini dikirim otomatis dari SeTrip. Kalau ada pertanyaan, balas email ini atau hubungi <a href="mailto:support@setrip.id" style="color:${PRIMARY};">support@setrip.id</a>.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>`;
}
// ============================================================================
// KYC verification
// ============================================================================
export interface KycApprovedData {
userName: string;
}
function kycApproved(d: KycApprovedData) {
return {
subject: "✅ Verifikasi organizer SeTrip kamu sudah disetujui",
html: shell({
title: "KYC Approved",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;color:${EMERALD_600};">🎉 Selamat ${d.userName}!</h1>
<p style="margin:0 0 12px;">Pengajuan verifikasi organizer kamu sudah <strong>disetujui</strong>. Mulai sekarang kamu bisa:</p>
<ul style="margin:0 0 16px;padding-left:20px;">
<li>Membuat trip <strong>berbayar</strong></li>
<li>Menerima payout otomatis ke rekening setelah trip selesai</li>
<li>Mendapat badge ✅ Verified Organizer di profil publik</li>
</ul>
`,
ctaLabel: "Buat Trip Sekarang",
ctaUrl: `${siteUrl}/create-trip`,
}),
};
}
export interface KycRejectedData {
userName: string;
rejectionReason: string;
}
function kycRejected(d: KycRejectedData) {
return {
subject: "❌ Pengajuan verifikasi SeTrip kamu ditolak",
html: shell({
title: "KYC Rejected",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;color:${RED_600};">Pengajuan ditolak</h1>
<p style="margin:0 0 12px;">Halo ${d.userName}, pengajuan verifikasi organizer kamu belum bisa kami setujui.</p>
<div style="background:#fef2f2;border-left:4px solid ${RED_600};padding:12px 16px;margin:16px 0;">
<p style="margin:0;font-weight:600;font-size:13px;">Alasan:</p>
<p style="margin:8px 0 0;font-size:14px;">${escapeHtml(d.rejectionReason)}</p>
</div>
<p style="margin:0 0 12px;">Perbaiki data sesuai catatan di atas lalu ajukan ulang lewat halaman verifikasi.</p>
`,
ctaLabel: "Perbaiki & Ajukan Ulang",
ctaUrl: `${siteUrl}/verify`,
}),
};
}
export interface KycReuploadRequestData {
userName: string;
fields: string[];
note: string;
}
function kycReuploadRequest(d: KycReuploadRequestData) {
const fieldList = d.fields
.map((f) => `<li>${escapeHtml(reuploadFieldLabel(f))}</li>`)
.join("");
return {
subject: "🔄 Admin minta kamu upload ulang data verifikasi",
html: shell({
title: "KYC Re-upload Request",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;color:${AMBER_600};">Perlu upload ulang</h1>
<p style="margin:0 0 12px;">Halo ${d.userName}, admin meminta kamu mengirim ulang beberapa data verifikasi.</p>
<div style="background:#fffbeb;border-left:4px solid ${AMBER_600};padding:12px 16px;margin:16px 0;">
<p style="margin:0;font-weight:600;font-size:13px;">Field yang perlu di-upload ulang:</p>
<ul style="margin:8px 0 0;padding-left:20px;font-size:14px;">${fieldList}</ul>
<p style="margin:12px 0 0;font-weight:600;font-size:13px;">Catatan admin:</p>
<p style="margin:4px 0 0;font-size:14px;">${escapeHtml(d.note)}</p>
</div>
<p style="margin:0 0 12px;">Buka halaman verifikasi, perbaiki field yang diminta, lalu submit ulang. Banner akan otomatis hilang setelah submit.</p>
`,
ctaLabel: "Submit Ulang",
ctaUrl: `${siteUrl}/verify`,
}),
};
}
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;
}
}
// ============================================================================
// Refund
// ============================================================================
export interface RefundCreatedData {
userName: string;
tripTitle: string;
amount: number;
reason: string;
}
function refundCreated(d: RefundCreatedData) {
return {
subject: `Pengajuan refund untuk "${d.tripTitle}" sudah kami terima`,
html: shell({
title: "Refund Created",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;">Laporan refund diterima</h1>
<p style="margin:0 0 12px;">Halo ${d.userName}, kami sudah menerima laporan refund kamu.</p>
<table cellpadding="0" cellspacing="0" style="width:100%;border-collapse:collapse;margin:16px 0;background:#fafafa;border-radius:8px;">
<tr><td style="padding:12px 16px;color:${NEUTRAL_500};font-size:13px;">Trip</td><td style="padding:12px 16px;font-weight:600;">${escapeHtml(d.tripTitle)}</td></tr>
<tr><td style="padding:12px 16px;color:${NEUTRAL_500};font-size:13px;border-top:1px solid #e5e5e5;">Nominal refund</td><td style="padding:12px 16px;font-weight:600;border-top:1px solid #e5e5e5;">${formatRupiah(d.amount)}</td></tr>
<tr><td style="padding:12px 16px;color:${NEUTRAL_500};font-size:13px;border-top:1px solid #e5e5e5;">Reason</td><td style="padding:12px 16px;font-weight:600;border-top:1px solid #e5e5e5;">${escapeHtml(d.reason)}</td></tr>
</table>
<p style="margin:0;font-size:14px;color:${NEUTRAL_500};">Admin akan review dalam 13 hari kerja. Kami kabari lagi via email.</p>
`,
}),
};
}
export interface RefundSucceededData {
userName: string;
tripTitle: string;
amount: number;
adminNote: string;
}
function refundSucceeded(d: RefundSucceededData) {
return {
subject: `✅ Refund ${formatRupiah(d.amount)} sudah dikirim`,
html: shell({
title: "Refund Succeeded",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;color:${EMERALD_600};">Refund sudah dikirim</h1>
<p style="margin:0 0 12px;">Halo ${d.userName}, refund untuk trip <strong>${escapeHtml(d.tripTitle)}</strong> sudah kami transfer.</p>
<p style="margin:0 0 12px;font-size:24px;font-weight:bold;color:${EMERALD_600};">${formatRupiah(d.amount)}</p>
${d.adminNote ? `<div style="background:#f0fdf4;border-left:4px solid ${EMERALD_600};padding:12px 16px;margin:16px 0;"><p style="margin:0;font-weight:600;font-size:13px;">Catatan admin:</p><p style="margin:4px 0 0;font-size:14px;">${escapeHtml(d.adminNote)}</p></div>` : ""}
<p style="margin:0;font-size:14px;">Cek rekening kamu — biasanya masuk dalam 1×24 jam (tergantung bank). Kalau lebih dari 3 hari belum muncul, balas email ini.</p>
`,
}),
};
}
export interface RefundFailedData {
userName: string;
tripTitle: string;
amount: number;
adminNote: string;
}
function refundFailed(d: RefundFailedData) {
return {
subject: `⚠️ Refund untuk "${d.tripTitle}" gagal diproses`,
html: shell({
title: "Refund Failed",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;color:${RED_600};">Refund gagal diproses</h1>
<p style="margin:0 0 12px;">Halo ${d.userName}, refund kamu untuk trip <strong>${escapeHtml(d.tripTitle)}</strong> sebesar <strong>${formatRupiah(d.amount)}</strong> gagal diproses.</p>
<div style="background:#fef2f2;border-left:4px solid ${RED_600};padding:12px 16px;margin:16px 0;">
<p style="margin:0;font-weight:600;font-size:13px;">Catatan admin:</p>
<p style="margin:4px 0 0;font-size:14px;">${escapeHtml(d.adminNote)}</p>
</div>
<p style="margin:0;font-size:14px;">Balas email ini atau hubungi <a href="mailto:support@setrip.id" style="color:${PRIMARY};">support@setrip.id</a> untuk koordinasi lanjutan. Kami akan bantu proses ulang.</p>
`,
}),
};
}
// ============================================================================
// Payment
// ============================================================================
export interface PaymentPaidData {
userName: string;
tripTitle: string;
tripId: string;
amount: number;
}
function paymentPaid(d: PaymentPaidData) {
return {
subject: `✅ Pembayaran ${formatRupiah(d.amount)} untuk "${d.tripTitle}" terkonfirmasi`,
html: shell({
title: "Payment Confirmed",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;color:${EMERALD_600};">Pembayaran terkonfirmasi 🎉</h1>
<p style="margin:0 0 12px;">Halo ${d.userName}, pembayaran kamu untuk trip <strong>${escapeHtml(d.tripTitle)}</strong> sudah masuk.</p>
<p style="margin:0 0 12px;font-size:20px;font-weight:bold;">${formatRupiah(d.amount)}</p>
<p style="margin:0 0 12px;font-size:14px;">Sampai jumpa di trip! Detail meeting point + itinerary lengkap bisa dicek di halaman trip.</p>
`,
ctaLabel: "Lihat Detail Trip",
ctaUrl: `${siteUrl}/trips/${d.tripId}`,
}),
};
}
export interface BookingApprovedData {
userName: string;
tripTitle: string;
tripId: string;
amount: number;
}
function bookingApproved(d: BookingApprovedData) {
const isFree = d.amount <= 0;
return {
subject: isFree
? `✅ Kamu disetujui ikut "${d.tripTitle}"`
: `✅ Kamu disetujui ikut "${d.tripTitle}" — siap bayar`,
html: shell({
title: "Booking Approved",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;color:${EMERALD_600};">Disetujui organizer!</h1>
<p style="margin:0 0 12px;">Halo ${d.userName}, organizer sudah <strong>menyetujui</strong> kamu untuk ikut trip <strong>${escapeHtml(d.tripTitle)}</strong>.</p>
${
isFree
? `<p style="margin:0 0 12px;font-size:14px;">Trip ini <strong>gratis</strong> — tidak perlu bayar. Cek detail meeting point + itinerary di halaman trip.</p>`
: `<p style="margin:0 0 12px;font-size:14px;">Untuk mengamankan slot, selesaikan pembayaran <strong>${formatRupiah(d.amount)}</strong> via Midtrans (BCA VA, GoPay, QRIS, dll). Dana ditahan SeTrip sampai trip selesai (escrow).</p>`
}
`,
ctaLabel: isFree ? "Lihat Detail Trip" : "Bayar Sekarang",
ctaUrl: isFree
? `${siteUrl}/trips/${d.tripId}`
: `${siteUrl}/trips/${d.tripId}/payment`,
}),
};
}
// ============================================================================
// Account moderation
// ============================================================================
export interface AccountSuspendedData {
userName: string;
reason: string;
}
function accountSuspended(d: AccountSuspendedData) {
return {
subject: "⛔ Akun SeTrip kamu ditangguhkan",
html: shell({
title: "Account Suspended",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;color:${RED_600};">Akun ditangguhkan</h1>
<p style="margin:0 0 12px;">Halo ${d.userName}, akun SeTrip kamu sedang dalam status <strong>ditangguhkan</strong>. Selama status ini berlaku, kamu tidak bisa login atau melakukan aksi (join trip, bikin trip, dll).</p>
<div style="background:#fef2f2;border-left:4px solid ${RED_600};padding:12px 16px;margin:16px 0;">
<p style="margin:0;font-weight:600;font-size:13px;">Alasan:</p>
<p style="margin:8px 0 0;font-size:14px;">${escapeHtml(d.reason)}</p>
</div>
<p style="margin:0;font-size:14px;">Kalau menurut kamu ini kesalahan atau ingin klarifikasi, balas email ini atau hubungi <a href="mailto:support@setrip.id" style="color:${PRIMARY};">support@setrip.id</a> dengan subject &quot;Banding suspend akun&quot;.</p>
`,
}),
};
}
// ============================================================================
// PR-E3 — Phase 2: notifikasi event (join, trip dibatalkan, payout, KYC)
// ============================================================================
/** Blok refund untuk email pembatalan trip. Pesan beda kalau tidak ada refund. */
function refundBlock(refundAmount: number): string {
if (refundAmount <= 0) {
return `<p style="margin:0 0 12px;font-size:14px;">Booking kamu untuk trip ini tidak punya pembayaran yang perlu dikembalikan.</p>`;
}
return `<div style="background:#f0fdf4;border-left:4px solid ${EMERALD_600};padding:12px 16px;margin:16px 0;">
<p style="margin:0;font-weight:600;font-size:13px;">Refund kamu sedang diproses</p>
<p style="margin:8px 0 0;font-size:18px;font-weight:bold;color:${EMERALD_600};">${formatRupiah(refundAmount)}</p>
<p style="margin:8px 0 0;font-size:13px;">Admin SeTrip akan memproses transfer ke rekening kamu. Kami kabari lagi via email saat refund selesai.</p>
</div>`;
}
export interface JoinRequestData {
organizerName: string;
joinerName: string;
tripTitle: string;
tripId: string;
}
function joinRequest(d: JoinRequestData) {
return {
subject: `👋 ${d.joinerName} mau gabung trip "${d.tripTitle}"`,
html: shell({
title: "Join Request",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;">Ada yang mau gabung trip kamu</h1>
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.organizerName)}, <strong>${escapeHtml(d.joinerName)}</strong> mengajukan diri untuk ikut trip <strong>${escapeHtml(d.tripTitle)}</strong>.</p>
<p style="margin:0 0 12px;font-size:14px;">Buka halaman trip untuk meninjau profil peserta lalu setujui atau tolak permintaannya.</p>
`,
ctaLabel: "Tinjau Permintaan",
ctaUrl: `${siteUrl}/trips/${d.tripId}`,
}),
};
}
export interface JoinRejectedData {
userName: string;
tripTitle: string;
}
function joinRejected(d: JoinRejectedData) {
return {
subject: `Update permintaan gabung trip "${d.tripTitle}"`,
html: shell({
title: "Join Rejected",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;">Permintaan gabung belum diterima</h1>
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.userName)}, organizer belum bisa menerima permintaan kamu untuk ikut trip <strong>${escapeHtml(d.tripTitle)}</strong> kali ini.</p>
<p style="margin:0 0 12px;font-size:14px;">Jangan berkecil hati — masih banyak trip seru lain yang bisa kamu ikuti.</p>
`,
ctaLabel: "Cari Trip Lain",
ctaUrl: `${siteUrl}/trips`,
}),
};
}
export interface PaymentExpiredData {
userName: string;
tripTitle: string;
tripId: string;
amount: number;
}
function paymentExpired(d: PaymentExpiredData) {
return {
subject: `⏰ Pembayaran trip "${d.tripTitle}" belum selesai`,
html: shell({
title: "Payment Expired",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;color:${AMBER_600};">Pembayaran belum selesai</h1>
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.userName)}, pembayaran kamu untuk trip <strong>${escapeHtml(d.tripTitle)}</strong> sebesar <strong>${formatRupiah(d.amount)}</strong> kadaluarsa atau gagal — slot kamu belum aman.</p>
<p style="margin:0 0 12px;font-size:14px;">Selama trip belum penuh dan belum berangkat, kamu masih bisa mengulang pembayaran.</p>
`,
ctaLabel: "Coba Bayar Lagi",
ctaUrl: `${siteUrl}/trips/${d.tripId}/payment`,
}),
};
}
export interface TripCancelledOrganizerData {
userName: string;
tripTitle: string;
refundAmount: number;
}
function tripCancelledOrganizer(d: TripCancelledOrganizerData) {
return {
subject: `❌ Trip "${d.tripTitle}" dibatalkan`,
html: shell({
title: "Trip Cancelled",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;color:${RED_600};">Trip dibatalkan</h1>
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.userName)}, mohon maaf — organizer membatalkan trip <strong>${escapeHtml(d.tripTitle)}</strong>. Partisipasi kamu di trip ini otomatis dibatalkan.</p>
${refundBlock(d.refundAmount)}
`,
ctaLabel: "Cari Trip Lain",
ctaUrl: `${siteUrl}/trips`,
}),
};
}
export interface TripCancelledAdminData {
userName: string;
tripTitle: string;
reason: string;
refundAmount: number;
}
function tripCancelledAdmin(d: TripCancelledAdminData) {
return {
subject: `❌ Trip "${d.tripTitle}" dibatalkan SeTrip`,
html: shell({
title: "Trip Cancelled by Admin",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;color:${RED_600};">Trip dibatalkan SeTrip</h1>
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.userName)}, trip <strong>${escapeHtml(d.tripTitle)}</strong> dibatalkan oleh tim admin SeTrip.</p>
<div style="background:#fef2f2;border-left:4px solid ${RED_600};padding:12px 16px;margin:16px 0;">
<p style="margin:0;font-weight:600;font-size:13px;">Alasan:</p>
<p style="margin:8px 0 0;font-size:14px;">${escapeHtml(d.reason)}</p>
</div>
${refundBlock(d.refundAmount)}
`,
}),
};
}
export interface PayoutReleasedData {
organizerName: string;
tripTitle: string;
amount: number;
}
function payoutReleased(d: PayoutReleasedData) {
return {
subject: `💰 Payout trip "${d.tripTitle}" masuk antrian transfer`,
html: shell({
title: "Payout Released",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;color:${EMERALD_600};">Payout siap ditransfer</h1>
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.organizerName)}, masa tahan (escrow) untuk trip <strong>${escapeHtml(d.tripTitle)}</strong> sudah berakhir.</p>
<p style="margin:0 0 12px;font-size:24px;font-weight:bold;color:${EMERALD_600};">${formatRupiah(d.amount)}</p>
<p style="margin:0;font-size:14px;">Dana ini masuk antrian transfer — admin SeTrip akan memprosesnya ke rekening kamu. Kami kabari lagi saat sudah ditransfer.</p>
`,
}),
};
}
export interface PayoutPaidData {
organizerName: string;
tripTitle: string;
amount: number;
adminNote: string;
}
function payoutPaid(d: PayoutPaidData) {
return {
subject: `✅ Payout ${formatRupiah(d.amount)} sudah ditransfer`,
html: shell({
title: "Payout Paid",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;color:${EMERALD_600};">Payout sudah ditransfer</h1>
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.organizerName)}, payout untuk trip <strong>${escapeHtml(d.tripTitle)}</strong> sudah kami transfer.</p>
<p style="margin:0 0 12px;font-size:24px;font-weight:bold;color:${EMERALD_600};">${formatRupiah(d.amount)}</p>
${d.adminNote ? `<div style="background:#f0fdf4;border-left:4px solid ${EMERALD_600};padding:12px 16px;margin:16px 0;"><p style="margin:0;font-weight:600;font-size:13px;">Catatan / referensi transfer:</p><p style="margin:4px 0 0;font-size:14px;">${escapeHtml(d.adminNote)}</p></div>` : ""}
<p style="margin:0;font-size:14px;">Cek rekening kamu — biasanya masuk dalam 1×24 jam tergantung bank.</p>
`,
}),
};
}
export interface AccountUnsuspendedData {
userName: string;
}
function accountUnsuspended(d: AccountUnsuspendedData) {
return {
subject: "✅ Akun SeTrip kamu aktif kembali",
html: shell({
title: "Account Unsuspended",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;color:${EMERALD_600};">Akun aktif kembali</h1>
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.userName)}, penangguhan akun SeTrip kamu sudah dicabut. Kamu bisa login dan beraktivitas seperti biasa lagi.</p>
`,
ctaLabel: "Buka SeTrip",
ctaUrl: siteUrl,
}),
};
}
export interface KycSubmittedData {
userName: string;
}
function kycSubmitted(d: KycSubmittedData) {
return {
subject: "📋 Pengajuan verifikasi organizer kamu sudah masuk",
html: shell({
title: "KYC Submitted",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;">Pengajuan diterima</h1>
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.userName)}, pengajuan verifikasi organizer kamu sudah masuk dan sedang antri di-review tim admin.</p>
<p style="margin:0 0 12px;font-size:14px;">Estimasi review <strong>13 hari kerja</strong>. Hasilnya kami kabari lewat email — tidak perlu submit ulang selama belum ada keputusan.</p>
`,
}),
};
}
export interface KycManualOverrideData {
userName: string;
}
function kycManualOverride(d: KycManualOverrideData) {
return {
subject: "✅ Kamu jadi organizer terverifikasi SeTrip",
html: shell({
title: "KYC Manual Override",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;color:${EMERALD_600};">🎉 Selamat ${escapeHtml(d.userName)}!</h1>
<p style="margin:0 0 12px;">Tim admin SeTrip sudah memverifikasi kamu sebagai organizer. Mulai sekarang kamu bisa membuat trip berbayar dan menerima payout.</p>
`,
ctaLabel: "Buat Trip Sekarang",
ctaUrl: `${siteUrl}/create-trip`,
}),
};
}
export interface KycReopenedData {
userName: string;
}
function kycReopened(d: KycReopenedData) {
return {
subject: "🔄 Pengajuan verifikasi kamu dibuka kembali",
html: shell({
title: "KYC Reopened",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;color:${AMBER_600};">Pengajuan dibuka kembali</h1>
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.userName)}, tim admin membuka kembali pengajuan verifikasi organizer kamu. Kamu bisa memperbaiki data lalu mengajukannya ulang.</p>
`,
ctaLabel: "Buka Halaman Verifikasi",
ctaUrl: `${siteUrl}/verify`,
}),
};
}
// ============================================================================
// Registry — discriminated union supaya type-safe per template.
// ============================================================================
export type EmailTemplate =
| { template: "kyc_approved"; data: KycApprovedData }
| { template: "kyc_rejected"; data: KycRejectedData }
| { template: "kyc_reupload_request"; data: KycReuploadRequestData }
| { template: "refund_created"; data: RefundCreatedData }
| { template: "refund_succeeded"; data: RefundSucceededData }
| { template: "refund_failed"; data: RefundFailedData }
| { template: "payment_paid"; data: PaymentPaidData }
| { template: "booking_approved"; data: BookingApprovedData }
| { template: "account_suspended"; data: AccountSuspendedData }
| { template: "join_request"; data: JoinRequestData }
| { template: "join_rejected"; data: JoinRejectedData }
| { template: "payment_expired"; data: PaymentExpiredData }
| { template: "trip_cancelled_organizer"; data: TripCancelledOrganizerData }
| { template: "trip_cancelled_admin"; data: TripCancelledAdminData }
| { template: "payout_released"; data: PayoutReleasedData }
| { template: "payout_paid"; data: PayoutPaidData }
| { template: "account_unsuspended"; data: AccountUnsuspendedData }
| { template: "kyc_submitted"; data: KycSubmittedData }
| { template: "kyc_manual_override"; data: KycManualOverrideData }
| { template: "kyc_reopened"; data: KycReopenedData };
export function renderEmail(input: EmailTemplate): {
subject: string;
html: string;
} {
switch (input.template) {
case "kyc_approved":
return kycApproved(input.data);
case "kyc_rejected":
return kycRejected(input.data);
case "kyc_reupload_request":
return kycReuploadRequest(input.data);
case "refund_created":
return refundCreated(input.data);
case "refund_succeeded":
return refundSucceeded(input.data);
case "refund_failed":
return refundFailed(input.data);
case "payment_paid":
return paymentPaid(input.data);
case "booking_approved":
return bookingApproved(input.data);
case "account_suspended":
return accountSuspended(input.data);
case "join_request":
return joinRequest(input.data);
case "join_rejected":
return joinRejected(input.data);
case "payment_expired":
return paymentExpired(input.data);
case "trip_cancelled_organizer":
return tripCancelledOrganizer(input.data);
case "trip_cancelled_admin":
return tripCancelledAdmin(input.data);
case "payout_released":
return payoutReleased(input.data);
case "payout_paid":
return payoutPaid(input.data);
case "account_unsuspended":
return accountUnsuspended(input.data);
case "kyc_submitted":
return kycSubmitted(input.data);
case "kyc_manual_override":
return kycManualOverride(input.data);
case "kyc_reopened":
return kycReopened(input.data);
}
}
// Escape HTML supaya tidak ada XSS via data user (mis. tripTitle dari user input).
function escapeHtml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
+47
View File
@@ -0,0 +1,47 @@
/**
* Helper transaksi Serializable + retry. Single source of truth supaya semua
* operasi yang rawan race (join/cancel trip, refund, payment) konsisten:
* - Isolation level Serializable → Postgres SSI mendeteksi konflik.
* - Retry otomatis saat serialization failure (Prisma error code `P2034`).
*/
import { Prisma } from "@/app/generated/prisma/client";
import { prisma } from "@/lib/prisma";
const SERIAL_TX_ATTEMPTS = 6;
export function isSerializationConflict(err: unknown): boolean {
return (
typeof err === "object" &&
err !== null &&
"code" in err &&
(err as { code: string }).code === "P2034"
);
}
/**
* Jalankan `fn` dalam transaksi Serializable. Kalau gagal karena konflik
* serialisasi, ulang sampai `SERIAL_TX_ATTEMPTS` kali. Error bisnis (non-P2034)
* langsung dilempar tanpa retry.
*/
export async function runSerializable<T>(
fn: (tx: Prisma.TransactionClient) => Promise<T>,
fallbackMessage = "Operasi sedang ramai. Coba lagi sebentar."
): Promise<T> {
let lastErr: unknown;
for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) {
try {
return await prisma.$transaction(fn, {
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
maxWait: 5000,
timeout: 15000,
});
} catch (e) {
lastErr = e;
if (isSerializationConflict(e) && attempt < SERIAL_TX_ATTEMPTS - 1) {
continue;
}
throw e;
}
}
throw lastErr instanceof Error ? lastErr : new Error(fallbackMessage);
}
+14
View File
@@ -40,6 +40,20 @@ export function formatLocalCalendarYmd(d: Date): string {
return `${y}-${m}-${day}`;
}
/**
* Kebalikan `formatLocalCalendarYmd`: `YYYY-MM-DD` → `Date` di tengah malam
* **lokal** browser. Dipakai untuk seed nilai awal date picker dari query
* string / DB tanpa pergeseran timezone. Return null kalau format tidak valid.
*/
export function localCalendarDateFromYmd(ymd: string): Date | null {
const parts = ymd.trim().slice(0, 10).split("-").map(Number);
const y = parts[0];
const m = parts[1];
const d = parts[2];
if (!y || !m || !d) return null;
return new Date(y, m - 1, d);
}
/**
* Simpan `trip.date` / `trip.endDate`: string `YYYY-MM-DD` diartikan sebagai
* **hari kalender UTC** yang sama (selaras dengan filter Open Trip).
+148
View File
@@ -0,0 +1,148 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import sharp from "sharp";
/**
* Penyimpanan gambar trip — publik, di disk lokal.
*
* Beda dengan `secure-storage.ts` (KYC): gambar trip TIDAK dienkripsi karena
* memang tampil ke semua pengunjung. Tiap gambar dikompres SEKALI saat upload
* (resize + WebP), jadi saat render server tinggal serve file statik kecil —
* tidak ada fetch URL eksternal, tidak ada masalah DNS.
*/
/** Batas ukuran file mentah dari user (sebelum kompresi). */
export const MAX_TRIP_IMAGE_UPLOAD_BYTES = 12 * 1024 * 1024;
/** MIME yang diterima saat upload. Output selalu dikonversi ke WebP. */
export const ALLOWED_TRIP_IMAGE_MIME = new Set([
"image/jpeg",
"image/png",
"image/webp",
]);
/** Prefix URL publik untuk gambar trip yang dikelola sendiri. */
export const TRIP_IMAGE_URL_PREFIX = "/api/trip-images/";
/** Sisi terpanjang hasil kompresi — cukup tajam untuk hero & cukup ringan. */
const MAX_DIMENSION = 1920;
/** Kualitas WebP — 80 = sweet spot kualitas/ukuran. */
const WEBP_QUALITY = 80;
/** Nama file valid di disk: `<32-hex>.webp`. Dipakai untuk cegah path traversal. */
const FILE_NAME_RE = /^[a-f0-9]{32}\.webp$/;
function rootDir(): string {
const fromEnv = process.env.TRIP_UPLOAD_DIR;
if (fromEnv && fromEnv.trim().length > 0) return fromEnv;
return path.join(process.cwd(), "uploads", "trips");
}
export function isValidTripImageName(name: string): boolean {
return FILE_NAME_RE.test(name);
}
/** Resolve nama file ke path absolut di dalam upload dir. Throw kalau mencurigakan. */
function resolveName(name: string): string {
if (!isValidTripImageName(name)) {
throw new Error("Nama file gambar tidak valid");
}
const dir = rootDir();
const abs = path.join(dir, name);
if (!abs.startsWith(dir + path.sep)) {
throw new Error("Nama file keluar dari direktori upload");
}
return abs;
}
export type StoredTripImage = {
/** Nama file di disk, mis. `ab12…ef.webp`. */
name: string;
/** URL publik yang disimpan ke `TripImage.url`. */
url: string;
/** Ukuran file hasil kompresi (byte). */
size: number;
};
/**
* Kompres + simpan satu gambar trip. Input mentah (JPG/PNG/WebP) di-resize agar
* muat dalam {@link MAX_DIMENSION}², dikonversi ke WebP, dan metadata (EXIF/GPS)
* dibuang. Gambar 10MB+ dari kamera HP biasanya menyusut jadi ratusan KB tanpa
* kehilangan kualitas yang terlihat.
*
* `sharp` melempar kalau buffer bukan gambar valid — itu jadi lapis validasi
* konten yang lebih kuat dari sekadar percaya `file.type`.
*/
export async function processAndSaveTripImage(
data: Buffer
): Promise<StoredTripImage> {
if (data.length === 0) throw new Error("File kosong");
if (data.length > MAX_TRIP_IMAGE_UPLOAD_BYTES) {
throw new Error("File terlalu besar");
}
let optimized: Buffer;
try {
optimized = await sharp(data)
// `.rotate()` tanpa argumen menerapkan orientasi dari EXIF lalu membuang
// metadata — foto HP tidak miring & lokasi GPS user tidak ikut tersimpan.
.rotate()
.resize({
width: MAX_DIMENSION,
height: MAX_DIMENSION,
fit: "inside",
withoutEnlargement: true,
})
.webp({ quality: WEBP_QUALITY })
.toBuffer();
} catch {
throw new Error("File bukan gambar yang valid");
}
const name = `${crypto.randomBytes(16).toString("hex")}.webp`;
const abs = resolveName(name);
await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.writeFile(abs, optimized, { mode: 0o644 });
return {
name,
url: `${TRIP_IMAGE_URL_PREFIX}${name}`,
size: optimized.length,
};
}
export async function readTripImage(name: string): Promise<Buffer> {
return fs.readFile(resolveName(name));
}
export async function deleteTripImage(name: string): Promise<void> {
await fs.rm(resolveName(name), { force: true });
}
/** True kalau URL menunjuk gambar trip yang dikelola sendiri (bukan URL eksternal lama). */
export function isManagedTripImageUrl(url: string): boolean {
if (!url.startsWith(TRIP_IMAGE_URL_PREFIX)) return false;
return isValidTripImageName(url.slice(TRIP_IMAGE_URL_PREFIX.length));
}
/** List semua nama file gambar yang ada di disk (untuk cron cleanup). */
export async function listTripImageNames(): Promise<string[]> {
try {
const entries = await fs.readdir(rootDir());
return entries.filter(isValidTripImageName);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
throw err;
}
}
/** mtime file. Null kalau file tidak ada. */
export async function tripImageMtime(name: string): Promise<Date | null> {
try {
const st = await fs.stat(resolveName(name));
return st.mtime;
} catch {
return null;
}
}
+6
View File
@@ -3,6 +3,12 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
dangerouslyAllowSVG: true,
// AVIF didahulukan — ~30% lebih kecil dari WebP, didukung browser modern.
formats: ["image/avif", "image/webp"],
// 75 = default (kartu/thumbnail); 90 = lightbox foto trip ukuran penuh.
qualities: [75, 90],
// Cache hasil optimasi minimal 1 hari supaya tidak re-optimize tiap request.
minimumCacheTTL: 86400,
remotePatterns: [
{
protocol: "https",
+61 -1038
View File
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -1,6 +1,6 @@
{
"name": "setrip",
"version": "0.14.0",
"version": "0.16.12",
"private": true,
"scripts": {
"dev": "next dev",
@@ -20,6 +20,7 @@
"bcryptjs": "^3.0.3",
"clsx": "^2.1.1",
"dayjs": "^1.11.20",
"lucide-react": "^1.16.0",
"next": "^16.2.4",
"next-auth": "^4.24.14",
"pg": "^8.20.0",
@@ -27,6 +28,8 @@
"react": "19.2.4",
"react-datepicker": "^9.1.0",
"react-dom": "19.2.4",
"resend": "^6.12.3",
"sharp": "^0.34.5",
"zod": "^4.3.6"
},
"devDependencies": {
@@ -41,5 +44,10 @@
"tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5"
},
"//overrides": "Patch sub-dependency transitif yang kena advisory npm audit, tanpa men-downgrade framework. Lepas saat versi induknya (next / prisma) sudah membawa versi aman sendiri.",
"overrides": {
"postcss": "^8.5.14",
"@hono/node-server": "^1.19.13"
}
}
@@ -0,0 +1,30 @@
-- AlterTable: tambah dukungan re-upload request, submission history,
-- dan manual override admin untuk OrganizerVerification.
--
-- Phase 2 (re-upload request): admin minta organizer upload ulang field
-- tertentu tanpa drop & recreate row.
ALTER TABLE "OrganizerVerification"
ADD COLUMN "reuploadRequested" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "reuploadFields" TEXT[] DEFAULT ARRAY[]::TEXT[],
ADD COLUMN "reuploadNote" TEXT;
-- Phase 3 (submission history): track jumlah submission + arsip rejection
-- reason setiap kali REJECTED (sebelum di-overwrite saat submit ulang).
-- `previousRejections` = array string `[{"at": "...", "reason": "..."}]`
-- yang di-append manual oleh service saat reject baru terjadi.
ALTER TABLE "OrganizerVerification"
ADD COLUMN "submissionCount" INTEGER NOT NULL DEFAULT 1,
ADD COLUMN "previousRejections" JSONB;
-- Phase 4 (manual override): flag bahwa verifikasi dibuat admin tanpa
-- upload KYC (mis. referral dari partner trusted). Untuk audit transparansi.
ALTER TABLE "OrganizerVerification"
ADD COLUMN "isManualOverride" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "manualOverrideById" TEXT,
ADD COLUMN "manualOverrideNote" TEXT;
-- FK admin yang manual-override — SET NULL kalau admin dihapus.
ALTER TABLE "OrganizerVerification" ADD CONSTRAINT
"OrganizerVerification_manualOverrideById_fkey"
FOREIGN KEY ("manualOverrideById") REFERENCES "User"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,44 @@
-- CreateEnum
CREATE TYPE "EmailJobStatus" AS ENUM ('PENDING', 'PROCESSING', 'SUCCESS', 'FAILED');
-- CreateTable: log append-only setiap email yang berhasil terkirim.
-- `idempotencyKey` UNIQUE cegah double-send saat webhook retry / cron rerun.
CREATE TABLE "EmailSent" (
"id" TEXT NOT NULL,
"idempotencyKey" TEXT NOT NULL,
"to" TEXT NOT NULL,
"template" TEXT NOT NULL,
"subject" TEXT NOT NULL,
"providerMessageId" TEXT,
"sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "EmailSent_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "EmailSent_idempotencyKey_key" ON "EmailSent"("idempotencyKey");
CREATE INDEX "EmailSent_to_sentAt_idx" ON "EmailSent"("to", "sentAt" DESC);
CREATE INDEX "EmailSent_template_sentAt_idx" ON "EmailSent"("template", "sentAt" DESC);
-- CreateTable: retry queue untuk email yang gagal saat sync send.
-- Cron `/api/cron/process-email-jobs` pick PENDING/FAILED (attempts<5),
-- exponential backoff (scheduledAt bumped tiap retry).
CREATE TABLE "EmailJob" (
"id" TEXT NOT NULL,
"idempotencyKey" TEXT NOT NULL,
"to" TEXT NOT NULL,
"template" TEXT NOT NULL,
"subject" TEXT NOT NULL,
"html" TEXT NOT NULL,
"status" "EmailJobStatus" NOT NULL DEFAULT 'PENDING',
"attempts" INTEGER NOT NULL DEFAULT 0,
"scheduledAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastAttemptAt" TIMESTAMP(3),
"lastError" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "EmailJob_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "EmailJob_status_scheduledAt_idx" ON "EmailJob"("status", "scheduledAt");
CREATE INDEX "EmailJob_idempotencyKey_idx" ON "EmailJob"("idempotencyKey");
@@ -0,0 +1,3 @@
-- AlterTable: simpan body HTML email terkirim supaya admin bisa resend
-- (PR-E5) dan investigasi isi email. Nullable — row lama tidak punya body.
ALTER TABLE "EmailSent" ADD COLUMN "html" TEXT;
+68
View File
@@ -38,6 +38,7 @@ model User {
organizerVerification OrganizerVerification? @relation("OrganizerVerificationOwner")
reviewedVerifications OrganizerVerification[] @relation("OrganizerVerificationReviewer")
manualOverrideVerifications OrganizerVerification[] @relation("OrganizerVerificationManualOverride")
reviewedRefunds Refund[] @relation("RefundReviewer")
@@ -135,6 +136,24 @@ model OrganizerVerification {
reviewedBy User? @relation("OrganizerVerificationReviewer", fields: [reviewedById], references: [id])
verifiedAt DateTime?
/// Phase 2: admin minta organizer upload ulang field tertentu. Saat true,
/// organizer page /verify menampilkan banner kuning + highlight field
/// yang diminta. Auto-clear saat organizer submit ulang.
reuploadRequested Boolean @default(false)
reuploadFields String[] @default([])
reuploadNote String?
/// Phase 3: jumlah submission ulang + arsip rejection sebelumnya
/// (di-append saat REJECTED baru, supaya history tidak ke-overwrite).
submissionCount Int @default(1)
previousRejections Json?
/// Phase 4: admin verify manual tanpa upload KYC (mis. partner trusted).
isManualOverride Boolean @default(false)
manualOverrideById String?
manualOverrideBy User? @relation("OrganizerVerificationManualOverride", fields: [manualOverrideById], references: [id], onDelete: SetNull)
manualOverrideNote String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
@@ -448,6 +467,55 @@ model Refund {
@@index([status, createdAt])
}
/// Log append-only setiap email yang berhasil terkirim. `idempotencyKey`
/// UNIQUE cegah double-send saat webhook retry / cron rerun.
model EmailSent {
id String @id @default(cuid())
idempotencyKey String @unique
to String
template String
subject String
/// Body HTML email yang dikirim — disimpan supaya admin bisa resend (PR-E5)
/// & investigasi isi email. Nullable: row lama (sebelum kolom ini ada) tidak
/// punya body sehingga tidak bisa di-resend.
html String?
/// ID dari Resend (atau provider lain) untuk troubleshooting di dashboard mereka.
providerMessageId String?
sentAt DateTime @default(now())
@@index([to, sentAt(sort: Desc)])
@@index([template, sentAt(sort: Desc)])
}
/// Retry queue untuk email yang gagal saat sync send. Cron pick PENDING/FAILED
/// (attempts<5) → retry dengan exponential backoff. Idempotent via `idempotencyKey`.
model EmailJob {
id String @id @default(cuid())
idempotencyKey String
to String
template String
subject String
html String
status EmailJobStatus @default(PENDING)
attempts Int @default(0)
scheduledAt DateTime @default(now())
lastAttemptAt DateTime?
lastError String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, scheduledAt])
@@index([idempotencyKey])
}
enum EmailJobStatus {
PENDING
PROCESSING
SUCCESS
FAILED
}
/// Log polymorphic untuk admin actions lintas entity. Append-only — kalau
/// admin dihapus, `adminId` di-set NULL tapi `adminEmail` snapshot tetap.
/// Dipakai untuk compliance & investigasi (siapa approve/reject/cancel/

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