Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1eb1889892 | |||
| 54ae86a88a | |||
| 249db76341 |
@@ -0,0 +1,116 @@
|
|||||||
|
# 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
|
||||||
@@ -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,6 +31,9 @@ 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 },
|
||||||
},
|
},
|
||||||
@@ -67,6 +70,8 @@ userSchema.post('save', async function syncUserProfile(doc) {
|
|||||||
// 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,6 +16,8 @@ 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,11 +37,34 @@ 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,
|
articles: sanitizedArticles,
|
||||||
total,
|
total,
|
||||||
page: parsedPage,
|
page: parsedPage,
|
||||||
limit: parsedLimit,
|
limit: parsedLimit,
|
||||||
|
|||||||
@@ -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({
|
const existingUser = await User.findOne({
|
||||||
$or: [{ email }, { username }],
|
$or: [{ email }, { username }],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ 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) {
|
||||||
@@ -40,6 +42,8 @@ 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 });
|
||||||
@@ -53,7 +57,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 } = req.body;
|
const { macroTopics, keywords, preferredSources, subscriptionState } = req.body;
|
||||||
|
|
||||||
const user = await User.findOne({ userId: req.user.userId });
|
const user = await User.findOne({ userId: req.user.userId });
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -66,6 +70,16 @@ 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();
|
||||||
@@ -78,6 +92,8 @@ 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 }
|
||||||
@@ -97,6 +113,8 @@ 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,3 +1,2 @@
|
|||||||
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,13 +4,15 @@ 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, onUpgrade, onCancelSubscription}: AccountSettingsProps) {
|
function AccountSettings({username, email, subscriptionState, subscriptionExpiresAt, 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">
|
||||||
@@ -48,7 +50,7 @@ function AccountSettings({username, email, subscriptionState, onUpgrade, onCance
|
|||||||
{isProPlan ? 'Piano Pro' : 'Piano Gratuito'}
|
{isProPlan ? 'Piano Pro' : 'Piano Gratuito'}
|
||||||
</span>
|
</span>
|
||||||
{isProPlan ? (
|
{isProPlan ? (
|
||||||
<p>Scade il 12/12/2026</p>
|
<p>{expires ? `Scade il ${expires.toLocaleDateString()}` : 'Piano Pro attivo'}</p>
|
||||||
) : (
|
) : (
|
||||||
<p>Passa al Pro per sbloccare monitoraggio e analisi avanzate.</p>
|
<p>Passa al Pro per sbloccare monitoraggio e analisi avanzate.</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,20 +1,45 @@
|
|||||||
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 { sendFeedback, saveArticle } 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 }: FeedContentProps) {
|
function FeedContent({ sentimentFilter = null, topicsFilter = null, preferenceFilter = 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.
|
||||||
@@ -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
|
// 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)
|
||||||
@@ -72,6 +114,7 @@ function FeedContent({ sentimentFilter = null, topicsFilter = null }: FeedConten
|
|||||||
{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' && (
|
||||||
<>
|
<>
|
||||||
@@ -79,13 +122,13 @@ function FeedContent({ sentimentFilter = null, topicsFilter = null }: FeedConten
|
|||||||
{(() => {
|
{(() => {
|
||||||
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()
|
const topic = topicsFilter.toLowerCase().trim()
|
||||||
const catMatch = (a.category || '').toLowerCase() === topic
|
const catMatch = (a.category || '').toLowerCase().trim() === topic
|
||||||
const trending = (a.trendingTopics || []).some((t) =>
|
const trending = (a.trendingTopics || []).some((t) =>
|
||||||
String(t || '').toLowerCase() === topic
|
String(t || '').toLowerCase().trim() === topic
|
||||||
)
|
)
|
||||||
const macroTopicMatch = (a.macroTopics || []).some((t) =>
|
const macroTopicMatch = (a.macroTopics || []).some((t) =>
|
||||||
String(t || '').toLowerCase() === topic
|
String(t || '').toLowerCase().trim() === topic
|
||||||
)
|
)
|
||||||
return catMatch || trending || macroTopicMatch
|
return catMatch || trending || macroTopicMatch
|
||||||
}
|
}
|
||||||
@@ -95,7 +138,18 @@ function FeedContent({ sentimentFilter = null, topicsFilter = null }: FeedConten
|
|||||||
return a.sentiment === sentimentFilter
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -122,7 +176,10 @@ function FeedContent({ sentimentFilter = null, topicsFilter = null }: FeedConten
|
|||||||
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,6 +4,8 @@ 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({
|
||||||
@@ -12,6 +14,8 @@ function FeedTopbar({
|
|||||||
onSentimentChange,
|
onSentimentChange,
|
||||||
topicsFilter = null,
|
topicsFilter = null,
|
||||||
onTopicChange,
|
onTopicChange,
|
||||||
|
preferenceFilter = null,
|
||||||
|
onPreferenceChange,
|
||||||
}: FeedTopbarProps) {
|
}: FeedTopbarProps) {
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
@@ -56,6 +60,19 @@ 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>
|
||||||
|
|||||||
@@ -12,11 +12,14 @@ 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, 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) => {
|
const handleVote = (clickedVote: 1 | -1) => {
|
||||||
if (!articleId || votePending) return
|
if (!articleId || votePending) return
|
||||||
|
|
||||||
@@ -24,6 +27,11 @@ 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. */}
|
||||||
@@ -78,22 +86,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">
|
<ActionButton label="Salva" tone="save" isActive={isSaved} onClick={handleSave} disabled={savePending}>
|
||||||
<BookmarkIcon />
|
<BookmarkIcon />
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -102,11 +110,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, 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 (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`action-button ${tone}`}
|
className={`action-button ${tone}${isActive ? ' active' : ''}`}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@@ -388,6 +388,24 @@
|
|||||||
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;
|
||||||
@@ -401,14 +419,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.load-more-button {
|
.load-more-button {
|
||||||
color: #2563eb;
|
padding: 12px 28px;
|
||||||
background: #fff;
|
border: none;
|
||||||
border: 1px solid #93c5fd;
|
border-radius: 12px;
|
||||||
box-shadow: none;
|
color: #fff;
|
||||||
|
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 {
|
||||||
background: #dbeafe;
|
transform: translateY(-2px);
|
||||||
|
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) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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)
|
||||||
@@ -16,6 +17,10 @@ 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" />
|
||||||
@@ -27,8 +32,10 @@ function FeedPage() {
|
|||||||
onSentimentChange={handleSentimentChange}
|
onSentimentChange={handleSentimentChange}
|
||||||
topicsFilter={topicsFilter}
|
topicsFilter={topicsFilter}
|
||||||
onTopicChange={handleTopicChange}
|
onTopicChange={handleTopicChange}
|
||||||
|
preferenceFilter={preferenceFilter}
|
||||||
|
onPreferenceChange={handlePreferenceChange}
|
||||||
/>
|
/>
|
||||||
<FeedContent sentimentFilter={sentimentFilter} topicsFilter={topicsFilter} />
|
<FeedContent sentimentFilter={sentimentFilter} topicsFilter={topicsFilter} preferenceFilter={preferenceFilter} />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ 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,
|
||||||
|
|||||||
@@ -121,12 +121,26 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
background: linear-gradient(90deg, #2563eb, #1d4ed8);
|
padding: 10px 16px;
|
||||||
transition: transform 0.2s ease, opacity 0.2s ease, background 0.2s ease;
|
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 {
|
.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 {
|
.settings-save-button svg {
|
||||||
@@ -135,6 +149,33 @@
|
|||||||
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;
|
||||||
@@ -152,19 +193,6 @@
|
|||||||
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;
|
||||||
@@ -263,17 +291,50 @@
|
|||||||
|
|
||||||
.subscription-upgrade-button {
|
.subscription-upgrade-button {
|
||||||
margin-top: 0;
|
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 {
|
.subscription-link-button {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
padding: 0;
|
padding: 10px 18px;
|
||||||
border: none;
|
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;
|
color: #2563eb;
|
||||||
background: transparent;
|
border-color: #2563eb;
|
||||||
text-decoration: underline;
|
background: #eff6ff;
|
||||||
text-underline-offset: 2px;
|
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscription-link-button:active {
|
||||||
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 900px) {
|
@media (min-width: 900px) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type SettingsSnapshot = {
|
|||||||
selectedMacroTopics: string[]
|
selectedMacroTopics: string[]
|
||||||
keywords: string[]
|
keywords: string[]
|
||||||
subscriptionState: SubscriptionState
|
subscriptionState: SubscriptionState
|
||||||
|
subscriptionExpiresAt?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProfileIdentity = {
|
type ProfileIdentity = {
|
||||||
@@ -43,6 +44,7 @@ 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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +81,9 @@ 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()
|
||||||
@@ -90,6 +95,8 @@ 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(() => {})
|
||||||
@@ -110,11 +117,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 })
|
persistSettings({ selectedMacroTopics, keywords, subscriptionState, subscriptionExpiresAt })
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// fallback local persist
|
// fallback local persist
|
||||||
persistSettings({ selectedMacroTopics, keywords, subscriptionState })
|
persistSettings({ selectedMacroTopics, keywords, subscriptionState, subscriptionExpiresAt })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,23 +149,44 @@ function SettingsPage() {
|
|||||||
if (res && res.profile) {
|
if (res && res.profile) {
|
||||||
setKeywords(res.profile.keywords || keywords)
|
setKeywords(res.profile.keywords || keywords)
|
||||||
}
|
}
|
||||||
persistSettings({ selectedMacroTopics, keywords, subscriptionState })
|
persistSettings({ selectedMacroTopics, keywords, subscriptionState, subscriptionExpiresAt })
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
persistSettings({ selectedMacroTopics, keywords, subscriptionState })
|
persistSettings({ selectedMacroTopics, keywords, subscriptionState, subscriptionExpiresAt })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpgrade = () => {
|
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'
|
const nextSubscriptionState: SubscriptionState = 'pro'
|
||||||
setSubscriptionState(nextSubscriptionState)
|
setSubscriptionState(nextSubscriptionState)
|
||||||
persistSettings({ selectedMacroTopics, keywords, subscriptionState: nextSubscriptionState })
|
setSubscriptionExpiresAt(new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString())
|
||||||
|
persistSettings({ selectedMacroTopics, keywords, subscriptionState: nextSubscriptionState, subscriptionExpiresAt })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCancelSubscription = () => {
|
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'
|
const nextSubscriptionState: SubscriptionState = 'free'
|
||||||
setSubscriptionState(nextSubscriptionState)
|
setSubscriptionState(nextSubscriptionState)
|
||||||
persistSettings({ selectedMacroTopics, keywords, subscriptionState: nextSubscriptionState })
|
setSubscriptionExpiresAt(null)
|
||||||
|
persistSettings({ selectedMacroTopics, keywords, subscriptionState: nextSubscriptionState, subscriptionExpiresAt })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -198,6 +226,7 @@ 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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export type ProfileResponse = {
|
|||||||
email?: string
|
email?: string
|
||||||
macroTopics?: string[]
|
macroTopics?: string[]
|
||||||
keywords?: string[]
|
keywords?: string[]
|
||||||
|
subscriptionPlan?: 'free' | 'pro'
|
||||||
|
subscriptionExpiresAt?: string | null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +49,7 @@ 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', {
|
||||||
@@ -57,7 +60,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 && data.macroTopics) {
|
if (n8nUrl && data.userId) {
|
||||||
try {
|
try {
|
||||||
await fetch(`${n8nUrl}/briefai/profile/update`, {
|
await fetch(`${n8nUrl}/briefai/profile/update`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -66,6 +69,7 @@ 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) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Article } from '../types/article'
|
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
|
const N8N = import.meta.env.VITE_N8N_URL
|
||||||
|
|
||||||
@@ -9,8 +9,19 @@ 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)
|
||||||
const userId: string = payload?.userId
|
let 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,3 +46,61 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user