Skip to content

Commit 321e8cb

Browse files
authored
Merge pull request #33 from Manuel10i/guide-og-social-cards
2 parents 19672e1 + a954fc0 commit 321e8cb

2 files changed

Lines changed: 169 additions & 4 deletions

File tree

src/app/(marketing)/guides/[slug]/page.tsx

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,15 +84,35 @@ export async function generateMetadata({
8484
const locale = await getLocale();
8585
const t = await getTranslations({ locale, namespace: "guides" });
8686
const canonical = guideUrl(slug);
87+
const metaTitle = t(`${guide.i18nKey}.metaTitle`);
88+
const metaDescription = t(`${guide.i18nKey}.metaDescription`);
8789
return {
88-
title: t(`${guide.i18nKey}.metaTitle`),
89-
description: t(`${guide.i18nKey}.metaDescription`),
90+
title: metaTitle,
91+
description: metaDescription,
9092
alternates: { canonical },
9193
openGraph: {
92-
title: t(`${guide.i18nKey}.metaTitle`),
93-
description: t(`${guide.i18nKey}.metaDescription`),
94+
title: metaTitle,
95+
description: metaDescription,
9496
url: canonical,
9597
type: "article",
98+
// A child openGraph replaces (not deep-merges) the root layout's, so its
99+
// default /api/og image is dropped here unless we set our own. Point at a
100+
// guide-specific card so shared links get a real preview image.
101+
images: [
102+
{
103+
url: `${BASE_URL}/api/og/guide/${slug}`,
104+
width: 1200,
105+
height: 630,
106+
alt: metaTitle,
107+
},
108+
],
109+
},
110+
// Root layout sets a generic twitter title/description; override so the
111+
// shared card headline matches the article. twitter.images stays omitted on
112+
// purpose (X falls back to the og:image above), per the site's card pattern.
113+
twitter: {
114+
title: metaTitle,
115+
description: metaDescription,
96116
},
97117
};
98118
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { ImageResponse } from "next/og";
2+
import { getTranslations } from "next-intl/server";
3+
import { getGuide } from "@/lib/guides";
4+
import { getMonthlyBusyness } from "@/lib/services/destination-busyness";
5+
import { crowdColor } from "@/lib/crowd-palette";
6+
7+
/**
8+
* On-demand Open Graph card for an editorial guide (1200×630). Mirrors the
9+
* destination card so shared guide links render a full-width branded preview
10+
* instead of the image-less "summary_large_image" fallback. Lives under /api so
11+
* it stays dynamic while the guide pages remain fully static — the pages
12+
* reference it via openGraph.images in their generateMetadata. The bar strip is
13+
* the target destination's real 12-month crowd profile (same source the article
14+
* uses), so the card is a genuine data card, not decoration.
15+
*/
16+
17+
export const runtime = "nodejs";
18+
19+
const SIZE = { width: 1200, height: 630 };
20+
21+
const FRAUNCES_URL =
22+
"https://fonts.gstatic.com/s/fraunces/v38/6NVf8FyLNQOQZAnv9ZwNjucMHVn85Ni7emAe9lKqZTnbB-gzTK0K1ChJdt9vIVYX9G37lod_sPEKsxx664UJf1iVSv7W.ttf";
23+
24+
const MONTHS = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
25+
26+
let frauncesPromise: Promise<ArrayBuffer | null> | null = null;
27+
28+
function loadFraunces(): Promise<ArrayBuffer | null> {
29+
frauncesPromise ??= fetch(FRAUNCES_URL)
30+
.then((res) => (res.ok ? res.arrayBuffer() : null))
31+
.catch(() => null);
32+
return frauncesPromise;
33+
}
34+
35+
export async function GET(
36+
_request: Request,
37+
{ params }: { params: Promise<{ slug: string }> },
38+
) {
39+
const { slug } = await params;
40+
const guide = getGuide(slug);
41+
if (!guide) {
42+
return new Response("Not found", { status: 404 });
43+
}
44+
45+
// OG cards are one-per-URL (locale is negotiated, not path-prefixed), so the
46+
// shared card uses the English headline, matching the default og:title.
47+
const t = await getTranslations({ locale: "en", namespace: "guides" });
48+
const title = t(`${guide.i18nKey}.metaTitle`);
49+
const scores = getMonthlyBusyness(guide.targetId);
50+
const fraunces = await loadFraunces();
51+
52+
return new ImageResponse(
53+
(
54+
<div
55+
style={{
56+
width: "100%",
57+
height: "100%",
58+
display: "flex",
59+
flexDirection: "column",
60+
justifyContent: "space-between",
61+
backgroundColor: "#f6f1e7",
62+
backgroundImage:
63+
"linear-gradient(to right, rgba(34,48,58,0.06) 1px, transparent 1px), linear-gradient(to bottom, rgba(34,48,58,0.06) 1px, transparent 1px)",
64+
backgroundSize: "80px 80px",
65+
padding: "64px 72px",
66+
}}
67+
>
68+
<div style={{ display: "flex", flexDirection: "column" }}>
69+
<div style={{ display: "flex", fontSize: 24, letterSpacing: 6, color: "#c2542f" }}>
70+
CROWD GUIDE
71+
</div>
72+
<div
73+
style={{
74+
display: "flex",
75+
marginTop: 18,
76+
fontSize: 72,
77+
lineHeight: 1.06,
78+
color: "#22303a",
79+
fontFamily: fraunces ? "Fraunces" : undefined,
80+
fontStyle: fraunces ? "italic" : undefined,
81+
}}
82+
>
83+
{title}
84+
</div>
85+
</div>
86+
87+
<div style={{ display: "flex", flexDirection: "column" }}>
88+
<div style={{ display: "flex", gap: 6 }}>
89+
{scores.map((s: number, i: number) => (
90+
<div
91+
key={i}
92+
style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 8 }}
93+
>
94+
<div
95+
style={{
96+
width: 82,
97+
height: 96,
98+
borderRadius: 3,
99+
backgroundColor: crowdColor(s, "light"),
100+
}}
101+
/>
102+
<div style={{ display: "flex", fontSize: 17, letterSpacing: 3, color: "#5c6a72" }}>
103+
{MONTHS[i]}
104+
</div>
105+
</div>
106+
))}
107+
</div>
108+
<div
109+
style={{
110+
display: "flex",
111+
justifyContent: "space-between",
112+
alignItems: "center",
113+
marginTop: 36,
114+
paddingTop: 24,
115+
borderTop: "1px solid #d8cdb8",
116+
}}
117+
>
118+
<div
119+
style={{
120+
display: "flex",
121+
fontSize: 30,
122+
color: "#22303a",
123+
fontFamily: fraunces ? "Fraunces" : undefined,
124+
}}
125+
>
126+
PackedPlaces
127+
</div>
128+
<div style={{ display: "flex", fontSize: 20, letterSpacing: 4, color: "#97998c" }}>
129+
PACKEDPLACES.COM
130+
</div>
131+
</div>
132+
</div>
133+
</div>
134+
),
135+
{
136+
...SIZE,
137+
headers: {
138+
"Cache-Control": "public, max-age=86400",
139+
},
140+
fonts: fraunces
141+
? [{ name: "Fraunces", data: fraunces, style: "italic" as const, weight: 600 as const }]
142+
: undefined,
143+
},
144+
);
145+
}

0 commit comments

Comments
 (0)