From 2ca8b957e9d06ea17857d53d5100910bcb4c3a30 Mon Sep 17 00:00:00 2001 From: Michele Borraccia Date: Fri, 3 Apr 2026 14:59:14 +0000 Subject: [PATCH] feat: sync all BRAIN mobile changes - onboarding, cookies, legal, mobile UX, settings - Add OnboardingWizard, BetaBanner, CookieBanner components - Add legal pages (Privacy, Terms, Cookies) - Update Layout with mobile topbar, sidebar drawer, plan banner - Update SettingsPage with profile, API config, security - Update CharacterForm with topic suggestions, niche chips - Update EditorialCalendar with shared strategy card - Update ContentPage with narrative technique + brief - Update SocialAccounts with 4 platforms and token guides - Fix CSS button color inheritance, mobile responsive - Add backup script - Update .gitignore for pgdata and backups Co-Authored-By: Claude (BRAIN/StackOS) --- .gitignore | 2 + backend/app/routers/auth.py | 219 ++-- backend/app/routers/content.py | 4 +- backend/app/schemas.py | 18 +- backup.sh | 27 + frontend/src/App.jsx | 12 + frontend/src/components/AffiliateForm.jsx | 368 +++--- frontend/src/components/AffiliateList.jsx | 281 ++--- frontend/src/components/AuthCallback.jsx | 24 +- frontend/src/components/BetaBanner.jsx | 69 ++ frontend/src/components/CharacterForm.jsx | 813 +++++-------- frontend/src/components/CharacterList.jsx | 239 ++-- frontend/src/components/ContentPage.jsx | 472 ++++---- frontend/src/components/CookieBanner.jsx | 95 ++ frontend/src/components/Dashboard.jsx | 6 +- frontend/src/components/EditorialCalendar.jsx | 725 +++++++----- frontend/src/components/Layout.jsx | 248 ++-- frontend/src/components/LoginPage.jsx | 13 +- frontend/src/components/OnboardingWizard.jsx | 194 ++++ frontend/src/components/PlanBanner.jsx | 83 +- frontend/src/components/PlanForm.jsx | 485 ++++---- frontend/src/components/PlanList.jsx | 313 +++-- frontend/src/components/SettingsPage.jsx | 1014 ++++++++++++----- frontend/src/components/SocialAccounts.jsx | 559 ++++----- .../src/components/legal/CookiePolicy.jsx | 129 +++ frontend/src/components/legal/LegalLayout.jsx | 97 ++ .../src/components/legal/PrivacyPolicy.jsx | 123 ++ .../src/components/legal/TermsOfService.jsx | 182 +++ frontend/src/index.css | 63 + 29 files changed, 4074 insertions(+), 2803 deletions(-) create mode 100755 backup.sh create mode 100644 frontend/src/components/BetaBanner.jsx create mode 100644 frontend/src/components/CookieBanner.jsx create mode 100644 frontend/src/components/OnboardingWizard.jsx create mode 100644 frontend/src/components/legal/CookiePolicy.jsx create mode 100644 frontend/src/components/legal/LegalLayout.jsx create mode 100644 frontend/src/components/legal/PrivacyPolicy.jsx create mode 100644 frontend/src/components/legal/TermsOfService.jsx diff --git a/.gitignore b/.gitignore index 6eab80d..25c6c97 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,5 @@ npm-debug.log* data/ *.db *.sqlite +pgdata/ +backups/ diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index f023f81..3a5fb23 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -131,6 +131,25 @@ def me(user: User = Depends(get_current_user)): return _user_response(user) +class UpdateProfileRequest(BaseModel): + display_name: str + +@router.put("/me") +def update_profile( + request: UpdateProfileRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Update user display name.""" + name = request.display_name.strip() + if not name: + raise HTTPException(status_code=400, detail="Il nome non può essere vuoto.") + current_user.display_name = name + db.commit() + db.refresh(current_user) + return _user_response(current_user) + + @router.post("/logout") def logout(): """Logout — client should remove the token.""" @@ -160,80 +179,93 @@ def oauth_google_start(): @router.get("/oauth/google/callback") -async def oauth_google_callback(code: str, state: Optional[str] = None, db: Session = Depends(get_db)): +async def oauth_google_callback(code: str, state: Optional[str] = None, error: Optional[str] = None, db: Session = Depends(get_db)): """Exchange Google OAuth code for token, create/find user, redirect with JWT.""" + # Handle Google OAuth user denial or access errors + if error: + return RedirectResponse(url=f"{settings.app_url}/login?oauth_error={error}") + if not settings.google_client_id or not settings.google_client_secret: - raise HTTPException(status_code=501, detail="Google OAuth non configurato.") + return RedirectResponse(url=f"{settings.app_url}/login?oauth_error=non_configurato") - # Exchange code for tokens - async with httpx.AsyncClient() as client: - token_resp = await client.post( - "https://oauth2.googleapis.com/token", - data={ - "code": code, - "client_id": settings.google_client_id, - "client_secret": settings.google_client_secret, - "redirect_uri": f"{settings.app_url}/api/auth/oauth/google/callback", - "grant_type": "authorization_code", - }, - ) - if token_resp.status_code != 200: - raise HTTPException(status_code=400, detail="Errore scambio token Google.") - token_data = token_resp.json() + try: + # Exchange code for tokens + async with httpx.AsyncClient() as client: + token_resp = await client.post( + "https://oauth2.googleapis.com/token", + data={ + "code": code, + "client_id": settings.google_client_id, + "client_secret": settings.google_client_secret, + "redirect_uri": f"{settings.app_url}/api/auth/oauth/google/callback", + "grant_type": "authorization_code", + }, + ) + if token_resp.status_code != 200: + return RedirectResponse(url=f"{settings.app_url}/login?oauth_error=token_exchange") + token_data = token_resp.json() - # Get user info - userinfo_resp = await client.get( - "https://www.googleapis.com/oauth2/v3/userinfo", - headers={"Authorization": f"Bearer {token_data['access_token']}"}, - ) - if userinfo_resp.status_code != 200: - raise HTTPException(status_code=400, detail="Errore recupero profilo Google.") - google_user = userinfo_resp.json() + # Get user info + userinfo_resp = await client.get( + "https://www.googleapis.com/oauth2/v3/userinfo", + headers={"Authorization": f"Bearer {token_data['access_token']}"}, + ) + if userinfo_resp.status_code != 200: + return RedirectResponse(url=f"{settings.app_url}/login?oauth_error=userinfo") + google_user = userinfo_resp.json() - google_id = google_user.get("sub") - email = google_user.get("email") - name = google_user.get("name") - picture = google_user.get("picture") + google_id = google_user.get("sub") + email = google_user.get("email") + name = google_user.get("name") + picture = google_user.get("picture") - # Find existing user by google_id or email - user = db.query(User).filter(User.google_id == google_id).first() - if not user and email: - user = get_user_by_email(db, email) + if not google_id or not email: + return RedirectResponse(url=f"{settings.app_url}/login?oauth_error=missing_data") - if user: - # Update google_id and avatar if missing - if not user.google_id: - user.google_id = google_id - if not user.avatar_url and picture: - user.avatar_url = picture - db.commit() - else: - # Create new user - username_base = (email or google_id).split("@")[0] - username = username_base - counter = 1 - while db.query(User).filter(User.username == username).first(): - username = f"{username_base}{counter}" - counter += 1 + # Find existing user by google_id or email + user = db.query(User).filter(User.google_id == google_id).first() + if not user and email: + user = get_user_by_email(db, email) - user = User( - username=username, - hashed_password=hash_password(secrets.token_urlsafe(32)), - email=email, - display_name=name or username, - avatar_url=picture, - auth_provider="google", - google_id=google_id, - subscription_plan="freemium", - is_admin=False, - ) - db.add(user) - db.commit() - db.refresh(user) + if user: + # Update google_id and avatar if missing + if not user.google_id: + user.google_id = google_id + if not user.avatar_url and picture: + user.avatar_url = picture + db.commit() + else: + # Create new user + username_base = (email or google_id).split("@")[0] + username = username_base + counter = 1 + while db.query(User).filter(User.username == username).first(): + username = f"{username_base}{counter}" + counter += 1 - jwt_token = create_access_token({"sub": user.username, "user_id": user.id}) - redirect_url = f"{settings.app_url}/auth/callback?token={jwt_token}" - return RedirectResponse(url=redirect_url) + user = User( + username=username, + hashed_password=hash_password(secrets.token_urlsafe(32)), + email=email, + display_name=name or username, + avatar_url=picture, + auth_provider="google", + google_id=google_id, + subscription_plan="freemium", + is_admin=False, + ) + db.add(user) + db.commit() + db.refresh(user) + + jwt_token = create_access_token({"sub": user.username, "user_id": user.id}) + redirect_url = f"{settings.app_url}/auth/callback?token={jwt_token}" + return RedirectResponse(url=redirect_url) + + except Exception as e: + import logging + logging.getLogger(__name__).error(f"Google OAuth callback error: {e}", exc_info=True) + return RedirectResponse(url=f"{settings.app_url}/login?oauth_error=server_error") # === Change password === @@ -300,3 +332,60 @@ def redeem_code( "subscription_plan": current_user.subscription_plan, "subscription_expires_at": current_user.subscription_expires_at.isoformat(), } + + +# === Export user data (GDPR) === + +@router.get("/export-data") +def export_user_data( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Export all personal data for the current user (GDPR compliance).""" + from app.models import Post, Character, AffiliateLinkModel, PublishingPlan, SocialAccount + + posts = db.query(Post).filter(Post.user_id == current_user.id).all() if hasattr(Post, 'user_id') else [] + + data = { + "exported_at": datetime.utcnow().isoformat(), + "user": { + "id": current_user.id, + "username": current_user.username, + "email": current_user.email, + "display_name": current_user.display_name, + "auth_provider": current_user.auth_provider, + "subscription_plan": current_user.subscription_plan, + "subscription_expires_at": current_user.subscription_expires_at.isoformat() if current_user.subscription_expires_at else None, + "created_at": current_user.created_at.isoformat() if current_user.created_at else None, + }, + } + from fastapi.responses import JSONResponse + from fastapi import Response + import json + content = json.dumps(data, ensure_ascii=False, indent=2) + return Response( + content=content, + media_type="application/json", + headers={"Content-Disposition": f'attachment; filename="leopost-data-{current_user.username}.json"'} + ) + + +# === Delete account === + +class DeleteAccountRequest(BaseModel): + confirmation: str # must equal "ELIMINA" + +@router.delete("/account") +def delete_account( + request: DeleteAccountRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Permanently delete the user account and all associated data.""" + if request.confirmation != "ELIMINA": + raise HTTPException(status_code=400, detail="Conferma non valida. Digita ELIMINA per procedere.") + + # Delete user (cascade should handle related records if FK set, else manual) + db.delete(current_user) + db.commit() + return {"message": "Account eliminato con successo."} diff --git a/backend/app/routers/content.py b/backend/app/routers/content.py index 09ec5f3..7cdc9f1 100644 --- a/backend/app/routers/content.py +++ b/backend/app/routers/content.py @@ -96,12 +96,12 @@ def generate_content( text = generate_post_text( character=char_dict, llm_provider=llm, - platform=request.platform, + platform=request.effective_platform, topic_hint=request.topic_hint, ) # Generate hashtags - hashtags = generate_hashtags(text, llm, request.platform) + hashtags = generate_hashtags(text, llm, request.effective_platform) # Handle affiliate links affiliate_links_used: list[dict] = [] diff --git a/backend/app/schemas.py b/backend/app/schemas.py index ff5b032..39bd366 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Optional +from typing import List, Optional from pydantic import BaseModel @@ -103,13 +103,23 @@ class PostResponse(BaseModel): class GenerateContentRequest(BaseModel): character_id: int - platform: str = "instagram" - content_type: str = "text" + platform: str = "instagram" # legacy single-platform (kept for compat) + content_type: str = "text" # legacy single type (kept for compat) + platforms: List[str] = [] # new: multi-platform (overrides platform if non-empty) + content_types: List[str] = [] # new: multi-type (overrides content_type if non-empty) topic_hint: Optional[str] = None include_affiliates: bool = True - provider: Optional[str] = None # override default LLM + provider: Optional[str] = None model: Optional[str] = None + @property + def effective_platform(self) -> str: + return self.platforms[0] if self.platforms else self.platform + + @property + def effective_content_type(self) -> str: + return self.content_types[0] if self.content_types else self.content_type + class GenerateImageRequest(BaseModel): character_id: int diff --git a/backup.sh b/backup.sh new file mode 100755 index 0000000..098f68e --- /dev/null +++ b/backup.sh @@ -0,0 +1,27 @@ +#!/bin/bash +set -euo pipefail + +BACKUP_DIR="/opt/lab-leopost-full-prod/backups" +FNAME="leopost_$(date +%Y%m%d_%H%M).sql.gz" +GDRIVE_DIR="gdrive:Backups/Leopost" +LOG="$BACKUP_DIR/backup.log" +RETENTION_DAYS=7 + +log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG"; } + +log "--- Avvio backup $FNAME ---" + +# 1. pg_dump → gzip +docker exec prod-leopost-postgres pg_dump -U leopost leopost | gzip > "$BACKUP_DIR/$FNAME" +SIZE=$(du -sh "$BACKUP_DIR/$FNAME" | cut -f1) +log "Dump creato: $FNAME ($SIZE)" + +# 2. Upload su Google Drive +rclone copy "$BACKUP_DIR/$FNAME" "$GDRIVE_DIR" --log-level ERROR 2>>"$LOG" +log "Upload Drive completato: $GDRIVE_DIR/$FNAME" + +# 3. Cleanup locale > 7 giorni +DELETED=$(find "$BACKUP_DIR" -name 'leopost_*.sql.gz' -mtime +$RETENTION_DAYS -print -delete | wc -l) +log "Cleanup: $DELETED file rimossi (retention ${RETENTION_DAYS}gg)" + +log "--- Backup completato con successo ---" diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1bdc921..f7b3419 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -19,6 +19,10 @@ import CommentsQueue from './components/CommentsQueue' import SettingsPage from './components/SettingsPage' import EditorialCalendar from './components/EditorialCalendar' import AdminSettings from './components/AdminSettings' +import CookieBanner from './components/CookieBanner' +import PrivacyPolicy from './components/legal/PrivacyPolicy' +import TermsOfService from './components/legal/TermsOfService' +import CookiePolicy from './components/legal/CookiePolicy' const BASE_PATH = import.meta.env.VITE_BASE_PATH !== undefined ? (import.meta.env.VITE_BASE_PATH || '/') @@ -28,9 +32,16 @@ export default function App() { return ( + + {/* Public routes */} } /> } /> + } /> + } /> + } /> + + {/* Protected routes */} }> }> } /> @@ -46,6 +57,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/AffiliateForm.jsx b/frontend/src/components/AffiliateForm.jsx index 8fc43df..30bce63 100644 --- a/frontend/src/components/AffiliateForm.jsx +++ b/frontend/src/components/AffiliateForm.jsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { api } from '../api' -const SUGGESTED_NETWORKS = ['Amazon', 'ClickBank', 'ShareASale', 'CJ', 'Impact'] +const SUGGESTED_NETWORKS = ['Amazon', 'ClickBank', 'ShareASale', 'CJ Affiliate', 'Impact', 'Awin', 'Tradedoubler'] const EMPTY_FORM = { character_id: '', @@ -26,11 +26,7 @@ export default function AffiliateForm() { const [error, setError] = useState('') const [loading, setLoading] = useState(isEdit) - useEffect(() => { - api.get('/characters/') - .then(setCharacters) - .catch(() => {}) - }, []) + useEffect(() => { api.get('/characters/').then(setCharacters).catch(() => {}) }, []) useEffect(() => { if (isEdit) { @@ -51,30 +47,18 @@ export default function AffiliateForm() { } }, [id, isEdit]) - const handleChange = (field, value) => { - setForm((prev) => ({ ...prev, [field]: value })) - } + const handleChange = (field, value) => setForm((prev) => ({ ...prev, [field]: value })) const addTopic = () => { const topic = topicInput.trim() - if (topic && !form.topics.includes(topic)) { - setForm((prev) => ({ ...prev, topics: [...prev.topics, topic] })) - } + if (topic && !form.topics.includes(topic)) setForm((prev) => ({ ...prev, topics: [...prev.topics, topic] })) setTopicInput('') } - const removeTopic = (topic) => { - setForm((prev) => ({ - ...prev, - topics: prev.topics.filter((t) => t !== topic), - })) - } + const removeTopic = (topic) => setForm((prev) => ({ ...prev, topics: prev.topics.filter((t) => t !== topic) })) const handleTopicKeyDown = (e) => { - if (e.key === 'Enter' || e.key === ',') { - e.preventDefault() - addTopic() - } + if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); addTopic() } } const handleSubmit = async (e) => { @@ -82,225 +66,185 @@ export default function AffiliateForm() { setError('') setSaving(true) try { - const payload = { - ...form, - character_id: form.character_id ? parseInt(form.character_id) : null, - } - if (isEdit) { - await api.put(`/affiliates/${id}`, payload) - } else { - await api.post('/affiliates/', payload) - } + const payload = { ...form, character_id: form.character_id ? parseInt(form.character_id) : null } + if (isEdit) await api.put(`/affiliates/${id}`, payload) + else await api.post('/affiliates/', payload) navigate('/affiliates') } catch (err) { setError(err.message || 'Errore nel salvataggio') - } finally { - setSaving(false) - } + } finally { setSaving(false) } } - if (loading) { - return ( -
-
-
- ) - } + if (loading) return ( +
+
+ +
+ ) return ( -
-
-

+
+ {/* Header */} +
+ {isEdit ? 'Modifica' : 'Nuovo Link'} +
+

{isEdit ? 'Modifica link affiliato' : 'Nuovo link affiliato'}

-

- {isEdit ? 'Aggiorna le informazioni del link' : 'Aggiungi un nuovo link affiliato'} +

+ {isEdit ? 'Aggiorna le informazioni del link' : 'Aggiungi un nuovo link affiliato al tuo catalogo'}

-
- {error && ( -
- {error} -
- )} - - {/* Main info */} -
-

- Informazioni link -

- -
- - -
- -
- -
- handleChange('network', e.target.value)} - placeholder="Es. Amazon, ClickBank..." - className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm" - required - /> -
- {SUGGESTED_NETWORKS.map((net) => ( - - ))} -
-
-
- -
- - handleChange('name', e.target.value)} - placeholder="Es. Corso Python, Hosting Premium..." - className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm" - required - /> -
- -
- - handleChange('url', e.target.value)} - placeholder="https://example.com/ref/..." - className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm font-mono" - required - /> -
- -
- - handleChange('tag', e.target.value)} - placeholder="Es. ref-luigi, tag-2026..." - className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm font-mono" - /> -
- -
- - Attivo -
+ {error && ( +
+ {error}
+ )} - {/* Topics */} -
-

