From d78321d7e011bfc768210d2685343060847a8bf0 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sun, 15 Mar 2026 15:44:28 +0000 Subject: [PATCH 1/3] [#38] Add Farcaster sharing button, meta tags, and OG image - ShareToFarcaster client component: detects mini app context via SDK, calls sdk.actions.composeCast() with story URL embed - generateMetadata on story pages: OG tags + fc:miniapp meta tag with launch_miniapp action pointing back to story URL - Dynamic OG image route at /story/[storylineId]/og: 1200x800 (3:2) showing title, writer address, and plot count Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/story/[storylineId]/og/route.tsx | 106 +++++++++++++++++++++++ src/app/story/[storylineId]/page.tsx | 60 +++++++++++++ src/components/ShareToFarcaster.tsx | 79 +++++++++++++++++ 3 files changed, 245 insertions(+) create mode 100644 src/app/story/[storylineId]/og/route.tsx create mode 100644 src/components/ShareToFarcaster.tsx diff --git a/src/app/story/[storylineId]/og/route.tsx b/src/app/story/[storylineId]/og/route.tsx new file mode 100644 index 00000000..c0beeecb --- /dev/null +++ b/src/app/story/[storylineId]/og/route.tsx @@ -0,0 +1,106 @@ +import { ImageResponse } from "next/og"; +import { createServerClient, type Storyline } from "../../../../../lib/supabase"; +import { truncateAddress } from "../../../../../lib/utils"; + +export const runtime = "edge"; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ storylineId: string }> }, +) { + const { storylineId } = await params; + const id = Number(storylineId); + + if (isNaN(id) || id <= 0) { + return new Response("Invalid storyline ID", { status: 400 }); + } + + const supabase = createServerClient(); + if (!supabase) { + return new Response("Database unavailable", { status: 503 }); + } + + const { data: storyline } = await supabase + .from("storylines") + .select("*") + .eq("storyline_id", id) + .eq("hidden", false) + .single(); + + if (!storyline) { + return new Response("Storyline not found", { status: 404 }); + } + + const sl = storyline as Storyline; + + return new ImageResponse( + ( +
+ {/* Top: branding */} +
+ PlotLink +
+ + {/* Center: title */} +
+
+ {sl.title} +
+
+ + {/* Bottom: metadata */} +
+ by {truncateAddress(sl.writer_address)} + + {sl.plot_count} {sl.plot_count === 1 ? "plot" : "plots"} + +
+
+ ), + { + width: 1200, + height: 800, + }, + ); +} diff --git a/src/app/story/[storylineId]/page.tsx b/src/app/story/[storylineId]/page.tsx index 18ae918f..89b08d3e 100644 --- a/src/app/story/[storylineId]/page.tsx +++ b/src/app/story/[storylineId]/page.tsx @@ -1,3 +1,4 @@ +import { type Metadata } from "next"; import { createServerClient, type Storyline, type Plot } from "../../../../lib/supabase"; import { DeadlineCountdown } from "../../../components/DeadlineCountdown"; import { TradingWidget } from "../../../components/TradingWidget"; @@ -5,6 +6,7 @@ import { PriceChart } from "../../../components/PriceChart"; import { DonateWidget } from "../../../components/DonateWidget"; import { RatingWidget } from "../../../components/RatingWidget"; import { RatingSummary } from "../../../components/RatingSummary"; +import { ShareToFarcaster } from "../../../components/ShareToFarcaster"; import { getTokenPrice, type TokenPriceInfo } from "../../../../lib/price"; import { IS_TESTNET } from "../../../../lib/contracts/constants"; import { type Address } from "viem"; @@ -13,6 +15,63 @@ import { AgentBadge } from "../../../components/AgentBadge"; type Params = Promise<{ storylineId: string }>; +const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"; + +export async function generateMetadata({ + params, +}: { + params: Params; +}): Promise { + const { storylineId } = await params; + const id = Number(storylineId); + + if (isNaN(id) || id <= 0) return {}; + + const supabase = createServerClient(); + if (!supabase) return {}; + + const { data: storyline } = await supabase + .from("storylines") + .select("*") + .eq("storyline_id", id) + .eq("hidden", false) + .single(); + + if (!storyline) return {}; + + const sl = storyline as Storyline; + const ogImageUrl = `${appUrl}/story/${id}/og`; + const storyUrl = `${appUrl}/story/${id}`; + const description = `A collaborative on-chain story by ${truncateAddress(sl.writer_address)} — ${sl.plot_count} ${sl.plot_count === 1 ? "plot" : "plots"}`; + + const fcEmbed = JSON.stringify({ + version: "1", + imageUrl: ogImageUrl, + button: { + title: "Read Story", + action: { + type: "launch_miniapp", + url: storyUrl, + name: "PlotLink", + splashBackgroundColor: "#0a0a0a", + }, + }, + }); + + return { + title: `${sl.title} — PlotLink`, + description, + openGraph: { + title: sl.title, + description, + images: [{ url: ogImageUrl, width: 1200, height: 800 }], + }, + other: { + "fc:miniapp": fcEmbed, + }, + }; +} + export default async function StoryPage({ params }: { params: Params }) { const { storylineId } = await params; const id = Number(storylineId); @@ -86,6 +145,7 @@ export default async function StoryPage({ params }: { params: Params }) { {sl.token_address && ( )} + diff --git a/src/components/ShareToFarcaster.tsx b/src/components/ShareToFarcaster.tsx new file mode 100644 index 00000000..7c031a07 --- /dev/null +++ b/src/components/ShareToFarcaster.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; + +/** + * "Share to Farcaster" button — only renders inside a Farcaster Mini App context. + * Calls sdk.actions.composeCast() with pre-filled text and story URL as embed. + */ +export function ShareToFarcaster({ + storylineId, + title, +}: { + storylineId: number; + title: string; +}) { + const [visible, setVisible] = useState(false); + + useEffect(() => { + if (typeof window === "undefined") return; + + let cancelled = false; + + import("@farcaster/miniapp-sdk") + .then(async ({ sdk }) => { + if (cancelled) return; + const ctx = await sdk.context; + if (ctx && !cancelled) setVisible(true); + }) + .catch(() => { + // Not in a Farcaster context + }); + + return () => { + cancelled = true; + }; + }, []); + + const handleShare = useCallback(async () => { + const { sdk } = await import("@farcaster/miniapp-sdk"); + + const appUrl = + process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"; + const storyUrl = `${appUrl}/story/${storylineId}`; + + await sdk.actions.composeCast({ + text: `Check out "${title}" on PlotLink`, + embeds: [storyUrl], + }); + }, [storylineId, title]); + + if (!visible) return null; + + return ( + + ); +} + +function FarcasterIcon() { + return ( + + + + + + ); +} From 64c0bbd959d9ac6156e76bf8f1fbeb2a5ec00fb0 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sun, 15 Mar 2026 15:47:30 +0000 Subject: [PATCH 2/3] [#38] Add current token price to OG image and metadata Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/story/[storylineId]/og/route.tsx | 13 +++++++++++++ src/app/story/[storylineId]/page.tsx | 10 +++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/app/story/[storylineId]/og/route.tsx b/src/app/story/[storylineId]/og/route.tsx index c0beeecb..50df23eb 100644 --- a/src/app/story/[storylineId]/og/route.tsx +++ b/src/app/story/[storylineId]/og/route.tsx @@ -1,5 +1,8 @@ import { ImageResponse } from "next/og"; +import { type Address } from "viem"; import { createServerClient, type Storyline } from "../../../../../lib/supabase"; +import { getTokenPrice } from "../../../../../lib/price"; +import { IS_TESTNET } from "../../../../../lib/contracts/constants"; import { truncateAddress } from "../../../../../lib/utils"; export const runtime = "edge"; @@ -33,6 +36,15 @@ export async function GET( const sl = storyline as Storyline; + const priceInfo = sl.token_address + ? await getTokenPrice(sl.token_address as Address) + : null; + + const reserveLabel = IS_TESTNET ? "WETH" : "$PLOT"; + const priceDisplay = priceInfo + ? `${priceInfo.pricePerToken} ${reserveLabel}` + : null; + return new ImageResponse( (
{sl.plot_count} {sl.plot_count === 1 ? "plot" : "plots"} + {priceDisplay && Price: {priceDisplay}}
), diff --git a/src/app/story/[storylineId]/page.tsx b/src/app/story/[storylineId]/page.tsx index 89b08d3e..6c77f7fa 100644 --- a/src/app/story/[storylineId]/page.tsx +++ b/src/app/story/[storylineId]/page.tsx @@ -42,7 +42,15 @@ export async function generateMetadata({ const sl = storyline as Storyline; const ogImageUrl = `${appUrl}/story/${id}/og`; const storyUrl = `${appUrl}/story/${id}`; - const description = `A collaborative on-chain story by ${truncateAddress(sl.writer_address)} — ${sl.plot_count} ${sl.plot_count === 1 ? "plot" : "plots"}`; + + const priceInfo = sl.token_address + ? await getTokenPrice(sl.token_address as Address) + : null; + const reserveLabel = IS_TESTNET ? "WETH" : "$PLOT"; + const priceSuffix = priceInfo + ? ` — Price: ${priceInfo.pricePerToken} ${reserveLabel}` + : ""; + const description = `A collaborative on-chain story by ${truncateAddress(sl.writer_address)} — ${sl.plot_count} ${sl.plot_count === 1 ? "plot" : "plots"}${priceSuffix}`; const fcEmbed = JSON.stringify({ version: "1", From bd275deb1cc9868c682c54cea0efb06b979a9108 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sun, 15 Mar 2026 15:48:31 +0000 Subject: [PATCH 3/3] [#38] Truncate long titles in OG image (Satori lineClamp fix) --- src/app/story/[storylineId]/og/route.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app/story/[storylineId]/og/route.tsx b/src/app/story/[storylineId]/og/route.tsx index 50df23eb..6edf9a13 100644 --- a/src/app/story/[storylineId]/og/route.tsx +++ b/src/app/story/[storylineId]/og/route.tsx @@ -86,11 +86,10 @@ export async function GET( fontSize: "48px", fontWeight: 700, color: "#00ff88", - lineClamp: 3, overflow: "hidden", }} > - {sl.title} + {sl.title.length > 80 ? `${sl.title.slice(0, 77)}...` : sl.title}