# Catálogo de Locais — Santa Mônica Clube de Campo

Os 47 espaços do clube em cards — incluindo as 33 churrasqueiras. O associado busca por nome, filtra por categoria
e abre a ficha completa: foto, destaque, descrição, onde fica, distância a
partir da posição dele, horários, telefone, "Como chegar" (abre o Google Maps)
e "Compartilhar".

A secretaria mantém tudo pelo painel em `/admin`, sem precisar de
desenvolvedor.

---

## Guia do projeto

### 1. O que é

Um site público de uma página (`/`) e um painel administrativo (`/admin`).

- **Associado:** busca, filtro por categoria, ficha com foto, horário, telefone, "Como chegar" e compartilhamento.
- **Secretaria:** cadastra e edita locais, categorias, horários, fotos e a planta pelo painel.
- **Integrações:** API JSON pública em `/api/mapa` (documentada em `docs/api-locais.md`).

| Camada | Tecnologia |
|---|---|
| Framework | Laravel 13 (PHP 8.3) |
| Painel | Filament 5 |
| Site | Blade + JavaScript puro (`public/js/app.js`, `public/css/app.css`), sem npm e sem build |
| Banco | SQLite (dev) ou MySQL (produção atual) |
| Cache | tabela `cache` do banco (`CACHE_STORE=database`) |
| Imagens | GD, convertidas para WebP |

### 2. Onde está cada coisa

```
app/Http/Controllers/MapaController.php   página / e ficha /locais/{slug}
app/Http/Controllers/MetricaController.php contador de visualizações
app/Services/MapaService.php              monta o payload (cache de 1 h)
app/Observers/LocalObserver.php           calcula latitude/longitude a partir de x/y
app/Observers/MapVersionObserver.php      garante uma única planta ativa
app/Models/MapVersion.php                 fórmula x/y → lat/lng
app/Filament/Resources/                   painel: Locais, Categorias, Plantas
app/Support/GoogleMapsLocal.php           formata o campo "Coordenadas"
app/Console/Commands/OtimizarImagens.php  comando mapa:otimizar-imagens
config/mapa.php                           contato, logo, origem padrão, reservas
database/migrations/                      estrutura das tabelas
database/seeders/ClubeSeeder.php          dados iniciais
resources/views/locais/                   página principal e partials
resources/views/filament/mapa-picker.blade.php  seletor da planta (não ligado, ver seção 11)
public/css/app.css                        design do site (tokens em :root)
public/js/app.js                          busca, filtro, ficha, compartilhar, beacon
routes/web.php                            todas as rotas
tests/Feature/                            AdminTest, MapaTest, MetricaTest
docs/guia-do-projeto.html                 versão visual deste guia
```

| Quero… | Onde mexer |
|---|---|
| Editar texto, telefone, horário ou foto de um local | Painel `/admin/locals` |
| Mudar telefone central, endereço ou horário do rodapé | `config/mapa.php`, chave `contato` |
| Trocar a logo | `config/mapa.php`, chave `logo` |
| Mudar cor ou fonte | `public/css/app.css`, `:root` |
| Mudar busca ou ficha | `public/js/app.js` |
| Mudar o layout | `resources/views/locais/` |
| Ativar reserva online | `config/mapa.php`, `reservas.url` |

### 3. Como funciona

1. O navegador abre `/`.
2. `MapaController@index` chama `MapaService::payload()`, que usa cache de 1 hora.
3. A Blade renderiza os cards já no HTML e serializa o payload em `<script id="mapa-payload">`.
4. O JavaScript filtra e abre a ficha (`<dialog>`) sem recarregar nada.
5. A ficha só conta como visualização se ficar aberta por 2 s. Isso envia `POST /locais/{slug}/view` (limite de 60/min por IP).

Os observers de `Local`, `Categoria`, `LocalHorario` e `MapVersion` apagam o cache ao salvar. Valores de `config/` ficam fora do cache e aparecem na hora.

Cada foto vira duas versões WebP: 1200 px (ficha) e 400 px (card). A planta vira WebP de 2400 px.

### 4. Configurar

