Prima versione della web app BriefAI

This commit is contained in:
Diego-C-05
2026-05-05 10:24:46 +02:00
commit 2955588c13
73 changed files with 28223 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
VITE_API_URL=http://localhost:5000
VITE_N8N_URL=https://n8n-cipolla.ampere.lucasacchi.net/webhook
+24
View File
@@ -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?
+75
View File
@@ -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...
},
},
])
```
+23
View File
@@ -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,
},
},
])
+13
View File
@@ -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>
+3131
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -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"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -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

+14
View File
@@ -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;
}
+81
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -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

File diff suppressed because one or more lines are too long

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
+42
View File
@@ -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;
}
+13
View File
@@ -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>,
)
+441
View File
@@ -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));
}
}
+37
View File
@@ -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
+373
View File
@@ -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;
}
}
+163
View File
@@ -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
+112
View File
@@ -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;
}
+85
View File
@@ -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';
+139
View File
@@ -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
+309
View File
@@ -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;
}
}
+198
View File
@@ -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
+289
View File
@@ -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;
}
}
+247
View File
@@ -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,
}),
})
}
+14
View File
@@ -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;
}
+25
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -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"]
}
+11
View File
@@ -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()] })
],
})