13 Commits

Author SHA1 Message Date
Felipe Coutinho
3e80d5995b chore(release): v2.4.1
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-16 21:16:44 +00:00
Felipe Coutinho
68daae7926 fix(docker): fixar PGDATA para compatibilidade com postgres:18-alpine (#41)
Container do PostgreSQL falhava ao iniciar em instalações existentes
após atualização da imagem: o entrypoint passou a recusar dados no
caminho legado. Variável PGDATA fixa o caminho e preserva dados de
quem já tinha o volume populado.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-16 21:16:40 +00:00
Felipe Coutinho
9413c470a8 style(ui): restaurar indentação tabs no dashboard layout
PR #42 trocou tabs por spaces no arquivo inteiro, quebrando o Biome.
Revertido pelo lint:fix para manter consistência com o resto do projeto.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-16 21:16:35 +00:00
Felipe Coutinho
ad1b0aa979 refactor(settings): remover tab órfã de Integrações
A tab foi introduzida no PR #42 mas não tinha TabsContent correspondente
e o value tinha typo ("intergrations") — UI vazia.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-16 21:16:31 +00:00
Felipe Coutinho
4d9a1c0a35 perf(db): otimizar índices — remover 7 sem uso, adicionar 17 em FKs
Baseado em análise do pg_stat_user_indexes (187 dias de estatísticas):
removidos 7 índices com 0 scans e adicionados 17 índices em foreign
keys que antes geravam sequential scans durante deletes nas tabelas pai.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-16 21:16:26 +00:00
Felipe Coutinho
5635705c56 feat(ui): layout animado auth, capitalização navbar (PR #42) 2026-04-16 15:19:53 +00:00
Alexsandro
4c97ed569d Merge branch 'main' into feat/fix-ui 2026-04-16 11:52:37 -03:00
Alexsandro
22a88de993 style(ui): update auth pages layout and navigation capitalization
This commit improves the visual design of the auth pages by adding a new layout wrapper with an animated blob background effect and updating the auth card shell with a glassmorphism style. It also updates the navigation items to use capitalized labels instead of lowercase for better readability.
2026-04-15 14:35:44 -03:00
Felipe Coutinho
9456aa98bc fix(ci): passar NEXT_PUBLIC_LOGO_DEV_TOKEN como build arg no Docker
NEXT_PUBLIC_* é inlined pelo Next.js em build time — a variável precisa
ser injetada via ARG no Dockerfile e build-args no workflow do CI.
Sem isso, o token fica undefined e os logos nunca são exibidos.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 01:06:23 +00:00
Felipe Coutinho
21c6a8d9d0 fix(lint): corrigir schema biome.json e formatação de imports
- biome.json: bump schema 2.4.10 → 2.4.11
- establishment-logo-picker.tsx, establishment-logo.tsx, navigation-menu.tsx, logo/index.ts: organizar imports e ajustar formatação conforme Biome 2.4.11

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 00:31:46 +00:00
Felipe Coutinho
c29ffa9a12 docs: registrar v2.4.0 — integração Logo.dev; atualizar screenshots
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 00:27:03 +00:00
Felipe Coutinho
8875de843b chore(deps): separar radix-ui em pacotes individuais e atualizar dependências; bump 2.4.0
- Remove pacote `radix-ui` (bundle monolítico); importa direto `@radix-ui/react-navigation-menu` e `@radix-ui/react-slider`
- Bump: @ai-sdk/* , @aws-sdk/* , @tanstack/react-query, ai, resend, dotenv, knip, @biomejs/biome, @types/node

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 00:26:56 +00:00
Felipe Coutinho
679ea752bb feat(logo): integração Logo.dev para logos automáticos de estabelecimentos
- Nova tabela `establishment_logos` no schema (userId + nameKey → domain)
- Utilitários: `buildLogoDevUrl`, `toNameKey`, `logoQueryKeys`, `LOGO_DEV_TOKEN`
- `EstablishmentLogo`: exibe logo via Logo.dev com fallback para iniciais; hover mostra ícone de edição
- `EstablishmentLogoPicker`: popover para buscar e fixar domínio Logo.dev por estabelecimento
- API routes: `GET /api/logo/mapping` e `GET /api/logo/search`
- Server actions/queries para persistência do mapeamento por usuário
- CSP: libera `https://img.logo.dev` em `img-src`
- `.env.example`: variáveis `NEXT_PUBLIC_LOGO_DEV_TOKEN` e `LOGO_DEV_SECRET_KEY`

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 00:26:48 +00:00
39 changed files with 4009 additions and 842 deletions

View File

@@ -3,10 +3,10 @@
# ============================================ # ============================================
# === Database === # === Database ===
# PostgreSQL local (Docker): use host "db" # Desenvolvimento local (pnpm dev): use host "localhost" (padrão abaixo)
# PostgreSQL local (sem Docker): use host "localhost" # Docker Compose completo: o compose.yml define DATABASE_URL automaticamente com host "db"
# PostgreSQL remoto: use URL completa do provider # PostgreSQL remoto: use URL completa do provider
DATABASE_URL=postgresql://openmonetis:openmonetis_dev_password@db:5432/openmonetis_db DATABASE_URL=postgresql://openmonetis:openmonetis_dev_password@localhost:5432/openmonetis_db
# Credenciais do PostgreSQL (apenas para Docker local) - Alterar # Credenciais do PostgreSQL (apenas para Docker local) - Alterar
POSTGRES_USER=openmonetis POSTGRES_USER=openmonetis
@@ -55,3 +55,10 @@ ANTHROPIC_API_KEY=
OPENAI_API_KEY= OPENAI_API_KEY=
GOOGLE_GENERATIVE_AI_API_KEY= GOOGLE_GENERATIVE_AI_API_KEY=
OPENROUTER_API_KEY= OPENROUTER_API_KEY=
# === Logo.dev (Opcional) ===
# Logos automáticos de estabelecimentos. Cadastre em https://www.logo.dev
# NEXT_PUBLIC_LOGO_DEV_TOKEN — token público (aparece no frontend, ok por design)
# LOGO_DEV_SECRET_KEY — chave secreta (apenas server-side, nunca expor)
NEXT_PUBLIC_LOGO_DEV_TOKEN=
LOGO_DEV_SECRET_KEY=

View File

@@ -85,6 +85,8 @@ jobs:
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
build-args: |
NEXT_PUBLIC_LOGO_DEV_TOKEN=${{ secrets.NEXT_PUBLIC_LOGO_DEV_TOKEN }}
- name: Image digest - name: Image digest
run: echo ${{ steps.meta.outputs.digest }} run: echo ${{ steps.meta.outputs.digest }}

View File

@@ -7,6 +7,45 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
## [Unreleased] ## [Unreleased]
## [2.4.1] - 2026-04-16
### Adicionado
- UI/Auth: layout animado nas páginas de login e signup com efeito blob (3 círculos coloridos em movimento) e card com glassmorphism; layout compartilhado extraído para `app/(auth)/layout.tsx` eliminando duplicação (PR #42)
- DB: 17 índices em foreign keys — evita sequential scans em deletes nas tabelas pai. Impacto maior nas FKs de `lancamentos` (conta_id, categoria_id, antecipacao_id), onde deletes em `categorias` antes provocavam full scan na tabela de lançamentos
### Alterado
- UI/Navbar: labels capitalizados (Lançamentos, Categorias, Contas) em vez de caixa baixa — melhora legibilidade (PR #42)
### Removido
- DB: 7 índices sem uso — `tokens_api_user_id_idx`, `cartoes_user_id_status_idx`, `contas_user_id_status_idx`, `pagadores_user_id_status_idx`, `pagadores_user_id_role_idx`, `dashboard_notification_states_user_id_archived_idx`, `antecipacoes_parcelas_series_id_idx` (0 scans em 187 dias de estatísticas)
- UI/Settings: tab de Integrações órfã removida (não tinha `TabsContent` correspondente)
### Corrigido
- Docker: container do PostgreSQL falhava ao iniciar em instalações existentes após atualização da imagem `postgres:18-alpine` — entrypoint passou a recusar dados no caminho legado `/var/lib/postgresql/data`. Adicionada variável `PGDATA` no `docker-compose.yml` para fixar o caminho e preservar dados de quem já tinha o volume populado (resolve #41)
## [2.4.0] - 2026-04-13
### Adicionado
- Estabelecimentos: integração com Logo.dev — logos automáticos de marcas exibidos na coluna de estabelecimentos nos lançamentos
- Estabelecimentos: picker de logo por estabelecimento — clique no avatar para buscar e fixar um domínio Logo.dev específico (salvo por usuário no banco)
- API: rotas `/api/logo/search` e `/api/logo/mapping` — proxy seguro para Logo.dev Brand Search API (secret key server-side) e consulta de mapeamentos salvos
- Schema: tabela `establishment_logos` com PK composta `(user_id, name_key)` para persistir preferências de logo por usuário
### Corrigido
- Dev: `.env.example` usava host `db` no `DATABASE_URL`, causando erro `EAI_AGAIN` ao rodar `pnpm dev` localmente — corrigido para `localhost`
### Documentação
- README: tabela comparativa entre Perfil 1 (Usar) e Perfil 2 (Desenvolver) com diferenças de setup, `DATABASE_URL` e instruções de atualização
- README: seção "Variáveis de Ambiente" esclarecida — distingue contexto Docker (Perfil 1) de desenvolvimento local (Perfil 2)
- Logo.dev: crie uma conta em logo.dev para obter as chaves `NEXT_PUBLIC_LOGO_DEV_TOKEN` e `LOGO_DEV_SECRET_KEY` — plano gratuito inclui 500.000 requisições/mês
## [2.3.8] - 2026-04-12 ## [2.3.8] - 2026-04-12
### Alterado ### Alterado

View File

@@ -40,6 +40,10 @@ COPY --from=deps /app/public/pdf.worker.min.mjs ./public/pdf.worker.min.mjs
ENV NEXT_TELEMETRY_DISABLED=1 \ ENV NEXT_TELEMETRY_DISABLED=1 \
NODE_ENV=production NODE_ENV=production
# Token público do Logo.dev — injetado em build time (NEXT_PUBLIC_* é inlined pelo Next.js)
ARG NEXT_PUBLIC_LOGO_DEV_TOKEN
ENV NEXT_PUBLIC_LOGO_DEV_TOKEN=$NEXT_PUBLIC_LOGO_DEV_TOKEN
# Build da aplicação Next.js # Build da aplicação Next.js
RUN pnpm build RUN pnpm build

View File

@@ -8,7 +8,7 @@
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor. > **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
[![Version](https://img.shields.io/badge/version-2.3.8-blue?style=flat-square)](CHANGELOG.md) [![Version](https://img.shields.io/badge/version-2.4.1-blue?style=flat-square)](CHANGELOG.md)
[![Next.js](https://img.shields.io/badge/Next.js-black?style=flat-square&logo=next.js)](https://nextjs.org/) [![Next.js](https://img.shields.io/badge/Next.js-black?style=flat-square&logo=next.js)](https://nextjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/) [![TypeScript](https://img.shields.io/badge/TypeScript-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-blue?style=flat-square&logo=postgresql)](https://www.postgresql.org/) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-blue?style=flat-square&logo=postgresql)](https://www.postgresql.org/)
@@ -107,6 +107,20 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
Escolha o perfil que corresponde ao seu objetivo: Escolha o perfil que corresponde ao seu objetivo:
| | Perfil 1 — Usar | Perfil 2 — Desenvolver |
|---|---|---|
| **Objetivo** | Rodar o app pronto | Modificar o código |
| **Clonar repositório** | Não | Sim |
| **Node.js / pnpm** | Não | Sim (Node 22+) |
| **Docker** | Sim | Sim |
| **Como iniciar** | `docker compose up -d` | `pnpm docker:db` + `pnpm dev` |
| **App roda em** | Container Docker | Host local (hot-reload) |
| **Banco roda em** | Container Docker | Container Docker |
| **`DATABASE_URL` (host)** | `db` (automático pelo compose) | `localhost` |
| **Banco remoto (Supabase, Neon...)** | Sim (`docker compose up -d app`) | Sim (ajustar `DATABASE_URL`) |
| **Como atualizar** | `pnpm docker:update` | `git pull` + `pnpm install` + `pnpm db:push` |
| **Indicado para** | Self-hosting, VPS, servidor | Contribuidores, customizações |
--- ---
### Perfil 1 — Usar (self-hosting) ### Perfil 1 — Usar (self-hosting)
@@ -148,6 +162,16 @@ sudo sh install-deps.sh
> Ao final, faça **logout e login** para as permissões do grupo `docker` terem efeito. > Ao final, faça **logout e login** para as permissões do grupo `docker` terem efeito.
#### Atualizando (Perfil 1)
```bash
pnpm docker:update
# ou equivalente:
docker compose pull && docker compose up -d
```
O schema do banco é aplicado automaticamente no startup — nenhum passo extra necessário.
--- ---
### Perfil 2 — Desenvolver ### Perfil 2 — Desenvolver
@@ -166,7 +190,8 @@ pnpm install
# 3. Configure o ambiente # 3. Configure o ambiente
cp .env.example .env cp .env.example .env
# Edite o .env com suas configurações # O DATABASE_URL já vem com host "localhost" (correto para dev local).
# Edite o .env com suas configurações (BETTER_AUTH_SECRET, etc.)
# 4. Suba o banco # 4. Suba o banco
pnpm docker:db pnpm docker:db
@@ -185,6 +210,16 @@ Acesse em: `http://localhost:3000`
Toda vez que salvar um arquivo, o app atualiza automaticamente sem precisar reiniciar. Toda vez que salvar um arquivo, o app atualiza automaticamente sem precisar reiniciar.
#### Atualizando (Perfil 2)
```bash
git pull
pnpm install # instala dependências novas, se houver
pnpm db:push # aplica mudanças de schema, se houver
```
O `pnpm dev` já em execução detecta as mudanças de código automaticamente — não precisa reiniciar.
--- ---
## 📜 Scripts Disponíveis ## 📜 Scripts Disponíveis
@@ -355,13 +390,49 @@ S3_BUCKET=
--- ---
## 🏷️ Logos de Estabelecimentos (Logo.dev)
O app exibe logos automáticos de marcas na coluna de estabelecimentos nos lançamentos. A integração usa a [Logo.dev](https://www.logo.dev) e é opcional — sem ela, o app exibe as iniciais coloridas normalmente.
### Variáveis
```env
NEXT_PUBLIC_LOGO_DEV_TOKEN=pk_... # token público (obrigatório para exibir logos)
LOGO_DEV_SECRET_KEY=sk_... # chave secreta (obrigatório para o picker de busca)
```
### Como configurar
**Self-hosted via Docker Hub (Coolify, Railway, etc.):**
O `NEXT_PUBLIC_LOGO_DEV_TOKEN` é inlinado pelo Next.js **em build time** — ele não pode ser injetado como variável de ambiente em runtime. Por isso o processo é diferente do usual:
1. Cadastre o secret `NEXT_PUBLIC_LOGO_DEV_TOKEN` no repositório GitHub Fork (Settings → Secrets → Actions)
2. O workflow de CI já está configurado para passar o valor como `--build-arg` no `docker build`
3. Faça um novo build (push ou Run workflow manual) — a imagem gerada já terá o token embutido
4. No Coolify (ou outro host), adicione apenas `LOGO_DEV_SECRET_KEY` como variável de ambiente runtime
**Desenvolvimento local:**
Adicione as duas variáveis no `.env` normalmente — o Next.js as lê em `pnpm dev` sem nenhuma etapa extra.
### Como usar
Após configurado, passe o mouse sobre o avatar de qualquer estabelecimento nos lançamentos — um ícone de lápis aparece. Clique para abrir o picker, busque pelo nome da marca e selecione o logo desejado. O mapeamento fica salvo por usuário no banco.
---
## 🔐 Variáveis de Ambiente ## 🔐 Variáveis de Ambiente
Copie `.env.example` para `.env` e configure: **Perfil 2 (dev):** copie `.env.example` para `.env` — o `DATABASE_URL` já vem com `localhost`, pronto para uso com `pnpm dev`.
**Perfil 1 (Docker):** não precisa definir `DATABASE_URL` — o compose já configura automaticamente com host `db`. Só defina se usar banco remoto (Supabase, Neon, etc.).
### Obrigatórias ### Obrigatórias
```env ```env
# Perfil 2 (dev): host "localhost" — o banco roda em container, o app no host
# Perfil 1 (Docker): não precisa definir — o compose usa "db" automaticamente
DATABASE_URL=postgresql://openmonetis:openmonetis_dev_password@localhost:5432/openmonetis_db DATABASE_URL=postgresql://openmonetis:openmonetis_dev_password@localhost:5432/openmonetis_db
BETTER_AUTH_SECRET=seu-secret-aqui # openssl rand -base64 32 BETTER_AUTH_SECRET=seu-secret-aqui # openssl rand -base64 32
BETTER_AUTH_URL=http://localhost:3000 BETTER_AUTH_URL=http://localhost:3000
@@ -398,6 +469,11 @@ ANTHROPIC_API_KEY=
OPENAI_API_KEY= OPENAI_API_KEY=
GOOGLE_GENERATIVE_AI_API_KEY= GOOGLE_GENERATIVE_AI_API_KEY=
OPENROUTER_API_KEY= OPENROUTER_API_KEY=
# Logo.dev (opcional, necessário para logos automáticos de estabelecimentos)
# NEXT_PUBLIC_LOGO_DEV_TOKEN deve ser passado como build arg no CI — veja seção Logo.dev
NEXT_PUBLIC_LOGO_DEV_TOKEN=
LOGO_DEV_SECRET_KEY=
``` ```
--- ---

View File

@@ -1,5 +1,5 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.4.10/schema.json", "$schema": "https://biomejs.dev/schemas/2.4.11/schema.json",
"vcs": { "vcs": {
"enabled": true, "enabled": true,
"clientKind": "git", "clientKind": "git",

View File

@@ -10,6 +10,7 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-openmonetis_dev_password} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-openmonetis_dev_password}
POSTGRES_DB: ${POSTGRES_DB:-openmonetis_db} POSTGRES_DB: ${POSTGRES_DB:-openmonetis_db}
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C" POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C"
PGDATA: /var/lib/postgresql/data
ports: ports:
- "${DB_PORT:-5432}:5432" - "${DB_PORT:-5432}:5432"
volumes: volumes:

View File

@@ -0,0 +1,24 @@
DROP INDEX "tokens_api_user_id_idx";--> statement-breakpoint
DROP INDEX "cartoes_user_id_status_idx";--> statement-breakpoint
DROP INDEX "dashboard_notification_states_user_id_archived_idx";--> statement-breakpoint
DROP INDEX "contas_user_id_status_idx";--> statement-breakpoint
DROP INDEX "antecipacoes_parcelas_series_id_idx";--> statement-breakpoint
DROP INDEX "pagadores_user_id_status_idx";--> statement-breakpoint
DROP INDEX "pagadores_user_id_role_idx";--> statement-breakpoint
CREATE INDEX "account_user_id_idx" ON "account" USING btree ("userId");--> statement-breakpoint
CREATE INDEX "anexos_user_id_idx" ON "anexos" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "orcamentos_categoria_id_idx" ON "orcamentos" USING btree ("categoria_id");--> statement-breakpoint
CREATE INDEX "cartoes_conta_id_idx" ON "cartoes" USING btree ("conta_id");--> statement-breakpoint
CREATE INDEX "import_category_mappings_category_id_idx" ON "import_category_mappings" USING btree ("category_id");--> statement-breakpoint
CREATE INDEX "pre_lancamentos_lancamento_id_idx" ON "pre_lancamentos" USING btree ("lancamento_id");--> statement-breakpoint
CREATE INDEX "antecipacoes_parcelas_lancamento_id_idx" ON "antecipacoes_parcelas" USING btree ("lancamento_id");--> statement-breakpoint
CREATE INDEX "antecipacoes_parcelas_pagador_id_idx" ON "antecipacoes_parcelas" USING btree ("pagador_id");--> statement-breakpoint
CREATE INDEX "antecipacoes_parcelas_categoria_id_idx" ON "antecipacoes_parcelas" USING btree ("categoria_id");--> statement-breakpoint
CREATE INDEX "anotacoes_user_id_idx" ON "anotacoes" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "passkey_user_id_idx" ON "passkey" USING btree ("userId");--> statement-breakpoint
CREATE INDEX "compartilhamentos_pagador_shared_with_user_id_idx" ON "compartilhamentos_pagador" USING btree ("shared_with_user_id");--> statement-breakpoint
CREATE INDEX "compartilhamentos_pagador_created_by_user_id_idx" ON "compartilhamentos_pagador" USING btree ("created_by_user_id");--> statement-breakpoint
CREATE INDEX "session_user_id_idx" ON "session" USING btree ("userId");--> statement-breakpoint
CREATE INDEX "lancamentos_conta_id_idx" ON "lancamentos" USING btree ("conta_id");--> statement-breakpoint
CREATE INDEX "lancamentos_categoria_id_idx" ON "lancamentos" USING btree ("categoria_id");--> statement-breakpoint
CREATE INDEX "lancamentos_antecipacao_id_idx" ON "lancamentos" USING btree ("antecipacao_id");

File diff suppressed because it is too large Load Diff

View File

@@ -176,6 +176,13 @@
"when": 1774891206703, "when": 1774891206703,
"tag": "0024_petite_lucky_pierre", "tag": "0024_petite_lucky_pierre",
"breakpoints": true "breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1776351838548,
"tag": "0025_burly_colonel_america",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "openmonetis", "name": "openmonetis",
"version": "2.3.8", "version": "2.4.1",
"private": true, "private": true,
"packageManager": "pnpm@10.33.0", "packageManager": "pnpm@10.33.0",
"scripts": { "scripts": {
@@ -18,32 +18,24 @@
"db:extensions": "tsx scripts/postgres/enable-extensions.ts", "db:extensions": "tsx scripts/postgres/enable-extensions.ts",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"postinstall": "cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/pdf.worker.min.mjs", "postinstall": "cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/pdf.worker.min.mjs",
"// --- Docker ---": "---",
"docker:up": "docker compose up -d", "docker:up": "docker compose up -d",
"//docker:up": "Sobe app (Docker Hub) + banco PostgreSQL em background", "//docker:up": "Sobe app (Docker Hub) + banco PostgreSQL em background",
"docker:db": "docker compose up -d db", "docker:db": "docker compose up -d db",
"//docker:db": "Sobe apenas o banco em background (para usar com pnpm dev)", "//docker:db": "Sobe apenas o banco em background (para usar com pnpm dev)",
"docker:down": "docker compose down", "docker:down": "docker compose down",
"//docker:down": "Para e remove os containers", "//docker:down": "Para e remove os containers",
"docker:logs": "docker compose logs -f", "docker:logs": "docker compose logs -f",
"//docker:logs": "Acompanha logs de todos os containers em tempo real", "//docker:logs": "Acompanha logs de todos os containers em tempo real",
"docker:update": "docker compose pull && docker compose up -d", "docker:update": "docker compose pull && docker compose up -d",
"//docker:update": "Atualiza para a imagem mais recente do Docker Hub e reinicia", "//docker:update": "Atualiza para a imagem mais recente do Docker Hub e reinicia",
"backup": "bash scripts/backup.sh" "backup": "bash scripts/backup.sh"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "^3.0.68", "@ai-sdk/anthropic": "^3.0.69",
"@ai-sdk/google": "^3.0.61", "@ai-sdk/google": "^3.0.63",
"@ai-sdk/openai": "^3.0.52", "@ai-sdk/openai": "^3.0.52",
"@aws-sdk/client-s3": "^3.1027.0", "@aws-sdk/client-s3": "^3.1030.0",
"@aws-sdk/s3-request-presigner": "^3.1027.0", "@aws-sdk/s3-request-presigner": "^3.1030.0",
"@better-auth/passkey": "^1.6.2", "@better-auth/passkey": "^1.6.2",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
@@ -57,11 +49,13 @@
"@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-dropdown-menu": "2.1.16",
"@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "2.1.8", "@radix-ui/react-label": "2.1.8",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "1.1.8", "@radix-ui/react-progress": "1.1.8",
"@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "2.2.6", "@radix-ui/react-select": "2.2.6",
"@radix-ui/react-separator": "1.1.8", "@radix-ui/react-separator": "1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "1.2.4", "@radix-ui/react-slot": "1.2.4",
"@radix-ui/react-switch": "1.2.6", "@radix-ui/react-switch": "1.2.6",
"@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-tabs": "1.1.13",
@@ -69,10 +63,10 @@
"@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toggle-group": "1.1.11",
"@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-tooltip": "1.2.8",
"@remixicon/react": "4.9.0", "@remixicon/react": "4.9.0",
"@tanstack/react-query": "^5.97.0", "@tanstack/react-query": "^5.99.0",
"@tanstack/react-table": "8.21.3", "@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "^3.13.23", "@tanstack/react-virtual": "^3.13.23",
"ai": "^6.0.154", "ai": "^6.0.159",
"better-auth": "1.6.2", "better-auth": "1.6.2",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"class-variance-authority": "0.7.1", "class-variance-authority": "0.7.1",
@@ -87,12 +81,11 @@
"next-themes": "0.4.6", "next-themes": "0.4.6",
"pdfjs-dist": "^5.6.205", "pdfjs-dist": "^5.6.205",
"pg": "8.20.0", "pg": "8.20.0",
"radix-ui": "^1.4.3",
"react": "19.2.5", "react": "19.2.5",
"react-day-picker": "^9.14.0", "react-day-picker": "^9.14.0",
"react-dom": "19.2.5", "react-dom": "19.2.5",
"recharts": "3.8.1", "recharts": "3.8.1",
"resend": "^6.10.0", "resend": "^6.11.0",
"sonner": "2.0.7", "sonner": "2.0.7",
"tailwind-merge": "3.5.0", "tailwind-merge": "3.5.0",
"vaul": "1.1.2", "vaul": "1.1.2",
@@ -104,16 +97,16 @@
} }
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.4.10", "@biomejs/biome": "2.4.11",
"@tailwindcss/postcss": "4.2.2", "@tailwindcss/postcss": "4.2.2",
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
"@types/node": "25.5.2", "@types/node": "25.6.0",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"@types/react": "19.2.14", "@types/react": "19.2.14",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"dotenv": "^17.4.1", "dotenv": "^17.4.2",
"drizzle-kit": "0.31.10", "drizzle-kit": "0.31.10",
"knip": "^6.3.1", "knip": "^6.4.1",
"tailwindcss": "4.2.2", "tailwindcss": "4.2.2",
"tsx": "4.21.0", "tsx": "4.21.0",
"typescript": "6.0.2" "typescript": "6.0.2"

704
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

After

Width:  |  Height:  |  Size: 163 KiB

23
src/app/(auth)/layout.tsx Normal file
View File

@@ -0,0 +1,23 @@
import { Logo } from "@/shared/components/logo";
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="relative flex min-h-svh flex-col items-center justify-center overflow-hidden bg-linear-to-b from-background via-background to-muted/20 px-5 py-8 md:px-8 md:py-10">
<div className="pointer-events-none absolute inset-0 overflow-hidden flex items-center justify-center">
<div className="absolute -right-32 top-0 h-96 w-96 rounded-full bg-primary/10 blur-3xl animate-blob mix-blend-multiply" />
<div className="absolute -left-32 bottom-0 h-96 w-96 rounded-full bg-primary/7 blur-3xl animate-blob animation-delay-2000 mix-blend-multiply" />
<div className="absolute -bottom-32 left-1/2 h-80 w-80 rounded-full bg-secondary/30 blur-3xl animate-blob animation-delay-4000 mix-blend-multiply" />
</div>
<div className="relative mb-6 flex md:hidden z-20">
<Logo variant="compact" colorIcon />
</div>
<div className="relative w-full max-w-sm md:max-w-5xl">{children}</div>
</div>
);
}

View File

@@ -1,21 +1,5 @@
import { LoginForm } from "@/features/auth/components/login-form"; import { LoginForm } from "@/features/auth/components/login-form";
import { Logo } from "@/shared/components/logo";
export default function LoginPage() { export default function LoginPage() {
return ( return <LoginForm />;
<div className="relative flex min-h-svh flex-col items-center justify-center overflow-hidden bg-linear-to-b from-background via-background to-muted/20 px-5 py-8 md:px-8 md:py-10">
<div className="pointer-events-none absolute inset-0">
<div className="absolute -right-32 -top-32 h-72 w-72 rounded-full bg-primary/10 blur-3xl" />
<div className="absolute -bottom-32 -left-32 h-72 w-72 rounded-full bg-primary/7 blur-3xl" />
</div>
<div className="relative mb-6 flex md:hidden">
<Logo variant="compact" colorIcon />
</div>
<div className="relative w-full max-w-sm md:max-w-5xl">
<LoginForm />
</div>
</div>
);
} }

View File

@@ -1,21 +1,5 @@
import { SignupForm } from "@/features/auth/components/signup-form"; import { SignupForm } from "@/features/auth/components/signup-form";
import { Logo } from "@/shared/components/logo";
export default function SignupPage() { export default function SignupPage() {
return ( return <SignupForm />;
<div className="relative flex min-h-svh flex-col items-center justify-center overflow-hidden bg-linear-to-b from-background via-background to-muted/20 px-5 py-8 md:px-8 md:py-10">
<div className="pointer-events-none absolute inset-0">
<div className="absolute -right-32 -top-32 h-72 w-72 rounded-full bg-primary/10 blur-3xl" />
<div className="absolute -bottom-32 -left-32 h-72 w-72 rounded-full bg-primary/7 blur-3xl" />
</div>
<div className="relative mb-6 flex md:hidden">
<Logo variant="compact" colorIcon />
</div>
<div className="relative w-full max-w-sm md:max-w-5xl">
<SignupForm />
</div>
</div>
);
} }

View File

@@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { getOptionalUserSession } from "@/shared/lib/auth/server";
import { fetchEstablishmentLogoDomain } from "@/shared/lib/logo/establishment-logo-queries";
/**
* GET /api/logo/mapping?name={name}
*
* Retorna o domínio Logo.dev salvo pelo usuário para um estabelecimento.
* Usado pelo EstablishmentLogo para hidratar o domain salvo no banco.
*/
export async function GET(request: Request) {
const session = await getOptionalUserSession();
if (!session) {
return NextResponse.json({ domain: null }, { status: 200 });
}
const { searchParams } = new URL(request.url);
const name = searchParams.get("name")?.trim();
if (!name) {
return NextResponse.json({ domain: null }, { status: 200 });
}
const domain = await fetchEstablishmentLogoDomain(session.user.id, name);
return NextResponse.json({ domain });
}

View File

@@ -0,0 +1,80 @@
import { NextResponse } from "next/server";
import { getOptionalUserSession } from "@/shared/lib/auth/server";
const LOGO_DEV_SEARCH_URL = "https://api.logo.dev/search";
interface LogoResult {
name: string;
domain: string;
}
async function searchByStrategy(
q: string,
strategy: "match" | "typeahead",
secretKey: string,
): Promise<LogoResult[]> {
try {
const url = `${LOGO_DEV_SEARCH_URL}?q=${encodeURIComponent(q)}&strategy=${strategy}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${secretKey}` },
next: { revalidate: 3600 },
});
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
/**
* GET /api/logo/search?q={name}
*
* Proxy seguro para a Logo.dev Brand Search API.
* Faz duas buscas paralelas (match + typeahead) e retorna até 20 resultados únicos.
* Usa LOGO_DEV_SECRET_KEY server-side — nunca exposta ao cliente.
*/
export async function GET(request: Request) {
const session = await getOptionalUserSession();
if (!session) {
return NextResponse.json({ error: "Não autorizado." }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const q = searchParams.get("q")?.trim();
if (!q) {
return NextResponse.json(
{ error: "Parâmetro q obrigatório." },
{ status: 400 },
);
}
const secretKey = process.env.LOGO_DEV_SECRET_KEY;
if (!secretKey) {
return NextResponse.json(
{ error: "Logo.dev não configurado." },
{ status: 503 },
);
}
// Duas buscas paralelas para maximizar resultados (cada uma retorna até 10)
const [matchResults, typeaheadResults] = await Promise.all([
searchByStrategy(q, "match", secretKey),
searchByStrategy(q, "typeahead", secretKey),
]);
// Mescla e deduplica por domain, mantendo ordem (match tem prioridade)
const seen = new Set<string>();
const merged: LogoResult[] = [];
for (const result of [...matchResults, ...typeaheadResults]) {
if (!seen.has(result.domain)) {
seen.add(result.domain);
merged.push(result);
if (merged.length >= 20) break;
}
}
return NextResponse.json(merged);
}

View File

@@ -354,3 +354,22 @@
justify-content: flex-end; justify-content: flex-end;
animation: blink-out 6s ease-in-out infinite; animation: blink-out 6s ease-in-out infinite;
} }
@keyframes blob {
0% { transform: translate(0px, 0px) scale(1); }
33% { transform: translate(30px, -50px) scale(1.1); }
66% { transform: translate(-20px, 20px) scale(0.9); }
100% { transform: translate(0px, 0px) scale(1); }
}
.animate-blob {
animation: blob 10s infinite alternate ease-in-out;
}
.animation-delay-2000 {
animation-delay: 2s;
}
.animation-delay-4000 {
animation-delay: 4s;
}

View File

@@ -32,57 +32,69 @@ export const user = pgTable("user", {
}).notNull(), }).notNull(),
}); });
export const account = pgTable("account", { export const account = pgTable(
id: text("id").primaryKey(), "account",
accountId: text("accountId").notNull(), {
providerId: text("providerId").notNull(), id: text("id").primaryKey(),
userId: text("userId") accountId: text("accountId").notNull(),
.notNull() providerId: text("providerId").notNull(),
.references(() => user.id, { onDelete: "cascade" }), userId: text("userId")
accessToken: text("accessToken"), .notNull()
refreshToken: text("refreshToken"), .references(() => user.id, { onDelete: "cascade" }),
idToken: text("idToken"), accessToken: text("accessToken"),
accessTokenExpiresAt: timestamp("accessTokenExpiresAt", { refreshToken: text("refreshToken"),
mode: "date", idToken: text("idToken"),
withTimezone: true, accessTokenExpiresAt: timestamp("accessTokenExpiresAt", {
mode: "date",
withTimezone: true,
}),
refreshTokenExpiresAt: timestamp("refreshTokenExpiresAt", {
mode: "date",
withTimezone: true,
}),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("createdAt", {
mode: "date",
withTimezone: true,
}).notNull(),
updatedAt: timestamp("updatedAt", {
mode: "date",
withTimezone: true,
}).notNull(),
},
(table) => ({
userIdIdx: index("account_user_id_idx").on(table.userId),
}), }),
refreshTokenExpiresAt: timestamp("refreshTokenExpiresAt", { );
mode: "date",
withTimezone: true,
}),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("createdAt", {
mode: "date",
withTimezone: true,
}).notNull(),
updatedAt: timestamp("updatedAt", {
mode: "date",
withTimezone: true,
}).notNull(),
});
export const session = pgTable("session", { export const session = pgTable(
id: text("id").primaryKey(), "session",
expiresAt: timestamp("expiresAt", { {
mode: "date", id: text("id").primaryKey(),
withTimezone: true, expiresAt: timestamp("expiresAt", {
}).notNull(), mode: "date",
token: text("token").notNull().unique(), withTimezone: true,
createdAt: timestamp("createdAt", { }).notNull(),
mode: "date", token: text("token").notNull().unique(),
withTimezone: true, createdAt: timestamp("createdAt", {
}).notNull(), mode: "date",
updatedAt: timestamp("updatedAt", { withTimezone: true,
mode: "date", }).notNull(),
withTimezone: true, updatedAt: timestamp("updatedAt", {
}).notNull(), mode: "date",
ipAddress: text("ipAddress"), withTimezone: true,
userAgent: text("userAgent"), }).notNull(),
userId: text("userId") ipAddress: text("ipAddress"),
.notNull() userAgent: text("userAgent"),
.references(() => user.id, { onDelete: "cascade" }), userId: text("userId")
}); .notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => ({
userIdIdx: index("session_user_id_idx").on(table.userId),
}),
);
export const verification = pgTable("verification", { export const verification = pgTable("verification", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
@@ -104,24 +116,30 @@ export const verification = pgTable("verification", {
// ===================== PASSKEY (WebAuthn) ===================== // ===================== PASSKEY (WebAuthn) =====================
export const passkey = pgTable("passkey", { export const passkey = pgTable(
id: text("id").primaryKey(), "passkey",
name: text("name"), {
publicKey: text("publicKey").notNull(), id: text("id").primaryKey(),
userId: text("userId") name: text("name"),
.notNull() publicKey: text("publicKey").notNull(),
.references(() => user.id, { onDelete: "cascade" }), userId: text("userId")
credentialID: text("credentialID").notNull(), .notNull()
counter: integer("counter").notNull(), .references(() => user.id, { onDelete: "cascade" }),
deviceType: text("deviceType").notNull(), credentialID: text("credentialID").notNull(),
backedUp: boolean("backedUp").notNull(), counter: integer("counter").notNull(),
transports: text("transports"), deviceType: text("deviceType").notNull(),
aaguid: text("aaguid"), backedUp: boolean("backedUp").notNull(),
createdAt: timestamp("createdAt", { transports: text("transports"),
mode: "date", aaguid: text("aaguid"),
withTimezone: true, createdAt: timestamp("createdAt", {
mode: "date",
withTimezone: true,
}),
},
(table) => ({
userIdIdx: index("passkey_user_id_idx").on(table.userId),
}), }),
}); );
export const userPreferences = pgTable("preferencias_usuario", { export const userPreferences = pgTable("preferencias_usuario", {
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`), id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
@@ -157,39 +175,30 @@ export const userPreferences = pgTable("preferencias_usuario", {
// ===================== PUBLIC TABLES ===================== // ===================== PUBLIC TABLES =====================
export const financialAccounts = pgTable( export const financialAccounts = pgTable("contas", {
"contas", id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
{ name: text("nome").notNull(),
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`), accountType: text("tipo_conta").notNull(),
name: text("nome").notNull(), note: text("anotacao"),
accountType: text("tipo_conta").notNull(), status: text("status").notNull(),
note: text("anotacao"), logo: text("logo").notNull(),
status: text("status").notNull(), initialBalance: numeric("saldo_inicial", { precision: 12, scale: 2 })
logo: text("logo").notNull(), .notNull()
initialBalance: numeric("saldo_inicial", { precision: 12, scale: 2 }) .default("0"),
.notNull() excludeFromBalance: boolean("excluir_do_saldo").notNull().default(false),
.default("0"), excludeInitialBalanceFromIncome: boolean("excluir_saldo_inicial_receitas")
excludeFromBalance: boolean("excluir_do_saldo").notNull().default(false), .notNull()
excludeInitialBalanceFromIncome: boolean("excluir_saldo_inicial_receitas") .default(false),
.notNull() userId: text("user_id")
.default(false), .notNull()
userId: text("user_id") .references(() => user.id, { onDelete: "cascade" }),
.notNull() createdAt: timestamp("created_at", {
.references(() => user.id, { onDelete: "cascade" }), mode: "date",
createdAt: timestamp("created_at", { withTimezone: true,
mode: "date", })
withTimezone: true, .notNull()
}) .defaultNow(),
.notNull() });
.defaultNow(),
},
(table) => ({
userIdStatusIdx: index("contas_user_id_status_idx").on(
table.userId,
table.status,
),
}),
);
export const categories = pgTable( export const categories = pgTable(
"categorias", "categorias",
@@ -248,14 +257,6 @@ export const payers = pgTable(
uniqueShareCode: uniqueIndex("pagadores_share_code_key").on( uniqueShareCode: uniqueIndex("pagadores_share_code_key").on(
table.shareCode, table.shareCode,
), ),
userIdStatusIdx: index("pagadores_user_id_status_idx").on(
table.userId,
table.status,
),
userIdRoleIdx: index("pagadores_user_id_role_idx").on(
table.userId,
table.role,
),
}), }),
); );
@@ -285,6 +286,12 @@ export const payerShares = pgTable(
table.payerId, table.payerId,
table.sharedWithUserId, table.sharedWithUserId,
), ),
sharedWithUserIdIdx: index(
"compartilhamentos_pagador_shared_with_user_id_idx",
).on(table.sharedWithUserId),
createdByUserIdIdx: index(
"compartilhamentos_pagador_created_by_user_id_idx",
).on(table.createdByUserId),
}), }),
); );
@@ -317,10 +324,7 @@ export const cards = pgTable(
}), }),
}, },
(table) => ({ (table) => ({
userIdStatusIdx: index("cartoes_user_id_status_idx").on( accountIdIdx: index("cartoes_conta_id_idx").on(table.accountId),
table.userId,
table.status,
),
}), }),
); );
@@ -387,26 +391,33 @@ export const budgets = pgTable(
userIdCategoryIdPeriodUnique: uniqueIndex( userIdCategoryIdPeriodUnique: uniqueIndex(
"orcamentos_user_id_categoria_id_periodo_key", "orcamentos_user_id_categoria_id_periodo_key",
).on(table.userId, table.categoryId, table.period), ).on(table.userId, table.categoryId, table.period),
categoryIdIdx: index("orcamentos_categoria_id_idx").on(table.categoryId),
}), }),
); );
export const notes = pgTable("anotacoes", { export const notes = pgTable(
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`), "anotacoes",
title: text("titulo"), {
description: text("descricao"), id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
type: text("tipo").notNull().default("nota"), // "nota" ou "tarefa" title: text("titulo"),
tasks: text("tasks"), // JSON stringificado com array de tarefas description: text("descricao"),
archived: boolean("arquivada").notNull().default(false), type: text("tipo").notNull().default("nota"), // "nota" ou "tarefa"
createdAt: timestamp("created_at", { tasks: text("tasks"), // JSON stringificado com array de tarefas
mode: "date", archived: boolean("arquivada").notNull().default(false),
withTimezone: true, createdAt: timestamp("created_at", {
}) mode: "date",
.notNull() withTimezone: true,
.defaultNow(), })
userId: text("user_id") .notNull()
.notNull() .defaultNow(),
.references(() => user.id, { onDelete: "cascade" }), userId: text("user_id")
}); .notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => ({
userIdIdx: index("anotacoes_user_id_idx").on(table.userId),
}),
);
export const savedInsights = pgTable( export const savedInsights = pgTable(
"insights_salvos", "insights_salvos",
@@ -460,7 +471,6 @@ export const apiTokens = pgTable(
.defaultNow(), .defaultNow(),
}, },
(table) => ({ (table) => ({
userIdIdx: index("tokens_api_user_id_idx").on(table.userId),
tokenHashIdx: uniqueIndex("tokens_api_token_hash_idx").on(table.tokenHash), tokenHashIdx: uniqueIndex("tokens_api_token_hash_idx").on(table.tokenHash),
}), }),
); );
@@ -524,6 +534,9 @@ export const inboxItems = pgTable(
table.userId, table.userId,
table.createdAt, table.createdAt,
), ),
transactionIdIdx: index("pre_lancamentos_lancamento_id_idx").on(
table.transactionId,
),
}), }),
); );
@@ -555,9 +568,6 @@ export const dashboardNotificationStates = pgTable(
userIdNotificationKeyUnique: uniqueIndex( userIdNotificationKeyUnique: uniqueIndex(
"dashboard_notification_states_user_id_key_unique", "dashboard_notification_states_user_id_key_unique",
).on(table.userId, table.notificationKey), ).on(table.userId, table.notificationKey),
userIdArchivedAtIdx: index(
"dashboard_notification_states_user_id_archived_idx",
).on(table.userId, table.archivedAt),
}), }),
); );
@@ -597,10 +607,14 @@ export const installmentAnticipations = pgTable(
.defaultNow(), .defaultNow(),
}, },
(table) => ({ (table) => ({
seriesIdIdx: index("antecipacoes_parcelas_series_id_idx").on(
table.seriesId,
),
userIdIdx: index("antecipacoes_parcelas_user_id_idx").on(table.userId), userIdIdx: index("antecipacoes_parcelas_user_id_idx").on(table.userId),
transactionIdIdx: index("antecipacoes_parcelas_lancamento_id_idx").on(
table.transactionId,
),
payerIdIdx: index("antecipacoes_parcelas_pagador_id_idx").on(table.payerId),
categoryIdIdx: index("antecipacoes_parcelas_categoria_id_idx").on(
table.categoryId,
),
}), }),
); );
@@ -700,6 +714,12 @@ export const transactions = pgTable(
table.cardId, table.cardId,
table.period, table.period,
), ),
// FK indexes: evitam seq scan em deletes/updates nas tabelas pai
accountIdIdx: index("lancamentos_conta_id_idx").on(table.accountId),
categoryIdIdx: index("lancamentos_categoria_id_idx").on(table.categoryId),
anticipationIdIdx: index("lancamentos_antecipacao_id_idx").on(
table.anticipationId,
),
// Dedup OFX: garante FITID único por usuário // Dedup OFX: garante FITID único por usuário
ofxFitIdUserIdIdx: uniqueIndex("lancamentos_ofx_fit_id_user_id_idx") ofxFitIdUserIdIdx: uniqueIndex("lancamentos_ofx_fit_id_user_id_idx")
.on(table.userId, table.ofxFitId) .on(table.userId, table.ofxFitId)
@@ -721,6 +741,7 @@ export const userRelations = relations(user, ({ many, one }) => ({
installmentAnticipations: many(installmentAnticipations), installmentAnticipations: many(installmentAnticipations),
apiTokens: many(apiTokens), apiTokens: many(apiTokens),
inboxItems: many(inboxItems), inboxItems: many(inboxItems),
establishmentLogos: many(establishmentLogos),
})); }));
export const accountRelations = relations(account, ({ one }) => ({ export const accountRelations = relations(account, ({ one }) => ({
@@ -904,19 +925,25 @@ export const installmentAnticipationsRelations = relations(
// ===================== ATTACHMENTS ===================== // ===================== ATTACHMENTS =====================
export const attachments = pgTable("anexos", { export const attachments = pgTable(
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`), "anexos",
userId: text("user_id") {
.notNull() id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
.references(() => user.id, { onDelete: "cascade" }), userId: text("user_id")
fileKey: text("chave_arquivo").notNull().unique(), .notNull()
fileName: text("nome_arquivo").notNull(), .references(() => user.id, { onDelete: "cascade" }),
fileSize: integer("tamanho_bytes").notNull(), fileKey: text("chave_arquivo").notNull().unique(),
mimeType: text("mime_type").notNull(), fileName: text("nome_arquivo").notNull(),
createdAt: timestamp("created_at", { mode: "date", withTimezone: true }) fileSize: integer("tamanho_bytes").notNull(),
.notNull() mimeType: text("mime_type").notNull(),
.defaultNow(), createdAt: timestamp("created_at", { mode: "date", withTimezone: true })
}); .notNull()
.defaultNow(),
},
(table) => ({
userIdIdx: index("anexos_user_id_idx").on(table.userId),
}),
);
export const transactionAttachments = pgTable( export const transactionAttachments = pgTable(
"lancamento_anexos", "lancamento_anexos",
@@ -952,9 +979,31 @@ export const importCategoryMappings = pgTable(
}, },
(table) => ({ (table) => ({
pk: primaryKey({ columns: [table.userId, table.descriptionKey] }), pk: primaryKey({ columns: [table.userId, table.descriptionKey] }),
categoryIdIdx: index("import_category_mappings_category_id_idx").on(
table.categoryId,
),
}), }),
); );
export const establishmentLogos = pgTable(
"establishment_logos",
{
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
nameKey: text("name_key").notNull(),
domain: text("domain").notNull(),
updatedAt: timestamp("updated_at", { mode: "date", withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => ({
pk: primaryKey({ columns: [table.userId, table.nameKey] }),
}),
);
export type EstablishmentLogo = typeof establishmentLogos.$inferSelect;
export type User = typeof user.$inferSelect; export type User = typeof user.$inferSelect;
export type NewUser = typeof user.$inferInsert; export type NewUser = typeof user.$inferInsert;
export type Account = typeof account.$inferSelect; export type Account = typeof account.$inferSelect;
@@ -1004,3 +1053,13 @@ export const transactionAttachmentsRelations = relations(
export type Attachment = typeof attachments.$inferSelect; export type Attachment = typeof attachments.$inferSelect;
export type TransactionAttachment = typeof transactionAttachments.$inferSelect; export type TransactionAttachment = typeof transactionAttachments.$inferSelect;
export const establishmentLogosRelations = relations(
establishmentLogos,
({ one }) => ({
user: one(user, {
fields: [establishmentLogos.userId],
references: [user.id],
}),
}),
);

View File

@@ -5,8 +5,9 @@ import AuthSidebar from "./auth-sidebar";
export function AuthCardShell({ children }: PropsWithChildren) { export function AuthCardShell({ children }: PropsWithChildren) {
return ( return (
<Card className="relative overflow-hidden p-0"> <Card className="relative overflow-hidden rounded-2xl md:rounded-[2rem] p-0 shadow-lg border-primary/10">
<div className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]"> <div className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,var(--color-primary)_0%,transparent_70%)] opacity-10 blur-3xl animate-blob mix-blend-multiply" />
<DotPattern <DotPattern
width={17} width={17}
height={17} height={17}
@@ -15,11 +16,13 @@ export function AuthCardShell({ children }: PropsWithChildren) {
cr={1.3} cr={1.3}
className="text-primary/8 mask-[linear-gradient(to_bottom,black,transparent_84%)]" className="text-primary/8 mask-[linear-gradient(to_bottom,black,transparent_84%)]"
/> />
<div className="absolute inset-0 bg-linear-to-br from-primary/6 via-transparent to-transparent" /> <div className="absolute inset-0 bg-linear-to-br from-primary/6 via-transparent to-transparent opacity-80" />
</div> </div>
<CardContent className="relative z-10 grid gap-0 p-0 md:min-h-[640px] md:grid-cols-[1.05fr_0.95fr]"> <CardContent className="relative z-10 grid gap-0 p-0 md:min-h-[640px] md:grid-cols-[1.05fr_0.95fr] overflow-hidden rounded-[inherit]">
<div className="flex bg-card/92 backdrop-blur-[1px]">{children}</div> <div className="flex bg-card/60 backdrop-blur-xl md:rounded-l-[2rem]">
{children}
</div>
<AuthSidebar /> <AuthSidebar />
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -36,12 +36,12 @@ export type FeatureItem = {
}; };
export const navLinks = [ export const navLinks = [
{ href: "#telas", label: "conheça as telas" }, { href: "#telas", label: "Conheça as telas" },
{ href: "#funcionalidades", label: "funcionalidades" }, { href: "#funcionalidades", label: "Funcionalidades" },
{ href: "#mobile", label: "mobile" }, { href: "#mobile", label: "Mobile" },
{ href: "#stack", label: "stack" }, { href: "#stack", label: "Stack" },
{ href: "#como-usar", label: "como usar" }, { href: "#como-usar", label: "Como usar" },
{ href: "#para-quem-e", label: "para quem é" }, { href: "#para-quem-e", label: "Para quem é" },
] as const; ] as const;
export const mainFeatures: FeatureItem[] = [ export const mainFeatures: FeatureItem[] = [

View File

@@ -199,7 +199,7 @@ function buildColumns({
return ( return (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<EstablishmentLogo name={name} size={28} /> <EstablishmentLogo name={name} size={32} />
<span className="flex flex-col py-0.5"> <span className="flex flex-col py-0.5">
<span className="text-xs text-muted-foreground flex items-center gap-2"> <span className="text-xs text-muted-foreground flex items-center gap-2">
{formatDate(purchaseDate)} {formatDate(purchaseDate)}

View File

@@ -38,7 +38,11 @@ function buildCsp(): string {
const connectExtras = [umamiOrigin, s3Origin].filter(Boolean).join(" "); const connectExtras = [umamiOrigin, s3Origin].filter(Boolean).join(" ");
const imgExtras = ["https://lh3.googleusercontent.com", s3Origin] const imgExtras = [
"https://lh3.googleusercontent.com",
"https://img.logo.dev",
s3Origin,
]
.filter(Boolean) .filter(Boolean)
.join(" "); .join(" ");

View File

@@ -0,0 +1,187 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState, useTransition } from "react";
import { Input } from "@/shared/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/shared/components/ui/popover";
import { Spinner } from "@/shared/components/ui/spinner";
import { buildLogoDevUrl, logoQueryKeys, toNameKey } from "@/shared/lib/logo";
import {
removeEstablishmentLogoAction,
saveEstablishmentLogoAction,
} from "@/shared/lib/logo/establishment-logo-actions";
import {
buildInitials,
getCategoryBgColorFromName,
getCategoryColorFromName,
} from "@/shared/utils/category-colors";
import { cn } from "@/shared/utils/ui";
interface LogoResult {
name: string;
domain: string;
}
async function fetchLogoResults(query: string): Promise<LogoResult[]> {
if (!query.trim()) return [];
const res = await fetch(
`/api/logo/search?q=${encodeURIComponent(query.trim())}`,
);
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? data : [];
}
interface EstablishmentLogoPickerProps {
name: string;
resolvedDomain: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (domain: string | null) => void;
children: React.ReactNode;
}
export function EstablishmentLogoPicker({
name,
resolvedDomain,
open,
onOpenChange,
onSelect,
children,
}: EstablishmentLogoPickerProps) {
const [isPending, startTransition] = useTransition();
const [searchInput, setSearchInput] = useState(name);
const [debouncedSearch, setDebouncedSearch] = useState(name);
const queryClient = useQueryClient();
useEffect(() => {
if (open) {
setSearchInput(name);
setDebouncedSearch(name);
}
}, [open, name]);
useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(searchInput), 400);
return () => clearTimeout(timer);
}, [searchInput]);
const { data: results = [], isLoading } = useQuery({
queryKey: logoQueryKeys.search(debouncedSearch),
queryFn: () => fetchLogoResults(debouncedSearch),
enabled: open && debouncedSearch.trim().length > 0,
staleTime: 1000 * 60 * 60,
});
function handleSelect(domain: string) {
startTransition(async () => {
await saveEstablishmentLogoAction(name, domain);
queryClient.setQueryData(logoQueryKeys.mapping(toNameKey(name)), {
domain,
});
onSelect(domain);
});
}
function handleReset() {
startTransition(async () => {
await removeEstablishmentLogoAction(name);
queryClient.setQueryData(logoQueryKeys.mapping(toNameKey(name)), {
domain: null,
});
onSelect(null);
});
}
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent className="w-80 p-3" align="start" side="bottom">
<p className="mb-2 text-muted-foreground text-xs">
Escolha um logo para <strong>{name}</strong>
</p>
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Buscar marca..."
className="mb-3 h-8 text-sm"
autoFocus
/>
{isLoading ? (
<div className="flex h-24 items-center justify-center">
<Spinner />
</div>
) : (
<div className="max-h-64 overflow-y-auto">
<div className="grid grid-cols-5 gap-1.5">
<button
type="button"
disabled={isPending}
onClick={handleReset}
className={cn(
"flex flex-col items-center gap-1 rounded-md p-1.5 text-center transition-colors hover:bg-accent disabled:opacity-50",
resolvedDomain === null &&
"ring-2 ring-primary ring-offset-1",
)}
title="Usar iniciais coloridas"
>
<div
className="flex items-center justify-center rounded-md font-medium text-xs"
style={{
width: 36,
height: 36,
backgroundColor: getCategoryBgColorFromName(name),
color: getCategoryColorFromName(name),
}}
aria-hidden
>
{buildInitials(name)}
</div>
<span className="w-full truncate text-[10px] leading-tight text-muted-foreground">
Iniciais
</span>
</button>
{results.map((r) => (
<button
key={r.domain}
type="button"
disabled={isPending}
onClick={() => handleSelect(r.domain)}
className={cn(
"flex flex-col items-center gap-1 rounded-md p-1.5 text-center transition-colors hover:bg-accent disabled:opacity-50",
resolvedDomain === r.domain &&
"ring-2 ring-primary ring-offset-1",
)}
title={r.name}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={buildLogoDevUrl(r.domain) ?? ""}
alt={r.name}
width={36}
height={36}
className="rounded-md object-contain"
style={{ width: 36, height: 36 }}
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
<span className="w-full truncate text-[10px] leading-tight">
{r.name}
</span>
</button>
))}
</div>
</div>
)}
</PopoverContent>
</Popover>
);
}

View File

@@ -1,31 +1,75 @@
"use client";
import { RiPencilLine } from "@remixicon/react";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import {
buildLogoDevUrl,
LOGO_DEV_TOKEN,
logoQueryKeys,
toNameKey,
} from "@/shared/lib/logo";
import { import {
buildInitials, buildInitials,
getCategoryBgColorFromName, getCategoryBgColorFromName,
getCategoryColorFromName, getCategoryColorFromName,
} from "@/shared/utils/category-colors"; } from "@/shared/utils/category-colors";
import { cn } from "@/shared/utils/ui"; import { cn } from "@/shared/utils/ui";
import { EstablishmentLogoPicker } from "./establishment-logo-picker";
async function fetchLogoMapping(
name: string,
): Promise<{ domain: string | null }> {
const res = await fetch(`/api/logo/mapping?name=${encodeURIComponent(name)}`);
if (!res.ok) return { domain: null };
return res.json();
}
interface EstablishmentLogoProps { interface EstablishmentLogoProps {
name: string; name: string;
/** Domínio Logo.dev pré-carregado pelo servidor (otimização opcional). */
domain?: string | null;
size?: number; size?: number;
className?: string; className?: string;
} }
export function EstablishmentLogo({ export function EstablishmentLogo({
name, name,
domain: initialDomain,
size = 32, size = 32,
className, className,
}: EstablishmentLogoProps) { }: EstablishmentLogoProps) {
const [pickerOpen, setPickerOpen] = useState(false);
const [imgError, setImgError] = useState(false);
const { data: mappingData } = useQuery({
queryKey: logoQueryKeys.mapping(toNameKey(name)),
queryFn: () => fetchLogoMapping(name),
placeholderData:
initialDomain !== undefined
? { domain: initialDomain ?? null }
: undefined,
staleTime: 1000 * 60 * 5,
enabled: LOGO_DEV_TOKEN !== undefined && LOGO_DEV_TOKEN !== "",
});
const resolvedDomain = mappingData?.domain ?? null;
// biome-ignore lint/correctness/useExhaustiveDependencies: resetar imgError é o efeito de domain mudar
useEffect(() => {
setImgError(false);
}, [resolvedDomain]);
const logoUrl = buildLogoDevUrl(resolvedDomain);
const showLogo = Boolean(logoUrl) && !imgError;
const initials = buildInitials(name); const initials = buildInitials(name);
const color = getCategoryColorFromName(name); const color = getCategoryColorFromName(name);
const bgColor = getCategoryBgColorFromName(name); const bgColor = getCategoryBgColorFromName(name);
return ( const initialsAvatar = (
<div <div
className={cn( className="flex shrink-0 items-center justify-center rounded-full font-medium"
"flex shrink-0 items-center justify-center rounded-full font-medium",
className,
)}
style={{ style={{
width: size, width: size,
height: size, height: size,
@@ -38,4 +82,60 @@ export function EstablishmentLogo({
{initials} {initials}
</div> </div>
); );
const logoImage =
showLogo && logoUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={logoUrl}
alt={name}
width={size}
height={size}
onError={() => setImgError(true)}
className="shrink-0 rounded-full object-cover"
style={{ width: size, height: size }}
/>
) : (
initialsAvatar
);
if (!LOGO_DEV_TOKEN) {
return (
<div className={cn("shrink-0", className)} aria-hidden>
{initialsAvatar}
</div>
);
}
return (
<EstablishmentLogoPicker
name={name}
resolvedDomain={resolvedDomain}
open={pickerOpen}
onOpenChange={setPickerOpen}
onSelect={() => setPickerOpen(false)}
>
<button
type="button"
className={cn("group relative shrink-0 cursor-pointer", className)}
onClick={(e) => e.stopPropagation()}
title={`Alterar logo de ${name}`}
aria-label={`Alterar logo de ${name}`}
>
{logoImage}
<span
className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50 opacity-0 transition-opacity group-hover:opacity-100"
aria-hidden
>
<RiPencilLine
style={{
width: Math.max(10, Math.round(size * 0.38)),
height: Math.max(10, Math.round(size * 0.38)),
}}
className="text-white"
/>
</span>
</button>
</EstablishmentLogoPicker>
);
} }

View File

@@ -4,3 +4,4 @@ export type {
} from "./category-icon-badge"; } from "./category-icon-badge";
export { CategoryIconBadge } from "./category-icon-badge"; export { CategoryIconBadge } from "./category-icon-badge";
export { EstablishmentLogo } from "./establishment-logo"; export { EstablishmentLogo } from "./establishment-logo";
export { EstablishmentLogoPicker } from "./establishment-logo-picker";

View File

@@ -37,7 +37,7 @@ export const NAV_SECTIONS: NavSection[] = [
items: [ items: [
{ {
href: "/transactions", href: "/transactions",
label: "lançamentos", label: "Lançamentos",
description: "Registre e gerencie suas transações", description: "Registre e gerencie suas transações",
icon: <RiArrowLeftRightLine className="size-4" />, icon: <RiArrowLeftRightLine className="size-4" />,
iconClass: "text-primary", iconClass: "text-primary",
@@ -45,14 +45,14 @@ export const NAV_SECTIONS: NavSection[] = [
}, },
{ {
href: "/inbox", href: "/inbox",
label: "pré-lançamentos", label: "Pré-lançamentos",
description: "Notificações capturadas pelo Companion", description: "Notificações capturadas pelo Companion",
icon: <RiAtLine className="size-4" />, icon: <RiAtLine className="size-4" />,
iconClass: "text-primary", iconClass: "text-primary",
}, },
{ {
href: "/calendar", href: "/calendar",
label: "calendário", label: "Calendário",
description: "Visualize lançamentos por dia", description: "Visualize lançamentos por dia",
icon: <RiCalendarEventLine className="size-4" />, icon: <RiCalendarEventLine className="size-4" />,
iconClass: "text-primary", iconClass: "text-primary",
@@ -65,21 +65,21 @@ export const NAV_SECTIONS: NavSection[] = [
items: [ items: [
{ {
href: "/cards", href: "/cards",
label: "cartões", label: "Cartões",
description: "Faturas e limites dos seus cartões", description: "Faturas e limites dos seus cartões",
icon: <RiBankCard2Line className="size-4" />, icon: <RiBankCard2Line className="size-4" />,
iconClass: "text-primary", iconClass: "text-primary",
}, },
{ {
href: "/accounts", href: "/accounts",
label: "contas", label: "Contas",
description: "Saldos e extratos bancários", description: "Saldos e extratos bancários",
icon: <RiBankLine className="size-4" />, icon: <RiBankLine className="size-4" />,
iconClass: "text-primary", iconClass: "text-primary",
}, },
{ {
href: "/budgets", href: "/budgets",
label: "orçamentos", label: "Orçamentos",
description: "Defina limites de gastos por categoria", description: "Defina limites de gastos por categoria",
icon: <RiBarChart2Line className="size-4" />, icon: <RiBarChart2Line className="size-4" />,
iconClass: "text-primary", iconClass: "text-primary",
@@ -92,28 +92,28 @@ export const NAV_SECTIONS: NavSection[] = [
items: [ items: [
{ {
href: "/payers", href: "/payers",
label: "pagadores", label: "Pagadores",
description: "Gerencie quem divide as despesas", description: "Gerencie quem divide as despesas",
icon: <RiGroupLine className="size-4" />, icon: <RiGroupLine className="size-4" />,
iconClass: "text-primary", iconClass: "text-primary",
}, },
{ {
href: "/categories", href: "/categories",
label: "categorias", label: "Categorias",
description: "Agrupe seus lançamentos", description: "Agrupe seus lançamentos",
icon: <RiPriceTag3Line className="size-4" />, icon: <RiPriceTag3Line className="size-4" />,
iconClass: "text-primary", iconClass: "text-primary",
}, },
{ {
href: "/notes", href: "/notes",
label: "anotações", label: "Anotações",
description: "Guarde lembretes e observações", description: "Guarde lembretes e observações",
icon: <RiTodoLine className="size-4" />, icon: <RiTodoLine className="size-4" />,
iconClass: "text-primary", iconClass: "text-primary",
}, },
{ {
href: "/attachments", href: "/attachments",
label: "anexos", label: "Anexos",
description: "Comprovantes e documentos", description: "Comprovantes e documentos",
icon: <RiAttachmentLine className="size-4" />, icon: <RiAttachmentLine className="size-4" />,
iconClass: "text-primary", iconClass: "text-primary",
@@ -126,7 +126,7 @@ export const NAV_SECTIONS: NavSection[] = [
items: [ items: [
{ {
href: "/insights", href: "/insights",
label: "insights", label: "Insights",
description: "Análises inteligentes dos seus dados", description: "Análises inteligentes dos seus dados",
icon: <RiSparklingLine className="size-4" />, icon: <RiSparklingLine className="size-4" />,
iconClass: "text-primary", iconClass: "text-primary",
@@ -134,14 +134,14 @@ export const NAV_SECTIONS: NavSection[] = [
}, },
{ {
href: "/reports/category-trends", href: "/reports/category-trends",
label: "tendências", label: "Tendências",
description: "Evolução de gastos por categoria", description: "Evolução de gastos por categoria",
icon: <RiFileChartLine className="size-4" />, icon: <RiFileChartLine className="size-4" />,
iconClass: "text-primary", iconClass: "text-primary",
}, },
{ {
href: "/reports/card-usage", href: "/reports/card-usage",
label: "uso de cartões", label: "Uso de cartões",
description: "Resumo de gastos por cartão", description: "Resumo de gastos por cartão",
icon: <RiBankCard2Line className="size-4" />, icon: <RiBankCard2Line className="size-4" />,
iconClass: "text-primary", iconClass: "text-primary",
@@ -149,14 +149,14 @@ export const NAV_SECTIONS: NavSection[] = [
}, },
{ {
href: "/reports/installment-analysis", href: "/reports/installment-analysis",
label: "análise de parcelas", label: "Análise de parcelas",
description: "Acompanhe parcelas em aberto", description: "Acompanhe parcelas em aberto",
icon: <RiSecurePaymentLine className="size-4" />, icon: <RiSecurePaymentLine className="size-4" />,
iconClass: "text-primary", iconClass: "text-primary",
}, },
{ {
href: "/reports/establishments", href: "/reports/establishments",
label: "estabelecimentos", label: "Estabelecimentos",
description: "Top gastos por estabelecimento", description: "Top gastos por estabelecimento",
icon: <RiStore2Line className="size-4" />, icon: <RiStore2Line className="size-4" />,
iconClass: "text-primary", iconClass: "text-primary",

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { RiDashboardLine, RiMenuLine } from "@remixicon/react"; import { RiDashboardLine, RiMenuLine } from "@remixicon/react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { CalculatorDialogContent } from "@/shared/components/calculator/calculator-dialog"; import { CalculatorDialogContent } from "@/shared/components/calculator/calculator-dialog";
@@ -28,7 +29,7 @@ import { NavPill } from "./nav-pill";
import { MobileTools, NavToolsDropdown } from "./nav-tools"; import { MobileTools, NavToolsDropdown } from "./nav-tools";
const triggerClass = const triggerClass =
"h-8! rounded-md! px-2! py-0! text-sm! font-medium! bg-transparent! shadow-none! lowercase! [&_svg]:text-current! text-black/75! hover:text-black! hover:bg-black/10! focus:text-black! focus:bg-black/10! focus-visible:ring-black/20! data-[state=open]:text-black! data-[state=open]:bg-black/10!"; "h-8! rounded-md! px-2! py-0! text-sm! font-medium! bg-transparent! shadow-none! capitalize! [&_svg]:text-current! text-black/75! hover:text-black! hover:bg-black/10! focus:text-black! focus:bg-black/10! focus-visible:ring-black/20! data-[state=open]:text-black! data-[state=open]:bg-black/10!";
const triggerActiveClass = "bg-black/15! text-black!"; const triggerActiveClass = "bg-black/15! text-black!";
@@ -42,9 +43,9 @@ export function NavMenu() {
return ( return (
<> <>
{/* Desktop */} {/* Desktop */}
<nav className="hidden md:flex items-center justify-center flex-1"> <nav className="hidden md:flex items-center justify-center flex-1 gap-4">
<NavigationMenu viewport={false}> <NavigationMenu viewport={false}>
<NavigationMenuList className="gap-0"> <NavigationMenuList className="gap-2">
<NavigationMenuItem> <NavigationMenuItem>
<NavPill href="/dashboard" preservePeriod> <NavPill href="/dashboard" preservePeriod>
Dashboard Dashboard
@@ -63,6 +64,7 @@ export function NavMenu() {
className={cn( className={cn(
triggerClass, triggerClass,
isSectionActive && triggerActiveClass, isSectionActive && triggerActiveClass,
"capitalize",
)} )}
> >
{section.label} {section.label}

View File

@@ -25,7 +25,7 @@ export function NavPill({ href, preservePeriod, children }: NavPillProps) {
preservePeriod={preservePeriod} preservePeriod={preservePeriod}
className={cn( className={cn(
buttonVariants({ variant: "navbar", size: "sm" }), buttonVariants({ variant: "navbar", size: "sm" }),
"lowercase", "capitalize",
isActive && "bg-black/15 text-black", isActive && "bg-black/15 text-black",
)} )}
> >

View File

@@ -22,7 +22,7 @@ export function NavToolsDropdown({ onOpenCalculator }: NavToolsDropdownProps) {
<RiCalculatorLine className="size-4" /> <RiCalculatorLine className="size-4" />
</span> </span>
<span className="flex flex-col flex-1 text-left"> <span className="flex flex-col flex-1 text-left">
<span className="font-semibold">calculadora</span> <span className="font-medium">Calculadora</span>
<span className="text-xs text-muted-foreground lowercase"> <span className="text-xs text-muted-foreground lowercase">
Faça cálculos rápidos Faça cálculos rápidos
</span> </span>
@@ -39,7 +39,7 @@ export function NavToolsDropdown({ onOpenCalculator }: NavToolsDropdownProps) {
)} )}
</span> </span>
<span className="flex flex-col flex-1 text-left"> <span className="flex flex-col flex-1 text-left">
<span className="font-semibold">privacidade</span> <span className="font-medium">Privacidade</span>
<span className="text-xs text-muted-foreground lowercase"> <span className="text-xs text-muted-foreground lowercase">
Oculta valores na tela Oculta valores na tela
</span> </span>

View File

@@ -1,6 +1,6 @@
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { RiArrowDropDownLine } from "@remixicon/react"; import { RiArrowDropDownLine } from "@remixicon/react";
import { cva } from "class-variance-authority"; import { cva } from "class-variance-authority";
import { NavigationMenu as NavigationMenuPrimitive } from "radix-ui";
import type * as React from "react"; import type * as React from "react";
import { cn } from "@/shared/utils"; import { cn } from "@/shared/utils";

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { Slider as SliderPrimitive } from "radix-ui"; import * as SliderPrimitive from "@radix-ui/react-slider";
import type * as React from "react"; import type * as React from "react";
import { cn } from "@/shared/utils/ui"; import { cn } from "@/shared/utils/ui";

View File

@@ -0,0 +1,64 @@
"use server";
import { and, eq } from "drizzle-orm";
import { establishmentLogos } from "@/db/schema";
import type { ActionResult } from "@/shared/lib/actions/helpers";
import {
handleActionError,
revalidateForEntity,
} from "@/shared/lib/actions/helpers";
import { getUserId } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
import { toNameKey } from "@/shared/lib/logo";
/**
* Salva ou atualiza o domínio Logo.dev preferido para um estabelecimento.
*/
export async function saveEstablishmentLogoAction(
name: string,
domain: string,
): Promise<ActionResult> {
try {
const userId = await getUserId();
const nameKey = toNameKey(name);
await db
.insert(establishmentLogos)
.values({ userId, nameKey, domain })
.onConflictDoUpdate({
target: [establishmentLogos.userId, establishmentLogos.nameKey],
set: { domain, updatedAt: new Date() },
});
revalidateForEntity("establishments", userId);
return { success: true, message: "Logo salvo." };
} catch (error) {
return handleActionError(error);
}
}
/**
* Remove o mapeamento salvo, voltando ao comportamento automático do Logo.dev.
*/
export async function removeEstablishmentLogoAction(
name: string,
): Promise<ActionResult> {
try {
const userId = await getUserId();
const nameKey = toNameKey(name);
await db
.delete(establishmentLogos)
.where(
and(
eq(establishmentLogos.userId, userId),
eq(establishmentLogos.nameKey, nameKey),
),
);
revalidateForEntity("establishments", userId);
return { success: true, message: "Logo restaurado." };
} catch (error) {
return handleActionError(error);
}
}

View File

@@ -0,0 +1,46 @@
import { and, eq, inArray } from "drizzle-orm";
import { establishmentLogos } from "@/db/schema";
import { db } from "@/shared/lib/db";
import { toNameKey } from "@/shared/lib/logo";
export { toNameKey };
/**
* Busca o domínio salvo para um único estabelecimento.
*/
export async function fetchEstablishmentLogoDomain(
userId: string,
name: string,
): Promise<string | null> {
const nameKey = toNameKey(name);
const row = await db.query.establishmentLogos.findFirst({
where: and(
eq(establishmentLogos.userId, userId),
eq(establishmentLogos.nameKey, nameKey),
),
columns: { domain: true },
});
return row?.domain ?? null;
}
/**
* Busca domínios salvos para múltiplos nomes de uma vez (evita N+1).
* Retorna um Map de nameKey → domain.
*/
export async function fetchEstablishmentLogoMap(
userId: string,
names: string[],
): Promise<Map<string, string>> {
const nameKeys = [...new Set(names.map(toNameKey))];
if (nameKeys.length === 0) return new Map();
const rows = await db.query.establishmentLogos.findMany({
where: and(
eq(establishmentLogos.userId, userId),
inArray(establishmentLogos.nameKey, nameKeys),
),
columns: { nameKey: true, domain: true },
});
return new Map(rows.map((r) => [r.nameKey, r.domain]));
}

View File

@@ -39,6 +39,27 @@ export const deriveNameFromLogo = (logo?: string | null) => {
.join(" "); .join(" ");
}; };
/**
* Normaliza o nome do estabelecimento para usar como chave de lookup no banco.
*/
export const toNameKey = (name: string): string => name.trim().toLowerCase();
// === Logo.dev ===
export const LOGO_DEV_TOKEN = process.env.NEXT_PUBLIC_LOGO_DEV_TOKEN;
export function buildLogoDevUrl(domain?: string | null): string | null {
if (!LOGO_DEV_TOKEN || !domain) return null;
return `https://img.logo.dev/${domain}?token=${LOGO_DEV_TOKEN}&size=64&format=png`;
}
export const logoQueryKeys = {
mapping: (nameKey: string) => ["logo-mapping", nameKey] as const,
search: (query: string) => ["logo-search", query] as const,
};
// === Local logo resolution ===
const LOGO_SRC_PATTERN = /^(https?:\/\/|data:)/; const LOGO_SRC_PATTERN = /^(https?:\/\/|data:)/;
type ResolveLogoSrcOptions = { type ResolveLogoSrcOptions = {