Requisitos: PHP 8.3, Composer 2 e as extensões do Laravel (`pdo`, `gd`, `mbstring`, `sqlite3` ou `mysql`). Neste computador, `php -v` responde 8.3.28 e `composer -V` responde 2.10.3.

Passo a passo para um ambiente novo:

1. `copy .env.example .env` (Linux/macOS: `cp`)
2. `php artisan key:generate`
3. Ajustar o `.env` (tabela abaixo)
4. `composer install`
5. `php artisan migrate --seed` (idempotente)
6. `php artisan storage:link`
7. `php artisan mapa:otimizar-imagens`
8. Criar um usuário do painel (seção 8)

| Chave | Local (dev) | Produção (atual) |
|---|---|---|
| `APP_ENV` | `local` | `production` |
| `APP_DEBUG` | `true` | `false` (nunca `true` em produção) |
| `APP_URL` | `http://localhost:8000` | `https://servicos2.santamonica.rec.br/mapa-online-smcc` |
| `ASSET_URL` | vazio | mesmo valor do `APP_URL` |
| `DB_CONNECTION` | `sqlite` | `mysql` (preencher `DB_*`) |
| `CACHE_STORE` | `database` | `database` |
| `SESSION_PATH` | `/` | `/mapa-online-smcc` |

> **Atenção:** o `.env` atual é o de produção. `php artisan serve` com ele tenta conectar no MySQL e gera links para o servidor. Para desenvolver, use um `.env` local separado.

Ajustes do clube em `config/mapa.php`: `contato.*` (telefone, endereço, horários, link do Google Maps), `logo`, `reservas.url` (`null` = botão liga para a secretaria) e `origem_padrao`. Se houver `config:cache` no servidor, rode `php artisan config:clear` depois de editar.

### 5. Rodar

```bash
php artisan serve        # http://localhost:8000 (site) e /admin (painel)
php artisan test         # testes
```

Se mudar a porta, mude o `APP_URL` junto. Senão as fotos quebram.

Em produção, `test_deploy_check.sh` faz uma checagem rápida (página principal, redirect e título).

| Comando | Quando |
|---|---|
| `php artisan migrate --seed` | Primeira vez ou após nova migração |
| `php artisan storage:link` | Uma vez por ambiente |
| `php artisan mapa:otimizar-imagens` | Após trocar foto ou planta (`--force` regera tudo) |
| `php artisan cache:clear` | Forçar releitura do payload |
| `php artisan route:list` | Ver as rotas |
| `php artisan tinker` | Mexer no banco pelo terminal |

### 6. Os dois números: x/y e latitude/longitude

| Par | Unidade | Onde fica | Quem preenche |
|---|---|---|---|
| **x, y** | porcentagem da planta (0 a 100). x: esquerda → direita. y: topo → base | `locais.x`, `locais.y` (podem ser nulos) | hoje ninguém pela interface (ver seção 11) |
| **latitude, longitude** | graus decimais | `locais.latitude`, `locais.longitude` | calculadas pelo `LocalObserver` |

Conversão, em `MapVersion::coordenada()`:

```
latitude  = base_lat + (50 - y) / 100 × span_lat
longitude = base_lng + (x - 50) / 100 × span_lng
```

Com a planta atual (`base_lat -22.741900`, `base_lng -47.312600`, `span_lat 0.0072`, `span_lng 0.0087`), o centro da planta (x=50, y=50) cai em `-22.741900, -47.312600`. Exemplo: a Secretaria tem `x 44.4` e `y 50.2`.

O campo **"Coordenadas"** do formulário é outra coisa. Ele guarda `google_maps_url`, usado no botão "Como chegar". Não é o mesmo par de x/y e não é recalculado a partir dele. O formato esperado é `latitude, longitude`.

Para ver o cálculo com números reais, abra `docs/guia-do-projeto.html` (calculador interativo).

### 7. Como ajustar

