diff --git a/PAYMENT_ROADMAP.md b/PAYMENT_ROADMAP.md new file mode 100644 index 0000000..432f817 --- /dev/null +++ b/PAYMENT_ROADMAP.md @@ -0,0 +1,244 @@ +# Setrip — Payment Roadmap + +Rencana implementasi flow pembayaran: handle trip gratis, halaman detail payment, dan integrasi Midtrans. + +> **Prinsip:** Sumber kebenaran pembayaran = record di tabel khusus, bukan timestamp di `TripParticipant`. Manual transfer & Midtrans = dua provider dari pipeline yang sama. Trip gratis = first-class case (bukan paid trip dengan amount 0). + +--- + +## Audit state sekarang (baseline) + +- Field `TripParticipant.markedPaidAt` + `paymentConfirmedAt` — dual-timestamp manual. +- Flow: user klik "Saya sudah bayar" → organizer klik "Konfirmasi pembayaran". Tidak ada record transaksi terpisah, tidak ada bukti transfer, tidak ada amount snapshot. +- Tidak ada halaman `/trips/[id]/payment`. Tombol bayar inline di trip detail. +- **Bug**: trip gratis (`price === 0`) tetap melewati flow yang sama — peserta tetap harus klik "Saya sudah bayar" dan organizer tetap harus konfirmasi. + +File terkait: [server/services/trip.service.ts](server/services/trip.service.ts) (`markParticipantPayment`, `confirmParticipantPayment`), [features/booking/actions.ts](features/booking/actions.ts), [features/booking/components/organizer-payment-queue.tsx](features/booking/components/organizer-payment-queue.tsx), [features/trip/components/join-trip-button.tsx](features/trip/components/join-trip-button.tsx), [server/repositories/participant.repo.ts](server/repositories/participant.repo.ts). + +--- + +## PR A — Free trip handling + halaman detail payment (manual flow) ✅ + +Selesai. `tsc --noEmit` lulus. Tanpa schema baru, tanpa migration. + +**Keputusan akses control:** halaman `/trips/[id]/payment` boleh diakses peserta apapun yang aktif (PENDING/CONFIRMED). Peserta PENDING bisa lihat nominal + rekening untuk persiapan, tapi diberi notice "tunggu approve dulu sebelum transfer". Organizer trip-nya sendiri di-redirect ke trip detail. + +| # | Item | Status | File | +|---|---|---|---| +| A1 | Helper `lib/trip-pricing.ts` dengan `isFreeTrip` & `isPaidTrip` | ✅ | [lib/trip-pricing.ts](lib/trip-pricing.ts) | +| A2 | Service guard: `markParticipantPayment` & `confirmParticipantPayment` reject trip gratis | ✅ | [server/services/trip.service.ts](server/services/trip.service.ts) | +| A3 | UI gate di JoinTripButton: hide flow pembayaran kalau gratis | ✅ | [features/trip/components/join-trip-button.tsx](features/trip/components/join-trip-button.tsx) | +| A4 | UI gate di trip detail: skip `OrganizerPaymentQueue` kalau gratis | ✅ | [app/trips/[id]/page.tsx](app/trips/[id]/page.tsx) | +| A5 | Halaman `/trips/[id]/payment` (server component dengan akses kontrol) | ✅ | [app/trips/[id]/payment/page.tsx](app/trips/%5Bid%5D/payment/page.tsx) | +| A6 | Konten halaman trip gratis: banner 🎉 + status keikutsertaan + CTA | ✅ | `FreeTripSection` di [page.tsx](app/trips/%5Bid%5D/payment/page.tsx) | +| A7 | Konten halaman trip berbayar: timeline + rekening organizer + tombol "Saya sudah bayar" + status | ✅ | `PaidTripSection`, `PaymentTimeline`, `BankRow` di [page.tsx](app/trips/%5Bid%5D/payment/page.tsx) | +| A8 | Link dari trip detail → `/trips/[id]/payment` (replace tombol inline) | ✅ | `showPaymentLink` di [join-trip-button.tsx](features/trip/components/join-trip-button.tsx) | +| A9 | Metadata + `robots: noindex` halaman payment | ✅ | [page.tsx](app/trips/%5Bid%5D/payment/page.tsx) — `metadata.robots: { index: false, follow: false }` | +| A+ | `MarkPaidButton` (client component) untuk action "Saya sudah bayar" | ✅ | [features/booking/components/mark-paid-button.tsx](features/booking/components/mark-paid-button.tsx) | +| A+ | `CopyButton` (client component) untuk copy nomor rekening / nominal | ✅ | [features/booking/components/copy-button.tsx](features/booking/components/copy-button.tsx) | + +**Tindakan manual:** tidak ada. Tidak ada migration di PR A. + +**Catatan edge case yang sudah dihandle:** +- Organizer trip-nya sendiri buka halaman → redirect ke trip detail. +- User belum login → redirect ke login dengan `callbackUrl`. +- User belum join / sudah cancel → tampil notice "kamu belum terdaftar". +- Trip dengan organizer yang belum APPROVED verifikasinya → tampil notice "rekening belum tersedia, hubungi organizer langsung" (tidak crash). +- Peserta PENDING di-warning "tunggu approve dulu sebelum transfer". +- Tombol "Saya sudah bayar" hanya muncul untuk CONFIRMED + belum mark + bank tersedia. + +--- + +## PR B — Refactor schema ke `Booking` + `Payment` (provider MANUAL only) ⏳ + +Pondasi untuk Midtrans tanpa lompat ke gateway dulu. UI tetap, sumber kebenaran pindah dari timestamp di `TripParticipant` ke tabel `Booking`/`Payment`. + +### Schema (target) + +```prisma +model Booking { + id String @id @default(cuid()) + tripId String + trip Trip @relation(fields: [tripId], references: [id]) + userId String + user User @relation(fields: [userId], references: [id]) + participantId String @unique + participant TripParticipant @relation(fields: [participantId], references: [id]) + + /// Snapshot harga saat booking dibuat — protect dari perubahan trip.price. + amount Int + currency String @default("IDR") + status BookingStatus @default(PENDING) + + payments Payment[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([tripId, status]) + @@index([userId]) +} + +enum BookingStatus { + PENDING // join belum approve atau belum bayar + AWAITING_PAY // approved, tinggal bayar + PAID // lunas (manual confirm atau gateway settlement) + CANCELLED + REFUNDED + EXPIRED +} + +model Payment { + id String @id @default(cuid()) + bookingId String + booking Booking @relation(fields: [bookingId], references: [id]) + + provider PaymentProvider + /// order_id eksternal — untuk MANUAL referensi internal, untuk MIDTRANS dikirim ke gateway. Unik per attempt. + externalOrderId String @unique + externalTxId String? + method String? // bca_va, gopay, qris, manual_transfer, dst + amount Int + status PaymentStatus @default(PENDING) + + rawCallback Json? + snapToken String? + expiresAt DateTime? + + paidAt DateTime? + failedAt DateTime? + rejectionReason String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([bookingId, status]) + @@index([provider, status]) +} + +enum PaymentProvider { + MANUAL + MIDTRANS +} + +enum PaymentStatus { + PENDING + AWAITING + PAID + FAILED + EXPIRED + CANCELLED + REFUNDED +} +``` + +### Tugas + +| # | Item | Status | Catatan | +|---|---|---|---| +| B1 | Update [prisma/schema.prisma](prisma/schema.prisma) — tambah `Booking`, `Payment`, 3 enum | ⏳ | Relasi 1-1 ke `TripParticipant` via `participantId` unique. | +| B2 | Migration baru `add_booking_payment` | ⏳ | CreateTable Booking & Payment + CreateEnum + index. | +| B3 | Data backfill migration: bikin `Booking` + `Payment` untuk semua `TripParticipant` aktif yang sudah punya `markedPaidAt` atau `paymentConfirmedAt` | ⏳ | Provider `MANUAL`. Status mapping: `paymentConfirmedAt → PAID`, `markedPaidAt only → AWAITING`, none → PENDING. Trip gratis → Booking PAID, tanpa Payment row. | +| B4 | `server/repositories/booking.repo.ts` + `payment.repo.ts` | ⏳ | findByTrip, findByUser, findByExternalOrderId. | +| B5 | `server/services/booking.service.ts` | ⏳ | `createForParticipant`, `markPaid (manual)`, `confirmPaid (organizer)`, `expireOldPending`. Idempotent. | +| B6 | Refactor `tripService.markParticipantPayment` → delegate ke `bookingService` | ⏳ | Backward compat: tetap update `TripParticipant` timestamp untuk transisi UI. | +| B7 | Refactor `confirmParticipantPayment` → delegate ke `bookingService` | ⏳ | Sama. | +| B8 | Update halaman payment (PR A) untuk baca dari `Booking`/`Payment` | ⏳ | Status timeline lebih kaya: dibuat → menunggu bayar → menunggu konfirmasi → lunas. | +| B9 | Update `OrganizerPaymentQueue` query | ⏳ | List Booking dengan Payment status AWAITING (manual) vs status sebelumnya. | +| B10 | Deprecate `TripParticipant.markedPaidAt` + `paymentConfirmedAt` | ⏳ | Tetap ada di DB untuk transisi. Hapus di PR berikutnya setelah UI fully cutover. | +| B11 | Index optimization | ⏳ | `@@index([tripId, status])` di Booking, `@@index([provider, status])` di Payment. | + +**Tindakan manual:** `npx prisma migrate deploy` + jalankan backfill script (B3 bisa dimasukkan ke migration SQL atau script TS terpisah). + +--- + +## PR C — Midtrans integration (Snap + webhook) ⏳ + +Tambah provider MIDTRANS ke pipeline yang sudah dibuat di PR B. Test di sandbox dulu. + +### Persiapan akun & env + +| Env | Keterangan | +|---|---| +| `MIDTRANS_SERVER_KEY` | Server key dari dashboard Midtrans (sandbox/production sesuai mode). Rahasia. | +| `MIDTRANS_CLIENT_KEY` | Client key. Boleh di expose ke frontend (untuk Snap script). | +| `MIDTRANS_IS_PRODUCTION` | `true`/`false` — pilih endpoint sandbox vs production. | +| `MIDTRANS_NOTIFICATION_URL` | URL callback publik kita, mis. `https://setrip.id/api/webhooks/midtrans`. Didaftarkan di dashboard Midtrans. | + +Tambah ke [env.example](env.example) dengan komentar. + +### Tugas + +| # | Item | Status | Catatan | +|---|---|---|---| +| C1 | Update [env.example](env.example) + dokumentasi env | ⏳ | 4 env baru. | +| C2 | `lib/midtrans.ts` — client tipis: `createSnapTransaction`, `verifySignature`, `mapStatus` | ⏳ | Pakai `fetch` + `crypto.createHash('sha512')`. Tidak butuh dependency baru. | +| C3 | Status mapping helper | ⏳ | `transaction_status` + `fraud_status` Midtrans → `PaymentStatus` internal. Tabel mapping ada di README PR ini. | +| C4 | Service `paymentService.startMidtransPayment(bookingId)` | ⏳ | Bikin Payment row provider=MIDTRANS, kirim ke Midtrans, simpan `snapToken` + `expiresAt`. Kalau Booking sudah PAID → reject. | +| C5 | Halaman payment: tombol "Bayar online (Midtrans)" untuk trip berbayar | ⏳ | Fallback "Transfer manual" tetap ada (provider MANUAL). User pilih sebelum lanjut. | +| C6 | Frontend: load Snap script + invoke `window.snap.pay(token)` | ⏳ | Loaded conditional di halaman payment, bukan global. Pakai client key dari env publik. | +| C7 | Webhook endpoint `app/api/webhooks/midtrans/route.ts` | ⏳ | POST. Verify signature (sha512). Lookup Payment by `externalOrderId`. Update idempotent. Selalu return 200. | +| C8 | Booking status sync setelah webhook PAID | ⏳ | `Booking.status = PAID`. Sync `TripParticipant.paymentConfirmedAt` untuk kompatibilitas. Concurrency: gunakan DB transaction. | +| C9 | Cron / scheduled job: expire Payment lama | ⏳ | Midtrans default expire 24 jam, tapi DB-side juga harus bersih supaya UI status akurat. Bisa dijalankan via Vercel cron atau manual scheduler. | +| C10 | Anti-replay: skip kalau `Payment.status` sudah final (PAID/FAILED/EXPIRED) | ⏳ | Webhook bisa diretry oleh Midtrans. | +| C11 | Logging callback mentah ke `Payment.rawCallback` (Json) | ⏳ | Audit & dispute. | +| C12 | Test scenario di sandbox | ⏳ | Settlement BCA VA, gopay, deny (kartu fraud), expire, cancel. | +| C13 | Status badge di halaman payment | ⏳ | Tampil real-time tanpa polling agresif (refresh manual atau interval longgar 10s). | +| C14 | Email/in-app notification setelah PAID | ⏳ | Optional Phase ini, bisa Phase berikutnya. | + +### Mapping `transaction_status` Midtrans → `PaymentStatus` + +| Midtrans | Trigger lain | `PaymentStatus` | +|---|---|---| +| `capture` | `fraud_status === "accept"` | PAID | +| `capture` | `fraud_status === "challenge"` | AWAITING (review manual di dashboard Midtrans) | +| `settlement` | — | PAID | +| `pending` | — | AWAITING | +| `deny` | — | FAILED | +| `expire` | — | EXPIRED | +| `cancel` | — | CANCELLED | +| `refund` / `partial_refund` | — | REFUNDED | + +### Webhook checklist (security) + +1. Verify signature: `sha512(order_id + status_code + gross_amount + SERVER_KEY) === signature_key`. Mismatch → 401, log. +2. Cek `gross_amount` cocok dengan `payment.amount` — kalau tidak sama, log anomaly, jangan PAID. +3. Lookup `Payment.externalOrderId === order_id`. Tidak ada → 200 OK + log (jangan biarkan Midtrans retry forever). +4. Idempotent: kalau status sudah final, skip update tapi tetap return 200. +5. Pakai DB transaction untuk update Payment + Booking + TripParticipant bersamaan. +6. Selalu return 200 kalau request valid (mismatch signature → 401, sisanya → 200 + log). + +### Edge cases yang gampang lupa + +- **Quota race**: dua user bayar bersamaan untuk slot terakhir → slot harus di-hold saat Booking dibuat (status AWAITING_PAY masih hitung kuota), release otomatis saat Payment EXPIRED. +- **Trip dibatalkan organizer setelah peserta bayar** → `Booking.status = REFUNDED` setelah dana balik. Implementasi refund Midtrans = PR terpisah (tidak di scope PR C ini). +- **User retry pembayaran setelah gagal** → bikin Payment baru (bukan reuse), `externalOrderId` baru (`setrip-{bookingId}-{retry}`). Booking status tetap AWAITING_PAY. +- **Webhook duplicate**: Midtrans bisa kirim notifikasi yang sama 2-3 kali. Idempotency key = `Payment.externalOrderId` + status terkini. +- **Sandbox vs production**: simulator Midtrans akan kirim callback ke `MIDTRANS_NOTIFICATION_URL`. Pastikan URL sandbox bisa diakses publik (tunneling kalau dev lokal — ngrok / cloudflared). + +--- + +## ❌ Anti-list (yang harus DITOLAK kalau muncul) + +- **Halaman payment yang berubah jadi marketplace checkout**: upsell ("trip serupa lebih murah"), pricing comparison, "harga turun" — semua menarik framing OTA. Halaman payment fokus pada satu transaksi: lunas atau belum. +- **Multi-method abstraksi prematur**: jangan bikin "PaymentProvider" generic untuk Stripe/Xendit/Doku sekaligus sebelum salah satu jalan. Mulai dari MANUAL + MIDTRANS, baru tambah kalau perlu. +- **Auto-refund logic kompleks** sebelum manual refund di dashboard Midtrans dipakai. Refund jarang, manual cukup di Phase awal. +- **Payment retry otomatis** dari sisi server. User harus eksplisit klik "bayar lagi" untuk attempt baru — supaya tidak ambigu siapa yang trigger. +- **Multi-currency** sebelum ada permintaan eksplisit. `currency` di schema sudah default IDR, tapi tidak perlu UI selector. +- **Saving credit card / tokenization** tanpa kebutuhan jelas. PCI scope naik drastis. Snap sudah handle tanpa simpan kartu di sisi kita. + +--- + +## Saran phasing + +PR berurutan. Setiap PR mandiri (siap di-deploy): + +1. **PR A** — Free trip handling + halaman payment (manual). Cepat, low-risk, no migration. **Mulai dari sini.** +2. **PR B** — Refactor ke `Booking` + `Payment`. Migration + backfill data lama. UI tetap mirip. +3. **PR C** — Tambah Midtrans Snap. Test di sandbox dulu sebelum production. + +Pertanyaan terbuka sebelum mulai: + +1. **Akses halaman payment**: hanya peserta CONFIRMED, atau juga PENDING (yang belum disetujui organizer)? Saya rekomendasi CONFIRMED only — peserta PENDING belum perlu lihat detail bayar. +2. **Snapshot amount di Booking**: kalau organizer ubah `trip.price` setelah booking dibuat, booking lama pakai harga lama atau baru? Saya rekomendasi tetap pakai snapshot lama (audit-friendly). +3. **Manual + Midtrans co-exist**: user pilih satu provider per booking, atau bisa retry dengan provider berbeda? Saya rekomendasi pilih satu — kalau gagal di Midtrans, bisa cancel dan buat Payment baru dengan provider MANUAL. diff --git a/app/trips/[id]/page.tsx b/app/trips/[id]/page.tsx index 077ec11..7dc307f 100644 --- a/app/trips/[id]/page.tsx +++ b/app/trips/[id]/page.tsx @@ -18,6 +18,7 @@ import { ImageGallery } from "@/features/trip/components/image-gallery"; import { TripReviewSection } from "@/features/review/components/trip-review-section"; import { categoryMeta } from "@/lib/activity-category"; import { vibeMeta } from "@/lib/vibe"; +import { isFreeTrip } from "@/lib/trip-pricing"; import { isPastTripLastDayForReview, isTripDepartureDayPast, @@ -126,9 +127,12 @@ export default async function TripDetailPage({ ) / 10 : null; - const paymentPendingParticipants = activeParticipants.filter( - (p) => p.markedPaidAt && !p.paymentConfirmedAt - ); + const tripIsFree = isFreeTrip(trip); + const paymentPendingParticipants = tripIsFree + ? [] + : activeParticipants.filter( + (p) => p.markedPaidAt && !p.paymentConfirmedAt + ); const catMeta = categoryMeta(trip.category); @@ -426,6 +430,7 @@ export default async function TripDetailPage({ isLoggedIn={!!session?.user} isOrganizer={isOrganizer} isJoined={!!currentParticipation} + isFree={tripIsFree} participationStatus={ currentParticipation?.status === "PENDING" || currentParticipation?.status === "CONFIRMED" diff --git a/app/trips/[id]/payment/page.tsx b/app/trips/[id]/payment/page.tsx new file mode 100644 index 0000000..5fc4d73 --- /dev/null +++ b/app/trips/[id]/payment/page.tsx @@ -0,0 +1,411 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { tripService } from "@/server/services/trip.service"; +import { organizerService } from "@/server/services/organizer.service"; +import { formatRupiah } from "@/lib/utils"; +import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates"; +import { isFreeTrip } from "@/lib/trip-pricing"; +import { categoryMeta } from "@/lib/activity-category"; +import { MarkPaidButton } from "@/features/booking/components/mark-paid-button"; +import { CopyButton } from "@/features/booking/components/copy-button"; + +export const metadata: Metadata = { + title: "Detail Pembayaran", + robots: { index: false, follow: false }, +}; + +interface PageProps { + params: Promise<{ id: string }>; +} + +export default async function PaymentPage({ params }: PageProps) { + const { id } = await params; + const session = await getServerSession(authOptions); + if (!session?.user) { + redirect(`/login?callbackUrl=/trips/${id}/payment`); + } + + let trip; + try { + trip = await tripService.getTripById(id); + } catch { + notFound(); + } + + // Organizer trip-nya sendiri tidak butuh halaman pembayaran. + if (trip.organizerId === session.user.id) { + redirect(`/trips/${id}`); + } + + const myParticipation = trip.participants.find( + (p) => p.userId === session.user.id && p.status !== "CANCELLED" + ); + + if (!myParticipation || myParticipation.status === "CANCELLED") { + return ( + + ); + } + // Narrowed: status sudah pasti PENDING atau CONFIRMED + const activeStatus: "PENDING" | "CONFIRMED" = myParticipation.status; + + const tripIsFree = isFreeTrip(trip); + const catMeta = categoryMeta(trip.category); + const dateRange = formatTripCalendarDateRangeLong(trip.date, trip.endDate); + + // Header info — sama untuk free vs paid + const tripHeader = ( +
+
+ + {catMeta.icon} + +
+

