diff --git a/src/app/api/admin/tenants/logo/route.ts b/src/app/api/admin/tenants/logo/route.ts index 74fcf83..5325f54 100644 --- a/src/app/api/admin/tenants/logo/route.ts +++ b/src/app/api/admin/tenants/logo/route.ts @@ -12,18 +12,30 @@ import { sanitizeFileName, serviceSupabase, } from '@/lib/assets'; -import { randomUUID } from 'crypto'; +import { randomUUID, createHash } from 'crypto'; +import { + newId, + parseBrandbook, + type BrandAsset, + BRANDBOOK_DOC_TYPE, + BRANDBOOK_DOC_TITLE, +} from '@/lib/brandbook'; export const dynamic = 'force-dynamic'; export const runtime = 'nodejs'; -const MAX_LOGO = 2 * 1024 * 1024; // 2 MB -const ALLOWED = new Set([ +const MAX_LOGO = 15 * 1024 * 1024; // 15 MB +const ALLOWED_MIMES = new Set([ 'image/png', 'image/jpeg', + 'image/jpg', 'image/webp', 'image/svg+xml', + 'image/svg', + 'image/x-png', + 'image/pjpeg', ]); +const ALLOWED_EXTS = new Set(['png', 'jpg', 'jpeg', 'webp', 'svg']); async function requireAgency() { const supabase = await createClient(); @@ -64,18 +76,36 @@ export async function POST(req: NextRequest) { } if (file.size > MAX_LOGO) { return NextResponse.json( - { error: 'Logo máximo 2 MB' }, + { error: 'Logo máximo 15 MB' }, { status: 400 } ); } - const mime = file.type || 'application/octet-stream'; - if (!ALLOWED.has(mime)) { + + const rawMime = (file.type || '').split(';')[0].trim().toLowerCase(); + const ext = (file.name || '').split('.').pop()?.toLowerCase() || ''; + const isAllowed = + ALLOWED_MIMES.has(rawMime) || + rawMime.startsWith('image/') || + ALLOWED_EXTS.has(ext); + + if (!isAllowed) { return NextResponse.json( - { error: 'Usa PNG, JPG, WEBP o SVG' }, + { error: 'Formato no soportado. Usa PNG, JPG, WEBP o SVG.' }, { status: 400 } ); } + const resolvedMime = + rawMime && rawMime !== 'application/octet-stream' + ? rawMime + : ext === 'svg' + ? 'image/svg+xml' + : ext === 'webp' + ? 'image/webp' + : ext === 'png' + ? 'image/png' + : 'image/jpeg'; + const bucketOk = await ensureAssetsBucket(); if (!bucketOk.ok) { return NextResponse.json( @@ -90,15 +120,51 @@ export async function POST(req: NextRequest) { } const safe = sanitizeFileName(file.name || 'logo.png'); - const path = `${tenantId}/branding/${randomUUID()}_${safe}`; + const assetId = randomUUID(); + const folder = 'branding'; + const path = `${tenantId}/${folder}/${assetId}_${safe}`; const buf = Buffer.from(await file.arrayBuffer()); + const sha256 = createHash('sha256').update(buf).digest('hex'); + const { error: upErr } = await sb.storage .from(ASSETS_BUCKET) - .upload(path, buf, { contentType: mime, upsert: true }); + .upload(path, buf, { contentType: resolvedMime, upsert: true }); if (upErr) { return NextResponse.json({ error: upErr.message }, { status: 500 }); } + // Register in asset_files table so it can be previewed/downloaded cleanly + try { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + await fetch(`${supabaseUrl()}/rest/v1/asset_files`, { + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=minimal', + }, + body: JSON.stringify({ + id: assetId, + tenant_id: tenantId, + title: safe, + folder, + file_name: safe, + mime_type: resolvedMime, + bytes: file.size, + storage_path: path, + sha256, + status: 'approved', + created_by: user?.id || null, + }), + }); + } catch { + // Non-blocking + } + // Store storage path; served via /api/branding/logo const logoUrl = `storage:${path}`; const patch = await fetch( @@ -125,6 +191,51 @@ export async function POST(req: NextRequest) { ); } + // Also sync into documents (brandbook) kit_assets so it appears in Zona de marca + try { + const docRes = await fetch( + `${supabaseUrl()}/rest/v1/documents?tenant_id=eq.${encodeURIComponent(tenantId)}&type=eq.${BRANDBOOK_DOC_TYPE}&select=id,content&order=updated_at.desc&limit=1`, + { headers, cache: 'no-store' } + ); + if (docRes.ok) { + const docs = await docRes.json(); + const doc = docs[0]; + if (doc) { + const book = parseBrandbook(doc.content); + if (book) { + const asset: BrandAsset = { + id: newId('kit'), + asset_file_id: assetId, + kind: 'logo', + role: 'primary', + label: safe, + file_name: safe, + mime_type: resolvedMime, + added_at: new Date().toISOString(), + }; + book.kit_assets = [ + asset, + ...(book.kit_assets || []).filter((a) => a.role !== 'primary'), + ]; + await fetch(`${supabaseUrl()}/rest/v1/documents?id=eq.${doc.id}`, { + method: 'PATCH', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=minimal', + }, + body: JSON.stringify({ + content: book, + updated_at: new Date().toISOString(), + }), + }); + } + } + } + } catch { + // Non-blocking + } + return NextResponse.json({ logoUrl, serveUrl: `/api/branding/logo?tenantId=${encodeURIComponent(tenantId)}`, diff --git a/src/app/api/brandbook/kit/route.ts b/src/app/api/brandbook/kit/route.ts index 091fe34..fd25b3d 100644 --- a/src/app/api/brandbook/kit/route.ts +++ b/src/app/api/brandbook/kit/route.ts @@ -235,6 +235,25 @@ export async function POST(req: NextRequest) { ); } + if (kind === 'logo') { + try { + await fetch( + `${supabaseUrl()}/rest/v1/tenants?id=eq.${encodeURIComponent(ctx.tenantId)}`, + { + method: 'PATCH', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=minimal', + }, + body: JSON.stringify({ logo_url: `storage:${storagePath}` }), + } + ); + } catch { + // Non-blocking + } + } + return NextResponse.json({ ok: true, asset, diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 0348241..1610259 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -4,7 +4,7 @@ import React, { useState, Suspense } from 'react'; import Image from 'next/image'; import { useSearchParams } from 'next/navigation'; import { createClient } from '@/lib/supabase/client'; -import { Mail, Lock, Eye, EyeOff, ShieldCheck, ArrowRight, Loader2 } from 'lucide-react'; +import { Mail, Lock, Eye, EyeOff, ArrowRight, Loader2 } from 'lucide-react'; function LoginForm() { const searchParams = useSearchParams(); @@ -172,15 +172,6 @@ export default function LoginPage() { > - -
-
- - Aislamiento RLS multi-tenant -
- · - TLS 1.3 -
); diff --git a/src/components/BrandZone.tsx b/src/components/BrandZone.tsx index 0adb7a7..50ee8d7 100644 --- a/src/components/BrandZone.tsx +++ b/src/components/BrandZone.tsx @@ -39,6 +39,7 @@ import { listToLines, newId, normalizeHex, + primaryLogo, type BrandAssetKind, type Brandbook, type BrandbookSectionId, @@ -503,14 +504,20 @@ export default function BrandZone({ } } - async function uploadKit(file: File) { + async function uploadKit( + file: File, + overrideKind?: BrandAssetKind, + overrideRole?: string + ) { + const kind = overrideKind || kitKind; + const role = overrideRole || kitRole; setKitUploading(true); setErr(null); try { const fd = new FormData(); fd.append('file', file); - fd.append('kind', kitKind); - fd.append('role', kitRole); + fd.append('kind', kind); + fd.append('role', role); fd.append('label', file.name); const r = await fetch('/api/brandbook/kit', { method: 'POST', body: fd }); const j = await r.json(); @@ -519,7 +526,7 @@ export default function BrandZone({ setScore(j.completeness?.score ?? 0); setMissing(j.completeness?.missing || []); setExists(true); - setOkMsg(`${ASSET_KIND_LABELS[kitKind]} añadido: ${file.name}`); + setOkMsg(`${ASSET_KIND_LABELS[kind]} añadido: ${file.name}`); setSection('visual'); } catch (e) { setErr(e instanceof Error ? e.message : 'Error kit'); @@ -696,6 +703,77 @@ export default function BrandZone({ + {/* Barra rápida de subida de archivos al kit */} +
+
+
+ + Añadir archivo al kit: +
+
+ + setKitRole(e.target.value)} + /> + + + PNG/SVG para logos · TTF/WOFF para tipografías · PDFs o imágenes (máx. 80 MB) + +
+
+
+ {err ? (
{err} @@ -1394,6 +1472,65 @@ export default function BrandZone({

+ {/* Logotipo oficial de la marca */} + +
+
+ {primaryLogo(book) ? ( + + ) : ( +
+

+ Sin logotipo subido todavía +

+

+ SVG vectorial, PNG transparente o JPG +

+
+ )} +
+
+
+

+ Archivo actual:{' '} + {primaryLogo(book)?.label || 'Ninguno'} +

+

+ Se sincroniza automáticamente con el menú del portal, la ficha del cliente y las plantillas de ads/Canva. +

+
+ +
+
+
+ {book.kit_assets.length === 0 ? ( -

- Todavía no hay logos ni archivos. Usa «Añadir al kit» - arriba. -

+
+

+ Todavía no hay logos ni archivos en el kit de marca. +

+ +
) : (
    {book.kit_assets.map((a) => ( diff --git a/src/components/ClientFicha.tsx b/src/components/ClientFicha.tsx index 32c486c..0de388d 100644 --- a/src/components/ClientFicha.tsx +++ b/src/components/ClientFicha.tsx @@ -13,6 +13,7 @@ import { Shield, Sparkles, Trash2, + Upload, UserPlus, Users, } from 'lucide-react'; @@ -162,25 +163,32 @@ export default function ClientFicha({ setModules(suggestModulesFromIntegrations(integrations)); } + const [logoUploading, setLogoUploading] = useState(false); + async function onLogoFile(file: File | null) { if (!file) return; - setSaving(true); + setLogoUploading(true); setErr(null); - const fd = new FormData(); - fd.set('tenantId', tenantId); - fd.set('file', file); - const res = await fetch('/api/admin/tenants/logo', { - method: 'POST', - body: fd, - }); - const data = await res.json(); - setSaving(false); - if (!res.ok) { - setErr(data.error || 'Error al subir logo'); - return; + try { + const fd = new FormData(); + fd.set('tenantId', tenantId); + fd.set('file', file); + const res = await fetch('/api/admin/tenants/logo', { + method: 'POST', + body: fd, + }); + const data = await res.json(); + if (!res.ok) { + setErr(data.error || 'Error al subir logo'); + return; + } + setLogoUrl(data.logoUrl); + setMsg('Logo del cliente actualizado con éxito — visible en la barra lateral y en la ficha'); + } catch { + setErr('Error de red al subir el logo'); + } finally { + setLogoUploading(false); } - setLogoUrl(data.logoUrl); - setMsg('Logo actualizado — se muestra en la vista del cliente'); } async function clearLogo() { @@ -385,12 +393,29 @@ export default function ClientFicha({
    -
    - - Logo en vista cliente +
    +
    + + Logo en vista cliente +
    + {logoUrl ? ( + + ) : null}
    -
    - {logoSrc ? ( + +
    + {logoUploading ? ( +
    + + Subiendo logo… +
    + ) : logoSrc ? ( // eslint-disable-next-line @next/next/no-img-element )}
    -