feat: Better decimal rating input with slider

- New RatingInput component with slider + numeric input
- Visual stars show partial fill for decimals
- Gradient slider (red → yellow → green) for intuitive scoring
- RatingBadge component for compact display
- Updated EntityRating to use new components
This commit is contained in:
fullsizemalt 2025-12-21 18:06:15 -08:00
parent d443eabd69
commit b973b9e270
2 changed files with 228 additions and 20 deletions

View file

@ -1,7 +1,7 @@
"use client" "use client"
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
import { StarRating } from "@/components/ui/star-rating" import { RatingInput, RatingBadge } from "@/components/ui/rating-input"
import { getApiUrl } from "@/lib/api-config" import { getApiUrl } from "@/lib/api-config"
interface EntityRatingProps { interface EntityRatingProps {
@ -14,16 +14,14 @@ export function EntityRating({ entityType, entityId, compact = false }: EntityRa
const [userRating, setUserRating] = useState(0) const [userRating, setUserRating] = useState(0)
const [averageRating, setAverageRating] = useState(0) const [averageRating, setAverageRating] = useState(0)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [hasRated, setHasRated] = useState(false)
useEffect(() => { useEffect(() => {
// Fetch average rating // Fetch average rating
fetch(`${getApiUrl()}/social/ratings/average?${entityType}_id=${entityId}`) fetch(`${getApiUrl()}/social/ratings/average?${entityType}_id=${entityId}`)
.then(res => res.json()) .then(res => res.ok ? res.json() : 0)
.then(data => setAverageRating(data)) .then(data => setAverageRating(data || 0))
.catch(err => console.error("Failed to fetch avg rating", err)) .catch(() => setAverageRating(0))
// Fetch user rating (if logged in)
// TODO: Implement fetching user's existing rating
}, [entityType, entityId]) }, [entityType, entityId])
const handleRate = async (score: number) => { const handleRate = async (score: number) => {
@ -35,7 +33,7 @@ export function EntityRating({ entityType, entityId, compact = false }: EntityRa
setLoading(true) setLoading(true)
try { try {
const body: any = { score } const body: Record<string, unknown> = { score }
body[`${entityType}_id`] = entityId body[`${entityType}_id`] = entityId
const res = await fetch(`${getApiUrl()}/social/ratings`, { const res = await fetch(`${getApiUrl()}/social/ratings`, {
@ -51,10 +49,11 @@ export function EntityRating({ entityType, entityId, compact = false }: EntityRa
const data = await res.json() const data = await res.json()
setUserRating(data.score) setUserRating(data.score)
setHasRated(true)
// Re-fetch average to keep it lively // Re-fetch average
fetch(`${getApiUrl()}/social/ratings/average?${entityType}_id=${entityId}`) fetch(`${getApiUrl()}/social/ratings/average?${entityType}_id=${entityId}`)
.then(res => res.json()) .then(res => res.ok ? res.json() : averageRating)
.then(setAverageRating) .then(setAverageRating)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
@ -66,24 +65,41 @@ export function EntityRating({ entityType, entityId, compact = false }: EntityRa
if (compact) { if (compact) {
return ( return (
<div className="flex items-center gap-1.5 opacity-80 hover:opacity-100 transition-opacity"> <div className="flex items-center gap-2">
<StarRating value={userRating} onChange={handleRate} size="sm" />
{averageRating > 0 && ( {averageRating > 0 && (
<span className="text-[10px] text-muted-foreground font-mono"> <RatingBadge value={averageRating} />
{averageRating.toFixed(1)}
</span>
)} )}
</div> </div>
) )
} }
return ( return (
<div className="flex items-center gap-2 border-l pl-4"> <div className="border rounded-lg p-4 bg-card">
<div className="flex flex-col"> <div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium">Rating:</span> <span className="text-sm font-medium">Your Rating</span>
<span className="text-xs text-muted-foreground">Avg: {averageRating.toFixed(1)}</span> {averageRating > 0 && (
<span className="text-xs text-muted-foreground">
Community avg: <span className="font-medium">{averageRating.toFixed(1)}</span>
</span>
)}
</div> </div>
<StarRating value={userRating} onChange={handleRate} />
<RatingInput
value={userRating}
onChange={handleRate}
showSlider={true}
/>
{loading && (
<p className="text-xs text-muted-foreground mt-2 animate-pulse">
Submitting...
</p>
)}
{hasRated && !loading && (
<p className="text-xs text-green-600 mt-2">
Rating saved!
</p>
)}
</div> </div>
) )
} }

View file

@ -0,0 +1,192 @@
"use client"
import { useState, useRef, useEffect } from "react"
import { Star } from "lucide-react"
import { cn } from "@/lib/utils"
interface RatingInputProps {
value: number
onChange?: (value: number) => void
readonly?: boolean
className?: string
size?: "sm" | "md" | "lg"
showSlider?: boolean
}
export function RatingInput({
value,
onChange,
readonly = false,
className,
size = "md",
showSlider = true
}: RatingInputProps) {
const [localValue, setLocalValue] = useState(value)
const [isEditing, setIsEditing] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
setLocalValue(value)
}, [value])
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = parseFloat(e.target.value)
setLocalValue(newValue)
onChange?.(newValue)
}
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value
if (raw === "") {
setLocalValue(0)
return
}
const newValue = parseFloat(raw)
if (!isNaN(newValue)) {
const clamped = Math.min(10, Math.max(1, newValue))
const rounded = Math.round(clamped * 10) / 10
setLocalValue(rounded)
}
}
const handleInputBlur = () => {
setIsEditing(false)
if (localValue > 0) {
onChange?.(localValue)
}
}
const handleInputKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
inputRef.current?.blur()
}
}
// Visual star representation (readonly display)
const renderStars = () => {
const stars = []
const fullStars = Math.floor(localValue)
const partialFill = (localValue - fullStars) * 100
for (let i = 0; i < 10; i++) {
const isFull = i < fullStars
const isPartial = i === fullStars && partialFill > 0
stars.push(
<div key={i} className="relative">
<Star className={cn(
size === "sm" ? "h-3 w-3" : size === "lg" ? "h-5 w-5" : "h-4 w-4",
"fill-muted text-muted-foreground/50"
)} />
{(isFull || isPartial) && (
<div
className="absolute inset-0 overflow-hidden"
style={{
clipPath: `inset(0 ${isFull ? 0 : 100 - partialFill}% 0 0)`
}}
>
<Star className={cn(
size === "sm" ? "h-3 w-3" : size === "lg" ? "h-5 w-5" : "h-4 w-4",
"fill-yellow-500 text-yellow-500"
)} />
</div>
)}
</div>
)
}
return stars
}
if (readonly) {
return (
<div className={cn("flex items-center gap-1", className)}>
<div className="flex gap-0.5">
{renderStars()}
</div>
<span className="ml-1.5 text-sm font-medium">
{localValue > 0 ? localValue.toFixed(1) : "—"}
</span>
</div>
)
}
return (
<div className={cn("flex flex-col gap-2", className)}>
{/* Stars Display + Numeric Input */}
<div className="flex items-center gap-3">
<div className="flex gap-0.5">
{renderStars()}
</div>
{/* Numeric Input */}
<div className="flex items-center gap-1">
<input
ref={inputRef}
type="number"
min="1"
max="10"
step="0.1"
value={isEditing ? localValue || "" : localValue.toFixed(1)}
onChange={handleInputChange}
onFocus={() => setIsEditing(true)}
onBlur={handleInputBlur}
onKeyDown={handleInputKeyDown}
className={cn(
"w-14 h-8 px-2 text-center font-mono font-bold rounded-md",
"border bg-background text-foreground",
"focus:outline-none focus:ring-2 focus:ring-primary",
size === "lg" ? "text-lg" : "text-sm"
)}
/>
<span className="text-muted-foreground text-sm">/10</span>
</div>
</div>
{/* Slider */}
{showSlider && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-4">1</span>
<input
type="range"
min="1"
max="10"
step="0.1"
value={localValue || 1}
onChange={handleSliderChange}
className={cn(
"flex-1 h-2 rounded-full appearance-none cursor-pointer",
"bg-gradient-to-r from-red-500 via-yellow-500 to-green-500",
"[&::-webkit-slider-thumb]:appearance-none",
"[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4",
"[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full",
"[&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-primary",
"[&::-webkit-slider-thumb]:shadow-md [&::-webkit-slider-thumb]:cursor-grab",
"[&::-webkit-slider-thumb]:active:cursor-grabbing"
)}
/>
<span className="text-xs text-muted-foreground w-4">10</span>
</div>
)}
</div>
)
}
// Compact version for inline use
export function RatingBadge({ value, className }: { value: number; className?: string }) {
const getColor = () => {
if (value >= 8) return "bg-green-500/10 text-green-600 border-green-500/20"
if (value >= 6) return "bg-yellow-500/10 text-yellow-600 border-yellow-500/20"
return "bg-red-500/10 text-red-600 border-red-500/20"
}
return (
<span className={cn(
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full border text-xs font-medium",
getColor(),
className
)}>
<Star className="h-3 w-3 fill-current" />
{value.toFixed(1)}
</span>
)
}