'use client'; import { useEffect, useState } from 'react'; import { ImagePlus, Star, X } from 'lucide-react'; import { getClient } from '@/core/lib/brainerce'; import { checkAuthStatus } from '@/core/lib/auth'; import { Link } from '@/core/lib/navigation'; import { useTranslations } from '@/core/lib/translations'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import type { MyProductReview, ProductReview, ReviewPhotoUpload } from 'brainerce'; interface ReviewFormProps { productId: string; } type PhotoPolicy = MyProductReview['photos']; type Stage = | { kind: 'loading' } | { kind: 'signed_out' } | { kind: 'not_eligible'; reason: MyProductReview['reason'] } | { kind: 'submit'; photos: PhotoPolicy } // `existingPhotos` comes from myImages, which INCLUDES photos still awaiting // the merchant — so a customer editing their review sees the one they already // uploaded instead of a form that looks like it lost it. | { kind: 'edit'; review: ProductReview; photos: PhotoPolicy; existingPhotos: ReviewPhotoUpload[]; }; /** * Single component that decides which UI to render for a customer on a PDP: * - Loading while we check auth state and eligibility * - Signed out -> sign-in CTA * - Signed in but not eligible -> "purchase this product to review" * - Eligible without review -> submit form * - Eligible with existing review -> edit/delete form (rating + body prefilled) */ export function ReviewForm({ productId }: ReviewFormProps) { const [stage, setStage] = useState({ kind: 'loading' }); const t = useTranslations('reviews'); useEffect(() => { let cancelled = false; async function load() { const auth = await checkAuthStatus(); if (cancelled) return; if (!auth.isLoggedIn) { setStage({ kind: 'signed_out' }); return; } try { const me = await getClient().getMyProductReview(productId); if (cancelled) return; if (!me.eligible) { setStage({ kind: 'not_eligible', reason: me.reason }); return; } const existingPhotos: ReviewPhotoUpload[] = me.myImages.map((img) => ({ key: img.assetKey, url: img.thumbnailUrl ?? img.url, width: img.width, height: img.height, })); if (me.myReview) { setStage({ kind: 'edit', review: me.myReview, photos: me.photos, existingPhotos }); return; } setStage({ kind: 'submit', photos: me.photos }); } catch { if (!cancelled) setStage({ kind: 'not_eligible', reason: 'product_not_found' }); } } void load(); return () => { cancelled = true; }; }, [productId]); if (stage.kind === 'loading') { return
{t('loading')}
; } if (stage.kind === 'signed_out') { return (

{t('signIn')} {' '} {t('signInSuffix')}

); } if (stage.kind === 'not_eligible') { return ; } if (stage.kind === 'edit') { return ( setStage({ kind: 'submit', photos: stage.photos })} /> ); } return ; } function NotEligibleMessage({ reason }: { reason: MyProductReview['reason'] }) { const t = useTranslations('reviews'); let message: string; switch (reason) { case 'no_eligible_order': message = t('onlyPurchasers'); break; case 'reviews_disabled': message = t('reviewsDisabled'); break; default: message = t('cannotReview'); } return (

{message}

); } function SubmitReviewBlock({ productId, photos }: { productId: string; photos: PhotoPolicy }) { return ( ); } function EditReviewBlock({ productId, initial, photos, existingPhotos, onDeleted, }: { productId: string; initial: ProductReview; photos: PhotoPolicy; existingPhotos: ReviewPhotoUpload[]; onDeleted: () => void; }) { const [deleting, setDeleting] = useState(false); const t = useTranslations('reviews'); const [deleteError, setDeleteError] = useState(null); async function handleDelete() { if (!window.confirm(t('confirmDelete'))) return; setDeleting(true); setDeleteError(null); try { await getClient().deleteMyProductReview(productId); onDeleted(); window.location.reload(); } catch { setDeleteError(t('deleteFailed')); } finally { setDeleting(false); } } return (

{t('startOver')}

{deleteError && (

{deleteError}

)}
); } /** * The actual form. Same shape for create and edit — only the API method * and the heading differ. Optimistic UX: success state + page reload to * let the server-rendered reviews list pick up the change. */ function ReviewEditor({ productId, mode, initialRating, initialBody, photos, initialPhotos, }: { productId: string; mode: 'create' | 'edit'; initialRating: number; initialBody: string; photos: PhotoPolicy; initialPhotos: ReviewPhotoUpload[]; }) { const [rating, setRating] = useState(initialRating); const t = useTranslations('reviews'); const [body, setBody] = useState(initialBody); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(false); const [uploads, setUploads] = useState(initialPhotos); const [uploading, setUploading] = useState(false); const roomLeft = photos.maxPerReview - uploads.length; async function handleFiles(event: React.ChangeEvent) { const picked = Array.from(event.target.files ?? []); // Reset the input so picking the same file twice still fires a change event. event.target.value = ''; if (picked.length === 0) return; const tooBig = picked.some((file) => file.size > photos.maxBytes); const accepted = picked.filter((file) => file.size <= photos.maxBytes).slice(0, roomLeft); if (tooBig) setError(t('photoTooLarge')); if (accepted.length === 0) return; setUploading(true); try { const done = await Promise.all( accepted.map((file) => getClient().uploadReviewPhoto(productId, file)) ); setUploads((prev) => [...prev, ...done]); if (!tooBig) setError(null); } catch { setError(t('photoUploadFailed')); } finally { setUploading(false); } } async function handleSubmit(event: React.FormEvent) { event.preventDefault(); setError(null); setSubmitting(true); try { const client = getClient(); // imageKeys REPLACES the photo set, so send every key we still want. Keys, // never urls — the url on an upload is only good for the local preview. const input = { rating, body: body.trim() || undefined, ...(photos.enabled ? { imageKeys: uploads.map((upload) => upload.key) } : {}), }; if (mode === 'edit') { await client.updateMyProductReview(productId, input); } else { await client.submitProductReview(productId, input); } setSuccess(true); setTimeout(() => window.location.reload(), 1200); } catch (err) { const status = (err as { status?: number })?.status; if (status === 409) { setError(t('alreadySubmitted')); } else if (status === 403) { setError(t('onlyPurchasers')); } else if (status === 429) { setError(t('tooMany')); } else { setError(mode === 'edit' ? t('updateFailed') : t('submitFailed')); } } finally { setSubmitting(false); } } if (success) { return (

{mode === 'edit' ? t('thanksUpdated') : t('thanksSubmitted')}

); } return (

{mode === 'edit' ? t('editYourReview') : t('writeReview')}

{t('yourRating')}
{[1, 2, 3, 4, 5].map((n) => ( ))}