From 54ae86a88ab55fa10498fba2b10e6f87f50dc82f Mon Sep 17 00:00:00 2001 From: Nicolo-Salvafiorita Date: Thu, 7 May 2026 11:56:14 +0200 Subject: [PATCH] feat: Implement subscription management and article saving features - Added subscriptionPlan and subscriptionExpiresAt fields to UserProfile model. - Enhanced profile routes to handle subscription state updates and expiration logic. - Introduced email and password validation during user registration. - Implemented link stripping for processed articles in the articles route. - Added save article functionality with appropriate UI feedback in the frontend. - Updated AccountSettings and FeedContent components to reflect subscription state and saved articles. - Improved error handling and local storage management for user preferences. --- Fix.md | 127 ++++++++++++++++++ .../backend/src/models/User.js | 9 +- .../backend/src/models/UserProfile.js | 2 + .../backend/src/routes/articles.js | 25 +++- .../backend/src/routes/auth.js | 18 +++ .../backend/src/routes/profile.js | 20 ++- .../src/components/AccountSettings.tsx | 6 +- .../src/components/FeedContent.tsx | 71 +++++++++- .../src/components/FeedTopbar.tsx | 17 +++ frontend-BriefAI/src/components/MagicCard.tsx | 20 ++- frontend-BriefAI/src/pages/FeedPage.css | 41 +++++- frontend-BriefAI/src/pages/FeedPage.tsx | 9 +- frontend-BriefAI/src/pages/RegisterPage.tsx | 7 + frontend-BriefAI/src/pages/SettingsPage.css | 105 ++++++++++++--- frontend-BriefAI/src/pages/SettingsPage.tsx | 53 ++++++-- frontend-BriefAI/src/services/apiService.ts | 6 +- frontend-BriefAI/src/services/feedService.ts | 17 ++- .../src/services/feedbackService.ts | 58 ++++++++ 18 files changed, 548 insertions(+), 63 deletions(-) create mode 100644 Fix.md diff --git a/Fix.md b/Fix.md new file mode 100644 index 0000000..d567e73 --- /dev/null +++ b/Fix.md @@ -0,0 +1,127 @@ +# 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 `text` → `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) + +--- + +## Testing Consigliato + +1. **Registrazione con email non valida** → Deve fallire +2. **Upgrade a Pro** → Deve aggiornare DB e mostrare data scadenza reale +3. **Cancel Subscription** → Deve tornare a Free e pulire expiry +4. **Articoli Processati** → Scaricare uno e verificare che URL/link siano rimossi +5. **FeedService** → Verificare che userId viene sempre recuperato correttamente +6. **Salva Articolo** → Cliccare bottone bookmark, deve disabilitarsi durante il caricamento e cambiare stile visivo se salvato + +--- + +## Credenziali Configurate + +- **MongoDB**: Atlas cluster (briefai-cluster) +- **JWT_SECRET**: Configurato in `.env` backend +- **CORS_ORIGIN**: http://localhost:5173 (frontend) +- **Env**: development diff --git a/digital-twin-news-feature-nicol-ai-frontend/backend/src/models/User.js b/digital-twin-news-feature-nicol-ai-frontend/backend/src/models/User.js index be3e458..ac1a2b2 100644 --- a/digital-twin-news-feature-nicol-ai-frontend/backend/src/models/User.js +++ b/digital-twin-news-feature-nicol-ai-frontend/backend/src/models/User.js @@ -31,6 +31,9 @@ const userSchema = new mongoose.Schema( preferredSources: [String], lastFeedGeneratedAt: Date, + subscriptionPlan: { type: String, enum: ['free', 'pro'], default: 'free' }, + subscriptionExpiresAt: Date, + createdAt: { type: Date, default: Date.now }, updatedAt: { type: Date, default: Date.now }, }, @@ -65,8 +68,10 @@ userSchema.post('save', async function syncUserProfile(doc) { keywords: doc.keywords || [], weights: weightsObject, // sentimentPreference removed: we no longer persist a global sentiment preference - preferredSources: doc.preferredSources || [], - lastFeedGeneratedAt: doc.lastFeedGeneratedAt || null, + preferredSources: doc.preferredSources || [], + lastFeedGeneratedAt: doc.lastFeedGeneratedAt || null, + subscriptionPlan: doc.subscriptionPlan || 'free', + subscriptionExpiresAt: doc.subscriptionExpiresAt || null, updatedAt: new Date(), }, }, diff --git a/digital-twin-news-feature-nicol-ai-frontend/backend/src/models/UserProfile.js b/digital-twin-news-feature-nicol-ai-frontend/backend/src/models/UserProfile.js index 7f268ff..88b81b4 100644 --- a/digital-twin-news-feature-nicol-ai-frontend/backend/src/models/UserProfile.js +++ b/digital-twin-news-feature-nicol-ai-frontend/backend/src/models/UserProfile.js @@ -16,6 +16,8 @@ const userProfileSchema = new mongoose.Schema( }, }, preferredSources: { type: [String], default: [] }, + subscriptionPlan: { type: String, enum: ['free', 'pro'], default: 'free' }, + subscriptionExpiresAt: Date, lastFeedGeneratedAt: Date, updatedAt: { type: Date, default: Date.now }, }, diff --git a/digital-twin-news-feature-nicol-ai-frontend/backend/src/routes/articles.js b/digital-twin-news-feature-nicol-ai-frontend/backend/src/routes/articles.js index d2240f8..4648045 100644 --- a/digital-twin-news-feature-nicol-ai-frontend/backend/src/routes/articles.js +++ b/digital-twin-news-feature-nicol-ai-frontend/backend/src/routes/articles.js @@ -37,11 +37,34 @@ router.get('/', auth, async (req, res) => { .skip((parsedPage - 1) * parsedLimit) .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>/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); return res.json({ success: true, - articles, + articles: sanitizedArticles, total, page: parsedPage, limit: parsedLimit, diff --git a/digital-twin-news-feature-nicol-ai-frontend/backend/src/routes/auth.js b/digital-twin-news-feature-nicol-ai-frontend/backend/src/routes/auth.js index a832db1..66d0cfd 100644 --- a/digital-twin-news-feature-nicol-ai-frontend/backend/src/routes/auth.js +++ b/digital-twin-news-feature-nicol-ai-frontend/backend/src/routes/auth.js @@ -30,6 +30,24 @@ 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({ $or: [{ email }, { username }], }); diff --git a/digital-twin-news-feature-nicol-ai-frontend/backend/src/routes/profile.js b/digital-twin-news-feature-nicol-ai-frontend/backend/src/routes/profile.js index 3fbfaa8..cc80dbc 100644 --- a/digital-twin-news-feature-nicol-ai-frontend/backend/src/routes/profile.js +++ b/digital-twin-news-feature-nicol-ai-frontend/backend/src/routes/profile.js @@ -32,6 +32,8 @@ router.get('/', auth, async (req, res) => { weights: userWeights, preferredSources: user.preferredSources || [], lastFeedGeneratedAt: user.lastFeedGeneratedAt || null, + subscriptionPlan: user.subscriptionPlan || 'free', + subscriptionExpiresAt: user.subscriptionExpiresAt || null, }; if (userProfile) { @@ -40,6 +42,8 @@ router.get('/', auth, async (req, res) => { profile.weights = userProfile.weights || profile.weights; profile.preferredSources = userProfile.preferredSources || profile.preferredSources; profile.lastFeedGeneratedAt = userProfile.lastFeedGeneratedAt || profile.lastFeedGeneratedAt; + profile.subscriptionPlan = userProfile.subscriptionPlan || profile.subscriptionPlan; + profile.subscriptionExpiresAt = userProfile.subscriptionExpiresAt || profile.subscriptionExpiresAt; } return res.json({ success: true, profile }); @@ -53,7 +57,7 @@ router.get('/', auth, async (req, res) => { router.put('/', auth, async (req, res) => { try { - const { macroTopics, keywords, preferredSources } = req.body; + const { macroTopics, keywords, preferredSources, subscriptionState } = req.body; const user = await User.findOne({ userId: req.user.userId }); if (!user) { @@ -66,6 +70,16 @@ router.put('/', auth, async (req, res) => { if (macroTopics) user.macroTopics = macroTopics; if (keywords) user.keywords = keywords; 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(); await user.save(); @@ -78,6 +92,8 @@ router.put('/', auth, async (req, res) => { macroTopics: user.macroTopics, keywords: user.keywords, preferredSources: user.preferredSources, + subscriptionPlan: user.subscriptionPlan, + subscriptionExpiresAt: user.subscriptionExpiresAt, updatedAt: new Date(), }, { upsert: true } @@ -97,6 +113,8 @@ router.put('/', auth, async (req, res) => { keywords: user.keywords, weights, preferredSources: user.preferredSources, + subscriptionPlan: user.subscriptionPlan, + subscriptionExpiresAt: user.subscriptionExpiresAt, }, }); } catch (err) { diff --git a/frontend-BriefAI/src/components/AccountSettings.tsx b/frontend-BriefAI/src/components/AccountSettings.tsx index 7bb7845..cecf963 100644 --- a/frontend-BriefAI/src/components/AccountSettings.tsx +++ b/frontend-BriefAI/src/components/AccountSettings.tsx @@ -4,13 +4,15 @@ type AccountSettingsProps = { username: string email: string subscriptionState: SubscriptionState + subscriptionExpiresAt?: string | null onUpgrade: () => void onCancelSubscription: () => void } // Sezione account: mostra profilo e gestisce in modo condizionale il piano attivo. -function AccountSettings({username, email, subscriptionState, onUpgrade, onCancelSubscription}: AccountSettingsProps) { +function AccountSettings({username, email, subscriptionState, subscriptionExpiresAt, onUpgrade, onCancelSubscription}: AccountSettingsProps) { const isProPlan = subscriptionState === 'pro' + const expires = subscriptionExpiresAt ? new Date(subscriptionExpiresAt) : null return (
@@ -48,7 +50,7 @@ function AccountSettings({username, email, subscriptionState, onUpgrade, onCance {isProPlan ? 'Piano Pro' : 'Piano Gratuito'} {isProPlan ? ( -

Scade il 12/12/2026

+

{expires ? `Scade il ${expires.toLocaleDateString()}` : 'Piano Pro attivo'}

) : (

Passa al Pro per sbloccare monitoraggio e analisi avanzate.

)} diff --git a/frontend-BriefAI/src/components/FeedContent.tsx b/frontend-BriefAI/src/components/FeedContent.tsx index 0d03ead..b9aa08e 100644 --- a/frontend-BriefAI/src/components/FeedContent.tsx +++ b/frontend-BriefAI/src/components/FeedContent.tsx @@ -1,20 +1,45 @@ import { useEffect, useState } from 'react' import MagicCard from './MagicCard' import { fetchPersonalizedFeed } from '../services/feedService' -import { sendFeedback } from '../services/feedbackService' +import { sendFeedback, saveArticle } from '../services/feedbackService' import type { Article } from '../types/article' type FeedContentProps = { sentimentFilter?: string | null topicsFilter?: string | null + preferenceFilter?: string | null } -function FeedContent({ sentimentFilter = null, topicsFilter = null }: FeedContentProps) { +function FeedContent({ sentimentFilter = null, topicsFilter = null, preferenceFilter = null }: FeedContentProps) { const [articles, setArticles] = useState([]) const [status, setStatus] = useState<'loading' | 'ok' | 'error'>('loading') const [voteByArticle, setVoteByArticle] = useState>({}) const [pendingByArticle, setPendingByArticle] = useState>({}) + const [savedByArticle, setSavedByArticle] = useState>({}) + const [savePendingByArticle, setSavePendingByArticle] = useState>({}) const [voteError, setVoteError] = useState(null) + const [saveError, setSaveError] = useState(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) => { // Without server idempotency, simulate undo by applying compensating vote. @@ -53,6 +78,23 @@ function FeedContent({ sentimentFilter = null, topicsFilter = null }: FeedConten } } + 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 useEffect(() => {fetchPersonalizedFeed().then((data) => { setArticles(data) @@ -72,6 +114,7 @@ function FeedContent({ sentimentFilter = null, topicsFilter = null }: FeedConten {status === 'loading' &&

Caricamento feed...

} {status === 'error' &&

Errore nel caricamento del feed.

} {voteError &&

{voteError}

} + {saveError &&

{saveError}

} {/*Render della lista di articoli se il caricamento è andato a buon fine*/} {status === 'ok' && ( <> @@ -79,13 +122,13 @@ function FeedContent({ sentimentFilter = null, topicsFilter = null }: FeedConten {(() => { const matchesTopic = (a: Article) => { if (!topicsFilter || topicsFilter === 'All Topics') return true - const topic = topicsFilter.toLowerCase() - const catMatch = (a.category || '').toLowerCase() === topic + const topic = topicsFilter.toLowerCase().trim() + const catMatch = (a.category || '').toLowerCase().trim() === topic const trending = (a.trendingTopics || []).some((t) => - String(t || '').toLowerCase() === topic + String(t || '').toLowerCase().trim() === topic ) const macroTopicMatch = (a.macroTopics || []).some((t) => - String(t || '').toLowerCase() === topic + String(t || '').toLowerCase().trim() === topic ) return catMatch || trending || macroTopicMatch } @@ -95,7 +138,18 @@ function FeedContent({ sentimentFilter = null, topicsFilter = null }: FeedConten return a.sentiment === sentimentFilter } - const filtered = articles.filter((a) => matchesSentiment(a) && matchesTopic(a)) + const matchesPreference = (a: Article) => { + 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 (
@@ -122,7 +176,10 @@ function FeedContent({ sentimentFilter = null, topicsFilter = null }: FeedConten entities={a.entities || []} voteState={voteByArticle[a.uniqueKey] ?? null} votePending={pendingByArticle[a.uniqueKey] ?? false} + isSaved={savedByArticle[a.uniqueKey] ?? false} + savePending={savePendingByArticle[a.uniqueKey] ?? false} onVoteChange={handleVoteChange} + onSave={handleSaveArticle} /> ))}
diff --git a/frontend-BriefAI/src/components/FeedTopbar.tsx b/frontend-BriefAI/src/components/FeedTopbar.tsx index d918008..4bb9a0a 100644 --- a/frontend-BriefAI/src/components/FeedTopbar.tsx +++ b/frontend-BriefAI/src/components/FeedTopbar.tsx @@ -4,6 +4,8 @@ type FeedTopbarProps = { onSentimentChange?: (sentiment: string) => void topicsFilter?: string | null onTopicChange?: (topic: string) => void + preferenceFilter?: string | null + onPreferenceChange?: (preference: string) => void } function FeedTopbar({ @@ -12,6 +14,8 @@ function FeedTopbar({ onSentimentChange, topicsFilter = null, onTopicChange, + preferenceFilter = null, + onPreferenceChange, }: FeedTopbarProps) { return (
Trasporti & Mobilità + + ) : null}
diff --git a/frontend-BriefAI/src/components/MagicCard.tsx b/frontend-BriefAI/src/components/MagicCard.tsx index 9e55d3f..1303ca7 100644 --- a/frontend-BriefAI/src/components/MagicCard.tsx +++ b/frontend-BriefAI/src/components/MagicCard.tsx @@ -12,11 +12,14 @@ type MagicCardProps = { entities: string[] voteState: 1 | -1 | null votePending?: boolean + isSaved?: boolean + savePending?: boolean onVoteChange: (articleId: string, nextVote: 1 | -1 | null) => void + onSave?: (articleId: string) => void } // 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, onVoteChange }: MagicCardProps) { +function MagicCard({ articleId, articleUrl, source, timeAgo, sentiment, title, summary, tags, entities, voteState, votePending = false, isSaved = false, savePending = false, onVoteChange, onSave }: MagicCardProps) { const handleVote = (clickedVote: 1 | -1) => { if (!articleId || votePending) return @@ -24,6 +27,11 @@ function MagicCard({ articleId, articleUrl, source, timeAgo, sentiment, title, s onVoteChange(articleId, nextVote) } + const handleSave = () => { + if (!articleId || savePending || !onSave) return + onSave(articleId) + } + return (
{/* Testata card: fonte e badge del sentiment allineati ai lati opposti. */} @@ -78,22 +86,22 @@ function MagicCard({ articleId, articleUrl, source, timeAgo, sentiment, title, s handleVote(1)} disabled={votePending} - style={{ opacity: voteState === -1 ? 0.4 : 1 }} > handleVote(-1)} disabled={votePending} - style={{ opacity: voteState === 1 ? 0.4 : 1 }} > - + @@ -102,11 +110,11 @@ function MagicCard({ articleId, articleUrl, source, timeAgo, sentiment, title, s } // Piccolo bottone riutilizzabile per mantenere coerente il footer azioni. -function ActionButton({ label, tone, children, onClick, disabled, style }: { label: string; tone: string; children: ReactNode; onClick?: () => void; disabled?: boolean; style?: React.CSSProperties }) { +function ActionButton({ label, tone, children, onClick, disabled, isActive, style }: { label: string; tone: string; children: ReactNode; onClick?: () => void; disabled?: boolean; isActive?: boolean; style?: React.CSSProperties }) { return (
) diff --git a/frontend-BriefAI/src/pages/RegisterPage.tsx b/frontend-BriefAI/src/pages/RegisterPage.tsx index d6ba1e3..00c0738 100644 --- a/frontend-BriefAI/src/pages/RegisterPage.tsx +++ b/frontend-BriefAI/src/pages/RegisterPage.tsx @@ -39,6 +39,13 @@ function RegisterPage({ onRegisterSuccess }: RegisterPageProps) { // 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 { await register({ email, diff --git a/frontend-BriefAI/src/pages/SettingsPage.css b/frontend-BriefAI/src/pages/SettingsPage.css index f625daa..ff0c4e7 100644 --- a/frontend-BriefAI/src/pages/SettingsPage.css +++ b/frontend-BriefAI/src/pages/SettingsPage.css @@ -121,12 +121,26 @@ display: inline-flex; align-items: center; gap: 8px; - background: linear-gradient(90deg, #2563eb, #1d4ed8); - transition: transform 0.2s ease, opacity 0.2s ease, background 0.2s ease; + 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); } .settings-save-button:hover { - transform: translateY(-1px); + transform: translateY(-2px); + 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 { @@ -135,6 +149,33 @@ 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 { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -152,19 +193,6 @@ 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 { display: flex; flex-wrap: wrap; @@ -263,17 +291,50 @@ .subscription-upgrade-button { margin-top: 0; - background: linear-gradient(90deg, #2563eb, #7c3aed); + padding: 10px 18px; + 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 { margin-top: 0; - padding: 0; - border: none; + padding: 10px 18px; + border: 1px solid #e5e7eb; + 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; - background: transparent; - text-decoration: underline; - text-underline-offset: 2px; + border-color: #2563eb; + background: #eff6ff; + box-shadow: 0 2px 8px rgba(37, 99, 235, 0.15); +} + +.subscription-link-button:active { + transform: scale(0.98); } @media (min-width: 900px) { diff --git a/frontend-BriefAI/src/pages/SettingsPage.tsx b/frontend-BriefAI/src/pages/SettingsPage.tsx index e437cbc..fd9acdf 100644 --- a/frontend-BriefAI/src/pages/SettingsPage.tsx +++ b/frontend-BriefAI/src/pages/SettingsPage.tsx @@ -13,6 +13,7 @@ type SettingsSnapshot = { selectedMacroTopics: string[] keywords: string[] subscriptionState: SubscriptionState + subscriptionExpiresAt?: string | null } type ProfileIdentity = { @@ -43,6 +44,7 @@ function readInitialSettings(): SettingsSnapshot { storedSnapshot?.selectedMacroTopics ?? onboardingSnapshot?.selectedTopics ?? DEFAULT_MACRO_TOPICS, keywords: storedSnapshot?.keywords ?? onboardingSnapshot?.keywords ?? [], subscriptionState: storedSnapshot?.subscriptionState ?? 'free', + subscriptionExpiresAt: storedSnapshot?.subscriptionExpiresAt ?? null, } } @@ -79,6 +81,9 @@ function SettingsPage() { const [subscriptionState, setSubscriptionState] = useState( () => readInitialSettings().subscriptionState, ) + const [subscriptionExpiresAt, setSubscriptionExpiresAt] = useState(() => + (readInitialSettings() as any).subscriptionExpiresAt ?? null, + ) useEffect(() => { fetchProfile() @@ -90,6 +95,8 @@ function SettingsPage() { }) setSelectedMacroTopics(res.profile.macroTopics || []) setKeywords(res.profile.keywords || []) + if (res.profile.subscriptionPlan) setSubscriptionState(res.profile.subscriptionPlan as SubscriptionState) + setSubscriptionExpiresAt(res.profile.subscriptionExpiresAt || null) } }) .catch(() => {}) @@ -110,11 +117,11 @@ function SettingsPage() { if (res && res.profile) { setSelectedMacroTopics(res.profile.macroTopics || selectedMacroTopics) } - persistSettings({ selectedMacroTopics, keywords, subscriptionState }) + persistSettings({ selectedMacroTopics, keywords, subscriptionState, subscriptionExpiresAt }) }) .catch(() => { // fallback local persist - persistSettings({ selectedMacroTopics, keywords, subscriptionState }) + persistSettings({ selectedMacroTopics, keywords, subscriptionState, subscriptionExpiresAt }) }) } @@ -142,23 +149,44 @@ function SettingsPage() { if (res && res.profile) { setKeywords(res.profile.keywords || keywords) } - persistSettings({ selectedMacroTopics, keywords, subscriptionState }) + persistSettings({ selectedMacroTopics, keywords, subscriptionState, subscriptionExpiresAt }) }) .catch(() => { - persistSettings({ selectedMacroTopics, keywords, subscriptionState }) + persistSettings({ selectedMacroTopics, keywords, subscriptionState, subscriptionExpiresAt }) }) } - const handleUpgrade = () => { - const nextSubscriptionState: SubscriptionState = 'pro' - setSubscriptionState(nextSubscriptionState) - persistSettings({ selectedMacroTopics, keywords, subscriptionState: nextSubscriptionState }) + const handleUpgrade = async () => { + try { + const res = await updateProfile({ subscriptionState: 'pro' }) + if (res && res.profile) { + 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 = () => { - const nextSubscriptionState: SubscriptionState = 'free' - setSubscriptionState(nextSubscriptionState) - persistSettings({ selectedMacroTopics, keywords, subscriptionState: nextSubscriptionState }) + const handleCancelSubscription = async () => { + try { + const res = await updateProfile({ subscriptionState: 'free' }) + if (res && res.profile) { + 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 ( @@ -198,6 +226,7 @@ function SettingsPage() { username={profileIdentity.username || 'Utente'} email={profileIdentity.email || 'Email non disponibile'} subscriptionState={subscriptionState} + subscriptionExpiresAt={subscriptionExpiresAt} onUpgrade={handleUpgrade} onCancelSubscription={handleCancelSubscription} /> diff --git a/frontend-BriefAI/src/services/apiService.ts b/frontend-BriefAI/src/services/apiService.ts index 415f64c..526195e 100644 --- a/frontend-BriefAI/src/services/apiService.ts +++ b/frontend-BriefAI/src/services/apiService.ts @@ -9,6 +9,8 @@ export type ProfileResponse = { email?: string macroTopics?: string[] keywords?: string[] + subscriptionPlan?: 'free' | 'pro' + subscriptionExpiresAt?: string | null } } @@ -47,6 +49,7 @@ export const updateProfile = async (data: { userId?: string macroTopics?: string[] keywords?: string[] + subscriptionState?: 'free' | 'pro' }) => { // Sync to backend const backendRes = await authFetch('/api/profile', { @@ -57,7 +60,7 @@ export const updateProfile = async (data: { // Opt-in sync to n8n if url is present and userId is known const n8nUrl = import.meta.env.VITE_N8N_URL; - if (n8nUrl && data.userId && data.macroTopics) { + if (n8nUrl && data.userId) { try { await fetch(`${n8nUrl}/briefai/profile/update`, { method: 'POST', @@ -66,6 +69,7 @@ export const updateProfile = async (data: { userId: data.userId, macroTopics: data.macroTopics, keywords: data.keywords, + subscriptionState: data.subscriptionState, }) }); } catch (e) { diff --git a/frontend-BriefAI/src/services/feedService.ts b/frontend-BriefAI/src/services/feedService.ts index 14acc94..f63397d 100644 --- a/frontend-BriefAI/src/services/feedService.ts +++ b/frontend-BriefAI/src/services/feedService.ts @@ -1,5 +1,5 @@ import type { Article } from '../types/article' -import { getAuthHeader, decodeToken } from './authService' +import { getAuthHeader, decodeToken, getMe } from './authService' const N8N = import.meta.env.VITE_N8N_URL @@ -9,8 +9,19 @@ export const fetchPersonalizedFeed = async (): Promise => { if (!token) throw new Error('Non autenticato') const payload = decodeToken(token) - const userId: string = payload?.userId - if (!userId) throw new Error('Token non valido') + let userId: string = payload?.userId + + // 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`, { method: 'POST', diff --git a/frontend-BriefAI/src/services/feedbackService.ts b/frontend-BriefAI/src/services/feedbackService.ts index 0a69a23..820bcbd 100644 --- a/frontend-BriefAI/src/services/feedbackService.ts +++ b/frontend-BriefAI/src/services/feedbackService.ts @@ -46,3 +46,61 @@ export const sendFeedback = async ( clearTimeout(timeoutId) } } + +export const saveArticle = async (articleId: string): Promise => { + 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) + } +}