Files
Epicure/apps/web/components/social/cooked-it-review.tsx
T
Arnaud f6975e98a9 fix: recipe/storage tier limits are lifetime totals, not monthly (v0.58.0)
Recipe count and storage usage shared the monthly user_usage bucket with
AI calls, so both incorrectly reset every month even though nothing was
deleted. Only AI calls should be monthly.

Recipe count and storage are now derived live from real data (recipes,
recipe/review photos, avatar) instead of a counter — deleting a photo or
recipe is itself the "decrement", no extra wiring needed. Storage size is
tracked per-row (recipePhotos.sizeMb, ratings.photoSizeMb, users.avatarSizeMb)
and threaded through presign -> upload -> save.

Also fixes avatar removal silently no-oping (client sent a field the PATCH
schema didn't recognize).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 20:32:31 +02:00

235 lines
8.1 KiB
TypeScript

"use client";
import { useState, useEffect, useRef } from "react";
import Image from "next/image";
import { useTranslations, useLocale } from "next-intl";
import { Star, Camera, X, ChefHat } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { getPublicUrl } from "@/lib/storage";
import { uploadToPresignedPost } from "@/lib/upload-client";
import { cn } from "@/lib/utils";
type Review = {
id: string;
score: number;
reviewText: string | null;
photoKey: string | null;
createdAt: string;
user: { id: string; name: string; username: string | null; avatarUrl: string | null };
};
function Stars({
value,
hovered,
onHover,
onPick,
}: {
value: number;
hovered: number;
onHover: (v: number) => void;
onPick: (v: number) => void;
}) {
const display = hovered || value;
return (
<div className="flex items-center gap-0.5">
{[1, 2, 3, 4, 5].map((i) => (
<button
key={i}
type="button"
onMouseEnter={() => onHover(i)}
onMouseLeave={() => onHover(0)}
onClick={() => onPick(i)}
className="hover:scale-110 transition-transform"
>
<Star className={cn("h-6 w-6", i <= display ? "fill-yellow-400 text-yellow-400" : "text-muted-foreground/30")} />
</button>
))}
</div>
);
}
export function CookedItReview({
recipeId,
initialScore = 0,
initialText = "",
initialPhotoKey = null,
}: {
recipeId: string;
initialScore?: number;
initialText?: string;
initialPhotoKey?: string | null;
}) {
const t = useTranslations("social");
const locale = useLocale();
const [score, setScore] = useState(initialScore);
const [hovered, setHovered] = useState(0);
const [text, setText] = useState(initialText);
const [photoKey, setPhotoKey] = useState<string | null>(initialPhotoKey);
const [photoSizeMb, setPhotoSizeMb] = useState(0);
const [preview, setPreview] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [saving, setSaving] = useState(false);
const [reviews, setReviews] = useState<Review[]>([]);
const [loadingReviews, setLoadingReviews] = useState(true);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
fetch(`/api/v1/recipes/${recipeId}/reviews`)
.then((res) => (res.ok ? res.json() : null))
.then((data: { data: Review[] } | null) => setReviews(data?.data ?? []))
.finally(() => setLoadingReviews(false));
}, [recipeId]);
async function handlePhoto(file: File) {
setUploading(true);
try {
const res = await fetch("/api/v1/upload/presign", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ recipeId, contentType: file.type, purpose: "review", fileSize: file.size }),
});
if (!res.ok) {
toast.error(t("reviewPhotoFailed"));
return;
}
const { url, fields, key, sizeMb } = (await res.json()) as { url: string; fields: Record<string, string>; key: string; sizeMb: number };
const uploaded = await uploadToPresignedPost(url, fields, file);
if (!uploaded) {
toast.error(t("reviewPhotoFailed"));
return;
}
setPhotoKey(key);
setPhotoSizeMb(sizeMb);
setPreview(URL.createObjectURL(file));
} finally {
setUploading(false);
}
}
async function submit() {
if (score < 1) return;
setSaving(true);
try {
const res = await fetch(`/api/v1/recipes/${recipeId}/rate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ score, reviewText: text.trim() || undefined, photoKey: photoKey ?? undefined, photoSizeMb }),
});
if (!res.ok) {
const err = (await res.json()) as { error?: string };
toast.error(err.error ?? t("ratingFailed"));
return;
}
toast.success(t("ratingSaved"));
const listRes = await fetch(`/api/v1/recipes/${recipeId}/reviews`);
if (listRes.ok) {
const data = (await listRes.json()) as { data: Review[] };
setReviews(data.data);
}
} finally {
setSaving(false);
}
}
return (
<div className="space-y-6">
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-center gap-2 text-sm font-medium">
<ChefHat className="h-4 w-4 text-primary" />
{t("cookedItPrompt")}
</div>
<Stars value={score} hovered={hovered} onHover={setHovered} onPick={setScore} />
<Textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={t("reviewTextPlaceholder")}
maxLength={2000}
rows={3}
/>
<div className="flex items-center gap-3">
{(preview || photoKey) && (
<div className="relative h-16 w-16">
<Image
src={preview ?? getPublicUrl(photoKey!)}
unoptimized
alt="Your cooked-it photo"
fill
className="rounded-lg object-cover border"
/>
<button
type="button"
onClick={() => { setPhotoKey(null); setPreview(null); setPhotoSizeMb(0); }}
className="absolute -top-1.5 -right-1.5 rounded-full bg-background border p-0.5"
>
<X className="h-3 w-3" />
</button>
</div>
)}
<Button
type="button"
variant="outline"
size="sm"
disabled={uploading}
onClick={() => inputRef.current?.click()}
>
<Camera className="h-4 w-4 mr-1.5" />
{uploading ? t("reviewPhotoUploading") : t("reviewPhotoAdd")}
</Button>
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/avif"
className="hidden"
onChange={(e) => e.target.files?.[0] && handlePhoto(e.target.files[0])}
/>
<Button type="button" size="sm" className="ml-auto" disabled={score < 1 || saving} onClick={submit}>
{saving ? t("reviewSubmitting") : t("reviewSubmit")}
</Button>
</div>
</div>
{!loadingReviews && reviews.length > 0 && (
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground">{t("reviewsTitle", { count: reviews.length })}</h3>
{reviews.map((r) => (
<div key={r.id} className="flex gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={r.user.avatarUrl ?? undefined} alt={r.user.name} />
<AvatarFallback>{r.user.name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-1.5">
<div className="flex items-center gap-2 text-sm">
<span className="font-medium">{r.user.name}</span>
<div className="flex items-center">
{[1, 2, 3, 4, 5].map((i) => (
<Star key={i} className={cn("h-3 w-3", i <= r.score ? "fill-yellow-400 text-yellow-400" : "text-muted-foreground/30")} />
))}
</div>
<span className="text-xs text-muted-foreground">
{new Date(r.createdAt).toLocaleDateString(locale)}
</span>
</div>
{r.reviewText && <p className="text-sm text-muted-foreground">{r.reviewText}</p>}
{r.photoKey && (
<div className="relative h-32 w-32">
<Image
src={getPublicUrl(r.photoKey)}
unoptimized
alt={`${r.user.name}'s cooked-it photo`}
fill
className="rounded-lg object-cover border"
/>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}