+ {catMeta.label} +

+

+ {trip.title} +

+

+ 📅 {dateRange} · 📍 {trip.location} +

+

+ Organizer:{" "} + + {trip.organizer.name} + +

+
+
+
+ ); + + return ( +
+
+ + ← Kembali ke trip + +
+ +

+ Detail Pembayaran +

+

+ {tripIsFree + ? "Trip ini gratis — kamu tidak perlu transfer apa-apa." + : "Transfer manual ke rekening organizer di bawah, lalu tandai sebagai sudah bayar."} +

+ + {tripHeader} + + {tripIsFree ? ( + + ) : ( + + )} +
+ ); +} + +function NotJoinedNotice({ tripId, title }: { tripId: string; title: string }) { + return ( +
+

+ Kamu belum terdaftar di trip ini +

+

+ Halaman pembayaran hanya tersedia untuk peserta trip{" "} + {title}. +

+ + Lihat detail trip + +
+ ); +} + +function FreeTripSection({ + tripId, + participationStatus, +}: { + tripId: string; + participationStatus: "PENDING" | "CONFIRMED"; +}) { + return ( +
+
+ 🎉 +
+

+ Trip ini gratis +

+

+ Tidak ada biaya yang perlu kamu transfer. +

+ +
+

+ Status keikutsertaan +

+

+ {participationStatus === "CONFIRMED" + ? "✅ Terkonfirmasi sebagai peserta" + : "⏳ Menunggu persetujuan organizer"} +

+
+ +
+ + Kembali ke detail trip + +
+
+ ); +} + +async function PaidTripSection({ + tripId, + organizerId, + organizerName, + price, + participationStatus, + markedPaidAt, + paymentConfirmedAt, +}: { + tripId: string; + organizerId: string; + organizerName: string; + price: number; + participationStatus: "PENDING" | "CONFIRMED"; + markedPaidAt: Date | null; + paymentConfirmedAt: Date | null; +}) { + const verification = await organizerService.getStatusForUser(organizerId); + const bankAvailable = verification?.status === "APPROVED"; + const canMarkPaid = + participationStatus === "CONFIRMED" && !markedPaidAt && !paymentConfirmedAt; + const showStatusOnly = !!markedPaidAt; + + return ( +
+ + + {!bankAvailable && ( +
+

Rekening organizer belum tersedia

+

+ Organizer trip ini belum melengkapi data verifikasi (bank). Hubungi + organizer langsung lewat profilnya untuk koordinasi pembayaran. +

+
+ )} + + {bankAvailable && ( +
+

+ Transfer ke rekening organizer +

+

+ Pastikan nominal persis seperti tercantum supaya organizer mudah + mencocokkan. +

+ +
+ + + +
+ +
+
+ +
    +
  • • Transfer dengan nominal pas, jangan dibulatkan.
  • +
  • • Simpan bukti transfer untuk jaga-jaga jika ada konfirmasi.
  • +
  • + • Setelah transfer, tekan tombol Saya sudah bayar di bawah + supaya organizer tahu dan bisa konfirmasi. +
  • +
+
+ )} + + {participationStatus === "PENDING" && ( +
+ Kamu belum disetujui organizer untuk ikut trip ini. Tunggu persetujuan + dulu sebelum transfer — supaya tidak perlu refund kalau ditolak. +
+ )} + + {canMarkPaid && bankAvailable && ( + + )} + + {showStatusOnly && ( +
+ {paymentConfirmedAt ? ( +

+ ✅ Pembayaran kamu sudah dikonfirmasi oleh{" "} + + {organizerName} + + . Sampai jumpa di trip! +

+ ) : ( +

+ ⏳ Kamu sudah menandai sudah bayar. Menunggu organizer mengecek + dan mengonfirmasi. +

+ )} +
+ )} + +
+ + ← Kembali ke detail trip + +
+
+ ); +} + +function PaymentTimeline({ + participationStatus, + markedPaidAt, + paymentConfirmedAt, +}: { + participationStatus: "PENDING" | "CONFIRMED"; + markedPaidAt: Date | null; + paymentConfirmedAt: Date | null; +}) { + const steps = [ + { + label: "Disetujui organizer", + done: participationStatus === "CONFIRMED", + }, + { + label: "Kamu menandai sudah bayar", + done: !!markedPaidAt, + }, + { + label: "Organizer konfirmasi pembayaran", + done: !!paymentConfirmedAt, + }, + ]; + + return ( +
+

+ Status pembayaran +

+
    + {steps.map((s, i) => ( +
  1. + + {s.done ? "✓" : i + 1} + + + {s.label} + +
  2. + ))} +
+
+ ); +} + +function BankRow({ + label, + value, + mono, + strong, + copyable, + copyValue, +}: { + label: string; + value: string; + mono?: boolean; + strong?: boolean; + copyable?: boolean; + copyValue?: string; +}) { + return ( +
+
+

+ {label} +

+

+ {value} +

+
+ {copyable && } +
+ ); +} diff --git a/components/shared/navbar.tsx b/components/shared/navbar.tsx index b8733d3..822d538 100644 --- a/components/shared/navbar.tsx +++ b/components/shared/navbar.tsx @@ -72,7 +72,7 @@ export function Navbar() { {session.user.name} @@ -109,11 +109,27 @@ export function Navbar() { aria-label="Toggle menu" > {menuOpen ? ( - + ) : ( - + )} diff --git a/features/booking/components/copy-button.tsx b/features/booking/components/copy-button.tsx new file mode 100644 index 0000000..5543e5b --- /dev/null +++ b/features/booking/components/copy-button.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { useState } from "react"; + +interface CopyButtonProps { + value: string; + label?: string; +} + +export function CopyButton({ value, label = "Salin" }: CopyButtonProps) { + const [copied, setCopied] = useState(false); + + async function handleClick() { + try { + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // ignore — user can copy manually + } + } + + return ( + + ); +} diff --git a/features/booking/components/mark-paid-button.tsx b/features/booking/components/mark-paid-button.tsx new file mode 100644 index 0000000..4130c74 --- /dev/null +++ b/features/booking/components/mark-paid-button.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { markParticipantPaidAction } from "@/features/booking/actions"; + +interface MarkPaidButtonProps { + tripId: string; + disabled?: boolean; +} + +export function MarkPaidButton({ tripId, disabled }: MarkPaidButtonProps) { + const router = useRouter(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + async function handleClick() { + setLoading(true); + setError(""); + const result = await markParticipantPaidAction(tripId); + setLoading(false); + if (result.error) { + setError(result.error); + return; + } + router.refresh(); + } + + return ( +
+ {error && ( +
+ {error} +
+ )} + +

+ Tekan setelah kamu transfer ke rekening di atas. Organizer akan cek & + konfirmasi pembayaran kamu. +

+
+ ); +} diff --git a/features/organizer/components/review-card.tsx b/features/organizer/components/review-card.tsx index 2746309..5600a35 100644 --- a/features/organizer/components/review-card.tsx +++ b/features/organizer/components/review-card.tsx @@ -63,7 +63,8 @@ export function ReviewCard({ verification }: { verification: Verification }) { {verification.user.name}

- {verification.user.email} · diajukan {formatDate(verification.createdAt)} + {verification.user.email} · diajukan{" "} + {formatDate(verification.createdAt)}

@@ -80,10 +81,7 @@ export function ReviewCard({ verification }: { verification: Verification }) { label="Bank" value={`${verification.bankName} · ${verification.bankAccountNumber}`} /> - + @@ -212,24 +210,30 @@ function ImagePreview({ label, src }: { label: string; src: string }) { rel="noopener noreferrer" className="block overflow-hidden rounded-xl border border-neutral-200 bg-neutral-100" > -
+
{/* Secure endpoint sends Cache-Control: private,no-store. Use plain to skip Next/Image optimizer. */} {/* eslint-disable-next-line @next/next/no-img-element */} - {label} + {label}
); } -function StatusPill({ status }: { status: "PENDING" | "APPROVED" | "REJECTED" }) { +function StatusPill({ + status, +}: { + status: "PENDING" | "APPROVED" | "REJECTED"; +}) { const cfg = { - PENDING: { label: "Pending", cls: "bg-amber-50 text-amber-700 ring-amber-200" }, - APPROVED: { label: "Disetujui", cls: "bg-primary-50 text-primary-700 ring-primary-200" }, + PENDING: { + label: "Pending", + cls: "bg-amber-50 text-amber-700 ring-amber-200", + }, + APPROVED: { + label: "Disetujui", + cls: "bg-primary-50 text-primary-700 ring-primary-200", + }, REJECTED: { label: "Ditolak", cls: "bg-red-50 text-red-700 ring-red-200" }, }[status]; return ( diff --git a/features/trip/components/join-trip-button.tsx b/features/trip/components/join-trip-button.tsx index 862843d..5d2020d 100644 --- a/features/trip/components/join-trip-button.tsx +++ b/features/trip/components/join-trip-button.tsx @@ -4,16 +4,17 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { joinTripAction, cancelJoinAction } from "@/features/trip/actions"; -import { markParticipantPaidAction } from "@/features/booking/actions"; interface JoinTripButtonProps { tripId: string; isLoggedIn: boolean; isOrganizer: boolean; isJoined: boolean; + /** Trip gratis (price <= 0) — sembunyikan flow pembayaran */ + isFree: boolean; /** Status partisipasi user saat isJoined (bukan organizer) */ participationStatus?: "PENDING" | "CONFIRMED" | null; - /** Status pembayaran manual (peserta) */ + /** Status pembayaran manual (peserta). Hanya relevan untuk trip berbayar. */ participantPayment?: { markedPaidAt: string | Date | null; paymentConfirmedAt: string | Date | null; @@ -29,6 +30,7 @@ export function JoinTripButton({ isLoggedIn, isOrganizer, isJoined, + isFree, participationStatus, participantPayment, isFull, @@ -108,28 +110,11 @@ export function JoinTripButton({ } } - async function handleMarkPaid() { - setLoading(true); - setError(""); - const result = await markParticipantPaidAction(tripId); - setLoading(false); - if (result.error) { - setError(result.error); - } else { - router.refresh(); - } - } - const pay = participantPayment; - const showMarkPaid = - isJoined && - pay && - !pay.paymentConfirmedAt && - !pay.markedPaidAt && - !isDeparturePast; + const showPaymentLink = !isFree && isJoined && !isDeparturePast; const waitingPaymentConfirm = - isJoined && pay && pay.markedPaidAt && !pay.paymentConfirmedAt; - const paymentDone = isJoined && pay && pay.paymentConfirmedAt; + !isFree && isJoined && pay && pay.markedPaidAt && !pay.paymentConfirmedAt; + const paymentDone = !isFree && isJoined && pay && pay.paymentConfirmedAt; return (
@@ -149,7 +134,8 @@ export function JoinTripButton({
Kamu sudah{" "} terkonfirmasi sebagai peserta - trip ini. + trip ini + {isFree && — trip gratis, tidak ada pembayaran 🎉}.
)} {waitingPaymentConfirm && ( @@ -164,15 +150,17 @@ export function JoinTripButton({ dikonfirmasi organizer.
)} - {showMarkPaid && ( - + {paymentDone + ? "Lihat detail pembayaran" + : pay?.markedPaidAt + ? "Lihat status pembayaran" + : "Buka detail pembayaran"} + )} {isJoined ? (