'use client'; import { useState, useEffect } from 'react'; import Link from 'next/link'; import Image from 'next/image'; import { Film, CheckCircle2, XCircle, AlertCircle, Clock, ChevronRight, Package, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Montserrat } from 'next/font/google'; const montserrat = Montserrat({ subsets: ['latin'], weight: ['200', '500', '600'], }); interface ClientVersion { id: string; versionNumber: number; approvalStatus: string; fps: number; duration: number | null; thumbnailUrl: string | null; notes: string | null; createdAt: string; } interface ClientTask { id: string; title: string; type: string; status: string; versions: ClientVersion[]; } interface ClientShot { id: string; shotCode: string; sequence: string | null; description: string | null; status: string; thumbnailUrl: string | null; tasks: ClientTask[]; } interface AssetTask extends ClientTask { asset?: { id: string; assetCode: string; name: string } | null; } interface Project { id: string; name: string; code: string; description: string | null; status: string; } const APPROVAL_STYLES: Record< string, { label: string; className: string; Icon: React.ElementType } > = { PENDING_REVIEW: { label: 'Awaiting Review', className: 'bg-amber-500/10 text-amber-400 border-amber-500/20', Icon: Clock, }, APPROVED: { label: 'Approved', className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20', Icon: CheckCircle2, }, REJECTED: { label: 'Rejected', className: 'bg-red-500/10 text-red-400 border-red-500/20', Icon: XCircle, }, NEEDS_CHANGES: { label: 'Needs Changes', className: 'bg-orange-500/10 text-orange-400 border-orange-500/20', Icon: AlertCircle, }, }; const TASK_TYPE_LABELS: Record = { TRACK: 'Tracking', ROTO: 'Roto', KEY: 'Keying', COMP: 'Comp', FX: 'FX', LIGHTING: 'Lighting', RENDER: 'Render', ANIMATION: 'Animation', MODEL: 'Model', TEXTURE: 'Texture', RIG: 'Rig', LOOKDEV: 'Lookdev', GENERAL: 'Task', }; function getLatestVersion(task: ClientTask): ClientVersion | undefined { return task.versions[0]; } export default function ClientPortalPage({ params, }: { params: Promise<{ token: string }>; }) { const [token, setToken] = useState(''); const [project, setProject] = useState(null); const [shots, setShots] = useState([]); const [assetTasks, setAssetTasks] = useState([]); const [sessionLabel, setSessionLabel] = useState(''); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { params.then(({ token: t }) => { setToken(t); fetch(`/api/client/${t}/project`) .then((r) => { if (!r.ok) throw new Error('Invalid or expired review link'); return r.json(); }) .then((data) => { setProject(data.project); setShots(data.shots ?? []); setAssetTasks(data.assetTasks ?? []); setSessionLabel(data.sessionLabel ?? ''); }) .catch((e) => setError(e.message)) .finally(() => setLoading(false)); }); }, [params]); if (loading) { return (
); } if (error || !project) { return (

Review link unavailable

{error ?? 'This review link has expired or is no longer active. Please request a new link from your studio contact.'}

); } const allTasks = [...shots.flatMap((s) => s.tasks), ...assetTasks]; const totalTasks = allTasks.length; const approved = allTasks.filter( (t) => getLatestVersion(t)?.approvalStatus === 'APPROVED', ).length; const needsChanges = allTasks.filter((t) => ['REJECTED', 'NEEDS_CHANGES'].includes( getLatestVersion(t)?.approvalStatus ?? '', ), ).length; const pending = totalTasks - approved - needsChanges; const shotsBySequence = shots.reduce>( (acc, shot) => { const seq = shot.sequence ?? 'Shots'; if (!acc[seq]) acc[seq] = []; acc[seq].push(shot); return acc; }, {}, ); return (
{/* Logo */}
Logo
TWO TALES vfx review
{sessionLabel && ( {sessionLabel} )}

{project.code}

{project.name}

{project.description && (

{project.description}

)}

{totalTasks}

Items

{approved}

Approved

{pending}

Awaiting Review

{needsChanges > 0 && (

{needsChanges}

Needs Changes

)}
{Object.entries(shotsBySequence).map(([sequence, seqShots]) => (

{sequence}

{seqShots.map((shot) => (
{shot.thumbnailUrl && (
{shot.shotCode}
)}

{shot.shotCode} {shot.description && ( {shot.description} )}

{shot.tasks.map((task) => { const ver = getLatestVersion(task); const approvalKey = ver?.approvalStatus ?? 'PENDING_REVIEW'; const approval = APPROVAL_STYLES[approvalKey] ?? APPROVAL_STYLES.PENDING_REVIEW; const ApprovalIcon = approval.Icon; return (

{task.title}

{TASK_TYPE_LABELS[task.type] ?? task.type}

{ver?.notes && (

“{ver.notes}”

)}
{ver ? (
v{String(ver.versionNumber).padStart(3, '0')} {approval.label}
) : ( No versions yet )} ); })}
))}
))} {assetTasks.length > 0 && (

Assets

{assetTasks.map((task) => { const ver = getLatestVersion(task); const approvalKey = ver?.approvalStatus ?? 'PENDING_REVIEW'; const approval = APPROVAL_STYLES[approvalKey] ?? APPROVAL_STYLES.PENDING_REVIEW; const ApprovalIcon = approval.Icon; return (

{task.asset?.assetCode ?? TASK_TYPE_LABELS[task.type]}

{TASK_TYPE_LABELS[task.type] ?? task.type}

{task.title}

{ver?.notes && (

“{ver.notes}”

)}
{ver ? (
v{String(ver.versionNumber).padStart(3, '0')} {approval.label}
) : ( No versions yet )} ); })}
)} {totalTasks === 0 && (

No items have been shared for review yet.

)}
); }