- **Adicionar local:** Painel → Locais → Criar. O slug vem do nome e não muda depois (links compartilhados dependem dele). Depois, `php artisan mapa:otimizar-imagens` se houver foto.
- **Desativar sem apagar:** desligue "ativo". O histórico é preservado e `/locais/{slug}` passa a dar 404.
- **Ordem da lista:** campo "ordem" (menor aparece primeiro).
- **Trocar a planta:** Plantas do clube → envie a imagem → marque "ativa" (as outras são desativadas) → ajuste a âncora se preciso → `mapa:otimizar-imagens`. Mudar a âncora move o "Como chegar" de quem depende de x/y.
- **Cores e fontes:** tokens em `:root` de `public/css/app.css`. A fonte vem do Google Fonts, no layout `resources/views/components/layouts/app.blade.php`.

### 8. Painel administrativo

Acesso em `/admin`, só para usuários com `admin = true`. Sem isso o login é aceito, mas a conta recebe 403.

```bash
php artisan tinker --execute="App\Models\User::create(['name'=>'Secretaria','email'=>'secretaria@exemplo.com','password'=>bcrypt('uma-senha-forte'),'admin'=>true]);"
```

Seções: **Dashboard** (totais, visualizações em 7 e 30 dias, top 10), **Locais**, **Categorias** e **Plantas do clube**.

### 9. Rotas e API

| Método | Caminho | Retorna |
|---|---|---|
| GET | `/` | Página principal (HTML) |
| GET | `/locais` | Redirect 301 para `/` |
| GET | `/locais/{slug}` | Página com a ficha aberta. 404 se inativo |
| POST | `/locais/{slug}/view` | 204. Conta uma visualização do dia |
| GET | `/api/mapa` | JSON com locais, categorias, contato, reservas, origem e logo |
| GET | `/admin` | Painel (login) |

Detalhes do JSON em `docs/api-locais.md`. Não há endpoint de escrita.

### 10. Testes e manutenção

`php artisan test` roda `tests/Feature/` (`AdminTest`, `MapaTest`, `MetricaTest`).

Rotina sugerida:
- backup diário do banco e de `storage/app/public`
- após trocar foto ou planta: `php artisan mapa:otimizar-imagens`
- após atualizar o código no servidor: `php artisan migrate --force` e `php artisan config:clear`
- logs em `storage/logs/` (com `APP_DEBUG=false`, erros ficam só no log)

### 11. Atenção: divergências encontradas

Comparando este README com o código:

1. **O seletor visual da planta não está ligado.** `mapa-picker.blade.php` existe, mas `LocalForm` não o usa e não há campos x/y no formulário. A posição só pode ser definida por `tinker` ou pelo banco.
2. **"Como chegar" não muda ao mover o marcador.** O `LocalObserver` só atualiza latitude/longitude. O botão usa `google_maps_url` ou o link geral do clube.
3. **Distância não existe no código.** Nada de distância a pé, geolocalização ou ordenação aparece em `app.js` ou nas views. A origem padrão vai no payload, mas não é usada. Confirmar se foi removido ou se ainda será feito.
4. **Senha temporária neste arquivo.** A seção "Criar um usuário" e a de dados do protótipo citam a senha do usuário provisionado. Troque a senha e remova o trecho.
5. **Ambiente:** o `.env` está em produção. Os comandos da seção "Como rodar" abaixo pressupõem outro `.env`. Rodar `migrate` ou `--seed` com as credenciais de produção mexe no banco do servidor.
6. **Dados fictícios:** telefones por local (DDD 19), 33 churrasqueiras sem endereço, posição nem foto própria, e a âncora da planta em Campinas/SP (o clube fica no Paraná, DDD 41).

---

## Como rodar

Este ambiente usa o PHP do WampServer e um `composer.phar` local — não há PHP
nem Composer no PATH do sistema.

```bash
C:/wamp64/bin/php/php8.3.28/php.exe artisan serve
```

Abra <http://localhost:8000>. O painel fica em <http://localhost:8000/admin>.

> **Importante:** o `APP_URL` do `.env` é `http://localhost:8000`. As URLs das
> imagens são geradas a partir dele, então rodar em outra porta deixa as fotos
> quebradas. Se mudar a porta, mude o `APP_URL` junto.

### Do zero, em outra máquina

```bash
php artisan key:generate
php artisan migrate --seed
php artisan storage:link
php artisan mapa:otimizar-imagens
```

O seeder é idempotente: pode rodar quantas vezes quiser, não duplica nada e
não desfaz a otimização das imagens.

