feat: multi-user SaaS, piani Freemium/Pro, Google OAuth, admin panel

BLOCCO 1 - Multi-user data model:
- User: email, display_name, avatar_url, auth_provider, google_id
- User: subscription_plan, subscription_expires_at, is_admin, post counters
- SubscriptionCode table per redeem codes
- user_id FK su Character, Post, AffiliateLink, EditorialPlan, SocialAccount, SystemSetting
- Migrazione SQLite-safe (ALTER TABLE) + preserva dati esistenti

BLOCCO 2 - Auth completo:
- Registrazione email/password + login multi-user
- Google OAuth 2.0 (httpx, no deps esterne)
- Callback flow: Google -> /auth/callback?token=JWT -> frontend
- Backward compat login admin con username

BLOCCO 3 - Piani e abbonamenti:
- Freemium: 1 character, 15 post/mese, FB+IG only, no auto-plans
- Pro: illimitato, tutte le piattaforme, tutte le feature
- Enforcement automatico in tutti i router
- Redeem codes con durate 1/3/6/12 mesi
- Admin panel: genera codici, lista utenti

BLOCCO 4 - Frontend completo:
- Login page design Leopost (split coral/cream, Google, social coming soon)
- AuthCallback per OAuth redirect
- PlanBanner, UpgradeModal con pricing
- AdminSettings per generazione codici
- CharacterForm con tab Account Social + guide setup

Deploy:
- Dockerfile con ARG VITE_BASE_PATH/VITE_API_BASE
- docker-compose.prod.yml per leopost.it (no subpath)
- docker-compose.yml aggiornato per lab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Michele
2026-03-31 20:01:07 +02:00
parent 2c16407f96
commit 77ca70cd48
31 changed files with 2818 additions and 449 deletions

View File

@@ -8,8 +8,9 @@
"clone_url": "https://git.mlhub.it/Michele/leopost-full.git" "clone_url": "https://git.mlhub.it/Michele/leopost-full.git"
}, },
"vps": { "vps": {
"deployed": false, "deployed": true,
"url": "https://lab.mlhub.it/leopost-full/", "url": "https://lab.mlhub.it/leopost-full/",
"last_deploy": "2026-03-31T15:26:00Z",
"container": "lab-leopost-full-app", "container": "lab-leopost-full-app",
"path": "/opt/lab-leopost-full/" "path": "/opt/lab-leopost-full/"
}, },

View File

@@ -4,6 +4,10 @@ WORKDIR /app/frontend
COPY frontend/package*.json ./ COPY frontend/package*.json ./
RUN npm install RUN npm install
COPY frontend/ ./ COPY frontend/ ./
ARG VITE_BASE_PATH=/leopost-full
ARG VITE_API_BASE=/leopost-full/api
ENV VITE_BASE_PATH=$VITE_BASE_PATH
ENV VITE_API_BASE=$VITE_API_BASE
RUN npm run build RUN npm run build
# Stage 2: Python backend + frontend built # Stage 2: Python backend + frontend built

View File

@@ -1,7 +1,9 @@
"""Core authentication utilities used throughout the application."""
from datetime import datetime, timedelta from datetime import datetime, timedelta
import bcrypt import bcrypt
from fastapi import APIRouter, Depends, HTTPException from fastapi import Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt from jose import JWTError, jwt
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -9,9 +11,7 @@ from sqlalchemy.orm import Session
from .config import settings from .config import settings
from .database import get_db from .database import get_db
from .models import User from .models import User
from .schemas import LoginRequest, Token
router = APIRouter(prefix="/api/auth", tags=["auth"])
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
@@ -30,31 +30,29 @@ def create_access_token(data: dict) -> str:
return jwt.encode(to_encode, settings.secret_key, algorithm="HS256") return jwt.encode(to_encode, settings.secret_key, algorithm="HS256")
def get_user_by_email(db: Session, email: str) -> User | None:
return db.query(User).filter(User.email == email).first()
def get_current_user( def get_current_user(
token: str = Depends(oauth2_scheme), db: Session = Depends(get_db) token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)
) -> User: ) -> User:
try: try:
payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"]) payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"])
username: str = payload.get("sub") # Support both sub (user_id) and legacy username
if username is None: user_id = payload.get("user_id")
username = payload.get("sub")
if user_id is None and username is None:
raise HTTPException(status_code=401, detail="Invalid token") raise HTTPException(status_code=401, detail="Invalid token")
except JWTError: except JWTError:
raise HTTPException(status_code=401, detail="Invalid token") raise HTTPException(status_code=401, detail="Invalid token")
user = db.query(User).filter(User.username == username).first()
if user_id is not None:
user = db.query(User).filter(User.id == user_id).first()
else:
# Legacy: username-based token
user = db.query(User).filter(User.username == username).first()
if user is None: if user is None:
raise HTTPException(status_code=401, detail="User not found") raise HTTPException(status_code=401, detail="User not found")
return user return user
@router.post("/login", response_model=Token)
def login(request: LoginRequest, db: Session = Depends(get_db)):
user = db.query(User).filter(User.username == request.username).first()
if not user or not verify_password(request.password, user.hashed_password):
raise HTTPException(status_code=401, detail="Invalid credentials")
token = create_access_token({"sub": user.username})
return Token(access_token=token)
@router.get("/me")
def me(user: User = Depends(get_current_user)):
return {"username": user.username}

View File

@@ -3,13 +3,23 @@ from pydantic_settings import BaseSettings
class Settings(BaseSettings): class Settings(BaseSettings):
database_url: str = "sqlite:///./data/leopost.db" database_url: str = "sqlite:///./data/leopost.db"
secret_key: str = "change-me-to-a-random-secret-key" secret_key: str = "leopost-secret-change-in-production-2026"
admin_username: str = "admin" admin_username: str = "admin"
admin_password: str = "changeme" admin_password: str = "changeme"
access_token_expire_minutes: int = 1440 # 24h access_token_expire_minutes: int = 1440 # 24h
# Google OAuth
# Reads from env vars: GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
google_client_id: str = ""
google_client_secret: str = ""
# App base URL (used for OAuth redirects)
# Reads from env var: APP_URL
app_url: str = "https://leopost.it"
class Config: class Config:
env_file = ".env" env_file = ".env"
extra = "ignore"
settings = Settings() settings = Settings()

View File

@@ -1,4 +1,4 @@
from sqlalchemy import create_engine from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker, declarative_base from sqlalchemy.orm import sessionmaker, declarative_base
from .config import settings from .config import settings
@@ -18,3 +18,69 @@ def get_db():
yield db yield db
finally: finally:
db.close() db.close()
def run_migrations(engine):
"""SQLite-safe migration: add new columns if they don't exist."""
migrations = {
"users": [
("email", "VARCHAR"),
("display_name", "VARCHAR"),
("avatar_url", "VARCHAR"),
("auth_provider", "VARCHAR DEFAULT 'local'"),
("google_id", "VARCHAR"),
("subscription_plan", "VARCHAR DEFAULT 'freemium'"),
("subscription_expires_at", "DATETIME"),
("is_admin", "BOOLEAN DEFAULT 0"),
("posts_generated_this_month", "INTEGER DEFAULT 0"),
("posts_reset_date", "DATE"),
],
"characters": [("user_id", "INTEGER")],
"posts": [("user_id", "INTEGER")],
"affiliate_links": [("user_id", "INTEGER")],
"editorial_plans": [("user_id", "INTEGER")],
"social_accounts": [("user_id", "INTEGER")],
"system_settings": [("user_id", "INTEGER")],
}
with engine.connect() as conn:
for table, cols in migrations.items():
try:
existing = {row[1] for row in conn.execute(text(f"PRAGMA table_info({table})"))}
for col_name, col_def in cols:
if col_name not in existing:
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {col_name} {col_def}"))
conn.commit()
except Exception as e:
print(f"Migration warning for {table}: {e}")
# Fix system_settings: remove UNIQUE constraint on 'key' by recreating the table
# This allows per-user settings (same key, different user_id)
try:
indexes = list(conn.execute(text("PRAGMA index_list(system_settings)")))
has_unique_key = any(
row[1].lower().startswith("ix_") or "key" in row[1].lower()
for row in indexes
if row[2] == 1 # unique=1
)
# Check via table creation SQL
create_sql_row = conn.execute(text(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='system_settings'"
)).fetchone()
if create_sql_row and "UNIQUE" in (create_sql_row[0] or "").upper():
# Recreate without UNIQUE on key
conn.execute(text("ALTER TABLE system_settings RENAME TO system_settings_old"))
conn.execute(text("""
CREATE TABLE system_settings (
id INTEGER PRIMARY KEY,
key VARCHAR(100) NOT NULL,
value JSON,
updated_at DATETIME,
user_id INTEGER REFERENCES users(id)
)
"""))
conn.execute(text("INSERT INTO system_settings SELECT id, key, value, updated_at, user_id FROM system_settings_old"))
conn.execute(text("DROP TABLE system_settings_old"))
conn.commit()
print("Migration: system_settings UNIQUE constraint on key removed.")
except Exception as e:
print(f"Migration warning for system_settings UNIQUE fix: {e}")

View File

