Files
Epicure/apps/web/components/social/comment-reactions.tsx
T
Arnaud e67145493e fix: rating not shown after refresh, comment reactions not shown until clicked
- The interactive RatingStars widget was always initialized with
  initialScore={0}, ignoring the current user's existing rating.
  Fetch it via a third parallel query and pass it through — the
  rating was persisted correctly all along, just never displayed
  back.
- CommentReactions never fetched its counts on mount, relying solely
  on initialCounts/initialUserReactions props that comments-section
  always passed as empty — so every comment showed zero reactions
  until you clicked one yourself (which returns fresh counts from the
  POST response). Fetch on mount via the existing GET endpoint.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 22:33:36 +02:00

129 lines
4.1 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import { toast } from "sonner";
const REACTIONS: Record<string, string> = {
like: "👍",
love: "❤️",
laugh: "😂",
wow: "😮",
sad: "😢",
fire: "🔥",
};
type Props = {
recipeId: string;
commentId: string;
initialCounts?: Record<string, number>;
initialUserReactions?: string[];
};
export function CommentReactions({ recipeId, commentId, initialCounts = {}, initialUserReactions = [] }: Props) {
const [counts, setCounts] = useState<Record<string, number>>(initialCounts);
const [userReactions, setUserReactions] = useState<string[]>(initialUserReactions);
const [pending, setPending] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
fetch(`/api/v1/recipes/${recipeId}/comments/${commentId}/reactions`)
.then((res) => (res.ok ? res.json() : null))
.then((data: { counts: Record<string, number>; userReactions: string[] } | null) => {
if (!data || cancelled) return;
setCounts(data.counts);
setUserReactions(data.userReactions);
})
.catch(() => {});
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recipeId, commentId]);
async function toggle(type: string) {
if (pending) return;
setPending(type);
// Optimistic update
const hasReacted = userReactions.includes(type);
setUserReactions((prev) =>
hasReacted ? prev.filter((r) => r !== type) : [...prev, type],
);
setCounts((prev) => ({
...prev,
[type]: Math.max(0, (prev[type] ?? 0) + (hasReacted ? -1 : 1)),
}));
try {
const res = await fetch(`/api/v1/recipes/${recipeId}/comments/${commentId}/reactions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type }),
});
if (!res.ok) {
// Revert optimistic update
setUserReactions((prev) =>
hasReacted ? [...prev, type] : prev.filter((r) => r !== type),
);
setCounts((prev) => ({
...prev,
[type]: Math.max(0, (prev[type] ?? 0) + (hasReacted ? 1 : -1)),
}));
if (res.status === 401) {
toast.error("Sign in to react to comments");
} else {
toast.error("Failed to update reaction");
}
return;
}
const data = await res.json() as { counts: Record<string, number>; added: boolean };
setCounts(data.counts);
setUserReactions((prev) => {
if (data.added && !prev.includes(type)) return [...prev, type];
if (!data.added && prev.includes(type)) return prev.filter((r) => r !== type);
return prev;
});
} catch {
// Revert on network error
setUserReactions((prev) =>
hasReacted ? [...prev, type] : prev.filter((r) => r !== type),
);
setCounts((prev) => ({
...prev,
[type]: Math.max(0, (prev[type] ?? 0) + (hasReacted ? 1 : -1)),
}));
toast.error("Failed to update reaction");
} finally {
setPending(null);
}
}
return (
<div className="flex flex-wrap gap-1">
{Object.entries(REACTIONS).map(([type, emoji]) => {
const reacted = userReactions.includes(type);
const cnt = counts[type] ?? 0;
return (
<button
key={type}
onClick={() => void toggle(type)}
disabled={pending === type}
className={[
"inline-flex items-center gap-0.5 rounded-full border px-2 py-0.5 text-xs transition-colors",
reacted
? "border-primary bg-primary/10 text-primary"
: "border-border bg-transparent text-muted-foreground hover:border-primary/50 hover:text-foreground",
pending === type ? "opacity-60 cursor-not-allowed" : "cursor-pointer",
].join(" ")}
aria-label={`${reacted ? "Remove" : "Add"} ${type} reaction`}
aria-pressed={reacted}
>
<span>{emoji}</span>
{cnt > 0 && <span>{cnt}</span>}
</button>
);
})}
</div>
);
}