Merge pull request '[feat] plan 페이지 생성' (#4) from feature-plan into master

Reviewed-on: #4
pull/5/head
minheon 2026-04-01 08:25:09 +00:00
commit c29541a365
59 changed files with 2687 additions and 49 deletions

View File

@ -19,5 +19,14 @@ export default defineConfig([
ecmaVersion: 2020, ecmaVersion: 2020,
globals: globals.browser, globals: globals.browser,
}, },
rules: {
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
},
}, },
]) ])

View File

@ -5,6 +5,7 @@ import MainSubNavLayout from "@/layouts/MainSubNavLayout";
// pages // pages
import { Home } from "@/pages/Home"; import { Home } from "@/pages/Home";
import { PlanPage } from "@/pages/Plan";
import { ReportPage } from "@/pages/Report"; import { ReportPage } from "@/pages/Report";
function App() { function App() {
@ -16,6 +17,7 @@ function App() {
</Route> </Route>
<Route element={<MainSubNavLayout />}> <Route element={<MainSubNavLayout />}>
<Route path="report/:id" element={<ReportPage />} /> <Route path="report/:id" element={<ReportPage />} />
<Route path="plan/:id" element={<PlanPage />} />
</Route> </Route>
</Routes> </Routes>
) )

View File

@ -130,12 +130,27 @@
/* ─── Utility Classes ─────────────────────────────────────────────── */ /* ─── Utility Classes ─────────────────────────────────────────────── */
/* 라이트 섹션 헤딩 그라디언트 텍스트 */ /*
- base h2 { color: navy } color
- background clip background-image */
.text-gradient { .text-gradient {
background: linear-gradient(to right, var(--color-navy-900), #3b5998); color: transparent;
background-image: linear-gradient(to right, var(--color-navy-900), #3b5998);
-webkit-background-clip: text; -webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text; background-clip: text;
-webkit-text-fill-color: transparent;
}
/*
* (h2) ·:
* WebKit -webkit-text-fill-color text-* .
* currentColor (Tailwind) color .
*/
.solid-text-paint {
-webkit-text-fill-color: currentColor !important;
/* bg-* 와 같은 요소에 clip:text가 남으면 글리프가 배경으로만 채워져 비어 보일 수 있음 */
-webkit-background-clip: border-box !important;
background-clip: border-box !important;
} }
/* 반투명 글래스 카드 — 랜딩 섹션 */ /* 반투명 글래스 카드 — 랜딩 섹션 */

View File

@ -0,0 +1,33 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { UI_PRIMARY_GRADIENT_CLASS } from "@/components/atoms/uiTokens";
export type ButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "type"> & {
children: ReactNode;
className?: string;
variant?: "primary" | "outline";
};
export function Button({
children,
className = "",
variant = "outline",
...rest
}: ButtonProps) {
return (
<button
type="button"
className={[
"rounded-full transition-all cursor-pointer break-keep solid-text-paint",
variant === "primary"
? `${UI_PRIMARY_GRADIENT_CLASS} text-white shadow-md`
: "bg-white border border-neutral-30 text-neutral-70 hover:bg-neutral-10",
className,
]
.filter(Boolean)
.join(" ")}
{...rest}
>
{children}
</button>
);
}

View File

@ -0,0 +1,37 @@
import type { HTMLAttributes, ReactNode } from "react";
export type PillProps = Omit<HTMLAttributes<HTMLSpanElement>, "className"> & {
children: ReactNode;
className?: string;
size?: "sm" | "md";
weight?: "medium" | "semibold" | "none";
inlineFlex?: boolean;
};
export function Pill({
children,
className = "",
size = "md",
weight = "medium",
inlineFlex = false,
...rest
}: PillProps) {
return (
<span
className={[
"rounded-full break-keep",
inlineFlex && "inline-flex items-center",
size === "sm" ? "px-2 py-1" : "px-3 py-1",
weight === "semibold" ? "font-semibold" : weight === "medium" ? "font-medium" : "",
"label-12",
className,
]
.filter(Boolean)
.join(" ")}
{...rest}
>
{/* 배경·테두리는 바깥 span, WebKit 글자 채움은 안쪽만 solid-text-paint */}
<span className="solid-text-paint">{children}</span>
</span>
);
}

View File

@ -0,0 +1,60 @@
import type { HTMLAttributes, ReactNode } from "react";
const paddingMap = {
none: "",
sm: "p-4",
md: "p-5",
lg: "p-6",
} as const;
export type SurfaceProps = Omit<HTMLAttributes<HTMLDivElement>, "className"> & {
children: ReactNode;
className?: string;
padding?: keyof typeof paddingMap;
radius?: "xl" | "2xl";
bordered?: boolean;
surface?: "white" | "none";
shadow?: boolean;
interactive?: boolean | "lift";
overflowHidden?: boolean;
};
export function Surface({
children,
className = "",
padding = "md",
radius = "2xl",
bordered = true,
surface = "white",
shadow = true,
interactive = false,
overflowHidden = false,
...rest
}: SurfaceProps) {
const interactiveClass =
interactive === "lift"
? "hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)] transition-shadow"
: interactive
? "hover:shadow-md transition-shadow"
: "";
return (
<div
className={[
radius === "xl" ? "rounded-xl" : "rounded-2xl",
bordered && "border border-neutral-20",
surface === "white" && "bg-white",
shadow && "card-shadow",
paddingMap[padding],
interactiveClass,
overflowHidden && "overflow-hidden",
className,
]
.filter(Boolean)
.join(" ")}
{...rest}
>
{children}
</div>
);
}

View File

@ -0,0 +1,4 @@
export { Button, type ButtonProps } from "@/components/atoms/Button";
export { Pill, type PillProps } from "@/components/atoms/Pill";
export { Surface, type SurfaceProps } from "@/components/atoms/Surface";
export { UI_PRIMARY_GRADIENT_CLASS } from "@/components/atoms/uiTokens";

View File

@ -0,0 +1,2 @@
/** 앱 전역에서 쓸 수 있는 브랜드 그라데이션 배경 클래스 (버튼·블록 배경 등) */
export const UI_PRIMARY_GRADIENT_CLASS = "bg-gradient-to-r from-violet-700 to-navy-950";

View File

@ -8,17 +8,29 @@ import type { BrandInconsistency } from "@/types/brandConsistency";
export type BrandConsistencyMapProps = { export type BrandConsistencyMapProps = {
inconsistencies: BrandInconsistency[]; inconsistencies: BrandInconsistency[];
className?: string; className?: string;
/** false면 상단 제목·설명을 숨김 (플랜 브랜딩 가이드 탭 등) */
showHeading?: boolean;
}; };
export function BrandConsistencyMap({ inconsistencies, className = "" }: BrandConsistencyMapProps) { export function BrandConsistencyMap({
inconsistencies,
className = "",
showHeading = true,
}: BrandConsistencyMapProps) {
const [expanded, setExpanded] = useState<number | null>(0); const [expanded, setExpanded] = useState<number | null>(0);
if (inconsistencies.length === 0) return null; if (inconsistencies.length === 0) return null;
return ( return (
<div className={`mt-8 animate-fade-in-up animation-delay-200 ${className}`.trim()}> <div
className={`${showHeading ? "mt-8 animate-fade-in-up animation-delay-200 " : ""}${className}`.trim()}
>
{showHeading ? (
<>
<h3 className="font-serif headline-20 text-navy-900 mb-2 break-keep">Brand Consistency Map</h3> <h3 className="font-serif headline-20 text-navy-900 mb-2 break-keep">Brand Consistency Map</h3>
<p className="body-14 text-neutral-60 mb-5 break-keep"> </p> <p className="body-14 text-neutral-60 mb-5 break-keep"> </p>
</>
) : null}
<div className="space-y-3"> <div className="space-y-3">
{inconsistencies.map((item, i) => { {inconsistencies.map((item, i) => {

View File

@ -14,3 +14,12 @@ export { HighlightPanel, type HighlightPanelProps } from "@/components/panel/Hig
export { ScoreRing, type ScoreRingProps } from "@/components/rating/ScoreRing"; export { ScoreRing, type ScoreRingProps } from "@/components/rating/ScoreRing";
export { StarRatingDisplay, type StarRatingDisplayProps } from "@/components/rating/StarRatingDisplay"; export { StarRatingDisplay, type StarRatingDisplayProps } from "@/components/rating/StarRatingDisplay";
export { PageSection, type PageSectionProps } from "@/components/section/PageSection"; export { PageSection, type PageSectionProps } from "@/components/section/PageSection";
export {
Button,
Pill,
Surface,
UI_PRIMARY_GRADIENT_CLASS,
type ButtonProps,
type PillProps,
type SurfaceProps,
} from "@/components/atoms";

View File

@ -6,6 +6,8 @@ export type PageSectionProps = {
subtitle?: string; subtitle?: string;
dark?: boolean; dark?: boolean;
className?: string; className?: string;
/** false면 섹션 입장용 `animate-fade-in-up` 미적용 (DEMO에 없는 모션을 쓰지 않을 때) */
animateEnter?: boolean;
children: ReactNode; children: ReactNode;
}; };
@ -15,13 +17,15 @@ export function PageSection({
subtitle, subtitle,
dark = false, dark = false,
className = "", className = "",
animateEnter = true,
children, children,
}: PageSectionProps) { }: PageSectionProps) {
return ( return (
<section <section
id={id} id={id}
className={[ className={[
"py-16 md:py-20 px-6 scroll-mt-36 animate-fade-in-up", "py-16 md:py-20 px-6 scroll-mt-36",
animateEnter ? "animate-fade-in-up" : "",
dark ? "bg-navy-900 text-white relative overflow-hidden" : "bg-white", dark ? "bg-navy-900 text-white relative overflow-hidden" : "bg-white",
className, className,
] ]
@ -34,19 +38,30 @@ export function PageSection({
aria-hidden aria-hidden
/> />
) : null} ) : null}
<div className="relative max-w-7xl mx-auto"> <div
className={[
"relative max-w-7xl mx-auto",
dark ? "text-white" : "text-neutral-80",
].join(" ")}
>
<header className="mb-10"> <header className="mb-10">
<h2 <h2
className={ className={
dark dark
? "font-serif text-3xl md:display-36 font-bold mb-3 bg-gradient-to-r from-lavender-200 to-marketing-ice bg-clip-text text-transparent" ? "font-serif text-3xl md:display-36 font-bold mb-3 bg-gradient-to-r from-lavender-200 to-marketing-ice bg-clip-text text-transparent"
: "font-serif text-3xl md:display-36 font-bold mb-3 text-gradient" : "font-serif text-3xl md:display-36 font-bold mb-3 text-transparent text-gradient"
} }
> >
{title} {title}
</h2> </h2>
{subtitle ? ( {subtitle ? (
<p className={dark ? "body-18 text-lavender-200 break-keep" : "body-18 text-neutral-70 break-keep"}> <p
className={
dark
? "body-18 text-lavender-200 break-keep solid-text-paint"
: "body-18 text-neutral-70 break-keep solid-text-paint"
}
>
{subtitle} {subtitle}
</p> </p>
) : null} ) : null}

View File

@ -0,0 +1,272 @@
import type { MarketingPlan } from "@/features/plan/types/marketingPlan";
export const MOCK_PLAN: MarketingPlan = {
id: 'view-clinic',
reportId: 'view-clinic',
clinicName: '뷰성형외과의원',
clinicNameEn: 'VIEW Plastic Surgery',
createdAt: '2026-03-22',
targetUrl: 'https://www.viewclinic.com',
// ─── Section 1: Brand Guide ───
brandGuide: {
colors: [
{ name: 'VIEW Purple', hex: '#7B2D8E', usage: '공식 로고 메인 컬러, 깃털 아이콘, 브랜드 텍스트' },
{ name: 'VIEW Gold', hex: '#E8B931', usage: '깃털 악센트, 강조 요소, CTA 포인트' },
{ name: 'VIEW Text Purple', hex: '#6B2D7B', usage: '한글/영문 브랜드명, 헤딩 텍스트' },
{ name: 'Warm White', hex: '#FAF8F5', usage: '배경, 카드, 여백 공간' },
{ name: 'Deep Charcoal', hex: '#2D2D2D', usage: '본문 텍스트, 서브 텍스트' },
],
fonts: [
{ family: 'Pretendard', weight: 'Bold 700', usage: '헤딩, 섹션 타이틀, CTA 버튼', sampleText: '안전이 예술이 되는 곳' },
{ family: 'Pretendard', weight: 'Regular 400', usage: '본문 텍스트, 설명, 캡션', sampleText: '21년 무사고 VIEW 성형외과' },
{ family: 'Playfair Display', weight: 'Bold 700', usage: '영문 헤딩, 프리미엄 강조', sampleText: 'VIEW Plastic Surgery' },
],
logoRules: [
{ rule: '보라색+골드 깃털 로고 통일 사용', description: '공식 깃털 심볼(보라색+골드) + VIEW 텍스트를 모든 채널에서 동일하게 사용', correct: true },
{ rule: '원형 로고: 보라색 테두리 버전', description: '프로필 사진용 원형 버전은 보라색 원 테두리 안에 깃털 심볼 + VIEW 텍스트 배치', correct: true },
{ rule: '가로형 로고: 깃털 + 텍스트 조합', description: '배너, 헤더에는 깃털 심볼 옆에 View Plastic Surgery 텍스트를 가로 배치', correct: true },
{ rule: '모델 사진 프로필 금지', description: '프로필 사진에 모델/환자 사진 대신 반드시 공식 깃털 로고 사용 (Instagram KR 위반 중)', correct: false },
{ rule: '비공식 변형 로고 사용 금지', description: 'YouTube의 VIEW 골드 텍스트 전용 로고는 비공식 — 깃털 심볼이 반드시 포함되어야 함', correct: false },
{ rule: '로고 주변 여백 확보', description: '로고 크기의 50% 이상 여백을 유지하여 가독성 확보', correct: true },
],
toneOfVoice: {
personality: ['차분한 전문가', '신뢰감 있는', '과장 없는', '환자 중심', '결과로 증명하는'],
communicationStyle: '환자의 불안과 고민을 이해하고, 전문적인 판단력으로 신뢰를 구축합니다. 유행을 좇지 않고 원칙을 말하는 병원으로서, 과장된 표현 대신 정확한 정보와 설명으로 설득합니다.',
doExamples: [
'"수술을 권하기 전에, 판단을 설명합니다"',
'"결과가 설명되는 수술"',
'"21년간 안전을 최우선으로"',
'"환자의 관점에서 생각합니다"',
],
dontExamples: [
'"강남 최고! 파격 할인!"',
'"연예인이 선택한 병원"',
'"이 가격은 오늘까지만!"',
'"100% 만족 보장"',
],
},
channelBranding: [
{ channel: 'YouTube', icon: 'youtube', profilePhoto: '보라색+골드 깃털 원형 로고', bannerSpec: '2560x1440px, 퍼플+골드 그라디언트 배경, 깃털 심볼 + "VIEW Plastic Surgery" 슬로건', bioTemplate: '안전이 예술이 되는 곳 — 21년 무사고 VIEW 성형외과\n02-539-1177 | 카톡: @뷰성형외과의원', currentStatus: 'incorrect' },
{ channel: 'Instagram KR', icon: 'instagram', profilePhoto: '보라색+골드 깃털 원형 로고', bannerSpec: 'N/A (하이라이트 커버: 퍼플 톤 아이콘 세트)', bioTemplate: '안전이 예술이 되는 곳 — VIEW 성형외과\n신논현역 3번 출구 | 02-539-1177\nviewclinic.com', currentStatus: 'incorrect' },
{ channel: 'Instagram EN', icon: 'instagram', profilePhoto: '보라색+골드 깃털 원형 로고', bannerSpec: 'N/A', bioTemplate: 'Where Safety Becomes Art — VIEW Plastic Surgery\nGangnam, Seoul | +82-2-539-1177\nviewclinic.com/en', currentStatus: 'incorrect' },
{ channel: 'Facebook KR', icon: 'facebook', profilePhoto: '보라색+골드 깃털 원형 로고', bannerSpec: '820x312px, 퍼플+골드 배너, 깃털 심볼 + 슬로건', bioTemplate: '안전이 예술이 되는 곳 — 21년 무사고 VIEW 성형외과', currentStatus: 'correct' },
{ channel: 'Facebook EN', icon: 'facebook', profilePhoto: '보라색+골드 깃털 원형 로고', bannerSpec: '820x312px, 동일 디자인 시스템', bioTemplate: 'Where Safety Becomes Art — VIEW Plastic Surgery', currentStatus: 'incorrect' },
{ channel: 'Naver Blog', icon: 'globe', profilePhoto: '보라색+골드 깃털 로고', bannerSpec: '블로그 상단: 깃털 심볼 + 대표 이미지', bioTemplate: '21년 무사고 VIEW 성형외과 공식 블로그\n가슴성형·안면윤곽·양악·눈코·리프팅', currentStatus: 'missing' },
{ channel: 'TikTok', icon: 'video', profilePhoto: '보라색+골드 깃털 원형 로고', bannerSpec: 'N/A', bioTemplate: 'VIEW 성형외과 — 안전이 예술이 되는 곳\n강남 신논현역 | 02-539-1177', currentStatus: 'missing' },
],
brandInconsistencies: [
{
field: '로고',
values: [
{ channel: 'YouTube', value: 'VIEW 텍스트 전용 골드 로고 (깃털 심볼 없음)', isCorrect: false },
{ channel: 'Instagram KR', value: '모델 프로필 사진 (로고 아님)', isCorrect: false },
{ channel: 'Instagram EN', value: 'VIEW 텍스트 전용 골드 로고 (깃털 심볼 없음)', isCorrect: false },
{ channel: 'Facebook KR', value: '보라색+골드 깃털 로고 (공식 로고)', isCorrect: true },
{ channel: 'Facebook EN', value: 'VIEW 텍스트 전용 골드 로고 (깃털 심볼 없음)', isCorrect: false },
{ channel: 'Website', value: '보라색+골드 깃털 로고 (공식 로고)', isCorrect: true },
],
impact: '공식 깃털 로고를 사용하는 채널은 Facebook KR과 웹사이트 2곳뿐. 나머지 4개 채널은 비공식 변형 로고 또는 모델 사진을 사용',
recommendation: '전 채널에 보라색+골드 깃털 공식 로고 통일 적용 (원형 버전: 프로필, 가로형 버전: 배너)',
},
{
field: '바이오/소개 메시지',
values: [
{ channel: 'YouTube', value: '💜뷰성형외과💜 VIEW가 예술이다!', isCorrect: false },
{ channel: 'Instagram KR', value: '뷰 성형외과 | 가슴성형·안면윤곽·눈성형', isCorrect: false },
{ channel: 'Facebook KR', value: '예쁨이 일상이 되는 순간!', isCorrect: false },
{ channel: 'Facebook EN', value: 'Official Account by VIEW Partners', isCorrect: false },
],
impact: '4개 채널, 4개의 서로 다른 소개 메시지 → 통일된 브랜드 포지셔닝 부재',
recommendation: '핵심 USP 포함 통일 바이오: "안전이 예술이 되는 곳 — 21년 무사고 VIEW"',
},
],
},
// ─── Section 2: Channel Strategies ───
channelStrategies: [
{
channelId: 'youtube', channelName: 'YouTube', icon: 'youtube',
currentStatus: '103K 구독자, 주 1회 업로드', targetGoal: '200K 구독자, 주 3회 업로드',
contentTypes: ['Shorts', 'Long-form', 'Community'],
postingFrequency: '주 3회 (롱폼 1 + Shorts 2)',
tone: '차분한 전문가 — 원장이 직접 설명하는 교육 콘텐츠',
formatGuidelines: ['Shorts: 15-60초, 세로형, 후크 3초 내', 'Long-form: 5-15분, 원장 설명 + B-roll', '썸네일: VIEW 골드 워터마크 + 통일 폰트'],
priority: 'P0',
},
{
channelId: 'instagram_kr', channelName: 'Instagram KR', icon: 'instagram',
currentStatus: '14K 팔로워, Reels 0개', targetGoal: '50K 팔로워, Reels 주 5개',
contentTypes: ['Reels', 'Carousel', 'Stories', 'Feed Image'],
postingFrequency: '일 1회 + Stories 일 2-3개',
tone: '차분하지만 접근 가능한 — 환자 관점의 Q&A',
formatGuidelines: ['Reels: YouTube Shorts 동시 게시', 'Carousel: 시술 가이드 5-7장', 'Stories: 병원 일상, 상담 비하인드, 투표'],
priority: 'P0',
},
{
channelId: 'instagram_en', channelName: 'Instagram EN', icon: 'instagram',
currentStatus: '68.8K 팔로워, Reels 활발', targetGoal: '100K 팔로워',
contentTypes: ['Reels', 'Before/After', 'Patient Stories'],
postingFrequency: '주 5회',
tone: 'Professional & warm — medical tourism storytelling',
formatGuidelines: ['Patient journey videos (English subtitles)', 'Before/After with consent', 'Korea travel + surgery content'],
priority: 'P1',
},
{
channelId: 'facebook', channelName: 'Facebook', icon: 'facebook',
currentStatus: 'KR 253명 + EN 88K, 로고 불일치', targetGoal: '통합 관리, 광고 리타겟 전용',
contentTypes: ['광고 크리에이티브', '리타겟 콘텐츠'],
postingFrequency: '주 2-3회 (광고 소재 위주)',
tone: '신뢰 기반 — 안전, 경험, 결과 강조',
formatGuidelines: ['KR 페이지 폐쇄 → EN 페이지로 통합', 'Facebook Pixel 리타겟 광고 최적화', '로고 VIEW 골드로 즉시 교체'],
priority: 'P1',
},
{
channelId: 'naver_blog', channelName: 'Naver Blog', icon: 'globe',
currentStatus: '미확인 / 미운영', targetGoal: '월 30,000 방문자',
contentTypes: ['SEO 블로그 포스트', '시술 가이드', '환자 후기'],
postingFrequency: '주 3회',
tone: '정보성 전문가 — 키워드 중심, 환자 고민 해결',
formatGuidelines: ['2,000자 이상 SEO 최적화 포스트', '시술별 FAQ 시리즈', '이미지 10장 이상 + 동영상 임베드'],
priority: 'P0',
},
{
channelId: 'tiktok', channelName: 'TikTok', icon: 'video',
currentStatus: '계정 없음', targetGoal: '10K 팔로워',
contentTypes: ['Shorts 크로스포스팅', '트렌드 챌린지'],
postingFrequency: '주 5회 (YouTube Shorts 동시 배포)',
tone: '가볍고 접근 가능한 — 20~30대 타겟',
formatGuidelines: ['YouTube Shorts 동시 업로드', '트렌딩 사운드 활용', '자막 필수 (음소거 시청 대비)'],
priority: 'P1',
},
{
channelId: 'kakaotalk', channelName: 'KakaoTalk', icon: 'messageSquare',
currentStatus: '상담 전용 운영', targetGoal: '상담 전환율 30% 향상',
contentTypes: ['상담 안내', '이벤트 알림', '예약 확인'],
postingFrequency: '주 1-2회 (메시지 발송)',
tone: '따뜻하고 전문적인 — 1:1 상담 톤',
formatGuidelines: ['자동 응답 + 상담사 연결 시스템', '시술별 상담 시나리오 준비', '예약 리마인더 자동 발송'],
priority: 'P1',
},
],
// ─── Section 3: Content Strategy ───
contentStrategy: {
pillars: [
{ title: '수술 전문성', description: '원장의 경험과 판단력을 보여주는 교육 콘텐츠', relatedUSP: 'Surgical Authority', exampleTopics: ['코성형 Q&A', '가슴보형물 선택 가이드', '양악수술 오해와 진실'], color: '#6C5CE7' },
{ title: '안전 & 신뢰', description: '21년 무사고 이력과 안전 시스템을 증명하는 콘텐츠', relatedUSP: 'Trust & Safety', exampleTopics: ['수술실 CCTV 공개', '마취 전문의 인터뷰', '회복 관리 시스템'], color: '#7A84D4' },
{ title: '결과 예측', description: '자연스러운 결과와 밸런스를 강조하는 비포/애프터', relatedUSP: 'Result Predictability', exampleTopics: ['자연스러운 코 라인', '얼굴 밸런스 분석', '과교정 방지 철학'], color: '#9B8AD4' },
{ title: '환자 여정', description: '상담부터 회복까지의 환자 경험을 보여주는 스토리텔링', relatedUSP: 'Patient Guidance', exampleTopics: ['상담 시뮬레이션', '수술 당일 브이로그', '회복 타임라인'], color: '#D4A872' },
],
typeMatrix: [
{ format: 'YouTube Long-form', channels: ['YouTube'], frequency: '주 1회', purpose: '깊은 신뢰 구축, 전문성 증명' },
{ format: 'Shorts / Reels', channels: ['YouTube', 'Instagram', 'TikTok'], frequency: '주 5회', purpose: '도달 확대, 첫 관심 유도' },
{ format: 'Carousel', channels: ['Instagram KR'], frequency: '주 2회', purpose: '정보 전달, 저장 유도' },
{ format: 'Blog Post', channels: ['Naver Blog'], frequency: '주 3회', purpose: 'SEO 검색 유입, 키워드 확보' },
{ format: 'Stories', channels: ['Instagram KR', 'Instagram EN'], frequency: '일 2-3개', purpose: '일상 소통, 친밀감 형성' },
{ format: 'Ad Creative', channels: ['Facebook', 'Instagram'], frequency: '월 4-8개', purpose: '신규 환자 유입, 리타겟' },
],
workflow: [
{ step: 1, name: '주제 선정', description: '키워드 분석 + 콘텐츠 필러 매칭', owner: '마케팅 매니저', duration: '1일' },
{ step: 2, name: '원고 작성', description: 'AI 초안 생성 + 의료 검수', owner: 'AI + 의료진', duration: '1-2일' },
{ step: 3, name: '비주얼 제작', description: '촬영/영상 편집/디자인', owner: '콘텐츠 팀', duration: '2-3일' },
{ step: 4, name: '검토 & 승인', description: '원장 최종 검토 + 의료광고 규정 체크', owner: '원장 / 법무', duration: '1일' },
{ step: 5, name: '배포 & 모니터링', description: '채널별 최적 시간 게시 + 성과 추적', owner: '마케팅 매니저', duration: '당일' },
],
repurposingSource: '1개 원장 롱폼 영상 (10분)',
repurposingOutputs: [
{ format: 'YouTube Long-form', channel: 'YouTube', description: '원본 풀 영상 업로드' },
{ format: 'Shorts 3-5개', channel: 'YouTube / Instagram / TikTok', description: '핵심 구간 15-60초 클립 추출' },
{ format: 'Carousel 1-2개', channel: 'Instagram KR', description: '영상 내용을 카드뉴스로 재구성' },
{ format: 'Blog Post 1개', channel: 'Naver Blog', description: '영상 스크립트 → SEO 블로그 포스트 변환' },
{ format: 'Stories 3-5개', channel: 'Instagram', description: '비하인드 + 촬영 현장 스니펫' },
{ format: 'Ad Creative 2개', channel: 'Facebook / Instagram', description: '가장 임팩트 있는 장면 + CTA 오버레이' },
],
},
// ─── Section 4: Content Calendar ───
calendar: {
weeks: [
{
weekNumber: 1, label: 'Week 1: 브랜드 정비 & 첫 콘텐츠',
entries: [
{ dayOfWeek: 0, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: '원장 인터뷰: VIEW의 수술 철학' },
{ dayOfWeek: 1, channel: 'Instagram KR', channelIcon: 'instagram', contentType: 'social', title: '프로필 리뉴얼 공지 + 첫 Reel' },
{ dayOfWeek: 2, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 코성형 Q&A #1' },
{ dayOfWeek: 2, channel: 'Naver', channelIcon: 'globe', contentType: 'blog', title: '코성형 가이드: 내 얼굴에 맞는 코' },
{ dayOfWeek: 3, channel: 'Instagram KR', channelIcon: 'instagram', contentType: 'social', title: 'Carousel: 가슴보형물 종류 비교' },
{ dayOfWeek: 4, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 전후 Before/After #1' },
{ dayOfWeek: 4, channel: 'Naver', channelIcon: 'globe', contentType: 'blog', title: '가슴성형 절개 위치별 장단점' },
],
},
{
weekNumber: 2, label: 'Week 2: 콘텐츠 엔진 가동',
entries: [
{ dayOfWeek: 0, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: '원장이 설명하는: 안면윤곽' },
{ dayOfWeek: 0, channel: 'Naver', channelIcon: 'globe', contentType: 'blog', title: '안면윤곽 수술 종류와 회복기간' },
{ dayOfWeek: 1, channel: 'Instagram KR', channelIcon: 'instagram', contentType: 'social', title: 'Reel: 윤곽 전후 변화' },
{ dayOfWeek: 2, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 사각턱 축소 과정' },
{ dayOfWeek: 3, channel: 'Instagram KR', channelIcon: 'instagram', contentType: 'social', title: 'Carousel: 리프팅 시술 비교' },
{ dayOfWeek: 3, channel: 'Facebook', channelIcon: 'facebook', contentType: 'ad', title: '광고: 코성형 상담 유도 (리타겟)' },
{ dayOfWeek: 4, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 눈성형 자연스러운 라인' },
{ dayOfWeek: 4, channel: 'Naver', channelIcon: 'globe', contentType: 'blog', title: '눈성형 쌍꺼풀 수술 FAQ' },
],
},
{
weekNumber: 3, label: 'Week 3: 신뢰 콘텐츠 강화',
entries: [
{ dayOfWeek: 0, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: '원장이 설명하는: 수술 안전 시스템' },
{ dayOfWeek: 1, channel: 'Instagram KR', channelIcon: 'instagram', contentType: 'social', title: 'Reel: 수술실 안전 장비 소개' },
{ dayOfWeek: 1, channel: 'Naver', channelIcon: 'globe', contentType: 'blog', title: '성형외과 선택 시 확인할 안전 기준 5가지' },
{ dayOfWeek: 2, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 마취 전문의가 함께합니다' },
{ dayOfWeek: 3, channel: 'Instagram KR', channelIcon: 'instagram', contentType: 'social', title: 'Carousel: 21년 무사고의 비결' },
{ dayOfWeek: 4, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 상담 전 꼭 알아야 할 것' },
{ dayOfWeek: 4, channel: 'Facebook', channelIcon: 'facebook', contentType: 'ad', title: '광고: 안전 시스템 소개 (신규 유입)' },
],
},
{
weekNumber: 4, label: 'Week 4: 전환 최적화',
entries: [
{ dayOfWeek: 0, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: '원장이 설명하는: 재수술 케이스' },
{ dayOfWeek: 0, channel: 'Naver', channelIcon: 'globe', contentType: 'blog', title: '재수술이 필요한 경우와 주의사항' },
{ dayOfWeek: 1, channel: 'Instagram KR', channelIcon: 'instagram', contentType: 'social', title: 'Reel: 재수술 전후 변화' },
{ dayOfWeek: 2, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 원장 한 줄 답변 모음' },
{ dayOfWeek: 3, channel: 'Instagram KR', channelIcon: 'instagram', contentType: 'social', title: 'Carousel: 상담 예약 가이드' },
{ dayOfWeek: 3, channel: 'Naver', channelIcon: 'globe', contentType: 'blog', title: '첫 성형 상담, 이것만 준비하세요' },
{ dayOfWeek: 4, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 이 달의 베스트 케이스' },
{ dayOfWeek: 4, channel: 'Facebook', channelIcon: 'facebook', contentType: 'ad', title: '광고: 월말 상담 예약 CTA' },
],
},
],
monthlySummary: [
{ type: 'video', label: '영상', count: 16, color: '#8B5CF6' },
{ type: 'blog', label: '블로그', count: 8, color: '#7A84D4' },
{ type: 'social', label: '소셜', count: 12, color: '#9B8AD4' },
{ type: 'ad', label: '광고', count: 4, color: '#D4A872' },
],
},
// ─── Section 5: Asset Collection ───
assetCollection: {
assets: [
{ id: 'a1', source: 'homepage', sourceLabel: '홈페이지', type: 'photo', title: '병원 내부 인테리어 사진', description: '로비, 상담실, 수술실 외관, 대기 공간 고화질 사진', repurposingSuggestions: ['Instagram Feed 배경', '유튜브 B-roll', 'Naver 블로그 대표 이미지'], status: 'collected' },
{ id: 'a2', source: 'homepage', sourceLabel: '홈페이지', type: 'photo', title: '의료진 프로필 사진', description: '28명 의료진 개인 프로필 사진 및 경력 정보', repurposingSuggestions: ['원장 소개 Carousel', '유튜브 섬네일', '네이버 블로그 프로필'], status: 'collected' },
{ id: 'a3', source: 'homepage', sourceLabel: '홈페이지', type: 'text', title: '시술 설명 텍스트', description: '가슴성형, 안면윤곽, 눈코 등 시술별 상세 설명', repurposingSuggestions: ['Naver 블로그 포스트 소스', 'Carousel 텍스트', '광고 카피'], status: 'collected' },
{ id: 'a4', source: 'youtube', sourceLabel: 'YouTube', type: 'video', title: '기존 롱폼 영상 1,064개', description: '10년간 축적된 시술 설명, Q&A, 인터뷰 영상 아카이브', repurposingSuggestions: ['AI Shorts 추출 100+개', 'Instagram Reels 변환', 'TikTok 크로스포스팅'], status: 'collected' },
{ id: 'a5', source: 'youtube', sourceLabel: 'YouTube', type: 'video', title: '고성과 Shorts (10만+ 조회)', description: '574K, 525K, 392K 조회 Shorts — 전후 변화 중심', repurposingSuggestions: ['Instagram Reels 재업로드', 'TikTok 동시 게시', '광고 소재 활용'], status: 'collected' },
{ id: 'a6', source: 'social', sourceLabel: '소셜미디어', type: 'photo', title: 'Instagram EN Before/After 사진', description: '@view_plastic_surgery 계정의 2,524개 게시물 중 B/A 사진', repurposingSuggestions: ['KR 계정 크로스포스팅', '유튜브 롱폼 삽입', 'Naver 블로그 활용'], status: 'collected' },
{ id: 'a7', source: 'social', sourceLabel: '소셜미디어', type: 'text', title: '강남언니 환자 리뷰 18,840건', description: '9.5점 평균, 시술별 실 환자 후기 텍스트', repurposingSuggestions: ['후기 기반 Carousel 시리즈', '블로그 환자 스토리', '광고 사회적 증거'], status: 'pending' },
{ id: 'a8', source: 'naver_place', sourceLabel: '네이버 플레이스', type: 'photo', title: '네이버 플레이스 사진', description: '병원 외관, 위치, 시설 사진', repurposingSuggestions: ['블로그 위치 안내 포스트', '구글 마이비즈니스 동기화'], status: 'pending' },
{ id: 'a9', source: 'blog', sourceLabel: '블로그', type: 'text', title: '네이버 블로그 기존 포스트', description: '기존 블로그 포스트 (수량 미확인)', repurposingSuggestions: ['SEO 최적화 리라이팅', '영상 스크립트 소스'], status: 'pending' },
{ id: 'a10', source: 'homepage', sourceLabel: '홈페이지', type: 'video', title: '개원 20주년 기념 영상', description: '뷰성형외과 20년 역사 + 시설 소개 영상 (1:30)', repurposingSuggestions: ['브랜드 스토리 Reel', '웹사이트 히어로 영상', '신뢰 광고 소재'], status: 'collected' },
{ id: 'a11', source: 'homepage', sourceLabel: '홈페이지', type: 'photo', title: '시술별 전후 사진 갤러리', description: '눈, 코, 가슴, 윤곽 시술별 비포/애프터 사진', repurposingSuggestions: ['Instagram B/A 시리즈', 'Shorts 전환 소스', '상담 자료'], status: 'needs_creation' },
],
youtubeRepurpose: [
{ title: '한번에 성공하는 성형', views: 574000, type: 'Short', repurposeAs: ['Instagram Reel', 'TikTok', '광고 소재'] },
{ title: '코성형+지방이식 전후', views: 525000, type: 'Short', repurposeAs: ['Instagram Reel', 'TikTok', 'Naver 블로그 삽입'] },
{ title: '코성형! 내 얼굴에 가장 예쁜 코', views: 124000, type: 'Long', repurposeAs: ['Shorts 5개 추출', 'Carousel 3개', 'Blog Post 변환'] },
{ title: '아나운서 박은영, 가슴 할 결심', views: 127000, type: 'Long', repurposeAs: ['Shorts 3개 추출', '스토리 시리즈', '광고 소재'] },
{ title: '서울대 의학박사의 가슴재수술 성공전략', views: 1400, type: 'Long', repurposeAs: ['Shorts 추출', 'SEO 블로그', 'Carousel'] },
],
},
};

View File

@ -0,0 +1,14 @@
/**
* SubNav·IntersectionObserver `id` .
* (`DEMO/src/pages/MarketingPlanPage.tsx` PLAN_SECTIONS )
*/
export const PLAN_SECTIONS = [
{ id: "branding-guide", label: "브랜딩 가이드" },
{ id: "channel-strategy", label: "채널 전략" },
{ id: "content-strategy", label: "콘텐츠 전략" },
{ id: "content-calendar", label: "콘텐츠 캘린더" },
{ id: "asset-collection", label: "에셋 수집" },
{ id: "my-asset-upload", label: "My Assets" },
] as const;
export type PlanSectionId = (typeof PLAN_SECTIONS)[number]["id"];

View File

@ -0,0 +1,11 @@
import { useParams } from "react-router-dom";
import { useMarketingPlan } from "@/features/plan/hooks/useMarketingPlan";
/**
* `plan/:id` .
* Zustand .
*/
export function useCurrentMarketingPlan() {
const { id } = useParams<{ id: string }>();
return useMarketingPlan(id);
}

View File

@ -0,0 +1,20 @@
import { useMemo } from "react";
import { MOCK_PLAN } from "@/features/plan/constants/mock_plan";
import type { MarketingPlan } from "@/features/plan/types/marketingPlan";
type UseMarketingPlanResult = {
data: MarketingPlan | null;
isLoading: boolean;
error: string | null;
};
/**
* Phase 1: `MOCK_PLAN` .
* `_planRouteId` `plan/:id`· API ( ).
*/
export function useMarketingPlan(_planRouteId: string | undefined): UseMarketingPlanResult {
return useMemo(
() => ({ data: MOCK_PLAN, isLoading: false, error: null }),
[],
);
}

View File

@ -0,0 +1,52 @@
import { useEffect, useMemo, useState } from "react";
import { useMainSubNav } from "@/layouts/MainSubNavLayout";
import type { SubNavItem } from "@/layouts/SubNav";
import { PLAN_SECTIONS } from "@/features/plan/constants/plan_sections";
export function usePlanSubNav() {
const { setSubNav } = useMainSubNav();
const [activeId, setActiveId] = useState<string>(PLAN_SECTIONS[0]?.id ?? "");
const items: SubNavItem[] = useMemo(
() =>
PLAN_SECTIONS.map((s) => ({
id: s.id,
label: s.label,
targetId: s.id,
})),
[]
);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
if (visible.length > 0) {
setActiveId(visible[0].target.id);
}
},
{ rootMargin: "-100px 0px -60% 0px", threshold: 0 }
);
PLAN_SECTIONS.forEach(({ id: sectionId }) => {
const el = document.getElementById(sectionId);
if (el) observer.observe(el);
});
return () => observer.disconnect();
}, []);
useEffect(() => {
setSubNav({
items,
activeId,
scrollActiveIntoView: true,
});
}, [activeId, items, setSubNav]);
useEffect(() => {
return () => setSubNav(null);
}, [setSubNav]);
}

View File

@ -0,0 +1,176 @@
import type { BrandInconsistency } from "@/types/brandConsistency";
// ─── Section 1: Branding Guide ───
export interface ColorSwatch {
name: string;
hex: string;
usage: string;
}
export interface FontSpec {
family: string;
weight: string;
usage: string;
sampleText: string;
}
export interface LogoUsageRule {
rule: string;
description: string;
correct: boolean;
}
export interface ToneOfVoice {
personality: string[];
communicationStyle: string;
doExamples: string[];
dontExamples: string[];
}
export interface ChannelBrandingRule {
channel: string;
icon: string;
profilePhoto: string;
bannerSpec: string;
bioTemplate: string;
currentStatus: 'correct' | 'incorrect' | 'missing';
}
export interface BrandGuide {
colors: ColorSwatch[];
fonts: FontSpec[];
logoRules: LogoUsageRule[];
toneOfVoice: ToneOfVoice;
channelBranding: ChannelBrandingRule[];
brandInconsistencies: BrandInconsistency[];
}
// ─── Section 2: Channel Communication Strategy ───
export interface ChannelStrategyCard {
channelId: string;
channelName: string;
icon: string;
currentStatus: string;
targetGoal: string;
contentTypes: string[];
postingFrequency: string;
tone: string;
formatGuidelines: string[];
priority: 'P0' | 'P1' | 'P2';
}
// ─── Section 3: Content Marketing Strategy ───
export interface ContentPillar {
title: string;
description: string;
relatedUSP: string;
exampleTopics: string[];
color: string;
}
export interface ContentTypeRow {
format: string;
channels: string[];
frequency: string;
purpose: string;
}
export interface WorkflowStep {
step: number;
name: string;
description: string;
owner: string;
duration: string;
}
export interface RepurposingOutput {
format: string;
channel: string;
description: string;
}
export interface ContentStrategyData {
pillars: ContentPillar[];
typeMatrix: ContentTypeRow[];
workflow: WorkflowStep[];
repurposingSource: string;
repurposingOutputs: RepurposingOutput[];
}
// ─── Section 4: Content Calendar ───
export type ContentCategory = 'video' | 'blog' | 'social' | 'ad';
export interface CalendarEntry {
dayOfWeek: number;
channel: string;
channelIcon: string;
contentType: ContentCategory;
title: string;
}
export interface CalendarWeek {
weekNumber: number;
label: string;
entries: CalendarEntry[];
}
export interface ContentCountSummary {
type: ContentCategory;
label: string;
count: number;
color: string;
}
export interface CalendarData {
weeks: CalendarWeek[];
monthlySummary: ContentCountSummary[];
}
// ─── Section 5: Asset Collection ───
export type AssetSource = 'homepage' | 'naver_place' | 'blog' | 'social' | 'youtube';
export type AssetType = 'photo' | 'video' | 'text';
export type AssetStatus = 'collected' | 'pending' | 'needs_creation';
export interface AssetCard {
id: string;
source: AssetSource;
sourceLabel: string;
type: AssetType;
title: string;
description: string;
repurposingSuggestions: string[];
status: AssetStatus;
}
export interface YouTubeRepurposeItem {
title: string;
views: number;
type: 'Short' | 'Long';
repurposeAs: string[];
}
export interface AssetCollectionData {
assets: AssetCard[];
youtubeRepurpose: YouTubeRepurposeItem[];
}
// ─── Root Plan Type ───
export interface MarketingPlan {
id: string;
reportId: string;
clinicName: string;
clinicNameEn: string;
createdAt: string;
targetUrl: string;
brandGuide: BrandGuide;
channelStrategies: ChannelStrategyCard[];
contentStrategy: ContentStrategyData;
calendar: CalendarData;
assetCollection: AssetCollectionData;
}

View File

@ -0,0 +1,44 @@
import { useCurrentMarketingPlan } from "@/features/plan/hooks/useCurrentMarketingPlan";
import { usePlanSubNav } from "@/features/plan/hooks/usePlanSubNav";
import { PlanAssetCollectionSection } from "@/features/plan/ui/PlanAssetCollectionSection";
import { PlanBrandingGuideSection } from "@/features/plan/ui/PlanBrandingGuideSection";
import { PlanChannelStrategySection } from "@/features/plan/ui/PlanChannelStrategySection";
import { PlanContentCalendarSection } from "@/features/plan/ui/PlanContentCalendarSection";
import { PlanContentStrategySection } from "@/features/plan/ui/PlanContentStrategySection";
import { PlanCtaSection } from "@/features/plan/ui/PlanCtaSection";
import { PlanHeaderSection } from "@/features/plan/ui/PlanHeaderSection";
import { PlanMyAssetUploadSection } from "@/features/plan/ui/PlanMyAssetUploadSection";
export function MarketingPlanPage() {
const { data, error } = useCurrentMarketingPlan();
usePlanSubNav();
if (error || !data) {
return (
<div className="min-h-[50vh] flex items-center justify-center px-6">
<div className="text-center max-w-md">
<p className="title-16 text-[var(--color-status-critical-text)] mb-2 break-keep">
</p>
<p className="body-14 text-neutral-60 break-keep">
{error ?? "마케팅 플랜을 찾을 수 없습니다."}
</p>
</div>
</div>
);
}
return (
<div data-plan-content data-plan-id={data.id}>
<PlanHeaderSection />
<PlanBrandingGuideSection />
<PlanChannelStrategySection />
<PlanContentStrategySection />
<PlanContentCalendarSection />
<PlanAssetCollectionSection />
<PlanMyAssetUploadSection />
<PlanCtaSection />
</div>
);
}

View File

@ -0,0 +1,22 @@
import { PageSection } from "@/components/section/PageSection";
import { useCurrentMarketingPlan } from "@/features/plan/hooks/useCurrentMarketingPlan";
import { AssetCollectionPanel } from "@/features/plan/ui/assetCollection/AssetCollectionPanel";
export function PlanAssetCollectionSection() {
const { data, error } = useCurrentMarketingPlan();
if (error || !data) {
return null;
}
return (
<PageSection
id="asset-collection"
title="에셋 수집"
subtitle="에셋 수집 & 리퍼포징 소스"
animateEnter={false}
>
<AssetCollectionPanel data={data.assetCollection} />
</PageSection>
);
}

View File

@ -0,0 +1,22 @@
import { PageSection } from "@/components/section/PageSection";
import { useCurrentMarketingPlan } from "@/features/plan/hooks/useCurrentMarketingPlan";
import { BrandingGuidePanel } from "@/features/plan/ui/branding/BrandingGuidePanel";
export function PlanBrandingGuideSection() {
const { data, error } = useCurrentMarketingPlan();
if (error || !data) {
return null;
}
return (
<PageSection
id="branding-guide"
title="브랜딩 가이드"
subtitle="브랜딩 가이드 빌드"
animateEnter={false}
>
<BrandingGuidePanel data={data.brandGuide} />
</PageSection>
);
}

View File

@ -0,0 +1,23 @@
import { PageSection } from "@/components/section/PageSection";
import { useCurrentMarketingPlan } from "@/features/plan/hooks/useCurrentMarketingPlan";
import { ChannelStrategyGrid } from "@/features/plan/ui/channelStrategy/ChannelStrategyGrid";
export function PlanChannelStrategySection() {
const { data, error } = useCurrentMarketingPlan();
if (error || !data) {
return null;
}
return (
<PageSection
id="channel-strategy"
title="채널 전략"
subtitle="채널별 커뮤니케이션 전략"
dark
animateEnter={false}
>
<ChannelStrategyGrid channels={data.channelStrategies} />
</PageSection>
);
}

View File

@ -0,0 +1,23 @@
import { PageSection } from "@/components/section/PageSection";
import { useCurrentMarketingPlan } from "@/features/plan/hooks/useCurrentMarketingPlan";
import { ContentCalendarPanel } from "@/features/plan/ui/contentCalendar/ContentCalendarPanel";
export function PlanContentCalendarSection() {
const { data, error } = useCurrentMarketingPlan();
if (error || !data) {
return null;
}
return (
<PageSection
id="content-calendar"
title="콘텐츠 캘린더"
subtitle="콘텐츠 캘린더 (월간)"
dark
animateEnter={false}
>
<ContentCalendarPanel data={data.calendar} />
</PageSection>
);
}

View File

@ -0,0 +1,22 @@
import { PageSection } from "@/components/section/PageSection";
import { useCurrentMarketingPlan } from "@/features/plan/hooks/useCurrentMarketingPlan";
import { ContentStrategyPanel } from "@/features/plan/ui/contentStrategy/ContentStrategyPanel";
export function PlanContentStrategySection() {
const { data, error } = useCurrentMarketingPlan();
if (error || !data) {
return null;
}
return (
<PageSection
id="content-strategy"
title="콘텐츠 전략"
subtitle="콘텐츠 마케팅 전략"
animateEnter={false}
>
<ContentStrategyPanel data={data.contentStrategy} />
</PageSection>
);
}

View File

@ -0,0 +1,118 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import DownloadIcon from "@/assets/report/download.svg?react";
import { Button } from "@/components/atoms/Button";
import { useCurrentMarketingPlan } from "@/features/plan/hooks/useCurrentMarketingPlan";
function PlanCtaRocketIcon({ className }: { className?: string }) {
return (
<svg
className={className}
width={28}
height={28}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
>
<path
d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
/** `DEMO/src/components/plan/PlanCTA.tsx` 레이아웃·카피·버튼 구성과 동일 (PDF는 Phase 1 목 동작). */
export function PlanCtaSection() {
const navigate = useNavigate();
const { data, error } = useCurrentMarketingPlan();
const [isExporting, setIsExporting] = useState(false);
if (error || !data) {
return null;
}
const planId = data.id;
const exportPdf = () => {
setIsExporting(true);
window.setTimeout(() => setIsExporting(false), 900);
};
return (
<section className="py-16 md:py-20 px-6 scroll-mt-36" aria-label="다음 단계 안내">
<div className="max-w-7xl mx-auto">
<div
data-cta-card
className="rounded-2xl bg-gradient-to-r from-[var(--color-marketing-cream)] via-[var(--color-marketing-lilac)] to-[var(--color-marketing-ice)] p-10 md:p-14 text-center"
>
<div className="flex justify-center mb-6">
<div className="w-14 h-14 rounded-full bg-white/80 backdrop-blur-sm border border-white/40 flex items-center justify-center">
<PlanCtaRocketIcon className="text-violet-700" />
</div>
</div>
<h3 className="font-serif text-2xl md:text-3xl font-bold text-navy-950 mb-3 break-keep">
</h3>
<p className="text-navy-950/60 mb-8 max-w-lg mx-auto break-keep">
INFINITH , .
</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Button
variant="primary"
onClick={() => navigate(`/studio/${planId}`)}
className="inline-flex items-center justify-center gap-2 px-6 py-3 text-sm font-medium hover:shadow-lg transition-shadow"
>
<span className="break-keep"> </span>
</Button>
<Button
variant="outline"
onClick={exportPdf}
disabled={isExporting}
className="inline-flex items-center justify-center gap-2 px-6 py-3 text-sm font-medium text-navy-950 shadow-sm hover:shadow-md disabled:opacity-60 disabled:cursor-not-allowed"
>
{isExporting ? (
<span
className="w-4 h-4 border-2 border-navy-950 border-t-transparent rounded-full animate-spin shrink-0"
aria-hidden
/>
) : (
<DownloadIcon width={16} height={16} className="shrink-0" aria-hidden />
)}
<span className="break-keep"> </span>
</Button>
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,34 @@
import { useCurrentMarketingPlan } from "@/features/plan/hooks/useCurrentMarketingPlan";
import { PlanHeaderDaysBadge } from "@/features/plan/ui/header/PlanHeaderDaysBadge";
import { PlanHeaderHeroBlobs } from "@/features/plan/ui/header/PlanHeaderHeroBlobs";
import { PlanHeaderHeroColumn } from "@/features/plan/ui/header/PlanHeaderHeroColumn";
import { PLAN_HEADER_BG_CLASS } from "@/features/plan/ui/header/planHeaderSectionStyles";
export function PlanHeaderSection() {
const { data, error } = useCurrentMarketingPlan();
if (error || !data) {
return null;
}
return (
<section
aria-label="마케팅 플랜 헤더"
className={`relative overflow-hidden scroll-mt-36 py-20 md:py-28 px-6 ${PLAN_HEADER_BG_CLASS}`}
>
<PlanHeaderHeroBlobs />
<div className="relative max-w-7xl mx-auto">
<div className="flex flex-col md:flex-row items-center md:items-start justify-between gap-10">
<PlanHeaderHeroColumn
clinicName={data.clinicName}
clinicNameEn={data.clinicNameEn}
date={data.createdAt}
targetUrl={data.targetUrl}
/>
<PlanHeaderDaysBadge />
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,19 @@
import { PageSection } from "@/components/section/PageSection";
import { useCurrentMarketingPlan } from "@/features/plan/hooks/useCurrentMarketingPlan";
import { MyAssetUploadPanel } from "@/features/plan/ui/myAssets/MyAssetUploadPanel";
export function PlanMyAssetUploadSection() {
const { data, error } = useCurrentMarketingPlan();
if (error || !data) {
return null;
}
return (
<div data-no-print>
<PageSection id="my-asset-upload" title="My Assets" subtitle="나의 에셋 업로드" animateEnter={false}>
<MyAssetUploadPanel />
</PageSection>
</div>
);
}

View File

@ -0,0 +1,40 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { Button } from "@/components/atoms/Button";
export type SegmentTabButtonProps = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
"type" | "className"
> & {
active: boolean;
onClick: () => void;
children: ReactNode;
/** 비활성 탭에 `card-shadow` (에셋 업로드 필터 등) */
inactiveShadow?: boolean;
className?: string;
};
export function SegmentTabButton({
active,
onClick,
children,
inactiveShadow = false,
className = "",
...rest
}: SegmentTabButtonProps) {
return (
<Button
variant={active ? "primary" : "outline"}
onClick={onClick}
className={[
"px-4 py-2 body-14-medium",
!active && inactiveShadow ? "card-shadow" : "",
className,
]
.filter(Boolean)
.join(" ")}
{...rest}
>
{children}
</Button>
);
}

View File

@ -0,0 +1,122 @@
import { useState } from "react";
import ChannelYoutubeIcon from "@/assets/icons/channel-youtube.svg?react";
import { Pill } from "@/components/atoms/Pill";
import { Surface } from "@/components/atoms/Surface";
import { SegmentTabButton } from "@/features/plan/ui/SegmentTabButton";
import type { AssetCollectionData } from "@/features/plan/types/marketingPlan";
import {
ASSET_COLLECTION_FILTER_TABS,
type AssetCollectionFilterKey,
} from "@/features/plan/ui/assetCollection/assetCollectionFilterTabs";
import {
assetSourceBadgeClass,
assetStatusConfig,
assetTypeBadgeClass,
assetTypeDisplayLabel,
formatYoutubeViews,
} from "@/features/plan/ui/assetCollection/assetCollectionBadgeClass";
type AssetCollectionPanelProps = {
data: AssetCollectionData;
};
export function AssetCollectionPanel({ data }: AssetCollectionPanelProps) {
const [activeFilter, setActiveFilter] = useState<AssetCollectionFilterKey>("all");
const filteredAssets =
activeFilter === "all" ? data.assets : data.assets.filter((a) => a.source === activeFilter);
return (
<div>
<div className="flex flex-wrap gap-2 mb-8" role="tablist" aria-label="에셋 출처 필터">
{ASSET_COLLECTION_FILTER_TABS.map((tab) => {
const isActive = activeFilter === tab.key;
return (
<SegmentTabButton
key={tab.key}
role="tab"
aria-selected={isActive}
active={isActive}
onClick={() => setActiveFilter(tab.key)}
>
{tab.label}
</SegmentTabButton>
);
})}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-12">
{filteredAssets.map((asset) => {
const statusInfo = assetStatusConfig(asset.status);
return (
<Surface key={asset.id}>
<div className="flex items-center gap-2 mb-3 flex-wrap">
<Pill className={`shrink-0 ${assetSourceBadgeClass(asset.source)}`}>{asset.sourceLabel}</Pill>
<Pill className={`shrink-0 ${assetTypeBadgeClass(asset.type)}`}>{assetTypeDisplayLabel(asset.type)}</Pill>
<Pill className={`ml-auto shrink-0 ${statusInfo.className}`}>{statusInfo.label}</Pill>
</div>
<h4 className="title-14 text-navy-900 mb-1 break-keep">{asset.title}</h4>
<p className="body-14 text-neutral-70 mb-3 break-keep">{asset.description}</p>
{asset.repurposingSuggestions.length > 0 ? (
<div>
<p className="label-12 font-semibold text-neutral-60 uppercase mb-2 break-keep">
Repurposing
</p>
<div className="flex flex-wrap gap-2">
{asset.repurposingSuggestions.map((suggestion, j) => (
<Pill key={j} size="sm" className="shrink-0 bg-lavender-100 text-violet-700">
{suggestion}
</Pill>
))}
</div>
</div>
) : null}
</Surface>
);
})}
</div>
{data.youtubeRepurpose.length > 0 ? (
<div>
<h3 className="font-serif headline-24 text-navy-900 mb-4 break-keep">
YouTube Top Videos for Repurposing
</h3>
<div className="flex overflow-x-auto gap-4 pb-4 scrollbar-hide">
{data.youtubeRepurpose.map((video) => (
<Surface key={video.title} className="min-w-[280px] shrink-0">
<div className="flex items-start gap-2 mb-3 min-w-0">
<ChannelYoutubeIcon width={18} height={18} className="text-[var(--color-status-critical-dot)] shrink-0 mt-1" aria-hidden />
<h4 className="title-14 text-navy-900 break-keep min-w-0">{video.title}</h4>
</div>
<div className="flex items-center gap-2 mb-3 flex-wrap">
<Pill className="shrink-0 bg-neutral-10 text-neutral-80">
{formatYoutubeViews(video.views)} views
</Pill>
<Pill
className={
video.type === "Short"
? "shrink-0 bg-lavender-100 text-violet-700 border border-lavender-300"
: "shrink-0 bg-[var(--color-status-info-bg)] text-[var(--color-status-info-text)] border border-[var(--color-status-info-border)]"
}
>
{video.type}
</Pill>
</div>
<p className="label-12 font-semibold text-neutral-60 uppercase mb-2 break-keep">Repurpose As:</p>
<div className="flex flex-wrap gap-2">
{video.repurposeAs.map((suggestion, j) => (
<Pill key={j} size="sm" className="shrink-0 bg-lavender-100 text-violet-700">
{suggestion}
</Pill>
))}
</div>
</Surface>
))}
</div>
</div>
) : null}
</div>
);
}

View File

@ -0,0 +1,75 @@
import type { AssetSource, AssetStatus, AssetType } from "@/features/plan/types/marketingPlan";
export function assetSourceBadgeClass(source: AssetSource): string {
switch (source) {
case "homepage":
return "bg-neutral-10 text-neutral-80 border border-neutral-20";
case "naver_place":
return "bg-[var(--color-status-good-bg)] text-[var(--color-status-good-text)] border border-[var(--color-status-good-border)]";
case "blog":
return "bg-[var(--color-status-info-bg)] text-[var(--color-status-info-text)] border border-[var(--color-status-info-border)]";
case "social":
return "bg-[var(--color-marketing-blush)]/50 text-navy-800 border border-neutral-30";
case "youtube":
return "bg-[var(--color-status-critical-bg)] text-[var(--color-status-critical-text)] border border-[var(--color-status-critical-border)]";
default:
return "bg-neutral-10 text-neutral-70 border border-neutral-20";
}
}
export function assetTypeDisplayLabel(type: AssetType): string {
switch (type) {
case "photo":
return "사진";
case "video":
return "영상";
case "text":
return "텍스트";
default:
return type;
}
}
export function assetTypeBadgeClass(type: AssetType): string {
switch (type) {
case "photo":
return "bg-[var(--color-status-info-bg)] text-[var(--color-status-info-text)] border border-[var(--color-status-info-border)]";
case "video":
return "bg-lavender-100 text-violet-700 border border-lavender-300";
case "text":
return "bg-[var(--color-status-warning-bg)] text-[var(--color-status-warning-text)] border border-[var(--color-status-warning-border)]";
default:
return "bg-neutral-10 text-neutral-70 border border-neutral-20";
}
}
export function assetStatusConfig(status: AssetStatus): { className: string; label: string } {
switch (status) {
case "collected":
return {
className:
"bg-[var(--color-status-good-bg)] text-[var(--color-status-good-text)] border border-[var(--color-status-good-border)]",
label: "수집완료",
};
case "pending":
return {
className:
"bg-[var(--color-status-warning-bg)] text-[var(--color-status-warning-text)] border border-[var(--color-status-warning-border)]",
label: "수집 대기",
};
case "needs_creation":
return {
className:
"bg-[var(--color-status-critical-bg)] text-[var(--color-status-critical-text)] border border-[var(--color-status-critical-border)]",
label: "제작 필요",
};
default:
return { className: "bg-neutral-10 text-neutral-70 border border-neutral-20", label: status };
}
}
export function formatYoutubeViews(views: number): string {
if (views >= 1_000_000) return `${(views / 1_000_000).toFixed(1)}M`;
if (views >= 1_000) return `${Math.round(views / 1_000)}K`;
return String(views);
}

View File

@ -0,0 +1,10 @@
export const ASSET_COLLECTION_FILTER_TABS = [
{ key: "all", label: "전체" },
{ key: "homepage", label: "홈페이지" },
{ key: "naver_place", label: "네이버" },
{ key: "blog", label: "블로그" },
{ key: "social", label: "소셜미디어" },
{ key: "youtube", label: "YouTube" },
] as const;
export type AssetCollectionFilterKey = (typeof ASSET_COLLECTION_FILTER_TABS)[number]["key"];

View File

@ -0,0 +1,35 @@
import type { ComponentType, SVGProps } from "react";
import ChannelFacebookIcon from "@/assets/icons/channel-facebook.svg?react";
import ChannelInstagramIcon from "@/assets/icons/channel-instagram.svg?react";
import ChannelYoutubeIcon from "@/assets/icons/channel-youtube.svg?react";
import GlobeIcon from "@/assets/report/globe.svg?react";
import MessageCircleIcon from "@/assets/report/message-circle.svg?react";
import VideoIcon from "@/assets/icons/video.svg?react";
type SvgIcon = ComponentType<SVGProps<SVGSVGElement>>;
const CHANNEL_ICON_MAP: Record<string, SvgIcon> = {
youtube: ChannelYoutubeIcon,
instagram: ChannelInstagramIcon,
facebook: ChannelFacebookIcon,
globe: GlobeIcon,
video: VideoIcon,
messagesquare: MessageCircleIcon,
};
function normalizeIconKey(icon: string): string {
return icon.toLowerCase().replace(/-/g, "");
}
type BrandingChannelIconProps = {
icon: string;
className?: string;
width?: number;
height?: number;
};
export function BrandingChannelIcon({ icon, className, width = 18, height = 18 }: BrandingChannelIconProps) {
const key = normalizeIconKey(icon);
const Icon = CHANNEL_ICON_MAP[key] ?? GlobeIcon;
return <Icon className={className} width={width} height={height} aria-hidden />;
}

View File

@ -0,0 +1,52 @@
import { Surface } from "@/components/atoms/Surface";
import type { BrandGuide } from "@/features/plan/types/marketingPlan";
import { BrandingChannelIcon } from "@/features/plan/ui/branding/BrandingChannelIcon";
import {
brandingChannelStatusBadgeClass,
brandingChannelStatusLabel,
} from "@/features/plan/ui/branding/brandingChannelStatusClass";
type BrandingChannelRulesTabProps = {
channels: BrandGuide["channelBranding"];
};
export function BrandingChannelRulesTab({ channels }: BrandingChannelRulesTabProps) {
return (
<div className="animate-fade-in-up">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{channels.map((ch) => (
<Surface key={ch.channel}>
<div className="flex items-center gap-3 mb-4 min-w-0">
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-violet-500/10 to-violet-700/10 flex items-center justify-center shrink-0">
<BrandingChannelIcon icon={ch.icon} className="text-violet-500" />
</div>
<p className="font-bold text-navy-900 break-keep truncate min-w-0">{ch.channel}</p>
<span
className={`ml-auto text-xs font-medium px-3 py-1 rounded-full border shrink-0 break-keep ${brandingChannelStatusBadgeClass(ch.currentStatus)}`}
>
{brandingChannelStatusLabel(ch.currentStatus)}
</span>
</div>
<div className="space-y-3 body-14">
<div>
<p className="text-neutral-60 label-12 uppercase tracking-wide mb-1 break-keep">Profile Photo</p>
<p className="text-neutral-80 font-medium break-keep">{ch.profilePhoto}</p>
</div>
<div>
<p className="text-neutral-60 label-12 uppercase tracking-wide mb-1 break-keep">Banner Spec</p>
<p className="text-neutral-80 font-medium break-keep">{ch.bannerSpec}</p>
</div>
<div>
<p className="text-neutral-60 label-12 uppercase tracking-wide mb-1 break-keep">Bio Template</p>
<div className="bg-neutral-10 rounded-xl p-3 border border-neutral-20">
<p className="font-mono label-12 text-neutral-70 whitespace-pre-wrap break-keep">{ch.bioTemplate}</p>
</div>
</div>
</div>
</Surface>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,45 @@
import { useState } from "react";
import { BrandConsistencyMap } from "@/components/brand/BrandConsistencyMap";
import { SegmentTabButton } from "@/features/plan/ui/SegmentTabButton";
import type { BrandGuide } from "@/features/plan/types/marketingPlan";
import { BRANDING_GUIDE_TAB_ITEMS, type BrandingGuideTabKey } from "@/features/plan/ui/branding/brandingTabItems";
import { BrandingChannelRulesTab } from "@/features/plan/ui/branding/BrandingChannelRulesTab";
import { BrandingToneVoiceTab } from "@/features/plan/ui/branding/BrandingToneVoiceTab";
import { BrandingVisualIdentityTab } from "@/features/plan/ui/branding/BrandingVisualIdentityTab";
type BrandingGuidePanelProps = {
data: BrandGuide;
};
export function BrandingGuidePanel({ data }: BrandingGuidePanelProps) {
const [activeTab, setActiveTab] = useState<BrandingGuideTabKey>("visual");
return (
<div>
<div className="flex flex-wrap gap-2 mb-8" role="tablist" aria-label="브랜딩 가이드 탭">
{BRANDING_GUIDE_TAB_ITEMS.map((tab) => {
const isActive = activeTab === tab.key;
return (
<SegmentTabButton
key={tab.key}
role="tab"
aria-selected={isActive}
title={tab.labelKr}
active={isActive}
onClick={() => setActiveTab(tab.key)}
>
{tab.label}
</SegmentTabButton>
);
})}
</div>
{activeTab === "visual" ? <BrandingVisualIdentityTab data={data} /> : null}
{activeTab === "tone" ? <BrandingToneVoiceTab tone={data.toneOfVoice} /> : null}
{activeTab === "channels" ? <BrandingChannelRulesTab channels={data.channelBranding} /> : null}
{activeTab === "consistency" ? (
<BrandConsistencyMap inconsistencies={data.brandInconsistencies} showHeading={false} />
) : null}
</div>
);
}

View File

@ -0,0 +1,69 @@
import CheckCircleIcon from "@/assets/report/check-circle.svg?react";
import XCircleIcon from "@/assets/report/x-circle.svg?react";
import type { BrandGuide } from "@/features/plan/types/marketingPlan";
type BrandingToneVoiceTabProps = {
tone: BrandGuide["toneOfVoice"];
};
export function BrandingToneVoiceTab({ tone }: BrandingToneVoiceTabProps) {
return (
<div className="space-y-6 animate-fade-in-up">
<div>
<h3 className="font-serif font-bold text-2xl text-navy-900 mb-4 break-keep">Personality</h3>
<div className="flex flex-wrap gap-2">
{tone.personality.map((trait) => (
<span
key={trait}
className="bg-gradient-to-r from-violet-700/10 to-navy-950/10 text-violet-700 border border-lavender-300 rounded-full px-4 py-2 body-14-medium break-keep"
>
{trait}
</span>
))}
</div>
</div>
<div>
<h3 className="font-serif font-bold text-2xl text-navy-900 mb-4 break-keep">Communication Style</h3>
<div className="rounded-2xl bg-neutral-10 p-6 border border-neutral-20">
<p className="body-16 text-neutral-80 leading-relaxed break-keep">{tone.communicationStyle}</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h4 className="title-14 text-[var(--color-status-good-text)] mb-3 flex items-center gap-2 break-keep">
<CheckCircleIcon width={16} height={16} className="shrink-0 text-[var(--color-status-good-dot)]" aria-hidden />
DO
</h4>
<div className="space-y-3">
{tone.doExamples.map((example, i) => (
<div
key={i}
className="border-l-4 border-[var(--color-status-good-dot)] bg-[var(--color-status-good-bg)]/30 p-4 rounded-r-xl"
>
<p className="body-14 text-neutral-80 break-keep">{example}</p>
</div>
))}
</div>
</div>
<div>
<h4 className="title-14 text-[var(--color-status-critical-text)] mb-3 flex items-center gap-2 break-keep">
<XCircleIcon width={16} height={16} className="shrink-0 text-[var(--color-status-critical-dot)]" aria-hidden />
DON&apos;T
</h4>
<div className="space-y-3">
{tone.dontExamples.map((example, i) => (
<div
key={i}
className="border-l-4 border-[var(--color-status-critical-dot)] bg-[var(--color-status-critical-bg)]/30 p-4 rounded-r-xl"
>
<p className="body-14 text-neutral-80 break-keep">{example}</p>
</div>
))}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,92 @@
import CheckCircleIcon from "@/assets/report/check-circle.svg?react";
import XCircleIcon from "@/assets/report/x-circle.svg?react";
import { Surface } from "@/components/atoms/Surface";
import type { BrandGuide } from "@/features/plan/types/marketingPlan";
type BrandingVisualIdentityTabProps = {
data: BrandGuide;
};
export function BrandingVisualIdentityTab({ data }: BrandingVisualIdentityTabProps) {
return (
<div className="space-y-8 animate-fade-in-up">
<div>
<h3 className="font-serif font-bold text-2xl text-navy-900 mb-4 break-keep">Color Palette</h3>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
{data.colors.map((swatch, i) => (
<Surface key={`${swatch.name}-${swatch.hex}-${i}`} padding="none" overflowHidden>
<div className="h-20" style={{ backgroundColor: swatch.hex }} />
<div className="p-3">
<p className="font-mono body-14 text-neutral-80">{swatch.hex}</p>
<p className="title-14 text-navy-900 break-keep">{swatch.name}</p>
<p className="label-12 text-neutral-60 break-keep">{swatch.usage}</p>
</div>
</Surface>
))}
</div>
</div>
<div>
<h3 className="font-serif font-bold text-2xl text-navy-900 mb-4 break-keep">Typography</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{data.fonts.map((spec) => (
<Surface key={`${spec.family}-${spec.weight}`}>
<p className="label-12 text-neutral-60 uppercase tracking-wide mb-2 break-keep">{spec.family}</p>
<p
className={`mb-3 text-navy-900 break-keep ${
spec.weight.toLowerCase().includes("bold") ? "text-2xl font-bold" : "text-lg"
}`}
style={{ fontFamily: spec.family }}
>
{spec.sampleText}
</p>
<p className="label-12 text-neutral-60 break-keep">
<span className="font-medium text-neutral-80">{spec.weight}</span>
{" · "}
{spec.usage}
</p>
</Surface>
))}
</div>
</div>
<div>
<h3 className="font-serif font-bold text-2xl text-navy-900 mb-4 break-keep">Logo Rules</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{data.logoRules.map((rule) => (
<div
key={rule.rule}
className={`rounded-2xl p-5 ${
rule.correct
? "border-2 border-[var(--color-status-good-border)] bg-[var(--color-status-good-bg)]/30"
: "border-2 border-[var(--color-status-critical-border)] bg-[var(--color-status-critical-bg)]/30"
}`}
>
<div className="flex items-start gap-3">
{rule.correct ? (
<CheckCircleIcon
width={20}
height={20}
className="text-[var(--color-status-good-dot)] shrink-0 mt-1"
aria-hidden
/>
) : (
<XCircleIcon
width={20}
height={20}
className="text-[var(--color-status-critical-dot)] shrink-0 mt-1"
aria-hidden
/>
)}
<div className="min-w-0">
<p className="title-14 text-navy-900 break-keep">{rule.rule}</p>
<p className="body-14 text-neutral-70 mt-1 break-keep">{rule.description}</p>
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,27 @@
import type { ChannelBrandingRule } from "@/features/plan/types/marketingPlan";
export function brandingChannelStatusBadgeClass(status: ChannelBrandingRule["currentStatus"]): string {
switch (status) {
case "correct":
return "bg-[var(--color-status-good-bg)] text-[var(--color-status-good-text)] border-[var(--color-status-good-border)]";
case "incorrect":
return "bg-[var(--color-status-critical-bg)] text-[var(--color-status-critical-text)] border-[var(--color-status-critical-border)]";
case "missing":
return "bg-neutral-10 text-neutral-60 border-neutral-30";
default:
return "bg-neutral-10 text-neutral-60 border-neutral-30";
}
}
export function brandingChannelStatusLabel(status: ChannelBrandingRule["currentStatus"]): string {
switch (status) {
case "correct":
return "Correct";
case "incorrect":
return "Incorrect";
case "missing":
return "Missing";
default:
return status;
}
}

View File

@ -0,0 +1,8 @@
export const BRANDING_GUIDE_TAB_ITEMS = [
{ 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;
export type BrandingGuideTabKey = (typeof BRANDING_GUIDE_TAB_ITEMS)[number]["key"];

View File

@ -0,0 +1,86 @@
import CalendarIcon from "@/assets/report/calendar.svg?react";
import { Pill } from "@/components/atoms/Pill";
import { Surface } from "@/components/atoms/Surface";
import type { ChannelStrategyCard } from "@/features/plan/types/marketingPlan";
import { BrandingChannelIcon } from "@/features/plan/ui/branding/BrandingChannelIcon";
import { channelStrategyPriorityPillClass } from "@/features/plan/ui/channelStrategy/channelStrategyPillClass";
type ChannelStrategyGridProps = {
channels: ChannelStrategyCard[];
};
function ChannelStrategyCard({ ch, index }: { ch: ChannelStrategyCard; index: number }) {
const delayClass =
index === 0
? ""
: index === 1
? "animation-delay-100"
: index === 2
? "animation-delay-200"
: "animation-delay-300";
return (
<Surface
bordered={false}
padding="lg"
interactive="lift"
className={`animate-fade-in-up ${delayClass}`}
>
<div className="flex items-center gap-3 mb-4 min-w-0">
<div className="w-10 h-10 rounded-xl bg-[var(--color-status-good-bg)] flex items-center justify-center shrink-0">
<BrandingChannelIcon icon={ch.icon} className="text-[var(--color-status-good-dot)]" width={20} height={20} />
</div>
<h4 className="title-18-md-20 text-navy-900 flex-1 min-w-0 break-keep">{ch.channelName}</h4>
<Pill weight="semibold" className={`shrink-0 border ${channelStrategyPriorityPillClass(ch.priority)}`}>
{ch.priority}
</Pill>
</div>
<div className="flex items-center gap-2 mb-4 flex-wrap">
<Pill className="border bg-[var(--color-status-critical-bg)] text-[var(--color-status-critical-text)] border-[var(--color-status-critical-border)]">
{ch.currentStatus}
</Pill>
<span className="body-14 text-neutral-60 shrink-0" aria-hidden>
</span>
<Pill className="border bg-[var(--color-status-good-bg)] text-[var(--color-status-good-text)] border-[var(--color-status-good-border)]">
{ch.targetGoal}
</Pill>
</div>
<div className="flex flex-wrap gap-2 mb-4">
{ch.contentTypes.map((type) => (
<Pill key={type} className="bg-neutral-10 border border-neutral-20 text-neutral-70">
{type}
</Pill>
))}
</div>
<div className="flex items-center gap-2 mb-3 min-w-0">
<CalendarIcon width={14} height={14} className="text-[var(--color-status-good-dot)] shrink-0" aria-hidden />
<p className="body-14 text-neutral-70 break-keep">{ch.postingFrequency}</p>
</div>
<p className="body-14 italic text-violet-500/80 mb-4 break-keep">{ch.tone}</p>
<ul className="space-y-2">
{ch.formatGuidelines.map((guideline, i) => (
<li key={i} className="flex items-start gap-2">
<span className="shrink-0 w-2 h-2 rounded-full bg-violet-500 mt-2" aria-hidden />
<span className="body-14 text-neutral-80 break-keep">{guideline}</span>
</li>
))}
</ul>
</Surface>
);
}
export function ChannelStrategyGrid({ channels }: ChannelStrategyGridProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{channels.map((ch, index) => (
<ChannelStrategyCard key={ch.channelId} ch={ch} index={index} />
))}
</div>
);
}

View File

@ -0,0 +1,14 @@
import type { ChannelStrategyCard } from "@/features/plan/types/marketingPlan";
export function channelStrategyPriorityPillClass(priority: ChannelStrategyCard["priority"]): string {
switch (priority) {
case "P0":
return "bg-[var(--color-status-critical-bg)] text-[var(--color-status-critical-text)] border-[var(--color-status-critical-border)]";
case "P1":
return "bg-[var(--color-status-warning-bg)] text-[var(--color-status-warning-text)] border-[var(--color-status-warning-border)]";
case "P2":
return "bg-[var(--color-status-good-bg)] text-[var(--color-status-good-text)] border-[var(--color-status-good-border)]";
default:
return "bg-neutral-10 text-neutral-60 border-neutral-20";
}
}

View File

@ -0,0 +1,110 @@
import { Pill } from "@/components/atoms/Pill";
import { Surface } from "@/components/atoms/Surface";
import type { CalendarData, CalendarEntry, ContentCategory } from "@/features/plan/types/marketingPlan";
import {
CALENDAR_CONTENT_TYPE_LABELS,
CALENDAR_DAY_HEADERS,
calendarContentTypeVisual,
} from "@/features/plan/ui/contentCalendar/calendarContentTypeVisual";
import { ContentCalendarTypeIcon } from "@/features/plan/ui/contentCalendar/ContentCalendarTypeIcon";
type ContentCalendarPanelProps = {
data: CalendarData;
};
function buildDayCells(entries: CalendarEntry[]): CalendarEntry[][] {
const dayCells: CalendarEntry[][] = Array.from({ length: 7 }, () => []);
for (const entry of entries) {
const dayIndex = entry.dayOfWeek;
if (dayIndex >= 0 && dayIndex <= 6) {
dayCells[dayIndex].push(entry);
}
}
return dayCells;
}
export function ContentCalendarPanel({ data }: ContentCalendarPanelProps) {
const legendTypes = Object.keys(CALENDAR_CONTENT_TYPE_LABELS) as ContentCategory[];
return (
<div>
<div className="flex flex-wrap gap-4 mb-8">
{data.monthlySummary.map((item) => {
const v = calendarContentTypeVisual(item.type);
return (
<Surface
key={item.type}
padding="sm"
surface="none"
className={`flex-1 min-w-[140px] ${v.summaryBg} ${v.summaryBorder}`}
>
<div className="flex items-center gap-2 mb-2">
<span className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: item.color }} aria-hidden />
<span className={`body-14-medium break-keep ${v.summaryText}`}>{item.label}</span>
</div>
<span className={`text-2xl font-bold break-keep ${v.summaryText}`}>{item.count}</span>
</Surface>
);
})}
</div>
{data.weeks.map((week) => {
const dayCells = buildDayCells(week.entries);
return (
<Surface key={week.weekNumber} className="mb-4">
<p className="title-14 text-navy-900 mb-3 break-keep">{week.label}</p>
<div className="grid grid-cols-7 gap-2">
{CALENDAR_DAY_HEADERS.map((day) => (
<div
key={day}
className="label-12 text-neutral-60 uppercase tracking-wide text-center mb-1 font-medium break-keep"
>
{day}
</div>
))}
{dayCells.map((entries, dayIdx) => (
<div
key={dayIdx}
className={`min-h-[80px] rounded-xl p-2 ${
entries.length > 0
? "bg-neutral-10/80 border border-neutral-20"
: "border border-dashed border-neutral-30/80"
}`}
>
{entries.map((entry, entryIdx) => {
const v = calendarContentTypeVisual(entry.contentType);
return (
<div
key={`${entry.title}-${entryIdx}`}
className={`${v.entryBg} border ${v.entryBorder} rounded-lg p-2 mb-1 card-shadow`}
>
<div className="flex items-center gap-1 mb-1">
<ContentCalendarTypeIcon type={entry.contentType} className={v.entryText} />
</div>
<p className="body-14 text-neutral-80 leading-tight break-keep">{entry.title}</p>
</div>
);
})}
</div>
))}
</div>
</Surface>
);
})}
<div className="flex flex-wrap gap-3 mt-4">
{legendTypes.map((type) => {
const v = calendarContentTypeVisual(type);
return (
<Pill
key={type}
className={`${v.summaryBg} ${v.summaryText} border ${v.summaryBorder} card-shadow`}
>
{CALENDAR_CONTENT_TYPE_LABELS[type]}
</Pill>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,25 @@
import type { ComponentType, SVGProps } from "react";
import ExternalLinkIcon from "@/assets/icons/external-link.svg?react";
import TrendingUpIcon from "@/assets/icons/trending-up.svg?react";
import VideoIcon from "@/assets/icons/video.svg?react";
import Link2Icon from "@/assets/report/link-2.svg?react";
import type { ContentCategory } from "@/features/plan/types/marketingPlan";
type Svg = ComponentType<SVGProps<SVGSVGElement>>;
const MAP: Record<ContentCategory, Svg> = {
video: VideoIcon,
blog: Link2Icon,
social: ExternalLinkIcon,
ad: TrendingUpIcon,
};
type ContentCalendarTypeIconProps = {
type: ContentCategory;
className?: string;
};
export function ContentCalendarTypeIcon({ type, className }: ContentCalendarTypeIconProps) {
const Icon = MAP[type];
return <Icon width={11} height={11} className={className} aria-hidden />;
}

View File

@ -0,0 +1,69 @@
import type { ContentCategory } from "@/features/plan/types/marketingPlan";
export type CalendarTypeVisual = {
summaryBg: string;
summaryBorder: string;
summaryText: string;
entryBg: string;
entryBorder: string;
entryText: string;
};
export function calendarContentTypeVisual(type: ContentCategory): CalendarTypeVisual {
switch (type) {
case "video":
return {
summaryBg: "bg-[var(--color-status-good-bg)]",
summaryBorder: "border-[var(--color-status-good-border)]",
summaryText: "text-[var(--color-status-good-text)]",
entryBg: "bg-[var(--color-status-good-bg)]",
entryBorder: "border-[var(--color-status-good-border)]",
entryText: "text-[var(--color-status-good-text)]",
};
case "blog":
return {
summaryBg: "bg-[var(--color-status-info-bg)]",
summaryBorder: "border-[var(--color-status-info-border)]",
summaryText: "text-[var(--color-status-info-text)]",
entryBg: "bg-[var(--color-status-info-bg)]",
entryBorder: "border-[var(--color-status-info-border)]",
entryText: "text-[var(--color-status-info-text)]",
};
case "social":
return {
summaryBg: "bg-[var(--color-status-warning-bg)]",
summaryBorder: "border-[var(--color-status-warning-border)]",
summaryText: "text-[var(--color-status-warning-text)]",
entryBg: "bg-[var(--color-status-warning-bg)]",
entryBorder: "border-[var(--color-status-warning-border)]",
entryText: "text-[var(--color-status-warning-text)]",
};
case "ad":
return {
summaryBg: "bg-[var(--color-status-critical-bg)]",
summaryBorder: "border-[var(--color-status-critical-border)]",
summaryText: "text-[var(--color-status-critical-text)]",
entryBg: "bg-[var(--color-status-critical-bg)]",
entryBorder: "border-[var(--color-status-critical-border)]",
entryText: "text-[var(--color-status-critical-text)]",
};
default:
return {
summaryBg: "bg-neutral-10",
summaryBorder: "border-neutral-20",
summaryText: "text-neutral-80",
entryBg: "bg-neutral-10",
entryBorder: "border-neutral-20",
entryText: "text-neutral-80",
};
}
}
export const CALENDAR_CONTENT_TYPE_LABELS: Record<ContentCategory, string> = {
video: "Video",
blog: "Blog",
social: "Social",
ad: "Ad",
};
export const CALENDAR_DAY_HEADERS = ["월", "화", "수", "목", "금", "토", "일"] as const;

View File

@ -0,0 +1,48 @@
import { useState } from "react";
import { SegmentTabButton } from "@/features/plan/ui/SegmentTabButton";
import type { ContentStrategyData } from "@/features/plan/types/marketingPlan";
import {
CONTENT_STRATEGY_TAB_ITEMS,
type ContentStrategyTabKey,
} from "@/features/plan/ui/contentStrategy/contentStrategyTabItems";
import { ContentStrategyPillarsTab } from "@/features/plan/ui/contentStrategy/ContentStrategyPillarsTab";
import { ContentStrategyRepurposingTab } from "@/features/plan/ui/contentStrategy/ContentStrategyRepurposingTab";
import { ContentStrategyTypesTab } from "@/features/plan/ui/contentStrategy/ContentStrategyTypesTab";
import { ContentStrategyWorkflowTab } from "@/features/plan/ui/contentStrategy/ContentStrategyWorkflowTab";
type ContentStrategyPanelProps = {
data: ContentStrategyData;
};
export function ContentStrategyPanel({ data }: ContentStrategyPanelProps) {
const [activeTab, setActiveTab] = useState<ContentStrategyTabKey>("pillars");
return (
<div>
<div className="flex flex-wrap gap-2 mb-8" role="tablist" aria-label="콘텐츠 전략 탭">
{CONTENT_STRATEGY_TAB_ITEMS.map((tab) => {
const isActive = activeTab === tab.key;
return (
<SegmentTabButton
key={tab.key}
role="tab"
aria-selected={isActive}
title={tab.labelKr}
active={isActive}
onClick={() => setActiveTab(tab.key)}
>
{tab.label}
</SegmentTabButton>
);
})}
</div>
{activeTab === "pillars" ? <ContentStrategyPillarsTab pillars={data.pillars} /> : null}
{activeTab === "types" ? <ContentStrategyTypesTab rows={data.typeMatrix} /> : null}
{activeTab === "workflow" ? <ContentStrategyWorkflowTab steps={data.workflow} /> : null}
{activeTab === "repurposing" ? (
<ContentStrategyRepurposingTab source={data.repurposingSource} outputs={data.repurposingOutputs} />
) : null}
</div>
);
}

View File

@ -0,0 +1,40 @@
import { Pill } from "@/components/atoms/Pill";
import { Surface } from "@/components/atoms/Surface";
import type { ContentPillar } from "@/features/plan/types/marketingPlan";
type ContentStrategyPillarsTabProps = {
pillars: ContentPillar[];
};
export function ContentStrategyPillarsTab({ pillars }: ContentStrategyPillarsTabProps) {
return (
<div className="grid md:grid-cols-2 gap-6 animate-fade-in-up">
{pillars.map((pillar, i) => (
<Surface
key={pillar.title}
padding="lg"
className={`border-l-4 animate-fade-in-up ${
i === 1 ? "animation-delay-100" : i === 2 ? "animation-delay-200" : i >= 3 ? "animation-delay-300" : ""
}`}
style={{ borderLeftColor: pillar.color }}
>
<h4 className="font-serif headline-20 text-navy-900 mb-2 break-keep">{pillar.title}</h4>
<p className="body-14 text-neutral-70 mb-3 break-keep">{pillar.description}</p>
<Pill className="inline-block bg-neutral-10 text-neutral-80 mb-4">{pillar.relatedUSP}</Pill>
<ul className="space-y-2">
{pillar.exampleTopics.map((topic, j) => (
<li key={j} className="flex items-start gap-2 body-14 text-neutral-80">
<span
className="shrink-0 w-2 h-2 rounded-full mt-2"
style={{ backgroundColor: pillar.color }}
aria-hidden
/>
<span className="break-keep">{topic}</span>
</li>
))}
</ul>
</Surface>
))}
</div>
);
}

View File

@ -0,0 +1,36 @@
import VideoIcon from "@/assets/icons/video.svg?react";
import { Surface } from "@/components/atoms/Surface";
import { UI_PRIMARY_GRADIENT_CLASS } from "@/components/atoms/uiTokens";
import type { RepurposingOutput } from "@/features/plan/types/marketingPlan";
type ContentStrategyRepurposingTabProps = {
source: string;
outputs: RepurposingOutput[];
};
export function ContentStrategyRepurposingTab({ source, outputs }: ContentStrategyRepurposingTabProps) {
return (
<div className="animate-fade-in-up">
<div className={`rounded-2xl p-6 text-white mb-6 ${UI_PRIMARY_GRADIENT_CLASS}`}>
<div className="flex items-center gap-3 min-w-0">
<VideoIcon width={28} height={28} className="text-lavender-200 shrink-0" aria-hidden />
<h4 className="font-serif headline-20 break-keep">{source}</h4>
</div>
</div>
<div className="flex justify-center mb-6">
<div className="w-px h-8 bg-gradient-to-b from-violet-700 to-neutral-30" aria-hidden />
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-3">
{outputs.map((output, i) => (
<Surface key={`${output.format}-${output.channel}-${i}`} radius="xl" padding="sm">
<p className="title-14 text-navy-900 mb-1 break-keep">{output.format}</p>
<p className="label-12 text-neutral-60 mb-1 break-keep">{output.channel}</p>
<p className="label-12 text-neutral-70 break-keep">{output.description}</p>
</Surface>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,41 @@
import { Pill } from "@/components/atoms/Pill";
import type { ContentTypeRow } from "@/features/plan/types/marketingPlan";
import { contentStrategyChannelBadgeClass } from "@/features/plan/ui/contentStrategy/contentStrategyChannelBadgeClass";
type ContentStrategyTypesTabProps = {
rows: ContentTypeRow[];
};
export function ContentStrategyTypesTab({ rows }: ContentStrategyTypesTabProps) {
return (
<div className="rounded-2xl overflow-hidden border border-neutral-20 animate-fade-in-up">
<div className="overflow-x-auto">
<div className="min-w-[640px]">
<div className="grid grid-cols-4 bg-navy-900 text-white">
<div className="px-6 py-4 body-14-medium break-keep">Format</div>
<div className="px-6 py-4 body-14-medium break-keep">Channels</div>
<div className="px-6 py-4 body-14-medium break-keep">Frequency</div>
<div className="px-6 py-4 body-14-medium break-keep">Purpose</div>
</div>
{rows.map((row, i) => (
<div
key={row.format}
className={`grid grid-cols-4 ${i % 2 === 0 ? "bg-white" : "bg-neutral-10"}`}
>
<div className="px-6 py-4 body-14-medium text-navy-900 break-keep">{row.format}</div>
<div className="px-6 py-4 flex flex-wrap gap-2">
{row.channels.map((ch) => (
<Pill key={ch} inlineFlex className={contentStrategyChannelBadgeClass(ch)}>
{ch}
</Pill>
))}
</div>
<div className="px-6 py-4 body-14 text-neutral-80 break-keep">{row.frequency}</div>
<div className="px-6 py-4 body-14 text-neutral-70 break-keep">{row.purpose}</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,45 @@
import ChevronRightIcon from "@/assets/report/chevron-right.svg?react";
import { Pill } from "@/components/atoms/Pill";
import { Surface } from "@/components/atoms/Surface";
import { UI_PRIMARY_GRADIENT_CLASS } from "@/components/atoms/uiTokens";
import type { WorkflowStep } from "@/features/plan/types/marketingPlan";
type ContentStrategyWorkflowTabProps = {
steps: WorkflowStep[];
};
export function ContentStrategyWorkflowTab({ steps }: ContentStrategyWorkflowTabProps) {
return (
<div className="flex md:flex-row flex-col gap-4 items-stretch animate-fade-in-up">
{steps.map((step, i) => (
<div key={step.step} className="flex md:flex-row flex-col items-center gap-4 flex-1 min-w-0">
<Surface className="flex-1 w-full">
<div
className={`w-10 h-10 rounded-full text-white title-14 flex items-center justify-center mb-3 ${UI_PRIMARY_GRADIENT_CLASS}`}
>
{step.step}
</div>
<h4 className="title-14 text-navy-900 mb-1 break-keep">{step.name}</h4>
<p className="body-14 text-neutral-70 mb-3 break-keep">{step.description}</p>
<div className="flex flex-wrap gap-2">
<Pill size="sm" weight="none" className="bg-lavender-100 text-violet-700 font-normal">
{step.owner}
</Pill>
<Pill size="sm" weight="none" className="bg-neutral-10 text-neutral-70 font-normal">
{step.duration}
</Pill>
</div>
</Surface>
{i < steps.length - 1 ? (
<ChevronRightIcon
width={20}
height={20}
className="text-neutral-40 shrink-0 hidden md:block"
aria-hidden
/>
) : null}
</div>
))}
</div>
);
}

View File

@ -0,0 +1,26 @@
/** 채널명 부분 일치로 뱃지 톤 결정 (DEMO `channelColorMap`과 동등한 역할, FE 토큰 위주) */
export function contentStrategyChannelBadgeClass(channel: string): string {
const c = channel.toLowerCase();
if (c.includes("youtube")) {
return "bg-[var(--color-status-critical-bg)] text-[var(--color-status-critical-text)] border border-[var(--color-status-critical-border)]";
}
if (c.includes("instagram")) {
return "bg-[var(--color-marketing-blush)]/40 text-navy-800 border border-neutral-30";
}
if (c.includes("facebook")) {
return "bg-[var(--color-status-info-bg)] text-[var(--color-status-info-text)] border border-[var(--color-status-info-border)]";
}
if (c.includes("blog") || c.includes("블로그") || c.includes("네이버")) {
return "bg-[var(--color-status-good-bg)] text-[var(--color-status-good-text)] border border-[var(--color-status-good-border)]";
}
if (c.includes("tiktok")) {
return "bg-lavender-100 text-violet-700 border border-lavender-300";
}
if (c.includes("카카오") || c.includes("kakao")) {
return "bg-[var(--color-status-warning-bg)] text-[var(--color-status-warning-text)] border border-[var(--color-status-warning-border)]";
}
if (c.includes("홈페이지") || c.includes("website")) {
return "bg-neutral-10 text-neutral-80 border border-neutral-20";
}
return "bg-neutral-10 text-neutral-70 border border-neutral-20";
}

View File

@ -0,0 +1,8 @@
export const CONTENT_STRATEGY_TAB_ITEMS = [
{ key: "pillars", label: "Content Pillars", labelKr: "콘텐츠 필러" },
{ key: "types", label: "Content Types", labelKr: "콘텐츠 유형" },
{ key: "workflow", label: "Production Workflow", labelKr: "제작 워크플로우" },
{ key: "repurposing", label: "Repurposing", labelKr: "콘텐츠 재활용" },
] as const;
export type ContentStrategyTabKey = (typeof CONTENT_STRATEGY_TAB_ITEMS)[number]["key"];

View File

@ -0,0 +1,11 @@
/** DEMO PlanHeader 우측 90 Days 배지와 동일 */
export function PlanHeaderDaysBadge() {
return (
<div className="shrink-0">
<div className="w-32 h-32 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#021341] flex flex-col items-center justify-center shadow-lg">
<span className="text-4xl font-bold text-white leading-none">90</span>
<span className="text-sm text-purple-200 break-keep">Days</span>
</div>
</div>
);
}

View File

@ -0,0 +1,21 @@
/**
* DEMO PlanHeader · (motion FE ).
*/
export function PlanHeaderHeroBlobs() {
return (
<>
<div
className="absolute top-10 left-10 w-72 h-72 rounded-full bg-indigo-200/30 blur-3xl pointer-events-none"
aria-hidden
/>
<div
className="absolute bottom-10 right-10 w-96 h-96 rounded-full bg-pink-200/30 blur-3xl pointer-events-none"
aria-hidden
/>
<div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-80 h-80 rounded-full bg-purple-200/20 blur-3xl pointer-events-none"
aria-hidden
/>
</>
);
}

View File

@ -0,0 +1,32 @@
import { PlanHeaderMetaChips } from "@/features/plan/ui/header/PlanHeaderMetaChips";
type PlanHeaderHeroColumnProps = {
clinicName: string;
clinicNameEn: string;
date: string;
targetUrl: string;
};
/** DEMO `PlanHeader` 좌측 타이포·간격과 동일 */
export function PlanHeaderHeroColumn({
clinicName,
clinicNameEn,
date,
targetUrl,
}: PlanHeaderHeroColumnProps) {
return (
<div className="flex-1 text-center md:text-left">
<p className="text-xs font-semibold text-[#6C5CE7] mb-4 tracking-widest uppercase break-keep">
Marketing Execution Plan
</p>
<h1 className="font-serif text-4xl md:text-5xl font-bold text-[#0A1128] mb-3 break-keep">
{clinicName}
</h1>
<p className="text-xl text-slate-600 mb-8 break-keep">{clinicNameEn}</p>
<PlanHeaderMetaChips date={date} targetUrl={targetUrl} />
</div>
);
}

View File

@ -0,0 +1,24 @@
import CalendarIcon from "@/assets/report/calendar.svg?react";
import GlobeIcon from "@/assets/report/globe.svg?react";
import { PLAN_HEADER_META_CHIP_CLASS } from "@/features/plan/ui/header/planHeaderSectionStyles";
type PlanHeaderMetaChipsProps = {
date: string;
targetUrl: string;
};
/** DEMO: 날짜·URL 모두 `<span>` 칩 (링크·truncate·호버 없음) */
export function PlanHeaderMetaChips({ date, targetUrl }: PlanHeaderMetaChipsProps) {
return (
<div className="flex flex-wrap gap-3 justify-center md:justify-start">
<span className={PLAN_HEADER_META_CHIP_CLASS}>
<CalendarIcon className="text-slate-400 shrink-0" width={14} height={14} aria-hidden />
<span className="break-keep">{date}</span>
</span>
<span className={PLAN_HEADER_META_CHIP_CLASS}>
<GlobeIcon className="text-slate-400 shrink-0" width={14} height={14} aria-hidden />
<span className="break-keep">{targetUrl}</span>
</span>
</div>
);
}

View File

@ -0,0 +1,6 @@
/** `DEMO/src/components/plan/PlanHeader.tsx` 배경·칩 클래스와 동일 */
export const PLAN_HEADER_BG_CLASS =
"bg-[radial-gradient(ellipse_at_top_left,#e0e7ff,transparent_50%),radial-gradient(ellipse_at_bottom_right,#fce7f3,transparent_50%),radial-gradient(ellipse_at_center,#f5f3ff,transparent_60%)]";
export const PLAN_HEADER_META_CHIP_CLASS =
"inline-flex items-center gap-2 rounded-full bg-white/60 backdrop-blur-sm border border-white/40 px-3 py-1 text-sm font-medium text-slate-700";

View File

@ -0,0 +1,333 @@
import { useCallback, useEffect, useRef, useState, type ChangeEvent, type DragEvent } from "react";
import { Pill } from "@/components/atoms/Pill";
import { Surface } from "@/components/atoms/Surface";
import { SegmentTabButton } from "@/features/plan/ui/SegmentTabButton";
type UploadCategory = "all" | "image" | "video" | "text";
interface UploadedAsset {
id: string;
file: File;
category: "image" | "video" | "text";
previewUrl: string | null;
name: string;
size: string;
uploadedAt: Date;
}
function categorize(file: File): "image" | "video" | "text" {
if (file.type.startsWith("image/")) return "image";
if (file.type.startsWith("video/")) return "video";
return "text";
}
function formatSize(bytes: number): string {
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`;
if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;
return `${bytes} B`;
}
function uid() {
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
const categoryConfig: Record<UploadCategory, { label: string }> = {
all: { label: "전체" },
image: { label: "Image" },
video: { label: "Video" },
text: { label: "Text" },
};
const categoryBadge: Record<"image" | "video" | "text", string> = {
image: "bg-lavender-100 text-violet-800 shadow-[2px_3px_6px_rgba(155,138,212,0.12)]",
video: "bg-[var(--color-status-critical-bg)] text-[var(--color-status-critical-text)] shadow-[2px_3px_6px_rgba(212,136,154,0.12)]",
text: "bg-[var(--color-status-warning-bg)] text-[var(--color-status-warning-text)] shadow-[2px_3px_6px_rgba(212,168,114,0.12)]",
};
const ACCEPT_MAP: Record<string, string> = {
"image/*": ".jpg,.jpeg,.png,.gif,.webp,.svg",
"video/*": ".mp4,.mov,.webm,.avi",
"text/*": ".txt,.md,.doc,.docx,.pdf,.csv,.json",
};
const ALL_ACCEPT = Object.values(ACCEPT_MAP).join(",");
function UploadArrowIcon({ className }: { className?: string }) {
return (
<svg className={className} width={28} height={28} viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M12 16V4M12 4L8 8M12 4L16 8"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M4 14V18C4 19.1 4.9 20 6 20H18C19.1 20 20 19.1 20 18V14"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function FileDocIcon({ className }: { className?: string }) {
return (
<svg className={className} width={36} height={36} viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M14 2H6C5.46957 2 4.96086 2.21071 4.58579 2.58579C4.21071 2.96086 4 3.46957 4 4V20C4 20.5304 4.21071 21.0391 4.58579 21.4142C4.96086 21.7893 5.46957 22 6 22H18C18.5304 22 19.0391 21.7893 19.4142 21.4142C19.7893 21.0391 20 20.5304 20 20V8L14 2Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M14 2V8H20" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function VideoBadgeIcon({ className }: { className?: string }) {
return (
<svg className={className} width={10} height={10} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M8 5v14l11-7z" />
</svg>
);
}
export function MyAssetUploadPanel() {
const [assets, setAssets] = useState<UploadedAsset[]>([]);
const [activeFilter, setActiveFilter] = useState<UploadCategory>("all");
const [isDragOver, setIsDragOver] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const assetsRef = useRef(assets);
assetsRef.current = assets;
const processFiles = useCallback((files: FileList | File[]) => {
const newAssets: UploadedAsset[] = Array.from(files).map((file) => {
const cat = categorize(file);
const previewUrl = cat === "image" || cat === "video" ? URL.createObjectURL(file) : null;
return {
id: uid(),
file,
category: cat,
previewUrl,
name: file.name,
size: formatSize(file.size),
uploadedAt: new Date(),
};
});
setAssets((prev) => [...newAssets, ...prev]);
}, []);
useEffect(() => {
return () => {
for (const a of assetsRef.current) {
if (a.previewUrl) URL.revokeObjectURL(a.previewUrl);
}
};
}, []);
const handleDrop = useCallback(
(e: DragEvent) => {
e.preventDefault();
setIsDragOver(false);
if (e.dataTransfer.files.length) processFiles(e.dataTransfer.files);
},
[processFiles],
);
const handleInputChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files?.length) {
processFiles(e.target.files);
e.target.value = "";
}
},
[processFiles],
);
const removeAsset = useCallback((id: string) => {
setAssets((prev) => {
const found = prev.find((a) => a.id === id);
if (found?.previewUrl) URL.revokeObjectURL(found.previewUrl);
return prev.filter((a) => a.id !== id);
});
}, []);
const filtered = activeFilter === "all" ? assets : assets.filter((a) => a.category === activeFilter);
const counts = {
all: assets.length,
image: assets.filter((a) => a.category === "image").length,
video: assets.filter((a) => a.category === "video").length,
text: assets.filter((a) => a.category === "text").length,
};
return (
<div>
<div
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
inputRef.current?.click();
}
}}
onDragOver={(e) => {
e.preventDefault();
setIsDragOver(true);
}}
onDragLeave={() => setIsDragOver(false)}
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
className={`relative rounded-2xl border-2 border-dashed p-10 md:p-14 text-center cursor-pointer transition-all mb-8 ${
isDragOver
? "border-lavender-400 bg-lavender-100/60 scale-[1.01]"
: "border-neutral-30 bg-neutral-10/50 hover:border-lavender-300 hover:bg-lavender-50/40"
}`}
>
<input
ref={inputRef}
type="file"
multiple
accept={ALL_ACCEPT}
onChange={handleInputChange}
className="hidden"
/>
<div className="flex justify-center mb-4">
<div className="w-14 h-14 rounded-2xl bg-lavender-100 flex items-center justify-center shadow-[3px_4px_12px_rgba(155,138,212,0.15)] text-lavender-500">
<UploadArrowIcon />
</div>
</div>
<p className="title-14 text-navy-900 mb-1 break-keep"> </p>
<p className="body-14 text-neutral-60 break-keep">
Image, Video, Text (JPG, PNG, MP4, MOV, TXT, PDF, DOC )
</p>
<div className="flex justify-center gap-2 mt-4 flex-wrap">
{(["image", "video", "text"] as const).map((cat) => (
<Pill key={cat} className={categoryBadge[cat]}>
{cat === "image" ? "Image" : cat === "video" ? "Video" : "Text"}
</Pill>
))}
</div>
</div>
{assets.length > 0 ? (
<>
<div className="flex flex-wrap items-center gap-2 mb-6" role="tablist" aria-label="업로드 유형 필터">
{(Object.keys(categoryConfig) as UploadCategory[]).map((key) => {
const isActive = activeFilter === key;
return (
<SegmentTabButton
key={key}
role="tab"
aria-selected={isActive}
active={isActive}
inactiveShadow
onClick={() => setActiveFilter(key)}
>
{categoryConfig[key].label}
<span className="ml-2 opacity-70">{counts[key]}</span>
</SegmentTabButton>
);
})}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filtered.map((asset) => (
<Surface
key={asset.id}
padding="none"
interactive
overflowHidden
className="group"
>
<div className="relative h-40 bg-neutral-10 flex items-center justify-center overflow-hidden">
{asset.category === "image" && asset.previewUrl ? (
<img src={asset.previewUrl} alt={asset.name} className="w-full h-full object-cover" />
) : null}
{asset.category === "video" && asset.previewUrl ? (
<video
src={asset.previewUrl}
className="w-full h-full object-cover"
muted
playsInline
onMouseOver={(e) => (e.target as HTMLVideoElement).play()}
onMouseOut={(e) => {
const v = e.target as HTMLVideoElement;
v.pause();
v.currentTime = 0;
}}
/>
) : null}
{asset.category === "text" ? (
<div className="flex flex-col items-center gap-2">
<FileDocIcon className="text-[var(--color-status-warning-text)]" />
<span className="label-12 text-neutral-60 font-medium">
{asset.name.split(".").pop()?.toUpperCase()}
</span>
</div>
) : null}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removeAsset(asset.id);
}}
className="absolute top-2 right-2 w-7 h-7 rounded-full bg-white/90 backdrop-blur-sm border border-neutral-20 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-sm hover:bg-[var(--color-status-critical-bg)] cursor-pointer"
aria-label="업로드 항목 제거"
>
<svg
width={12}
height={12}
viewBox="0 0 12 12"
fill="none"
className="text-[var(--color-status-critical-text)]"
aria-hidden
>
<path
d="M2 2L10 10M10 2L2 10"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
<Pill
weight="semibold"
className={`absolute top-2 left-2 ${categoryBadge[asset.category]}`}
>
{asset.category === "image"
? "Image"
: asset.category === "video"
? "Video"
: "Text"}
</Pill>
{asset.category === "video" ? (
<div className="absolute bottom-2 right-2 flex items-center gap-1 bg-black/50 backdrop-blur-sm rounded-full px-2 py-1">
<VideoBadgeIcon className="text-white" />
<span className="label-12 text-white font-medium">Video</span>
</div>
) : null}
</div>
<div className="p-4">
<p className="body-14 font-medium text-navy-900 truncate mb-1">{asset.name}</p>
<p className="label-12 text-neutral-60">{asset.size}</p>
</div>
</Surface>
))}
</div>
</>
) : null}
</div>
);
}

View File

@ -0,0 +1,40 @@
import { useParams } from "react-router-dom";
import { useReportSubNav } from "@/features/report/hooks/useReportSubNav";
import { ReportChannelsSection } from "@/features/report/ui/ReportChannelsSection";
import { ReportClinicSection } from "@/features/report/ui/ReportClinicSection";
import { ReportDiagnosisSection } from "@/features/report/ui/ReportDiagnosisSection";
import { ReportFacebookSection } from "@/features/report/ui/ReportFacebookSection";
import { ReportInstagramSection } from "@/features/report/ui/ReportInstagramSection";
import { ReportKpiSection } from "@/features/report/ui/ReportKpiSection";
import { ReportOtherChannelsSection } from "@/features/report/ui/ReportOtherChannelsSection";
import { ReportOverviewSection } from "@/features/report/ui/ReportOverviewSection";
import { ReportRoadmapSection } from "@/features/report/ui/ReportRoadmapSection";
import { ReportTransformationSection } from "@/features/report/ui/ReportTransformationSection";
import { ReportYouTubeSection } from "@/features/report/ui/ReportYouTubeSection";
export function ReportPage() {
const { id } = useParams<{ id: string }>();
useReportSubNav();
return (
<div data-report-content>
<p className="body-14 text-neutral-60 px-6 max-w-7xl mx-auto py-2">
Report ID: <span className="text-navy-900 font-medium">{id}</span>
</p>
<div className="divide-y divide-neutral-20">
<ReportOverviewSection />
<ReportClinicSection />
<ReportChannelsSection />
<ReportYouTubeSection />
<ReportInstagramSection />
<ReportFacebookSection />
<ReportOtherChannelsSection />
<ReportDiagnosisSection />
<ReportTransformationSection />
<ReportRoadmapSection />
<ReportKpiSection />
</div>
</div>
);
}

View File

@ -5,6 +5,9 @@ import ChevronRightIcon from "@/assets/home/chevron-right.svg?react";
/** 리포트 라우트가 `report/:id`일 때 점/플로우에서 이동할 기본 경로 */ /** 리포트 라우트가 `report/:id`일 때 점/플로우에서 이동할 기본 경로 */
const DEFAULT_REPORT_NAV_PATH = "/report/demo"; const DEFAULT_REPORT_NAV_PATH = "/report/demo";
/** 플랜 라우트가 `plan/:id`일 때 점/플로우에서 이동할 기본 경로 */
const DEFAULT_PLAN_NAV_PATH = "/plan/demo";
type FlowStep = { type FlowStep = {
id: string; id: string;
label: string; label: string;
@ -21,7 +24,12 @@ const PAGE_FLOW: FlowStep[] = [
navigatePath: DEFAULT_REPORT_NAV_PATH, navigatePath: DEFAULT_REPORT_NAV_PATH,
isActive: (p) => p === "/report" || p.startsWith("/report/"), isActive: (p) => p === "/report" || p.startsWith("/report/"),
}, },
{ id: "plan", label: "콘텐츠 기획", navigatePath: "/plan", isActive: (p) => p === "/plan" }, {
id: "plan",
label: "콘텐츠 기획",
navigatePath: DEFAULT_PLAN_NAV_PATH,
isActive: (p) => p === "/plan" || p.startsWith("/plan/"),
},
{ id: "studio", label: "콘텐츠 제작", navigatePath: "/studio", isActive: (p) => p === "/studio" }, { id: "studio", label: "콘텐츠 제작", navigatePath: "/studio", isActive: (p) => p === "/studio" },
{ id: "channels", label: "채널 연결", navigatePath: "/channels", isActive: (p) => p === "/channels" }, { id: "channels", label: "채널 연결", navigatePath: "/channels", isActive: (p) => p === "/channels" },
{ {

5
src/pages/Plan.tsx Normal file
View File

@ -0,0 +1,5 @@
import { MarketingPlanPage } from "@/features/plan/ui/MarketingPlanPage";
export function PlanPage() {
return <MarketingPlanPage />;
}

View File

@ -1,40 +1,5 @@
import { useParams } from "react-router-dom"; import { ReportPage as ReportFeaturePage } from "@/features/report/ui/ReportPage";
import { useReportSubNav } from "@/features/report/hooks/useReportSubNav";
import { ReportChannelsSection } from "@/features/report/ui/ReportChannelsSection";
import { ReportClinicSection } from "@/features/report/ui/ReportClinicSection";
import { ReportDiagnosisSection } from "@/features/report/ui/ReportDiagnosisSection";
import { ReportFacebookSection } from "@/features/report/ui/ReportFacebookSection";
import { ReportInstagramSection } from "@/features/report/ui/ReportInstagramSection";
import { ReportKpiSection } from "@/features/report/ui/ReportKpiSection";
import { ReportOtherChannelsSection } from "@/features/report/ui/ReportOtherChannelsSection";
import { ReportOverviewSection } from "@/features/report/ui/ReportOverviewSection";
import { ReportRoadmapSection } from "@/features/report/ui/ReportRoadmapSection";
import { ReportTransformationSection } from "@/features/report/ui/ReportTransformationSection";
import { ReportYouTubeSection } from "@/features/report/ui/ReportYouTubeSection";
export function ReportPage() { export function ReportPage() {
const { id } = useParams<{ id: string }>(); return <ReportFeaturePage />;
useReportSubNav();
return (
<div data-report-content>
<p className="body-14 text-neutral-60 px-6 max-w-7xl mx-auto py-2">
Report ID: <span className="text-navy-900 font-medium">{id}</span>
</p>
<div className="divide-y divide-neutral-20">
<ReportOverviewSection />
<ReportClinicSection />
<ReportChannelsSection />
<ReportYouTubeSection />
<ReportInstagramSection />
<ReportFacebookSection />
<ReportOtherChannelsSection />
<ReportDiagnosisSection />
<ReportTransformationSection />
<ReportRoadmapSection />
<ReportKpiSection />
</div>
</div>
);
} }