Prima versione della web app BriefAI
@@ -0,0 +1,7 @@
|
|||||||
|
la data del piano pro è statica e non penso che sia registrato nel database se il piano è pro o free
|
||||||
|
|
||||||
|
onCancelSubscription e onUpgrade sono void (AccountSettings.tsx)
|
||||||
|
|
||||||
|
in feedservices.ts non capisco se il token è quello che ho impostato io o è random
|
||||||
|
|
||||||
|
email non va bene perchè viene accettata a prescindere anche se non ha senso
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# environment
|
||||||
|
.env
|
||||||
|
|
||||||
|
# python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# logs and outputs
|
||||||
|
*.log
|
||||||
|
output/
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# 📰 Digital Twin News
|
||||||
|
|
||||||
|
Sistema di News Intelligence basato su LLM che raccoglie notizie tramite RSS,
|
||||||
|
le analizza con AI e fornisce una dashboard personalizzata per aziende.
|
||||||
|
|
||||||
|
## Team
|
||||||
|
| Membro | Ruolo |
|
||||||
|
|--------|-------|
|
||||||
|
| Cipolla Diego | Data Pipeline Engineer |
|
||||||
|
| Bocca Lorenzo | Frontend & Auth Engineer |
|
||||||
|
| Galluzzo Matteo | AI, Modelli & Logica |
|
||||||
|
| Salvafiorita Nicolò | Integrazione AI/UI |
|
||||||
|
|
||||||
|
## Architettura
|
||||||
|
```
|
||||||
|
[RSS Feed] → [Data Pipeline] → [MongoDB + Redis]
|
||||||
|
↓
|
||||||
|
[AI Analysis (Claude API)]
|
||||||
|
↓
|
||||||
|
[Backend API (Express)]
|
||||||
|
↓
|
||||||
|
[Frontend Dashboard (React)]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stack tecnologico
|
||||||
|
| Layer | Tecnologie |
|
||||||
|
|-------|-----------|
|
||||||
|
| Data Pipeline | Node.js, RSS Parser, Redis, node-cron |
|
||||||
|
| Storage | MongoDB, Redis |
|
||||||
|
| AI & Analytics | Python, Claude API, NLTK |
|
||||||
|
| Backend | Node.js, Express, JWT |
|
||||||
|
| Frontend | React, Material UI, Chart.js, Recharts |
|
||||||
|
| Infrastruttura | Docker Compose |
|
||||||
|
|
||||||
|
## Avvio rapido
|
||||||
|
```bash
|
||||||
|
# 1. Avvia i database
|
||||||
|
docker-compose up -d mongo redis
|
||||||
|
|
||||||
|
# 2. AI Analysis
|
||||||
|
cd ai-analysis
|
||||||
|
source venv/bin/activate
|
||||||
|
python src/scheduler.py
|
||||||
|
|
||||||
|
# 3. Backend API
|
||||||
|
cd backend
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# 4. Data Pipeline
|
||||||
|
cd data-pipeline
|
||||||
|
npm start
|
||||||
|
|
||||||
|
# 5. Frontend
|
||||||
|
cd frontend
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## KPI del sistema
|
||||||
|
- Frequenza aggiornamento: ogni 15 minuti
|
||||||
|
- Batch AI: 10 articoli per ciclo
|
||||||
|
- Autenticazione: JWT 24h
|
||||||
|
- Fallback locale: attivo se API LLM non disponibile
|
||||||
|
|
||||||
|
## Limitazioni documentate
|
||||||
|
- Sistema batch-based (non real-time puro)
|
||||||
|
- Free tier API LLM gestito con fallback locale
|
||||||
|
- Testing automatizzato fuori scope MVP
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "src/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/server.js",
|
||||||
|
"dev": "nodemon src/server.js"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "commonjs",
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"cors": "^2.8.6",
|
||||||
|
"dotenv": "^17.4.1",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"mongoose": "^9.4.1",
|
||||||
|
"morgan": "^1.10.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.1.14"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
module.exports = function auth(req, res, next) {
|
||||||
|
const token = req.header('Authorization')?.replace('Bearer ', '');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Accesso negato. Token mancante.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
req.user = decoded;
|
||||||
|
return next();
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Token non valido o scaduto.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
const articleSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
uniqueKey: { type: String, unique: true, required: true },
|
||||||
|
title: { type: String, required: true },
|
||||||
|
url: String,
|
||||||
|
pubDate: String,
|
||||||
|
source: String,
|
||||||
|
category: String,
|
||||||
|
content: String,
|
||||||
|
|
||||||
|
summary: String,
|
||||||
|
sentiment: {
|
||||||
|
type: String,
|
||||||
|
enum: ['Positive', 'Neutral', 'Negative', null],
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
entities: [String],
|
||||||
|
trendingTopics: [String],
|
||||||
|
macroTopics: [String],
|
||||||
|
|
||||||
|
status: { type: String, enum: ['raw', 'processed'], default: 'raw' },
|
||||||
|
aiProcessed: { type: Boolean, default: false },
|
||||||
|
aiError: String,
|
||||||
|
processedAt: Date,
|
||||||
|
createdAt: { type: Date, default: Date.now },
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = mongoose.model('Article', articleSchema);
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
|
||||||
|
// Register model early so post-save hook can resolve it safely.
|
||||||
|
require('./UserProfile');
|
||||||
|
|
||||||
|
const userSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
userId: { type: String, unique: true, required: true },
|
||||||
|
email: { type: String, unique: true, required: true },
|
||||||
|
password: { type: String, required: true },
|
||||||
|
username: { type: String, unique: true, required: true },
|
||||||
|
role: { type: String, enum: ['admin', 'user'], default: 'user' },
|
||||||
|
|
||||||
|
macroTopics: [String],
|
||||||
|
keywords: [String],
|
||||||
|
weights: {
|
||||||
|
type: Map,
|
||||||
|
of: Number,
|
||||||
|
default: () =>
|
||||||
|
new Map([
|
||||||
|
['tech', 1.0],
|
||||||
|
['news', 1.0],
|
||||||
|
['social', 1.0],
|
||||||
|
['news-it', 1.0],
|
||||||
|
['general', 1.0],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
preferredSources: [String],
|
||||||
|
|
||||||
|
lastFeedGeneratedAt: Date,
|
||||||
|
createdAt: { type: Date, default: Date.now },
|
||||||
|
updatedAt: { type: Date, default: Date.now },
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
userSchema.pre('save', async function preSave() {
|
||||||
|
if (!this.isModified('password')) return;
|
||||||
|
this.password = await bcrypt.hash(this.password, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
userSchema.methods.comparePassword = async function comparePassword(candidatePassword) {
|
||||||
|
return bcrypt.compare(candidatePassword, this.password);
|
||||||
|
};
|
||||||
|
|
||||||
|
userSchema.post('save', async function syncUserProfile(doc) {
|
||||||
|
const UserProfile = mongoose.model('UserProfile');
|
||||||
|
|
||||||
|
const weightsObject = {};
|
||||||
|
if (doc.weights instanceof Map) {
|
||||||
|
doc.weights.forEach((value, key) => {
|
||||||
|
weightsObject[key] = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await UserProfile.updateOne(
|
||||||
|
{ userId: doc.userId },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
userId: doc.userId,
|
||||||
|
macroTopics: doc.macroTopics || ['Scienza & Ricerca'],
|
||||||
|
keywords: doc.keywords || [],
|
||||||
|
weights: weightsObject,
|
||||||
|
// sentimentPreference removed: we no longer persist a global sentiment preference
|
||||||
|
preferredSources: doc.preferredSources || [],
|
||||||
|
lastFeedGeneratedAt: doc.lastFeedGeneratedAt || null,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ upsert: true }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = mongoose.model('User', userSchema);
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
const userProfileSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
userId: { type: String, unique: true, required: true },
|
||||||
|
macroTopics: { type: [String], default: ['Scienza & Ricerca'] },
|
||||||
|
keywords: { type: [String], default: [] },
|
||||||
|
weights: {
|
||||||
|
type: Object,
|
||||||
|
default: {
|
||||||
|
tech: 1.0,
|
||||||
|
news: 1.0,
|
||||||
|
social: 1.0,
|
||||||
|
'news-it': 1.0,
|
||||||
|
general: 1.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preferredSources: { type: [String], default: [] },
|
||||||
|
lastFeedGeneratedAt: Date,
|
||||||
|
updatedAt: { type: Date, default: Date.now },
|
||||||
|
},
|
||||||
|
{ collection: 'user_profiles', timestamps: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = mongoose.model('UserProfile', userProfileSchema);
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const Article = require('../models/Article');
|
||||||
|
const auth = require('../middleware/auth');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/', auth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
category,
|
||||||
|
sentiment,
|
||||||
|
source,
|
||||||
|
status = 'processed',
|
||||||
|
limit = 50,
|
||||||
|
page = 1,
|
||||||
|
search,
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const filter = { status };
|
||||||
|
|
||||||
|
if (category) filter.category = category;
|
||||||
|
if (sentiment) filter.sentiment = sentiment;
|
||||||
|
if (source) filter.source = source;
|
||||||
|
if (search) {
|
||||||
|
filter.$or = [
|
||||||
|
{ title: { $regex: search, $options: 'i' } },
|
||||||
|
{ summary: { $regex: search, $options: 'i' } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedLimit = Number.parseInt(limit, 10) || 50;
|
||||||
|
const parsedPage = Number.parseInt(page, 10) || 1;
|
||||||
|
|
||||||
|
const articles = await Article.find(filter)
|
||||||
|
.sort({ pubDate: -1 })
|
||||||
|
.limit(parsedLimit)
|
||||||
|
.skip((parsedPage - 1) * parsedLimit)
|
||||||
|
.select('-__v');
|
||||||
|
|
||||||
|
const total = await Article.countDocuments(filter);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
articles,
|
||||||
|
total,
|
||||||
|
page: parsedPage,
|
||||||
|
limit: parsedLimit,
|
||||||
|
totalPages: Math.ceil(total / parsedLimit),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Articles List Error]', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:uniqueKey', auth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const article = await Article.findOne({ uniqueKey: req.params.uniqueKey });
|
||||||
|
|
||||||
|
if (!article) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Articolo non trovato.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
article,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const User = require('../models/User');
|
||||||
|
const auth = require('../middleware/auth');
|
||||||
|
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.post('/register', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { email, password, username } = req.body;
|
||||||
|
|
||||||
|
// Support payloads that send preferences nested under `preferences` (frontend)
|
||||||
|
// or as top-level fields for backwards compatibility.
|
||||||
|
const pref = req.body && req.body.preferences ? req.body.preferences : {}
|
||||||
|
const rawMacroTopics = Array.isArray(pref.macroTopics) ? pref.macroTopics : req.body.macroTopics
|
||||||
|
const rawKeywords = Array.isArray(pref.keywords) ? pref.keywords : req.body.keywords
|
||||||
|
|
||||||
|
const macroTopics = Array.isArray(rawMacroTopics) && rawMacroTopics.length
|
||||||
|
? rawMacroTopics
|
||||||
|
: ['Scienza & Ricerca']
|
||||||
|
|
||||||
|
const keywords = Array.isArray(rawKeywords) ? rawKeywords : []
|
||||||
|
|
||||||
|
if (!email || !password || !username) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Email, password e username sono obbligatori.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUser = await User.findOne({
|
||||||
|
$or: [{ email }, { username }],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Email o username gia registrati.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = `user_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||||
|
|
||||||
|
const user = new User({
|
||||||
|
userId,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
username,
|
||||||
|
macroTopics,
|
||||||
|
keywords,
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
// Genera token JWT al momento della registrazione per evitare login separato
|
||||||
|
const token = jwt.sign(
|
||||||
|
{
|
||||||
|
userId: user.userId,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
|
process.env.JWT_SECRET,
|
||||||
|
{ expiresIn: '24h' }
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Utente registrato con successo.',
|
||||||
|
token,
|
||||||
|
userId: user.userId,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Auth Register Error]', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/login', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Email e password sono obbligatori.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.findOne({ email });
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Utente non trovato.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await user.comparePassword(password);
|
||||||
|
if (!valid) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Password errata.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = jwt.sign(
|
||||||
|
{
|
||||||
|
userId: user.userId,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
|
process.env.JWT_SECRET,
|
||||||
|
{ expiresIn: '24h' }
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
token,
|
||||||
|
userId: user.userId,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Auth Login Error]', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/me', auth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = await User.findOne({ userId: req.user.userId }).select('-password');
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Utente non trovato.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const User = require('../models/User');
|
||||||
|
const UserProfile = require('../models/UserProfile');
|
||||||
|
const auth = require('../middleware/auth');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/', auth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = await User.findOne({ userId: req.user.userId }).select('-password');
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Profilo non trovato.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read associated UserProfile (may be created/updated by n8n)
|
||||||
|
const userProfile = await UserProfile.findOne({ userId: user.userId }).lean();
|
||||||
|
|
||||||
|
// Convert weights map (from User model) to plain object
|
||||||
|
const userWeights = user.weights instanceof Map ? Object.fromEntries(user.weights) : user.weights || {};
|
||||||
|
|
||||||
|
// Build merged profile: prefer values from userProfile when available
|
||||||
|
const profile = {
|
||||||
|
userId: user.userId,
|
||||||
|
email: user.email,
|
||||||
|
username: user.username,
|
||||||
|
macroTopics: user.macroTopics || [],
|
||||||
|
keywords: user.keywords || [],
|
||||||
|
weights: userWeights,
|
||||||
|
preferredSources: user.preferredSources || [],
|
||||||
|
lastFeedGeneratedAt: user.lastFeedGeneratedAt || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (userProfile) {
|
||||||
|
profile.macroTopics = userProfile.macroTopics || profile.macroTopics;
|
||||||
|
profile.keywords = userProfile.keywords || profile.keywords;
|
||||||
|
profile.weights = userProfile.weights || profile.weights;
|
||||||
|
profile.preferredSources = userProfile.preferredSources || profile.preferredSources;
|
||||||
|
profile.lastFeedGeneratedAt = userProfile.lastFeedGeneratedAt || profile.lastFeedGeneratedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, profile });
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/', auth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { macroTopics, keywords, preferredSources } = req.body;
|
||||||
|
|
||||||
|
const user = await User.findOne({ userId: req.user.userId });
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Utente non trovato.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (macroTopics) user.macroTopics = macroTopics;
|
||||||
|
if (keywords) user.keywords = keywords;
|
||||||
|
if (preferredSources) user.preferredSources = preferredSources;
|
||||||
|
|
||||||
|
user.updatedAt = new Date();
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
// Sync su user_profiles in modo che n8n e backend siano allineati
|
||||||
|
try {
|
||||||
|
await UserProfile.findOneAndUpdate(
|
||||||
|
{ userId: user.userId },
|
||||||
|
{
|
||||||
|
macroTopics: user.macroTopics,
|
||||||
|
keywords: user.keywords,
|
||||||
|
preferredSources: user.preferredSources,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
{ upsert: true }
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Profile Sync] Impossibile aggiornare user_profiles:', e.message || e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const weights = user.weights instanceof Map ? Object.fromEntries(user.weights) : user.weights || {};
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Profilo aggiornato con successo.',
|
||||||
|
profile: {
|
||||||
|
userId: user.userId,
|
||||||
|
macroTopics: user.macroTopics,
|
||||||
|
keywords: user.keywords,
|
||||||
|
weights,
|
||||||
|
preferredSources: user.preferredSources,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const Article = require('../models/Article');
|
||||||
|
const auth = require('../middleware/auth');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/sentiment', auth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await Article.aggregate([
|
||||||
|
{ $match: { status: 'processed' } },
|
||||||
|
{ $group: { _id: '$sentiment', count: { $sum: 1 } } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
return res.json({ success: true, data: result });
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/categories', auth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await Article.aggregate([
|
||||||
|
{ $match: { status: 'processed' } },
|
||||||
|
{ $group: { _id: '$category', count: { $sum: 1 } } },
|
||||||
|
{ $sort: { count: -1 } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
return res.json({ success: true, data: result });
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/trending', auth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await Article.aggregate([
|
||||||
|
{ $match: { status: 'processed' } },
|
||||||
|
{ $unwind: '$trendingTopics' },
|
||||||
|
{ $group: { _id: '$trendingTopics', count: { $sum: 1 } } },
|
||||||
|
{ $sort: { count: -1 } },
|
||||||
|
{ $limit: 10 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
return res.json({ success: true, data: result });
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/sources', auth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await Article.aggregate([
|
||||||
|
{ $match: { status: 'processed' } },
|
||||||
|
{ $group: { _id: '$source', count: { $sum: 1 } } },
|
||||||
|
{ $sort: { count: -1 } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
return res.json({ success: true, data: result });
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/overview', auth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const total = await Article.countDocuments({});
|
||||||
|
const processed = await Article.countDocuments({ status: 'processed' });
|
||||||
|
const raw = await Article.countDocuments({ status: 'raw' });
|
||||||
|
|
||||||
|
const last24h = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
|
const recentArticles = await Article.countDocuments({ createdAt: { $gte: last24h } });
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
total,
|
||||||
|
processed,
|
||||||
|
raw,
|
||||||
|
recentArticles,
|
||||||
|
processingRate: total > 0 ? ((processed / total) * 100).toFixed(1) : 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
const cors = require('cors');
|
||||||
|
const helmet = require('helmet');
|
||||||
|
const morgan = require('morgan');
|
||||||
|
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const authRoutes = require('./routes/auth');
|
||||||
|
const articleRoutes = require('./routes/articles');
|
||||||
|
const profileRoutes = require('./routes/profile');
|
||||||
|
const statsRoutes = require('./routes/stats');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(helmet());
|
||||||
|
const allowedOrigins = process.env.CORS_ORIGIN
|
||||||
|
? process.env.CORS_ORIGIN.split(',')
|
||||||
|
: process.env.NODE_ENV === 'production'
|
||||||
|
? [process.env.FRONTEND_URL || 'https://your-production-domain.com']
|
||||||
|
: ['http://localhost:5173'];
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
cors({
|
||||||
|
origin: (origin, callback) => {
|
||||||
|
if (!origin || allowedOrigins.includes(origin)) return callback(null, true);
|
||||||
|
return callback(new Error('Not allowed by CORS'));
|
||||||
|
},
|
||||||
|
credentials: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
app.use(morgan('dev'));
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
app.use('/api/auth', authRoutes);
|
||||||
|
app.use('/api/articles', articleRoutes);
|
||||||
|
app.use('/api/profile', profileRoutes);
|
||||||
|
app.use('/api/stats', statsRoutes);
|
||||||
|
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
service: 'BriefAI Backend',
|
||||||
|
version: '1.0.0',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use((req, res) => {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Endpoint non trovato.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
console.error('[Server Error]', err.stack);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: process.env.NODE_ENV === 'production' ? 'Errore interno del server.' : err.message,
|
||||||
|
});
|
||||||
|
if (typeof next === 'function') {
|
||||||
|
return next(err);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const port = Number.parseInt(process.env.PORT, 10) || 5000;
|
||||||
|
|
||||||
|
mongoose
|
||||||
|
.connect(process.env.MONGO_URI)
|
||||||
|
.then(() => {
|
||||||
|
console.log('Connesso a MongoDB Atlas');
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`Backend BriefAI attivo su http://localhost:${port}`);
|
||||||
|
console.log('Endpoints disponibili:');
|
||||||
|
console.log('POST /api/auth/register');
|
||||||
|
console.log('POST /api/auth/login');
|
||||||
|
console.log('GET /api/auth/me');
|
||||||
|
console.log('GET /api/articles');
|
||||||
|
console.log('GET /api/articles/:uniqueKey');
|
||||||
|
console.log('GET /api/profile');
|
||||||
|
console.log('PUT /api/profile');
|
||||||
|
console.log('GET /api/stats/sentiment');
|
||||||
|
console.log('GET /api/stats/categories');
|
||||||
|
console.log('GET /api/stats/trending');
|
||||||
|
console.log('GET /api/stats/sources');
|
||||||
|
console.log('GET /api/stats/overview');
|
||||||
|
console.log('GET /health');
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Errore connessione MongoDB:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# version: '3.8'
|
||||||
|
|
||||||
|
# services:
|
||||||
|
# n8n:
|
||||||
|
# image: n8nio/n8n:latest
|
||||||
|
# ports:
|
||||||
|
# - "5678:5678"
|
||||||
|
# environment:
|
||||||
|
# - N8N_BASIC_AUTH_ACTIVE=true
|
||||||
|
# - N8N_BASIC_AUTH_USER=admin
|
||||||
|
# - N8N_BASIC_AUTH_PASSWORD=root
|
||||||
|
# - JWT_SECRET=changeme_replace_with_secure_value
|
||||||
|
# volumes:
|
||||||
|
# - n8n_data:/home/node/.n8n
|
||||||
|
|
||||||
|
# volumes:
|
||||||
|
# n8n_data:
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// Snippet: JWT and password helpers for n8n Code nodes
|
||||||
|
// Usage: paste the needed functions into a Code node at the start of your webhook workflows.
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
function base64url(bufOrStr){
|
||||||
|
const b = Buffer.isBuffer(bufOrStr) ? bufOrStr : Buffer.from(String(bufOrStr));
|
||||||
|
return b.toString('base64').replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
|
||||||
|
}
|
||||||
|
|
||||||
|
function signJWT(payload, secret, expiresInSec=86400){
|
||||||
|
const header = base64url(JSON.stringify({ alg:'HS256', typ:'JWT' }));
|
||||||
|
const exp = Math.floor(Date.now()/1000) + expiresInSec;
|
||||||
|
const body = base64url(JSON.stringify({ ...payload, exp }));
|
||||||
|
const sig = base64url(crypto.createHmac('sha256', secret).update(`${header}.${body}`).digest());
|
||||||
|
return `${header}.${body}.${sig}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyJWT(token, secret){
|
||||||
|
try {
|
||||||
|
const [h,b,s] = token.split('.');
|
||||||
|
const expected = base64url(crypto.createHmac('sha256', secret).update(`${h}.${b}`).digest());
|
||||||
|
if (expected !== s) return null;
|
||||||
|
const payload = JSON.parse(Buffer.from(b.replace(/-/g,'+').replace(/_/g,'/'),'base64').toString());
|
||||||
|
if (payload.exp && Math.floor(Date.now()/1000) > payload.exp) return null;
|
||||||
|
return payload;
|
||||||
|
} catch(e){ return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashPassword(password){
|
||||||
|
const salt = crypto.randomBytes(16).toString('hex');
|
||||||
|
const derived = crypto.scryptSync(password, salt, 64).toString('hex');
|
||||||
|
return { salt, hash: derived };
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyPassword(password, salt, hash){
|
||||||
|
return crypto.scryptSync(password, salt, 64).toString('hex') === hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { signJWT, verifyJWT, hashPassword, verifyPassword };
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.14.0",
|
||||||
|
"@emotion/styled": "^11.14.1",
|
||||||
|
"@mui/icons-material": "^7.3.9",
|
||||||
|
"@mui/material": "^7.3.9",
|
||||||
|
"@testing-library/dom": "^10.4.1",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^13.5.0",
|
||||||
|
"axios": "^1.13.6",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-chartjs-2": "^5.3.1",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"react-router-dom": "^7.13.1",
|
||||||
|
"react-scripts": "^5.0.1",
|
||||||
|
"recharts": "^3.8.0",
|
||||||
|
"web-vitals": "^2.1.4"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "react-scripts start",
|
||||||
|
"build": "react-scripts build",
|
||||||
|
"test": "react-scripts test",
|
||||||
|
"eject": "react-scripts eject"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": [
|
||||||
|
"react-app",
|
||||||
|
"react-app/jest"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,43 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Web site created using create-react-app"
|
||||||
|
/>
|
||||||
|
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||||
|
<!--
|
||||||
|
manifest.json provides metadata used when your web app is installed on a
|
||||||
|
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||||
|
-->
|
||||||
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||||
|
<!--
|
||||||
|
Notice the use of %PUBLIC_URL% in the tags above.
|
||||||
|
It will be replaced with the URL of the `public` folder during the build.
|
||||||
|
Only files inside the `public` folder can be referenced from the HTML.
|
||||||
|
|
||||||
|
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||||
|
work correctly both with client-side routing and a non-root public URL.
|
||||||
|
Learn how to configure a non-root public URL by running `npm run build`.
|
||||||
|
-->
|
||||||
|
<title>React App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
<!--
|
||||||
|
This HTML file is a template.
|
||||||
|
If you open it directly in the browser, you will see an empty page.
|
||||||
|
|
||||||
|
You can add webfonts, meta tags, or analytics to this file.
|
||||||
|
The build step will place the bundled scripts into the <body> tag.
|
||||||
|
|
||||||
|
To begin the development, run `npm start` or `yarn start`.
|
||||||
|
To create a production bundle, use `npm run build` or `yarn build`.
|
||||||
|
-->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"short_name": "React App",
|
||||||
|
"name": "Create React App Sample",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "favicon.ico",
|
||||||
|
"sizes": "64x64 32x32 24x24 16x16",
|
||||||
|
"type": "image/x-icon"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo192.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"background_color": "#ffffff"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# https://www.robotstxt.org/robotstxt.html
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
@echo off
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
echo Starting n8n with Docker...
|
||||||
|
docker-compose up -d n8n
|
||||||
|
|
||||||
|
echo Starting Data Pipeline...
|
||||||
|
start "data-pipeline" cmd /k "cd briefai\data-pipeline && npm start"
|
||||||
|
|
||||||
|
echo Starting AI Analysis...
|
||||||
|
start "ai-analysis" cmd /k "cd ai-analysis && call venv\Scripts\activate.bat && python src\scheduler.py"
|
||||||
|
|
||||||
|
echo Starting Backend...
|
||||||
|
start "backend" cmd /k "cd backend && npm run dev"
|
||||||
|
|
||||||
|
echo Starting Frontend...
|
||||||
|
start "frontend" cmd /k "cd frontend && npm start"
|
||||||
|
|
||||||
|
exit /b 0
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_API_URL=http://localhost:5000
|
||||||
|
VITE_N8N_URL=https://n8n-cipolla.ampere.lucasacchi.net/webhook
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is enabled on this template. See [this documentation](https://react.dev/learn/react-compiler) for more information.
|
||||||
|
|
||||||
|
Note: This will impact Vite dev & build performances.
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>frontend-briefai</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend-briefai",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.5",
|
||||||
|
"react-dom": "^19.2.5",
|
||||||
|
"react-router-dom": "^7.14.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.29.0",
|
||||||
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@rolldown/plugin-babel": "^0.2.3",
|
||||||
|
"@types/babel__core": "^7.20.5",
|
||||||
|
"@types/node": "^24.12.2",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
|
"eslint": "^9.39.4",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.5.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"typescript-eslint": "^8.58.2",
|
||||||
|
"vite": "^8.0.9"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
.app-shell {
|
||||||
|
width: 100%;
|
||||||
|
height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell > * {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import type { ReactElement } from 'react'
|
||||||
|
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||||
|
import './App.css'
|
||||||
|
import FeedPage from './pages/FeedPage'
|
||||||
|
import HomePage from './pages/HomePage'
|
||||||
|
import LoginPage from './pages/LoginPage'
|
||||||
|
import OnboardingPage from './pages/OnboardingPage'
|
||||||
|
import RegisterPage from './pages/RegisterPage'
|
||||||
|
import SettingsPage from './pages/SettingsPage'
|
||||||
|
import TrendsPage from './pages/TrendsPage'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
// Stato minimo di autenticazione usato per abilitare/bloccare le route private.
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="app-shell">
|
||||||
|
<Routes>
|
||||||
|
{/* Home pubblica: prima pagina visibile a utenti non autenticati. */}
|
||||||
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route
|
||||||
|
path="/login"
|
||||||
|
element={<LoginPage onLoginSuccess={() => setIsAuthenticated(true)} />}
|
||||||
|
/>
|
||||||
|
{/* Route pubblica: consente la registrazione di un nuovo utente. */}
|
||||||
|
<Route
|
||||||
|
path="/register"
|
||||||
|
element={<RegisterPage onRegisterSuccess={() => setIsAuthenticated(true)} />}
|
||||||
|
/>
|
||||||
|
<Route path="/onboarding" element={<OnboardingPage />} />
|
||||||
|
{/* Compatibilita: vecchio path /home reindirizzato al nuovo onboarding. */}
|
||||||
|
<Route path="/home" element={<Navigate to="/onboarding" replace />} />
|
||||||
|
<Route
|
||||||
|
path="/feed"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute isAuthenticated={isAuthenticated}>
|
||||||
|
<FeedPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/tendenze"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute isAuthenticated={isAuthenticated}>
|
||||||
|
<TrendsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/impostazioni"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute isAuthenticated={isAuthenticated}>
|
||||||
|
<SettingsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProtectedRouteProps = {
|
||||||
|
isAuthenticated: boolean
|
||||||
|
children: ReactElement
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProtectedRoute({ isAuthenticated, children }: ProtectedRouteProps) {
|
||||||
|
// Guard tecnica della route: se l'utente non e autenticato, verifica anche
|
||||||
|
// la presenza di un token nel localStorage come fallback per evitare
|
||||||
|
// condizioni di gara (es. subito dopo la registrazione).
|
||||||
|
const token = localStorage.getItem('briefai_token')
|
||||||
|
if (!isAuthenticated && !token) {
|
||||||
|
return <Navigate to="/login" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,75 @@
|
|||||||
|
type SubscriptionState = 'free' | 'pro'
|
||||||
|
|
||||||
|
type AccountSettingsProps = {
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
subscriptionState: SubscriptionState
|
||||||
|
onUpgrade: () => void
|
||||||
|
onCancelSubscription: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sezione account: mostra profilo e gestisce in modo condizionale il piano attivo.
|
||||||
|
function AccountSettings({username, email, subscriptionState, onUpgrade, onCancelSubscription}: AccountSettingsProps) {
|
||||||
|
const isProPlan = subscriptionState === 'pro'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="settings-stack" aria-label="Profilo">
|
||||||
|
<section className="settings-card" aria-label="Informazioni profilo">
|
||||||
|
<header className="settings-section-header">
|
||||||
|
<div>
|
||||||
|
<h2>Informazioni profilo</h2>
|
||||||
|
<p>Controlla i dati di base associati al tuo account.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="profile-info-grid">
|
||||||
|
<div>
|
||||||
|
<span className="profile-info-label">Email</span>
|
||||||
|
<p className="profile-info-value">{email}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="profile-info-label">Nome utente</span>
|
||||||
|
<p className="profile-info-value">{username}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="settings-card" aria-label="Gestione abbonamento">
|
||||||
|
<header className="settings-section-header">
|
||||||
|
<div>
|
||||||
|
<h2>Gestione abbonamento</h2>
|
||||||
|
<p>Gestisci il piano attualmente associato al tuo profilo.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="subscription-box">
|
||||||
|
<div className="subscription-copy">
|
||||||
|
<span className={`plan-badge ${isProPlan ? 'pro' : 'free'}`}>
|
||||||
|
{isProPlan ? 'Piano Pro' : 'Piano Gratuito'}
|
||||||
|
</span>
|
||||||
|
{isProPlan ? (
|
||||||
|
<p>Scade il 12/12/2026</p>
|
||||||
|
) : (
|
||||||
|
<p>Passa al Pro per sbloccare monitoraggio e analisi avanzate.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="subscription-actions">
|
||||||
|
{isProPlan ? (
|
||||||
|
<button type="button" className="subscription-link-button" onClick={onCancelSubscription}>
|
||||||
|
Annulla abbonamento
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" className="subscription-upgrade-button" onClick={onUpgrade}>
|
||||||
|
Passa a Pro
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { SubscriptionState }
|
||||||
|
export default AccountSettings
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import MagicCard from './MagicCard'
|
||||||
|
import { fetchPersonalizedFeed } from '../services/feedService'
|
||||||
|
import type { Article } from '../types/article'
|
||||||
|
|
||||||
|
type FeedContentProps = {
|
||||||
|
sentimentFilter?: string | null
|
||||||
|
topicsFilter?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeedContent({ sentimentFilter = null, topicsFilter = null }: FeedContentProps) {
|
||||||
|
const [articles, setArticles] = useState<Article[]>([])
|
||||||
|
const [status, setStatus] = useState<'loading' | 'ok' | 'error'>('loading')
|
||||||
|
|
||||||
|
// Prende il fetch del feed personalizzato e popola la variabile di stato con i dati
|
||||||
|
useEffect(() => {fetchPersonalizedFeed().then((data) => {
|
||||||
|
setArticles(data)
|
||||||
|
setStatus('ok')
|
||||||
|
})
|
||||||
|
.catch(() => setStatus('error'))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="feed-content" aria-label="Contenuto feed">
|
||||||
|
<header className="feed-header">
|
||||||
|
<p className="feed-kicker">BriefAI Notizie</p>
|
||||||
|
<h1>Il tuo flusso di intelligenza</h1>
|
||||||
|
<p>Notizie personalizzate con approfondimenti AI</p>
|
||||||
|
</header>
|
||||||
|
{/* Gestione degli stati*/}
|
||||||
|
{status === 'loading' && <p>Caricamento feed...</p>}
|
||||||
|
{status === 'error' && <p>Errore nel caricamento del feed.</p>}
|
||||||
|
{/*Render della lista di articoli se il caricamento è andato a buon fine*/}
|
||||||
|
{status === 'ok' && (
|
||||||
|
<>
|
||||||
|
{/* Client-side filtering */}
|
||||||
|
{(() => {
|
||||||
|
const matchesTopic = (a: Article) => {
|
||||||
|
if (!topicsFilter || topicsFilter === 'All Topics') return true
|
||||||
|
const topic = topicsFilter.toLowerCase()
|
||||||
|
const catMatch = (a.category || '').toLowerCase() === topic
|
||||||
|
const trending = (a.trendingTopics || []).some((t) =>
|
||||||
|
String(t || '').toLowerCase() === topic
|
||||||
|
)
|
||||||
|
const macroTopicMatch = (a.macroTopics || []).some((t) =>
|
||||||
|
String(t || '').toLowerCase() === topic
|
||||||
|
)
|
||||||
|
return catMatch || trending || macroTopicMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchesSentiment = (a: Article) => {
|
||||||
|
if (!sentimentFilter || sentimentFilter === 'All Sentiment') return true
|
||||||
|
return a.sentiment === sentimentFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = articles.filter((a) => matchesSentiment(a) && matchesTopic(a))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{filtered.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="feed-list">
|
||||||
|
{filtered.map((a) => (
|
||||||
|
<MagicCard
|
||||||
|
key={a.uniqueKey}
|
||||||
|
articleId={a.uniqueKey}
|
||||||
|
source={a.source}
|
||||||
|
timeAgo={new Date(a.pubDate).toLocaleString()}
|
||||||
|
sentiment={
|
||||||
|
a.sentiment === 'Positive'
|
||||||
|
? 'Positivo'
|
||||||
|
: a.sentiment === 'Negative'
|
||||||
|
? 'Negativo'
|
||||||
|
: 'Neutrale'
|
||||||
|
}
|
||||||
|
title={a.title}
|
||||||
|
summary={a.summary}
|
||||||
|
tags={a.macroTopics?.length ? a.macroTopics : (a.trendingTopics || [a.category])}
|
||||||
|
entities={a.entities || []}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="feed-filter-info">
|
||||||
|
Mostrando {filtered.length} di {articles.length} articoli
|
||||||
|
{(sentimentFilter && sentimentFilter !== 'All Sentiment') ||
|
||||||
|
(topicsFilter && topicsFilter !== 'All Topics')
|
||||||
|
? ` con${sentimentFilter && sentimentFilter !== 'All Sentiment' ? ` sentiment ${sentimentFilter}` : ''}${
|
||||||
|
sentimentFilter && topicsFilter ? ' e' : ''
|
||||||
|
}${topicsFilter && topicsFilter !== 'All Topics' ? ` topic ${topicsFilter}` : ''}`
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="feed-no-results">
|
||||||
|
Nessun articolo trovato con i filtri selezionati
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="feed-footer">
|
||||||
|
<button type="button" className="load-more-button">
|
||||||
|
Carica altre notizie
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FeedContent
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
type FeedSidebarProps = {
|
||||||
|
activeItem?: 'feed' | 'tendenze' | 'impostazioni'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sidebar principale del feed: gestisce brand, navigazione e blocco profilo.
|
||||||
|
const navigationItems = [
|
||||||
|
{ id: 'feed', label: 'Notizie', icon: LayoutDashboardIcon },
|
||||||
|
{ id: 'tendenze', label: 'Tendenze', icon: TrendingUpIcon },
|
||||||
|
{ id: 'impostazioni', label: 'Impostazioni', icon: SettingsIcon },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
function FeedSidebar({ activeItem = 'feed' }: FeedSidebarProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="feed-sidebar" aria-label="Navigazione principale">
|
||||||
|
{/* Logo BreafAI. */}
|
||||||
|
<div className="sidebar-brand">
|
||||||
|
<div className="brand-mark" aria-hidden="true">
|
||||||
|
<SparklesIcon />
|
||||||
|
</div>
|
||||||
|
<div className="brand-text">BriefAI</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Menu di navigazione laterale. */}
|
||||||
|
<nav className="sidebar-nav" aria-label="Menu notizie">
|
||||||
|
{navigationItems.map((item) => {
|
||||||
|
const isActive = item.id === activeItem
|
||||||
|
const Icon = item.icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
className={`sidebar-nav-item ${isActive ? 'active' : ''}`}
|
||||||
|
onClick={() => navigate(item.id === 'feed' ? '/feed' : `/${item.id}`)}
|
||||||
|
>
|
||||||
|
<Icon />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Box profilo: resta agganciato in basso. */}
|
||||||
|
<div className="sidebar-profile">
|
||||||
|
<div className="profile-avatar" aria-hidden="true">
|
||||||
|
JD
|
||||||
|
</div>
|
||||||
|
<div className="profile-meta">
|
||||||
|
<strong>John Doe</strong>
|
||||||
|
<span>john@example.com</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SparklesIcon() {
|
||||||
|
return <span aria-hidden="true">✦</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
function LayoutDashboardIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<rect x="3" y="3" width="8" height="8" rx="2" />
|
||||||
|
<rect x="13" y="3" width="8" height="5" rx="2" />
|
||||||
|
<rect x="13" y="10" width="8" height="11" rx="2" />
|
||||||
|
<rect x="3" y="13" width="8" height="8" rx="2" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrendingUpIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M3 17l6-6 4 4 8-8" />
|
||||||
|
<path d="M14 7h7v7" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M12 8.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z" />
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.05.05a2 2 0 0 1-1.42 3.41 2 2 0 0 1-1.42-.59l-.06-.05a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.08a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.05a2 2 0 0 1-2.84-2.83l.05-.05A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.05-.05A2 2 0 0 1 5.64 3.72a2 2 0 0 1 1.42.59l.06.05a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.08a1.65 1.65 0 0 0 1 1.51h.06a1.65 1.65 0 0 0 1.82-.33l.06-.05a2 2 0 0 1 2.83 2.83l-.05.05A1.65 1.65 0 0 0 20.4 9H21a2 2 0 0 1 0 4h-.08a1.65 1.65 0 0 0-1.51 1Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FeedSidebar
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
type FeedTopbarProps = {
|
||||||
|
showFeedFilters?: boolean
|
||||||
|
sentimentFilter?: string | null
|
||||||
|
onSentimentChange?: (sentiment: string) => void
|
||||||
|
topicsFilter?: string | null
|
||||||
|
onTopicChange?: (topic: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeedTopbar({
|
||||||
|
showFeedFilters = false,
|
||||||
|
sentimentFilter = null,
|
||||||
|
onSentimentChange,
|
||||||
|
topicsFilter = null,
|
||||||
|
onTopicChange,
|
||||||
|
}: FeedTopbarProps) {
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className={`feed-topbar ${showFeedFilters ? 'with-filters' : ''}`}
|
||||||
|
aria-label="Intestazione feed"
|
||||||
|
>
|
||||||
|
{showFeedFilters ? (
|
||||||
|
<nav className="feed-filter-nav" aria-label="Filtri feed">
|
||||||
|
<label className="feed-filter-control" htmlFor="sentiment-filter">
|
||||||
|
<span className="feed-filter-label">Sentiment</span>
|
||||||
|
<select
|
||||||
|
id="sentiment-filter"
|
||||||
|
value={sentimentFilter || 'All Sentiment'}
|
||||||
|
onChange={(e) => onSentimentChange?.(e.target.value)}
|
||||||
|
>
|
||||||
|
<option>All Sentiment</option>
|
||||||
|
<option>Positive</option>
|
||||||
|
<option>Negative</option>
|
||||||
|
<option>Neutral</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="feed-filter-control" htmlFor="topic-filter">
|
||||||
|
<span className="feed-filter-label">Topics</span>
|
||||||
|
<select
|
||||||
|
id="topic-filter"
|
||||||
|
value={topicsFilter || 'All Topics'}
|
||||||
|
onChange={(e) => onTopicChange?.(e.target.value)}
|
||||||
|
>
|
||||||
|
<option>All Topics</option>
|
||||||
|
<option>Intelligenza Artificiale</option>
|
||||||
|
<option>Cybersecurity</option>
|
||||||
|
<option>Business & Finanza</option>
|
||||||
|
<option>Politica & Geopolitica</option>
|
||||||
|
<option>Startup & Innovazione</option>
|
||||||
|
<option>Software & Sviluppo</option>
|
||||||
|
<option>Scienza & Ricerca</option>
|
||||||
|
<option>Energia & Ambiente</option>
|
||||||
|
<option>Economia & Mercati</option>
|
||||||
|
<option>Social Media & Cultura</option>
|
||||||
|
<option>Salute & Medicina</option>
|
||||||
|
<option>Trasporti & Mobilità</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</nav>
|
||||||
|
) : null}
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FeedTopbar
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
type InterestPreferencesProps = {
|
||||||
|
selectedMacroTopics: string[]
|
||||||
|
onToggleMacroTopic: (topic: string) => void
|
||||||
|
onSaveMacroTopics: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type CategoryOption = {
|
||||||
|
label: string
|
||||||
|
emoji: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const macroTopicOptions: CategoryOption[] = [
|
||||||
|
{ label: 'Intelligenza Artificiale', emoji: '🤖' },
|
||||||
|
{ label: 'Cybersecurity', emoji: '🔒' },
|
||||||
|
{ label: 'Business & Finanza', emoji: '💼' },
|
||||||
|
{ label: 'Politica & Geopolitica', emoji: '⚖️' },
|
||||||
|
{ label: 'Startup & Innovazione', emoji: '🚀' },
|
||||||
|
{ label: 'Software & Sviluppo', emoji: '💻' },
|
||||||
|
{ label: 'Scienza & Ricerca', emoji: '🔬' },
|
||||||
|
{ label: 'Energia & Ambiente', emoji: '🌱' },
|
||||||
|
{ label: 'Economia & Mercati', emoji: '📈' },
|
||||||
|
{ label: 'Social Media & Cultura', emoji: '📱' },
|
||||||
|
{ label: 'Salute & Medicina', emoji: '🏥' },
|
||||||
|
{ label: 'Trasporti & Mobilità', emoji: '🚗' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Sezione interessi: gestisce la selezione rapida delle categorie del feed.
|
||||||
|
function InterestPreferences({ selectedMacroTopics, onToggleMacroTopic, onSaveMacroTopics }: InterestPreferencesProps) {
|
||||||
|
return (
|
||||||
|
<section className="settings-card" aria-label="Le tue categorie">
|
||||||
|
<header className="settings-section-header">
|
||||||
|
<div>
|
||||||
|
<h2>I tuoi Macro-Topics</h2>
|
||||||
|
<p>Scegli i macro-temi che devono modellare il tuo feed.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="category-grid">
|
||||||
|
{macroTopicOptions.map((topic) => {
|
||||||
|
const isSelected = selectedMacroTopics.includes(topic.label)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={topic.label}
|
||||||
|
type="button"
|
||||||
|
className={`category-tile ${isSelected ? 'selected' : ''}`}
|
||||||
|
onClick={() => onToggleMacroTopic(topic.label)}
|
||||||
|
>
|
||||||
|
<span className="category-emoji" aria-hidden="true">
|
||||||
|
{topic.emoji}
|
||||||
|
</span>
|
||||||
|
<span className="category-label">{topic.label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-action-row">
|
||||||
|
<button type="button" className="settings-save-button" onClick={onSaveMacroTopics}>
|
||||||
|
<SaveIcon />
|
||||||
|
Salva
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SaveIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M17 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V7l-4-4Z" />
|
||||||
|
<path d="M7 3v6h8V3" />
|
||||||
|
<path d="M7 21v-6h10v6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InterestPreferences
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { sendFeedback } from '../services/feedbackService'
|
||||||
|
|
||||||
|
type MagicCardProps = {
|
||||||
|
articleId?: string
|
||||||
|
source: string
|
||||||
|
timeAgo: string
|
||||||
|
sentiment: 'Positivo' | 'Negativo' | 'Neutrale'
|
||||||
|
title: string
|
||||||
|
summary: string
|
||||||
|
tags: string[]
|
||||||
|
entities: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card notizia: mostra fonte, sentiment, testo, tag, entità e azioni rapide.
|
||||||
|
function MagicCard({ articleId, source, timeAgo, sentiment, title, summary, tags, entities }: MagicCardProps) {
|
||||||
|
const [voted, setVoted] = useState<1 | -1 | null>(null)
|
||||||
|
|
||||||
|
const handleVote = (vote: 1 | -1) => {
|
||||||
|
if (voted !== null) return
|
||||||
|
setVoted(vote)
|
||||||
|
if (articleId) sendFeedback(articleId, vote)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="magic-card">
|
||||||
|
{/* Testata card: fonte e badge del sentiment allineati ai lati opposti. */}
|
||||||
|
<header className="magic-card-header">
|
||||||
|
<div>
|
||||||
|
<p className="magic-card-source">
|
||||||
|
{source} • {timeAgo}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className={`sentiment-badge ${sentiment.toLowerCase()}`}>{sentiment}</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<h3 className="magic-card-title">{title}</h3>
|
||||||
|
<p className="magic-card-summary">{summary}</p>
|
||||||
|
|
||||||
|
{/* Tag tematici: servono a classificare velocemente la notizia. */}
|
||||||
|
<div className="magic-card-tags">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<span key={tag} className="tag-chip">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Entità rilevate: evidenziano i nomi chiave citati nella notizia. */}
|
||||||
|
<div className="magic-card-entities">
|
||||||
|
<span className="entities-label">Entità:</span>
|
||||||
|
<div className="chip-wrap">
|
||||||
|
{entities.map((entity) => (
|
||||||
|
<span key={entity} className="entity-chip">
|
||||||
|
{entity}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Azioni rapide: pulsanti iconici per interagire con la card. */}
|
||||||
|
<footer className="magic-card-actions" aria-label="Azioni notizia">
|
||||||
|
<ActionButton
|
||||||
|
label="Mi piace"
|
||||||
|
tone="like"
|
||||||
|
onClick={() => handleVote(1)}
|
||||||
|
disabled={voted !== null}
|
||||||
|
style={{ opacity: voted === -1 ? 0.4 : 1 }}
|
||||||
|
>
|
||||||
|
<ThumbUpIcon />
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton
|
||||||
|
label="Non mi piace"
|
||||||
|
tone="dislike"
|
||||||
|
onClick={() => handleVote(-1)}
|
||||||
|
disabled={voted !== null}
|
||||||
|
style={{ opacity: voted === 1 ? 0.4 : 1 }}
|
||||||
|
>
|
||||||
|
<ThumbDownIcon />
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton label="Salva" tone="save">
|
||||||
|
<BookmarkIcon />
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton label="Condividi" tone="share">
|
||||||
|
<ShareIcon />
|
||||||
|
</ActionButton>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`action-button ${tone}`}
|
||||||
|
aria-label={label}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThumbUpIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M7 11v10H4V11h3Zm3 10h7.5a2.5 2.5 0 0 0 2.45-2.02l1.03-5.48A2.5 2.5 0 0 0 18.53 10H13V5.5a2.5 2.5 0 0 0-5 0V11L10 21Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThumbDownIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M7 13V3H4v10h3Zm3-10h7.5a2.5 2.5 0 0 1 2.45 2.02l1.03 5.48A2.5 2.5 0 0 1 18.53 14H13v4.5a2.5 2.5 0 0 1-5 0V13L10 3Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BookmarkIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M6 3a2 2 0 0 0-2 2v16l8-4 8 4V5a2 2 0 0 0-2-2H6Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShareIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M18 8a3 3 0 1 0-2.82-4h-.01A3 3 0 0 0 18 8ZM6 15a3 3 0 1 0 0 6 3 3 0 0 0 0-6Zm12-4a3 3 0 0 0-2.83 2H15L9 10V8.8A3 3 0 1 0 7.4 10l5.2 2.6v.4a3 3 0 1 0 1.4 2.4V14l5.16-2.58A3 3 0 0 0 18 11Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MagicCard
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Navigate, Outlet } from 'react-router-dom';
|
||||||
|
import { getMe } from '../services/authService';
|
||||||
|
|
||||||
|
export default function ProtectedRoute() {
|
||||||
|
const [status, setStatus] = useState<'loading'|'ok'|'unauth'>('loading');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getMe().then(user =>
|
||||||
|
setStatus(user ? 'ok' : 'unauth')
|
||||||
|
).catch(() => setStatus('unauth'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (status === 'loading') return <div>Caricamento...</div>;
|
||||||
|
if (status === 'unauth') return <Navigate to='/login' replace />;
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
type SettingsTab = 'interests' | 'account'
|
||||||
|
|
||||||
|
type SettingsTabsProps = {
|
||||||
|
activeTab: SettingsTab
|
||||||
|
onTabChange: (tab: SettingsTab) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabItems: Array<{ id: SettingsTab; label: string }> = [
|
||||||
|
{ id: 'interests', label: 'Interessi' },
|
||||||
|
{ id: 'account', label: 'Profilo' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Tabs di Settings: servono per separare preferenze feed e dati account.
|
||||||
|
function SettingsTabs({ activeTab, onTabChange }: SettingsTabsProps) {
|
||||||
|
return (
|
||||||
|
<div className="settings-tabs" role="tablist" aria-label="Schede impostazioni">
|
||||||
|
{tabItems.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === tab.id}
|
||||||
|
className={`settings-tab ${activeTab === tab.id ? 'active' : ''}`}
|
||||||
|
onClick={() => onTabChange(tab.id)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { SettingsTab }
|
||||||
|
export default SettingsTabs
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
type TrackedKeywordsProps = {
|
||||||
|
keywords: string[]
|
||||||
|
keywordInput: string
|
||||||
|
onKeywordInputChange: (value: string) => void
|
||||||
|
onAddKeyword: (keyword: string) => void
|
||||||
|
onRemoveKeyword: (keyword: string) => void
|
||||||
|
onSaveKeywords: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const keywordSuggestions = ['OpenAI', 'Google AI', 'Anthropic', 'Tesla', 'SpaceX']
|
||||||
|
|
||||||
|
// Sezione keyword: permette di aggiungere, rimuovere e salvare le entità monitorate.
|
||||||
|
function TrackedKeywords({
|
||||||
|
keywords,
|
||||||
|
keywordInput,
|
||||||
|
onKeywordInputChange,
|
||||||
|
onAddKeyword,
|
||||||
|
onRemoveKeyword,
|
||||||
|
onSaveKeywords,
|
||||||
|
}: TrackedKeywordsProps) {
|
||||||
|
const handleSubmit = () => {
|
||||||
|
onAddKeyword(keywordInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="settings-card" aria-label="Parole chiave monitorate">
|
||||||
|
<header className="settings-section-header">
|
||||||
|
<div>
|
||||||
|
<h2>Parole chiave monitorate</h2>
|
||||||
|
<p>Monitora aziende, persone e temi con un tracciamento rapido.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="keyword-toolbar">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="settings-keyword-input"
|
||||||
|
placeholder="Aggiungi parola chiave..."
|
||||||
|
value={keywordInput}
|
||||||
|
onChange={(event) => onKeywordInputChange(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault()
|
||||||
|
handleSubmit()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button type="button" className="keyword-add-button" onClick={handleSubmit}>
|
||||||
|
<PlusIcon />
|
||||||
|
Aggiungi
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="suggestion-row" aria-label="Suggerimenti parole chiave">
|
||||||
|
{keywordSuggestions.map((suggestion) => (
|
||||||
|
<button
|
||||||
|
key={suggestion}
|
||||||
|
type="button"
|
||||||
|
className="suggestion-chip"
|
||||||
|
onClick={() => onAddKeyword(suggestion)}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">+</span>
|
||||||
|
{suggestion}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{keywords.length > 0 && (
|
||||||
|
<div className="tracked-chip-wrap">
|
||||||
|
{keywords.map((keyword) => (
|
||||||
|
<span key={keyword} className="tracked-chip">
|
||||||
|
{keyword}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tracked-chip-remove"
|
||||||
|
aria-label={`Rimuovi ${keyword}`}
|
||||||
|
onClick={() => onRemoveKeyword(keyword)}
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="settings-action-row">
|
||||||
|
<button type="button" className="settings-save-button" onClick={onSaveKeywords}>
|
||||||
|
<SaveIcon />
|
||||||
|
Salva
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlusIcon() {
|
||||||
|
return <span aria-hidden="true">+</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
function SaveIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M17 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V7l-4-4Z" />
|
||||||
|
<path d="M7 3v6h8V3" />
|
||||||
|
<path d="M7 21v-6h10v6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TrackedKeywords
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
:root {
|
||||||
|
--text: #13212b;
|
||||||
|
--muted: #4e636f;
|
||||||
|
--bg-top: #f7fbff;
|
||||||
|
--bg-bottom: #e8f2ff;
|
||||||
|
--panel-bg: rgba(255, 255, 255, 0.82);
|
||||||
|
--panel-border: #c7d7e3;
|
||||||
|
--panel-shadow: 0 22px 55px rgba(17, 44, 70, 0.18);
|
||||||
|
--input-bg: #ffffff;
|
||||||
|
--focus: #1f7ab8;
|
||||||
|
--accent-a: #0e7490;
|
||||||
|
--accent-b: #155e75;
|
||||||
|
--danger: #c23616;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: 'Space Grotesk', 'Segoe UI', sans-serif;
|
||||||
|
line-height: 1.45;
|
||||||
|
overflow-x: hidden;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 15% 15%, rgba(31, 122, 184, 0.2), transparent 34%),
|
||||||
|
radial-gradient(circle at 85% 85%, rgba(14, 116, 144, 0.22), transparent 28%),
|
||||||
|
linear-gradient(180deg, var(--bg-top), var(--bg-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
.feed-layout {
|
||||||
|
height: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 256px minmax(0, 1fr);
|
||||||
|
background: #f8fafc;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-sidebar {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 20px 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-right: 1px solid #e5e7eb;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 14px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #2563eb, #7c3aed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark span {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-text {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: linear-gradient(135deg, #2563eb, #7c3aed);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-item {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 14px;
|
||||||
|
color: #64748b;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-item:hover {
|
||||||
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-item.active {
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #2563eb, #7c3aed);
|
||||||
|
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-item svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-profile {
|
||||||
|
margin-top: auto;
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding-top: 18px;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
background: linear-gradient(135deg, #2563eb, #7c3aed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-meta {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-meta strong {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-meta span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-main {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-topbar {
|
||||||
|
height: 64px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-topbar.with-filters {
|
||||||
|
height: auto;
|
||||||
|
min-height: 64px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-filter-nav {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 896px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-filter-control {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-filter-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #64748b;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-filter-control select {
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font: inherit;
|
||||||
|
color: #0f172a;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-filter-control select:focus-visible {
|
||||||
|
outline: 2px solid #1f7ab8;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-content {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 896px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 24px 48px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-kicker {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-header h1 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: clamp(34px, 4vw, 48px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-header p {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.magic-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
|
||||||
|
transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.magic-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: #dbeafe;
|
||||||
|
box-shadow: 0 16px 34px rgba(37, 99, 235, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.magic-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.magic-card-source {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sentiment-badge {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sentiment-badge.positivo {
|
||||||
|
color: #166534;
|
||||||
|
background: #dcfce7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sentiment-badge.negativo {
|
||||||
|
color: #991b1b;
|
||||||
|
background: #fee2e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sentiment-badge.neutrale {
|
||||||
|
color: #334155;
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.magic-card-title {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: #111827;
|
||||||
|
transition: color 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.magic-card:hover .magic-card-title {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.magic-card-summary {
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.magic-card-tags,
|
||||||
|
.magic-card-entities {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 7px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #1d4ed8;
|
||||||
|
background: #dbeafe;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entities-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 7px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid #c4b5fd;
|
||||||
|
color: #6d28d9;
|
||||||
|
background: #f5f3ff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.magic-card-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
margin-top: 0;
|
||||||
|
min-height: 44px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 14px;
|
||||||
|
color: #64748b;
|
||||||
|
background: #fff;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
transition: color 180ms ease, border-color 180ms ease, background 180ms ease, transform 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button.like:hover {
|
||||||
|
color: #16a34a;
|
||||||
|
border-color: #86efac;
|
||||||
|
background: #f0fdf4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button.dislike:hover {
|
||||||
|
color: #dc2626;
|
||||||
|
border-color: #fca5a5;
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button.save:hover {
|
||||||
|
color: #2563eb;
|
||||||
|
border-color: #93c5fd;
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button.share:hover {
|
||||||
|
color: #7c3aed;
|
||||||
|
border-color: #c4b5fd;
|
||||||
|
background: #f5f3ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more-button {
|
||||||
|
color: #2563eb;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #93c5fd;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more-button:hover {
|
||||||
|
background: #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.feed-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-sidebar {
|
||||||
|
min-height: auto;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-profile {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.feed-topbar.with-filters {
|
||||||
|
padding: 10px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-filter-nav {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-filter-control {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-content {
|
||||||
|
padding: 24px 16px 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.magic-card-actions {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import FeedContent from '../components/FeedContent'
|
||||||
|
import FeedSidebar from '../components/FeedSidebar'
|
||||||
|
import FeedTopbar from '../components/FeedTopbar'
|
||||||
|
import './FeedPage.css'
|
||||||
|
|
||||||
|
function FeedPage() {
|
||||||
|
const [sentimentFilter, setSentimentFilter] = useState<string | null>(null)
|
||||||
|
const [topicsFilter, setTopicsFilter] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleSentimentChange = (sentiment: string) => {
|
||||||
|
setSentimentFilter(sentiment === 'All Sentiment' ? null : sentiment)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTopicChange = (topic: string) => {
|
||||||
|
setTopicsFilter(topic === 'All Topics' ? null : topic)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="feed-layout" aria-label="Feed BriefAI">
|
||||||
|
<FeedSidebar activeItem="feed" />
|
||||||
|
|
||||||
|
<section className="feed-main">
|
||||||
|
<FeedTopbar
|
||||||
|
showFeedFilters
|
||||||
|
sentimentFilter={sentimentFilter}
|
||||||
|
onSentimentChange={handleSentimentChange}
|
||||||
|
topicsFilter={topicsFilter}
|
||||||
|
onTopicChange={handleTopicChange}
|
||||||
|
/>
|
||||||
|
<FeedContent sentimentFilter={sentimentFilter} topicsFilter={topicsFilter} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FeedPage
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
.home-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
padding: 56px 24px 48px;
|
||||||
|
background: linear-gradient(180deg, #f8fafc 0%, #ffffff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-container {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1024px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-badge {
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
width: fit-content;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
background: linear-gradient(135deg, #2563eb, #7c3aed);
|
||||||
|
box-shadow: 0 14px 28px rgba(37, 99, 235, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-badge svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section h1 {
|
||||||
|
margin: 0 0 24px;
|
||||||
|
font-size: clamp(2.5rem, 6vw, 3.75rem);
|
||||||
|
line-height: 1.05;
|
||||||
|
font-weight: 800;
|
||||||
|
background: linear-gradient(135deg, #2563eb, #7c3aed);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-subtitle {
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 960px;
|
||||||
|
font-size: clamp(1.06rem, 2.4vw, 1.25rem);
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-grid {
|
||||||
|
margin-top: 36px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-card {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: linear-gradient(160deg, #ffffff 0%, #f8fafc 100%);
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 20px 34px rgba(15, 23, 42, 0.09);
|
||||||
|
transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -1px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(135deg, rgba(37, 99, 235, 0.2), rgba(124, 58, 237, 0.2));
|
||||||
|
opacity: 0;
|
||||||
|
z-index: -2;
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-card::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
right: -42px;
|
||||||
|
top: -42px;
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: radial-gradient(circle, rgba(59, 130, 246, 0.2), transparent 70%);
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-card:hover {
|
||||||
|
transform: translateY(-6px);
|
||||||
|
border-color: #bfdbfe;
|
||||||
|
box-shadow: 0 28px 42px rgba(15, 23, 42, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-card:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.free-plan {
|
||||||
|
border-color: #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-plan {
|
||||||
|
border-color: #ddd6fe;
|
||||||
|
background: linear-gradient(160deg, #ffffff 0%, #f5f3ff 100%);
|
||||||
|
box-shadow: 0 22px 38px rgba(91, 33, 182, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e3a8a;
|
||||||
|
background: #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-chip.premium {
|
||||||
|
color: #5b21b6;
|
||||||
|
background: #ede9fe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-card h2 {
|
||||||
|
margin: 14px 0 10px;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-card p {
|
||||||
|
margin: 0;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-actions {
|
||||||
|
margin-top: 18px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-actions a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-login-button,
|
||||||
|
.plan-register-button {
|
||||||
|
min-width: 116px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 11px 16px;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-login-button {
|
||||||
|
color: #ffffff;
|
||||||
|
background: linear-gradient(135deg, #2563eb, #7c3aed);
|
||||||
|
box-shadow: 0 10px 20px rgba(37, 99, 235, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-login-button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 20px 36px rgba(37, 99, 235, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-register-button {
|
||||||
|
color: #334155;
|
||||||
|
border: 2px solid #e2e8f0;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-register-button:hover {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.benefits-section {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1280px;
|
||||||
|
margin-top: 96px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.benefit-card {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 32px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 24px 36px rgba(15, 23, 42, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.benefit-icon-wrap {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 14px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.benefit-icon-wrap svg {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.benefit-icon-wrap.blue {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e3a8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.benefit-icon-wrap.violet {
|
||||||
|
background: #ede9fe;
|
||||||
|
color: #5b21b6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.benefit-icon-wrap.green {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.benefit-card h3 {
|
||||||
|
margin: 18px 0 12px;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.benefit-card p {
|
||||||
|
margin: 0;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-section {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1024px;
|
||||||
|
margin-top: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-section h2 {
|
||||||
|
margin: 0 0 32px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: clamp(1.85rem, 4vw, 2rem);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-shell {
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 48px;
|
||||||
|
background: linear-gradient(135deg, #f1f5f9, #e2e8f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 20px 36px rgba(15, 23, 42, 0.11);
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sentiment-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #166534;
|
||||||
|
background: #dcfce7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card h3 {
|
||||||
|
margin: 16px 0 12px;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card p {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip.ai {
|
||||||
|
color: #1d4ed8;
|
||||||
|
background: #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip.openai {
|
||||||
|
color: #6d28d9;
|
||||||
|
background: #ede9fe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip.safety {
|
||||||
|
color: #475569;
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-actions {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-footer {
|
||||||
|
margin-top: 96px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.benefits-section {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 899px) {
|
||||||
|
.plan-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-shell {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import './HomePage.css'
|
||||||
|
|
||||||
|
function HomePage() {
|
||||||
|
return (
|
||||||
|
<section className="home-page" aria-label="Home pubblica BriefAI">
|
||||||
|
<div className="home-container">
|
||||||
|
{/* Hero principale: comunica il valore del prodotto in pochi secondi. */}
|
||||||
|
<header className="hero-section">
|
||||||
|
<p className="hero-badge">
|
||||||
|
<SparklesIcon />
|
||||||
|
<span>La tua intelligence sulle notizie potenziata dall'IA</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h1>Ottieni notizie pronte all'azione in 30 secondi</h1>
|
||||||
|
|
||||||
|
<p className="hero-subtitle">
|
||||||
|
BriefAI trasforma il sovraccarico informativo in intelligence operativa. Riassunti
|
||||||
|
generati dall'IA, analisi del sentiment e feed personalizzati per founder, analisti e
|
||||||
|
appassionati di tecnologia.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="plan-grid" aria-label="Piani disponibili">
|
||||||
|
<article className="plan-card free-plan" aria-label="Piano gratuito">
|
||||||
|
<span className="plan-chip">Free</span>
|
||||||
|
<h2>Piano Gratuito</h2>
|
||||||
|
<p>
|
||||||
|
Inizia con il feed intelligente di base, riepiloghi rapidi e monitoraggio leggero
|
||||||
|
dei trend principali.
|
||||||
|
</p>
|
||||||
|
<div className="plan-actions">
|
||||||
|
<Link to="/login" className="plan-login-button">
|
||||||
|
Login
|
||||||
|
</Link>
|
||||||
|
<Link to="/onboarding" className="plan-register-button">
|
||||||
|
Registrati
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="plan-card premium-plan" aria-label="Piano premium">
|
||||||
|
<span className="plan-chip premium">Premium</span>
|
||||||
|
<h2>Piano a Pagamento</h2>
|
||||||
|
<p>
|
||||||
|
Sblocca analisi avanzate, alert strategici, maggiore profondita di sentiment e
|
||||||
|
tracciamento esteso di keyword e competitor.
|
||||||
|
</p>
|
||||||
|
<div className="plan-actions">
|
||||||
|
<Link to="/login" className="plan-login-button">
|
||||||
|
Login
|
||||||
|
</Link>
|
||||||
|
<Link to="/onboarding" className="plan-register-button">
|
||||||
|
Registrati
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Sezione benefici: tre card per descrivere le funzionalita principali. */}
|
||||||
|
<section className="benefits-section" aria-label="Benefici principali">
|
||||||
|
<article className="benefit-card">
|
||||||
|
<div className="benefit-icon-wrap blue">
|
||||||
|
<SparklesIcon />
|
||||||
|
</div>
|
||||||
|
<h3>Feed Personalizzato</h3>
|
||||||
|
<p>
|
||||||
|
L'IA impara i tuoi interessi e ti mostra solo cio che conta davvero. Monitora
|
||||||
|
competitor, brand e temi emergenti in un unico flusso.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="benefit-card">
|
||||||
|
<div className="benefit-icon-wrap violet">
|
||||||
|
<ZapIcon />
|
||||||
|
</div>
|
||||||
|
<h3>Riassunti IA</h3>
|
||||||
|
<p>
|
||||||
|
Ogni articolo compresso in massimo 50 parole. Hai l'essenza senza rumore e recuperi
|
||||||
|
ore preziose ogni giorno.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="benefit-card">
|
||||||
|
<div className="benefit-icon-wrap green">
|
||||||
|
<TrendingUpIcon />
|
||||||
|
</div>
|
||||||
|
<h3>Insight di Sentiment</h3>
|
||||||
|
<p>
|
||||||
|
Analisi istantanea del sentiment su ogni notizia. Comprendi rapidamente percezione di
|
||||||
|
mercato e reputazione del brand.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Anteprima prodotto: simula una card reale del feed per mostrare l'output. */}
|
||||||
|
<section className="preview-section" aria-label="Anteprima applicazione">
|
||||||
|
<h2>Guarda BriefAI in azione</h2>
|
||||||
|
|
||||||
|
<div className="preview-shell">
|
||||||
|
<article className="preview-card" aria-label="Demo articolo IA">
|
||||||
|
<header className="preview-card-header">
|
||||||
|
<span>TechCrunch • 2 ore fa</span>
|
||||||
|
<span className="sentiment-badge">Positivo</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<h3>OpenAI annuncia un importante passo avanti nella sicurezza dell'IA</h3>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
OpenAI introduce un nuovo framework di sicurezza che riduce del 60% gli output
|
||||||
|
dannosi. I leader del settore elogiano l'approccio. Potrebbe diventare uno standard
|
||||||
|
per i laboratori di IA.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="preview-tags" aria-label="Tag articolo">
|
||||||
|
<span className="tag-chip ai">IA</span>
|
||||||
|
<span className="tag-chip openai">OpenAI</span>
|
||||||
|
<span className="tag-chip safety">Sicurezza</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="preview-actions" aria-label="Azioni articolo">
|
||||||
|
<span>👍 Mi piace</span>
|
||||||
|
<span>💾 Salva</span>
|
||||||
|
<span>🔗 Condividi</span>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer className="home-footer">BriefAI © 2026 - Strumento di Decision Intelligence</footer>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SparklesIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6L12 3Z" />
|
||||||
|
<path d="M19 14l.8 2.2L22 17l-2.2.8L19 20l-.8-2.2L16 17l2.2-.8L19 14Z" />
|
||||||
|
<path d="M5 14l.8 2.2L8 17l-2.2.8L5 20l-.8-2.2L2 17l2.2-.8L5 14Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ZapIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M13 2 4 14h6l-1 8 9-12h-6l1-8Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrendingUpIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M3 17l6-6 4 4 8-8" />
|
||||||
|
<path d="M14 7h7v7" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HomePage
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
.app-shell > .auth-panel {
|
||||||
|
flex: 0 1 460px;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel,
|
||||||
|
.home-panel {
|
||||||
|
width: min(100%, 460px);
|
||||||
|
background: var(--panel-bg);
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 28px;
|
||||||
|
box-shadow: var(--panel-shadow);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel .eyebrow {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel h1 {
|
||||||
|
margin: 8px 0 8px;
|
||||||
|
font-size: clamp(36px, 4vw, 44px);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel .subtitle {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-link-row {
|
||||||
|
margin: -8px 0 16px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form input,
|
||||||
|
.login-form button {
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form input {
|
||||||
|
background: var(--input-bg);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form input:focus-visible,
|
||||||
|
.login-form button:focus-visible {
|
||||||
|
outline: 2px solid var(--focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form button {
|
||||||
|
margin-top: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 12px 14px;
|
||||||
|
color: #fff;
|
||||||
|
border-color: transparent;
|
||||||
|
background: linear-gradient(120deg, var(--accent-a), var(--accent-b));
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row input {
|
||||||
|
margin-top: 3px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row span {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-footer {
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-link {
|
||||||
|
color: var(--accent-b);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type { FormEvent } from 'react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { login } from '../services/authService'
|
||||||
|
import './LoginPage.css'
|
||||||
|
|
||||||
|
type LoginPageProps = {
|
||||||
|
onLoginSuccess?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginPage({ onLoginSuccess }: LoginPageProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
try {
|
||||||
|
await login(email, password)
|
||||||
|
setError('')
|
||||||
|
if (typeof onLoginSuccess === 'function') onLoginSuccess()
|
||||||
|
navigate('/feed')
|
||||||
|
} catch (err: any) {
|
||||||
|
setError('Credenziali non valide')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="auth-panel" aria-label="Accesso BriefAI">
|
||||||
|
<p className="eyebrow">BriefAI</p>
|
||||||
|
<h1>Accedi</h1>
|
||||||
|
<p className="subtitle">Inserisci le tue credenziali per iniziare la configurazione.</p>
|
||||||
|
|
||||||
|
<form className="login-form" onSubmit={handleSubmit}>
|
||||||
|
<label htmlFor="email">Email</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label htmlFor="password">Password</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
placeholder="Inserisci la password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="top-link-row">
|
||||||
|
<a className="text-link" href="#" aria-label="Recupera password">
|
||||||
|
Ti sei dimenticato la password?
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button type="submit">Entra</button>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="error" role="alert" aria-live="polite">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="auth-footer">
|
||||||
|
Non hai un account? <Link className="text-link" to="/onboarding">Registrati</Link>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="auth-footer">
|
||||||
|
Vuoi tornare alla pagina iniziale? <Link className="text-link" to="/">Home</Link>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginPage
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
.app-shell > .onboarding-shell {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-shell {
|
||||||
|
width: min(100%, 920px);
|
||||||
|
height: 100%;
|
||||||
|
padding: 82px 16px 24px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-wrapper {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(247, 251, 255, 0.95);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
padding: 0 16px 8px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-track {
|
||||||
|
height: 2px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #2563eb, #7c3aed);
|
||||||
|
transition: width 280ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-meta {
|
||||||
|
margin: 8px auto 0;
|
||||||
|
width: min(100%, 920px);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-card {
|
||||||
|
max-width: 780px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 28px;
|
||||||
|
box-shadow: var(--panel-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-card h1 {
|
||||||
|
margin: 8px 0 8px;
|
||||||
|
font-size: clamp(36px, 4vw, 44px);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-card .subtitle {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topics-grid {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-card {
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid #dce7f0;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 18px 12px;
|
||||||
|
color: var(--text);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 180ms ease, border-color 180ms ease, background 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-card:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-card.selected {
|
||||||
|
border-color: #2563eb;
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-emoji {
|
||||||
|
font-size: 2.25rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-check {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
bottom: 8px;
|
||||||
|
color: #2563eb;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-action {
|
||||||
|
margin-top: 18px;
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(90deg, #2563eb, #7c3aed);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-action:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyword-input {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyword-input:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
border-color: #2563eb;
|
||||||
|
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-section {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-section h2 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-chip {
|
||||||
|
margin-top: 0;
|
||||||
|
border: 1px solid #d5dbe4;
|
||||||
|
background: #e5e7eb;
|
||||||
|
color: #1f2937;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-chip:hover {
|
||||||
|
background: #dbeafe;
|
||||||
|
border-color: #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracking-chip {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-chip {
|
||||||
|
margin-top: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0;
|
||||||
|
color: #2563eb;
|
||||||
|
background: #fff;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-actions {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-action {
|
||||||
|
margin-top: 18px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
background: #e5e7eb;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.topics-grid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import './OnboardingPage.css'
|
||||||
|
|
||||||
|
const TOPIC_OPTIONS = [
|
||||||
|
{ label: 'Intelligenza Artificiale', emoji: '🤖' },
|
||||||
|
{ label: 'Cybersecurity', emoji: '🔒' },
|
||||||
|
{ label: 'Business & Finanza', emoji: '💼' },
|
||||||
|
{ label: 'Politica & Geopolitica', emoji: '⚖️' },
|
||||||
|
{ label: 'Startup & Innovazione', emoji: '🚀' },
|
||||||
|
{ label: 'Software & Sviluppo', emoji: '💻' },
|
||||||
|
{ label: 'Scienza & Ricerca', emoji: '🔬' },
|
||||||
|
{ label: 'Energia & Ambiente', emoji: '🌱' },
|
||||||
|
{ label: 'Economia & Mercati', emoji: '📈' },
|
||||||
|
{ label: 'Social Media & Cultura', emoji: '📱' },
|
||||||
|
{ label: 'Salute & Medicina', emoji: '🏥' },
|
||||||
|
{ label: 'Trasporti & Mobilità', emoji: '🚗' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const SUGGESTIONS = ['OpenAI', 'Google AI', 'Anthropic', 'Tesla', 'SpaceX'] as const
|
||||||
|
|
||||||
|
function OnboardingPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
// Step corrente del wizard: 1 per categorie, 2 per tracking keyword.
|
||||||
|
const [step, setStep] = useState<1 | 2>(1)
|
||||||
|
// Stato accumulato dello step 1 (interessi selezionati).
|
||||||
|
const [selectedTopics, setSelectedTopics] = useState<string[]>([])
|
||||||
|
// Stato accumulato dello step 2 (keyword da monitorare).
|
||||||
|
const [keywords, setKeywords] = useState<string[]>([])
|
||||||
|
const [keywordInput, setKeywordInput] = useState('')
|
||||||
|
|
||||||
|
const progressPercent = step === 1 ? 50 : 100
|
||||||
|
|
||||||
|
// Evita di mostrare nei suggerimenti keyword gia presenti nella lista personale.
|
||||||
|
const suggestionPool = useMemo(
|
||||||
|
() => SUGGESTIONS.filter((item) => !keywords.includes(item)),
|
||||||
|
[keywords],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Toggle atomico della card: se gia selezionata la rimuove, altrimenti la aggiunge.
|
||||||
|
const toggleTopic = (topic: string) => {
|
||||||
|
setSelectedTopics((prevTopics) =>
|
||||||
|
prevTopics.includes(topic)
|
||||||
|
? prevTopics.filter((savedTopic) => savedTopic !== topic)
|
||||||
|
: [...prevTopics, topic],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addKeyword = (rawValue: string) => {
|
||||||
|
const cleanedKeyword = rawValue.trim()
|
||||||
|
|
||||||
|
// Normalizzazione semplice: ignora vuoti e duplicati per mantenere la lista consistente.
|
||||||
|
if (!cleanedKeyword || keywords.includes(cleanedKeyword)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setKeywords((prevKeywords) => [...prevKeywords, cleanedKeyword])
|
||||||
|
setKeywordInput('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeKeyword = (keywordToRemove: string) => {
|
||||||
|
setKeywords((prevKeywords) =>
|
||||||
|
prevKeywords.filter((savedKeyword) => savedKeyword !== keywordToRemove),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToNextStep = () => {
|
||||||
|
// Primo click su Continua: passa allo step 2 senza fare submit globale.
|
||||||
|
if (step === 1) {
|
||||||
|
setStep(2)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Salvataggio demo in localStorage: simula la persistenza prima del redirect
|
||||||
|
// verso il form di registrazione. Nessuna chiamata al backend qui.
|
||||||
|
localStorage.setItem(
|
||||||
|
'briefai-onboarding',
|
||||||
|
JSON.stringify({ selectedTopics, keywords }),
|
||||||
|
)
|
||||||
|
// Dopo il wizard, mostra il form di registrazione (pre-auth flow).
|
||||||
|
navigate('/register')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="onboarding-shell" aria-label="Configurazione iniziale BriefAI">
|
||||||
|
<header className="progress-wrapper" aria-label="Progresso onboarding">
|
||||||
|
<div className="progress-track">
|
||||||
|
<div className="progress-fill" style={{ width: `${progressPercent}%` }}></div>
|
||||||
|
</div>
|
||||||
|
<div className="progress-meta">
|
||||||
|
<span>{`Passo ${step} di 2`}</span>
|
||||||
|
<span>{`${progressPercent}%`}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{step === 1 ? (
|
||||||
|
<article className="onboarding-card">
|
||||||
|
<h1>A cosa sei interessato?</h1>
|
||||||
|
<p className="subtitle">Seleziona tutte le categorie che ti interessano</p>
|
||||||
|
|
||||||
|
<div className="topics-grid">
|
||||||
|
{TOPIC_OPTIONS.map((topic) => {
|
||||||
|
const isSelected = selectedTopics.includes(topic.label)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={topic.label}
|
||||||
|
type="button"
|
||||||
|
className={`topic-card ${isSelected ? 'selected' : ''}`}
|
||||||
|
onClick={() => toggleTopic(topic.label)}
|
||||||
|
>
|
||||||
|
<span className="topic-emoji" aria-hidden="true">
|
||||||
|
{topic.emoji}
|
||||||
|
</span>
|
||||||
|
<span>{topic.label}</span>
|
||||||
|
{isSelected && (
|
||||||
|
<span className="topic-check" aria-hidden="true">
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="onboarding-action"
|
||||||
|
onClick={goToNextStep}
|
||||||
|
disabled={selectedTopics.length === 0}
|
||||||
|
>
|
||||||
|
Continua
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
) : (
|
||||||
|
<article className="onboarding-card">
|
||||||
|
<h1>Tieni traccia di argomenti specifici</h1>
|
||||||
|
<p className="subtitle">Aggiungi concorrenti, marchi o parole chiave da monitorare.</p>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="keyword-input"
|
||||||
|
placeholder="Inserisci concorrenti, marchi, argomenti..."
|
||||||
|
value={keywordInput}
|
||||||
|
onChange={(event) => setKeywordInput(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault()
|
||||||
|
addKeyword(keywordInput)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="chip-section" aria-label="Suggerimenti">
|
||||||
|
<h2>Suggerimenti</h2>
|
||||||
|
<div className="chip-wrap">
|
||||||
|
{suggestionPool.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item}
|
||||||
|
type="button"
|
||||||
|
className="suggestion-chip"
|
||||||
|
onClick={() => addKeyword(item)}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">+</span>
|
||||||
|
{item}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{keywords.length > 0 && (
|
||||||
|
<section className="chip-section" aria-label="La tua lista di monitoraggio">
|
||||||
|
<h2>La tua lista di monitoraggio</h2>
|
||||||
|
<div className="chip-wrap">
|
||||||
|
{keywords.map((keyword) => (
|
||||||
|
<span key={keyword} className="tracking-chip">
|
||||||
|
{keyword}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="remove-chip"
|
||||||
|
onClick={() => removeKeyword(keyword)}
|
||||||
|
aria-label={`Rimuovi ${keyword}`}
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="step-actions">
|
||||||
|
<button type="button" className="secondary-action" onClick={() => setStep(1)}>
|
||||||
|
Indietro
|
||||||
|
</button>
|
||||||
|
<button type="button" className="onboarding-action" onClick={goToNextStep}>
|
||||||
|
Continua
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OnboardingPage
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@import './LoginPage.css';
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import type { FormEvent } from 'react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { register } from '../services/authService'
|
||||||
|
import './RegisterPage.css'
|
||||||
|
|
||||||
|
type RegisterPageProps = {
|
||||||
|
onRegisterSuccess?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function RegisterPage({ onRegisterSuccess }: RegisterPageProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
const [acceptedTerms, setAcceptedTerms] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
// Validazione esplicita: se i termini non sono accettati, blocca l'invio.
|
||||||
|
if (!acceptedTerms) {
|
||||||
|
setError("Devi accettare i Termini di servizio e l'Informativa sulla privacy.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recupera preferenze accumulate durante l'onboarding (se presenti)
|
||||||
|
let macroTopics: string[] | undefined = undefined
|
||||||
|
let keywords: string[] | undefined = undefined
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('briefai-onboarding')
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
macroTopics = parsed.selectedTopics || undefined
|
||||||
|
keywords = parsed.keywords || undefined
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore parsing errors
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await register({
|
||||||
|
email,
|
||||||
|
password: newPassword,
|
||||||
|
username,
|
||||||
|
preferences: {
|
||||||
|
macroTopics,
|
||||||
|
keywords,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
setError('')
|
||||||
|
// Pulizia locale
|
||||||
|
setUsername('')
|
||||||
|
setEmail('')
|
||||||
|
setNewPassword('')
|
||||||
|
setAcceptedTerms(false)
|
||||||
|
if (typeof onRegisterSuccess === 'function') onRegisterSuccess()
|
||||||
|
// Dopo la registrazione il token è salvato dal servizio; redirige al feed
|
||||||
|
navigate('/feed')
|
||||||
|
} catch (err: any) {
|
||||||
|
// Estrae il messaggio d'errore lanciato dal backend (`throw await res.json()`),
|
||||||
|
// oppure mostra un fallback se l'API non risponde correttamente.
|
||||||
|
const errorMessage = err?.error || err?.message || 'Errore nella registrazione'
|
||||||
|
setError(errorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="auth-panel" aria-label="Registrazione BriefAI">
|
||||||
|
<p className="eyebrow">BriefAI</p>
|
||||||
|
<h1>Registrati</h1>
|
||||||
|
<p className="subtitle">Crea il tuo account per iniziare.</p>
|
||||||
|
|
||||||
|
<form className="login-form" onSubmit={handleSubmit}>
|
||||||
|
<label htmlFor="register-username">Utente</label>
|
||||||
|
<input
|
||||||
|
id="register-username"
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
|
placeholder="Il tuo username"
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label htmlFor="register-email">Email</label>
|
||||||
|
<input
|
||||||
|
id="register-email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
placeholder="nome@email.com"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label htmlFor="register-password">Nuova password</label>
|
||||||
|
<input
|
||||||
|
id="register-password"
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(event) => setNewPassword(event.target.value)}
|
||||||
|
placeholder="Nuova password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label className="checkbox-row" htmlFor="register-terms">
|
||||||
|
<input
|
||||||
|
id="register-terms"
|
||||||
|
type="checkbox"
|
||||||
|
checked={acceptedTerms}
|
||||||
|
onChange={(event) => setAcceptedTerms(event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Accetto i Termini di servizio e l'Informativa sulla privacy</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit">Crea account</button>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="error" role="alert" aria-live="polite">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="auth-footer">
|
||||||
|
Se hai gia un account fai <Link className="text-link" to="/login">accesso</Link>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="auth-footer">
|
||||||
|
Torna alla <Link className="text-link" to="/">home</Link>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RegisterPage
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
.settings-layout {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-main {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-content {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header h1 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: clamp(34px, 4vw, 48px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header p {
|
||||||
|
margin: 0;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
margin: 24px 0 20px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tab {
|
||||||
|
margin-top: 0;
|
||||||
|
padding: 12px 0;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: #64748b;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
transition: color 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tab:hover {
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tab.active {
|
||||||
|
color: #2563eb;
|
||||||
|
border-bottom-color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section-header h2 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section-header p {
|
||||||
|
margin: 0;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tile {
|
||||||
|
position: relative;
|
||||||
|
min-height: 108px;
|
||||||
|
margin-top: 0;
|
||||||
|
padding: 16px 12px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #fff;
|
||||||
|
color: #111827;
|
||||||
|
transition: transform 0.2s ease, border-color 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tile:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tile.selected {
|
||||||
|
border-color: #2563eb;
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-emoji {
|
||||||
|
font-size: 1.875rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-action-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-save-button {
|
||||||
|
margin-top: 0;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-save-button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-save-button svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyword-toolbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-keyword-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-keyword-input:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
border-color: #2563eb;
|
||||||
|
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;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracked-chip-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracked-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1d4ed8;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracked-chip-remove {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
margin-top: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #1d4ed8;
|
||||||
|
background: #fff;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-info-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-info-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-info-value {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscription-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscription-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-badge {
|
||||||
|
width: fit-content;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-badge.free {
|
||||||
|
color: #334155;
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-badge.pro {
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(90deg, #2563eb, #7c3aed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscription-copy p {
|
||||||
|
margin: 0;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscription-upgrade-button {
|
||||||
|
margin-top: 0;
|
||||||
|
background: linear-gradient(90deg, #2563eb, #7c3aed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscription-link-button {
|
||||||
|
margin-top: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
color: #2563eb;
|
||||||
|
background: transparent;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.category-grid {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.settings-content {
|
||||||
|
padding: 24px 16px 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tabs {
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyword-toolbar,
|
||||||
|
.subscription-box {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-action-row {
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-save-button,
|
||||||
|
.keyword-add-button {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import FeedSidebar from '../components/FeedSidebar'
|
||||||
|
import FeedTopbar from '../components/FeedTopbar'
|
||||||
|
import AccountSettings, { type SubscriptionState } from '../components/AccountSettings'
|
||||||
|
import InterestPreferences from '../components/InterestPreferences'
|
||||||
|
import SettingsTabs, { type SettingsTab } from '../components/SettingsTabs'
|
||||||
|
import TrackedKeywords from '../components/TrackedKeywords'
|
||||||
|
import './FeedPage.css'
|
||||||
|
import './SettingsPage.css'
|
||||||
|
import { fetchProfile, updateProfile } from '../services/apiService'
|
||||||
|
|
||||||
|
type SettingsSnapshot = {
|
||||||
|
selectedMacroTopics: string[]
|
||||||
|
keywords: string[]
|
||||||
|
subscriptionState: SubscriptionState
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'briefai-settings'
|
||||||
|
const ONBOARDING_KEY = 'briefai-onboarding'
|
||||||
|
const DEFAULT_MACRO_TOPICS = ['Scienza & Ricerca']
|
||||||
|
|
||||||
|
function readInitialSettings(): SettingsSnapshot {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return {
|
||||||
|
selectedMacroTopics: DEFAULT_MACRO_TOPICS,
|
||||||
|
keywords: [],
|
||||||
|
subscriptionState: 'free',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onboardingSnapshot = readJSON<{ selectedTopics?: string[]; keywords?: string[] }>(
|
||||||
|
ONBOARDING_KEY,
|
||||||
|
)
|
||||||
|
const storedSnapshot = readJSON<Partial<SettingsSnapshot>>(STORAGE_KEY)
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedMacroTopics:
|
||||||
|
storedSnapshot?.selectedMacroTopics ?? onboardingSnapshot?.selectedTopics ?? DEFAULT_MACRO_TOPICS,
|
||||||
|
keywords: storedSnapshot?.keywords ?? onboardingSnapshot?.keywords ?? [],
|
||||||
|
subscriptionState: storedSnapshot?.subscriptionState ?? 'free',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJSON<T>(storageKey: string): T | undefined {
|
||||||
|
try {
|
||||||
|
const rawValue = window.localStorage.getItem(storageKey)
|
||||||
|
|
||||||
|
if (!rawValue) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(rawValue) as T
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistSettings(snapshot: SettingsSnapshot) {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagina Impostazioni: gestisce preferenze feed e account con una struttura a tab.
|
||||||
|
function SettingsPage() {
|
||||||
|
const [activeTab, setActiveTab] = useState<SettingsTab>('interests')
|
||||||
|
const [selectedMacroTopics, setSelectedMacroTopics] = useState<string[]>(() =>
|
||||||
|
readInitialSettings().selectedMacroTopics,
|
||||||
|
)
|
||||||
|
const [keywords, setKeywords] = useState<string[]>(() => readInitialSettings().keywords)
|
||||||
|
const [keywordInput, setKeywordInput] = useState('')
|
||||||
|
const [subscriptionState, setSubscriptionState] = useState<SubscriptionState>(
|
||||||
|
() => readInitialSettings().subscriptionState,
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchProfile()
|
||||||
|
.then((res) => {
|
||||||
|
if (res && res.profile) {
|
||||||
|
setSelectedMacroTopics(res.profile.macroTopics || [])
|
||||||
|
setKeywords(res.profile.keywords || [])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleToggleMacroTopic = (topic: string) => {
|
||||||
|
// Toggle semplice: se la categoria esiste la rimuove, altrimenti la aggiunge.
|
||||||
|
setSelectedMacroTopics((currentTopics) =>
|
||||||
|
currentTopics.includes(topic)
|
||||||
|
? currentTopics.filter((savedTopic) => savedTopic !== topic)
|
||||||
|
: [...currentTopics, topic],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveMacroTopics = () => {
|
||||||
|
updateProfile({ macroTopics: selectedMacroTopics })
|
||||||
|
.then((res) => {
|
||||||
|
if (res && res.profile) {
|
||||||
|
setSelectedMacroTopics(res.profile.macroTopics || selectedMacroTopics)
|
||||||
|
}
|
||||||
|
persistSettings({ selectedMacroTopics, keywords, subscriptionState })
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// fallback local persist
|
||||||
|
persistSettings({ selectedMacroTopics, keywords, subscriptionState })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddKeyword = (rawKeyword: string) => {
|
||||||
|
const cleanedKeyword = rawKeyword.trim()
|
||||||
|
|
||||||
|
// Evita duplicati e valori vuoti per mantenere la lista leggibile.
|
||||||
|
if (!cleanedKeyword || keywords.includes(cleanedKeyword)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setKeywords((currentKeywords) => [...currentKeywords, cleanedKeyword])
|
||||||
|
setKeywordInput('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveKeyword = (keywordToRemove: string) => {
|
||||||
|
setKeywords((currentKeywords) =>
|
||||||
|
currentKeywords.filter((savedKeyword) => savedKeyword !== keywordToRemove),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveKeywords = () => {
|
||||||
|
updateProfile({ keywords })
|
||||||
|
.then((res) => {
|
||||||
|
if (res && res.profile) {
|
||||||
|
setKeywords(res.profile.keywords || keywords)
|
||||||
|
}
|
||||||
|
persistSettings({ selectedMacroTopics, keywords, subscriptionState })
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
persistSettings({ selectedMacroTopics, keywords, subscriptionState })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpgrade = () => {
|
||||||
|
const nextSubscriptionState: SubscriptionState = 'pro'
|
||||||
|
setSubscriptionState(nextSubscriptionState)
|
||||||
|
persistSettings({ selectedMacroTopics, keywords, subscriptionState: nextSubscriptionState })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancelSubscription = () => {
|
||||||
|
const nextSubscriptionState: SubscriptionState = 'free'
|
||||||
|
setSubscriptionState(nextSubscriptionState)
|
||||||
|
persistSettings({ selectedMacroTopics, keywords, subscriptionState: nextSubscriptionState })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="feed-layout settings-layout" aria-label="Impostazioni BriefAI">
|
||||||
|
<FeedSidebar activeItem="impostazioni" />
|
||||||
|
|
||||||
|
<section className="feed-main settings-main">
|
||||||
|
<FeedTopbar />
|
||||||
|
|
||||||
|
<div className="feed-content settings-content">
|
||||||
|
<header className="settings-header">
|
||||||
|
<h1>Impostazioni</h1>
|
||||||
|
<p>Gestisci preferenze e profilo</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<SettingsTabs activeTab={activeTab} onTabChange={setActiveTab} />
|
||||||
|
|
||||||
|
{activeTab === 'interests' ? (
|
||||||
|
<div className="settings-stack">
|
||||||
|
<InterestPreferences
|
||||||
|
selectedMacroTopics={selectedMacroTopics}
|
||||||
|
onToggleMacroTopic={handleToggleMacroTopic}
|
||||||
|
onSaveMacroTopics={handleSaveMacroTopics}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TrackedKeywords
|
||||||
|
keywords={keywords}
|
||||||
|
keywordInput={keywordInput}
|
||||||
|
onKeywordInputChange={setKeywordInput}
|
||||||
|
onAddKeyword={handleAddKeyword}
|
||||||
|
onRemoveKeyword={handleRemoveKeyword}
|
||||||
|
onSaveKeywords={handleSaveKeywords}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<AccountSettings
|
||||||
|
username="John Doe"
|
||||||
|
email="john@example.com"
|
||||||
|
subscriptionState={subscriptionState}
|
||||||
|
onUpgrade={handleUpgrade}
|
||||||
|
onCancelSubscription={handleCancelSubscription}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SettingsPage
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
.trends-content {
|
||||||
|
max-width: 1280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trends-header {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trends-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trends-flame {
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trends-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(34px, 4vw, 48px);
|
||||||
|
font-weight: 800;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trends-header p {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trends-stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-stat-card {
|
||||||
|
border: 1px solid;
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-stat-card.hottest {
|
||||||
|
border-color: #fb923c;
|
||||||
|
background: linear-gradient(135deg, #fff7ed, #fee2e2);
|
||||||
|
color: #9a3412;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-stat-card.emerging {
|
||||||
|
border-color: #60a5fa;
|
||||||
|
background: linear-gradient(135deg, #eff6ff, #f5f3ff);
|
||||||
|
color: #1e3a8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-stat-card.growth {
|
||||||
|
border-color: #4ade80;
|
||||||
|
background: linear-gradient(135deg, #ecfdf5, #d1fae5);
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-stat-icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-stat-icon svg {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-stat-label {
|
||||||
|
margin: 8px 0 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-stat-value {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(26px, 3vw, 32px);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-stat-meta {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trends-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-card-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 48px minmax(0, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 10px 26px rgba(15, 23, 42, 0.04);
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-card-row:hover {
|
||||||
|
border-color: #60a5fa;
|
||||||
|
box-shadow: 0 16px 36px rgba(37, 99, 235, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-rank {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-rank.rank-1 {
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #f97316, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-rank.rank-2 {
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-rank.rank-3 {
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #22c55e, #10b981);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-rank.rank-default {
|
||||||
|
color: #4b5563;
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-top-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-top-row h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-sentiment {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-sentiment.positive {
|
||||||
|
color: #166534;
|
||||||
|
background: #dcfce7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-sentiment.negative {
|
||||||
|
color: #991b1b;
|
||||||
|
background: #fee2e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-sentiment.neutral {
|
||||||
|
color: #475569;
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-description {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-metrics-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-growth {
|
||||||
|
margin: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #16a34a;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-growth svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-volume,
|
||||||
|
.topic-articles {
|
||||||
|
margin: 0;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-volume strong,
|
||||||
|
.topic-articles strong {
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-progress-track {
|
||||||
|
margin-top: 12px;
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e5e7eb;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-progress-fill.top {
|
||||||
|
background: linear-gradient(90deg, #f97316, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-progress-fill.standard {
|
||||||
|
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.trends-stats-grid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.trends-stats-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-card-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-rank {
|
||||||
|
width: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-top-row {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-top-row h2 {
|
||||||
|
font-size: 21px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import FeedSidebar from '../components/FeedSidebar'
|
||||||
|
import FeedTopbar from '../components/FeedTopbar'
|
||||||
|
import './FeedPage.css'
|
||||||
|
import './TrendsPage.css'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { fetchTrendingTopics, fetchSentimentStats } from '../services/apiService'
|
||||||
|
|
||||||
|
type Sentiment = 'Positive' | 'Negative' | 'Neutral'
|
||||||
|
|
||||||
|
type TrendingTopic = {
|
||||||
|
rank: number
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
volume: number
|
||||||
|
growth: number
|
||||||
|
articles: number
|
||||||
|
sentiment: Sentiment
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultTrendingTopics: TrendingTopic[] = [
|
||||||
|
{
|
||||||
|
rank: 1,
|
||||||
|
name: 'AI Safety',
|
||||||
|
description: 'Major breakthroughs in AI safety frameworks and alignment research',
|
||||||
|
volume: 1420,
|
||||||
|
growth: 45,
|
||||||
|
articles: 87,
|
||||||
|
sentiment: 'Positive',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rank: 2,
|
||||||
|
name: 'Quantum Computing',
|
||||||
|
description: 'New quantum processors and cryptography implications',
|
||||||
|
volume: 980,
|
||||||
|
growth: 32,
|
||||||
|
articles: 54,
|
||||||
|
sentiment: 'Neutral',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rank: 3,
|
||||||
|
name: 'Fintech Investment',
|
||||||
|
description: 'Record funding rounds in AI-powered financial solutions',
|
||||||
|
volume: 870,
|
||||||
|
growth: 28,
|
||||||
|
articles: 62,
|
||||||
|
sentiment: 'Positive',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rank: 4,
|
||||||
|
name: 'Crypto Regulation',
|
||||||
|
description: 'Increased regulatory scrutiny and compliance requirements',
|
||||||
|
volume: 760,
|
||||||
|
growth: 18,
|
||||||
|
articles: 48,
|
||||||
|
sentiment: 'Negative',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rank: 5,
|
||||||
|
name: 'Clean Energy',
|
||||||
|
description: 'Battery technology breakthroughs and renewable energy adoption',
|
||||||
|
volume: 640,
|
||||||
|
growth: 15,
|
||||||
|
articles: 41,
|
||||||
|
sentiment: 'Positive',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rank: 6,
|
||||||
|
name: 'Remote Work Tech',
|
||||||
|
description: 'New collaboration tools and hybrid workplace solutions',
|
||||||
|
volume: 520,
|
||||||
|
growth: 12,
|
||||||
|
articles: 35,
|
||||||
|
sentiment: 'Neutral',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rank: 7,
|
||||||
|
name: 'Cybersecurity',
|
||||||
|
description: 'Rising threats and zero-trust architecture adoption',
|
||||||
|
volume: 480,
|
||||||
|
growth: 10,
|
||||||
|
articles: 32,
|
||||||
|
sentiment: 'Negative',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rank: 8,
|
||||||
|
name: 'Space Tech',
|
||||||
|
description: 'Commercial space launches and satellite communications',
|
||||||
|
volume: 420,
|
||||||
|
growth: 8,
|
||||||
|
articles: 28,
|
||||||
|
sentiment: 'Positive',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function formatMentions(value: number) {
|
||||||
|
return value.toLocaleString('en-US')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSentimentClass(sentiment: Sentiment) {
|
||||||
|
if (sentiment === 'Positive') {
|
||||||
|
return 'topic-sentiment positive'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sentiment === 'Negative') {
|
||||||
|
return 'topic-sentiment negative'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'topic-sentiment neutral'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRankClass(rank: number) {
|
||||||
|
if (rank === 1) return 'topic-rank rank-1'
|
||||||
|
if (rank === 2) return 'topic-rank rank-2'
|
||||||
|
if (rank === 3) return 'topic-rank rank-3'
|
||||||
|
return 'topic-rank rank-default'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProgressClass(rank: number) {
|
||||||
|
return rank === 1 ? 'topic-progress-fill top' : 'topic-progress-fill standard'
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrendsPage() {
|
||||||
|
const [topics, setTopics] = useState<TrendingTopic[]>(defaultTrendingTopics)
|
||||||
|
const [sentimentOverview, setSentimentOverview] = useState<any>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTrendingTopics()
|
||||||
|
.then((data) => {
|
||||||
|
if (Array.isArray(data) && data.length) setTopics(data)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
|
||||||
|
fetchSentimentStats()
|
||||||
|
.then((data) => setSentimentOverview(data))
|
||||||
|
.catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="feed-layout" aria-label="Trending Topics BriefAI">
|
||||||
|
<FeedSidebar activeItem="tendenze" />
|
||||||
|
|
||||||
|
<section className="feed-main">
|
||||||
|
<FeedTopbar />
|
||||||
|
|
||||||
|
<section className="feed-content trends-content" aria-label="Contenuto trending topics">
|
||||||
|
<header className="trends-header">
|
||||||
|
<div className="trends-title-row">
|
||||||
|
<span className="trends-flame" aria-hidden="true">
|
||||||
|
🔥
|
||||||
|
</span>
|
||||||
|
<h1>Trending Topics</h1>
|
||||||
|
</div>
|
||||||
|
<p>Hot topics ranked by growth and volume</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="trends-stats-grid" aria-label="Statistiche trend principali">
|
||||||
|
<article className="trend-stat-card hottest">
|
||||||
|
<span className="trend-stat-icon" aria-hidden="true">
|
||||||
|
🔥
|
||||||
|
</span>
|
||||||
|
<p className="trend-stat-label">Hottest Topic</p>
|
||||||
|
<p className="trend-stat-value">AI Safety</p>
|
||||||
|
<p className="trend-stat-meta">+45% growth this week</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="trend-stat-card emerging">
|
||||||
|
<span className="trend-stat-icon" aria-hidden="true">
|
||||||
|
<TrendingUpMiniIcon />
|
||||||
|
</span>
|
||||||
|
<p className="trend-stat-label">Emerging Topics</p>
|
||||||
|
<p className="trend-stat-value">12</p>
|
||||||
|
<p className="trend-stat-meta">New this week</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="trend-stat-card growth">
|
||||||
|
<span className="trend-stat-icon" aria-hidden="true">
|
||||||
|
<ArrowUpMiniIcon />
|
||||||
|
</span>
|
||||||
|
<p className="trend-stat-label">Avg. Growth</p>
|
||||||
|
<p className="trend-stat-value">+21%</p>
|
||||||
|
<p className="trend-stat-meta">Across all topics</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="trends-list" aria-label="Lista trending topics">
|
||||||
|
{topics.map((topic) => (
|
||||||
|
<article key={topic.rank} className="topic-card-row">
|
||||||
|
<div className={getRankClass(topic.rank)} aria-label={`Rank ${topic.rank}`}>
|
||||||
|
#{topic.rank}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="topic-main">
|
||||||
|
<div className="topic-top-row">
|
||||||
|
<h2>{topic.name}</h2>
|
||||||
|
<span className={getSentimentClass(topic.sentiment)}>{topic.sentiment}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="topic-description">{topic.description}</p>
|
||||||
|
|
||||||
|
<div className="topic-metrics-row" aria-label={`Metriche ${topic.name}`}>
|
||||||
|
<p className="topic-growth">
|
||||||
|
<TrendingUpMiniIcon />
|
||||||
|
<span>{`+${topic.growth}%`}</span>
|
||||||
|
</p>
|
||||||
|
<p className="topic-volume">
|
||||||
|
<strong>{formatMentions(topic.volume)}</strong> mentions
|
||||||
|
</p>
|
||||||
|
<p className="topic-articles">
|
||||||
|
<strong>{topic.articles}</strong> articles
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="topic-progress-track" aria-hidden="true">
|
||||||
|
<div
|
||||||
|
className={getProgressClass(topic.rank)}
|
||||||
|
style={{ width: `${Math.min((topic.growth / 50) * 100, 100)}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrendingUpMiniIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<path d="M3 16l6-6 4 4 8-8" />
|
||||||
|
<path d="M14 6h7v7" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArrowUpMiniIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<path d="M12 19V5" />
|
||||||
|
<path d="M6 11l6-6 6 6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TrendsPage
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
const BASE = import.meta.env.VITE_API_URL as string
|
||||||
|
import { getAuthHeader } from './authService'
|
||||||
|
|
||||||
|
const authFetch = (path: string, options: RequestInit = {}) =>
|
||||||
|
fetch(`${BASE}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: { ...getAuthHeader(), ...options.headers },
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /api/stats/sentiment
|
||||||
|
export const fetchSentimentStats = () =>
|
||||||
|
authFetch('/api/stats/sentiment').then((r) => r.json())
|
||||||
|
|
||||||
|
// GET /api/stats/trending
|
||||||
|
export const fetchTrendingTopics = () =>
|
||||||
|
authFetch('/api/stats/trending').then((r) => r.json())
|
||||||
|
|
||||||
|
// GET /api/stats/categories
|
||||||
|
export const fetchCategoryStats = () =>
|
||||||
|
authFetch('/api/stats/categories').then((r) => r.json())
|
||||||
|
|
||||||
|
// GET /api/stats/sources
|
||||||
|
export const fetchSourceStats = () =>
|
||||||
|
authFetch('/api/stats/sources').then((r) => r.json())
|
||||||
|
|
||||||
|
// GET /api/stats/overview
|
||||||
|
export const fetchOverview = () =>
|
||||||
|
authFetch('/api/stats/overview').then((r) => r.json())
|
||||||
|
|
||||||
|
// GET /api/profile
|
||||||
|
export const fetchProfile = () =>
|
||||||
|
authFetch('/api/profile').then((r) => r.json())
|
||||||
|
|
||||||
|
// PUT /api/profile
|
||||||
|
export const updateProfile = async (data: {
|
||||||
|
userId?: string
|
||||||
|
macroTopics?: string[]
|
||||||
|
keywords?: string[]
|
||||||
|
}) => {
|
||||||
|
// Sync to backend
|
||||||
|
const backendRes = await authFetch('/api/profile', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}).then((r) => r.json());
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
try {
|
||||||
|
await fetch(`${n8nUrl}/briefai/profile/update`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
userId: data.userId,
|
||||||
|
macroTopics: data.macroTopics,
|
||||||
|
keywords: data.keywords,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Failed to sync profile to n8n webhook", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return backendRes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
const BASE = import.meta.env.VITE_API_URL as string;
|
||||||
|
|
||||||
|
// Salva il token dopo login o registrazione
|
||||||
|
const saveToken = (token: string) =>
|
||||||
|
localStorage.setItem('briefai_token', token);
|
||||||
|
|
||||||
|
// Restituisce l'header Authorization da aggiungere a ogni richiesta protetta
|
||||||
|
export const getAuthHeader = (): Record<string, string> => {
|
||||||
|
const token = localStorage.getItem('briefai_token');
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Decodifica il payload JWT senza librerie esterne
|
||||||
|
export const decodeToken = (token: string) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(atob(token.split('.')[1]));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// POST /api/auth/login
|
||||||
|
export const login = async (email: string, password: string) => {
|
||||||
|
const res = await fetch(`${BASE}/api/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw await res.json();
|
||||||
|
const data = await res.json();
|
||||||
|
saveToken(data.token);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// POST /api/auth/register
|
||||||
|
export const register = async (payload: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
username: string;
|
||||||
|
preferences?: {
|
||||||
|
macroTopics?: string[] | undefined;
|
||||||
|
keywords?: string[] | undefined;
|
||||||
|
} | undefined;
|
||||||
|
}) => {
|
||||||
|
const res = await fetch(`${BASE}/api/auth/register`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw await res.json();
|
||||||
|
const data = await res.json();
|
||||||
|
saveToken(data.token);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// GET /api/auth/me — verifica se il token è ancora valido
|
||||||
|
export const getMe = async () => {
|
||||||
|
const res = await fetch(`${BASE}/api/auth/me`, {
|
||||||
|
headers: getAuthHeader(),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
localStorage.removeItem('briefai_token');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Logout locale
|
||||||
|
export const logout = () =>
|
||||||
|
localStorage.removeItem('briefai_token');
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { Article } from '../types/article'
|
||||||
|
import { getAuthHeader, decodeToken } from './authService'
|
||||||
|
|
||||||
|
const N8N = import.meta.env.VITE_N8N_URL
|
||||||
|
|
||||||
|
export const fetchPersonalizedFeed = async (): Promise<Article[]> => {
|
||||||
|
console.log("🚀 N8N URL:", N8N);
|
||||||
|
const token = localStorage.getItem('briefai_token')
|
||||||
|
if (!token) throw new Error('Non autenticato')
|
||||||
|
|
||||||
|
const payload = decodeToken(token)
|
||||||
|
const userId: string = payload?.userId
|
||||||
|
if (!userId) throw new Error('Token non valido')
|
||||||
|
|
||||||
|
const res = await fetch(`${N8N}/briefai/feed`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...getAuthHeader() },
|
||||||
|
body: JSON.stringify({ userId, limit: 20 }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const status = res.status
|
||||||
|
const statusText = res.statusText
|
||||||
|
const errorText = await res.text().catch(() => 'non leggibile')
|
||||||
|
console.error('[FeedService] HTTP Error:', { status, statusText, errorText })
|
||||||
|
throw new Error(`Errore nel recupero del feed: ${status} ${statusText}`)
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
console.log('[FeedService] Response:', data)
|
||||||
|
const articles = (data.articles ?? []).map((a: any) => {
|
||||||
|
const idVal = a.uniqueKey ?? a.id ?? a._id ?? ''
|
||||||
|
const uniqueKey = typeof idVal === 'object' ? String(idVal) : idVal
|
||||||
|
return { ...a, uniqueKey }
|
||||||
|
})
|
||||||
|
return articles
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
const N8N = import.meta.env.VITE_N8N_URL
|
||||||
|
|
||||||
|
export const sendFeedback = async (
|
||||||
|
articleId: string,
|
||||||
|
vote: 1 | -1
|
||||||
|
): Promise<void> => {
|
||||||
|
const token = localStorage.getItem('briefai_token')
|
||||||
|
if (!token) return
|
||||||
|
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1]))
|
||||||
|
|
||||||
|
await fetch(`${N8N}/briefai/feedback`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
userId: payload.userId,
|
||||||
|
articleId,
|
||||||
|
vote,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export interface Article {
|
||||||
|
uniqueKey: string;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
pubDate: string;
|
||||||
|
source: string;
|
||||||
|
category: string;
|
||||||
|
summary: string;
|
||||||
|
sentiment: 'Positive' | 'Negative' | 'Neutral';
|
||||||
|
entities: string[];
|
||||||
|
trendingTopics: string[];
|
||||||
|
macroTopics?: string[];
|
||||||
|
score?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react, { reactCompilerPreset } from '@vitejs/plugin-react'
|
||||||
|
import babel from '@rolldown/plugin-babel'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
babel({ presets: [reactCompilerPreset()] })
|
||||||
|
],
|
||||||
|
})
|
||||||