From 92fadede974424cc4b7d3fb357e3413a28be06fa Mon Sep 17 00:00:00 2001 From: minheon Date: Wed, 1 Apr 2026 17:24:12 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20plan=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- eslint.config.js | 9 + src/app/App.tsx | 2 + src/app/index.css | 21 +- src/components/atoms/Button.tsx | 33 ++ src/components/atoms/Pill.tsx | 37 ++ src/components/atoms/Surface.tsx | 60 ++++ src/components/atoms/index.ts | 4 + src/components/atoms/uiTokens.ts | 2 + src/components/brand/BrandConsistencyMap.tsx | 20 +- src/components/index.ts | 9 + src/components/section/PageSection.tsx | 23 +- src/features/plan/constants/mock_plan.ts | 272 ++++++++++++++ src/features/plan/constants/plan_sections.ts | 14 + .../plan/hooks/useCurrentMarketingPlan.ts | 11 + src/features/plan/hooks/useMarketingPlan.ts | 20 ++ src/features/plan/hooks/usePlanSubNav.ts | 52 +++ src/features/plan/types/marketingPlan.ts | 176 +++++++++ src/features/plan/ui/MarketingPlanPage.tsx | 44 +++ .../plan/ui/PlanAssetCollectionSection.tsx | 22 ++ .../plan/ui/PlanBrandingGuideSection.tsx | 22 ++ .../plan/ui/PlanChannelStrategySection.tsx | 23 ++ .../plan/ui/PlanContentCalendarSection.tsx | 23 ++ .../plan/ui/PlanContentStrategySection.tsx | 22 ++ src/features/plan/ui/PlanCtaSection.tsx | 118 +++++++ src/features/plan/ui/PlanHeaderSection.tsx | 34 ++ .../plan/ui/PlanMyAssetUploadSection.tsx | 19 + src/features/plan/ui/SegmentTabButton.tsx | 40 +++ .../assetCollection/AssetCollectionPanel.tsx | 122 +++++++ .../assetCollectionBadgeClass.ts | 75 ++++ .../assetCollectionFilterTabs.ts | 10 + .../plan/ui/branding/BrandingChannelIcon.tsx | 35 ++ .../ui/branding/BrandingChannelRulesTab.tsx | 52 +++ .../plan/ui/branding/BrandingGuidePanel.tsx | 45 +++ .../plan/ui/branding/BrandingToneVoiceTab.tsx | 69 ++++ .../ui/branding/BrandingVisualIdentityTab.tsx | 92 +++++ .../ui/branding/brandingChannelStatusClass.ts | 27 ++ .../plan/ui/branding/brandingTabItems.ts | 8 + .../channelStrategy/ChannelStrategyGrid.tsx | 86 +++++ .../channelStrategyPillClass.ts | 14 + .../contentCalendar/ContentCalendarPanel.tsx | 110 ++++++ .../ContentCalendarTypeIcon.tsx | 25 ++ .../calendarContentTypeVisual.ts | 69 ++++ .../contentStrategy/ContentStrategyPanel.tsx | 48 +++ .../ContentStrategyPillarsTab.tsx | 40 +++ .../ContentStrategyRepurposingTab.tsx | 36 ++ .../ContentStrategyTypesTab.tsx | 41 +++ .../ContentStrategyWorkflowTab.tsx | 45 +++ .../contentStrategyChannelBadgeClass.ts | 26 ++ .../contentStrategyTabItems.ts | 8 + .../plan/ui/header/PlanHeaderDaysBadge.tsx | 11 + .../plan/ui/header/PlanHeaderHeroBlobs.tsx | 21 ++ .../plan/ui/header/PlanHeaderHeroColumn.tsx | 32 ++ .../plan/ui/header/PlanHeaderMetaChips.tsx | 24 ++ .../plan/ui/header/planHeaderSectionStyles.ts | 6 + .../plan/ui/myAssets/MyAssetUploadPanel.tsx | 333 ++++++++++++++++++ src/features/report/ui/ReportPage.tsx | 40 +++ src/layouts/PageNavigator.tsx | 10 +- src/pages/Plan.tsx | 5 + src/pages/Report.tsx | 39 +- 59 files changed, 2687 insertions(+), 49 deletions(-) create mode 100644 src/components/atoms/Button.tsx create mode 100644 src/components/atoms/Pill.tsx create mode 100644 src/components/atoms/Surface.tsx create mode 100644 src/components/atoms/index.ts create mode 100644 src/components/atoms/uiTokens.ts create mode 100644 src/features/plan/constants/mock_plan.ts create mode 100644 src/features/plan/constants/plan_sections.ts create mode 100644 src/features/plan/hooks/useCurrentMarketingPlan.ts create mode 100644 src/features/plan/hooks/useMarketingPlan.ts create mode 100644 src/features/plan/hooks/usePlanSubNav.ts create mode 100644 src/features/plan/types/marketingPlan.ts create mode 100644 src/features/plan/ui/MarketingPlanPage.tsx create mode 100644 src/features/plan/ui/PlanAssetCollectionSection.tsx create mode 100644 src/features/plan/ui/PlanBrandingGuideSection.tsx create mode 100644 src/features/plan/ui/PlanChannelStrategySection.tsx create mode 100644 src/features/plan/ui/PlanContentCalendarSection.tsx create mode 100644 src/features/plan/ui/PlanContentStrategySection.tsx create mode 100644 src/features/plan/ui/PlanCtaSection.tsx create mode 100644 src/features/plan/ui/PlanHeaderSection.tsx create mode 100644 src/features/plan/ui/PlanMyAssetUploadSection.tsx create mode 100644 src/features/plan/ui/SegmentTabButton.tsx create mode 100644 src/features/plan/ui/assetCollection/AssetCollectionPanel.tsx create mode 100644 src/features/plan/ui/assetCollection/assetCollectionBadgeClass.ts create mode 100644 src/features/plan/ui/assetCollection/assetCollectionFilterTabs.ts create mode 100644 src/features/plan/ui/branding/BrandingChannelIcon.tsx create mode 100644 src/features/plan/ui/branding/BrandingChannelRulesTab.tsx create mode 100644 src/features/plan/ui/branding/BrandingGuidePanel.tsx create mode 100644 src/features/plan/ui/branding/BrandingToneVoiceTab.tsx create mode 100644 src/features/plan/ui/branding/BrandingVisualIdentityTab.tsx create mode 100644 src/features/plan/ui/branding/brandingChannelStatusClass.ts create mode 100644 src/features/plan/ui/branding/brandingTabItems.ts create mode 100644 src/features/plan/ui/channelStrategy/ChannelStrategyGrid.tsx create mode 100644 src/features/plan/ui/channelStrategy/channelStrategyPillClass.ts create mode 100644 src/features/plan/ui/contentCalendar/ContentCalendarPanel.tsx create mode 100644 src/features/plan/ui/contentCalendar/ContentCalendarTypeIcon.tsx create mode 100644 src/features/plan/ui/contentCalendar/calendarContentTypeVisual.ts create mode 100644 src/features/plan/ui/contentStrategy/ContentStrategyPanel.tsx create mode 100644 src/features/plan/ui/contentStrategy/ContentStrategyPillarsTab.tsx create mode 100644 src/features/plan/ui/contentStrategy/ContentStrategyRepurposingTab.tsx create mode 100644 src/features/plan/ui/contentStrategy/ContentStrategyTypesTab.tsx create mode 100644 src/features/plan/ui/contentStrategy/ContentStrategyWorkflowTab.tsx create mode 100644 src/features/plan/ui/contentStrategy/contentStrategyChannelBadgeClass.ts create mode 100644 src/features/plan/ui/contentStrategy/contentStrategyTabItems.ts create mode 100644 src/features/plan/ui/header/PlanHeaderDaysBadge.tsx create mode 100644 src/features/plan/ui/header/PlanHeaderHeroBlobs.tsx create mode 100644 src/features/plan/ui/header/PlanHeaderHeroColumn.tsx create mode 100644 src/features/plan/ui/header/PlanHeaderMetaChips.tsx create mode 100644 src/features/plan/ui/header/planHeaderSectionStyles.ts create mode 100644 src/features/plan/ui/myAssets/MyAssetUploadPanel.tsx create mode 100644 src/features/report/ui/ReportPage.tsx create mode 100644 src/pages/Plan.tsx diff --git a/eslint.config.js b/eslint.config.js index 5e6b472..6119520 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -19,5 +19,14 @@ export default defineConfig([ ecmaVersion: 2020, globals: globals.browser, }, + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + }, }, ]) diff --git a/src/app/App.tsx b/src/app/App.tsx index b9ff8e7..4dff973 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -5,6 +5,7 @@ import MainSubNavLayout from "@/layouts/MainSubNavLayout"; // pages import { Home } from "@/pages/Home"; +import { PlanPage } from "@/pages/Plan"; import { ReportPage } from "@/pages/Report"; function App() { @@ -16,6 +17,7 @@ function App() { }> } /> + } /> ) diff --git a/src/app/index.css b/src/app/index.css index 9aa6294..70e04c3 100644 --- a/src/app/index.css +++ b/src/app/index.css @@ -130,12 +130,27 @@ /* ─── Utility Classes ─────────────────────────────────────────────── */ -/* 라이트 섹션 헤딩 그라디언트 텍스트 */ +/* 라이트 섹션 헤딩 그라디언트 텍스트 + - base의 h2 { color: navy }와 함께 쓸 때 color를 반드시 투명으로 두어야 그라데이션이 보임 + - background 단축 속성은 clip을 리셋할 수 있어 background-image만 사용 */ .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-text-fill-color: transparent; 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; } /* 반투명 글래스 카드 — 랜딩 섹션 */ diff --git a/src/components/atoms/Button.tsx b/src/components/atoms/Button.tsx new file mode 100644 index 0000000..521415c --- /dev/null +++ b/src/components/atoms/Button.tsx @@ -0,0 +1,33 @@ +import type { ButtonHTMLAttributes, ReactNode } from "react"; +import { UI_PRIMARY_GRADIENT_CLASS } from "@/components/atoms/uiTokens"; + +export type ButtonProps = Omit, "type"> & { + children: ReactNode; + className?: string; + variant?: "primary" | "outline"; +}; + +export function Button({ + children, + className = "", + variant = "outline", + ...rest +}: ButtonProps) { + return ( + + ); +} diff --git a/src/components/atoms/Pill.tsx b/src/components/atoms/Pill.tsx new file mode 100644 index 0000000..61c3c9f --- /dev/null +++ b/src/components/atoms/Pill.tsx @@ -0,0 +1,37 @@ +import type { HTMLAttributes, ReactNode } from "react"; + +export type PillProps = Omit, "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, WebKit 글자 채움은 안쪽만 solid-text-paint */} + {children} + + ); +} diff --git a/src/components/atoms/Surface.tsx b/src/components/atoms/Surface.tsx new file mode 100644 index 0000000..fc64795 --- /dev/null +++ b/src/components/atoms/Surface.tsx @@ -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, "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 ( +
+ {children} +
+ ); +} diff --git a/src/components/atoms/index.ts b/src/components/atoms/index.ts new file mode 100644 index 0000000..c0df762 --- /dev/null +++ b/src/components/atoms/index.ts @@ -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"; diff --git a/src/components/atoms/uiTokens.ts b/src/components/atoms/uiTokens.ts new file mode 100644 index 0000000..1a66646 --- /dev/null +++ b/src/components/atoms/uiTokens.ts @@ -0,0 +1,2 @@ +/** 앱 전역에서 쓸 수 있는 브랜드 그라데이션 배경 클래스 (버튼·블록 배경 등) */ +export const UI_PRIMARY_GRADIENT_CLASS = "bg-gradient-to-r from-violet-700 to-navy-950"; diff --git a/src/components/brand/BrandConsistencyMap.tsx b/src/components/brand/BrandConsistencyMap.tsx index 3e9fdde..cd5c237 100644 --- a/src/components/brand/BrandConsistencyMap.tsx +++ b/src/components/brand/BrandConsistencyMap.tsx @@ -8,17 +8,29 @@ import type { BrandInconsistency } from "@/types/brandConsistency"; export type BrandConsistencyMapProps = { inconsistencies: BrandInconsistency[]; className?: string; + /** false면 상단 제목·설명을 숨김 (플랜 브랜딩 가이드 탭 등) */ + showHeading?: boolean; }; -export function BrandConsistencyMap({ inconsistencies, className = "" }: BrandConsistencyMapProps) { +export function BrandConsistencyMap({ + inconsistencies, + className = "", + showHeading = true, +}: BrandConsistencyMapProps) { const [expanded, setExpanded] = useState(0); if (inconsistencies.length === 0) return null; return ( -
-