### Criar um usuário do painel

```bash
php artisan tinker --execute="App\Models\User::create(['name'=>'Secretaria','email'=>'secretaria@exemplo.com','password'=>bcrypt('trocar-esta-senha'),'admin'=>true]);"
```

O acesso ao `/admin` exige `admin = true`. Sem essa marcação o usuário toma
403 — é o que impede qualquer conta criada no sistema de abrir o painel.

Já existe um usuário provisionado: **ti@santamonica.rec.br**, com senha
temporária `smcc2026`. **Troque essa senha antes de qualquer uso real.**

---

## Stack

| Camada | Escolha | Por quê |
|---|---|---|
| Framework | Laravel 13 | — |
| PHP | 8.3.28 (WampServer) | 8.2+ basta |
| Banco | SQLite | catálogo pequeno e cacheado; trocar para MySQL é só mudar o `.env` |
| Front | Blade + **JavaScript puro** | ver abaixo |
| Admin | Filament 5 | CRUD com upload, repeater e reordenação em um dia |
| Imagens | GD (nativa) | converte para WebP sem dependência externa |

### Por que JavaScript puro

Os cards são renderizados pelo servidor — conteúdo indexável e sem flash de
grade vazia. O payload inteiro chega junto com a página, então filtrar e buscar
é só alternar `hidden` nos cards já em tela. Não há o que um framework reativo
buscar aqui: sobrariam ~250 linhas de código e um bundle.

O resultado é **zero dependência de front-end**: sem `npm install`, sem Vite,
sem build. `public/css/app.css` e `public/js/app.js` são servidos como estão.

A ficha usa `<dialog>` nativo, que traz fechamento por Esc, foco preso e fundo
inerte sem uma linha de JS.

---

## Estrutura

```
app/
├── Console/Commands/OtimizarImagens.php   php artisan mapa:otimizar-imagens
├── Filament/
│   ├── Resources/{Locals,Categorias,MapVersions}/
│   └── Widgets/{ResumoClube,LocaisMaisVistos}.php
├── Http/Controllers/{MapaController,MetricaController}.php
├── Models/{Local,Categoria,LocalHorario,MapVersion,LocalView,User}.php
├── Observers/{LocalObserver,MapVersionObserver}.php
└── Services/{MapaService,OtimizadorImagem}.php

resources/views/
├── components/layouts/app.blade.php
├── filament/mapa-picker.blade.php          seletor visual de posição (admin)
└── locais/index.blade.php + partials/{header,card,ficha,footer}

public/
├── css/app.css      tokens do design, escritos à mão
└── js/app.js        busca, filtro, ficha, distância
```

---

## Decisões de interface

### Mobile

O celular é o caso principal — associado dentro do clube procurando um espaço.
O que mudou, e o efeito medido em 375 × 812:

| | Antes | Depois |
|---|---|---|
| Espaço antes do 1º card | 443 px (55% da tela) | **368 px (45%)** |
| Altura de um card | 358 px | **130 px** |
| Cards visíveis por tela | 1 | **4** |
| Rolagem para ver os 13 originais | 6,8 telas | **3,1 telas** |
| Categorias visíveis sem rolar | 2 de 8 | **8 de 8** |

O topo encolheu menos do que daria para encolher, porque mostrar as 7
categorias de uma vez custa três linhas. Foi troca deliberada: 75 px a mais de
cabeçalho, em troca de nenhuma categoria escondida.

Como:

- **Card horizontal** — foto de 108 px à esquerda, texto à direita, resumo em duas linhas (`line-clamp`). O texto "Ver detalhes" some; o card inteiro já é o alvo de toque.
- **Cabeçalho rola para fora**, dando a tela ao conteúdo.
- **Chamada do hero escondida** no celular: eram duas linhas empurrando a lista para baixo, e a busca já explica a página.
- **Alvos de toque de 44 px** nos chips e 50 px nos botões da ficha.
- **`font-size: 16px` no campo de busca**, que é o que impede o iOS de dar zoom ao focar.
- **`env(safe-area-inset-*)`** nas laterais e no rodapé, para telas com recorte.
- Abrir a ficha **trava a rolagem do fundo** (`body.tem-ficha`).

