diff --git a/frontend-BriefAI/src/components/FeedContent.tsx b/frontend-BriefAI/src/components/FeedContent.tsx index b0e7e6f..0d03ead 100644 --- a/frontend-BriefAI/src/components/FeedContent.tsx +++ b/frontend-BriefAI/src/components/FeedContent.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import MagicCard from './MagicCard' import { fetchPersonalizedFeed } from '../services/feedService' +import { sendFeedback } from '../services/feedbackService' import type { Article } from '../types/article' type FeedContentProps = { @@ -11,6 +12,46 @@ type FeedContentProps = { function FeedContent({ sentimentFilter = null, topicsFilter = null }: FeedContentProps) { const [articles, setArticles] = useState([]) const [status, setStatus] = useState<'loading' | 'ok' | 'error'>('loading') + const [voteByArticle, setVoteByArticle] = useState>({}) + const [pendingByArticle, setPendingByArticle] = useState>({}) + const [voteError, setVoteError] = useState(null) + + const sendVoteDelta = async (articleId: string, previousVote: 1 | -1 | null, nextVote: 1 | -1 | null) => { + // Without server idempotency, simulate undo by applying compensating vote. + if (previousVote === nextVote) return + + if (previousVote === null && nextVote !== null) { + await sendFeedback(articleId, nextVote) + return + } + + if (previousVote !== null && nextVote === null) { + await sendFeedback(articleId, previousVote === 1 ? -1 : 1) + return + } + + if (previousVote !== null && nextVote !== null) { + await sendFeedback(articleId, nextVote) + } + } + + const handleVoteChange = async (articleId: string, nextVote: 1 | -1 | null) => { + const previousVote = voteByArticle[articleId] ?? null + if (previousVote === nextVote || pendingByArticle[articleId]) return + + setVoteError(null) + setVoteByArticle((prev) => ({ ...prev, [articleId]: nextVote })) + setPendingByArticle((prev) => ({ ...prev, [articleId]: true })) + + try { + await sendVoteDelta(articleId, previousVote, nextVote) + } catch { + setVoteByArticle((prev) => ({ ...prev, [articleId]: previousVote })) + setVoteError('Impossibile aggiornare il voto. Riprova.') + } finally { + setPendingByArticle((prev) => ({ ...prev, [articleId]: false })) + } + } // Prende il fetch del feed personalizzato e popola la variabile di stato con i dati useEffect(() => {fetchPersonalizedFeed().then((data) => { @@ -30,6 +71,7 @@ function FeedContent({ sentimentFilter = null, topicsFilter = null }: FeedConten {/* Gestione degli stati*/} {status === 'loading' &&

Caricamento feed...

} {status === 'error' &&

Errore nel caricamento del feed.

} + {voteError &&

{voteError}

} {/*Render della lista di articoli se il caricamento è andato a buon fine*/} {status === 'ok' && ( <> @@ -78,6 +120,9 @@ function FeedContent({ sentimentFilter = null, topicsFilter = null }: FeedConten summary={a.summary} tags={a.macroTopics?.length ? a.macroTopics : (a.trendingTopics || [a.category])} entities={a.entities || []} + voteState={voteByArticle[a.uniqueKey] ?? null} + votePending={pendingByArticle[a.uniqueKey] ?? false} + onVoteChange={handleVoteChange} /> ))} diff --git a/frontend-BriefAI/src/components/MagicCard.tsx b/frontend-BriefAI/src/components/MagicCard.tsx index e82f7d3..9e55d3f 100644 --- a/frontend-BriefAI/src/components/MagicCard.tsx +++ b/frontend-BriefAI/src/components/MagicCard.tsx @@ -1,6 +1,4 @@ import type { ReactNode } from 'react' -import { useState } from 'react' -import { sendFeedback } from '../services/feedbackService' type MagicCardProps = { articleId?: string @@ -12,16 +10,18 @@ type MagicCardProps = { summary: string tags: string[] entities: string[] + voteState: 1 | -1 | null + votePending?: boolean + onVoteChange: (articleId: string, nextVote: 1 | -1 | null) => void } // Card notizia: mostra fonte, sentiment, testo, tag, entità e azioni rapide. -function MagicCard({ articleId, articleUrl, source, timeAgo, sentiment, title, summary, tags, entities }: MagicCardProps) { - const [voted, setVoted] = useState<1 | -1 | null>(null) +function MagicCard({ articleId, articleUrl, source, timeAgo, sentiment, title, summary, tags, entities, voteState, votePending = false, onVoteChange }: MagicCardProps) { + const handleVote = (clickedVote: 1 | -1) => { + if (!articleId || votePending) return - const handleVote = (vote: 1 | -1) => { - if (voted !== null) return - setVoted(vote) - if (articleId) sendFeedback(articleId, vote) + const nextVote = voteState === clickedVote ? null : clickedVote + onVoteChange(articleId, nextVote) } return ( @@ -79,8 +79,8 @@ function MagicCard({ articleId, articleUrl, source, timeAgo, sentiment, title, s label="Mi piace" tone="like" onClick={() => handleVote(1)} - disabled={voted !== null} - style={{ opacity: voted === -1 ? 0.4 : 1 }} + disabled={votePending} + style={{ opacity: voteState === -1 ? 0.4 : 1 }} > @@ -88,8 +88,8 @@ function MagicCard({ articleId, articleUrl, source, timeAgo, sentiment, title, s label="Non mi piace" tone="dislike" onClick={() => handleVote(-1)} - disabled={voted !== null} - style={{ opacity: voted === 1 ? 0.4 : 1 }} + disabled={votePending} + style={{ opacity: voteState === 1 ? 0.4 : 1 }} > diff --git a/frontend-BriefAI/src/services/feedbackService.ts b/frontend-BriefAI/src/services/feedbackService.ts index 3f2ae6d..0a69a23 100644 --- a/frontend-BriefAI/src/services/feedbackService.ts +++ b/frontend-BriefAI/src/services/feedbackService.ts @@ -1,21 +1,48 @@ const N8N = import.meta.env.VITE_N8N_URL +const FEEDBACK_TIMEOUT_MS = 5000 export const sendFeedback = async ( articleId: string, vote: 1 | -1 ): Promise => { const token = localStorage.getItem('briefai_token') - if (!token) return + if (!token) throw new Error('Token mancante') - const payload = JSON.parse(atob(token.split('.')[1])) + if (!N8N) throw new Error('VITE_N8N_URL non configurata') - await fetch(`${N8N}/briefai/feedback`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - userId: payload.userId, - articleId, - vote, - }), - }) + let payload: { userId?: string } | null = null + try { + payload = JSON.parse(atob(token.split('.')[1])) + } catch { + throw new Error('Token non valido') + } + + if (!payload?.userId) throw new Error('userId non presente nel token') + + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), FEEDBACK_TIMEOUT_MS) + + try { + const response = await fetch(`${N8N}/briefai/feedback`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userId: payload.userId, + articleId, + vote, + }), + signal: controller.signal, + }) + + if (!response.ok) { + throw new Error(`Feedback HTTP ${response.status}`) + } + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error('Timeout feedback') + } + throw error + } finally { + clearTimeout(timeoutId) + } }