feat: 클리닉 전용 페이지 추가, PageContainer 도입, 불필요한 ui 주석처리

main
Mina Choi 2026-05-14 11:53:29 +09:00
parent e66b208318
commit 49367756ea
77 changed files with 3498 additions and 924 deletions

View File

@ -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',

View File

@ -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>
)
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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" />
)}

View File

@ -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>
);
}

View File

@ -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 };

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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' },
],
};
}),
};

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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 /> },
]

View File

@ -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[];
}

View File

@ -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 */}

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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 && (

View File

@ -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>
);
}

View File

@ -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,101 +138,126 @@ function VisualIdentityTab({ data }: { data: BrandGuide }) {
{/* Color Palette */}
<div>
<h3 className="font-serif font-bold text-2xl text-brand-navy mb-4">Color Palette</h3>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
{colors.map((swatch: ColorSwatch, idx: number) => (
<ColorSwatchCard
key={swatch.hex + idx}
swatch={swatch}
onUpdate={(newHex) => handleColorUpdate(idx, newHex)}
/>
))}
</div>
{colors.length > 0 ? (
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
{colors.map((swatch: ColorSwatch, idx: number) => (
<ColorSwatchCard
key={swatch.hex + idx}
swatch={swatch}
onUpdate={(newHex) => handleColorUpdate(idx, newHex)}
/>
))}
</div>
) : (
<EmptyState size="md" />
)}
</div>
{/* Typography */}
<div>
<h3 className="font-serif font-bold text-2xl text-brand-navy mb-4">Typography</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{data.fonts.map((spec) => (
<div
key={`${spec.family}-${spec.weight}`}
className="rounded-2xl border border-slate-100 p-5"
>
<p className="text-sm text-slate-500 uppercase tracking-wide mb-2">
{spec.family}
</p>
<p
className={`mb-3 text-brand-navy ${
spec.weight.toLowerCase().includes('bold')
? 'text-2xl font-bold'
: 'text-lg'
}`}
style={{ fontFamily: spec.family }}
{data.fonts.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{data.fonts.map((spec) => (
<div
key={`${spec.family}-${spec.weight}`}
className="rounded-2xl border border-slate-100 p-5"
>
{spec.sampleText}
</p>
<p className="text-xs text-slate-500">
<span className="font-medium text-slate-700">{spec.weight}</span> &middot;{' '}
{spec.usage}
</p>
</div>
))}
</div>
<p className="text-sm text-slate-500 uppercase tracking-wide mb-2">
{spec.family}
</p>
<p
className={`mb-3 text-brand-navy ${
spec.weight.toLowerCase().includes('bold')
? 'text-2xl font-bold'
: 'text-lg'
}`}
style={{ fontFamily: spec.family }}
>
{spec.sampleText}
</p>
<p className="text-xs text-slate-500">
<span className="font-medium text-slate-700">{spec.weight}</span> &middot;{' '}
{spec.usage}
</p>
</div>
))}
</div>
) : (
<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>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* DO Column */}
<div>
<div className="flex items-center gap-2 mb-3">
<CheckFilled size={18} className="text-brand-purple-soft" />
<span className="font-semibold text-brand-purple-muted text-sm uppercase tracking-widest">DO</span>
</div>
<div className="space-y-3">
{data.logoRules.filter((r) => r.correct).map((rule) => (
<div
key={rule.rule}
className="rounded-2xl p-4 border-2 border-brand-tint-lavender bg-brand-tint-purple/40"
>
<div className="flex items-start gap-3">
<CheckFilled size={16} className="text-brand-purple-soft shrink-0 mt-0.5" />
<div>
<p className="font-semibold text-brand-navy text-sm">{rule.rule}</p>
<p className="text-xs text-slate-600 mt-1">{rule.description}</p>
{data.logoRules.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* DO Column */}
<div>
<div className="flex items-center gap-2 mb-3">
<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
key={rule.rule}
className="rounded-2xl p-4 border-2 border-brand-tint-lavender bg-brand-tint-purple/40"
>
<div className="flex items-start gap-3">
<CheckFilled size={16} className="text-brand-purple-soft shrink-0 mt-0.5" />
<div>
<p className="font-semibold text-brand-navy text-sm">{rule.rule}</p>
<p className="text-xs text-slate-600 mt-1">{rule.description}</p>
</div>
</div>
</div>
</div>
))}
</div>
))}
) : (
<EmptyState size="sm" />
)}
</div>
</div>
{/* DON'T Column */}
<div>
<div className="flex items-center gap-2 mb-3">
<CrossFilled size={18} className="text-brand-rose-mid" />
<span className="font-semibold text-brand-rose text-sm uppercase tracking-widest">DON&apos;T</span>
</div>
<div className="space-y-3">
{data.logoRules.filter((r) => !r.correct).map((rule) => (
<div
key={rule.rule}
className="rounded-2xl p-4 border-2 border-brand-rose-soft bg-brand-rose-bg/40"
>
<div className="flex items-start gap-3">
<CrossFilled size={16} className="text-brand-rose-mid shrink-0 mt-0.5" />
<div>
<p className="font-semibold text-brand-navy text-sm">{rule.rule}</p>
<p className="text-xs text-slate-600 mt-1">{rule.description}</p>
{/* DON'T Column */}
<div>
<div className="flex items-center gap-2 mb-3">
<CrossFilled size={18} className="text-brand-rose-mid" />
<span className="font-semibold text-brand-rose text-sm uppercase tracking-widest">DON&apos;T</span>
</div>
{data.logoRules.some((r) => !r.correct) ? (
<div className="space-y-3">
{data.logoRules.filter((r) => !r.correct).map((rule) => (
<div
key={rule.rule}
className="rounded-2xl p-4 border-2 border-brand-rose-soft bg-brand-rose-bg/40"
>
<div className="flex items-start gap-3">
<CrossFilled size={16} className="text-brand-rose-mid shrink-0 mt-0.5" />
<div>
<p className="font-semibold text-brand-navy text-sm">{rule.rule}</p>
<p className="text-xs text-slate-600 mt-1">{rule.description}</p>
</div>
</div>
</div>
</div>
))}
</div>
))}
) : (
<EmptyState size="sm" />
)}
</div>
</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,26 +273,34 @@ function ToneVoiceTab({ tone }: { tone: BrandGuide['toneOfVoice'] }) {
{/* Personality */}
<div>
<h3 className="font-serif font-bold text-2xl text-brand-navy mb-4">Personality</h3>
<div className="flex flex-wrap gap-2">
{tone.personality.map((trait) => (
<span
key={trait}
className="bg-gradient-to-r from-brand-purple/10 to-brand-purple-deep/10 text-brand-purple border border-purple-200 rounded-full px-4 py-2 font-medium text-sm"
>
{trait}
</span>
))}
</div>
{tone.personality && tone.personality.length > 0 ? (
<div className="flex flex-wrap gap-2">
{tone.personality.map((trait) => (
<span
key={trait}
className="bg-gradient-to-r from-brand-purple/10 to-brand-purple-deep/10 text-brand-purple border border-purple-200 rounded-full px-4 py-2 font-medium text-sm"
>
{trait}
</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>
<div className="rounded-2xl bg-slate-50 p-6">
<p className="text-base leading-relaxed text-slate-700">
{tone.communicationStyle}
</p>
</div>
{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,31 +309,39 @@ 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>
<div className="space-y-3">
{tone.doExamples.map((example, i) => (
<div
key={i}
className="border-l-4 border-brand-purple-soft bg-brand-tint-purple/30 p-4 rounded-r-lg"
>
<p className="text-sm text-slate-700">{example}</p>
</div>
))}
</div>
{tone.doExamples && tone.doExamples.length > 0 ? (
<div className="space-y-3">
{tone.doExamples.map((example, i) => (
<div
key={i}
className="border-l-4 border-brand-purple-soft bg-brand-tint-purple/30 p-4 rounded-r-lg"
>
<p className="text-sm text-slate-700">{example}</p>
</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&apos;T
</h4>
<div className="space-y-3">
{tone.dontExamples.map((example, i) => (
<div
key={i}
className="border-l-4 border-brand-rose-mid bg-brand-rose-bg/30 p-4 rounded-r-lg"
>
<p className="text-sm text-slate-700">{example}</p>
</div>
))}
</div>
{tone.dontExamples && tone.dontExamples.length > 0 ? (
<div className="space-y-3">
{tone.dontExamples.map((example, i) => (
<div
key={i}
className="border-l-4 border-brand-rose-mid bg-brand-rose-bg/30 p-4 rounded-r-lg"
>
<p className="text-sm text-slate-700">{example}</p>
</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' && (

View File

@ -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,39 +206,49 @@ export default function ContentStrategy({ data }: ContentStrategyProps) {
{/* Tab 4: Repurposing */}
{activeTab === 'repurposing' && (
<motion.div
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
{/* Source Card */}
<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>
(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">
<div className="w-px h-8 bg-gradient-to-b from-brand-purple to-slate-200" />
</div>
</div>
{/* Connector */}
<div className="flex justify-center mb-6">
<div className="w-px h-8 bg-gradient-to-b from-brand-purple to-slate-200" />
</div>
{/* Outputs Grid */}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-3">
{data.repurposingOutputs.map((output, i) => (
<motion.div
key={`${output.format}-${i}`}
className="rounded-xl border border-slate-100 bg-white p-4"
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: i * 0.05 }}
>
<p className="font-semibold text-sm text-brand-navy mb-1">{output.format}</p>
<p className="text-xs text-slate-500 mb-1">{output.channel}</p>
<p className="text-xs text-slate-600">{output.description}</p>
</motion.div>
))}
</div>
</motion.div>
{/* 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
key={`${output.format}-${i}`}
className="rounded-xl border border-slate-100 bg-white p-4"
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: i * 0.05 }}
>
<p className="font-semibold text-sm text-brand-navy mb-1">{output.format}</p>
<p className="text-xs text-slate-500 mb-1">{output.channel}</p>
<p className="text-xs text-slate-600">{output.description}</p>
</motion.div>
))}
</div>
) : (
<EmptyState size="sm" />
)}
</motion.div>
) : (
<EmptyState size="md" />
)
)}
</SectionWrapper>
);

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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 /> },
]

View File

@ -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">
<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 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">
<FeatureComparisonTable />
</section>
<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">
<ContactFirstBox />
</section>
<PageContainer asChild className="mb-16">
<section>
<ContactFirstBox />
</section>
</PageContainer>
{/* ── Section 6 · Launch Promotion ───────────── */}
<section className="px-6 mb-20 max-w-7xl mx-auto">
<PromotionBanner />
</section>
<PageContainer asChild className="mb-20">
<section>
<PromotionBanner />
</section>
</PageContainer>
{/* ── Section 7 · FAQ ─────────────────────────── */}
<section className="px-6 mb-20 max-w-7xl mx-auto">
<FAQ />
</section>
<PageContainer asChild className="mb-20">
<section>
<FAQ />
</section>
</PageContainer>
{/* ── Section 8 · Enterprise Contact ─────────── */}
<section className="px-6 max-w-7xl mx-auto">
<EnterpriseContact />
</section>
<PageContainer asChild>
<section>
<EnterpriseContact />
</section>
</PageContainer>
</main>
);
}

View File

@ -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>
))}

View File

@ -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>
);
}

View File

@ -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>
))}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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,33 +59,45 @@ export function ReportNav({ sections }: ReportNavProps) {
return (
<nav data-report-nav className="sticky top-20 z-40 bg-white/95 border-b border-slate-100">
<div
ref={navRef}
className="max-w-7xl mx-auto flex overflow-x-auto scrollbar-hide"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{sections.map(({ id, label }) => (
<Button
type="button"
variant="ghost"
key={id}
ref={(el) => {
if (el) tabRefs.current.set(id, el);
}}
onClick={() => handleClick(id)}
className={`
shrink-0 px-4 py-3 h-auto text-sm font-medium whitespace-nowrap
border-b-2 rounded-none hover:bg-transparent
${activeId === id
? 'border-brand-purple-vivid text-brand-navy hover:text-brand-navy'
: 'border-transparent text-slate-500 hover:text-slate-700'
}
`}
>
{label}
</Button>
))}
</div>
<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="flex-1 min-w-0 flex overflow-x-auto scrollbar-hide"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{sections.map(({ id, label }) => (
<Button
type="button"
variant="ghost"
key={id}
ref={(el) => {
if (el) tabRefs.current.set(id, el);
}}
onClick={() => handleClick(id)}
className={`
shrink-0 px-4 py-3 h-auto text-sm font-medium whitespace-nowrap
border-b-2 rounded-none hover:bg-transparent
${activeId === id
? 'border-brand-purple-vivid text-brand-navy hover:text-brand-navy'
: 'border-transparent text-slate-500 hover:text-slate-700'
}
`}
>
{label}
</Button>
))}
</div>
{rightSlot && (
<div className="shrink-0 pr-4 md:pr-6" data-no-print>
{rightSlot}
</div>
)}
</PageContainer>
</nav>
);
}

View File

@ -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>
))}

View File

@ -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>
);
}

View File

@ -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">

View File

@ -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>
);
}

View File

@ -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: '전화 + 카카오톡 상담 + 온라인 예약',
},

View File

@ -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: '전화 상담 + 온라인 예약',

View File

@ -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: '전화 상담 + 온라인 예약',

View File

@ -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: '전화 상담 + 카카오톡',
},

