523 lines
20 KiB
TypeScript
523 lines
20 KiB
TypeScript
import { useState, useRef, useEffect, type ComponentType } from 'react';
|
|
import { motion } from 'motion/react';
|
|
import { ArrowRight } from 'lucide-react';
|
|
import {
|
|
YoutubeFilled,
|
|
InstagramFilled,
|
|
FacebookFilled,
|
|
GlobeFilled,
|
|
VideoFilled,
|
|
MessageFilled,
|
|
CheckFilled,
|
|
CrossFilled,
|
|
WarningFilled,
|
|
} from '../icons/FilledIcons';
|
|
import { SectionWrapper } from '../report/ui/SectionWrapper';
|
|
import type { BrandGuide, ColorSwatch } from '../../types/plan';
|
|
import type { BrandInconsistency } from '../../types/report';
|
|
|
|
interface BrandingGuideProps {
|
|
data: BrandGuide;
|
|
}
|
|
|
|
const tabItems = [
|
|
{ key: 'visual', label: 'Visual Identity', labelKr: '비주얼 아이덴티티' },
|
|
{ key: 'tone', label: 'Tone & Voice', labelKr: '톤 & 보이스' },
|
|
{ key: 'channels', label: 'Channel Rules', labelKr: '채널별 규칙' },
|
|
{ key: 'consistency', label: 'Brand Consistency', labelKr: '브랜드 일관성' },
|
|
] as const;
|
|
|
|
type TabKey = (typeof tabItems)[number]['key'];
|
|
|
|
const channelIconMap: Record<string, ComponentType<{ size?: number; className?: string }>> = {
|
|
youtube: YoutubeFilled,
|
|
instagram: InstagramFilled,
|
|
facebook: FacebookFilled,
|
|
globe: GlobeFilled,
|
|
video: VideoFilled,
|
|
messagesquare: MessageFilled,
|
|
};
|
|
|
|
function getChannelIcon(icon: string) {
|
|
return channelIconMap[icon.toLowerCase()] ?? GlobeFilled;
|
|
}
|
|
|
|
const statusColor: Record<string, string> = {
|
|
correct: 'bg-[#F3F0FF] text-[#4A3A7C] border-[#D5CDF5]',
|
|
incorrect: 'bg-[#FFF0F0] text-[#7C3A4B] border-[#F5D5DC]',
|
|
missing: 'bg-slate-100 text-slate-500 border-slate-200',
|
|
};
|
|
|
|
const statusLabel: Record<string, string> = {
|
|
correct: 'Correct',
|
|
incorrect: 'Incorrect',
|
|
missing: 'Missing',
|
|
};
|
|
|
|
/* ─── Color Swatch Editor Popover ─── */
|
|
function ColorSwatchCard({ swatch, onUpdate }: { swatch: ColorSwatch; onUpdate: (newHex: string) => void }) {
|
|
const [open, setOpen] = useState(false);
|
|
const [hexInput, setHexInput] = useState(swatch.hex);
|
|
const popoverRef = useRef<HTMLDivElement>(null);
|
|
const colorInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Close on outside click
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
function handleClick(e: MouseEvent) {
|
|
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
|
setOpen(false);
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', handleClick);
|
|
return () => document.removeEventListener('mousedown', handleClick);
|
|
}, [open]);
|
|
|
|
// Sync hex input when swatch changes externally
|
|
useEffect(() => { setHexInput(swatch.hex); }, [swatch.hex]);
|
|
|
|
const applyHex = (value: string) => {
|
|
const normalized = value.startsWith('#') ? value : `#${value}`;
|
|
if (/^#[0-9A-Fa-f]{6}$/.test(normalized)) {
|
|
onUpdate(normalized.toUpperCase());
|
|
setHexInput(normalized.toUpperCase());
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div ref={popoverRef} className="relative rounded-2xl border border-slate-100 overflow-visible shadow-[3px_4px_12px_rgba(0,0,0,0.06)]">
|
|
{/* Swatch — click to open */}
|
|
<button
|
|
className="w-full h-20 rounded-t-2xl cursor-pointer group relative overflow-hidden"
|
|
style={{ backgroundColor: swatch.hex }}
|
|
onClick={() => { setOpen((p) => !p); setHexInput(swatch.hex); }}
|
|
title="색상 편집"
|
|
>
|
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors flex items-center justify-center">
|
|
<svg className="w-5 h-5 text-white opacity-0 group-hover:opacity-80 transition-opacity drop-shadow" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536M9 11l6-6 3 3-6 6H9v-3z" />
|
|
</svg>
|
|
</div>
|
|
</button>
|
|
|
|
<div className="p-3">
|
|
<p className="font-mono text-sm text-slate-700">{swatch.hex}</p>
|
|
<p className="font-medium text-sm text-[#0A1128]">{swatch.name}</p>
|
|
<p className="text-xs text-slate-500">{swatch.usage}</p>
|
|
</div>
|
|
|
|
{/* Edit Popover */}
|
|
{open && (
|
|
<div className="absolute top-full left-0 mt-2 z-50 bg-white rounded-2xl shadow-[4px_6px_16px_rgba(0,0,0,0.12)] border border-slate-100 p-4 w-52">
|
|
{/* Native color wheel */}
|
|
<div className="flex items-center gap-3 mb-3">
|
|
<input
|
|
ref={colorInputRef}
|
|
type="color"
|
|
value={swatch.hex}
|
|
onChange={(e) => { onUpdate(e.target.value.toUpperCase()); setHexInput(e.target.value.toUpperCase()); }}
|
|
className="w-10 h-10 rounded-xl border border-slate-200 cursor-pointer p-0.5"
|
|
/>
|
|
<span className="text-xs text-slate-500">색상 선택</span>
|
|
</div>
|
|
|
|
{/* Hex input */}
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs font-mono text-slate-400">#</span>
|
|
<input
|
|
type="text"
|
|
value={hexInput.replace('#', '')}
|
|
onChange={(e) => setHexInput(`#${e.target.value}`)}
|
|
onBlur={(e) => applyHex(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') applyHex(hexInput); }}
|
|
maxLength={6}
|
|
placeholder="6C5CE7"
|
|
className="flex-1 font-mono text-sm border border-slate-200 rounded-lg px-2 py-1.5 text-[#0A1128] focus:outline-none focus:ring-2 focus:ring-[#6C5CE7]/30 focus:border-[#6C5CE7]"
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={() => setOpen(false)}
|
|
className="mt-3 w-full text-xs py-1.5 rounded-xl bg-[#F3F0FF] text-[#4A3A7C] font-medium hover:bg-[#EBE6FF] transition-colors"
|
|
>
|
|
완료
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ─── Visual Identity Tab ─── */
|
|
function VisualIdentityTab({ data }: { data: BrandGuide }) {
|
|
const [colors, setColors] = useState<ColorSwatch[]>(data.colors);
|
|
|
|
const handleColorUpdate = (idx: number, newHex: string) => {
|
|
setColors((prev) => prev.map((c, i) => i === idx ? { ...c, hex: newHex } : c));
|
|
};
|
|
|
|
return (
|
|
<motion.div
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.4 }}
|
|
className="space-y-8"
|
|
>
|
|
{/* Color Palette */}
|
|
<div>
|
|
<h3 className="font-serif font-bold text-2xl text-[#0A1128] mb-4">Color Palette</h3>
|
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
|
{colors.map((swatch: ColorSwatch, idx: number) => (
|
|
<ColorSwatchCard
|
|
key={swatch.hex + idx}
|
|
swatch={swatch}
|
|
onUpdate={(newHex) => handleColorUpdate(idx, newHex)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Typography */}
|
|
<div>
|
|
<h3 className="font-serif font-bold text-2xl text-[#0A1128] mb-4">Typography</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
{data.fonts.map((spec) => (
|
|
<div
|
|
key={`${spec.family}-${spec.weight}`}
|
|
className="rounded-2xl border border-slate-100 p-5"
|
|
>
|
|
<p className="text-sm text-slate-500 uppercase tracking-wide mb-2">
|
|
{spec.family}
|
|
</p>
|
|
<p
|
|
className={`mb-3 text-[#0A1128] ${
|
|
spec.weight.toLowerCase().includes('bold')
|
|
? 'text-2xl font-bold'
|
|
: 'text-lg'
|
|
}`}
|
|
style={{ fontFamily: spec.family }}
|
|
>
|
|
{spec.sampleText}
|
|
</p>
|
|
<p className="text-xs text-slate-500">
|
|
<span className="font-medium text-slate-700">{spec.weight}</span> ·{' '}
|
|
{spec.usage}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Logo Rules — DO / DON'T split columns */}
|
|
<div>
|
|
<h3 className="font-serif font-bold text-2xl text-[#0A1128] mb-4">Logo Rules</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
{/* DO Column */}
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<CheckFilled size={18} className="text-[#9B8AD4]" />
|
|
<span className="font-semibold text-[#4A3A7C] text-sm uppercase tracking-widest">DO</span>
|
|
</div>
|
|
<div className="space-y-3">
|
|
{data.logoRules.filter((r) => r.correct).map((rule) => (
|
|
<div
|
|
key={rule.rule}
|
|
className="rounded-2xl p-4 border-2 border-[#D5CDF5] bg-[#F3F0FF]/40"
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<CheckFilled size={16} className="text-[#9B8AD4] shrink-0 mt-0.5" />
|
|
<div>
|
|
<p className="font-semibold text-[#0A1128] text-sm">{rule.rule}</p>
|
|
<p className="text-xs text-slate-600 mt-1">{rule.description}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* DON'T Column */}
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<CrossFilled size={18} className="text-[#D4889A]" />
|
|
<span className="font-semibold text-[#7C3A4B] text-sm uppercase tracking-widest">DON'T</span>
|
|
</div>
|
|
<div className="space-y-3">
|
|
{data.logoRules.filter((r) => !r.correct).map((rule) => (
|
|
<div
|
|
key={rule.rule}
|
|
className="rounded-2xl p-4 border-2 border-[#F5D5DC] bg-[#FFF0F0]/40"
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<CrossFilled size={16} className="text-[#D4889A] shrink-0 mt-0.5" />
|
|
<div>
|
|
<p className="font-semibold text-[#0A1128] text-sm">{rule.rule}</p>
|
|
<p className="text-xs text-slate-600 mt-1">{rule.description}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
/* ─── Tone & Voice Tab ─── */
|
|
function ToneVoiceTab({ tone }: { tone: BrandGuide['toneOfVoice'] }) {
|
|
return (
|
|
<motion.div
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.4 }}
|
|
className="space-y-6"
|
|
>
|
|
{/* Personality */}
|
|
<div>
|
|
<h3 className="font-serif font-bold text-2xl text-[#0A1128] mb-4">Personality</h3>
|
|
<div className="flex flex-wrap gap-2">
|
|
{tone.personality.map((trait) => (
|
|
<span
|
|
key={trait}
|
|
className="bg-gradient-to-r from-[#4F1DA1]/10 to-[#021341]/10 text-[#4F1DA1] border border-purple-200 rounded-full px-4 py-2 font-medium text-sm"
|
|
>
|
|
{trait}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Communication Style */}
|
|
<div>
|
|
<h3 className="font-serif font-bold text-2xl text-[#0A1128] mb-4">Communication Style</h3>
|
|
<div className="rounded-2xl bg-slate-50 p-6">
|
|
<p className="text-base leading-relaxed text-slate-700">
|
|
{tone.communicationStyle}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* DO / DON'T */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div>
|
|
<h4 className="font-semibold text-[#4A3A7C] mb-3 flex items-center gap-2">
|
|
<CheckFilled size={16} className="text-[#9B8AD4]" /> DO
|
|
</h4>
|
|
<div className="space-y-3">
|
|
{tone.doExamples.map((example, i) => (
|
|
<div
|
|
key={i}
|
|
className="border-l-4 border-[#9B8AD4] bg-[#F3F0FF]/30 p-4 rounded-r-lg"
|
|
>
|
|
<p className="text-sm text-slate-700">{example}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<h4 className="font-semibold text-[#7C3A4B] mb-3 flex items-center gap-2">
|
|
<CrossFilled size={16} className="text-[#D4889A]" /> DON'T
|
|
</h4>
|
|
<div className="space-y-3">
|
|
{tone.dontExamples.map((example, i) => (
|
|
<div
|
|
key={i}
|
|
className="border-l-4 border-[#D4889A] bg-[#FFF0F0]/30 p-4 rounded-r-lg"
|
|
>
|
|
<p className="text-sm text-slate-700">{example}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
/* ─── Channel Rules Tab ─── */
|
|
function ChannelRulesTab({ channels }: { channels: BrandGuide['channelBranding'] }) {
|
|
return (
|
|
<motion.div
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.4 }}
|
|
>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{channels.map((ch) => {
|
|
const Icon = getChannelIcon(ch.icon);
|
|
return (
|
|
<div key={ch.channel} className="rounded-2xl border border-slate-100 p-5">
|
|
{/* Header */}
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-[#6C5CE7]/10 to-[#4F1DA1]/10 flex items-center justify-center">
|
|
<Icon size={18} className="text-[#6C5CE7]" />
|
|
</div>
|
|
<p className="font-bold text-[#0A1128]">{ch.channel}</p>
|
|
<span
|
|
className={`ml-auto text-xs font-medium px-3 py-1 rounded-full border ${
|
|
statusColor[ch.currentStatus]
|
|
}`}
|
|
>
|
|
{statusLabel[ch.currentStatus]}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Specs */}
|
|
<div className="space-y-3 text-sm">
|
|
<div>
|
|
<p className="text-slate-500 text-xs uppercase tracking-wide mb-1">Profile Photo</p>
|
|
<p className="text-slate-700 font-medium">{ch.profilePhoto}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-slate-500 text-xs uppercase tracking-wide mb-1">Banner Spec</p>
|
|
<p className="text-slate-700 font-medium">{ch.bannerSpec}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-slate-500 text-xs uppercase tracking-wide mb-1">Bio Template</p>
|
|
<div className="bg-slate-50 rounded-xl p-3">
|
|
<p className="font-mono text-xs text-slate-600 whitespace-pre-wrap">
|
|
{ch.bioTemplate}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
/* ─── Brand Consistency Tab (accordion) ─── */
|
|
function BrandConsistencyTab({ inconsistencies }: { inconsistencies: BrandInconsistency[] }) {
|
|
const [expanded, setExpanded] = useState<number | null>(0);
|
|
|
|
return (
|
|
<motion.div
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.4 }}
|
|
>
|
|
<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 */}
|
|
<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-[#0A1128] 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 */}
|
|
{expanded === i && (
|
|
<div className="px-5 pb-5 border-t border-slate-100">
|
|
<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 ? (
|
|
<CheckFilled size={15} className="text-[#9B8AD4]" />
|
|
) : (
|
|
<CrossFilled 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 flex items-center gap-1">
|
|
<WarningFilled size={12} className="text-[#D4A872]" />
|
|
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 flex items-center gap-1">
|
|
<CheckFilled size={12} className="text-[#9B8AD4]" />
|
|
Recommendation
|
|
</p>
|
|
<p className="text-sm text-[#4A3A7C]">{item.recommendation}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
/* ─── Main Component ─── */
|
|
export default function BrandingGuide({ data }: BrandingGuideProps) {
|
|
const [activeTab, setActiveTab] = useState<TabKey>('visual');
|
|
|
|
return (
|
|
<SectionWrapper
|
|
id="branding-guide"
|
|
title="Branding Guide"
|
|
subtitle="브랜딩 가이드 빌드"
|
|
>
|
|
{/* Tabs */}
|
|
<div className="flex flex-wrap gap-2 mb-8">
|
|
{tabItems.map((tab) => (
|
|
<button
|
|
key={tab.key}
|
|
onClick={() => setActiveTab(tab.key)}
|
|
className={`rounded-full px-4 py-2 text-sm font-medium transition-all ${
|
|
activeTab === tab.key
|
|
? 'bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white shadow-lg'
|
|
: 'bg-white border border-slate-200 text-slate-600 hover:bg-slate-50'
|
|
}`}
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{activeTab === 'visual' && <VisualIdentityTab data={data} />}
|
|
{activeTab === 'tone' && <ToneVoiceTab tone={data.toneOfVoice} />}
|
|
{activeTab === 'channels' && <ChannelRulesTab channels={data.channelBranding} />}
|
|
{activeTab === 'consistency' && (
|
|
<BrandConsistencyTab inconsistencies={data.brandInconsistencies} />
|
|
)}
|
|
</SectionWrapper>
|
|
);
|
|
}
|