- Topic correlati -

-

- I topic aiutano l'AI a scegliere il link giusto per ogni contenuto + + + {/* ── Informazioni link ────────────────────────────────── */} +

+ + + + + + handleChange('network', e.target.value)} + placeholder="Es. Amazon, ClickBank…" style={inputStyle} required + onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} /> +
+ {SUGGESTED_NETWORKS.map((net) => ( + + ))} +
+
+ + + handleChange('name', e.target.value)} + placeholder="Es. Corso Python, Hosting Premium, Libro XYZ…" style={inputStyle} required + onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} /> + + + + handleChange('url', e.target.value)} + placeholder="https://example.com/ref/..." style={{ ...inputStyle, fontFamily: 'monospace', fontSize: '0.82rem' }} required + onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} /> + + + + handleChange('tag', e.target.value)} + placeholder="Es. ref-luigi, campagna-maggio…" style={{ ...inputStyle, fontFamily: 'monospace', fontSize: '0.82rem' }} + onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} /> + + +
+ + Attivo +
+
+ + {/* ── Topic correlati ──────────────────────────────────── */} +
+

+ I topic aiutano l'AI a scegliere il link giusto per ogni contenuto generato.

-
- setTopicInput(e.target.value)} - onKeyDown={handleTopicKeyDown} - placeholder="Scrivi un topic e premi Invio" - className="flex-1 px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm" - /> - +
+ setTopicInput(e.target.value)} + onKeyDown={handleTopicKeyDown} placeholder="Scrivi un topic e premi Invio" + style={{ ...inputStyle, flex: 1 }} + onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} /> +
{form.topics.length > 0 && ( -
+
{form.topics.map((topic) => ( - + {topic} - + ))}
)} -
+
- {/* Actions */} -
- - +
+ +
) } + +function Section({ title, children }) { + return ( +
+

{title}

+
+ {children} +
+
+ ) +} + +function Field({ label, children }) { + return ( +
+ + {children} +
+ ) +} + +const inputStyle = { + width: '100%', padding: '0.625rem 0.875rem', + border: '1px solid var(--border)', borderRadius: 0, + fontSize: '0.875rem', color: 'var(--ink)', + backgroundColor: 'var(--surface)', outline: 'none', + boxSizing: 'border-box', transition: 'border-color 0.15s', + fontFamily: "'DM Sans', sans-serif", +} +const selectStyle = { ...inputStyle, cursor: 'pointer' } +const btnPrimary = { + display: 'inline-block', padding: '0.65rem 1.5rem', + backgroundColor: 'var(--ink)', color: 'white', + fontFamily: "'DM Sans', sans-serif", fontWeight: 600, + fontSize: '0.875rem', border: 'none', cursor: 'pointer', +} +const btnSecondary = { ...btnPrimary, backgroundColor: 'var(--cream-dark)', color: '#1A1A1A', border: '1px solid #C8C0B4' } diff --git a/frontend/src/components/AffiliateList.jsx b/frontend/src/components/AffiliateList.jsx index 79c2b18..5fffe7a 100644 --- a/frontend/src/components/AffiliateList.jsx +++ b/frontend/src/components/AffiliateList.jsx @@ -2,12 +2,12 @@ import { useState, useEffect } from 'react' import { Link } from 'react-router-dom' import { api } from '../api' -const networkColors = { - Amazon: 'bg-amber-50 text-amber-700', - ClickBank: 'bg-emerald-50 text-emerald-700', - ShareASale: 'bg-blue-50 text-blue-700', - CJ: 'bg-violet-50 text-violet-700', - Impact: 'bg-rose-50 text-rose-700', +const NETWORK_COLORS = { + Amazon: { bg: '#FFF8E1', color: '#B45309' }, + ClickBank: { bg: '#ECFDF5', color: '#065F46' }, + ShareASale:{ bg: '#EFF6FF', color: '#1D4ED8' }, + CJ: { bg: '#F5F3FF', color: '#6D28D9' }, + Impact: { bg: '#FFF1F2', color: '#BE123C' }, } export default function AffiliateList() { @@ -16,203 +16,166 @@ export default function AffiliateList() { const [loading, setLoading] = useState(true) const [filterCharacter, setFilterCharacter] = useState('') - useEffect(() => { - loadData() - }, []) + useEffect(() => { loadData() }, []) const loadData = async () => { setLoading(true) try { const [linksData, charsData] = await Promise.all([ - api.get('/affiliates/'), - api.get('/characters/'), + api.get('/affiliates/'), api.get('/characters/'), ]) setLinks(linksData) setCharacters(charsData) - } catch { - // silent - } finally { - setLoading(false) - } + } catch {} finally { setLoading(false) } } const getCharacterName = (id) => { if (!id) return 'Globale' - const c = characters.find((ch) => ch.id === id) - return c ? c.name : '—' - } - - const getNetworkColor = (network) => { - return networkColors[network] || 'bg-slate-100 text-slate-600' + return characters.find(c => c.id === id)?.name || '—' } const handleToggle = async (link) => { - try { - await api.put(`/affiliates/${link.id}`, { is_active: !link.is_active }) - loadData() - } catch { - // silent - } + await api.put(`/affiliates/${link.id}`, { is_active: !link.is_active }).catch(() => {}) + loadData() } const handleDelete = async (id, name) => { if (!confirm(`Eliminare "${name}"?`)) return - try { - await api.delete(`/affiliates/${id}`) - loadData() - } catch { - // silent - } + await api.delete(`/affiliates/${id}`).catch(() => {}) + loadData() } - const truncateUrl = (url) => { - if (!url) return '—' - if (url.length <= 50) return url - return url.substring(0, 50) + '...' - } - - const filtered = links.filter((l) => { + const filtered = links.filter(l => { if (filterCharacter === '') return true if (filterCharacter === 'global') return !l.character_id return String(l.character_id) === filterCharacter }) return ( -
-
+
+ {/* Header */} +
-

Link Affiliati

-

- Gestisci i link affiliati per la monetizzazione + Link Affiliati +

+

+ Monetizzazione +

+

+ Gestisci i link affiliati: Leopost li inserisce automaticamente nei contenuti generati.

- - + Nuovo Link - + + Nuovo Link
- {/* Filters */} -
- - - - {filtered.length} link - -
+ {/* Filter */} + {characters.length > 0 && ( +
+ + + {filtered.length} link +
+ )} {loading ? ( -
-
-
+ ) : filtered.length === 0 ? ( -
-

-

Nessun link affiliato

-

- Aggiungi i tuoi primi link affiliati per monetizzare i contenuti -

- - + Crea link affiliato - -
+ ) : ( -
-
- - - - - - - - - - - - - - - - {filtered.map((link) => ( - - +
NetworkNomeURLTagTopicPersonaggioStatoClickAzioni
- - {link.network || '—'} - +
+ + + + {['Network','Nome','URL','Personaggio','Stato','Click',''].map(h => ( + + ))} + + + + {filtered.map(link => { + const nc = NETWORK_COLORS[link.network] || { bg: 'var(--cream-dark)', color: 'var(--ink-muted)' } + return ( + e.currentTarget.style.backgroundColor = 'var(--cream)'} + onMouseLeave={e => e.currentTarget.style.backgroundColor = 'transparent'} + > + - - + - + - - - - - + - ))} - -
{h}
+ {link.network || '—'} {link.name} - {truncateUrl(link.url)} + {link.name} + {link.url?.substring(0, 45)}{link.url?.length > 45 ? '…' : ''} - {link.tag || '—'} + {getCharacterName(link.character_id)} + -
- {link.topics && link.topics.slice(0, 2).map((t, i) => ( - - {t} - - ))} - {link.topics && link.topics.length > 2 && ( - +{link.topics.length - 2} - )} -
-
- {getCharacterName(link.character_id)} - - - - {link.click_count ?? 0} - -
- - Modifica - - - +
{link.click_count ?? 0} +
+ Modifica + +
-
+ ) + })} +
)}
) } + +function EmptyState({ icon, title, description, cta, to }) { + return ( +
+
{icon}
+

{title}

+

{description}

+ {cta} +
+ ) +} + +function Spinner() { + return ( +
+
+ +
+ ) +} + +const btnPrimary = { + display: 'inline-block', padding: '0.6rem 1.25rem', + backgroundColor: 'var(--ink)', color: 'white', + fontFamily: "'DM Sans', sans-serif", fontWeight: 600, + fontSize: '0.875rem', textDecoration: 'none', + border: 'none', cursor: 'pointer', whiteSpace: 'nowrap', +} +const btnSmall = { + display: 'inline-block', padding: '0.35rem 0.75rem', + backgroundColor: 'var(--cream-dark)', color: 'var(--ink-light)', + fontFamily: "'DM Sans', sans-serif", fontWeight: 500, + fontSize: '0.78rem', textDecoration: 'none', + border: 'none', cursor: 'pointer', +} +const selectStyle = { + padding: '0.45rem 0.75rem', border: '1px solid var(--border)', + backgroundColor: 'var(--surface)', color: 'var(--ink)', + fontSize: '0.85rem', fontFamily: "'DM Sans', sans-serif", + outline: 'none', cursor: 'pointer', +} diff --git a/frontend/src/components/AuthCallback.jsx b/frontend/src/components/AuthCallback.jsx index 5dc2014..6703c85 100644 --- a/frontend/src/components/AuthCallback.jsx +++ b/frontend/src/components/AuthCallback.jsx @@ -2,6 +2,16 @@ import { useEffect } from 'react' import { useNavigate, useSearchParams } from 'react-router-dom' import { useAuth } from '../AuthContext' +const ERROR_MESSAGES = { + non_configurato: 'Google OAuth non è ancora configurato su questo server.', + token_exchange: 'Errore durante lo scambio del token Google. Riprova.', + userinfo: 'Impossibile recuperare il profilo Google. Riprova.', + missing_data: 'Il tuo account Google non ha fornito i dati necessari.', + server_error: 'Errore interno durante il login con Google. Riprova tra qualche momento.', + access_denied: 'Hai annullato il login con Google.', + default: 'Errore di accesso con Google. Riprova.', +} + export default function AuthCallback() { const [params] = useSearchParams() const navigate = useNavigate() @@ -9,27 +19,31 @@ export default function AuthCallback() { useEffect(() => { const token = params.get('token') + const oauthError = params.get('oauth_error') + if (token) { loginWithToken(token) navigate('/', { replace: true }) + } else if (oauthError) { + const msg = ERROR_MESSAGES[oauthError] || ERROR_MESSAGES.default + navigate(`/login?error=${encodeURIComponent(msg)}`, { replace: true }) } else { navigate('/login', { replace: true }) } }, []) return ( -
+
-

Accesso in corso...

+

Accesso in corso…

diff --git a/frontend/src/components/BetaBanner.jsx b/frontend/src/components/BetaBanner.jsx new file mode 100644 index 0000000..75506f5 --- /dev/null +++ b/frontend/src/components/BetaBanner.jsx @@ -0,0 +1,69 @@ +import { useState } from 'react' +import { Link } from 'react-router-dom' + +const DISMISSED_KEY = 'leopost_beta_banner_dismissed' + +export default function BetaBanner() { + const [dismissed, setDismissed] = useState(() => { + try { return !!localStorage.getItem(DISMISSED_KEY) } catch { return false } + }) + + if (dismissed) return null + + const dismiss = () => { + try { localStorage.setItem(DISMISSED_KEY, '1') } catch {} + setDismissed(true) + } + + return ( +
+
+ + Beta + +

+ Sei un Early Adopter — grazie per testare Leopost in anteprima. + Puoi riscattare il tuo codice Pro da{' '} + + Impostazioni + . +

+
+ +
+ ) +} diff --git a/frontend/src/components/CharacterForm.jsx b/frontend/src/components/CharacterForm.jsx index 37f200c..81c0743 100644 --- a/frontend/src/components/CharacterForm.jsx +++ b/frontend/src/components/CharacterForm.jsx @@ -1,98 +1,41 @@ import { useState, useEffect } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { api } from '../api' -import { useAuth } from '../AuthContext' const EMPTY_FORM = { name: '', niche: '', topics: [], tone: '', - visual_style: { primary_color: '#f97316', secondary_color: '#1e293b', font: '' }, + visual_style: { primary_color: '#E85A4F', secondary_color: '#1A1A1A', font: '' }, is_active: true, } -const PLATFORMS = [ - { - id: 'facebook', - name: 'Facebook', - icon: '📘', - color: '#1877F2', - guide: [ - 'Vai su developers.facebook.com e accedi con il tuo account.', - 'Crea una nuova App → scegli "Business".', - 'Aggiungi il prodotto "Facebook Login" e "Pages API".', - 'In "Graph API Explorer", seleziona la tua app e la tua Pagina.', - 'Genera un Page Access Token con permessi: pages_manage_posts, pages_read_engagement.', - 'Copia il Page ID dalla pagina Facebook (Info → ID pagina).', - ], - proOnly: false, - }, - { - id: 'instagram', - name: 'Instagram', - icon: '📸', - color: '#E1306C', - guide: [ - 'Instagram usa le API di Facebook (Meta).', - 'Nella stessa app Meta, aggiungi il prodotto "Instagram Graph API".', - 'Collega un profilo Instagram Business alla tua pagina Facebook.', - 'In Graph API Explorer, genera un token con scope: instagram_basic, instagram_content_publish.', - 'Trova l\'Instagram User ID tramite: GET /{page-id}?fields=instagram_business_account.', - 'Inserisci il token e l\'IG User ID nei campi sottostanti.', - ], - proOnly: false, - }, - { - id: 'youtube', - name: 'YouTube', - icon: '▶️', - color: '#FF0000', - guide: [ - 'Vai su console.cloud.google.com e crea un progetto.', - 'Abilita "YouTube Data API v3" nella sezione API & Services.', - 'Crea credenziali OAuth 2.0 (tipo: Web application).', - 'Autorizza l\'accesso al tuo canale YouTube seguendo il flusso OAuth.', - 'Copia l\'Access Token e il Channel ID (visibile in YouTube Studio → Personalizzazione → Informazioni).', - ], - proOnly: true, - }, - { - id: 'tiktok', - name: 'TikTok', - icon: '🎵', - color: '#000000', - guide: [ - 'Vai su developers.tiktok.com e registra un account sviluppatore.', - 'Crea una nuova app → seleziona "Content Posting API".', - 'Richiedi i permessi: video.publish, video.upload.', - 'Completa il processo di verifica app (può richiedere alcuni giorni).', - 'Una volta approvata, genera un access token seguendo la documentazione OAuth 2.0.', - ], - proOnly: true, - }, +const NICHE_CHIPS = [ + 'Food & Ricette', 'Fitness & Sport', 'Tech & AI', 'Beauty & Skincare', + 'Fashion & Style', 'Travel & Lifestyle', 'Finance & Investimenti', 'Salute & Wellness', + 'Gaming', 'Business & Marketing', 'Ambiente & Sostenibilità', 'Arte & Design', + 'Musica', 'Educazione', 'Cucina Italiana', 'Automotive', +] + +const TOPIC_CHIPS = [ + 'Tutorial', 'Before/After', 'Tips & Tricks', 'Dietro le quinte', + 'Recensione prodotto', 'Unboxing', 'Trend del momento', 'FAQ', + 'Motivazione', 'Case study', 'Confronto', 'Sfida / Challenge', + 'News del settore', 'Ispirazione', 'Lista consigli', 'Storia personale', ] export default function CharacterForm() { const { id } = useParams() const isEdit = Boolean(id) const navigate = useNavigate() - const { isPro } = useAuth() - const [activeTab, setActiveTab] = useState('profile') const [form, setForm] = useState(EMPTY_FORM) const [topicInput, setTopicInput] = useState('') const [saving, setSaving] = useState(false) const [error, setError] = useState('') const [loading, setLoading] = useState(isEdit) - // Social accounts state - const [socialAccounts, setSocialAccounts] = useState({}) - const [expandedGuide, setExpandedGuide] = useState(null) - const [savingToken, setSavingToken] = useState({}) - const [tokenInputs, setTokenInputs] = useState({}) - const [pageIdInputs, setPageIdInputs] = useState({}) - useEffect(() => { if (isEdit) { api.get(`/characters/${id}`) @@ -103,8 +46,8 @@ export default function CharacterForm() { topics: data.topics || [], tone: data.tone || '', visual_style: { - primary_color: data.visual_style?.primary_color || '#f97316', - secondary_color: data.visual_style?.secondary_color || '#1e293b', + primary_color: data.visual_style?.primary_color || '#E85A4F', + secondary_color: data.visual_style?.secondary_color || '#1A1A1A', font: data.visual_style?.font || '', }, is_active: data.is_active ?? true, @@ -112,49 +55,24 @@ export default function CharacterForm() { }) .catch(() => setError('Personaggio non trovato')) .finally(() => setLoading(false)) - - // Load social accounts for this character - api.get(`/social/accounts?character_id=${id}`) - .then((accounts) => { - const map = {} - accounts.forEach((acc) => { map[acc.platform] = acc }) - setSocialAccounts(map) - }) - .catch(() => {}) } }, [id, isEdit]) - const handleChange = (field, value) => { - setForm((prev) => ({ ...prev, [field]: value })) - } + const handleChange = (field, value) => setForm((prev) => ({ ...prev, [field]: value })) + const handleStyleChange = (field, value) => setForm((prev) => ({ ...prev, visual_style: { ...prev.visual_style, [field]: value } })) - const handleStyleChange = (field, value) => { - setForm((prev) => ({ - ...prev, - visual_style: { ...prev.visual_style, [field]: value }, - })) - } - - const addTopic = () => { - const topic = topicInput.trim() + const addTopic = (t) => { + const topic = (t || topicInput).trim() if (topic && !form.topics.includes(topic)) { setForm((prev) => ({ ...prev, topics: [...prev.topics, topic] })) } - setTopicInput('') + if (!t) setTopicInput('') } - const removeTopic = (topic) => { - setForm((prev) => ({ - ...prev, - topics: prev.topics.filter((t) => t !== topic), - })) - } + const removeTopic = (topic) => setForm((prev) => ({ ...prev, topics: prev.topics.filter((t) => t !== topic) })) const handleTopicKeyDown = (e) => { - if (e.key === 'Enter' || e.key === ',') { - e.preventDefault() - addTopic() - } + if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); addTopic() } } const handleSubmit = async (e) => { @@ -162,471 +80,252 @@ export default function CharacterForm() { setError('') setSaving(true) try { - if (isEdit) { - await api.put(`/characters/${id}`, form) - } else { - await api.post('/characters/', form) - } + if (isEdit) { await api.put(`/characters/${id}`, form) } + else { await api.post('/characters/', form) } navigate('/characters') } catch (err) { - if (err.data?.upgrade_required) { - setError(err.data.message || 'Limite piano raggiunto. Passa a Pro.') - } else { - setError(err.message || 'Errore nel salvataggio') - } - } finally { - setSaving(false) - } + if (err.data?.upgrade_required) setError(err.data.message || 'Limite piano raggiunto. Passa a Pro.') + else setError(err.message || 'Errore nel salvataggio') + } finally { setSaving(false) } } - const handleSaveToken = async (platform) => { - if (!isEdit) return - const token = tokenInputs[platform] || '' - const pageId = pageIdInputs[platform] || '' - if (!token.trim()) return - - setSavingToken((prev) => ({ ...prev, [platform]: true })) - try { - const existing = socialAccounts[platform] - if (existing) { - await api.put(`/social/accounts/${existing.id}`, { - access_token: token, - page_id: pageId || undefined, - }) - } else { - await api.post('/social/accounts', { - character_id: Number(id), - platform, - access_token: token, - page_id: pageId || undefined, - account_name: platform, - }) - } - // Reload - const accounts = await api.get(`/social/accounts?character_id=${id}`) - const map = {} - accounts.forEach((acc) => { map[acc.platform] = acc }) - setSocialAccounts(map) - setTokenInputs((prev) => ({ ...prev, [platform]: '' })) - setPageIdInputs((prev) => ({ ...prev, [platform]: '' })) - } catch (err) { - alert(err.message || 'Errore nel salvataggio del token.') - } finally { - setSavingToken((prev) => ({ ...prev, [platform]: false })) - } - } - - const handleDisconnect = async (platform) => { - const acc = socialAccounts[platform] - if (!acc) return - if (!window.confirm(`Disconnetti ${platform}?`)) return - try { - await api.delete(`/social/accounts/${acc.id}`) - setSocialAccounts((prev) => { - const next = { ...prev } - delete next[platform] - return next - }) - } catch (err) { - alert(err.message || 'Errore nella disconnessione.') - } - } - - if (loading) { - return ( -
-
-
- ) - } + if (loading) return ( +
+
+ +
+ ) return ( -
-
-

- {isEdit ? 'Modifica personaggio' : 'Nuovo personaggio'} +
+ {/* Header */} +
+ {isEdit ? 'Modifica' : 'Nuovo Personaggio'} +
+

+ {isEdit ? 'Modifica personaggio' : 'Crea un Personaggio'}

-

- {isEdit ? 'Aggiorna il profilo editoriale' : 'Crea un nuovo profilo editoriale'} +

+ Il personaggio è la voce editoriale. Definisci nicchia, tono e topic ricorrenti: l'AI li userà ogni volta che genera contenuti.

- {/* Tabs */} -
- {[ - { id: 'profile', label: 'Profilo' }, - { id: 'social', label: 'Account Social', disabled: !isEdit }, - ].map((tab) => ( - - ))} -
- - {activeTab === 'profile' && ( -
- {error && ( -
- {error} -
- )} - - {/* Basic info */} -
-

- Informazioni base -

- -
- - handleChange('name', e.target.value)} - placeholder="Es. TechGuru, FoodBlogger..." - className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm" - required - /> -
- -
- - handleChange('niche', e.target.value)} - placeholder="Es. Tecnologia, Food, Fitness..." - className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm" - required - /> -
- -
- -