@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 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
- # Temporary Holding Version
1
+ # ProdMaker
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.
@@ -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)}}