Merge pull request '[feat] plan 페이지 생성' (#4) from feature-plan into master
Reviewed-on: #4pull/5/head
commit
c29541a365
|
|
@ -19,5 +19,14 @@ export default defineConfig([
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
},
|
},
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
caughtErrorsIgnorePattern: '^_',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import MainSubNavLayout from "@/layouts/MainSubNavLayout";
|
||||||
|
|
||||||
// pages
|
// pages
|
||||||
import { Home } from "@/pages/Home";
|
import { Home } from "@/pages/Home";
|
||||||
|
import { PlanPage } from "@/pages/Plan";
|
||||||
import { ReportPage } from "@/pages/Report";
|
import { ReportPage } from "@/pages/Report";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
|
@ -16,6 +17,7 @@ function App() {
|
||||||
</Route>
|
</Route>
|
||||||
<Route element={<MainSubNavLayout />}>
|
<Route element={<MainSubNavLayout />}>
|
||||||
<Route path="report/:id" element={<ReportPage />} />
|
<Route path="report/:id" element={<ReportPage />} />
|
||||||
|
<Route path="plan/:id" element={<PlanPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -130,12 +130,27 @@
|
||||||
|
|
||||||
/* ─── Utility Classes ─────────────────────────────────────────────── */
|
/* ─── Utility Classes ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
/* 라이트 섹션 헤딩 그라디언트 텍스트 */
|
/* 라이트 섹션 헤딩 그라디언트 텍스트
|
||||||
|
- base의 h2 { color: navy }와 함께 쓸 때 color를 반드시 투명으로 두어야 그라데이션이 보임
|
||||||
|
- background 단축 속성은 clip을 리셋할 수 있어 background-image만 사용 */
|
||||||
.text-gradient {
|
.text-gradient {
|
||||||
background: linear-gradient(to right, var(--color-navy-900), #3b5998);
|
color: transparent;
|
||||||
|
background-image: linear-gradient(to right, var(--color-navy-900), #3b5998);
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 그라데이션 제목(h2)과 같은 레이아웃 안의 뱃지·버튼:
|
||||||
|
* WebKit 계열에서 -webkit-text-fill-color 가 상속되면 text-* 만으로는 글씨가 비어 보일 수 있음.
|
||||||
|
* currentColor 는 해당 요소의 (Tailwind) color 값과 맞춘다.
|
||||||
|
*/
|
||||||
|
.solid-text-paint {
|
||||||
|
-webkit-text-fill-color: currentColor !important;
|
||||||
|
/* bg-* 와 같은 요소에 clip:text가 남으면 글리프가 배경으로만 채워져 비어 보일 수 있음 */
|
||||||
|
-webkit-background-clip: border-box !important;
|
||||||
|
background-clip: border-box !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 반투명 글래스 카드 — 랜딩 섹션 */
|
/* 반투명 글래스 카드 — 랜딩 섹션 */
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||||
|
import { UI_PRIMARY_GRADIENT_CLASS } from "@/components/atoms/uiTokens";
|
||||||
|
|
||||||
|
export type ButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "type"> & {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
variant?: "primary" | "outline";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
variant = "outline",
|
||||||
|
...rest
|
||||||
|
}: ButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={[
|
||||||
|
"rounded-full transition-all cursor-pointer break-keep solid-text-paint",
|
||||||
|
variant === "primary"
|
||||||
|
? `${UI_PRIMARY_GRADIENT_CLASS} text-white shadow-md`
|
||||||
|
: "bg-white border border-neutral-30 text-neutral-70 hover:bg-neutral-10",
|
||||||
|
className,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import type { HTMLAttributes, ReactNode } from "react";
|
||||||
|
|
||||||
|
export type PillProps = Omit<HTMLAttributes<HTMLSpanElement>, "className"> & {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
size?: "sm" | "md";
|
||||||
|
weight?: "medium" | "semibold" | "none";
|
||||||
|
inlineFlex?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Pill({
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
size = "md",
|
||||||
|
weight = "medium",
|
||||||
|
inlineFlex = false,
|
||||||
|
...rest
|
||||||
|
}: PillProps) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
"rounded-full break-keep",
|
||||||
|
inlineFlex && "inline-flex items-center",
|
||||||
|
size === "sm" ? "px-2 py-1" : "px-3 py-1",
|
||||||
|
weight === "semibold" ? "font-semibold" : weight === "medium" ? "font-medium" : "",
|
||||||
|
"label-12",
|
||||||
|
className,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{/* 배경·테두리는 바깥 span, WebKit 글자 채움은 안쪽만 solid-text-paint */}
|
||||||
|
<span className="solid-text-paint">{children}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
import type { HTMLAttributes, ReactNode } from "react";
|
||||||
|
|
||||||
|
const paddingMap = {
|
||||||
|
none: "",
|
||||||
|
sm: "p-4",
|
||||||
|
md: "p-5",
|
||||||
|
lg: "p-6",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type SurfaceProps = Omit<HTMLAttributes<HTMLDivElement>, "className"> & {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
padding?: keyof typeof paddingMap;
|
||||||
|
radius?: "xl" | "2xl";
|
||||||
|
bordered?: boolean;
|
||||||
|
surface?: "white" | "none";
|
||||||
|
shadow?: boolean;
|
||||||
|
interactive?: boolean | "lift";
|
||||||
|
overflowHidden?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Surface({
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
padding = "md",
|
||||||
|
radius = "2xl",
|
||||||
|
bordered = true,
|
||||||
|
surface = "white",
|
||||||
|
shadow = true,
|
||||||
|
interactive = false,
|
||||||
|
overflowHidden = false,
|
||||||
|
...rest
|
||||||
|
}: SurfaceProps) {
|
||||||
|
const interactiveClass =
|
||||||
|
interactive === "lift"
|
||||||
|
? "hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)] transition-shadow"
|
||||||
|
: interactive
|
||||||
|
? "hover:shadow-md transition-shadow"
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
radius === "xl" ? "rounded-xl" : "rounded-2xl",
|
||||||
|
bordered && "border border-neutral-20",
|
||||||
|
surface === "white" && "bg-white",
|
||||||
|
shadow && "card-shadow",
|
||||||
|
paddingMap[padding],
|
||||||
|
interactiveClass,
|
||||||
|
overflowHidden && "overflow-hidden",
|
||||||
|
className,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
/** 앱 전역에서 쓸 수 있는 브랜드 그라데이션 배경 클래스 (버튼·블록 배경 등) */
|
||||||
|
export const UI_PRIMARY_GRADIENT_CLASS = "bg-gradient-to-r from-violet-700 to-navy-950";
|
||||||
|
|
@ -8,17 +8,29 @@ import type { BrandInconsistency } from "@/types/brandConsistency";
|
||||||
export type BrandConsistencyMapProps = {
|
export type BrandConsistencyMapProps = {
|
||||||
inconsistencies: BrandInconsistency[];
|
inconsistencies: BrandInconsistency[];
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** false면 상단 제목·설명을 숨김 (플랜 브랜딩 가이드 탭 등) */
|
||||||
|
showHeading?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BrandConsistencyMap({ inconsistencies, className = "" }: BrandConsistencyMapProps) {
|
export function BrandConsistencyMap({
|
||||||
|
inconsistencies,
|
||||||
|
className = "",
|
||||||
|
showHeading = true,
|
||||||
|
}: BrandConsistencyMapProps) {
|
||||||
const [expanded, setExpanded] = useState<number | null>(0);
|
const [expanded, setExpanded] = useState<number | null>(0);
|
||||||
|
|
||||||
if (inconsistencies.length === 0) return null;
|
if (inconsistencies.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`mt-8 animate-fade-in-up animation-delay-200 ${className}`.trim()}>
|
<div
|
||||||
<h3 className="font-serif headline-20 text-navy-900 mb-2 break-keep">Brand Consistency Map</h3>
|
className={`${showHeading ? "mt-8 animate-fade-in-up animation-delay-200 " : ""}${className}`.trim()}
|
||||||
<p className="body-14 text-neutral-60 mb-5 break-keep">전 채널 브랜드 일관성 분석</p>
|
>
|
||||||
|
{showHeading ? (
|
||||||
|
<>
|
||||||
|
<h3 className="font-serif headline-20 text-navy-900 mb-2 break-keep">Brand Consistency Map</h3>
|
||||||
|
<p className="body-14 text-neutral-60 mb-5 break-keep">전 채널 브랜드 일관성 분석</p>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{inconsistencies.map((item, i) => {
|
{inconsistencies.map((item, i) => {
|
||||||
|
|
|
||||||
|
|
@ -14,3 +14,12 @@ export { HighlightPanel, type HighlightPanelProps } from "@/components/panel/Hig
|
||||||
export { ScoreRing, type ScoreRingProps } from "@/components/rating/ScoreRing";
|
export { ScoreRing, type ScoreRingProps } from "@/components/rating/ScoreRing";
|
||||||
export { StarRatingDisplay, type StarRatingDisplayProps } from "@/components/rating/StarRatingDisplay";
|
export { StarRatingDisplay, type StarRatingDisplayProps } from "@/components/rating/StarRatingDisplay";
|
||||||
export { PageSection, type PageSectionProps } from "@/components/section/PageSection";
|
export { PageSection, type PageSectionProps } from "@/components/section/PageSection";
|
||||||
|
export {
|
||||||
|
Button,
|
||||||
|
Pill,
|
||||||
|
Surface,
|
||||||
|
UI_PRIMARY_GRADIENT_CLASS,
|
||||||
|
type ButtonProps,
|
||||||
|
type PillProps,
|
||||||
|
type SurfaceProps,
|
||||||
|
} from "@/components/atoms";
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ export type PageSectionProps = {
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
dark?: boolean;
|
dark?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** false면 섹션 입장용 `animate-fade-in-up` 미적용 (DEMO에 없는 모션을 쓰지 않을 때) */
|
||||||
|
animateEnter?: boolean;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -15,13 +17,15 @@ export function PageSection({
|
||||||
subtitle,
|
subtitle,
|
||||||
dark = false,
|
dark = false,
|
||||||
className = "",
|
className = "",
|
||||||
|
animateEnter = true,
|
||||||
children,
|
children,
|
||||||
}: PageSectionProps) {
|
}: PageSectionProps) {
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
id={id}
|
id={id}
|
||||||
className={[
|
className={[
|
||||||
"py-16 md:py-20 px-6 scroll-mt-36 animate-fade-in-up",
|
"py-16 md:py-20 px-6 scroll-mt-36",
|
||||||
|
animateEnter ? "animate-fade-in-up" : "",
|
||||||
dark ? "bg-navy-900 text-white relative overflow-hidden" : "bg-white",
|
dark ? "bg-navy-900 text-white relative overflow-hidden" : "bg-white",
|
||||||
className,
|
className,
|
||||||
]
|
]
|
||||||
|
|
@ -34,19 +38,30 @@ export function PageSection({
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="relative max-w-7xl mx-auto">
|
<div
|
||||||
|
className={[
|
||||||
|
"relative max-w-7xl mx-auto",
|
||||||
|
dark ? "text-white" : "text-neutral-80",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
<header className="mb-10">
|
<header className="mb-10">
|
||||||
<h2
|
<h2
|
||||||
className={
|
className={
|
||||||
dark
|
dark
|
||||||
? "font-serif text-3xl md:display-36 font-bold mb-3 bg-gradient-to-r from-lavender-200 to-marketing-ice bg-clip-text text-transparent"
|
? "font-serif text-3xl md:display-36 font-bold mb-3 bg-gradient-to-r from-lavender-200 to-marketing-ice bg-clip-text text-transparent"
|
||||||
: "font-serif text-3xl md:display-36 font-bold mb-3 text-gradient"
|
: "font-serif text-3xl md:display-36 font-bold mb-3 text-transparent text-gradient"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
{subtitle ? (
|
{subtitle ? (
|
||||||
<p className={dark ? "body-18 text-lavender-200 break-keep" : "body-18 text-neutral-70 break-keep"}>
|
<p
|
||||||
|
className={
|
||||||
|
dark
|
||||||
|
? "body-18 text-lavender-200 break-keep solid-text-paint"
|
||||||
|
: "body-18 text-neutral-70 break-keep solid-text-paint"
|
||||||
|
}
|
||||||
|
>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -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'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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"];
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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 }),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useMainSubNav } from "@/layouts/MainSubNavLayout";
|
||||||
|
import type { SubNavItem } from "@/layouts/SubNav";
|
||||||
|
import { PLAN_SECTIONS } from "@/features/plan/constants/plan_sections";
|
||||||
|
|
||||||
|
export function usePlanSubNav() {
|
||||||
|
const { setSubNav } = useMainSubNav();
|
||||||
|
const [activeId, setActiveId] = useState<string>(PLAN_SECTIONS[0]?.id ?? "");
|
||||||
|
|
||||||
|
const items: SubNavItem[] = useMemo(
|
||||||
|
() =>
|
||||||
|
PLAN_SECTIONS.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
label: s.label,
|
||||||
|
targetId: s.id,
|
||||||
|
})),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const visible = entries
|
||||||
|
.filter((e) => e.isIntersecting)
|
||||||
|
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
|
||||||
|
if (visible.length > 0) {
|
||||||
|
setActiveId(visible[0].target.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: "-100px 0px -60% 0px", threshold: 0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
PLAN_SECTIONS.forEach(({ id: sectionId }) => {
|
||||||
|
const el = document.getElementById(sectionId);
|
||||||
|
if (el) observer.observe(el);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSubNav({
|
||||||
|
items,
|
||||||
|
activeId,
|
||||||
|
scrollActiveIntoView: true,
|
||||||
|
});
|
||||||
|
}, [activeId, items, setSubNav]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => setSubNav(null);
|
||||||
|
}, [setSubNav]);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { useCurrentMarketingPlan } from "@/features/plan/hooks/useCurrentMarketingPlan";
|
||||||
|
import { usePlanSubNav } from "@/features/plan/hooks/usePlanSubNav";
|
||||||
|
import { PlanAssetCollectionSection } from "@/features/plan/ui/PlanAssetCollectionSection";
|
||||||
|
import { PlanBrandingGuideSection } from "@/features/plan/ui/PlanBrandingGuideSection";
|
||||||
|
import { PlanChannelStrategySection } from "@/features/plan/ui/PlanChannelStrategySection";
|
||||||
|
import { PlanContentCalendarSection } from "@/features/plan/ui/PlanContentCalendarSection";
|
||||||
|
import { PlanContentStrategySection } from "@/features/plan/ui/PlanContentStrategySection";
|
||||||
|
import { PlanCtaSection } from "@/features/plan/ui/PlanCtaSection";
|
||||||
|
import { PlanHeaderSection } from "@/features/plan/ui/PlanHeaderSection";
|
||||||
|
import { PlanMyAssetUploadSection } from "@/features/plan/ui/PlanMyAssetUploadSection";
|
||||||
|
|
||||||
|
export function MarketingPlanPage() {
|
||||||
|
const { data, error } = useCurrentMarketingPlan();
|
||||||
|
|
||||||
|
usePlanSubNav();
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-[50vh] flex items-center justify-center px-6">
|
||||||
|
<div className="text-center max-w-md">
|
||||||
|
<p className="title-16 text-[var(--color-status-critical-text)] mb-2 break-keep">
|
||||||
|
오류가 발생했습니다
|
||||||
|
</p>
|
||||||
|
<p className="body-14 text-neutral-60 break-keep">
|
||||||
|
{error ?? "마케팅 플랜을 찾을 수 없습니다."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-plan-content data-plan-id={data.id}>
|
||||||
|
<PlanHeaderSection />
|
||||||
|
<PlanBrandingGuideSection />
|
||||||
|
<PlanChannelStrategySection />
|
||||||
|
<PlanContentStrategySection />
|
||||||
|
<PlanContentCalendarSection />
|
||||||
|
<PlanAssetCollectionSection />
|
||||||
|
<PlanMyAssetUploadSection />
|
||||||
|
<PlanCtaSection />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { PageSection } from "@/components/section/PageSection";
|
||||||
|
import { useCurrentMarketingPlan } from "@/features/plan/hooks/useCurrentMarketingPlan";
|
||||||
|
import { AssetCollectionPanel } from "@/features/plan/ui/assetCollection/AssetCollectionPanel";
|
||||||
|
|
||||||
|
export function PlanAssetCollectionSection() {
|
||||||
|
const { data, error } = useCurrentMarketingPlan();
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageSection
|
||||||
|
id="asset-collection"
|
||||||
|
title="에셋 수집"
|
||||||
|
subtitle="에셋 수집 & 리퍼포징 소스"
|
||||||
|
animateEnter={false}
|
||||||
|
>
|
||||||
|
<AssetCollectionPanel data={data.assetCollection} />
|
||||||
|
</PageSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { PageSection } from "@/components/section/PageSection";
|
||||||
|
import { useCurrentMarketingPlan } from "@/features/plan/hooks/useCurrentMarketingPlan";
|
||||||
|
import { BrandingGuidePanel } from "@/features/plan/ui/branding/BrandingGuidePanel";
|
||||||
|
|
||||||
|
export function PlanBrandingGuideSection() {
|
||||||
|
const { data, error } = useCurrentMarketingPlan();
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageSection
|
||||||
|
id="branding-guide"
|
||||||
|
title="브랜딩 가이드"
|
||||||
|
subtitle="브랜딩 가이드 빌드"
|
||||||
|
animateEnter={false}
|
||||||
|
>
|
||||||
|
<BrandingGuidePanel data={data.brandGuide} />
|
||||||
|
</PageSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { PageSection } from "@/components/section/PageSection";
|
||||||
|
import { useCurrentMarketingPlan } from "@/features/plan/hooks/useCurrentMarketingPlan";
|
||||||
|
import { ChannelStrategyGrid } from "@/features/plan/ui/channelStrategy/ChannelStrategyGrid";
|
||||||
|
|
||||||
|
export function PlanChannelStrategySection() {
|
||||||
|
const { data, error } = useCurrentMarketingPlan();
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageSection
|
||||||
|
id="channel-strategy"
|
||||||
|
title="채널 전략"
|
||||||
|
subtitle="채널별 커뮤니케이션 전략"
|
||||||
|
dark
|
||||||
|
animateEnter={false}
|
||||||
|
>
|
||||||
|
<ChannelStrategyGrid channels={data.channelStrategies} />
|
||||||
|
</PageSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { PageSection } from "@/components/section/PageSection";
|
||||||
|
import { useCurrentMarketingPlan } from "@/features/plan/hooks/useCurrentMarketingPlan";
|
||||||
|
import { ContentCalendarPanel } from "@/features/plan/ui/contentCalendar/ContentCalendarPanel";
|
||||||
|
|
||||||
|
export function PlanContentCalendarSection() {
|
||||||
|
const { data, error } = useCurrentMarketingPlan();
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageSection
|
||||||
|
id="content-calendar"
|
||||||
|
title="콘텐츠 캘린더"
|
||||||
|
subtitle="콘텐츠 캘린더 (월간)"
|
||||||
|
dark
|
||||||
|
animateEnter={false}
|
||||||
|
>
|
||||||
|
<ContentCalendarPanel data={data.calendar} />
|
||||||
|
</PageSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { PageSection } from "@/components/section/PageSection";
|
||||||
|
import { useCurrentMarketingPlan } from "@/features/plan/hooks/useCurrentMarketingPlan";
|
||||||
|
import { ContentStrategyPanel } from "@/features/plan/ui/contentStrategy/ContentStrategyPanel";
|
||||||
|
|
||||||
|
export function PlanContentStrategySection() {
|
||||||
|
const { data, error } = useCurrentMarketingPlan();
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageSection
|
||||||
|
id="content-strategy"
|
||||||
|
title="콘텐츠 전략"
|
||||||
|
subtitle="콘텐츠 마케팅 전략"
|
||||||
|
animateEnter={false}
|
||||||
|
>
|
||||||
|
<ContentStrategyPanel data={data.contentStrategy} />
|
||||||
|
</PageSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import DownloadIcon from "@/assets/report/download.svg?react";
|
||||||
|
import { Button } from "@/components/atoms/Button";
|
||||||
|
import { useCurrentMarketingPlan } from "@/features/plan/hooks/useCurrentMarketingPlan";
|
||||||
|
|
||||||
|
function PlanCtaRocketIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={className}
|
||||||
|
width={28}
|
||||||
|
height={28}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `DEMO/src/components/plan/PlanCTA.tsx` 레이아웃·카피·버튼 구성과 동일 (PDF는 Phase 1 목 동작). */
|
||||||
|
export function PlanCtaSection() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data, error } = useCurrentMarketingPlan();
|
||||||
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const planId = data.id;
|
||||||
|
|
||||||
|
const exportPdf = () => {
|
||||||
|
setIsExporting(true);
|
||||||
|
window.setTimeout(() => setIsExporting(false), 900);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="py-16 md:py-20 px-6 scroll-mt-36" aria-label="다음 단계 안내">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<div
|
||||||
|
data-cta-card
|
||||||
|
className="rounded-2xl bg-gradient-to-r from-[var(--color-marketing-cream)] via-[var(--color-marketing-lilac)] to-[var(--color-marketing-ice)] p-10 md:p-14 text-center"
|
||||||
|
>
|
||||||
|
<div className="flex justify-center mb-6">
|
||||||
|
<div className="w-14 h-14 rounded-full bg-white/80 backdrop-blur-sm border border-white/40 flex items-center justify-center">
|
||||||
|
<PlanCtaRocketIcon className="text-violet-700" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="font-serif text-2xl md:text-3xl font-bold text-navy-950 mb-3 break-keep">
|
||||||
|
콘텐츠 제작을 시작하세요
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p className="text-navy-950/60 mb-8 max-w-lg mx-auto break-keep">
|
||||||
|
INFINITH가 브랜딩부터 콘텐츠 제작, 채널 배포까지 자동화합니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => navigate(`/studio/${planId}`)}
|
||||||
|
className="inline-flex items-center justify-center gap-2 px-6 py-3 text-sm font-medium hover:shadow-lg transition-shadow"
|
||||||
|
>
|
||||||
|
<span className="break-keep">콘텐츠 제작 시작</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={exportPdf}
|
||||||
|
disabled={isExporting}
|
||||||
|
className="inline-flex items-center justify-center gap-2 px-6 py-3 text-sm font-medium text-navy-950 shadow-sm hover:shadow-md disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isExporting ? (
|
||||||
|
<span
|
||||||
|
className="w-4 h-4 border-2 border-navy-950 border-t-transparent rounded-full animate-spin shrink-0"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DownloadIcon width={16} height={16} className="shrink-0" aria-hidden />
|
||||||
|
)}
|
||||||
|
<span className="break-keep">플랜 다운로드</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { useCurrentMarketingPlan } from "@/features/plan/hooks/useCurrentMarketingPlan";
|
||||||
|
import { PlanHeaderDaysBadge } from "@/features/plan/ui/header/PlanHeaderDaysBadge";
|
||||||
|
import { PlanHeaderHeroBlobs } from "@/features/plan/ui/header/PlanHeaderHeroBlobs";
|
||||||
|
import { PlanHeaderHeroColumn } from "@/features/plan/ui/header/PlanHeaderHeroColumn";
|
||||||
|
import { PLAN_HEADER_BG_CLASS } from "@/features/plan/ui/header/planHeaderSectionStyles";
|
||||||
|
|
||||||
|
export function PlanHeaderSection() {
|
||||||
|
const { data, error } = useCurrentMarketingPlan();
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label="마케팅 플랜 헤더"
|
||||||
|
className={`relative overflow-hidden scroll-mt-36 py-20 md:py-28 px-6 ${PLAN_HEADER_BG_CLASS}`}
|
||||||
|
>
|
||||||
|
<PlanHeaderHeroBlobs />
|
||||||
|
|
||||||
|
<div className="relative max-w-7xl mx-auto">
|
||||||
|
<div className="flex flex-col md:flex-row items-center md:items-start justify-between gap-10">
|
||||||
|
<PlanHeaderHeroColumn
|
||||||
|
clinicName={data.clinicName}
|
||||||
|
clinicNameEn={data.clinicNameEn}
|
||||||
|
date={data.createdAt}
|
||||||
|
targetUrl={data.targetUrl}
|
||||||
|
/>
|
||||||
|
<PlanHeaderDaysBadge />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { PageSection } from "@/components/section/PageSection";
|
||||||
|
import { useCurrentMarketingPlan } from "@/features/plan/hooks/useCurrentMarketingPlan";
|
||||||
|
import { MyAssetUploadPanel } from "@/features/plan/ui/myAssets/MyAssetUploadPanel";
|
||||||
|
|
||||||
|
export function PlanMyAssetUploadSection() {
|
||||||
|
const { data, error } = useCurrentMarketingPlan();
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-no-print>
|
||||||
|
<PageSection id="my-asset-upload" title="My Assets" subtitle="나의 에셋 업로드" animateEnter={false}>
|
||||||
|
<MyAssetUploadPanel />
|
||||||
|
</PageSection>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||||
|
import { Button } from "@/components/atoms/Button";
|
||||||
|
|
||||||
|
export type SegmentTabButtonProps = Omit<
|
||||||
|
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
"type" | "className"
|
||||||
|
> & {
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
/** 비활성 탭에 `card-shadow` (에셋 업로드 필터 등) */
|
||||||
|
inactiveShadow?: boolean;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SegmentTabButton({
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
inactiveShadow = false,
|
||||||
|
className = "",
|
||||||
|
...rest
|
||||||
|
}: SegmentTabButtonProps) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant={active ? "primary" : "outline"}
|
||||||
|
onClick={onClick}
|
||||||
|
className={[
|
||||||
|
"px-4 py-2 body-14-medium",
|
||||||
|
!active && inactiveShadow ? "card-shadow" : "",
|
||||||
|
className,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import ChannelYoutubeIcon from "@/assets/icons/channel-youtube.svg?react";
|
||||||
|
import { Pill } from "@/components/atoms/Pill";
|
||||||
|
import { Surface } from "@/components/atoms/Surface";
|
||||||
|
import { SegmentTabButton } from "@/features/plan/ui/SegmentTabButton";
|
||||||
|
import type { AssetCollectionData } from "@/features/plan/types/marketingPlan";
|
||||||
|
import {
|
||||||
|
ASSET_COLLECTION_FILTER_TABS,
|
||||||
|
type AssetCollectionFilterKey,
|
||||||
|
} from "@/features/plan/ui/assetCollection/assetCollectionFilterTabs";
|
||||||
|
import {
|
||||||
|
assetSourceBadgeClass,
|
||||||
|
assetStatusConfig,
|
||||||
|
assetTypeBadgeClass,
|
||||||
|
assetTypeDisplayLabel,
|
||||||
|
formatYoutubeViews,
|
||||||
|
} from "@/features/plan/ui/assetCollection/assetCollectionBadgeClass";
|
||||||
|
|
||||||
|
type AssetCollectionPanelProps = {
|
||||||
|
data: AssetCollectionData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AssetCollectionPanel({ data }: AssetCollectionPanelProps) {
|
||||||
|
const [activeFilter, setActiveFilter] = useState<AssetCollectionFilterKey>("all");
|
||||||
|
|
||||||
|
const filteredAssets =
|
||||||
|
activeFilter === "all" ? data.assets : data.assets.filter((a) => a.source === activeFilter);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap gap-2 mb-8" role="tablist" aria-label="에셋 출처 필터">
|
||||||
|
{ASSET_COLLECTION_FILTER_TABS.map((tab) => {
|
||||||
|
const isActive = activeFilter === tab.key;
|
||||||
|
return (
|
||||||
|
<SegmentTabButton
|
||||||
|
key={tab.key}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive}
|
||||||
|
active={isActive}
|
||||||
|
onClick={() => setActiveFilter(tab.key)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</SegmentTabButton>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-12">
|
||||||
|
{filteredAssets.map((asset) => {
|
||||||
|
const statusInfo = assetStatusConfig(asset.status);
|
||||||
|
return (
|
||||||
|
<Surface key={asset.id}>
|
||||||
|
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
||||||
|
<Pill className={`shrink-0 ${assetSourceBadgeClass(asset.source)}`}>{asset.sourceLabel}</Pill>
|
||||||
|
<Pill className={`shrink-0 ${assetTypeBadgeClass(asset.type)}`}>{assetTypeDisplayLabel(asset.type)}</Pill>
|
||||||
|
<Pill className={`ml-auto shrink-0 ${statusInfo.className}`}>{statusInfo.label}</Pill>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 className="title-14 text-navy-900 mb-1 break-keep">{asset.title}</h4>
|
||||||
|
<p className="body-14 text-neutral-70 mb-3 break-keep">{asset.description}</p>
|
||||||
|
|
||||||
|
{asset.repurposingSuggestions.length > 0 ? (
|
||||||
|
<div>
|
||||||
|
<p className="label-12 font-semibold text-neutral-60 uppercase mb-2 break-keep">
|
||||||
|
Repurposing →
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{asset.repurposingSuggestions.map((suggestion, j) => (
|
||||||
|
<Pill key={j} size="sm" className="shrink-0 bg-lavender-100 text-violet-700">
|
||||||
|
{suggestion}
|
||||||
|
</Pill>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.youtubeRepurpose.length > 0 ? (
|
||||||
|
<div>
|
||||||
|
<h3 className="font-serif headline-24 text-navy-900 mb-4 break-keep">
|
||||||
|
YouTube Top Videos for Repurposing
|
||||||
|
</h3>
|
||||||
|
<div className="flex overflow-x-auto gap-4 pb-4 scrollbar-hide">
|
||||||
|
{data.youtubeRepurpose.map((video) => (
|
||||||
|
<Surface key={video.title} className="min-w-[280px] shrink-0">
|
||||||
|
<div className="flex items-start gap-2 mb-3 min-w-0">
|
||||||
|
<ChannelYoutubeIcon width={18} height={18} className="text-[var(--color-status-critical-dot)] shrink-0 mt-1" aria-hidden />
|
||||||
|
<h4 className="title-14 text-navy-900 break-keep min-w-0">{video.title}</h4>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
||||||
|
<Pill className="shrink-0 bg-neutral-10 text-neutral-80">
|
||||||
|
{formatYoutubeViews(video.views)} views
|
||||||
|
</Pill>
|
||||||
|
<Pill
|
||||||
|
className={
|
||||||
|
video.type === "Short"
|
||||||
|
? "shrink-0 bg-lavender-100 text-violet-700 border border-lavender-300"
|
||||||
|
: "shrink-0 bg-[var(--color-status-info-bg)] text-[var(--color-status-info-text)] border border-[var(--color-status-info-border)]"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{video.type}
|
||||||
|
</Pill>
|
||||||
|
</div>
|
||||||
|
<p className="label-12 font-semibold text-neutral-60 uppercase mb-2 break-keep">Repurpose As:</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{video.repurposeAs.map((suggestion, j) => (
|
||||||
|
<Pill key={j} size="sm" className="shrink-0 bg-lavender-100 text-violet-700">
|
||||||
|
{suggestion}
|
||||||
|
</Pill>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Surface>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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"];
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import type { ComponentType, SVGProps } from "react";
|
||||||
|
import ChannelFacebookIcon from "@/assets/icons/channel-facebook.svg?react";
|
||||||
|
import ChannelInstagramIcon from "@/assets/icons/channel-instagram.svg?react";
|
||||||
|
import ChannelYoutubeIcon from "@/assets/icons/channel-youtube.svg?react";
|
||||||
|
import GlobeIcon from "@/assets/report/globe.svg?react";
|
||||||
|
import MessageCircleIcon from "@/assets/report/message-circle.svg?react";
|
||||||
|
import VideoIcon from "@/assets/icons/video.svg?react";
|
||||||
|
|
||||||
|
type SvgIcon = ComponentType<SVGProps<SVGSVGElement>>;
|
||||||
|
|
||||||
|
const CHANNEL_ICON_MAP: Record<string, SvgIcon> = {
|
||||||
|
youtube: ChannelYoutubeIcon,
|
||||||
|
instagram: ChannelInstagramIcon,
|
||||||
|
facebook: ChannelFacebookIcon,
|
||||||
|
globe: GlobeIcon,
|
||||||
|
video: VideoIcon,
|
||||||
|
messagesquare: MessageCircleIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeIconKey(icon: string): string {
|
||||||
|
return icon.toLowerCase().replace(/-/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
type BrandingChannelIconProps = {
|
||||||
|
icon: string;
|
||||||
|
className?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BrandingChannelIcon({ icon, className, width = 18, height = 18 }: BrandingChannelIconProps) {
|
||||||
|
const key = normalizeIconKey(icon);
|
||||||
|
const Icon = CHANNEL_ICON_MAP[key] ?? GlobeIcon;
|
||||||
|
return <Icon className={className} width={width} height={height} aria-hidden />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { Surface } from "@/components/atoms/Surface";
|
||||||
|
import type { BrandGuide } from "@/features/plan/types/marketingPlan";
|
||||||
|
import { BrandingChannelIcon } from "@/features/plan/ui/branding/BrandingChannelIcon";
|
||||||
|
import {
|
||||||
|
brandingChannelStatusBadgeClass,
|
||||||
|
brandingChannelStatusLabel,
|
||||||
|
} from "@/features/plan/ui/branding/brandingChannelStatusClass";
|
||||||
|
|
||||||
|
type BrandingChannelRulesTabProps = {
|
||||||
|
channels: BrandGuide["channelBranding"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BrandingChannelRulesTab({ channels }: BrandingChannelRulesTabProps) {
|
||||||
|
return (
|
||||||
|
<div className="animate-fade-in-up">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{channels.map((ch) => (
|
||||||
|
<Surface key={ch.channel}>
|
||||||
|
<div className="flex items-center gap-3 mb-4 min-w-0">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-violet-500/10 to-violet-700/10 flex items-center justify-center shrink-0">
|
||||||
|
<BrandingChannelIcon icon={ch.icon} className="text-violet-500" />
|
||||||
|
</div>
|
||||||
|
<p className="font-bold text-navy-900 break-keep truncate min-w-0">{ch.channel}</p>
|
||||||
|
<span
|
||||||
|
className={`ml-auto text-xs font-medium px-3 py-1 rounded-full border shrink-0 break-keep ${brandingChannelStatusBadgeClass(ch.currentStatus)}`}
|
||||||
|
>
|
||||||
|
{brandingChannelStatusLabel(ch.currentStatus)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 body-14">
|
||||||
|
<div>
|
||||||
|
<p className="text-neutral-60 label-12 uppercase tracking-wide mb-1 break-keep">Profile Photo</p>
|
||||||
|
<p className="text-neutral-80 font-medium break-keep">{ch.profilePhoto}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-neutral-60 label-12 uppercase tracking-wide mb-1 break-keep">Banner Spec</p>
|
||||||
|
<p className="text-neutral-80 font-medium break-keep">{ch.bannerSpec}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-neutral-60 label-12 uppercase tracking-wide mb-1 break-keep">Bio Template</p>
|
||||||
|
<div className="bg-neutral-10 rounded-xl p-3 border border-neutral-20">
|
||||||
|
<p className="font-mono label-12 text-neutral-70 whitespace-pre-wrap break-keep">{ch.bioTemplate}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Surface>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { BrandConsistencyMap } from "@/components/brand/BrandConsistencyMap";
|
||||||
|
import { SegmentTabButton } from "@/features/plan/ui/SegmentTabButton";
|
||||||
|
import type { BrandGuide } from "@/features/plan/types/marketingPlan";
|
||||||
|
import { BRANDING_GUIDE_TAB_ITEMS, type BrandingGuideTabKey } from "@/features/plan/ui/branding/brandingTabItems";
|
||||||
|
import { BrandingChannelRulesTab } from "@/features/plan/ui/branding/BrandingChannelRulesTab";
|
||||||
|
import { BrandingToneVoiceTab } from "@/features/plan/ui/branding/BrandingToneVoiceTab";
|
||||||
|
import { BrandingVisualIdentityTab } from "@/features/plan/ui/branding/BrandingVisualIdentityTab";
|
||||||
|
|
||||||
|
type BrandingGuidePanelProps = {
|
||||||
|
data: BrandGuide;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BrandingGuidePanel({ data }: BrandingGuidePanelProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<BrandingGuideTabKey>("visual");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap gap-2 mb-8" role="tablist" aria-label="브랜딩 가이드 탭">
|
||||||
|
{BRANDING_GUIDE_TAB_ITEMS.map((tab) => {
|
||||||
|
const isActive = activeTab === tab.key;
|
||||||
|
return (
|
||||||
|
<SegmentTabButton
|
||||||
|
key={tab.key}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive}
|
||||||
|
title={tab.labelKr}
|
||||||
|
active={isActive}
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</SegmentTabButton>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === "visual" ? <BrandingVisualIdentityTab data={data} /> : null}
|
||||||
|
{activeTab === "tone" ? <BrandingToneVoiceTab tone={data.toneOfVoice} /> : null}
|
||||||
|
{activeTab === "channels" ? <BrandingChannelRulesTab channels={data.channelBranding} /> : null}
|
||||||
|
{activeTab === "consistency" ? (
|
||||||
|
<BrandConsistencyMap inconsistencies={data.brandInconsistencies} showHeading={false} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
import CheckCircleIcon from "@/assets/report/check-circle.svg?react";
|
||||||
|
import XCircleIcon from "@/assets/report/x-circle.svg?react";
|
||||||
|
import type { BrandGuide } from "@/features/plan/types/marketingPlan";
|
||||||
|
|
||||||
|
type BrandingToneVoiceTabProps = {
|
||||||
|
tone: BrandGuide["toneOfVoice"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BrandingToneVoiceTab({ tone }: BrandingToneVoiceTabProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-fade-in-up">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-serif font-bold text-2xl text-navy-900 mb-4 break-keep">Personality</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{tone.personality.map((trait) => (
|
||||||
|
<span
|
||||||
|
key={trait}
|
||||||
|
className="bg-gradient-to-r from-violet-700/10 to-navy-950/10 text-violet-700 border border-lavender-300 rounded-full px-4 py-2 body-14-medium break-keep"
|
||||||
|
>
|
||||||
|
{trait}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="font-serif font-bold text-2xl text-navy-900 mb-4 break-keep">Communication Style</h3>
|
||||||
|
<div className="rounded-2xl bg-neutral-10 p-6 border border-neutral-20">
|
||||||
|
<p className="body-16 text-neutral-80 leading-relaxed break-keep">{tone.communicationStyle}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<h4 className="title-14 text-[var(--color-status-good-text)] mb-3 flex items-center gap-2 break-keep">
|
||||||
|
<CheckCircleIcon width={16} height={16} className="shrink-0 text-[var(--color-status-good-dot)]" aria-hidden />
|
||||||
|
DO
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{tone.doExamples.map((example, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="border-l-4 border-[var(--color-status-good-dot)] bg-[var(--color-status-good-bg)]/30 p-4 rounded-r-xl"
|
||||||
|
>
|
||||||
|
<p className="body-14 text-neutral-80 break-keep">{example}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="title-14 text-[var(--color-status-critical-text)] mb-3 flex items-center gap-2 break-keep">
|
||||||
|
<XCircleIcon width={16} height={16} className="shrink-0 text-[var(--color-status-critical-dot)]" aria-hidden />
|
||||||
|
DON'T
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{tone.dontExamples.map((example, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="border-l-4 border-[var(--color-status-critical-dot)] bg-[var(--color-status-critical-bg)]/30 p-4 rounded-r-xl"
|
||||||
|
>
|
||||||
|
<p className="body-14 text-neutral-80 break-keep">{example}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
import CheckCircleIcon from "@/assets/report/check-circle.svg?react";
|
||||||
|
import XCircleIcon from "@/assets/report/x-circle.svg?react";
|
||||||
|
import { Surface } from "@/components/atoms/Surface";
|
||||||
|
import type { BrandGuide } from "@/features/plan/types/marketingPlan";
|
||||||
|
|
||||||
|
type BrandingVisualIdentityTabProps = {
|
||||||
|
data: BrandGuide;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BrandingVisualIdentityTab({ data }: BrandingVisualIdentityTabProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 animate-fade-in-up">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-serif font-bold text-2xl text-navy-900 mb-4 break-keep">Color Palette</h3>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||||
|
{data.colors.map((swatch, i) => (
|
||||||
|
<Surface key={`${swatch.name}-${swatch.hex}-${i}`} padding="none" overflowHidden>
|
||||||
|
<div className="h-20" style={{ backgroundColor: swatch.hex }} />
|
||||||
|
<div className="p-3">
|
||||||
|
<p className="font-mono body-14 text-neutral-80">{swatch.hex}</p>
|
||||||
|
<p className="title-14 text-navy-900 break-keep">{swatch.name}</p>
|
||||||
|
<p className="label-12 text-neutral-60 break-keep">{swatch.usage}</p>
|
||||||
|
</div>
|
||||||
|
</Surface>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="font-serif font-bold text-2xl text-navy-900 mb-4 break-keep">Typography</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{data.fonts.map((spec) => (
|
||||||
|
<Surface key={`${spec.family}-${spec.weight}`}>
|
||||||
|
<p className="label-12 text-neutral-60 uppercase tracking-wide mb-2 break-keep">{spec.family}</p>
|
||||||
|
<p
|
||||||
|
className={`mb-3 text-navy-900 break-keep ${
|
||||||
|
spec.weight.toLowerCase().includes("bold") ? "text-2xl font-bold" : "text-lg"
|
||||||
|
}`}
|
||||||
|
style={{ fontFamily: spec.family }}
|
||||||
|
>
|
||||||
|
{spec.sampleText}
|
||||||
|
</p>
|
||||||
|
<p className="label-12 text-neutral-60 break-keep">
|
||||||
|
<span className="font-medium text-neutral-80">{spec.weight}</span>
|
||||||
|
{" · "}
|
||||||
|
{spec.usage}
|
||||||
|
</p>
|
||||||
|
</Surface>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="font-serif font-bold text-2xl text-navy-900 mb-4 break-keep">Logo Rules</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{data.logoRules.map((rule) => (
|
||||||
|
<div
|
||||||
|
key={rule.rule}
|
||||||
|
className={`rounded-2xl p-5 ${
|
||||||
|
rule.correct
|
||||||
|
? "border-2 border-[var(--color-status-good-border)] bg-[var(--color-status-good-bg)]/30"
|
||||||
|
: "border-2 border-[var(--color-status-critical-border)] bg-[var(--color-status-critical-bg)]/30"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{rule.correct ? (
|
||||||
|
<CheckCircleIcon
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
className="text-[var(--color-status-good-dot)] shrink-0 mt-1"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<XCircleIcon
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
className="text-[var(--color-status-critical-dot)] shrink-0 mt-1"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="title-14 text-navy-900 break-keep">{rule.rule}</p>
|
||||||
|
<p className="body-14 text-neutral-70 mt-1 break-keep">{rule.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"];
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
import CalendarIcon from "@/assets/report/calendar.svg?react";
|
||||||
|
import { Pill } from "@/components/atoms/Pill";
|
||||||
|
import { Surface } from "@/components/atoms/Surface";
|
||||||
|
import type { ChannelStrategyCard } from "@/features/plan/types/marketingPlan";
|
||||||
|
import { BrandingChannelIcon } from "@/features/plan/ui/branding/BrandingChannelIcon";
|
||||||
|
import { channelStrategyPriorityPillClass } from "@/features/plan/ui/channelStrategy/channelStrategyPillClass";
|
||||||
|
|
||||||
|
type ChannelStrategyGridProps = {
|
||||||
|
channels: ChannelStrategyCard[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function ChannelStrategyCard({ ch, index }: { ch: ChannelStrategyCard; index: number }) {
|
||||||
|
const delayClass =
|
||||||
|
index === 0
|
||||||
|
? ""
|
||||||
|
: index === 1
|
||||||
|
? "animation-delay-100"
|
||||||
|
: index === 2
|
||||||
|
? "animation-delay-200"
|
||||||
|
: "animation-delay-300";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Surface
|
||||||
|
bordered={false}
|
||||||
|
padding="lg"
|
||||||
|
interactive="lift"
|
||||||
|
className={`animate-fade-in-up ${delayClass}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-4 min-w-0">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-[var(--color-status-good-bg)] flex items-center justify-center shrink-0">
|
||||||
|
<BrandingChannelIcon icon={ch.icon} className="text-[var(--color-status-good-dot)]" width={20} height={20} />
|
||||||
|
</div>
|
||||||
|
<h4 className="title-18-md-20 text-navy-900 flex-1 min-w-0 break-keep">{ch.channelName}</h4>
|
||||||
|
<Pill weight="semibold" className={`shrink-0 border ${channelStrategyPriorityPillClass(ch.priority)}`}>
|
||||||
|
{ch.priority}
|
||||||
|
</Pill>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mb-4 flex-wrap">
|
||||||
|
<Pill className="border bg-[var(--color-status-critical-bg)] text-[var(--color-status-critical-text)] border-[var(--color-status-critical-border)]">
|
||||||
|
{ch.currentStatus}
|
||||||
|
</Pill>
|
||||||
|
<span className="body-14 text-neutral-60 shrink-0" aria-hidden>
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
<Pill className="border bg-[var(--color-status-good-bg)] text-[var(--color-status-good-text)] border-[var(--color-status-good-border)]">
|
||||||
|
{ch.targetGoal}
|
||||||
|
</Pill>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
|
{ch.contentTypes.map((type) => (
|
||||||
|
<Pill key={type} className="bg-neutral-10 border border-neutral-20 text-neutral-70">
|
||||||
|
{type}
|
||||||
|
</Pill>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mb-3 min-w-0">
|
||||||
|
<CalendarIcon width={14} height={14} className="text-[var(--color-status-good-dot)] shrink-0" aria-hidden />
|
||||||
|
<p className="body-14 text-neutral-70 break-keep">{ch.postingFrequency}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="body-14 italic text-violet-500/80 mb-4 break-keep">{ch.tone}</p>
|
||||||
|
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{ch.formatGuidelines.map((guideline, i) => (
|
||||||
|
<li key={i} className="flex items-start gap-2">
|
||||||
|
<span className="shrink-0 w-2 h-2 rounded-full bg-violet-500 mt-2" aria-hidden />
|
||||||
|
<span className="body-14 text-neutral-80 break-keep">{guideline}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChannelStrategyGrid({ channels }: ChannelStrategyGridProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{channels.map((ch, index) => (
|
||||||
|
<ChannelStrategyCard key={ch.channelId} ch={ch} index={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
import { Pill } from "@/components/atoms/Pill";
|
||||||
|
import { Surface } from "@/components/atoms/Surface";
|
||||||
|
import type { CalendarData, CalendarEntry, ContentCategory } from "@/features/plan/types/marketingPlan";
|
||||||
|
import {
|
||||||
|
CALENDAR_CONTENT_TYPE_LABELS,
|
||||||
|
CALENDAR_DAY_HEADERS,
|
||||||
|
calendarContentTypeVisual,
|
||||||
|
} from "@/features/plan/ui/contentCalendar/calendarContentTypeVisual";
|
||||||
|
import { ContentCalendarTypeIcon } from "@/features/plan/ui/contentCalendar/ContentCalendarTypeIcon";
|
||||||
|
|
||||||
|
type ContentCalendarPanelProps = {
|
||||||
|
data: CalendarData;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildDayCells(entries: CalendarEntry[]): CalendarEntry[][] {
|
||||||
|
const dayCells: CalendarEntry[][] = Array.from({ length: 7 }, () => []);
|
||||||
|
for (const entry of entries) {
|
||||||
|
const dayIndex = entry.dayOfWeek;
|
||||||
|
if (dayIndex >= 0 && dayIndex <= 6) {
|
||||||
|
dayCells[dayIndex].push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dayCells;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContentCalendarPanel({ data }: ContentCalendarPanelProps) {
|
||||||
|
const legendTypes = Object.keys(CALENDAR_CONTENT_TYPE_LABELS) as ContentCategory[];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap gap-4 mb-8">
|
||||||
|
{data.monthlySummary.map((item) => {
|
||||||
|
const v = calendarContentTypeVisual(item.type);
|
||||||
|
return (
|
||||||
|
<Surface
|
||||||
|
key={item.type}
|
||||||
|
padding="sm"
|
||||||
|
surface="none"
|
||||||
|
className={`flex-1 min-w-[140px] ${v.summaryBg} ${v.summaryBorder}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: item.color }} aria-hidden />
|
||||||
|
<span className={`body-14-medium break-keep ${v.summaryText}`}>{item.label}</span>
|
||||||
|
</div>
|
||||||
|
<span className={`text-2xl font-bold break-keep ${v.summaryText}`}>{item.count}</span>
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.weeks.map((week) => {
|
||||||
|
const dayCells = buildDayCells(week.entries);
|
||||||
|
return (
|
||||||
|
<Surface key={week.weekNumber} className="mb-4">
|
||||||
|
<p className="title-14 text-navy-900 mb-3 break-keep">{week.label}</p>
|
||||||
|
<div className="grid grid-cols-7 gap-2">
|
||||||
|
{CALENDAR_DAY_HEADERS.map((day) => (
|
||||||
|
<div
|
||||||
|
key={day}
|
||||||
|
className="label-12 text-neutral-60 uppercase tracking-wide text-center mb-1 font-medium break-keep"
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{dayCells.map((entries, dayIdx) => (
|
||||||
|
<div
|
||||||
|
key={dayIdx}
|
||||||
|
className={`min-h-[80px] rounded-xl p-2 ${
|
||||||
|
entries.length > 0
|
||||||
|
? "bg-neutral-10/80 border border-neutral-20"
|
||||||
|
: "border border-dashed border-neutral-30/80"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{entries.map((entry, entryIdx) => {
|
||||||
|
const v = calendarContentTypeVisual(entry.contentType);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${entry.title}-${entryIdx}`}
|
||||||
|
className={`${v.entryBg} border ${v.entryBorder} rounded-lg p-2 mb-1 card-shadow`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1 mb-1">
|
||||||
|
<ContentCalendarTypeIcon type={entry.contentType} className={v.entryText} />
|
||||||
|
</div>
|
||||||
|
<p className="body-14 text-neutral-80 leading-tight break-keep">{entry.title}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3 mt-4">
|
||||||
|
{legendTypes.map((type) => {
|
||||||
|
const v = calendarContentTypeVisual(type);
|
||||||
|
return (
|
||||||
|
<Pill
|
||||||
|
key={type}
|
||||||
|
className={`${v.summaryBg} ${v.summaryText} border ${v.summaryBorder} card-shadow`}
|
||||||
|
>
|
||||||
|
{CALENDAR_CONTENT_TYPE_LABELS[type]}
|
||||||
|
</Pill>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import type { ComponentType, SVGProps } from "react";
|
||||||
|
import ExternalLinkIcon from "@/assets/icons/external-link.svg?react";
|
||||||
|
import TrendingUpIcon from "@/assets/icons/trending-up.svg?react";
|
||||||
|
import VideoIcon from "@/assets/icons/video.svg?react";
|
||||||
|
import Link2Icon from "@/assets/report/link-2.svg?react";
|
||||||
|
import type { ContentCategory } from "@/features/plan/types/marketingPlan";
|
||||||
|
|
||||||
|
type Svg = ComponentType<SVGProps<SVGSVGElement>>;
|
||||||
|
|
||||||
|
const MAP: Record<ContentCategory, Svg> = {
|
||||||
|
video: VideoIcon,
|
||||||
|
blog: Link2Icon,
|
||||||
|
social: ExternalLinkIcon,
|
||||||
|
ad: TrendingUpIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
type ContentCalendarTypeIconProps = {
|
||||||
|
type: ContentCategory;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ContentCalendarTypeIcon({ type, className }: ContentCalendarTypeIconProps) {
|
||||||
|
const Icon = MAP[type];
|
||||||
|
return <Icon width={11} height={11} className={className} aria-hidden />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
import type { ContentCategory } from "@/features/plan/types/marketingPlan";
|
||||||
|
|
||||||
|
export type CalendarTypeVisual = {
|
||||||
|
summaryBg: string;
|
||||||
|
summaryBorder: string;
|
||||||
|
summaryText: string;
|
||||||
|
entryBg: string;
|
||||||
|
entryBorder: string;
|
||||||
|
entryText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function calendarContentTypeVisual(type: ContentCategory): CalendarTypeVisual {
|
||||||
|
switch (type) {
|
||||||
|
case "video":
|
||||||
|
return {
|
||||||
|
summaryBg: "bg-[var(--color-status-good-bg)]",
|
||||||
|
summaryBorder: "border-[var(--color-status-good-border)]",
|
||||||
|
summaryText: "text-[var(--color-status-good-text)]",
|
||||||
|
entryBg: "bg-[var(--color-status-good-bg)]",
|
||||||
|
entryBorder: "border-[var(--color-status-good-border)]",
|
||||||
|
entryText: "text-[var(--color-status-good-text)]",
|
||||||
|
};
|
||||||
|
case "blog":
|
||||||
|
return {
|
||||||
|
summaryBg: "bg-[var(--color-status-info-bg)]",
|
||||||
|
summaryBorder: "border-[var(--color-status-info-border)]",
|
||||||
|
summaryText: "text-[var(--color-status-info-text)]",
|
||||||
|
entryBg: "bg-[var(--color-status-info-bg)]",
|
||||||
|
entryBorder: "border-[var(--color-status-info-border)]",
|
||||||
|
entryText: "text-[var(--color-status-info-text)]",
|
||||||
|
};
|
||||||
|
case "social":
|
||||||
|
return {
|
||||||
|
summaryBg: "bg-[var(--color-status-warning-bg)]",
|
||||||
|
summaryBorder: "border-[var(--color-status-warning-border)]",
|
||||||
|
summaryText: "text-[var(--color-status-warning-text)]",
|
||||||
|
entryBg: "bg-[var(--color-status-warning-bg)]",
|
||||||
|
entryBorder: "border-[var(--color-status-warning-border)]",
|
||||||
|
entryText: "text-[var(--color-status-warning-text)]",
|
||||||
|
};
|
||||||
|
case "ad":
|
||||||
|
return {
|
||||||
|
summaryBg: "bg-[var(--color-status-critical-bg)]",
|
||||||
|
summaryBorder: "border-[var(--color-status-critical-border)]",
|
||||||
|
summaryText: "text-[var(--color-status-critical-text)]",
|
||||||
|
entryBg: "bg-[var(--color-status-critical-bg)]",
|
||||||
|
entryBorder: "border-[var(--color-status-critical-border)]",
|
||||||
|
entryText: "text-[var(--color-status-critical-text)]",
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
summaryBg: "bg-neutral-10",
|
||||||
|
summaryBorder: "border-neutral-20",
|
||||||
|
summaryText: "text-neutral-80",
|
||||||
|
entryBg: "bg-neutral-10",
|
||||||
|
entryBorder: "border-neutral-20",
|
||||||
|
entryText: "text-neutral-80",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CALENDAR_CONTENT_TYPE_LABELS: Record<ContentCategory, string> = {
|
||||||
|
video: "Video",
|
||||||
|
blog: "Blog",
|
||||||
|
social: "Social",
|
||||||
|
ad: "Ad",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CALENDAR_DAY_HEADERS = ["월", "화", "수", "목", "금", "토", "일"] as const;
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { SegmentTabButton } from "@/features/plan/ui/SegmentTabButton";
|
||||||
|
import type { ContentStrategyData } from "@/features/plan/types/marketingPlan";
|
||||||
|
import {
|
||||||
|
CONTENT_STRATEGY_TAB_ITEMS,
|
||||||
|
type ContentStrategyTabKey,
|
||||||
|
} from "@/features/plan/ui/contentStrategy/contentStrategyTabItems";
|
||||||
|
import { ContentStrategyPillarsTab } from "@/features/plan/ui/contentStrategy/ContentStrategyPillarsTab";
|
||||||
|
import { ContentStrategyRepurposingTab } from "@/features/plan/ui/contentStrategy/ContentStrategyRepurposingTab";
|
||||||
|
import { ContentStrategyTypesTab } from "@/features/plan/ui/contentStrategy/ContentStrategyTypesTab";
|
||||||
|
import { ContentStrategyWorkflowTab } from "@/features/plan/ui/contentStrategy/ContentStrategyWorkflowTab";
|
||||||
|
|
||||||
|
type ContentStrategyPanelProps = {
|
||||||
|
data: ContentStrategyData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ContentStrategyPanel({ data }: ContentStrategyPanelProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<ContentStrategyTabKey>("pillars");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap gap-2 mb-8" role="tablist" aria-label="콘텐츠 전략 탭">
|
||||||
|
{CONTENT_STRATEGY_TAB_ITEMS.map((tab) => {
|
||||||
|
const isActive = activeTab === tab.key;
|
||||||
|
return (
|
||||||
|
<SegmentTabButton
|
||||||
|
key={tab.key}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive}
|
||||||
|
title={tab.labelKr}
|
||||||
|
active={isActive}
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</SegmentTabButton>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === "pillars" ? <ContentStrategyPillarsTab pillars={data.pillars} /> : null}
|
||||||
|
{activeTab === "types" ? <ContentStrategyTypesTab rows={data.typeMatrix} /> : null}
|
||||||
|
{activeTab === "workflow" ? <ContentStrategyWorkflowTab steps={data.workflow} /> : null}
|
||||||
|
{activeTab === "repurposing" ? (
|
||||||
|
<ContentStrategyRepurposingTab source={data.repurposingSource} outputs={data.repurposingOutputs} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { Pill } from "@/components/atoms/Pill";
|
||||||
|
import { Surface } from "@/components/atoms/Surface";
|
||||||
|
import type { ContentPillar } from "@/features/plan/types/marketingPlan";
|
||||||
|
|
||||||
|
type ContentStrategyPillarsTabProps = {
|
||||||
|
pillars: ContentPillar[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ContentStrategyPillarsTab({ pillars }: ContentStrategyPillarsTabProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid md:grid-cols-2 gap-6 animate-fade-in-up">
|
||||||
|
{pillars.map((pillar, i) => (
|
||||||
|
<Surface
|
||||||
|
key={pillar.title}
|
||||||
|
padding="lg"
|
||||||
|
className={`border-l-4 animate-fade-in-up ${
|
||||||
|
i === 1 ? "animation-delay-100" : i === 2 ? "animation-delay-200" : i >= 3 ? "animation-delay-300" : ""
|
||||||
|
}`}
|
||||||
|
style={{ borderLeftColor: pillar.color }}
|
||||||
|
>
|
||||||
|
<h4 className="font-serif headline-20 text-navy-900 mb-2 break-keep">{pillar.title}</h4>
|
||||||
|
<p className="body-14 text-neutral-70 mb-3 break-keep">{pillar.description}</p>
|
||||||
|
<Pill className="inline-block bg-neutral-10 text-neutral-80 mb-4">{pillar.relatedUSP}</Pill>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{pillar.exampleTopics.map((topic, j) => (
|
||||||
|
<li key={j} className="flex items-start gap-2 body-14 text-neutral-80">
|
||||||
|
<span
|
||||||
|
className="shrink-0 w-2 h-2 rounded-full mt-2"
|
||||||
|
style={{ backgroundColor: pillar.color }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="break-keep">{topic}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Surface>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import VideoIcon from "@/assets/icons/video.svg?react";
|
||||||
|
import { Surface } from "@/components/atoms/Surface";
|
||||||
|
import { UI_PRIMARY_GRADIENT_CLASS } from "@/components/atoms/uiTokens";
|
||||||
|
import type { RepurposingOutput } from "@/features/plan/types/marketingPlan";
|
||||||
|
|
||||||
|
type ContentStrategyRepurposingTabProps = {
|
||||||
|
source: string;
|
||||||
|
outputs: RepurposingOutput[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ContentStrategyRepurposingTab({ source, outputs }: ContentStrategyRepurposingTabProps) {
|
||||||
|
return (
|
||||||
|
<div className="animate-fade-in-up">
|
||||||
|
<div className={`rounded-2xl p-6 text-white mb-6 ${UI_PRIMARY_GRADIENT_CLASS}`}>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<VideoIcon width={28} height={28} className="text-lavender-200 shrink-0" aria-hidden />
|
||||||
|
<h4 className="font-serif headline-20 break-keep">{source}</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center mb-6">
|
||||||
|
<div className="w-px h-8 bg-gradient-to-b from-violet-700 to-neutral-30" aria-hidden />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{outputs.map((output, i) => (
|
||||||
|
<Surface key={`${output.format}-${output.channel}-${i}`} radius="xl" padding="sm">
|
||||||
|
<p className="title-14 text-navy-900 mb-1 break-keep">{output.format}</p>
|
||||||
|
<p className="label-12 text-neutral-60 mb-1 break-keep">{output.channel}</p>
|
||||||
|
<p className="label-12 text-neutral-70 break-keep">{output.description}</p>
|
||||||
|
</Surface>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { Pill } from "@/components/atoms/Pill";
|
||||||
|
import type { ContentTypeRow } from "@/features/plan/types/marketingPlan";
|
||||||
|
import { contentStrategyChannelBadgeClass } from "@/features/plan/ui/contentStrategy/contentStrategyChannelBadgeClass";
|
||||||
|
|
||||||
|
type ContentStrategyTypesTabProps = {
|
||||||
|
rows: ContentTypeRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ContentStrategyTypesTab({ rows }: ContentStrategyTypesTabProps) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl overflow-hidden border border-neutral-20 animate-fade-in-up">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="min-w-[640px]">
|
||||||
|
<div className="grid grid-cols-4 bg-navy-900 text-white">
|
||||||
|
<div className="px-6 py-4 body-14-medium break-keep">Format</div>
|
||||||
|
<div className="px-6 py-4 body-14-medium break-keep">Channels</div>
|
||||||
|
<div className="px-6 py-4 body-14-medium break-keep">Frequency</div>
|
||||||
|
<div className="px-6 py-4 body-14-medium break-keep">Purpose</div>
|
||||||
|
</div>
|
||||||
|
{rows.map((row, i) => (
|
||||||
|
<div
|
||||||
|
key={row.format}
|
||||||
|
className={`grid grid-cols-4 ${i % 2 === 0 ? "bg-white" : "bg-neutral-10"}`}
|
||||||
|
>
|
||||||
|
<div className="px-6 py-4 body-14-medium text-navy-900 break-keep">{row.format}</div>
|
||||||
|
<div className="px-6 py-4 flex flex-wrap gap-2">
|
||||||
|
{row.channels.map((ch) => (
|
||||||
|
<Pill key={ch} inlineFlex className={contentStrategyChannelBadgeClass(ch)}>
|
||||||
|
{ch}
|
||||||
|
</Pill>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-4 body-14 text-neutral-80 break-keep">{row.frequency}</div>
|
||||||
|
<div className="px-6 py-4 body-14 text-neutral-70 break-keep">{row.purpose}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import ChevronRightIcon from "@/assets/report/chevron-right.svg?react";
|
||||||
|
import { Pill } from "@/components/atoms/Pill";
|
||||||
|
import { Surface } from "@/components/atoms/Surface";
|
||||||
|
import { UI_PRIMARY_GRADIENT_CLASS } from "@/components/atoms/uiTokens";
|
||||||
|
import type { WorkflowStep } from "@/features/plan/types/marketingPlan";
|
||||||
|
|
||||||
|
type ContentStrategyWorkflowTabProps = {
|
||||||
|
steps: WorkflowStep[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ContentStrategyWorkflowTab({ steps }: ContentStrategyWorkflowTabProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex md:flex-row flex-col gap-4 items-stretch animate-fade-in-up">
|
||||||
|
{steps.map((step, i) => (
|
||||||
|
<div key={step.step} className="flex md:flex-row flex-col items-center gap-4 flex-1 min-w-0">
|
||||||
|
<Surface className="flex-1 w-full">
|
||||||
|
<div
|
||||||
|
className={`w-10 h-10 rounded-full text-white title-14 flex items-center justify-center mb-3 ${UI_PRIMARY_GRADIENT_CLASS}`}
|
||||||
|
>
|
||||||
|
{step.step}
|
||||||
|
</div>
|
||||||
|
<h4 className="title-14 text-navy-900 mb-1 break-keep">{step.name}</h4>
|
||||||
|
<p className="body-14 text-neutral-70 mb-3 break-keep">{step.description}</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Pill size="sm" weight="none" className="bg-lavender-100 text-violet-700 font-normal">
|
||||||
|
{step.owner}
|
||||||
|
</Pill>
|
||||||
|
<Pill size="sm" weight="none" className="bg-neutral-10 text-neutral-70 font-normal">
|
||||||
|
{step.duration}
|
||||||
|
</Pill>
|
||||||
|
</div>
|
||||||
|
</Surface>
|
||||||
|
{i < steps.length - 1 ? (
|
||||||
|
<ChevronRightIcon
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
className="text-neutral-40 shrink-0 hidden md:block"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
|
@ -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"];
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
/** DEMO PlanHeader 우측 90 Days 배지와 동일 */
|
||||||
|
export function PlanHeaderDaysBadge() {
|
||||||
|
return (
|
||||||
|
<div className="shrink-0">
|
||||||
|
<div className="w-32 h-32 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#021341] flex flex-col items-center justify-center shadow-lg">
|
||||||
|
<span className="text-4xl font-bold text-white leading-none">90</span>
|
||||||
|
<span className="text-sm text-purple-200 break-keep">Days</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
/**
|
||||||
|
* DEMO PlanHeader의 블롭 위치·색상과 동일 (motion 이동은 FE에서 미구현 — 정적 레이어만 유지).
|
||||||
|
*/
|
||||||
|
export function PlanHeaderHeroBlobs() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="absolute top-10 left-10 w-72 h-72 rounded-full bg-indigo-200/30 blur-3xl pointer-events-none"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute bottom-10 right-10 w-96 h-96 rounded-full bg-pink-200/30 blur-3xl pointer-events-none"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-80 h-80 rounded-full bg-purple-200/20 blur-3xl pointer-events-none"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { PlanHeaderMetaChips } from "@/features/plan/ui/header/PlanHeaderMetaChips";
|
||||||
|
|
||||||
|
type PlanHeaderHeroColumnProps = {
|
||||||
|
clinicName: string;
|
||||||
|
clinicNameEn: string;
|
||||||
|
date: string;
|
||||||
|
targetUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** DEMO `PlanHeader` 좌측 타이포·간격과 동일 */
|
||||||
|
export function PlanHeaderHeroColumn({
|
||||||
|
clinicName,
|
||||||
|
clinicNameEn,
|
||||||
|
date,
|
||||||
|
targetUrl,
|
||||||
|
}: PlanHeaderHeroColumnProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 text-center md:text-left">
|
||||||
|
<p className="text-xs font-semibold text-[#6C5CE7] mb-4 tracking-widest uppercase break-keep">
|
||||||
|
Marketing Execution Plan
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h1 className="font-serif text-4xl md:text-5xl font-bold text-[#0A1128] mb-3 break-keep">
|
||||||
|
{clinicName}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-xl text-slate-600 mb-8 break-keep">{clinicNameEn}</p>
|
||||||
|
|
||||||
|
<PlanHeaderMetaChips date={date} targetUrl={targetUrl} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import CalendarIcon from "@/assets/report/calendar.svg?react";
|
||||||
|
import GlobeIcon from "@/assets/report/globe.svg?react";
|
||||||
|
import { PLAN_HEADER_META_CHIP_CLASS } from "@/features/plan/ui/header/planHeaderSectionStyles";
|
||||||
|
|
||||||
|
type PlanHeaderMetaChipsProps = {
|
||||||
|
date: string;
|
||||||
|
targetUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** DEMO: 날짜·URL 모두 `<span>` 칩 (링크·truncate·호버 없음) */
|
||||||
|
export function PlanHeaderMetaChips({ date, targetUrl }: PlanHeaderMetaChipsProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-3 justify-center md:justify-start">
|
||||||
|
<span className={PLAN_HEADER_META_CHIP_CLASS}>
|
||||||
|
<CalendarIcon className="text-slate-400 shrink-0" width={14} height={14} aria-hidden />
|
||||||
|
<span className="break-keep">{date}</span>
|
||||||
|
</span>
|
||||||
|
<span className={PLAN_HEADER_META_CHIP_CLASS}>
|
||||||
|
<GlobeIcon className="text-slate-400 shrink-0" width={14} height={14} aria-hidden />
|
||||||
|
<span className="break-keep">{targetUrl}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -0,0 +1,333 @@
|
||||||
|
import { useCallback, useEffect, useRef, useState, type ChangeEvent, type DragEvent } from "react";
|
||||||
|
import { Pill } from "@/components/atoms/Pill";
|
||||||
|
import { Surface } from "@/components/atoms/Surface";
|
||||||
|
import { SegmentTabButton } from "@/features/plan/ui/SegmentTabButton";
|
||||||
|
|
||||||
|
type UploadCategory = "all" | "image" | "video" | "text";
|
||||||
|
|
||||||
|
interface UploadedAsset {
|
||||||
|
id: string;
|
||||||
|
file: File;
|
||||||
|
category: "image" | "video" | "text";
|
||||||
|
previewUrl: string | null;
|
||||||
|
name: string;
|
||||||
|
size: string;
|
||||||
|
uploadedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function categorize(file: File): "image" | "video" | "text" {
|
||||||
|
if (file.type.startsWith("image/")) return "image";
|
||||||
|
if (file.type.startsWith("video/")) return "video";
|
||||||
|
return "text";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`;
|
||||||
|
if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||||
|
return `${bytes} B`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uid() {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryConfig: Record<UploadCategory, { label: string }> = {
|
||||||
|
all: { label: "전체" },
|
||||||
|
image: { label: "Image" },
|
||||||
|
video: { label: "Video" },
|
||||||
|
text: { label: "Text" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoryBadge: Record<"image" | "video" | "text", string> = {
|
||||||
|
image: "bg-lavender-100 text-violet-800 shadow-[2px_3px_6px_rgba(155,138,212,0.12)]",
|
||||||
|
video: "bg-[var(--color-status-critical-bg)] text-[var(--color-status-critical-text)] shadow-[2px_3px_6px_rgba(212,136,154,0.12)]",
|
||||||
|
text: "bg-[var(--color-status-warning-bg)] text-[var(--color-status-warning-text)] shadow-[2px_3px_6px_rgba(212,168,114,0.12)]",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACCEPT_MAP: Record<string, string> = {
|
||||||
|
"image/*": ".jpg,.jpeg,.png,.gif,.webp,.svg",
|
||||||
|
"video/*": ".mp4,.mov,.webm,.avi",
|
||||||
|
"text/*": ".txt,.md,.doc,.docx,.pdf,.csv,.json",
|
||||||
|
};
|
||||||
|
const ALL_ACCEPT = Object.values(ACCEPT_MAP).join(",");
|
||||||
|
|
||||||
|
function UploadArrowIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} width={28} height={28} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||||
|
<path
|
||||||
|
d="M12 16V4M12 4L8 8M12 4L16 8"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M4 14V18C4 19.1 4.9 20 6 20H18C19.1 20 20 19.1 20 18V14"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileDocIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} width={36} height={36} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||||
|
<path
|
||||||
|
d="M14 2H6C5.46957 2 4.96086 2.21071 4.58579 2.58579C4.21071 2.96086 4 3.46957 4 4V20C4 20.5304 4.21071 21.0391 4.58579 21.4142C4.96086 21.7893 5.46957 22 6 22H18C18.5304 22 19.0391 21.7893 19.4142 21.4142C19.7893 21.0391 20 20.5304 20 20V8L14 2Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path d="M14 2V8H20" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VideoBadgeIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} width={10} height={10} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||||
|
<path d="M8 5v14l11-7z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MyAssetUploadPanel() {
|
||||||
|
const [assets, setAssets] = useState<UploadedAsset[]>([]);
|
||||||
|
const [activeFilter, setActiveFilter] = useState<UploadCategory>("all");
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const assetsRef = useRef(assets);
|
||||||
|
assetsRef.current = assets;
|
||||||
|
|
||||||
|
const processFiles = useCallback((files: FileList | File[]) => {
|
||||||
|
const newAssets: UploadedAsset[] = Array.from(files).map((file) => {
|
||||||
|
const cat = categorize(file);
|
||||||
|
const previewUrl = cat === "image" || cat === "video" ? URL.createObjectURL(file) : null;
|
||||||
|
return {
|
||||||
|
id: uid(),
|
||||||
|
file,
|
||||||
|
category: cat,
|
||||||
|
previewUrl,
|
||||||
|
name: file.name,
|
||||||
|
size: formatSize(file.size),
|
||||||
|
uploadedAt: new Date(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setAssets((prev) => [...newAssets, ...prev]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
for (const a of assetsRef.current) {
|
||||||
|
if (a.previewUrl) URL.revokeObjectURL(a.previewUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragOver(false);
|
||||||
|
if (e.dataTransfer.files.length) processFiles(e.dataTransfer.files);
|
||||||
|
},
|
||||||
|
[processFiles],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleInputChange = useCallback(
|
||||||
|
(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files?.length) {
|
||||||
|
processFiles(e.target.files);
|
||||||
|
e.target.value = "";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[processFiles],
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeAsset = useCallback((id: string) => {
|
||||||
|
setAssets((prev) => {
|
||||||
|
const found = prev.find((a) => a.id === id);
|
||||||
|
if (found?.previewUrl) URL.revokeObjectURL(found.previewUrl);
|
||||||
|
return prev.filter((a) => a.id !== id);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filtered = activeFilter === "all" ? assets : assets.filter((a) => a.category === activeFilter);
|
||||||
|
|
||||||
|
const counts = {
|
||||||
|
all: assets.length,
|
||||||
|
image: assets.filter((a) => a.category === "image").length,
|
||||||
|
video: assets.filter((a) => a.category === "video").length,
|
||||||
|
text: assets.filter((a) => a.category === "text").length,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
inputRef.current?.click();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragOver(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setIsDragOver(false)}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
className={`relative rounded-2xl border-2 border-dashed p-10 md:p-14 text-center cursor-pointer transition-all mb-8 ${
|
||||||
|
isDragOver
|
||||||
|
? "border-lavender-400 bg-lavender-100/60 scale-[1.01]"
|
||||||
|
: "border-neutral-30 bg-neutral-10/50 hover:border-lavender-300 hover:bg-lavender-50/40"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept={ALL_ACCEPT}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-lavender-100 flex items-center justify-center shadow-[3px_4px_12px_rgba(155,138,212,0.15)] text-lavender-500">
|
||||||
|
<UploadArrowIcon />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="title-14 text-navy-900 mb-1 break-keep">파일을 드래그하거나 클릭하여 업로드</p>
|
||||||
|
<p className="body-14 text-neutral-60 break-keep">
|
||||||
|
Image, Video, Text 파일 지원 (JPG, PNG, MP4, MOV, TXT, PDF, DOC 등)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex justify-center gap-2 mt-4 flex-wrap">
|
||||||
|
{(["image", "video", "text"] as const).map((cat) => (
|
||||||
|
<Pill key={cat} className={categoryBadge[cat]}>
|
||||||
|
{cat === "image" ? "Image" : cat === "video" ? "Video" : "Text"}
|
||||||
|
</Pill>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{assets.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mb-6" role="tablist" aria-label="업로드 유형 필터">
|
||||||
|
{(Object.keys(categoryConfig) as UploadCategory[]).map((key) => {
|
||||||
|
const isActive = activeFilter === key;
|
||||||
|
return (
|
||||||
|
<SegmentTabButton
|
||||||
|
key={key}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive}
|
||||||
|
active={isActive}
|
||||||
|
inactiveShadow
|
||||||
|
onClick={() => setActiveFilter(key)}
|
||||||
|
>
|
||||||
|
{categoryConfig[key].label}
|
||||||
|
<span className="ml-2 opacity-70">{counts[key]}</span>
|
||||||
|
</SegmentTabButton>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
|
{filtered.map((asset) => (
|
||||||
|
<Surface
|
||||||
|
key={asset.id}
|
||||||
|
padding="none"
|
||||||
|
interactive
|
||||||
|
overflowHidden
|
||||||
|
className="group"
|
||||||
|
>
|
||||||
|
<div className="relative h-40 bg-neutral-10 flex items-center justify-center overflow-hidden">
|
||||||
|
{asset.category === "image" && asset.previewUrl ? (
|
||||||
|
<img src={asset.previewUrl} alt={asset.name} className="w-full h-full object-cover" />
|
||||||
|
) : null}
|
||||||
|
{asset.category === "video" && asset.previewUrl ? (
|
||||||
|
<video
|
||||||
|
src={asset.previewUrl}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
onMouseOver={(e) => (e.target as HTMLVideoElement).play()}
|
||||||
|
onMouseOut={(e) => {
|
||||||
|
const v = e.target as HTMLVideoElement;
|
||||||
|
v.pause();
|
||||||
|
v.currentTime = 0;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{asset.category === "text" ? (
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<FileDocIcon className="text-[var(--color-status-warning-text)]" />
|
||||||
|
<span className="label-12 text-neutral-60 font-medium">
|
||||||
|
{asset.name.split(".").pop()?.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeAsset(asset.id);
|
||||||
|
}}
|
||||||
|
className="absolute top-2 right-2 w-7 h-7 rounded-full bg-white/90 backdrop-blur-sm border border-neutral-20 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-sm hover:bg-[var(--color-status-critical-bg)] cursor-pointer"
|
||||||
|
aria-label="업로드 항목 제거"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width={12}
|
||||||
|
height={12}
|
||||||
|
viewBox="0 0 12 12"
|
||||||
|
fill="none"
|
||||||
|
className="text-[var(--color-status-critical-text)]"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M2 2L10 10M10 2L2 10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Pill
|
||||||
|
weight="semibold"
|
||||||
|
className={`absolute top-2 left-2 ${categoryBadge[asset.category]}`}
|
||||||
|
>
|
||||||
|
{asset.category === "image"
|
||||||
|
? "Image"
|
||||||
|
: asset.category === "video"
|
||||||
|
? "Video"
|
||||||
|
: "Text"}
|
||||||
|
</Pill>
|
||||||
|
|
||||||
|
{asset.category === "video" ? (
|
||||||
|
<div className="absolute bottom-2 right-2 flex items-center gap-1 bg-black/50 backdrop-blur-sm rounded-full px-2 py-1">
|
||||||
|
<VideoBadgeIcon className="text-white" />
|
||||||
|
<span className="label-12 text-white font-medium">Video</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4">
|
||||||
|
<p className="body-14 font-medium text-navy-900 truncate mb-1">{asset.name}</p>
|
||||||
|
<p className="label-12 text-neutral-60">{asset.size}</p>
|
||||||
|
</div>
|
||||||
|
</Surface>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { useReportSubNav } from "@/features/report/hooks/useReportSubNav";
|
||||||
|
import { ReportChannelsSection } from "@/features/report/ui/ReportChannelsSection";
|
||||||
|
import { ReportClinicSection } from "@/features/report/ui/ReportClinicSection";
|
||||||
|
import { ReportDiagnosisSection } from "@/features/report/ui/ReportDiagnosisSection";
|
||||||
|
import { ReportFacebookSection } from "@/features/report/ui/ReportFacebookSection";
|
||||||
|
import { ReportInstagramSection } from "@/features/report/ui/ReportInstagramSection";
|
||||||
|
import { ReportKpiSection } from "@/features/report/ui/ReportKpiSection";
|
||||||
|
import { ReportOtherChannelsSection } from "@/features/report/ui/ReportOtherChannelsSection";
|
||||||
|
import { ReportOverviewSection } from "@/features/report/ui/ReportOverviewSection";
|
||||||
|
import { ReportRoadmapSection } from "@/features/report/ui/ReportRoadmapSection";
|
||||||
|
import { ReportTransformationSection } from "@/features/report/ui/ReportTransformationSection";
|
||||||
|
import { ReportYouTubeSection } from "@/features/report/ui/ReportYouTubeSection";
|
||||||
|
|
||||||
|
export function ReportPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
useReportSubNav();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-report-content>
|
||||||
|
<p className="body-14 text-neutral-60 px-6 max-w-7xl mx-auto py-2">
|
||||||
|
Report ID: <span className="text-navy-900 font-medium">{id}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="divide-y divide-neutral-20">
|
||||||
|
<ReportOverviewSection />
|
||||||
|
<ReportClinicSection />
|
||||||
|
<ReportChannelsSection />
|
||||||
|
<ReportYouTubeSection />
|
||||||
|
<ReportInstagramSection />
|
||||||
|
<ReportFacebookSection />
|
||||||
|
<ReportOtherChannelsSection />
|
||||||
|
<ReportDiagnosisSection />
|
||||||
|
<ReportTransformationSection />
|
||||||
|
<ReportRoadmapSection />
|
||||||
|
<ReportKpiSection />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,9 @@ import ChevronRightIcon from "@/assets/home/chevron-right.svg?react";
|
||||||
/** 리포트 라우트가 `report/:id`일 때 점/플로우에서 이동할 기본 경로 */
|
/** 리포트 라우트가 `report/:id`일 때 점/플로우에서 이동할 기본 경로 */
|
||||||
const DEFAULT_REPORT_NAV_PATH = "/report/demo";
|
const DEFAULT_REPORT_NAV_PATH = "/report/demo";
|
||||||
|
|
||||||
|
/** 플랜 라우트가 `plan/:id`일 때 점/플로우에서 이동할 기본 경로 */
|
||||||
|
const DEFAULT_PLAN_NAV_PATH = "/plan/demo";
|
||||||
|
|
||||||
type FlowStep = {
|
type FlowStep = {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -21,7 +24,12 @@ const PAGE_FLOW: FlowStep[] = [
|
||||||
navigatePath: DEFAULT_REPORT_NAV_PATH,
|
navigatePath: DEFAULT_REPORT_NAV_PATH,
|
||||||
isActive: (p) => p === "/report" || p.startsWith("/report/"),
|
isActive: (p) => p === "/report" || p.startsWith("/report/"),
|
||||||
},
|
},
|
||||||
{ id: "plan", label: "콘텐츠 기획", navigatePath: "/plan", isActive: (p) => p === "/plan" },
|
{
|
||||||
|
id: "plan",
|
||||||
|
label: "콘텐츠 기획",
|
||||||
|
navigatePath: DEFAULT_PLAN_NAV_PATH,
|
||||||
|
isActive: (p) => p === "/plan" || p.startsWith("/plan/"),
|
||||||
|
},
|
||||||
{ id: "studio", label: "콘텐츠 제작", navigatePath: "/studio", isActive: (p) => p === "/studio" },
|
{ id: "studio", label: "콘텐츠 제작", navigatePath: "/studio", isActive: (p) => p === "/studio" },
|
||||||
{ id: "channels", label: "채널 연결", navigatePath: "/channels", isActive: (p) => p === "/channels" },
|
{ id: "channels", label: "채널 연결", navigatePath: "/channels", isActive: (p) => p === "/channels" },
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { MarketingPlanPage } from "@/features/plan/ui/MarketingPlanPage";
|
||||||
|
|
||||||
|
export function PlanPage() {
|
||||||
|
return <MarketingPlanPage />;
|
||||||
|
}
|
||||||
|
|
@ -1,40 +1,5 @@
|
||||||
import { useParams } from "react-router-dom";
|
import { ReportPage as ReportFeaturePage } from "@/features/report/ui/ReportPage";
|
||||||
import { useReportSubNav } from "@/features/report/hooks/useReportSubNav";
|
|
||||||
import { ReportChannelsSection } from "@/features/report/ui/ReportChannelsSection";
|
|
||||||
import { ReportClinicSection } from "@/features/report/ui/ReportClinicSection";
|
|
||||||
import { ReportDiagnosisSection } from "@/features/report/ui/ReportDiagnosisSection";
|
|
||||||
import { ReportFacebookSection } from "@/features/report/ui/ReportFacebookSection";
|
|
||||||
import { ReportInstagramSection } from "@/features/report/ui/ReportInstagramSection";
|
|
||||||
import { ReportKpiSection } from "@/features/report/ui/ReportKpiSection";
|
|
||||||
import { ReportOtherChannelsSection } from "@/features/report/ui/ReportOtherChannelsSection";
|
|
||||||
import { ReportOverviewSection } from "@/features/report/ui/ReportOverviewSection";
|
|
||||||
import { ReportRoadmapSection } from "@/features/report/ui/ReportRoadmapSection";
|
|
||||||
import { ReportTransformationSection } from "@/features/report/ui/ReportTransformationSection";
|
|
||||||
import { ReportYouTubeSection } from "@/features/report/ui/ReportYouTubeSection";
|
|
||||||
|
|
||||||
export function ReportPage() {
|
export function ReportPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
return <ReportFeaturePage />;
|
||||||
useReportSubNav();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div data-report-content>
|
|
||||||
<p className="body-14 text-neutral-60 px-6 max-w-7xl mx-auto py-2">
|
|
||||||
Report ID: <span className="text-navy-900 font-medium">{id}</span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="divide-y divide-neutral-20">
|
|
||||||
<ReportOverviewSection />
|
|
||||||
<ReportClinicSection />
|
|
||||||
<ReportChannelsSection />
|
|
||||||
<ReportYouTubeSection />
|
|
||||||
<ReportInstagramSection />
|
|
||||||
<ReportFacebookSection />
|
|
||||||
<ReportOtherChannelsSection />
|
|
||||||
<ReportDiagnosisSection />
|
|
||||||
<ReportTransformationSection />
|
|
||||||
<ReportRoadmapSection />
|
|
||||||
<ReportKpiSection />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue