Compare commits
5 Commits
v2.3.8
...
9456aa98bc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9456aa98bc | ||
|
|
21c6a8d9d0 | ||
|
|
c29ffa9a12 | ||
|
|
8875de843b | ||
|
|
679ea752bb |
15
.env.example
@@ -3,10 +3,10 @@
|
||||
# ============================================
|
||||
|
||||
# === Database ===
|
||||
# PostgreSQL local (Docker): use host "db"
|
||||
# PostgreSQL local (sem Docker): use host "localhost"
|
||||
# Desenvolvimento local (pnpm dev): use host "localhost" (padrão abaixo)
|
||||
# Docker Compose completo: o compose.yml define DATABASE_URL automaticamente com host "db"
|
||||
# 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
|
||||
POSTGRES_USER=openmonetis
|
||||
@@ -54,4 +54,11 @@ UMAMI_DOMAINS=
|
||||
ANTHROPIC_API_KEY=
|
||||
OPENAI_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=
|
||||
2
.github/workflows/docker-publish.yml
vendored
@@ -85,6 +85,8 @@ jobs:
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: linux/amd64,linux/arm64
|
||||
build-args: |
|
||||
NEXT_PUBLIC_LOGO_DEV_TOKEN=${{ secrets.NEXT_PUBLIC_LOGO_DEV_TOKEN }}
|
||||
|
||||
- name: Image digest
|
||||
run: echo ${{ steps.meta.outputs.digest }}
|
||||
|
||||
19
CHANGELOG.md
@@ -7,6 +7,25 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
### Alterado
|
||||
|
||||
@@ -40,6 +40,10 @@ COPY --from=deps /app/public/pdf.worker.min.mjs ./public/pdf.worker.min.mjs
|
||||
ENV NEXT_TELEMETRY_DISABLED=1 \
|
||||
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
|
||||
RUN pnpm build
|
||||
|
||||
|
||||
45
README.md
@@ -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.
|
||||
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://nextjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](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:
|
||||
|
||||
| | 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)
|
||||
@@ -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.
|
||||
|
||||
#### 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
|
||||
@@ -166,7 +190,8 @@ pnpm install
|
||||
|
||||
# 3. Configure o ambiente
|
||||
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
|
||||
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.
|
||||
|
||||
#### 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
|
||||
@@ -357,11 +392,15 @@ S3_BUCKET=
|
||||
|
||||
## 🔐 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
|
||||
|
||||
```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
|
||||
BETTER_AUTH_SECRET=seu-secret-aqui # openssl rand -base64 32
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
|
||||
@@ -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": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
|
||||
35
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openmonetis",
|
||||
"version": "2.3.8",
|
||||
"version": "2.4.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"scripts": {
|
||||
@@ -18,32 +18,24 @@
|
||||
"db:extensions": "tsx scripts/postgres/enable-extensions.ts",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"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": "Sobe app (Docker Hub) + banco PostgreSQL em background",
|
||||
|
||||
"docker:db": "docker compose up -d db",
|
||||
"//docker:db": "Sobe apenas o banco em background (para usar com pnpm dev)",
|
||||
|
||||
"docker:down": "docker compose down",
|
||||
"//docker:down": "Para e remove os containers",
|
||||
|
||||
"docker:logs": "docker compose logs -f",
|
||||
"//docker:logs": "Acompanha logs de todos os containers em tempo real",
|
||||
|
||||
"docker:update": "docker compose pull && docker compose up -d",
|
||||
"//docker:update": "Atualiza para a imagem mais recente do Docker Hub e reinicia",
|
||||
|
||||
"backup": "bash scripts/backup.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.68",
|
||||
"@ai-sdk/google": "^3.0.61",
|
||||
"@ai-sdk/anthropic": "^3.0.69",
|
||||
"@ai-sdk/google": "^3.0.63",
|
||||
"@ai-sdk/openai": "^3.0.52",
|
||||
"@aws-sdk/client-s3": "^3.1027.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1027.0",
|
||||
"@aws-sdk/client-s3": "^3.1030.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1030.0",
|
||||
"@better-auth/passkey": "^1.6.2",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -57,11 +49,13 @@
|
||||
"@radix-ui/react-dropdown-menu": "2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@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-progress": "1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-select": "2.2.6",
|
||||
"@radix-ui/react-separator": "1.1.8",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "1.2.4",
|
||||
"@radix-ui/react-switch": "1.2.6",
|
||||
"@radix-ui/react-tabs": "1.1.13",
|
||||
@@ -69,10 +63,10 @@
|
||||
"@radix-ui/react-toggle-group": "1.1.11",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@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-virtual": "^3.13.23",
|
||||
"ai": "^6.0.154",
|
||||
"ai": "^6.0.159",
|
||||
"better-auth": "1.6.2",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "0.7.1",
|
||||
@@ -87,12 +81,11 @@
|
||||
"next-themes": "0.4.6",
|
||||
"pdfjs-dist": "^5.6.205",
|
||||
"pg": "8.20.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.5",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "19.2.5",
|
||||
"recharts": "3.8.1",
|
||||
"resend": "^6.10.0",
|
||||
"resend": "^6.11.0",
|
||||
"sonner": "2.0.7",
|
||||
"tailwind-merge": "3.5.0",
|
||||
"vaul": "1.1.2",
|
||||
@@ -104,16 +97,16 @@
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.10",
|
||||
"@biomejs/biome": "2.4.11",
|
||||
"@tailwindcss/postcss": "4.2.2",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "25.5.2",
|
||||
"@types/node": "25.6.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"dotenv": "^17.4.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-kit": "0.31.10",
|
||||
"knip": "^6.3.1",
|
||||
"knip": "^6.4.1",
|
||||
"tailwindcss": "4.2.2",
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "6.0.2"
|
||||
|
||||
704
pnpm-lock.yaml
generated
|
Before Width: | Height: | Size: 198 KiB After Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 202 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 163 KiB |
26
src/app/api/logo/mapping/route.ts
Normal 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 });
|
||||
}
|
||||
80
src/app/api/logo/search/route.ts
Normal 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);
|
||||
}
|
||||
@@ -721,6 +721,7 @@ export const userRelations = relations(user, ({ many, one }) => ({
|
||||
installmentAnticipations: many(installmentAnticipations),
|
||||
apiTokens: many(apiTokens),
|
||||
inboxItems: many(inboxItems),
|
||||
establishmentLogos: many(establishmentLogos),
|
||||
}));
|
||||
|
||||
export const accountRelations = relations(account, ({ one }) => ({
|
||||
@@ -955,6 +956,25 @@ export const importCategoryMappings = pgTable(
|
||||
}),
|
||||
);
|
||||
|
||||
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 NewUser = typeof user.$inferInsert;
|
||||
export type Account = typeof account.$inferSelect;
|
||||
@@ -1004,3 +1024,13 @@ export const transactionAttachmentsRelations = relations(
|
||||
|
||||
export type Attachment = typeof attachments.$inferSelect;
|
||||
export type TransactionAttachment = typeof transactionAttachments.$inferSelect;
|
||||
|
||||
export const establishmentLogosRelations = relations(
|
||||
establishmentLogos,
|
||||
({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [establishmentLogos.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -199,7 +199,7 @@ function buildColumns({
|
||||
|
||||
return (
|
||||
<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="text-xs text-muted-foreground flex items-center gap-2">
|
||||
{formatDate(purchaseDate)}
|
||||
|
||||
@@ -38,7 +38,11 @@ function buildCsp(): string {
|
||||
|
||||
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)
|
||||
.join(" ");
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
buildInitials,
|
||||
getCategoryBgColorFromName,
|
||||
getCategoryColorFromName,
|
||||
} from "@/shared/utils/category-colors";
|
||||
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 {
|
||||
name: string;
|
||||
/** Domínio Logo.dev pré-carregado pelo servidor (otimização opcional). */
|
||||
domain?: string | null;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EstablishmentLogo({
|
||||
name,
|
||||
domain: initialDomain,
|
||||
size = 32,
|
||||
className,
|
||||
}: 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 color = getCategoryColorFromName(name);
|
||||
const bgColor = getCategoryBgColorFromName(name);
|
||||
|
||||
return (
|
||||
const initialsAvatar = (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center rounded-full font-medium",
|
||||
className,
|
||||
)}
|
||||
className="flex shrink-0 items-center justify-center rounded-full font-medium"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
@@ -38,4 +82,60 @@ export function EstablishmentLogo({
|
||||
{initials}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,3 +4,4 @@ export type {
|
||||
} from "./category-icon-badge";
|
||||
export { CategoryIconBadge } from "./category-icon-badge";
|
||||
export { EstablishmentLogo } from "./establishment-logo";
|
||||
export { EstablishmentLogoPicker } from "./establishment-logo-picker";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
|
||||
import { RiArrowDropDownLine } from "@remixicon/react";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { NavigationMenu as NavigationMenuPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
import { cn } from "@/shared/utils";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Slider as SliderPrimitive } from "radix-ui";
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
import type * as React from "react";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
|
||||
|
||||
64
src/shared/lib/logo/establishment-logo-actions.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
46
src/shared/lib/logo/establishment-logo-queries.ts
Normal 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]));
|
||||
}
|
||||
@@ -39,6 +39,27 @@ export const deriveNameFromLogo = (logo?: string | null) => {
|
||||
.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:)/;
|
||||
|
||||
type ResolveLogoSrcOptions = {
|
||||
|
||||