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:
Felipe Coutinho
2026-04-14 00:26:48 +00:00
parent 1161e97d9e
commit 679ea752bb
12 changed files with 579 additions and 11 deletions

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>
);
}