Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/app/story/[storylineId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -66,6 +68,9 @@ export default async function StoryPage({ params }: { params: Params }) {
<TradingWidget tokenAddress={sl.token_address as Address} />
)}
<DonateWidget storylineId={id} />
{sl.token_address && (
<RatingWidget storylineId={id} tokenAddress={sl.token_address} />
)}
<div className="mt-10 space-y-10">
{plots.map((plot) => (
<PlotEntry key={plot.id} plot={plot} />
Expand Down Expand Up @@ -107,6 +112,7 @@ function StoryHeader({
agent
</span>
)}
<RatingSummary storylineId={storyline.storyline_id} />
</div>

{priceInfo && (
Expand Down
35 changes: 35 additions & 0 deletions src/components/RatingSummary.tsx
Original file line number Diff line number Diff line change
@@ -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<RatingsResponse>({
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 (
<span>
{[1, 2, 3, 4, 5].map((star) => (
<span
key={star}
className={star <= Math.round(data.average) ? "text-accent" : "text-muted"}
>
*
</span>
))}{" "}
{data.average.toFixed(1)} ({data.count})
</span>
);
}
234 changes: 234 additions & 0 deletions src/components/RatingWidget.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span className={sizeClass}>
{[1, 2, 3, 4, 5].map((star) => (
<span
key={star}
className={star <= Math.round(rating) ? "text-accent" : "text-muted"}
>
*
</span>
))}
</span>
);
}

export function RatingWidget({ storylineId, tokenAddress }: RatingWidgetProps) {
const { address, isConnected } = useAccount();
const { signMessageAsync } = useSignMessage();

const [selectedRating, setSelectedRating] = useState<number>(0);
const [comment, setComment] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);

// Fetch ratings
const { data: ratingsData, refetch } = useQuery<RatingsResponse>({
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 (
<section className="border-border mt-8 rounded border px-4 py-4">
<div className="flex items-center justify-between">
<h2 className="text-foreground text-sm font-medium">Ratings</h2>
{count > 0 && (
<div className="flex items-center gap-2 text-xs">
<StarDisplay rating={average} />
<span className="text-muted">
{average.toFixed(1)} ({count})
</span>
</div>
)}
</div>

{/* Rating form or gate message */}
{isConnected && hasTokens ? (
<div className="mt-3">
<label className="text-muted block text-[10px] uppercase tracking-wider">
Your rating
</label>
<div className="mt-1 flex gap-1">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
onClick={() => setSelectedRating(star)}
disabled={submitting}
className={`rounded border px-2 py-1 text-xs transition-colors ${
star <= selectedRating
? "border-accent text-accent"
: "border-border text-muted hover:border-accent-dim"
} disabled:opacity-50`}
>
{star}
</button>
))}
</div>

<textarea
placeholder="Comment (optional)"
value={comment}
onChange={(e) => setComment(e.target.value)}
disabled={submitting}
rows={2}
className="border-border bg-background text-foreground mt-2 w-full resize-none rounded border px-3 py-2 text-sm focus:border-accent focus:outline-none disabled:opacity-50"
/>

<button
onClick={submitRating}
disabled={selectedRating === 0 || submitting}
className="bg-accent text-background mt-2 w-full rounded py-2 text-xs font-medium transition-opacity disabled:opacity-40"
>
{submitting ? "Signing..." : success ? "Updated!" : "Submit Rating"}
</button>

{error && <p className="mt-2 text-xs text-red-400">{error}</p>}
</div>
) : isConnected ? (
<p className="text-muted mt-3 text-xs">
Hold storyline tokens to rate this story.
</p>
) : null}

{/* Recent ratings */}
{ratings.length > 0 && (
<div className="border-border mt-4 border-t pt-3">
<h3 className="text-muted text-[10px] uppercase tracking-wider">
Recent Ratings
</h3>
<div className="mt-2 space-y-2">
{ratings.slice(0, 10).map((r) => (
<div key={r.id} className="text-xs">
<div className="flex items-center gap-2">
<span className="text-foreground">
{truncateAddress(r.rater_address)}
</span>
<StarDisplay rating={r.rating} />
</div>
{r.comment && (
<p className="text-muted mt-0.5 pl-0.5">{r.comment}</p>
)}
</div>
))}
</div>
</div>
)}
</section>
);
}
Loading