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
+99
View File
@@ -0,0 +1,99 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { signOut, useSession } from "next-auth/react";
import { acceptTermsAction } from "@/features/auth/actions";
export function AcceptTermsForm() {
const router = useRouter();
const { update } = useSession();
const [checked, setChecked] = useState(false);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleAccept() {
setError("");
setLoading(true);
const result = await acceptTermsAction({ accepted: checked });
if (result.error) {
setError(result.error);
setLoading(false);
return;
}
// Refresh JWT supaya middleware lihat acceptedTermsAndPrivacy=true.
// Kalau update() gagal (network), JWT callback di server akan re-fetch
// DB pada request berikutnya karena cookie token masih `false`.
try {
await update();
} catch {
setError(
"Persetujuan tersimpan, tapi sesi belum ter-refresh. Coba refresh halaman atau klik lagi."
);
setLoading(false);
return;
}
router.replace("/");
router.refresh();
}
return (
<div className="mt-6 rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm">
{error && (
<div className="mb-4 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600">
{error}
</div>
)}
<label className="flex items-start gap-2.5 text-sm text-neutral-700">
<input
type="checkbox"
checked={checked}
onChange={(e) => setChecked(e.target.checked)}
className="mt-0.5 h-4 w-4 shrink-0 rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
/>
<span>
Saya telah membaca dan menyetujui{" "}
<Link
href="/terms"
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-primary-600 hover:text-primary-700"
>
Syarat &amp; Ketentuan
</Link>{" "}
dan{" "}
<Link
href="/privacy"
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-primary-600 hover:text-primary-700"
>
Kebijakan Privasi
</Link>{" "}
SeTrip.
</span>
</label>
<div className="mt-5 flex flex-col gap-2 sm:flex-row">
<button
type="button"
onClick={handleAccept}
disabled={!checked || loading}
className="flex-1 rounded-xl bg-primary-600 py-2.5 text-sm font-bold text-white shadow-lg shadow-primary-600/20 transition-colors hover:bg-primary-700 disabled:opacity-50"
>
{loading ? "Memproses..." : "Setuju & Lanjutkan"}
</button>
<button
type="button"
onClick={() => signOut({ callbackUrl: "/login" })}
disabled={loading}
className="rounded-xl border border-neutral-200 bg-white px-4 py-2.5 text-sm font-medium text-neutral-600 hover:bg-neutral-50 disabled:opacity-50"
>
Keluar
</button>
</div>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { userRepo } from "@/server/repositories/user.repo";
import { AcceptTermsForm } from "./form";
export default async function AcceptTermsPage() {
const session = await getServerSession(authOptions);
if (!session?.user) redirect("/login?callbackUrl=/accept-terms");
// Source of truth = DB (token bisa stale).
const user = await userRepo.findById(session.user.id);
if (user?.acceptedTermsAndPrivacy) redirect("/");
return (
<div className="mx-auto max-w-xl px-4 py-10 sm:py-16">
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
Satu langkah lagi
</h1>
<p className="mt-2 text-sm text-neutral-600">
Sebelum melanjutkan, mohon baca dan setujui Syarat &amp; Ketentuan dan
Kebijakan Privasi SeTrip.
</p>
<AcceptTermsForm />
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Buat Open Trip",
description:
"Buat open trip di SeTrip — hiking, camping, snorkeling, city trip, dan aktivitas bareng lainnya. Atur itinerary, harga, dan ajak orang lain ikut serta.",
alternates: { canonical: "/create-trip" },
robots: { index: false, follow: false },
};
export default function CreateTripLayout({ children }: { children: React.ReactNode }) {
return children;
}
+94
View File
@@ -0,0 +1,94 @@
import Link from "next/link";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { organizerService } from "@/server/services/organizer.service";
import { CreateTripForm } from "@/features/trip/components/create-trip-form";
export default async function CreateTripPage() {
const session = await getServerSession(authOptions);
if (!session?.user) {
return (
<div className="flex min-h-[calc(100vh-3.5rem)] items-center justify-center px-4">
<div className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary-50 text-3xl">
🔒
</div>
<p className="mb-4 text-neutral-500">
Kamu harus login untuk membuat trip.
</p>
<Link
href="/login?callbackUrl=/create-trip"
className="inline-block rounded-xl bg-primary-600 px-6 py-2.5 text-sm font-semibold text-white hover:bg-primary-700"
>
Login
</Link>
</div>
</div>
);
}
const verification = await organizerService.getStatusForUser(session.user.id);
const isVerifiedOrganizer = verification?.status === "APPROVED";
return (
<div className="mx-auto max-w-2xl px-4 py-6 sm:py-8">
<div className="mb-4 sm:mb-6">
<h1 className="text-2xl font-bold text-neutral-800">Buat Trip Baru</h1>
<p className="mt-1 text-sm text-neutral-500">
Ajak orang baru jalan bareng!
</p>
</div>
{!isVerifiedOrganizer && (
<VerificationBanner status={verification?.status ?? null} />
)}
<CreateTripForm isVerifiedOrganizer={isVerifiedOrganizer} />
</div>
);
}
function VerificationBanner({
status,
}: {
status: "PENDING" | "APPROVED" | "REJECTED" | null;
}) {
if (status === "PENDING") {
return (
<div className="mb-5 rounded-2xl border border-amber-200 bg-amber-50 p-4 sm:p-5">
<p className="text-sm font-bold text-amber-800">
Verifikasi sedang diproses
</p>
<p className="mt-1 text-sm text-neutral-700">
Pengajuan verifikasi-mu masih ditinjau admin. Sementara menunggu, kamu
masih bisa membuat <strong>trip gratis</strong> (harga 0).
</p>
</div>
);
}
const isRejected = status === "REJECTED";
return (
<div className="mb-5 rounded-2xl border border-amber-200 bg-amber-50 p-4 sm:p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex-1">
<p className="text-sm font-bold text-amber-800">
{isRejected ? "Verifikasi ditolak" : "Belum terverifikasi"}
</p>
<p className="mt-1 text-sm text-neutral-700">
{isRejected
? "Pengajuan sebelumnya ditolak. Untuk membuat trip berbayar, perbaiki data dan ajukan ulang."
: "Untuk membuat trip berbayar, akun kamu perlu diverifikasi (KTP, foto memegang kertas SETRIP, & rekening). Trip gratis tidak butuh verifikasi."}
</p>
</div>
<Link
href="/verify"
className="inline-flex shrink-0 items-center justify-center rounded-xl bg-amber-600 px-4 py-2 text-sm font-bold text-white shadow-sm transition-colors hover:bg-amber-700 sm:px-5"
>
{isRejected ? "Ajukan Ulang" : "Verifikasi Sekarang"}
</Link>
</div>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { Navbar } from "@/components/shared/navbar";
import { ProfileNudgeBanner } from "@/components/shared/profile-nudge-banner";
import { Footer } from "@/components/shared/footer";
/**
* Layout user-facing (semua halaman publik + dashboard organizer/peserta).
* Berisi navbar global, profile-nudge banner, dan footer.
*
* Tidak berlaku untuk halaman admin — admin punya layout sendiri di
* app/admin/layout.tsx dengan sidebar khusus.
*/
export default function PublicLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<Navbar />
<ProfileNudgeBanner />
<main className="flex-1">{children}</main>
<Footer />
</>
);
}
+13
View File
@@ -0,0 +1,13 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Masuk",
description:
"Masuk ke akun SeTrip untuk gabung open trip & aktivitas bareng dan kelola perjalananmu.",
alternates: { canonical: "/login" },
robots: { index: false, follow: true },
};
export default function LoginLayout({ children }: { children: React.ReactNode }) {
return children;
}
+158
View File
@@ -0,0 +1,158 @@
"use client";
import { useState, Suspense } from "react";
import { signIn, getSession } from "next-auth/react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { GoogleSignInButton } from "@/components/shared/google-sign-in-button";
function safeInternalPath(raw: string | null): string {
if (!raw || !raw.startsWith("/") || raw.startsWith("//")) return "/";
return raw;
}
function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError("");
setLoading(true);
const formData = new FormData(e.currentTarget);
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const result = await signIn("credentials", {
email,
password,
redirect: false,
});
setLoading(false);
if (result?.error) {
setError(result.error);
} else {
const rawCallback = searchParams.get("callbackUrl");
let next = safeInternalPath(rawCallback);
// Tanpa callbackUrl eksplisit, arahkan admin ke dashboard /admin.
if (!rawCallback) {
const session = await getSession();
if (session?.user?.isAdmin) next = "/admin";
}
router.push(next);
router.refresh();
}
}
return (
<div className="relative flex min-h-[calc(100vh-4rem)] items-center justify-center px-4 py-8 sm:py-12">
{/* Background image */}
<Image
src="/images/seed/gunung-login.jpg"
alt=""
fill
className="object-cover"
priority
/>
{/* Dark overlay */}
<div className="absolute inset-0 bg-neutral-900/60 backdrop-blur-[2px]" />
<div className="relative z-10 w-full max-w-sm">
{/* Header */}
<div className="mb-6 text-center sm:mb-8">
<Link href="/" className="mb-3 inline-flex items-center gap-2">
<Image
src="/images/SeTrip.png"
alt="SeTrip"
width={40}
height={40}
className="h-10 w-10 object-contain"
/>
<span className="text-2xl font-bold text-white">
Se<span className="text-primary-400">Trip</span>
</span>
</Link>
<p className="text-sm text-neutral-300">
Login dan mulai petualanganmu bareng
</p>
</div>
{/* Card */}
<div className="rounded-2xl border border-white/10 bg-white/95 p-6 shadow-2xl backdrop-blur-sm">
{error && (
<div className="mb-4 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600">
{error}
</div>
)}
<GoogleSignInButton callbackUrl={safeInternalPath(searchParams.get("callbackUrl"))} />
<div className="my-4 flex items-center gap-3 text-xs text-neutral-400">
<span className="h-px flex-1 bg-neutral-200" />
<span>atau</span>
<span className="h-px flex-1 bg-neutral-200" />
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="email" className="mb-1.5 block text-sm font-semibold text-neutral-700">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 px-4 py-2.5 text-sm text-neutral-800 transition-colors placeholder:text-neutral-400 focus:bg-white"
placeholder="email@example.com"
/>
</div>
<div>
<label htmlFor="password" className="mb-1.5 block text-sm font-semibold text-neutral-700">
Password
</label>
<input
id="password"
name="password"
type="password"
required
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 px-4 py-2.5 text-sm text-neutral-800 transition-colors placeholder:text-neutral-400 focus:bg-white"
placeholder="Minimal 6 karakter"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-xl bg-primary-600 py-2.5 text-sm font-bold text-white shadow-lg shadow-primary-600/20 transition-colors hover:bg-primary-700 disabled:opacity-50"
>
{loading ? "Loading..." : "Login"}
</button>
</form>
</div>
<p className="mt-5 text-center text-sm text-neutral-300">
Belum punya akun?{" "}
<Link href="/register" className="font-semibold text-primary-400 hover:text-primary-300">
Daftar sekarang
</Link>
</p>
</div>
</div>
);
}
export default function LoginPage() {
return (
<Suspense fallback={null}>
<LoginForm />
</Suspense>
);
}
+393
View File
@@ -0,0 +1,393 @@
import type { Metadata } from "next";
import Link from "next/link";
import Image from "next/image";
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 { siteConfig, siteUrl, absoluteUrl } from "@/lib/site";
import { ACTIVITY_CATEGORIES, categoryMeta } from "@/lib/activity-category";
type OpenTrip = Awaited<ReturnType<typeof tripService.getOpenTrips>>[number];
function mapParticipants(trip: OpenTrip) {
return trip.participants.map((p) => ({
id: p.id,
name: p.user.name,
image: p.user.image,
interests: p.user.profile?.interests ?? [],
}));
}
export const metadata: Metadata = {
title: "Cari Teman Trip & Aktivitas — Pergi Bareng, Bukan Sendiri",
description: `${siteConfig.slogan} ${siteConfig.description}`,
alternates: { canonical: "/" },
openGraph: {
title: `${siteConfig.name} — Cari Teman Trip & Aktivitas, Gabung Bareng`,
description: siteConfig.slogan,
url: "/",
},
};
export default async function HomePage() {
const session = await getServerSession(authOptions);
const [trips, viewerProfile] = await Promise.all([
tripService.getOpenTrips(),
session?.user?.id
? profileRepo.findByUserId(session.user.id)
: Promise.resolve(null),
]);
const viewerInterests = viewerProfile?.interests ?? [];
const now = new Date();
const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
const upcomingTrips = trips
.filter((t) => new Date(t.date) <= nextWeek)
.slice(0, 3);
const upcomingIds = new Set(upcomingTrips.map((t) => t.id));
const latestTrips = trips
.filter((t) => !upcomingIds.has(t.id))
.slice(0, 6);
const shownIds = new Set([...upcomingIds, ...latestTrips.map((t) => t.id)]);
// Section sosial: trip yang paling ramai joiner-nya (social proof, bukan price proof).
const buzzingTrips = trips
.filter((t) => !shownIds.has(t.id) && t._count.participants > 0)
.sort((a, b) => b._count.participants - a._count.participants)
.slice(0, 3);
const orgJsonLd = {
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization",
"@id": `${siteUrl}/#organization`,
name: siteConfig.name,
url: siteUrl,
logo: absoluteUrl("/images/SeTrip.png"),
slogan: siteConfig.slogan,
description: siteConfig.description,
},
{
"@type": "WebSite",
"@id": `${siteUrl}/#website`,
url: siteUrl,
name: siteConfig.name,
description: siteConfig.description,
publisher: { "@id": `${siteUrl}/#organization` },
inLanguage: "id-ID",
potentialAction: {
"@type": "SearchAction",
target: `${siteUrl}/trips?q={search_term_string}`,
"query-input": "required name=search_term_string",
},
},
],
};
return (
<div className="relative min-h-screen bg-neutral-50">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(orgJsonLd) }}
/>
{/* ========== HERO ========== */}
<section className="relative overflow-hidden bg-neutral-900">
{/* Logo background full */}
<Image
src="/images/SeTrip.png"
alt=""
fill
className="object-cover opacity-10 brightness-150"
priority
/>
<div className="absolute inset-0 bg-linear-to-br from-primary-900/85 via-neutral-900/75 to-secondary-900/80" />
<div className="relative mx-auto max-w-4xl px-4 pb-10 pt-8 text-center sm:pb-14 sm:pt-12 lg:pb-16 lg:pt-14">
{/* Brand badge */}
<div className="mb-4 inline-flex items-center gap-1.5 rounded-full border border-primary-400/30 bg-primary-600/20 px-3 py-1 sm:mb-6 sm:gap-2 sm:px-4 sm:py-1.5">
<span className="text-xs sm:text-sm">🤝</span>
<span className="text-xs font-medium text-primary-300 sm:text-sm">
Cari teman trip & aktivitas
</span>
</div>
<h1 className="mb-3 text-3xl font-extrabold leading-tight tracking-tight text-white sm:mb-4 sm:text-4xl lg:text-5xl">
Pergi bareng,{" "}
<span className="text-primary-400">bukan sendiri</span>
</h1>
<p className="mx-auto mb-2 max-w-sm text-base font-medium text-neutral-300 sm:mb-3 sm:max-w-lg sm:text-lg">
Lagi pengen jalan tapi gak punya teman?{" "}
<span className="text-primary-400">SeTrip</span> tempatnya.
</p>
<p className="mx-auto mb-6 max-w-xs text-sm text-neutral-400 sm:mb-8 sm:max-w-md sm:text-base">
Gabung open trip hiking, camping, snorkeling, sampai city trip.
Ketemu orang baru, dari stranger jadi travel buddies. Grup kecil,
organizer terverifikasi.
</p>
<Link
href="/trips"
className="inline-block rounded-xl bg-primary-600 px-6 py-2.5 text-sm font-semibold text-white shadow-lg shadow-primary-600/25 transition-all hover:bg-primary-500 hover:scale-105 active:scale-95 sm:px-8 sm:py-3 sm:text-base"
>
Cari Teman Trip
</Link>
{/* Stats */}
<div className="mt-8 flex justify-center gap-6 sm:mt-10 sm:gap-10 lg:gap-12">
<div>
<p className="text-xl font-bold text-primary-400 sm:text-2xl">
{trips.length}
</p>
<p className="text-[11px] text-neutral-400 sm:text-xs">Trip Tersedia</p>
</div>
<div className="h-8 w-px bg-neutral-700 sm:h-10" />
<div>
<p className="text-xl font-bold text-white sm:text-2xl">100%</p>
<p className="text-[11px] text-neutral-400 sm:text-xs">Seru</p>
</div>
</div>
</div>
</section>
{/* ========== CONTENT ========== */}
<div className="mx-auto max-w-6xl px-4 py-6 space-y-8 sm:py-8 sm:space-y-10 lg:py-10 lg:space-y-12">
{/* Jelajah per kategori */}
<section>
<div className="mb-4 flex items-center gap-3 sm:mb-5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-secondary-100 text-base sm:h-9 sm:w-9 sm:text-lg">
</div>
<div>
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
Jelajah per Kategori
</h2>
<p className="text-[11px] text-neutral-500 sm:text-xs">
Hiking, diving, konser, sampai retreat
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
{ACTIVITY_CATEGORIES.map((c) => {
const m = categoryMeta(c);
return (
<Link
key={c}
href={`/trips?category=${c}`}
className="inline-flex items-center gap-1.5 rounded-full border border-neutral-200 bg-white px-3 py-1.5 text-xs font-medium text-neutral-700 transition-colors hover:border-primary-300 hover:bg-primary-50 hover:text-primary-700"
>
<span>{m.icon}</span>
<span>{m.label}</span>
</Link>
);
})}
</div>
</section>
{/* Trip Terdekat */}
{upcomingTrips.length > 0 && (
<section>
<div className="mb-4 flex items-center gap-3 sm:mb-5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-100 text-base sm:h-9 sm:w-9 sm:text-lg">
🔥
</div>
<div>
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
Trip Terdekat
</h2>
<p className="text-[11px] text-neutral-500 sm:text-xs">
Berangkat dalam 7 hari ke depan
</p>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{upcomingTrips.slice(0, 3).map((trip, i) => (
<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={mapParticipants(trip)}
viewerInterests={viewerInterests}
priority={i === 0}
/>
))}
</div>
</section>
)}
{/* Open Trip */}
<section>
<div className="mb-4 flex items-center justify-between sm:mb-5">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-secondary-100 text-base sm:h-9 sm:w-9 sm:text-lg">
🏔
</div>
<div>
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
Open Trip
</h2>
<p className="hidden text-xs text-neutral-500 sm:block">
Pilih trip, ketemu teman baru
</p>
</div>
</div>
<Link
href="/trips"
className="rounded-lg bg-secondary-50 px-2.5 py-1 text-xs font-medium text-secondary-600 hover:bg-secondary-100 sm:px-3 sm:py-1.5 sm:text-sm"
>
Lihat semua
</Link>
</div>
{latestTrips.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">
🏕
</div>
<p className="mb-1 text-base font-bold text-neutral-800 sm:text-lg">
Belum ada trip tersedia
</p>
<p className="mb-5 text-sm text-neutral-500 sm:mb-6">
Jadilah yang pertama buat open trip di sini!
</p>
<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">
{latestTrips.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={mapParticipants(trip)}
viewerInterests={viewerInterests}
/>
))}
</div>
)}
</section>
{/* Lagi Ramai — social proof, bukan price proof */}
{buzzingTrips.length > 0 && (
<section>
<div className="mb-4 flex items-center gap-3 sm:mb-5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-100 text-base sm:h-9 sm:w-9 sm:text-lg">
🤝
</div>
<div>
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
Lagi Ramai
</h2>
<p className="text-[11px] text-neutral-500 sm:text-xs">
Banyak yang sudah gabung kamu nggak bakal jalan sendirian
</p>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{buzzingTrips.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={mapParticipants(trip)}
viewerInterests={viewerInterests}
/>
))}
</div>
</section>
)}
{/* CTA Bottom */}
<section className="overflow-hidden rounded-2xl bg-neutral-800 p-6 text-center sm:p-8 lg:p-12">
<h2 className="mb-2 text-xl font-bold text-white sm:text-2xl">
Dari stranger, jadi travel buddies.
</h2>
<p className="mx-auto mb-5 max-w-xs text-sm text-neutral-400 sm:mb-6 sm:max-w-sm sm:text-base">
Buat trip dan kumpulin grup sendiri, atau gabung trip yang sudah
jalan. Kenalan baru menunggu di puncak.
</p>
<div className="flex flex-col justify-center gap-2 sm:flex-row sm:gap-3">
<Link
href="/create-trip"
className="rounded-xl bg-primary-600 px-6 py-2.5 text-sm font-semibold text-white shadow-lg shadow-primary-600/25 hover:bg-primary-500"
>
Buat Trip
</Link>
<Link
href="/trips"
className="rounded-xl border border-neutral-600 px-6 py-2.5 text-sm font-semibold text-neutral-300 hover:border-neutral-500 hover:text-white"
>
Cari Teman Trip
</Link>
</div>
</section>
</div>
{/* ========== FAB ========== */}
<Link
href="/create-trip"
className="fixed bottom-4 right-4 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-primary-600 text-xl font-bold text-white shadow-xl shadow-primary-600/30 transition-all hover:scale-110 hover:bg-primary-500 active:scale-95 sm:bottom-6 sm:right-6 sm:h-14 sm:w-14 sm:text-2xl"
title="Buat Trip"
>
+
</Link>
</div>
);
}
+104
View File
@@ -0,0 +1,104 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { profileService } from "@/server/services/profile.service";
import { UserCard } from "@/features/profile/components/user-card";
import { PeopleFilter } from "@/features/profile/components/people-filter";
import { isVibe, vibeLabel } from "@/lib/vibe";
import { siteConfig } from "@/lib/site";
interface PeoplePageProps {
searchParams: Promise<{
city?: string;
interest?: string;
vibe?: string;
}>;
}
export async function generateMetadata({
searchParams,
}: PeoplePageProps): Promise<Metadata> {
const { city, interest, vibe: vibeParam } = await searchParams;
const vibe = isVibe(vibeParam) ? vibeParam : undefined;
const parts: string[] = [];
if (vibe) parts.push(`Vibe ${vibeLabel(vibe).toLowerCase()}`);
if (city) parts.push(`di ${city}`);
if (interest) parts.push(`#${interest.toLowerCase()}`);
const title = parts.length
? `Cari Teman ${parts.join(" ")}`
: "Cari Teman Aktivitas — Profil Anggota";
const description = `Telusuri profil anggota ${siteConfig.name} berdasarkan minat, kota, dan vibe. Temukan calon teman trip dengan ritme yang cocok sebelum gabung bareng.`;
return {
title,
description,
alternates: { canonical: "/people" },
openGraph: { title, description, url: "/people" },
};
}
export default async function PeoplePage({ searchParams }: PeoplePageProps) {
const params = await searchParams;
const vibe = isVibe(params.vibe) ? params.vibe : undefined;
const filters = {
city: params.city?.trim() || undefined,
interest: params.interest?.trim().toLowerCase() || undefined,
vibe,
};
const hasFilters = Boolean(filters.city || filters.interest || filters.vibe);
const people = await profileService.findPeople(filters);
return (
<div className="mx-auto max-w-6xl px-4 py-6 sm:py-8">
<div className="mb-5 flex flex-col gap-2 sm:mb-6">
<h1 className="text-xl font-bold text-neutral-800 sm:text-2xl">
Cari Teman Aktivitas
</h1>
<p className="text-sm text-neutral-500">
{hasFilters
? `${people.length} orang ditemukan dengan filter di atas`
: `${people.length} anggota dengan profil sosial — kenali dulu sebelum gabung trip`}
</p>
</div>
<div className="mb-6">
<Suspense fallback={null}>
<PeopleFilter />
</Suspense>
</div>
{people.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">
🔍
</div>
<p className="mb-1 text-base font-bold text-neutral-800 sm:text-lg">
{hasFilters
? "Belum ada anggota yang cocok"
: "Belum ada anggota dengan profil terisi"}
</p>
<p className="text-sm text-neutral-500">
{hasFilters
? "Coba longgarkan filter — kota, minat, atau vibe."
: "Setelah anggota lain mengisi profil, mereka akan muncul di sini."}
</p>
</div>
) : (
<ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{people.map((u) => (
<li key={u.id}>
<UserCard
id={u.id}
name={u.name}
image={u.image}
isVerifiedOrganizer={
u.organizerVerification?.status === "APPROVED"
}
profile={u.profile}
/>
</li>
))}
</ul>
)}
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Kebijakan Privasi",
description:
"Kebijakan Privasi SeTrip. Pelajari bagaimana kami mengumpulkan, menggunakan, dan melindungi data Anda.",
alternates: { canonical: "/privacy" },
};
export default function PrivacyLayout({ children }: { children: React.ReactNode }) {
return children;
}
+235
View File
@@ -0,0 +1,235 @@
import Link from "next/link";
export default function PrivacyPage() {
return (
<div className="mx-auto max-w-3xl px-4 py-8 sm:py-12">
<article className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm sm:p-10">
<header className="mb-8 border-b border-neutral-200 pb-6">
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
🔒 Kebijakan Privasi SeTrip
</h1>
<p className="mt-2 text-sm text-neutral-500">
Terakhir diperbarui: 2026-04-27
</p>
<p className="mt-4 text-sm leading-relaxed text-neutral-700">
SeTrip menghargai privasi Anda. Kebijakan Privasi ini menjelaskan
bagaimana kami mengumpulkan, menggunakan, dan melindungi informasi
Anda saat menggunakan platform SeTrip. Dengan menggunakan SeTrip,
Anda menyetujui praktik yang dijelaskan dalam Kebijakan Privasi ini.
</p>
</header>
<div className="space-y-8 text-sm leading-relaxed text-neutral-700">
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">
1. Informasi yang Kami Kumpulkan
</h2>
<h3 className="mb-2 mt-4 font-semibold text-neutral-800">a. Informasi Akun</h3>
<ul className="ml-5 list-disc space-y-1.5">
<li>Nama</li>
<li>Email</li>
<li>Nomor telepon</li>
<li>Password (disimpan dalam bentuk terenkripsi)</li>
</ul>
<h3 className="mb-2 mt-4 font-semibold text-neutral-800">b. Informasi Profil</h3>
<ul className="ml-5 list-disc space-y-1.5">
<li>Foto profil</li>
<li>Deskripsi diri</li>
<li>Riwayat trip</li>
</ul>
<h3 className="mb-2 mt-4 font-semibold text-neutral-800">c. Informasi Transaksi</h3>
<ul className="ml-5 list-disc space-y-1.5">
<li>Data booking trip</li>
<li>Status pembayaran</li>
<li>Riwayat aktivitas</li>
</ul>
<h3 className="mb-2 mt-4 font-semibold text-neutral-800">d. Informasi Teknis</h3>
<ul className="ml-5 list-disc space-y-1.5">
<li>Alamat IP</li>
<li>Browser</li>
<li>Perangkat yang digunakan</li>
<li>Log aktivitas</li>
</ul>
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">
2. Cara Kami Menggunakan Informasi
</h2>
<p className="mb-3">Kami menggunakan informasi Anda untuk:</p>
<ul className="ml-5 list-disc space-y-1.5">
<li>Membuat dan mengelola akun</li>
<li>Menghubungkan pengguna dengan organizer</li>
<li>Memproses booking dan aktivitas trip</li>
<li>Meningkatkan layanan dan pengalaman pengguna</li>
<li>Mengirim notifikasi terkait aktivitas</li>
<li>Mencegah penipuan dan penyalahgunaan</li>
</ul>
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">3. Pembagian Informasi</h2>
<p className="mb-3">
Kami tidak menjual data pribadi Anda. Namun, kami dapat membagikan
informasi dalam kondisi berikut:
</p>
<h3 className="mb-2 mt-4 font-semibold text-neutral-800">a. Dengan Organizer</h3>
<ul className="ml-5 list-disc space-y-1.5">
<li>
Informasi dasar seperti nama dan kontak dapat dibagikan kepada
organizer untuk keperluan trip
</li>
</ul>
<h3 className="mb-2 mt-4 font-semibold text-neutral-800">
b. Dengan Penyedia Layanan
</h3>
<ul className="ml-5 list-disc space-y-1.5">
<li>Untuk kebutuhan teknis (hosting, analytics, dll)</li>
</ul>
<h3 className="mb-2 mt-4 font-semibold text-neutral-800">c. Kewajiban Hukum</h3>
<ul className="ml-5 list-disc space-y-1.5">
<li>Jika diminta oleh hukum atau otoritas berwenang</li>
</ul>
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">4. Keamanan Data</h2>
<p className="mb-3">Kami berusaha melindungi data Anda dengan:</p>
<ul className="ml-5 list-disc space-y-1.5">
<li>Enkripsi password</li>
<li>Pembatasan akses data</li>
<li>Sistem keamanan standar industri</li>
</ul>
<p className="mt-3">Namun, tidak ada sistem yang 100% aman.</p>
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">5. Penyimpanan Data</h2>
<p className="mb-3">Kami menyimpan data Anda selama:</p>
<ul className="ml-5 list-disc space-y-1.5">
<li>Akun Anda aktif</li>
<li>Dibutuhkan untuk keperluan layanan</li>
</ul>
<p className="mt-3">
Data dapat dihapus atas permintaan pengguna, kecuali diwajibkan
oleh hukum untuk disimpan.
</p>
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">6. Hak Pengguna</h2>
<p className="mb-3">Anda memiliki hak untuk:</p>
<ul className="ml-5 list-disc space-y-1.5">
<li>Mengakses data pribadi Anda</li>
<li>Memperbarui informasi</li>
<li>Menghapus akun</li>
<li>Menarik persetujuan</li>
</ul>
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">7. Cookie &amp; Tracking</h2>
<p className="mb-3">SeTrip dapat menggunakan:</p>
<ul className="ml-5 list-disc space-y-1.5">
<li>Cookie</li>
<li>Teknologi pelacakan sederhana</li>
</ul>
<p className="mt-3 mb-2">Untuk:</p>
<ul className="ml-5 list-disc space-y-1.5">
<li>Menyimpan sesi login</li>
<li>Meningkatkan pengalaman pengguna</li>
</ul>
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">
8. Layanan Pihak Ketiga
</h2>
<p className="mb-3">SeTrip dapat menggunakan layanan pihak ketiga seperti:</p>
<ul className="ml-5 list-disc space-y-1.5">
<li>Hosting</li>
<li>Analytics</li>
<li>Payment gateway (di masa depan)</li>
</ul>
<p className="mt-3">
Kami tidak bertanggung jawab atas kebijakan privasi pihak ketiga
tersebut.
</p>
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">
9. Perlindungan terhadap Penipuan
</h2>
<p className="mb-3">Kami dapat menggunakan data untuk:</p>
<ul className="ml-5 list-disc space-y-1.5">
<li>Mendeteksi aktivitas mencurigakan</li>
<li>Mencegah penipuan</li>
<li>Melindungi pengguna lain</li>
</ul>
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">
10. Perubahan Kebijakan Privasi
</h2>
<p className="mb-3">
SeTrip dapat memperbarui Kebijakan Privasi ini sewaktu-waktu.
Pengguna disarankan untuk:
</p>
<ul className="ml-5 list-disc space-y-1.5">
<li>Membaca secara berkala</li>
<li>Memahami perubahan yang berlaku</li>
</ul>
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">11. Kontak</h2>
<p>
Jika Anda memiliki pertanyaan mengenai Kebijakan Privasi ini,
silakan hubungi:{" "}
<a
href="mailto:support@setrip.com"
className="font-semibold text-primary-600 hover:text-primary-700"
>
support@setrip.com
</a>
</p>
</section>
<section className="rounded-xl bg-neutral-50 p-5">
<h2 className="mb-2 text-lg font-bold text-neutral-900"> Persetujuan</h2>
<p className="mb-2">
Dengan menggunakan SeTrip, Anda menyatakan bahwa:
</p>
<ul className="ml-5 list-disc space-y-1.5">
<li>Telah membaca</li>
<li>Memahami</li>
<li>Menyetujui Kebijakan Privasi ini</li>
</ul>
</section>
</div>
<footer className="mt-10 border-t border-neutral-200 pt-6 text-sm">
<p className="text-neutral-500">
Lihat juga{" "}
<Link
href="/terms"
className="font-semibold text-primary-600 hover:text-primary-700"
>
Syarat &amp; Ketentuan
</Link>
.
</p>
</footer>
</article>
</div>
);
}
+275
View File
@@ -0,0 +1,275 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { profileService } from "@/server/services/profile.service";
import { payoutRepo } from "@/server/repositories/payout.repo";
import { TripCard } from "@/features/trip/components/trip-card";
import { ProfileTripRow } from "@/features/profile/components/profile-trip-row";
import { ProfileEditor } from "@/features/profile/components/profile-editor";
import { EarningsSection } from "@/features/payout/components/earnings-section";
export const metadata: Metadata = {
title: "Profil Saya",
robots: { index: false, follow: false },
};
export default async function ProfilePage() {
const session = await getServerSession(authOptions);
if (!session?.user) {
redirect("/login?callbackUrl=/profile");
}
const [data, ownProfile, payouts] = await Promise.all([
profileService.getProfileDashboard(session.user.id),
profileService.getOwnProfile(session.user.id),
payoutRepo.listForOrganizer(session.user.id),
]);
const {
user,
isVerifiedOrganizer,
organizedTrips,
activeJoined,
cancelledJoined,
reviewable,
} = data;
const memberSince = new Intl.DateTimeFormat("id-ID", {
month: "long",
year: "numeric",
}).format(user.createdAt);
return (
<div className="mx-auto max-w-4xl px-4 py-6 sm:py-8">
<div className="mb-6 flex flex-col gap-4 rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:flex-row sm:items-center sm:justify-between sm:p-6">
<div className="flex items-center gap-4">
{user.image ? (
<Image
src={user.image}
alt=""
width={72}
height={72}
className="h-18 w-18 rounded-full object-cover"
/>
) : (
<div className="flex h-18 w-18 shrink-0 items-center justify-center rounded-full bg-primary-600 text-2xl font-bold text-white">
{user.name.charAt(0).toUpperCase()}
</div>
)}
<div className="min-w-0">
<h1 className="text-xl font-bold text-neutral-800 sm:text-2xl">
{user.name}
</h1>
<p className="mt-0.5 truncate text-sm text-neutral-500">
{user.email}
</p>
<p className="mt-1 text-xs text-neutral-400">
Anggota sejak {memberSince}
</p>
<p className="mt-2 text-xs text-neutral-500">
Di sini kamu bisa lihat trip yang kamu buat sebagai{" "}
<span className="font-semibold text-primary-700">organizer</span>,
trip yang kamu{" "}
<span className="font-semibold text-secondary-700">ikuti</span>{" "}
sebagai peserta, dan{" "}
<span className="font-semibold text-amber-700">ulasan</span> untuk
trip yang sudah selesai (lewat halaman trip).
</p>
</div>
</div>
<Link
href="/create-trip"
className="shrink-0 rounded-xl bg-primary-600 px-4 py-2.5 text-center text-sm font-semibold text-white shadow-md hover:bg-primary-700"
>
+ Buat trip
</Link>
</div>
{/* Pendapatan dari peserta (escrow payout) */}
<EarningsSection payouts={payouts} />
{/* Profil sosial publik */}
<div className="mb-6">
<ProfileEditor
userId={user.id}
initial={
ownProfile
? {
bio: ownProfile.bio,
city: ownProfile.city,
interests: ownProfile.interests,
instagram: ownProfile.instagram,
vibe: ownProfile.vibe,
}
: null
}
/>
</div>
{/* Trip selesai — akses ulasan (trip ini tidak muncul di Open Trip) */}
{reviewable.length > 0 && (
<section className="mb-8 rounded-2xl border border-amber-200 bg-amber-50/60 p-4 sm:p-5">
<h2 className="mb-1 text-base font-bold text-amber-900 sm:text-lg">
Trip selesai & ulasan
</h2>
<p className="mb-4 text-xs text-amber-900/80 sm:text-sm">
Trip yang sudah lewat tidak tampil di daftar Open Trip. Buka trip di
bawah ini lalu scroll ke bagian ulasan di halaman detail untuk
memberi atau mengubah rating.
</p>
<ul className="space-y-2">
{reviewable.map((p) => {
const t = p.trip;
const hasReview = t.reviews.length > 0;
return (
<li key={p.id}>
<ProfileTripRow
href={`/trips/${t.id}`}
title={t.title}
destination={t.destination}
date={t.date}
endDate={t.endDate}
rightSlot={
<span
className={
hasReview
? "text-secondary-700"
: "font-bold text-amber-800"
}
>
{hasReview ? "Ubah ulasan →" : "Beri ulasan →"}
</span>
}
/>
</li>
);
})}
</ul>
</section>
)}
<div className="grid gap-8 lg:grid-cols-2">
<section>
<h2 className="mb-3 text-base font-bold text-neutral-800 sm:text-lg">
Trip yang kamu buat
<span className="ml-2 text-sm font-normal text-neutral-400">
({organizedTrips.length})
</span>
</h2>
{organizedTrips.length === 0 ? (
<p className="rounded-xl border border-dashed border-neutral-200 bg-neutral-50 px-4 py-6 text-center text-sm text-neutral-500">
Belum ada trip.{" "}
<Link
href="/create-trip"
className="font-semibold text-primary-600"
>
Buat trip pertama
</Link>
</p>
) : (
<div className="space-y-4">
{organizedTrips.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={`${user.name} (Kamu)`}
status={trip.status}
coverImage={trip.images[0]?.url}
isVerifiedOrganizer={isVerifiedOrganizer}
/>
))}
</div>
)}
</section>
<section>
<h2 className="mb-3 text-base font-bold text-neutral-800 sm:text-lg">
Trip yang kamu ikuti
<span className="ml-2 text-sm font-normal text-neutral-400">
({activeJoined.length})
</span>
</h2>
{activeJoined.length === 0 ? (
<p className="rounded-xl border border-dashed border-neutral-200 bg-neutral-50 px-4 py-6 text-center text-sm text-neutral-500">
Belum join trip.{" "}
<Link href="/trips" className="font-semibold text-primary-600">
Cari open trip
</Link>
</p>
) : (
<ul className="space-y-2">
{activeJoined.map((p) => {
const t = p.trip;
return (
<li key={p.id}>
<ProfileTripRow
href={`/trips/${t.id}`}
title={t.title}
destination={t.destination}
date={t.date}
endDate={t.endDate}
rightSlot={
<span
className={
p.status === "PENDING"
? "font-medium text-amber-700"
: "text-neutral-400"
}
>
{p.status === "CONFIRMED"
? "Terkonfirmasi"
: p.status === "PENDING"
? "Menunggu organizer"
: p.status}
</span>
}
/>
</li>
);
})}
</ul>
)}
{cancelledJoined.length > 0 && (
<div className="mt-6">
<h3 className="mb-2 text-sm font-semibold text-neutral-500">
Riwayat batal ({cancelledJoined.length})
</h3>
<ul className="space-y-2 opacity-80">
{cancelledJoined.map((p) => {
const t = p.trip;
return (
<li key={p.id}>
<ProfileTripRow
href={`/trips/${t.id}`}
title={t.title}
destination={t.destination}
date={t.date}
endDate={t.endDate}
rightSlot={
<span className="text-red-500/90">Dibatalkan</span>
}
/>
</li>
);
})}
</ul>
</div>
)}
</section>
</div>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Daftar Akun",
description:
"Buat akun SeTrip gratis. Cari open trip & aktivitas bareng, gabung bareng, dan mulai petualanganmu.",
alternates: { canonical: "/register" },
};
export default function RegisterLayout({ children }: { children: React.ReactNode }) {
return children;
}
+202
View File
@@ -0,0 +1,202 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { signIn } from "next-auth/react";
import Link from "next/link";
import Image from "next/image";
import { registerAction } from "@/features/auth/actions";
import { GoogleSignInButton } from "@/components/shared/google-sign-in-button";
export default function RegisterPage() {
const router = useRouter();
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError("");
setLoading(true);
const formData = new FormData(e.currentTarget);
const result = await registerAction(formData);
if (result.error) {
setError(result.error);
setLoading(false);
return;
}
const loginResult = await signIn("credentials", {
email: formData.get("email") as string,
password: formData.get("password") as string,
redirect: false,
});
setLoading(false);
if (loginResult?.error) {
router.push("/login");
} else {
router.push("/");
router.refresh();
}
}
return (
<div className="relative flex min-h-[calc(100vh-4rem)] items-center justify-center px-4 py-8 sm:py-12">
{/* Background image */}
<Image
src="/images/seed/gunung-register.jpg"
alt=""
fill
className="object-cover"
priority
/>
{/* Dark overlay */}
<div className="absolute inset-0 bg-neutral-900/60 backdrop-blur-[2px]" />
<div className="relative z-10 w-full max-w-sm">
{/* Header */}
<div className="mb-6 text-center sm:mb-8">
<Link href="/" className="mb-3 inline-flex items-center gap-2">
<Image
src="/images/SeTrip.png"
alt="SeTrip"
width={40}
height={40}
className="h-10 w-10 object-contain"
/>
<span className="text-2xl font-bold text-white">
Se<span className="text-primary-400">Trip</span>
</span>
</Link>
<p className="text-sm text-neutral-300">
Daftar dan mulai cari teman trip & aktivitas
</p>
</div>
{/* Card */}
<div className="rounded-2xl border border-white/10 bg-white/95 p-6 shadow-2xl backdrop-blur-sm">
{error && (
<div className="mb-4 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600">
{error}
</div>
)}
<GoogleSignInButton label="Daftar dengan Google" />
<div className="my-4 flex items-center gap-3 text-xs text-neutral-400">
<span className="h-px flex-1 bg-neutral-200" />
<span>atau</span>
<span className="h-px flex-1 bg-neutral-200" />
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="name" className="mb-1.5 block text-sm font-semibold text-neutral-700">
Nama Lengkap
</label>
<input
id="name"
name="name"
type="text"
required
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 px-4 py-2.5 text-sm text-neutral-800 transition-colors placeholder:text-neutral-400 focus:bg-white"
placeholder="Nama kamu"
/>
</div>
<div>
<label htmlFor="email" className="mb-1.5 block text-sm font-semibold text-neutral-700">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 px-4 py-2.5 text-sm text-neutral-800 transition-colors placeholder:text-neutral-400 focus:bg-white"
placeholder="email@example.com"
/>
</div>
<div>
<label htmlFor="password" className="mb-1.5 block text-sm font-semibold text-neutral-700">
Password
</label>
<input
id="password"
name="password"
type="password"
required
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 px-4 py-2.5 text-sm text-neutral-800 transition-colors placeholder:text-neutral-400 focus:bg-white"
placeholder="Minimal 6 karakter"
/>
</div>
<div>
<label htmlFor="confirmPassword" className="mb-1.5 block text-sm font-semibold text-neutral-700">
Konfirmasi Password
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
required
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 px-4 py-2.5 text-sm text-neutral-800 transition-colors placeholder:text-neutral-400 focus:bg-white"
placeholder="Ulangi password"
/>
</div>
<label className="flex items-start gap-2.5 text-sm text-neutral-700">
<input
id="acceptedTermsAndPrivacy"
name="acceptedTermsAndPrivacy"
type="checkbox"
required
className="mt-0.5 h-4 w-4 shrink-0 rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
/>
<span>
Saya menyetujui{" "}
<Link
href="/terms"
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-primary-600 hover:text-primary-700"
>
Syarat &amp; Ketentuan
</Link>{" "}
dan{" "}
<Link
href="/privacy"
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-primary-600 hover:text-primary-700"
>
Kebijakan Privasi
</Link>
.
</span>
</label>
<button
type="submit"
disabled={loading}
className="w-full rounded-xl bg-primary-600 py-2.5 text-sm font-bold text-white shadow-lg shadow-primary-600/20 transition-colors hover:bg-primary-700 disabled:opacity-50"
>
{loading ? "Loading..." : "Daftar"}
</button>
</form>
</div>
<p className="mt-5 text-center text-sm text-neutral-300">
Sudah punya akun?{" "}
<Link href="/login" className="font-semibold text-primary-400 hover:text-primary-300">
Login
</Link>
</p>
</div>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Syarat & Ketentuan",
description:
"Syarat & Ketentuan penggunaan platform SeTrip. Baca sebelum menggunakan layanan SeTrip.",
alternates: { canonical: "/terms" },
};
export default function TermsLayout({ children }: { children: React.ReactNode }) {
return children;
}
+292
View File
@@ -0,0 +1,292 @@
import Link from "next/link";
export default function TermsPage() {
return (
<div className="mx-auto max-w-3xl px-4 py-8 sm:py-12">
<article className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm sm:p-10">
<header className="mb-8 border-b border-neutral-200 pb-6">
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
📜 Syarat &amp; Ketentuan SeTrip
</h1>
<p className="mt-2 text-sm text-neutral-500">
Terakhir diperbarui: 2026-04-27
</p>
<p className="mt-4 text-sm leading-relaxed text-neutral-700">
Selamat datang di SeTrip. Dengan mengakses atau menggunakan platform
SeTrip, Anda menyetujui untuk terikat oleh Syarat &amp; Ketentuan
berikut.
</p>
</header>
<div className="space-y-8 text-sm leading-relaxed text-neutral-700">
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">1. Definisi</h2>
<ul className="ml-5 list-disc space-y-1.5">
<li>
<strong>SeTrip</strong>: Platform yang menghubungkan pengguna
dengan penyelenggara trip.
</li>
<li>
<strong>Pengguna (User)</strong>: Individu yang menggunakan
aplikasi SeTrip.
</li>
<li>
<strong>Organizer (Penyelenggara)</strong>: Pengguna yang membuat
dan mengelola trip.
</li>
<li>
<strong>Trip</strong>: Kegiatan perjalanan yang dibuat oleh
organizer.
</li>
<li>
<strong>Platform</strong>: Website atau aplikasi SeTrip.
</li>
</ul>
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">2. Peran SeTrip</h2>
<p className="mb-3">
SeTrip bertindak sebagai <strong>platform perantara</strong> yang
menghubungkan pengguna dan organizer. SeTrip:
</p>
<ul className="ml-5 list-disc space-y-1.5">
<li>Bukan penyelenggara trip</li>
<li>Tidak terlibat langsung dalam pelaksanaan perjalanan</li>
<li>Tidak bertanggung jawab atas kegiatan selama trip berlangsung</li>
</ul>
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">
3. Penggunaan Platform
</h2>
<p className="mb-3">
Dengan menggunakan SeTrip, Anda menyatakan bahwa:
</p>
<ul className="ml-5 list-disc space-y-1.5">
<li>Berusia minimal 18 tahun atau memiliki izin dari wali</li>
<li>Memberikan informasi yang benar dan akurat</li>
<li>
Tidak menggunakan platform untuk: penipuan, aktivitas ilegal,
atau penyebaran informasi palsu
</li>
</ul>
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">4. Akun Pengguna</h2>
<ul className="ml-5 list-disc space-y-1.5">
<li>Pengguna bertanggung jawab atas keamanan akun masing-masing</li>
<li>Dilarang membagikan akun kepada pihak lain</li>
<li>
SeTrip berhak menangguhkan atau menghapus akun jika terjadi
pelanggaran
</li>
</ul>
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">5. Trip &amp; Booking</h2>
<ul className="ml-5 list-disc space-y-1.5">
<li>Organizer bertanggung jawab atas seluruh informasi trip</li>
<li>Pengguna wajib membaca detail trip sebelum melakukan join</li>
<li>
Dengan melakukan join trip, pengguna menyetujui seluruh ketentuan
trip yang dibuat oleh organizer
</li>
</ul>
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">6. Pembayaran</h2>
<ul className="ml-5 list-disc space-y-1.5">
<li>Pembayaran dilakukan sesuai metode yang tersedia di platform</li>
<li>
Dalam fase awal, pembayaran dapat dilakukan langsung kepada
organizer
</li>
<li>
SeTrip tidak menjamin keamanan transaksi yang dilakukan di luar
platform
</li>
</ul>
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">
7. Pembatalan &amp; Refund
</h2>
<ul className="ml-5 list-disc space-y-1.5">
<li>Kebijakan pembatalan ditentukan oleh organizer</li>
<li>
SeTrip tidak bertanggung jawab atas refund yang tidak diberikan
oleh organizer
</li>
<li>
Pengguna disarankan untuk memahami kebijakan sebelum melakukan
pembayaran
</li>
</ul>
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">
8. Tanggung Jawab Organizer
</h2>
<p className="mb-3">Organizer wajib:</p>
<ul className="ml-5 list-disc space-y-1.5">
<li>Memberikan informasi trip yang jelas dan akurat</li>
<li>Menjalankan trip sesuai deskripsi</li>
<li>Bertanggung jawab atas keselamatan peserta selama trip</li>
</ul>
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">9. Risiko Perjalanan</h2>
<p className="mb-3">
Pengguna memahami bahwa aktivitas perjalanan, terutama kegiatan
outdoor, memiliki risiko termasuk namun tidak terbatas pada:
</p>
<ul className="ml-5 list-disc space-y-1.5">
<li>Cedera</li>
<li>Kecelakaan</li>
<li>Cuaca ekstrem</li>
<li>Kondisi tak terduga lainnya</li>
</ul>
<blockquote className="mt-3 border-l-4 border-primary-500 bg-primary-50 px-4 py-3 italic text-neutral-700">
Mengikuti kegiatan secara sadar dan bertanggung jawab atas risiko
pribadi
</blockquote>
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">
10. Batasan Tanggung Jawab
</h2>
<p className="mb-3">SeTrip tidak bertanggung jawab atas:</p>
<ul className="ml-5 list-disc space-y-1.5">
<li>Kerugian finansial</li>
<li>Cedera atau kecelakaan</li>
<li>Kegagalan pelaksanaan trip</li>
<li>Tindakan organizer atau pengguna lain</li>
</ul>
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">
11. Larangan Transaksi di Luar Platform
</h2>
<p className="mb-3">
Pengguna disarankan untuk tidak melakukan transaksi di luar
platform. SeTrip tidak bertanggung jawab atas:
</p>
<ul className="ml-5 list-disc space-y-1.5">
<li>Penipuan</li>
<li>Kerugian</li>
<li>Masalah lain yang terjadi akibat transaksi di luar sistem SeTrip</li>
</ul>
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">12. Sistem Review</h2>
<ul className="ml-5 list-disc space-y-1.5">
<li>Pengguna dapat memberikan review setelah trip</li>
<li>Review harus jujur dan tidak mengandung unsur fitnah</li>
<li>SeTrip berhak menghapus review yang melanggar</li>
</ul>
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">
13. Penangguhan &amp; Penghentian Akun
</h2>
<p className="mb-3">SeTrip berhak untuk:</p>
<ul className="ml-5 list-disc space-y-1.5">
<li>Menangguhkan akun</li>
<li>Menghapus akun</li>
<li>Membatasi akses</li>
</ul>
<p className="mt-3 mb-3">Jika pengguna:</p>
<ul className="ml-5 list-disc space-y-1.5">
<li>Melanggar ketentuan</li>
<li>Terindikasi melakukan penipuan</li>
<li>Menyalahgunakan platform</li>
</ul>
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">14. Perubahan Layanan</h2>
<p className="mb-3">SeTrip dapat:</p>
<ul className="ml-5 list-disc space-y-1.5">
<li>Mengubah fitur</li>
<li>Menghentikan layanan</li>
<li>Menambahkan kebijakan baru</li>
</ul>
<p className="mt-3">Tanpa pemberitahuan sebelumnya.</p>
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">
15. Perubahan Syarat &amp; Ketentuan
</h2>
<p className="mb-3">
SeTrip dapat memperbarui Syarat &amp; Ketentuan ini kapan saja.
Pengguna disarankan untuk:
</p>
<ul className="ml-5 list-disc space-y-1.5">
<li>Membaca secara berkala</li>
<li>Memahami perubahan yang berlaku</li>
</ul>
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">16. Hukum yang Berlaku</h2>
<p>
Syarat &amp; Ketentuan ini diatur oleh hukum yang berlaku di
Republik Indonesia.
</p>
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">17. Kontak</h2>
<p>
Jika Anda memiliki pertanyaan, silakan hubungi:{" "}
<a
href="mailto:support@setrip.com"
className="font-semibold text-primary-600 hover:text-primary-700"
>
support@setrip.com
</a>
</p>
</section>
<section className="rounded-xl bg-neutral-50 p-5">
<h2 className="mb-2 text-lg font-bold text-neutral-900"> Persetujuan</h2>
<p className="mb-2">
Dengan menggunakan SeTrip, Anda menyatakan bahwa:
</p>
<ul className="ml-5 list-disc space-y-1.5">
<li>Telah membaca</li>
<li>Memahami</li>
<li>Menyetujui seluruh isi Syarat &amp; Ketentuan ini</li>
</ul>
</section>
</div>
<footer className="mt-10 border-t border-neutral-200 pt-6 text-sm">
<p className="text-neutral-500">
Lihat juga{" "}
<Link
href="/privacy"
className="font-semibold text-primary-600 hover:text-primary-700"
>
Kebijakan Privasi
</Link>
.
</p>
</footer>
</article>
</div>
);
}
+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>
);
}
+262
View File
@@ -0,0 +1,262 @@
import type { Metadata } from "next";
import Link from "next/link";
import Image from "next/image";
import { notFound } from "next/navigation";
import { profileService } from "@/server/services/profile.service";
import { trustService } from "@/server/services/trust.service";
import { reviewService } from "@/server/services/review.service";
import { TripCard } from "@/features/trip/components/trip-card";
import { ProfileTripRow } from "@/features/profile/components/profile-trip-row";
import { OrganizerStatsPanel } from "@/features/profile/components/organizer-stats-panel";
import { OrganizerReviewsList } from "@/features/review/components/organizer-reviews-list";
import { siteConfig } from "@/lib/site";
import { vibeMeta } from "@/lib/vibe";
interface PageProps {
params: Promise<{ id: string }>;
}
export async function generateMetadata({
params,
}: PageProps): Promise<Metadata> {
const { id } = await params;
const data = await profileService.getPublicProfile(id);
if (!data) {
return { title: "Profil tidak ditemukan", robots: { index: false } };
}
const { user } = data;
const title = `${user.name} — Profil`;
const desc =
user.profile?.bio?.slice(0, 160) ||
`Lihat profil ${user.name} di ${siteConfig.name}: trip yang dibuat, trip yang diikuti, dan minat aktivitas.`;
return {
title,
description: desc,
alternates: { canonical: `/u/${id}` },
openGraph: { title, description: desc, url: `/u/${id}` },
};
}
export default async function PublicProfilePage({ params }: PageProps) {
const { id } = await params;
const data = await profileService.getPublicProfile(id);
if (!data) notFound();
const { user, isVerifiedOrganizer, organizedTrips, joinedTrips } = data;
const profile = user.profile;
const memberSince = new Date(user.createdAt).toLocaleDateString("id-ID", {
month: "long",
year: "numeric",
});
// Trust panel hanya relevan untuk user yang berperan organizer.
// Hindari query Prisma yang nggak perlu untuk user yang murni peserta.
const isOrganizerProfile = organizedTrips.length > 0 || isVerifiedOrganizer;
const [organizerTrust, organizerReviews] = isOrganizerProfile
? await Promise.all([
trustService.getOrganizerTrust(user.id),
reviewService.getReviewsByOrganizer(user.id),
])
: [null, []];
return (
<div className="mx-auto max-w-4xl px-4 py-6 sm:py-8">
{/* Header */}
<section className="rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:gap-5">
<div className="relative h-20 w-20 shrink-0 overflow-hidden rounded-full bg-neutral-200 sm:h-24 sm:w-24">
{user.image ? (
<Image
src={user.image}
alt={user.name}
fill
sizes="96px"
className="object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-2xl font-bold text-neutral-500">
{user.name.charAt(0).toUpperCase()}
</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-xl font-bold text-neutral-800 sm:text-2xl">
{user.name}
</h1>
{isVerifiedOrganizer && (
<span
className="inline-flex items-center gap-0.5 rounded-full bg-primary-100 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-primary-800"
title="Identitas organizer telah diverifikasi (KTP & rekening)"
>
Verified Organizer
</span>
)}
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-neutral-500">
{profile?.city && (
<span className="inline-flex items-center gap-1">
📍 {profile.city}
</span>
)}
<span className="text-xs">Bergabung sejak {memberSince}</span>
</div>
{profile?.bio && (
<p className="mt-3 whitespace-pre-line text-sm text-neutral-700">
{profile.bio}
</p>
)}
{profile?.vibe && (
<div className="mt-3">
<span
className="inline-flex items-center gap-1.5 rounded-full bg-primary-50 px-2.5 py-1 text-xs font-semibold text-primary-700"
title={vibeMeta(profile.vibe).description}
>
<span aria-hidden>{vibeMeta(profile.vibe).icon}</span>
<span>Vibe: {vibeMeta(profile.vibe).label}</span>
</span>
</div>
)}
{profile?.interests && profile.interests.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{profile.interests.map((tag) => (
<span
key={tag}
className="rounded-full bg-secondary-50 px-2.5 py-0.5 text-xs font-medium text-secondary-700"
>
#{tag}
</span>
))}
</div>
)}
{profile?.instagram && (
<a
href={`https://instagram.com/${profile.instagram}`}
target="_blank"
rel="noopener noreferrer nofollow"
className="mt-3 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700"
>
<span>📸</span>
<span>@{profile.instagram}</span>
</a>
)}
</div>
</div>
<div className="mt-5 grid grid-cols-2 gap-3 border-t border-neutral-100 pt-4 text-center sm:grid-cols-3">
<div>
<p className="text-lg font-bold text-primary-600">
{organizedTrips.length}
</p>
<p className="text-[11px] text-neutral-500">Trip dibuat</p>
</div>
<div>
<p className="text-lg font-bold text-secondary-600">
{joinedTrips.length}
</p>
<p className="text-[11px] text-neutral-500">Trip diikuti</p>
</div>
<div className="col-span-2 sm:col-span-1">
<p className="text-lg font-bold text-neutral-700">
{organizedTrips.length + joinedTrips.length}
</p>
<p className="text-[11px] text-neutral-500">Total perjalanan</p>
</div>
</div>
</section>
{organizerTrust && <OrganizerStatsPanel trust={organizerTrust} />}
{organizerTrust && organizerReviews.length > 0 && (
<OrganizerReviewsList
reviews={organizerReviews}
totalCount={organizerTrust.reviewCount}
/>
)}
{/* Empty profile hint */}
{!profile && (
<p className="mt-5 rounded-xl border border-dashed border-neutral-200 bg-neutral-50 px-4 py-3 text-center text-xs text-neutral-500">
{user.name} belum melengkapi profil sosial bio, kota, & minat akan
muncul di sini setelah diisi.
</p>
)}
{/* Trip dibuat */}
{organizedTrips.length > 0 && (
<section className="mt-8">
<h2 className="mb-3 text-base font-bold text-neutral-800 sm:text-lg">
Trip yang dibuat ({organizedTrips.length})
</h2>
<div className="grid gap-4 sm:grid-cols-2">
{organizedTrips.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={user.name}
status={trip.status}
coverImage={trip.images[0]?.url}
isVerifiedOrganizer={isVerifiedOrganizer}
/>
))}
</div>
</section>
)}
{/* Trip diikuti */}
{joinedTrips.length > 0 && (
<section className="mt-8">
<h2 className="mb-3 text-base font-bold text-neutral-800 sm:text-lg">
Trip yang diikuti ({joinedTrips.length})
</h2>
<ul className="space-y-2">
{joinedTrips.map((trip) => (
<li key={trip.id}>
<ProfileTripRow
href={`/trips/${trip.id}`}
title={trip.title}
destination={trip.destination}
date={trip.date}
endDate={trip.endDate}
rightSlot={
<span className="text-neutral-500">
bareng{" "}
<Link
href={`/u/${trip.organizer.id}`}
className="font-medium text-neutral-700 hover:text-primary-600"
>
{trip.organizer.name}
</Link>
</span>
}
/>
</li>
))}
</ul>
</section>
)}
{/* Empty state */}
{organizedTrips.length === 0 && joinedTrips.length === 0 && (
<p className="mt-8 rounded-xl border border-dashed border-neutral-200 bg-white px-4 py-10 text-center text-sm text-neutral-500">
Belum ada trip yang dibuat atau diikuti.
</p>
)}
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Verifikasi Organizer",
description:
"Ajukan verifikasi sebagai organizer SeTrip dengan KTP & data rekening untuk membuat trip berbayar.",
alternates: { canonical: "/verify" },
robots: { index: false, follow: false },
};
export default function VerifyLayout({ children }: { children: React.ReactNode }) {
return children;
}
+93
View File
@@ -0,0 +1,93 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { organizerService } from "@/server/services/organizer.service";
import { VerifyForm } from "@/features/organizer/components/verify-form";
import { VerifiedBadge } from "@/components/shared/verified-badge";
export default async function VerifyPage() {
const session = await getServerSession(authOptions);
if (!session?.user) {
redirect("/login?callbackUrl=/verify");
}
const verification = await organizerService.getStatusForUser(session.user.id);
const initial = verification
? {
fullName: verification.fullName,
nik: organizerService.decryptNik(verification.nikEncrypted),
birthDate: verification.birthDate,
address: verification.address,
ktpImageKey: verification.ktpImageKey,
livenessKey: verification.livenessKey,
bankName: verification.bankName,
bankAccountNumber: verification.bankAccountNumber,
bankAccountName: verification.bankAccountName,
}
: null;
return (
<div className="mx-auto max-w-2xl px-4 py-8 sm:py-12">
<div className="mb-6">
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
Verifikasi Organizer
</h1>
<p className="mt-2 text-sm text-neutral-600">
Lengkapi data berikut untuk mengaktifkan kemampuan membuat trip berbayar.
</p>
</div>
{verification?.status === "APPROVED" && (
<div className="mb-6 rounded-2xl border border-primary-200 bg-primary-50 p-5">
<div className="mb-2 flex items-center gap-2">
<VerifiedBadge size="md" />
<span className="text-sm font-semibold text-primary-800">
Akun terverifikasi
</span>
</div>
<p className="text-sm text-neutral-700">
Selamat! Kamu sudah bisa membuat trip berbayar.
</p>
</div>
)}
{verification?.status === "PENDING" && (
<div className="mb-6 rounded-2xl border border-amber-200 bg-amber-50 p-5">
<p className="mb-1 text-sm font-bold text-amber-800">
Menunggu review admin
</p>
<p className="text-sm text-neutral-700">
Pengajuanmu sedang diproses. Kami akan memberitahu via email setelah selesai.
</p>
</div>
)}
{verification?.status === "REJECTED" && (
<div className="mb-6 rounded-2xl border border-red-200 bg-red-50 p-5">
<p className="mb-1 text-sm font-bold text-red-800"> Pengajuan ditolak</p>
{verification.rejectionReason && (
<p className="text-sm text-neutral-700">
<span className="font-semibold">Alasan:</span>{" "}
{verification.rejectionReason}
</p>
)}
<p className="mt-2 text-sm text-neutral-700">
Kamu bisa memperbaiki data dan mengajukan ulang di bawah.
</p>
</div>
)}
{verification?.status !== "APPROVED" && verification?.status !== "PENDING" && (
<VerifyForm initial={initial} />
)}
<p className="mt-6 text-center text-sm text-neutral-500">
<Link href="/profile" className="hover:text-primary-600">
Kembali ke profil
</Link>
</p>
</div>
);
}