@danhachuel/prodmaker 0.0.0-stage → 0.1.1
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 +56 -0
- package/LICENSE +21 -0
- package/MANUAL-INSTALACAO.md +68 -0
- package/README.md +42 -2
- package/THIRD-PARTY-NOTICES.md +29 -0
- package/app/__init__.py +1 -0
- package/app/main.py +324 -0
- package/frontend/dist/assets/index-COSPz2F9.css +1 -0
- package/frontend/dist/assets/index-CoHrpz8h.js +67 -0
- package/frontend/dist/index.html +23 -0
- package/legal/index.html +58 -0
- package/legal/privacy.html +67 -0
- package/legal/terms.html +57 -0
- package/package.json +41 -4
- package/requirements.txt +4 -0
- package/scripts/cli.mjs +249 -0
- package/seed/campaign-defaults/google.json +24 -0
- package/seed/campaign-defaults/meta.json +18 -0
- package/seed/campaign-defaults/pinterest.json +8 -0
- package/seed/campaign-defaults/tiktok.json +14 -0
- package/seed/checklists/lancamento.md +29 -0
- package/seed/decision-rules/otimizacao.json +12 -0
- package/seed/negatives/google-words.txt +20 -0
- package/seed/prompts/criativos.md +12 -0
- package/seed/prompts/infoproduto.md +23 -0
- package/seed/prompts/keywords.md +12 -0
- package/seed/prompts/pagina-vendas.md +24 -0
- package/seed/prompts/pins.md +15 -0
- package/seed/prompts/pre-sell.md +18 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Changelog — ProdMaker
|
|
2
|
+
|
|
3
|
+
Todas as versões seguem a etiqueta visual `MAJOR.MINOR.PATCH` com patch de dois dígitos
|
|
4
|
+
(o registry NPM normaliza `0.1.00` → `0.1.0`).
|
|
5
|
+
|
|
6
|
+
## 0.1.01 — 2026-10-25
|
|
7
|
+
|
|
8
|
+
- **Parte 2 — Frontend React + Vite + Tailwind (base AdsX 1:1).**
|
|
9
|
+
- Tela de login 1:1: split-screen com lado escuro (`#0A0A0A`) — título com highlight violeta,
|
|
10
|
+
4 etapas numeradas (Adicione o produto → Crie a Presell → Crie a Campanha → Receba Vendas)
|
|
11
|
+
com setas rosa, orbs flutuantes violeta/rosa, partículas animadas — e lado claro com
|
|
12
|
+
formulário (e-mail + código de ativação com olho mostrar/ocultar), seletor de idioma
|
|
13
|
+
(PT/EN/ES), links de suporte e rodapé com Termos/Privacidade (`/legal`). **Sem marcas terceiras.**
|
|
14
|
+
- Painel AdsX 1:1: sidebar 260px com menu completo (Seus Produtos, AdsDashboard, AdsHosting,
|
|
15
|
+
Infoprodutos, AdsPages, AdsWords, AdsCreator, AdsTracking, AdsBot, Pinterest, Contas do
|
|
16
|
+
Google, Chaves de API, Notificações, Logs, FAQ), submenus expansíveis, badge de versão,
|
|
17
|
+
botão Sair (gradiente); top bar com pill de contexto, avatar do usuário e **tema claro/escuro**
|
|
18
|
+
via `data-theme` (variáveis CSS do protótipo).
|
|
19
|
+
- **Seus Produtos funcional:** busca, abas 🔍 Em Análise / ✓ Aprovados, tabela com ações
|
|
20
|
+
(✏️ Editar, ✈️ AdsWord, ✓ Aprovar · 🛡️ AdsPages, 🎯 AdsCreator, ✈️ AdsWord, 🗑️), modal de
|
|
21
|
+
adicionar/editar (nome, plataforma, nicho, link de afiliado, comissão, preço), estados vazios
|
|
22
|
+
sem dados inventados.
|
|
23
|
+
- **AdsDashboard:** cards Conversões, Lucro Líquido, ROAS, Investimento (zerados até houver
|
|
24
|
+
tracking real — a UI nunca inventa dados).
|
|
25
|
+
- Módulos restantes como placeholders documentados (o que vem em cada um).
|
|
26
|
+
- Backend: CRUD completo de produtos em SQLite (`/api/produtos` GET/POST/PUT/DELETE +
|
|
27
|
+
`/aprovar`), versão 0.1.01.
|
|
28
|
+
- Auth: guard de sessão no frontend (cookie HMAC httpOnly), logout via sidebar.
|
|
29
|
+
- Workflow `publish-npm`: normalização canônica da versão solicitada antes do `npm version`
|
|
30
|
+
(corrige `npm error Version not changed` no npm 11 / Node 24 quando o package.json já
|
|
31
|
+
está na versão canônica da etiqueta visual pedida).
|
|
32
|
+
|
|
33
|
+
## 0.1.00 — 2026-10-25
|
|
34
|
+
|
|
35
|
+
- **Parte 1 — Fundação do repositório.**
|
|
36
|
+
- Monorepo npm `@danhachuel/prodmaker` (bin `prodmaker`) com launcher `scripts/cli.mjs`
|
|
37
|
+
(comandos `install`, `run`, `doctor`) — REGRA ZERO: nunca abre o navegador, apenas
|
|
38
|
+
imprime a URL `http://localhost:8188` no terminal.
|
|
39
|
+
- Backend FastAPI mínimo (`app/main.py`): auth local simulada (e-mail + código de
|
|
40
|
+
ativação, cookie HMAC httpOnly, licença local 31/12/2099), `/api/health`,
|
|
41
|
+
`/api/version`, `/api/settings` (GET/PUT com leitura protegida de JSON corrompido),
|
|
42
|
+
SPA fallback para `frontend/dist` (Parte 2) e pasta `/legal`.
|
|
43
|
+
- Storage persistente em `PRODMAKER_HOME` (env → `C:\Users\danha\Documents\REPOSITORIO\ProdMaker`
|
|
44
|
+
→ `%USERPROFILE%\ProdMaker`): `storage/{state,pages,produtos,artifacts,downloads,logs}`
|
|
45
|
+
+ `settings.json` com defaults.
|
|
46
|
+
- Seeds versionados em `seed/`: prompts (infoproduto, página de vendas 12 seções,
|
|
47
|
+
pre-sell, keywords, criativos, pins), negativas padrão Google Ads, defaults de
|
|
48
|
+
campanha (Google/Meta/TikTok/Pinterest), árvore de decisão de otimização e
|
|
49
|
+
checklist de lançamento.
|
|
50
|
+
- Pasta `legal/` servida em `/legal` (index, privacy LGPD, terms) para verificação
|
|
51
|
+
de aplicação junto às plataformas.
|
|
52
|
+
- Workflow `publish-npm` adaptado (validação de metadados, smoke test do launcher,
|
|
53
|
+
publicação com dist-tag `next`, verificação de tarball e promoção a `latest` via
|
|
54
|
+
tag `vX.Y.ZZ`).
|
|
55
|
+
- Documentos: AGENTS.md (regras permanentes), README, MANUAL-INSTALACAO,
|
|
56
|
+
THIRD-PARTY-NOTICES e Planejamento Único (v2.0).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dan Pariente Hachuel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Manual de Instalação — ProdMaker
|
|
2
|
+
|
|
3
|
+
## Pré-requisitos
|
|
4
|
+
|
|
5
|
+
- **Node.js 20 ou superior** — https://nodejs.org
|
|
6
|
+
- **Python 3.10 ou superior** — https://python.org (marcar "Add to PATH" no Windows)
|
|
7
|
+
- Internet para download do pacote npm e das dependências
|
|
8
|
+
|
|
9
|
+
## Instalar
|
|
10
|
+
|
|
11
|
+
Abra o terminal (CMD, PowerShell, MobaXterm ou outro) e execute:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# Windows (CMD/MobaXterm)
|
|
15
|
+
npx.cmd --yes --prefer-online @danhachuel/prodmaker@0.1.1 install
|
|
16
|
+
|
|
17
|
+
# Linux/macOS
|
|
18
|
+
npx --yes @danhachuel/prodmaker@0.1.1 install
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
O que o `install` faz:
|
|
22
|
+
|
|
23
|
+
1. Cria a pasta de dados **PRODMAKER_HOME** (ordem de resolução: variável `PRODMAKER_HOME` → `C:\Users\danha\Documents\REPOSITORIO\ProdMaker` → `%USERPROFILE%\ProdMaker`).
|
|
24
|
+
2. Cria o storage local: `storage/{state,pages,produtos,artifacts,downloads,logs}` + `settings.json`.
|
|
25
|
+
3. Cria um ambiente virtual Python (`venv`) dentro do PRODMAKER_HOME e instala o `requirements.txt`.
|
|
26
|
+
|
|
27
|
+
## Executar
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# Windows (CMD/MobaXterm)
|
|
31
|
+
npx.cmd --yes --prefer-online @danhachuel/prodmaker@0.1.1
|
|
32
|
+
|
|
33
|
+
# Linux/macOS
|
|
34
|
+
npx --yes @danhachuel/prodmaker@0.1.1
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
O terminal imprimirá:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
┌─────────────────────────────────────────────┐
|
|
41
|
+
│ ProdMaker rodando em: │
|
|
42
|
+
│ >>> http://localhost:8188 <<< │
|
|
43
|
+
│ Abra esta URL MANUALMENTE no navegador. │
|
|
44
|
+
└─────────────────────────────────────────────┘
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**Abra a URL manualmente.** O ProdMaker nunca abre o navegador sozinho (REGRA ZERO).
|
|
48
|
+
Se a porta 8188 estiver ocupada, o launcher usará automaticamente 8189–8199 e informará.
|
|
49
|
+
|
|
50
|
+
- Login: informe e-mail e código de ativação (no modo local simulado, qualquer e-mail válido + código com 4+ caracteres).
|
|
51
|
+
- Para encerrar: `Ctrl+C` no terminal.
|
|
52
|
+
|
|
53
|
+
## Diagnóstico
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
npx.cmd --yes @danhachuel/prodmaker@0.1.0 doctor
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Verifica Node, Python, venv, dependências, storage, backend e porta.
|
|
60
|
+
|
|
61
|
+
## Solução de problemas
|
|
62
|
+
|
|
63
|
+
| Problema | Solução |
|
|
64
|
+
|---|---|
|
|
65
|
+
| "Python não encontrado" | Instale o Python 3.10+ e rode `prodmaker install` novamente. |
|
|
66
|
+
| Porta 8188 ocupada | O launcher migra para 8189–8199 automaticamente; confira a URL impressa. |
|
|
67
|
+
| `settings.json corrompido` | O sistema preserva a cópia em `storage/state/settings.corrupt.json` e não sobrescreve. Restaure manualmente. |
|
|
68
|
+
| Erro no Windows sobre scripts desabilitados | Use `npx.cmd` (CMD/MobaXterm) em vez do `npx` do PowerShell. |
|
package/README.md
CHANGED
|
@@ -1,3 +1,43 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ProdMaker
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
🚀 **SaaS local para criação de infoprodutos low-ticket, landing pages, campanhas de tráfego e tracking** — baseado no sistema AdsX, executado no seu computador via `npx` e acessado manualmente no navegador.
|
|
4
|
+
|
|
5
|
+
> ⚠️ **REGRA ZERO:** o ProdMaker **nunca abre o navegador**. Ao iniciar, ele apenas imprime a URL (`http://localhost:8188`) no terminal — você abre manualmente.
|
|
6
|
+
|
|
7
|
+
## Instalação
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# CMD/MobaXterm (Windows)
|
|
11
|
+
npx.cmd --yes --prefer-online @danhachuel/prodmaker@0.1.1 install
|
|
12
|
+
npx.cmd --yes --prefer-online @danhachuel/prodmaker@0.1.1
|
|
13
|
+
|
|
14
|
+
# Linux/macOS/PowerShell
|
|
15
|
+
npx --yes @danhachuel/prodmaker@0.1.1 install
|
|
16
|
+
npx --yes @danhachuel/prodmaker@0.1.1
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
1. `install` cria a pasta de dados (`PRODMAKER_HOME`), o storage local e instala as dependências Python.
|
|
20
|
+
2. O segundo comando inicia o servidor em `http://localhost:8188` e imprime a URL no terminal.
|
|
21
|
+
3. **Abra a URL manualmente no navegador**, faça login (e-mail + código de ativação) e acesse o painel.
|
|
22
|
+
|
|
23
|
+
## O que está incluído (0.1.01 — Parte 2: Frontend)
|
|
24
|
+
|
|
25
|
+
| Área | Estado |
|
|
26
|
+
|---|---|
|
|
27
|
+
| Launcher npm (`install` / `run` / `doctor`) | ✅ |
|
|
28
|
+
| Backend FastAPI (auth local, settings, health) | ✅ |
|
|
29
|
+
| Storage persistente local (fora do cache npm) | ✅ |
|
|
30
|
+
| Seeds de negócio (prompts, negativas, defaults, decisão, checklist) | ✅ |
|
|
31
|
+
| `/legal` (index, privacidade LGPD, termos) | ✅ |
|
|
32
|
+
| Workflow de publicação NPM via GitHub Actions | ✅ |
|
|
33
|
+
| Frontend React + Vite + Tailwind (login + painel AdsX) | ✅ |
|
|
34
|
+
| Seus Produtos (CRUD real: análise → aprovação) | ✅ |
|
|
35
|
+
| Tema claro/escuro | ✅ |
|
|
36
|
+
|
|
37
|
+
## Estrutura
|
|
38
|
+
|
|
39
|
+
Consulte o [Planejamento Único](./PRODMAKER%20—%20PLANEJAMENTO%20ÚNICO%20E%20COMPLETO.md) para a visão completa (arquitetura, UI AdsX, módulos, fases e regras), o [Manual de Instalação](./MANUAL-INSTALACAO.md) e as [Regras do Repositório](./AGENTS.md).
|
|
40
|
+
|
|
41
|
+
## Licença
|
|
42
|
+
|
|
43
|
+
MIT — ver [LICENSE](./LICENSE) e [THIRD-PARTY-NOTICES](./THIRD-PARTY-NOTICES.md).
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Third-Party Notices — ProdMaker
|
|
2
|
+
|
|
3
|
+
O ProdMaker incorpora ou faz interface com os seguintes projetos de terceiros.
|
|
4
|
+
Esta lista cresce a cada fase conforme novas integrações são ativadas.
|
|
5
|
+
|
|
6
|
+
## Runtime (Parte 1)
|
|
7
|
+
|
|
8
|
+
- **FastAPI** — MIT License — https://github.com/fastapi/fastapi
|
|
9
|
+
- **Uvicorn** — BSD-3-Clause — https://github.com/encode/uvicorn
|
|
10
|
+
- **Pydantic** — MIT License — https://github.com/pydantic/pydantic
|
|
11
|
+
- **Node.js** — MIT License — https://nodejs.org
|
|
12
|
+
|
|
13
|
+
## Integrações planejadas (fases futuras)
|
|
14
|
+
|
|
15
|
+
- **google-ads-mcp-rw** — Google Ads MCP (write) — https://pypi.org/project/google-ads-mcp-rw
|
|
16
|
+
- **meta-ads-mcp-python** / **faborsky/meta-ads-app** — Meta Marketing API
|
|
17
|
+
- **AdsMCP/tiktok-ads-mcp-server** — TikTok Business API
|
|
18
|
+
- **google-ads-python** — cliente oficial Google Ads (conversões offline)
|
|
19
|
+
- **py3-pinterest** — cliente Pinterest
|
|
20
|
+
- **apprise** — notificações push — https://github.com/caronc/apprise
|
|
21
|
+
- **pyfun-ai** — pipeline de geração de páginas
|
|
22
|
+
- **zebbern/website-clone** — clonagem de páginas
|
|
23
|
+
- **redis-developer/redis-datasets (fraud-detection)** — padrão anti-fraude
|
|
24
|
+
- **WeasyPrint / xhtml2pdf** — geração de PDF
|
|
25
|
+
- **Gamma AI API** — geração premium de documentos
|
|
26
|
+
- **fastapi-saas-starter** — referência estrutural (futura versão em nuvem)
|
|
27
|
+
|
|
28
|
+
As licenças completas de cada projeto ficam nos repositórios oficiais linked acima.
|
|
29
|
+
Este arquivo deve ser atualizado sempre que uma nova dependência for incorporada.
|
package/app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Torna `app` um pacote Python (necessário para `uvicorn app.main:app`).
|
package/app/main.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hashlib
|
|
3
|
+
import hmac
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sqlite3
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from fastapi import FastAPI, HTTPException, Request, Response
|
|
11
|
+
from fastapi.responses import FileResponse, HTMLResponse
|
|
12
|
+
from fastapi.staticfiles import StaticFiles
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
APP_VERSION = "0.1.01"
|
|
16
|
+
COOKIE_NAME = "prodmaker_session"
|
|
17
|
+
SESSION_DAYS = 30
|
|
18
|
+
|
|
19
|
+
PRODMAKER_HOME = Path(os.environ.get("PRODMAKER_HOME", str(Path.home() / "ProdMaker")))
|
|
20
|
+
STORAGE = PRODMAKER_HOME / "storage"
|
|
21
|
+
STATE = STORAGE / "state"
|
|
22
|
+
DIST_DIR = Path(__file__).resolve().parent.parent / "frontend" / "dist"
|
|
23
|
+
LEGAL_DIR = Path(__file__).resolve().parent.parent / "legal"
|
|
24
|
+
|
|
25
|
+
app = FastAPI(title="ProdMaker", version=APP_VERSION)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Storage helpers (leitura protegida: nunca sobrescrever dados corrompidos)
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def ensure_state_dir() -> None:
|
|
34
|
+
STATE.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_settings() -> dict:
|
|
38
|
+
settings_path = STATE / "settings.json"
|
|
39
|
+
if not settings_path.exists():
|
|
40
|
+
return {"schema": 1, "port": 8188, "language": "pt-BR", "theme": "light", "api_keys": {}, "llm": {"providers": []}}
|
|
41
|
+
try:
|
|
42
|
+
return json.loads(settings_path.read_text(encoding="utf-8"))
|
|
43
|
+
except json.JSONDecodeError:
|
|
44
|
+
raise HTTPException(status_code=500, detail="settings.json corrompido — leitura protegida. Restaure o arquivo manualmente.")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def save_settings(data: dict) -> dict:
|
|
48
|
+
ensure_state_dir()
|
|
49
|
+
settings_path = STATE / "settings.json"
|
|
50
|
+
if settings_path.exists():
|
|
51
|
+
try:
|
|
52
|
+
json.loads(settings_path.read_text(encoding="utf-8"))
|
|
53
|
+
except json.JSONDecodeError:
|
|
54
|
+
backup = STATE / "settings.corrupt.json"
|
|
55
|
+
backup.write_text(settings_path.read_text(encoding="utf-8", errors="replace"), encoding="utf-8")
|
|
56
|
+
raise HTTPException(status_code=500, detail="settings.json corrompido — cópia preservada em settings.corrupt.json.")
|
|
57
|
+
settings_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
58
|
+
return data
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_secret() -> bytes:
|
|
62
|
+
ensure_state_dir()
|
|
63
|
+
secret_path = STATE / "secret.key"
|
|
64
|
+
if not secret_path.exists():
|
|
65
|
+
secret_path.write_bytes(os.urandom(32))
|
|
66
|
+
return secret_path.read_bytes()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# Banco de dados local (SQLite via stdlib)
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_db():
|
|
75
|
+
ensure_state_dir()
|
|
76
|
+
conn = sqlite3.connect(STATE / "prodmaker.db")
|
|
77
|
+
conn.row_factory = sqlite3.Row
|
|
78
|
+
return conn
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def ensure_db() -> None:
|
|
82
|
+
with get_db() as conn:
|
|
83
|
+
conn.execute(
|
|
84
|
+
"""
|
|
85
|
+
CREATE TABLE IF NOT EXISTS produtos (
|
|
86
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
87
|
+
nome TEXT NOT NULL,
|
|
88
|
+
plataforma TEXT NOT NULL DEFAULT 'Outro',
|
|
89
|
+
link_afiliado TEXT NOT NULL DEFAULT '',
|
|
90
|
+
comissao REAL NOT NULL DEFAULT 0,
|
|
91
|
+
preco REAL NOT NULL DEFAULT 0,
|
|
92
|
+
nicho TEXT NOT NULL DEFAULT '',
|
|
93
|
+
status TEXT NOT NULL DEFAULT 'em_analise',
|
|
94
|
+
created_at TEXT NOT NULL,
|
|
95
|
+
updated_at TEXT NOT NULL
|
|
96
|
+
)
|
|
97
|
+
"""
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
ensure_db()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def now_str() -> str:
|
|
105
|
+
return time.strftime("%Y-%m-%d %H:%M:%S")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def row_to_dict(row) -> dict:
|
|
109
|
+
return {key: row[key] for key in row.keys()}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# Auth local (v1: validação simulada; estrutura pronta para validação remota)
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def make_token(email: str) -> str:
|
|
118
|
+
payload = base64.urlsafe_b64encode(json.dumps({"email": email, "exp": int(time.time()) + SESSION_DAYS * 86400}).encode()).decode()
|
|
119
|
+
signature = hmac.new(get_secret(), payload.encode(), hashlib.sha256).hexdigest()
|
|
120
|
+
return f"{payload}.{signature}"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def read_token(token: str) -> dict | None:
|
|
124
|
+
try:
|
|
125
|
+
payload, signature = token.split(".", 1)
|
|
126
|
+
expected = hmac.new(get_secret(), payload.encode(), hashlib.sha256).hexdigest()
|
|
127
|
+
if not hmac.compare_digest(signature, expected):
|
|
128
|
+
return None
|
|
129
|
+
data = json.loads(base64.urlsafe_b64decode(payload))
|
|
130
|
+
if int(data.get("exp", 0)) < time.time():
|
|
131
|
+
return None
|
|
132
|
+
return data
|
|
133
|
+
except Exception:
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def get_license() -> dict:
|
|
138
|
+
license_path = STATE / "license.json"
|
|
139
|
+
if license_path.exists():
|
|
140
|
+
try:
|
|
141
|
+
return json.loads(license_path.read_text(encoding="utf-8"))
|
|
142
|
+
except json.JSONDecodeError:
|
|
143
|
+
return {}
|
|
144
|
+
return {}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class LoginBody(BaseModel):
|
|
148
|
+
email: str
|
|
149
|
+
activation_code: str
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@app.post("/api/auth/login")
|
|
153
|
+
def auth_login(body: LoginBody, response: Response):
|
|
154
|
+
email = body.email.strip()
|
|
155
|
+
code = body.activation_code.strip()
|
|
156
|
+
if "@" not in email or len(code) < 4:
|
|
157
|
+
raise HTTPException(status_code=400, detail="E-mail inválido ou código de ativação muito curto (mínimo 4 caracteres).")
|
|
158
|
+
ensure_state_dir()
|
|
159
|
+
license_path = STATE / "license.json"
|
|
160
|
+
if not license_path.exists():
|
|
161
|
+
license_data = {"email": email, "created_at": time.strftime("%Y-%m-%dT%H:%M:%S"), "expires": "2099-12-31"}
|
|
162
|
+
license_path.write_text(json.dumps(license_data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
163
|
+
response.set_cookie(COOKIE_NAME, make_token(email), httponly=True, max_age=SESSION_DAYS * 86400, samesite="lax")
|
|
164
|
+
return {"ok": True, "email": email, "license": get_license()}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@app.post("/api/auth/logout")
|
|
168
|
+
def auth_logout(response: Response):
|
|
169
|
+
response.delete_cookie(COOKIE_NAME)
|
|
170
|
+
return {"ok": True}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@app.get("/api/auth/session")
|
|
174
|
+
def auth_session(request: Request):
|
|
175
|
+
token = request.cookies.get(COOKIE_NAME)
|
|
176
|
+
data = read_token(token) if token else None
|
|
177
|
+
if not data:
|
|
178
|
+
return {"authenticated": False}
|
|
179
|
+
return {"authenticated": True, "email": data.get("email"), "license": get_license()}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
# Produtos (Seus Produtos — em análise / aprovados)
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class ProdutoBody(BaseModel):
|
|
188
|
+
nome: str
|
|
189
|
+
plataforma: str = "Outro"
|
|
190
|
+
link_afiliado: str = ""
|
|
191
|
+
comissao: float = 0
|
|
192
|
+
preco: float = 0
|
|
193
|
+
nicho: str = ""
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@app.get("/api/produtos")
|
|
197
|
+
def list_produtos():
|
|
198
|
+
with get_db() as conn:
|
|
199
|
+
rows = conn.execute("SELECT * FROM produtos ORDER BY updated_at DESC, id DESC").fetchall()
|
|
200
|
+
return [row_to_dict(row) for row in rows]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@app.post("/api/produtos")
|
|
204
|
+
def create_produto(body: ProdutoBody):
|
|
205
|
+
if not body.nome.strip():
|
|
206
|
+
raise HTTPException(status_code=400, detail="Informe o nome do produto.")
|
|
207
|
+
timestamp = now_str()
|
|
208
|
+
with get_db() as conn:
|
|
209
|
+
cursor = conn.execute(
|
|
210
|
+
"INSERT INTO produtos (nome, plataforma, link_afiliado, comissao, preco, nicho, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'em_analise', ?, ?)",
|
|
211
|
+
(body.nome.strip(), body.plataforma, body.link_afiliado.strip(), body.comissao, body.preco, body.nicho.strip(), timestamp, timestamp),
|
|
212
|
+
)
|
|
213
|
+
row = conn.execute("SELECT * FROM produtos WHERE id = ?", (cursor.lastrowid,)).fetchone()
|
|
214
|
+
return row_to_dict(row)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@app.put("/api/produtos/{produto_id}")
|
|
218
|
+
def update_produto(produto_id: int, body: ProdutoBody):
|
|
219
|
+
if not body.nome.strip():
|
|
220
|
+
raise HTTPException(status_code=400, detail="Informe o nome do produto.")
|
|
221
|
+
with get_db() as conn:
|
|
222
|
+
found = conn.execute("SELECT id FROM produtos WHERE id = ?", (produto_id,)).fetchone()
|
|
223
|
+
if not found:
|
|
224
|
+
raise HTTPException(status_code=404, detail="Produto não encontrado.")
|
|
225
|
+
conn.execute(
|
|
226
|
+
"UPDATE produtos SET nome = ?, plataforma = ?, link_afiliado = ?, comissao = ?, preco = ?, nicho = ?, updated_at = ? WHERE id = ?",
|
|
227
|
+
(body.nome.strip(), body.plataforma, body.link_afiliado.strip(), body.comissao, body.preco, body.nicho.strip(), now_str(), produto_id),
|
|
228
|
+
)
|
|
229
|
+
row = conn.execute("SELECT * FROM produtos WHERE id = ?", (produto_id,)).fetchone()
|
|
230
|
+
return row_to_dict(row)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@app.delete("/api/produtos/{produto_id}")
|
|
234
|
+
def delete_produto(produto_id: int):
|
|
235
|
+
with get_db() as conn:
|
|
236
|
+
cursor = conn.execute("DELETE FROM produtos WHERE id = ?", (produto_id,))
|
|
237
|
+
if cursor.rowcount == 0:
|
|
238
|
+
raise HTTPException(status_code=404, detail="Produto não encontrado.")
|
|
239
|
+
return {"ok": True}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@app.post("/api/produtos/{produto_id}/aprovar")
|
|
243
|
+
def aprovar_produto(produto_id: int):
|
|
244
|
+
with get_db() as conn:
|
|
245
|
+
cursor = conn.execute("UPDATE produtos SET status = 'aprovado', updated_at = ? WHERE id = ?", (now_str(), produto_id))
|
|
246
|
+
if cursor.rowcount == 0:
|
|
247
|
+
raise HTTPException(status_code=404, detail="Produto não encontrado.")
|
|
248
|
+
row = conn.execute("SELECT * FROM produtos WHERE id = ?", (produto_id,)).fetchone()
|
|
249
|
+
return row_to_dict(row)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ---------------------------------------------------------------------------
|
|
253
|
+
# Núcleo
|
|
254
|
+
# ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@app.get("/api/health")
|
|
258
|
+
def health():
|
|
259
|
+
return {"ok": True, "version": APP_VERSION}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@app.get("/api/version")
|
|
263
|
+
def version():
|
|
264
|
+
return {"version": APP_VERSION, "canonical": "0.1.1"}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@app.get("/api/settings")
|
|
268
|
+
def read_settings():
|
|
269
|
+
return get_settings()
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@app.put("/api/settings")
|
|
273
|
+
def update_settings(data: dict):
|
|
274
|
+
return save_settings(data)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ---------------------------------------------------------------------------
|
|
278
|
+
# Estáticos: /legal, frontend/dist (SPA fallback) e páginas geradas
|
|
279
|
+
# ---------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
if LEGAL_DIR.exists():
|
|
282
|
+
app.mount("/legal", StaticFiles(directory=str(LEGAL_DIR), html=True), name="legal")
|
|
283
|
+
|
|
284
|
+
PAGES_DIR = STORAGE / "pages"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@app.get("/pages/{page_id}")
|
|
288
|
+
def preview_page(page_id: str):
|
|
289
|
+
page_path = PAGES_DIR / page_id / "index.html"
|
|
290
|
+
if page_path.exists():
|
|
291
|
+
return FileResponse(page_path)
|
|
292
|
+
raise HTTPException(status_code=404, detail="Página não encontrada.")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _spa_index():
|
|
296
|
+
index_path = DIST_DIR / "index.html"
|
|
297
|
+
if index_path.exists():
|
|
298
|
+
return FileResponse(index_path)
|
|
299
|
+
return HTMLResponse(
|
|
300
|
+
"""<!DOCTYPE html>
|
|
301
|
+
<html lang="pt-BR"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
302
|
+
<title>ProdMaker</title>
|
|
303
|
+
<style>body{font-family:-apple-system,'Segoe UI',Roboto,sans-serif;background:#f8fafc;color:#1e293b;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0}
|
|
304
|
+
.box{max-width:520px;padding:40px;text-align:center}h1{color:#7C3AED}.muted{color:#64748b}</style></head>
|
|
305
|
+
<body><div class="box"><h1>🚀 ProdMaker</h1>
|
|
306
|
+
<p>O backend está rodando.</p>
|
|
307
|
+
<p class="muted">O frontend (React + Vite + Tailwind) será publicado na Parte 2.</p>
|
|
308
|
+
<p class="muted">API: <code>/api/health</code> · Legal: <code>/legal/</code></p></div></body></html>"""
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@app.get("/")
|
|
313
|
+
def index():
|
|
314
|
+
return _spa_index()
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@app.get("/{full_path:path}")
|
|
318
|
+
def spa_fallback(full_path: str):
|
|
319
|
+
if full_path.startswith("api/"):
|
|
320
|
+
raise HTTPException(status_code=404, detail="Não encontrado.")
|
|
321
|
+
candidate = DIST_DIR / full_path
|
|
322
|
+
if full_path and candidate.is_file():
|
|
323
|
+
return FileResponse(candidate)
|
|
324
|
+
return _spa_index()
|
|
@@ -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}.mb-20{margin-bottom:5rem}.block{display:block}.flex{display:flex}.table{display:table}.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)}.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)}}
|