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
16 changes: 6 additions & 10 deletions src/components/RatingSummary.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useQuery } from "@tanstack/react-query";
import { StarDisplay } from "./StarRating";

interface RatingsResponse {
average: number;
Expand All @@ -20,16 +21,11 @@ export function RatingSummary({ storylineId }: { storylineId: number }) {
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 className="inline-flex items-center gap-1">
<StarDisplay rating={data.average} size={14} />
<span className="text-muted text-xs">
{data.average.toFixed(1)} ({data.count})
</span>
</span>
);
}
42 changes: 9 additions & 33 deletions src/components/RatingWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { publicClient } from "../../lib/rpc";
import { erc20Abi } from "../../lib/price";
import type { Address } from "viem";
import { truncateAddress } from "../../lib/utils";
import { StarDisplay, StarInput } from "./StarRating";

interface RatingData {
id: number;
Expand All @@ -30,22 +31,6 @@ interface RatingWidgetProps {
tokenAddress: string;
}

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();
Expand Down Expand Up @@ -149,7 +134,7 @@ export function RatingWidget({ storylineId, tokenAddress }: RatingWidgetProps) {
<h2 className="text-foreground text-sm font-medium">Ratings</h2>
{count > 0 && (
<div className="flex items-center gap-2 text-xs">
<StarDisplay rating={average} />
<StarDisplay rating={average} size={18} />
<span className="text-muted">
{average.toFixed(1)} ({count})
</span>
Expand All @@ -163,21 +148,12 @@ export function RatingWidget({ storylineId, tokenAddress }: RatingWidgetProps) {
<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 className="mt-1">
<StarInput
value={selectedRating}
onChange={setSelectedRating}
disabled={submitting}
/>
</div>

<textarea
Expand Down Expand Up @@ -219,7 +195,7 @@ export function RatingWidget({ storylineId, tokenAddress }: RatingWidgetProps) {
<span className="text-foreground">
{truncateAddress(r.rater_address)}
</span>
<StarDisplay rating={r.rating} />
<StarDisplay rating={r.rating} size={12} />
</div>
{r.comment && (
<p className="text-muted mt-0.5 pl-0.5">{r.comment}</p>
Expand Down
109 changes: 109 additions & 0 deletions src/components/StarRating.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"use client";

import { useId, useState } from "react";

interface StarIconProps {
/** 0 = empty, 1 = full, 0-1 = partial fill */
fill: number;
size: number;
clipId: string;
className?: string;
}

function StarIcon({ fill, size, clipId, className = "" }: StarIconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
className={className}
aria-hidden="true"
>
{fill > 0 && fill < 1 && (
<defs>
<clipPath id={clipId}>
<rect x="0" y="0" width={24 * fill} height="24" />
</clipPath>
</defs>
)}
{/* Empty star outline */}
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="text-muted"
/>
{/* Filled star (full or partial) */}
{fill > 0 && (
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
fill="currentColor"
stroke="currentColor"
strokeWidth="1.5"
className="text-accent"
clipPath={fill < 1 ? `url(#${clipId})` : undefined}
/>
)}
</svg>
);
}

// ---------------------------------------------------------------------------
// Display-only star rating (supports fractional values)
// ---------------------------------------------------------------------------

interface StarDisplayProps {
rating: number;
size?: number;
}

export function StarDisplay({ rating, size = 16 }: StarDisplayProps) {
const baseId = useId();
return (
<span className="inline-flex items-center gap-0.5">
{[1, 2, 3, 4, 5].map((star) => {
const fill = Math.min(1, Math.max(0, rating - (star - 1)));
return <StarIcon key={star} fill={fill} size={size} clipId={`${baseId}-${star}`} />;
})}
</span>
);
}

// ---------------------------------------------------------------------------
// Interactive star rating input
// ---------------------------------------------------------------------------

interface StarInputProps {
value: number;
onChange: (rating: number) => void;
disabled?: boolean;
size?: number;
}

export function StarInput({ value, onChange, disabled = false, size = 24 }: StarInputProps) {
const baseId = useId();
const [hovered, setHovered] = useState(0);
const display = hovered || value;

return (
<span
className="inline-flex items-center gap-0.5"
onMouseLeave={() => setHovered(0)}
>
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
onClick={() => !disabled && onChange(star)}
onMouseEnter={() => !disabled && setHovered(star)}
disabled={disabled}
className="cursor-pointer p-0.5 transition-transform hover:scale-110 disabled:cursor-default disabled:opacity-50"
aria-label={`Rate ${star} star${star > 1 ? "s" : ""}`}
>
<StarIcon fill={star <= display ? 1 : 0} size={size} clipId={`${baseId}-${star}`} />
</button>
))}
</span>
);
}
Loading