@danhachuel/prodmaker 0.1.9 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/app/api/dashboard.py +67 -0
- package/app/api/guias.py +34 -0
- package/app/api/infoprodutos.py +10 -1
- package/app/api/lancamentos.py +81 -0
- package/app/api/logs.py +42 -0
- package/app/api/notificacoes.py +40 -0
- package/app/api/paginas.py +8 -0
- package/app/api/pins.py +80 -0
- package/app/main.py +14 -2
- package/app/services/campaigns.py +5 -0
- package/app/services/database.py +45 -0
- package/app/services/lancamentos.py +148 -0
- package/app/services/notify.py +127 -0
- package/app/services/pinterest.py +174 -0
- package/app/services/vendas.py +11 -1
- package/frontend/dist/assets/index-B4wxnTCs.css +1 -0
- package/frontend/dist/assets/index-DGhGACYG.js +74 -0
- package/frontend/dist/index.html +2 -2
- package/package.json +1 -1
- package/requirements.txt +4 -0
- package/scripts/cli.mjs +1 -1
- package/seed/references/pixel-e-tuneis.md +39 -0
- package/seed/references/webhooks-de-venda.md +38 -0
- package/frontend/dist/assets/index-B88UVmZI.css +0 -1
- package/frontend/dist/assets/index-CKt-bO8f.js +0 -70
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""AdsBot — Lançamento Assistido: wizard persistido + Relatório de Lançamento."""
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .database import get_db, now_str, row_to_dict
|
|
6
|
+
from .llm import LLMError
|
|
7
|
+
|
|
8
|
+
SEED_DIR = Path(__file__).resolve().parent.parent.parent / "seed"
|
|
9
|
+
ETAPAS = [
|
|
10
|
+
{"numero": 1, "nome": "Produto", "emoji": "📦", "rota": "/"},
|
|
11
|
+
{"numero": 2, "nome": "Página", "emoji": "🛡️", "rota": "/adspages"},
|
|
12
|
+
{"numero": 3, "nome": "Publicar", "emoji": "🌐", "rota": "/hosting/paginas"},
|
|
13
|
+
{"numero": 4, "nome": "Campanhas", "emoji": "🎯", "rota": "/adscreator"},
|
|
14
|
+
{"numero": 5, "nome": "Tracking", "emoji": "🔄", "rota": "/tracking/visitas"},
|
|
15
|
+
{"numero": 6, "nome": "Conclusão", "emoji": "✅", "rota": "/adsbot"},
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _decode(record: dict) -> dict:
|
|
20
|
+
try:
|
|
21
|
+
record["dados"] = json.loads(record.get("dados_json") or "{}")
|
|
22
|
+
except Exception: # noqa: BLE001
|
|
23
|
+
record["dados"] = {}
|
|
24
|
+
return record
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _get_row(lancamento_id: int) -> dict:
|
|
28
|
+
with get_db() as conn:
|
|
29
|
+
row = conn.execute("SELECT * FROM lancamentos WHERE id = ?", (lancamento_id,)).fetchone()
|
|
30
|
+
if not row:
|
|
31
|
+
raise LLMError("Lançamento não encontrado.")
|
|
32
|
+
return row_to_dict(row)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def atual() -> dict | None:
|
|
36
|
+
with get_db() as conn:
|
|
37
|
+
row = conn.execute("SELECT * FROM lancamentos WHERE status = 'em_andamento' ORDER BY id DESC LIMIT 1").fetchone()
|
|
38
|
+
return _decode(row_to_dict(row)) if row else None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def listar() -> list[dict]:
|
|
42
|
+
with get_db() as conn:
|
|
43
|
+
rows = conn.execute("SELECT * FROM lancamentos ORDER BY id DESC").fetchall()
|
|
44
|
+
return [_decode(row_to_dict(row)) for row in rows]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def iniciar(produto_id: int | None = None) -> dict:
|
|
48
|
+
existing = atual()
|
|
49
|
+
if existing:
|
|
50
|
+
if produto_id and not existing.get("produto_id"):
|
|
51
|
+
return definir_produto(existing["id"], produto_id)
|
|
52
|
+
return existing
|
|
53
|
+
timestamp = now_str()
|
|
54
|
+
with get_db() as conn:
|
|
55
|
+
cursor = conn.execute(
|
|
56
|
+
"INSERT INTO lancamentos (produto_id, etapa_atual, status, dados_json, created_at, updated_at) VALUES (?, 1, 'em_andamento', '{}', ?, ?)",
|
|
57
|
+
(produto_id, timestamp, timestamp),
|
|
58
|
+
)
|
|
59
|
+
row = conn.execute("SELECT * FROM lancamentos WHERE id = ?", (cursor.lastrowid,)).fetchone()
|
|
60
|
+
return _decode(row_to_dict(row))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def definir_produto(lancamento_id: int, produto_id: int) -> dict:
|
|
64
|
+
with get_db() as conn:
|
|
65
|
+
conn.execute("UPDATE lancamentos SET produto_id = ?, updated_at = ? WHERE id = ?", (produto_id, now_str(), lancamento_id))
|
|
66
|
+
return _get_row(lancamento_id)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def salvar_etapa(lancamento_id: int, etapa: int, dados: dict) -> dict:
|
|
70
|
+
if not 1 <= etapa <= len(ETAPAS):
|
|
71
|
+
raise LLMError(f"Etapa inválida (1–{len(ETAPAS)}).")
|
|
72
|
+
with get_db() as conn:
|
|
73
|
+
row = conn.execute("SELECT * FROM lancamentos WHERE id = ?", (lancamento_id,)).fetchone()
|
|
74
|
+
if not row:
|
|
75
|
+
raise LLMError("Lançamento não encontrado.")
|
|
76
|
+
merged = {}
|
|
77
|
+
try:
|
|
78
|
+
merged = json.loads(row["dados_json"] or "{}")
|
|
79
|
+
except Exception: # noqa: BLE001
|
|
80
|
+
merged = {}
|
|
81
|
+
merged.update(dados or {})
|
|
82
|
+
conn.execute(
|
|
83
|
+
"UPDATE lancamentos SET etapa_atual = ?, dados_json = ?, updated_at = ? WHERE id = ?",
|
|
84
|
+
(max(etapa, row["etapa_atual"] if row["status"] == "concluido" else 0), json.dumps(merged, ensure_ascii=False), now_str(), lancamento_id),
|
|
85
|
+
)
|
|
86
|
+
return _get_row(lancamento_id)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def concluir(lancamento_id: int) -> dict:
|
|
90
|
+
with get_db() as conn:
|
|
91
|
+
conn.execute("UPDATE lancamentos SET status = 'concluido', etapa_atual = ?, updated_at = ? WHERE id = ?", (len(ETAPAS), now_str(), lancamento_id))
|
|
92
|
+
return relatorio(lancamento_id)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def relatorio(lancamento_id: int) -> dict:
|
|
96
|
+
record = _get_row(lancamento_id)
|
|
97
|
+
dados = json.loads(record.get("dados_json") or "{}")
|
|
98
|
+
produto_id = record.get("produto_id") or dados.get("produto_id")
|
|
99
|
+
produto = None
|
|
100
|
+
analysis = None
|
|
101
|
+
paginas = []
|
|
102
|
+
campanhas = []
|
|
103
|
+
if produto_id:
|
|
104
|
+
with get_db() as conn:
|
|
105
|
+
produto_row = conn.execute("SELECT * FROM produtos WHERE id = ?", (produto_id,)).fetchone()
|
|
106
|
+
if produto_row:
|
|
107
|
+
produto = row_to_dict(produto_row)
|
|
108
|
+
if produto and produto.get("analysis"):
|
|
109
|
+
try:
|
|
110
|
+
analysis = json.loads(produto["analysis"])
|
|
111
|
+
except Exception: # noqa: BLE001
|
|
112
|
+
analysis = None
|
|
113
|
+
with get_db() as conn:
|
|
114
|
+
paginas = [row_to_dict(r) for r in conn.execute("SELECT * FROM paginas WHERE produto_id = ? ORDER BY id DESC", (produto_id,)).fetchall()]
|
|
115
|
+
campanhas = [row_to_dict(r) for r in conn.execute("SELECT * FROM campanhas WHERE produto_id = ? ORDER BY id DESC", (produto_id,)).fetchall()]
|
|
116
|
+
checklist_path = SEED_DIR / "checklists" / "lancamento.md"
|
|
117
|
+
checklist = ""
|
|
118
|
+
if checklist_path.exists():
|
|
119
|
+
checklist = checklist_path.read_text(encoding="utf-8")
|
|
120
|
+
return {
|
|
121
|
+
"lancamento": record,
|
|
122
|
+
"produto": produto,
|
|
123
|
+
"analysis": analysis,
|
|
124
|
+
"paginas": [
|
|
125
|
+
{"id": p["id"], "titulo": p["titulo"], "tipo": p["tipo"], "status": p["status"], "published_url": p["published_url"]}
|
|
126
|
+
for p in paginas
|
|
127
|
+
],
|
|
128
|
+
"campanhas": [
|
|
129
|
+
{"id": c["id"], "nome": c["nome"], "plataforma": c["plataforma"], "status": c["status"], "cpa_alvo": c["cpa_alvo"], "orcamento_diario": c["orcamento_diario"]}
|
|
130
|
+
for c in campanhas
|
|
131
|
+
],
|
|
132
|
+
"checklist": checklist,
|
|
133
|
+
"etapas": ETAPAS,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def progresso(record: dict) -> dict:
|
|
138
|
+
etapa = record.get("etapa_atual") or 1
|
|
139
|
+
total = len(ETAPAS)
|
|
140
|
+
concluido = record.get("status") == "concluido"
|
|
141
|
+
percentual = 100 if concluido else round((etapa - 1) / total * 100)
|
|
142
|
+
return {
|
|
143
|
+
"etapa": etapa,
|
|
144
|
+
"total": total,
|
|
145
|
+
"percentual": percentual,
|
|
146
|
+
"faltam": 0 if concluido else total - etapa + 1,
|
|
147
|
+
"status": record.get("status"),
|
|
148
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""AdsNotify — notificações push (apprise) + histórico local.
|
|
2
|
+
|
|
3
|
+
- Canais: URLs apprise configuradas em settings.notifications.targets. Se houver Telegram
|
|
4
|
+
configurado em Chaves de API, o destino t://TOKEN/CHATID entra automaticamente.
|
|
5
|
+
- Eventos com toggle em settings.notifications.eventos (default: desligados).
|
|
6
|
+
- Falha de envio NUNCA quebra o fluxo — apenas registra no histórico.
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from .database import get_db, now_str, row_to_dict
|
|
12
|
+
from .llm import LLMError
|
|
13
|
+
from .store import get_settings
|
|
14
|
+
|
|
15
|
+
EVENTOS = {
|
|
16
|
+
"venda_recebida": "Venda recebida",
|
|
17
|
+
"campanha_publicada": "Campanha publicada",
|
|
18
|
+
"pagina_publicada": "Página publicada",
|
|
19
|
+
"infoproduto_gerado": "Infoproduto gerado",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _telegram_target() -> str | None:
|
|
24
|
+
keys = (get_settings().get("api_keys") or {}).get("telegram") or {}
|
|
25
|
+
token = (keys.get("bot_token") or "").strip()
|
|
26
|
+
chat_id = (keys.get("chat_id") or "").strip()
|
|
27
|
+
if token and chat_id:
|
|
28
|
+
return f"tgram://{token}/{chat_id}"
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def listar_config() -> dict:
|
|
33
|
+
settings = get_settings()
|
|
34
|
+
notifications = settings.get("notifications") or {}
|
|
35
|
+
return {
|
|
36
|
+
"eventos": {key: bool((notifications.get("eventos") or {}).get(key, False)) for key in EVENTOS},
|
|
37
|
+
"targets": notifications.get("targets") or [],
|
|
38
|
+
"eventos_disponiveis": EVENTOS,
|
|
39
|
+
"canal_telegram": bool(_telegram_target()),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def salvar_config(eventos: dict, targets: list) -> dict:
|
|
44
|
+
settings = get_settings()
|
|
45
|
+
current = settings.get("notifications") or {}
|
|
46
|
+
merged_eventos = {key: bool((current.get("eventos") or {}).get(key, False)) for key in EVENTOS}
|
|
47
|
+
merged_eventos.update({key: bool(value) for key, value in (eventos or {}).items() if key in EVENTOS})
|
|
48
|
+
settings["notifications"] = {"eventos": merged_eventos, "targets": [str(item).strip() for item in (targets or []) if str(item).strip()]}
|
|
49
|
+
from .store import save_settings
|
|
50
|
+
|
|
51
|
+
save_settings(settings)
|
|
52
|
+
return listar_config()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def historico(limit: int = 50) -> list[dict]:
|
|
56
|
+
with get_db() as conn:
|
|
57
|
+
rows = conn.execute("SELECT * FROM notificacoes ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
|
|
58
|
+
return [row_to_dict(row) for row in rows]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _registrar(event: str, titulo: str, mensagem: str, canal: str, status: str, erro: str = "") -> None:
|
|
62
|
+
with get_db() as conn:
|
|
63
|
+
conn.execute(
|
|
64
|
+
"INSERT INTO notificacoes (event, titulo, mensagem, canal, status, erro, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
65
|
+
(event, titulo, mensagem, canal, status, erro[:2000], now_str()),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _alvos() -> list[str]:
|
|
70
|
+
settings = get_settings()
|
|
71
|
+
targets = list((settings.get("notifications") or {}).get("targets") or [])
|
|
72
|
+
telegram = _telegram_target()
|
|
73
|
+
if telegram and telegram not in targets:
|
|
74
|
+
targets.insert(0, telegram)
|
|
75
|
+
return [item for item in targets if item.strip()]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _is_enabled(event: str) -> bool:
|
|
79
|
+
settings = get_settings()
|
|
80
|
+
return bool(((settings.get("notifications") or {}).get("eventos") or {}).get(event, False))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def enviar(event: str, titulo: str, mensagem: str, forcar: bool = False) -> dict:
|
|
84
|
+
"""Envia uma notificação pelos canais configurados (best-effort)."""
|
|
85
|
+
if not forcar and not _is_enabled(event):
|
|
86
|
+
return {"enviado": False, "motivo": "evento desligado"}
|
|
87
|
+
alvos = _alvos()
|
|
88
|
+
if not alvos:
|
|
89
|
+
erro = "Nenhum canal configurado (settings.notifications.targets ou Telegram em Chaves de API)."
|
|
90
|
+
_registrar(event, titulo, mensagem, "", "erro", erro)
|
|
91
|
+
if forcar:
|
|
92
|
+
raise LLMError(erro)
|
|
93
|
+
return {"enviado": False, "motivo": erro}
|
|
94
|
+
try:
|
|
95
|
+
import apprise
|
|
96
|
+
except ImportError as exc:
|
|
97
|
+
_registrar(event, titulo, mensagem, ",".join(alvos), "erro", f"apprise ausente ({exc.name}) — rode 'prodmaker install'.")
|
|
98
|
+
return {"enviado": False, "motivo": "apprise não instalado"}
|
|
99
|
+
apobj = apprise.Apprise()
|
|
100
|
+
enviados = []
|
|
101
|
+
erros = []
|
|
102
|
+
for alvo in alvos:
|
|
103
|
+
if apobj.add(alvo):
|
|
104
|
+
enviados.append(alvo)
|
|
105
|
+
else:
|
|
106
|
+
erros.append(f"canal inválido: {alvo}")
|
|
107
|
+
if not enviados:
|
|
108
|
+
erro = "; ".join(erros)
|
|
109
|
+
_registrar(event, titulo, mensagem, ",".join(alvos), "erro", erro)
|
|
110
|
+
raise LLMError(erro)
|
|
111
|
+
ok = apobj.notify(title=titulo, body=mensagem)
|
|
112
|
+
if ok:
|
|
113
|
+
_registrar(event, titulo, mensagem, ",".join(enviados), "enviada")
|
|
114
|
+
return {"enviado": True, "canais": enviados}
|
|
115
|
+
erro = "apprise não conseguiu entregar (verifique tokens/chat id)."
|
|
116
|
+
_registrar(event, titulo, mensagem, ",".join(enviados), "erro", erro)
|
|
117
|
+
raise LLMError(erro)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def notify_event(event: str, titulo: str, mensagem: str) -> None:
|
|
121
|
+
"""Hook seguro: usado pelos fluxos de venda/página/campanha/infoproduto."""
|
|
122
|
+
try:
|
|
123
|
+
enviar(event, titulo, mensagem)
|
|
124
|
+
except LLMError:
|
|
125
|
+
pass
|
|
126
|
+
except Exception: # noqa: BLE001 — notificação nunca quebra a operação principal
|
|
127
|
+
pass
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Pinterest — orgânico: ideias de pins por IA, gestão local e agendamento best-effort.
|
|
2
|
+
|
|
3
|
+
Criação de imagens é feita pelo usuário (Canva/IA) no formato 1000x1500 — o ProdMaker
|
|
4
|
+
entrega título/descrição/texto da imagem/sugestão. O agendamento usa py3-pinterest
|
|
5
|
+
(import preguiçoso) quando as credenciais estão em Chaves de API.
|
|
6
|
+
"""
|
|
7
|
+
import csv
|
|
8
|
+
import io
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .database import get_db, now_str, row_to_dict
|
|
13
|
+
from .llm import LLMError, generate
|
|
14
|
+
from .store import get_settings
|
|
15
|
+
|
|
16
|
+
SEED_DIR = Path(__file__).resolve().parent.parent.parent / "seed"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _config() -> dict:
|
|
20
|
+
keys = (get_settings().get("api_keys") or {}).get("pinterest") or {}
|
|
21
|
+
return {
|
|
22
|
+
"email": (keys.get("email") or "").strip(),
|
|
23
|
+
"password": (keys.get("password") or "").strip(),
|
|
24
|
+
"board": (keys.get("board") or "").strip(),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def gerar_ideias(produto: dict, board: str = "", link: str = "", quantidade: int = 10) -> dict:
|
|
29
|
+
template_path = SEED_DIR / "prompts" / "pins.md"
|
|
30
|
+
template = template_path.read_text(encoding="utf-8") if template_path.exists() else (
|
|
31
|
+
'Crie {QUANTIDADE} ideias de Pins para {NOME_DO_PRODUTO} no formato '
|
|
32
|
+
'{"pins":[{"titulo_pin":"","descricao":"","texto_na_imagem":"","sugestao_imagem":""}]}'
|
|
33
|
+
)
|
|
34
|
+
prompt = (
|
|
35
|
+
template.replace("{QUANTIDADE}", str(quantidade))
|
|
36
|
+
.replace("{NOME_DO_PRODUTO}", produto.get("nome") or "")
|
|
37
|
+
.replace("{DOR}", produto.get("nicho") or "")
|
|
38
|
+
)
|
|
39
|
+
result = generate(
|
|
40
|
+
prompt,
|
|
41
|
+
system="Você é um especialista em Pinterest SEO. Responda SOMENTE com JSON válido, sem markdown.",
|
|
42
|
+
max_tokens=4000,
|
|
43
|
+
)
|
|
44
|
+
try:
|
|
45
|
+
data = _clean_json(result["text"])
|
|
46
|
+
parsed = json.loads(data)
|
|
47
|
+
except Exception as exc: # noqa: BLE001
|
|
48
|
+
raise LLMError(f"Resposta do LLM não trouxe JSON utilizável: {exc}") from exc
|
|
49
|
+
pins_raw = parsed.get("pins") or []
|
|
50
|
+
timestamp = now_str()
|
|
51
|
+
criados = []
|
|
52
|
+
for item in pins_raw:
|
|
53
|
+
if not isinstance(item, dict):
|
|
54
|
+
continue
|
|
55
|
+
titulo = (item.get("titulo_pin") or "").strip()
|
|
56
|
+
if not titulo:
|
|
57
|
+
continue
|
|
58
|
+
with get_db() as conn:
|
|
59
|
+
cursor = conn.execute(
|
|
60
|
+
"INSERT INTO pins (produto_id, titulo, descricao, texto_imagem, sugestao_imagem, board, link, status, provider, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, 'ideia', ?, ?)",
|
|
61
|
+
(
|
|
62
|
+
produto.get("id"),
|
|
63
|
+
titulo[:120],
|
|
64
|
+
(item.get("descricao") or "")[:500],
|
|
65
|
+
(item.get("texto_na_imagem") or "")[:200],
|
|
66
|
+
(item.get("sugestao_imagem") or "")[:200],
|
|
67
|
+
board or _config()["board"],
|
|
68
|
+
link,
|
|
69
|
+
result["provider"],
|
|
70
|
+
timestamp,
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
criados.append(row_to_dict(conn.execute("SELECT * FROM pins WHERE id = ?", (cursor.lastrowid,)).fetchone()))
|
|
74
|
+
return {"gerados": len(criados), "provider": result["provider"]}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _clean_json(text: str) -> str:
|
|
78
|
+
import re
|
|
79
|
+
|
|
80
|
+
cleaned = re.sub(r"```(?:json)?", "", text).strip()
|
|
81
|
+
match = re.search(r"\{.*\}", cleaned, re.DOTALL)
|
|
82
|
+
return match.group(0) if match else cleaned
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def listar(produto_id: int | None = None) -> list[dict]:
|
|
86
|
+
query = "SELECT * FROM pins"
|
|
87
|
+
params: tuple = ()
|
|
88
|
+
if produto_id:
|
|
89
|
+
query += " WHERE produto_id = ?"
|
|
90
|
+
params = (produto_id,)
|
|
91
|
+
query += " ORDER BY id DESC"
|
|
92
|
+
with get_db() as conn:
|
|
93
|
+
rows = conn.execute(query, params).fetchall()
|
|
94
|
+
return [row_to_dict(row) for row in rows]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def marcar(pin_id: int, status: str) -> dict:
|
|
98
|
+
validos = {"ideia", "pronto", "agendado", "publicado"}
|
|
99
|
+
if status not in validos:
|
|
100
|
+
raise LLMError(f"Status inválido: {status}")
|
|
101
|
+
with get_db() as conn:
|
|
102
|
+
cursor = conn.execute("UPDATE pins SET status = ? WHERE id = ?", (status, pin_id))
|
|
103
|
+
if cursor.rowcount == 0:
|
|
104
|
+
raise LLMError("Pin não encontrado.")
|
|
105
|
+
row = conn.execute("SELECT * FROM pins WHERE id = ?", (pin_id,)).fetchone()
|
|
106
|
+
return row_to_dict(row)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def remover(pin_id: int) -> None:
|
|
110
|
+
with get_db() as conn:
|
|
111
|
+
cursor = conn.execute("DELETE FROM pins WHERE id = ?", (pin_id,))
|
|
112
|
+
if cursor.rowcount == 0:
|
|
113
|
+
raise LLMError("Pin não encontrado.")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def exportar_csv(pin_ids: list[int]) -> str:
|
|
117
|
+
with get_db() as conn:
|
|
118
|
+
placeholders = ",".join("?" * len(pin_ids)) if pin_ids else "0"
|
|
119
|
+
rows = conn.execute(f"SELECT * FROM pins WHERE id IN ({placeholders}) ORDER BY id", pin_ids).fetchall()
|
|
120
|
+
buffer = io.StringIO()
|
|
121
|
+
writer = csv.writer(buffer)
|
|
122
|
+
writer.writerow(["titulo_pin", "descricao", "texto_na_imagem", "sugestao_imagem", "board", "link"])
|
|
123
|
+
for row in rows:
|
|
124
|
+
writer.writerow([row["titulo"], row["descricao"], row["texto_imagem"], row["sugestao_imagem"], row["board"], row["link"]])
|
|
125
|
+
return buffer.getvalue()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def agendar(pin_id: int, imagem_path: str = "") -> dict:
|
|
129
|
+
"""Agenda um pin via py3-pinterest (best-effort). Sem imagem/credenciais: erro honesto.
|
|
130
|
+
|
|
131
|
+
O upload da imagem depende de um arquivo local (criado no Canva/IA pelo usuário).
|
|
132
|
+
"""
|
|
133
|
+
config = _config()
|
|
134
|
+
if not config["email"] or not config["password"]:
|
|
135
|
+
raise LLMError("Pinterest requer e-mail e senha em Chaves de API (card Pinterest).")
|
|
136
|
+
if not imagem_path:
|
|
137
|
+
raise LLMError(
|
|
138
|
+
"Informe o caminho da imagem local do pin (1000x1500). "
|
|
139
|
+
"Crie a imagem no Canva/IA e salve — o agendamento usa o arquivo local."
|
|
140
|
+
)
|
|
141
|
+
with get_db() as conn:
|
|
142
|
+
row = conn.execute("SELECT * FROM pins WHERE id = ?", (pin_id,)).fetchone()
|
|
143
|
+
if not row:
|
|
144
|
+
raise LLMError("Pin não encontrado.")
|
|
145
|
+
pin = row_to_dict(row)
|
|
146
|
+
try:
|
|
147
|
+
from py3_pinterest import Pinterest, PinterestLogin # noqa: F401
|
|
148
|
+
except ImportError as exc:
|
|
149
|
+
raise LLMError(f"py3-pinterest ausente ({exc.name}) — rode 'prodmaker install'.") from exc
|
|
150
|
+
try:
|
|
151
|
+
login = PinterestLogin(config["email"], config["password"])
|
|
152
|
+
login.login()
|
|
153
|
+
client = Pinterest()
|
|
154
|
+
setter = getattr(client, "set_session", None)
|
|
155
|
+
session = getattr(login, "session", None)
|
|
156
|
+
if callable(setter) and session is not None:
|
|
157
|
+
setter(session)
|
|
158
|
+
pin_fn = getattr(client, "pin", None)
|
|
159
|
+
if not callable(pin_fn):
|
|
160
|
+
raise LLMError("A versão instalada do py3-pinterest não expõe 'pin' — verifique a biblioteca.")
|
|
161
|
+
board = pin["board"] or config["board"] or "ProdMaker"
|
|
162
|
+
response = pin_fn(board=board, link=pin["link"], image=imagem_path, title=pin["titulo"], description=pin["descricao"])
|
|
163
|
+
with get_db() as conn:
|
|
164
|
+
conn.execute("UPDATE pins SET status = 'agendado', erro = '' WHERE id = ?", (pin_id,))
|
|
165
|
+
return {"ok": True, "pin": pin_id, "resposta": bool(response), "board": board}
|
|
166
|
+
except LLMError:
|
|
167
|
+
raise
|
|
168
|
+
except Exception as exc: # noqa: BLE001 — attribute drift, login 2FA, throttling...
|
|
169
|
+
with get_db() as conn:
|
|
170
|
+
conn.execute("UPDATE pins SET erro = ? WHERE id = ?", (str(exc)[:500], pin_id))
|
|
171
|
+
raise LLMError(
|
|
172
|
+
f"py3-pinterest não conseguiu agendar ({type(exc).__name__}: {str(exc)[:150]}). "
|
|
173
|
+
"Contas com 2FA/throttling exigem sessão manual — use o CSV exportado como alternativa."
|
|
174
|
+
) from exc
|
package/app/services/vendas.py
CHANGED
|
@@ -214,7 +214,17 @@ def registrar_venda(data: dict) -> dict:
|
|
|
214
214
|
),
|
|
215
215
|
)
|
|
216
216
|
row = conn.execute("SELECT * FROM vendas WHERE id = ?", (cursor.lastrowid,)).fetchone()
|
|
217
|
-
|
|
217
|
+
record = row_to_dict(row)
|
|
218
|
+
|
|
219
|
+
from .notify import notify_event
|
|
220
|
+
|
|
221
|
+
notify_event(
|
|
222
|
+
"venda_recebida",
|
|
223
|
+
"Nova venda recebida",
|
|
224
|
+
f"{record.get('produto_nome') or 'Produto'} — R$ {record.get('valor')} ({record.get('fonte')})"
|
|
225
|
+
+ (f" · GCLID {record['gclid'][:20]}" if record.get("gclid") else ""),
|
|
226
|
+
)
|
|
227
|
+
return record
|
|
218
228
|
|
|
219
229
|
|
|
220
230
|
def list_vendas(limit: int = 300) -> list[dict]:
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.block{display:block}.flex{display:flex}.table{display:table}.hidden{display:none}.border{border-width:1px}.outline{outline-style:solid}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}:root{--primary: #8b5cf6;--primary-hover: #7c3aed;--primary-light: #f3e8ff;--success: #10b981;--warning: #f59e0b;--danger: #ef4444;--bg-body: #f8fafc;--bg-card: #ffffff;--bg-sidebar: #ffffff;--text-main: #1e293b;--text-muted: #64748b;--border: #e2e8f0;--hover-bg: #f1f5f9;--input-bg: #f1f5f9;--toast-bg: #10b981;--warning-bg: #fffbeb;--warning-border: #fde68a;--code-bg: #f1f5f9}[data-theme=dark]{--bg-body: #121212;--bg-card: #1e1e1e;--bg-sidebar: #181818;--text-main: #f8fafc;--text-muted: #a1a1aa;--border: #333333;--hover-bg: #2a2a2a;--input-bg: #2a2a2a;--primary-light: #2d1b4e;--toast-bg: #059669;--warning-bg: #2d2000;--warning-border: #78350f;--code-bg: #000000}*{margin:0;padding:0;box-sizing:border-box}html,body{height:100%}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;background-color:var(--bg-body);color:var(--text-main);transition:background .3s,color .3s}.app-shell{display:flex;height:100vh;overflow:hidden}aside.sidebar{width:260px;background-color:var(--bg-sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;height:100%;overflow-y:auto;flex-shrink:0}.logo-area{padding:20px;font-size:22px;font-weight:800;display:flex;align-items:center;gap:10px;color:var(--text-main);border-bottom:1px solid var(--border)}.logo-area span.badge{font-size:10px;border:1px solid var(--border);padding:4px 8px;border-radius:6px;color:var(--text-muted);display:flex;align-items:center;gap:4px;font-weight:600}nav ul{list-style:none;padding:0;margin:0}nav li.nav-item{padding:12px 20px;cursor:pointer;display:flex;align-items:center;gap:12px;color:var(--text-muted);font-size:14px;font-weight:500;transition:all .2s;-webkit-user-select:none;-moz-user-select:none;user-select:none}nav li.nav-item:hover{background-color:var(--hover-bg);color:var(--primary)}nav li.nav-item.active{background-color:var(--primary-light);color:var(--primary);font-weight:600}nav li.has-submenu{flex-direction:column;align-items:flex-start;padding:0}nav li.has-submenu>div{padding:12px 20px;width:100%;display:flex;align-items:center;gap:12px;cursor:pointer;color:var(--text-muted);font-weight:500;font-size:14px;-webkit-user-select:none;-moz-user-select:none;user-select:none}nav li.has-submenu>div:hover{background-color:var(--hover-bg);color:var(--primary)}nav li.has-submenu>div .submenu-arrow{font-size:10px;margin-left:auto;transition:transform .2s}nav li.has-submenu.expanded>div .submenu-arrow{transform:rotate(180deg)}nav li.has-submenu ul{display:none;width:100%;padding-left:10px;margin-bottom:10px}nav li.has-submenu.expanded ul{display:block}nav li.has-submenu ul li{padding:10px 20px;font-size:13px;color:var(--text-muted);cursor:pointer;display:flex;gap:8px;font-weight:500}nav li.has-submenu ul li:hover{color:var(--primary)}nav li.has-submenu ul li.active{color:var(--primary);font-weight:600}.divider{height:1px;background:var(--border);margin:15px 20px}.sidebar-bottom{margin-top:auto;padding:16px;border-top:1px solid var(--border)}.btn-logout{width:100%;border:none;padding:12px;border-radius:8px;font-weight:600;font-size:14px;cursor:pointer;color:#fff;background-image:linear-gradient(to right,#6366f1,#a855f7);display:flex;align-items:center;justify-content:center;gap:8px}.btn-logout:hover{opacity:.9}main.main-content{flex:1;display:flex;flex-direction:column;overflow-y:auto;position:relative}header.top-bar{background:var(--bg-card);padding:15px 30px;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--border);position:sticky;top:0;z-index:10}.promo-banner{background-color:var(--primary-light);color:var(--primary);padding:8px 16px;border-radius:20px;font-size:13px;font-weight:500;cursor:pointer}.user-profile{display:flex;align-items:center;gap:15px;font-size:14px;font-weight:500}.user-profile .avatar{background:var(--primary-light);color:var(--primary);padding:5px 10px;border-radius:50%;font-weight:700;text-transform:uppercase}.theme-toggle{background:none;border:1px solid var(--border);color:var(--text-main);padding:5px 10px;border-radius:20px;cursor:pointer;font-size:16px}.content-wrapper{padding:30px 30px 80px;max-width:1200px;margin:0 auto;width:100%}h1{font-size:24px;margin-bottom:5px;color:var(--text-main)}p.subtitle{font-size:14px;color:var(--text-muted);margin-top:0;margin-bottom:20px}button.btn-primary{background-color:var(--primary);color:#fff;border:none;padding:10px 20px;border-radius:6px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;gap:8px;font-size:14px}button.btn-primary:hover{background-color:var(--primary-hover)}button.btn-primary:disabled{background-color:var(--hover-bg);color:var(--text-muted);cursor:not-allowed}button.btn-outline{background:transparent;border:1px solid var(--border);padding:10px 20px;border-radius:6px;font-weight:600;cursor:pointer;color:var(--text-main);font-size:14px}button.btn-outline:hover{background:var(--hover-bg)}table{width:100%;border-collapse:collapse;background:var(--bg-card);border-radius:8px;overflow:hidden;margin-top:15px;border:1px solid var(--border)}th,td{padding:12px 15px;text-align:left;border-bottom:1px solid var(--border);font-size:13px;color:var(--text-main)}th{background-color:var(--hover-bg);color:var(--text-muted);font-weight:600;text-transform:uppercase;font-size:11px}tr.empty-row td{text-align:center;color:var(--text-muted);padding:32px 15px;font-weight:500}.action-icons{display:flex;gap:15px;color:var(--text-muted);cursor:pointer;flex-wrap:wrap}.action-icons span:hover{color:var(--primary)}.form-group{margin-bottom:15px}.form-group label{display:block;font-size:13px;font-weight:600;margin-bottom:5px;color:var(--text-main)}.form-group input,.form-group select,.form-group textarea{width:100%;padding:10px;border:1px solid var(--border);border-radius:6px;font-size:14px;outline:none;background:var(--input-bg);color:var(--text-main)}.form-group input:focus,.form-group select:focus,.form-group textarea:focus{border-color:var(--primary)}.form-row{display:flex;gap:15px}.form-row .form-group{flex:1}.form-card{background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:25px;margin-bottom:20px}.tabs{display:flex;gap:20px;border-bottom:1px solid var(--border);margin-bottom:15px}.tab{padding:10px 0;color:var(--text-muted);font-weight:600;font-size:14px;cursor:pointer;position:relative}.tab.active{color:var(--primary)}.tab.active:after{content:"";position:absolute;bottom:-1px;left:0;width:100%;height:2px;background:var(--primary)}.metrics-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:15px;margin-bottom:30px}.metric-card{background:var(--bg-card);padding:20px;border-radius:8px;border:1px solid var(--border);position:relative}.metric-card h3{font-size:13px;color:var(--text-muted);margin:0 0 10px;font-weight:500}.metric-card .value{font-size:24px;font-weight:700;color:var(--text-main)}.metric-card .value.green{color:var(--success)}.metric-card .value.purple{color:var(--primary)}.metrics-note{font-size:13px;color:var(--text-muted);margin-top:-18px;margin-bottom:30px}.modal-overlay{position:fixed;top:0;left:0;width:100vw;height:100vh;background:#0009;display:flex;justify-content:center;align-items:center;z-index:1000;-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px)}.modal-content{background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:30px;width:100%;max-width:800px;box-shadow:0 20px 25px -5px #0003;position:relative;color:var(--text-main);max-height:90vh;overflow-y:auto}.modal-content h2{font-size:20px;margin-bottom:18px}.modal-close{background:none;border:none;font-size:20px;cursor:pointer;color:var(--text-muted);position:absolute;top:15px;right:15px}.toast{position:fixed;top:70px;right:20px;background-color:var(--toast-bg);color:#fff;padding:15px 20px;border-radius:8px;display:flex;align-items:flex-start;gap:10px;z-index:2000;max-width:350px;box-shadow:0 4px 12px #0003}.placeholder-list{padding-left:20px;color:var(--text-muted);font-size:14px;margin:12px 0}.placeholder-list li{margin-bottom:6px}.placeholder-note{font-size:13px;color:var(--text-muted);margin-top:14px;font-weight:500}.loading-screen{display:flex;height:100vh;align-items:center;justify-content:center;color:var(--text-muted);font-size:15px}.login-error{color:var(--danger);font-size:13px;margin-bottom:12px;font-weight:500}.api-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-top:20px}.api-card{background-color:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:30px 20px;display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:pointer}.api-card:hover{border-color:var(--primary)}.api-logo{font-size:24px;font-weight:700;margin-bottom:15px;color:var(--text-main)}.api-status{font-size:12px;color:var(--text-muted)}.domain-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:20px;margin-top:20px}.domain-card{background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:20px;display:flex;flex-direction:column;justify-content:space-between;min-height:350px}.domain-title{font-size:16px;font-weight:600;margin-bottom:15px;color:var(--text-main);word-break:break-all}.status-tag{display:flex;align-items:center;gap:8px;font-size:13px;margin-bottom:20px;font-weight:500}.status-tag.pending{color:var(--text-muted)}.status-tag.online{color:var(--success)}.input-group{display:flex;align-items:center;background:var(--input-bg);border-radius:6px;margin-bottom:10px;border:1px solid var(--border)}.input-group input{border:none;background:transparent;padding:10px;flex:1;font-size:12px;color:var(--text-muted);outline:none;min-width:0}.input-group button{background:none;border:none;padding:0 10px;cursor:pointer;color:var(--text-main)}.domain-list{list-style:none;padding:0;margin-bottom:20px}.domain-list li{display:flex;align-items:center;gap:8px;font-size:13px;margin-bottom:10px;color:var(--text-main)}.domain-actions{display:flex;gap:10px}.domain-actions button{flex:1;padding:8px;font-size:12px}.domain-card-bottom{display:flex;flex-direction:column;gap:10px;margin-top:20px}@media (max-width: 1100px){.domain-grid{grid-template-columns:repeat(2,1fr)}}@media (max-width: 800px){.domain-grid{grid-template-columns:1fr}}.login-page{display:flex;flex-direction:row;height:100vh;width:100%}.login-left{flex:1;position:relative;overflow:hidden;background:#0a0a0a;width:50%;display:flex}.login-left-content{position:relative;z-index:10;display:flex;flex-direction:column;justify-content:center;padding:4rem;height:100%;color:#fff;font-family:Inter,-apple-system,sans-serif;width:100%}.login-left-content *{color:#fff}.login-left-title{font-size:3rem;font-weight:700;line-height:1.2;margin-bottom:1.5rem;font-family:Poppins,sans-serif}.login-left-title .highlight-text{color:#8b5cf6}.login-left-description{font-size:1.25rem;margin-bottom:3rem;max-width:28rem;line-height:1.6;color:#ffffff80!important}.features-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:1.5rem;max-width:44rem;overflow:visible}.feature-step-item{display:flex;flex-direction:column;align-items:center;gap:.75rem;min-width:0;animation:step-card-in .5s ease-out both}.feature-step-item:nth-child(1){animation-delay:.3s}.feature-step-item:nth-child(2){animation-delay:.45s}.feature-step-item:nth-child(3){animation-delay:.6s}.feature-step-item:nth-child(4){animation-delay:.75s}@keyframes step-card-in{0%{opacity:0;transform:translateY(16px)}to{opacity:1;transform:translateY(0)}}.feature-card{display:flex;align-items:flex-start;gap:1rem;background:#7c3aed14;-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border-radius:1.25rem;padding:1.25rem;border:1px solid rgba(124,58,237,.22);position:relative;transition:border-color .3s ease,box-shadow .3s ease}.feature-card:hover{border-color:#7c3aed73;box-shadow:0 0 20px #7c3aed26}.feature-step{width:7rem;height:7rem;padding:0;border-radius:1.25rem;display:flex;align-items:center;justify-content:center;flex-shrink:0;align-self:center}.feature-step-number{position:absolute;top:.6rem;left:.75rem;font-size:.7rem;font-weight:600;line-height:1;color:#8b5cf68c;letter-spacing:.02em}.feature-step-icon{display:flex;align-items:center;justify-content:center}.feature-step-icon i{font-size:3rem;color:#8b5cf6bf;font-family:Material Symbols Outlined,sans-serif!important;font-variation-settings:"FILL" 1,"wght" 400,"GRAD" 0,"opsz" 24!important}.feature-step-arrow{position:absolute;right:-1.25rem;top:50%;transform:translateY(-50%);z-index:1}.feature-step-arrow:after{content:"";position:absolute;right:-7px;top:50%;width:0;height:0;border-left:8px solid rgba(236,72,153,.65);border-top:5px solid transparent;border-bottom:5px solid transparent;transform:translateY(-50%)}.feature-step-last .feature-step-arrow{display:none}.feature-title{font-weight:500;margin:0;font-size:.875rem;line-height:1.25;text-align:center;width:100%;color:#ffffffa6!important}.login-blur-effect{position:absolute;border-radius:50%;filter:blur(80px);opacity:1;pointer-events:none}.blur-effect-center{position:absolute;top:0;right:0;bottom:0;left:0;border-radius:0;filter:none;pointer-events:none;width:100%;height:100%;background:radial-gradient(ellipse 60% 40% at 50% 30%,rgba(124,58,237,.12) 0%,transparent 70%)}.blur-effect-1{width:520px;height:520px;background:radial-gradient(circle,rgba(124,58,237,.22) 0%,transparent 70%);filter:blur(40px);top:-10%;left:-8%;animation:orb-float-1 12s ease-in-out infinite}.blur-effect-2{width:440px;height:440px;background:radial-gradient(circle,rgba(236,72,153,.18) 0%,transparent 70%);filter:blur(40px);bottom:0%;right:-6%;animation:orb-float-2 15s ease-in-out infinite}@keyframes orb-float-1{0%,to{transform:translate(0) scale(1)}33%{transform:translate(40px,-30px) scale(1.08)}66%{transform:translate(-20px,20px) scale(.95)}}@keyframes orb-float-2{0%,to{transform:translate(0) scale(1)}33%{transform:translate(-30px,20px) scale(1.06)}66%{transform:translate(25px,-25px) scale(.97)}}.login-particle{position:absolute;border-radius:50%;pointer-events:none;filter:blur(1px);bottom:-10px;animation:particle-rise ease-in infinite}@keyframes particle-rise{0%{transform:translateY(0) scale(1);opacity:0}10%{opacity:1}90%{opacity:.6}to{transform:translateY(-110vh) scale(.4);opacity:0}}.login-right{flex:1;display:flex;flex-direction:column;justify-content:space-between;align-items:center;background-color:#f8fafc;padding:2rem;position:relative;width:50%;overflow-y:auto}.login-language-toggle{position:absolute;top:1.5rem;right:1.5rem;z-index:10}.language-toggle-wrapper{position:relative}.language-toggle-btn{background:none;border:1px solid #e2e8f0;border-radius:8px;padding:6px 10px;cursor:pointer;display:flex;align-items:center;justify-content:center;color:#64748b}.language-toggle-btn .material-symbols-outlined{font-size:20px}.language-toggle-dropdown{display:none;position:absolute;top:100%;right:0;margin-top:.5rem;background:#fff;border:1px solid #e2e8f0;border-radius:8px;box-shadow:0 4px 12px #0000001a;min-width:120px;overflow:hidden}.language-toggle-wrapper:hover .language-toggle-dropdown,.language-toggle-dropdown:hover{display:block}.language-toggle-item{padding:.5rem 1rem;cursor:pointer;font-size:.875rem}.language-toggle-item:hover{background:#f1f5f9}.language-toggle-item.active{background:#ede9fe;font-weight:600;color:#7c3aed}.login-form{width:100%;max-width:400px;flex:1;display:flex;flex-direction:column;justify-content:center}.login-form h2{font-size:1.75rem;font-weight:700;color:#334155;margin-bottom:.5rem}.login-form .login-subtitle{font-size:1rem;color:#64748b;margin-bottom:2rem}.login-form .form-group label{display:block;margin-bottom:.5rem;font-weight:500;font-size:.9rem;color:#334155}.login-form .form-group input{width:100%;padding:12px 15px;border-radius:4px;font-size:1rem;border:1px solid #e2e8f0;background:transparent;transition:border .3s ease;outline:none}.login-form .form-group input::-moz-placeholder{font-weight:300;font-size:.8rem;color:#94a3b8}.login-form .form-group input::placeholder{font-weight:300;font-size:.8rem;color:#94a3b8}.login-form .form-group input:focus{border-color:#8b5cf6;box-shadow:0 0 5px #8b5cf6}.login-form .form-group{margin-bottom:1.5rem}.email-input-wrapper,.password-input-wrapper{position:relative;width:100%}.email-input-wrapper input{padding-left:2.5rem!important}.password-input-wrapper input{padding-left:2.5rem!important;padding-right:45px}.email-icon,.lock-icon{position:absolute;left:12px;top:50%;transform:translateY(-50%);color:#ced1d6;pointer-events:none;font-size:20px}.password-toggle-btn{position:absolute;right:12px;top:50%;transform:translateY(-50%);background:none;border:none;cursor:pointer;padding:4px;display:flex;align-items:center;justify-content:center;color:#94a3b8;z-index:1}.password-toggle-btn:hover{color:#7c3aed}.login-button{border:none;border-radius:1.25rem;cursor:pointer;text-align:center;font-weight:500;letter-spacing:.5px;display:flex;align-items:center;justify-content:center;gap:.5rem;width:100%;padding:12px;background-color:#7c3aed;color:#fff;font-size:1rem;margin-top:.5rem;transition:background-color .3s ease,box-shadow .3s ease,transform .2s ease}.login-button:hover:not(:disabled){background-color:#6d28d9;transform:translateY(-2px)}.login-button:disabled{background-color:#e2e8f0;color:#94a3b8;cursor:not-allowed}.support-link{margin-top:1.5rem;text-align:center;font-size:.85rem;color:#64748b}.support-link p{margin-bottom:.5rem}.support-link a{color:#7c3aed;text-decoration:none;font-weight:500;cursor:pointer}.support-link a:hover{text-decoration:underline}.login-footer-bottom{padding-bottom:1rem;width:100%;max-width:400px;text-align:center;font-size:.8rem;color:#94a3b8}.login-footer-bottom a{color:#94a3b8;text-decoration:none;margin:0 10px}.login-footer-bottom a:hover{color:#7c3aed;text-decoration:underline}@media (max-width: 1024px){.login-left{display:none!important}.login-right{width:100%!important}}@media (max-width: 900px){.metrics-grid{grid-template-columns:repeat(2,1fr)}}.pixel-header{display:flex;align-items:center;gap:15px;margin-bottom:20px}.pixel-icon{background:var(--primary-light);color:var(--primary);padding:10px;border-radius:8px;font-size:20px}.pixel-top-bar{display:flex;justify-content:space-between;align-items:center;background:var(--bg-card);border:1px solid var(--border);padding:15px;border-radius:8px;margin-bottom:20px;flex-wrap:wrap;gap:10px}.pixel-tags{display:flex;gap:10px}.pixel-tag{background:var(--hover-bg);padding:4px 10px;border-radius:20px;font-size:12px;font-weight:600;color:var(--primary)}.warning-box{background:var(--warning-bg);border:1px solid var(--warning-border);border-radius:8px;padding:20px;display:flex;gap:15px;align-items:flex-start;margin-bottom:20px;position:relative}.warning-box .icon{background:var(--warning);color:#fff;width:30px;height:30px;border-radius:50%;display:flex;justify-content:center;align-items:center;font-weight:700;flex-shrink:0}.warning-box h4{font-size:15px;color:var(--text-main);margin-bottom:5px}.warning-box p{font-size:13px;color:var(--text-muted);margin:0;max-width:70%}.warning-box .btn-dark{position:absolute;right:20px;top:20px;background:#1e293b;color:#fff;border:none;padding:10px 15px;border-radius:6px;font-weight:600;font-size:12px;cursor:pointer}@media (max-width: 800px){.warning-box{flex-direction:column}.warning-box p{max-width:100%}.warning-box .btn-dark{position:static;margin-top:10px}}.visitor-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:15px;margin-bottom:30px}.visitor-card{background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:15px;display:flex;flex-direction:column;gap:10px;cursor:pointer;transition:.2s}.visitor-card:hover{border-color:var(--primary);transform:translateY(-2px);box-shadow:0 4px 12px #0000000d}.visitor-card-header{display:flex;justify-content:space-between;align-items:center;gap:8px}.visitor-ip{display:flex;align-items:center;gap:8px;font-weight:600;font-size:14px;word-break:break-all}.visitor-badge{background:var(--warning-bg);color:var(--warning);padding:3px 8px;border-radius:12px;font-size:11px;font-weight:600;white-space:nowrap}.visitor-card-details{font-size:12px;color:var(--text-muted);display:flex;justify-content:space-between;gap:8px;flex-wrap:wrap}.visitor-card-link{font-size:12px;color:var(--primary);text-align:right;cursor:pointer}.charts-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:15px;margin-bottom:20px}.chart-card{background:var(--bg-card);padding:20px;border-radius:8px;border:1px solid var(--border)}.chart-card h3{font-size:15px;margin-bottom:5px}.chart-card p{font-size:12px;color:var(--text-muted);margin-bottom:20px}.donut-chart{width:150px;height:150px;border-radius:50%;margin:0 auto 15px;display:flex;justify-content:center;align-items:center}.donut-inner{width:100px;height:100px;background:var(--bg-card);border-radius:50%}.chart-legend{display:flex;justify-content:center;flex-wrap:wrap;gap:12px;font-size:12px;color:var(--text-muted)}.legend-item{display:flex;align-items:center;gap:6px}.legend-color{width:10px;height:10px;border-radius:50%;display:inline-block}.bottom-grid{display:grid;grid-template-columns:1.5fr 1fr;gap:20px;margin-bottom:20px}.traffic-flow-card{background:var(--bg-card);padding:20px;border-radius:8px;border:1px solid var(--border)}.traffic-flow-card h3{font-size:16px}.traffic-flow-visual{height:200px;background:linear-gradient(to top,var(--primary-light),transparent);border-radius:8px;margin-top:20px;position:relative;border-bottom:2px solid var(--border);overflow:hidden}.traffic-flow-visual svg{width:100%;height:100%;position:absolute;bottom:0;left:0}.funnel-card{background:var(--bg-card);padding:20px;border-radius:8px;border:1px solid var(--border)}.funnel-step{margin-bottom:15px}.funnel-step-header{display:flex;justify-content:space-between;font-size:13px;font-weight:600;margin-bottom:5px;gap:8px}.funnel-step-bar{height:8px;background:var(--hover-bg);border-radius:4px;overflow:hidden}.funnel-step-bar div{height:100%;background:var(--primary);border-radius:4px}.visitor-modal-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px}.badge-human{background:#ecfdf5;color:#059669;padding:4px 10px;border-radius:20px;font-size:12px;font-weight:700}[data-theme=dark] .badge-human{background:#052e2b;color:#34d399}.visitor-details-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:15px;margin-bottom:25px}.detail-box{border:1px solid var(--border);border-radius:8px;padding:15px}.detail-box .label{font-size:11px;color:var(--text-muted);text-transform:uppercase;display:flex;align-items:center;gap:5px;margin-bottom:10px;font-weight:700}.detail-box .main-val{font-size:14px;font-weight:600;margin-bottom:4px;word-break:break-all}.detail-box .sub-val{font-size:12px;color:var(--text-muted);word-break:break-all}.badge-origin{background:#e0f2fe;color:#0284c7;padding:2px 8px;border-radius:12px;font-size:11px;font-weight:600;display:inline-block}[data-theme=dark] .badge-origin{background:#082f49;color:#7dd3fc}.modal-tabs{display:flex;gap:20px;border-bottom:1px solid var(--border);margin-bottom:20px}.modal-tab{padding:10px 0;font-size:13px;font-weight:600;color:var(--text-muted);cursor:pointer;border-bottom:2px solid transparent}.modal-tab.active{color:var(--primary);border-bottom-color:var(--primary)}.tracking-id-box{border:1px solid var(--border);border-radius:8px;padding:15px;display:inline-block;min-width:200px;background:var(--hover-bg);margin:0 10px 10px 0}.tracking-id-box .label{font-size:11px;color:var(--text-muted);font-weight:700;margin-bottom:5px}.tracking-id-box .val{font-size:14px;font-weight:600;word-break:break-all}.json-box{background:var(--code-bg);padding:20px;border-radius:8px;font-family:monospace;font-size:13px;color:var(--success);white-space:pre-wrap;border:1px solid var(--border);position:relative}.json-copy-btn{position:absolute;top:10px;right:10px;background:var(--bg-card);border:1px solid var(--border);padding:5px 10px;border-radius:4px;font-size:12px;cursor:pointer;color:var(--text-main)}@media (max-width: 1100px){.visitor-grid,.charts-grid{grid-template-columns:repeat(2,1fr)}.bottom-grid{grid-template-columns:1fr}}@media (max-width: 800px){.visitor-grid,.charts-grid{grid-template-columns:1fr}.visitor-details-grid{grid-template-columns:repeat(2,1fr)}}
|