diff --git a/src/app/story/[storylineId]/page.tsx b/src/app/story/[storylineId]/page.tsx
index b6e214e7..855f554d 100644
--- a/src/app/story/[storylineId]/page.tsx
+++ b/src/app/story/[storylineId]/page.tsx
@@ -3,6 +3,8 @@ import { DeadlineCountdown } from "../../../components/DeadlineCountdown";
import { TradingWidget } from "../../../components/TradingWidget";
import { PriceChart } from "../../../components/PriceChart";
import { DonateWidget } from "../../../components/DonateWidget";
+import { RatingWidget } from "../../../components/RatingWidget";
+import { RatingSummary } from "../../../components/RatingSummary";
import { getTokenPrice, type TokenPriceInfo } from "../../../../lib/price";
import { IS_TESTNET } from "../../../../lib/contracts/constants";
import { type Address } from "viem";
@@ -66,6 +68,9 @@ export default async function StoryPage({ params }: { params: Params }) {
)}
+ {sl.token_address && (
+
+ )}
{plots.map((plot) => (
@@ -107,6 +112,7 @@ function StoryHeader({
agent
)}
+
{priceInfo && (
diff --git a/src/components/RatingSummary.tsx b/src/components/RatingSummary.tsx
new file mode 100644
index 00000000..4bfdf35e
--- /dev/null
+++ b/src/components/RatingSummary.tsx
@@ -0,0 +1,35 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+
+interface RatingsResponse {
+ average: number;
+ count: number;
+}
+
+export function RatingSummary({ storylineId }: { storylineId: number }) {
+ const { data } = useQuery({
+ queryKey: ["ratings", storylineId],
+ queryFn: async () => {
+ const res = await fetch(`/api/ratings?storylineId=${storylineId}`);
+ if (!res.ok) throw new Error("Failed to fetch ratings");
+ return res.json();
+ },
+ });
+
+ if (!data || data.count === 0) return null;
+
+ return (
+
+ {[1, 2, 3, 4, 5].map((star) => (
+
+ *
+
+ ))}{" "}
+ {data.average.toFixed(1)} ({data.count})
+
+ );
+}
diff --git a/src/components/RatingWidget.tsx b/src/components/RatingWidget.tsx
new file mode 100644
index 00000000..65a9826a
--- /dev/null
+++ b/src/components/RatingWidget.tsx
@@ -0,0 +1,234 @@
+"use client";
+
+import { useState, useEffect, useCallback } from "react";
+import { useAccount, useSignMessage } from "wagmi";
+import { useQuery } from "@tanstack/react-query";
+import { publicClient } from "../../lib/rpc";
+import { erc20Abi } from "../../lib/price";
+import type { Address } from "viem";
+
+interface RatingData {
+ id: number;
+ storyline_id: number;
+ rater_address: string;
+ rating: number;
+ comment: string | null;
+ created_at: string;
+ updated_at: string;
+}
+
+interface RatingsResponse {
+ ratings: RatingData[];
+ average: number;
+ count: number;
+}
+
+interface RatingWidgetProps {
+ storylineId: number;
+ tokenAddress: string;
+}
+
+function truncateAddress(address: string): string {
+ return `${address.slice(0, 6)}...${address.slice(-4)}`;
+}
+
+function StarDisplay({ rating, size = "sm" }: { rating: number; size?: "sm" | "lg" }) {
+ const sizeClass = size === "lg" ? "text-base" : "text-xs";
+ return (
+
+ {[1, 2, 3, 4, 5].map((star) => (
+
+ *
+
+ ))}
+
+ );
+}
+
+export function RatingWidget({ storylineId, tokenAddress }: RatingWidgetProps) {
+ const { address, isConnected } = useAccount();
+ const { signMessageAsync } = useSignMessage();
+
+ const [selectedRating, setSelectedRating] = useState(0);
+ const [comment, setComment] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(false);
+
+ // Fetch ratings
+ const { data: ratingsData, refetch } = useQuery({
+ queryKey: ["ratings", storylineId],
+ queryFn: async () => {
+ const res = await fetch(`/api/ratings?storylineId=${storylineId}`);
+ if (!res.ok) throw new Error("Failed to fetch ratings");
+ return res.json();
+ },
+ });
+
+ // Check token balance
+ const { data: hasTokens } = useQuery({
+ queryKey: ["tokenBalance", tokenAddress, address],
+ queryFn: async () => {
+ if (!address) return false;
+ const balance = await publicClient.readContract({
+ address: tokenAddress as Address,
+ abi: erc20Abi,
+ functionName: "balanceOf",
+ args: [address],
+ });
+ return balance > BigInt(0);
+ },
+ enabled: isConnected && !!address,
+ });
+
+ // Pre-fill existing rating or reset when wallet changes
+ useEffect(() => {
+ if (ratingsData && address) {
+ const existing = ratingsData.ratings.find(
+ (r) => r.rater_address === address.toLowerCase(),
+ );
+ if (existing) {
+ setSelectedRating(existing.rating);
+ setComment(existing.comment ?? "");
+ } else {
+ setSelectedRating(0);
+ setComment("");
+ }
+ } else if (!address) {
+ setSelectedRating(0);
+ setComment("");
+ }
+ }, [ratingsData, address]);
+
+ const submitRating = useCallback(async () => {
+ if (!address || selectedRating === 0) return;
+
+ try {
+ setError(null);
+ setSuccess(false);
+ setSubmitting(true);
+
+ const message = `Rate storyline ${storylineId} with rating ${selectedRating}`;
+ const signature = await signMessageAsync({ message });
+
+ const res = await fetch("/api/ratings", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ storylineId,
+ rating: selectedRating,
+ comment: comment || undefined,
+ signature,
+ message,
+ }),
+ });
+
+ if (!res.ok) {
+ const body = await res.json();
+ throw new Error(body.error || "Failed to submit rating");
+ }
+
+ setSuccess(true);
+ refetch();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to submit");
+ } finally {
+ setSubmitting(false);
+ }
+ }, [address, selectedRating, comment, storylineId, signMessageAsync, refetch]);
+
+ const ratings = ratingsData?.ratings ?? [];
+ const average = ratingsData?.average ?? 0;
+ const count = ratingsData?.count ?? 0;
+
+ return (
+
+
+
Ratings
+ {count > 0 && (
+
+
+
+ {average.toFixed(1)} ({count})
+
+
+ )}
+
+
+ {/* Rating form or gate message */}
+ {isConnected && hasTokens ? (
+
+
+
+ {[1, 2, 3, 4, 5].map((star) => (
+
+ ))}
+
+
+
+ ) : isConnected ? (
+
+ Hold storyline tokens to rate this story.
+
+ ) : null}
+
+ {/* Recent ratings */}
+ {ratings.length > 0 && (
+
+
+ Recent Ratings
+
+
+ {ratings.slice(0, 10).map((r) => (
+
+
+
+ {truncateAddress(r.rater_address)}
+
+
+
+ {r.comment && (
+
{r.comment}
+ )}
+
+ ))}
+
+
+ )}
+
+ );
+}