From 1715b0ac6774488f276e44474ac007b18f9795f9 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Sat, 10 Oct 2026 01:35:08 +0200 Subject: [PATCH] Expand Zona de marca into a full brand kit with asset uploads. Expert sections plus logos, fonts, color tokens and file kit so ads, calendars and B2B content share one brand system. Co-authored-by: Cursor --- src/app/api/brandbook/kit/route.ts | 300 ++++++++ src/components/BrandZone.tsx | 1109 ++++++++++++++++++++++++---- src/lib/brandbook.ts | 691 ++++++++++++++--- 3 files changed, 1862 insertions(+), 238 deletions(-) create mode 100644 src/app/api/brandbook/kit/route.ts diff --git a/src/app/api/brandbook/kit/route.ts b/src/app/api/brandbook/kit/route.ts new file mode 100644 index 0000000..091fe34 --- /dev/null +++ b/src/app/api/brandbook/kit/route.ts @@ -0,0 +1,300 @@ +/** + * POST multipart — upload logo/font/image/doc into brand kit + link in brandbook. + * Fields: file, kind, role?, label? + * DELETE ?assetId= — unlink + optional delete file + */ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +import { resolveActiveTenant, restGet } from '@/lib/tenant-context'; +import { + isAgencyRole, + serviceHeaders, + supabaseUrl, + userRole, +} from '@/lib/agency'; +import { + ASSETS_BUCKET, + MAX_ASSET_BYTES, + ensureAssetsBucket, + sanitizeFileName, + serviceSupabase, +} from '@/lib/assets'; +import { + BRANDBOOK_DOC_TITLE, + BRANDBOOK_DOC_TYPE, + BRANDBOOK_FOLDER, + brandbookCompleteness, + emptyBrandbook, + newId, + parseBrandbook, + type BrandAsset, + type BrandAssetKind, + type Brandbook, +} from '@/lib/brandbook'; +import { createHash, randomUUID } from 'crypto'; + +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +const KINDS = new Set([ + 'logo', + 'font', + 'image', + 'icon', + 'document', + 'pattern', + 'video', + 'other', +]); + +async function loadBook(tenantId: string, label: string) { + const r = await restGet<{ id: string; content: unknown }[]>( + `/rest/v1/documents?tenant_id=eq.${encodeURIComponent( + tenantId + )}&type=eq.${BRANDBOOK_DOC_TYPE}&select=id,content&order=updated_at.desc&limit=1` + ); + const row = r.data?.[0]; + return { + id: row?.id || null, + book: parseBrandbook(row?.content) || emptyBrandbook(label), + }; +} + +async function saveBook( + tenantId: string, + docId: string | null, + book: Brandbook +) { + const headers = serviceHeaders(); + const base = supabaseUrl(); + if (!headers || !base) throw new Error('No service role'); + book.updated_at = new Date().toISOString(); + if (docId) { + const patch = await fetch(`${base}/rest/v1/documents?id=eq.${docId}`, { + method: 'PATCH', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=minimal', + }, + body: JSON.stringify({ + content: book, + title: BRANDBOOK_DOC_TITLE, + updated_at: book.updated_at, + }), + }); + if (!patch.ok) throw new Error(await patch.text()); + return docId; + } + const ins = await fetch(`${base}/rest/v1/documents`, { + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=representation', + }, + body: JSON.stringify({ + tenant_id: tenantId, + type: BRANDBOOK_DOC_TYPE, + title: BRANDBOOK_DOC_TITLE, + status: 'approved', + content: book, + }), + }); + if (!ins.ok) throw new Error(await ins.text()); + const rows = (await ins.json()) as { id: string }[]; + return rows[0]?.id || null; +} + +export async function POST(req: NextRequest) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + if (!isAgencyRole(userRole(user))) { + return NextResponse.json({ error: 'Solo agencia' }, { status: 403 }); + } + const ctx = await resolveActiveTenant(user); + if (!ctx.tenantId || ctx.portfolioWide) { + return NextResponse.json( + { error: 'Selecciona un cliente en Contexto.' }, + { status: 400 } + ); + } + + const bucketOk = await ensureAssetsBucket(); + if (!bucketOk.ok) { + return NextResponse.json( + { error: `Storage: ${bucketOk.error}` }, + { status: 503 } + ); + } + const svc = serviceSupabase(); + const headers = serviceHeaders(); + if (!svc || !headers) { + return NextResponse.json({ error: 'No service role' }, { status: 503 }); + } + + const form = await req.formData(); + const file = form.get('file'); + if (!(file instanceof File) || file.size <= 0) { + return NextResponse.json({ error: 'Archivo requerido' }, { status: 400 }); + } + if (file.size > MAX_ASSET_BYTES) { + return NextResponse.json({ error: 'Archivo > 80 MB' }, { status: 400 }); + } + + const kindRaw = String(form.get('kind') || 'other') as BrandAssetKind; + const kind = KINDS.has(kindRaw) ? kindRaw : 'other'; + const role = String(form.get('role') || kind).slice(0, 80); + const label = String(form.get('label') || file.name).slice(0, 120); + + const safeName = sanitizeFileName(file.name); + const id = randomUUID(); + const folder = `${BRANDBOOK_FOLDER}/${kind}`; + const storagePath = `${ctx.tenantId}/${folder}/${id}_${safeName}`; + const buf = Buffer.from(await file.arrayBuffer()); + const sha256 = createHash('sha256').update(buf).digest('hex'); + + const up = await svc.storage.from(ASSETS_BUCKET).upload(storagePath, buf, { + contentType: file.type || 'application/octet-stream', + upsert: false, + }); + if (up.error) { + return NextResponse.json({ error: up.error.message }, { status: 500 }); + } + + const row = { + id, + tenant_id: ctx.tenantId, + title: label || safeName, + folder, + file_name: safeName, + mime_type: file.type || 'application/octet-stream', + bytes: file.size, + storage_path: storagePath, + sha256, + status: 'approved', + created_by: user.id, + }; + const ins = await fetch(`${supabaseUrl()}/rest/v1/asset_files`, { + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=representation', + }, + body: JSON.stringify(row), + }); + if (!ins.ok) { + await svc.storage.from(ASSETS_BUCKET).remove([storagePath]); + return NextResponse.json({ error: await ins.text() }, { status: 500 }); + } + + const asset: BrandAsset = { + id: newId('kit'), + asset_file_id: id, + kind, + role, + label, + file_name: safeName, + mime_type: file.type || undefined, + added_at: new Date().toISOString(), + }; + + const { id: docId, book } = await loadBook(ctx.tenantId, ctx.label); + book.brand_name = book.brand_name || ctx.label; + book.kit_assets = [asset, ...(book.kit_assets || [])]; + + // Auto-add font token when uploading a font file + if (kind === 'font') { + const family = label.replace(/\.(ttf|otf|woff2?|zip)$/i, '').trim() || label; + book.fonts_kit = [ + { + id: newId('font'), + family, + role: /display|headline|titulo/i.test(role) ? 'headline' : 'body', + weights: '400,700', + source: 'file', + url_or_notes: `Archivo: ${safeName}`, + asset_file_id: id, + }, + ...(book.fonts_kit || []), + ]; + } + + try { + await saveBook(ctx.tenantId, docId, book); + } catch (e) { + return NextResponse.json( + { error: e instanceof Error ? e.message : 'No se pudo guardar brandbook' }, + { status: 500 } + ); + } + + return NextResponse.json({ + ok: true, + asset, + brandbook: book, + completeness: brandbookCompleteness(book), + }); +} + +export async function DELETE(req: NextRequest) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + if (!isAgencyRole(userRole(user))) { + return NextResponse.json({ error: 'Solo agencia' }, { status: 403 }); + } + const ctx = await resolveActiveTenant(user); + if (!ctx.tenantId || ctx.portfolioWide) { + return NextResponse.json( + { error: 'Selecciona un cliente' }, + { status: 400 } + ); + } + + const kitId = req.nextUrl.searchParams.get('kitId') || ''; + const fileId = req.nextUrl.searchParams.get('assetFileId') || ''; + if (!kitId && !fileId) { + return NextResponse.json({ error: 'kitId requerido' }, { status: 400 }); + } + + const { id: docId, book } = await loadBook(ctx.tenantId, ctx.label); + const removed = book.kit_assets.find( + (a) => a.id === kitId || a.asset_file_id === fileId + ); + book.kit_assets = book.kit_assets.filter( + (a) => a.id !== kitId && a.asset_file_id !== fileId + ); + if (removed?.asset_file_id) { + book.fonts_kit = book.fonts_kit.map((f) => + f.asset_file_id === removed.asset_file_id + ? { ...f, asset_file_id: undefined } + : f + ); + } + + try { + await saveBook(ctx.tenantId, docId, book); + } catch (e) { + return NextResponse.json( + { error: e instanceof Error ? e.message : 'Error' }, + { status: 500 } + ); + } + + return NextResponse.json({ + ok: true, + brandbook: book, + completeness: brandbookCompleteness(book), + }); +} diff --git a/src/components/BrandZone.tsx b/src/components/BrandZone.tsx index fc63948..fec8bb3 100644 --- a/src/components/BrandZone.tsx +++ b/src/components/BrandZone.tsx @@ -8,30 +8,48 @@ import { Sparkles, Upload, CheckCircle2, + Plus, + Trash2, + ImageIcon, + Type, + Palette, } from 'lucide-react'; import { PageHeader, Surface, SectionTitle } from '@/components/ui'; import { + ASSET_KIND_LABELS, BRANDBOOK_SECTIONS, emptyBrandbook, linesToList, listToLines, + newId, + normalizeHex, + primaryLogo, + type BrandAssetKind, type Brandbook, type BrandbookSectionId, + type ColorToken, + type FontToken, } from '@/lib/brandbook'; function Field({ label, hint, children, + className = '', }: { label: string; hint?: string; children: React.ReactNode; + className?: string; }) { return ( -