347 lines
13 KiB
TypeScript
347 lines
13 KiB
TypeScript
import { notFound } from "next/navigation";
|
||
import { getServerSession } from "next-auth";
|
||
import Link from "next/link";
|
||
import { authOptions } from "@/lib/auth";
|
||
import { tripService } from "@/server/services/trip.service";
|
||
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 {
|
||
isPastTripLastDayForReview,
|
||
isTripDepartureDayPast,
|
||
} from "@/lib/trip-dates";
|
||
|
||
export default async function TripDetailPage({
|
||
params,
|
||
}: {
|
||
params: Promise<{ id: string }>;
|
||
}) {
|
||
const { id } = await params;
|
||
const session = await getServerSession(authOptions);
|
||
|
||
let trip;
|
||
try {
|
||
trip = await tripService.getTripById(id);
|
||
} catch {
|
||
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,
|
||
100
|
||
);
|
||
const isOrganizer = session?.user?.id === trip.organizerId;
|
||
const currentParticipation = session?.user
|
||
? trip.participants.find(
|
||
(p) => p.userId === session.user.id && p.status !== "CANCELLED"
|
||
)
|
||
: null;
|
||
|
||
const isDeparturePast = isTripDepartureDayPast(trip.date);
|
||
const canReview =
|
||
!!session?.user &&
|
||
!isOrganizer &&
|
||
currentParticipation?.status === "CONFIRMED" &&
|
||
isPastTripLastDayForReview(trip.date, trip.endDate);
|
||
|
||
const myReview = session?.user
|
||
? trip.reviews.find((r) => r.userId === session.user.id) ?? null
|
||
: null;
|
||
|
||
const averageRating =
|
||
trip.reviews.length > 0
|
||
? Math.round(
|
||
(trip.reviews.reduce((s, r) => s + r.rating, 0) /
|
||
trip.reviews.length) *
|
||
10
|
||
) / 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 */}
|
||
<div className="mb-3 flex items-center gap-2 text-xs text-neutral-500 sm:mb-4 sm:text-sm">
|
||
<Link href="/trips" className="hover:text-primary-600">
|
||
Open Trip
|
||
</Link>
|
||
<span>/</span>
|
||
<span className="truncate text-neutral-700">{trip.mountain}</span>
|
||
</div>
|
||
|
||
<div className="overflow-hidden rounded-2xl border border-neutral-200 bg-white shadow-sm">
|
||
{/* Image Gallery */}
|
||
<ImageGallery images={trip.images} />
|
||
|
||
{/* Title bar */}
|
||
<div className="border-b border-neutral-100 px-4 py-3 sm:px-6 sm:py-4">
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div className="min-w-0">
|
||
<h1 className="text-lg font-bold text-neutral-800 sm:text-xl">
|
||
{trip.title}
|
||
</h1>
|
||
<p className="mt-0.5 flex items-center gap-1.5 text-sm text-neutral-500">
|
||
🏔️ {trip.mountain}
|
||
</p>
|
||
</div>
|
||
<span
|
||
className={`shrink-0 rounded-full px-2.5 py-0.5 text-xs font-bold sm:px-3 sm:py-1 ${
|
||
trip.status === "OPEN"
|
||
? "bg-primary-100 text-primary-700"
|
||
: trip.status === "FULL"
|
||
? "bg-amber-100 text-amber-700"
|
||
: "bg-neutral-100 text-neutral-500"
|
||
}`}
|
||
>
|
||
{trip.status}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-5 p-4 sm:space-y-6 sm:p-6">
|
||
{/* Info Grid */}
|
||
<div className="grid grid-cols-2 gap-2 sm:gap-3">
|
||
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4">
|
||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-secondary-100 text-sm sm:h-10 sm:w-10 sm:text-lg">
|
||
📍
|
||
</span>
|
||
<div className="min-w-0">
|
||
<p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Lokasi</p>
|
||
<p className="truncate text-xs font-semibold text-neutral-800 sm:text-sm">{trip.location}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4">
|
||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-secondary-100 text-sm sm:h-10 sm:w-10 sm:text-lg">
|
||
📅
|
||
</span>
|
||
<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">
|
||
{formatTripCalendarDateRangeLong(trip.date, trip.endDate)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 rounded-xl bg-primary-50 p-3 sm:gap-3 sm:p-4">
|
||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary-100 text-sm sm:h-10 sm:w-10 sm:text-lg">
|
||
💰
|
||
</span>
|
||
<div className="min-w-0">
|
||
<p className="text-[10px] font-medium text-primary-600 sm:text-xs">Harga</p>
|
||
<p className="text-base font-bold text-primary-700 sm:text-xl">
|
||
{formatRupiah(trip.price)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4">
|
||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-200 text-sm sm:h-10 sm:w-10 sm:text-lg">
|
||
👤
|
||
</span>
|
||
<div className="min-w-0">
|
||
<p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Organizer</p>
|
||
<p className="truncate text-xs font-semibold text-neutral-800 sm:text-sm">
|
||
{trip.organizer.name}
|
||
</p>
|
||
</div>
|
||
</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">
|
||
<span className="text-xs font-semibold text-neutral-700 sm:text-sm">
|
||
Peserta
|
||
</span>
|
||
<span className="text-xs font-bold text-neutral-800 sm:text-sm">
|
||
{participantCount}{" "}
|
||
<span className="font-normal text-neutral-400">
|
||
/ {trip.maxParticipants}
|
||
</span>
|
||
</span>
|
||
</div>
|
||
<div className="h-2 overflow-hidden rounded-full bg-neutral-100 sm:h-2.5">
|
||
<div
|
||
className={`h-full rounded-full transition-all ${
|
||
fillPercent >= 100
|
||
? "bg-amber-500"
|
||
: fillPercent >= 70
|
||
? "bg-secondary-500"
|
||
: "bg-primary-500"
|
||
}`}
|
||
style={{ width: `${fillPercent}%` }}
|
||
/>
|
||
</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
|
||
? `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>
|
||
<h2 className="mb-2 text-xs font-bold text-neutral-700 sm:text-sm">
|
||
Deskripsi Trip
|
||
</h2>
|
||
<p className="whitespace-pre-wrap text-xs leading-relaxed text-neutral-600 sm:text-sm">
|
||
{trip.description}
|
||
</p>
|
||
</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}
|
||
/>
|
||
|
||
<TripReviewSection
|
||
tripId={trip.id}
|
||
reviews={trip.reviews.map((r) => ({
|
||
id: r.id,
|
||
rating: r.rating,
|
||
comment: r.comment,
|
||
createdAt: r.createdAt,
|
||
user: r.user,
|
||
}))}
|
||
averageRating={averageRating}
|
||
canReview={canReview}
|
||
myReview={
|
||
myReview
|
||
? { rating: myReview.rating, comment: myReview.comment }
|
||
: null
|
||
}
|
||
/>
|
||
|
||
{/* Peserta yang sudah disetujui organizer (publik) */}
|
||
<div>
|
||
<h2 className="mb-3 text-xs font-bold text-neutral-700 sm:text-sm">
|
||
Peserta terkonfirmasi ({confirmedCount})
|
||
</h2>
|
||
{confirmedCount === 0 ? (
|
||
<p className="text-xs text-neutral-400 sm:text-sm">
|
||
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">
|
||
{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"
|
||
>
|
||
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-primary-600 text-[9px] font-bold text-white sm:h-6 sm:w-6 sm:text-[10px]">
|
||
{p.user.name.charAt(0).toUpperCase()}
|
||
</div>
|
||
<span className="text-xs font-medium text-neutral-700 sm:text-sm">
|
||
{p.user.name}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|