diff --git a/orval.config.ts b/orval.config.ts index 572d3e7..4b1ea76 100644 --- a/orval.config.ts +++ b/orval.config.ts @@ -1,9 +1,18 @@ import { defineConfig } from 'orval' +// .env 로드 (Node 20.12+ 기본 기능, 추가 의존성 불필요) +try { + process.loadEnvFile('.env') +} catch { + // .env 파일이 없으면 무시 (기본값 사용) +} + +const apiBaseUrl = process.env.VITE_API_BASE_URL ?? 'http://localhost:8001' + export default defineConfig({ api: { - // sdk 파일 및 모델 가져올 swagger 서버 주소 - input: 'http://40.82.133.44:8001/openapi.json', + // sdk 파일 및 모델 가져올 swagger 서버 주소 (.env 의 VITE_API_BASE_URL) + input: `${apiBaseUrl}/openapi.json`, output: { mode: 'tags-split', target: './src/shared/api/generated', diff --git a/src/app/providers.tsx b/src/app/providers.tsx index d1ed346..d87ba35 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -1,5 +1,4 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import { useState, type ReactNode } from 'react' export function Providers({ children }: { children: ReactNode }) { @@ -19,7 +18,6 @@ export function Providers({ children }: { children: ReactNode }) { return ( {children} - {import.meta.env.DEV && } ) } diff --git a/src/features/admin/pages/ApiDashboardPage.tsx b/src/features/admin/pages/ApiDashboardPage.tsx index 8a3082a..933205e 100644 --- a/src/features/admin/pages/ApiDashboardPage.tsx +++ b/src/features/admin/pages/ApiDashboardPage.tsx @@ -8,6 +8,7 @@ import { LinkExternalFilled, GlobeFilled, PrismFilled, YoutubeFilled, VideoFilled, MegaphoneFilled, } from '@/shared/icons/FilledIcons'; +import { PageContainer } from '@/shared/ui/page-container'; /* ───────────────────────────── 타입 ───────────────────────────── */ @@ -498,7 +499,7 @@ export default function ApiDashboardPage() { return (
-
+ {/* Page Header */} VITE_ 접두사 키만 프론트엔드에서 확인 가능합니다. 서버 키는 백엔드 환경설정에서 확인하세요.
-
+ ); } diff --git a/src/features/admin/pages/DataValidationPage.tsx b/src/features/admin/pages/DataValidationPage.tsx index b42867b..c403cab 100644 --- a/src/features/admin/pages/DataValidationPage.tsx +++ b/src/features/admin/pages/DataValidationPage.tsx @@ -18,6 +18,7 @@ import { Server, } from 'lucide-react'; import type { ComponentType } from 'react'; +import { PageContainer } from '@/shared/ui/page-container'; // ─── Types ─── @@ -240,7 +241,7 @@ export default function DataValidationPage() {
-
+

Data Collection Validation

데이터 수집 검증 리포트

Firecrawl API를 활용한 실제 크롤링 테스트 결과 — 뷰성형외과 대상

