feat:migliorato pulsanti voto per poter votare piu volte

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Diego-C-05
2026-05-06 12:42:47 +02:00
parent 358e498837
commit e7d6cab186
3 changed files with 95 additions and 23 deletions
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import MagicCard from './MagicCard' import MagicCard from './MagicCard'
import { fetchPersonalizedFeed } from '../services/feedService' import { fetchPersonalizedFeed } from '../services/feedService'
import { sendFeedback } from '../services/feedbackService'
import type { Article } from '../types/article' import type { Article } from '../types/article'
type FeedContentProps = { type FeedContentProps = {
@@ -11,6 +12,46 @@ type FeedContentProps = {
function FeedContent({ sentimentFilter = null, topicsFilter = null }: FeedContentProps) { function FeedContent({ sentimentFilter = null, topicsFilter = null }: FeedContentProps) {
const [articles, setArticles] = useState<Article[]>([]) const [articles, setArticles] = useState<Article[]>([])
const [status, setStatus] = useState<'loading' | 'ok' | 'error'>('loading') const [status, setStatus] = useState<'loading' | 'ok' | 'error'>('loading')
const [voteByArticle, setVoteByArticle] = useState<Record<string, 1 | -1 | null>>({})
const [pendingByArticle, setPendingByArticle] = useState<Record<string, boolean>>({})
const [voteError, setVoteError] = useState<string | null>(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 // Prende il fetch del feed personalizzato e popola la variabile di stato con i dati
useEffect(() => {fetchPersonalizedFeed().then((data) => { useEffect(() => {fetchPersonalizedFeed().then((data) => {
@@ -30,6 +71,7 @@ function FeedContent({ sentimentFilter = null, topicsFilter = null }: FeedConten
{/* Gestione degli stati*/} {/* Gestione degli stati*/}
{status === 'loading' && <p>Caricamento feed...</p>} {status === 'loading' && <p>Caricamento feed...</p>}
{status === 'error' && <p>Errore nel caricamento del feed.</p>} {status === 'error' && <p>Errore nel caricamento del feed.</p>}
{voteError && <p className="feed-no-results">{voteError}</p>}
{/*Render della lista di articoli se il caricamento è andato a buon fine*/} {/*Render della lista di articoli se il caricamento è andato a buon fine*/}
{status === 'ok' && ( {status === 'ok' && (
<> <>
@@ -78,6 +120,9 @@ function FeedContent({ sentimentFilter = null, topicsFilter = null }: FeedConten
summary={a.summary} summary={a.summary}
tags={a.macroTopics?.length ? a.macroTopics : (a.trendingTopics || [a.category])} tags={a.macroTopics?.length ? a.macroTopics : (a.trendingTopics || [a.category])}
entities={a.entities || []} entities={a.entities || []}
voteState={voteByArticle[a.uniqueKey] ?? null}
votePending={pendingByArticle[a.uniqueKey] ?? false}
onVoteChange={handleVoteChange}
/> />
))} ))}
</div> </div>
+12 -12
View File
@@ -1,6 +1,4 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { useState } from 'react'
import { sendFeedback } from '../services/feedbackService'
type MagicCardProps = { type MagicCardProps = {
articleId?: string articleId?: string
@@ -12,16 +10,18 @@ type MagicCardProps = {
summary: string summary: string
tags: string[] tags: string[]
entities: 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. // Card notizia: mostra fonte, sentiment, testo, tag, entità e azioni rapide.
function MagicCard({ articleId, articleUrl, source, timeAgo, sentiment, title, summary, tags, entities }: MagicCardProps) { function MagicCard({ articleId, articleUrl, source, timeAgo, sentiment, title, summary, tags, entities, voteState, votePending = false, onVoteChange }: MagicCardProps) {
const [voted, setVoted] = useState<1 | -1 | null>(null) const handleVote = (clickedVote: 1 | -1) => {
if (!articleId || votePending) return
const handleVote = (vote: 1 | -1) => { const nextVote = voteState === clickedVote ? null : clickedVote
if (voted !== null) return onVoteChange(articleId, nextVote)
setVoted(vote)
if (articleId) sendFeedback(articleId, vote)
} }
return ( return (
@@ -79,8 +79,8 @@ function MagicCard({ articleId, articleUrl, source, timeAgo, sentiment, title, s
label="Mi piace" label="Mi piace"
tone="like" tone="like"
onClick={() => handleVote(1)} onClick={() => handleVote(1)}
disabled={voted !== null} disabled={votePending}
style={{ opacity: voted === -1 ? 0.4 : 1 }} style={{ opacity: voteState === -1 ? 0.4 : 1 }}
> >
<ThumbUpIcon /> <ThumbUpIcon />
</ActionButton> </ActionButton>
@@ -88,8 +88,8 @@ function MagicCard({ articleId, articleUrl, source, timeAgo, sentiment, title, s
label="Non mi piace" label="Non mi piace"
tone="dislike" tone="dislike"
onClick={() => handleVote(-1)} onClick={() => handleVote(-1)}
disabled={voted !== null} disabled={votePending}
style={{ opacity: voted === 1 ? 0.4 : 1 }} style={{ opacity: voteState === 1 ? 0.4 : 1 }}
> >
<ThumbDownIcon /> <ThumbDownIcon />
</ActionButton> </ActionButton>
@@ -1,15 +1,29 @@
const N8N = import.meta.env.VITE_N8N_URL const N8N = import.meta.env.VITE_N8N_URL
const FEEDBACK_TIMEOUT_MS = 5000
export const sendFeedback = async ( export const sendFeedback = async (
articleId: string, articleId: string,
vote: 1 | -1 vote: 1 | -1
): Promise<void> => { ): Promise<void> => {
const token = localStorage.getItem('briefai_token') 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`, { 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -17,5 +31,18 @@ export const sendFeedback = async (
articleId, articleId,
vote, 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)
}
} }