create public layout and admin and fix escrow and refund
This commit is contained in:
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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 & Ketentuan dan
|
||||
Kebijakan Privasi SeTrip.
|
||||
</p>
|
||||
<AcceptTermsForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 & 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 & Ketentuan
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 & 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 & 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 & 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 & 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 & 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 & Ketentuan
|
||||
</h2>
|
||||
<p className="mb-3">
|
||||
SeTrip dapat memperbarui Syarat & 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 & 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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user