feat: 클리닉 전용 페이지 추가, PageContainer 도입, 불필요한 ui 주석처리
parent
e66b208318
commit
49367756ea
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="min-h-screen bg-slate-50 pt-28 pb-16 px-6">
|
||||
<div className="max-w-7xl mx-auto space-y-8">
|
||||
<PageContainer className="space-y-8 px-0">
|
||||
|
||||
{/* Page Header */}
|
||||
<motion.div
|
||||
|
|
@ -617,7 +618,7 @@ export default function ApiDashboardPage() {
|
|||
<PrismFilled size={14} className="text-slate-300 inline mr-1" />
|
||||
VITE_ 접두사 키만 프론트엔드에서 확인 가능합니다. 서버 키는 백엔드 환경설정에서 확인하세요.
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<div className="bg-[#0A1128] py-14 px-6 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-[500px] h-[500px] rounded-full bg-[#6C5CE7]/10 blur-[120px]" />
|
||||
<div className="absolute bottom-0 left-0 w-[300px] h-[300px] rounded-full bg-purple-500/5 blur-[100px]" />
|
||||
<div className="max-w-6xl mx-auto relative">
|
||||
<PageContainer className="relative max-w-6xl px-0">
|
||||
<p className="text-xs font-semibold text-purple-300 tracking-widest uppercase mb-3">Data Collection Validation</p>
|
||||
<h1 className="font-serif text-3xl md:text-4xl font-bold text-white mb-3">데이터 수집 검증 리포트</h1>
|
||||
<p className="text-purple-200/70 max-w-xl mb-4">Firecrawl API를 활용한 실제 크롤링 테스트 결과 — 뷰성형외과 대상</p>
|
||||
|
|
@ -249,10 +250,10 @@ export default function DataValidationPage() {
|
|||
<span className="px-3 py-1 rounded-full bg-white/10 text-purple-200">API: Firecrawl v1</span>
|
||||
<span className="px-3 py-1 rounded-full bg-white/10 text-purple-200">총 크레딧 소모: 15 credits</span>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</div>
|
||||
|
||||
<div className="max-w-6xl mx-auto px-6 py-10 space-y-12">
|
||||
<PageContainer className="max-w-6xl py-10 space-y-12">
|
||||
|
||||
{/* ── Section 1: 수집 프로세스 ── */}
|
||||
<section>
|
||||
|
|
@ -559,7 +560,7 @@ export default function DataValidationPage() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</PageContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ClassifiedUrls, 'unknown'>;
|
||||
|
||||
/** 채널별 메타 — 라벨/아이콘/색상/플레이스홀더 (뷰성형외과 실 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<ChannelKey, PlatformKey> = {
|
||||
homepage: 'website',
|
||||
youtube: 'youtube',
|
||||
instagram: 'instagram',
|
||||
facebook: 'facebook',
|
||||
naverPlace: 'naverPlace',
|
||||
naverBlog: 'naverBlog',
|
||||
gangnamUnni: 'gangnamUnni',
|
||||
};
|
||||
|
||||
const PLACEHOLDER: Record<ChannelKey, string> = {
|
||||
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<ChannelKey, string>;
|
||||
type ChannelUrlInputs = Record<ChannelKey, string>;
|
||||
|
||||
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<EmptyClassified>(EMPTY_URLS);
|
||||
const [urls, setUrls] = useState<ChannelUrlInputs>(EMPTY_URLS);
|
||||
|
||||
// 통합 분류 결과 — 7개 필드 값을 join해 classifyUrls에 한 번에 통과시켜 manualChannels 구성.
|
||||
const aggregated = useMemo(() => {
|
||||
|
|
@ -153,29 +154,35 @@ export default function MultiChannelInput({ variant = 'hero', onAnalyze }: Multi
|
|||
<div className="w-full max-w-2xl mx-auto">
|
||||
{/* 7개 채널별 입력 필드 — 한 줄에 하나, 좌측 아이콘 + 우측 검증 상태 */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{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 (
|
||||
<div key={key} className="relative">
|
||||
{/* 좌측 채널 아이콘 — 입력 시 컬러 적용, 빈 칸일 땐 회색 */}
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none flex items-center gap-2">
|
||||
<Icon size={16} className={value ? color : isHero ? 'text-slate-400' : 'text-white/40'} />
|
||||
{/* 좌측 채널 아이콘 — 입력 시 브랜드 컬러, 빈 칸일 땐 회색.
|
||||
z-10 으로 input 의 bg/backdrop-blur 위에 올림 (없으면 input 배경이 아이콘을 가림) */}
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none flex items-center gap-2 z-10">
|
||||
<Icon
|
||||
size={16}
|
||||
style={{ color: value ? meta.color : inactiveColor }}
|
||||
/>
|
||||
</div>
|
||||
{/* 채널 라벨 — 좌측 상단 작은 라벨로 placeholder 위에 노출 */}
|
||||
<input
|
||||
type="url"
|
||||
inputMode="url"
|
||||
value={value}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
{/* 우측 검증 상태 아이콘 */}
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||
{/* 우측 검증 상태 아이콘 — 동일하게 z-10 */}
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none z-10">
|
||||
{status === 'valid' && (
|
||||
<CheckFilled size={16} className="text-emerald-500" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<div className="pt-20 min-h-screen">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-brand-grad-peach via-brand-grad-violet to-brand-grad-sky py-16 px-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<PageContainer className="max-w-5xl px-0">
|
||||
<p className="text-xs font-semibold text-brand-purple-vivid tracking-widest uppercase mb-3">Channel Integration</p>
|
||||
<h1 className="font-serif text-3xl md:text-4xl font-bold text-brand-purple-deep mb-3">
|
||||
채널 연결
|
||||
|
|
@ -233,11 +234,11 @@ export default function ChannelConnectPage() {
|
|||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</div>
|
||||
|
||||
{/* Channel Grid */}
|
||||
<div className="max-w-5xl mx-auto px-6 py-12">
|
||||
<PageContainer className="max-w-5xl py-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{CHANNELS.map(ch => {
|
||||
const state = channels[ch.id];
|
||||
|
|
@ -370,7 +371,7 @@ export default function ChannelConnectPage() {
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<PlatformKey, PlatformMeta> = {
|
||||
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 = (
|
||||
<>
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex items-center justify-center rounded-full"
|
||||
style={{
|
||||
backgroundColor: `${meta.color}14`,
|
||||
width: size === 'sm' ? 18 : 22,
|
||||
height: size === 'sm' ? 18 : 22,
|
||||
}}
|
||||
>
|
||||
<Icon size={size === 'sm' ? 11 : 13} style={{ color: meta.color }} />
|
||||
</span>
|
||||
<span className="text-slate-600 truncate max-w-[160px]" title={target.handle}>
|
||||
{target.handle}
|
||||
</span>
|
||||
{isClickable && <ExternalLink size={11} className="text-slate-300 flex-shrink-0" />}
|
||||
</>
|
||||
);
|
||||
|
||||
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 (
|
||||
<a
|
||||
href={target.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`${baseClass} ${sizeClass} hover:border-slate-300 hover:bg-slate-50`}
|
||||
>
|
||||
{inner}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className={`${baseClass} ${sizeClass}`}>{inner}</span>;
|
||||
}
|
||||
|
||||
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 <span className="text-xs text-slate-400">{emptyLabel}</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{targets.map((t) => (
|
||||
<PlatformChip key={`${t.platform}-${t.handle}`} target={t} size={size} clickable={clickable} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { PLATFORM_META };
|
||||
|
|
@ -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<ChannelHandles>(
|
||||
(acc, target) => ({ ...acc, [target.platform]: target.handle }),
|
||||
{},
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-[radial-gradient(ellipse_at_top_left,#e0e7ff,transparent_50%),radial-gradient(ellipse_at_bottom_right,#fce7f3,transparent_50%),radial-gradient(ellipse_at_center,#f5f3ff,transparent_60%)] py-20 px-6">
|
||||
{/* Animated blobs — ReportHeader 와 동일 패턴 */}
|
||||
<motion.div
|
||||
className="absolute top-10 left-10 w-72 h-72 rounded-full bg-indigo-200/30 blur-3xl"
|
||||
animate={{ x: [0, 30, 0], y: [0, -20, 0] }}
|
||||
transition={{ duration: 8, repeat: Infinity, ease: 'easeInOut' }}
|
||||
aria-hidden
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute bottom-10 right-10 w-96 h-96 rounded-full bg-pink-200/30 blur-3xl"
|
||||
animate={{ x: [0, -20, 0], y: [0, 30, 0] }}
|
||||
transition={{ duration: 10, repeat: Infinity, ease: 'easeInOut' }}
|
||||
aria-hidden
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-80 h-80 rounded-full bg-purple-200/20 blur-3xl"
|
||||
animate={{ scale: [1, 1.1, 1] }}
|
||||
transition={{ duration: 6, repeat: Infinity, ease: 'easeInOut' }}
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
<PageContainer className="relative px-0">
|
||||
<div className="flex flex-col md:flex-row items-center md:items-start justify-between gap-10">
|
||||
{/* Left: identity */}
|
||||
<motion.div
|
||||
className="flex-1 text-center md:text-left min-w-0"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<motion.p
|
||||
className="font-serif text-3xl md:text-4xl font-normal text-[#6C5CE7] mb-4 tracking-wide"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
>
|
||||
Clinic Workspace
|
||||
</motion.p>
|
||||
|
||||
{clinic.logoUrl ? (
|
||||
<motion.div
|
||||
className="mb-4"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: 0.15 }}
|
||||
>
|
||||
<img
|
||||
src={clinic.logoUrl}
|
||||
alt={clinic.name}
|
||||
className="h-16 md:h-20 w-auto object-contain md:mx-0 mx-auto"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
) : null}
|
||||
|
||||
<motion.h1
|
||||
className="font-serif text-4xl md:text-5xl font-bold text-[#0A1128] mb-3"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
>
|
||||
{clinic.name}
|
||||
</motion.h1>
|
||||
|
||||
{clinic.nameEn && (
|
||||
<motion.p
|
||||
className="text-lg text-slate-600 mb-8"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
>
|
||||
{clinic.nameEn}
|
||||
</motion.p>
|
||||
)}
|
||||
|
||||
{/* Meta chips — ReportHeader 와 동일 스타일 */}
|
||||
<motion.div
|
||||
className="flex flex-wrap gap-3 justify-center md:justify-start mb-6"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: 0.4 }}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2 rounded-full bg-white/60 backdrop-blur-sm border border-white/40 px-3 py-1 text-sm font-medium text-slate-700">
|
||||
<Calendar size={14} className="text-slate-400" />
|
||||
마지막 분석 {formatDate(latestRun?.completedAt ?? latestRun?.startedAt)}
|
||||
</span>
|
||||
{clinic.location && (
|
||||
<span className="inline-flex items-center gap-2 rounded-full bg-white/60 backdrop-blur-sm border border-white/40 px-3 py-1 text-sm font-medium text-slate-700">
|
||||
<MapPin size={14} className="text-slate-400" />
|
||||
{clinic.location}
|
||||
</span>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* 등록된 채널 바로가기 — ReportHeader 와 동일 패턴 */}
|
||||
{clinic.defaultTargets.length > 0 && (
|
||||
<motion.div
|
||||
className="mb-6"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: 0.5 }}
|
||||
>
|
||||
<ChannelLinkButtons
|
||||
handles={socialHandles}
|
||||
variant="light"
|
||||
className="justify-center md:justify-start"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<motion.div
|
||||
className="flex flex-wrap gap-2 justify-center md:justify-start"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: 0.6 }}
|
||||
>
|
||||
<Link
|
||||
to="/report/loading"
|
||||
className="inline-flex items-center gap-1.5 px-5 py-2.5 rounded-full text-xs font-semibold text-white bg-gradient-to-r from-[#4F1DA1] to-[#021341] shadow-sm hover:opacity-90 transition-all"
|
||||
>
|
||||
<Plus size={14} />새 분석 시작
|
||||
</Link>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Right: stats card — ReportHeader 의 Score card 와 동일 구조 */}
|
||||
<motion.div
|
||||
className="shrink-0"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.3 }}
|
||||
>
|
||||
<div className="bg-white/60 backdrop-blur-sm border border-white/40 rounded-3xl p-8 shadow-lg">
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wide text-center mb-4">
|
||||
Overall Score
|
||||
</p>
|
||||
<ScoreRing score={score != null ? Math.round(score) : 0} size={160} label="종합 점수" />
|
||||
<div className={`flex items-center justify-center gap-1 text-xs font-medium ${trendColor} mt-3`}>
|
||||
<span className="text-slate-500">이전 대비</span>
|
||||
<TrendIcon size={13} />
|
||||
{trendValue ?? <span>변동 없음</span>}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<article
|
||||
onClick={goToReport}
|
||||
onKeyDown={(e) => {
|
||||
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"
|
||||
>
|
||||
<div className="flex flex-col md:flex-row md:items-stretch">
|
||||
{/* Score block */}
|
||||
<div className="flex md:flex-col items-center justify-center gap-3 md:gap-1 px-6 py-5 md:py-6 md:w-32 flex-shrink-0 border-b md:border-b-0 md:border-r border-slate-100">
|
||||
<div className="flex items-baseline gap-0.5">
|
||||
<span className="font-serif text-4xl font-bold text-[#0A1128] leading-none">
|
||||
{formatScore(run.overallScore)}
|
||||
</span>
|
||||
<span className="text-sm text-slate-400">/100</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center: meta + targets */}
|
||||
<div className="flex-1 min-w-0 px-6 py-5 flex flex-col justify-center gap-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full border text-[10px] font-medium ${status.cls}`}>
|
||||
<span className="w-1 h-1 rounded-full bg-current opacity-60" aria-hidden />
|
||||
{status.label}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 text-xs text-slate-500">
|
||||
<Calendar size={11} className="text-slate-400" />
|
||||
{formatDate(run.startedAt)}
|
||||
</span>
|
||||
{highlighted && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-brand-purple text-white">
|
||||
최신
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PlatformChips targets={run.targets} />
|
||||
</div>
|
||||
|
||||
{/* Right: actions */}
|
||||
<div className="flex md:flex-col items-stretch justify-center gap-2 px-6 py-5 md:py-6 md:w-48 flex-shrink-0 md:border-l md:border-slate-100">
|
||||
<Link
|
||||
to={reportHref}
|
||||
onClick={stop}
|
||||
className="inline-flex items-center justify-center gap-1.5 flex-1 md:flex-none px-4 py-2 rounded-full text-xs font-semibold text-white bg-gradient-to-r from-[#4F1DA1] to-[#021341] shadow-sm hover:opacity-90 transition-all"
|
||||
>
|
||||
<FileText size={12} />
|
||||
리포트 보기
|
||||
<ArrowUpRight size={11} className="opacity-80" />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to={planHref}
|
||||
onClick={stop}
|
||||
className="inline-flex items-center justify-center gap-1.5 flex-1 md:flex-none px-4 py-2 rounded-full text-xs font-medium text-brand-purple bg-brand-tint-purple/60 border border-brand-tint-lavender hover:bg-brand-tint-purple transition-colors"
|
||||
>
|
||||
<Sparkles size={12} />
|
||||
기획 보기
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string, WorkspacePlan> = {};
|
||||
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 (
|
||||
<SectionWrapper
|
||||
id="analysis"
|
||||
title="분석 리포트"
|
||||
subtitle={`총 ${rows.length}건 · 카드 본문 클릭은 리포트 보기, 옆 버튼으로 해당 분석의 플랜으로 이동합니다.`}
|
||||
>
|
||||
<div className="flex justify-end mb-5">
|
||||
<Link
|
||||
to="/report/loading"
|
||||
className="inline-flex items-center gap-1.5 px-5 py-2.5 rounded-full text-xs font-semibold text-white bg-gradient-to-r from-[#4F1DA1] to-[#021341] shadow-sm hover:opacity-90 transition-all"
|
||||
>
|
||||
<Plus size={14} />새 분석 시작
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState
|
||||
size="lg"
|
||||
hint="설정 탭에서 분석 대상을 등록하고, 상단의 '새 분석 시작' 을 눌러 첫 리포트를 받아보세요."
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
{visible.map(({ run, plan }, i) => (
|
||||
<AnalysisCard
|
||||
key={run.runId}
|
||||
clinicId={clinicId}
|
||||
run={run}
|
||||
plan={plan}
|
||||
highlighted={i === 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<div
|
||||
ref={sentinelRef}
|
||||
className="flex items-center justify-center py-8 text-xs text-slate-400"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 border-2 border-slate-300 border-t-brand-purple rounded-full animate-spin" />
|
||||
더 불러오는 중...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SectionWrapper>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<PlatformKey, string> = {
|
||||
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<Record<PlatformKey, string>>(() => {
|
||||
const initial: Record<PlatformKey, string> = {
|
||||
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 (
|
||||
<SectionWrapper
|
||||
id="settings"
|
||||
title="분석 대상 설정"
|
||||
subtitle="이 병원의 채널 정보를 등록해두면 새 분석 실행 시 자동으로 불러옵니다."
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Platform handles */}
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-6 shadow-[3px_4px_12px_rgba(0,0,0,0.06)]">
|
||||
<div className="mb-5">
|
||||
<h3 className="font-serif text-lg font-bold text-primary-900">채널 URL · 핸들</h3>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
분석에 사용할 공식 채널을 입력하세요. 비워두면 해당 채널은 분석에서 제외됩니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{EDITABLE_PLATFORMS.map((platform) => {
|
||||
const meta = PLATFORM_META[platform];
|
||||
const Icon = meta.Icon;
|
||||
return (
|
||||
<div key={platform}>
|
||||
<div className="flex items-center gap-3">
|
||||
<label
|
||||
htmlFor={`platform-${platform}`}
|
||||
className="flex items-center gap-2 w-32 flex-shrink-0"
|
||||
>
|
||||
<span
|
||||
className="flex items-center justify-center rounded-full w-7 h-7"
|
||||
style={{ backgroundColor: `${meta.color}14` }}
|
||||
>
|
||||
<Icon size={14} style={{ color: meta.color }} />
|
||||
</span>
|
||||
<span className="text-xs font-medium text-primary-900">{meta.label}</span>
|
||||
</label>
|
||||
<Input
|
||||
id={`platform-${platform}`}
|
||||
value={values[platform]}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
{platform === 'website' && (
|
||||
<p className="ml-[140px] mt-1.5 flex items-start gap-1.5 text-[11px] text-slate-500 leading-relaxed">
|
||||
<Info size={11} className="mt-0.5 shrink-0 text-brand-purple-vivid" />
|
||||
홈페이지를 변경하면 다음 분석부터 병원 기본 정보(이름·주소·진료시간·의료진 등)가 새 사이트 기준으로 함께 갱신돼요.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center justify-between border-t border-slate-100 pt-4">
|
||||
<p
|
||||
className={`inline-flex items-center gap-1.5 text-xs ${
|
||||
saved ? 'text-emerald-600' : 'text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{saved ? (
|
||||
<>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" aria-hidden />
|
||||
로컬에 저장되었습니다 (API 연동 후속 작업)
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle size={12} className="text-slate-300" />
|
||||
저장은 아직 서버에 반영되지 않습니다.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
className="inline-flex items-center gap-1.5 px-5 py-2.5 rounded-full text-xs font-semibold text-white bg-gradient-to-r from-[#4F1DA1] to-[#021341] shadow-sm hover:opacity-90 transition-all"
|
||||
>
|
||||
<Save size={13} />변경 저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionWrapper>
|
||||
);
|
||||
}
|
||||
|
|
@ -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' },
|
||||
],
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
|
@ -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() {
|
|||
<div className="absolute top-0 right-0 w-[500px] h-[500px] rounded-full bg-[#7B2D8E]/10 blur-[120px]" />
|
||||
<div className="absolute bottom-0 left-0 w-[300px] h-[300px] rounded-full bg-purple-500/5 blur-[100px]" />
|
||||
|
||||
<div className="max-w-5xl mx-auto relative">
|
||||
<PageContainer className="relative max-w-5xl px-0">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-xs text-purple-300/60 mb-6">
|
||||
<span>병원 검색</span>
|
||||
|
|
@ -165,10 +166,10 @@ export default function ClinicProfilePage() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</div>
|
||||
|
||||
<div className="max-w-5xl mx-auto px-6 -mt-6">
|
||||
<PageContainer className="max-w-5xl -mt-6">
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* ── 기본 정보 카드 ── */}
|
||||
|
|
@ -442,7 +443,7 @@ export default function ClinicProfilePage() {
|
|||
</motion.div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 부착해야 함
|
||||
// - <ScrollRestoration /> 이 navigation 시 scroll 을 0 으로 리셋하므로
|
||||
// `preventScrollReset: true` 로 차단 후 직접 scrollTo
|
||||
const tabWrapperRef = useRef<HTMLDivElement>(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 (
|
||||
<div className="pt-20 flex-1 flex flex-col">
|
||||
<WorkspaceHeader
|
||||
clinic={data.clinic}
|
||||
latestRun={latestRun}
|
||||
previousRun={previousRun}
|
||||
/>
|
||||
|
||||
{/* hero 아래 본문 영역 — 흰 배경이 footer까지 자연스럽게 이어지도록 */}
|
||||
{/* tabWrapperRef 는 ref-forwarding 되지 않는 Tabs 대신 일반 div 에 부착 */}
|
||||
<div ref={tabWrapperRef} className="flex-1 flex flex-col bg-white">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
className="w-full flex-1"
|
||||
>
|
||||
{/* 탭 스트립 — sticky. 점프는 tabWrapperRef(non-sticky Tabs root) 기준 */}
|
||||
<div className="sticky top-20 z-30 bg-white/95 backdrop-blur-md border-b border-slate-100">
|
||||
<PageContainer>
|
||||
<TabsList
|
||||
variant="line"
|
||||
className="h-auto w-full justify-start gap-1 bg-transparent p-0"
|
||||
>
|
||||
<TabsTrigger
|
||||
value="analysis"
|
||||
className="px-5 py-4 text-sm font-medium data-[state=active]:text-brand-purple data-[state=active]:after:bg-brand-purple"
|
||||
>
|
||||
<FileSearch size={15} />
|
||||
분석
|
||||
<span className="ml-1 text-[10px] font-semibold text-slate-400 group-data-[state=active]/tabs-trigger:text-brand-purple/80">
|
||||
{data.runs.length}
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="settings"
|
||||
className="px-5 py-4 text-sm font-medium data-[state=active]:text-brand-purple data-[state=active]:after:bg-brand-purple"
|
||||
>
|
||||
<SettingsIcon size={15} />
|
||||
설정
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</PageContainer>
|
||||
</div>
|
||||
|
||||
{/* 탭 패널 */}
|
||||
<TabsContent value="analysis">
|
||||
<AnalysisTab
|
||||
clinicId={data.clinic.clinicId}
|
||||
runs={data.runs}
|
||||
plans={data.plans}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="settings">
|
||||
<SettingsTab clinic={data.clinic} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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: <ClinicProfilePage /> },
|
||||
|
||||
// 유저 워크스페이스 (계약 병원 전용)
|
||||
{ path: 'clinics/:clinicId', element: <ClinicWorkspacePage /> },
|
||||
{ path: 'clinics/:clinicId/report/:id', element: <UserReportPage /> },
|
||||
{ path: 'clinics/:clinicId/plan/:id', element: <UserPlanPage /> },
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<section className={`py-16 px-6 ${dark ? 'bg-primary-900 text-white' : ''}`}>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<PageContainer className="px-0">
|
||||
<SectionHeader title={title} desc={desc} />
|
||||
{children}
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -139,7 +140,7 @@ export default function ComponentsPage() {
|
|||
<div className="pt-20 bg-white">
|
||||
{/* Hero */}
|
||||
<div className="px-6 py-20 bg-gradient-to-br from-[#fff3eb] via-[#e4cfff] to-[#f5f9ff]">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<PageContainer className="px-0">
|
||||
<p className="font-mono text-xs text-slate-600 uppercase tracking-widest mb-3">
|
||||
Design System
|
||||
</p>
|
||||
|
|
@ -148,7 +149,7 @@ export default function ComponentsPage() {
|
|||
토큰·폰트·버튼·카드·폼·다이얼로그 등 공통 요소를 한 화면에서 점검. 새로 만들 때 이 페이지에 추가해두면
|
||||
디자이너/개발자가 같은 곳을 봅니다.
|
||||
</p>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</div>
|
||||
|
||||
{/* 1. Brand Colors */}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
<div className="bg-brand-navy py-14 px-6 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-[500px] h-[500px] rounded-full bg-brand-purple-vivid/10 blur-[120px]" />
|
||||
<div className="max-w-5xl mx-auto relative">
|
||||
<PageContainer className="relative max-w-5xl px-0">
|
||||
<p className="text-xs font-semibold text-purple-300 tracking-widest uppercase mb-3">Content Distribution</p>
|
||||
<h1 className="font-serif text-3xl md:text-4xl font-bold text-white mb-3">
|
||||
콘텐츠 배포
|
||||
|
|
@ -66,10 +67,10 @@ export default function DistributionPage() {
|
|||
<p className="text-purple-200/70 max-w-xl">
|
||||
제작된 콘텐츠를 연결된 채널에 동시 배포합니다.
|
||||
</p>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</div>
|
||||
|
||||
<div className="max-w-5xl mx-auto px-6 py-10">
|
||||
<PageContainer className="max-w-5xl py-10">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
|
||||
{/* Left: Content Preview + Meta */}
|
||||
|
|
@ -350,7 +351,7 @@ export default function DistributionPage() {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ export default function CTA() {
|
|||
>
|
||||
<MultiChannelInput variant="cta" onAnalyze={handleAnalyze} />
|
||||
|
||||
{/* 보조 CTA — 가격 플랜 */}
|
||||
{/* 보조 CTA — 가격 플랜 (임시 비활성)
|
||||
<div className="mt-6 flex justify-center">
|
||||
<Link
|
||||
to="/pricing?from=cta"
|
||||
|
|
@ -68,6 +68,7 @@ export default function CTA() {
|
|||
가격 플랜 보기 →
|
||||
</Link>
|
||||
</div>
|
||||
*/}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export default function Hero() {
|
|||
};
|
||||
|
||||
return (
|
||||
<section className="relative pt-28 pb-12 md:pt-36 md:pb-16 overflow-hidden flex flex-col items-center justify-center text-center px-6">
|
||||
<section id="home" className="relative pt-28 pb-12 md:pt-36 md:pb-16 overflow-hidden flex flex-col items-center justify-center text-center px-6">
|
||||
{/* Background Gradient */}
|
||||
<div className="absolute inset-0 -z-10 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-indigo-100 via-purple-50 to-pink-50 opacity-70"></div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<div className="absolute top-[20%] right-[-10%] w-[40vw] h-[40vw] min-w-[500px] min-h-[500px] rounded-full bg-[#e4cfff] opacity-40 blur-[120px] animate-blob-large animation-delay-7000 pointer-events-none"></div>
|
||||
<div className="absolute bottom-[-10%] left-[20%] w-[60vw] h-[60vw] min-w-[700px] min-h-[700px] rounded-full bg-[#f5f9ff] opacity-80 blur-[120px] animate-blob-large animation-delay-14000 pointer-events-none"></div>
|
||||
|
||||
<div className="max-w-7xl mx-auto relative z-10">
|
||||
<PageContainer className="relative z-10 px-0">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
|
|
@ -186,7 +187,7 @@ export default function Modules() {
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<section className="py-24 bg-slate-50 px-6 relative overflow-hidden">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<section id="problems" className="py-24 bg-slate-50 px-6 relative overflow-hidden">
|
||||
<PageContainer className="max-w-6xl px-0">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
|
|
@ -55,7 +56,7 @@ export default function Problems() {
|
|||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
<div className="absolute top-[20%] left-[20%] right-[20%] bottom-[20%] rounded-full border border-white/5 shadow-[0_0_40px_rgba(255,255,255,0.02)_inset]"></div>
|
||||
|
|
@ -76,41 +76,41 @@ export default function Solution() {
|
|||
|
||||
{/* Node A: Audit (Left) — 구 Analysis. 병원·채널 진단 리포트 */}
|
||||
<div className="absolute top-1/2 left-0 -translate-x-1/2 -translate-y-1/2 z-30 flex flex-col items-center gap-3 md:gap-4">
|
||||
<div className="w-16 h-16 md:w-20 md:h-20 rounded-full border border-purple-500/30 bg-primary-900/80 backdrop-blur-sm flex items-center justify-center shadow-[0_0_20px_rgba(168,85,247,0.15)]">
|
||||
<span className="text-3xl md:text-4xl font-bold text-purple-300">A</span>
|
||||
<div className="w-12 h-12 md:w-20 md:h-20 rounded-full border border-purple-500/30 bg-primary-900/80 backdrop-blur-sm flex items-center justify-center shadow-[0_0_20px_rgba(168,85,247,0.15)]">
|
||||
<span className="text-2xl md:text-4xl font-bold text-purple-300">A</span>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<span className="block text-lg md:text-2xl font-medium text-purple-200 leading-tight">Audit</span>
|
||||
<span className="block text-xs md:text-2xl font-medium text-purple-200 leading-tight">Audit</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Node G: Generation (Top) — 전략·로드맵 문서 생성 (AI 콘텐츠 생성 아님) */}
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 z-30 flex flex-col items-center gap-3 md:gap-4">
|
||||
<div className="w-16 h-16 md:w-20 md:h-20 rounded-full border border-purple-500/30 bg-primary-900/80 backdrop-blur-sm flex items-center justify-center shadow-[0_0_20px_rgba(168,85,247,0.15)]">
|
||||
<span className="text-3xl md:text-4xl font-bold text-purple-300">G</span>
|
||||
<div className="w-12 h-12 md:w-20 md:h-20 rounded-full border border-purple-500/30 bg-primary-900/80 backdrop-blur-sm flex items-center justify-center shadow-[0_0_20px_rgba(168,85,247,0.15)]">
|
||||
<span className="text-2xl md:text-4xl font-bold text-purple-300">G</span>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<span className="block text-lg md:text-2xl font-medium text-purple-200 leading-tight">Generation</span>
|
||||
<span className="block text-xs md:text-2xl font-medium text-purple-200 leading-tight">Generation</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Node D: Direction (Right) — 구 Distribution. 채널별 전략·우선순위 설계 */}
|
||||
<div className="absolute top-1/2 right-0 translate-x-1/2 -translate-y-1/2 z-30 flex flex-col items-center gap-3 md:gap-4">
|
||||
<div className="w-16 h-16 md:w-20 md:h-20 rounded-full border border-purple-500/30 bg-primary-900/80 backdrop-blur-sm flex items-center justify-center shadow-[0_0_20px_rgba(168,85,247,0.15)]">
|
||||
<span className="text-3xl md:text-4xl font-bold text-purple-300">D</span>
|
||||
<div className="w-12 h-12 md:w-20 md:h-20 rounded-full border border-purple-500/30 bg-primary-900/80 backdrop-blur-sm flex items-center justify-center shadow-[0_0_20px_rgba(168,85,247,0.15)]">
|
||||
<span className="text-2xl md:text-4xl font-bold text-purple-300">D</span>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<span className="block text-lg md:text-2xl font-medium text-purple-200 leading-tight">Direction</span>
|
||||
<span className="block text-xs md:text-2xl font-medium text-purple-200 leading-tight">Direction</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Node P: Planning (Bottom) — 구 Performance. KPI 목표 설정 + 주간 조정 */}
|
||||
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2 z-30 flex flex-col items-center gap-3 md:gap-4">
|
||||
<div className="w-16 h-16 md:w-20 md:h-20 rounded-full border border-purple-500/30 bg-primary-900/80 backdrop-blur-sm flex items-center justify-center shadow-[0_0_20px_rgba(168,85,247,0.15)]">
|
||||
<span className="text-3xl md:text-4xl font-bold text-purple-300">P</span>
|
||||
<div className="w-12 h-12 md:w-20 md:h-20 rounded-full border border-purple-500/30 bg-primary-900/80 backdrop-blur-sm flex items-center justify-center shadow-[0_0_20px_rgba(168,85,247,0.15)]">
|
||||
<span className="text-2xl md:text-4xl font-bold text-purple-300">P</span>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<span className="block text-lg md:text-2xl font-medium text-purple-200 leading-tight">Planning</span>
|
||||
<span className="block text-xs md:text-2xl font-medium text-purple-200 leading-tight">Planning</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { motion } from 'motion/react';
|
||||
import { PageContainer } from '@/shared/ui/page-container';
|
||||
|
||||
export default function TargetAudience() {
|
||||
return (
|
||||
<section className="py-24 bg-white px-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<section id="audience" className="py-24 bg-white px-6">
|
||||
<PageContainer className="px-0">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
|
|
@ -68,7 +69,7 @@ export default function TargetAudience() {
|
|||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<section id="use-cases" className="py-24 bg-white px-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<PageContainer className="px-0">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
|
|
@ -68,7 +69,7 @@ export default function UseCases() {
|
|||
</ul>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<div className="bg-brand-navy py-14 px-6 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-[500px] h-[500px] rounded-full bg-brand-purple-vivid/10 blur-[120px]" />
|
||||
<div className="absolute bottom-0 left-0 w-[300px] h-[300px] rounded-full bg-purple-500/5 blur-[100px]" />
|
||||
<div className="max-w-6xl mx-auto relative">
|
||||
<PageContainer className="relative max-w-6xl px-0">
|
||||
<p className="text-xs font-semibold text-purple-300 tracking-widest uppercase mb-3">Performance Intelligence</p>
|
||||
<h1 className="font-serif text-3xl md:text-4xl font-bold text-white mb-3">성과 대시보드</h1>
|
||||
<p className="text-purple-200/70 max-w-xl mb-8">모든 채널의 마케팅 성과를 실시간으로 모니터링합니다.</p>
|
||||
|
|
@ -78,10 +79,10 @@ export default function PerformancePage() {
|
|||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</div>
|
||||
|
||||
<div className="max-w-6xl mx-auto px-6 py-10">
|
||||
<PageContainer className="max-w-6xl py-10">
|
||||
|
||||
{/* Overview Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 mb-10">
|
||||
|
|
@ -386,7 +387,7 @@ export default function PerformancePage() {
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
</div>
|
||||
|
||||
{/* Asset Cards Grid */}
|
||||
{filteredAssets.length === 0 ? (
|
||||
<div className="mb-12">
|
||||
<EmptyState size="md" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-12">
|
||||
{filteredAssets.map((asset, i) => {
|
||||
const statusInfo = statusConfig[asset.status];
|
||||
|
|
@ -141,6 +147,7 @@ export default function AssetCollection({ data }: AssetCollectionProps) {
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* YouTube Repurpose Section */}
|
||||
{data.youtubeRepurpose.length > 0 && (
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div>
|
||||
<h3 className="font-serif font-bold text-2xl text-brand-navy mb-2">
|
||||
Brand in Action
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500 mb-5">
|
||||
등록된 컬러·타이포가 실제 콘텐츠에 적용됐을 때의 모습입니다.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 max-w-2xl">
|
||||
{/* ── Instagram Post Mockup ─────────────────────────── */}
|
||||
<motion.figure
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="rounded-2xl border border-slate-200 bg-white overflow-hidden shadow-[3px_4px_18px_rgba(15,23,42,0.06)]"
|
||||
>
|
||||
{/* IG 헤더 */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-slate-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center text-[9px] font-bold ring-1 ring-white"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${primary}, ${accent})`,
|
||||
color: onPrimary === 'light' ? '#fff' : '#0A1128',
|
||||
}}
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
<span className="text-[10px] font-semibold text-brand-navy">
|
||||
{clinicName.toLowerCase().replace(/\s+/g, '_')}
|
||||
</span>
|
||||
</div>
|
||||
<InstagramFilled size={12} />
|
||||
</div>
|
||||
|
||||
{/* IG 본문 (1:1) */}
|
||||
<div
|
||||
className="relative aspect-square flex items-center justify-center px-4 overflow-hidden"
|
||||
style={{
|
||||
background: `radial-gradient(circle at 30% 20%, ${accent}, ${primary} 60%, ${tertiary})`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute -top-12 -right-12 w-32 h-32 rounded-full blur-2xl opacity-30"
|
||||
style={{ backgroundColor: accent }}
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-10 -left-10 w-28 h-28 rounded-full blur-2xl opacity-20"
|
||||
style={{ backgroundColor: '#fff' }}
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
<div className="relative text-center max-w-[85%]">
|
||||
<p
|
||||
className={`text-[8px] uppercase tracking-[0.25em] mb-1.5 ${
|
||||
onPrimary === 'light' ? 'text-white/70' : 'text-black/60'
|
||||
}`}
|
||||
style={{ fontFamily: bodyFont }}
|
||||
>
|
||||
{clinicName} · Story
|
||||
</p>
|
||||
<h4
|
||||
className={`text-lg font-bold leading-tight whitespace-pre-line ${
|
||||
onPrimary === 'light' ? 'text-white' : 'text-brand-navy'
|
||||
}`}
|
||||
style={{ fontFamily: headingFont }}
|
||||
>
|
||||
{computedHeadline}
|
||||
</h4>
|
||||
<p
|
||||
className={`mt-2 text-[10px] leading-relaxed line-clamp-2 ${
|
||||
onPrimary === 'light' ? 'text-white/85' : 'text-black/75'
|
||||
}`}
|
||||
style={{ fontFamily: bodyFont }}
|
||||
>
|
||||
{computedTagline}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* IG 액션바 */}
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<div className="flex items-center gap-2 text-slate-700">
|
||||
<Heart size={14} />
|
||||
<MessageCircle size={14} />
|
||||
<Send size={14} />
|
||||
</div>
|
||||
<Bookmark size={14} className="text-slate-700" />
|
||||
</div>
|
||||
<figcaption className="px-3 pb-2 text-[9px] uppercase tracking-wider text-slate-400">
|
||||
Instagram Feed · 1:1
|
||||
</figcaption>
|
||||
</motion.figure>
|
||||
|
||||
{/* ── YouTube Thumbnail Mockup ──────────────────────── */}
|
||||
<motion.figure
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className="rounded-2xl border border-slate-200 bg-white overflow-hidden shadow-[3px_4px_18px_rgba(15,23,42,0.06)]"
|
||||
>
|
||||
{/* 썸네일 (16:9) */}
|
||||
<div
|
||||
className="relative aspect-video flex items-end p-3 overflow-hidden"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${primary} 0%, ${tertiary} 100%)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute top-0 right-0 w-40 h-40 rounded-full blur-3xl opacity-25"
|
||||
style={{ backgroundColor: accent }}
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
{/* 코너 브랜드 마크 */}
|
||||
<div className="absolute top-2 left-2 flex items-center gap-1.5">
|
||||
<div
|
||||
className="w-5 h-5 rounded flex items-center justify-center text-[8px] font-bold backdrop-blur"
|
||||
style={{
|
||||
backgroundColor: 'rgba(255,255,255,0.15)',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
<span className="text-[8px] uppercase tracking-[0.2em] text-white/60 font-medium">
|
||||
{clinicName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-2 right-2 text-white/40">
|
||||
<Sparkles size={10} />
|
||||
</div>
|
||||
|
||||
{/* 헤드라인 */}
|
||||
<div className="relative max-w-[85%]">
|
||||
<span
|
||||
className="inline-block text-[8px] uppercase tracking-[0.25em] text-white/60 mb-1"
|
||||
style={{ fontFamily: bodyFont }}
|
||||
>
|
||||
Episode 01
|
||||
</span>
|
||||
<h4
|
||||
className="text-white text-base font-bold leading-snug whitespace-pre-line"
|
||||
style={{ fontFamily: headingFont }}
|
||||
>
|
||||
{computedHeadline}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
{/* Play 버튼 */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
<div
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center backdrop-blur"
|
||||
style={{ backgroundColor: 'rgba(255,255,255,0.25)' }}
|
||||
>
|
||||
<Play size={14} className="text-white fill-white ml-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-2 right-2 px-1 py-0.5 rounded text-[8px] font-medium text-white bg-black/60">
|
||||
03:24
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 유튜브 메타 */}
|
||||
<div className="flex items-start gap-2 px-3 py-2">
|
||||
<div
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center text-[10px] font-bold flex-shrink-0"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${primary}, ${accent})`,
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className="text-xs font-semibold text-brand-navy line-clamp-2 leading-snug"
|
||||
style={{ fontFamily: headingFont }}
|
||||
>
|
||||
{computedHeadline.replace(/\n/g, ' · ')}
|
||||
</p>
|
||||
<div className="flex items-center gap-1 mt-0.5 text-[9px] text-slate-500">
|
||||
<YoutubeFilled size={9} />
|
||||
<span>{clinicName}</span>
|
||||
<span>·</span>
|
||||
<span>조회수 12K</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption className="px-3 pb-2 text-[9px] uppercase tracking-wider text-slate-400">
|
||||
YouTube Thumbnail · 16:9
|
||||
</figcaption>
|
||||
</motion.figure>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<ColorSwatch[]>(data.colors);
|
||||
|
||||
const handleColorUpdate = (idx: number, newHex: string) => {
|
||||
|
|
@ -135,6 +138,7 @@ function VisualIdentityTab({ data }: { data: BrandGuide }) {
|
|||
{/* Color Palette */}
|
||||
<div>
|
||||
<h3 className="font-serif font-bold text-2xl text-brand-navy mb-4">Color Palette</h3>
|
||||
{colors.length > 0 ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
{colors.map((swatch: ColorSwatch, idx: number) => (
|
||||
<ColorSwatchCard
|
||||
|
|
@ -144,11 +148,15 @@ function VisualIdentityTab({ data }: { data: BrandGuide }) {
|
|||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState size="md" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Typography */}
|
||||
<div>
|
||||
<h3 className="font-serif font-bold text-2xl text-brand-navy mb-4">Typography</h3>
|
||||
{data.fonts.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{data.fonts.map((spec) => (
|
||||
<div
|
||||
|
|
@ -175,11 +183,15 @@ function VisualIdentityTab({ data }: { data: BrandGuide }) {
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState size="md" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Logo Rules — DO / DON'T split columns */}
|
||||
<div>
|
||||
<h3 className="font-serif font-bold text-2xl text-brand-navy mb-4">Logo Rules</h3>
|
||||
{data.logoRules.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* DO Column */}
|
||||
<div>
|
||||
|
|
@ -187,6 +199,7 @@ function VisualIdentityTab({ data }: { data: BrandGuide }) {
|
|||
<CheckFilled size={18} className="text-brand-purple-soft" />
|
||||
<span className="font-semibold text-brand-purple-muted text-sm uppercase tracking-widest">DO</span>
|
||||
</div>
|
||||
{data.logoRules.some((r) => r.correct) ? (
|
||||
<div className="space-y-3">
|
||||
{data.logoRules.filter((r) => r.correct).map((rule) => (
|
||||
<div
|
||||
|
|
@ -203,6 +216,9 @@ function VisualIdentityTab({ data }: { data: BrandGuide }) {
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState size="sm" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* DON'T Column */}
|
||||
|
|
@ -211,6 +227,7 @@ function VisualIdentityTab({ data }: { data: BrandGuide }) {
|
|||
<CrossFilled size={18} className="text-brand-rose-mid" />
|
||||
<span className="font-semibold text-brand-rose text-sm uppercase tracking-widest">DON'T</span>
|
||||
</div>
|
||||
{data.logoRules.some((r) => !r.correct) ? (
|
||||
<div className="space-y-3">
|
||||
{data.logoRules.filter((r) => !r.correct).map((rule) => (
|
||||
<div
|
||||
|
|
@ -227,9 +244,20 @@ function VisualIdentityTab({ data }: { data: BrandGuide }) {
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState size="sm" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState size="md" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Brand applied preview — IG/YouTube mockup */}
|
||||
{data.colors.length > 0 && data.fonts.length > 0 && (
|
||||
<BrandAppliedPreview data={data} clinicName={clinicName} />
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
@ -245,6 +273,7 @@ function ToneVoiceTab({ tone }: { tone: BrandGuide['toneOfVoice'] }) {
|
|||
{/* Personality */}
|
||||
<div>
|
||||
<h3 className="font-serif font-bold text-2xl text-brand-navy mb-4">Personality</h3>
|
||||
{tone.personality && tone.personality.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tone.personality.map((trait) => (
|
||||
<span
|
||||
|
|
@ -255,16 +284,23 @@ function ToneVoiceTab({ tone }: { tone: BrandGuide['toneOfVoice'] }) {
|
|||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState size="sm" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Communication Style */}
|
||||
<div>
|
||||
<h3 className="font-serif font-bold text-2xl text-brand-navy mb-4">Communication Style</h3>
|
||||
{tone.communicationStyle ? (
|
||||
<div className="rounded-2xl bg-slate-50 p-6">
|
||||
<p className="text-base leading-relaxed text-slate-700">
|
||||
{tone.communicationStyle}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState size="sm" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* DO / DON'T */}
|
||||
|
|
@ -273,6 +309,7 @@ function ToneVoiceTab({ tone }: { tone: BrandGuide['toneOfVoice'] }) {
|
|||
<h4 className="font-semibold text-brand-purple-muted mb-3 flex items-center gap-2">
|
||||
<CheckFilled size={16} className="text-brand-purple-soft" /> DO
|
||||
</h4>
|
||||
{tone.doExamples && tone.doExamples.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{tone.doExamples.map((example, i) => (
|
||||
<div
|
||||
|
|
@ -283,11 +320,15 @@ function ToneVoiceTab({ tone }: { tone: BrandGuide['toneOfVoice'] }) {
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState size="sm" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-brand-rose mb-3 flex items-center gap-2">
|
||||
<CrossFilled size={16} className="text-brand-rose-mid" /> DON'T
|
||||
</h4>
|
||||
{tone.dontExamples && tone.dontExamples.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{tone.dontExamples.map((example, i) => (
|
||||
<div
|
||||
|
|
@ -298,6 +339,9 @@ function ToneVoiceTab({ tone }: { tone: BrandGuide['toneOfVoice'] }) {
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState size="sm" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
|
@ -306,6 +350,13 @@ function ToneVoiceTab({ tone }: { tone: BrandGuide['toneOfVoice'] }) {
|
|||
|
||||
/* ─── 채널 규칙 탭 ─── */
|
||||
function ChannelRulesTab({ channels }: { channels: BrandGuide['channelBranding'] }) {
|
||||
if (!channels || channels.length === 0) {
|
||||
return (
|
||||
<motion.div animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.4 }}>
|
||||
<EmptyState size="md" />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
|
|
@ -362,6 +413,14 @@ function ChannelRulesTab({ channels }: { channels: BrandGuide['channelBranding']
|
|||
function BrandConsistencyTab({ inconsistencies }: { inconsistencies: BrandInconsistency[] }) {
|
||||
const [expanded, setExpanded] = useState<number | null>(0);
|
||||
|
||||
if (!inconsistencies || inconsistencies.length === 0) {
|
||||
return (
|
||||
<motion.div animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.4 }}>
|
||||
<EmptyState size="md" />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
|
|
@ -458,7 +517,7 @@ function BrandConsistencyTab({ inconsistencies }: { inconsistencies: BrandIncons
|
|||
}
|
||||
|
||||
/* ─── 메인 컴포넌트 ─── */
|
||||
export default function BrandingGuide({ data }: BrandingGuideProps) {
|
||||
export default function BrandingGuide({ data, clinicName }: BrandingGuideProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('visual');
|
||||
|
||||
return (
|
||||
|
|
@ -485,7 +544,7 @@ export default function BrandingGuide({ data }: BrandingGuideProps) {
|
|||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'visual' && <VisualIdentityTab data={data} />}
|
||||
{activeTab === 'visual' && <VisualIdentityTab data={data} clinicName={clinicName} />}
|
||||
{activeTab === 'tone' && <ToneVoiceTab tone={data.toneOfVoice} />}
|
||||
{activeTab === 'channels' && <ChannelRulesTab channels={data.channelBranding} />}
|
||||
{activeTab === 'consistency' && (
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
</div>
|
||||
|
||||
{/* Tab 1: Content Pillars */}
|
||||
{activeTab === 'pillars' && (
|
||||
{activeTab === 'pillars' && (data.pillars && data.pillars.length > 0 ? (
|
||||
<motion.div
|
||||
className="grid md:grid-cols-2 gap-6"
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
|
|
@ -103,10 +104,12 @@ export default function ContentStrategy({ data }: ContentStrategyProps) {
|
|||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
) : (
|
||||
<EmptyState size="md" />
|
||||
))}
|
||||
|
||||
{/* Tab 2: Content Types */}
|
||||
{activeTab === 'types' && (
|
||||
{activeTab === 'types' && (data.typeMatrix && data.typeMatrix.length > 0 ? (
|
||||
<motion.div
|
||||
className="rounded-2xl overflow-hidden border border-slate-100"
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
|
|
@ -143,10 +146,15 @@ export default function ContentStrategy({ data }: ContentStrategyProps) {
|
|||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
) : (
|
||||
<EmptyState size="md" />
|
||||
))}
|
||||
|
||||
{/* Tab 3: Production Workflow */}
|
||||
{activeTab === 'workflow' && (() => {
|
||||
if (!data.workflow || data.workflow.length === 0) {
|
||||
return <EmptyState size="md" />;
|
||||
}
|
||||
// step 수에 따라 동일 너비 grid 컬럼 매핑 (Tailwind purge 안전)
|
||||
const cols = data.workflow.length;
|
||||
const gridColsClass =
|
||||
|
|
@ -198,17 +206,20 @@ export default function ContentStrategy({ data }: ContentStrategyProps) {
|
|||
|
||||
{/* Tab 4: Repurposing */}
|
||||
{activeTab === 'repurposing' && (
|
||||
(data.repurposingSource || (data.repurposingOutputs && data.repurposingOutputs.length > 0)) ? (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
{/* Source Card */}
|
||||
{data.repurposingSource && (
|
||||
<div className="rounded-2xl bg-gradient-to-r from-brand-purple to-brand-purple-deep p-6 text-white mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<VideoFilled size={28} className="text-white/60" />
|
||||
<h4 className="font-serif text-xl font-bold">{data.repurposingSource}</h4>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connector */}
|
||||
<div className="flex justify-center mb-6">
|
||||
|
|
@ -216,6 +227,7 @@ export default function ContentStrategy({ data }: ContentStrategyProps) {
|
|||
</div>
|
||||
|
||||
{/* Outputs Grid */}
|
||||
{data.repurposingOutputs && data.repurposingOutputs.length > 0 ? (
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{data.repurposingOutputs.map((output, i) => (
|
||||
<motion.div
|
||||
|
|
@ -230,7 +242,13 @@ export default function ContentStrategy({ data }: ContentStrategyProps) {
|
|||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState size="sm" />
|
||||
)}
|
||||
</motion.div>
|
||||
) : (
|
||||
<EmptyState size="md" />
|
||||
)
|
||||
)}
|
||||
</SectionWrapper>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<T>(v: T | null | undefined): v is T {
|
||||
return v != null;
|
||||
}
|
||||
|
||||
function nonEmpty<T>(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 (
|
||||
<div data-plan-content>
|
||||
<PlanHeader
|
||||
clinicName={data.clinicName}
|
||||
clinicNameEn={data.clinicNameEn}
|
||||
date={data.createdAt}
|
||||
targetUrl={data.targetUrl}
|
||||
socialHandles={socialHandles}
|
||||
/>
|
||||
|
||||
{hasValue(data.brandGuide) ? (
|
||||
<BrandingGuide data={data.brandGuide} clinicName={data.clinicName} />
|
||||
) : (
|
||||
<EmptySection id="branding-guide" title="Brand Guide" subtitle="브랜딩 가이드" />
|
||||
)}
|
||||
|
||||
{nonEmpty(data.channelStrategies) ? (
|
||||
<ChannelStrategy channels={data.channelStrategies} />
|
||||
) : (
|
||||
<EmptySection id="channel-strategy" title="Channel Strategy" subtitle="채널 전략" />
|
||||
)}
|
||||
|
||||
{hasValue(data.contentStrategy) ? (
|
||||
<ContentStrategy data={data.contentStrategy} />
|
||||
) : (
|
||||
<EmptySection id="content-strategy" title="Content Strategy" subtitle="콘텐츠 전략" />
|
||||
)}
|
||||
|
||||
{hasValue(data.calendar) ? (
|
||||
<ContentCalendar data={data.calendar} />
|
||||
) : (
|
||||
<EmptySection id="content-calendar" title="Content Calendar" subtitle="콘텐츠 캘린더" />
|
||||
)}
|
||||
|
||||
{hasValue(data.assetCollection) ? (
|
||||
<AssetCollection data={data.assetCollection} />
|
||||
) : (
|
||||
<EmptySection id="asset-collection" title="Asset Collection" subtitle="에셋 수집" />
|
||||
)}
|
||||
|
||||
{nonEmpty(data.repurposingProposals) ? (
|
||||
<RepurposingProposal proposals={data.repurposingProposals} />
|
||||
) : (
|
||||
<EmptySection id="repurposing-proposal" title="Repurposing Proposals" subtitle="리퍼포징 제안" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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' }}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<PageContainer className="px-0">
|
||||
<div
|
||||
data-cta-card
|
||||
className="rounded-2xl bg-gradient-to-r from-brand-grad-peach via-brand-grad-violet to-brand-grad-sky p-10 md:p-14 text-center"
|
||||
|
|
@ -62,7 +63,7 @@ export default function PlanCTA() {
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</motion.section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<section className="relative overflow-hidden bg-[radial-gradient(ellipse_at_top_left,#e0e7ff,transparent_50%),radial-gradient(ellipse_at_bottom_right,#fce7f3,transparent_50%),radial-gradient(ellipse_at_center,#f5f3ff,transparent_60%)] py-20 md:py-28 px-6">
|
||||
|
|
@ -45,7 +49,7 @@ export default function PlanHeader({
|
|||
transition={{ duration: 6, repeat: Infinity, ease: 'easeInOut' }}
|
||||
/>
|
||||
|
||||
<div className="relative max-w-7xl mx-auto">
|
||||
<PageContainer className="relative px-0">
|
||||
<div className="flex flex-col md:flex-row items-center md:items-start justify-between gap-10">
|
||||
{/* Left: Text content — plain div, no opacity animation */}
|
||||
<div className="flex-1 text-center md:text-left">
|
||||
|
|
@ -71,6 +75,17 @@ export default function PlanHeader({
|
|||
{targetUrl}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 등록된 채널 바로가기 */}
|
||||
{socialHandles && (
|
||||
<div className="mt-4">
|
||||
<ChannelLinkButtons
|
||||
handles={socialHandles}
|
||||
variant="light"
|
||||
className="justify-center md:justify-start"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: 90 Days badge */}
|
||||
|
|
@ -83,7 +98,7 @@ export default function PlanHeader({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center pt-20">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-10 h-10 border-4 border-[#6C5CE7] border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-slate-500 text-sm">마케팅 기획을 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center pt-20">
|
||||
<div className="text-center">
|
||||
<p className="text-[#7C3A4B] font-medium mb-2">오류가 발생했습니다</p>
|
||||
<p className="text-slate-500 text-sm">
|
||||
{error ?? '마케팅 기획을 찾을 수 없습니다.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-20">
|
||||
<ReportNav
|
||||
sections={PLAN_SECTIONS}
|
||||
rightSlot={
|
||||
<div className="flex items-center gap-2">
|
||||
<PdfDownloadButton filename={`${data.clinicName}_Marketing_Plan`} />
|
||||
<Link
|
||||
to={`/report/${id}`}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-full text-xs font-semibold text-slate-700 bg-white border border-slate-200 hover:border-slate-300 hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
<FileSearch size={12} />
|
||||
분석 리포트 보기
|
||||
<ArrowRight size={11} className="opacity-80" />
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<PlanBody data={data} />
|
||||
<PlanCTA />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center pt-20">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-10 h-10 border-4 border-[#6C5CE7] border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-slate-500 text-sm">마케팅 플랜을 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center pt-20">
|
||||
<div className="text-center">
|
||||
<p className="text-[#7C3A4B] font-medium mb-2">오류가 발생했습니다</p>
|
||||
<p className="text-slate-500 text-sm">
|
||||
{error ?? '마케팅 플랜을 찾을 수 없습니다.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-20">
|
||||
<ReportNav sections={PLAN_SECTIONS} />
|
||||
|
||||
<div data-plan-content>
|
||||
<PlanHeader
|
||||
clinicName={data.clinicName}
|
||||
clinicNameEn={data.clinicNameEn}
|
||||
date={data.createdAt}
|
||||
targetUrl={data.targetUrl}
|
||||
/>
|
||||
|
||||
<BrandingGuide data={data.brandGuide} />
|
||||
|
||||
<ChannelStrategy channels={data.channelStrategies} />
|
||||
|
||||
<ContentStrategy data={data.contentStrategy} />
|
||||
|
||||
<ContentCalendar data={data.calendar} />
|
||||
|
||||
<AssetCollection data={data.assetCollection} />
|
||||
|
||||
{data.repurposingProposals && data.repurposingProposals.length > 0 && (
|
||||
<RepurposingProposal proposals={data.repurposingProposals} />
|
||||
)}
|
||||
|
||||
{data.workflow && (
|
||||
<WorkflowTracker data={data.workflow} />
|
||||
)}
|
||||
|
||||
<div data-no-print>
|
||||
<MyAssetUpload />
|
||||
</div>
|
||||
|
||||
<div data-no-print>
|
||||
<StrategyAdjustmentSection clinicId={clinicId} planId={data.id} />
|
||||
</div>
|
||||
|
||||
<PlanCTA />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center pt-20">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-10 h-10 border-4 border-[#6C5CE7] border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-slate-500 text-sm">마케팅 기획을 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center pt-20">
|
||||
<div className="text-center">
|
||||
<p className="text-[#7C3A4B] font-medium mb-2">오류가 발생했습니다</p>
|
||||
<p className="text-slate-500 text-sm">{error ?? '마케팅 기획을 찾을 수 없습니다.'}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="pt-20">
|
||||
<ReportNav
|
||||
sections={PLAN_SECTIONS}
|
||||
leftSlot={
|
||||
<Link
|
||||
to={`/clinics/${clinicId}`}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-medium text-slate-500 hover:text-primary-900 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
워크스페이스로
|
||||
</Link>
|
||||
}
|
||||
rightSlot={
|
||||
<div className="flex items-center gap-2">
|
||||
<PdfDownloadButton filename={`${data.clinicName}_Marketing_Plan`} />
|
||||
{reportTargetId && (
|
||||
<Link
|
||||
to={`/clinics/${clinicId}/report/${reportTargetId}`}
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-xs font-medium text-slate-600 bg-slate-50 border border-slate-200 hover:bg-white hover:border-slate-300 transition-all"
|
||||
>
|
||||
<FileSearch size={12} />
|
||||
기반 리포트
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<PlanBody data={data} />
|
||||
|
||||
{/* 유저 전용 인터랙티브 섹션 */}
|
||||
{data.workflow && (
|
||||
<div data-no-print>
|
||||
<WorkflowTracker data={data.workflow} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div data-no-print>
|
||||
<MyAssetUpload />
|
||||
</div>
|
||||
|
||||
<div data-no-print>
|
||||
<StrategyAdjustmentSection clinicId={clinicId ?? stateClinicId} planId={data.id} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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: <MarketingPlanPage /> },
|
||||
// 손님(랜딩→분석→리포트→플랜) 흐름. 유저 워크스페이스 경로는 features/clinics/routes.tsx 참조.
|
||||
{ path: 'plan/:id', element: <GuestPlanPage /> },
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
{/* ── Section 3 · 3 Tier Cards ─────────────────── */}
|
||||
<section id="tiers" className="px-6 max-w-7xl mx-auto mb-20 scroll-mt-24">
|
||||
<PageContainer asChild className="mb-20 scroll-mt-24">
|
||||
<section id="tiers">
|
||||
<div className="grid md:grid-cols-3 gap-6 md:gap-8 items-stretch">
|
||||
{tiers.map((tier) => (
|
||||
<TierCard key={tier.id} tier={tier} billing={billing} onSelect={handleTierSelect} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</PageContainer>
|
||||
|
||||
{/* ── Section 4 · Feature Comparison Table ─── */}
|
||||
<section className="px-6 mb-20 max-w-7xl mx-auto">
|
||||
<PageContainer asChild className="mb-20">
|
||||
<section>
|
||||
<FeatureComparisonTable />
|
||||
</section>
|
||||
</PageContainer>
|
||||
|
||||
{/* ── Section 5 · 먼저 문의하기 강조 ───────────── */}
|
||||
{/* outer max-w-7xl로 Tier Cards 섹션과 동일 정렬 (반응형 자동 축소) */}
|
||||
<section className="px-6 mb-16 max-w-7xl mx-auto">
|
||||
<PageContainer asChild className="mb-16">
|
||||
<section>
|
||||
<ContactFirstBox />
|
||||
</section>
|
||||
</PageContainer>
|
||||
|
||||
{/* ── Section 6 · Launch Promotion ───────────── */}
|
||||
<section className="px-6 mb-20 max-w-7xl mx-auto">
|
||||
<PageContainer asChild className="mb-20">
|
||||
<section>
|
||||
<PromotionBanner />
|
||||
</section>
|
||||
</PageContainer>
|
||||
|
||||
{/* ── Section 7 · FAQ ─────────────────────────── */}
|
||||
<section className="px-6 mb-20 max-w-7xl mx-auto">
|
||||
<PageContainer asChild className="mb-20">
|
||||
<section>
|
||||
<FAQ />
|
||||
</section>
|
||||
</PageContainer>
|
||||
|
||||
{/* ── Section 8 · Enterprise Contact ─────────── */}
|
||||
<section className="px-6 max-w-7xl mx-auto">
|
||||
<PageContainer asChild>
|
||||
<section>
|
||||
<EnterpriseContact />
|
||||
</section>
|
||||
</PageContainer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -171,8 +171,9 @@ export default function ClinicSnapshot({ data }: ClinicSnapshotProps) {
|
|||
{data.certifications.map((cert) => (
|
||||
<span
|
||||
key={cert}
|
||||
className="rounded-full bg-white/60 backdrop-blur-sm border border-white/40 px-3 py-1 text-sm font-medium text-slate-700"
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-brand-tint-purple border border-brand-tint-lavender px-3 py-1 text-sm font-medium text-brand-purple-muted"
|
||||
>
|
||||
<Award size={12} className="text-brand-purple" />
|
||||
{cert}
|
||||
</span>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
disabled={isExporting}
|
||||
className={
|
||||
className ??
|
||||
'inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-xs font-medium text-slate-600 bg-slate-50 border border-slate-200 hover:bg-white hover:border-slate-300 transition-all disabled:opacity-60 disabled:cursor-wait'
|
||||
}
|
||||
>
|
||||
{isExporting ? (
|
||||
<>
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
생성 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download size={12} />
|
||||
다운로드
|
||||
<ChevronDown size={12} className="opacity-70" />
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onSelect={() => exportPDF(filename)}>
|
||||
<FileText size={14} className="text-slate-500" />
|
||||
<span>PDF로 저장</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => exportReportCSV(filename, report)}>
|
||||
<FileSpreadsheet size={14} className="text-slate-500" />
|
||||
<span>CSV로 저장</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
|
@ -118,9 +118,6 @@ export default function OtherChannels({ channels, website }: OtherChannelsProps)
|
|||
<p className={`text-sm font-medium ${pixel.installed ? 'text-[#4A3A7C]' : 'text-[#7C3A4B]'}`}>
|
||||
{pixel.name}
|
||||
</p>
|
||||
{pixel.details && (
|
||||
<p className="text-xs text-slate-500 truncate">{pixel.details}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => exportPDF(filename)}
|
||||
disabled={isExporting}
|
||||
className={
|
||||
className ??
|
||||
'inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-xs font-medium text-slate-600 bg-slate-50 border border-slate-200 hover:bg-white hover:border-slate-300 transition-all disabled:opacity-60 disabled:cursor-wait'
|
||||
}
|
||||
title="PDF로 다운로드"
|
||||
>
|
||||
{isExporting ? (
|
||||
<>
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
생성 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download size={12} />
|
||||
{label}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<T>(v: T | null | undefined): v is T {
|
||||
return v != null;
|
||||
}
|
||||
|
||||
function nonEmpty<T>(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 (
|
||||
<div data-report-content>
|
||||
<ReportHeader
|
||||
overallScore={data.overallScore}
|
||||
clinicName={data.clinicSnapshot.name}
|
||||
clinicNameEn={data.clinicSnapshot.nameEn}
|
||||
targetUrl={data.targetUrl}
|
||||
date={data.createdAt}
|
||||
location={data.clinicSnapshot.location}
|
||||
logoImage={data.clinicSnapshot.logoImages?.horizontal}
|
||||
brandColors={data.clinicSnapshot.brandColors}
|
||||
socialHandles={socialHandles}
|
||||
/>
|
||||
|
||||
<SectionErrorBoundary>
|
||||
{hasValue(data.clinicSnapshot) && data.clinicSnapshot.name ? (
|
||||
<ClinicSnapshot data={data.clinicSnapshot} />
|
||||
) : (
|
||||
<EmptySection id="clinic-snapshot" title="Clinic Overview" subtitle="병원 기본 정보" />
|
||||
)}
|
||||
</SectionErrorBoundary>
|
||||
|
||||
<SectionErrorBoundary>
|
||||
{nonEmpty(data.channelScores) ? (
|
||||
<ChannelOverview channels={data.channelScores} />
|
||||
) : (
|
||||
<EmptySection id="channel-overview" title="Channel Overview" subtitle="채널 종합 진단" />
|
||||
)}
|
||||
</SectionErrorBoundary>
|
||||
|
||||
<SectionErrorBoundary>
|
||||
{hasValue(data.youtubeAudit) && data.youtubeAudit.handle ? (
|
||||
<YouTubeAudit data={data.youtubeAudit} />
|
||||
) : (
|
||||
<EmptySection id="youtube-audit" title="YouTube Analysis" subtitle="유튜브 채널 분석" />
|
||||
)}
|
||||
</SectionErrorBoundary>
|
||||
|
||||
<SectionErrorBoundary>
|
||||
{hasValue(data.instagramAudit) && data.instagramAudit.handle ? (
|
||||
<InstagramAudit data={data.instagramAudit} />
|
||||
) : (
|
||||
<EmptySection id="instagram-audit" title="Instagram Analysis" subtitle="인스타그램 분석" />
|
||||
)}
|
||||
</SectionErrorBoundary>
|
||||
|
||||
<SectionErrorBoundary>
|
||||
{hasValue(data.facebookAudit) && (data.facebookAudit.handle || data.facebookAudit.followers > 0) ? (
|
||||
<FacebookAudit data={data.facebookAudit} />
|
||||
) : (
|
||||
<EmptySection id="facebook-audit" title="Facebook Analysis" subtitle="페이스북 페이지 분석" />
|
||||
)}
|
||||
</SectionErrorBoundary>
|
||||
|
||||
<SectionErrorBoundary>
|
||||
{nonEmpty(data.otherChannels) || hasValue(data.websiteAudit) ? (
|
||||
<OtherChannels channels={data.otherChannels ?? []} website={data.websiteAudit} />
|
||||
) : (
|
||||
<EmptySection id="other-channels" title="Other Channels" subtitle="기타 채널 및 웹사이트" />
|
||||
)}
|
||||
</SectionErrorBoundary>
|
||||
|
||||
<SectionErrorBoundary>
|
||||
{nonEmpty(data.problemDiagnosis) ? (
|
||||
<ProblemDiagnosis diagnosis={data.problemDiagnosis} />
|
||||
) : (
|
||||
<EmptySection id="problem-diagnosis" title="Problem Diagnosis" subtitle="문제 진단" />
|
||||
)}
|
||||
</SectionErrorBoundary>
|
||||
|
||||
<SectionErrorBoundary>
|
||||
{hasValue(data.transformation) ? (
|
||||
<TransformationProposal data={data.transformation} />
|
||||
) : (
|
||||
<EmptySection id="transformation" title="Transformation" subtitle="개선 제안" />
|
||||
)}
|
||||
</SectionErrorBoundary>
|
||||
|
||||
<SectionErrorBoundary>
|
||||
{nonEmpty(data.roadmap) ? (
|
||||
<RoadmapTimeline months={data.roadmap} />
|
||||
) : (
|
||||
<EmptySection id="roadmap" title="Roadmap" subtitle="실행 로드맵" />
|
||||
)}
|
||||
</SectionErrorBoundary>
|
||||
|
||||
<SectionErrorBoundary>
|
||||
{nonEmpty(data.kpiDashboard) ? (
|
||||
<KPIDashboard metrics={data.kpiDashboard} clinicName={data.clinicSnapshot.name} />
|
||||
) : (
|
||||
<EmptySection id="kpi-dashboard" title="KPI Dashboard" subtitle="핵심 지표" />
|
||||
)}
|
||||
</SectionErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<section className="relative overflow-hidden bg-[radial-gradient(ellipse_at_top_left,#e0e7ff,transparent_50%),radial-gradient(ellipse_at_bottom_right,#fce7f3,transparent_50%),radial-gradient(ellipse_at_center,#f5f3ff,transparent_60%)] py-20 px-6">
|
||||
|
|
@ -54,7 +59,7 @@ export default function ReportHeader({
|
|||
transition={{ duration: 6, repeat: Infinity, ease: 'easeInOut' }}
|
||||
/>
|
||||
|
||||
<div className="relative max-w-7xl mx-auto">
|
||||
<PageContainer className="relative px-0">
|
||||
<div className="flex flex-col md:flex-row items-center md:items-start justify-between gap-10">
|
||||
{/* Left: Text content */}
|
||||
<motion.div
|
||||
|
|
@ -131,6 +136,23 @@ export default function ReportHeader({
|
|||
{location}
|
||||
</span>
|
||||
</motion.div>
|
||||
|
||||
{/* 등록된 채널 바로가기 */}
|
||||
{socialHandles && (
|
||||
<motion.div
|
||||
className="mt-4"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: 0.5 }}
|
||||
>
|
||||
<ChannelLinkButtons
|
||||
handles={socialHandles}
|
||||
variant="light"
|
||||
className="justify-center md:justify-start"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Right: Score ring */}
|
||||
|
|
@ -149,7 +171,7 @@ export default function ReportHeader({
|
|||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>(null);
|
||||
const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
||||
|
|
@ -54,9 +59,15 @@ export function ReportNav({ sections }: ReportNavProps) {
|
|||
|
||||
return (
|
||||
<nav data-report-nav className="sticky top-20 z-40 bg-white/95 border-b border-slate-100">
|
||||
<PageContainer className="px-0 flex items-center gap-3">
|
||||
{leftSlot && (
|
||||
<div className="shrink-0 pl-4 md:pl-6" data-no-print>
|
||||
{leftSlot}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={navRef}
|
||||
className="max-w-7xl mx-auto flex overflow-x-auto scrollbar-hide"
|
||||
className="flex-1 min-w-0 flex overflow-x-auto scrollbar-hide"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{sections.map(({ id, label }) => (
|
||||
|
|
@ -81,6 +92,12 @@ export function ReportNav({ sections }: ReportNavProps) {
|
|||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{rightSlot && (
|
||||
<div className="shrink-0 pr-4 md:pr-6" data-no-print>
|
||||
{rightSlot}
|
||||
</div>
|
||||
)}
|
||||
</PageContainer>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
<span
|
||||
key={pl}
|
||||
className="rounded-full bg-white/60 backdrop-blur-sm border border-white/40 px-3 py-1 text-sm font-medium text-slate-700"
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-rose-50 border border-rose-200 px-3 py-1 text-sm font-medium text-rose-700"
|
||||
>
|
||||
<ListVideo size={12} className="text-rose-500" />
|
||||
{pl}
|
||||
</span>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<SectionWrapper id={id} title={title} subtitle={subtitle}>
|
||||
<EmptyState size="lg" hint={hint} />
|
||||
</SectionWrapper>
|
||||
);
|
||||
}
|
||||
|
|
@ -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' }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,rgba(108,92,231,0.15),transparent_60%)]" />
|
||||
)}
|
||||
<div className="relative max-w-7xl mx-auto">
|
||||
<PageContainer className="relative px-0">
|
||||
<div className="mb-10">
|
||||
<h2
|
||||
className={`
|
||||
|
|
@ -54,7 +55,7 @@ export function SectionWrapper({
|
|||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: '전화 + 카카오톡 상담 + 온라인 예약',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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: '전화 상담 + 온라인 예약',
|
||||
|
|
|
|||
|
|
@ -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: '전화 상담 + 온라인 예약',
|
||||
|
|
|
|||
|
|
@ -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: '전화 상담 + 카카오톡',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 (글로벌)',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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: '전화 상담 + 카카오톡 상담',
|
||||
|
|
|
|||
|
|
@ -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: '전화 상담 + 다국어 온라인 예약',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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<HTMLElement>('*').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 };
|
||||
|
|
|
|||
|
|
@ -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<typeof useEnrichment>['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<string, unknown> | undefined;
|
||||
const metadata = state?.metadata as Record<string, unknown> | undefined;
|
||||
const stateSocialHandles = metadata?.socialHandles as Record<string, string | null> | 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center pt-20">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-10 h-10 border-4 border-[#6C5CE7] border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-slate-500 text-sm">리포트를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center pt-20">
|
||||
<div className="text-center">
|
||||
<p className="text-[#7C3A4B] font-medium mb-2">오류가 발생했습니다</p>
|
||||
<p className="text-slate-500 text-sm">{error ?? '리포트를 찾을 수 없습니다.'}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScreenshotProvider screenshots={data.screenshots ?? []}>
|
||||
<div className="pt-20">
|
||||
<ReportNav
|
||||
sections={REPORT_SECTIONS}
|
||||
rightSlot={
|
||||
<div className="flex items-center gap-2">
|
||||
<DownloadMenuButton
|
||||
filename={`${data.clinicSnapshot.name}_Marketing_Intelligence_Report`}
|
||||
report={data}
|
||||
/>
|
||||
<Link
|
||||
to={`/plan/${id}`}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-full text-xs font-semibold text-brand-purple bg-brand-tint-purple/60 border border-brand-tint-lavender hover:bg-brand-tint-purple transition-colors"
|
||||
>
|
||||
<Sparkles size={12} />
|
||||
마케팅 기획 보기
|
||||
<ArrowRight size={11} className="opacity-80" />
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{enrichStatus === 'loading' && (
|
||||
<div className="fixed bottom-6 right-6 z-50 flex items-center gap-3 px-4 py-3 bg-white rounded-xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] border border-slate-100">
|
||||
<div className="w-4 h-4 border-2 border-[#6C5CE7] border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-xs text-slate-500">채널 데이터 보강 중...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ReportBody data={data} />
|
||||
|
||||
{/* Guest 전용 — 도입 문의 CTA */}
|
||||
<section className="px-6 py-16 bg-gradient-to-b from-white to-[#F3F0FF]/30">
|
||||
<div className="max-w-3xl mx-auto rounded-3xl bg-[#0A1128] p-10 text-center relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-[300px] h-[300px] rounded-full bg-[#7B2D8E]/20 blur-[120px]" aria-hidden />
|
||||
<div className="relative">
|
||||
<p className="text-xs uppercase tracking-wider text-purple-300/70 mb-3">
|
||||
INFINITH 도입 안내
|
||||
</p>
|
||||
<h2 className="font-serif text-2xl md:text-3xl font-bold text-white mb-3 leading-snug">
|
||||
이 리포트의 다음 단계 — 마케팅 기획
|
||||
</h2>
|
||||
<p className="text-sm text-purple-200/70 break-keep leading-relaxed mb-7 max-w-xl mx-auto">
|
||||
계약 병원 전용 워크스페이스에서 본 분석을 기반으로 콘텐츠 캘린더,
|
||||
자산 관리, 워크플로우 추적까지 한 곳에서 운영할 수 있습니다.
|
||||
</p>
|
||||
<a
|
||||
href={buildContactMailto(`도입 문의 — ${data.clinicSnapshot.name} 리포트`)}
|
||||
className="inline-flex items-center gap-2 px-6 py-3 text-sm font-semibold text-[#0A1128] bg-white rounded-full shadow-sm hover:opacity-90 transition-all"
|
||||
>
|
||||
도입 문의하기
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</ScreenshotProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string, unknown> | undefined;
|
||||
const metadata = state?.metadata as Record<string, unknown> | undefined;
|
||||
const stateSocialHandles = metadata?.socialHandles as Record<string, string | null> | 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 (
|
||||
<div className="min-h-screen flex items-center justify-center pt-20">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-10 h-10 border-4 border-[#6C5CE7] border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-slate-500 text-sm">리포트를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center pt-20">
|
||||
<div className="text-center">
|
||||
<p className="text-[#7C3A4B] font-medium mb-2">오류가 발생했습니다</p>
|
||||
<p className="text-slate-500 text-sm">{error ?? '리포트를 찾을 수 없습니다.'}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScreenshotProvider screenshots={data.screenshots ?? []}>
|
||||
<div className="pt-20">
|
||||
<ReportNav sections={REPORT_SECTIONS} />
|
||||
|
||||
{/* Enrichment status indicator */}
|
||||
{enrichStatus === 'loading' && (
|
||||
<div className="fixed bottom-6 right-6 z-50 flex items-center gap-3 px-4 py-3 bg-white rounded-xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] border border-slate-100">
|
||||
<div className="w-4 h-4 border-2 border-[#6C5CE7] border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-xs text-slate-500">채널 데이터 보강 중...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div data-report-content>
|
||||
<ReportHeader
|
||||
overallScore={data.overallScore}
|
||||
clinicName={data.clinicSnapshot.name}
|
||||
clinicNameEn={data.clinicSnapshot.nameEn}
|
||||
targetUrl={data.targetUrl}
|
||||
date={data.createdAt}
|
||||
location={data.clinicSnapshot.location}
|
||||
logoImage={data.clinicSnapshot.logoImages?.horizontal}
|
||||
brandColors={data.clinicSnapshot.brandColors}
|
||||
/>
|
||||
|
||||
<SectionErrorBoundary><ClinicSnapshot data={data.clinicSnapshot} /></SectionErrorBoundary>
|
||||
|
||||
<SectionErrorBoundary><ChannelOverview channels={data.channelScores} /></SectionErrorBoundary>
|
||||
|
||||
<SectionErrorBoundary><YouTubeAudit data={data.youtubeAudit} /></SectionErrorBoundary>
|
||||
|
||||
<SectionErrorBoundary><InstagramAudit data={data.instagramAudit} /></SectionErrorBoundary>
|
||||
|
||||
<SectionErrorBoundary><FacebookAudit data={data.facebookAudit} /></SectionErrorBoundary>
|
||||
|
||||
<SectionErrorBoundary><OtherChannels
|
||||
channels={data.otherChannels}
|
||||
website={data.websiteAudit}
|
||||
/></SectionErrorBoundary>
|
||||
|
||||
<SectionErrorBoundary><ProblemDiagnosis diagnosis={data.problemDiagnosis} /></SectionErrorBoundary>
|
||||
|
||||
<SectionErrorBoundary><TransformationProposal data={data.transformation} /></SectionErrorBoundary>
|
||||
|
||||
<SectionErrorBoundary><RoadmapTimeline months={data.roadmap} /></SectionErrorBoundary>
|
||||
|
||||
<SectionErrorBoundary><KPIDashboard metrics={data.kpiDashboard} clinicName={data.clinicSnapshot.name} /></SectionErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</ScreenshotProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center pt-20">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-10 h-10 border-4 border-[#6C5CE7] border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-slate-500 text-sm">리포트를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center pt-20">
|
||||
<div className="text-center">
|
||||
<p className="text-[#7C3A4B] font-medium mb-2">오류가 발생했습니다</p>
|
||||
<p className="text-slate-500 text-sm">{error ?? '리포트를 찾을 수 없습니다.'}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScreenshotProvider screenshots={data.screenshots ?? []}>
|
||||
<div className="pt-20">
|
||||
<ReportNav
|
||||
sections={REPORT_SECTIONS}
|
||||
leftSlot={
|
||||
<Link
|
||||
to={`/clinics/${clinicId}`}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-medium text-slate-500 hover:text-primary-900 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
워크스페이스로
|
||||
</Link>
|
||||
}
|
||||
rightSlot={
|
||||
<div className="flex items-center gap-2">
|
||||
<DownloadMenuButton
|
||||
filename={`${data.clinicSnapshot.name}_Marketing_Intelligence_Report`}
|
||||
report={data}
|
||||
/>
|
||||
<Link
|
||||
to={`/report/loading`}
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-xs font-medium text-slate-600 bg-slate-50 border border-slate-200 hover:bg-white hover:border-slate-300 transition-all"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
다시 분석
|
||||
</Link>
|
||||
<Link
|
||||
to={`/clinics/${clinicId}/plan/${id}`}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-full text-xs font-semibold text-white bg-gradient-to-r from-[#4F1DA1] to-[#021341] shadow-sm hover:opacity-90 transition-all"
|
||||
>
|
||||
<Sparkles size={12} />
|
||||
마케팅 기획 보기
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{enrichStatus === 'loading' && (
|
||||
<div className="fixed bottom-6 right-6 z-50 flex items-center gap-3 px-4 py-3 bg-white rounded-xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] border border-slate-100">
|
||||
<div className="w-4 h-4 border-2 border-[#6C5CE7] border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-xs text-slate-500">채널 데이터 보강 중...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ReportBody data={data} />
|
||||
</div>
|
||||
</ScreenshotProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -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: <AnalysisLoadingPage /> },
|
||||
{ path: 'report/loading/:reportId', element: <AnalysisLoadingPage /> },
|
||||
{ path: 'report/:id', element: <ReportPage /> },
|
||||
// 손님(랜딩→분석) 흐름. 유저 워크스페이스 경로는 features/clinics/routes.tsx 참조.
|
||||
{ path: 'report/:id', element: <GuestReportPage /> },
|
||||
]
|
||||
|
|
|
|||
|
|
@ -166,7 +166,6 @@ export interface OtherChannel {
|
|||
export interface TrackingPixel {
|
||||
name: string;
|
||||
installed: boolean;
|
||||
details?: string;
|
||||
}
|
||||
|
||||
export interface WebsiteAudit {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="min-h-screen pt-24 pb-32 px-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<PageContainer className="max-w-5xl px-0">
|
||||
{/* Progress Bar */}
|
||||
<div className="flex items-center justify-center mb-12">
|
||||
{steps.map((s, i) => (
|
||||
|
|
@ -231,7 +232,7 @@ export default function StudioWizard() {
|
|||
<div />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<T> {
|
||||
visible: T[];
|
||||
hasMore: boolean;
|
||||
sentinelRef: (node: Element | null) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export function useInfiniteList<T>(
|
||||
items: T[],
|
||||
{ initialSize = 10, pageSize = 10, rootMargin = '200px' }: UseInfiniteListOptions = {},
|
||||
): UseInfiniteListResult<T> {
|
||||
const [count, setCount] = useState(initialSize);
|
||||
const observerRef = useRef<IntersectionObserver | null>(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 };
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style}>
|
||||
<rect x="2" y="4" width="20" height="16" rx="4" fill="currentColor" opacity="0.25" />
|
||||
<path d="M10 8.5v7l6-3.5-6-3.5z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function InstagramFilled({ size = 20, className = '' }: IconProps) {
|
||||
export function InstagramFilled({ size = 20, className = '', style }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style}>
|
||||
<rect x="2" y="2" width="20" height="20" rx="6" fill="currentColor" opacity="0.25" />
|
||||
<circle cx="12" cy="12" r="4.5" fill="currentColor" />
|
||||
<circle cx="17.5" cy="6.5" r="1.5" fill="currentColor" />
|
||||
|
|
@ -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 (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style}>
|
||||
<rect x="2" y="2" width="20" height="20" rx="6" fill="currentColor" opacity="0.25" />
|
||||
<path d="M15.5 3.5H13.5C11.29 3.5 9.5 5.29 9.5 7.5V9.5H7.5V12.5H9.5V20.5H12.5V12.5H14.5L15.5 9.5H12.5V7.5C12.5 7.22 12.72 7 13 7H15.5V3.5Z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function GlobeFilled({ size = 20, className = '' }: IconProps) {
|
||||
export function GlobeFilled({ size = 20, className = '', style }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style}>
|
||||
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.2" />
|
||||
<ellipse cx="12" cy="12" rx="4" ry="10" fill="currentColor" opacity="0.35" />
|
||||
<line x1="2" y1="12" x2="22" y2="12" stroke="currentColor" strokeWidth="1.5" opacity="0.5" />
|
||||
|
|
@ -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 (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style}>
|
||||
<rect x="1" y="5" width="15" height="14" rx="3" fill="currentColor" opacity="0.25" />
|
||||
<path d="M16 9.5L22 6.5V17.5L16 14.5V9.5Z" fill="currentColor" />
|
||||
<rect x="1" y="5" width="15" height="14" rx="3" fill="currentColor" opacity="0.5" />
|
||||
|
|
@ -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 (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style}>
|
||||
<path d="M4 4H20C21.1 4 22 4.9 22 6V16C22 17.1 21.1 18 20 18H6L2 22V6C2 4.9 2.9 4 4 4Z" fill="currentColor" opacity="0.3" />
|
||||
<path d="M4 4H20C21.1 4 22 4.9 22 6V16C22 17.1 21.1 18 20 18H6L2 22V6C2 4.9 2.9 4 4 4Z" fill="currentColor" opacity="0.4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CalendarFilled({ size = 20, className = '' }: IconProps) {
|
||||
export function CalendarFilled({ size = 20, className = '', style }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style}>
|
||||
<rect x="3" y="4" width="18" height="18" rx="3" fill="currentColor" opacity="0.25" />
|
||||
<rect x="3" y="4" width="18" height="6" rx="3" fill="currentColor" opacity="0.5" />
|
||||
<circle cx="8" cy="15" r="1.2" fill="currentColor" />
|
||||
|
|
@ -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 (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style}>
|
||||
<path d="M6 2H14L20 8V20C20 21.1 19.1 22 18 22H6C4.9 22 4 21.1 4 20V4C4 2.9 4.9 2 6 2Z" fill="currentColor" opacity="0.25" />
|
||||
<path d="M14 2L20 8H16C14.9 8 14 7.1 14 6V2Z" fill="currentColor" opacity="0.5" />
|
||||
<rect x="8" y="12" width="8" height="1.5" rx="0.75" fill="currentColor" />
|
||||
|
|
@ -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 (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style}>
|
||||
<circle cx="18" cy="5" r="3.5" fill="currentColor" opacity="0.4" />
|
||||
<circle cx="6" cy="12" r="3.5" fill="currentColor" opacity="0.4" />
|
||||
<circle cx="18" cy="19" r="3.5" fill="currentColor" opacity="0.4" />
|
||||
|
|
@ -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 (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style}>
|
||||
<path d="M19 3L8 8H4C2.9 8 2 8.9 2 10V14C2 15.1 2.9 16 4 16H5L7 21H10L8 16L19 21V3Z" fill="currentColor" opacity="0.35" />
|
||||
<path d="M21 10C22.1 10 23 10.9 23 12C23 13.1 22.1 14 21 14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" opacity="0.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TiktokFilled({ size = 20, className = '' }: IconProps) {
|
||||
export function TiktokFilled({ size = 20, className = '', style }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style}>
|
||||
<rect x="2" y="2" width="20" height="20" rx="6" fill="currentColor" opacity="0.25" />
|
||||
<path d="M16.5 4.5V12.5C16.5 15.26 14.26 17.5 11.5 17.5C8.74 17.5 6.5 15.26 6.5 12.5C6.5 9.74 8.74 7.5 11.5 7.5V10C10.12 10 9 11.12 9 12.5C9 13.88 10.12 15 11.5 15C12.88 15 14 13.88 14 12.5V4.5H16.5Z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MusicFilled({ size = 20, className = '' }: IconProps) {
|
||||
export function MusicFilled({ size = 20, className = '', style }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style}>
|
||||
<circle cx="7" cy="17" r="3" fill="currentColor" opacity="0.25" />
|
||||
<circle cx="17" cy="15" r="3" fill="currentColor" opacity="0.25" />
|
||||
<circle cx="7" cy="17" r="2" fill="currentColor" />
|
||||
|
|
@ -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 (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style}>
|
||||
<path d="M12 2L3 7V12C3 17.5 6.8 22.7 12 24C17.2 22.7 21 17.5 21 12V7L12 2Z" fill="currentColor" opacity="0.25" />
|
||||
<path d="M12 2L3 7V12C3 17.5 6.8 22.7 12 24C17.2 22.7 21 17.5 21 12V7L12 2Z" fill="currentColor" opacity="0.3" />
|
||||
<path d="M10 14L8.5 12.5L7.5 13.5L10 16L16.5 9.5L15.5 8.5L10 14Z" fill="currentColor" />
|
||||
|
|
@ -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 (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style}>
|
||||
<ellipse cx="12" cy="6" rx="8" ry="3" fill="currentColor" opacity="0.35" />
|
||||
<path d="M4 6V12C4 13.66 7.58 15 12 15C16.42 15 20 13.66 20 12V6" fill="currentColor" opacity="0.25" />
|
||||
<path d="M4 12V18C4 19.66 7.58 21 12 21C16.42 21 20 19.66 20 18V12" fill="currentColor" opacity="0.35" />
|
||||
|
|
@ -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 (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style}>
|
||||
<rect x="3" y="2" width="18" height="8" rx="2" fill="currentColor" opacity="0.3" />
|
||||
<rect x="3" y="14" width="18" height="8" rx="2" fill="currentColor" opacity="0.3" />
|
||||
<circle cx="7" cy="6" r="1.5" fill="currentColor" />
|
||||
|
|
@ -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 (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style}>
|
||||
<path d="M13 2L4 14H11L10 22L20 10H13L13 2Z" fill="currentColor" opacity="0.25" />
|
||||
<path d="M13 2L4 14H11L10 22L20 10H13L13 2Z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function EyeFilled({ size = 20, className = '' }: IconProps) {
|
||||
export function EyeFilled({ size = 20, className = '', style }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style}>
|
||||
<path d="M12 4.5C7 4.5 2.73 7.61 1 12C2.73 16.39 7 19.5 12 19.5C17 19.5 21.27 16.39 23 12C21.27 7.61 17 4.5 12 4.5Z" fill="currentColor" opacity="0.2" />
|
||||
<circle cx="12" cy="12" r="4" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function EyeOffFilled({ size = 20, className = '' }: IconProps) {
|
||||
export function EyeOffFilled({ size = 20, className = '', style }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style}>
|
||||
<path d="M12 4.5C7 4.5 2.73 7.61 1 12C2.73 16.39 7 19.5 12 19.5C17 19.5 21.27 16.39 23 12C21.27 7.61 17 4.5 12 4.5Z" fill="currentColor" opacity="0.15" />
|
||||
<circle cx="12" cy="12" r="4" fill="currentColor" opacity="0.3" />
|
||||
<line x1="4" y1="4" x2="20" y2="20" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||
|
|
@ -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 (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style}>
|
||||
<rect x="8" y="8" width="12" height="14" rx="2" fill="currentColor" opacity="0.25" />
|
||||
<rect x="4" y="2" width="12" height="14" rx="2" fill="currentColor" opacity="0.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CheckFilled({ size = 20, className = '' }: IconProps) {
|
||||
export function CheckFilled({ size = 20, className = '', style }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style}>
|
||||
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.25" />
|
||||
<path d="M8 12L11 15L16 9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CrossFilled({ size = 20, className = '' }: IconProps) {
|
||||
export function CrossFilled({ size = 20, className = '', style }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style}>
|
||||
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.25" />
|
||||
<path d="M8 8L16 16M16 8L8 16" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function WarningFilled({ size = 20, className = '' }: IconProps) {
|
||||
export function WarningFilled({ size = 20, className = '', style }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style}>
|
||||
<path d="M12 2L1 21H23L12 2Z" fill="currentColor" opacity="0.25" />
|
||||
<rect x="11" y="9" width="2" height="6" rx="1" fill="currentColor" />
|
||||
<circle cx="12" cy="18" r="1.2" fill="currentColor" />
|
||||
|
|
@ -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 (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style}>
|
||||
<circle cx="12" cy="12" r="9" fill="currentColor" opacity="0.15" />
|
||||
<path d="M17.65 6.35A7.95 7.95 0 0012 4C7.58 4 4 7.58 4 12C4 16.42 7.58 20 12 20C15.73 20 18.84 17.45 19.73 14H17.65C16.83 16.33 14.61 18 12 18C8.69 18 6 15.31 6 12C6 8.69 8.69 6 12 6C13.66 6 15.14 6.69 16.22 7.78L13 11H20V4L17.65 6.35Z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function FlowFilled({ size = 20, className = '' }: IconProps) {
|
||||
export function FlowFilled({ size = 20, className = '', style }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style}>
|
||||
<rect x="2" y="3" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.35" />
|
||||
<rect x="9" y="9" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.5" />
|
||||
<rect x="16" y="15" width="6" height="6" rx="1.5" fill="currentColor" />
|
||||
|
|
@ -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 (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style}>
|
||||
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.25" />
|
||||
<circle cx="12" cy="12" r="7" fill="currentColor" opacity="0.35" />
|
||||
<text x="12" y="16" textAnchor="middle" fontSize="10" fontWeight="bold" fill="currentColor">$</text>
|
||||
|
|
@ -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 (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style}>
|
||||
<rect x="3" y="5" width="14" height="14" rx="2" fill="currentColor" opacity="0.2" />
|
||||
<path d="M14 3H21V10" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M21 3L10 14" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||
|
|
@ -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 (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style}>
|
||||
<rect x="3" y="16" width="18" height="5" rx="1.5" fill="currentColor" opacity="0.25" />
|
||||
<path d="M12 3v10M8 10l4 4 4-4" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function RocketFilled({ size = 20, className = '' }: IconProps) {
|
||||
export function RocketFilled({ size = 20, className = '', style }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style}>
|
||||
<ellipse cx="12" cy="9" rx="5" ry="7" fill="currentColor" opacity="0.25" />
|
||||
<path d="M12 2C9 2 6 5 6 9c0 3 1.5 5.5 3 7h6c1.5-1.5 3-4 3-7 0-4-3-7-6-7z" fill="currentColor" />
|
||||
<path d="M9 16l-2 4h10l-2-4" fill="currentColor" opacity="0.4" />
|
||||
|
|
@ -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 (
|
||||
<svg width={w} height={h} viewBox="0 0 28 20" fill="none" className={className}>
|
||||
<svg width={w} height={h} viewBox="0 0 28 20" fill="none" className={className} style={style}>
|
||||
<defs>
|
||||
<linearGradient id={id} x1="0" y1="0" x2="28" y2="20" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stopColor="currentColor" stopOpacity="0.9" />
|
||||
|
|
|
|||
|
|
@ -1,21 +1,32 @@
|
|||
import { Link } from 'react-router';
|
||||
import { PageContainer } from '@/shared/ui/page-container';
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="bg-white border-t border-slate-100 py-12 px-6">
|
||||
<div className="max-w-7xl mx-auto flex flex-col md:flex-row items-center justify-between gap-6">
|
||||
<PageContainer className="px-0 flex flex-col gap-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<span className="font-serif text-3xl font-black tracking-[0.05em] bg-gradient-to-r from-[#4F1DA1] to-[#021341] bg-clip-text text-transparent">INFINITH</span>
|
||||
</Link>
|
||||
<div className="text-sm text-slate-500 text-center md:text-left">
|
||||
© {new Date().getFullYear()} INFINITH. All rights reserved. <br className="md:hidden" />
|
||||
Infinite Marketing for Premium Medical Business & Marketing Agency
|
||||
</div>
|
||||
<div className="flex items-center gap-6 text-sm font-medium text-slate-600">
|
||||
<a href="#" className="hover:text-primary-900 transition-colors">Privacy Policy</a>
|
||||
<a href="#" className="hover:text-primary-900 transition-colors">Terms of Service</a>
|
||||
<div className="flex items-center gap-3 text-sm font-medium text-slate-600">
|
||||
<a href="#" className="hover:text-primary-900 transition-colors">개인정보처리방침</a>
|
||||
<span className="text-slate-300">·</span>
|
||||
<a href="#" className="hover:text-primary-900 transition-colors">이용약관</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 leading-relaxed space-y-1">
|
||||
<p className="font-medium text-slate-600">㈜에이아이오투오</p>
|
||||
<p>사업자 등록번호 : 620-87-00810 | 대표 : 안성민</p>
|
||||
<p>본사 : 41593 대구광역시 북구 옥산로 111, 5층 유니콘랩 대구 A05호</p>
|
||||
<p>연구소 : 13453 경기 성남시 수정구 금토로 32 (금토동) (주)KT 판교빌딩 504호~505호 (East)</p>
|
||||
<p>전화 : 070-4260-8310 | 010-2755-6463</p>
|
||||
<p>이메일 : o2oteam@o2o.kr</p>
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 border-t border-slate-100 pt-4">
|
||||
Copyright ⓒ O2O Inc. All rights reserved
|
||||
</div>
|
||||
</PageContainer>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="min-h-screen bg-slate-50 selection:bg-purple-200 selection:text-primary-900">
|
||||
<div className="min-h-screen flex flex-col bg-slate-50 selection:bg-purple-200 selection:text-primary-900">
|
||||
{!isLoadingPage && <Navbar />}
|
||||
<main className="flex-1 flex flex-col">
|
||||
<Outlet />
|
||||
</main>
|
||||
{!isLoadingPage && <Footer />}
|
||||
<ScrollRestoration />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<nav className="fixed top-0 left-0 right-0 z-50 bg-white/95 border-b border-slate-100 backdrop-blur-md">
|
||||
<div className="max-w-7xl mx-auto px-6 h-20 flex items-center justify-between">
|
||||
<PageContainer className="h-20 flex items-center justify-between">
|
||||
{/* 로고 */}
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<span className="font-serif text-3xl font-black tracking-[0.05em] bg-gradient-to-r from-[#4F1DA1] to-[#021341] bg-clip-text text-transparent">
|
||||
|
|
@ -33,30 +34,43 @@ export default function Navbar() {
|
|||
</span>
|
||||
</Link>
|
||||
|
||||
{/* 가운데 메뉴 */}
|
||||
<div className="hidden md:flex items-center gap-8 text-sm font-medium text-slate-600">
|
||||
{/* 가운데 메뉴 — 랜딩 페이지 섹션 순서 그대로 */}
|
||||
<div className="hidden md:flex items-center gap-7 text-sm font-medium text-slate-600">
|
||||
<a href="/#home" className="hover:text-primary-900 transition-colors">
|
||||
Home
|
||||
</a>
|
||||
<a href="/#audience" className="hover:text-primary-900 transition-colors">
|
||||
Audience
|
||||
</a>
|
||||
<a href="/#problems" className="hover:text-primary-900 transition-colors">
|
||||
Problems
|
||||
</a>
|
||||
<a href="/#solution" className="hover:text-primary-900 transition-colors">
|
||||
Product
|
||||
</a>
|
||||
<Link
|
||||
to="/pricing?from=header"
|
||||
className="hover:text-primary-900 transition-colors"
|
||||
>
|
||||
Pricing
|
||||
</Link>
|
||||
<a href="/#modules" className="hover:text-primary-900 transition-colors">
|
||||
Modules
|
||||
</a>
|
||||
<a href="/#use-cases" className="hover:text-primary-900 transition-colors">
|
||||
Use Cases
|
||||
</a>
|
||||
{/* Pricing 메뉴 임시 비활성
|
||||
<Link to="/pricing?from=header" className="hover:text-primary-900 transition-colors">
|
||||
Pricing
|
||||
</Link>
|
||||
*/}
|
||||
</div>
|
||||
|
||||
{/* 우측 CTA — Login(Secondary) + 문의하기(Primary) */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Login 버튼 임시 비활성
|
||||
<Link
|
||||
to="/login"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-semibold text-[#021341] bg-white border border-slate-200 rounded-full hover:bg-slate-50 hover:border-slate-300 transition-colors"
|
||||
>
|
||||
Login
|
||||
</Link>
|
||||
*/}
|
||||
<a
|
||||
href={buildContactMailto('도입 문의')}
|
||||
className="inline-flex items-center gap-2 px-6 py-3 text-sm font-semibold text-white rounded-full transition-all shadow-sm hover:shadow-md bg-gradient-to-r from-[#4F1DA1] to-[#021341] hover:opacity-90"
|
||||
|
|
@ -65,7 +79,7 @@ export default function Navbar() {
|
|||
<ArrowRight className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<PlatformKey, PlatformMeta> = {
|
||||
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 (
|
||||
<div className={`flex flex-wrap gap-2 ${className ?? ''}`}>
|
||||
{entries.map(({ key, handle }) => {
|
||||
const meta = META[key];
|
||||
const Icon = meta.Icon;
|
||||
const url = meta.buildUrl(handle);
|
||||
return (
|
||||
<a
|
||||
key={key}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`inline-flex items-center gap-2 rounded-full px-3.5 py-2 text-xs font-medium transition-colors ${baseClass}`}
|
||||
title={`${meta.label} — ${url}`}
|
||||
>
|
||||
<Icon size={16} className="shrink-0" style={{ color: meta.color }} />
|
||||
<span className="truncate max-w-[140px]">{handle}</span>
|
||||
<ExternalLink size={12} className="text-slate-400" />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger(props: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 6,
|
||||
align = "end",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
className={cn(
|
||||
"z-50 min-w-[10rem] overflow-hidden rounded-xl border border-slate-200 bg-white p-1 text-slate-700 shadow-lg",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
className={cn(
|
||||
"relative flex cursor-pointer select-none items-center gap-2 rounded-lg px-3 py-2 text-sm outline-none transition-colors hover:bg-slate-100 focus:bg-slate-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-slate-100", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* EmptyState — 리포트/플랜의 섹션·서브섹션에서 데이터가 없을 때 공통으로 쓰는 fallback.
|
||||
*
|
||||
* size:
|
||||
* - sm: 카드 안 작은 영역 (서브섹션, 그래프 자리 등)
|
||||
* - md: 섹션 내부 일반 영역 (기본값)
|
||||
* - lg: SectionWrapper 전체를 채우는 경우
|
||||
*
|
||||
* 사용 예:
|
||||
* {nonEmpty(items) ? <List items={items} /> : <EmptyState size="sm" />}
|
||||
*/
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
'border border-dashed border-slate-200 bg-slate-50/50 text-center',
|
||||
sizeMap[size],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<p className={cn('text-slate-500', textSizeMap[size])}>데이터 없음</p>
|
||||
{hint && <p className="text-xs text-slate-400 mt-1">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<Comp
|
||||
data-slot="page-container"
|
||||
className={cn('mx-auto w-full max-w-7xl px-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { PageContainer }
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,5 +19,5 @@
|
|||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
"include": ["src", "vite.config.ts", "orval.config.ts"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue