- 
- 
- 
This commit is contained in:
2026-05-18 18:31:16 +07:00
parent b599d01eea
commit c4efe4453b
36 changed files with 3057 additions and 1493 deletions
+60 -183
View File
@@ -4,15 +4,13 @@ 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 { bookingService } from "@/server/services/booking.service";
import { paymentService } from "@/server/services/payment.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 { MidtransPayButton } from "@/features/booking/components/midtrans-pay-button";
import { CopyButton } from "@/features/booking/components/copy-button";
export const metadata: Metadata = {
title: "Detail Pembayaran",
@@ -21,9 +19,10 @@ export const metadata: Metadata = {
interface PageProps {
params: Promise<{ id: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}
export default async function PaymentPage({ params }: PageProps) {
export default async function PaymentPage({ params, searchParams }: PageProps) {
const { id } = await params;
const session = await getServerSession(authOptions);
if (!session?.user) {
@@ -37,11 +36,26 @@ export default async function PaymentPage({ params }: PageProps) {
notFound();
}
// Organizer trip-nya sendiri tidak butuh halaman pembayaran.
if (trip.organizerId === session.user.id) {
redirect(`/trips/${id}`);
}
// Saat user kembali dari Snap, Midtrans append `order_id` (+ status_code +
// transaction_status) ke finishUrl. Tarik status terkini dari Core API
// sebelum render supaya UI sinkron tanpa menunggu webhook — penting di dev
// (localhost) dan saat webhook tertunda.
const sp = await searchParams;
const orderIdParam = sp.order_id;
const orderId = Array.isArray(orderIdParam) ? orderIdParam[0] : orderIdParam;
if (orderId) {
try {
await paymentService.reconcileFromGateway(orderId, session.user.id);
} catch {
// Jangan blokir render kalau gateway tidak responsif — webhook tetap
// sumber kebenaran jangka panjang. Status di UI akan apa adanya dari DB.
}
}
const booking = await bookingService.getByTripAndUser(
trip.id,
session.user.id
@@ -51,15 +65,10 @@ export default async function PaymentPage({ params }: PageProps) {
return <NotJoinedNotice tripId={trip.id} title={trip.title} />;
}
const latestManualPayment = booking.payments.find(
(p) => p.provider === "MANUAL"
);
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">
@@ -104,29 +113,19 @@ export default async function PaymentPage({ params }: PageProps) {
<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."}
: "Bayar lewat Midtrans untuk mengamankan slot kamu. Pembayaran akan ter-konfirmasi otomatis."}
</p>
{tripHeader}
{tripIsFree ? (
<FreeTripSection
tripId={trip.id}
bookingStatus={booking.status}
/>
<FreeTripSection tripId={trip.id} bookingStatus={booking.status} />
) : (
<PaidTripSection
tripId={trip.id}
organizerId={trip.organizerId}
organizerName={trip.organizer.name}
price={trip.price}
bookingStatus={booking.status}
paymentMarkedAt={
latestManualPayment?.status === "AWAITING"
? latestManualPayment.updatedAt
: null
}
paymentPaidAt={latestManualPayment?.paidAt ?? null}
/>
)}
</div>
@@ -153,19 +152,21 @@ function NotJoinedNotice({ tripId, title }: { tripId: string; title: string }) {
);
}
type BookingStatus =
| "PENDING"
| "AWAITING_PAY"
| "PAID"
| "CANCELLED"
| "REFUNDED"
| "PARTIALLY_REFUNDED"
| "EXPIRED";
function FreeTripSection({
tripId,
bookingStatus,
}: {
tripId: string;
bookingStatus:
| "PENDING"
| "AWAITING_PAY"
| "PAID"
| "CANCELLED"
| "REFUNDED"
| "PARTIALLY_REFUNDED"
| "EXPIRED";
bookingStatus: BookingStatus;
}) {
return (
<section className="rounded-2xl border border-emerald-200 bg-emerald-50/60 p-6 text-center shadow-sm sm:p-8">
@@ -202,146 +203,59 @@ function FreeTripSection({
);
}
async function PaidTripSection({
function PaidTripSection({
tripId,
organizerId,
organizerName,
price,
bookingStatus,
paymentMarkedAt,
paymentPaidAt,
}: {
tripId: string;
organizerId: string;
organizerName: string;
price: number;
bookingStatus:
| "PENDING"
| "AWAITING_PAY"
| "PAID"
| "CANCELLED"
| "REFUNDED"
| "PARTIALLY_REFUNDED"
| "EXPIRED";
paymentMarkedAt: Date | null;
paymentPaidAt: Date | null;
bookingStatus: BookingStatus;
}) {
const verification = await organizerService.getStatusForUser(organizerId);
const bankAvailable = verification?.status === "APPROVED";
const isApproved = bookingStatus === "AWAITING_PAY" || bookingStatus === "PAID";
const isApproved =
bookingStatus === "AWAITING_PAY" || bookingStatus === "PAID";
const isPendingApproval = bookingStatus === "PENDING";
const hasMarkedPaid = !!paymentMarkedAt || !!paymentPaidAt;
const isFullyPaid = bookingStatus === "PAID";
const canMarkPaid = bookingStatus === "AWAITING_PAY" && !paymentMarkedAt;
const canPay = bookingStatus === "AWAITING_PAY";
return (
<div className="space-y-5">
<PaymentTimeline
approved={isApproved}
markedPaid={hasMarkedPaid}
confirmedPaid={isFullyPaid}
/>
<PaymentTimeline approved={isApproved} confirmedPaid={isFullyPaid} />
{!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.
<section className="rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
<div className="flex items-baseline justify-between gap-3">
<h3 className="text-sm font-bold text-neutral-900 sm:text-base">
Total Pembayaran
</h3>
<p className="text-lg font-bold text-primary-700 sm:text-xl">
{formatRupiah(price)}
</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>
)}
<p className="mt-1 text-xs text-neutral-500">
Pembayaran diproses oleh Midtrans (BCA VA, GoPay, QRIS, kartu, dll).
Dana ditahan SeTrip sampai trip selesai bukan transfer langsung
ke organizer.
</p>
</section>
{isPendingApproval && (
<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.
dulu sebelum bayar supaya tidak perlu refund kalau ditolak.
</div>
)}
{canMarkPaid && (
<div className="space-y-3">
{bankAvailable && (
<>
<MarkPaidButton tripId={tripId} />
<div className="flex items-center gap-3">
<span className="h-px flex-1 bg-neutral-200" />
<span className="text-[11px] font-semibold uppercase tracking-wide text-neutral-400">
atau
</span>
<span className="h-px flex-1 bg-neutral-200" />
</div>
</>
)}
<MidtransPayButton tripId={tripId} />
</div>
)}
{canPay && <MidtransPayButton tripId={tripId} />}
{hasMarkedPaid && (
<div className="rounded-2xl border border-neutral-200 bg-white p-4 text-sm text-neutral-600 shadow-sm sm:p-5">
{isFullyPaid ? (
<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>
)}
{isFullyPaid && (
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900 sm:p-5">
<p>
Pembayaran kamu sudah terkonfirmasi. Sampai jumpa di trip
bareng{" "}
<span className="font-semibold">{organizerName}</span>!
</p>
</div>
)}
@@ -359,17 +273,14 @@ async function PaidTripSection({
function PaymentTimeline({
approved,
markedPaid,
confirmedPaid,
}: {
approved: boolean;
markedPaid: boolean;
confirmedPaid: boolean;
}) {
const steps = [
{ label: "Disetujui organizer", done: approved },
{ label: "Kamu menandai sudah bayar", done: markedPaid },
{ label: "Organizer konfirmasi pembayaran", done: confirmedPaid },
{ label: "Pembayaran terkonfirmasi Midtrans", done: confirmedPaid },
];
return (
@@ -404,37 +315,3 @@ function PaymentTimeline({
</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>
);
}