diff --git a/frontend/components/social/entity-rating.tsx b/frontend/components/social/entity-rating.tsx index 98bf66d..4780f32 100644 --- a/frontend/components/social/entity-rating.tsx +++ b/frontend/components/social/entity-rating.tsx @@ -1,7 +1,7 @@ "use client" 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" interface EntityRatingProps { @@ -14,16 +14,14 @@ export function EntityRating({ entityType, entityId, compact = false }: EntityRa const [userRating, setUserRating] = useState(0) const [averageRating, setAverageRating] = useState(0) const [loading, setLoading] = useState(false) + const [hasRated, setHasRated] = useState(false) useEffect(() => { // Fetch average rating fetch(`${getApiUrl()}/social/ratings/average?${entityType}_id=${entityId}`) - .then(res => res.json()) - .then(data => setAverageRating(data)) - .catch(err => console.error("Failed to fetch avg rating", err)) - - // Fetch user rating (if logged in) - // TODO: Implement fetching user's existing rating + .then(res => res.ok ? res.json() : 0) + .then(data => setAverageRating(data || 0)) + .catch(() => setAverageRating(0)) }, [entityType, entityId]) const handleRate = async (score: number) => { @@ -35,7 +33,7 @@ export function EntityRating({ entityType, entityId, compact = false }: EntityRa setLoading(true) try { - const body: any = { score } + const body: Record = { score } body[`${entityType}_id`] = entityId const res = await fetch(`${getApiUrl()}/social/ratings`, { @@ -51,10 +49,11 @@ export function EntityRating({ entityType, entityId, compact = false }: EntityRa const data = await res.json() setUserRating(data.score) + setHasRated(true) - // Re-fetch average to keep it lively + // Re-fetch average fetch(`${getApiUrl()}/social/ratings/average?${entityType}_id=${entityId}`) - .then(res => res.json()) + .then(res => res.ok ? res.json() : averageRating) .then(setAverageRating) } catch (err) { console.error(err) @@ -66,24 +65,41 @@ export function EntityRating({ entityType, entityId, compact = false }: EntityRa if (compact) { return ( -
- +
{averageRating > 0 && ( - - {averageRating.toFixed(1)} - + )}
) } return ( -
-
- Rating: - Avg: {averageRating.toFixed(1)} +
+
+ Your Rating + {averageRating > 0 && ( + + Community avg: {averageRating.toFixed(1)} + + )}
- + + + + {loading && ( +

+ Submitting... +

+ )} + {hasRated && !loading && ( +

+ ✓ Rating saved! +

+ )}
) } diff --git a/frontend/components/ui/rating-input.tsx b/frontend/components/ui/rating-input.tsx new file mode 100644 index 0000000..a4346bb --- /dev/null +++ b/frontend/components/ui/rating-input.tsx @@ -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(null) + + useEffect(() => { + setLocalValue(value) + }, [value]) + + const handleSliderChange = (e: React.ChangeEvent) => { + const newValue = parseFloat(e.target.value) + setLocalValue(newValue) + onChange?.(newValue) + } + + const handleInputChange = (e: React.ChangeEvent) => { + 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( +
+ + {(isFull || isPartial) && ( +
+ +
+ )} +
+ ) + } + return stars + } + + if (readonly) { + return ( +
+
+ {renderStars()} +
+ + {localValue > 0 ? localValue.toFixed(1) : "—"} + +
+ ) + } + + return ( +
+ {/* Stars Display + Numeric Input */} +
+
+ {renderStars()} +
+ + {/* Numeric Input */} +
+ 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" + )} + /> + /10 +
+
+ + {/* Slider */} + {showSlider && ( +
+ 1 + + 10 +
+ )} +
+ ) +} + +// 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 ( + + + {value.toFixed(1)} + + ) +}