diff --git a/.env.example b/.env.example index 2f91bc9..457b056 100644 --- a/.env.example +++ b/.env.example @@ -58,7 +58,9 @@ 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= +# Ambas as variáveis são lidas em runtime pelo servidor — basta configurá-las +# no host (Coolify, Railway, Docker Compose etc.), sem mexer no CI. +# LOGO_DEV_TOKEN — token público usado pelo servidor para montar a URL da imagem +# LOGO_DEV_SECRET_KEY — chave secreta para a Brand Search API (picker de logo) +LOGO_DEV_TOKEN= LOGO_DEV_SECRET_KEY= \ No newline at end of file diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index f35062a..99d1d74 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -85,8 +85,6 @@ 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 }} diff --git a/Dockerfile b/Dockerfile index 0be22ed..14dee82 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,9 +40,9 @@ 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 +# Nota: a integração Logo.dev não precisa mais de build args. O token +# `LOGO_DEV_TOKEN` é lido em runtime no servidor — basta configurá-lo no +# host (Coolify, Railway, etc.) junto com `LOGO_DEV_SECRET_KEY`. # Build da aplicação Next.js RUN pnpm build diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index bb13a18..407ab13 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -1,9 +1,10 @@ import { connection } from "next/server"; import { fetchDashboardNavbarData } from "@/features/dashboard/navbar-queries"; import { AppNavbar } from "@/shared/components/navigation/navbar/app-navbar"; +import { LogoDevProvider } from "@/shared/components/providers/logo-dev-provider"; import { PrivacyProvider } from "@/shared/components/providers/privacy-provider"; -import { DotPattern } from "@/shared/components/ui/dot-pattern"; import { getUserSession } from "@/shared/lib/auth/server"; +import { isLogoDevEnabled } from "@/shared/lib/logo/server"; export default async function DashboardLayout({ children, @@ -13,33 +14,25 @@ export default async function DashboardLayout({ await connection(); const session = await getUserSession(); const navbarData = await fetchDashboardNavbarData(session.user.id); + const logoDevEnabled = isLogoDevEnabled(); return ( - - -
-
- -
-
-
-
- {children} + + + +
+
+
+ {children} +
-
- + + ); } diff --git a/src/app/api/logo/mapping/route.ts b/src/app/api/logo/mapping/route.ts index e866aff..ad0eb18 100644 --- a/src/app/api/logo/mapping/route.ts +++ b/src/app/api/logo/mapping/route.ts @@ -1,26 +1,29 @@ import { NextResponse } from "next/server"; import { getOptionalUserSession } from "@/shared/lib/auth/server"; import { fetchEstablishmentLogoDomain } from "@/shared/lib/logo/establishment-logo-queries"; +import { buildLogoDevUrl } from "@/shared/lib/logo/server"; /** * 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. + * Retorna o domínio Logo.dev salvo pelo usuário para um estabelecimento, + * junto com a `logoUrl` final (construída server-side com o token). O + * cliente usa `logoUrl` diretamente — sem precisar conhecer o token. */ export async function GET(request: Request) { const session = await getOptionalUserSession(); if (!session) { - return NextResponse.json({ domain: null }, { status: 200 }); + return NextResponse.json({ domain: null, logoUrl: null }, { status: 200 }); } const { searchParams } = new URL(request.url); const name = searchParams.get("name")?.trim(); if (!name) { - return NextResponse.json({ domain: null }, { status: 200 }); + return NextResponse.json({ domain: null, logoUrl: null }, { status: 200 }); } const domain = await fetchEstablishmentLogoDomain(session.user.id, name); - return NextResponse.json({ domain }); + const logoUrl = buildLogoDevUrl(domain); + return NextResponse.json({ domain, logoUrl }); } diff --git a/src/app/api/logo/search/route.ts b/src/app/api/logo/search/route.ts index 6b75757..24d5141 100644 --- a/src/app/api/logo/search/route.ts +++ b/src/app/api/logo/search/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { getOptionalUserSession } from "@/shared/lib/auth/server"; +import { buildLogoDevUrl } from "@/shared/lib/logo/server"; const LOGO_DEV_SEARCH_URL = "https://api.logo.dev/search"; @@ -8,6 +9,10 @@ interface LogoResult { domain: string; } +interface LogoResultWithUrl extends LogoResult { + logoUrl: string | null; +} + async function searchByStrategy( q: string, strategy: "match" | "typeahead", @@ -66,12 +71,14 @@ export async function GET(request: Request) { // Mescla e deduplica por domain, mantendo ordem (match tem prioridade) const seen = new Set(); - const merged: LogoResult[] = []; + const merged: LogoResultWithUrl[] = []; for (const result of [...matchResults, ...typeaheadResults]) { if (!seen.has(result.domain)) { seen.add(result.domain); - merged.push(result); + // logoUrl é construída server-side com o token — o cliente nunca + // precisa conhecer LOGO_DEV_TOKEN para renderizar a imagem. + merged.push({ ...result, logoUrl: buildLogoDevUrl(result.domain) }); if (merged.length >= 20) break; } } diff --git a/src/shared/components/entity-avatar/category-icon-badge.tsx b/src/shared/components/entity-avatar/category-icon-badge.tsx index d2ad022..f5c29d0 100644 --- a/src/shared/components/entity-avatar/category-icon-badge.tsx +++ b/src/shared/components/entity-avatar/category-icon-badge.tsx @@ -13,7 +13,7 @@ const sizeVariants = { sm: { container: "size-8", icon: "size-4", - text: "text-[10px]", + text: "text-xs", }, md: { container: "size-9", diff --git a/src/shared/components/entity-avatar/establishment-logo-picker.tsx b/src/shared/components/entity-avatar/establishment-logo-picker.tsx index c652ecb..276761a 100644 --- a/src/shared/components/entity-avatar/establishment-logo-picker.tsx +++ b/src/shared/components/entity-avatar/establishment-logo-picker.tsx @@ -9,7 +9,7 @@ import { PopoverTrigger, } from "@/shared/components/ui/popover"; import { Spinner } from "@/shared/components/ui/spinner"; -import { buildLogoDevUrl, logoQueryKeys, toNameKey } from "@/shared/lib/logo"; +import { logoQueryKeys, toNameKey } from "@/shared/lib/logo"; import { removeEstablishmentLogoAction, saveEstablishmentLogoAction, @@ -24,6 +24,8 @@ import { cn } from "@/shared/utils/ui"; interface LogoResult { name: string; domain: string; + /** URL da imagem construída server-side — cliente usa direto sem token. */ + logoUrl: string | null; } async function fetchLogoResults(query: string): Promise { @@ -77,13 +79,14 @@ export function EstablishmentLogoPicker({ staleTime: 1000 * 60 * 60, }); - function handleSelect(domain: string) { + function handleSelect(result: LogoResult) { startTransition(async () => { - await saveEstablishmentLogoAction(name, domain); + await saveEstablishmentLogoAction(name, result.domain); queryClient.setQueryData(logoQueryKeys.mapping(toNameKey(name)), { - domain, + domain: result.domain, + logoUrl: result.logoUrl, }); - onSelect(domain); + onSelect(result.domain); }); } @@ -92,6 +95,7 @@ export function EstablishmentLogoPicker({ await removeEstablishmentLogoAction(name); queryClient.setQueryData(logoQueryKeys.mapping(toNameKey(name)), { domain: null, + logoUrl: null, }); onSelect(null); }); @@ -143,7 +147,7 @@ export function EstablishmentLogoPicker({ > {buildInitials(name)}
- + Iniciais @@ -153,7 +157,7 @@ export function EstablishmentLogoPicker({ key={r.domain} type="button" disabled={isPending} - onClick={() => handleSelect(r.domain)} + onClick={() => handleSelect(r)} 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 && @@ -161,9 +165,8 @@ export function EstablishmentLogoPicker({ )} title={r.name} > - {/* eslint-disable-next-line @next/next/no-img-element */} {r.name} - + {r.name} diff --git a/src/shared/components/entity-avatar/establishment-logo.tsx b/src/shared/components/entity-avatar/establishment-logo.tsx index 7d8f1dc..d646dfd 100644 --- a/src/shared/components/entity-avatar/establishment-logo.tsx +++ b/src/shared/components/entity-avatar/establishment-logo.tsx @@ -2,13 +2,9 @@ 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 { useState } from "react"; +import { useLogoDevEnabled } from "@/shared/components/providers/logo-dev-provider"; +import { logoQueryKeys, toNameKey } from "@/shared/lib/logo"; import { buildInitials, getCategoryBgColorFromName, @@ -17,11 +13,14 @@ import { import { cn } from "@/shared/utils/ui"; import { EstablishmentLogoPicker } from "./establishment-logo-picker"; -async function fetchLogoMapping( - name: string, -): Promise<{ domain: string | null }> { +interface LogoMappingResponse { + domain: string | null; + logoUrl: string | null; +} + +async function fetchLogoMapping(name: string): Promise { const res = await fetch(`/api/logo/mapping?name=${encodeURIComponent(name)}`); - if (!res.ok) return { domain: null }; + if (!res.ok) return { domain: null, logoUrl: null }; return res.json(); } @@ -29,6 +28,8 @@ interface EstablishmentLogoProps { name: string; /** Domínio Logo.dev pré-carregado pelo servidor (otimização opcional). */ domain?: string | null; + /** URL pré-construída no servidor — evita flicker no primeiro render. */ + logoUrl?: string | null; size?: number; className?: string; } @@ -36,31 +37,33 @@ interface EstablishmentLogoProps { export function EstablishmentLogo({ name, domain: initialDomain, + logoUrl: initialLogoUrl, size = 32, className, }: EstablishmentLogoProps) { + const logoDevEnabled = useLogoDevEnabled(); const [pickerOpen, setPickerOpen] = useState(false); const [imgError, setImgError] = useState(false); + const hasPlaceholder = + initialDomain !== undefined || initialLogoUrl !== undefined; + const { data: mappingData } = useQuery({ queryKey: logoQueryKeys.mapping(toNameKey(name)), queryFn: () => fetchLogoMapping(name), - placeholderData: - initialDomain !== undefined - ? { domain: initialDomain ?? null } - : undefined, + placeholderData: hasPlaceholder + ? { + domain: initialDomain ?? null, + logoUrl: initialLogoUrl ?? null, + } + : undefined, staleTime: 1000 * 60 * 5, - enabled: LOGO_DEV_TOKEN !== undefined && LOGO_DEV_TOKEN !== "", + enabled: logoDevEnabled, }); const resolvedDomain = mappingData?.domain ?? null; + const logoUrl = mappingData?.logoUrl ?? 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); @@ -85,7 +88,6 @@ export function EstablishmentLogo({ const logoImage = showLogo && logoUrl ? ( - // eslint-disable-next-line @next/next/no-img-element {name} {initialsAvatar} diff --git a/src/shared/components/providers/logo-dev-provider.tsx b/src/shared/components/providers/logo-dev-provider.tsx new file mode 100644 index 0000000..2239df7 --- /dev/null +++ b/src/shared/components/providers/logo-dev-provider.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { createContext, useContext } from "react"; + +/** + * Expõe, para Client Components, se a integração Logo.dev está configurada. + * + * O valor é determinado server-side (lendo `LOGO_DEV_TOKEN` via + * `isLogoDevEnabled()` em `@/shared/lib/logo/server`) e passado como prop + * a partir do layout do dashboard — evitando que o cliente precise do token. + */ +const LogoDevContext = createContext(false); + +interface LogoDevProviderProps { + enabled: boolean; + children: React.ReactNode; +} + +export function LogoDevProvider({ enabled, children }: LogoDevProviderProps) { + return ( + + {children} + + ); +} + +export function useLogoDevEnabled(): boolean { + return useContext(LogoDevContext); +} diff --git a/src/shared/lib/logo/index.ts b/src/shared/lib/logo/index.ts index 1448b33..6eb9f4c 100644 --- a/src/shared/lib/logo/index.ts +++ b/src/shared/lib/logo/index.ts @@ -1,14 +1,5 @@ -/** - * Logo utilities - * - * Consolidated from: - * - /lib/logo.ts (utility functions) - */ - /** * Normalizes logo path to get just the filename - * @param logo - Logo path or URL - * @returns Filename only */ export const normalizeLogo = (logo?: string | null) => logo?.split("/").filter(Boolean).pop() ?? ""; @@ -45,13 +36,11 @@ export const deriveNameFromLogo = (logo?: string | null) => { 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`; -} +// +// A construção de URLs e a leitura do token acontecem server-side em +// `./server.ts`. O cliente consome `logoUrl` pré-construída a partir das +// API routes (`/api/logo/mapping` e `/api/logo/search`) e usa o +// `LogoDevProvider` para saber se a integração está habilitada. export const logoQueryKeys = { mapping: (nameKey: string) => ["logo-mapping", nameKey] as const, diff --git a/src/shared/lib/logo/server.ts b/src/shared/lib/logo/server.ts new file mode 100644 index 0000000..b61f292 --- /dev/null +++ b/src/shared/lib/logo/server.ts @@ -0,0 +1,30 @@ +/** + * Helpers server-only para Logo.dev. + * + * IMPORTANTE: este módulo lê `process.env.LOGO_DEV_TOKEN`, que não existe + * no bundle do cliente. Nunca importe este arquivo de Client Components + * — use o `LogoDevProvider` para propagar o estado `enabled` e consuma + * `logoUrl` a partir das respostas das API routes. + */ +function getLogoDevToken(): string | undefined { + const token = process.env.LOGO_DEV_TOKEN; + return token && token.length > 0 ? token : undefined; +} + +/** + * Indica se a integração Logo.dev está configurada. + * Usado para habilitar o picker e a exibição de logos na UI. + */ +export function isLogoDevEnabled(): boolean { + return getLogoDevToken() !== undefined; +} + +/** + * Constrói a URL final da imagem Logo.dev com o token aplicado server-side. + * Retorna null se o token não estiver configurado ou se o domínio for vazio. + */ +export function buildLogoDevUrl(domain?: string | null): string | null { + const token = getLogoDevToken(); + if (!token || !domain) return null; + return `https://img.logo.dev/${domain}?token=${token}&size=64&format=png`; +}