mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +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:
@@ -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 {
|
||||
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";
|
||||
|
||||
Reference in New Issue
Block a user