Files
4ventures-client-portal/src/components/BrandOverview.tsx

589 lines
21 KiB
TypeScript

'use client';
import React, { useEffect, useMemo, useState } from 'react';
import {
ArrowRight,
CheckCircle2,
ExternalLink,
ImageIcon,
Palette,
Type,
} from 'lucide-react';
import { Surface } from '@/components/ui';
import {
primaryLogo,
type Brandbook,
type BrandbookSectionId,
} from '@/lib/brandbook';
function LogoPreview({
assetFileId,
alt,
}: {
assetFileId: string;
alt: string;
}) {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
const r = await fetch(
`/api/assets/download?id=${encodeURIComponent(assetFileId)}`
);
const j = await r.json();
if (!cancelled && r.ok && j.url) setUrl(j.url);
})();
return () => {
cancelled = true;
};
}, [assetFileId]);
if (!url) {
return (
<div className="flex h-16 w-full max-w-xs items-center justify-center rounded-xl border border-dashed border-gray-300 bg-gray-50 text-xs text-gray-400">
Cargando logo…
</div>
);
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={url}
alt={alt}
className="max-h-16 w-auto max-w-full object-contain"
/>
);
}
function PillRow({
title,
items,
emptyHint,
accent = 'brand',
}: {
title: string;
items: string[];
emptyHint: string;
accent?: 'brand' | 'sky' | 'violet' | 'amber';
}) {
const tone =
accent === 'sky'
? 'border-sky-200 bg-sky-50 text-sky-900'
: accent === 'violet'
? 'border-violet-200 bg-violet-50 text-violet-900'
: accent === 'amber'
? 'border-amber-200 bg-amber-50 text-amber-900'
: 'border-brand-200 bg-brand-50 text-brand-900';
return (
<div className="space-y-2">
<p className="text-[11px] font-bold uppercase tracking-[0.14em] text-gray-500">
{title}
</p>
{items.length ? (
<div className="flex flex-wrap gap-2">
{items.map((item) => (
<span
key={item}
className={`inline-flex rounded-full border px-3 py-1 text-xs font-semibold ${tone}`}
>
{item}
</span>
))}
</div>
) : (
<p className="text-xs text-gray-400 italic">{emptyHint}</p>
)}
</div>
);
}
function websiteFromBook(book: Brandbook): string | null {
const candidates = [
book.website,
book.channel_handles?.blog,
book.channels?.blog,
];
for (const c of candidates) {
const raw = String(c || '').trim();
if (!raw) continue;
if (/^https?:\/\//i.test(raw)) return raw;
if (raw.includes('.') && !raw.startsWith('@')) {
return `https://${raw.replace(/^\/+/, '')}`;
}
}
return null;
}
export default function BrandOverview({
book,
tenantLabel,
score,
missing,
onEditSection,
onContinueEdit,
embedded = false,
}: {
book: Brandbook;
tenantLabel: string;
score: number;
missing: string[];
onEditSection: (id: BrandbookSectionId) => void;
onContinueEdit: () => void;
/** Cuando va dentro del panel de Zona de marca (sin card exterior). */
embedded?: boolean;
}) {
const logo = primaryLogo(book);
const site = websiteFromBook(book);
const fonts = book.fonts_kit.slice(0, 4);
const colors = book.colors.slice(0, 8);
const values = book.values.filter(Boolean).slice(0, 8);
const aesthetic = [
...book.personality,
book.photography_style,
book.illustration_style,
book.visual_notes,
]
.flatMap((x) =>
String(x || '')
.split(/[,;·|/]/)
.map((s) => s.trim())
.filter(Boolean)
)
.filter((v, i, arr) => arr.indexOf(v) === i)
.slice(0, 8);
const tonePills = [
book.tone,
...book.voice_do.slice(0, 3),
...book.personality.slice(0, 2),
]
.map((s) => String(s || '').trim())
.filter(Boolean)
.filter((v, i, arr) => arr.indexOf(v) === i)
.slice(0, 6);
const overviewText = useMemo(() => {
return (
book.elevator_pitch ||
book.brand_promise ||
book.positioning ||
book.mission ||
''
).trim();
}, [book]);
const header = (
<div
className={
embedded
? 'flex flex-wrap items-start justify-between gap-3 pb-2'
: 'border-b border-gray-200 bg-gradient-to-br from-gray-50 via-white to-brand-50/40 px-5 py-4 sm:px-6'
}
>
<div className={embedded ? '' : undefined}>
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-brand-600">
Brand Overview
</p>
<h2
className={`mt-1 font-extrabold tracking-tight text-gray-900 ${
embedded ? 'text-base' : 'text-xl sm:text-2xl'
}`}
>
Perfil de marca
</h2>
{!embedded ? (
<p className="mt-1 text-sm text-gray-600">
Resumen visual del brandbook · editable por secciones
</p>
) : null}
</div>
<button
type="button"
onClick={onContinueEdit}
className="inline-flex items-center gap-1.5 rounded-xl border border-gray-200 bg-white px-3 py-2 text-xs font-bold text-gray-800 hover:bg-gray-50"
>
Editar identidad
<ArrowRight className="h-3.5 w-3.5" />
</button>
</div>
);
const content = (
<div className={embedded ? 'space-y-8' : 'space-y-8 px-5 py-6 sm:px-6'}>
{/* Identity */}
<section className="grid gap-6 lg:grid-cols-[1.2fr_1fr]">
<div className="space-y-3">
<p className="text-[11px] font-bold uppercase tracking-[0.14em] text-gray-500">
Identidad
</p>
<h3 className="text-2xl font-extrabold text-gray-900 tracking-tight">
{book.brand_name || tenantLabel}
</h3>
{site ? (
<a
href={site}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-sm font-medium text-brand-700 hover:underline"
>
{site.replace(/^https?:\/\//i, '')}
<ExternalLink className="h-3.5 w-3.5" />
</a>
) : (
<button
type="button"
onClick={() => onEditSection('canales')}
className="text-sm text-gray-400 hover:text-brand-700"
>
Añadir web en Canales →
</button>
)}
<div className="mt-4 flex min-h-[88px] items-center justify-center rounded-2xl border border-gray-200 bg-white px-6 py-5 shadow-sm">
{logo ? (
<LogoPreview
assetFileId={logo.asset_file_id}
alt={logo.label || book.brand_name || 'Logo'}
/>
) : (
<button
type="button"
onClick={() => onEditSection('visual')}
className="flex flex-col items-center gap-2 text-gray-400 hover:text-brand-700"
>
<ImageIcon className="h-8 w-8" />
<span className="text-xs font-semibold">Subir logo</span>
</button>
)}
</div>
</div>
<div className="space-y-5">
<div className="space-y-2">
<p className="text-[11px] font-bold uppercase tracking-[0.14em] text-gray-500 inline-flex items-center gap-1.5">
<Type className="h-3.5 w-3.5" /> Tipografías
</p>
{fonts.length ? (
<div className="grid grid-cols-2 gap-2">
{fonts.map((f) => (
<div
key={f.id}
className="rounded-xl border border-gray-200 bg-gray-50 px-3 py-3"
>
<p
className="text-2xl font-semibold text-gray-900 leading-none"
style={{ fontFamily: `"${f.family}", sans-serif` }}
>
Aa
</p>
<p className="mt-2 text-[11px] font-semibold text-gray-700 truncate">
{f.family}
</p>
<p className="text-[10px] text-gray-500 capitalize">
{f.role}
</p>
</div>
))}
</div>
) : (
<button
type="button"
onClick={() => onEditSection('visual')}
className="w-full rounded-xl border border-dashed border-gray-300 bg-gray-50 px-3 py-6 text-xs text-gray-500 hover:border-brand-300 hover:text-brand-700"
>
Añadir tipografías en Visual
</button>
)}
</div>
<div className="space-y-2">
<p className="text-[11px] font-bold uppercase tracking-[0.14em] text-gray-500 inline-flex items-center gap-1.5">
<Palette className="h-3.5 w-3.5" /> Colores
</p>
{colors.length ? (
<div className="flex flex-wrap gap-3">
{colors.map((c) => (
<div key={c.id} className="flex flex-col items-center gap-1.5">
<span
className="h-11 w-11 rounded-full border border-black/10 shadow-sm"
style={{ background: c.hex }}
title={`${c.name} ${c.hex}`}
/>
<span className="text-[10px] font-mono text-gray-600">
{c.hex}
</span>
</div>
))}
</div>
) : (
<button
type="button"
onClick={() => onEditSection('visual')}
className="w-full rounded-xl border border-dashed border-gray-300 bg-gray-50 px-3 py-4 text-xs text-gray-500 hover:border-brand-300"
>
Definir paleta en Visual
</button>
)}
</div>
</div>
</section>
{/* Tagline */}
<section className="rounded-2xl border border-gray-200 bg-gradient-to-r from-brand-50/80 via-white to-sky-50/60 px-5 py-6">
<p className="text-[11px] font-bold uppercase tracking-[0.14em] text-gray-500">
Tagline
</p>
{book.tagline ? (
<p className="mt-2 text-xl sm:text-2xl font-semibold text-brand-800 leading-snug">
{book.tagline}
</p>
) : (
<button
type="button"
onClick={() => onEditSection('identidad')}
className="mt-2 text-sm text-gray-400 italic hover:text-brand-700"
>
Añadir tagline en Identidad →
</button>
)}
</section>
<div className="grid gap-6 lg:grid-cols-3">
<PillRow
title="Valores de marca"
items={values}
emptyHint="Completa valores en Identidad"
accent="brand"
/>
<PillRow
title="Estética / personalidad"
items={aesthetic}
emptyHint="Personalidad + notas visuales"
accent="violet"
/>
<PillRow
title="Tono de voz"
items={tonePills}
emptyHint="Define tono en Voz y tono"
accent="sky"
/>
</div>
{/* Business overview */}
<section className="space-y-2">
<p className="text-[11px] font-bold uppercase tracking-[0.14em] text-gray-500">
Business overview
</p>
<div className="rounded-2xl border border-gray-200 bg-white px-4 py-4 text-sm leading-relaxed text-gray-700">
{overviewText ? (
overviewText
) : (
<button
type="button"
onClick={() => onEditSection('identidad')}
className="text-gray-400 italic hover:text-brand-700"
>
Escribe el elevator pitch o la promesa de marca →
</button>
)}
</div>
</section>
{/* Copy real ads / RRSS */}
<section className="space-y-3">
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] font-bold uppercase tracking-[0.14em] text-gray-500">
Copy real (Meta / Google / RRSS)
</p>
<button
type="button"
onClick={() => onEditSection('ads')}
className="text-[11px] font-semibold text-brand-700 hover:underline"
>
Ver en Ads →
</button>
</div>
{(book.winning_hooks || []).length ||
(book.ads_usps || []).length ||
(book.ads_ctas || []).length ? (
<div className="space-y-3 rounded-2xl border border-amber-200 bg-amber-50/40 px-4 py-4">
{(book.ads_ctas || []).length ? (
<p className="text-xs text-gray-700">
<span className="font-semibold">CTAs:</span>{' '}
{(book.ads_ctas || []).slice(0, 6).join(' · ')}
</p>
) : null}
{(book.ads_usps || []).length ? (
<PillRow
title="USPs en anuncios"
items={(book.ads_usps || []).slice(0, 6)}
emptyHint=""
accent="amber"
/>
) : null}
{(book.winning_hooks || []).length ? (
<ul className="space-y-1.5 text-xs text-gray-700 list-disc pl-4">
{(book.winning_hooks || []).slice(0, 5).map((h) => (
<li key={h.slice(0, 40)} className="leading-snug">
{h}
</li>
))}
</ul>
) : null}
</div>
) : (
<button
type="button"
onClick={() => onEditSection('ads')}
className="w-full rounded-xl border border-dashed border-gray-300 bg-gray-50 px-3 py-4 text-xs text-gray-500 hover:border-brand-300"
>
Sin copy de ads aún · Auto-rellenar marca o conectar Meta/Google
</button>
)}
</section>
{/* LinkedIn B2B & Thought Leadership */}
<section className="space-y-3">
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] font-bold uppercase tracking-[0.14em] text-gray-500">
Estrategia LinkedIn B2B & Thought Leadership
</p>
<button
type="button"
onClick={() => onEditSection('b2b')}
className="text-[11px] font-semibold text-indigo-700 hover:underline"
>
Ver y editar en 9. LinkedIn B2B →
</button>
</div>
{(book.linkedin_themes || []).length ||
(book.thought_leadership || []).length ||
(book.case_study_angles || []).length ? (
<div className="space-y-3 rounded-2xl border border-indigo-200 bg-indigo-50/40 px-5 py-4">
{(book.linkedin_themes || []).length ? (
<PillRow
title="Temas prioritarios LinkedIn"
items={(book.linkedin_themes || []).slice(0, 5)}
emptyHint=""
accent="sky"
/>
) : null}
{(book.thought_leadership || []).length ? (
<div className="space-y-1.5">
<p className="text-[11px] font-bold uppercase tracking-[0.14em] text-gray-500">
Posicionamiento de Autoridad / Thought Leadership
</p>
<ul className="space-y-1.5 text-xs text-gray-700 list-disc pl-4">
{(book.thought_leadership || []).slice(0, 3).map((tl, i) => (
<li key={i} className="leading-snug">
{tl}
</li>
))}
</ul>
</div>
) : null}
{(book.buyer_roles || []).length ? (
<div className="space-y-1">
<p className="text-[11px] font-bold uppercase tracking-[0.14em] text-gray-500">
Buyer Roles B2B
</p>
<p className="text-xs text-gray-700 font-medium">
{(book.buyer_roles || []).slice(0, 4).join(' · ')}
</p>
</div>
) : null}
{(book.case_study_angles || []).length ? (
<div className="space-y-1.5">
<p className="text-[11px] font-bold uppercase tracking-[0.14em] text-gray-500">
Casos de estudio B2B observados
</p>
<ul className="space-y-1 text-xs text-gray-700 list-disc pl-4">
{(book.case_study_angles || []).slice(0, 3).map((cs, i) => (
<li key={i} className="leading-snug">
{cs}
</li>
))}
</ul>
</div>
) : null}
{(book.linkedin_cta || []).length ? (
<p className="text-xs text-gray-700 pt-1 border-t border-indigo-100">
<span className="font-semibold text-indigo-900">CTAs de baja fricción:</span>{' '}
{(book.linkedin_cta || []).slice(0, 3).join(' · ')}
</p>
) : null}
</div>
) : (
<button
type="button"
onClick={() => onEditSection('b2b')}
className="w-full rounded-xl border border-dashed border-gray-300 bg-gray-50 px-3 py-4 text-xs text-gray-500 hover:border-brand-300"
>
Sin estrategia LinkedIn B2B configurada · Configurar en 9. LinkedIn B2B →
</button>
)}
</section>
</div>
);
const footer = (
<div
className={`flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between ${
embedded
? 'border-t border-gray-100 pt-4'
: 'border-t border-gray-200 bg-gray-50 px-5 py-4 sm:px-6'
}`}
>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<p className="text-xs font-semibold text-gray-800">
{score}% completo
</p>
{score >= 100 ? (
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-emerald-700">
<CheckCircle2 className="h-3.5 w-3.5" /> Kit sólido
</span>
) : missing[0] ? (
<p className="truncate text-[11px] text-amber-700">
Falta: {missing.slice(0, 2).join(' · ')}
</p>
) : null}
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-gray-200">
<div
className="h-full rounded-full bg-brand-500 transition-all"
style={{ width: `${Math.min(100, Math.max(0, score))}%` }}
/>
</div>
</div>
<button
type="button"
onClick={onContinueEdit}
className="inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-brand-600 px-5 py-2.5 text-sm font-bold text-white hover:bg-brand-700"
>
{score >= 80 ? 'Seguir editando' : 'Completar marca'}
<ArrowRight className="h-4 w-4" />
</button>
</div>
);
if (embedded) {
return (
<div className="space-y-6">
{header}
{content}
{footer}
</div>
);
}
return (
<div className="space-y-4">
<Surface className="!p-0 overflow-hidden border-gray-200">
{header}
{content}
{footer}
</Surface>
</div>
);
}