Files
setrip/app/(public)/trips/[id]/payment/page.tsx
T
2026-05-21 11:59:02 +07:00

371 lines
12 KiB
TypeScript

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 { 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 { MidtransPayButton } from "@/features/booking/components/midtrans-pay-button";
import {
ArrowLeft,
CalendarDays,
MapPin,
PartyPopper,
CircleCheck,
Clock,
Check,
} from "lucide-react";
export const metadata: Metadata = {
title: "Detail Pembayaran",
robots: { index: false, follow: false },
};
interface PageProps {
params: Promise<{ id: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}
export default async function PaymentPage({ params, searchParams }: 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();
}
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
);
if (!booking || booking.status === "CANCELLED") {
return <NotJoinedNotice tripId={trip.id} title={trip.title} />;
}
const tripIsFree = isFreeTrip(trip);
const catMeta = categoryMeta(trip.category);
const dateRange = formatTripCalendarDateRangeLong(trip.date, trip.endDate);
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 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-neutral-500 sm:text-sm">
<span className="inline-flex items-center gap-1">
<CalendarDays size={13} strokeWidth={1.75} aria-hidden />
{dateRange}
</span>
<span className="inline-flex items-center gap-1">
<MapPin size={13} strokeWidth={1.75} aria-hidden />
{trip.location}
</span>
</p>
<p className="mt-1 text-xs text-neutral-500 sm:text-sm">
Organizer:{" "}
<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="inline-flex items-center gap-1 hover:text-primary-600"
>
<ArrowLeft size={14} strokeWidth={1.75} aria-hidden />
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."
: "Bayar lewat Midtrans untuk mengamankan slot kamu. Pembayaran akan ter-konfirmasi otomatis."}
</p>
{tripHeader}
{tripIsFree ? (
<FreeTripSection tripId={trip.id} bookingStatus={booking.status} />
) : (
<PaidTripSection
tripId={trip.id}
organizerName={trip.organizer.name}
price={trip.price}
bookingStatus={booking.status}
/>
)}
</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>
);
}
type BookingStatus =
| "PENDING"
| "AWAITING_PAY"
| "PAID"
| "CANCELLED"
| "REFUNDED"
| "PARTIALLY_REFUNDED"
| "EXPIRED";
function FreeTripSection({
tripId,
bookingStatus,
}: {
tripId: string;
bookingStatus: BookingStatus;
}) {
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">
<PartyPopper
size={26}
strokeWidth={1.75}
aria-hidden
className="text-emerald-600"
/>
</div>
<h2 className="mb-1 text-lg font-bold text-emerald-900 sm:text-xl">
Trip ini gratis
</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="flex items-center gap-1.5 text-sm font-bold text-neutral-800">
{bookingStatus === "PAID" ? (
<>
<CircleCheck
size={15}
strokeWidth={2}
aria-hidden
className="text-emerald-600"
/>
Terkonfirmasi sebagai peserta
</>
) : (
<>
<Clock
size={15}
strokeWidth={2}
aria-hidden
className="text-amber-600"
/>
Menunggu persetujuan organizer
</>
)}
</p>
</div>
<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>
);
}
function PaidTripSection({
tripId,
organizerName,
price,
bookingStatus,
}: {
tripId: string;
organizerName: string;
price: number;
bookingStatus: BookingStatus;
}) {
const isApproved =
bookingStatus === "AWAITING_PAY" || bookingStatus === "PAID";
const isPendingApproval = bookingStatus === "PENDING";
const isFullyPaid = bookingStatus === "PAID";
const canPay = bookingStatus === "AWAITING_PAY";
return (
<div className="space-y-5">
<PaymentTimeline approved={isApproved} confirmedPaid={isFullyPaid} />
<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>
<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 bayar supaya tidak perlu refund kalau ditolak.
</div>
)}
{canPay && <MidtransPayButton tripId={tripId} />}
{isFullyPaid && (
<div className="flex items-start gap-2 rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900 sm:p-5">
<CircleCheck
size={16}
strokeWidth={2}
aria-hidden
className="mt-0.5 shrink-0 text-emerald-600"
/>
<p>
Pembayaran kamu sudah terkonfirmasi. Sampai jumpa di trip bareng{" "}
<span className="font-semibold">{organizerName}</span>!
</p>
</div>
)}
<div className="text-center">
<Link
href={`/trips/${tripId}`}
className="inline-flex items-center gap-1 text-sm text-neutral-500 hover:text-primary-600"
>
<ArrowLeft size={14} strokeWidth={1.75} aria-hidden />
Kembali ke detail trip
</Link>
</div>
</div>
);
}
function PaymentTimeline({
approved,
confirmedPaid,
}: {
approved: boolean;
confirmedPaid: boolean;
}) {
const steps = [
{ label: "Disetujui organizer", done: approved },
{ label: "Pembayaran terkonfirmasi Midtrans", done: confirmedPaid },
];
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 ? (
<Check size={12} strokeWidth={3} aria-hidden />
) : (
i + 1
)}
</span>
<span
className={`text-sm ${
s.done
? "font-semibold text-neutral-800"
: "text-neutral-500"
}`}
>
{s.label}
</span>
</li>
))}
</ol>
</section>
);
}