create payment roadmap pr a
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user