add payment, trust badge, handle race condition, fix booking schema

This commit is contained in:
arifal
2026-04-20 23:57:31 +07:00
parent ba5f64ae0e
commit fcdca34460
33 changed files with 1781 additions and 138 deletions
+94 -8
View File
@@ -3,8 +3,14 @@ import { getServerSession } from "next-auth";
import Link from "next/link";
import { authOptions } from "@/lib/auth";
import { tripService } from "@/server/services/trip.service";
import { formatRupiah, formatDateRange } from "@/lib/utils";
import { trustService } from "@/server/services/trust.service";
import { formatRupiah } from "@/lib/utils";
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
import { JoinTripButton } from "@/features/trip/components/join-trip-button";
import { OrganizerJoinRequests } from "@/features/trip/components/organizer-join-requests";
import { OrganizerTrustPanel } from "@/features/trip/components/organizer-trust-panel";
import { TripProgramBlock } from "@/features/trip/components/trip-program-block";
import { OrganizerPaymentQueue } from "@/features/booking/components/organizer-payment-queue";
import { ImageGallery } from "@/features/trip/components/image-gallery";
import { TripReviewSection } from "@/features/review/components/trip-review-section";
import {
@@ -27,10 +33,21 @@ export default async function TripDetailPage({
notFound();
}
const organizerTrust = await trustService.getOrganizerTrust(
trip.organizerId
);
const activeParticipants = trip.participants.filter(
(p) => p.status !== "CANCELLED"
);
const confirmedParticipants = activeParticipants.filter(
(p) => p.status === "CONFIRMED"
);
const pendingParticipants = activeParticipants.filter(
(p) => p.status === "PENDING"
);
const participantCount = activeParticipants.length;
const confirmedCount = confirmedParticipants.length;
const spotsLeft = trip.maxParticipants - participantCount;
const fillPercent = Math.min(
(participantCount / trip.maxParticipants) * 100,
@@ -63,6 +80,10 @@ export default async function TripDetailPage({
) / 10
: null;
const paymentPendingParticipants = activeParticipants.filter(
(p) => p.markedPaidAt && !p.paymentConfirmedAt
);
return (
<div className="mx-auto max-w-3xl px-4 py-4 sm:py-8">
{/* Breadcrumb */}
@@ -123,7 +144,7 @@ export default async function TripDetailPage({
<div className="min-w-0">
<p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Tanggal</p>
<p className="truncate text-xs font-semibold text-neutral-800 sm:text-sm">
{formatDateRange(trip.date, trip.endDate)}
{formatTripCalendarDateRangeLong(trip.date, trip.endDate)}
</p>
</div>
</div>
@@ -153,6 +174,12 @@ export default async function TripDetailPage({
</div>
</div>
<OrganizerTrustPanel
name={trip.organizer.name}
image={trip.organizer.image}
trust={organizerTrust}
/>
{/* Participant Progress */}
<div className="rounded-xl border border-neutral-200 p-3 sm:p-4">
<div className="mb-2 flex items-center justify-between">
@@ -179,12 +206,30 @@ export default async function TripDetailPage({
/>
</div>
<p className="mt-1.5 text-[11px] text-neutral-500 sm:text-xs">
Maksimal {trip.maxParticipants} orang. Saat ini {participantCount}{" "}
mendaftar, {confirmedCount} sudah disetujui organizer.
</p>
<p className="mt-1 text-[11px] text-neutral-500 sm:text-xs">
{spotsLeft > 0
? `${spotsLeft} slot tersisa — yuk gabung!`
? `Masih ada ${spotsLeft} tempat — yuk gabung!`
: "Trip sudah penuh"}
{confirmedCount < participantCount && (
<>
{" "}
· {participantCount - confirmedCount} menunggu persetujuan
organizer
</>
)}
</p>
</div>
<TripProgramBlock
meetingPoint={trip.meetingPoint}
itinerary={trip.itinerary}
whatsIncluded={trip.whatsIncluded}
whatsExcluded={trip.whatsExcluded}
/>
{/* Description */}
{trip.description && (
<div>
@@ -197,12 +242,50 @@ export default async function TripDetailPage({
</div>
)}
{isOrganizer && pendingParticipants.length > 0 && (
<OrganizerJoinRequests
tripId={trip.id}
pending={pendingParticipants.map((p) => ({
id: p.id,
user: p.user,
markedPaidAt: p.markedPaidAt,
}))}
/>
)}
{isOrganizer && paymentPendingParticipants.length > 0 && (
<OrganizerPaymentQueue
tripId={trip.id}
items={paymentPendingParticipants.map((p) => ({
id: p.id,
user: p.user,
joinStatus:
p.status === "PENDING" ? ("PENDING" as const) : ("CONFIRMED" as const),
}))}
/>
)}
{/* Action */}
<JoinTripButton
tripId={trip.id}
isLoggedIn={!!session?.user}
isOrganizer={isOrganizer}
isJoined={!!currentParticipation}
participationStatus={
currentParticipation?.status === "PENDING" ||
currentParticipation?.status === "CONFIRMED"
? currentParticipation.status
: null
}
participantPayment={
currentParticipation
? {
markedPaidAt: currentParticipation.markedPaidAt,
paymentConfirmedAt:
currentParticipation.paymentConfirmedAt,
}
: null
}
isFull={spotsLeft <= 0}
tripStatus={trip.status}
isDeparturePast={isDeparturePast}
@@ -226,18 +309,21 @@ export default async function TripDetailPage({
}
/>
{/* Participants List */}
{/* Peserta yang sudah disetujui organizer (publik) */}
<div>
<h2 className="mb-3 text-xs font-bold text-neutral-700 sm:text-sm">
Peserta ({participantCount})
Peserta terkonfirmasi ({confirmedCount})
</h2>
{participantCount === 0 ? (
{confirmedCount === 0 ? (
<p className="text-xs text-neutral-400 sm:text-sm">
Belum ada peserta. Jadilah yang pertama! 🎒
Belum ada peserta yang dikonfirmasi.{" "}
{pendingParticipants.length > 0
? "Cek permintaan join di atas untuk menyetujui peserta."
: "Jadilah yang pertama mendaftar! 🎒"}
</p>
) : (
<div className="flex flex-wrap gap-1.5 sm:gap-2">
{activeParticipants.map((p) => (
{confirmedParticipants.map((p) => (
<div
key={p.id}
className="flex items-center gap-1.5 rounded-full bg-neutral-100 px-2.5 py-1 sm:gap-2 sm:px-3 sm:py-1.5"