feat: INFINITH Marketing Platform — Phase 1 MVP

Complete frontend reference implementation:
- Landing page (7 sections, responsive, animated)
- Marketing Intelligence Report (11 sections, VIEW Plastic Surgery demo)
- Marketing Execution Plan (8 sections, branding/channel/calendar)
- Content Studio wizard (channel → strategy → sound → generate)
- Channel Connect page (7 platform OAuth flows)
- Distribution page (YouTube publish flow)
- Performance Dashboard (KPI, funnel, heatmap, trends)
- PDF export (section-based, no mid-content splits)
- Design system (pastel palette, filled icons, Pretendard/Playfair)
- Page navigator (landing ↔ report ↔ plan)
- Asset upload (drag & drop, image/video/text)

Docs: PRD v2.0, DESIGN_SYSTEM.md, API_CONNECTORS.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
claude/bold-hawking
Haewon Kam 2026-03-24 16:48:11 +09:00
commit bbb7a0de60
90 changed files with 16754 additions and 0 deletions

11
.claude/launch.json Normal file
View File

@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "infinith-dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 3000
}
]
}

View File

@ -0,0 +1,19 @@
{
"permissions": {
"allow": [
"mcp__mcp-server-firecrawl__firecrawl_search",
"WebSearch",
"mcp__mcp-server-firecrawl__firecrawl_map",
"mcp__caced381-eeb5-4b6a-ae62-ba481220fcb1__doc",
"WebFetch(domain:www.viewclinic.com)",
"WebFetch(domain:www.gangnamunni.com)",
"WebFetch(domain:www.realself.com)",
"mcp__caced381-eeb5-4b6a-ae62-ba481220fcb1__site-explorer-pages-by-backlinks",
"WebFetch(domain:www.youtube.com)",
"WebFetch(domain:socialblade.com)",
"WebFetch(domain:www.facebook.com)",
"mcp__mcp-registry__search_mcp_registry",
"Bash(npx tsc:*)"
]
}
}

9
.env.example Normal file
View File

@ -0,0 +1,9 @@
# GEMINI_API_KEY: Required for Gemini AI API calls.
# AI Studio automatically injects this at runtime from user secrets.
# Users configure this via the Secrets panel in the AI Studio UI.
GEMINI_API_KEY="MY_GEMINI_API_KEY"
# APP_URL: The URL where this applet is hosted.
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints.
APP_URL="MY_APP_URL"

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
node_modules/
build/
dist/
coverage/
.DS_Store
*.log
.env*
!.env.example

View File

@ -0,0 +1,966 @@
(High-Ticket Medical Clinics Optimization Version)
[Role & Objective]
Act as a **Healthcare Marketing Intelligence Expert** with strong domain knowledge in **high-ticket medical clinics**
(plastic surgery, dermatology, aesthetic medicine, obesity & weight-loss clinics).
Your objective is to produce a **Marketing Intelligence Report** that will be presented to **clinic owners and decision-makers BEFORE any content is produced**.
The report must clearly explain:
•   **Why patients choose this clinic**
•   **What triggers first-time reservations**
•   **How the clinic can scale patient acquisition through content**
**Primary Goal:** New patient acquisition
**Core KPI:** Number of first-time booked patients (new consultations / procedures)
[Core Analysis Requirements]
Analyze the clinic based on:
•   Clinic location & accessibility
•   Medical specialties & signature procedures
•   Doctor credibility (experience, reputation, philosophy)
•   Facility quality & visual assets (interior, equipment, before/after availability)
•   Online presence (website, SNS, review platforms)
•   Competitive density & patient alternatives
Additionally analyze:
•   **Patient decision psychology**
•   **Trust-building & risk-reduction factors**
•   **Price sensitivity vs authority perception**
[Target Patient Intelligence]
Include clearly defined:
**• Target Patient Segments**
(e.g. First-time aesthetic patients, Repeat maintenance patients, Medical tourism patients, High-trust premium seekers)
**•** **Patient Personas**
For each persona, clarify:
•   Primary concern (pain point)
•   Emotional trigger
•   Key hesitation before booking
•   Final decision trigger
[Unique Selling Propositions (USPs)]
Identify clinic-specific advantages such as:
•   Medical authority & specialization
•   Outcome consistency & safety emphasis
•   Signature techniques or protocols
•   Consultation philosophy
•   Post-procedure care system
Only include **USPs that directly impact first-time booking decisions**.
[Competitive Landscape]
Analyze:
•   **Direct competitors** (same specialty, same region, similar price band)
•   **Indirect competitors** (lower-priced clinics, overseas options, non-surgical alternatives)
Clarify:
•   Where this clinic wins
•   Where it must avoid price competition
•   How it should differentiate through content, not discounts
[Market Positioning]
Define the clinics positioning using:
•   Trust level (entry / mid / premium / authority)
•   Emotional positioning (safe, transformative, discreet, expert-led)
•   Price perception (value-based vs outcome-based)
[Key Selling Point Structuring UI Optimized]
From the analysis above, extract **Key Selling Points** using the structure below.
**Rules:**
1.     Focus ONLY on factors that directly influence **first-time patient booking**
2.     Each selling point must be concise and instantly scannable
3.     Language must be reusable for:
◦   Ads
◦   Short-form videos
◦   Website headlines
◦   Booking page sections
1.     Avoid full sentences in descriptions
2.     Phrase everything as **conversion-ready selling language**
**Output Format:**
[Category]
(Tag keyword 58 words, noun-based, UI oval-style)
* One-line selling phrase (not a full sentence)
**Limit:**
•   **6 to 8 Key Selling Points only**
[Content & Automation Readiness Check]
Ensure that:
•   Each **tag keyword** can map directly to:
◦   A content pillar
◦   A short-form video series
◦   A landing page section
•   Each **selling phrase** can be used as:
◦   Video hook
◦   Image headline
◦   Paid ad copy snippet
◦   Consultation booking CTA
[Patient Decision Journey Intelligence]
Briefly identify:
•   Awareness-stage content triggers
•   Trust-building content formats
•   Final booking conversion drivers
Focus strictly on **new patient acquisition flow**.
[Local Authority & Environment Intelligence]
As the FINAL section of the report, recommend **contextual location advantages** that enhance trust and conversion.
**Guidelines:**
1.     Highlight **TOP 3 well-recognized location factors**
(medical district, premium commercial area, landmark proximity)
2.     Must strengthen:
◦   Clinic credibility
◦   Patient reassurance
◦   Medical tourism storytelling
1.     Avoid minor or generic location mentions
**Output Format:**
[Local Authority Context]
* Location Factor Name
* Approximate distance or relevance
* One short authority-enhancing value phrase
These should support:
•   Brand trust narratives
•   Clinic authority positioning
•   Reservation conversion storytelling
✅ Result
This prompt is optimized to:
•   Sell **medical trust, not procedures**
•   Convert **hesitation into booking**
•   Align **content, ads, and consultation funnels**
•   Speak directly to **clinic owners**** real KPI: new patients**
원하시면 다음 단계로 바로 이어갈 수 있어요:
•   이 프롬프트를 실제 병원 **1**곳 기준으로 즉시 실행
•   성형외과 / 피부과 / 비만클리닉 각 세부 버전 분기
•   이 구조를 기반으로 콘텐츠 자동화 시스템**(MCP / Agent)** 설계
다음 액션, 어디까지 같이 갈까요?
성형외과 / 피부과 / 비만클리닉 각 세부 버전 분기
① Plastic Surgery Clinic
Marketing Intelligence Report Prompt (Conversion-Driven)
**[Role & Objective]**
Act as a **Plastic Surgery Marketing Intelligence Expert** specialized in **high-ticket surgical procedures**.
Your objective is to produce a **pre-content strategic intelligence report** that explains:
•   Why patients trust this clinic with irreversible decisions
•   What reduces fear and hesitation before surgery
•   How the clinic converts trust into booked consultations
**Primary Goal:** New surgical consultation bookings
**Core KPI:** First-time surgery consultations → surgery conversion rate
**[Core Analysis Focus]**
Analyze the clinic based on:
•   Signature surgeries (eyes, nose, contouring, revision, anti-aging)
•   Surgeon authority (experience, philosophy, specialization)
•   Safety systems (anesthesia, emergency protocol, recovery care)
•   Before/After asset quality & credibility
•   Consultation structure & decision guidance style
•   Competitor saturation in same procedure category
**Critical Lens:**
➡️ _Fear reduction & irreversible decision psychology_
**[Target Patient Intelligence]**
Focus on personas such as:
•   First-time surgery patients
•   Revision surgery patients
•   Authority-seeking premium patients
For each persona, define:
•   Primary fear (risk, regret, overdone results)
•   Trust barrier
•   Final booking trigger (authority, outcomes, safety proof)
**[Key Selling Point Structuring UI Optimized]**
**Emphasis Categories**
•   Surgical authority
•   Outcome predictability
•   Safety & recovery system
•   Consultation philosophy
**Examples (structure only):**
[Surgeon Authority]
(20+ Years Surgical Specialization)
* Decision led by experience, not trends
➡️ 68 only, strictly surgery-decision drivers
**[Content Conversion Logic]**
Ensure selling points support:
•   Long-form trust videos
•   Short-form “decision confidence” clips
•   Consultation booking reassurance content
② Dermatology / Aesthetic Skin Clinic
Marketing Intelligence Report Prompt (Retention → Conversion)
**[Role & Objective]**
Act as a **Dermatology & Skin Aesthetic Marketing Intelligence Expert** with deep understanding of **non-surgical, repeat-based treatments**.
Your objective is to produce a **marketing intelligence report** that explains:
•   Why patients choose this clinic for ongoing skin management
•   How expertise outperforms device-based competition
•   How content builds long-term patient trust & repeat visits
**Primary Goal:** New patient entry
**Core KPI:** First-time treatment bookings → repeat treatment rate
**[Core Analysis Focus]**
Analyze the clinic based on:
•   Skin specialization areas (acne, pigmentation, lifting, aging)
•   Doctor diagnostic philosophy
•   Treatment customization logic
•   Equipment vs expertise balance
•   Review credibility & before/after consistency
•   Competitor clinics using price/device marketing
**Critical Lens:**
➡️ _“My skin is different” psychology_
**[Target Patient Intelligence]**
Focus on personas such as:
•   Skin-problem chronic patients
•   First-time aesthetic treatment seekers
•   Maintenance & anti-aging patients
For each persona, define:
•   Skin anxiety
•   Previous treatment disappointment
•   Trust-building trigger (diagnosis clarity, consistency)
**[Key Selling Point Structuring UI Optimized]**
**Emphasis Categories**
•   Skin diagnosis expertise
•   Customized treatment logic
•   Long-term skin planning
•   Gentle but effective approach
**Examples (structure only):**
[Skin Diagnosis Authority]
(Personalized Skin Analysis Protocol)
* Treatments start with diagnosis, not devices
➡️ 68 only, repeat-visit-driving factors
**[Content Conversion Logic]**
Ensure selling points map to:
•   Educational short-form content
•   Skin myth-busting videos
•   “Doctor explains” authority reels
③ Obesity / Weight-Loss Clinic
Marketing Intelligence Report Prompt (Outcome Trust Focused)
**[Role & Objective]**
Act as a **Medical Weight-Loss Marketing Intelligence Expert** specializing in **obesity treatment, metabolic health, and body transformation**.
Your objective is to produce a **conversion-focused intelligence report** that explains:
•   Why patients trust this clinic with long-term body change
•   How medical credibility beats diet trends
•   How content turns skepticism into booked consultations
**Primary Goal:** New patient consultations
**Core KPI:** Initial obesity consultation bookings → program enrollment
**[Core Analysis Focus]**
Analyze the clinic based on:
•   Medical approach (hormonal, metabolic, injection, program-based)
•   Doctor medical authority (not fitness influencer positioning)
•   Outcome sustainability proof
•   Emotional support & coaching system
•   Competitor landscape (gyms, diet programs, online injections)
**Critical Lens:**
➡️ _Failure fatigue & hope restoration_
**[Target Patient Intelligence]**
Focus on personas such as:
•   Repeated diet failure patients
•   Medical-condition-related obesity patients
•   Privacy-sensitive premium clients
For each persona, define:
•   Emotional exhaustion point
•   Skepticism trigger
•   Decision trigger (medical trust, realistic promise)
**[Key Selling Point Structuring UI Optimized]**
**Emphasis Categories**
•   Medical legitimacy
•   Sustainable outcome logic
•   Privacy & dignity
•   Program accountability
**Examples (structure only):**
[Medical Weight-Loss Authority]
(Physician-Led Obesity Treatment)
* Medical solutions beyond willpower
➡️ 68 only, enrollment-driving factors
**[Content Conversion Logic]**
Ensure selling points support:
•   Trust-restoring content
•   Medical explanation reels
•   Long-form patient journey storytelling
 정리하면 (Tell it like it is)
•   성형외과**:** 공포 제거 + 권위 집중
•   피부과**:** 진단 신뢰 + 반복 설득
•   비만클리닉**:** 실패 공감 + 의료적 희망
이렇게 환자 의사결정 구조 자체가 다르기 때문에,
콘텐츠 포맷 · 훅 · CTA가 완전히 달라져야 해.
다음으로 추천할 액션은 하나야: 이 중 하나를 실제 병원 케이스로 즉시 시뮬레이션
(예: 강남 성형외과 / 피부과 / 비만클리닉 중 택1)
Marketing Intelligence Report e.g
뷰성형외과
1. Clinic Snapshot & Market Context
**Location**
•   강남구 논현동
•   성형외과 밀집 핵심 상권 (정보 과잉 + 선택 피로 극대화 구간)
**Market Reality (Tell it like it is)**
•   환자는 “어디가 제일 잘함?”이 아니라
**“여기서 하면 후회 안 할까?”**를 묻는 상태
•   가격 비교보다 의사 신뢰**·**결과 예측 가능성이 최종 결정 요인
➡️ 이 시장에서 콘텐츠의 역할은 홍보가 아니라 ****결정 보조****
2. Target Patient Segments & Personas
🎯 **Core Segment 1 —** 첫 수술 고민층 **(20~30**대**)**
•   **Pain Point:** 실패·부자연스러움 공포
•   행동**:** 후기·Before/After 집요하게 탐색
•   결정 트리거**:**
“원장이 직접 봐주고, 결과가 예상된다”
🎯 **Core Segment 2 —** 재수술 **/** 고난이도 케이스
•   **Pain Point:** 기존 수술 실패 경험
•   행동**:** 병원 수 대면 상담
•   결정 트리거**:**
“문제 원인을 정확히 짚어주는 설명”
🎯 **Core Segment 3 —** 프리미엄 신뢰 추구층
•   **Pain Point:** 과잉진료·트렌드 수술 불신
•   행동**:** 브랜드 톤·의사 철학 확인
•   결정 트리거**:**
“유행 말고 원칙을 말하는 병원”
3. Booking Decision Drivers (핵심 요인)
| | | |
|---|---|---|
|**단계**|**환자 머릿속 질문**|**콘텐츠 역할**|
|인지|여긴 뭐가 다른데?|차별 논리 제시|
|검증|믿어도 되나?|의사 판단력 증명|
|결심|여기로 가도 되나?|후회 가능성 제거|
➡️ 뷰성형외과는 **“**결과를 설명할 수 있는 병원**”** 포지션이 유효
4. Unique Selling Propositions (Booking-Critical)
•   원장 중심 상담 구조
•   과장 없는 결과 설명 톤
•   얼굴 밸런스·조화 강조
•   강남 상권 대비 차분한 브랜드 무드
•   상담 → 수술 → 회복의 일관된 플로우
※ “싸다 / 유명하다”는 USP 아님※ **“**판단을 대신해주는 병원**”**이 핵심
5. Competitive Landscape
**Direct Competitors**
•   논현·신사 대형 성형외과
•   수술 케이스 대량 생산형 병원
**Indirect Competitors**
•   SNS 인플루언서 병원
•   해외 의료관광 패키지
뷰성형외과가 피해야 할 전장
•   가격 경쟁
•   전후사진 과잉 비교
이겨야 할 전장
•   상담 신뢰
•   결과 예측 언어
6. Market Positioning
**Position**
•   트렌드형 ❌
•   공장형 ❌
•   👉 판단형 **·** 신뢰형 성형외과
**Emotional Anchor**
•   “여기선 내가 휘둘리지 않는다”
7. Key Selling Points UI Optimized (6개)
**※** 전부 광고**·**숏폼**·**상담페이지에 바로 사용 가능
**[Surgical Authority]**
(**Director-Led Surgical Decision System**)
•   Decisions guided by experience, not trends
**[Consultation Philosophy]**
(**Outcome-First Consultation Logic**)
•   Surgery planned from final result backward
**[Result Predictability]**
(**Natural Balance-Oriented Design**)
•   Subtle changes, stable long-term outcomes
**[Trust & Safety]**
(**Structured Surgical Safety Protocol**)
•   Risk minimized through process, not luck
**[Patient Guidance]**
(**Fear-Reduction Decision Support**)
•   Clear answers before irreversible choices
**[Location Advantage]**
(**Gangnam Nonhyeon Medical Hub**)
•   Access to Koreas top medical infrastructure
8. Content & Automation Readiness Check
| | | |
|---|---|---|
|**Tag**|**콘텐츠 테마**|**활용**|
|Surgical Authority|원장 판단 설명|숏폼 / 인터뷰|
|Consultation Logic|상담 시뮬레이션|유튜브 / 릴스|
|Result Predictability|결과 예측 사례|전환 랜딩|
|Safety Protocol|수술 시스템 공개|신뢰 콘텐츠|
|Fear Reduction|고민 질문 답변|예약 유도|
|Location Authority|강남 신뢰 스토리|브랜딩|
➡️ 콘텐츠 **=** 설득 자동화 장치
9. Nearby Attractions Stay & Trust Storytelling
**[Nearby Attractions]**
•   가로수길
약 5분
◦   Premium lifestyle & recovery-friendly walks
•   압구정 로데오
약 7분
◦   High-end beauty & fashion district
•   청담동 명품거리
약 10분
◦   Koreas premium aesthetic epicenter
➡️ 의료 **+** 라이프스타일 **=** 프리미엄 신뢰 서사
최종 한 줄 요약 (원장용)
뷰성형외과는 **“**잘한다**”**를 말할 필요가 없다**.
**환자의 결정을 대신해줄 수 있는 병원이라는 점만 증명하면 된다**.**
다음으로 바로 갈 수 있는 선택지는 세 가지야:
1⃣ 이 리포트를 원장 발표용 슬라이드로 재구성
2⃣ Key Selling Point 기준으로 숏폼 콘텐츠 **30**개 자동 설계
3⃣ 상담 → 예약 전환율을 높이는 랜딩 **& CTA** 구조 설계
어디부터 실행할까?
인스타그램과 페이스북에 올릴 릴스 콘텐츠를 이 분석 내용에 기반해서 기획, 비주얼요소(병원에서 제공한 마케팅 활용가능 비포애프터사진, 병원 내부 및 진료진 프로파일 등 소스), 텍스트(모션 그래픽 연출 포함), 위 제시한 다양한 콘텐츠 앵글 적용한 버전으로 제시, 내러티브 사용과 홍보성 노래 사용 두가지로 크게 나눈뒤 콘텐츠 세분화
좋아. 이제부터는 **“실제 집행 가능한 릴스 설계 문서”**로 간다.
아이디어 나열 말고, 촬영 → 편집 → 업로드까지 바로 이어지는 수준으로 정리할게.
(광고/유기 도달 모두 대응, 신환 예약 **KPI** 기준)
아래는 뷰성형외과
마케팅 인텔리전스 분석을 그대로 릴스 구조로 번역한 결과다.
Instagram / Facebook Reels 콘텐츠 기획안
(Based on Marketing Intelligence Report)
전체 구조 개요
콘텐츠 대분류 **(2** 트랙**)**
1**Narrative** 기반 콘텐츠 → 신뢰·결정 보조
2**Promotional Music** 기반 콘텐츠 → 도달·관심 유도
각 트랙 안에서
**Key Selling Point(USP) ×** 콘텐츠 앵글 **×** 비주얼 소스로 세분화
1⃣ Narrative 기반 Reels
🎯 목적: “여기서 해도 되겠다”라는 결정 확신
A-1. 상담 신뢰형 (Fear Reduction)
🎥 콘텐츠 컨셉
**“**수술을 권하기 전에**,** 판단을 설명합니다**”**
▸ 비주얼 소스
•   원장 상담 장면 (정면 or 측면 실루엣)
•   상담실 내부 컷 (차분, 여백)
•   얼굴 라인 스케치 / 시뮬레이션 일부 (과하지 않게)
•   비포/애프터는 짧게**,** 흐릿한 전환으로 사용
▸ 내러티브 **(**자막 **+** 음성 가능**)**
**Hook (03s)**
“성형에서 가장 무서운 건,
설명 없이 결정되는 순간입니다.”
**Body (4****12s)**
“왜 이 수술을 해야 하는지
왜 이 정도가 적당한지
그걸 먼저 설명합니다.”
**Close (1318s)**
“결정은, 그 다음입니다.”
▸ 모션 그래픽 텍스트
•   “Decision before Surgery”
•   “Outcome-first consultation”
•   타이핑 효과 or 페이드 인
▸ **CTA**
•   “상담 예약하기”
•   “원장 상담으로 시작하세요”
A-2. 결과 예측형 (Outcome Predictability)
🎥 콘텐츠 컨셉
**“**이 병원은 결과를 ****말****로 설명합니다**”**
▸ 비주얼 소스
•   동일 케이스 비포/애프터 (정면 고정)
•   애프터 컷은 짧게**,** 과도한 강조 금지
•   원장 설명 컷 삽입
▸ 내러티브
**Hook**
“전후 사진만 보면,
불안해지는 이유가 있습니다.”
**Body**
“중요한 건
어떤 결과를 목표로 했는지입니다.”
**Close**
“결과가 설명되는 수술.”
▸ 텍스트 오버레이
•   “Predictable outcome”
•   “No overcorrection”
•   얇은 라인 그래픽
A-3. 재수술·고난이도 신뢰형
🎥 콘텐츠 컨셉
**“**다시 손대는 수술은**,** 더 조심해야 합니다**”**
▸ 비주얼 소스
•   차분한 상담실 컷
•   원장 클로즈업
•   Before는 최소화, After는 디테일 컷
▸ 내러티브
“이미 한 번의 선택이
쉽지 않았다는 걸 알고 있습니다.”
“그래서 더 많은 설명이 필요합니다.”
▸ **CTA**
•   “재수술 상담”
•   “고난이도 케이스 상담”
2⃣ Promotional Music 기반 Reels
🎯 목적: 도달 **+** 첫 관심 유도
(유행 BGM / 저작권 문제 없는 홍보성 음악)
B-1. 비포애프터 임팩트형
🎥 콘텐츠 컨셉
**“**과하지 않은 변화**”**
▸ 비주얼 소스
•   동일 구도 비포 → 애프터
•   컷당 0.8~1.2초
•   자연광, 무표정 기준
▸ 텍스트 **(Full Sentence** ❌**)**
•   “Natural balance”
•   “No overdone look”
•   “Designed, not rushed”
▸ 편집 포인트
•   비트에 맞춘 컷 전환
•   줌·왜곡 효과 ❌
B-2. 병원 공간 & 무드형
🎥 콘텐츠 컨셉
**“**강남이지만**,** 조용한 이유**”**
▸ 비주얼 소스
•   병원 내부 인테리어
•   대기 공간, 상담실, 복도
•   의료기기 디테일 컷
▸ 텍스트
•   “Private”
•   “Calm”
•   “Decision-focused clinic”
▸ 활용
•   브랜드 무드 각인
•   광고용 크리에이티브에 최적
B-3. 의료진 프로파일형
🎥 콘텐츠 컨셉
**“**유행보다 판단**”**
▸ 비주얼 소스
•   원장 정면 or 측면
•   진료 중 짧은 컷
•   수술 전 체크 장면
▸ 텍스트
•   “Director-led surgery”
•   “Experience matters”
•   “Results over trends”
콘텐츠 앵글 × Key Selling Point 매핑
| | |
|---|---|
|**Key Selling Point**|**콘텐츠 타입**|
|Surgical Authority|A-1, B-3|
|Consultation Logic|A-1|
|Result Predictability|A-2, B-1|
|Safety Protocol|A-1|
|Fear Reduction|A-1, A-3|
|Gangnam Authority|B-2|
업로드 운영 가이드 (핵심)
•   **Narrative:** 주 23회 (저장·상담 전환 목적)
•   **Music** 기반**:** 주 34회 (도달·리타겟 풀 확장)
•   광고 집행 시
→ Narrative = 리타겟
→ Music = 신규 유입
마지막으로, 솔직하게 말하면
이 구조의 핵심은 **“자랑하지 않는 홍보”**야.
뷰성형외과는
👉 보여주면 되는 병원이 아니라
👉 설명해주면 선택되는 병원이야.

362
PRD.md Normal file
View File

@ -0,0 +1,362 @@
# INFINITH — Infinite Marketing Platform
## Product Requirements Document (PRD)
**Version:** 2.0
**Date:** 2026-03-23
**Author:** Product Owner, Vertical AI SaaS
**Status:** Phase 1 MVP Implemented (UI/UX Reference)
---
## 1. Executive Summary
INFINITH는 프리미엄 의료 비즈니스(피부과, 성형외과, 치과 등)와 병원 마케팅 대행사를 위한 **AI-Powered Marketing Automation SaaS**이다. 핵심 가치는 **AGDP Cycle** — Analysis → Generation → Distribution → Performance — 의 자율 순환 엔진을 통해 콘텐츠 품질(CTR)을 지속적으로 최적화하는 **Self-Improving Marketing Engine**을 제공하는 것이다.
### 1.1 현재 구현 상태 (v2.0 기준)
| 구분 | 상태 | 설명 |
|------|------|------|
| 랜딩 페이지 | **완료** | 7개 섹션, 반응형, 애니메이션 |
| 마케팅 분석 리포트 | **UI 완료** | 11개 섹션, 하드코딩 데이터 (뷰성형외과) |
| 콘텐츠 기획 페이지 | **UI 완료** | 8개 섹션, 하드코딩 데이터 |
| PDF 내보내기 | **완료** | html2canvas + jsPDF, 섹션 단위 분할 |
| 페이지 네비게이터 | **완료** | 3페이지 간 이동 (랜딩 ↔ 분석 ↔ 기획) |
| 에셋 업로드 | **UI 완료** | 드래그&드롭, 이미지/영상/텍스트 |
| 디자인 시스템 | **확립** | 컬러, 타이포, 아이콘, 컴포넌트 패턴 |
| 백엔드/인증/결제 | **미구현** | Phase 2에서 개발 예정 |
---
## 2. Problem Statement
### 2.1 고객 Pain Points
| # | Pain Point | Impact |
|---|-----------|--------|
| 1 | 콘텐츠 생산의 한계 | 블로그, SEO, 유튜브, 숏폼 — 인력/비용/시간 부족 |
| 2 | 영상 제작 비용 | 촬영·편집·기획 고비용, 숏폼 지속 제작 불가 |
| 3 | 데이터 기반 마케팅 부재 | 키워드·채널·콘텐츠 성과를 통합 파악 불가 |
### 2.2 Market Gap
- 기존 마케팅 자동화 툴(HubSpot, Hootsuite 등)은 **한국 의료 마케팅** 특화 기능 부재
- 네이버 블로그/플레이스/카페 등 **한국 로컬 플랫폼** 지원 미비
- 의료광고 심의·규정 준수 자동화 없음
- 텍스트→영상 자동 변환(숏폼) 파이프라인 부재
---
## 3. Target Users & Personas
### Persona 1: 프리미엄 의료 비즈니스 원장 / 마케팅 담당자
- **Who:** 피부과, 성형외과, 치과, 안과, 헬스케어 클리닉
- **Need:** 검색 상위 노출, 환자 전환율 증가, 고품질 영상 대량 확대
- **Behavior:** 월 100~500만원 마케팅 예산, 1-2명 내부 마케팅 인력
- **Goal:** 비용 대비 ROI 극대화, 자연 검색 유입 확대
### Persona 2: 병원 마케팅 대행사
- **Who:** 콘텐츠 마케팅 Agency, 영상 마케팅 Agency, 광고 운영 대행사
- **Need:** 다수 클라이언트 통합 운영, 콘텐츠 생산성 극대화
- **Behavior:** 10~50개 병원 클라이언트 관리, 3-10명 팀
- **Goal:** 인건비 절감, 영상 제작 자동화, 성과 리포팅 효율화
---
## 4. Product Vision & AGDP Cycle
```
┌─────────────────────────────────────────────────┐
│ AGDP Self-Improving Cycle │
│ │
│ ┌───────────┐ ┌───────────┐ │
│ │ Analysis │──────▶│ Generation│ │
│ │ (분석) │ │ (생성) │ │
│ └─────▲─────┘ └─────┬─────┘ │
│ │ │ │
│ │ ▼ │
│ ┌─────┴─────┐ ┌───────────┐ │
│ │Performance│◀──────│Distribution│ │
│ │ (성과) │ │ (배포) │ │
│ └───────────┘ └───────────┘ │
│ │
│ 매 사이클마다 Performance 데이터가 Analysis에 │
│ 피드백되어 콘텐츠 품질 자율 최적화 │
└─────────────────────────────────────────────────┘
```
---
## 5. 구현된 페이지 구조 & 사용자 흐름
### 5.1 Page Flow
```
Landing (/)
└─ URL 입력 → "Analyze Marketing Performance" 클릭
└─ Analysis Loading (/report/loading)
└─ 5단계 체크리스트 애니메이션 (5.5초)
└─ Marketing Intelligence Report (/report/:id)
└─ "마케팅 기획" 버튼 클릭
└─ Marketing Execution Plan (/plan/:id)
```
**PageNavigator:** 하단 고정 pill-shaped 바로 3페이지 간 자유 이동
### 5.2 Landing Page — 7 Sections
| # | Component | 배경 | 핵심 기능 |
|---|-----------|------|----------|
| 1 | Hero | 라이트 (radial gradient) | URL 입력 → 분석 시작 CTA |
| 2 | TargetAudience | 화이트 | 2개 glass-card (병원 / 대행사) |
| 3 | Problems | 슬레이트 | 3가지 핵심 과제 카드 |
| 4 | Solution | **다크** (`#0A1128`) | AGDP 순환 다이어그램 (SVG 애니메이션) |
| 5 | Modules | 화이트 + 블롭 | 5개 모듈 카드 (Pentagon 레이아웃) |
| 6 | UseCases | 화이트 | 병원/대행사 유즈케이스 비교 |
| 7 | CTA | **다크** (`#0A1128`) | URL 입력 CTA (따뜻한 그라디언트 인풋) |
### 5.3 Marketing Intelligence Report — 11 Sections
| # | Component | 배경 | 데이터 소스 (Phase 2) |
|---|-----------|------|---------------------|
| 1 | ReportHeader | 파스텔 radial gradient | 종합 스코어, 의원 정보 |
| 2 | ClinicSnapshot | 라이트 | 의원 프로필, 의료진, 인증, 브랜드 컬러 |
| 3 | ChannelOverview | 라이트 | 6채널 스코어링 (ScoreRing + SeverityBadge) |
| 4 | YouTubeAudit | 라이트 | 구독자, 조회수, 인기 영상, 진단 |
| 5 | InstagramAudit | 라이트 | KR/EN 계정 분리 분석, Reels 진단 |
| 6 | FacebookAudit | 라이트 | 페이지 분석, 브랜드 일관성 맵, 통합 제안 |
| 7 | OtherChannels | 라이트 | 강남언니, 네이버, 웹사이트, 트래킹 픽셀 |
| 8 | ProblemDiagnosis | **다크** | 핵심 문제 진단 (severity별 분류) |
| 9 | TransformationProposal | 라이트 | As-Is → To-Be 5탭 (브랜드/콘텐츠/플랫폼/웹/신채널) |
| 10 | RoadmapTimeline | 라이트 | 90일 로드맵 (월별 태스크 체크리스트) |
| 11 | KPIDashboard | 라이트 | KPI 테이블 + CTA (PDF 다운로드 + 마케팅 기획) |
### 5.4 Marketing Execution Plan — 8 Sections
| # | Component | 배경 | 기능 |
|---|-----------|------|------|
| 1 | PlanHeader | 파스텔 radial gradient | 90 Days 배지, 의원 정보 |
| 2 | BrandingGuide | 라이트 | 4탭: 비주얼/톤앤매너/채널/일관성 |
| 3 | ChannelStrategy | **다크** | 7채널 전략 카드 (화이트 카드 on 다크 bg) |
| 4 | ContentStrategy | 라이트 | 4탭: 필러/유형/워크플로우/리퍼포징 |
| 5 | ContentCalendar | **다크** | 주간 캘린더 (화이트 카드 on 다크 bg) |
| 6 | AssetCollection | 라이트 | 에셋 카드 + 유튜브 리퍼포징 |
| 7 | MyAssetUpload | 라이트 | 드래그&드롭 업로드 (이미지/영상/텍스트) |
| 8 | PlanCTA | 독립 섹션 | 따뜻한 그라디언트 CTA + PDF 다운로드 |
---
## 6. Core Modules — 기능 요구사항
### Module 1: Marketing Intelligence (Analysis) — Phase 1 MVP
| Feature | Description | Priority | 구현 상태 |
|---------|-------------|----------|----------|
| URL 기반 초기 진단 | URL 입력 → 종합 분석 리포트 자동 생성 | P0 | **UI 완료** (하드코딩) |
| 채널별 스코어링 | YouTube/Instagram/Facebook/강남언니/웹사이트 점수화 | P0 | **UI 완료** |
| 브랜드 일관성 분석 | 채널 간 로고/컬러/네이밍 불일치 진단 | P0 | **UI 완료** |
| 문제 진단 | severity 기반 핵심 과제 도출 | P0 | **UI 완료** |
| As-Is → To-Be 변환 전략 | 5개 영역별 전환 제안 | P0 | **UI 완료** |
| 90일 로드맵 | 월별 실행 계획 | P0 | **UI 완료** |
| KPI 대시보드 | 3개월/12개월 목표 지표 | P0 | **UI 완료** |
| 스크린샷 근거 자료 | 분석 과정 캡쳐 → 리포트 첨부 | P1 | **컨텍스트 구축 완료** |
| PDF 내보내기 | 리포트/기획서 PDF 다운로드 | P0 | **구현 완료** |
### Module 2: Content Planning (Generation 준비)
| Feature | Description | Priority | 구현 상태 |
|---------|-------------|----------|----------|
| 브랜딩 가이드 빌드 | 컬러/타이포/톤앤매너/채널별 규칙 | P0 | **UI 완료** |
| 채널 전략 수립 | 7채널별 As-Is→To-Be, 콘텐츠 유형, 게시 빈도 | P0 | **UI 완료** |
| 콘텐츠 전략 | 필러/유형 매트릭스/워크플로우/리퍼포징 | P0 | **UI 완료** |
| 콘텐츠 캘린더 | 4주 주간 캘린더 + 월간 서머리 | P0 | **UI 완료** |
| 에셋 수집 | 홈페이지/SNS/유튜브 리퍼포징 가능 에셋 목록 | P0 | **UI 완료** |
| My Asset 업로드 | 사용자 에셋 업로드 (이미지/영상/텍스트) | P1 | **UI 완료** |
### Module 3~5: 미구현 (Phase 2+)
- Content Creation (AI 콘텐츠 생성 + Human-in-the-Loop)
- Video Automation (블로그→영상 변환)
- Distribution Engine (자동 배포)
- Performance Intelligence (성과 추적)
---
## 7. 디자인 시스템
> 전체 디자인 시스템 스펙: **`docs/DESIGN_SYSTEM.md`** 참조
### 7.1 핵심 원칙
- **No primary colors** — 빨강, 주황, 초록, 파랑 등 원색 사용 금지
- **Pastel semantic palette** — 상태 표현은 반드시 INFINITH 파스텔 팔레트 사용
- **Filled icons only** — 라인 아이콘 금지, SVG shape fill 스타일만 사용
- **No emoji** — 아이콘은 벡터 shape 전용
- **Dark/Light rhythm** — 섹션 교대로 다크(`#0A1128`)/라이트(`white`) 배경
- **White cards on dark** — 다크 섹션 내 카드는 `bg-white rounded-2xl shadow` 사용 (glass 아님)
### 7.2 INFINITH 브랜드 컬러
| Token | Hex | 용도 |
|-------|-----|------|
| Primary Navy | `#0A1128` | 다크 섹션 bg, 본문 텍스트 |
| Gradient Start | `#4F1DA1` | 버튼/배지 그라디언트 시작 |
| Gradient End | `#021341` | 버튼/배지 그라디언트 끝 |
| Accent Purple | `#6C5CE7` | 강조, 활성 탭 보더, 아이콘 |
| Warm Peach | `#fff3eb` | CTA 그라디언트 시작 |
| Soft Lilac | `#e4cfff` | CTA 그라디언트 중간 |
| Cool White-Blue | `#f5f9ff` | CTA 그라디언트 끝 |
### 7.3 타이포그래피
| 용도 | 폰트 | Weight | 크기 |
|------|-------|--------|------|
| 영문 헤딩 | Playfair Display | Bold 700 | 3xl~6xl |
| 한글 헤딩 | Pretendard | Bold 700 | 2xl~4xl |
| 본문 | Pretendard / Inter | Regular 400 | sm~base |
| INFINITH 로고 | Playfair Display | Black 900 | 3xl, `tracking-[0.05em]` |
---
## 8. External API/Connector 요구사항
| Category | Connector | Purpose | Priority |
|----------|-----------|---------|----------|
| 소셜미디어 스크래핑 | **Apify** | Instagram/Facebook/YouTube/TikTok 스크래핑, 스크린샷, 에셋 다운로드 | **P0** |
| 웹 스크래핑 | **Firecrawl** | 웹사이트 분석, 콘텐츠 추출 | P0 (크레딧 충전 필요) |
| YouTube | YouTube Data API | 채널 통계, 영상 목록 | P0 |
| SEO | **Ahrefs API** | 키워드 분석, 도메인 레이팅 | P1 (연결됨) |
| 한국 로컬 | Naver Search/Place API | 블로그/플레이스 분석 | P0 |
| AI | Claude API (Anthropic) | 콘텐츠 분석, 리포트 생성 | P0 |
| 영상 생성 | Creatomate API | 프로그래머블 영상 생성 | P1 |
| 결제 | Stripe + Toss Payments | 글로벌 + 국내 결제 | P1 |
---
## 9. Technical Architecture
### 9.1 현재 스택 (Phase 1 — 프론트엔드 레퍼런스)
| Layer | Technology |
|-------|-----------|
| Frontend | React 19 + Vite 6 + TypeScript |
| Styling | Tailwind CSS 4 + 커스텀 디자인 시스템 |
| Routing | React Router 7 |
| Animation | Motion (Framer Motion) |
| Icons | Lucide React + 커스텀 FilledIcons (SVG) |
| PDF Export | html2canvas-pro + jsPDF |
| Data | 하드코딩 mock data (`src/data/`) |
### 9.2 목표 스택 (Phase 2 — 풀스택 SaaS)
| Layer | Technology | Rationale |
|-------|-----------|-----------|
| Frontend | Next.js 15 (App Router) | SSR/SSG, SEO 최적화 |
| Styling | Tailwind CSS + 현재 디자인 시스템 유지 | 마이그레이션 최소화 |
| Backend | Next.js API Routes + tRPC | 풀스택 타입 안전성 |
| Database | Supabase (PostgreSQL) | Auth + DB + Storage 통합 |
| Auth | Supabase Auth + NextAuth.js | Kakao/Google OAuth |
| AI Orchestration | LangGraph / Claude Agent SDK | 멀티 에이전트 |
| Queue | Inngest or Trigger.dev | 비동기 AI 작업 처리 |
| Hosting | Vercel | Next.js 최적화 |
### 9.3 Data Model
현재 TypeScript 타입 정의가 **API 계약서** 역할:
- `src/types/report.ts` — MarketingReport 및 모든 하위 타입
- `src/types/plan.ts` — MarketingPlan 및 모든 하위 타입
```
Organization (워크스페이스)
├── Members (User + Role)
├── Subscription (Plan + Usage)
├── Projects (마케팅 프로젝트)
│ ├── MarketingReport (분석 리포트)
│ │ ├── ClinicSnapshot
│ │ ├── ChannelScores
│ │ ├── YouTubeAudit / InstagramAudit / FacebookAudit
│ │ ├── ProblemDiagnosis
│ │ ├── TransformationProposal
│ │ └── KPIDashboard
│ ├── MarketingPlan (콘텐츠 기획)
│ │ ├── BrandGuide
│ │ ├── ChannelStrategy
│ │ ├── ContentStrategy
│ │ ├── ContentCalendar
│ │ └── AssetCollection
│ └── UserAssets (업로드된 에셋)
├── Channels (연동 채널)
└── AuditLogs
```
---
## 10. Development Phases
### Phase 0: Foundation (완료 항목 + 잔여)
- [x] 랜딩 페이지 7개 섹션
- [x] 디자인 시스템 확립 (컬러, 타이포, 아이콘, 컴포넌트)
- [x] 마케팅 분석 리포트 UI (11개 섹션)
- [x] 콘텐츠 기획 페이지 UI (8개 섹션)
- [x] PDF 내보내기
- [x] 페이지 네비게이터
- [x] 에셋 업로드 UI
- [x] 스크린샷 근거 자료 컨텍스트 (ScreenshotProvider)
- [ ] Next.js 마이그레이션
- [ ] CI/CD 파이프라인
### Phase 1: Backend + 동적 데이터 (4주)
- [ ] Supabase 연동 (Auth + DB + Storage)
- [ ] Apify 연동 → 소셜미디어 스크래핑 파이프라인
- [ ] YouTube Data API 연동
- [ ] Naver Search/Place API 연동
- [ ] 하드코딩 데이터 → 실제 API 교체
- [ ] 스크린샷 캡쳐 → 리포트 자동 첨부
### Phase 2: AI Agent + Content Creation (4주)
- [ ] Claude API 연동 → AI 분석 리포트 생성
- [ ] 콘텐츠 기획 AI 자동 생성
- [ ] Human-in-the-Loop 에디터
- [ ] 의료광고 규정 검수 Agent
### Phase 3: Billing + Multi-tenancy (4주)
- [ ] 인증 시스템 (Email, Google, Kakao OAuth)
- [ ] Stripe + Toss Payments 결제
- [ ] 에이전시 멀티 클라이언트 모드
- [ ] RBAC (Owner/Admin/Editor/Viewer)
### Phase 4~6: Distribution, Performance, Growth
- (기존 PRD v1.0 Phase 4~6 유지)
---
## 11. Pricing Strategy
| Plan | Target | Price (Monthly) | Limits |
|------|--------|----------------|--------|
| **Free** | Trial | ₩0 | URL 분석 1회, 블로그 3개/월, 영상 0 |
| **Starter** | 개인 병원 | ₩99,000 | 블로그 20개/월, 영상 5개/월, 채널 3개 |
| **Professional** | 중형 병원 | ₩299,000 | 블로그 50개/월, 영상 20개/월, 채널 무제한, 팀 5명 |
| **Agency** | 대행사 | ₩699,000 | 클라이언트 10개, 블로그 200개/월, 영상 50개/월, 팀 무제한 |
| **Enterprise** | 대형 대행사 | Custom | 무제한, SSO, 전용 지원, SLA |
---
## 12. Success Metrics (KPIs)
| Metric | Target (6개월) | Target (12개월) |
|--------|---------------|-----------------|
| MAU | 200 | 1,000 |
| Paid Conversion Rate | 5% | 8% |
| MRR | ₩20M | ₩100M |
| Churn Rate | < 8% | < 5% |
| AI 초안 승인율 | > 60% | > 75% |
| 평균 리포트 생성 시간 | < 5 | < 2 |
---
## 13. 관련 문서
| 문서 | 경로 | 용도 |
|------|------|------|
| **Design System Spec** | `docs/DESIGN_SYSTEM.md` | 컬러, 타이포, 아이콘, 컴포넌트 규칙 |
| **TypeScript 타입 정의** | `src/types/report.ts`, `src/types/plan.ts` | API 계약서 |
| **Mock 데이터** | `src/data/mockReport.ts`, `src/data/mockPlan.ts` | 레퍼런스 데이터 구조 |
| **커스텀 아이콘** | `src/components/icons/FilledIcons.tsx` | Filled SVG 아이콘 세트 |
---
*This PRD is a living document. Updated as product decisions are made and user feedback is incorporated.*

20
README.md Normal file
View File

@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/d446f5f5-e089-404a-93ce-32a72f2f5108
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

262
docs/API_CONNECTORS.md Normal file
View File

@ -0,0 +1,262 @@
# INFINITH — API & MCP Connector Registry
**Version:** 1.0 | **Updated:** 2026-03-24 | **Status:** Backend Development Ready
---
## 1. Currently Connected (이미 연결됨)
| Service | Purpose | Status | Dashboard |
|---------|---------|--------|-----------|
| [Firecrawl](https://docs.firecrawl.dev/introduction) | 웹사이트 스크래핑, 콘텐츠 추출, 스크린샷 | **Credits 소진** | [Recharge](https://www.firecrawl.dev/pricing) |
| [Ahrefs API](https://docs.ahrefs.com/docs/api/reference/introduction) | 키워드 분석, 백링크, 도메인 레이팅 | **Connected** | [Dashboard](https://ahrefs.com/dashboard) |
| [Figma MCP](https://developers.figma.com/) | 디자인 에셋 읽기, 브랜드 변수 추출 | **Connected** | [Developer](https://developers.figma.com/) |
| [Slack MCP](https://api.slack.com/) | 팀 알림, 채널 메시징 | **Connected** | [API](https://api.slack.com/) |
| [Notion MCP](https://developers.notion.com/) | 문서/DB 관리, 리포트 저장 | **Connected** | [Integrations](https://www.notion.so/my-integrations) |
| [Google Drive](https://console.cloud.google.com/) | 파일 저장/공유 | **Connected** | [Console](https://console.cloud.google.com/) |
| [Gemini (nano-banana-pro)](https://aistudio.google.com/) | AI 이미지 생성/편집 | **Connected** | [AI Studio](https://aistudio.google.com/) |
---
## 2. P0 — 즉시 연결 필요 (Phase 1 Backend)
### 2.1 Apify — 소셜미디어 통합 스크래핑
| Item | Link |
|------|------|
| **가입** | https://console.apify.com/sign-up |
| **API 문서** | https://docs.apify.com/api/v2 |
| **가격** | https://apify.com/pricing |
| **MCP 서버 (GitHub)** | https://github.com/apify/apify-mcp-server |
| **MCP 서버 (npm)** | `npx @apify/actors-mcp-server` |
| **MCP 연동 가이드** | https://docs.apify.com/platform/integrations/mcp |
| **용도** | Instagram/Facebook/YouTube/TikTok 프로필 스크래핑, 스크린샷 배치 캡쳐, 에셋 다운로드 |
| **예상 비용** | $49/mo (Starter) — 100 Actor runs/day |
### 2.2 YouTube Data API v3 — 채널 분석
| Item | Link |
|------|------|
| **API 활성화** | https://console.cloud.google.com/apis/library/youtube.googleapis.com |
| **시작 가이드** | https://developers.google.com/youtube/v3/getting-started |
| **API 레퍼런스** | https://developers.google.com/youtube/v3/docs |
| **MCP 서버** | https://github.com/sparfenyuk/mcp-youtube (`npx yt-mcp`) |
| **용도** | 채널 통계, 구독자, 영상 목록, 조회수, 댓글 분석 |
| **예상 비용** | 무료 (일 10,000 units quota) |
### 2.3 Naver Search API — 한국 로컬 검색
| Item | Link |
|------|------|
| **개발자 센터** | https://developers.naver.com/ |
| **API 목록** | https://naver.github.io/naver-openapi-guide/apilist.html |
| **MCP 서버 (GitHub)** | https://github.com/isnow890/naver-search-mcp |
| **MCP 서버 (npm)** | `npx @isnow890/naver-search-mcp` |
| **Naver Shopping Insight MCP** | https://github.com/dataartai/naver-api-mcp |
| **용도** | 블로그/카페/뉴스 검색, 키워드 볼륨 분석 |
| **예상 비용** | 무료 (일 25,000건) |
### 2.4 Supabase — 백엔드 인프라 (Auth + DB + Storage)
| Item | Link |
|------|------|
| **Dashboard** | https://supabase.com/dashboard |
| **문서** | https://supabase.com/docs |
| **MCP 서버 (GitHub)** | https://github.com/supabase-community/supabase-mcp |
| **MCP 서버 (npm)** | `npx @supabase/mcp-server-supabase` |
| **MCP 가이드** | https://supabase.com/docs/guides/getting-started/mcp |
| **용도** | PostgreSQL DB, 인증(OAuth), 파일 스토리지, Realtime |
| **예상 비용** | 무료 (Free tier: 500MB DB, 1GB Storage) |
### 2.5 Claude / Anthropic API — AI 엔진
| Item | Link |
|------|------|
| **Console (API Keys)** | https://console.anthropic.com/ |
| **API 문서** | https://docs.anthropic.com/en/docs/get-started |
| **Platform 문서** | https://platform.claude.com/docs/en/home |
| **용도** | 마케팅 분석 리포트 AI 생성, 콘텐츠 생성, 전략 수립 |
| **예상 비용** | Usage-based (Sonnet: $3/1M input, $15/1M output) |
---
## 3. P1 — Phase 2 연결 (AI Agent + Content Creation)
### 3.1 Perplexity API — AI 검색 + AEO
| Item | Link |
|------|------|
| **가입** | https://www.perplexity.ai/api-platform |
| **API Key 설정** | https://www.perplexity.ai/settings/api |
| **문서** | https://docs.perplexity.ai/ |
| **가격** | https://docs.perplexity.ai/docs/getting-started/pricing |
| **MCP 서버 (공식)** | https://github.com/perplexityai/modelcontextprotocol |
| **MCP 서버 (npm)** | `npx @perplexity-ai/mcp-server` |
| **용도** | AI 기반 웹 검색, AEO(Answer Engine Optimization) 최적화, 트렌드 분석 |
### 3.2 Creatomate API — 영상/이미지 자동 생성
| Item | Link |
|------|------|
| **가입** | https://creatomate.com/ |
| **API 문서** | https://creatomate.com/docs/api/introduction |
| **가격** | https://creatomate.com/pricing |
| **Developer Hub** | https://creatomate.com/developers |
| **용도** | 템플릿 기반 영상/이미지 생성, 숏폼 자동 제작, 소셜 포맷별 리사이즈 |
| **예상 비용** | $49/mo (Pro: 월 100 renders) |
### 3.3 Instagram / Facebook Graph API — 공식 게시/분석
| Item | Link |
|------|------|
| **Meta Developer Portal** | https://developers.facebook.com/ |
| **앱 생성** | https://developers.facebook.com/apps/ |
| **Instagram API 문서** | https://developers.facebook.com/docs/instagram-platform/ |
| **Instagram API Reference** | https://developers.facebook.com/docs/instagram-platform/reference/ |
| **Facebook Graph API 문서** | https://developers.facebook.com/docs/graph-api/ |
| **Graph API Get Started** | https://developers.facebook.com/docs/graph-api/get-started/ |
| **용도** | SNS 자동 게시, Reels 업로드, 인사이트 데이터 수집 |
| **예상 비용** | 무료 |
### 3.4 Google Search Console API — SEO 성과
| Item | Link |
|------|------|
| **API 활성화** | https://console.cloud.google.com/apis/library/searchconsole.googleapis.com |
| **문서** | https://developers.google.com/webmaster-tools |
| **용도** | 검색 노출량, CTR, 키워드 순위 변동 추적 |
| **예상 비용** | 무료 |
### 3.5 Google Analytics 4 API — 웹 트래픽
| Item | Link |
|------|------|
| **API 활성화** | https://console.cloud.google.com/apis/library/analyticsdata.googleapis.com |
| **Quickstart** | https://developers.google.com/analytics/devguides/reporting/data/v1/quickstart |
| **용도** | 웹사이트 유입 경로, 전환 데이터, 세션 분석 |
| **예상 비용** | 무료 |
---
## 4. P2 — 고도화 단계
### 4.1 Naver Place / Map API
| Item | Link |
|------|------|
| **Naver Cloud** | https://www.ncloud.com/ |
| **Map API** | https://www.ncloud.com/v2/product/applicationService/maps |
| **API 문서** | https://api.ncloud-docs.com/docs/common-ncpapi |
| **MCP 서버** | https://github.com/num2k/naver-map-mcp |
| **용도** | 플레이스 정보, 병원 리뷰, 위치 데이터 |
### 4.2 Google Maps Places API
| Item | Link |
|------|------|
| **API 활성화** | https://console.cloud.google.com/google/maps-apis/overview |
| **API Key 발급** | https://developers.google.com/maps/documentation/places/web-service/get-api-key |
| **Places API 문서** | https://developers.google.com/maps/documentation/places/web-service/overview |
| **용도** | 구글 리뷰, 평점, 위치 정보, 경쟁 병원 분석 |
| **예상 비용** | $17/1,000 requests |
### 4.3 Google Vision API
| Item | Link |
|------|------|
| **API 활성화** | https://console.cloud.google.com/apis/library/vision.googleapis.com |
| **문서** | https://docs.cloud.google.com/vision/docs/setup |
| **가격** | https://cloud.google.com/vision/pricing |
| **용도** | 로고 감지, 브랜드 컬러 자동 추출, 이미지 분석 |
| **예상 비용** | $1.50/1,000 images |
### 4.4 Brandfetch API
| Item | Link |
|------|------|
| **가입** | https://developers.brandfetch.com/register |
| **문서** | https://docs.brandfetch.com/ |
| **Brand API** | https://docs.brandfetch.com/brand-api/overview |
| **가격** | https://brandfetch.com/developers/pricing |
| **용도** | 도메인 → 브랜드 로고/컬러/폰트 자동 추출 |
### 4.5 TikTok API
| Item | Link |
|------|------|
| **Developer Portal** | https://developers.tiktok.com/ |
| **문서** | https://developers.tiktok.com/doc/overview |
| **Business API** | https://business-api.tiktok.com/portal |
| **Business API 가입** | https://business-api.tiktok.com/portal/developer/register |
| **용도** | TikTok 영상 게시, 계정 통계, 트렌드 분석 |
### 4.6 Canva Connect API
| Item | Link |
|------|------|
| **Developer Portal** | https://www.canva.dev/ |
| **문서** | https://www.canva.dev/docs/connect/ |
| **Quickstart** | https://www.canva.dev/docs/connect/quickstart/ |
| **용도** | 템플릿 기반 이미지 Autofill (Enterprise 전용) |
### 4.7 Puppeteer (서버사이드 스크린샷)
| Item | Link |
|------|------|
| **npm** | https://www.npmjs.com/package/puppeteer |
| **문서** | https://pptr.dev/ |
| **GitHub** | https://github.com/puppeteer/puppeteer |
| **용도** | 서버사이드 웹페이지 스크린샷 캡쳐 (분석 근거 자료) |
| **예상 비용** | 무료 (OSS) |
---
## 5. MCP 서버 Quick Reference
### 즉시 연결 (P0)
```bash
# Apify — 소셜미디어 스크래핑 통합
npx @apify/actors-mcp-server
# Supabase — Auth + DB + Storage
npx @supabase/mcp-server-supabase
# Naver Search — 한국 로컬 검색
npx @isnow890/naver-search-mcp
# YouTube — 채널 분석
npx yt-mcp
```
### Phase 2 연결 (P1)
```bash
# Perplexity — AI 검색
npx @perplexity-ai/mcp-server
# Stripe — 글로벌 결제 (B2B 계약 전환 후)
npx @stripe/mcp
# Naver Map — 플레이스 분석
# https://github.com/num2k/naver-map-mcp
```
### 이미 연결됨
```
Firecrawl ── 웹 스크래핑 (크레딧 충전 필요)
Ahrefs ───── SEO/키워드 분석
Figma ────── 디자인 에셋
Slack ────── 팀 커뮤니케이션
Notion ───── 문서/DB
Google Drive ── 파일 관리
Gemini ───── AI 이미지 생성
```
---
## 6. 월간 예상 비용 (MVP 기준)
| Service | Plan | 월 비용 | 비고 |
|---------|------|--------|------|
| Supabase | Free | ₩0 | 500MB DB, 1GB Storage |
| YouTube Data API | Free | ₩0 | 일 10K units |
| Naver Search API | Free | ₩0 | 일 25K건 |
| Anthropic (Claude) | Usage | ~₩50,000 | 리포트 100건/월 기준 |
| Apify | Starter | ~₩65,000 | $49/mo |
| Firecrawl | Growth | ~₩26,000 | $19/mo |
| Creatomate | Pro | ~₩65,000 | $49/mo (Phase 2) |
| **Phase 1 합계** | | **~₩141,000/mo** | Supabase+YouTube+Naver+Claude+Apify+Firecrawl |
| **Phase 2 합계** | | **~₩206,000/mo** | + Creatomate |
---
## 7. 결제 시스템
> **현재 전략: B2B 계약 우선**
> Stripe / Toss Payments 결제 시스템은 B2C 전환 시점에 개발 예정.
> 현재는 수동 청구서 발행으로 진행.
---
*Last updated: 2026-03-24*

263
docs/DESIGN_SYSTEM.md Normal file
View File

@ -0,0 +1,263 @@
# INFINITH Design System Specification
**Version:** 1.0
**Date:** 2026-03-23
---
## 1. Design Principles
| 원칙 | 설명 |
|------|------|
| **No Primary Colors** | 빨강, 주황, 초록, 파랑 등 원색 사용 금지. 파스텔 변형만 허용 |
| **Filled Icons Only** | 라인(outlined) 아이콘 금지. SVG shape fill 스타일만 사용 |
| **No Emoji** | 모든 아이콘은 벡터 shape으로 표현 |
| **Dark/Light Rhythm** | 페이지 내 섹션은 다크/라이트 배경을 교대 배치 |
| **White Cards on Dark** | 다크 섹션 내 카드는 `bg-white` 사용 (glassmorphism 아님) |
| **Diagonal Shadows** | 카드/요소에 사선 방향 연한 그림자: `shadow-[3px_4px_12px_rgba(0,0,0,0.06)]` |
| **Pastel Semantics** | 상태 표현은 반드시 INFINITH 파스텔 팔레트의 bg/text/border/dot 세트 사용 |
---
## 2. Color System
### 2.1 INFINITH Brand Colors
| Token | Hex | CSS Variable | 용도 |
|-------|-----|-------------|------|
| Primary Navy | `#0A1128` | `--color-primary-900` | 다크 섹션 bg, 본문 텍스트, 테이블 헤더 |
| Medium Navy | `#1A2B5E` | `--color-primary-800` | 보조 다크 |
| Near White | `#F4F6FB` | `--color-primary-50` | 밝은 배경 변형 |
| Accent Purple | `#6C5CE7` | `--color-accent` | 활성 탭 보더, 강조 아이콘, 로딩 스피너 |
| Gradient Start | `#4F1DA1` | — | 버튼, 배지 그라디언트 왼쪽 |
| Gradient End | `#021341` | — | 버튼, 배지 그라디언트 오른쪽 |
### 2.2 Warm CTA Gradient (3-stop)
```css
background: linear-gradient(to right, #fff3eb, #e4cfff, #f5f9ff);
```
| Stop | Hex | 이름 |
|------|-----|------|
| Start | `#fff3eb` | Warm Peach |
| Mid | `#e4cfff` | Soft Lilac |
| End | `#f5f9ff` | Cool White-Blue |
**사용처:** CTA 카드 배경, URL 입력 필드, KPI CTA 카드
### 2.3 Semantic Status Palette
**규칙:** 상태를 표현할 때 반드시 이 4가지 세트 중 하나를 사용. 원색(빨강/초록) 절대 금지.
| Status | Background | Text | Border | Dot | 한국어 |
|--------|-----------|------|--------|-----|--------|
| Critical | `#FFF0F0` | `#7C3A4B` | `#F5D5DC` | `#D4889A` | 심각 |
| Warning | `#FFF6ED` | `#7C5C3A` | `#F5E0C5` | `#D4A872` | 주의 |
| Good | `#F3F0FF` | `#4A3A7C` | `#D5CDF5` | `#9B8AD4` | 양호 |
| Info | `#EFF0FF` | `#3A3F7C` | `#C5CBF5` | `#7A84D4` | 우수 |
**CSS Variables:**
```css
--color-status-{level}-bg
--color-status-{level}-text
--color-status-{level}-border
--color-status-{level}-dot
```
### 2.4 ScoreRing Color Thresholds
| Score Range | Color | Token |
|-------------|-------|-------|
| 0~40% | `#C084CF` | Soft Violet (Critical) |
| 41~60% | `#8B9CF7` | Periwinkle (Caution) |
| 61~80% | `#7C6DD8` | Medium Purple (Good) |
| 81~100% | `#6C5CE7` | INFINITH Purple (Excellent) |
### 2.5 Platform Brand Colors (외부 브랜드 존중)
| Platform | Hex | 용도 |
|----------|-----|------|
| YouTube | `#FF0000` | 아이콘 컨테이너, 진단 구분 |
| Instagram | `#E1306C` | 아이콘 그라디언트 |
| Facebook | `#1877F2` | 아이콘 컨테이너, 로고 |
| VIEW Purple | `#7B2D8E` | 고객 브랜드 (뷰성형외과) |
| VIEW Gold | `#E8B931` | 고객 브랜드 (뷰성형외과) |
---
## 3. Typography
### 3.1 Font Families
| 폰트 | 역할 | 로딩 |
|------|------|------|
| **Playfair Display** | 영문 헤딩, 섹션 타이틀, INFINITH 로고 | Google Fonts |
| **Pretendard** | 한글 헤딩, 본문 | CDN |
| **Inter** | 영문 본문, UI 요소 | Google Fonts (sans-serif) |
### 3.2 Size & Weight Scale
| 용도 | 폰트 | Size | Weight | Tailwind Class |
|------|-------|------|--------|---------------|
| INFINITH 로고 | Playfair Display | `3xl` (30px) | Black 900 | `font-serif text-3xl font-black tracking-[0.05em]` |
| 페이지 H1 | Playfair Display | `4xl~5xl` | Bold 700 | `font-serif text-4xl md:text-5xl font-bold` |
| 섹션 타이틀 (영문) | Playfair Display | `3xl~4xl` | Bold 700 | `font-serif text-3xl md:text-4xl font-bold` |
| 섹션 서브타이틀 | Pretendard | `lg` (18px) | Regular 400 | `text-lg` |
| 카드 제목 | Pretendard | `lg` (18px) | Bold 700 | `text-lg font-bold` |
| 본문 | Pretendard / Inter | `sm~base` (14~16px) | Regular 400 | `text-sm` or `text-base` |
| 캡션/라벨 | Inter | `xs` (12px) | Medium 500 | `text-xs font-medium` |
| 배지/태그 | Inter | `xs` (12px) | Semibold 600 | `text-xs font-semibold` |
### 3.3 Text Color Mapping
| 컨텍스트 | 라이트 섹션 | 다크 섹션 |
|----------|-----------|----------|
| 제목 | `text-[#0A1128]` | gradient `from-purple-300 to-blue-300` |
| 서브타이틀 | `text-slate-600` | `text-purple-200` |
| 본문 | `text-slate-700` | `text-white/80` |
| 캡션 | `text-slate-500` | `text-purple-300` |
| 강조 | `text-[#6C5CE7]` | `text-purple-300` |
---
## 4. Layout Patterns
### 4.1 SectionWrapper
모든 콘텐츠 섹션의 기본 래퍼. `src/components/report/ui/SectionWrapper.tsx`
| Prop | Light Mode | Dark Mode |
|------|-----------|-----------|
| Background | `bg-white` | `bg-[#0A1128]` + `radial-gradient rgba(108,92,231,0.15)` |
| Padding | `py-16 md:py-20 px-6` | 동일 |
| Max Width | `max-w-7xl mx-auto` | 동일 |
| Title Style | `.text-gradient` (navy gradient) | purple-300→blue-300 gradient |
| Animation | `opacity: 0, y: 40``1, 0` on scroll | 동일 |
### 4.2 Card Patterns
| 패턴 | 클래스 | 사용처 |
|------|--------|--------|
| **Standard Card** | `bg-white rounded-2xl border border-slate-100 shadow-sm p-5` | 대부분의 라이트 섹션 카드 |
| **White Card on Dark** | `bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6` | ChannelStrategy, ContentCalendar |
| **Glass Card** | `bg-white/70 backdrop-blur-xl border border-white/40 shadow-[0_8px_32px_0_rgba(31,38,135,0.07)] rounded-2xl` | 랜딩 페이지 (TargetAudience, UseCases) |
| **Dark Glass Card** | `bg-white/10 backdrop-blur-sm border border-white/10 rounded-2xl` | ProblemDiagnosis 내부 |
| **Gradient Card** | `bg-gradient-to-r from-[#4F1DA1] to-[#021341]` | ConsolidationCard, 강조 카드 |
### 4.3 Shadow System
```css
/* Standard (대부분의 카드) */
shadow-sm /* Tailwind default */
/* Diagonal soft shadow (화이트 카드 on 다크) */
shadow-[3px_4px_12px_rgba(0,0,0,0.06)] /* 기본 */
shadow-[4px_6px_16px_rgba(0,0,0,0.09)] /* hover */
/* Status-tinted shadows (배지/태그) */
shadow-[2px_3px_8px_rgba(212,136,154,0.15)] /* Critical tint */
shadow-[2px_3px_8px_rgba(212,168,114,0.15)] /* Warning tint */
shadow-[2px_3px_8px_rgba(155,138,212,0.15)] /* Good/Purple tint */
shadow-[2px_3px_8px_rgba(122,132,212,0.15)] /* Info tint */
/* Elevated (헤더, 라이트박스) */
shadow-lg /* 리포트/플랜 헤더 패널 */
shadow-2xl /* 라이트박스 모달 */
```
---
## 5. Component Inventory
### 5.1 Shared UI Components (`src/components/report/ui/`)
| Component | Props | Description |
|-----------|-------|-------------|
| `SectionWrapper` | `id, title, subtitle, dark?, className?` | 모든 섹션의 래퍼 |
| `MetricCard` | `value, label, trend?, subtext?` | 숫자 지표 카드 |
| `ScoreRing` | `score, maxScore, size?` | SVG 원형 점수 표시 |
| `SeverityBadge` | `severity` | 상태 배지 (심각/주의/양호/우수) |
| `DiagnosisRow` | `category, detail, severity, evidenceIds?` | 진단 항목 행 |
| `ComparisonRow` | `area, asIs, toBe` | As-Is → To-Be 비교 행 |
| `EvidenceGallery` | `evidenceIds[]` | 스크린샷 근거 갤러리 |
| `EvidenceScreenshot` | `evidence, compact?` | 개별 스크린샷 카드 |
| `EvidenceLightbox` | `evidence, onClose` | 스크린샷 확대 모달 |
### 5.2 Custom Icons (`src/components/icons/FilledIcons.tsx`)
모든 아이콘은 **fill 전용** (stroke 없음), `opacity: 0.25` 배경 rect + fill path.
| Icon | 용도 |
|------|------|
| `YoutubeFilled` | YouTube 채널 |
| `InstagramFilled` | Instagram 채널 |
| `FacebookFilled` | Facebook 채널 |
| `TiktokFilled` | TikTok 채널 |
| `GlobeFilled` | 웹사이트/기타 채널 |
| `VideoFilled` | 영상 콘텐츠 |
| `FileTextFilled` | 블로그 콘텐츠 |
| `ShareFilled` | 소셜 콘텐츠 |
| `MegaphoneFilled` | 광고 콘텐츠 |
| `MessageFilled` | 메시지/커뮤니티 |
| `CalendarFilled` | 일정/빈도 |
### 5.3 Button Patterns
| 타입 | 클래스 | 사용처 |
|------|--------|--------|
| **Primary** | `bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white rounded-full` | CTA, 로그인 |
| **Secondary** | `bg-white border border-slate-200 text-[#021341] rounded-full` | PDF 다운로드 |
| **Tab Active** | `bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white rounded-full` | 탭 인터페이스 |
| **Tab Inactive** | `bg-slate-100 text-slate-600 rounded-full hover:bg-slate-200` | 탭 인터페이스 |
| **Nav Active** | `border-b-2 border-[#6C5CE7] text-[#0A1128]` | ReportNav 탭 |
### 5.4 Badge/Tag Patterns
| 타입 | 스타일 | 예시 |
|------|--------|------|
| **Priority P0** | `bg-[#FFF0F0] text-[#7C3A4B] border-[#F5D5DC]` | 긴급 채널 |
| **Priority P1** | `bg-[#FFF6ED] text-[#7C5C3A] border-[#F5E0C5]` | 주요 채널 |
| **Priority P2** | `bg-[#F3F0FF] text-[#4A3A7C] border-[#D5CDF5]` | 보조 채널 |
| **Content Type** | 각 semantic status 색상 | 카드 내 유형 태그 |
| **Platform** | `bg-slate-100 text-slate-700` | 일반 플랫폼 라벨 |
---
## 6. Animation Patterns
| 패턴 | 구현 | 사용처 |
|------|------|--------|
| **Scroll reveal** | `motion.div` `opacity: 0, y: 20→40` | 모든 SectionWrapper, 카드 |
| **Stagger** | `delay: index * 0.1` | 그리드 카드, 리스트 |
| **Blob float** | `animate-blob` (7s) / `animate-blob-large` (25s) | Hero, Modules 배경 |
| **Score ring** | `motion.circle` dashoffset animation | ScoreRing |
| **Page transition** | Instant (no transition) | React Router |
---
## 7. PDF Export Rules
| 규칙 | 구현 |
|------|------|
| 숨길 요소 | `data-report-nav`, `data-plan-nav`, `nav`, `data-cta-card`, `data-no-print` |
| 콘텐츠 래퍼 | `data-report-content` 또는 `data-plan-content` |
| 페이지 분할 | 섹션 단위 원자적 처리, 900px 이하 섹션은 통째로 |
| 마진 | 8mm 전방향 |
| 푸터 | "INFINITH Marketing Intelligence Report | Page X / Y" (7pt, #B4B4B4) |
| 해상도 | `scale: 2`, JPEG `quality: 0.9` |
| 애니메이션 | 내보내기 전 `opacity: 1, transform: none` 강제 적용 |
---
## 8. Responsive Breakpoints
| Breakpoint | Width | 주요 변화 |
|------------|-------|----------|
| Mobile | < 768px | 1 , |
| Tablet (md) | 768px+ | 2열 그리드, 중간 헤딩 |
| Desktop (lg) | 1024px+ | 3열 그리드, 풀 레이아웃 |
| Max content | 1280px | `max-w-7xl mx-auto` |
---
*이 문서는 코드와 함께 버전 관리됩니다. 디자인 변경 시 반드시 이 문서도 업데이트하세요.*

View File

@ -0,0 +1,71 @@
# INFINITH Marketing Extract
Here is the extracted text from the `INFINITH - Infinite Marketing` component files:
## Navbar
- **Brand:** INFINITH
- **Links:** Solution, Modules, Use Cases
- **Button:** Login
## Hero
- **Badge:** Infinite Marketing for Premium Medical Business & Marketing Agency
- **Headline:** AI Marketing Engine Infinite Marketing.
- **Description:** AI가 콘텐츠를 만들고 데이터가 마케팅을 개선합니다. 콘텐츠 기획, 생성, 영상 제작, 채널 배포, 데이터 분석을 하나의 자동화 엔진으로.
- **Call to Action:** Enter Your Website URL ➔ Analyze Marketing Performance
- **Subtext:** 네이버 블로그, 플레이스, 소셜미디어 등 Online Presence 종합 분석 리포트를 제공합니다.
## Target Audience
- **Headline:** Who is Infinite Marketing for
- **Sub-headline:** 프리미엄 의료 서비스와 전문 마케팅 에이전시를 위한 최적의 솔루션
- **Premium Medical Business:** 고객 LTV가 높고 브랜드 경쟁이 심해 콘텐츠 마케팅이 필수적인 프리미엄 의료 서비스 제공 병원
- *Tags:* 피부과, 성형외과, 치과, 안과, 헬스케어 클리닉, 피트니스
- **Medical Marketing Agency:** 콘텐츠 제작 비용 부담과 인력 의존도가 높고 영상 제작 생산성 개선이 필요한 병원 마케팅 대행사
- *Tags:* 병원 마케팅 대행사, 콘텐츠 마케팅 Agency, 영상 마케팅 Agency, 광고 운영 대행사
## Problems
- **Headline:** Premium Medical Marketing is Hard
- **Sub-headline:** 병원 마케팅이 직면한 3가지 핵심 과제
- **1. 콘텐츠 생산의 한계:** 블로그, SEO, 유튜브, 숏폼 등 지속적 생산이 필요하지만 인력과 비용, 시간 부족으로 제한됩니다.
- **2. 영상 콘텐츠 제작 비용:** 영상은 중요하지만 촬영, 편집, 기획 비용이 높습니다. 특히 숏폼 콘텐츠는 지속적인 제작이 어렵습니다.
- **3. 데이터 기반의 마케팅 부족:** 어떤 콘텐츠가 효과적인지, 어떤 키워드가 유입을 만드는지, 어떤 채널이 성과가 좋은지 알기 어렵고, 각 플랫폼들의 데이터들을 한눈에 파악할 수 없습니다.
## Solution
- **Badge:** AI Marketing Engine
- **Headline:** Infinite Marketing Engine
- **Description:** Infinite Marketing for Premium Medical Business & Marketing Agency. Infinite Marketing은 Premium Medical Business와 Marketing Agency를 위한 AI Marketing Automation Platform입니다. INFINITH는 마케팅 분석, 콘텐츠 생성, 영상 콘텐츠 제작, 채널 배포, 성과 분석과 피드백 적용을 하나의 Self-Improving Marketing Engine으로 제공합니다.
- **AGDP Cycle:**
- A (Analysis), G (Generation), D (Distribution), P (Performance)
- 분석(Analysis) → 생성(Generation) → 배포(Distribution) → 성과(Performance)의 무한 루프를 통해 콘텐츠 품질(CTR)을 자율 최적화합니다.
## Modules
- **Headline:** Core Modules
- **Sub-headline:** 성능 개선 반영 자율 순환 마케팅 시스템
- **Self-Improving Growth Engine:**
1. **Marketing Intelligence:** 브랜딩, 마케팅 현황 분석, 타겟 고객 분석, 키워드 분석, 경쟁 및 포지셔닝 분석, SEO 전략 & 채널별 콘텐츠 기획 (*AI 기반 시장 통찰력 도출*)
2. **Content Creation:** 블로그 콘텐츠 생성, SEO 콘텐츠 생성, SNS 콘텐츠 생성, 마케팅 카피 생성, Human-in-the loop 프로세스 (*고품질 맞춤형 콘텐츠 자동화*)
3. **Video Automation:** 블로그 → 영상 변환, 숏폼 콘텐츠 생성, 유튜브 콘텐츠 제작, SNS 영상 제작, 멀티모달 AI 엔진: 영상 + 음악 + 카피 (*원클릭 영상 제작 시스템*)
4. **Distribution Engine:** 블로그 게시, SNS 자동 게시, 유튜브 업로드, 콘텐츠 일정 관리, SEO, AEO 자동 최적화 (*전 채널 통합 배포 및 최적화*)
5. **Performance Intelligence:** SEO 성과 분석, 콘텐츠 성과 분석, 채널 성과 분석, AI 콘텐츠 개선 전략 추천, 데이터 기반 효과 검증 (*실시간 성과 추적 및 개선*)
## Use Cases
- **Headline:** Use Cases
- **Sub-headline:** Infinite Marketing이 만드는 실질적인 변화를 확인해보세요!
- **Premium Medical Business:**
- SEO 콘텐츠 자동 생산으로 검색 상위 노출 달성
- 비용 부담 없이 고품질 영상 콘텐츠 대량 확대 (NEW)
- 자연 검색 유입 증가로 인한 환자 전환율 상승
- **Marketing Agency:**
- AI 기반 콘텐츠 제작 자동화로 생산성 극대화
- 블로그 텍스트 기반 영상 제작 자동화로 리소스 절감
- 다수 클라이언트 계정의 통합 운영 및 효율화
## CTA (Call to Action)
- **Headline:** Ready to Transform Your Marketing?
- **Description:** URL 하나로 시작하는 완벽한 마케팅 자동화. 지금 바로 무료 진단을 받아보세요.
- **Action:** Enter Your URL ➔ Analyze
- **Subtext:** 네이버 블로그, 플레이스, 소셜미디어 종합 분석 리포트 받아보기
## Footer
- **Brand:** INFINITH. All rights reserved.
- **Tagline:** Infinite Marketing for Premium Medical Business & Marketing Agency
- **Links:** Privacy Policy, Terms of Service

17
index.html Normal file
View File

@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>INFINITH - Infinite Marketing</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Playfair+Display:ital,wght@0,400;0,500;0,600;0,700;0,900;1,400&display=swap" rel="stylesheet">
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5
metadata.json Normal file
View File

@ -0,0 +1,5 @@
{
"name": "Remix: INFINITH - Infinite Marketing",
"description": "Infinite Marketing for Premium Medical Business \u0026 Marketing Agency",
"requestFramePermissions": []
}

4980
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
package.json Normal file
View File

@ -0,0 +1,38 @@
{
"name": "react-example",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port=3000 --host=0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"clean": "rm -rf dist",
"lint": "tsc --noEmit"
},
"dependencies": {
"@google/genai": "^1.29.0",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"better-sqlite3": "^12.4.1",
"dotenv": "^17.2.3",
"express": "^4.21.2",
"html2canvas-pro": "^2.0.2",
"jspdf": "^4.2.1",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router": "^7.13.1",
"vite": "^6.2.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.21",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="450" viewBox="0 0 800 450">
<rect width="800" height="450" fill="#F3F0FF"/>
<rect x="20" y="20" width="760" height="410" rx="16" fill="white" stroke="#D5CDF5" stroke-width="2"/>
<text x="400" y="200" text-anchor="middle" font-family="system-ui" font-size="20" fill="#6C5CE7" font-weight="600">Facebook EN 페이지</text>
<text x="400" y="240" text-anchor="middle" font-family="system-ui" font-size="14" fill="#9B8AD4">Screenshot Evidence Placeholder</text>
<text x="400" y="270" text-anchor="middle" font-family="system-ui" font-size="12" fill="#C4BAE8">Production: Captured via Firecrawl / Puppeteer</text>
</svg>

After

Width:  |  Height:  |  Size: 686 B

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="450" viewBox="0 0 800 450">
<rect width="800" height="450" fill="#F3F0FF"/>
<rect x="20" y="20" width="760" height="410" rx="16" fill="white" stroke="#D5CDF5" stroke-width="2"/>
<text x="400" y="200" text-anchor="middle" font-family="system-ui" font-size="20" fill="#6C5CE7" font-weight="600">Instagram EN (@view_plastic_surgery)</text>
<text x="400" y="240" text-anchor="middle" font-family="system-ui" font-size="14" fill="#9B8AD4">Screenshot Evidence Placeholder</text>
<text x="400" y="270" text-anchor="middle" font-family="system-ui" font-size="12" fill="#C4BAE8">Production: Captured via Firecrawl / Puppeteer</text>
</svg>

After

Width:  |  Height:  |  Size: 701 B

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="450" viewBox="0 0 800 450">
<rect width="800" height="450" fill="#F3F0FF"/>
<rect x="20" y="20" width="760" height="410" rx="16" fill="white" stroke="#D5CDF5" stroke-width="2"/>
<text x="400" y="200" text-anchor="middle" font-family="system-ui" font-size="20" fill="#6C5CE7" font-weight="600">Instagram KR (@viewplastic)</text>
<text x="400" y="240" text-anchor="middle" font-family="system-ui" font-size="14" fill="#9B8AD4">Screenshot Evidence Placeholder</text>
<text x="400" y="270" text-anchor="middle" font-family="system-ui" font-size="12" fill="#C4BAE8">Production: Captured via Firecrawl / Puppeteer</text>
</svg>

After

Width:  |  Height:  |  Size: 692 B

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="450" viewBox="0 0 800 450">
<rect width="800" height="450" fill="#F3F0FF"/>
<rect x="20" y="20" width="760" height="410" rx="16" fill="white" stroke="#D5CDF5" stroke-width="2"/>
<text x="400" y="200" text-anchor="middle" font-family="system-ui" font-size="20" fill="#6C5CE7" font-weight="600">viewclinic.com 홈페이지</text>
<text x="400" y="240" text-anchor="middle" font-family="system-ui" font-size="14" fill="#9B8AD4">Screenshot Evidence Placeholder</text>
<text x="400" y="270" text-anchor="middle" font-family="system-ui" font-size="12" fill="#C4BAE8">Production: Captured via Firecrawl / Puppeteer</text>
</svg>

After

Width:  |  Height:  |  Size: 692 B

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="450" viewBox="0 0 800 450">
<rect width="800" height="450" fill="#F3F0FF"/>
<rect x="20" y="20" width="760" height="410" rx="16" fill="white" stroke="#D5CDF5" stroke-width="2"/>
<text x="400" y="200" text-anchor="middle" font-family="system-ui" font-size="20" fill="#6C5CE7" font-weight="600">YouTube 연결 링크</text>
<text x="400" y="240" text-anchor="middle" font-family="system-ui" font-size="14" fill="#9B8AD4">Screenshot Evidence Placeholder</text>
<text x="400" y="270" text-anchor="middle" font-family="system-ui" font-size="12" fill="#C4BAE8">Production: Captured via Firecrawl / Puppeteer</text>
</svg>

After

Width:  |  Height:  |  Size: 686 B

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="450" viewBox="0 0 800 450">
<rect width="800" height="450" fill="#F3F0FF"/>
<rect x="20" y="20" width="760" height="410" rx="16" fill="white" stroke="#D5CDF5" stroke-width="2"/>
<text x="400" y="200" text-anchor="middle" font-family="system-ui" font-size="20" fill="#6C5CE7" font-weight="600">YouTube 채널 페이지</text>
<text x="400" y="240" text-anchor="middle" font-family="system-ui" font-size="14" fill="#9B8AD4">Screenshot Evidence Placeholder</text>
<text x="400" y="270" text-anchor="middle" font-family="system-ui" font-size="12" fill="#C4BAE8">Production: Captured via Firecrawl / Puppeteer</text>
</svg>

After

Width:  |  Height:  |  Size: 689 B

18
src/App.tsx Normal file
View File

@ -0,0 +1,18 @@
import { Outlet, useLocation } from 'react-router';
import Navbar from './components/Navbar';
import Footer from './components/Footer';
import PageNavigator from './components/PageNavigator';
export default function App() {
const location = useLocation();
const isLoadingPage = location.pathname === '/report/loading';
return (
<div className="min-h-screen bg-slate-50 selection:bg-purple-200 selection:text-primary-900">
{!isLoadingPage && <Navbar />}
<Outlet />
{!isLoadingPage && <Footer />}
{!isLoadingPage && <PageNavigator />}
</div>
);
}

66
src/components/CTA.tsx Normal file
View File

@ -0,0 +1,66 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { motion } from 'motion/react';
import { ArrowRight } from 'lucide-react';
export default function CTA() {
const [url, setUrl] = useState('');
const navigate = useNavigate();
const handleAnalyze = () => {
if (url.trim()) navigate('/report/loading', { state: { url } });
};
return (
<section className="py-20 md:py-24 bg-primary-900 text-white px-6 relative overflow-hidden">
<div className="absolute inset-0 -z-10 bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-indigo-900 via-primary-900 to-primary-900 opacity-80"></div>
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-purple-500/20 rounded-full blur-[100px] pointer-events-none"></div>
<div className="max-w-4xl mx-auto text-center relative z-10">
<motion.h2
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="text-4xl md:text-5xl font-serif font-bold mb-4 leading-tight text-transparent bg-clip-text bg-gradient-to-r from-[#fff3eb] via-[#e4cfff] to-[#f5f9ff]"
>
Ready to Transform Your Marketing?
</motion.h2>
<motion.p
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.1 }}
className="text-lg md:text-xl text-purple-200 mb-10 max-w-2xl mx-auto font-light"
>
URL . .
</motion.p>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.2 }}
className="flex flex-col items-center justify-center gap-4 max-w-md mx-auto"
>
<input
type="url"
placeholder="Enter Your URL"
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAnalyze()}
className="w-full px-8 py-4 text-base font-medium bg-gradient-to-r from-[#fff3eb] via-[#e4cfff] to-[#f5f9ff] border border-white/20 rounded-full focus:outline-none focus:ring-2 focus:ring-white/50 shadow-sm text-center text-primary-900 placeholder:text-primary-900/60"
/>
<button onClick={handleAnalyze} className="w-full px-10 py-4 text-lg font-medium text-white rounded-full transition-all shadow-xl hover:shadow-2xl flex items-center justify-center gap-2 group bg-gradient-to-r from-[#4F1DA1] to-[#021341] hover:from-[#AF90FF] hover:to-[#AF90FF]">
Analyze
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</button>
<p className="text-sm text-purple-200/80 mt-2">
, ,
</p>
</motion.div>
</div>
</section>
);
}

21
src/components/Footer.tsx Normal file
View File

@ -0,0 +1,21 @@
import React from 'react';
export default function Footer() {
return (
<footer className="bg-white border-t border-slate-100 py-12 px-6">
<div className="max-w-7xl mx-auto flex flex-col md:flex-row items-center justify-between gap-6">
<div className="flex items-center gap-2">
<span className="font-serif text-3xl font-black tracking-[0.05em] bg-gradient-to-r from-[#4F1DA1] to-[#021341] bg-clip-text text-transparent">INFINITH</span>
</div>
<div className="text-sm text-slate-500 text-center md:text-left">
&copy; {new Date().getFullYear()} INFINITH. All rights reserved. <br className="md:hidden" />
Infinite Marketing for Premium Medical Business & Marketing Agency
</div>
<div className="flex items-center gap-6 text-sm font-medium text-slate-600">
<a href="#" className="hover:text-primary-900 transition-colors">Privacy Policy</a>
<a href="#" className="hover:text-primary-900 transition-colors">Terms of Service</a>
</div>
</div>
</footer>
);
}

83
src/components/Hero.tsx Normal file
View File

@ -0,0 +1,83 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { motion } from 'motion/react';
import { ArrowRight, Sparkles } from 'lucide-react';
export default function Hero() {
const [url, setUrl] = useState('');
const navigate = useNavigate();
const handleAnalyze = () => {
if (url.trim()) navigate('/report/loading', { state: { url } });
};
return (
<section className="relative pt-40 pb-24 md:pt-52 md:pb-32 overflow-hidden flex flex-col items-center justify-center min-h-screen text-center px-6">
{/* Background Gradient */}
<div className="absolute inset-0 -z-10 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-indigo-100 via-purple-50 to-pink-50 opacity-70"></div>
<div className="max-w-4xl mx-auto">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/60 backdrop-blur-sm border border-white/40 shadow-sm mb-8"
>
<Sparkles className="w-4 h-4 text-accent" />
<span className="text-sm font-medium text-slate-700">Infinite Marketing for Premium Medical Business & Marketing Agency</span>
</motion.div>
<motion.h1
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.1 }}
className="text-5xl md:text-7xl font-serif font-bold text-primary-900 leading-[1.1] tracking-tight mb-6"
>
AI Marketing Engine <br className="hidden md:block" />
<span className="text-gradient">Infinite Marketing.</span>
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
className="text-lg md:text-xl text-slate-600 mb-10 max-w-3xl mx-auto leading-relaxed"
>
Infinite Marketing for Premium Medical Business & Marketing Agency. <br className="hidden md:block"/>
AI .
, , , , .
</motion.p>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.3 }}
className="flex flex-col items-center justify-center gap-5 max-w-lg mx-auto w-full"
>
<div className="relative w-full group">
<input
type="url"
placeholder="Enter Your Website URL"
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAnalyze()}
className="w-full px-8 py-5 text-base font-medium bg-white/80 backdrop-blur-sm border border-slate-200 rounded-2xl focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent/40 shadow-sm text-center text-primary-900 placeholder:text-slate-400 transition-all group-hover:border-slate-300"
/>
</div>
<button onClick={handleAnalyze} className="w-full px-8 py-5 text-base font-bold text-white rounded-2xl transition-all shadow-xl hover:shadow-2xl flex items-center justify-center gap-3 group bg-gradient-to-r from-[#4F1DA1] to-[#021341] hover:scale-[1.02] active:scale-[0.98]">
Analyze Marketing Performance
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</button>
<p className="text-xs font-medium text-slate-500 mt-2">
, , Online Presence .
</p>
</motion.div>
</div>
{/* Decorative elements */}
<div className="absolute top-1/4 left-10 w-64 h-64 bg-purple-300 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-blob"></div>
<div className="absolute top-1/3 right-10 w-64 h-64 bg-pink-300 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-blob animation-delay-2000"></div>
<div className="absolute -bottom-8 left-1/2 -translate-x-1/2 w-64 h-64 bg-indigo-300 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-blob animation-delay-4000"></div>
</section>
);
}

187
src/components/Modules.tsx Normal file
View File

@ -0,0 +1,187 @@
import React from 'react';
import { motion } from 'motion/react';
const modules = [
{
step: "1",
title: "Marketing Intelligence",
items: [
"브랜딩, 마케팅 현황 분석",
"타겟 고객 분석",
"키워드 분석",
"경쟁 및 포지셔닝 분석",
"SEO 전략 & 채널별 콘텐츠 기획"
],
highlight: "AI 기반 시장 통찰력 도출",
color: "bg-[#021341]",
textColor: "text-indigo-600"
},
{
step: "2",
title: "Content Creation",
items: [
"블로그 콘텐츠 생성",
"SEO 콘텐츠 생성",
"SNS 콘텐츠 생성",
"마케팅 카피 생성",
"Human-in-the loop 프로세스"
],
highlight: "고품질 맞춤형 콘텐츠 자동화",
color: "bg-[#021341]",
textColor: "text-indigo-600"
},
{
step: "3",
title: "Video Automation",
items: [
"블로그 → 영상 변환",
"숏폼 콘텐츠 생성",
"유튜브 콘텐츠 제작",
"SNS 영상 제작",
"멀티모달 AI 엔진: 영상 + 음악 + 카피"
],
highlight: "원클릭 영상 제작 시스템",
color: "bg-[#021341]",
textColor: "text-indigo-600"
},
{
step: "4",
title: "Distribution Engine",
items: [
"블로그 게시",
"SNS 자동 게시",
"유튜브 업로드",
"콘텐츠 일정 관리",
"SEO, AEO 자동 최적화"
],
highlight: "전 채널 통합 배포 및 최적화",
color: "bg-[#021341]",
textColor: "text-indigo-600"
},
{
step: "5",
title: "Performance Intelligence",
items: [
"SEO 성과 분석",
"콘텐츠 성과 분석",
"채널 성과 분석",
"AI 콘텐츠 개선 전략 추천",
"데이터 기반 효과 검증"
],
highlight: "실시간 성과 추적 및 개선",
color: "bg-[#021341]",
textColor: "text-indigo-600"
}
];
const Flywheel = () => (
<div className="relative w-72 h-72 md:w-[400px] md:h-[400px] flex items-center justify-center">
{/* Center Content */}
<div className="absolute inset-0 flex flex-col items-center justify-center text-center z-10">
<h3 className="text-[26px] md:text-[31px] font-serif font-bold text-slate-800 leading-tight">
Self-Improving<br/>Growth Engine
</h3>
</div>
</div>
);
const ModuleCard: React.FC<{ mod: any, className?: string }> = ({ mod, className }) => (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className={`bg-white rounded-2xl p-5 md:p-6 shadow-xl shadow-slate-200/50 border border-slate-100 flex flex-col ${className}`}
>
<div className="flex items-center gap-3 mb-5">
<div className={`w-10 h-10 shrink-0 rounded-xl flex items-center justify-center text-white font-bold text-lg ${mod.color}`}>
{mod.step}
</div>
<h3 className="text-lg md:text-xl font-bold text-slate-800 leading-tight whitespace-nowrap tracking-tight">{mod.title}</h3>
</div>
<ul className="space-y-3 mb-6 flex-grow">
{mod.items.map((item: string, i: number) => (
<li key={i} className="text-slate-600 text-sm md:text-base leading-relaxed break-keep">
{item}
</li>
))}
</ul>
<div className={`mt-auto pt-4 border-t border-slate-100 font-bold text-sm md:text-base tracking-tight whitespace-nowrap ${mod.textColor}`}>
{mod.highlight}
</div>
</motion.div>
);
export default function Modules() {
return (
<section id="modules" className="py-24 md:py-32 bg-white px-6 overflow-hidden relative">
{/* Animated Background Blobs */}
<div className="absolute top-[-10%] left-[-10%] w-[50vw] h-[50vw] min-w-[600px] min-h-[600px] rounded-full bg-[#fff3eb] opacity-80 blur-[120px] animate-blob-large pointer-events-none"></div>
<div className="absolute top-[20%] right-[-10%] w-[40vw] h-[40vw] min-w-[500px] min-h-[500px] rounded-full bg-[#e4cfff] opacity-40 blur-[120px] animate-blob-large animation-delay-7000 pointer-events-none"></div>
<div className="absolute bottom-[-10%] left-[20%] w-[60vw] h-[60vw] min-w-[700px] min-h-[700px] rounded-full bg-[#f5f9ff] opacity-80 blur-[120px] animate-blob-large animation-delay-14000 pointer-events-none"></div>
<div className="max-w-7xl mx-auto relative z-10">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="text-center mb-16 md:mb-24"
>
<h2 className="text-4xl md:text-5xl font-serif font-bold text-primary-900 mb-6">
Core Modules
</h2>
<p className="text-lg md:text-xl text-slate-600 max-w-2xl mx-auto">
</p>
</motion.div>
{/* Desktop Layout — Pentagon around Flywheel */}
<div className="hidden lg:block relative max-w-[1100px] mx-auto mt-10" style={{ height: '920px' }}>
{/* Center flywheel */}
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<Flywheel />
</div>
{/* Pentagon: 5 cards around center, all same width */}
{/* 1. Top center */}
<div className="absolute left-1/2 -translate-x-1/2" style={{ top: '0px' }}>
<ModuleCard mod={modules[0]} className="w-[260px]" />
</div>
{/* 2. Upper right */}
<div className="absolute" style={{ right: '40px', top: '130px' }}>
<ModuleCard mod={modules[1]} className="w-[260px]" />
</div>
{/* 3. Lower right */}
<div className="absolute" style={{ right: '100px', bottom: '30px' }}>
<ModuleCard mod={modules[2]} className="w-[260px]" />
</div>
{/* 4. Lower left */}
<div className="absolute" style={{ left: '100px', bottom: '30px' }}>
<ModuleCard mod={modules[3]} className="w-[260px]" />
</div>
{/* 5. Upper left */}
<div className="absolute" style={{ left: '40px', top: '130px' }}>
<ModuleCard mod={modules[4]} className="w-[260px]" />
</div>
</div>
{/* Mobile/Tablet Layout (Flex Column) */}
<div className="lg:hidden flex flex-col gap-8 items-center">
<Flywheel />
<div className="w-full grid md:grid-cols-2 gap-6 mt-8">
{modules.map((mod, idx) => (
<ModuleCard key={idx} mod={mod} className="w-full" />
))}
</div>
</div>
</div>
</section>
);
}

24
src/components/Navbar.tsx Normal file
View File

@ -0,0 +1,24 @@
import React from 'react';
import { motion } from 'motion/react';
export default function Navbar() {
return (
<nav className="fixed top-0 left-0 right-0 z-50 bg-white/70 backdrop-blur-lg border-b border-white/20">
<div className="max-w-7xl mx-auto px-6 h-20 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-serif text-3xl font-black tracking-[0.05em] bg-gradient-to-r from-[#4F1DA1] to-[#021341] bg-clip-text text-transparent">INFINITH</span>
</div>
<div className="hidden md:flex items-center gap-8 text-sm font-medium text-slate-600">
<a href="#solution" className="hover:text-primary-900 transition-colors">Solution</a>
<a href="#modules" className="hover:text-primary-900 transition-colors">Modules</a>
<a href="#use-cases" className="hover:text-primary-900 transition-colors">Use Cases</a>
</div>
<div className="flex items-center gap-4">
<button className="px-6 py-3 text-sm font-medium text-white rounded-full transition-all shadow-sm hover:shadow-md bg-gradient-to-r from-[#4F1DA1] to-[#021341] hover:opacity-90">
Login
</button>
</div>
</div>
</nav>
);
}

View File

@ -0,0 +1,66 @@
import { useLocation, useNavigate } from 'react-router';
import { ChevronLeft, ChevronRight } from 'lucide-react';
const PAGE_FLOW = [
{ path: '/', label: '랜딩' },
{ path: '/report/view-clinic', label: '마케팅 분석' },
{ path: '/plan/view-clinic', label: '콘텐츠 기획' },
{ path: '/studio/view-clinic', label: '콘텐츠 제작' },
{ path: '/channels', label: '채널 연결' },
{ path: '/distribute', label: '콘텐츠 배포' },
{ path: '/performance', label: '성과 관리' },
];
export default function PageNavigator() {
const location = useLocation();
const navigate = useNavigate();
const currentIndex = PAGE_FLOW.findIndex((p) => p.path === location.pathname);
if (currentIndex === -1) return null;
const prev = currentIndex > 0 ? PAGE_FLOW[currentIndex - 1] : null;
const next = currentIndex < PAGE_FLOW.length - 1 ? PAGE_FLOW[currentIndex + 1] : null;
return (
<div
data-no-print
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-1 bg-white/90 backdrop-blur-xl border border-slate-200 rounded-full px-2 py-2 shadow-[0_4px_20px_rgba(0,0,0,0.08)]"
>
{/* Back */}
<button
onClick={() => prev && navigate(prev.path)}
disabled={!prev}
className="flex items-center gap-2 px-3 py-2 rounded-full text-sm font-medium transition-all disabled:opacity-30 disabled:cursor-not-allowed hover:bg-slate-50 text-slate-600"
>
<ChevronLeft size={16} />
{prev && <span className="hidden sm:inline">{prev.label}</span>}
</button>
{/* Page Indicators */}
<div className="flex items-center gap-2 px-2">
{PAGE_FLOW.map((page, i) => (
<button
key={page.path}
onClick={() => navigate(page.path)}
title={page.label}
className={`rounded-full transition-all ${
i === currentIndex
? 'w-6 h-2 bg-[#0A1128]'
: 'w-2 h-2 bg-slate-300 hover:bg-slate-400'
}`}
/>
))}
</div>
{/* Next */}
<button
onClick={() => next && navigate(next.path)}
disabled={!next}
className="flex items-center gap-2 px-3 py-2 rounded-full text-sm font-medium transition-all disabled:opacity-30 disabled:cursor-not-allowed hover:bg-slate-50 text-slate-600"
>
{next && <span className="hidden sm:inline">{next.label}</span>}
<ChevronRight size={16} />
</button>
</div>
);
}

View File

@ -0,0 +1,60 @@
import { motion } from 'motion/react';
const problems = [
{
title: "콘텐츠 생산의 한계",
desc: "블로그, SEO, 유튜브, 숏폼 등 지속적 생산이 필요하지만 인력과 비용, 시간 부족으로 제한됩니다."
},
{
title: "영상 콘텐츠 제작 비용",
desc: "영상은 중요하지만 촬영, 편집, 기획 비용이 높습니다. 특히 숏폼 콘텐츠는 지속적인 제작이 어렵습니다.",
highlight: true
},
{
title: "데이터 기반의 마케팅 부족",
desc: "어떤 콘텐츠가 효과적인지, 어떤 키워드가 유입을 만드는지, 어떤 채널이 성과가 좋은지 알기 어렵고, 각 플랫폼들의 데이터들을 한눈에 파악할 수 없습니다."
}
];
export default function Problems() {
return (
<section className="py-24 bg-slate-50 px-6 relative overflow-hidden">
<div className="max-w-6xl mx-auto">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="text-center mb-16"
>
<h2 className="text-3xl md:text-5xl font-serif font-bold text-primary-900 mb-4">
Premium Medical Marketing is Hard
</h2>
<p className="text-lg text-slate-600 max-w-2xl mx-auto">
3
</p>
</motion.div>
<div className="grid md:grid-cols-3 gap-6">
{problems.map((problem, idx) => (
<motion.div
key={idx}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: idx * 0.1 }}
className="bg-white rounded-2xl p-8 md:p-10 border border-slate-100 shadow-sm hover:shadow-md transition-shadow flex flex-col justify-center"
>
<h3 className={`text-2xl font-bold mb-4 ${problem.highlight ? 'text-transparent bg-clip-text bg-gradient-to-r from-indigo-600 to-purple-600' : 'text-primary-900'}`}>
{problem.title}
</h3>
<p className="text-slate-600 text-base leading-relaxed">
{problem.desc}
</p>
</motion.div>
))}
</div>
</div>
</section>
);
}

146
src/components/Solution.tsx Normal file
View File

@ -0,0 +1,146 @@
import { motion } from 'motion/react';
import { Sparkles, ArrowRight } from 'lucide-react';
export default function Solution() {
return (
<section id="solution" className="py-32 bg-primary-900 text-white px-6 relative overflow-hidden">
{/* Background Glow */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] bg-accent/20 rounded-full blur-[120px] pointer-events-none"></div>
<div className="max-w-4xl mx-auto text-center relative z-10">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/10 backdrop-blur-sm border border-white/20 mb-8"
>
<Sparkles className="w-4 h-4 text-purple-300" />
<span className="text-sm font-medium text-purple-100">AI Marketing Engine</span>
</motion.div>
<motion.h2
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.1 }}
className="text-4xl md:text-6xl font-serif font-bold mb-6 leading-tight text-transparent bg-clip-text bg-gradient-to-r from-[#fff3eb] via-[#e4cfff] to-[#f5f9ff]"
>
Infinite Marketing Engine
</motion.h2>
<motion.p
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.2 }}
className="text-xl md:text-2xl text-slate-300 mb-12 max-w-3xl mx-auto leading-relaxed font-light"
>
<span className="font-medium text-white">Infinite Marketing for Premium Medical Business & Marketing Agency</span>
<br className="hidden md:block" />
Infinite Marketing Premium Medical Business Marketing Agency AI Marketing Automation Platform.
INFINITH , , , , Self-Improving Marketing Engine .
</motion.p>
{/* Circular Loop Diagram */}
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.8, delay: 0.3 }}
className="relative w-full max-w-[320px] md:max-w-[500px] aspect-square mx-auto mt-16 mb-24 md:mb-32"
>
{/* Static Inner Ring */}
<div className="absolute top-[20%] left-[20%] right-[20%] bottom-[20%] rounded-full border border-white/5 shadow-[0_0_40px_rgba(255,255,255,0.02)_inset]"></div>
{/* Animated Glowing Ring */}
<svg className="absolute inset-0 w-full h-full animate-[spin_20s_linear_infinite]" viewBox="0 0 100 100">
<defs>
<linearGradient id="ringGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#a78bfa" stopOpacity="0.1" />
<stop offset="50%" stopColor="#c084fc" stopOpacity="0.5" />
<stop offset="100%" stopColor="#e879f9" stopOpacity="0.1" />
</linearGradient>
</defs>
<circle cx="50" cy="50" r="30" fill="none" stroke="url(#ringGrad)" strokeWidth="0.5" strokeDasharray="2 2" />
</svg>
{/* Center Orb */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-20">
<div className="w-40 h-40 md:w-56 md:h-56 rounded-full bg-primary-900/90 backdrop-blur-xl border border-white/5 flex flex-col items-center justify-center shadow-[0_0_60px_rgba(167,139,250,0.1)]">
<span className="text-4xl md:text-6xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-[#fff3eb] via-[#e4cfff] to-[#f5f9ff] mb-2">AGDP</span>
<h3 className="text-xl md:text-3xl font-serif font-bold text-white text-center leading-tight">Infinite<br/>Marketing</h3>
</div>
</div>
{/* Node A: Analysis (Left) */}
<div className="absolute top-1/2 left-0 -translate-x-1/2 -translate-y-1/2 z-30 flex flex-col items-center gap-3 md:gap-4">
<div className="w-16 h-16 md:w-20 md:h-20 rounded-full border border-purple-500/30 bg-primary-900/80 backdrop-blur-sm flex items-center justify-center shadow-[0_0_20px_rgba(168,85,247,0.15)]">
<span className="text-3xl md:text-4xl font-bold text-purple-300">A</span>
</div>
<div className="text-center">
<span className="block text-lg md:text-2xl font-medium text-purple-200 leading-tight">Analysis</span>
</div>
</div>
{/* Node G: Generation (Top) */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 z-30 flex flex-col items-center gap-3 md:gap-4">
<div className="w-16 h-16 md:w-20 md:h-20 rounded-full border border-purple-500/30 bg-primary-900/80 backdrop-blur-sm flex items-center justify-center shadow-[0_0_20px_rgba(168,85,247,0.15)]">
<span className="text-3xl md:text-4xl font-bold text-purple-300">G</span>
</div>
<div className="text-center">
<span className="block text-lg md:text-2xl font-medium text-purple-200 leading-tight">Generation</span>
</div>
</div>
{/* Node D: Distribution (Right) */}
<div className="absolute top-1/2 right-0 translate-x-1/2 -translate-y-1/2 z-30 flex flex-col items-center gap-3 md:gap-4">
<div className="w-16 h-16 md:w-20 md:h-20 rounded-full border border-purple-500/30 bg-primary-900/80 backdrop-blur-sm flex items-center justify-center shadow-[0_0_20px_rgba(168,85,247,0.15)]">
<span className="text-3xl md:text-4xl font-bold text-purple-300">D</span>
</div>
<div className="text-center">
<span className="block text-lg md:text-2xl font-medium text-purple-200 leading-tight">Distribution</span>
</div>
</div>
{/* Node P: Performance (Bottom) */}
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2 z-30 flex flex-col items-center gap-3 md:gap-4">
<div className="w-16 h-16 md:w-20 md:h-20 rounded-full border border-purple-500/30 bg-primary-900/80 backdrop-blur-sm flex items-center justify-center shadow-[0_0_20px_rgba(168,85,247,0.15)]">
<span className="text-3xl md:text-4xl font-bold text-purple-300">P</span>
</div>
<div className="text-center">
<span className="block text-lg md:text-2xl font-medium text-purple-200 leading-tight">Performance</span>
</div>
</div>
{/* Reward Signal Curved Text (P to A) */}
<svg className="absolute inset-0 w-full h-full pointer-events-none z-20" viewBox="0 0 100 100">
<defs>
<path id="rewardPath" d="M 10.6 56.9 A 40 40 0 0 0 43.1 89.4" fill="none" />
</defs>
<text fontSize="3.5" className="font-medium uppercase tracking-widest" fill="#d8b4fe" opacity="0.8">
<textPath href="#rewardPath" startOffset="50%" textAnchor="middle">
Reward SIGNAL
</textPath>
</text>
</svg>
</motion.div>
{/* AGDP Cycle Description */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.5 }}
className="max-w-3xl mx-auto mt-12 text-center"
>
<div className="inline-block bg-white/5 border border-white/10 rounded-2xl px-6 py-4 backdrop-blur-sm">
<p className="text-sm md:text-base text-slate-300">
<span className="font-bold text-purple-300">AGDP Cycle:</span> (Analysis) (Generation) (Distribution) (Performance) (CTR) <span className="text-white font-medium"> </span>.
</p>
</div>
</motion.div>
</div>
</section>
);
}

View File

@ -0,0 +1,66 @@
import { motion } from 'motion/react';
export default function TargetAudience() {
return (
<section className="py-24 bg-white px-6">
<div className="max-w-7xl mx-auto">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="text-center mb-16"
>
<h2 className="text-3xl md:text-5xl font-serif font-bold text-primary-900 mb-4">
Who is Infinite Marketing for
</h2>
<p className="text-lg text-slate-600 max-w-2xl mx-auto">
</p>
</motion.div>
<div className="grid md:grid-cols-2 gap-8">
<motion.div
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.1 }}
className="glass-card p-10 md:p-12 bg-gradient-to-br from-slate-50 to-white border border-slate-100 rounded-3xl"
>
<h3 className="text-4xl md:text-5xl font-serif font-bold text-primary-900 mb-6 leading-tight">Premium Medical Business</h3>
<p className="text-slate-600 mb-10 leading-relaxed text-lg">
LTV
</p>
<div className="flex flex-wrap gap-3">
{['피부과', '성형외과', '치과', '안과', '헬스케어 클리닉', '피트니스'].map((item, i) => (
<div key={i} className="bg-white px-5 py-3 rounded-2xl shadow-sm border border-slate-100 text-slate-700 font-medium hover:shadow-md transition-shadow">
{item}
</div>
))}
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, x: 20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.2 }}
className="glass-card p-10 md:p-12 bg-gradient-to-br from-purple-50 to-white border border-purple-100/50 rounded-3xl"
>
<h3 className="text-4xl md:text-5xl font-serif font-bold text-primary-900 mb-6 leading-tight">Medical Marketing Agency</h3>
<p className="text-slate-600 mb-10 leading-relaxed text-lg">
</p>
<div className="flex flex-wrap gap-3">
{['병원 마케팅 대행사', '콘텐츠 마케팅 Agency', '영상 마케팅 Agency', '광고 운영 대행사'].map((item, i) => (
<div key={i} className="bg-white px-5 py-3 rounded-2xl shadow-sm border border-purple-50 text-slate-700 font-medium hover:shadow-md transition-shadow">
{item}
</div>
))}
</div>
</motion.div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,71 @@
import { motion } from 'motion/react';
import { CheckCircle2 } from 'lucide-react';
export default function UseCases() {
return (
<section id="use-cases" className="py-24 bg-white px-6">
<div className="max-w-7xl mx-auto">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="text-center mb-16"
>
<h2 className="text-3xl md:text-5xl font-serif font-bold text-primary-900 mb-4">
Use Cases
</h2>
<p className="text-lg text-slate-600 max-w-2xl mx-auto font-medium">
Infinite Marketing !
</p>
</motion.div>
<div className="grid md:grid-cols-2 gap-12">
<motion.div
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.1 }}
className="glass-card p-10 md:p-12 bg-gradient-to-br from-blue-50/50 to-white border border-blue-100/30"
>
<h3 className="text-2xl md:text-3xl font-serif font-bold text-primary-900 mb-8">Premium Medical Business</h3>
<ul className="space-y-6">
{[
'SEO 콘텐츠 자동 생산으로 검색 상위 노출 달성',
'비용 부담 없이 고품질 영상 콘텐츠 대량 확대',
'자연 검색 유입 증가로 인한 환자 전환율 상승'
].map((item, i) => (
<li key={i} className="flex items-start gap-4 text-slate-700 font-medium group">
<CheckCircle2 className="w-6 h-6 text-[#7A84D4] shrink-0 mt-1" />
<span className="leading-relaxed text-lg group-hover:text-primary-900 transition-colors">{item}</span>
</li>
))}
</ul>
</motion.div>
<motion.div
initial={{ opacity: 0, x: 20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.2 }}
className="glass-card p-10 md:p-12 bg-gradient-to-br from-purple-50/50 to-white border border-purple-100/30"
>
<h3 className="text-2xl md:text-3xl font-serif font-bold text-primary-900 mb-8">Marketing Agency</h3>
<ul className="space-y-6">
{[
'AI 기반 콘텐츠 제작 자동화로 생산성 극대화',
'블로그 텍스트 기반 영상 제작 자동화로 리소스 절감',
'다수 클라이언트 계정의 통합 운영 및 효율화'
].map((item, i) => (
<li key={i} className="flex items-start gap-4 text-slate-700 font-medium group">
<CheckCircle2 className="w-6 h-6 text-purple-500 shrink-0 mt-1" />
<span className="leading-relaxed text-lg group-hover:text-primary-900 transition-colors">{item}</span>
</li>
))}
</ul>
</motion.div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,135 @@
/**
* Filled/Shape-style icons for Channel Strategy & Content Calendar.
* Soft pastel colors, no outlines all shapes use fill only.
*/
interface IconProps {
size?: number;
className?: string;
}
export function YoutubeFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<rect x="2" y="4" width="20" height="16" rx="4" fill="currentColor" opacity="0.25" />
<path d="M10 8.5v7l6-3.5-6-3.5z" fill="currentColor" />
</svg>
);
}
export function InstagramFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<rect x="2" y="2" width="20" height="20" rx="6" fill="currentColor" opacity="0.25" />
<circle cx="12" cy="12" r="4.5" fill="currentColor" />
<circle cx="17.5" cy="6.5" r="1.5" fill="currentColor" />
</svg>
);
}
export function FacebookFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<rect x="2" y="2" width="20" height="20" rx="6" fill="currentColor" opacity="0.25" />
<path d="M15.5 3.5H13.5C11.29 3.5 9.5 5.29 9.5 7.5V9.5H7.5V12.5H9.5V20.5H12.5V12.5H14.5L15.5 9.5H12.5V7.5C12.5 7.22 12.72 7 13 7H15.5V3.5Z" fill="currentColor" />
</svg>
);
}
export function GlobeFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.2" />
<ellipse cx="12" cy="12" rx="4" ry="10" fill="currentColor" opacity="0.35" />
<line x1="2" y1="12" x2="22" y2="12" stroke="currentColor" strokeWidth="1.5" opacity="0.5" />
<line x1="12" y1="2" x2="12" y2="22" stroke="currentColor" strokeWidth="1.5" opacity="0.3" />
</svg>
);
}
export function VideoFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<rect x="1" y="5" width="15" height="14" rx="3" fill="currentColor" opacity="0.25" />
<path d="M16 9.5L22 6.5V17.5L16 14.5V9.5Z" fill="currentColor" />
<rect x="1" y="5" width="15" height="14" rx="3" fill="currentColor" opacity="0.5" />
</svg>
);
}
export function MessageFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<path d="M4 4H20C21.1 4 22 4.9 22 6V16C22 17.1 21.1 18 20 18H6L2 22V6C2 4.9 2.9 4 4 4Z" fill="currentColor" opacity="0.3" />
<path d="M4 4H20C21.1 4 22 4.9 22 6V16C22 17.1 21.1 18 20 18H6L2 22V6C2 4.9 2.9 4 4 4Z" fill="currentColor" opacity="0.4" />
</svg>
);
}
export function CalendarFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<rect x="3" y="4" width="18" height="18" rx="3" fill="currentColor" opacity="0.25" />
<rect x="3" y="4" width="18" height="6" rx="3" fill="currentColor" opacity="0.5" />
<circle cx="8" cy="15" r="1.2" fill="currentColor" />
<circle cx="12" cy="15" r="1.2" fill="currentColor" />
<circle cx="16" cy="15" r="1.2" fill="currentColor" />
<line x1="8" y1="2" x2="8" y2="5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<line x1="16" y1="2" x2="16" y2="5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
export function FileTextFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<path d="M6 2H14L20 8V20C20 21.1 19.1 22 18 22H6C4.9 22 4 21.1 4 20V4C4 2.9 4.9 2 6 2Z" fill="currentColor" opacity="0.25" />
<path d="M14 2L20 8H16C14.9 8 14 7.1 14 6V2Z" fill="currentColor" opacity="0.5" />
<rect x="8" y="12" width="8" height="1.5" rx="0.75" fill="currentColor" />
<rect x="8" y="16" width="5" height="1.5" rx="0.75" fill="currentColor" />
</svg>
);
}
export function ShareFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<circle cx="18" cy="5" r="3.5" fill="currentColor" opacity="0.4" />
<circle cx="6" cy="12" r="3.5" fill="currentColor" opacity="0.4" />
<circle cx="18" cy="19" r="3.5" fill="currentColor" opacity="0.4" />
<line x1="9" y1="10.5" x2="15" y2="6.5" stroke="currentColor" strokeWidth="2" opacity="0.3" />
<line x1="9" y1="13.5" x2="15" y2="17.5" stroke="currentColor" strokeWidth="2" opacity="0.3" />
</svg>
);
}
export function MegaphoneFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<path d="M19 3L8 8H4C2.9 8 2 8.9 2 10V14C2 15.1 2.9 16 4 16H5L7 21H10L8 16L19 21V3Z" fill="currentColor" opacity="0.35" />
<path d="M21 10C22.1 10 23 10.9 23 12C23 13.1 22.1 14 21 14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" opacity="0.5" />
</svg>
);
}
export function TiktokFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<rect x="2" y="2" width="20" height="20" rx="6" fill="currentColor" opacity="0.25" />
<path d="M16.5 4.5V12.5C16.5 15.26 14.26 17.5 11.5 17.5C8.74 17.5 6.5 15.26 6.5 12.5C6.5 9.74 8.74 7.5 11.5 7.5V10C10.12 10 9 11.12 9 12.5C9 13.88 10.12 15 11.5 15C12.88 15 14 13.88 14 12.5V4.5H16.5Z" fill="currentColor" />
</svg>
);
}
export function MusicFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<circle cx="7" cy="17" r="3" fill="currentColor" opacity="0.25" />
<circle cx="17" cy="15" r="3" fill="currentColor" opacity="0.25" />
<circle cx="7" cy="17" r="2" fill="currentColor" />
<circle cx="17" cy="15" r="2" fill="currentColor" />
<path d="M9 17V7L19 5V15" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none" />
<rect x="9" y="5" width="10" height="2" rx="1" fill="currentColor" opacity="0.4" />
</svg>
);
}

View File

@ -0,0 +1,192 @@
import { useState } from 'react';
import { motion } from 'motion/react';
import { Youtube } from 'lucide-react';
import { SectionWrapper } from '../report/ui/SectionWrapper';
import type { AssetCollectionData, AssetSource, AssetStatus, AssetType } from '../../types/plan';
interface AssetCollectionProps {
data: AssetCollectionData;
}
const filterTabs = [
{ key: 'all', label: '전체' },
{ key: 'homepage', label: '홈페이지' },
{ key: 'naver_place', label: '네이버' },
{ key: 'blog', label: '블로그' },
{ key: 'social', label: '소셜미디어' },
{ key: 'youtube', label: 'YouTube' },
] as const;
type FilterKey = (typeof filterTabs)[number]['key'];
const sourceBadgeColors: Record<AssetSource, string> = {
homepage: 'bg-slate-100 text-slate-700',
naver_place: 'bg-[#F3F0FF] text-[#4A3A7C]',
blog: 'bg-[#EFF0FF] text-[#3A3F7C]',
social: 'bg-pink-100 text-pink-700',
youtube: 'bg-[#FFF0F0] text-[#7C3A4B]',
};
const typeBadgeColors: Record<AssetType, string> = {
photo: 'bg-indigo-50 text-indigo-700',
video: 'bg-purple-50 text-purple-700',
text: 'bg-[#FFF6ED] text-[#7C5C3A]',
};
const statusConfig: Record<AssetStatus, { className: string; label: string }> = {
collected: { className: 'bg-[#F3F0FF] text-[#4A3A7C]', label: '수집완료' },
pending: { className: 'bg-[#FFF6ED] text-[#7C5C3A]', label: '수집 대기' },
needs_creation: { className: 'bg-[#FFF0F0] text-[#7C3A4B]', label: '제작 필요' },
};
function formatViews(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);
}
export default function AssetCollection({ data }: AssetCollectionProps) {
const [activeFilter, setActiveFilter] = useState<FilterKey>('all');
const filteredAssets =
activeFilter === 'all'
? data.assets
: data.assets.filter((a) => a.source === activeFilter);
return (
<SectionWrapper
id="asset-collection"
title="Asset Collection"
subtitle="에셋 수집 & 리퍼포징 소스"
>
{/* Source Filter Tabs */}
<div className="flex flex-wrap gap-2 mb-8">
{filterTabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveFilter(tab.key)}
className={`rounded-full px-4 py-2 text-sm font-medium transition-all ${
activeFilter === tab.key
? 'bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white shadow-lg'
: 'bg-white border border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Asset Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-12">
{filteredAssets.map((asset, i) => {
const statusInfo = statusConfig[asset.status];
return (
<motion.div
key={asset.id}
className="rounded-2xl border border-slate-100 bg-white shadow-sm p-5"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: i * 0.05 }}
>
{/* Top badges row */}
<div className="flex items-center gap-2 mb-3 flex-wrap">
<span
className={`rounded-full px-3 py-1 text-xs font-medium ${sourceBadgeColors[asset.source]}`}
>
{asset.sourceLabel}
</span>
<span
className={`rounded-full px-3 py-1 text-xs font-medium ${typeBadgeColors[asset.type]}`}
>
{asset.type}
</span>
<span
className={`rounded-full px-3 py-1 text-xs font-medium ml-auto ${statusInfo.className}`}
>
{statusInfo.label}
</span>
</div>
{/* Title & Description */}
<h4 className="font-semibold text-[#0A1128] mb-1">{asset.title}</h4>
<p className="text-sm text-slate-600 mb-3">{asset.description}</p>
{/* Repurposing suggestions */}
{asset.repurposingSuggestions.length > 0 && (
<div>
<p className="text-xs font-semibold text-slate-500 uppercase mb-2">
Repurposing &rarr;
</p>
<div className="flex flex-wrap gap-2">
{asset.repurposingSuggestions.map((suggestion, j) => (
<span
key={j}
className="rounded-full bg-purple-50 text-purple-700 text-xs px-2 py-1"
>
{suggestion}
</span>
))}
</div>
</div>
)}
</motion.div>
);
})}
</div>
{/* YouTube Repurpose Section */}
{data.youtubeRepurpose.length > 0 && (
<div>
<h3 className="font-serif text-2xl font-bold text-[#0A1128] mb-4">
YouTube Top Videos for Repurposing
</h3>
<div className="flex overflow-x-auto gap-4 pb-4 scrollbar-hide" style={{ scrollbarWidth: 'none' }}>
{data.youtubeRepurpose.map((video, i) => (
<motion.div
key={video.title}
className="min-w-[280px] rounded-2xl border border-slate-100 bg-white shadow-sm p-5 shrink-0"
initial={{ opacity: 0, x: 20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: i * 0.1 }}
>
<div className="flex items-start gap-2 mb-3">
<Youtube size={18} className="text-[#D4889A] shrink-0 mt-1" />
<h4 className="font-semibold text-sm text-[#0A1128]">{video.title}</h4>
</div>
<div className="flex items-center gap-2 mb-3">
<span className="rounded-full bg-slate-100 text-slate-700 px-3 py-1 text-xs font-medium">
{formatViews(video.views)} views
</span>
<span
className={`rounded-full px-3 py-1 text-xs font-medium ${
video.type === 'Short'
? 'bg-purple-50 text-purple-700'
: 'bg-[#EFF0FF] text-[#3A3F7C]'
}`}
>
{video.type}
</span>
</div>
<p className="text-xs font-semibold text-slate-500 uppercase mb-2">
Repurpose As:
</p>
<div className="flex flex-wrap gap-2">
{video.repurposeAs.map((suggestion, j) => (
<span
key={j}
className="rounded-full bg-purple-50 text-purple-700 text-xs px-2 py-1"
>
{suggestion}
</span>
))}
</div>
</motion.div>
))}
</div>
</div>
)}
</SectionWrapper>
);
}

View File

@ -0,0 +1,408 @@
import { useState, type ComponentType } from 'react';
import { motion } from 'motion/react';
import {
Youtube,
Instagram,
Facebook,
Globe,
Video,
MessageSquare,
CheckCircle2,
XCircle,
AlertCircle,
ArrowRight,
} from 'lucide-react';
import { SectionWrapper } from '../report/ui/SectionWrapper';
import type { BrandGuide } from '../../types/plan';
import type { BrandInconsistency } from '../../types/report';
interface BrandingGuideProps {
data: BrandGuide;
}
const tabItems = [
{ key: 'visual', label: 'Visual Identity', labelKr: '비주얼 아이덴티티' },
{ key: 'tone', label: 'Tone & Voice', labelKr: '톤 & 보이스' },
{ key: 'channels', label: 'Channel Rules', labelKr: '채널별 규칙' },
{ key: 'consistency', label: 'Brand Consistency', labelKr: '브랜드 일관성' },
] as const;
type TabKey = (typeof tabItems)[number]['key'];
const channelIconMap: Record<string, ComponentType<{ size?: number; className?: string }>> = {
youtube: Youtube,
instagram: Instagram,
facebook: Facebook,
globe: Globe,
video: Video,
messagesquare: MessageSquare,
};
function getChannelIcon(icon: string) {
return channelIconMap[icon.toLowerCase()] ?? Globe;
}
const statusColor: Record<string, string> = {
correct: 'bg-[#F3F0FF] text-[#4A3A7C] border-[#D5CDF5]',
incorrect: 'bg-[#FFF0F0] text-[#7C3A4B] border-[#F5D5DC]',
missing: 'bg-slate-100 text-slate-500 border-slate-200',
};
const statusLabel: Record<string, string> = {
correct: 'Correct',
incorrect: 'Incorrect',
missing: 'Missing',
};
/* ─── Visual Identity Tab ─── */
function VisualIdentityTab({ data }: { data: BrandGuide }) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="space-y-8"
>
{/* Color Palette */}
<div>
<h3 className="font-serif font-bold text-2xl text-[#0A1128] mb-4">Color Palette</h3>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
{data.colors.map((swatch) => (
<div
key={swatch.hex}
className="rounded-2xl border border-slate-100 overflow-hidden"
>
<div className="h-20" style={{ backgroundColor: swatch.hex }} />
<div className="p-3">
<p className="font-mono text-sm text-slate-700">{swatch.hex}</p>
<p className="font-medium text-sm text-[#0A1128]">{swatch.name}</p>
<p className="text-xs text-slate-500">{swatch.usage}</p>
</div>
</div>
))}
</div>
</div>
{/* Typography */}
<div>
<h3 className="font-serif font-bold text-2xl text-[#0A1128] mb-4">Typography</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{data.fonts.map((spec) => (
<div
key={`${spec.family}-${spec.weight}`}
className="rounded-2xl border border-slate-100 p-5"
>
<p className="text-sm text-slate-500 uppercase tracking-wide mb-2">
{spec.family}
</p>
<p
className={`mb-3 text-[#0A1128] ${
spec.weight.toLowerCase().includes('bold')
? 'text-2xl font-bold'
: 'text-lg'
}`}
style={{ fontFamily: spec.family }}
>
{spec.sampleText}
</p>
<p className="text-xs text-slate-500">
<span className="font-medium text-slate-700">{spec.weight}</span> &middot;{' '}
{spec.usage}
</p>
</div>
))}
</div>
</div>
{/* Logo Rules */}
<div>
<h3 className="font-serif font-bold text-2xl text-[#0A1128] mb-4">Logo Rules</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{data.logoRules.map((rule) => (
<div
key={rule.rule}
className={`rounded-2xl p-5 ${
rule.correct
? 'border-2 border-[#D5CDF5] bg-[#F3F0FF]/30'
: 'border-2 border-[#F5D5DC] bg-[#FFF0F0]/30'
}`}
>
<div className="flex items-start gap-3">
{rule.correct ? (
<CheckCircle2 size={20} className="text-[#9B8AD4] shrink-0 mt-1" />
) : (
<XCircle size={20} className="text-[#D4889A] shrink-0 mt-1" />
)}
<div>
<p className="font-semibold text-[#0A1128]">{rule.rule}</p>
<p className="text-sm text-slate-600 mt-1">{rule.description}</p>
</div>
</div>
</div>
))}
</div>
</div>
</motion.div>
);
}
/* ─── Tone & Voice Tab ─── */
function ToneVoiceTab({ tone }: { tone: BrandGuide['toneOfVoice'] }) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="space-y-6"
>
{/* Personality */}
<div>
<h3 className="font-serif font-bold text-2xl text-[#0A1128] mb-4">Personality</h3>
<div className="flex flex-wrap gap-2">
{tone.personality.map((trait) => (
<span
key={trait}
className="bg-gradient-to-r from-[#4F1DA1]/10 to-[#021341]/10 text-[#4F1DA1] border border-purple-200 rounded-full px-4 py-2 font-medium text-sm"
>
{trait}
</span>
))}
</div>
</div>
{/* Communication Style */}
<div>
<h3 className="font-serif font-bold text-2xl text-[#0A1128] mb-4">Communication Style</h3>
<div className="rounded-2xl bg-slate-50 p-6">
<p className="text-base leading-relaxed text-slate-700">
{tone.communicationStyle}
</p>
</div>
</div>
{/* DO / DON'T */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h4 className="font-semibold text-[#4A3A7C] mb-3 flex items-center gap-2">
<CheckCircle2 size={16} /> DO
</h4>
<div className="space-y-3">
{tone.doExamples.map((example, i) => (
<div
key={i}
className="border-l-4 border-[#9B8AD4] bg-[#F3F0FF]/30 p-4 rounded-r-lg"
>
<p className="text-sm text-slate-700">{example}</p>
</div>
))}
</div>
</div>
<div>
<h4 className="font-semibold text-[#7C3A4B] mb-3 flex items-center gap-2">
<XCircle size={16} /> DON&apos;T
</h4>
<div className="space-y-3">
{tone.dontExamples.map((example, i) => (
<div
key={i}
className="border-l-4 border-[#D4889A] bg-[#FFF0F0]/30 p-4 rounded-r-lg"
>
<p className="text-sm text-slate-700">{example}</p>
</div>
))}
</div>
</div>
</div>
</motion.div>
);
}
/* ─── Channel Rules Tab ─── */
function ChannelRulesTab({ channels }: { channels: BrandGuide['channelBranding'] }) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{channels.map((ch) => {
const Icon = getChannelIcon(ch.icon);
return (
<div key={ch.channel} className="rounded-2xl border border-slate-100 p-5">
{/* Header */}
<div className="flex items-center gap-3 mb-4">
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-[#6C5CE7]/10 to-[#4F1DA1]/10 flex items-center justify-center">
<Icon size={18} className="text-[#6C5CE7]" />
</div>
<p className="font-bold text-[#0A1128]">{ch.channel}</p>
<span
className={`ml-auto text-xs font-medium px-3 py-1 rounded-full border ${
statusColor[ch.currentStatus]
}`}
>
{statusLabel[ch.currentStatus]}
</span>
</div>
{/* Specs */}
<div className="space-y-3 text-sm">
<div>
<p className="text-slate-500 text-xs uppercase tracking-wide mb-1">Profile Photo</p>
<p className="text-slate-700 font-medium">{ch.profilePhoto}</p>
</div>
<div>
<p className="text-slate-500 text-xs uppercase tracking-wide mb-1">Banner Spec</p>
<p className="text-slate-700 font-medium">{ch.bannerSpec}</p>
</div>
<div>
<p className="text-slate-500 text-xs uppercase tracking-wide mb-1">Bio Template</p>
<div className="bg-slate-50 rounded-xl p-3">
<p className="font-mono text-xs text-slate-600 whitespace-pre-wrap">
{ch.bioTemplate}
</p>
</div>
</div>
</div>
</div>
);
})}
</div>
</motion.div>
);
}
/* ─── Brand Consistency Tab (accordion) ─── */
function BrandConsistencyTab({ inconsistencies }: { inconsistencies: BrandInconsistency[] }) {
const [expanded, setExpanded] = useState<number | null>(0);
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div className="space-y-3">
{inconsistencies.map((item, i) => (
<div
key={item.field}
className="rounded-2xl border border-slate-100 bg-white shadow-sm overflow-hidden"
>
{/* Header */}
<button
onClick={() => setExpanded(expanded === i ? null : i)}
className="w-full flex items-center justify-between p-5 text-left hover:bg-slate-50/50 transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-[#0A1128] flex items-center justify-center text-white text-xs font-bold">
{item.values.filter((v) => !v.isCorrect).length}
</div>
<div>
<p className="font-semibold text-[#0A1128]">{item.field}</p>
<p className="text-xs text-slate-500">
{item.values.filter((v) => !v.isCorrect).length}
</p>
</div>
</div>
<ArrowRight
size={16}
className={`text-slate-400 transition-transform ${
expanded === i ? 'rotate-90' : ''
}`}
/>
</button>
{/* Expanded */}
{expanded === i && (
<div className="px-5 pb-5 border-t border-slate-100">
<div className="grid gap-2 mt-4 mb-4">
{item.values.map((v) => (
<div
key={v.channel}
className={`flex items-center justify-between py-3 px-3 rounded-lg text-sm ${
v.isCorrect ? 'bg-[#F3F0FF]/60' : 'bg-[#FFF0F0]/60'
}`}
>
<span className="font-medium text-slate-700 min-w-[100px]">
{v.channel}
</span>
<span
className={`flex-1 text-right ${
v.isCorrect ? 'text-[#4A3A7C]' : 'text-[#7C3A4B]'
}`}
>
{v.value}
</span>
<span className="ml-3">
{v.isCorrect ? (
<CheckCircle2 size={15} className="text-[#9B8AD4]" />
) : (
<XCircle size={15} className="text-[#D4889A]" />
)}
</span>
</div>
))}
</div>
{/* Impact */}
<div className="rounded-xl bg-[#FFF6ED] border border-[#F5E0C5] p-4 mb-3">
<p className="text-xs font-semibold text-[#7C5C3A] uppercase tracking-wide mb-1">
<AlertCircle size={12} className="inline mr-1" />
Impact
</p>
<p className="text-sm text-[#7C5C3A]">{item.impact}</p>
</div>
{/* Recommendation */}
<div className="rounded-xl bg-[#F3F0FF] border border-[#D5CDF5] p-4">
<p className="text-xs font-semibold text-[#4A3A7C] uppercase tracking-wide mb-1">
<CheckCircle2 size={12} className="inline mr-1" />
Recommendation
</p>
<p className="text-sm text-[#4A3A7C]">{item.recommendation}</p>
</div>
</div>
)}
</div>
))}
</div>
</motion.div>
);
}
/* ─── Main Component ─── */
export default function BrandingGuide({ data }: BrandingGuideProps) {
const [activeTab, setActiveTab] = useState<TabKey>('visual');
return (
<SectionWrapper
id="branding-guide"
title="Branding Guide"
subtitle="브랜딩 가이드 빌드"
>
{/* Tabs */}
<div className="flex flex-wrap gap-2 mb-8">
{tabItems.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`rounded-full px-4 py-2 text-sm font-medium transition-all ${
activeTab === tab.key
? 'bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white shadow-lg'
: 'bg-white border border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
>
{tab.label}
</button>
))}
</div>
{activeTab === 'visual' && <VisualIdentityTab data={data} />}
{activeTab === 'tone' && <ToneVoiceTab tone={data.toneOfVoice} />}
{activeTab === 'channels' && <ChannelRulesTab channels={data.channelBranding} />}
{activeTab === 'consistency' && (
<BrandConsistencyTab inconsistencies={data.brandInconsistencies} />
)}
</SectionWrapper>
);
}

View File

@ -0,0 +1,123 @@
import { type ComponentType } from 'react';
import { motion } from 'motion/react';
import {
YoutubeFilled,
InstagramFilled,
FacebookFilled,
GlobeFilled,
VideoFilled,
MessageFilled,
CalendarFilled,
TiktokFilled,
} from '../icons/FilledIcons';
import { SectionWrapper } from '../report/ui/SectionWrapper';
import type { ChannelStrategyCard } from '../../types/plan';
interface ChannelStrategyProps {
channels: ChannelStrategyCard[];
}
const channelIconMap: Record<string, ComponentType<{ size?: number; className?: string }>> = {
youtube: YoutubeFilled,
instagram: InstagramFilled,
facebook: FacebookFilled,
globe: GlobeFilled,
video: VideoFilled,
messagesquare: MessageFilled,
tiktok: TiktokFilled,
};
function getChannelIcon(icon: string) {
return channelIconMap[icon.toLowerCase()] ?? GlobeFilled;
}
const priorityStyle: Record<string, string> = {
P0: 'bg-[#FFF0F0] text-[#7C3A4B] border border-[#F5D5DC] shadow-[2px_3px_8px_rgba(212,136,154,0.15)]',
P1: 'bg-[#FFF6ED] text-[#7C5C3A] border border-[#F5E0C5] shadow-[2px_3px_8px_rgba(212,168,114,0.15)]',
P2: 'bg-[#F3F0FF] text-[#4A3A7C] border border-[#D5CDF5] shadow-[2px_3px_8px_rgba(155,138,212,0.15)]',
};
export default function ChannelStrategy({ channels }: ChannelStrategyProps) {
return (
<SectionWrapper
id="channel-strategy"
title="Channel Strategy"
subtitle="채널별 커뮤니케이션 전략"
dark
>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{channels.map((ch, index) => {
const Icon = getChannelIcon(ch.icon);
return (
<motion.div
key={ch.channelId}
className="bg-white rounded-2xl p-6 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)] transition-shadow"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
>
{/* Header */}
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-[#F3F0FF] flex items-center justify-center">
<Icon size={20} className="text-[#9B8AD4]" />
</div>
<h4 className="text-lg font-bold text-[#0A1128] flex-1">{ch.channelName}</h4>
<span
className={`text-xs font-semibold px-3 py-1 rounded-full ${
priorityStyle[ch.priority] ?? 'bg-slate-100 text-slate-600'
}`}
>
{ch.priority}
</span>
</div>
{/* Current → Target */}
<div className="flex items-center gap-2 mb-4 flex-wrap">
<span className="bg-[#FFF0F0] text-[#7C3A4B] rounded-full px-3 py-1 text-xs font-medium border border-[#F5D5DC] shadow-[2px_3px_6px_rgba(212,136,154,0.12)]">
{ch.currentStatus}
</span>
<span className="text-slate-400 text-sm">&rarr;</span>
<span className="bg-[#F3F0FF] text-[#4A3A7C] rounded-full px-3 py-1 text-xs font-medium border border-[#D5CDF5] shadow-[2px_3px_6px_rgba(155,138,212,0.12)]">
{ch.targetGoal}
</span>
</div>
{/* Content Types */}
<div className="flex flex-wrap gap-2 mb-4">
{ch.contentTypes.map((type) => (
<span
key={type}
className="bg-slate-50 border border-slate-100 rounded-full px-3 py-1 text-xs text-slate-600 font-medium shadow-[2px_3px_6px_rgba(0,0,0,0.04)]"
>
{type}
</span>
))}
</div>
{/* Posting Frequency */}
<div className="flex items-center gap-2 mb-3">
<CalendarFilled size={14} className="text-[#9B8AD4] shrink-0" />
<p className="text-sm text-slate-600">{ch.postingFrequency}</p>
</div>
{/* Tone */}
<p className="text-sm italic text-[#6C5CE7]/70 mb-4">{ch.tone}</p>
{/* Format Guidelines */}
<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-[#6C5CE7] mt-2" />
<span className="text-sm text-slate-700">{guideline}</span>
</li>
))}
</ul>
</motion.div>
);
})}
</div>
</SectionWrapper>
);
}

View File

@ -0,0 +1,153 @@
import { motion } from 'motion/react';
import {
VideoFilled,
FileTextFilled,
ShareFilled,
MegaphoneFilled,
} from '../icons/FilledIcons';
import { SectionWrapper } from '../report/ui/SectionWrapper';
import type { CalendarData, ContentCategory, CalendarEntry } from '../../types/plan';
interface ContentCalendarProps {
data: CalendarData;
}
const contentTypeColors: Record<ContentCategory, { bg: string; text: string; entry: string; border: string; shadow: string }> = {
video: { bg: 'bg-[#F3F0FF]', text: 'text-[#6C5CE7]', entry: 'bg-[#F3F0FF] border-[#D5CDF5]', border: 'border-[#D5CDF5]', shadow: 'shadow-[2px_3px_8px_rgba(155,138,212,0.15)]' },
blog: { bg: 'bg-[#EFF0FF]', text: 'text-[#3A3F7C]', entry: 'bg-[#EFF0FF] border-[#C5CBF5]', border: 'border-[#C5CBF5]', shadow: 'shadow-[2px_3px_8px_rgba(122,132,212,0.15)]' },
social: { bg: 'bg-[#FFF6ED]', text: 'text-[#7C5C3A]', entry: 'bg-[#FFF6ED] border-[#F5E0C5]', border: 'border-[#F5E0C5]', shadow: 'shadow-[2px_3px_8px_rgba(212,168,114,0.15)]' },
ad: { bg: 'bg-[#FFF0F0]', text: 'text-[#7C3A4B]', entry: 'bg-[#FFF0F0] border-[#F5D5DC]', border: 'border-[#F5D5DC]', shadow: 'shadow-[2px_3px_8px_rgba(212,136,154,0.15)]' },
};
const contentTypeLabels: Record<ContentCategory, string> = {
video: 'Video',
blog: 'Blog',
social: 'Social',
ad: 'Ad',
};
const contentTypeIcons: Record<ContentCategory, typeof VideoFilled> = {
video: VideoFilled,
blog: FileTextFilled,
social: ShareFilled,
ad: MegaphoneFilled,
};
const dayHeaders = ['월', '화', '수', '목', '금', '토', '일'];
export default function ContentCalendar({ data }: ContentCalendarProps) {
return (
<SectionWrapper
id="content-calendar"
title="Content Calendar"
subtitle="콘텐츠 캘린더 (월간)"
dark
>
{/* Monthly Summary */}
<div className="flex flex-wrap gap-4 mb-8">
{data.monthlySummary.map((item) => {
const colors = contentTypeColors[item.type];
return (
<motion.div
key={item.type}
className={`flex-1 min-w-[140px] rounded-2xl border p-4 ${colors.bg} ${colors.border} ${colors.shadow}`}
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.3 }}
>
<div className="flex items-center gap-2 mb-2">
<span
className="w-3 h-3 rounded-full"
style={{ backgroundColor: item.color }}
/>
<span className={`text-sm font-medium ${colors.text}`}>{item.label}</span>
</div>
<span className={`text-2xl font-bold ${colors.text}`}>{item.count}</span>
</motion.div>
);
})}
</div>
{/* Weekly Calendar Grid */}
{data.weeks.map((week, weekIdx) => {
const dayCells: CalendarEntry[][] = Array.from({ length: 7 }, () => []);
for (const entry of week.entries) {
const dayIndex = entry.dayOfWeek;
if (dayIndex >= 0 && dayIndex <= 6) {
dayCells[dayIndex].push(entry);
}
}
return (
<motion.div
key={week.weekNumber}
className="bg-white rounded-2xl p-5 mb-4 shadow-[3px_4px_12px_rgba(0,0,0,0.06)]"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: weekIdx * 0.1 }}
>
<p className="text-sm font-semibold text-[#0A1128] mb-3">{week.label}</p>
<div className="grid grid-cols-7 gap-2">
{/* Day headers */}
{dayHeaders.map((day) => (
<div
key={day}
className="text-xs text-slate-400 uppercase tracking-wide text-center mb-1 font-medium"
>
{day}
</div>
))}
{/* Day cells */}
{dayCells.map((entries, dayIdx) => (
<div
key={dayIdx}
className={`min-h-[80px] rounded-xl p-2 ${
entries.length > 0
? 'bg-slate-50/50 border border-slate-100'
: 'border border-dashed border-slate-200/60'
}`}
>
{entries.map((entry, entryIdx) => {
const colors = contentTypeColors[entry.contentType];
const Icon = contentTypeIcons[entry.contentType];
return (
<div
key={entryIdx}
className={`${colors.entry} border rounded-lg p-2 mb-1 shadow-[2px_2px_6px_rgba(0,0,0,0.04)]`}
>
<div className="flex items-center gap-1 mb-1">
<Icon size={11} className={colors.text} />
</div>
<p className="text-sm text-slate-700 leading-tight">
{entry.title}
</p>
</div>
);
})}
</div>
))}
</div>
</motion.div>
);
})}
{/* Color Legend */}
<div className="flex flex-wrap gap-3 mt-4">
{(Object.keys(contentTypeColors) as ContentCategory[]).map((type) => {
const colors = contentTypeColors[type];
return (
<span
key={type}
className={`${colors.bg} ${colors.text} border ${colors.border} rounded-full px-3 py-1 text-xs font-medium ${colors.shadow}`}
>
{contentTypeLabels[type]}
</span>
);
})}
</div>
</SectionWrapper>
);
}

View File

@ -0,0 +1,230 @@
import { useState } from 'react';
import { motion } from 'motion/react';
import { ArrowRight, Video, FileText, Share2, Megaphone } from 'lucide-react';
import { SectionWrapper } from '../report/ui/SectionWrapper';
import type { ContentStrategyData } from '../../types/plan';
interface ContentStrategyProps {
data: ContentStrategyData;
}
const tabItems = [
{ 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;
type TabKey = (typeof tabItems)[number]['key'];
const channelColorMap: Record<string, string> = {
YouTube: 'bg-[#FFF0F0] text-[#7C3A4B]',
Instagram: 'bg-pink-100 text-pink-700',
Blog: 'bg-[#EFF0FF] text-[#3A3F7C]',
'블로그': 'bg-[#EFF0FF] text-[#3A3F7C]',
'네이버블로그': 'bg-[#F3F0FF] text-[#4A3A7C]',
'네이버': 'bg-[#F3F0FF] text-[#4A3A7C]',
Facebook: 'bg-indigo-100 text-indigo-700',
'홈페이지': 'bg-slate-100 text-slate-700',
Website: 'bg-slate-100 text-slate-700',
TikTok: 'bg-purple-100 text-purple-700',
'카카오': 'bg-[#FFF6ED] text-[#7C5C3A]',
};
function getChannelBadgeClass(channel: string): string {
for (const [key, value] of Object.entries(channelColorMap)) {
if (channel.toLowerCase().includes(key.toLowerCase())) return value;
}
return 'bg-slate-100 text-slate-700';
}
export default function ContentStrategy({ data }: ContentStrategyProps) {
const [activeTab, setActiveTab] = useState<TabKey>('pillars');
return (
<SectionWrapper
id="content-strategy"
title="Content Strategy"
subtitle="콘텐츠 마케팅 전략"
>
{/* Tabs */}
<div className="flex flex-wrap gap-2 mb-8">
{tabItems.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`rounded-full px-4 py-2 text-sm font-medium transition-all ${
activeTab === tab.key
? 'bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white shadow-lg'
: 'bg-white border border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Tab 1: Content Pillars */}
{activeTab === 'pillars' && (
<motion.div
className="grid md:grid-cols-2 gap-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
{data.pillars.map((pillar, i) => (
<motion.div
key={pillar.title}
className="rounded-2xl border border-slate-100 bg-white shadow-sm p-6 border-l-4"
style={{ borderLeftColor: pillar.color }}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: i * 0.1 }}
>
<h4 className="font-serif text-xl font-bold text-[#0A1128] mb-2">
{pillar.title}
</h4>
<p className="text-sm text-slate-600 mb-3">{pillar.description}</p>
<span className="inline-block rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700 mb-4">
{pillar.relatedUSP}
</span>
<ul className="space-y-2">
{pillar.exampleTopics.map((topic, j) => (
<li key={j} className="flex items-start gap-2 text-sm text-slate-700">
<span
className="shrink-0 w-2 h-2 rounded-full mt-2"
style={{ backgroundColor: pillar.color }}
/>
{topic}
</li>
))}
</ul>
</motion.div>
))}
</motion.div>
)}
{/* Tab 2: Content Types */}
{activeTab === 'types' && (
<motion.div
className="rounded-2xl overflow-hidden border border-slate-100"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div className="grid grid-cols-4 bg-[#0A1128] text-white">
<div className="px-6 py-4 text-sm font-semibold">Format</div>
<div className="px-6 py-4 text-sm font-semibold">Channels</div>
<div className="px-6 py-4 text-sm font-semibold">Frequency</div>
<div className="px-6 py-4 text-sm font-semibold">Purpose</div>
</div>
{data.typeMatrix.map((row, i) => (
<motion.div
key={row.format}
className={`grid grid-cols-4 ${i % 2 === 0 ? 'bg-white' : 'bg-slate-50'}`}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: i * 0.04 }}
>
<div className="px-6 py-4 text-sm font-medium text-[#0A1128]">
{row.format}
</div>
<div className="px-6 py-4 flex flex-wrap gap-2">
{row.channels.map((ch) => (
<span
key={ch}
className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-medium ${getChannelBadgeClass(ch)}`}
>
{ch}
</span>
))}
</div>
<div className="px-6 py-4 text-sm text-slate-700">{row.frequency}</div>
<div className="px-6 py-4 text-sm text-slate-600">{row.purpose}</div>
</motion.div>
))}
</motion.div>
)}
{/* Tab 3: Production Workflow */}
{activeTab === 'workflow' && (
<motion.div
className="flex md:flex-row flex-col gap-4 items-stretch"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
{data.workflow.map((step, i) => (
<div key={step.step} className="flex md:flex-row flex-col items-center gap-4 flex-1">
<motion.div
className="flex-1 w-full rounded-2xl border border-slate-100 bg-white shadow-sm p-5"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: i * 0.1 }}
>
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white font-bold flex items-center justify-center mb-3">
{step.step}
</div>
<h4 className="font-semibold text-[#0A1128] mb-1">{step.name}</h4>
<p className="text-sm text-slate-600 mb-3">{step.description}</p>
<div className="flex flex-wrap gap-2">
<span className="rounded-full bg-purple-50 text-purple-700 px-2 py-1 text-xs">
{step.owner}
</span>
<span className="rounded-full bg-slate-100 text-slate-600 px-2 py-1 text-xs">
{step.duration}
</span>
</div>
</motion.div>
{i < data.workflow.length - 1 && (
<ArrowRight
size={20}
className="text-slate-300 shrink-0 hidden md:block"
/>
)}
</div>
))}
</motion.div>
)}
{/* Tab 4: Repurposing */}
{activeTab === 'repurposing' && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
{/* Source Card */}
<div className="rounded-2xl bg-gradient-to-r from-[#4F1DA1] to-[#021341] p-6 text-white mb-6">
<div className="flex items-center gap-3">
<Video size={28} className="text-purple-300" />
<h4 className="font-serif text-xl font-bold">{data.repurposingSource}</h4>
</div>
</div>
{/* Connector */}
<div className="flex justify-center mb-6">
<div className="w-px h-8 bg-gradient-to-b from-[#4F1DA1] to-slate-200" />
</div>
{/* Outputs Grid */}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-3">
{data.repurposingOutputs.map((output, i) => (
<motion.div
key={`${output.format}-${i}`}
className="rounded-xl border border-slate-100 bg-white p-4"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: i * 0.05 }}
>
<p className="font-semibold text-sm text-[#0A1128] mb-1">{output.format}</p>
<p className="text-xs text-slate-500 mb-1">{output.channel}</p>
<p className="text-xs text-slate-600">{output.description}</p>
</motion.div>
))}
</div>
</motion.div>
)}
</SectionWrapper>
);
}

View File

@ -0,0 +1,302 @@
import { useState, useRef, useCallback, type DragEvent, type ChangeEvent } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { SectionWrapper } from '../report/ui/SectionWrapper';
import { VideoFilled, FileTextFilled } from '../icons/FilledIcons';
// ─── Types ───
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;
}
// ─── Helpers ───
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-[#F3F0FF] text-[#4A3A7C] shadow-[2px_3px_6px_rgba(155,138,212,0.12)]',
video: 'bg-[#FFF0F0] text-[#7C3A4B] shadow-[2px_3px_6px_rgba(212,136,154,0.12)]',
text: 'bg-[#FFF6ED] text-[#7C5C3A] 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(',');
// ─── Component ───
export default function MyAssetUpload() {
const [assets, setAssets] = useState<UploadedAsset[]>([]);
const [activeFilter, setActiveFilter] = useState<UploadCategory>('all');
const [isDragOver, setIsDragOver] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
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]);
}, []);
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 (
<SectionWrapper
id="my-asset-upload"
title="My Assets"
subtitle="나의 에셋 업로드"
>
{/* Drop Zone */}
<div
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-[#9B8AD4] bg-[#F3F0FF]/60 scale-[1.01]'
: 'border-slate-200 bg-slate-50/50 hover:border-[#D5CDF5] hover:bg-[#F3F0FF]/20'
}`}
>
<input
ref={inputRef}
type="file"
multiple
accept={ALL_ACCEPT}
onChange={handleInputChange}
className="hidden"
/>
{/* Upload Icon */}
<div className="flex justify-center mb-4">
<div className="w-14 h-14 rounded-2xl bg-[#F3F0FF] flex items-center justify-center shadow-[3px_4px_12px_rgba(155,138,212,0.15)]">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
<path
d="M12 16V4M12 4L8 8M12 4L16 8"
stroke="#9B8AD4"
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="#9B8AD4"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
</div>
<p className="text-[#0A1128] font-semibold mb-1">
</p>
<p className="text-sm text-slate-400">
Image, Video, Text (JPG, PNG, MP4, MOV, TXT, PDF, DOC )
</p>
{/* File Type Badges */}
<div className="flex justify-center gap-2 mt-4">
{(['image', 'video', 'text'] as const).map((cat) => (
<span
key={cat}
className={`rounded-full px-3 py-1 text-xs font-medium ${categoryBadge[cat]}`}
>
{cat === 'image' ? 'Image' : cat === 'video' ? 'Video' : 'Text'}
</span>
))}
</div>
</div>
{/* Filter Tabs + Count */}
{assets.length > 0 && (
<>
<div className="flex flex-wrap items-center gap-2 mb-6">
{(Object.keys(categoryConfig) as UploadCategory[]).map((key) => (
<button
key={key}
onClick={() => setActiveFilter(key)}
className={`rounded-full px-4 py-2 text-sm font-medium transition-all ${
activeFilter === key
? 'bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white shadow-md'
: 'bg-white border border-slate-200 text-slate-600 hover:bg-slate-50 shadow-[2px_3px_6px_rgba(0,0,0,0.04)]'
}`}
>
{categoryConfig[key].label}
<span className="ml-2 opacity-70">{counts[key]}</span>
</button>
))}
</div>
{/* Uploaded Assets Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<AnimatePresence mode="popLayout">
{filtered.map((asset) => (
<motion.div
key={asset.id}
layout
className="bg-white rounded-2xl border border-slate-100 overflow-hidden shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)] transition-shadow group"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.25 }}
>
{/* Preview Area */}
<div className="relative h-40 bg-slate-50 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"
/>
)}
{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;
}}
/>
)}
{asset.category === 'text' && (
<div className="flex flex-col items-center gap-2">
<FileTextFilled size={36} className="text-[#D4A872]" />
<span className="text-xs text-slate-400 font-medium">
{asset.name.split('.').pop()?.toUpperCase()}
</span>
</div>
)}
{/* Remove Button */}
<button
onClick={() => removeAsset(asset.id)}
className="absolute top-2 right-2 w-7 h-7 rounded-full bg-white/90 backdrop-blur-sm border border-slate-100 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-sm hover:bg-[#FFF0F0]"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M2 2L10 10M10 2L2 10" stroke="#7C3A4B" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
{/* Category Badge */}
<span
className={`absolute top-2 left-2 rounded-full px-3 py-1 text-xs font-semibold ${categoryBadge[asset.category]}`}
>
{asset.category === 'image'
? 'Image'
: asset.category === 'video'
? 'Video'
: 'Text'}
</span>
{/* Video Duration Overlay */}
{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">
<VideoFilled size={10} className="text-white" />
<span className="text-xs text-white font-medium">Video</span>
</div>
)}
</div>
{/* Info */}
<div className="p-4">
<p className="text-sm font-medium text-[#0A1128] truncate mb-1">
{asset.name}
</p>
<p className="text-xs text-slate-400">{asset.size}</p>
</div>
</motion.div>
))}
</AnimatePresence>
</div>
</>
)}
</SectionWrapper>
);
}

View File

@ -0,0 +1,64 @@
import { motion } from 'motion/react';
import { useNavigate } from 'react-router';
import { Rocket, Download, Loader2 } from 'lucide-react';
import { useExportPDF } from '../../hooks/useExportPDF';
export default function PlanCTA() {
const { exportPDF, isExporting } = useExportPDF();
const navigate = useNavigate();
return (
<motion.section
className="py-16 md:py-20 px-6"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, ease: 'easeOut' }}
>
<div className="max-w-7xl mx-auto">
<div
data-cta-card
className="rounded-2xl bg-gradient-to-r from-[#fff3eb] via-[#e4cfff] to-[#f5f9ff] 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">
<Rocket size={28} className="text-[#4F1DA1]" />
</div>
</div>
<h3 className="font-serif text-2xl md:text-3xl font-bold text-[#021341] mb-3">
</h3>
<p className="text-[#021341]/60 mb-8 max-w-lg mx-auto">
INFINITH , .
</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<button
type="button"
onClick={() => navigate('/studio/view-clinic')}
className="inline-flex items-center justify-center gap-2 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#021341] px-6 py-3 text-sm font-medium text-white shadow-md hover:shadow-lg transition-shadow"
>
</button>
<button
type="button"
onClick={() => exportPDF('INFINITH_Marketing_Plan')}
disabled={isExporting}
className="inline-flex items-center justify-center gap-2 rounded-full bg-white border border-slate-200 px-6 py-3 text-sm font-medium text-[#021341] shadow-sm hover:shadow-md transition-shadow disabled:opacity-60"
>
{isExporting ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Download size={16} />
)}
</button>
</div>
</div>
</div>
</motion.section>
);
}

View File

@ -0,0 +1,113 @@
import { motion } from 'motion/react';
import { Calendar, Globe } from 'lucide-react';
interface PlanHeaderProps {
clinicName: string;
clinicNameEn: string;
date: string;
targetUrl: string;
}
export default function PlanHeader({
clinicName,
clinicNameEn,
date,
targetUrl,
}: PlanHeaderProps) {
return (
<section className="relative overflow-hidden 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%)] py-20 md:py-28 px-6">
{/* Animated blobs */}
<motion.div
className="absolute top-10 left-10 w-72 h-72 rounded-full bg-indigo-200/30 blur-3xl"
animate={{ x: [0, 30, 0], y: [0, -20, 0] }}
transition={{ duration: 8, repeat: Infinity, ease: 'easeInOut' }}
/>
<motion.div
className="absolute bottom-10 right-10 w-96 h-96 rounded-full bg-pink-200/30 blur-3xl"
animate={{ x: [0, -20, 0], y: [0, 30, 0] }}
transition={{ duration: 10, repeat: Infinity, ease: 'easeInOut' }}
/>
<motion.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"
animate={{ scale: [1, 1.1, 1] }}
transition={{ duration: 6, repeat: Infinity, ease: 'easeInOut' }}
/>
<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">
{/* Left: Text content */}
<motion.div
className="flex-1 text-center md:text-left"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
>
<motion.p
className="text-xs font-semibold text-[#6C5CE7] mb-4 tracking-widest uppercase"
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.1 }}
>
Marketing Execution Plan
</motion.p>
<motion.h1
className="font-serif text-4xl md:text-5xl font-bold text-[#0A1128] mb-3"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.2 }}
>
{clinicName}
</motion.h1>
<motion.p
className="text-xl text-slate-600 mb-8"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.3 }}
>
{clinicNameEn}
</motion.p>
<motion.div
className="flex flex-wrap gap-3 justify-center md:justify-start"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.4 }}
>
<span className="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">
<Calendar size={14} className="text-slate-400" />
{date}
</span>
<span className="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">
<Globe size={14} className="text-slate-400" />
{targetUrl}
</span>
</motion.div>
</motion.div>
{/* Right: 90 Days badge */}
<motion.div
className="shrink-0"
initial={{ opacity: 0, scale: 0.8 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.3 }}
>
<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">Days</span>
</div>
</motion.div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,64 @@
import type { ComponentType } from 'react';
import { motion } from 'motion/react';
import { Youtube, Instagram, Globe, Star, Facebook, Search } from 'lucide-react';
import { SectionWrapper } from './ui/SectionWrapper';
import { ScoreRing } from './ui/ScoreRing';
import { SeverityBadge } from './ui/SeverityBadge';
import type { ChannelScore } from '../../types/report';
interface ChannelOverviewProps {
channels: ChannelScore[];
}
const iconMap: Record<string, ComponentType<{ size?: number; className?: string }>> = {
youtube: Youtube,
instagram: Instagram,
facebook: Facebook,
star: Star,
globe: Globe,
search: Search,
};
const brandColor: Record<string, string> = {
facebook: '#1877F2',
instagram: '#E1306C',
youtube: '#FF0000',
globe: '#6B2D8B',
};
function getChannelColor(icon: string | undefined): string | undefined {
return brandColor[icon?.toLowerCase() ?? ''];
}
export default function ChannelOverview({ channels }: ChannelOverviewProps) {
return (
<SectionWrapper id="channel-overview" title="Channel Health Score" subtitle="채널별 건강도 종합">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
{channels.map((ch, i) => {
const Icon = iconMap[ch.icon?.toLowerCase()] ?? Globe;
const color = getChannelColor(ch.icon);
return (
<motion.div
key={ch.channel}
className="rounded-2xl bg-white border border-slate-100 shadow-sm p-4 text-center flex flex-col items-center gap-3"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: i * 0.08 }}
>
<div className="w-10 h-10 rounded-xl bg-slate-50 flex items-center justify-center">
<Icon size={20} style={color ? { color } : undefined} className={color ? '' : 'text-slate-500'} />
</div>
<p className="text-sm font-medium text-[#0A1128]">{ch.channel}</p>
<ScoreRing score={ch.score} maxScore={ch.maxScore} size={60} color={color} />
<p className="text-xs text-slate-500 line-clamp-2 leading-relaxed">
{ch.headline}
</p>
<SeverityBadge severity={ch.status} />
</motion.div>
);
})}
</div>
</SectionWrapper>
);
}

View File

@ -0,0 +1,114 @@
import { motion } from 'motion/react';
import { Calendar, Users, MapPin, Phone, Award, Star, Globe } from 'lucide-react';
import { SectionWrapper } from './ui/SectionWrapper';
import type { ClinicSnapshot as ClinicSnapshotType } from '../../types/report';
interface ClinicSnapshotProps {
data: ClinicSnapshotType;
}
function formatNumber(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
return n.toLocaleString();
}
const infoFields = (data: ClinicSnapshotType) => [
{ label: '개원', value: `${data.established} (${data.yearsInBusiness}년)`, icon: Calendar },
{ label: '의료진', value: `${data.staffCount}`, icon: Users },
{ label: '강남언니 평점', value: `${data.overallRating} / 5.0`, icon: Star },
{ label: '리뷰 수', value: formatNumber(data.totalReviews), icon: Star },
{ label: '시술 가격대', value: `${data.priceRange.min} ~ ${data.priceRange.max}`, icon: Globe },
{ label: '위치', value: `${data.location} (${data.nearestStation})`, icon: MapPin },
{ label: '전화', value: data.phone, icon: Phone },
{ label: '도메인', value: data.domain, icon: Globe },
];
export default function ClinicSnapshot({ data }: ClinicSnapshotProps) {
const fields = infoFields(data);
return (
<SectionWrapper id="clinic-snapshot" title="Clinic Overview" subtitle="병원 기본 정보">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
{fields.map((field, i) => {
const Icon = field.icon;
return (
<motion.div
key={field.label}
className="rounded-2xl bg-white border border-slate-100 shadow-sm p-5"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: i * 0.05 }}
>
<div className="flex items-start gap-3">
<div className="shrink-0 w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center">
<Icon size={16} className="text-slate-400" />
</div>
<div className="min-w-0">
<p className="text-xs text-slate-500 uppercase tracking-wide">{field.label}</p>
<p className="text-lg font-semibold text-[#0A1128] mt-1">{field.value}</p>
</div>
</div>
</motion.div>
);
})}
</div>
{/* Lead Doctor Highlight */}
<motion.div
className="rounded-2xl bg-gradient-to-r from-[#4F1DA1]/5 to-[#021341]/5 border border-purple-100 p-6 mb-8"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.3 }}
>
<div className="flex items-center gap-2 mb-3">
<Award size={20} className="text-[#6C5CE7]" />
<h3 className="font-serif font-bold text-2xl text-[#0A1128]"> </h3>
</div>
<p className="text-xl font-bold text-[#0A1128] mb-1">{data.leadDoctor.name}</p>
<p className="text-sm text-slate-600 mb-3">{data.leadDoctor.credentials}</p>
<div className="flex items-center gap-4">
<div className="flex items-center gap-1">
{Array.from({ length: 5 }).map((_, i) => (
<Star
key={i}
size={16}
className={i < Math.round(data.leadDoctor.rating) ? 'text-[#6B2D8B] fill-[#6B2D8B]' : 'text-slate-200'}
/>
))}
<span className="text-sm font-semibold text-[#0A1128] ml-1">
{data.leadDoctor.rating}
</span>
</div>
<span className="text-sm text-slate-500">
{formatNumber(data.leadDoctor.reviewCount)}
</span>
</div>
</motion.div>
{/* Certifications */}
{data.certifications.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.4 }}
>
<p className="text-sm font-semibold text-slate-700 mb-3"> </p>
<div className="flex flex-wrap gap-2">
{data.certifications.map((cert) => (
<span
key={cert}
className="rounded-full bg-white/60 backdrop-blur-sm border border-white/40 px-3 py-1 text-sm font-medium text-slate-700"
>
{cert}
</span>
))}
</div>
</motion.div>
)}
</SectionWrapper>
);
}

View File

@ -0,0 +1,353 @@
import { useState } from 'react';
import { motion } from 'motion/react';
import {
Facebook, AlertCircle, AlertTriangle, ExternalLink, CheckCircle2, XCircle,
ArrowRight, Link2, MessageCircle, TrendingUp, Eye, ImageIcon, Globe,
} from 'lucide-react';
import { SectionWrapper } from './ui/SectionWrapper';
import { SeverityBadge } from './ui/SeverityBadge';
import { EvidenceGallery } from './ui/EvidenceGallery';
import type {
FacebookAudit as FacebookAuditType,
FacebookPage,
BrandInconsistency,
DiagnosisItem,
} from '../../types/report';
function formatNumber(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return n.toLocaleString();
}
/* ─── Page Card ─── */
function PageCard({ page, index }: { key?: string | number; page: FacebookPage; index: number }) {
const isKR = page.language === 'KR';
const langColor = isKR ? 'bg-slate-100 text-slate-700' : 'bg-[#EFF0FF] text-[#3A3F7C]';
const isLogoMismatch = page.logo?.includes('불일치');
const isLowFollowers = page.followers < 500;
return (
<motion.div
className={`rounded-2xl border shadow-sm p-6 ${
isKR && isLowFollowers
? 'bg-[#FFF0F0]/30 border-[#F5D5DC]/60'
: 'bg-white border-slate-100'
}`}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.15 }}
>
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<span className={`text-xs font-medium px-3 py-1 rounded-full ${langColor}`}>
{page.label}
</span>
<div className="w-8 h-8 rounded-lg bg-[#1877F2] flex items-center justify-center">
<Facebook size={16} className="text-white" />
</div>
</div>
{isKR && isLowFollowers && (
<span className="text-xs font-medium px-3 py-1 rounded-full bg-[#FFF0F0] text-[#7C3A4B]">
</span>
)}
{page.hasWhatsApp && (
<span className="text-xs font-medium px-3 py-1 rounded-full bg-[#F3F0FF] text-[#4A3A7C]">
WhatsApp
</span>
)}
</div>
<h3 className="font-bold text-lg text-[#0A1128] mb-1">{page.pageName}</h3>
<p className="text-xs text-slate-500 mb-4">{page.category}</p>
{/* Metrics grid */}
<div className="grid grid-cols-3 gap-2 mb-5">
<div className="rounded-xl bg-slate-50 p-3 text-center">
<p className="text-xs text-slate-400 uppercase tracking-wide"></p>
<p className={`text-lg font-bold ${isLowFollowers ? 'text-[#7C3A4B]' : 'text-[#0A1128]'}`}>
{formatNumber(page.followers)}
</p>
</div>
<div className="rounded-xl bg-slate-50 p-3 text-center">
<p className="text-xs text-slate-400 uppercase tracking-wide"></p>
<p className={`text-lg font-bold ${page.reviews === 0 ? 'text-[#7C3A4B]' : 'text-[#0A1128]'}`}>
{page.reviews}
</p>
</div>
<div className="rounded-xl bg-slate-50 p-3 text-center">
<p className="text-xs text-slate-400 uppercase tracking-wide"></p>
<p className="text-lg font-bold text-[#0A1128]">{formatNumber(page.following)}</p>
</div>
</div>
{/* Detail rows */}
<div className="space-y-3 mb-5 text-sm">
<div className="flex items-center justify-between py-1 border-b border-slate-100 last:border-0">
<span className="text-slate-500 flex items-center gap-2"><Eye size={13} /> </span>
<span className="font-medium text-[#0A1128]">{page.recentPostAge}</span>
</div>
{page.postFrequency && (
<div className="flex items-center justify-between py-1 border-b border-slate-100 last:border-0">
<span className="text-slate-500 flex items-center gap-2"><TrendingUp size={13} /> </span>
<span className="font-medium text-[#0A1128]">{page.postFrequency}</span>
</div>
)}
{page.topContentType && (
<div className="flex items-center justify-between py-1 border-b border-slate-100 last:border-0">
<span className="text-slate-500 flex items-center gap-2"><ImageIcon size={13} /> </span>
<span className="font-medium text-[#0A1128] text-right text-xs max-w-[180px]">{page.topContentType}</span>
</div>
)}
{page.engagement && (
<div className="flex items-center justify-between py-1 border-b border-slate-100 last:border-0">
<span className="text-slate-500 flex items-center gap-2"><MessageCircle size={13} /> </span>
<span className={`font-medium text-right text-xs max-w-[180px] ${
page.engagement.includes('0~3') ? 'text-[#7C3A4B]' : 'text-[#0A1128]'
}`}>{page.engagement}</span>
</div>
)}
</div>
{/* Logo analysis - the enhanced version */}
<div className={`rounded-xl p-4 mb-4 ${
isLogoMismatch
? 'bg-[#FFF0F0] border border-[#F5D5DC]'
: 'bg-[#F3F0FF] border border-[#D5CDF5]'
}`}>
<div className="flex items-start gap-2 mb-2">
{isLogoMismatch
? <AlertTriangle size={16} className="text-[#7C3A4B] shrink-0 mt-1" />
: <CheckCircle2 size={16} className="text-[#9B8AD4] shrink-0 mt-1" />
}
<p className={`text-sm font-semibold ${isLogoMismatch ? 'text-[#7C3A4B]' : 'text-[#4A3A7C]'}`}>
{page.logo}
</p>
</div>
<p className={`text-xs leading-relaxed ml-6 ${isLogoMismatch ? 'text-[#7C3A4B]' : 'text-[#4A3A7C]'}`}>
{page.logoDescription}
</p>
</div>
{/* Domain link */}
<div className="rounded-xl bg-slate-50 p-3 mb-4">
<div className="flex items-center gap-2 mb-1">
<Link2 size={12} className="text-slate-400" />
<p className="text-xs text-slate-400 uppercase tracking-wide"> </p>
</div>
<p className={`text-sm font-mono ${
page.linkedDomain?.includes('다름') ? 'text-[#7C5C3A]' : 'text-slate-700'
}`}>
{page.linkedDomain || page.link}
</p>
</div>
{/* Bio */}
<div className="rounded-xl bg-slate-50 p-3 mb-4">
<p className="text-xs text-slate-400 uppercase tracking-wide mb-1">Bio</p>
<p className="text-sm text-slate-600 italic">"{page.bio}"</p>
</div>
{/* Link */}
<a
href={`https://${page.url}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-[#6C5CE7] hover:underline"
>
<ExternalLink size={14} />
{page.url}
</a>
</motion.div>
);
}
/* ─── Brand Inconsistency Map ─── */
function BrandConsistencyMap({ inconsistencies }: { inconsistencies: BrandInconsistency[] }) {
const [expanded, setExpanded] = useState<number | null>(0);
return (
<motion.div
className="mt-8"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.3 }}
>
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-2">Brand Consistency Map</h3>
<p className="text-sm text-slate-500 mb-5"> </p>
<div className="space-y-3">
{inconsistencies.map((item, i) => (
<div
key={item.field}
className="rounded-2xl border border-slate-100 bg-white shadow-sm overflow-hidden"
>
{/* Header - clickable */}
<button
onClick={() => setExpanded(expanded === i ? null : i)}
className="w-full flex items-center justify-between p-5 text-left hover:bg-slate-50/50 transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-primary-900 flex items-center justify-center text-white text-xs font-bold">
{item.values.filter(v => !v.isCorrect).length}
</div>
<div>
<p className="font-semibold text-[#0A1128]">{item.field}</p>
<p className="text-xs text-slate-500">
{item.values.filter(v => !v.isCorrect).length}
</p>
</div>
</div>
<ArrowRight
size={16}
className={`text-slate-400 transition-transform ${expanded === i ? 'rotate-90' : ''}`}
/>
</button>
{/* Expanded content */}
{expanded === i && (
<div className="px-5 pb-5 border-t border-slate-100">
{/* Channel values */}
<div className="grid gap-2 mt-4 mb-4">
{item.values.map((v) => (
<div
key={v.channel}
className={`flex items-center justify-between py-3 px-3 rounded-lg text-sm ${
v.isCorrect ? 'bg-[#F3F0FF]/60' : 'bg-[#FFF0F0]/60'
}`}
>
<span className="font-medium text-slate-700 min-w-[100px]">{v.channel}</span>
<span className={`flex-1 text-right ${v.isCorrect ? 'text-[#4A3A7C]' : 'text-[#7C3A4B]'}`}>
{v.value}
</span>
<span className="ml-3">
{v.isCorrect
? <CheckCircle2 size={15} className="text-[#9B8AD4]" />
: <XCircle size={15} className="text-[#D4889A]" />
}
</span>
</div>
))}
</div>
{/* Impact */}
<div className="rounded-xl bg-[#FFF6ED] border border-[#F5E0C5] p-4 mb-3">
<p className="text-xs font-semibold text-[#7C5C3A] uppercase tracking-wide mb-1">
<AlertCircle size={12} className="inline mr-1" />
Impact
</p>
<p className="text-sm text-[#7C5C3A]">{item.impact}</p>
</div>
{/* Recommendation */}
<div className="rounded-xl bg-[#F3F0FF] border border-[#D5CDF5] p-4">
<p className="text-xs font-semibold text-[#4A3A7C] uppercase tracking-wide mb-1">
<CheckCircle2 size={12} className="inline mr-1" />
Recommendation
</p>
<p className="text-sm text-[#4A3A7C]">{item.recommendation}</p>
</div>
</div>
)}
</div>
))}
</div>
</motion.div>
);
}
/* ─── Diagnosis Section ─── */
function DiagnosisSection({ items }: { items: DiagnosisItem[] }) {
return (
<motion.div
className="mt-8"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.4 }}
>
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-2"> </h3>
<p className="text-sm text-slate-500 mb-4">Facebook </p>
<div className="rounded-2xl border border-slate-100 bg-white shadow-sm overflow-hidden">
{items.map((item, i) => (
<div
key={i}
className={`p-5 ${
i < items.length - 1 ? 'border-b border-slate-100' : ''
}`}
>
<div className="flex items-start gap-4">
<div className="min-w-[120px] md:min-w-[160px]">
<p className="font-semibold text-sm text-[#0A1128]">{item.category}</p>
</div>
<p className="flex-1 text-sm text-slate-600">{item.detail}</p>
<div className="shrink-0">
<SeverityBadge severity={item.severity} />
</div>
</div>
{item.evidenceIds && item.evidenceIds.length > 0 && (
<EvidenceGallery evidenceIds={item.evidenceIds} compact />
)}
</div>
))}
</div>
</motion.div>
);
}
/* ─── Consolidation Recommendation ─── */
function ConsolidationCard({ text }: { text: string }) {
return (
<motion.div
className="mt-8 rounded-2xl bg-gradient-to-r from-[#4F1DA1] to-[#021341] p-6 md:p-8 text-white"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.5 }}
>
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-xl bg-white/10 flex items-center justify-center shrink-0">
<Globe size={20} className="text-white" />
</div>
<div>
<h4 className="font-serif font-bold text-2xl mb-2"> </h4>
<p className="text-sm text-purple-200 leading-relaxed">{text}</p>
</div>
</div>
</motion.div>
);
}
/* ─── Main Component ─── */
export default function FacebookAudit({ data }: { data: FacebookAuditType }) {
return (
<SectionWrapper id="facebook-audit" title="Facebook Analysis" subtitle="페이스북 페이지 분석">
{/* Page cards side by side */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{data.pages.map((page, i) => (
<PageCard key={page.pageName} page={page} index={i} />
))}
</div>
{/* Brand Consistency Map - the new enhanced section */}
{data.brandInconsistencies && data.brandInconsistencies.length > 0 && (
<BrandConsistencyMap inconsistencies={data.brandInconsistencies} />
)}
{/* Diagnosis table */}
{data.diagnosis && data.diagnosis.length > 0 && (
<DiagnosisSection items={data.diagnosis} />
)}
{/* Consolidation recommendation */}
{data.consolidationRecommendation && (
<ConsolidationCard text={data.consolidationRecommendation} />
)}
</SectionWrapper>
);
}

View File

@ -0,0 +1,136 @@
import { motion } from 'motion/react';
import { Instagram, AlertCircle, FileText, Users, Eye } from 'lucide-react';
import { SectionWrapper } from './ui/SectionWrapper';
import { MetricCard } from './ui/MetricCard';
import { DiagnosisRow } from './ui/DiagnosisRow';
import type { InstagramAudit as InstagramAuditType, InstagramAccount } from '../../types/report';
interface InstagramAuditProps {
data: InstagramAuditType;
}
function formatNumber(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
return n.toLocaleString();
}
function AccountCard({ account, index }: { key?: string | number; account: InstagramAccount; index: number }) {
const langColor = account.language === 'KR'
? 'bg-slate-100 text-slate-700'
: 'bg-[#EFF0FF] text-[#3A3F7C]';
return (
<motion.div
className="rounded-2xl bg-white border border-slate-100 shadow-sm p-6"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
>
{/* Language badge + handle */}
<div className="flex items-center gap-2 mb-4">
<span className={`text-xs font-medium px-3 py-1 rounded-full ${langColor}`}>
{account.label}
</span>
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center">
<Instagram size={16} className="text-white" />
</div>
</div>
<h3 className="font-bold text-lg text-[#0A1128] mb-1">{account.handle}</h3>
<p className="text-xs text-slate-500 mb-4">{account.category}</p>
{/* Compact metrics */}
<div className="grid grid-cols-3 gap-2 mb-4">
<div className="rounded-xl bg-slate-50 p-3 text-center">
<p className="text-xs text-slate-500"></p>
<p className="text-lg font-bold text-[#0A1128]">{formatNumber(account.posts)}</p>
</div>
<div className="rounded-xl bg-slate-50 p-3 text-center">
<p className="text-xs text-slate-500"></p>
<p className="text-lg font-bold text-[#0A1128]">{formatNumber(account.followers)}</p>
</div>
<div className="rounded-xl bg-slate-50 p-3 text-center">
<p className="text-xs text-slate-500"></p>
<p className="text-lg font-bold text-[#0A1128]">{formatNumber(account.following)}</p>
</div>
</div>
{/* Format & reels */}
<div className="space-y-2 mb-4">
<div className="flex items-center justify-between text-sm">
<span className="text-slate-500"> </span>
<span className="font-medium text-[#0A1128]">{account.contentFormat}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-slate-500"> </span>
<span className={`font-medium ${account.reelsCount === 0 ? 'text-[#7C3A4B]' : 'text-[#0A1128]'}`}>
{account.reelsCount === 0 ? (
<span className="inline-flex items-center gap-1">
<AlertCircle size={14} className="text-[#D4889A]" />
0 ()
</span>
) : (
account.reelsCount
)}
</span>
</div>
</div>
{/* Highlights */}
{account.highlights.length > 0 && (
<div className="mb-4">
<p className="text-xs text-slate-500 mb-2"></p>
<div className="flex flex-wrap gap-2">
{account.highlights.map((h) => (
<span
key={h}
className="rounded-full bg-white/60 backdrop-blur-sm border border-white/40 px-3 py-1 text-sm font-medium text-slate-700"
>
{h}
</span>
))}
</div>
</div>
)}
{/* Bio */}
{account.bio && (
<div className="rounded-xl bg-slate-50 p-3">
<p className="text-xs text-slate-400 mb-1">Bio</p>
<p className="text-sm text-slate-600 whitespace-pre-line">{account.bio}</p>
</div>
)}
</motion.div>
);
}
export default function InstagramAudit({ data }: InstagramAuditProps) {
return (
<SectionWrapper id="instagram-audit" title="Instagram Analysis" subtitle="인스타그램 채널 분석">
{/* Account cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
{data.accounts.map((account, i) => (
<AccountCard key={account.handle} account={account} index={i} />
))}
</div>
{/* Diagnosis */}
{data.diagnosis.length > 0 && (
<motion.div
className="rounded-2xl bg-white border border-slate-100 shadow-sm p-6"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.3 }}
>
<p className="text-sm font-semibold text-slate-700 mb-4"> </p>
{data.diagnosis.map((item, i) => (
<DiagnosisRow key={i} category={item.category} detail={item.detail} severity={item.severity} evidenceIds={item.evidenceIds} />
))}
</motion.div>
)}
</SectionWrapper>
);
}

View File

@ -0,0 +1,106 @@
import { motion } from 'motion/react';
import { TrendingUp, ArrowUpRight, Download, Loader2 } from 'lucide-react';
import { SectionWrapper } from './ui/SectionWrapper';
import { useExportPDF } from '../../hooks/useExportPDF';
import type { KPIMetric } from '../../types/report';
interface KPIDashboardProps {
metrics: KPIMetric[];
}
function isNegativeValue(value: string): boolean {
const lower = value.toLowerCase();
return lower === '0' || lower.includes('없음') || lower.includes('불가') || lower === 'n/a';
}
export default function KPIDashboard({ metrics }: KPIDashboardProps) {
const { exportPDF, isExporting } = useExportPDF();
return (
<SectionWrapper id="kpi-dashboard" title="KPI Dashboard" subtitle="핵심 성과 지표 목표">
{/* KPI Table */}
<motion.div
className="rounded-2xl overflow-hidden border border-slate-100 mb-10"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
>
{/* Header */}
<div className="grid grid-cols-4 bg-[#0A1128] text-white">
<div className="px-6 py-4 text-sm font-semibold">Metric</div>
<div className="px-6 py-4 text-sm font-semibold">Current</div>
<div className="px-6 py-4 text-sm font-semibold">3-Month Target</div>
<div className="px-6 py-4 text-sm font-semibold">12-Month Target</div>
</div>
{/* Data rows */}
{metrics.map((metric, i) => (
<motion.div
key={metric.metric}
className={`grid grid-cols-4 ${i % 2 === 0 ? 'bg-white' : 'bg-slate-50'}`}
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.3, delay: i * 0.04 }}
>
<div className="px-6 py-4 text-sm font-medium text-[#0A1128]">{metric.metric}</div>
<div className={`px-6 py-4 text-sm font-semibold ${isNegativeValue(metric.current) ? 'text-[#7C3A4B]' : 'text-[#0A1128]'}`}>
{metric.current}
</div>
<div className="px-6 py-4 text-sm font-medium text-[#4A3A7C]">{metric.target3Month}</div>
<div className="px-6 py-4 text-sm font-medium text-[#4A3A7C]">{metric.target12Month}</div>
</motion.div>
))}
</motion.div>
{/* CTA Card */}
<motion.div
data-cta-card
className="rounded-2xl bg-gradient-to-r from-[#fff3eb] via-[#e4cfff] to-[#f5f9ff] p-8 md:p-12 text-center relative overflow-hidden"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.3 }}
>
<div className="relative">
<div className="w-14 h-14 rounded-2xl bg-[#021341]/10 flex items-center justify-center mx-auto mb-6">
<TrendingUp size={28} className="text-[#021341]" />
</div>
<h3 className="font-serif text-2xl md:text-3xl font-bold text-[#021341] mb-3">
Start Your Transformation
</h3>
<p className="text-[#021341]/60 mb-8 max-w-xl mx-auto">
INFINITH . 90 .
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<a
href="/plan/view-clinic"
className="inline-flex items-center gap-2 bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white font-semibold px-8 py-4 rounded-full hover:shadow-xl transition-all"
>
<ArrowUpRight size={18} />
</a>
<button
onClick={() => exportPDF('INFINITH_Marketing_Report_뷰성형외과')}
disabled={isExporting}
className="inline-flex items-center gap-2 bg-white border border-slate-200 text-[#021341] font-semibold px-8 py-4 rounded-full hover:bg-slate-50 shadow-sm hover:shadow-md transition-all disabled:opacity-60 disabled:cursor-not-allowed"
>
{isExporting ? (
<>
<Loader2 size={18} className="animate-spin" />
...
</>
) : (
<>
<Download size={18} />
</>
)}
</button>
</div>
</div>
</motion.div>
</SectionWrapper>
);
}

View File

@ -0,0 +1,155 @@
import { motion } from 'motion/react';
import { CheckCircle2, XCircle, HelpCircle, ExternalLink, AlertCircle, Globe, Shield } from 'lucide-react';
import { SectionWrapper } from './ui/SectionWrapper';
import type { OtherChannel, WebsiteAudit } from '../../types/report';
interface OtherChannelsProps {
channels: OtherChannel[];
website: WebsiteAudit;
}
const statusConfig = {
active: { icon: CheckCircle2, color: 'text-[#9B8AD4]', label: '활성' },
inactive: { icon: XCircle, color: 'text-[#D4889A]', label: '비활성' },
unknown: { icon: HelpCircle, color: 'text-slate-400', label: '미확인' },
not_found: { icon: XCircle, color: 'text-[#D4889A]', label: '미발견' },
};
export default function OtherChannels({ channels, website }: OtherChannelsProps) {
return (
<SectionWrapper id="other-channels" title="Other Channels & Website" subtitle="기타 채널 및 웹사이트 기술 진단">
{/* Other Channels */}
<motion.div
className="rounded-2xl bg-white border border-slate-100 shadow-sm overflow-hidden mb-8"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
>
<div className="px-6 py-4 border-b border-slate-100">
<h3 className="font-serif font-bold text-2xl text-[#0A1128]"> </h3>
</div>
<div className="divide-y divide-slate-100">
{channels.map((ch, i) => {
const cfg = statusConfig[ch.status];
const StatusIcon = cfg.icon;
return (
<motion.div
key={ch.name}
className="flex items-center gap-4 px-6 py-4"
initial={{ opacity: 0, x: -10 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.3, delay: i * 0.05 }}
>
<StatusIcon size={20} className={cfg.color} />
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-[#0A1128]">{ch.name}</p>
<p className="text-sm text-slate-500">{ch.details}</p>
</div>
<span className={`text-xs font-medium ${cfg.color}`}>{cfg.label}</span>
{ch.url && (
<a
href={ch.url}
target="_blank"
rel="noopener noreferrer"
className="text-[#6C5CE7] hover:underline"
>
<ExternalLink size={16} />
</a>
)}
</motion.div>
);
})}
</div>
</motion.div>
{/* Website Tech Audit */}
<motion.div
className="rounded-2xl bg-white border border-slate-100 shadow-sm p-6"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<div className="flex items-center gap-2 mb-6">
<Globe size={20} className="text-[#6C5CE7]" />
<h3 className="font-serif font-bold text-2xl text-[#0A1128]"> </h3>
</div>
{/* Domain info */}
<div className="rounded-xl bg-slate-50 p-4 mb-6">
<p className="text-sm font-semibold text-[#0A1128] mb-2"> : {website.primaryDomain}</p>
{website.additionalDomains.length > 0 && (
<div className="mt-2 space-y-1">
{website.additionalDomains.map((d) => (
<p key={d.domain} className="text-sm text-slate-600">
{d.domain} <span className="text-slate-400">{d.purpose}</span>
</p>
))}
</div>
)}
<p className="text-sm text-slate-500 mt-2">
CTA: <span className="font-medium text-[#0A1128]">{website.mainCTA}</span>
</p>
</div>
{/* Tracking Pixels */}
<div className="mb-6">
<p className="text-sm font-semibold text-slate-700 mb-3"> </p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{website.trackingPixels.map((pixel) => (
<div
key={pixel.name}
className={`flex items-center gap-2 rounded-xl p-3 ${
pixel.installed
? 'bg-[#F3F0FF] border border-[#D5CDF5]'
: 'bg-[#FFF0F0] border border-[#F5D5DC]'
}`}
>
{pixel.installed ? (
<CheckCircle2 size={16} className="text-[#9B8AD4] shrink-0" />
) : (
<XCircle size={16} className="text-[#D4889A] shrink-0" />
)}
<div className="min-w-0">
<p className={`text-sm font-medium ${pixel.installed ? 'text-[#4A3A7C]' : 'text-[#7C3A4B]'}`}>
{pixel.name}
</p>
{pixel.details && (
<p className="text-xs text-slate-500 truncate">{pixel.details}</p>
)}
</div>
</div>
))}
</div>
</div>
{/* SNS Links Status */}
{!website.snsLinksOnSite && (
<motion.div
className="rounded-xl bg-[#FFF0F0] border border-[#F5D5DC] p-4 flex items-start gap-3"
initial={{ opacity: 0, scale: 0.95 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: 0.3 }}
>
<AlertCircle size={20} className="text-[#7C3A4B] shrink-0 mt-1" />
<div>
<p className="text-sm font-bold text-[#7C3A4B]"> SNS </p>
<p className="text-sm text-[#7C3A4B] mt-1">
. SNS .
</p>
</div>
</motion.div>
)}
{website.snsLinksOnSite && (
<div className="rounded-xl bg-[#F3F0FF] border border-[#D5CDF5] p-4 flex items-center gap-3">
<CheckCircle2 size={20} className="text-[#9B8AD4]" />
<p className="text-sm font-medium text-[#4A3A7C]"> SNS </p>
</div>
)}
</motion.div>
</SectionWrapper>
);
}

View File

@ -0,0 +1,50 @@
import { motion } from 'motion/react';
import { AlertCircle } from 'lucide-react';
import { SectionWrapper } from './ui/SectionWrapper';
import type { DiagnosisItem } from '../../types/report';
interface ProblemDiagnosisProps {
diagnosis: DiagnosisItem[];
}
const severityDot: Record<string, string> = {
critical: 'bg-[#C084CF]',
warning: 'bg-[#8B9CF7]',
good: 'bg-[#7C6DD8]',
excellent: 'bg-[#6C5CE7]',
unknown: 'bg-slate-400',
};
export default function ProblemDiagnosis({ diagnosis }: ProblemDiagnosisProps) {
return (
<SectionWrapper id="problem-diagnosis" title="Critical Issues" subtitle="핵심 문제 진단" dark>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{diagnosis.map((item, i) => (
<motion.div
key={i}
className="bg-white/10 backdrop-blur-sm border border-white/10 rounded-2xl p-6 relative overflow-hidden"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: i * 0.08 }}
>
{/* Severity dot */}
<div className="absolute top-4 right-4">
<span className={`block w-3 h-3 rounded-full ${severityDot[item.severity] ?? severityDot.unknown}`} />
</div>
<div className="flex items-start gap-3">
<div className="shrink-0 w-8 h-8 rounded-lg bg-white/10 flex items-center justify-center mt-1">
<AlertCircle size={16} className="text-[#E8B4C0]" />
</div>
<div>
<p className="text-lg font-bold text-white mb-2">{item.category}</p>
<p className="text-sm text-purple-200 leading-relaxed">{item.detail}</p>
</div>
</div>
</motion.div>
))}
</div>
</SectionWrapper>
);
}

View File

@ -0,0 +1,143 @@
import { motion } from 'motion/react';
import { Calendar, Globe, MapPin } from 'lucide-react';
import { ScoreRing } from './ui/ScoreRing';
interface ReportHeaderProps {
clinicName: string;
clinicNameEn: string;
overallScore: number;
date: string;
targetUrl: string;
location: string;
logoImage?: string;
brandColors?: { primary: string; accent: string; text: string };
}
export default function ReportHeader({
clinicName,
logoImage,
brandColors,
clinicNameEn,
overallScore,
date,
targetUrl,
location,
}: ReportHeaderProps) {
return (
<section className="relative overflow-hidden 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%)] py-20 px-6">
{/* Animated blobs */}
<motion.div
className="absolute top-10 left-10 w-72 h-72 rounded-full bg-indigo-200/30 blur-3xl"
animate={{ x: [0, 30, 0], y: [0, -20, 0] }}
transition={{ duration: 8, repeat: Infinity, ease: 'easeInOut' }}
/>
<motion.div
className="absolute bottom-10 right-10 w-96 h-96 rounded-full bg-pink-200/30 blur-3xl"
animate={{ x: [0, -20, 0], y: [0, 30, 0] }}
transition={{ duration: 10, repeat: Infinity, ease: 'easeInOut' }}
/>
<motion.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"
animate={{ scale: [1, 1.1, 1] }}
transition={{ duration: 6, repeat: Infinity, ease: 'easeInOut' }}
/>
<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">
{/* Left: Text content */}
<motion.div
className="flex-1 text-center md:text-left"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
>
<motion.p
className="font-serif text-3xl md:text-4xl font-normal text-[#6C5CE7] mb-4 tracking-wide"
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.1 }}
>
Marketing Intelligence Report
</motion.p>
{logoImage && (
<motion.div
className="mb-4"
initial={{ opacity: 0, scale: 0.9 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.15 }}
>
<img
src={logoImage}
alt={clinicName}
className="h-16 md:h-20 w-auto object-contain md:mx-0 mx-auto"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
</motion.div>
)}
<motion.h1
className="font-serif text-4xl md:text-5xl font-bold text-[#0A1128] mb-3"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.2 }}
>
{clinicName}
</motion.h1>
<motion.p
className="text-lg text-slate-600 mb-8"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.3 }}
>
{clinicNameEn}
</motion.p>
<motion.div
className="flex flex-wrap gap-3 justify-center md:justify-start"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.4 }}
>
<span className="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">
<Calendar size={14} className="text-slate-400" />
{date}
</span>
<span className="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">
<Globe size={14} className="text-slate-400" />
{targetUrl}
</span>
<span className="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">
<MapPin size={14} className="text-slate-400" />
{location}
</span>
</motion.div>
</motion.div>
{/* Right: Score ring */}
<motion.div
className="shrink-0"
initial={{ opacity: 0, scale: 0.8 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.3 }}
>
<div className="bg-white/60 backdrop-blur-sm border border-white/40 rounded-3xl p-8 shadow-lg">
<p className="text-xs text-slate-500 uppercase tracking-wide text-center mb-4">
Overall Score
</p>
<ScoreRing score={overallScore} size={160} label="종합 점수" />
</div>
</motion.div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,81 @@
import { useEffect, useRef, useState } from 'react';
interface ReportNavProps {
sections: { id: string; label: string }[];
}
export function ReportNav({ sections }: ReportNavProps) {
const [activeId, setActiveId] = useState(sections[0]?.id ?? '');
const navRef = useRef<HTMLDivElement>(null);
const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
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 }
);
sections.forEach(({ id }) => {
const el = document.getElementById(id);
if (el) observer.observe(el);
});
return () => observer.disconnect();
}, [sections]);
useEffect(() => {
const activeTab = tabRefs.current.get(activeId);
if (activeTab && navRef.current) {
activeTab.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'center',
});
}
}, [activeId]);
const handleClick = (id: string) => {
const el = document.getElementById(id);
if (el) {
el.scrollIntoView({ behavior: 'smooth' });
}
};
return (
<nav data-report-nav className="sticky top-20 z-40 bg-white/80 backdrop-blur-lg border-b border-slate-100">
<div
ref={navRef}
className="max-w-7xl mx-auto flex overflow-x-auto scrollbar-hide"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{sections.map(({ id, label }) => (
<button
key={id}
ref={(el) => {
if (el) tabRefs.current.set(id, el);
}}
onClick={() => handleClick(id)}
className={`
shrink-0 px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap
border-b-2
${activeId === id
? 'border-[#6C5CE7] text-[#0A1128]'
: 'border-transparent text-slate-500 hover:text-slate-700'
}
`}
>
{label}
</button>
))}
</div>
</nav>
);
}

View File

@ -0,0 +1,61 @@
import { motion } from 'motion/react';
import { CheckCircle2 } from 'lucide-react';
import { SectionWrapper } from './ui/SectionWrapper';
import type { RoadmapMonth } from '../../types/report';
interface RoadmapTimelineProps {
months: RoadmapMonth[];
}
export default function RoadmapTimeline({ months }: RoadmapTimelineProps) {
return (
<SectionWrapper id="roadmap" title="90-Day Roadmap" subtitle="실행 로드맵">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{months.map((month, i) => (
<motion.div
key={month.month}
className="rounded-2xl bg-white border border-slate-100 shadow-sm p-6"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: i * 0.15 }}
>
{/* Month badge */}
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white flex items-center justify-center font-bold text-sm">
{month.month}
</div>
<div>
<h3 className="font-serif font-bold text-xl md:text-2xl text-[#0A1128]">{month.title}</h3>
<p className="text-sm text-slate-500">{month.subtitle}</p>
</div>
</div>
{/* Task checklist */}
<ul className="space-y-3">
{month.tasks.map((task, j) => (
<motion.li
key={j}
className="flex items-start gap-3"
initial={{ opacity: 0, x: -10 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.3, delay: i * 0.15 + j * 0.05 }}
>
{task.completed ? (
<CheckCircle2 size={18} className="text-[#9B8AD4] shrink-0 mt-1" />
) : (
<div className="w-[18px] h-[18px] rounded-full border-2 border-slate-200 shrink-0 mt-1" />
)}
<span className={`text-sm ${task.completed ? 'text-slate-400 line-through' : 'text-slate-700'}`}>
{task.task}
</span>
</motion.li>
))}
</ul>
</motion.div>
))}
</div>
</SectionWrapper>
);
}

View File

@ -0,0 +1,205 @@
import { useState, type ComponentType } from 'react';
import { motion } from 'motion/react';
import { Youtube, Instagram, Facebook, Globe, Search, Star, ArrowUpRight } from 'lucide-react';
import { SectionWrapper } from './ui/SectionWrapper';
import { ComparisonRow } from './ui/ComparisonRow';
import type { TransformationProposal as TransformationProposalType, PlatformStrategy } from '../../types/report';
interface TransformationProposalProps {
data: TransformationProposalType;
}
const tabItems = [
{ key: 'brand', label: 'Brand Identity', labelKr: '브랜드 아이덴티티' },
{ key: 'content', label: 'Content Strategy', labelKr: '콘텐츠 전략' },
{ key: 'platform', label: 'Platform Strategies', labelKr: '플랫폼 전략' },
{ key: 'website', label: 'Website', labelKr: '웹사이트 개선' },
{ key: 'newChannel', label: 'New Channels', labelKr: '신규 채널' },
] as const;
type TabKey = (typeof tabItems)[number]['key'];
const platformIconMap: Record<string, ComponentType<{ size?: number; className?: string }>> = {
youtube: Youtube,
instagram: Instagram,
facebook: Facebook,
website: Globe,
blog: Globe,
naver: Search,
tiktok: Star,
};
function PlatformStrategyCard({ strategy, index }: { key?: string | number; strategy: PlatformStrategy; index: number }) {
const Icon = platformIconMap[strategy.icon?.toLowerCase()] ?? Globe;
return (
<motion.div
className="rounded-2xl bg-white border border-slate-100 shadow-sm p-6"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#6C5CE7]/10 to-[#4F1DA1]/10 flex items-center justify-center">
<Icon size={20} className="text-[#6C5CE7]" />
</div>
<h4 className="font-bold text-[#0A1128]">{strategy.platform}</h4>
</div>
{/* Current to Target */}
<div className="flex items-center gap-3 mb-4 text-sm">
<span className="rounded-lg bg-[#FFF0F0] px-3 py-2 text-[#7C3A4B] font-medium">
{strategy.currentMetric}
</span>
<ArrowUpRight size={16} className="text-slate-400" />
<span className="rounded-lg bg-[#F3F0FF] px-3 py-2 text-[#4A3A7C] font-medium">
{strategy.targetMetric}
</span>
</div>
{/* Strategy bullets */}
<ul className="space-y-2">
{strategy.strategies.map((s, i) => (
<li key={i} className="flex items-start gap-2">
<span className="shrink-0 w-2 h-2 rounded-full bg-[#6C5CE7] mt-2" />
<div>
<p className="text-sm font-medium text-[#0A1128]">{s.strategy}</p>
<p className="text-xs text-slate-500">{s.detail}</p>
</div>
</li>
))}
</ul>
</motion.div>
);
}
const priorityColor: Record<string, string> = {
high: 'bg-[#FFF0F0] text-[#7C3A4B] border-[#F5D5DC]',
medium: 'bg-[#FFF6ED] text-[#7C5C3A] border-[#F5E0C5]',
low: 'bg-[#F3F0FF] text-[#4A3A7C] border-[#D5CDF5]',
: 'bg-[#FFF0F0] text-[#7C3A4B] border-[#F5D5DC]',
: 'bg-[#FFF6ED] text-[#7C5C3A] border-[#F5E0C5]',
: 'bg-[#F3F0FF] text-[#4A3A7C] border-[#D5CDF5]',
};
export default function TransformationProposal({ data }: TransformationProposalProps) {
const [activeTab, setActiveTab] = useState<TabKey>('brand');
return (
<SectionWrapper id="transformation" title="Transformation Proposal" subtitle="As-Is → To-Be 전환 제안">
{/* Tabs */}
<div className="flex flex-wrap gap-2 mb-8">
{tabItems.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`rounded-full px-4 py-2 text-sm font-medium transition-all ${
activeTab === tab.key
? 'bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white shadow-lg'
: 'bg-white border border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Brand Identity */}
{activeTab === 'brand' && (
<motion.div
className="rounded-2xl bg-white border border-slate-100 shadow-sm p-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<h3 className="font-serif font-bold text-2xl text-[#0A1128] mb-4"> </h3>
{data.brandIdentity.map((item, i) => (
<ComparisonRow key={i} area={item.area} asIs={item.asIs} toBe={item.toBe} />
))}
</motion.div>
)}
{/* Content Strategy */}
{activeTab === 'content' && (
<motion.div
className="rounded-2xl bg-white border border-slate-100 shadow-sm p-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<h3 className="font-serif font-bold text-2xl text-[#0A1128] mb-4"> </h3>
{data.contentStrategy.map((item, i) => (
<ComparisonRow key={i} area={item.area} asIs={item.asIs} toBe={item.toBe} />
))}
</motion.div>
)}
{/* Platform Strategies */}
{activeTab === 'platform' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{data.platformStrategies.map((strategy, i) => (
<PlatformStrategyCard key={strategy.platform} strategy={strategy} index={i} />
))}
</div>
)}
{/* Website Improvements */}
{activeTab === 'website' && (
<motion.div
className="rounded-2xl bg-white border border-slate-100 shadow-sm p-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<h3 className="font-serif font-bold text-2xl text-[#0A1128] mb-4"> </h3>
{data.websiteImprovements.map((item, i) => (
<ComparisonRow key={i} area={item.area} asIs={item.asIs} toBe={item.toBe} />
))}
</motion.div>
)}
{/* New Channel Proposals */}
{activeTab === 'newChannel' && (
<motion.div
className="rounded-2xl bg-white border border-slate-100 shadow-sm overflow-hidden"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<table className="w-full text-left">
<thead>
<tr className="bg-slate-50 border-b border-slate-100">
<th className="px-6 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wide"></th>
<th className="px-6 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wide"></th>
<th className="px-6 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wide"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{data.newChannelProposals.map((ch, i) => (
<motion.tr
key={ch.channel}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: i * 0.05 }}
>
<td className="px-6 py-4 text-sm font-medium text-[#0A1128]">{ch.channel}</td>
<td className="px-6 py-4">
<span
className={`inline-flex items-center rounded-full text-xs font-medium px-3 py-1 border ${
priorityColor[ch.priority.toLowerCase()] ?? 'bg-slate-50 text-slate-700 border-slate-200'
}`}
>
{ch.priority}
</span>
</td>
<td className="px-6 py-4 text-sm text-slate-600">{ch.rationale}</td>
</motion.tr>
))}
</tbody>
</table>
</motion.div>
)}
</SectionWrapper>
);
}

View File

@ -0,0 +1,177 @@
import { motion } from 'motion/react';
import { Youtube, Users, Video, Eye, TrendingUp, ExternalLink } from 'lucide-react';
import { SectionWrapper } from './ui/SectionWrapper';
import { MetricCard } from './ui/MetricCard';
import { DiagnosisRow } from './ui/DiagnosisRow';
import type { YouTubeAudit as YouTubeAuditType } from '../../types/report';
interface YouTubeAuditProps {
data: YouTubeAuditType;
}
function formatNumber(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
return n.toLocaleString();
}
export default function YouTubeAudit({ data }: YouTubeAuditProps) {
return (
<SectionWrapper id="youtube-audit" title="YouTube Analysis" subtitle="유튜브 채널 분석">
{/* Metrics row */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<MetricCard
label="구독자"
value={formatNumber(data.subscribers)}
icon={<Users size={20} />}
subtext={data.subscriberRank}
/>
<MetricCard
label="총 영상 수"
value={formatNumber(data.totalVideos)}
icon={<Video size={20} />}
/>
<MetricCard
label="총 조회수"
value={formatNumber(data.totalViews)}
icon={<Eye size={20} />}
/>
<MetricCard
label="주간 성장"
value={`+${formatNumber(data.weeklyViewGrowth.absolute)}`}
icon={<TrendingUp size={20} />}
subtext={`${data.weeklyViewGrowth.percentage > 0 ? '+' : ''}${data.weeklyViewGrowth.percentage}%`}
trend={data.weeklyViewGrowth.percentage > 0 ? 'up' : data.weeklyViewGrowth.percentage < 0 ? 'down' : 'neutral'}
/>
</div>
{/* Channel info card */}
<motion.div
className="rounded-2xl bg-white border border-slate-100 shadow-sm p-6 mb-8"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-[#FFF0F0] flex items-center justify-center">
<Youtube size={20} className="text-[#D4889A]" />
</div>
<div>
<p className="font-bold text-[#0A1128]">{data.channelName}</p>
<p className="text-sm text-slate-500">{data.handle}</p>
</div>
</div>
<p className="text-sm text-slate-600 mb-4">{data.channelDescription}</p>
<div className="flex flex-wrap gap-3 text-sm text-slate-500">
<span>: {data.channelCreatedDate}</span>
<span> : {data.avgVideoLength}</span>
<span> : {data.uploadFrequency}</span>
</div>
{/* Linked URLs */}
{data.linkedUrls.length > 0 && (
<div className="mt-4 flex flex-wrap gap-2">
{data.linkedUrls.map((link) => (
<a
key={link.url}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-[#6C5CE7] hover:underline"
>
<ExternalLink size={12} />
{link.label}
</a>
))}
</div>
)}
</motion.div>
{/* Playlists */}
{data.playlists.length > 0 && (
<motion.div
className="mb-8"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.25 }}
>
<p className="text-sm font-semibold text-slate-700 mb-3"></p>
<div className="flex flex-wrap gap-2">
{data.playlists.map((pl) => (
<span
key={pl}
className="rounded-full bg-white/60 backdrop-blur-sm border border-white/40 px-3 py-1 text-sm font-medium text-slate-700"
>
{pl}
</span>
))}
</div>
</motion.div>
)}
{/* Top Videos */}
{data.topVideos.length > 0 && (
<motion.div
className="mb-8"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.3 }}
>
<p className="text-sm font-semibold text-slate-700 mb-4"> TOP {data.topVideos.length}</p>
<div className="flex gap-4 overflow-x-auto pb-4 scrollbar-thin scrollbar-thumb-slate-200">
{data.topVideos.map((video, i) => (
<motion.div
key={i}
className="rounded-xl bg-white border border-slate-100 shadow-sm p-4 min-w-[250px] shrink-0"
initial={{ opacity: 0, x: 20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: i * 0.05 }}
>
<div className="flex items-center gap-2 mb-2">
<span
className={`text-xs font-medium px-2 py-1 rounded-full ${
video.type === 'Short'
? 'bg-purple-50 text-purple-700'
: 'bg-[#EFF0FF] text-[#3A3F7C]'
}`}
>
{video.type}
</span>
<span className="text-xs text-slate-400">{video.uploadedAgo}</span>
</div>
<p className="text-sm font-medium text-[#0A1128] line-clamp-2 mb-2">
{video.title}
</p>
<div className="flex items-center gap-1 text-sm text-slate-600">
<Eye size={14} className="text-slate-400" />
<span className="font-semibold">{formatNumber(video.views)}</span>
<span className="text-slate-400">views</span>
</div>
</motion.div>
))}
</div>
</motion.div>
)}
{/* Diagnosis */}
{data.diagnosis.length > 0 && (
<motion.div
className="rounded-2xl bg-white border border-slate-100 shadow-sm p-6"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.35 }}
>
<p className="text-sm font-semibold text-slate-700 mb-4"> </p>
{data.diagnosis.map((item, i) => (
<DiagnosisRow key={i} category={item.category} detail={item.detail} severity={item.severity} evidenceIds={item.evidenceIds} />
))}
</motion.div>
)}
</SectionWrapper>
);
}

View File

@ -0,0 +1,20 @@
interface ComparisonRowProps {
key?: string | number;
area: string;
asIs: string;
toBe: string;
}
export function ComparisonRow({ area, asIs, toBe }: ComparisonRowProps) {
return (
<div className="grid grid-cols-[120px_1fr_1fr] gap-3 items-start py-4 border-b border-slate-100 last:border-0">
<span className="font-medium text-sm text-slate-700 pt-3">{area}</span>
<div className="bg-[#FFF0F0]/50 rounded-lg p-3 text-sm text-slate-600">
{asIs}
</div>
<div className="bg-[#F3F0FF]/50 rounded-lg p-3 text-sm text-slate-700 font-medium">
{toBe}
</div>
</div>
);
}

View File

@ -0,0 +1,28 @@
import type { Severity } from '../../../types/report';
import { SeverityBadge } from './SeverityBadge';
import { EvidenceGallery } from './EvidenceGallery';
interface DiagnosisRowProps {
key?: string | number;
category: string;
detail: string;
severity: Severity;
evidenceIds?: string[];
}
export function DiagnosisRow({ category, detail, severity, evidenceIds }: DiagnosisRowProps) {
return (
<div className="py-4 border-b border-slate-100 last:border-0">
<div className="flex items-center gap-4">
<span className="font-bold text-sm text-[#0A1128] shrink-0 w-28">
{category}
</span>
<p className="flex-1 text-sm text-slate-600">{detail}</p>
<div className="shrink-0">
<SeverityBadge severity={severity} />
</div>
</div>
<EvidenceGallery evidenceIds={evidenceIds} compact />
</div>
);
}

View File

@ -0,0 +1,34 @@
import { useScreenshots } from '../../../contexts/ScreenshotContext';
import { EvidenceScreenshot } from './EvidenceScreenshot';
interface EvidenceGalleryProps {
evidenceIds?: string[];
compact?: boolean;
}
export function EvidenceGallery({ evidenceIds, compact }: EvidenceGalleryProps) {
const { getByIds } = useScreenshots();
if (!evidenceIds?.length) return null;
const items = getByIds(evidenceIds);
if (!items.length) return null;
if (items.length === 1) {
return (
<div className="mt-3">
<EvidenceScreenshot evidence={items[0]} compact={compact} />
</div>
);
}
return (
<div className="mt-3 flex gap-3 overflow-x-auto pb-2" style={{ scrollbarWidth: 'thin' }}>
{items.map((item) => (
<div key={item.id} className="shrink-0 w-[200px]">
<EvidenceScreenshot evidence={item} compact />
</div>
))}
</div>
);
}

View File

@ -0,0 +1,105 @@
import { useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import type { ScreenshotEvidence } from '../../../types/report';
interface EvidenceLightboxProps {
evidence: ScreenshotEvidence | null;
onClose: () => void;
}
export function EvidenceLightbox({ evidence, onClose }: EvidenceLightboxProps) {
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
},
[onClose],
);
useEffect(() => {
if (evidence) {
document.addEventListener('keydown', handleKeyDown);
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = '';
};
}, [evidence, handleKeyDown]);
return (
<AnimatePresence>
{evidence && (
<motion.div
data-no-print
className="fixed inset-0 z-50 flex items-center justify-center p-6"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Content */}
<motion.div
className="relative max-w-5xl w-full max-h-[90vh] flex flex-col"
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
transition={{ duration: 0.2 }}
>
{/* Close button */}
<button
onClick={onClose}
className="absolute -top-3 -right-3 z-10 w-8 h-8 rounded-full bg-white shadow-md flex items-center justify-center hover:bg-slate-50 transition-colors"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M2 2L12 12M12 2L2 12" stroke="#0A1128" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
{/* Image container with annotations */}
<div className="relative rounded-2xl overflow-hidden bg-white shadow-2xl">
<img
src={evidence.url}
alt={evidence.caption}
className="w-full h-auto max-h-[70vh] object-contain"
/>
{/* Annotation overlays */}
{evidence.annotations?.map((ann, i) => (
<div
key={i}
className="absolute border-2 border-[#C084CF] rounded-lg pointer-events-none"
style={{
left: `${ann.x}%`,
top: `${ann.y}%`,
width: ann.width ? `${ann.width}%` : 'auto',
height: ann.height ? `${ann.height}%` : 'auto',
}}
>
{ann.label && (
<span className="absolute -top-6 left-0 text-xs bg-[#C084CF] text-white px-2 py-1 rounded font-medium whitespace-nowrap">
{ann.label}
</span>
)}
</div>
))}
</div>
{/* Caption */}
<div className="mt-3 bg-white rounded-xl px-4 py-3 shadow-sm">
<p className="text-sm text-[#0A1128] font-medium">{evidence.caption}</p>
{evidence.sourceUrl && (
<p className="text-xs text-slate-400 mt-1">{evidence.sourceUrl}</p>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@ -0,0 +1,78 @@
import { useState } from 'react';
import type { ScreenshotEvidence } from '../../../types/report';
import { EvidenceLightbox } from './EvidenceLightbox';
interface EvidenceScreenshotProps {
evidence: ScreenshotEvidence;
compact?: boolean;
}
export function EvidenceScreenshot({ evidence, compact }: EvidenceScreenshotProps) {
const [loaded, setLoaded] = useState(true);
const [lightboxOpen, setLightboxOpen] = useState(false);
if (!loaded) return null;
return (
<>
<button
type="button"
onClick={() => setLightboxOpen(true)}
className={`group relative rounded-xl overflow-hidden border border-slate-100 bg-white shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)] transition-all cursor-pointer ${
compact ? 'w-full' : 'w-full max-w-[400px]'
}`}
>
<div className="relative">
<img
src={evidence.url}
alt={evidence.caption}
className={`w-full object-cover ${compact ? 'h-24' : 'h-48'}`}
onError={() => setLoaded(false)}
/>
{/* Hover overlay */}
<div className="absolute inset-0 bg-[#0A1128]/0 group-hover:bg-[#0A1128]/10 transition-colors flex items-center justify-center">
<div className="w-8 h-8 rounded-full bg-white/90 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-sm">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M15 3H21V9M21 3L13 11M10 5H5C3.9 5 3 5.9 3 7V19C3 20.1 3.9 21 5 21H17C18.1 21 19 20.1 19 19V14" stroke="#6C5CE7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
</div>
{/* Channel badge */}
<span className="absolute top-2 left-2 rounded-full bg-white/90 backdrop-blur-sm px-2 py-1 text-xs font-medium text-[#4A3A7C] shadow-sm">
{evidence.channel}
</span>
{/* Annotation indicators */}
{evidence.annotations?.map((ann, i) => (
<div
key={i}
className="absolute border-2 border-[#C084CF]/60 rounded-lg pointer-events-none"
style={{
left: `${ann.x}%`,
top: `${ann.y}%`,
width: ann.width ? `${ann.width}%` : 'auto',
height: ann.height ? `${ann.height}%` : 'auto',
}}
/>
))}
</div>
{/* Caption */}
{!compact && (
<div className="px-3 py-2">
<p className="text-xs text-slate-600 leading-relaxed line-clamp-2">
{evidence.caption}
</p>
</div>
)}
</button>
<EvidenceLightbox
evidence={lightboxOpen ? evidence : null}
onClose={() => setLightboxOpen(false)}
/>
</>
);
}

View File

@ -0,0 +1,44 @@
import type { ReactNode } from 'react';
import { ArrowUp, ArrowDown, Minus } from 'lucide-react';
interface MetricCardProps {
key?: string | number;
label: string;
value: string | number;
subtext?: string;
icon?: ReactNode;
trend?: 'up' | 'down' | 'neutral';
}
const trendConfig = {
up: { icon: ArrowUp, color: 'text-emerald-500' },
down: { icon: ArrowDown, color: 'text-red-500' },
neutral: { icon: Minus, color: 'text-slate-400' },
};
export function MetricCard({ label, value, subtext, icon, trend }: MetricCardProps) {
return (
<div className="rounded-2xl border border-slate-100 shadow-sm bg-white p-5 relative">
{icon && (
<div className="absolute top-4 right-4 text-slate-300">
{icon}
</div>
)}
<p className="text-sm text-slate-500 mb-1">{label}</p>
<div className="flex items-end gap-2">
<span className="text-3xl font-bold text-[#0A1128]">{value}</span>
{trend && (
<span className={`${trendConfig[trend].color} mb-1`}>
{(() => {
const TrendIcon = trendConfig[trend].icon;
return <TrendIcon size={18} />;
})()}
</span>
)}
</div>
{subtext && (
<p className="text-xs text-slate-400 mt-1">{subtext}</p>
)}
</div>
);
}

View File

@ -0,0 +1,73 @@
import { motion } from 'motion/react';
interface ScoreRingProps {
score: number;
maxScore?: number;
size?: number;
label?: string;
color?: string;
}
function getScoreColor(score: number, maxScore: number): string {
const pct = (score / maxScore) * 100;
if (pct <= 40) return '#C084CF'; // soft violet — critical
if (pct <= 60) return '#8B9CF7'; // periwinkle blue — caution
if (pct <= 80) return '#7C6DD8'; // medium purple — good
return '#6C5CE7'; // Infinith primary purple — excellent
}
export function ScoreRing({
score,
maxScore = 100,
size = 120,
label,
color,
}: ScoreRingProps) {
const strokeWidth = 8;
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const progress = Math.min(score / maxScore, 1);
const strokeDashoffset = circumference * (1 - progress);
const resolvedColor = color ?? getScoreColor(score, maxScore);
return (
<div className="flex flex-col items-center gap-2">
<div className="relative" style={{ width: size, height: size }}>
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
className="-rotate-90"
>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="#f1f5f9"
strokeWidth={strokeWidth}
/>
<motion.circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={resolvedColor}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
initial={{ strokeDashoffset: circumference }}
animate={{ strokeDashoffset }}
transition={{ duration: 1, ease: 'easeOut' }}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-2xl font-bold text-[#0A1128]">{score}</span>
</div>
</div>
{label && (
<span className="text-sm text-slate-500 text-center">{label}</span>
)}
</div>
);
}

View File

@ -0,0 +1,65 @@
import type { ReactNode } from 'react';
import { motion } from 'motion/react';
interface SectionWrapperProps {
id: string;
title: string;
subtitle?: string;
children: ReactNode;
dark?: boolean;
className?: string;
}
export function SectionWrapper({
id,
title,
subtitle,
children,
dark = false,
className = '',
}: SectionWrapperProps) {
return (
<motion.section
id={id}
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, ease: 'easeOut' }}
className={`
${dark
? 'bg-[#0A1128] text-white relative overflow-hidden'
: 'bg-white'
}
py-16 md:py-20 px-6
${className}
`}
>
{dark && (
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,rgba(108,92,231,0.15),transparent_60%)]" />
)}
<div className="relative max-w-7xl mx-auto">
<div className="mb-10">
<h2
className={`
font-serif font-bold text-3xl md:text-4xl mb-3
${dark
? 'bg-gradient-to-r from-purple-300 to-blue-300 bg-clip-text text-transparent'
: 'text-gradient'
}
`}
>
{title}
</h2>
{subtitle && (
<p
className={`text-lg ${dark ? 'text-purple-200' : 'text-slate-600'}`}
>
{subtitle}
</p>
)}
</div>
{children}
</div>
</motion.section>
);
}

View File

@ -0,0 +1,41 @@
type SeverityLevel = 'critical' | 'warning' | 'good' | 'excellent' | 'unknown';
interface SeverityBadgeProps {
severity: SeverityLevel;
label?: string;
}
const config: Record<SeverityLevel, { className: string; defaultLabel: string }> = {
critical: {
className: 'bg-[#FFF0F0] text-[#7C3A4B] border-[#F5D5DC]',
defaultLabel: '심각',
},
warning: {
className: 'bg-[#FFF6ED] text-[#7C5C3A] border-[#F5E0C5]',
defaultLabel: '주의',
},
good: {
className: 'bg-[#F3F0FF] text-[#4A3A7C] border-[#D5CDF5]',
defaultLabel: '양호',
},
excellent: {
className: 'bg-[#EFF0FF] text-[#3A3F7C] border-[#C5CBF5]',
defaultLabel: '우수',
},
unknown: {
className: 'bg-slate-50 text-slate-700 border-slate-200',
defaultLabel: '미확인',
},
};
export function SeverityBadge({ severity, label }: SeverityBadgeProps) {
const { className, defaultLabel } = config[severity];
return (
<span
className={`inline-flex items-center rounded-full text-xs font-medium px-3 py-1 border ${className}`}
>
{label ?? defaultLabel}
</span>
);
}

View File

@ -0,0 +1,202 @@
import { useState, useCallback } from 'react';
import { motion } from 'motion/react';
import { FileTextFilled } from '../icons/FilledIcons';
import type { StudioState } from '../../types/studio';
import { CHANNEL_OPTIONS } from '../../types/studio';
import { generateImage, type GenerateResult } from '../../services/geminiImageGen';
interface Props {
studioState: StudioState;
}
type GenStatus = 'idle' | 'generating' | 'done' | 'error';
const PLACEHOLDER_BLOG = `# 한번에 성공하는 코성형, VIEW의 비결
. VIEW 21 , .
## VIEW?
- **21 **
- ** **
- ** CCTV**
- ** **
##
### 1.
. VIEW .
### 2.
. .
### 3.
. (, ) .
##
. VIEW 1:1 .
02-XXX-XXXX | `;
export default function BlogEditorStep({ studioState }: Props) {
const [imageStatus, setImageStatus] = useState<GenStatus>('idle');
const [imageResult, setImageResult] = useState<GenerateResult | null>(null);
const [imageError, setImageError] = useState('');
const [blogText, setBlogText] = useState(PLACEHOLDER_BLOG);
const [activeTab, setActiveTab] = useState<'image' | 'text'>('image');
const activeChannel = CHANNEL_OPTIONS.find(c => c.channel === studioState.channel);
const activeFormat = activeChannel?.formats.find(f => f.key === studioState.format);
const handleGenerateImage = useCallback(async () => {
setImageStatus('generating');
setImageError('');
try {
const res = await generateImage(studioState);
setImageResult(res);
setImageStatus('done');
} catch (err) {
setImageError(err instanceof Error ? err.message : '이미지 생성 실패');
setImageStatus('error');
}
}, [studioState]);
return (
<div>
{/* Tabs */}
<div className="flex gap-2 mb-8">
{([
{ key: 'image' as const, label: '대표 이미지' },
{ key: 'text' as const, label: '블로그 텍스트' },
]).map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex items-center gap-2 px-5 py-3 rounded-full text-sm font-medium transition-all ${
activeTab === tab.key
? 'bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white shadow-md'
: 'bg-white border border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
>
{tab.label}
</button>
))}
</div>
{activeTab === 'image' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Image generation */}
<div>
<h3 className="font-serif font-bold text-2xl text-[#0A1128] mb-2"> </h3>
<p className="text-sm text-slate-500 mb-6"> AI </p>
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-5 mb-4 space-y-3">
<SummaryRow label="포맷" value={activeFormat?.label ?? '-'} />
<SummaryRow label="비율" value="16:9 (블로그 헤더)" />
<SummaryRow label="스타일" value="프리미엄 의료 마케팅" />
</div>
{(imageStatus === 'idle' || imageStatus === 'error') && (
<button
onClick={handleGenerateImage}
className="w-full flex items-center justify-center gap-2 py-4 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white text-sm font-medium shadow-md hover:shadow-lg transition-all"
>
<FileTextFilled size={18} className="text-white" />
{imageStatus === 'error' ? '다시 시도' : '이미지 생성'}
</button>
)}
{imageStatus === 'error' && (
<p className="mt-3 text-sm text-[#7C3A4B] text-center">{imageError}</p>
)}
{imageStatus === 'done' && (
<button
onClick={() => { setImageStatus('idle'); setImageResult(null); }}
className="w-full py-3 rounded-full bg-white border border-slate-200 text-slate-600 text-sm font-medium shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:bg-slate-50 transition-all"
>
</button>
)}
</div>
{/* Image preview */}
<div>
<h3 className="font-serif font-bold text-2xl text-[#0A1128] mb-4"></h3>
<div className="w-full aspect-video rounded-2xl overflow-hidden border border-slate-200 bg-slate-50 flex items-center justify-center relative">
{imageStatus === 'idle' && (
<div className="text-center px-6">
<div className="w-16 h-16 rounded-2xl bg-[#F3F0FF] flex items-center justify-center mx-auto mb-4">
<FileTextFilled size={28} className="text-[#9B8AD4]" />
</div>
<p className="text-sm text-slate-400"> </p>
</div>
)}
{imageStatus === 'generating' && (
<div className="text-center">
<div className="w-12 h-12 border-4 border-[#6C5CE7] border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-sm text-slate-500"> ...</p>
</div>
)}
{imageStatus === 'error' && (
<p className="text-sm text-[#7C3A4B]">{imageError}</p>
)}
{imageStatus === 'done' && imageResult?.imageDataUrl && (
<motion.img
src={imageResult.imageDataUrl}
alt="Blog header"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="absolute inset-0 w-full h-full object-cover"
/>
)}
</div>
</div>
</div>
)}
{activeTab === 'text' && (
<div>
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="font-serif font-bold text-2xl text-[#0A1128]"> </h3>
<p className="text-sm text-slate-500 mt-1">AI (Markdown )</p>
</div>
<div className="flex gap-2">
<span className="px-3 py-1 rounded-full bg-[#F3F0FF] text-[#4A3A7C] text-xs font-medium border border-[#D5CDF5]">
{blogText.length.toLocaleString()}
</span>
<span className="px-3 py-1 rounded-full bg-[#EFF0FF] text-[#3A3F7C] text-xs font-medium border border-[#C5CBF5]">
SEO
</span>
</div>
</div>
<textarea
value={blogText}
onChange={(e) => setBlogText(e.target.value)}
className="w-full min-h-[500px] p-6 rounded-2xl border border-slate-200 bg-white shadow-[3px_4px_12px_rgba(0,0,0,0.06)] text-sm text-slate-700 leading-relaxed font-mono resize-y focus:outline-none focus:border-[#6C5CE7] focus:ring-1 focus:ring-[#6C5CE7]/20 transition-all"
placeholder="블로그 콘텐츠를 입력하세요..."
/>
<div className="flex gap-3 mt-6">
<button className="flex-1 py-3 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white text-sm font-medium shadow-md hover:shadow-lg transition-all">
</button>
<button className="flex-1 py-3 rounded-full bg-white border border-slate-200 text-slate-600 text-sm font-medium shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:bg-slate-50 transition-all">
AI
</button>
</div>
</div>
)}
</div>
);
}
function SummaryRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between py-2 border-b border-slate-50 last:border-0">
<span className="text-sm text-slate-500">{label}</span>
<span className="text-sm font-medium text-[#0A1128]">{value}</span>
</div>
);
}

View File

@ -0,0 +1,128 @@
import { type ComponentType } from 'react';
import {
YoutubeFilled,
InstagramFilled,
FacebookFilled,
GlobeFilled,
TiktokFilled,
} from '../icons/FilledIcons';
import { CHANNEL_OPTIONS, type StudioChannel, type ContentFormat } from '../../types/studio';
interface Props {
selectedChannel: StudioChannel | null;
selectedFormat: ContentFormat | null;
onChannelSelect: (ch: StudioChannel | null) => void;
onFormatSelect: (fmt: ContentFormat | null) => void;
}
const iconMap: Record<string, ComponentType<{ size?: number; className?: string }>> = {
youtube: YoutubeFilled,
instagram: InstagramFilled,
facebook: FacebookFilled,
globe: GlobeFilled,
tiktok: TiktokFilled,
};
export default function ChannelFormatStep({ selectedChannel, selectedFormat, onChannelSelect, onFormatSelect }: Props) {
const activeChannel = CHANNEL_OPTIONS.find(c => c.channel === selectedChannel);
return (
<div>
{/* Channel Selection */}
<div className="mb-10">
<h3 className="font-serif font-bold text-2xl text-[#0A1128] mb-2"> </h3>
<p className="text-sm text-slate-500 mb-6"> </p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
{CHANNEL_OPTIONS.map(ch => {
const Icon = iconMap[ch.icon] ?? GlobeFilled;
const isSelected = selectedChannel === ch.channel;
return (
<button
key={ch.channel}
onClick={() => {
onChannelSelect(isSelected ? null : ch.channel);
onFormatSelect(null);
}}
className={`relative flex flex-col items-center gap-3 p-6 rounded-2xl border-2 transition-all ${
isSelected
? 'border-[#6C5CE7] bg-[#F3F0FF]/30 shadow-[3px_4px_12px_rgba(108,92,231,0.12)]'
: 'border-slate-100 bg-white shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:border-[#D5CDF5]'
}`}
>
{isSelected && (
<div className="absolute top-3 right-3 w-5 h-5 rounded-full bg-[#6C5CE7] flex items-center justify-center">
<svg width="10" height="10" viewBox="0 0 14 14" fill="none">
<path d="M2 7L5.5 10.5L12 3.5" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
)}
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
isSelected ? 'bg-[#F3F0FF]' : 'bg-slate-50'
}`}>
<Icon size={24} className={isSelected ? 'text-[#6C5CE7]' : 'text-[#9B8AD4]'} />
</div>
<span className={`text-sm font-semibold ${
isSelected ? 'text-[#0A1128]' : 'text-slate-600'
}`}>
{ch.label}
</span>
</button>
);
})}
</div>
</div>
{/* Format Selection */}
{activeChannel && (
<div>
<h3 className="font-serif font-bold text-2xl text-[#0A1128] mb-2"> </h3>
<p className="text-sm text-slate-500 mb-6">{activeChannel.label} </p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
{activeChannel.formats.map(fmt => {
const isSelected = selectedFormat === fmt.key;
return (
<button
key={fmt.key}
onClick={() => onFormatSelect(isSelected ? null : fmt.key)}
className={`relative flex flex-col items-center gap-3 p-5 rounded-2xl border-2 transition-all ${
isSelected
? 'border-[#6C5CE7] bg-[#F3F0FF]/30 shadow-[3px_4px_12px_rgba(108,92,231,0.12)]'
: 'border-slate-100 bg-white shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:border-[#D5CDF5]'
}`}
>
{isSelected && (
<div className="absolute top-3 right-3 w-5 h-5 rounded-full bg-[#6C5CE7] flex items-center justify-center">
<svg width="10" height="10" viewBox="0 0 14 14" fill="none">
<path d="M2 7L5.5 10.5L12 3.5" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
)}
{/* Aspect ratio preview */}
<div className={`rounded-lg bg-slate-100 border border-slate-200 flex items-center justify-center ${
fmt.aspectRatio === '9:16' ? 'w-10 h-16' :
fmt.aspectRatio === '16:9' ? 'w-16 h-10' :
fmt.aspectRatio === '4:5' ? 'w-12 h-14' :
'w-12 h-12'
}`}>
<span className="text-xs text-slate-400 font-medium">{fmt.aspectRatio}</span>
</div>
<span className={`text-sm font-semibold ${
isSelected ? 'text-[#0A1128]' : 'text-slate-600'
}`}>
{fmt.label}
</span>
</button>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,236 @@
import { useState, useCallback, useRef } from 'react';
import { motion } from 'motion/react';
import { VideoFilled, FileTextFilled } from '../icons/FilledIcons';
import { CHANNEL_OPTIONS, MUSIC_TRACKS, type StudioState, type GenerateOutputType } from '../../types/studio';
import { generateImage, type GenerateResult } from '../../services/geminiImageGen';
interface Props {
studioState: StudioState;
outputType: GenerateOutputType;
onOutputTypeChange: (type: GenerateOutputType) => void;
}
type GenerateStatus = 'idle' | 'generating' | 'done' | 'error';
export default function GeneratePreviewStep({ studioState, outputType, onOutputTypeChange }: Props) {
const [status, setStatus] = useState<GenerateStatus>('idle');
const [result, setResult] = useState<GenerateResult | null>(null);
const [errorMsg, setErrorMsg] = useState('');
const downloadRef = useRef<HTMLAnchorElement>(null);
const activeChannel = CHANNEL_OPTIONS.find(c => c.channel === studioState.channel);
const activeFormat = activeChannel?.formats.find(f => f.key === studioState.format);
const activeTrack = MUSIC_TRACKS.find(t => t.id === studioState.sound.trackId);
const handleGenerate = useCallback(async () => {
setStatus('generating');
setErrorMsg('');
setResult(null);
if (outputType === 'image') {
try {
const res = await generateImage(studioState);
setResult(res);
setStatus('done');
} catch (err) {
setErrorMsg(err instanceof Error ? err.message : 'Image generation failed');
setStatus('error');
}
} else {
// Video: placeholder (Creatomate integration needed)
setTimeout(() => setStatus('done'), 4000);
}
}, [studioState, outputType]);
const handleReset = useCallback(() => {
setStatus('idle');
setResult(null);
setErrorMsg('');
}, []);
const handleDownload = useCallback(() => {
if (!result?.imageDataUrl || !downloadRef.current) return;
const link = downloadRef.current;
link.href = result.imageDataUrl;
link.download = `INFINITH_${activeChannel?.label ?? 'content'}_${activeFormat?.key ?? 'image'}.png`;
link.click();
}, [result, activeChannel, activeFormat]);
const aspectClass =
activeFormat?.aspectRatio === '9:16' ? 'w-[240px] h-[426px]' :
activeFormat?.aspectRatio === '1:1' ? 'w-[340px] h-[340px]' :
activeFormat?.aspectRatio === '4:5' ? 'w-[300px] h-[375px]' :
'w-[426px] h-[240px]';
return (
<div>
{/* Hidden download anchor */}
<a ref={downloadRef} className="hidden" />
{/* Output Type Tabs */}
<div className="flex gap-2 mb-8">
{([
{ key: 'image' as const, label: '이미지 생성', Icon: FileTextFilled },
{ key: 'video' as const, label: '영상 생성', Icon: VideoFilled },
]).map(tab => (
<button
key={tab.key}
onClick={() => { onOutputTypeChange(tab.key); handleReset(); }}
className={`flex items-center gap-2 px-5 py-3 rounded-full text-sm font-medium transition-all ${
outputType === tab.key
? 'bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white shadow-md'
: 'bg-white border border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
>
<tab.Icon size={16} className={outputType === tab.key ? 'text-white' : 'text-[#9B8AD4]'} />
{tab.label}
</button>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Settings Summary */}
<div>
<h3 className="font-serif font-bold text-2xl text-[#0A1128] mb-4"> </h3>
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 space-y-4">
<SummaryRow label="채널" value={activeChannel?.label ?? '-'} />
<SummaryRow label="포맷" value={activeFormat?.label ?? '-'} />
<SummaryRow label="비율" value={activeFormat?.aspectRatio ?? '-'} />
<SummaryRow label="음악" value={activeTrack?.name ?? (studioState.sound.genre === 'none' ? '없음' : '미선택')} />
<SummaryRow label="나레이션" value={studioState.sound.narrationEnabled
? `${studioState.sound.narrationLanguage.toUpperCase()} / ${studioState.sound.narrationVoice === 'female' ? 'Female' : 'Male'}`
: '없음'
} />
<SummaryRow label="자막" value={studioState.sound.subtitleEnabled ? 'ON' : 'OFF'} />
<SummaryRow label="출력" value={outputType === 'image' ? '이미지' : '영상'} />
</div>
{/* Generate Button */}
{(status === 'idle' || status === 'error') && (
<button
onClick={handleGenerate}
className="mt-6 w-full flex items-center justify-center gap-2 py-4 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white text-sm font-medium shadow-md hover:shadow-lg transition-all"
>
{outputType === 'image' ? (
<FileTextFilled size={18} className="text-white" />
) : (
<VideoFilled size={18} className="text-white" />
)}
{status === 'error' ? '다시 시도' : outputType === 'image' ? '이미지 생성' : '영상 생성'}
</button>
)}
{status === 'error' && (
<p className="mt-3 text-sm text-[#7C3A4B] text-center">{errorMsg}</p>
)}
{status === 'done' && (
<div className="mt-6 flex gap-3">
{result?.imageDataUrl && (
<button
onClick={handleDownload}
className="flex-1 py-3 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white text-sm font-medium shadow-md hover:shadow-lg transition-all"
>
</button>
)}
<button
onClick={handleReset}
className="flex-1 py-3 rounded-full bg-white border border-slate-200 text-slate-600 text-sm font-medium shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:bg-slate-50 transition-all"
>
</button>
</div>
)}
</div>
{/* Preview Area */}
<div className="flex flex-col items-center">
<h3 className="font-serif font-bold text-2xl text-[#0A1128] mb-4 self-start"></h3>
<div className={`${aspectClass} rounded-2xl overflow-hidden border border-slate-200 bg-slate-50 flex items-center justify-center relative`}>
{status === 'idle' && (
<div className="text-center px-6">
<div className="w-16 h-16 rounded-2xl bg-[#F3F0FF] flex items-center justify-center mx-auto mb-4">
{outputType === 'image' ? (
<FileTextFilled size={28} className="text-[#9B8AD4]" />
) : (
<VideoFilled size={28} className="text-[#9B8AD4]" />
)}
</div>
<p className="text-sm text-slate-400">
</p>
</div>
)}
{status === 'generating' && (
<div className="text-center">
<div className="w-12 h-12 border-4 border-[#6C5CE7] border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-sm text-slate-500 mb-1">
{outputType === 'image' ? '이미지' : '영상'} ...
</p>
<p className="text-xs text-slate-400">AI </p>
</div>
)}
{status === 'error' && (
<div className="text-center px-6">
<div className="w-14 h-14 rounded-full bg-[#FFF0F0] flex items-center justify-center mx-auto mb-4">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" fill="#D4889A" opacity="0.3" />
<path d="M12 8v4M12 16h.01" stroke="#7C3A4B" strokeWidth="2" strokeLinecap="round" />
</svg>
</div>
<p className="text-sm text-[#7C3A4B]">{errorMsg}</p>
</div>
)}
{status === 'done' && result?.imageDataUrl && (
<motion.img
src={result.imageDataUrl}
alt="Generated content"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5 }}
className="absolute inset-0 w-full h-full object-cover"
/>
)}
{status === 'done' && !result?.imageDataUrl && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5 }}
className="absolute inset-0 bg-gradient-to-br from-[#F3F0FF] via-white to-[#FFF6ED] flex flex-col items-center justify-center p-6"
>
<div className="w-14 h-14 rounded-full bg-[#6C5CE7] flex items-center justify-center mb-4">
<svg width="24" height="24" viewBox="0 0 14 14" fill="none">
<path d="M2 7L5.5 10.5L12 3.5" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<p className="text-lg font-semibold text-[#0A1128] mb-1"> </p>
<p className="text-sm text-slate-500 text-center">
{activeChannel?.label} {activeFormat?.label}
{outputType === 'image' ? ' 이미지' : ' 영상'}
</p>
<div className="mt-4 px-4 py-2 rounded-full bg-[#F3F0FF] text-[#4A3A7C] text-xs font-medium border border-[#D5CDF5]">
{activeFormat?.aspectRatio} | {outputType === 'image' ? 'PNG' : 'MP4'}
</div>
</motion.div>
)}
</div>
</div>
</div>
</div>
);
}
function SummaryRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between py-2 border-b border-slate-50 last:border-0">
<span className="text-sm text-slate-500">{label}</span>
<span className="text-sm font-medium text-[#0A1128]">{value}</span>
</div>
);
}

View File

@ -0,0 +1,171 @@
import { MusicFilled, MessageFilled } from '../icons/FilledIcons';
import { MUSIC_TRACKS, type SoundSettings, type MusicGenre, type NarrationLanguage, type NarrationVoice } from '../../types/studio';
interface Props {
sound: SoundSettings;
onGenreChange: (genre: MusicGenre) => void;
onTrackChange: (trackId: string | null) => void;
onNarrationToggle: (enabled: boolean) => void;
onLanguageChange: (lang: NarrationLanguage) => void;
onVoiceChange: (voice: NarrationVoice) => void;
onSubtitleToggle: (enabled: boolean) => void;
}
const GENRES: { key: MusicGenre; label: string }[] = [
{ key: 'calm', label: 'Calm' },
{ key: 'upbeat', label: 'Upbeat' },
{ key: 'cinematic', label: 'Cinematic' },
{ key: 'corporate', label: 'Corporate' },
{ key: 'none', label: 'No Music' },
];
const LANGUAGES: { key: NarrationLanguage; label: string }[] = [
{ key: 'ko', label: '한국어' },
{ key: 'en', label: 'English' },
{ key: 'ja', label: '日本語' },
{ key: 'zh', label: '中文' },
];
export default function SoundStudioStep({ sound, onGenreChange, onTrackChange, onNarrationToggle, onLanguageChange, onVoiceChange, onSubtitleToggle }: Props) {
const filteredTracks = MUSIC_TRACKS.filter(t => t.genre === sound.genre);
return (
<div className="space-y-10">
{/* Music Genre */}
<div>
<h3 className="font-serif font-bold text-2xl text-[#0A1128] mb-2"> </h3>
<p className="text-sm text-slate-500 mb-6"> </p>
<div className="flex flex-wrap gap-2 mb-6">
{GENRES.map(g => (
<button
key={g.key}
onClick={() => onGenreChange(g.key)}
className={`px-5 py-3 rounded-full text-sm font-medium transition-all ${
sound.genre === g.key
? 'bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white shadow-md'
: 'bg-white border border-slate-200 text-slate-600 hover:bg-slate-50 shadow-[2px_3px_6px_rgba(0,0,0,0.04)]'
}`}
>
{g.label}
</button>
))}
</div>
{/* Track List */}
{sound.genre !== 'none' && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{filteredTracks.map(track => {
const isSelected = sound.trackId === track.id;
return (
<button
key={track.id}
onClick={() => onTrackChange(isSelected ? null : track.id)}
className={`flex items-center gap-3 p-4 rounded-2xl border-2 transition-all text-left ${
isSelected
? 'border-[#6C5CE7] bg-[#F3F0FF]/30 shadow-[3px_4px_12px_rgba(108,92,231,0.12)]'
: 'border-slate-100 bg-white shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:border-[#D5CDF5]'
}`}
>
<div className={`w-10 h-10 rounded-xl flex items-center justify-center shrink-0 ${
isSelected ? 'bg-[#F3F0FF]' : 'bg-slate-50'
}`}>
<MusicFilled size={18} className={isSelected ? 'text-[#6C5CE7]' : 'text-[#9B8AD4]'} />
</div>
<div className="min-w-0">
<p className={`text-sm font-semibold truncate ${isSelected ? 'text-[#0A1128]' : 'text-slate-700'}`}>
{track.name}
</p>
<p className="text-xs text-slate-400">{track.duration}</p>
</div>
</button>
);
})}
</div>
)}
</div>
{/* Narration */}
<div>
<div className="flex items-center gap-4 mb-6">
<h3 className="font-serif font-bold text-2xl text-[#0A1128]"></h3>
<button
onClick={() => onNarrationToggle(!sound.narrationEnabled)}
className={`relative w-12 h-6 rounded-full transition-colors ${
sound.narrationEnabled ? 'bg-[#6C5CE7]' : 'bg-slate-200'
}`}
>
<div className={`absolute top-1 w-5 h-5 rounded-full bg-white shadow transition-transform ${
sound.narrationEnabled ? 'translate-x-6' : 'translate-x-1'
}`} />
</button>
</div>
{sound.narrationEnabled && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{/* Language */}
<div>
<p className="text-sm font-medium text-slate-600 mb-3"></p>
<div className="flex flex-wrap gap-2">
{LANGUAGES.map(lang => (
<button
key={lang.key}
onClick={() => onLanguageChange(lang.key)}
className={`px-4 py-2 rounded-full text-sm font-medium transition-all ${
sound.narrationLanguage === lang.key
? 'bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white shadow-md'
: 'bg-white border border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
>
{lang.label}
</button>
))}
</div>
</div>
{/* Voice */}
<div>
<p className="text-sm font-medium text-slate-600 mb-3"></p>
<div className="flex gap-3">
{(['female', 'male'] as NarrationVoice[]).map(voice => (
<button
key={voice}
onClick={() => onVoiceChange(voice)}
className={`flex items-center gap-2 px-5 py-3 rounded-2xl border-2 transition-all ${
sound.narrationVoice === voice
? 'border-[#6C5CE7] bg-[#F3F0FF]/30 shadow-[3px_4px_12px_rgba(108,92,231,0.12)]'
: 'border-slate-100 bg-white shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:border-[#D5CDF5]'
}`}
>
<MessageFilled size={18} className={sound.narrationVoice === voice ? 'text-[#6C5CE7]' : 'text-[#9B8AD4]'} />
<span className="text-sm font-medium">{voice === 'female' ? 'Female' : 'Male'}</span>
</button>
))}
</div>
</div>
</div>
)}
</div>
{/* Subtitle */}
<div>
<div className="flex items-center gap-4">
<h3 className="font-serif font-bold text-2xl text-[#0A1128]"></h3>
<button
onClick={() => onSubtitleToggle(!sound.subtitleEnabled)}
className={`relative w-12 h-6 rounded-full transition-colors ${
sound.subtitleEnabled ? 'bg-[#6C5CE7]' : 'bg-slate-200'
}`}
>
<div className={`absolute top-1 w-5 h-5 rounded-full bg-white shadow transition-transform ${
sound.subtitleEnabled ? 'translate-x-6' : 'translate-x-1'
}`} />
</button>
<span className="text-sm text-slate-500">
{sound.subtitleEnabled ? '자막이 영상에 포함됩니다' : '자막 없음'}
</span>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,109 @@
import type { AssetSourceType } from '../../types/studio';
interface Props {
selectedPillar: string | null;
selectedSources: AssetSourceType[];
onPillarSelect: (id: string | null) => void;
onSourceToggle: (source: AssetSourceType) => void;
}
const PILLARS = [
{ id: 'safety', title: '안전과 신뢰', description: '수술 안전 시스템, 21년 무사고, CCTV, 마취과 전문의 상주', color: '#6C5CE7' },
{ id: 'expertise', title: '전문성과 기술력', description: '분야별 전문의, 최신 장비, 논문 실적, 학회 활동', color: '#7A84D4' },
{ id: 'results', title: '자연스러운 결과', description: '비포/애프터, 환자 후기, 한번에 성공하는 성형', color: '#9B8AD4' },
{ id: 'care', title: '환자 중심 케어', description: '상담 프로세스, 사후 관리, 1:1 맞춤 플랜', color: '#D4A872' },
];
const SOURCES: { key: AssetSourceType; title: string; description: string }[] = [
{ key: 'collected', title: '수집된 에셋', description: '홈페이지, 블로그, SNS에서 수집한 기존 에셋' },
{ key: 'my_assets', title: 'My Assets', description: '직접 업로드한 이미지, 영상, 텍스트 파일' },
{ key: 'ai_generated', title: 'AI 생성', description: 'AI가 새로 생성하는 이미지, 텍스트, 영상' },
];
export default function StrategySourceStep({ selectedPillar, selectedSources, onPillarSelect, onSourceToggle }: Props) {
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-10">
{/* Pillar Selection */}
<div>
<h3 className="font-serif font-bold text-2xl text-[#0A1128] mb-2"> </h3>
<p className="text-sm text-slate-500 mb-6"> </p>
<div className="space-y-3">
{PILLARS.map(pillar => {
const isSelected = selectedPillar === pillar.id;
return (
<button
key={pillar.id}
onClick={() => onPillarSelect(isSelected ? null : pillar.id)}
className={`w-full text-left relative p-5 rounded-2xl border-2 transition-all ${
isSelected
? 'border-[#6C5CE7] bg-[#F3F0FF]/30 shadow-[3px_4px_12px_rgba(108,92,231,0.12)]'
: 'border-slate-100 bg-white shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:border-[#D5CDF5]'
}`}
>
{isSelected && (
<div className="absolute top-4 right-4 w-5 h-5 rounded-full bg-[#6C5CE7] flex items-center justify-center">
<svg width="10" height="10" viewBox="0 0 14 14" fill="none">
<path d="M2 7L5.5 10.5L12 3.5" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
)}
<div className="flex items-start gap-3">
<div
className="w-1 h-10 rounded-full shrink-0 mt-1"
style={{ backgroundColor: pillar.color }}
/>
<div>
<h4 className="font-semibold text-[#0A1128] mb-1">{pillar.title}</h4>
<p className="text-sm text-slate-500">{pillar.description}</p>
</div>
</div>
</button>
);
})}
</div>
</div>
{/* Source Selection */}
<div>
<h3 className="font-serif font-bold text-2xl text-[#0A1128] mb-2"> </h3>
<p className="text-sm text-slate-500 mb-6"> ( )</p>
<div className="space-y-3">
{SOURCES.map(source => {
const isSelected = selectedSources.includes(source.key);
return (
<button
key={source.key}
onClick={() => onSourceToggle(source.key)}
className={`w-full text-left relative p-5 rounded-2xl border-2 transition-all ${
isSelected
? 'border-[#6C5CE7] bg-[#F3F0FF]/30 shadow-[3px_4px_12px_rgba(108,92,231,0.12)]'
: 'border-slate-100 bg-white shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:border-[#D5CDF5]'
}`}
>
<div className="flex items-center gap-3">
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center shrink-0 transition-all ${
isSelected
? 'border-[#6C5CE7] bg-[#6C5CE7]'
: 'border-slate-300 bg-white'
}`}>
{isSelected && (
<svg width="10" height="10" viewBox="0 0 14 14" fill="none">
<path d="M2 7L5.5 10.5L12 3.5" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</div>
<div>
<h4 className="font-semibold text-[#0A1128] mb-1">{source.title}</h4>
<p className="text-sm text-slate-500">{source.description}</p>
</div>
</div>
</button>
);
})}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,234 @@
import { useState, useCallback, useMemo } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import type { StudioState, StudioChannel, ContentFormat, AssetSourceType, MusicGenre, NarrationLanguage, NarrationVoice, GenerateOutputType } from '../../types/studio';
import { DEFAULT_SOUND } from '../../types/studio';
import ChannelFormatStep from './ChannelFormatStep';
import StrategySourceStep from './StrategySourceStep';
import SoundStudioStep from './SoundStudioStep';
import GeneratePreviewStep from './GeneratePreviewStep';
import BlogEditorStep from './BlogEditorStep';
interface StepDef {
key: string;
label: string;
}
const VIDEO_STEPS: StepDef[] = [
{ key: 'channel', label: '채널 선택' },
{ key: 'strategy', label: '전략 선택' },
{ key: 'sound', label: '사운드' },
{ key: 'generate', label: '생성' },
];
const BLOG_STEPS: StepDef[] = [
{ key: 'channel', label: '채널 선택' },
{ key: 'strategy', label: '전략 선택' },
{ key: 'blog', label: '콘텐츠 생성' },
];
function isBlogChannel(channel: StudioChannel | null): boolean {
return channel === 'naver_blog';
}
export default function StudioWizard() {
const [step, setStep] = useState(0);
const [state, setState] = useState<StudioState>({
channel: null,
format: null,
pillarId: null,
assetSources: [],
sound: DEFAULT_SOUND,
outputType: 'video',
});
const steps = useMemo(() => isBlogChannel(state.channel) ? BLOG_STEPS : VIDEO_STEPS, [state.channel]);
const setChannel = useCallback((channel: StudioChannel | null) => {
setState(s => ({
...s,
channel,
format: null,
outputType: channel === 'naver_blog' ? 'image' : 'video',
}));
// Reset step if switching between blog/video flow
setStep(0);
}, []);
const setFormat = useCallback((format: ContentFormat | null) => {
setState(s => ({ ...s, format }));
}, []);
const setPillar = useCallback((pillarId: string | null) => {
setState(s => ({ ...s, pillarId }));
}, []);
const toggleAssetSource = useCallback((source: AssetSourceType) => {
setState(s => ({
...s,
assetSources: s.assetSources.includes(source)
? s.assetSources.filter(x => x !== source)
: [...s.assetSources, source],
}));
}, []);
const setGenre = useCallback((genre: MusicGenre) => {
setState(s => ({ ...s, sound: { ...s.sound, genre, trackId: null } }));
}, []);
const setTrack = useCallback((trackId: string | null) => {
setState(s => ({ ...s, sound: { ...s.sound, trackId } }));
}, []);
const setNarration = useCallback((enabled: boolean) => {
setState(s => ({ ...s, sound: { ...s.sound, narrationEnabled: enabled } }));
}, []);
const setNarrationLang = useCallback((lang: NarrationLanguage) => {
setState(s => ({ ...s, sound: { ...s.sound, narrationLanguage: lang } }));
}, []);
const setNarrationVoice = useCallback((voice: NarrationVoice) => {
setState(s => ({ ...s, sound: { ...s.sound, narrationVoice: voice } }));
}, []);
const setSubtitle = useCallback((enabled: boolean) => {
setState(s => ({ ...s, sound: { ...s.sound, subtitleEnabled: enabled } }));
}, []);
const setOutputType = useCallback((type: GenerateOutputType) => {
setState(s => ({ ...s, outputType: type }));
}, []);
const currentStepKey = steps[step]?.key;
const canProceed = (): boolean => {
switch (currentStepKey) {
case 'channel': return state.channel !== null && state.format !== null;
case 'strategy': return state.pillarId !== null && state.assetSources.length > 0;
case 'sound': return true;
case 'generate': return true;
case 'blog': return true;
default: return false;
}
};
return (
<div className="min-h-screen pt-24 pb-32 px-6">
<div className="max-w-5xl mx-auto">
{/* Progress Bar */}
<div className="flex items-center justify-center mb-12">
{steps.map((s, i) => (
<div key={s.key} className="flex items-center">
<button
onClick={() => i < step && setStep(i)}
className={`flex items-center gap-2 transition-all ${i <= step ? 'cursor-pointer' : 'cursor-default'}`}
>
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold transition-all ${
i < step
? 'bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white'
: i === step
? 'border-2 border-[#6C5CE7] text-[#6C5CE7] bg-white'
: 'border-2 border-slate-200 text-slate-400 bg-white'
}`}>
{i < step ? (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M2 7L5.5 10.5L12 3.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : (
i + 1
)}
</div>
<span className={`text-sm font-medium hidden md:inline ${
i <= step ? 'text-[#0A1128]' : 'text-slate-400'
}`}>
{s.label}
</span>
</button>
{i < steps.length - 1 && (
<div className={`w-12 md:w-20 h-1 mx-2 transition-colors ${
i < step ? 'bg-[#6C5CE7]' : 'bg-slate-200'
}`} />
)}
</div>
))}
</div>
{/* Step Content */}
<AnimatePresence mode="wait">
<motion.div
key={`${currentStepKey}-${step}`}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
>
{currentStepKey === 'channel' && (
<ChannelFormatStep
selectedChannel={state.channel}
selectedFormat={state.format}
onChannelSelect={setChannel}
onFormatSelect={setFormat}
/>
)}
{currentStepKey === 'strategy' && (
<StrategySourceStep
selectedPillar={state.pillarId}
selectedSources={state.assetSources}
onPillarSelect={setPillar}
onSourceToggle={toggleAssetSource}
/>
)}
{currentStepKey === 'sound' && (
<SoundStudioStep
sound={state.sound}
onGenreChange={setGenre}
onTrackChange={setTrack}
onNarrationToggle={setNarration}
onLanguageChange={setNarrationLang}
onVoiceChange={setNarrationVoice}
onSubtitleToggle={setSubtitle}
/>
)}
{currentStepKey === 'generate' && (
<GeneratePreviewStep
studioState={state}
outputType={state.outputType}
onOutputTypeChange={setOutputType}
/>
)}
{currentStepKey === 'blog' && (
<BlogEditorStep studioState={state} />
)}
</motion.div>
</AnimatePresence>
{/* Bottom Navigation */}
<div className="flex items-center justify-between mt-12">
<button
onClick={() => setStep(s => Math.max(0, s - 1))}
disabled={step === 0}
className="flex items-center gap-2 px-5 py-3 rounded-full text-sm font-medium text-slate-600 bg-white border border-slate-200 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:bg-slate-50 transition-all disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronLeft size={16} />
</button>
{step < steps.length - 1 ? (
<button
onClick={() => setStep(s => Math.min(steps.length - 1, s + 1))}
disabled={!canProceed()}
className="flex items-center gap-2 px-6 py-3 rounded-full text-sm font-medium text-white bg-gradient-to-r from-[#4F1DA1] to-[#021341] shadow-md hover:shadow-lg transition-all disabled:opacity-40 disabled:cursor-not-allowed"
>
<ChevronRight size={16} />
</button>
) : (
<div />
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,41 @@
import { createContext, useContext, useMemo, type ReactNode } from 'react';
import type { ScreenshotEvidence } from '../types/report';
interface ScreenshotContextValue {
screenshots: ScreenshotEvidence[];
getById: (id: string) => ScreenshotEvidence | undefined;
getByIds: (ids: string[]) => ScreenshotEvidence[];
}
const ScreenshotContext = createContext<ScreenshotContextValue>({
screenshots: [],
getById: () => undefined,
getByIds: () => [],
});
export function ScreenshotProvider({
screenshots,
children,
}: {
screenshots: ScreenshotEvidence[];
children: ReactNode;
}) {
const value = useMemo(() => {
const map = new Map(screenshots.map((s) => [s.id, s]));
return {
screenshots,
getById: (id: string) => map.get(id),
getByIds: (ids: string[]) => ids.map((id) => map.get(id)).filter(Boolean) as ScreenshotEvidence[],
};
}, [screenshots]);
return (
<ScreenshotContext.Provider value={value}>
{children}
</ScreenshotContext.Provider>
);
}
export function useScreenshots() {
return useContext(ScreenshotContext);
}

272
src/data/mockPlan.ts Normal file
View File

@ -0,0 +1,272 @@
import type { MarketingPlan } from '../types/plan';
export const mockPlan: 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'] },
],
},
};

500
src/data/mockReport.ts Normal file
View File

@ -0,0 +1,500 @@
import type { MarketingReport } from '../types/report';
export const mockReport: MarketingReport = {
id: 'view-clinic',
createdAt: '2026-03-22',
targetUrl: 'https://www.viewclinic.com',
overallScore: 62,
clinicSnapshot: {
name: '뷰성형외과의원',
nameEn: 'VIEW Plastic Surgery',
established: '2005',
yearsInBusiness: 21,
staffCount: 28,
leadDoctor: {
name: '최순우',
credentials: '서울대 출신, 의학박사',
rating: 4.7, // TODO: 강남언니 실제 의료진 평점 확인
reviewCount: 1809,
},
overallRating: 4.8, // TODO: 강남언니 실제 병원 평점 확인 (5.0 만점)
totalReviews: 18840,
priceRange: { min: '97,900', max: '13,200,000+', currency: '₩' },
certifications: [
'수술실 CCTV',
'전담 마취과 전문의',
'응급대응 시스템',
'여의사 상담',
'보건복지부장관 표창',
'안면윤곽 수상',
'모티바 사용량 1위',
'19층 안전스마트 빌딩',
'렛미인 출연',
'All-In-One 시스템',
],
mediaAppearances: ['렛미인 TV 프로그램', '보건복지부장관 표창', '안면윤곽 수상'],
medicalTourism: ['VisitKorea 등재', '강남 메디컬투어센터 협력기관', '외국인 전용 서비스'],
location: '서울시 강남구 봉은사로 107 (논현동)',
nearestStation: '9호선 신논현역 3번 출구 50m',
phone: '02-539-1177',
domain: 'viewclinic.com',
logoImages: {
circle: '/assets/clients/view-clinic/logo-circle.png',
horizontal: '/assets/clients/view-clinic/logo-horizontal.png',
korean: '/assets/clients/view-clinic/logo-korean.png',
},
brandColors: {
primary: '#7B2D8E',
accent: '#E8B931',
text: '#6B2D7B',
},
},
channelScores: [
{ channel: 'YouTube', icon: 'youtube', score: 65, maxScore: 100, status: 'warning', headline: '103K 구독자, 조회수 하락세' },
{ channel: 'Instagram KR', icon: 'instagram', score: 35, maxScore: 100, status: 'critical', headline: '14K 팔로워, Reels 0개' },
{ channel: 'Instagram EN', icon: 'instagram', score: 55, maxScore: 100, status: 'warning', headline: '68.8K 팔로워, 활발한 편' },
{ channel: 'Facebook', icon: 'facebook', score: 40, maxScore: 100, status: 'critical', headline: '브랜드 불일치, 계정 분산' },
{ channel: '강남언니', icon: 'star', score: 95, maxScore: 100, status: 'excellent', headline: '4.8점, 18,840 리뷰' },
{ channel: 'Website', icon: 'globe', score: 50, maxScore: 100, status: 'warning', headline: 'SNS 연결 없음, 트래킹만 존재' },
],
youtubeAudit: {
channelName: '뷰성형외과 VIEW Plastic Surgery',
handle: '@ViewclinicKR',
subscribers: 103000,
totalVideos: 1064,
totalViews: 9952722,
weeklyViewGrowth: { absolute: 67097, percentage: 4.09 },
estimatedMonthlyRevenue: { min: 499, max: 1000 },
avgVideoLength: '4.4분',
uploadFrequency: '~주 1회',
channelCreatedDate: '2015-06-29',
subscriberRank: '#570K',
channelDescription: '💜뷰성형외과💜\nVIEW가 예술이다! ✨\n19층 규모의 안전스마트 빌딩\n환자의 관점에서 생각하고\n환자의 입장에서 아름다움의 가치를 찾습니다.',
linkedUrls: [
{ label: '뷰성형외과 홈페이지', url: 'viewclinic.com' },
{ label: 'Instagram', url: 'instagram.com/viewplastic' },
{ label: '이벤트 보기', url: 'viewclinic.com/board/events' },
{ label: '상담 예약', url: 'viewclinic.com/counsel/reservation' },
{ label: '카톡 상담', url: 'pf.kakao.com/_xbtVxjl' },
],
playlists: [
'VIEW 💜 무엇이든 물어보세요',
'VIEW 💜 재수술',
'VIEW 💜 가슴',
'VIEW 💜 눈+코',
'VIEW 💜 윤곽+양악',
'VIEW 💜 지방성형',
'VIEW 💜 피부+안티에이징',
'VIEW랜딩 💜',
'VIEW 💜 방송영상',
],
topVideos: [
{ title: '한번에 성공하는 성형', views: 574000, uploadedAgo: '4년 전', type: 'Short' },
{ title: '코성형+지방이식 전후', views: 525000, uploadedAgo: '4년 전', type: 'Short' },
{ title: '쌍수+뒤밑트임 전후', views: 392000, uploadedAgo: '3년 전', type: 'Short' },
{ title: 'V라인턱 변신과정 전격공개', views: 194000, uploadedAgo: '4년 전', type: 'Short' },
{ title: 'K-미녀 클라스', views: 161000, uploadedAgo: '4년 전', type: 'Short' },
{ title: '앞트임하면 대박나는 사람', views: 154000, uploadedAgo: '2년 전', type: 'Short' },
{ title: '코성형! 내 얼굴에 가장 예쁜 코 찾아드립니다', views: 124000, uploadedAgo: '3년 전', type: 'Long', duration: '7:59' },
{ title: '아나운서 박은영, 가슴 할 결심을 하다', views: 127000, uploadedAgo: '9개월 전', type: 'Long', duration: '43:39' },
],
diagnosis: [
{ category: '구독자 대비 조회수 비율', detail: '영상당 평균 ~9,300회 (103K 구독자 대비 9% 도달률)', severity: 'critical', evidenceIds: ['yt-channel'] },
{ category: '최근 롱폼 조회수', detail: '대부분 1,000~4,000회 수준', severity: 'critical' },
{ category: 'Shorts 조회수', detail: '최근 업로드 500~1,000회 (과거 대비 급감)', severity: 'warning' },
{ category: '업로드 빈도', detail: '주 1회 — 알고리즘 노출 최소 기준 미달', severity: 'warning' },
{ category: '콘텐츠 톤앤매너', detail: '일관성 없음 — 교육/Q&A/전후/브랜딩 혼재', severity: 'critical' },
{ category: '썸네일 디자인', detail: '통일된 브랜드 시스템 없음', severity: 'warning' },
{ category: '최고 성과 Shorts', detail: '4년 전 콘텐츠 — 최근 재현 실패', severity: 'critical' },
],
},
instagramAudit: {
accounts: [
{
handle: '@viewplastic',
language: 'KR',
label: '국내 (한국어)',
posts: 1409,
followers: 14000,
following: 4760,
category: 'Health/beauty',
profileLink: 'litt.ly/viewplasticsurgery',
highlights: ['수술정보', 'ABOUT VIEW', '모델 모집', 'VIEW EVENT', '진료안내'],
reelsCount: 0,
contentFormat: '카드뉴스 (정보형 이미지) 100%',
profilePhoto: '모델 사진 (브랜드 로고 아님)',
bio: '뷰 성형외과 | 가슴성형 · 안면윤곽 · 눈성형 · 코성형 · 리프팅\n💕신논현역 3번 출구 | 카톡 \'뷰성형외과의원\' | 02-539-1177',
},
{
handle: '@view_plastic_surgery',
language: 'EN',
label: '국제 (영어)',
posts: 2524,
followers: 68800,
following: 2834,
category: 'Health/beauty',
profileLink: 'litt.ly/viewplasticsurgeryenglish',
highlights: ['Mathilde', 'Thet San', 'Katerina', 'Yuri', 'Liposuction', 'Why VIEW?', 'Face Contour'],
reelsCount: 50,
contentFormat: 'Before/After + 환자 스토리 + Reels',
profilePhoto: 'VIEW 골드 로고',
bio: 'VIEW Plastic Surgery Official by VIEW Partners\n⚕ Most Renowned Hospital in Korea\n107 Bongeunsa-ro Gangnam-gu, Seoul, Korea',
},
],
diagnosis: [
{ category: '계정 분리 → 팔로워 분산', detail: 'KR 14K + EN 68.8K = 합산 82.8K이지만 각각 약함', severity: 'warning' },
{ category: 'KR 계정 Reels 전무', detail: '인스타 알고리즘 핵심인 Reels 콘텐츠 0개', severity: 'critical', evidenceIds: ['ig-kr-profile'] },
{ category: '브랜드 비주얼 불일치', detail: 'KR=모델 프사, EN=VIEW 골드 로고', severity: 'warning', evidenceIds: ['ig-kr-profile', 'ig-en-profile'] },
{ category: 'KR 팔로잉 과다', detail: '4,760 팔로잉 — 팔로우백 전략 의심', severity: 'warning' },
{ category: '크로스포스팅 없음', detail: 'YouTube Shorts → Instagram Reels 연동 없음', severity: 'critical' },
{ category: '유튜브 ↔ 인스타 유입 단절', detail: '103K 구독자 → 14K 팔로워 전환 실패', severity: 'critical' },
],
},
facebookAudit: {
pages: [
{
url: 'facebook.com/viewps1',
pageName: '뷰성형외과',
language: 'KR',
label: '국내 (한국어)',
followers: 253,
following: 0,
category: '성형외과 의사',
bio: '예쁨이 일상이 되는 순간! #뷰성형외과',
logo: '일치 (공식 로고)',
logoDescription: '보라색+골드 깃털 공식 로고 사용 — 웹사이트와 동일한 공식 브랜드 자산. 원형 테두리 안에 깃털 심볼 + VIEW / Plastic Surgery 텍스트가 정확히 배치됨.',
link: 'viewclinic.com',
linkedDomain: 'viewclinic.com',
reviews: 0,
recentPostAge: '1일 전',
hasWhatsApp: false,
postFrequency: '주 1~2회 (카드뉴스 크로스포스팅)',
topContentType: 'Instagram 카드뉴스 그대로 복사 게시',
engagement: '게시물당 좋아요 0~3개, 댓글 거의 없음',
},
{
url: 'facebook.com/viewclinic',
pageName: 'View Plastic Surgery',
language: 'EN',
label: '국제 (영어)',
followers: 88000,
following: 11,
category: '건강/뷰티',
bio: 'Official Account by VIEW Partners',
logo: '불일치 (비공식 변형)',
logoDescription: 'VIEW 텍스트 전용 골드 로고 — 공식 깃털 심볼이 빠진 비공식 변형 버전. YouTube, Instagram EN과 동일하지만, 공식 브랜드 가이드(보라색+골드 깃털)와 불일치.',
link: 'viewplasticsurgery.com',
linkedDomain: 'viewplasticsurgery.com (메인 도메인 viewclinic.com과 다름)',
reviews: 3,
recentPostAge: '14분 전',
hasWhatsApp: true,
postFrequency: '일 1~2회 (Before/After, 환자 스토리)',
topContentType: 'Before/After 사진 + 환자 여정 Reels',
engagement: '게시물당 좋아요 50~300개, 댓글 10~50개',
},
],
diagnosis: [
{
category: '채널 간 로고 파편화',
detail: 'Facebook KR만 공식 깃털 로고를 사용하고, EN 페이지는 비공식 VIEW 골드 텍스트 로고를 사용. YouTube, Instagram도 각각 다른 변형 로고 사용 중.',
severity: 'critical',
evidenceIds: ['fb-en-page', 'ig-kr-profile', 'ig-en-profile'],
},
{
category: 'KR 페이지 사실상 방치',
detail: '팔로워 253명, 리뷰 0개, 게시물 참여율 0% — 운영 비용 대비 효과 없음',
severity: 'critical',
},
{
category: '도메인 불일치',
detail: 'KR 페이지 → viewclinic.com, EN 페이지 → viewplasticsurgery.com — 서로 다른 도메인으로 연결, SEO 및 트래픽 분산',
severity: 'warning',
},
{
category: 'KR/EN 팔로워 348:1 격차',
detail: 'EN 88K vs KR 253 — 국내 환자 유입 채널로서 Facebook KR은 완전히 실패',
severity: 'critical',
},
{
category: 'KR 콘텐츠 전략 없음',
detail: 'Instagram 카드뉴스를 그대로 복사 게시 — Facebook 네이티브 콘텐츠(동영상, 이벤트, 그룹) 활용 0%',
severity: 'warning',
},
{
category: 'Facebook Pixel ↔ 페이지 비연동',
detail: '웹사이트에 Facebook Pixel(ID: 299151214739571)이 설치되어 있으나, KR 페이지와의 광고 리타겟 연동 미확인',
severity: 'warning',
},
],
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곳뿐. YouTube, Instagram, Facebook EN은 비공식 변형 로고 사용',
recommendation: '전 채널에 보라색+골드 깃털 공식 로고 통일 (원형: 프로필, 가로형: 배너)',
},
{
field: '연결 도메인',
values: [
{ channel: 'YouTube', value: 'viewclinic.com', isCorrect: true },
{ channel: 'Instagram KR', value: 'litt.ly/viewplasticsurgery', isCorrect: true },
{ channel: 'Instagram EN', value: 'litt.ly/viewplasticsurgeryenglish', isCorrect: true },
{ channel: 'Facebook KR', value: 'viewclinic.com', isCorrect: true },
{ channel: 'Facebook EN', value: 'viewplasticsurgery.com', isCorrect: false },
],
impact: 'EN 페이지가 별도 도메인(viewplasticsurgery.com)으로 연결 → 도메인 권위(Domain Authority) 분산, SEO 불이익',
recommendation: 'viewclinic.com/en 하위 경로로 국제 페이지 통합, 기존 도메인은 301 리다이렉트',
},
{
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개의 서로 다른 소개 메시지 → 통일된 브랜드 포지셔닝 부재, 핵심 USP(안전/21년 무사고) 미전달',
recommendation: '핵심 USP 포함 통일 바이오: "안전이 예술이 되는 곳 — 21년 무사고 VIEW 성형외과"',
},
],
consolidationRecommendation: 'Facebook KR 페이지(253명)는 폐쇄 또는 EN 페이지(88K)로 통합을 권장합니다. KR 페이지는 투자 대비 효과가 사실상 제로이며, 브랜드 혼란만 가중시키고 있습니다. Facebook은 한국 시장에서 오가닉 도달 목적이 아닌, Facebook Pixel 기반 리타겟 광고 전용 채널로 운영하는 것이 효율적입니다.',
},
otherChannels: [
{ name: '카카오톡', status: 'active', details: '상담 전용 채널 운영', url: 'pf.kakao.com/_xbtVxjl' },
{ name: '네이버 블로그', status: 'unknown', details: 'Naver API 연동 필요' },
{ name: '네이버 플레이스', status: 'unknown', details: 'Naver API 연동 필요' },
{ name: 'TikTok', status: 'not_found', details: '계정 없음 또는 비활성' },
{ name: '강남언니', status: 'active', details: '4.8점, 18,840 리뷰, 28 의료진', url: 'gangnamunni.com/hospitals/189' },
{ name: '모두닥', status: 'active', details: '기본 정보 등재' },
{ name: 'Goodoc', status: 'active', details: '기본 정보 등재' },
{ name: '닥터나우', status: 'active', details: '기본 정보 등재' },
],
websiteAudit: {
primaryDomain: 'viewclinic.com',
additionalDomains: [
{ domain: 'viewplasticsurgery.com', purpose: '영문 국제 사이트' },
{ domain: 'viewclinic-chat.com', purpose: '채팅 상담 전용' },
{ domain: 'viewclinic.modoo.at', purpose: '구 모두홈페이지' },
],
snsLinksOnSite: false,
trackingPixels: [
{ name: 'Facebook Pixel', installed: true, details: 'ID: 299151214739571' },
{ name: 'Kakao Pixel', installed: true },
{ name: 'Google Tag Manager', installed: true, details: 'GTM-52RT6DMK' },
],
mainCTA: '전화 + 카카오톡 상담',
},
problemDiagnosis: [
{
category: '브랜드 아이덴티티 파편화',
detail: '공식 깃털 로고(보라색+골드)는 Facebook KR과 웹사이트에만 사용. YouTube/Instagram EN/Facebook EN은 비공식 골드 텍스트 로고, Instagram KR은 모델 사진 사용 — 6개 채널에 4종의 서로 다른 시각적 아이덴티티',
severity: 'critical',
},
{
category: '콘텐츠 전략 부재',
detail: '콘텐츠 캘린더 없음, 톤앤매너 가이드 없음, KR↔EN 시너지 없음, YouTube→Instagram 크로스포스팅 없음',
severity: 'critical',
},
{
category: '플랫폼 간 유입 단절',
detail: 'YouTube 103K → Instagram 14K 전환 실패, 웹사이트에 SNS 링크 0개, 강남언니 18.8K 리뷰→영상 전환 없음',
severity: 'critical',
},
],
transformation: {
brandIdentity: [
{ area: '로고', asIs: '채널마다 다른 로고 4종', toBe: 'VIEW 골드 로고 1종 통일' },
{ area: '컬러 팔레트', asIs: '없음 (혼재)', toBe: 'Primary: Gold (#C4A462) + Dark (#1A1A1A)' },
{ area: '프로필 사진', asIs: 'KR=모델, EN=로고, FB=깃털', toBe: '전 채널 VIEW 골드 로고 통일' },
{ area: '바이오 메시지', asIs: '채널마다 다른 메시지', toBe: '"안전이 예술이 되는 곳 — 21년 무사고 VIEW"' },
{ area: '해시태그', asIs: '비체계적', toBe: '#뷰성형외과 #VIEW성형 #강남성형외과 #21년무사고' },
],
contentStrategy: [
{ area: '콘텐츠 캘린더', asIs: '없음', toBe: '월간 콘텐츠 캘린더 (4주 사이클)' },
{ area: '업로드 빈도', asIs: 'YouTube 주1회, Instagram 비정기', toBe: 'YouTube 주3회 + Instagram 일1회 + Shorts/Reels 주5회' },
{ area: '콘텐츠 포맷', asIs: 'KR Instagram = 카드뉴스만', toBe: '카드뉴스 30% + Reels 40% + 카루셀 20% + Stories 10%' },
{ area: '콘텐츠 앵글', asIs: '시술 정보 중심 (병원 관점)', toBe: '환자 의사결정 보조 중심 (환자 관점)' },
{ area: '톤앤매너', asIs: '없음', toBe: '"차분한 전문가" — 과장 없이, 설명으로 설득' },
],
platformStrategies: [
{
platform: 'YouTube',
icon: 'youtube',
currentMetric: '103K subscribers',
targetMetric: '200K / 12개월',
strategies: [
{ strategy: '업로드 빈도 3배 증가', detail: '주 3회 (롱폼 1 + Shorts 2)' },
{ strategy: '기존 영상 재활용', detail: '1,064개 기존 영상에서 AI 숏폼 100개 추출' },
{ strategy: '썸네일 시스템화', detail: 'VIEW 골드 워터마크 + 일관된 폰트/컬러' },
{ strategy: '커뮤니티 탭 활용', detail: '주 2회 투표/질문 — 구독자 참여 활성화' },
],
},
{
platform: 'Instagram KR',
icon: 'instagram',
currentMetric: '14K followers',
targetMetric: '50K / 12개월',
strategies: [
{ strategy: 'Reels 즉시 시작', detail: 'YouTube Shorts 동시 게시 → 최소 주 5개' },
{ strategy: '프로필 사진 교체', detail: '모델 사진 → VIEW 골드 로고' },
{ strategy: '팔로잉 정리', detail: '4,760 → 300 이하로 정리' },
{ strategy: 'Stories 활성화', detail: '일 2~3개 (상담 비하인드, 병원 일상)' },
],
},
{
platform: 'Facebook',
icon: 'facebook',
currentMetric: 'KR 253 + EN 88K',
targetMetric: '통합 관리',
strategies: [
{ strategy: '계정 통합', detail: 'KR 253명 페이지 → EN 88K 페이지로 통합 또는 폐쇄' },
{ strategy: '로고 통일', detail: '보라색 깃털 → VIEW 골드 로고' },
{ strategy: '역할 정의', detail: 'FB = 광고 랜딩 + 리타겟 전용' },
],
},
],
websiteImprovements: [
{ area: 'SNS 링크', asIs: '홈페이지에 0개', toBe: 'Header/Footer에 YouTube + Instagram + KakaoTalk 링크' },
{ area: 'YouTube 임베드', asIs: '없음', toBe: '시술 페이지별 관련 YouTube 영상 임베드' },
{ area: '콘텐츠 허브', asIs: '없음', toBe: 'SEO 콘텐츠 허브 구축 (시술별 가이드)' },
{ area: '도메인 통합', asIs: '4개 도메인 분산', toBe: 'viewclinic.com 단일 도메인 + /en 국제 페이지' },
],
newChannelProposals: [
{ channel: 'TikTok', priority: 'P1', rationale: '20~30대 첫 수술 고민층 도달, YouTube Shorts 동시 배포' },
{ channel: '네이버 블로그', priority: 'P0', rationale: '한국 검색 1위 플랫폼 — SEO 핵심' },
{ channel: '네이버 플레이스', priority: 'P0', rationale: '지역 검색 노출 필수' },
],
},
roadmap: [
{
month: 1,
title: 'Foundation',
subtitle: '기반 구축',
tasks: [
{ task: '브랜드 아이덴티티 가이드 확정 (로고, 컬러, 폰트, 톤앤매너)', completed: false },
{ task: '전 채널 프로필 사진/배너 통일 교체', completed: false },
{ task: 'Facebook KR 페이지 정리 (통합 또는 폐쇄)', completed: false },
{ task: 'Instagram KR 팔로잉 정리 (4,760 → 300)', completed: false },
{ task: '웹사이트에 YouTube/Instagram 링크 추가', completed: false },
{ task: '기존 YouTube 영상 100개 → AI 숏폼 추출 시작', completed: false },
{ task: '콘텐츠 캘린더 v1 수립', completed: false },
],
},
{
month: 2,
title: 'Content Engine',
subtitle: '콘텐츠 엔진 가동',
tasks: [
{ task: 'YouTube Shorts 주 3~5회 업로드 시작', completed: false },
{ task: 'Instagram Reels 주 5회 업로드 시작', completed: false },
{ task: '원장 촬영 세션 월 2회 스케줄 확정', completed: false },
{ task: '"원장이 설명하는" 시리즈 4편 제작/업로드', completed: false },
{ task: '네이버 블로그 개설 및 시술 가이드 10편 게시', completed: false },
{ task: 'TikTok 계정 개설 및 Shorts 동시 배포', completed: false },
],
},
{
month: 3,
title: 'Optimization',
subtitle: '최적화 & 광고',
tasks: [
{ task: '콘텐츠 성과 분석 리포트 v1', completed: false },
{ task: '고성과 콘텐츠 기반 Instagram/Facebook 광고 세팅', completed: false },
{ task: 'YouTube 썸네일 A/B 테스트', completed: false },
{ task: '콘텐츠 캘린더 v2 (성과 데이터 반영)', completed: false },
{ task: '네이버 플레이스 최적화', completed: false },
{ task: 'KPI 리뷰: 구독자/팔로워 성장률, 상담 전환 추적', completed: false },
],
},
],
kpiDashboard: [
{ metric: 'YouTube 구독자', current: '103K', target3Month: '115K', target12Month: '200K' },
{ metric: 'YouTube 월 조회수', current: '~270K', target3Month: '500K', target12Month: '1.5M' },
{ metric: 'YouTube Shorts 평균 조회수', current: '500~1,000', target3Month: '5,000', target12Month: '20,000' },
{ metric: 'Instagram KR 팔로워', current: '14K', target3Month: '20K', target12Month: '50K' },
{ metric: 'Instagram KR Reels 평균 조회수', current: '0 (없음)', target3Month: '3,000', target12Month: '10,000' },
{ metric: 'Instagram EN 팔로워', current: '68.8K', target3Month: '75K', target12Month: '100K' },
{ metric: '네이버 블로그 방문자', current: '0 (없음)', target3Month: '5,000/월', target12Month: '30,000/월' },
{ metric: '웹사이트 → SNS 유입', current: '0%', target3Month: '5%', target12Month: '15%' },
{ metric: '콘텐츠 → 상담 전환', current: '측정 불가', target3Month: 'UTM 추적 시작', target12Month: '월 50건' },
],
screenshots: [
{
id: 'yt-channel',
url: '/assets/clients/view-clinic/screenshots/yt-channel.svg',
channel: 'YouTube',
capturedAt: '2026-03-22T10:00:00Z',
caption: 'YouTube 채널 메인 — 구독자 103K, 주 1회 업로드, Shorts 조회수 하락세',
sourceUrl: 'https://youtube.com/@ViewclinicKR',
},
{
id: 'yt-about-links',
url: '/assets/clients/view-clinic/screenshots/yt-about-links.svg',
channel: 'YouTube',
capturedAt: '2026-03-22T10:01:00Z',
caption: 'YouTube 정보 탭 — Instagram @viewplastic 연결, Facebook 미연결',
sourceUrl: 'https://youtube.com/@ViewclinicKR/about',
annotations: [
{ type: 'highlight', x: 55, y: 68, width: 30, height: 5, label: 'Instagram 링크' },
],
},
{
id: 'ig-kr-profile',
url: '/assets/clients/view-clinic/screenshots/ig-kr-profile.svg',
channel: 'Instagram',
capturedAt: '2026-03-22T10:02:00Z',
caption: 'Instagram KR (@viewplastic) — 14K 팔로워, 1,409 게시물, Reels 미활용',
sourceUrl: 'https://instagram.com/viewplastic',
},
{
id: 'ig-en-profile',
url: '/assets/clients/view-clinic/screenshots/ig-en-profile.svg',
channel: 'Instagram',
capturedAt: '2026-03-22T10:03:00Z',
caption: 'Instagram EN (@view_plastic_surgery) — 68.9K 팔로워, Reels 활발, 환자 스토리',
sourceUrl: 'https://instagram.com/view_plastic_surgery',
},
{
id: 'fb-en-page',
url: '/assets/clients/view-clinic/screenshots/fb-en-page.svg',
channel: 'Facebook',
capturedAt: '2026-03-22T10:04:00Z',
caption: 'Facebook EN — 타이어 텍스트 Bio, bit.ly 링크, 브랜드 불일치 확인',
sourceUrl: 'https://facebook.com/viewplasticsurgery',
annotations: [
{ type: 'highlight', x: 15, y: 20, width: 25, height: 15, label: '로고 불일치' },
{ type: 'highlight', x: 15, y: 70, width: 20, height: 5, label: 'bit.ly 링크' },
],
},
{
id: 'website-homepage',
url: '/assets/clients/view-clinic/screenshots/website-homepage.svg',
channel: 'Website',
capturedAt: '2026-03-22T10:05:00Z',
caption: 'viewclinic.com 홈페이지 — 팝업 오버레이, SNS 링크 미노출',
sourceUrl: 'https://www.viewclinic.com',
annotations: [
{ type: 'highlight', x: 30, y: 15, width: 40, height: 60, label: '팝업 차단' },
],
},
],
};

214
src/hooks/useExportPDF.ts Normal file
View File

@ -0,0 +1,214 @@
import { useState, useCallback } from 'react';
/**
* PDF Export Strategy:
* Instead of capturing each section as one giant canvas and slicing mid-content,
* we break each section into smaller "sub-blocks" (header, cards, rows, etc.)
* and treat each sub-block atomically never splitting a block across pages.
*
* For sections smaller than one page: render as a single image.
* For sections taller than one page: find natural break points (child elements)
* and capture each child separately, starting a new page when needed.
*/
function collectSubBlocks(section: HTMLElement): HTMLElement[] {
// If the section is short enough to fit one page (~1200px at scale 2),
// treat the whole section as one block
if (section.offsetHeight <= 900) {
return [section];
}
// Otherwise, break into sub-blocks:
// Look for the section header (first child with dark bg or title)
// and then each subsequent child
const children = Array.from(section.children) as HTMLElement[];
if (children.length <= 1) {
return [section]; // Can't break further
}
// Group: keep section header + subtitle together as first block,
// then each remaining child as its own block
const blocks: HTMLElement[] = [];
// Find the section-level wrapper — if it has a header area and a content area
// For our SectionWrapper pattern: first child is usually the header area
for (const child of children) {
if (child.offsetHeight === 0 || child.offsetWidth === 0) continue;
blocks.push(child);
}
return blocks.length > 0 ? blocks : [section];
}
export function useExportPDF() {
const [isExporting, setIsExporting] = useState(false);
const exportPDF = useCallback(async (filename = 'INFINITH_Marketing_Intelligence_Report') => {
setIsExporting(true);
try {
const [{ default: html2canvas }, { jsPDF }] = await Promise.all([
import('html2canvas-pro'),
import('jspdf'),
]);
// 1. Force all animations to their final state
document.documentElement.style.setProperty('--motion-duration', '0s');
const animatedEls: { el: HTMLElement; origOpacity: string; origTransform: string }[] = [];
document.querySelectorAll('[style*="opacity"]').forEach((el) => {
const htmlEl = el as HTMLElement;
animatedEls.push({
el: htmlEl,
origOpacity: htmlEl.style.opacity,
origTransform: htmlEl.style.transform,
});
htmlEl.style.opacity = '1';
htmlEl.style.transform = 'none';
});
// 2. Hide UI-only elements
const hideSelectors = [
'[data-report-nav]',
'[data-plan-nav]',
'nav',
'[data-cta-card]',
'[data-no-print]',
];
const hiddenEls: HTMLElement[] = [];
hideSelectors.forEach((sel) => {
document.querySelectorAll(sel).forEach((el) => {
hiddenEls.push(el as HTMLElement);
(el as HTMLElement).style.display = 'none';
});
});
// 3. Find the content wrapper
const contentEl =
(document.querySelector('[data-report-content]') as HTMLElement) ||
(document.querySelector('[data-plan-content]') as HTMLElement);
if (!contentEl) throw new Error('Report content element not found');
// 4. Setup PDF
const pdf = new jsPDF('p', 'mm', 'a4');
const pageWidth = 210;
const pageHeight = 297;
const margin = 8;
const usableWidth = pageWidth - margin * 2;
const footerSpace = 10;
const maxContentY = pageHeight - margin - footerSpace;
let currentY = margin;
let needsNewPageForNext = false;
const sections = Array.from(contentEl.children) as HTMLElement[];
for (let sIdx = 0; sIdx < sections.length; sIdx++) {
const section = sections[sIdx];
if (section.offsetHeight === 0 || section.offsetWidth === 0) continue;
// Decide: capture whole section or break into sub-blocks
const blocks = collectSubBlocks(section);
for (let bIdx = 0; bIdx < blocks.length; bIdx++) {
const block = blocks[bIdx];
if (block.offsetHeight === 0) continue;
const canvas = await html2canvas(block, {
scale: 2,
useCORS: true,
logging: false,
backgroundColor: null, // Preserve dark section backgrounds
windowWidth: 1280,
removeContainer: true,
});
const blockHeightMM = (canvas.height * usableWidth) / canvas.width;
// Will this block fit on the current page?
if (currentY + blockHeightMM > maxContentY && currentY > margin + 5) {
// Doesn't fit — start a new page
pdf.addPage();
currentY = margin;
}
// If block is STILL taller than one full page, we must slice it
if (blockHeightMM > maxContentY - margin) {
const pxPerMM = canvas.width / usableWidth;
let srcY = 0;
let remainPx = canvas.height;
let isFirstSlice = true;
while (remainPx > 0) {
if (!isFirstSlice) {
pdf.addPage();
currentY = margin;
}
isFirstSlice = false;
const availMM = maxContentY - currentY;
const availPx = availMM * pxPerMM;
const sliceH = Math.min(remainPx, availPx);
const sliceCanvas = document.createElement('canvas');
sliceCanvas.width = canvas.width;
sliceCanvas.height = Math.ceil(sliceH);
const ctx = sliceCanvas.getContext('2d');
if (ctx) {
ctx.drawImage(
canvas,
0, srcY, canvas.width, Math.ceil(sliceH),
0, 0, canvas.width, Math.ceil(sliceH),
);
}
const sliceData = sliceCanvas.toDataURL('image/jpeg', 0.9);
const sliceMM = sliceH / pxPerMM;
pdf.addImage(sliceData, 'JPEG', margin, currentY, usableWidth, sliceMM);
currentY += sliceMM;
srcY += sliceH;
remainPx -= sliceH;
}
} else {
// Normal case: block fits on current page
const imgData = canvas.toDataURL('image/jpeg', 0.9);
pdf.addImage(imgData, 'JPEG', margin, currentY, usableWidth, blockHeightMM);
currentY += blockHeightMM;
}
}
// Small gap between sections
currentY += 2;
}
// 6. Restore hidden elements
hiddenEls.forEach((el) => { el.style.display = ''; });
animatedEls.forEach(({ el, origOpacity, origTransform }) => {
el.style.opacity = origOpacity;
el.style.transform = origTransform;
});
document.documentElement.style.removeProperty('--motion-duration');
// 7. Add page footers
const totalPages = pdf.getNumberOfPages();
for (let i = 1; i <= totalPages; i++) {
pdf.setPage(i);
pdf.setFontSize(7);
pdf.setTextColor(180);
pdf.text(
`INFINITH Marketing Intelligence Report | Page ${i} / ${totalPages}`,
pageWidth / 2,
pageHeight - 5,
{ align: 'center' },
);
}
pdf.save(`${filename}.pdf`);
} catch (err) {
console.error('PDF export failed:', err);
} finally {
setIsExporting(false);
}
}, []);
return { exportPDF, isExporting };
}

View File

@ -0,0 +1,34 @@
import { useState, useEffect } from 'react';
import type { MarketingPlan } from '../types/plan';
import { mockPlan } from '../data/mockPlan';
interface UseMarketingPlanResult {
data: MarketingPlan | null;
isLoading: boolean;
error: string | null;
}
export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult {
const [data, setData] = useState<MarketingPlan | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!id) {
setError('No plan ID provided');
setIsLoading(false);
return;
}
// Phase 1: Return mock data
// Phase 2+: Replace with real API call
const timer = setTimeout(() => {
setData(mockPlan);
setIsLoading(false);
}, 100);
return () => clearTimeout(timer);
}, [id]);
return { data, isLoading, error };
}

34
src/hooks/useReport.ts Normal file
View File

@ -0,0 +1,34 @@
import { useState, useEffect } from 'react';
import type { MarketingReport } from '../types/report';
import { mockReport } from '../data/mockReport';
interface UseReportResult {
data: MarketingReport | null;
isLoading: boolean;
error: string | null;
}
export function useReport(id: string | undefined): UseReportResult {
const [data, setData] = useState<MarketingReport | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!id) {
setError('No report ID provided');
setIsLoading(false);
return;
}
// Phase 1: Return mock data immediately
// Phase 2+: Replace with real API call — fetch(`/api/reports/${id}`)
const timer = setTimeout(() => {
setData(mockReport);
setIsLoading(false);
}, 100);
return () => clearTimeout(timer);
}, [id]);
return { data, isLoading, error };
}

123
src/index.css Normal file
View File

@ -0,0 +1,123 @@
@import "tailwindcss";
@theme {
--font-sans: "Pretendard Variable", "Pretendard", "Inter", ui-sans-serif, system-ui, sans-serif;
--font-serif: "Playfair Display", ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--color-primary-900: #0A1128; /* Dark Navy for text and buttons */
--color-primary-800: #1A2B5E;
--color-primary-50: #F4F6FB;
--color-accent: #6C5CE7; /* Purple accent */
/* INFINITH Semantic Status Colors — pastel palette derived from brand gradient */
/* Critical/Negative: warm rose (peach-toned) */
--color-status-critical-bg: #FFF0F0;
--color-status-critical-text: #7C3A4B;
--color-status-critical-border: #F5D5DC;
--color-status-critical-dot: #D4889A;
/* Warning/Pending: warm sand (cream-toned) */
--color-status-warning-bg: #FFF6ED;
--color-status-warning-text: #7C5C3A;
--color-status-warning-border: #F5E0C5;
--color-status-warning-dot: #D4A872;
/* Good/Positive: soft lavender (purple-toned) */
--color-status-good-bg: #F3F0FF;
--color-status-good-text: #4A3A7C;
--color-status-good-border: #D5CDF5;
--color-status-good-dot: #9B8AD4;
/* Excellent/Info: cool periwinkle (blue-lavender) */
--color-status-info-bg: #EFF0FF;
--color-status-info-text: #3A3F7C;
--color-status-info-border: #C5CBF5;
--color-status-info-dot: #7A84D4;
--animate-blob: blob 7s infinite;
}
@keyframes blob {
0% {
transform: translate(0px, 0px) scale(1);
}
33% {
transform: translate(30px, -50px) scale(1.1);
}
66% {
transform: translate(-20px, 20px) scale(0.9);
}
100% {
transform: translate(0px, 0px) scale(1);
}
}
.animation-delay-2000 {
animation-delay: 2s;
}
.animation-delay-4000 {
animation-delay: 4s;
}
@layer base {
body {
@apply font-sans text-slate-800 bg-slate-50 antialiased;
}
h1, h2, h3, h4, h5, h6 {
@apply font-serif text-primary-900;
}
}
.glass-card {
@apply bg-white/70 backdrop-blur-xl border border-white/40 shadow-[0_8px_32px_0_rgba(31,38,135,0.07)] rounded-2xl;
}
.gradient-bg {
background: radial-gradient(circle at top left, #fdfbfb, #f3e7e9);
@apply relative;
}
.gradient-bg::before {
content: "";
@apply absolute inset-0 bg-white/30 backdrop-blur-[2px] pointer-events-none;
}
.soft-gradient {
background: linear-gradient(145deg, #fdfbfb 0%, #ebedee 100%);
}
.text-gradient {
background: linear-gradient(to right, #0A1128, #3b5998);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
@keyframes blob-large {
0% {
transform: translate(0px, 0px) scale(1);
}
33% {
transform: translate(8vw, -8vh) scale(1.1);
}
66% {
transform: translate(-8vw, 8vh) scale(0.9);
}
100% {
transform: translate(0px, 0px) scale(1);
}
}
.animate-blob-large {
animation: blob-large 25s infinite ease-in-out;
}
.animation-delay-7000 {
animation-delay: 7s;
}
.animation-delay-14000 {
animation-delay: 14s;
}

32
src/main.tsx Normal file
View File

@ -0,0 +1,32 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter, Routes, Route } from 'react-router';
import App from './App.tsx';
import LandingPage from './pages/LandingPage.tsx';
import AnalysisLoadingPage from './pages/AnalysisLoadingPage.tsx';
import ReportPage from './pages/ReportPage.tsx';
import MarketingPlanPage from './pages/MarketingPlanPage.tsx';
import ContentStudioPage from './pages/ContentStudioPage.tsx';
import ChannelConnectPage from './pages/ChannelConnectPage.tsx';
import DistributionPage from './pages/DistributionPage.tsx';
import PerformancePage from './pages/PerformancePage.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<Routes>
<Route element={<App />}>
<Route index element={<LandingPage />} />
<Route path="report/loading" element={<AnalysisLoadingPage />} />
<Route path="report/:id" element={<ReportPage />} />
<Route path="plan/:id" element={<MarketingPlanPage />} />
<Route path="studio/:id" element={<ContentStudioPage />} />
<Route path="channels" element={<ChannelConnectPage />} />
<Route path="distribute" element={<DistributionPage />} />
<Route path="performance" element={<PerformancePage />} />
</Route>
</Routes>
</BrowserRouter>
</StrictMode>,
);

View File

@ -0,0 +1,132 @@
import { useState, useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router';
import { motion } from 'motion/react';
import { Check } from 'lucide-react';
const steps = [
'Scanning website...',
'Capturing channel screenshots...',
'Analyzing social media presence...',
'Evaluating brand consistency...',
'Generating intelligence report...',
];
export default function AnalysisLoadingPage() {
const [currentStep, setCurrentStep] = useState(0);
const navigate = useNavigate();
const location = useLocation();
const url = (location.state as { url?: string })?.url;
useEffect(() => {
const timers: ReturnType<typeof setTimeout>[] = [];
steps.forEach((_, index) => {
timers.push(
setTimeout(() => {
setCurrentStep(index + 1);
}, (index + 1) * 1250)
);
});
timers.push(
setTimeout(() => {
navigate('/report/view-clinic', { replace: true });
}, 5500)
);
return () => timers.forEach(clearTimeout);
}, [navigate]);
return (
<div className="relative min-h-screen bg-primary-900 flex flex-col items-center justify-center px-6 overflow-hidden">
{/* Radial gradient overlay */}
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,_rgba(79,29,161,0.25)_0%,_transparent_70%)]" />
{/* Purple glow blob */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] h-[500px] bg-purple-600/20 rounded-full blur-[120px] pointer-events-none" />
<div className="relative z-10 flex flex-col items-center w-full max-w-lg">
{/* INFINITH logo text */}
<motion.h1
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="text-4xl md:text-5xl font-serif font-bold text-gradient mb-4"
>
INFINITH
</motion.h1>
{/* Entered URL */}
{url && (
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.1 }}
className="text-purple-300/80 text-sm font-mono mb-12 truncate max-w-full"
>
{url}
</motion.p>
)}
{/* Analysis steps */}
<div className="w-full space-y-5 mb-14">
{steps.map((step, index) => {
const isCompleted = currentStep > index;
const isActive = currentStep === index;
return (
<motion.div
key={step}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: isActive || isCompleted ? 1 : 0.3, x: 0 }}
transition={{ duration: 0.4, delay: index * 0.15 }}
className="flex items-center gap-4"
>
{/* Status icon */}
<div className="w-7 h-7 flex-shrink-0 flex items-center justify-center">
{isCompleted ? (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
className="w-7 h-7 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#6C5CE7] flex items-center justify-center"
>
<Check className="w-4 h-4 text-white" strokeWidth={3} />
</motion.div>
) : isActive ? (
<div className="w-7 h-7 rounded-full border-2 border-purple-400 border-t-transparent animate-spin" />
) : (
<div className="w-7 h-7 rounded-full border-2 border-white/10" />
)}
</div>
{/* Step text */}
<span
className={`text-base font-sans transition-colors duration-300 ${
isCompleted
? 'text-white'
: isActive
? 'text-purple-200'
: 'text-white/30'
}`}
>
{step}
</span>
</motion.div>
);
})}
</div>
{/* Progress bar */}
<div className="w-full h-2 bg-white/10 rounded-full overflow-hidden">
<motion.div
initial={{ width: '0%' }}
animate={{ width: '100%' }}
transition={{ duration: 5, ease: 'linear' }}
className="h-full bg-gradient-to-r from-[#4F1DA1] to-[#6C5CE7] rounded-full"
/>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,371 @@
import { useState, useCallback } from 'react';
import { useNavigate } from 'react-router';
import { motion, AnimatePresence } from 'motion/react';
import {
YoutubeFilled,
InstagramFilled,
FacebookFilled,
GlobeFilled,
TiktokFilled,
} from '../components/icons/FilledIcons';
import type { ComponentType } from 'react';
interface ChannelDef {
id: string;
name: string;
description: string;
icon: ComponentType<{ size?: number; className?: string }>;
brandColor: string;
bgColor: string;
borderColor: string;
fields: { key: string; label: string; placeholder: string; type?: string }[];
}
const CHANNELS: ChannelDef[] = [
{
id: 'youtube',
name: 'YouTube',
description: '채널 연동으로 영상 자동 업로드, 성과 분석',
icon: YoutubeFilled,
brandColor: '#FF0000',
bgColor: '#FFF0F0',
borderColor: '#F5D5DC',
fields: [
{ key: 'channelUrl', label: '채널 URL', placeholder: 'https://youtube.com/@YourChannel' },
],
},
{
id: 'instagram_kr',
name: 'Instagram KR',
description: '한국 계정 — Reels, Feed, Stories 자동 게시',
icon: InstagramFilled,
brandColor: '#E1306C',
bgColor: '#FFF0F5',
borderColor: '#F5D0DC',
fields: [
{ key: 'handle', label: '핸들', placeholder: '@viewclinic_kr' },
],
},
{
id: 'instagram_en',
name: 'Instagram EN',
description: '글로벌 계정 — 해외 환자 대상 콘텐츠',
icon: InstagramFilled,
brandColor: '#E1306C',
bgColor: '#FFF0F5',
borderColor: '#F5D0DC',
fields: [
{ key: 'handle', label: '핸들', placeholder: '@viewplasticsurgery' },
],
},
{
id: 'facebook_kr',
name: 'Facebook KR',
description: '한국 페이지 — 광고 리타겟, 콘텐츠 배포',
icon: FacebookFilled,
brandColor: '#1877F2',
bgColor: '#F0F4FF',
borderColor: '#C5D5F5',
fields: [
{ key: 'pageUrl', label: '페이지 URL', placeholder: 'https://facebook.com/YourPage' },
],
},
{
id: 'facebook_en',
name: 'Facebook EN',
description: '글로벌 페이지 — 해외 환자 유입',
icon: FacebookFilled,
brandColor: '#1877F2',
bgColor: '#F0F4FF',
borderColor: '#C5D5F5',
fields: [
{ key: 'pageUrl', label: '페이지 URL', placeholder: 'https://facebook.com/YourPageEN' },
],
},
{
id: 'naver_blog',
name: 'Naver Blog',
description: 'SEO 블로그 포스트 자동 게시, 키워드 최적화',
icon: GlobeFilled,
brandColor: '#03C75A',
bgColor: '#F0FFF5',
borderColor: '#C5F5D5',
fields: [
{ key: 'blogUrl', label: '블로그 URL', placeholder: 'https://blog.naver.com/yourblog' },
{ key: 'apiKey', label: 'API Key', placeholder: 'Naver Open API Key', type: 'password' },
],
},
{
id: 'naver_place',
name: 'Naver Place',
description: '플레이스 정보 동기화, 리뷰 모니터링',
icon: GlobeFilled,
brandColor: '#03C75A',
bgColor: '#F0FFF5',
borderColor: '#C5F5D5',
fields: [
{ key: 'placeUrl', label: '플레이스 URL', placeholder: 'https://map.naver.com/...' },
],
},
{
id: 'tiktok',
name: 'TikTok',
description: 'Shorts 크로스포스팅, 트렌드 콘텐츠',
icon: TiktokFilled,
brandColor: '#000000',
bgColor: '#F5F5F5',
borderColor: '#E0E0E0',
fields: [
{ key: 'handle', label: '핸들', placeholder: '@yourclinic' },
],
},
{
id: 'gangnamunni',
name: '강남언니',
description: '리뷰 모니터링, 평점 추적, 시술 정보 동기화',
icon: GlobeFilled,
brandColor: '#6B2D8B',
bgColor: '#F3F0FF',
borderColor: '#D5CDF5',
fields: [
{ key: 'hospitalUrl', label: '병원 페이지 URL', placeholder: 'https://gangnamunni.com/hospitals/...' },
],
},
{
id: 'website',
name: 'Website',
description: '홈페이지 SEO 모니터링, 트래킹 픽셀 관리',
icon: GlobeFilled,
brandColor: '#6C5CE7',
bgColor: '#F3F0FF',
borderColor: '#D5CDF5',
fields: [
{ key: 'url', label: '웹사이트 URL', placeholder: 'https://www.yourclinic.com' },
{ key: 'gaId', label: 'Google Analytics ID', placeholder: 'G-XXXXXXXXXX' },
],
},
];
type ConnectionStatus = 'disconnected' | 'connecting' | 'connected';
interface ChannelState {
status: ConnectionStatus;
values: Record<string, string>;
}
export default function ChannelConnectPage() {
const navigate = useNavigate();
const [channels, setChannels] = useState<Record<string, ChannelState>>(() => {
const init: Record<string, ChannelState> = {};
for (const ch of CHANNELS) {
init[ch.id] = { status: 'disconnected', values: {} };
}
return init;
});
const [expandedId, setExpandedId] = useState<string | null>(null);
const handleFieldChange = useCallback((channelId: string, fieldKey: string, value: string) => {
setChannels(prev => ({
...prev,
[channelId]: {
...prev[channelId],
values: { ...prev[channelId].values, [fieldKey]: value },
},
}));
}, []);
const handleConnect = useCallback((channelId: string) => {
setChannels(prev => ({
...prev,
[channelId]: { ...prev[channelId], status: 'connecting' },
}));
// Simulate connection
setTimeout(() => {
setChannels(prev => ({
...prev,
[channelId]: { ...prev[channelId], status: 'connected' },
}));
}, 2000);
}, []);
const handleDisconnect = useCallback((channelId: string) => {
setChannels(prev => ({
...prev,
[channelId]: { status: 'disconnected', values: {} },
}));
}, []);
const connectedCount = (Object.values(channels) as ChannelState[]).filter(c => c.status === 'connected').length;
return (
<div className="pt-20 min-h-screen">
{/* Header */}
<div className="bg-gradient-to-r from-[#fff3eb] via-[#e4cfff] to-[#f5f9ff] py-16 px-6">
<div className="max-w-5xl mx-auto">
<p className="text-xs font-semibold text-[#6C5CE7] tracking-widest uppercase mb-3">Channel Integration</p>
<h1 className="font-serif text-3xl md:text-4xl font-bold text-[#021341] mb-3">
</h1>
<p className="text-[#021341]/60 max-w-xl">
.
</p>
{/* Connection Summary + Distribute Button */}
<div className="flex items-center gap-4 mt-8">
<div className="flex items-center gap-2 px-4 py-2 rounded-full bg-white/70 backdrop-blur-sm border border-white/40">
<div className={`w-3 h-3 rounded-full ${connectedCount > 0 ? 'bg-[#6C5CE7]' : 'bg-slate-300'}`} />
<span className="text-sm font-medium text-[#021341]">
{connectedCount} / {CHANNELS.length}
</span>
</div>
{connectedCount > 0 && (
<button
onClick={() => navigate('/distribute')}
className="flex items-center gap-2 px-5 py-2 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white text-sm font-medium shadow-md hover:shadow-lg transition-all"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M3 8h10M9 4l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
)}
</div>
</div>
</div>
{/* Channel Grid */}
<div className="max-w-5xl mx-auto px-6 py-12">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{CHANNELS.map(ch => {
const state = channels[ch.id];
const isExpanded = expandedId === ch.id;
const Icon = ch.icon;
const allFieldsFilled = ch.fields.every(f => (state.values[f.key] ?? '').trim().length > 0);
return (
<motion.div
key={ch.id}
layout
className={`rounded-2xl border-2 overflow-hidden transition-all ${
state.status === 'connected'
? 'border-[#D5CDF5] bg-[#F3F0FF]/20'
: isExpanded
? 'border-[#6C5CE7] bg-white shadow-[3px_4px_12px_rgba(108,92,231,0.12)]'
: 'border-slate-100 bg-white shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:border-[#D5CDF5]'
}`}
>
{/* Card Header */}
<button
onClick={() => setExpandedId(isExpanded ? null : ch.id)}
className="w-full flex items-center gap-4 p-5 text-left"
>
<div
className="w-11 h-11 rounded-xl flex items-center justify-center shrink-0"
style={{ backgroundColor: ch.bgColor }}
>
<Icon size={22} style={{ color: ch.brandColor }} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-[#0A1128]">{ch.name}</h3>
{state.status === 'connected' && (
<span className="px-2 py-1 rounded-full bg-[#F3F0FF] text-[#4A3A7C] text-xs font-semibold border border-[#D5CDF5]">
</span>
)}
</div>
<p className="text-xs text-slate-500 mt-1 truncate">{ch.description}</p>
</div>
<svg
width="20" height="20" viewBox="0 0 20 20" fill="none"
className={`shrink-0 text-slate-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
>
<path d="M5 7.5L10 12.5L15 7.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{/* Expanded Content */}
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25 }}
className="overflow-hidden"
>
<div className="px-5 pb-5 pt-1 border-t border-slate-100">
{state.status === 'connected' ? (
<div>
<div className="flex items-center gap-2 mb-4 mt-3">
<div className="w-2 h-2 rounded-full bg-[#6C5CE7]" />
<span className="text-sm text-[#4A3A7C] font-medium"> : </span>
</div>
{ch.fields.map(f => (
<div key={f.key} className="mb-2">
<span className="text-xs text-slate-400">{f.label}</span>
<p className="text-sm text-slate-700 font-medium">
{f.type === 'password' ? '••••••••' : state.values[f.key]}
</p>
</div>
))}
<button
onClick={() => handleDisconnect(ch.id)}
className="mt-4 w-full py-3 rounded-full bg-white border border-[#F5D5DC] text-[#7C3A4B] text-sm font-medium hover:bg-[#FFF0F0] transition-all"
>
</button>
</div>
) : (
<div className="mt-3">
{ch.fields.map(f => (
<div key={f.key} className="mb-3">
<label className="text-xs font-medium text-slate-600 mb-1 block">{f.label}</label>
<input
type={f.type ?? 'text'}
value={state.values[f.key] ?? ''}
onChange={e => handleFieldChange(ch.id, f.key, e.target.value)}
placeholder={f.placeholder}
className="w-full px-4 py-3 rounded-xl border border-slate-200 text-sm text-slate-700 placeholder:text-slate-300 focus:outline-none focus:border-[#6C5CE7] focus:ring-1 focus:ring-[#6C5CE7]/20 transition-all"
/>
</div>
))}
<div className="flex gap-2 mt-4">
<button
onClick={() => handleConnect(ch.id)}
disabled={!allFieldsFilled || state.status === 'connecting'}
className="flex-1 flex items-center justify-center gap-2 py-3 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white text-sm font-medium shadow-md hover:shadow-lg transition-all disabled:opacity-40 disabled:cursor-not-allowed"
>
{state.status === 'connecting' ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
...
</>
) : (
'연결하기'
)}
</button>
<button
className="px-4 py-3 rounded-full bg-white border border-slate-200 text-slate-500 text-sm font-medium hover:bg-slate-50 transition-all"
>
OAuth
</button>
</div>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
})}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,9 @@
import StudioWizard from '../components/studio/StudioWizard';
export default function ContentStudioPage() {
return (
<div className="pt-20">
<StudioWizard />
</div>
);
}

View File

@ -0,0 +1,385 @@
import { useState, useCallback } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import {
YoutubeFilled,
InstagramFilled,
FacebookFilled,
GlobeFilled,
TiktokFilled,
VideoFilled,
FileTextFilled,
ShareFilled,
} from '../components/icons/FilledIcons';
import type { ComponentType } from 'react';
// ─── Types ───
type DistributeStatus = 'ready' | 'publishing' | 'published' | 'failed';
interface ChannelTarget {
id: string;
name: string;
icon: ComponentType<{ size?: number; className?: string }>;
brandColor: string;
bgColor: string;
connected: boolean;
selected: boolean;
status: DistributeStatus;
format: string;
}
// ─── Mock content from Studio ───
const MOCK_CONTENT = {
title: '한번에 성공하는 코성형, VIEW의 비결',
description: '코성형은 얼굴의 중심을 결정하는 중요한 수술입니다. VIEW 성형외과는 21년간 축적된 노하우를 바탕으로 자연스러운 결과를 만들어냅니다.',
type: 'video' as const,
duration: '0:58',
aspectRatio: '9:16',
tags: ['코성형', '뷰성형외과', '강남성형외과', '코수술', '자연스러운코'],
thumbnail: null as string | null,
};
const INITIAL_CHANNELS: ChannelTarget[] = [
{ id: 'youtube', name: 'YouTube Shorts', icon: YoutubeFilled, brandColor: '#FF0000', bgColor: '#FFF0F0', connected: true, selected: true, status: 'ready', format: 'Shorts (9:16)' },
{ id: 'instagram_kr', name: 'Instagram Reels', icon: InstagramFilled, brandColor: '#E1306C', bgColor: '#FFF0F5', connected: true, selected: true, status: 'ready', format: 'Reels (9:16)' },
{ id: 'instagram_en', name: 'Instagram EN', icon: InstagramFilled, brandColor: '#E1306C', bgColor: '#FFF0F5', connected: true, selected: false, status: 'ready', format: 'Reels (9:16)' },
{ id: 'tiktok', name: 'TikTok', icon: TiktokFilled, brandColor: '#000000', bgColor: '#F5F5F5', connected: true, selected: true, status: 'ready', format: 'Short Video (9:16)' },
{ id: 'facebook_kr', name: 'Facebook KR', icon: FacebookFilled, brandColor: '#1877F2', bgColor: '#F0F4FF', connected: true, selected: false, status: 'ready', format: 'Video Post' },
{ id: 'naver_blog', name: 'Naver Blog', icon: GlobeFilled, brandColor: '#03C75A', bgColor: '#F0FFF5', connected: false, selected: false, status: 'ready', format: 'Blog Post' },
{ id: 'facebook_en', name: 'Facebook EN', icon: FacebookFilled, brandColor: '#1877F2', bgColor: '#F0F4FF', connected: false, selected: false, status: 'ready', format: 'Video Post' },
];
// ─── Component ───
export default function DistributionPage() {
const [channels, setChannels] = useState(INITIAL_CHANNELS);
const [title, setTitle] = useState(MOCK_CONTENT.title);
const [description, setDescription] = useState(MOCK_CONTENT.description);
const [tags, setTags] = useState(MOCK_CONTENT.tags);
const [scheduleMode, setScheduleMode] = useState<'now' | 'scheduled'>('now');
const [scheduleDate, setScheduleDate] = useState('');
const [scheduleHour, setScheduleHour] = useState(9);
const [scheduleMinute, setScheduleMinute] = useState(0);
const [schedulePeriod, setSchedulePeriod] = useState<'AM' | 'PM'>('AM');
const [isPublishing, setIsPublishing] = useState(false);
const selectedChannels = channels.filter(c => c.connected && c.selected);
const allPublished = selectedChannels.length > 0 && selectedChannels.every(c => c.status === 'published');
const toggleChannel = useCallback((id: string) => {
setChannels(prev => prev.map(c =>
c.id === id && c.connected ? { ...c, selected: !c.selected } : c
));
}, []);
const handlePublish = useCallback(() => {
setIsPublishing(true);
// Simulate sequential publishing
const selected = channels.filter(c => c.connected && c.selected);
selected.forEach((ch, i) => {
setTimeout(() => {
setChannels(prev => prev.map(c =>
c.id === ch.id ? { ...c, status: 'publishing' } : c
));
}, i * 1500);
setTimeout(() => {
setChannels(prev => prev.map(c =>
c.id === ch.id ? { ...c, status: 'published' } : c
));
if (i === selected.length - 1) setIsPublishing(false);
}, (i + 1) * 1500 + 500);
});
}, [channels]);
return (
<div className="pt-20 min-h-screen">
{/* Header */}
<div className="bg-[#0A1128] py-14 px-6 relative overflow-hidden">
<div className="absolute top-0 right-0 w-[500px] h-[500px] rounded-full bg-[#6C5CE7]/10 blur-[120px]" />
<div className="max-w-5xl mx-auto relative">
<p className="text-xs font-semibold text-purple-300 tracking-widest uppercase mb-3">Content Distribution</p>
<h1 className="font-serif text-3xl md:text-4xl font-bold text-white mb-3">
</h1>
<p className="text-purple-200/70 max-w-xl">
.
</p>
</div>
</div>
<div className="max-w-5xl mx-auto px-6 py-10">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left: Content Preview + Meta */}
<div className="lg:col-span-1">
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4"></h3>
{/* Video Preview */}
<div className="w-full aspect-[9/16] max-w-[220px] mx-auto rounded-2xl bg-gradient-to-br from-[#F3F0FF] via-white to-[#FFF6ED] border border-slate-200 flex flex-col items-center justify-center mb-6">
<VideoFilled size={32} className="text-[#9B8AD4] mb-3" />
<p className="text-xs text-slate-500">{MOCK_CONTENT.aspectRatio}</p>
<p className="text-xs text-slate-400 mt-1">{MOCK_CONTENT.duration}</p>
</div>
{/* Title */}
<div className="mb-4">
<label className="text-xs font-medium text-slate-600 mb-1 block"></label>
<input
value={title}
onChange={e => setTitle(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-slate-200 text-sm text-slate-700 focus:outline-none focus:border-[#6C5CE7] focus:ring-1 focus:ring-[#6C5CE7]/20 transition-all"
/>
</div>
{/* Description */}
<div className="mb-4">
<label className="text-xs font-medium text-slate-600 mb-1 block"></label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
rows={4}
className="w-full px-4 py-3 rounded-xl border border-slate-200 text-sm text-slate-700 resize-y focus:outline-none focus:border-[#6C5CE7] focus:ring-1 focus:ring-[#6C5CE7]/20 transition-all"
/>
</div>
{/* Tags */}
<div>
<label className="text-xs font-medium text-slate-600 mb-2 block"></label>
<div className="flex flex-wrap gap-2">
{tags.map(tag => (
<span
key={tag}
className="px-3 py-1 rounded-full bg-[#F3F0FF] text-[#4A3A7C] text-xs font-medium border border-[#D5CDF5]"
>
#{tag}
</span>
))}
</div>
</div>
</div>
{/* Right: Channel Selection + Schedule + Publish */}
<div className="lg:col-span-2">
{/* Channel Selection */}
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4"> </h3>
<div className="space-y-3 mb-8">
{channels.map(ch => {
const Icon = ch.icon;
return (
<motion.div
key={ch.id}
layout
className={`flex items-center gap-4 p-4 rounded-2xl border-2 transition-all ${
!ch.connected
? 'border-slate-100 bg-slate-50/50 opacity-50'
: ch.selected
? 'border-[#6C5CE7] bg-[#F3F0FF]/20 shadow-[3px_4px_12px_rgba(108,92,231,0.08)]'
: 'border-slate-100 bg-white shadow-[3px_4px_12px_rgba(0,0,0,0.04)] hover:border-[#D5CDF5]'
}`}
>
{/* Checkbox */}
<button
onClick={() => toggleChannel(ch.id)}
disabled={!ch.connected}
className={`w-5 h-5 rounded border-2 flex items-center justify-center shrink-0 transition-all ${
ch.selected && ch.connected
? 'border-[#6C5CE7] bg-[#6C5CE7]'
: 'border-slate-300 bg-white'
} disabled:cursor-not-allowed`}
>
{ch.selected && ch.connected && (
<svg width="10" height="10" viewBox="0 0 14 14" fill="none">
<path d="M2 7L5.5 10.5L12 3.5" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</button>
{/* Icon */}
<div
className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
style={{ backgroundColor: ch.bgColor }}
>
<Icon size={20} style={{ color: ch.brandColor }} />
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-[#0A1128]">{ch.name}</span>
{!ch.connected && (
<span className="px-2 py-1 rounded-full bg-[#FFF6ED] text-[#7C5C3A] text-xs font-semibold border border-[#F5E0C5]">
</span>
)}
</div>
<p className="text-xs text-slate-400">{ch.format}</p>
</div>
{/* Status */}
<div className="shrink-0">
{ch.status === 'publishing' && (
<div className="w-5 h-5 border-2 border-[#6C5CE7] border-t-transparent rounded-full animate-spin" />
)}
{ch.status === 'published' && (
<div className="w-6 h-6 rounded-full bg-[#6C5CE7] flex items-center justify-center">
<svg width="12" height="12" viewBox="0 0 14 14" fill="none">
<path d="M2 7L5.5 10.5L12 3.5" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
)}
{ch.status === 'failed' && (
<div className="w-6 h-6 rounded-full bg-[#FFF0F0] flex items-center justify-center border border-[#F5D5DC]">
<span className="text-[#7C3A4B] text-xs font-bold">!</span>
</div>
)}
</div>
</motion.div>
);
})}
</div>
{/* Schedule */}
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4"> </h3>
<div className="flex gap-2 mb-4">
{([
{ key: 'now' as const, label: '즉시 배포' },
{ key: 'scheduled' as const, label: '예약 배포' },
]).map(opt => (
<button
key={opt.key}
onClick={() => setScheduleMode(opt.key)}
className={`px-5 py-3 rounded-full text-sm font-medium transition-all ${
scheduleMode === opt.key
? 'bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white shadow-md'
: 'bg-white border border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
>
{opt.label}
</button>
))}
</div>
{scheduleMode === 'scheduled' && (
<div className="mb-6 space-y-4">
{/* Date */}
<div>
<label className="text-xs font-medium text-slate-600 mb-2 block"></label>
<input
type="date"
value={scheduleDate}
onChange={e => setScheduleDate(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-slate-200 text-sm text-slate-700 focus:outline-none focus:border-[#6C5CE7] transition-all appearance-none"
/>
</div>
{/* Custom Time Picker */}
<div>
<label className="text-xs font-medium text-slate-600 mb-2 block"></label>
<div className="flex items-center gap-3">
{/* Hour */}
<div className="flex items-center gap-1">
<button
onClick={() => setScheduleHour(h => h <= 1 ? 12 : h - 1)}
className="w-8 h-8 rounded-lg bg-slate-50 border border-slate-200 flex items-center justify-center text-slate-500 hover:bg-slate-100 transition-all"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M2 6h8M6 2l-4 4 4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
</button>
<div className="w-12 h-10 rounded-xl bg-[#F3F0FF] border border-[#D5CDF5] flex items-center justify-center text-lg font-semibold text-[#0A1128]">
{String(scheduleHour).padStart(2, '0')}
</div>
<button
onClick={() => setScheduleHour(h => h >= 12 ? 1 : h + 1)}
className="w-8 h-8 rounded-lg bg-slate-50 border border-slate-200 flex items-center justify-center text-slate-500 hover:bg-slate-100 transition-all"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M10 6H2M6 10l4-4-4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
</button>
</div>
<span className="text-xl font-bold text-slate-300">:</span>
{/* Minute */}
<div className="flex items-center gap-1">
<button
onClick={() => setScheduleMinute(m => m <= 0 ? 55 : m - 5)}
className="w-8 h-8 rounded-lg bg-slate-50 border border-slate-200 flex items-center justify-center text-slate-500 hover:bg-slate-100 transition-all"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M2 6h8M6 2l-4 4 4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
</button>
<div className="w-12 h-10 rounded-xl bg-[#F3F0FF] border border-[#D5CDF5] flex items-center justify-center text-lg font-semibold text-[#0A1128]">
{String(scheduleMinute).padStart(2, '0')}
</div>
<button
onClick={() => setScheduleMinute(m => m >= 55 ? 0 : m + 5)}
className="w-8 h-8 rounded-lg bg-slate-50 border border-slate-200 flex items-center justify-center text-slate-500 hover:bg-slate-100 transition-all"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M10 6H2M6 10l4-4-4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
</button>
</div>
{/* AM/PM */}
<div className="flex rounded-xl overflow-hidden border border-slate-200">
{(['AM', 'PM'] as const).map(p => (
<button
key={p}
onClick={() => setSchedulePeriod(p)}
className={`px-4 py-2 text-sm font-medium transition-all ${
schedulePeriod === p
? 'bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white'
: 'bg-white text-slate-500 hover:bg-slate-50'
}`}
>
{p}
</button>
))}
</div>
</div>
</div>
</div>
)}
{/* Publish Button */}
{!allPublished ? (
<button
onClick={handlePublish}
disabled={selectedChannels.length === 0 || isPublishing}
className="w-full flex items-center justify-center gap-2 py-4 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white text-sm font-medium shadow-md hover:shadow-lg transition-all disabled:opacity-40 disabled:cursor-not-allowed"
>
{isPublishing ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
... ({selectedChannels.filter(c => c.status === 'published').length}/{selectedChannels.length})
</>
) : (
<>
<ShareFilled size={18} className="text-white" />
{selectedChannels.length} {scheduleMode === 'now' ? '즉시 배포' : '예약 배포'}
</>
)}
</button>
) : (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="w-full py-6 rounded-2xl bg-[#F3F0FF] border border-[#D5CDF5] text-center"
>
<div className="w-12 h-12 rounded-full bg-[#6C5CE7] flex items-center justify-center mx-auto mb-3">
<svg width="20" height="20" viewBox="0 0 14 14" fill="none">
<path d="M2 7L5.5 10.5L12 3.5" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<p className="text-lg font-semibold text-[#0A1128] mb-1"> </p>
<p className="text-sm text-[#4A3A7C]">
{selectedChannels.length}
</p>
</motion.div>
)}
</div>
</div>
</div>
</div>
);
}

21
src/pages/LandingPage.tsx Normal file
View File

@ -0,0 +1,21 @@
import Hero from '../components/Hero';
import TargetAudience from '../components/TargetAudience';
import Problems from '../components/Problems';
import Solution from '../components/Solution';
import Modules from '../components/Modules';
import UseCases from '../components/UseCases';
import CTA from '../components/CTA';
export default function LandingPage() {
return (
<main>
<Hero />
<TargetAudience />
<Problems />
<Solution />
<Modules />
<UseCases />
<CTA />
</main>
);
}

View File

@ -0,0 +1,82 @@
import { useParams } from 'react-router';
import { useMarketingPlan } from '../hooks/useMarketingPlan';
import { ReportNav } from '../components/report/ReportNav';
// Plan section components
import PlanHeader from '../components/plan/PlanHeader';
import BrandingGuide from '../components/plan/BrandingGuide';
import ChannelStrategy from '../components/plan/ChannelStrategy';
import ContentStrategy from '../components/plan/ContentStrategy';
import ContentCalendar from '../components/plan/ContentCalendar';
import AssetCollection from '../components/plan/AssetCollection';
import MyAssetUpload from '../components/plan/MyAssetUpload';
import PlanCTA from '../components/plan/PlanCTA';
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' },
];
export default function MarketingPlanPage() {
const { id } = useParams<{ id: string }>();
const { data, isLoading, error } = useMarketingPlan(id);
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center pt-20">
<div className="flex flex-col items-center gap-4">
<div className="w-10 h-10 border-4 border-[#6C5CE7] border-t-transparent rounded-full animate-spin" />
<p className="text-slate-500 text-sm"> ...</p>
</div>
</div>
);
}
if (error || !data) {
return (
<div className="min-h-screen flex items-center justify-center pt-20">
<div className="text-center">
<p className="text-[#7C3A4B] font-medium mb-2"> </p>
<p className="text-slate-500 text-sm">
{error ?? '마케팅 플랜을 찾을 수 없습니다.'}
</p>
</div>
</div>
);
}
return (
<div className="pt-20">
<ReportNav sections={PLAN_SECTIONS} />
<div data-plan-content>
<PlanHeader
clinicName={data.clinicName}
clinicNameEn={data.clinicNameEn}
date={data.createdAt}
targetUrl={data.targetUrl}
/>
<BrandingGuide data={data.brandGuide} />
<ChannelStrategy channels={data.channelStrategies} />
<ContentStrategy data={data.contentStrategy} />
<ContentCalendar data={data.calendar} />
<AssetCollection data={data.assetCollection} />
<div data-no-print>
<MyAssetUpload />
</div>
<PlanCTA />
</div>
</div>
);
}

View File

@ -0,0 +1,499 @@
import { useState } from 'react';
import { motion } from 'motion/react';
import {
YoutubeFilled,
InstagramFilled,
FacebookFilled,
GlobeFilled,
TiktokFilled,
VideoFilled,
FileTextFilled,
ShareFilled,
} from '../components/icons/FilledIcons';
import type { ComponentType } from 'react';
// ─── Types ───
interface ChannelMetric {
id: string;
name: string;
icon: ComponentType<{ size?: number; className?: string }>;
brandColor: string;
bgColor: string;
followers: string;
followersDelta: string;
views: string;
viewsDelta: string;
engagement: string;
engagementDelta: string;
posts: number;
score: number;
}
interface ContentPerformance {
id: string;
title: string;
channel: string;
type: 'video' | 'blog' | 'social';
views: string;
likes: string;
comments: string;
ctr: string;
publishedAt: string;
}
// ─── Mock Data ───
const CHANNELS: ChannelMetric[] = [
{ id: 'youtube', name: 'YouTube', icon: YoutubeFilled, brandColor: '#FF0000', bgColor: '#FFF0F0', followers: '103K', followersDelta: '+2.1K', views: '270K', viewsDelta: '+18%', engagement: '4.2%', engagementDelta: '+0.8%', posts: 12, score: 65 },
{ id: 'instagram_kr', name: 'Instagram KR', icon: InstagramFilled, brandColor: '#E1306C', bgColor: '#FFF0F5', followers: '14K', followersDelta: '+890', views: '45K', viewsDelta: '+32%', engagement: '3.1%', engagementDelta: '+1.2%', posts: 24, score: 35 },
{ id: 'instagram_en', name: 'Instagram EN', icon: InstagramFilled, brandColor: '#E1306C', bgColor: '#FFF0F5', followers: '68.8K', followersDelta: '+1.2K', views: '120K', viewsDelta: '+8%', engagement: '5.6%', engagementDelta: '+0.3%', posts: 18, score: 55 },
{ id: 'tiktok', name: 'TikTok', icon: TiktokFilled, brandColor: '#000000', bgColor: '#F5F5F5', followers: '0', followersDelta: 'NEW', views: '0', viewsDelta: '-', engagement: '0%', engagementDelta: '-', posts: 0, score: 0 },
{ id: 'facebook', name: 'Facebook', icon: FacebookFilled, brandColor: '#1877F2', bgColor: '#F0F4FF', followers: '341', followersDelta: '+12', views: '2.1K', viewsDelta: '-5%', engagement: '0.8%', engagementDelta: '-0.2%', posts: 6, score: 40 },
{ id: 'naver', name: 'Naver Blog', icon: GlobeFilled, brandColor: '#03C75A', bgColor: '#F0FFF5', followers: '-', followersDelta: '-', views: '8.2K', viewsDelta: '+45%', engagement: '2.4%', engagementDelta: '+1.1%', posts: 8, score: 72 },
];
const TOP_CONTENT: ContentPerformance[] = [
{ id: '1', title: '한번에 성공하는 코성형, VIEW의 비결', channel: 'YouTube', type: 'video', views: '12.4K', likes: '342', comments: '28', ctr: '8.2%', publishedAt: '3일 전' },
{ id: '2', title: '안면윤곽 수술 종류와 회복기간', channel: 'Naver Blog', type: 'blog', views: '3.2K', likes: '-', comments: '12', ctr: '12.5%', publishedAt: '5일 전' },
{ id: '3', title: 'Reel: 윤곽 전후 변화', channel: 'Instagram KR', type: 'social', views: '8.7K', likes: '567', comments: '45', ctr: '6.1%', publishedAt: '2일 전' },
{ id: '4', title: 'Shorts: 사각턱 축소 과정', channel: 'YouTube', type: 'video', views: '5.1K', likes: '189', comments: '15', ctr: '4.8%', publishedAt: '4일 전' },
{ id: '5', title: '코성형 가이드: 내 얼굴에 맞는 코', channel: 'Naver Blog', type: 'blog', views: '2.8K', likes: '-', comments: '8', ctr: '15.2%', publishedAt: '6일 전' },
];
const OVERVIEW_STATS = [
{ label: '총 노출', value: '445K', delta: '+24%', positive: true },
{ label: '총 조회', value: '89.2K', delta: '+18%', positive: true },
{ label: '평균 참여율', value: '3.8%', delta: '+0.6%', positive: true },
{ label: '콘텐츠 발행', value: '68건', delta: '+12건', positive: true },
{ label: '신규 팔로워', value: '+4.3K', delta: '+32%', positive: true },
{ label: '전환 (상담)', value: '47건', delta: '+15건', positive: true },
];
// ─── Funnel Data ───
const FUNNEL_STEPS = [
{ label: '노출', labelEn: 'Impressions', value: 445000, display: '445K', color: '#6C5CE7' },
{ label: '클릭', labelEn: 'Clicks', value: 89200, display: '89.2K', color: '#7C6DD8' },
{ label: '웹사이트 유입', labelEn: 'Website Visits', value: 12400, display: '12.4K', color: '#9B8AD4' },
{ label: '상담 문의', labelEn: 'Inquiries', value: 478, display: '478', color: '#B8A9E8' },
{ label: '예약 전환', labelEn: 'Conversions', value: 47, display: '47', color: '#D5CDF5' },
];
// ─── Channel Trend Data (4 weeks) ───
const CHANNEL_TREND = [
{ week: 'W1', youtube: 85, instagram: 32, naver: 18, facebook: 8 },
{ week: 'W2', youtube: 92, instagram: 41, naver: 24, facebook: 7 },
{ week: 'W3', youtube: 78, instagram: 55, naver: 31, facebook: 9 },
{ week: 'W4', youtube: 105, instagram: 68, naver: 38, facebook: 6 },
];
const TREND_CHANNELS = [
{ key: 'youtube' as const, label: 'YouTube', color: 'rgba(155,138,212,0.35)' },
{ key: 'instagram' as const, label: 'Instagram', color: 'rgba(212,168,186,0.3)' },
{ key: 'naver' as const, label: 'Naver', color: 'rgba(160,200,180,0.3)' },
{ key: 'facebook' as const, label: 'Facebook', color: 'rgba(160,180,220,0.25)' },
];
// ─── Heatmap Data (Day × Time Slot) ───
const DAYS = ['월', '화', '수', '목', '금', '토', '일'];
const TIME_SLOTS = ['오전 (6-12)', '오후 (12-18)', '저녁 (18-24)', '심야 (0-6)'];
// Engagement rate by day × time slot (0-10 scale)
const HEATMAP_DATA = [
[3, 7, 8, 2], // 월
[4, 6, 9, 1], // 화
[5, 8, 7, 2], // 수
[6, 9, 8, 1], // 목
[4, 7, 10, 3], // 금
[2, 5, 6, 4], // 토
[1, 4, 5, 3], // 일
];
// ─── Component ───
const typeIcons: Record<string, ComponentType<{ size?: number; className?: string }>> = {
video: VideoFilled,
blog: FileTextFilled,
social: ShareFilled,
};
const typeColors: Record<string, { bg: string; text: string }> = {
video: { bg: 'bg-[#F3F0FF]', text: 'text-[#4A3A7C]' },
blog: { bg: 'bg-[#EFF0FF]', text: 'text-[#3A3F7C]' },
social: { bg: 'bg-[#FFF6ED]', text: 'text-[#7C5C3A]' },
};
function heatmapColor(value: number): string {
if (value >= 9) return 'bg-[#2d2640] text-white';
if (value >= 7) return 'bg-[#4a4460] text-white';
if (value >= 5) return 'bg-[#8e89a8] text-white';
if (value >= 3) return 'bg-[#c8c4d8] text-[#4a4460]';
return 'bg-[#f0eef5] text-[#8e89a8]';
}
export default function PerformancePage() {
const [period, setPeriod] = useState<'7d' | '30d' | '90d'>('30d');
const funnelMax = FUNNEL_STEPS[0].value;
const trendMax = Math.max(...CHANNEL_TREND.flatMap(w => [w.youtube, w.instagram, w.naver, w.facebook]));
return (
<div className="pt-20 min-h-screen">
{/* Header */}
<div className="bg-[#0A1128] py-14 px-6 relative overflow-hidden">
<div className="absolute top-0 right-0 w-[500px] h-[500px] rounded-full bg-[#6C5CE7]/10 blur-[120px]" />
<div className="absolute bottom-0 left-0 w-[300px] h-[300px] rounded-full bg-purple-500/5 blur-[100px]" />
<div className="max-w-6xl mx-auto relative">
<p className="text-xs font-semibold text-purple-300 tracking-widest uppercase mb-3">Performance Intelligence</p>
<h1 className="font-serif text-3xl md:text-4xl font-bold text-white mb-3"> </h1>
<p className="text-purple-200/70 max-w-xl mb-8"> .</p>
<div className="flex gap-2">
{([
{ key: '7d' as const, label: '7일' },
{ key: '30d' as const, label: '30일' },
{ key: '90d' as const, label: '90일' },
]).map(p => (
<button
key={p.key}
onClick={() => setPeriod(p.key)}
className={`px-4 py-2 rounded-full text-sm font-medium transition-all ${
period === p.key ? 'bg-white text-[#0A1128]' : 'bg-white/10 text-purple-200 hover:bg-white/20'
}`}
>
{p.label}
</button>
))}
</div>
</div>
</div>
<div className="max-w-6xl mx-auto px-6 py-10">
{/* Overview Stats */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 mb-10">
{OVERVIEW_STATS.map((stat, i) => (
<motion.div
key={stat.label}
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-4"
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: i * 0.05 }}
>
<p className="text-xs text-slate-500 mb-1">{stat.label}</p>
<p className="text-xl font-bold text-[#0A1128]">{stat.value}</p>
<p className={`text-xs font-medium mt-1 ${stat.positive ? 'text-[#4A3A7C]' : 'text-[#7C3A4B]'}`}>{stat.delta}</p>
</motion.div>
))}
</div>
{/* ═══ Section 1: Marketing Funnel ═══ */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 mb-10">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="font-serif font-bold text-xl text-[#0A1128]"> </h3>
<p className="text-xs text-slate-500 mt-1"> </p>
</div>
</div>
<div className="space-y-3">
{FUNNEL_STEPS.map((step, i) => {
const widthPct = Math.max((step.value / funnelMax) * 100, 8);
const convRate = i > 0
? ((step.value / FUNNEL_STEPS[i - 1].value) * 100).toFixed(1)
: null;
return (
<motion.div
key={step.label}
className="flex items-center gap-4"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.4, delay: i * 0.1 }}
>
{/* Label */}
<div className="w-24 shrink-0 text-right">
<p className="text-sm font-medium text-[#0A1128]">{step.label}</p>
<p className="text-xs text-slate-400">{step.labelEn}</p>
</div>
{/* Bar */}
<div className="flex-1 relative">
<motion.div
className="h-10 rounded-xl flex items-center px-4"
style={{ backgroundColor: step.color }}
initial={{ width: 0 }}
animate={{ width: `${widthPct}%` }}
transition={{ duration: 0.7, delay: i * 0.12 }}
>
<span className="text-sm font-bold text-white whitespace-nowrap">{step.display}</span>
</motion.div>
</div>
{/* Conversion Rate */}
<div className="w-16 shrink-0 text-right">
{convRate ? (
<span className={`text-xs font-semibold px-2 py-1 rounded-full ${
parseFloat(convRate) >= 10
? 'bg-[#F3F0FF] text-[#4A3A7C]'
: 'bg-[#FFF6ED] text-[#7C5C3A]'
}`}>
{convRate}%
</span>
) : (
<span className="text-xs text-slate-300"></span>
)}
</div>
</motion.div>
);
})}
</div>
{/* Funnel insight */}
<div className="mt-6 p-4 rounded-xl bg-[#FFF6ED] border border-[#F5E0C5]">
<p className="text-sm text-[#7C5C3A]">
<span className="font-semibold"> :</span> <span className="font-bold">13.9%</span> . 20% .
</p>
</div>
</div>
{/* ═══ Section 2: Channel Trend (Stacked Bar) ═══ */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 mb-10">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="font-serif font-bold text-xl text-[#0A1128]"> </h3>
<p className="text-xs text-slate-500 mt-1"> (: K)</p>
</div>
{/* Legend */}
<div className="flex gap-3">
{TREND_CHANNELS.map(ch => (
<div key={ch.key} className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: ch.color }} />
<span className="text-xs text-slate-500">{ch.label}</span>
</div>
))}
</div>
</div>
<div className="flex items-end justify-center gap-10 h-[220px] px-8">
{CHANNEL_TREND.map((week, wi) => {
const total = week.youtube + week.instagram + week.naver + week.facebook;
return (
<div key={week.week} className="flex flex-col items-center gap-2" style={{ width: '80px' }}>
{/* Total label above bar */}
<p className="text-xs text-slate-500 font-medium">{total}K</p>
{/* Stacked bar */}
<div className="w-full flex flex-col-reverse items-stretch rounded-xl overflow-hidden" style={{ height: `${(total / (trendMax * 1.5)) * 160}px` }}>
{TREND_CHANNELS.map(ch => {
const val = week[ch.key];
const segH = (val / total) * 100;
return (
<motion.div
key={ch.key}
className="w-full relative group"
style={{ height: `${segH}%`, backgroundColor: ch.color }}
initial={{ scaleY: 0 }}
animate={{ scaleY: 1 }}
transition={{ duration: 0.5, delay: wi * 0.1 }}
>
<div className="absolute -top-8 left-1/2 -translate-x-1/2 hidden group-hover:block bg-[#0A1128] text-white text-xs px-2 py-1 rounded whitespace-nowrap z-10">
{ch.label}: {val}K
</div>
</motion.div>
);
})}
</div>
{/* Week label */}
<p className="text-xs font-medium text-slate-600">{week.week}</p>
</div>
);
})}
</div>
{/* Trend insight */}
<div className="mt-6 p-4 rounded-xl bg-[#F3F0FF] border border-[#D5CDF5]">
<p className="text-sm text-[#4A3A7C]">
<span className="font-semibold"> :</span> Instagram <span className="font-bold">+112%</span> (W1W4). Naver Blog <span className="font-bold">+111%</span> . YouTube .
</p>
</div>
</div>
{/* ═══ Section 3: Day × Time Heatmap ═══ */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 mb-10">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="font-serif font-bold text-xl text-[#0A1128]"> </h3>
<p className="text-xs text-slate-500 mt-1">× </p>
</div>
{/* Scale legend */}
<div className="flex items-center gap-1">
<span className="text-xs text-slate-400"></span>
{[1, 3, 5, 7, 9].map(v => (
<div key={v} className={`w-4 h-4 rounded ${heatmapColor(v)}`} />
))}
<span className="text-xs text-slate-400"></span>
</div>
</div>
{/* Heatmap grid */}
<div className="overflow-x-auto">
<div className="min-w-[500px]">
{/* Time slot headers */}
<div className="grid grid-cols-[60px_repeat(4,1fr)] gap-2 mb-2">
<div />
{TIME_SLOTS.map(slot => (
<div key={slot} className="text-center text-xs text-slate-500 font-medium">{slot}</div>
))}
</div>
{/* Rows */}
{DAYS.map((day, di) => (
<motion.div
key={day}
className="grid grid-cols-[60px_repeat(4,1fr)] gap-2 mb-2"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: di * 0.05 }}
>
<div className="flex items-center justify-center text-sm font-medium text-[#0A1128]">{day}</div>
{HEATMAP_DATA[di].map((val, ti) => (
<div
key={ti}
className={`h-12 rounded-xl flex items-center justify-center text-sm font-semibold transition-all hover:scale-105 cursor-default ${heatmapColor(val)}`}
>
{val > 0 ? `${val * 10}%` : '-'}
</div>
))}
</motion.div>
))}
</div>
</div>
{/* Heatmap insight */}
<div className="mt-6 p-4 rounded-xl bg-[#F3F0FF] border border-[#D5CDF5]">
<p className="text-sm text-[#4A3A7C]">
<span className="font-semibold"> :</span> <span className="font-bold"> (18-24)</span> . (12-18) . .
</p>
</div>
</div>
{/* Channel Performance Grid */}
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4"> </h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-10">
{CHANNELS.map((ch, i) => {
const Icon = ch.icon;
return (
<motion.div
key={ch.id}
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-5"
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: i * 0.08 }}
>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl flex items-center justify-center" style={{ backgroundColor: ch.bgColor }}>
<Icon size={20} style={{ color: ch.brandColor }} />
</div>
<div className="flex-1">
<h4 className="text-sm font-semibold text-[#0A1128]">{ch.name}</h4>
<p className="text-xs text-slate-400">{ch.posts} </p>
</div>
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold ${
ch.score >= 70 ? 'bg-[#F3F0FF] text-[#4A3A7C]' :
ch.score >= 40 ? 'bg-[#FFF6ED] text-[#7C5C3A]' :
ch.score > 0 ? 'bg-[#FFF0F0] text-[#7C3A4B]' :
'bg-slate-50 text-slate-400'
}`}>
{ch.score || '-'}
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<MetricCell label="팔로워" value={ch.followers} delta={ch.followersDelta} />
<MetricCell label="조회수" value={ch.views} delta={ch.viewsDelta} />
<MetricCell label="참여율" value={ch.engagement} delta={ch.engagementDelta} />
</div>
</motion.div>
);
})}
</div>
{/* Top Content */}
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4"> TOP 5</h3>
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] overflow-hidden mb-10">
<div className="grid grid-cols-[1fr_100px_80px_80px_60px_70px_70px] gap-2 px-5 py-3 bg-[#0A1128] text-white text-xs font-medium">
<span></span>
<span></span>
<span className="text-right"></span>
<span className="text-right"></span>
<span className="text-right"></span>
<span className="text-right">CTR</span>
<span className="text-right"></span>
</div>
{TOP_CONTENT.map((content, i) => {
const TypeIcon = typeIcons[content.type] ?? FileTextFilled;
const colors = typeColors[content.type] ?? typeColors.blog;
return (
<motion.div
key={content.id}
className={`grid grid-cols-[1fr_100px_80px_80px_60px_70px_70px] gap-2 px-5 py-4 items-center ${
i % 2 === 0 ? 'bg-white' : 'bg-slate-50/50'
} border-b border-slate-50 last:border-0`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: i * 0.08 }}
>
<div className="flex items-center gap-2 min-w-0">
<div className={`w-7 h-7 rounded-lg flex items-center justify-center shrink-0 ${colors.bg}`}>
<TypeIcon size={14} className={colors.text} />
</div>
<span className="text-sm text-[#0A1128] truncate">{content.title}</span>
</div>
<span className="text-xs text-slate-500">{content.channel}</span>
<span className="text-sm font-medium text-[#0A1128] text-right">{content.views}</span>
<span className="text-sm text-slate-600 text-right">{content.likes}</span>
<span className="text-sm text-slate-600 text-right">{content.comments}</span>
<span className={`text-sm font-medium text-right ${
parseFloat(content.ctr) >= 10 ? 'text-[#4A3A7C]' : 'text-slate-600'
}`}>{content.ctr}</span>
<span className="text-xs text-slate-400 text-right">{content.publishedAt}</span>
</motion.div>
);
})}
</div>
{/* AI Recommendations */}
<div className="bg-gradient-to-r from-[#fff3eb] via-[#e4cfff] to-[#f5f9ff] rounded-2xl p-8">
<h3 className="font-serif font-bold text-xl text-[#021341] mb-4">AI </h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{[
{ title: 'YouTube Shorts 확대', desc: 'Shorts 조회수가 Long-form 대비 3.2배 높습니다. 주 3회 이상 Shorts 업로드를 권장합니다.' },
{ title: 'Instagram Reels 시작', desc: 'KR 계정에 Reels 0개입니다. 경쟁 병원 평균 주 5개 — 즉시 시작이 필요합니다.' },
{ title: '랜딩 페이지 최적화', desc: '클릭→유입 전환율 13.9%로 업계 평균 20% 대비 낮음. CTA 버튼 위치와 페이지 속도 개선 필요.' },
].map((rec, i) => (
<div key={i} className="bg-white/70 backdrop-blur-sm rounded-xl border border-white/40 p-5">
<h4 className="font-semibold text-[#021341] mb-2">{rec.title}</h4>
<p className="text-sm text-[#021341]/60">{rec.desc}</p>
</div>
))}
</div>
</div>
</div>
</div>
);
}
function MetricCell({ label, value, delta }: { label: string; value: string; delta: string }) {
const isPositive = delta.startsWith('+');
const isNew = delta === 'NEW' || delta === '-';
return (
<div className="text-center">
<p className="text-xs text-slate-400 mb-1">{label}</p>
<p className="text-sm font-semibold text-[#0A1128]">{value}</p>
<p className={`text-xs font-medium ${
isNew ? 'text-slate-400' : isPositive ? 'text-[#4A3A7C]' : 'text-[#7C3A4B]'
}`}>{delta}</p>
</div>
);
}

102
src/pages/ReportPage.tsx Normal file
View File

@ -0,0 +1,102 @@
import { useParams } from 'react-router';
import { useReport } from '../hooks/useReport';
import { ReportNav } from '../components/report/ReportNav';
import { ScreenshotProvider } from '../contexts/ScreenshotContext';
// Report section components
import ReportHeader from '../components/report/ReportHeader';
import ClinicSnapshot from '../components/report/ClinicSnapshot';
import ChannelOverview from '../components/report/ChannelOverview';
import YouTubeAudit from '../components/report/YouTubeAudit';
import InstagramAudit from '../components/report/InstagramAudit';
import FacebookAudit from '../components/report/FacebookAudit';
import OtherChannels from '../components/report/OtherChannels';
import ProblemDiagnosis from '../components/report/ProblemDiagnosis';
import TransformationProposal from '../components/report/TransformationProposal';
import RoadmapTimeline from '../components/report/RoadmapTimeline';
import KPIDashboard from '../components/report/KPIDashboard';
const REPORT_SECTIONS = [
{ id: 'header', label: '개요' },
{ id: 'clinic-snapshot', label: '의원 현황' },
{ id: 'channel-overview', label: '채널 종합' },
{ id: 'youtube-audit', label: 'YouTube' },
{ id: 'instagram-audit', label: 'Instagram' },
{ id: 'facebook-audit', label: 'Facebook' },
{ id: 'other-channels', label: '기타 채널' },
{ id: 'problem-diagnosis', label: '문제 진단' },
{ id: 'transformation', label: '변환 전략' },
{ id: 'roadmap', label: '로드맵' },
{ id: 'kpi-dashboard', label: 'KPI' },
];
export default function ReportPage() {
const { id } = useParams<{ id: string }>();
const { data, isLoading, error } = useReport(id);
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center pt-20">
<div className="flex flex-col items-center gap-4">
<div className="w-10 h-10 border-4 border-[#6C5CE7] border-t-transparent rounded-full animate-spin" />
<p className="text-slate-500 text-sm"> ...</p>
</div>
</div>
);
}
if (error || !data) {
return (
<div className="min-h-screen flex items-center justify-center pt-20">
<div className="text-center">
<p className="text-[#7C3A4B] font-medium mb-2"> </p>
<p className="text-slate-500 text-sm">{error ?? '리포트를 찾을 수 없습니다.'}</p>
</div>
</div>
);
}
return (
<ScreenshotProvider screenshots={data.screenshots ?? []}>
<div className="pt-20">
<ReportNav sections={REPORT_SECTIONS} />
<div data-report-content>
<ReportHeader
overallScore={data.overallScore}
clinicName={data.clinicSnapshot.name}
clinicNameEn={data.clinicSnapshot.nameEn}
targetUrl={data.targetUrl}
date={data.createdAt}
location={data.clinicSnapshot.location}
logoImage={data.clinicSnapshot.logoImages?.horizontal}
brandColors={data.clinicSnapshot.brandColors}
/>
<ClinicSnapshot data={data.clinicSnapshot} />
<ChannelOverview channels={data.channelScores} />
<YouTubeAudit data={data.youtubeAudit} />
<InstagramAudit data={data.instagramAudit} />
<FacebookAudit data={data.facebookAudit} />
<OtherChannels
channels={data.otherChannels}
website={data.websiteAudit}
/>
<ProblemDiagnosis diagnosis={data.problemDiagnosis} />
<TransformationProposal data={data.transformation} />
<RoadmapTimeline months={data.roadmap} />
<KPIDashboard metrics={data.kpiDashboard} />
</div>
</div>
</ScreenshotProvider>
);
}

View File

@ -0,0 +1,81 @@
import { GoogleGenAI } from '@google/genai';
import type { StudioState } from '../types/studio';
import { CHANNEL_OPTIONS } from '../types/studio';
const client = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY ?? '' });
function buildPrompt(state: StudioState): string {
const channel = CHANNEL_OPTIONS.find(c => c.channel === state.channel);
const format = channel?.formats.find(f => f.key === state.format);
const pillarDescriptions: Record<string, string> = {
safety: 'hospital safety systems, clean surgical rooms, CCTV monitoring, professional medical team, trust and reliability',
expertise: 'medical expertise, advanced surgical equipment, professional doctors, medical certifications, precision and skill',
results: 'natural beautiful results, before and after transformation, patient satisfaction, subtle elegant outcomes',
care: 'patient-centered care, warm consultation, personalized treatment plan, caring medical staff, comfortable clinic interior',
};
const pillarContext = state.pillarId ? pillarDescriptions[state.pillarId] ?? '' : '';
const aspectRatio = format?.aspectRatio ?? '16:9';
const channelHints: Record<string, string> = {
youtube: 'YouTube thumbnail style, bold text overlay area, high contrast, eye-catching',
instagram: 'Instagram aesthetic, clean minimalist, lifestyle photography style, warm tones',
naver_blog: 'Korean blog header image, informative, medical illustration, clean layout',
tiktok: 'TikTok cover image, vertical format, trendy, dynamic, youthful',
facebook: 'Facebook ad creative, professional, compelling, clear messaging space',
};
const channelHint = state.channel ? channelHints[state.channel] ?? '' : '';
return [
'Generate a premium medical marketing image for a plastic surgery clinic.',
`Theme: ${pillarContext}`,
`Style: ${channelHint}`,
`Aspect ratio: ${aspectRatio}`,
'Color palette: soft purple (#7B2D8E), gold (#E8B931), warm white (#FAF8F5).',
'Premium, luxurious, trustworthy aesthetic.',
'No text or logos in the image.',
'Photorealistic, high quality, professional medical marketing.',
].filter(Boolean).join(' ');
}
export interface GenerateResult {
imageDataUrl: string;
prompt: string;
}
export async function generateImage(state: StudioState): Promise<GenerateResult> {
const prompt = buildPrompt(state);
let response;
try {
response = await client.models.generateContent({
model: 'gemini-2.5-flash-image',
contents: prompt,
config: {
responseModalities: ['Text', 'Image'],
},
});
} catch (err: any) {
const code = err?.status ?? err?.code ?? err?.message?.match(/"code":(\d+)/)?.[1];
if (String(code) === '429' || err?.message?.includes('RESOURCE_EXHAUSTED')) {
throw new Error('API 요청 한도 초과 — 잠시 후 다시 시도해주세요');
}
if (String(code) === '400' || err?.message?.includes('INVALID_ARGUMENT')) {
throw new Error('API 설정 오류 — 관리자에게 문의하세요');
}
throw new Error('이미지 생성 중 오류가 발생했습니다');
}
// Extract image from response parts
const parts = response.candidates?.[0]?.content?.parts;
if (!parts) throw new Error('AI 응답을 받지 못했습니다');
const imagePart = parts.find((p: any) => p.inlineData?.mimeType?.startsWith('image/'));
if (!imagePart?.inlineData) throw new Error('이미지가 생성되지 않았습니다 — 다시 시도해주세요');
const { mimeType, data } = imagePart.inlineData;
const imageDataUrl = `data:${mimeType};base64,${data}`;
return { imageDataUrl, prompt };
}

176
src/types/plan.ts Normal file
View File

@ -0,0 +1,176 @@
import type { BrandInconsistency } from './report';
// ─── 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;
}

230
src/types/report.ts Normal file
View File

@ -0,0 +1,230 @@
export type Severity = 'critical' | 'warning' | 'good' | 'excellent' | 'unknown';
export interface ScreenshotAnnotation {
type: 'highlight' | 'arrow' | 'text';
x: number;
y: number;
width?: number;
height?: number;
label?: string;
color?: string;
}
export interface ScreenshotEvidence {
id: string;
url: string;
channel: string;
capturedAt: string;
caption: string;
sourceUrl?: string;
annotations?: ScreenshotAnnotation[];
}
export interface DiagnosisItem {
category: string;
detail: string;
severity: Severity;
evidenceIds?: string[];
}
export interface ClinicSnapshot {
name: string;
nameEn: string;
established: string;
yearsInBusiness: number;
staffCount: number;
leadDoctor: {
name: string;
credentials: string;
rating: number;
reviewCount: number;
};
overallRating: number;
totalReviews: number;
priceRange: { min: string; max: string; currency: string };
certifications: string[];
mediaAppearances: string[];
medicalTourism: string[];
location: string;
nearestStation: string;
phone: string;
domain: string;
logoImages?: {
circle?: string;
horizontal?: string;
korean?: string;
};
brandColors?: {
primary: string;
accent: string;
text: string;
};
}
export interface TopVideo {
title: string;
views: number;
uploadedAgo: string;
type: 'Short' | 'Long';
duration?: string;
}
export interface YouTubeAudit {
channelName: string;
handle: string;
subscribers: number;
totalVideos: number;
totalViews: number;
weeklyViewGrowth: { absolute: number; percentage: number };
estimatedMonthlyRevenue: { min: number; max: number };
avgVideoLength: string;
uploadFrequency: string;
channelCreatedDate: string;
subscriberRank: string;
channelDescription: string;
linkedUrls: { label: string; url: string }[];
playlists: string[];
topVideos: TopVideo[];
diagnosis: DiagnosisItem[];
}
export interface InstagramAccount {
handle: string;
language: 'KR' | 'EN';
label: string;
posts: number;
followers: number;
following: number;
category: string;
profileLink: string;
highlights: string[];
reelsCount: number;
contentFormat: string;
profilePhoto: string;
bio: string;
}
export interface InstagramAudit {
accounts: InstagramAccount[];
diagnosis: DiagnosisItem[];
}
export interface BrandInconsistency {
field: string;
values: { channel: string; value: string; isCorrect: boolean }[];
impact: string;
recommendation: string;
}
export interface FacebookPage {
url: string;
pageName: string;
language: 'KR' | 'EN';
label: string;
followers: number;
following: number;
category: string;
bio: string;
logo: string;
logoDescription: string;
link: string;
linkedDomain: string;
reviews: number;
recentPostAge: string;
hasWhatsApp: boolean;
postFrequency?: string;
topContentType?: string;
engagement?: string;
}
export interface FacebookAudit {
pages: FacebookPage[];
diagnosis: DiagnosisItem[];
brandInconsistencies: BrandInconsistency[];
consolidationRecommendation: string;
}
export interface OtherChannel {
name: string;
status: 'active' | 'inactive' | 'unknown' | 'not_found';
details: string;
url?: string;
}
export interface TrackingPixel {
name: string;
installed: boolean;
details?: string;
}
export interface WebsiteAudit {
primaryDomain: string;
additionalDomains: { domain: string; purpose: string }[];
snsLinksOnSite: boolean;
trackingPixels: TrackingPixel[];
mainCTA: string;
}
export interface AsIsToBeItem {
area: string;
asIs: string;
toBe: string;
}
export interface PlatformStrategy {
platform: string;
icon: string;
currentMetric: string;
targetMetric: string;
strategies: { strategy: string; detail: string }[];
}
export interface TransformationProposal {
brandIdentity: AsIsToBeItem[];
contentStrategy: AsIsToBeItem[];
platformStrategies: PlatformStrategy[];
websiteImprovements: AsIsToBeItem[];
newChannelProposals: { channel: string; priority: string; rationale: string }[];
}
export interface RoadmapMonth {
month: number;
title: string;
subtitle: string;
tasks: { task: string; completed: boolean }[];
}
export interface KPIMetric {
metric: string;
current: string;
target3Month: string;
target12Month: string;
}
export interface ChannelScore {
channel: string;
icon: string;
score: number;
maxScore: number;
status: Severity;
headline: string;
}
export interface MarketingReport {
id: string;
createdAt: string;
targetUrl: string;
overallScore: number;
clinicSnapshot: ClinicSnapshot;
channelScores: ChannelScore[];
youtubeAudit: YouTubeAudit;
instagramAudit: InstagramAudit;
facebookAudit: FacebookAudit;
otherChannels: OtherChannel[];
websiteAudit: WebsiteAudit;
problemDiagnosis: DiagnosisItem[];
transformation: TransformationProposal;
roadmap: RoadmapMonth[];
kpiDashboard: KPIMetric[];
screenshots?: ScreenshotEvidence[];
}

126
src/types/studio.ts Normal file
View File

@ -0,0 +1,126 @@
// ─── Content Creation Studio Types ───
export type StudioChannel = 'youtube' | 'instagram' | 'naver_blog' | 'tiktok' | 'facebook';
export type ContentFormat =
| 'shorts' | 'long_form' // YouTube
| 'reels' | 'carousel' | 'feed_image' | 'stories' // Instagram
| 'seo_post' | 'faq_post' // Naver Blog
| 'short_video' // TikTok
| 'ad_creative' | 'retarget_content'; // Facebook
export interface ChannelFormatOption {
channel: StudioChannel;
label: string;
icon: string;
formats: { key: ContentFormat; label: string; aspectRatio: '16:9' | '9:16' | '1:1' | '4:5' }[];
}
export type ContentPillarId = string;
export type AssetSourceType = 'collected' | 'my_assets' | 'ai_generated';
export type MusicGenre = 'calm' | 'upbeat' | 'cinematic' | 'corporate' | 'none';
export interface MusicTrack {
id: string;
name: string;
genre: MusicGenre;
duration: string;
}
export type NarrationLanguage = 'ko' | 'en' | 'ja' | 'zh';
export type NarrationVoice = 'male' | 'female';
export interface SoundSettings {
genre: MusicGenre;
trackId: string | null;
narrationEnabled: boolean;
narrationLanguage: NarrationLanguage;
narrationVoice: NarrationVoice;
subtitleEnabled: boolean;
}
export type GenerateOutputType = 'image' | 'video';
export interface StudioState {
channel: StudioChannel | null;
format: ContentFormat | null;
pillarId: ContentPillarId | null;
assetSources: AssetSourceType[];
sound: SoundSettings;
outputType: GenerateOutputType;
}
export const DEFAULT_SOUND: SoundSettings = {
genre: 'calm',
trackId: null,
narrationEnabled: false,
narrationLanguage: 'ko',
narrationVoice: 'female',
subtitleEnabled: true,
};
export const CHANNEL_OPTIONS: ChannelFormatOption[] = [
{
channel: 'youtube',
label: 'YouTube',
icon: 'youtube',
formats: [
{ key: 'shorts', label: 'Shorts', aspectRatio: '9:16' },
{ key: 'long_form', label: 'Long-form', aspectRatio: '16:9' },
],
},
{
channel: 'instagram',
label: 'Instagram',
icon: 'instagram',
formats: [
{ key: 'reels', label: 'Reels', aspectRatio: '9:16' },
{ key: 'carousel', label: 'Carousel', aspectRatio: '1:1' },
{ key: 'feed_image', label: 'Feed Image', aspectRatio: '1:1' },
{ key: 'stories', label: 'Stories', aspectRatio: '9:16' },
],
},
{
channel: 'naver_blog',
label: 'Naver Blog',
icon: 'globe',
formats: [
{ key: 'seo_post', label: 'SEO Post', aspectRatio: '16:9' },
{ key: 'faq_post', label: 'FAQ Post', aspectRatio: '16:9' },
],
},
{
channel: 'tiktok',
label: 'TikTok',
icon: 'tiktok',
formats: [
{ key: 'short_video', label: 'Short Video', aspectRatio: '9:16' },
],
},
{
channel: 'facebook',
label: 'Facebook',
icon: 'facebook',
formats: [
{ key: 'ad_creative', label: 'Ad Creative', aspectRatio: '1:1' },
{ key: 'retarget_content', label: 'Retarget Content', aspectRatio: '16:9' },
],
},
];
export const MUSIC_TRACKS: MusicTrack[] = [
{ id: 'calm-1', name: 'Gentle Morning', genre: 'calm', duration: '2:30' },
{ id: 'calm-2', name: 'Soft Healing', genre: 'calm', duration: '3:15' },
{ id: 'calm-3', name: 'Peaceful Flow', genre: 'calm', duration: '2:45' },
{ id: 'upbeat-1', name: 'Fresh Start', genre: 'upbeat', duration: '2:20' },
{ id: 'upbeat-2', name: 'Bright Day', genre: 'upbeat', duration: '2:50' },
{ id: 'upbeat-3', name: 'Energy Boost', genre: 'upbeat', duration: '3:00' },
{ id: 'cinematic-1', name: 'Grand Reveal', genre: 'cinematic', duration: '3:30' },
{ id: 'cinematic-2', name: 'Transformation', genre: 'cinematic', duration: '4:00' },
{ id: 'cinematic-3', name: 'Before & After', genre: 'cinematic', duration: '2:55' },
{ id: 'corp-1', name: 'Professional Trust', genre: 'corporate', duration: '2:40' },
{ id: 'corp-2', name: 'Clean & Modern', genre: 'corporate', duration: '3:10' },
{ id: 'corp-3', name: 'Confidence', genre: 'corporate', duration: '2:35' },
];

26
tsconfig.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

24
vite.config.ts Normal file
View File

@ -0,0 +1,24 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import {defineConfig, loadEnv} from 'vite';
export default defineConfig(({mode}) => {
const env = loadEnv(mode, '.', '');
return {
plugins: [react(), tailwindcss()],
define: {
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
server: {
// HMR is disabled in AI Studio via DISABLE_HMR env var.
// Do not modify—file watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true',
},
};
});

View File

@ -0,0 +1,426 @@
# 뷰성형외과 — Marketing Intelligence Report
## Social Media & Digital Presence Audit + [As-Is → To-Be] Proposal
**Report Date:** 2026-03-22
**Target:** 뷰성형외과 VIEW Plastic Surgery (@ViewclinicKR)
**Domain:** viewclinic.com
**Location:** 서울시 강남구 봉은사로 107 (논현동), 신논현역 3번 출구 50m
---
## 1. Clinic Snapshot
| Attribute | Value |
|-----------|-------|
| 정식 명칭 | 뷰성형외과의원 (View Plastic Surgery) |
| 개원 | 2005년 (21년차) |
| 의료진 | 28명 |
| 대표 원장 | 최순우 (서울대, 강남언니 9.4★, 1,809 리뷰) |
| 강남언니 평점 | **9.5 / 10** (18,840 리뷰) |
| 시술 가격대 | ₩97,900 ~ ₩13,200,000+ |
| 안전 인증 | 10개 뱃지 (수술실 CCTV, 전담 마취과 전문의, 응급대응, 여의사) |
| 미디어 | 렛미인 출연, 보건복지부장관 표창, 안면윤곽 수상 |
| 의료관광 | VisitKorea 등재, 강남 메디컬투어센터 협력기관 |
---
## 2. 전 채널 Digital Presence Audit — As-Is 현황
### 2.1 YouTube
| Metric | Value | Assessment |
|--------|-------|------------|
| **채널명** | 뷰성형외과 VIEW Plastic Surgery | |
| **핸들** | @ViewclinicKR | |
| **구독자** | **103,000** | ⭐⭐⭐⭐ 강남 성형외과 중 상위권 |
| **총 영상 수** | **1,064개** | ⭐⭐⭐⭐ 대량 콘텐츠 자산 보유 |
| **총 조회수** | **9,952,722회** | ⭐⭐⭐ 구독자 대비 낮음 (영상당 평균 ~9,300회) |
| **주간 조회 증가** | +67,097 (+4.09%) | ⭐⭐⭐ 소폭 성장 중 |
| **월 수익 추정** | $499 ~ $1,000 | ⭐⭐ 수익화보다는 신뢰 구축 채널 |
| **평균 영상 길이** | 4.4분 | 중간 (숏폼+롱폼 혼재) |
| **업로드 빈도** | ~주 1회 | ⭐⭐ 너무 낮음 |
| **채널 개설일** | 2015년 6월 29일 | 10년차 채널 |
| **구독자 순위** | #570K | 글로벌 순위 |
**YouTube 채널 설명 (원문):**
> 💜뷰성형외과💜
> VIEW가 예술이다! ✨
> 19층 규모의 안전스마트 빌딩 ✨
> 지하4층~지상15층 규모의 첨단 시스템 구축
> [상담, 검진, 수술, 회복에 최적화 된 All-In-One시스템]
>
> 환자의 관점에서 생각하고
> 환자의 입장에서 아름다움의 가치를 찾습니다.
> 숙련된 의료전문인 집단이 환자의 삶과 마음에
> 긍정적인 영향을 끼칠 수 있도록
> 고민을 거듭하며
> 이상적인 수술 결과를 선사하고자 합니다.
**YouTube 연결 링크:**
- 🏥 홈페이지: viewclinic.com
- 📸 Instagram: instagram.com/viewplastic
- 📋 이벤트: viewclinic.com/board/events
- 📞 상담 예약: viewclinic.com/counsel/reservation
- 💬 카톡 상담: pf.kakao.com/_xbtVxjl
**콘텐츠 플레이리스트 구조:**
- VIEW 💜 무엇이든 물어보세요 (Q&A)
- VIEW 💜 재수술
- VIEW 💜 가슴
- VIEW 💜 눈+코
- VIEW 💜 윤곽+양악
- VIEW 💜 지방성형
- VIEW 💜 피부+안티에이징
- VIEW랜딩 💜 (브랜딩)
- VIEW 💜 방송영상
**Top Performing Videos (조회수 기준):**
| 제목 | 조회수 | 업로드 | Type |
|------|--------|--------|------|
| 한번에 성공하는 성형 | 574K | 4년 전 | Short |
| 코성형 전후 + 지방이식 | 525K | 4년 전 | Short |
| 쌍수+뒤밑트임 전후 | 392K | 3년 전 | Short |
| V라인턱 변신과정 | 194K | 4년 전 | Short |
| K-미녀 클라스 | 161K | 4년 전 | Short |
| 앞트임하면 대박나는 사람 | 154K | 2년 전 | Short |
| 코성형 내 얼굴에 맞는 코 | 124K | 3년 전 | Long (8m) |
| 아나운서 박은영 가슴 결심 (콜라보) | 127K | 9개월 전 | Long (외부) |
**YouTube 진단 요약:**
| 항목 | 진단 | 심각도 |
|------|------|--------|
| 구독자 대비 조회수 비율 | 영상당 평균 ~9,300회 (103K 구독자 대비 9% 도달률) | 🔴 심각 |
| 최근 롱폼 조회수 | 대부분 1,000~4,000회 수준 | 🔴 심각 |
| Shorts 조회수 | 최근 업로드 500~1,000회 (과거 대비 급감) | 🟡 주의 |
| 업로드 빈도 | 주 1회 — 알고리즘 노출 최소 기준 미달 | 🟡 주의 |
| 콘텐츠 톤앤매너 | 일관성 없음 — 교육/Q&A/전후/브랜딩 혼재 | 🔴 심각 |
| 썸네일 디자인 | 통일된 브랜드 시스템 없음 | 🟡 주의 |
| 최고 성과 Shorts | 4년 전 콘텐츠 — 최근 재현 실패 | 🔴 심각 |
---
### 2.2 Instagram
**두 개의 계정 운영 — 국내(KR) + 국제(EN)**
#### @viewplastic (국내/한국어)
| Metric | Value | Assessment |
|--------|-------|------------|
| **게시물** | 1,409개 | ⭐⭐⭐⭐ 풍부한 콘텐츠 자산 |
| **팔로워** | **14,000** | ⭐⭐ 103K 유튜브 대비 매우 낮음 |
| **팔로잉** | 4,760 | ⚠️ 과다 — 팔로잉/팔로워 비율 불균형 |
| **카테고리** | Health/beauty | |
| **프로필 링크** | litt.ly/viewplasticsurgery | Linktree 사용 |
| **하이라이트** | 수술정보, ABOUT VIEW, 모델 모집, VIEW EVENT, 진료안내 | |
| **Reels** | ⛔ **0개 — 릴스 콘텐츠 전무** | 🔴 치명적 |
| **콘텐츠 형식** | 카드뉴스 (정보형 이미지 카드) 100% | 🟡 단조로움 |
| **프로필 사진** | 모델 사진 (브랜드 로고 아님) | 🟡 비일관 |
#### @view_plastic_surgery (국제/영어)
| Metric | Value | Assessment |
|--------|-------|------------|
| **게시물** | 2,524개 | ⭐⭐⭐⭐ 국내보다 많음 |
| **팔로워** | **68,800** | ⭐⭐⭐⭐ 국내의 5배 |
| **팔로잉** | 2,834 | |
| **바이오** | "VIEW Plastic Surgery Official by VIEW Partners / Most Renowned Hospital in Korea" | |
| **프로필 링크** | litt.ly/viewplasticsurgeryenglish | |
| **하이라이트** | 환자 스토리 (Mathilde, Thet San, Katerina, Yuri), Liposuction, Why VIEW?, Face Contour | |
| **콘텐츠 형식** | Before/After + 환자 스토리 + 시술 설명 (영어) | ⭐⭐⭐ |
| **프로필 사진** | VIEW 골드 로고 (브랜드 일관성 ✅) | |
| **Reels** | 존재 (영상 콘텐츠 일부 활용) | ⭐⭐⭐ |
**Instagram 진단 요약:**
| 문제점 | 상세 | 심각도 |
|--------|------|--------|
| 계정 분리로 인한 팔로워 분산 | KR 14K + EN 68.8K = 합산 82.8K이지만 각각 약함 | 🟡 |
| 국내 계정 Reels 전무 | 인스타 알고리즘 핵심인 Reels 콘텐츠 0개 | 🔴 치명적 |
| 브랜드 비주얼 불일치 | KR=모델 프사, EN=VIEW 골드 로고 | 🟡 |
| 국내 팔로잉 과다 | 4,760 팔로잉 — 팔로우백 전략 의심 | 🟡 |
| 콘텐츠 크로스포스팅 없음 | YouTube Shorts → Instagram Reels 연동 없음 | 🔴 |
| 유튜브 103K 구독자 → 인스타 14K | 플랫폼 간 유입 전략 부재 | 🔴 |
---
### 2.3 Facebook
**두 개의 페이지 운영 — 국내(KR) + 국제(EN)**
#### facebook.com/viewps1 (국내/한국어)
| Metric | Value | Assessment |
|--------|-------|------------|
| **페이지명** | 뷰성형외과 | |
| **팔로워** | **253명** | 🔴 사실상 방치 상태 |
| **팔로잉** | 0명 | |
| **카테고리** | 성형외과 의사 | |
| **소개** | "예쁨이 일상이 되는 순간! #뷰성형외과" | |
| **로고** | 보라색 깃털 로고 ⚠️ (메인 VIEW 로고와 다름) | 🔴 브랜드 불일치 |
| **링크** | viewclinic.com | |
| **최근 게시물** | 1일 전 (활동은 하고 있음) | |
#### facebook.com/viewclinic (국제/영어)
| Metric | Value | Assessment |
|--------|-------|------------|
| **페이지명** | View Plastic Surgery | |
| **팔로워** | **88,000명** | ⭐⭐⭐⭐ 양호 |
| **팔로잉** | 11명 | |
| **바이오** | "Official Account by VIEW Partners" | |
| **리뷰** | 3개 | ⭐ 매우 적음 |
| **로고** | VIEW 골드 로고 ✅ | |
| **링크** | viewplasticsurgery.com (다른 도메인!) | ⚠️ |
| **WhatsApp** | 연결됨 (외국인 상담용) | |
---
### 2.4 기타 채널
| Channel | Status | Details |
|---------|--------|---------|
| **카카오톡** | ✅ 활성 | pf.kakao.com/_xbtVxjl — 상담 전용 |
| **네이버 블로그** | ❓ 미확인 | Naver API 필요 |
| **네이버 플레이스** | ❓ 미확인 | Naver API 필요 |
| **TikTok** | ❌ 미발견 | 계정 없음 또는 비활성 |
| **강남언니** | ✅ 최상위 | 9.5점, 18,840 리뷰, 28 의료진 |
| **모두닥** | ✅ 등재 | 기본 정보 |
| **Goodoc** | ✅ 등재 | 기본 정보 |
| **닥터나우** | ✅ 등재 | 기본 정보 |
### 2.5 웹사이트 (viewclinic.com) 기술 진단
| Item | Status | Details |
|------|--------|---------|
| **도메인** | viewclinic.com | 메인 KR 사이트 |
| **보조 도메인** | viewplasticsurgery.com | EN 국제 사이트 (FB에서 발견) |
| **보조 도메인** | viewclinic-chat.com | 채팅 상담 전용 |
| **보조 도메인** | viewclinic.modoo.at | 모두홈페이지 (구) |
| **SNS 링크 on Homepage** | ⛔ **없음** | 홈페이지에 YouTube/Instagram 링크 0개 |
| **Facebook Pixel** | ✅ 설치 (ID: 299151214739571) | 광고 집행 중 추정 |
| **Kakao Pixel** | ✅ 설치 | 카카오 광고 집행 중 추정 |
| **Google Tag Manager** | ✅ 설치 (GTM-52RT6DMK) | 트래킹 활성 |
| **카카오톡 상담** | ✅ 메인 CTA | 전화 + 카톡이 유일한 전환 경로 |
---
## 3. 핵심 문제 진단 — Brand & Content Strategy Gaps
### 🔴 Critical Issues
#### 3.1 브랜드 아이덴티티 파편화
```
현재 상태 (As-Is):
YouTube 로고: VIEW (골드, 다크 배경)
Instagram KR: 모델 프로필 사진 (로고 없음)
Instagram EN: VIEW (골드 원형 로고)
Facebook KR: 보라색 깃털 로고 (완전 다른 디자인!)
Facebook EN: VIEW (골드 로고)
웹사이트: VIEW (화이트/그레이)
→ 5개 채널, 4개의 서로 다른 시각적 아이덴티티
→ 브랜드 인지도 구축 불가능 상태
```
#### 3.2 콘텐츠 전략 부재
- **콘텐츠 캘린더:** 없음 (비정기적 업로드)
- **콘텐츠 톤앤매너 가이드:** 없음
- **KR ↔ EN 콘텐츠 시너지:** 없음 (완전 분리 운영)
- **YouTube → Instagram 크로스포스팅:** 없음
- **Shorts ↔ Reels 동시 활용:** 안 함
#### 3.3 플랫폼 간 유입 단절
```
YouTube 103K ─✕─ Instagram KR 14K
(연결 경로 없음)
Website ─✕─ YouTube/Instagram (홈페이지에 SNS 링크 없음)
강남언니 18.8K 리뷰 ─✕─ YouTube (리뷰→영상 전환 없음)
```
---
## 4. [As-Is → To-Be] Transformation Proposal
### 4.1 브랜드 아이덴티티 통합
| Area | As-Is | To-Be |
|------|-------|-------|
| **로고** | 채널마다 다른 로고 4종 | VIEW 골드 로고 1종 통일 |
| **컬러 팔레트** | 없음 (혼재) | Primary: Gold (#C4A462) + Dark (#1A1A1A) / Accent: Purple (#8B5CF6) |
| **프로필 사진** | KR=모델, EN=로고, FB=깃털 | 전 채널 VIEW 골드 로고 통일 |
| **배너 디자인** | 채널마다 다름 | 통일 배너 시스템 (시즌별 교체) |
| **바이오 메시지** | 채널마다 다른 메시지 | 핵심 USP 1줄 통일: "안전이 예술이 되는 곳 — 21년 무사고 VIEW" |
| **해시태그** | 비체계적 | 브랜드 태그 세트: #뷰성형외과 #VIEW성형 #강남성형외과 #21년무사고 |
### 4.2 콘텐츠 전략 수립
| Area | As-Is | To-Be |
|------|-------|-------|
| **콘텐츠 캘린더** | 없음 | 월간 콘텐츠 캘린더 (4주 사이클) |
| **업로드 빈도** | YouTube 주1회, Instagram 비정기 | YouTube 주3회 + Instagram 일1회 + Shorts/Reels 주5회 |
| **콘텐츠 포맷** | KR Instagram = 카드뉴스만 | 카드뉴스 30% + Reels 40% + 카루셀 20% + Stories 10% |
| **콘텐츠 앵글** | 시술 정보 중심 (병원 관점) | 환자 의사결정 보조 중심 (환자 관점) |
| **톤앤매너** | 없음 | "차분한 전문가" — 과장 없이, 설명으로 설득 |
| **Shorts/Reels** | 과거 히트작 재현 실패 | Before/After + 원장 한 마디 + 환자 브이로그 체계화 |
### 4.3 콘텐츠 파이프라인 제안
```
[Phase 1: 콘텐츠 소스]
원장 촬영 (월 2회, 각 2시간)
→ 롱폼 4개 + 숏폼 20개 + 카드뉴스 8개 + 스토리 소스
기존 YouTube 1,064개 영상
→ AI 기반 숏폼 재편집 (30초 클립 × 100개)
→ Instagram Reels 자동 변환
환자 후기 (강남언니 18,840건)
→ 텍스트 기반 카드뉴스 시리즈
→ "실제 환자 Q&A" 콘텐츠 시리즈
[Phase 2: 크로스 플랫폼 배포]
YouTube Long → YouTube Shorts + Instagram Reels + TikTok
Blog Post → Instagram 카드뉴스 + 네이버 블로그
환자 후기 → Instagram Stories + YouTube Community
[Phase 3: 성과 피드백]
조회수/저장수/공유수 분석 → 다음 달 콘텐츠 전략 반영
```
### 4.4 플랫폼별 전략 — To-Be
#### YouTube (103K → 목표 200K / 12개월)
| Strategy | Detail |
|----------|--------|
| **업로드 빈도** | 주 3회 (롱폼 1 + Shorts 2) |
| **Shorts 전략** | Before/After 15초 + 원장 한 줄 답변 30초 + 환자 반응 15초 |
| **롱폼 전략** | "원장이 설명하는" 시리즈 (5~10분) — 교육+신뢰 구축 |
| **썸네일 시스템** | VIEW 골드 워터마크 + 일관된 폰트/컬러 시스템 구축 |
| **커뮤니티 탭** | 주 2회 투표/질문 — 구독자 참여 활성화 |
| **재활용 전략** | 기존 1,064개 영상에서 AI 숏폼 100개 추출 |
#### Instagram KR @viewplastic (14K → 목표 50K / 12개월)
| Strategy | Detail |
|----------|--------|
| **🔴 Reels 즉시 시작** | YouTube Shorts 동시 게시 → 최소 주 5개 |
| **카드뉴스 개선** | 통일 디자인 시스템 + VIEW 골드 브랜딩 적용 |
| **Stories 활용** | 일 2~3개 (상담 비하인드, 병원 일상, Q&A 스티커) |
| **하이라이트 재구성** | 시술별 (눈/코/가슴/윤곽) + 환자후기 + 원장소개 |
| **프로필 사진** | 모델 사진 → VIEW 골드 로고로 변경 |
| **팔로잉 정리** | 4,760 → 300 이하로 정리 (브랜드 신뢰도) |
#### Instagram EN @view_plastic_surgery (68.8K → 목표 100K / 12개월)
| Strategy | Detail |
|----------|--------|
| **Reels 강화** | 환자 브이로그 (외국인 Korean journey 스토리텔링) |
| **의료관광 콘텐츠** | "Korea Trip + Surgery" 패키지 콘텐츠 |
| **콜라보** | 외국인 뷰티 인플루언서 초청 콘텐츠 |
| **KR 채널 시너지** | KR 콘텐츠 영어 자막 → EN 채널 크로스포스팅 |
#### Facebook (통합 제안)
| Strategy | Detail |
|----------|--------|
| **계정 통합** | KR 253명 페이지 → EN 88K 페이지로 통합 또는 폐쇄 |
| **로고 통일** | 보라색 깃털 ❌ → VIEW 골드 로고 |
| **역할 정의** | FB = 광고 랜딩 + 리타겟 전용 (오가닉 포기) |
| **광고 최적화** | Facebook Pixel 이미 설치 → 리타겟 광고 세팅 |
#### 신규 채널 제안
| Channel | Priority | Rationale |
|---------|----------|-----------|
| **TikTok** | P1 | 20~30대 첫 수술 고민층 도달, YouTube Shorts 동시 배포 |
| **네이버 블로그** | P0 | 한국 검색 1위 플랫폼 — SEO 핵심 |
| **네이버 플레이스** | P0 | 지역 검색 노출 필수 |
| **카카오 채널** | P1 (이미 보유) | 상담 전환 퍼널 강화 |
### 4.5 웹사이트 개선
| Area | As-Is | To-Be |
|------|-------|-------|
| **SNS 링크** | 홈페이지에 0개 | Header/Footer에 YouTube + Instagram + KakaoTalk 링크 |
| **YouTube 임베드** | 없음 | 시술 페이지별 관련 YouTube 영상 임베드 |
| **블로그/콘텐츠 허브** | 없음 | SEO 콘텐츠 허브 구축 (시술별 가이드) |
| **도메인 통합** | 4개 도메인 분산 | viewclinic.com 단일 도메인 + /en 국제 페이지 |
---
## 5. 전환 퍼널 As-Is → To-Be
### As-Is (현재)
```
[환자 인지] → 강남언니/네이버 검색 → viewclinic.com → 전화/카톡 → 상담
(YouTube/Instagram은 유입 경로에서 단절)
```
### To-Be (제안)
```
[환자 인지]
├── YouTube Shorts/Reels → 프로필 링크 → 상담 예약
├── Instagram Reels → 하이라이트 → DM/카톡
├── 네이버 블로그 → 시술 가이드 → 플레이스 → 전화
├── TikTok → YouTube 롱폼 → 신뢰 구축 → 상담 예약
├── 강남언니 리뷰 → 웹사이트 → YouTube 임베드 → 상담
└── Google SEO → 블로그 → 상담 예약
[전환 단계]
인지 → 관심(YouTube/Reels) → 신뢰(롱폼/후기) → 검증(강남언니) → 상담(카톡/전화) → 예약
```
---
## 6. 90일 실행 로드맵
### Month 1: Foundation (기반 구축)
- [ ] 브랜드 아이덴티티 가이드 확정 (로고, 컬러, 폰트, 톤앤매너)
- [ ] 전 채널 프로필 사진/배너 통일 교체
- [ ] Facebook KR 페이지 정리 (통합 또는 폐쇄)
- [ ] Instagram KR 팔로잉 정리 (4,760 → 300)
- [ ] 웹사이트에 YouTube/Instagram 링크 추가
- [ ] 기존 YouTube 영상 100개 → AI 숏폼 추출 작업 시작
- [ ] 콘텐츠 캘린더 v1 수립
### Month 2: Content Engine (콘텐츠 엔진 가동)
- [ ] YouTube Shorts 주 3~5회 업로드 시작
- [ ] Instagram Reels 주 5회 업로드 시작 (YouTube Shorts 동시 배포)
- [ ] 원장 촬영 세션 월 2회 스케줄 확정
- [ ] "원장이 설명하는" 시리즈 4편 제작/업로드
- [ ] 네이버 블로그 계정 개설 및 시술 가이드 10편 게시
- [ ] TikTok 계정 개설 및 Shorts 동시 배포
### Month 3: Optimization (최적화 & 광고)
- [ ] 콘텐츠 성과 분석 리포트 v1 (조회수/저장/공유/전환)
- [ ] 고성과 콘텐츠 기반 Instagram/Facebook 광고 세팅
- [ ] YouTube 썸네일 A/B 테스트
- [ ] 콘텐츠 캘린더 v2 (성과 데이터 반영)
- [ ] 네이버 플레이스 최적화
- [ ] KPI 리뷰: 구독자/팔로워 성장률, 상담 전환 추적
---
## 7. KPI 대시보드
| Metric | 현재 (As-Is) | 3개월 목표 | 12개월 목표 |
|--------|-------------|-----------|------------|
| YouTube 구독자 | 103K | 115K | 200K |
| YouTube 월 조회수 | ~270K (추정) | 500K | 1.5M |
| YouTube Shorts 평균 조회수 | 500~1,000 | 5,000 | 20,000 |
| Instagram KR 팔로워 | 14K | 20K | 50K |
| Instagram KR Reels 평균 조회수 | 0 (없음) | 3,000 | 10,000 |
| Instagram EN 팔로워 | 68.8K | 75K | 100K |
| 네이버 블로그 방문자 | 0 (없음) | 5,000/월 | 30,000/월 |
| 웹사이트 → SNS 유입 | 0% | 5% | 15% |
| 콘텐츠 → 상담 전환 | 측정 불가 | UTM 추적 시작 | 월 50건 |
---
*이 리포트는 INFINITH Marketing Intelligence Module의 프로토타입 출력물입니다.*
*실제 서비스에서는 Naver API, Ahrefs, YouTube Analytics, Instagram Insights 데이터가 자동 통합됩니다.*