Carlexxx commited on
Commit
8072ab3
·
1 Parent(s): 8424761
Files changed (45) hide show
  1. app/__pycache__/__init__.cpython-313.pyc +0 -0
  2. app/__pycache__/auth.cpython-313.pyc +0 -0
  3. app/__pycache__/backup_service.cpython-313.pyc +0 -0
  4. app/__pycache__/config.cpython-313.pyc +0 -0
  5. app/__pycache__/excel_service.cpython-313.pyc +0 -0
  6. app/__pycache__/main.cpython-313.pyc +0 -0
  7. app/__pycache__/paginas_html.cpython-313.pyc +0 -0
  8. app/__pycache__/schemas.cpython-313.pyc +0 -0
  9. app/__pycache__/seed_data.cpython-313.pyc +0 -0
  10. app/__pycache__/seo.cpython-313.pyc +0 -0
  11. app/__pycache__/site_config.cpython-313.pyc +0 -0
  12. app/auth.py +54 -0
  13. app/backup_service.py +210 -0
  14. app/main.py +242 -22
  15. app/paginas_html.py +79 -0
  16. app/routers/__pycache__/__init__.cpython-313.pyc +0 -0
  17. app/routers/__pycache__/anuncios.cpython-313.pyc +0 -0
  18. app/routers/anuncios.py +35 -6
  19. app/schemas.py +1 -0
  20. app/seo.py +64 -5
  21. app/site_config.py +81 -0
  22. requirements.txt +1 -0
  23. static/admin.html +0 -231
  24. static/busca.html +0 -149
  25. static/css/style.css +185 -23
  26. static/imovel.html +0 -87
  27. static/index.html +0 -161
  28. static/js/admin.js +462 -98
  29. static/js/admin_midias.js +86 -0
  30. static/js/api.js +16 -5
  31. static/js/busca.js +1 -1
  32. static/js/home.js +38 -11
  33. static/js/imovel.js +4 -2
  34. {static → templates}/404.html +2 -8
  35. templates/_footer.html +4 -6
  36. templates/_header.html +4 -4
  37. templates/admin.html +386 -0
  38. templates/admin_login.html +39 -0
  39. templates/admin_midias.html +48 -0
  40. templates/busca.html +6 -14
  41. {static → templates}/contato.html +4 -77
  42. templates/imovel.html +10 -9
  43. templates/index.html +31 -0
  44. {static → templates}/servicos.html +3 -73
  45. {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 FileResponse, HTMLResponse, PlainTextResponse, RedirectResponse, Response
 
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
- NAO_ENCONTRADO = os.path.join(STATIC_DIR, "404.html")
 
 
 
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 FileResponse(NAO_ENCONTRADO, status_code=404)
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": f"https://wa.me/5541998112201?text={quote(mensagem_whats)}",
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
- return FileResponse(os.path.join(STATIC_DIR, "index.html"))
347
-
348
- @app.get("/{page_name}.html", include_in_schema=False)
349
- def paginas(page_name: str):
350
- caminho = os.path.join(STATIC_DIR, f"{page_name}.html")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
351
  if os.path.exists(caminho):
352
- return FileResponse(caminho)
353
- return FileResponse(NAO_ENCONTRADO, status_code=404)
 
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("/export/planilha", summary="Baixa a planilha Excel atual (o banco de dados)")
 
 
 
 
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("/", response_model=AnuncioResponse, status_code=201, summary="Cria um novo anúncio")
 
 
 
 
 
 
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("/{anuncio_id}", response_model=AnuncioResponse, summary="Substitui todos os dados de um anúncio")
 
 
 
 
 
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("/{anuncio_id}", response_model=AnuncioResponse, summary="Atualiza parcialmente um anúncio")
 
 
 
 
 
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("/{anuncio_id}", status_code=204, summary="Remove um anúncio")
 
 
 
 
 
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'<div class="card-price">{preco_exibicao(a)}</div>'
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
- partes = [f"{a['tipo_imovel']} {acao} em {a['bairro']}, {a['cidade']} (PR) por {preco_formatado(a['preco'])}"]
 
 
 
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
- "offers": {
 
 
 
 
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 &amp; 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 &amp; 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 &amp; 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(250, 247, 240, 0.92);
156
- backdrop-filter: blur(10px) saturate(1.1);
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
- .quick-search-section { padding: 0; }
 
 
 
 
 
 
 
 
 
276
  .quick-search {
277
  display: flex;
278
  align-items: center;
279
  gap: 8px;
280
- max-width: 100%;
281
- margin: 0;
282
- background: var(--chumbo);
283
- border: none;
284
- border-bottom: 1px solid var(--linha-chumbo);
285
- border-radius: 0;
286
- box-shadow: none;
 
 
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: 6px 6px 6px 6px;
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: 40px; height: 40px;
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 { filter: brightness(1.07); transform: translateY(-1px); }
 
 
 
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: 16/10; } }
625
  .cidade-box {
626
  position: relative;
627
  border-radius: var(--radius);
628
  overflow: hidden;
629
- aspect-ratio: 4/5;
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
- .site-footer { background: var(--granito-2); color: var(--texto-muted); padding: 56px 0 26px; margin-top: 0; border-top: 1px solid var(--linha-chumbo); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 de demonstração (CRUD de anúncios) */
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
- function ativarSecaoMidia(id) {
10
- midiaAnuncioId = id;
11
- document.getElementById("midia-card").style.display = "";
12
- document.getElementById("midia-hint").textContent =
13
- `Gerenciando mídias do anúncio #${id} — arquivos enviados ficam salvos em /data/midia/anuncios/${id}/`;
14
- carregarMidias();
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
- document.getElementById("btn-upload-midia").addEventListener("click", async () => {
38
- const input = document.getElementById("midia-arquivos");
39
- if (!midiaAnuncioId) { toast("Salve o anúncio antes de enviar mídias.", true); return; }
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
- document.getElementById("btn-add-midia-url").addEventListener("click", async () => {
64
- const input = document.getElementById("midia-url");
65
- const url = input.value.trim();
66
- if (!midiaAnuncioId) { toast("Salve o anúncio antes de adicionar mídias.", true); return; }
67
- if (!url) { toast("Cole a URL da mídia (foto, vídeo ou áudio).", true); return; }
 
 
 
 
 
 
 
 
 
 
68
 
69
- try {
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
- document.getElementById("midia-grid").addEventListener("click", async (e) => {
80
- const btn = e.target.closest(".midia-remove");
81
- if (!btn) return;
82
- if (!confirm("Excluir esta mídia? Essa ação não pode ser desfeita.")) return;
83
- try {
84
- await apiSend("DELETE", `/${midiaAnuncioId}/midias?url=${encodeURIComponent(btn.dataset.url)}`);
85
- toast("Mídia removida.");
86
- carregarMidias();
87
- } catch (err) {
88
- toast(err.message, true);
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="7" class="muted">Nenhum anúncio cadastrado ainda.</td></tr>`;
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
- toast(err.message, true);
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
- toast(err.message, true);
215
  }
216
  }
217
  });
218
 
219
- form.addEventListener("submit", async (e) => {
220
- e.preventDefault();
221
- const id = document.getElementById("a-id").value;
222
- const payload = lerFormulario();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
 
224
- btnSalvar.disabled = true;
 
 
 
 
 
225
  try {
226
- if (id) {
227
- await apiSend("PUT", `/${id}`, payload);
228
- toast(`Anúncio #${id} atualizado.`);
229
- ativarSecaoMidia(id);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  } else {
231
- const criado = await apiSend("POST", "/", payload);
232
- toast(`Anúncio #${criado.id} criado.`);
233
- ativarSecaoMidia(criado.id);
234
  }
235
- limparFormulario();
236
- carregarLista();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  } catch (err) {
 
238
  toast(err.message, true);
239
  } finally {
240
- btnSalvar.disabled = false;
241
  }
242
  });
243
 
244
- btnCancelar.addEventListener("click", limparFormulario);
 
 
 
 
 
245
 
246
- document.addEventListener("DOMContentLoaded", carregarLista);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- const WHATSAPP_NUMERO = "5541998112201"; // SK atendimento central (demo)
 
 
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
- throw new Error(detalhe.detail || `Erro ${res.status} ao consultar a API`);
 
 
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
- throw new Error(detalhe.detail ? JSON.stringify(detalhe.detail) : `Erro ${res.status}`);
 
 
33
  }
34
  return method === "DELETE" ? null : res.json();
35
  }
36
 
37
- function formatMoney(valor, tipoNegocio) {
 
 
 
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> imóve${data.total === 1 ? "l" : "is"} encontrados${data.total ? ` · exibindo ${de}–${ate}` : ""}`;
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
- function montarShowcase(el, destaques) {
36
- 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("");
37
- const arrows = destaques.length > 1 ? `
38
- <button type="button" class="showcase-arrow prev" aria-label="Imóvel anterior">‹</button>
39
- <button type="button" class="showcase-arrow next" aria-label="Próximo imóvel">›</button>` : "";
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
+ 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
- <div class="price">${formatMoney(a.preco, a.tipo_negocio)}</div>
107
- <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>
 
 
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=6">
11
  </head>
12
  <body>
13
- <header class="site-header">
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
- <div class="footer-brand"><img src="/img/logo-sk.svg" alt="SK Imobiliária" height="30"></div>
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>Av. Beira Mar, 1120 — Caiobá, Matinhos/PR</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> SK Imobiliária · CRECI-PR 00000-J · Salete Kuhnert dos Santos</span>
37
- <span>Site demonstrativo — dados fictícios</span>
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/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>
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">&lt;/&gt; 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=6">
28
  </head>
29
  <body>
30
 
31
  {% include "_header.html" %}
32
 
33
 
34
- <section class="page-hero">
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> imóveis</button>
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> imóve{{ 'l' if total == 1 else 'is' }} encontrados</span>
113
- <div class="field" style="min-width:210px">
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
- </div>
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=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 &amp; 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
- <aside class="contact-card reveal reveal-right">
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
- <footer class="site-footer" style="margin-top:0">
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 &amp; 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=6">
30
  </head>
31
  <body>
32
 
33
  {% include "_header.html" %}
34
 
35
- <section class="container" style="padding-top:28px" id="imovel-container">
36
- <p class="breadcrumb reveal">
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="reveal reveal-left">
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 reveal reveal-right">
 
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="display:none">
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=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 &amp; 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
- <footer class="site-footer" style="margin-top:0">
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 &amp; 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=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 &amp; 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
- <footer class="site-footer" style="margin-top:0">
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 &amp; 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>