@@ -16,10 +16,11 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from .auth import hash_password from .auth import hash_password
from .auth import router as auth_router
from .config import settings from .config import settings
from .database import Base, SessionLocal, engine from .database import Base, SessionLocal, engine, run_migrations
from .models import User from .models import User
from .routers.admin import router as admin_router
from .routers.auth import router as auth_router
from .routers.affiliates import router as affiliates_router from .routers.affiliates import router as affiliates_router
from .routers.characters import router as characters_router from .routers.characters import router as characters_router
from .routers.comments import router as comments_router from .routers.comments import router as comments_router
@@ -78,10 +79,13 @@ async def lifespan(app: FastAPI):
data_dir = Path("./data") data_dir = Path("./data")
data_dir.mkdir(parents=True, exist_ok=True) data_dir.mkdir(parents=True, exist_ok=True)
# Create tables # Run migrations FIRST (add new columns to existing tables)
run_migrations(engine)
# Create tables (for new tables like subscription_codes)
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
# Create admin user if not exists # Create or update admin user
db = SessionLocal() db = SessionLocal()
try: try:
existing = db.query(User).filter(User.username == settings.admin_username).first() existing = db.query(User).filter(User.username == settings.admin_username).first()
@@ -89,9 +93,28 @@ async def lifespan(app: FastAPI):
admin = User( admin = User(
username=settings.admin_username, username=settings.admin_username,
hashed_password=hash_password(settings.admin_password), hashed_password=hash_password(settings.admin_password),
email="admin@leopost.it",
display_name="Admin",
auth_provider="local",
subscription_plan="pro",
is_admin=True,
) )
db.add(admin) db.add(admin)
db.commit() db.commit()
else:
# Update existing admin to ensure proper flags
updated = False
if not existing.is_admin:
existing.is_admin = True
updated = True
if existing.subscription_plan != "pro":
existing.subscription_plan = "pro"
updated = True
if not existing.email:
existing.email = "admin@leopost.it"
updated = True
if updated:
db.commit()
finally: finally:
db.close() db.close()
@@ -114,13 +137,17 @@ async def lifespan(app: FastAPI):
# CRITICAL: Do NOT pass root_path here — use Uvicorn --root-path instead. # CRITICAL: Do NOT pass root_path here — use Uvicorn --root-path instead.
app = FastAPI( app = FastAPI(
title="Leopost Full", title="Leopost Full",
version="0.1.0", version="0.2.0",
lifespan=lifespan, lifespan=lifespan,
) )
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["http://localhost:5173"], allow_origins=[
"http://localhost:5173",
"https://leopost.it",
"https://www.leopost.it",
],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
@@ -131,6 +158,7 @@ app.add_middleware(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(admin_router)
app.include_router(characters_router) app.include_router(characters_router)
app.include_router(content_router) app.include_router(content_router)
app.include_router(affiliates_router) app.include_router(affiliates_router)
@@ -143,7 +171,7 @@ app.include_router(editorial_router)
@app.get("/api/health") @app.get("/api/health")
def health(): def health():
return {"status": "ok", "version": "0.1.0"} return {"status": "ok", "version": "0.2.0"}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, JSON, String, Text from sqlalchemy import Boolean, Column, Date, DateTime, ForeignKey, Integer, JSON, String, Text
from .database import Base from .database import Base
@@ -15,6 +15,30 @@ class User(Base):
hashed_password = Column(String(255), nullable=False) hashed_password = Column(String(255), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
# Multi-user SaaS fields
email = Column(String(255), unique=True, nullable=True)
display_name = Column(String(100), nullable=True)
avatar_url = Column(String(500), nullable=True)
auth_provider = Column(String(50), default="local")
google_id = Column(String(200), unique=True, nullable=True)
subscription_plan = Column(String(50), default="freemium")
subscription_expires_at = Column(DateTime, nullable=True)
is_admin = Column(Boolean, default=False)
posts_generated_this_month = Column(Integer, default=0)
posts_reset_date = Column(Date, nullable=True)
class SubscriptionCode(Base):
__tablename__ = "subscription_codes"
id = Column(Integer, primary_key=True)
code = Column(String(100), unique=True, nullable=False)
duration_months = Column(Integer, nullable=False) # 1, 3, 6, 12
created_by_admin_id = Column(Integer, ForeignKey("users.id"))
used_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
used_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
class Character(Base): class Character(Base):
__tablename__ = "characters" __tablename__ = "characters"
@@ -31,6 +55,7 @@ class Character(Base):
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
# === Phase 2: Content Generation === # === Phase 2: Content Generation ===
@@ -53,6 +78,7 @@ class Post(Base):
status = Column(String(20), default="draft") # draft, approved, scheduled, published, failed status = Column(String(20), default="draft") # draft, approved, scheduled, published, failed
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
# === Phase 4: Affiliate Links === # === Phase 4: Affiliate Links ===
@@ -70,6 +96,7 @@ class AffiliateLink(Base):
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
click_count = Column(Integer, default=0) click_count = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
# === Phase 5: Scheduling === # === Phase 5: Scheduling ===
@@ -90,6 +117,7 @@ class EditorialPlan(Base):
is_active = Column(Boolean, default=False) is_active = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
class ScheduledPost(Base): class ScheduledPost(Base):
@@ -124,6 +152,7 @@ class SocialAccount(Base):
extra_data = Column(JSON, default=dict) # platform-specific data extra_data = Column(JSON, default=dict) # platform-specific data
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
# === Phase 11: Comment Management === # === Phase 11: Comment Management ===
@@ -151,6 +180,7 @@ class SystemSetting(Base):
__tablename__ = "system_settings" __tablename__ = "system_settings"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
key = Column(String(100), unique=True, nullable=False) key = Column(String(100), nullable=False)
value = Column(JSON) value = Column(JSON)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)

View File

@@ -0,0 +1,49 @@
"""Piano di abbonamento e limiti per feature."""
PLAN_LIMITS = {
"freemium": {
"characters_max": 1,
"posts_per_month": 15,
"platforms": ["facebook", "instagram"],
"auto_plans": False,
"comments_management": False,
"affiliate_links": False,
"editorial_calendar_max": 5,
},
"pro": {
"characters_max": None,
"posts_per_month": None,
"platforms": ["facebook", "instagram", "youtube", "tiktok"],
"auto_plans": True,
"comments_management": True,
"affiliate_links": True,
"editorial_calendar_max": None,
},
}
def get_plan(user) -> dict:
"""Returns the effective plan for a user (checks expiry for pro)."""
from datetime import datetime
plan = getattr(user, "subscription_plan", "freemium") or "freemium"
if plan == "pro":
expires = getattr(user, "subscription_expires_at", None)
if expires and expires < datetime.utcnow():
return PLAN_LIMITS["freemium"]
return PLAN_LIMITS.get(plan, PLAN_LIMITS["freemium"])
def check_limit(user, feature: str, current_count: int = 0) -> tuple[bool, str]:
"""Returns (allowed, error_message)."""
limits = get_plan(user)
limit = limits.get(feature)
if limit is None:
return True, ""
if isinstance(limit, bool):
if not limit:
return False, f"Feature '{feature}' non disponibile nel piano Freemium. Passa a Pro."
return True, ""
if isinstance(limit, int):
if current_count >= limit:
return False, f"Hai raggiunto il limite del piano Freemium ({limit} {feature}). Passa a Pro."
return True, ""

View File

@@ -0,0 +1,113 @@
"""Admin router — user management and subscription code generation."""
import secrets
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from ..auth import get_current_user
from ..database import get_db
from ..models import SubscriptionCode, User
router = APIRouter(prefix="/api/admin", tags=["admin"])
def _require_admin(current_user: User = Depends(get_current_user)) -> User:
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Accesso riservato agli amministratori.")
return current_user
class GenerateCodeRequest(BaseModel):
duration_months: int # 1, 3, 6, 12
@router.get("/users")
def list_users(
db: Session = Depends(get_db),
admin: User = Depends(_require_admin),
):
"""List all users (admin only)."""
users = db.query(User).order_by(User.created_at.desc()).all()
return [
{
"id": u.id,
"email": u.email,
"username": u.username,
"display_name": u.display_name,
"subscription_plan": u.subscription_plan or "freemium",
"subscription_expires_at": u.subscription_expires_at.isoformat() if u.subscription_expires_at else None,
"is_admin": bool(u.is_admin),
"auth_provider": u.auth_provider or "local",
"created_at": u.created_at.isoformat() if u.created_at else None,
}
for u in users
]
@router.post("/codes/generate")
def generate_code(
request: GenerateCodeRequest,
db: Session = Depends(get_db),
admin: User = Depends(_require_admin),
):
"""Generate a new Pro subscription code (admin only)."""
if request.duration_months not in (1, 3, 6, 12):
raise HTTPException(status_code=400, detail="duration_months deve essere 1, 3, 6 o 12.")
raw = secrets.token_urlsafe(12).upper()[:12]
code_str = f"LP-{raw}"
# Ensure uniqueness
attempts = 0
while db.query(SubscriptionCode).filter(SubscriptionCode.code == code_str).first():
raw = secrets.token_urlsafe(12).upper()[:12]
code_str = f"LP-{raw}"
attempts += 1
if attempts > 10:
raise HTTPException(status_code=500, detail="Impossibile generare codice univoco.")
code = SubscriptionCode(
code=code_str,
duration_months=request.duration_months,
created_by_admin_id=admin.id,
)
db.add(code)
db.commit()
db.refresh(code)
return {
"code": code.code,
"duration_months": code.duration_months,
"created_at": code.created_at.isoformat(),
}
@router.get("/codes")
def list_codes(
db: Session = Depends(get_db),
admin: User = Depends(_require_admin),
):
"""List all subscription codes (admin only)."""
codes = db.query(SubscriptionCode).order_by(SubscriptionCode.created_at.desc()).all()
result = []
for c in codes:
used_by_email = None
if c.used_by_user_id:
used_user = db.query(User).filter(User.id == c.used_by_user_id).first()
if used_user:
used_by_email = used_user.email or used_user.username
result.append({
"id": c.id,
"code": c.code,
"duration_months": c.duration_months,
"status": "used" if c.used_by_user_id else "active",
"used_by": used_by_email,
"used_at": c.used_at.isoformat() if c.used_at else None,
"created_at": c.created_at.isoformat() if c.created_at else None,
})
return result

View File

@@ -8,13 +8,13 @@ from sqlalchemy.orm import Session
from ..auth import get_current_user from ..auth import get_current_user
from ..database import get_db from ..database import get_db
from ..models import AffiliateLink from ..models import AffiliateLink, User
from ..plan_limits import get_plan
from ..schemas import AffiliateLinkCreate, AffiliateLinkResponse, AffiliateLinkUpdate from ..schemas import AffiliateLinkCreate, AffiliateLinkResponse, AffiliateLinkUpdate
router = APIRouter( router = APIRouter(
prefix="/api/affiliates", prefix="/api/affiliates",
tags=["affiliates"], tags=["affiliates"],
dependencies=[Depends(get_current_user)],
) )
@@ -22,27 +22,48 @@ router = APIRouter(
def list_affiliate_links( def list_affiliate_links(
character_id: int | None = Query(None), character_id: int | None = Query(None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
): ):
"""List all affiliate links, optionally filtered by character.""" """List all affiliate links, optionally filtered by character."""
query = db.query(AffiliateLink) query = db.query(AffiliateLink).filter(AffiliateLink.user_id == current_user.id)
if character_id is not None: if character_id is not None:
query = query.filter(AffiliateLink.character_id == character_id) query = query.filter(AffiliateLink.character_id == character_id)
return query.order_by(AffiliateLink.created_at.desc()).all() return query.order_by(AffiliateLink.created_at.desc()).all()
@router.get("/{link_id}", response_model=AffiliateLinkResponse) @router.get("/{link_id}", response_model=AffiliateLinkResponse)
def get_affiliate_link(link_id: int, db: Session = Depends(get_db)): def get_affiliate_link(
link_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get a single affiliate link by ID.""" """Get a single affiliate link by ID."""
link = db.query(AffiliateLink).filter(AffiliateLink.id == link_id).first() link = (
db.query(AffiliateLink)
.filter(AffiliateLink.id == link_id, AffiliateLink.user_id == current_user.id)
.first()
)
if not link: if not link:
raise HTTPException(status_code=404, detail="Affiliate link not found") raise HTTPException(status_code=404, detail="Affiliate link not found")
return link return link
@router.post("/", response_model=AffiliateLinkResponse, status_code=201) @router.post("/", response_model=AffiliateLinkResponse, status_code=201)
def create_affiliate_link(data: AffiliateLinkCreate, db: Session = Depends(get_db)): def create_affiliate_link(
data: AffiliateLinkCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Create a new affiliate link.""" """Create a new affiliate link."""
plan = get_plan(current_user)
if not plan.get("affiliate_links"):
raise HTTPException(
status_code=403,
detail={"message": "Affiliate links disponibili solo con Pro.", "upgrade_required": True},
)
link = AffiliateLink(**data.model_dump()) link = AffiliateLink(**data.model_dump())
link.user_id = current_user.id
db.add(link) db.add(link)
db.commit() db.commit()
db.refresh(link) db.refresh(link)
@@ -51,10 +72,17 @@ def create_affiliate_link(data: AffiliateLinkCreate, db: Session = Depends(get_d
@router.put("/{link_id}", response_model=AffiliateLinkResponse) @router.put("/{link_id}", response_model=AffiliateLinkResponse)
def update_affiliate_link( def update_affiliate_link(
link_id: int, data: AffiliateLinkUpdate, db: Session = Depends(get_db) link_id: int,
data: AffiliateLinkUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
): ):
"""Update an affiliate link.""" """Update an affiliate link."""
link = db.query(AffiliateLink).filter(AffiliateLink.id == link_id).first() link = (
db.query(AffiliateLink)
.filter(AffiliateLink.id == link_id, AffiliateLink.user_id == current_user.id)
.first()
)
if not link: if not link:
raise HTTPException(status_code=404, detail="Affiliate link not found") raise HTTPException(status_code=404, detail="Affiliate link not found")
update_data = data.model_dump(exclude_unset=True) update_data = data.model_dump(exclude_unset=True)
@@ -66,9 +94,17 @@ def update_affiliate_link(
@router.delete("/{link_id}", status_code=204) @router.delete("/{link_id}", status_code=204)
def delete_affiliate_link(link_id: int, db: Session = Depends(get_db)): def delete_affiliate_link(
link_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Delete an affiliate link.""" """Delete an affiliate link."""
link = db.query(AffiliateLink).filter(AffiliateLink.id == link_id).first() link = (
db.query(AffiliateLink)
.filter(AffiliateLink.id == link_id, AffiliateLink.user_id == current_user.id)
.first()
)
if not link: if not link:
raise HTTPException(status_code=404, detail="Affiliate link not found") raise HTTPException(status_code=404, detail="Affiliate link not found")
db.delete(link) db.delete(link)

277
backend/app/routers/auth.py Normal file
View File

@@ -0,0 +1,277 @@
"""Authentication router — multi-user SaaS with local + Google OAuth."""
import secrets
import uuid
from datetime import datetime, timedelta
from typing import Optional
import httpx
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
from ..auth import (
create_access_token,
get_current_user,
get_user_by_email,
hash_password,
verify_password,
)
from ..config import settings
from ..database import get_db
from ..models import SubscriptionCode, User
router = APIRouter(prefix="/api/auth", tags=["auth"])
# === Schemas ===
class RegisterRequest(BaseModel):
email: str
password: str
display_name: Optional[str] = None
class LoginRequest(BaseModel):
email: Optional[str] = None
username: Optional[str] = None # backward compat
password: str
class RedeemCodeRequest(BaseModel):
code: str
def _user_response(user: User) -> dict:
return {
"id": user.id,
"email": user.email,
"username": user.username,
"display_name": user.display_name,
"avatar_url": user.avatar_url,
"subscription_plan": user.subscription_plan or "freemium",
"subscription_expires_at": user.subscription_expires_at.isoformat() if user.subscription_expires_at else None,
"is_admin": bool(user.is_admin),
"posts_generated_this_month": user.posts_generated_this_month or 0,
}
# === Endpoints ===
@router.post("/register")
def register(request: RegisterRequest, db: Session = Depends(get_db)):
"""Register a new user with email + password."""
# Check email uniqueness
existing = get_user_by_email(db, request.email)
if existing:
raise HTTPException(status_code=400, detail="Email già registrata.")
# Build username from email for backward compat
username_base = request.email.split("@")[0]
username = username_base
counter = 1
while db.query(User).filter(User.username == username).first():
username = f"{username_base}{counter}"
counter += 1
user = User(
username=username,
hashed_password=hash_password(request.password),
email=request.email,
display_name=request.display_name or username,
auth_provider="local",
subscription_plan="freemium",
is_admin=False,
)
db.add(user)
db.commit()
db.refresh(user)
token = create_access_token({"sub": user.username, "user_id": user.id})
return {
"access_token": token,
"token_type": "bearer",
"user": _user_response(user),
}
@router.post("/login")
def login(request: LoginRequest, db: Session = Depends(get_db)):
"""Login with email (or legacy username) + password."""
user = None
if request.email:
user = get_user_by_email(db, request.email)
elif request.username:
# Backward compat: check username OR email
user = db.query(User).filter(User.username == request.username).first()
if not user:
user = get_user_by_email(db, request.username)
if not user or not verify_password(request.password, user.hashed_password):
raise HTTPException(status_code=401, detail="Credenziali non valide.")
token = create_access_token({"sub": user.username, "user_id": user.id})
return {
"access_token": token,
"token_type": "bearer",
"user": _user_response(user),
}
@router.get("/me")
def me(user: User = Depends(get_current_user)):
"""Get current authenticated user info."""
return _user_response(user)
@router.post("/logout")
def logout():
"""Logout — client should remove the token."""
return {"message": "ok"}
# === Google OAuth ===
@router.get("/oauth/google")
def oauth_google_start():
"""Redirect to Google OAuth consent screen."""
if not settings.google_client_id:
raise HTTPException(status_code=501, detail="Google OAuth non configurato.")
state_token = str(uuid.uuid4())
params = {
"client_id": settings.google_client_id,
"redirect_uri": f"{settings.app_url}/api/auth/oauth/google/callback",
"response_type": "code",
"scope": "openid email profile",
"state": state_token,
"access_type": "offline",
}
query = "&".join(f"{k}={v}" for k, v in params.items())
auth_url = f"https://accounts.google.com/o/oauth2/v2/auth?{query}"
return RedirectResponse(url=auth_url)
@router.get("/oauth/google/callback")
async def oauth_google_callback(code: str, state: Optional[str] = None, db: Session = Depends(get_db)):
"""Exchange Google OAuth code for token, create/find user, redirect with JWT."""
if not settings.google_client_id or not settings.google_client_secret:
raise HTTPException(status_code=501, detail="Google OAuth 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()
# 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()
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 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
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)
# === Subscription code redemption ===
@router.post("/redeem")
def redeem_code(
request: RedeemCodeRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Redeem a Pro subscription code."""
code = db.query(SubscriptionCode).filter(
SubscriptionCode.code == request.code.upper().strip()
).first()
if not code:
raise HTTPException(status_code=404, detail="Codice non trovato.")
if code.used_by_user_id is not None:
raise HTTPException(status_code=400, detail="Codice già utilizzato.")
# Calculate new expiry
now = datetime.utcnow()
current_expiry = current_user.subscription_expires_at
if current_user.subscription_plan == "pro" and current_expiry and current_expiry > now:
# Extend existing pro subscription
base_date = current_expiry
else:
base_date = now
new_expiry = base_date + timedelta(days=30 * code.duration_months)
# Update user
current_user.subscription_plan = "pro"
current_user.subscription_expires_at = new_expiry
# Mark code as used
code.used_by_user_id = current_user.id
code.used_at = now
db.commit()
db.refresh(current_user)
return {
"subscription_plan": current_user.subscription_plan,
"subscription_expires_at": current_user.subscription_expires_at.isoformat(),
}

View File

@@ -5,32 +5,59 @@ from sqlalchemy.orm import Session
from ..auth import get_current_user from ..auth import get_current_user
from ..database import get_db from ..database import get_db
from ..models import Character from ..models import Character, User
from ..plan_limits import check_limit
from ..schemas import CharacterCreate, CharacterResponse, CharacterUpdate from ..schemas import CharacterCreate, CharacterResponse, CharacterUpdate
router = APIRouter( router = APIRouter(
prefix="/api/characters", prefix="/api/characters",
tags=["characters"], tags=["characters"],
dependencies=[Depends(get_current_user)],
) )
@router.get("/", response_model=list[CharacterResponse]) @router.get("/", response_model=list[CharacterResponse])
def list_characters(db: Session = Depends(get_db)): def list_characters(
return db.query(Character).order_by(Character.created_at.desc()).all() db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return (
db.query(Character)
.filter(Character.user_id == current_user.id)
.order_by(Character.created_at.desc())
.all()
)
@router.get("/{character_id}", response_model=CharacterResponse) @router.get("/{character_id}", response_model=CharacterResponse)
def get_character(character_id: int, db: Session = Depends(get_db)): def get_character(
character = db.query(Character).filter(Character.id == character_id).first() character_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
character = (
db.query(Character)
.filter(Character.id == character_id, Character.user_id == current_user.id)
.first()
)
if not character: if not character:
raise HTTPException(status_code=404, detail="Character not found") raise HTTPException(status_code=404, detail="Character not found")
return character return character
@router.post("/", response_model=CharacterResponse, status_code=201) @router.post("/", response_model=CharacterResponse, status_code=201)
def create_character(data: CharacterCreate, db: Session = Depends(get_db)): def create_character(
data: CharacterCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# Check plan limit for characters
count = db.query(Character).filter(Character.user_id == current_user.id).count()
allowed, msg = check_limit(current_user, "characters_max", count)
if not allowed:
raise HTTPException(status_code=403, detail={"message": msg, "upgrade_required": True})
character = Character(**data.model_dump()) character = Character(**data.model_dump())
character.user_id = current_user.id
db.add(character) db.add(character)
db.commit() db.commit()
db.refresh(character) db.refresh(character)
@@ -39,9 +66,16 @@ def create_character(data: CharacterCreate, db: Session = Depends(get_db)):
@router.put("/{character_id}", response_model=CharacterResponse) @router.put("/{character_id}", response_model=CharacterResponse)
def update_character( def update_character(
character_id: int, data: CharacterUpdate, db: Session = Depends(get_db) character_id: int,
data: CharacterUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
): ):
character = db.query(Character).filter(Character.id == character_id).first() character = (
db.query(Character)
.filter(Character.id == character_id, Character.user_id == current_user.id)
.first()
)
if not character: if not character:
raise HTTPException(status_code=404, detail="Character not found") raise HTTPException(status_code=404, detail="Character not found")
update_data = data.model_dump(exclude_unset=True) update_data = data.model_dump(exclude_unset=True)
@@ -54,8 +88,16 @@ def update_character(
@router.delete("/{character_id}", status_code=204) @router.delete("/{character_id}", status_code=204)
def delete_character(character_id: int, db: Session = Depends(get_db)): def delete_character(
character = db.query(Character).filter(Character.id == character_id).first() character_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
character = (
db.query(Character)
.filter(Character.id == character_id, Character.user_id == current_user.id)
.first()
)
if not character: if not character:
raise HTTPException(status_code=404, detail="Character not found") raise HTTPException(status_code=404, detail="Character not found")
db.delete(character) db.delete(character)

View File

@@ -10,7 +10,8 @@ from sqlalchemy.orm import Session
from ..auth import get_current_user from ..auth import get_current_user
from ..database import get_db from ..database import get_db
from ..models import Comment, Post, ScheduledPost, SocialAccount, SystemSetting from ..models import Comment, Post, ScheduledPost, SocialAccount, SystemSetting, User
from ..plan_limits import get_plan
from ..schemas import CommentAction, CommentResponse from ..schemas import CommentAction, CommentResponse
from ..services.llm import get_llm_provider from ..services.llm import get_llm_provider
from ..services.social import get_publisher from ..services.social import get_publisher
@@ -18,7 +19,6 @@ from ..services.social import get_publisher
router = APIRouter( router = APIRouter(
prefix="/api/comments", prefix="/api/comments",
tags=["comments"], tags=["comments"],
dependencies=[Depends(get_current_user)],
) )
@@ -28,6 +28,7 @@ def list_comments(
reply_status: str | None = Query(None), reply_status: str | None = Query(None),
scheduled_post_id: int | None = Query(None), scheduled_post_id: int | None = Query(None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
): ):
"""List comments with optional filters.""" """List comments with optional filters."""
query = db.query(Comment) query = db.query(Comment)
@@ -41,8 +42,17 @@ def list_comments(
@router.get("/pending", response_model=list[CommentResponse]) @router.get("/pending", response_model=list[CommentResponse])
def list_pending_comments(db: Session = Depends(get_db)): def list_pending_comments(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get only pending comments (reply_status='pending').""" """Get only pending comments (reply_status='pending')."""
plan = get_plan(current_user)
if not plan.get("comments_management"):
raise HTTPException(
status_code=403,
detail={"message": "Gestione commenti disponibile solo con Pro.", "upgrade_required": True},
)
return ( return (
db.query(Comment) db.query(Comment)
.filter(Comment.reply_status == "pending") .filter(Comment.reply_status == "pending")
@@ -52,7 +62,11 @@ def list_pending_comments(db: Session = Depends(get_db)):
@router.get("/{comment_id}", response_model=CommentResponse) @router.get("/{comment_id}", response_model=CommentResponse)
def get_comment(comment_id: int, db: Session = Depends(get_db)): def get_comment(
comment_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get a single comment by ID.""" """Get a single comment by ID."""
comment = db.query(Comment).filter(Comment.id == comment_id).first() comment = db.query(Comment).filter(Comment.id == comment_id).first()
if not comment: if not comment:
@@ -62,9 +76,19 @@ def get_comment(comment_id: int, db: Session = Depends(get_db)):
@router.post("/{comment_id}/action", response_model=CommentResponse) @router.post("/{comment_id}/action", response_model=CommentResponse)
def action_on_comment( def action_on_comment(
comment_id: int, data: CommentAction, db: Session = Depends(get_db) comment_id: int,
data: CommentAction,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
): ):
"""Take action on a comment: approve, edit, or ignore.""" """Take action on a comment: approve, edit, or ignore."""
plan = get_plan(current_user)
if not plan.get("comments_management"):
raise HTTPException(
status_code=403,
detail={"message": "Gestione commenti disponibile solo con Pro.", "upgrade_required": True},
)
comment = db.query(Comment).filter(Comment.id == comment_id).first() comment = db.query(Comment).filter(Comment.id == comment_id).first()
if not comment: if not comment:
raise HTTPException(status_code=404, detail="Comment not found") raise HTTPException(status_code=404, detail="Comment not found")
@@ -88,7 +112,11 @@ def action_on_comment(
@router.post("/{comment_id}/reply", response_model=CommentResponse) @router.post("/{comment_id}/reply", response_model=CommentResponse)
def reply_to_comment(comment_id: int, db: Session = Depends(get_db)): def reply_to_comment(
comment_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Send the approved reply via the social platform API.""" """Send the approved reply via the social platform API."""
comment = db.query(Comment).filter(Comment.id == comment_id).first() comment = db.query(Comment).filter(Comment.id == comment_id).first()
if not comment: if not comment:
@@ -100,7 +128,6 @@ def reply_to_comment(comment_id: int, db: Session = Depends(get_db)):
if not comment.external_comment_id: if not comment.external_comment_id:
raise HTTPException(status_code=400, detail="No external comment ID available for reply") raise HTTPException(status_code=400, detail="No external comment ID available for reply")
# Find the social account for this platform via the scheduled post
if not comment.scheduled_post_id: if not comment.scheduled_post_id:
raise HTTPException(status_code=400, detail="Comment is not linked to a scheduled post") raise HTTPException(status_code=400, detail="Comment is not linked to a scheduled post")
@@ -131,7 +158,6 @@ def reply_to_comment(comment_id: int, db: Session = Depends(get_db)):
detail=f"No active {comment.platform} account found for this character", detail=f"No active {comment.platform} account found for this character",
) )
# Build publisher kwargs
kwargs: dict = {} kwargs: dict = {}
if account.platform == "facebook": if account.platform == "facebook":
kwargs["page_id"] = account.page_id kwargs["page_id"] = account.page_id
@@ -156,13 +182,12 @@ def reply_to_comment(comment_id: int, db: Session = Depends(get_db)):
@router.post("/fetch/{platform}") @router.post("/fetch/{platform}")
def fetch_comments(platform: str, db: Session = Depends(get_db)): def fetch_comments(
"""Fetch new comments from a platform for all published posts. platform: str,
db: Session = Depends(get_db),
Creates Comment records for any new comments not already in the database. current_user: User = Depends(get_current_user),
Uses LLM to generate AI-suggested replies for each new comment. ):
""" """Fetch new comments from a platform for all published posts."""
# Get all published scheduled posts for this platform
published_posts = ( published_posts = (
db.query(ScheduledPost) db.query(ScheduledPost)
.filter( .filter(
@@ -176,12 +201,17 @@ def fetch_comments(platform: str, db: Session = Depends(get_db)):
if not published_posts: if not published_posts:
return {"new_comments": 0, "message": f"No published posts found for {platform}"} return {"new_comments": 0, "message": f"No published posts found for {platform}"}
# Get LLM settings for AI reply generation
llm_provider_name = None llm_provider_name = None
llm_api_key = None llm_api_key = None
llm_model = None llm_model = None
for key in ("llm_provider", "llm_api_key", "llm_model"): for key in ("llm_provider", "llm_api_key", "llm_model"):
setting = db.query(SystemSetting).filter(SystemSetting.key == key).first() setting = (
db.query(SystemSetting)
.filter(SystemSetting.key == key, SystemSetting.user_id == current_user.id)
.first()
)
if not setting:
setting = db.query(SystemSetting).filter(SystemSetting.key == key, SystemSetting.user_id == None).first()
if setting: if setting:
if key == "llm_provider": if key == "llm_provider":
llm_provider_name = setting.value llm_provider_name = setting.value
@@ -195,17 +225,15 @@ def fetch_comments(platform: str, db: Session = Depends(get_db)):
try: try:
llm = get_llm_provider(llm_provider_name, llm_api_key, llm_model) llm = get_llm_provider(llm_provider_name, llm_api_key, llm_model)
except ValueError: except ValueError:
pass # LLM not available, skip AI replies pass
new_comment_count = 0 new_comment_count = 0
for scheduled in published_posts: for scheduled in published_posts:
# Get the post to find the character
post = db.query(Post).filter(Post.id == scheduled.post_id).first() post = db.query(Post).filter(Post.id == scheduled.post_id).first()
if not post: if not post:
continue continue
# Find the social account
account = ( account = (
db.query(SocialAccount) db.query(SocialAccount)
.filter( .filter(
@@ -218,7 +246,6 @@ def fetch_comments(platform: str, db: Session = Depends(get_db)):
if not account or not account.access_token: if not account or not account.access_token:
continue continue
# Build publisher kwargs
kwargs: dict = {} kwargs: dict = {}
if account.platform == "facebook": if account.platform == "facebook":
kwargs["page_id"] = account.page_id kwargs["page_id"] = account.page_id
@@ -229,14 +256,13 @@ def fetch_comments(platform: str, db: Session = Depends(get_db)):
publisher = get_publisher(account.platform, account.access_token, **kwargs) publisher = get_publisher(account.platform, account.access_token, **kwargs)
comments = publisher.get_comments(scheduled.external_post_id) comments = publisher.get_comments(scheduled.external_post_id)
except (RuntimeError, ValueError): except (RuntimeError, ValueError):
continue # Skip this post if API call fails continue
for ext_comment in comments: for ext_comment in comments:
ext_id = ext_comment.get("id", "") ext_id = ext_comment.get("id", "")
if not ext_id: if not ext_id:
continue continue
# Check if comment already exists
existing = ( existing = (
db.query(Comment) db.query(Comment)
.filter(Comment.external_comment_id == ext_id) .filter(Comment.external_comment_id == ext_id)
@@ -245,7 +271,6 @@ def fetch_comments(platform: str, db: Session = Depends(get_db)):
if existing: if existing:
continue continue
# Generate AI suggested reply if LLM is available
ai_reply = None ai_reply = None
if llm: if llm:
try: try:
@@ -261,9 +286,8 @@ def fetch_comments(platform: str, db: Session = Depends(get_db)):
) )
ai_reply = llm.generate(prompt, system=system_prompt) ai_reply = llm.generate(prompt, system=system_prompt)
except RuntimeError: except RuntimeError:
pass # Skip AI reply if generation fails pass
# Create comment record
comment = Comment( comment = Comment(
scheduled_post_id=scheduled.id, scheduled_post_id=scheduled.id,
platform=platform, platform=platform,

View File

@@ -3,14 +3,15 @@
Handles post generation via LLM, image generation, and CRUD operations on posts. Handles post generation via LLM, image generation, and CRUD operations on posts.
""" """
from datetime import datetime from datetime import date, datetime
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..auth import get_current_user from ..auth import get_current_user
from ..database import get_db from ..database import get_db
from ..models import AffiliateLink, Character, Post, SystemSetting from ..models import AffiliateLink, Character, Post, SystemSetting, User
from ..plan_limits import check_limit
from ..schemas import ( from ..schemas import (
GenerateContentRequest, GenerateContentRequest,
GenerateImageRequest, GenerateImageRequest,
@@ -24,30 +25,57 @@ from ..services.llm import get_llm_provider
router = APIRouter( router = APIRouter(
prefix="/api/content", prefix="/api/content",
tags=["content"], tags=["content"],
dependencies=[Depends(get_current_user)],
) )
def _get_setting(db: Session, key: str) -> str | None: def _get_setting(db: Session, key: str, user_id: int = None) -> str | None:
"""Retrieve a system setting value by key.""" """Retrieve a system setting value by key, preferring user-specific over global."""
setting = db.query(SystemSetting).filter(SystemSetting.key == key).first() if user_id is not None:
setting = (
db.query(SystemSetting)
.filter(SystemSetting.key == key, SystemSetting.user_id == user_id)
.first()
)
if setting is not None:
return setting.value
# Fallback to global (no user_id)
setting = db.query(SystemSetting).filter(SystemSetting.key == key, SystemSetting.user_id == None).first()
if setting is None: if setting is None:
return None return None
return setting.value return setting.value
@router.post("/generate", response_model=PostResponse) @router.post("/generate", response_model=PostResponse)
def generate_content(request: GenerateContentRequest, db: Session = Depends(get_db)): def generate_content(
request: GenerateContentRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Generate content for a character using LLM.""" """Generate content for a character using LLM."""
# Validate character exists # Validate character belongs to user
character = db.query(Character).filter(Character.id == request.character_id).first() character = (
db.query(Character)
.filter(Character.id == request.character_id, Character.user_id == current_user.id)
.first()
)
if not character: if not character:
raise HTTPException(status_code=404, detail="Character not found") raise HTTPException(status_code=404, detail="Character not found")
# Get LLM settings # Check monthly post limit
provider_name = request.provider or _get_setting(db, "llm_provider") first_of_month = date.today().replace(day=1)
api_key = _get_setting(db, "llm_api_key") if current_user.posts_reset_date != first_of_month:
model = request.model or _get_setting(db, "llm_model") current_user.posts_generated_this_month = 0
current_user.posts_reset_date = first_of_month
db.commit()
allowed, msg = check_limit(current_user, "posts_per_month", current_user.posts_generated_this_month or 0)
if not allowed:
raise HTTPException(status_code=403, detail={"message": msg, "upgrade_required": True})
# Get LLM settings (user-specific first, then global)
provider_name = request.provider or _get_setting(db, "llm_provider", current_user.id)
api_key = _get_setting(db, "llm_api_key", current_user.id)
model = request.model or _get_setting(db, "llm_model", current_user.id)
if not provider_name: if not provider_name:
raise HTTPException(status_code=400, detail="LLM provider not configured. Set 'llm_provider' in settings.") raise HTTPException(status_code=400, detail="LLM provider not configured. Set 'llm_provider' in settings.")
@@ -63,7 +91,7 @@ def generate_content(request: GenerateContentRequest, db: Session = Depends(get_
} }
# Create LLM provider and generate text # Create LLM provider and generate text
base_url = _get_setting(db, "llm_base_url") base_url = _get_setting(db, "llm_base_url", current_user.id)
llm = get_llm_provider(provider_name, api_key, model, base_url=base_url) llm = get_llm_provider(provider_name, api_key, model, base_url=base_url)
text = generate_post_text( text = generate_post_text(
character=char_dict, character=char_dict,
@@ -82,6 +110,7 @@ def generate_content(request: GenerateContentRequest, db: Session = Depends(get_
db.query(AffiliateLink) db.query(AffiliateLink)
.filter( .filter(
AffiliateLink.is_active == True, AffiliateLink.is_active == True,
AffiliateLink.user_id == current_user.id,
(AffiliateLink.character_id == character.id) | (AffiliateLink.character_id == None), (AffiliateLink.character_id == character.id) | (AffiliateLink.character_id == None),
) )
.all() .all()
@@ -102,6 +131,7 @@ def generate_content(request: GenerateContentRequest, db: Session = Depends(get_
# Create post record # Create post record
post = Post( post = Post(
character_id=character.id, character_id=character.id,
user_id=current_user.id,
content_type=request.content_type, content_type=request.content_type,
text_content=text, text_content=text,
hashtags=hashtags, hashtags=hashtags,
@@ -112,29 +142,37 @@ def generate_content(request: GenerateContentRequest, db: Session = Depends(get_
status="draft", status="draft",
) )
db.add(post) db.add(post)
# Increment monthly counter
current_user.posts_generated_this_month = (current_user.posts_generated_this_month or 0) + 1
db.commit() db.commit()
db.refresh(post) db.refresh(post)
return post return post
@router.post("/generate-image", response_model=PostResponse) @router.post("/generate-image", response_model=PostResponse)
def generate_image(request: GenerateImageRequest, db: Session = Depends(get_db)): def generate_image(
request: GenerateImageRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Generate an image for a character and attach to a post.""" """Generate an image for a character and attach to a post."""
# Validate character exists character = (
character = db.query(Character).filter(Character.id == request.character_id).first() db.query(Character)
.filter(Character.id == request.character_id, Character.user_id == current_user.id)
.first()
)
if not character: if not character:
raise HTTPException(status_code=404, detail="Character not found") raise HTTPException(status_code=404, detail="Character not found")
# Get image settings provider_name = request.provider or _get_setting(db, "image_provider", current_user.id)
provider_name = request.provider or _get_setting(db, "image_provider") api_key = _get_setting(db, "image_api_key", current_user.id)
api_key = _get_setting(db, "image_api_key")
if not provider_name: if not provider_name:
raise HTTPException(status_code=400, detail="Image provider not configured. Set 'image_provider' in settings.") raise HTTPException(status_code=400, detail="Image provider not configured. Set 'image_provider' in settings.")
if not api_key: if not api_key:
raise HTTPException(status_code=400, detail="Image API key not configured. Set 'image_api_key' in settings.") raise HTTPException(status_code=400, detail="Image API key not configured. Set 'image_api_key' in settings.")
# Build prompt from character if not provided
prompt = request.prompt prompt = request.prompt
if not prompt: if not prompt:
style_hint = request.style_hint or "" style_hint = request.style_hint or ""
@@ -146,13 +184,12 @@ def generate_image(request: GenerateImageRequest, db: Session = Depends(get_db))
f"Style: {style_desc} {style_hint}".strip() f"Style: {style_desc} {style_hint}".strip()
) )
# Generate image
image_provider = get_image_provider(provider_name, api_key) image_provider = get_image_provider(provider_name, api_key)
image_url = image_provider.generate(prompt, size=request.size) image_url = image_provider.generate(prompt, size=request.size)
# Create a new post with the image
post = Post( post = Post(
character_id=character.id, character_id=character.id,
user_id=current_user.id,
content_type="image", content_type="image",
image_url=image_url, image_url=image_url,
platform_hint="instagram", platform_hint="instagram",
@@ -169,9 +206,10 @@ def list_posts(
character_id: int | None = Query(None), character_id: int | None = Query(None),
status: str | None = Query(None), status: str | None = Query(None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
): ):
"""List all posts with optional filters.""" """List all posts with optional filters."""
query = db.query(Post) query = db.query(Post).filter(Post.user_id == current_user.id)
if character_id is not None: if character_id is not None:
query = query.filter(Post.character_id == character_id) query = query.filter(Post.character_id == character_id)
if status is not None: if status is not None:
@@ -180,18 +218,27 @@ def list_posts(
@router.get("/posts/{post_id}", response_model=PostResponse) @router.get("/posts/{post_id}", response_model=PostResponse)
def get_post(post_id: int, db: Session = Depends(get_db)): def get_post(
post_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get a single post by ID.""" """Get a single post by ID."""
post = db.query(Post).filter(Post.id == post_id).first() post = db.query(Post).filter(Post.id == post_id, Post.user_id == current_user.id).first()
if not post: if not post:
raise HTTPException(status_code=404, detail="Post not found") raise HTTPException(status_code=404, detail="Post not found")
return post return post
@router.put("/posts/{post_id}", response_model=PostResponse) @router.put("/posts/{post_id}", response_model=PostResponse)
def update_post(post_id: int, data: PostUpdate, db: Session = Depends(get_db)): def update_post(
post_id: int,
data: PostUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Update a post.""" """Update a post."""
post = db.query(Post).filter(Post.id == post_id).first() post = db.query(Post).filter(Post.id == post_id, Post.user_id == current_user.id).first()
if not post: if not post:
raise HTTPException(status_code=404, detail="Post not found") raise HTTPException(status_code=404, detail="Post not found")
update_data = data.model_dump(exclude_unset=True) update_data = data.model_dump(exclude_unset=True)
@@ -204,9 +251,13 @@ def update_post(post_id: int, data: PostUpdate, db: Session = Depends(get_db)):
@router.delete("/posts/{post_id}", status_code=204) @router.delete("/posts/{post_id}", status_code=204)
def delete_post(post_id: int, db: Session = Depends(get_db)): def delete_post(
post_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Delete a post.""" """Delete a post."""
post = db.query(Post).filter(Post.id == post_id).first() post = db.query(Post).filter(Post.id == post_id, Post.user_id == current_user.id).first()
if not post: if not post:
raise HTTPException(status_code=404, detail="Post not found") raise HTTPException(status_code=404, detail="Post not found")
db.delete(post) db.delete(post)
@@ -214,9 +265,13 @@ def delete_post(post_id: int, db: Session = Depends(get_db)):
@router.post("/posts/{post_id}/approve", response_model=PostResponse) @router.post("/posts/{post_id}/approve", response_model=PostResponse)
def approve_post(post_id: int, db: Session = Depends(get_db)): def approve_post(
post_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Approve a post (set status to 'approved').""" """Approve a post (set status to 'approved')."""
post = db.query(Post).filter(Post.id == post_id).first() post = db.query(Post).filter(Post.id == post_id, Post.user_id == current_user.id).first()
if not post: if not post:
raise HTTPException(status_code=404, detail="Post not found") raise HTTPException(status_code=404, detail="Post not found")
post.status = "approved" post.status = "approved"

View File

@@ -10,7 +10,8 @@ from sqlalchemy.orm import Session
from ..auth import get_current_user from ..auth import get_current_user
from ..database import get_db from ..database import get_db
from ..models import EditorialPlan, ScheduledPost from ..models import EditorialPlan, ScheduledPost, User
from ..plan_limits import get_plan
from ..schemas import ( from ..schemas import (
EditorialPlanCreate, EditorialPlanCreate,
EditorialPlanResponse, EditorialPlanResponse,
@@ -22,7 +23,6 @@ from ..schemas import (
router = APIRouter( router = APIRouter(
prefix="/api/plans", prefix="/api/plans",
tags=["plans"], tags=["plans"],
dependencies=[Depends(get_current_user)],
) )
@@ -33,9 +33,10 @@ router = APIRouter(
def list_plans( def list_plans(
character_id: int | None = Query(None), character_id: int | None = Query(None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
): ):
"""List all editorial plans, optionally filtered by character.""" """List all editorial plans, optionally filtered by character."""
query = db.query(EditorialPlan) query = db.query(EditorialPlan).filter(EditorialPlan.user_id == current_user.id)
if character_id is not None: if character_id is not None:
query = query.filter(EditorialPlan.character_id == character_id) query = query.filter(EditorialPlan.character_id == character_id)
return query.order_by(EditorialPlan.created_at.desc()).all() return query.order_by(EditorialPlan.created_at.desc()).all()
@@ -48,9 +49,16 @@ def list_all_scheduled_posts(
date_from: datetime | None = Query(None), date_from: datetime | None = Query(None),
date_after: datetime | None = Query(None), date_after: datetime | None = Query(None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
): ):
"""Get all scheduled posts across all plans with optional filters.""" """Get all scheduled posts across all plans with optional filters."""
query = db.query(ScheduledPost) # Join with plans to filter by user
user_plan_ids = [
p.id for p in db.query(EditorialPlan.id).filter(EditorialPlan.user_id == current_user.id).all()
]
query = db.query(ScheduledPost).filter(
(ScheduledPost.plan_id.in_(user_plan_ids)) | (ScheduledPost.plan_id == None)
)
if platform is not None: if platform is not None:
query = query.filter(ScheduledPost.platform == platform) query = query.filter(ScheduledPost.platform == platform)
if status is not None: if status is not None:
@@ -63,18 +71,38 @@ def list_all_scheduled_posts(
@router.get("/{plan_id}", response_model=EditorialPlanResponse) @router.get("/{plan_id}", response_model=EditorialPlanResponse)
def get_plan(plan_id: int, db: Session = Depends(get_db)): def get_plan(
plan_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get a single editorial plan by ID.""" """Get a single editorial plan by ID."""
plan = db.query(EditorialPlan).filter(EditorialPlan.id == plan_id).first() plan = (
db.query(EditorialPlan)
.filter(EditorialPlan.id == plan_id, EditorialPlan.user_id == current_user.id)
.first()
)
if not plan: if not plan:
raise HTTPException(status_code=404, detail="Editorial plan not found") raise HTTPException(status_code=404, detail="Editorial plan not found")
return plan return plan
@router.post("/", response_model=EditorialPlanResponse, status_code=201) @router.post("/", response_model=EditorialPlanResponse, status_code=201)
def create_plan(data: EditorialPlanCreate, db: Session = Depends(get_db)): def create_plan(
data: EditorialPlanCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Create a new editorial plan.""" """Create a new editorial plan."""
plan_limits = get_plan(current_user)
if not plan_limits.get("auto_plans"):
raise HTTPException(
status_code=403,
detail={"message": "Piani automatici disponibili solo con Pro.", "upgrade_required": True},
)
plan = EditorialPlan(**data.model_dump()) plan = EditorialPlan(**data.model_dump())
plan.user_id = current_user.id
db.add(plan) db.add(plan)
db.commit() db.commit()
db.refresh(plan) db.refresh(plan)
@@ -83,10 +111,17 @@ def create_plan(data: EditorialPlanCreate, db: Session = Depends(get_db)):
@router.put("/{plan_id}", response_model=EditorialPlanResponse) @router.put("/{plan_id}", response_model=EditorialPlanResponse)
def update_plan( def update_plan(
plan_id: int, data: EditorialPlanUpdate, db: Session = Depends(get_db) plan_id: int,
data: EditorialPlanUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
): ):
"""Update an editorial plan.""" """Update an editorial plan."""
plan = db.query(EditorialPlan).filter(EditorialPlan.id == plan_id).first() plan = (
db.query(EditorialPlan)
.filter(EditorialPlan.id == plan_id, EditorialPlan.user_id == current_user.id)
.first()
)
if not plan: if not plan:
raise HTTPException(status_code=404, detail="Editorial plan not found") raise HTTPException(status_code=404, detail="Editorial plan not found")
update_data = data.model_dump(exclude_unset=True) update_data = data.model_dump(exclude_unset=True)
@@ -99,21 +134,36 @@ def update_plan(
@router.delete("/{plan_id}", status_code=204) @router.delete("/{plan_id}", status_code=204)
def delete_plan(plan_id: int, db: Session = Depends(get_db)): def delete_plan(
plan_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Delete an editorial plan and its associated scheduled posts.""" """Delete an editorial plan and its associated scheduled posts."""
plan = db.query(EditorialPlan).filter(EditorialPlan.id == plan_id).first() plan = (
db.query(EditorialPlan)
.filter(EditorialPlan.id == plan_id, EditorialPlan.user_id == current_user.id)
.first()
)
if not plan: if not plan:
raise HTTPException(status_code=404, detail="Editorial plan not found") raise HTTPException(status_code=404, detail="Editorial plan not found")
# Delete associated scheduled posts first
db.query(ScheduledPost).filter(ScheduledPost.plan_id == plan_id).delete() db.query(ScheduledPost).filter(ScheduledPost.plan_id == plan_id).delete()
db.delete(plan) db.delete(plan)
db.commit() db.commit()
@router.post("/{plan_id}/toggle", response_model=EditorialPlanResponse) @router.post("/{plan_id}/toggle", response_model=EditorialPlanResponse)
def toggle_plan(plan_id: int, db: Session = Depends(get_db)): def toggle_plan(
plan_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Toggle the is_active status of an editorial plan.""" """Toggle the is_active status of an editorial plan."""
plan = db.query(EditorialPlan).filter(EditorialPlan.id == plan_id).first() plan = (
db.query(EditorialPlan)
.filter(EditorialPlan.id == plan_id, EditorialPlan.user_id == current_user.id)
.first()
)
if not plan: if not plan:
raise HTTPException(status_code=404, detail="Editorial plan not found") raise HTTPException(status_code=404, detail="Editorial plan not found")
plan.is_active = not plan.is_active plan.is_active = not plan.is_active
@@ -127,9 +177,17 @@ def toggle_plan(plan_id: int, db: Session = Depends(get_db)):
@router.get("/{plan_id}/schedule", response_model=list[ScheduledPostResponse]) @router.get("/{plan_id}/schedule", response_model=list[ScheduledPostResponse])
def get_plan_scheduled_posts(plan_id: int, db: Session = Depends(get_db)): def get_plan_scheduled_posts(
plan_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get all scheduled posts for a specific plan.""" """Get all scheduled posts for a specific plan."""
plan = db.query(EditorialPlan).filter(EditorialPlan.id == plan_id).first() plan = (
db.query(EditorialPlan)
.filter(EditorialPlan.id == plan_id, EditorialPlan.user_id == current_user.id)
.first()
)
if not plan: if not plan:
raise HTTPException(status_code=404, detail="Editorial plan not found") raise HTTPException(status_code=404, detail="Editorial plan not found")
return ( return (
@@ -141,7 +199,11 @@ def get_plan_scheduled_posts(plan_id: int, db: Session = Depends(get_db)):
@router.post("/schedule", response_model=ScheduledPostResponse, status_code=201) @router.post("/schedule", response_model=ScheduledPostResponse, status_code=201)
def schedule_post(data: ScheduledPostCreate, db: Session = Depends(get_db)): def schedule_post(
data: ScheduledPostCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Manually schedule a post.""" """Manually schedule a post."""
scheduled = ScheduledPost(**data.model_dump()) scheduled = ScheduledPost(**data.model_dump())
db.add(scheduled) db.add(scheduled)

View File

@@ -1,6 +1,7 @@
"""System settings router. """System settings router.
Manages key-value system settings including API provider configuration. Manages key-value system settings including API provider configuration.
Each user has their own private settings.
""" """
from datetime import datetime from datetime import datetime
@@ -10,51 +11,60 @@ from sqlalchemy.orm import Session
from ..auth import get_current_user from ..auth import get_current_user
from ..database import get_db from ..database import get_db
from ..models import SystemSetting from ..models import SystemSetting, User
from ..schemas import SettingResponse, SettingUpdate from ..schemas import SettingResponse, SettingUpdate
router = APIRouter( router = APIRouter(
prefix="/api/settings", prefix="/api/settings",
tags=["settings"], tags=["settings"],
dependencies=[Depends(get_current_user)],
) )
@router.get("/", response_model=list[SettingResponse]) @router.get("/", response_model=list[SettingResponse])
def list_settings(db: Session = Depends(get_db)): def list_settings(
"""Get all system settings.""" db: Session = Depends(get_db),
settings = db.query(SystemSetting).order_by(SystemSetting.key).all() current_user: User = Depends(get_current_user),
):
"""Get all system settings for the current user."""
settings = (
db.query(SystemSetting)
.filter(SystemSetting.user_id == current_user.id)
.order_by(SystemSetting.key)
.all()
)
return settings return settings
@router.get("/providers/status") @router.get("/providers/status")
def get_providers_status(db: Session = Depends(get_db)): def get_providers_status(
"""Check which API providers are configured (have API keys set). db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
Returns a dict indicating configuration status for each provider category. ):
""" """Check which API providers are configured (have API keys set)."""
# Helper to check if a setting exists and has a truthy value
def _has_setting(key: str) -> str | None: def _has_setting(key: str) -> str | None:
setting = db.query(SystemSetting).filter(SystemSetting.key == key).first() # User-specific first
setting = (
db.query(SystemSetting)
.filter(SystemSetting.key == key, SystemSetting.user_id == current_user.id)
.first()
)
if not setting:
# Global fallback
setting = db.query(SystemSetting).filter(
SystemSetting.key == key, SystemSetting.user_id == None
).first()
if setting and setting.value: if setting and setting.value:
return setting.value if isinstance(setting.value, str) else str(setting.value) return setting.value if isinstance(setting.value, str) else str(setting.value)
return None return None
# LLM provider
llm_provider = _has_setting("llm_provider") llm_provider = _has_setting("llm_provider")
llm_key = _has_setting("llm_api_key") llm_key = _has_setting("llm_api_key")
# Image provider
image_provider = _has_setting("image_provider") image_provider = _has_setting("image_provider")
image_key = _has_setting("image_api_key") image_key = _has_setting("image_api_key")
# Voice provider (future)
voice_provider = _has_setting("voice_provider") voice_provider = _has_setting("voice_provider")
voice_key = _has_setting("voice_api_key") voice_key = _has_setting("voice_api_key")
# Social platforms - check for any active social accounts
from ..models import SocialAccount from ..models import SocialAccount
social_platforms = {} social_platforms = {}
for platform in ("facebook", "instagram", "youtube", "tiktok"): for platform in ("facebook", "instagram", "youtube", "tiktok"):
has_account = ( has_account = (
@@ -63,6 +73,7 @@ def get_providers_status(db: Session = Depends(get_db)):
SocialAccount.platform == platform, SocialAccount.platform == platform,
SocialAccount.is_active == True, SocialAccount.is_active == True,
SocialAccount.access_token != None, SocialAccount.access_token != None,
SocialAccount.user_id == current_user.id,
) )
.first() .first()
) )
@@ -86,26 +97,40 @@ def get_providers_status(db: Session = Depends(get_db)):
@router.get("/{key}", response_model=SettingResponse) @router.get("/{key}", response_model=SettingResponse)
def get_setting(key: str, db: Session = Depends(get_db)): def get_setting(
"""Get a single setting by key.""" key: str,
setting = db.query(SystemSetting).filter(SystemSetting.key == key).first() db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get a single setting by key (user-specific)."""
setting = (
db.query(SystemSetting)
.filter(SystemSetting.key == key, SystemSetting.user_id == current_user.id)
.first()
)
if not setting: if not setting:
raise HTTPException(status_code=404, detail=f"Setting '{key}' not found") raise HTTPException(status_code=404, detail=f"Setting '{key}' not found")
return setting return setting
@router.put("/{key}", response_model=SettingResponse) @router.put("/{key}", response_model=SettingResponse)
def upsert_setting(key: str, data: SettingUpdate, db: Session = Depends(get_db)): def upsert_setting(
"""Create or update a setting by key. key: str,
data: SettingUpdate,
If the setting exists, update its value. If not, create it. db: Session = Depends(get_db),
""" current_user: User = Depends(get_current_user),
setting = db.query(SystemSetting).filter(SystemSetting.key == key).first() ):
"""Create or update a setting by key (user-specific)."""
setting = (
db.query(SystemSetting)
.filter(SystemSetting.key == key, SystemSetting.user_id == current_user.id)
.first()
)
if setting: if setting:
setting.value = data.value setting.value = data.value
setting.updated_at = datetime.utcnow() setting.updated_at = datetime.utcnow()
else: else:
setting = SystemSetting(key=key, value=data.value) setting = SystemSetting(key=key, value=data.value, user_id=current_user.id)
db.add(setting) db.add(setting)
db.commit() db.commit()
db.refresh(setting) db.refresh(setting)
@@ -113,9 +138,17 @@ def upsert_setting(key: str, data: SettingUpdate, db: Session = Depends(get_db))
@router.delete("/{key}", status_code=204) @router.delete("/{key}", status_code=204)
def delete_setting(key: str, db: Session = Depends(get_db)): def delete_setting(
"""Delete a setting by key.""" key: str,
setting = db.query(SystemSetting).filter(SystemSetting.key == key).first() db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Delete a setting by key (user-specific)."""
setting = (
db.query(SystemSetting)
.filter(SystemSetting.key == key, SystemSetting.user_id == current_user.id)
.first()
)
if not setting: if not setting:
raise HTTPException(status_code=404, detail=f"Setting '{key}' not found") raise HTTPException(status_code=404, detail=f"Setting '{key}' not found")
db.delete(setting) db.delete(setting)

View File

@@ -10,7 +10,7 @@ from sqlalchemy.orm import Session
from ..auth import get_current_user from ..auth import get_current_user
from ..database import get_db from ..database import get_db
from ..models import Post, ScheduledPost, SocialAccount from ..models import Post, ScheduledPost, SocialAccount, User
from ..schemas import ( from ..schemas import (
ScheduledPostResponse, ScheduledPostResponse,
SocialAccountCreate, SocialAccountCreate,
@@ -22,7 +22,6 @@ from ..services.social import get_publisher
router = APIRouter( router = APIRouter(
prefix="/api/social", prefix="/api/social",
tags=["social"], tags=["social"],
dependencies=[Depends(get_current_user)],
) )
@@ -33,27 +32,41 @@ router = APIRouter(
def list_social_accounts( def list_social_accounts(
character_id: int | None = Query(None), character_id: int | None = Query(None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
): ):
"""List all social accounts, optionally filtered by character.""" """List all social accounts, optionally filtered by character."""
query = db.query(SocialAccount) query = db.query(SocialAccount).filter(SocialAccount.user_id == current_user.id)
if character_id is not None: if character_id is not None:
query = query.filter(SocialAccount.character_id == character_id) query = query.filter(SocialAccount.character_id == character_id)
return query.order_by(SocialAccount.created_at.desc()).all() return query.order_by(SocialAccount.created_at.desc()).all()
@router.get("/accounts/{account_id}", response_model=SocialAccountResponse) @router.get("/accounts/{account_id}", response_model=SocialAccountResponse)
def get_social_account(account_id: int, db: Session = Depends(get_db)): def get_social_account(
account_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get a single social account by ID.""" """Get a single social account by ID."""
account = db.query(SocialAccount).filter(SocialAccount.id == account_id).first() account = (
db.query(SocialAccount)
.filter(SocialAccount.id == account_id, SocialAccount.user_id == current_user.id)
.first()
)
if not account: if not account:
raise HTTPException(status_code=404, detail="Social account not found") raise HTTPException(status_code=404, detail="Social account not found")
return account return account
@router.post("/accounts", response_model=SocialAccountResponse, status_code=201) @router.post("/accounts", response_model=SocialAccountResponse, status_code=201)
def create_social_account(data: SocialAccountCreate, db: Session = Depends(get_db)): def create_social_account(
data: SocialAccountCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Create/connect a new social account.""" """Create/connect a new social account."""
account = SocialAccount(**data.model_dump()) account = SocialAccount(**data.model_dump())
account.user_id = current_user.id
db.add(account) db.add(account)
db.commit() db.commit()
db.refresh(account) db.refresh(account)
@@ -62,10 +75,17 @@ def create_social_account(data: SocialAccountCreate, db: Session = Depends(get_d
@router.put("/accounts/{account_id}", response_model=SocialAccountResponse) @router.put("/accounts/{account_id}", response_model=SocialAccountResponse)
def update_social_account( def update_social_account(
account_id: int, data: SocialAccountUpdate, db: Session = Depends(get_db) account_id: int,
data: SocialAccountUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
): ):
"""Update a social account.""" """Update a social account."""
account = db.query(SocialAccount).filter(SocialAccount.id == account_id).first() account = (
db.query(SocialAccount)
.filter(SocialAccount.id == account_id, SocialAccount.user_id == current_user.id)
.first()
)
if not account: if not account:
raise HTTPException(status_code=404, detail="Social account not found") raise HTTPException(status_code=404, detail="Social account not found")
update_data = data.model_dump(exclude_unset=True) update_data = data.model_dump(exclude_unset=True)
@@ -77,9 +97,17 @@ def update_social_account(
@router.delete("/accounts/{account_id}", status_code=204) @router.delete("/accounts/{account_id}", status_code=204)
def delete_social_account(account_id: int, db: Session = Depends(get_db)): def delete_social_account(
account_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Delete a social account.""" """Delete a social account."""
account = db.query(SocialAccount).filter(SocialAccount.id == account_id).first() account = (
db.query(SocialAccount)
.filter(SocialAccount.id == account_id, SocialAccount.user_id == current_user.id)
.first()
)
if not account: if not account:
raise HTTPException(status_code=404, detail="Social account not found") raise HTTPException(status_code=404, detail="Social account not found")
db.delete(account) db.delete(account)
@@ -87,9 +115,17 @@ def delete_social_account(account_id: int, db: Session = Depends(get_db)):
@router.post("/accounts/{account_id}/test") @router.post("/accounts/{account_id}/test")
def test_social_account(account_id: int, db: Session = Depends(get_db)): def test_social_account(
account_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Test connection to a social account by making a simple API call.""" """Test connection to a social account by making a simple API call."""
account = db.query(SocialAccount).filter(SocialAccount.id == account_id).first() account = (
db.query(SocialAccount)
.filter(SocialAccount.id == account_id, SocialAccount.user_id == current_user.id)
.first()
)
if not account: if not account:
raise HTTPException(status_code=404, detail="Social account not found") raise HTTPException(status_code=404, detail="Social account not found")
@@ -97,7 +133,6 @@ def test_social_account(account_id: int, db: Session = Depends(get_db)):
raise HTTPException(status_code=400, detail="No access token configured for this account") raise HTTPException(status_code=400, detail="No access token configured for this account")
try: try:
# Build kwargs based on platform
kwargs: dict = {} kwargs: dict = {}
if account.platform == "facebook": if account.platform == "facebook":
if not account.page_id: if not account.page_id:
@@ -109,7 +144,6 @@ def test_social_account(account_id: int, db: Session = Depends(get_db)):
raise HTTPException(status_code=400, detail="Instagram account requires ig_user_id") raise HTTPException(status_code=400, detail="Instagram account requires ig_user_id")
kwargs["ig_user_id"] = ig_user_id kwargs["ig_user_id"] = ig_user_id
# Try to instantiate the publisher (validates credentials format)
get_publisher(account.platform, account.access_token, **kwargs) get_publisher(account.platform, account.access_token, **kwargs)
return {"status": "ok", "message": f"Connection to {account.platform} account is configured correctly"} return {"status": "ok", "message": f"Connection to {account.platform} account is configured correctly"}
@@ -123,7 +157,11 @@ def test_social_account(account_id: int, db: Session = Depends(get_db)):
@router.post("/publish/{scheduled_post_id}", response_model=ScheduledPostResponse) @router.post("/publish/{scheduled_post_id}", response_model=ScheduledPostResponse)
def publish_scheduled_post(scheduled_post_id: int, db: Session = Depends(get_db)): def publish_scheduled_post(
scheduled_post_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Manually trigger publishing of a scheduled post.""" """Manually trigger publishing of a scheduled post."""
scheduled = ( scheduled = (
db.query(ScheduledPost) db.query(ScheduledPost)
@@ -133,18 +171,17 @@ def publish_scheduled_post(scheduled_post_id: int, db: Session = Depends(get_db)
if not scheduled: if not scheduled:
raise HTTPException(status_code=404, detail="Scheduled post not found") raise HTTPException(status_code=404, detail="Scheduled post not found")
# Get the post content post = db.query(Post).filter(Post.id == scheduled.post_id, Post.user_id == current_user.id).first()
post = db.query(Post).filter(Post.id == scheduled.post_id).first()
if not post: if not post:
raise HTTPException(status_code=404, detail="Associated post not found") raise HTTPException(status_code=404, detail="Associated post not found")
# Find the social account for this platform and character
account = ( account = (
db.query(SocialAccount) db.query(SocialAccount)
.filter( .filter(
SocialAccount.character_id == post.character_id, SocialAccount.character_id == post.character_id,
SocialAccount.platform == scheduled.platform, SocialAccount.platform == scheduled.platform,
SocialAccount.is_active == True, SocialAccount.is_active == True,
SocialAccount.user_id == current_user.id,
) )
.first() .first()
) )
@@ -157,7 +194,6 @@ def publish_scheduled_post(scheduled_post_id: int, db: Session = Depends(get_db)
if not account.access_token: if not account.access_token:
raise HTTPException(status_code=400, detail="Social account has no access token configured") raise HTTPException(status_code=400, detail="Social account has no access token configured")
# Build publisher kwargs
kwargs: dict = {} kwargs: dict = {}
if account.platform == "facebook": if account.platform == "facebook":
kwargs["page_id"] = account.page_id kwargs["page_id"] = account.page_id
@@ -170,7 +206,6 @@ def publish_scheduled_post(scheduled_post_id: int, db: Session = Depends(get_db)
publisher = get_publisher(account.platform, account.access_token, **kwargs) publisher = get_publisher(account.platform, account.access_token, **kwargs)
# Determine publish method based on content type
text = post.text_content or "" text = post.text_content or ""
if post.hashtags: if post.hashtags:
text = f"{text}\n\n{' '.join(post.hashtags)}" text = f"{text}\n\n{' '.join(post.hashtags)}"
@@ -182,12 +217,10 @@ def publish_scheduled_post(scheduled_post_id: int, db: Session = Depends(get_db)
else: else:
external_id = publisher.publish_text(text) external_id = publisher.publish_text(text)
# Update scheduled post
scheduled.status = "published" scheduled.status = "published"
scheduled.published_at = datetime.utcnow() scheduled.published_at = datetime.utcnow()
scheduled.external_post_id = external_id scheduled.external_post_id = external_id
# Update post status
post.status = "published" post.status = "published"
post.updated_at = datetime.utcnow() post.updated_at = datetime.utcnow()

View File

@@ -14,6 +14,7 @@ class LoginRequest(BaseModel):
class Token(BaseModel): class Token(BaseModel):
access_token: str access_token: str
token_type: str = "bearer" token_type: str = "bearer"
user: Optional[dict] = None
# === Characters === # === Characters ===

29
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,29 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
args:
VITE_BASE_PATH: ""
VITE_API_BASE: "/api"
container_name: prod-leopost-full-app
restart: unless-stopped
volumes:
- ./data:/app/data
environment:
- DATABASE_URL=sqlite:///./data/leopost.db
- APP_URL=https://leopost.it
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
- SECRET_KEY=${SECRET_KEY:-leopost-prod-secret-2026}
networks:
- proxy_net
deploy:
resources:
limits:
memory: 1024M
cpus: '1.0'
networks:
proxy_net:
external: true

View File

@@ -1,12 +1,18 @@
services: services:
app: app:
build: . build:
context: .
dockerfile: Dockerfile
args:
VITE_BASE_PATH: "/leopost-full"
VITE_API_BASE: "/leopost-full/api"
container_name: lab-leopost-full-app container_name: lab-leopost-full-app
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- ./data:/app/data - ./data:/app/data
environment: environment:
- DATABASE_URL=sqlite:///./data/leopost.db - DATABASE_URL=sqlite:///./data/leopost.db
- APP_URL=https://lab.mlhub.it/leopost-full
networks: networks:
- proxy_net - proxy_net
deploy: deploy:

View File

@@ -3,6 +3,7 @@ import { AuthProvider } from './AuthContext'
import ProtectedRoute from './components/ProtectedRoute' import ProtectedRoute from './components/ProtectedRoute'
import Layout from './components/Layout' import Layout from './components/Layout'
import LoginPage from './components/LoginPage' import LoginPage from './components/LoginPage'
import AuthCallback from './components/AuthCallback'
import Dashboard from './components/Dashboard' import Dashboard from './components/Dashboard'
import CharacterList from './components/CharacterList' import CharacterList from './components/CharacterList'
import CharacterForm from './components/CharacterForm' import CharacterForm from './components/CharacterForm'
@@ -17,13 +18,19 @@ import SocialAccounts from './components/SocialAccounts'
import CommentsQueue from './components/CommentsQueue' import CommentsQueue from './components/CommentsQueue'
import SettingsPage from './components/SettingsPage' import SettingsPage from './components/SettingsPage'
import EditorialCalendar from './components/EditorialCalendar' import EditorialCalendar from './components/EditorialCalendar'
import AdminSettings from './components/AdminSettings'
const BASE_PATH = import.meta.env.VITE_BASE_PATH !== undefined
? (import.meta.env.VITE_BASE_PATH || '/')
: '/leopost-full'
export default function App() { export default function App() {
return ( return (
<BrowserRouter basename="/leopost-full"> <BrowserRouter basename={BASE_PATH}>
<AuthProvider> <AuthProvider>
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route element={<ProtectedRoute />}> <Route element={<ProtectedRoute />}>
<Route element={<Layout />}> <Route element={<Layout />}>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
@@ -43,6 +50,7 @@ export default function App() {
<Route path="/comments" element={<CommentsQueue />} /> <Route path="/comments" element={<CommentsQueue />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="/editorial" element={<EditorialCalendar />} /> <Route path="/editorial" element={<EditorialCalendar />} />
<Route path="/admin" element={<AdminSettings />} />
</Route> </Route>
</Route> </Route>
<Route path="*" element={<Navigate to="/" />} /> <Route path="*" element={<Navigate to="/" />} />

View File

@@ -3,27 +3,81 @@ import { api } from './api'
const AuthContext = createContext(null) const AuthContext = createContext(null)
const PLAN_LIMITS = {
freemium: {
characters: 1,
posts: 15,
platforms: ['facebook', 'instagram'],
auto_plans: false,
comments_management: false,
affiliate_links: false,
},
pro: {
characters: null,
posts: null,
platforms: ['facebook', 'instagram', 'youtube', 'tiktok'],
auto_plans: true,
comments_management: true,
affiliate_links: true,
},
}
function computeIsPro(user) {
if (!user) return false
if (user.subscription_plan !== 'pro') return false
if (user.subscription_expires_at) {
return new Date(user.subscription_expires_at) > new Date()
}
return true
}
export function AuthProvider({ children }) { export function AuthProvider({ children }) {
const [user, setUser] = useState(null) const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const loadUser = async () => {
try {
const data = await api.get('/auth/me')
setUser(data)
return data
} catch {
localStorage.removeItem('token')
setUser(null)
return null
}
}
useEffect(() => { useEffect(() => {
const token = localStorage.getItem('token') const token = localStorage.getItem('token')
if (token) { if (token) {
api.get('/auth/me') loadUser().finally(() => setLoading(false))
.then((data) => setUser(data))
.catch(() => localStorage.removeItem('token'))
.finally(() => setLoading(false))
} else { } else {
setLoading(false) setLoading(false)
} }
}, []) }, [])
const login = async (username, password) => { const login = async (emailOrUsername, password) => {
const data = await api.post('/auth/login', { username, password }) // Try email login first, fall back to username
const isEmail = emailOrUsername.includes('@')
const body = isEmail
? { email: emailOrUsername, password }
: { username: emailOrUsername, password }
const data = await api.post('/auth/login', body)
localStorage.setItem('token', data.access_token) localStorage.setItem('token', data.access_token)
const me = await api.get('/auth/me') setUser(data.user)
setUser(me) return data.user
}
const register = async (email, password, displayName) => {
const data = await api.post('/auth/register', { email, password, display_name: displayName })
localStorage.setItem('token', data.access_token)
setUser(data.user)
return data.user
}
const loginWithToken = (token) => {
localStorage.setItem('token', token)
loadUser()
} }
const logout = () => { const logout = () => {
@@ -31,8 +85,23 @@ export function AuthProvider({ children }) {
setUser(null) setUser(null)
} }
const isPro = computeIsPro(user)
const isAdmin = Boolean(user?.is_admin)
const planLimits = PLAN_LIMITS[isPro ? 'pro' : 'freemium']
return ( return (
<AuthContext.Provider value={{ user, loading, login, logout }}> <AuthContext.Provider value={{
user,
loading,
login,
register,
logout,
loginWithToken,
loadUser,
isPro,
isAdmin,
planLimits,
}}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
) )

View File

@@ -1,4 +1,4 @@
const BASE_URL = '/leopost-full/api' const BASE_URL = import.meta.env.VITE_API_BASE || '/api'
async function request(method, path, body = null) { async function request(method, path, body = null) {
const headers = { 'Content-Type': 'application/json' } const headers = { 'Content-Type': 'application/json' }
@@ -13,13 +13,21 @@ async function request(method, path, body = null) {
if (res.status === 401) { if (res.status === 401) {
localStorage.removeItem('token') localStorage.removeItem('token')
window.location.href = '/leopost-full/login' const basePath = import.meta.env.VITE_BASE_PATH || '/leopost-full'
window.location.href = basePath ? `${basePath}/login` : '/login'
return return
} }
if (!res.ok) { if (!res.ok) {
const error = await res.json().catch(() => ({ detail: 'Request failed' })) const error = await res.json().catch(() => ({ detail: 'Request failed' }))
throw new Error(error.detail || 'Request failed') // Pass through structured errors (upgrade_required etc.)
const detail = error.detail
if (typeof detail === 'object' && detail !== null) {
const err = new Error(detail.message || 'Request failed')
err.data = detail
throw err
}
throw new Error(detail || 'Request failed')
} }
if (res.status === 204) return null if (res.status === 204) return null
@@ -32,3 +40,5 @@ export const api = {
put: (path, body) => request('PUT', path, body), put: (path, body) => request('PUT', path, body),
delete: (path) => request('DELETE', path), delete: (path) => request('DELETE', path),
} }
export { BASE_URL }

View File

@@ -0,0 +1,344 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../api'
import { useAuth } from '../AuthContext'
const INK = '#1A1A2E'
const MUTED = '#888'
const BORDER = '#E8E4DC'
const CORAL = '#FF6B4A'
const DURATIONS = [
{ value: 1, label: '1 mese', price: '€14.95' },
{ value: 3, label: '3 mesi', price: '€39.95' },
{ value: 6, label: '6 mesi', price: '€64.95' },
{ value: 12, label: '12 mesi (1 anno)', price: '€119.95' },
]
export default function AdminSettings() {
const { isAdmin, user } = useAuth()
const navigate = useNavigate()
const [users, setUsers] = useState([])
const [codes, setCodes] = useState([])
const [loadingUsers, setLoadingUsers] = useState(true)
const [loadingCodes, setLoadingCodes] = useState(true)
const [duration, setDuration] = useState(1)
const [generating, setGenerating] = useState(false)
const [generatedCode, setGeneratedCode] = useState(null)
const [copied, setCopied] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (!isAdmin) {
navigate('/')
return
}
loadUsers()
loadCodes()
}, [isAdmin])
const loadUsers = async () => {
try {
const data = await api.get('/admin/users')
setUsers(data)
} catch {
// silently fail
} finally {
setLoadingUsers(false)
}
}
const loadCodes = async () => {
try {
const data = await api.get('/admin/codes')
setCodes(data)
} catch {
// silently fail
} finally {
setLoadingCodes(false)
}
}
const handleGenerateCode = async () => {
setGenerating(true)
setError('')
setGeneratedCode(null)
try {
const result = await api.post('/admin/codes/generate', { duration_months: duration })
setGeneratedCode(result)
loadCodes()
} catch (err) {
setError(err.message || 'Errore nella generazione del codice.')
} finally {
setGenerating(false)
}
}
const copyCode = async (code) => {
try {
await navigator.clipboard.writeText(code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// fallback
}
}
if (!isAdmin) return null
return (
<div>
<div style={{ marginBottom: '1.5rem' }}>
<h2 style={{ color: INK, fontSize: '1.5rem', fontWeight: 700, margin: 0 }}>
Admin Settings
</h2>
<p style={{ color: MUTED, fontSize: '0.875rem', margin: '0.25rem 0 0' }}>
Gestione utenti e codici abbonamento
</p>
</div>
{/* Generate Code Section */}
<div style={{
backgroundColor: 'white',
border: `1px solid ${BORDER}`,
borderRadius: '14px',
padding: '1.5rem',
marginBottom: '1.5rem',
}}>
<h3 style={{ margin: '0 0 1rem', fontSize: '1rem', color: INK, fontWeight: 600 }}>
Genera Codice Pro
</h3>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '0.75rem', flexWrap: 'wrap' }}>
<div>
<label style={{ display: 'block', fontSize: '0.8rem', fontWeight: 600, color: MUTED, marginBottom: '0.4rem' }}>
Durata abbonamento
</label>
<select
value={duration}
onChange={(e) => setDuration(Number(e.target.value))}
style={{
padding: '0.6rem 1rem',
border: `1px solid ${BORDER}`,
borderRadius: '8px',
fontSize: '0.875rem',
color: INK,
backgroundColor: 'white',
cursor: 'pointer',
outline: 'none',
}}
>
{DURATIONS.map((d) => (
<option key={d.value} value={d.value}>
{d.label} {d.price}
</option>
))}
</select>
</div>
<button
onClick={handleGenerateCode}
disabled={generating}
style={{
padding: '0.6rem 1.25rem',
backgroundColor: CORAL,
color: 'white',
border: 'none',
borderRadius: '8px',
fontWeight: 600,
fontSize: '0.875rem',
cursor: generating ? 'not-allowed' : 'pointer',
opacity: generating ? 0.7 : 1,
}}
>
{generating ? 'Generazione...' : 'Genera Codice'}
</button>
</div>
{error && (
<p style={{ marginTop: '0.75rem', color: '#DC2626', fontSize: '0.875rem' }}>{error}</p>
)}
{generatedCode && (
<div style={{
marginTop: '1rem',
padding: '1rem',
backgroundColor: '#F0FDF4',
border: '1px solid #86EFAC',
borderRadius: '10px',
}}>
<p style={{ margin: '0 0 0.5rem', fontSize: '0.8rem', color: '#065F46', fontWeight: 600 }}>
Codice generato ({DURATIONS.find(d => d.value === generatedCode.duration_months)?.label})
</p>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<code style={{
flex: 1,
padding: '0.5rem 0.75rem',
backgroundColor: 'white',
border: '1px solid #A7F3D0',
borderRadius: '6px',
fontFamily: 'monospace',
fontSize: '1rem',
letterSpacing: '0.1em',
color: '#065F46',
}}>
{generatedCode.code}
</code>
<button
onClick={() => copyCode(generatedCode.code)}
style={{
padding: '0.5rem 0.75rem',
backgroundColor: copied ? '#16A34A' : INK,
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: 600,
transition: 'background-color 0.2s',
}}
>
{copied ? 'Copiato ✓' : 'Copia'}
</button>
</div>
</div>
)}
</div>
{/* Codes Table */}
<div style={{
backgroundColor: 'white',
border: `1px solid ${BORDER}`,
borderRadius: '14px',
padding: '1.5rem',
marginBottom: '1.5rem',
}}>
<h3 style={{ margin: '0 0 1rem', fontSize: '1rem', color: INK, fontWeight: 600 }}>
Codici Generati
</h3>
{loadingCodes ? (
<p style={{ color: MUTED, fontSize: '0.875rem' }}>Caricamento...</p>
) : codes.length === 0 ? (
<p style={{ color: MUTED, fontSize: '0.875rem' }}>Nessun codice generato ancora.</p>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.85rem' }}>
<thead>
<tr style={{ borderBottom: `2px solid ${BORDER}` }}>
{['Codice', 'Durata', 'Stato', 'Usato da', 'Data uso'].map((h) => (
<th key={h} style={{ textAlign: 'left', padding: '0.5rem 0.75rem', color: MUTED, fontWeight: 600, fontSize: '0.8rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{codes.map((code) => (
<tr key={code.id} style={{ borderBottom: `1px solid ${BORDER}` }}>
<td style={{ padding: '0.6rem 0.75rem', fontFamily: 'monospace', color: INK, fontWeight: 600 }}>
{code.code}
</td>
<td style={{ padding: '0.6rem 0.75rem', color: INK }}>
{DURATIONS.find(d => d.value === code.duration_months)?.label || `${code.duration_months}m`}
</td>
<td style={{ padding: '0.6rem 0.75rem' }}>
<span style={{
display: 'inline-block',
padding: '0.15rem 0.5rem',
borderRadius: '10px',
fontSize: '0.75rem',
fontWeight: 600,
backgroundColor: code.status === 'used' ? '#FEE2E2' : '#ECFDF5',
color: code.status === 'used' ? '#DC2626' : '#16A34A',
}}>
{code.status === 'used' ? 'Usato' : 'Attivo'}
</span>
</td>
<td style={{ padding: '0.6rem 0.75rem', color: MUTED }}>
{code.used_by || '—'}
</td>
<td style={{ padding: '0.6rem 0.75rem', color: MUTED }}>
{code.used_at ? new Date(code.used_at).toLocaleDateString('it-IT') : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Users Table */}
<div style={{
backgroundColor: 'white',
border: `1px solid ${BORDER}`,
borderRadius: '14px',
padding: '1.5rem',
}}>
<h3 style={{ margin: '0 0 1rem', fontSize: '1rem', color: INK, fontWeight: 600 }}>
Utenti ({users.length})
</h3>
{loadingUsers ? (
<p style={{ color: MUTED, fontSize: '0.875rem' }}>Caricamento...</p>
) : users.length === 0 ? (
<p style={{ color: MUTED, fontSize: '0.875rem' }}>Nessun utente.</p>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.85rem' }}>
<thead>
<tr style={{ borderBottom: `2px solid ${BORDER}` }}>
{['Email / Username', 'Piano', 'Scadenza', 'Provider', 'Registrazione'].map((h) => (
<th key={h} style={{ textAlign: 'left', padding: '0.5rem 0.75rem', color: MUTED, fontWeight: 600, fontSize: '0.8rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} style={{ borderBottom: `1px solid ${BORDER}`, backgroundColor: u.is_admin ? '#FFFBEB' : 'white' }}>
<td style={{ padding: '0.6rem 0.75rem' }}>
<div style={{ color: INK, fontWeight: 500 }}>{u.email || u.username}</div>
{u.display_name && u.display_name !== u.email && (
<div style={{ color: MUTED, fontSize: '0.78rem' }}>{u.display_name}</div>
)}
{u.is_admin && (
<span style={{ fontSize: '0.7rem', backgroundColor: '#FEF3C7', color: '#D97706', padding: '0.1rem 0.4rem', borderRadius: '4px', fontWeight: 600 }}>
admin
</span>
)}
</td>
<td style={{ padding: '0.6rem 0.75rem' }}>
<span style={{
display: 'inline-block',
padding: '0.15rem 0.5rem',
borderRadius: '10px',
fontSize: '0.75rem',
fontWeight: 600,
backgroundColor: u.subscription_plan === 'pro' ? '#FFF5F3' : '#F5F5F5',
color: u.subscription_plan === 'pro' ? CORAL : MUTED,
}}>
{u.subscription_plan || 'freemium'}
</span>
</td>
<td style={{ padding: '0.6rem 0.75rem', color: MUTED, fontSize: '0.8rem' }}>
{u.subscription_expires_at
? new Date(u.subscription_expires_at).toLocaleDateString('it-IT')
: u.subscription_plan === 'pro' ? '∞' : '—'}
</td>
<td style={{ padding: '0.6rem 0.75rem', color: MUTED }}>
{u.auth_provider || 'local'}
</td>
<td style={{ padding: '0.6rem 0.75rem', color: MUTED, fontSize: '0.8rem' }}>
{u.created_at ? new Date(u.created_at).toLocaleDateString('it-IT') : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,37 @@
import { useEffect } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useAuth } from '../AuthContext'
export default function AuthCallback() {
const [params] = useSearchParams()
const navigate = useNavigate()
const { loginWithToken } = useAuth()
useEffect(() => {
const token = params.get('token')
if (token) {
loginWithToken(token)
navigate('/', { replace: true })
} else {
navigate('/login', { replace: true })
}
}, [])
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<div style={{ textAlign: 'center' }}>
<div style={{
width: 40,
height: 40,
border: '3px solid #FF6B4A',
borderTopColor: 'transparent',
borderRadius: '50%',
animation: 'spin 0.8s linear infinite',
margin: '0 auto 1rem',
}} />
<p style={{ color: '#666' }}>Accesso in corso...</p>
</div>
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
)
}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { api } from '../api' import { api } from '../api'
import { useAuth } from '../AuthContext'
const EMPTY_FORM = { const EMPTY_FORM = {
name: '', name: '',
@@ -11,17 +12,87 @@ const EMPTY_FORM = {
is_active: true, 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,
},
]
export default function CharacterForm() { export default function CharacterForm() {
const { id } = useParams() const { id } = useParams()
const isEdit = Boolean(id) const isEdit = Boolean(id)
const navigate = useNavigate() const navigate = useNavigate()
const { isPro } = useAuth()
const [activeTab, setActiveTab] = useState('profile')
const [form, setForm] = useState(EMPTY_FORM) const [form, setForm] = useState(EMPTY_FORM)
const [topicInput, setTopicInput] = useState('') const [topicInput, setTopicInput] = useState('')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(isEdit) 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(() => { useEffect(() => {
if (isEdit) { if (isEdit) {
api.get(`/characters/${id}`) api.get(`/characters/${id}`)
@@ -41,6 +112,15 @@ export default function CharacterForm() {
}) })
.catch(() => setError('Personaggio non trovato')) .catch(() => setError('Personaggio non trovato'))
.finally(() => setLoading(false)) .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]) }, [id, isEdit])
@@ -89,12 +169,69 @@ export default function CharacterForm() {
} }
navigate('/characters') navigate('/characters')
} catch (err) { } catch (err) {
setError(err.message || 'Errore nel salvataggio') if (err.data?.upgrade_required) {
setError(err.data.message || 'Limite piano raggiunto. Passa a Pro.')
} else {
setError(err.message || 'Errore nel salvataggio')
}
} finally { } finally {
setSaving(false) 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) { if (loading) {
return ( return (
<div className="flex justify-center py-12"> <div className="flex justify-center py-12">
@@ -114,218 +251,382 @@ export default function CharacterForm() {
</p> </p>
</div> </div>
<form onSubmit={handleSubmit} className="max-w-2xl space-y-6"> {/* Tabs */}
{error && ( <div className="flex gap-1 mb-6 p-1 rounded-lg inline-flex" style={{ backgroundColor: '#F1F5F9', border: '1px solid #E2E8F0' }}>
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm"> {[
{error} { id: 'profile', label: 'Profilo' },
</div> { id: 'social', label: 'Account Social', disabled: !isEdit },
)} ].map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => !tab.disabled && setActiveTab(tab.id)}
disabled={tab.disabled}
className="px-4 py-2 rounded-md text-sm font-medium transition-all"
style={{
backgroundColor: activeTab === tab.id ? 'white' : 'transparent',
color: activeTab === tab.id ? '#1E293B' : tab.disabled ? '#CBD5E1' : '#64748B',
boxShadow: activeTab === tab.id ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
cursor: tab.disabled ? 'not-allowed' : 'pointer',
}}
>
{tab.label}
{tab.disabled && <span className="ml-1 text-xs">(salva prima)</span>}
</button>
))}
</div>
{/* Basic info */} {activeTab === 'profile' && (
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4"> <form onSubmit={handleSubmit} className="max-w-2xl space-y-6">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider"> {error && (
Informazioni base <div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
</h3> {error}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Nome personaggio
</label>
<input
type="text"
value={form.name}
onChange={(e) => 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
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Niche / Settore
</label>
<input
type="text"
value={form.niche}
onChange={(e) => 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
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Tono di comunicazione
</label>
<textarea
value={form.tone}
onChange={(e) => handleChange('tone', e.target.value)}
placeholder="Descrivi lo stile di comunicazione: informale, professionale, ironico, motivazionale..."
rows={3}
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 resize-none"
/>
</div>
<div className="flex items-center gap-3">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={form.is_active}
onChange={(e) => handleChange('is_active', e.target.checked)}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-brand-500"></div>
</label>
<span className="text-sm text-slate-700">Attivo</span>
</div>
</div>
{/* Topics */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Topic ricorrenti
</h3>
<div className="flex gap-2">
<input
type="text"
value={topicInput}
onChange={(e) => 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"
/>
<button
type="button"
onClick={addTopic}
className="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg text-sm font-medium transition-colors"
>
Aggiungi
</button>
</div>
{form.topics.length > 0 && (
<div className="flex flex-wrap gap-2">
{form.topics.map((topic) => (
<span
key={topic}
className="inline-flex items-center gap-1.5 px-3 py-1 bg-brand-50 text-brand-700 rounded-lg text-sm"
>
{topic}
<button
type="button"
onClick={() => removeTopic(topic)}
className="text-brand-400 hover:text-brand-600"
>
×
</button>
</span>
))}
</div> </div>
)} )}
</div>
{/* Visual style */} {/* Basic info */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4"> <div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider"> <h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Stile visivo Informazioni base
</h3> </h3>
<div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1"> <label className="block text-sm font-medium text-slate-700 mb-1">
Colore primario Nome personaggio
</label> </label>
<div className="flex items-center gap-2"> <input
<input type="text"
type="color" value={form.name}
value={form.visual_style.primary_color} onChange={(e) => handleChange('name', e.target.value)}
onChange={(e) => handleStyleChange('primary_color', e.target.value)} placeholder="Es. TechGuru, FoodBlogger..."
className="w-10 h-10 rounded border border-slate-200 cursor-pointer" 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
<input />
type="text"
value={form.visual_style.primary_color}
onChange={(e) => handleStyleChange('primary_color', e.target.value)}
className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm font-mono"
/>
</div>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1"> <label className="block text-sm font-medium text-slate-700 mb-1">
Colore secondario Niche / Settore
</label> </label>
<div className="flex items-center gap-2"> <input
<input type="text"
type="color" value={form.niche}
value={form.visual_style.secondary_color} onChange={(e) => handleChange('niche', e.target.value)}
onChange={(e) => handleStyleChange('secondary_color', e.target.value)} placeholder="Es. Tecnologia, Food, Fitness..."
className="w-10 h-10 rounded border border-slate-200 cursor-pointer" 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
<input />
type="text"
value={form.visual_style.secondary_color}
onChange={(e) => handleStyleChange('secondary_color', e.target.value)}
className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm font-mono"
/>
</div>
</div> </div>
</div>
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1"> <label className="block text-sm font-medium text-slate-700 mb-1">
Font preferito Tono di comunicazione
</label> </label>
<input <textarea
type="text" value={form.tone}
value={form.visual_style.font} onChange={(e) => handleChange('tone', e.target.value)}
onChange={(e) => handleStyleChange('font', e.target.value)} placeholder="Descrivi lo stile di comunicazione: informale, professionale, ironico, motivazionale..."
placeholder="Es. Montserrat, Poppins, Inter..." rows={3}
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" 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 resize-none"
/> />
</div> </div>
{/* Preview */}
<div className="mt-4 p-4 rounded-lg border border-dashed border-slate-300">
<p className="text-xs text-slate-400 mb-2">Anteprima</p>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div <label className="relative inline-flex items-center cursor-pointer">
className="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold" <input
style={{ backgroundColor: form.visual_style.primary_color }} type="checkbox"
checked={form.is_active}
onChange={(e) => handleChange('is_active', e.target.checked)}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-brand-500"></div>
</label>
<span className="text-sm text-slate-700">Attivo</span>
</div>
</div>
{/* Topics */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Topic ricorrenti
</h3>
<div className="flex gap-2">
<input
type="text"
value={topicInput}
onChange={(e) => 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"
/>
<button
type="button"
onClick={addTopic}
className="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg text-sm font-medium transition-colors"
> >
{form.name?.charAt(0)?.toUpperCase() || '?'} Aggiungi
</button>
</div>
{form.topics.length > 0 && (
<div className="flex flex-wrap gap-2">
{form.topics.map((topic) => (
<span
key={topic}
className="inline-flex items-center gap-1.5 px-3 py-1 bg-brand-50 text-brand-700 rounded-lg text-sm"
>
{topic}
<button
type="button"
onClick={() => removeTopic(topic)}
className="text-brand-400 hover:text-brand-600"
>
×
</button>
</span>
))}
</div> </div>
)}
</div>
{/* Visual style */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Stile visivo
</h3>
<div className="grid grid-cols-2 gap-4">
<div> <div>
<p className="font-semibold text-sm" style={{ color: form.visual_style.secondary_color }}> <label className="block text-sm font-medium text-slate-700 mb-1">
{form.name || 'Nome personaggio'} Colore primario
</p> </label>
<p className="text-xs text-slate-400">{form.niche || 'Niche'}</p> <div className="flex items-center gap-2">
<input
type="color"
value={form.visual_style.primary_color}
onChange={(e) => handleStyleChange('primary_color', e.target.value)}
className="w-10 h-10 rounded border border-slate-200 cursor-pointer"
/>
<input
type="text"
value={form.visual_style.primary_color}
onChange={(e) => handleStyleChange('primary_color', e.target.value)}
className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm font-mono"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Colore secondario
</label>
<div className="flex items-center gap-2">
<input
type="color"
value={form.visual_style.secondary_color}
onChange={(e) => handleStyleChange('secondary_color', e.target.value)}
className="w-10 h-10 rounded border border-slate-200 cursor-pointer"
/>
<input
type="text"
value={form.visual_style.secondary_color}
onChange={(e) => handleStyleChange('secondary_color', e.target.value)}
className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm font-mono"
/>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Font preferito
</label>
<input
type="text"
value={form.visual_style.font}
onChange={(e) => handleStyleChange('font', e.target.value)}
placeholder="Es. Montserrat, Poppins, Inter..."
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"
/>
</div>
{/* Preview */}
<div className="mt-4 p-4 rounded-lg border border-dashed border-slate-300">
<p className="text-xs text-slate-400 mb-2">Anteprima</p>
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold"
style={{ backgroundColor: form.visual_style.primary_color }}
>
{form.name?.charAt(0)?.toUpperCase() || '?'}
</div>
<div>
<p className="font-semibold text-sm" style={{ color: form.visual_style.secondary_color }}>
{form.name || 'Nome personaggio'}
</p>
<p className="text-xs text-slate-400">{form.niche || 'Niche'}</p>
</div>
</div> </div>
</div> </div>
</div> </div>
</div>
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
type="submit" type="submit"
disabled={saving} disabled={saving}
className="px-6 py-2.5 bg-brand-600 hover:bg-brand-700 disabled:bg-brand-300 text-white font-medium rounded-lg transition-colors text-sm" className="px-6 py-2.5 bg-brand-600 hover:bg-brand-700 disabled:bg-brand-300 text-white font-medium rounded-lg transition-colors text-sm"
> >
{saving ? 'Salvataggio...' : isEdit ? 'Salva modifiche' : 'Crea personaggio'} {saving ? 'Salvataggio...' : isEdit ? 'Salva modifiche' : 'Crea personaggio'}
</button> </button>
<button <button
type="button" type="button"
onClick={() => navigate('/characters')} onClick={() => navigate('/characters')}
className="px-6 py-2.5 bg-white hover:bg-slate-50 text-slate-600 font-medium rounded-lg border border-slate-200 transition-colors text-sm" className="px-6 py-2.5 bg-white hover:bg-slate-50 text-slate-600 font-medium rounded-lg border border-slate-200 transition-colors text-sm"
> >
Annulla Annulla
</button> </button>
</div>
</form>
)}
{activeTab === 'social' && isEdit && (
<div className="max-w-2xl space-y-4">
{PLATFORMS.map((platform) => {
const account = socialAccounts[platform.id]
const isConnected = Boolean(account?.access_token)
const locked = platform.proOnly && !isPro
const guideOpen = expandedGuide === platform.id
return (
<div
key={platform.id}
className="bg-white rounded-xl border border-slate-200 p-5"
>
{/* Platform header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<span style={{ fontSize: '1.5rem' }}>{platform.icon}</span>
<div>
<div className="flex items-center gap-2">
<span className="font-semibold text-slate-800">{platform.name}</span>
{locked && (
<span className="text-xs px-2 py-0.5 rounded-full font-semibold" style={{ backgroundColor: '#FFF5F3', color: '#FF6B4A' }}>
🔒 Piano Pro
</span>
)}
{!locked && (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${isConnected ? 'bg-emerald-50 text-emerald-700' : 'bg-slate-100 text-slate-400'}`}>
{isConnected ? '● Connesso' : '○ Non connesso'}
</span>
)}
</div>
{account?.account_name && (
<p className="text-xs text-slate-400">{account.account_name}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{locked ? (
<button
disabled
className="px-3 py-1.5 text-xs font-medium rounded-lg opacity-40 cursor-not-allowed"
style={{ backgroundColor: '#F1F5F9', color: '#64748B' }}
>
Disponibile con Pro
</button>
) : isConnected ? (
<button
onClick={() => handleDisconnect(platform.id)}
className="px-3 py-1.5 text-xs font-medium rounded-lg text-red-600 hover:bg-red-50 border border-red-200"
>
Disconnetti
</button>
) : null}
{!locked && (
<button
type="button"
onClick={() => setExpandedGuide(guideOpen ? null : platform.id)}
className="px-3 py-1.5 text-xs font-medium rounded-lg text-slate-500 hover:bg-slate-100 border border-slate-200"
>
{guideOpen ? '▲ Nascondi guida' : '▼ Guida setup'}
</button>
)}
</div>
</div>
{/* Guide accordion */}
{guideOpen && !locked && (
<div className="mb-4 p-4 rounded-lg" style={{ backgroundColor: '#F8FAFC', border: '1px solid #E2E8F0' }}>
<h4 className="text-xs font-bold text-slate-600 uppercase tracking-wide mb-2">
Come connettere {platform.name}
</h4>
<ol className="space-y-1.5">
{platform.guide.map((step, i) => (
<li key={i} className="text-xs text-slate-600 flex gap-2">
<span className="font-bold text-slate-400 flex-shrink-0">{i + 1}.</span>
<span>{step}</span>
</li>
))}
</ol>
<div className="mt-3 p-2.5 rounded" style={{ backgroundColor: '#FFF8E1', border: '1px solid #FFE082' }}>
<p className="text-xs text-amber-700">
<strong>Nota:</strong> L'integrazione OAuth diretta è in arrivo. Per ora, copia manualmente il token nei campi sottostanti.
</p>
</div>
</div>
)}
{/* Manual token input */}
{!locked && (
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">
Access Token{platform.id === 'facebook' || platform.id === 'instagram' ? ' (Page/User Access Token)' : ''}
</label>
<input
type="password"
value={tokenInputs[platform.id] || ''}
onChange={(e) => setTokenInputs((prev) => ({ ...prev, [platform.id]: e.target.value }))}
placeholder={isConnected ? ' (token già salvato)' : 'Incolla il token qui...'}
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-xs font-mono focus:outline-none"
/>
</div>
{(platform.id === 'facebook' || platform.id === 'youtube') && (
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">
{platform.id === 'facebook' ? 'Page ID' : 'Channel ID'}
</label>
<input
type="text"
value={pageIdInputs[platform.id] || ''}
onChange={(e) => setPageIdInputs((prev) => ({ ...prev, [platform.id]: e.target.value }))}
placeholder={isConnected && account?.page_id ? account.page_id : 'Es. 123456789'}
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-xs font-mono focus:outline-none"
/>
</div>
)}
<button
type="button"
onClick={() => handleSaveToken(platform.id)}
disabled={savingToken[platform.id] || !tokenInputs[platform.id]?.trim()}
className="px-4 py-2 text-xs font-medium rounded-lg text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
style={{ backgroundColor: '#FF6B4A' }}
>
{savingToken[platform.id] ? 'Salvataggio...' : 'Salva Token'}
</button>
</div>
)}
</div>
)
})}
</div> </div>
</form> )}
</div> </div>
) )
} }

View File

@@ -1,8 +1,11 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { api } from '../api' import { api } from '../api'
import { useAuth } from '../AuthContext'
import PlanBanner from './PlanBanner'
export default function Dashboard() { export default function Dashboard() {
const { user, isAdmin } = useAuth()
const [stats, setStats] = useState({ const [stats, setStats] = useState({
characters: 0, characters: 0,
active: 0, active: 0,
@@ -43,13 +46,26 @@ export default function Dashboard() {
return ( return (
<div> <div>
<h2 className="text-2xl font-bold mb-1" style={{ color: 'var(--ink)' }}> <div className="flex items-center justify-between mb-1">
Dashboard <h2 className="text-2xl font-bold" style={{ color: 'var(--ink)' }}>
</h2> Dashboard
<p className="text-sm mb-5" style={{ color: 'var(--muted)' }}> </h2>
Panoramica Leopost Full {isAdmin && (
<Link
to="/admin"
className="px-3 py-1 text-xs font-semibold rounded-lg"
style={{ backgroundColor: '#FEF3C7', color: '#D97706', border: '1px solid #FDE68A' }}
>
Admin Settings
</Link>
)}
</div>
<p className="text-sm mb-3" style={{ color: 'var(--muted)' }}>
{user?.display_name ? `Ciao, ${user.display_name}` : ''}Panoramica Leopost Full
</p> </p>
<PlanBanner />
{/* Stats grid */} {/* Stats grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mt-5"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mt-5">
<StatCard label="Personaggi" value={loading ? '—' : stats.characters} sub={`${stats.active} attivi`} accentColor="var(--coral)" /> <StatCard label="Personaggi" value={loading ? '—' : stats.characters} sub={`${stats.active} attivi`} accentColor="var(--coral)" />

View File

@@ -1,13 +1,24 @@
import { useState } from 'react' import { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useAuth } from '../AuthContext' import { useAuth } from '../AuthContext'
import { BASE_URL } from '../api'
const CORAL = '#FF6B4A'
const CREAM = '#FAF8F3'
const INK = '#1A1A2E'
const MUTED = '#888'
const BORDER = '#E8E4DC'
export default function LoginPage() { export default function LoginPage() {
const [username, setUsername] = useState('') const [mode, setMode] = useState('login') // 'login' | 'register'
const [email, setEmail] = useState('')
const [displayName, setDisplayName] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const { login } = useAuth() const [showRedeemModal, setShowRedeemModal] = useState(false)
const { login, register } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
@@ -15,95 +26,427 @@ export default function LoginPage() {
setError('') setError('')
setLoading(true) setLoading(true)
try { try {
await login(username, password) if (mode === 'login') {
await login(email, password)
} else {
await register(email, password, displayName)
}
navigate('/') navigate('/')
} catch (err) { } catch (err) {
setError(err.message || 'Login failed') setError(err.message || (mode === 'login' ? 'Credenziali non valide' : 'Errore durante la registrazione'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
const handleGoogleLogin = () => {
window.location.href = `${BASE_URL}/auth/oauth/google`
}
const comingSoon = (e) => {
e.preventDefault()
// tooltip handled via title attr
}
return ( return (
<div <div style={{ display: 'flex', height: '100vh', fontFamily: 'Inter, sans-serif' }}>
className="min-h-screen flex items-center justify-center px-4" {/* LEFT SIDE */}
style={{ backgroundColor: 'var(--ink)' }} <div style={{
> width: '40%',
<div className="w-full max-w-sm"> backgroundColor: CORAL,
<div className="text-center mb-8"> padding: '3rem',
<h1 className="text-3xl font-bold text-white tracking-tight font-serif"> display: 'flex',
Leopost <span style={{ color: 'var(--coral)' }}>Full</span> flexDirection: 'column',
justifyContent: 'space-between',
color: 'white',
}}>
<div>
<h1 style={{
fontFamily: 'Georgia, serif',
fontSize: '2.8rem',
fontWeight: 700,
margin: 0,
letterSpacing: '-1px',
}}>
Leopost
</h1> </h1>
<p className="mt-2 text-sm" style={{ color: 'var(--muted)' }}> <p style={{ fontSize: '1.05rem', marginTop: '0.5rem', opacity: 0.9, lineHeight: 1.4 }}>
Content Automation Platform Il tuo studio editoriale AI per i social
</p> </p>
<ul style={{ marginTop: '2.5rem', listStyle: 'none', padding: 0, lineHeight: 2 }}>
<li style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
<span style={{ fontSize: '1.2rem' }}></span> Genera contenuti AI per ogni piattaforma
</li>
<li style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
<span style={{ fontSize: '1.2rem' }}></span> Pubblica su Facebook, Instagram, YouTube, TikTok
</li>
<li style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
<span style={{ fontSize: '1.2rem' }}></span> Schedula in automatico con piani editoriali
</li>
<li style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
<span style={{ fontSize: '1.2rem' }}></span> Gestisci commenti con risposte AI
</li>
<li style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
<span style={{ fontSize: '1.2rem' }}></span> Link affiliati integrati nei post
</li>
</ul>
</div> </div>
<form <div style={{
onSubmit={handleSubmit} backgroundColor: 'rgba(255,255,255,0.15)',
className="rounded-xl p-8 shadow-xl" borderRadius: '12px',
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }} padding: '1rem 1.5rem',
> fontSize: '0.85rem',
{error && ( backdropFilter: 'blur(4px)',
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm"> }}>
{error} <strong>Early Adopter Beta</strong> Unisciti ora e ottieni un accesso esclusivo al piano Pro a prezzo speciale.
</div> </div>
)} </div>
<div className="space-y-4"> {/* RIGHT SIDE */}
<div> <div style={{
<label flex: 1,
className="block text-sm font-medium mb-1" backgroundColor: CREAM,
style={{ color: 'var(--ink)' }} display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2rem',
}}>
<div style={{ width: '100%', maxWidth: '420px' }}>
{/* Toggle */}
<div style={{
display: 'flex',
backgroundColor: 'white',
borderRadius: '10px',
border: `1px solid ${BORDER}`,
padding: '4px',
marginBottom: '1.5rem',
}}>
{['login', 'register'].map((m) => (
<button
key={m}
onClick={() => { setMode(m); setError('') }}
style={{
flex: 1,
padding: '0.5rem',
border: 'none',
borderRadius: '7px',
cursor: 'pointer',
fontWeight: 600,
fontSize: '0.875rem',
transition: 'all 0.2s',
backgroundColor: mode === m ? CORAL : 'transparent',
color: mode === m ? 'white' : MUTED,
}}
> >
Username {m === 'login' ? 'Accedi' : 'Registrati'}
</button>
))}
</div>
<form onSubmit={handleSubmit} style={{
backgroundColor: 'white',
borderRadius: '16px',
padding: '2rem',
border: `1px solid ${BORDER}`,
boxShadow: '0 4px 24px rgba(0,0,0,0.06)',
}}>
{error && (
<div style={{
marginBottom: '1rem',
padding: '0.75rem 1rem',
backgroundColor: '#FEE2E2',
border: '1px solid #FECACA',
borderRadius: '8px',
color: '#DC2626',
fontSize: '0.875rem',
}}>
{error}
</div>
)}
{mode === 'register' && (
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', fontSize: '0.8rem', fontWeight: 600, color: INK, marginBottom: '0.4rem' }}>
Nome visualizzato
</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Il tuo nome o nickname"
style={inputStyle}
/>
</div>
)}
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', fontSize: '0.8rem', fontWeight: 600, color: INK, marginBottom: '0.4rem' }}>
Email
</label> </label>
<input <input
type="text" type="email"
value={username} value={email}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-2.5 rounded-lg text-sm focus:outline-none" placeholder="tu@esempio.it"
style={{
border: '1px solid var(--border)',
color: 'var(--ink)',
backgroundColor: 'var(--cream)',
}}
required required
style={inputStyle}
/> />
</div> </div>
<div> <div style={{ marginBottom: '1.5rem' }}>
<label <label style={{ display: 'block', fontSize: '0.8rem', fontWeight: 600, color: INK, marginBottom: '0.4rem' }}>
className="block text-sm font-medium mb-1"
style={{ color: 'var(--ink)' }}
>
Password Password
</label> </label>
<input <input
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2.5 rounded-lg text-sm focus:outline-none" placeholder="••••••••"
style={{
border: '1px solid var(--border)',
color: 'var(--ink)',
backgroundColor: 'var(--cream)',
}}
required required
style={inputStyle}
/> />
</div> </div>
<button
type="submit"
disabled={loading}
style={{
width: '100%',
padding: '0.75rem',
backgroundColor: CORAL,
color: 'white',
border: 'none',
borderRadius: '8px',
fontWeight: 600,
fontSize: '0.9rem',
cursor: loading ? 'not-allowed' : 'pointer',
opacity: loading ? 0.7 : 1,
transition: 'opacity 0.2s',
}}
>
{loading ? 'Caricamento...' : mode === 'login' ? 'Accedi' : 'Crea account'}
</button>
</form>
{/* Divider */}
<div style={{ display: 'flex', alignItems: 'center', margin: '1.25rem 0', gap: '1rem' }}>
<div style={{ flex: 1, height: 1, backgroundColor: BORDER }} />
<span style={{ color: MUTED, fontSize: '0.8rem' }}>oppure</span>
<div style={{ flex: 1, height: 1, backgroundColor: BORDER }} />
</div> </div>
<button {/* Social login buttons */}
type="submit" <div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
disabled={loading} {/* Google */}
className="mt-6 w-full py-2.5 text-white font-medium rounded-lg transition-opacity text-sm" <button
style={{ backgroundColor: 'var(--coral)', opacity: loading ? 0.7 : 1 }} onClick={handleGoogleLogin}
> style={{
{loading ? 'Accesso...' : 'Accedi'} display: 'flex',
</button> alignItems: 'center',
</form> justifyContent: 'center',
gap: '0.75rem',
width: '100%',
padding: '0.7rem',
backgroundColor: 'white',
border: `1px solid ${BORDER}`,
borderRadius: '8px',
cursor: 'pointer',
fontWeight: 500,
fontSize: '0.875rem',
color: INK,
transition: 'box-shadow 0.2s',
}}
>
<GoogleIcon />
Continua con Google
</button>
{/* Coming soon row */}
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'center' }}>
{[
{ name: 'Facebook', icon: '📘' },
{ name: 'Microsoft', icon: '🪟' },
{ name: 'Apple', icon: '🍎' },
{ name: 'Instagram', icon: '📸' },
{ name: 'TikTok', icon: '🎵' },
].map(({ name, icon }) => (
<button
key={name}
onClick={comingSoon}
title={`${name} — Disponibile a breve`}
style={{
padding: '0.5rem 0.75rem',
backgroundColor: '#F5F5F5',
border: `1px solid ${BORDER}`,
borderRadius: '8px',
cursor: 'not-allowed',
fontSize: '1rem',
opacity: 0.5,
position: 'relative',
}}
>
{icon}
</button>
))}
</div>
<p style={{ textAlign: 'center', fontSize: '0.75rem', color: MUTED, margin: '0.25rem 0 0' }}>
Altri provider disponibili a breve
</p>
</div>
{/* Redeem code link */}
<p style={{ textAlign: 'center', marginTop: '1.5rem', fontSize: '0.85rem', color: MUTED }}>
Hai un codice Pro?{' '}
<button
onClick={() => setShowRedeemModal(true)}
style={{
background: 'none',
border: 'none',
color: CORAL,
cursor: 'pointer',
fontWeight: 600,
fontSize: '0.85rem',
padding: 0,
}}
>
Riscattalo
</button>
</p>
{showRedeemModal && (
<RedeemModal onClose={() => setShowRedeemModal(false)} />
)}
</div>
</div> </div>
</div> </div>
) )
} }
function RedeemModal({ onClose }) {
const [code, setCode] = useState('')
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const { login } = useAuth()
const handleRedeem = async (e) => {
e.preventDefault()
setLoading(true)
setError('')
setMessage('')
try {
const { api } = await import('../api')
const result = await api.post('/auth/redeem', { code })
setMessage(`Codice riscattato! Piano Pro attivo fino al ${new Date(result.subscription_expires_at).toLocaleDateString('it-IT')}.`)
} catch (err) {
setError(err.message || 'Codice non valido o già utilizzato.')
} finally {
setLoading(false)
}
}
return (
<div style={{
position: 'fixed', inset: 0,
backgroundColor: 'rgba(0,0,0,0.4)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 1000,
}}>
<div style={{
backgroundColor: 'white',
borderRadius: '16px',
padding: '2rem',
width: '380px',
maxWidth: '90vw',
boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
}}>
<h3 style={{ margin: '0 0 0.5rem', fontSize: '1.1rem', color: INK }}>Riscatta Codice Pro</h3>
<p style={{ margin: '0 0 1.25rem', fontSize: '0.85rem', color: MUTED }}>
Inserisci il tuo codice di attivazione (es. LP-XXXXXXXX)
</p>
{message ? (
<div style={{ padding: '0.75rem', backgroundColor: '#F0FDF4', border: '1px solid #86EFAC', borderRadius: '8px', color: '#16A34A', fontSize: '0.875rem', marginBottom: '1rem' }}>
{message}
</div>
) : (
<form onSubmit={handleRedeem}>
{error && (
<div style={{ padding: '0.75rem', backgroundColor: '#FEE2E2', border: '1px solid #FECACA', borderRadius: '8px', color: '#DC2626', fontSize: '0.875rem', marginBottom: '0.75rem' }}>
{error}
</div>
)}
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
placeholder="LP-XXXXXXXXXXXXXXXX"
required
style={{ ...inputStyle, marginBottom: '0.75rem', fontFamily: 'monospace', letterSpacing: '0.05em' }}
/>
<button
type="submit"
disabled={loading}
style={{
width: '100%',
padding: '0.7rem',
backgroundColor: '#FF6B4A',
color: 'white',
border: 'none',
borderRadius: '8px',
fontWeight: 600,
cursor: loading ? 'not-allowed' : 'pointer',
opacity: loading ? 0.7 : 1,
}}
>
{loading ? 'Verifica...' : 'Riscatta'}
</button>
</form>
)}
<button
onClick={onClose}
style={{
marginTop: '1rem',
width: '100%',
padding: '0.6rem',
backgroundColor: 'transparent',
border: `1px solid ${BORDER}`,
borderRadius: '8px',
cursor: 'pointer',
color: MUTED,
fontSize: '0.875rem',
}}
>
Chiudi
</button>
</div>
</div>
)
}
function GoogleIcon() {
return (
<svg width="18" height="18" viewBox="0 0 48 48">
<path fill="#FFC107" d="M43.6 20H24v8h11.3C33.6 33.1 29.3 36 24 36c-6.6 0-12-5.4-12-12s5.4-12 12-12c3.1 0 5.8 1.2 8 3l5.7-5.7C34.2 6.6 29.3 4.5 24 4.5 12.7 4.5 3.5 13.7 3.5 25S12.7 45.5 24 45.5c10.5 0 19.5-7.6 19.5-21 0-1.2-.1-2.4-.4-3.5z" />
<path fill="#FF3D00" d="M6.3 14.7l6.6 4.8C14.6 15.1 19 12 24 12c3.1 0 5.8 1.2 8 3l5.7-5.7C34.2 6.6 29.3 4.5 24 4.5c-7.7 0-14.4 4.4-17.7 10.2z" />
<path fill="#4CAF50" d="M24 45.5c5.2 0 9.9-1.9 13.5-5l-6.2-5.2C29.3 37 26.8 38 24 38c-5.3 0-9.7-3-11.3-7.4l-6.6 5.1C9.6 41.1 16.3 45.5 24 45.5z" />
<path fill="#1976D2" d="M43.6 20H24v8h11.3c-.7 2.1-2 3.9-3.7 5.2l6.2 5.2c3.7-3.4 5.7-8.4 5.7-13.4 0-1.2-.1-2.4-.4-3.5z" />
</svg>
)
}
const inputStyle = {
width: '100%',
padding: '0.65rem 0.9rem',
border: `1px solid #E8E4DC`,
borderRadius: '8px',
fontSize: '0.875rem',
outline: 'none',
boxSizing: 'border-box',
color: '#1A1A2E',
backgroundColor: '#FAF8F3',
}

View File

@@ -0,0 +1,93 @@
import { useState } from 'react'
import { useAuth } from '../AuthContext'
import UpgradeModal from './UpgradeModal'
export default function PlanBanner() {
const { user, isPro } = useAuth()
const [showUpgrade, setShowUpgrade] = useState(false)
if (!user) return null
if (isPro) {
const expires = user.subscription_expires_at
? new Date(user.subscription_expires_at).toLocaleDateString('it-IT')
: null
return (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.75rem 1rem',
backgroundColor: '#ECFDF5',
border: '1px solid #A7F3D0',
borderRadius: '10px',
marginBottom: '1.25rem',
}}>
<span style={{ fontSize: '1.1rem' }}></span>
<div>
<span style={{ fontWeight: 600, fontSize: '0.875rem', color: '#065F46' }}>Piano Pro</span>
{expires && (
<span style={{ fontSize: '0.8rem', color: '#059669', marginLeft: '0.5rem' }}>
Attivo fino al {expires}
</span>
)}
</div>
</div>
)
}
const postsUsed = user.posts_generated_this_month || 0
const postsMax = 15
return (
<>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0.75rem 1rem',
backgroundColor: '#FFF7F5',
border: '1px solid #FFCBB8',
borderRadius: '10px',
marginBottom: '1.25rem',
flexWrap: 'wrap',
gap: '0.5rem',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontSize: '1rem' }}>🆓</span>
<span style={{ fontWeight: 600, fontSize: '0.875rem', color: '#C2410C' }}>
Piano Freemium
</span>
<span style={{ fontSize: '0.8rem', color: '#9A3412' }}>
{postsUsed} post su {postsMax} usati questo mese
</span>
{/* progress bar */}
<div style={{ width: 80, height: 6, backgroundColor: '#FED7AA', borderRadius: 3, overflow: 'hidden', marginLeft: 4 }}>
<div style={{
height: '100%',
width: `${Math.min(100, (postsUsed / postsMax) * 100)}%`,
backgroundColor: '#F97316',
borderRadius: 3,
}} />
</div>
</div>
<button
onClick={() => setShowUpgrade(true)}
style={{
padding: '0.4rem 0.9rem',
backgroundColor: '#FF6B4A',
color: 'white',
border: 'none',
borderRadius: '7px',
cursor: 'pointer',
fontWeight: 600,
fontSize: '0.8rem',
}}
>
Passa a Pro
</button>
</div>
{showUpgrade && <UpgradeModal onClose={() => setShowUpgrade(false)} />}
</>
)
}

View File

@@ -0,0 +1,247 @@
import { useState } from 'react'
import { api } from '../api'
import { useAuth } from '../AuthContext'
const CORAL = '#FF6B4A'
const INK = '#1A1A2E'
const MUTED = '#888'
const BORDER = '#E8E4DC'
const PLANS = [
{ months: 1, label: '1 mese', price: '€14.95', pricePerMonth: '€14.95/mese' },
{ months: 3, label: '3 mesi', price: '€39.95', pricePerMonth: '€13.32/mese', badge: 'Risparmia 15%' },
{ months: 6, label: '6 mesi', price: '€64.95', pricePerMonth: '€10.83/mese', badge: 'Risparmia 28%' },
{ months: 12, label: '1 anno', price: '€119.95', pricePerMonth: '€9.99/mese', badge: 'Best Value ✦' },
]
const COMPARISON = [
{ feature: 'Personaggi', free: '1', pro: 'Illimitati' },
{ feature: 'Post al mese', free: '15', pro: 'Illimitati' },
{ feature: 'Piattaforme', free: 'FB + IG', pro: 'FB + IG + YT + TT' },
{ feature: 'Piani automatici', free: '✗', pro: '✓' },
{ feature: 'Gestione commenti AI', free: '✗', pro: '✓' },
{ feature: 'Link affiliati', free: '✗', pro: '✓' },
{ feature: 'Calendario editoriale', free: '5 slot', pro: 'Illimitato' },
{ feature: 'Priorità supporto', free: '✗', pro: '✓' },
]
export default function UpgradeModal({ onClose }) {
const [redeemCode, setRedeemCode] = useState('')
const [redeemLoading, setRedeemLoading] = useState(false)
const [redeemError, setRedeemError] = useState('')
const [redeemSuccess, setRedeemSuccess] = useState('')
const { loadUser } = useAuth()
const handleRedeem = async (e) => {
e.preventDefault()
setRedeemLoading(true)
setRedeemError('')
setRedeemSuccess('')
try {
const result = await api.post('/auth/redeem', { code: redeemCode })
setRedeemSuccess(`Piano Pro attivato fino al ${new Date(result.subscription_expires_at).toLocaleDateString('it-IT')}!`)
await loadUser()
} catch (err) {
setRedeemError(err.message || 'Codice non valido o già utilizzato.')
} finally {
setRedeemLoading(false)
}
}
return (
<div style={{
position: 'fixed', inset: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 1000,
padding: '1rem',
overflowY: 'auto',
}}>
<div style={{
backgroundColor: 'white',
borderRadius: '20px',
padding: '2.5rem',
width: '100%',
maxWidth: '680px',
maxHeight: '90vh',
overflowY: 'auto',
boxShadow: '0 25px 80px rgba(0,0,0,0.25)',
position: 'relative',
}}>
{/* Header */}
<div style={{ textAlign: 'center', marginBottom: '1.5rem' }}>
<span style={{
display: 'inline-block',
padding: '0.3rem 0.8rem',
backgroundColor: '#FFF3E0',
color: '#E65100',
borderRadius: '20px',
fontSize: '0.75rem',
fontWeight: 700,
letterSpacing: '0.05em',
marginBottom: '0.75rem',
}}>
EARLY ADOPTER BETA
</span>
<h2 style={{ margin: 0, fontSize: '1.8rem', color: INK, fontWeight: 700 }}>
Passa a Leopost Pro
</h2>
<p style={{ margin: '0.5rem 0 0', color: MUTED, fontSize: '0.9rem' }}>
Sblocca tutto il potenziale del tuo studio editoriale AI
</p>
</div>
{/* Comparison table */}
<div style={{
border: `1px solid ${BORDER}`,
borderRadius: '12px',
overflow: 'hidden',
marginBottom: '1.5rem',
}}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr' }}>
<div style={{ padding: '0.75rem 1rem', backgroundColor: '#F9F9F9', fontWeight: 700, fontSize: '0.8rem', color: MUTED, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Feature</div>
<div style={{ padding: '0.75rem 1rem', backgroundColor: '#F9F9F9', fontWeight: 700, fontSize: '0.8rem', color: MUTED, textTransform: 'uppercase', textAlign: 'center' }}>Freemium</div>
<div style={{ padding: '0.75rem 1rem', backgroundColor: '#FFF5F3', fontWeight: 700, fontSize: '0.8rem', color: CORAL, textTransform: 'uppercase', textAlign: 'center' }}>Pro </div>
</div>
{COMPARISON.map((row, i) => (
<div key={row.feature} style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr 1fr',
borderTop: `1px solid ${BORDER}`,
backgroundColor: i % 2 === 0 ? 'white' : '#FAFAFA',
}}>
<div style={{ padding: '0.6rem 1rem', fontSize: '0.85rem', color: INK }}>{row.feature}</div>
<div style={{ padding: '0.6rem 1rem', fontSize: '0.85rem', color: MUTED, textAlign: 'center' }}>{row.free}</div>
<div style={{ padding: '0.6rem 1rem', fontSize: '0.85rem', color: '#16A34A', fontWeight: 600, textAlign: 'center' }}>{row.pro}</div>
</div>
))}
</div>
{/* Pricing */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '0.75rem', marginBottom: '1.5rem' }}>
{PLANS.map((plan) => (
<div key={plan.months} style={{
border: `1px solid ${BORDER}`,
borderRadius: '10px',
padding: '1rem',
position: 'relative',
backgroundColor: plan.months === 12 ? '#FFF5F3' : 'white',
borderColor: plan.months === 12 ? CORAL : BORDER,
}}>
{plan.badge && (
<span style={{
position: 'absolute',
top: '-10px',
right: '10px',
backgroundColor: CORAL,
color: 'white',
padding: '0.15rem 0.5rem',
borderRadius: '10px',
fontSize: '0.7rem',
fontWeight: 700,
}}>
{plan.badge}
</span>
)}
<div style={{ fontWeight: 700, color: INK, marginBottom: '0.25rem' }}>{plan.label}</div>
<div style={{ fontSize: '1.4rem', fontWeight: 800, color: plan.months === 12 ? CORAL : INK }}>{plan.price}</div>
<div style={{ fontSize: '0.75rem', color: MUTED }}>{plan.pricePerMonth}</div>
</div>
))}
</div>
{/* CTA */}
<a
href="mailto:info@leopost.it?subject=Richiesta Piano Pro - Early Adopter&body=Salve, sono interessato al piano Pro di Leopost. Potete contattarmi per i dettagli?"
style={{
display: 'block',
textAlign: 'center',
padding: '0.9rem',
backgroundColor: CORAL,
color: 'white',
borderRadius: '10px',
fontWeight: 700,
fontSize: '0.95rem',
textDecoration: 'none',
marginBottom: '1.5rem',
}}
>
Contattaci per attivare Pro Early Adopter
</a>
{/* Redeem code */}
<div style={{
border: `1px solid ${BORDER}`,
borderRadius: '10px',
padding: '1.25rem',
backgroundColor: '#FAFAFA',
}}>
<h4 style={{ margin: '0 0 0.75rem', fontSize: '0.9rem', color: INK }}>Hai già un codice?</h4>
{redeemSuccess ? (
<div style={{ padding: '0.75rem', backgroundColor: '#F0FDF4', border: '1px solid #86EFAC', borderRadius: '8px', color: '#16A34A', fontSize: '0.875rem' }}>
{redeemSuccess}
</div>
) : (
<form onSubmit={handleRedeem} style={{ display: 'flex', gap: '0.5rem' }}>
<input
type="text"
value={redeemCode}
onChange={(e) => setRedeemCode(e.target.value.toUpperCase())}
placeholder="LP-XXXXXXXXXXXXXXXX"
style={{
flex: 1,
padding: '0.6rem 0.75rem',
border: `1px solid ${BORDER}`,
borderRadius: '7px',
fontFamily: 'monospace',
fontSize: '0.875rem',
outline: 'none',
}}
/>
<button
type="submit"
disabled={redeemLoading || !redeemCode.trim()}
style={{
padding: '0.6rem 1rem',
backgroundColor: INK,
color: 'white',
border: 'none',
borderRadius: '7px',
cursor: redeemLoading ? 'not-allowed' : 'pointer',
fontWeight: 600,
fontSize: '0.85rem',
opacity: redeemLoading ? 0.7 : 1,
whiteSpace: 'nowrap',
}}
>
{redeemLoading ? '...' : 'Riscatta'}
</button>
</form>
)}
{redeemError && (
<p style={{ margin: '0.5rem 0 0', color: '#DC2626', fontSize: '0.8rem' }}>{redeemError}</p>
)}
</div>
{/* Close button */}
<button
onClick={onClose}
style={{
position: 'absolute',
top: '1rem',
right: '1rem',
background: 'none',
border: 'none',
fontSize: '1.4rem',
cursor: 'pointer',
color: MUTED,
lineHeight: 1,
}}
>
×
</button>
</div>
</div>
)
}

View File

@@ -3,7 +3,7 @@ import react from '@vitejs/plugin-react'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
base: '/leopost-full/', base: process.env.VITE_BASE_PATH !== undefined ? (process.env.VITE_BASE_PATH || '/') : '/leopost-full/',
server: { server: {
port: 5173, port: 5173,
proxy: { proxy: {
@@ -12,6 +12,10 @@ export default defineConfig({
changeOrigin: true, changeOrigin: true,
rewrite: (path) => path.replace(/^\/leopost-full/, ''), rewrite: (path) => path.replace(/^\/leopost-full/, ''),
}, },
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
}, },
}, },
build: { build: {