o2o-infinith-demo/src/components/plan/BrandingGuide.tsx

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> &middot;{' '}
{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&apos;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&apos;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>
);
}