"use client"; import { useState, useEffect, useRef, useCallback } from "react"; import Link from "next/link"; import Image from "next/image"; import { ReviewPlayer, type ReviewPlayerRef } from "@/components/player/ReviewPlayer"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from "@/components/ui/dialog"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { ScrollArea } from "@/components/ui/scroll-area"; import { useToast } from "@/components/ui/use-toast"; import { cn, frameToTimecode, getInitials } from "@/lib/utils"; import { Film, ArrowLeft, CheckCircle2, XCircle, AlertCircle, MessageSquare, Send, Clock, ChevronLeft, ChevronRight, Copy, Check, } from "lucide-react"; import { useReviewStore } from "@/hooks/use-review-player"; import { ReviewPasswordGate } from "@/components/clients/ReviewPasswordGate"; interface Comment { id: string; frameNumber: number; timestamp: number; text: string; isResolved: boolean; createdAt: string; author: { id: string; name: string | null; image: string | null; email: string }; replies: { id: string; text: string; createdAt: string; author: { name: string | null } }[]; } interface Version { id: string; versionNumber: number; fileUrl: string; fps: number; duration: number | null; approvalStatus: string; notes: string | null; shot?: { id: string; shotCode: string; project: { id: string; name: string; code: string }; } | null; task?: { id: string; title: string; type: string; project: { id: string; name: string; code: string }; shot?: { shotCode: string } | null; asset?: { assetCode: string; name: string } | null; } | null; } const APPROVAL_STATUS_STYLES: Record = { PENDING_REVIEW: "bg-amber-500/10 text-amber-400 border-amber-500/20", APPROVED: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", REJECTED: "bg-red-500/10 text-red-400 border-red-500/20", NEEDS_CHANGES: "bg-orange-500/10 text-orange-400 border-orange-500/20", }; const QUEUE_KEY = 'client-portal-queue'; export default function ClientReviewPage({ params, }: { params: Promise<{ token: string; versionId: string }>; }) { const [token, setToken] = useState(""); const [version, setVersion] = useState(null); const [comments, setComments] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [requiresPassword, setRequiresPassword] = useState(false); const [versionId, setVersionId] = useState(""); const currentFrame = useReviewStore((s) => s.currentFrame); // Comment form state const [commentText, setCommentText] = useState(""); const [commentFrame, setCommentFrame] = useState(null); const [submittingComment, setSubmittingComment] = useState(false); // Approval dialog const [approvalDialog, setApprovalDialog] = useState<{ open: boolean; status: "APPROVED" | "REJECTED" | "NEEDS_CHANGES" | null; }>({ open: false, status: null }); const [approvalNotes, setApprovalNotes] = useState(""); const [submittingApproval, setSubmittingApproval] = useState(false); const [currentApprovalStatus, setCurrentApprovalStatus] = useState("PENDING_REVIEW"); const [nextReview, setNextReview] = useState<{ versionId: string; label: string } | null>(null); const [prevReview, setPrevReview] = useState<{ versionId: string; label: string } | null>(null); const [copiedTaskName, setCopiedTaskName] = useState(false); const playerRef = useRef(null); const { toast } = useToast(); useEffect(() => { params.then(({ token: t, versionId: vId }) => { setToken(t); setVersionId(vId); loadVersion(t, vId); }); }, [params]); useEffect(() => { if (!versionId) return; try { const queueJson = sessionStorage.getItem(QUEUE_KEY); if (!queueJson) { setNextReview(null); setPrevReview(null); return; } const queue: Array<{ versionId: string; label: string }> = JSON.parse(queueJson); const idx = queue.findIndex((item) => item.versionId === versionId); setNextReview(idx >= 0 && idx < queue.length - 1 ? queue[idx + 1] : null); setPrevReview(idx > 0 ? queue[idx - 1] : null); } catch { setNextReview(null); setPrevReview(null); } }, [versionId]); const loadVersion = (t: string, vId: string) => { setLoading(true); setError(null); fetch(`/api/client/${t}/versions/${vId}`) .then(async (r) => { if (r.status === 401) { const data = await r.json().catch(() => ({})); if (data.requiresPassword) { setRequiresPassword(true); return null; } } if (!r.ok) throw new Error("Invalid or expired review link"); return r.json(); }) .then((data) => { if (!data) return; setVersion(data.version); setComments(data.comments); setCurrentApprovalStatus(data.version.approvalStatus); setRequiresPassword(false); }) .catch((e) => setError(e.message)) .finally(() => setLoading(false)); }; const refreshComments = useCallback(async (t: string, vId: string) => { const res = await fetch(`/api/client/${t}/versions/${vId}`); if (res.ok) { const data = await res.json(); setComments(data.comments); } }, []); const handlePlayerAddComment = useCallback((frameNumber: number, _timestamp: number) => { playerRef.current?.pause(); setCommentFrame(frameNumber); }, []); const handleSubmitComment = async () => { if (!commentText.trim() || commentFrame === null || !version) return; setSubmittingComment(true); try { const res = await fetch(`/api/client/${token}/comment`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ versionId: version.id, frameNumber: commentFrame, timestamp: commentFrame / version.fps, text: commentText.trim(), }), }); if (!res.ok) throw new Error("Failed to post comment"); setCommentText(""); setCommentFrame(null); await refreshComments(token, version.id); toast({ title: "Comment added" }); } catch { toast({ title: "Failed to add comment", variant: "destructive" }); } finally { setSubmittingComment(false); } }; const handleSubmitApproval = async () => { if (!approvalDialog.status || !version) return; setSubmittingApproval(true); try { const res = await fetch(`/api/client/${token}/approve`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ versionId: version.id, status: approvalDialog.status, notes: approvalNotes, }), }); if (!res.ok) throw new Error("Failed to submit decision"); setCurrentApprovalStatus(approvalDialog.status); setApprovalDialog({ open: false, status: null }); setApprovalNotes(""); toast({ title: approvalDialog.status === "APPROVED" ? "Shot approved!" : approvalDialog.status === "REJECTED" ? "Version rejected" : "Changes requested", description: approvalDialog.status === "APPROVED" ? "The shot has been marked as client approved." : undefined, }); } catch { toast({ title: "Failed to submit decision", variant: "destructive" }); } finally { setSubmittingApproval(false); } }; if (loading) { return (
); } if (requiresPassword) { return ( loadVersion(token, versionId)} /> ); } if (error || !version) { return (

Version unavailable

{error}

); } const approvalStyle = APPROVAL_STATUS_STYLES[currentApprovalStatus] ?? ""; return (
{/* Header */}
{/* Row 1: back + shot info + status */}
All Shots
Logo
{(() => { const project = version.shot?.project ?? version.task?.project; const contextCode = version.shot?.shotCode ?? version.task?.shot?.shotCode ?? version.task?.asset?.assetCode ?? null; const taskName = version.task?.title ?? null; return ( <> {project?.code} / {contextCode && ( <> {contextCode} )} {taskName && ( <> / {taskName} )} ); })()}
{/* Row 2 on mobile / inline on desktop: decision buttons */}
{prevReview && ( <> Prev {prevReview.label}
)} {nextReview && ( <>
{nextReview.label} Next )}
{/* Main: player + comments — stacked on mobile, side-by-side on md+ */}
{/* Player */}
{/* Comment panel */}

Notes {comments.length > 0 && ( ({comments.length}) )}

{comments.length === 0 ? (

No notes yet.

Pause the video and add a note at any frame.

) : (
{comments.map((comment) => (
playerRef.current?.seekToFrame(comment.frameNumber)} title={`Jump to frame ${comment.frameNumber}`} >
{getInitials(comment.author.name ?? comment.author.email)} {comment.author.name ?? comment.author.email.split("@")[0]} {frameToTimecode(comment.frameNumber, version.fps)}

{comment.text}

{comment.replies.map((reply) => (
{reply.author.name ?? "Reply"}

{reply.text}

))}
))}
)}
{/* Add note */} {commentFrame !== null ? (

Frame {commentFrame} · {frameToTimecode(commentFrame, version.fps)}