@@ -249,10 +250,10 @@ export default function DataValidationPage() { API: Firecrawl v1 총 크레딧 소모: 15 credits
-
+
-
+ {/* ── Section 1: 수집 프로세스 ── */}
@@ -559,7 +560,7 @@ export default function DataValidationPage() {
-
+ ); } diff --git a/src/features/channels/components/MultiChannelInput.tsx b/src/features/channels/components/MultiChannelInput.tsx index bc1ffdc..babb2a1 100644 --- a/src/features/channels/components/MultiChannelInput.tsx +++ b/src/features/channels/components/MultiChannelInput.tsx @@ -22,19 +22,9 @@ * - variant='cta': 다크 배경 (CTA 섹션) */ -import { useMemo, useState, type ReactElement } from 'react'; +import { useMemo, useState } from 'react'; import { ArrowRight } from 'lucide-react'; -import { - GlobeFilled, - YoutubeFilled, - InstagramFilled, - FacebookFilled, - DatabaseFilled, - FileTextFilled, - MessageFilled, - CheckFilled, - WarningFilled, -} from '@/shared/icons/FilledIcons'; +import { CheckFilled, WarningFilled } from '@/shared/icons/FilledIcons'; import { classifyUrls, hasAnalyzableChannels, @@ -42,6 +32,8 @@ import { type ClassifiedUrls, } from '../lib/classifyUrls'; import { Button } from '@/shared/ui/button'; +import { PLATFORM_META } from '@/features/clinics/components/PlatformChips'; +import type { PlatformKey } from '@/features/clinics/types/workspace'; /** discover-channels Edge Function에 전달되는 수동 채널 URL 묶음. */ export interface ManualChannels { @@ -69,26 +61,35 @@ interface MultiChannelInputProps { type ChannelKey = keyof Omit; -/** 채널별 메타 — 라벨/아이콘/색상/플레이스홀더 (뷰성형외과 실 URL을 데모 placeholder로). */ -const CHANNEL_META: Array<{ - key: ChannelKey; - label: string; - Icon: (props: { size?: number; className?: string }) => ReactElement; - color: string; - placeholder: string; -}> = [ - { key: 'homepage', label: '홈페이지', Icon: GlobeFilled, color: 'text-brand-purple', placeholder: 'viewclinic.com' }, - { key: 'youtube', label: 'YouTube', Icon: YoutubeFilled, color: 'text-[#FF0000]', placeholder: 'youtube.com/@ViewclinicKR' }, - { key: 'instagram', label: 'Instagram', Icon: InstagramFilled,color: 'text-[#E1306C]', placeholder: 'instagram.com/viewplastic' }, - { key: 'facebook', label: 'Facebook', Icon: FacebookFilled, color: 'text-[#1877F2]', placeholder: 'facebook.com/viewps1' }, - { key: 'naverPlace', label: '네이버 플레이스', Icon: DatabaseFilled, color: 'text-[#03C75A]', placeholder: 'place.naver.com/hospital/11709005' }, - { key: 'naverBlog', label: '네이버 블로그', Icon: FileTextFilled, color: 'text-[#03C75A]', placeholder: 'blog.naver.com/viewclinicps' }, - { key: 'gangnamUnni', label: '강남언니', Icon: MessageFilled, color: 'text-[#FF5C89]', placeholder: 'gangnamunni.com/hospitals/189' }, +/** ChannelKey(분류기 결과 키) → 공통 PlatformKey 매핑 — 'homepage' ↔ 'website' 만 다름 */ +const CHANNEL_TO_PLATFORM: Record = { + homepage: 'website', + youtube: 'youtube', + instagram: 'instagram', + facebook: 'facebook', + naverPlace: 'naverPlace', + naverBlog: 'naverBlog', + gangnamUnni: 'gangnamUnni', +}; + +const PLACEHOLDER: Record = { + homepage: 'viewclinic.com', + youtube: 'youtube.com/@ViewclinicKR', + instagram: 'instagram.com/viewplastic', + facebook: 'facebook.com/viewps1', + naverPlace: 'place.naver.com/hospital/11709005', + naverBlog: 'blog.naver.com/viewclinicps', + gangnamUnni: 'gangnamunni.com/hospitals/189', +}; + +/** 채널 입력 순서 — 공통 PLATFORM_META 의 label/color/Icon 을 그대로 사용 */ +const CHANNEL_ORDER: ChannelKey[] = [ + 'homepage', 'youtube', 'instagram', 'facebook', 'naverPlace', 'naverBlog', 'gangnamUnni', ]; -type EmptyClassified = Record; +type ChannelUrlInputs = Record; -const EMPTY_URLS: EmptyClassified = { +const EMPTY_URLS: ChannelUrlInputs = { homepage: '', youtube: '', instagram: '', facebook: '', naverPlace: '', naverBlog: '', gangnamUnni: '', }; @@ -109,7 +110,7 @@ function validateField(value: string, expected: ChannelKey): 'empty' | 'valid' | } export default function MultiChannelInput({ variant = 'hero', onAnalyze }: MultiChannelInputProps) { - const [urls, setUrls] = useState(EMPTY_URLS); + const [urls, setUrls] = useState(EMPTY_URLS); // 통합 분류 결과 — 7개 필드 값을 join해 classifyUrls에 한 번에 통과시켜 manualChannels 구성. const aggregated = useMemo(() => { @@ -153,29 +154,35 @@ export default function MultiChannelInput({ variant = 'hero', onAnalyze }: Multi
{/* 7개 채널별 입력 필드 — 한 줄에 하나, 좌측 아이콘 + 우측 검증 상태 */}
- {CHANNEL_META.map(({ key, label, Icon, color, placeholder }) => { + {CHANNEL_ORDER.map((key) => { + const meta = PLATFORM_META[CHANNEL_TO_PLATFORM[key]]; + const Icon = meta.Icon; const value = urls[key]; const status = validateField(value, key); + const inactiveColor = isHero ? '#94a3b8' /* slate-400 */ : 'rgba(255,255,255,0.4)'; return (
- {/* 좌측 채널 아이콘 — 입력 시 컬러 적용, 빈 칸일 땐 회색 */} -
- + {/* 좌측 채널 아이콘 — 입력 시 브랜드 컬러, 빈 칸일 땐 회색. + z-10 으로 input 의 bg/backdrop-blur 위에 올림 (없으면 input 배경이 아이콘을 가림) */} +
+
- {/* 채널 라벨 — 좌측 상단 작은 라벨로 placeholder 위에 노출 */} setUrls((prev) => ({ ...prev, [key]: e.target.value }))} - placeholder={`${label} · ${placeholder}`} - aria-label={label} + placeholder={`${meta.label} · ${PLACEHOLDER[key]}`} + aria-label={meta.label} className={inputClass} spellCheck={false} autoComplete="off" /> - {/* 우측 검증 상태 아이콘 */} -
+ {/* 우측 검증 상태 아이콘 — 동일하게 z-10 */} +
{status === 'valid' && ( )} diff --git a/src/features/channels/pages/ChannelConnectPage.tsx b/src/features/channels/pages/ChannelConnectPage.tsx index cc498a4..25e92b5 100644 --- a/src/features/channels/pages/ChannelConnectPage.tsx +++ b/src/features/channels/pages/ChannelConnectPage.tsx @@ -11,6 +11,7 @@ import { import type { ComponentType } from 'react'; import { Button } from '@/shared/ui/button'; import { Input } from '@/shared/ui/input'; +import { PageContainer } from '@/shared/ui/page-container'; interface ChannelDef { id: string; @@ -204,7 +205,7 @@ export default function ChannelConnectPage() {
{/* Header */}
-
+

Channel Integration

채널 연결 @@ -233,11 +234,11 @@ export default function ChannelConnectPage() { )}

-
+
{/* Channel Grid */} -
+
{CHANNELS.map(ch => { const state = channels[ch.id]; @@ -370,7 +371,7 @@ export default function ChannelConnectPage() { ); })}
-
+
); } diff --git a/src/features/clinics/components/PlatformChips.tsx b/src/features/clinics/components/PlatformChips.tsx new file mode 100644 index 0000000..c3e2af8 --- /dev/null +++ b/src/features/clinics/components/PlatformChips.tsx @@ -0,0 +1,115 @@ +/** + * PlatformChips — 분석 대상 플랫폼/URL을 칩 묶음으로 표시. + * 리포트 카드, 플랜 카드, 설정 탭 등에서 공통으로 사용합니다. + * + * 디자인 원칙: + * - 모든 칩은 동일한 형태 (rounded-full, slate-100 보더, 흰 배경) + * - 플랫폼 식별은 좌측 컬러 도트 + 아이콘으로 (배경색 폭주 방지) + * - URL 클릭 가능 시 ExternalLink 아이콘 노출 + */ +import { ExternalLink, Heart, MapPin } from 'lucide-react'; +import type { CSSProperties } from 'react'; +import { + InstagramFilled, + YoutubeFilled, + FacebookFilled, + GlobeFilled, + TiktokFilled, + FileTextFilled, +} from '@/shared/icons/FilledIcons'; +import type { PlatformKey, PlatformTarget } from '../types/workspace'; + +interface PlatformMeta { + label: string; + color: string; + Icon: (props: { size?: number; className?: string; style?: CSSProperties }) => React.ReactElement; +} + +const PLATFORM_META: Record = { + website: { label: '홈페이지', color: '#6C5CE7', Icon: GlobeFilled }, + instagram: { label: 'Instagram', color: '#833AB4', Icon: InstagramFilled }, + youtube: { label: 'YouTube', color: '#FF3D3D', Icon: YoutubeFilled }, + facebook: { label: 'Facebook', color: '#1877F2', Icon: FacebookFilled }, + tiktok: { label: 'TikTok', color: '#0A1128', Icon: TiktokFilled }, + gangnamUnni: { label: '강남언니', color: '#FF6B8A', Icon: Heart }, + naverPlace: { label: '네이버 플레이스', color: '#03C75A', Icon: MapPin }, + naverBlog: { label: '네이버 블로그', color: '#03C75A', Icon: FileTextFilled }, +}; + +interface PlatformChipProps { + target: PlatformTarget; + size?: 'sm' | 'md'; + clickable?: boolean; +} + +export function PlatformChip({ target, size = 'sm', clickable = false }: PlatformChipProps) { + const meta = PLATFORM_META[target.platform]; + const Icon = meta.Icon; + const isClickable = clickable && !!target.url; + + const inner = ( + <> + + + + + {target.handle} + + {isClickable && } + + ); + + const baseClass = + 'inline-flex items-center gap-1.5 rounded-full bg-white border border-slate-200 font-medium transition-colors'; + const sizeClass = size === 'sm' ? 'px-2.5 py-1 text-[11px]' : 'px-3 py-1.5 text-xs'; + + if (isClickable) { + return ( + + {inner} + + ); + } + + return {inner}; +} + +interface PlatformChipsProps { + targets: PlatformTarget[]; + size?: 'sm' | 'md'; + clickable?: boolean; + emptyLabel?: string; +} + +export function PlatformChips({ + targets, + size = 'sm', + clickable = false, + emptyLabel = '연결된 채널 없음', +}: PlatformChipsProps) { + if (targets.length === 0) { + return {emptyLabel}; + } + return ( +
+ {targets.map((t) => ( + + ))} +
+ ); +} + +export { PLATFORM_META }; diff --git a/src/features/clinics/components/WorkspaceHeader.tsx b/src/features/clinics/components/WorkspaceHeader.tsx new file mode 100644 index 0000000..e201f8b --- /dev/null +++ b/src/features/clinics/components/WorkspaceHeader.tsx @@ -0,0 +1,230 @@ +/** + * WorkspaceHeader — ReportHeader 와 동일한 비주얼 톤(파스텔 그라데이션 + 보라색 + * 세리프 eyebrow + 네이비 굵은 세리프 타이틀 + white/60 backdrop-blur 메타 칩) + * 으로 클리닉 컨텍스트의 시각적 일관성을 유지합니다. + */ +import { Link } from 'react-router'; +import { motion } from 'motion/react'; +import { + Calendar, + MapPin, + Plus, + TrendingUp, + TrendingDown, + Minus, +} from 'lucide-react'; +import type { WorkspaceClinicProfile, WorkspaceRun } from '../types/workspace'; +import { PageContainer } from '@/shared/ui/page-container'; +import { ChannelLinkButtons, type ChannelHandles } from '@/shared/ui/channel-link-buttons'; +import { ScoreRing } from '@/features/report/components/ui/ScoreRing'; + +interface WorkspaceHeaderProps { + clinic: WorkspaceClinicProfile; + latestRun?: WorkspaceRun; + previousRun?: WorkspaceRun; +} + +function trend(latest?: number | null, prev?: number | null) { + if (latest == null || prev == null) return { dir: 'flat' as const, diff: 0 }; + const diff = Math.round(latest - prev); + if (diff > 0) return { dir: 'up' as const, diff }; + if (diff < 0) return { dir: 'down' as const, diff }; + return { dir: 'flat' as const, diff: 0 }; +} + +function formatDate(iso?: string | null) { + if (!iso) return '—'; + try { + return new Date(iso).toLocaleDateString('ko-KR', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + } catch { + return iso; + } +} + +export function WorkspaceHeader({ + clinic, + latestRun, + previousRun, +}: WorkspaceHeaderProps) { + const score = latestRun?.overallScore ?? null; + const t = trend(latestRun?.overallScore, previousRun?.overallScore); + const TrendIcon = t.dir === 'up' ? TrendingUp : t.dir === 'down' ? TrendingDown : Minus; + const trendColor = + t.dir === 'up' + ? 'text-emerald-600' + : t.dir === 'down' + ? 'text-rose-500' + : 'text-slate-400'; + const trendValue = t.dir === 'flat' ? null : `${t.diff > 0 ? '+' : ''}${t.diff}`; + + const socialHandles: ChannelHandles = clinic.defaultTargets.reduce( + (acc, target) => ({ ...acc, [target.platform]: target.handle }), + {}, + ); + + return ( +
+ {/* Animated blobs — ReportHeader 와 동일 패턴 */} + + + + + +
+ {/* Left: identity */} + + + Clinic Workspace + + + {clinic.logoUrl ? ( + + {clinic.name} { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> + + ) : null} + + + {clinic.name} + + + {clinic.nameEn && ( + + {clinic.nameEn} + + )} + + {/* Meta chips — ReportHeader 와 동일 스타일 */} + + + + 마지막 분석 {formatDate(latestRun?.completedAt ?? latestRun?.startedAt)} + + {clinic.location && ( + + + {clinic.location} + + )} + + + {/* 등록된 채널 바로가기 — ReportHeader 와 동일 패턴 */} + {clinic.defaultTargets.length > 0 && ( + + + + )} + + {/* Actions */} + + + 새 분석 시작 + + + + + {/* Right: stats card — ReportHeader 의 Score card 와 동일 구조 */} + +
+

+ Overall Score +

+ +
+ 이전 대비 + + {trendValue ?? 변동 없음} +
+
+
+
+
+
+ ); +} diff --git a/src/features/clinics/components/cards/AnalysisCard.tsx b/src/features/clinics/components/cards/AnalysisCard.tsx new file mode 100644 index 0000000..65fdd65 --- /dev/null +++ b/src/features/clinics/components/cards/AnalysisCard.tsx @@ -0,0 +1,126 @@ +/** + * AnalysisCard — 워크스페이스 단일 분석 아이템 카드. + * + * 한 카드에 리포트 + 플랜이 같이 묶입니다. + * - 카드 본문 클릭 → 리포트 보기 (기본 동작) + * - [리포트 보기] / [플랜 보기 | + 플랜 만들기] 명시적 버튼 + * - 분석 대상 플랫폼 칩(URL/핸들) 표시 + */ +import { Link, useNavigate } from 'react-router'; +import { Calendar, ArrowUpRight, FileText, Sparkles } from 'lucide-react'; +import { PlatformChips } from '../PlatformChips'; +import type { WorkspacePlan, WorkspaceRun } from '../../types/workspace'; + +function statusBadge(status: WorkspaceRun['status']) { + const map = { + completed: { label: '완료', cls: 'bg-status-good-bg text-status-good-text border-status-good-border' }, + running: { label: '진행 중', cls: 'bg-status-info-bg text-status-info-text border-status-info-border' }, + queued: { label: '대기', cls: 'bg-slate-50 text-slate-500 border-slate-200' }, + failed: { label: '실패', cls: 'bg-status-critical-bg text-status-critical-text border-status-critical-border' }, + } as const; + return map[status]; +} + +function formatScore(score: number | null) { + return score == null ? '—' : String(Math.round(score)); +} + +function formatDate(iso: string) { + const d = new Date(iso); + return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`; +} + +interface AnalysisCardProps { + clinicId: string; + run: WorkspaceRun; + /** 모든 run 은 1:1 로 연결된 plan 을 가짐 */ + plan: WorkspacePlan; + /** 최신 row 강조 */ + highlighted?: boolean; +} + +export function AnalysisCard({ clinicId, run, plan, highlighted = false }: AnalysisCardProps) { + const navigate = useNavigate(); + const status = statusBadge(run.status); + + // 가라데이터 데모 — 실제 리포트/플랜 ID 대신 view-clinic 데모 데이터로 진입. + // 실 데이터 연동 시 `/clinics/${clinicId}/report/${run.runId}` 등으로 복원. + void clinicId; + void run.runId; + void plan.planId; + const reportHref = '/report/view-clinic'; + const planHref = '/plan/view-clinic'; + + const goToReport = () => navigate(reportHref); + const stop = (e: React.MouseEvent) => e.stopPropagation(); + + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + goToReport(); + } + }} + role="button" + tabIndex={0} + className="group cursor-pointer rounded-2xl border border-slate-100 bg-white transition-all hover:border-[#D5CDF5] hover:shadow-[3px_4px_18px_rgba(79,29,161,0.08)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-purple/30" + > +
+ {/* Score block */} +
+
+ + {formatScore(run.overallScore)} + + /100 +
+
+ + {/* Center: meta + targets */} +
+
+ + + {status.label} + + + + {formatDate(run.startedAt)} + + {highlighted && ( + + 최신 + + )} +
+ + +
+ + {/* Right: actions */} +
+ + + 리포트 보기 + + + + + + 기획 보기 + +
+
+
+ ); +} diff --git a/src/features/clinics/components/tabs/AnalysisTab.tsx b/src/features/clinics/components/tabs/AnalysisTab.tsx new file mode 100644 index 0000000..ffe3455 --- /dev/null +++ b/src/features/clinics/components/tabs/AnalysisTab.tsx @@ -0,0 +1,100 @@ +/** + * AnalysisTab — 워크스페이스 분석 탭 (리포트+플랜 통합). + * 각 분석 run = 1:1 plan. 카드 본문 클릭은 리포트, 옆 버튼은 플랜. + * + * 리스트는 무한스크롤 — 서버가 전체 배열을 던지면 클라이언트에서 10개씩 잘라 노출. + */ +import { useMemo } from 'react'; +import { Plus } from 'lucide-react'; +import { Link } from 'react-router'; +import { SectionWrapper } from '@/features/report/components/ui/SectionWrapper'; +import { EmptyState } from '@/shared/ui/empty-state'; +import { useInfiniteList } from '@/shared/hooks/useInfiniteList'; +import { AnalysisCard } from '../cards/AnalysisCard'; +import type { WorkspacePlan, WorkspaceRun } from '../../types/workspace'; + +interface AnalysisTabProps { + clinicId: string; + runs: WorkspaceRun[]; + plans: WorkspacePlan[]; +} + +const PAGE_SIZE = 10; + +export function AnalysisTab({ clinicId, runs, plans }: AnalysisTabProps) { + const planByRun = useMemo(() => { + const map: Record = {}; + const priority = { active: 3, draft: 2, archived: 1 } as const; + for (const plan of plans) { + const prev = map[plan.baseRunId]; + if (!prev || priority[plan.status] > priority[prev.status]) { + map[plan.baseRunId] = plan; + } + } + return map; + }, [plans]); + + const rows = useMemo( + () => + [...runs] + .sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()) + .map((run) => ({ run, plan: planByRun[run.runId] })) + .filter((row): row is { run: WorkspaceRun; plan: WorkspacePlan } => !!row.plan), + [runs, planByRun], + ); + + const { visible, hasMore, sentinelRef } = useInfiniteList(rows, { + initialSize: PAGE_SIZE, + pageSize: PAGE_SIZE, + }); + + return ( + +
+ + 새 분석 시작 + +
+ + {rows.length === 0 ? ( + + ) : ( + <> +
+ {visible.map(({ run, plan }, i) => ( + + ))} +
+ + {hasMore && ( +
+
+
+ 더 불러오는 중... +
+
+ )} + + )} + + ); +} diff --git a/src/features/clinics/components/tabs/SettingsTab.tsx b/src/features/clinics/components/tabs/SettingsTab.tsx new file mode 100644 index 0000000..47447e8 --- /dev/null +++ b/src/features/clinics/components/tabs/SettingsTab.tsx @@ -0,0 +1,150 @@ +/** + * SettingsTab — 워크스페이스 설정 탭. + * ReportBody 의 SectionWrapper 스타일을 그대로 따름. + * 분석 대상 URL/소셜 핸들 편집 + 병원 기본 정보 확인. + */ +import { useState } from 'react'; +import { Save, AlertCircle, Info } from 'lucide-react'; +import { Input } from '@/shared/ui/input'; +import { Button } from '@/shared/ui/button'; +import { SectionWrapper } from '@/features/report/components/ui/SectionWrapper'; +import { PLATFORM_META } from '../PlatformChips'; +import type { PlatformKey, WorkspaceClinicProfile } from '../../types/workspace'; + +const EDITABLE_PLATFORMS: PlatformKey[] = [ + 'website', + 'youtube', + 'instagram', + 'facebook', + 'naverPlace', + 'naverBlog', + 'gangnamUnni', + 'tiktok', +]; + +const PLATFORM_PLACEHOLDER: Record = { + website: 'your-clinic.co.kr', + instagram: 'instagram.com/your_clinic', + youtube: 'youtube.com/@your_channel', + facebook: 'facebook.com/your.page', + tiktok: '@your_clinic', + gangnamUnni: 'gangnamunni.com/hospitals/000', + naverPlace: 'place.naver.com/hospital/0000000', + naverBlog: 'blog.naver.com/your_blog', +}; + +interface SettingsTabProps { + clinic: WorkspaceClinicProfile; +} + +export function SettingsTab({ clinic }: SettingsTabProps) { + const [values, setValues] = useState>(() => { + const initial: Record = { + website: '', + instagram: '', + youtube: '', + facebook: '', + tiktok: '', + }; + clinic.defaultTargets.forEach((t) => { + initial[t.platform] = t.handle; + }); + return initial; + }); + const [saved, setSaved] = useState(false); + + const handleChange = (platform: PlatformKey, value: string) => { + setValues((prev) => ({ ...prev, [platform]: value })); + setSaved(false); + }; + + const handleSave = () => { + // TODO: API 연동 — PATCH /api/clinics/:id + setSaved(true); + }; + + return ( + +
+ {/* Platform handles */} +
+
+

채널 URL · 핸들

+

+ 분석에 사용할 공식 채널을 입력하세요. 비워두면 해당 채널은 분석에서 제외됩니다. +

+
+ +
+ {EDITABLE_PLATFORMS.map((platform) => { + const meta = PLATFORM_META[platform]; + const Icon = meta.Icon; + return ( +
+
+ + handleChange(platform, e.target.value)} + placeholder={PLATFORM_PLACEHOLDER[platform]} + className="flex-1 h-10 text-sm bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:border-[#9B8AD4] focus:ring-2 focus:ring-[#9B8AD4]/20" + /> +
+ {platform === 'website' && ( +

+ + 홈페이지를 변경하면 다음 분석부터 병원 기본 정보(이름·주소·진료시간·의료진 등)가 새 사이트 기준으로 함께 갱신돼요. +

+ )} +
+ ); + })} +
+ +
+

+ {saved ? ( + <> + + 로컬에 저장되었습니다 (API 연동 후속 작업) + + ) : ( + <> + + 저장은 아직 서버에 반영되지 않습니다. + + )} +

+ +
+
+
+
+ ); +} diff --git a/src/features/clinics/data/mockClinicWorkspace.ts b/src/features/clinics/data/mockClinicWorkspace.ts new file mode 100644 index 0000000..cbeb628 --- /dev/null +++ b/src/features/clinics/data/mockClinicWorkspace.ts @@ -0,0 +1,65 @@ +/** + * Mock 워크스페이스 데이터 — RunSummary API에 부족한 필드(분석 대상 URL/핸들) + * 가 추가되기 전까지 시각화용으로 사용합니다. + * + * 향후 백엔드 변경 시 `useClinicWorkspace.ts` 에서 이 mock 대신 실 데이터로 교체. + */ +import type { WorkspaceData } from '../types/workspace'; + +export const mockWorkspace: WorkspaceData = { + clinic: { + clinicId: 'view-gangnam', + name: '뷰성형외과', + nameEn: 'VIEW Plastic Surgery', + location: '서울 강남구 신사동', + brandColor: '#4F1DA1', + // mockClinicProfile.ts 의 CLINIC.websites/socialChannels 와 동일한 출처 — 두 페이지 핸들 일치 유지 + defaultTargets: [ + { platform: 'website', handle: 'viewclinic.com', url: 'https://viewclinic.com' }, + { platform: 'youtube', handle: 'youtube.com/@ViewclinicKR', url: 'https://youtube.com/@ViewclinicKR' }, + { platform: 'instagram', handle: 'instagram.com/viewplastic', url: 'https://instagram.com/viewplastic' }, + { platform: 'facebook', handle: 'facebook.com/viewps1', url: 'https://facebook.com/viewps1' }, + { platform: 'naverPlace', handle: 'place.naver.com/hospital/11709005', url: 'https://m.place.naver.com/hospital/11709005/home' }, + { platform: 'naverBlog', handle: 'blog.naver.com/viewclinicps', url: 'https://blog.naver.com/viewclinicps' }, + { platform: 'gangnamUnni', handle: 'gangnamunni.com/hospitals/189', url: 'https://www.gangnamunni.com/hospitals/189' }, + ], + }, + runs: Array.from({ length: 10 }, (_, i) => { + // 최신 → 과거 순 (2026-04 부터 매 ~12일 간격으로 거슬러 올라감) + const baseDate = new Date('2026-04-22T09:11:00Z'); + baseDate.setDate(baseDate.getDate() - i * 12); + const score = Math.max(45, 85 - i * 1.5); + const targets = [ + { platform: 'website' as const, handle: 'viewclinic.com' }, + { platform: 'instagram' as const, handle: '@viewplastic' }, + ...(i % 3 !== 2 ? [{ platform: 'youtube' as const, handle: '@ViewclinicKR' }] : []), + ...(i % 2 === 0 ? [{ platform: 'facebook' as const, handle: 'viewclinic' }] : []), + { platform: 'gangnamUnni' as const, handle: 'gangnamunni.com/hospitals/189' }, + { platform: 'naverPlace' as const, handle: 'place.naver.com/hospital/11709005' }, + ]; + return { + runId: `run-${baseDate.toISOString().slice(0, 10)}`, + startedAt: baseDate.toISOString(), + completedAt: new Date(baseDate.getTime() + 23 * 60 * 1000).toISOString(), + status: 'completed' as const, + overallScore: Math.round(score), + targets, + }; + }), + plans: Array.from({ length: 10 }, (_, i) => { + const baseDate = new Date('2026-04-22T09:11:00Z'); + baseDate.setDate(baseDate.getDate() - i * 12); + const runId = `run-${baseDate.toISOString().slice(0, 10)}`; + return { + planId: `plan-${baseDate.toISOString().slice(0, 10)}`, + baseRunId: runId, + createdAt: new Date(baseDate.getTime() + 24 * 60 * 60 * 1000).toISOString(), + status: (i === 0 ? 'active' : 'archived') as 'active' | 'archived', + workflowProgress: i === 0 ? 42 : 100, + channels: [ + { platform: 'instagram' as const, handle: '@viewplastic' }, + { platform: 'youtube' as const, handle: '@ViewclinicKR' }, + ], + }; + }), +}; diff --git a/src/features/clinics/pages/ClinicProfilePage.tsx b/src/features/clinics/pages/ClinicProfilePage.tsx index 6695cdb..cf1810e 100644 --- a/src/features/clinics/pages/ClinicProfilePage.tsx +++ b/src/features/clinics/pages/ClinicProfilePage.tsx @@ -26,6 +26,7 @@ import { TrendingUp, } from 'lucide-react'; import { useGetReport } from '@/shared/api/generated/reports/reports'; +import { PageContainer } from '@/shared/ui/page-container'; import { CLINIC, DOCTORS, RATINGS, PROCEDURES } from '../data/mockClinicProfile'; export default function ClinicProfilePage() { @@ -118,7 +119,7 @@ export default function ClinicProfilePage() {
-
+ {/* Breadcrumb */}
병원 검색 @@ -165,10 +166,10 @@ export default function ClinicProfilePage() {
-
+
-
+
{/* ── 기본 정보 카드 ── */} @@ -442,7 +443,7 @@ export default function ClinicProfilePage() {
-
+
); } diff --git a/src/features/clinics/pages/ClinicWorkspacePage.tsx b/src/features/clinics/pages/ClinicWorkspacePage.tsx new file mode 100644 index 0000000..2e02582 --- /dev/null +++ b/src/features/clinics/pages/ClinicWorkspacePage.tsx @@ -0,0 +1,141 @@ +/** + * ClinicWorkspacePage — `/clinics/:clinicId` + * + * 계약된 병원 유저의 메인 워크스페이스. + * - 탭 [분석] [설정] 두 개로 단순화. + * - 분석 탭에서 각 카드 = 하나의 분석 run + 연결된 플랜. + * 카드 본문 클릭은 리포트 페이지로 이동, 옆 버튼으로 플랜 진입. + * + * 비주얼: ReportHeader / ReportBody(SectionWrapper) 와 동일 톤. + */ +import { useMemo, useRef } from 'react'; +import { useParams, useSearchParams } from 'react-router'; +import { FileSearch, Settings as SettingsIcon } from 'lucide-react'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/shared/ui/tabs'; +import { WorkspaceHeader } from '../components/WorkspaceHeader'; +import { AnalysisTab } from '../components/tabs/AnalysisTab'; +import { SettingsTab } from '../components/tabs/SettingsTab'; +import { mockWorkspace } from '../data/mockClinicWorkspace'; +import { PageContainer } from '@/shared/ui/page-container'; + +const VALID_TABS = ['analysis', 'settings'] as const; +type TabKey = (typeof VALID_TABS)[number]; + +function isTabKey(value: string | null): value is TabKey { + return value !== null && (VALID_TABS as readonly string[]).includes(value); +} + +export default function ClinicWorkspacePage() { + const { clinicId } = useParams<{ clinicId: string }>(); + const [searchParams, setSearchParams] = useSearchParams(); + const requested = searchParams.get('tab'); + const activeTab: TabKey = isTabKey(requested) ? requested : 'analysis'; + + // TODO: 실 API 연동 (useGetClinicHistory + report 메타 보강) + const data = useMemo( + () => ({ + ...mockWorkspace, + clinic: { + ...mockWorkspace.clinic, + clinicId: clinicId ?? mockWorkspace.clinic.clinicId, + }, + }), + [clinicId], + ); + + const sortedRuns = useMemo( + () => + [...data.runs].sort( + (a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(), + ), + [data.runs], + ); + const latestRun = sortedRuns[0]; + const previousRun = sortedRuns[1]; + + // 탭 클릭 시 탭 스트립이 viewport 상단(Navbar 바로 아래)으로 오도록 점프. + // - sticky 자체에 scrollIntoView 는 동작 X (브라우저가 stuck 위치를 "이미 정확"으로 인식) + // - shadcn Tabs 는 forwardRef X — 일반 div wrapper 에 ref 부착해야 함 + // - 이 navigation 시 scroll 을 0 으로 리셋하므로 + // `preventScrollReset: true` 로 차단 후 직접 scrollTo + const tabWrapperRef = useRef(null); + + const handleTabChange = (next: string) => { + if (!isTabKey(next)) return; + const params = new URLSearchParams(searchParams); + if (next === 'analysis') { + params.delete('tab'); + } else { + params.set('tab', next); + } + setSearchParams(params, { replace: true, preventScrollReset: true }); + + if (!tabWrapperRef.current) return; + // Navbar 높이를 실측 — h-20 변경 시 자동 대응 + const navbar = document.querySelector('nav'); + const navbarHeight = navbar instanceof HTMLElement ? navbar.offsetHeight : 80; + const y = + tabWrapperRef.current.getBoundingClientRect().top + window.scrollY - navbarHeight; + window.scrollTo({ top: y, behavior: 'auto' }); + }; + + return ( +
+ + + {/* hero 아래 본문 영역 — 흰 배경이 footer까지 자연스럽게 이어지도록 */} + {/* tabWrapperRef 는 ref-forwarding 되지 않는 Tabs 대신 일반 div 에 부착 */} +
+ + {/* 탭 스트립 — sticky. 점프는 tabWrapperRef(non-sticky Tabs root) 기준 */} +
+ + + + + 분석 + + {data.runs.length} + + + + + 설정 + + + +
+ + {/* 탭 패널 */} + + + + + + +
+
+
+ ); +} diff --git a/src/features/clinics/routes.tsx b/src/features/clinics/routes.tsx index 70abdaa..174d085 100644 --- a/src/features/clinics/routes.tsx +++ b/src/features/clinics/routes.tsx @@ -2,7 +2,16 @@ import { lazy } from 'react' import type { RouteObject } from 'react-router' const ClinicProfilePage = lazy(() => import('./pages/ClinicProfilePage')) +const ClinicWorkspacePage = lazy(() => import('./pages/ClinicWorkspacePage')) +const UserReportPage = lazy(() => import('@/features/report/pages/UserReportPage')) +const UserPlanPage = lazy(() => import('@/features/plan/pages/UserPlanPage')) export const clinicsRoutes: RouteObject[] = [ + // 공개 프로필 (손님/누구나) { path: 'clinic/:id', element: }, + + // 유저 워크스페이스 (계약 병원 전용) + { path: 'clinics/:clinicId', element: }, + { path: 'clinics/:clinicId/report/:id', element: }, + { path: 'clinics/:clinicId/plan/:id', element: }, ] diff --git a/src/features/clinics/types/workspace.ts b/src/features/clinics/types/workspace.ts new file mode 100644 index 0000000..297c540 --- /dev/null +++ b/src/features/clinics/types/workspace.ts @@ -0,0 +1,68 @@ +/** + * Clinic workspace types — 유저 워크스페이스 (계약된 병원 전용) 뷰 모델. + * + * 백엔드 RunSummary는 메타데이터만 담고 있어서, 분석 대상 플랫폼/URL을 함께 + * 표시하려면 추가 데이터가 필요합니다. 현재는 mock 단으로 구성하고, + * 향후 API에서 같은 형태로 받을 수 있도록 타입을 고정합니다. + */ + +export type PlatformKey = + | 'website' + | 'instagram' + | 'youtube' + | 'facebook' + | 'tiktok' + | 'gangnamUnni' + | 'naverPlace' + | 'naverBlog'; + +export interface PlatformTarget { + platform: PlatformKey; + /** 표시용 핸들 또는 도메인. 예: '@view_clinic', 'view.co.kr' */ + handle: string; + /** 실제 URL (선택). 예: 'https://instagram.com/view_clinic' */ + url?: string; +} + +export type WorkspaceRunStatus = 'completed' | 'running' | 'failed' | 'queued'; + +export interface WorkspaceRun { + runId: string; + startedAt: string; + completedAt: string | null; + status: WorkspaceRunStatus; + overallScore: number | null; + /** 분석 대상 (플랫폼별 핸들) */ + targets: PlatformTarget[]; +} + +export type WorkspacePlanStatus = 'draft' | 'active' | 'archived'; + +export interface WorkspacePlan { + planId: string; + /** 베이스 리포트 run id (어떤 리포트에서 파생됐는지) */ + baseRunId: string; + createdAt: string; + status: WorkspacePlanStatus; + /** 워크플로우 진척률 0-100. null = 미시작/없음 */ + workflowProgress: number | null; + /** 플랜이 다루는 채널 (요약 표시용) */ + channels: PlatformTarget[]; +} + +export interface WorkspaceClinicProfile { + clinicId: string; + name: string; + nameEn?: string; + location?: string; + logoUrl?: string; + brandColor?: string; + /** 현재 등록된 분석 대상 (설정 탭에서 편집) */ + defaultTargets: PlatformTarget[]; +} + +export interface WorkspaceData { + clinic: WorkspaceClinicProfile; + runs: WorkspaceRun[]; + plans: WorkspacePlan[]; +} diff --git a/src/features/dev/pages/ComponentsPage.tsx b/src/features/dev/pages/ComponentsPage.tsx index c5cb15b..8473e5f 100644 --- a/src/features/dev/pages/ComponentsPage.tsx +++ b/src/features/dev/pages/ComponentsPage.tsx @@ -36,6 +36,7 @@ import { SelectValue, } from '@/shared/ui/select'; import * as FilledIcons from '@/shared/icons/FilledIcons'; +import { PageContainer } from '@/shared/ui/page-container'; /* ─────────────── 작은 보조 컴포넌트 ─────────────── */ @@ -121,10 +122,10 @@ function Section({ }) { return (
-
+ {children} -
+
); } @@ -139,7 +140,7 @@ export default function ComponentsPage() {
{/* Hero */}
-
+

Design System

@@ -148,7 +149,7 @@ export default function ComponentsPage() { 토큰·폰트·버튼·카드·폼·다이얼로그 등 공통 요소를 한 화면에서 점검. 새로 만들 때 이 페이지에 추가해두면 디자이너/개발자가 같은 곳을 봅니다.

-
+
{/* 1. Brand Colors */} diff --git a/src/features/distribution/pages/DistributionPage.tsx b/src/features/distribution/pages/DistributionPage.tsx index 2468b1e..03141d5 100644 --- a/src/features/distribution/pages/DistributionPage.tsx +++ b/src/features/distribution/pages/DistributionPage.tsx @@ -7,6 +7,7 @@ import { import { Button } from '@/shared/ui/button'; import { Input } from '@/shared/ui/input'; import { Textarea } from '@/shared/ui/textarea'; +import { PageContainer } from '@/shared/ui/page-container'; import { MOCK_CONTENT, INITIAL_CHANNELS } from '../data/distributionMocks'; // ─── 컴포넌트 ─── @@ -58,7 +59,7 @@ export default function DistributionPage() { {/* Header */}
-
+

Content Distribution

콘텐츠 배포 @@ -66,10 +67,10 @@ export default function DistributionPage() {

제작된 콘텐츠를 연결된 채널에 동시 배포합니다.

-

+
-
+
{/* Left: Content Preview + Meta */} @@ -350,7 +351,7 @@ export default function DistributionPage() { )}
-
+
); } diff --git a/src/features/landing/components/CTA.tsx b/src/features/landing/components/CTA.tsx index 7177d97..61a6285 100644 --- a/src/features/landing/components/CTA.tsx +++ b/src/features/landing/components/CTA.tsx @@ -59,7 +59,7 @@ export default function CTA() { > - {/* 보조 CTA — 가격 플랜 */} + {/* 보조 CTA — 가격 플랜 (임시 비활성)
+ */}
diff --git a/src/features/landing/components/Hero.tsx b/src/features/landing/components/Hero.tsx index c6aaf89..c9bc404 100644 --- a/src/features/landing/components/Hero.tsx +++ b/src/features/landing/components/Hero.tsx @@ -16,7 +16,7 @@ export default function Hero() { }; return ( -
+
{/* Background Gradient */}
diff --git a/src/features/landing/components/Modules.tsx b/src/features/landing/components/Modules.tsx index ba6cd2a..9793a3d 100644 --- a/src/features/landing/components/Modules.tsx +++ b/src/features/landing/components/Modules.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { motion } from 'motion/react'; +import { PageContainer } from '@/shared/ui/page-container'; // PART II 피봇: 1·5번은 Product 1.0 Available, 2·3·4는 Coming Soon. // 카피는 Intelligence + Planning 중심으로 조정 (구조 5개 카드는 유지). @@ -124,7 +125,7 @@ export default function Modules() {
-
+
-
+ ); } diff --git a/src/features/landing/components/Problems.tsx b/src/features/landing/components/Problems.tsx index 37ce561..3fb0a81 100644 --- a/src/features/landing/components/Problems.tsx +++ b/src/features/landing/components/Problems.tsx @@ -1,4 +1,5 @@ import { motion } from 'motion/react'; +import { PageContainer } from '@/shared/ui/page-container'; // PART II 피봇: 현장 고충 중심으로 재작성. #3 "데이터 기반의 마케팅 부족"은 유지(사용자 피드백). const problems = [ @@ -19,8 +20,8 @@ const problems = [ export default function Problems() { return ( -
-
+
+ ))}
-
+ ); } diff --git a/src/features/landing/components/Solution.tsx b/src/features/landing/components/Solution.tsx index b9ba9db..3fdf00d 100644 --- a/src/features/landing/components/Solution.tsx +++ b/src/features/landing/components/Solution.tsx @@ -1,5 +1,5 @@ import { motion } from 'motion/react'; -import { Sparkles, ArrowRight } from 'lucide-react'; +import { Sparkles } from 'lucide-react'; export default function Solution() { return ( @@ -49,7 +49,7 @@ export default function Solution() { 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" + className="relative w-full max-w-[240px] md:max-w-[500px] aspect-square mx-auto mt-16 mb-24 md:mb-32" > {/* Static Inner Ring */}
@@ -76,41 +76,41 @@ export default function Solution() { {/* Node A: Audit (Left) — 구 Analysis. 병원·채널 진단 리포트 */}
-
- A +
+ A
- Audit + Audit
{/* Node G: Generation (Top) — 전략·로드맵 문서 생성 (AI 콘텐츠 생성 아님) */}
-
- G +
+ G
- Generation + Generation
{/* Node D: Direction (Right) — 구 Distribution. 채널별 전략·우선순위 설계 */}
-
- D +
+ D
- Direction + Direction
{/* Node P: Planning (Bottom) — 구 Performance. KPI 목표 설정 + 주간 조정 */}
-
- P +
+ P
- Planning + Planning
diff --git a/src/features/landing/components/TargetAudience.tsx b/src/features/landing/components/TargetAudience.tsx index 4e0a1b6..c4177d8 100644 --- a/src/features/landing/components/TargetAudience.tsx +++ b/src/features/landing/components/TargetAudience.tsx @@ -1,9 +1,10 @@ import { motion } from 'motion/react'; +import { PageContainer } from '@/shared/ui/page-container'; export default function TargetAudience() { return ( -
-
+
+
-
+ ); } diff --git a/src/features/landing/components/UseCases.tsx b/src/features/landing/components/UseCases.tsx index 5469689..4af8bdc 100644 --- a/src/features/landing/components/UseCases.tsx +++ b/src/features/landing/components/UseCases.tsx @@ -1,10 +1,11 @@ import { motion } from 'motion/react'; import { CheckCircle2 } from 'lucide-react'; +import { PageContainer } from '@/shared/ui/page-container'; export default function UseCases() { return (
-
+
-
+ ); } diff --git a/src/features/performance/pages/PerformancePage.tsx b/src/features/performance/pages/PerformancePage.tsx index f9249f4..c8ecf22 100644 --- a/src/features/performance/pages/PerformancePage.tsx +++ b/src/features/performance/pages/PerformancePage.tsx @@ -19,6 +19,7 @@ import { HEATMAP_DATA, AI_RECOMMENDATIONS, } from '../data/performanceMocks'; +import { PageContainer } from '@/shared/ui/page-container'; // ─── Component ─── @@ -54,7 +55,7 @@ export default function PerformancePage() {
-
+

Performance Intelligence

성과 대시보드

모든 채널의 마케팅 성과를 실시간으로 모니터링합니다.

@@ -78,10 +79,10 @@ export default function PerformancePage() { ))}
-
+
-
+ {/* Overview Stats */}
@@ -386,7 +387,7 @@ export default function PerformancePage() { ))}
-
+
); } diff --git a/src/features/plan/components/AssetCollection.tsx b/src/features/plan/components/AssetCollection.tsx index 8192048..0f88d65 100644 --- a/src/features/plan/components/AssetCollection.tsx +++ b/src/features/plan/components/AssetCollection.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { motion } from 'motion/react'; import { YoutubeFilled } from '@/shared/icons/FilledIcons'; import { SectionWrapper } from '@/features/report/components/ui/SectionWrapper'; +import { EmptyState } from '@/shared/ui/empty-state'; import { Button } from '@/shared/ui/button'; import AssetDetailModal from './AssetDetailModal'; import type { AssetCollectionData, AssetCard, YouTubeRepurposeItem, AssetSource, AssetStatus, AssetType } from '@/features/plan/types/plan'; @@ -85,6 +86,11 @@ export default function AssetCollection({ data }: AssetCollectionProps) {
{/* Asset Cards Grid */} + {filteredAssets.length === 0 ? ( +
+ +
+ ) : (
{filteredAssets.map((asset, i) => { const statusInfo = statusConfig[asset.status]; @@ -141,6 +147,7 @@ export default function AssetCollection({ data }: AssetCollectionProps) { ); })}
+ )} {/* YouTube Repurpose Section */} {data.youtubeRepurpose.length > 0 && ( diff --git a/src/features/plan/components/BrandAppliedPreview.tsx b/src/features/plan/components/BrandAppliedPreview.tsx new file mode 100644 index 0000000..03eb5dd --- /dev/null +++ b/src/features/plan/components/BrandAppliedPreview.tsx @@ -0,0 +1,286 @@ +/** + * BrandAppliedPreview — 브랜드(컬러/타이포/로고)가 실제로 적용된 모습을 + * 인스타 포스트 + 유튜브 썸네일 두 가지 미니 mockup 으로 보여주는 컴포넌트. + * + * 외부 자산(병원 사진, 의사 사진 등) 없이 컬러+폰트+이니셜만으로 구성되어 + * 어느 병원이든 즉시 미리보기가 가능합니다. + */ +import { motion } from 'motion/react'; +import { Heart, MessageCircle, Send, Bookmark, Play, Sparkles } from 'lucide-react'; +import { + InstagramFilled, + YoutubeFilled, +} from '@/shared/icons/FilledIcons'; +import type { BrandGuide } from '@/features/plan/types/plan'; + +interface BrandAppliedPreviewProps { + data: BrandGuide; + clinicName: string; + /** 미리보기 헤드라인. 없으면 toneOfVoice 의 personality 첫 단어 + 병원명 조합 */ + headline?: string; + /** 미리보기 태그라인. 없으면 communicationStyle 일부 */ + tagline?: string; +} + +function getHeadingFont(data: BrandGuide): string | undefined { + const heading = data.fonts.find( + (f) => + f.usage.toLowerCase().includes('heading') || + f.usage.toLowerCase().includes('headline') || + f.weight.toLowerCase().includes('bold'), + ); + return heading?.family ?? data.fonts[0]?.family; +} + +function getBodyFont(data: BrandGuide): string | undefined { + const body = data.fonts.find( + (f) => + f.usage.toLowerCase().includes('body') || + f.usage.toLowerCase().includes('text'), + ); + return body?.family ?? data.fonts[data.fonts.length - 1]?.family; +} + +/** 첫 2자만 이니셜로 (한글이면 1자) */ +function getInitial(name: string): string { + const trimmed = name.trim(); + if (!trimmed) return '?'; + const isKorean = /[가-힣]/.test(trimmed[0]); + return isKorean ? trimmed[0] : trimmed.slice(0, 2).toUpperCase(); +} + +/** hex → 텍스트가 흰색 vs 검정 어느 쪽이 더 잘 보일지 결정 */ +function pickTextOn(hex: string): 'light' | 'dark' { + const cleaned = hex.replace('#', ''); + if (cleaned.length !== 6) return 'light'; + const r = parseInt(cleaned.slice(0, 2), 16); + const g = parseInt(cleaned.slice(2, 4), 16); + const b = parseInt(cleaned.slice(4, 6), 16); + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return luminance > 0.6 ? 'dark' : 'light'; +} + +export function BrandAppliedPreview({ + data, + clinicName, + headline, + tagline, +}: BrandAppliedPreviewProps) { + const primary = data.colors[0]?.hex ?? '#4F1DA1'; + const accent = data.colors[1]?.hex ?? '#6C5CE7'; + const tertiary = data.colors[2]?.hex ?? '#021341'; + const headingFont = getHeadingFont(data); + const bodyFont = getBodyFont(data); + const initial = getInitial(clinicName); + + const computedHeadline = + headline ?? + `${data.toneOfVoice?.personality?.[0] ?? '자연스러운'}\n${clinicName}`; + const computedTagline = + tagline ?? data.toneOfVoice?.communicationStyle?.slice(0, 40) ?? `${clinicName} · 프리미엄 케어`; + + const onPrimary = pickTextOn(primary); + + return ( +
+

+ Brand in Action +

+

+ 등록된 컬러·타이포가 실제 콘텐츠에 적용됐을 때의 모습입니다. +

+ +
+ {/* ── Instagram Post Mockup ─────────────────────────── */} + + {/* IG 헤더 */} +
+
+
+ {initial} +
+ + {clinicName.toLowerCase().replace(/\s+/g, '_')} + +
+ +
+ + {/* IG 본문 (1:1) */} +
+
+
+ +
+

+ {clinicName} · Story +

+

+ {computedHeadline} +

+

+ {computedTagline} +

+
+
+ + {/* IG 액션바 */} +
+
+ + + +
+ +
+
+ Instagram Feed · 1:1 +
+ + + {/* ── YouTube Thumbnail Mockup ──────────────────────── */} + + {/* 썸네일 (16:9) */} +
+
+ + {/* 코너 브랜드 마크 */} +
+
+ {initial} +
+ + {clinicName} + +
+ +
+ +
+ + {/* 헤드라인 */} +
+ + Episode 01 + +

+ {computedHeadline} +

+
+ + {/* Play 버튼 */} +
+
+ +
+
+ +
+ 03:24 +
+
+ + {/* 유튜브 메타 */} +
+
+ {initial} +
+
+

+ {computedHeadline.replace(/\n/g, ' · ')} +

+
+ + {clinicName} + · + 조회수 12K +
+
+
+
+ YouTube Thumbnail · 16:9 +
+ +
+
+ ); +} diff --git a/src/features/plan/components/BrandingGuide.tsx b/src/features/plan/components/BrandingGuide.tsx index e040be5..4261ffa 100644 --- a/src/features/plan/components/BrandingGuide.tsx +++ b/src/features/plan/components/BrandingGuide.tsx @@ -7,8 +7,10 @@ import { WarningFilled, } from '@/shared/icons/FilledIcons'; import { SectionWrapper } from '@/features/report/components/ui/SectionWrapper'; +import { EmptyState } from '@/shared/ui/empty-state'; import { Button } from '@/shared/ui/button'; import { Input } from '@/shared/ui/input'; +import { BrandAppliedPreview } from './BrandAppliedPreview'; import { tabItems, type TabKey, @@ -21,6 +23,7 @@ import type { BrandInconsistency } from '@/features/report/types/report'; interface BrandingGuideProps { data: BrandGuide; + clinicName: string; } /* ─── 색상 스와치 편집 팝오버 ─── */ @@ -119,7 +122,7 @@ function ColorSwatchCard({ swatch, onUpdate }: { swatch: ColorSwatch; onUpdate: } /* ─── 비주얼 아이덴티티 탭 ─── */ -function VisualIdentityTab({ data }: { data: BrandGuide }) { +function VisualIdentityTab({ data, clinicName }: { data: BrandGuide; clinicName: string }) { const [colors, setColors] = useState(data.colors); const handleColorUpdate = (idx: number, newHex: string) => { @@ -135,101 +138,126 @@ function VisualIdentityTab({ data }: { data: BrandGuide }) { {/* Color Palette */}

Color Palette

-
- {colors.map((swatch: ColorSwatch, idx: number) => ( - handleColorUpdate(idx, newHex)} - /> - ))} -
+ {colors.length > 0 ? ( +
+ {colors.map((swatch: ColorSwatch, idx: number) => ( + handleColorUpdate(idx, newHex)} + /> + ))} +
+ ) : ( + + )}
{/* Typography */}

Typography

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

- {spec.family} -

-

0 ? ( +

+ {data.fonts.map((spec) => ( +
- {spec.sampleText} -

-

- {spec.weight} ·{' '} - {spec.usage} -

-
- ))} -
+

+ {spec.family} +

+

+ {spec.sampleText} +

+

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

+
+ ))} +
+ ) : ( + + )}
{/* Logo Rules — DO / DON'T split columns */}

Logo Rules

-
- {/* DO Column */} -
-
- - DO -
-
- {data.logoRules.filter((r) => r.correct).map((rule) => ( -
-
- -
-

{rule.rule}

-

{rule.description}

+ {data.logoRules.length > 0 ? ( +
+ {/* DO Column */} +
+
+ + DO +
+ {data.logoRules.some((r) => r.correct) ? ( +
+ {data.logoRules.filter((r) => r.correct).map((rule) => ( +
+
+ +
+

{rule.rule}

+

{rule.description}

+
+
-
+ ))}
- ))} + ) : ( + + )}
-
- {/* DON'T Column */} -
-
- - DON'T -
-
- {data.logoRules.filter((r) => !r.correct).map((rule) => ( -
-
- -
-

{rule.rule}

-

{rule.description}

+ {/* DON'T Column */} +
+
+ + DON'T +
+ {data.logoRules.some((r) => !r.correct) ? ( +
+ {data.logoRules.filter((r) => !r.correct).map((rule) => ( +
+
+ +
+

{rule.rule}

+

{rule.description}

+
+
-
+ ))}
- ))} + ) : ( + + )}
-
+ ) : ( + + )}
+ + {/* Brand applied preview — IG/YouTube mockup */} + {data.colors.length > 0 && data.fonts.length > 0 && ( + + )} ); } @@ -245,26 +273,34 @@ function ToneVoiceTab({ tone }: { tone: BrandGuide['toneOfVoice'] }) { {/* Personality */}

Personality

-
- {tone.personality.map((trait) => ( - - {trait} - - ))} -
+ {tone.personality && tone.personality.length > 0 ? ( +
+ {tone.personality.map((trait) => ( + + {trait} + + ))} +
+ ) : ( + + )}
{/* Communication Style */}

Communication Style

-
-

- {tone.communicationStyle} -

-
+ {tone.communicationStyle ? ( +
+

+ {tone.communicationStyle} +

+
+ ) : ( + + )}
{/* DO / DON'T */} @@ -273,31 +309,39 @@ function ToneVoiceTab({ tone }: { tone: BrandGuide['toneOfVoice'] }) {

DO

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

{example}

-
- ))} -
+ {tone.doExamples && tone.doExamples.length > 0 ? ( +
+ {tone.doExamples.map((example, i) => ( +
+

{example}

+
+ ))} +
+ ) : ( + + )}

DON'T

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

{example}

-
- ))} -
+ {tone.dontExamples && tone.dontExamples.length > 0 ? ( +
+ {tone.dontExamples.map((example, i) => ( +
+

{example}

+
+ ))} +
+ ) : ( + + )}
@@ -306,6 +350,13 @@ function ToneVoiceTab({ tone }: { tone: BrandGuide['toneOfVoice'] }) { /* ─── 채널 규칙 탭 ─── */ function ChannelRulesTab({ channels }: { channels: BrandGuide['channelBranding'] }) { + if (!channels || channels.length === 0) { + return ( + + + + ); + } return ( (0); + if (!inconsistencies || inconsistencies.length === 0) { + return ( + + + + ); + } + return ( ('visual'); return ( @@ -485,7 +544,7 @@ export default function BrandingGuide({ data }: BrandingGuideProps) { ))}
- {activeTab === 'visual' && } + {activeTab === 'visual' && } {activeTab === 'tone' && } {activeTab === 'channels' && } {activeTab === 'consistency' && ( diff --git a/src/features/plan/components/ContentStrategy.tsx b/src/features/plan/components/ContentStrategy.tsx index 69c09c0..ff212a5 100644 --- a/src/features/plan/components/ContentStrategy.tsx +++ b/src/features/plan/components/ContentStrategy.tsx @@ -3,6 +3,7 @@ import { motion } from 'motion/react'; import { ArrowRight } from 'lucide-react'; import { VideoFilled } from '@/shared/icons/FilledIcons'; import { SectionWrapper } from '@/features/report/components/ui/SectionWrapper'; +import { EmptyState } from '@/shared/ui/empty-state'; import { Button } from '@/shared/ui/button'; import type { ContentStrategyData } from '@/features/plan/types/plan'; @@ -68,7 +69,7 @@ export default function ContentStrategy({ data }: ContentStrategyProps) {
{/* Tab 1: Content Pillars */} - {activeTab === 'pillars' && ( + {activeTab === 'pillars' && (data.pillars && data.pillars.length > 0 ? ( ))} - )} + ) : ( + + ))} {/* Tab 2: Content Types */} - {activeTab === 'types' && ( + {activeTab === 'types' && (data.typeMatrix && data.typeMatrix.length > 0 ? ( ))} - )} + ) : ( + + ))} {/* Tab 3: Production Workflow */} {activeTab === 'workflow' && (() => { + if (!data.workflow || data.workflow.length === 0) { + return ; + } // step 수에 따라 동일 너비 grid 컬럼 매핑 (Tailwind purge 안전) const cols = data.workflow.length; const gridColsClass = @@ -198,39 +206,49 @@ export default function ContentStrategy({ data }: ContentStrategyProps) { {/* Tab 4: Repurposing */} {activeTab === 'repurposing' && ( - - {/* Source Card */} -
-
- -

{data.repurposingSource}

+ (data.repurposingSource || (data.repurposingOutputs && data.repurposingOutputs.length > 0)) ? ( + + {/* Source Card */} + {data.repurposingSource && ( +
+
+ +

{data.repurposingSource}

+
+
+ )} + + {/* Connector */} +
+
-
- {/* Connector */} -
-
-
- - {/* Outputs Grid */} -
- {data.repurposingOutputs.map((output, i) => ( - -

{output.format}

-

{output.channel}

-

{output.description}

-
- ))} -
- + {/* Outputs Grid */} + {data.repurposingOutputs && data.repurposingOutputs.length > 0 ? ( +
+ {data.repurposingOutputs.map((output, i) => ( + +

{output.format}

+

{output.channel}

+

{output.description}

+
+ ))} +
+ ) : ( + + )} + + ) : ( + + ) )} ); diff --git a/src/features/plan/components/PlanBody.tsx b/src/features/plan/components/PlanBody.tsx new file mode 100644 index 0000000..f29f16a --- /dev/null +++ b/src/features/plan/components/PlanBody.tsx @@ -0,0 +1,83 @@ +/** + * PlanBody — 마케팅 기획 본문 (읽기 전용 섹션) 의 순수 렌더링 컴포넌트. + * + * 각 섹션은 데이터가 비어있으면 `EmptySection` 으로 fallback 되어 + * "데이터가 없습니다" 안내가 표시됩니다. + */ +import type { MarketingPlan } from '../types/plan'; +import { EmptySection } from '@/features/report/components/ui/EmptySection'; +import PlanHeader from './PlanHeader'; +import BrandingGuide from './BrandingGuide'; +import ChannelStrategy from './ChannelStrategy'; +import ContentStrategy from './ContentStrategy'; +import ContentCalendar from './ContentCalendar'; +import AssetCollection from './AssetCollection'; +import RepurposingProposal from './RepurposingProposal'; + +interface PlanBodyProps { + data: MarketingPlan; +} + +function hasValue(v: T | null | undefined): v is T { + return v != null; +} + +function nonEmpty(arr: T[] | null | undefined): arr is T[] { + return Array.isArray(arr) && arr.length > 0; +} + +export default function PlanBody({ data }: PlanBodyProps) { + // 플랜 데이터엔 직접 social handle 필드가 없어 website 만 전달. + // 정식 연동 시 플랜이 source report 의 socialHandles 를 같이 가져와야 함 (TODO). + const socialHandles = { + website: data.targetUrl || null, + }; + + return ( +
+ + + {hasValue(data.brandGuide) ? ( + + ) : ( + + )} + + {nonEmpty(data.channelStrategies) ? ( + + ) : ( + + )} + + {hasValue(data.contentStrategy) ? ( + + ) : ( + + )} + + {hasValue(data.calendar) ? ( + + ) : ( + + )} + + {hasValue(data.assetCollection) ? ( + + ) : ( + + )} + + {nonEmpty(data.repurposingProposals) ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/features/plan/components/PlanCTA.tsx b/src/features/plan/components/PlanCTA.tsx index 693c160..3f18fa4 100644 --- a/src/features/plan/components/PlanCTA.tsx +++ b/src/features/plan/components/PlanCTA.tsx @@ -2,6 +2,7 @@ import { motion } from 'motion/react'; import { useNavigate, useParams } from 'react-router'; import { RocketFilled, DownloadFilled } from '@/shared/icons/FilledIcons'; import { Button } from '@/shared/ui/button'; +import { PageContainer } from '@/shared/ui/page-container'; import { useExportPDF } from '@/features/report/hooks/useExportPDF'; export default function PlanCTA() { @@ -15,7 +16,7 @@ export default function PlanCTA() { animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.6, ease: 'easeOut' }} > -
+
-
+ ); } diff --git a/src/features/plan/components/PlanHeader.tsx b/src/features/plan/components/PlanHeader.tsx index 26943e8..ab4507a 100644 --- a/src/features/plan/components/PlanHeader.tsx +++ b/src/features/plan/components/PlanHeader.tsx @@ -1,5 +1,7 @@ import { motion } from 'motion/react'; import { CalendarFilled, GlobeFilled } from '@/shared/icons/FilledIcons'; +import { PageContainer } from '@/shared/ui/page-container'; +import { ChannelLinkButtons, type ChannelHandles } from '@/shared/ui/channel-link-buttons'; function formatDate(raw: string): string { try { @@ -18,6 +20,7 @@ interface PlanHeaderProps { clinicNameEn: string; date: string; targetUrl: string; + socialHandles?: ChannelHandles; } export default function PlanHeader({ @@ -25,6 +28,7 @@ export default function PlanHeader({ clinicNameEn, date, targetUrl, + socialHandles, }: PlanHeaderProps) { return (
@@ -45,7 +49,7 @@ export default function PlanHeader({ transition={{ duration: 6, repeat: Infinity, ease: 'easeInOut' }} /> -
+
{/* Left: Text content — plain div, no opacity animation */}
@@ -71,6 +75,17 @@ export default function PlanHeader({ {targetUrl}
+ + {/* 등록된 채널 바로가기 */} + {socialHandles && ( +
+ +
+ )}
{/* Right: 90 Days badge */} @@ -83,7 +98,7 @@ export default function PlanHeader({
-
+ ); } diff --git a/src/features/plan/pages/GuestPlanPage.tsx b/src/features/plan/pages/GuestPlanPage.tsx new file mode 100644 index 0000000..193f6d3 --- /dev/null +++ b/src/features/plan/pages/GuestPlanPage.tsx @@ -0,0 +1,83 @@ +/** + * GuestPlanPage — `/plan/:id` + * + * 손님(비계약 방문자)이 보는 플랜 미리보기. 본문은 UserPlanPage 와 동일하나, + * 인터랙티브 섹션(자산 업로드/전략 조정/워크플로우)은 노출되지 않고 + * 하단에 도입 문의 CTA(PlanCTA) 가 붙습니다. + */ +import { useEffect } from 'react'; +import { Link, useParams, useLocation } from 'react-router'; +import { ArrowRight, FileSearch } from 'lucide-react'; +import { useMarketingPlan } from '../hooks/useMarketingPlan'; +import { ReportNav } from '@/features/report/components/ReportNav'; +import { PdfDownloadButton } from '@/features/report/components/PdfDownloadButton'; +import { PLAN_SECTIONS } from '@/shared/constants/planSections'; +import PlanBody from '../components/PlanBody'; +import PlanCTA from '../components/PlanCTA'; + +export default function GuestPlanPage() { + const { id } = useParams<{ id: string }>(); + const location = useLocation(); + const { data, isLoading, error } = useMarketingPlan(id); + + // 해시 기반 스크롤: /plan/:id#section-id → 렌더링 후 해당 섹션으로 + useEffect(() => { + if (isLoading || !location.hash) return; + const sectionId = location.hash.slice(1); + const timer = setTimeout(() => { + const el = document.getElementById(sectionId); + if (!el) return; + const STICKY_OFFSET = 128; + const y = el.getBoundingClientRect().top + window.scrollY - STICKY_OFFSET; + window.scrollTo({ top: y, behavior: 'smooth' }); + }, 300); + return () => clearTimeout(timer); + }, [isLoading, location.hash]); + + if (isLoading) { + return ( +
+
+
+

마케팅 기획을 불러오는 중...

+
+
+ ); + } + + if (error || !data) { + return ( +
+
+

오류가 발생했습니다

+

+ {error ?? '마케팅 기획을 찾을 수 없습니다.'} +

+
+
+ ); + } + + return ( +
+ + + + + 분석 리포트 보기 + + +
+ } + /> + + +
+ ); +} diff --git a/src/features/plan/pages/MarketingPlanPage.tsx b/src/features/plan/pages/MarketingPlanPage.tsx deleted file mode 100644 index 45e1f43..0000000 --- a/src/features/plan/pages/MarketingPlanPage.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { useEffect } from 'react'; -import { useParams, useLocation } from 'react-router'; -import { useMarketingPlan } from '../hooks/useMarketingPlan'; -import { ReportNav } from '@/features/report/components/ReportNav'; -import { PLAN_SECTIONS } from '@/shared/constants/planSections'; - -// 플랜 섹션 컴포넌트 -import PlanHeader from '@/features/plan/components/PlanHeader'; -import BrandingGuide from '@/features/plan/components/BrandingGuide'; -import ChannelStrategy from '@/features/plan/components/ChannelStrategy'; -import ContentStrategy from '@/features/plan/components/ContentStrategy'; -import ContentCalendar from '@/features/plan/components/ContentCalendar'; -import AssetCollection from '@/features/plan/components/AssetCollection'; -import RepurposingProposal from '@/features/plan/components/RepurposingProposal'; -import MyAssetUpload from '@/features/plan/components/MyAssetUpload'; -import StrategyAdjustmentSection from '@/features/plan/components/StrategyAdjustmentSection'; -import WorkflowTracker from '@/features/plan/components/WorkflowTracker'; -import PlanCTA from '@/features/plan/components/PlanCTA'; - -export default function MarketingPlanPage() { - const { id } = useParams<{ id: string }>(); - const location = useLocation(); - const clinicId = (location.state as { clinicId?: string } | undefined)?.clinicId || null; - const { data, isLoading, error } = useMarketingPlan(id); - - // 해시 기반 스크롤: /plan/:id#section-id → 렌더링 후 해당 섹션으로 스크롤 - // sticky Navbar (80px) + ReportNav (~48px) 오프셋으로 섹션 상단 가려짐 방지. - useEffect(() => { - if (isLoading || !location.hash) return; - const sectionId = location.hash.slice(1); - const timer = setTimeout(() => { - const el = document.getElementById(sectionId); - if (!el) return; - const STICKY_OFFSET = 128; - const y = el.getBoundingClientRect().top + window.scrollY - STICKY_OFFSET; - window.scrollTo({ top: y, behavior: 'smooth' }); - }, 300); - return () => clearTimeout(timer); - }, [isLoading, location.hash]); - - if (isLoading) { - return ( -
-
-
-

마케팅 플랜을 불러오는 중...

-
-
- ); - } - - if (error || !data) { - return ( -
-
-

오류가 발생했습니다

-

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

-
-
- ); - } - - return ( -
- - -
- - - - - - - - - - - - - {data.repurposingProposals && data.repurposingProposals.length > 0 && ( - - )} - - {data.workflow && ( - - )} - -
- -
- -
- -
- - -
-
- ); -} diff --git a/src/features/plan/pages/UserPlanPage.tsx b/src/features/plan/pages/UserPlanPage.tsx new file mode 100644 index 0000000..cef6fff --- /dev/null +++ b/src/features/plan/pages/UserPlanPage.tsx @@ -0,0 +1,117 @@ +/** + * UserPlanPage — `/clinics/:clinicId/plan/:id` + * + * 계약된 병원 유저가 워크스페이스에서 운영하는 마케팅 기획 화면. + * GuestPlanPage 의 본문 + 워크스페이스 액션바 + 인터랙티브 섹션 + * (MyAssetUpload / StrategyAdjustmentSection / WorkflowTracker). + */ +import { useEffect } from 'react'; +import { Link, useParams, useLocation } from 'react-router'; +import { ArrowLeft, FileSearch } from 'lucide-react'; +import { useMarketingPlan } from '../hooks/useMarketingPlan'; +import { ReportNav } from '@/features/report/components/ReportNav'; +import { PdfDownloadButton } from '@/features/report/components/PdfDownloadButton'; +import { PLAN_SECTIONS } from '@/shared/constants/planSections'; +import PlanBody from '../components/PlanBody'; +import MyAssetUpload from '../components/MyAssetUpload'; +import StrategyAdjustmentSection from '../components/StrategyAdjustmentSection'; +import WorkflowTracker from '../components/WorkflowTracker'; + +export default function UserPlanPage() { + const { clinicId, id } = useParams<{ clinicId: string; id: string }>(); + const location = useLocation(); + const stateClinicId = (location.state as { clinicId?: string } | undefined)?.clinicId || null; + const { data, isLoading, error } = useMarketingPlan(id); + + useEffect(() => { + if (isLoading || !location.hash) return; + const sectionId = location.hash.slice(1); + const timer = setTimeout(() => { + const el = document.getElementById(sectionId); + if (!el) return; + const STICKY_OFFSET = 128; + const y = el.getBoundingClientRect().top + window.scrollY - STICKY_OFFSET; + window.scrollTo({ top: y, behavior: 'smooth' }); + }, 300); + return () => clearTimeout(timer); + }, [isLoading, location.hash]); + + if (isLoading) { + return ( +
+
+
+

마케팅 기획을 불러오는 중...

+
+
+ ); + } + + if (error || !data) { + return ( +
+
+

오류가 발생했습니다

+

{error ?? '마케팅 기획을 찾을 수 없습니다.'}

+
+
+ ); + } + + // baseRunId(이 플랜의 베이스 리포트) — 데이터 모델에 정식 필드 있으면 그걸 우선 + const baseRunId = + (data as unknown as { baseReportId?: string }).baseReportId || + (data as unknown as { reportId?: string }).reportId || + null; + + // 데모 환경: baseRunId 가 없으면 같은 plan id 로 리포트도 존재 (1:1 매핑) + const reportTargetId = baseRunId ?? id; + + return ( +
+ + + 워크스페이스로 + + } + rightSlot={ +
+ + {reportTargetId && ( + + + 기반 리포트 + + )} +
+ } + /> + + + + {/* 유저 전용 인터랙티브 섹션 */} + {data.workflow && ( +
+ +
+ )} + +
+ +
+ +
+ +
+
+ ); +} diff --git a/src/features/plan/routes.tsx b/src/features/plan/routes.tsx index 02f6eb6..e2c038d 100644 --- a/src/features/plan/routes.tsx +++ b/src/features/plan/routes.tsx @@ -1,8 +1,9 @@ import { lazy } from 'react' import type { RouteObject } from 'react-router' -const MarketingPlanPage = lazy(() => import('./pages/MarketingPlanPage')) +const GuestPlanPage = lazy(() => import('./pages/GuestPlanPage')) export const planRoutes: RouteObject[] = [ - { path: 'plan/:id', element: }, + // 손님(랜딩→분석→리포트→플랜) 흐름. 유저 워크스페이스 경로는 features/clinics/routes.tsx 참조. + { path: 'plan/:id', element: }, ] diff --git a/src/features/pricing/pages/PricingPage.tsx b/src/features/pricing/pages/PricingPage.tsx index f42517e..b82b0b6 100644 --- a/src/features/pricing/pages/PricingPage.tsx +++ b/src/features/pricing/pages/PricingPage.tsx @@ -33,6 +33,7 @@ import FeatureComparisonTable from '../components/FeatureComparisonTable'; import FAQ from '../components/FAQ'; import { buildContactMailto } from '@/shared/lib/contact'; import { Button } from '@/shared/ui/button'; +import { PageContainer } from '@/shared/ui/page-container'; import { tiers, type Tier } from '../data/pricingTiers'; // ─── 가격 포맷터 ────────────────────────────────────────────────── @@ -350,39 +351,51 @@ export default function PricingPage() { {/* ── Section 3 · 3 Tier Cards ─────────────────── */} -
-
- {tiers.map((tier) => ( - - ))} -
-
+ +
+
+ {tiers.map((tier) => ( + + ))} +
+
+
{/* ── Section 4 · Feature Comparison Table ─── */} -
- -
+ +
+ +
+
{/* ── Section 5 · 먼저 문의하기 강조 ───────────── */} {/* outer max-w-7xl로 Tier Cards 섹션과 동일 정렬 (반응형 자동 축소) */} -
- -
+ +
+ +
+
{/* ── Section 6 · Launch Promotion ───────────── */} -
- -
+ +
+ +
+
{/* ── Section 7 · FAQ ─────────────────────────── */} -
- -
+ +
+ +
+
{/* ── Section 8 · Enterprise Contact ─────────── */} -
- -
+ +
+ +
+
); } diff --git a/src/features/report/components/ClinicSnapshot.tsx b/src/features/report/components/ClinicSnapshot.tsx index c5f965e..f1641f1 100644 --- a/src/features/report/components/ClinicSnapshot.tsx +++ b/src/features/report/components/ClinicSnapshot.tsx @@ -171,8 +171,9 @@ export default function ClinicSnapshot({ data }: ClinicSnapshotProps) { {data.certifications.map((cert) => ( + {cert} ))} diff --git a/src/features/report/components/DownloadMenuButton.tsx b/src/features/report/components/DownloadMenuButton.tsx new file mode 100644 index 0000000..18688cc --- /dev/null +++ b/src/features/report/components/DownloadMenuButton.tsx @@ -0,0 +1,65 @@ +/** + * DownloadMenuButton — 리포트 다운로드 단일 진입점. + * 클릭하면 PDF / CSV 선택 메뉴가 드롭다운으로 열림. + * + * PDF: 브라우저 네이티브 인쇄(window.print) → "PDF로 저장" + * CSV: 리포트의 표 데이터를 다중 섹션 CSV 단일 파일로 다운로드 + */ +import { Download, FileText, FileSpreadsheet, ChevronDown, Loader2 } from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from '@/shared/ui/dropdown-menu'; +import { useExportPDF } from '@/features/report/hooks/useExportPDF'; +import { useExportCSV } from '@/features/report/hooks/useExportCSV'; +import type { MarketingReport } from '@/features/report/types/report'; + +interface DownloadMenuButtonProps { + /** 파일명 베이스. 자동으로 .pdf / .csv 확장자 붙음 */ + filename: string; + /** 서버에서 받아온 리포트 전체 (CSV 생성에 사용) */ + report: MarketingReport; + className?: string; +} + +export function DownloadMenuButton({ filename, report, className }: DownloadMenuButtonProps) { + const { exportPDF, isExporting } = useExportPDF(); + const { exportReportCSV } = useExportCSV(); + + return ( + + + {isExporting ? ( + <> + + 생성 중... + + ) : ( + <> + + 다운로드 + + + )} + + + exportPDF(filename)}> + + PDF로 저장 + + exportReportCSV(filename, report)}> + + CSV로 저장 + + + + ); +} diff --git a/src/features/report/components/OtherChannels.tsx b/src/features/report/components/OtherChannels.tsx index a9b57bf..98fb3c1 100644 --- a/src/features/report/components/OtherChannels.tsx +++ b/src/features/report/components/OtherChannels.tsx @@ -118,9 +118,6 @@ export default function OtherChannels({ channels, website }: OtherChannelsProps)

{pixel.name}

- {pixel.details && ( -

{pixel.details}

- )}
))} diff --git a/src/features/report/components/PdfDownloadButton.tsx b/src/features/report/components/PdfDownloadButton.tsx new file mode 100644 index 0000000..2cbb227 --- /dev/null +++ b/src/features/report/components/PdfDownloadButton.tsx @@ -0,0 +1,51 @@ +/** + * PdfDownloadButton — 리포트/플랜 PDF 다운로드 버튼. + * + * 브라우저 네이티브 인쇄 다이얼로그를 띄워 "PDF로 저장" 흐름을 사용합니다. + * `@media print` 규칙(`src/styles/custom.css`)이 레이아웃·색상·페이지 분할을 담당하며, + * `data-no-print`/`data-report-nav`/`data-plan-nav`/`data-cta-card`/`nav` 요소는 + * 인쇄에서 자동 제외됩니다. + */ +import { Download, Loader2 } from 'lucide-react'; +import { useExportPDF } from '@/features/report/hooks/useExportPDF'; + +interface PdfDownloadButtonProps { + /** PDF 파일명. 자동으로 .pdf 확장자 붙음. 'Plan' 포함 시 푸터 라벨도 변경 */ + filename: string; + /** 버튼 표시 라벨. 기본: PDF */ + label?: string; + className?: string; +} + +export function PdfDownloadButton({ + filename, + label = 'PDF', + className, +}: PdfDownloadButtonProps) { + const { exportPDF, isExporting } = useExportPDF(); + + return ( + + ); +} diff --git a/src/features/report/components/ReportBody.tsx b/src/features/report/components/ReportBody.tsx new file mode 100644 index 0000000..677d3a6 --- /dev/null +++ b/src/features/report/components/ReportBody.tsx @@ -0,0 +1,139 @@ +/** + * ReportBody — 리포트 본문(헤더 + 모든 섹션) 순수 렌더링. + * + * 각 섹션은 데이터가 비어있으면 `EmptySection` 으로 fallback 되어 + * "데이터가 없습니다" 안내가 표시됩니다. Guest / User 두 페이지 모두 + * 동일한 동작. + */ +import type { MarketingReport } from '@/features/report/types/report'; +import { SectionErrorBoundary } from '@/features/report/components/ui/SectionErrorBoundary'; +import { EmptySection } from '@/features/report/components/ui/EmptySection'; +import ReportHeader from '@/features/report/components/ReportHeader'; +import ClinicSnapshot from '@/features/report/components/ClinicSnapshot'; +import ChannelOverview from '@/features/report/components/ChannelOverview'; +import YouTubeAudit from '@/features/report/components/YouTubeAudit'; +import InstagramAudit from '@/features/report/components/InstagramAudit'; +import FacebookAudit from '@/features/report/components/FacebookAudit'; +import OtherChannels from '@/features/report/components/OtherChannels'; +import ProblemDiagnosis from '@/features/report/components/ProblemDiagnosis'; +import TransformationProposal from '@/features/report/components/TransformationProposal'; +import RoadmapTimeline from '@/features/report/components/RoadmapTimeline'; +import KPIDashboard from '@/features/report/components/KPIDashboard'; + +interface ReportBodyProps { + data: MarketingReport; +} + +function hasValue(v: T | null | undefined): v is T { + return v != null; +} + +function nonEmpty(arr: T[] | null | undefined): arr is T[] { + return Array.isArray(arr) && arr.length > 0; +} + +export default function ReportBody({ data }: ReportBodyProps) { + // 각 audit에서 핸들 끌어와 헤더의 바로가기 버튼에 전달 + const socialHandles = { + website: data.targetUrl || data.clinicSnapshot.domain || null, + youtube: data.youtubeAudit?.handle || null, + instagram: data.instagramAudit?.accounts?.[0]?.handle || null, + facebook: data.facebookAudit?.pages?.[0]?.url || data.facebookAudit?.pages?.[0]?.pageName || null, + }; + + return ( +
+ + + + {hasValue(data.clinicSnapshot) && data.clinicSnapshot.name ? ( + + ) : ( + + )} + + + + {nonEmpty(data.channelScores) ? ( + + ) : ( + + )} + + + + {hasValue(data.youtubeAudit) && data.youtubeAudit.handle ? ( + + ) : ( + + )} + + + + {hasValue(data.instagramAudit) && data.instagramAudit.handle ? ( + + ) : ( + + )} + + + + {hasValue(data.facebookAudit) && (data.facebookAudit.handle || data.facebookAudit.followers > 0) ? ( + + ) : ( + + )} + + + + {nonEmpty(data.otherChannels) || hasValue(data.websiteAudit) ? ( + + ) : ( + + )} + + + + {nonEmpty(data.problemDiagnosis) ? ( + + ) : ( + + )} + + + + {hasValue(data.transformation) ? ( + + ) : ( + + )} + + + + {nonEmpty(data.roadmap) ? ( + + ) : ( + + )} + + + + {nonEmpty(data.kpiDashboard) ? ( + + ) : ( + + )} + +
+ ); +} diff --git a/src/features/report/components/ReportHeader.tsx b/src/features/report/components/ReportHeader.tsx index ea65783..5a28d71 100644 --- a/src/features/report/components/ReportHeader.tsx +++ b/src/features/report/components/ReportHeader.tsx @@ -1,6 +1,8 @@ import { motion } from 'motion/react'; import { Calendar, Globe, MapPin } from 'lucide-react'; import { ScoreRing } from './ui/ScoreRing'; +import { ChannelLinkButtons, type ChannelHandles } from '@/shared/ui/channel-link-buttons'; +import { PageContainer } from '@/shared/ui/page-container'; function formatDate(raw: string): string { try { @@ -23,6 +25,8 @@ interface ReportHeaderProps { location: string; logoImage?: string; brandColors?: { primary: string; accent: string; text: string }; + /** 등록된 채널 핸들/URL — 외부 새 탭으로 이동 버튼 묶음을 렌더링 */ + socialHandles?: ChannelHandles; } export default function ReportHeader({ @@ -34,6 +38,7 @@ export default function ReportHeader({ date, targetUrl, location, + socialHandles, }: ReportHeaderProps) { return (
@@ -54,7 +59,7 @@ export default function ReportHeader({ transition={{ duration: 6, repeat: Infinity, ease: 'easeInOut' }} /> -
+
{/* Left: Text content */} + + {/* 등록된 채널 바로가기 */} + {socialHandles && ( + + + + )} {/* Right: Score ring */} @@ -149,7 +171,7 @@ export default function ReportHeader({
-
+ ); } diff --git a/src/features/report/components/ReportNav.tsx b/src/features/report/components/ReportNav.tsx index 103daa7..d7a65d4 100644 --- a/src/features/report/components/ReportNav.tsx +++ b/src/features/report/components/ReportNav.tsx @@ -1,11 +1,16 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, type ReactNode } from 'react'; import { Button } from '@/shared/ui/button'; +import { PageContainer } from '@/shared/ui/page-container'; interface ReportNavProps { sections: { id: string; label: string }[]; + /** 좌측 슬롯 — 보통 "워크스페이스로" 같은 뒤로가기 링크 */ + leftSlot?: ReactNode; + /** 우측 슬롯 — 보통 리포트↔플랜 점프 버튼 등 보조 액션 */ + rightSlot?: ReactNode; } -export function ReportNav({ sections }: ReportNavProps) { +export function ReportNav({ sections, leftSlot, rightSlot }: ReportNavProps) { const [activeId, setActiveId] = useState(sections[0]?.id ?? ''); const navRef = useRef(null); const tabRefs = useRef>(new Map()); @@ -54,33 +59,45 @@ export function ReportNav({ sections }: ReportNavProps) { return ( ); } diff --git a/src/features/report/components/YouTubeAudit.tsx b/src/features/report/components/YouTubeAudit.tsx index cd2fe6c..23a1a83 100644 --- a/src/features/report/components/YouTubeAudit.tsx +++ b/src/features/report/components/YouTubeAudit.tsx @@ -1,5 +1,5 @@ import { motion } from 'motion/react'; -import { Youtube, Users, Video, Eye, TrendingUp, ExternalLink } from 'lucide-react'; +import { Youtube, Users, Video, Eye, TrendingUp, ExternalLink, ListVideo } from 'lucide-react'; import { SectionWrapper } from './ui/SectionWrapper'; import { EmptyState } from './ui/EmptyState'; import { MetricCard } from './ui/MetricCard'; @@ -123,8 +123,9 @@ export default function YouTubeAudit({ data }: YouTubeAuditProps) { {data.playlists.map((pl) => ( + {pl} ))} diff --git a/src/features/report/components/ui/EmptySection.tsx b/src/features/report/components/ui/EmptySection.tsx new file mode 100644 index 0000000..8ba76be --- /dev/null +++ b/src/features/report/components/ui/EmptySection.tsx @@ -0,0 +1,21 @@ +/** + * EmptySection — SectionWrapper + EmptyState 조합. + * 섹션 전체가 비었을 때 사용. 서브섹션은 `EmptyState` 단독 사용. + */ +import { SectionWrapper } from './SectionWrapper'; +import { EmptyState } from '@/shared/ui/empty-state'; + +interface EmptySectionProps { + id: string; + title: string; + subtitle?: string; + hint?: string; +} + +export function EmptySection({ id, title, subtitle, hint }: EmptySectionProps) { + return ( + + + + ); +} diff --git a/src/features/report/components/ui/ScoreRing.tsx b/src/features/report/components/ui/ScoreRing.tsx index 7bbc4c0..2636400 100644 --- a/src/features/report/components/ui/ScoreRing.tsx +++ b/src/features/report/components/ui/ScoreRing.tsx @@ -57,8 +57,9 @@ export function ScoreRing({ strokeLinecap="round" strokeDasharray={circumference} initial={{ strokeDashoffset: circumference }} - animate={{ strokeDashoffset }} - transition={{ duration: 1, ease: 'easeOut' }} + whileInView={{ strokeDashoffset }} + viewport={{ once: true, margin: '-50px' }} + transition={{ duration: 1.2, ease: 'easeOut' }} />
diff --git a/src/features/report/components/ui/SectionWrapper.tsx b/src/features/report/components/ui/SectionWrapper.tsx index d124c9d..cd6fbf5 100644 --- a/src/features/report/components/ui/SectionWrapper.tsx +++ b/src/features/report/components/ui/SectionWrapper.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from 'react'; +import { PageContainer } from '@/shared/ui/page-container'; interface SectionWrapperProps { id: string; @@ -32,7 +33,7 @@ export function SectionWrapper({ {dark && (
)} -
+

{children} -

+
); } diff --git a/src/features/report/data/mockReport.ts b/src/features/report/data/mockReport.ts index 111c966..321de7d 100644 --- a/src/features/report/data/mockReport.ts +++ b/src/features/report/data/mockReport.ts @@ -311,12 +311,12 @@ export const mockReport: MarketingReport = { { platform: 'Naver Cafe', url: 'https://cafe.naver.com/bluectcom2', location: 'Footer' }, ], trackingPixels: [ - { name: 'Facebook Pixel', installed: true, details: 'ID: 299151214739571' }, - { name: 'Facebook Domain Verification', installed: true, details: 'lm854gkic9948c6xk2ti76inryqk65' }, - { name: 'Google Site Verification', installed: true, details: 'A8vo9aOWSvGL5-yFKhbtlHPqJCkH-egNdWVqVd9gKac' }, - { name: 'Naver Site Verification', installed: true, details: 'a8cb4fab1fdf7277c0892eeddf457b5c939349e8' }, + { name: 'Facebook Pixel', installed: true }, + { name: 'Facebook Domain Verification', installed: true }, + { name: 'Google Site Verification', installed: true }, + { name: 'Naver Site Verification', installed: true }, { name: 'Kakao Pixel', installed: true }, - { name: 'Google Tag Manager', installed: true, details: 'GTM-52RT6DMK' }, + { name: 'Google Tag Manager', installed: true }, ], mainCTA: '전화 + 카카오톡 상담 + 온라인 예약', }, diff --git a/src/features/report/data/mockReport_banobagi.ts b/src/features/report/data/mockReport_banobagi.ts index 6003f9e..4a5c8c0 100644 --- a/src/features/report/data/mockReport_banobagi.ts +++ b/src/features/report/data/mockReport_banobagi.ts @@ -202,8 +202,8 @@ export const mockReportBanobagi: MarketingReport = { { platform: 'Naver Blog', url: 'https://blog.naver.com/banobagips', location: 'Footer' }, ], trackingPixels: [ - { name: 'Facebook Pixel', installed: true, details: '설치 확인' }, - { name: 'Google Analytics', installed: true, details: 'GA4 추정' }, + { name: 'Facebook Pixel', installed: true }, + { name: 'Google Analytics', installed: true }, { name: 'Naver Site Verification', installed: true }, ], mainCTA: '전화 상담 + 온라인 예약', diff --git a/src/features/report/data/mockReport_grand.ts b/src/features/report/data/mockReport_grand.ts index 2b4a1e7..8d84810 100644 --- a/src/features/report/data/mockReport_grand.ts +++ b/src/features/report/data/mockReport_grand.ts @@ -170,7 +170,7 @@ export const mockReportGrand: MarketingReport = { { platform: 'Naver Blog', url: 'https://blog.naver.com/grandprs', location: 'Footer' }, ], trackingPixels: [ - { name: 'Google Analytics', installed: true, details: 'GA4 추정' }, + { name: 'Google Analytics', installed: true }, { name: 'Naver Site Verification', installed: true }, ], mainCTA: '전화 상담 + 온라인 예약', diff --git a/src/features/report/data/mockReport_irum.ts b/src/features/report/data/mockReport_irum.ts index edf7cf5..360c712 100644 --- a/src/features/report/data/mockReport_irum.ts +++ b/src/features/report/data/mockReport_irum.ts @@ -187,7 +187,7 @@ export const mockReportIrum: MarketingReport = { { platform: 'Naver Blog', url: 'https://blog.naver.com/seoulips', location: 'Footer' }, ], trackingPixels: [ - { name: 'Google Analytics', installed: true, details: 'GA4 추정' }, + { name: 'Google Analytics', installed: true }, ], mainCTA: '전화 상담 + 카카오톡', }, diff --git a/src/features/report/data/mockReport_o2o.ts b/src/features/report/data/mockReport_o2o.ts index e130fd8..c14c643 100644 --- a/src/features/report/data/mockReport_o2o.ts +++ b/src/features/report/data/mockReport_o2o.ts @@ -211,10 +211,10 @@ export const mockReportO2O: MarketingReport = { { platform: 'TikTok', url: 'https://www.tiktok.com/@o2oclinic', location: 'Footer' }, ], trackingPixels: [ - { name: 'Google Analytics 4', installed: true, details: 'GA4 + GTM 정상 운영' }, - { name: 'Meta Pixel', installed: true, details: 'Facebook/Instagram 광고 픽셀 설치 완료' }, - { name: 'Naver Analytics', installed: true, details: '네이버 검색광고 전환 추적' }, - { name: 'TikTok Pixel', installed: false, details: '미설치 — 도입 권장' }, + { name: 'Google Analytics 4', installed: true }, + { name: 'Meta Pixel', installed: true }, + { name: 'Naver Analytics', installed: true }, + { name: 'TikTok Pixel', installed: false }, ], mainCTA: '온라인 상담 예약 + 카카오톡 + WhatsApp (글로벌)', }, diff --git a/src/features/report/data/mockReport_ts.ts b/src/features/report/data/mockReport_ts.ts index 5f172bb..6afe380 100644 --- a/src/features/report/data/mockReport_ts.ts +++ b/src/features/report/data/mockReport_ts.ts @@ -171,7 +171,7 @@ export const mockReportTs: MarketingReport = { { platform: 'Naver Blog', url: 'https://blog.naver.com/tsprs', location: 'Footer' }, ], trackingPixels: [ - { name: 'Google Analytics', installed: true, details: 'GA4 추정' }, + { name: 'Google Analytics', installed: true }, { name: 'Naver Site Verification', installed: true }, ], mainCTA: '전화 상담 + 카카오톡 상담', diff --git a/src/features/report/data/mockReport_wonjin.ts b/src/features/report/data/mockReport_wonjin.ts index 76f5f73..7805852 100644 --- a/src/features/report/data/mockReport_wonjin.ts +++ b/src/features/report/data/mockReport_wonjin.ts @@ -187,8 +187,8 @@ export const mockReportWonjin: MarketingReport = { { platform: 'Naver Blog', url: 'https://blog.naver.com/popokpop', location: 'Footer' }, ], trackingPixels: [ - { name: 'Facebook Pixel', installed: true, details: '설치 확인 (추정)' }, - { name: 'Google Analytics', installed: true, details: 'GA4 추정' }, + { name: 'Facebook Pixel', installed: true }, + { name: 'Google Analytics', installed: true }, ], mainCTA: '전화 상담 + 다국어 온라인 예약', }, diff --git a/src/features/report/hooks/useExportCSV.ts b/src/features/report/hooks/useExportCSV.ts new file mode 100644 index 0000000..aea8843 --- /dev/null +++ b/src/features/report/hooks/useExportCSV.ts @@ -0,0 +1,248 @@ +import { useCallback } from 'react'; +import type { MarketingReport } from '@/features/report/types/report'; + +/** + * 리포트의 표 데이터 전체를 단일 CSV로 내보내는 훅. + * 섹션마다 `=== Section Title ===` 헤더로 구분. + * Excel/Numbers에서 한글이 깨지지 않도록 UTF-8 BOM 포함. + */ + +type Cell = string | number | null | undefined; +type Row = Cell[]; +type Section = { title: string; rows: Row[] }; + +function escapeCell(value: Cell): string { + const s = String(value ?? ''); + if (/[",\n\r]/.test(s)) { + return `"${s.replace(/"/g, '""')}"`; + } + return s; +} + +function buildReportCsv(report: MarketingReport): string { + const sections: Section[] = []; + + // ─── KPI Dashboard ─── + if (report.kpiDashboard?.length) { + sections.push({ + title: 'KPI Dashboard', + rows: [ + ['Metric', 'Current', '3-Month Target', '12-Month Target'], + ...report.kpiDashboard.map((m) => [m.metric, m.current, m.target3Month, m.target12Month]), + ], + }); + } + + // ─── Channel Scores ─── + if (report.channelScores?.length) { + sections.push({ + title: 'Channel Scores', + rows: [ + ['Channel', 'Score', 'Max Score', 'Status', 'Headline'], + ...report.channelScores.map((c) => [c.channel, c.score, c.maxScore, c.status, c.headline]), + ], + }); + } + + // ─── Problem Diagnosis ─── + if (report.problemDiagnosis?.length) { + sections.push({ + title: 'Problem Diagnosis', + rows: [ + ['Category', 'Detail', 'Severity'], + ...report.problemDiagnosis.map((d) => [d.category, d.detail, d.severity]), + ], + }); + } + + // ─── Roadmap (월별 → 태스크 단위로 풀어 펼침) ─── + if (report.roadmap?.length) { + const rows: Row[] = [['Month', 'Title', 'Subtitle', 'Task', 'Completed']]; + report.roadmap.forEach((m) => { + if (m.tasks.length === 0) { + rows.push([m.month, m.title, m.subtitle, '', '']); + return; + } + m.tasks.forEach((t, i) => { + rows.push([ + i === 0 ? m.month : '', + i === 0 ? m.title : '', + i === 0 ? m.subtitle : '', + t.task, + t.completed ? 'Y' : 'N', + ]); + }); + }); + sections.push({ title: 'Roadmap', rows }); + } + + // ─── Transformation ─── + const t = report.transformation; + const asIsToBe = (title: string, items: { area: string; asIs: string; toBe: string }[]) => { + if (!items?.length) return; + sections.push({ + title, + rows: [['Area', 'As-Is', 'To-Be'], ...items.map((i) => [i.area, i.asIs, i.toBe])], + }); + }; + asIsToBe('Transformation: Brand Identity', t?.brandIdentity ?? []); + asIsToBe('Transformation: Content Strategy', t?.contentStrategy ?? []); + asIsToBe('Transformation: Website Improvements', t?.websiteImprovements ?? []); + + if (t?.newChannelProposals?.length) { + sections.push({ + title: 'Transformation: New Channel Proposals', + rows: [ + ['Channel', 'Priority', 'Rationale'], + ...t.newChannelProposals.map((p) => [p.channel, p.priority, p.rationale]), + ], + }); + } + + if (t?.platformStrategies?.length) { + const rows: Row[] = [['Platform', 'Current Metric', 'Target Metric', 'Strategy', 'Detail']]; + t.platformStrategies.forEach((p) => { + if (p.strategies.length === 0) { + rows.push([p.platform, p.currentMetric, p.targetMetric, '', '']); + return; + } + p.strategies.forEach((s, i) => { + rows.push([ + i === 0 ? p.platform : '', + i === 0 ? p.currentMetric : '', + i === 0 ? p.targetMetric : '', + s.strategy, + s.detail, + ]); + }); + }); + sections.push({ title: 'Transformation: Platform Strategies', rows }); + } + + // ─── Channel diagnoses ─── + const diagSection = (title: string, items: { category: string; detail: string; severity: string }[]) => { + if (!items?.length) return; + sections.push({ + title, + rows: [['Category', 'Detail', 'Severity'], ...items.map((d) => [d.category, d.detail, d.severity])], + }); + }; + diagSection('YouTube Diagnosis', report.youtubeAudit?.diagnosis ?? []); + diagSection('Instagram Diagnosis', report.instagramAudit?.diagnosis ?? []); + diagSection('Facebook Diagnosis', report.facebookAudit?.diagnosis ?? []); + + // ─── Facebook Brand Inconsistencies (채널별 값으로 풀어 펼침) ─── + if (report.facebookAudit?.brandInconsistencies?.length) { + const rows: Row[] = [['Field', 'Channel', 'Value', 'Correct', 'Impact', 'Recommendation']]; + report.facebookAudit.brandInconsistencies.forEach((b) => { + b.values.forEach((v, i) => { + rows.push([ + i === 0 ? b.field : '', + v.channel, + v.value, + v.isCorrect ? 'Y' : 'N', + i === 0 ? b.impact : '', + i === 0 ? b.recommendation : '', + ]); + }); + }); + sections.push({ title: 'Facebook Brand Inconsistencies', rows }); + } + + // ─── Instagram Accounts (요약 컬럼만) ─── + if (report.instagramAudit?.accounts?.length) { + sections.push({ + title: 'Instagram Accounts', + rows: [ + ['Handle', 'Language', 'Label', 'Followers', 'Following', 'Posts', 'Reels', 'Category'], + ...report.instagramAudit.accounts.map((a) => [ + a.handle, + a.language, + a.label, + a.followers, + a.following, + a.posts, + a.reelsCount, + a.category, + ]), + ], + }); + } + + // ─── Facebook Pages (요약 컬럼만) ─── + if (report.facebookAudit?.pages?.length) { + sections.push({ + title: 'Facebook Pages', + rows: [ + ['Page Name', 'Language', 'Label', 'Followers', 'Reviews', 'Category', 'URL'], + ...report.facebookAudit.pages.map((p) => [ + p.pageName, + p.language, + p.label, + p.followers, + p.reviews, + p.category, + p.url, + ]), + ], + }); + } + + // ─── Other Channels ─── + if (report.otherChannels?.length) { + sections.push({ + title: 'Other Channels', + rows: [ + ['Name', 'Status', 'Details', 'URL'], + ...report.otherChannels.map((c) => [c.name, c.status, c.details, c.url ?? '']), + ], + }); + } + + // ─── Website Audit (단일 행 요약) ─── + if (report.websiteAudit) { + const w = report.websiteAudit; + sections.push({ + title: 'Website Audit', + rows: [ + ['Primary Domain', 'Main CTA', 'SNS Links On Site'], + [w.primaryDomain, w.mainCTA, w.snsLinksOnSite ? 'Y' : 'N'], + ], + }); + if (w.trackingPixels?.length) { + sections.push({ + title: 'Website: Tracking Pixels', + rows: [['Name', 'Installed'], ...w.trackingPixels.map((p) => [p.name, p.installed ? 'Y' : 'N'])], + }); + } + } + + // ─── 출력 조립: 섹션 사이 공백 행 한 줄 ─── + const lines: string[] = []; + sections.forEach((sec, idx) => { + if (idx > 0) lines.push(''); + lines.push(`=== ${sec.title} ===`); + sec.rows.forEach((row) => lines.push(row.map(escapeCell).join(','))); + }); + + return lines.join('\r\n'); +} + +export function useExportCSV() { + const exportReportCSV = useCallback((filename: string, report: MarketingReport) => { + const csv = '' + buildReportCsv(report); // Excel 한글 깨짐 방지 BOM + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = `${filename}.csv`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + + URL.revokeObjectURL(url); + }, []); + + return { exportReportCSV }; +} diff --git a/src/features/report/hooks/useExportPDF.ts b/src/features/report/hooks/useExportPDF.ts index 6b02828..b072a52 100644 --- a/src/features/report/hooks/useExportPDF.ts +++ b/src/features/report/hooks/useExportPDF.ts @@ -1,284 +1,59 @@ import { useState, useCallback } from 'react'; /** - * PDF Export — 블록 기반 전략. + * Chrome(브라우저) 네이티브 인쇄 다이얼로그로 PDF 저장. * - * 각 섹션은 페이지 간에 분할되지 않는 원자적 서브 블록으로 분리됨. - * framer-motion 애니메이션이 있는 다크 섹션은 별도 처리 필요: - * 모든 섹션을 뷰포트에 스크롤해서 `whileInView`를 트리거한 뒤, - * html2canvas 캡처 전에 인라인 opacity/transform을 최종 상태로 강제 설정. + * 레이아웃·색상·페이지 분할은 `@media print` CSS가 담당 (`src/styles/custom.css`). + * 호출 직전 framer-motion `whileInView` 잔여 요소(opacity:0/transform)를 + * 최종 상태로 강제하여 사용자가 보지 못한 섹션이 빈 페이지로 인쇄되는 것을 방지. */ -function isDarkSection(section: HTMLElement): boolean { - const bg = window.getComputedStyle(section).backgroundColor; - if (!bg || bg === 'rgba(0, 0, 0, 0)' || bg === 'transparent') return false; - // rgb(r, g, b) 파싱 — 휘도가 낮으면 다크로 판단 - const match = bg.match(/(\d+),\s*(\d+),\s*(\d+)/); - if (!match) return section.classList.contains('bg-[#0A1128]'); - const [, r, g, b] = match.map(Number); - return (r * 0.299 + g * 0.587 + b * 0.114) < 50; -} +function forceMotionFinalState(): () => void { + const root = + (document.querySelector('[data-report-content]') as HTMLElement | null) || + (document.querySelector('[data-plan-content]') as HTMLElement | null); + if (!root) return () => {}; -function collectSubBlocks(section: HTMLElement): HTMLElement[] { - // 다크 섹션은 통째로 캡처 — 서브 블록으로 나누면 다크 배경이 사라짐 - if (isDarkSection(section)) return [section]; + const saved: { el: HTMLElement; opacity: string; transform: string }[] = []; - if (section.offsetHeight <= 900) return [section]; - - const children = Array.from(section.children) as HTMLElement[]; - if (children.length <= 1) return [section]; - - const blocks: HTMLElement[] = []; - for (const child of children) { - if (child.offsetHeight === 0 || child.offsetWidth === 0) continue; - blocks.push(child); - } - return blocks.length > 0 ? blocks : [section]; -} - -/** - * 모든 섹션을 스크롤하여 `whileInView` 애니메이션을 트리거한 뒤, - * 안정화될 때까지 대기. 스냅샷 캡처 전에 framer-motion이 최종 - * 인라인 스타일을 적용하도록 보장. - */ -async function triggerAllAnimations(contentEl: HTMLElement): Promise { - const sections = Array.from(contentEl.children) as HTMLElement[]; - - for (const section of sections) { - section.scrollIntoView({ behavior: 'instant' as ScrollBehavior }); - // framer-motion이 intersection을 감지하고 스타일을 적용할 시간 부여 - await new Promise((r) => setTimeout(r, 80)); - - // 중첩된 whileInView를 위해 서브 자식 요소도 뷰포트로 스크롤 - const motionChildren = section.querySelectorAll('[style]'); - for (let i = 0; i < Math.min(motionChildren.length, 20); i++) { - (motionChildren[i] as HTMLElement).scrollIntoView({ behavior: 'instant' as ScrollBehavior }); - await new Promise((r) => setTimeout(r, 30)); - } - } - - // 최상단으로 스크롤하고 모두 안정화될 때까지 대기 - window.scrollTo(0, 0); - await new Promise((r) => setTimeout(r, 200)); -} - -/** - * 모든 요소를 완전히 보이도록 강제 — opacity 1, transform 없음. - * whileInView가 도달하지 못한 잔여 요소를 처리. - */ -function forceVisible(contentEl: HTMLElement): (() => void) { - document.documentElement.style.setProperty('--motion-duration', '0s'); - - const saved: { el: HTMLElement; cssText: string }[] = []; - - contentEl.querySelectorAll('*').forEach((node) => { - const el = node as HTMLElement; + root.querySelectorAll('*').forEach((el) => { const s = el.style; + const opacityOff = s.opacity !== '' && parseFloat(s.opacity) < 1; + const transformOn = s.transform !== '' && s.transform !== 'none'; + if (!opacityOff && !transformOn) return; - // 인라인과 computed 모두 확인 - const needsFix = - (s.opacity !== '' && s.opacity !== '1') || - (s.transform !== '' && s.transform !== 'none') || - s.visibility === 'hidden'; - - if (needsFix) { - saved.push({ el, cssText: s.cssText }); - s.opacity = '1'; - s.transform = 'none'; - s.visibility = 'visible'; - } - }); - - // 2차 패스: computed opacity < 1 처리 (motion이 클래스로 설정했을 수도) - contentEl.querySelectorAll('*').forEach((node) => { - const el = node as HTMLElement; - const computed = window.getComputedStyle(el); - if (parseFloat(computed.opacity) < 0.99) { - if (!saved.find((s) => s.el === el)) { - saved.push({ el, cssText: el.style.cssText }); - } - el.style.opacity = '1'; - el.style.transform = 'none'; - } + saved.push({ el, opacity: s.opacity, transform: s.transform }); + if (opacityOff) s.opacity = '1'; + if (transformOn) s.transform = 'none'; }); return () => { - saved.forEach(({ el, cssText }) => { - el.style.cssText = cssText; + saved.forEach(({ el, opacity, transform }) => { + el.style.opacity = opacity; + el.style.transform = transform; }); - document.documentElement.style.removeProperty('--motion-duration'); }; } -/** - * Export용 CSS 오버라이드: overflow 해제, 클리핑 방지. - */ -function addExportCSS(): (() => void) { - const style = document.createElement('style'); - style.id = 'pdf-export-overrides'; - style.textContent = ` - [data-report-content] .overflow-x-auto, - [data-plan-content] .overflow-x-auto { - overflow: visible !important; - flex-wrap: wrap !important; - } - [data-report-content] .scrollbar-thin, - [data-plan-content] .scrollbar-thin { - overflow: visible !important; - } - [data-report-content] .shrink-0, - [data-plan-content] .shrink-0 { - flex-shrink: 1 !important; - min-width: 0 !important; - } - `; - document.head.appendChild(style); - return () => style.remove(); -} - export function useExportPDF() { const [isExporting, setIsExporting] = useState(false); - const exportPDF = useCallback(async (filename = 'INFINITH_Marketing_Intelligence_Report') => { + const exportPDF = useCallback((filename = 'INFINITH_Marketing_Intelligence_Report') => { setIsExporting(true); - try { - const [{ default: html2canvas }, { jsPDF }] = await Promise.all([ - import('html2canvas-pro'), - import('jspdf'), - ]); + const originalTitle = document.title; + document.title = filename; + const restoreMotion = forceMotionFinalState(); - const contentEl = - (document.querySelector('[data-report-content]') as HTMLElement) || - (document.querySelector('[data-plan-content]') as HTMLElement); - if (!contentEl) throw new Error('Report content element not found'); - - // Step 1: whileInView 애니메이션 트리거를 위해 모든 섹션 스크롤 - await triggerAllAnimations(contentEl); - - // Step 2: 모든 요소를 강제로 visible 처리 (잔여 요소 처리) - const restoreVisible = forceVisible(contentEl); - - // Step 3: CSS 오버라이드 - const restoreCSS = addExportCSS(); - - // Step 4: UI 요소 숨기기 - const hideSelectors = [ - '[data-report-nav]', - '[data-plan-nav]', - 'nav', - '[data-cta-card]', - '[data-no-print]', - ]; - const hiddenEls: { el: HTMLElement; display: string }[] = []; - hideSelectors.forEach((sel) => { - document.querySelectorAll(sel).forEach((el) => { - const htmlEl = el as HTMLElement; - hiddenEls.push({ el: htmlEl, display: htmlEl.style.display }); - htmlEl.style.display = 'none'; - }); - }); - - await new Promise((r) => setTimeout(r, 150)); - - // Step 5: PDF 생성 - const pdf = new jsPDF('p', 'mm', 'a4'); - const pageWidth = 210; - const pageHeight = 297; - const margin = 8; - const usableWidth = pageWidth - margin * 2; - const footerSpace = 10; - const maxContentY = pageHeight - margin - footerSpace; - let currentY = margin; - - const sections = Array.from(contentEl.children) as HTMLElement[]; - - for (const section of sections) { - if (section.offsetHeight === 0 || section.offsetWidth === 0) continue; - if (window.getComputedStyle(section).display === 'none') continue; - - const blocks = collectSubBlocks(section); - - for (const block of blocks) { - if (block.offsetHeight === 0) continue; - - const canvas = await html2canvas(block, { - scale: 2, - useCORS: true, - logging: false, - backgroundColor: null, - windowWidth: 1280, - removeContainer: true, - }); - - const blockHeightMM = (canvas.height * usableWidth) / canvas.width; - - // 블록이 안 들어가면 새 페이지 - if (currentY + blockHeightMM > maxContentY && currentY > margin + 5) { - pdf.addPage(); - currentY = margin; - } - - // 긴 블록: 페이지 단위로 슬라이싱 - if (blockHeightMM > maxContentY - margin) { - const pxPerMM = canvas.width / usableWidth; - let srcY = 0; - let remainPx = canvas.height; - let isFirst = true; - - while (remainPx > 0) { - if (!isFirst) { pdf.addPage(); currentY = margin; } - isFirst = false; - - const availPx = (maxContentY - currentY) * pxPerMM; - const sliceH = Math.min(remainPx, availPx); - - const sliceCanvas = document.createElement('canvas'); - sliceCanvas.width = canvas.width; - sliceCanvas.height = Math.ceil(sliceH); - const ctx = sliceCanvas.getContext('2d'); - if (ctx) { - ctx.drawImage(canvas, 0, srcY, canvas.width, Math.ceil(sliceH), 0, 0, canvas.width, Math.ceil(sliceH)); - } - - const sliceMM = sliceH / pxPerMM; - pdf.addImage(sliceCanvas.toDataURL('image/jpeg', 0.9), 'JPEG', margin, currentY, usableWidth, sliceMM); - currentY += sliceMM; - srcY += sliceH; - remainPx -= sliceH; - } - } else { - pdf.addImage(canvas.toDataURL('image/jpeg', 0.9), 'JPEG', margin, currentY, usableWidth, blockHeightMM); - currentY += blockHeightMM; - } - } - - currentY += 2; // 섹션 간 여백 - } - - // Step 6: 복원 - hiddenEls.forEach(({ el, display }) => { el.style.display = display; }); - restoreCSS(); - restoreVisible(); - - // Step 7: 푸터 - const totalPages = pdf.getNumberOfPages(); - const footerLabel = filename.includes('Plan') - ? 'INFINITH Marketing Plan' - : 'INFINITH Marketing Intelligence Report'; - for (let i = 1; i <= totalPages; i++) { - pdf.setPage(i); - pdf.setFontSize(7); - pdf.setTextColor(180); - pdf.text(`${footerLabel} | Page ${i} / ${totalPages}`, pageWidth / 2, pageHeight - 5, { align: 'center' }); - } - - pdf.save(`${filename}.pdf`); - } catch (err) { - console.error('PDF export failed:', err); - } finally { + const cleanup = () => { + restoreMotion(); + document.title = originalTitle; setIsExporting(false); - } + window.removeEventListener('afterprint', cleanup); + }; + window.addEventListener('afterprint', cleanup); + + window.print(); }, []); return { exportPDF, isExporting }; diff --git a/src/features/report/hooks/useReportPageData.ts b/src/features/report/hooks/useReportPageData.ts new file mode 100644 index 0000000..50ee8d7 --- /dev/null +++ b/src/features/report/hooks/useReportPageData.ts @@ -0,0 +1,66 @@ +/** + * useReportPageData — Guest / User 리포트 페이지가 공통으로 쓰는 데이터 훅. + * + * 단순히 기존 `useReport` + `useEnrichment` 조합을 한 곳에 묶어 + * 페이지 wrapper 두 곳에서 동일한 코드를 반복하지 않도록 합니다. + */ +import { useMemo } from 'react'; +import { useLocation } from 'react-router'; +import type { MarketingReport } from '@/features/report/types/report'; +import { useReport } from '@/features/report/hooks/useReport'; +import { useEnrichment } from '@/features/channels/hooks/useEnrichment'; + +interface UseReportPageDataResult { + data: MarketingReport | null; + isLoading: boolean; + error: string | null; + enrichStatus: ReturnType['status']; +} + +export function useReportPageData(id: string | undefined): UseReportPageDataResult { + const location = useLocation(); + const { + data: baseData, + isLoading, + error, + isEnriched, + socialHandles: dbSocialHandles, + } = useReport(id); + + const enrichmentParams = useMemo(() => { + if (!baseData || isEnriched) return null; + + const state = location.state as Record | undefined; + const metadata = state?.metadata as Record | undefined; + const stateSocialHandles = metadata?.socialHandles as Record | undefined; + + const handles = stateSocialHandles || dbSocialHandles; + + const igHandles: string[] = Array.isArray(handles?.instagram) + ? (handles.instagram.filter(Boolean) as string[]) + : handles?.instagram + ? [handles.instagram as string] + : []; + + const ytHandle = handles?.youtube || baseData.youtubeAudit?.handle || undefined; + const fbHandle = handles?.facebook || undefined; + + return { + reportId: baseData.id, + clinicName: baseData.clinicSnapshot.name, + instagramHandles: igHandles.length > 0 ? igHandles : undefined, + youtubeChannelId: ytHandle || undefined, + facebookHandle: fbHandle as string | undefined, + address: baseData.clinicSnapshot.location || undefined, + }; + }, [baseData, isEnriched, dbSocialHandles, location.state]); + + const { status: enrichStatus, enrichedReport } = useEnrichment(baseData, enrichmentParams); + + return { + data: enrichedReport || baseData, + isLoading, + error, + enrichStatus, + }; +} diff --git a/src/features/report/pages/GuestReportPage.tsx b/src/features/report/pages/GuestReportPage.tsx new file mode 100644 index 0000000..8cc84cd --- /dev/null +++ b/src/features/report/pages/GuestReportPage.tsx @@ -0,0 +1,104 @@ +/** + * GuestReportPage — `/report/:id` + * + * 손님(랜딩에서 분석을 실행한 비계약 방문자)이 보는 리포트 화면. + * 본문은 UserReportPage 와 동일하나, 하단에 도입 문의 CTA가 추가되고 + * 워크스페이스용 액션바는 노출되지 않습니다. + */ +import { Link, useParams } from 'react-router'; +import { ArrowRight, Sparkles } from 'lucide-react'; +import { useReportPageData } from '../hooks/useReportPageData'; +import { ReportNav } from '../components/ReportNav'; +import { ScreenshotProvider } from '../stores/ScreenshotContext'; +import { REPORT_SECTIONS } from '@/shared/constants/reportSections'; +import { buildContactMailto } from '@/shared/lib/contact'; +import ReportBody from '../components/ReportBody'; +import { DownloadMenuButton } from '../components/DownloadMenuButton'; + +export default function GuestReportPage() { + const { id } = useParams<{ id: string }>(); + const { data, isLoading, error, enrichStatus } = useReportPageData(id); + + if (isLoading) { + return ( +
+
+
+

리포트를 불러오는 중...

+
+
+ ); + } + + if (error || !data) { + return ( +
+
+

오류가 발생했습니다

+

{error ?? '리포트를 찾을 수 없습니다.'}

+
+
+ ); + } + + return ( + +
+ + + + + 마케팅 기획 보기 + + +
+ } + /> + + {enrichStatus === 'loading' && ( +
+
+ 채널 데이터 보강 중... +
+ )} + + + + {/* Guest 전용 — 도입 문의 CTA */} +
+
+
+
+

+ INFINITH 도입 안내 +

+

+ 이 리포트의 다음 단계 — 마케팅 기획 +

+

+ 계약 병원 전용 워크스페이스에서 본 분석을 기반으로 콘텐츠 캘린더, + 자산 관리, 워크플로우 추적까지 한 곳에서 운영할 수 있습니다. +

+ + 도입 문의하기 + + +
+
+
+
+
+ ); +} diff --git a/src/features/report/pages/ReportPage.tsx b/src/features/report/pages/ReportPage.tsx deleted file mode 100644 index dc5f694..0000000 --- a/src/features/report/pages/ReportPage.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { useMemo } from 'react'; -import { useParams, useLocation } from 'react-router'; -import { useReport } from '../hooks/useReport'; -import { useEnrichment } from '@/features/channels/hooks/useEnrichment'; -import { ReportNav } from '@/features/report/components/ReportNav'; -import { ScreenshotProvider } from '@/features/report/stores/ScreenshotContext'; -import { REPORT_SECTIONS } from '@/shared/constants/reportSections'; - -import { SectionErrorBoundary } from '@/features/report/components/ui/SectionErrorBoundary'; -import ReportHeader from '@/features/report/components/ReportHeader'; -import ClinicSnapshot from '@/features/report/components/ClinicSnapshot'; -import ChannelOverview from '@/features/report/components/ChannelOverview'; -import YouTubeAudit from '@/features/report/components/YouTubeAudit'; -import InstagramAudit from '@/features/report/components/InstagramAudit'; -import FacebookAudit from '@/features/report/components/FacebookAudit'; -import OtherChannels from '@/features/report/components/OtherChannels'; -import ProblemDiagnosis from '@/features/report/components/ProblemDiagnosis'; -import TransformationProposal from '@/features/report/components/TransformationProposal'; -import RoadmapTimeline from '@/features/report/components/RoadmapTimeline'; -import KPIDashboard from '@/features/report/components/KPIDashboard'; - -export default function ReportPage() { - const { id } = useParams<{ id: string }>(); - const location = useLocation(); - const { - data: baseData, - isLoading, - error, - isEnriched, - socialHandles: dbSocialHandles, - } = useReport(id); - - // 보강 파라미터 생성 — 이미 보강된 경우(DB 데이터) 스킵 - const enrichmentParams = useMemo(() => { - if (!baseData || isEnriched) return null; - - // 우선순위: location.state socialHandles > DB socialHandles > 변환된 데이터 - const state = location.state as Record | undefined; - const metadata = state?.metadata as Record | undefined; - const stateSocialHandles = metadata?.socialHandles as Record | undefined; - - const handles = stateSocialHandles || dbSocialHandles; - - // Instagram: 다중 계정(배열) 또는 단일 핸들 모두 지원 - const igHandles: string[] = Array.isArray(handles?.instagram) - ? handles.instagram.filter(Boolean) as string[] - : handles?.instagram ? [handles.instagram as string] : []; - - const ytHandle = - handles?.youtube || - baseData.youtubeAudit?.handle || - undefined; - - const fbHandle = handles?.facebook || undefined; - - return { - reportId: baseData.id, - clinicName: baseData.clinicSnapshot.name, - instagramHandles: igHandles.length > 0 ? igHandles : undefined, - youtubeChannelId: ytHandle || undefined, - facebookHandle: fbHandle as string | undefined, - address: baseData.clinicSnapshot.location || undefined, - }; - }, [baseData, isEnriched, dbSocialHandles, location.state]); - - const { status: enrichStatus, enrichedReport } = useEnrichment(baseData, enrichmentParams); - - // 보강 데이터가 있으면 사용, 없으면 베이스 데이터 사용 - const data = enrichedReport || baseData; - - if (isLoading) { - return ( -
-
-
-

리포트를 불러오는 중...

-
-
- ); - } - - if (error || !data) { - return ( -
-
-

오류가 발생했습니다

-

{error ?? '리포트를 찾을 수 없습니다.'}

-
-
- ); - } - - return ( - -
- - - {/* Enrichment status indicator */} - {enrichStatus === 'loading' && ( -
-
- 채널 데이터 보강 중... -
- )} - -
- - - - - - - - - - - - - - - - - - - - - -
-
- - ); -} diff --git a/src/features/report/pages/UserReportPage.tsx b/src/features/report/pages/UserReportPage.tsx new file mode 100644 index 0000000..4cf46b6 --- /dev/null +++ b/src/features/report/pages/UserReportPage.tsx @@ -0,0 +1,93 @@ +/** + * UserReportPage — `/clinics/:clinicId/report/:id` + * + * 계약된 병원 유저가 워크스페이스 안에서 보는 리포트 화면. + * 본문은 GuestReportPage 와 동일하지만: + * - 상단에 워크스페이스 액션바 (워크스페이스로 돌아가기, 플랜 생성, 다시 분석) + * - 하단의 도입 문의 CTA 없음 + */ +import { Link, useParams } from 'react-router'; +import { ArrowLeft, Sparkles, RefreshCw } from 'lucide-react'; +import { useReportPageData } from '../hooks/useReportPageData'; +import { ReportNav } from '../components/ReportNav'; +import { ScreenshotProvider } from '../stores/ScreenshotContext'; +import { REPORT_SECTIONS } from '@/shared/constants/reportSections'; +import ReportBody from '../components/ReportBody'; +import { DownloadMenuButton } from '../components/DownloadMenuButton'; + +export default function UserReportPage() { + const { clinicId, id } = useParams<{ clinicId: string; id: string }>(); + const { data, isLoading, error, enrichStatus } = useReportPageData(id); + + if (isLoading) { + return ( +
+
+
+

리포트를 불러오는 중...

+
+
+ ); + } + + if (error || !data) { + return ( +
+
+

오류가 발생했습니다

+

{error ?? '리포트를 찾을 수 없습니다.'}

+
+
+ ); + } + + return ( + +
+ + + 워크스페이스로 + + } + rightSlot={ +
+ + + + 다시 분석 + + + + 마케팅 기획 보기 + +
+ } + /> + + {enrichStatus === 'loading' && ( +
+
+ 채널 데이터 보강 중... +
+ )} + + +
+ + ); +} diff --git a/src/features/report/routes.tsx b/src/features/report/routes.tsx index ef27dff..2ada30e 100644 --- a/src/features/report/routes.tsx +++ b/src/features/report/routes.tsx @@ -1,11 +1,12 @@ import { lazy } from 'react' import type { RouteObject } from 'react-router' -const ReportPage = lazy(() => import('./pages/ReportPage')) +const GuestReportPage = lazy(() => import('./pages/GuestReportPage')) const AnalysisLoadingPage = lazy(() => import('./pages/AnalysisLoadingPage')) export const reportRoutes: RouteObject[] = [ { path: 'report/loading', element: }, { path: 'report/loading/:reportId', element: }, - { path: 'report/:id', element: }, + // 손님(랜딩→분석) 흐름. 유저 워크스페이스 경로는 features/clinics/routes.tsx 참조. + { path: 'report/:id', element: }, ] diff --git a/src/features/report/types/report.ts b/src/features/report/types/report.ts index bd2868f..6843bb7 100644 --- a/src/features/report/types/report.ts +++ b/src/features/report/types/report.ts @@ -166,7 +166,6 @@ export interface OtherChannel { export interface TrackingPixel { name: string; installed: boolean; - details?: string; } export interface WebsiteAudit { diff --git a/src/features/studio/components/StudioWizard.tsx b/src/features/studio/components/StudioWizard.tsx index 91f578c..eb353bf 100644 --- a/src/features/studio/components/StudioWizard.tsx +++ b/src/features/studio/components/StudioWizard.tsx @@ -9,6 +9,7 @@ import SoundStudioStep from './SoundStudioStep'; import GeneratePreviewStep from './GeneratePreviewStep'; import BlogEditorStep from './BlogEditorStep'; import { Button } from '@/shared/ui/button'; +import { PageContainer } from '@/shared/ui/page-container'; interface StepDef { key: string; @@ -116,7 +117,7 @@ export default function StudioWizard() { return (
-
+ {/* Progress Bar */}
{steps.map((s, i) => ( @@ -231,7 +232,7 @@ export default function StudioWizard() {
)}
-
+
); } diff --git a/src/shared/hooks/useInfiniteList.ts b/src/shared/hooks/useInfiniteList.ts new file mode 100644 index 0000000..249b0a8 --- /dev/null +++ b/src/shared/hooks/useInfiniteList.ts @@ -0,0 +1,73 @@ +/** + * useInfiniteList — 서버가 한 번에 다 던진 배열을 클라이언트에서 + * 페이지 단위로 잘라 점진적으로 노출하는 훅. + * + * IntersectionObserver 로 sentinel(마지막 아이템 하단 또는 별도 div)이 + * 뷰포트에 들어올 때 다음 페이지를 노출. + */ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +interface UseInfiniteListOptions { + /** 초기 노출 개수 (기본 10) */ + initialSize?: number; + /** 한 번에 추가 노출할 개수 (기본 10) */ + pageSize?: number; + /** sentinel 트리거 거리 (px). 음수면 미리 트리거. 기본 -100 */ + rootMargin?: string; +} + +interface UseInfiniteListResult { + visible: T[]; + hasMore: boolean; + sentinelRef: (node: Element | null) => void; + reset: () => void; +} + +export function useInfiniteList( + items: T[], + { initialSize = 10, pageSize = 10, rootMargin = '200px' }: UseInfiniteListOptions = {}, +): UseInfiniteListResult { + const [count, setCount] = useState(initialSize); + const observerRef = useRef(null); + + // 입력 배열이 바뀌면 초기 size 로 리셋 + useEffect(() => { + setCount(initialSize); + }, [items, initialSize]); + + const visible = useMemo(() => items.slice(0, count), [items, count]); + const hasMore = count < items.length; + + const sentinelRef = useCallback( + (node: Element | null) => { + if (observerRef.current) { + observerRef.current.disconnect(); + observerRef.current = null; + } + if (!node || !hasMore) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) { + setCount((prev) => Math.min(prev + pageSize, items.length)); + } + }, + { rootMargin }, + ); + observer.observe(node); + observerRef.current = observer; + }, + [hasMore, items.length, pageSize, rootMargin], + ); + + const reset = useCallback(() => setCount(initialSize), [initialSize]); + + // unmount 정리 + useEffect(() => { + return () => { + observerRef.current?.disconnect(); + }; + }, []); + + return { visible, hasMore, sentinelRef, reset }; +} diff --git a/src/shared/icons/FilledIcons.tsx b/src/shared/icons/FilledIcons.tsx index d4bfc1d..a4ec5fc 100644 --- a/src/shared/icons/FilledIcons.tsx +++ b/src/shared/icons/FilledIcons.tsx @@ -3,23 +3,26 @@ * 부드러운 파스텔 컬러, 외곽선 없음 — 모든 도형은 fill 만 사용. */ +import type { CSSProperties } from 'react'; + interface IconProps { size?: number; className?: string; + style?: CSSProperties; } -export function YoutubeFilled({ size = 20, className = '' }: IconProps) { +export function YoutubeFilled({ size = 20, className = '', style }: IconProps) { return ( - + ); } -export function InstagramFilled({ size = 20, className = '' }: IconProps) { +export function InstagramFilled({ size = 20, className = '', style }: IconProps) { return ( - + @@ -27,18 +30,18 @@ export function InstagramFilled({ size = 20, className = '' }: IconProps) { ); } -export function FacebookFilled({ size = 20, className = '' }: IconProps) { +export function FacebookFilled({ size = 20, className = '', style }: IconProps) { return ( - + ); } -export function GlobeFilled({ size = 20, className = '' }: IconProps) { +export function GlobeFilled({ size = 20, className = '', style }: IconProps) { return ( - + @@ -47,9 +50,9 @@ export function GlobeFilled({ size = 20, className = '' }: IconProps) { ); } -export function VideoFilled({ size = 20, className = '' }: IconProps) { +export function VideoFilled({ size = 20, className = '', style }: IconProps) { return ( - + @@ -57,18 +60,18 @@ export function VideoFilled({ size = 20, className = '' }: IconProps) { ); } -export function MessageFilled({ size = 20, className = '' }: IconProps) { +export function MessageFilled({ size = 20, className = '', style }: IconProps) { return ( - + ); } -export function CalendarFilled({ size = 20, className = '' }: IconProps) { +export function CalendarFilled({ size = 20, className = '', style }: IconProps) { return ( - + @@ -80,9 +83,9 @@ export function CalendarFilled({ size = 20, className = '' }: IconProps) { ); } -export function FileTextFilled({ size = 20, className = '' }: IconProps) { +export function FileTextFilled({ size = 20, className = '', style }: IconProps) { return ( - + @@ -91,9 +94,9 @@ export function FileTextFilled({ size = 20, className = '' }: IconProps) { ); } -export function ShareFilled({ size = 20, className = '' }: IconProps) { +export function ShareFilled({ size = 20, className = '', style }: IconProps) { return ( - + @@ -103,27 +106,27 @@ export function ShareFilled({ size = 20, className = '' }: IconProps) { ); } -export function MegaphoneFilled({ size = 20, className = '' }: IconProps) { +export function MegaphoneFilled({ size = 20, className = '', style }: IconProps) { return ( - + ); } -export function TiktokFilled({ size = 20, className = '' }: IconProps) { +export function TiktokFilled({ size = 20, className = '', style }: IconProps) { return ( - + ); } -export function MusicFilled({ size = 20, className = '' }: IconProps) { +export function MusicFilled({ size = 20, className = '', style }: IconProps) { return ( - + @@ -136,9 +139,9 @@ export function MusicFilled({ size = 20, className = '' }: IconProps) { /* ─── Dashboard / Utility Icons ─── */ -export function ShieldFilled({ size = 20, className = '' }: IconProps) { +export function ShieldFilled({ size = 20, className = '', style }: IconProps) { return ( - + @@ -146,9 +149,9 @@ export function ShieldFilled({ size = 20, className = '' }: IconProps) { ); } -export function DatabaseFilled({ size = 20, className = '' }: IconProps) { +export function DatabaseFilled({ size = 20, className = '', style }: IconProps) { return ( - + @@ -157,9 +160,9 @@ export function DatabaseFilled({ size = 20, className = '' }: IconProps) { ); } -export function ServerFilled({ size = 20, className = '' }: IconProps) { +export function ServerFilled({ size = 20, className = '', style }: IconProps) { return ( - + @@ -170,27 +173,27 @@ export function ServerFilled({ size = 20, className = '' }: IconProps) { ); } -export function BoltFilled({ size = 20, className = '' }: IconProps) { +export function BoltFilled({ size = 20, className = '', style }: IconProps) { return ( - + ); } -export function EyeFilled({ size = 20, className = '' }: IconProps) { +export function EyeFilled({ size = 20, className = '', style }: IconProps) { return ( - + ); } -export function EyeOffFilled({ size = 20, className = '' }: IconProps) { +export function EyeOffFilled({ size = 20, className = '', style }: IconProps) { return ( - + @@ -198,36 +201,36 @@ export function EyeOffFilled({ size = 20, className = '' }: IconProps) { ); } -export function CopyFilled({ size = 20, className = '' }: IconProps) { +export function CopyFilled({ size = 20, className = '', style }: IconProps) { return ( - + ); } -export function CheckFilled({ size = 20, className = '' }: IconProps) { +export function CheckFilled({ size = 20, className = '', style }: IconProps) { return ( - + ); } -export function CrossFilled({ size = 20, className = '' }: IconProps) { +export function CrossFilled({ size = 20, className = '', style }: IconProps) { return ( - + ); } -export function WarningFilled({ size = 20, className = '' }: IconProps) { +export function WarningFilled({ size = 20, className = '', style }: IconProps) { return ( - + @@ -235,18 +238,18 @@ export function WarningFilled({ size = 20, className = '' }: IconProps) { ); } -export function RefreshFilled({ size = 20, className = '' }: IconProps) { +export function RefreshFilled({ size = 20, className = '', style }: IconProps) { return ( - + ); } -export function FlowFilled({ size = 20, className = '' }: IconProps) { +export function FlowFilled({ size = 20, className = '', style }: IconProps) { return ( - + @@ -256,9 +259,9 @@ export function FlowFilled({ size = 20, className = '' }: IconProps) { ); } -export function CoinFilled({ size = 20, className = '' }: IconProps) { +export function CoinFilled({ size = 20, className = '', style }: IconProps) { return ( - + $ @@ -266,9 +269,9 @@ export function CoinFilled({ size = 20, className = '' }: IconProps) { ); } -export function LinkExternalFilled({ size = 20, className = '' }: IconProps) { +export function LinkExternalFilled({ size = 20, className = '', style }: IconProps) { return ( - + @@ -281,18 +284,18 @@ export function LinkExternalFilled({ size = 20, className = '' }: IconProps) { * HubSpot 스타일 인피니티 루프 + 그라데이션 셰이딩. * 가로 비율, 텍스트 cap-height 에 맞춰 스케일링. */ -export function DownloadFilled({ size = 20, className = '' }: IconProps) { +export function DownloadFilled({ size = 20, className = '', style }: IconProps) { return ( - + ); } -export function RocketFilled({ size = 20, className = '' }: IconProps) { +export function RocketFilled({ size = 20, className = '', style }: IconProps) { return ( - + @@ -301,12 +304,12 @@ export function RocketFilled({ size = 20, className = '' }: IconProps) { ); } -export function PrismFilled({ size = 20, className = '' }: IconProps) { +export function PrismFilled({ size = 20, className = '', style }: IconProps) { const w = Math.round(size * 1.6); const h = size; const id = `inf-grad-${size}`; return ( - + diff --git a/src/shared/layouts/Footer.tsx b/src/shared/layouts/Footer.tsx index bb178be..821cc0f 100644 --- a/src/shared/layouts/Footer.tsx +++ b/src/shared/layouts/Footer.tsx @@ -1,21 +1,32 @@ import { Link } from 'react-router'; +import { PageContainer } from '@/shared/ui/page-container'; export default function Footer() { return (
-
- - INFINITH - -
- © {new Date().getFullYear()} INFINITH. All rights reserved.
- Infinite Marketing for Premium Medical Business & Marketing Agency + +
+ + INFINITH + +
-
- Privacy Policy - Terms of Service +
+

㈜에이아이오투오

+

사업자 등록번호 : 620-87-00810 | 대표 : 안성민

+

본사 : 41593 대구광역시 북구 옥산로 111, 5층 유니콘랩 대구 A05호

+

연구소 : 13453 경기 성남시 수정구 금토로 32 (금토동) (주)KT 판교빌딩 504호~505호 (East)

+

전화 : 070-4260-8310 | 010-2755-6463

+

이메일 : o2oteam@o2o.kr

-
+
+ Copyright ⓒ O2O Inc. All rights reserved +
+
); } diff --git a/src/shared/layouts/Layout.tsx b/src/shared/layouts/Layout.tsx index d1f04ce..e1a6b1c 100644 --- a/src/shared/layouts/Layout.tsx +++ b/src/shared/layouts/Layout.tsx @@ -1,4 +1,4 @@ -import { Outlet, useLocation } from 'react-router' +import { Outlet, ScrollRestoration, useLocation } from 'react-router' import Navbar from './Navbar' import Footer from './Footer' @@ -6,11 +6,18 @@ export function Layout() { const location = useLocation() const isLoadingPage = location.pathname.startsWith('/report/loading') + // react-router v7 빌트인 ScrollRestoration: + // - 새 라우트 진입 → top 스크롤 + // - 뒤로/앞으로 → 이전 스크롤 위치 자동 복원 + // - 해시 앵커가 있으면 그곳으로 스크롤 return ( -
+
{!isLoadingPage && } - +
+ +
{!isLoadingPage &&
} +
) } diff --git a/src/shared/layouts/Navbar.tsx b/src/shared/layouts/Navbar.tsx index 6cd9cc1..6512fc9 100644 --- a/src/shared/layouts/Navbar.tsx +++ b/src/shared/layouts/Navbar.tsx @@ -21,11 +21,12 @@ import { Link } from 'react-router'; import { ArrowRight } from 'lucide-react'; import { buildContactMailto } from '../lib/contact'; +import { PageContainer } from '@/shared/ui/page-container'; export default function Navbar() { return ( ); } diff --git a/src/shared/ui/channel-link-buttons.tsx b/src/shared/ui/channel-link-buttons.tsx new file mode 100644 index 0000000..97e1b5b --- /dev/null +++ b/src/shared/ui/channel-link-buttons.tsx @@ -0,0 +1,160 @@ +/** + * ChannelLinkButtons — 등록된 플랫폼 URL/핸들로 새 탭 열기 버튼 묶음. + * + * Report / Plan 헤더 영역에서 공통 사용. + * - 입력은 normalized handle(@view_clinic) 또는 URL(view.co.kr) 모두 허용 + * - 비어있는 플랫폼은 자동 생략 + * - 디자인: white/60 backdrop-blur + 호버 시 더 진해짐 (ReportHeader 의 메타 칩과 동일 톤) + */ +import { ExternalLink, Heart, MapPin } from 'lucide-react'; +import type { CSSProperties } from 'react'; +import { + InstagramFilled, + YoutubeFilled, + FacebookFilled, + GlobeFilled, + TiktokFilled, + FileTextFilled, +} from '@/shared/icons/FilledIcons'; + +export interface ChannelHandles { + website?: string | null; + instagram?: string | null; + youtube?: string | null; + facebook?: string | null; + tiktok?: string | null; + gangnamUnni?: string | null; + naverPlace?: string | null; + naverBlog?: string | null; +} + +type PlatformKey = keyof ChannelHandles; + +interface PlatformMeta { + label: string; + color: string; + Icon: (props: { size?: number; className?: string; style?: CSSProperties }) => React.ReactElement; + buildUrl: (handle: string) => string; +} + +const META: Record = { + website: { + label: '홈페이지', + color: '#6C5CE7', + Icon: GlobeFilled, + buildUrl: (h) => (/^https?:\/\//.test(h) ? h : `https://${h}`), + }, + instagram: { + label: 'Instagram', + color: '#833AB4', + Icon: InstagramFilled, + buildUrl: (h) => `https://instagram.com/${h.replace(/^@/, '')}`, + }, + youtube: { + label: 'YouTube', + color: '#FF3D3D', + Icon: YoutubeFilled, + buildUrl: (h) => `https://youtube.com/${h.startsWith('@') ? h : `@${h}`}`, + }, + facebook: { + label: 'Facebook', + color: '#1877F2', + Icon: FacebookFilled, + buildUrl: (h) => (/^https?:\/\//.test(h) ? h : `https://facebook.com/${h}`), + }, + tiktok: { + label: 'TikTok', + color: '#0A1128', + Icon: TiktokFilled, + buildUrl: (h) => `https://tiktok.com/${h.startsWith('@') ? h : `@${h}`}`, + }, + gangnamUnni: { + label: '강남언니', + color: '#FF6B8A', + Icon: Heart, + buildUrl: (h) => { + if (/^https?:\/\//.test(h)) return h; + if (/gangnamunni\.com\//.test(h)) return `https://www.${h.replace(/^\/+/, '')}`; + return `https://www.gangnamunni.com/hospitals/${h.replace(/^\//, '')}`; + }, + }, + naverPlace: { + label: '네이버 플레이스', + color: '#03C75A', + Icon: MapPin, + buildUrl: (h) => { + if (/^https?:\/\//.test(h)) return h; + if (/place\.naver\.com\//.test(h)) return `https://m.${h.replace(/^m\./, '').replace(/^\/+/, '')}/home`; + return `https://m.place.naver.com/hospital/${h.replace(/^\//, '')}/home`; + }, + }, + naverBlog: { + label: '네이버 블로그', + color: '#03C75A', + Icon: FileTextFilled, + buildUrl: (h) => { + if (/^https?:\/\//.test(h)) return h; + if (/blog\.naver\.com\//.test(h)) return `https://${h.replace(/^\/+/, '')}`; + return `https://blog.naver.com/${h.replace(/^\//, '')}`; + }, + }, +}; + +const ORDER: PlatformKey[] = [ + 'website', + 'youtube', + 'instagram', + 'facebook', + 'naverPlace', + 'naverBlog', + 'gangnamUnni', + 'tiktok', +]; + +interface ChannelLinkButtonsProps { + handles: ChannelHandles; + variant?: 'light' | 'solid'; + className?: string; +} + +export function ChannelLinkButtons({ + handles, + variant = 'light', + className, +}: ChannelLinkButtonsProps) { + const entries = ORDER.flatMap((key) => { + const handle = handles[key]; + if (!handle) return []; + return [{ key, handle }]; + }); + if (entries.length === 0) return null; + + const baseClass = + variant === 'light' + ? 'bg-white/60 backdrop-blur-sm border border-white/40 hover:bg-white/80 text-slate-700' + : 'bg-white border border-slate-200 hover:border-slate-300 hover:bg-slate-50 text-slate-700'; + + return ( +
+ {entries.map(({ key, handle }) => { + const meta = META[key]; + const Icon = meta.Icon; + const url = meta.buildUrl(handle); + return ( + + + {handle} + + + ); + })} +
+ ); +} diff --git a/src/shared/ui/dropdown-menu.tsx b/src/shared/ui/dropdown-menu.tsx new file mode 100644 index 0000000..5147c18 --- /dev/null +++ b/src/shared/ui/dropdown-menu.tsx @@ -0,0 +1,76 @@ +/** + * shadcn DropdownMenu — radix-ui 의 DropdownMenu primitive 위에 얇은 스타일 wrapper. + * 필요한 part만 export: Root / Trigger / Content / Item / Separator. + */ +import * as React from "react" +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui" + +import { cn } from "@/shared/lib/utils" + +function DropdownMenu(props: React.ComponentProps) { + return +} + +function DropdownMenuTrigger(props: React.ComponentProps) { + return +} + +function DropdownMenuContent({ + className, + sideOffset = 6, + align = "end", + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, +} diff --git a/src/shared/ui/empty-state.tsx b/src/shared/ui/empty-state.tsx new file mode 100644 index 0000000..023d2a6 --- /dev/null +++ b/src/shared/ui/empty-state.tsx @@ -0,0 +1,45 @@ +/** + * EmptyState — 리포트/플랜의 섹션·서브섹션에서 데이터가 없을 때 공통으로 쓰는 fallback. + * + * size: + * - sm: 카드 안 작은 영역 (서브섹션, 그래프 자리 등) + * - md: 섹션 내부 일반 영역 (기본값) + * - lg: SectionWrapper 전체를 채우는 경우 + * + * 사용 예: + * {nonEmpty(items) ? : } + */ +import { cn } from '@/shared/lib/utils'; + +interface EmptyStateProps { + size?: 'sm' | 'md' | 'lg'; + hint?: string; + className?: string; +} + +const sizeMap = { + sm: 'px-4 py-5 rounded-xl', + md: 'px-6 py-8 rounded-2xl', + lg: 'px-6 py-12 rounded-2xl', +} as const; + +const textSizeMap = { + sm: 'text-xs', + md: 'text-sm', + lg: 'text-sm', +} as const; + +export function EmptyState({ size = 'md', hint, className }: EmptyStateProps) { + return ( +
+

데이터 없음

+ {hint &&

{hint}

} +
+ ); +} diff --git a/src/shared/ui/page-container.tsx b/src/shared/ui/page-container.tsx new file mode 100644 index 0000000..631ebbb --- /dev/null +++ b/src/shared/ui/page-container.tsx @@ -0,0 +1,21 @@ +import * as React from 'react' +import { Slot } from 'radix-ui' + +import { cn } from '@/shared/lib/utils' + +function PageContainer({ + asChild = false, + className, + ...props +}: React.ComponentProps<'div'> & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : 'div' + return ( + + ) +} + +export { PageContainer } diff --git a/src/styles/custom.css b/src/styles/custom.css index 42d454a..e1bc88c 100644 --- a/src/styles/custom.css +++ b/src/styles/custom.css @@ -51,3 +51,91 @@ .soft-gradient { background: linear-gradient(145deg, #fdfbfb 0%, #ebedee 100%); } + +/* ─── Print / PDF (브라우저 네이티브 인쇄 → PDF) ──────────── + useExportPDF 훅이 window.print()를 호출하면 이 규칙이 적용됨. + 리포트/플랜 본문(data-report-content / data-plan-content)만 인쇄. */ +@media print { + @page { + size: A4; + margin: 10mm 10mm; + } + + /* 브랜드 색·다크 섹션·그라데이션 그대로 유지 */ + *, *::before, *::after { + -webkit-print-color-adjust: exact !important; + print-color-adjust: exact !important; + } + + html, body { + background: #ffffff !important; + margin: 0 !important; + padding: 0 !important; + } + + /* 인쇄에서 제외할 UI 요소 */ + [data-no-print], + [data-report-nav], + [data-plan-nav], + [data-cta-card], + nav { + display: none !important; + } + + /* sticky/fixed 요소는 매 페이지마다 반복 인쇄되므로 정적으로 */ + .sticky, .fixed { + position: static !important; + } + + /* 가로 스크롤 영역은 줄바꿈으로 펼쳐 모든 콘텐츠를 보이게 */ + [data-report-content] .overflow-x-auto, + [data-report-content] .overflow-x-scroll, + [data-report-content] .scrollbar-thin, + [data-plan-content] .overflow-x-auto, + [data-plan-content] .overflow-x-scroll, + [data-plan-content] .scrollbar-thin { + overflow: visible !important; + flex-wrap: wrap !important; + } + [data-report-content] .shrink-0, + [data-plan-content] .shrink-0 { + flex-shrink: 1 !important; + min-width: 0 !important; + } + + /* SectionWrapper의 화면용 py-16/md:py-20 (≈64-80px)은 인쇄에선 너무 큼 → 살짝만 압축. + 좌우 padding(px-6)과 PageContainer의 max-w-7xl/px-6은 그대로 유지하여 가독성 보존. */ + [data-report-content] section, + [data-plan-content] section { + padding-top: 10mm !important; + padding-bottom: 10mm !important; + } + + /* 화면의 sticky/fixed nav 오프셋용 상단 padding 제거 (인쇄에선 nav가 숨겨지므로 불필요) */ + .pt-16, .pt-20, .pt-24, .pt-28, .pt-32 { + padding-top: 0 !important; + } + + /* ReportHeader/PlanHeader 등 첫 섹션은 페이지 맨 위에 바로 붙도록 상단 패딩 제거 */ + [data-report-content] > section:first-child, + [data-plan-content] > section:first-child { + padding-top: 0 !important; + } + + /* break-inside: avoid는 의도적으로 안 씀. + KPI 표·로드맵·Transformation 칩 카드처럼 한 페이지보다 긴 요소가 + 통째로 다음 페이지로 밀려서 큰 빈 공간을 만들기 때문. + 필요한 곳은 [data-print-keep] 명시 시 그 요소만 묶어주는 방식 사용. */ + [data-print-keep] { + break-inside: avoid; + page-break-inside: avoid; + } + + /* framer-motion whileInView 잔여 inline opacity 처리 + (useExportPDF에서 JS로도 강제하지만 안전장치) */ + [style*="opacity: 0"], + [style*="opacity:0"] { + opacity: 1 !important; + } +} + diff --git a/src/styles/index.css b/src/styles/index.css index 8f0345e..ecf87fc 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -226,6 +226,10 @@ * { @apply border-border; } + /* fixed Navbar(h-20 = 5rem) 만큼 앵커 스크롤 오프셋. #section 으로 이동해도 네비에 가려지지 않음 */ + html { + scroll-padding-top: 5rem; + } body { @apply bg-background text-foreground font-sans antialiased; } @@ -238,4 +242,11 @@ .text-white h4, .text-white h5, .text-white h6 { @apply text-white; } + /* Tailwind v4 의 preflight 가 button 의 기본 cursor 를 제거하므로, + 활성화된 클릭 가능 요소(button, role="button", a) 는 명시적으로 pointer 부여 */ + button:not(:disabled), + [role='button']:not([aria-disabled='true']), + a[href] { + cursor: pointer; + } } diff --git a/tsconfig.json b/tsconfig.json index 9627e64..2f81f2b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,5 +19,5 @@ "@/*": ["./src/*"] } }, - "include": ["src", "vite.config.ts"] + "include": ["src", "vite.config.ts", "orval.config.ts"] }