Files
setrip/app/trips/[id]/opengraph-image.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

228 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }
);
}