create payment roadmap pr a
This commit is contained in:
@@ -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.
|
||||||
@@ -18,6 +18,7 @@ import { ImageGallery } from "@/features/trip/components/image-gallery";
|
|||||||
import { TripReviewSection } from "@/features/review/components/trip-review-section";
|
import { TripReviewSection } from "@/features/review/components/trip-review-section";
|
||||||
import { categoryMeta } from "@/lib/activity-category";
|
import { categoryMeta } from "@/lib/activity-category";
|
||||||
import { vibeMeta } from "@/lib/vibe";
|
import { vibeMeta } from "@/lib/vibe";
|
||||||
|
import { isFreeTrip } from "@/lib/trip-pricing";
|
||||||
import {
|
import {
|
||||||
isPastTripLastDayForReview,
|
isPastTripLastDayForReview,
|
||||||
isTripDepartureDayPast,
|
isTripDepartureDayPast,
|
||||||
@@ -126,9 +127,12 @@ export default async function TripDetailPage({
|
|||||||
) / 10
|
) / 10
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const paymentPendingParticipants = activeParticipants.filter(
|
const tripIsFree = isFreeTrip(trip);
|
||||||
(p) => p.markedPaidAt && !p.paymentConfirmedAt
|
const paymentPendingParticipants = tripIsFree
|
||||||
);
|
? []
|
||||||
|
: activeParticipants.filter(
|
||||||
|
(p) => p.markedPaidAt && !p.paymentConfirmedAt
|
||||||
|
);
|
||||||
|
|
||||||
const catMeta = categoryMeta(trip.category);
|
const catMeta = categoryMeta(trip.category);
|
||||||
|
|
||||||
@@ -426,6 +430,7 @@ export default async function TripDetailPage({
|
|||||||
isLoggedIn={!!session?.user}
|
isLoggedIn={!!session?.user}
|
||||||
isOrganizer={isOrganizer}
|
isOrganizer={isOrganizer}
|
||||||
isJoined={!!currentParticipation}
|
isJoined={!!currentParticipation}
|
||||||
|
isFree={tripIsFree}
|
||||||
participationStatus={
|
participationStatus={
|
||||||
currentParticipation?.status === "PENDING" ||
|
currentParticipation?.status === "PENDING" ||
|
||||||
currentParticipation?.status === "CONFIRMED"
|
currentParticipation?.status === "CONFIRMED"
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<NotJoinedNotice tripId={trip.id} title={trip.title} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 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 = (
|
||||||
|
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-neutral-100 text-xl">
|
||||||
|
{catMeta.icon}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-neutral-500">
|
||||||
|
{catMeta.label}
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-0.5 truncate text-base font-bold text-neutral-900 sm:text-lg">
|
||||||
|
{trip.title}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-xs text-neutral-500 sm:text-sm">
|
||||||
|
📅 {dateRange} · 📍 {trip.location}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-neutral-500 sm:text-sm">
|
||||||
|
Organizer:{" "}
|
||||||
|
<Link
|
||||||
|
href={`/u/${trip.organizer.id}`}
|
||||||
|
className="font-medium text-neutral-700 hover:text-primary-600"
|
||||||
|
>
|
||||||
|
{trip.organizer.name}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl px-4 py-6 sm:py-10">
|
||||||
|
<div className="mb-4 flex items-center gap-2 text-xs text-neutral-500 sm:text-sm">
|
||||||
|
<Link href={`/trips/${trip.id}`} className="hover:text-primary-600">
|
||||||
|
← Kembali ke trip
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="mb-1 text-xl font-bold text-neutral-900 sm:text-2xl">
|
||||||
|
Detail Pembayaran
|
||||||
|
</h2>
|
||||||
|
<p className="mb-5 text-sm text-neutral-500">
|
||||||
|
{tripIsFree
|
||||||
|
? "Trip ini gratis — kamu tidak perlu transfer apa-apa."
|
||||||
|
: "Transfer manual ke rekening organizer di bawah, lalu tandai sebagai sudah bayar."}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{tripHeader}
|
||||||
|
|
||||||
|
{tripIsFree ? (
|
||||||
|
<FreeTripSection
|
||||||
|
tripId={trip.id}
|
||||||
|
participationStatus={activeStatus}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<PaidTripSection
|
||||||
|
tripId={trip.id}
|
||||||
|
organizerId={trip.organizerId}
|
||||||
|
organizerName={trip.organizer.name}
|
||||||
|
price={trip.price}
|
||||||
|
participationStatus={activeStatus}
|
||||||
|
markedPaidAt={myParticipation.markedPaidAt}
|
||||||
|
paymentConfirmedAt={myParticipation.paymentConfirmedAt}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotJoinedNotice({ tripId, title }: { tripId: string; title: string }) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl px-4 py-12 text-center">
|
||||||
|
<h1 className="mb-2 text-xl font-bold text-neutral-900 sm:text-2xl">
|
||||||
|
Kamu belum terdaftar di trip ini
|
||||||
|
</h1>
|
||||||
|
<p className="mb-5 text-sm text-neutral-500">
|
||||||
|
Halaman pembayaran hanya tersedia untuk peserta trip{" "}
|
||||||
|
<span className="font-semibold text-neutral-700">{title}</span>.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href={`/trips/${tripId}`}
|
||||||
|
className="inline-block rounded-xl bg-primary-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-primary-700"
|
||||||
|
>
|
||||||
|
Lihat detail trip
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FreeTripSection({
|
||||||
|
tripId,
|
||||||
|
participationStatus,
|
||||||
|
}: {
|
||||||
|
tripId: string;
|
||||||
|
participationStatus: "PENDING" | "CONFIRMED";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-2xl border border-emerald-200 bg-emerald-50/60 p-6 text-center shadow-sm sm:p-8">
|
||||||
|
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-emerald-100 text-2xl">
|
||||||
|
🎉
|
||||||
|
</div>
|
||||||
|
<h2 className="mb-1 text-lg font-bold text-emerald-900 sm:text-xl">
|
||||||
|
Trip ini gratis
|
||||||
|
</h2>
|
||||||
|
<p className="mb-5 text-sm text-emerald-900/80">
|
||||||
|
Tidak ada biaya yang perlu kamu transfer.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mx-auto inline-flex flex-col gap-1 rounded-xl border border-emerald-200 bg-white px-5 py-3 text-left">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-emerald-700">
|
||||||
|
Status keikutsertaan
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-bold text-neutral-800">
|
||||||
|
{participationStatus === "CONFIRMED"
|
||||||
|
? "✅ Terkonfirmasi sebagai peserta"
|
||||||
|
: "⏳ Menunggu persetujuan organizer"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<Link
|
||||||
|
href={`/trips/${tripId}`}
|
||||||
|
className="inline-block rounded-xl bg-emerald-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-emerald-700"
|
||||||
|
>
|
||||||
|
Kembali ke detail trip
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<PaymentTimeline
|
||||||
|
participationStatus={participationStatus}
|
||||||
|
markedPaidAt={markedPaidAt}
|
||||||
|
paymentConfirmedAt={paymentConfirmedAt}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!bankAvailable && (
|
||||||
|
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||||||
|
<p className="font-semibold">Rekening organizer belum tersedia</p>
|
||||||
|
<p className="mt-1 text-amber-800/90">
|
||||||
|
Organizer trip ini belum melengkapi data verifikasi (bank). Hubungi
|
||||||
|
organizer langsung lewat profilnya untuk koordinasi pembayaran.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bankAvailable && (
|
||||||
|
<section className="rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
|
||||||
|
<h3 className="mb-1 text-sm font-bold text-neutral-900 sm:text-base">
|
||||||
|
Transfer ke rekening organizer
|
||||||
|
</h3>
|
||||||
|
<p className="mb-4 text-xs text-neutral-500 sm:text-sm">
|
||||||
|
Pastikan nominal persis seperti tercantum supaya organizer mudah
|
||||||
|
mencocokkan.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-3 rounded-xl bg-neutral-50 p-4 sm:p-5">
|
||||||
|
<BankRow
|
||||||
|
label="Bank"
|
||||||
|
value={verification.bankName}
|
||||||
|
copyable
|
||||||
|
/>
|
||||||
|
<BankRow
|
||||||
|
label="Nomor rekening"
|
||||||
|
value={verification.bankAccountNumber}
|
||||||
|
copyable
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<BankRow
|
||||||
|
label="Atas nama"
|
||||||
|
value={verification.bankAccountName}
|
||||||
|
/>
|
||||||
|
<div className="mt-2 border-t border-neutral-200 pt-3">
|
||||||
|
<BankRow
|
||||||
|
label="Nominal transfer"
|
||||||
|
value={formatRupiah(price)}
|
||||||
|
strong
|
||||||
|
copyable
|
||||||
|
copyValue={String(price)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="mt-4 space-y-1.5 text-[11px] text-neutral-500 sm:text-xs">
|
||||||
|
<li>• Transfer dengan nominal pas, jangan dibulatkan.</li>
|
||||||
|
<li>• Simpan bukti transfer untuk jaga-jaga jika ada konfirmasi.</li>
|
||||||
|
<li>
|
||||||
|
• Setelah transfer, tekan tombol <em>Saya sudah bayar</em> di bawah
|
||||||
|
supaya organizer tahu dan bisa konfirmasi.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{participationStatus === "PENDING" && (
|
||||||
|
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||||||
|
Kamu belum disetujui organizer untuk ikut trip ini. Tunggu persetujuan
|
||||||
|
dulu sebelum transfer — supaya tidak perlu refund kalau ditolak.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canMarkPaid && bankAvailable && (
|
||||||
|
<MarkPaidButton tripId={tripId} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showStatusOnly && (
|
||||||
|
<div className="rounded-2xl border border-neutral-200 bg-white p-4 text-sm text-neutral-600 shadow-sm sm:p-5">
|
||||||
|
{paymentConfirmedAt ? (
|
||||||
|
<p>
|
||||||
|
✅ Pembayaran kamu sudah dikonfirmasi oleh{" "}
|
||||||
|
<span className="font-semibold text-neutral-800">
|
||||||
|
{organizerName}
|
||||||
|
</span>
|
||||||
|
. Sampai jumpa di trip!
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p>
|
||||||
|
⏳ Kamu sudah menandai sudah bayar. Menunggu organizer mengecek
|
||||||
|
dan mengonfirmasi.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<Link
|
||||||
|
href={`/trips/${tripId}`}
|
||||||
|
className="text-sm text-neutral-500 hover:text-primary-600"
|
||||||
|
>
|
||||||
|
← Kembali ke detail trip
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<section className="rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-5">
|
||||||
|
<h3 className="mb-3 text-xs font-bold text-neutral-700 sm:text-sm">
|
||||||
|
Status pembayaran
|
||||||
|
</h3>
|
||||||
|
<ol className="space-y-2.5">
|
||||||
|
{steps.map((s, i) => (
|
||||||
|
<li key={i} className="flex items-start gap-3">
|
||||||
|
<span
|
||||||
|
className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[11px] font-bold ${
|
||||||
|
s.done
|
||||||
|
? "bg-emerald-500 text-white"
|
||||||
|
: "bg-neutral-200 text-neutral-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.done ? "✓" : i + 1}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-sm ${
|
||||||
|
s.done
|
||||||
|
? "font-semibold text-neutral-800"
|
||||||
|
: "text-neutral-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BankRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
mono,
|
||||||
|
strong,
|
||||||
|
copyable,
|
||||||
|
copyValue,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
mono?: boolean;
|
||||||
|
strong?: boolean;
|
||||||
|
copyable?: boolean;
|
||||||
|
copyValue?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-neutral-500">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={`mt-0.5 truncate text-sm text-neutral-800 ${
|
||||||
|
mono ? "font-mono" : ""
|
||||||
|
} ${strong ? "text-base font-bold text-primary-700" : ""}`}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{copyable && <CopyButton value={copyValue ?? value} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -72,7 +72,7 @@ export function Navbar() {
|
|||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/profile"
|
href="/profile"
|
||||||
className="max-w-[140px] truncate text-sm font-medium text-neutral-700 hover:text-primary-600"
|
className="max-w-35 truncate text-sm font-medium text-neutral-700 hover:text-primary-600"
|
||||||
>
|
>
|
||||||
{session.user.name}
|
{session.user.name}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -109,11 +109,27 @@ export function Navbar() {
|
|||||||
aria-label="Toggle menu"
|
aria-label="Toggle menu"
|
||||||
>
|
>
|
||||||
{menuOpen ? (
|
{menuOpen ? (
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
<path d="M5 5l10 10M15 5L5 15" />
|
<path d="M5 5l10 10M15 5L5 15" />
|
||||||
</svg>
|
</svg>
|
||||||
) : (
|
) : (
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
<path d="M3 5h14M3 10h14M3 15h14" />
|
<path d="M3 5h14M3 10h14M3 15h14" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClick}
|
||||||
|
className="rounded-lg border border-neutral-200 bg-white px-2.5 py-1 text-xs font-medium text-neutral-600 transition-colors hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
{copied ? "✓ Tersalin" : label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div>
|
||||||
|
{error && (
|
||||||
|
<div className="mb-3 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={loading || disabled}
|
||||||
|
className="w-full rounded-xl bg-primary-600 py-3 text-sm font-bold text-white shadow-lg shadow-primary-600/20 transition-colors hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "Memproses..." : "Saya sudah bayar"}
|
||||||
|
</button>
|
||||||
|
<p className="mt-2 text-center text-[11px] text-neutral-500">
|
||||||
|
Tekan setelah kamu transfer ke rekening di atas. Organizer akan cek &
|
||||||
|
konfirmasi pembayaran kamu.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -63,7 +63,8 @@ export function ReviewCard({ verification }: { verification: Verification }) {
|
|||||||
{verification.user.name}
|
{verification.user.name}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-neutral-500">
|
<p className="text-xs text-neutral-500">
|
||||||
{verification.user.email} · diajukan {formatDate(verification.createdAt)}
|
{verification.user.email} · diajukan{" "}
|
||||||
|
{formatDate(verification.createdAt)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<StatusPill status={verification.status} />
|
<StatusPill status={verification.status} />
|
||||||
@@ -80,10 +81,7 @@ export function ReviewCard({ verification }: { verification: Verification }) {
|
|||||||
label="Bank"
|
label="Bank"
|
||||||
value={`${verification.bankName} · ${verification.bankAccountNumber}`}
|
value={`${verification.bankName} · ${verification.bankAccountNumber}`}
|
||||||
/>
|
/>
|
||||||
<Field
|
<Field label="Pemilik Rekening" value={verification.bankAccountName} />
|
||||||
label="Pemilik Rekening"
|
|
||||||
value={verification.bankAccountName}
|
|
||||||
/>
|
|
||||||
<Field label="Alamat" value={verification.address} fullWidth />
|
<Field label="Alamat" value={verification.address} fullWidth />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -212,24 +210,30 @@ function ImagePreview({ label, src }: { label: string; src: string }) {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="block overflow-hidden rounded-xl border border-neutral-200 bg-neutral-100"
|
className="block overflow-hidden rounded-xl border border-neutral-200 bg-neutral-100"
|
||||||
>
|
>
|
||||||
<div className="relative aspect-[4/3] w-full">
|
<div className="relative aspect-4/3 w-full">
|
||||||
{/* Secure endpoint sends Cache-Control: private,no-store. Use plain <img> to skip Next/Image optimizer. */}
|
{/* Secure endpoint sends Cache-Control: private,no-store. Use plain <img> to skip Next/Image optimizer. */}
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img src={src} alt={label} className="h-full w-full object-cover" />
|
||||||
src={src}
|
|
||||||
alt={label}
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusPill({ status }: { status: "PENDING" | "APPROVED" | "REJECTED" }) {
|
function StatusPill({
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
status: "PENDING" | "APPROVED" | "REJECTED";
|
||||||
|
}) {
|
||||||
const cfg = {
|
const cfg = {
|
||||||
PENDING: { label: "Pending", cls: "bg-amber-50 text-amber-700 ring-amber-200" },
|
PENDING: {
|
||||||
APPROVED: { label: "Disetujui", cls: "bg-primary-50 text-primary-700 ring-primary-200" },
|
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" },
|
REJECTED: { label: "Ditolak", cls: "bg-red-50 text-red-700 ring-red-200" },
|
||||||
}[status];
|
}[status];
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,16 +4,17 @@ import { useState } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { joinTripAction, cancelJoinAction } from "@/features/trip/actions";
|
import { joinTripAction, cancelJoinAction } from "@/features/trip/actions";
|
||||||
import { markParticipantPaidAction } from "@/features/booking/actions";
|
|
||||||
|
|
||||||
interface JoinTripButtonProps {
|
interface JoinTripButtonProps {
|
||||||
tripId: string;
|
tripId: string;
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
isOrganizer: boolean;
|
isOrganizer: boolean;
|
||||||
isJoined: boolean;
|
isJoined: boolean;
|
||||||
|
/** Trip gratis (price <= 0) — sembunyikan flow pembayaran */
|
||||||
|
isFree: boolean;
|
||||||
/** Status partisipasi user saat isJoined (bukan organizer) */
|
/** Status partisipasi user saat isJoined (bukan organizer) */
|
||||||
participationStatus?: "PENDING" | "CONFIRMED" | null;
|
participationStatus?: "PENDING" | "CONFIRMED" | null;
|
||||||
/** Status pembayaran manual (peserta) */
|
/** Status pembayaran manual (peserta). Hanya relevan untuk trip berbayar. */
|
||||||
participantPayment?: {
|
participantPayment?: {
|
||||||
markedPaidAt: string | Date | null;
|
markedPaidAt: string | Date | null;
|
||||||
paymentConfirmedAt: string | Date | null;
|
paymentConfirmedAt: string | Date | null;
|
||||||
@@ -29,6 +30,7 @@ export function JoinTripButton({
|
|||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
isOrganizer,
|
isOrganizer,
|
||||||
isJoined,
|
isJoined,
|
||||||
|
isFree,
|
||||||
participationStatus,
|
participationStatus,
|
||||||
participantPayment,
|
participantPayment,
|
||||||
isFull,
|
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 pay = participantPayment;
|
||||||
const showMarkPaid =
|
const showPaymentLink = !isFree && isJoined && !isDeparturePast;
|
||||||
isJoined &&
|
|
||||||
pay &&
|
|
||||||
!pay.paymentConfirmedAt &&
|
|
||||||
!pay.markedPaidAt &&
|
|
||||||
!isDeparturePast;
|
|
||||||
const waitingPaymentConfirm =
|
const waitingPaymentConfirm =
|
||||||
isJoined && pay && pay.markedPaidAt && !pay.paymentConfirmedAt;
|
!isFree && isJoined && pay && pay.markedPaidAt && !pay.paymentConfirmedAt;
|
||||||
const paymentDone = isJoined && pay && pay.paymentConfirmedAt;
|
const paymentDone = !isFree && isJoined && pay && pay.paymentConfirmedAt;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -149,7 +134,8 @@ export function JoinTripButton({
|
|||||||
<div className="mb-3 rounded-xl border border-secondary-200 bg-secondary-50 px-4 py-3 text-sm font-medium text-secondary-900">
|
<div className="mb-3 rounded-xl border border-secondary-200 bg-secondary-50 px-4 py-3 text-sm font-medium text-secondary-900">
|
||||||
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>}.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{waitingPaymentConfirm && (
|
{waitingPaymentConfirm && (
|
||||||
@@ -164,15 +150,17 @@ export function JoinTripButton({
|
|||||||
<span className="font-semibold">dikonfirmasi organizer</span>.
|
<span className="font-semibold">dikonfirmasi organizer</span>.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showMarkPaid && (
|
{showPaymentLink && (
|
||||||
<button
|
<Link
|
||||||
type="button"
|
href={`/trips/${tripId}/payment`}
|
||||||
onClick={handleMarkPaid}
|
className="mb-3 block w-full rounded-xl border-2 border-primary-500 bg-white py-3 text-center text-sm font-bold text-primary-700 transition-colors hover:bg-primary-50"
|
||||||
disabled={loading}
|
|
||||||
className="mb-3 w-full rounded-xl border-2 border-primary-500 bg-white py-3 text-sm font-bold text-primary-700 transition-colors hover:bg-primary-50 disabled:opacity-50"
|
|
||||||
>
|
>
|
||||||
{loading ? "Memproses..." : "Saya sudah bayar"}
|
{paymentDone
|
||||||
</button>
|
? "Lihat detail pembayaran"
|
||||||
|
: pay?.markedPaidAt
|
||||||
|
? "Lihat status pembayaran"
|
||||||
|
: "Buka detail pembayaran"}
|
||||||
|
</Link>
|
||||||
)}
|
)}
|
||||||
{isJoined ? (
|
{isJoined ? (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Pricing helpers untuk trip. Single source of truth — semua gate "trip gratis"
|
||||||
|
* harus lewat sini, jangan compare `price === 0` inline.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function isFreeTrip(trip: { price: number }): boolean {
|
||||||
|
return trip.price <= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPaidTrip(trip: { price: number }): boolean {
|
||||||
|
return !isFreeTrip(trip);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { tripRepo, type TripFilters } from "@/server/repositories/trip.repo";
|
|||||||
import { participantRepo } from "@/server/repositories/participant.repo";
|
import { participantRepo } from "@/server/repositories/participant.repo";
|
||||||
import { LIMITS } from "@/lib/limits";
|
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";
|
||||||
|
|
||||||
const SERIAL_TX_ATTEMPTS = 6;
|
const SERIAL_TX_ATTEMPTS = 6;
|
||||||
|
|
||||||
@@ -311,6 +312,12 @@ export const tripService = {
|
|||||||
throw new Error("Trip tidak ditemukan");
|
throw new Error("Trip tidak ditemukan");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isFreeTrip(trip)) {
|
||||||
|
throw new Error(
|
||||||
|
"Trip ini gratis — tidak ada pembayaran yang perlu ditandai"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (isTripDepartureDayPast(trip.date)) {
|
if (isTripDepartureDayPast(trip.date)) {
|
||||||
throw new Error("Trip sudah lewat — pembayaran tidak perlu ditandai");
|
throw new Error("Trip sudah lewat — pembayaran tidak perlu ditandai");
|
||||||
}
|
}
|
||||||
@@ -363,6 +370,9 @@ export const tripService = {
|
|||||||
if (trip.organizerId !== organizerId) {
|
if (trip.organizerId !== organizerId) {
|
||||||
throw new Error("Hanya organizer yang bisa mengonfirmasi pembayaran");
|
throw new Error("Hanya organizer yang bisa mengonfirmasi pembayaran");
|
||||||
}
|
}
|
||||||
|
if (isFreeTrip(trip)) {
|
||||||
|
throw new Error("Trip ini gratis — tidak ada pembayaran yang perlu dikonfirmasi");
|
||||||
|
}
|
||||||
|
|
||||||
const participant = await participantRepo.findById(participantId);
|
const participant = await participantRepo.findById(participantId);
|
||||||
if (!participant || participant.tripId !== tripId) {
|
if (!participant || participant.tripId !== tripId) {
|
||||||
|
|||||||
Reference in New Issue
Block a user