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,
|
||||
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)}`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
>
|
||||
<LoginForm />
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
</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 ? (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 p-3.5 text-xs font-medium text-red-700">
|
||||
{err}
|
||||
@@ -1394,6 +1472,65 @@ export default function BrandZone({
|
||||
</p>
|
||||
</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
|
||||
eyebrow="Estilo"
|
||||
title="Logo, foto y reglas de composición"
|
||||
@@ -1494,10 +1631,24 @@ export default function BrandZone({
|
||||
cols={1}
|
||||
>
|
||||
{book.kit_assets.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-gray-300 bg-gray-50/70 p-6 text-center space-y-2">
|
||||
<p className="text-xs text-gray-500">
|
||||
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.
|
||||
</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">
|
||||
{book.kit_assets.map((a) => (
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Shield,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Upload,
|
||||
UserPlus,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
@@ -162,10 +163,13 @@ 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);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.set('tenantId', tenantId);
|
||||
fd.set('file', file);
|
||||
@@ -174,13 +178,17 @@ export default function ClientFicha({
|
||||
body: fd,
|
||||
});
|
||||
const data = await res.json();
|
||||
setSaving(false);
|
||||
if (!res.ok) {
|
||||
setErr(data.error || 'Error al subir logo');
|
||||
return;
|
||||
}
|
||||
setLogoUrl(data.logoUrl);
|
||||
setMsg('Logo actualizado — se muestra en la vista del cliente');
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearLogo() {
|
||||
@@ -385,12 +393,29 @@ export default function ClientFicha({
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-gray-900">
|
||||
<ImageIcon className="w-4 h-4 text-brand-600" />
|
||||
Logo en vista cliente
|
||||
</div>
|
||||
<div className="flex items-center justify-center h-28 rounded-xl border border-dashed border-gray-300 bg-gray-50 overflow-hidden">
|
||||
{logoSrc ? (
|
||||
{logoUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void clearLogo()}
|
||||
className="text-xs text-red-600 hover:underline font-medium"
|
||||
>
|
||||
Quitar logo
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<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
|
||||
<img
|
||||
src={logoSrc}
|
||||
@@ -403,27 +428,29 @@ export default function ClientFicha({
|
||||
</p>
|
||||
)}
|
||||
</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
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||
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"
|
||||
onChange={(e) => void onLogoFile(e.target.files?.[0] || null)}
|
||||
accept="image/*,.svg"
|
||||
disabled={logoUploading}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0] || null;
|
||||
void onLogoFile(f);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{logoUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
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 className="text-[11px] text-gray-500 leading-relaxed">
|
||||
PNG, JPG, SVG o WEBP · máx. 15 MB. Aparece automáticamente en el menú lateral y en el kit visual del cliente.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user