diff --git a/src/app/story/[storylineId]/og/route.tsx b/src/app/story/[storylineId]/og/route.tsx
new file mode 100644
index 00000000..6edf9a13
--- /dev/null
+++ b/src/app/story/[storylineId]/og/route.tsx
@@ -0,0 +1,118 @@
+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";
+
+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;
+
+ 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(
+ (
+
+ {/* Top: branding */}
+
+ PlotLink
+
+
+ {/* Center: title */}
+
+
+ {sl.title.length > 80 ? `${sl.title.slice(0, 77)}...` : sl.title}
+
+
+
+ {/* Bottom: metadata */}
+
+ by {truncateAddress(sl.writer_address)}
+
+ {sl.plot_count} {sl.plot_count === 1 ? "plot" : "plots"}
+
+ {priceDisplay && Price: {priceDisplay}}
+
+
+ ),
+ {
+ width: 1200,
+ height: 800,
+ },
+ );
+}
diff --git a/src/app/story/[storylineId]/page.tsx b/src/app/story/[storylineId]/page.tsx
index 18ae918f..6c77f7fa 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,71 @@ 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 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",
+ 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 +153,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 (
+
+ );
+}