o2o-infinith-frontend/src/features/report/components/FacebookAudit.tsx

368 lines
15 KiB
TypeScript

import { useState } from 'react';
import { motion } from 'motion/react';
import {
Facebook, AlertCircle, AlertTriangle, ExternalLink, CheckCircle2, XCircle,
ArrowRight, Link2, MessageCircle, TrendingUp, Eye, ImageIcon, Globe,
} from 'lucide-react';
import { SectionWrapper } from './ui/SectionWrapper';
import { SeverityBadge } from './ui/SeverityBadge';
import { EvidenceGallery } from './ui/EvidenceGallery';
import { Button } from '@/shared/ui/button';
import type {
FacebookAudit as FacebookAuditType,
FacebookPage,
BrandInconsistency,
DiagnosisItem,
} from '@/features/report/types/report';
function formatNumber(n: number): string {
return n.toLocaleString();
}
/* ─── Page Card ─── */
function PageCard({ page, index }: { key?: string | number; page: FacebookPage; index: number }) {
const isKR = page.language === 'KR';
const langColor = isKR ? 'bg-slate-100 text-slate-700' : 'bg-brand-tint-violet text-brand-purple-faint';
const isLogoMismatch = page.logo?.includes('불일치');
const isLowFollowers = page.followers < 500;
return (
<motion.div
className={`rounded-2xl border shadow-sm p-6 ${
isKR && isLowFollowers
? 'bg-brand-rose-bg/30 border-brand-rose-soft/60'
: 'bg-white border-slate-100'
}`}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.15 }}
>
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<span className={`text-xs font-medium px-3 py-1 rounded-full ${langColor}`}>
{page.label}
</span>
<div className="w-8 h-8 rounded-lg bg-[#1877F2] flex items-center justify-center">
<Facebook size={16} className="text-white" />
</div>
</div>
{isKR && isLowFollowers && (
<span className="text-xs font-medium px-3 py-1 rounded-full bg-brand-rose-bg text-brand-rose">
</span>
)}
{page.hasWhatsApp && (
<span className="text-xs font-medium px-3 py-1 rounded-full bg-brand-tint-purple text-brand-purple-muted">
WhatsApp
</span>
)}
</div>
<h3 className="font-bold text-lg text-brand-navy mb-1">
{page.url ? (
<a
href={
page.url.startsWith('http')
? page.url
: page.url.startsWith('facebook.com/') || page.url.startsWith('www.facebook.com/')
? `https://${page.url.replace(/^www\./, 'www.')}`
: `https://www.facebook.com/${page.url.replace(/^@/, '')}`
}
target="_blank"
rel="noopener noreferrer"
className="hover:text-brand-purple-vivid inline-flex items-center gap-1"
>
{page.pageName}
<ExternalLink size={13} className="text-brand-purple-vivid" />
</a>
) : page.pageName}
</h3>
<p className="text-xs text-slate-500 mb-4">{page.category}</p>
{/* Metrics grid */}
<div className="grid grid-cols-3 gap-2 mb-5">
<div className="rounded-xl bg-slate-50 p-3 text-center">
<p className="text-xs text-slate-400 uppercase tracking-wide"></p>
<p className={`text-lg font-bold ${isLowFollowers ? 'text-brand-rose' : 'text-brand-navy'}`}>
{formatNumber(page.followers)}
</p>
</div>
<div className="rounded-xl bg-slate-50 p-3 text-center">
<p className="text-xs text-slate-400 uppercase tracking-wide"></p>
<p className={`text-lg font-bold ${page.reviews === 0 ? 'text-brand-rose' : 'text-brand-navy'}`}>
{page.reviews}
</p>
</div>
<div className="rounded-xl bg-slate-50 p-3 text-center">
<p className="text-xs text-slate-400 uppercase tracking-wide"></p>
<p className="text-lg font-bold text-brand-navy">{formatNumber(page.following)}</p>
</div>
</div>
{/* Detail rows */}
<div className="space-y-3 mb-5 text-sm">
<div className="flex items-center justify-between py-1 border-b border-slate-100 last:border-0">
<span className="text-slate-500 flex items-center gap-2"><Eye size={13} /> </span>
<span className="font-medium text-brand-navy">{page.recentPostAge}</span>
</div>
{page.postFrequency && (
<div className="flex items-center justify-between py-1 border-b border-slate-100 last:border-0">
<span className="text-slate-500 flex items-center gap-2"><TrendingUp size={13} /> </span>
<span className="font-medium text-brand-navy">{page.postFrequency}</span>
</div>
)}
{page.topContentType && (
<div className="flex items-center justify-between py-1 border-b border-slate-100 last:border-0">
<span className="text-slate-500 flex items-center gap-2"><ImageIcon size={13} /> </span>
<span className="font-medium text-brand-navy text-right text-xs max-w-[180px]">{page.topContentType}</span>
</div>
)}
{page.engagement && (
<div className="flex items-center justify-between py-1 border-b border-slate-100 last:border-0">
<span className="text-slate-500 flex items-center gap-2"><MessageCircle size={13} /> </span>
<span className={`font-medium text-right text-xs max-w-[180px] ${
page.engagement.includes('0~3') ? 'text-brand-rose' : 'text-brand-navy'
}`}>{page.engagement}</span>
</div>
)}
</div>
{/* Logo analysis - the enhanced version */}
<div className={`rounded-xl p-4 mb-4 ${
isLogoMismatch
? 'bg-brand-rose-bg border border-brand-rose-soft'
: 'bg-brand-tint-purple border border-brand-tint-lavender'
}`}>
<div className="flex items-start gap-2 mb-2">
{isLogoMismatch
? <AlertTriangle size={16} className="text-brand-rose shrink-0 mt-1" />
: <CheckCircle2 size={16} className="text-brand-purple-soft shrink-0 mt-1" />
}
<p className={`text-sm font-semibold ${isLogoMismatch ? 'text-brand-rose' : 'text-brand-purple-muted'}`}>
{page.logo}
</p>
</div>
<p className={`text-xs leading-relaxed ml-6 ${isLogoMismatch ? 'text-brand-rose' : 'text-brand-purple-muted'}`}>
{page.logoDescription}
</p>
</div>
{/* Domain link */}
<div className="rounded-xl bg-slate-50 p-3 mb-4">
<div className="flex items-center gap-2 mb-1">
<Link2 size={12} className="text-slate-400" />
<p className="text-xs text-slate-400 uppercase tracking-wide"> </p>
</div>
<p className={`text-sm font-mono ${
page.linkedDomain?.includes('다름') ? 'text-brand-earth' : 'text-slate-700'
}`}>
{page.linkedDomain || page.link}
</p>
</div>
{/* Bio */}
<div className="rounded-xl bg-slate-50 p-3 mb-4">
<p className="text-xs text-slate-400 uppercase tracking-wide mb-1">Bio</p>
<p className="text-sm text-slate-600 italic">"{page.bio}"</p>
</div>
{/* Link moved to page name header */}
</motion.div>
);
}
/* ─── Brand Inconsistency Map ─── */
function BrandConsistencyMap({ inconsistencies }: { inconsistencies: BrandInconsistency[] }) {
const [expanded, setExpanded] = useState<number | null>(0);
return (
<motion.div
className="mt-8"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.3 }}
>
<h3 className="font-serif font-bold text-xl text-brand-navy mb-2">Brand Consistency Map</h3>
<p className="text-sm text-slate-500 mb-5"> </p>
<div className="space-y-3">
{inconsistencies.map((item, i) => (
<div
key={item.field}
className="rounded-2xl border border-slate-100 bg-white shadow-sm overflow-hidden"
>
{/* Header - clickable */}
<Button
type="button"
variant="ghost"
onClick={() => setExpanded(expanded === i ? null : i)}
className="w-full flex items-center justify-between p-5 h-auto text-left hover:bg-slate-50/50 rounded-none"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-primary-900 flex items-center justify-center text-white text-xs font-bold">
{item.values.filter(v => !v.isCorrect).length}
</div>
<div>
<p className="font-semibold text-brand-navy">{item.field}</p>
<p className="text-xs text-slate-500">
{item.values.filter(v => !v.isCorrect).length}
</p>
</div>
</div>
<ArrowRight
size={16}
className={`text-slate-400 transition-transform ${expanded === i ? 'rotate-90' : ''}`}
/>
</Button>
{/* Expanded content */}
{expanded === i && (
<div className="px-5 pb-5 border-t border-slate-100">
{/* Channel values */}
<div className="grid gap-2 mt-4 mb-4">
{item.values.map((v) => (
<div
key={v.channel}
className={`flex items-center justify-between py-3 px-3 rounded-lg text-sm ${
v.isCorrect ? 'bg-brand-tint-purple/60' : 'bg-brand-rose-bg/60'
}`}
>
<span className="font-medium text-slate-700 min-w-[100px]">{v.channel}</span>
<span className={`flex-1 text-right ${v.isCorrect ? 'text-brand-purple-muted' : 'text-brand-rose'}`}>
{v.value}
</span>
<span className="ml-3">
{v.isCorrect
? <CheckCircle2 size={15} className="text-brand-purple-soft" />
: <XCircle size={15} className="text-brand-rose-mid" />
}
</span>
</div>
))}
</div>
{/* Impact */}
<div className="rounded-xl bg-brand-earth-bg border border-brand-earth-soft p-4 mb-3">
<p className="text-xs font-semibold text-brand-earth uppercase tracking-wide mb-1">
<AlertCircle size={12} className="inline mr-1" />
Impact
</p>
<p className="text-sm text-brand-earth">{item.impact}</p>
</div>
{/* Recommendation */}
<div className="rounded-xl bg-brand-tint-purple border border-brand-tint-lavender p-4">
<p className="text-xs font-semibold text-brand-purple-muted uppercase tracking-wide mb-1">
<CheckCircle2 size={12} className="inline mr-1" />
Recommendation
</p>
<p className="text-sm text-brand-purple-muted">{item.recommendation}</p>
</div>
</div>
)}
</div>
))}
</div>
</motion.div>
);
}
/* ─── Diagnosis Section ─── */
function DiagnosisSection({ items }: { items: DiagnosisItem[] }) {
return (
<motion.div
className="mt-8"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.4 }}
>
<h3 className="font-serif font-bold text-xl text-brand-navy mb-2"> </h3>
<p className="text-sm text-slate-500 mb-4">Facebook </p>
<div className="rounded-2xl border border-slate-100 bg-white shadow-sm overflow-hidden">
{items.map((item, i) => (
<div
key={i}
className={`p-5 ${
i < items.length - 1 ? 'border-b border-slate-100' : ''
}`}
>
<div className="flex items-start gap-4">
<div className="min-w-[120px] md:min-w-[160px]">
<p className="font-semibold text-sm text-brand-navy">{item.category}</p>
</div>
<p className="flex-1 text-sm text-slate-600">{item.detail}</p>
<div className="shrink-0">
<SeverityBadge severity={item.severity} />
</div>
</div>
{item.evidenceIds && item.evidenceIds.length > 0 && (
<EvidenceGallery evidenceIds={item.evidenceIds} compact />
)}
</div>
))}
</div>
</motion.div>
);
}
/* ─── Consolidation Recommendation ─── */
function ConsolidationCard({ text }: { text: string }) {
return (
<motion.div
className="mt-8 rounded-2xl bg-gradient-to-r from-brand-purple to-brand-purple-deep p-6 md:p-8 text-white"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.5 }}
>
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-xl bg-white/10 flex items-center justify-center shrink-0">
<Globe size={20} className="text-white" />
</div>
<div>
<h4 className="font-serif font-bold text-2xl mb-2"> </h4>
<p className="text-sm text-purple-200 leading-relaxed">{text}</p>
</div>
</div>
</motion.div>
);
}
/* ─── Main Component ─── */
export default function FacebookAudit({ data }: { data: FacebookAuditType }) {
const hasData = data.pages.length > 0 || data.diagnosis.length > 0;
if (!hasData) return null;
return (
<SectionWrapper id="facebook-audit" title="Facebook Analysis" subtitle="페이스북 페이지 분석">
{/* Page cards side by side */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{data.pages.map((page, i) => (
<PageCard key={page.pageName} page={page} index={i} />
))}
</div>
{/* Brand Consistency Map - the new enhanced section */}
{data.brandInconsistencies && data.brandInconsistencies.length > 0 && (
<BrandConsistencyMap inconsistencies={data.brandInconsistencies} />
)}
{/* Diagnosis table */}
{data.diagnosis && data.diagnosis.length > 0 && (
<DiagnosisSection items={data.diagnosis} />
)}
{/* Consolidation recommendation */}
{data.consolidationRecommendation && (
<ConsolidationCard text={data.consolidationRecommendation} />
)}
</SectionWrapper>
);
}