### Filtros: grade, não carrossel

No celular as 8 categorias ficam numa **grade de 3 colunas**, todas visíveis de
uma vez. A primeira tentativa foi um carrossel horizontal, e era ruim: mostrava
2 categorias, escondia 6, e ninguém varre uma faixa lateral atrás de uma opção
que não sabe que existe — o gesto ainda competia com a rolagem vertical da
lista.

Para caber, no celular o chip perde o **ícone** (decorativo, já que o rótulo
está ao lado) e o **contador** por categoria — juntos são ~50 px por chip, a
diferença entre duas e quatro linhas. A linha "N locais" logo abaixo mostra o
total do filtro ativo, que é o número que importa na hora. "Todos os locais"
encurta para "Todos".

A grade de colunas iguais também evita o chip órfão desalinhado que sobrava
quando as larguras eram livres.

### Marca

A logo vem do site do clube
(`config/mapa.php`, chave `logo`), e é a mesma imagem no header e no rodapé —
lá ela é embranquecida por `filter: brightness(0) invert(1)`, o que dispensa
manter um segundo arquivo. Nos dois casos precisa de `align-self: flex-start`,
senão o flex a estica na transversal e distorce a marca.

> É um *hotlink*: se o arquivo sair do ar ou mudar de endereço no WordPress do
> clube, a logo some do sistema. Para produção, o mais seguro é baixá-la para
> `storage/app/public/marca/` e apontar a config para lá.

### As 33 churrasqueiras

Cada churrasqueira é um **local próprio** (`churrasqueira-1` … `churrasqueira-33`),
porque cada uma tem endereço próprio dentro do clube. Elas têm **categoria
própria**, então ficam contidas no filtro "Churrasqueiras" e não atrapalham
quem procura outra coisa — sem essa categoria, 33 cards quase iguais tomariam
70% do catálogo.

Uma tentativa anterior modelou as 33 como "unidades" de um único card. Foi
descartada quando ficou claro que cada uma tem endereço próprio: sob um card só
não haveria onde guardar 33 endereços nem 33 links compartilháveis.

Locais reserváveis (as 33 churrasqueiras e o Salão de Eventos) ganham a tag
**Reservável** no card e o botão **Reservar** na ficha. Enquanto não houver
reserva online, o botão liga para a secretaria; preencher `reservas.url` em
`config/mapa.php` o transforma em link.

### Posição e endereço são opcionais

`x`, `y` e `area` aceitam nulo. Um local pode ser cadastrado antes de alguém ir
a campo marcar onde ele fica — é a situação das 33 churrasqueiras.

Sem posição, o card **não mostra distância** e a ficha esconde a linha "Onde
fica", em vez de exibir rótulo vazio ou um número inventado. Preencher x/y
depois faz o `LocalObserver` gerar a coordenada normalmente.

Na tabela do painel, `Endereço / setor` é editável direto na linha: são 33
endereços para preencher, e abrir cada ficha para isso seria sofrimento.

### Um único lugar para cada coisa

O filtro de categoria vive **só** na barra de chips abaixo da busca. O menu do
header ficou com logo e telefone: "Mapa", "Locais", "Serviços" e o botão "Ver
todos os locais" foram removidos porque os quatro levavam à própria página, e
"Serviços" era apenas mais uma categoria já presente nos filtros.

`/locais` redireciona para `/` — existia como página separada quando havia um
mapa para diferenciar dela.

### Rodapé em fluxo normal

O rodapé fica depois da grade, em fluxo normal do documento. Antes a página era
um shell `height:100vh; overflow:hidden` com o rodapé de altura fixa dentro —
arranjo que o fazia sobrepor o conteúdo assim que a tela era baixa.

### Sem `backdrop-filter`

O header usa fundo sólido. O blur custa composição a cada scroll e pesa no
totem da portaria e em celulares antigos.

---

## Como funciona por dentro

### x/y é a fonte da verdade das coordenadas

O site não mostra mais a planta, mas cada local continua guardando uma posição
percentual nela (`x`, `y` de 0 a 100), de onde sai o cálculo de distância
interna:

