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() {
>
+ 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. +
+- 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. +
+ +