f6975e98a9
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>
235 lines
8.1 KiB
TypeScript
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>
|
|
);
|
|
}
|