Spaces:
Paused
Paused
Carlexxx commited on
Commit ·
8072ab3
1
Parent(s): 8424761
xxx
Browse files- app/__pycache__/__init__.cpython-313.pyc +0 -0
- app/__pycache__/auth.cpython-313.pyc +0 -0
- app/__pycache__/backup_service.cpython-313.pyc +0 -0
- app/__pycache__/config.cpython-313.pyc +0 -0
- app/__pycache__/excel_service.cpython-313.pyc +0 -0
- app/__pycache__/main.cpython-313.pyc +0 -0
- app/__pycache__/paginas_html.cpython-313.pyc +0 -0
- app/__pycache__/schemas.cpython-313.pyc +0 -0
- app/__pycache__/seed_data.cpython-313.pyc +0 -0
- app/__pycache__/seo.cpython-313.pyc +0 -0
- app/__pycache__/site_config.cpython-313.pyc +0 -0
- app/auth.py +54 -0
- app/backup_service.py +210 -0
- app/main.py +242 -22
- app/paginas_html.py +79 -0
- app/routers/__pycache__/__init__.cpython-313.pyc +0 -0
- app/routers/__pycache__/anuncios.cpython-313.pyc +0 -0
- app/routers/anuncios.py +35 -6
- app/schemas.py +1 -0
- app/seo.py +64 -5
- app/site_config.py +81 -0
- requirements.txt +1 -0
- static/admin.html +0 -231
- static/busca.html +0 -149
- static/css/style.css +185 -23
- static/imovel.html +0 -87
- static/index.html +0 -161
- static/js/admin.js +462 -98
- static/js/admin_midias.js +86 -0
- static/js/api.js +16 -5
- static/js/busca.js +1 -1
- static/js/home.js +38 -11
- static/js/imovel.js +4 -2
- {static → templates}/404.html +2 -8
- templates/_footer.html +4 -6
- templates/_header.html +4 -4
- templates/admin.html +386 -0
- templates/admin_login.html +39 -0
- templates/admin_midias.html +48 -0
- templates/busca.html +6 -14
- {static → templates}/contato.html +4 -77
- templates/imovel.html +10 -9
- templates/index.html +31 -0
- {static → templates}/servicos.html +3 -73
- {static → templates}/sobre.html +3 -64
app/__pycache__/__init__.cpython-313.pyc
CHANGED
|
Binary files a/app/__pycache__/__init__.cpython-313.pyc and b/app/__pycache__/__init__.cpython-313.pyc differ
|
|
|
app/__pycache__/auth.cpython-313.pyc
ADDED
|
Binary file (3.05 kB). View file
|
|
|
app/__pycache__/backup_service.cpython-313.pyc
ADDED
|
Binary file (13.7 kB). View file
|
|
|
app/__pycache__/config.cpython-313.pyc
CHANGED
|
Binary files a/app/__pycache__/config.cpython-313.pyc and b/app/__pycache__/config.cpython-313.pyc differ
|
|
|
app/__pycache__/excel_service.cpython-313.pyc
CHANGED
|
Binary files a/app/__pycache__/excel_service.cpython-313.pyc and b/app/__pycache__/excel_service.cpython-313.pyc differ
|
|
|
app/__pycache__/main.cpython-313.pyc
CHANGED
|
Binary files a/app/__pycache__/main.cpython-313.pyc and b/app/__pycache__/main.cpython-313.pyc differ
|
|
|
app/__pycache__/paginas_html.cpython-313.pyc
ADDED
|
Binary file (4.31 kB). View file
|
|
|
app/__pycache__/schemas.cpython-313.pyc
CHANGED
|
Binary files a/app/__pycache__/schemas.cpython-313.pyc and b/app/__pycache__/schemas.cpython-313.pyc differ
|
|
|
app/__pycache__/seed_data.cpython-313.pyc
DELETED
|
Binary file (11.6 kB)
|
|
|
app/__pycache__/seo.cpython-313.pyc
CHANGED
|
Binary files a/app/__pycache__/seo.cpython-313.pyc and b/app/__pycache__/seo.cpython-313.pyc differ
|
|
|
app/__pycache__/site_config.cpython-313.pyc
ADDED
|
Binary file (4.27 kB). View file
|
|
|
app/auth.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Autenticação simples do painel administrativo.
|
| 3 |
+
|
| 4 |
+
Não é um sistema de usuários completo (não há cadastro, nem papéis) — é uma
|
| 5 |
+
única senha de acesso, guardada em variável de ambiente, validada contra uma
|
| 6 |
+
sessão assinada em cookie (via SessionMiddleware do Starlette). O objetivo é
|
| 7 |
+
apenas fechar a porta que antes estava totalmente aberta em /admin.html e nas
|
| 8 |
+
rotas de escrita da API de anúncios.
|
| 9 |
+
|
| 10 |
+
Definir em produção:
|
| 11 |
+
ADMIN_PASSWORD -> senha de acesso ao painel
|
| 12 |
+
SECRET_KEY -> chave usada para assinar o cookie de sessão
|
| 13 |
+
|
| 14 |
+
Se não forem definidas, caem em valores padrão de demonstração (a senha é
|
| 15 |
+
avisada no log de startup) — não use os padrões em produção.
|
| 16 |
+
"""
|
| 17 |
+
import hmac
|
| 18 |
+
import os
|
| 19 |
+
|
| 20 |
+
from fastapi import HTTPException, Request
|
| 21 |
+
|
| 22 |
+
SESSION_KEY = "admin_autenticado"
|
| 23 |
+
SESSION_COOKIE = "sk_admin_sessao"
|
| 24 |
+
|
| 25 |
+
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "sk-imobiliaria-2026")
|
| 26 |
+
SECRET_KEY = os.environ.get("SECRET_KEY") or os.environ.get("SESSION_SECRET_KEY") or os.urandom(32).hex()
|
| 27 |
+
|
| 28 |
+
USANDO_SENHA_PADRAO = "ADMIN_PASSWORD" not in os.environ
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def senha_correta(senha: str) -> bool:
|
| 32 |
+
"""Compara em tempo constante para não vazar a senha por timing."""
|
| 33 |
+
return hmac.compare_digest((senha or "").strip(), ADMIN_PASSWORD)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def esta_autenticado(request: Request) -> bool:
|
| 37 |
+
return bool(request.session.get(SESSION_KEY))
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def autenticar(request: Request) -> None:
|
| 41 |
+
request.session[SESSION_KEY] = True
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def deslogar(request: Request) -> None:
|
| 45 |
+
request.session.clear()
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def exigir_admin_api(request: Request) -> None:
|
| 49 |
+
"""Dependência para proteger rotas de escrita da API usadas pelo painel admin."""
|
| 50 |
+
if not esta_autenticado(request):
|
| 51 |
+
raise HTTPException(
|
| 52 |
+
status_code=401,
|
| 53 |
+
detail="Não autenticado. Faça login em /admin/login para gerenciar anúncios.",
|
| 54 |
+
)
|
app/backup_service.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Backup e restauração completa dos dados do site.
|
| 3 |
+
|
| 4 |
+
Reúne num único arquivo .zip tudo que o painel admin é capaz de alterar:
|
| 5 |
+
- a planilha de anúncios (data/imoveis.xlsx)
|
| 6 |
+
- as configurações do site (data/config_site.json)
|
| 7 |
+
- as mídias (fotos/vídeos/áudios) enviadas para os anúncios (data/midia/)
|
| 8 |
+
- o HTML das páginas institucionais editáveis pelo painel (templates/*.html)
|
| 9 |
+
|
| 10 |
+
A restauração faz o caminho inverso: recebe um .zip gerado por este mesmo
|
| 11 |
+
módulo e sobrescreve esses arquivos. Antes de sobrescrever qualquer coisa,
|
| 12 |
+
é tirado automaticamente um backup de segurança do estado atual (guardado
|
| 13 |
+
em data/backups/), para permitir desfazer uma restauração malfeita.
|
| 14 |
+
"""
|
| 15 |
+
import io
|
| 16 |
+
import json
|
| 17 |
+
import os
|
| 18 |
+
import shutil
|
| 19 |
+
import zipfile
|
| 20 |
+
from datetime import datetime
|
| 21 |
+
|
| 22 |
+
from fastapi import HTTPException
|
| 23 |
+
|
| 24 |
+
from app.config import DATA_DIR, EXCEL_PATH
|
| 25 |
+
from app import paginas_html, site_config
|
| 26 |
+
|
| 27 |
+
MIDIA_DIR = os.path.join(DATA_DIR, "midia")
|
| 28 |
+
BACKUPS_DIR = os.path.join(DATA_DIR, "backups")
|
| 29 |
+
|
| 30 |
+
MANIFEST_APP_ID = "skimobiliaria"
|
| 31 |
+
MANIFEST_VERSAO = 1
|
| 32 |
+
|
| 33 |
+
MAX_BACKUPS_AUTOMATICOS = 8
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _agora_str() -> str:
|
| 37 |
+
return datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _montar_zip(destino: io.BytesIO, motivo: str) -> dict:
|
| 41 |
+
"""Escreve o backup completo dentro de `destino` (um buffer ou arquivo aberto em
|
| 42 |
+
modo binário) e devolve um pequeno resumo do que foi incluído."""
|
| 43 |
+
resumo = {"planilha": False, "config_site": False, "midias": 0, "paginas": 0}
|
| 44 |
+
|
| 45 |
+
with zipfile.ZipFile(destino, "w", zipfile.ZIP_DEFLATED) as zf:
|
| 46 |
+
manifest = {
|
| 47 |
+
"app": MANIFEST_APP_ID,
|
| 48 |
+
"versao": MANIFEST_VERSAO,
|
| 49 |
+
"gerado_em": datetime.now().isoformat(),
|
| 50 |
+
"motivo": motivo,
|
| 51 |
+
}
|
| 52 |
+
zf.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2))
|
| 53 |
+
|
| 54 |
+
if os.path.exists(EXCEL_PATH):
|
| 55 |
+
zf.write(EXCEL_PATH, "data/imoveis.xlsx")
|
| 56 |
+
resumo["planilha"] = True
|
| 57 |
+
|
| 58 |
+
if os.path.exists(site_config.CONFIG_PATH):
|
| 59 |
+
zf.write(site_config.CONFIG_PATH, "data/config_site.json")
|
| 60 |
+
resumo["config_site"] = True
|
| 61 |
+
|
| 62 |
+
if os.path.isdir(MIDIA_DIR):
|
| 63 |
+
for raiz, _dirs, arquivos in os.walk(MIDIA_DIR):
|
| 64 |
+
for nome in arquivos:
|
| 65 |
+
caminho_absoluto = os.path.join(raiz, nome)
|
| 66 |
+
caminho_relativo = os.path.relpath(caminho_absoluto, DATA_DIR)
|
| 67 |
+
zf.write(caminho_absoluto, os.path.join("data", caminho_relativo))
|
| 68 |
+
resumo["midias"] += 1
|
| 69 |
+
|
| 70 |
+
for _pid, (arquivo, _nome, _desc) in paginas_html.PAGINAS_EDITAVEIS.items():
|
| 71 |
+
caminho = os.path.join(paginas_html.TEMPLATES_DIR, arquivo)
|
| 72 |
+
if os.path.exists(caminho):
|
| 73 |
+
zf.write(caminho, os.path.join("paginas", arquivo))
|
| 74 |
+
resumo["paginas"] += 1
|
| 75 |
+
|
| 76 |
+
return resumo
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def gerar_backup_zip(motivo: str = "manual") -> tuple[bytes, str, dict]:
|
| 80 |
+
"""Gera o backup completo em memória. Devolve (bytes_do_zip, nome_do_arquivo, resumo)."""
|
| 81 |
+
buffer = io.BytesIO()
|
| 82 |
+
resumo = _montar_zip(buffer, motivo)
|
| 83 |
+
nome_arquivo = f"backup_skimobiliaria_{_agora_str()}.zip"
|
| 84 |
+
return buffer.getvalue(), nome_arquivo, resumo
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _salvar_backup_automatico(motivo: str) -> str:
|
| 88 |
+
"""Salva uma cópia de segurança em data/backups/ antes de uma restauração,
|
| 89 |
+
e descarta as mais antigas para não deixar a pasta crescer sem limite."""
|
| 90 |
+
os.makedirs(BACKUPS_DIR, exist_ok=True)
|
| 91 |
+
nome = f"auto_{_agora_str()}_{motivo}.zip"
|
| 92 |
+
caminho = os.path.join(BACKUPS_DIR, nome)
|
| 93 |
+
with open(caminho, "wb") as f:
|
| 94 |
+
_montar_zip(f, motivo=f"automático (antes de {motivo})")
|
| 95 |
+
|
| 96 |
+
backups = sorted(
|
| 97 |
+
(n for n in os.listdir(BACKUPS_DIR) if n.startswith("auto_") and n.endswith(".zip")),
|
| 98 |
+
)
|
| 99 |
+
excedentes = backups[:-MAX_BACKUPS_AUTOMATICOS] if len(backups) > MAX_BACKUPS_AUTOMATICOS else []
|
| 100 |
+
for antigo in excedentes:
|
| 101 |
+
try:
|
| 102 |
+
os.remove(os.path.join(BACKUPS_DIR, antigo))
|
| 103 |
+
except OSError:
|
| 104 |
+
pass
|
| 105 |
+
|
| 106 |
+
return nome
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def listar_backups_automaticos() -> list:
|
| 110 |
+
if not os.path.isdir(BACKUPS_DIR):
|
| 111 |
+
return []
|
| 112 |
+
itens = []
|
| 113 |
+
for nome in sorted(os.listdir(BACKUPS_DIR), reverse=True):
|
| 114 |
+
if nome.endswith(".zip"):
|
| 115 |
+
caminho = os.path.join(BACKUPS_DIR, nome)
|
| 116 |
+
itens.append({
|
| 117 |
+
"nome": nome,
|
| 118 |
+
"tamanho_bytes": os.path.getsize(caminho),
|
| 119 |
+
"criado_em": datetime.fromtimestamp(os.path.getmtime(caminho)).isoformat(),
|
| 120 |
+
})
|
| 121 |
+
return itens
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def caminho_backup_automatico(nome: str) -> str:
|
| 125 |
+
"""Resolve o caminho de um backup automático pelo nome, com trava contra path traversal."""
|
| 126 |
+
nome_seguro = os.path.basename(nome)
|
| 127 |
+
caminho = os.path.normpath(os.path.join(BACKUPS_DIR, nome_seguro))
|
| 128 |
+
if not caminho.startswith(os.path.normpath(BACKUPS_DIR)) or not os.path.isfile(caminho):
|
| 129 |
+
raise HTTPException(status_code=404, detail="Backup não encontrado.")
|
| 130 |
+
return caminho
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _validar_zip(zf: zipfile.ZipFile) -> dict:
|
| 134 |
+
nomes = zf.namelist()
|
| 135 |
+
if "manifest.json" not in nomes:
|
| 136 |
+
raise HTTPException(
|
| 137 |
+
status_code=400,
|
| 138 |
+
detail="Arquivo .zip inválido: não parece ser um backup gerado por este site (falta manifest.json).",
|
| 139 |
+
)
|
| 140 |
+
try:
|
| 141 |
+
manifest = json.loads(zf.read("manifest.json").decode("utf-8"))
|
| 142 |
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
| 143 |
+
raise HTTPException(status_code=400, detail="manifest.json do backup está corrompido.")
|
| 144 |
+
if manifest.get("app") != MANIFEST_APP_ID:
|
| 145 |
+
raise HTTPException(status_code=400, detail="Este .zip não é um backup da SK Imobiliária.")
|
| 146 |
+
return manifest
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _extrair_seguro(zf: zipfile.ZipFile, nome_no_zip: str, destino: str) -> None:
|
| 150 |
+
"""Extrai um único membro do zip para `destino`, validando que o caminho final
|
| 151 |
+
não escapa do diretório esperado (proteção contra 'zip slip')."""
|
| 152 |
+
destino_normalizado = os.path.normpath(destino)
|
| 153 |
+
diretorio_pai = os.path.dirname(destino_normalizado)
|
| 154 |
+
os.makedirs(diretorio_pai, exist_ok=True)
|
| 155 |
+
with zf.open(nome_no_zip) as origem, open(destino_normalizado, "wb") as saida:
|
| 156 |
+
shutil.copyfileobj(origem, saida)
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def restaurar_backup_zip(conteudo_zip: bytes) -> dict:
|
| 160 |
+
"""Restaura um backup completo a partir dos bytes de um .zip.
|
| 161 |
+
Sempre tira um backup de segurança do estado atual antes de sobrescrever nada."""
|
| 162 |
+
try:
|
| 163 |
+
zf = zipfile.ZipFile(io.BytesIO(conteudo_zip))
|
| 164 |
+
except zipfile.BadZipFile:
|
| 165 |
+
raise HTTPException(status_code=400, detail="O arquivo enviado não é um .zip válido.")
|
| 166 |
+
|
| 167 |
+
with zf:
|
| 168 |
+
_validar_zip(zf)
|
| 169 |
+
nomes = set(zf.namelist())
|
| 170 |
+
|
| 171 |
+
backup_seguranca = _salvar_backup_automatico("restauracao")
|
| 172 |
+
|
| 173 |
+
resumo = {"planilha": False, "config_site": False, "midias": 0, "paginas": 0, "backup_seguranca": backup_seguranca}
|
| 174 |
+
|
| 175 |
+
if "data/imoveis.xlsx" in nomes:
|
| 176 |
+
_extrair_seguro(zf, "data/imoveis.xlsx", EXCEL_PATH)
|
| 177 |
+
resumo["planilha"] = True
|
| 178 |
+
|
| 179 |
+
if "data/config_site.json" in nomes:
|
| 180 |
+
_extrair_seguro(zf, "data/config_site.json", site_config.CONFIG_PATH)
|
| 181 |
+
resumo["config_site"] = True
|
| 182 |
+
|
| 183 |
+
nomes_midia = [n for n in nomes if n.startswith("data/midia/") and not n.endswith("/")]
|
| 184 |
+
if nomes_midia:
|
| 185 |
+
# Restauração completa: a pasta de mídias atual é substituída pelo
|
| 186 |
+
# conteúdo do backup, para o site ficar exatamente como estava
|
| 187 |
+
# no momento em que o backup foi gerado.
|
| 188 |
+
if os.path.isdir(MIDIA_DIR):
|
| 189 |
+
shutil.rmtree(MIDIA_DIR)
|
| 190 |
+
os.makedirs(MIDIA_DIR, exist_ok=True)
|
| 191 |
+
for nome_no_zip in nomes_midia:
|
| 192 |
+
caminho_relativo = os.path.relpath(nome_no_zip, "data")
|
| 193 |
+
destino = os.path.normpath(os.path.join(DATA_DIR, caminho_relativo))
|
| 194 |
+
if not destino.startswith(os.path.normpath(MIDIA_DIR)):
|
| 195 |
+
continue # trava de segurança contra path traversal
|
| 196 |
+
_extrair_seguro(zf, nome_no_zip, destino)
|
| 197 |
+
resumo["midias"] += 1
|
| 198 |
+
|
| 199 |
+
arquivos_paginas_validos = {arquivo for arquivo, _n, _d in paginas_html.PAGINAS_EDITAVEIS.values()}
|
| 200 |
+
for nome_no_zip in nomes:
|
| 201 |
+
if not nome_no_zip.startswith("paginas/"):
|
| 202 |
+
continue
|
| 203 |
+
nome_arquivo = os.path.basename(nome_no_zip)
|
| 204 |
+
if nome_arquivo not in arquivos_paginas_validos:
|
| 205 |
+
continue # ignora qualquer coisa que não seja uma página institucional conhecida
|
| 206 |
+
destino = os.path.join(paginas_html.TEMPLATES_DIR, nome_arquivo)
|
| 207 |
+
_extrair_seguro(zf, nome_no_zip, destino)
|
| 208 |
+
resumo["paginas"] += 1
|
| 209 |
+
|
| 210 |
+
return resumo
|
app/main.py
CHANGED
|
@@ -16,21 +16,24 @@ Executar com:
|
|
| 16 |
Documentação interativa da API em:
|
| 17 |
http://127.0.0.1:8000/api/docs
|
| 18 |
"""
|
|
|
|
| 19 |
import json
|
| 20 |
import os
|
| 21 |
import re
|
| 22 |
from typing import Optional
|
| 23 |
from urllib.parse import quote, urlencode
|
| 24 |
|
| 25 |
-
from fastapi import FastAPI, Request
|
| 26 |
from fastapi.middleware.cors import CORSMiddleware
|
| 27 |
from fastapi.staticfiles import StaticFiles
|
| 28 |
from fastapi.templating import Jinja2Templates
|
| 29 |
-
from fastapi.responses import
|
|
|
|
| 30 |
|
| 31 |
-
from app import excel_service, seo
|
| 32 |
from app.config import DATA_DIR
|
| 33 |
from app.routers import anuncios
|
|
|
|
| 34 |
|
| 35 |
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 36 |
STATIC_DIR = os.path.join(BASE_DIR, "static")
|
|
@@ -62,10 +65,22 @@ app.add_middleware(
|
|
| 62 |
allow_headers=["*"],
|
| 63 |
)
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
app.include_router(anuncios.router)
|
| 66 |
|
| 67 |
templates = Jinja2Templates(directory=TEMPLATES_DIR) if os.path.isdir(TEMPLATES_DIR) else None
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
|
| 71 |
@app.on_event("startup")
|
|
@@ -73,6 +88,12 @@ def startup_event():
|
|
| 73 |
"""Cria a planilha (com a carteira de demonstração) e a pasta de mídias, se ainda não existirem."""
|
| 74 |
excel_service.init_excel(with_demo_data=True)
|
| 75 |
os.makedirs(MIDIA_DIR, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
|
| 78 |
@app.get("/api/status", tags=["Status"], summary="Verifica se a API está no ar")
|
|
@@ -83,20 +104,142 @@ def status():
|
|
| 83 |
}
|
| 84 |
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
# --------------------------------------------------------------------------
|
| 87 |
# Página do anúncio — /imovel/{id}/{slug-com-palavras-chave}
|
| 88 |
# --------------------------------------------------------------------------
|
| 89 |
|
| 90 |
-
def _pagina_nao_encontrada():
|
| 91 |
-
return
|
| 92 |
|
| 93 |
|
| 94 |
@app.get("/imovel/{anuncio_id}", include_in_schema=False)
|
| 95 |
-
def imovel_sem_slug(anuncio_id: int):
|
| 96 |
"""Sem slug na URL -> redireciona (301) para a URL canônica com palavra-chave."""
|
| 97 |
a = excel_service.buscar_anuncio(anuncio_id)
|
| 98 |
if not a:
|
| 99 |
-
return _pagina_nao_encontrada()
|
| 100 |
return RedirectResponse(f"/imovel/{anuncio_id}/{seo.slug_anuncio(a)}", status_code=301)
|
| 101 |
|
| 102 |
|
|
@@ -104,7 +247,7 @@ def imovel_sem_slug(anuncio_id: int):
|
|
| 104 |
def imovel_pagina(request: Request, anuncio_id: int, slug: str):
|
| 105 |
a = excel_service.buscar_anuncio(anuncio_id)
|
| 106 |
if not a:
|
| 107 |
-
return _pagina_nao_encontrada()
|
| 108 |
|
| 109 |
slug_canonico = seo.slug_anuncio(a)
|
| 110 |
if slug != slug_canonico:
|
|
@@ -165,7 +308,7 @@ def imovel_pagina(request: Request, anuncio_id: int, slug: str):
|
|
| 165 |
"anuncio_json": json.dumps(a, ensure_ascii=False, default=str),
|
| 166 |
"preco_exibicao": seo.preco_exibicao(a),
|
| 167 |
"status_class": seo.status_classe(a["status"]),
|
| 168 |
-
"whatsapp_link":
|
| 169 |
"tel_link": re.sub(r"\D", "", a.get("telefone_contato") or ""),
|
| 170 |
"iniciais_corretor": iniciais,
|
| 171 |
"cidade_slug": seo.cidade_slug(a["cidade"]),
|
|
@@ -250,7 +393,7 @@ def busca_geral(request: Request):
|
|
| 250 |
def busca_por_cidade(request: Request, cidade_slug: str):
|
| 251 |
cidade = seo.CIDADES_SLUG_PARA_NOME.get(cidade_slug)
|
| 252 |
if not cidade:
|
| 253 |
-
return _pagina_nao_encontrada()
|
| 254 |
return _renderizar_busca(request, cidade=cidade)
|
| 255 |
|
| 256 |
|
|
@@ -263,7 +406,7 @@ def comprar(request: Request):
|
|
| 263 |
def comprar_cidade(request: Request, cidade_slug: str):
|
| 264 |
cidade = seo.CIDADES_SLUG_PARA_NOME.get(cidade_slug)
|
| 265 |
if not cidade:
|
| 266 |
-
return _pagina_nao_encontrada()
|
| 267 |
return _renderizar_busca(request, cidade=cidade, tipo_negocio="Venda")
|
| 268 |
|
| 269 |
|
|
@@ -276,7 +419,7 @@ def alugar(request: Request):
|
|
| 276 |
def alugar_cidade(request: Request, cidade_slug: str):
|
| 277 |
cidade = seo.CIDADES_SLUG_PARA_NOME.get(cidade_slug)
|
| 278 |
if not cidade:
|
| 279 |
-
return _pagina_nao_encontrada()
|
| 280 |
return _renderizar_busca(request, cidade=cidade, tipo_negocio="Locação")
|
| 281 |
|
| 282 |
|
|
@@ -292,6 +435,67 @@ def busca_html_legado(request: Request):
|
|
| 292 |
return RedirectResponse(destino, status_code=301)
|
| 293 |
|
| 294 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
# --------------------------------------------------------------------------
|
| 296 |
# sitemap.xml e robots.txt
|
| 297 |
# --------------------------------------------------------------------------
|
|
@@ -324,6 +528,7 @@ def robots(request: Request):
|
|
| 324 |
"User-agent: *\n"
|
| 325 |
"Allow: /\n"
|
| 326 |
"Disallow: /admin.html\n"
|
|
|
|
| 327 |
"Disallow: /api/\n"
|
| 328 |
f"Sitemap: {base_url}/sitemap.xml\n"
|
| 329 |
)
|
|
@@ -341,13 +546,28 @@ if os.path.isdir(STATIC_DIR):
|
|
| 341 |
app.mount("/js", StaticFiles(directory=os.path.join(STATIC_DIR, "js")), name="js")
|
| 342 |
app.mount("/img", StaticFiles(directory=os.path.join(STATIC_DIR, "img")), name="img")
|
| 343 |
|
| 344 |
-
@app.get("/", include_in_schema=False)
|
| 345 |
-
def home():
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 351 |
if os.path.exists(caminho):
|
| 352 |
-
return
|
| 353 |
-
return
|
|
|
|
| 16 |
Documentação interativa da API em:
|
| 17 |
http://127.0.0.1:8000/api/docs
|
| 18 |
"""
|
| 19 |
+
import io
|
| 20 |
import json
|
| 21 |
import os
|
| 22 |
import re
|
| 23 |
from typing import Optional
|
| 24 |
from urllib.parse import quote, urlencode
|
| 25 |
|
| 26 |
+
from fastapi import Body, Depends, FastAPI, File, HTTPException, Request, UploadFile
|
| 27 |
from fastapi.middleware.cors import CORSMiddleware
|
| 28 |
from fastapi.staticfiles import StaticFiles
|
| 29 |
from fastapi.templating import Jinja2Templates
|
| 30 |
+
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse, Response, StreamingResponse
|
| 31 |
+
from starlette.middleware.sessions import SessionMiddleware
|
| 32 |
|
| 33 |
+
from app import auth, backup_service, excel_service, seo, site_config
|
| 34 |
from app.config import DATA_DIR
|
| 35 |
from app.routers import anuncios
|
| 36 |
+
from app import paginas_html
|
| 37 |
|
| 38 |
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 39 |
STATIC_DIR = os.path.join(BASE_DIR, "static")
|
|
|
|
| 65 |
allow_headers=["*"],
|
| 66 |
)
|
| 67 |
|
| 68 |
+
# Sessão assinada em cookie, usada pelo login simples do painel admin (ver app/auth.py).
|
| 69 |
+
app.add_middleware(
|
| 70 |
+
SessionMiddleware,
|
| 71 |
+
secret_key=auth.SECRET_KEY,
|
| 72 |
+
session_cookie=auth.SESSION_COOKIE,
|
| 73 |
+
same_site="lax",
|
| 74 |
+
max_age=60 * 60 * 8, # 8h
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
app.include_router(anuncios.router)
|
| 78 |
|
| 79 |
templates = Jinja2Templates(directory=TEMPLATES_DIR) if os.path.isdir(TEMPLATES_DIR) else None
|
| 80 |
+
if templates is not None:
|
| 81 |
+
# Disponibiliza site_config() em qualquer template (inclusive nos includes
|
| 82 |
+
# _header.html/_footer.html), sem precisar passar no contexto de cada rota.
|
| 83 |
+
templates.env.globals["site_config"] = site_config.get_config
|
| 84 |
|
| 85 |
|
| 86 |
@app.on_event("startup")
|
|
|
|
| 88 |
"""Cria a planilha (com a carteira de demonstração) e a pasta de mídias, se ainda não existirem."""
|
| 89 |
excel_service.init_excel(with_demo_data=True)
|
| 90 |
os.makedirs(MIDIA_DIR, exist_ok=True)
|
| 91 |
+
if auth.USANDO_SENHA_PADRAO:
|
| 92 |
+
print(
|
| 93 |
+
"[aviso] ADMIN_PASSWORD não definida — usando senha de demonstração "
|
| 94 |
+
f"'{auth.ADMIN_PASSWORD}' para /admin.html. Defina ADMIN_PASSWORD (e SECRET_KEY) "
|
| 95 |
+
"em produção."
|
| 96 |
+
)
|
| 97 |
|
| 98 |
|
| 99 |
@app.get("/api/status", tags=["Status"], summary="Verifica se a API está no ar")
|
|
|
|
| 104 |
}
|
| 105 |
|
| 106 |
|
| 107 |
+
# --------------------------------------------------------------------------
|
| 108 |
+
# Configurações do site — dados de contato usados em todo o site (header,
|
| 109 |
+
# footer, página de contato, links de WhatsApp) e editáveis pelo painel admin.
|
| 110 |
+
# --------------------------------------------------------------------------
|
| 111 |
+
|
| 112 |
+
@app.get("/api/config", tags=["Configurações"], summary="Configurações públicas do site (contato, WhatsApp, etc.)")
|
| 113 |
+
def obter_config_site():
|
| 114 |
+
return site_config.get_config()
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
@app.put(
|
| 118 |
+
"/api/config",
|
| 119 |
+
tags=["Configurações"],
|
| 120 |
+
summary="Atualiza as configurações do site (somente admin)",
|
| 121 |
+
dependencies=[Depends(auth.exigir_admin_api)],
|
| 122 |
+
)
|
| 123 |
+
def atualizar_config_site(dados: dict = Body(...)):
|
| 124 |
+
return site_config.salvar_config(dados)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
# --------------------------------------------------------------------------
|
| 128 |
+
# Editor de páginas HTML — permite editar o HTML das páginas institucionais
|
| 129 |
+
# (início, sobre, serviços, contato, 404, cabeçalho e rodapé) pelo painel admin,
|
| 130 |
+
# sem precisar mexer no código. Ver app/paginas_html.py para a lista de páginas
|
| 131 |
+
# liberadas (as páginas com lógica dinâmica pesada — busca, imóvel, admin — não
|
| 132 |
+
# entram, para não correr o risco de quebrar o site).
|
| 133 |
+
# --------------------------------------------------------------------------
|
| 134 |
+
|
| 135 |
+
@app.get(
|
| 136 |
+
"/api/paginas",
|
| 137 |
+
tags=["Editor de páginas"],
|
| 138 |
+
summary="Lista as páginas HTML que podem ser editadas pelo painel",
|
| 139 |
+
dependencies=[Depends(auth.exigir_admin_api)],
|
| 140 |
+
)
|
| 141 |
+
def listar_paginas_editaveis():
|
| 142 |
+
return paginas_html.listar_paginas()
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
@app.get(
|
| 146 |
+
"/api/paginas/{pagina_id}",
|
| 147 |
+
tags=["Editor de páginas"],
|
| 148 |
+
summary="Retorna o HTML atual de uma página editável",
|
| 149 |
+
dependencies=[Depends(auth.exigir_admin_api)],
|
| 150 |
+
)
|
| 151 |
+
def obter_pagina_html(pagina_id: str):
|
| 152 |
+
return {"id": pagina_id, "conteudo": paginas_html.ler_pagina(pagina_id)}
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
@app.put(
|
| 156 |
+
"/api/paginas/{pagina_id}",
|
| 157 |
+
tags=["Editor de páginas"],
|
| 158 |
+
summary="Salva o HTML de uma página editável (somente admin)",
|
| 159 |
+
dependencies=[Depends(auth.exigir_admin_api)],
|
| 160 |
+
)
|
| 161 |
+
def salvar_pagina_html(pagina_id: str, dados: dict = Body(...)):
|
| 162 |
+
paginas_html.salvar_pagina(pagina_id, dados.get("conteudo", ""), jinja_env=templates.env)
|
| 163 |
+
return {"ok": True}
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
# --------------------------------------------------------------------------
|
| 167 |
+
# Backup e restauração — cópia completa de tudo que o painel admin controla:
|
| 168 |
+
# a planilha de anúncios, as configurações do site, as mídias enviadas para
|
| 169 |
+
# os anúncios e o HTML das páginas institucionais editáveis. Ver
|
| 170 |
+
# app/backup_service.py para os detalhes de como o .zip é montado/lido.
|
| 171 |
+
# --------------------------------------------------------------------------
|
| 172 |
+
|
| 173 |
+
@app.get(
|
| 174 |
+
"/api/backup/exportar",
|
| 175 |
+
tags=["Backup"],
|
| 176 |
+
summary="Baixa um backup .zip com todos os dados do site (planilha, configurações, mídias e páginas)",
|
| 177 |
+
dependencies=[Depends(auth.exigir_admin_api)],
|
| 178 |
+
)
|
| 179 |
+
def exportar_backup():
|
| 180 |
+
conteudo, nome_arquivo, _resumo = backup_service.gerar_backup_zip(motivo="download manual")
|
| 181 |
+
return StreamingResponse(
|
| 182 |
+
io.BytesIO(conteudo),
|
| 183 |
+
media_type="application/zip",
|
| 184 |
+
headers={"Content-Disposition": f'attachment; filename="{nome_arquivo}"'},
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
@app.get(
|
| 189 |
+
"/api/backup/automaticos",
|
| 190 |
+
tags=["Backup"],
|
| 191 |
+
summary="Lista os backups de segurança tirados automaticamente antes de cada restauração",
|
| 192 |
+
dependencies=[Depends(auth.exigir_admin_api)],
|
| 193 |
+
)
|
| 194 |
+
def listar_backups_automaticos():
|
| 195 |
+
return backup_service.listar_backups_automaticos()
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
@app.get(
|
| 199 |
+
"/api/backup/automaticos/{nome}",
|
| 200 |
+
tags=["Backup"],
|
| 201 |
+
summary="Baixa um backup automático específico",
|
| 202 |
+
dependencies=[Depends(auth.exigir_admin_api)],
|
| 203 |
+
)
|
| 204 |
+
def baixar_backup_automatico(nome: str):
|
| 205 |
+
caminho = backup_service.caminho_backup_automatico(nome)
|
| 206 |
+
return StreamingResponse(
|
| 207 |
+
open(caminho, "rb"),
|
| 208 |
+
media_type="application/zip",
|
| 209 |
+
headers={"Content-Disposition": f'attachment; filename="{os.path.basename(caminho)}"'},
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
@app.post(
|
| 214 |
+
"/api/backup/restaurar",
|
| 215 |
+
tags=["Backup"],
|
| 216 |
+
summary="Restaura o site a partir de um backup .zip (sobrescreve os dados atuais)",
|
| 217 |
+
dependencies=[Depends(auth.exigir_admin_api)],
|
| 218 |
+
)
|
| 219 |
+
async def restaurar_backup(arquivo: UploadFile = File(...)):
|
| 220 |
+
if not (arquivo.filename or "").lower().endswith(".zip"):
|
| 221 |
+
raise HTTPException(status_code=400, detail="Envie um arquivo .zip de backup.")
|
| 222 |
+
conteudo = await arquivo.read()
|
| 223 |
+
if not conteudo:
|
| 224 |
+
raise HTTPException(status_code=400, detail="Arquivo vazio.")
|
| 225 |
+
resumo = backup_service.restaurar_backup_zip(conteudo)
|
| 226 |
+
return {"ok": True, "resumo": resumo}
|
| 227 |
+
|
| 228 |
+
|
| 229 |
# --------------------------------------------------------------------------
|
| 230 |
# Página do anúncio — /imovel/{id}/{slug-com-palavras-chave}
|
| 231 |
# --------------------------------------------------------------------------
|
| 232 |
|
| 233 |
+
def _pagina_nao_encontrada(request: Request):
|
| 234 |
+
return templates.TemplateResponse("404.html", {"request": request}, status_code=404)
|
| 235 |
|
| 236 |
|
| 237 |
@app.get("/imovel/{anuncio_id}", include_in_schema=False)
|
| 238 |
+
def imovel_sem_slug(request: Request, anuncio_id: int):
|
| 239 |
"""Sem slug na URL -> redireciona (301) para a URL canônica com palavra-chave."""
|
| 240 |
a = excel_service.buscar_anuncio(anuncio_id)
|
| 241 |
if not a:
|
| 242 |
+
return _pagina_nao_encontrada(request)
|
| 243 |
return RedirectResponse(f"/imovel/{anuncio_id}/{seo.slug_anuncio(a)}", status_code=301)
|
| 244 |
|
| 245 |
|
|
|
|
| 247 |
def imovel_pagina(request: Request, anuncio_id: int, slug: str):
|
| 248 |
a = excel_service.buscar_anuncio(anuncio_id)
|
| 249 |
if not a:
|
| 250 |
+
return _pagina_nao_encontrada(request)
|
| 251 |
|
| 252 |
slug_canonico = seo.slug_anuncio(a)
|
| 253 |
if slug != slug_canonico:
|
|
|
|
| 308 |
"anuncio_json": json.dumps(a, ensure_ascii=False, default=str),
|
| 309 |
"preco_exibicao": seo.preco_exibicao(a),
|
| 310 |
"status_class": seo.status_classe(a["status"]),
|
| 311 |
+
"whatsapp_link": site_config.get_whatsapp_link(mensagem_whats),
|
| 312 |
"tel_link": re.sub(r"\D", "", a.get("telefone_contato") or ""),
|
| 313 |
"iniciais_corretor": iniciais,
|
| 314 |
"cidade_slug": seo.cidade_slug(a["cidade"]),
|
|
|
|
| 393 |
def busca_por_cidade(request: Request, cidade_slug: str):
|
| 394 |
cidade = seo.CIDADES_SLUG_PARA_NOME.get(cidade_slug)
|
| 395 |
if not cidade:
|
| 396 |
+
return _pagina_nao_encontrada(request)
|
| 397 |
return _renderizar_busca(request, cidade=cidade)
|
| 398 |
|
| 399 |
|
|
|
|
| 406 |
def comprar_cidade(request: Request, cidade_slug: str):
|
| 407 |
cidade = seo.CIDADES_SLUG_PARA_NOME.get(cidade_slug)
|
| 408 |
if not cidade:
|
| 409 |
+
return _pagina_nao_encontrada(request)
|
| 410 |
return _renderizar_busca(request, cidade=cidade, tipo_negocio="Venda")
|
| 411 |
|
| 412 |
|
|
|
|
| 419 |
def alugar_cidade(request: Request, cidade_slug: str):
|
| 420 |
cidade = seo.CIDADES_SLUG_PARA_NOME.get(cidade_slug)
|
| 421 |
if not cidade:
|
| 422 |
+
return _pagina_nao_encontrada(request)
|
| 423 |
return _renderizar_busca(request, cidade=cidade, tipo_negocio="Locação")
|
| 424 |
|
| 425 |
|
|
|
|
| 435 |
return RedirectResponse(destino, status_code=301)
|
| 436 |
|
| 437 |
|
| 438 |
+
# --------------------------------------------------------------------------
|
| 439 |
+
# Painel admin — /admin.html (protegido) e login simples em /admin/login
|
| 440 |
+
# --------------------------------------------------------------------------
|
| 441 |
+
|
| 442 |
+
@app.get("/admin/login", response_class=HTMLResponse, include_in_schema=False)
|
| 443 |
+
def admin_login_form(request: Request):
|
| 444 |
+
if auth.esta_autenticado(request):
|
| 445 |
+
return RedirectResponse("/admin.html", status_code=302)
|
| 446 |
+
return templates.TemplateResponse(
|
| 447 |
+
"admin_login.html",
|
| 448 |
+
{
|
| 449 |
+
"request": request,
|
| 450 |
+
"erro": request.query_params.get("erro") == "1",
|
| 451 |
+
"expirado": request.query_params.get("expirado") == "1",
|
| 452 |
+
},
|
| 453 |
+
)
|
| 454 |
+
|
| 455 |
+
|
| 456 |
+
@app.post("/admin/login", include_in_schema=False)
|
| 457 |
+
async def admin_login_submit(request: Request):
|
| 458 |
+
form = await request.form()
|
| 459 |
+
if auth.senha_correta(form.get("senha", "")):
|
| 460 |
+
auth.autenticar(request)
|
| 461 |
+
return RedirectResponse("/admin.html", status_code=302)
|
| 462 |
+
return RedirectResponse("/admin/login?erro=1", status_code=302)
|
| 463 |
+
|
| 464 |
+
|
| 465 |
+
@app.get("/admin/logout", include_in_schema=False)
|
| 466 |
+
def admin_logout(request: Request):
|
| 467 |
+
auth.deslogar(request)
|
| 468 |
+
return RedirectResponse("/admin/login", status_code=302)
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
@app.get("/admin.html", response_class=HTMLResponse, include_in_schema=False)
|
| 472 |
+
def admin_painel(request: Request):
|
| 473 |
+
"""Rota explícita (tem prioridade sobre o catch-all /{page_name}.html abaixo) —
|
| 474 |
+
é aqui que a autenticação do painel é exigida."""
|
| 475 |
+
if not auth.esta_autenticado(request):
|
| 476 |
+
return RedirectResponse("/admin/login", status_code=302)
|
| 477 |
+
return templates.TemplateResponse("admin.html", {"request": request})
|
| 478 |
+
|
| 479 |
+
|
| 480 |
+
@app.get("/admin", include_in_schema=False)
|
| 481 |
+
@app.get("/admin/", include_in_schema=False)
|
| 482 |
+
def admin_atalho():
|
| 483 |
+
"""Atalho de conveniência: /admin e /admin/ -> /admin.html (que já cuida do login)."""
|
| 484 |
+
return RedirectResponse("/admin.html", status_code=302)
|
| 485 |
+
|
| 486 |
+
|
| 487 |
+
@app.get("/admin/midias/{anuncio_id}", response_class=HTMLResponse, include_in_schema=False)
|
| 488 |
+
def admin_midias_pagina(request: Request, anuncio_id: int):
|
| 489 |
+
"""Página própria (não é pop-up) só para enviar/remover as fotos, vídeos
|
| 490 |
+
e áudios de UM anúncio específico, sem carregar o resto do painel admin."""
|
| 491 |
+
if not auth.esta_autenticado(request):
|
| 492 |
+
return RedirectResponse("/admin/login", status_code=302)
|
| 493 |
+
a = excel_service.buscar_anuncio(anuncio_id)
|
| 494 |
+
if not a:
|
| 495 |
+
return _pagina_nao_encontrada(request)
|
| 496 |
+
return templates.TemplateResponse("admin_midias.html", {"request": request, "a": a})
|
| 497 |
+
|
| 498 |
+
|
| 499 |
# --------------------------------------------------------------------------
|
| 500 |
# sitemap.xml e robots.txt
|
| 501 |
# --------------------------------------------------------------------------
|
|
|
|
| 528 |
"User-agent: *\n"
|
| 529 |
"Allow: /\n"
|
| 530 |
"Disallow: /admin.html\n"
|
| 531 |
+
"Disallow: /admin/\n"
|
| 532 |
"Disallow: /api/\n"
|
| 533 |
f"Sitemap: {base_url}/sitemap.xml\n"
|
| 534 |
)
|
|
|
|
| 546 |
app.mount("/js", StaticFiles(directory=os.path.join(STATIC_DIR, "js")), name="js")
|
| 547 |
app.mount("/img", StaticFiles(directory=os.path.join(STATIC_DIR, "img")), name="img")
|
| 548 |
|
| 549 |
+
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
|
| 550 |
+
def home(request: Request):
|
| 551 |
+
# Destaques renderizados no servidor (ver app/seo.py:render_showcase_ssr):
|
| 552 |
+
# antes eram desenhados só pelo JS depois do carregamento, então um
|
| 553 |
+
# crawler que não executa JS via a página inicial "vazia". Agora o
|
| 554 |
+
# HTML já chega com o carrossel pronto; home.js só liga a interação.
|
| 555 |
+
base_url = str(request.base_url).rstrip("/")
|
| 556 |
+
destaques = excel_service.listar_destaques(8)
|
| 557 |
+
return templates.TemplateResponse(
|
| 558 |
+
"index.html",
|
| 559 |
+
{
|
| 560 |
+
"request": request,
|
| 561 |
+
"showcase_html": seo.render_showcase_ssr(destaques, base_url),
|
| 562 |
+
"destaques_json": json.dumps(destaques, ensure_ascii=False, default=str),
|
| 563 |
+
},
|
| 564 |
+
)
|
| 565 |
+
|
| 566 |
+
# Páginas institucionais (sobre, servicos, contato, admin, ...) — todas
|
| 567 |
+
# são templates Jinja2 que reaproveitam o mesmo _header.html/_footer.html.
|
| 568 |
+
@app.get("/{page_name}.html", response_class=HTMLResponse, include_in_schema=False)
|
| 569 |
+
def paginas(request: Request, page_name: str):
|
| 570 |
+
caminho = os.path.join(TEMPLATES_DIR, f"{page_name}.html")
|
| 571 |
if os.path.exists(caminho):
|
| 572 |
+
return templates.TemplateResponse(f"{page_name}.html", {"request": request})
|
| 573 |
+
return _pagina_nao_encontrada(request)
|
app/paginas_html.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Edição das páginas HTML (institucionais) direto pelo painel admin.
|
| 3 |
+
|
| 4 |
+
Só as páginas "seguras" de editar entram na lista abaixo — ou seja, as que
|
| 5 |
+
não dependem de variáveis dinâmicas complexas passadas pelo backend (busca,
|
| 6 |
+
imóvel, admin*) para funcionar. Editar essas por aqui, sem cuidado com as
|
| 7 |
+
tags {% %} e {{ }}, quebraria a busca/anúncios/login para todo mundo.
|
| 8 |
+
|
| 9 |
+
Cada página tem um identificador curto (usado na API e no seletor do painel)
|
| 10 |
+
mapeado para o arquivo real em /templates.
|
| 11 |
+
"""
|
| 12 |
+
import os
|
| 13 |
+
|
| 14 |
+
from fastapi import HTTPException
|
| 15 |
+
|
| 16 |
+
from app.config import BASE_DIR
|
| 17 |
+
|
| 18 |
+
TEMPLATES_DIR = os.path.join(BASE_DIR, "templates")
|
| 19 |
+
|
| 20 |
+
# id -> (arquivo em /templates, nome amigável, descrição curta)
|
| 21 |
+
PAGINAS_EDITAVEIS = {
|
| 22 |
+
"inicio": ("index.html", "Página inicial", "A home do site (/)"),
|
| 23 |
+
"sobre": ("sobre.html", "Sobre", "/sobre.html"),
|
| 24 |
+
"servicos": ("servicos.html", "Serviços", "/servicos.html"),
|
| 25 |
+
"contato": ("contato.html", "Contato", "/contato.html"),
|
| 26 |
+
"404": ("404.html", "Página não encontrada (404)", "Exibida quando a URL não existe"),
|
| 27 |
+
"header": ("_header.html", "Cabeçalho", "Aparece no topo de todas as páginas do site"),
|
| 28 |
+
"footer": ("_footer.html", "Rodapé", "Aparece no rodapé de todas as páginas do site"),
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def listar_paginas() -> list:
|
| 33 |
+
return [
|
| 34 |
+
{"id": pid, "nome": nome, "descricao": desc}
|
| 35 |
+
for pid, (_, nome, desc) in PAGINAS_EDITAVEIS.items()
|
| 36 |
+
]
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _caminho(pagina_id: str) -> str:
|
| 40 |
+
entrada = PAGINAS_EDITAVEIS.get(pagina_id)
|
| 41 |
+
if not entrada:
|
| 42 |
+
raise HTTPException(status_code=404, detail="Página não encontrada ou não editável por aqui.")
|
| 43 |
+
return os.path.join(TEMPLATES_DIR, entrada[0])
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def ler_pagina(pagina_id: str) -> str:
|
| 47 |
+
caminho = _caminho(pagina_id)
|
| 48 |
+
if not os.path.exists(caminho):
|
| 49 |
+
raise HTTPException(status_code=404, detail="Arquivo da página não existe no servidor.")
|
| 50 |
+
with open(caminho, "r", encoding="utf-8") as f:
|
| 51 |
+
return f.read()
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def salvar_pagina(pagina_id: str, conteudo: str, jinja_env=None) -> None:
|
| 55 |
+
caminho = _caminho(pagina_id)
|
| 56 |
+
|
| 57 |
+
if not conteudo or not conteudo.strip():
|
| 58 |
+
raise HTTPException(status_code=400, detail="O conteúdo da página não pode ficar vazio.")
|
| 59 |
+
|
| 60 |
+
# Valida a sintaxe Jinja (tags {% %} / {{ }}) antes de gravar, para não
|
| 61 |
+
# derrubar a página pro público com um erro de digitação no template.
|
| 62 |
+
if jinja_env is not None:
|
| 63 |
+
try:
|
| 64 |
+
jinja_env.parse(conteudo)
|
| 65 |
+
except Exception as e:
|
| 66 |
+
raise HTTPException(status_code=400, detail=f"HTML/Jinja inválido — nada foi salvo: {e}")
|
| 67 |
+
|
| 68 |
+
# Guarda uma cópia da versão anterior (um nível de backup) antes de sobrescrever.
|
| 69 |
+
if os.path.exists(caminho):
|
| 70 |
+
try:
|
| 71 |
+
with open(caminho, "r", encoding="utf-8") as f:
|
| 72 |
+
anterior = f.read()
|
| 73 |
+
with open(caminho + ".bak", "w", encoding="utf-8") as f:
|
| 74 |
+
f.write(anterior)
|
| 75 |
+
except OSError:
|
| 76 |
+
pass # backup é best-effort, não deve impedir o salvamento
|
| 77 |
+
|
| 78 |
+
with open(caminho, "w", encoding="utf-8") as f:
|
| 79 |
+
f.write(conteudo)
|
app/routers/__pycache__/__init__.cpython-313.pyc
CHANGED
|
Binary files a/app/routers/__pycache__/__init__.cpython-313.pyc and b/app/routers/__pycache__/__init__.cpython-313.pyc differ
|
|
|
app/routers/__pycache__/anuncios.cpython-313.pyc
CHANGED
|
Binary files a/app/routers/__pycache__/anuncios.cpython-313.pyc and b/app/routers/__pycache__/anuncios.cpython-313.pyc differ
|
|
|
app/routers/anuncios.py
CHANGED
|
@@ -7,10 +7,11 @@ import time
|
|
| 7 |
import uuid
|
| 8 |
from typing import Optional
|
| 9 |
|
| 10 |
-
from fastapi import APIRouter, File, HTTPException, Query, UploadFile
|
| 11 |
from fastapi.responses import FileResponse
|
| 12 |
|
| 13 |
from app import excel_service
|
|
|
|
| 14 |
from app.config import DATA_DIR, EXCEL_PATH
|
| 15 |
from app.schemas import (
|
| 16 |
AnuncioCreate,
|
|
@@ -161,7 +162,11 @@ def estatisticas():
|
|
| 161 |
return excel_service.calcular_estatisticas()
|
| 162 |
|
| 163 |
|
| 164 |
-
@router.get(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
def exportar_planilha():
|
| 166 |
return FileResponse(
|
| 167 |
EXCEL_PATH,
|
|
@@ -189,14 +194,25 @@ def similares(anuncio_id: int, limite: int = Query(4, ge=1, le=20)):
|
|
| 189 |
return excel_service.anuncios_similares(anuncio_id, limite)
|
| 190 |
|
| 191 |
|
| 192 |
-
@router.post(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
def criar(anuncio: AnuncioCreate):
|
| 194 |
dados = anuncio.model_dump(mode="json")
|
| 195 |
criado = excel_service.criar_anuncio(dados)
|
| 196 |
return criado
|
| 197 |
|
| 198 |
|
| 199 |
-
@router.put(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
def atualizar_completo(anuncio_id: int, anuncio: AnuncioCreate):
|
| 201 |
dados = anuncio.model_dump(mode="json")
|
| 202 |
atualizado = excel_service.atualizar_anuncio(anuncio_id, dados)
|
|
@@ -205,7 +221,12 @@ def atualizar_completo(anuncio_id: int, anuncio: AnuncioCreate):
|
|
| 205 |
return atualizado
|
| 206 |
|
| 207 |
|
| 208 |
-
@router.patch(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
def atualizar_parcial(anuncio_id: int, anuncio: AnuncioUpdate):
|
| 210 |
dados = anuncio.model_dump(mode="json", exclude_unset=True)
|
| 211 |
atualizado = excel_service.atualizar_anuncio(anuncio_id, dados)
|
|
@@ -214,7 +235,12 @@ def atualizar_parcial(anuncio_id: int, anuncio: AnuncioUpdate):
|
|
| 214 |
return atualizado
|
| 215 |
|
| 216 |
|
| 217 |
-
@router.delete(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
def deletar(anuncio_id: int):
|
| 219 |
sucesso = excel_service.deletar_anuncio(anuncio_id)
|
| 220 |
if not sucesso:
|
|
@@ -225,6 +251,7 @@ def deletar(anuncio_id: int):
|
|
| 225 |
"/{anuncio_id}/midias/upload",
|
| 226 |
response_model=AnuncioResponse,
|
| 227 |
summary="Envia uma ou mais fotos/vídeos/áudios para o anúncio",
|
|
|
|
| 228 |
)
|
| 229 |
def upload_midias(anuncio_id: int, arquivos: list[UploadFile] = File(...)):
|
| 230 |
"""
|
|
@@ -270,6 +297,7 @@ def upload_midias(anuncio_id: int, arquivos: list[UploadFile] = File(...)):
|
|
| 270 |
"/{anuncio_id}/midias/url",
|
| 271 |
response_model=AnuncioResponse,
|
| 272 |
summary="Adiciona uma mídia (foto/vídeo/áudio) por URL, sem precisar fazer upload do arquivo",
|
|
|
|
| 273 |
)
|
| 274 |
def adicionar_midia_por_url(anuncio_id: int, url: str = Query(..., description="URL completa da mídia (imagem, vídeo ou áudio)")):
|
| 275 |
"""
|
|
@@ -290,6 +318,7 @@ def adicionar_midia_por_url(anuncio_id: int, url: str = Query(..., description="
|
|
| 290 |
"/{anuncio_id}/midias",
|
| 291 |
response_model=AnuncioResponse,
|
| 292 |
summary="Remove uma mídia específica (foto/vídeo) do anúncio",
|
|
|
|
| 293 |
)
|
| 294 |
def remover_midia_endpoint(anuncio_id: int, url: str = Query(..., description="URL exata da mídia a remover")):
|
| 295 |
"""
|
|
|
|
| 7 |
import uuid
|
| 8 |
from typing import Optional
|
| 9 |
|
| 10 |
+
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
| 11 |
from fastapi.responses import FileResponse
|
| 12 |
|
| 13 |
from app import excel_service
|
| 14 |
+
from app.auth import exigir_admin_api
|
| 15 |
from app.config import DATA_DIR, EXCEL_PATH
|
| 16 |
from app.schemas import (
|
| 17 |
AnuncioCreate,
|
|
|
|
| 162 |
return excel_service.calcular_estatisticas()
|
| 163 |
|
| 164 |
|
| 165 |
+
@router.get(
|
| 166 |
+
"/export/planilha",
|
| 167 |
+
summary="Baixa a planilha Excel atual (o banco de dados)",
|
| 168 |
+
dependencies=[Depends(exigir_admin_api)],
|
| 169 |
+
)
|
| 170 |
def exportar_planilha():
|
| 171 |
return FileResponse(
|
| 172 |
EXCEL_PATH,
|
|
|
|
| 194 |
return excel_service.anuncios_similares(anuncio_id, limite)
|
| 195 |
|
| 196 |
|
| 197 |
+
@router.post(
|
| 198 |
+
"/",
|
| 199 |
+
response_model=AnuncioResponse,
|
| 200 |
+
status_code=201,
|
| 201 |
+
summary="Cria um novo anúncio",
|
| 202 |
+
dependencies=[Depends(exigir_admin_api)],
|
| 203 |
+
)
|
| 204 |
def criar(anuncio: AnuncioCreate):
|
| 205 |
dados = anuncio.model_dump(mode="json")
|
| 206 |
criado = excel_service.criar_anuncio(dados)
|
| 207 |
return criado
|
| 208 |
|
| 209 |
|
| 210 |
+
@router.put(
|
| 211 |
+
"/{anuncio_id}",
|
| 212 |
+
response_model=AnuncioResponse,
|
| 213 |
+
summary="Substitui todos os dados de um anúncio",
|
| 214 |
+
dependencies=[Depends(exigir_admin_api)],
|
| 215 |
+
)
|
| 216 |
def atualizar_completo(anuncio_id: int, anuncio: AnuncioCreate):
|
| 217 |
dados = anuncio.model_dump(mode="json")
|
| 218 |
atualizado = excel_service.atualizar_anuncio(anuncio_id, dados)
|
|
|
|
| 221 |
return atualizado
|
| 222 |
|
| 223 |
|
| 224 |
+
@router.patch(
|
| 225 |
+
"/{anuncio_id}",
|
| 226 |
+
response_model=AnuncioResponse,
|
| 227 |
+
summary="Atualiza parcialmente um anúncio",
|
| 228 |
+
dependencies=[Depends(exigir_admin_api)],
|
| 229 |
+
)
|
| 230 |
def atualizar_parcial(anuncio_id: int, anuncio: AnuncioUpdate):
|
| 231 |
dados = anuncio.model_dump(mode="json", exclude_unset=True)
|
| 232 |
atualizado = excel_service.atualizar_anuncio(anuncio_id, dados)
|
|
|
|
| 235 |
return atualizado
|
| 236 |
|
| 237 |
|
| 238 |
+
@router.delete(
|
| 239 |
+
"/{anuncio_id}",
|
| 240 |
+
status_code=204,
|
| 241 |
+
summary="Remove um anúncio",
|
| 242 |
+
dependencies=[Depends(exigir_admin_api)],
|
| 243 |
+
)
|
| 244 |
def deletar(anuncio_id: int):
|
| 245 |
sucesso = excel_service.deletar_anuncio(anuncio_id)
|
| 246 |
if not sucesso:
|
|
|
|
| 251 |
"/{anuncio_id}/midias/upload",
|
| 252 |
response_model=AnuncioResponse,
|
| 253 |
summary="Envia uma ou mais fotos/vídeos/áudios para o anúncio",
|
| 254 |
+
dependencies=[Depends(exigir_admin_api)],
|
| 255 |
)
|
| 256 |
def upload_midias(anuncio_id: int, arquivos: list[UploadFile] = File(...)):
|
| 257 |
"""
|
|
|
|
| 297 |
"/{anuncio_id}/midias/url",
|
| 298 |
response_model=AnuncioResponse,
|
| 299 |
summary="Adiciona uma mídia (foto/vídeo/áudio) por URL, sem precisar fazer upload do arquivo",
|
| 300 |
+
dependencies=[Depends(exigir_admin_api)],
|
| 301 |
)
|
| 302 |
def adicionar_midia_por_url(anuncio_id: int, url: str = Query(..., description="URL completa da mídia (imagem, vídeo ou áudio)")):
|
| 303 |
"""
|
|
|
|
| 318 |
"/{anuncio_id}/midias",
|
| 319 |
response_model=AnuncioResponse,
|
| 320 |
summary="Remove uma mídia específica (foto/vídeo) do anúncio",
|
| 321 |
+
dependencies=[Depends(exigir_admin_api)],
|
| 322 |
)
|
| 323 |
def remover_midia_endpoint(anuncio_id: int, url: str = Query(..., description="URL exata da mídia a remover")):
|
| 324 |
"""
|
app/schemas.py
CHANGED
|
@@ -28,6 +28,7 @@ class TipoImovel(str, Enum):
|
|
| 28 |
|
| 29 |
class StatusAnuncio(str, Enum):
|
| 30 |
disponivel = "Disponível"
|
|
|
|
| 31 |
vendido = "Vendido"
|
| 32 |
alugado = "Alugado"
|
| 33 |
reservado = "Reservado"
|
|
|
|
| 28 |
|
| 29 |
class StatusAnuncio(str, Enum):
|
| 30 |
disponivel = "Disponível"
|
| 31 |
+
lancamento = "Lançamento"
|
| 32 |
vendido = "Vendido"
|
| 33 |
alugado = "Alugado"
|
| 34 |
reservado = "Reservado"
|
app/seo.py
CHANGED
|
@@ -65,6 +65,10 @@ def preco_formatado(valor: float) -> str:
|
|
| 65 |
|
| 66 |
|
| 67 |
def preco_exibicao(a: dict) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
valor = preco_formatado(a["preco"])
|
| 69 |
return f"{valor}/mês" if a["tipo_negocio"] == "Locação" else valor
|
| 70 |
|
|
@@ -139,6 +143,7 @@ def render_card_ssr(a: dict, base_url: str = "") -> str:
|
|
| 139 |
|
| 140 |
url = url_anuncio(a, base_url)
|
| 141 |
status_cls = status_classe(a["status"])
|
|
|
|
| 142 |
|
| 143 |
return (
|
| 144 |
f'<a class="card" href="{url}">'
|
|
@@ -147,7 +152,7 @@ def render_card_ssr(a: dict, base_url: str = "") -> str:
|
|
| 147 |
f'</div>'
|
| 148 |
f'<div class="card-body">'
|
| 149 |
f'<span class="status-pill {status_cls}">{escape(a["status"])}</span>'
|
| 150 |
-
f'
|
| 151 |
f'<h3 class="card-title">{escape(a["titulo"])}</h3>'
|
| 152 |
f'<div class="card-loc">📍 {escape(a["bairro"])}, {escape(a["cidade"])}</div>'
|
| 153 |
f'<div class="card-specs"><span>{specs_html}</span></div>'
|
|
@@ -201,6 +206,54 @@ def render_galeria_ssr(midias: list[str], titulo: str) -> str:
|
|
| 201 |
)
|
| 202 |
|
| 203 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
# --------------------------------------------------------------------------
|
| 205 |
# Página do anúncio
|
| 206 |
# --------------------------------------------------------------------------
|
|
@@ -213,7 +266,10 @@ def titulo_anuncio(a: dict) -> str:
|
|
| 213 |
def descricao_anuncio(a: dict) -> str:
|
| 214 |
acao = "à venda" if a["tipo_negocio"] == "Venda" else "para locação"
|
| 215 |
area = a.get("area_util") or a.get("area_total")
|
| 216 |
-
|
|
|
|
|
|
|
|
|
|
| 217 |
if a.get("quartos"):
|
| 218 |
partes.append(f"{a['quartos']} quarto(s)")
|
| 219 |
if area:
|
|
@@ -244,15 +300,18 @@ def jsonld_anuncio(a: dict, url: str, midias: list[str]) -> str:
|
|
| 244 |
"postalCode": a.get("cep") or None,
|
| 245 |
"addressCountry": "BR",
|
| 246 |
},
|
| 247 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
"@type": "Offer",
|
| 249 |
"price": a["preco"],
|
| 250 |
"priceCurrency": "BRL",
|
| 251 |
"availability": disponibilidade,
|
| 252 |
"businessFunction": funcao,
|
| 253 |
"url": url,
|
| 254 |
-
}
|
| 255 |
-
}
|
| 256 |
imagens = [
|
| 257 |
m for m in midias
|
| 258 |
if not re.search(r"\.(mp4|webm|mov|ogg|mp3|wav|m4a|aac|oga|opus|weba)(\?|#|$)", m, re.I)
|
|
|
|
| 65 |
|
| 66 |
|
| 67 |
def preco_exibicao(a: dict) -> str:
|
| 68 |
+
"""Texto do valor pronto para exibição. Retorna "" quando o preço não foi
|
| 69 |
+
informado (0/vazio) — nesses casos o valor não deve aparecer no site."""
|
| 70 |
+
if not a.get("preco"):
|
| 71 |
+
return ""
|
| 72 |
valor = preco_formatado(a["preco"])
|
| 73 |
return f"{valor}/mês" if a["tipo_negocio"] == "Locação" else valor
|
| 74 |
|
|
|
|
| 143 |
|
| 144 |
url = url_anuncio(a, base_url)
|
| 145 |
status_cls = status_classe(a["status"])
|
| 146 |
+
preco_html = f'<div class="card-price">{preco_exibicao(a)}</div>' if a.get("preco") else ""
|
| 147 |
|
| 148 |
return (
|
| 149 |
f'<a class="card" href="{url}">'
|
|
|
|
| 152 |
f'</div>'
|
| 153 |
f'<div class="card-body">'
|
| 154 |
f'<span class="status-pill {status_cls}">{escape(a["status"])}</span>'
|
| 155 |
+
f'{preco_html}'
|
| 156 |
f'<h3 class="card-title">{escape(a["titulo"])}</h3>'
|
| 157 |
f'<div class="card-loc">📍 {escape(a["bairro"])}, {escape(a["cidade"])}</div>'
|
| 158 |
f'<div class="card-specs"><span>{specs_html}</span></div>'
|
|
|
|
| 206 |
)
|
| 207 |
|
| 208 |
|
| 209 |
+
def render_showcase_ssr(destaques: list[dict], base_url: str = "") -> str:
|
| 210 |
+
"""Carrossel de destaques da página inicial, renderizado no servidor —
|
| 211 |
+
mesma marcação (classes/atributos) que static/js/home.js gera em
|
| 212 |
+
montarShowcase(), para que o JS só precise "ligar" a interatividade
|
| 213 |
+
(troca de slide/dots) em cima do HTML que já chegou pronto, sem precisar
|
| 214 |
+
buscar e desenhar os destaques via API antes de aparecerem para o
|
| 215 |
+
usuário (ou para o Google)."""
|
| 216 |
+
from html import escape
|
| 217 |
+
|
| 218 |
+
if not destaques:
|
| 219 |
+
return '<p class="empty-state">Nenhum imóvel em destaque no momento.</p>'
|
| 220 |
+
|
| 221 |
+
def _slide(a: dict) -> str:
|
| 222 |
+
midias_raw = a.get("midias")
|
| 223 |
+
try:
|
| 224 |
+
midias = json.loads(midias_raw) if isinstance(midias_raw, str) else (midias_raw or [])
|
| 225 |
+
except (json.JSONDecodeError, TypeError):
|
| 226 |
+
midias = []
|
| 227 |
+
foto = next((str(m) for m in midias if m), "") if midias else ""
|
| 228 |
+
tag_cls = "tag-venda" if a["tipo_negocio"] == "Venda" else "tag-locacao"
|
| 229 |
+
url = url_anuncio(a, base_url)
|
| 230 |
+
img_html = f'<img src="{escape(foto)}" alt="{escape(a.get("titulo") or "")}" loading="lazy">' if foto else ""
|
| 231 |
+
estilo = "" if foto else ' style="background:var(--midia-placeholder-2)"'
|
| 232 |
+
return (
|
| 233 |
+
f'<a class="showcase-slide" href="{url}"{estilo}>'
|
| 234 |
+
f'{img_html}'
|
| 235 |
+
f'<span class="tag showcase-tag {tag_cls}">{escape(a["tipo_negocio"])}</span>'
|
| 236 |
+
f'<div class="showcase-info"><h2>{escape(a.get("titulo") or "")}</h2></div>'
|
| 237 |
+
f'</a>'
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
dots_html = ""
|
| 241 |
+
arrows_html = ""
|
| 242 |
+
if len(destaques) > 1:
|
| 243 |
+
dots_html = '<div class="showcase-dots">' + "".join(
|
| 244 |
+
f'<button type="button" class="showcase-dot{" active" if i == 0 else ""}" data-idx="{i}" '
|
| 245 |
+
f'aria-label="Ir para o imóvel {i + 1}"></button>'
|
| 246 |
+
for i in range(len(destaques))
|
| 247 |
+
) + "</div>"
|
| 248 |
+
arrows_html = (
|
| 249 |
+
'<button type="button" class="showcase-arrow prev" aria-label="Imóvel anterior">‹</button>'
|
| 250 |
+
'<button type="button" class="showcase-arrow next" aria-label="Próximo imóvel">›</button>'
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
slides_html = "".join(_slide(a) for a in destaques)
|
| 254 |
+
return f'<div class="showcase-track">{slides_html}</div>{dots_html}{arrows_html}'
|
| 255 |
+
|
| 256 |
+
|
| 257 |
# --------------------------------------------------------------------------
|
| 258 |
# Página do anúncio
|
| 259 |
# --------------------------------------------------------------------------
|
|
|
|
| 266 |
def descricao_anuncio(a: dict) -> str:
|
| 267 |
acao = "à venda" if a["tipo_negocio"] == "Venda" else "para locação"
|
| 268 |
area = a.get("area_util") or a.get("area_total")
|
| 269 |
+
base = f"{a['tipo_imovel']} {acao} em {a['bairro']}, {a['cidade']} (PR)"
|
| 270 |
+
if a.get("preco"):
|
| 271 |
+
base += f" por {preco_formatado(a['preco'])}"
|
| 272 |
+
partes = [base]
|
| 273 |
if a.get("quartos"):
|
| 274 |
partes.append(f"{a['quartos']} quarto(s)")
|
| 275 |
if area:
|
|
|
|
| 300 |
"postalCode": a.get("cep") or None,
|
| 301 |
"addressCountry": "BR",
|
| 302 |
},
|
| 303 |
+
}
|
| 304 |
+
# Só inclui a oferta de preço quando o valor foi de fato informado (evita
|
| 305 |
+
# declarar "price": 0 no schema.org, o que confundiria buscadores/agregadores).
|
| 306 |
+
if a.get("preco"):
|
| 307 |
+
dados["offers"] = {
|
| 308 |
"@type": "Offer",
|
| 309 |
"price": a["preco"],
|
| 310 |
"priceCurrency": "BRL",
|
| 311 |
"availability": disponibilidade,
|
| 312 |
"businessFunction": funcao,
|
| 313 |
"url": url,
|
| 314 |
+
}
|
|
|
|
| 315 |
imagens = [
|
| 316 |
m for m in midias
|
| 317 |
if not re.search(r"\.(mp4|webm|mov|ogg|mp3|wav|m4a|aac|oga|opus|weba)(\?|#|$)", m, re.I)
|
app/site_config.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Configurações gerais do site (dados de contato, WhatsApp, endereço, CRECI etc.)
|
| 3 |
+
|
| 4 |
+
Antes esses dados ficavam "hardcoded" espalhados pelos templates
|
| 5 |
+
(_header.html, _footer.html, contato.html) e pelo main.py. Agora eles moram
|
| 6 |
+
em um único arquivo JSON (data/config_site.json) editável pelo painel admin
|
| 7 |
+
em /admin.html, aba "Configurações do site".
|
| 8 |
+
"""
|
| 9 |
+
import json
|
| 10 |
+
import os
|
| 11 |
+
import re
|
| 12 |
+
import threading
|
| 13 |
+
|
| 14 |
+
from app.config import DATA_DIR
|
| 15 |
+
|
| 16 |
+
CONFIG_PATH = os.path.join(DATA_DIR, "config_site.json")
|
| 17 |
+
|
| 18 |
+
_lock = threading.Lock()
|
| 19 |
+
|
| 20 |
+
# Valores padrão — os mesmos que já existiam hardcoded no site.
|
| 21 |
+
PADRAO = {
|
| 22 |
+
"nome_empresa": "SK Imobiliária",
|
| 23 |
+
"whatsapp_numero": "5541998112201",
|
| 24 |
+
"whatsapp_texto_padrao": "Olá! Vim pelo site da SK Imobiliária e gostaria de falar com um corretor.",
|
| 25 |
+
"telefone_exibicao": "(41) 99811-2201",
|
| 26 |
+
"email": "contato@skimobiliaria.com.br",
|
| 27 |
+
"endereco": "Av. Beira Mar, 1120 — Caiobá, Matinhos/PR",
|
| 28 |
+
"horario_atendimento": "Seg–sáb, 9h às 18h",
|
| 29 |
+
"creci": "CRECI-PR 00000-J",
|
| 30 |
+
"responsavel": "Salete Kuhnert",
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
# Campos que podem ser editados via API/painel — mesma lista de PADRAO.
|
| 34 |
+
CAMPOS_EDITAVEIS = list(PADRAO.keys())
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _ler_arquivo() -> dict:
|
| 38 |
+
if not os.path.exists(CONFIG_PATH):
|
| 39 |
+
return {}
|
| 40 |
+
try:
|
| 41 |
+
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
| 42 |
+
return json.load(f)
|
| 43 |
+
except (json.JSONDecodeError, OSError):
|
| 44 |
+
return {}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def get_config() -> dict:
|
| 48 |
+
"""Retorna a configuração atual, preenchendo com os padrões quaisquer campos ausentes."""
|
| 49 |
+
salvo = _ler_arquivo()
|
| 50 |
+
config = dict(PADRAO)
|
| 51 |
+
config.update({k: v for k, v in salvo.items() if k in PADRAO})
|
| 52 |
+
return config
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def get_whatsapp_link(mensagem: str = None) -> str:
|
| 56 |
+
"""Monta o link do wa.me com o número atual e, opcionalmente, uma mensagem pré-preenchida."""
|
| 57 |
+
from urllib.parse import quote
|
| 58 |
+
|
| 59 |
+
config = get_config()
|
| 60 |
+
numero = config["whatsapp_numero"]
|
| 61 |
+
texto = mensagem if mensagem is not None else config["whatsapp_texto_padrao"]
|
| 62 |
+
return f"https://wa.me/{numero}?text={quote(texto)}"
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def get_tel_link() -> str:
|
| 66 |
+
config = get_config()
|
| 67 |
+
return re.sub(r"\D", "", config["telefone_exibicao"] or "")
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def salvar_config(dados: dict) -> dict:
|
| 71 |
+
"""Atualiza somente os campos conhecidos e grava no disco (thread-safe)."""
|
| 72 |
+
with _lock:
|
| 73 |
+
atual = get_config()
|
| 74 |
+
for campo in CAMPOS_EDITAVEIS:
|
| 75 |
+
if campo in dados and dados[campo] is not None:
|
| 76 |
+
valor = str(dados[campo]).strip()
|
| 77 |
+
atual[campo] = valor
|
| 78 |
+
os.makedirs(DATA_DIR, exist_ok=True)
|
| 79 |
+
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
|
| 80 |
+
json.dump(atual, f, ensure_ascii=False, indent=2)
|
| 81 |
+
return atual
|
requirements.txt
CHANGED
|
@@ -4,3 +4,4 @@ openpyxl==3.1.5
|
|
| 4 |
pydantic==2.10.3
|
| 5 |
python-multipart==0.0.20
|
| 6 |
jinja2==3.1.4
|
|
|
|
|
|
| 4 |
pydantic==2.10.3
|
| 5 |
python-multipart==0.0.20
|
| 6 |
jinja2==3.1.4
|
| 7 |
+
itsdangerous==2.2.0
|
static/admin.html
DELETED
|
@@ -1,231 +0,0 @@
|
|
| 1 |
-
<!DOCTYPE html>
|
| 2 |
-
<html lang="pt-BR">
|
| 3 |
-
<head>
|
| 4 |
-
<meta charset="UTF-8">
|
| 5 |
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
-
<title>Área do corretor — SK Imobiliária</title>
|
| 7 |
-
<meta name="robots" content="noindex">
|
| 8 |
-
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 9 |
-
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@500;600&display=swap" rel="stylesheet">
|
| 10 |
-
<link rel="icon" type="image/svg+xml" href="/img/logo-sk.svg">
|
| 11 |
-
<link rel="stylesheet" href="/css/style.css?v=6">
|
| 12 |
-
</head>
|
| 13 |
-
<body>
|
| 14 |
-
|
| 15 |
-
<header class="site-header">
|
| 16 |
-
<nav class="nav">
|
| 17 |
-
<a class="brand" href="/">
|
| 18 |
-
<img src="/img/logo-sk.svg" alt="SK Imobiliária" class="brand-mark" height="38">
|
| 19 |
-
</a>
|
| 20 |
-
<button class="nav-toggle" aria-label="Abrir menu" aria-expanded="false"><svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="18" x2="21" y2="18"></line></svg></button>
|
| 21 |
-
<ul class="nav-links">
|
| 22 |
-
<li><a href="/">Início</a></li>
|
| 23 |
-
<li><a href="/busca">Comprar & Alugar</a></li>
|
| 24 |
-
<li><a href="/servicos.html">Serviços</a></li>
|
| 25 |
-
<li><a href="/sobre.html">Sobre</a></li>
|
| 26 |
-
<li><a href="/contato.html">Contato</a></li>
|
| 27 |
-
</ul>
|
| 28 |
-
<a class="nav-cta" href="/api/docs">API / Swagger</a>
|
| 29 |
-
</nav>
|
| 30 |
-
</header>
|
| 31 |
-
|
| 32 |
-
<section class="page-hero" style="padding-bottom:24px">
|
| 33 |
-
<div class="container">
|
| 34 |
-
<span class="eyebrow reveal">Área do corretor · demonstração</span>
|
| 35 |
-
<h1 class="reveal" style="--i:1">Gerenciar anúncios</h1>
|
| 36 |
-
<p class="reveal" style="--i:2">Painel de exemplo para criar, editar e remover anúncios diretamente na planilha que alimenta o site. <strong>Sem autenticação</strong> — em produção, proteja esta rota antes de publicar.</p>
|
| 37 |
-
</div>
|
| 38 |
-
</section>
|
| 39 |
-
|
| 40 |
-
<section class="container">
|
| 41 |
-
<div class="section-head">
|
| 42 |
-
<div>
|
| 43 |
-
<h2 class="mt-0">Novo anúncio</h2>
|
| 44 |
-
<p>Os campos seguem exatamente o que a API espera — nada de retrabalho na planilha.</p>
|
| 45 |
-
</div>
|
| 46 |
-
<a class="btn btn-outline" href="/api/anuncios/export/planilha">Baixar planilha (.xlsx)</a>
|
| 47 |
-
</div>
|
| 48 |
-
|
| 49 |
-
<form class="form-card" id="form-anuncio">
|
| 50 |
-
<input type="hidden" id="a-id">
|
| 51 |
-
<div class="form-grid">
|
| 52 |
-
<div class="form-field">
|
| 53 |
-
<label for="a-tipo-negocio">Tipo de negócio</label>
|
| 54 |
-
<select id="a-tipo-negocio" required>
|
| 55 |
-
<option value="Venda">Venda</option>
|
| 56 |
-
<option value="Locação">Locação</option>
|
| 57 |
-
</select>
|
| 58 |
-
</div>
|
| 59 |
-
<div class="form-field">
|
| 60 |
-
<label for="a-tipo-imovel">Tipo de imóvel</label>
|
| 61 |
-
<select id="a-tipo-imovel" required>
|
| 62 |
-
<option>Casa</option><option>Apartamento</option><option>Terreno</option>
|
| 63 |
-
<option>Comercial</option><option>Sala Comercial</option><option>Galpão</option>
|
| 64 |
-
<option>Chácara/Sítio</option><option>Cobertura</option><option>Kitnet/Studio</option>
|
| 65 |
-
</select>
|
| 66 |
-
</div>
|
| 67 |
-
</div>
|
| 68 |
-
|
| 69 |
-
<div class="form-field">
|
| 70 |
-
<label for="a-titulo">Título</label>
|
| 71 |
-
<input type="text" id="a-titulo" required minlength="3" maxlength="150" placeholder="Ex.: Apartamento 2 quartos vista mar - Caiobá">
|
| 72 |
-
</div>
|
| 73 |
-
<div class="form-field">
|
| 74 |
-
<label for="a-descricao">Descrição</label>
|
| 75 |
-
<textarea id="a-descricao" maxlength="3000"></textarea>
|
| 76 |
-
</div>
|
| 77 |
-
|
| 78 |
-
<div class="form-grid">
|
| 79 |
-
<div class="form-field">
|
| 80 |
-
<label for="a-endereco">Endereço</label>
|
| 81 |
-
<input type="text" id="a-endereco">
|
| 82 |
-
</div>
|
| 83 |
-
<div class="form-field">
|
| 84 |
-
<label for="a-bairro">Bairro</label>
|
| 85 |
-
<input type="text" id="a-bairro">
|
| 86 |
-
</div>
|
| 87 |
-
<div class="form-field">
|
| 88 |
-
<label for="a-cidade">Cidade</label>
|
| 89 |
-
<input type="text" id="a-cidade" required list="cidades-litoral">
|
| 90 |
-
<datalist id="cidades-litoral">
|
| 91 |
-
<option value="Matinhos"><option value="Guaratuba"><option value="Pontal do Paraná">
|
| 92 |
-
</datalist>
|
| 93 |
-
</div>
|
| 94 |
-
<div class="form-field">
|
| 95 |
-
<label for="a-estado">UF</label>
|
| 96 |
-
<input type="text" id="a-estado" required maxlength="2" value="PR">
|
| 97 |
-
</div>
|
| 98 |
-
<div class="form-field">
|
| 99 |
-
<label for="a-cep">CEP</label>
|
| 100 |
-
<input type="text" id="a-cep">
|
| 101 |
-
</div>
|
| 102 |
-
</div>
|
| 103 |
-
|
| 104 |
-
<div class="form-grid">
|
| 105 |
-
<div class="form-field">
|
| 106 |
-
<label for="a-preco">Preço (R$)</label>
|
| 107 |
-
<input type="number" id="a-preco" required min="0" step="0.01">
|
| 108 |
-
</div>
|
| 109 |
-
<div class="form-field">
|
| 110 |
-
<label for="a-condominio">Condomínio (R$)</label>
|
| 111 |
-
<input type="number" id="a-condominio" min="0" step="0.01" value="0">
|
| 112 |
-
</div>
|
| 113 |
-
<div class="form-field">
|
| 114 |
-
<label for="a-iptu">IPTU (R$)</label>
|
| 115 |
-
<input type="number" id="a-iptu" min="0" step="0.01" value="0">
|
| 116 |
-
</div>
|
| 117 |
-
<div class="form-field">
|
| 118 |
-
<label for="a-area-util">Área útil (m²)</label>
|
| 119 |
-
<input type="number" id="a-area-util" min="0" step="0.01" value="0">
|
| 120 |
-
</div>
|
| 121 |
-
<div class="form-field">
|
| 122 |
-
<label for="a-area-total">Área total (m²)</label>
|
| 123 |
-
<input type="number" id="a-area-total" min="0" step="0.01" value="0">
|
| 124 |
-
</div>
|
| 125 |
-
</div>
|
| 126 |
-
|
| 127 |
-
<div class="form-grid">
|
| 128 |
-
<div class="form-field">
|
| 129 |
-
<label for="a-quartos">Quartos</label>
|
| 130 |
-
<input type="number" id="a-quartos" min="0" value="0">
|
| 131 |
-
</div>
|
| 132 |
-
<div class="form-field">
|
| 133 |
-
<label for="a-suites">Suítes</label>
|
| 134 |
-
<input type="number" id="a-suites" min="0" value="0">
|
| 135 |
-
</div>
|
| 136 |
-
<div class="form-field">
|
| 137 |
-
<label for="a-banheiros">Banheiros</label>
|
| 138 |
-
<input type="number" id="a-banheiros" min="0" value="0">
|
| 139 |
-
</div>
|
| 140 |
-
<div class="form-field">
|
| 141 |
-
<label for="a-vagas">Vagas de garagem</label>
|
| 142 |
-
<input type="number" id="a-vagas" min="0" value="0">
|
| 143 |
-
</div>
|
| 144 |
-
</div>
|
| 145 |
-
|
| 146 |
-
<div class="form-grid">
|
| 147 |
-
<div class="form-field">
|
| 148 |
-
<label for="a-status">Status</label>
|
| 149 |
-
<select id="a-status">
|
| 150 |
-
<option>Disponível</option><option>Reservado</option><option>Vendido</option>
|
| 151 |
-
<option>Alugado</option><option>Inativo</option>
|
| 152 |
-
</select>
|
| 153 |
-
</div>
|
| 154 |
-
<div class="form-field">
|
| 155 |
-
<label for="a-corretor">Corretor responsável</label>
|
| 156 |
-
<input type="text" id="a-corretor">
|
| 157 |
-
</div>
|
| 158 |
-
<div class="form-field">
|
| 159 |
-
<label for="a-telefone">Telefone de contato</label>
|
| 160 |
-
<input type="text" id="a-telefone">
|
| 161 |
-
</div>
|
| 162 |
-
<div class="form-field" style="justify-content:center">
|
| 163 |
-
<label><input type="checkbox" id="a-destaque"> Marcar como destaque</label>
|
| 164 |
-
</div>
|
| 165 |
-
</div>
|
| 166 |
-
|
| 167 |
-
<div style="display:flex;gap:12px;margin-top:8px">
|
| 168 |
-
<button class="btn btn-primary" type="submit" id="btn-salvar">Salvar anúncio</button>
|
| 169 |
-
<button class="btn btn-outline" type="button" id="btn-cancelar" style="display:none">Cancelar edição</button>
|
| 170 |
-
</div>
|
| 171 |
-
</form>
|
| 172 |
-
</section>
|
| 173 |
-
|
| 174 |
-
<section class="container">
|
| 175 |
-
<div class="section-head">
|
| 176 |
-
<div>
|
| 177 |
-
<h2 class="mt-0">Mídias do anúncio</h2>
|
| 178 |
-
<p class="muted" id="midia-hint">Salve ou edite um anúncio acima para gerenciar suas fotos, vídeos e áudios.</p>
|
| 179 |
-
</div>
|
| 180 |
-
</div>
|
| 181 |
-
|
| 182 |
-
<div class="form-card" id="midia-card" style="display:none">
|
| 183 |
-
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:18px">
|
| 184 |
-
<input type="file" id="midia-arquivos" accept="image/*,video/*,audio/*" multiple>
|
| 185 |
-
<button class="btn btn-primary" id="btn-upload-midia" type="button">Enviar mídia</button>
|
| 186 |
-
<span class="form-note" id="midia-upload-status" style="margin-top:0"></span>
|
| 187 |
-
</div>
|
| 188 |
-
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:18px;padding-top:14px;border-top:1px dashed rgba(27,75,79,.15)">
|
| 189 |
-
<input type="url" id="midia-url" placeholder="https://.../audio-do-corretor.mp3" style="flex:1;min-width:240px">
|
| 190 |
-
<button class="btn btn-outline" id="btn-add-midia-url" type="button">Adicionar por URL</button>
|
| 191 |
-
</div>
|
| 192 |
-
<p class="muted" style="margin-top:-10px;margin-bottom:18px;font-size:.82rem">
|
| 193 |
-
Envie arquivos (fotos, vídeos ou áudios) ou cole a URL de uma mídia já hospedada em outro lugar. Tudo é salvo como uma lista de URLs em JSON no campo "midias" da planilha.
|
| 194 |
-
</p>
|
| 195 |
-
<div class="midia-grid" id="midia-grid"></div>
|
| 196 |
-
</div>
|
| 197 |
-
</section>
|
| 198 |
-
|
| 199 |
-
<section class="container">
|
| 200 |
-
<div class="section-head">
|
| 201 |
-
<div>
|
| 202 |
-
<h2 class="mt-0">Anúncios cadastrados</h2>
|
| 203 |
-
<p id="admin-total" class="muted">carregando…</p>
|
| 204 |
-
</div>
|
| 205 |
-
</div>
|
| 206 |
-
<div style="overflow-x:auto">
|
| 207 |
-
<table class="table-admin">
|
| 208 |
-
<thead>
|
| 209 |
-
<tr><th>#</th><th>Título</th><th>Negócio</th><th>Cidade</th><th>Preço</th><th>Status</th><th>Ações</th></tr>
|
| 210 |
-
</thead>
|
| 211 |
-
<tbody id="admin-tbody">
|
| 212 |
-
<tr><td colspan="7" class="muted">Carregando anúncios…</td></tr>
|
| 213 |
-
</tbody>
|
| 214 |
-
</table>
|
| 215 |
-
</div>
|
| 216 |
-
</section>
|
| 217 |
-
|
| 218 |
-
<footer class="site-footer">
|
| 219 |
-
<div class="container">
|
| 220 |
-
<div class="footer-bottom">
|
| 221 |
-
<span>© <span data-year></span> SK Imobiliária · Painel de demonstração</span>
|
| 222 |
-
<span><a href="/">Voltar ao site</a></span>
|
| 223 |
-
</div>
|
| 224 |
-
</div>
|
| 225 |
-
</footer>
|
| 226 |
-
|
| 227 |
-
<script src="/js/api.js?v=2"></script>
|
| 228 |
-
<script src="/js/main.js?v=2"></script>
|
| 229 |
-
<script src="/js/admin.js?v=2"></script>
|
| 230 |
-
</body>
|
| 231 |
-
</html>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static/busca.html
DELETED
|
@@ -1,149 +0,0 @@
|
|
| 1 |
-
<!DOCTYPE html>
|
| 2 |
-
<html lang="pt-BR">
|
| 3 |
-
<head>
|
| 4 |
-
<meta charset="UTF-8">
|
| 5 |
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
-
<title>Buscar imóveis — Sahkey Imóveis</title>
|
| 7 |
-
<meta name="description" content="Busque casas, apartamentos e terrenos à venda e locação no litoral do Paraná com filtros por cidade, bairro, preço e mais.">
|
| 8 |
-
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 9 |
-
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@500;600&display=swap" rel="stylesheet">
|
| 10 |
-
<link rel="stylesheet" href="/css/style.css?v=6">
|
| 11 |
-
</head>
|
| 12 |
-
<body>
|
| 13 |
-
|
| 14 |
-
<header class="site-header">
|
| 15 |
-
<nav class="nav">
|
| 16 |
-
<a class="brand" href="index.html">Sahkey <small>Imóveis</small></a>
|
| 17 |
-
<button class="nav-toggle" aria-label="Abrir menu" aria-expanded="false"><svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="18" x2="21" y2="18"></line></svg></button>
|
| 18 |
-
<ul class="nav-links">
|
| 19 |
-
<li><a href="index.html">Início</a></li>
|
| 20 |
-
<li><a href="busca.html">Comprar & Alugar</a></li>
|
| 21 |
-
<li><a href="servicos.html">Serviços</a></li>
|
| 22 |
-
<li><a href="sobre.html">Sobre</a></li>
|
| 23 |
-
<li><a href="contato.html">Contato</a></li>
|
| 24 |
-
</ul>
|
| 25 |
-
<a class="nav-cta nav-whats" href="https://wa.me/5541998112201?text=Ol%C3%A1%21%20Vim%20pelo%20site%20da%20SK%20Imobili%C3%A1ria%20e%20gostaria%20de%20falar%20com%20um%20corretor." target="_blank" rel="noopener" aria-label="Falar no WhatsApp: (41) 99811-2201"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M17.47 14.38c-.3-.15-1.76-.87-2.03-.97-.27-.1-.47-.15-.67.15-.2.3-.77.97-.94 1.16-.17.2-.35.22-.64.08-.3-.15-1.26-.46-2.39-1.48-.88-.79-1.48-1.76-1.65-2.06-.17-.3-.02-.46.13-.61.13-.13.3-.35.45-.52.15-.17.2-.3.3-.5.1-.2.05-.37-.02-.52-.08-.15-.67-1.61-.92-2.21-.24-.58-.49-.5-.67-.51-.17-.01-.37-.01-.57-.01-.2 0-.52.07-.79.37-.27.3-1.04 1.02-1.04 2.48s1.07 2.87 1.21 3.07c.15.2 2.1 3.2 5.08 4.49.71.31 1.26.49 1.69.62.71.23 1.36.2 1.87.12.57-.09 1.76-.72 2.01-1.41.25-.7.25-1.29.17-1.41-.07-.13-.27-.2-.57-.35z"/><path d="M12.03 2C6.51 2 2 6.48 2 12c0 1.82.48 3.58 1.4 5.12L2 22l5.03-1.32A9.96 9.96 0 0 0 12.03 22C17.55 22 22 17.52 22 12S17.55 2 12.03 2zm0 18.09c-1.66 0-3.28-.44-4.7-1.28l-.34-.2-2.98.78.8-2.91-.22-.3A8.08 8.08 0 0 1 3.93 12c0-4.46 3.64-8.09 8.1-8.09 4.46 0 8.09 3.63 8.09 8.09 0 4.46-3.63 8.09-8.09 8.09z"/></svg><span>(41) 99811-2201</span></a>
|
| 26 |
-
</nav>
|
| 27 |
-
</header>
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
<section class="container" style="padding-top:36px">
|
| 31 |
-
<div class="search-layout">
|
| 32 |
-
|
| 33 |
-
<aside class="filters-panel" id="filters-panel">
|
| 34 |
-
<h3>Filtros</h3>
|
| 35 |
-
<span class="filters-count" id="filters-total">carregando opções…</span>
|
| 36 |
-
|
| 37 |
-
<div class="filter-group">
|
| 38 |
-
<h4>Negócio</h4>
|
| 39 |
-
<ul class="facet-list" id="facet-negocio"></ul>
|
| 40 |
-
</div>
|
| 41 |
-
|
| 42 |
-
<div class="filter-group">
|
| 43 |
-
<h4>Cidade</h4>
|
| 44 |
-
<ul class="facet-list" id="facet-cidade"></ul>
|
| 45 |
-
</div>
|
| 46 |
-
|
| 47 |
-
<div class="filter-group">
|
| 48 |
-
<h4>Bairro</h4>
|
| 49 |
-
<ul class="facet-list" id="facet-bairro"></ul>
|
| 50 |
-
</div>
|
| 51 |
-
|
| 52 |
-
<div class="filter-group">
|
| 53 |
-
<h4>Tipo de imóvel</h4>
|
| 54 |
-
<ul class="facet-list" id="facet-tipo"></ul>
|
| 55 |
-
</div>
|
| 56 |
-
|
| 57 |
-
<div class="filter-group">
|
| 58 |
-
<h4>Faixa de preço (R$)</h4>
|
| 59 |
-
<div class="range-row">
|
| 60 |
-
<input type="number" id="f-preco-min" placeholder="mín.">
|
| 61 |
-
<span class="muted">—</span>
|
| 62 |
-
<input type="number" id="f-preco-max" placeholder="máx.">
|
| 63 |
-
</div>
|
| 64 |
-
</div>
|
| 65 |
-
|
| 66 |
-
<div class="filter-group">
|
| 67 |
-
<h4>Quartos (mínimo)</h4>
|
| 68 |
-
<ul class="facet-list" id="facet-quartos"></ul>
|
| 69 |
-
</div>
|
| 70 |
-
|
| 71 |
-
<div class="filter-group">
|
| 72 |
-
<h4>Status</h4>
|
| 73 |
-
<ul class="facet-list" id="facet-status"></ul>
|
| 74 |
-
</div>
|
| 75 |
-
|
| 76 |
-
<label style="display:flex;align-items:center;gap:8px;font-size:.86rem;margin-bottom:16px">
|
| 77 |
-
<input type="checkbox" id="f-destaque"> Somente destaques
|
| 78 |
-
</label>
|
| 79 |
-
|
| 80 |
-
<button class="btn btn-outline btn-block" id="btn-limpar">Limpar filtros</button>
|
| 81 |
-
</aside>
|
| 82 |
-
|
| 83 |
-
<main>
|
| 84 |
-
<div class="chips" id="chips-ativos"></div>
|
| 85 |
-
<div class="results-toolbar">
|
| 86 |
-
<span class="results-count" id="results-count">Carregando resultados…</span>
|
| 87 |
-
<div class="field" style="min-width:210px">
|
| 88 |
-
<select id="f-ordenar">
|
| 89 |
-
<option value="mais_recentes">Mais recentes</option>
|
| 90 |
-
<option value="preco_asc">Menor preço</option>
|
| 91 |
-
<option value="preco_desc">Maior preço</option>
|
| 92 |
-
<option value="area_asc">Menor área</option>
|
| 93 |
-
<option value="area_desc">Maior área</option>
|
| 94 |
-
</select>
|
| 95 |
-
</div>
|
| 96 |
-
</div>
|
| 97 |
-
|
| 98 |
-
<div class="grid-cards" id="results-grid"></div>
|
| 99 |
-
<div class="pagination" id="pagination"></div>
|
| 100 |
-
</main>
|
| 101 |
-
</div>
|
| 102 |
-
</section>
|
| 103 |
-
|
| 104 |
-
<footer class="site-footer">
|
| 105 |
-
<div class="container">
|
| 106 |
-
<div class="footer-grid">
|
| 107 |
-
<div>
|
| 108 |
-
<div class="footer-brand">Sahkey Imóveis</div>
|
| 109 |
-
<p class="muted" style="font-size:.86rem">Venda, locação e gestão de negócios imobiliários no litoral do Paraná.</p>
|
| 110 |
-
</div>
|
| 111 |
-
<div>
|
| 112 |
-
<h4>Navegação</h4>
|
| 113 |
-
<ul>
|
| 114 |
-
<li><a href="busca.html">Comprar & Alugar</a></li>
|
| 115 |
-
<li><a href="servicos.html">Serviços</a></li>
|
| 116 |
-
<li><a href="sobre.html">Sobre a Sahkey</a></li>
|
| 117 |
-
<li><a href="contato.html">Contato</a></li>
|
| 118 |
-
</ul>
|
| 119 |
-
</div>
|
| 120 |
-
<div>
|
| 121 |
-
<h4>Cidades</h4>
|
| 122 |
-
<ul>
|
| 123 |
-
<li><a href="busca.html?cidade=Matinhos">Matinhos</a></li>
|
| 124 |
-
<li><a href="busca.html?cidade=Guaratuba">Guaratuba</a></li>
|
| 125 |
-
<li><a href="busca.html?cidade=Pontal do Paraná">Pontal do Paraná</a></li>
|
| 126 |
-
</ul>
|
| 127 |
-
</div>
|
| 128 |
-
<div>
|
| 129 |
-
<h4>Contato</h4>
|
| 130 |
-
<ul>
|
| 131 |
-
<li>Av. Beira Mar, 1120 — Caiobá, Matinhos/PR</li>
|
| 132 |
-
<li>(41) 99811-2201</li>
|
| 133 |
-
<li>contato@sahkeyimoveis.com.br</li>
|
| 134 |
-
<li><a href="admin.html">Área do corretor →</a></li>
|
| 135 |
-
</ul>
|
| 136 |
-
</div>
|
| 137 |
-
</div>
|
| 138 |
-
<div class="footer-bottom">
|
| 139 |
-
<span>© <span data-year></span> Sahkey Imóveis · CRECI-PR 00000-J (demo)</span>
|
| 140 |
-
<span>Site demonstrativo — dados fictícios</span>
|
| 141 |
-
</div>
|
| 142 |
-
</div>
|
| 143 |
-
</footer>
|
| 144 |
-
|
| 145 |
-
<script src="/js/api.js"></script>
|
| 146 |
-
<script src="/js/main.js"></script>
|
| 147 |
-
<script src="/js/busca.js"></script>
|
| 148 |
-
</body>
|
| 149 |
-
</html>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static/css/style.css
CHANGED
|
@@ -151,10 +151,11 @@ body { padding-top: var(--header-total-h); }
|
|
| 151 |
position: fixed;
|
| 152 |
top: 0; left: 0; right: 0;
|
| 153 |
z-index: 50;
|
| 154 |
-
|
| 155 |
-
background: rgba(
|
| 156 |
-
backdrop-filter: blur(
|
| 157 |
border-bottom: 1px solid var(--linha-chumbo);
|
|
|
|
| 158 |
}
|
| 159 |
.nav {
|
| 160 |
display: grid;
|
|
@@ -271,20 +272,40 @@ body { padding-top: var(--header-total-h); }
|
|
| 271 |
.nav { padding: var(--nav-pad-y) 16px; gap: 10px; }
|
| 272 |
}
|
| 273 |
|
| 274 |
-
/* -------------------- Busca rápida (campo único, compacto, encaixado no topo) --------------------
|
| 275 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
.quick-search {
|
| 277 |
display: flex;
|
| 278 |
align-items: center;
|
| 279 |
gap: 8px;
|
| 280 |
-
max-width:
|
| 281 |
-
margin: 0;
|
| 282 |
-
background: var(--chumbo);
|
| 283 |
-
border:
|
| 284 |
-
border-
|
| 285 |
-
|
| 286 |
-
|
|
|
|
|
|
|
| 287 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
}
|
| 289 |
.quick-search-icon { flex-shrink: 0; color: var(--texto-fraco); }
|
| 290 |
.quick-search input {
|
|
@@ -295,24 +316,30 @@ body { padding-top: var(--header-total-h); }
|
|
| 295 |
font-family: var(--f-body);
|
| 296 |
font-size: 0.92rem;
|
| 297 |
color: var(--texto);
|
| 298 |
-
padding:
|
| 299 |
}
|
| 300 |
.quick-search input::placeholder { color: var(--texto-fraco); }
|
| 301 |
.quick-search input:focus { outline: none; }
|
| 302 |
-
.qs-submit
|
|
|
|
| 303 |
flex-shrink: 0;
|
| 304 |
-
width:
|
| 305 |
border-radius: 50%; border: none;
|
| 306 |
-
background: linear-gradient(135deg, var(--dourado), var(--dourado-3));
|
| 307 |
-
color: #fff;
|
| 308 |
display: flex; align-items: center; justify-content: center;
|
| 309 |
cursor: pointer;
|
|
|
|
| 310 |
transition: filter .15s, transform .15s;
|
| 311 |
}
|
| 312 |
-
.qs-submit:hover
|
|
|
|
|
|
|
|
|
|
| 313 |
|
| 314 |
@media (max-width: 640px) {
|
| 315 |
-
.quick-search { }
|
|
|
|
| 316 |
}
|
| 317 |
.field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
| 318 |
.field label {
|
|
@@ -464,6 +491,7 @@ section { padding: 68px 0; }
|
|
| 464 |
padding: 3px 9px; border-radius: 999px; font-weight: 700; display: inline-block;
|
| 465 |
}
|
| 466 |
.status-disponivel { background: rgba(117,127,76,.16); color: #4E5733; }
|
|
|
|
| 467 |
.status-reservado, .status-inativo { background: rgba(181,80,44,.14); color: #954323; }
|
| 468 |
.status-vendido, .status-alugado { background: rgba(58,51,36,.08); color: var(--texto-muted); }
|
| 469 |
|
|
@@ -621,12 +649,12 @@ section { padding: 68px 0; }
|
|
| 621 |
|
| 622 |
/* -------------------- Boxes de cidade com foto -------------------- */
|
| 623 |
.cidades-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 22px; }
|
| 624 |
-
@media (max-width: 860px) { .cidades-grid { grid-template-columns: 1fr; } .cidade-box { aspect-ratio:
|
| 625 |
.cidade-box {
|
| 626 |
position: relative;
|
| 627 |
border-radius: var(--radius);
|
| 628 |
overflow: hidden;
|
| 629 |
-
aspect-ratio:
|
| 630 |
display: flex; align-items: flex-end;
|
| 631 |
box-shadow: var(--shadow-sm);
|
| 632 |
border: 1px solid var(--linha-chumbo);
|
|
@@ -799,7 +827,32 @@ section { padding: 68px 0; }
|
|
| 799 |
.service-card li { margin-bottom: 6px; }
|
| 800 |
|
| 801 |
/* -------------------- Footer -------------------- */
|
| 802 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 803 |
.footer-grid { display: grid; grid-template-columns: 1.4fr 1fr 1fr 1fr; gap: 32px; padding-bottom: 34px; border-bottom: 1px solid var(--linha-chumbo); }
|
| 804 |
@media (max-width: 760px) { .footer-grid { grid-template-columns: 1fr 1fr; } }
|
| 805 |
.footer-grid h4 { color: var(--dourado-2); font-family: var(--f-mono); font-size: .74rem; letter-spacing: .08em; text-transform: uppercase; margin-bottom: 14px; }
|
|
@@ -985,4 +1038,113 @@ body.filtros-abertos { overflow: hidden; }
|
|
| 985 |
border-bottom: 1px solid var(--linha-chumbo);
|
| 986 |
}
|
| 987 |
.page-hero h1 { color: var(--marfim); max-width: 700px; }
|
| 988 |
-
.page-hero p { max-width: 600px; color: var(--texto-muted); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
position: fixed;
|
| 152 |
top: 0; left: 0; right: 0;
|
| 153 |
z-index: 50;
|
| 154 |
+
|
| 155 |
+
background: linear-gradient(180deg, rgba(255,255,255,.96), rgba(250,247,240,.9));
|
| 156 |
+
backdrop-filter: blur(12px) saturate(1.15);
|
| 157 |
border-bottom: 1px solid var(--linha-chumbo);
|
| 158 |
+
box-shadow: 0 1px 0 rgba(255,255,255,.7) inset, 0 14px 34px -20px rgba(58,51,36,.32);
|
| 159 |
}
|
| 160 |
.nav {
|
| 161 |
display: grid;
|
|
|
|
| 272 |
.nav { padding: var(--nav-pad-y) 16px; gap: 10px; }
|
| 273 |
}
|
| 274 |
|
| 275 |
+
/* -------------------- Busca rápida (campo único, compacto, encaixado no topo) --------------------
|
| 276 |
+
Pílula "flutuante": textura em gradiente + sombra elevada (para se destacar
|
| 277 |
+
do header acima) + sombra interna sutil (para parecer levemente recuada,
|
| 278 |
+
como um campo de verdade) — em vez da barra reta e chapada de antes. */
|
| 279 |
+
.quick-search-section {
|
| 280 |
+
padding: 14px 18px 20px;
|
| 281 |
+
border: 1px solid var(--linha-chumbo);
|
| 282 |
+
border-bottom: 1px solid var(--linha-chumbo);
|
| 283 |
+
background:
|
| 284 |
+
radial-gradient(600px 220px at 50% 0%, rgba(179,143,75,.05), transparent 70%);
|
| 285 |
+
}
|
| 286 |
.quick-search {
|
| 287 |
display: flex;
|
| 288 |
align-items: center;
|
| 289 |
gap: 8px;
|
| 290 |
+
max-width: 640px;
|
| 291 |
+
margin: 0 auto;
|
| 292 |
+
background: linear-gradient(170deg, var(--chumbo), var(--chumbo-2) 90%);
|
| 293 |
+
border: 1px solid var(--linha-chumbo);
|
| 294 |
+
border-radius: 999px;
|
| 295 |
+
box-shadow:
|
| 296 |
+
0 16px 34px -18px rgba(58, 51, 36, 0.32),
|
| 297 |
+
0 1px 0 rgba(255,255,255,.75) inset,
|
| 298 |
+
0 -1px 3px rgba(58,51,36,.05) inset;
|
| 299 |
|
| 300 |
+
transition: box-shadow .2s ease, border-color .2s ease, transform .2s ease;
|
| 301 |
+
}
|
| 302 |
+
.quick-search:focus-within {
|
| 303 |
+
border-color: rgba(179,143,75,.45);
|
| 304 |
+
box-shadow:
|
| 305 |
+
0 18px 38px -16px rgba(58, 51, 36, 0.36),
|
| 306 |
+
0 0 0 3px rgba(179,143,75,.14),
|
| 307 |
+
0 1px 0 rgba(255,255,255,.8) inset;
|
| 308 |
+
transform: translateY(-1px);
|
| 309 |
}
|
| 310 |
.quick-search-icon { flex-shrink: 0; color: var(--texto-fraco); }
|
| 311 |
.quick-search input {
|
|
|
|
| 316 |
font-family: var(--f-body);
|
| 317 |
font-size: 0.92rem;
|
| 318 |
color: var(--texto);
|
| 319 |
+
padding: 8px 6px 8px 6px;
|
| 320 |
}
|
| 321 |
.quick-search input::placeholder { color: var(--texto-fraco); }
|
| 322 |
.quick-search input:focus { outline: none; }
|
| 323 |
+
.qs-submit,
|
| 324 |
+
button.quick-search-icon {
|
| 325 |
flex-shrink: 0;
|
| 326 |
+
width: 38px; height: 38px;
|
| 327 |
border-radius: 50%; border: none;
|
| 328 |
+
background: linear-gradient(135deg, var(--dourado), var(--dourado-3)) !important;
|
| 329 |
+
color: #fff !important;
|
| 330 |
display: flex; align-items: center; justify-content: center;
|
| 331 |
cursor: pointer;
|
| 332 |
+
box-shadow: 0 10px 20px -10px rgba(201,162,75,.6);
|
| 333 |
transition: filter .15s, transform .15s;
|
| 334 |
}
|
| 335 |
+
.qs-submit:hover,
|
| 336 |
+
button.quick-search-icon:hover { filter: brightness(1.07); transform: translateY(-1px); }
|
| 337 |
+
.qs-submit svg,
|
| 338 |
+
button.quick-search-icon svg { color: #fff; }
|
| 339 |
|
| 340 |
@media (max-width: 640px) {
|
| 341 |
+
.quick-search-section { padding: 10px 12px 16px; }
|
| 342 |
+
.quick-search { padding: 4px 5px 4px 16px; }
|
| 343 |
}
|
| 344 |
.field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
| 345 |
.field label {
|
|
|
|
| 491 |
padding: 3px 9px; border-radius: 999px; font-weight: 700; display: inline-block;
|
| 492 |
}
|
| 493 |
.status-disponivel { background: rgba(117,127,76,.16); color: #4E5733; }
|
| 494 |
+
.status-lancamento { background: rgba(58,110,150,.16); color: #2B5A78; }
|
| 495 |
.status-reservado, .status-inativo { background: rgba(181,80,44,.14); color: #954323; }
|
| 496 |
.status-vendido, .status-alugado { background: rgba(58,51,36,.08); color: var(--texto-muted); }
|
| 497 |
|
|
|
|
| 649 |
|
| 650 |
/* -------------------- Boxes de cidade com foto -------------------- */
|
| 651 |
.cidades-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 22px; }
|
| 652 |
+
@media (max-width: 860px) { .cidades-grid { grid-template-columns: 1fr; } .cidade-box { aspect-ratio: 5/10; } }
|
| 653 |
.cidade-box {
|
| 654 |
position: relative;
|
| 655 |
border-radius: var(--radius);
|
| 656 |
overflow: hidden;
|
| 657 |
+
aspect-ratio: 5/3;
|
| 658 |
display: flex; align-items: flex-end;
|
| 659 |
box-shadow: var(--shadow-sm);
|
| 660 |
border: 1px solid var(--linha-chumbo);
|
|
|
|
| 827 |
.service-card li { margin-bottom: 6px; }
|
| 828 |
|
| 829 |
/* -------------------- Footer -------------------- */
|
| 830 |
+
/* -------------------- Footer --------------------
|
| 831 |
+
Textura em camadas (radiais suaves, ecoando a do body) + gradiente vertical
|
| 832 |
+
sutil + sombra "elevada" na borda superior, para o rodapé parecer uma
|
| 833 |
+
plataforma com profundidade em vez de uma faixa de cor chapada. */
|
| 834 |
+
.site-footer {
|
| 835 |
+
position: relative;
|
| 836 |
+
background:
|
| 837 |
+
radial-gradient(900px 420px at 12% 0%, rgba(179,143,75,.06), transparent 60%),
|
| 838 |
+
radial-gradient(700px 380px at 100% -10%, rgba(179,143,75,.05), transparent 55%),
|
| 839 |
+
linear-gradient(180deg, var(--granito-2), var(--chumbo-3));
|
| 840 |
+
color: var(--texto-muted);
|
| 841 |
+
padding: 56px 0 26px;
|
| 842 |
+
margin-top: 0;
|
| 843 |
+
border-top: 1px solid var(--linha-chumbo);
|
| 844 |
+
box-shadow:
|
| 845 |
+
0 1px 0 rgba(255,255,255,.55) inset,
|
| 846 |
+
0 -30px 50px -38px rgba(58, 51, 36, 0.3);
|
| 847 |
+
}
|
| 848 |
+
.site-footer::before {
|
| 849 |
+
content: "";
|
| 850 |
+
position: absolute;
|
| 851 |
+
top: -20px; left: 0; right: 0;
|
| 852 |
+
height: 20px;
|
| 853 |
+
background: linear-gradient(180deg, rgba(58,51,36,.10), rgba(58,51,36,0));
|
| 854 |
+
pointer-events: none;
|
| 855 |
+
}
|
| 856 |
.footer-grid { display: grid; grid-template-columns: 1.4fr 1fr 1fr 1fr; gap: 32px; padding-bottom: 34px; border-bottom: 1px solid var(--linha-chumbo); }
|
| 857 |
@media (max-width: 760px) { .footer-grid { grid-template-columns: 1fr 1fr; } }
|
| 858 |
.footer-grid h4 { color: var(--dourado-2); font-family: var(--f-mono); font-size: .74rem; letter-spacing: .08em; text-transform: uppercase; margin-bottom: 14px; }
|
|
|
|
| 1038 |
border-bottom: 1px solid var(--linha-chumbo);
|
| 1039 |
}
|
| 1040 |
.page-hero h1 { color: var(--marfim); max-width: 700px; }
|
| 1041 |
+
.page-hero p { max-width: 600px; color: var(--texto-muted); }
|
| 1042 |
+
|
| 1043 |
+
/* -------------------- Painel admin: link de sair -------------------- */
|
| 1044 |
+
.admin-logout-link { display: inline-block; margin-top: 14px; font-size: .84rem; font-weight: 600; color: var(--oceano); }
|
| 1045 |
+
.admin-logout-link:hover { color: var(--dourado); }
|
| 1046 |
+
|
| 1047 |
+
/* -------------------- Painel admin: abas -------------------- */
|
| 1048 |
+
.admin-tabs { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 28px; border-bottom: 1px solid var(--linha-chumbo); padding-bottom: 0; }
|
| 1049 |
+
.admin-tab {
|
| 1050 |
+
background: none; border: none; border-bottom: 2px solid transparent; color: var(--texto-muted);
|
| 1051 |
+
font-family: inherit; font-size: .9rem; font-weight: 600; padding: 12px 4px; margin-bottom: -1px; cursor: pointer;
|
| 1052 |
+
}
|
| 1053 |
+
.admin-tab:hover { color: var(--texto); }
|
| 1054 |
+
.admin-tab.is-active { color: var(--oceano); border-bottom-color: var(--dourado); }
|
| 1055 |
+
.admin-tabs-extra { margin-left: auto; margin-bottom: 8px; }
|
| 1056 |
+
.admin-panel { margin-top: 4px; }
|
| 1057 |
+
|
| 1058 |
+
/* -------------------- Painel admin: seleção de destaques -------------------- */
|
| 1059 |
+
.destaques-lista { display: flex; flex-direction: column; gap: 10px; }
|
| 1060 |
+
.destaque-item {
|
| 1061 |
+
display: flex; align-items: center; justify-content: space-between; gap: 16px;
|
| 1062 |
+
background: var(--chumbo-2); border: 1px solid var(--linha-chumbo); border-radius: var(--radius-sm);
|
| 1063 |
+
padding: 14px 16px; cursor: pointer;
|
| 1064 |
+
}
|
| 1065 |
+
.destaque-item:hover { border-color: rgba(201,162,75,.4); }
|
| 1066 |
+
.destaque-item-info { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; min-width: 0; }
|
| 1067 |
+
.destaque-item-titulo { font-weight: 600; color: var(--texto); }
|
| 1068 |
+
.destaque-item-local { font-size: .82rem; }
|
| 1069 |
+
|
| 1070 |
+
.switch { position: relative; display: inline-block; width: 42px; height: 24px; flex-shrink: 0; }
|
| 1071 |
+
.switch input { position: absolute; opacity: 0; width: 100%; height: 100%; margin: 0; cursor: pointer; z-index: 1; }
|
| 1072 |
+
.switch-track { position: absolute; inset: 0; background: var(--chumbo-3); border: 1px solid var(--linha-chumbo); border-radius: 999px; transition: background .15s ease; }
|
| 1073 |
+
.switch-track::before {
|
| 1074 |
+
content: ""; position: absolute; width: 18px; height: 18px; left: 2px; top: 2px; border-radius: 50%;
|
| 1075 |
+
background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,.2); transition: transform .15s ease;
|
| 1076 |
+
}
|
| 1077 |
+
.switch input:checked + .switch-track { background: var(--dourado); border-color: var(--dourado); }
|
| 1078 |
+
.switch input:checked + .switch-track::before { transform: translateX(18px); }
|
| 1079 |
+
.switch input:disabled + .switch-track { opacity: .6; cursor: default; }
|
| 1080 |
+
|
| 1081 |
+
/* -------------------- Tela de login do admin -------------------- */
|
| 1082 |
+
.admin-login-body {
|
| 1083 |
+
min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px;
|
| 1084 |
+
background:
|
| 1085 |
+
radial-gradient(700px 380px at 15% 0%, rgba(179,143,75,.13), transparent 60%),
|
| 1086 |
+
linear-gradient(180deg, var(--granito-2), var(--chumbo));
|
| 1087 |
+
}
|
| 1088 |
+
.admin-login-wrap { width: 100%; max-width: 380px; }
|
| 1089 |
+
.admin-login-card {
|
| 1090 |
+
background: linear-gradient(165deg, var(--chumbo-2), var(--chumbo)); border: 1px solid var(--linha-chumbo);
|
| 1091 |
+
border-radius: var(--radius); padding: 36px 30px; display: flex; flex-direction: column; gap: 6px; box-shadow: var(--shadow);
|
| 1092 |
+
}
|
| 1093 |
+
.admin-login-logo { height: 34px; width: auto; margin-bottom: 14px; }
|
| 1094 |
+
.admin-login-card h1 { margin: 2px 0 4px; }
|
| 1095 |
+
.admin-login-card .muted { margin: 0 0 16px; font-size: .88rem; }
|
| 1096 |
+
.admin-login-card .form-field { margin: 10px 0 18px; }
|
| 1097 |
+
.admin-login-btn { width: 100%; }
|
| 1098 |
+
.admin-login-alerta { background: rgba(181,80,44,.12); color: #954323; border: 1px solid rgba(181,80,44,.3); border-radius: var(--radius-sm); padding: 10px 12px; font-size: .84rem; margin: 4px 0 4px; }
|
| 1099 |
+
.admin-login-voltar { display: block; text-align: center; margin-top: 18px; font-size: .82rem; color: var(--texto-muted); }
|
| 1100 |
+
.admin-login-voltar:hover { color: var(--oceano); }
|
| 1101 |
+
|
| 1102 |
+
/* -------------------- Editor visual de páginas HTML -------------------- */
|
| 1103 |
+
.pg-mode-tabs { display: flex; gap: 8px; margin-bottom: 14px; }
|
| 1104 |
+
.pg-mode-tab {
|
| 1105 |
+
background: var(--chumbo-2); border: 1px solid var(--linha-chumbo); color: var(--texto-muted);
|
| 1106 |
+
border-radius: var(--radius-sm); padding: 7px 14px; font-size: .82rem; font-weight: 600; cursor: pointer;
|
| 1107 |
+
}
|
| 1108 |
+
.pg-mode-tab.is-active { background: var(--oceano); border-color: var(--oceano); color: #fff; }
|
| 1109 |
+
.pg-toolbar {
|
| 1110 |
+
display: flex; flex-wrap: wrap; gap: 4px; padding: 8px; margin-bottom: 0;
|
| 1111 |
+
background: var(--chumbo-2); border: 1px solid var(--linha-chumbo); border-bottom: none;
|
| 1112 |
+
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
| 1113 |
+
}
|
| 1114 |
+
.pg-toolbar button {
|
| 1115 |
+
background: transparent; border: 1px solid transparent; color: var(--texto); border-radius: 6px;
|
| 1116 |
+
min-width: 30px; height: 30px; font-size: .84rem; cursor: pointer; font-family: var(--f-body);
|
| 1117 |
+
}
|
| 1118 |
+
.pg-toolbar button:hover { background: rgba(201,162,75,.15); border-color: rgba(201,162,75,.4); }
|
| 1119 |
+
.pg-toolbar .pg-tb-sep { width: 1px; margin: 4px 4px; background: var(--linha-chumbo); }
|
| 1120 |
+
.pg-visual-editor {
|
| 1121 |
+
width: 100%; min-height: 480px; max-height: 720px; overflow: auto; background: #fff; color: #1a1a1a;
|
| 1122 |
+
border: 1px solid var(--linha-chumbo); border-radius: 0 0 var(--radius-sm) var(--radius-sm);
|
| 1123 |
+
padding: 22px 26px; font-family: var(--f-body); font-size: .96rem; line-height: 1.6;
|
| 1124 |
+
}
|
| 1125 |
+
.pg-visual-editor:focus { outline: 2px solid rgba(201,162,75,.5); outline-offset: -2px; }
|
| 1126 |
+
.pg-visual-editor h1, .pg-visual-editor h2, .pg-visual-editor h3 { font-family: var(--f-display); margin: .6em 0 .3em; }
|
| 1127 |
+
.pg-visual-editor p { margin: 0 0 1em; }
|
| 1128 |
+
.pg-visual-editor ul, .pg-visual-editor ol { padding-left: 22px; margin: 0 0 1em; }
|
| 1129 |
+
.pg-visual-editor a { color: #1b6e73; }
|
| 1130 |
+
.pg-visual-editor img { max-width: 100%; border-radius: 6px; }
|
| 1131 |
+
.cms-locked-inline {
|
| 1132 |
+
display: inline-block; background: rgba(201,162,75,.18); border: 1px dashed #b98a2e; border-radius: 4px;
|
| 1133 |
+
padding: 0 5px; font-size: .8em; cursor: not-allowed; user-select: none;
|
| 1134 |
+
}
|
| 1135 |
+
.cms-locked-block {
|
| 1136 |
+
display: block; background: rgba(201,162,75,.12); border: 1px dashed #b98a2e; border-radius: 6px;
|
| 1137 |
+
padding: 8px 12px; margin: 10px 0; font-size: .82rem; color: #7a5c17; cursor: not-allowed; user-select: none;
|
| 1138 |
+
font-family: var(--f-mono);
|
| 1139 |
+
}
|
| 1140 |
+
/* O HTML das páginas usa classes de animação de entrada (.reveal, .reveal-left, etc.)
|
| 1141 |
+
que ficam com opacity:0 até o JS do site (main.js) marcar como visível ao rolar a
|
| 1142 |
+
página. Esse JS não roda dentro do editor, então sem este override o conteúdo
|
| 1143 |
+
ficaria invisível ali dentro mesmo estando presente no HTML. */
|
| 1144 |
+
.pg-visual-editor .reveal,
|
| 1145 |
+
.pg-visual-editor .reveal-left,
|
| 1146 |
+
.pg-visual-editor .reveal-right,
|
| 1147 |
+
.pg-visual-editor .reveal-zoom,
|
| 1148 |
+
.pg-visual-editor .reveal-pop {
|
| 1149 |
+
opacity: 1 !important; transform: none !important; filter: none !important; transition: none !important;
|
| 1150 |
+
}
|
static/imovel.html
DELETED
|
@@ -1,87 +0,0 @@
|
|
| 1 |
-
<!DOCTYPE html>
|
| 2 |
-
<html lang="pt-BR">
|
| 3 |
-
<head>
|
| 4 |
-
<meta charset="UTF-8">
|
| 5 |
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
-
<title id="page-title">Imóvel — Sahkey Imóveis</title>
|
| 7 |
-
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
-
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@500;600&display=swap" rel="stylesheet">
|
| 9 |
-
<link rel="stylesheet" href="/css/style.css?v=6">
|
| 10 |
-
</head>
|
| 11 |
-
<body>
|
| 12 |
-
|
| 13 |
-
<header class="site-header">
|
| 14 |
-
<nav class="nav">
|
| 15 |
-
<a class="brand" href="index.html">Sahkey <small>Imóveis</small></a>
|
| 16 |
-
<button class="nav-toggle" aria-label="Abrir menu" aria-expanded="false"><svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="18" x2="21" y2="18"></line></svg></button>
|
| 17 |
-
<ul class="nav-links">
|
| 18 |
-
<li><a href="index.html">Início</a></li>
|
| 19 |
-
<li><a href="busca.html">Comprar & Alugar</a></li>
|
| 20 |
-
<li><a href="servicos.html">Serviços</a></li>
|
| 21 |
-
<li><a href="sobre.html">Sobre</a></li>
|
| 22 |
-
<li><a href="contato.html">Contato</a></li>
|
| 23 |
-
</ul>
|
| 24 |
-
<a class="nav-cta nav-whats" href="https://wa.me/5541998112201?text=Ol%C3%A1%21%20Vim%20pelo%20site%20da%20SK%20Imobili%C3%A1ria%20e%20gostaria%20de%20falar%20com%20um%20corretor." target="_blank" rel="noopener" aria-label="Falar no WhatsApp: (41) 99811-2201"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M17.47 14.38c-.3-.15-1.76-.87-2.03-.97-.27-.1-.47-.15-.67.15-.2.3-.77.97-.94 1.16-.17.2-.35.22-.64.08-.3-.15-1.26-.46-2.39-1.48-.88-.79-1.48-1.76-1.65-2.06-.17-.3-.02-.46.13-.61.13-.13.3-.35.45-.52.15-.17.2-.3.3-.5.1-.2.05-.37-.02-.52-.08-.15-.67-1.61-.92-2.21-.24-.58-.49-.5-.67-.51-.17-.01-.37-.01-.57-.01-.2 0-.52.07-.79.37-.27.3-1.04 1.02-1.04 2.48s1.07 2.87 1.21 3.07c.15.2 2.1 3.2 5.08 4.49.71.31 1.26.49 1.69.62.71.23 1.36.2 1.87.12.57-.09 1.76-.72 2.01-1.41.25-.7.25-1.29.17-1.41-.07-.13-.27-.2-.57-.35z"/><path d="M12.03 2C6.51 2 2 6.48 2 12c0 1.82.48 3.58 1.4 5.12L2 22l5.03-1.32A9.96 9.96 0 0 0 12.03 22C17.55 22 22 17.52 22 12S17.55 2 12.03 2zm0 18.09c-1.66 0-3.28-.44-4.7-1.28l-.34-.2-2.98.78.8-2.91-.22-.3A8.08 8.08 0 0 1 3.93 12c0-4.46 3.64-8.09 8.1-8.09 4.46 0 8.09 3.63 8.09 8.09 0 4.46-3.63 8.09-8.09 8.09z"/></svg><span>(41) 99811-2201</span></a>
|
| 25 |
-
</nav>
|
| 26 |
-
</header>
|
| 27 |
-
|
| 28 |
-
<section class="container" style="padding-top:28px" id="imovel-container">
|
| 29 |
-
<p class="loading-state">Carregando imóvel…</p>
|
| 30 |
-
</section>
|
| 31 |
-
|
| 32 |
-
<section class="container" id="similares-section" style="display:none">
|
| 33 |
-
<div class="section-head">
|
| 34 |
-
<div>
|
| 35 |
-
<span class="eyebrow">Você também pode gostar</span>
|
| 36 |
-
<h2>Imóveis semelhantes</h2>
|
| 37 |
-
</div>
|
| 38 |
-
</div>
|
| 39 |
-
<div class="carousel" id="similares-carousel"></div>
|
| 40 |
-
</section>
|
| 41 |
-
|
| 42 |
-
<footer class="site-footer">
|
| 43 |
-
<div class="container">
|
| 44 |
-
<div class="footer-grid">
|
| 45 |
-
<div>
|
| 46 |
-
<div class="footer-brand">Sahkey Imóveis</div>
|
| 47 |
-
<p class="muted" style="font-size:.86rem">Venda, locação e gestão de negócios imobiliários no litoral do Paraná.</p>
|
| 48 |
-
</div>
|
| 49 |
-
<div>
|
| 50 |
-
<h4>Navegação</h4>
|
| 51 |
-
<ul>
|
| 52 |
-
<li><a href="busca.html">Comprar & Alugar</a></li>
|
| 53 |
-
<li><a href="servicos.html">Serviços</a></li>
|
| 54 |
-
<li><a href="sobre.html">Sobre a Sahkey</a></li>
|
| 55 |
-
<li><a href="contato.html">Contato</a></li>
|
| 56 |
-
</ul>
|
| 57 |
-
</div>
|
| 58 |
-
<div>
|
| 59 |
-
<h4>Cidades</h4>
|
| 60 |
-
<ul>
|
| 61 |
-
<li><a href="busca.html?cidade=Matinhos">Matinhos</a></li>
|
| 62 |
-
<li><a href="busca.html?cidade=Guaratuba">Guaratuba</a></li>
|
| 63 |
-
<li><a href="busca.html?cidade=Pontal do Paraná">Pontal do Paraná</a></li>
|
| 64 |
-
</ul>
|
| 65 |
-
</div>
|
| 66 |
-
<div>
|
| 67 |
-
<h4>Contato</h4>
|
| 68 |
-
<ul>
|
| 69 |
-
<li>Av. Beira Mar, 1120 — Caiobá, Matinhos/PR</li>
|
| 70 |
-
<li>(41) 99811-2201</li>
|
| 71 |
-
<li>contato@sahkeyimoveis.com.br</li>
|
| 72 |
-
<li><a href="admin.html">Área do corretor →</a></li>
|
| 73 |
-
</ul>
|
| 74 |
-
</div>
|
| 75 |
-
</div>
|
| 76 |
-
<div class="footer-bottom">
|
| 77 |
-
<span>© <span data-year></span> Sahkey Imóveis · CRECI-PR 00000-J (demo)</span>
|
| 78 |
-
<span>Site demonstrativo — dados fictícios</span>
|
| 79 |
-
</div>
|
| 80 |
-
</div>
|
| 81 |
-
</footer>
|
| 82 |
-
|
| 83 |
-
<script src="/js/api.js"></script>
|
| 84 |
-
<script src="/js/main.js"></script>
|
| 85 |
-
<script src="/js/imovel.js"></script>
|
| 86 |
-
</body>
|
| 87 |
-
</html>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static/index.html
DELETED
|
@@ -1,161 +0,0 @@
|
|
| 1 |
-
<!DOCTYPE html>
|
| 2 |
-
<html lang="pt-BR">
|
| 3 |
-
<head>
|
| 4 |
-
<meta charset="UTF-8">
|
| 5 |
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
-
<title>SK Imobiliária — Venda, locação e gestão no litoral do Paraná</title>
|
| 7 |
-
<meta name="description" content="SK Imobiliária: casas, apartamentos e terrenos à venda e locação em Matinhos, Guaratuba e Pontal do Paraná. Gestão completa do seu negócio imobiliário no litoral.">
|
| 8 |
-
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 9 |
-
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@500;600&display=swap" rel="stylesheet">
|
| 10 |
-
<link rel="icon" type="image/svg+xml" href="/img/logo-sk.svg">
|
| 11 |
-
<link rel="stylesheet" href="/css/style.css?v=6">
|
| 12 |
-
</head>
|
| 13 |
-
<body>
|
| 14 |
-
|
| 15 |
-
<header class="site-header">
|
| 16 |
-
<nav class="nav">
|
| 17 |
-
<button class="nav-toggle" aria-label="Abrir menu" aria-expanded="false">
|
| 18 |
-
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="18" x2="21" y2="18"></line></svg>
|
| 19 |
-
</button>
|
| 20 |
-
<a class="brand" href="/">
|
| 21 |
-
<img src="/img/logo-sk.svg" alt="SK Imobiliária" class="brand-mark" height="38">
|
| 22 |
-
</a>
|
| 23 |
-
<ul class="nav-links">
|
| 24 |
-
<li><a href="/">Início</a></li>
|
| 25 |
-
<li><a href="/busca">Comprar & Alugar</a></li>
|
| 26 |
-
<li><a href="/servicos.html">Serviços</a></li>
|
| 27 |
-
<li><a href="/sobre.html">Sobre</a></li>
|
| 28 |
-
<li><a href="/contato.html">Contato</a></li>
|
| 29 |
-
</ul>
|
| 30 |
-
<a class="nav-cta nav-whats" href="https://wa.me/5541998112201?text=Ol%C3%A1%21%20Vim%20pelo%20site%20da%20SK%20Imobili%C3%A1ria%20e%20gostaria%20de%20falar%20com%20um%20corretor." target="_blank" rel="noopener" aria-label="Falar no WhatsApp: (41) 99811-2201"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M17.47 14.38c-.3-.15-1.76-.87-2.03-.97-.27-.1-.47-.15-.67.15-.2.3-.77.97-.94 1.16-.17.2-.35.22-.64.08-.3-.15-1.26-.46-2.39-1.48-.88-.79-1.48-1.76-1.65-2.06-.17-.3-.02-.46.13-.61.13-.13.3-.35.45-.52.15-.17.2-.3.3-.5.1-.2.05-.37-.02-.52-.08-.15-.67-1.61-.92-2.21-.24-.58-.49-.5-.67-.51-.17-.01-.37-.01-.57-.01-.2 0-.52.07-.79.37-.27.3-1.04 1.02-1.04 2.48s1.07 2.87 1.21 3.07c.15.2 2.1 3.2 5.08 4.49.71.31 1.26.49 1.69.62.71.23 1.36.2 1.87.12.57-.09 1.76-.72 2.01-1.41.25-.7.25-1.29.17-1.41-.07-.13-.27-.2-.57-.35z"/><path d="M12.03 2C6.51 2 2 6.48 2 12c0 1.82.48 3.58 1.4 5.12L2 22l5.03-1.32A9.96 9.96 0 0 0 12.03 22C17.55 22 22 17.52 22 12S17.55 2 12.03 2zm0 18.09c-1.66 0-3.28-.44-4.7-1.28l-.34-.2-2.98.78.8-2.91-.22-.3A8.08 8.08 0 0 1 3.93 12c0-4.46 3.64-8.09 8.1-8.09 4.46 0 8.09 3.63 8.09 8.09 0 4.46-3.63 8.09-8.09 8.09z"/></svg><span>(41) 99811-2201</span></a>
|
| 31 |
-
</nav>
|
| 32 |
-
</header>
|
| 33 |
-
|
| 34 |
-
<section class="quick-search-section">
|
| 35 |
-
<form class="quick-search" action="/busca" method="get">
|
| 36 |
-
<input type="search" name="busca" placeholder="Buscar por título, bairro ou endereço…" aria-label="Buscar imóveis">
|
| 37 |
-
<button type="submit" id="f-busca" class="quick-search-icon"
|
| 38 |
-
style="background:none; border:none; outline:none; box-shadow:none;"
|
| 39 |
-
aria-label="Buscar">
|
| 40 |
-
<svg class="quick-search-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="7"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
|
| 41 |
-
</button>
|
| 42 |
-
</form>
|
| 43 |
-
</section>
|
| 44 |
-
|
| 45 |
-
<section class="showcase-section">
|
| 46 |
-
<div class="container showcase-head reveal">
|
| 47 |
-
</div>
|
| 48 |
-
<div class="showcase reveal" id="destaques-carousel">
|
| 49 |
-
<p class="loading-state">Carregando destaques…</p>
|
| 50 |
-
</div>
|
| 51 |
-
</section>
|
| 52 |
-
|
| 53 |
-
<section class="container" style="padding:36px">
|
| 54 |
-
<div class="section-head reveal">
|
| 55 |
-
<div>
|
| 56 |
-
<span class="eyebrow eyebrow-gold">Onde atuamos</span>
|
| 57 |
-
<h2>Cada cidade do litoral paranaense tem seu ritmo </h2>
|
| 58 |
-
<p>Conhecemos os três de perto. Escolha uma para começar a busca.</p>
|
| 59 |
-
</div>
|
| 60 |
-
</div>
|
| 61 |
-
<div class="cidades-grid grid-lined cols-3">
|
| 62 |
-
<a class="cidade-box luxe-frame reveal reveal-left" href="/busca/matinhos">
|
| 63 |
-
<img src="https://s2-g1.glbimg.com/5Fz3LMozLCEPXRVTUBIm8wrhyQ4=/0x0:2048x1536/984x0/smart/filters:strip_icc()/i.s3.glbimg.com/v1/AUTH_59edd422c0c84a879bd37670ae4f538a/internal_photos/bs/2024/R/T/LRJAPWQbWHTbJpbFCM4A/revitalizacao-braia-brava.jpg" alt="Praia de Matinhos" loading="lazy">
|
| 64 |
-
<div class="cidade-box-body">
|
| 65 |
-
<h3>Matinhos</h3>
|
| 66 |
-
<span class="cidade-box-cta">Ver imóveis em Matinhos →</span>
|
| 67 |
-
</div>
|
| 68 |
-
</a>
|
| 69 |
-
<a class="cidade-box luxe-frame reveal reveal-zoom" style="--i:1" href="/busca/guaratuba">
|
| 70 |
-
<img src="https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcSaeaY21ZsNlv2BbAsSkQenglXfagSN7H9yINs12eh1u2GSKT2qDWQw69xr&s=10" alt="Baía de Guaratuba" loading="lazy">
|
| 71 |
-
<div class="cidade-box-body">
|
| 72 |
-
<h3>Guaratuba</h3>
|
| 73 |
-
<span class="cidade-box-cta">Ver imóveis em Guaratuba →</span>
|
| 74 |
-
</div>
|
| 75 |
-
</a>
|
| 76 |
-
<a class="cidade-box luxe-frame reveal reveal-right" style="--i:2" href="/busca/pontal-do-parana">
|
| 77 |
-
<img src="https://i0.wp.com/www.portallitoraldoparana.com.br/wp-content/uploads/2020/11/Pontal-do-sul.jpg?fit=960%2C960&ssl=1" alt="Dunas de Pontal do Paraná" loading="lazy">
|
| 78 |
-
<div class="cidade-box-body">
|
| 79 |
-
<h3>Pontal do Paraná</h3>
|
| 80 |
-
<span class="cidade-box-cta">Ver imóveis em Pontal →</span>
|
| 81 |
-
</div>
|
| 82 |
-
</a>
|
| 83 |
-
</div>
|
| 84 |
-
</section>
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
<section class="container" style="padding:36px">
|
| 88 |
-
<div class="section-head reveal">
|
| 89 |
-
<div>
|
| 90 |
-
<span class="eyebrow eyebrow-gold">Como ajudamos</span>
|
| 91 |
-
<h2>Venda, locação e gestão — sob um só corretor</h2>
|
| 92 |
-
<p>Do primeiro contato à entrega das chaves (ou ao relatório mensal do seu imóvel investido).</p>
|
| 93 |
-
</div>
|
| 94 |
-
<a class="btn btn-outline" href="/servicos.html">Conhecer todos os serviços</a>
|
| 95 |
-
</div>
|
| 96 |
-
<div class="services-grid grid-lined cols-3">
|
| 97 |
-
<div class="service-card reveal">
|
| 98 |
-
<span class="num">Venda</span>
|
| 99 |
-
<h3>Compra e venda</h3>
|
| 100 |
-
<p class="muted">Avaliação de mercado, divulgação e negociação até a escritura.</p>
|
| 101 |
-
</div>
|
| 102 |
-
<div class="service-card reveal" style="--i:1">
|
| 103 |
-
<span class="num">Locação</span>
|
| 104 |
-
<h3>Locação anual e temporada</h3>
|
| 105 |
-
<p class="muted">Contratos, vistoria e cuidado com inquilinos — inclusive na alta temporada.</p>
|
| 106 |
-
</div>
|
| 107 |
-
<div class="service-card reveal" style="--i:2">
|
| 108 |
-
<span class="num">Gestão</span>
|
| 109 |
-
<h3>Gestão de negócios imobiliários</h3>
|
| 110 |
-
<p class="muted">Administração de carteiras, repasses e relatórios para investidores.</p>
|
| 111 |
-
</div>
|
| 112 |
-
</div>
|
| 113 |
-
</section>
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
<footer class="site-footer" style="margin-top:0">
|
| 117 |
-
<div class="container">
|
| 118 |
-
<div class="footer-grid">
|
| 119 |
-
<div>
|
| 120 |
-
<div class="footer-brand"><img src="/img/logo-sk.svg" alt="SK Imobiliária" height="30"></div>
|
| 121 |
-
<p class="muted" style="font-size:.86rem">Venda, locação e gestão de negócios imobiliários no litoral do Paraná.</p>
|
| 122 |
-
</div>
|
| 123 |
-
<div>
|
| 124 |
-
<h4>Navegação</h4>
|
| 125 |
-
<ul>
|
| 126 |
-
<li><a href="/busca">Comprar & Alugar</a></li>
|
| 127 |
-
<li><a href="/servicos.html">Serviços</a></li>
|
| 128 |
-
<li><a href="/sobre.html">Sobre a SK</a></li>
|
| 129 |
-
<li><a href="/contato.html">Contato</a></li>
|
| 130 |
-
</ul>
|
| 131 |
-
</div>
|
| 132 |
-
<div>
|
| 133 |
-
<h4>Cidades</h4>
|
| 134 |
-
<ul>
|
| 135 |
-
<li><a href="/busca/matinhos">Imóveis em Matinhos</a></li>
|
| 136 |
-
<li><a href="/busca/guaratuba">Imóveis em Guaratuba</a></li>
|
| 137 |
-
<li><a href="/busca/pontal-do-parana">Imóveis em Pontal do Paraná</a></li>
|
| 138 |
-
</ul>
|
| 139 |
-
</div>
|
| 140 |
-
<div>
|
| 141 |
-
<h4>Contato</h4>
|
| 142 |
-
<ul>
|
| 143 |
-
<li>Av. Beira Mar, 1120 — Caiobá, Matinhos/PR</li>
|
| 144 |
-
<li>(41) 99811-2201</li>
|
| 145 |
-
<li>contato@skimobiliaria.com.br</li>
|
| 146 |
-
<li><a href="/admin.html">Área do corretor →</a></li>
|
| 147 |
-
</ul>
|
| 148 |
-
</div>
|
| 149 |
-
</div>
|
| 150 |
-
<div class="footer-bottom">
|
| 151 |
-
<span>© <span data-year></span> SK Imobiliária · CRECI-PR 00000-J · Salete Kuhnert dos Santos</span>
|
| 152 |
-
<span>Site demonstrativo — dados fictícios</span>
|
| 153 |
-
</div>
|
| 154 |
-
</div>
|
| 155 |
-
</footer>
|
| 156 |
-
|
| 157 |
-
<script src="/js/api.js?v=3"></script>
|
| 158 |
-
<script src="/js/main.js?v=3"></script>
|
| 159 |
-
<script src="/js/home.js?v=4"></script>
|
| 160 |
-
</body>
|
| 161 |
-
</html>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static/js/admin.js
CHANGED
|
@@ -1,94 +1,63 @@
|
|
| 1 |
-
/* SK Imobiliária — painel administrativo
|
| 2 |
|
| 3 |
const form = document.getElementById("form-anuncio");
|
| 4 |
const btnCancelar = document.getElementById("btn-cancelar");
|
| 5 |
const btnSalvar = document.getElementById("btn-salvar");
|
|
|
|
| 6 |
|
| 7 |
let midiaAnuncioId = null;
|
| 8 |
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
async function carregarMidias() {
|
| 18 |
-
const grid = document.getElementById("midia-grid");
|
| 19 |
-
if (!midiaAnuncioId) return;
|
| 20 |
-
try {
|
| 21 |
-
const a = await apiGet(`/${midiaAnuncioId}`);
|
| 22 |
-
const midias = parseMidias(a.midias);
|
| 23 |
-
if (!midias.length) {
|
| 24 |
-
grid.innerHTML = `<p class="muted" style="grid-column:1/-1">Nenhuma mídia enviada ainda.</p>`;
|
| 25 |
-
return;
|
| 26 |
-
}
|
| 27 |
-
grid.innerHTML = midias.map((url) => `
|
| 28 |
-
<div class="midia-item">
|
| 29 |
-
${mediaThumbTag(url)}
|
| 30 |
-
<button type="button" class="midia-remove" data-url="${escapeHtml(url)}" title="Excluir mídia" aria-label="Excluir mídia">✕</button>
|
| 31 |
-
</div>`).join("");
|
| 32 |
-
} catch (err) {
|
| 33 |
-
grid.innerHTML = `<p class="muted" style="grid-column:1/-1">Erro ao carregar mídias: ${escapeHtml(err.message)}</p>`;
|
| 34 |
}
|
|
|
|
| 35 |
}
|
| 36 |
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
if (!input.files.length) { toast("Selecione ao menos um arquivo.", true); return; }
|
| 41 |
-
|
| 42 |
-
const fd = new FormData();
|
| 43 |
-
Array.from(input.files).forEach((f) => fd.append("arquivos", f));
|
| 44 |
-
|
| 45 |
-
const statusEl = document.getElementById("midia-upload-status");
|
| 46 |
-
statusEl.textContent = "Enviando…";
|
| 47 |
-
try {
|
| 48 |
-
const res = await fetch(`${API_BASE}/${midiaAnuncioId}/midias/upload`, { method: "POST", body: fd });
|
| 49 |
-
if (!res.ok) {
|
| 50 |
-
const d = await res.json().catch(() => ({}));
|
| 51 |
-
throw new Error(d.detail ? JSON.stringify(d.detail) : `Erro ${res.status}`);
|
| 52 |
-
}
|
| 53 |
-
input.value = "";
|
| 54 |
-
statusEl.textContent = "";
|
| 55 |
-
toast("Mídia enviada com sucesso.");
|
| 56 |
-
carregarMidias();
|
| 57 |
-
} catch (err) {
|
| 58 |
-
statusEl.textContent = "";
|
| 59 |
-
toast(err.message, true);
|
| 60 |
-
}
|
| 61 |
-
});
|
| 62 |
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
-
|
| 70 |
-
await apiSend("POST", `/${midiaAnuncioId}/midias/url?url=${encodeURIComponent(url)}`);
|
| 71 |
-
input.value = "";
|
| 72 |
-
toast("Mídia adicionada com sucesso.");
|
| 73 |
-
carregarMidias();
|
| 74 |
-
} catch (err) {
|
| 75 |
-
toast(err.message, true);
|
| 76 |
-
}
|
| 77 |
-
});
|
| 78 |
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
| 91 |
|
|
|
|
| 92 |
function lerFormulario() {
|
| 93 |
return {
|
| 94 |
tipo_negocio: document.getElementById("a-tipo-negocio").value,
|
|
@@ -142,8 +111,10 @@ function preencherFormulario(a) {
|
|
| 142 |
document.getElementById("a-corretor").value = a.corretor_responsavel || "";
|
| 143 |
document.getElementById("a-telefone").value = a.telefone_contato || "";
|
| 144 |
|
|
|
|
| 145 |
btnSalvar.textContent = `Atualizar anúncio #${a.id}`;
|
| 146 |
btnCancelar.style.display = "";
|
|
|
|
| 147 |
form.scrollIntoView({ behavior: "smooth" });
|
| 148 |
ativarSecaoMidia(a.id);
|
| 149 |
}
|
|
@@ -152,10 +123,41 @@ function limparFormulario() {
|
|
| 152 |
form.reset();
|
| 153 |
document.getElementById("a-id").value = "";
|
| 154 |
document.getElementById("a-estado").value = "PR";
|
|
|
|
| 155 |
btnSalvar.textContent = "Salvar anúncio";
|
| 156 |
btnCancelar.style.display = "none";
|
|
|
|
|
|
|
|
|
|
| 157 |
}
|
| 158 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
async function carregarLista() {
|
| 160 |
const tbody = document.getElementById("admin-tbody");
|
| 161 |
tbody.innerHTML = `<tr><td colspan="7" class="muted">Carregando anúncios…</td></tr>`;
|
|
@@ -163,7 +165,7 @@ async function carregarLista() {
|
|
| 163 |
const data = await apiGet("/", { limit: 200, ordenar_por: "mais_recentes" });
|
| 164 |
document.getElementById("admin-total").textContent = `${data.total} anúncio(s) na base`;
|
| 165 |
if (!data.resultados.length) {
|
| 166 |
-
tbody.innerHTML = `<tr><td colspan="
|
| 167 |
return;
|
| 168 |
}
|
| 169 |
tbody.innerHTML = data.resultados.map((a) => `
|
|
@@ -172,8 +174,9 @@ async function carregarLista() {
|
|
| 172 |
<td>${escapeHtml(a.titulo)}</td>
|
| 173 |
<td>${a.tipo_negocio}</td>
|
| 174 |
<td>${escapeHtml(a.cidade)}</td>
|
| 175 |
-
<td class="badge-mono">${formatMoney(a.preco, a.tipo_negocio).replace(/<[^>]+>/g, "")}</td>
|
| 176 |
<td><span class="status-pill ${statusClass(a.status)}">${a.status}</span></td>
|
|
|
|
| 177 |
<td>
|
| 178 |
<button class="icon-btn" data-acao="editar" data-id="${a.id}">Editar</button>
|
| 179 |
<button class="icon-btn" data-acao="ver" data-id="${a.id}">Ver</button>
|
|
@@ -195,12 +198,16 @@ document.getElementById("admin-tbody").addEventListener("click", async (e) => {
|
|
| 195 |
window.open(`/imovel/${id}`, "_blank");
|
| 196 |
return;
|
| 197 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
if (btn.dataset.acao === "editar") {
|
| 199 |
try {
|
| 200 |
const a = await apiGet(`/${id}`);
|
| 201 |
preencherFormulario(a);
|
| 202 |
} catch (err) {
|
| 203 |
-
|
| 204 |
}
|
| 205 |
return;
|
| 206 |
}
|
|
@@ -211,36 +218,393 @@ document.getElementById("admin-tbody").addEventListener("click", async (e) => {
|
|
| 211 |
toast(`Anúncio #${id} removido.`);
|
| 212 |
carregarLista();
|
| 213 |
} catch (err) {
|
| 214 |
-
|
| 215 |
}
|
| 216 |
}
|
| 217 |
});
|
| 218 |
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
const
|
| 222 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
|
| 224 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
try {
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
} else {
|
| 231 |
-
|
| 232 |
-
toast(`Anúncio #${criado.id} criado.`);
|
| 233 |
-
ativarSecaoMidia(criado.id);
|
| 234 |
}
|
| 235 |
-
|
| 236 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
} catch (err) {
|
|
|
|
| 238 |
toast(err.message, true);
|
| 239 |
} finally {
|
| 240 |
-
|
| 241 |
}
|
| 242 |
});
|
| 243 |
|
| 244 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
|
| 246 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* SK Imobiliária — painel administrativo (CRUD de anúncios, abas e destaques) */
|
| 2 |
|
| 3 |
const form = document.getElementById("form-anuncio");
|
| 4 |
const btnCancelar = document.getElementById("btn-cancelar");
|
| 5 |
const btnSalvar = document.getElementById("btn-salvar");
|
| 6 |
+
const formHeading = document.getElementById("form-heading");
|
| 7 |
|
| 8 |
let midiaAnuncioId = null;
|
| 9 |
|
| 10 |
+
/* -------------------- Erros / sessão expirada -------------------- */
|
| 11 |
+
// Qualquer chamada de escrita à API pode voltar 401 se a sessão do admin
|
| 12 |
+
// caducou (ou o login foi feito em outra aba e essa sessão foi encerrada).
|
| 13 |
+
// Nesse caso mandamos direto para a tela de login em vez de só mostrar um toast.
|
| 14 |
+
function tratarErro(err) {
|
| 15 |
+
if (err && err.status === 401) {
|
| 16 |
+
window.location.href = "/admin/login?expirado=1";
|
| 17 |
+
return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
}
|
| 19 |
+
toast(err.message, true);
|
| 20 |
}
|
| 21 |
|
| 22 |
+
/* -------------------- Abas -------------------- */
|
| 23 |
+
const abas = document.querySelectorAll(".admin-tab");
|
| 24 |
+
const paineis = document.querySelectorAll(".admin-panel");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
+
function ativarAba(nome) {
|
| 27 |
+
abas.forEach((btn) => {
|
| 28 |
+
const ativa = btn.dataset.tab === nome;
|
| 29 |
+
btn.classList.toggle("is-active", ativa);
|
| 30 |
+
btn.setAttribute("aria-selected", ativa ? "true" : "false");
|
| 31 |
+
});
|
| 32 |
+
paineis.forEach((painel) => {
|
| 33 |
+
painel.hidden = painel.dataset.panel !== nome;
|
| 34 |
+
});
|
| 35 |
+
if (nome === "editar") carregarLista();
|
| 36 |
+
if (nome === "destaques") carregarDestaques();
|
| 37 |
+
if (nome === "config") carregarConfig();
|
| 38 |
+
if (nome === "paginas" && !paginasCarregadas) carregarListaPaginas();
|
| 39 |
+
if (nome === "backup") carregarBackupsAutomaticos();
|
| 40 |
+
}
|
| 41 |
|
| 42 |
+
abas.forEach((btn) => btn.addEventListener("click", () => ativarAba(btn.dataset.tab)));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
+
/* -------------------- Mídias do anúncio --------------------
|
| 45 |
+
O envio/URL/exclusão de mídias não fica mais dentro do formulário de
|
| 46 |
+
adicionar/editar anúncio — vive só na página dedicada /admin/midias/{id}
|
| 47 |
+
(ver templates/admin_midias.html + static/js/admin_midias.js). Aqui só
|
| 48 |
+
mostramos o botão que leva para lá. */
|
| 49 |
+
function ativarSecaoMidia(id) {
|
| 50 |
+
midiaAnuncioId = id;
|
| 51 |
+
document.getElementById("midia-hint").textContent =
|
| 52 |
+
`Arquivos do anúncio #${id} ficam salvos em /data/midia/anuncios/${id}/`;
|
| 53 |
+
const btnPaginaMidias = document.getElementById("btn-midia-nova-janela");
|
| 54 |
+
btnPaginaMidias.style.display = "";
|
| 55 |
+
btnPaginaMidias.onclick = () => {
|
| 56 |
+
window.location.href = `/admin/midias/${id}`;
|
| 57 |
+
};
|
| 58 |
+
}
|
| 59 |
|
| 60 |
+
/* -------------------- Formulário: Adicionar / Editar anúncio -------------------- */
|
| 61 |
function lerFormulario() {
|
| 62 |
return {
|
| 63 |
tipo_negocio: document.getElementById("a-tipo-negocio").value,
|
|
|
|
| 111 |
document.getElementById("a-corretor").value = a.corretor_responsavel || "";
|
| 112 |
document.getElementById("a-telefone").value = a.telefone_contato || "";
|
| 113 |
|
| 114 |
+
formHeading.textContent = `Editar anúncio #${a.id}`;
|
| 115 |
btnSalvar.textContent = `Atualizar anúncio #${a.id}`;
|
| 116 |
btnCancelar.style.display = "";
|
| 117 |
+
ativarAba("adicionar");
|
| 118 |
form.scrollIntoView({ behavior: "smooth" });
|
| 119 |
ativarSecaoMidia(a.id);
|
| 120 |
}
|
|
|
|
| 123 |
form.reset();
|
| 124 |
document.getElementById("a-id").value = "";
|
| 125 |
document.getElementById("a-estado").value = "PR";
|
| 126 |
+
formHeading.textContent = "Novo anúncio";
|
| 127 |
btnSalvar.textContent = "Salvar anúncio";
|
| 128 |
btnCancelar.style.display = "none";
|
| 129 |
+
// Observação: a seção de mídias (e o botão "nova janela") não são escondidos
|
| 130 |
+
// aqui de propósito — depois de salvar um anúncio novo ela deve continuar
|
| 131 |
+
// visível, apontando para o anúncio recém-criado (ver ativarSecaoMidia).
|
| 132 |
}
|
| 133 |
|
| 134 |
+
form.addEventListener("submit", async (e) => {
|
| 135 |
+
e.preventDefault();
|
| 136 |
+
const id = document.getElementById("a-id").value;
|
| 137 |
+
const payload = lerFormulario();
|
| 138 |
+
|
| 139 |
+
btnSalvar.disabled = true;
|
| 140 |
+
try {
|
| 141 |
+
if (id) {
|
| 142 |
+
await apiSend("PUT", `/${id}`, payload);
|
| 143 |
+
toast(`Anúncio #${id} atualizado.`);
|
| 144 |
+
ativarSecaoMidia(id);
|
| 145 |
+
} else {
|
| 146 |
+
const criado = await apiSend("POST", "/", payload);
|
| 147 |
+
toast(`Anúncio #${criado.id} criado.`);
|
| 148 |
+
ativarSecaoMidia(criado.id);
|
| 149 |
+
}
|
| 150 |
+
limparFormulario();
|
| 151 |
+
} catch (err) {
|
| 152 |
+
tratarErro(err);
|
| 153 |
+
} finally {
|
| 154 |
+
btnSalvar.disabled = false;
|
| 155 |
+
}
|
| 156 |
+
});
|
| 157 |
+
|
| 158 |
+
btnCancelar.addEventListener("click", limparFormulario);
|
| 159 |
+
|
| 160 |
+
/* -------------------- Aba: Editar anúncios (lista) -------------------- */
|
| 161 |
async function carregarLista() {
|
| 162 |
const tbody = document.getElementById("admin-tbody");
|
| 163 |
tbody.innerHTML = `<tr><td colspan="7" class="muted">Carregando anúncios…</td></tr>`;
|
|
|
|
| 165 |
const data = await apiGet("/", { limit: 200, ordenar_por: "mais_recentes" });
|
| 166 |
document.getElementById("admin-total").textContent = `${data.total} anúncio(s) na base`;
|
| 167 |
if (!data.resultados.length) {
|
| 168 |
+
tbody.innerHTML = `<tr><td colspan="8" class="muted">Nenhum anúncio cadastrado ainda.</td></tr>`;
|
| 169 |
return;
|
| 170 |
}
|
| 171 |
tbody.innerHTML = data.resultados.map((a) => `
|
|
|
|
| 174 |
<td>${escapeHtml(a.titulo)}</td>
|
| 175 |
<td>${a.tipo_negocio}</td>
|
| 176 |
<td>${escapeHtml(a.cidade)}</td>
|
| 177 |
+
<td class="badge-mono">${formatMoney(a.preco, a.tipo_negocio, false).replace(/<[^>]+>/g, "")}</td>
|
| 178 |
<td><span class="status-pill ${statusClass(a.status)}">${a.status}</span></td>
|
| 179 |
+
<td><button class="icon-btn" data-acao="midias" data-id="${a.id}">Mídias</button></td>
|
| 180 |
<td>
|
| 181 |
<button class="icon-btn" data-acao="editar" data-id="${a.id}">Editar</button>
|
| 182 |
<button class="icon-btn" data-acao="ver" data-id="${a.id}">Ver</button>
|
|
|
|
| 198 |
window.open(`/imovel/${id}`, "_blank");
|
| 199 |
return;
|
| 200 |
}
|
| 201 |
+
if (btn.dataset.acao === "midias") {
|
| 202 |
+
window.location.href = `/admin/midias/${id}`;
|
| 203 |
+
return;
|
| 204 |
+
}
|
| 205 |
if (btn.dataset.acao === "editar") {
|
| 206 |
try {
|
| 207 |
const a = await apiGet(`/${id}`);
|
| 208 |
preencherFormulario(a);
|
| 209 |
} catch (err) {
|
| 210 |
+
tratarErro(err);
|
| 211 |
}
|
| 212 |
return;
|
| 213 |
}
|
|
|
|
| 218 |
toast(`Anúncio #${id} removido.`);
|
| 219 |
carregarLista();
|
| 220 |
} catch (err) {
|
| 221 |
+
tratarErro(err);
|
| 222 |
}
|
| 223 |
}
|
| 224 |
});
|
| 225 |
|
| 226 |
+
/* -------------------- Aba: Selecionar destaques -------------------- */
|
| 227 |
+
async function carregarDestaques() {
|
| 228 |
+
const lista = document.getElementById("destaques-lista");
|
| 229 |
+
lista.innerHTML = `<p class="muted">Carregando anúncios…</p>`;
|
| 230 |
+
try {
|
| 231 |
+
const data = await apiGet("/", { limit: 200, ordenar_por: "mais_recentes" });
|
| 232 |
+
if (!data.resultados.length) {
|
| 233 |
+
lista.innerHTML = `<p class="muted">Nenhum anúncio cadastrado ainda.</p>`;
|
| 234 |
+
return;
|
| 235 |
+
}
|
| 236 |
+
lista.innerHTML = data.resultados.map((a) => `
|
| 237 |
+
<label class="destaque-item">
|
| 238 |
+
<div class="destaque-item-info">
|
| 239 |
+
<span class="badge-mono">#${a.id}</span>
|
| 240 |
+
<span class="destaque-item-titulo">${escapeHtml(a.titulo)}</span>
|
| 241 |
+
<span class="muted destaque-item-local">${escapeHtml(a.bairro)}, ${escapeHtml(a.cidade)} · ${a.tipo_negocio}</span>
|
| 242 |
+
</div>
|
| 243 |
+
<span class="switch">
|
| 244 |
+
<input type="checkbox" data-id="${a.id}" ${a.destaque ? "checked" : ""}>
|
| 245 |
+
<span class="switch-track"></span>
|
| 246 |
+
</span>
|
| 247 |
+
</label>
|
| 248 |
+
`).join("");
|
| 249 |
+
} catch (err) {
|
| 250 |
+
lista.innerHTML = `<p class="muted">Erro ao carregar: ${escapeHtml(err.message)}</p>`;
|
| 251 |
+
}
|
| 252 |
+
}
|
| 253 |
|
| 254 |
+
document.getElementById("destaques-lista").addEventListener("change", async (e) => {
|
| 255 |
+
const input = e.target.closest('input[type="checkbox"][data-id]');
|
| 256 |
+
if (!input) return;
|
| 257 |
+
const id = input.dataset.id;
|
| 258 |
+
const destaque = input.checked;
|
| 259 |
+
input.disabled = true;
|
| 260 |
try {
|
| 261 |
+
await apiSend("PATCH", `/${id}`, { destaque });
|
| 262 |
+
toast(destaque ? `Anúncio #${id} marcado como destaque.` : `Anúncio #${id} removido dos destaques.`);
|
| 263 |
+
} catch (err) {
|
| 264 |
+
input.checked = !destaque; // desfaz a marcação visual se a chamada falhar
|
| 265 |
+
tratarErro(err);
|
| 266 |
+
} finally {
|
| 267 |
+
input.disabled = false;
|
| 268 |
+
}
|
| 269 |
+
});
|
| 270 |
+
|
| 271 |
+
/* -------------------- Aba: Configurações do site -------------------- */
|
| 272 |
+
const formConfig = document.getElementById("form-config");
|
| 273 |
+
const btnSalvarConfig = document.getElementById("btn-salvar-config");
|
| 274 |
+
|
| 275 |
+
async function carregarConfig() {
|
| 276 |
+
try {
|
| 277 |
+
const cfg = await fetch("/api/config").then((r) => r.json());
|
| 278 |
+
document.getElementById("cfg-nome-empresa").value = cfg.nome_empresa || "";
|
| 279 |
+
document.getElementById("cfg-responsavel").value = cfg.responsavel || "";
|
| 280 |
+
document.getElementById("cfg-creci").value = cfg.creci || "";
|
| 281 |
+
document.getElementById("cfg-whatsapp-numero").value = cfg.whatsapp_numero || "";
|
| 282 |
+
document.getElementById("cfg-telefone-exibicao").value = cfg.telefone_exibicao || "";
|
| 283 |
+
document.getElementById("cfg-email").value = cfg.email || "";
|
| 284 |
+
document.getElementById("cfg-whatsapp-texto").value = cfg.whatsapp_texto_padrao || "";
|
| 285 |
+
document.getElementById("cfg-endereco").value = cfg.endereco || "";
|
| 286 |
+
document.getElementById("cfg-horario").value = cfg.horario_atendimento || "";
|
| 287 |
+
} catch (err) {
|
| 288 |
+
toast("Erro ao carregar configurações do site.", true);
|
| 289 |
+
}
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
formConfig.addEventListener("submit", async (e) => {
|
| 293 |
+
e.preventDefault();
|
| 294 |
+
const payload = {
|
| 295 |
+
nome_empresa: document.getElementById("cfg-nome-empresa").value,
|
| 296 |
+
responsavel: document.getElementById("cfg-responsavel").value,
|
| 297 |
+
creci: document.getElementById("cfg-creci").value,
|
| 298 |
+
whatsapp_numero: document.getElementById("cfg-whatsapp-numero").value.replace(/\D/g, ""),
|
| 299 |
+
telefone_exibicao: document.getElementById("cfg-telefone-exibicao").value,
|
| 300 |
+
email: document.getElementById("cfg-email").value,
|
| 301 |
+
whatsapp_texto_padrao: document.getElementById("cfg-whatsapp-texto").value,
|
| 302 |
+
endereco: document.getElementById("cfg-endereco").value,
|
| 303 |
+
horario_atendimento: document.getElementById("cfg-horario").value,
|
| 304 |
+
};
|
| 305 |
+
btnSalvarConfig.disabled = true;
|
| 306 |
+
try {
|
| 307 |
+
const res = await fetch("/api/config", {
|
| 308 |
+
method: "PUT",
|
| 309 |
+
headers: { "Content-Type": "application/json" },
|
| 310 |
+
body: JSON.stringify(payload),
|
| 311 |
+
});
|
| 312 |
+
if (!res.ok) {
|
| 313 |
+
if (res.status === 401) { window.location.href = "/admin/login?expirado=1"; return; }
|
| 314 |
+
const d = await res.json().catch(() => ({}));
|
| 315 |
+
throw new Error(d.detail ? JSON.stringify(d.detail) : `Erro ${res.status}`);
|
| 316 |
+
}
|
| 317 |
+
toast("Configurações do site salvas com sucesso.");
|
| 318 |
+
} catch (err) {
|
| 319 |
+
toast(err.message, true);
|
| 320 |
+
} finally {
|
| 321 |
+
btnSalvarConfig.disabled = false;
|
| 322 |
+
}
|
| 323 |
+
});
|
| 324 |
+
|
| 325 |
+
document.addEventListener("DOMContentLoaded", carregarLista);
|
| 326 |
+
|
| 327 |
+
/* -------------------- Aba: Editar páginas HTML (editor visual) -------------------- */
|
| 328 |
+
let paginasCarregadas = false;
|
| 329 |
+
const PAGINA_URL_PREVIEW = {
|
| 330 |
+
inicio: "/",
|
| 331 |
+
sobre: "/sobre.html",
|
| 332 |
+
servicos: "/servicos.html",
|
| 333 |
+
contato: "/contato.html",
|
| 334 |
+
"404": "/pagina-inexistente-" + Date.now(),
|
| 335 |
+
header: "/",
|
| 336 |
+
footer: "/",
|
| 337 |
+
};
|
| 338 |
+
|
| 339 |
+
const pgSelect = document.getElementById("pg-select");
|
| 340 |
+
const pgConteudo = document.getElementById("pg-conteudo"); // fonte de verdade (HTML bruto completo do arquivo)
|
| 341 |
+
const pgVisual = document.getElementById("pg-visual"); // área contenteditable (só o miolo editável)
|
| 342 |
+
const pgBlocoVisual = document.getElementById("pg-bloco-visual");
|
| 343 |
+
const pgDescricao = document.getElementById("pg-descricao");
|
| 344 |
+
const pgVerLink = document.getElementById("pg-ver-link");
|
| 345 |
+
const pgStatus = document.getElementById("pg-status");
|
| 346 |
+
const btnSalvarPagina = document.getElementById("btn-salvar-pagina");
|
| 347 |
+
const btnModoVisual = document.getElementById("pg-modo-visual");
|
| 348 |
+
const btnModoCodigo = document.getElementById("pg-modo-codigo");
|
| 349 |
+
|
| 350 |
+
let pgModoAtual = "visual";
|
| 351 |
+
let pgPre = ""; // tudo antes do <body...> (doctype, head, etc.) — nunca editado visualmente
|
| 352 |
+
let pgPos = ""; // tudo a partir de </body> — nunca editado visualmente
|
| 353 |
+
let pgTokens = {}; // token -> trecho original (Jinja {{ }}/{% %} e blocos <script>)
|
| 354 |
+
let pgTokenSeq = 0;
|
| 355 |
+
|
| 356 |
+
/* ---- Tokenização: protege trechos {{ }}, {% %} e <script> antes de jogar no contenteditable,
|
| 357 |
+
pra edição visual não corromper a lógica do template nem executar scripts sem querer. ---- */
|
| 358 |
+
function tokenizarMiolo(html) {
|
| 359 |
+
pgTokens = {};
|
| 360 |
+
pgTokenSeq = 0;
|
| 361 |
+
|
| 362 |
+
let resultado = html.replace(/<script[\s\S]*?<\/script>/gi, (trecho) => {
|
| 363 |
+
const token = `scriptTok${pgTokenSeq++}`;
|
| 364 |
+
pgTokens[token] = trecho;
|
| 365 |
+
return `<div class="cms-locked-block" contenteditable="false" data-cms-token="${token}">🔒 Bloco de script (edite em "Código HTML")</div>`;
|
| 366 |
+
});
|
| 367 |
+
|
| 368 |
+
resultado = resultado.replace(/\{\{[\s\S]*?\}\}|\{%[\s\S]*?%\}/g, (trecho) => {
|
| 369 |
+
const token = `jinjaTok${pgTokenSeq++}`;
|
| 370 |
+
pgTokens[token] = trecho;
|
| 371 |
+
return `<span class="cms-locked-inline" contenteditable="false" data-cms-token="${token}">🔒</span>`;
|
| 372 |
+
});
|
| 373 |
+
|
| 374 |
+
return resultado;
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
function destokenizarMiolo(html) {
|
| 378 |
+
let resultado = html;
|
| 379 |
+
Object.keys(pgTokens).forEach((token) => {
|
| 380 |
+
const original = pgTokens[token];
|
| 381 |
+
const regexBloco = new RegExp(`<div[^>]*data-cms-token="${token}"[^>]*>[\\s\\S]*?</div>`, "");
|
| 382 |
+
const regexInline = new RegExp(`<span[^>]*data-cms-token="${token}"[^>]*>[\\s\\S]*?</span>`, "");
|
| 383 |
+
if (regexBloco.test(resultado)) {
|
| 384 |
+
resultado = resultado.replace(regexBloco, original);
|
| 385 |
} else {
|
| 386 |
+
resultado = resultado.replace(regexInline, original);
|
|
|
|
|
|
|
| 387 |
}
|
| 388 |
+
});
|
| 389 |
+
return resultado;
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
/* ---- Divide o arquivo em: antes do <body>, miolo (editável), depois de </body>.
|
| 393 |
+
Páginas parciais sem <body> (cabeçalho/rodapé) caem inteiras no miolo. ---- */
|
| 394 |
+
function dividirEmBody(htmlCompleto) {
|
| 395 |
+
const abre = htmlCompleto.match(/<body[^>]*>/i);
|
| 396 |
+
const fecha = htmlCompleto.match(/<\/body>/i);
|
| 397 |
+
if (!abre || !fecha) {
|
| 398 |
+
return { pre: "", miolo: htmlCompleto, pos: "" };
|
| 399 |
+
}
|
| 400 |
+
const idxAbre = abre.index + abre[0].length;
|
| 401 |
+
const idxFecha = fecha.index;
|
| 402 |
+
return {
|
| 403 |
+
pre: htmlCompleto.slice(0, idxAbre),
|
| 404 |
+
miolo: htmlCompleto.slice(idxAbre, idxFecha),
|
| 405 |
+
pos: htmlCompleto.slice(idxFecha),
|
| 406 |
+
};
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
function carregarVisualApartirDoCodigo() {
|
| 410 |
+
const partes = dividirEmBody(pgConteudo.value);
|
| 411 |
+
pgPre = partes.pre;
|
| 412 |
+
pgPos = partes.pos;
|
| 413 |
+
pgVisual.innerHTML = tokenizarMiolo(partes.miolo);
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
function sincronizarCodigoApartirDoVisual() {
|
| 417 |
+
const mioloEditado = destokenizarMiolo(pgVisual.innerHTML);
|
| 418 |
+
pgConteudo.value = pgPre + mioloEditado + pgPos;
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
function definirModoEdicao(modo) {
|
| 422 |
+
if (modo === pgModoAtual) return;
|
| 423 |
+
if (modo === "codigo") {
|
| 424 |
+
sincronizarCodigoApartirDoVisual();
|
| 425 |
+
pgBlocoVisual.hidden = true;
|
| 426 |
+
pgConteudo.hidden = false;
|
| 427 |
+
} else {
|
| 428 |
+
carregarVisualApartirDoCodigo();
|
| 429 |
+
pgConteudo.hidden = true;
|
| 430 |
+
pgBlocoVisual.hidden = false;
|
| 431 |
+
}
|
| 432 |
+
pgModoAtual = modo;
|
| 433 |
+
btnModoVisual.classList.toggle("is-active", modo === "visual");
|
| 434 |
+
btnModoCodigo.classList.toggle("is-active", modo === "codigo");
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
btnModoVisual.addEventListener("click", () => definirModoEdicao("visual"));
|
| 438 |
+
btnModoCodigo.addEventListener("click", () => definirModoEdicao("codigo"));
|
| 439 |
+
|
| 440 |
+
/* ---- Barra de ferramentas (comandos básicos de formatação) ---- */
|
| 441 |
+
document.getElementById("pg-toolbar").addEventListener("mousedown", (e) => {
|
| 442 |
+
// Evita perder a seleção de texto do contenteditable ao clicar num botão da barra.
|
| 443 |
+
if (e.target.closest("button")) e.preventDefault();
|
| 444 |
+
});
|
| 445 |
+
|
| 446 |
+
document.getElementById("pg-toolbar").addEventListener("click", (e) => {
|
| 447 |
+
const btn = e.target.closest("button[data-cmd], button[data-block]");
|
| 448 |
+
if (!btn) return;
|
| 449 |
+
pgVisual.focus();
|
| 450 |
+
if (btn.dataset.cmd) {
|
| 451 |
+
document.execCommand(btn.dataset.cmd, false, null);
|
| 452 |
+
} else if (btn.dataset.block) {
|
| 453 |
+
document.execCommand("formatBlock", false, btn.dataset.block);
|
| 454 |
+
}
|
| 455 |
+
});
|
| 456 |
+
|
| 457 |
+
document.getElementById("pg-tb-link").addEventListener("click", () => {
|
| 458 |
+
const url = prompt("URL do link:", "https://");
|
| 459 |
+
if (!url) return;
|
| 460 |
+
pgVisual.focus();
|
| 461 |
+
document.execCommand("createLink", false, url);
|
| 462 |
+
});
|
| 463 |
+
|
| 464 |
+
document.getElementById("pg-tb-img").addEventListener("click", () => {
|
| 465 |
+
const url = prompt("URL da imagem:", "https://");
|
| 466 |
+
if (!url) return;
|
| 467 |
+
pgVisual.focus();
|
| 468 |
+
document.execCommand("insertImage", false, url);
|
| 469 |
+
});
|
| 470 |
+
|
| 471 |
+
/* ---- Carregar página selecionada ---- */
|
| 472 |
+
async function carregarListaPaginas() {
|
| 473 |
+
try {
|
| 474 |
+
const paginas = await fetch("/api/paginas").then((r) => r.json());
|
| 475 |
+
pgSelect.innerHTML = paginas.map((p) => `<option value="${p.id}">${escapeHtml(p.nome)}</option>`).join("");
|
| 476 |
+
paginasCarregadas = true;
|
| 477 |
+
if (paginas.length) carregarConteudoPagina(paginas[0].id, paginas[0].descricao);
|
| 478 |
+
} catch (err) {
|
| 479 |
+
pgDescricao.textContent = "Erro ao carregar lista de páginas.";
|
| 480 |
+
}
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
async function carregarConteudoPagina(id, descricao) {
|
| 484 |
+
pgStatus.textContent = "";
|
| 485 |
+
pgDescricao.textContent = descricao || "";
|
| 486 |
+
pgVerLink.href = PAGINA_URL_PREVIEW[id] || "/";
|
| 487 |
+
pgVisual.innerHTML = "<p><em>Carregando…</em></p>";
|
| 488 |
+
try {
|
| 489 |
+
const res = await fetch(`/api/paginas/${id}`);
|
| 490 |
+
if (!res.ok) throw new Error(`Erro ${res.status} ao carregar a página`);
|
| 491 |
+
const data = await res.json();
|
| 492 |
+
pgConteudo.value = data.conteudo;
|
| 493 |
+
pgModoAtual = "codigo"; // força a sincronização abaixo a (re)gerar a visão visual
|
| 494 |
+
definirModoEdicao("visual");
|
| 495 |
+
} catch (err) {
|
| 496 |
+
pgVisual.innerHTML = "";
|
| 497 |
+
pgStatus.textContent = err.message;
|
| 498 |
+
}
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
pgSelect.addEventListener("change", () => {
|
| 502 |
+
const opt = pgSelect.options[pgSelect.selectedIndex];
|
| 503 |
+
carregarConteudoPagina(pgSelect.value, opt ? opt.textContent : "");
|
| 504 |
+
});
|
| 505 |
+
|
| 506 |
+
btnSalvarPagina.addEventListener("click", async () => {
|
| 507 |
+
const id = pgSelect.value;
|
| 508 |
+
if (!id) return;
|
| 509 |
+
if (pgModoAtual === "visual") sincronizarCodigoApartirDoVisual();
|
| 510 |
+
if (!confirm(`Salvar as alterações de "${pgSelect.options[pgSelect.selectedIndex].textContent}"? Isso substitui o HTML atual dessa página no site.`)) return;
|
| 511 |
+
|
| 512 |
+
btnSalvarPagina.disabled = true;
|
| 513 |
+
pgStatus.textContent = "Salvando…";
|
| 514 |
+
try {
|
| 515 |
+
const res = await fetch(`/api/paginas/${id}`, {
|
| 516 |
+
method: "PUT",
|
| 517 |
+
headers: { "Content-Type": "application/json" },
|
| 518 |
+
body: JSON.stringify({ conteudo: pgConteudo.value }),
|
| 519 |
+
});
|
| 520 |
+
if (!res.ok) {
|
| 521 |
+
if (res.status === 401) { window.location.href = "/admin/login?expirado=1"; return; }
|
| 522 |
+
const d = await res.json().catch(() => ({}));
|
| 523 |
+
throw new Error(d.detail || `Erro ${res.status}`);
|
| 524 |
+
}
|
| 525 |
+
pgStatus.textContent = "";
|
| 526 |
+
toast("Página salva com sucesso.");
|
| 527 |
} catch (err) {
|
| 528 |
+
pgStatus.textContent = "";
|
| 529 |
toast(err.message, true);
|
| 530 |
} finally {
|
| 531 |
+
btnSalvarPagina.disabled = false;
|
| 532 |
}
|
| 533 |
});
|
| 534 |
|
| 535 |
+
/* -------------------- Aba: Backup e restauração -------------------- */
|
| 536 |
+
function formatarTamanho(bytes) {
|
| 537 |
+
if (bytes < 1024) return `${bytes} B`;
|
| 538 |
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
| 539 |
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
| 540 |
+
}
|
| 541 |
|
| 542 |
+
function formatarDataHora(isoStr) {
|
| 543 |
+
try {
|
| 544 |
+
return new Date(isoStr).toLocaleString("pt-BR");
|
| 545 |
+
} catch {
|
| 546 |
+
return isoStr;
|
| 547 |
+
}
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
async function carregarBackupsAutomaticos() {
|
| 551 |
+
const tbody = document.getElementById("backup-auto-tbody");
|
| 552 |
+
tbody.innerHTML = `<tr><td colspan="4" class="muted">Carregando…</td></tr>`;
|
| 553 |
+
try {
|
| 554 |
+
const res = await fetch("/api/backup/automaticos");
|
| 555 |
+
if (!res.ok) {
|
| 556 |
+
if (res.status === 401) { window.location.href = "/admin/login?expirado=1"; return; }
|
| 557 |
+
throw new Error(`Erro ${res.status} ao listar backups`);
|
| 558 |
+
}
|
| 559 |
+
const lista = await res.json();
|
| 560 |
+
if (!lista.length) {
|
| 561 |
+
tbody.innerHTML = `<tr><td colspan="4" class="muted">Nenhum backup automático ainda — eles são criados sozinhos antes de cada restauração.</td></tr>`;
|
| 562 |
+
return;
|
| 563 |
+
}
|
| 564 |
+
tbody.innerHTML = lista.map((b) => `
|
| 565 |
+
<tr>
|
| 566 |
+
<td class="badge-mono">${escapeHtml(b.nome)}</td>
|
| 567 |
+
<td>${formatarDataHora(b.criado_em)}</td>
|
| 568 |
+
<td>${formatarTamanho(b.tamanho_bytes)}</td>
|
| 569 |
+
<td><a class="icon-btn" href="/api/backup/automaticos/${encodeURIComponent(b.nome)}">Baixar</a></td>
|
| 570 |
+
</tr>
|
| 571 |
+
`).join("");
|
| 572 |
+
} catch (err) {
|
| 573 |
+
tbody.innerHTML = `<tr><td colspan="4">Erro ao carregar: ${escapeHtml(err.message)}</td></tr>`;
|
| 574 |
+
}
|
| 575 |
+
}
|
| 576 |
+
|
| 577 |
+
document.getElementById("btn-restaurar-backup").addEventListener("click", async () => {
|
| 578 |
+
const input = document.getElementById("backup-arquivo");
|
| 579 |
+
const statusEl = document.getElementById("backup-restaurar-status");
|
| 580 |
+
if (!input.files.length) { toast("Selecione o arquivo .zip do backup.", true); return; }
|
| 581 |
+
|
| 582 |
+
if (!confirm(
|
| 583 |
+
"Isso vai SUBSTITUIR a planilha de anúncios, as configurações do site, as mídias e as páginas atuais pelos dados desse backup. " +
|
| 584 |
+
"Um backup de segurança do estado atual é feito automaticamente antes. Quer continuar?"
|
| 585 |
+
)) return;
|
| 586 |
+
|
| 587 |
+
const fd = new FormData();
|
| 588 |
+
fd.append("arquivo", input.files[0]);
|
| 589 |
+
|
| 590 |
+
const btn = document.getElementById("btn-restaurar-backup");
|
| 591 |
+
btn.disabled = true;
|
| 592 |
+
statusEl.textContent = "Restaurando…";
|
| 593 |
+
try {
|
| 594 |
+
const res = await fetch("/api/backup/restaurar", { method: "POST", body: fd });
|
| 595 |
+
if (!res.ok) {
|
| 596 |
+
if (res.status === 401) { window.location.href = "/admin/login?expirado=1"; return; }
|
| 597 |
+
const d = await res.json().catch(() => ({}));
|
| 598 |
+
throw new Error(d.detail ? JSON.stringify(d.detail) : `Erro ${res.status}`);
|
| 599 |
+
}
|
| 600 |
+
input.value = "";
|
| 601 |
+
statusEl.textContent = "";
|
| 602 |
+
toast("Backup restaurado com sucesso. Recarregue as demais abas para ver os dados atualizados.");
|
| 603 |
+
carregarBackupsAutomaticos();
|
| 604 |
+
} catch (err) {
|
| 605 |
+
statusEl.textContent = "";
|
| 606 |
+
toast(err.message, true);
|
| 607 |
+
} finally {
|
| 608 |
+
btn.disabled = false;
|
| 609 |
+
}
|
| 610 |
+
});
|
static/js/admin_midias.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* SK Imobiliária — janela dedicada de gerenciamento de mídias (aberta a partir do painel admin) */
|
| 2 |
+
|
| 3 |
+
const anuncioId = Number(document.currentScript.dataset.anuncioId);
|
| 4 |
+
|
| 5 |
+
function tratarErro(err) {
|
| 6 |
+
if (err && err.status === 401) {
|
| 7 |
+
window.location.href = "/admin/login?expirado=1";
|
| 8 |
+
return;
|
| 9 |
+
}
|
| 10 |
+
toast(err.message, true);
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
async function carregarMidias() {
|
| 14 |
+
const grid = document.getElementById("midia-grid");
|
| 15 |
+
try {
|
| 16 |
+
const a = await apiGet(`/${anuncioId}`);
|
| 17 |
+
const midias = parseMidias(a.midias);
|
| 18 |
+
if (!midias.length) {
|
| 19 |
+
grid.innerHTML = `<p class="muted" style="grid-column:1/-1">Nenhuma mídia enviada ainda.</p>`;
|
| 20 |
+
return;
|
| 21 |
+
}
|
| 22 |
+
grid.innerHTML = midias.map((url) => `
|
| 23 |
+
<div class="midia-item">
|
| 24 |
+
${mediaThumbTag(url)}
|
| 25 |
+
<button type="button" class="midia-remove" data-url="${escapeHtml(url)}" title="Excluir mídia" aria-label="Excluir mídia">✕</button>
|
| 26 |
+
</div>`).join("");
|
| 27 |
+
} catch (err) {
|
| 28 |
+
grid.innerHTML = `<p class="muted" style="grid-column:1/-1">Erro ao carregar mídias: ${escapeHtml(err.message)}</p>`;
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
document.getElementById("btn-upload-midia").addEventListener("click", async () => {
|
| 33 |
+
const input = document.getElementById("midia-arquivos");
|
| 34 |
+
if (!input.files.length) { toast("Selecione ao menos um arquivo.", true); return; }
|
| 35 |
+
|
| 36 |
+
const fd = new FormData();
|
| 37 |
+
Array.from(input.files).forEach((f) => fd.append("arquivos", f));
|
| 38 |
+
|
| 39 |
+
const statusEl = document.getElementById("midia-upload-status");
|
| 40 |
+
statusEl.textContent = "Enviando…";
|
| 41 |
+
try {
|
| 42 |
+
const res = await fetch(`${API_BASE}/${anuncioId}/midias/upload`, { method: "POST", body: fd });
|
| 43 |
+
if (!res.ok) {
|
| 44 |
+
if (res.status === 401) { window.location.href = "/admin/login?expirado=1"; return; }
|
| 45 |
+
const d = await res.json().catch(() => ({}));
|
| 46 |
+
throw new Error(d.detail ? JSON.stringify(d.detail) : `Erro ${res.status}`);
|
| 47 |
+
}
|
| 48 |
+
input.value = "";
|
| 49 |
+
statusEl.textContent = "";
|
| 50 |
+
toast("Mídia enviada com sucesso.");
|
| 51 |
+
carregarMidias();
|
| 52 |
+
} catch (err) {
|
| 53 |
+
statusEl.textContent = "";
|
| 54 |
+
toast(err.message, true);
|
| 55 |
+
}
|
| 56 |
+
});
|
| 57 |
+
|
| 58 |
+
document.getElementById("btn-add-midia-url").addEventListener("click", async () => {
|
| 59 |
+
const input = document.getElementById("midia-url");
|
| 60 |
+
const url = input.value.trim();
|
| 61 |
+
if (!url) { toast("Cole a URL da mídia (foto, vídeo ou áudio).", true); return; }
|
| 62 |
+
|
| 63 |
+
try {
|
| 64 |
+
await apiSend("POST", `/${anuncioId}/midias/url?url=${encodeURIComponent(url)}`);
|
| 65 |
+
input.value = "";
|
| 66 |
+
toast("Mídia adicionada com sucesso.");
|
| 67 |
+
carregarMidias();
|
| 68 |
+
} catch (err) {
|
| 69 |
+
tratarErro(err);
|
| 70 |
+
}
|
| 71 |
+
});
|
| 72 |
+
|
| 73 |
+
document.getElementById("midia-grid").addEventListener("click", async (e) => {
|
| 74 |
+
const btn = e.target.closest(".midia-remove");
|
| 75 |
+
if (!btn) return;
|
| 76 |
+
if (!confirm("Excluir esta mídia? Essa ação não pode ser desfeita.")) return;
|
| 77 |
+
try {
|
| 78 |
+
await apiSend("DELETE", `/${anuncioId}/midias?url=${encodeURIComponent(btn.dataset.url)}`);
|
| 79 |
+
toast("Mídia removida.");
|
| 80 |
+
carregarMidias();
|
| 81 |
+
} catch (err) {
|
| 82 |
+
tratarErro(err);
|
| 83 |
+
}
|
| 84 |
+
});
|
| 85 |
+
|
| 86 |
+
carregarMidias();
|
static/js/api.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
| 1 |
/* SK Imobiliária — camada de acesso à API e helpers de formatação */
|
| 2 |
|
| 3 |
const API_BASE = "/api/anuncios";
|
| 4 |
-
|
|
|
|
|
|
|
| 5 |
|
| 6 |
function apiUrl(path, params = {}) {
|
| 7 |
const url = new URL(API_BASE + path, window.location.origin);
|
|
@@ -15,7 +17,9 @@ async function apiGet(path, params = {}) {
|
|
| 15 |
const res = await fetch(apiUrl(path, params));
|
| 16 |
if (!res.ok) {
|
| 17 |
const detalhe = await res.json().catch(() => ({}));
|
| 18 |
-
|
|
|
|
|
|
|
| 19 |
}
|
| 20 |
return res.json();
|
| 21 |
}
|
|
@@ -29,13 +33,19 @@ async function apiSend(method, path, body) {
|
|
| 29 |
const res = await fetch(API_BASE + path, opts);
|
| 30 |
if (!res.ok) {
|
| 31 |
const detalhe = await res.json().catch(() => ({}));
|
| 32 |
-
|
|
|
|
|
|
|
| 33 |
}
|
| 34 |
return method === "DELETE" ? null : res.json();
|
| 35 |
}
|
| 36 |
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
| 38 |
const n = Number(valor) || 0;
|
|
|
|
| 39 |
const formatted = n.toLocaleString("pt-BR", { style: "currency", currency: "BRL", maximumFractionDigits: 0 });
|
| 40 |
if (tipoNegocio === "Locação") return `${formatted}<small> /mês</small>`;
|
| 41 |
return formatted;
|
|
@@ -115,6 +125,7 @@ function urlAnuncio(a) {
|
|
| 115 |
function statusClass(status) {
|
| 116 |
const map = {
|
| 117 |
"Disponível": "status-disponivel",
|
|
|
|
| 118 |
"Reservado": "status-reservado",
|
| 119 |
"Inativo": "status-inativo",
|
| 120 |
"Vendido": "status-vendido",
|
|
@@ -155,7 +166,7 @@ function cardImovel(a) {
|
|
| 155 |
</div>
|
| 156 |
<div class="card-body">
|
| 157 |
<span class="status-pill ${statusClass(a.status)}">${a.status}</span>
|
| 158 |
-
<div class="card-price">${formatMoney(a.preco, a.tipo_negocio)}</div>
|
| 159 |
<h3 class="card-title">${escapeHtml(a.titulo)}</h3>
|
| 160 |
<div class="card-loc">📍 ${escapeHtml(a.bairro)}, ${escapeHtml(a.cidade)}</div>
|
| 161 |
<div class="card-specs">${specs.join("")}</div>
|
|
|
|
| 1 |
/* SK Imobiliária — camada de acesso à API e helpers de formatação */
|
| 2 |
|
| 3 |
const API_BASE = "/api/anuncios";
|
| 4 |
+
// Número de fallback (demo) — na prática, o número usado é o de window.SITE_CONFIG,
|
| 5 |
+
// preenchido pelo _header.html a partir das Configurações do site no painel admin.
|
| 6 |
+
const WHATSAPP_NUMERO = (window.SITE_CONFIG && window.SITE_CONFIG.whatsapp_numero) || "5541998112201";
|
| 7 |
|
| 8 |
function apiUrl(path, params = {}) {
|
| 9 |
const url = new URL(API_BASE + path, window.location.origin);
|
|
|
|
| 17 |
const res = await fetch(apiUrl(path, params));
|
| 18 |
if (!res.ok) {
|
| 19 |
const detalhe = await res.json().catch(() => ({}));
|
| 20 |
+
const err = new Error(detalhe.detail || `Erro ${res.status} ao consultar a API`);
|
| 21 |
+
err.status = res.status;
|
| 22 |
+
throw err;
|
| 23 |
}
|
| 24 |
return res.json();
|
| 25 |
}
|
|
|
|
| 33 |
const res = await fetch(API_BASE + path, opts);
|
| 34 |
if (!res.ok) {
|
| 35 |
const detalhe = await res.json().catch(() => ({}));
|
| 36 |
+
const err = new Error(detalhe.detail ? JSON.stringify(detalhe.detail) : `Erro ${res.status}`);
|
| 37 |
+
err.status = res.status;
|
| 38 |
+
throw err;
|
| 39 |
}
|
| 40 |
return method === "DELETE" ? null : res.json();
|
| 41 |
}
|
| 42 |
|
| 43 |
+
// ocultarZero=true (padrão): quando o valor não foi informado (0/vazio), não
|
| 44 |
+
// exibe nada — usado nas páginas públicas (cards, página do imóvel). O painel
|
| 45 |
+
// admin passa ocultarZero=false para continuar vendo "R$ 0" e saber que falta preencher.
|
| 46 |
+
function formatMoney(valor, tipoNegocio, ocultarZero = true) {
|
| 47 |
const n = Number(valor) || 0;
|
| 48 |
+
if (!n && ocultarZero) return "";
|
| 49 |
const formatted = n.toLocaleString("pt-BR", { style: "currency", currency: "BRL", maximumFractionDigits: 0 });
|
| 50 |
if (tipoNegocio === "Locação") return `${formatted}<small> /mês</small>`;
|
| 51 |
return formatted;
|
|
|
|
| 125 |
function statusClass(status) {
|
| 126 |
const map = {
|
| 127 |
"Disponível": "status-disponivel",
|
| 128 |
+
"Lançamento": "status-lancamento",
|
| 129 |
"Reservado": "status-reservado",
|
| 130 |
"Inativo": "status-inativo",
|
| 131 |
"Vendido": "status-vendido",
|
|
|
|
| 166 |
</div>
|
| 167 |
<div class="card-body">
|
| 168 |
<span class="status-pill ${statusClass(a.status)}">${a.status}</span>
|
| 169 |
+
${a.preco ? `<div class="card-price">${formatMoney(a.preco, a.tipo_negocio)}</div>` : ""}
|
| 170 |
<h3 class="card-title">${escapeHtml(a.titulo)}</h3>
|
| 171 |
<div class="card-loc">📍 ${escapeHtml(a.bairro)}, ${escapeHtml(a.cidade)}</div>
|
| 172 |
<div class="card-specs">${specs.join("")}</div>
|
static/js/busca.js
CHANGED
|
@@ -145,7 +145,7 @@ async function carregarResultados() {
|
|
| 145 |
|
| 146 |
const de = data.total === 0 ? 0 : data.skip + 1;
|
| 147 |
const ate = Math.min(data.skip + data.resultados.length, data.total);
|
| 148 |
-
resumo.innerHTML = `<b>${data.total}</b>
|
| 149 |
atualizarContadorBotaoAplicar(data.total);
|
| 150 |
|
| 151 |
renderFacetList("facet-tipo", data.facetas.tipos_imovel, "tipo_imovel");
|
|
|
|
| 145 |
|
| 146 |
const de = data.total === 0 ? 0 : data.skip + 1;
|
| 147 |
const ate = Math.min(data.skip + data.resultados.length, data.total);
|
| 148 |
+
resumo.innerHTML = `<b>${data.total}</b> encontrados${data.total ? ` - exibindo ${de}–${ate}` : ""}`;
|
| 149 |
atualizarContadorBotaoAplicar(data.total);
|
| 150 |
|
| 151 |
renderFacetList("facet-tipo", data.facetas.tipos_imovel, "tipo_imovel");
|
static/js/home.js
CHANGED
|
@@ -32,17 +32,12 @@ function ajustarAlturaShowcase() {
|
|
| 32 |
|
| 33 |
let _showcaseTimer = null;
|
| 34 |
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
el.innerHTML = `
|
| 42 |
-
<div class="showcase-track">${destaques.map(showcaseSlide).join("")}</div>
|
| 43 |
-
${destaques.length > 1 ? `<div class="showcase-dots">${dots}</div>` : ""}
|
| 44 |
-
${arrows}`;
|
| 45 |
-
|
| 46 |
const track = el.querySelector(".showcase-track");
|
| 47 |
const slides = Array.from(track.children);
|
| 48 |
const dotEls = Array.from(el.querySelectorAll(".showcase-dot"));
|
|
@@ -85,8 +80,40 @@ function montarShowcase(el, destaques) {
|
|
| 85 |
track.addEventListener("pointerdown", reiniciarAutoplay);
|
| 86 |
}
|
| 87 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
async function carregarDestaques() {
|
| 89 |
const el = document.getElementById("destaques-carousel");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
try {
|
| 91 |
const destaques = await apiGet("/destaques", { limite: 8 });
|
| 92 |
if (!destaques.length) {
|
|
|
|
| 32 |
|
| 33 |
let _showcaseTimer = null;
|
| 34 |
|
| 35 |
+
/* Liga a interatividade (dots, setas, autoplay, destaque do dot ativo) num
|
| 36 |
+
carrossel cuja marcação (.showcase-track/.showcase-dots/.showcase-arrow)
|
| 37 |
+
já existe no DOM — vinda do servidor (SSR, ver render_showcase_ssr em
|
| 38 |
+
app/seo.py) ou montada agora mesmo por montarShowcase() no modo de
|
| 39 |
+
fallback client-side abaixo. */
|
| 40 |
+
function ligarShowcase(el) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
const track = el.querySelector(".showcase-track");
|
| 42 |
const slides = Array.from(track.children);
|
| 43 |
const dotEls = Array.from(el.querySelectorAll(".showcase-dot"));
|
|
|
|
| 80 |
track.addEventListener("pointerdown", reiniciarAutoplay);
|
| 81 |
}
|
| 82 |
|
| 83 |
+
/* Fallback 100% client-side: usado só se por algum motivo a página chegar
|
| 84 |
+
sem o HTML pronto do servidor (ex.: cache antigo). Desenha a marcação e
|
| 85 |
+
então liga a mesma interatividade de ligarShowcase(). */
|
| 86 |
+
function montarShowcase(el, destaques) {
|
| 87 |
+
const dots = destaques.map((_, i) => `<button type="button" class="showcase-dot${i === 0 ? " active" : ""}" data-idx="${i}" aria-label="Ir para o imóvel ${i + 1}"></button>`).join("");
|
| 88 |
+
const arrows = destaques.length > 1 ? `
|
| 89 |
+
<button type="button" class="showcase-arrow prev" aria-label="Imóvel anterior">‹</button>
|
| 90 |
+
<button type="button" class="showcase-arrow next" aria-label="Próximo imóvel">›</button>` : "";
|
| 91 |
+
|
| 92 |
+
el.innerHTML = `
|
| 93 |
+
<div class="showcase-track">${destaques.map(showcaseSlide).join("")}</div>
|
| 94 |
+
${destaques.length > 1 ? `<div class="showcase-dots">${dots}</div>` : ""}
|
| 95 |
+
${arrows}`;
|
| 96 |
+
|
| 97 |
+
ligarShowcase(el);
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
async function carregarDestaques() {
|
| 101 |
const el = document.getElementById("destaques-carousel");
|
| 102 |
+
|
| 103 |
+
// Caminho principal: a página já chegou com o carrossel renderizado no
|
| 104 |
+
// servidor (ver render_showcase_ssr em app/seo.py + window.__DESTAQUES_SSR__
|
| 105 |
+
// embutido pelo index.html). Não precisamos buscar nem redesenhar nada —
|
| 106 |
+
// só ligar a interatividade em cima da marcação que já está no DOM.
|
| 107 |
+
if (Array.isArray(window.__DESTAQUES_SSR__)) {
|
| 108 |
+
if (window.__DESTAQUES_SSR__.length && el.querySelector(".showcase-track")) {
|
| 109 |
+
ligarShowcase(el);
|
| 110 |
+
}
|
| 111 |
+
ajustarAlturaShowcase();
|
| 112 |
+
return;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
// Fallback: página sem os dados embutidos pelo servidor (ex.: HTML em cache
|
| 116 |
+
// de uma versão anterior) — busca e monta via API, como antes.
|
| 117 |
try {
|
| 118 |
const destaques = await apiGet("/destaques", { limite: 8 });
|
| 119 |
if (!destaques.length) {
|
static/js/imovel.js
CHANGED
|
@@ -103,8 +103,10 @@ async function renderizarImovelClientSide(id) {
|
|
| 103 |
</div>
|
| 104 |
|
| 105 |
<aside class="contact-card reveal reveal-right">
|
| 106 |
-
|
| 107 |
-
|
|
|
|
|
|
|
| 108 |
|
| 109 |
<div class="broker">
|
| 110 |
<div class="avatar">${iniciais(a.corretor_responsavel)}</div>
|
|
|
|
| 103 |
</div>
|
| 104 |
|
| 105 |
<aside class="contact-card reveal reveal-right">
|
| 106 |
+
${a.preco
|
| 107 |
+
? `<div class="price">${formatMoney(a.preco, a.tipo_negocio)}</div>
|
| 108 |
+
<p class="form-note">${a.tipo_negocio === "Locação" ? "Valor mensal, sem condomínio e IPTU" : "Valor de venda à vista, sujeito a condições de financiamento"}</p>`
|
| 109 |
+
: `<p class="form-note">Valor sob consulta — fale com o corretor.</p>`}
|
| 110 |
|
| 111 |
<div class="broker">
|
| 112 |
<div class="avatar">${iniciais(a.corretor_responsavel)}</div>
|
{static → templates}/404.html
RENAMED
|
@@ -7,16 +7,10 @@
|
|
| 7 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@500;600&display=swap" rel="stylesheet">
|
| 9 |
<link rel="icon" type="image/svg+xml" href="/img/logo-sk.svg">
|
| 10 |
-
<link rel="stylesheet" href="/css/style.css?v=
|
| 11 |
</head>
|
| 12 |
<body>
|
| 13 |
-
|
| 14 |
-
<nav class="nav">
|
| 15 |
-
<a class="brand" href="/">
|
| 16 |
-
<img src="/img/logo-sk.svg" alt="SK Imobiliária" class="brand-mark" height="38">
|
| 17 |
-
</a>
|
| 18 |
-
</nav>
|
| 19 |
-
</header>
|
| 20 |
<section class="container text-center" style="padding:120px 24px">
|
| 21 |
<span class="eyebrow" style="justify-content:center">Erro 404</span>
|
| 22 |
<h1>Essa maré não trouxe essa página</h1>
|
|
|
|
| 7 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@500;600&display=swap" rel="stylesheet">
|
| 9 |
<link rel="icon" type="image/svg+xml" href="/img/logo-sk.svg">
|
| 10 |
+
<link rel="stylesheet" href="/css/style.css?v=8">
|
| 11 |
</head>
|
| 12 |
<body>
|
| 13 |
+
{% include "_header.html" %}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
<section class="container text-center" style="padding:120px 24px">
|
| 15 |
<span class="eyebrow" style="justify-content:center">Erro 404</span>
|
| 16 |
<h1>Essa maré não trouxe essa página</h1>
|
templates/_footer.html
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
<div class="container">
|
| 3 |
<div class="footer-grid">
|
| 4 |
<div>
|
| 5 |
-
<
|
| 6 |
<p class="muted" style="font-size:.86rem">Venda, locação e gestão de negócios imobiliários no litoral do Paraná.</p>
|
| 7 |
</div>
|
| 8 |
<div>
|
|
@@ -25,16 +25,14 @@
|
|
| 25 |
<div>
|
| 26 |
<h4>Contato</h4>
|
| 27 |
<ul>
|
| 28 |
-
<li>
|
| 29 |
-
<li>(41) 99811-2201</li>
|
| 30 |
-
<li>contato@skimobiliaria.com.br</li>
|
| 31 |
<li><a href="/admin.html">Área do corretor →</a></li>
|
| 32 |
</ul>
|
| 33 |
</div>
|
| 34 |
</div>
|
| 35 |
<div class="footer-bottom">
|
| 36 |
-
<span>© <span data-year></span>
|
| 37 |
-
<
|
| 38 |
</div>
|
| 39 |
</div>
|
| 40 |
</footer>
|
|
|
|
| 2 |
<div class="container">
|
| 3 |
<div class="footer-grid">
|
| 4 |
<div>
|
| 5 |
+
<h4>{{ site_config().nome_empresa | upper }}</h4>
|
| 6 |
<p class="muted" style="font-size:.86rem">Venda, locação e gestão de negócios imobiliários no litoral do Paraná.</p>
|
| 7 |
</div>
|
| 8 |
<div>
|
|
|
|
| 25 |
<div>
|
| 26 |
<h4>Contato</h4>
|
| 27 |
<ul>
|
| 28 |
+
<li>{{ site_config().email }}</li>
|
|
|
|
|
|
|
| 29 |
<li><a href="/admin.html">Área do corretor →</a></li>
|
| 30 |
</ul>
|
| 31 |
</div>
|
| 32 |
</div>
|
| 33 |
<div class="footer-bottom">
|
| 34 |
+
<span>© <span data-year></span> {{ site_config().nome_empresa }} · {{ site_config().creci }} · {{ site_config().responsavel }}</span>
|
| 35 |
+
<a href="/sitemap.xml">Mapa do site</a>
|
| 36 |
</div>
|
| 37 |
</div>
|
| 38 |
</footer>
|
templates/_header.html
CHANGED
|
@@ -13,16 +13,16 @@
|
|
| 13 |
<li><a href="/sobre.html">Sobre</a></li>
|
| 14 |
<li><a href="/contato.html">Contato</a></li>
|
| 15 |
</ul>
|
| 16 |
-
<a class="nav-cta nav-whats" href="https://wa.me/
|
| 17 |
</nav>
|
| 18 |
</header>
|
| 19 |
|
|
|
|
|
|
|
| 20 |
<section class="quick-search-section">
|
| 21 |
<form class="quick-search" action="/busca" method="get">
|
| 22 |
<input type="search" name="busca" placeholder="Buscar por título, bairro ou endereço…" aria-label="Buscar imóveis">
|
| 23 |
-
<button type="submit" id="f-busca" class="quick-search-icon"
|
| 24 |
-
style="background:none; border:none; outline:none; box-shadow:none;"
|
| 25 |
-
aria-label="Buscar">
|
| 26 |
<svg class="quick-search-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="7"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
|
| 27 |
</button>
|
| 28 |
</form>
|
|
|
|
| 13 |
<li><a href="/sobre.html">Sobre</a></li>
|
| 14 |
<li><a href="/contato.html">Contato</a></li>
|
| 15 |
</ul>
|
| 16 |
+
<a class="nav-cta nav-whats" href="https://wa.me/{{ site_config().whatsapp_numero }}?text={{ site_config().whatsapp_texto_padrao | urlencode }}" target="_blank" rel="noopener" aria-label="Falar no WhatsApp: {{ site_config().telefone_exibicao }}"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M17.47 14.38c-.3-.15-1.76-.87-2.03-.97-.27-.1-.47-.15-.67.15-.2.3-.77.97-.94 1.16-.17.2-.35.22-.64.08-.3-.15-1.26-.46-2.39-1.48-.88-.79-1.48-1.76-1.65-2.06-.17-.3-.02-.46.13-.61.13-.13.3-.35.45-.52.15-.17.2-.3.3-.5.1-.2.05-.37-.02-.52-.08-.15-.67-1.61-.92-2.21-.24-.58-.49-.5-.67-.51-.17-.01-.37-.01-.57-.01-.2 0-.52.07-.79.37-.27.3-1.04 1.02-1.04 2.48s1.07 2.87 1.21 3.07c.15.2 2.1 3.2 5.08 4.49.71.31 1.26.49 1.69.62.71.23 1.36.2 1.87.12.57-.09 1.76-.72 2.01-1.41.25-.7.25-1.29.17-1.41-.07-.13-.27-.2-.57-.35z"/><path d="M12.03 2C6.51 2 2 6.48 2 12c0 1.82.48 3.58 1.4 5.12L2 22l5.03-1.32A9.96 9.96 0 0 0 12.03 22C17.55 22 22 17.52 22 12S17.55 2 12.03 2zm0 18.09c-1.66 0-3.28-.44-4.7-1.28l-.34-.2-2.98.78.8-2.91-.22-.3A8.08 8.08 0 0 1 3.93 12c0-4.46 3.64-8.09 8.1-8.09 4.46 0 8.09 3.63 8.09 8.09 0 4.46-3.63 8.09-8.09 8.09z"/></svg><span>{{ site_config().telefone_exibicao }}</span></a>
|
| 17 |
</nav>
|
| 18 |
</header>
|
| 19 |
|
| 20 |
+
<script>window.SITE_CONFIG = {{ site_config() | tojson }};</script>
|
| 21 |
+
|
| 22 |
<section class="quick-search-section">
|
| 23 |
<form class="quick-search" action="/busca" method="get">
|
| 24 |
<input type="search" name="busca" placeholder="Buscar por título, bairro ou endereço…" aria-label="Buscar imóveis">
|
| 25 |
+
<button type="submit" id="f-busca" class="quick-search-icon" aria-label="Buscar">
|
|
|
|
|
|
|
| 26 |
<svg class="quick-search-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="7"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
|
| 27 |
</button>
|
| 28 |
</form>
|
templates/admin.html
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="pt-BR">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Área do corretor — SK Imobiliária</title>
|
| 7 |
+
<meta name="robots" content="noindex">
|
| 8 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@500;600&display=swap" rel="stylesheet">
|
| 10 |
+
<link rel="icon" type="image/svg+xml" href="/img/logo-sk.svg">
|
| 11 |
+
<link rel="stylesheet" href="/css/style.css?v=8">
|
| 12 |
+
</head>
|
| 13 |
+
<body>
|
| 14 |
+
|
| 15 |
+
{% include "_header.html" %}
|
| 16 |
+
<section class="page-hero" style="padding-bottom:24px">
|
| 17 |
+
<div class="container">
|
| 18 |
+
<span class="eyebrow reveal">Área do corretor</span>
|
| 19 |
+
<h1 class="reveal" style="--i:1">Gerenciar anúncios</h1>
|
| 20 |
+
<p class="reveal" style="--i:2">Painel para criar, editar, destacar e remover anúncios diretamente na planilha que alimenta o site.</p>
|
| 21 |
+
<a class="admin-logout-link reveal" style="--i:3" href="/admin/logout">Sair do painel →</a>
|
| 22 |
+
</div>
|
| 23 |
+
</section>
|
| 24 |
+
|
| 25 |
+
<section class="container">
|
| 26 |
+
<div class="admin-tabs" role="tablist">
|
| 27 |
+
<button type="button" class="admin-tab is-active" data-tab="adicionar" role="tab" aria-selected="true">Adicionar anúncio</button>
|
| 28 |
+
<button type="button" class="admin-tab" data-tab="editar" role="tab" aria-selected="false">Editar anúncios</button>
|
| 29 |
+
<button type="button" class="admin-tab" data-tab="destaques" role="tab" aria-selected="false">Selecionar destaques</button>
|
| 30 |
+
<button type="button" class="admin-tab" data-tab="config" role="tab" aria-selected="false">Configurações do site</button>
|
| 31 |
+
<button type="button" class="admin-tab" data-tab="paginas" role="tab" aria-selected="false">Editar páginas HTML</button>
|
| 32 |
+
<button type="button" class="admin-tab" data-tab="backup" role="tab" aria-selected="false">Backup e restauração</button>
|
| 33 |
+
<a class="btn btn-outline admin-tabs-extra" href="/api/anuncios/export/planilha">Baixar planilha (.xlsx)</a>
|
| 34 |
+
</div>
|
| 35 |
+
|
| 36 |
+
<!-- ==================== Aba: Adicionar anúncio (também usada para editar) ==================== -->
|
| 37 |
+
<div class="admin-panel" id="painel-adicionar" data-panel="adicionar">
|
| 38 |
+
<div class="section-head">
|
| 39 |
+
<div>
|
| 40 |
+
<h2 class="mt-0" id="form-heading">Novo anúncio</h2>
|
| 41 |
+
<p>Os campos seguem exatamente o que a API espera — nada de retrabalho na planilha.</p>
|
| 42 |
+
</div>
|
| 43 |
+
</div>
|
| 44 |
+
|
| 45 |
+
<form class="form-card" id="form-anuncio">
|
| 46 |
+
<input type="hidden" id="a-id">
|
| 47 |
+
<div class="form-grid">
|
| 48 |
+
<div class="form-field">
|
| 49 |
+
<label for="a-tipo-negocio">Tipo de negócio</label>
|
| 50 |
+
<select id="a-tipo-negocio" required>
|
| 51 |
+
<option value="Venda">Venda</option>
|
| 52 |
+
<option value="Locação">Locação</option>
|
| 53 |
+
</select>
|
| 54 |
+
</div>
|
| 55 |
+
<div class="form-field">
|
| 56 |
+
<label for="a-tipo-imovel">Tipo de imóvel</label>
|
| 57 |
+
<select id="a-tipo-imovel" required>
|
| 58 |
+
<option>Casa</option><option>Apartamento</option><option>Terreno</option>
|
| 59 |
+
<option>Comercial</option><option>Sala Comercial</option><option>Galpão</option>
|
| 60 |
+
<option>Chácara/Sítio</option><option>Cobertura</option><option>Kitnet/Studio</option>
|
| 61 |
+
</select>
|
| 62 |
+
</div>
|
| 63 |
+
</div>
|
| 64 |
+
|
| 65 |
+
<div class="form-field">
|
| 66 |
+
<label for="a-titulo">Título</label>
|
| 67 |
+
<input type="text" id="a-titulo" required minlength="3" maxlength="150" placeholder="Ex.: Apartamento 2 quartos vista mar - Caiobá">
|
| 68 |
+
</div>
|
| 69 |
+
<div class="form-field">
|
| 70 |
+
<label for="a-descricao">Descrição</label>
|
| 71 |
+
<textarea id="a-descricao" maxlength="3000"></textarea>
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
<div class="form-grid">
|
| 75 |
+
<div class="form-field">
|
| 76 |
+
<label for="a-endereco">Endereço</label>
|
| 77 |
+
<input type="text" id="a-endereco">
|
| 78 |
+
</div>
|
| 79 |
+
<div class="form-field">
|
| 80 |
+
<label for="a-bairro">Bairro</label>
|
| 81 |
+
<input type="text" id="a-bairro">
|
| 82 |
+
</div>
|
| 83 |
+
<div class="form-field">
|
| 84 |
+
<label for="a-cidade">Cidade</label>
|
| 85 |
+
<input type="text" id="a-cidade" required list="cidades-litoral">
|
| 86 |
+
<datalist id="cidades-litoral">
|
| 87 |
+
<option value="Matinhos"><option value="Guaratuba"><option value="Pontal do Paraná">
|
| 88 |
+
</datalist>
|
| 89 |
+
</div>
|
| 90 |
+
<div class="form-field">
|
| 91 |
+
<label for="a-estado">UF</label>
|
| 92 |
+
<input type="text" id="a-estado" required maxlength="2" value="PR">
|
| 93 |
+
</div>
|
| 94 |
+
<div class="form-field">
|
| 95 |
+
<label for="a-cep">CEP</label>
|
| 96 |
+
<input type="text" id="a-cep">
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
|
| 100 |
+
<div class="form-grid">
|
| 101 |
+
<div class="form-field">
|
| 102 |
+
<label for="a-preco">Preço (R$)</label>
|
| 103 |
+
<input type="number" id="a-preco" required min="0" step="0.01">
|
| 104 |
+
</div>
|
| 105 |
+
<div class="form-field">
|
| 106 |
+
<label for="a-condominio">Condomínio (R$)</label>
|
| 107 |
+
<input type="number" id="a-condominio" min="0" step="0.01" value="0">
|
| 108 |
+
</div>
|
| 109 |
+
<div class="form-field">
|
| 110 |
+
<label for="a-iptu">IPTU (R$)</label>
|
| 111 |
+
<input type="number" id="a-iptu" min="0" step="0.01" value="0">
|
| 112 |
+
</div>
|
| 113 |
+
<div class="form-field">
|
| 114 |
+
<label for="a-area-util">Área útil (m²)</label>
|
| 115 |
+
<input type="number" id="a-area-util" min="0" step="0.01" value="0">
|
| 116 |
+
</div>
|
| 117 |
+
<div class="form-field">
|
| 118 |
+
<label for="a-area-total">Área total (m²)</label>
|
| 119 |
+
<input type="number" id="a-area-total" min="0" step="0.01" value="0">
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
|
| 123 |
+
<div class="form-grid">
|
| 124 |
+
<div class="form-field">
|
| 125 |
+
<label for="a-quartos">Quartos</label>
|
| 126 |
+
<input type="number" id="a-quartos" min="0" value="0">
|
| 127 |
+
</div>
|
| 128 |
+
<div class="form-field">
|
| 129 |
+
<label for="a-suites">Suítes</label>
|
| 130 |
+
<input type="number" id="a-suites" min="0" value="0">
|
| 131 |
+
</div>
|
| 132 |
+
<div class="form-field">
|
| 133 |
+
<label for="a-banheiros">Banheiros</label>
|
| 134 |
+
<input type="number" id="a-banheiros" min="0" value="0">
|
| 135 |
+
</div>
|
| 136 |
+
<div class="form-field">
|
| 137 |
+
<label for="a-vagas">Vagas de garagem</label>
|
| 138 |
+
<input type="number" id="a-vagas" min="0" value="0">
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
|
| 142 |
+
<div class="form-grid">
|
| 143 |
+
<div class="form-field">
|
| 144 |
+
<label for="a-status">Status</label>
|
| 145 |
+
<select id="a-status">
|
| 146 |
+
<option>Disponível</option><option>Lançamento</option><option>Reservado</option><option>Vendido</option>
|
| 147 |
+
<option>Alugado</option><option>Inativo</option>
|
| 148 |
+
</select>
|
| 149 |
+
</div>
|
| 150 |
+
<div class="form-field">
|
| 151 |
+
<label for="a-corretor">Corretor responsável</label>
|
| 152 |
+
<input type="text" id="a-corretor">
|
| 153 |
+
</div>
|
| 154 |
+
<div class="form-field">
|
| 155 |
+
<label for="a-telefone">Telefone de contato</label>
|
| 156 |
+
<input type="text" id="a-telefone">
|
| 157 |
+
</div>
|
| 158 |
+
<div class="form-field" style="justify-content:center">
|
| 159 |
+
<label><input type="checkbox" id="a-destaque"> Marcar como destaque</label>
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
|
| 163 |
+
<div style="display:flex;gap:12px;margin-top:8px">
|
| 164 |
+
<button class="btn btn-primary" type="submit" id="btn-salvar">Salvar anúncio</button>
|
| 165 |
+
<button class="btn btn-outline" type="button" id="btn-cancelar" style="display:none">Cancelar edição</button>
|
| 166 |
+
</div>
|
| 167 |
+
</form>
|
| 168 |
+
|
| 169 |
+
<div class="section-head" style="margin-top:44px">
|
| 170 |
+
<div>
|
| 171 |
+
<h2 class="mt-0">Mídias do anúncio</h2>
|
| 172 |
+
<p class="muted" id="midia-hint">Salve um anúncio acima para liberar o gerenciamento de fotos, vídeos e áudios.</p>
|
| 173 |
+
</div>
|
| 174 |
+
<button type="button" class="btn btn-primary" id="btn-midia-nova-janela" style="display:none">Gerenciar mídias deste anúncio →</button>
|
| 175 |
+
</div>
|
| 176 |
+
<p class="muted" style="margin-top:-10px;font-size:.82rem">
|
| 177 |
+
O envio, adição por URL e exclusão de fotos, vídeos e áudios é feito numa página própria (não é um pop-up), para não misturar com os dados do anúncio.
|
| 178 |
+
</p>
|
| 179 |
+
</div>
|
| 180 |
+
|
| 181 |
+
<!-- ==================== Aba: Editar anúncios (lista) ==================== -->
|
| 182 |
+
<div class="admin-panel" id="painel-editar" data-panel="editar" hidden>
|
| 183 |
+
<div class="section-head">
|
| 184 |
+
<div>
|
| 185 |
+
<h2 class="mt-0">Anúncios cadastrados</h2>
|
| 186 |
+
<p id="admin-total" class="muted">carregando…</p>
|
| 187 |
+
</div>
|
| 188 |
+
</div>
|
| 189 |
+
<div style="overflow-x:auto">
|
| 190 |
+
<table class="table-admin">
|
| 191 |
+
<thead>
|
| 192 |
+
<tr><th>#</th><th>Título</th><th>Negócio</th><th>Cidade</th><th>Preço</th><th>Status</th><th>Mídias</th><th>Ações</th></tr>
|
| 193 |
+
</thead>
|
| 194 |
+
<tbody id="admin-tbody">
|
| 195 |
+
<tr><td colspan="8" class="muted">Carregando anúncios…</td></tr>
|
| 196 |
+
</tbody>
|
| 197 |
+
</table>
|
| 198 |
+
</div>
|
| 199 |
+
</div>
|
| 200 |
+
|
| 201 |
+
<!-- ==================== Aba: Selecionar destaques ==================== -->
|
| 202 |
+
<div class="admin-panel" id="painel-destaques" data-panel="destaques" hidden>
|
| 203 |
+
<div class="section-head">
|
| 204 |
+
<div>
|
| 205 |
+
<h2 class="mt-0">Selecionar destaques</h2>
|
| 206 |
+
<p class="muted">Ative para exibir o imóvel na vitrine/carrossel de destaques do site. A alteração é salva na hora.</p>
|
| 207 |
+
</div>
|
| 208 |
+
</div>
|
| 209 |
+
<div id="destaques-lista" class="destaques-lista">
|
| 210 |
+
<p class="muted">Carregando anúncios…</p>
|
| 211 |
+
</div>
|
| 212 |
+
</div>
|
| 213 |
+
|
| 214 |
+
<!-- ==================== Aba: Configurações do site ==================== -->
|
| 215 |
+
<div class="admin-panel" id="painel-config" data-panel="config" hidden>
|
| 216 |
+
<div class="section-head">
|
| 217 |
+
<div>
|
| 218 |
+
<h2 class="mt-0">Configurações do site</h2>
|
| 219 |
+
<p class="muted">Dados de contato usados no cabeçalho, rodapé, página de contato e nos links de WhatsApp de todo o site.</p>
|
| 220 |
+
</div>
|
| 221 |
+
</div>
|
| 222 |
+
|
| 223 |
+
<form class="form-card" id="form-config">
|
| 224 |
+
<div class="form-grid">
|
| 225 |
+
<div class="form-field">
|
| 226 |
+
<label for="cfg-nome-empresa">Nome da empresa</label>
|
| 227 |
+
<input type="text" id="cfg-nome-empresa" required>
|
| 228 |
+
</div>
|
| 229 |
+
<div class="form-field">
|
| 230 |
+
<label for="cfg-responsavel">Responsável / corretor(a) principal</label>
|
| 231 |
+
<input type="text" id="cfg-responsavel">
|
| 232 |
+
</div>
|
| 233 |
+
<div class="form-field">
|
| 234 |
+
<label for="cfg-creci">CRECI</label>
|
| 235 |
+
<input type="text" id="cfg-creci">
|
| 236 |
+
</div>
|
| 237 |
+
</div>
|
| 238 |
+
|
| 239 |
+
<div class="form-grid">
|
| 240 |
+
<div class="form-field">
|
| 241 |
+
<label for="cfg-whatsapp-numero">Número do WhatsApp (só dígitos, com DDI+DDD)</label>
|
| 242 |
+
<input type="text" id="cfg-whatsapp-numero" required placeholder="5541998112201">
|
| 243 |
+
</div>
|
| 244 |
+
<div class="form-field">
|
| 245 |
+
<label for="cfg-telefone-exibicao">Telefone (formatado, para exibir no site)</label>
|
| 246 |
+
<input type="text" id="cfg-telefone-exibicao" required placeholder="(41) 99811-2201">
|
| 247 |
+
</div>
|
| 248 |
+
<div class="form-field">
|
| 249 |
+
<label for="cfg-email">E-mail de contato</label>
|
| 250 |
+
<input type="email" id="cfg-email" required>
|
| 251 |
+
</div>
|
| 252 |
+
</div>
|
| 253 |
+
|
| 254 |
+
<div class="form-field">
|
| 255 |
+
<label for="cfg-whatsapp-texto">Mensagem padrão do botão de WhatsApp do site</label>
|
| 256 |
+
<textarea id="cfg-whatsapp-texto" maxlength="300"></textarea>
|
| 257 |
+
</div>
|
| 258 |
+
|
| 259 |
+
<div class="form-grid">
|
| 260 |
+
<div class="form-field">
|
| 261 |
+
<label for="cfg-endereco">Endereço</label>
|
| 262 |
+
<input type="text" id="cfg-endereco">
|
| 263 |
+
</div>
|
| 264 |
+
<div class="form-field">
|
| 265 |
+
<label for="cfg-horario">Horário de atendimento</label>
|
| 266 |
+
<input type="text" id="cfg-horario" placeholder="Seg–sáb, 9h às 18h">
|
| 267 |
+
</div>
|
| 268 |
+
</div>
|
| 269 |
+
|
| 270 |
+
<button class="btn btn-primary" type="submit" id="btn-salvar-config">Salvar configurações</button>
|
| 271 |
+
</form>
|
| 272 |
+
</div>
|
| 273 |
+
|
| 274 |
+
<!-- ==================== Aba: Editar páginas HTML ==================== -->
|
| 275 |
+
<div class="admin-panel" id="painel-paginas" data-panel="paginas" hidden>
|
| 276 |
+
<div class="section-head">
|
| 277 |
+
<div>
|
| 278 |
+
<h2 class="mt-0">Editar páginas HTML</h2>
|
| 279 |
+
<p class="muted">Edite o HTML das páginas institucionais direto por aqui. Cuidado com as marcações entre <code>{% raw %}{{ }}{% endraw %}</code> e <code>{% raw %}{% %}{% endraw %}</code> — elas são geradas pelo sistema; removê-las ou digitar errado pode quebrar a página para os visitantes. Uma cópia da versão anterior é guardada automaticamente a cada salvamento.</p>
|
| 280 |
+
</div>
|
| 281 |
+
</div>
|
| 282 |
+
|
| 283 |
+
<div class="form-card">
|
| 284 |
+
<div class="form-grid" style="margin-bottom:18px">
|
| 285 |
+
<div class="form-field">
|
| 286 |
+
<label for="pg-select">Página</label>
|
| 287 |
+
<select id="pg-select"></select>
|
| 288 |
+
</div>
|
| 289 |
+
<div class="form-field" style="justify-content:flex-end">
|
| 290 |
+
<a class="btn btn-outline" id="pg-ver-link" href="#" target="_blank" rel="noopener">Ver página no site ↗</a>
|
| 291 |
+
</div>
|
| 292 |
+
</div>
|
| 293 |
+
<p class="muted" id="pg-descricao" style="margin-top:-10px;margin-bottom:14px;font-size:.82rem"></p>
|
| 294 |
+
|
| 295 |
+
<div class="pg-mode-tabs">
|
| 296 |
+
<button type="button" class="pg-mode-tab is-active" id="pg-modo-visual" data-modo="visual">✏️ Visual</button>
|
| 297 |
+
<button type="button" class="pg-mode-tab" id="pg-modo-codigo" data-modo="codigo"></> Código HTML</button>
|
| 298 |
+
</div>
|
| 299 |
+
|
| 300 |
+
<div id="pg-bloco-visual">
|
| 301 |
+
<div class="pg-toolbar" id="pg-toolbar">
|
| 302 |
+
<button type="button" data-cmd="bold" title="Negrito"><strong>B</strong></button>
|
| 303 |
+
<button type="button" data-cmd="italic" title="Itálico"><em>I</em></button>
|
| 304 |
+
<button type="button" data-cmd="underline" title="Sublinhado"><u>U</u></button>
|
| 305 |
+
<span class="pg-tb-sep"></span>
|
| 306 |
+
<button type="button" data-block="H1" title="Título 1">H1</button>
|
| 307 |
+
<button type="button" data-block="H2" title="Título 2">H2</button>
|
| 308 |
+
<button type="button" data-block="H3" title="Título 3">H3</button>
|
| 309 |
+
<button type="button" data-block="P" title="Parágrafo">¶</button>
|
| 310 |
+
<span class="pg-tb-sep"></span>
|
| 311 |
+
<button type="button" data-cmd="insertUnorderedList" title="Lista com marcadores">• Lista</button>
|
| 312 |
+
<button type="button" data-cmd="insertOrderedList" title="Lista numerada">1. Lista</button>
|
| 313 |
+
<span class="pg-tb-sep"></span>
|
| 314 |
+
<button type="button" id="pg-tb-link" title="Inserir link">🔗 Link</button>
|
| 315 |
+
<button type="button" data-cmd="unlink" title="Remover link">🔗✕</button>
|
| 316 |
+
<button type="button" id="pg-tb-img" title="Inserir imagem por URL">🖼️ Imagem</button>
|
| 317 |
+
<span class="pg-tb-sep"></span>
|
| 318 |
+
<button type="button" data-cmd="undo" title="Desfazer">↶</button>
|
| 319 |
+
<button type="button" data-cmd="redo" title="Refazer">↷</button>
|
| 320 |
+
</div>
|
| 321 |
+
<div id="pg-visual" class="pg-visual-editor" contenteditable="true"></div>
|
| 322 |
+
<p class="muted" style="font-size:.78rem;margin-top:8px">
|
| 323 |
+
Os trechos com fundo destacado (🔒) são gerados pelo sistema (cabeçalho, rodapé, scripts) e não podem ser editados aqui — dá pra mover ou apagar o bloco inteiro, mas não editar o conteúdo dele. Para mexer neles, use o modo "Código HTML".
|
| 324 |
+
</p>
|
| 325 |
+
</div>
|
| 326 |
+
|
| 327 |
+
<textarea id="pg-conteudo" spellcheck="false" hidden style="width:100%;min-height:480px;font-family:'IBM Plex Mono',monospace;font-size:.82rem;line-height:1.5;padding:16px;border-radius:10px;border:1px solid rgba(27,75,79,.2);resize:vertical"></textarea>
|
| 328 |
+
|
| 329 |
+
<div style="display:flex;gap:12px;margin-top:14px;align-items:center">
|
| 330 |
+
<button class="btn btn-primary" type="button" id="btn-salvar-pagina">Salvar página</button>
|
| 331 |
+
<span class="form-note" id="pg-status" style="margin-top:0"></span>
|
| 332 |
+
</div>
|
| 333 |
+
</div>
|
| 334 |
+
</div>
|
| 335 |
+
|
| 336 |
+
<!-- ==================== Aba: Backup e restauração ==================== -->
|
| 337 |
+
<div class="admin-panel" id="painel-backup" data-panel="backup" hidden>
|
| 338 |
+
<div class="section-head">
|
| 339 |
+
<div>
|
| 340 |
+
<h2 class="mt-0">Backup e restauração</h2>
|
| 341 |
+
<p class="muted">Baixe uma cópia completa do site (planilha de anúncios, configurações, fotos/vídeos/áudios dos anúncios e páginas institucionais) ou restaure a partir de um backup anterior.</p>
|
| 342 |
+
</div>
|
| 343 |
+
</div>
|
| 344 |
+
|
| 345 |
+
<div class="form-card">
|
| 346 |
+
<h3 class="mt-0" style="font-size:1.05rem">Baixar backup completo</h3>
|
| 347 |
+
<p class="muted" style="font-size:.85rem;margin-top:-6px">
|
| 348 |
+
Gera um arquivo .zip com tudo: a planilha (data/imoveis.xlsx), as configurações do site, todas as mídias enviadas para os anúncios e o HTML das páginas editáveis. Guarde esse arquivo em local seguro.
|
| 349 |
+
</p>
|
| 350 |
+
<a class="btn btn-primary" id="btn-baixar-backup" href="/api/backup/exportar">Baixar backup (.zip)</a>
|
| 351 |
+
</div>
|
| 352 |
+
|
| 353 |
+
<div class="form-card" style="margin-top:24px">
|
| 354 |
+
<h3 class="mt-0" style="font-size:1.05rem">Restaurar a partir de um backup</h3>
|
| 355 |
+
<p class="muted" style="font-size:.85rem;margin-top:-6px">
|
| 356 |
+
⚠️ Isso substitui os dados atuais (planilha, configurações, mídias e páginas) pelos dados do arquivo enviado. Antes de restaurar, o sistema tira automaticamente um backup de segurança do estado atual — então dá pra desfazer se algo der errado.
|
| 357 |
+
</p>
|
| 358 |
+
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:12px">
|
| 359 |
+
<input type="file" id="backup-arquivo" accept=".zip,application/zip">
|
| 360 |
+
<button class="btn btn-outline" id="btn-restaurar-backup" type="button">Restaurar backup</button>
|
| 361 |
+
<span class="form-note" id="backup-restaurar-status" style="margin-top:0"></span>
|
| 362 |
+
</div>
|
| 363 |
+
</div>
|
| 364 |
+
|
| 365 |
+
<div class="form-card" style="margin-top:24px">
|
| 366 |
+
<h3 class="mt-0" style="font-size:1.05rem">Backups de segurança automáticos</h3>
|
| 367 |
+
<p class="muted" style="font-size:.85rem;margin-top:-6px">
|
| 368 |
+
Cópias tiradas automaticamente pelo sistema logo antes de cada restauração (as 8 mais recentes ficam guardadas).
|
| 369 |
+
</p>
|
| 370 |
+
<div style="overflow-x:auto">
|
| 371 |
+
<table class="table-admin">
|
| 372 |
+
<thead><tr><th>Arquivo</th><th>Criado em</th><th>Tamanho</th><th>Ações</th></tr></thead>
|
| 373 |
+
<tbody id="backup-auto-tbody"><tr><td colspan="4" class="muted">Carregando…</td></tr></tbody>
|
| 374 |
+
</table>
|
| 375 |
+
</div>
|
| 376 |
+
</div>
|
| 377 |
+
</div>
|
| 378 |
+
</section>
|
| 379 |
+
|
| 380 |
+
{% include "_footer.html" %}
|
| 381 |
+
|
| 382 |
+
<script src="/js/api.js?v=2"></script>
|
| 383 |
+
<script src="/js/main.js?v=2"></script>
|
| 384 |
+
<script src="/js/admin.js?v=4"></script>
|
| 385 |
+
</body>
|
| 386 |
+
</html>
|
templates/admin_login.html
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="pt-BR">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Entrar — Área do corretor · SK Imobiliária</title>
|
| 7 |
+
<meta name="robots" content="noindex">
|
| 8 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@500;600&display=swap" rel="stylesheet">
|
| 10 |
+
<link rel="icon" type="image/svg+xml" href="/img/logo-sk.svg">
|
| 11 |
+
<link rel="stylesheet" href="/css/style.css?v=8">
|
| 12 |
+
</head>
|
| 13 |
+
<body class="admin-login-body">
|
| 14 |
+
|
| 15 |
+
<main class="admin-login-wrap">
|
| 16 |
+
<form class="admin-login-card" method="post" action="/admin/login">
|
| 17 |
+
<img src="/img/logo-sk.svg" alt="SK Imobiliária" class="admin-login-logo">
|
| 18 |
+
<span class="eyebrow">Área do corretor</span>
|
| 19 |
+
<h1>Entrar</h1>
|
| 20 |
+
<p class="muted">Acesso restrito à equipe SK Imobiliária. Informe a senha do painel para gerenciar os anúncios.</p>
|
| 21 |
+
|
| 22 |
+
{% if erro %}
|
| 23 |
+
<p class="admin-login-alerta" role="alert">Senha incorreta. Tente novamente.</p>
|
| 24 |
+
{% elif expirado %}
|
| 25 |
+
<p class="admin-login-alerta" role="alert">Sua sessão expirou. Faça login novamente.</p>
|
| 26 |
+
{% endif %}
|
| 27 |
+
|
| 28 |
+
<div class="form-field">
|
| 29 |
+
<label for="senha">Senha</label>
|
| 30 |
+
<input type="password" id="senha" name="senha" required autofocus autocomplete="current-password">
|
| 31 |
+
</div>
|
| 32 |
+
|
| 33 |
+
<button class="btn btn-primary admin-login-btn" type="submit">Entrar</button>
|
| 34 |
+
<a class="admin-login-voltar" href="/">← Voltar para o site</a>
|
| 35 |
+
</form>
|
| 36 |
+
</main>
|
| 37 |
+
|
| 38 |
+
</body>
|
| 39 |
+
</html>
|
templates/admin_midias.html
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="pt-BR">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Mídias do anúncio #{{ a.id }} — Área do corretor — SK Imobiliária</title>
|
| 7 |
+
<meta name="robots" content="noindex">
|
| 8 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@500;600&display=swap" rel="stylesheet">
|
| 10 |
+
<link rel="icon" type="image/svg+xml" href="/img/logo-sk.svg">
|
| 11 |
+
<link rel="stylesheet" href="/css/style.css?v=8">
|
| 12 |
+
</head>
|
| 13 |
+
<body>
|
| 14 |
+
|
| 15 |
+
{% include "_header.html" %}
|
| 16 |
+
<section class="page-hero" style="padding-bottom:24px">
|
| 17 |
+
<div class="container">
|
| 18 |
+
<a class="admin-logout-link reveal" href="/admin.html">← Voltar ao painel</a>
|
| 19 |
+
<span class="eyebrow reveal" style="--i:1">Área do corretor</span>
|
| 20 |
+
<h1 class="reveal" style="--i:2">Mídias do anúncio #{{ a.id }}</h1>
|
| 21 |
+
<p class="reveal" style="--i:3">{{ a.titulo }} — arquivos ficam salvos em /data/midia/anuncios/{{ a.id }}/</p>
|
| 22 |
+
</div>
|
| 23 |
+
</section>
|
| 24 |
+
|
| 25 |
+
<section class="container" style="padding-bottom:64px">
|
| 26 |
+
<div class="form-card" id="midia-card" style="max-width:820px">
|
| 27 |
+
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:18px">
|
| 28 |
+
<input type="file" id="midia-arquivos" accept="image/*,video/*,audio/*" multiple>
|
| 29 |
+
<button class="btn btn-primary" id="btn-upload-midia" type="button">Enviar mídia</button>
|
| 30 |
+
<span class="form-note" id="midia-upload-status" style="margin-top:0"></span>
|
| 31 |
+
</div>
|
| 32 |
+
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:18px;padding-top:14px;border-top:1px dashed rgba(27,75,79,.15)">
|
| 33 |
+
<input type="url" id="midia-url" placeholder="https://.../audio-do-corretor.mp3" style="flex:1;min-width:240px">
|
| 34 |
+
<button class="btn btn-outline" id="btn-add-midia-url" type="button">Adicionar por URL</button>
|
| 35 |
+
</div>
|
| 36 |
+
<p class="muted" style="margin-top:-10px;margin-bottom:18px;font-size:.82rem">
|
| 37 |
+
Envie arquivos (fotos, vídeos ou áudios) ou cole a URL de uma mídia já hospedada em outro lugar. Tudo é salvo como uma lista de URLs em JSON no campo "midias" da planilha. Nesta página só é possível inserir ou excluir mídias — para trocar uma foto, exclua a antiga e insira a nova.
|
| 38 |
+
</p>
|
| 39 |
+
<div class="midia-grid" id="midia-grid"><p class="muted">Carregando mídias…</p></div>
|
| 40 |
+
</div>
|
| 41 |
+
</section>
|
| 42 |
+
|
| 43 |
+
{% include "_footer.html" %}
|
| 44 |
+
|
| 45 |
+
<script src="/js/api.js?v=2"></script>
|
| 46 |
+
<script src="/js/admin_midias.js?v=1" data-anuncio-id="{{ a.id }}"></script>
|
| 47 |
+
</body>
|
| 48 |
+
</html>
|
templates/busca.html
CHANGED
|
@@ -24,22 +24,14 @@
|
|
| 24 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 25 |
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@500;600&display=swap" rel="stylesheet">
|
| 26 |
<link rel="icon" type="image/svg+xml" href="/img/logo-sk.svg">
|
| 27 |
-
<link rel="stylesheet" href="/css/style.css?v=
|
| 28 |
</head>
|
| 29 |
<body>
|
| 30 |
|
| 31 |
{% include "_header.html" %}
|
| 32 |
|
| 33 |
|
| 34 |
-
<section class="
|
| 35 |
-
<div class="container reveal">
|
| 36 |
-
<span class="eyebrow eyebrow-gold">Busque seu próximo recanto</span>
|
| 37 |
-
<h1>{{ h1 }}</h1>
|
| 38 |
-
<p>{{ total }} imóve{{ 'l' if total == 1 else 'is' }} encontrados agora — refine por cidade, bairro, preço e características. Os números ao lado de cada filtro mostram quantos imóveis restam a cada escolha.</p>
|
| 39 |
-
</div>
|
| 40 |
-
</section>
|
| 41 |
-
|
| 42 |
-
<section class="container" style="padding-top:36px">
|
| 43 |
<div class="search-layout">
|
| 44 |
|
| 45 |
<div class="filters-backdrop" id="filters-backdrop"></div>
|
|
@@ -99,7 +91,7 @@
|
|
| 99 |
<button class="btn btn-outline btn-block" id="btn-limpar">Limpar filtros</button>
|
| 100 |
|
| 101 |
<div class="filters-panel-footer">
|
| 102 |
-
<button type="button" class="btn btn-primary btn-block" id="btn-aplicar-filtros">Ver <span id="btn-aplicar-total">{{ total }}</span>
|
| 103 |
</div>
|
| 104 |
</aside>
|
| 105 |
|
|
@@ -109,8 +101,8 @@
|
|
| 109 |
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M7 12h10M10 18h4"/></svg>
|
| 110 |
Filtros
|
| 111 |
</button>
|
| 112 |
-
<span class="results-count" id="results-count"><b>{{ total }}</b>
|
| 113 |
-
|
| 114 |
<select id="f-ordenar">
|
| 115 |
<option value="mais_recentes">Mais recentes</option>
|
| 116 |
<option value="preco_asc">Menor preço</option>
|
|
@@ -118,7 +110,7 @@
|
|
| 118 |
<option value="area_asc">Menor área</option>
|
| 119 |
<option value="area_desc">Maior área</option>
|
| 120 |
</select>
|
| 121 |
-
|
| 122 |
</div>
|
| 123 |
|
| 124 |
<div class="chips" id="chips-ativos"></div>
|
|
|
|
| 24 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 25 |
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@500;600&display=swap" rel="stylesheet">
|
| 26 |
<link rel="icon" type="image/svg+xml" href="/img/logo-sk.svg">
|
| 27 |
+
<link rel="stylesheet" href="/css/style.css?v=8">
|
| 28 |
</head>
|
| 29 |
<body>
|
| 30 |
|
| 31 |
{% include "_header.html" %}
|
| 32 |
|
| 33 |
|
| 34 |
+
<section class="container" style="padding-top:12px">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
<div class="search-layout">
|
| 36 |
|
| 37 |
<div class="filters-backdrop" id="filters-backdrop"></div>
|
|
|
|
| 91 |
<button class="btn btn-outline btn-block" id="btn-limpar">Limpar filtros</button>
|
| 92 |
|
| 93 |
<div class="filters-panel-footer">
|
| 94 |
+
<button type="button" class="btn btn-primary btn-block" id="btn-aplicar-filtros">Ver <span id="btn-aplicar-total">{{ total }}</span> </button>
|
| 95 |
</div>
|
| 96 |
</aside>
|
| 97 |
|
|
|
|
| 101 |
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M7 12h10M10 18h4"/></svg>
|
| 102 |
Filtros
|
| 103 |
</button>
|
| 104 |
+
<span class="results-count" id="results-count"><b>{{ total }}</b> encontrados</span>
|
| 105 |
+
|
| 106 |
<select id="f-ordenar">
|
| 107 |
<option value="mais_recentes">Mais recentes</option>
|
| 108 |
<option value="preco_asc">Menor preço</option>
|
|
|
|
| 110 |
<option value="area_asc">Menor área</option>
|
| 111 |
<option value="area_desc">Maior área</option>
|
| 112 |
</select>
|
| 113 |
+
|
| 114 |
</div>
|
| 115 |
|
| 116 |
<div class="chips" id="chips-ativos"></div>
|
{static → templates}/contato.html
RENAMED
|
@@ -8,29 +8,11 @@
|
|
| 8 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 9 |
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@500;600&display=swap" rel="stylesheet">
|
| 10 |
<link rel="icon" type="image/svg+xml" href="/img/logo-sk.svg">
|
| 11 |
-
<link rel="stylesheet" href="/css/style.css?v=
|
| 12 |
</head>
|
| 13 |
<body>
|
| 14 |
|
| 15 |
-
|
| 16 |
-
<nav class="nav">
|
| 17 |
-
<button class="nav-toggle" aria-label="Abrir menu" aria-expanded="false">
|
| 18 |
-
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="18" x2="21" y2="18"></line></svg>
|
| 19 |
-
</button>
|
| 20 |
-
<a class="brand" href="/">
|
| 21 |
-
<img src="/img/logo-sk.svg" alt="SK Imobiliária" class="brand-mark" height="38">
|
| 22 |
-
</a>
|
| 23 |
-
<ul class="nav-links">
|
| 24 |
-
<li><a href="/">Início</a></li>
|
| 25 |
-
<li><a href="/busca">Comprar & Alugar</a></li>
|
| 26 |
-
<li><a href="/servicos.html">Serviços</a></li>
|
| 27 |
-
<li><a href="/sobre.html">Sobre</a></li>
|
| 28 |
-
<li><a href="/contato.html">Contato</a></li>
|
| 29 |
-
</ul>
|
| 30 |
-
<a class="nav-cta nav-whats" href="https://wa.me/5541998112201?text=Ol%C3%A1%21%20Vim%20pelo%20site%20da%20SK%20Imobili%C3%A1ria%20e%20gostaria%20de%20falar%20com%20um%20corretor." target="_blank" rel="noopener" aria-label="Falar no WhatsApp: (41) 99811-2201"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M17.47 14.38c-.3-.15-1.76-.87-2.03-.97-.27-.1-.47-.15-.67.15-.2.3-.77.97-.94 1.16-.17.2-.35.22-.64.08-.3-.15-1.26-.46-2.39-1.48-.88-.79-1.48-1.76-1.65-2.06-.17-.3-.02-.46.13-.61.13-.13.3-.35.45-.52.15-.17.2-.3.3-.5.1-.2.05-.37-.02-.52-.08-.15-.67-1.61-.92-2.21-.24-.58-.49-.5-.67-.51-.17-.01-.37-.01-.57-.01-.2 0-.52.07-.79.37-.27.3-1.04 1.02-1.04 2.48s1.07 2.87 1.21 3.07c.15.2 2.1 3.2 5.08 4.49.71.31 1.26.49 1.69.62.71.23 1.36.2 1.87.12.57-.09 1.76-.72 2.01-1.41.25-.7.25-1.29.17-1.41-.07-.13-.27-.2-.57-.35z"/><path d="M12.03 2C6.51 2 2 6.48 2 12c0 1.82.48 3.58 1.4 5.12L2 22l5.03-1.32A9.96 9.96 0 0 0 12.03 22C17.55 22 22 17.52 22 12S17.55 2 12.03 2zm0 18.09c-1.66 0-3.28-.44-4.7-1.28l-.34-.2-2.98.78.8-2.91-.22-.3A8.08 8.08 0 0 1 3.93 12c0-4.46 3.64-8.09 8.1-8.09 4.46 0 8.09 3.63 8.09 8.09 0 4.46-3.63 8.09-8.09 8.09z"/></svg><span>(41) 99811-2201</span></a>
|
| 31 |
-
</nav>
|
| 32 |
-
</header>
|
| 33 |
-
|
| 34 |
<section class="page-hero">
|
| 35 |
<div class="container">
|
| 36 |
<span class="eyebrow reveal">Fale com a SK</span>
|
|
@@ -78,69 +60,14 @@
|
|
| 78 |
<textarea id="c-mensagem" placeholder="Conte um pouco mais sobre o que você procura…"></textarea>
|
| 79 |
</div>
|
| 80 |
<button class="btn btn-primary btn-block" type="submit">Enviar pelo WhatsApp</button>
|
| 81 |
-
<p class="form-note">Ao enviar, você será redirecionado ao WhatsApp da SK com a mensagem já preenchida.</p>
|
| 82 |
</form>
|
| 83 |
</div>
|
| 84 |
|
| 85 |
-
|
| 86 |
-
<span class="eyebrow eyebrow-gold" style="margin-top:0">Atendimento direto</span>
|
| 87 |
-
<div class="broker">
|
| 88 |
-
<div class="avatar">SK</div>
|
| 89 |
-
<div>
|
| 90 |
-
<strong>Equipe SK Imobiliária</strong><br>
|
| 91 |
-
<span class="muted" style="font-size:.82rem">Matinhos, Guaratuba e Pontal do Paraná</span>
|
| 92 |
-
</div>
|
| 93 |
-
</div>
|
| 94 |
-
<div class="divider"></div>
|
| 95 |
-
<p class="badge-mono" style="display:block;margin-bottom:10px">📍 Av. Beira Mar, 1120 — Caiobá, Matinhos/PR</p>
|
| 96 |
-
<p class="badge-mono" style="display:block;margin-bottom:10px">✉️ contato@skimobiliaria.com.br</p>
|
| 97 |
-
<p class="badge-mono" style="display:block;margin-bottom:18px">🕐 Seg–sáb, 9h às 18h</p>
|
| 98 |
-
<a class="btn btn-primary btn-block" target="_blank" rel="noopener" href="https://wa.me/5541998112201">Chamar no WhatsApp</a>
|
| 99 |
-
<a class="btn btn-outline btn-block" style="margin-top:10px" href="tel:+5541998112201">Ligar: (41) 99811-2201</a>
|
| 100 |
-
</aside>
|
| 101 |
</div>
|
| 102 |
</section>
|
| 103 |
|
| 104 |
-
|
| 105 |
-
<div class="container">
|
| 106 |
-
<div class="footer-grid">
|
| 107 |
-
<div>
|
| 108 |
-
<div class="footer-brand"><img src="/img/logo-sk.svg" alt="SK Imobiliária" height="30"></div>
|
| 109 |
-
<p class="muted" style="font-size:.86rem">Venda, locação e gestão de negócios imobiliários no litoral do Paraná.</p>
|
| 110 |
-
</div>
|
| 111 |
-
<div>
|
| 112 |
-
<h4>Navegação</h4>
|
| 113 |
-
<ul>
|
| 114 |
-
<li><a href="/busca">Comprar & Alugar</a></li>
|
| 115 |
-
<li><a href="/servicos.html">Serviços</a></li>
|
| 116 |
-
<li><a href="/sobre.html">Sobre a SK</a></li>
|
| 117 |
-
<li><a href="/contato.html">Contato</a></li>
|
| 118 |
-
</ul>
|
| 119 |
-
</div>
|
| 120 |
-
<div>
|
| 121 |
-
<h4>Cidades</h4>
|
| 122 |
-
<ul>
|
| 123 |
-
<li><a href="/busca/matinhos">Imóveis em Matinhos</a></li>
|
| 124 |
-
<li><a href="/busca/guaratuba">Imóveis em Guaratuba</a></li>
|
| 125 |
-
<li><a href="/busca/pontal-do-parana">Imóveis em Pontal do Paraná</a></li>
|
| 126 |
-
</ul>
|
| 127 |
-
</div>
|
| 128 |
-
<div>
|
| 129 |
-
<h4>Contato</h4>
|
| 130 |
-
<ul>
|
| 131 |
-
<li>Av. Beira Mar, 1120 — Caiobá, Matinhos/PR</li>
|
| 132 |
-
<li>(41) 99811-2201</li>
|
| 133 |
-
<li>contato@skimobiliaria.com.br</li>
|
| 134 |
-
<li><a href="/admin.html">Área do corretor →</a></li>
|
| 135 |
-
</ul>
|
| 136 |
-
</div>
|
| 137 |
-
</div>
|
| 138 |
-
<div class="footer-bottom">
|
| 139 |
-
<span>© <span data-year></span> SK Imobiliária · CRECI-PR 00000-J · Salete Kuhnert dos Santos</span>
|
| 140 |
-
<span>Site demonstrativo — dados fictícios</span>
|
| 141 |
-
</div>
|
| 142 |
-
</div>
|
| 143 |
-
</footer>
|
| 144 |
|
| 145 |
<script src="/js/api.js?v=2"></script>
|
| 146 |
<script src="/js/main.js?v=2"></script>
|
|
|
|
| 8 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 9 |
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@500;600&display=swap" rel="stylesheet">
|
| 10 |
<link rel="icon" type="image/svg+xml" href="/img/logo-sk.svg">
|
| 11 |
+
<link rel="stylesheet" href="/css/style.css?v=8">
|
| 12 |
</head>
|
| 13 |
<body>
|
| 14 |
|
| 15 |
+
{% include "_header.html" %}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
<section class="page-hero">
|
| 17 |
<div class="container">
|
| 18 |
<span class="eyebrow reveal">Fale com a SK</span>
|
|
|
|
| 60 |
<textarea id="c-mensagem" placeholder="Conte um pouco mais sobre o que você procura…"></textarea>
|
| 61 |
</div>
|
| 62 |
<button class="btn btn-primary btn-block" type="submit">Enviar pelo WhatsApp</button>
|
|
|
|
| 63 |
</form>
|
| 64 |
</div>
|
| 65 |
|
| 66 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
</div>
|
| 68 |
</section>
|
| 69 |
|
| 70 |
+
{% include "_footer.html" %}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
<script src="/js/api.js?v=2"></script>
|
| 73 |
<script src="/js/main.js?v=2"></script>
|
templates/imovel.html
CHANGED
|
@@ -26,14 +26,14 @@
|
|
| 26 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 27 |
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@500;600&display=swap" rel="stylesheet">
|
| 28 |
<link rel="icon" type="image/svg+xml" href="/img/logo-sk.svg">
|
| 29 |
-
<link rel="stylesheet" href="/css/style.css?v=
|
| 30 |
</head>
|
| 31 |
<body>
|
| 32 |
|
| 33 |
{% include "_header.html" %}
|
| 34 |
|
| 35 |
-
<section class="container" style="padding-top:
|
| 36 |
-
<p class="breadcrumb
|
| 37 |
<a href="/">Início</a> / <a href="/busca">Imóveis</a> /
|
| 38 |
<a href="/busca/{{ cidade_slug }}">{{ a.cidade }}</a> / <span>{{ a.titulo }}</span>
|
| 39 |
</p>
|
|
@@ -49,7 +49,7 @@
|
|
| 49 |
</div>
|
| 50 |
|
| 51 |
<div class="property-detail-grid">
|
| 52 |
-
<div class="
|
| 53 |
<span class="status-pill {{ status_class }}" style="margin-top:20px;display:inline-block">{{ a.status }}</span>
|
| 54 |
<h2 style="margin-top:16px">Sobre o imóvel</h2>
|
| 55 |
<p>{{ a.descricao or "Descrição não informada pelo corretor responsável." }}</p>
|
|
@@ -66,9 +66,13 @@
|
|
| 66 |
</p>
|
| 67 |
</div>
|
| 68 |
|
| 69 |
-
<aside class="contact-card
|
|
|
|
| 70 |
<div class="price">{{ preco_exibicao }}</div>
|
| 71 |
<p class="form-note">{{ "Valor mensal, sem condomínio e IPTU" if a.tipo_negocio == "Locação" else "Valor de venda à vista, sujeito a condições de financiamento" }}</p>
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
<div class="broker">
|
| 74 |
<div class="avatar">{{ iniciais_corretor }}</div>
|
|
@@ -84,11 +88,8 @@
|
|
| 84 |
</div>
|
| 85 |
</section>
|
| 86 |
|
| 87 |
-
<div class="container">
|
| 88 |
-
<div class="ornament-divider center-only reveal" style="margin:8px 0"><span class="diamond"></span></div>
|
| 89 |
-
</div>
|
| 90 |
|
| 91 |
-
<section class="container" id="similares-section" style="
|
| 92 |
<div class="section-head reveal">
|
| 93 |
<div>
|
| 94 |
<span class="eyebrow eyebrow-gold">Você também pode gostar</span>
|
|
|
|
| 26 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 27 |
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@500;600&display=swap" rel="stylesheet">
|
| 28 |
<link rel="icon" type="image/svg+xml" href="/img/logo-sk.svg">
|
| 29 |
+
<link rel="stylesheet" href="/css/style.css?v=8">
|
| 30 |
</head>
|
| 31 |
<body>
|
| 32 |
|
| 33 |
{% include "_header.html" %}
|
| 34 |
|
| 35 |
+
<section class="container" style="padding-top:12px" id="imovel-container">
|
| 36 |
+
<p class="breadcrumb ">
|
| 37 |
<a href="/">Início</a> / <a href="/busca">Imóveis</a> /
|
| 38 |
<a href="/busca/{{ cidade_slug }}">{{ a.cidade }}</a> / <span>{{ a.titulo }}</span>
|
| 39 |
</p>
|
|
|
|
| 49 |
</div>
|
| 50 |
|
| 51 |
<div class="property-detail-grid">
|
| 52 |
+
<div class="">
|
| 53 |
<span class="status-pill {{ status_class }}" style="margin-top:20px;display:inline-block">{{ a.status }}</span>
|
| 54 |
<h2 style="margin-top:16px">Sobre o imóvel</h2>
|
| 55 |
<p>{{ a.descricao or "Descrição não informada pelo corretor responsável." }}</p>
|
|
|
|
| 66 |
</p>
|
| 67 |
</div>
|
| 68 |
|
| 69 |
+
<aside class="contact-card ">
|
| 70 |
+
{% if preco_exibicao %}
|
| 71 |
<div class="price">{{ preco_exibicao }}</div>
|
| 72 |
<p class="form-note">{{ "Valor mensal, sem condomínio e IPTU" if a.tipo_negocio == "Locação" else "Valor de venda à vista, sujeito a condições de financiamento" }}</p>
|
| 73 |
+
{% else %}
|
| 74 |
+
<p class="form-note">Valor sob consulta — fale com o corretor.</p>
|
| 75 |
+
{% endif %}
|
| 76 |
|
| 77 |
<div class="broker">
|
| 78 |
<div class="avatar">{{ iniciais_corretor }}</div>
|
|
|
|
| 88 |
</div>
|
| 89 |
</section>
|
| 90 |
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
+
<section class="container" id="similares-section" style="padding-top:12px" >
|
| 93 |
<div class="section-head reveal">
|
| 94 |
<div>
|
| 95 |
<span class="eyebrow eyebrow-gold">Você também pode gostar</span>
|
templates/index.html
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="pt-BR">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>SK Imobiliária — Venda, locação e gestão no litoral do Paraná</title>
|
| 7 |
+
<meta name="description" content="SK Imobiliária: casas, apartamentos e terrenos à venda e locação em Matinhos, Guaratuba e Pontal do Paraná. Gestão completa do seu negócio imobiliário no litoral.">
|
| 8 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@500;600&display=swap" rel="stylesheet">
|
| 10 |
+
<link rel="icon" type="image/svg+xml" href="/img/logo-sk.svg">
|
| 11 |
+
<link rel="stylesheet" href="/css/style.css?v=8">
|
| 12 |
+
</head>
|
| 13 |
+
<body>
|
| 14 |
+
|
| 15 |
+
{% include "_header.html" %}
|
| 16 |
+
|
| 17 |
+
<section class="showcase-section">
|
| 18 |
+
<div class="container showcase-head reveal">
|
| 19 |
+
</div>
|
| 20 |
+
<div class="showcase reveal" id="destaques-carousel">{{ showcase_html | safe }}</div>
|
| 21 |
+
</section>
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
{% include "_footer.html" %}
|
| 25 |
+
|
| 26 |
+
<script>window.__DESTAQUES_SSR__ = {{ destaques_json | safe }};</script>
|
| 27 |
+
<script src="/js/api.js?v=3"></script>
|
| 28 |
+
<script src="/js/main.js?v=3"></script>
|
| 29 |
+
<script src="/js/home.js?v=5"></script>
|
| 30 |
+
</body>
|
| 31 |
+
</html>
|
{static → templates}/servicos.html
RENAMED
|
@@ -8,30 +8,11 @@
|
|
| 8 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 9 |
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@500;600&display=swap" rel="stylesheet">
|
| 10 |
<link rel="icon" type="image/svg+xml" href="/img/logo-sk.svg">
|
| 11 |
-
<link rel="stylesheet" href="/css/style.css?v=
|
| 12 |
</head>
|
| 13 |
<body>
|
| 14 |
|
| 15 |
-
|
| 16 |
-
<nav class="nav">
|
| 17 |
-
<button class="nav-toggle" aria-label="Abrir menu" aria-expanded="false">
|
| 18 |
-
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="18" x2="21" y2="18"></line></svg>
|
| 19 |
-
</button>
|
| 20 |
-
<a class="brand" href="/">
|
| 21 |
-
<img src="/img/logo-sk.svg" alt="SK Imobiliária" class="brand-mark" height="38">
|
| 22 |
-
</a>
|
| 23 |
-
<ul class="nav-links">
|
| 24 |
-
<li><a href="/">Início</a></li>
|
| 25 |
-
<li><a href="/busca">Comprar & Alugar</a></li>
|
| 26 |
-
<li><a href="/servicos.html">Serviços</a></li>
|
| 27 |
-
<li><a href="/sobre.html">Sobre</a></li>
|
| 28 |
-
<li><a href="/contato.html">Contato</a></li>
|
| 29 |
-
</ul>
|
| 30 |
-
<a class="nav-cta nav-whats" href="https://wa.me/5541998112201?text=Ol%C3%A1%21%20Vim%20pelo%20site%20da%20SK%20Imobili%C3%A1ria%20e%20gostaria%20de%20falar%20com%20um%20corretor." target="_blank" rel="noopener" aria-label="Falar no WhatsApp: (41) 99811-2201"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M17.47 14.38c-.3-.15-1.76-.87-2.03-.97-.27-.1-.47-.15-.67.15-.2.3-.77.97-.94 1.16-.17.2-.35.22-.64.08-.3-.15-1.26-.46-2.39-1.48-.88-.79-1.48-1.76-1.65-2.06-.17-.3-.02-.46.13-.61.13-.13.3-.35.45-.52.15-.17.2-.3.3-.5.1-.2.05-.37-.02-.52-.08-.15-.67-1.61-.92-2.21-.24-.58-.49-.5-.67-.51-.17-.01-.37-.01-.57-.01-.2 0-.52.07-.79.37-.27.3-1.04 1.02-1.04 2.48s1.07 2.87 1.21 3.07c.15.2 2.1 3.2 5.08 4.49.71.31 1.26.49 1.69.62.71.23 1.36.2 1.87.12.57-.09 1.76-.72 2.01-1.41.25-.7.25-1.29.17-1.41-.07-.13-.27-.2-.57-.35z"/><path d="M12.03 2C6.51 2 2 6.48 2 12c0 1.82.48 3.58 1.4 5.12L2 22l5.03-1.32A9.96 9.96 0 0 0 12.03 22C17.55 22 22 17.52 22 12S17.55 2 12.03 2zm0 18.09c-1.66 0-3.28-.44-4.7-1.28l-.34-.2-2.98.78.8-2.91-.22-.3A8.08 8.08 0 0 1 3.93 12c0-4.46 3.64-8.09 8.1-8.09 4.46 0 8.09 3.63 8.09 8.09 0 4.46-3.63 8.09-8.09 8.09z"/></svg><span>(41) 99811-2201</span></a>
|
| 31 |
-
</nav>
|
| 32 |
-
</header>
|
| 33 |
-
|
| 34 |
-
|
| 35 |
<section class="page-hero">
|
| 36 |
<div class="container">
|
| 37 |
<span class="eyebrow eyebrow-gold reveal">O que fazemos</span>
|
|
@@ -42,10 +23,6 @@
|
|
| 42 |
|
| 43 |
<section class="container" style="padding:36px">
|
| 44 |
<div class="service-row reveal reveal-left">
|
| 45 |
-
<div class="service-media luxe-frame">
|
| 46 |
-
<img src="https://picsum.photos/seed/sk-servico-venda/800/620" alt="Compra e venda de imóveis no litoral" loading="lazy">
|
| 47 |
-
<span class="service-num">01</span>
|
| 48 |
-
</div>
|
| 49 |
<div class="service-copy">
|
| 50 |
<h3>Compra e venda</h3>
|
| 51 |
<p class="muted">Conduzimos a negociação do anúncio à escritura, com avaliação de mercado baseada em imóveis comparáveis da própria região.</p>
|
|
@@ -60,10 +37,6 @@
|
|
| 60 |
</div>
|
| 61 |
|
| 62 |
<div class="service-row reverse reveal reveal-right">
|
| 63 |
-
<div class="service-media luxe-frame">
|
| 64 |
-
<img src="https://picsum.photos/seed/sk-servico-locacao/800/620" alt="Locação anual e por temporada no litoral" loading="lazy">
|
| 65 |
-
<span class="service-num">02</span>
|
| 66 |
-
</div>
|
| 67 |
<div class="service-copy">
|
| 68 |
<h3>Locação anual e por temporada</h3>
|
| 69 |
<p class="muted">Do contrato de 30 meses ao aluguel de uma semana de verão — cuidamos da burocracia para os dois lados da negociação.</p>
|
|
@@ -78,10 +51,6 @@
|
|
| 78 |
</div>
|
| 79 |
|
| 80 |
<div class="service-row reveal reveal-left">
|
| 81 |
-
<div class="service-media luxe-frame">
|
| 82 |
-
<img src="https://picsum.photos/seed/sk-servico-gestao/800/620" alt="Gestão de negócios imobiliários no litoral" loading="lazy">
|
| 83 |
-
<span class="service-num">03</span>
|
| 84 |
-
</div>
|
| 85 |
<div class="service-copy">
|
| 86 |
<h3>Gestão de negócios imobiliários</h3>
|
| 87 |
<p class="muted">Para quem tem um ou mais imóveis no litoral e prefere delegar a administração completa da carteira.</p>
|
|
@@ -127,46 +96,7 @@
|
|
| 127 |
</div>
|
| 128 |
</section>
|
| 129 |
|
| 130 |
-
|
| 131 |
-
<div class="container">
|
| 132 |
-
<div class="footer-grid">
|
| 133 |
-
<div>
|
| 134 |
-
<div class="footer-brand"><img src="/img/logo-sk.svg" alt="SK Imobiliária" height="30"></div>
|
| 135 |
-
<p class="muted" style="font-size:.86rem">Venda, locação e gestão de negócios imobiliários no litoral do Paraná.</p>
|
| 136 |
-
</div>
|
| 137 |
-
<div>
|
| 138 |
-
<h4>Navegação</h4>
|
| 139 |
-
<ul>
|
| 140 |
-
<li><a href="/busca">Comprar & Alugar</a></li>
|
| 141 |
-
<li><a href="/servicos.html">Serviços</a></li>
|
| 142 |
-
<li><a href="/sobre.html">Sobre a SK</a></li>
|
| 143 |
-
<li><a href="/contato.html">Contato</a></li>
|
| 144 |
-
</ul>
|
| 145 |
-
</div>
|
| 146 |
-
<div>
|
| 147 |
-
<h4>Cidades</h4>
|
| 148 |
-
<ul>
|
| 149 |
-
<li><a href="/busca/matinhos">Imóveis em Matinhos</a></li>
|
| 150 |
-
<li><a href="/busca/guaratuba">Imóveis em Guaratuba</a></li>
|
| 151 |
-
<li><a href="/busca/pontal-do-parana">Imóveis em Pontal do Paraná</a></li>
|
| 152 |
-
</ul>
|
| 153 |
-
</div>
|
| 154 |
-
<div>
|
| 155 |
-
<h4>Contato</h4>
|
| 156 |
-
<ul>
|
| 157 |
-
<li>Av. Beira Mar, 1120 — Caiobá, Matinhos/PR</li>
|
| 158 |
-
<li>(41) 99811-2201</li>
|
| 159 |
-
<li>contato@skimobiliaria.com.br</li>
|
| 160 |
-
<li><a href="/admin.html">Área do corretor →</a></li>
|
| 161 |
-
</ul>
|
| 162 |
-
</div>
|
| 163 |
-
</div>
|
| 164 |
-
<div class="footer-bottom">
|
| 165 |
-
<span>© <span data-year></span> SK Imobiliária · CRECI-PR 00000-J · Salete Kuhnert dos Santos</span>
|
| 166 |
-
<span>Site demonstrativo — dados fictícios</span>
|
| 167 |
-
</div>
|
| 168 |
-
</div>
|
| 169 |
-
</footer>
|
| 170 |
|
| 171 |
<script src="/js/main.js?v=2"></script>
|
| 172 |
</body>
|
|
|
|
| 8 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 9 |
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@500;600&display=swap" rel="stylesheet">
|
| 10 |
<link rel="icon" type="image/svg+xml" href="/img/logo-sk.svg">
|
| 11 |
+
<link rel="stylesheet" href="/css/style.css?v=8">
|
| 12 |
</head>
|
| 13 |
<body>
|
| 14 |
|
| 15 |
+
{% include "_header.html" %}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
<section class="page-hero">
|
| 17 |
<div class="container">
|
| 18 |
<span class="eyebrow eyebrow-gold reveal">O que fazemos</span>
|
|
|
|
| 23 |
|
| 24 |
<section class="container" style="padding:36px">
|
| 25 |
<div class="service-row reveal reveal-left">
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
<div class="service-copy">
|
| 27 |
<h3>Compra e venda</h3>
|
| 28 |
<p class="muted">Conduzimos a negociação do anúncio à escritura, com avaliação de mercado baseada em imóveis comparáveis da própria região.</p>
|
|
|
|
| 37 |
</div>
|
| 38 |
|
| 39 |
<div class="service-row reverse reveal reveal-right">
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
<div class="service-copy">
|
| 41 |
<h3>Locação anual e por temporada</h3>
|
| 42 |
<p class="muted">Do contrato de 30 meses ao aluguel de uma semana de verão — cuidamos da burocracia para os dois lados da negociação.</p>
|
|
|
|
| 51 |
</div>
|
| 52 |
|
| 53 |
<div class="service-row reveal reveal-left">
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
<div class="service-copy">
|
| 55 |
<h3>Gestão de negócios imobiliários</h3>
|
| 56 |
<p class="muted">Para quem tem um ou mais imóveis no litoral e prefere delegar a administração completa da carteira.</p>
|
|
|
|
| 96 |
</div>
|
| 97 |
</section>
|
| 98 |
|
| 99 |
+
{% include "_footer.html" %}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
<script src="/js/main.js?v=2"></script>
|
| 102 |
</body>
|
{static → templates}/sobre.html
RENAMED
|
@@ -8,30 +8,11 @@
|
|
| 8 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 9 |
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@500;600&display=swap" rel="stylesheet">
|
| 10 |
<link rel="icon" type="image/svg+xml" href="/img/logo-sk.svg">
|
| 11 |
-
<link rel="stylesheet" href="/css/style.css?v=
|
| 12 |
</head>
|
| 13 |
<body>
|
| 14 |
|
| 15 |
-
|
| 16 |
-
<nav class="nav">
|
| 17 |
-
<button class="nav-toggle" aria-label="Abrir menu" aria-expanded="false">
|
| 18 |
-
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="18" x2="21" y2="18"></line></svg>
|
| 19 |
-
</button>
|
| 20 |
-
<a class="brand" href="/">
|
| 21 |
-
<img src="/img/logo-sk.svg" alt="SK Imobiliária" class="brand-mark" height="38">
|
| 22 |
-
</a>
|
| 23 |
-
<ul class="nav-links">
|
| 24 |
-
<li><a href="/">Início</a></li>
|
| 25 |
-
<li><a href="/busca">Comprar & Alugar</a></li>
|
| 26 |
-
<li><a href="/servicos.html">Serviços</a></li>
|
| 27 |
-
<li><a href="/sobre.html">Sobre</a></li>
|
| 28 |
-
<li><a href="/contato.html">Contato</a></li>
|
| 29 |
-
</ul>
|
| 30 |
-
<a class="nav-cta nav-whats" href="https://wa.me/5541998112201?text=Ol%C3%A1%21%20Vim%20pelo%20site%20da%20SK%20Imobili%C3%A1ria%20e%20gostaria%20de%20falar%20com%20um%20corretor." target="_blank" rel="noopener" aria-label="Falar no WhatsApp: (41) 99811-2201"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M17.47 14.38c-.3-.15-1.76-.87-2.03-.97-.27-.1-.47-.15-.67.15-.2.3-.77.97-.94 1.16-.17.2-.35.22-.64.08-.3-.15-1.26-.46-2.39-1.48-.88-.79-1.48-1.76-1.65-2.06-.17-.3-.02-.46.13-.61.13-.13.3-.35.45-.52.15-.17.2-.3.3-.5.1-.2.05-.37-.02-.52-.08-.15-.67-1.61-.92-2.21-.24-.58-.49-.5-.67-.51-.17-.01-.37-.01-.57-.01-.2 0-.52.07-.79.37-.27.3-1.04 1.02-1.04 2.48s1.07 2.87 1.21 3.07c.15.2 2.1 3.2 5.08 4.49.71.31 1.26.49 1.69.62.71.23 1.36.2 1.87.12.57-.09 1.76-.72 2.01-1.41.25-.7.25-1.29.17-1.41-.07-.13-.27-.2-.57-.35z"/><path d="M12.03 2C6.51 2 2 6.48 2 12c0 1.82.48 3.58 1.4 5.12L2 22l5.03-1.32A9.96 9.96 0 0 0 12.03 22C17.55 22 22 17.52 22 12S17.55 2 12.03 2zm0 18.09c-1.66 0-3.28-.44-4.7-1.28l-.34-.2-2.98.78.8-2.91-.22-.3A8.08 8.08 0 0 1 3.93 12c0-4.46 3.64-8.09 8.1-8.09 4.46 0 8.09 3.63 8.09 8.09 0 4.46-3.63 8.09-8.09 8.09z"/></svg><span>(41) 99811-2201</span></a>
|
| 31 |
-
</nav>
|
| 32 |
-
</header>
|
| 33 |
-
|
| 34 |
-
|
| 35 |
<section class="page-hero">
|
| 36 |
<div class="container">
|
| 37 |
<span class="eyebrow reveal">Desde a areia até a escritura</span>
|
|
@@ -48,9 +29,6 @@
|
|
| 48 |
<p>Começamos atendendo famílias que buscavam a primeira casa de temporada em Caiobá. Com o tempo, passamos a cuidar também da locação anual de moradores fixos e da gestão de carteiras de investidores que têm um ou vários imóveis entre Matinhos, Guaratuba e Pontal do Paraná.</p>
|
| 49 |
<p>Hoje a SK atua nas três frentes que sustentam o mercado imobiliário do litoral: <strong>venda</strong>, <strong>locação</strong> (anual e por temporada) e <strong>gestão de negócios imobiliários</strong> para quem prefere delegar a administração do próprio patrimônio.</p>
|
| 50 |
</div>
|
| 51 |
-
<div class="service-media luxe-frame reveal reveal-right">
|
| 52 |
-
<img src="https://picsum.photos/seed/sk-sobre-equipe/800/900" alt="Equipe SK atendendo no litoral do Paraná" loading="lazy">
|
| 53 |
-
</div>
|
| 54 |
</div>
|
| 55 |
|
| 56 |
<div class="ornament-divider reveal"><span class="diamond"></span></div>
|
|
@@ -76,46 +54,7 @@
|
|
| 76 |
</section>
|
| 77 |
|
| 78 |
|
| 79 |
-
|
| 80 |
-
<div class="container">
|
| 81 |
-
<div class="footer-grid">
|
| 82 |
-
<div>
|
| 83 |
-
<div class="footer-brand"><img src="/img/logo-sk.svg" alt="SK Imobiliária" height="30"></div>
|
| 84 |
-
<p class="muted" style="font-size:.86rem">Venda, locação e gestão de negócios imobiliários no litoral do Paraná.</p>
|
| 85 |
-
</div>
|
| 86 |
-
<div>
|
| 87 |
-
<h4>Navegação</h4>
|
| 88 |
-
<ul>
|
| 89 |
-
<li><a href="/busca">Comprar & Alugar</a></li>
|
| 90 |
-
<li><a href="/servicos.html">Serviços</a></li>
|
| 91 |
-
<li><a href="/sobre.html">Sobre a SK</a></li>
|
| 92 |
-
<li><a href="/contato.html">Contato</a></li>
|
| 93 |
-
</ul>
|
| 94 |
-
</div>
|
| 95 |
-
<div>
|
| 96 |
-
<h4>Cidades</h4>
|
| 97 |
-
<ul>
|
| 98 |
-
<li><a href="/busca/matinhos">Imóveis em Matinhos</a></li>
|
| 99 |
-
<li><a href="/busca/guaratuba">Imóveis em Guaratuba</a></li>
|
| 100 |
-
<li><a href="/busca/pontal-do-parana">Imóveis em Pontal do Paraná</a></li>
|
| 101 |
-
</ul>
|
| 102 |
-
</div>
|
| 103 |
-
<div>
|
| 104 |
-
<h4>Contato</h4>
|
| 105 |
-
<ul>
|
| 106 |
-
<li>Av. Beira Mar, 1120 — Caiobá, Matinhos/PR</li>
|
| 107 |
-
<li>(41) 99811-2201</li>
|
| 108 |
-
<li>contato@skimobiliaria.com.br</li>
|
| 109 |
-
<li><a href="/admin.html">Área do corretor →</a></li>
|
| 110 |
-
</ul>
|
| 111 |
-
</div>
|
| 112 |
-
</div>
|
| 113 |
-
<div class="footer-bottom">
|
| 114 |
-
<span>© <span data-year></span> SK Imobiliária · CRECI-PR 00000-J · Salete Kuhnert dos Santos</span>
|
| 115 |
-
<span>Site demonstrativo — dados fictícios</span>
|
| 116 |
-
</div>
|
| 117 |
-
</div>
|
| 118 |
-
</footer>
|
| 119 |
|
| 120 |
<script src="/js/main.js?v=2"></script>
|
| 121 |
</body>
|
|
|
|
| 8 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 9 |
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@500;600&display=swap" rel="stylesheet">
|
| 10 |
<link rel="icon" type="image/svg+xml" href="/img/logo-sk.svg">
|
| 11 |
+
<link rel="stylesheet" href="/css/style.css?v=8">
|
| 12 |
</head>
|
| 13 |
<body>
|
| 14 |
|
| 15 |
+
{% include "_header.html" %}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
<section class="page-hero">
|
| 17 |
<div class="container">
|
| 18 |
<span class="eyebrow reveal">Desde a areia até a escritura</span>
|
|
|
|
| 29 |
<p>Começamos atendendo famílias que buscavam a primeira casa de temporada em Caiobá. Com o tempo, passamos a cuidar também da locação anual de moradores fixos e da gestão de carteiras de investidores que têm um ou vários imóveis entre Matinhos, Guaratuba e Pontal do Paraná.</p>
|
| 30 |
<p>Hoje a SK atua nas três frentes que sustentam o mercado imobiliário do litoral: <strong>venda</strong>, <strong>locação</strong> (anual e por temporada) e <strong>gestão de negócios imobiliários</strong> para quem prefere delegar a administração do próprio patrimônio.</p>
|
| 31 |
</div>
|
|
|
|
|
|
|
|
|
|
| 32 |
</div>
|
| 33 |
|
| 34 |
<div class="ornament-divider reveal"><span class="diamond"></span></div>
|
|
|
|
| 54 |
</section>
|
| 55 |
|
| 56 |
|
| 57 |
+
{% include "_footer.html" %}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
<script src="/js/main.js?v=2"></script>
|
| 60 |
</body>
|