Compare commits
No commits in common. "master" and "main" have entirely different histories.
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "infinith-dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 3000
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -1,24 +1,8 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
node_modules/
|
||||
build/
|
||||
dist/
|
||||
coverage/
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
*.log
|
||||
.env*
|
||||
!.env.example
|
||||
|
|
|
|||
|
|
@ -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 clinic’s 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 – 5–8 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
|
||||
|
||||
➡️ 6–8 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
|
||||
|
||||
➡️ 6–8 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
|
||||
|
||||
➡️ 6–8 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 Korea’s 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분
|
||||
|
||||
◦ Korea’s 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 (0–3s)**
|
||||
|
||||
“성형에서 가장 무서운 건,
|
||||
설명 없이 결정되는 순간입니다.”
|
||||
|
||||
**Body (4–****12s)**
|
||||
|
||||
“왜 이 수술을 해야 하는지
|
||||
왜 이 정도가 적당한지
|
||||
그걸 먼저 설명합니다.”
|
||||
|
||||
**Close (13–18s)**
|
||||
|
||||
“결정은, 그 다음입니다.”
|
||||
|
||||
▸ 모션 그래픽 텍스트
|
||||
|
||||
• “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:** 주 2–3회 (저장·상담 전환 목적)
|
||||
|
||||
• **Music** 기반**:** 주 3–4회 (도달·리타겟 풀 확장)
|
||||
|
||||
• 광고 집행 시
|
||||
→ Narrative = 리타겟
|
||||
→ Music = 신규 유입
|
||||
|
||||
마지막으로, 솔직하게 말하면
|
||||
|
||||
이 구조의 핵심은 **“자랑하지 않는 홍보”**야.
|
||||
뷰성형외과는
|
||||
👉 보여주면 되는 병원이 아니라
|
||||
👉 설명해주면 선택되는 병원이야.
|
||||
|
|
@ -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.*
|
||||
63
README.md
|
|
@ -1,55 +1,20 @@
|
|||
## 프로젝트
|
||||
- 이름 : Clinic AD
|
||||
<div align="center">
|
||||
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||
</div>
|
||||
|
||||
## 기술 스택
|
||||
- React 19 + TypeScript
|
||||
- Vite
|
||||
- Tailwind CSS
|
||||
- TanStack Query
|
||||
- Zustand
|
||||
# Run and deploy your AI Studio app
|
||||
|
||||
## 디렉토리 구조
|
||||
This contains everything you need to run your app locally.
|
||||
|
||||
```bash
|
||||
src/
|
||||
├── app/ # 애플리케이션 진입점 및 전역 설정 (Router, Providers, 글로벌 스타일)
|
||||
│ └── providers/ # React Context Provider 모음 (QueryProvider 등)
|
||||
├── assets/ # 정적 파일 (이미지, 폰트, SVG 아이콘 등)
|
||||
├── components/ # 도메인에 종속되지 않는 공통 UI 컴포넌트 (버튼, 카드, 디자인 시스템)
|
||||
├── features/ # 핵심 비즈니스 로직 및 도메인 영역 (이 구조의 핵심)
|
||||
│ ├── home/ # 홈(랜딩) 도메인
|
||||
│ │ ├── content/ # 해당 도메인 전용 UI 텍스트·카피 (정적 콘텐츠)
|
||||
│ │ ├── hooks/ # 해당 도메인 전용 커스텀 훅
|
||||
│ │ └── ui/ # 해당 도메인 전용 UI 컴포넌트
|
||||
│ ├── report/ # 리포트 도메인
|
||||
│ │ ├── config/ # 섹션 ID·레이블 등 UI 설정값
|
||||
│ │ ├── hooks/ # 해당 도메인 전용 커스텀 훅
|
||||
│ │ ├── mocks/ # API 연동 전 임시 목업 데이터
|
||||
│ │ ├── types/ # 해당 도메인 전용 타입 정의
|
||||
│ │ └── ui/ # 해당 도메인 전용 UI 컴포넌트
|
||||
│ └── plan/ # 마케팅 플랜 도메인 (report와 동일한 구조)
|
||||
│ ├── config/
|
||||
│ ├── hooks/
|
||||
│ ├── mocks/
|
||||
│ ├── types/
|
||||
│ └── ui/
|
||||
├── hooks/ # 전역에서 사용하는 공통 훅 (useInView 등)
|
||||
├── layouts/ # 페이지 레이아웃 (GNB, SubNav, Footer 등)
|
||||
├── pages/ # 라우팅과 1:1 매칭되는 페이지 진입점 (features의 컴포넌트만 조립)
|
||||
├── services/ # 공통 API 클라이언트 설정 (Axios 인스턴스, 인터셉터 등)
|
||||
├── store/ # 전역 상태 관리 (사용자 세션, 테마 등)
|
||||
├── types/ # 여러 도메인에서 공유하는 공통 타입 정의
|
||||
└── utils/ # 공통 유틸리티 함수 (숫자 포맷팅, URL 처리 등)
|
||||
```
|
||||
View your app in AI Studio: https://ai.studio/apps/d446f5f5-e089-404a-93ce-32a72f2f5108
|
||||
|
||||
## 시작하기
|
||||
## Run Locally
|
||||
|
||||
### 설치
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
**Prerequisites:** Node.js
|
||||
|
||||
### 실행
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
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`
|
||||
|
|
|
|||
|
|
@ -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*
|
||||
|
|
@ -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` |
|
||||
|
||||
---
|
||||
|
||||
*이 문서는 코드와 함께 버전 관리됩니다. 디자인 변경 시 반드시 이 문서도 업데이트하세요.*
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
|
|
@ -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
|
||||
12
index.html
|
|
@ -1,13 +1,17 @@
|
|||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>fe</title>
|
||||
<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/app/main.tsx"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "Remix: INFINITH - Infinite Marketing",
|
||||
"description": "Infinite Marketing for Premium Medical Business \u0026 Marketing Agency",
|
||||
"requestFramePermissions": []
|
||||
}
|
||||
54
package.json
|
|
@ -1,38 +1,38 @@
|
|||
{
|
||||
"name": "fe",
|
||||
"name": "react-example",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"dev": "vite --port=3000 --host=0.0.0.0",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"clean": "rm -rf dist",
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"axios": "^1.13.6",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"zustand": "^5.0.11"
|
||||
"@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": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-svgr": "^5.0.0",
|
||||
"vite-tsconfig-paths": "^6.1.1"
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
Before Width: | Height: | Size: 9.3 KiB |
|
|
@ -1,24 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.9 KiB |
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import { Routes, Route } from "react-router-dom";
|
||||
// layouts
|
||||
import MainLayout from "@/layouts/MainLayout";
|
||||
import MainSubNavLayout from "@/layouts/MainSubNavLayout";
|
||||
|
||||
// pages
|
||||
import { Home } from "@/pages/Home";
|
||||
import { PlanPage } from "@/pages/Plan";
|
||||
import { ReportPage } from "@/pages/Report";
|
||||
|
||||
function App() {
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<MainLayout />}>
|
||||
<Route index element={<Home />} />
|
||||
</Route>
|
||||
<Route element={<MainSubNavLayout />}>
|
||||
<Route path="report/:id" element={<ReportPage />} />
|
||||
<Route path="plan/:id" element={<PlanPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
|
@ -1,259 +0,0 @@
|
|||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@100..900&family=Playfair+Display:ital,wght@0,400..900;1,400..900&display=swap');
|
||||
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css');
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ─── Design Tokens ───────────────────────────────────────────────── */
|
||||
@theme {
|
||||
/* Navy ─ 950(darkest) · 900 · 800 · 50(lightest)
|
||||
짙은 네이비 계열. 본문 텍스트, 다크 섹션 배경, 그라디언트 끝 */
|
||||
--color-navy-950: #021341;
|
||||
--color-navy-900: #0A1128;
|
||||
--color-navy-800: #1A2B5E;
|
||||
--color-navy-50: #F4F6FB;
|
||||
|
||||
/* Violet ─ 700(deep) · 500(accent)
|
||||
퍼플 계열. 그라디언트 시작(700), 활성 상태·아이콘(500) */
|
||||
--color-violet-700: #4F1DA1;
|
||||
--color-violet-500: #6C5CE7;
|
||||
/* Marketing accents (CTA / Process / Hero blobs — shared with DEMO) */
|
||||
--color-marketing-cream: #fff3eb;
|
||||
--color-marketing-lilac: #e4cfff;
|
||||
--color-marketing-ice: #f5f9ff;
|
||||
--color-lavender-100: #f3e8ff;
|
||||
--color-lavender-200: #e9d4ff;
|
||||
--color-lavender-300: #d8b4fe;
|
||||
--color-violet-hover: #af90ff;
|
||||
/* Hero radial + blob tints */
|
||||
--color-hero-wash-start: #e0e7ff;
|
||||
--color-hero-wash-mid: #faf5ff;
|
||||
--color-hero-wash-end: #fdf2f8;
|
||||
--color-marketing-blush: #fbcfe8;
|
||||
|
||||
/* Neutral ─ 90(darkest) · 80 · 70 · 60 · 40 · 30 · 20 · 10 · 00(white) */
|
||||
--color-neutral-90: #151515;
|
||||
--color-neutral-80: #303030;
|
||||
--color-neutral-70: #606060;
|
||||
--color-neutral-60: #808080;
|
||||
--color-neutral-40: #DADBDE;
|
||||
--color-neutral-30: #EAEBEF;
|
||||
--color-neutral-20: #F0F1F4;
|
||||
--color-neutral-10: #F7F8FA;
|
||||
--color-neutral-00: #FFFFFF;
|
||||
|
||||
/* Status ─ {critical | warning | good | info} × {bg | text | border | dot}
|
||||
파스텔 시맨틱 팔레트. 원색(빨강·초록) 사용 금지 */
|
||||
--color-status-critical-bg: #FFF0F0;
|
||||
--color-status-critical-text: #7C3A4B;
|
||||
--color-status-critical-border: #F5D5DC;
|
||||
--color-status-critical-dot: #D4889A;
|
||||
|
||||
--color-status-warning-bg: #FFF6ED;
|
||||
--color-status-warning-text: #7C5C3A;
|
||||
--color-status-warning-border: #F5E0C5;
|
||||
--color-status-warning-dot: #D4A872;
|
||||
|
||||
--color-status-good-bg: #F3F0FF;
|
||||
--color-status-good-text: #4A3A7C;
|
||||
--color-status-good-border: #D5CDF5;
|
||||
--color-status-good-dot: #9B8AD4;
|
||||
|
||||
--color-status-info-bg: #EFF0FF;
|
||||
--color-status-info-text: #3A3F7C;
|
||||
--color-status-info-border: #C5CBF5;
|
||||
--color-status-info-dot: #7A84D4;
|
||||
|
||||
/* Typography */
|
||||
--font-sans: "Pretendard Variable", "Pretendard", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-serif: "Playfair Display", ui-serif, Georgia, Cambria, serif;
|
||||
--font-inter: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
|
||||
/* Animation */
|
||||
--animate-blob: blob 7s infinite;
|
||||
--animate-blob-large: blob-large 25s infinite ease-in-out;
|
||||
--animate-fade-in-up: fade-in-up 0.6s ease-out both;
|
||||
--animate-fade-in-left: fade-in-left 0.6s ease-out both;
|
||||
--animate-fade-in-right: fade-in-right 0.6s ease-out both;
|
||||
--animate-fade-in-scale: fade-in-scale 0.8s ease-out both;
|
||||
}
|
||||
|
||||
/* ─── Keyframes ───────────────────────────────────────────────────── */
|
||||
@keyframes blob {
|
||||
0% { transform: translate(0, 0) scale(1); }
|
||||
33% { transform: translate(30px, -50px) scale(1.1); }
|
||||
66% { transform: translate(-20px, 20px) scale(0.9); }
|
||||
100% { transform: translate(0, 0) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes blob-large {
|
||||
0% { transform: translate(0, 0) scale(1); }
|
||||
33% { transform: translate(8vw, -8vh) scale(1.1); }
|
||||
66% { transform: translate(-8vw, 8vh) scale(0.9); }
|
||||
100% { transform: translate(0, 0) scale(1); }
|
||||
}
|
||||
|
||||
/* fade + slide-up: replaces framer-motion entrance (opacity 0→1, y 20→0) */
|
||||
@keyframes fade-in-up {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* fade + slide-left/right: whileInView x:-20→0 / x:20→0 */
|
||||
@keyframes fade-in-left {
|
||||
from { opacity: 0; transform: translateX(-20px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
@keyframes fade-in-right {
|
||||
from { opacity: 0; transform: translateX(20px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
@keyframes fade-in-scale {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* ─── Animation Delays ────────────────────────────────────────────── */
|
||||
/* UI entrance stagger (short) */
|
||||
.animation-delay-100 { animation-delay: 0.1s; }
|
||||
.animation-delay-200 { animation-delay: 0.2s; }
|
||||
.animation-delay-300 { animation-delay: 0.3s; }
|
||||
.animation-delay-400 { animation-delay: 0.4s; }
|
||||
.animation-delay-500 { animation-delay: 0.5s; }
|
||||
|
||||
/* Blob float delays (long) */
|
||||
.animation-delay-2000 { animation-delay: 2s; }
|
||||
.animation-delay-4000 { animation-delay: 4s; }
|
||||
.animation-delay-7000 { animation-delay: 7s; }
|
||||
.animation-delay-14000 { animation-delay: 14s; }
|
||||
|
||||
/* ─── Utility Classes ─────────────────────────────────────────────── */
|
||||
|
||||
/* 라이트 섹션 헤딩 그라디언트 텍스트
|
||||
- base의 h2 { color: navy }와 함께 쓸 때 color를 반드시 투명으로 두어야 그라데이션이 보임
|
||||
- background 단축 속성은 clip을 리셋할 수 있어 background-image만 사용 */
|
||||
.text-gradient {
|
||||
color: transparent;
|
||||
background-image: linear-gradient(to right, var(--color-navy-900), #3b5998);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
/*
|
||||
* 그라데이션 제목(h2)과 같은 레이아웃 안의 뱃지·버튼:
|
||||
* WebKit 계열에서 -webkit-text-fill-color 가 상속되면 text-* 만으로는 글씨가 비어 보일 수 있음.
|
||||
* currentColor 는 해당 요소의 (Tailwind) color 값과 맞춘다.
|
||||
*/
|
||||
.solid-text-paint {
|
||||
-webkit-text-fill-color: currentColor !important;
|
||||
/* bg-* 와 같은 요소에 clip:text가 남으면 글리프가 배경으로만 채워져 비어 보일 수 있음 */
|
||||
-webkit-background-clip: border-box !important;
|
||||
background-clip: border-box !important;
|
||||
}
|
||||
|
||||
/* 반투명 글래스 카드 — 랜딩 섹션 */
|
||||
.glass-card {
|
||||
@apply bg-white/70 backdrop-blur-xl border border-white/40 rounded-2xl;
|
||||
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.07);
|
||||
}
|
||||
|
||||
/* 다크 섹션 위 화이트 카드 사선 그림자 */
|
||||
.card-shadow { box-shadow: 3px 4px 12px rgba(0, 0, 0, 0.06); }
|
||||
.card-shadow:hover { box-shadow: 4px 6px 16px rgba(0, 0, 0, 0.09); }
|
||||
|
||||
/* 브랜드 그라디언트 Primary 버튼 */
|
||||
.btn-primary {
|
||||
@apply cursor-pointer bg-gradient-to-r from-violet-700 to-navy-950 text-white rounded-full font-medium transition-all;
|
||||
}
|
||||
|
||||
/* ─── Typography Scale ────────────────────────────────────────────── */
|
||||
/* Display */
|
||||
.display-72 { font-family: var(--font-serif); font-size: 72px; font-weight: 700; line-height: 1.1; letter-spacing: -1.44px; }
|
||||
.display-48 { font-family: var(--font-serif); font-size: 48px; font-weight: 700; line-height: 1.1; letter-spacing: -0.96px; }
|
||||
.display-36 { font-family: var(--font-serif); font-size: 36px; font-weight: 700; line-height: 1.15; letter-spacing: -0.72px; }
|
||||
|
||||
/* Headline */
|
||||
.headline-30 { font-family: var(--font-serif); font-size: 30px; font-weight: 700; line-height: 1.2; letter-spacing: -0.6px; }
|
||||
.headline-24 { font-family: var(--font-serif); font-size: 24px; font-weight: 700; line-height: 1.3; letter-spacing: -0.48px; }
|
||||
.headline-20 { font-family: var(--font-serif); font-size: 20px; font-weight: 700; line-height: 1.4; letter-spacing: -0.4px; }
|
||||
|
||||
/* Title */
|
||||
.title-20 { font-family: var(--font-sans); font-size: 20px; font-weight: 700; line-height: 1.4; letter-spacing: -0.4px; }
|
||||
.title-18 { font-family: var(--font-sans); font-size: 18px; font-weight: 700; line-height: 1.4; letter-spacing: -0.36px; }
|
||||
.title-18-semibold { font-family: var(--font-sans); font-size: 18px; font-weight: 600; line-height: 1.4; letter-spacing: -0.36px; }
|
||||
.title-16 { font-family: var(--font-sans); font-size: 16px; font-weight: 600; line-height: 1.4; letter-spacing: -0.32px; }
|
||||
.title-14 { font-family: var(--font-sans); font-size: 14px; font-weight: 600; line-height: 1.4; letter-spacing: -0.28px; }
|
||||
|
||||
/* Body */
|
||||
.body-20 { font-family: var(--font-sans); font-size: 20px; font-weight: 400; line-height: 1.6; letter-spacing: -0.4px; }
|
||||
.body-20-medium { font-family: var(--font-sans); font-size: 20px; font-weight: 500; line-height: 1.4; letter-spacing: -0.4px; }
|
||||
.body-18 { font-family: var(--font-sans); font-size: 18px; font-weight: 400; line-height: 1.6; letter-spacing: -0.36px; }
|
||||
.body-16 { font-family: var(--font-sans); font-size: 16px; font-weight: 400; line-height: 1.5; letter-spacing: -0.32px; }
|
||||
.body-16-medium { font-family: var(--font-sans); font-size: 16px; font-weight: 500; line-height: 1.5; letter-spacing: -0.32px; }
|
||||
.body-14 { font-family: var(--font-sans); font-size: 14px; font-weight: 400; line-height: 1.5; letter-spacing: -0.28px; }
|
||||
.body-14-medium { font-family: var(--font-sans); font-size: 14px; font-weight: 500; line-height: 1.5; letter-spacing: -0.28px; }
|
||||
|
||||
/* Label */
|
||||
.label-12 { font-family: var(--font-sans); font-size: 12px; font-weight: 500; line-height: 1.4; letter-spacing: -0.24px; }
|
||||
.label-12-semibold { font-family: var(--font-sans); font-size: 12px; font-weight: 600; line-height: 1.4; letter-spacing: -0.24px; }
|
||||
|
||||
/* Responsive body: 18px → 20px at md (text-lg / text-xl) */
|
||||
.body-18-md-20 {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 400;
|
||||
line-height: 1.625;
|
||||
letter-spacing: -0.36px;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.body-18-md-20 {
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: -0.4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive sans title: 18px → 20px at md (module card titles 등) */
|
||||
.title-18-md-20 {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
letter-spacing: -0.36px;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.title-18-md-20 {
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: -0.4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive list body: 14px → 16px at md */
|
||||
.body-14-md-16 {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 400;
|
||||
line-height: 1.625;
|
||||
letter-spacing: -0.28px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.body-14-md-16 {
|
||||
font-size: 1rem;
|
||||
letter-spacing: -0.32px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Base ────────────────────────────────────────────────────────── */
|
||||
@layer base {
|
||||
html, body {
|
||||
@apply font-sans text-neutral-80 bg-neutral-10 antialiased;
|
||||
font-style: normal;
|
||||
line-height: normal;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
@apply font-serif text-navy-900;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { QueryProvider } from './providers/QueryProvider';
|
||||
|
||||
import './index.css';
|
||||
import App from './App.tsx';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<QueryProvider>
|
||||
<App />
|
||||
</QueryProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import { useState } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
export function QueryProvider({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000, // 1분 동안 데이터를 fresh로 유지
|
||||
gcTime: 5 * 60 * 1000, // 5분 동안 캐시 유지 후 가비지 컬렉션
|
||||
refetchOnWindowFocus: false, // 윈도우 포커스 시 자동 재요청 비활성화
|
||||
retry: 1, // 요청 실패 시 1회 재시도
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 10h12M12 6l4 4-4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 227 B |
|
Before Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 185 KiB |
|
|
@ -1,4 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" />
|
||||
<path d="M9 12l2 2 4-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 312 B |
|
|
@ -1,3 +0,0 @@
|
|||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 12L6 8l4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 220 B |
|
|
@ -1,3 +0,0 @@
|
|||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 4l4 4-4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 218 B |
|
|
@ -1,16 +0,0 @@
|
|||
<svg width="28" height="20" viewBox="0 0 28 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="inf-grad" x1="0" y1="0" x2="28" y2="20" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="currentColor" stop-opacity="0.9"/>
|
||||
<stop offset="45%" stop-color="currentColor" stop-opacity="0.55"/>
|
||||
<stop offset="100%" stop-color="currentColor" stop-opacity="0.85"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d="M2,10 C2,4 8,4 14,10 C20,16 26,16 26,10 C26,4 20,4 14,10 C8,16 2,16 2,10Z"
|
||||
stroke="url(#inf-grad)"
|
||||
stroke-width="2.8"
|
||||
stroke-linejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 651 B |
|
|
@ -1,13 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<g clip-path="url(#clip0_2455_185)">
|
||||
<path d="M7.34467 1.87599C7.37324 1.72306 7.45439 1.58493 7.57407 1.48553C7.69375 1.38614 7.84443 1.33173 8 1.33173C8.15558 1.33173 8.30626 1.38614 8.42594 1.48553C8.54562 1.58493 8.62677 1.72306 8.65534 1.87599L9.356 5.58132C9.40577 5.84475 9.53379 6.08707 9.72336 6.27664C9.91293 6.46621 10.1552 6.59423 10.4187 6.64399L14.124 7.34466C14.2769 7.37322 14.4151 7.45437 14.5145 7.57405C14.6139 7.69374 14.6683 7.84441 14.6683 7.99999C14.6683 8.15557 14.6139 8.30624 14.5145 8.42592C14.4151 8.54561 14.2769 8.62676 14.124 8.65532L10.4187 9.35599C10.1552 9.40575 9.91293 9.53377 9.72336 9.72334C9.53379 9.91291 9.40577 10.1552 9.356 10.4187L8.65534 14.124C8.62677 14.2769 8.54562 14.415 8.42594 14.5144C8.30626 14.6138 8.15558 14.6683 8 14.6683C7.84443 14.6683 7.69375 14.6138 7.57407 14.5144C7.45439 14.415 7.37324 14.2769 7.34467 14.124L6.644 10.4187C6.59424 10.1552 6.46622 9.91291 6.27665 9.72334C6.08708 9.53377 5.84477 9.40575 5.58134 9.35599L1.876 8.65532C1.72307 8.62676 1.58495 8.54561 1.48555 8.42592C1.38615 8.30624 1.33174 8.15557 1.33174 7.99999C1.33174 7.84441 1.38615 7.69374 1.48555 7.57405C1.58495 7.45437 1.72307 7.37322 1.876 7.34466L5.58134 6.64399C5.84477 6.59423 6.08708 6.46621 6.27665 6.27664C6.46622 6.08707 6.59424 5.84475 6.644 5.58132L7.34467 1.87599Z" stroke="#6C5CE7" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M13.3333 1.33334V4.00001" stroke="#6C5CE7" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14.6667 2.66666H12" stroke="#6C5CE7" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2.66666 14.6667C3.40304 14.6667 3.99999 14.0697 3.99999 13.3333C3.99999 12.597 3.40304 12 2.66666 12C1.93028 12 1.33333 12.597 1.33333 13.3333C1.33333 14.0697 1.93028 14.6667 2.66666 14.6667Z" stroke="#6C5CE7" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2455_185">
|
||||
<rect width="16" height="16" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.1 KiB |
|
|
@ -1,13 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<g clip-path="url(#clip0_2455_340)">
|
||||
<path d="M7.34467 1.87605C7.37323 1.72312 7.45438 1.58499 7.57407 1.4856C7.69375 1.3862 7.84442 1.33179 8 1.33179C8.15558 1.33179 8.30625 1.3862 8.42594 1.4856C8.54562 1.58499 8.62677 1.72312 8.65533 1.87605L9.356 5.58138C9.40576 5.84482 9.53378 6.08713 9.72335 6.2767C9.91292 6.46627 10.1552 6.59429 10.4187 6.64405L14.124 7.34472C14.2769 7.37328 14.4151 7.45443 14.5145 7.57412C14.6139 7.6938 14.6683 7.84447 14.6683 8.00005C14.6683 8.15563 14.6139 8.3063 14.5145 8.42599C14.4151 8.54567 14.2769 8.62682 14.124 8.65538L10.4187 9.35605C10.1552 9.40581 9.91292 9.53383 9.72335 9.7234C9.53378 9.91297 9.40576 10.1553 9.356 10.4187L8.65533 14.1241C8.62677 14.277 8.54562 14.4151 8.42594 14.5145C8.30625 14.6139 8.15558 14.6683 8 14.6683C7.84442 14.6683 7.69375 14.6139 7.57407 14.5145C7.45438 14.4151 7.37323 14.277 7.34467 14.1241L6.644 10.4187C6.59424 10.1553 6.46622 9.91297 6.27665 9.7234C6.08708 9.53383 5.84477 9.40581 5.58133 9.35605L1.876 8.65538C1.72307 8.62682 1.58494 8.54567 1.48555 8.42599C1.38615 8.3063 1.33174 8.15563 1.33174 8.00005C1.33174 7.84447 1.38615 7.6938 1.48555 7.57412C1.58494 7.45443 1.72307 7.37328 1.876 7.34472L5.58133 6.64405C5.84477 6.59429 6.08708 6.46627 6.27665 6.2767C6.46622 6.08713 6.59424 5.84482 6.644 5.58138L7.34467 1.87605Z" stroke="#DAB2FF" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M13.3333 1.33325V3.99992" stroke="#DAB2FF" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14.6667 2.66675H12" stroke="#DAB2FF" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2.66667 14.6667C3.40305 14.6667 4 14.0697 4 13.3333C4 12.597 3.40305 12 2.66667 12C1.93029 12 1.33333 12.597 1.33333 13.3333C1.33333 14.0697 1.93029 14.6667 2.66667 14.6667Z" stroke="#DAB2FF" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2455_340">
|
||||
<rect width="16" height="16" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.1 KiB |
|
|
@ -1,4 +0,0 @@
|
|||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" />
|
||||
<path d="M12 8v4M12 16h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 273 B |
|
|
@ -1,3 +0,0 @@
|
|||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 5v14M19 12l-7 7-7-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 228 B |
|
|
@ -1,3 +0,0 @@
|
|||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 19V5M5 12l7-7 7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 226 B |
|
|
@ -1,4 +0,0 @@
|
|||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="8" r="6" stroke="currentColor" stroke-width="2" />
|
||||
<path d="M8.21 13.89 7 23l5-3 5 3-1.21-9.12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 312 B |
|
|
@ -1,6 +0,0 @@
|
|||
<svg width="20" height="20" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 423 B |
|
|
@ -1,5 +0,0 @@
|
|||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="2" width="20" height="20" rx="5" stroke="currentColor" stroke-width="2" />
|
||||
<circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="2" />
|
||||
<circle cx="17.5" cy="6.5" r="1.5" fill="currentColor" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 329 B |
|
|
@ -1,4 +0,0 @@
|
|||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="11" cy="11" r="8" stroke="currentColor" stroke-width="2" />
|
||||
<path d="m21 21-4.3-4.3" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 269 B |
|
|
@ -1,6 +0,0 @@
|
|||
<svg width="20" height="20" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 485 B |
|
|
@ -1,3 +0,0 @@
|
|||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14 21 3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 281 B |
|
|
@ -1,10 +0,0 @@
|
|||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 345 B |
|
|
@ -1,3 +0,0 @@
|
|||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 189 B |
|
|
@ -1,9 +0,0 @@
|
|||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 511 B |
|
|
@ -1,6 +0,0 @@
|
|||
<svg width="16" height="16" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 225 B |
|
|
@ -1,4 +0,0 @@
|
|||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M22 7 13.5 15.5 8.5 10.5 2 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M16 7h6v6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 345 B |
|
|
@ -1,9 +0,0 @@
|
|||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm11-4a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 336 B |
|
|
@ -1,10 +0,0 @@
|
|||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<rect x="2" y="6" width="14" height="12" rx="2" stroke="currentColor" stroke-width="2" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 392 B |
|
|
@ -1,10 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path d="M12 9v4M12 17h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 429 B |
|
|
@ -1,9 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M7 17L17 7M17 7H9M17 7V15"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 271 B |
|
|
@ -1,4 +0,0 @@
|
|||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" stroke="currentColor" stroke-width="2" />
|
||||
<path d="M16 2v4M8 2v4M3 10h18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 294 B |
|
|
@ -1,4 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" />
|
||||
<path d="M9 12l2 2 4-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 312 B |
|
|
@ -1,3 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M6 4l4 4-4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 238 B |
|
|
@ -1,9 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M12 3v12m0 0l4-4m-4 4L8 11M5 21h14"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 280 B |
|
|
@ -1,6 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 454 B |
|
|
@ -1,4 +0,0 @@
|
|||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" />
|
||||
<path d="M2 12h20M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20" stroke="currentColor" stroke-width="2" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 285 B |
|
|
@ -1,10 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" />
|
||||
<path
|
||||
d="M9.09 9a3 3 0 1 1 5.83 1c0 2-3 2-3 4M12 17h.01"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 367 B |
|
|
@ -1,5 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" fill="currentColor" />
|
||||
<path d="M21 15l-5-5L5 21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 391 B |
|
|
@ -1,9 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M9 17H7A5 5 0 0 1 7 7h2M15 7h2a5 5 0 1 1 0 10h-2M8 12h8"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 301 B |
|
|
@ -1,10 +0,0 @@
|
|||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M20 10c0 6-8 12-8 12S4 16 4 10a8 8 0 1 1 16 0Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 347 B |
|
|
@ -1,9 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M7.9 20A9 9 0 1 0 4 16.1L2 22l5.9-2z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 282 B |
|
|
@ -1,4 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" />
|
||||
<path d="M15 9l-6 6M9 9l6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 293 B |
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
© {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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
import { UI_PRIMARY_GRADIENT_CLASS } from "@/components/atoms/uiTokens";
|
||||
|
||||
export type ButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "type"> & {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
variant?: "primary" | "outline";
|
||||
};
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
className = "",
|
||||
variant = "outline",
|
||||
...rest
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
"rounded-full transition-all cursor-pointer break-keep solid-text-paint",
|
||||
variant === "primary"
|
||||
? `${UI_PRIMARY_GRADIENT_CLASS} text-white shadow-md`
|
||||
: "bg-white border border-neutral-30 text-neutral-70 hover:bg-neutral-10",
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export type PillProps = Omit<HTMLAttributes<HTMLSpanElement>, "className"> & {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
size?: "sm" | "md";
|
||||
weight?: "medium" | "semibold" | "none";
|
||||
inlineFlex?: boolean;
|
||||
};
|
||||
|
||||
export function Pill({
|
||||
children,
|
||||
className = "",
|
||||
size = "md",
|
||||
weight = "medium",
|
||||
inlineFlex = false,
|
||||
...rest
|
||||
}: PillProps) {
|
||||
return (
|
||||
<span
|
||||
className={[
|
||||
"rounded-full break-keep",
|
||||
inlineFlex && "inline-flex items-center",
|
||||
size === "sm" ? "px-2 py-1" : "px-3 py-1",
|
||||
weight === "semibold" ? "font-semibold" : weight === "medium" ? "font-medium" : "",
|
||||
"label-12",
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
{...rest}
|
||||
>
|
||||
{/* 배경·테두리는 바깥 span, WebKit 글자 채움은 안쪽만 solid-text-paint */}
|
||||
<span className="solid-text-paint">{children}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
const paddingMap = {
|
||||
none: "",
|
||||
sm: "p-4",
|
||||
md: "p-5",
|
||||
lg: "p-6",
|
||||
} as const;
|
||||
|
||||
export type SurfaceProps = Omit<HTMLAttributes<HTMLDivElement>, "className"> & {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
padding?: keyof typeof paddingMap;
|
||||
radius?: "xl" | "2xl";
|
||||
bordered?: boolean;
|
||||
surface?: "white" | "none";
|
||||
shadow?: boolean;
|
||||
interactive?: boolean | "lift";
|
||||
overflowHidden?: boolean;
|
||||
};
|
||||
|
||||
export function Surface({
|
||||
children,
|
||||
className = "",
|
||||
padding = "md",
|
||||
radius = "2xl",
|
||||
bordered = true,
|
||||
surface = "white",
|
||||
shadow = true,
|
||||
interactive = false,
|
||||
overflowHidden = false,
|
||||
...rest
|
||||
}: SurfaceProps) {
|
||||
const interactiveClass =
|
||||
interactive === "lift"
|
||||
? "hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)] transition-shadow"
|
||||
: interactive
|
||||
? "hover:shadow-md transition-shadow"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
radius === "xl" ? "rounded-xl" : "rounded-2xl",
|
||||
bordered && "border border-neutral-20",
|
||||
surface === "white" && "bg-white",
|
||||
shadow && "card-shadow",
|
||||
paddingMap[padding],
|
||||
interactiveClass,
|
||||
overflowHidden && "overflow-hidden",
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export { Button, type ButtonProps } from "@/components/atoms/Button";
|
||||
export { Pill, type PillProps } from "@/components/atoms/Pill";
|
||||
export { Surface, type SurfaceProps } from "@/components/atoms/Surface";
|
||||
export { UI_PRIMARY_GRADIENT_CLASS } from "@/components/atoms/uiTokens";
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
/** 앱 전역에서 쓸 수 있는 브랜드 그라데이션 배경 클래스 (버튼·블록 배경 등) */
|
||||
export const UI_PRIMARY_GRADIENT_CLASS = "bg-gradient-to-r from-violet-700 to-navy-950";
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import type { Severity } from "@/types/severity";
|
||||
|
||||
export type SeverityBadgeProps = {
|
||||
severity: Severity;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
const config: Record<Severity, { className: string; defaultLabel: string }> = {
|
||||
critical: {
|
||||
className:
|
||||
"bg-[var(--color-status-critical-bg)] text-[var(--color-status-critical-text)] border-[var(--color-status-critical-border)]",
|
||||
defaultLabel: "심각",
|
||||
},
|
||||
warning: {
|
||||
className:
|
||||
"bg-[var(--color-status-warning-bg)] text-[var(--color-status-warning-text)] border-[var(--color-status-warning-border)]",
|
||||
defaultLabel: "주의",
|
||||
},
|
||||
good: {
|
||||
className:
|
||||
"bg-[var(--color-status-good-bg)] text-[var(--color-status-good-text)] border-[var(--color-status-good-border)]",
|
||||
defaultLabel: "양호",
|
||||
},
|
||||
excellent: {
|
||||
className:
|
||||
"bg-[var(--color-status-info-bg)] text-[var(--color-status-info-text)] border-[var(--color-status-info-border)]",
|
||||
defaultLabel: "우수",
|
||||
},
|
||||
unknown: {
|
||||
className: "bg-neutral-10 text-neutral-80 border-neutral-20",
|
||||
defaultLabel: "미확인",
|
||||
},
|
||||
};
|
||||
|
||||
export function SeverityBadge({ severity, label }: SeverityBadgeProps) {
|
||||
const { className, defaultLabel } = config[severity];
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full label-12 font-medium px-3 py-1 border ${className}`}
|
||||
>
|
||||
{label ?? defaultLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
import { useState } from "react";
|
||||
import AlertCircleIcon from "@/assets/icons/alert-circle.svg?react";
|
||||
import ChevronRightIcon from "@/assets/report/chevron-right.svg?react";
|
||||
import CheckCircleIcon from "@/assets/report/check-circle.svg?react";
|
||||
import XCircleIcon from "@/assets/report/x-circle.svg?react";
|
||||
import type { BrandInconsistency } from "@/types/brandConsistency";
|
||||
|
||||
export type BrandConsistencyMapProps = {
|
||||
inconsistencies: BrandInconsistency[];
|
||||
className?: string;
|
||||
/** false면 상단 제목·설명을 숨김 (플랜 브랜딩 가이드 탭 등) */
|
||||
showHeading?: boolean;
|
||||
};
|
||||
|
||||
export function BrandConsistencyMap({
|
||||
inconsistencies,
|
||||
className = "",
|
||||
showHeading = true,
|
||||
}: BrandConsistencyMapProps) {
|
||||
const [expanded, setExpanded] = useState<number | null>(0);
|
||||
|
||||
if (inconsistencies.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${showHeading ? "mt-8 animate-fade-in-up animation-delay-200 " : ""}${className}`.trim()}
|
||||
>
|
||||
{showHeading ? (
|
||||
<>
|
||||
<h3 className="font-serif headline-20 text-navy-900 mb-2 break-keep">Brand Consistency Map</h3>
|
||||
<p className="body-14 text-neutral-60 mb-5 break-keep">전 채널 브랜드 일관성 분석</p>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
{inconsistencies.map((item, i) => {
|
||||
const wrongCount = item.values.filter((v) => !v.isCorrect).length;
|
||||
const isOpen = expanded === i;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.field}
|
||||
className="rounded-2xl border border-neutral-20 bg-white card-shadow overflow-hidden"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(isOpen ? null : i)}
|
||||
className="w-full flex items-center justify-between gap-3 p-5 text-left hover:bg-neutral-10/80 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-8 h-8 rounded-lg bg-navy-900 flex items-center justify-center text-white label-12-semibold shrink-0">
|
||||
{wrongCount}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="title-14 text-navy-900 break-keep">{item.field}</p>
|
||||
<p className="label-12 text-neutral-60 break-keep">{wrongCount}개 채널 불일치</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRightIcon
|
||||
width={16}
|
||||
height={16}
|
||||
className={`text-neutral-60 shrink-0 transition-transform ${isOpen ? "rotate-90" : ""}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen ? (
|
||||
<div className="px-5 pb-5 border-t border-neutral-20">
|
||||
<div className="grid gap-2 mt-4 mb-4">
|
||||
{item.values.map((v) => (
|
||||
<div
|
||||
key={v.channel}
|
||||
className={`flex flex-wrap items-center justify-between gap-2 py-3 px-3 rounded-lg body-14 sm:flex-nowrap ${
|
||||
v.isCorrect
|
||||
? "bg-[var(--color-status-good-bg)]/60"
|
||||
: "bg-[var(--color-status-critical-bg)]/60"
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium text-neutral-80 shrink-0 min-w-[100px] break-keep">
|
||||
{v.channel}
|
||||
</span>
|
||||
<span
|
||||
className={`flex-1 text-right min-w-0 break-keep ${
|
||||
v.isCorrect
|
||||
? "text-[var(--color-status-good-text)]"
|
||||
: "text-[var(--color-status-critical-text)]"
|
||||
}`}
|
||||
>
|
||||
{v.value}
|
||||
</span>
|
||||
<span className="ml-auto sm:ml-3 shrink-0">
|
||||
{v.isCorrect ? (
|
||||
<CheckCircleIcon
|
||||
width={15}
|
||||
height={15}
|
||||
className="text-[var(--color-status-good-dot)]"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<XCircleIcon
|
||||
width={15}
|
||||
height={15}
|
||||
className="text-[var(--color-status-critical-dot)]"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-[var(--color-status-warning-bg)] border border-[var(--color-status-warning-border)] p-4 mb-3">
|
||||
<p className="label-12-semibold text-[var(--color-status-warning-text)] uppercase tracking-wide mb-1 break-keep">
|
||||
<AlertCircleIcon className="inline mr-1 align-text-bottom shrink-0" width={12} height={12} aria-hidden />
|
||||
Impact
|
||||
</p>
|
||||
<p className="body-14 text-[var(--color-status-warning-text)] break-keep">{item.impact}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-[var(--color-status-good-bg)] border border-[var(--color-status-good-border)] p-4">
|
||||
<p className="label-12-semibold text-[var(--color-status-good-text)] uppercase tracking-wide mb-1 break-keep">
|
||||
<CheckCircleIcon className="inline mr-1 align-text-bottom shrink-0" width={12} height={12} aria-hidden />
|
||||
Recommendation
|
||||
</p>
|
||||
<p className="body-14 text-[var(--color-status-good-text)] break-keep">{item.recommendation}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { SeverityBadge } from "@/components/badge/SeverityBadge";
|
||||
import { ScoreRing } from "@/components/rating/ScoreRing";
|
||||
import type { Severity } from "@/types/severity";
|
||||
|
||||
export type ChannelScoreCardProps = {
|
||||
channel: string;
|
||||
icon: ReactNode;
|
||||
/** 아이콘·점수 링에 쓰는 브랜드/강조 색 (없으면 링은 점수대비 자동색) */
|
||||
accentColor?: string;
|
||||
score: number;
|
||||
maxScore: number;
|
||||
headline: string;
|
||||
severity: Severity;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
export function ChannelScoreCard({
|
||||
channel,
|
||||
icon,
|
||||
accentColor,
|
||||
score,
|
||||
maxScore,
|
||||
headline,
|
||||
severity,
|
||||
className = "",
|
||||
style,
|
||||
}: ChannelScoreCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-2xl bg-white border border-neutral-20 shadow-sm p-4 text-center flex flex-col items-center gap-3 animate-fade-in-up ${className}`.trim()}
|
||||
style={style}
|
||||
>
|
||||
<div
|
||||
className={`w-10 h-10 rounded-xl bg-neutral-10 flex items-center justify-center shrink-0 [&_svg]:block ${accentColor ? "" : "text-neutral-60"}`}
|
||||
style={accentColor ? { color: accentColor } : undefined}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<p className="body-14-medium text-navy-900 break-keep px-1">{channel}</p>
|
||||
<ScoreRing score={score} maxScore={maxScore} size={60} color={accentColor} className="gap-1" />
|
||||
<p className="label-12 text-neutral-60 line-clamp-2 leading-relaxed min-h-8 break-keep px-1">{headline}</p>
|
||||
<SeverityBadge severity={severity} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
import type { CSSProperties, ReactNode } from "react";
|
||||
|
||||
export type InfoStatCardProps = {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
export function InfoStatCard({ icon, label, value, className = "", style }: InfoStatCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-2xl bg-white border border-neutral-20 shadow-sm p-5 animate-fade-in-up ${className}`.trim()}
|
||||
style={style}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0 w-8 h-8 rounded-lg bg-neutral-10 flex items-center justify-center text-neutral-60">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="label-12 text-neutral-60 uppercase tracking-wide break-keep">{label}</p>
|
||||
<p className="title-18 text-navy-900 mt-1 break-keep">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import type { ReactNode } from "react";
|
||||
import ArrowDownIcon from "@/assets/icons/arrow-down.svg?react";
|
||||
import ArrowUpIcon from "@/assets/icons/arrow-up.svg?react";
|
||||
import MinusIcon from "@/assets/icons/minus.svg?react";
|
||||
|
||||
export type MetricCardProps = {
|
||||
label: string;
|
||||
value: string | number;
|
||||
subtext?: string;
|
||||
icon?: ReactNode;
|
||||
trend?: "up" | "down" | "neutral";
|
||||
};
|
||||
|
||||
const trendConfig = {
|
||||
up: { Icon: ArrowUpIcon, className: "text-violet-600" },
|
||||
down: { Icon: ArrowDownIcon, className: "text-[var(--color-status-critical-text)]" },
|
||||
neutral: { Icon: MinusIcon, className: "text-neutral-60" },
|
||||
} as const;
|
||||
|
||||
export function MetricCard({ label, value, subtext, icon, trend }: MetricCardProps) {
|
||||
const TrendGlyph = trend ? trendConfig[trend].Icon : null;
|
||||
const trendColor = trend ? trendConfig[trend].className : "";
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-neutral-20 shadow-sm bg-white p-5 relative animate-fade-in-up">
|
||||
{icon ? <div className="absolute top-4 right-4 text-neutral-40 [&_svg]:block">{icon}</div> : null}
|
||||
<p className="body-14 text-neutral-60 mb-1 break-keep">{label}</p>
|
||||
<div className="flex items-end gap-2 min-w-0">
|
||||
<span className="text-3xl font-bold text-navy-900 break-keep">{value}</span>
|
||||
{trend && TrendGlyph ? (
|
||||
<span className={`mb-1 ${trendColor}`}>
|
||||
<TrendGlyph width={18} height={18} aria-hidden />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{subtext ? <p className="label-12 text-neutral-60 mt-1 break-keep">{subtext}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import CheckCircleIcon from "@/assets/report/check-circle.svg?react";
|
||||
import XCircleIcon from "@/assets/report/x-circle.svg?react";
|
||||
|
||||
export type PixelInstallCardProps = {
|
||||
name: string;
|
||||
installed: boolean;
|
||||
details?: string;
|
||||
};
|
||||
|
||||
export function PixelInstallCard({ name, installed, details }: PixelInstallCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-xl p-3 min-w-0 ${
|
||||
installed
|
||||
? "bg-[var(--color-status-good-bg)] border border-[var(--color-status-good-border)]"
|
||||
: "bg-[var(--color-status-critical-bg)] border border-[var(--color-status-critical-border)]"
|
||||
}`}
|
||||
>
|
||||
{installed ? (
|
||||
<CheckCircleIcon width={16} height={16} className="text-[var(--color-status-good-dot)] shrink-0" aria-hidden />
|
||||
) : (
|
||||
<XCircleIcon width={16} height={16} className="text-[var(--color-status-critical-dot)] shrink-0" aria-hidden />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className={`body-14-medium break-keep ${
|
||||
installed ? "text-[var(--color-status-good-text)]" : "text-[var(--color-status-critical-text)]"
|
||||
}`}
|
||||
>
|
||||
{name}
|
||||
</p>
|
||||
{details ? (
|
||||
<p className="label-12 text-neutral-60 break-keep min-w-0">{details}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
import type { CSSProperties } from "react";
|
||||
import EyeIcon from "@/assets/icons/eye.svg?react";
|
||||
import { formatCompactNumber } from "@/utils/formatNumber";
|
||||
|
||||
export type TopVideoCardProps = {
|
||||
title: string;
|
||||
views: number;
|
||||
uploadedAgo: string;
|
||||
type: "Short" | "Long";
|
||||
duration?: string;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
export function TopVideoCard({
|
||||
title,
|
||||
views,
|
||||
uploadedAgo,
|
||||
type,
|
||||
duration,
|
||||
className = "",
|
||||
style,
|
||||
}: TopVideoCardProps) {
|
||||
const typeClass =
|
||||
type === "Short"
|
||||
? "bg-lavender-100 text-violet-700"
|
||||
: "bg-[var(--color-status-info-bg)] text-[var(--color-status-info-text)]";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-xl bg-white border border-neutral-20 shadow-sm p-4 min-w-[250px] shrink-0 animate-fade-in-up ${className}`.trim()}
|
||||
style={style}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className={`label-12 font-medium px-2 py-1 rounded-full break-keep ${typeClass}`}>{type}</span>
|
||||
{duration ? <span className="label-12 text-neutral-60 break-keep">{duration}</span> : null}
|
||||
<span className="label-12 text-neutral-60 break-keep">{uploadedAgo}</span>
|
||||
</div>
|
||||
<p className="body-14-medium text-navy-900 line-clamp-2 mb-2 break-keep">{title}</p>
|
||||
<div className="flex items-center gap-1 body-14 text-neutral-70 min-w-0">
|
||||
<EyeIcon width={14} height={14} className="text-neutral-60 shrink-0" aria-hidden />
|
||||
<span className="title-14 break-keep">{formatCompactNumber(views)}</span>
|
||||
<span className="text-neutral-60 break-keep">views</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
export type TagChipListProps = {
|
||||
tags: string[];
|
||||
title?: string;
|
||||
className?: string;
|
||||
/** 기본 `animation-delay-400`. 빈 문자열이면 지연 없음 */
|
||||
entranceDelayClass?: string;
|
||||
};
|
||||
|
||||
export function TagChipList({
|
||||
tags,
|
||||
title,
|
||||
className = "",
|
||||
entranceDelayClass = "animation-delay-400",
|
||||
}: TagChipListProps) {
|
||||
if (!tags.length) return null;
|
||||
|
||||
return (
|
||||
<div className={`animate-fade-in-up ${entranceDelayClass} ${className}`.trim()}>
|
||||
{title ? <p className="body-14-medium text-neutral-80 mb-3 break-keep">{title}</p> : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full bg-white/60 backdrop-blur-sm border border-neutral-20 px-3 py-1 body-14-medium text-neutral-80 break-keep"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
export { BrandConsistencyMap, type BrandConsistencyMapProps } from "@/components/brand/BrandConsistencyMap";
|
||||
export { SeverityBadge, type SeverityBadgeProps } from "@/components/badge/SeverityBadge";
|
||||
export { ChannelScoreCard, type ChannelScoreCardProps } from "@/components/card/ChannelScoreCard";
|
||||
export { InfoStatCard, type InfoStatCardProps } from "@/components/card/InfoStatCard";
|
||||
export { MetricCard, type MetricCardProps } from "@/components/card/MetricCard";
|
||||
export { PixelInstallCard, type PixelInstallCardProps } from "@/components/card/PixelInstallCard";
|
||||
export { TopVideoCard, type TopVideoCardProps } from "@/components/card/TopVideoCard";
|
||||
export { TagChipList, type TagChipListProps } from "@/components/chip/TagChipList";
|
||||
export { ConsolidationCallout, type ConsolidationCalloutProps } from "@/components/panel/ConsolidationCallout";
|
||||
export { HighlightPanel, type HighlightPanelProps } from "@/components/panel/HighlightPanel";
|
||||
export { ScoreRing, type ScoreRingProps } from "@/components/rating/ScoreRing";
|
||||
export { StarRatingDisplay, type StarRatingDisplayProps } from "@/components/rating/StarRatingDisplay";
|
||||
export { PageSection, type PageSectionProps } from "@/components/section/PageSection";
|
||||
export {
|
||||
Button,
|
||||
Pill,
|
||||
Surface,
|
||||
UI_PRIMARY_GRADIENT_CLASS,
|
||||
type ButtonProps,
|
||||
type PillProps,
|
||||
type SurfaceProps,
|
||||
} from "@/components/atoms";
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import type { ReactNode } from "react";
|
||||
|
||||
export type ConsolidationCalloutProps = {
|
||||
title: string;
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/** 통합·전략 권고 등 강조 CTA 블록 — 리포트 채널 섹션 공통 */
|
||||
export function ConsolidationCallout({
|
||||
title,
|
||||
icon,
|
||||
children,
|
||||
className = "",
|
||||
}: ConsolidationCalloutProps) {
|
||||
return (
|
||||
<div
|
||||
className={`mt-8 rounded-2xl bg-gradient-to-r from-violet-700 to-navy-950 p-6 md:p-8 text-white animate-fade-in-up animation-delay-300 ${className}`.trim()}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{icon ? (
|
||||
<div className="w-10 h-10 rounded-xl bg-white/10 flex items-center justify-center shrink-0 text-white [&_svg]:block">
|
||||
{icon}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-w-0">
|
||||
<h4 className="font-serif headline-24 mb-2 break-keep">{title}</h4>
|
||||
<div className="body-14 text-lavender-200 leading-relaxed break-keep">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import type { ReactNode } from "react";
|
||||
|
||||
export type HighlightPanelProps = {
|
||||
title: string;
|
||||
icon?: ReactNode;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function HighlightPanel({ title, icon, className = "", children }: HighlightPanelProps) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-2xl bg-gradient-to-r from-violet-700/5 to-navy-950/5 border border-lavender-200 p-6 animate-fade-in-up animation-delay-300 ${className}`.trim()}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{icon ? <span className="text-violet-700 shrink-0 [&_svg]:block">{icon}</span> : null}
|
||||
<h3 className="font-serif headline-20 text-navy-900">{title}</h3>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 →
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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> ·{' '}
|
||||
{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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">→</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||