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) => ( + + ))} +
+ +