feat:migliorato pulsanti voto per poter votare piu volte
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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,21 +1,48 @@
|
|||||||
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
|
||||||
method: 'POST',
|
try {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
payload = JSON.parse(atob(token.split('.')[1]))
|
||||||
body: JSON.stringify({
|
} catch {
|
||||||
userId: payload.userId,
|
throw new Error('Token non valido')
|
||||||
articleId,
|
}
|
||||||
vote,
|
|
||||||
}),
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user