create public layout and admin and fix escrow and refund
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user