```
latitude  = base_lat + (50 - y) / 100 * span_lat
longitude = base_lng + (x - 50) / 100 * span_lng
```

Com âncora em `(-22.74190, -47.31260)` e extensão `0.0072 × 0.0087` graus
(~800 × 890 m). O `LocalObserver` recalcula no `saving`, então mover o marcador
no painel já atualiza a distância mostrada nos cards.

A planta sobrevive como **ferramenta interna do admin**: clicar nela é mais
fácil que digitar latitude e longitude à mão.

A projeção é linear — aproximação boa para um terreno desse tamanho (erro
abaixo de 5 m). Se o clube levantar as coordenadas reais por GPS, inverta a
relação e passe a calcular x/y a partir de lat/lng.

### Payload único

`MapaService::payload()` monta locais, categorias e a origem padrão das
distâncias, e cacheia por uma hora. A página serializa esse array em um
`<script type="application/json">` e o JS trabalha em memória a partir daí.
Nenhum XHR no caminho crítico.

A origem padrão (a portaria) é resolvida **no servidor** — o front não precisa
saber que existe uma planta por trás das coordenadas.

O cache é invalidado por observers em `Local`, `Categoria`, `LocalHorario` e
`MapVersion`. **Sem isso a secretaria edita e não vê nada mudar** — é o defeito
mais comum nesse tipo de projeto, e o motivo de existir um teste só para ele.

### Distância e "Como chegar"

A distância é **interna, a pé, medida da portaria** — depende só da geometria
relativa da planta, então é confiável mesmo com a âncora geográfica ainda não
conferida em campo.

A geolocalização do visitante está **desativada de propósito**: ela mediria da
posição real dele até a âncora lat/lng da planta. Enquanto essa âncora não for
confirmada, o resultado seria absurdo. Reativar depois de acertar
`base_lat`/`base_lng` da MapVersion ativa (o código está em `public/js/app.js`,
com o comentário explicando).

O "Como chegar" aponta para o **place do clube no Google Maps**, igual para
todos os locais — quem vai de carro dirige até o clube, não até a quadra de
tênis. Fica em `config/mapa.php`, chave `contato.google_maps`.

### Valores de `config/` ficam fora do cache

`config/` não dispara os observers que limpam a chave `mapa.payload`. Se os
valores de contato fossem cacheados junto com os dados do banco, editar um
telefone serviria o número velho por até uma hora — ou quebraria a view, que
foi exatamente o que aconteceu ao acrescentar `telefone_link`. Por isso o
`payload()` cacheia só a parte do banco e mescla `config/` a cada chamada.
Há um teste para isso.

### Só uma planta ativa

`MapVersionObserver` desativa as outras ao ativar uma. Com duas plantas ativas
`MapVersion::ativa()` passa a devolver qualquer uma delas e as coordenadas de
todos os locais oscilam entre duas âncoras.

---

## Imagens

Os assets originais do protótipo pesavam **11,1 MB** (fotos de ~550 KB cada,
planta de 4,6 MB). Servir isso no 4G do clube — que é a condição real de uso —
tornava a página inutilizável.

```bash
php artisan mapa:otimizar-imagens          # converte para WebP
php artisan mapa:otimizar-imagens --force  # regera mesmo se já existir
```

Resultado medido:

| | Antes | Depois |
|---|---|---|
| Cada foto | ~550 KB PNG | ~23 KB (ficha) + ~10 KB (thumb) |
| Planta (só admin) | 4,6 MB PNG | **240 KB** WebP |
| Total dos assets | 11,1 MB | **653 KB** (−94%) |
| Primeiro carregamento | ~11 MB | **124 KB** |

Cada foto vira duas versões: 1200 px para a ficha e 400 px para o card. O card
carrega só o thumb; a foto grande só chega quando a ficha abre. E como a planta
saiu do site, ela não pesa mais no carregamento — fica só no painel.

Rode o comando **sempre que trocar uma foto pelo painel** — o upload guarda o
original, a conversão é um passo separado.

---

## Painel administrativo

