11'use client' ;
22
33import { useCallback , useEffect , useMemo , useState } from 'react' ;
4- import { CheckCircle2 , Clock , AlertCircle , ShieldCheck } from 'lucide-react' ;
4+ import { CheckCircle2 , Clock , AlertCircle , Shield , ShieldCheck , Loader2 } from 'lucide-react' ;
55import { useTranslation } from 'react-i18next' ;
66import { useEvents } from '@/hooks/useEvents' ;
77import { useTaskChainEvents } from '@/hooks/useTaskChainEvents' ;
88import { useChainState } from '@/hooks/useChainState' ;
9- import { useState , useCallback } from 'react' ;
10- import { CheckCircle2 , Clock , AlertCircle , ShieldCheck , Loader2 } from 'lucide-react' ;
11- import { useMemo , useState , useCallback } from 'react' ;
12- import { CheckCircle2 , Clock , AlertCircle , Shield , Loader2 } from 'lucide-react' ;
13- import { useTranslation } from 'react-i18next' ;
14- import TaskFilters from './TaskFilters' ;
159import { useTaskFilters } from '@/hooks/useTaskFilters' ;
10+ import TaskFilters from './TaskFilters' ;
1611import { useToast } from '@/components/Toast' ;
1712
1813export interface TaskCardTask {
@@ -90,31 +85,6 @@ function statusSortWeight(task: TaskCardTask): number {
9085 return 2 ;
9186}
9287
93- export default function TaskCard ( {
94- tasks : initialTasks = mockTasks ,
95- pollIntervalMs,
96- } : TaskCardProps ) {
97- const { t } = useTranslation ( ) ;
98- const { emit } = useEvents ( ) ;
99- const { forceSync } = useChainState ( { cacheKey : 'tasks' } ) ;
100- const { lastEvent } = useTaskChainEvents ( { intervalMs : pollIntervalMs } ) ;
101-
102- const [ taskList , setTaskList ] = useState < TaskCardTask [ ] > ( initialTasks ) ;
103- const [ completedIds , setCompletedIds ] = useState < Set < string > > (
104- ( ) => new Set ( initialTasks . filter ( ( t ) => t . is_done || t . status === 'completed' ) . map ( ( t ) => t . id ) ) ,
105- ) ;
106- const [ animatingId , setAnimatingId ] = useState < string | null > ( null ) ;
107-
108- const sortedTasks = useMemo (
109- ( ) => [ ...taskList ] . sort ( ( a , b ) => statusSortWeight ( a ) - statusSortWeight ( b ) ) ,
110- [ taskList ] ,
111- ) ;
112-
113- useEffect ( ( ) => {
114- if ( initialTasks !== mockTasks ) {
115- setTaskList ( initialTasks ) ;
116- }
117- } , [ initialTasks ] ) ;
11888function delay ( ms : number ) : Promise < void > {
11989 return new Promise ( ( r ) => setTimeout ( r , ms ) ) ;
12090}
@@ -134,18 +104,67 @@ export function matchesFilter(task: TaskCardTask, status: string, priority: stri
134104 return true ;
135105}
136106
137- export default function TaskCard ( { tasks = mockTasks , submitVote = defaultSubmitVote } : TaskCardProps ) {
107+ export default function TaskCard ( {
108+ tasks : initialTasks = mockTasks ,
109+ pollIntervalMs,
110+ submitVote = defaultSubmitVote ,
111+ } : TaskCardProps ) {
138112 const { t } = useTranslation ( ) ;
113+ const { emit } = useEvents ( ) ;
114+ const { forceSync } = useChainState ( { cacheKey : 'tasks' } ) ;
115+ const { lastEvent } = useTaskChainEvents ( { intervalMs : pollIntervalMs } ) ;
139116 const { filters } = useTaskFilters ( ) ;
140117 const { showToast } = useToast ( ) ;
141118
119+ const [ taskList , setTaskList ] = useState < TaskCardTask [ ] > ( initialTasks ) ;
120+ const [ animatingId , setAnimatingId ] = useState < string | null > ( null ) ;
142121 const [ pendingVotes , setPendingVotes ] = useState < Record < string , boolean > > ( { } ) ;
143122 const [ optimisticVotes , setOptimisticVotes ] = useState < Record < string , number > > ( { } ) ;
144123 const [ optimisticStatus , setOptimisticStatus ] = useState < Record < string , TaskCardTask [ 'status' ] > > ( { } ) ;
145124
125+ useEffect ( ( ) => {
126+ if ( initialTasks !== mockTasks ) {
127+ setTaskList ( initialTasks ) ;
128+ }
129+ } , [ initialTasks ] ) ;
130+
131+ // Auto-verify: when the chain-event poller reports a task was verified
132+ // on-chain, mark it completed locally and pulse a completion animation.
133+ useEffect ( ( ) => {
134+ if ( ! lastEvent ) return ;
135+ const { taskId, taskTitle } = lastEvent ;
136+
137+ setTaskList ( ( prev ) => {
138+ const existing = prev . find ( ( task ) => task . id === taskId ) ;
139+ if ( ! existing || existing . is_done || existing . status === 'completed' ) return prev ;
140+ return prev . map ( ( task ) =>
141+ task . id === taskId ? { ...task , status : 'completed' , is_done : true } : task ,
142+ ) ;
143+ } ) ;
144+
145+ setAnimatingId ( taskId ) ;
146+ const timer = setTimeout ( ( ) => setAnimatingId ( null ) , 600 ) ;
147+
148+ emit ( {
149+ type : 'task_verified' ,
150+ actor : 'system' ,
151+ resource : taskTitle ,
152+ resourceId : taskId ,
153+ metadata : { taskId } ,
154+ } ) ;
155+ forceSync ( [ 'tasks' , `task:${ taskId } ` ] ) ;
156+
157+ return ( ) => clearTimeout ( timer ) ;
158+ } , [ lastEvent , emit , forceSync ] ) ;
159+
146160 const filteredTasks = useMemo (
147- ( ) => tasks . filter ( ( task ) => matchesFilter ( task , filters . status , filters . priority ) ) ,
148- [ tasks , filters . status , filters . priority ] ,
161+ ( ) => taskList . filter ( ( task ) => matchesFilter ( task , filters . status , filters . priority ) ) ,
162+ [ taskList , filters . status , filters . priority ] ,
163+ ) ;
164+
165+ const sortedTasks = useMemo (
166+ ( ) => [ ...filteredTasks ] . sort ( ( a , b ) => statusSortWeight ( a ) - statusSortWeight ( b ) ) ,
167+ [ filteredTasks ] ,
149168 ) ;
150169
151170 const handleVerify = useCallback (
@@ -154,7 +173,6 @@ export default function TaskCard({ tasks = mockTasks, submitVote = defaultSubmit
154173 if ( pendingVotes [ taskId ] ) return ;
155174
156175 const prevStatus = optimisticStatus [ taskId ] ?? task . status ;
157- const prevVotes = task . votes ?? 0 ;
158176 const optimisticDelta = optimisticVotes [ taskId ] ?? 0 ;
159177
160178 setPendingVotes ( ( p ) => ( { ...p , [ taskId ] : true } ) ) ;
@@ -188,47 +206,6 @@ export default function TaskCard({ tasks = mockTasks, submitVote = defaultSubmit
188206 [ pendingVotes , optimisticVotes , optimisticStatus , submitVote , showToast , t ] ,
189207 ) ;
190208
191- useEffect ( ( ) => {
192- if ( ! lastEvent ) return ;
193- const { taskId } = lastEvent ;
194- setTaskList ( ( prev ) => {
195- const existing = prev . find ( ( t ) => t . id === taskId ) ;
196- if ( ! existing || existing . is_done || existing . status === 'completed' ) return prev ;
197- return prev . map ( ( t ) =>
198- t . id === taskId ? { ...t , status : 'completed' , is_done : true } : t ,
199- ) ;
200- } ) ;
201- setCompletedIds ( ( prev ) => new Set ( prev ) . add ( taskId ) ) ;
202- setAnimatingId ( taskId ) ;
203- const timer = setTimeout ( ( ) => setAnimatingId ( null ) , 600 ) ;
204- return ( ) => clearTimeout ( timer ) ;
205- } , [ lastEvent ] ) ;
206-
207- const handleVote = useCallback (
208- ( task : TaskCardTask ) => {
209- const title = task . title ?? t ( task . titleKey ?? '' ) ;
210- setTaskList ( ( prev ) =>
211- prev . map ( ( t ) =>
212- t . id === task . id ? { ...t , status : 'completed' , is_done : true } : t ,
213- ) ,
214- ) ;
215- setCompletedIds ( ( prev ) => new Set ( prev ) . add ( task . id ) ) ;
216- setAnimatingId ( task . id ) ;
217- setTimeout ( ( ) => setAnimatingId ( null ) , 600 ) ;
218-
219- emit ( {
220- type : 'task_verified' ,
221- actor : 'guardian' ,
222- resource : title ,
223- resourceId : task . id ,
224- metadata : { taskId : task . id , reward : task . reward } ,
225- } ) ;
226-
227- forceSync ( [ 'tasks' , `task:${ task . id } ` ] ) ;
228- } ,
229- [ emit , forceSync , t ] ,
230- ) ;
231-
232209 const getStatusIcon = ( status : TaskCardTask [ 'status' ] , isAnimating : boolean ) => {
233210 if ( isAnimating ) {
234211 return < ShieldCheck className = "w-5 h-5 text-emerald-600 dark:text-emerald-400 animate-bounce" aria-hidden = "true" /> ;
@@ -270,36 +247,6 @@ export default function TaskCard({ tasks = mockTasks, submitVote = defaultSubmit
270247 < h2 className = "text-xl font-semibold text-slate-900 dark:text-white" > { t ( 'tasks.heading' ) } </ h2 >
271248 </ div >
272249
273- < div className = "space-y-3" >
274- { sortedTasks . map ( ( task ) => {
275- const status = task . is_done ? 'completed' : task . status ;
276- const title = task . title ?? t ( task . titleKey ?? '' ) ;
277- const canVote = ! task . is_done && status !== 'completed' ;
278- const isAnimating = animatingId === task . id ;
279- { tasks . map ( ( task ) => {
280- const baseStatus = task . is_done ? 'completed' : task . status ;
281- const status = optimisticStatus [ task . id ] ?? baseStatus ;
282- const title = task . title ?? t ( task . titleKey ?? '' ) ;
283- const isPending = pendingVotes [ task . id ] ?? false ;
284- const canVote = ! task . is_done && status !== 'completed' && ! isPending ;
285- const voteCount = ( task . votes ?? 0 ) + ( optimisticVotes [ task . id ] ?? 0 ) ;
286-
287- return (
288- < div
289- key = { task . id }
290- className = { `bg-white dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 rounded-xl p-4 shadow-sm transition-all duration-500 ${
291- isAnimating
292- ? 'border-emerald-400 dark:border-emerald-500 scale-[1.02] animate-in zoom-in-95 fade-in'
293- : 'hover:border-slate-300 dark:hover:border-slate-600'
294- } `}
295- >
296- < div className = "flex flex-col sm:flex-row sm:items-start justify-between gap-4" >
297- < div className = "flex items-start gap-3 flex-1 w-full" >
298- { getStatusIcon ( status , isAnimating ) }
299- < div className = "flex-1" >
300- < div className = "flex items-center gap-2" >
301- < h3 className = "font-medium text-slate-900 dark:text-white" > { title } </ h3 >
302- { getPriorityBadge ( task . priority ) }
303250 < TaskFilters />
304251
305252 { filteredTasks . length === 0 ? (
@@ -308,22 +255,27 @@ export default function TaskCard({ tasks = mockTasks, submitVote = defaultSubmit
308255 </ p >
309256 ) : (
310257 < div className = "space-y-3" >
311- { filteredTasks . map ( ( task ) => {
258+ { sortedTasks . map ( ( task ) => {
312259 const baseStatus = task . is_done ? 'completed' : task . status ;
313260 const status = optimisticStatus [ task . id ] ?? baseStatus ;
314261 const title = task . title ?? t ( task . titleKey ?? '' ) ;
315262 const isPending = pendingVotes [ task . id ] ?? false ;
316263 const canVote = ! task . is_done && status !== 'completed' && ! isPending ;
317264 const voteCount = ( task . votes ?? 0 ) + ( optimisticVotes [ task . id ] ?? 0 ) ;
265+ const isAnimating = animatingId === task . id ;
318266
319267 return (
320268 < div
321269 key = { task . id }
322- className = "bg-white dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 rounded-xl p-4 hover:border-slate-300 dark:hover:border-slate-600 transition-colors shadow-sm"
270+ className = { `bg-white dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 rounded-xl p-4 shadow-sm transition-all duration-500 ${
271+ isAnimating
272+ ? 'border-emerald-400 dark:border-emerald-500 scale-[1.02] animate-in zoom-in-95 fade-in'
273+ : 'hover:border-slate-300 dark:hover:border-slate-600'
274+ } `}
323275 >
324276 < div className = "flex flex-col sm:flex-row sm:items-start justify-between gap-4" >
325277 < div className = "flex items-start gap-3 flex-1 w-full" >
326- { getStatusIcon ( status ) }
278+ { getStatusIcon ( status , isAnimating ) }
327279 < div className = "flex-1" >
328280 < div className = "flex items-center gap-2" >
329281 < h3 className = "font-medium text-slate-900 dark:text-white" > { title } </ h3 >
@@ -371,31 +323,6 @@ export default function TaskCard({ tasks = mockTasks, submitVote = defaultSubmit
371323 ) : null }
372324 </ div >
373325 </ div >
374- < div className = "text-right space-y-2" >
375- < span className = "block text-lg font-semibold text-indigo-600 dark:text-indigo-400" > { task . reward } </ span >
376- { isPending ? (
377- < button
378- type = "button"
379- disabled
380- className = "rounded-lg bg-slate-100 dark:bg-slate-700 px-3 py-1.5 text-sm font-semibold text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-slate-600 cursor-wait transition-colors flex items-center gap-2"
381- >
382- < Loader2 className = "w-4 h-4 animate-spin" aria-hidden = "true" />
383- { t ( 'tasks.verify.pending' ) }
384- </ button >
385- ) : canVote ? (
386- < button
387- type = "button"
388- onClick = { ( ) => handleVote ( task ) }
389- className = "rounded-lg bg-indigo-600 px-3 py-1.5 text-sm font-semibold text-white shadow-sm transition-all hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-slate-900 active:scale-95"
390- aria-label = { `Vote for ${ title } ` }
391- onClick = { ( ) => handleVerify ( task ) }
392- className = "rounded-lg bg-indigo-600 px-3 py-1.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-slate-900"
393- aria-label = { `Verify quality for ${ title } ` }
394- >
395- { t ( 'tasks.verify.action' ) }
396- </ button >
397- ) : null }
398- </ div >
399326 </ div >
400327 ) ;
401328 } ) }
0 commit comments