Compare commits

...

26 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
99 changed files with 6509 additions and 2344 deletions
+2 -1
View File
@@ -10,7 +10,8 @@
"PowerShell(npx prisma generate 2>&1)", "PowerShell(npx prisma generate 2>&1)",
"PowerShell(npx tsc --noEmit 2>&1)", "PowerShell(npx tsc --noEmit 2>&1)",
"PowerShell(npx eslint server/services/refund.service.ts server/repositories/refund.repo.ts features/refund app/admin/refunds 2>&1)", "PowerShell(npx eslint server/services/refund.service.ts server/repositories/refund.repo.ts features/refund app/admin/refunds 2>&1)",
"PowerShell(npx eslint server lib features app 2>&1)" "PowerShell(npx eslint server lib features app 2>&1)",
"Bash(npx eslint *)"
] ]
} }
} }
+15
View File
@@ -12,6 +12,9 @@ KYC_ENCRYPTION_KEY=
KYC_NIK_PEPPER= KYC_NIK_PEPPER=
# Absolute path for private KYC uploads (default: <cwd>/uploads/private) # Absolute path for private KYC uploads (default: <cwd>/uploads/private)
KYC_UPLOAD_DIR= KYC_UPLOAD_DIR=
# Absolute path for public trip image uploads (default: <cwd>/uploads/trips)
# Pakai volume persisten — file di sini harus selamat saat redeploy/restart.
TRIP_UPLOAD_DIR=
GOOGLE_CLIENT_ID="xxxxxxxx" GOOGLE_CLIENT_ID="xxxxxxxx"
GOOGLE_CLIENT_SECRET="xxxxxxxx" GOOGLE_CLIENT_SECRET="xxxxxxxx"
@@ -45,3 +48,15 @@ CRON_SECRET=
# 2. Copy "Webhook URL", paste di sini # 2. Copy "Webhook URL", paste di sini
# Format: https://discord.com/api/webhooks/<id>/<token> # Format: https://discord.com/api/webhooks/<id>/<token>
ADMIN_ALERT_WEBHOOK_URL= ADMIN_ALERT_WEBHOOK_URL=
# === Email notifications (Resend) ===
# API key Resend untuk kirim email transaksional (KYC, refund, payment, suspend).
# Tanpa env, sync send di-skip dan semua email di-queue di DB (status PENDING).
# Setelah env di-set, cron `/api/cron/process-email-jobs` akan drain queue.
# Sign up: https://resend.com → API Keys
RESEND_API_KEY=
# Email sender — format RFC 5322 "Display Name <email@domain>".
# Domain harus diverifikasi di Resend dashboard (SPF + DKIM).
# Default `onboarding@resend.dev` cocok untuk dev/testing.
EMAIL_FROM="SeTrip <onboarding@resend.dev>"
+2 -1
View File
@@ -36,7 +36,8 @@ yarn-error.log*
.env.development .env.development
.env.local .env.local
# private uploads (KYC: KTP / liveness). Never serve directly. # runtime uploads KYC (encrypted, private) & trip images (public, served via
# /api/trip-images). User data, not source: keep out of git, back up separately.
/uploads/ /uploads/
# vercel # vercel
+165
View File
@@ -0,0 +1,165 @@
# Setrip — Email Notifications Roadmap
Status implementasi notifikasi email transaksional ke user & organizer. Pakai pola yang sama dengan refund/admin roadmap: per-phase checklist, idempotent, auditable.
> **Prinsip:**
> - **Transactional only di MVP** — KYC, refund, payment, account moderation. Marketing/reminder belakangan.
> - **Idempotent** — webhook retry / cron rerun tidak boleh double-send. Pakai `idempotencyKey` unique constraint di `EmailSent`.
> - **Non-blocking** — server action utama tidak boleh gagal kalau email gateway down. Pattern: try send sync; kalau gagal, enqueue `EmailJob` untuk retry cron.
> - **Audit-friendly** — semua email tercatat (sent atau queued) supaya admin bisa cek "kenapa user X belum dapat email Y?".
> - **Unsubscribe-aware** — transactional email (refund, payment, suspend) tetap dikirim. Marketing (reminder, social signal) opt-in dengan unsubscribe link.
**Progress (per 2026-05-20):** PR-E1, E2, E3, E5 ✅ — foundation, transactional email, notifikasi event Phase 2, dan admin email log + retry selesai. PR-E4 ⏳ (marketing/reminder) sengaja ditunda — belum dibutuhkan.
---
## Baseline (kondisi sekarang)
- ❌ Tidak ada email service terintegrasi.
- ❌ Tidak ada template engine.
- ❌ User & organizer hanya tahu state via UI — kalau tidak buka app, miss event penting (refund cair, KYC approve, dst).
---
## Provider choice
**Resend** — alasan:
- Free tier 3000 email/bulan + 100/hari (cukup untuk MVP)
- React Email native (kalau mau upgrade dari plain HTML)
- API simple (POST `/emails`)
- DNS setup ringan (SPF + DKIM auto)
Alternatif: AWS SES (paling murah at scale), SendGrid, Mailgun. **Decision:** Resend untuk MVP, evaluate ulang saat lebih dari 50k email/bulan.
**Library dependency:** SKIP — pakai `fetch` ke `https://api.resend.com/emails` directly. Lebih ringkas, satu-satunya dependency-free option.
---
## PR-E1 — Foundation: schema + service + cron (MVP wajib) ✅
**Tujuan:** infrastructure kirim email yang idempotent + retry-able, tanpa nge-block server action.
**Keputusan asumsi:**
- 2 model baru:
- `EmailSent` — append-only log dengan `idempotencyKey @unique`. Cek di sini sebelum kirim → cegah double-send.
- `EmailJob` — retry queue untuk send yang gagal sync. Status `PENDING/PROCESSING/SUCCESS/FAILED`, attempt counter, max 5 retry exponential.
- Service `emailService.send({ to, template, data, idempotencyKey })`:
1. Cek `EmailSent` by idempotencyKey → kalau exist, return early.
2. Render template ke `{ subject, html }`.
3. POST ke Resend.
4. Sukses → insert `EmailSent` row.
5. Gagal → insert `EmailJob` row (status PENDING, attempts=1).
- Cron `/api/cron/process-email-jobs` setiap 5 menit — pick PENDING + FAILED (attempts<5), retry, mark SUCCESS atau bump attempts.
- Caller pattern: `void emailService.send(...)` (fire-and-forget) supaya tidak nge-block server action. Try/catch internal sudah handle error.
| # | Item | Status | File |
|---|---|---|---|
| E1.1 | Model `EmailSent { idempotencyKey @unique, to, template, sentAt, providerMessageId? }` + migration | ✅ | [prisma/schema.prisma](prisma/schema.prisma) |
| E1.2 | Model `EmailJob { idempotencyKey, to, subject, html, status, attempts, lastError?, scheduledAt }` + migration | ✅ | [prisma/schema.prisma](prisma/schema.prisma) |
| E1.3 | Service `lib/email/send.ts` — Resend client (raw fetch) + idempotency + enqueue on failure | ✅ | `lib/email/send.ts` |
| E1.4 | Template registry — function per template return `{ subject, html }` | ✅ | `lib/email/templates.ts` |
| E1.5 | Cron route `/api/cron/process-email-jobs` pakai `runCron` helper | ✅ | `app/api/cron/process-email-jobs/route.ts` |
| E1.6 | Env: `RESEND_API_KEY`, `EMAIL_FROM` (mis. `"SeTrip <no-reply@setrip.id>"`) | ✅ | `.env.example` |
**Tindakan manual ops:**
1. Buat akun Resend, verify domain `setrip.id` (atau pakai `onboarding@resend.dev` di dev).
2. Set DNS SPF + DKIM record di provider domain.
3. Generate API key, set env `RESEND_API_KEY` + `EMAIL_FROM` di production.
4. Daftarkan cron baru di system crontab: `*/5 * * * * curl ... /api/cron/process-email-jobs`.
---
## PR-E2 — Phase 1: Wire transactional emails (MVP wajib) ✅
**Tujuan:** kirim email untuk event paling kritis (KYC, refund, payment, suspend) — yang kalau miss bikin user kehilangan uang atau bingung permanen.
| # | Trigger | Penerima | Template | Wire point | Status |
|---|---|---|---|---|---|
| E2.1 | KYC `VERIFICATION_APPROVE` | User | `kyc_approved` | `reviewVerificationAction` | ✅ |
| E2.2 | KYC `VERIFICATION_REJECT` | User | `kyc_rejected` (sertakan reason) | `reviewVerificationAction` | ✅ |
| E2.3 | KYC re-upload request | User | `kyc_reupload_request` (fields + note) | `requestReuploadAction` | ✅ |
| E2.4 | Refund created (admin atau auto-trigger) | User | `refund_created` (amount + reason) | `createRefundAction` + `tripService.closeTrip` (loop semua peserta PAID) | ✅ |
| E2.5 | Refund SUCCEEDED | User | `refund_succeeded` (amount, cek rekening) | `decideRefundAction` (decision=SUCCEEDED) | ✅ |
| E2.6 | Refund FAILED | User | `refund_failed` (alasan + langkah next) | `decideRefundAction` (decision=FAILED) | ✅ |
| E2.7 | Midtrans webhook PAID | User | `payment_paid` (terima kasih + detail booking) | `paymentService.applyGatewayStatus` (di branch PAID success) | ✅ |
| E2.8 | Booking di-approve organizer (status → AWAITING_PAY) | User | `booking_approved` (link bayar + deadline) | `confirmParticipantAction` | ✅ |
| E2.9 | Account suspend | User | `account_suspended` (reason + email support) | `suspendUserAction` | ✅ |
> ️ **E2.4** — jalur admin (`createRefundAction`) kirim `refund_created`. Untuk auto-refund saat trip dibatalkan, peserta dikabari lewat email `trip_cancelled_organizer` / `trip_cancelled_admin` (E3.4/E3.5) yang sudah memuat blok nominal refund — satu email konsolidasi, bukan dua.
**Format idempotencyKey:**
- `kyc_approved-<verificationId>`
- `refund_succeeded-<refundId>`
- `payment_paid-<paymentId>`
- `booking_approved-<bookingId>`
- `account_suspended-<userId>-<suspendedAt>` (allow re-suspend kalau diulang)
**Tindakan manual ops:**
1. Test setiap template di staging — render via Resend "Send test" atau preview HTML lokal.
2. Pastikan `EMAIL_FROM` domain match SPF/DKIM supaya tidak masuk spam.
---
## PR-E3 — Phase 2: UX enhancement (post-MVP) ✅
Email yang berguna tapi tidak critical kalau miss.
| # | Trigger | Penerima | Template | Wire point | Status |
|---|---|---|---|---|---|
| E3.1 | User join trip (PENDING) | Organizer | `join_request` (siapa, link approve) | `joinTripAction` | ✅ |
| E3.2 | Organizer reject join | User | `join_rejected` (singkat) | `rejectParticipantAction` | ✅ |
| E3.3 | Payment EXPIRED / FAILED | User | `payment_expired` (suruh start ulang) | `paymentService.applyGatewayStatus` | ✅ |
| E3.4 | Trip CLOSED (organizer cancel) | Semua peserta aktif | `trip_cancelled_organizer` (batch) | `tripService.closeTrip` (organizer actor) | ✅ |
| E3.5 | Trip CLOSED (admin force-cancel) | Semua peserta + organizer | `trip_cancelled_admin` (reason) | `tripService.closeTrip` (admin actor) | ✅ |
| E3.6 | Payout RELEASED | Organizer | `payout_released` (ETA transfer dari admin) | `payoutService.releaseEligible` | ✅ |
| E3.7 | Payout PAID | Organizer | `payout_paid` (cek rekening + adminNote) | `markPayoutPaidAction` | ✅ |
| E3.8 | Account unsuspended | User | `account_unsuspended` | `unsuspendUserAction` | ✅ |
| E3.9 | KYC submitted | User | `kyc_submitted` (ETA review) | `submitVerificationAction` | ✅ |
| E3.10 | KYC manual override | User | `kyc_manual_override` (admin verified) | `manualOverrideVerificationAction` | ✅ |
| E3.11 | KYC reopened (admin) | User | `kyc_reopened` (boleh submit ulang) | `reopenVerificationAction` | ✅ |
**Wire point email:** semua dikirim `void emailService.send(...)` (fire-and-forget, idempotent). Untuk batch trip-cancelled, `closeTrip` mengembalikan daftar penerima + nominal refund; email dikirim oleh action setelah transaksi commit (bukan di dalam tx).
---
## PR-E4 — Phase 3: Marketing / reminder (post-MVP, opt-in) ⏳
Email engagement — perlu user preference + unsubscribe link.
| # | Trigger | Penerima | Template | Wire point |
|---|---|---|---|---|
| E4.1 | Welcome email saat signup | User | `welcome` | NextAuth `events.signIn` first time |
| E4.2 | Reminder H-3 keberangkatan | User | `trip_reminder_h3` (meeting point + itinerary) | Cron daily |
| E4.3 | Reminder H-1 keberangkatan | User | `trip_reminder_h1` | Cron daily |
| E4.4 | Trip selesai → minta review (H+1) | User CONFIRMED | `review_prompt` | Cron daily |
| E4.5 | Review baru diterima | Organizer | `new_review` | `createReviewAction` |
| E4.6 | Trip jadi FULL | Organizer | `trip_full` | `tripService.joinTrip` (saat FULL transition) |
**Prerequisite:** tabel `UserEmailPreference` dengan kategori `marketing` / `reminders` + unsubscribe token. Skip sampai Phase 4.
---
## PR-E5 — Admin UI: email log + queue retry (post-MVP) ✅
Visibilitas admin untuk troubleshoot "kenapa user X tidak dapat email?".
| # | Item | Status | File |
|---|---|---|---|
| E5.1 | Page `/admin/emails` — list `EmailSent` + `EmailJob`, filter recipient + template, status lewat tab | ✅ | `app/admin/emails/page.tsx` |
| E5.2 | Tombol "Kirim ulang" untuk EmailJob gagal/antri — retry sync langsung | ✅ | `features/email/components/email-row-actions.tsx` |
| E5.3 | Tombol "Resend" untuk EmailSent — key turunan `#resend-<ts>`, butuh `EmailSent.html` | ✅ | `features/email/actions.ts` |
| E5.4 | Stats card di `/admin/system` + `/admin/emails`: antri, gagal 24 jam, perlu aksi manual | ✅ | `app/admin/system/page.tsx` |
**Tindakan manual ops:**
1. Run migration `20260520000000_add_email_sent_html` (kolom `EmailSent.html`) di staging → production. Tanpa ini, resend (E5.3) tidak tersedia untuk email yang dikirim sebelum migration.
2. Tambahkan `/admin/emails` ke admin nav — sudah dilakukan di `components/admin/admin-sidebar.tsx`.
> ️ Deviasi minor dari rencana awal: filter tanggal tidak diimplementasikan (list dibatasi 100 baris terbaru); filter status diwujudkan sebagai tab (Gagal / Antrian / Terkirim).
---
## Skip / never (eksplisit)
- ❌ SMS / WhatsApp — beda regulatory, beda cost. Stick to email.
- ❌ Push notification (browser/mobile) — perlu PWA setup terpisah.
- ❌ In-app inbox — komplexitas tinggi, low ROI di MVP. Email cukup.
+26 -1
View File
@@ -94,7 +94,7 @@ Alur ini menggambarkan satu peserta dari pertama kali mendaftar sampai pembayara
### 5. Ringkasan peran data ### 5. Ringkasan peran data
| Konsep | Penyimpanan | | Konsep | Penyimpanan |
|--------|-------------| | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Trip | `Trip`: judul, gunung, lokasi, tanggal, kuota, harga, status trip (`OPEN` / `FULL` / …), meeting point, itinerary, termasuk/tidak termasuk, relasi ke organizer | | Trip | `Trip`: judul, gunung, lokasi, tanggal, kuota, harga, status trip (`OPEN` / `FULL` / …), meeting point, itinerary, termasuk/tidak termasuk, relasi ke organizer |
| Peserta | `TripParticipant` unik per `(tripId, userId)`: status **`PENDING`** / **`CONFIRMED`** / **`CANCELLED`**, serta **`markedPaidAt`** & **`paymentConfirmedAt`** untuk alur bayar manual | | Peserta | `TripParticipant` unik per `(tripId, userId)`: status **`PENDING`** / **`CONFIRMED`** / **`CANCELLED`**, serta **`markedPaidAt`** & **`paymentConfirmedAt`** untuk alur bayar manual |
| Organizer (kepercayaan) | `OrganizerVerification` (1-1 ke `User`) berisi KTP, foto liveness (memegang kertas "SETRIP"), rekening, dan status (`PENDING` / `APPROVED` / `REJECTED`); badge **Verified Organizer** muncul ketika `status === "APPROVED"` (helper `lib/trust.ts → isVerifiedOrganizer`). Agregat rating & jumlah trip dihitung dari ulasan & trip. | | Organizer (kepercayaan) | `OrganizerVerification` (1-1 ke `User`) berisi KTP, foto liveness (memegang kertas "SETRIP"), rekening, dan status (`PENDING` / `APPROVED` / `REJECTED`); badge **Verified Organizer** muncul ketika `status === "APPROVED"` (helper `lib/trust.ts → isVerifiedOrganizer`). Agregat rating & jumlah trip dihitung dari ulasan & trip. |
@@ -128,3 +128,28 @@ Buka [http://localhost:3000](http://localhost:3000).
- [Next.js Documentation](https://nextjs.org/docs) - [Next.js Documentation](https://nextjs.org/docs)
- [Prisma Documentation](https://www.prisma.io/docs) - [Prisma Documentation](https://www.prisma.io/docs)
# 1. Install SEMUA dep (termasuk dev) — deterministik dari lockfile.
# --include=dev memaksa dev terpasang walau NODE_ENV=production ter-export.
npm ci --include=dev
# 2. Prisma: generate client + apply migrasi
npx prisma generate
npx prisma migrate deploy
# 3. Build (butuh devDependencies)
npm run build
# 4. (Opsional) ramping-kan node_modules — buang dev SETELAH build selesai
npm prune --omit=dev
# 5. Jalankan
pm2 start ecosystem.config.js --env production
# atau restart: pm2 restart setrip --update-env
+22 -20
View File
@@ -4,6 +4,8 @@ Status implementasi sistem refund yang dapat dipercaya dan auditable — dari sc
> **Prinsip:** refund = financial event, bukan flag boolean. Setiap rupiah yang keluar harus auditable, idempotent, dan punya state machine eksplisit. Untuk MVP: tetap simple, tapi anticipate partial refund + async gateway dari hari pertama. > **Prinsip:** refund = financial event, bukan flag boolean. Setiap rupiah yang keluar harus auditable, idempotent, dan punya state machine eksplisit. Untuk MVP: tetap simple, tapi anticipate partial refund + async gateway dari hari pertama.
**Progress (per 2026-05-20):** PR-R1, R2, R3 ✅ — MVP refund (schema + service, organizer-cancel auto-refund, self-service user cancel) selesai. PR-R4 / R5 / R6 ⏳ post-MVP belum dikerjakan.
--- ---
## Audit state sekarang (baseline) ## Audit state sekarang (baseline)
@@ -34,7 +36,7 @@ File baseline: [prisma/schema.prisma](prisma/schema.prisma), [server/services/bo
--- ---
## PR-R1 — Refund Schema + Service Stub (foundation) ## PR-R1 — Refund Schema + Service Stub (foundation)
Foundation. Bikin entity `Refund` + service stub yang cuma create record `PENDING` (belum panggil gateway). UI admin minimal: list + manual mark succeeded. Tanpa ini, semua PR berikutnya tidak bisa jalan. Foundation. Bikin entity `Refund` + service stub yang cuma create record `PENDING` (belum panggil gateway). UI admin minimal: list + manual mark succeeded. Tanpa ini, semua PR berikutnya tidak bisa jalan.
@@ -48,14 +50,14 @@ Foundation. Bikin entity `Refund` + service stub yang cuma create record `PENDIN
| # | Item | Status | File | | # | Item | Status | File |
|---|---|---|---| |---|---|---|---|
| R1.1 | Model `Refund` + enum `RefundReason`, `RefundStatus`, `RefundInitiator` | | [prisma/schema.prisma](prisma/schema.prisma) | | R1.1 | Model `Refund` + enum `RefundReason`, `RefundStatus`, `RefundInitiator` | | [prisma/schema.prisma](prisma/schema.prisma) |
| R1.2 | Tambah `BookingStatus.PARTIALLY_REFUNDED` | | [prisma/schema.prisma](prisma/schema.prisma) | | R1.2 | Tambah `BookingStatus.PARTIALLY_REFUNDED` | | [prisma/schema.prisma](prisma/schema.prisma) |
| R1.3 | Migration `add_refund_model` | | `prisma/migrations/` | | R1.3 | Migration `add_refund_model` | | `prisma/migrations/` |
| R1.4 | `refundRepo` (create, findById, findByBooking, listPending, updateStatus) | | `server/repositories/refund.repo.ts` | | R1.4 | `refundRepo` (create, findById, findByBooking, listPending, updateStatus) | | `server/repositories/refund.repo.ts` |
| R1.5 | `refundService.requestRefund(input)` — create Refund PENDING + validasi `amount <= payment.amount - totalRefunded` | | `server/services/refund.service.ts` | | R1.5 | `refundService.requestRefund(input)` — create Refund PENDING + validasi `amount <= payment.amount - totalRefunded` | | `server/services/refund.service.ts` |
| R1.6 | `refundService.approveRefund(refundId, adminId)` — PENDING → APPROVED | | `server/services/refund.service.ts` | | R1.6 | `refundService.approveRefund(refundId, adminId)` — PENDING → APPROVED | | `server/services/refund.service.ts` |
| R1.7 | `refundService.markSucceededManual(refundId, adminId)` — APPROVED → SUCCEEDED, update Booking/Payment status. Untuk MVP refund manual transfer (non-Midtrans channel). | | `server/services/refund.service.ts` | | R1.7 | `refundService.markSucceededManual(refundId, adminId)` — APPROVED → SUCCEEDED, update Booking/Payment status. Untuk MVP refund manual transfer (non-Midtrans channel). | | `server/services/refund.service.ts` |
| R1.8 | UI admin `/admin/refunds` — list PENDING + tombol approve & mark-succeeded | | `app/admin/refunds/page.tsx` | | R1.8 | UI admin `/admin/refunds` — list PENDING + tombol approve & mark-succeeded | | `app/admin/refunds/page.tsx` |
**Tindakan manual:** **Tindakan manual:**
1. Run migration di staging → smoke test → run di production. 1. Run migration di staging → smoke test → run di production.
@@ -63,7 +65,7 @@ Foundation. Bikin entity `Refund` + service stub yang cuma create record `PENDIN
--- ---
## PR-R2 — Auto-Trigger Refund saat Trip CLOSED (organizer cancel) ## PR-R2 — Auto-Trigger Refund saat Trip CLOSED (organizer cancel)
Saat organizer batalkan trip (`status = CLOSED`), semua peserta dengan `Booking.status = PAID` otomatis di-create `Refund` record dengan `reason = ORGANIZER_CANCELLED`, `initiatedBy = SYSTEM`, full amount. Saat organizer batalkan trip (`status = CLOSED`), semua peserta dengan `Booking.status = PAID` otomatis di-create `Refund` record dengan `reason = ORGANIZER_CANCELLED`, `initiatedBy = SYSTEM`, full amount.
@@ -75,16 +77,16 @@ Saat organizer batalkan trip (`status = CLOSED`), semua peserta dengan `Booking.
| # | Item | Status | File | | # | Item | Status | File |
|---|---|---|---| |---|---|---|---|
| R2.1 | `tripService.closeTrip(tripId, organizerId)` — set status CLOSED + auto-create refunds | | [server/services/trip.service.ts](server/services/trip.service.ts) | | R2.1 | `tripService.closeTrip(tripId, organizerId)` — set status CLOSED + auto-create refunds | | [server/services/trip.service.ts](server/services/trip.service.ts) |
| R2.2 | Auto-approve untuk SYSTEM refund dengan reason ORGANIZER_CANCELLED | | `server/services/refund.service.ts` | | R2.2 | Auto-approve untuk SYSTEM refund dengan reason ORGANIZER_CANCELLED | | `server/services/refund.service.ts` |
| R2.3 | UI organizer: tombol "Batalkan trip" di dashboard organizer dengan confirmation modal | | `features/trip/components/cancel-trip-button.tsx` | | R2.3 | UI organizer: tombol "Batalkan trip" di dashboard organizer dengan confirmation modal | | `features/trip/components/cancel-trip-button.tsx` |
| R2.4 | Server action `cancelTripAction` | | `features/trip/actions.ts` | | R2.4 | Server action `cancelTripAction` | | `features/trip/actions.ts` |
**Tindakan manual:** tidak ada. **Tindakan manual:** tidak ada.
--- ---
## PR-R3 — Self-Service User Cancel dengan Refund Window ## PR-R3 — Self-Service User Cancel dengan Refund Window
User bisa cancel booking sendiri, refund dihitung berdasarkan kebijakan default (hardcoded dulu, jangan polymorphic). User bisa cancel booking sendiri, refund dihitung berdasarkan kebijakan default (hardcoded dulu, jangan polymorphic).
@@ -100,11 +102,11 @@ User bisa cancel booking sendiri, refund dihitung berdasarkan kebijakan default
| # | Item | Status | File | | # | Item | Status | File |
|---|---|---|---| |---|---|---|---|
| R3.1 | `lib/refund-policy.ts``calculateRefundAmount(bookingAmount, daysUntilDeparture)` | | `lib/refund-policy.ts` | | R3.1 | `lib/refund-policy.ts``calculateRefundAmount(bookingAmount, daysUntilDeparture)` | | `lib/refund-policy.ts` |
| R3.2 | `refundService.requestUserCancellation(bookingId, userId)` — pakai policy + create Refund PENDING | | `server/services/refund.service.ts` | | R3.2 | `refundService.requestUserCancellation(bookingId, userId)` — pakai policy + create Refund PENDING | | `server/services/refund.service.ts` |
| R3.3 | UI user di trip detail: tombol "Cancel booking" + modal preview refund amount | | `features/booking/components/cancel-booking-button.tsx` | | R3.3 | UI user di trip detail: tombol "Cancel booking" + modal preview refund amount | | `features/booking/components/cancel-booking-button.tsx` |
| R3.4 | Server action `cancelBookingAction` | | `features/booking/actions.ts` | | R3.4 | Server action `cancelBookingAction` | | `features/booking/actions.ts` |
| R3.5 | Display refund policy di trip detail (di blok info atau accordion) — transparency | | [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/page.tsx) | | R3.5 | Display refund policy di trip detail (di blok info atau accordion) — transparency | | [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/page.tsx) |
**Tindakan manual:** **Tindakan manual:**
1. Tulis copy kebijakan refund untuk halaman Terms & Privacy. 1. Tulis copy kebijakan refund untuk halaman Terms & Privacy.
+198
View File
@@ -0,0 +1,198 @@
# 🎨 SeTrip — UI Style Guide
Panduan visual untuk membuat tampilan SeTrip terasa **natural, manusiawi, dan tidak "AI-generated"** — tanpa mengorbankan SEO.
> Prinsip utama: **clean, calm, earthy.** SeTrip itu social-companion platform ("pergi bareng, bukan sendiri"), bukan marketplace booking. UI harus terasa hangat & tenang, bukan ramai & promosi.
---
## 1. Filosofi Desain
| Hindari (kesan AI-generated) | Gunakan (kesan natural) |
| --- | --- |
| ❌ Gradient berlebihan | ✅ Background putih bersih / `neutral-50` |
| ❌ Neumorphism | ✅ Soft green / earthy tone |
| ❌ Glassmorphism ekstrem | ✅ Border tipis 1px + shadow lembut |
| ❌ Icon 3D / emoji sebagai UI icon | ✅ Stroke icon tipis (lucide-react) |
| ❌ Card mengambang dengan blur tebal | ✅ Simple rounded card, datar, jelas |
| ❌ Warna saturasi tinggi di mana-mana | ✅ 1 warna aksen, sisanya netral |
**Tiga kata kunci:** *bersih · tenang · jujur.* Kalau sebuah elemen terasa "ingin pamer", kemungkinan besar perlu disederhanakan.
---
## 2. Warna
Token warna sudah tersedia di [app/globals.css](app/globals.css) — **gunakan token, jangan hardcode hex.**
| Peran | Token | Catatan |
| --- | --- | --- |
| Aksi utama / brand | `primary-600` (#16A34A) | Hijau gunung — earthy, tidak neon |
| Hover aksi utama | `primary-700` | Hindari `primary-500` (terlalu terang) untuk hover tombol |
| Aksen sekunder | `secondary-600` (#0EA5E9) | Pakai hemat — info, link, badge vibe |
| Teks utama | `neutral-800` | |
| Teks sekunder | `neutral-500` | |
| Border | `neutral-200` | Selalu 1px |
| Background halaman | `neutral-50` | |
| Surface / card | `white` | |
### Aturan warna
- **Satu aksen per layar.** Hijau adalah bintangnya. Biru hanya bumbu.
- **Maksimal 1 area gradient per halaman**, dan harus halus (mis. hero). Sisanya warna solid.
- Surface = putih solid. Jangan pakai `bg-white/80 + backdrop-blur` untuk card biasa.
- Earthy tone tambahan diperbolehkan sebagai background section (`primary-50`, `amber-50`) tapi jangan dijadikan blok besar warna-warni.
---
## 3. Sistem Ikon — lucide-react
`lucide-react` sudah terpasang. **Stroke icon = wajah baru SeTrip.**
### Aturan ikon
- **Stroke icon, bukan filled.** Lucide default sudah stroke — jangan ganti `fill`.
- Ukuran konsisten: `16` (inline teks), `20` (tombol/list), `24` (header section).
- Ketebalan stroke seragam: `strokeWidth={1.75}` (default lucide `2` sedikit terlalu tebal untuk gaya clean ini).
- Warna ikut teks: `text-neutral-500` untuk netral, `text-primary-600` untuk aktif.
- **Jangan** beri ikon background bulat berwarna + emoji di dalamnya (pola lama). Cukup ikon polos, atau ikon di atas lingkaran `neutral-100` yang sangat soft bila perlu penekanan.
```tsx
import { Mountain } from "lucide-react";
// inline
<Mountain size={16} strokeWidth={1.75} className="text-neutral-500" />
// di tombol
<Plus size={20} strokeWidth={1.75} />
```
### Pemetaan ikon per fitur
| Fitur | Ikon lucide |
| --- | --- |
| Trip | `Mountain` |
| Group / peserta | `Users` |
| Organizer | `BadgeCheck` |
| Verified | `ShieldCheck` |
| Payment | `Wallet` |
| Meeting Point | `MapPinned` |
| Chat | `MessageCircle` |
| Review / rating | `Star` |
| Profil | `UserRound` |
Saran tambahan yang konsisten dengan set di atas:
| Konteks | Ikon lucide |
| --- | --- |
| Tanggal / jadwal | `CalendarDays` |
| Lokasi umum | `MapPin` |
| Buat trip (FAB & CTA) | `Plus` |
| Cari / filter | `Search`, `SlidersHorizontal` |
| Menu mobile | `Menu` / `X` |
| Kategori (jelajah) | `Compass` |
| Sedang ramai / populer | `Flame` atau `TrendingUp` |
| Harga | `Tag` |
> **Catatan emoji kategori:** `categoryMeta()` di [lib/activity-category.ts](lib/activity-category.ts) masih memakai emoji (🏔️🏕️🤿). Boleh dipertahankan **hanya** di konten data trip (terasa playful & manusiawi di tempat itu), tapi **elemen UI/chrome** (navbar, header section, tombol, badge status) harus pakai stroke icon.
---
## 4. Komponen
### Card
```
✅ rounded-2xl · border border-neutral-200 · bg-white
✅ hover: shadow lembut + translate-y-0.5 (sudah dipakai di TripCard — pertahankan)
❌ jangan: shadow tebal default, blur, gradient border
```
### Tombol
| Jenis | Style |
| --- | --- |
| Primer | `bg-primary-600 hover:bg-primary-700 text-white rounded-xl` |
| Sekunder | `border border-neutral-200 text-neutral-700 hover:bg-neutral-50` |
| Ghost | `text-neutral-600 hover:bg-neutral-100` |
- Shadow tombol seperlunya. `shadow-lg shadow-primary-600/25` boleh untuk **satu** CTA utama per layar, jangan semua tombol.
- `hover:scale-105` cukup untuk CTA hero saja — jangan di semua tombol (terasa "demo template").
- Sertakan ikon lucide bila memperjelas aksi (mis. `Plus` untuk "Buat Trip").
### Badge / pill
- `rounded-full`, teks kecil, warna soft (`primary-50`/`primary-700`).
- Status pakai warna semantik solid lembut, bukan transparan + blur.
### Header section
Pola lama: kotak berwarna + emoji. Pola baru:
```tsx
<div className="flex items-center gap-2.5">
<Compass size={20} strokeWidth={1.75} className="text-primary-600" />
<div>
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">Jelajah per Kategori</h2>
<p className="text-xs text-neutral-500">Hiking, diving, konser, sampai retreat</p>
</div>
</div>
```
---
## 5. Yang Perlu Dirombak di Codebase
Temuan konkret dari kode saat ini:
| Lokasi | Masalah | Aksi |
| --- | --- | --- |
| [app/(public)/page.tsx](app/(public)/page.tsx) | Header section pakai kotak warna + emoji (✨🔥🏔️🤝), badge hero pakai emoji 🤝 | Ganti ke stroke icon (`Compass`, `Flame`, `Mountain`, `Users`) |
| [app/(public)/page.tsx](app/(public)/page.tsx#L110) | Hero gradient 3 warna (`from-primary-900 via-neutral-900 to-secondary-900`) | Sederhanakan jadi overlay solid `neutral-900/80` atau gradient 2 warna halus |
| [app/(public)/page.tsx](app/(public)/page.tsx#L386) | FAB pakai teks `"+"` | Ganti `<Plus size={24} />` |
| [app/(public)/page.tsx](app/(public)/page.tsx#L153) | Stat "100% Seru" terasa filler/AI | Ganti metrik nyata (jumlah peserta, organizer terverifikasi) atau hapus |
| [components/shared/navbar.tsx](components/shared/navbar.tsx#L112) | Hamburger pakai inline SVG manual | Ganti `Menu` / `X` dari lucide |
| [components/shared/navbar.tsx](components/shared/navbar.tsx#L13) | `bg-white/90 backdrop-blur-md` | Boleh dipertahankan (tipis, wajar untuk sticky nav) — jangan ditebalkan |
| [features/trip/components/trip-card.tsx](features/trip/components/trip-card.tsx) | Avatar fallback & meta info bisa diperkuat dengan ikon stroke (`Users`, `CalendarDays`, `MapPin`) | Tambah ikon kecil di baris meta |
Prioritas: **homepage dulu** (paling sering dilihat & paling kuat kesan AI-nya), lalu navbar, lalu komponen kartu.
---
## 6. SEO — Wajib Dijaga
Perubahan visual **tidak boleh** menurunkan SEO. Aturan:
- **Ikon lucide = inline SVG**, ringan & tidak memblokir render. Aman untuk Core Web Vitals.
- **Ikon dekoratif** (hiasan di samping teks) harus `aria-hidden`. Lucide perlu di-set manual:
```tsx
<Mountain size={16} aria-hidden className="text-neutral-500" />
```
- **Ikon yang berdiri sendiri sebagai tombol** (mis. tombol menu) wajib punya label:
```tsx
<button aria-label="Buka menu"><Menu size={20} aria-hidden /></button>
```
- **Jangan ubah teks jadi gambar.** Heading, slogan, deskripsi harus tetap teks HTML.
- **Pertahankan hirarki heading:** satu `<h1>` per halaman, `<h2>` untuk section. Jangan turunkan jadi `<div>` saat merapikan visual.
- **Pertahankan metadata & JSON-LD** di [app/layout.tsx](app/layout.tsx) dan [app/(public)/page.tsx](app/(public)/page.tsx) — structured data, OpenGraph, canonical jangan disentuh saat refactor UI.
- **Komponen tetap Server Component** kalau memungkinkan. Jangan tambah `"use client"` cuma untuk render ikon — lucide jalan di server.
- **Gambar:** terus pakai `next/image` dengan `alt` deskriptif dan `priority` untuk LCP (cover hero & kartu pertama).
- **Kontras warna** minimal AA: stroke icon `neutral-500` di atas putih sudah memenuhi; jangan pakai `neutral-300` untuk ikon/teks penting.
---
## 7. Checklist Implementasi
- [ ] Ganti semua emoji di chrome UI (navbar, header section, tombol, FAB) → stroke icon lucide
- [ ] Standarkan `size` (16/20/24) & `strokeWidth={1.75}` di seluruh ikon
- [ ] Sederhanakan gradient hero homepage jadi maksimal 2 warna / overlay solid
- [ ] Ganti hamburger SVG manual di navbar → `Menu`/`X`
- [ ] Tinjau metrik "100% Seru" — ganti angka nyata atau hapus
- [ ] Pastikan ikon dekoratif `aria-hidden`, ikon-tombol punya `aria-label`
- [ ] Pastikan struktur heading `h1`/`h2` tetap utuh setelah refactor
- [ ] Jalankan Lighthouse — skor SEO & Accessibility tidak turun
- [ ] Verifikasi tidak ada `"use client"` baru yang ditambahkan hanya demi ikon
---
*Acuan token: [app/globals.css](app/globals.css) · Acuan brand: [lib/site.ts](lib/site.ts)*
+14 -6
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { Lock, Clock, CircleAlert } from "lucide-react";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { organizerService } from "@/server/services/organizer.service"; import { organizerService } from "@/server/services/organizer.service";
@@ -11,8 +12,13 @@ export default async function CreateTripPage() {
return ( return (
<div className="flex min-h-[calc(100vh-3.5rem)] items-center justify-center px-4"> <div className="flex min-h-[calc(100vh-3.5rem)] items-center justify-center px-4">
<div className="text-center"> <div className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary-50 text-3xl"> <div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary-50">
🔒 <Lock
size={28}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
</div> </div>
<p className="mb-4 text-neutral-500"> <p className="mb-4 text-neutral-500">
Kamu harus login untuk membuat trip. Kamu harus login untuk membuat trip.
@@ -57,8 +63,9 @@ function VerificationBanner({
if (status === "PENDING") { if (status === "PENDING") {
return ( return (
<div className="mb-5 rounded-2xl border border-amber-200 bg-amber-50 p-4 sm:p-5"> <div className="mb-5 rounded-2xl border border-amber-200 bg-amber-50 p-4 sm:p-5">
<p className="text-sm font-bold text-amber-800"> <p className="flex items-center gap-1.5 text-sm font-bold text-amber-800">
Verifikasi sedang diproses <Clock size={15} strokeWidth={2} aria-hidden />
Verifikasi sedang diproses
</p> </p>
<p className="mt-1 text-sm text-neutral-700"> <p className="mt-1 text-sm text-neutral-700">
Pengajuan verifikasi-mu masih ditinjau admin. Sementara menunggu, kamu Pengajuan verifikasi-mu masih ditinjau admin. Sementara menunggu, kamu
@@ -73,8 +80,9 @@ function VerificationBanner({
<div className="mb-5 rounded-2xl border border-amber-200 bg-amber-50 p-4 sm:p-5"> <div className="mb-5 rounded-2xl border border-amber-200 bg-amber-50 p-4 sm:p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex-1"> <div className="flex-1">
<p className="text-sm font-bold text-amber-800"> <p className="flex items-center gap-1.5 text-sm font-bold text-amber-800">
{isRejected ? "Verifikasi ditolak" : "Belum terverifikasi"} <CircleAlert size={15} strokeWidth={2} aria-hidden />
{isRejected ? "Verifikasi ditolak" : "Belum terverifikasi"}
</p> </p>
<p className="mt-1 text-sm text-neutral-700"> <p className="mt-1 text-sm text-neutral-700">
{isRejected {isRejected
+17
View File
@@ -0,0 +1,17 @@
/**
* Skeleton generik untuk route group `(public)` — fallback streaming bagi
* halaman yang tidak punya `loading.tsx` sendiri (beranda, profil, dll).
*/
export default function Loading() {
return (
<div className="mx-auto max-w-5xl px-4 py-10">
<div className="h-8 w-1/2 animate-pulse rounded-xl bg-neutral-200" />
<div className="mt-4 space-y-3">
<div className="h-4 w-full animate-pulse rounded bg-neutral-100" />
<div className="h-4 w-5/6 animate-pulse rounded bg-neutral-100" />
<div className="h-4 w-2/3 animate-pulse rounded bg-neutral-100" />
</div>
<div className="mt-8 h-64 animate-pulse rounded-2xl bg-neutral-100" />
</div>
);
}
+13 -1
View File
@@ -1,4 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Masuk", title: "Masuk",
@@ -8,6 +11,15 @@ export const metadata: Metadata = {
robots: { index: false, follow: true }, robots: { index: false, follow: true },
}; };
export default function LoginLayout({ children }: { children: React.ReactNode }) { export default async function LoginLayout({
children,
}: {
children: React.ReactNode;
}) {
// User yang sudah login tidak boleh mengakses halaman login lagi.
const session = await getServerSession(authOptions);
if (session?.user) {
redirect(session.user.isAdmin ? "/admin" : "/");
}
return children; return children;
} }
+10 -7
View File
@@ -38,13 +38,16 @@ function LoginForm() {
if (result?.error) { if (result?.error) {
setError(result.error); setError(result.error);
} else { } else {
const rawCallback = searchParams.get("callbackUrl"); const callbackPath = safeInternalPath(searchParams.get("callbackUrl"));
let next = safeInternalPath(rawCallback);
// Tanpa callbackUrl eksplisit, arahkan admin ke dashboard /admin.
if (!rawCallback) {
const session = await getSession(); const session = await getSession();
if (session?.user?.isAdmin) next = "/admin"; // Admin selalu diarahkan ke dashboard /admin setelah login — kecuali
} // callbackUrl memang menuju sub-halaman admin (deep link dari /admin/...).
// callbackUrl non-admin (mis. "/" sisa dari percobaan login Google) tidak
// boleh membuat admin "nyangkut" di halaman publik.
const next =
session?.user?.isAdmin && !callbackPath.startsWith("/admin")
? "/admin"
: callbackPath;
router.push(next); router.push(next);
router.refresh(); router.refresh();
} }
@@ -84,7 +87,7 @@ function LoginForm() {
</div> </div>
{/* Card */} {/* Card */}
<div className="rounded-2xl border border-white/10 bg-white/95 p-6 shadow-2xl backdrop-blur-sm"> <div className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-2xl">
{error && ( {error && (
<div className="mb-4 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600"> <div className="mb-4 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600">
{error} {error}
+95 -64
View File
@@ -8,6 +8,15 @@ import { profileRepo } from "@/server/repositories/profile.repo";
import { TripCard } from "@/features/trip/components/trip-card"; import { TripCard } from "@/features/trip/components/trip-card";
import { siteConfig, siteUrl, absoluteUrl } from "@/lib/site"; import { siteConfig, siteUrl, absoluteUrl } from "@/lib/site";
import { ACTIVITY_CATEGORIES, categoryMeta } from "@/lib/activity-category"; import { ACTIVITY_CATEGORIES, categoryMeta } from "@/lib/activity-category";
import {
Compass,
Flame,
Mountain,
Handshake,
Tent,
Plus,
type LucideIcon,
} from "lucide-react";
type OpenTrip = Awaited<ReturnType<typeof tripService.getOpenTrips>>[number]; type OpenTrip = Awaited<ReturnType<typeof tripService.getOpenTrips>>[number];
@@ -44,6 +53,9 @@ export default async function HomePage() {
const now = new Date(); const now = new Date();
const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
// Social proof: total orang yang sudah gabung di seluruh open trip.
const joinerCount = trips.reduce((sum, t) => sum + t._count.participants, 0);
const upcomingTrips = trips const upcomingTrips = trips
.filter((t) => new Date(t.date) <= nextWeek) .filter((t) => new Date(t.date) <= nextWeek)
.slice(0, 3); .slice(0, 3);
@@ -107,12 +119,17 @@ export default async function HomePage() {
className="object-cover opacity-10 brightness-150" className="object-cover opacity-10 brightness-150"
priority priority
/> />
<div className="absolute inset-0 bg-linear-to-br from-primary-900/85 via-neutral-900/75 to-secondary-900/80" /> <div className="absolute inset-0 bg-linear-to-br from-neutral-900/90 to-primary-900/80" />
<div className="relative mx-auto max-w-4xl px-4 pb-10 pt-8 text-center sm:pb-14 sm:pt-12 lg:pb-16 lg:pt-14"> <div className="relative mx-auto max-w-4xl px-4 pb-10 pt-8 text-center sm:pb-14 sm:pt-12 lg:pb-16 lg:pt-14">
{/* Brand badge */} {/* Brand badge */}
<div className="mb-4 inline-flex items-center gap-1.5 rounded-full border border-primary-400/30 bg-primary-600/20 px-3 py-1 sm:mb-6 sm:gap-2 sm:px-4 sm:py-1.5"> <div className="mb-4 inline-flex items-center gap-1.5 rounded-full border border-primary-400/30 bg-primary-600/20 px-3 py-1 sm:mb-6 sm:gap-2 sm:px-4 sm:py-1.5">
<span className="text-xs sm:text-sm">🤝</span> <Handshake
size={14}
strokeWidth={1.75}
aria-hidden
className="text-primary-300"
/>
<span className="text-xs font-medium text-primary-300 sm:text-sm"> <span className="text-xs font-medium text-primary-300 sm:text-sm">
Cari teman trip & aktivitas Cari teman trip & aktivitas
</span> </span>
@@ -150,8 +167,12 @@ export default async function HomePage() {
</div> </div>
<div className="h-8 w-px bg-neutral-700 sm:h-10" /> <div className="h-8 w-px bg-neutral-700 sm:h-10" />
<div> <div>
<p className="text-xl font-bold text-white sm:text-2xl">100%</p> <p className="text-xl font-bold text-white sm:text-2xl">
<p className="text-[11px] text-neutral-400 sm:text-xs">Seru</p> {joinerCount}
</p>
<p className="text-[11px] text-neutral-400 sm:text-xs">
Sudah Gabung
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -161,19 +182,11 @@ export default async function HomePage() {
<div className="mx-auto max-w-6xl px-4 py-6 space-y-8 sm:py-8 sm:space-y-10 lg:py-10 lg:space-y-12"> <div className="mx-auto max-w-6xl px-4 py-6 space-y-8 sm:py-8 sm:space-y-10 lg:py-10 lg:space-y-12">
{/* Jelajah per kategori */} {/* Jelajah per kategori */}
<section> <section>
<div className="mb-4 flex items-center gap-3 sm:mb-5"> <SectionHeading
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-secondary-100 text-base sm:h-9 sm:w-9 sm:text-lg"> icon={Compass}
title="Jelajah per Kategori"
</div> subtitle="Hiking, diving, konser, sampai retreat"
<div> />
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
Jelajah per Kategori
</h2>
<p className="text-[11px] text-neutral-500 sm:text-xs">
Hiking, diving, konser, sampai retreat
</p>
</div>
</div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{ACTIVITY_CATEGORIES.map((c) => { {ACTIVITY_CATEGORIES.map((c) => {
const m = categoryMeta(c); const m = categoryMeta(c);
@@ -194,19 +207,11 @@ export default async function HomePage() {
{/* Trip Terdekat */} {/* Trip Terdekat */}
{upcomingTrips.length > 0 && ( {upcomingTrips.length > 0 && (
<section> <section>
<div className="mb-4 flex items-center gap-3 sm:mb-5"> <SectionHeading
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-100 text-base sm:h-9 sm:w-9 sm:text-lg"> icon={Flame}
🔥 title="Trip Terdekat"
</div> subtitle="Berangkat dalam 7 hari ke depan"
<div> />
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
Trip Terdekat
</h2>
<p className="text-[11px] text-neutral-500 sm:text-xs">
Berangkat dalam 7 hari ke depan
</p>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{upcomingTrips.slice(0, 3).map((trip, i) => ( {upcomingTrips.slice(0, 3).map((trip, i) => (
<TripCard <TripCard
@@ -239,32 +244,29 @@ export default async function HomePage() {
{/* Open Trip */} {/* Open Trip */}
<section> <section>
<div className="mb-4 flex items-center justify-between sm:mb-5"> <SectionHeading
<div className="flex items-center gap-3"> icon={Mountain}
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-secondary-100 text-base sm:h-9 sm:w-9 sm:text-lg"> title="Open Trip"
🏔 subtitle="Pilih trip, ketemu teman baru"
</div> action={
<div>
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
Open Trip
</h2>
<p className="hidden text-xs text-neutral-500 sm:block">
Pilih trip, ketemu teman baru
</p>
</div>
</div>
<Link <Link
href="/trips" href="/trips"
className="rounded-lg bg-secondary-50 px-2.5 py-1 text-xs font-medium text-secondary-600 hover:bg-secondary-100 sm:px-3 sm:py-1.5 sm:text-sm" className="shrink-0 rounded-lg bg-secondary-50 px-2.5 py-1 text-xs font-medium text-secondary-600 hover:bg-secondary-100 sm:px-3 sm:py-1.5 sm:text-sm"
> >
Lihat semua Lihat semua
</Link> </Link>
</div> }
/>
{latestTrips.length === 0 ? ( {latestTrips.length === 0 ? (
<div className="rounded-2xl border-2 border-dashed border-neutral-200 bg-white p-8 text-center sm:p-14"> <div className="rounded-2xl border-2 border-dashed border-neutral-200 bg-white p-8 text-center sm:p-14">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 text-2xl sm:h-16 sm:w-16 sm:text-3xl"> <div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 sm:h-16 sm:w-16">
🏕 <Tent
size={26}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
</div> </div>
<p className="mb-1 text-base font-bold text-neutral-800 sm:text-lg"> <p className="mb-1 text-base font-bold text-neutral-800 sm:text-lg">
Belum ada trip tersedia Belum ada trip tersedia
@@ -312,19 +314,11 @@ export default async function HomePage() {
{/* Lagi Ramai — social proof, bukan price proof */} {/* Lagi Ramai — social proof, bukan price proof */}
{buzzingTrips.length > 0 && ( {buzzingTrips.length > 0 && (
<section> <section>
<div className="mb-4 flex items-center gap-3 sm:mb-5"> <SectionHeading
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-100 text-base sm:h-9 sm:w-9 sm:text-lg"> icon={Handshake}
🤝 title="Lagi Ramai"
</div> subtitle="Banyak yang sudah gabung — kamu nggak bakal jalan sendirian"
<div> />
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
Lagi Ramai
</h2>
<p className="text-[11px] text-neutral-500 sm:text-xs">
Banyak yang sudah gabung kamu nggak bakal jalan sendirian
</p>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{buzzingTrips.map((trip) => ( {buzzingTrips.map((trip) => (
<TripCard <TripCard
@@ -383,11 +377,48 @@ export default async function HomePage() {
{/* ========== FAB ========== */} {/* ========== FAB ========== */}
<Link <Link
href="/create-trip" href="/create-trip"
className="fixed bottom-4 right-4 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-primary-600 text-xl font-bold text-white shadow-xl shadow-primary-600/30 transition-all hover:scale-110 hover:bg-primary-500 active:scale-95 sm:bottom-6 sm:right-6 sm:h-14 sm:w-14 sm:text-2xl" className="fixed bottom-4 right-4 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-primary-600 text-white shadow-xl shadow-primary-600/30 transition-all hover:scale-110 hover:bg-primary-500 active:scale-95 sm:bottom-6 sm:right-6 sm:h-14 sm:w-14"
title="Buat Trip" aria-label="Buat Trip"
> >
+ <Plus size={24} strokeWidth={2} aria-hidden />
</Link> </Link>
</div> </div>
); );
} }
/** Heading section homepage — ikon stroke + judul, opsional aksi di kanan. */
function SectionHeading({
icon: Icon,
title,
subtitle,
action,
}: {
icon: LucideIcon;
title: string;
subtitle?: string;
action?: React.ReactNode;
}) {
return (
<div className="mb-4 flex items-center justify-between gap-3 sm:mb-5">
<div className="flex items-center gap-2.5">
<Icon
size={22}
strokeWidth={1.75}
aria-hidden
className="shrink-0 text-primary-600"
/>
<div>
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
{title}
</h2>
{subtitle && (
<p className="text-[11px] text-neutral-500 sm:text-xs">
{subtitle}
</p>
)}
</div>
</div>
{action}
</div>
);
}
+8 -2
View File
@@ -5,6 +5,7 @@ import { UserCard } from "@/features/profile/components/user-card";
import { PeopleFilter } from "@/features/profile/components/people-filter"; import { PeopleFilter } from "@/features/profile/components/people-filter";
import { isVibe, vibeLabel } from "@/lib/vibe"; import { isVibe, vibeLabel } from "@/lib/vibe";
import { siteConfig } from "@/lib/site"; import { siteConfig } from "@/lib/site";
import { Users } from "lucide-react";
interface PeoplePageProps { interface PeoplePageProps {
searchParams: Promise<{ searchParams: Promise<{
@@ -68,8 +69,13 @@ export default async function PeoplePage({ searchParams }: PeoplePageProps) {
{people.length === 0 ? ( {people.length === 0 ? (
<div className="rounded-2xl border-2 border-dashed border-neutral-200 bg-white p-8 text-center sm:p-14"> <div className="rounded-2xl border-2 border-dashed border-neutral-200 bg-white p-8 text-center sm:p-14">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 text-2xl sm:h-16 sm:w-16 sm:text-3xl"> <div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 sm:h-16 sm:w-16">
🔍 <Users
size={26}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
</div> </div>
<p className="mb-1 text-base font-bold text-neutral-800 sm:text-lg"> <p className="mb-1 text-base font-bold text-neutral-800 sm:text-lg">
{hasFilters {hasFilters
+18 -3
View File
@@ -1,12 +1,19 @@
import Link from "next/link"; import Link from "next/link";
import { ShieldCheck, CircleCheck } from "lucide-react";
export default function PrivacyPage() { export default function PrivacyPage() {
return ( return (
<div className="mx-auto max-w-3xl px-4 py-8 sm:py-12"> <div className="mx-auto max-w-3xl px-4 py-8 sm:py-12">
<article className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm sm:p-10"> <article className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm sm:p-10">
<header className="mb-8 border-b border-neutral-200 pb-6"> <header className="mb-8 border-b border-neutral-200 pb-6">
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl"> <h1 className="flex items-center gap-2 text-2xl font-bold text-neutral-900 sm:text-3xl">
🔒 Kebijakan Privasi SeTrip <ShieldCheck
size={28}
strokeWidth={1.75}
aria-hidden
className="shrink-0 text-primary-600"
/>
Kebijakan Privasi SeTrip
</h1> </h1>
<p className="mt-2 text-sm text-neutral-500"> <p className="mt-2 text-sm text-neutral-500">
Terakhir diperbarui: 2026-04-27 Terakhir diperbarui: 2026-04-27
@@ -205,7 +212,15 @@ export default function PrivacyPage() {
</section> </section>
<section className="rounded-xl bg-neutral-50 p-5"> <section className="rounded-xl bg-neutral-50 p-5">
<h2 className="mb-2 text-lg font-bold text-neutral-900"> Persetujuan</h2> <h2 className="mb-2 flex items-center gap-1.5 text-lg font-bold text-neutral-900">
<CircleCheck
size={18}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
Persetujuan
</h2>
<p className="mb-2"> <p className="mb-2">
Dengan menggunakan SeTrip, Anda menyatakan bahwa: Dengan menggunakan SeTrip, Anda menyatakan bahwa:
</p> </p>
+8 -5
View File
@@ -10,6 +10,7 @@ import { TripCard } from "@/features/trip/components/trip-card";
import { ProfileTripRow } from "@/features/profile/components/profile-trip-row"; import { ProfileTripRow } from "@/features/profile/components/profile-trip-row";
import { ProfileEditor } from "@/features/profile/components/profile-editor"; import { ProfileEditor } from "@/features/profile/components/profile-editor";
import { EarningsSection } from "@/features/payout/components/earnings-section"; import { EarningsSection } from "@/features/payout/components/earnings-section";
import { Plus, ChevronRight } from "lucide-react";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Profil Saya", title: "Profil Saya",
@@ -81,9 +82,10 @@ export default async function ProfilePage() {
</div> </div>
<Link <Link
href="/create-trip" href="/create-trip"
className="shrink-0 rounded-xl bg-primary-600 px-4 py-2.5 text-center text-sm font-semibold text-white shadow-md hover:bg-primary-700" className="inline-flex shrink-0 items-center justify-center gap-1.5 rounded-xl bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white shadow-md hover:bg-primary-700"
> >
+ Buat trip <Plus size={16} strokeWidth={2} aria-hidden />
Buat trip
</Link> </Link>
</div> </div>
@@ -133,13 +135,14 @@ export default async function ProfilePage() {
endDate={t.endDate} endDate={t.endDate}
rightSlot={ rightSlot={
<span <span
className={ className={`inline-flex items-center gap-0.5 ${
hasReview hasReview
? "text-secondary-700" ? "text-secondary-700"
: "font-bold text-amber-800" : "font-bold text-amber-800"
} }`}
> >
{hasReview ? "Ubah ulasan" : "Beri ulasan"} {hasReview ? "Ubah ulasan" : "Beri ulasan"}
<ChevronRight size={14} strokeWidth={2} aria-hidden />
</span> </span>
} }
/> />
+13 -1
View File
@@ -1,4 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Daftar Akun", title: "Daftar Akun",
@@ -7,6 +10,15 @@ export const metadata: Metadata = {
alternates: { canonical: "/register" }, alternates: { canonical: "/register" },
}; };
export default function RegisterLayout({ children }: { children: React.ReactNode }) { export default async function RegisterLayout({
children,
}: {
children: React.ReactNode;
}) {
// User yang sudah login tidak boleh mengakses halaman daftar lagi.
const session = await getServerSession(authOptions);
if (session?.user) {
redirect(session.user.isAdmin ? "/admin" : "/");
}
return children; return children;
} }
+1 -1
View File
@@ -77,7 +77,7 @@ export default function RegisterPage() {
</div> </div>
{/* Card */} {/* Card */}
<div className="rounded-2xl border border-white/10 bg-white/95 p-6 shadow-2xl backdrop-blur-sm"> <div className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-2xl">
{error && ( {error && (
<div className="mb-4 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600"> <div className="mb-4 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600">
{error} {error}
+18 -3
View File
@@ -1,12 +1,19 @@
import Link from "next/link"; import Link from "next/link";
import { FileText, CircleCheck } from "lucide-react";
export default function TermsPage() { export default function TermsPage() {
return ( return (
<div className="mx-auto max-w-3xl px-4 py-8 sm:py-12"> <div className="mx-auto max-w-3xl px-4 py-8 sm:py-12">
<article className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm sm:p-10"> <article className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm sm:p-10">
<header className="mb-8 border-b border-neutral-200 pb-6"> <header className="mb-8 border-b border-neutral-200 pb-6">
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl"> <h1 className="flex items-center gap-2 text-2xl font-bold text-neutral-900 sm:text-3xl">
📜 Syarat &amp; Ketentuan SeTrip <FileText
size={28}
strokeWidth={1.75}
aria-hidden
className="shrink-0 text-primary-600"
/>
Syarat &amp; Ketentuan SeTrip
</h1> </h1>
<p className="mt-2 text-sm text-neutral-500"> <p className="mt-2 text-sm text-neutral-500">
Terakhir diperbarui: 2026-04-27 Terakhir diperbarui: 2026-04-27
@@ -262,7 +269,15 @@ export default function TermsPage() {
</section> </section>
<section className="rounded-xl bg-neutral-50 p-5"> <section className="rounded-xl bg-neutral-50 p-5">
<h2 className="mb-2 text-lg font-bold text-neutral-900"> Persetujuan</h2> <h2 className="mb-2 flex items-center gap-1.5 text-lg font-bold text-neutral-900">
<CircleCheck
size={18}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
Persetujuan
</h2>
<p className="mb-2"> <p className="mb-2">
Dengan menggunakan SeTrip, Anda menyatakan bahwa: Dengan menggunakan SeTrip, Anda menyatakan bahwa:
</p> </p>
+31
View File
@@ -0,0 +1,31 @@
/** Skeleton halaman detail trip — tampil instan saat data masih di-fetch. */
export default function Loading() {
return (
<div className="mx-auto max-w-3xl px-4 py-4 sm:py-8">
<div className="mb-3 h-4 w-40 animate-pulse rounded bg-neutral-200 sm:mb-4" />
<div className="overflow-hidden rounded-2xl border border-neutral-200 bg-white shadow-sm">
<div className="h-44 animate-pulse bg-neutral-200 sm:h-56 lg:h-72" />
<div className="border-b border-neutral-100 px-4 py-4 sm:px-6">
<div className="h-6 w-2/3 animate-pulse rounded bg-neutral-200" />
<div className="mt-2 h-4 w-1/3 animate-pulse rounded bg-neutral-100" />
</div>
<div className="space-y-5 p-4 sm:space-y-6 sm:p-6">
<div className="grid grid-cols-2 gap-2 sm:gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="h-16 animate-pulse rounded-xl bg-neutral-100 sm:h-[72px]"
/>
))}
</div>
<div className="h-24 animate-pulse rounded-xl bg-neutral-100" />
<div className="h-32 animate-pulse rounded-xl bg-neutral-100" />
<div className="h-12 animate-pulse rounded-xl bg-neutral-200" />
</div>
</div>
</div>
);
}
+10 -2
View File
@@ -2,7 +2,7 @@ import { ImageResponse } from "next/og";
import { tripService } from "@/server/services/trip.service"; import { tripService } from "@/server/services/trip.service";
import { formatRupiah } from "@/lib/utils"; import { formatRupiah } from "@/lib/utils";
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates"; import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
import { siteConfig } from "@/lib/site"; import { siteConfig, siteUrl } from "@/lib/site";
export const alt = `${siteConfig.name} — Open Trip & Aktivitas Bareng`; export const alt = `${siteConfig.name} — Open Trip & Aktivitas Bareng`;
export const size = { width: 1200, height: 630 }; export const size = { width: 1200, height: 630 };
@@ -43,7 +43,15 @@ export default async function TripOgImage({
); );
} }
const cover = trip.images[0]?.url; // Satori (ImageResponse) mem-fetch gambar server-side dan butuh URL absolut.
// Foto trip baru disimpan sebagai path relatif `/api/trip-images/...` —
// prefix dengan origin. Foto lama (URL eksternal absolut) dipakai apa adanya.
const coverRaw = trip.images[0]?.url;
const cover = coverRaw
? coverRaw.startsWith("http")
? coverRaw
: `${siteUrl}${coverRaw}`
: undefined;
const dateLabel = formatTripCalendarDateRangeLong(trip.date, trip.endDate); const dateLabel = formatTripCalendarDateRangeLong(trip.date, trip.endDate);
const price = formatRupiah(trip.price); const price = formatRupiah(trip.price);
+56 -15
View File
@@ -28,6 +28,14 @@ import {
isTripDepartureDayPast, isTripDepartureDayPast,
} from "@/lib/trip-dates"; } from "@/lib/trip-dates";
import { previewRefund } from "@/lib/refund-policy"; import { previewRefund } from "@/lib/refund-policy";
import {
MapPin,
CalendarDays,
Wallet,
UserRound,
Zap,
Users,
} from "lucide-react";
export async function generateMetadata({ export async function generateMetadata({
params, params,
@@ -309,8 +317,13 @@ export default async function TripDetailPage({
{/* Info Grid */} {/* Info Grid */}
<div className="grid grid-cols-2 gap-2 sm:gap-3"> <div className="grid grid-cols-2 gap-2 sm:gap-3">
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4"> <div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-secondary-100 text-sm sm:h-10 sm:w-10 sm:text-lg"> <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-secondary-100 sm:h-10 sm:w-10">
📍 <MapPin
size={18}
strokeWidth={1.75}
aria-hidden
className="text-secondary-700"
/>
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Lokasi</p> <p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Lokasi</p>
@@ -319,8 +332,13 @@ export default async function TripDetailPage({
</div> </div>
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4"> <div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-secondary-100 text-sm sm:h-10 sm:w-10 sm:text-lg"> <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-secondary-100 sm:h-10 sm:w-10">
📅 <CalendarDays
size={18}
strokeWidth={1.75}
aria-hidden
className="text-secondary-700"
/>
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Tanggal</p> <p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Tanggal</p>
@@ -331,8 +349,13 @@ export default async function TripDetailPage({
</div> </div>
<div className="flex items-center gap-2 rounded-xl bg-primary-50 p-3 sm:gap-3 sm:p-4"> <div className="flex items-center gap-2 rounded-xl bg-primary-50 p-3 sm:gap-3 sm:p-4">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary-100 text-sm sm:h-10 sm:w-10 sm:text-lg"> <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary-100 sm:h-10 sm:w-10">
💰 <Wallet
size={18}
strokeWidth={1.75}
aria-hidden
className="text-primary-700"
/>
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-[10px] font-medium text-primary-600 sm:text-xs">Harga</p> <p className="text-[10px] font-medium text-primary-600 sm:text-xs">Harga</p>
@@ -343,8 +366,13 @@ export default async function TripDetailPage({
</div> </div>
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4"> <div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-200 text-sm sm:h-10 sm:w-10 sm:text-lg"> <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-200 sm:h-10 sm:w-10">
👤 <UserRound
size={18}
strokeWidth={1.75}
aria-hidden
className="text-neutral-600"
/>
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Organizer</p> <p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Organizer</p>
@@ -372,8 +400,9 @@ export default async function TripDetailPage({
Peserta Peserta
</span> </span>
{spotsLeft > 0 && spotsLeft <= 3 && ( {spotsLeft > 0 && spotsLeft <= 3 && (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-bold text-amber-800 sm:text-[11px]"> <span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-bold text-amber-800 sm:text-[11px]">
Tinggal {spotsLeft} spot! <Zap size={11} strokeWidth={2} aria-hidden />
Tinggal {spotsLeft} spot!
</span> </span>
)} )}
{spotsLeft <= 0 && ( {spotsLeft <= 0 && (
@@ -418,8 +447,14 @@ export default async function TripDetailPage({
)} )}
</p> </p>
{confirmedCount > 0 && ( {confirmedCount > 0 && (
<p className="mt-2 text-[11px] text-neutral-600 sm:text-xs"> <p className="mt-2 flex flex-wrap items-center gap-x-1 gap-y-0.5 text-[11px] text-neutral-600 sm:text-xs">
<span aria-hidden>👥</span> Sudah join:{" "} <Users
size={13}
strokeWidth={1.75}
aria-hidden
className="text-neutral-400"
/>
Sudah join:{" "}
<span className="font-medium text-neutral-800"> <span className="font-medium text-neutral-800">
{confirmedParticipants {confirmedParticipants
.slice(0, 3) .slice(0, 3)
@@ -547,7 +582,7 @@ export default async function TripDetailPage({
Belum ada peserta yang dikonfirmasi.{" "} Belum ada peserta yang dikonfirmasi.{" "}
{pendingParticipants.length > 0 {pendingParticipants.length > 0
? "Cek permintaan join di atas untuk menyetujui peserta." ? "Cek permintaan join di atas untuk menyetujui peserta."
: "Jadilah yang pertama mendaftar! 🎒"} : "Jadilah yang pertama mendaftar!"}
</p> </p>
) : ( ) : (
<ul className="grid gap-2 sm:grid-cols-2"> <ul className="grid gap-2 sm:grid-cols-2">
@@ -578,8 +613,14 @@ export default async function TripDetailPage({
{p.user.name} {p.user.name}
</p> </p>
{city && ( {city && (
<p className="truncate text-[11px] text-neutral-500 sm:text-xs"> <p className="flex items-center gap-1 truncate text-[11px] text-neutral-500 sm:text-xs">
📍 {city} <MapPin
size={11}
strokeWidth={1.75}
aria-hidden
className="shrink-0"
/>
{city}
</p> </p>
)} )}
{interests.length > 0 && ( {interests.length > 0 && (
+69 -16
View File
@@ -11,6 +11,15 @@ import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
import { isFreeTrip } from "@/lib/trip-pricing"; import { isFreeTrip } from "@/lib/trip-pricing";
import { categoryMeta } from "@/lib/activity-category"; import { categoryMeta } from "@/lib/activity-category";
import { MidtransPayButton } from "@/features/booking/components/midtrans-pay-button"; import { MidtransPayButton } from "@/features/booking/components/midtrans-pay-button";
import {
ArrowLeft,
CalendarDays,
MapPin,
PartyPopper,
CircleCheck,
Clock,
Check,
} from "lucide-react";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Detail Pembayaran", title: "Detail Pembayaran",
@@ -82,8 +91,15 @@ export default async function PaymentPage({ params, searchParams }: PageProps) {
<h1 className="mt-0.5 truncate text-base font-bold text-neutral-900 sm:text-lg"> <h1 className="mt-0.5 truncate text-base font-bold text-neutral-900 sm:text-lg">
{trip.title} {trip.title}
</h1> </h1>
<p className="mt-1 text-xs text-neutral-500 sm:text-sm"> <p className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-neutral-500 sm:text-sm">
📅 {dateRange} · 📍 {trip.location} <span className="inline-flex items-center gap-1">
<CalendarDays size={13} strokeWidth={1.75} aria-hidden />
{dateRange}
</span>
<span className="inline-flex items-center gap-1">
<MapPin size={13} strokeWidth={1.75} aria-hidden />
{trip.location}
</span>
</p> </p>
<p className="mt-1 text-xs text-neutral-500 sm:text-sm"> <p className="mt-1 text-xs text-neutral-500 sm:text-sm">
Organizer:{" "} Organizer:{" "}
@@ -102,8 +118,12 @@ export default async function PaymentPage({ params, searchParams }: PageProps) {
return ( return (
<div className="mx-auto max-w-2xl px-4 py-6 sm:py-10"> <div className="mx-auto max-w-2xl px-4 py-6 sm:py-10">
<div className="mb-4 flex items-center gap-2 text-xs text-neutral-500 sm:text-sm"> <div className="mb-4 flex items-center gap-2 text-xs text-neutral-500 sm:text-sm">
<Link href={`/trips/${trip.id}`} className="hover:text-primary-600"> <Link
Kembali ke trip href={`/trips/${trip.id}`}
className="inline-flex items-center gap-1 hover:text-primary-600"
>
<ArrowLeft size={14} strokeWidth={1.75} aria-hidden />
Kembali ke trip
</Link> </Link>
</div> </div>
@@ -170,8 +190,13 @@ function FreeTripSection({
}) { }) {
return ( return (
<section className="rounded-2xl border border-emerald-200 bg-emerald-50/60 p-6 text-center shadow-sm sm:p-8"> <section className="rounded-2xl border border-emerald-200 bg-emerald-50/60 p-6 text-center shadow-sm sm:p-8">
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-emerald-100 text-2xl"> <div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-emerald-100">
🎉 <PartyPopper
size={26}
strokeWidth={1.75}
aria-hidden
className="text-emerald-600"
/>
</div> </div>
<h2 className="mb-1 text-lg font-bold text-emerald-900 sm:text-xl"> <h2 className="mb-1 text-lg font-bold text-emerald-900 sm:text-xl">
Trip ini gratis Trip ini gratis
@@ -184,10 +209,28 @@ function FreeTripSection({
<p className="text-[11px] font-semibold uppercase tracking-wide text-emerald-700"> <p className="text-[11px] font-semibold uppercase tracking-wide text-emerald-700">
Status keikutsertaan Status keikutsertaan
</p> </p>
<p className="text-sm font-bold text-neutral-800"> <p className="flex items-center gap-1.5 text-sm font-bold text-neutral-800">
{bookingStatus === "PAID" {bookingStatus === "PAID" ? (
? "✅ Terkonfirmasi sebagai peserta" <>
: "⏳ Menunggu persetujuan organizer"} <CircleCheck
size={15}
strokeWidth={2}
aria-hidden
className="text-emerald-600"
/>
Terkonfirmasi sebagai peserta
</>
) : (
<>
<Clock
size={15}
strokeWidth={2}
aria-hidden
className="text-amber-600"
/>
Menunggu persetujuan organizer
</>
)}
</p> </p>
</div> </div>
@@ -250,10 +293,15 @@ function PaidTripSection({
{canPay && <MidtransPayButton tripId={tripId} />} {canPay && <MidtransPayButton tripId={tripId} />}
{isFullyPaid && ( {isFullyPaid && (
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900 sm:p-5"> <div className="flex items-start gap-2 rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900 sm:p-5">
<CircleCheck
size={16}
strokeWidth={2}
aria-hidden
className="mt-0.5 shrink-0 text-emerald-600"
/>
<p> <p>
Pembayaran kamu sudah terkonfirmasi. Sampai jumpa di trip Pembayaran kamu sudah terkonfirmasi. Sampai jumpa di trip bareng{" "}
bareng{" "}
<span className="font-semibold">{organizerName}</span>! <span className="font-semibold">{organizerName}</span>!
</p> </p>
</div> </div>
@@ -262,9 +310,10 @@ function PaidTripSection({
<div className="text-center"> <div className="text-center">
<Link <Link
href={`/trips/${tripId}`} href={`/trips/${tripId}`}
className="text-sm text-neutral-500 hover:text-primary-600" className="inline-flex items-center gap-1 text-sm text-neutral-500 hover:text-primary-600"
> >
Kembali ke detail trip <ArrowLeft size={14} strokeWidth={1.75} aria-hidden />
Kembali ke detail trip
</Link> </Link>
</div> </div>
</div> </div>
@@ -298,7 +347,11 @@ function PaymentTimeline({
: "bg-neutral-200 text-neutral-500" : "bg-neutral-200 text-neutral-500"
}`} }`}
> >
{s.done ? "✓" : i + 1} {s.done ? (
<Check size={12} strokeWidth={3} aria-hidden />
) : (
i + 1
)}
</span> </span>
<span <span
className={`text-sm ${ className={`text-sm ${
+29
View File
@@ -0,0 +1,29 @@
/** Skeleton daftar trip — tampil instan saat list masih di-fetch. */
export default function Loading() {
return (
<div className="mx-auto max-w-6xl px-4 py-6 sm:py-8">
<div className="mb-6 h-8 w-56 animate-pulse rounded-lg bg-neutral-200 sm:mb-8" />
<div className="mb-6 h-40 animate-pulse rounded-2xl bg-neutral-100" />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="overflow-hidden rounded-2xl border border-neutral-200 bg-white"
>
<div className="h-40 animate-pulse bg-neutral-200" />
<div className="space-y-3 p-4">
<div className="h-5 w-3/4 animate-pulse rounded bg-neutral-200" />
<div className="h-4 w-1/2 animate-pulse rounded bg-neutral-100" />
<div className="h-4 w-2/3 animate-pulse rounded bg-neutral-100" />
<div className="mt-3 flex items-center justify-between border-t border-neutral-100 pt-3">
<div className="h-5 w-20 animate-pulse rounded bg-neutral-200" />
<div className="h-4 w-16 animate-pulse rounded bg-neutral-100" />
</div>
</div>
</div>
))}
</div>
</div>
);
}
+24 -5
View File
@@ -11,6 +11,7 @@ import { siteConfig } from "@/lib/site";
import { categoryLabel, isActivityCategory } from "@/lib/activity-category"; import { categoryLabel, isActivityCategory } from "@/lib/activity-category";
import { isVibe } from "@/lib/vibe"; import { isVibe } from "@/lib/vibe";
import type { GroupSize } from "@/server/repositories/trip.repo"; import type { GroupSize } from "@/server/repositories/trip.repo";
import { Plus, Search, Tent } from "lucide-react";
const GROUP_SIZES: GroupSize[] = ["SMALL", "MEDIUM", "LARGE"]; const GROUP_SIZES: GroupSize[] = ["SMALL", "MEDIUM", "LARGE"];
function isGroupSize(value: unknown): value is GroupSize { function isGroupSize(value: unknown): value is GroupSize {
@@ -98,9 +99,10 @@ export default async function TripsPage({ searchParams }: TripsPageProps) {
</div> </div>
<Link <Link
href="/create-trip" href="/create-trip"
className="w-full rounded-xl bg-primary-600 px-4 py-2.5 text-center text-sm font-semibold text-white shadow-md shadow-primary-600/20 hover:bg-primary-700 sm:w-auto" className="inline-flex w-full items-center justify-center gap-1.5 rounded-xl bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white shadow-md shadow-primary-600/20 hover:bg-primary-700 sm:w-auto"
> >
+ Buat Trip <Plus size={16} strokeWidth={2} aria-hidden />
Buat Trip
</Link> </Link>
</div> </div>
@@ -113,8 +115,22 @@ export default async function TripsPage({ searchParams }: TripsPageProps) {
{trips.length === 0 ? ( {trips.length === 0 ? (
<div className="rounded-2xl border-2 border-dashed border-neutral-200 bg-white p-8 text-center sm:p-14"> <div className="rounded-2xl border-2 border-dashed border-neutral-200 bg-white p-8 text-center sm:p-14">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 text-2xl sm:h-16 sm:w-16 sm:text-3xl"> <div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 sm:h-16 sm:w-16">
{hasFilters ? "🔍" : "🏕️"} {hasFilters ? (
<Search
size={26}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
) : (
<Tent
size={26}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
)}
</div> </div>
<p className="mb-1 text-base font-bold text-neutral-800 sm:text-lg"> <p className="mb-1 text-base font-bold text-neutral-800 sm:text-lg">
{hasFilters {hasFilters
@@ -137,7 +153,7 @@ export default async function TripsPage({ searchParams }: TripsPageProps) {
</div> </div>
) : ( ) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{trips.map((trip) => ( {trips.map((trip, index) => (
<TripCard <TripCard
key={trip.id} key={trip.id}
id={trip.id} id={trip.id}
@@ -154,6 +170,9 @@ export default async function TripsPage({ searchParams }: TripsPageProps) {
organizerName={trip.organizer.name} organizerName={trip.organizer.name}
status={trip.status} status={trip.status}
coverImage={trip.images[0]?.url} coverImage={trip.images[0]?.url}
// Baris pertama (3 kartu) di atas fold — muat segera supaya
// tidak jadi LCP yang lambat.
priority={index < 3}
isVerifiedOrganizer={ isVerifiedOrganizer={
trip.organizer.organizerVerification?.status === "APPROVED" trip.organizer.organizerVerification?.status === "APPROVED"
} }
+8 -5
View File
@@ -11,6 +11,7 @@ import { OrganizerStatsPanel } from "@/features/profile/components/organizer-sta
import { OrganizerReviewsList } from "@/features/review/components/organizer-reviews-list"; import { OrganizerReviewsList } from "@/features/review/components/organizer-reviews-list";
import { siteConfig } from "@/lib/site"; import { siteConfig } from "@/lib/site";
import { vibeMeta } from "@/lib/vibe"; import { vibeMeta } from "@/lib/vibe";
import { BadgeCheck, MapPin, AtSign } from "lucide-react";
interface PageProps { interface PageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@@ -86,10 +87,11 @@ export default async function PublicProfilePage({ params }: PageProps) {
</h1> </h1>
{isVerifiedOrganizer && ( {isVerifiedOrganizer && (
<span <span
className="inline-flex items-center gap-0.5 rounded-full bg-primary-100 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-primary-800" className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-primary-800"
title="Identitas organizer telah diverifikasi (KTP & rekening)" title="Identitas organizer telah diverifikasi (KTP & rekening)"
> >
Verified Organizer <BadgeCheck size={12} strokeWidth={2} aria-hidden />
Verified Organizer
</span> </span>
)} )}
</div> </div>
@@ -97,7 +99,8 @@ export default async function PublicProfilePage({ params }: PageProps) {
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-neutral-500"> <div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-neutral-500">
{profile?.city && ( {profile?.city && (
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
📍 {profile.city} <MapPin size={13} strokeWidth={1.75} aria-hidden />
{profile.city}
</span> </span>
)} )}
<span className="text-xs">Bergabung sejak {memberSince}</span> <span className="text-xs">Bergabung sejak {memberSince}</span>
@@ -141,8 +144,8 @@ export default async function PublicProfilePage({ params }: PageProps) {
rel="noopener noreferrer nofollow" rel="noopener noreferrer nofollow"
className="mt-3 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700" className="mt-3 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700"
> >
<span>📸</span> <AtSign size={15} strokeWidth={1.75} aria-hidden />
<span>@{profile.instagram}</span> <span>{profile.instagram}</span>
</a> </a>
)} )}
</div> </div>
+17 -7
View File
@@ -1,5 +1,6 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { Clock, RefreshCw, CircleX, ArrowLeft } from "lucide-react";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { organizerService } from "@/server/services/organizer.service"; import { organizerService } from "@/server/services/organizer.service";
@@ -72,8 +73,9 @@ export default async function VerifyPage() {
{verification?.status === "PENDING" && !verification.reuploadRequested && ( {verification?.status === "PENDING" && !verification.reuploadRequested && (
<div className="mb-6 rounded-2xl border border-amber-200 bg-amber-50 p-5"> <div className="mb-6 rounded-2xl border border-amber-200 bg-amber-50 p-5">
<p className="mb-1 text-sm font-bold text-amber-800"> <p className="mb-1 flex items-center gap-1.5 text-sm font-bold text-amber-800">
Menunggu review admin <Clock size={15} strokeWidth={2} aria-hidden />
Menunggu review admin
</p> </p>
<p className="text-sm text-neutral-700"> <p className="text-sm text-neutral-700">
Pengajuanmu sedang diproses. Kami akan memberitahu via email setelah selesai. Pengajuanmu sedang diproses. Kami akan memberitahu via email setelah selesai.
@@ -83,8 +85,9 @@ export default async function VerifyPage() {
{verification?.reuploadRequested && ( {verification?.reuploadRequested && (
<div className="mb-6 rounded-2xl border-2 border-amber-400 bg-amber-50 p-5"> <div className="mb-6 rounded-2xl border-2 border-amber-400 bg-amber-50 p-5">
<p className="mb-1 text-sm font-bold text-amber-900"> <p className="mb-1 flex items-center gap-1.5 text-sm font-bold text-amber-900">
🔄 Admin minta kamu upload ulang <RefreshCw size={15} strokeWidth={2} aria-hidden />
Admin minta kamu upload ulang
</p> </p>
{verification.reuploadNote && ( {verification.reuploadNote && (
<p className="mb-3 text-sm text-neutral-700"> <p className="mb-3 text-sm text-neutral-700">
@@ -117,7 +120,10 @@ export default async function VerifyPage() {
{verification?.status === "REJECTED" && ( {verification?.status === "REJECTED" && (
<div className="mb-6 rounded-2xl border border-red-200 bg-red-50 p-5"> <div className="mb-6 rounded-2xl border border-red-200 bg-red-50 p-5">
<p className="mb-1 text-sm font-bold text-red-800"> Pengajuan ditolak</p> <p className="mb-1 flex items-center gap-1.5 text-sm font-bold text-red-800">
<CircleX size={15} strokeWidth={2} aria-hidden />
Pengajuan ditolak
</p>
{verification.rejectionReason && ( {verification.rejectionReason && (
<p className="text-sm text-neutral-700"> <p className="text-sm text-neutral-700">
<span className="font-semibold">Alasan:</span>{" "} <span className="font-semibold">Alasan:</span>{" "}
@@ -135,8 +141,12 @@ export default async function VerifyPage() {
verification?.reuploadRequested)) && <VerifyForm initial={initial} />} verification?.reuploadRequested)) && <VerifyForm initial={initial} />}
<p className="mt-6 text-center text-sm text-neutral-500"> <p className="mt-6 text-center text-sm text-neutral-500">
<Link href="/profile" className="hover:text-primary-600"> <Link
Kembali ke profil href="/profile"
className="inline-flex items-center gap-1 hover:text-primary-600"
>
<ArrowLeft size={14} strokeWidth={1.75} aria-hidden />
Kembali ke profil
</Link> </Link>
</p> </p>
</div> </div>
+31 -7
View File
@@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { ArrowLeft, CalendarDays, CircleAlert, MapPin } from "lucide-react";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin"; import { isAdminEmail } from "@/lib/admin";
import { bookingRepo } from "@/server/repositories/booking.repo"; import { bookingRepo } from "@/server/repositories/booking.repo";
@@ -69,8 +70,12 @@ export default async function AdminBookingDetailPage({ params }: PageProps) {
return ( return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:py-12"> <div className="mx-auto max-w-4xl px-4 py-8 sm:py-12">
<div className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-neutral-500"> <div className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-neutral-500">
<Link href="/admin" className="hover:text-primary-600"> <Link
Dashboard href="/admin"
className="inline-flex items-center gap-1 hover:text-primary-600"
>
<ArrowLeft size={14} strokeWidth={2} aria-hidden />
Dashboard
</Link> </Link>
<Link <Link
href={`/admin/trips/${booking.tripId}`} href={`/admin/trips/${booking.tripId}`}
@@ -87,9 +92,22 @@ export default async function AdminBookingDetailPage({ params }: PageProps) {
<h1 className="mt-0.5 text-xl font-bold text-neutral-900 sm:text-2xl"> <h1 className="mt-0.5 text-xl font-bold text-neutral-900 sm:text-2xl">
{booking.trip.title} {booking.trip.title}
</h1> </h1>
<p className="mt-1 text-sm text-neutral-500"> <p className="mt-1 flex flex-wrap items-center gap-1 text-sm text-neutral-500">
📅 {formatTripCalendarDateRangeLong(booking.trip.date, booking.trip.endDate)}{" "} <CalendarDays
· 📍 {booking.trip.destination}, {booking.trip.location} size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0"
/>
{formatTripCalendarDateRangeLong(booking.trip.date, booking.trip.endDate)}
<span aria-hidden>·</span>
<MapPin
size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0"
/>
{booking.trip.destination}, {booking.trip.location}
</p> </p>
<div className="mt-4 grid gap-3 sm:grid-cols-2"> <div className="mt-4 grid gap-3 sm:grid-cols-2">
@@ -300,8 +318,14 @@ function PaymentEventCard({
</p> </p>
)} )}
{payment.rejectionReason && ( {payment.rejectionReason && (
<p className="text-red-700"> <p className="flex items-center gap-1 text-red-700">
{payment.rejectionReason} <CircleAlert
size={14}
strokeWidth={2}
aria-hidden
className="shrink-0"
/>
{payment.rejectionReason}
</p> </p>
)} )}
</div> </div>
+17
View File
@@ -0,0 +1,17 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Admin · Email Log",
description:
"Halaman admin untuk memantau pengiriman email — antrian, kegagalan, dan retry.",
alternates: { canonical: "/admin/emails" },
robots: { index: false, follow: false },
};
export default function AdminEmailsLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}
+346
View File
@@ -0,0 +1,346 @@
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { emailRepo } from "@/server/repositories/email.repo";
import {
RetryEmailButton,
ResendEmailButton,
} from "@/features/email/components/email-row-actions";
type Tab = "failed" | "queue" | "sent";
const TABS: { key: Tab; label: string }[] = [
{ key: "failed", label: "Gagal" },
{ key: "queue", label: "Antrian" },
{ key: "sent", label: "Terkirim" },
];
interface PageProps {
searchParams: Promise<{ tab?: string; to?: string; template?: string }>;
}
export default async function AdminEmailsPage({ searchParams }: PageProps) {
const session = await getServerSession(authOptions);
if (!session?.user) redirect("/login?callbackUrl=/admin/emails");
if (!isAdminEmail(session.user.email)) {
return (
<div className="mx-auto max-w-2xl px-4 py-12 text-center">
<p className="text-sm text-neutral-600">
Halaman ini hanya untuk admin SeTrip.
</p>
</div>
);
}
const params = await searchParams;
const tab: Tab = TABS.some((t) => t.key === params.tab)
? (params.tab as Tab)
: "failed";
const filters = {
to: params.to?.trim() || undefined,
template: params.template?.trim() || undefined,
};
const stats = await emailRepo.stats();
const jobs =
tab === "sent"
? []
: await emailRepo.listJobs(
tab === "failed" ? ["FAILED"] : ["PENDING", "PROCESSING"],
filters
);
const sent = tab === "sent" ? await emailRepo.listSent(filters) : [];
return (
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
<header className="mb-6">
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
Email Log
</h1>
<p className="mt-1 text-sm text-neutral-500">
Pantau pengiriman email transaksional. Email yang gagal dikirim bisa
di-retry manual; email terkirim bisa di-resend kalau peserta lapor
tidak menerima.
</p>
</header>
{/* Kartu ringkasan */}
<div className="mb-6 grid gap-3 sm:grid-cols-3">
<StatCard
label="Antri dikirim"
value={stats.queued}
tone={stats.queued > 0 ? "amber" : "ok"}
hint="Job menunggu cron / retry"
/>
<StatCard
label="Gagal 24 jam"
value={stats.failed24h}
tone={stats.failed24h > 0 ? "red" : "ok"}
hint="Job gagal dalam sehari terakhir"
/>
<StatCard
label="Perlu aksi manual"
value={stats.deadLetter}
tone={stats.deadLetter > 0 ? "red" : "ok"}
hint="Gagal & habis 5 attempt — cron berhenti retry"
/>
</div>
{/* Tabs */}
<div className="mb-4 flex flex-wrap gap-2">
{TABS.map((t) => (
<a
key={t.key}
href={`/admin/emails?tab=${t.key}`}
className={`rounded-full px-4 py-1.5 text-sm font-semibold transition-colors ${
tab === t.key
? "bg-primary-600 text-white"
: "bg-neutral-100 text-neutral-600 hover:bg-neutral-200"
}`}
>
{t.label}
</a>
))}
</div>
{/* Filter */}
<form
method="get"
action="/admin/emails"
className="mb-4 flex flex-wrap items-end gap-2 rounded-2xl border border-neutral-200 bg-white p-3 shadow-sm"
>
<input type="hidden" name="tab" value={tab} />
<div className="min-w-[180px] flex-1">
<label
htmlFor="filter-to"
className="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
>
Penerima (email)
</label>
<input
id="filter-to"
name="to"
defaultValue={params.to ?? ""}
placeholder="user@email.com"
className="w-full rounded-lg border border-neutral-200 bg-neutral-50 px-2 py-1.5 text-sm text-neutral-800 focus:border-primary-400 focus:bg-white"
/>
</div>
<div className="min-w-[160px] flex-1">
<label
htmlFor="filter-template"
className="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
>
Template
</label>
<input
id="filter-template"
name="template"
defaultValue={params.template ?? ""}
placeholder="mis. refund_succeeded"
className="w-full rounded-lg border border-neutral-200 bg-neutral-50 px-2 py-1.5 text-sm text-neutral-800 focus:border-primary-400 focus:bg-white"
/>
</div>
<button
type="submit"
className="rounded-lg bg-primary-600 px-4 py-1.5 text-sm font-semibold text-white hover:bg-primary-700"
>
Cari
</button>
{(filters.to || filters.template) && (
<a
href={`/admin/emails?tab=${tab}`}
className="rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-500 hover:bg-neutral-50"
>
Reset
</a>
)}
</form>
{tab === "sent" ? (
<SentTable rows={sent} />
) : (
<JobTable rows={jobs} tab={tab} />
)}
</div>
);
}
function StatCard({
label,
value,
tone,
hint,
}: {
label: string;
value: number;
tone: "ok" | "amber" | "red";
hint: string;
}) {
const cls =
tone === "red"
? "border-red-200 bg-red-50/60"
: tone === "amber"
? "border-amber-200 bg-amber-50/60"
: "border-emerald-200 bg-emerald-50/50";
const valueCls =
tone === "red"
? "text-red-700"
: tone === "amber"
? "text-amber-700"
: "text-emerald-700";
return (
<div className={`rounded-2xl border p-4 shadow-sm ${cls}`}>
<p className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
{label}
</p>
<p className={`mt-1 text-2xl font-bold ${valueCls}`}>{value}</p>
<p className="mt-0.5 text-[11px] text-neutral-500">{hint}</p>
</div>
);
}
function JobTable({
rows,
tab,
}: {
rows: Awaited<ReturnType<typeof emailRepo.listJobs>>;
tab: "failed" | "queue";
}) {
if (rows.length === 0) {
return (
<EmptyState
message={
tab === "failed"
? "Tidak ada email gagal — semua pengiriman lancar. 🎉"
: "Tidak ada email yang sedang antri."
}
/>
);
}
return (
<div className="overflow-x-auto rounded-2xl border border-neutral-200 bg-white shadow-sm">
<table className="min-w-full divide-y divide-neutral-100 text-sm">
<thead className="bg-neutral-50 text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
<tr>
<th className="px-3 py-2 text-left">Penerima</th>
<th className="px-3 py-2 text-left">Template</th>
<th className="px-3 py-2 text-left">Status</th>
<th className="px-3 py-2 text-left">Attempt</th>
<th className="px-3 py-2 text-left">
{tab === "failed" ? "Error terakhir" : "Dijadwalkan"}
</th>
<th className="px-3 py-2 text-left">Aksi</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100 text-xs text-neutral-700">
{rows.map((r) => (
<tr key={r.id}>
<td className="px-3 py-2">{r.to}</td>
<td className="px-3 py-2 font-mono">{r.template}</td>
<td className="px-3 py-2">
<EmailBadge value={r.status} />
</td>
<td className="px-3 py-2">
{r.attempts}
{r.attempts >= 5 && (
<span className="ml-1 text-[10px] font-semibold text-red-600">
(mati)
</span>
)}
</td>
<td className="px-3 py-2 text-neutral-500">
{tab === "failed"
? r.lastError
? truncate(r.lastError, 90)
: "—"
: formatDateTime(r.scheduledAt)}
</td>
<td className="px-3 py-2">
<RetryEmailButton jobId={r.id} />
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function SentTable({
rows,
}: {
rows: Awaited<ReturnType<typeof emailRepo.listSent>>;
}) {
if (rows.length === 0) {
return <EmptyState message="Belum ada email terkirim yang cocok." />;
}
return (
<div className="overflow-x-auto rounded-2xl border border-neutral-200 bg-white shadow-sm">
<table className="min-w-full divide-y divide-neutral-100 text-sm">
<thead className="bg-neutral-50 text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
<tr>
<th className="px-3 py-2 text-left">Penerima</th>
<th className="px-3 py-2 text-left">Template</th>
<th className="px-3 py-2 text-left">Subject</th>
<th className="px-3 py-2 text-left">Terkirim</th>
<th className="px-3 py-2 text-left">Aksi</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100 text-xs text-neutral-700">
{rows.map((r) => (
<tr key={r.id}>
<td className="px-3 py-2">{r.to}</td>
<td className="px-3 py-2 font-mono">{r.template}</td>
<td className="px-3 py-2">{truncate(r.subject, 60)}</td>
<td className="px-3 py-2 text-neutral-500">
{formatDateTime(r.sentAt)}
</td>
<td className="px-3 py-2">
<ResendEmailButton emailSentId={r.id} disabled={!r.html} />
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function EmptyState({ message }: { message: string }) {
return (
<div className="rounded-2xl border border-dashed border-neutral-300 bg-white p-10 text-center">
<p className="text-sm text-neutral-500">{message}</p>
</div>
);
}
function EmailBadge({ value }: { value: string }) {
const cls =
value === "SUCCESS"
? "bg-emerald-100 text-emerald-800"
: value === "FAILED"
? "bg-red-100 text-red-800"
: "bg-amber-100 text-amber-800";
return (
<span
className={`inline-block rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${cls}`}
>
{value}
</span>
);
}
function formatDateTime(d: Date): string {
return d.toLocaleString("id-ID", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function truncate(s: string, max: number): string {
return s.length > max ? `${s.slice(0, max)}` : s;
}
+7 -1
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { Lock } from "lucide-react";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { AdminSidebar } from "@/components/admin/admin-sidebar"; import { AdminSidebar } from "@/components/admin/admin-sidebar";
@@ -31,7 +32,12 @@ export default async function AdminLayout({
return ( return (
<div className="flex min-h-screen items-center justify-center bg-neutral-50 px-4"> <div className="flex min-h-screen items-center justify-center bg-neutral-50 px-4">
<div className="max-w-md rounded-2xl border border-neutral-200 bg-white p-8 text-center shadow-sm"> <div className="max-w-md rounded-2xl border border-neutral-200 bg-white p-8 text-center shadow-sm">
<p className="text-2xl">🔒</p> <Lock
size={28}
strokeWidth={1.75}
aria-hidden
className="mx-auto text-neutral-500"
/>
<h1 className="mt-2 text-base font-bold text-neutral-900"> <h1 className="mt-2 text-base font-bold text-neutral-900">
Halaman khusus admin Halaman khusus admin
</h1> </h1>
+4 -2
View File
@@ -1,6 +1,7 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { ChevronRight } from "lucide-react";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { organizerRepo } from "@/server/repositories/organizer.repo"; import { organizerRepo } from "@/server/repositories/organizer.repo";
import { refundRepo } from "@/server/repositories/refund.repo"; import { refundRepo } from "@/server/repositories/refund.repo";
@@ -155,8 +156,9 @@ export default async function AdminDashboardPage() {
> >
{s.label} {s.label}
</span> </span>
<span className="text-xs text-neutral-400 group-hover:text-primary-600"> <span className="inline-flex items-center gap-1 text-xs text-neutral-400 group-hover:text-primary-600">
Buka Buka
<ChevronRight size={14} strokeWidth={2} aria-hidden />
</span> </span>
</div> </div>
<p className="mt-3 text-3xl font-bold text-neutral-900">{s.value}</p> <p className="mt-3 text-3xl font-bold text-neutral-900">{s.value}</p>
+114 -13
View File
@@ -1,10 +1,17 @@
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import {
ArrowUpRight,
CircleAlert,
CircleCheck,
CircleX,
} from "lucide-react";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin"; import { isAdminEmail } from "@/lib/admin";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { systemHealthService } from "@/server/services/system-health.service"; import { systemHealthService } from "@/server/services/system-health.service";
import { emailRepo } from "@/server/repositories/email.repo";
interface JobSummary { interface JobSummary {
jobName: string; jobName: string;
@@ -55,7 +62,11 @@ async function getJobSummary(jobName: string): Promise<JobSummary> {
} }
// Daftar cron yang dipantau. Tambah entry baru saat menambah cron route handler. // Daftar cron yang dipantau. Tambah entry baru saat menambah cron route handler.
const TRACKED_JOBS = ["auto-complete-trips"] as const; const TRACKED_JOBS = [
"auto-complete-trips",
"process-email-jobs",
"cleanup-trip-images",
] as const;
function healthOf(summary: JobSummary): "ok" | "stale" | "failed" { function healthOf(summary: JobSummary): "ok" | "stale" | "failed" {
if (summary.lastRun?.status === "FAILED") return "failed"; if (summary.lastRun?.status === "FAILED") return "failed";
@@ -80,20 +91,22 @@ export default async function AdminSystemPage() {
); );
} }
const [summaries, recentRuns, stale] = await Promise.all([ const [summaries, recentRuns, stale, emailStats] = await Promise.all([
Promise.all(TRACKED_JOBS.map(getJobSummary)), Promise.all(TRACKED_JOBS.map(getJobSummary)),
prisma.cronRun.findMany({ prisma.cronRun.findMany({
orderBy: { startedAt: "desc" }, orderBy: { startedAt: "desc" },
take: 20, take: 20,
}), }),
systemHealthService.detectStale(), systemHealthService.detectStale(),
emailRepo.stats(),
]); ]);
const hasAnyStale = const hasAnyStale =
stale.stalePaymentsCount > 0 || stale.stalePaymentsCount > 0 ||
stale.awaitingPayPastDepartureCount > 0 || stale.awaitingPayPastDepartureCount > 0 ||
stale.overduePayoutsCount > 0 || stale.overduePayoutsCount > 0 ||
stale.stuckRefundsCount > 0; stale.stuckRefundsCount > 0 ||
emailStats.deadLetter > 0;
return ( return (
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12"> <div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
@@ -109,8 +122,9 @@ export default async function AdminSystemPage() {
{hasAnyStale && ( {hasAnyStale && (
<section className="mb-6 rounded-2xl border border-amber-300 bg-amber-50 p-4 sm:p-5"> <section className="mb-6 rounded-2xl border border-amber-300 bg-amber-50 p-4 sm:p-5">
<h2 className="mb-2 text-sm font-bold text-amber-900"> <h2 className="mb-2 flex items-center gap-1.5 text-sm font-bold text-amber-900">
Stale State Alerts <CircleAlert size={16} strokeWidth={2} aria-hidden />
Stale State Alerts
</h2> </h2>
<ul className="space-y-1 text-xs text-amber-900"> <ul className="space-y-1 text-xs text-amber-900">
{stale.stalePaymentsCount > 0 && ( {stale.stalePaymentsCount > 0 && (
@@ -134,9 +148,10 @@ export default async function AdminSystemPage() {
cron history di bawah.{" "} cron history di bawah.{" "}
<Link <Link
href="/admin/payouts?tab=HELD" href="/admin/payouts?tab=HELD"
className="font-semibold text-amber-700 hover:underline" className="inline-flex items-center gap-1 font-semibold text-amber-700 hover:underline"
> >
Lihat HELD Lihat HELD
<ArrowUpRight size={14} strokeWidth={2} aria-hidden />
</Link> </Link>
</li> </li>
)} )}
@@ -146,9 +161,24 @@ export default async function AdminSystemPage() {
&gt; 7 hari belum di-process.{" "} &gt; 7 hari belum di-process.{" "}
<Link <Link
href="/admin/refunds?tab=APPROVED" href="/admin/refunds?tab=APPROVED"
className="font-semibold text-amber-700 hover:underline" className="inline-flex items-center gap-1 font-semibold text-amber-700 hover:underline"
> >
Lihat APPROVED 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> </Link>
</li> </li>
)} )}
@@ -171,16 +201,23 @@ export default async function AdminSystemPage() {
: "border-red-200 bg-red-50/50"; : "border-red-200 bg-red-50/50";
const badge = const badge =
health === "ok" health === "ok"
? { label: "🟢 OK", cls: "bg-emerald-100 text-emerald-800" } ? {
label: "OK",
icon: CircleCheck,
cls: "bg-emerald-100 text-emerald-800",
}
: health === "stale" : health === "stale"
? { ? {
label: "🟡 STALE", label: "STALE",
icon: CircleAlert,
cls: "bg-amber-100 text-amber-800", cls: "bg-amber-100 text-amber-800",
} }
: { : {
label: "🔴 FAILED", label: "FAILED",
icon: CircleX,
cls: "bg-red-100 text-red-800", cls: "bg-red-100 text-red-800",
}; };
const BadgeIcon = badge.icon;
return ( return (
<div <div
key={s.jobName} key={s.jobName}
@@ -196,8 +233,9 @@ export default async function AdminSystemPage() {
</p> </p>
</div> </div>
<span <span
className={`rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${badge.cls}`} className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${badge.cls}`}
> >
<BadgeIcon size={12} strokeWidth={2.25} aria-hidden />
{badge.label} {badge.label}
</span> </span>
</div> </div>
@@ -236,6 +274,38 @@ export default async function AdminSystemPage() {
</div> </div>
</section> </section>
<section className="mb-8">
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
Email
</h2>
<div className="grid gap-3 sm:grid-cols-3">
<EmailStat
label="Antri dikirim"
value={emailStats.queued}
tone={emailStats.queued > 0 ? "amber" : "ok"}
/>
<EmailStat
label="Gagal 24 jam"
value={emailStats.failed24h}
tone={emailStats.failed24h > 0 ? "red" : "ok"}
/>
<EmailStat
label="Perlu aksi manual"
value={emailStats.deadLetter}
tone={emailStats.deadLetter > 0 ? "red" : "ok"}
/>
</div>
<p className="mt-2 text-xs text-neutral-500">
<Link
href="/admin/emails"
className="inline-flex items-center gap-1 font-semibold text-primary-600 hover:underline"
>
Buka Email Log
<ArrowUpRight size={14} strokeWidth={2} aria-hidden />
</Link>
</p>
</section>
<section> <section>
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500"> <h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
Recent Runs (20 terakhir) Recent Runs (20 terakhir)
@@ -301,6 +371,37 @@ function truncate(s: string, max: number): string {
return s.length > max ? `${s.slice(0, max)}` : s; return s.length > max ? `${s.slice(0, max)}` : s;
} }
function EmailStat({
label,
value,
tone,
}: {
label: string;
value: number;
tone: "ok" | "amber" | "red";
}) {
const cls =
tone === "red"
? "border-red-200 bg-red-50/60"
: tone === "amber"
? "border-amber-200 bg-amber-50/60"
: "border-emerald-200 bg-emerald-50/50";
const valueCls =
tone === "red"
? "text-red-700"
: tone === "amber"
? "text-amber-700"
: "text-emerald-700";
return (
<div className={`rounded-2xl border p-4 shadow-sm ${cls}`}>
<p className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
{label}
</p>
<p className={`mt-1 text-2xl font-bold ${valueCls}`}>{value}</p>
</div>
);
}
function StatusBadge({ value }: { value: string }) { function StatusBadge({ value }: { value: string }) {
const cls = const cls =
value === "SUCCESS" value === "SUCCESS"
+26 -7
View File
@@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { ArrowLeft, CalendarDays, MapPin } from "lucide-react";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin"; import { isAdminEmail } from "@/lib/admin";
import { tripService } from "@/server/services/trip.service"; import { tripService } from "@/server/services/trip.service";
@@ -67,8 +68,12 @@ export default async function AdminTripDetailPage({ params }: PageProps) {
return ( return (
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12"> <div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
<div className="mb-4 text-xs text-neutral-500"> <div className="mb-4 text-xs text-neutral-500">
<Link href="/admin/trips" className="hover:text-primary-600"> <Link
Kembali ke list trips href="/admin/trips"
className="inline-flex items-center gap-1 hover:text-primary-600"
>
<ArrowLeft size={14} strokeWidth={2} aria-hidden />
Kembali ke list trips
</Link> </Link>
</div> </div>
@@ -84,9 +89,22 @@ export default async function AdminTripDetailPage({ params }: PageProps) {
<h1 className="text-xl font-bold text-neutral-900 sm:text-2xl"> <h1 className="text-xl font-bold text-neutral-900 sm:text-2xl">
{trip.title} {trip.title}
</h1> </h1>
<p className="mt-1 text-sm text-neutral-500"> <p className="mt-1 flex flex-wrap items-center gap-1 text-sm text-neutral-500">
📅 {formatTripCalendarDateRangeLong(trip.date, trip.endDate)} · <CalendarDays
📍 {trip.destination}, {trip.location} size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0"
/>
{formatTripCalendarDateRangeLong(trip.date, trip.endDate)}
<span aria-hidden>·</span>
<MapPin
size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0"
/>
{trip.destination}, {trip.location}
</p> </p>
<p className="mt-1 text-xs text-neutral-500"> <p className="mt-1 text-xs text-neutral-500">
Organizer:{" "} Organizer:{" "}
@@ -220,8 +238,9 @@ export default async function AdminTripDetailPage({ params }: PageProps) {
{p.user.name} {p.user.name}
</Link> </Link>
{p.user.profile?.city && ( {p.user.profile?.city && (
<span className="ml-2 text-[11px] text-neutral-500"> <span className="ml-2 inline-flex items-center gap-1 text-[11px] text-neutral-500">
📍 {p.user.profile.city} <MapPin size={12} strokeWidth={2} aria-hidden />
{p.user.profile.city}
</span> </span>
)} )}
</div> </div>
+17 -3
View File
@@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { CalendarDays, MapPin } from "lucide-react";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin"; import { isAdminEmail } from "@/lib/admin";
import { tripRepo } from "@/server/repositories/trip.repo"; import { tripRepo } from "@/server/repositories/trip.repo";
@@ -127,9 +128,22 @@ export default async function AdminTripsPage({ searchParams }: PageProps) {
<h2 className="truncate text-base font-bold text-neutral-900 sm:text-lg"> <h2 className="truncate text-base font-bold text-neutral-900 sm:text-lg">
{t.title} {t.title}
</h2> </h2>
<p className="mt-1 truncate text-xs text-neutral-500 sm:text-sm"> <p className="mt-1 flex items-center gap-1 truncate text-xs text-neutral-500 sm:text-sm">
📅 {formatTripCalendarDateRangeLong(t.date, t.endDate)} <CalendarDays
{" · "}📍 {t.location} size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0"
/>
{formatTripCalendarDateRangeLong(t.date, t.endDate)}
<span aria-hidden>·</span>
<MapPin
size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0"
/>
{t.location}
</p> </p>
<p className="mt-1 text-xs text-neutral-500 sm:text-sm"> <p className="mt-1 text-xs text-neutral-500 sm:text-sm">
Organizer:{" "} Organizer:{" "}
+16 -8
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { ArrowLeft, ArrowUpRight, Ban, Check } from "lucide-react";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin"; import { isAdminEmail } from "@/lib/admin";
import { userRepo } from "@/server/repositories/user.repo"; import { userRepo } from "@/server/repositories/user.repo";
@@ -38,8 +39,12 @@ export default async function AdminUserDetailPage({ params }: PageProps) {
return ( return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:py-12"> <div className="mx-auto max-w-4xl px-4 py-8 sm:py-12">
<div className="mb-4 text-xs text-neutral-500"> <div className="mb-4 text-xs text-neutral-500">
<Link href="/admin/users" className="hover:text-primary-600"> <Link
Kembali ke list users href="/admin/users"
className="inline-flex items-center gap-1 hover:text-primary-600"
>
<ArrowLeft size={14} strokeWidth={2} aria-hidden />
Kembali ke list users
</Link> </Link>
</div> </div>
@@ -75,8 +80,9 @@ export default async function AdminUserDetailPage({ params }: PageProps) {
</span> </span>
)} )}
{user.organizerVerification?.status === "APPROVED" && ( {user.organizerVerification?.status === "APPROVED" && (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-emerald-800"> <span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-emerald-800">
Verified Organizer <Check size={12} strokeWidth={2.5} aria-hidden />
Verified Organizer
</span> </span>
)} )}
</div> </div>
@@ -122,8 +128,9 @@ export default async function AdminUserDetailPage({ params }: PageProps) {
{user.suspended && ( {user.suspended && (
<section className="mb-6 rounded-2xl border border-red-300 bg-red-50 p-4 sm:p-5"> <section className="mb-6 rounded-2xl border border-red-300 bg-red-50 p-4 sm:p-5">
<h2 className="text-sm font-bold text-red-900"> <h2 className="flex items-center gap-1.5 text-sm font-bold text-red-900">
Akun ditangguhkan <Ban size={16} strokeWidth={2} aria-hidden />
Akun ditangguhkan
</h2> </h2>
<p className="mt-1 text-xs text-red-900/80"> <p className="mt-1 text-xs text-red-900/80">
{user.suspendedReason ?? "Tidak ada alasan tercatat."} {user.suspendedReason ?? "Tidak ada alasan tercatat."}
@@ -244,9 +251,10 @@ export default async function AdminUserDetailPage({ params }: PageProps) {
{" · "} {" · "}
<Link <Link
href={`/admin/verifications?tab=${user.organizerVerification.status}`} href={`/admin/verifications?tab=${user.organizerVerification.status}`}
className="text-secondary-700 hover:text-secondary-900" className="inline-flex items-center gap-1 text-secondary-700 hover:text-secondary-900"
> >
Buka di /admin/verifications Buka di /admin/verifications
<ArrowUpRight size={14} strokeWidth={2} aria-hidden />
</Link> </Link>
</p> </p>
{user.organizerVerification.rejectionReason && ( {user.organizerVerification.rejectionReason && (
+7 -4
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { Check, ChartColumn } from "lucide-react";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin"; import { isAdminEmail } from "@/lib/admin";
import { userRepo } from "@/server/repositories/user.repo"; import { userRepo } from "@/server/repositories/user.repo";
@@ -56,9 +57,10 @@ export default async function AdminUsersPage({ searchParams }: PageProps) {
</div> </div>
<Link <Link
href="/admin/users/stats" href="/admin/users/stats"
className="rounded-xl border border-neutral-200 bg-white px-3 py-1.5 text-xs font-semibold text-neutral-700 hover:bg-neutral-50" 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"
> >
📊 Stats <ChartColumn size={16} strokeWidth={2} aria-hidden />
Stats
</Link> </Link>
</header> </header>
@@ -147,8 +149,9 @@ export default async function AdminUsersPage({ searchParams }: PageProps) {
</span> </span>
)} )}
{u.organizerVerification?.status === "APPROVED" && ( {u.organizerVerification?.status === "APPROVED" && (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-emerald-800"> <span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-emerald-800">
Organizer <Check size={12} strokeWidth={2.5} aria-hidden />
Organizer
</span> </span>
)} )}
</div> </div>
+7 -2
View File
@@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { ArrowLeft } from "lucide-react";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin"; import { isAdminEmail } from "@/lib/admin";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -97,8 +98,12 @@ export default async function AdminUserStatsPage() {
return ( return (
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12"> <div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
<div className="mb-4 text-xs text-neutral-500"> <div className="mb-4 text-xs text-neutral-500">
<Link href="/admin/users" className="hover:text-primary-600"> <Link
Kembali ke list users href="/admin/users"
className="inline-flex items-center gap-1 hover:text-primary-600"
>
<ArrowLeft size={14} strokeWidth={2} aria-hidden />
Kembali ke list users
</Link> </Link>
</div> </div>
+74
View File
@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from "next/server";
import { runCron } from "@/lib/cron-runner";
import { prisma } from "@/lib/prisma";
import {
deleteTripImage,
listTripImageNames,
tripImageMtime,
TRIP_IMAGE_URL_PREFIX,
} from "@/lib/trip-image-storage";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/** File yang lebih tua dari ini & tak direferensikan DB dianggap yatim. */
const ORPHAN_AGE_MS = 24 * 60 * 60 * 1000;
/**
* Cron — hapus file gambar trip yatim.
*
* Form create-trip multi-step mengunggah foto SEBELUM trip tersimpan; kalau
* user menutup form di tengah jalan, file menggantung di disk tanpa pernah
* jadi `TripImage`. Sweep ini menghapus file >24 jam yang tidak direferensikan
* `TripImage` mana pun. Idempotent — aman dijalankan berulang.
*
* Trigger: lihat docs/CRON_SETUP.md. Header wajib `Authorization: Bearer ${CRON_SECRET}`.
*/
export async function GET(req: NextRequest) {
const secret = process.env.CRON_SECRET;
if (!secret) {
console.error("[cron/cleanup-trip-images] CRON_SECRET tidak di-set");
return NextResponse.json(
{ error: "Server misconfigured" },
{ status: 500 }
);
}
if (req.headers.get("authorization") !== `Bearer ${secret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const outcome = await runCron("cleanup-trip-images", async () => {
const names = await listTripImageNames();
if (names.length === 0) return { scanned: 0, deleted: 0 };
const referenced = await prisma.tripImage.findMany({
where: { url: { startsWith: TRIP_IMAGE_URL_PREFIX } },
select: { url: true },
});
const referencedNames = new Set(
referenced.map((r) => r.url.slice(TRIP_IMAGE_URL_PREFIX.length))
);
const now = Date.now();
let deleted = 0;
for (const name of names) {
if (referencedNames.has(name)) continue;
const mtime = await tripImageMtime(name);
// File baru di-upload tapi trip belum tersimpan → beri tenggang 24 jam.
if (!mtime || now - mtime.getTime() < ORPHAN_AGE_MS) continue;
await deleteTripImage(name);
deleted++;
}
return { scanned: names.length, deleted };
});
if (!outcome.ok) {
console.error("[cron/cleanup-trip-images] gagal", outcome.error);
return NextResponse.json(
{ error: "Gagal menjalankan cleanup" },
{ status: 500 }
);
}
console.log("[cron/cleanup-trip-images] selesai", outcome.payload);
return NextResponse.json({ ok: true, ...outcome.payload });
}
+36
View File
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { emailService } from "@/lib/email/send";
import { runCron } from "@/lib/cron-runner";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Cron — proses retry queue email (jobs status PENDING/FAILED dengan
* attempts<5 dan scheduledAt sudah lewat).
*
* Trigger setiap 5 menit via system crontab — lihat docs/CRON_SETUP.md.
* Header wajib: `Authorization: Bearer ${CRON_SECRET}`.
*/
export async function GET(req: NextRequest) {
const secret = process.env.CRON_SECRET;
if (!secret) {
return NextResponse.json(
{ error: "Server misconfigured (CRON_SECRET)" },
{ status: 500 }
);
}
const authHeader = req.headers.get("authorization");
if (authHeader !== `Bearer ${secret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const outcome = await runCron("process-email-jobs", async () => {
return emailService.processQueue(50);
});
if (!outcome.ok) {
return NextResponse.json({ error: outcome.error }, { status: 500 });
}
return NextResponse.json({ ok: true, ...outcome.payload });
}
+38
View File
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { isValidTripImageName, readTripImage } from "@/lib/trip-image-storage";
export const runtime = "nodejs";
interface RouteCtx {
params: Promise<{ name: string }>;
}
/**
* Sajikan gambar trip dari disk lokal. Publik — gambar trip memang tampil ke
* semua pengunjung. Di-cache `immutable` selama setahun: nama file
* content-addressed (hex acak), jadi konten untuk satu nama tidak pernah
* berubah. Beban render = baca file kecil dari disk, tanpa fetch eksternal.
*/
export async function GET(_req: NextRequest, ctx: RouteCtx) {
const { name } = await ctx.params;
if (!isValidTripImageName(name)) {
return NextResponse.json({ error: "Tidak ditemukan" }, { status: 404 });
}
let data: Buffer;
try {
data = await readTripImage(name);
} catch {
return NextResponse.json({ error: "Tidak ditemukan" }, { status: 404 });
}
return new NextResponse(new Uint8Array(data), {
status: 200,
headers: {
"Content-Type": "image/webp",
"Content-Length": String(data.length),
"Cache-Control": "public, max-age=31536000, immutable",
"X-Content-Type-Options": "nosniff",
},
});
}
+73
View File
@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { requireActiveUser } from "@/lib/auth-guards";
import {
ALLOWED_TRIP_IMAGE_MIME,
MAX_TRIP_IMAGE_UPLOAD_BYTES,
processAndSaveTripImage,
} from "@/lib/trip-image-storage";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Upload satu foto trip. Dipanggil dari form create-trip saat user memilih
* file — gambar langsung dikompres & disimpan, route mengembalikan URL publik
* yang nanti ikut disubmit bersama data trip.
*
* File yatim (di-upload tapi trip batal dibuat) dibersihkan cron
* `/api/cron/cleanup-trip-images`.
*/
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
await requireActiveUser(session.user.id);
} catch (err) {
return NextResponse.json(
{ error: (err as Error).message },
{ status: 403 }
);
}
let form: FormData;
try {
form = await req.formData();
} catch {
return NextResponse.json(
{ error: "Body bukan multipart/form-data" },
{ status: 400 }
);
}
const file = form.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "File wajib diisi" }, { status: 400 });
}
if (!ALLOWED_TRIP_IMAGE_MIME.has(file.type)) {
return NextResponse.json(
{ error: "Hanya menerima JPG, PNG, atau WebP" },
{ status: 415 }
);
}
if (file.size > MAX_TRIP_IMAGE_UPLOAD_BYTES) {
return NextResponse.json(
{ error: "Ukuran file maksimal 12MB" },
{ status: 413 }
);
}
try {
const buf = Buffer.from(await file.arrayBuffer());
const saved = await processAndSaveTripImage(buf);
return NextResponse.json({ url: saved.url, size: saved.size });
} catch (err) {
return NextResponse.json(
{ error: (err as Error).message || "Gagal memproses gambar" },
{ status: 400 }
);
}
}
+53
View File
@@ -64,6 +64,16 @@ select:focus {
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1) !important; box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1) !important;
} }
/* Input wrapper isi penuh lebar parent + popper di atas konten lain */
.react-datepicker-wrapper,
.react-datepicker__input-container {
width: 100% !important;
}
.react-datepicker-popper {
z-index: 50 !important;
}
.react-datepicker__header { .react-datepicker__header {
background: #f9fafb !important; background: #f9fafb !important;
border-bottom: 1px solid #e5e7eb !important; border-bottom: 1px solid #e5e7eb !important;
@@ -132,3 +142,46 @@ select:focus {
.react-datepicker__close-icon:hover::after { .react-datepicker__close-icon:hover::after {
background-color: #16a34a !important; background-color: #16a34a !important;
} }
/* Dropdown bulan / tahun (mode select) */
.react-datepicker__month-select,
.react-datepicker__year-select {
border: 1px solid #e5e7eb !important;
border-radius: 0.5rem !important;
padding: 2px 4px !important;
font-size: 0.8125rem !important;
background: #fff !important;
}
/* Pemilih jam (showTimeSelectOnly) */
.react-datepicker__time-container {
width: 96px !important;
}
.react-datepicker__time-container
.react-datepicker__time
.react-datepicker__time-box {
width: 96px !important;
}
.react-datepicker-time__header {
font-weight: 700 !important;
color: #1f2937 !important;
font-size: 0.8125rem !important;
}
.react-datepicker__time-list-item {
font-size: 0.8125rem !important;
color: #1f2937 !important;
}
.react-datepicker__time-list-item:hover {
background: #dcfce7 !important;
color: #15803d !important;
}
.react-datepicker__time-list-item--selected {
background: #16a34a !important;
color: #fff !important;
font-weight: 700 !important;
}
+31
View File
@@ -0,0 +1,31 @@
import type { MetadataRoute } from "next";
import { siteConfig } from "@/lib/site";
/**
* Web app manifest — dideteksi otomatis oleh Next App Router (`<link
* rel="manifest">` di-inject). Mendukung "Add to Home Screen" di mobile.
*/
export default function manifest(): MetadataRoute.Manifest {
return {
name: `${siteConfig.name}${siteConfig.slogan}`,
short_name: siteConfig.name,
description: siteConfig.description,
start_url: "/",
display: "standalone",
background_color: "#f9fafb",
theme_color: "#16a34a",
icons: [
{
src: "/images/SeTrip.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
{
src: "/SeTrip.ico",
sizes: "any",
type: "image/x-icon",
},
],
};
}
+8 -1
View File
@@ -7,7 +7,14 @@ export default function robots(): MetadataRoute.Robots {
{ {
userAgent: "*", userAgent: "*",
allow: "/", allow: "/",
disallow: ["/api/", "/profile", "/create-trip"], disallow: [
"/api/",
"/admin",
"/profile",
"/create-trip",
"/verify",
"/trips/*/payment",
],
}, },
], ],
sitemap: absoluteUrl("/sitemap.xml"), sitemap: absoluteUrl("/sitemap.xml"),
+6
View File
@@ -24,6 +24,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
changeFrequency: "hourly", changeFrequency: "hourly",
priority: 0.9, priority: 0.9,
}, },
{
url: absoluteUrl("/people"),
lastModified: now,
changeFrequency: "daily",
priority: 0.7,
},
{ {
url: absoluteUrl("/register"), url: absoluteUrl("/register"),
lastModified: now, lastModified: now,
+30 -19
View File
@@ -5,17 +5,33 @@ import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import Image from "next/image"; import Image from "next/image";
import { signOut } from "next-auth/react"; import { signOut } from "next-auth/react";
import {
ArrowLeft,
ArrowUpRight,
Banknote,
Compass,
IdCard,
LayoutDashboard,
Mail,
Menu,
ScrollText,
Settings,
Users,
X,
type LucideIcon,
} from "lucide-react";
import { AdminSearchBar } from "@/features/admin/components/admin-search-bar"; import { AdminSearchBar } from "@/features/admin/components/admin-search-bar";
const NAV_ITEMS: { href: string; label: string; icon: string }[] = [ const NAV_ITEMS: { href: string; label: string; icon: LucideIcon }[] = [
{ href: "/admin", label: "Dashboard", icon: "📊" }, { href: "/admin", label: "Dashboard", icon: LayoutDashboard },
{ href: "/admin/trips", label: "Trips", icon: "🧭" }, { href: "/admin/trips", label: "Trips", icon: Compass },
{ href: "/admin/users", label: "Users", icon: "👥" }, { href: "/admin/users", label: "Users", icon: Users },
{ href: "/admin/verifications", label: "Verifikasi", icon: "🪪" }, { href: "/admin/verifications", label: "Verifikasi", icon: IdCard },
{ href: "/admin/refunds", label: "Refund", icon: "↩️" }, { href: "/admin/refunds", label: "Refund", icon: ArrowLeft },
{ href: "/admin/payouts", label: "Payout", icon: "💸" }, { href: "/admin/payouts", label: "Payout", icon: Banknote },
{ href: "/admin/audit-log", label: "Audit Log", icon: "📜" }, { href: "/admin/emails", label: "Email", icon: Mail },
{ href: "/admin/system", label: "System", icon: "⚙️" }, { href: "/admin/audit-log", label: "Audit Log", icon: ScrollText },
{ href: "/admin/system", label: "System", icon: Settings },
]; ];
interface AdminSidebarProps { interface AdminSidebarProps {
@@ -50,13 +66,9 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
aria-expanded={open} aria-expanded={open}
> >
{open ? ( {open ? (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> <X size={20} strokeWidth={2} aria-hidden />
<path d="M5 5l10 10M15 5L5 15" />
</svg>
) : ( ) : (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> <Menu size={20} strokeWidth={2} aria-hidden />
<path d="M3 5h14M3 10h14M3 15h14" />
</svg>
)} )}
</button> </button>
</header> </header>
@@ -105,6 +117,7 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
const isActive = const isActive =
pathname === item.href || pathname === item.href ||
(item.href !== "/admin" && pathname?.startsWith(item.href)); (item.href !== "/admin" && pathname?.startsWith(item.href));
const Icon = item.icon;
return ( return (
<li key={item.href}> <li key={item.href}>
<Link <Link
@@ -116,9 +129,7 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
: "text-neutral-700 hover:bg-neutral-100" : "text-neutral-700 hover:bg-neutral-100"
}`} }`}
> >
<span aria-hidden className="text-base"> <Icon size={20} strokeWidth={1.75} aria-hidden />
{item.icon}
</span>
<span>{item.label}</span> <span>{item.label}</span>
</Link> </Link>
</li> </li>
@@ -135,7 +146,7 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
className="flex items-center gap-3 rounded-lg px-3 py-2 text-xs font-medium text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700" className="flex items-center gap-3 rounded-lg px-3 py-2 text-xs font-medium text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700"
> >
<span aria-hidden></span> <ArrowUpRight size={16} strokeWidth={1.75} aria-hidden />
<span>Lihat situs publik</span> <span>Lihat situs publik</span>
</Link> </Link>
</li> </li>
+255
View File
@@ -0,0 +1,255 @@
"use client";
/**
* Komponen pemilih tanggal & jam bersama — satu-satunya tempat aplikasi
* memakai `react-datepicker`. Semua field tanggal/jam (form & filter) harus
* lewat sini supaya tampilan + locale konsisten.
*
* - `DateField` → satu tanggal (controlled atau uncontrolled untuk form).
* - `DateRangeField` → rentang tanggal (berangkatpulang, filter).
* - `TimeField` → jam "HH:mm" (itinerary).
*
* Tema visual di-override di `app/globals.css` (blok `.react-datepicker`).
*/
import { useState } from "react";
import ReactDatePicker, { registerLocale } from "react-datepicker";
import { id as idLocale } from "date-fns/locale";
import "react-datepicker/dist/react-datepicker.css";
import {
formatLocalCalendarYmd,
localCalendarDateFromYmd,
} from "@/lib/trip-dates";
import { isValidTimeFormat } from "@/lib/itinerary";
registerLocale("id", idLocale);
const FIELD_CLS =
"w-full rounded-xl border border-neutral-200 bg-neutral-50 py-2.5 pl-9 pr-3 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-primary-500 focus:bg-white";
function CalendarIcon() {
return (
<span className="pointer-events-none absolute left-3 top-1/2 z-10 -translate-y-1/2 text-neutral-400">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4"
>
<path
fillRule="evenodd"
d="M5.75 2a.75.75 0 01.75.75V4h7V2.75a.75.75 0 011.5 0V4h.25A2.75 2.75 0 0118 6.75v8.5A2.75 2.75 0 0115.25 18H4.75A2.75 2.75 0 012 15.25v-8.5A2.75 2.75 0 014.75 4H5V2.75A.75.75 0 015.75 2zm-1 5.5c-.69 0-1.25.56-1.25 1.25v6.5c0 .69.56 1.25 1.25 1.25h10.5c.69 0 1.25-.56 1.25-1.25v-6.5c0-.69-.56-1.25-1.25-1.25H4.75z"
clipRule="evenodd"
/>
</svg>
</span>
);
}
function ClockIcon() {
return (
<span className="pointer-events-none absolute left-3 top-1/2 z-10 -translate-y-1/2 text-neutral-400">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm.75-13a.75.75 0 00-1.5 0v5c0 .284.16.544.415.67l3 1.5a.75.75 0 00.67-1.34L10.75 9.54V5z"
clipRule="evenodd"
/>
</svg>
</span>
);
}
interface DateFieldProps {
/** Mode controlled. Kalau `undefined`, komponen jalan uncontrolled. */
value?: Date | null;
/** Nilai awal untuk mode uncontrolled (mis. di dalam form GET/POST biasa). */
defaultValue?: Date | null;
/**
* Nilai awal uncontrolled berupa `YYYY-MM-DD`. Dipakai saat parent adalah
* Server Component (mis. filter admin) — string di-parse di browser supaya
* tidak ada pergeseran timezone server↔klien.
*/
defaultValueYmd?: string;
onChange?: (date: Date | null) => void;
/** Kalau diisi, render hidden input `YYYY-MM-DD` supaya ikut ter-submit form. */
name?: string;
id?: string;
minDate?: Date;
maxDate?: Date;
placeholder?: string;
disabled?: boolean;
required?: boolean;
/** Dropdown bulan + tahun — cocok untuk tanggal lahir. */
withMonthYearDropdown?: boolean;
}
/** Pemilih satu tanggal. */
export function DateField({
value,
defaultValue = null,
defaultValueYmd,
onChange,
name,
id,
minDate,
maxDate,
placeholder = "Pilih tanggal...",
disabled = false,
required = false,
withMonthYearDropdown = false,
}: DateFieldProps) {
const isControlled = value !== undefined;
const [internal, setInternal] = useState<Date | null>(
() => (defaultValueYmd ? localCalendarDateFromYmd(defaultValueYmd) : defaultValue)
);
const current = isControlled ? value : internal;
function handleChange(date: Date | null) {
if (!isControlled) setInternal(date);
onChange?.(date);
}
// Default rentang masuk akal untuk picker bulan/tahun (mis. tanggal lahir).
const effectiveMin =
minDate ??
(withMonthYearDropdown
? new Date(new Date().getFullYear() - 120, 0, 1)
: undefined);
return (
<div className="relative">
<CalendarIcon />
<ReactDatePicker
id={id}
selected={current ?? null}
onChange={handleChange}
locale="id"
dateFormat="dd MMM yyyy"
minDate={effectiveMin}
maxDate={maxDate}
disabled={disabled}
required={required}
placeholderText={placeholder}
isClearable={!required && !disabled}
showMonthDropdown={withMonthYearDropdown}
showYearDropdown={withMonthYearDropdown}
dropdownMode="select"
className={FIELD_CLS}
wrapperClassName="w-full"
/>
{name && (
<input
type="hidden"
name={name}
value={current ? formatLocalCalendarYmd(current) : ""}
/>
)}
</div>
);
}
interface DateRangeFieldProps {
startDate: Date | null;
endDate: Date | null;
onChange: (start: Date | null, end: Date | null) => void;
minDate?: Date;
placeholder?: string;
id?: string;
}
/** Pemilih rentang tanggal (berangkatpulang, filter). */
export function DateRangeField({
startDate,
endDate,
onChange,
minDate,
placeholder = "Pilih tanggal...",
id,
}: DateRangeFieldProps) {
return (
<div className="relative">
<CalendarIcon />
<ReactDatePicker
id={id}
selectsRange
startDate={startDate}
endDate={endDate}
onChange={(dates) => {
const [start, end] = dates as [Date | null, Date | null];
onChange(start, end);
}}
locale="id"
dateFormat="dd MMM yyyy"
minDate={minDate}
isClearable
placeholderText={placeholder}
className={FIELD_CLS}
wrapperClassName="w-full"
/>
</div>
);
}
function timeStringToDate(value: string): Date | null {
if (!isValidTimeFormat(value)) return null;
const [h, m] = value.split(":").map(Number);
const d = new Date();
d.setHours(h, m, 0, 0);
return d;
}
function dateToTimeString(d: Date): string {
const h = String(d.getHours()).padStart(2, "0");
const m = String(d.getMinutes()).padStart(2, "0");
return `${h}:${m}`;
}
interface TimeFieldProps {
/** Jam dalam format "HH:mm", atau "" kalau kosong. */
value: string;
onChange: (value: string) => void;
id?: string;
placeholder?: string;
disabled?: boolean;
/** Tampilkan tombol clear (untuk jam opsional, mis. jam selesai). */
clearable?: boolean;
}
/** Pemilih jam "HH:mm" 24-jam dengan interval 15 menit. */
export function TimeField({
value,
onChange,
id,
placeholder = "--:--",
disabled = false,
clearable = false,
}: TimeFieldProps) {
return (
<div className="relative">
<ClockIcon />
<ReactDatePicker
id={id}
selected={timeStringToDate(value)}
onChange={(d: Date | null) => onChange(d ? dateToTimeString(d) : "")}
showTimeSelect
showTimeSelectOnly
timeIntervals={15}
timeCaption="Jam"
dateFormat="HH:mm"
timeFormat="HH:mm"
locale="id"
disabled={disabled}
isClearable={clearable && !disabled}
placeholderText={placeholder}
className={FIELD_CLS}
wrapperClassName="w-full"
/>
</div>
);
}
+3 -22
View File
@@ -4,6 +4,7 @@ import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { useSession, signOut } from "next-auth/react"; import { useSession, signOut } from "next-auth/react";
import { Menu, X } from "lucide-react";
export function Navbar() { export function Navbar() {
const { data: session } = useSession(); const { data: session } = useSession();
@@ -109,29 +110,9 @@ export function Navbar() {
aria-label="Toggle menu" aria-label="Toggle menu"
> >
{menuOpen ? ( {menuOpen ? (
<svg <X size={20} strokeWidth={1.75} aria-hidden />
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M5 5l10 10M15 5L5 15" />
</svg>
) : ( ) : (
<svg <Menu size={20} strokeWidth={1.75} aria-hidden />
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M3 5h14M3 10h14M3 15h14" />
</svg>
)} )}
</button> </button>
</div> </div>
+8 -11
View File
@@ -1,23 +1,20 @@
import { BadgeCheck } from "lucide-react";
type Size = "sm" | "md"; type Size = "sm" | "md";
export function VerifiedBadge({ size = "sm" }: { size?: Size }) { export function VerifiedBadge({ size = "sm" }: { size?: Size }) {
const cls = const cls =
size === "md" size === "md" ? "px-2.5 py-1 text-xs" : "px-2 py-0.5 text-[10px]";
? "px-2.5 py-1 text-xs"
: "px-2 py-0.5 text-[10px]";
return ( return (
<span <span
className={`inline-flex items-center gap-1 rounded-full bg-primary-50 font-semibold text-primary-700 ring-1 ring-primary-200 ${cls}`} className={`inline-flex items-center gap-1 rounded-full bg-primary-50 font-semibold text-primary-700 ring-1 ring-primary-200 ${cls}`}
title="Organizer terverifikasi SeTrip" title="Organizer terverifikasi SeTrip"
> >
<svg <BadgeCheck
viewBox="0 0 16 16" size={size === "md" ? 14 : 12}
fill="currentColor" strokeWidth={1.75}
className={size === "md" ? "h-3.5 w-3.5" : "h-3 w-3"} aria-hidden
aria-hidden="true" />
>
<path d="M8 0l2.09 1.74L12.86 1.5l.64 2.78 2.5 1.5-1.5 2.5.5 2.86-2.78.64-1.5 2.5-2.72-.59L5.5 14.5 4 12 1.5 11.36 2 8.5.5 6 3 4.5l.64-2.78 2.77.24L8 0zm-1.07 9.4l4.6-4.6-1.06-1.06-3.54 3.54-1.41-1.42-1.06 1.06 2.47 2.48z" />
</svg>
Verified Verified
</span> </span>
); );
+175 -21
View File
@@ -2,13 +2,23 @@
Aplikasi punya beberapa endpoint cron yang harus di-trigger periodik dari luar. Karena deploy pakai PM2 di VPS Linux (bukan Vercel), trigger schedule pakai **system crontab** — zero dependency, OS-native. Aplikasi punya beberapa endpoint cron yang harus di-trigger periodik dari luar. Karena deploy pakai PM2 di VPS Linux (bukan Vercel), trigger schedule pakai **system crontab** — zero dependency, OS-native.
> **Audit trail otomatis:** semua cron yang di-wrap `runCron()` helper auto-log ke tabel `CronRun` (start/finish/error). Cek hasilnya real-time di `/admin/system` — link "System" di sidebar admin. Tidak perlu tail log untuk monitoring rutin.
---
## Daftar cron job ## Daftar cron job
| Endpoint | Schedule | Tujuan | | # | Endpoint | Schedule | Frekuensi | Tujuan |
|---|---|---| |---|---|---|---|---|
| `GET /api/cron/auto-complete-trips` | `0 18 * * *` (18:00 UTC = 01:00 WIB) | Flip trip yang sudah lewat tanggal selesai dari `OPEN`/`FULL` ke `COMPLETED`. | | 1 | `GET /api/cron/auto-complete-trips` | `0 18 * * *` | Daily 01:00 WIB (18:00 UTC) | Flip trip yang sudah lewat tanggal selesai dari `OPEN`/`FULL` ke `COMPLETED`. Setelah itu, release payout HELD yang sudah lewat `heldUntil`. |
| 2 | `GET /api/cron/process-email-jobs` | `*/5 * * * *` | Setiap 5 menit | Drain retry queue email — pick `EmailJob` status `PENDING`/`FAILED` (attempts<5), retry via Resend dengan exponential backoff. |
| 3 | `GET /api/cron/cleanup-trip-images` | `30 18 * * *` | Daily 01:30 WIB (18:30 UTC) | Hapus file gambar trip yatim — foto yang di-upload di form create-trip tapi trip-nya batal dibuat. Hanya file >24 jam yang tak direferensikan `TripImage`. |
## Setup di server Semua cron pakai pola yang sama: header `Authorization: Bearer ${CRON_SECRET}`, idempotent, auto-log ke `CronRun`. Tambah cron baru = tambah baris di tabel ini + tabel `TRACKED_JOBS` di [app/admin/system/page.tsx](../app/admin/system/page.tsx).
---
## Setup di server (one-time)
### 1. Set `CRON_SECRET` di env production ### 1. Set `CRON_SECRET` di env production
@@ -30,7 +40,17 @@ Restart PM2 supaya proses re-load env:
pm2 restart setrip --update-env pm2 restart setrip --update-env
``` ```
### 2. Daftarkan crontab ### 2. Set env opsional untuk fitur lain yang di-trigger cron
| Env | Dibutuhkan oleh | Akibat kalau kosong |
|---|---|---|
| `RESEND_API_KEY` | `process-email-jobs` cron | Email tetap di-queue di DB; cron skip dengan warning. Set nanti dan cron akan auto-drain queue. |
| `EMAIL_FROM` | `process-email-jobs` cron | Pakai default `SeTrip <onboarding@resend.dev>` (cocok untuk dev/test). |
| `ADMIN_ALERT_WEBHOOK_URL` | `runCron` (semua cron) | Tidak ada Discord push notif saat cron FAILED. Admin tetap bisa cek manual di `/admin/system`. |
Semua env ini ada di [.env.example](../.env.example) dengan instruksi setup masing-masing.
### 3. Daftarkan crontab
Edit crontab user yang punya akses ke env (biasanya user yang menjalankan PM2): Edit crontab user yang punya akses ke env (biasanya user yang menjalankan PM2):
@@ -38,11 +58,19 @@ Edit crontab user yang punya akses ke env (biasanya user yang menjalankan PM2):
crontab -e crontab -e
``` ```
Tambah baris (ganti `https://your-domain.com` dan `<CRON_SECRET>` sesuai env yang di-set di step 1): Tambah baris berikut (ganti `https://your-domain.com` dan `<CRON_SECRET>` sesuai env yang di-set di step 1):
```cron ```cron
# Setrip — auto-complete trips harian (jam 01:00 WIB) # === Setrip cron jobs ===
# 1. Auto-complete trip + release payout (daily 01:00 WIB)
0 18 * * * curl -fsS -H "Authorization: Bearer <CRON_SECRET>" https://your-domain.com/api/cron/auto-complete-trips >> /var/log/setrip-cron.log 2>&1 0 18 * * * curl -fsS -H "Authorization: Bearer <CRON_SECRET>" https://your-domain.com/api/cron/auto-complete-trips >> /var/log/setrip-cron.log 2>&1
# 2. Drain email retry queue (setiap 5 menit)
*/5 * * * * curl -fsS -H "Authorization: Bearer <CRON_SECRET>" https://your-domain.com/api/cron/process-email-jobs >> /var/log/setrip-cron.log 2>&1
# 3. Bersihkan gambar trip yatim (daily 01:30 WIB)
30 18 * * * curl -fsS -H "Authorization: Bearer <CRON_SECRET>" https://your-domain.com/api/cron/cleanup-trip-images >> /var/log/setrip-cron.log 2>&1
``` ```
Verifikasi crontab tersimpan: Verifikasi crontab tersimpan:
@@ -51,57 +79,183 @@ Verifikasi crontab tersimpan:
crontab -l crontab -l
``` ```
### 3. Siapkan file log ### 4. Siapkan file log
```bash ```bash
sudo touch /var/log/setrip-cron.log sudo touch /var/log/setrip-cron.log
sudo chown $(whoami) /var/log/setrip-cron.log sudo chown $(whoami) /var/log/setrip-cron.log
``` ```
## Test manual Optional — logrotate supaya log tidak menggemuk:
```bash
sudo nano /etc/logrotate.d/setrip-cron
```
Isi:
```
/var/log/setrip-cron.log {
weekly
rotate 4
compress
missingok
notifempty
}
```
---
## Test manual (sanity check setelah deploy)
Sebelum tunggu schedule, panggil endpoint langsung untuk verifikasi: Sebelum tunggu schedule, panggil endpoint langsung untuk verifikasi:
```bash ```bash
# Test cron 1
curl -fsS -H "Authorization: Bearer $CRON_SECRET" https://your-domain.com/api/cron/auto-complete-trips curl -fsS -H "Authorization: Bearer $CRON_SECRET" https://your-domain.com/api/cron/auto-complete-trips
# Test cron 2
curl -fsS -H "Authorization: Bearer $CRON_SECRET" https://your-domain.com/api/cron/process-email-jobs
``` ```
**Expected response:** **Expected response per cron:**
- Belum ada trip yang lewat: `{"ok":true,"completed":0,"ids":[]}` | Cron | Sukses kosong | Sukses ada pekerjaan |
- Ada trip yang lewat: `{"ok":true,"completed":2,"ids":["clx...","cly..."]}` |---|---|---|
| `auto-complete-trips` | `{"ok":true,"completed":0,"ids":[],"payoutsReleased":[]}` | `{"ok":true,"completed":2,"ids":["clx...","cly..."],"payoutsReleased":["..."]}` |
| `process-email-jobs` | `{"ok":true,"picked":0,"succeeded":0,"failed":0}` | `{"ok":true,"picked":5,"succeeded":5,"failed":0}` |
| `cleanup-trip-images` | `{"ok":true,"scanned":0,"deleted":0}` | `{"ok":true,"scanned":12,"deleted":3}` |
**Kalau dapat 401:** `CRON_SECRET` di env tidak match dengan header. Cek `pm2 env <id>`. **Error response:**
- **401** — `CRON_SECRET` di env tidak match dengan header. Cek `pm2 env <id>`.
- **500 "Server misconfigured"** — `CRON_SECRET` belum di-set di env.
- **500 lain** — cek log app atau `/admin/system` untuk detail error.
**Kalau dapat 500:** `CRON_SECRET` belum di-set di env. ---
## Monitoring ## Monitoring
Tail log cron: ### Cara utama: `/admin/system` (admin panel)
Buka [/admin/system](/admin/system) — tampilkan:
- **Per-job summary card**: last run, last success, count 7 hari, error count 7 hari, health badge (🟢 OK / 🟡 STALE / 🔴 FAILED).
- **Recent runs table**: 20 cron run terakhir lintas semua job (waktu, status, payload, error).
- **Stale state alerts**: banner kuning kalau ada Payment AWAITING > 25h / Payout HELD overdue / Refund APPROVED > 7d.
Cek setiap pagi sebelum mulai kerja — kalau semua 🟢 OK, tidak ada incident.
### Cara backup: tail log file
```bash ```bash
tail -f /var/log/setrip-cron.log tail -f /var/log/setrip-cron.log
``` ```
Cek log app PM2 (untuk `console.log` dari endpoint): PM2 log untuk `console.log` dari endpoint:
```bash ```bash
pm2 logs setrip --lines 100 | grep cron pm2 logs setrip --lines 100 | grep cron
``` ```
### Discord push notif (opsional)
Kalau env `ADMIN_ALERT_WEBHOOK_URL` di-set ke Discord webhook URL, `runCron` otomatis kirim 🚨 message saat cron FAILED — admin bisa react langsung tanpa harus cek dashboard.
Cara setup: Discord channel → Edit Channel → Integrations → Webhooks → New → copy URL → set di env.
---
## Troubleshooting ## Troubleshooting
**Cron jalan tapi tidak ada efek di DB:** **Cron jalan tapi tidak ada efek di DB:**
- Cek `pm2 logs setrip` untuk error. - Cek `/admin/system` — kalau status SUCCESS dengan `payload: { completed: 0 }`, memang tidak ada pekerjaan saat ini.
- Verifikasi waktu server: `date` (output harus UTC kalau pakai schedule UTC). - Cek `pm2 logs setrip` untuk error runtime.
- Verifikasi waktu server: `date -u` (output harus UTC kalau pakai schedule UTC).
**Cron tidak jalan sama sekali:** **Cron tidak jalan sama sekali:**
- Cek service cron aktif: `systemctl status cron` (Debian/Ubuntu) atau `systemctl status crond` (RHEL/CentOS). - Cek service cron aktif: `systemctl status cron` (Debian/Ubuntu) atau `systemctl status crond` (RHEL/CentOS).
- Cek crontab terdaftar di user yang benar: `sudo crontab -u $(whoami) -l`. - Cek crontab terdaftar di user yang benar: `sudo crontab -u $(whoami) -l`.
- Cek `/admin/system` — kalau "Last run" jauh dari ekspektasi (mis. > 25 jam untuk daily cron), schedule mungkin tidak ke-trigger.
**`process-email-jobs` SUCCESS tapi email tidak terkirim:**
- Cek `RESEND_API_KEY` di env. Tanpa env, cron return early dengan warning di log.
- Cek dashboard Resend untuk delivery status / bounce.
- Cek tabel `EmailJob` di DB: status `PENDING`/`FAILED` + `lastError` field.
**`auto-complete-trips` SUCCESS tapi trip masih OPEN:**
- Cek `Trip.endDate` (kalau ada) atau `Trip.date` — harus lewat cutoff (`utcStartOfDay(now)`).
- Trip dengan status `CLOSED` sengaja tidak di-touch (organizer eksplisit batalkan).
**Secret bocor:** **Secret bocor:**
- Generate ulang `CRON_SECRET`, update di `.env` + crontab line, restart PM2. - Generate ulang `CRON_SECRET`, update di `.env` + semua baris crontab, restart PM2.
## Hari kalau pindah ke Vercel / PaaS lain **Cron FAILED berturut-turut:**
- `/admin/system` akan tampilkan badge 🔴 FAILED.
- Kalau env `ADMIN_ALERT_WEBHOOK_URL` di-set, Discord channel akan dapat notif.
- Klik "Last error" di card cron untuk lihat stack trace, atau cek tabel `CronRun.errorMessage` langsung.
Tinggal hapus crontab line + bikin `vercel.json` (atau equivalent platform). Endpoint sudah platform-agnostic — proteksinya sama (header `Authorization: Bearer <CRON_SECRET>`). ---
## Saat menambah cron baru (developer note)
Checklist:
1. Buat route handler di `app/api/cron/<name>/route.ts` dengan pola standar (CRON_SECRET check + `runCron(jobName, fn)` wrapper).
2. Tambah entry di tabel **Daftar cron job** di doc ini.
3. Tambah baris di `TRACKED_JOBS` di [app/admin/system/page.tsx](../app/admin/system/page.tsx) supaya muncul di health card.
4. Brief ops: tambah baris di server crontab dengan schedule yang sesuai.
Pattern minimal cron handler:
```ts
// app/api/cron/<name>/route.ts
import { NextRequest, NextResponse } from "next/server";
import { runCron } from "@/lib/cron-runner";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
const secret = process.env.CRON_SECRET;
if (!secret) {
return NextResponse.json({ error: "Server misconfigured" }, { status: 500 });
}
if (req.headers.get("authorization") !== `Bearer ${secret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const outcome = await runCron("<jobName>", async () => {
// ... actual work; return value masuk ke CronRun.payload
return { processedCount: 0 };
});
if (!outcome.ok) {
return NextResponse.json({ error: outcome.error }, { status: 500 });
}
return NextResponse.json({ ok: true, ...outcome.payload });
}
```
---
## Kalau pindah ke Vercel / PaaS lain
Bikin `vercel.json` di root:
```json
{
"crons": [
{
"path": "/api/cron/auto-complete-trips",
"schedule": "0 18 * * *"
},
{
"path": "/api/cron/process-email-jobs",
"schedule": "*/5 * * * *"
}
]
}
```
Vercel Cron otomatis kirim header `Authorization: Bearer <VERCEL_CRON_SECRET>` — sesuaikan logic auth check di route handler (atau pakai env yang sama). Endpoint sudah platform-agnostic — tidak ada code change yang diperlukan.
> **Catatan:** Vercel Cron free tier limit 2 cron/project + minimum schedule 1 jam. Untuk `process-email-jobs` yang 5 menit, perlu upgrade Vercel Pro atau pertahankan VPS untuk cron.
+40
View File
@@ -6,6 +6,8 @@ import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin"; import { isAdminEmail } from "@/lib/admin";
import { userService } from "@/server/services/user.service"; import { userService } from "@/server/services/user.service";
import { auditLog } from "@/server/services/audit-log.service"; import { auditLog } from "@/server/services/audit-log.service";
import { emailService } from "@/lib/email/send";
import { userRepo } from "@/server/repositories/user.repo";
export async function suspendUserAction(userId: string, reason: string) { export async function suspendUserAction(userId: string, reason: string) {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
@@ -29,6 +31,10 @@ export async function suspendUserAction(userId: string, reason: string) {
entityId: userId, entityId: userId,
payload: { reason: reason.trim() }, payload: { reason: reason.trim() },
}); });
// Notif email user — due process: kasih tahu alasan + cara appeal.
void notifySuspended(userId, reason.trim());
revalidatePath("/admin/users"); revalidatePath("/admin/users");
revalidatePath(`/admin/users/${userId}`); revalidatePath(`/admin/users/${userId}`);
return { success: true as const }; return { success: true as const };
@@ -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) { export async function unsuspendUserAction(userId: string) {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session?.user) { if (!session?.user) {
@@ -54,6 +90,10 @@ export async function unsuspendUserAction(userId: string) {
entityType: "User", entityType: "User",
entityId: userId, entityId: userId,
}); });
// Notif email user — kabari akun sudah aktif kembali.
void notifyUnsuspended(userId);
revalidatePath("/admin/users"); revalidatePath("/admin/users");
revalidatePath(`/admin/users/${userId}`); revalidatePath(`/admin/users/${userId}`);
return { success: true as const }; return { success: true as const };
@@ -1,3 +1,5 @@
import { DateField } from "@/components/shared/date-picker";
interface AdminFilterBarProps { interface AdminFilterBarProps {
/** URL base (mis. `/admin/refunds`) yang menerima query params. */ /** URL base (mis. `/admin/refunds`) yang menerima query params. */
action: string; action: string;
@@ -45,12 +47,11 @@ export function AdminFilterBar({
> >
Dari tanggal Dari tanggal
</label> </label>
<input <DateField
id="filter-dateFrom" id="filter-dateFrom"
name="dateFrom" name="dateFrom"
type="date" defaultValueYmd={values.dateFrom}
defaultValue={values.dateFrom ?? ""} placeholder="Dari tanggal"
className="w-full rounded-lg border border-neutral-200 bg-neutral-50 px-2 py-1.5 text-sm text-neutral-800 focus:border-primary-400 focus:bg-white"
/> />
</div> </div>
@@ -61,12 +62,11 @@ export function AdminFilterBar({
> >
Sampai tanggal Sampai tanggal
</label> </label>
<input <DateField
id="filter-dateTo" id="filter-dateTo"
name="dateTo" name="dateTo"
type="date" defaultValueYmd={values.dateTo}
defaultValue={values.dateTo ?? ""} placeholder="Sampai tanggal"
className="w-full rounded-lg border border-neutral-200 bg-neutral-50 px-2 py-1.5 text-sm text-neutral-800 focus:border-primary-400 focus:bg-white"
/> />
</div> </div>
@@ -1,3 +1,5 @@
import { Download } from "lucide-react";
interface ExportCsvLinkProps { interface ExportCsvLinkProps {
/** URL endpoint export, mis. `/api/admin/export/refunds`. */ /** URL endpoint export, mis. `/api/admin/export/refunds`. */
href: string; 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" className="inline-flex items-center gap-1.5 rounded-xl border border-neutral-200 bg-white px-3 py-1.5 text-xs font-semibold text-neutral-700 hover:bg-neutral-50"
download download
> >
<span aria-hidden></span> <Download size={16} strokeWidth={2} aria-hidden />
<span>{label}</span> <span>{label}</span>
</a> </a>
); );
@@ -2,6 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Lock } from "lucide-react";
import { manualOverrideVerificationAction } from "@/features/organizer/actions"; import { manualOverrideVerificationAction } from "@/features/organizer/actions";
interface ManualVerifyButtonProps { interface ManualVerifyButtonProps {
@@ -49,9 +50,10 @@ export function ManualVerifyButton({
<button <button
type="button" type="button"
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
className="rounded-xl border border-secondary-300 bg-white px-4 py-2 text-sm font-bold text-secondary-700 hover:bg-secondary-50" 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"
> >
🔒 Manual verify (tanpa KYC) <Lock size={18} strokeWidth={2} aria-hidden />
Manual verify (tanpa KYC)
</button> </button>
); );
} }
@@ -2,6 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Check } from "lucide-react";
import { adminReconcileMidtransAction } from "@/features/booking/actions"; import { adminReconcileMidtransAction } from "@/features/booking/actions";
interface AdminReconcileButtonProps { interface AdminReconcileButtonProps {
@@ -45,8 +46,9 @@ export function AdminReconcileButton({
{loading ? "Reconciling..." : "Reconcile Midtrans"} {loading ? "Reconciling..." : "Reconcile Midtrans"}
</button> </button>
{status && ( {status && (
<span className="text-[11px] font-medium text-emerald-700"> <span className="inline-flex items-center gap-1 text-[11px] font-medium text-emerald-700">
{reconcileOutcomeLabel(status)} <Check size={12} strokeWidth={2.5} aria-hidden />
{reconcileOutcomeLabel(status)}
</span> </span>
)} )}
{error && ( {error && (
@@ -2,6 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { CircleAlert } from "lucide-react";
import { cancelBookingWithRefundAction } from "@/features/booking/actions"; import { cancelBookingWithRefundAction } from "@/features/booking/actions";
import { formatRupiah } from "@/lib/utils"; import { formatRupiah } from "@/lib/utils";
@@ -112,9 +113,17 @@ export function CancelBookingButton({ tripId, preview }: CancelBookingButtonProp
Tier: {preview.tierLabel} Tier: {preview.tierLabel}
</p> </p>
{noRefund ? ( {noRefund ? (
<p className="mt-2 text-xs text-red-700"> <p className="mt-2 flex items-start gap-1.5 text-xs text-red-700">
Di luar window refund uang tidak dikembalikan. Booking akan <CircleAlert
size={14}
strokeWidth={2}
aria-hidden
className="mt-0.5 shrink-0"
/>
<span>
Di luar window refund uang tidak dikembalikan. Booking akan
di-cancel langsung. di-cancel langsung.
</span>
</p> </p>
) : ( ) : (
<p className="mt-2 text-xs text-neutral-600"> <p className="mt-2 text-xs text-neutral-600">
+13 -2
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Check, Copy } from "lucide-react";
interface CopyButtonProps { interface CopyButtonProps {
value: string; value: string;
@@ -24,9 +25,19 @@ export function CopyButton({ value, label = "Salin" }: CopyButtonProps) {
<button <button
type="button" type="button"
onClick={handleClick} onClick={handleClick}
className="rounded-lg border border-neutral-200 bg-white px-2.5 py-1 text-xs font-medium text-neutral-600 transition-colors hover:bg-neutral-50" className="inline-flex items-center gap-1 rounded-lg border border-neutral-200 bg-white px-2.5 py-1 text-xs font-medium text-neutral-600 transition-colors hover:bg-neutral-50"
> >
{copied ? "✓ Tersalin" : label} {copied ? (
<>
<Check size={13} strokeWidth={2.5} aria-hidden className="text-emerald-600" />
Tersalin
</>
) : (
<>
<Copy size={13} strokeWidth={1.75} aria-hidden />
{label}
</>
)}
</button> </button>
); );
} }
+57
View File
@@ -0,0 +1,57 @@
"use server";
import { getServerSession } from "next-auth";
import { revalidatePath } from "next/cache";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { emailService } from "@/lib/email/send";
import { auditLog } from "@/server/services/audit-log.service";
async function requireAdmin() {
const session = await getServerSession(authOptions);
if (!session?.user || !isAdminEmail(session.user.email)) {
return null;
}
return session.user;
}
/** E5.2 — admin retry satu EmailJob yang gagal/antri, kirim ulang langsung. */
export async function retryEmailJobAction(jobId: string) {
const admin = await requireAdmin();
if (!admin) return { error: "Tidak memiliki akses admin" };
if (!jobId) return { error: "jobId tidak valid" };
const result = await emailService.retryJob(jobId);
if (!result.ok) {
return { error: result.error ?? "Gagal mengirim ulang email" };
}
await auditLog.record({
admin: { id: admin.id, email: admin.email },
action: "EMAIL_JOB_RETRY",
entityType: "EmailJob",
entityId: jobId,
});
revalidatePath("/admin/emails");
revalidatePath("/admin/system");
return { success: true as const };
}
/** E5.3 — admin resend email yang sudah pernah terkirim (mis. user lapor tidak terima). */
export async function resendEmailAction(emailSentId: string) {
const admin = await requireAdmin();
if (!admin) return { error: "Tidak memiliki akses admin" };
if (!emailSentId) return { error: "emailSentId tidak valid" };
const result = await emailService.resendEmail(emailSentId);
if (!result.ok) {
return { error: result.error ?? "Gagal mengirim ulang email" };
}
await auditLog.record({
admin: { id: admin.id, email: admin.email },
action: "EMAIL_RESEND",
entityType: "EmailSent",
entityId: emailSentId,
});
revalidatePath("/admin/emails");
return { success: true as const };
}
@@ -0,0 +1,110 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Check } from "lucide-react";
import {
retryEmailJobAction,
resendEmailAction,
} from "@/features/email/actions";
const BTN_CLS =
"rounded-lg border border-primary-200 bg-primary-50 px-2.5 py-1 text-[11px] font-semibold text-primary-700 transition-colors hover:bg-primary-100 disabled:cursor-not-allowed disabled:opacity-50";
/** E5.2 — tombol kirim ulang untuk satu EmailJob (antri / gagal). */
export function RetryEmailButton({ jobId }: { jobId: string }) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function handleRetry() {
setLoading(true);
setError("");
const res = await retryEmailJobAction(jobId);
setLoading(false);
if ("error" in res) {
setError(res.error ?? "Gagal");
return;
}
router.refresh();
}
return (
<div>
<button
type="button"
onClick={handleRetry}
disabled={loading}
className={BTN_CLS}
>
{loading ? "Mengirim…" : "Kirim ulang"}
</button>
{error && (
<p className="mt-1 max-w-50 text-[10px] text-red-600">{error}</p>
)}
</div>
);
}
/** E5.3 — tombol resend untuk email yang sudah terkirim. */
export function ResendEmailButton({
emailSentId,
disabled,
}: {
emailSentId: string;
disabled?: boolean;
}) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [done, setDone] = useState(false);
async function handleResend() {
setLoading(true);
setError("");
const res = await resendEmailAction(emailSentId);
setLoading(false);
if ("error" in res) {
setError(res.error ?? "Gagal");
return;
}
setDone(true);
router.refresh();
}
if (disabled) {
return (
<span
className="text-[10px] text-neutral-400"
title="Body email lama tidak tersimpan"
>
</span>
);
}
return (
<div>
<button
type="button"
onClick={handleResend}
disabled={loading || done}
className={`${BTN_CLS} inline-flex items-center gap-1`}
>
{loading ? (
"Mengirim…"
) : done ? (
<>
<Check size={12} strokeWidth={2.5} aria-hidden />
Terkirim
</>
) : (
"Resend"
)}
</button>
{error && (
<p className="mt-1 max-w-50 text-[10px] text-red-600">{error}</p>
)}
</div>
);
}
+122
View File
@@ -10,6 +10,10 @@ import {
type ReuploadField, type ReuploadField,
} from "@/server/services/organizer.service"; } from "@/server/services/organizer.service";
import { auditLog } from "@/server/services/audit-log.service"; import { auditLog } from "@/server/services/audit-log.service";
import { emailService } from "@/lib/email/send";
import { organizerRepo } from "@/server/repositories/organizer.repo";
import { userRepo } from "@/server/repositories/user.repo";
import { prisma } from "@/lib/prisma";
import { submitVerificationSchema, reviewVerificationSchema } from "./schemas"; import { submitVerificationSchema, reviewVerificationSchema } from "./schemas";
export async function submitVerificationAction(formData: FormData) { export async function submitVerificationAction(formData: FormData) {
@@ -40,6 +44,7 @@ export async function submitVerificationAction(formData: FormData) {
...result.data, ...result.data,
birthDate: new Date(result.data.birthDate), birthDate: new Date(result.data.birthDate),
}); });
void notifyKycSubmitted(session.user.id);
revalidatePath("/verify"); revalidatePath("/verify");
revalidatePath("/profile"); revalidatePath("/profile");
revalidatePath("/admin/verifications"); revalidatePath("/admin/verifications");
@@ -86,6 +91,10 @@ export async function reviewVerificationAction(formData: FormData) {
? { rejectionReason: result.data.rejectionReason ?? null } ? { rejectionReason: result.data.rejectionReason ?? null }
: undefined, : undefined,
}); });
// Notif email — fire and forget, jangan blok response.
void notifyVerificationDecision(result.data.verificationId, result.data.decision, result.data.rejectionReason);
revalidatePath("/admin/verifications"); revalidatePath("/admin/verifications");
revalidatePath("/verify"); revalidatePath("/verify");
revalidatePath("/profile"); revalidatePath("/profile");
@@ -95,6 +104,89 @@ export async function reviewVerificationAction(formData: FormData) {
} }
} }
async function notifyVerificationDecision(
verificationId: string,
decision: "APPROVED" | "REJECTED",
rejectionReason?: string
) {
const verification = await organizerRepo.findById(verificationId);
if (!verification) return;
const user = await userRepo.findById(verification.userId);
if (!user) return;
if (decision === "APPROVED") {
await emailService.send({
to: user.email,
idempotencyKey: `kyc_approved-${verificationId}`,
template: {
template: "kyc_approved",
data: { userName: user.name },
},
});
} else {
await emailService.send({
to: user.email,
// submissionCount supaya kalau reject berulang masing-masing dapat email.
idempotencyKey: `kyc_rejected-${verificationId}-${verification.submissionCount}`,
template: {
template: "kyc_rejected",
data: {
userName: user.name,
rejectionReason: rejectionReason ?? "(tidak ada alasan tercatat)",
},
},
});
}
}
/** E3.9 — kabari user kalau pengajuan verifikasi-nya sudah masuk antrian. */
async function notifyKycSubmitted(userId: string) {
const verification = await prisma.organizerVerification.findUnique({
where: { userId },
select: { submissionCount: true },
});
const user = await userRepo.findById(userId);
if (!verification || !user) return;
await emailService.send({
to: user.email,
idempotencyKey: `kyc_submitted-${userId}-${verification.submissionCount}`,
template: {
template: "kyc_submitted",
data: { userName: user.name },
},
});
}
/** E3.11 — kabari user kalau pengajuan verifikasi-nya dibuka kembali admin. */
async function notifyKycReopened(verificationId: string) {
const verification = await organizerRepo.findById(verificationId);
if (!verification) return;
const user = await userRepo.findById(verification.userId);
if (!user) return;
await emailService.send({
to: user.email,
idempotencyKey: `kyc_reopened-${verificationId}-${verification.submissionCount}`,
template: {
template: "kyc_reopened",
data: { userName: user.name },
},
});
}
/** E3.10 — kabari user kalau admin verifikasi dia secara manual override. */
async function notifyKycManualOverride(userId: string, verificationId: string) {
const user = await userRepo.findById(userId);
if (!user) return;
await emailService.send({
to: user.email,
idempotencyKey: `kyc_manual_override-${verificationId}`,
template: {
template: "kyc_manual_override",
data: { userName: user.name },
},
});
}
/** /**
* Admin reopen pengajuan REJECTED ke PENDING — supaya organizer bisa * Admin reopen pengajuan REJECTED ke PENDING — supaya organizer bisa
* di-review ulang tanpa drop & recreate row. Note wajib min 10 char untuk audit. * di-review ulang tanpa drop & recreate row. Note wajib min 10 char untuk audit.
@@ -114,6 +206,7 @@ export async function reopenVerificationAction(
adminId: session.user.id, adminId: session.user.id,
note, note,
}); });
void notifyKycReopened(verificationId);
await auditLog.record({ await auditLog.record({
admin: { id: session.user.id, email: session.user.email }, admin: { id: session.user.id, email: session.user.email },
action: "VERIFICATION_REOPEN", action: "VERIFICATION_REOPEN",
@@ -159,6 +252,10 @@ export async function requestReuploadAction(
entityId: verificationId, entityId: verificationId,
payload: { fields: valid, note: note.trim() }, payload: { fields: valid, note: note.trim() },
}); });
// Notif email organizer — urgent, action required.
void notifyReuploadRequest(verificationId, valid, note.trim());
revalidatePath("/admin/verifications"); revalidatePath("/admin/verifications");
revalidatePath("/verify"); revalidatePath("/verify");
return { success: true as const }; return { success: true as const };
@@ -167,6 +264,30 @@ export async function requestReuploadAction(
} }
} }
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). * Phase 4: admin verify user tanpa upload KYC (partner trusted referral).
* Bikin row APPROVED dengan flag `isManualOverride = true`. * Bikin row APPROVED dengan flag `isManualOverride = true`.
@@ -192,6 +313,7 @@ export async function manualOverrideVerificationAction(input: {
bankAccountNumber: input.bankAccountNumber, bankAccountNumber: input.bankAccountNumber,
bankAccountName: input.bankAccountName, bankAccountName: input.bankAccountName,
}); });
void notifyKycManualOverride(input.userId, result.id);
await auditLog.record({ await auditLog.record({
admin: { id: session.user.id, email: session.user.email }, admin: { id: session.user.id, email: session.user.email },
action: "VERIFICATION_MANUAL_OVERRIDE", action: "VERIFICATION_MANUAL_OVERRIDE",
+13 -8
View File
@@ -2,6 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { CircleCheck, CircleX, RefreshCw } from "lucide-react";
import { import {
reopenVerificationAction, reopenVerificationAction,
requestReuploadAction, requestReuploadAction,
@@ -180,9 +181,10 @@ export function ReviewCard({ verification }: { verification: Verification }) {
type="button" type="button"
onClick={() => setShowReopen(true)} onClick={() => setShowReopen(true)}
disabled={loading} disabled={loading}
className="rounded-xl border border-amber-300 bg-white px-4 py-2 text-sm font-bold text-amber-700 hover:bg-amber-50 disabled:opacity-50" className="inline-flex items-center gap-1.5 rounded-xl border border-amber-300 bg-white px-4 py-2 text-sm font-bold text-amber-700 hover:bg-amber-50 disabled:opacity-50"
> >
🔄 Buka kembali ke PENDING <RefreshCw size={18} strokeWidth={2} aria-hidden />
Buka kembali ke PENDING
</button> </button>
) : ( ) : (
<div className="space-y-2 rounded-xl border border-amber-200 bg-amber-50/60 p-3"> <div className="space-y-2 rounded-xl border border-amber-200 bg-amber-50/60 p-3">
@@ -336,25 +338,28 @@ export function ReviewCard({ verification }: { verification: Verification }) {
type="button" type="button"
onClick={() => decide("APPROVED")} onClick={() => decide("APPROVED")}
disabled={loading} disabled={loading}
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50" className="inline-flex items-center gap-1.5 rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50"
> >
Setujui <CircleCheck size={18} strokeWidth={2} aria-hidden />
Setujui
</button> </button>
<button <button
type="button" type="button"
onClick={() => setShowReupload(true)} onClick={() => setShowReupload(true)}
disabled={loading} disabled={loading}
className="rounded-xl border border-amber-300 bg-white px-4 py-2 text-sm font-bold text-amber-700 hover:bg-amber-50 disabled:opacity-50" className="inline-flex items-center gap-1.5 rounded-xl border border-amber-300 bg-white px-4 py-2 text-sm font-bold text-amber-700 hover:bg-amber-50 disabled:opacity-50"
> >
🔄 Minta re-upload <RefreshCw size={18} strokeWidth={2} aria-hidden />
Minta re-upload
</button> </button>
<button <button
type="button" type="button"
onClick={() => setShowReject(true)} onClick={() => setShowReject(true)}
disabled={loading} disabled={loading}
className="rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50 disabled:opacity-50" className="inline-flex items-center gap-1.5 rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50 disabled:opacity-50"
> >
Tolak <CircleX size={18} strokeWidth={2} aria-hidden />
Tolak
</button> </button>
</div> </div>
)} )}
+59 -16
View File
@@ -2,7 +2,9 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { IdCard, Image as ImageIcon, Landmark, Check } from "lucide-react";
import { submitVerificationAction } from "@/features/organizer/actions"; import { submitVerificationAction } from "@/features/organizer/actions";
import { DateField } from "@/components/shared/date-picker";
type Initial = { type Initial = {
fullName: string; fullName: string;
@@ -21,23 +23,31 @@ type UploadKind = "ktp" | "liveness";
const ACCEPT_MIME = "image/jpeg,image/png,image/webp"; const ACCEPT_MIME = "image/jpeg,image/png,image/webp";
const MAX_BYTES = 5 * 1024 * 1024; const MAX_BYTES = 5 * 1024 * 1024;
function toYmd(d: Date): string {
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
const day = String(d.getUTCDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
export function VerifyForm({ initial }: { initial: Initial }) { export function VerifyForm({ initial }: { initial: Initial }) {
const router = useRouter(); const router = useRouter();
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [ktpKey, setKtpKey] = useState(initial?.ktpImageKey ?? ""); const [ktpKey, setKtpKey] = useState(initial?.ktpImageKey ?? "");
const [livenessKey, setLivenessKey] = useState(initial?.livenessKey ?? ""); const [livenessKey, setLivenessKey] = useState(initial?.livenessKey ?? "");
// `birthDate` dari DB tersimpan sebagai tengah malam UTC — baca pakai getter
// UTC supaya hari kalender yang tampil di picker tidak bergeser.
const [birthDate, setBirthDate] = useState<Date | null>(
initial
? new Date(
initial.birthDate.getUTCFullYear(),
initial.birthDate.getUTCMonth(),
initial.birthDate.getUTCDate()
)
: null
);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();
setError(""); setError("");
if (!birthDate) {
setError("Tanggal lahir wajib diisi");
return;
}
if (!ktpKey || !livenessKey) { if (!ktpKey || !livenessKey) {
setError("Foto KTP dan foto memegang kertas SETRIP wajib diunggah"); setError("Foto KTP dan foto memegang kertas SETRIP wajib diunggah");
return; return;
@@ -70,7 +80,15 @@ export function VerifyForm({ initial }: { initial: Initial }) {
)} )}
<section> <section>
<h2 className="mb-3 text-base font-bold text-neutral-900">📇 Data KTP</h2> <h2 className="mb-3 flex items-center gap-2 text-base font-bold text-neutral-900">
<IdCard
size={18}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
Data KTP
</h2>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<label className="mb-1.5 block text-sm font-semibold text-neutral-700"> <label className="mb-1.5 block text-sm font-semibold text-neutral-700">
@@ -102,15 +120,21 @@ export function VerifyForm({ initial }: { initial: Initial }) {
/> />
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-semibold text-neutral-700"> <label
htmlFor="birthDate"
className="mb-1.5 block text-sm font-semibold text-neutral-700"
>
Tanggal Lahir Tanggal Lahir
</label> </label>
<input <DateField
id="birthDate"
name="birthDate" name="birthDate"
type="date" value={birthDate}
onChange={setBirthDate}
maxDate={new Date()}
withMonthYearDropdown
required required
defaultValue={initial ? toYmd(new Date(initial.birthDate)) : ""} placeholder="Pilih tanggal lahir"
className={inputCls}
/> />
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
@@ -130,7 +154,15 @@ export function VerifyForm({ initial }: { initial: Initial }) {
</section> </section>
<section> <section>
<h2 className="mb-3 text-base font-bold text-neutral-900">🖼 Foto</h2> <h2 className="mb-3 flex items-center gap-2 text-base font-bold text-neutral-900">
<ImageIcon
size={18}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
Foto
</h2>
<p className="mb-3 text-xs text-neutral-500"> <p className="mb-3 text-xs text-neutral-500">
Foto disimpan terenkripsi di server SeTrip dan hanya bisa dilihat oleh Foto disimpan terenkripsi di server SeTrip dan hanya bisa dilihat oleh
tim admin saat review. Maks 5MB, JPG/PNG/WebP. tim admin saat review. Maks 5MB, JPG/PNG/WebP.
@@ -163,7 +195,15 @@ export function VerifyForm({ initial }: { initial: Initial }) {
</section> </section>
<section> <section>
<h2 className="mb-3 text-base font-bold text-neutral-900">🏦 Rekening Bank</h2> <h2 className="mb-3 flex items-center gap-2 text-base font-bold text-neutral-900">
<Landmark
size={18}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
Rekening Bank
</h2>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div> <div>
<label className="mb-1.5 block text-sm font-semibold text-neutral-700"> <label className="mb-1.5 block text-sm font-semibold text-neutral-700">
@@ -297,7 +337,10 @@ function FileUpload({
/> />
</label> </label>
{value && !busy && ( {value && !busy && (
<span className="text-xs text-neutral-500"> Terunggah</span> <span className="inline-flex items-center gap-1 text-xs text-emerald-600">
<Check size={13} strokeWidth={2.5} aria-hidden />
Terunggah
</span>
)} )}
</div> </div>
{previewUrl && ( {previewUrl && (
+22
View File
@@ -5,7 +5,9 @@ import { revalidatePath } from "next/cache";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin"; import { isAdminEmail } from "@/lib/admin";
import { payoutService } from "@/server/services/payout.service"; import { payoutService } from "@/server/services/payout.service";
import { payoutRepo } from "@/server/repositories/payout.repo";
import { auditLog } from "@/server/services/audit-log.service"; import { auditLog } from "@/server/services/audit-log.service";
import { emailService } from "@/lib/email/send";
import { payoutMarkPaidSchema } from "./schemas"; import { payoutMarkPaidSchema } from "./schemas";
async function requireAdmin() { async function requireAdmin() {
@@ -34,6 +36,7 @@ export async function markPayoutPaidAction(formData: FormData) {
adminId: admin.id, adminId: admin.id,
adminNote: parsed.data.adminNote, adminNote: parsed.data.adminNote,
}); });
void notifyPayoutPaid(parsed.data.payoutId, parsed.data.adminNote);
await auditLog.record({ await auditLog.record({
admin: { id: admin.id, email: admin.email }, admin: { id: admin.id, email: admin.email },
action: "PAYOUT_MARK_PAID", action: "PAYOUT_MARK_PAID",
@@ -49,3 +52,22 @@ export async function markPayoutPaidAction(formData: FormData) {
return { error: (err as Error).message }; return { error: (err as Error).message };
} }
} }
/** E3.7 — kabari organizer kalau payout-nya sudah ditransfer admin. */
async function notifyPayoutPaid(payoutId: string, adminNote: string) {
const payout = await payoutRepo.findById(payoutId);
if (!payout) return;
await emailService.send({
to: payout.organizer.email,
idempotencyKey: `payout_paid-${payout.id}`,
template: {
template: "payout_paid",
data: {
organizerName: payout.organizer.name,
tripTitle: payout.trip.title,
amount: payout.amount,
adminNote,
},
},
});
}
@@ -3,6 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ArrowRight, Banknote, CircleAlert } from "lucide-react";
import { markPayoutPaidAction } from "@/features/payout/actions"; import { markPayoutPaidAction } from "@/features/payout/actions";
import { formatRupiah } from "@/lib/utils"; import { formatRupiah } from "@/lib/utils";
@@ -95,9 +96,10 @@ export function PayoutReviewCard({ payout }: { payout: PayoutCardData }) {
</p> </p>
<Link <Link
href={`/admin/bookings/${payout.booking.id}`} href={`/admin/bookings/${payout.booking.id}`}
className="mt-1 inline-block text-[11px] font-semibold text-secondary-700 hover:text-secondary-900" className="mt-1 inline-flex items-center gap-1 text-[11px] font-semibold text-secondary-700 hover:text-secondary-900"
> >
Lihat timeline booking <ArrowRight size={14} strokeWidth={2} aria-hidden />
Lihat timeline booking
</Link> </Link>
</div> </div>
<StatusPill status={payout.status} /> <StatusPill status={payout.status} />
@@ -142,9 +144,18 @@ export function PayoutReviewCard({ payout }: { payout: PayoutCardData }) {
</p> </p>
</div> </div>
) : ( ) : (
<p className="text-amber-700"> <p className="flex gap-1.5 text-amber-700">
Organizer belum menyelesaikan verifikasi (KYC) tidak ada rekening <CircleAlert
snapshot. Hubungi organizer untuk konfirmasi rekening sebelum transfer. size={16}
strokeWidth={1.75}
aria-hidden
className="mt-0.5 shrink-0"
/>
<span>
Organizer belum menyelesaikan verifikasi (KYC) tidak ada
rekening snapshot. Hubungi organizer untuk konfirmasi rekening
sebelum transfer.
</span>
</p> </p>
)} )}
</div> </div>
@@ -212,9 +223,10 @@ export function PayoutReviewCard({ payout }: { payout: PayoutCardData }) {
type="button" type="button"
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
disabled={loading} disabled={loading}
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50" className="inline-flex items-center gap-1.5 rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50"
> >
💸 Tandai sudah ditransfer ke organizer <Banknote size={18} strokeWidth={2} aria-hidden />
Tandai sudah ditransfer ke organizer
</button> </button>
)} )}
</div> </div>
@@ -1,3 +1,4 @@
import { BadgeCheck, Star } from "lucide-react";
import type { OrganizerTrust } from "@/server/services/trust.service"; import type { OrganizerTrust } from "@/server/services/trust.service";
interface OrganizerStatsPanelProps { interface OrganizerStatsPanelProps {
@@ -47,7 +48,8 @@ export function OrganizerStatsPanel({ trust }: OrganizerStatsPanelProps) {
className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-0.5 text-[11px] font-bold uppercase tracking-wide text-primary-800" className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-0.5 text-[11px] font-bold uppercase tracking-wide text-primary-800"
title="Identitas organizer telah diverifikasi (KTP & rekening)" title="Identitas organizer telah diverifikasi (KTP & rekening)"
> >
Verified Organizer <BadgeCheck size={12} strokeWidth={2} aria-hidden />
Verified Organizer
</span> </span>
)} )}
{isTripLeader && ( {isTripLeader && (
@@ -83,7 +85,21 @@ export function OrganizerStatsPanel({ trust }: OrganizerStatsPanelProps) {
/> />
<Stat <Stat
label="Rating" label="Rating"
value={avgRating != null ? `${avgRating}` : "—"} value={
avgRating != null ? (
<span className="inline-flex items-center gap-1">
{avgRating}
<Star
size={14}
strokeWidth={2}
fill="currentColor"
aria-hidden
/>
</span>
) : (
"—"
)
}
subtitle={ subtitle={
reviewCount > 0 reviewCount > 0
? `${reviewCount} ulasan` ? `${reviewCount} ulasan`
@@ -107,8 +123,15 @@ export function OrganizerStatsPanel({ trust }: OrganizerStatsPanelProps) {
key={star} key={star}
className="flex items-center gap-2 text-xs" className="flex items-center gap-2 text-xs"
> >
<span className="w-8 shrink-0 font-medium text-neutral-600"> <span className="flex w-8 shrink-0 items-center gap-0.5 font-medium text-neutral-600">
{star} {star}
<Star
size={11}
strokeWidth={2}
fill="currentColor"
aria-hidden
className="text-amber-500"
/>
</span> </span>
<div className="h-2 flex-1 overflow-hidden rounded-full bg-neutral-100"> <div className="h-2 flex-1 overflow-hidden rounded-full bg-neutral-100">
<div <div
@@ -138,7 +161,7 @@ const TONE_CLASSES = {
interface StatProps { interface StatProps {
label: string; label: string;
value: string; value: React.ReactNode;
subtitle?: string; subtitle?: string;
tone: keyof typeof TONE_CLASSES; tone: keyof typeof TONE_CLASSES;
} }
@@ -2,6 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ArrowUpRight } from "lucide-react";
import { updateProfileAction } from "@/features/profile/actions"; import { updateProfileAction } from "@/features/profile/actions";
import { LIMITS } from "@/lib/limits"; import { LIMITS } from "@/lib/limits";
import { VIBES, vibeMeta } from "@/lib/vibe"; import { VIBES, vibeMeta } from "@/lib/vibe";
@@ -102,9 +103,10 @@ export function ProfileEditor({ userId, initial }: ProfileEditorProps) {
href={`/u/${userId}`} href={`/u/${userId}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="rounded-lg border border-neutral-200 px-3 py-1.5 text-xs font-medium text-neutral-600 hover:bg-neutral-50" className="inline-flex items-center gap-1 rounded-lg border border-neutral-200 px-3 py-1.5 text-xs font-medium text-neutral-600 hover:bg-neutral-50"
> >
Lihat publik Lihat publik
<ArrowUpRight size={13} strokeWidth={1.75} aria-hidden />
</a> </a>
<button <button
type="button" type="button"
@@ -324,9 +326,10 @@ export function ProfileEditor({ userId, initial }: ProfileEditorProps) {
href={`/u/${userId}`} href={`/u/${userId}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="rounded-xl border border-neutral-200 px-4 py-2.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50" className="inline-flex items-center gap-1 rounded-xl border border-neutral-200 px-4 py-2.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
> >
Lihat publik Lihat publik
<ArrowUpRight size={14} strokeWidth={1.75} aria-hidden />
</a> </a>
</div> </div>
</form> </form>
+7 -4
View File
@@ -1,5 +1,6 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { MapPin, BadgeCheck } from "lucide-react";
import { vibeMeta } from "@/lib/vibe"; import { vibeMeta } from "@/lib/vibe";
import type { Vibe } from "@/app/generated/prisma/enums"; import type { Vibe } from "@/app/generated/prisma/enums";
@@ -48,17 +49,19 @@ export function UserCard({
{name} {name}
</p> </p>
{profile?.city && ( {profile?.city && (
<p className="truncate text-[11px] text-neutral-500 sm:text-xs"> <p className="flex items-center gap-1 truncate text-[11px] text-neutral-500 sm:text-xs">
📍 {profile.city} <MapPin size={11} strokeWidth={1.75} aria-hidden className="shrink-0" />
{profile.city}
</p> </p>
)} )}
<div className="mt-1 flex flex-wrap gap-1"> <div className="mt-1 flex flex-wrap gap-1">
{isVerifiedOrganizer && ( {isVerifiedOrganizer && (
<span <span
className="inline-flex items-center gap-0.5 rounded-full bg-primary-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-primary-800" className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-primary-800"
title="Organizer terverifikasi" title="Organizer terverifikasi"
> >
Organizer <BadgeCheck size={11} strokeWidth={2} aria-hidden />
Organizer
</span> </span>
)} )}
{profile?.vibe && ( {profile?.vibe && (
+83
View File
@@ -6,6 +6,8 @@ import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin"; import { isAdminEmail } from "@/lib/admin";
import { refundService } from "@/server/services/refund.service"; import { refundService } from "@/server/services/refund.service";
import { auditLog } from "@/server/services/audit-log.service"; import { auditLog } from "@/server/services/audit-log.service";
import { emailService } from "@/lib/email/send";
import { prisma } from "@/lib/prisma";
import { createRefundSchema, refundDecisionSchema } from "./schemas"; import { createRefundSchema, refundDecisionSchema } from "./schemas";
async function requireAdmin() { async function requireAdmin() {
@@ -51,6 +53,9 @@ export async function createRefundAction(formData: FormData) {
reason: parsed.data.reason, reason: parsed.data.reason,
}, },
}); });
void notifyRefundCreated(refund.id);
revalidatePath("/admin/refunds"); revalidatePath("/admin/refunds");
return { success: true }; return { success: true };
} catch (err) { } 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) { export async function decideRefundAction(formData: FormData) {
const admin = await requireAdmin(); const admin = await requireAdmin();
if (!admin) return { error: "Tidak memiliki akses admin" }; if (!admin) return { error: "Tidak memiliki akses admin" };
@@ -110,6 +186,13 @@ export async function decideRefundAction(formData: FormData) {
entityId: refundId, entityId: refundId,
payload: adminNote ? { adminNote } : undefined, payload: adminNote ? { adminNote } : undefined,
}); });
// Notif email user kalau decision final (SUCCEEDED/FAILED) — APPROVE/REJECT
// intermediate, refund_created sudah dikirim sebelumnya.
if (decision === "SUCCEEDED" || decision === "FAILED") {
void notifyRefundDecision(refundId, decision, adminNote ?? "");
}
revalidatePath("/admin/refunds"); revalidatePath("/admin/refunds");
return { success: true }; return { success: true };
} catch (err) { } catch (err) {
@@ -1,3 +1,4 @@
import { LifeBuoy } from "lucide-react";
import { getRefundPolicyTiers } from "@/lib/refund-policy"; import { getRefundPolicyTiers } from "@/lib/refund-policy";
/** /**
@@ -9,7 +10,13 @@ export function RefundPolicySection() {
return ( return (
<details className="rounded-xl border border-neutral-200 bg-neutral-50/60 p-3 text-xs sm:text-sm"> <details className="rounded-xl border border-neutral-200 bg-neutral-50/60 p-3 text-xs sm:text-sm">
<summary className="cursor-pointer select-none font-semibold text-neutral-700"> <summary className="cursor-pointer select-none font-semibold text-neutral-700">
🛟 Kebijakan refund saat peserta cancel <LifeBuoy
size={15}
strokeWidth={1.75}
aria-hidden
className="mr-1.5 inline align-text-bottom"
/>
Kebijakan refund saat peserta cancel
</summary> </summary>
<div className="mt-2 space-y-2 text-neutral-600"> <div className="mt-2 space-y-2 text-neutral-600">
<p className="text-[11px] text-neutral-500 sm:text-xs"> <p className="text-[11px] text-neutral-500 sm:text-xs">
@@ -21,7 +28,7 @@ export function RefundPolicySection() {
{tiers.map((t) => ( {tiers.map((t) => (
<li key={t.minDaysBefore} className="flex items-baseline gap-2"> <li key={t.minDaysBefore} className="flex items-baseline gap-2">
<span <span
className={`inline-flex min-w-[3rem] justify-center rounded-full px-2 py-0.5 text-[10px] font-bold ${ className={`inline-flex min-w-12 justify-center rounded-full px-2 py-0.5 text-[10px] font-bold ${
t.refundPercentage >= 80 t.refundPercentage >= 80
? "bg-primary-100 text-primary-700" ? "bg-primary-100 text-primary-700"
: t.refundPercentage >= 50 : t.refundPercentage >= 50
@@ -3,6 +3,13 @@
import { useState } from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import {
ArrowRight,
Banknote,
CircleAlert,
CircleCheck,
CircleX,
} from "lucide-react";
import { decideRefundAction } from "@/features/refund/actions"; import { decideRefundAction } from "@/features/refund/actions";
import { formatRupiah } from "@/lib/utils"; import { formatRupiah } from "@/lib/utils";
@@ -129,9 +136,10 @@ export function RefundReviewCard({ refund }: { refund: RefundCardData }) {
</p> </p>
<Link <Link
href={`/admin/bookings/${refund.booking.id}`} href={`/admin/bookings/${refund.booking.id}`}
className="mt-1 inline-block text-[11px] font-semibold text-secondary-700 hover:text-secondary-900" className="mt-1 inline-flex items-center gap-1 text-[11px] font-semibold text-secondary-700 hover:text-secondary-900"
> >
Lihat timeline payment & refund <ArrowRight size={14} strokeWidth={2} aria-hidden />
Lihat timeline payment & refund
</Link> </Link>
</div> </div>
<Field <Field
@@ -211,17 +219,19 @@ export function RefundReviewCard({ refund }: { refund: RefundCardData }) {
type="button" type="button"
onClick={() => setOpenAction("APPROVE")} onClick={() => setOpenAction("APPROVE")}
disabled={loading} disabled={loading}
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50" className="inline-flex items-center gap-1.5 rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50"
> >
Setujui <CircleCheck size={18} strokeWidth={2} aria-hidden />
Setujui
</button> </button>
<button <button
type="button" type="button"
onClick={() => setOpenAction("REJECT")} onClick={() => setOpenAction("REJECT")}
disabled={loading} disabled={loading}
className="rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50 disabled:opacity-50" className="inline-flex items-center gap-1.5 rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50 disabled:opacity-50"
> >
Tolak <CircleX size={18} strokeWidth={2} aria-hidden />
Tolak
</button> </button>
</> </>
)} )}
@@ -231,17 +241,19 @@ export function RefundReviewCard({ refund }: { refund: RefundCardData }) {
type="button" type="button"
onClick={() => setOpenAction("SUCCEEDED")} onClick={() => setOpenAction("SUCCEEDED")}
disabled={loading} disabled={loading}
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50" className="inline-flex items-center gap-1.5 rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50"
> >
💸 Tandai sudah ditransfer <Banknote size={18} strokeWidth={2} aria-hidden />
Tandai sudah ditransfer
</button> </button>
<button <button
type="button" type="button"
onClick={() => setOpenAction("FAILED")} onClick={() => setOpenAction("FAILED")}
disabled={loading} disabled={loading}
className="rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50 disabled:opacity-50" className="inline-flex items-center gap-1.5 rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50 disabled:opacity-50"
> >
Tandai gagal <CircleAlert size={18} strokeWidth={2} aria-hidden />
Tandai gagal
</button> </button>
</> </>
)} )}
@@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { Star } from "lucide-react";
import type { OrganizerReviewItem } from "@/server/services/review.service"; import type { OrganizerReviewItem } from "@/server/services/review.service";
interface OrganizerReviewsListProps { interface OrganizerReviewsListProps {
@@ -62,11 +63,22 @@ export function OrganizerReviewsList({
> >
{r.user.name} {r.user.name}
</Link> </Link>
<span className="text-xs font-bold text-amber-600"> <span
{"★".repeat(r.rating)} className="flex shrink-0 items-center gap-0.5"
<span className="text-neutral-300"> aria-label={`Rating ${r.rating} dari 5`}
{"★".repeat(5 - r.rating)} >
</span> {[1, 2, 3, 4, 5].map((n) => (
<Star
key={n}
size={12}
strokeWidth={2}
fill="currentColor"
aria-hidden
className={
n <= r.rating ? "text-amber-500" : "text-neutral-200"
}
/>
))}
</span> </span>
</div> </div>
+151
View File
@@ -13,6 +13,8 @@ import { revalidatePath } from "next/cache";
import { tripStoredInstantFromYmd } from "@/lib/trip-dates"; import { tripStoredInstantFromYmd } from "@/lib/trip-dates";
import { requireActiveUser } from "@/lib/auth-guards"; import { requireActiveUser } from "@/lib/auth-guards";
import { auditLog } from "@/server/services/audit-log.service"; import { auditLog } from "@/server/services/audit-log.service";
import { emailService } from "@/lib/email/send";
import { prisma } from "@/lib/prisma";
export async function createTripAction(formData: FormData) { export async function createTripAction(formData: FormData) {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
@@ -129,6 +131,7 @@ export async function joinTripAction(tripId: string) {
try { try {
await requireActiveUser(session.user.id); await requireActiveUser(session.user.id);
await tripService.joinTrip(tripId, session.user.id); await tripService.joinTrip(tripId, session.user.id);
void notifyJoinRequest(tripId, session.user.id);
revalidatePath(`/trips/${tripId}`); revalidatePath(`/trips/${tripId}`);
revalidatePath("/trips"); revalidatePath("/trips");
revalidatePath("/"); revalidatePath("/");
@@ -172,6 +175,9 @@ export async function confirmParticipantAction(
participantId, participantId,
session.user.id session.user.id
); );
void notifyBookingApproved(participantId);
revalidatePath(`/trips/${tripId}`); revalidatePath(`/trips/${tripId}`);
revalidatePath("/trips"); revalidatePath("/trips");
revalidatePath("/"); revalidatePath("/");
@@ -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( export async function rejectParticipantAction(
tripId: string, tripId: string,
participantId: string participantId: string
@@ -197,6 +282,7 @@ export async function rejectParticipantAction(
participantId, participantId,
session.user.id session.user.id
); );
void notifyJoinRejected(participantId);
revalidatePath(`/trips/${tripId}`); revalidatePath(`/trips/${tripId}`);
revalidatePath("/trips"); revalidatePath("/trips");
revalidatePath("/"); revalidatePath("/");
@@ -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) { export async function cancelTripAction(tripId: string) {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session?.user) { if (!session?.user) {
@@ -218,6 +364,7 @@ export async function cancelTripAction(tripId: string) {
type: "ORGANIZER", type: "ORGANIZER",
userId: session.user.id, userId: session.user.id,
}); });
notifyTripCancelled(tripId, result.notify, { type: "ORGANIZER" });
revalidatePath(`/trips/${tripId}`); revalidatePath(`/trips/${tripId}`);
revalidatePath("/trips"); revalidatePath("/trips");
revalidatePath("/"); revalidatePath("/");
@@ -264,6 +411,10 @@ export async function adminCancelTripAction(tripId: string, reason: string) {
adminId: session.user.id, adminId: session.user.id,
reason: trimmedReason, reason: trimmedReason,
}); });
notifyTripCancelled(tripId, result.notify, {
type: "ADMIN",
reason: trimmedReason,
});
await auditLog.record({ await auditLog.record({
admin: { id: session.user.id, email: session.user.email }, admin: { id: session.user.id, email: session.user.email },
action: "TRIP_ADMIN_CANCEL", action: "TRIP_ADMIN_CANCEL",
@@ -2,6 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { CircleCheck } from "lucide-react";
import { adminCancelTripAction } from "@/features/trip/actions"; import { adminCancelTripAction } from "@/features/trip/actions";
interface AdminCancelTripButtonProps { interface AdminCancelTripButtonProps {
@@ -42,7 +43,10 @@ export function AdminCancelTripButton({ tripId }: AdminCancelTripButtonProps) {
if (result) { if (result) {
return ( return (
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900"> <div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900">
<p className="font-bold"> Trip berhasil dibatalkan.</p> <p className="flex items-center gap-1.5 font-bold">
<CircleCheck size={18} strokeWidth={2} aria-hidden />
Trip berhasil dibatalkan.
</p>
<ul className="mt-2 space-y-0.5 text-xs"> <ul className="mt-2 space-y-0.5 text-xs">
<li> {result.refundCount} booking PAID refund auto-dibuat</li> <li> {result.refundCount} booking PAID refund auto-dibuat</li>
<li> <li>
+57 -82
View File
@@ -2,10 +2,17 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import DatePicker from "react-datepicker"; import {
import "react-datepicker/dist/react-datepicker.css"; ArrowLeft,
ArrowRight,
Check,
X,
CircleAlert,
Users,
} from "lucide-react";
import { DateRangeField, TimeField } from "@/components/shared/date-picker";
import { createTripAction } from "@/features/trip/actions"; import { createTripAction } from "@/features/trip/actions";
import { ImageUrlInput } from "@/features/trip/components/image-url-input"; import { TripImageUpload } from "@/features/trip/components/trip-image-upload";
import { formatLocalCalendarYmd } from "@/lib/trip-dates"; import { formatLocalCalendarYmd } from "@/lib/trip-dates";
import { ACTIVITY_CATEGORIES, categoryMeta } from "@/lib/activity-category"; import { ACTIVITY_CATEGORIES, categoryMeta } from "@/lib/activity-category";
import { VIBES, vibeMeta } from "@/lib/vibe"; import { VIBES, vibeMeta } from "@/lib/vibe";
@@ -54,7 +61,7 @@ const INITIAL_STATE: FormState = {
itineraryDays: [], itineraryDays: [],
whatsIncluded: "", whatsIncluded: "",
whatsExcluded: "", whatsExcluded: "",
imageUrls: [""], imageUrls: [],
maxParticipants: "", maxParticipants: "",
priceDisplay: "", priceDisplay: "",
}; };
@@ -145,18 +152,11 @@ export function CreateTripForm({ isVerifiedOrganizer }: CreateTripFormProps) {
return null; return null;
} }
if (target === 3) { if (target === 3) {
const hasInvalidUrl = state.imageUrls // Foto divalidasi saat upload (route + komponen). Di sini cukup cek
.map((u) => u.trim()) // batas jumlah supaya tidak melampaui kapasitas.
.filter(Boolean) if (state.imageUrls.length > LIMITS.MAX_IMAGE_URLS) {
.some((u) => { return `Maksimal ${LIMITS.MAX_IMAGE_URLS} foto`;
try {
const parsed = new URL(u);
return parsed.protocol !== "http:" && parsed.protocol !== "https:";
} catch {
return true;
} }
});
if (hasInvalidUrl) return "Ada URL foto yang tidak valid (harus http/https)";
for (let d = 0; d < state.itineraryDays.length; d++) { for (let d = 0; d < state.itineraryDays.length; d++) {
const dayItems = state.itineraryDays[d]; const dayItems = state.itineraryDays[d];
@@ -331,6 +331,7 @@ export function CreateTripForm({ isVerifiedOrganizer }: CreateTripFormProps) {
whatsExcluded={state.whatsExcluded} whatsExcluded={state.whatsExcluded}
imageUrls={state.imageUrls} imageUrls={state.imageUrls}
onChange={update} onChange={update}
onError={setStepError}
/> />
)} )}
@@ -368,9 +369,10 @@ export function CreateTripForm({ isVerifiedOrganizer }: CreateTripFormProps) {
type="button" type="button"
onClick={goBack} onClick={goBack}
disabled={step === 1 || loading} disabled={step === 1 || loading}
className="rounded-xl border border-neutral-200 bg-white px-4 py-2.5 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40" className="inline-flex items-center gap-1 rounded-xl border border-neutral-200 bg-white px-4 py-2.5 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40"
> >
Kembali <ArrowLeft size={15} strokeWidth={2} aria-hidden />
Kembali
</button> </button>
{isLastStep ? ( {isLastStep ? (
@@ -389,9 +391,10 @@ export function CreateTripForm({ isVerifiedOrganizer }: CreateTripFormProps) {
<button <button
type="button" type="button"
onClick={goNext} onClick={goNext}
className="rounded-xl bg-primary-600 px-5 py-2.5 text-sm font-bold text-white shadow-lg shadow-primary-600/20 transition-colors hover:bg-primary-700" className="inline-flex items-center gap-1 rounded-xl bg-primary-600 px-5 py-2.5 text-sm font-bold text-white shadow-lg shadow-primary-600/20 transition-colors hover:bg-primary-700"
> >
Lanjut Lanjut
<ArrowRight size={15} strokeWidth={2} aria-hidden />
</button> </button>
)} )}
</div> </div>
@@ -443,7 +446,11 @@ function Stepper({
: "cursor-not-allowed" : "cursor-not-allowed"
}`} }`}
> >
{isCompleted ? "✓" : s.id} {isCompleted ? (
<Check size={14} strokeWidth={3} aria-hidden />
) : (
s.id
)}
</button> </button>
<span <span
className={`ml-2 hidden text-xs font-semibold sm:inline ${ className={`ml-2 hidden text-xs font-semibold sm:inline ${
@@ -685,6 +692,7 @@ function StepDetail({
whatsExcluded, whatsExcluded,
imageUrls, imageUrls,
onChange, onChange,
onError,
}: { }: {
meetingPoint: string; meetingPoint: string;
itineraryDays: ItineraryDays; itineraryDays: ItineraryDays;
@@ -692,6 +700,7 @@ function StepDetail({
whatsExcluded: string; whatsExcluded: string;
imageUrls: string[]; imageUrls: string[];
onChange: <K extends keyof FormState>(key: K, value: FormState[K]) => void; onChange: <K extends keyof FormState>(key: K, value: FormState[K]) => void;
onError: (msg: string) => void;
}) { }) {
return ( return (
<div className="space-y-5"> <div className="space-y-5">
@@ -764,9 +773,10 @@ function StepDetail({
</div> </div>
</div> </div>
<ImageUrlInput <TripImageUpload
value={imageUrls} value={imageUrls}
onChange={(urls) => onChange("imageUrls", urls)} onChange={(urls) => onChange("imageUrls", urls)}
onError={onError}
/> />
</div> </div>
); );
@@ -885,40 +895,35 @@ function ItineraryBuilder({
> >
<div className="flex flex-col gap-2 sm:flex-row sm:items-start"> <div className="flex flex-col gap-2 sm:flex-row sm:items-start">
<div className="flex shrink-0 gap-2"> <div className="flex shrink-0 gap-2">
<div> <div className="w-32">
<label className="mb-0.5 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"> <label
htmlFor={`itin-${dayIdx}-${itemIdx}-start`}
className="mb-0.5 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
>
Mulai Mulai
</label> </label>
<input <TimeField
type="time" id={`itin-${dayIdx}-${itemIdx}-start`}
value={item.startTime} value={item.startTime}
onChange={(e) => onChange={(v) =>
updateItem( updateItem(dayIdx, itemIdx, "startTime", v)
dayIdx,
itemIdx,
"startTime",
e.target.value
)
} }
className="w-30 rounded-lg border border-neutral-200 bg-white px-2 py-1.5 text-sm text-neutral-800 focus:border-primary-400"
/> />
</div> </div>
<div> <div className="w-32">
<label className="mb-0.5 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"> <label
htmlFor={`itin-${dayIdx}-${itemIdx}-end`}
className="mb-0.5 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
>
Selesai Selesai
</label> </label>
<input <TimeField
type="time" id={`itin-${dayIdx}-${itemIdx}-end`}
value={item.endTime} value={item.endTime}
onChange={(e) => onChange={(v) =>
updateItem( updateItem(dayIdx, itemIdx, "endTime", v)
dayIdx,
itemIdx,
"endTime",
e.target.value
)
} }
className="w-30 rounded-lg border border-neutral-200 bg-white px-2 py-1.5 text-sm text-neutral-800 focus:border-primary-400" clearable
/> />
</div> </div>
</div> </div>
@@ -948,7 +953,7 @@ function ItineraryBuilder({
aria-label="Hapus aktivitas" aria-label="Hapus aktivitas"
className="self-end rounded-lg px-2 py-1.5 text-neutral-400 hover:bg-red-50 hover:text-red-500 sm:self-center" className="self-end rounded-lg px-2 py-1.5 text-neutral-400 hover:bg-red-50 hover:text-red-500 sm:self-center"
> >
<X size={16} strokeWidth={2} aria-hidden />
</button> </button>
</div> </div>
</li> </li>
@@ -1021,37 +1026,13 @@ function StepSchedule({
<label className="mb-1.5 block text-sm font-semibold text-neutral-700"> <label className="mb-1.5 block text-sm font-semibold text-neutral-700">
Tanggal berangkat pulang Tanggal berangkat pulang
</label> </label>
<div className="relative"> <DateRangeField
<span className="absolute left-3 top-1/2 z-10 -translate-y-1/2 text-neutral-400">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4"
>
<path
fillRule="evenodd"
d="M5.75 2a.75.75 0 01.75.75V4h7V2.75a.75.75 0 011.5 0V4h.25A2.75 2.75 0 0118 6.75v8.5A2.75 2.75 0 0115.25 18H4.75A2.75 2.75 0 012 15.25v-8.5A2.75 2.75 0 014.75 4H5V2.75A.75.75 0 015.75 2zm-1 5.5c-.69 0-1.25.56-1.25 1.25v6.5c0 .69.56 1.25 1.25 1.25h10.5c.69 0 1.25-.56 1.25-1.25v-6.5c0-.69-.56-1.25-1.25-1.25H4.75z"
clipRule="evenodd"
/>
</svg>
</span>
<DatePicker
selectsRange
startDate={startDate} startDate={startDate}
endDate={endDate} endDate={endDate}
onChange={(dates) => { onChange={(s, e) => onDateChange(s, e)}
const [s, e] = dates as [Date | null, Date | null];
onDateChange(s, e);
}}
minDate={new Date()} minDate={new Date()}
placeholderText="Pilih tanggal..."
dateFormat="dd MMM yyyy"
isClearable
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 py-2.5 pl-9 pr-3 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-primary-500 focus:bg-white"
/> />
</div> </div>
</div>
<div> <div>
<label <label
@@ -1062,14 +1043,7 @@ function StepSchedule({
</label> </label>
<div className="relative"> <div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"> <span className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400">
<svg <Users size={16} strokeWidth={1.75} aria-hidden />
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4"
>
<path d="M7 8a3 3 0 100-6 3 3 0 000 6zM14.5 9a2.5 2.5 0 100-5 2.5 2.5 0 000 5zM1.615 16.428a1.224 1.224 0 01-.569-1.175 6.002 6.002 0 0111.908 0c.058.467-.172.92-.57 1.174A9.953 9.953 0 017 18a9.953 9.953 0 01-5.385-1.572zM14.5 16h-.106c.07-.297.088-.611.048-.933a7.47 7.47 0 00-1.588-3.755 4.502 4.502 0 015.874 2.636.818.818 0 01-.36.98A7.465 7.465 0 0114.5 16z" />
</svg>
</span> </span>
<input <input
id="maxParticipants" id="maxParticipants"
@@ -1114,8 +1088,9 @@ function StepSchedule({
/> />
</div> </div>
{blockedByVerification && ( {blockedByVerification && (
<p className="mt-2 text-xs font-medium text-amber-700"> <p className="mt-2 flex items-center gap-1.5 text-xs font-medium text-amber-700">
Trip berbayar butuh verifikasi organizer terlebih dahulu. <CircleAlert size={14} strokeWidth={2} aria-hidden />
Trip berbayar butuh verifikasi organizer terlebih dahulu.
</p> </p>
)} )}
</div> </div>
+165 -9
View File
@@ -1,7 +1,8 @@
"use client"; "use client";
import Image from "next/image"; import Image from "next/image";
import { useState } from "react"; import { useEffect, useState } from "react";
import { Mountain, Maximize2, X, ChevronLeft, ChevronRight } from "lucide-react";
interface TripImage { interface TripImage {
id: string; id: string;
@@ -11,11 +12,48 @@ interface TripImage {
export function ImageGallery({ images }: { images: TripImage[] }) { export function ImageGallery({ images }: { images: TripImage[] }) {
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const [lightboxOpen, setLightboxOpen] = useState(false);
const hasMultiple = images.length > 1;
function showPrev() {
setActiveIndex((i) => (i - 1 + images.length) % images.length);
}
function showNext() {
setActiveIndex((i) => (i + 1) % images.length);
}
// Saat lightbox terbuka: kunci scroll body + dukung keyboard (Esc tutup,
// panah kiri/kanan untuk ganti foto).
useEffect(() => {
if (!lightboxOpen) return;
function onKey(e: KeyboardEvent) {
// Logika prev/next di-inline (bukan panggil showPrev/showNext) supaya
// effect tidak bergantung pada fungsi yang dibuat ulang tiap render.
if (e.key === "Escape") setLightboxOpen(false);
else if (e.key === "ArrowLeft") {
setActiveIndex((i) => (i - 1 + images.length) % images.length);
} else if (e.key === "ArrowRight") {
setActiveIndex((i) => (i + 1) % images.length);
}
}
document.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [lightboxOpen, images.length]);
if (images.length === 0) { if (images.length === 0) {
return ( return (
<div className="flex h-44 items-center justify-center bg-linear-to-br from-primary-800 to-secondary-900 sm:h-56 lg:h-72"> <div className="flex h-44 items-center justify-center bg-neutral-100 sm:h-56 lg:h-72">
<span className="text-5xl sm:text-6xl">🏔</span> <Mountain
size={56}
strokeWidth={1.5}
aria-hidden
className="text-neutral-300"
/>
</div> </div>
); );
} }
@@ -24,18 +62,25 @@ export function ImageGallery({ images }: { images: TripImage[] }) {
return ( return (
<div> <div>
{/* Main Image */} {/* Main Image — klik untuk lihat ukuran penuh */}
<div className="relative h-44 bg-neutral-900 sm:h-56 lg:h-72"> <button
type="button"
onClick={() => setLightboxOpen(true)}
aria-label="Lihat foto ukuran penuh"
className="group relative block h-44 w-full cursor-zoom-in bg-neutral-900 sm:h-56 lg:h-72"
>
<Image <Image
src={activeImage.url} src={activeImage.url}
alt={activeImage.caption || "Foto trip"} alt={activeImage.caption || "Foto trip"}
fill fill
className="object-cover" // `object-contain` — tampilkan gambar utuh tanpa terpotong; rasio
// foto bebas, sisi yang tak terisi jadi bar gelap (bg-neutral-900).
className="object-contain"
sizes="(max-width: 768px) 100vw, 768px" sizes="(max-width: 768px) 100vw, 768px"
priority priority
/> />
{activeImage.caption && ( {activeImage.caption && (
<div className="absolute bottom-0 left-0 right-0 bg-linear-to-t from-black/60 to-transparent px-3 pb-2.5 pt-6 sm:px-4 sm:pb-3 sm:pt-8"> <div className="absolute bottom-0 left-0 right-0 bg-linear-to-t from-black/60 to-transparent px-3 pb-2.5 pt-6 text-left sm:px-4 sm:pb-3 sm:pt-8">
<p className="text-xs font-medium text-white sm:text-sm"> <p className="text-xs font-medium text-white sm:text-sm">
{activeImage.caption} {activeImage.caption}
</p> </p>
@@ -45,14 +90,20 @@ export function ImageGallery({ images }: { images: TripImage[] }) {
<div className="absolute right-2 top-2 rounded-full bg-black/50 px-2 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm sm:right-3 sm:top-3 sm:px-2.5 sm:py-1 sm:text-xs"> <div className="absolute right-2 top-2 rounded-full bg-black/50 px-2 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm sm:right-3 sm:top-3 sm:px-2.5 sm:py-1 sm:text-xs">
{activeIndex + 1} / {images.length} {activeIndex + 1} / {images.length}
</div> </div>
</div> {/* Petunjuk perbesar */}
<span className="absolute left-2 top-2 inline-flex items-center gap-1 rounded-full bg-black/50 px-2 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm transition-colors group-hover:bg-black/70 sm:left-3 sm:top-3 sm:px-2.5 sm:py-1 sm:text-xs">
<Maximize2 size={11} strokeWidth={2} aria-hidden />
Lihat penuh
</span>
</button>
{/* Thumbnails */} {/* Thumbnails */}
{images.length > 1 && ( {hasMultiple && (
<div className="flex gap-1 overflow-x-auto bg-neutral-100 p-1.5 sm:gap-1.5 sm:p-2"> <div className="flex gap-1 overflow-x-auto bg-neutral-100 p-1.5 sm:gap-1.5 sm:p-2">
{images.map((img, i) => ( {images.map((img, i) => (
<button <button
key={img.id} key={img.id}
type="button"
onClick={() => setActiveIndex(i)} onClick={() => setActiveIndex(i)}
className={`relative h-11 w-16 shrink-0 overflow-hidden rounded-md transition-all sm:h-14 sm:w-20 sm:rounded-lg ${ className={`relative h-11 w-16 shrink-0 overflow-hidden rounded-md transition-all sm:h-14 sm:w-20 sm:rounded-lg ${
i === activeIndex i === activeIndex
@@ -71,6 +122,111 @@ export function ImageGallery({ images }: { images: TripImage[] }) {
))} ))}
</div> </div>
)} )}
{/* Lightbox — penampil foto ukuran penuh */}
{lightboxOpen && (
<div
role="dialog"
aria-modal="true"
aria-label="Penampil foto trip"
className="fixed inset-0 z-50 flex flex-col bg-black/95"
>
{/* Top bar */}
<div className="flex items-center justify-between px-4 py-3 text-white">
<span className="text-sm font-medium tabular-nums">
{activeIndex + 1} / {images.length}
</span>
<button
type="button"
onClick={() => setLightboxOpen(false)}
aria-label="Tutup"
className="flex h-9 w-9 items-center justify-center rounded-full bg-white/10 transition-colors hover:bg-white/20"
>
<X size={20} strokeWidth={2} aria-hidden />
</button>
</div>
{/* Area gambar — klik latar untuk menutup */}
<div
className="relative flex-1"
onClick={() => setLightboxOpen(false)}
>
<div
className="relative h-full w-full"
onClick={(e) => e.stopPropagation()}
>
<Image
src={activeImage.url}
alt={activeImage.caption || "Foto trip"}
fill
className="object-contain"
sizes="100vw"
quality={90}
/>
</div>
{hasMultiple && (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
showPrev();
}}
aria-label="Foto sebelumnya"
className="absolute left-2 top-1/2 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/25 sm:left-4"
>
<ChevronLeft size={24} strokeWidth={2} aria-hidden />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
showNext();
}}
aria-label="Foto berikutnya"
className="absolute right-2 top-1/2 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/25 sm:right-4"
>
<ChevronRight size={24} strokeWidth={2} aria-hidden />
</button>
</>
)}
</div>
{activeImage.caption && (
<p className="px-4 py-3 text-center text-sm text-white/80">
{activeImage.caption}
</p>
)}
{/* Thumbnail strip di dalam lightbox */}
{hasMultiple && (
<div className="flex justify-center gap-1.5 overflow-x-auto px-4 pb-4">
{images.map((img, i) => (
<button
key={img.id}
type="button"
onClick={() => setActiveIndex(i)}
aria-label={`Lihat foto ${i + 1}`}
className={`relative h-12 w-16 shrink-0 overflow-hidden rounded-md transition-all ${
i === activeIndex
? "ring-2 ring-primary-400 ring-offset-1 ring-offset-black"
: "opacity-50 hover:opacity-100"
}`}
>
<Image
src={img.url}
alt=""
fill
className="object-cover"
sizes="64px"
/>
</button>
))}
</div>
)}
</div>
)}
</div> </div>
); );
} }
@@ -1,82 +0,0 @@
"use client";
import { LIMITS } from "@/lib/limits";
interface ImageUrlInputProps {
value: string[];
onChange: (urls: string[]) => void;
}
export function ImageUrlInput({ value, onChange }: ImageUrlInputProps) {
const urls = value.length > 0 ? value : [""];
const max = LIMITS.MAX_IMAGE_URLS;
function addField() {
if (urls.length < max) {
onChange([...urls, ""]);
}
}
function removeField(index: number) {
const next = urls.filter((_, i) => i !== index);
onChange(next.length > 0 ? next : [""]);
}
function updateField(index: number, next: string) {
const updated = [...urls];
updated[index] = next;
onChange(updated);
}
return (
<div>
<label className="mb-2 flex items-center justify-between">
<span className="text-sm font-semibold text-neutral-700">
Foto Trip (URL)
</span>
<span className="text-xs text-neutral-400">
{urls.length}/{max}
</span>
</label>
<div className="space-y-2">
{urls.map((url, i) => (
<div key={i} className="flex gap-2">
<input
type="url"
value={url}
onChange={(e) => updateField(i, e.target.value)}
className="flex-1 rounded-xl border border-neutral-200 bg-neutral-50 px-4 py-2.5 text-sm text-neutral-800 placeholder:text-neutral-400 focus:bg-white"
placeholder={
i === 0
? "URL foto utama (cover)"
: `URL foto ${i + 1} (opsional)`
}
/>
{urls.length > 1 && (
<button
type="button"
onClick={() => removeField(i)}
aria-label={`Hapus foto ${i + 1}`}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-neutral-200 text-neutral-400 hover:bg-red-50 hover:text-red-500"
>
</button>
)}
</div>
))}
</div>
{urls.length < max && (
<button
type="button"
onClick={addField}
className="mt-2 flex items-center gap-1 rounded-lg px-2 py-1 text-sm font-medium text-secondary-600 hover:bg-secondary-50"
>
+ Tambah foto
</button>
)}
<p className="mt-1.5 text-xs text-neutral-400">
Upload foto ke hosting (imgur, imgbb, dll) lalu paste URL-nya di sini.
</p>
</div>
);
}
@@ -143,7 +143,7 @@ export function JoinTripButton({
Kamu sudah{" "} Kamu sudah{" "}
<span className="font-semibold">terkonfirmasi</span> sebagai peserta <span className="font-semibold">terkonfirmasi</span> sebagai peserta
trip ini trip ini
{isFree && <span> trip gratis, tidak ada pembayaran 🎉</span>}. {isFree && <span> trip gratis, tidak ada pembayaran</span>}.
</div> </div>
)} )}
{needsPayment && ( {needsPayment && (
@@ -1,4 +1,5 @@
import Image from "next/image"; import Image from "next/image";
import { BadgeCheck, Star } from "lucide-react";
import type { OrganizerTrust } from "@/server/services/trust.service"; import type { OrganizerTrust } from "@/server/services/trust.service";
interface OrganizerTrustPanelProps { interface OrganizerTrustPanelProps {
@@ -13,7 +14,7 @@ export function OrganizerTrustPanel({
trust, trust,
}: OrganizerTrustPanelProps) { }: OrganizerTrustPanelProps) {
return ( return (
<div className="rounded-xl border border-neutral-200 bg-linear-to-br from-white to-neutral-50 p-4 sm:p-5"> <div className="rounded-xl border border-neutral-200 bg-white p-4 sm:p-5">
<h2 className="mb-3 text-xs font-bold text-neutral-700 sm:text-sm"> <h2 className="mb-3 text-xs font-bold text-neutral-700 sm:text-sm">
Organizer & kepercayaan Organizer & kepercayaan
</h2> </h2>
@@ -42,7 +43,8 @@ export function OrganizerTrustPanel({
className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-primary-800 sm:text-xs" className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-primary-800 sm:text-xs"
title="Identitas organizer telah diverifikasi (KTP & rekening)" title="Identitas organizer telah diverifikasi (KTP & rekening)"
> >
Verified Organizer <BadgeCheck size={12} strokeWidth={2} aria-hidden />
Verified Organizer
</span> </span>
)} )}
{trust.isTripLeader && ( {trust.isTripLeader && (
@@ -54,7 +56,7 @@ export function OrganizerTrustPanel({
</div> </div>
</div> </div>
<div className="flex flex-1 flex-wrap gap-3 border-t border-neutral-100 pt-3 sm:border-t-0 sm:border-l sm:pl-4 sm:pt-0"> <div className="flex flex-1 flex-wrap gap-3 border-t border-neutral-100 pt-3 sm:border-t-0 sm:border-l sm:pl-4 sm:pt-0">
<div className="min-w-[100px] rounded-lg bg-white/80 px-3 py-2 shadow-sm ring-1 ring-neutral-100"> <div className="min-w-25 rounded-lg bg-white/80 px-3 py-2 shadow-sm ring-1 ring-neutral-100">
<p className="text-[10px] font-medium text-neutral-500 sm:text-xs"> <p className="text-[10px] font-medium text-neutral-500 sm:text-xs">
Trip selesai Trip selesai
</p> </p>
@@ -67,7 +69,7 @@ export function OrganizerTrustPanel({
</p> </p>
)} )}
</div> </div>
<div className="min-w-[100px] rounded-lg bg-white/80 px-3 py-2 shadow-sm ring-1 ring-neutral-100"> <div className="min-w-25 rounded-lg bg-white/80 px-3 py-2 shadow-sm ring-1 ring-neutral-100">
<p className="text-[10px] font-medium text-neutral-500 sm:text-xs"> <p className="text-[10px] font-medium text-neutral-500 sm:text-xs">
Peserta dilayani Peserta dilayani
</p> </p>
@@ -75,12 +77,24 @@ export function OrganizerTrustPanel({
{trust.totalParticipantsServed} {trust.totalParticipantsServed}
</p> </p>
</div> </div>
<div className="min-w-[100px] rounded-lg bg-white/80 px-3 py-2 shadow-sm ring-1 ring-neutral-100"> <div className="min-w-25 rounded-lg bg-white/80 px-3 py-2 shadow-sm ring-1 ring-neutral-100">
<p className="text-[10px] font-medium text-neutral-500 sm:text-xs"> <p className="text-[10px] font-medium text-neutral-500 sm:text-xs">
Rating organizer Rating organizer
</p> </p>
<p className="text-lg font-bold text-amber-700"> <p className="flex items-center gap-1 text-lg font-bold text-amber-700">
{trust.avgRating != null ? `${trust.avgRating}` : "—"} {trust.avgRating != null ? (
<>
{trust.avgRating}
<Star
size={15}
strokeWidth={2}
fill="currentColor"
aria-hidden
/>
</>
) : (
"—"
)}
</p> </p>
{trust.reviewCount > 0 && ( {trust.reviewCount > 0 && (
<p className="text-[10px] text-neutral-400"> <p className="text-[10px] text-neutral-400">
+33 -8
View File
@@ -1,5 +1,12 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import {
MapPin,
CalendarDays,
UserRound,
BadgeCheck,
Sparkles,
} from "lucide-react";
import { formatRupiah } from "@/lib/utils"; import { formatRupiah } from "@/lib/utils";
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates"; import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
import { categoryMeta } from "@/lib/activity-category"; import { categoryMeta } from "@/lib/activity-category";
@@ -132,21 +139,38 @@ export function TripCard({
<div className="mt-3 space-y-1 text-sm text-neutral-600"> <div className="mt-3 space-y-1 text-sm text-neutral-600">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="text-xs text-secondary-500">📍</span> {location} <MapPin
size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0 text-neutral-400"
/>
<span className="truncate">{location}</span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="text-xs text-secondary-500">📅</span>{" "} <CalendarDays
{formatTripCalendarDateRangeLong(date, endDate)} size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0 text-neutral-400"
/>
<span>{formatTripCalendarDateRangeLong(date, endDate)}</span>
</div> </div>
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs text-secondary-500">👤</span>{" "} <UserRound
size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0 text-neutral-400"
/>
<span className="truncate">{organizerName}</span> <span className="truncate">{organizerName}</span>
{isVerifiedOrganizer && ( {isVerifiedOrganizer && (
<span <span
className="inline-flex items-center gap-0.5 rounded-full bg-primary-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-primary-800" className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-primary-800"
title="Identitas organizer telah diverifikasi (KTP & rekening)" title="Identitas organizer telah diverifikasi (KTP & rekening)"
> >
Verified <BadgeCheck size={11} strokeWidth={2} aria-hidden />
Verified
</span> </span>
)} )}
{isSmallGroup && ( {isSmallGroup && (
@@ -193,10 +217,11 @@ export function TripCard({
)} )}
{overlapCount > 0 && ( {overlapCount > 0 && (
<span <span
className="rounded-full bg-secondary-50 px-2 py-0.5 text-[11px] font-semibold text-secondary-700" className="inline-flex items-center gap-1 rounded-full bg-secondary-50 px-2 py-0.5 text-[11px] font-semibold text-secondary-700"
title="Peserta dengan minimal 1 minat sama dengan kamu" title="Peserta dengan minimal 1 minat sama dengan kamu"
> >
{overlapCount} peserta sama minat <Sparkles size={11} strokeWidth={2} aria-hidden />
{overlapCount} peserta sama minat
</span> </span>
)} )}
</div> </div>
+17 -44
View File
@@ -2,9 +2,12 @@
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import DatePicker from "react-datepicker"; import { Search } from "lucide-react";
import "react-datepicker/dist/react-datepicker.css"; import { DateRangeField } from "@/components/shared/date-picker";
import { formatLocalCalendarYmd } from "@/lib/trip-dates"; import {
formatLocalCalendarYmd,
localCalendarDateFromYmd,
} from "@/lib/trip-dates";
import { import {
ACTIVITY_CATEGORIES, ACTIVITY_CATEGORIES,
categoryMeta, categoryMeta,
@@ -43,12 +46,14 @@ export function TripFilter() {
isGroupSize(initialGroup) ? initialGroup : null isGroupSize(initialGroup) ? initialGroup : null
); );
const [query, setQuery] = useState(searchParams.get("q") ?? ""); const [query, setQuery] = useState(searchParams.get("q") ?? "");
const [startDate, setStartDate] = useState<Date | null>( const [startDate, setStartDate] = useState<Date | null>(() => {
searchParams.get("from") ? new Date(searchParams.get("from")!) : null const from = searchParams.get("from");
); return from ? localCalendarDateFromYmd(from) : null;
const [endDate, setEndDate] = useState<Date | null>( });
searchParams.get("to") ? new Date(searchParams.get("to")!) : null const [endDate, setEndDate] = useState<Date | null>(() => {
); const to = searchParams.get("to");
return to ? localCalendarDateFromYmd(to) : null;
});
function buildParams(overrides?: { function buildParams(overrides?: {
category?: ActivityCategory | null; category?: ActivityCategory | null;
@@ -259,18 +264,7 @@ export function TripFilter() {
</label> </label>
<div className="relative"> <div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"> <span className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400">
<svg <Search size={16} strokeWidth={1.75} aria-hidden />
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4"
>
<path
fillRule="evenodd"
d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
clipRule="evenodd"
/>
</svg>
</span> </span>
<input <input
type="text" type="text"
@@ -287,34 +281,13 @@ export function TripFilter() {
<label className="mb-1.5 block text-xs font-medium text-neutral-500"> <label className="mb-1.5 block text-xs font-medium text-neutral-500">
Tanggal Tanggal
</label> </label>
<div className="relative"> <DateRangeField
<span className="absolute left-3 top-1/2 z-10 -translate-y-1/2 text-neutral-400">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4"
>
<path
fillRule="evenodd"
d="M5.75 2a.75.75 0 01.75.75V4h7V2.75a.75.75 0 011.5 0V4h.25A2.75 2.75 0 0118 6.75v8.5A2.75 2.75 0 0115.25 18H4.75A2.75 2.75 0 012 15.25v-8.5A2.75 2.75 0 014.75 4H5V2.75A.75.75 0 015.75 2zm-1 5.5c-.69 0-1.25.56-1.25 1.25v6.5c0 .69.56 1.25 1.25 1.25h10.5c.69 0 1.25-.56 1.25-1.25v-6.5c0-.69-.56-1.25-1.25-1.25H4.75z"
clipRule="evenodd"
/>
</svg>
</span>
<DatePicker
selectsRange
startDate={startDate} startDate={startDate}
endDate={endDate} endDate={endDate}
onChange={handleDateChange} onChange={(start, end) => handleDateChange([start, end])}
minDate={new Date()} minDate={new Date()}
placeholderText="Pilih tanggal..."
dateFormat="dd MMM yyyy"
isClearable
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 py-2.5 pl-9 pr-3 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-primary-500 focus:bg-white"
/> />
</div> </div>
</div>
{/* Buttons */} {/* Buttons */}
<div className="flex gap-2 sm:shrink-0"> <div className="flex gap-2 sm:shrink-0">
@@ -0,0 +1,175 @@
"use client";
import { useRef, useState } from "react";
import Image from "next/image";
import { ImagePlus, X, Loader2 } from "lucide-react";
import { LIMITS } from "@/lib/limits";
interface TripImageUploadProps {
/** URL gambar yang sudah terunggah (path `/api/trip-images/...`). */
value: string[];
onChange: (urls: string[]) => void;
/** Lapor error ke form (mis. file terlalu besar / gagal upload). */
onError?: (msg: string) => void;
}
const ACCEPT_MIME = "image/jpeg,image/png,image/webp";
/** Sinkron dengan MAX_TRIP_IMAGE_UPLOAD_BYTES di lib/trip-image-storage.ts. */
const MAX_BYTES = 12 * 1024 * 1024;
/**
* Pengganti input URL foto: user memilih file dari perangkatnya, tiap file
* langsung di-upload & dikompres server-side. Form hanya menyimpan URL hasil.
*/
export function TripImageUpload({
value,
onChange,
onError,
}: TripImageUploadProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [uploadingCount, setUploadingCount] = useState(0);
const max = LIMITS.MAX_IMAGE_URLS;
const usedSlots = value.length + uploadingCount;
const remaining = max - usedSlots;
async function uploadOne(file: File): Promise<string | null> {
if (!ACCEPT_MIME.split(",").includes(file.type)) {
onError?.(`"${file.name}" harus JPG, PNG, atau WebP`);
return null;
}
if (file.size > MAX_BYTES) {
onError?.(`"${file.name}" melebihi 12MB`);
return null;
}
const fd = new FormData();
fd.set("file", file);
try {
const res = await fetch("/api/upload/trip-image", {
method: "POST",
body: fd,
});
const json = await res.json();
if (!res.ok) {
onError?.(json.error ?? `Gagal mengunggah "${file.name}"`);
return null;
}
return json.url as string;
} catch {
onError?.(`Gagal mengunggah "${file.name}"`);
return null;
}
}
async function handlePick(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? []);
e.target.value = "";
if (files.length === 0) return;
if (remaining <= 0) {
onError?.(`Maksimal ${max} foto`);
return;
}
const picked = files.slice(0, remaining);
if (files.length > remaining) {
onError?.(`Hanya ${remaining} foto pertama diunggah (maks ${max})`);
}
setUploadingCount((c) => c + picked.length);
// `value` di-snapshot saat handler dibuat; upload sekuensial supaya urutan
// foto stabil, lalu hasil yang berhasil ditambahkan sekali di akhir.
const uploaded: string[] = [];
for (const file of picked) {
const url = await uploadOne(file);
setUploadingCount((c) => c - 1);
if (url) uploaded.push(url);
}
if (uploaded.length > 0) onChange([...value, ...uploaded]);
}
function removeAt(index: number) {
onChange(value.filter((_, i) => i !== index));
}
return (
<div>
<label className="mb-2 flex items-center justify-between">
<span className="text-sm font-semibold text-neutral-700">
Foto Trip
</span>
<span className="text-xs text-neutral-400">
{value.length}/{max}
</span>
</label>
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
{value.map((url, i) => (
<div
key={url}
className="group relative aspect-square overflow-hidden rounded-xl border border-neutral-200 bg-neutral-100"
>
<Image
src={url}
alt={i === 0 ? "Foto cover" : `Foto ${i + 1}`}
fill
className="object-cover"
sizes="(max-width: 640px) 33vw, 160px"
/>
{i === 0 && (
<span className="absolute left-1 top-1 rounded-md bg-primary-600/90 px-1.5 py-0.5 text-[10px] font-bold text-white">
Cover
</span>
)}
<button
type="button"
onClick={() => removeAt(i)}
aria-label={`Hapus foto ${i + 1}`}
className="absolute right-1 top-1 flex h-6 w-6 items-center justify-center rounded-full bg-black/55 text-white transition-colors hover:bg-red-600"
>
<X size={13} strokeWidth={2.5} aria-hidden />
</button>
</div>
))}
{Array.from({ length: uploadingCount }).map((_, i) => (
<div
key={`uploading-${i}`}
className="flex aspect-square items-center justify-center rounded-xl border border-dashed border-neutral-300 bg-neutral-50"
>
<Loader2
size={20}
strokeWidth={2}
aria-hidden
className="animate-spin text-neutral-400"
/>
</div>
))}
{remaining > 0 && (
<button
type="button"
onClick={() => inputRef.current?.click()}
className="flex aspect-square flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-neutral-300 bg-neutral-50/60 text-neutral-500 transition-colors hover:border-primary-400 hover:text-primary-600"
>
<ImagePlus size={20} strokeWidth={1.75} aria-hidden />
<span className="text-[11px] font-semibold">Tambah</span>
</button>
)}
</div>
<input
ref={inputRef}
type="file"
accept={ACCEPT_MIME}
multiple
onChange={handlePick}
className="sr-only"
/>
<p className="mt-1.5 text-xs text-neutral-400">
Unggah langsung dari galeri/kamera JPG, PNG, atau WebP, maks 12MB per
foto. Foto pertama jadi cover. Gambar besar otomatis dikompres tanpa
mengorbankan kualitas.
</p>
</div>
);
}
+10 -2
View File
@@ -9,13 +9,21 @@ import {
} from "@/lib/trip-dates"; } from "@/lib/trip-dates";
import { isValidTimeFormat, timeToMinutes } from "@/lib/itinerary"; import { isValidTimeFormat, timeToMinutes } from "@/lib/itinerary";
/**
* Foto trip sekarang adalah file yang diunggah ke server sendiri, bukan URL
* eksternal. Nilai yang valid hanya path terkelola `/api/trip-images/<hex>.webp`
* yang dihasilkan route upload — regex ini sengaja ketat supaya URL arbitrary
* (yang dulu sering tidak reachable dari server) tidak bisa lolos lagi.
*/
export const tripImageUrlsSchema = z export const tripImageUrlsSchema = z
.array( .array(
z z
.string() .string()
.trim() .trim()
.max(LIMITS.MAX_URL_LENGTH, "URL terlalu panjang") .regex(
.url("Setiap URL gambar harus valid (http/https)") /^\/api\/trip-images\/[a-f0-9]{32}\.webp$/,
"Foto trip tidak valid — silakan unggah ulang fotonya"
)
) )
.max(LIMITS.MAX_IMAGE_URLS, `Maksimal ${LIMITS.MAX_IMAGE_URLS} foto`); .max(LIMITS.MAX_IMAGE_URLS, `Maksimal ${LIMITS.MAX_IMAGE_URLS} foto`);
+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}`; 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 * Simpan `trip.date` / `trip.endDate`: string `YYYY-MM-DD` diartikan sebagai
* **hari kalender UTC** yang sama (selaras dengan filter Open Trip). * **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 = { const nextConfig: NextConfig = {
images: { images: {
dangerouslyAllowSVG: true, 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: [ remotePatterns: [
{ {
protocol: "https", 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", "name": "setrip",
"version": "0.15.0", "version": "0.16.12",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -20,6 +20,7 @@
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"lucide-react": "^1.16.0",
"next": "^16.2.4", "next": "^16.2.4",
"next-auth": "^4.24.14", "next-auth": "^4.24.14",
"pg": "^8.20.0", "pg": "^8.20.0",
@@ -27,6 +28,8 @@
"react": "19.2.4", "react": "19.2.4",
"react-datepicker": "^9.1.0", "react-datepicker": "^9.1.0",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"resend": "^6.12.3",
"sharp": "^0.34.5",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
@@ -41,5 +44,10 @@
"tailwindcss": "^4", "tailwindcss": "^4",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5" "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,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;
+49
View File
@@ -467,6 +467,55 @@ model Refund {
@@index([status, createdAt]) @@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 /// Log polymorphic untuk admin actions lintas entity. Append-only — kalau
/// admin dihapus, `adminId` di-set NULL tapi `adminEmail` snapshot tetap. /// admin dihapus, `adminId` di-set NULL tapi `adminEmail` snapshot tetap.
/// Dipakai untuk compliance & investigasi (siapa approve/reject/cancel/ /// Dipakai untuk compliance & investigasi (siapa approve/reject/cancel/
+984 -259
View File
File diff suppressed because it is too large Load Diff
+77
View File
@@ -0,0 +1,77 @@
import { prisma } from "@/lib/prisma";
import { Prisma } from "@/app/generated/prisma/client";
import type { EmailJobStatus } from "@/app/generated/prisma/enums";
/** Filter untuk halaman admin email log. Keduanya opsional, match `contains`. */
export interface EmailLogFilters {
to?: string;
template?: string;
}
const LIST_LIMIT = 100;
function buildWhere<T extends { to?: unknown; template?: unknown }>(
filters: EmailLogFilters
): T {
const where = {} as T;
if (filters.to) {
(where as { to?: unknown }).to = {
contains: filters.to,
mode: "insensitive",
};
}
if (filters.template) {
(where as { template?: unknown }).template = {
contains: filters.template,
mode: "insensitive",
};
}
return where;
}
export const emailRepo = {
/** EmailJob (retry queue) per status — terbaru dulu. */
async listJobs(statuses: EmailJobStatus[], filters: EmailLogFilters) {
const where = buildWhere<Prisma.EmailJobWhereInput>(filters);
where.status = { in: statuses };
return prisma.emailJob.findMany({
where,
orderBy: { updatedAt: "desc" },
take: LIST_LIMIT,
});
},
/** EmailSent (log email berhasil terkirim) — terbaru dulu. */
async listSent(filters: EmailLogFilters) {
const where = buildWhere<Prisma.EmailSentWhereInput>(filters);
return prisma.emailSent.findMany({
where,
orderBy: { sentAt: "desc" },
take: LIST_LIMIT,
});
},
/**
* Statistik kesehatan pengiriman email — dipakai kartu ringkasan
* `/admin/emails` dan `/admin/system`.
* - `queued` : job menunggu dikirim (PENDING/PROCESSING).
* - `failed24h` : job gagal dalam 24 jam terakhir.
* - `deadLetter` : job gagal yang sudah habis 5 attempt — cron berhenti
* retry, butuh aksi manual admin.
*/
async stats() {
const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000);
const [queued, failed24h, deadLetter] = await Promise.all([
prisma.emailJob.count({
where: { status: { in: ["PENDING", "PROCESSING"] } },
}),
prisma.emailJob.count({
where: { status: "FAILED", updatedAt: { gte: since24h } },
}),
prisma.emailJob.count({
where: { status: "FAILED", attempts: { gte: 5 } },
}),
]);
return { queued, failed24h, deadLetter };
},
};
+68
View File
@@ -11,6 +11,7 @@ import {
} from "@/lib/midtrans"; } from "@/lib/midtrans";
import { isTripDepartureDayPast } from "@/lib/trip-dates"; import { isTripDepartureDayPast } from "@/lib/trip-dates";
import { payoutService } from "@/server/services/payout.service"; import { payoutService } from "@/server/services/payout.service";
import { emailService } from "@/lib/email/send";
const SERIAL_TX_ATTEMPTS = 6; const SERIAL_TX_ATTEMPTS = 6;
@@ -177,6 +178,16 @@ async function applyGatewayStatus(
}); });
const isConflict = const isConflict =
newStatus === "PAID" && finalBooking?.status !== "PAID"; newStatus === "PAID" && finalBooking?.status !== "PAID";
// Notif email user kalau payment benar-benar berhasil di-apply ke booking.
if (newStatus === "PAID" && !isConflict) {
void notifyPaymentPaid(payment.id);
}
// E3.3 — pembayaran kadaluarsa/gagal: kabari user supaya bisa retry.
if (newStatus === "EXPIRED" || newStatus === "FAILED") {
void notifyPaymentFailed(payment.id);
}
return { return {
ok: true, ok: true,
status: isConflict ? "booking_conflict" : "updated", status: isConflict ? "booking_conflict" : "updated",
@@ -469,6 +480,63 @@ export const paymentService = {
}, },
}; };
async function notifyPaymentPaid(paymentId: string) {
const payment = await prisma.payment.findUnique({
where: { id: paymentId },
include: {
booking: {
include: {
user: { select: { email: true, name: true } },
trip: { select: { id: true, title: true } },
},
},
},
});
if (!payment) return;
await emailService.send({
to: payment.booking.user.email,
idempotencyKey: `payment_paid-${payment.id}`,
template: {
template: "payment_paid",
data: {
userName: payment.booking.user.name,
tripTitle: payment.booking.trip.title,
tripId: payment.booking.trip.id,
amount: payment.amount,
},
},
});
}
/** E3.3 — kabari user kalau pembayaran expired/gagal supaya bisa retry. */
async function notifyPaymentFailed(paymentId: string) {
const payment = await prisma.payment.findUnique({
where: { id: paymentId },
include: {
booking: {
include: {
user: { select: { email: true, name: true } },
trip: { select: { id: true, title: true } },
},
},
},
});
if (!payment) return;
await emailService.send({
to: payment.booking.user.email,
idempotencyKey: `payment_expired-${payment.id}`,
template: {
template: "payment_expired",
data: {
userName: payment.booking.user.name,
tripTitle: payment.booking.trip.title,
tripId: payment.booking.trip.id,
amount: payment.amount,
},
},
});
}
// Re-export untuk testing langsung kalau perlu (tetap private dari modul lain). // Re-export untuk testing langsung kalau perlu (tetap private dari modul lain).
export const _internal = { applyGatewayStatus }; export const _internal = { applyGatewayStatus };
export type { MidtransTransactionStatus }; export type { MidtransTransactionStatus };
+28
View File
@@ -1,6 +1,7 @@
import { Prisma } from "@/app/generated/prisma/client"; import { Prisma } from "@/app/generated/prisma/client";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { payoutRepo } from "@/server/repositories/payout.repo"; import { payoutRepo } from "@/server/repositories/payout.repo";
import { emailService } from "@/lib/email/send";
const SERIAL_TX_ATTEMPTS = 6; const SERIAL_TX_ATTEMPTS = 6;
@@ -134,6 +135,33 @@ export const payoutService = {
where: { id: { in: ids }, status: "HELD" }, where: { id: { in: ids }, status: "HELD" },
data: { status: "RELEASED", releasedAt: now }, data: { status: "RELEASED", releasedAt: now },
}); });
// E3.6 — kabari organizer payout-nya sudah lepas hold & masuk antrian
// transfer. Pin ke `releasedAt: now` supaya hanya yang baru di-release.
const released = await prisma.payout.findMany({
where: { id: { in: ids }, status: "RELEASED", releasedAt: now },
select: {
id: true,
amount: true,
organizer: { select: { email: true, name: true } },
trip: { select: { title: true } },
},
});
for (const p of released) {
void emailService.send({
to: p.organizer.email,
idempotencyKey: `payout_released-${p.id}`,
template: {
template: "payout_released",
data: {
organizerName: p.organizer.name,
tripTitle: p.trip.title,
amount: p.amount,
},
},
});
}
return { releasedIds: ids }; return { releasedIds: ids };
}, },
+9 -34
View File
@@ -1,43 +1,10 @@
import { randomBytes } from "crypto"; import { randomBytes } from "crypto";
import { Prisma } from "@/app/generated/prisma/client"; import { Prisma } from "@/app/generated/prisma/client";
import { prisma } from "@/lib/prisma";
import { refundRepo } from "@/server/repositories/refund.repo"; import { refundRepo } from "@/server/repositories/refund.repo";
import { calculateRefundAmount, daysUntilDeparture } from "@/lib/refund-policy"; import { calculateRefundAmount, daysUntilDeparture } from "@/lib/refund-policy";
import { isTripDepartureDayPast } from "@/lib/trip-dates"; import { isTripDepartureDayPast } from "@/lib/trip-dates";
import { payoutService } from "@/server/services/payout.service"; import { payoutService } from "@/server/services/payout.service";
import { runSerializable } from "@/lib/serializable-tx";
const SERIAL_TX_ATTEMPTS = 6;
function isSerializationConflict(err: unknown): boolean {
return (
typeof err === "object" &&
err !== null &&
"code" in err &&
(err as { code: string }).code === "P2034"
);
}
async function runSerializable<T>(fn: (tx: Prisma.TransactionClient) => Promise<T>): 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("Gagal memproses refund. Coba lagi sebentar.");
}
function newIdempotencyKey(): string { function newIdempotencyKey(): string {
return `refund_${randomBytes(16).toString("hex")}`; return `refund_${randomBytes(16).toString("hex")}`;
@@ -358,6 +325,14 @@ export const refundService = {
if (booking.userId !== input.userId) { if (booking.userId !== input.userId) {
throw new Error("Booking ini bukan milikmu"); throw new Error("Booking ini bukan milikmu");
} }
if (
booking.trip.status === "CLOSED" ||
booking.trip.status === "COMPLETED"
) {
throw new Error(
"Trip sudah dibatalkan/selesai — pembatalan mandiri ditutup. Hubungi admin untuk proses refund."
);
}
if (booking.status !== "PAID") { if (booking.status !== "PAID") {
throw new Error( throw new Error(
"Hanya booking PAID yang bisa cancel dengan refund. Booking yang belum lunas bisa cancel dari tombol 'Batal Ikut'." "Hanya booking PAID yang bisa cancel dengan refund. Booking yang belum lunas bisa cancel dari tombol 'Batal Ikut'."
+169 -134
View File
@@ -1,9 +1,7 @@
import { cache } from "react";
import { Prisma } from "@/app/generated/prisma/client"; import { Prisma } from "@/app/generated/prisma/client";
import type { ActivityCategory, Vibe } from "@/app/generated/prisma/enums"; import type { ActivityCategory, Vibe } from "@/app/generated/prisma/enums";
import { prisma } from "@/lib/prisma";
import { tripRepo, type TripFilters } from "@/server/repositories/trip.repo"; import { tripRepo, type TripFilters } from "@/server/repositories/trip.repo";
import { participantRepo } from "@/server/repositories/participant.repo";
import { bookingRepo } from "@/server/repositories/booking.repo";
import { refundService } from "@/server/services/refund.service"; import { refundService } from "@/server/services/refund.service";
import { payoutService } from "@/server/services/payout.service"; import { payoutService } from "@/server/services/payout.service";
import { payoutRepo } from "@/server/repositories/payout.repo"; import { payoutRepo } from "@/server/repositories/payout.repo";
@@ -11,17 +9,7 @@ import { LIMITS } from "@/lib/limits";
import { utcStartOfDay, isTripDepartureDayPast } from "@/lib/trip-dates"; import { utcStartOfDay, isTripDepartureDayPast } from "@/lib/trip-dates";
import { isFreeTrip } from "@/lib/trip-pricing"; import { isFreeTrip } from "@/lib/trip-pricing";
import type { ItineraryItemInput } from "@/lib/itinerary"; import type { ItineraryItemInput } from "@/lib/itinerary";
import { runSerializable } from "@/lib/serializable-tx";
const SERIAL_TX_ATTEMPTS = 6;
function isSerializationConflict(err: unknown): boolean {
return (
typeof err === "object" &&
err !== null &&
"code" in err &&
(err as { code: string }).code === "P2034"
);
}
interface CreateTripInput { interface CreateTripInput {
category: ActivityCategory; category: ActivityCategory;
@@ -51,13 +39,18 @@ export const tripService = {
return tripRepo.findAll(); return tripRepo.findAll();
}, },
async getTripById(id: string) { /**
* Ambil trip by id. Dibungkus `React.cache()` `generateMetadata`, body
* halaman, dan `opengraph-image` memanggil ini untuk id yang sama dalam satu
* request, jadi query Prisma yang berat ini cukup jalan sekali per request.
*/
getTripById: cache(async (id: string) => {
const trip = await tripRepo.findById(id); const trip = await tripRepo.findById(id);
if (!trip) { if (!trip) {
throw new Error("Trip tidak ditemukan"); throw new Error("Trip tidak ditemukan");
} }
return trip; return trip;
}, }),
async createTrip(input: CreateTripInput) { async createTrip(input: CreateTripInput) {
if (isTripDepartureDayPast(input.date)) { if (isTripDepartureDayPast(input.date)) {
@@ -106,11 +99,7 @@ export const tripService = {
itineraryItems, itineraryItems,
} satisfies Prisma.TripCreateInput; } satisfies Prisma.TripCreateInput;
let lastErr: unknown; return runSerializable(async (tx) => {
for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) {
try {
return await prisma.$transaction(
async (tx) => {
const todayCount = await tx.trip.count({ const todayCount = await tx.trip.count({
where: { where: {
organizerId: input.organizerId, organizerId: input.organizerId,
@@ -123,32 +112,11 @@ export const tripService = {
); );
} }
return tx.trip.create({ data: tripData }); return tx.trip.create({ data: tripData });
}, }, "Gagal membuat trip. Coba lagi sebentar.");
{
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("Gagal membuat trip. Coba lagi sebentar.");
}, },
async joinTrip(tripId: string, userId: string) { async joinTrip(tripId: string, userId: string) {
let lastErr: unknown; return runSerializable(async (tx) => {
for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) {
try {
return await prisma.$transaction(
async (tx) => {
const trip = await tx.trip.findUnique({ const trip = await tx.trip.findUnique({
where: { id: tripId }, where: { id: tripId },
select: { select: {
@@ -231,57 +199,57 @@ export const tripService = {
} }
return participant; return participant;
}, }, "Pendaftaran sedang ramai. Coba lagi sebentar.");
{
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("Pendaftaran sedang ramai. Coba lagi sebentar.");
}, },
/**
* Peserta batal ikut trip (untuk booking yang BELUM lunas). Seluruh read +
* write dibungkus satu transaksi Serializable supaya aman dari race:
* - Status booking dicek di dalam tx, dan `booking.updateMany` difilter
* `PENDING/AWAITING_PAY` kalau webhook pembayaran menandai booking PAID
* bersamaan, booking lunas TIDAK ikut di-cancel (cegah uang menggantung
* tanpa Refund record).
* - Re-open trip FULL OPEN dilakukan di tx yang sama dan kondisional
* (`status: "FULL"`) supaya tidak menimpa trip yang sudah CLOSED.
*/
async cancelJoin(tripId: string, userId: string) { async cancelJoin(tripId: string, userId: string) {
const trip = await tripRepo.findById(tripId); return runSerializable(async (tx) => {
const trip = await tx.trip.findUnique({
where: { id: tripId },
select: { id: true, status: true, date: true, maxParticipants: true },
});
if (!trip) { if (!trip) {
throw new Error("Trip tidak ditemukan"); throw new Error("Trip tidak ditemukan");
} }
if (isTripDepartureDayPast(trip.date)) { if (isTripDepartureDayPast(trip.date)) {
throw new Error( throw new Error(
"Tanggal berangkat trip sudah lewat — pendaftaran tidak bisa dibatalkan. Jika trip sudah selesai, gunakan bagian ulasan." "Tanggal berangkat trip sudah lewat — pendaftaran tidak bisa dibatalkan. Jika trip sudah selesai, gunakan bagian ulasan."
); );
} }
const existing = await participantRepo.findByTripAndUser(tripId, userId); const existing = await tx.tripParticipant.findUnique({
where: { tripId_userId: { tripId, userId } },
});
if (!existing || existing.status === "CANCELLED") { if (!existing || existing.status === "CANCELLED") {
throw new Error("Kamu tidak terdaftar di trip ini"); throw new Error("Kamu tidak terdaftar di trip ini");
} }
// Safety: kalau booking sudah PAID, paksa lewat refund flow supaya tidak // Safety: kalau booking sudah PAID/PARTIALLY_REFUNDED, paksa lewat refund
// ada uang menggantung tanpa Refund record. // flow supaya tidak ada uang menggantung tanpa Refund record. Dicek di
const existingBooking = await bookingRepo.findByTripAndUser(tripId, userId); // dalam tx supaya konsisten dengan webhook pembayaran.
const booking = await tx.booking.findUnique({
where: { participantId: existing.id },
select: { status: true },
});
if ( if (
existingBooking && booking &&
(existingBooking.status === "PAID" || (booking.status === "PAID" || booking.status === "PARTIALLY_REFUNDED")
existingBooking.status === "PARTIALLY_REFUNDED")
) { ) {
throw new Error( throw new Error(
"Booking kamu sudah lunas — pakai tombol 'Cancel & Request Refund' supaya uang bisa dikembalikan." "Booking kamu sudah lunas — pakai tombol 'Cancel & Request Refund' supaya uang bisa dikembalikan."
); );
} }
const result = await prisma.$transaction(async (tx) => {
const cancelled = await tx.tripParticipant.update({ const cancelled = await tx.tripParticipant.update({
where: { tripId_userId: { tripId, userId } }, where: { tripId_userId: { tripId, userId } },
data: { data: {
@@ -290,21 +258,33 @@ export const tripService = {
paymentConfirmedAt: null, paymentConfirmedAt: null,
}, },
}); });
// Hanya cancel booking yang belum lunas — filter status menutup race
// dengan webhook pembayaran yang bisa menandai PAID secara bersamaan.
await tx.booking.updateMany({ await tx.booking.updateMany({
where: { participantId: existing.id }, where: {
participantId: existing.id,
status: { in: ["PENDING", "AWAITING_PAY"] },
},
data: { status: "CANCELLED" }, data: { status: "CANCELLED" },
}); });
// Slot kembali kosong → re-open trip. Kondisional `status: "FULL"`
// supaya tidak menghidupkan trip yang sudah CLOSED/COMPLETED.
if (trip.status === "FULL") {
const activeCount = await tx.tripParticipant.count({
where: { tripId, status: { not: "CANCELLED" } },
});
if (activeCount < trip.maxParticipants) {
await tx.trip.updateMany({
where: { id: tripId, status: "FULL" },
data: { status: "OPEN" },
});
}
}
return cancelled; return cancelled;
}); });
if (trip.status === "FULL") {
const count = await participantRepo.countByTrip(tripId);
if (count < trip.maxParticipants) {
await tripRepo.updateStatus(tripId, "OPEN");
}
}
return result;
}, },
async confirmParticipant( async confirmParticipant(
@@ -312,15 +292,24 @@ export const tripService = {
participantId: string, participantId: string,
organizerId: string organizerId: string
) { ) {
const trip = await tripRepo.findById(tripId); return runSerializable(async (tx) => {
const trip = await tx.trip.findUnique({
where: { id: tripId },
select: { id: true, organizerId: true, price: true },
});
if (!trip) { if (!trip) {
throw new Error("Trip tidak ditemukan"); throw new Error("Trip tidak ditemukan");
} }
if (trip.organizerId !== organizerId) { if (trip.organizerId !== organizerId) {
throw new Error("Hanya organizer trip ini yang bisa mengonfirmasi peserta"); throw new Error(
"Hanya organizer trip ini yang bisa mengonfirmasi peserta"
);
} }
const participant = await participantRepo.findById(participantId); const participant = await tx.tripParticipant.findUnique({
where: { id: participantId },
select: { id: true, tripId: true, status: true },
});
if (!participant || participant.tripId !== tripId) { if (!participant || participant.tripId !== tripId) {
throw new Error("Peserta tidak ditemukan"); throw new Error("Peserta tidak ditemukan");
} }
@@ -328,17 +317,28 @@ export const tripService = {
throw new Error("Peserta ini tidak dalam status menunggu persetujuan"); throw new Error("Peserta ini tidak dalam status menunggu persetujuan");
} }
// Trip gratis: Booking langsung PAID. Trip berbayar: AWAITING_PAY (tinggal bayar). // Trip gratis: Booking langsung PAID. Trip berbayar: AWAITING_PAY.
const nextBookingStatus = isFreeTrip(trip) ? "PAID" : "AWAITING_PAY"; const nextBookingStatus = isFreeTrip(trip) ? "PAID" : "AWAITING_PAY";
return prisma.$transaction(async (tx) => { // Update kondisional — kalau peserta sudah berubah status (mis. user
// batal ikut bersamaan), count 0 → tolak, jangan resurrect peserta.
const confirmed = await tx.tripParticipant.updateMany({
where: { id: participantId, status: "PENDING" },
data: { status: "CONFIRMED" },
});
if (confirmed.count === 0) {
throw new Error("Peserta ini tidak dalam status menunggu persetujuan");
}
// Filter `not CANCELLED` supaya booking yang sudah dibatalkan tidak
// ikut "dihidupkan" kembali oleh konfirmasi yang balapan.
await tx.booking.updateMany({ await tx.booking.updateMany({
where: { participantId }, where: { participantId, status: { not: "CANCELLED" } },
data: { status: nextBookingStatus }, data: { status: nextBookingStatus },
}); });
return tx.tripParticipant.update({
return tx.tripParticipant.findUniqueOrThrow({
where: { id: participantId }, where: { id: participantId },
data: { status: "CONFIRMED" },
}); });
}); });
}, },
@@ -348,45 +348,79 @@ export const tripService = {
participantId: string, participantId: string,
organizerId: string organizerId: string
) { ) {
const trip = await tripRepo.findById(tripId); return runSerializable(async (tx) => {
const trip = await tx.trip.findUnique({
where: { id: tripId },
select: {
id: true,
status: true,
organizerId: true,
maxParticipants: true,
},
});
if (!trip) { if (!trip) {
throw new Error("Trip tidak ditemukan"); throw new Error("Trip tidak ditemukan");
} }
if (trip.organizerId !== organizerId) { if (trip.organizerId !== organizerId) {
throw new Error("Hanya organizer trip ini yang bisa menolak permintaan bergabung"); throw new Error(
"Hanya organizer trip ini yang bisa menolak permintaan bergabung"
);
} }
const participant = await participantRepo.findById(participantId); const participant = await tx.tripParticipant.findUnique({
where: { id: participantId },
select: { id: true, tripId: true, status: true },
});
if (!participant || participant.tripId !== tripId) { if (!participant || participant.tripId !== tripId) {
throw new Error("Peserta tidak ditemukan"); throw new Error("Peserta tidak ditemukan");
} }
if (participant.status !== "PENDING") { if (participant.status !== "PENDING") {
throw new Error("Hanya permintaan yang masih menunggu yang bisa ditolak"); throw new Error(
"Hanya permintaan yang masih menunggu yang bisa ditolak"
);
} }
await prisma.$transaction(async (tx) => { // Update kondisional pada status PENDING — aman dari race dengan
await tx.tripParticipant.update({ // confirm/cancel yang berjalan bersamaan.
where: { id: participantId }, const rejected = await tx.tripParticipant.updateMany({
where: { id: participantId, status: "PENDING" },
data: { data: {
status: "CANCELLED", status: "CANCELLED",
markedPaidAt: null, markedPaidAt: null,
paymentConfirmedAt: null, paymentConfirmedAt: null,
}, },
}); });
if (rejected.count === 0) {
throw new Error(
"Hanya permintaan yang masih menunggu yang bisa ditolak"
);
}
// Peserta PENDING belum bisa punya booking lunas, tapi filter status
// tetap dipasang supaya tidak pernah menimpa booking PAID.
await tx.booking.updateMany({ await tx.booking.updateMany({
where: { participantId }, where: {
participantId,
status: { in: ["PENDING", "AWAITING_PAY"] },
},
data: { status: "CANCELLED" }, data: { status: "CANCELLED" },
}); });
});
// Slot kembali kosong → re-open trip, kondisional `status: "FULL"`.
if (trip.status === "FULL") { if (trip.status === "FULL") {
const count = await participantRepo.countByTrip(tripId); const activeCount = await tx.tripParticipant.count({
if (count < trip.maxParticipants) { where: { tripId, status: { not: "CANCELLED" } },
await tripRepo.updateStatus(tripId, "OPEN"); });
if (activeCount < trip.maxParticipants) {
await tx.trip.updateMany({
where: { id: tripId, status: "FULL" },
data: { status: "OPEN" },
});
} }
} }
return { ok: true as const }; return { ok: true as const };
});
}, },
/** /**
@@ -427,25 +461,23 @@ export const tripService = {
| { type: "ORGANIZER"; userId: string } | { type: "ORGANIZER"; userId: string }
| { type: "ADMIN"; adminId: string; reason: string } | { type: "ADMIN"; adminId: string; reason: string }
) { ) {
let lastErr: unknown; return runSerializable(async (tx) => {
for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) {
try {
return await prisma.$transaction(
async (tx) => {
const trip = await tx.trip.findUnique({ const trip = await tx.trip.findUnique({
where: { id: tripId }, where: { id: tripId },
select: { id: true, status: true, organizerId: true, date: true }, select: {
id: true,
status: true,
organizerId: true,
date: true,
title: true,
organizer: { select: { id: true, email: true, name: true } },
},
}); });
if (!trip) { if (!trip) {
throw new Error("Trip tidak ditemukan"); throw new Error("Trip tidak ditemukan");
} }
if ( if (actor.type === "ORGANIZER" && trip.organizerId !== actor.userId) {
actor.type === "ORGANIZER" && throw new Error("Hanya organizer trip ini yang bisa membatalkan trip");
trip.organizerId !== actor.userId
) {
throw new Error(
"Hanya organizer trip ini yang bisa membatalkan trip"
);
} }
if (trip.status === "CLOSED") { if (trip.status === "CLOSED") {
throw new Error("Trip sudah dibatalkan"); throw new Error("Trip sudah dibatalkan");
@@ -481,6 +513,8 @@ export const tripService = {
const refundsCreated: string[] = []; const refundsCreated: string[] = [];
const cancelledBookings: string[] = []; const cancelledBookings: string[] = [];
const skippedBookings: string[] = []; const skippedBookings: string[] = [];
// userId → nominal refund yang dibuat untuk dia (untuk email pembatalan).
const refundByUser = new Map<string, number>();
for (const b of bookings) { for (const b of bookings) {
if (b.status === "CANCELLED" || b.status === "EXPIRED") { if (b.status === "CANCELLED" || b.status === "EXPIRED") {
@@ -521,6 +555,7 @@ export const tripService = {
} }
); );
refundsCreated.push(refund.id); refundsCreated.push(refund.id);
refundByUser.set(b.userId, remaining);
// Trip dibatalkan → cancel payout HELD/RELEASED organizer untuk // Trip dibatalkan → cancel payout HELD/RELEASED organizer untuk
// booking ini. Payout PAID di-flag clawback otomatis. // booking ini. Payout PAID di-flag clawback otomatis.
@@ -541,6 +576,12 @@ export const tripService = {
} }
} }
// Snapshot peserta aktif SEBELUM di-cancel — untuk notifikasi email.
const activeParticipants = await tx.tripParticipant.findMany({
where: { tripId, status: { not: "CANCELLED" } },
select: { user: { select: { id: true, email: true, name: true } } },
});
// Semua participant aktif → CANCELLED (apapun status booking-nya). // Semua participant aktif → CANCELLED (apapun status booking-nya).
await tx.tripParticipant.updateMany({ await tx.tripParticipant.updateMany({
where: { tripId, status: { not: "CANCELLED" } }, where: { tripId, status: { not: "CANCELLED" } },
@@ -567,24 +608,18 @@ export const tripService = {
refundsCreated, refundsCreated,
cancelledBookings, cancelledBookings,
skippedBookings, skippedBookings,
}; // Data penerima notifikasi — email dikirim oleh action setelah tx commit.
}, notify: {
{ tripTitle: trip.title,
isolationLevel: Prisma.TransactionIsolationLevel.Serializable, organizer: trip.organizer,
maxWait: 5000, participants: activeParticipants.map((p) => ({
timeout: 15000, userId: p.user.id,
} email: p.user.email,
); name: p.user.name,
} catch (e) { refundAmount: refundByUser.get(p.user.id) ?? 0,
lastErr = e; })),
if (isSerializationConflict(e) && attempt < SERIAL_TX_ATTEMPTS - 1) { },
continue; };
} }, "Gagal membatalkan trip. Coba lagi sebentar.");
throw e;
}
}
throw lastErr instanceof Error
? lastErr
: new Error("Gagal membatalkan trip. Coba lagi sebentar.");
}, },
}; };