- **Locais** — CRUD completo, reordenação por arrasto, filtro por categoria, toggle "no site" direto na tabela, repeater de horários. O destaque é o **seletor visual de posição**: clique na planta e o marcador vai para lá.
- **Categorias** — CRUD com preview do ícone SVG e reordenação.
- **Plantas do clube** — upload de nova planta e ajuste da âncora geográfica. Usada só para posicionar os locais.
- **Dashboard** — total de locais publicados, categorias ativas e consultas em 7/30 dias, mais o ranking dos 10 locais mais consultados.

O slug é a chave de rota, então as URLs do painel são `/admin/locals/academia/edit`.

---

## Testes

```bash
C:/wamp64/bin/php/php8.3.28/php.exe artisan test
```

41 testes, 111 asserções — todos passando. Cobrem:

- os 47 cards renderizados pelo servidor, com seus dados de busca e categoria
- as 33 churrasqueiras como locais próprios, todas reserváveis e com deep link
- o almoxarifado na categoria Serviços
- local sem posição não ganha coordenada, e ganha ao ser marcado depois
- deep links (`/locais/academia`) e o redirect de `/locais` para a home
- meta tags Open Graph do local (o que o WhatsApp mostra ao compartilhar)
- 404 para local inativo e para slug inexistente
- derivação de lat/lng ao mover o marcador
- origem padrão das distâncias resolvida no servidor
- contato completo no payload e refletido no rodapé
- mudança de `config/` aparecendo na hora, mesmo com o cache quente
- invalidação do cache ao editar local **e** ao editar horário
- idempotência do seeder (não duplica horários nem plantas)
- garantia de uma única planta ativa
- contador de visualizações agregando no mesmo dia
- painel: 403 sem permissão, 200 com, e renderização do seletor de posição

---

## Dados que ainda são do protótipo

O contato do clube já é real (`config/mapa.php`): Rodovia Régis Bittencourt
nº 5000, (41) 3675-4200, e o place do Google Maps. **Dois conjuntos de dados
continuam fictícios**, herdados do protótipo:

1. **Os telefones por local** — os 13 originais são `(19) 3406-10xx`, com DDD de
   outra região. Aparecem na ficha de cada local e precisam ser trocados pelos
   ramais reais (ou removidos, deixando só o telefone central do rodapé).
2. **A âncora geográfica da planta** — `base_lat -22.741900` / `base_lng
   -47.312600` fica na região de Campinas/SP, incompatível com o endereço e o
   DDD 41. Isso **não afeta** as distâncias internas (que são relativas) nem o
   "Como chegar" (que usa o place do Google), mas precisa ser corrigido antes
   de reativar a geolocalização.
3. **As 33 churrasqueiras estão sem endereço, sem posição e sem foto própria** —
   todas usam a foto do quiosque. Descrição e horário são iguais para as 33 e
   foram escritos como ponto de partida, não como texto aprovado. Endereço e
   posição foram deixados em branco de propósito: preencher as 33 com o mesmo
   palpite produziria 33 distâncias falsas na tela.

---

## Pendências antes do go-live

- [ ] Trocar a senha do usuário `ti@santamonica.rec.br`
- [ ] Substituir os telefones por local (ver seção acima)
- [ ] Preencher endereço, posição e foto das 33 churrasqueiras
- [ ] Definir o canal de reserva (`reservas.url` em `config/mapa.php`)
- [ ] Corrigir a âncora geográfica da planta, ou manter o GPS desligado
- [ ] Conferir com a secretaria os textos e horários dos locais
- [ ] HTTPS — `navigator.geolocation` e `navigator.share` não funcionam em HTTP, então sem TLS a distância real e o compartilhamento nativo deixam de existir
- [ ] Definir o `APP_URL` de produção
- [ ] Auto-hospedar a Plus Jakarta Sans e fechar a CSP (hoje vem do Google Fonts)
- [ ] Backup diário do banco e de `storage/app/public`
- [ ] Páginas 404 e 500 com a identidade do clube

## Roadmap

1. Badge "Aberto agora / Fecha às 20h" — exige migrar `local_horarios` de texto livre para dia da semana + hora estruturados
2. Ordenar por distância quando o visitante autoriza o GPS
3. Camada de eventos do dia ("Torneio de beach tennis hoje às 14h")
4. PWA com os cards em cache, para o sinal fraco dentro do terreno
