docs(readme): redesenha o README com hero em SVG [skip ci]

This commit is contained in:
evandro committed 2026-10-24 04:58:06 -03:00
1 parent 4567ae4786
commit a89251d7e0
2 files changed
+116 -113

No files matched your search

+58 -113
View File
@@ -1,113 +1,54 @@
<div align="center">
<img src="public/apple-touch-icon.png" width="110" alt="Ícone do NutriScan">
<img src="assets/readme/hero.svg" width="100%" alt="NutriScan: busca de alimentos no Open Food Facts por código de barras ou nome, mostrando um produto com Nutri-Score D e NOVA grupo 4">
# NutriScan
![Next.js 15.5](https://img.shields.io/badge/Next.js-15.5-317257?style=flat-square)
![React 19](https://img.shields.io/badge/React-19-317257?style=flat-square)
![Prisma 6 + SQLite](https://img.shields.io/badge/Prisma_6-SQLite-317257?style=flat-square)
![Licença: uso pessoal](https://img.shields.io/badge/licen%C3%A7a-uso_pessoal-317257?style=flat-square)
**Busca de produtos alimentícios sobre o Open Food Facts — por código de barras
ou nome, com filtros de Nutri-Score e NOVA, cache em camadas e listas de compras
por ID.**
![Next.js](https://img.shields.io/badge/Next.js-%5E15.5.10-informational)
![TypeScript](https://img.shields.io/badge/TypeScript-%5E5.9.3-informational)
![Licença](https://img.shields.io/badge/licen%C3%A7a-uso_pessoal-informational)
[Demo](https://nutriscan.evandro.dev.br) · [Recursos](#recursos) · [Como funciona](#como-funciona) · [Execução local](#execu%C3%A7%C3%A3o-local) · [Licença](#licen%C3%A7a)
[Notas da API Open Food Facts](docs/openFoodFacts.md) · [Issues](https://git.evandro.dev.br/evandro/nutriscan/issues)
</div>
> [!NOTE]
> **Estado do projeto:** redesign completo concluído em 2026-07-16 (`feat: complete
> NutriScan redesign`), seguido de correções de deploy com Prisma/SQLite e de
> integração do Rybbit Analytics. A instância pública do autor roda em
> [nutriscan.evandro.dev.br](https://nutriscan.evandro.dev.br). Não há suíte de
> testes automatizados no repositório; o único check disponível é `pnpm lint`.
O cache JSON do servidor vem no repositório (`data/products-cache.json`) com produtos reais do Open Food Facts. Contado por Nutri-Score, num clone novo e sem instalar nada:
> [!IMPORTANT]
> **Licença de Uso Pessoal:** o código só pode ser usado para fins pessoais e não
> comerciais, **apenas em localhost**. É proibido fazer deploy público,
> redistribuir, usar comercialmente ou criar obras derivadas para distribuição
> (texto completo em [`LICENSE`](LICENSE)). A instância pública acima é a do
> próprio autor.
```console
$ node -e "const p = Object.values(require('./data/products-cache.json').products); const g = {}; for (const x of p) g[x.nutrition_grades] = (g[x.nutrition_grades] || 0) + 1; console.log(p.length, g)"
232 { e: 26, unknown: 167, d: 24, c: 9, a: 2, b: 4 }
```
## Recursos
## O que é
NutriScan é um app web em Next.js para buscar produtos alimentícios no [Open Food Facts](https://world.openfoodfacts.org/) e montar listas de compras. A entrada é detectada sozinha: 8 a 13 dígitos viram busca por código de barras; o resto, busca por nome ou marca.
Páginas: `/` (landing), `/resultados`, `/produto/[barcode]`, `/lista/[id]` e `/sobre`.
| Recurso | O que faz |
|---|---|
| **Busca por código de barras ou nome** | O tipo é detectado sozinho: entrada com 8 a 13 dígitos é tratada como código de barras, o resto como nome/marca |
| **Filtros** | Nutri-Score A–E, grupos NOVA 1–4, categorias, marcas, alérgenos e aditivos (incluir/excluir, ou "sem aditivos"), faixas por 100 g de energia, gordura, carboidratos, proteínas, açúcares, fibras e sódio, e países |
| **12 critérios de ordenação** | Relevância, Nutri-Score, nome, marca, energia, gordura, açúcares, completude, com imagem, com dados nutricionais, Eco-Score e grupo NOVA, em ordem crescente ou decrescente |
| **Cache em camadas** | `localStorage` no navegador (TTL padrão de 1 hora) e um cache JSON no servidor (`data/products-cache.json`, 232 produtos no repositório) que é consultado antes do Open Food Facts |
| **Modo cache-only** | Em produção, o usuário pode ligar um switch para o servidor responder só com o cache JSON, sem consultar o Open Food Facts |
| **País por IP** | A rota `/api/location` descobre o país do visitante e a busca prioriza produtos dele; o resultado fica 7 dias no `localStorage`, com Brasil como fallback |
| **Listas de compras** | Várias listas acessíveis por ID em `/lista/[id]`; cada item tem nome, quantidade, unidade e status de comprado, persistidos com Prisma + SQLite |
| **Interface** | Tema claro/escuro, componentes shadcn/ui sobre Radix UI, animações com Framer Motion, skeletons, paginação e manifest de PWA |
Páginas: `/` (landing), `/resultados`, `/produto/[barcode]`, `/lista/[id]` e `/sobre`.
## Por que usar
- **Menos chamadas ao Open Food Facts.** A busca passa por dois caches antes da API: `localStorage` no navegador (TTL padrão de 1 hora) e o cache JSON no servidor. Tudo que vem da API é gravado no cache JSON.
- **Funciona sem a API.** No modo cache-only o servidor responde só com o cache JSON, sem consultar o Open Food Facts.
- **Filtros combinados**: Nutri-Score, NOVA, alérgenos, aditivos e faixas de nutrientes ao mesmo tempo, com 12 ordenações.
Quando não usar: se você precisa publicar o app ou usá-lo comercialmente (a licença só permite uso pessoal em localhost), ler código de barras pela câmera (a entrada é digitada ou colada) ou esperar Nutri-Score em cada produto (no cache acima, 167 de 232 produtos vieram sem nota).
## Como funciona
```mermaid
flowchart LR
U["Navegador<br/>SearchBar · FilterSidebar"] --> L{"localStorage<br/>TTL 1 h"}
L -->|hit| R["Resultados"]
L -->|miss| A["Rotas Next.js<br/>/api/search · /api/products/[barcode]"]
A --> J{"data/products-cache.json<br/>cache JSON no servidor"}
J -->|hit| R
J -->|"miss (fora do cache-only)"| O["Open Food Facts<br/>world.openfoodfacts.org"]
O -->|salva no cache JSON| R
P["/lista/[id]"] --> S["Server Actions<br/>app/actions.ts"] --> D["Prisma + SQLite"]
```
1. O navegador procura a busca no `localStorage` (`lib/cacheManager.ts`, `hooks/useSearchCache.ts`).
2. Se não achar, chama as rotas Next.js `/api/search` e `/api/products/[barcode]`.
3. As rotas consultam o cache JSON do servidor (`lib/jsonCacheManager.ts`, `data/products-cache.json`).
4. Fora do modo cache-only, o que falta vem do Open Food Facts (`lib/openFoodFactsApi.ts`: timeout de 8 s, 200 ms entre páginas, até 1000 produtos por página) e é gravado no cache JSON. `POST /api/cache/sync` também grava produtos enviados pelo cliente.
5. As listas de compras usam Server Actions (`app/actions.ts`) sobre Prisma + SQLite: modelos `Lista` e `Produtos` em `prisma/schema.prisma`.
<details>
<summary>Detalhes técnicos</summary>
| Peça | Onde | O que faz |
|---|---|---|
| Wrapper da API | `lib/openFoodFactsApi.ts` | Timeout de 8 s por requisição, intervalo de 200 ms entre páginas, páginas de até 1000 produtos, ordenação com score de completude |
| Cache do navegador | `lib/cacheManager.ts`, `hooks/useSearchCache.ts` | Buscas com filtros em `localStorage`, índice de chaves e estatísticas |
| Cache do servidor | `lib/jsonCacheManager.ts` | Singleton que lê e grava `data/products-cache.json` com merge dos produtos |
| Sincronização | `POST /api/cache/sync` | Recebe produtos do cliente e os grava no cache JSON |
| Estatísticas | `GET /api/storage-stats` | Estatísticas de armazenamento do cache |
| Localização | `GET /api/location` | IP via `x-forwarded-for`/`x-real-ip` → país via ip-api.com |
| Listas | `app/actions.ts` | `verificarLista`, `criarItens`, `listarProdutos`, `deletarProduto`, `toggleComprado` |
| Imagens | `next.config.mjs` | Libera `static.openfoodfacts.org` e `images.openfoodfacts.org`, formatos WebP/AVIF |
Modelos Prisma (`prisma/schema.prisma`, SQLite): `Lista` (`id`, timestamps) e
`Produtos` (`nome`, `qtd`, `tipoUN`, `comprado`, `listaId`).
Estrutura:
```text
app/ App Router: landing, /resultados, /produto, /lista, /sobre
app/api/ search, products/[barcode], location, cache/sync, storage-stats
app/actions.ts Server Actions das listas de compras
lib/ wrapper da API, caches (navegador e JSON), geolocalização, Prisma
hooks/ busca, filtros, cache-only, geolocalização, monitor de storage
components/ui/ componentes shadcn/ui
data/ products-cache.json
prisma/ schema.prisma
docs/ notas da API Open Food Facts e material do redesign
```
</details>
## Limitações conhecidas
- O switch de cache-only só existe em produção (`NODE_ENV=production`); em desenvolvimento ele fica desligado.
- Toda busca nova depende do Open Food Facts: o wrapper aplica timeout de 8 s e intervalo de 200 ms entre páginas, mas não há fila ou fallback além do cache.
- A localização é por IP (o servidor consulta o ip-api.com), não pela API de geolocalização do navegador.
- O código de barras é digitado ou colado; não há leitura por câmera.
- O `sitemap.xml` lista só a página inicial.
## Execução local
> [!WARNING]
> **Não existe `.env.example`** e **`pnpm db:migrate` não funciona num clone novo**:
> ele roda `prisma migrate deploy`, mas `/prisma/migrations` está no `.gitignore`.
> Crie o banco com `pnpm db:push`. O `DATABASE_URL` é resolvido a partir da pasta
> `prisma/`, então `file:./db/database.sqlite` vira `prisma/db/database.sqlite`
> (pasta também ignorada pelo git).
## Início rápido
Requisitos: Node.js 18+ (o deploy usa `nodejs_18`) e pnpm (o deploy fixa `[email protected]`).
@@ -123,42 +64,46 @@ pnpm db:push # cria o banco SQLite a partir do schema
pnpm dev # http://localhost:3000
```
Variáveis opcionais lidas pelo código:
> [!WARNING]
> **Não existe `.env.example`** e **`pnpm db:migrate` não funciona num clone novo**: ele roda `prisma migrate deploy`, mas `/prisma/migrations` está no `.gitignore`. Crie o banco com `pnpm db:push`. O `DATABASE_URL` é resolvido a partir da pasta `prisma/`, então `file:./db/database.sqlite` vira `prisma/db/database.sqlite` (pasta também ignorada pelo git).
Outros scripts: `pnpm build` (`prisma generate && next build`), `pnpm start`, `pnpm lint` (`next lint`), `pnpm db:generate`.
## Configuração
| Variável | Efeito |
|---|---|
| `NEXT_PUBLIC_CACHE_ONLY_MODE=true` | A rota de produto responde só com o cache JSON |
| `DATABASE_URL` | Banco SQLite das listas de compras (obrigatória) |
| `NEXT_PUBLIC_CACHE_ONLY_MODE=true` | As rotas de busca e de produto respondem só com o cache JSON |
| `NEXT_PUBLIC_RYBBIT_HOST` | Destino do proxy `/analytics/*` (padrão `https://analytics.evandro.dev.br/api`) |
| Script | Comando |
|---|---|
| `pnpm dev` | `next dev` |
| `pnpm build` | `prisma generate && next build` |
| `pnpm start` | `next start` |
| `pnpm lint` | `next lint` |
| `pnpm db:push` | `prisma db push` |
| `pnpm db:generate` | `prisma generate` |
| `pnpm db:migrate` | `prisma migrate deploy` (exige migrações locais, veja o aviso acima) |
O switch de cache-only na interface só funciona em produção (`NODE_ENV=production`); as rotas `/api/search` e `/api/products/[barcode]` aceitam `?cache_only=true` em qualquer ambiente.
## Deploy
O que sai do seu servidor:
O repositório traz duas configurações, sem workflow de CI:
- **Open Food Facts**: dados e imagens de produtos (`next.config.mjs` libera `static.openfoodfacts.org` e `images.openfoodfacts.org`).
- **ip-api.com**: o IP do visitante, enviado pelo servidor por HTTP, para descobrir o país.
- **Rybbit Analytics** (self-hosted, via proxy `/analytics/`): com session replay e rastreamento de erros.
- O app também exibe um card de doação via [Buy Me a Coffee](https://buymeacoffee.com/evandrotruuta).
- [`dokploy.yaml`](dokploy.yaml): instala o pnpm, roda `pnpm install --no-frozen-lockfile`, `pnpx prisma generate` e `pnpm run build`.
- [`nixpacks.toml`](nixpacks.toml): `nodejs_18` + `openssl`, `[email protected]`, `DATABASE_URL=file:./db/database.sqlite`, `prisma db push --accept-data-loss`, build e `pnpm start`.
## Estado do projeto
Projeto pessoal, sem suíte de testes; o único check é `pnpm lint`. O último trabalho de código foi o redesign completo e os ajustes no deploy com Prisma/SQLite, em 2026-07-16. O repositório traz [`dokploy.yaml`](dokploy.yaml) e [`nixpacks.toml`](nixpacks.toml) para o deploy do autor, sem workflow de CI. A instância pública do autor em `nutriscan.evandro.dev.br` não respondia com o app em 2026-10-24.
Limites conhecidos:
- Toda busca nova depende do Open Food Facts; além do cache, não há fila nem fallback.
- A localização é por IP, não pela API de geolocalização do navegador.
- O `sitemap.xml` lista só a página inicial.
## Licença
Licença de Uso Pessoal, em [`LICENSE`](LICENSE) (Copyright (c) 2024 Evandro): uso
pessoal, não comercial e local; sem deploy público, redistribuição ou obras
derivadas. Dúvidas sobre a licença: evandro@evandro.dev.br.
Licença de Uso Pessoal, em [`LICENSE`](LICENSE) (Copyright (c) 2024 Evandro): uso pessoal, não comercial e só em localhost. É proibido fazer deploy público, redistribuir, usar comercialmente ou criar obras derivadas para distribuição. Dúvidas sobre a licença: evandro@evandro.dev.br.
Bugs e sugestões: [issues do repositório](https://git.evandro.dev.br/evandro/nutriscan/issues).
Autor: [evandro.dev.br](https://evandro.dev.br).
Créditos: [Open Food Facts](https://world.openfoodfacts.org/), [Next.js](https://nextjs.org/), [Prisma](https://www.prisma.io/), [shadcn/ui](https://ui.shadcn.com/) e [Radix UI](https://www.radix-ui.com/).
## Avisos
<div align="center">
- **Analytics:** o app carrega o Rybbit Analytics (self-hosted, via proxy `/analytics/`) com session replay e rastreamento de erros.
- **Dados externos:** a localização envia o IP do visitante, pelo servidor, ao [ip-api.com](https://ip-api.com); os dados de produtos vêm do [Open Food Facts](https://world.openfoodfacts.org/).
- **Doação:** o app exibe um card de doação via [Buy Me a Coffee](https://buymeacoffee.com/evandrotruuta).
- **Créditos:** [Open Food Facts](https://world.openfoodfacts.org/), [Next.js](https://nextjs.org/), [Prisma](https://www.prisma.io/), [shadcn/ui](https://ui.shadcn.com/) e [Radix UI](https://www.radix-ui.com/).
<sub>Feito por <a href="https://evandro.dev.br">Evandro Fonseca Junior</a> · <a href="https://git.evandro.dev.br/evandro">git.evandro.dev.br</a></sub>
</div>
+58
View File
@@ -0,0 +1,58 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="420" viewBox="0 0 1200 420" role="img" aria-labelledby="title desc">
<title id="title">NutriScan</title>
<desc id="desc">NutriScan busca produtos alimentícios no Open Food Facts por código de barras ou nome. À direita, a busca pelo código 7891962032450 devolve o Biscoito Maizena Bauducco com Nutri-Score D e NOVA grupo 4, dados reais do cache do repositório.</desc>
<rect width="1200" height="420" rx="24" fill="#f4f0e8"/>
<g id="title-block" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif">
<text x="64" y="104" font-family="ui-monospace, 'SF Mono', Consolas, monospace" font-size="20" fill="#7c6a54">Open Food Facts · busca e listas</text>
<text x="64" y="186" font-size="72" font-weight="700" fill="#1e1810">NutriScan</text>
<text x="64" y="246" font-size="28" fill="#1e1810">Busque alimentos por código de barras</text>
<text x="64" y="282" font-size="28" fill="#1e1810">ou nome e compare Nutri-Score e NOVA.</text>
<text x="64" y="344" font-family="ui-monospace, 'SF Mono', Consolas, monospace" font-size="20" fill="#317257">Next.js 15 · Prisma + SQLite</text>
</g>
<g id="project-proof" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif">
<rect x="632" y="48" width="504" height="324" rx="20" fill="#ffffff" stroke="#e8e0d0" stroke-width="2"/>
<g id="search">
<rect x="664" y="80" width="440" height="64" rx="12" fill="#faf8f5" stroke="#d4c8b0" stroke-width="2"/>
<g fill="#1e1810">
<rect x="684" y="96" width="3" height="32"/>
<rect x="690" y="96" width="2" height="32"/>
<rect x="695" y="96" width="5" height="32"/>
<rect x="703" y="96" width="2" height="32"/>
<rect x="708" y="96" width="3" height="32"/>
<rect x="715" y="96" width="2" height="32"/>
<rect x="720" y="96" width="5" height="32"/>
<rect x="728" y="96" width="2" height="32"/>
<rect x="733" y="96" width="3" height="32"/>
</g>
<text x="752" y="121" font-family="ui-monospace, 'SF Mono', Consolas, monospace" font-size="24" fill="#1e1810">7891962032450</text>
<text x="1084" y="119" font-family="ui-monospace, 'SF Mono', Consolas, monospace" font-size="18" fill="#317257" text-anchor="end">EAN-13</text>
</g>
<text x="664" y="204" font-size="26" font-weight="700" fill="#1e1810">Biscoito Maizena</text>
<text x="664" y="236" font-size="20" fill="#7c6a54">Bauducco · pacote 170 g</text>
<g id="nutri-score" font-weight="700" text-anchor="middle">
<text x="664" y="282" font-family="ui-monospace, 'SF Mono', Consolas, monospace" font-size="18" font-weight="400" fill="#7c6a54" text-anchor="start">NUTRI-SCORE</text>
<rect x="664" y="300" width="44" height="44" rx="8" fill="#038141" fill-opacity="0.3"/>
<text x="686" y="330" font-size="22" fill="#1e1810">A</text>
<rect x="712" y="300" width="44" height="44" rx="8" fill="#85bb2f" fill-opacity="0.3"/>
<text x="734" y="330" font-size="22" fill="#1e1810">B</text>
<rect x="760" y="300" width="44" height="44" rx="8" fill="#fecb02" fill-opacity="0.3"/>
<text x="782" y="330" font-size="22" fill="#1e1810">C</text>
<rect x="808" y="292" width="60" height="60" rx="10" fill="#ee8100"/>
<text x="838" y="333" font-size="32" fill="#ffffff">D</text>
<rect x="872" y="300" width="44" height="44" rx="8" fill="#e63e11" fill-opacity="0.3"/>
<text x="894" y="330" font-size="22" fill="#1e1810">E</text>
</g>
<g id="nova">
<text x="964" y="282" font-family="ui-monospace, 'SF Mono', Consolas, monospace" font-size="18" fill="#7c6a54">NOVA</text>
<rect x="964" y="300" width="140" height="44" rx="22" fill="#1e1810"/>
<text x="1034" y="329" font-size="20" font-weight="700" fill="#ffffff" text-anchor="middle">grupo 4</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB