Files
setrip/app/profile/page.tsx
T
arifal d91e16b6ef feat(seo): add metadata, sitemap, robots, JSON-LD, dynamic OG image
- Centralize brand/keyword config in lib/site.ts (slogan, 22 keywords).
- Root layout: metadataBase, title template, OG/Twitter defaults, robots rules.
- Per-page metadata: home, trips list (filter-aware), trip detail, profile (noindex).
- Layout wrappers add metadata to client-component pages: login, register, create-trip.
- Trip detail: generateMetadata + JSON-LD Event + BreadcrumbList (price, availability, rating).
- Home page: JSON-LD Organization + WebSite + SearchAction (sitelink search).
- app/sitemap.ts: dynamic sitemap pulling OPEN/FULL trips from Prisma.
- app/robots.ts: disallow /api/, /profile, /create-trip; references sitemap.
- app/trips/[id]/opengraph-image.tsx: dynamic 1200x630 OG image per trip with
  cover photo, title, mountain, date, price, brand badge.
- Seeder: switch trip images from local SVG placeholders to real Unsplash CDN URLs.
- Drop 18 obsolete seed SVGs from public/images/seed/.

New env: NEXT_PUBLIC_SITE_URL (defaults to localhost:3000) — set to prod domain on deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 02:14:08 +07:00

234 lines
8.7 KiB
TypeScript

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 { TripCard } from "@/features/trip/components/trip-card";
import { ProfileTripRow } from "@/features/profile/components/profile-trip-row";
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 = await profileService.getProfileDashboard(session.user.id);
const { user, 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-[72px] w-[72px] rounded-full object-cover"
/>
) : (
<div className="flex h-[72px] w-[72px] 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>
{/* 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}
mountain={t.mountain}
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}
mountain={trip.mountain}
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}
/>
))}
</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}
mountain={t.mountain}
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}
mountain={t.mountain}
date={t.date}
endDate={t.endDate}
rightSlot={
<span className="text-red-500/90">Dibatalkan</span>
}
/>
</li>
);
})}
</ul>
</div>
)}
</section>
</div>
</div>
);
}