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:
@@ -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/"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
49
backend/app/plan_limits.py
Normal file
49
backend/app/plan_limits.py
Normal 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, ""
|
||||||
113
backend/app/routers/admin.py
Normal file
113
backend/app/routers/admin.py
Normal 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
|
||||||
@@ -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
277
backend/app/routers/auth.py
Normal 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(),
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
29
docker-compose.prod.yml
Normal 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
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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="/" />} />
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
344
frontend/src/components/AdminSettings.jsx
Normal file
344
frontend/src/components/AdminSettings.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
37
frontend/src/components/AuthCallback.jsx
Normal file
37
frontend/src/components/AuthCallback.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)" />
|
||||||
|
|||||||
@@ -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',
|
||||||
|
}
|
||||||
|
|||||||
93
frontend/src/components/PlanBanner.jsx
Normal file
93
frontend/src/components/PlanBanner.jsx
Normal 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)} />}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
247
frontend/src/components/UpgradeModal.jsx
Normal file
247
frontend/src/components/UpgradeModal.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user