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:
@@ -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 Pendakian`;
|
||||
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 Pendakian</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Middle: title + mountain */}
|
||||
<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.mountain} · {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 }
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user