From 92fadede974424cc4b7d3fb357e3413a28be06fa Mon Sep 17 00:00:00 2001 From: minheon Date: Wed, 1 Apr 2026 17:24:12 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[feat]=20plan=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=20=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 ; } From 45f6a9f6abfbc85317cd34f7703e05fd3e9ddb2f Mon Sep 17 00:00:00 2001 From: minheon Date: Thu, 2 Apr 2026 10:55:09 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[fix]=20=EB=94=94=EB=A0=89=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=20=EA=B5=AC=EC=A1=B0=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=EB=AA=85=EC=B9=AD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 33 +++++++++----- src/app/providers/index.tsx | 0 src/components/card/TopVideoCard.tsx | 2 +- src/components/index.ts | 3 -- src/components/rating/StarRatingDisplay.tsx | 2 +- .../cta_contents.ts => content/cta.ts} | 0 .../hero_contents.ts => content/hero.ts} | 0 .../modules.ts} | 0 .../problem.ts} | 0 .../process.ts} | 0 .../solution.ts} | 0 .../useCases.ts} | 0 src/features/home/ui/CTASection.tsx | 2 +- src/features/home/ui/HeroSection.tsx | 2 +- src/features/home/ui/ProblemSection.tsx | 2 +- src/features/home/ui/SolutionSection.tsx | 2 +- src/features/home/ui/SystemSection.tsx | 2 +- src/features/home/ui/UseCaseSection.tsx | 2 +- .../home/ui/process/AgdpEngineDiagram.tsx | 2 +- .../home/ui/process/AgdpOrbitNode.tsx | 4 +- .../home/ui/system/CoreModuleCard.tsx | 2 +- .../planSections.ts} | 0 src/features/plan/hooks/useMarketingPlan.ts | 2 +- src/features/plan/hooks/usePlanSubNav.ts | 2 +- .../{constants/mock_plan.ts => mocks/plan.ts} | 0 src/features/plan/ui/MarketingPlanPage.tsx | 44 ------------------- src/features/plan/ui/PlanHeaderSection.tsx | 2 +- .../assetCollection/AssetCollectionPanel.tsx | 2 +- ...lass.ts => assetCollectionBadgeClasses.ts} | 0 .../ui/branding/BrandingChannelRulesTab.tsx | 2 +- .../plan/ui/branding/BrandingGuidePanel.tsx | 2 +- ...ass.ts => brandingChannelStatusClasses.ts} | 0 .../{brandingTabItems.ts => brandingTabs.ts} | 0 .../channelStrategy/ChannelStrategyGrid.tsx | 2 +- ...Class.ts => channelStrategyPillClasses.ts} | 0 .../contentCalendar/ContentCalendarPanel.tsx | 2 +- ...isual.ts => calendarContentTypeClasses.ts} | 0 .../contentStrategy/ContentStrategyPanel.tsx | 2 +- .../ContentStrategyTypesTab.tsx | 2 +- ... => contentStrategyChannelBadgeClasses.ts} | 0 ...tegyTabItems.ts => contentStrategyTabs.ts} | 0 .../plan/ui/header/PlanHeaderMetaChips.tsx | 2 +- ...nStyles.ts => planHeaderSectionClasses.ts} | 0 .../reportSections.ts} | 0 src/features/report/hooks/useReportSubNav.ts | 2 +- .../channelScores.ts} | 0 .../clinicSnapshot.ts} | 0 .../facebookAudit.ts} | 0 .../instagramAudit.ts} | 0 .../{constants/mock_kpi.ts => mocks/kpi.ts} | 0 .../otherChannels.ts} | 2 +- .../problemDiagnosis.ts} | 0 .../reportOverview.ts} | 0 .../mock_roadmap.ts => mocks/roadmap.ts} | 0 .../transformation.ts} | 0 .../youtubeAudit.ts} | 0 .../report}/types/otherChannels.ts | 0 .../report/ui/ReportChannelsSection.tsx | 2 +- .../report/ui/ReportClinicSection.tsx | 2 +- .../report/ui/ReportDiagnosisSection.tsx | 2 +- .../report/ui/ReportFacebookSection.tsx | 4 +- .../report/ui/ReportInstagramSection.tsx | 4 +- src/features/report/ui/ReportKpiSection.tsx | 2 +- .../report/ui/ReportOtherChannelsSection.tsx | 4 +- .../report/ui/ReportOverviewSection.tsx | 4 +- src/features/report/ui/ReportPage.tsx | 40 ----------------- .../report/ui/ReportRoadmapSection.tsx | 2 +- .../report/ui/ReportTransformationSection.tsx | 2 +- .../report/ui/ReportYouTubeSection.tsx | 4 +- .../ui/clinic/clinicSnapshotStatRows.tsx | 2 +- .../report/ui}/diagnosis/DiagnosisRow.tsx | 0 .../ui/diagnosis/ProblemDiagnosisCard.tsx | 2 +- ...erityDotClass.ts => severityDotClasses.ts} | 0 .../report/ui/facebook/FacebookPageCard.tsx | 6 +-- ...{langBadgeClass.ts => langBadgeClasses.ts} | 0 .../ui/instagram/InstagramAccountCard.tsx | 6 +-- ...{langBadgeClass.ts => langBadgeClasses.ts} | 0 .../ui/otherChannels}/OtherChannelRow.tsx | 4 +- .../ui/otherChannels/OtherChannelsList.tsx | 4 +- .../otherChannels/WebsiteTechAuditBlock.tsx | 2 +- .../report/ui/overview/OverviewMetaChips.tsx | 2 +- ...ionStyles.ts => overviewSectionClasses.ts} | 0 .../ui/transformation}/ComparisonRow.tsx | 0 .../NewChannelProposalsTable.tsx | 2 +- .../TransformationTabbedView.tsx | 2 +- ...yClass.ts => newChannelPriorityClasses.ts} | 0 .../ui/youtube/YouTubeChannelInfoCard.tsx | 2 +- .../report/ui/youtube/YouTubeMetricsGrid.tsx | 2 +- src/layouts/PageNavigator.tsx | 6 +-- src/pages/Plan.tsx | 43 +++++++++++++++++- src/pages/Report.tsx | 33 +++++++++++++- src/services/index.ts | 2 + src/store/index.ts | 2 + src/{lib => utils}/formatNumber.ts | 0 src/{lib => utils}/safeUrl.ts | 0 95 files changed, 160 insertions(+), 164 deletions(-) delete mode 100644 src/app/providers/index.tsx rename src/features/home/{constants/cta_contents.ts => content/cta.ts} (100%) rename src/features/home/{constants/hero_contents.ts => content/hero.ts} (100%) rename src/features/home/{constants/modules_contents.ts => content/modules.ts} (100%) rename src/features/home/{constants/problem_contents.ts => content/problem.ts} (100%) rename src/features/home/{constants/process_contents.ts => content/process.ts} (100%) rename src/features/home/{constants/solution_contents.ts => content/solution.ts} (100%) rename src/features/home/{constants/use_cases_contents.ts => content/useCases.ts} (100%) rename src/features/plan/{constants/plan_sections.ts => config/planSections.ts} (100%) rename src/features/plan/{constants/mock_plan.ts => mocks/plan.ts} (100%) delete mode 100644 src/features/plan/ui/MarketingPlanPage.tsx rename src/features/plan/ui/assetCollection/{assetCollectionBadgeClass.ts => assetCollectionBadgeClasses.ts} (100%) rename src/features/plan/ui/branding/{brandingChannelStatusClass.ts => brandingChannelStatusClasses.ts} (100%) rename src/features/plan/ui/branding/{brandingTabItems.ts => brandingTabs.ts} (100%) rename src/features/plan/ui/channelStrategy/{channelStrategyPillClass.ts => channelStrategyPillClasses.ts} (100%) rename src/features/plan/ui/contentCalendar/{calendarContentTypeVisual.ts => calendarContentTypeClasses.ts} (100%) rename src/features/plan/ui/contentStrategy/{contentStrategyChannelBadgeClass.ts => contentStrategyChannelBadgeClasses.ts} (100%) rename src/features/plan/ui/contentStrategy/{contentStrategyTabItems.ts => contentStrategyTabs.ts} (100%) rename src/features/plan/ui/header/{planHeaderSectionStyles.ts => planHeaderSectionClasses.ts} (100%) rename src/features/report/{constants/report_sections.ts => config/reportSections.ts} (100%) rename src/features/report/{constants/mock_channel_scores.ts => mocks/channelScores.ts} (100%) rename src/features/report/{constants/mock_clinic_snapshot.ts => mocks/clinicSnapshot.ts} (100%) rename src/features/report/{constants/mock_facebook_audit.ts => mocks/facebookAudit.ts} (100%) rename src/features/report/{constants/mock_instagram_audit.ts => mocks/instagramAudit.ts} (100%) rename src/features/report/{constants/mock_kpi.ts => mocks/kpi.ts} (100%) rename src/features/report/{constants/mock_other_channels.ts => mocks/otherChannels.ts} (95%) rename src/features/report/{constants/mock_problem_diagnosis.ts => mocks/problemDiagnosis.ts} (100%) rename src/features/report/{constants/mock_report_overview.ts => mocks/reportOverview.ts} (100%) rename src/features/report/{constants/mock_roadmap.ts => mocks/roadmap.ts} (100%) rename src/features/report/{constants/mock_transformation.ts => mocks/transformation.ts} (100%) rename src/features/report/{constants/mock_youtube_audit.ts => mocks/youtubeAudit.ts} (100%) rename src/{ => features/report}/types/otherChannels.ts (100%) delete mode 100644 src/features/report/ui/ReportPage.tsx rename src/{components => features/report/ui}/diagnosis/DiagnosisRow.tsx (100%) rename src/features/report/ui/diagnosis/{severityDotClass.ts => severityDotClasses.ts} (100%) rename src/features/report/ui/facebook/{langBadgeClass.ts => langBadgeClasses.ts} (100%) rename src/features/report/ui/instagram/{langBadgeClass.ts => langBadgeClasses.ts} (100%) rename src/{components/channel => features/report/ui/otherChannels}/OtherChannelRow.tsx (94%) rename src/features/report/ui/overview/{overviewSectionStyles.ts => overviewSectionClasses.ts} (100%) rename src/{components/compare => features/report/ui/transformation}/ComparisonRow.tsx (100%) rename src/features/report/ui/transformation/{newChannelPriorityClass.ts => newChannelPriorityClasses.ts} (100%) create mode 100644 src/services/index.ts create mode 100644 src/store/index.ts rename src/{lib => utils}/formatNumber.ts (100%) rename src/{lib => utils}/safeUrl.ts (100%) diff --git a/README.md b/README.md index c206def..5d4b642 100644 --- a/README.md +++ b/README.md @@ -10,25 +10,36 @@ ## 디렉토리 구조 -디렉토리 구조는 다음을 따를 예정입니다. ```bash src/ ├── app/ # 애플리케이션 진입점 및 전역 설정 (Router, Providers, 글로벌 스타일) -├── assets/ # 정적 파일 (이미지, 폰트, 로티 애니메이션 등) -├── components/ # 도메인에 종속되지 않는 공통 UI 컴포넌트 (버튼, 모달, 디자인 시스템) +│ └── providers/ # React Context Provider 모음 (QueryProvider 등) +├── assets/ # 정적 파일 (이미지, 폰트, SVG 아이콘 등) +├── components/ # 도메인에 종속되지 않는 공통 UI 컴포넌트 (버튼, 카드, 디자인 시스템) ├── features/ # 핵심 비즈니스 로직 및 도메인 영역 (이 구조의 핵심) -│ ├── auth/ # 특정 도메인 (예: 인증) -│ │ ├── api/ # 해당 도메인 전용 API 통신 함수 +│ ├── home/ # 홈(랜딩) 도메인 +│ │ ├── content/ # 해당 도메인 전용 UI 텍스트·카피 (정적 콘텐츠) │ │ ├── hooks/ # 해당 도메인 전용 커스텀 훅 -│ │ ├── store/ # 해당 도메인 전용 상태 (Zustand 등) +│ │ └── ui/ # 해당 도메인 전용 UI 컴포넌트 +│ ├── report/ # 리포트 도메인 +│ │ ├── config/ # 섹션 ID·레이블 등 UI 설정값 +│ │ ├── hooks/ # 해당 도메인 전용 커스텀 훅 +│ │ ├── mocks/ # API 연동 전 임시 목업 데이터 │ │ ├── types/ # 해당 도메인 전용 타입 정의 │ │ └── ui/ # 해당 도메인 전용 UI 컴포넌트 -├── hooks/ # 전역에서 사용하는 공통 훅 (useClickOutside 등) -├── layouts/ # 페이지 레이아웃 (GNB, Sidebar, 풋터 등) -├── pages/ # 라우팅과 1:1 매칭되는 페이지 진입점 (여기서는 features의 컴포넌트만 조립) +│ └── plan/ # 마케팅 플랜 도메인 (report와 동일한 구조) +│ ├── config/ +│ ├── hooks/ +│ ├── mocks/ +│ ├── types/ +│ └── ui/ +├── hooks/ # 전역에서 사용하는 공통 훅 (useInView 등) +├── layouts/ # 페이지 레이아웃 (GNB, SubNav, Footer 등) +├── pages/ # 라우팅과 1:1 매칭되는 페이지 진입점 (features의 컴포넌트만 조립) ├── services/ # 공통 API 클라이언트 설정 (Axios 인스턴스, 인터셉터 등) ├── store/ # 전역 상태 관리 (사용자 세션, 테마 등) -└── utils/ # 공통 유틸리티 함수 (날짜 포맷팅, 정규식 등) +├── types/ # 여러 도메인에서 공유하는 공통 타입 정의 +└── utils/ # 공통 유틸리티 함수 (숫자 포맷팅, URL 처리 등) ``` ## 시작하기 @@ -41,4 +52,4 @@ npm install ### 실행 ```bash npm run dev -``` \ No newline at end of file +``` diff --git a/src/app/providers/index.tsx b/src/app/providers/index.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/src/components/card/TopVideoCard.tsx b/src/components/card/TopVideoCard.tsx index 790624f..7af811e 100644 --- a/src/components/card/TopVideoCard.tsx +++ b/src/components/card/TopVideoCard.tsx @@ -1,6 +1,6 @@ import type { CSSProperties } from "react"; import EyeIcon from "@/assets/icons/eye.svg?react"; -import { formatCompactNumber } from "@/lib/formatNumber"; +import { formatCompactNumber } from "@/utils/formatNumber"; export type TopVideoCardProps = { title: string; diff --git a/src/components/index.ts b/src/components/index.ts index e083438..f4d7f9d 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,6 +1,4 @@ -export { ComparisonRow, type ComparisonRowProps } from "@/components/compare/ComparisonRow"; export { BrandConsistencyMap, type BrandConsistencyMapProps } from "@/components/brand/BrandConsistencyMap"; -export { OtherChannelRow, type OtherChannelRowProps } from "@/components/channel/OtherChannelRow"; export { SeverityBadge, type SeverityBadgeProps } from "@/components/badge/SeverityBadge"; export { ChannelScoreCard, type ChannelScoreCardProps } from "@/components/card/ChannelScoreCard"; export { InfoStatCard, type InfoStatCardProps } from "@/components/card/InfoStatCard"; @@ -8,7 +6,6 @@ export { MetricCard, type MetricCardProps } from "@/components/card/MetricCard"; export { PixelInstallCard, type PixelInstallCardProps } from "@/components/card/PixelInstallCard"; export { TopVideoCard, type TopVideoCardProps } from "@/components/card/TopVideoCard"; export { TagChipList, type TagChipListProps } from "@/components/chip/TagChipList"; -export { DiagnosisRow, type DiagnosisRowProps } from "@/components/diagnosis/DiagnosisRow"; export { ConsolidationCallout, type ConsolidationCalloutProps } from "@/components/panel/ConsolidationCallout"; export { HighlightPanel, type HighlightPanelProps } from "@/components/panel/HighlightPanel"; export { ScoreRing, type ScoreRingProps } from "@/components/rating/ScoreRing"; diff --git a/src/components/rating/StarRatingDisplay.tsx b/src/components/rating/StarRatingDisplay.tsx index 5faade3..be71c1d 100644 --- a/src/components/rating/StarRatingDisplay.tsx +++ b/src/components/rating/StarRatingDisplay.tsx @@ -1,5 +1,5 @@ import StarIcon from "@/assets/icons/star.svg?react"; -import { formatCompactNumber } from "@/lib/formatNumber"; +import { formatCompactNumber } from "@/utils/formatNumber"; export type StarRatingDisplayProps = { rating: number; diff --git a/src/features/home/constants/cta_contents.ts b/src/features/home/content/cta.ts similarity index 100% rename from src/features/home/constants/cta_contents.ts rename to src/features/home/content/cta.ts diff --git a/src/features/home/constants/hero_contents.ts b/src/features/home/content/hero.ts similarity index 100% rename from src/features/home/constants/hero_contents.ts rename to src/features/home/content/hero.ts diff --git a/src/features/home/constants/modules_contents.ts b/src/features/home/content/modules.ts similarity index 100% rename from src/features/home/constants/modules_contents.ts rename to src/features/home/content/modules.ts diff --git a/src/features/home/constants/problem_contents.ts b/src/features/home/content/problem.ts similarity index 100% rename from src/features/home/constants/problem_contents.ts rename to src/features/home/content/problem.ts diff --git a/src/features/home/constants/process_contents.ts b/src/features/home/content/process.ts similarity index 100% rename from src/features/home/constants/process_contents.ts rename to src/features/home/content/process.ts diff --git a/src/features/home/constants/solution_contents.ts b/src/features/home/content/solution.ts similarity index 100% rename from src/features/home/constants/solution_contents.ts rename to src/features/home/content/solution.ts diff --git a/src/features/home/constants/use_cases_contents.ts b/src/features/home/content/useCases.ts similarity index 100% rename from src/features/home/constants/use_cases_contents.ts rename to src/features/home/content/useCases.ts diff --git a/src/features/home/ui/CTASection.tsx b/src/features/home/ui/CTASection.tsx index 1f125ff..728c631 100644 --- a/src/features/home/ui/CTASection.tsx +++ b/src/features/home/ui/CTASection.tsx @@ -5,7 +5,7 @@ import { CTA_FOOTNOTE, CTA_HEADLINE, CTA_URL_PLACEHOLDER, -} from "@/features/home/constants/cta_contents"; +} from "@/features/home/content/cta"; import { useAnalyze } from "@/features/home/hooks/useAnalyze"; import { useInView } from "@/hooks/useInView"; diff --git a/src/features/home/ui/HeroSection.tsx b/src/features/home/ui/HeroSection.tsx index 752acef..3e6cb2e 100644 --- a/src/features/home/ui/HeroSection.tsx +++ b/src/features/home/ui/HeroSection.tsx @@ -7,7 +7,7 @@ import { HERO_LEAD_EN, HERO_LEAD_KO, HERO_URL_PLACEHOLDER, -} from "@/features/home/constants/hero_contents"; +} from "@/features/home/content/hero"; import { useAnalyze } from "@/features/home/hooks/useAnalyze"; export function HeroSection() { diff --git a/src/features/home/ui/ProblemSection.tsx b/src/features/home/ui/ProblemSection.tsx index f8e86e0..7df2d8b 100644 --- a/src/features/home/ui/ProblemSection.tsx +++ b/src/features/home/ui/ProblemSection.tsx @@ -1,4 +1,4 @@ -import { PROBLEM_CARDS, PROBLEM_CARD_STAGGER } from "@/features/home/constants/problem_contents"; +import { PROBLEM_CARDS, PROBLEM_CARD_STAGGER } from "@/features/home/content/problem"; import { useInView } from "@/hooks/useInView"; export function ProblemSection() { diff --git a/src/features/home/ui/SolutionSection.tsx b/src/features/home/ui/SolutionSection.tsx index 476dbeb..5117eaa 100644 --- a/src/features/home/ui/SolutionSection.tsx +++ b/src/features/home/ui/SolutionSection.tsx @@ -1,4 +1,4 @@ -import { SOLUTION_CARDS } from "@/features/home/constants/solution_contents"; +import { SOLUTION_CARDS } from "@/features/home/content/solution"; import { useInView } from "@/hooks/useInView"; export function SolutionSection() { diff --git a/src/features/home/ui/SystemSection.tsx b/src/features/home/ui/SystemSection.tsx index 2f027cb..c676294 100644 --- a/src/features/home/ui/SystemSection.tsx +++ b/src/features/home/ui/SystemSection.tsx @@ -1,4 +1,4 @@ -import { CORE_MODULES, MODULE_CARD_STAGGER } from "@/features/home/constants/modules_contents"; +import { CORE_MODULES, MODULE_CARD_STAGGER } from "@/features/home/content/modules"; import { useInView } from "@/hooks/useInView"; import { CoreModuleCard } from "./system/CoreModuleCard"; import { CoreModulesCenterHeading } from "./system/CoreModulesCenterHeading"; diff --git a/src/features/home/ui/UseCaseSection.tsx b/src/features/home/ui/UseCaseSection.tsx index 3dd69f6..ab3d32c 100644 --- a/src/features/home/ui/UseCaseSection.tsx +++ b/src/features/home/ui/UseCaseSection.tsx @@ -1,5 +1,5 @@ import CheckCircleIcon from "@/assets/home/check-circle.svg?react"; -import { USE_CASE_CARDS } from "@/features/home/constants/use_cases_contents"; +import { USE_CASE_CARDS } from "@/features/home/content/useCases"; import { useInView } from "@/hooks/useInView"; export function UseCaseSection() { diff --git a/src/features/home/ui/process/AgdpEngineDiagram.tsx b/src/features/home/ui/process/AgdpEngineDiagram.tsx index 01abb4d..6432a50 100644 --- a/src/features/home/ui/process/AgdpEngineDiagram.tsx +++ b/src/features/home/ui/process/AgdpEngineDiagram.tsx @@ -1,4 +1,4 @@ -import { AGDP_NODES } from "@/features/home/constants/process_contents"; +import { AGDP_NODES } from "@/features/home/content/process"; import { AgdpOrbitNode } from "./AgdpOrbitNode"; import { AgdpRewardPathLabel } from "./AgdpRewardPathLabel"; diff --git a/src/features/home/ui/process/AgdpOrbitNode.tsx b/src/features/home/ui/process/AgdpOrbitNode.tsx index eb139d0..5703f6f 100644 --- a/src/features/home/ui/process/AgdpOrbitNode.tsx +++ b/src/features/home/ui/process/AgdpOrbitNode.tsx @@ -1,5 +1,5 @@ -import type { AgdpNodeDef } from "@/features/home/constants/process_contents"; -import { AGDP_SLOT_WRAPPER_CLASS } from "@/features/home/constants/process_contents"; +import type { AgdpNodeDef } from "@/features/home/content/process"; +import { AGDP_SLOT_WRAPPER_CLASS } from "@/features/home/content/process"; type Props = { node: AgdpNodeDef }; diff --git a/src/features/home/ui/system/CoreModuleCard.tsx b/src/features/home/ui/system/CoreModuleCard.tsx index c0dc52a..198131f 100644 --- a/src/features/home/ui/system/CoreModuleCard.tsx +++ b/src/features/home/ui/system/CoreModuleCard.tsx @@ -1,4 +1,4 @@ -import type { CoreModule } from "@/features/home/constants/modules_contents"; +import type { CoreModule } from "@/features/home/content/modules"; type Props = { mod: CoreModule; diff --git a/src/features/plan/constants/plan_sections.ts b/src/features/plan/config/planSections.ts similarity index 100% rename from src/features/plan/constants/plan_sections.ts rename to src/features/plan/config/planSections.ts diff --git a/src/features/plan/hooks/useMarketingPlan.ts b/src/features/plan/hooks/useMarketingPlan.ts index 5db5b48..35c0b5e 100644 --- a/src/features/plan/hooks/useMarketingPlan.ts +++ b/src/features/plan/hooks/useMarketingPlan.ts @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { MOCK_PLAN } from "@/features/plan/constants/mock_plan"; +import { MOCK_PLAN } from "@/features/plan/mocks/plan"; import type { MarketingPlan } from "@/features/plan/types/marketingPlan"; type UseMarketingPlanResult = { diff --git a/src/features/plan/hooks/usePlanSubNav.ts b/src/features/plan/hooks/usePlanSubNav.ts index d085a90..efd36f3 100644 --- a/src/features/plan/hooks/usePlanSubNav.ts +++ b/src/features/plan/hooks/usePlanSubNav.ts @@ -1,7 +1,7 @@ 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"; +import { PLAN_SECTIONS } from "@/features/plan/config/planSections"; export function usePlanSubNav() { const { setSubNav } = useMainSubNav(); diff --git a/src/features/plan/constants/mock_plan.ts b/src/features/plan/mocks/plan.ts similarity index 100% rename from src/features/plan/constants/mock_plan.ts rename to src/features/plan/mocks/plan.ts diff --git a/src/features/plan/ui/MarketingPlanPage.tsx b/src/features/plan/ui/MarketingPlanPage.tsx deleted file mode 100644 index 095f469..0000000 --- a/src/features/plan/ui/MarketingPlanPage.tsx +++ /dev/null @@ -1,44 +0,0 @@ -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/PlanHeaderSection.tsx b/src/features/plan/ui/PlanHeaderSection.tsx index a09ac68..8830fa8 100644 --- a/src/features/plan/ui/PlanHeaderSection.tsx +++ b/src/features/plan/ui/PlanHeaderSection.tsx @@ -2,7 +2,7 @@ import { useCurrentMarketingPlan } from "@/features/plan/hooks/useCurrentMarketi 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"; +import { PLAN_HEADER_BG_CLASS } from "@/features/plan/ui/header/planHeaderSectionClasses"; export function PlanHeaderSection() { const { data, error } = useCurrentMarketingPlan(); diff --git a/src/features/plan/ui/assetCollection/AssetCollectionPanel.tsx b/src/features/plan/ui/assetCollection/AssetCollectionPanel.tsx index eb49762..a5020b6 100644 --- a/src/features/plan/ui/assetCollection/AssetCollectionPanel.tsx +++ b/src/features/plan/ui/assetCollection/AssetCollectionPanel.tsx @@ -14,7 +14,7 @@ import { assetTypeBadgeClass, assetTypeDisplayLabel, formatYoutubeViews, -} from "@/features/plan/ui/assetCollection/assetCollectionBadgeClass"; +} from "@/features/plan/ui/assetCollection/assetCollectionBadgeClasses"; type AssetCollectionPanelProps = { data: AssetCollectionData; diff --git a/src/features/plan/ui/assetCollection/assetCollectionBadgeClass.ts b/src/features/plan/ui/assetCollection/assetCollectionBadgeClasses.ts similarity index 100% rename from src/features/plan/ui/assetCollection/assetCollectionBadgeClass.ts rename to src/features/plan/ui/assetCollection/assetCollectionBadgeClasses.ts diff --git a/src/features/plan/ui/branding/BrandingChannelRulesTab.tsx b/src/features/plan/ui/branding/BrandingChannelRulesTab.tsx index 3fea650..f15c7fd 100644 --- a/src/features/plan/ui/branding/BrandingChannelRulesTab.tsx +++ b/src/features/plan/ui/branding/BrandingChannelRulesTab.tsx @@ -4,7 +4,7 @@ import { BrandingChannelIcon } from "@/features/plan/ui/branding/BrandingChannel import { brandingChannelStatusBadgeClass, brandingChannelStatusLabel, -} from "@/features/plan/ui/branding/brandingChannelStatusClass"; +} from "@/features/plan/ui/branding/brandingChannelStatusClasses"; type BrandingChannelRulesTabProps = { channels: BrandGuide["channelBranding"]; diff --git a/src/features/plan/ui/branding/BrandingGuidePanel.tsx b/src/features/plan/ui/branding/BrandingGuidePanel.tsx index c350514..694816c 100644 --- a/src/features/plan/ui/branding/BrandingGuidePanel.tsx +++ b/src/features/plan/ui/branding/BrandingGuidePanel.tsx @@ -2,7 +2,7 @@ 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 { BRANDING_GUIDE_TAB_ITEMS, type BrandingGuideTabKey } from "@/features/plan/ui/branding/brandingTabs"; import { BrandingChannelRulesTab } from "@/features/plan/ui/branding/BrandingChannelRulesTab"; import { BrandingToneVoiceTab } from "@/features/plan/ui/branding/BrandingToneVoiceTab"; import { BrandingVisualIdentityTab } from "@/features/plan/ui/branding/BrandingVisualIdentityTab"; diff --git a/src/features/plan/ui/branding/brandingChannelStatusClass.ts b/src/features/plan/ui/branding/brandingChannelStatusClasses.ts similarity index 100% rename from src/features/plan/ui/branding/brandingChannelStatusClass.ts rename to src/features/plan/ui/branding/brandingChannelStatusClasses.ts diff --git a/src/features/plan/ui/branding/brandingTabItems.ts b/src/features/plan/ui/branding/brandingTabs.ts similarity index 100% rename from src/features/plan/ui/branding/brandingTabItems.ts rename to src/features/plan/ui/branding/brandingTabs.ts diff --git a/src/features/plan/ui/channelStrategy/ChannelStrategyGrid.tsx b/src/features/plan/ui/channelStrategy/ChannelStrategyGrid.tsx index 0c0c577..6a731f2 100644 --- a/src/features/plan/ui/channelStrategy/ChannelStrategyGrid.tsx +++ b/src/features/plan/ui/channelStrategy/ChannelStrategyGrid.tsx @@ -3,7 +3,7 @@ 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"; +import { channelStrategyPriorityPillClass } from "@/features/plan/ui/channelStrategy/channelStrategyPillClasses"; type ChannelStrategyGridProps = { channels: ChannelStrategyCard[]; diff --git a/src/features/plan/ui/channelStrategy/channelStrategyPillClass.ts b/src/features/plan/ui/channelStrategy/channelStrategyPillClasses.ts similarity index 100% rename from src/features/plan/ui/channelStrategy/channelStrategyPillClass.ts rename to src/features/plan/ui/channelStrategy/channelStrategyPillClasses.ts diff --git a/src/features/plan/ui/contentCalendar/ContentCalendarPanel.tsx b/src/features/plan/ui/contentCalendar/ContentCalendarPanel.tsx index df6729d..d8bb16e 100644 --- a/src/features/plan/ui/contentCalendar/ContentCalendarPanel.tsx +++ b/src/features/plan/ui/contentCalendar/ContentCalendarPanel.tsx @@ -5,7 +5,7 @@ import { CALENDAR_CONTENT_TYPE_LABELS, CALENDAR_DAY_HEADERS, calendarContentTypeVisual, -} from "@/features/plan/ui/contentCalendar/calendarContentTypeVisual"; +} from "@/features/plan/ui/contentCalendar/calendarContentTypeClasses"; import { ContentCalendarTypeIcon } from "@/features/plan/ui/contentCalendar/ContentCalendarTypeIcon"; type ContentCalendarPanelProps = { diff --git a/src/features/plan/ui/contentCalendar/calendarContentTypeVisual.ts b/src/features/plan/ui/contentCalendar/calendarContentTypeClasses.ts similarity index 100% rename from src/features/plan/ui/contentCalendar/calendarContentTypeVisual.ts rename to src/features/plan/ui/contentCalendar/calendarContentTypeClasses.ts diff --git a/src/features/plan/ui/contentStrategy/ContentStrategyPanel.tsx b/src/features/plan/ui/contentStrategy/ContentStrategyPanel.tsx index a591be8..8fddf43 100644 --- a/src/features/plan/ui/contentStrategy/ContentStrategyPanel.tsx +++ b/src/features/plan/ui/contentStrategy/ContentStrategyPanel.tsx @@ -4,7 +4,7 @@ import type { ContentStrategyData } from "@/features/plan/types/marketingPlan"; import { CONTENT_STRATEGY_TAB_ITEMS, type ContentStrategyTabKey, -} from "@/features/plan/ui/contentStrategy/contentStrategyTabItems"; +} from "@/features/plan/ui/contentStrategy/contentStrategyTabs"; import { ContentStrategyPillarsTab } from "@/features/plan/ui/contentStrategy/ContentStrategyPillarsTab"; import { ContentStrategyRepurposingTab } from "@/features/plan/ui/contentStrategy/ContentStrategyRepurposingTab"; import { ContentStrategyTypesTab } from "@/features/plan/ui/contentStrategy/ContentStrategyTypesTab"; diff --git a/src/features/plan/ui/contentStrategy/ContentStrategyTypesTab.tsx b/src/features/plan/ui/contentStrategy/ContentStrategyTypesTab.tsx index 2d29a22..f326001 100644 --- a/src/features/plan/ui/contentStrategy/ContentStrategyTypesTab.tsx +++ b/src/features/plan/ui/contentStrategy/ContentStrategyTypesTab.tsx @@ -1,6 +1,6 @@ import { Pill } from "@/components/atoms/Pill"; import type { ContentTypeRow } from "@/features/plan/types/marketingPlan"; -import { contentStrategyChannelBadgeClass } from "@/features/plan/ui/contentStrategy/contentStrategyChannelBadgeClass"; +import { contentStrategyChannelBadgeClass } from "@/features/plan/ui/contentStrategy/contentStrategyChannelBadgeClasses"; type ContentStrategyTypesTabProps = { rows: ContentTypeRow[]; diff --git a/src/features/plan/ui/contentStrategy/contentStrategyChannelBadgeClass.ts b/src/features/plan/ui/contentStrategy/contentStrategyChannelBadgeClasses.ts similarity index 100% rename from src/features/plan/ui/contentStrategy/contentStrategyChannelBadgeClass.ts rename to src/features/plan/ui/contentStrategy/contentStrategyChannelBadgeClasses.ts diff --git a/src/features/plan/ui/contentStrategy/contentStrategyTabItems.ts b/src/features/plan/ui/contentStrategy/contentStrategyTabs.ts similarity index 100% rename from src/features/plan/ui/contentStrategy/contentStrategyTabItems.ts rename to src/features/plan/ui/contentStrategy/contentStrategyTabs.ts diff --git a/src/features/plan/ui/header/PlanHeaderMetaChips.tsx b/src/features/plan/ui/header/PlanHeaderMetaChips.tsx index e9d542a..8209915 100644 --- a/src/features/plan/ui/header/PlanHeaderMetaChips.tsx +++ b/src/features/plan/ui/header/PlanHeaderMetaChips.tsx @@ -1,6 +1,6 @@ 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"; +import { PLAN_HEADER_META_CHIP_CLASS } from "@/features/plan/ui/header/planHeaderSectionClasses"; type PlanHeaderMetaChipsProps = { date: string; diff --git a/src/features/plan/ui/header/planHeaderSectionStyles.ts b/src/features/plan/ui/header/planHeaderSectionClasses.ts similarity index 100% rename from src/features/plan/ui/header/planHeaderSectionStyles.ts rename to src/features/plan/ui/header/planHeaderSectionClasses.ts diff --git a/src/features/report/constants/report_sections.ts b/src/features/report/config/reportSections.ts similarity index 100% rename from src/features/report/constants/report_sections.ts rename to src/features/report/config/reportSections.ts diff --git a/src/features/report/hooks/useReportSubNav.ts b/src/features/report/hooks/useReportSubNav.ts index 950daaa..7188e27 100644 --- a/src/features/report/hooks/useReportSubNav.ts +++ b/src/features/report/hooks/useReportSubNav.ts @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import { useMainSubNav } from "@/layouts/MainSubNavLayout"; import type { SubNavItem } from "@/layouts/SubNav"; -import { REPORT_SECTIONS } from "@/features/report/constants/report_sections"; +import { REPORT_SECTIONS } from "@/features/report/config/reportSections"; export function useReportSubNav() { const { setSubNav } = useMainSubNav(); diff --git a/src/features/report/constants/mock_channel_scores.ts b/src/features/report/mocks/channelScores.ts similarity index 100% rename from src/features/report/constants/mock_channel_scores.ts rename to src/features/report/mocks/channelScores.ts diff --git a/src/features/report/constants/mock_clinic_snapshot.ts b/src/features/report/mocks/clinicSnapshot.ts similarity index 100% rename from src/features/report/constants/mock_clinic_snapshot.ts rename to src/features/report/mocks/clinicSnapshot.ts diff --git a/src/features/report/constants/mock_facebook_audit.ts b/src/features/report/mocks/facebookAudit.ts similarity index 100% rename from src/features/report/constants/mock_facebook_audit.ts rename to src/features/report/mocks/facebookAudit.ts diff --git a/src/features/report/constants/mock_instagram_audit.ts b/src/features/report/mocks/instagramAudit.ts similarity index 100% rename from src/features/report/constants/mock_instagram_audit.ts rename to src/features/report/mocks/instagramAudit.ts diff --git a/src/features/report/constants/mock_kpi.ts b/src/features/report/mocks/kpi.ts similarity index 100% rename from src/features/report/constants/mock_kpi.ts rename to src/features/report/mocks/kpi.ts diff --git a/src/features/report/constants/mock_other_channels.ts b/src/features/report/mocks/otherChannels.ts similarity index 95% rename from src/features/report/constants/mock_other_channels.ts rename to src/features/report/mocks/otherChannels.ts index d2b29fb..a970a54 100644 --- a/src/features/report/constants/mock_other_channels.ts +++ b/src/features/report/mocks/otherChannels.ts @@ -1,4 +1,4 @@ -import type { OtherChannelsReport } from "@/types/otherChannels"; +import type { OtherChannelsReport } from "@/features/report/types/otherChannels"; /** DEMO `mockReport` 기타 채널 + 웹사이트 진단 */ export const MOCK_OTHER_CHANNELS_REPORT: OtherChannelsReport = { diff --git a/src/features/report/constants/mock_problem_diagnosis.ts b/src/features/report/mocks/problemDiagnosis.ts similarity index 100% rename from src/features/report/constants/mock_problem_diagnosis.ts rename to src/features/report/mocks/problemDiagnosis.ts diff --git a/src/features/report/constants/mock_report_overview.ts b/src/features/report/mocks/reportOverview.ts similarity index 100% rename from src/features/report/constants/mock_report_overview.ts rename to src/features/report/mocks/reportOverview.ts diff --git a/src/features/report/constants/mock_roadmap.ts b/src/features/report/mocks/roadmap.ts similarity index 100% rename from src/features/report/constants/mock_roadmap.ts rename to src/features/report/mocks/roadmap.ts diff --git a/src/features/report/constants/mock_transformation.ts b/src/features/report/mocks/transformation.ts similarity index 100% rename from src/features/report/constants/mock_transformation.ts rename to src/features/report/mocks/transformation.ts diff --git a/src/features/report/constants/mock_youtube_audit.ts b/src/features/report/mocks/youtubeAudit.ts similarity index 100% rename from src/features/report/constants/mock_youtube_audit.ts rename to src/features/report/mocks/youtubeAudit.ts diff --git a/src/types/otherChannels.ts b/src/features/report/types/otherChannels.ts similarity index 100% rename from src/types/otherChannels.ts rename to src/features/report/types/otherChannels.ts diff --git a/src/features/report/ui/ReportChannelsSection.tsx b/src/features/report/ui/ReportChannelsSection.tsx index 5a1392f..acced32 100644 --- a/src/features/report/ui/ReportChannelsSection.tsx +++ b/src/features/report/ui/ReportChannelsSection.tsx @@ -1,5 +1,5 @@ import { PageSection } from "@/components/section/PageSection"; -import { MOCK_CHANNEL_SCORES } from "@/features/report/constants/mock_channel_scores"; +import { MOCK_CHANNEL_SCORES } from "@/features/report/mocks/channelScores"; import type { ChannelScore } from "@/features/report/types/channelScore"; import { ChannelScoreGrid } from "@/features/report/ui/channels/ChannelScoreGrid"; diff --git a/src/features/report/ui/ReportClinicSection.tsx b/src/features/report/ui/ReportClinicSection.tsx index f44ff2f..86ba7ec 100644 --- a/src/features/report/ui/ReportClinicSection.tsx +++ b/src/features/report/ui/ReportClinicSection.tsx @@ -1,5 +1,5 @@ import { PageSection } from "@/components/section/PageSection"; -import { MOCK_CLINIC_SNAPSHOT } from "@/features/report/constants/mock_clinic_snapshot"; +import { MOCK_CLINIC_SNAPSHOT } from "@/features/report/mocks/clinicSnapshot"; import type { ClinicSnapshot } from "@/features/report/types/clinicSnapshot"; import { ClinicCertificationsBlock } from "@/features/report/ui/clinic/ClinicCertificationsBlock"; import { ClinicInfoStatGrid } from "@/features/report/ui/clinic/ClinicInfoStatGrid"; diff --git a/src/features/report/ui/ReportDiagnosisSection.tsx b/src/features/report/ui/ReportDiagnosisSection.tsx index c463579..774ab84 100644 --- a/src/features/report/ui/ReportDiagnosisSection.tsx +++ b/src/features/report/ui/ReportDiagnosisSection.tsx @@ -1,5 +1,5 @@ import { PageSection } from "@/components/section/PageSection"; -import { MOCK_PROBLEM_DIAGNOSIS } from "@/features/report/constants/mock_problem_diagnosis"; +import { MOCK_PROBLEM_DIAGNOSIS } from "@/features/report/mocks/problemDiagnosis"; import type { DiagnosisItem } from "@/features/report/types/diagnosis"; import { ProblemDiagnosisCard } from "@/features/report/ui/diagnosis/ProblemDiagnosisCard"; diff --git a/src/features/report/ui/ReportFacebookSection.tsx b/src/features/report/ui/ReportFacebookSection.tsx index 099ad9f..d1c4b19 100644 --- a/src/features/report/ui/ReportFacebookSection.tsx +++ b/src/features/report/ui/ReportFacebookSection.tsx @@ -1,9 +1,9 @@ import GlobeIcon from "@/assets/report/globe.svg?react"; import { BrandConsistencyMap } from "@/components/brand/BrandConsistencyMap"; -import { DiagnosisRow } from "@/components/diagnosis/DiagnosisRow"; +import { DiagnosisRow } from "@/features/report/ui/diagnosis/DiagnosisRow"; import { ConsolidationCallout } from "@/components/panel/ConsolidationCallout"; import { PageSection } from "@/components/section/PageSection"; -import { MOCK_FACEBOOK_AUDIT } from "@/features/report/constants/mock_facebook_audit"; +import { MOCK_FACEBOOK_AUDIT } from "@/features/report/mocks/facebookAudit"; import type { FacebookAudit } from "@/features/report/types/facebookAudit"; import { FacebookPageCard } from "@/features/report/ui/facebook/FacebookPageCard"; diff --git a/src/features/report/ui/ReportInstagramSection.tsx b/src/features/report/ui/ReportInstagramSection.tsx index f88808f..9c796ed 100644 --- a/src/features/report/ui/ReportInstagramSection.tsx +++ b/src/features/report/ui/ReportInstagramSection.tsx @@ -1,6 +1,6 @@ -import { DiagnosisRow } from "@/components/diagnosis/DiagnosisRow"; +import { DiagnosisRow } from "@/features/report/ui/diagnosis/DiagnosisRow"; import { PageSection } from "@/components/section/PageSection"; -import { MOCK_INSTAGRAM_AUDIT } from "@/features/report/constants/mock_instagram_audit"; +import { MOCK_INSTAGRAM_AUDIT } from "@/features/report/mocks/instagramAudit"; import type { InstagramAudit } from "@/features/report/types/instagramAudit"; import { InstagramAccountCard } from "@/features/report/ui/instagram/InstagramAccountCard"; diff --git a/src/features/report/ui/ReportKpiSection.tsx b/src/features/report/ui/ReportKpiSection.tsx index a0de3b3..d7ac409 100644 --- a/src/features/report/ui/ReportKpiSection.tsx +++ b/src/features/report/ui/ReportKpiSection.tsx @@ -1,5 +1,5 @@ import { PageSection } from "@/components/section/PageSection"; -import { MOCK_KPI_METRICS } from "@/features/report/constants/mock_kpi"; +import { MOCK_KPI_METRICS } from "@/features/report/mocks/kpi"; import type { KpiMetric } from "@/features/report/types/kpiDashboard"; import { KpiMetricsTable } from "@/features/report/ui/kpi/KpiMetricsTable"; import { KpiTransformationCtaCard } from "@/features/report/ui/kpi/KpiTransformationCtaCard"; diff --git a/src/features/report/ui/ReportOtherChannelsSection.tsx b/src/features/report/ui/ReportOtherChannelsSection.tsx index 8b3c0c4..dc88de5 100644 --- a/src/features/report/ui/ReportOtherChannelsSection.tsx +++ b/src/features/report/ui/ReportOtherChannelsSection.tsx @@ -1,6 +1,6 @@ import { PageSection } from "@/components/section/PageSection"; -import { MOCK_OTHER_CHANNELS_REPORT } from "@/features/report/constants/mock_other_channels"; -import type { OtherChannelsReport } from "@/types/otherChannels"; +import { MOCK_OTHER_CHANNELS_REPORT } from "@/features/report/mocks/otherChannels"; +import type { OtherChannelsReport } from "@/features/report/types/otherChannels"; import { OtherChannelsList } from "@/features/report/ui/otherChannels/OtherChannelsList"; import { WebsiteTechAuditBlock } from "@/features/report/ui/otherChannels/WebsiteTechAuditBlock"; diff --git a/src/features/report/ui/ReportOverviewSection.tsx b/src/features/report/ui/ReportOverviewSection.tsx index a3ecc3d..77cf445 100644 --- a/src/features/report/ui/ReportOverviewSection.tsx +++ b/src/features/report/ui/ReportOverviewSection.tsx @@ -1,9 +1,9 @@ -import { MOCK_REPORT_OVERVIEW } from "@/features/report/constants/mock_report_overview"; +import { MOCK_REPORT_OVERVIEW } from "@/features/report/mocks/reportOverview"; import type { ReportOverviewData } from "@/features/report/types/reportOverview"; import { OverviewHeroBlobs } from "@/features/report/ui/overview/OverviewHeroBlobs"; import { OverviewHeroColumn } from "@/features/report/ui/overview/OverviewHeroColumn"; import { OverviewScorePanel } from "@/features/report/ui/overview/OverviewScorePanel"; -import { OVERVIEW_SECTION_BG_CLASS } from "@/features/report/ui/overview/overviewSectionStyles"; +import { OVERVIEW_SECTION_BG_CLASS } from "@/features/report/ui/overview/overviewSectionClasses"; type ReportOverviewSectionProps = { data?: ReportOverviewData; diff --git a/src/features/report/ui/ReportPage.tsx b/src/features/report/ui/ReportPage.tsx deleted file mode 100644 index c4e7891..0000000 --- a/src/features/report/ui/ReportPage.tsx +++ /dev/null @@ -1,40 +0,0 @@ -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/features/report/ui/ReportRoadmapSection.tsx b/src/features/report/ui/ReportRoadmapSection.tsx index c376c98..c840144 100644 --- a/src/features/report/ui/ReportRoadmapSection.tsx +++ b/src/features/report/ui/ReportRoadmapSection.tsx @@ -1,5 +1,5 @@ import { PageSection } from "@/components/section/PageSection"; -import { MOCK_ROADMAP } from "@/features/report/constants/mock_roadmap"; +import { MOCK_ROADMAP } from "@/features/report/mocks/roadmap"; import type { RoadmapMonth } from "@/features/report/types/roadmap"; import { RoadmapMonthsGrid } from "@/features/report/ui/roadmap/RoadmapMonthsGrid"; diff --git a/src/features/report/ui/ReportTransformationSection.tsx b/src/features/report/ui/ReportTransformationSection.tsx index 0c88b83..c935fee 100644 --- a/src/features/report/ui/ReportTransformationSection.tsx +++ b/src/features/report/ui/ReportTransformationSection.tsx @@ -1,5 +1,5 @@ import { PageSection } from "@/components/section/PageSection"; -import { MOCK_TRANSFORMATION } from "@/features/report/constants/mock_transformation"; +import { MOCK_TRANSFORMATION } from "@/features/report/mocks/transformation"; import type { TransformationProposal } from "@/features/report/types/transformationProposal"; import { TransformationTabbedView } from "@/features/report/ui/transformation/TransformationTabbedView"; diff --git a/src/features/report/ui/ReportYouTubeSection.tsx b/src/features/report/ui/ReportYouTubeSection.tsx index 4c64914..eca4e52 100644 --- a/src/features/report/ui/ReportYouTubeSection.tsx +++ b/src/features/report/ui/ReportYouTubeSection.tsx @@ -1,7 +1,7 @@ import { TagChipList } from "@/components/chip/TagChipList"; -import { DiagnosisRow } from "@/components/diagnosis/DiagnosisRow"; +import { DiagnosisRow } from "@/features/report/ui/diagnosis/DiagnosisRow"; import { PageSection } from "@/components/section/PageSection"; -import { MOCK_YOUTUBE_AUDIT } from "@/features/report/constants/mock_youtube_audit"; +import { MOCK_YOUTUBE_AUDIT } from "@/features/report/mocks/youtubeAudit"; import type { YouTubeAudit } from "@/features/report/types/youtubeAudit"; import { YouTubeChannelInfoCard } from "@/features/report/ui/youtube/YouTubeChannelInfoCard"; import { YouTubeMetricsGrid } from "@/features/report/ui/youtube/YouTubeMetricsGrid"; diff --git a/src/features/report/ui/clinic/clinicSnapshotStatRows.tsx b/src/features/report/ui/clinic/clinicSnapshotStatRows.tsx index 50c9f83..46a012f 100644 --- a/src/features/report/ui/clinic/clinicSnapshotStatRows.tsx +++ b/src/features/report/ui/clinic/clinicSnapshotStatRows.tsx @@ -6,7 +6,7 @@ import CalendarIcon from "@/assets/report/calendar.svg?react"; import GlobeIcon from "@/assets/report/globe.svg?react"; import MapPinIcon from "@/assets/report/map-pin.svg?react"; import type { ClinicSnapshot } from "@/features/report/types/clinicSnapshot"; -import { formatCompactNumber } from "@/lib/formatNumber"; +import { formatCompactNumber } from "@/utils/formatNumber"; export type ClinicStatRow = { label: string; diff --git a/src/components/diagnosis/DiagnosisRow.tsx b/src/features/report/ui/diagnosis/DiagnosisRow.tsx similarity index 100% rename from src/components/diagnosis/DiagnosisRow.tsx rename to src/features/report/ui/diagnosis/DiagnosisRow.tsx diff --git a/src/features/report/ui/diagnosis/ProblemDiagnosisCard.tsx b/src/features/report/ui/diagnosis/ProblemDiagnosisCard.tsx index 2e4337b..a465ef5 100644 --- a/src/features/report/ui/diagnosis/ProblemDiagnosisCard.tsx +++ b/src/features/report/ui/diagnosis/ProblemDiagnosisCard.tsx @@ -1,6 +1,6 @@ import AlertCircleIcon from "@/assets/icons/alert-circle.svg?react"; import type { DiagnosisItem } from "@/features/report/types/diagnosis"; -import { problemDiagnosisSeverityDotClass } from "@/features/report/ui/diagnosis/severityDotClass"; +import { problemDiagnosisSeverityDotClass } from "@/features/report/ui/diagnosis/severityDotClasses"; export type ProblemDiagnosisCardProps = { item: DiagnosisItem; diff --git a/src/features/report/ui/diagnosis/severityDotClass.ts b/src/features/report/ui/diagnosis/severityDotClasses.ts similarity index 100% rename from src/features/report/ui/diagnosis/severityDotClass.ts rename to src/features/report/ui/diagnosis/severityDotClasses.ts diff --git a/src/features/report/ui/facebook/FacebookPageCard.tsx b/src/features/report/ui/facebook/FacebookPageCard.tsx index 918ebdb..0635074 100644 --- a/src/features/report/ui/facebook/FacebookPageCard.tsx +++ b/src/features/report/ui/facebook/FacebookPageCard.tsx @@ -8,9 +8,9 @@ import ImageIcon from "@/assets/report/image.svg?react"; import Link2Icon from "@/assets/report/link-2.svg?react"; import MessageCircleIcon from "@/assets/report/message-circle.svg?react"; import type { FacebookPage } from "@/features/report/types/facebookAudit"; -import { facebookLangBadgeClass } from "@/features/report/ui/facebook/langBadgeClass"; -import { formatCompactNumber } from "@/lib/formatNumber"; -import { safeUrl } from "@/lib/safeUrl"; +import { facebookLangBadgeClass } from "@/features/report/ui/facebook/langBadgeClasses"; +import { formatCompactNumber } from "@/utils/formatNumber"; +import { safeUrl } from "@/utils/safeUrl"; export type FacebookPageCardProps = { page: FacebookPage; diff --git a/src/features/report/ui/facebook/langBadgeClass.ts b/src/features/report/ui/facebook/langBadgeClasses.ts similarity index 100% rename from src/features/report/ui/facebook/langBadgeClass.ts rename to src/features/report/ui/facebook/langBadgeClasses.ts diff --git a/src/features/report/ui/instagram/InstagramAccountCard.tsx b/src/features/report/ui/instagram/InstagramAccountCard.tsx index 866faca..60ae0e8 100644 --- a/src/features/report/ui/instagram/InstagramAccountCard.tsx +++ b/src/features/report/ui/instagram/InstagramAccountCard.tsx @@ -3,9 +3,9 @@ import ChannelInstagramIcon from "@/assets/icons/channel-instagram.svg?react"; import ExternalLinkIcon from "@/assets/icons/external-link.svg?react"; import { TagChipList } from "@/components/chip/TagChipList"; import type { InstagramAccount } from "@/features/report/types/instagramAudit"; -import { instagramLangBadgeClass } from "@/features/report/ui/instagram/langBadgeClass"; -import { formatCompactNumber } from "@/lib/formatNumber"; -import { safeUrl } from "@/lib/safeUrl"; +import { instagramLangBadgeClass } from "@/features/report/ui/instagram/langBadgeClasses"; +import { formatCompactNumber } from "@/utils/formatNumber"; +import { safeUrl } from "@/utils/safeUrl"; export type InstagramAccountCardProps = { account: InstagramAccount; diff --git a/src/features/report/ui/instagram/langBadgeClass.ts b/src/features/report/ui/instagram/langBadgeClasses.ts similarity index 100% rename from src/features/report/ui/instagram/langBadgeClass.ts rename to src/features/report/ui/instagram/langBadgeClasses.ts diff --git a/src/components/channel/OtherChannelRow.tsx b/src/features/report/ui/otherChannels/OtherChannelRow.tsx similarity index 94% rename from src/components/channel/OtherChannelRow.tsx rename to src/features/report/ui/otherChannels/OtherChannelRow.tsx index dbb2c32..24efdcf 100644 --- a/src/components/channel/OtherChannelRow.tsx +++ b/src/features/report/ui/otherChannels/OtherChannelRow.tsx @@ -2,8 +2,8 @@ import ExternalLinkIcon from "@/assets/icons/external-link.svg?react"; import CheckCircleIcon from "@/assets/report/check-circle.svg?react"; import HelpCircleIcon from "@/assets/report/help-circle.svg?react"; import XCircleIcon from "@/assets/report/x-circle.svg?react"; -import type { OtherChannelStatus } from "@/types/otherChannels"; -import { safeUrl } from "@/lib/safeUrl"; +import type { OtherChannelStatus } from "@/features/report/types/otherChannels"; +import { safeUrl } from "@/utils/safeUrl"; export type OtherChannelRowProps = { name: string; diff --git a/src/features/report/ui/otherChannels/OtherChannelsList.tsx b/src/features/report/ui/otherChannels/OtherChannelsList.tsx index e9ad3ef..9b5ccc0 100644 --- a/src/features/report/ui/otherChannels/OtherChannelsList.tsx +++ b/src/features/report/ui/otherChannels/OtherChannelsList.tsx @@ -1,5 +1,5 @@ -import { OtherChannelRow } from "@/components/channel/OtherChannelRow"; -import type { OtherChannel } from "@/types/otherChannels"; +import { OtherChannelRow } from "@/features/report/ui/otherChannels/OtherChannelRow"; +import type { OtherChannel } from "@/features/report/types/otherChannels"; export type OtherChannelsListProps = { channels: OtherChannel[]; diff --git a/src/features/report/ui/otherChannels/WebsiteTechAuditBlock.tsx b/src/features/report/ui/otherChannels/WebsiteTechAuditBlock.tsx index 09735b0..67b7e2b 100644 --- a/src/features/report/ui/otherChannels/WebsiteTechAuditBlock.tsx +++ b/src/features/report/ui/otherChannels/WebsiteTechAuditBlock.tsx @@ -2,7 +2,7 @@ import AlertCircleIcon from "@/assets/icons/alert-circle.svg?react"; import CheckCircleIcon from "@/assets/report/check-circle.svg?react"; import GlobeIcon from "@/assets/report/globe.svg?react"; import { PixelInstallCard } from "@/components/card/PixelInstallCard"; -import type { WebsiteAudit } from "@/types/otherChannels"; +import type { WebsiteAudit } from "@/features/report/types/otherChannels"; export type WebsiteTechAuditBlockProps = { website: WebsiteAudit; diff --git a/src/features/report/ui/overview/OverviewMetaChips.tsx b/src/features/report/ui/overview/OverviewMetaChips.tsx index 20a9740..bc16b51 100644 --- a/src/features/report/ui/overview/OverviewMetaChips.tsx +++ b/src/features/report/ui/overview/OverviewMetaChips.tsx @@ -1,7 +1,7 @@ import CalendarIcon from "@/assets/report/calendar.svg?react"; import GlobeIcon from "@/assets/report/globe.svg?react"; import MapPinIcon from "@/assets/report/map-pin.svg?react"; -import { OVERVIEW_META_CHIP_CLASS } from "@/features/report/ui/overview/overviewSectionStyles"; +import { OVERVIEW_META_CHIP_CLASS } from "@/features/report/ui/overview/overviewSectionClasses"; export type OverviewMetaChipsProps = { date: string; diff --git a/src/features/report/ui/overview/overviewSectionStyles.ts b/src/features/report/ui/overview/overviewSectionClasses.ts similarity index 100% rename from src/features/report/ui/overview/overviewSectionStyles.ts rename to src/features/report/ui/overview/overviewSectionClasses.ts diff --git a/src/components/compare/ComparisonRow.tsx b/src/features/report/ui/transformation/ComparisonRow.tsx similarity index 100% rename from src/components/compare/ComparisonRow.tsx rename to src/features/report/ui/transformation/ComparisonRow.tsx diff --git a/src/features/report/ui/transformation/NewChannelProposalsTable.tsx b/src/features/report/ui/transformation/NewChannelProposalsTable.tsx index 0d41f64..813b627 100644 --- a/src/features/report/ui/transformation/NewChannelProposalsTable.tsx +++ b/src/features/report/ui/transformation/NewChannelProposalsTable.tsx @@ -1,5 +1,5 @@ import type { NewChannelProposal } from "@/features/report/types/transformationProposal"; -import { newChannelPriorityClass } from "@/features/report/ui/transformation/newChannelPriorityClass"; +import { newChannelPriorityClass } from "@/features/report/ui/transformation/newChannelPriorityClasses"; export type NewChannelProposalsTableProps = { rows: NewChannelProposal[]; diff --git a/src/features/report/ui/transformation/TransformationTabbedView.tsx b/src/features/report/ui/transformation/TransformationTabbedView.tsx index 9cc9478..54c8a2c 100644 --- a/src/features/report/ui/transformation/TransformationTabbedView.tsx +++ b/src/features/report/ui/transformation/TransformationTabbedView.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { ComparisonRow } from "@/components/compare/ComparisonRow"; +import { ComparisonRow } from "@/features/report/ui/transformation/ComparisonRow"; import type { TransformationProposal } from "@/features/report/types/transformationProposal"; import { NewChannelProposalsTable } from "@/features/report/ui/transformation/NewChannelProposalsTable"; import { PlatformStrategyCard } from "@/features/report/ui/transformation/PlatformStrategyCard"; diff --git a/src/features/report/ui/transformation/newChannelPriorityClass.ts b/src/features/report/ui/transformation/newChannelPriorityClasses.ts similarity index 100% rename from src/features/report/ui/transformation/newChannelPriorityClass.ts rename to src/features/report/ui/transformation/newChannelPriorityClasses.ts diff --git a/src/features/report/ui/youtube/YouTubeChannelInfoCard.tsx b/src/features/report/ui/youtube/YouTubeChannelInfoCard.tsx index d01c710..30cb067 100644 --- a/src/features/report/ui/youtube/YouTubeChannelInfoCard.tsx +++ b/src/features/report/ui/youtube/YouTubeChannelInfoCard.tsx @@ -1,7 +1,7 @@ import ChannelYoutubeIcon from "@/assets/icons/channel-youtube.svg?react"; import ExternalLinkIcon from "@/assets/icons/external-link.svg?react"; import type { YouTubeAudit } from "@/features/report/types/youtubeAudit"; -import { safeUrl } from "@/lib/safeUrl"; +import { safeUrl } from "@/utils/safeUrl"; export type YouTubeChannelInfoCardProps = { data: YouTubeAudit; diff --git a/src/features/report/ui/youtube/YouTubeMetricsGrid.tsx b/src/features/report/ui/youtube/YouTubeMetricsGrid.tsx index b332eff..66d14ad 100644 --- a/src/features/report/ui/youtube/YouTubeMetricsGrid.tsx +++ b/src/features/report/ui/youtube/YouTubeMetricsGrid.tsx @@ -4,7 +4,7 @@ import VideoIcon from "@/assets/icons/video.svg?react"; import UsersIcon from "@/assets/icons/users.svg?react"; import { MetricCard } from "@/components/card/MetricCard"; import type { YouTubeAudit } from "@/features/report/types/youtubeAudit"; -import { formatCompactNumber } from "@/lib/formatNumber"; +import { formatCompactNumber } from "@/utils/formatNumber"; export type YouTubeMetricsGridProps = { data: YouTubeAudit; diff --git a/src/layouts/PageNavigator.tsx b/src/layouts/PageNavigator.tsx index 4364820..e815e48 100644 --- a/src/layouts/PageNavigator.tsx +++ b/src/layouts/PageNavigator.tsx @@ -68,7 +68,7 @@ export function PageNavigator() { {/* 이전 페이지 */}