fix: eliminar footer rls en login y habilitar subida y sincronizacion de logo de cliente
This commit is contained in:
@@ -12,18 +12,30 @@ import {
|
|||||||
sanitizeFileName,
|
sanitizeFileName,
|
||||||
serviceSupabase,
|
serviceSupabase,
|
||||||
} from '@/lib/assets';
|
} 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 dynamic = 'force-dynamic';
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
const MAX_LOGO = 2 * 1024 * 1024; // 2 MB
|
const MAX_LOGO = 15 * 1024 * 1024; // 15 MB
|
||||||
const ALLOWED = new Set([
|
const ALLOWED_MIMES = new Set([
|
||||||
'image/png',
|
'image/png',
|
||||||
'image/jpeg',
|
'image/jpeg',
|
||||||
|
'image/jpg',
|
||||||
'image/webp',
|
'image/webp',
|
||||||
'image/svg+xml',
|
'image/svg+xml',
|
||||||
|
'image/svg',
|
||||||
|
'image/x-png',
|
||||||
|
'image/pjpeg',
|
||||||
]);
|
]);
|
||||||
|
const ALLOWED_EXTS = new Set(['png', 'jpg', 'jpeg', 'webp', 'svg']);
|
||||||
|
|
||||||
async function requireAgency() {
|
async function requireAgency() {
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
@@ -64,18 +76,36 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
if (file.size > MAX_LOGO) {
|
if (file.size > MAX_LOGO) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Logo máximo 2 MB' },
|
{ error: 'Logo máximo 15 MB' },
|
||||||
{ status: 400 }
|
{ 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(
|
return NextResponse.json(
|
||||||
{ error: 'Usa PNG, JPG, WEBP o SVG' },
|
{ error: 'Formato no soportado. Usa PNG, JPG, WEBP o SVG.' },
|
||||||
{ status: 400 }
|
{ 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();
|
const bucketOk = await ensureAssetsBucket();
|
||||||
if (!bucketOk.ok) {
|
if (!bucketOk.ok) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -90,15 +120,51 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const safe = sanitizeFileName(file.name || 'logo.png');
|
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 buf = Buffer.from(await file.arrayBuffer());
|
||||||
|
const sha256 = createHash('sha256').update(buf).digest('hex');
|
||||||
|
|
||||||
const { error: upErr } = await sb.storage
|
const { error: upErr } = await sb.storage
|
||||||
.from(ASSETS_BUCKET)
|
.from(ASSETS_BUCKET)
|
||||||
.upload(path, buf, { contentType: mime, upsert: true });
|
.upload(path, buf, { contentType: resolvedMime, upsert: true });
|
||||||
if (upErr) {
|
if (upErr) {
|
||||||
return NextResponse.json({ error: upErr.message }, { status: 500 });
|
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
|
// Store storage path; served via /api/branding/logo
|
||||||
const logoUrl = `storage:${path}`;
|
const logoUrl = `storage:${path}`;
|
||||||
const patch = await fetch(
|
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({
|
return NextResponse.json({
|
||||||
logoUrl,
|
logoUrl,
|
||||||
serveUrl: `/api/branding/logo?tenantId=${encodeURIComponent(tenantId)}`,
|
serveUrl: `/api/branding/logo?tenantId=${encodeURIComponent(tenantId)}`,
|
||||||
|
|||||||
@@ -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({
|
return NextResponse.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
asset,
|
asset,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import React, { useState, Suspense } from 'react';
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { createClient } from '@/lib/supabase/client';
|
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() {
|
function LoginForm() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -172,15 +172,6 @@ export default function LoginPage() {
|
|||||||
>
|
>
|
||||||
<LoginForm />
|
<LoginForm />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
<div className="mt-8 flex items-center justify-center gap-4 text-[11px] text-gray-500">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<ShieldCheck className="w-3.5 h-3.5 text-emerald-500" />
|
|
||||||
<span>Aislamiento RLS multi-tenant</span>
|
|
||||||
</div>
|
|
||||||
<span>·</span>
|
|
||||||
<span>TLS 1.3</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
listToLines,
|
listToLines,
|
||||||
newId,
|
newId,
|
||||||
normalizeHex,
|
normalizeHex,
|
||||||
|
primaryLogo,
|
||||||
type BrandAssetKind,
|
type BrandAssetKind,
|
||||||
type Brandbook,
|
type Brandbook,
|
||||||
type BrandbookSectionId,
|
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);
|
setKitUploading(true);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
try {
|
try {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('file', file);
|
fd.append('file', file);
|
||||||
fd.append('kind', kitKind);
|
fd.append('kind', kind);
|
||||||
fd.append('role', kitRole);
|
fd.append('role', role);
|
||||||
fd.append('label', file.name);
|
fd.append('label', file.name);
|
||||||
const r = await fetch('/api/brandbook/kit', { method: 'POST', body: fd });
|
const r = await fetch('/api/brandbook/kit', { method: 'POST', body: fd });
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
@@ -519,7 +526,7 @@ export default function BrandZone({
|
|||||||
setScore(j.completeness?.score ?? 0);
|
setScore(j.completeness?.score ?? 0);
|
||||||
setMissing(j.completeness?.missing || []);
|
setMissing(j.completeness?.missing || []);
|
||||||
setExists(true);
|
setExists(true);
|
||||||
setOkMsg(`${ASSET_KIND_LABELS[kitKind]} añadido: ${file.name}`);
|
setOkMsg(`${ASSET_KIND_LABELS[kind]} añadido: ${file.name}`);
|
||||||
setSection('visual');
|
setSection('visual');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr(e instanceof Error ? e.message : 'Error kit');
|
setErr(e instanceof Error ? e.message : 'Error kit');
|
||||||
@@ -696,6 +703,77 @@ export default function BrandZone({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Barra rápida de subida de archivos al kit */}
|
||||||
|
<div className="rounded-2xl border border-gray-200 bg-white p-3.5 shadow-2xs">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-bold text-gray-900 shrink-0">
|
||||||
|
<Upload className="w-4 h-4 text-brand-600" />
|
||||||
|
Añadir archivo al kit:
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 flex-1">
|
||||||
|
<select
|
||||||
|
className="rounded-lg border border-gray-300 bg-white px-2.5 py-1.5 text-xs font-semibold text-gray-800 shadow-2xs focus:border-brand-500 focus:outline-hidden"
|
||||||
|
value={kitKind}
|
||||||
|
onChange={(e) => {
|
||||||
|
const k = e.target.value as BrandAssetKind;
|
||||||
|
setKitKind(k);
|
||||||
|
setKitRole(
|
||||||
|
k === 'logo'
|
||||||
|
? 'primary'
|
||||||
|
: k === 'font'
|
||||||
|
? 'body'
|
||||||
|
: k === 'image'
|
||||||
|
? 'moodboard'
|
||||||
|
: 'asset'
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Object.entries(ASSET_KIND_LABELS).map(([k, v]) => (
|
||||||
|
<option key={k} value={k}>
|
||||||
|
{v}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
className="rounded-lg border border-gray-300 bg-white px-2.5 py-1.5 text-xs text-gray-800 shadow-2xs focus:border-brand-500 focus:outline-hidden w-36"
|
||||||
|
placeholder="Rol (ej. primary, dark)"
|
||||||
|
value={kitRole}
|
||||||
|
onChange={(e) => setKitRole(e.target.value)}
|
||||||
|
/>
|
||||||
|
<label className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-brand-600 px-3.5 py-1.5 text-xs font-semibold text-white shadow-2xs hover:bg-brand-700 cursor-pointer disabled:opacity-50">
|
||||||
|
{kitUploading ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
{kitUploading ? 'Subiendo…' : 'Subir archivo'}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
accept={
|
||||||
|
kitKind === 'logo' || kitKind === 'image' || kitKind === 'icon'
|
||||||
|
? 'image/*,.svg,.pdf'
|
||||||
|
: kitKind === 'font'
|
||||||
|
? '.ttf,.otf,.woff,.woff2,.zip'
|
||||||
|
: kitKind === 'video'
|
||||||
|
? 'video/*'
|
||||||
|
: '*/*'
|
||||||
|
}
|
||||||
|
disabled={kitUploading}
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) void uploadKit(f);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<span className="text-[11px] text-gray-500">
|
||||||
|
PNG/SVG para logos · TTF/WOFF para tipografías · PDFs o imágenes (máx. 80 MB)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{err ? (
|
{err ? (
|
||||||
<div className="rounded-xl border border-red-200 bg-red-50 p-3.5 text-xs font-medium text-red-700">
|
<div className="rounded-xl border border-red-200 bg-red-50 p-3.5 text-xs font-medium text-red-700">
|
||||||
{err}
|
{err}
|
||||||
@@ -1394,6 +1472,65 @@ export default function BrandZone({
|
|||||||
</p>
|
</p>
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
|
|
||||||
|
{/* Logotipo oficial de la marca */}
|
||||||
|
<PanelCard
|
||||||
|
eyebrow="Logotipo"
|
||||||
|
title="Logotipo oficial de la marca"
|
||||||
|
description="Sube el archivo oficial (SVG, PNG transparente o JPG) que se usará en cabeceras y creatividades."
|
||||||
|
accent="rose"
|
||||||
|
cols={1}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 p-2">
|
||||||
|
<div className="flex items-center justify-center w-full sm:w-64 h-32 rounded-xl border border-dashed border-gray-300 bg-gray-50/80 p-3 overflow-hidden">
|
||||||
|
{primaryLogo(book) ? (
|
||||||
|
<KitThumb
|
||||||
|
assetFileId={primaryLogo(book)!.asset_file_id}
|
||||||
|
alt="Logo principal"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-center px-4">
|
||||||
|
<p className="text-xs font-medium text-gray-500">
|
||||||
|
Sin logotipo subido todavía
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-gray-400 mt-0.5">
|
||||||
|
SVG vectorial, PNG transparente o JPG
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-3 w-full">
|
||||||
|
<div className="text-xs text-gray-600 space-y-1">
|
||||||
|
<p>
|
||||||
|
<strong className="text-gray-900 font-semibold">Archivo actual:</strong>{' '}
|
||||||
|
{primaryLogo(book)?.label || 'Ninguno'}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-gray-500 leading-relaxed">
|
||||||
|
Se sincroniza automáticamente con el menú del portal, la ficha del cliente y las plantillas de ads/Canva.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-brand-600 px-4 py-2 text-xs font-semibold text-white hover:bg-brand-700 cursor-pointer shadow-2xs disabled:opacity-50">
|
||||||
|
{kitUploading ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
{kitUploading ? 'Subiendo logo…' : primaryLogo(book) ? 'Cambiar logotipo oficial' : 'Subir logotipo oficial'}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
accept="image/*,.svg"
|
||||||
|
disabled={kitUploading}
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) void uploadKit(f, 'logo', 'primary');
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PanelCard>
|
||||||
|
|
||||||
<PanelCard
|
<PanelCard
|
||||||
eyebrow="Estilo"
|
eyebrow="Estilo"
|
||||||
title="Logo, foto y reglas de composición"
|
title="Logo, foto y reglas de composición"
|
||||||
@@ -1494,10 +1631,24 @@ export default function BrandZone({
|
|||||||
cols={1}
|
cols={1}
|
||||||
>
|
>
|
||||||
{book.kit_assets.length === 0 ? (
|
{book.kit_assets.length === 0 ? (
|
||||||
<p className="text-xs text-gray-500">
|
<div className="rounded-xl border border-dashed border-gray-300 bg-gray-50/70 p-6 text-center space-y-2">
|
||||||
Todavía no hay logos ni archivos. Usa «Añadir al kit»
|
<p className="text-xs text-gray-500">
|
||||||
arriba.
|
Todavía no hay logos ni archivos en el kit de marca.
|
||||||
</p>
|
</p>
|
||||||
|
<label className="inline-flex items-center gap-1.5 rounded-lg bg-white border border-gray-300 px-3.5 py-1.5 text-xs font-semibold text-gray-700 shadow-2xs hover:bg-gray-50 cursor-pointer">
|
||||||
|
<Plus className="w-3.5 h-3.5 text-brand-600" />
|
||||||
|
Subir archivo al kit
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) void uploadKit(f);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
<ul className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{book.kit_assets.map((a) => (
|
{book.kit_assets.map((a) => (
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Shield,
|
Shield,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Upload,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
Users,
|
Users,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -162,25 +163,32 @@ export default function ClientFicha({
|
|||||||
setModules(suggestModulesFromIntegrations(integrations));
|
setModules(suggestModulesFromIntegrations(integrations));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [logoUploading, setLogoUploading] = useState(false);
|
||||||
|
|
||||||
async function onLogoFile(file: File | null) {
|
async function onLogoFile(file: File | null) {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
setSaving(true);
|
setLogoUploading(true);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
const fd = new FormData();
|
try {
|
||||||
fd.set('tenantId', tenantId);
|
const fd = new FormData();
|
||||||
fd.set('file', file);
|
fd.set('tenantId', tenantId);
|
||||||
const res = await fetch('/api/admin/tenants/logo', {
|
fd.set('file', file);
|
||||||
method: 'POST',
|
const res = await fetch('/api/admin/tenants/logo', {
|
||||||
body: fd,
|
method: 'POST',
|
||||||
});
|
body: fd,
|
||||||
const data = await res.json();
|
});
|
||||||
setSaving(false);
|
const data = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setErr(data.error || 'Error al subir logo');
|
setErr(data.error || 'Error al subir logo');
|
||||||
return;
|
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() {
|
async function clearLogo() {
|
||||||
@@ -385,12 +393,29 @@ export default function ClientFicha({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-4 rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
|
<section className="space-y-4 rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||||
<div className="flex items-center gap-2 text-sm font-semibold text-gray-900">
|
<div className="flex items-center justify-between">
|
||||||
<ImageIcon className="w-4 h-4 text-brand-600" />
|
<div className="flex items-center gap-2 text-sm font-semibold text-gray-900">
|
||||||
Logo en vista cliente
|
<ImageIcon className="w-4 h-4 text-brand-600" />
|
||||||
|
Logo en vista cliente
|
||||||
|
</div>
|
||||||
|
{logoUrl ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void clearLogo()}
|
||||||
|
className="text-xs text-red-600 hover:underline font-medium"
|
||||||
|
>
|
||||||
|
Quitar logo
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-center h-28 rounded-xl border border-dashed border-gray-300 bg-gray-50 overflow-hidden">
|
|
||||||
{logoSrc ? (
|
<div className="relative flex items-center justify-center h-28 rounded-xl border border-dashed border-gray-300 bg-gray-50 overflow-hidden p-2">
|
||||||
|
{logoUploading ? (
|
||||||
|
<div className="flex flex-col items-center gap-1.5 text-xs text-brand-600 font-medium">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
<span>Subiendo logo…</span>
|
||||||
|
</div>
|
||||||
|
) : logoSrc ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
src={logoSrc}
|
src={logoSrc}
|
||||||
@@ -403,27 +428,29 @@ export default function ClientFicha({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<label className="block">
|
|
||||||
<span className="sr-only">Subir logo</span>
|
<label className="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-brand-600 px-4 py-2.5 text-xs font-semibold text-white shadow-2xs hover:bg-brand-700 cursor-pointer disabled:opacity-50">
|
||||||
|
{logoUploading ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
{logoUploading ? 'Subiendo logo…' : logoUrl ? 'Cambiar logotipo' : 'Subir logotipo'}
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
accept="image/*,.svg"
|
||||||
className="block w-full text-xs text-gray-600 file:mr-3 file:rounded-lg file:border-0 file:bg-brand-50 file:px-3 file:py-1.5 file:text-xs file:font-semibold file:text-brand-700"
|
disabled={logoUploading}
|
||||||
onChange={(e) => void onLogoFile(e.target.files?.[0] || null)}
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0] || null;
|
||||||
|
void onLogoFile(f);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{logoUrl ? (
|
|
||||||
<button
|
<p className="text-[11px] text-gray-500 leading-relaxed">
|
||||||
type="button"
|
PNG, JPG, SVG o WEBP · máx. 15 MB. Aparece automáticamente en el menú lateral y en el kit visual del cliente.
|
||||||
onClick={() => void clearLogo()}
|
|
||||||
className="text-xs text-red-600 hover:underline"
|
|
||||||
>
|
|
||||||
Quitar logo
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
<p className="text-[11px] text-gray-600 leading-relaxed">
|
|
||||||
PNG/JPG/WEBP/SVG · máx. 2 MB. Aparece en el menú lateral cuando el
|
|
||||||
cliente (o tú en su contexto) abre el portal.
|
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user