mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 19:01:47 +00:00
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>
This commit is contained in:
13
.env.example
13
.env.example
@@ -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=
|
||||||
26
src/app/api/logo/mapping/route.ts
Normal file
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
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),
|
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 }) => ({
|
||||||
@@ -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 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 +1024,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],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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(" ");
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
LOGO_DEV_TOKEN,
|
||||||
|
buildLogoDevUrl,
|
||||||
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
64
src/shared/lib/logo/establishment-logo-actions.ts
Normal file
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
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,29 @@ 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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user