Improved Licensing
This commit is contained in:
@@ -29,6 +29,9 @@ textstat>=0.7.12
|
|||||||
scikit-learn==1.5.2
|
scikit-learn==1.5.2
|
||||||
setuptools>=65.0.0
|
setuptools>=65.0.0
|
||||||
|
|
||||||
|
# PDF Generation
|
||||||
|
reportlab>=4.0.0
|
||||||
|
|
||||||
# Web Frontend
|
# Web Frontend
|
||||||
fastapi==0.115.0
|
fastapi==0.115.0
|
||||||
uvicorn==0.32.0
|
uvicorn==0.32.0
|
||||||
|
|||||||
@@ -75,6 +75,14 @@ class BaseAgent(ABC):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to log usage to DB: {e}")
|
logger.warning(f"Failed to log usage to DB: {e}")
|
||||||
|
|
||||||
|
# Increment company token quota
|
||||||
|
if self._company_id:
|
||||||
|
try:
|
||||||
|
from uuid import UUID
|
||||||
|
await db.increment_company_tokens(UUID(self._company_id), total_tokens)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to increment company tokens: {e}")
|
||||||
|
|
||||||
async def call_openai(
|
async def call_openai(
|
||||||
self,
|
self,
|
||||||
system_prompt: str,
|
system_prompt: str,
|
||||||
|
|||||||
@@ -1184,18 +1184,17 @@ class DatabaseClient:
|
|||||||
self,
|
self,
|
||||||
key: str,
|
key: str,
|
||||||
max_employees: int,
|
max_employees: int,
|
||||||
max_posts_per_day: int,
|
daily_token_limit: Optional[int] = None,
|
||||||
max_researches_per_day: int,
|
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
) -> LicenseKey:
|
) -> LicenseKey:
|
||||||
"""Create new license key."""
|
"""Create new license key."""
|
||||||
data = {
|
data = {
|
||||||
"key": key,
|
"key": key,
|
||||||
"max_employees": max_employees,
|
"max_employees": max_employees,
|
||||||
"max_posts_per_day": max_posts_per_day,
|
|
||||||
"max_researches_per_day": max_researches_per_day,
|
|
||||||
"description": description,
|
"description": description,
|
||||||
}
|
}
|
||||||
|
if daily_token_limit is not None:
|
||||||
|
data["daily_token_limit"] = daily_token_limit
|
||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
lambda: self.client.table("license_keys").insert(data).execute()
|
lambda: self.client.table("license_keys").insert(data).execute()
|
||||||
)
|
)
|
||||||
@@ -1222,6 +1221,16 @@ class DatabaseClient:
|
|||||||
logger.info(f"Marked license key as used: {key}")
|
logger.info(f"Marked license key as used: {key}")
|
||||||
return LicenseKey(**result.data[0])
|
return LicenseKey(**result.data[0])
|
||||||
|
|
||||||
|
async def get_license_key_by_id(self, key_id: UUID) -> Optional[LicenseKey]:
|
||||||
|
"""Get license key by UUID."""
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
lambda: self.client.table("license_keys")
|
||||||
|
.select("*")
|
||||||
|
.eq("id", str(key_id))
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
return LicenseKey(**result.data[0]) if result.data else None
|
||||||
|
|
||||||
async def update_license_key(self, key_id: UUID, updates: Dict[str, Any]) -> LicenseKey:
|
async def update_license_key(self, key_id: UUID, updates: Dict[str, Any]) -> LicenseKey:
|
||||||
"""Update license key limits (admin only)."""
|
"""Update license key limits (admin only)."""
|
||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
@@ -1270,8 +1279,7 @@ class DatabaseClient:
|
|||||||
data = {
|
data = {
|
||||||
"company_id": str(company_id),
|
"company_id": str(company_id),
|
||||||
"date": date_.isoformat(),
|
"date": date_.isoformat(),
|
||||||
"posts_created": 0,
|
"tokens_used": 0
|
||||||
"researches_created": 0
|
|
||||||
}
|
}
|
||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
lambda: self.client.table("company_daily_quotas")
|
lambda: self.client.table("company_daily_quotas")
|
||||||
@@ -1280,47 +1288,22 @@ class DatabaseClient:
|
|||||||
)
|
)
|
||||||
return CompanyDailyQuota(**result.data[0])
|
return CompanyDailyQuota(**result.data[0])
|
||||||
|
|
||||||
async def increment_company_posts_quota(self, company_id: UUID) -> None:
|
async def increment_company_tokens(self, company_id: UUID, tokens: int) -> None:
|
||||||
"""Increment daily posts count for company."""
|
"""Increment daily token usage for company."""
|
||||||
try:
|
try:
|
||||||
quota = await self.get_company_daily_quota(company_id)
|
quota = await self.get_company_daily_quota(company_id)
|
||||||
new_count = quota.posts_created + 1
|
new_count = quota.tokens_used + tokens
|
||||||
|
|
||||||
result = await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
lambda: self.client.table("company_daily_quotas")
|
lambda: self.client.table("company_daily_quotas")
|
||||||
.update({"posts_created": new_count})
|
.update({"tokens_used": new_count})
|
||||||
.eq("id", str(quota.id))
|
.eq("id", str(quota.id))
|
||||||
.execute()
|
.execute()
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"Incremented posts quota for company {company_id}: {quota.posts_created} -> {new_count}")
|
logger.info(f"Incremented token quota for company {company_id}: {quota.tokens_used} -> {new_count}")
|
||||||
|
|
||||||
if not result.data:
|
|
||||||
logger.error(f"Failed to increment posts quota - no data returned")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error incrementing posts quota for company {company_id}: {e}")
|
logger.warning(f"Error incrementing token quota for company {company_id}: {e}")
|
||||||
raise
|
|
||||||
|
|
||||||
async def increment_company_researches_quota(self, company_id: UUID) -> None:
|
|
||||||
"""Increment daily researches count for company."""
|
|
||||||
try:
|
|
||||||
quota = await self.get_company_daily_quota(company_id)
|
|
||||||
new_count = quota.researches_created + 1
|
|
||||||
|
|
||||||
result = await asyncio.to_thread(
|
|
||||||
lambda: self.client.table("company_daily_quotas")
|
|
||||||
.update({"researches_created": new_count})
|
|
||||||
.eq("id", str(quota.id))
|
|
||||||
.execute()
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"Incremented researches quota for company {company_id}: {quota.researches_created} -> {new_count}")
|
|
||||||
|
|
||||||
if not result.data:
|
|
||||||
logger.error(f"Failed to increment researches quota - no data returned")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error incrementing researches quota for company {company_id}: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def get_company_limits(self, company_id: UUID) -> Optional[LicenseKey]:
|
async def get_company_limits(self, company_id: UUID) -> Optional[LicenseKey]:
|
||||||
"""Get company limits from associated license key.
|
"""Get company limits from associated license key.
|
||||||
@@ -1340,39 +1323,21 @@ class DatabaseClient:
|
|||||||
|
|
||||||
return LicenseKey(**result.data[0]) if result.data else None
|
return LicenseKey(**result.data[0]) if result.data else None
|
||||||
|
|
||||||
async def check_company_post_limit(self, company_id: UUID) -> tuple[bool, str]:
|
async def check_company_token_limit(self, company_id: UUID) -> tuple[bool, str, int, int]:
|
||||||
"""Check if company can create more posts today.
|
"""Check if company has token budget remaining today.
|
||||||
|
|
||||||
Returns (can_create: bool, error_message: str)
|
Returns (can_proceed: bool, error_message: str, tokens_used: int, daily_limit: int)
|
||||||
"""
|
"""
|
||||||
license_key = await self.get_company_limits(company_id)
|
license_key = await self.get_company_limits(company_id)
|
||||||
if not license_key:
|
if not license_key or license_key.daily_token_limit is None:
|
||||||
# No license key, use defaults (unlimited)
|
return True, "", 0, 0 # no limit = unlimited
|
||||||
return True, ""
|
|
||||||
|
|
||||||
quota = await self.get_company_daily_quota(company_id)
|
quota = await self.get_company_daily_quota(company_id)
|
||||||
|
|
||||||
if quota.posts_created >= license_key.max_posts_per_day:
|
if quota.tokens_used >= license_key.daily_token_limit:
|
||||||
return False, f"Tageslimit erreicht ({license_key.max_posts_per_day} Posts/Tag). Versuche es morgen wieder."
|
return False, f"Tageslimit erreicht ({license_key.daily_token_limit:,} Tokens/Tag). Morgen wieder verfügbar.", quota.tokens_used, license_key.daily_token_limit
|
||||||
|
|
||||||
return True, ""
|
return True, "", quota.tokens_used, license_key.daily_token_limit
|
||||||
|
|
||||||
async def check_company_research_limit(self, company_id: UUID) -> tuple[bool, str]:
|
|
||||||
"""Check if company can create more researches today.
|
|
||||||
|
|
||||||
Returns (can_create: bool, error_message: str)
|
|
||||||
"""
|
|
||||||
license_key = await self.get_company_limits(company_id)
|
|
||||||
if not license_key:
|
|
||||||
# No license key, use defaults (unlimited)
|
|
||||||
return True, ""
|
|
||||||
|
|
||||||
quota = await self.get_company_daily_quota(company_id)
|
|
||||||
|
|
||||||
if quota.researches_created >= license_key.max_researches_per_day:
|
|
||||||
return False, f"Tageslimit erreicht ({license_key.max_researches_per_day} Researches/Tag). Versuche es morgen wieder."
|
|
||||||
|
|
||||||
return True, ""
|
|
||||||
|
|
||||||
async def check_company_employee_limit(self, company_id: UUID) -> tuple[bool, str]:
|
async def check_company_employee_limit(self, company_id: UUID) -> tuple[bool, str]:
|
||||||
"""Check if company can add more employees.
|
"""Check if company can add more employees.
|
||||||
|
|||||||
@@ -365,8 +365,7 @@ class LicenseKey(DBModel):
|
|||||||
|
|
||||||
# Limits
|
# Limits
|
||||||
max_employees: int = 5
|
max_employees: int = 5
|
||||||
max_posts_per_day: int = 10
|
daily_token_limit: Optional[int] = None # None = unlimited
|
||||||
max_researches_per_day: int = 5
|
|
||||||
|
|
||||||
# Usage
|
# Usage
|
||||||
used: bool = False
|
used: bool = False
|
||||||
@@ -382,7 +381,6 @@ class CompanyDailyQuota(DBModel):
|
|||||||
id: Optional[UUID] = None
|
id: Optional[UUID] = None
|
||||||
company_id: UUID
|
company_id: UUID
|
||||||
date: date
|
date: date
|
||||||
posts_created: int = 0
|
tokens_used: int = 0
|
||||||
researches_created: int = 0
|
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
updated_at: Optional[datetime] = None
|
updated_at: Optional[datetime] = None
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import smtplib
|
|||||||
import secrets
|
import secrets
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.base import MIMEBase
|
||||||
|
from email import encoders
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
@@ -75,6 +77,48 @@ def send_email(to_email: str, subject: str, html_content: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def send_email_with_attachment(
|
||||||
|
to_email: str,
|
||||||
|
subject: str,
|
||||||
|
html_content: str,
|
||||||
|
attachment_bytes: bytes,
|
||||||
|
attachment_filename: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Send an email with a file attachment (e.g. PDF)."""
|
||||||
|
if not settings.smtp_host or not settings.smtp_user:
|
||||||
|
logger.warning("SMTP not configured, skipping email send")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg = MIMEMultipart("mixed")
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg["From"] = f"{settings.smtp_from_name} <{settings.smtp_user}>"
|
||||||
|
msg["To"] = to_email
|
||||||
|
|
||||||
|
# HTML body
|
||||||
|
alt_part = MIMEMultipart("alternative")
|
||||||
|
alt_part.attach(MIMEText(html_content, "html"))
|
||||||
|
msg.attach(alt_part)
|
||||||
|
|
||||||
|
# Attachment
|
||||||
|
part = MIMEBase("application", "octet-stream")
|
||||||
|
part.set_payload(attachment_bytes)
|
||||||
|
encoders.encode_base64(part)
|
||||||
|
part.add_header("Content-Disposition", f'attachment; filename="{attachment_filename}"')
|
||||||
|
msg.attach(part)
|
||||||
|
|
||||||
|
with smtplib.SMTP(settings.smtp_host, settings.smtp_port) as server:
|
||||||
|
server.starttls()
|
||||||
|
server.login(settings.smtp_user, settings.smtp_password)
|
||||||
|
server.sendmail(settings.smtp_user, to_email, msg.as_string())
|
||||||
|
|
||||||
|
logger.info(f"Email with attachment sent to {to_email}: {subject}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send email with attachment to {to_email}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def send_approval_request_email(
|
def send_approval_request_email(
|
||||||
to_email: str,
|
to_email: str,
|
||||||
post_id: UUID,
|
post_id: UUID,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"""Admin panel routes (password-protected) - User Management & Statistics."""
|
"""Admin panel routes (password-protected) - User Management & Statistics."""
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
|
import math
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
@@ -12,11 +14,19 @@ from fastapi.templating import Jinja2Templates
|
|||||||
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, HRFlowable, Image
|
||||||
|
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||||
|
from reportlab.lib.units import cm
|
||||||
|
from reportlab.lib import colors
|
||||||
|
from reportlab.lib.pagesizes import A4
|
||||||
|
|
||||||
from src.database import db
|
from src.database import db
|
||||||
from src.web.admin.auth import (
|
from src.web.admin.auth import (
|
||||||
WEB_PASSWORD, AUTH_COOKIE_NAME, hash_password, verify_auth
|
WEB_PASSWORD, AUTH_COOKIE_NAME, hash_password, verify_auth
|
||||||
)
|
)
|
||||||
from src.web.user.auth import UserSession, set_user_session
|
from src.web.user.auth import UserSession, set_user_session
|
||||||
|
from src.services.email_service import send_email_with_attachment
|
||||||
|
from src.config import settings
|
||||||
|
|
||||||
# Router with /admin prefix
|
# Router with /admin prefix
|
||||||
admin_router = APIRouter(prefix="/admin", tags=["admin"])
|
admin_router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
@@ -549,8 +559,7 @@ async def license_keys_page(request: Request):
|
|||||||
async def generate_license_key(
|
async def generate_license_key(
|
||||||
request: Request,
|
request: Request,
|
||||||
max_employees: int = Form(...),
|
max_employees: int = Form(...),
|
||||||
max_posts_per_day: int = Form(...),
|
daily_token_limit: Optional[int] = Form(None),
|
||||||
max_researches_per_day: int = Form(...),
|
|
||||||
description: str = Form(None)
|
description: str = Form(None)
|
||||||
):
|
):
|
||||||
"""Generate new license key."""
|
"""Generate new license key."""
|
||||||
@@ -567,8 +576,7 @@ async def generate_license_key(
|
|||||||
license_key = await db.create_license_key(
|
license_key = await db.create_license_key(
|
||||||
key=key,
|
key=key,
|
||||||
max_employees=max_employees,
|
max_employees=max_employees,
|
||||||
max_posts_per_day=max_posts_per_day,
|
daily_token_limit=daily_token_limit,
|
||||||
max_researches_per_day=max_researches_per_day,
|
|
||||||
description=description
|
description=description
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -583,8 +591,7 @@ async def update_license_key_route(
|
|||||||
request: Request,
|
request: Request,
|
||||||
key_id: str,
|
key_id: str,
|
||||||
max_employees: int = Form(...),
|
max_employees: int = Form(...),
|
||||||
max_posts_per_day: int = Form(...),
|
daily_token_limit: Optional[int] = Form(None),
|
||||||
max_researches_per_day: int = Form(...),
|
|
||||||
description: str = Form(None)
|
description: str = Form(None)
|
||||||
):
|
):
|
||||||
"""Update license key limits."""
|
"""Update license key limits."""
|
||||||
@@ -594,8 +601,7 @@ async def update_license_key_route(
|
|||||||
try:
|
try:
|
||||||
updates = {
|
updates = {
|
||||||
"max_employees": max_employees,
|
"max_employees": max_employees,
|
||||||
"max_posts_per_day": max_posts_per_day,
|
"daily_token_limit": daily_token_limit,
|
||||||
"max_researches_per_day": max_researches_per_day,
|
|
||||||
"description": description
|
"description": description
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -618,3 +624,333 @@ async def delete_license_key_route(request: Request, key_id: str):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error deleting license key: {e}")
|
logger.error(f"Error deleting license key: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== LICENSE KEY OFFER ====================
|
||||||
|
|
||||||
|
AVG_TOKENS_PER_POST = 50_000
|
||||||
|
SERVER_COST_EUR = 16.0
|
||||||
|
SERVER_SHARE_FRACTION = 0.10 # 10% of server costs per customer
|
||||||
|
API_COST_PER_1K_TOKENS_EUR = 0.003 # rough average API cost in EUR
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_eur(amount: float) -> str:
|
||||||
|
"""Format a float as German EUR string, e.g. 1.250,00 €"""
|
||||||
|
return f"{amount:,.2f} €".replace(",", "X").replace(".", ",").replace("X", ".")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_offer_pdf(
|
||||||
|
key_description: str,
|
||||||
|
max_employees: int,
|
||||||
|
daily_token_limit: Optional[int],
|
||||||
|
price: float,
|
||||||
|
payment_frequency: str,
|
||||||
|
recipient_email: str,
|
||||||
|
) -> bytes:
|
||||||
|
"""Generate a professional, simple PDF offer with logo."""
|
||||||
|
buffer = BytesIO()
|
||||||
|
|
||||||
|
PAGE_W, PAGE_H = A4
|
||||||
|
MARGIN = 1.8 * cm
|
||||||
|
|
||||||
|
doc = SimpleDocTemplate(
|
||||||
|
buffer,
|
||||||
|
pagesize=A4,
|
||||||
|
rightMargin=MARGIN,
|
||||||
|
leftMargin=MARGIN,
|
||||||
|
topMargin=MARGIN,
|
||||||
|
bottomMargin=MARGIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Colors ────────────────────────────────────────────────────────────────
|
||||||
|
C_DARK = colors.HexColor("#2d3838")
|
||||||
|
C_HIGHLIGHT = colors.HexColor("#ffc700")
|
||||||
|
C_GRAY = colors.HexColor("#666666")
|
||||||
|
C_LGRAY = colors.HexColor("#f5f5f5")
|
||||||
|
C_RULE = colors.HexColor("#e0e0e0")
|
||||||
|
C_WHITE = colors.white
|
||||||
|
CONTENT_W = PAGE_W - 2 * MARGIN
|
||||||
|
HALF_W = (CONTENT_W - 0.3 * cm) / 2 # for 2-col feature grid
|
||||||
|
|
||||||
|
# ── Styles ────────────────────────────────────────────────────────────────
|
||||||
|
s = getSampleStyleSheet()
|
||||||
|
def ps(name, **kw):
|
||||||
|
return ParagraphStyle(name, parent=s["Normal"], **kw)
|
||||||
|
|
||||||
|
s_body = ps("body", fontSize=9.5, textColor=C_DARK, leading=14)
|
||||||
|
s_small = ps("sml", fontSize=8, textColor=C_GRAY, leading=12)
|
||||||
|
s_label = ps("lbl", fontSize=8.5, textColor=C_GRAY, leading=13)
|
||||||
|
s_value = ps("val", fontSize=9.5, textColor=C_DARK, leading=13, fontName="Helvetica-Bold")
|
||||||
|
s_sec = ps("sec", fontSize=9, textColor=C_GRAY, leading=12, fontName="Helvetica-Bold",
|
||||||
|
spaceBefore=0, spaceAfter=0, letterSpacing=0.8)
|
||||||
|
s_price = ps("prc", fontSize=19, textColor=C_DARK, leading=24, fontName="Helvetica-Bold")
|
||||||
|
s_feat = ps("feat", fontSize=8.5, textColor=C_DARK, leading=12, fontName="Helvetica-Bold")
|
||||||
|
s_fdesc = ps("fdsc", fontSize=7.5, textColor=C_GRAY, leading=11)
|
||||||
|
|
||||||
|
# ── Computed values ───────────────────────────────────────────────────────
|
||||||
|
today = datetime.utcnow().strftime("%d.%m.%Y")
|
||||||
|
validity = (datetime.utcnow() + timedelta(days=30)).strftime("%d.%m.%Y")
|
||||||
|
offer_number = f"ANG-{datetime.utcnow().strftime('%Y%m%d-%H%M')}"
|
||||||
|
plan_name = key_description or "Standard Plan"
|
||||||
|
price_str = _fmt_eur(price)
|
||||||
|
freq_cap = payment_frequency.capitalize()
|
||||||
|
|
||||||
|
if daily_token_limit:
|
||||||
|
posts_per_month = int(daily_token_limit * 30 / AVG_TOKENS_PER_POST)
|
||||||
|
token_str = (
|
||||||
|
f"{daily_token_limit:,}".replace(",", ".") +
|
||||||
|
f" Tokens / Tag (ca. {posts_per_month} Posts / Monat)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
token_str = "Unbegrenzt"
|
||||||
|
|
||||||
|
story = []
|
||||||
|
|
||||||
|
# ── Header: Logo + ANGEBOT ────────────────────────────────────────────────
|
||||||
|
logo_path = Path(__file__).parent.parent / "static" / "logo.png"
|
||||||
|
logo = (
|
||||||
|
Image(str(logo_path), width=2.8 * cm, height=2.8 * cm, kind="proportional")
|
||||||
|
if logo_path.exists()
|
||||||
|
else Paragraph("<b>Onyva</b>", ps("lt", fontSize=13, textColor=C_WHITE, fontName="Helvetica-Bold"))
|
||||||
|
)
|
||||||
|
hdr = Table(
|
||||||
|
[[
|
||||||
|
logo,
|
||||||
|
[
|
||||||
|
Paragraph('<font color="#ffc700"><b>ANGEBOT</b></font>',
|
||||||
|
ps("ttl", fontSize=20, fontName="Helvetica-Bold", alignment=2, leading=25)),
|
||||||
|
Spacer(1, 0.1 * cm),
|
||||||
|
Paragraph(f'<font color="#aaaaaa">{offer_number}</font>',
|
||||||
|
ps("anr", fontSize=8, textColor=C_GRAY, alignment=2, leading=12)),
|
||||||
|
],
|
||||||
|
]],
|
||||||
|
colWidths=[4.5 * cm, CONTENT_W - 4.5 * cm],
|
||||||
|
)
|
||||||
|
hdr.setStyle(TableStyle([
|
||||||
|
("BACKGROUND", (0, 0), (-1, -1), C_DARK),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 10),
|
||||||
|
("BOTTOMPADDING", (0, 0), (-1, -1), 10),
|
||||||
|
("LEFTPADDING", (0, 0), (0, -1), 14),
|
||||||
|
("RIGHTPADDING", (-1, 0), (-1, -1), 14),
|
||||||
|
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||||
|
]))
|
||||||
|
story.append(hdr)
|
||||||
|
story.append(Spacer(1, 0.45 * cm))
|
||||||
|
|
||||||
|
# ── Meta block (recipient / date / validity) ──────────────────────────────
|
||||||
|
meta = Table(
|
||||||
|
[
|
||||||
|
[Paragraph("Empfänger", s_label), Paragraph(recipient_email, s_value),
|
||||||
|
Paragraph("Datum", s_label), Paragraph(today, s_body)],
|
||||||
|
[Paragraph("Plan", s_label), Paragraph(plan_name, s_body),
|
||||||
|
Paragraph("Gültig bis",s_label), Paragraph(validity, s_body)],
|
||||||
|
],
|
||||||
|
colWidths=[2.8 * cm, CONTENT_W / 2 - 2.8 * cm, 2.2 * cm, CONTENT_W / 2 - 2.2 * cm],
|
||||||
|
)
|
||||||
|
meta.setStyle(TableStyle([
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 2),
|
||||||
|
("BOTTOMPADDING", (0, 0), (-1, -1), 2),
|
||||||
|
("LEFTPADDING", (0, 0), (-1, -1), 0),
|
||||||
|
("RIGHTPADDING", (0, 0), (-1, -1), 4),
|
||||||
|
]))
|
||||||
|
story.append(meta)
|
||||||
|
story.append(Spacer(1, 0.3 * cm))
|
||||||
|
story.append(HRFlowable(width="100%", thickness=1.5, color=C_HIGHLIGHT, spaceAfter=0.35 * cm))
|
||||||
|
|
||||||
|
# ── Intro (one compact line) ───────────────────────────────────────────────
|
||||||
|
story.append(Paragraph(
|
||||||
|
"Sehr geehrte Damen und Herren, nachfolgend erhalten Sie unser Angebot für die Nutzung der "
|
||||||
|
"<b>Onyva LinkedIn Content Automation</b> Plattform.",
|
||||||
|
s_body,
|
||||||
|
))
|
||||||
|
story.append(Spacer(1, 0.35 * cm))
|
||||||
|
|
||||||
|
# ── Section: Lizenzdetails ────────────────────────────────────────────────
|
||||||
|
story.append(Paragraph("LIZENZDETAILS", s_sec))
|
||||||
|
story.append(Spacer(1, 0.15 * cm))
|
||||||
|
|
||||||
|
# Compact limits bar (single row, two columns)
|
||||||
|
limits_bar = Table(
|
||||||
|
[[
|
||||||
|
Paragraph(f"<b>Mitarbeiter:</b> {max_employees} Nutzer", s_body),
|
||||||
|
Paragraph(f"<b>KI-Token-Limit:</b> {token_str}", s_body),
|
||||||
|
]],
|
||||||
|
colWidths=[CONTENT_W / 2, CONTENT_W / 2],
|
||||||
|
)
|
||||||
|
limits_bar.setStyle(TableStyle([
|
||||||
|
("BACKGROUND", (0, 0), (-1, -1), C_WHITE),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 6),
|
||||||
|
("BOTTOMPADDING",(0, 0), (-1, -1), 6),
|
||||||
|
("LEFTPADDING", (0, 0), (-1, -1), 10),
|
||||||
|
("RIGHTPADDING", (0, 0), (-1, -1), 10),
|
||||||
|
("GRID", (0, 0), (-1, -1), 0.5, C_RULE),
|
||||||
|
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||||
|
]))
|
||||||
|
story.append(limits_bar)
|
||||||
|
story.append(Spacer(1, 0.25 * cm))
|
||||||
|
|
||||||
|
# Features: 2-column grid
|
||||||
|
FEATURES = [
|
||||||
|
("LinkedIn-Profil-Analyse", "KI-gestützt, einmalig pro Profil – Basis für individuelle Content-Strategie"),
|
||||||
|
("Post-Typen & Strategie", "Definierbare Typen mit Gewichtung, KI-Analyse & Strategieanpassung"),
|
||||||
|
("KI-Post-Erstellung", "Chat-basiert, iterativ, mit Qualitätsprüfung & Stilvalidierung"),
|
||||||
|
("Mitarbeiterverwaltung", "Posts verwalten, kommentieren, freigeben oder ablehnen per UI"),
|
||||||
|
("Research-Funktion", "Automatische Themenrecherche zu aktuellen Branchentrends"),
|
||||||
|
("Posting-Kalender", "Planung & Terminierung von Posts über alle Mitarbeiter hinweg"),
|
||||||
|
("E-Mail-Workflows", "Freigabe-Anfragen & Entscheidungsbenachrichtigungen per E-Mail"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def feat_cell(name, desc):
|
||||||
|
return Paragraph(
|
||||||
|
f'<font color="#2d3838"><b>✓ {name}</b></font><br/>'
|
||||||
|
f'<font size="7.5" color="#888888"> {desc}</font>',
|
||||||
|
ps(f"fc_{name[:4]}", fontSize=8.5, leading=13),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pair features into rows of 2
|
||||||
|
feat_rows = []
|
||||||
|
for i in range(0, len(FEATURES), 2):
|
||||||
|
left = feat_cell(*FEATURES[i])
|
||||||
|
right = feat_cell(*FEATURES[i + 1]) if i + 1 < len(FEATURES) else Paragraph("", s_small)
|
||||||
|
feat_rows.append([left, right])
|
||||||
|
|
||||||
|
feat_t = Table(feat_rows, colWidths=[HALF_W, HALF_W], hAlign="LEFT")
|
||||||
|
feat_t.setStyle(TableStyle([
|
||||||
|
("BACKGROUND", (0, 0), (-1, -1), C_WHITE),
|
||||||
|
("GRID", (0, 0), (-1, -1), 0.4, C_RULE),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 5),
|
||||||
|
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
|
||||||
|
("LEFTPADDING", (0, 0), (-1, -1), 8),
|
||||||
|
("RIGHTPADDING", (0, 0), (-1, -1), 8),
|
||||||
|
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||||
|
]))
|
||||||
|
story.append(feat_t)
|
||||||
|
story.append(Spacer(1, 0.4 * cm))
|
||||||
|
|
||||||
|
# ── Section: Preisgestaltung ──────────────────────────────────────────────
|
||||||
|
story.append(Paragraph("PREISGESTALTUNG", s_sec))
|
||||||
|
story.append(Spacer(1, 0.15 * cm))
|
||||||
|
|
||||||
|
price_rows = [
|
||||||
|
[Paragraph("Preis", s_label), Paragraph(price_str, s_price)],
|
||||||
|
[Paragraph("Zahlungsweise", s_label), Paragraph(freq_cap, s_body)],
|
||||||
|
]
|
||||||
|
if payment_frequency == "jährlich":
|
||||||
|
price_rows.append([Paragraph("Jahresbetrag", s_label), Paragraph(_fmt_eur(price * 12), s_body)])
|
||||||
|
|
||||||
|
price_t = Table(price_rows, colWidths=[4 * cm, CONTENT_W - 4 * cm])
|
||||||
|
price_t.setStyle(TableStyle([
|
||||||
|
("BACKGROUND", (0, 0), (-1, -1), C_WHITE),
|
||||||
|
("GRID", (0, 0), (-1, -1), 0.5, C_RULE),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 6),
|
||||||
|
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
|
||||||
|
("LEFTPADDING", (0, 0), (-1, -1), 10),
|
||||||
|
("RIGHTPADDING", (0, 0), (-1, -1), 10),
|
||||||
|
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||||
|
]))
|
||||||
|
story.append(price_t)
|
||||||
|
story.append(Spacer(1, 0.2 * cm))
|
||||||
|
story.append(Paragraph("Alle Preise zzgl. gesetzlicher Mehrwertsteuer.", s_small))
|
||||||
|
story.append(Spacer(1, 0.5 * cm))
|
||||||
|
|
||||||
|
# ── Footer ────────────────────────────────────────────────────────────────
|
||||||
|
story.append(HRFlowable(width="100%", thickness=0.5, color=C_RULE, spaceAfter=0.25 * cm))
|
||||||
|
story.append(Paragraph(
|
||||||
|
"Bei Fragen stehen wir Ihnen gerne zur Verfügung – Kontakt: <b>team@onyva.de</b>",
|
||||||
|
s_small,
|
||||||
|
))
|
||||||
|
story.append(Spacer(1, 0.35 * cm))
|
||||||
|
story.append(Paragraph("Mit freundlichen Grüßen,<br/><b>Onyva</b>", s_body))
|
||||||
|
|
||||||
|
doc.build(story)
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.post("/api/license-keys/{key_id}/send-offer")
|
||||||
|
async def send_license_offer(
|
||||||
|
request: Request,
|
||||||
|
key_id: str,
|
||||||
|
email: str = Form(...),
|
||||||
|
price: float = Form(...),
|
||||||
|
payment_frequency: str = Form(...),
|
||||||
|
):
|
||||||
|
"""Generate a PDF offer and send it via email."""
|
||||||
|
if not verify_auth(request):
|
||||||
|
raise HTTPException(status_code=401)
|
||||||
|
|
||||||
|
try:
|
||||||
|
key = await db.get_license_key_by_id(UUID(key_id))
|
||||||
|
if not key:
|
||||||
|
raise HTTPException(status_code=404, detail="Lizenzschlüssel nicht gefunden")
|
||||||
|
|
||||||
|
pdf_bytes = _build_offer_pdf(
|
||||||
|
key_description=key.description or "",
|
||||||
|
max_employees=key.max_employees,
|
||||||
|
daily_token_limit=key.daily_token_limit,
|
||||||
|
price=price,
|
||||||
|
payment_frequency=payment_frequency,
|
||||||
|
recipient_email=email,
|
||||||
|
)
|
||||||
|
|
||||||
|
posts_note = ""
|
||||||
|
if key.daily_token_limit:
|
||||||
|
posts_per_month = int(key.daily_token_limit * 30 / AVG_TOKENS_PER_POST)
|
||||||
|
token_str = f"{key.daily_token_limit:,}".replace(",", ".") + f" Tokens/Tag (ca. {posts_per_month} Posts/Monat)"
|
||||||
|
else:
|
||||||
|
token_str = "Unbegrenzt"
|
||||||
|
|
||||||
|
price_str = f"{price:,.2f} €".replace(",", "X").replace(".", ",").replace("X", ".")
|
||||||
|
plan_name = key.description or "Standard Plan"
|
||||||
|
|
||||||
|
html_body = f"""
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; background: #ffffff;">
|
||||||
|
<div style="background: #2d3838; padding: 24px 32px; border-radius: 8px 8px 0 0;">
|
||||||
|
<h1 style="color: #ffc700; margin: 0; font-size: 22px;">LinkedIn Content Automation</h1>
|
||||||
|
<p style="color: #cccccc; margin: 6px 0 0; font-size: 13px;">Ihr persönliches Angebot</p>
|
||||||
|
</div>
|
||||||
|
<div style="background: #f9f9f9; padding: 32px;">
|
||||||
|
<p style="color: #333; font-size: 15px;">Sehr geehrte Damen und Herren,</p>
|
||||||
|
<p style="color: #333; font-size: 14px; line-height: 1.6;">
|
||||||
|
vielen Dank für Ihr Interesse an unserer LinkedIn Content Automation Lösung.<br>
|
||||||
|
Im Anhang finden Sie Ihr persönliches Angebot als PDF-Dokument.
|
||||||
|
</p>
|
||||||
|
<div style="background: #2d3838; border-radius: 8px; padding: 20px 24px; margin: 24px 0;">
|
||||||
|
<h3 style="color: #ffc700; margin: 0 0 12px; font-size: 14px; text-transform: uppercase; letter-spacing: 1px;">Angebotsübersicht</h3>
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
<tr><td style="color: #aaa; font-size: 13px; padding: 4px 0;">Plan</td><td style="color: #fff; font-size: 13px; font-weight: bold;">{plan_name}</td></tr>
|
||||||
|
<tr><td style="color: #aaa; font-size: 13px; padding: 4px 0;">Mitarbeiter</td><td style="color: #fff; font-size: 13px;">{key.max_employees} Nutzer</td></tr>
|
||||||
|
<tr><td style="color: #aaa; font-size: 13px; padding: 4px 0;">Token-Limit</td><td style="color: #fff; font-size: 13px;">{token_str}</td></tr>
|
||||||
|
<tr style="border-top: 1px solid #4a5858;"><td style="color: #aaa; font-size: 13px; padding: 8px 0 4px;">Preis</td><td style="color: #ffc700; font-size: 18px; font-weight: bold; padding-top: 6px;">{price_str}</td></tr>
|
||||||
|
<tr><td style="color: #aaa; font-size: 13px; padding: 4px 0;">Zahlungsweise</td><td style="color: #fff; font-size: 13px;">{payment_frequency.capitalize()}</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p style="color: #666; font-size: 13px;">Alle Preise zzgl. gesetzlicher MwSt. Das Angebot ist 30 Tage gültig.</p>
|
||||||
|
<p style="color: #333; font-size: 14px;">Bei Fragen stehen wir Ihnen gerne zur Verfügung.</p>
|
||||||
|
<p style="color: #333; font-size: 14px;">Mit freundlichen Grüßen,<br><strong>Onyva</strong></p>
|
||||||
|
</div>
|
||||||
|
<div style="background: #2d3838; padding: 16px 32px; border-radius: 0 0 8px 8px; text-align: center;">
|
||||||
|
<p style="color: #888; font-size: 11px; margin: 0;">Onyva • team@onyva.de</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
offer_number = datetime.utcnow().strftime('%Y%m%d%H%M')
|
||||||
|
filename = f"Angebot_{offer_number}.pdf"
|
||||||
|
success = send_email_with_attachment(
|
||||||
|
to_email=email,
|
||||||
|
subject=f"Ihr Angebot – LinkedIn Content Automation ({plan_name})",
|
||||||
|
html_content=html_body,
|
||||||
|
attachment_bytes=pdf_bytes,
|
||||||
|
attachment_filename=filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=500, detail="E-Mail konnte nicht gesendet werden. SMTP prüfen.")
|
||||||
|
|
||||||
|
return JSONResponse({"success": True, "message": f"Angebot erfolgreich an {email} gesendet."})
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error sending license offer: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|||||||
@@ -62,8 +62,7 @@
|
|||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<div class="text-sm text-gray-300 space-y-1">
|
<div class="text-sm text-gray-300 space-y-1">
|
||||||
<div>👥 {{ key.max_employees }} Mitarbeiter</div>
|
<div>👥 {{ key.max_employees }} Mitarbeiter</div>
|
||||||
<div>📝 {{ key.max_posts_per_day }} Posts/Tag</div>
|
<div>🪙 {% if key.daily_token_limit %}{{ key.daily_token_limit | int | string }} Tokens/Tag{% else %}Unbegrenzt{% endif %}</div>
|
||||||
<div>🔍 {{ key.max_researches_per_day }} Researches/Tag</div>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
@@ -81,13 +80,20 @@
|
|||||||
{{ key.created_at.strftime('%d.%m.%Y') if key.created_at else '-' }}
|
{{ key.created_at.strftime('%d.%m.%Y') if key.created_at else '-' }}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-right">
|
<td class="px-6 py-4 text-right">
|
||||||
<button onclick="editKey('{{ key.id }}', {{ key.max_employees }}, {{ key.max_posts_per_day }}, {{ key.max_researches_per_day }}, '{{ key.description or '' }}')"
|
<button onclick="editKey('{{ key.id }}', {{ key.max_employees }}, {{ key.daily_token_limit or '' }}, '{{ key.description or '' }}')"
|
||||||
class="text-blue-400 hover:text-blue-300 p-2 rounded transition-colors"
|
class="text-blue-400 hover:text-blue-300 p-2 rounded transition-colors"
|
||||||
title="Bearbeiten">
|
title="Bearbeiten">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button onclick="openOfferModal('{{ key.id }}', {{ key.max_employees }}, {{ key.daily_token_limit or 0 }}, '{{ key.description or '' }}')"
|
||||||
|
class="text-green-400 hover:text-green-300 p-2 rounded transition-colors"
|
||||||
|
title="Angebot senden">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
{% if not key.used %}
|
{% if not key.used %}
|
||||||
<button onclick="copyKey('{{ key.key }}')"
|
<button onclick="copyKey('{{ key.key }}')"
|
||||||
class="text-brand-highlight hover:text-brand-highlight-dark p-2 rounded transition-colors"
|
class="text-brand-highlight hover:text-brand-highlight-dark p-2 rounded transition-colors"
|
||||||
@@ -129,18 +135,11 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||||||
Max. Posts pro Tag
|
Tägliches Token-Limit
|
||||||
</label>
|
</label>
|
||||||
<input type="number" id="edit_max_posts_per_day" name="max_posts_per_day" min="1" required
|
<input type="number" id="edit_daily_token_limit" name="daily_token_limit" min="1000" placeholder="z.B. 100000"
|
||||||
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
|
||||||
Max. Researches pro Tag
|
|
||||||
</label>
|
|
||||||
<input type="number" id="edit_max_researches_per_day" name="max_researches_per_day" min="1" required
|
|
||||||
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white">
|
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Leer lassen = unbegrenzt</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -164,6 +163,80 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Offer Modal -->
|
||||||
|
<div id="offerModal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div class="card-bg rounded-xl border p-8 max-w-lg w-full mx-4">
|
||||||
|
<h2 class="text-2xl font-bold text-white mb-1">Angebot senden</h2>
|
||||||
|
<p class="text-gray-400 text-sm mb-6">Erstellt ein professionelles PDF-Angebot und sendet es per E-Mail.</p>
|
||||||
|
|
||||||
|
<form id="offerForm" class="space-y-4">
|
||||||
|
<input type="hidden" id="offer_key_id" name="key_id">
|
||||||
|
|
||||||
|
<!-- Calculated Info -->
|
||||||
|
<div id="offerCalcInfo" class="bg-brand-bg/60 rounded-lg p-4 text-sm space-y-1 border border-gray-600">
|
||||||
|
<div class="flex justify-between"><span class="text-gray-400">Plan:</span><span id="offer_plan" class="text-white font-medium"></span></div>
|
||||||
|
<div class="flex justify-between"><span class="text-gray-400">Mitarbeiter:</span><span id="offer_employees" class="text-white"></span></div>
|
||||||
|
<div class="flex justify-between"><span class="text-gray-400">Token-Limit/Tag:</span><span id="offer_tokens" class="text-white"></span></div>
|
||||||
|
<div class="flex justify-between"><span class="text-gray-400">Ca. Posts/Monat:</span><span id="offer_posts" class="text-white"></span></div>
|
||||||
|
<div class="border-t border-gray-600 pt-2 mt-2 flex justify-between"><span class="text-gray-400">Empfohlener Preis:</span><span id="offer_suggested" class="text-brand-highlight font-bold"></span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Price -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-300 mb-2">Angebotspreis (€/Monat)</label>
|
||||||
|
<input type="number" id="offer_price" name="price" step="0.01" min="0" required
|
||||||
|
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white focus:border-brand-highlight focus:outline-none">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Payment frequency -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-300 mb-2">Zahlungsweise</label>
|
||||||
|
<div class="grid grid-cols-3 gap-2" id="freq-buttons">
|
||||||
|
<button type="button" onclick="selectFreq('monatlich')" id="freq-monatlich"
|
||||||
|
class="freq-btn active-freq px-4 py-2.5 rounded-lg text-sm font-medium border transition-colors">
|
||||||
|
Monatlich
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="selectFreq('jährlich')" id="freq-jährlich"
|
||||||
|
class="freq-btn px-4 py-2.5 rounded-lg text-sm font-medium border transition-colors">
|
||||||
|
Jährlich
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="selectFreq('einmalig')" id="freq-einmalig"
|
||||||
|
class="freq-btn px-4 py-2.5 rounded-lg text-sm font-medium border transition-colors">
|
||||||
|
Einmalig
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="offer_payment_frequency" name="payment_frequency" value="monatlich">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Yearly total hint -->
|
||||||
|
<div id="yearly-hint" class="hidden text-sm text-gray-400 bg-brand-bg/40 rounded p-3 border border-gray-700">
|
||||||
|
Jährlicher Gesamtbetrag: <span id="yearly-total" class="text-white font-semibold"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-300 mb-2">E-Mail-Adresse</label>
|
||||||
|
<input type="email" id="offer_email" name="email" placeholder="kunde@beispiel.de" required
|
||||||
|
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white focus:border-brand-highlight focus:outline-none">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="offer-status" class="hidden text-sm rounded-lg px-4 py-3"></div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-2">
|
||||||
|
<button type="submit" id="offerSubmitBtn"
|
||||||
|
class="flex-1 px-6 py-3 btn-primary rounded-lg font-medium flex items-center justify-center gap-2">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
|
||||||
|
Angebot senden
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="closeOfferModal()"
|
||||||
|
class="px-6 py-3 bg-gray-600 hover:bg-gray-700 text-white rounded-lg font-medium transition-colors">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Generate Modal -->
|
<!-- Generate Modal -->
|
||||||
<div id="generateModal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
<div id="generateModal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
<div class="card-bg rounded-xl border p-8 max-w-md w-full mx-4">
|
<div class="card-bg rounded-xl border p-8 max-w-md w-full mx-4">
|
||||||
@@ -180,18 +253,11 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||||||
Max. Posts pro Tag
|
Tägliches Token-Limit
|
||||||
</label>
|
</label>
|
||||||
<input type="number" name="max_posts_per_day" min="1" value="10" required
|
<input type="number" name="daily_token_limit" min="1000" value="100000"
|
||||||
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
|
||||||
Max. Researches pro Tag
|
|
||||||
</label>
|
|
||||||
<input type="number" name="max_researches_per_day" min="1" value="5" required
|
|
||||||
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white">
|
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Leer lassen = unbegrenzt</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -216,6 +282,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<style>
|
||||||
|
.freq-btn { background-color: #3d4848; border-color: #5a6868; color: #9ca3af; }
|
||||||
|
.freq-btn:hover { background-color: #4a5858; color: #e5e7eb; }
|
||||||
|
.freq-btn.active-freq { background-color: #ffc700; border-color: #ffc700; color: #2d3838; font-weight: 700; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
function openGenerateModal() {
|
function openGenerateModal() {
|
||||||
@@ -227,11 +301,10 @@ function closeGenerateModal() {
|
|||||||
document.getElementById('generateForm').reset();
|
document.getElementById('generateForm').reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
function editKey(keyId, maxEmployees, maxPostsPerDay, maxResearchesPerDay, description) {
|
function editKey(keyId, maxEmployees, dailyTokenLimit, description) {
|
||||||
document.getElementById('edit_key_id').value = keyId;
|
document.getElementById('edit_key_id').value = keyId;
|
||||||
document.getElementById('edit_max_employees').value = maxEmployees;
|
document.getElementById('edit_max_employees').value = maxEmployees;
|
||||||
document.getElementById('edit_max_posts_per_day').value = maxPostsPerDay;
|
document.getElementById('edit_daily_token_limit').value = dailyTokenLimit || '';
|
||||||
document.getElementById('edit_max_researches_per_day').value = maxResearchesPerDay;
|
|
||||||
document.getElementById('edit_description').value = description;
|
document.getElementById('edit_description').value = description;
|
||||||
document.getElementById('editModal').classList.remove('hidden');
|
document.getElementById('editModal').classList.remove('hidden');
|
||||||
}
|
}
|
||||||
@@ -300,5 +373,108 @@ async function deleteKey(keyId) {
|
|||||||
alert('Fehler beim Löschen: ' + error.message);
|
alert('Fehler beim Löschen: ' + error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Offer Modal ────────────────────────────────────────────────────────────
|
||||||
|
const AVG_TOKENS_PER_POST = 50000;
|
||||||
|
const API_COST_PER_1K_EUR = 0.003;
|
||||||
|
const SERVER_SHARE_EUR = 1.60;
|
||||||
|
|
||||||
|
function calcSuggestedPrice(dailyTokenLimit) {
|
||||||
|
if (!dailyTokenLimit) return null;
|
||||||
|
const monthlyTokens = dailyTokenLimit * 30;
|
||||||
|
const apiCostEur = (monthlyTokens / 1000) * API_COST_PER_1K_EUR;
|
||||||
|
const raw = apiCostEur * 2.5 + SERVER_SHARE_EUR * 2 + 5;
|
||||||
|
return Math.ceil(raw / 5) * 5; // round up to nearest 5€
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtNum(n) {
|
||||||
|
return n.toLocaleString('de-DE');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openOfferModal(keyId, maxEmployees, dailyTokenLimit, description) {
|
||||||
|
document.getElementById('offer_key_id').value = keyId;
|
||||||
|
document.getElementById('offer_plan').textContent = description || 'Standard Plan';
|
||||||
|
document.getElementById('offer_employees').textContent = maxEmployees + ' Nutzer';
|
||||||
|
|
||||||
|
if (dailyTokenLimit) {
|
||||||
|
const postsPerMonth = Math.floor(dailyTokenLimit * 30 / AVG_TOKENS_PER_POST);
|
||||||
|
document.getElementById('offer_tokens').textContent = fmtNum(dailyTokenLimit) + ' Tokens/Tag';
|
||||||
|
document.getElementById('offer_posts').textContent = '~' + postsPerMonth + ' Posts';
|
||||||
|
const suggested = calcSuggestedPrice(dailyTokenLimit);
|
||||||
|
document.getElementById('offer_suggested').textContent = suggested + ' €';
|
||||||
|
document.getElementById('offer_price').value = suggested;
|
||||||
|
} else {
|
||||||
|
document.getElementById('offer_tokens').textContent = 'Unbegrenzt';
|
||||||
|
document.getElementById('offer_posts').textContent = '–';
|
||||||
|
document.getElementById('offer_suggested').textContent = '–';
|
||||||
|
}
|
||||||
|
|
||||||
|
selectFreq('monatlich');
|
||||||
|
document.getElementById('offer_email').value = '';
|
||||||
|
document.getElementById('offer-status').classList.add('hidden');
|
||||||
|
document.getElementById('offerModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeOfferModal() {
|
||||||
|
document.getElementById('offerModal').classList.add('hidden');
|
||||||
|
document.getElementById('offerForm').reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectFreq(freq) {
|
||||||
|
document.getElementById('offer_payment_frequency').value = freq;
|
||||||
|
['monatlich', 'jährlich', 'einmalig'].forEach(f => {
|
||||||
|
const btn = document.getElementById('freq-' + f);
|
||||||
|
btn.classList.toggle('active-freq', f === freq);
|
||||||
|
});
|
||||||
|
// Show yearly total hint
|
||||||
|
const price = parseFloat(document.getElementById('offer_price').value) || 0;
|
||||||
|
const hint = document.getElementById('yearly-hint');
|
||||||
|
if (freq === 'jährlich' && price > 0) {
|
||||||
|
document.getElementById('yearly-total').textContent = (price * 12).toLocaleString('de-DE', {minimumFractionDigits: 2}) + ' €';
|
||||||
|
hint.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
hint.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('offer_price').addEventListener('input', () => {
|
||||||
|
if (document.getElementById('offer_payment_frequency').value === 'jährlich') {
|
||||||
|
selectFreq('jährlich');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('offerForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
const keyId = formData.get('key_id');
|
||||||
|
const statusEl = document.getElementById('offer-status');
|
||||||
|
const submitBtn = document.getElementById('offerSubmitBtn');
|
||||||
|
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = 'Wird gesendet…';
|
||||||
|
statusEl.classList.add('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/admin/api/license-keys/${keyId}/send-offer`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(data.detail || 'Fehler beim Senden');
|
||||||
|
|
||||||
|
statusEl.className = 'text-sm rounded-lg px-4 py-3 bg-green-900/40 border border-green-600 text-green-300';
|
||||||
|
statusEl.textContent = data.message || 'Angebot erfolgreich gesendet!';
|
||||||
|
statusEl.classList.remove('hidden');
|
||||||
|
submitBtn.textContent = '✓ Gesendet';
|
||||||
|
} catch (error) {
|
||||||
|
statusEl.className = 'text-sm rounded-lg px-4 py-3 bg-red-900/40 border border-red-600 text-red-300';
|
||||||
|
statusEl.textContent = 'Fehler: ' + error.message;
|
||||||
|
statusEl.classList.remove('hidden');
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg> Angebot senden';
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -78,9 +78,17 @@
|
|||||||
|
|
||||||
{% block head %}{% endblock %}
|
{% block head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body class="text-gray-100 min-h-screen flex">
|
<body class="text-gray-100 min-h-screen flex {% if limit_reached %}pt-6{% endif %}">
|
||||||
|
|
||||||
|
{% if limit_reached %}
|
||||||
|
<!-- Token Limit Banner -->
|
||||||
|
<div class="fixed top-0 left-0 right-0 z-[9999] bg-red-600 text-white text-xs font-medium flex items-center justify-center gap-2 px-4" style="height: 1.5rem;">
|
||||||
|
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/></svg>
|
||||||
|
Token-Limit erreicht – keine KI-Aktionen mehr heute möglich. Morgen wird das Limit zurückgesetzt.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<aside id="sidebar" class="w-64 sidebar-bg border-r border-gray-600 flex flex-col fixed h-full">
|
<aside id="sidebar" class="w-64 sidebar-bg border-r border-gray-600 flex flex-col fixed h-full top-0" {% if limit_reached %}style="top: 1.5rem; height: calc(100vh - 1.5rem);"{% endif %}>
|
||||||
<div class="p-4 border-b border-gray-600">
|
<div class="p-4 border-b border-gray-600">
|
||||||
<div class="flex items-center justify-between gap-3 logo-container">
|
<div class="flex items-center justify-between gap-3 logo-container">
|
||||||
<div class="logo-full">
|
<div class="logo-full">
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Posts Sidebar -->
|
<!-- Posts Sidebar -->
|
||||||
<div class="posts-sidebar">
|
<div class="posts-sidebar" {% if limit_reached %}style="top: 1.5rem; height: calc(100vh - 1.5rem);"{% endif %}>
|
||||||
<div class="posts-sidebar-header">
|
<div class="posts-sidebar-header">
|
||||||
<button onclick="startNewPost()">
|
<button id="new-post-btn" onclick="startNewPost()" {% if limit_reached %}disabled title="{{ limit_message }}"{% endif %}>
|
||||||
+ Neuer Post
|
+ Neuer Post
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -123,6 +123,12 @@ aside.collapsed ~ main .posts-sidebar {
|
|||||||
background-color: #e6b300;
|
background-color: #e6b300;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.posts-sidebar-header button:disabled {
|
||||||
|
background-color: #4a5568;
|
||||||
|
color: #a0aec0;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
/* Posts List */
|
/* Posts List */
|
||||||
.posts-sidebar-list {
|
.posts-sidebar-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -226,7 +232,7 @@ aside.collapsed ~ main .chat-fixed-input {
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<!-- Header (Fixed at top) -->
|
<!-- Header (Fixed at top) -->
|
||||||
<div class="chat-fixed-header fixed top-0 bg-brand-bg z-20">
|
<div class="chat-fixed-header fixed bg-brand-bg z-20" style="top: {% if limit_reached %}1.5rem{% else %}0{% endif %}">
|
||||||
<div class="px-8 py-4">
|
<div class="px-8 py-4">
|
||||||
<h1 class="text-xl font-bold text-white">💬 Chat Assistent</h1>
|
<h1 class="text-xl font-bold text-white">💬 Chat Assistent</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -277,15 +283,16 @@ aside.collapsed ~ main .chat-fixed-input {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="chat-input"
|
id="chat-input"
|
||||||
placeholder="Beschreibe deinen Post..."
|
placeholder="{% if limit_reached %}Token-Limit erreicht – morgen wieder verfügbar{% else %}Beschreibe deinen Post...{% endif %}"
|
||||||
class="w-full input-bg border border-gray-600 rounded-full px-6 py-3 text-white focus:outline-none focus:border-brand-highlight transition-colors"
|
class="w-full input-bg border border-gray-600 rounded-full px-6 py-3 text-white focus:outline-none focus:border-brand-highlight transition-colors {% if limit_reached %}opacity-50 cursor-not-allowed{% endif %}"
|
||||||
onkeydown="handleChatKeydown(event)">
|
onkeydown="handleChatKeydown(event)"
|
||||||
|
{% if limit_reached %}disabled{% endif %}>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onclick="sendMessage()"
|
onclick="sendMessage()"
|
||||||
id="send-btn"
|
id="send-btn"
|
||||||
class="w-12 h-12 bg-brand-highlight hover:bg-yellow-500 text-black rounded-full transition-all flex items-center justify-center flex-shrink-0 hover:scale-110"
|
class="w-12 h-12 bg-brand-highlight hover:bg-yellow-500 text-black rounded-full transition-all flex items-center justify-center flex-shrink-0 hover:scale-110 {% if limit_reached %}opacity-50 cursor-not-allowed{% endif %}"
|
||||||
title="Senden (Enter)">
|
{% if limit_reached %}disabled title="{{ limit_message }}"{% else %}title="Senden (Enter)"{% endif %}>
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -310,6 +317,28 @@ let currentPost = null;
|
|||||||
let conversationId = null;
|
let conversationId = null;
|
||||||
let selectedPostTypeId = null;
|
let selectedPostTypeId = null;
|
||||||
let currentLoadedPostId = null; // Track active post
|
let currentLoadedPostId = null; // Track active post
|
||||||
|
let tokenLimitReached = {{ 'true' if limit_reached else 'false' }};
|
||||||
|
|
||||||
|
function disableChat(message) {
|
||||||
|
tokenLimitReached = true;
|
||||||
|
const input = document.getElementById('chat-input');
|
||||||
|
const sendBtn = document.getElementById('send-btn');
|
||||||
|
const newPostBtn = document.getElementById('new-post-btn');
|
||||||
|
if (input) {
|
||||||
|
input.disabled = true;
|
||||||
|
input.placeholder = message || 'Token-Limit erreicht – morgen wieder verfügbar';
|
||||||
|
input.classList.add('opacity-50', 'cursor-not-allowed');
|
||||||
|
}
|
||||||
|
if (sendBtn) {
|
||||||
|
sendBtn.disabled = true;
|
||||||
|
sendBtn.classList.add('opacity-50', 'cursor-not-allowed');
|
||||||
|
sendBtn.title = message || 'Token-Limit erreicht';
|
||||||
|
}
|
||||||
|
if (newPostBtn) {
|
||||||
|
newPostBtn.disabled = true;
|
||||||
|
newPostBtn.title = message || 'Token-Limit erreicht';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// User profile data
|
// User profile data
|
||||||
const userProfilePicture = "{{ profile_picture or '' }}";
|
const userProfilePicture = "{{ profile_picture or '' }}";
|
||||||
@@ -348,6 +377,8 @@ function handleChatKeydown(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function sendMessage() {
|
async function sendMessage() {
|
||||||
|
if (tokenLimitReached) return;
|
||||||
|
|
||||||
const input = document.getElementById('chat-input');
|
const input = document.getElementById('chat-input');
|
||||||
const message = input.value.trim();
|
const message = input.value.trim();
|
||||||
|
|
||||||
@@ -436,6 +467,12 @@ async function sendMessage() {
|
|||||||
saveBtn.classList.remove('hidden');
|
saveBtn.classList.remove('hidden');
|
||||||
saveBtn.classList.add('flex');
|
saveBtn.classList.add('flex');
|
||||||
}
|
}
|
||||||
|
} else if (result.token_limit_exceeded) {
|
||||||
|
disableChat(result.error);
|
||||||
|
// Remove temp message, show save option if there's an unsaved new post
|
||||||
|
if (!currentLoadedPostId && currentPost) {
|
||||||
|
showTokenLimitModal();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
showToast('Fehler: ' + (result.error || 'Unbekannter Fehler'), 'error');
|
showToast('Fehler: ' + (result.error || 'Unbekannter Fehler'), 'error');
|
||||||
addMessageToChat('ai', '❌ Entschuldigung, es gab einen Fehler. Bitte versuche es erneut.');
|
addMessageToChat('ai', '❌ Entschuldigung, es gab einen Fehler. Bitte versuche es erneut.');
|
||||||
@@ -599,6 +636,8 @@ function escapeHtml(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startNewPost() {
|
function startNewPost() {
|
||||||
|
if (tokenLimitReached) return;
|
||||||
|
|
||||||
// Reset state
|
// Reset state
|
||||||
chatHistory = [];
|
chatHistory = [];
|
||||||
currentPost = null;
|
currentPost = null;
|
||||||
@@ -824,6 +863,40 @@ function showToast(message, type = 'info') {
|
|||||||
setTimeout(() => toast.remove(), 300);
|
setTimeout(() => toast.remove(), 300);
|
||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Token limit modal (for new posts when limit is reached mid-session)
|
||||||
|
function showTokenLimitModal() {
|
||||||
|
document.getElementById('tokenLimitModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLimitModal() {
|
||||||
|
document.getElementById('tokenLimitModal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAndCloseLimitModal() {
|
||||||
|
closeLimitModal();
|
||||||
|
if (currentPost) {
|
||||||
|
// Trigger save
|
||||||
|
const saveBtn = document.getElementById('save-btn');
|
||||||
|
if (saveBtn && !saveBtn.classList.contains('hidden')) {
|
||||||
|
saveBtn.click();
|
||||||
|
} else {
|
||||||
|
showToast('Bitte speichere deinen Post manuell.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Token Limit Modal -->
|
||||||
|
<div id="tokenLimitModal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div class="card-bg rounded-xl border p-6 max-w-md w-full mx-4">
|
||||||
|
<h3 class="text-lg font-semibold text-white mb-3">Token-Limit erreicht</h3>
|
||||||
|
<p class="text-gray-400 text-sm mb-4">Das tägliche Token-Limit wurde erreicht. Möchtest du deinen bisherigen Post speichern?</p>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button onclick="saveAndCloseLimitModal()" class="flex-1 btn-primary px-4 py-2 rounded-lg">Post speichern</button>
|
||||||
|
<button onclick="closeLimitModal()" class="px-4 py-2 bg-gray-600 rounded-lg text-white">Verwerfen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -79,9 +79,18 @@
|
|||||||
|
|
||||||
{% block head %}{% endblock %}
|
{% block head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body class="text-gray-100 min-h-screen flex">
|
<body class="text-gray-100 min-h-screen flex {% if limit_reached %}pt-6{% endif %}">
|
||||||
|
|
||||||
|
{% if limit_reached %}
|
||||||
|
<!-- Token Limit Banner -->
|
||||||
|
<div class="fixed top-0 left-0 right-0 z-[9999] bg-red-600 text-white text-xs font-medium flex items-center justify-center gap-2 px-4" style="height: 1.5rem;">
|
||||||
|
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/></svg>
|
||||||
|
Token-Limit erreicht – keine KI-Aktionen mehr heute möglich. Morgen wird das Limit zurückgesetzt.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<aside id="sidebar" class="w-64 sidebar-bg border-r border-gray-600 flex flex-col fixed h-full">
|
<aside id="sidebar" class="w-64 sidebar-bg border-r border-gray-600 flex flex-col fixed h-full top-0" {% if limit_reached %}style="top: 1.5rem; height: calc(100vh - 1.5rem);"{% endif %}>
|
||||||
<div class="p-4 border-b border-gray-600">
|
<div class="p-4 border-b border-gray-600">
|
||||||
<div class="flex items-center justify-between gap-3 logo-container">
|
<div class="flex items-center justify-between gap-3 logo-container">
|
||||||
<div class="logo-full">
|
<div class="logo-full">
|
||||||
|
|||||||
@@ -80,46 +80,35 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Daily Quota Section -->
|
<!-- Daily Quota Section -->
|
||||||
{% if quota and license_key %}
|
{% if quota %}
|
||||||
<div class="card-bg border rounded-xl p-6 mb-8">
|
<div class="card-bg border rounded-xl p-6 mb-8">
|
||||||
<h2 class="text-lg font-semibold text-white mb-4">Tägliche Limits</h2>
|
<h2 class="text-lg font-semibold text-white mb-4">Token-Verbrauch heute</h2>
|
||||||
|
|
||||||
<div class="grid md:grid-cols-2 gap-4">
|
<div class="bg-brand-bg/30 rounded-lg p-4">
|
||||||
<!-- Posts Quota -->
|
<div class="flex items-center justify-between mb-2">
|
||||||
<div class="bg-brand-bg/30 rounded-lg p-4">
|
<span class="text-sm text-gray-400">Tokens heute</span>
|
||||||
<div class="flex items-center justify-between mb-2">
|
<span class="text-xs text-gray-500">
|
||||||
<span class="text-sm text-gray-400">Posts heute</span>
|
{{ quota.tokens_used | default(0) }}
|
||||||
<span class="text-xs text-gray-500">{{ quota.posts_created | default(0) }}/{{ license_key.max_posts_per_day }}</span>
|
/
|
||||||
</div>
|
{% if license_key and license_key.daily_token_limit %}{{ license_key.daily_token_limit }}{% else %}∞{% endif %}
|
||||||
<div class="w-full bg-brand-bg rounded-full h-2">
|
</span>
|
||||||
{% set posts_pct = ((quota.posts_created / license_key.max_posts_per_day * 100) if license_key.max_posts_per_day > 0 else 0) | round %}
|
|
||||||
<div class="bg-brand-highlight h-2 rounded-full transition-all"
|
|
||||||
style="width: {{ posts_pct }}%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="w-full bg-brand-bg rounded-full h-2">
|
||||||
<!-- Researches Quota -->
|
{% if license_key and license_key.daily_token_limit and license_key.daily_token_limit > 0 %}
|
||||||
<div class="bg-brand-bg/30 rounded-lg p-4">
|
{% set token_pct = ((quota.tokens_used / license_key.daily_token_limit * 100) | round) %}
|
||||||
<div class="flex items-center justify-between mb-2">
|
{% set token_pct = [token_pct, 100] | min %}
|
||||||
<span class="text-sm text-gray-400">Researches heute</span>
|
{% else %}
|
||||||
<span class="text-xs text-gray-500">{{ quota.researches_created | default(0) }}/{{ license_key.max_researches_per_day }}</span>
|
{% set token_pct = 0 %}
|
||||||
</div>
|
{% endif %}
|
||||||
<div class="w-full bg-brand-bg rounded-full h-2">
|
<div class="h-2 rounded-full transition-all {% if token_pct >= 90 %}bg-red-500{% elif token_pct >= 70 %}bg-yellow-500{% else %}bg-brand-highlight{% endif %}"
|
||||||
{% set researches_pct = ((quota.researches_created / license_key.max_researches_per_day * 100) if license_key.max_researches_per_day > 0 else 0) | round %}
|
style="width: {{ token_pct }}%"></div>
|
||||||
<div class="bg-blue-500 h-2 rounded-full transition-all"
|
|
||||||
style="width: {{ researches_pct }}%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500 mt-3">
|
<p class="text-xs text-gray-500 mt-3">
|
||||||
Limits werden täglich um Mitternacht zurückgesetzt. (Lizenz: {{ license_key.key }})
|
Limits werden täglich um Mitternacht zurückgesetzt.
|
||||||
|
{% if license_key %}(Lizenz: {{ license_key.key }}){% else %}Unbegrenzter Account.{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% elif quota %}
|
|
||||||
<!-- No license key - show info message -->
|
|
||||||
<div class="bg-blue-900/50 border border-blue-500 text-blue-200 px-4 py-3 rounded-lg mb-6">
|
|
||||||
<strong>Info:</strong> Dieser Account hat keinen Lizenzschlüssel und ist daher unbegrenzt.
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Quick Actions -->
|
<!-- Quick Actions -->
|
||||||
|
|||||||
@@ -13,23 +13,6 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Limit Warning -->
|
|
||||||
{% if limit_reached %}
|
|
||||||
<div class="max-w-2xl mx-auto mb-8">
|
|
||||||
<div class="bg-red-900/50 border border-red-500 text-red-200 px-6 py-4 rounded-xl">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<svg class="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<strong class="font-semibold">Limit erreicht</strong>
|
|
||||||
<p class="text-sm mt-1">{{ limit_message }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Wizard Container (hidden during generation) -->
|
<!-- Wizard Container (hidden during generation) -->
|
||||||
<div id="wizardContainer" {% if limit_reached %}style="pointer-events: none; opacity: 0.5;"{% endif %}>
|
<div id="wizardContainer" {% if limit_reached %}style="pointer-events: none; opacity: 0.5;"{% endif %}>
|
||||||
<div class="max-w-2xl mx-auto">
|
<div class="max-w-2xl mx-auto">
|
||||||
|
|||||||
@@ -18,20 +18,6 @@
|
|||||||
<p class="text-gray-400">Recherchiere neue Content-Themen für {{ employee_name }}</p>
|
<p class="text-gray-400">Recherchiere neue Content-Themen für {{ employee_name }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Limit Warning -->
|
|
||||||
{% if limit_reached %}
|
|
||||||
<div class="bg-red-900/50 border border-red-500 text-red-200 px-6 py-4 rounded-xl mb-8">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<svg class="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<strong class="font-semibold">Limit erreicht</strong>
|
|
||||||
<p class="text-sm mt-1">{{ limit_message }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
<!-- Left: Form -->
|
<!-- Left: Form -->
|
||||||
|
|||||||
@@ -2,23 +2,6 @@
|
|||||||
{% block title %}Post erstellen - LinkedIn Posts{% endblock %}
|
{% block title %}Post erstellen - LinkedIn Posts{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Limit Warning -->
|
|
||||||
{% if limit_reached %}
|
|
||||||
<div class="max-w-2xl mx-auto mb-8">
|
|
||||||
<div class="bg-red-900/50 border border-red-500 text-red-200 px-6 py-4 rounded-xl">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<svg class="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<strong class="font-semibold">Limit erreicht</strong>
|
|
||||||
<p class="text-sm mt-1">{{ limit_message }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Wizard Container (hidden during generation) -->
|
<!-- Wizard Container (hidden during generation) -->
|
||||||
<div id="wizardContainer" {% if limit_reached %}style="pointer-events: none; opacity: 0.5;"{% endif %}>
|
<div id="wizardContainer" {% if limit_reached %}style="pointer-events: none; opacity: 0.5;"{% endif %}>
|
||||||
<div class="max-w-2xl mx-auto">
|
<div class="max-w-2xl mx-auto">
|
||||||
|
|||||||
@@ -730,7 +730,7 @@
|
|||||||
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
|
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
|
||||||
KI-Verbesserungen
|
KI-Verbesserungen
|
||||||
</h3>
|
</h3>
|
||||||
<button onclick="loadSuggestions()" id="refreshSuggestionsBtn" class="p-1.5 rounded-lg bg-brand-bg hover:bg-brand-bg-light text-gray-400 hover:text-white transition-colors" title="KI-Vorschläge generieren">
|
<button onclick="loadSuggestions()" id="refreshSuggestionsBtn" {% if limit_reached %}disabled{% endif %} class="p-1.5 rounded-lg bg-brand-bg hover:bg-brand-bg-light text-gray-400 hover:text-white transition-colors {% if limit_reached %}opacity-50 cursor-not-allowed{% endif %}" title="{% if limit_reached %}{{ limit_message }}{% else %}KI-Vorschläge generieren{% endif %}">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -738,19 +738,19 @@
|
|||||||
<!-- Quick Suggestions (Default) -->
|
<!-- Quick Suggestions (Default) -->
|
||||||
<div id="quickSuggestions" class="space-y-2 mb-4">
|
<div id="quickSuggestions" class="space-y-2 mb-4">
|
||||||
<p class="text-xs text-gray-500 mb-3">Schnelle Anpassungen:</p>
|
<p class="text-xs text-gray-500 mb-3">Schnelle Anpassungen:</p>
|
||||||
<button onclick="applyQuickSuggestion('Mache den Hook emotionaler und aufmerksamkeitsstärker')" class="w-full text-left px-3 py-2 bg-brand-bg/50 hover:bg-brand-bg rounded-lg text-sm text-gray-300 hover:text-white transition-colors flex items-center gap-2">
|
<button {% if not limit_reached %}onclick="applyQuickSuggestion('Mache den Hook emotionaler und aufmerksamkeitsstärker')"{% endif %} {% if limit_reached %}disabled{% endif %} class="w-full text-left px-3 py-2 bg-brand-bg/50 hover:bg-brand-bg rounded-lg text-sm text-gray-300 hover:text-white transition-colors flex items-center gap-2 {% if limit_reached %}opacity-50 cursor-not-allowed{% endif %}">
|
||||||
<span class="text-brand-highlight">⚡</span> Hook verstärken
|
<span class="text-brand-highlight">⚡</span> Hook verstärken
|
||||||
</button>
|
</button>
|
||||||
<button onclick="applyQuickSuggestion('Füge einen starken Call-to-Action am Ende hinzu')" class="w-full text-left px-3 py-2 bg-brand-bg/50 hover:bg-brand-bg rounded-lg text-sm text-gray-300 hover:text-white transition-colors flex items-center gap-2">
|
<button {% if not limit_reached %}onclick="applyQuickSuggestion('Füge einen starken Call-to-Action am Ende hinzu')"{% endif %} {% if limit_reached %}disabled{% endif %} class="w-full text-left px-3 py-2 bg-brand-bg/50 hover:bg-brand-bg rounded-lg text-sm text-gray-300 hover:text-white transition-colors flex items-center gap-2 {% if limit_reached %}opacity-50 cursor-not-allowed{% endif %}">
|
||||||
<span class="text-brand-highlight">🎯</span> Call-to-Action hinzufügen
|
<span class="text-brand-highlight">🎯</span> Call-to-Action hinzufügen
|
||||||
</button>
|
</button>
|
||||||
<button onclick="applyQuickSuggestion('Füge eine kurze persönliche Anekdote oder Erfahrung hinzu')" class="w-full text-left px-3 py-2 bg-brand-bg/50 hover:bg-brand-bg rounded-lg text-sm text-gray-300 hover:text-white transition-colors flex items-center gap-2">
|
<button {% if not limit_reached %}onclick="applyQuickSuggestion('Füge eine kurze persönliche Anekdote oder Erfahrung hinzu')"{% endif %} {% if limit_reached %}disabled{% endif %} class="w-full text-left px-3 py-2 bg-brand-bg/50 hover:bg-brand-bg rounded-lg text-sm text-gray-300 hover:text-white transition-colors flex items-center gap-2 {% if limit_reached %}opacity-50 cursor-not-allowed{% endif %}">
|
||||||
<span class="text-brand-highlight">📖</span> Storytelling einbauen
|
<span class="text-brand-highlight">📖</span> Storytelling einbauen
|
||||||
</button>
|
</button>
|
||||||
<button onclick="applyQuickSuggestion('Verbessere die Struktur: Kürzere Absätze, mehr Weißraum, bessere Lesbarkeit')" class="w-full text-left px-3 py-2 bg-brand-bg/50 hover:bg-brand-bg rounded-lg text-sm text-gray-300 hover:text-white transition-colors flex items-center gap-2">
|
<button {% if not limit_reached %}onclick="applyQuickSuggestion('Verbessere die Struktur: Kürzere Absätze, mehr Weißraum, bessere Lesbarkeit')"{% endif %} {% if limit_reached %}disabled{% endif %} class="w-full text-left px-3 py-2 bg-brand-bg/50 hover:bg-brand-bg rounded-lg text-sm text-gray-300 hover:text-white transition-colors flex items-center gap-2 {% if limit_reached %}opacity-50 cursor-not-allowed{% endif %}">
|
||||||
<span class="text-brand-highlight">📝</span> Struktur optimieren
|
<span class="text-brand-highlight">📝</span> Struktur optimieren
|
||||||
</button>
|
</button>
|
||||||
<button onclick="applyQuickSuggestion('Kürze den Post auf das Wesentliche, entferne überflüssige Worte')" class="w-full text-left px-3 py-2 bg-brand-bg/50 hover:bg-brand-bg rounded-lg text-sm text-gray-300 hover:text-white transition-colors flex items-center gap-2">
|
<button {% if not limit_reached %}onclick="applyQuickSuggestion('Kürze den Post auf das Wesentliche, entferne überflüssige Worte')"{% endif %} {% if limit_reached %}disabled{% endif %} class="w-full text-left px-3 py-2 bg-brand-bg/50 hover:bg-brand-bg rounded-lg text-sm text-gray-300 hover:text-white transition-colors flex items-center gap-2 {% if limit_reached %}opacity-50 cursor-not-allowed{% endif %}">
|
||||||
<span class="text-brand-highlight">✂️</span> Kürzer & prägnanter
|
<span class="text-brand-highlight">✂️</span> Kürzer & prägnanter
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -768,8 +768,8 @@
|
|||||||
<div class="mt-4 pt-4 border-t border-brand-bg-light">
|
<div class="mt-4 pt-4 border-t border-brand-bg-light">
|
||||||
<label class="text-xs text-gray-400 block mb-2">Eigene Anweisung</label>
|
<label class="text-xs text-gray-400 block mb-2">Eigene Anweisung</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<input type="text" id="customSuggestion" placeholder="z.B. Mache es humorvoller" class="flex-1 px-3 py-2 bg-brand-bg border border-brand-bg-light rounded-lg text-sm text-white placeholder-gray-500 focus:outline-none focus:border-brand-highlight">
|
<input type="text" id="customSuggestion" placeholder="z.B. Mache es humorvoller" class="flex-1 px-3 py-2 bg-brand-bg border border-brand-bg-light rounded-lg text-sm text-white placeholder-gray-500 focus:outline-none focus:border-brand-highlight {% if limit_reached %}opacity-50 cursor-not-allowed{% endif %}" {% if limit_reached %}disabled{% endif %}>
|
||||||
<button onclick="applyCustomSuggestion()" id="applyCustomBtn" class="px-3 py-2 bg-brand-highlight hover:bg-brand-highlight/90 text-brand-bg-dark rounded-lg transition-colors">
|
<button {% if not limit_reached %}onclick="applyCustomSuggestion()"{% endif %} id="applyCustomBtn" class="px-3 py-2 bg-brand-highlight hover:bg-brand-highlight/90 text-brand-bg-dark rounded-lg transition-colors {% if limit_reached %}opacity-50 cursor-not-allowed{% endif %}" {% if limit_reached %}disabled{% endif %}>
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M5 5l7 7-7 7"/></svg>
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M5 5l7 7-7 7"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,20 +7,6 @@
|
|||||||
<p class="text-gray-400">Recherchiere neue Content-Themen mit Perplexity AI</p>
|
<p class="text-gray-400">Recherchiere neue Content-Themen mit Perplexity AI</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Limit Warning -->
|
|
||||||
{% if limit_reached %}
|
|
||||||
<div class="bg-red-900/50 border border-red-500 text-red-200 px-6 py-4 rounded-xl mb-8">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<svg class="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<strong class="font-semibold">Limit erreicht</strong>
|
|
||||||
<p class="text-sm mt-1">{{ limit_message }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
<!-- Left: Form -->
|
<!-- Left: Form -->
|
||||||
|
|||||||
@@ -1615,6 +1615,14 @@ async def post_detail_page(request: Request, post_id: str):
|
|||||||
for item in post.media_items
|
for item in post.media_items
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Check token limit for quick action buttons
|
||||||
|
limit_reached = False
|
||||||
|
limit_message = ""
|
||||||
|
if session.account_type in ("company", "employee") and session.company_id:
|
||||||
|
can_proceed, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id))
|
||||||
|
limit_reached = not can_proceed
|
||||||
|
limit_message = error_msg
|
||||||
|
|
||||||
return templates.TemplateResponse("post_detail.html", {
|
return templates.TemplateResponse("post_detail.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"page": "posts",
|
"page": "posts",
|
||||||
@@ -1627,7 +1635,9 @@ async def post_detail_page(request: Request, post_id: str):
|
|||||||
"post_type_analysis": post_type_analysis,
|
"post_type_analysis": post_type_analysis,
|
||||||
"final_feedback": final_feedback,
|
"final_feedback": final_feedback,
|
||||||
"profile_picture_url": profile_picture_url,
|
"profile_picture_url": profile_picture_url,
|
||||||
"media_items_dict": media_items_dict
|
"media_items_dict": media_items_dict,
|
||||||
|
"limit_reached": limit_reached,
|
||||||
|
"limit_message": limit_message
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
import traceback
|
||||||
@@ -1643,11 +1653,11 @@ async def research_page(request: Request):
|
|||||||
if not session:
|
if not session:
|
||||||
return RedirectResponse(url="/login", status_code=302)
|
return RedirectResponse(url="/login", status_code=302)
|
||||||
|
|
||||||
# Check research limit for companies/employees
|
# Check token limit for companies/employees
|
||||||
limit_reached = False
|
limit_reached = False
|
||||||
limit_message = ""
|
limit_message = ""
|
||||||
if session.account_type in ("company", "employee") and session.company_id:
|
if session.account_type in ("company", "employee") and session.company_id:
|
||||||
can_create, error_msg = await db.check_company_research_limit(UUID(session.company_id))
|
can_create, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id))
|
||||||
limit_reached = not can_create
|
limit_reached = not can_create
|
||||||
limit_message = error_msg
|
limit_message = error_msg
|
||||||
|
|
||||||
@@ -1672,11 +1682,11 @@ async def create_post_page(request: Request):
|
|||||||
if not session:
|
if not session:
|
||||||
return RedirectResponse(url="/login", status_code=302)
|
return RedirectResponse(url="/login", status_code=302)
|
||||||
|
|
||||||
# Check post limit for companies/employees
|
# Check token limit for companies/employees
|
||||||
limit_reached = False
|
limit_reached = False
|
||||||
limit_message = ""
|
limit_message = ""
|
||||||
if session.account_type in ("company", "employee") and session.company_id:
|
if session.account_type in ("company", "employee") and session.company_id:
|
||||||
can_create, error_msg = await db.check_company_post_limit(UUID(session.company_id))
|
can_create, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id))
|
||||||
limit_reached = not can_create
|
limit_reached = not can_create
|
||||||
limit_message = error_msg
|
limit_message = error_msg
|
||||||
|
|
||||||
@@ -1703,6 +1713,14 @@ async def chat_create_page(request: Request):
|
|||||||
|
|
||||||
user_id = UUID(session.user_id)
|
user_id = UUID(session.user_id)
|
||||||
|
|
||||||
|
# Check token limit for companies/employees
|
||||||
|
limit_reached = False
|
||||||
|
limit_message = ""
|
||||||
|
if session.account_type in ("company", "employee") and session.company_id:
|
||||||
|
can_create, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id))
|
||||||
|
limit_reached = not can_create
|
||||||
|
limit_message = error_msg
|
||||||
|
|
||||||
# Get post types
|
# Get post types
|
||||||
post_types = await db.get_post_types(user_id)
|
post_types = await db.get_post_types(user_id)
|
||||||
if not post_types:
|
if not post_types:
|
||||||
@@ -1724,7 +1742,9 @@ async def chat_create_page(request: Request):
|
|||||||
"session": session,
|
"session": session,
|
||||||
"post_types": post_types,
|
"post_types": post_types,
|
||||||
"profile_picture": profile_picture,
|
"profile_picture": profile_picture,
|
||||||
"saved_posts": saved_posts
|
"saved_posts": saved_posts,
|
||||||
|
"limit_reached": limit_reached,
|
||||||
|
"limit_message": limit_message
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -1848,11 +1868,9 @@ async def start_research(request: Request, background_tasks: BackgroundTasks, po
|
|||||||
if not session:
|
if not session:
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
|
||||||
# CHECK COMPANY RESEARCH LIMIT and capture company_id for quota tracking
|
# CHECK COMPANY TOKEN LIMIT
|
||||||
quota_company_id = None
|
|
||||||
if session.account_type in ("company", "employee") and session.company_id:
|
if session.account_type in ("company", "employee") and session.company_id:
|
||||||
quota_company_id = UUID(session.company_id)
|
can_create, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id))
|
||||||
can_create, error_msg = await db.check_company_research_limit(quota_company_id)
|
|
||||||
if not can_create:
|
if not can_create:
|
||||||
raise HTTPException(status_code=429, detail=error_msg)
|
raise HTTPException(status_code=429, detail=error_msg)
|
||||||
|
|
||||||
@@ -1876,14 +1894,6 @@ async def start_research(request: Request, background_tasks: BackgroundTasks, po
|
|||||||
post_type_id=UUID(post_type_id) if post_type_id else None
|
post_type_id=UUID(post_type_id) if post_type_id else None
|
||||||
)
|
)
|
||||||
|
|
||||||
# INCREMENT COMPANY QUOTA after successful research
|
|
||||||
if quota_company_id:
|
|
||||||
try:
|
|
||||||
await db.increment_company_researches_quota(quota_company_id)
|
|
||||||
logger.info(f"Incremented research quota for company {quota_company_id}")
|
|
||||||
except Exception as quota_error:
|
|
||||||
logger.error(f"Failed to increment research quota: {quota_error}")
|
|
||||||
|
|
||||||
progress_store[task_id] = {"status": "completed", "message": f"{len(topics)} Topics gefunden!", "progress": 100, "topics": topics}
|
progress_store[task_id] = {"status": "completed", "message": f"{len(topics)} Topics gefunden!", "progress": 100, "topics": topics}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Research failed: {e}")
|
logger.exception(f"Research failed: {e}")
|
||||||
@@ -1994,11 +2004,9 @@ async def create_post(
|
|||||||
if not session:
|
if not session:
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
|
||||||
# CHECK COMPANY POST LIMIT and capture company_id for quota tracking
|
# CHECK COMPANY TOKEN LIMIT
|
||||||
quota_company_id = None
|
|
||||||
if session.account_type in ("company", "employee") and session.company_id:
|
if session.account_type in ("company", "employee") and session.company_id:
|
||||||
quota_company_id = UUID(session.company_id)
|
can_create, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id))
|
||||||
can_create, error_msg = await db.check_company_post_limit(quota_company_id)
|
|
||||||
if not can_create:
|
if not can_create:
|
||||||
raise HTTPException(status_code=429, detail=error_msg)
|
raise HTTPException(status_code=429, detail=error_msg)
|
||||||
|
|
||||||
@@ -2031,14 +2039,6 @@ async def create_post(
|
|||||||
selected_hook=selected_hook
|
selected_hook=selected_hook
|
||||||
)
|
)
|
||||||
|
|
||||||
# INCREMENT COMPANY QUOTA after successful creation
|
|
||||||
if quota_company_id:
|
|
||||||
try:
|
|
||||||
await db.increment_company_posts_quota(quota_company_id)
|
|
||||||
logger.info(f"Incremented post quota for company {quota_company_id}")
|
|
||||||
except Exception as quota_error:
|
|
||||||
logger.error(f"Failed to increment post quota: {quota_error}")
|
|
||||||
|
|
||||||
progress_store[task_id] = {
|
progress_store[task_id] = {
|
||||||
"status": "completed", "message": "Post erstellt!", "progress": 100,
|
"status": "completed", "message": "Post erstellt!", "progress": 100,
|
||||||
"result": {
|
"result": {
|
||||||
@@ -2061,6 +2061,12 @@ async def get_post_suggestions(request: Request, post_id: str):
|
|||||||
if not session:
|
if not session:
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
|
||||||
|
# Check token limit for companies/employees
|
||||||
|
if session.account_type in ("company", "employee") and session.company_id:
|
||||||
|
can_proceed, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id))
|
||||||
|
if not can_proceed:
|
||||||
|
raise HTTPException(status_code=429, detail=error_msg)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
post = await db.get_generated_post(UUID(post_id))
|
post = await db.get_generated_post(UUID(post_id))
|
||||||
if not post:
|
if not post:
|
||||||
@@ -2100,6 +2106,12 @@ async def revise_post(
|
|||||||
if not session:
|
if not session:
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
|
||||||
|
# Check token limit for companies/employees
|
||||||
|
if session.account_type in ("company", "employee") and session.company_id:
|
||||||
|
can_proceed, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id))
|
||||||
|
if not can_proceed:
|
||||||
|
raise HTTPException(status_code=429, detail=error_msg)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
post = await db.get_generated_post(UUID(post_id))
|
post = await db.get_generated_post(UUID(post_id))
|
||||||
if not post:
|
if not post:
|
||||||
@@ -2886,11 +2898,11 @@ async def company_manage_research(request: Request, employee_id: str = None):
|
|||||||
if not emp_user or str(emp_user.company_id) != session.company_id:
|
if not emp_user or str(emp_user.company_id) != session.company_id:
|
||||||
return RedirectResponse(url="/company/manage", status_code=302)
|
return RedirectResponse(url="/company/manage", status_code=302)
|
||||||
|
|
||||||
# Check research limit
|
# Check token limit
|
||||||
limit_reached = False
|
limit_reached = False
|
||||||
limit_message = ""
|
limit_message = ""
|
||||||
if session.company_id:
|
if session.company_id:
|
||||||
can_create, error_msg = await db.check_company_research_limit(UUID(session.company_id))
|
can_create, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id))
|
||||||
limit_reached = not can_create
|
limit_reached = not can_create
|
||||||
limit_message = error_msg
|
limit_message = error_msg
|
||||||
|
|
||||||
@@ -2933,11 +2945,11 @@ async def company_manage_create(request: Request, employee_id: str = None):
|
|||||||
if not emp_user or str(emp_user.company_id) != session.company_id:
|
if not emp_user or str(emp_user.company_id) != session.company_id:
|
||||||
return RedirectResponse(url="/company/manage", status_code=302)
|
return RedirectResponse(url="/company/manage", status_code=302)
|
||||||
|
|
||||||
# Check post limit
|
# Check token limit
|
||||||
limit_reached = False
|
limit_reached = False
|
||||||
limit_message = ""
|
limit_message = ""
|
||||||
if session.company_id:
|
if session.company_id:
|
||||||
can_create, error_msg = await db.check_company_post_limit(UUID(session.company_id))
|
can_create, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id))
|
||||||
limit_reached = not can_create
|
limit_reached = not can_create
|
||||||
limit_message = error_msg
|
limit_message = error_msg
|
||||||
|
|
||||||
@@ -3308,6 +3320,12 @@ async def chat_generate_post(request: Request):
|
|||||||
if not post_type_id:
|
if not post_type_id:
|
||||||
return JSONResponse({"success": False, "error": "Post-Typ erforderlich"})
|
return JSONResponse({"success": False, "error": "Post-Typ erforderlich"})
|
||||||
|
|
||||||
|
# Check token limit for companies/employees
|
||||||
|
if session.account_type in ("company", "employee") and session.company_id:
|
||||||
|
can_proceed, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id))
|
||||||
|
if not can_proceed:
|
||||||
|
return JSONResponse({"success": False, "token_limit_exceeded": True, "error": error_msg})
|
||||||
|
|
||||||
user_id = UUID(session.user_id)
|
user_id = UUID(session.user_id)
|
||||||
|
|
||||||
# Get post type info
|
# Get post type info
|
||||||
@@ -3341,6 +3359,11 @@ async def chat_generate_post(request: Request):
|
|||||||
# Generate post using writer agent with user's content as primary focus
|
# Generate post using writer agent with user's content as primary focus
|
||||||
from src.agents.writer import WriterAgent
|
from src.agents.writer import WriterAgent
|
||||||
writer = WriterAgent()
|
writer = WriterAgent()
|
||||||
|
writer.set_tracking_context(
|
||||||
|
operation='post_creation',
|
||||||
|
user_id=session.user_id,
|
||||||
|
company_id=session.company_id
|
||||||
|
)
|
||||||
|
|
||||||
# Create a topic structure from user's message
|
# Create a topic structure from user's message
|
||||||
topic = {
|
topic = {
|
||||||
@@ -3399,6 +3422,12 @@ async def chat_refine_post(request: Request):
|
|||||||
if not post_type_id:
|
if not post_type_id:
|
||||||
return JSONResponse({"success": False, "error": "Post-Typ erforderlich"})
|
return JSONResponse({"success": False, "error": "Post-Typ erforderlich"})
|
||||||
|
|
||||||
|
# Check token limit for companies/employees
|
||||||
|
if session.account_type in ("company", "employee") and session.company_id:
|
||||||
|
can_proceed, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id))
|
||||||
|
if not can_proceed:
|
||||||
|
return JSONResponse({"success": False, "token_limit_exceeded": True, "error": error_msg})
|
||||||
|
|
||||||
user_id = UUID(session.user_id)
|
user_id = UUID(session.user_id)
|
||||||
|
|
||||||
# Get post type info
|
# Get post type info
|
||||||
@@ -3442,6 +3471,11 @@ async def chat_refine_post(request: Request):
|
|||||||
# Refine post using writer with feedback
|
# Refine post using writer with feedback
|
||||||
from src.agents.writer import WriterAgent
|
from src.agents.writer import WriterAgent
|
||||||
writer = WriterAgent()
|
writer = WriterAgent()
|
||||||
|
writer.set_tracking_context(
|
||||||
|
operation='post_creation',
|
||||||
|
user_id=session.user_id,
|
||||||
|
company_id=session.company_id
|
||||||
|
)
|
||||||
|
|
||||||
topic = {
|
topic = {
|
||||||
"title": "Chat refinement",
|
"title": "Chat refinement",
|
||||||
|
|||||||
Reference in New Issue
Block a user