1 Commits

Author SHA1 Message Date
Diego-C-05 c7e7e42b24 freat:esempio 2026-05-07 09:45:36 +02:00
20 changed files with 65 additions and 538 deletions
-116
View File
@@ -1,116 +0,0 @@
# Fixes Applied
Data: May 7, 2026
## Problemi Risolti
### 1. Persistenza Piano Abbonamento (Pro/Free)
**Problema:** La data del piano pro era statica e il piano non era registrato nel database.
**Fix:**
- `backend/src/models/User.js`: Aggiunto campo `subscriptionPlan` (enum: 'free'|'pro') e `subscriptionExpiresAt`
- `backend/src/models/UserProfile.js`: Aggiunto campo `subscriptionPlan` e `subscriptionExpiresAt` allo schema
- `backend/src/routes/profile.js`:
- GET `/api/profile` ora restituisce `subscriptionPlan` e `subscriptionExpiresAt`
- PUT `/api/profile` accetta `subscriptionState` per aggiornare piano (pro imposta expiry 30 giorni)
- Sincronizzazione con `UserProfile` collection
---
### 2. Handlers Abbonamento Implementati
**Problema:** `onCancelSubscription` e `onUpgrade` in `AccountSettings.tsx` erano void (non facevano nulla).
**Fix:**
- `frontend-BriefAI/src/pages/SettingsPage.tsx`:
- `handleUpgrade()` ora chiama `updateProfile({ subscriptionState: 'pro' })`
- `handleCancelSubscription()` ora chiama `updateProfile({ subscriptionState: 'free' })`
- Entrambi sincronizzano lo stato con localStorage e il backend
- Aggiunto stato `subscriptionExpiresAt` locale
- `frontend-BriefAI/src/components/AccountSettings.tsx`:
- Riceve `subscriptionExpiresAt` come prop
- Mostra data reale di scadenza invece della data statica "12/12/2026"
---
### 3. Validazione Email Migliorata
**Problema:** Email veniva accettata a prescindere, anche se non aveva senso.
**Fix:**
- `backend/src/routes/auth.js`: Aggiunta validazione regex `/^[^\s@]+@[^\s@]+\.[^\s@]+$/` in POST `/auth/register`
- `frontend-BriefAI/src/pages/RegisterPage.tsx`: Aggiunta validazione client-side pre-submit
---
### 4. Token Handling in FeedService
**Problema:** Incertezza se il token fosse quello impostato o random.
**Fix:**
- `frontend-BriefAI/src/services/feedService.ts`:
- Importato `getMe` da authService
- Se il token non è decodificabile localmente, fa fallback a `getMe()` per ottenere userId dal backend
- Logging migliorato con messaggi di errore più chiari
---
### 5. Rimozione Link da Articoli Processed
**Problema:** Gli articoli processati contenevano link.
**Fix:**
- `backend/src/routes/articles.js`:
- Aggiunta funzione `stripLinks()` che rimuove:
- Anchor tag HTML `<a href="...">text</a>``text`
- Markdown link `[text](url)``text`
- Plain URL `https://...`
- Per articoli con `status='processed'`, viene pulito `content` e `summary` prima di rispondere
---
### 6. Implementazione Salvataggio Articoli (Save/Bookmark)
**Problema:** Il bottone "Salva" era presente ma non faceva nulla (non aveva handler).
**Fix:**
- `frontend-BriefAI/src/services/feedbackService.ts`:
- Aggiunta funzione `saveArticle(articleId)` che invia il salvataggio al webhook n8n
- Utilizza stessa logica di timeout e error handling di `sendFeedback`
- `frontend-BriefAI/src/components/MagicCard.tsx`:
- Aggiunto parametro `isSaved` per mostrare stato visivo
- Aggiunto parametro `savePending` per disabilitare bottone durante il caricamento
- Aggiunto parametro `onSave` callback
- Bottone Salva ora ha handler `handleSave` e disabilitazione appropriata
- `frontend-BriefAI/src/components/FeedContent.tsx`:
- Aggiunto stato `savedByArticle` per tracciare articoli salvati
- Aggiunto stato `savePendingByArticle` per gestire loading
- Aggiunto handler `handleSaveArticle` con error handling
- Aggiunto messaggio di errore `saveError` visibile all'utente
- Passa `isSaved`, `savePending`, `onSave` a cada MagicCard
---
## File Modificati
### Backend
- `/root/brief_ai/digital-twin-news-feature-nicol-ai-frontend/backend/src/models/User.js`
- `/root/brief_ai/digital-twin-news-feature-nicol-ai-frontend/backend/src/models/UserProfile.js`
- `/root/brief_ai/digital-twin-news-feature-nicol-ai-frontend/backend/src/routes/auth.js`
- `/root/brief_ai/digital-twin-news-feature-nicol-ai-frontend/backend/src/routes/profile.js`
- `/root/brief_ai/digital-twin-news-feature-nicol-ai-frontend/backend/src/routes/articles.js`
- `/root/brief_ai/digital-twin-news-feature-nicol-ai-frontend/backend/.env` (credenziali MongoDB Atlas aggiornate)
### Frontend
- `/root/brief_ai/frontend-BriefAI/src/components/AccountSettings.tsx`
- `/root/brief_ai/frontend-BriefAI/src/pages/SettingsPage.tsx`
- `/root/brief_ai/frontend-BriefAI/src/pages/RegisterPage.tsx`
- `/root/brief_ai/frontend-BriefAI/src/services/apiService.ts`
- `/root/brief_ai/frontend-BriefAI/src/services/feedService.ts`
- `/root/brief_ai/frontend-BriefAI/src/services/feedbackService.ts` (aggiunta funzione saveArticle)
- `/root/brief_ai/frontend-BriefAI/src/components/MagicCard.tsx` (implementato onSave handler)
- `/root/brief_ai/frontend-BriefAI/src/components/FeedContent.tsx` (gestione stato salvataggio)
---
## Credenziali Configurate
- **MongoDB**: Atlas cluster (briefai-cluster)
- **JWT_SECRET**: Configurato in `.env` backend
- **CORS_ORIGIN**: http://localhost:5173 (frontend)
- **Env**: development
+1 -1
View File
@@ -6,5 +6,5 @@ in feedservices.ts non capisco se il token è quello che ho impostato io o è ra
email non va bene perchè viene accettata a prescindere anche se non ha senso email non va bene perchè viene accettata a prescindere anche se non ha senso
.
togli link dagli articoli processed togli link dagli articoli processed
@@ -31,9 +31,6 @@ const userSchema = new mongoose.Schema(
preferredSources: [String], preferredSources: [String],
lastFeedGeneratedAt: Date, lastFeedGeneratedAt: Date,
subscriptionPlan: { type: String, enum: ['free', 'pro'], default: 'free' },
subscriptionExpiresAt: Date,
createdAt: { type: Date, default: Date.now }, createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now }, updatedAt: { type: Date, default: Date.now },
}, },
@@ -68,10 +65,8 @@ userSchema.post('save', async function syncUserProfile(doc) {
keywords: doc.keywords || [], keywords: doc.keywords || [],
weights: weightsObject, weights: weightsObject,
// sentimentPreference removed: we no longer persist a global sentiment preference // sentimentPreference removed: we no longer persist a global sentiment preference
preferredSources: doc.preferredSources || [], preferredSources: doc.preferredSources || [],
lastFeedGeneratedAt: doc.lastFeedGeneratedAt || null, lastFeedGeneratedAt: doc.lastFeedGeneratedAt || null,
subscriptionPlan: doc.subscriptionPlan || 'free',
subscriptionExpiresAt: doc.subscriptionExpiresAt || null,
updatedAt: new Date(), updatedAt: new Date(),
}, },
}, },
@@ -16,8 +16,6 @@ const userProfileSchema = new mongoose.Schema(
}, },
}, },
preferredSources: { type: [String], default: [] }, preferredSources: { type: [String], default: [] },
subscriptionPlan: { type: String, enum: ['free', 'pro'], default: 'free' },
subscriptionExpiresAt: Date,
lastFeedGeneratedAt: Date, lastFeedGeneratedAt: Date,
updatedAt: { type: Date, default: Date.now }, updatedAt: { type: Date, default: Date.now },
}, },
@@ -37,34 +37,11 @@ router.get('/', auth, async (req, res) => {
.skip((parsedPage - 1) * parsedLimit) .skip((parsedPage - 1) * parsedLimit)
.select('-__v'); .select('-__v');
// Helper to strip anchor tags and plain URLs from text fields
const stripLinks = (text) => {
if (!text || typeof text !== 'string') return text;
// remove HTML anchor tags but keep inner text
let s = text.replace(/<a[^>]*>(.*?)<\/a>/gi, '$1');
// remove markdown links [text](url) -> keep text
s = s.replace(/\[([^\]]+)\]\((?:[^)]+)\)/g, '$1');
// remove plain URLs
s = s.replace(/https?:\/\/[^\s\"'<>]+/gi, '');
return s;
};
// For processed articles, strip links from `content` and `summary`
const sanitizedArticles = articles.map((a) => {
if (a.status === 'processed') {
const doc = a.toObject ? a.toObject() : a;
doc.content = stripLinks(doc.content);
doc.summary = stripLinks(doc.summary);
return doc;
}
return a;
});
const total = await Article.countDocuments(filter); const total = await Article.countDocuments(filter);
return res.json({ return res.json({
success: true, success: true,
articles: sanitizedArticles, articles,
total, total,
page: parsedPage, page: parsedPage,
limit: parsedLimit, limit: parsedLimit,
@@ -30,24 +30,6 @@ router.post('/register', async (req, res) => {
}); });
} }
// Basic email format validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(String(email).toLowerCase())) {
return res.status(400).json({
success: false,
error: 'Email non valida.',
});
}
// Password validation: min 8 chars, 1 uppercase, 1 number
const passwordRegex = /^(?=.*[A-Z])(?=.*\d).{8,}$/;
if (!passwordRegex.test(password)) {
return res.status(400).json({
success: false,
error: 'Password deve avere almeno 8 caratteri, 1 maiuscola e 1 numero.',
});
}
const existingUser = await User.findOne({ const existingUser = await User.findOne({
$or: [{ email }, { username }], $or: [{ email }, { username }],
}); });
@@ -32,8 +32,6 @@ router.get('/', auth, async (req, res) => {
weights: userWeights, weights: userWeights,
preferredSources: user.preferredSources || [], preferredSources: user.preferredSources || [],
lastFeedGeneratedAt: user.lastFeedGeneratedAt || null, lastFeedGeneratedAt: user.lastFeedGeneratedAt || null,
subscriptionPlan: user.subscriptionPlan || 'free',
subscriptionExpiresAt: user.subscriptionExpiresAt || null,
}; };
if (userProfile) { if (userProfile) {
@@ -42,8 +40,6 @@ router.get('/', auth, async (req, res) => {
profile.weights = userProfile.weights || profile.weights; profile.weights = userProfile.weights || profile.weights;
profile.preferredSources = userProfile.preferredSources || profile.preferredSources; profile.preferredSources = userProfile.preferredSources || profile.preferredSources;
profile.lastFeedGeneratedAt = userProfile.lastFeedGeneratedAt || profile.lastFeedGeneratedAt; profile.lastFeedGeneratedAt = userProfile.lastFeedGeneratedAt || profile.lastFeedGeneratedAt;
profile.subscriptionPlan = userProfile.subscriptionPlan || profile.subscriptionPlan;
profile.subscriptionExpiresAt = userProfile.subscriptionExpiresAt || profile.subscriptionExpiresAt;
} }
return res.json({ success: true, profile }); return res.json({ success: true, profile });
@@ -57,7 +53,7 @@ router.get('/', auth, async (req, res) => {
router.put('/', auth, async (req, res) => { router.put('/', auth, async (req, res) => {
try { try {
const { macroTopics, keywords, preferredSources, subscriptionState } = req.body; const { macroTopics, keywords, preferredSources } = req.body;
const user = await User.findOne({ userId: req.user.userId }); const user = await User.findOne({ userId: req.user.userId });
if (!user) { if (!user) {
@@ -70,16 +66,6 @@ router.put('/', auth, async (req, res) => {
if (macroTopics) user.macroTopics = macroTopics; if (macroTopics) user.macroTopics = macroTopics;
if (keywords) user.keywords = keywords; if (keywords) user.keywords = keywords;
if (preferredSources) user.preferredSources = preferredSources; if (preferredSources) user.preferredSources = preferredSources;
if (subscriptionState) {
// subscriptionState expected to be 'free' or 'pro'
user.subscriptionPlan = subscriptionState === 'pro' ? 'pro' : 'free';
if (subscriptionState === 'pro') {
// set expiry to 30 days from now by default
user.subscriptionExpiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
} else {
user.subscriptionExpiresAt = null;
}
}
user.updatedAt = new Date(); user.updatedAt = new Date();
await user.save(); await user.save();
@@ -92,8 +78,6 @@ router.put('/', auth, async (req, res) => {
macroTopics: user.macroTopics, macroTopics: user.macroTopics,
keywords: user.keywords, keywords: user.keywords,
preferredSources: user.preferredSources, preferredSources: user.preferredSources,
subscriptionPlan: user.subscriptionPlan,
subscriptionExpiresAt: user.subscriptionExpiresAt,
updatedAt: new Date(), updatedAt: new Date(),
}, },
{ upsert: true } { upsert: true }
@@ -113,8 +97,6 @@ router.put('/', auth, async (req, res) => {
keywords: user.keywords, keywords: user.keywords,
weights, weights,
preferredSources: user.preferredSources, preferredSources: user.preferredSources,
subscriptionPlan: user.subscriptionPlan,
subscriptionExpiresAt: user.subscriptionExpiresAt,
}, },
}); });
} catch (err) { } catch (err) {
+1
View File
@@ -1,2 +1,3 @@
VITE_API_URL=http://localhost:5000 VITE_API_URL=http://localhost:5000
VITE_N8N_URL=https://n8n-cipolla.ampere.lucasacchi.net/webhook VITE_N8N_URL=https://n8n-cipolla.ampere.lucasacchi.net/webhook
//commit test
@@ -4,15 +4,13 @@ type AccountSettingsProps = {
username: string username: string
email: string email: string
subscriptionState: SubscriptionState subscriptionState: SubscriptionState
subscriptionExpiresAt?: string | null
onUpgrade: () => void onUpgrade: () => void
onCancelSubscription: () => void onCancelSubscription: () => void
} }
// Sezione account: mostra profilo e gestisce in modo condizionale il piano attivo. // Sezione account: mostra profilo e gestisce in modo condizionale il piano attivo.
function AccountSettings({username, email, subscriptionState, subscriptionExpiresAt, onUpgrade, onCancelSubscription}: AccountSettingsProps) { function AccountSettings({username, email, subscriptionState, onUpgrade, onCancelSubscription}: AccountSettingsProps) {
const isProPlan = subscriptionState === 'pro' const isProPlan = subscriptionState === 'pro'
const expires = subscriptionExpiresAt ? new Date(subscriptionExpiresAt) : null
return ( return (
<section className="settings-stack" aria-label="Profilo"> <section className="settings-stack" aria-label="Profilo">
@@ -50,7 +48,7 @@ function AccountSettings({username, email, subscriptionState, subscriptionExpire
{isProPlan ? 'Piano Pro' : 'Piano Gratuito'} {isProPlan ? 'Piano Pro' : 'Piano Gratuito'}
</span> </span>
{isProPlan ? ( {isProPlan ? (
<p>{expires ? `Scade il ${expires.toLocaleDateString()}` : 'Piano Pro attivo'}</p> <p>Scade il 12/12/2026</p>
) : ( ) : (
<p>Passa al Pro per sbloccare monitoraggio e analisi avanzate.</p> <p>Passa al Pro per sbloccare monitoraggio e analisi avanzate.</p>
)} )}
@@ -1,45 +1,20 @@
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, saveArticle } from '../services/feedbackService' import { sendFeedback } from '../services/feedbackService'
import type { Article } from '../types/article' import type { Article } from '../types/article'
type FeedContentProps = { type FeedContentProps = {
sentimentFilter?: string | null sentimentFilter?: string | null
topicsFilter?: string | null topicsFilter?: string | null
preferenceFilter?: string | null
} }
function FeedContent({ sentimentFilter = null, topicsFilter = null, preferenceFilter = 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 [voteByArticle, setVoteByArticle] = useState<Record<string, 1 | -1 | null>>({})
const [pendingByArticle, setPendingByArticle] = useState<Record<string, boolean>>({}) const [pendingByArticle, setPendingByArticle] = useState<Record<string, boolean>>({})
const [savedByArticle, setSavedByArticle] = useState<Record<string, boolean>>({})
const [savePendingByArticle, setSavePendingByArticle] = useState<Record<string, boolean>>({})
const [voteError, setVoteError] = useState<string | null>(null) const [voteError, setVoteError] = useState<string | null>(null)
const [saveError, setSaveError] = useState<string | null>(null)
// Carica articoli salvati e voti da localStorage
useEffect(() => {
try {
const saved = JSON.parse(localStorage.getItem('briefai_saved_articles') || '{}')
setSavedByArticle(saved)
const votes = JSON.parse(localStorage.getItem('briefai_voted_articles') || '{}')
setVoteByArticle(votes)
} catch (e) {
console.warn('Errore caricamento preferenze:', e)
}
}, [])
// Persisti voti in localStorage quando cambiano
useEffect(() => {
try {
localStorage.setItem('briefai_voted_articles', JSON.stringify(voteByArticle))
} catch (e) {
console.warn('Errore salvataggio voti:', e)
}
}, [voteByArticle])
const sendVoteDelta = async (articleId: string, previousVote: 1 | -1 | null, nextVote: 1 | -1 | null) => { const sendVoteDelta = async (articleId: string, previousVote: 1 | -1 | null, nextVote: 1 | -1 | null) => {
// Without server idempotency, simulate undo by applying compensating vote. // Without server idempotency, simulate undo by applying compensating vote.
@@ -78,23 +53,6 @@ function FeedContent({ sentimentFilter = null, topicsFilter = null, preferenceFi
} }
} }
const handleSaveArticle = async (articleId: string) => {
if (savePendingByArticle[articleId]) return
setSaveError(null)
setSavedByArticle((prev) => ({ ...prev, [articleId]: !prev[articleId] }))
setSavePendingByArticle((prev) => ({ ...prev, [articleId]: true }))
try {
await saveArticle(articleId)
} catch {
setSavedByArticle((prev) => ({ ...prev, [articleId]: !prev[articleId] }))
setSaveError('Impossibile salvare l\'articolo. Riprova.')
} finally {
setSavePendingByArticle((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) => {
setArticles(data) setArticles(data)
@@ -114,7 +72,6 @@ function FeedContent({ sentimentFilter = null, topicsFilter = null, preferenceFi
{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>} {voteError && <p className="feed-no-results">{voteError}</p>}
{saveError && <p className="feed-no-results">{saveError}</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' && (
<> <>
@@ -122,13 +79,13 @@ function FeedContent({ sentimentFilter = null, topicsFilter = null, preferenceFi
{(() => { {(() => {
const matchesTopic = (a: Article) => { const matchesTopic = (a: Article) => {
if (!topicsFilter || topicsFilter === 'All Topics') return true if (!topicsFilter || topicsFilter === 'All Topics') return true
const topic = topicsFilter.toLowerCase().trim() const topic = topicsFilter.toLowerCase()
const catMatch = (a.category || '').toLowerCase().trim() === topic const catMatch = (a.category || '').toLowerCase() === topic
const trending = (a.trendingTopics || []).some((t) => const trending = (a.trendingTopics || []).some((t) =>
String(t || '').toLowerCase().trim() === topic String(t || '').toLowerCase() === topic
) )
const macroTopicMatch = (a.macroTopics || []).some((t) => const macroTopicMatch = (a.macroTopics || []).some((t) =>
String(t || '').toLowerCase().trim() === topic String(t || '').toLowerCase() === topic
) )
return catMatch || trending || macroTopicMatch return catMatch || trending || macroTopicMatch
} }
@@ -138,18 +95,7 @@ function FeedContent({ sentimentFilter = null, topicsFilter = null, preferenceFi
return a.sentiment === sentimentFilter return a.sentiment === sentimentFilter
} }
const matchesPreference = (a: Article) => { const filtered = articles.filter((a) => matchesSentiment(a) && matchesTopic(a))
if (!preferenceFilter || preferenceFilter === 'Tutti') return true
if (preferenceFilter === 'Like') {
return voteByArticle[a.uniqueKey] === 1
}
if (preferenceFilter === 'Salvati') {
return savedByArticle[a.uniqueKey] === true
}
return true
}
const filtered = articles.filter((a) => matchesSentiment(a) && matchesTopic(a) && matchesPreference(a))
return ( return (
<div> <div>
@@ -176,10 +122,7 @@ function FeedContent({ sentimentFilter = null, topicsFilter = null, preferenceFi
entities={a.entities || []} entities={a.entities || []}
voteState={voteByArticle[a.uniqueKey] ?? null} voteState={voteByArticle[a.uniqueKey] ?? null}
votePending={pendingByArticle[a.uniqueKey] ?? false} votePending={pendingByArticle[a.uniqueKey] ?? false}
isSaved={savedByArticle[a.uniqueKey] ?? false}
savePending={savePendingByArticle[a.uniqueKey] ?? false}
onVoteChange={handleVoteChange} onVoteChange={handleVoteChange}
onSave={handleSaveArticle}
/> />
))} ))}
</div> </div>
@@ -4,8 +4,6 @@ type FeedTopbarProps = {
onSentimentChange?: (sentiment: string) => void onSentimentChange?: (sentiment: string) => void
topicsFilter?: string | null topicsFilter?: string | null
onTopicChange?: (topic: string) => void onTopicChange?: (topic: string) => void
preferenceFilter?: string | null
onPreferenceChange?: (preference: string) => void
} }
function FeedTopbar({ function FeedTopbar({
@@ -14,8 +12,6 @@ function FeedTopbar({
onSentimentChange, onSentimentChange,
topicsFilter = null, topicsFilter = null,
onTopicChange, onTopicChange,
preferenceFilter = null,
onPreferenceChange,
}: FeedTopbarProps) { }: FeedTopbarProps) {
return ( return (
<header <header
@@ -60,19 +56,6 @@ function FeedTopbar({
<option>Trasporti & Mobilità</option> <option>Trasporti & Mobilità</option>
</select> </select>
</label> </label>
<label className="feed-filter-control" htmlFor="preference-filter">
<span className="feed-filter-label">Preferenza</span>
<select
id="preference-filter"
value={preferenceFilter || 'Tutti'}
onChange={(e) => onPreferenceChange?.(e.target.value)}
>
<option>Tutti</option>
<option>Like</option>
<option>Salvati</option>
</select>
</label>
</nav> </nav>
) : null} ) : null}
</header> </header>
+6 -14
View File
@@ -12,14 +12,11 @@ type MagicCardProps = {
entities: string[] entities: string[]
voteState: 1 | -1 | null voteState: 1 | -1 | null
votePending?: boolean votePending?: boolean
isSaved?: boolean
savePending?: boolean
onVoteChange: (articleId: string, nextVote: 1 | -1 | null) => void onVoteChange: (articleId: string, nextVote: 1 | -1 | null) => void
onSave?: (articleId: string) => 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, voteState, votePending = false, isSaved = false, savePending = false, onVoteChange, onSave }: MagicCardProps) { function MagicCard({ articleId, articleUrl, source, timeAgo, sentiment, title, summary, tags, entities, voteState, votePending = false, onVoteChange }: MagicCardProps) {
const handleVote = (clickedVote: 1 | -1) => { const handleVote = (clickedVote: 1 | -1) => {
if (!articleId || votePending) return if (!articleId || votePending) return
@@ -27,11 +24,6 @@ function MagicCard({ articleId, articleUrl, source, timeAgo, sentiment, title, s
onVoteChange(articleId, nextVote) onVoteChange(articleId, nextVote)
} }
const handleSave = () => {
if (!articleId || savePending || !onSave) return
onSave(articleId)
}
return ( return (
<article className="magic-card"> <article className="magic-card">
{/* Testata card: fonte e badge del sentiment allineati ai lati opposti. */} {/* Testata card: fonte e badge del sentiment allineati ai lati opposti. */}
@@ -86,22 +78,22 @@ function MagicCard({ articleId, articleUrl, source, timeAgo, sentiment, title, s
<ActionButton <ActionButton
label="Mi piace" label="Mi piace"
tone="like" tone="like"
isActive={voteState === 1}
onClick={() => handleVote(1)} onClick={() => handleVote(1)}
disabled={votePending} disabled={votePending}
style={{ opacity: voteState === -1 ? 0.4 : 1 }}
> >
<ThumbUpIcon /> <ThumbUpIcon />
</ActionButton> </ActionButton>
<ActionButton <ActionButton
label="Non mi piace" label="Non mi piace"
tone="dislike" tone="dislike"
isActive={voteState === -1}
onClick={() => handleVote(-1)} onClick={() => handleVote(-1)}
disabled={votePending} disabled={votePending}
style={{ opacity: voteState === 1 ? 0.4 : 1 }}
> >
<ThumbDownIcon /> <ThumbDownIcon />
</ActionButton> </ActionButton>
<ActionButton label="Salva" tone="save" isActive={isSaved} onClick={handleSave} disabled={savePending}> <ActionButton label="Salva" tone="save">
<BookmarkIcon /> <BookmarkIcon />
</ActionButton> </ActionButton>
</footer> </footer>
@@ -110,11 +102,11 @@ function MagicCard({ articleId, articleUrl, source, timeAgo, sentiment, title, s
} }
// Piccolo bottone riutilizzabile per mantenere coerente il footer azioni. // Piccolo bottone riutilizzabile per mantenere coerente il footer azioni.
function ActionButton({ label, tone, children, onClick, disabled, isActive, style }: { label: string; tone: string; children: ReactNode; onClick?: () => void; disabled?: boolean; isActive?: boolean; style?: React.CSSProperties }) { function ActionButton({ label, tone, children, onClick, disabled, style }: { label: string; tone: string; children: ReactNode; onClick?: () => void; disabled?: boolean; style?: React.CSSProperties }) {
return ( return (
<button <button
type="button" type="button"
className={`action-button ${tone}${isActive ? ' active' : ''}`} className={`action-button ${tone}`}
aria-label={label} aria-label={label}
onClick={onClick} onClick={onClick}
disabled={disabled} disabled={disabled}
+5 -36
View File
@@ -388,24 +388,6 @@
background: #f5f3ff; background: #f5f3ff;
} }
.action-button.like.active {
color: #16a34a;
border-color: #86efac;
background: #f0fdf4;
}
.action-button.dislike.active {
color: #dc2626;
border-color: #fca5a5;
background: #fef2f2;
}
.action-button.save.active {
color: #2563eb;
border-color: #93c5fd;
background: #eff6ff;
}
.action-button svg { .action-button svg {
width: 18px; width: 18px;
height: 18px; height: 18px;
@@ -419,27 +401,14 @@
} }
.load-more-button { .load-more-button {
padding: 12px 28px; color: #2563eb;
border: none; background: #fff;
border-radius: 12px; border: 1px solid #93c5fd;
color: #fff; box-shadow: none;
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3);
} }
.load-more-button:hover { .load-more-button:hover {
transform: translateY(-2px); background: #dbeafe;
box-shadow: 0 6px 16px rgba(37, 99, 235, 0.4);
background: linear-gradient(135deg, #1d4ed8 0%, #1e40af 100%);
}
.load-more-button:active {
transform: translateY(0);
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3);
} }
@media (max-width: 1024px) { @media (max-width: 1024px) {
+1 -8
View File
@@ -7,7 +7,6 @@ import './FeedPage.css'
function FeedPage() { function FeedPage() {
const [sentimentFilter, setSentimentFilter] = useState<string | null>(null) const [sentimentFilter, setSentimentFilter] = useState<string | null>(null)
const [topicsFilter, setTopicsFilter] = useState<string | null>(null) const [topicsFilter, setTopicsFilter] = useState<string | null>(null)
const [preferenceFilter, setPreferenceFilter] = useState<string | null>(null)
const handleSentimentChange = (sentiment: string) => { const handleSentimentChange = (sentiment: string) => {
setSentimentFilter(sentiment === 'All Sentiment' ? null : sentiment) setSentimentFilter(sentiment === 'All Sentiment' ? null : sentiment)
@@ -17,10 +16,6 @@ function FeedPage() {
setTopicsFilter(topic === 'All Topics' ? null : topic) setTopicsFilter(topic === 'All Topics' ? null : topic)
} }
const handlePreferenceChange = (preference: string) => {
setPreferenceFilter(preference === 'Tutti' ? null : preference)
}
return ( return (
<div className="feed-layout" aria-label="Feed BriefAI"> <div className="feed-layout" aria-label="Feed BriefAI">
<FeedSidebar activeItem="feed" /> <FeedSidebar activeItem="feed" />
@@ -32,10 +27,8 @@ function FeedPage() {
onSentimentChange={handleSentimentChange} onSentimentChange={handleSentimentChange}
topicsFilter={topicsFilter} topicsFilter={topicsFilter}
onTopicChange={handleTopicChange} onTopicChange={handleTopicChange}
preferenceFilter={preferenceFilter}
onPreferenceChange={handlePreferenceChange}
/> />
<FeedContent sentimentFilter={sentimentFilter} topicsFilter={topicsFilter} preferenceFilter={preferenceFilter} /> <FeedContent sentimentFilter={sentimentFilter} topicsFilter={topicsFilter} />
</section> </section>
</div> </div>
) )
@@ -39,13 +39,6 @@ function RegisterPage({ onRegisterSuccess }: RegisterPageProps) {
// ignore parsing errors // ignore parsing errors
} }
// Basic email format validation on client
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(String(email).toLowerCase())) {
setError('Formato email non valido.')
return
}
try { try {
await register({ await register({
email, email,
+22 -83
View File
@@ -121,26 +121,12 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 10px 16px; background: linear-gradient(90deg, #2563eb, #1d4ed8);
border: none; transition: transform 0.2s ease, opacity 0.2s ease, background 0.2s ease;
border-radius: 10px;
color: #fff;
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3);
} }
.settings-save-button:hover { .settings-save-button:hover {
transform: translateY(-2px); transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(37, 99, 235, 0.4);
background: linear-gradient(135deg, #1d4ed8 0%, #1e40af 100%);
}
.settings-save-button:active {
transform: translateY(0);
} }
.settings-save-button svg { .settings-save-button svg {
@@ -149,33 +135,6 @@
fill: currentColor; fill: currentColor;
} }
.keyword-add-button {
margin-top: 0;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
border: none;
border-radius: 10px;
color: #fff;
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3);
}
.keyword-add-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(37, 99, 235, 0.4);
background: linear-gradient(135deg, #1d4ed8 0%, #1e40af 100%);
}
.keyword-add-button:active {
transform: translateY(0);
}
.keyword-toolbar { .keyword-toolbar {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
@@ -193,6 +152,19 @@
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15); box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
} }
.keyword-add-button {
margin-top: 0;
display: inline-flex;
align-items: center;
gap: 8px;
background: #2563eb;
transition: transform 0.2s ease, background 0.2s ease;
}
.keyword-add-button:hover {
transform: translateY(-1px);
}
.suggestion-row { .suggestion-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -291,50 +263,17 @@
.subscription-upgrade-button { .subscription-upgrade-button {
margin-top: 0; margin-top: 0;
padding: 10px 18px; background: linear-gradient(90deg, #2563eb, #7c3aed);
border: none;
border-radius: 10px;
color: #fff;
background: linear-gradient(135deg, #2563eb 0%, #7c3aed 100%);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3);
}
.subscription-upgrade-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(37, 99, 235, 0.4);
background: linear-gradient(135deg, #1d4ed8 0%, #6d28d9 100%);
}
.subscription-upgrade-button:active {
transform: translateY(0);
} }
.subscription-link-button { .subscription-link-button {
margin-top: 0; margin-top: 0;
padding: 10px 18px; padding: 0;
border: 1px solid #e5e7eb; border: none;
border-radius: 10px;
color: #64748b;
background: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
.subscription-link-button:hover {
color: #2563eb; color: #2563eb;
border-color: #2563eb; background: transparent;
background: #eff6ff; text-decoration: underline;
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.15); text-underline-offset: 2px;
}
.subscription-link-button:active {
transform: scale(0.98);
} }
@media (min-width: 900px) { @media (min-width: 900px) {
+12 -41
View File
@@ -13,7 +13,6 @@ type SettingsSnapshot = {
selectedMacroTopics: string[] selectedMacroTopics: string[]
keywords: string[] keywords: string[]
subscriptionState: SubscriptionState subscriptionState: SubscriptionState
subscriptionExpiresAt?: string | null
} }
type ProfileIdentity = { type ProfileIdentity = {
@@ -44,7 +43,6 @@ function readInitialSettings(): SettingsSnapshot {
storedSnapshot?.selectedMacroTopics ?? onboardingSnapshot?.selectedTopics ?? DEFAULT_MACRO_TOPICS, storedSnapshot?.selectedMacroTopics ?? onboardingSnapshot?.selectedTopics ?? DEFAULT_MACRO_TOPICS,
keywords: storedSnapshot?.keywords ?? onboardingSnapshot?.keywords ?? [], keywords: storedSnapshot?.keywords ?? onboardingSnapshot?.keywords ?? [],
subscriptionState: storedSnapshot?.subscriptionState ?? 'free', subscriptionState: storedSnapshot?.subscriptionState ?? 'free',
subscriptionExpiresAt: storedSnapshot?.subscriptionExpiresAt ?? null,
} }
} }
@@ -81,9 +79,6 @@ function SettingsPage() {
const [subscriptionState, setSubscriptionState] = useState<SubscriptionState>( const [subscriptionState, setSubscriptionState] = useState<SubscriptionState>(
() => readInitialSettings().subscriptionState, () => readInitialSettings().subscriptionState,
) )
const [subscriptionExpiresAt, setSubscriptionExpiresAt] = useState<string | null>(() =>
(readInitialSettings() as any).subscriptionExpiresAt ?? null,
)
useEffect(() => { useEffect(() => {
fetchProfile() fetchProfile()
@@ -95,8 +90,6 @@ function SettingsPage() {
}) })
setSelectedMacroTopics(res.profile.macroTopics || []) setSelectedMacroTopics(res.profile.macroTopics || [])
setKeywords(res.profile.keywords || []) setKeywords(res.profile.keywords || [])
if (res.profile.subscriptionPlan) setSubscriptionState(res.profile.subscriptionPlan as SubscriptionState)
setSubscriptionExpiresAt(res.profile.subscriptionExpiresAt || null)
} }
}) })
.catch(() => {}) .catch(() => {})
@@ -117,11 +110,11 @@ function SettingsPage() {
if (res && res.profile) { if (res && res.profile) {
setSelectedMacroTopics(res.profile.macroTopics || selectedMacroTopics) setSelectedMacroTopics(res.profile.macroTopics || selectedMacroTopics)
} }
persistSettings({ selectedMacroTopics, keywords, subscriptionState, subscriptionExpiresAt }) persistSettings({ selectedMacroTopics, keywords, subscriptionState })
}) })
.catch(() => { .catch(() => {
// fallback local persist // fallback local persist
persistSettings({ selectedMacroTopics, keywords, subscriptionState, subscriptionExpiresAt }) persistSettings({ selectedMacroTopics, keywords, subscriptionState })
}) })
} }
@@ -149,44 +142,23 @@ function SettingsPage() {
if (res && res.profile) { if (res && res.profile) {
setKeywords(res.profile.keywords || keywords) setKeywords(res.profile.keywords || keywords)
} }
persistSettings({ selectedMacroTopics, keywords, subscriptionState, subscriptionExpiresAt }) persistSettings({ selectedMacroTopics, keywords, subscriptionState })
}) })
.catch(() => { .catch(() => {
persistSettings({ selectedMacroTopics, keywords, subscriptionState, subscriptionExpiresAt }) persistSettings({ selectedMacroTopics, keywords, subscriptionState })
}) })
} }
const handleUpgrade = async () => { const handleUpgrade = () => {
try { const nextSubscriptionState: SubscriptionState = 'pro'
const res = await updateProfile({ subscriptionState: 'pro' }) setSubscriptionState(nextSubscriptionState)
if (res && res.profile) { persistSettings({ selectedMacroTopics, keywords, subscriptionState: nextSubscriptionState })
setSubscriptionState((res.profile.subscriptionPlan as SubscriptionState) || 'pro')
setSubscriptionExpiresAt(res.profile.subscriptionExpiresAt || null)
persistSettings({ selectedMacroTopics, keywords, subscriptionState: (res.profile.subscriptionPlan as SubscriptionState) || 'pro', subscriptionExpiresAt: res.profile.subscriptionExpiresAt || null })
}
} catch (e) {
// fallback local only
const nextSubscriptionState: SubscriptionState = 'pro'
setSubscriptionState(nextSubscriptionState)
setSubscriptionExpiresAt(new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString())
persistSettings({ selectedMacroTopics, keywords, subscriptionState: nextSubscriptionState, subscriptionExpiresAt })
}
} }
const handleCancelSubscription = async () => { const handleCancelSubscription = () => {
try { const nextSubscriptionState: SubscriptionState = 'free'
const res = await updateProfile({ subscriptionState: 'free' }) setSubscriptionState(nextSubscriptionState)
if (res && res.profile) { persistSettings({ selectedMacroTopics, keywords, subscriptionState: nextSubscriptionState })
setSubscriptionState((res.profile.subscriptionPlan as SubscriptionState) || 'free')
setSubscriptionExpiresAt(res.profile.subscriptionExpiresAt || null)
persistSettings({ selectedMacroTopics, keywords, subscriptionState: (res.profile.subscriptionPlan as SubscriptionState) || 'free', subscriptionExpiresAt: res.profile.subscriptionExpiresAt || null })
}
} catch (e) {
const nextSubscriptionState: SubscriptionState = 'free'
setSubscriptionState(nextSubscriptionState)
setSubscriptionExpiresAt(null)
persistSettings({ selectedMacroTopics, keywords, subscriptionState: nextSubscriptionState, subscriptionExpiresAt })
}
} }
return ( return (
@@ -226,7 +198,6 @@ function SettingsPage() {
username={profileIdentity.username || 'Utente'} username={profileIdentity.username || 'Utente'}
email={profileIdentity.email || 'Email non disponibile'} email={profileIdentity.email || 'Email non disponibile'}
subscriptionState={subscriptionState} subscriptionState={subscriptionState}
subscriptionExpiresAt={subscriptionExpiresAt}
onUpgrade={handleUpgrade} onUpgrade={handleUpgrade}
onCancelSubscription={handleCancelSubscription} onCancelSubscription={handleCancelSubscription}
/> />
+1 -5
View File
@@ -9,8 +9,6 @@ export type ProfileResponse = {
email?: string email?: string
macroTopics?: string[] macroTopics?: string[]
keywords?: string[] keywords?: string[]
subscriptionPlan?: 'free' | 'pro'
subscriptionExpiresAt?: string | null
} }
} }
@@ -49,7 +47,6 @@ export const updateProfile = async (data: {
userId?: string userId?: string
macroTopics?: string[] macroTopics?: string[]
keywords?: string[] keywords?: string[]
subscriptionState?: 'free' | 'pro'
}) => { }) => {
// Sync to backend // Sync to backend
const backendRes = await authFetch('/api/profile', { const backendRes = await authFetch('/api/profile', {
@@ -60,7 +57,7 @@ export const updateProfile = async (data: {
// Opt-in sync to n8n if url is present and userId is known // Opt-in sync to n8n if url is present and userId is known
const n8nUrl = import.meta.env.VITE_N8N_URL; const n8nUrl = import.meta.env.VITE_N8N_URL;
if (n8nUrl && data.userId) { if (n8nUrl && data.userId && data.macroTopics) {
try { try {
await fetch(`${n8nUrl}/briefai/profile/update`, { await fetch(`${n8nUrl}/briefai/profile/update`, {
method: 'POST', method: 'POST',
@@ -69,7 +66,6 @@ export const updateProfile = async (data: {
userId: data.userId, userId: data.userId,
macroTopics: data.macroTopics, macroTopics: data.macroTopics,
keywords: data.keywords, keywords: data.keywords,
subscriptionState: data.subscriptionState,
}) })
}); });
} catch (e) { } catch (e) {
+3 -14
View File
@@ -1,5 +1,5 @@
import type { Article } from '../types/article' import type { Article } from '../types/article'
import { getAuthHeader, decodeToken, getMe } from './authService' import { getAuthHeader, decodeToken } from './authService'
const N8N = import.meta.env.VITE_N8N_URL const N8N = import.meta.env.VITE_N8N_URL
@@ -9,19 +9,8 @@ export const fetchPersonalizedFeed = async (): Promise<Article[]> => {
if (!token) throw new Error('Non autenticato') if (!token) throw new Error('Non autenticato')
const payload = decodeToken(token) const payload = decodeToken(token)
let userId: string = payload?.userId const userId: string = payload?.userId
if (!userId) throw new Error('Token non valido')
// Fallback: if token decoding failed, ask backend who we are
if (!userId) {
try {
const me = await getMe()
userId = me?.user?.userId
} catch (e) {
// ignore and let later check throw
}
}
if (!userId) throw new Error('Token non valido o utente non riconosciuto')
const res = await fetch(`${N8N}/briefai/feed`, { const res = await fetch(`${N8N}/briefai/feed`, {
method: 'POST', method: 'POST',
@@ -46,61 +46,3 @@ export const sendFeedback = async (
clearTimeout(timeoutId) clearTimeout(timeoutId)
} }
} }
export const saveArticle = async (articleId: string): Promise<void> => {
const token = localStorage.getItem('briefai_token')
if (!token) throw new Error('Token mancante')
if (!N8N) {
// Fallback: salva solo localmente se n8n non è configurato
console.warn('N8N_URL non configurato, salvataggio locale')
const saved = JSON.parse(localStorage.getItem('briefai_saved_articles') || '{}')
saved[articleId] = true
localStorage.setItem('briefai_saved_articles', JSON.stringify(saved))
return
}
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/save-article`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: payload.userId,
articleId,
}),
signal: controller.signal,
})
if (!response.ok) {
console.error(`Save article HTTP ${response.status}`, response)
// Fallback locale se n8n fallisce
const saved = JSON.parse(localStorage.getItem('briefai_saved_articles') || '{}')
saved[articleId] = true
localStorage.setItem('briefai_saved_articles', JSON.stringify(saved))
return
}
} catch (error) {
console.error('Save article error:', error)
if (error instanceof Error && error.name === 'AbortError') {
throw new Error('Timeout save article')
}
// Fallback locale se errore di rete
const saved = JSON.parse(localStorage.getItem('briefai_saved_articles') || '{}')
saved[articleId] = true
localStorage.setItem('briefai_saved_articles', JSON.stringify(saved))
} finally {
clearTimeout(timeoutId)
}
}