create public layout and admin and fix escrow and refund

This commit is contained in:
arifal
2026-05-12 00:05:30 +07:00
parent a07942c4b4
commit 958514d575
48 changed files with 1928 additions and 18 deletions
+227
View File
@@ -0,0 +1,227 @@
import { ImageResponse } from "next/og";
import { tripService } from "@/server/services/trip.service";
import { formatRupiah } from "@/lib/utils";
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
import { siteConfig } from "@/lib/site";
export const alt = `${siteConfig.name} — Open Trip & Aktivitas Bareng`;
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default async function TripOgImage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
let trip;
try {
trip = await tripService.getTripById(id);
} catch {
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background:
"linear-gradient(135deg, #14532d 0%, #16a34a 60%, #0c4a6e 100%)",
color: "white",
fontSize: 96,
fontWeight: 800,
letterSpacing: -2,
}}
>
{siteConfig.name}
</div>
),
{ ...size }
);
}
const cover = trip.images[0]?.url;
const dateLabel = formatTripCalendarDateRangeLong(trip.date, trip.endDate);
const price = formatRupiah(trip.price);
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
position: "relative",
background: "#0a0a0a",
color: "white",
fontFamily: "sans-serif",
}}
>
{cover && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={cover}
alt=""
width={1200}
height={630}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
objectFit: "cover",
filter: "brightness(0.45) saturate(1.05)",
}}
/>
)}
<div
style={{
position: "absolute",
inset: 0,
background:
"linear-gradient(135deg, rgba(20,83,45,0.88) 0%, rgba(10,10,10,0.5) 55%, rgba(12,74,110,0.85) 100%)",
display: "flex",
}}
/>
<div
style={{
position: "relative",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
padding: 64,
width: "100%",
height: "100%",
}}
>
{/* Top: brand badge */}
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
background: "rgba(22,163,74,0.25)",
border: "1px solid rgba(74,222,128,0.45)",
borderRadius: 999,
padding: "10px 22px",
fontSize: 24,
fontWeight: 600,
color: "#86efac",
}}
>
<span style={{ fontSize: 28 }}>🤝</span>
<span>Open Trip Bareng</span>
</div>
</div>
{/* Middle: title + destination */}
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div
style={{
fontSize: trip.title.length > 40 ? 64 : 76,
fontWeight: 800,
letterSpacing: -2,
lineHeight: 1.05,
display: "flex",
maxWidth: 1050,
}}
>
{trip.title}
</div>
<div
style={{
fontSize: 32,
color: "#bbf7d0",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<span>📍</span>
<span>
{trip.destination} · {trip.location}
</span>
</div>
</div>
{/* Bottom: date / price / brand */}
<div
style={{
display: "flex",
alignItems: "flex-end",
justifyContent: "space-between",
gap: 32,
}}
>
<div style={{ display: "flex", flexDirection: "column", gap: 22 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 16,
fontSize: 26,
color: "#e0f2fe",
}}
>
<span style={{ fontSize: 30 }}>📅</span>
<span>{dateLabel}</span>
</div>
<div
style={{
display: "flex",
alignItems: "baseline",
gap: 14,
}}
>
<span style={{ fontSize: 22, color: "#86efac" }}>Mulai</span>
<span
style={{
fontSize: 56,
fontWeight: 800,
color: "#4ade80",
letterSpacing: -1,
}}
>
{price}
</span>
<span style={{ fontSize: 22, color: "#86efac" }}>/ orang</span>
</div>
</div>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
gap: 6,
}}
>
<div
style={{
fontSize: 48,
fontWeight: 800,
letterSpacing: -2,
display: "flex",
}}
>
<span>Se</span>
<span style={{ color: "#4ade80" }}>Trip</span>
</div>
<div style={{ fontSize: 18, color: "#a3a3a3" }}>
{siteConfig.slogan}
</div>
</div>
</div>
</div>
</div>
),
{ ...size }
);
}
+635
View File
@@ -0,0 +1,635 @@
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 { bookingRepo } from "@/server/repositories/booking.repo";
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 { CancelTripButton } from "@/features/trip/components/cancel-trip-button";
import { CancelBookingButton } from "@/features/booking/components/cancel-booking-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 { RefundPolicySection } from "@/features/refund/components/refund-policy-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";
import { previewRefund } from "@/lib/refund-policy";
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}): Promise<Metadata> {
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)
: [];
// Booking peserta saat ini — dipakai untuk render CancelBookingButton vs
// tombol "Batal Ikut" biasa. Hanya untuk non-organizer yang ikut trip.
const myBooking =
session?.user && !isOrganizer && currentParticipation
? await bookingService.getByTripAndUser(trip.id, session.user.id)
: null;
// Untuk CancelTripButton: jumlah booking PAID/PARTIALLY_REFUNDED (yang akan
// auto-refund). Hanya dihitung saat organizer mengakses trip yang masih
// bisa dibatalkan.
const canOrganizerCancel =
isOrganizer &&
(trip.status === "OPEN" || trip.status === "FULL") &&
!isDeparturePast;
const paidBookingCount = canOrganizerCancel
? await bookingRepo.countSettledForTrip(trip.id)
: 0;
// Preview refund untuk CancelBookingButton (server-side supaya konsisten
// dengan service yang juga pakai policy yang sama).
const refundPreview =
myBooking && myBooking.status === "PAID" && !isDeparturePast
? previewRefund(myBooking.amount, trip.date)
: null;
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 (
<div className="mx-auto max-w-3xl px-4 py-4 sm:py-8">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
{/* 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.destination}</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 flex-wrap items-center gap-1.5 text-sm text-neutral-500">
<span aria-hidden>{catMeta.icon}</span>
<span className="rounded-full bg-neutral-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-neutral-600">
{catMeta.label}
</span>
{trip.vibe && (
<span
className="inline-flex items-center gap-0.5 rounded-full bg-secondary-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-secondary-700"
title={vibeMeta(trip.vibe).description}
>
<span aria-hidden>{vibeMeta(trip.vibe).icon}</span>
<span>{vibeMeta(trip.vibe).label}</span>
</span>
)}
<span className="truncate">{trip.destination}</span>
</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>
<Link
href={`/u/${trip.organizer.id}`}
className="truncate text-xs font-semibold text-neutral-800 hover:text-primary-700 sm:text-sm"
>
{trip.organizer.name}
</Link>
</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 gap-2">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-neutral-700 sm:text-sm">
Peserta
</span>
{spotsLeft > 0 && spotsLeft <= 3 && (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-bold text-amber-800 sm:text-[11px]">
Tinggal {spotsLeft} spot!
</span>
)}
{spotsLeft <= 0 && (
<span className="rounded-full bg-neutral-200 px-2 py-0.5 text-[10px] font-bold text-neutral-700 sm:text-[11px]">
Penuh
</span>
)}
</div>
<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>
{confirmedCount > 0 && (
<p className="mt-2 text-[11px] text-neutral-600 sm:text-xs">
<span aria-hidden>👥</span> Sudah join:{" "}
<span className="font-medium text-neutral-800">
{confirmedParticipants
.slice(0, 3)
.map((p) => p.user.name.split(" ")[0])
.join(", ")}
</span>
{confirmedCount > 3 && (
<span className="text-neutral-500">
{" "}
+{confirmedCount - 3} lainnya
</span>
)}
</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 && paymentPendingBookings.length > 0 && (
<OrganizerPaymentQueue
tripId={trip.id}
items={paymentPendingBookings.map((b) => ({
id: b.participantId,
user: { name: b.user.name, image: b.user.image },
joinStatus: "CONFIRMED" as const,
}))}
/>
)}
{/* Action */}
<JoinTripButton
tripId={trip.id}
isLoggedIn={!!session?.user}
isOrganizer={isOrganizer}
isJoined={!!currentParticipation}
isFree={tripIsFree}
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}
hideCancelButton={!!refundPreview}
/>
{/* Peserta PAID: cancel + request refund (lewat policy default). */}
{refundPreview && (
<CancelBookingButton
tripId={trip.id}
preview={{
days: refundPreview.days,
refundAmount: refundPreview.refundAmount,
bookingAmount: refundPreview.bookingAmount,
tierLabel: refundPreview.tier.label,
}}
/>
)}
{/* Organizer: batalkan trip (auto-refund peserta PAID). */}
{canOrganizerCancel && (
<CancelTripButton
tripId={trip.id}
paidParticipantCount={paidBookingCount}
/>
)}
{/* Kebijakan refund — transparency sebelum user cancel. */}
{!tripIsFree && <RefundPolicySection />}
<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-1 text-xs font-bold text-neutral-700 sm:text-sm">
Peserta terkonfirmasi ({confirmedCount})
</h2>
<p className="mb-3 text-[11px] text-neutral-500 sm:text-xs">
Kenalan dulu sebelum berangkat klik kartu untuk lihat profil.
</p>
{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>
) : (
<ul className="grid gap-2 sm:grid-cols-2">
{confirmedParticipants.map((p) => {
const interests = p.user.profile?.interests ?? [];
const city = p.user.profile?.city;
return (
<li key={p.id}>
<Link
href={`/u/${p.user.id}`}
className="flex items-start gap-3 rounded-xl border border-neutral-200 bg-white px-3 py-2.5 transition-colors hover:border-primary-300 hover:bg-primary-50/30"
>
{p.user.image ? (
<Image
src={p.user.image}
alt=""
width={40}
height={40}
className="h-10 w-10 shrink-0 rounded-full object-cover"
/>
) : (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary-600 text-sm font-bold text-white">
{p.user.name.charAt(0).toUpperCase()}
</div>
)}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-neutral-800">
{p.user.name}
</p>
{city && (
<p className="truncate text-[11px] text-neutral-500 sm:text-xs">
📍 {city}
</p>
)}
{interests.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{interests.slice(0, 3).map((tag) => (
<span
key={tag}
className="rounded-full bg-secondary-50 px-1.5 py-0.5 text-[10px] font-medium text-secondary-700"
>
#{tag}
</span>
))}
{interests.length > 3 && (
<span className="text-[10px] text-neutral-400">
+{interests.length - 3}
</span>
)}
</div>
)}
</div>
</Link>
</li>
);
})}
</ul>
)}
</div>
</div>
</div>
</div>
);
}
+440
View File
@@ -0,0 +1,440 @@
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 { bookingService } from "@/server/services/booking.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 { MidtransPayButton } from "@/features/booking/components/midtrans-pay-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 booking = await bookingService.getByTripAndUser(
trip.id,
session.user.id
);
if (!booking || booking.status === "CANCELLED") {
return <NotJoinedNotice tripId={trip.id} title={trip.title} />;
}
const latestManualPayment = booking.payments.find(
(p) => p.provider === "MANUAL"
);
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}
bookingStatus={booking.status}
/>
) : (
<PaidTripSection
tripId={trip.id}
organizerId={trip.organizerId}
organizerName={trip.organizer.name}
price={trip.price}
bookingStatus={booking.status}
paymentMarkedAt={
latestManualPayment?.status === "AWAITING"
? latestManualPayment.updatedAt
: null
}
paymentPaidAt={latestManualPayment?.paidAt ?? null}
/>
)}
</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,
bookingStatus,
}: {
tripId: string;
bookingStatus:
| "PENDING"
| "AWAITING_PAY"
| "PAID"
| "CANCELLED"
| "REFUNDED"
| "PARTIALLY_REFUNDED"
| "EXPIRED";
}) {
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">
{bookingStatus === "PAID"
? "✅ 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,
bookingStatus,
paymentMarkedAt,
paymentPaidAt,
}: {
tripId: string;
organizerId: string;
organizerName: string;
price: number;
bookingStatus:
| "PENDING"
| "AWAITING_PAY"
| "PAID"
| "CANCELLED"
| "REFUNDED"
| "PARTIALLY_REFUNDED"
| "EXPIRED";
paymentMarkedAt: Date | null;
paymentPaidAt: Date | null;
}) {
const verification = await organizerService.getStatusForUser(organizerId);
const bankAvailable = verification?.status === "APPROVED";
const isApproved = bookingStatus === "AWAITING_PAY" || bookingStatus === "PAID";
const isPendingApproval = bookingStatus === "PENDING";
const hasMarkedPaid = !!paymentMarkedAt || !!paymentPaidAt;
const isFullyPaid = bookingStatus === "PAID";
const canMarkPaid = bookingStatus === "AWAITING_PAY" && !paymentMarkedAt;
return (
<div className="space-y-5">
<PaymentTimeline
approved={isApproved}
markedPaid={hasMarkedPaid}
confirmedPaid={isFullyPaid}
/>
{!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>
)}
{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 transfer supaya tidak perlu refund kalau ditolak.
</div>
)}
{canMarkPaid && (
<div className="space-y-3">
{bankAvailable && (
<>
<MarkPaidButton tripId={tripId} />
<div className="flex items-center gap-3">
<span className="h-px flex-1 bg-neutral-200" />
<span className="text-[11px] font-semibold uppercase tracking-wide text-neutral-400">
atau
</span>
<span className="h-px flex-1 bg-neutral-200" />
</div>
</>
)}
<MidtransPayButton tripId={tripId} />
</div>
)}
{hasMarkedPaid && (
<div className="rounded-2xl border border-neutral-200 bg-white p-4 text-sm text-neutral-600 shadow-sm sm:p-5">
{isFullyPaid ? (
<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({
approved,
markedPaid,
confirmedPaid,
}: {
approved: boolean;
markedPaid: boolean;
confirmedPaid: boolean;
}) {
const steps = [
{ label: "Disetujui organizer", done: approved },
{ label: "Kamu menandai sudah bayar", done: markedPaid },
{ label: "Organizer konfirmasi pembayaran", 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 ? "✓" : 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>
);
}
+173
View File
@@ -0,0 +1,173 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Suspense } from "react";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { tripService } from "@/server/services/trip.service";
import { profileRepo } from "@/server/repositories/profile.repo";
import { TripCard } from "@/features/trip/components/trip-card";
import { TripFilter } from "@/features/trip/components/trip-filter";
import { siteConfig } from "@/lib/site";
import { categoryLabel, isActivityCategory } from "@/lib/activity-category";
import { isVibe } from "@/lib/vibe";
import type { GroupSize } from "@/server/repositories/trip.repo";
const GROUP_SIZES: GroupSize[] = ["SMALL", "MEDIUM", "LARGE"];
function isGroupSize(value: unknown): value is GroupSize {
return typeof value === "string" && (GROUP_SIZES as string[]).includes(value);
}
interface TripsPageProps {
searchParams: Promise<{
q?: string;
from?: string;
to?: string;
category?: string;
vibe?: string;
groupSize?: string;
}>;
}
export async function generateMetadata({
searchParams,
}: TripsPageProps): Promise<Metadata> {
const { q, category: categoryParam } = await searchParams;
const category = isActivityCategory(categoryParam) ? categoryParam : undefined;
const categoryName = category ? categoryLabel(category) : null;
const title = q
? `Cari Teman Trip "${q}" — Gabung Bareng`
: categoryName
? `Cari Teman ${categoryName} — Daftar Open Trip Aktif`
: "Cari Teman Trip & Aktivitas — Daftar Open Trip Aktif";
const description = q
? `Hasil pencarian "${q}" di ${siteConfig.name}. Temukan teman seperjalanan, lihat trip & organizer terverifikasi, langsung gabung.`
: categoryName
? `Daftar open trip ${categoryName.toLowerCase()} di ${siteConfig.name}. Pilih trip, kenal calon teman seperjalanan, dan gabung bareng — grup kecil & organizer terverifikasi.`
: `Daftar open trip aktif di ${siteConfig.name} — hiking, camping, snorkeling, city trip, dan aktivitas bareng lainnya. Pilih trip, kenal calon teman seperjalanan, dan gabung bareng — grup kecil & organizer terverifikasi.`;
return {
title,
description,
alternates: { canonical: "/trips" },
openGraph: { title, description, url: "/trips" },
};
}
export default async function TripsPage({ searchParams }: TripsPageProps) {
const params = await searchParams;
const category = isActivityCategory(params.category) ? params.category : undefined;
const vibe = isVibe(params.vibe) ? params.vibe : undefined;
const groupSize = isGroupSize(params.groupSize) ? params.groupSize : undefined;
const hasFilters = Boolean(
params.q || params.from || params.to || category || vibe || groupSize
);
const filters = {
q: params.q,
from: params.from,
to: params.to,
category,
vibe,
groupSize,
};
const session = await getServerSession(authOptions);
const [trips, allTrips, viewerProfile] = await Promise.all([
tripService.getOpenTrips(filters),
hasFilters ? tripService.getOpenTrips() : null,
session?.user?.id
? profileRepo.findByUserId(session.user.id)
: Promise.resolve(null),
]);
const totalCount = hasFilters ? allTrips!.length : trips.length;
const viewerInterests = viewerProfile?.interests ?? [];
return (
<div className="mx-auto max-w-6xl px-4 py-6 sm:py-8">
<div className="mb-6 flex flex-col gap-3 sm:mb-8 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-xl font-bold text-neutral-800 sm:text-2xl">
{category
? `Cari Teman ${categoryLabel(category)}`
: "Cari Teman Trip & Aktivitas"}
</h1>
<p className="mt-0.5 text-sm text-neutral-500">
{hasFilters
? `${trips.length} dari ${totalCount} trip ditemukan`
: `${trips.length} trip tersedia — pilih, kenalan, lalu gabung`}
</p>
</div>
<Link
href="/create-trip"
className="w-full rounded-xl bg-primary-600 px-4 py-2.5 text-center text-sm font-semibold text-white shadow-md shadow-primary-600/20 hover:bg-primary-700 sm:w-auto"
>
+ Buat Trip
</Link>
</div>
{/* Filter */}
<div className="mb-6">
<Suspense fallback={null}>
<TripFilter />
</Suspense>
</div>
{trips.length === 0 ? (
<div className="rounded-2xl border-2 border-dashed border-neutral-200 bg-white p-8 text-center sm:p-14">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 text-2xl sm:h-16 sm:w-16 sm:text-3xl">
{hasFilters ? "🔍" : "🏕️"}
</div>
<p className="mb-1 text-base font-bold text-neutral-800 sm:text-lg">
{hasFilters
? "Tidak ada trip yang cocok"
: "Belum ada trip tersedia"}
</p>
<p className="mb-5 text-sm text-neutral-500 sm:mb-6">
{hasFilters
? "Coba ubah kata kunci atau rentang tanggal pencarian"
: "Jadilah yang pertama membuat open trip di sini!"}
</p>
{!hasFilters && (
<Link
href="/create-trip"
className="inline-block rounded-xl bg-primary-600 px-5 py-2.5 text-sm font-semibold text-white shadow-lg shadow-primary-600/25 hover:bg-primary-700"
>
Buat Trip Baru
</Link>
)}
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{trips.map((trip) => (
<TripCard
key={trip.id}
id={trip.id}
title={trip.title}
category={trip.category}
vibe={trip.vibe}
destination={trip.destination}
location={trip.location}
date={trip.date}
endDate={trip.endDate}
price={trip.price}
maxParticipants={trip.maxParticipants}
participantCount={trip._count.participants}
organizerName={trip.organizer.name}
status={trip.status}
coverImage={trip.images[0]?.url}
isVerifiedOrganizer={
trip.organizer.organizerVerification?.status === "APPROVED"
}
participants={trip.participants.map((p) => ({
id: p.id,
name: p.user.name,
image: p.user.image,
interests: p.user.profile?.interests ?? [],
}))}
viewerInterests={viewerInterests}
/>
))}
</div>
)}
</div>
);
}