365 lines
14 KiB
TypeScript
365 lines
14 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 type {
|
|
FacebookAudit as FacebookAuditType,
|
|
FacebookPage,
|
|
BrandInconsistency,
|
|
DiagnosisItem,
|
|
} from '../../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-[#EFF0FF] text-[#3A3F7C]';
|
|
const isLogoMismatch = page.logo?.includes('불일치');
|
|
const isLowFollowers = page.followers < 500;
|
|
|
|
return (
|
|
<motion.div
|
|
className={`rounded-2xl border shadow-sm p-6 ${
|
|
isKR && isLowFollowers
|
|
? 'bg-[#FFF0F0]/30 border-[#F5D5DC]/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-[#FFF0F0] text-[#7C3A4B]">
|
|
방치 상태
|
|
</span>
|
|
)}
|
|
{page.hasWhatsApp && (
|
|
<span className="text-xs font-medium px-3 py-1 rounded-full bg-[#F3F0FF] text-[#4A3A7C]">
|
|
WhatsApp 연결
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<h3 className="font-bold text-lg text-[#0A1128] 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-[#6C5CE7] inline-flex items-center gap-1"
|
|
>
|
|
{page.pageName}
|
|
<ExternalLink size={13} className="text-[#6C5CE7]" />
|
|
</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-[#7C3A4B]' : 'text-[#0A1128]'}`}>
|
|
{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-[#7C3A4B]' : 'text-[#0A1128]'}`}>
|
|
{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-[#0A1128]">{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-[#0A1128]">{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-[#0A1128]">{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-[#0A1128] 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-[#7C3A4B]' : 'text-[#0A1128]'
|
|
}`}>{page.engagement}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Logo analysis - the enhanced version */}
|
|
<div className={`rounded-xl p-4 mb-4 ${
|
|
isLogoMismatch
|
|
? 'bg-[#FFF0F0] border border-[#F5D5DC]'
|
|
: 'bg-[#F3F0FF] border border-[#D5CDF5]'
|
|
}`}>
|
|
<div className="flex items-start gap-2 mb-2">
|
|
{isLogoMismatch
|
|
? <AlertTriangle size={16} className="text-[#7C3A4B] shrink-0 mt-1" />
|
|
: <CheckCircle2 size={16} className="text-[#9B8AD4] shrink-0 mt-1" />
|
|
}
|
|
<p className={`text-sm font-semibold ${isLogoMismatch ? 'text-[#7C3A4B]' : 'text-[#4A3A7C]'}`}>
|
|
로고 {page.logo}
|
|
</p>
|
|
</div>
|
|
<p className={`text-xs leading-relaxed ml-6 ${isLogoMismatch ? 'text-[#7C3A4B]' : 'text-[#4A3A7C]'}`}>
|
|
{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-[#7C5C3A]' : '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-[#0A1128] 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
|
|
onClick={() => setExpanded(expanded === i ? null : i)}
|
|
className="w-full flex items-center justify-between p-5 text-left hover:bg-slate-50/50 transition-colors"
|
|
>
|
|
<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-[#0A1128]">{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-[#F3F0FF]/60' : 'bg-[#FFF0F0]/60'
|
|
}`}
|
|
>
|
|
<span className="font-medium text-slate-700 min-w-[100px]">{v.channel}</span>
|
|
<span className={`flex-1 text-right ${v.isCorrect ? 'text-[#4A3A7C]' : 'text-[#7C3A4B]'}`}>
|
|
{v.value}
|
|
</span>
|
|
<span className="ml-3">
|
|
{v.isCorrect
|
|
? <CheckCircle2 size={15} className="text-[#9B8AD4]" />
|
|
: <XCircle size={15} className="text-[#D4889A]" />
|
|
}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Impact */}
|
|
<div className="rounded-xl bg-[#FFF6ED] border border-[#F5E0C5] p-4 mb-3">
|
|
<p className="text-xs font-semibold text-[#7C5C3A] uppercase tracking-wide mb-1">
|
|
<AlertCircle size={12} className="inline mr-1" />
|
|
Impact
|
|
</p>
|
|
<p className="text-sm text-[#7C5C3A]">{item.impact}</p>
|
|
</div>
|
|
|
|
{/* Recommendation */}
|
|
<div className="rounded-xl bg-[#F3F0FF] border border-[#D5CDF5] p-4">
|
|
<p className="text-xs font-semibold text-[#4A3A7C] uppercase tracking-wide mb-1">
|
|
<CheckCircle2 size={12} className="inline mr-1" />
|
|
Recommendation
|
|
</p>
|
|
<p className="text-sm text-[#4A3A7C]">{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-[#0A1128] 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-[#0A1128]">{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-[#4F1DA1] to-[#021341] 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>
|
|
);
|
|
}
|