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"
|
||||
},
|
||||
"vps": {
|
||||
"deployed": false,
|
||||
"deployed": true,
|
||||
"url": "https://lab.mlhub.it/leopost-full/",
|
||||
"last_deploy": "2026-03-31T15:26:00Z",
|
||||
"container": "lab-leopost-full-app",
|
||||
"path": "/opt/lab-leopost-full/"
|
||||
},
|
||||
|
||||
@@ -4,6 +4,10 @@ WORKDIR /app/frontend
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm install
|
||||
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
|
||||
|
||||
# Stage 2: Python backend + frontend built
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Core authentication utilities used throughout the application."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import bcrypt
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import Depends, HTTPException
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -9,9 +11,7 @@ from sqlalchemy.orm import Session
|
||||
from .config import settings
|
||||
from .database import get_db
|
||||
from .models import User
|
||||
from .schemas import LoginRequest, Token
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
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")
|
||||
|
||||
|
||||
def get_user_by_email(db: Session, email: str) -> User | None:
|
||||
return db.query(User).filter(User.email == email).first()
|
||||
|
||||
|
||||
def get_current_user(
|
||||
token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)
|
||||
) -> User:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"])
|
||||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
# Support both sub (user_id) and legacy username
|
||||
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")
|
||||
except JWTError:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
|
||||
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:
|
||||
raise HTTPException(status_code=401, detail="User not found")
|
||||
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):
|
||||
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_password: str = "changeme"
|
||||
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:
|
||||
env_file = ".env"
|
||||
extra = "ignore"
|
||||
|
||||
|
||||
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 .config import settings
|
||||
@@ -18,3 +18,69 @@ def get_db():
|
||||
yield db
|
||||
finally:
|
||||
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 .auth import hash_password
|
||||
from .auth import router as auth_router
|
||||
from .config import settings
|
||||
from .database import Base, SessionLocal, engine
|
||||
from .database import Base, SessionLocal, engine, run_migrations
|
||||
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.characters import router as characters_router
|
||||
from .routers.comments import router as comments_router
|
||||
@@ -78,10 +79,13 @@ async def lifespan(app: FastAPI):
|
||||
data_dir = Path("./data")
|
||||
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)
|
||||
|
||||
# Create admin user if not exists
|
||||
# Create or update admin user
|
||||
db = SessionLocal()
|
||||
try:
|
||||
existing = db.query(User).filter(User.username == settings.admin_username).first()
|
||||
@@ -89,9 +93,28 @@ async def lifespan(app: FastAPI):
|
||||
admin = User(
|
||||
username=settings.admin_username,
|
||||
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.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:
|
||||
db.close()
|
||||
|
||||
@@ -114,13 +137,17 @@ async def lifespan(app: FastAPI):
|
||||
# CRITICAL: Do NOT pass root_path here — use Uvicorn --root-path instead.
|
||||
app = FastAPI(
|
||||
title="Leopost Full",
|
||||
version="0.1.0",
|
||||
version="0.2.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:5173"],
|
||||
allow_origins=[
|
||||
"http://localhost:5173",
|
||||
"https://leopost.it",
|
||||
"https://www.leopost.it",
|
||||
],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
@@ -131,6 +158,7 @@ app.add_middleware(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
app.include_router(auth_router)
|
||||
app.include_router(admin_router)
|
||||
app.include_router(characters_router)
|
||||
app.include_router(content_router)
|
||||
app.include_router(affiliates_router)
|
||||
@@ -143,7 +171,7 @@ app.include_router(editorial_router)
|
||||
|
||||
@app.get("/api/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 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
|
||||
|
||||
@@ -15,6 +15,30 @@ class User(Base):
|
||||
hashed_password = Column(String(255), nullable=False)
|
||||
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):
|
||||
__tablename__ = "characters"
|
||||
@@ -31,6 +55,7 @@ class Character(Base):
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=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 ===
|
||||
@@ -53,6 +78,7 @@ class Post(Base):
|
||||
status = Column(String(20), default="draft") # draft, approved, scheduled, published, failed
|
||||
created_at = Column(DateTime, default=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 ===
|
||||
@@ -70,6 +96,7 @@ class AffiliateLink(Base):
|
||||
is_active = Column(Boolean, default=True)
|
||||
click_count = Column(Integer, default=0)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
|
||||
# === Phase 5: Scheduling ===
|
||||
@@ -90,6 +117,7 @@ class EditorialPlan(Base):
|
||||
is_active = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
|
||||
class ScheduledPost(Base):
|
||||
@@ -124,6 +152,7 @@ class SocialAccount(Base):
|
||||
extra_data = Column(JSON, default=dict) # platform-specific data
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
|
||||
# === Phase 11: Comment Management ===
|
||||
@@ -151,6 +180,7 @@ class SystemSetting(Base):
|
||||
__tablename__ = "system_settings"
|
||||
|
||||
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)
|
||||
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 ..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
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/affiliates",
|
||||
tags=["affiliates"],
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
|
||||
|
||||
@@ -22,27 +22,48 @@ router = APIRouter(
|
||||
def list_affiliate_links(
|
||||
character_id: int | None = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""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:
|
||||
query = query.filter(AffiliateLink.character_id == character_id)
|
||||
return query.order_by(AffiliateLink.created_at.desc()).all()
|
||||
|
||||
|
||||
@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."""
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail="Affiliate link not found")
|
||||
return link
|
||||
|
||||
|
||||
@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."""
|
||||
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.user_id = current_user.id
|
||||
db.add(link)
|
||||
db.commit()
|
||||
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)
|
||||
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."""
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail="Affiliate link not found")
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
@@ -66,9 +94,17 @@ def update_affiliate_link(
|
||||
|
||||
|
||||
@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."""
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail="Affiliate link not found")
|
||||
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 ..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
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/characters",
|
||||
tags=["characters"],
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=list[CharacterResponse])
|
||||
def list_characters(db: Session = Depends(get_db)):
|
||||
return db.query(Character).order_by(Character.created_at.desc()).all()
|
||||
def list_characters(
|
||||
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)
|
||||
def get_character(character_id: int, db: Session = Depends(get_db)):
|
||||
character = db.query(Character).filter(Character.id == character_id).first()
|
||||
def get_character(
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail="Character not found")
|
||||
return character
|
||||
|
||||
|
||||
@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.user_id = current_user.id
|
||||
db.add(character)
|
||||
db.commit()
|
||||
db.refresh(character)
|
||||
@@ -39,9 +66,16 @@ def create_character(data: CharacterCreate, db: Session = Depends(get_db)):
|
||||
|
||||
@router.put("/{character_id}", response_model=CharacterResponse)
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail="Character not found")
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
@@ -54,8 +88,16 @@ def update_character(
|
||||
|
||||
|
||||
@router.delete("/{character_id}", status_code=204)
|
||||
def delete_character(character_id: int, db: Session = Depends(get_db)):
|
||||
character = db.query(Character).filter(Character.id == character_id).first()
|
||||
def delete_character(
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail="Character not found")
|
||||
db.delete(character)
|
||||
|
||||
@@ -10,7 +10,8 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from ..auth import get_current_user
|
||||
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 ..services.llm import get_llm_provider
|
||||
from ..services.social import get_publisher
|
||||
@@ -18,7 +19,6 @@ from ..services.social import get_publisher
|
||||
router = APIRouter(
|
||||
prefix="/api/comments",
|
||||
tags=["comments"],
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ def list_comments(
|
||||
reply_status: str | None = Query(None),
|
||||
scheduled_post_id: int | None = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List comments with optional filters."""
|
||||
query = db.query(Comment)
|
||||
@@ -41,8 +42,17 @@ def list_comments(
|
||||
|
||||
|
||||
@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')."""
|
||||
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 (
|
||||
db.query(Comment)
|
||||
.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)
|
||||
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."""
|
||||
comment = db.query(Comment).filter(Comment.id == comment_id).first()
|
||||
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)
|
||||
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."""
|
||||
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()
|
||||
if not comment:
|
||||
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)
|
||||
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."""
|
||||
comment = db.query(Comment).filter(Comment.id == comment_id).first()
|
||||
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:
|
||||
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:
|
||||
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",
|
||||
)
|
||||
|
||||
# Build publisher kwargs
|
||||
kwargs: dict = {}
|
||||
if account.platform == "facebook":
|
||||
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}")
|
||||
def fetch_comments(platform: str, db: Session = Depends(get_db)):
|
||||
"""Fetch new comments from a platform for all published posts.
|
||||
|
||||
Creates Comment records for any new comments not already in the database.
|
||||
Uses LLM to generate AI-suggested replies for each new comment.
|
||||
"""
|
||||
# Get all published scheduled posts for this platform
|
||||
def fetch_comments(
|
||||
platform: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Fetch new comments from a platform for all published posts."""
|
||||
published_posts = (
|
||||
db.query(ScheduledPost)
|
||||
.filter(
|
||||
@@ -176,12 +201,17 @@ def fetch_comments(platform: str, db: Session = Depends(get_db)):
|
||||
if not published_posts:
|
||||
return {"new_comments": 0, "message": f"No published posts found for {platform}"}
|
||||
|
||||
# Get LLM settings for AI reply generation
|
||||
llm_provider_name = None
|
||||
llm_api_key = None
|
||||
llm_model = None
|
||||
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 key == "llm_provider":
|
||||
llm_provider_name = setting.value
|
||||
@@ -195,17 +225,15 @@ def fetch_comments(platform: str, db: Session = Depends(get_db)):
|
||||
try:
|
||||
llm = get_llm_provider(llm_provider_name, llm_api_key, llm_model)
|
||||
except ValueError:
|
||||
pass # LLM not available, skip AI replies
|
||||
pass
|
||||
|
||||
new_comment_count = 0
|
||||
|
||||
for scheduled in published_posts:
|
||||
# Get the post to find the character
|
||||
post = db.query(Post).filter(Post.id == scheduled.post_id).first()
|
||||
if not post:
|
||||
continue
|
||||
|
||||
# Find the social account
|
||||
account = (
|
||||
db.query(SocialAccount)
|
||||
.filter(
|
||||
@@ -218,7 +246,6 @@ def fetch_comments(platform: str, db: Session = Depends(get_db)):
|
||||
if not account or not account.access_token:
|
||||
continue
|
||||
|
||||
# Build publisher kwargs
|
||||
kwargs: dict = {}
|
||||
if account.platform == "facebook":
|
||||
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)
|
||||
comments = publisher.get_comments(scheduled.external_post_id)
|
||||
except (RuntimeError, ValueError):
|
||||
continue # Skip this post if API call fails
|
||||
continue
|
||||
|
||||
for ext_comment in comments:
|
||||
ext_id = ext_comment.get("id", "")
|
||||
if not ext_id:
|
||||
continue
|
||||
|
||||
# Check if comment already exists
|
||||
existing = (
|
||||
db.query(Comment)
|
||||
.filter(Comment.external_comment_id == ext_id)
|
||||
@@ -245,7 +271,6 @@ def fetch_comments(platform: str, db: Session = Depends(get_db)):
|
||||
if existing:
|
||||
continue
|
||||
|
||||
# Generate AI suggested reply if LLM is available
|
||||
ai_reply = None
|
||||
if llm:
|
||||
try:
|
||||
@@ -261,9 +286,8 @@ def fetch_comments(platform: str, db: Session = Depends(get_db)):
|
||||
)
|
||||
ai_reply = llm.generate(prompt, system=system_prompt)
|
||||
except RuntimeError:
|
||||
pass # Skip AI reply if generation fails
|
||||
pass
|
||||
|
||||
# Create comment record
|
||||
comment = Comment(
|
||||
scheduled_post_id=scheduled.id,
|
||||
platform=platform,
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
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 sqlalchemy.orm import Session
|
||||
|
||||
from ..auth import get_current_user
|
||||
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 (
|
||||
GenerateContentRequest,
|
||||
GenerateImageRequest,
|
||||
@@ -24,30 +25,57 @@ from ..services.llm import get_llm_provider
|
||||
router = APIRouter(
|
||||
prefix="/api/content",
|
||||
tags=["content"],
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
|
||||
|
||||
def _get_setting(db: Session, key: str) -> str | None:
|
||||
"""Retrieve a system setting value by key."""
|
||||
setting = db.query(SystemSetting).filter(SystemSetting.key == key).first()
|
||||
def _get_setting(db: Session, key: str, user_id: int = None) -> str | None:
|
||||
"""Retrieve a system setting value by key, preferring user-specific over global."""
|
||||
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:
|
||||
return None
|
||||
return setting.value
|
||||
|
||||
|
||||
@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."""
|
||||
# Validate character exists
|
||||
character = db.query(Character).filter(Character.id == request.character_id).first()
|
||||
# Validate character belongs to user
|
||||
character = (
|
||||
db.query(Character)
|
||||
.filter(Character.id == request.character_id, Character.user_id == current_user.id)
|
||||
.first()
|
||||
)
|
||||
if not character:
|
||||
raise HTTPException(status_code=404, detail="Character not found")
|
||||
|
||||
# Get LLM settings
|
||||
provider_name = request.provider or _get_setting(db, "llm_provider")
|
||||
api_key = _get_setting(db, "llm_api_key")
|
||||
model = request.model or _get_setting(db, "llm_model")
|
||||
# Check monthly post limit
|
||||
first_of_month = date.today().replace(day=1)
|
||||
if current_user.posts_reset_date != first_of_month:
|
||||
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:
|
||||
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
|
||||
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)
|
||||
text = generate_post_text(
|
||||
character=char_dict,
|
||||
@@ -82,6 +110,7 @@ def generate_content(request: GenerateContentRequest, db: Session = Depends(get_
|
||||
db.query(AffiliateLink)
|
||||
.filter(
|
||||
AffiliateLink.is_active == True,
|
||||
AffiliateLink.user_id == current_user.id,
|
||||
(AffiliateLink.character_id == character.id) | (AffiliateLink.character_id == None),
|
||||
)
|
||||
.all()
|
||||
@@ -102,6 +131,7 @@ def generate_content(request: GenerateContentRequest, db: Session = Depends(get_
|
||||
# Create post record
|
||||
post = Post(
|
||||
character_id=character.id,
|
||||
user_id=current_user.id,
|
||||
content_type=request.content_type,
|
||||
text_content=text,
|
||||
hashtags=hashtags,
|
||||
@@ -112,29 +142,37 @@ def generate_content(request: GenerateContentRequest, db: Session = Depends(get_
|
||||
status="draft",
|
||||
)
|
||||
db.add(post)
|
||||
|
||||
# Increment monthly counter
|
||||
current_user.posts_generated_this_month = (current_user.posts_generated_this_month or 0) + 1
|
||||
db.commit()
|
||||
db.refresh(post)
|
||||
return post
|
||||
|
||||
|
||||
@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."""
|
||||
# Validate character exists
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail="Character not found")
|
||||
|
||||
# Get image settings
|
||||
provider_name = request.provider or _get_setting(db, "image_provider")
|
||||
api_key = _get_setting(db, "image_api_key")
|
||||
provider_name = request.provider or _get_setting(db, "image_provider", current_user.id)
|
||||
api_key = _get_setting(db, "image_api_key", current_user.id)
|
||||
|
||||
if not provider_name:
|
||||
raise HTTPException(status_code=400, detail="Image provider not configured. Set 'image_provider' in settings.")
|
||||
if not api_key:
|
||||
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
|
||||
if not prompt:
|
||||
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()
|
||||
)
|
||||
|
||||
# Generate image
|
||||
image_provider = get_image_provider(provider_name, api_key)
|
||||
image_url = image_provider.generate(prompt, size=request.size)
|
||||
|
||||
# Create a new post with the image
|
||||
post = Post(
|
||||
character_id=character.id,
|
||||
user_id=current_user.id,
|
||||
content_type="image",
|
||||
image_url=image_url,
|
||||
platform_hint="instagram",
|
||||
@@ -169,9 +206,10 @@ def list_posts(
|
||||
character_id: int | None = Query(None),
|
||||
status: str | None = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""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:
|
||||
query = query.filter(Post.character_id == character_id)
|
||||
if status is not None:
|
||||
@@ -180,18 +218,27 @@ def list_posts(
|
||||
|
||||
|
||||
@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."""
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
return post
|
||||
|
||||
|
||||
@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."""
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
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)
|
||||
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."""
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
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)
|
||||
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')."""
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
post.status = "approved"
|
||||
|
||||
@@ -10,7 +10,8 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from ..auth import get_current_user
|
||||
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 (
|
||||
EditorialPlanCreate,
|
||||
EditorialPlanResponse,
|
||||
@@ -22,7 +23,6 @@ from ..schemas import (
|
||||
router = APIRouter(
|
||||
prefix="/api/plans",
|
||||
tags=["plans"],
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
|
||||
|
||||
@@ -33,9 +33,10 @@ router = APIRouter(
|
||||
def list_plans(
|
||||
character_id: int | None = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""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:
|
||||
query = query.filter(EditorialPlan.character_id == character_id)
|
||||
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_after: datetime | None = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""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:
|
||||
query = query.filter(ScheduledPost.platform == platform)
|
||||
if status is not None:
|
||||
@@ -63,18 +71,38 @@ def list_all_scheduled_posts(
|
||||
|
||||
|
||||
@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."""
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail="Editorial plan not found")
|
||||
return plan
|
||||
|
||||
|
||||
@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."""
|
||||
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.user_id = current_user.id
|
||||
db.add(plan)
|
||||
db.commit()
|
||||
db.refresh(plan)
|
||||
@@ -83,10 +111,17 @@ def create_plan(data: EditorialPlanCreate, db: Session = Depends(get_db)):
|
||||
|
||||
@router.put("/{plan_id}", response_model=EditorialPlanResponse)
|
||||
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."""
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail="Editorial plan not found")
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
@@ -99,21 +134,36 @@ def update_plan(
|
||||
|
||||
|
||||
@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."""
|
||||
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:
|
||||
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.delete(plan)
|
||||
db.commit()
|
||||
|
||||
|
||||
@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."""
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail="Editorial plan not found")
|
||||
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])
|
||||
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."""
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail="Editorial plan not found")
|
||||
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)
|
||||
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."""
|
||||
scheduled = ScheduledPost(**data.model_dump())
|
||||
db.add(scheduled)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""System settings router.
|
||||
|
||||
Manages key-value system settings including API provider configuration.
|
||||
Each user has their own private settings.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
@@ -10,51 +11,60 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..database import get_db
|
||||
from ..models import SystemSetting
|
||||
from ..models import SystemSetting, User
|
||||
from ..schemas import SettingResponse, SettingUpdate
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/settings",
|
||||
tags=["settings"],
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=list[SettingResponse])
|
||||
def list_settings(db: Session = Depends(get_db)):
|
||||
"""Get all system settings."""
|
||||
settings = db.query(SystemSetting).order_by(SystemSetting.key).all()
|
||||
def list_settings(
|
||||
db: Session = Depends(get_db),
|
||||
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
|
||||
|
||||
|
||||
@router.get("/providers/status")
|
||||
def get_providers_status(db: Session = Depends(get_db)):
|
||||
"""Check which API providers are configured (have API keys set).
|
||||
|
||||
Returns a dict indicating configuration status for each provider category.
|
||||
"""
|
||||
# Helper to check if a setting exists and has a truthy value
|
||||
def get_providers_status(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Check which API providers are configured (have API keys set)."""
|
||||
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:
|
||||
return setting.value if isinstance(setting.value, str) else str(setting.value)
|
||||
return None
|
||||
|
||||
# LLM provider
|
||||
llm_provider = _has_setting("llm_provider")
|
||||
llm_key = _has_setting("llm_api_key")
|
||||
|
||||
# Image provider
|
||||
image_provider = _has_setting("image_provider")
|
||||
image_key = _has_setting("image_api_key")
|
||||
|
||||
# Voice provider (future)
|
||||
voice_provider = _has_setting("voice_provider")
|
||||
voice_key = _has_setting("voice_api_key")
|
||||
|
||||
# Social platforms - check for any active social accounts
|
||||
from ..models import SocialAccount
|
||||
|
||||
social_platforms = {}
|
||||
for platform in ("facebook", "instagram", "youtube", "tiktok"):
|
||||
has_account = (
|
||||
@@ -63,6 +73,7 @@ def get_providers_status(db: Session = Depends(get_db)):
|
||||
SocialAccount.platform == platform,
|
||||
SocialAccount.is_active == True,
|
||||
SocialAccount.access_token != None,
|
||||
SocialAccount.user_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
@@ -86,26 +97,40 @@ def get_providers_status(db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/{key}", response_model=SettingResponse)
|
||||
def get_setting(key: str, db: Session = Depends(get_db)):
|
||||
"""Get a single setting by key."""
|
||||
setting = db.query(SystemSetting).filter(SystemSetting.key == key).first()
|
||||
def get_setting(
|
||||
key: str,
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail=f"Setting '{key}' not found")
|
||||
return setting
|
||||
|
||||
|
||||
@router.put("/{key}", response_model=SettingResponse)
|
||||
def upsert_setting(key: str, data: SettingUpdate, db: Session = Depends(get_db)):
|
||||
"""Create or update a setting by key.
|
||||
|
||||
If the setting exists, update its value. If not, create it.
|
||||
"""
|
||||
setting = db.query(SystemSetting).filter(SystemSetting.key == key).first()
|
||||
def upsert_setting(
|
||||
key: str,
|
||||
data: SettingUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""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:
|
||||
setting.value = data.value
|
||||
setting.updated_at = datetime.utcnow()
|
||||
else:
|
||||
setting = SystemSetting(key=key, value=data.value)
|
||||
setting = SystemSetting(key=key, value=data.value, user_id=current_user.id)
|
||||
db.add(setting)
|
||||
db.commit()
|
||||
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)
|
||||
def delete_setting(key: str, db: Session = Depends(get_db)):
|
||||
"""Delete a setting by key."""
|
||||
setting = db.query(SystemSetting).filter(SystemSetting.key == key).first()
|
||||
def delete_setting(
|
||||
key: str,
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail=f"Setting '{key}' not found")
|
||||
db.delete(setting)
|
||||
|
||||
@@ -10,7 +10,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..database import get_db
|
||||
from ..models import Post, ScheduledPost, SocialAccount
|
||||
from ..models import Post, ScheduledPost, SocialAccount, User
|
||||
from ..schemas import (
|
||||
ScheduledPostResponse,
|
||||
SocialAccountCreate,
|
||||
@@ -22,7 +22,6 @@ from ..services.social import get_publisher
|
||||
router = APIRouter(
|
||||
prefix="/api/social",
|
||||
tags=["social"],
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
|
||||
|
||||
@@ -33,27 +32,41 @@ router = APIRouter(
|
||||
def list_social_accounts(
|
||||
character_id: int | None = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""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:
|
||||
query = query.filter(SocialAccount.character_id == character_id)
|
||||
return query.order_by(SocialAccount.created_at.desc()).all()
|
||||
|
||||
|
||||
@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."""
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail="Social account not found")
|
||||
return account
|
||||
|
||||
|
||||
@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."""
|
||||
account = SocialAccount(**data.model_dump())
|
||||
account.user_id = current_user.id
|
||||
db.add(account)
|
||||
db.commit()
|
||||
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)
|
||||
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."""
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail="Social account not found")
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
@@ -77,9 +97,17 @@ def update_social_account(
|
||||
|
||||
|
||||
@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."""
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail="Social account not found")
|
||||
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")
|
||||
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."""
|
||||
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:
|
||||
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")
|
||||
|
||||
try:
|
||||
# Build kwargs based on platform
|
||||
kwargs: dict = {}
|
||||
if account.platform == "facebook":
|
||||
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")
|
||||
kwargs["ig_user_id"] = ig_user_id
|
||||
|
||||
# Try to instantiate the publisher (validates credentials format)
|
||||
get_publisher(account.platform, account.access_token, **kwargs)
|
||||
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)
|
||||
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."""
|
||||
scheduled = (
|
||||
db.query(ScheduledPost)
|
||||
@@ -133,18 +171,17 @@ def publish_scheduled_post(scheduled_post_id: int, db: Session = Depends(get_db)
|
||||
if not scheduled:
|
||||
raise HTTPException(status_code=404, detail="Scheduled post not found")
|
||||
|
||||
# Get the post content
|
||||
post = db.query(Post).filter(Post.id == scheduled.post_id).first()
|
||||
post = db.query(Post).filter(Post.id == scheduled.post_id, Post.user_id == current_user.id).first()
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="Associated post not found")
|
||||
|
||||
# Find the social account for this platform and character
|
||||
account = (
|
||||
db.query(SocialAccount)
|
||||
.filter(
|
||||
SocialAccount.character_id == post.character_id,
|
||||
SocialAccount.platform == scheduled.platform,
|
||||
SocialAccount.is_active == True,
|
||||
SocialAccount.user_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
@@ -157,7 +194,6 @@ def publish_scheduled_post(scheduled_post_id: int, db: Session = Depends(get_db)
|
||||
if not account.access_token:
|
||||
raise HTTPException(status_code=400, detail="Social account has no access token configured")
|
||||
|
||||
# Build publisher kwargs
|
||||
kwargs: dict = {}
|
||||
if account.platform == "facebook":
|
||||
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)
|
||||
|
||||
# Determine publish method based on content type
|
||||
text = post.text_content or ""
|
||||
if 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:
|
||||
external_id = publisher.publish_text(text)
|
||||
|
||||
# Update scheduled post
|
||||
scheduled.status = "published"
|
||||
scheduled.published_at = datetime.utcnow()
|
||||
scheduled.external_post_id = external_id
|
||||
|
||||
# Update post status
|
||||
post.status = "published"
|
||||
post.updated_at = datetime.utcnow()
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ class LoginRequest(BaseModel):
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
user: Optional[dict] = None
|
||||
|
||||
|
||||
# === 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:
|
||||
app:
|
||||
build: .
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_BASE_PATH: "/leopost-full"
|
||||
VITE_API_BASE: "/leopost-full/api"
|
||||
container_name: lab-leopost-full-app
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- DATABASE_URL=sqlite:///./data/leopost.db
|
||||
- APP_URL=https://lab.mlhub.it/leopost-full
|
||||
networks:
|
||||
- proxy_net
|
||||
deploy:
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AuthProvider } from './AuthContext'
|
||||
import ProtectedRoute from './components/ProtectedRoute'
|
||||
import Layout from './components/Layout'
|
||||
import LoginPage from './components/LoginPage'
|
||||
import AuthCallback from './components/AuthCallback'
|
||||
import Dashboard from './components/Dashboard'
|
||||
import CharacterList from './components/CharacterList'
|
||||
import CharacterForm from './components/CharacterForm'
|
||||
@@ -17,13 +18,19 @@ import SocialAccounts from './components/SocialAccounts'
|
||||
import CommentsQueue from './components/CommentsQueue'
|
||||
import SettingsPage from './components/SettingsPage'
|
||||
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() {
|
||||
return (
|
||||
<BrowserRouter basename="/leopost-full">
|
||||
<BrowserRouter basename={BASE_PATH}>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
@@ -43,6 +50,7 @@ export default function App() {
|
||||
<Route path="/comments" element={<CommentsQueue />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/editorial" element={<EditorialCalendar />} />
|
||||
<Route path="/admin" element={<AdminSettings />} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
|
||||
@@ -3,27 +3,81 @@ import { api } from './api'
|
||||
|
||||
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 }) {
|
||||
const [user, setUser] = useState(null)
|
||||
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(() => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
api.get('/auth/me')
|
||||
.then((data) => setUser(data))
|
||||
.catch(() => localStorage.removeItem('token'))
|
||||
.finally(() => setLoading(false))
|
||||
loadUser().finally(() => setLoading(false))
|
||||
} else {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const login = async (username, password) => {
|
||||
const data = await api.post('/auth/login', { username, password })
|
||||
const login = async (emailOrUsername, 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)
|
||||
const me = await api.get('/auth/me')
|
||||
setUser(me)
|
||||
setUser(data.user)
|
||||
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 = () => {
|
||||
@@ -31,8 +85,23 @@ export function AuthProvider({ children }) {
|
||||
setUser(null)
|
||||
}
|
||||
|
||||
const isPro = computeIsPro(user)
|
||||
const isAdmin = Boolean(user?.is_admin)
|
||||
const planLimits = PLAN_LIMITS[isPro ? 'pro' : 'freemium']
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, logout }}>
|
||||
<AuthContext.Provider value={{
|
||||
user,
|
||||
loading,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
loginWithToken,
|
||||
loadUser,
|
||||
isPro,
|
||||
isAdmin,
|
||||
planLimits,
|
||||
}}>
|
||||
{children}
|
||||
</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) {
|
||||
const headers = { 'Content-Type': 'application/json' }
|
||||
@@ -13,13 +13,21 @@ async function request(method, path, body = null) {
|
||||
|
||||
if (res.status === 401) {
|
||||
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
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
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
|
||||
@@ -32,3 +40,5 @@ export const api = {
|
||||
put: (path, body) => request('PUT', path, body),
|
||||
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 { useNavigate, useParams } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
import { useAuth } from '../AuthContext'
|
||||
|
||||
const EMPTY_FORM = {
|
||||
name: '',
|
||||
@@ -11,17 +12,87 @@ const EMPTY_FORM = {
|
||||
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() {
|
||||
const { id } = useParams()
|
||||
const isEdit = Boolean(id)
|
||||
const navigate = useNavigate()
|
||||
const { isPro } = useAuth()
|
||||
|
||||
const [activeTab, setActiveTab] = useState('profile')
|
||||
const [form, setForm] = useState(EMPTY_FORM)
|
||||
const [topicInput, setTopicInput] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(isEdit)
|
||||
|
||||
// Social accounts state
|
||||
const [socialAccounts, setSocialAccounts] = useState({})
|
||||
const [expandedGuide, setExpandedGuide] = useState(null)
|
||||
const [savingToken, setSavingToken] = useState({})
|
||||
const [tokenInputs, setTokenInputs] = useState({})
|
||||
const [pageIdInputs, setPageIdInputs] = useState({})
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit) {
|
||||
api.get(`/characters/${id}`)
|
||||
@@ -41,6 +112,15 @@ export default function CharacterForm() {
|
||||
})
|
||||
.catch(() => setError('Personaggio non trovato'))
|
||||
.finally(() => setLoading(false))
|
||||
|
||||
// Load social accounts for this character
|
||||
api.get(`/social/accounts?character_id=${id}`)
|
||||
.then((accounts) => {
|
||||
const map = {}
|
||||
accounts.forEach((acc) => { map[acc.platform] = acc })
|
||||
setSocialAccounts(map)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
}, [id, isEdit])
|
||||
|
||||
@@ -89,12 +169,69 @@ export default function CharacterForm() {
|
||||
}
|
||||
navigate('/characters')
|
||||
} catch (err) {
|
||||
if (err.data?.upgrade_required) {
|
||||
setError(err.data.message || 'Limite piano raggiunto. Passa a Pro.')
|
||||
} else {
|
||||
setError(err.message || 'Errore nel salvataggio')
|
||||
}
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveToken = async (platform) => {
|
||||
if (!isEdit) return
|
||||
const token = tokenInputs[platform] || ''
|
||||
const pageId = pageIdInputs[platform] || ''
|
||||
if (!token.trim()) return
|
||||
|
||||
setSavingToken((prev) => ({ ...prev, [platform]: true }))
|
||||
try {
|
||||
const existing = socialAccounts[platform]
|
||||
if (existing) {
|
||||
await api.put(`/social/accounts/${existing.id}`, {
|
||||
access_token: token,
|
||||
page_id: pageId || undefined,
|
||||
})
|
||||
} else {
|
||||
await api.post('/social/accounts', {
|
||||
character_id: Number(id),
|
||||
platform,
|
||||
access_token: token,
|
||||
page_id: pageId || undefined,
|
||||
account_name: platform,
|
||||
})
|
||||
}
|
||||
// Reload
|
||||
const accounts = await api.get(`/social/accounts?character_id=${id}`)
|
||||
const map = {}
|
||||
accounts.forEach((acc) => { map[acc.platform] = acc })
|
||||
setSocialAccounts(map)
|
||||
setTokenInputs((prev) => ({ ...prev, [platform]: '' }))
|
||||
setPageIdInputs((prev) => ({ ...prev, [platform]: '' }))
|
||||
} catch (err) {
|
||||
alert(err.message || 'Errore nel salvataggio del token.')
|
||||
} finally {
|
||||
setSavingToken((prev) => ({ ...prev, [platform]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDisconnect = async (platform) => {
|
||||
const acc = socialAccounts[platform]
|
||||
if (!acc) return
|
||||
if (!window.confirm(`Disconnetti ${platform}?`)) return
|
||||
try {
|
||||
await api.delete(`/social/accounts/${acc.id}`)
|
||||
setSocialAccounts((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[platform]
|
||||
return next
|
||||
})
|
||||
} catch (err) {
|
||||
alert(err.message || 'Errore nella disconnessione.')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
@@ -114,6 +251,32 @@ export default function CharacterForm() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 mb-6 p-1 rounded-lg inline-flex" style={{ backgroundColor: '#F1F5F9', border: '1px solid #E2E8F0' }}>
|
||||
{[
|
||||
{ id: 'profile', label: 'Profilo' },
|
||||
{ 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>
|
||||
|
||||
{activeTab === 'profile' && (
|
||||
<form onSubmit={handleSubmit} className="max-w-2xl space-y-6">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
|
||||
@@ -326,6 +489,144 @@ export default function CharacterForm() {
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
import { useAuth } from '../AuthContext'
|
||||
import PlanBanner from './PlanBanner'
|
||||
|
||||
export default function Dashboard() {
|
||||
const { user, isAdmin } = useAuth()
|
||||
const [stats, setStats] = useState({
|
||||
characters: 0,
|
||||
active: 0,
|
||||
@@ -43,13 +46,26 @@ export default function Dashboard() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-1" style={{ color: 'var(--ink)' }}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h2 className="text-2xl font-bold" style={{ color: 'var(--ink)' }}>
|
||||
Dashboard
|
||||
</h2>
|
||||
<p className="text-sm mb-5" style={{ color: 'var(--muted)' }}>
|
||||
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>
|
||||
|
||||
<PlanBanner />
|
||||
|
||||
{/* Stats grid */}
|
||||
<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)" />
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
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() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [mode, setMode] = useState('login') // 'login' | 'register'
|
||||
const [email, setEmail] = useState('')
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { login } = useAuth()
|
||||
const [showRedeemModal, setShowRedeemModal] = useState(false)
|
||||
|
||||
const { login, register } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
@@ -15,95 +26,427 @@ export default function LoginPage() {
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
await login(username, password)
|
||||
if (mode === 'login') {
|
||||
await login(email, password)
|
||||
} else {
|
||||
await register(email, password, displayName)
|
||||
}
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Login failed')
|
||||
setError(err.message || (mode === 'login' ? 'Credenziali non valide' : 'Errore durante la registrazione'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
window.location.href = `${BASE_URL}/auth/oauth/google`
|
||||
}
|
||||
|
||||
const comingSoon = (e) => {
|
||||
e.preventDefault()
|
||||
// tooltip handled via title attr
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center px-4"
|
||||
style={{ backgroundColor: 'var(--ink)' }}
|
||||
>
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-white tracking-tight font-serif">
|
||||
Leopost <span style={{ color: 'var(--coral)' }}>Full</span>
|
||||
<div style={{ display: 'flex', height: '100vh', fontFamily: 'Inter, sans-serif' }}>
|
||||
{/* LEFT SIDE */}
|
||||
<div style={{
|
||||
width: '40%',
|
||||
backgroundColor: CORAL,
|
||||
padding: '3rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
color: 'white',
|
||||
}}>
|
||||
<div>
|
||||
<h1 style={{
|
||||
fontFamily: 'Georgia, serif',
|
||||
fontSize: '2.8rem',
|
||||
fontWeight: 700,
|
||||
margin: 0,
|
||||
letterSpacing: '-1px',
|
||||
}}>
|
||||
Leopost
|
||||
</h1>
|
||||
<p className="mt-2 text-sm" style={{ color: 'var(--muted)' }}>
|
||||
Content Automation Platform
|
||||
<p style={{ fontSize: '1.05rem', marginTop: '0.5rem', opacity: 0.9, lineHeight: 1.4 }}>
|
||||
Il tuo studio editoriale AI per i social
|
||||
</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>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="rounded-xl p-8 shadow-xl"
|
||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||
<div style={{
|
||||
backgroundColor: 'rgba(255,255,255,0.15)',
|
||||
borderRadius: '12px',
|
||||
padding: '1rem 1.5rem',
|
||||
fontSize: '0.85rem',
|
||||
backdropFilter: 'blur(4px)',
|
||||
}}>
|
||||
<strong>Early Adopter Beta</strong> — Unisciti ora e ottieni un accesso esclusivo al piano Pro a prezzo speciale.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT SIDE */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
backgroundColor: CREAM,
|
||||
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,
|
||||
}}
|
||||
>
|
||||
{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 className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
|
||||
<div style={{
|
||||
marginBottom: '1rem',
|
||||
padding: '0.75rem 1rem',
|
||||
backgroundColor: '#FEE2E2',
|
||||
border: '1px solid #FECACA',
|
||||
borderRadius: '8px',
|
||||
color: '#DC2626',
|
||||
fontSize: '0.875rem',
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-medium mb-1"
|
||||
style={{ color: 'var(--ink)' }}
|
||||
>
|
||||
Username
|
||||
{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={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full px-4 py-2.5 rounded-lg text-sm focus:outline-none"
|
||||
style={{
|
||||
border: '1px solid var(--border)',
|
||||
color: 'var(--ink)',
|
||||
backgroundColor: 'var(--cream)',
|
||||
}}
|
||||
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>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="tu@esempio.it"
|
||||
required
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-medium mb-1"
|
||||
style={{ color: 'var(--ink)' }}
|
||||
>
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<label style={{ display: 'block', fontSize: '0.8rem', fontWeight: 600, color: INK, marginBottom: '0.4rem' }}>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-4 py-2.5 rounded-lg text-sm focus:outline-none"
|
||||
style={{
|
||||
border: '1px solid var(--border)',
|
||||
color: 'var(--ink)',
|
||||
backgroundColor: 'var(--cream)',
|
||||
}}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="mt-6 w-full py-2.5 text-white font-medium rounded-lg transition-opacity text-sm"
|
||||
style={{ backgroundColor: 'var(--coral)', opacity: loading ? 0.7 : 1 }}
|
||||
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 ? 'Accesso...' : 'Accedi'}
|
||||
{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>
|
||||
|
||||
{/* Social login buttons */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
||||
{/* Google */}
|
||||
<button
|
||||
onClick={handleGoogleLogin}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
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({
|
||||
plugins: [react()],
|
||||
base: '/leopost-full/',
|
||||
base: process.env.VITE_BASE_PATH !== undefined ? (process.env.VITE_BASE_PATH || '/') : '/leopost-full/',
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
@@ -12,6 +12,10 @@ export default defineConfig({
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/leopost-full/, ''),
|
||||
},
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
||||
Reference in New Issue
Block a user