View File

@ -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 (글로벌)',
},

View File

@ -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: '전화 상담 + 카카오톡 상담',

View File

@ -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: '전화 상담 + 다국어 온라인 예약',
},

View File

@ -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 };
}

View File

@ -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 };

View File

@ -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,
};
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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 /> },
]

View File

@ -166,7 +166,6 @@ export interface OtherChannel {
export interface TrackingPixel {
name: string;
installed: boolean;
details?: string;
}
export interface WebsiteAudit {

View File

@ -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>
);
}

View File

@ -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 };
}

View File

@ -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" />

View File

@ -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">
<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">
&copy; {new Date().getFullYear()} INFINITH. All rights reserved. <br className="md:hidden" />
Infinite Marketing for Premium Medical Business & Marketing Agency
<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="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="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="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>
<div className="text-xs text-slate-400 border-t border-slate-100 pt-4">
Copyright O2O Inc. All rights reserved
</div>
</PageContainer>
</footer>
);
}

View File

@ -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 />}
<Outlet />
<main className="flex-1 flex flex-col">
<Outlet />
</main>
{!isLoadingPage && <Footer />}
<ScrollRestoration />
</div>
)
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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,
}

View File

@ -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>
);
}

View File

@ -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 }

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -19,5 +19,5 @@
"@/*": ["./src/*"]
}
},
"include": ["src", "vite.config.ts"]
"include": ["src", "vite.config.ts", "orval.config.ts"]
}