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>
This commit is contained in:
arifal
2026-04-27 02:14:08 +07:00
parent a4508dc828
commit d91e16b6ef
31 changed files with 657 additions and 255 deletions
+134
View File
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getServerSession } from "next-auth";
import Link from "next/link";
@@ -6,6 +7,7 @@ import { tripService } from "@/server/services/trip.service";
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 { OrganizerJoinRequests } from "@/features/trip/components/organizer-join-requests";
import { OrganizerTrustPanel } from "@/features/trip/components/organizer-trust-panel";
@@ -18,6 +20,47 @@ import {
isTripDepartureDayPast,
} from "@/lib/trip-dates";
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.mountain}`;
const fallbackDescription = `Open trip ${trip.mountain} 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,
}: {
@@ -84,8 +127,99 @@ export default async function TripDetailPage({
(p) => p.markedPaidAt && !p.paymentConfirmedAt
);
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.mountain,
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.mountain,
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">