import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { getServerSession } from "next-auth"; import Link from "next/link"; import Image from "next/image"; import { authOptions } from "@/lib/auth"; import { tripService } from "@/server/services/trip.service"; import { bookingService } from "@/server/services/booking.service"; import { trustService } from "@/server/services/trust.service"; import { formatRupiah } from "@/lib/utils"; import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates"; import { siteConfig, siteUrl, absoluteUrl } from "@/lib/site"; 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 { categoryMeta } from "@/lib/activity-category"; import { vibeMeta } from "@/lib/vibe"; import { isFreeTrip } from "@/lib/trip-pricing"; import { isPastTripLastDayForReview, isTripDepartureDayPast, } from "@/lib/trip-dates"; export async function generateMetadata({ params, }: { params: Promise<{ id: string }>; }): Promise { const { id } = await params; let trip; try { trip = await tripService.getTripById(id); } catch { return { title: "Trip tidak ditemukan", robots: { index: false, follow: false }, }; } const title = `${trip.title} โ€” ${trip.destination}`; const fallbackDescription = `Open trip ${trip.destination} di ${trip.location}, ${formatTripCalendarDateRangeLong(trip.date, trip.endDate)}. Harga ${formatRupiah(trip.price)}/orang, max ${trip.maxParticipants} peserta. Gabung di ${siteConfig.name}.`; const description = trip.description?.replace(/\s+/g, " ").trim().slice(0, 160) || fallbackDescription; // OG/Twitter image otomatis di-inject dari `opengraph-image.tsx` colocated. return { title, description, alternates: { canonical: `/trips/${id}` }, openGraph: { type: "article", title, description, url: `/trips/${id}`, }, twitter: { card: "summary_large_image", title, description, }, }; } 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 tripIsFree = isFreeTrip(trip); // Antrian konfirmasi pembayaran: source dari Booking + Payment (B9). // Hanya organizer yang butuh data ini, dan hanya untuk trip berbayar. const paymentPendingBookings = !tripIsFree && isOrganizer ? await bookingService.getAwaitingManualForTrip(trip.id) : []; const catMeta = categoryMeta(trip.category); const tripUrl = absoluteUrl(`/trips/${trip.id}`); const eventStatus = trip.status === "OPEN" ? "https://schema.org/EventScheduled" : trip.status === "CLOSED" ? "https://schema.org/EventCancelled" : "https://schema.org/EventScheduled"; const offerAvailability = trip.status === "OPEN" ? "https://schema.org/InStock" : trip.status === "FULL" ? "https://schema.org/SoldOut" : "https://schema.org/Discontinued"; const jsonLd = { "@context": "https://schema.org", "@graph": [ { "@type": "Event", "@id": `${tripUrl}#event`, name: trip.title, description: trip.description ?? undefined, startDate: trip.date.toISOString(), endDate: (trip.endDate ?? trip.date).toISOString(), eventStatus, eventAttendanceMode: "https://schema.org/OfflineEventAttendanceMode", location: { "@type": "Place", name: trip.destination, address: { "@type": "PostalAddress", addressLocality: trip.location, addressCountry: "ID", }, }, image: trip.images.length ? trip.images.map((i) => i.url) : [absoluteUrl("/images/SeTrip.png")], organizer: { "@type": "Person", name: trip.organizer.name, }, offers: { "@type": "Offer", url: tripUrl, price: trip.price, priceCurrency: "IDR", availability: offerAvailability, validFrom: trip.createdAt.toISOString(), }, maximumAttendeeCapacity: trip.maxParticipants, ...(averageRating && trip.reviews.length > 0 ? { aggregateRating: { "@type": "AggregateRating", ratingValue: averageRating, reviewCount: trip.reviews.length, bestRating: 5, worstRating: 1, }, } : {}), isAccessibleForFree: false, inLanguage: "id-ID", }, { "@type": "BreadcrumbList", "@id": `${tripUrl}#breadcrumbs`, itemListElement: [ { "@type": "ListItem", position: 1, name: "Beranda", item: siteUrl }, { "@type": "ListItem", position: 2, name: "Open Trip", item: absoluteUrl("/trips"), }, { "@type": "ListItem", position: 3, name: trip.destination, item: tripUrl, }, ], }, ], }; return (