Brand Consistency Map

-

전 채널 브랜드 일관성 분석

+
+ {showHeading ? ( + <> +

Brand Consistency Map

+

전 채널 브랜드 일관성 분석

+ + ) : null}
{inconsistencies.map((item, i) => { diff --git a/src/components/index.ts b/src/components/index.ts index c3a2d13..e083438 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -14,3 +14,12 @@ export { HighlightPanel, type HighlightPanelProps } from "@/components/panel/Hig export { ScoreRing, type ScoreRingProps } from "@/components/rating/ScoreRing"; export { StarRatingDisplay, type StarRatingDisplayProps } from "@/components/rating/StarRatingDisplay"; 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"; diff --git a/src/components/section/PageSection.tsx b/src/components/section/PageSection.tsx index 84dd7c8..1da0134 100644 --- a/src/components/section/PageSection.tsx +++ b/src/components/section/PageSection.tsx @@ -6,6 +6,8 @@ export type PageSectionProps = { subtitle?: string; dark?: boolean; className?: string; + /** false면 섹션 입장용 `animate-fade-in-up` 미적용 (DEMO에 없는 모션을 쓰지 않을 때) */ + animateEnter?: boolean; children: ReactNode; }; @@ -15,13 +17,15 @@ export function PageSection({ subtitle, dark = false, className = "", + animateEnter = true, children, }: PageSectionProps) { return (
) : null} -
+

{title}

{subtitle ? ( -

+

{subtitle}

) : null} diff --git a/src/features/plan/constants/mock_plan.ts b/src/features/plan/constants/mock_plan.ts new file mode 100644 index 0000000..a1376f1 --- /dev/null +++ b/src/features/plan/constants/mock_plan.ts @@ -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'] }, + ], + }, +}; diff --git a/src/features/plan/constants/plan_sections.ts b/src/features/plan/constants/plan_sections.ts new file mode 100644 index 0000000..8175e6a --- /dev/null +++ b/src/features/plan/constants/plan_sections.ts @@ -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"]; diff --git a/src/features/plan/hooks/useCurrentMarketingPlan.ts b/src/features/plan/hooks/useCurrentMarketingPlan.ts new file mode 100644 index 0000000..916290b --- /dev/null +++ b/src/features/plan/hooks/useCurrentMarketingPlan.ts @@ -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); +} diff --git a/src/features/plan/hooks/useMarketingPlan.ts b/src/features/plan/hooks/useMarketingPlan.ts new file mode 100644 index 0000000..5db5b48 --- /dev/null +++ b/src/features/plan/hooks/useMarketingPlan.ts @@ -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 }), + [], + ); +} diff --git a/src/features/plan/hooks/usePlanSubNav.ts b/src/features/plan/hooks/usePlanSubNav.ts new file mode 100644 index 0000000..d085a90 --- /dev/null +++ b/src/features/plan/hooks/usePlanSubNav.ts @@ -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(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]); +} diff --git a/src/features/plan/types/marketingPlan.ts b/src/features/plan/types/marketingPlan.ts new file mode 100644 index 0000000..e0802d8 --- /dev/null +++ b/src/features/plan/types/marketingPlan.ts @@ -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; +} diff --git a/src/features/plan/ui/MarketingPlanPage.tsx b/src/features/plan/ui/MarketingPlanPage.tsx new file mode 100644 index 0000000..095f469 --- /dev/null +++ b/src/features/plan/ui/MarketingPlanPage.tsx @@ -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 ( +
+
+

+ 오류가 발생했습니다 +

+

+ {error ?? "마케팅 플랜을 찾을 수 없습니다."} +

+
+
+ ); + } + + return ( +
+ + + + + + + + +
+ ); +} diff --git a/src/features/plan/ui/PlanAssetCollectionSection.tsx b/src/features/plan/ui/PlanAssetCollectionSection.tsx new file mode 100644 index 0000000..bc5d832 --- /dev/null +++ b/src/features/plan/ui/PlanAssetCollectionSection.tsx @@ -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 ( + + + + ); +} diff --git a/src/features/plan/ui/PlanBrandingGuideSection.tsx b/src/features/plan/ui/PlanBrandingGuideSection.tsx new file mode 100644 index 0000000..44d0225 --- /dev/null +++ b/src/features/plan/ui/PlanBrandingGuideSection.tsx @@ -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 ( + + + + ); +} diff --git a/src/features/plan/ui/PlanChannelStrategySection.tsx b/src/features/plan/ui/PlanChannelStrategySection.tsx new file mode 100644 index 0000000..4efbe57 --- /dev/null +++ b/src/features/plan/ui/PlanChannelStrategySection.tsx @@ -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 ( + + + + ); +} diff --git a/src/features/plan/ui/PlanContentCalendarSection.tsx b/src/features/plan/ui/PlanContentCalendarSection.tsx new file mode 100644 index 0000000..a67a72e --- /dev/null +++ b/src/features/plan/ui/PlanContentCalendarSection.tsx @@ -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 ( + + + + ); +} diff --git a/src/features/plan/ui/PlanContentStrategySection.tsx b/src/features/plan/ui/PlanContentStrategySection.tsx new file mode 100644 index 0000000..9e20863 --- /dev/null +++ b/src/features/plan/ui/PlanContentStrategySection.tsx @@ -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 ( + + + + ); +} diff --git a/src/features/plan/ui/PlanCtaSection.tsx b/src/features/plan/ui/PlanCtaSection.tsx new file mode 100644 index 0000000..0923bbc --- /dev/null +++ b/src/features/plan/ui/PlanCtaSection.tsx @@ -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 ( + + + + + + + ); +} + +/** `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 ( +
+
+
+
+
+ +
+
+ +

+ 콘텐츠 제작을 시작하세요 +

+ +

+ INFINITH가 브랜딩부터 콘텐츠 제작, 채널 배포까지 자동화합니다. +

+ +
+ + + +
+
+
+
+ ); +} diff --git a/src/features/plan/ui/PlanHeaderSection.tsx b/src/features/plan/ui/PlanHeaderSection.tsx new file mode 100644 index 0000000..a09ac68 --- /dev/null +++ b/src/features/plan/ui/PlanHeaderSection.tsx @@ -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 ( +
+ + +
+
+ + +
+
+
+ ); +} diff --git a/src/features/plan/ui/PlanMyAssetUploadSection.tsx b/src/features/plan/ui/PlanMyAssetUploadSection.tsx new file mode 100644 index 0000000..a9e1983 --- /dev/null +++ b/src/features/plan/ui/PlanMyAssetUploadSection.tsx @@ -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 ( +
+ + + +
+ ); +} diff --git a/src/features/plan/ui/SegmentTabButton.tsx b/src/features/plan/ui/SegmentTabButton.tsx new file mode 100644 index 0000000..49240e2 --- /dev/null +++ b/src/features/plan/ui/SegmentTabButton.tsx @@ -0,0 +1,40 @@ +import type { ButtonHTMLAttributes, ReactNode } from "react"; +import { Button } from "@/components/atoms/Button"; + +export type SegmentTabButtonProps = Omit< + ButtonHTMLAttributes, + "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 ( + + ); +} diff --git a/src/features/plan/ui/assetCollection/AssetCollectionPanel.tsx b/src/features/plan/ui/assetCollection/AssetCollectionPanel.tsx new file mode 100644 index 0000000..eb49762 --- /dev/null +++ b/src/features/plan/ui/assetCollection/AssetCollectionPanel.tsx @@ -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("all"); + + const filteredAssets = + activeFilter === "all" ? data.assets : data.assets.filter((a) => a.source === activeFilter); + + return ( +
+
+ {ASSET_COLLECTION_FILTER_TABS.map((tab) => { + const isActive = activeFilter === tab.key; + return ( + setActiveFilter(tab.key)} + > + {tab.label} + + ); + })} +
+ +
+ {filteredAssets.map((asset) => { + const statusInfo = assetStatusConfig(asset.status); + return ( + +
+ {asset.sourceLabel} + {assetTypeDisplayLabel(asset.type)} + {statusInfo.label} +
+ +

{asset.title}

+

{asset.description}

+ + {asset.repurposingSuggestions.length > 0 ? ( +
+

+ Repurposing → +

+
+ {asset.repurposingSuggestions.map((suggestion, j) => ( + + {suggestion} + + ))} +
+
+ ) : null} +
+ ); + })} +
+ + {data.youtubeRepurpose.length > 0 ? ( +
+

+ YouTube Top Videos for Repurposing +

+
+ {data.youtubeRepurpose.map((video) => ( + +
+ +

{video.title}

+
+
+ + {formatYoutubeViews(video.views)} views + + + {video.type} + +
+

Repurpose As:

+
+ {video.repurposeAs.map((suggestion, j) => ( + + {suggestion} + + ))} +
+
+ ))} +
+
+ ) : null} +
+ ); +} diff --git a/src/features/plan/ui/assetCollection/assetCollectionBadgeClass.ts b/src/features/plan/ui/assetCollection/assetCollectionBadgeClass.ts new file mode 100644 index 0000000..bfdd8fa --- /dev/null +++ b/src/features/plan/ui/assetCollection/assetCollectionBadgeClass.ts @@ -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); +} diff --git a/src/features/plan/ui/assetCollection/assetCollectionFilterTabs.ts b/src/features/plan/ui/assetCollection/assetCollectionFilterTabs.ts new file mode 100644 index 0000000..88a10f5 --- /dev/null +++ b/src/features/plan/ui/assetCollection/assetCollectionFilterTabs.ts @@ -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"]; diff --git a/src/features/plan/ui/branding/BrandingChannelIcon.tsx b/src/features/plan/ui/branding/BrandingChannelIcon.tsx new file mode 100644 index 0000000..36f9ceb --- /dev/null +++ b/src/features/plan/ui/branding/BrandingChannelIcon.tsx @@ -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>; + +const CHANNEL_ICON_MAP: Record = { + 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 ; +} diff --git a/src/features/plan/ui/branding/BrandingChannelRulesTab.tsx b/src/features/plan/ui/branding/BrandingChannelRulesTab.tsx new file mode 100644 index 0000000..3fea650 --- /dev/null +++ b/src/features/plan/ui/branding/BrandingChannelRulesTab.tsx @@ -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 ( +
+
+ {channels.map((ch) => ( + +
+
+ +
+

{ch.channel}

+ + {brandingChannelStatusLabel(ch.currentStatus)} + +
+ +
+
+

Profile Photo

+

{ch.profilePhoto}

+
+
+

Banner Spec

+

{ch.bannerSpec}

+
+
+

Bio Template

+
+

{ch.bioTemplate}

+
+
+
+
+ ))} +
+
+ ); +} diff --git a/src/features/plan/ui/branding/BrandingGuidePanel.tsx b/src/features/plan/ui/branding/BrandingGuidePanel.tsx new file mode 100644 index 0000000..c350514 --- /dev/null +++ b/src/features/plan/ui/branding/BrandingGuidePanel.tsx @@ -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("visual"); + + return ( +
+
+ {BRANDING_GUIDE_TAB_ITEMS.map((tab) => { + const isActive = activeTab === tab.key; + return ( + setActiveTab(tab.key)} + > + {tab.label} + + ); + })} +
+ + {activeTab === "visual" ? : null} + {activeTab === "tone" ? : null} + {activeTab === "channels" ? : null} + {activeTab === "consistency" ? ( + + ) : null} +
+ ); +} diff --git a/src/features/plan/ui/branding/BrandingToneVoiceTab.tsx b/src/features/plan/ui/branding/BrandingToneVoiceTab.tsx new file mode 100644 index 0000000..944e0e3 --- /dev/null +++ b/src/features/plan/ui/branding/BrandingToneVoiceTab.tsx @@ -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 ( +
+
+

Personality

+
+ {tone.personality.map((trait) => ( + + {trait} + + ))} +
+
+ +
+

Communication Style

+
+

{tone.communicationStyle}

+
+
+ +
+
+

+ + DO +

+
+ {tone.doExamples.map((example, i) => ( +
+

{example}

+
+ ))} +
+
+
+

+ + DON'T +

+
+ {tone.dontExamples.map((example, i) => ( +
+

{example}

+
+ ))} +
+
+
+
+ ); +} diff --git a/src/features/plan/ui/branding/BrandingVisualIdentityTab.tsx b/src/features/plan/ui/branding/BrandingVisualIdentityTab.tsx new file mode 100644 index 0000000..46abe02 --- /dev/null +++ b/src/features/plan/ui/branding/BrandingVisualIdentityTab.tsx @@ -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 ( +
+
+

Color Palette

+
+ {data.colors.map((swatch, i) => ( + +
+
+

{swatch.hex}

+

{swatch.name}

+

{swatch.usage}

+
+ + ))} +
+
+ +
+

Typography

+
+ {data.fonts.map((spec) => ( + +

{spec.family}

+

+ {spec.sampleText} +

+

+ {spec.weight} + {" · "} + {spec.usage} +

+
+ ))} +
+
+ +
+

Logo Rules

+
+ {data.logoRules.map((rule) => ( +
+
+ {rule.correct ? ( + + ) : ( + + )} +
+

{rule.rule}

+

{rule.description}

+
+
+
+ ))} +
+
+
+ ); +} diff --git a/src/features/plan/ui/branding/brandingChannelStatusClass.ts b/src/features/plan/ui/branding/brandingChannelStatusClass.ts new file mode 100644 index 0000000..26a12e5 --- /dev/null +++ b/src/features/plan/ui/branding/brandingChannelStatusClass.ts @@ -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; + } +} diff --git a/src/features/plan/ui/branding/brandingTabItems.ts b/src/features/plan/ui/branding/brandingTabItems.ts new file mode 100644 index 0000000..be3dad8 --- /dev/null +++ b/src/features/plan/ui/branding/brandingTabItems.ts @@ -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"]; diff --git a/src/features/plan/ui/channelStrategy/ChannelStrategyGrid.tsx b/src/features/plan/ui/channelStrategy/ChannelStrategyGrid.tsx new file mode 100644 index 0000000..0c0c577 --- /dev/null +++ b/src/features/plan/ui/channelStrategy/ChannelStrategyGrid.tsx @@ -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 ( + +
+
+ +
+

{ch.channelName}

+ + {ch.priority} + +
+ +
+ + {ch.currentStatus} + + + → + + + {ch.targetGoal} + +
+ +
+ {ch.contentTypes.map((type) => ( + + {type} + + ))} +
+ +
+ +

{ch.postingFrequency}

+
+ +

{ch.tone}

+ +
    + {ch.formatGuidelines.map((guideline, i) => ( +
  • + + {guideline} +
  • + ))} +
+
+ ); +} + +export function ChannelStrategyGrid({ channels }: ChannelStrategyGridProps) { + return ( +
+ {channels.map((ch, index) => ( + + ))} +
+ ); +} diff --git a/src/features/plan/ui/channelStrategy/channelStrategyPillClass.ts b/src/features/plan/ui/channelStrategy/channelStrategyPillClass.ts new file mode 100644 index 0000000..b00c388 --- /dev/null +++ b/src/features/plan/ui/channelStrategy/channelStrategyPillClass.ts @@ -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"; + } +} diff --git a/src/features/plan/ui/contentCalendar/ContentCalendarPanel.tsx b/src/features/plan/ui/contentCalendar/ContentCalendarPanel.tsx new file mode 100644 index 0000000..df6729d --- /dev/null +++ b/src/features/plan/ui/contentCalendar/ContentCalendarPanel.tsx @@ -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 ( +
+
+ {data.monthlySummary.map((item) => { + const v = calendarContentTypeVisual(item.type); + return ( + +
+ + {item.label} +
+ {item.count} +
+ ); + })} +
+ + {data.weeks.map((week) => { + const dayCells = buildDayCells(week.entries); + return ( + +

{week.label}

+
+ {CALENDAR_DAY_HEADERS.map((day) => ( +
+ {day} +
+ ))} + {dayCells.map((entries, dayIdx) => ( +
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 ( +
+
+ +
+

{entry.title}

+
+ ); + })} +
+ ))} +
+
+ ); + })} + +
+ {legendTypes.map((type) => { + const v = calendarContentTypeVisual(type); + return ( + + {CALENDAR_CONTENT_TYPE_LABELS[type]} + + ); + })} +
+
+ ); +} diff --git a/src/features/plan/ui/contentCalendar/ContentCalendarTypeIcon.tsx b/src/features/plan/ui/contentCalendar/ContentCalendarTypeIcon.tsx new file mode 100644 index 0000000..6af8bb5 --- /dev/null +++ b/src/features/plan/ui/contentCalendar/ContentCalendarTypeIcon.tsx @@ -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>; + +const MAP: Record = { + 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 ; +} diff --git a/src/features/plan/ui/contentCalendar/calendarContentTypeVisual.ts b/src/features/plan/ui/contentCalendar/calendarContentTypeVisual.ts new file mode 100644 index 0000000..68ef2a3 --- /dev/null +++ b/src/features/plan/ui/contentCalendar/calendarContentTypeVisual.ts @@ -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 = { + video: "Video", + blog: "Blog", + social: "Social", + ad: "Ad", +}; + +export const CALENDAR_DAY_HEADERS = ["월", "화", "수", "목", "금", "토", "일"] as const; diff --git a/src/features/plan/ui/contentStrategy/ContentStrategyPanel.tsx b/src/features/plan/ui/contentStrategy/ContentStrategyPanel.tsx new file mode 100644 index 0000000..a591be8 --- /dev/null +++ b/src/features/plan/ui/contentStrategy/ContentStrategyPanel.tsx @@ -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("pillars"); + + return ( +
+
+ {CONTENT_STRATEGY_TAB_ITEMS.map((tab) => { + const isActive = activeTab === tab.key; + return ( + setActiveTab(tab.key)} + > + {tab.label} + + ); + })} +
+ + {activeTab === "pillars" ? : null} + {activeTab === "types" ? : null} + {activeTab === "workflow" ? : null} + {activeTab === "repurposing" ? ( + + ) : null} +
+ ); +} diff --git a/src/features/plan/ui/contentStrategy/ContentStrategyPillarsTab.tsx b/src/features/plan/ui/contentStrategy/ContentStrategyPillarsTab.tsx new file mode 100644 index 0000000..674d926 --- /dev/null +++ b/src/features/plan/ui/contentStrategy/ContentStrategyPillarsTab.tsx @@ -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 ( +
+ {pillars.map((pillar, i) => ( + = 3 ? "animation-delay-300" : "" + }`} + style={{ borderLeftColor: pillar.color }} + > +

{pillar.title}

+

{pillar.description}

+ {pillar.relatedUSP} +
    + {pillar.exampleTopics.map((topic, j) => ( +
  • + + {topic} +
  • + ))} +
+
+ ))} +
+ ); +} diff --git a/src/features/plan/ui/contentStrategy/ContentStrategyRepurposingTab.tsx b/src/features/plan/ui/contentStrategy/ContentStrategyRepurposingTab.tsx new file mode 100644 index 0000000..388036a --- /dev/null +++ b/src/features/plan/ui/contentStrategy/ContentStrategyRepurposingTab.tsx @@ -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 ( +
+
+
+ +

{source}

+
+
+ +
+
+
+ +
+ {outputs.map((output, i) => ( + +

{output.format}

+

{output.channel}

+

{output.description}

+
+ ))} +
+
+ ); +} diff --git a/src/features/plan/ui/contentStrategy/ContentStrategyTypesTab.tsx b/src/features/plan/ui/contentStrategy/ContentStrategyTypesTab.tsx new file mode 100644 index 0000000..2d29a22 --- /dev/null +++ b/src/features/plan/ui/contentStrategy/ContentStrategyTypesTab.tsx @@ -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 ( +
+
+
+
+
Format
+
Channels
+
Frequency
+
Purpose
+
+ {rows.map((row, i) => ( +
+
{row.format}
+
+ {row.channels.map((ch) => ( + + {ch} + + ))} +
+
{row.frequency}
+
{row.purpose}
+
+ ))} +
+
+
+ ); +} diff --git a/src/features/plan/ui/contentStrategy/ContentStrategyWorkflowTab.tsx b/src/features/plan/ui/contentStrategy/ContentStrategyWorkflowTab.tsx new file mode 100644 index 0000000..24b9da8 --- /dev/null +++ b/src/features/plan/ui/contentStrategy/ContentStrategyWorkflowTab.tsx @@ -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 ( +
+ {steps.map((step, i) => ( +
+ +
+ {step.step} +
+

{step.name}

+

{step.description}

+
+ + {step.owner} + + + {step.duration} + +
+
+ {i < steps.length - 1 ? ( + + ) : null} +
+ ))} +
+ ); +} diff --git a/src/features/plan/ui/contentStrategy/contentStrategyChannelBadgeClass.ts b/src/features/plan/ui/contentStrategy/contentStrategyChannelBadgeClass.ts new file mode 100644 index 0000000..3a2fe40 --- /dev/null +++ b/src/features/plan/ui/contentStrategy/contentStrategyChannelBadgeClass.ts @@ -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"; +} diff --git a/src/features/plan/ui/contentStrategy/contentStrategyTabItems.ts b/src/features/plan/ui/contentStrategy/contentStrategyTabItems.ts new file mode 100644 index 0000000..9df3fcf --- /dev/null +++ b/src/features/plan/ui/contentStrategy/contentStrategyTabItems.ts @@ -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"]; diff --git a/src/features/plan/ui/header/PlanHeaderDaysBadge.tsx b/src/features/plan/ui/header/PlanHeaderDaysBadge.tsx new file mode 100644 index 0000000..f58c5a2 --- /dev/null +++ b/src/features/plan/ui/header/PlanHeaderDaysBadge.tsx @@ -0,0 +1,11 @@ +/** DEMO PlanHeader 우측 90 Days 배지와 동일 */ +export function PlanHeaderDaysBadge() { + return ( +
+
+ 90 + Days +
+
+ ); +} diff --git a/src/features/plan/ui/header/PlanHeaderHeroBlobs.tsx b/src/features/plan/ui/header/PlanHeaderHeroBlobs.tsx new file mode 100644 index 0000000..4e68cec --- /dev/null +++ b/src/features/plan/ui/header/PlanHeaderHeroBlobs.tsx @@ -0,0 +1,21 @@ +/** + * DEMO PlanHeader의 블롭 위치·색상과 동일 (motion 이동은 FE에서 미구현 — 정적 레이어만 유지). + */ +export function PlanHeaderHeroBlobs() { + return ( + <> +
+
+
+ + ); +} diff --git a/src/features/plan/ui/header/PlanHeaderHeroColumn.tsx b/src/features/plan/ui/header/PlanHeaderHeroColumn.tsx new file mode 100644 index 0000000..e962141 --- /dev/null +++ b/src/features/plan/ui/header/PlanHeaderHeroColumn.tsx @@ -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 ( +
+

+ Marketing Execution Plan +

+ +

+ {clinicName} +

+ +

{clinicNameEn}

+ + +
+ ); +} diff --git a/src/features/plan/ui/header/PlanHeaderMetaChips.tsx b/src/features/plan/ui/header/PlanHeaderMetaChips.tsx new file mode 100644 index 0000000..e9d542a --- /dev/null +++ b/src/features/plan/ui/header/PlanHeaderMetaChips.tsx @@ -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 모두 `` 칩 (링크·truncate·호버 없음) */ +export function PlanHeaderMetaChips({ date, targetUrl }: PlanHeaderMetaChipsProps) { + return ( +
+ + + {date} + + + + {targetUrl} + +
+ ); +} diff --git a/src/features/plan/ui/header/planHeaderSectionStyles.ts b/src/features/plan/ui/header/planHeaderSectionStyles.ts new file mode 100644 index 0000000..3975d06 --- /dev/null +++ b/src/features/plan/ui/header/planHeaderSectionStyles.ts @@ -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"; diff --git a/src/features/plan/ui/myAssets/MyAssetUploadPanel.tsx b/src/features/plan/ui/myAssets/MyAssetUploadPanel.tsx new file mode 100644 index 0000000..569bd06 --- /dev/null +++ b/src/features/plan/ui/myAssets/MyAssetUploadPanel.tsx @@ -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 = { + 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 = { + "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 ( + + + + + ); +} + +function FileDocIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + +function VideoBadgeIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +export function MyAssetUploadPanel() { + const [assets, setAssets] = useState([]); + const [activeFilter, setActiveFilter] = useState("all"); + const [isDragOver, setIsDragOver] = useState(false); + const inputRef = useRef(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) => { + 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 ( +
+
{ + 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" + }`} + > + + +
+
+ +
+
+ +

파일을 드래그하거나 클릭하여 업로드

+

+ Image, Video, Text 파일 지원 (JPG, PNG, MP4, MOV, TXT, PDF, DOC 등) +

+ +
+ {(["image", "video", "text"] as const).map((cat) => ( + + {cat === "image" ? "Image" : cat === "video" ? "Video" : "Text"} + + ))} +
+
+ + {assets.length > 0 ? ( + <> +
+ {(Object.keys(categoryConfig) as UploadCategory[]).map((key) => { + const isActive = activeFilter === key; + return ( + setActiveFilter(key)} + > + {categoryConfig[key].label} + {counts[key]} + + ); + })} +
+ +
+ {filtered.map((asset) => ( + +
+ {asset.category === "image" && asset.previewUrl ? ( + {asset.name} + ) : null} + {asset.category === "video" && asset.previewUrl ? ( +
+ +
+

{asset.name}

+

{asset.size}

+
+
+ ))} +
+ + ) : null} +
+ ); +} diff --git a/src/features/report/ui/ReportPage.tsx b/src/features/report/ui/ReportPage.tsx new file mode 100644 index 0000000..c4e7891 --- /dev/null +++ b/src/features/report/ui/ReportPage.tsx @@ -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 ( +
+

+ Report ID: {id} +

+ +
+ + + + + + + + + + + +
+
+ ); +} diff --git a/src/layouts/PageNavigator.tsx b/src/layouts/PageNavigator.tsx index a28a217..4364820 100644 --- a/src/layouts/PageNavigator.tsx +++ b/src/layouts/PageNavigator.tsx @@ -5,6 +5,9 @@ import ChevronRightIcon from "@/assets/home/chevron-right.svg?react"; /** 리포트 라우트가 `report/:id`일 때 점/플로우에서 이동할 기본 경로 */ const DEFAULT_REPORT_NAV_PATH = "/report/demo"; +/** 플랜 라우트가 `plan/:id`일 때 점/플로우에서 이동할 기본 경로 */ +const DEFAULT_PLAN_NAV_PATH = "/plan/demo"; + type FlowStep = { id: string; label: string; @@ -21,7 +24,12 @@ const PAGE_FLOW: FlowStep[] = [ navigatePath: DEFAULT_REPORT_NAV_PATH, 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: "channels", label: "채널 연결", navigatePath: "/channels", isActive: (p) => p === "/channels" }, { diff --git a/src/pages/Plan.tsx b/src/pages/Plan.tsx new file mode 100644 index 0000000..18f204d --- /dev/null +++ b/src/pages/Plan.tsx @@ -0,0 +1,5 @@ +import { MarketingPlanPage } from "@/features/plan/ui/MarketingPlanPage"; + +export function PlanPage() { + return ; +} diff --git a/src/pages/Report.tsx b/src/pages/Report.tsx index c4e7891..512dfd3 100644 --- a/src/pages/Report.tsx +++ b/src/pages/Report.tsx @@ -1,40 +1,5 @@ -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"; +import { ReportPage as ReportFeaturePage } from "@/features/report/ui/ReportPage"; export function ReportPage() { - const { id } = useParams<{ id: string }>(); - useReportSubNav(); - - return ( -
-

- Report ID: {id} -

- -
- - - - - - - - - - - -
-
- ); + return ; } -- 2.40.1