feat: 채널 아이콘/링크 통합 + 리포트·플랜 디자인 개선

shared 채널 아이콘 유틸:
- src/shared/icons/channelIcons.ts (resolveChannelIcon — 한글/영문 이름 모두 매핑)
- src/shared/ui/platform-icon.tsx
- channel-link-buttons.tsx 정리

리포트:
- ClinicSnapshot: 외부 채널/플랫폼 링크 버튼(ExternalLinkButtons) 노출
- ReportHeader: 기존 ChannelLinkButtons 제거 (ClinicSnapshot 으로 이관)
- ReportBody: ClinicSnapshot 에 targetUrl·socialHandles 전달, KPIDashboard 에 report 전달
- ChannelOverview: 채널 이름 정규화로 한글 채널도 아이콘/색 매칭
- TransformationProposal: 디자인 다듬기
- GuestReportPage: 하단 '도입 문의' CTA 섹션 제거

플랜:
- BrandingGuide / ChannelStrategy: 공용 resolveChannelIcon 사용으로 중복 제거
- PlanPage: PlanCTA 에 plan 전달 (CSV 내보내기용)

기타:
- MultiChannelInput: 공용 platform-icon 사용
main
Mina Choi 2026-05-20 11:51:18 +09:00
parent 317cd61519
commit 47fed51efc
14 changed files with 339 additions and 127 deletions

View File

@ -56,6 +56,8 @@ export interface AnalyzePayload {
interface MultiChannelInputProps { interface MultiChannelInputProps {
variant?: 'hero' | 'cta'; variant?: 'hero' | 'cta';
onAnalyze: (payload: AnalyzePayload) => void; onAnalyze: (payload: AnalyzePayload) => void;
/** 디폴트 입력값 — /dev/test 등 목업 시나리오용. 미지정 시 빈 폼. */
initialUrls?: Partial<ChannelUrlInputs>;
} }
type ChannelKey = keyof Omit<ClassifiedUrls, 'unknown'>; type ChannelKey = keyof Omit<ClassifiedUrls, 'unknown'>;
@ -108,8 +110,8 @@ function validateField(value: string, expected: ChannelKey): 'empty' | 'valid' |
return 'invalid'; return 'invalid';
} }
export default function MultiChannelInput({ variant = 'hero', onAnalyze }: MultiChannelInputProps) { export default function MultiChannelInput({ variant = 'hero', onAnalyze, initialUrls }: MultiChannelInputProps) {
const [urls, setUrls] = useState<ChannelUrlInputs>(EMPTY_URLS); const [urls, setUrls] = useState<ChannelUrlInputs>(() => ({ ...EMPTY_URLS, ...initialUrls }));
// 통합 분류 결과 — 7개 필드 값을 join해 classifyUrls에 한 번에 통과시켜 manualChannels 구성. // 통합 분류 결과 — 7개 필드 값을 join해 classifyUrls에 한 번에 통과시켜 manualChannels 구성.
const aggregated = useMemo(() => { const aggregated = useMemo(() => {

View File

@ -14,10 +14,10 @@ import { Input } from '@/shared/ui/input';
import { import {
tabItems, tabItems,
type TabKey, type TabKey,
getChannelIcon,
statusColor, statusColor,
statusLabel, statusLabel,
} from '../data/brandingGuideConstants'; } from '../data/brandingGuideConstants';
import { resolveChannelIcon } from '@/shared/icons/channelIcons';
import type { BrandGuide, ColorSwatch } from '@/features/plan/types/plan'; import type { BrandGuide, ColorSwatch } from '@/features/plan/types/plan';
import type { BrandInconsistency } from '@/features/report/types/report'; import type { BrandInconsistency } from '@/features/report/types/report';
@ -364,7 +364,7 @@ function ChannelRulesTab({ channels }: { channels: BrandGuide['channelBranding']
> >
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{channels.map((ch) => { {channels.map((ch) => {
const Icon = getChannelIcon(ch.icon); const Icon = resolveChannelIcon(ch.channel, ch.icon);
return ( return (
<div key={ch.channel} className="rounded-2xl border border-slate-100 p-5"> <div key={ch.channel} className="rounded-2xl border border-slate-100 p-5">
{/* Header */} {/* Header */}

View File

@ -1,15 +1,6 @@
import { type ComponentType } from 'react';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { import { CalendarFilled } from '@/shared/icons/FilledIcons';
YoutubeFilled, import { resolveChannelIcon } from '@/shared/icons/channelIcons';
InstagramFilled,
FacebookFilled,
GlobeFilled,
VideoFilled,
MessageFilled,
CalendarFilled,
TiktokFilled,
} from '@/shared/icons/FilledIcons';
import { SectionWrapper } from '@/features/report/components/ui/SectionWrapper'; import { SectionWrapper } from '@/features/report/components/ui/SectionWrapper';
import type { ChannelStrategyCard } from '@/features/plan/types/plan'; import type { ChannelStrategyCard } from '@/features/plan/types/plan';
@ -17,20 +8,6 @@ interface ChannelStrategyProps {
channels: ChannelStrategyCard[]; channels: ChannelStrategyCard[];
} }
const channelIconMap: Record<string, ComponentType<{ size?: number; className?: string }>> = {
youtube: YoutubeFilled,
instagram: InstagramFilled,
facebook: FacebookFilled,
globe: GlobeFilled,
video: VideoFilled,
messagesquare: MessageFilled,
tiktok: TiktokFilled,
};
function getChannelIcon(icon: string) {
return channelIconMap[icon.toLowerCase()] ?? GlobeFilled;
}
const priorityStyle: Record<string, string> = { const priorityStyle: Record<string, string> = {
P0: 'bg-[#FFF0F0] text-[#7C3A4B] border border-[#F5D5DC] shadow-[2px_3px_8px_rgba(212,136,154,0.15)]', P0: 'bg-[#FFF0F0] text-[#7C3A4B] border border-[#F5D5DC] shadow-[2px_3px_8px_rgba(212,136,154,0.15)]',
P1: 'bg-[#FFF6ED] text-[#7C5C3A] border border-[#F5E0C5] shadow-[2px_3px_8px_rgba(212,168,114,0.15)]', P1: 'bg-[#FFF6ED] text-[#7C5C3A] border border-[#F5E0C5] shadow-[2px_3px_8px_rgba(212,168,114,0.15)]',
@ -47,7 +24,7 @@ export default function ChannelStrategy({ channels }: ChannelStrategyProps) {
> >
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{channels.map((ch, index) => { {channels.map((ch, index) => {
const Icon = getChannelIcon(ch.icon); const Icon = resolveChannelIcon(ch.channelName, ch.icon);
return ( return (
<motion.div <motion.div

View File

@ -75,7 +75,7 @@ export default function PlanPage() {
<MyAssetUpload analysisRunId={id} /> <MyAssetUpload analysisRunId={id} />
</div> </div>
<PlanCTA /> <PlanCTA plan={data} />
</div> </div>
); );
} }

View File

@ -1,6 +1,6 @@
import type { ComponentType, CSSProperties } from 'react'; import type { ComponentType, CSSProperties } from 'react';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { Youtube, Instagram, Globe, Star, Facebook, Search } from 'lucide-react'; import { Youtube, Instagram, Globe, Star, Facebook, Search, MapPin, FileText, Music } from 'lucide-react';
import { SectionWrapper } from './ui/SectionWrapper'; import { SectionWrapper } from './ui/SectionWrapper';
import { ScoreRing } from './ui/ScoreRing'; import { ScoreRing } from './ui/ScoreRing';
import { SeverityBadge } from './ui/SeverityBadge'; import { SeverityBadge } from './ui/SeverityBadge';
@ -17,6 +17,9 @@ const iconMap: Record<string, ComponentType<{ size?: number; className?: string;
star: Star, star: Star,
globe: Globe, globe: Globe,
search: Search, search: Search,
map: MapPin,
blog: FileText,
video: Music,
}; };
const channelLabel: Record<string, string> = { const channelLabel: Record<string, string> = {
@ -38,8 +41,22 @@ const brandColor: Record<string, string> = {
globe: '#6B2D8B', globe: '#6B2D8B',
}; };
function getChannelColor(icon: string | undefined): string | undefined { /** channel 이름 (영문 key 또는 한글) → iconMap·brandColor 의 키로 정규화 */
return brandColor[icon?.toLowerCase() ?? '']; function resolveChannelKey(raw: string | undefined): string | undefined {
if (!raw) return undefined;
const norm = raw.toLowerCase().replace(/\s+/g, '');
// 영문 키
if (norm in iconMap) return norm;
// 한글 별칭
if (norm.includes('유튜브') || norm.includes('youtube')) return 'youtube';
if (norm.includes('인스타') || norm.includes('instagram')) return 'instagram';
if (norm.includes('페이스북') || norm.includes('facebook')) return 'facebook';
if (norm.includes('네이버블로그') || norm.includes('naverblog') || norm.includes('블로그')) return 'blog';
if (norm.includes('네이버플레이스') || norm.includes('naverplace') || norm.includes('플레이스')) return 'map';
if (norm.includes('강남언니') || norm.includes('gangnamunni')) return 'star';
if (norm.includes('틱톡') || norm.includes('tiktok')) return 'video';
if (norm.includes('웹사이트') || norm.includes('website') || norm.includes('홈페이지')) return 'globe';
return undefined;
} }
export default function ChannelOverview({ channels }: ChannelOverviewProps) { export default function ChannelOverview({ channels }: ChannelOverviewProps) {
@ -47,8 +64,10 @@ export default function ChannelOverview({ channels }: ChannelOverviewProps) {
<SectionWrapper id="channel-overview" title="Channel Health Score" subtitle="채널별 건강도 종합"> <SectionWrapper id="channel-overview" title="Channel Health Score" subtitle="채널별 건강도 종합">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
{channels.map((ch, i) => { {channels.map((ch, i) => {
const Icon = iconMap[ch.icon?.toLowerCase()] ?? Globe; // 1) icon 값으로 매칭 → 2) channel 이름 (한글 포함) 으로 fallback
const color = getChannelColor(ch.icon); const key = resolveChannelKey(ch.icon) || resolveChannelKey(ch.channel);
const Icon = key ? iconMap[key] : Globe;
const color = key ? brandColor[key] : undefined;
return ( return (
<motion.div <motion.div
key={ch.channel} key={ch.channel}

View File

@ -1,10 +1,13 @@
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { Calendar, Users, MapPin, Phone, BadgeCheck, Star, Globe, ExternalLink, Building2 } from 'lucide-react'; import { Calendar, Users, MapPin, Phone, BadgeCheck, Star, Globe, ExternalLink, Building2 } from 'lucide-react';
import { SectionWrapper } from './ui/SectionWrapper'; import { SectionWrapper } from './ui/SectionWrapper';
import { ExternalLinkButtons, buildClinicLinks } from './ui/ExternalLinkButtons';
import type { ClinicSnapshot as ClinicSnapshotType } from '@/features/report/types/report'; import type { ClinicSnapshot as ClinicSnapshotType } from '@/features/report/types/report';
interface ClinicSnapshotProps { interface ClinicSnapshotProps {
data: ClinicSnapshotType; data: ClinicSnapshotType;
targetUrl?: string;
socialHandles?: Record<string, string | null> | null;
} }
function formatNumber(n: number): string { function formatNumber(n: number): string {
@ -30,12 +33,26 @@ const infoFields = (data: ClinicSnapshotType): InfoField[] => [
data.registryData?.websiteEn ? { label: '영문 사이트', value: data.registryData.websiteEn, icon: Globe, href: data.registryData.websiteEn } : null, data.registryData?.websiteEn ? { label: '영문 사이트', value: data.registryData.websiteEn, icon: Globe, href: data.registryData.websiteEn } : null,
].filter(Boolean) as InfoField[]; ].filter(Boolean) as InfoField[];
export default function ClinicSnapshot({ data }: ClinicSnapshotProps) { export default function ClinicSnapshot({ data, targetUrl, socialHandles }: ClinicSnapshotProps) {
const fields = infoFields(data); const fields = infoFields(data);
const linkItems = buildClinicLinks({
homepage: targetUrl || data.domain,
gangnamUnniUrl: data.registryData?.gangnamUnniUrl,
naverPlaceUrl: data.registryData?.naverPlaceUrl,
googleMapsUrl: data.registryData?.googleMapsUrl,
youtubeUrl: socialHandles?.youtube ? `https://youtube.com/${socialHandles.youtube.replace(/^@/, '@')}` : null,
instagramUrl: socialHandles?.instagram ? `https://instagram.com/${socialHandles.instagram.replace(/^@/, '')}` : null,
facebookUrl: socialHandles?.facebook ? `https://facebook.com/${socialHandles.facebook}` : null,
naverBlogUrl: socialHandles?.naverBlog || null,
tiktokUrl: socialHandles?.tiktok || null,
});
return ( return (
<SectionWrapper id="clinic-snapshot" title="Clinic Overview" subtitle="병원 기본 정보"> <SectionWrapper id="clinic-snapshot" title="Clinic Overview" subtitle="병원 기본 정보">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
{/* 외부 채널/플랫폼 링크 버튼 — URL 있는 항목만 색깔 버튼으로 렌더링 */}
<ExternalLinkButtons items={linkItems} className="mb-8" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
{fields.map((field, i) => { {fields.map((field, i) => {
const Icon = field.icon; const Icon = field.icon;
return ( return (

View File

@ -57,7 +57,7 @@ export default function ReportBody({ data }: ReportBodyProps) {
<SectionErrorBoundary> <SectionErrorBoundary>
{hasValue(data.clinicSnapshot) && data.clinicSnapshot.name ? ( {hasValue(data.clinicSnapshot) && data.clinicSnapshot.name ? (
<ClinicSnapshot data={data.clinicSnapshot} /> <ClinicSnapshot data={data.clinicSnapshot} targetUrl={data.targetUrl} socialHandles={socialHandles} />
) : ( ) : (
<EmptySection id="clinic-snapshot" title="Clinic Overview" subtitle="병원 기본 정보" /> <EmptySection id="clinic-snapshot" title="Clinic Overview" subtitle="병원 기본 정보" />
)} )}
@ -129,7 +129,7 @@ export default function ReportBody({ data }: ReportBodyProps) {
<SectionErrorBoundary> <SectionErrorBoundary>
{nonEmpty(data.kpiDashboard) ? ( {nonEmpty(data.kpiDashboard) ? (
<KPIDashboard metrics={data.kpiDashboard} clinicName={data.clinicSnapshot.name} /> <KPIDashboard metrics={data.kpiDashboard} clinicName={data.clinicSnapshot.name} report={data} />
) : ( ) : (
<EmptySection id="kpi-dashboard" title="KPI Dashboard" subtitle="핵심 지표" /> <EmptySection id="kpi-dashboard" title="KPI Dashboard" subtitle="핵심 지표" />
)} )}

View File

@ -1,7 +1,6 @@
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { Calendar, Globe, MapPin } from 'lucide-react'; import { Calendar, Globe, MapPin } from 'lucide-react';
import { ScoreRing } from './ui/ScoreRing'; import { ScoreRing } from './ui/ScoreRing';
import { ChannelLinkButtons, type ChannelHandles } from '@/shared/ui/channel-link-buttons';
import { PageContainer } from '@/shared/ui/page-container'; import { PageContainer } from '@/shared/ui/page-container';
function formatDate(raw: string): string { function formatDate(raw: string): string {
@ -21,24 +20,21 @@ interface ReportHeaderProps {
clinicNameEn: string; clinicNameEn: string;
overallScore: number; overallScore: number;
date: string; date: string;
targetUrl: string; targetUrl?: string;
location: string; location: string;
logoImage?: string; logoImage?: string;
brandColors?: { primary: string; accent: string; text: string }; brandColors?: { primary: string; accent: string; text: string };
/** 등록된 채널 핸들/URL — 외부 새 탭으로 이동 버튼 묶음을 렌더링 */ socialHandles?: Record<string, string | null>;
socialHandles?: ChannelHandles;
} }
export default function ReportHeader({ export default function ReportHeader({
clinicName, clinicName,
logoImage, logoImage,
brandColors,
clinicNameEn, clinicNameEn,
overallScore, overallScore,
date, date,
targetUrl, targetUrl,
location, location,
socialHandles,
}: ReportHeaderProps) { }: ReportHeaderProps) {
return ( 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"> <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">
@ -127,32 +123,18 @@ export default function ReportHeader({
<Calendar size={14} className="text-slate-400" /> <Calendar size={14} className="text-slate-400" />
{formatDate(date)} {formatDate(date)}
</span> </span>
{targetUrl && (
<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"> <span className="inline-flex items-center gap-2 rounded-full bg-white/60 backdrop-blur-sm border border-white/40 px-3 py-1 text-sm font-medium text-slate-700">
<Globe size={14} className="text-slate-400" /> <Globe size={14} className="text-slate-400" />
{targetUrl} {targetUrl}
</span> </span>
)}
<span className="inline-flex items-center gap-2 rounded-full bg-white/60 backdrop-blur-sm border border-white/40 px-3 py-1 text-sm font-medium text-slate-700"> <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" /> <MapPin size={14} className="text-slate-400" />
{location} {location}
</span> </span>
</motion.div> </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> </motion.div>
{/* Right: Score ring */} {/* Right: Score ring */}

View File

@ -1,9 +1,10 @@
import { useState, type ComponentType } from 'react'; import { useState } from 'react';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { Youtube, Instagram, Facebook, Globe, Search, Star, ArrowUpRight } from 'lucide-react'; import { ArrowUpRight } from 'lucide-react';
import { SectionWrapper } from './ui/SectionWrapper'; import { SectionWrapper } from './ui/SectionWrapper';
import { ComparisonRow } from './ui/ComparisonRow'; import { ComparisonRow } from './ui/ComparisonRow';
import { Button } from '@/shared/ui/button'; import { Button } from '@/shared/ui/button';
import { PlatformIcon, resolvePlatformVariant, PLATFORM_COLORS } from '@/shared/ui/platform-icon';
import type { TransformationProposal as TransformationProposalType, PlatformStrategy } from '@/features/report/types/report'; import type { TransformationProposal as TransformationProposalType, PlatformStrategy } from '@/features/report/types/report';
interface TransformationProposalProps { interface TransformationProposalProps {
@ -20,18 +21,11 @@ const tabItems = [
type TabKey = (typeof tabItems)[number]['key']; type TabKey = (typeof tabItems)[number]['key'];
const platformIconMap: Record<string, ComponentType<{ size?: number; className?: string }>> = {
youtube: Youtube,
instagram: Instagram,
facebook: Facebook,
website: Globe,
blog: Globe,
naver: Search,
tiktok: Star,
};
function PlatformStrategyCard({ strategy, index }: { key?: string | number; strategy: PlatformStrategy; index: number }) { function PlatformStrategyCard({ strategy, index }: { key?: string | number; strategy: PlatformStrategy; index: number }) {
const Icon = platformIconMap[strategy.icon?.toLowerCase()] ?? Globe; // strategy.icon 이 없거나 매칭 실패해도 strategy.platform 으로 fallback
const iconVariant = strategy.icon || strategy.platform;
const platformKey = resolvePlatformVariant(iconVariant);
const platformColor = platformKey ? PLATFORM_COLORS[platformKey] : '#6C5CE7';
return ( return (
<motion.div <motion.div
@ -42,8 +36,11 @@ function PlatformStrategyCard({ strategy, index }: { key?: string | number; stra
transition={{ duration: 0.5, delay: index * 0.1 }} transition={{ duration: 0.5, delay: index * 0.1 }}
> >
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-brand-purple-vivid/10 to-brand-purple/10 flex items-center justify-center"> <div
<Icon size={20} className="text-brand-purple-vivid" /> className="w-10 h-10 rounded-xl flex items-center justify-center"
style={{ backgroundColor: `${platformColor}14` }}
>
<PlatformIcon variant={iconVariant} size={20} colored />
</div> </div>
<h4 className="font-bold text-brand-navy">{strategy.platform}</h4> <h4 className="font-bold text-brand-navy">{strategy.platform}</h4>
</div> </div>

View File

@ -0,0 +1,73 @@
import { motion } from 'motion/react';
import { ExternalLink } from 'lucide-react';
/**
* / .
* URL .
*/
export interface ExternalLinkItem {
label: string;
url: string;
/** Tailwind classes for bg + border + text color */
colorClass: string;
}
interface ExternalLinkButtonsProps {
items: ExternalLinkItem[];
className?: string;
}
export function ExternalLinkButtons({ items, className }: ExternalLinkButtonsProps) {
const visible = items.filter((it) => it.url);
if (visible.length === 0) return null;
return (
<motion.div
className={`flex flex-wrap gap-2 ${className ?? ''}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
{visible.map((item) => (
<a
key={item.label}
href={item.url}
target="_blank"
rel="noopener noreferrer"
className={`inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors ${item.colorClass}`}
>
{item.label} <ExternalLink size={11} />
</a>
))}
</motion.div>
);
}
/** ClinicSnapshot 데이터에서 사용 가능한 URL 들을 색상 매핑된 항목 배열로 변환 */
export function buildClinicLinks(opts: {
homepage?: string | null;
gangnamUnniUrl?: string | null;
naverPlaceUrl?: string | null;
naverBlogUrl?: string | null;
googleMapsUrl?: string | null;
youtubeUrl?: string | null;
instagramUrl?: string | null;
facebookUrl?: string | null;
tiktokUrl?: string | null;
}): ExternalLinkItem[] {
const normalize = (raw: string | null | undefined) => {
if (!raw) return '';
return /^https?:\/\//.test(raw) ? raw : `https://${raw.replace(/^\/+/, '')}`;
};
return [
{ label: '홈페이지', url: normalize(opts.homepage), colorClass: 'bg-purple-50 border-purple-200 text-purple-700 hover:bg-purple-100' },
{ label: '강남언니', url: normalize(opts.gangnamUnniUrl), colorClass: 'bg-pink-50 border-pink-200 text-pink-700 hover:bg-pink-100' },
{ label: '네이버 플레이스', url: normalize(opts.naverPlaceUrl), colorClass: 'bg-green-50 border-green-200 text-green-700 hover:bg-green-100' },
{ label: '네이버 블로그', url: normalize(opts.naverBlogUrl), colorClass: 'bg-emerald-50 border-emerald-200 text-emerald-700 hover:bg-emerald-100' },
{ label: 'Google Maps', url: normalize(opts.googleMapsUrl), colorClass: 'bg-blue-50 border-blue-200 text-blue-700 hover:bg-blue-100' },
{ label: 'YouTube', url: normalize(opts.youtubeUrl), colorClass: 'bg-red-50 border-red-200 text-red-700 hover:bg-red-100' },
{ label: 'Instagram', url: normalize(opts.instagramUrl), colorClass: 'bg-fuchsia-50 border-fuchsia-200 text-fuchsia-700 hover:bg-fuchsia-100' },
{ label: 'Facebook', url: normalize(opts.facebookUrl), colorClass: 'bg-sky-50 border-sky-200 text-sky-700 hover:bg-sky-100' },
{ label: 'TikTok', url: normalize(opts.tiktokUrl), colorClass: 'bg-slate-50 border-slate-200 text-slate-700 hover:bg-slate-100' },
];
}

View File

@ -2,16 +2,13 @@
* GuestReportPage `/report/:id` * GuestReportPage `/report/:id`
* *
* ( ) . * ( ) .
* UserReportPage , CTA
* . * .
*/ */
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { ArrowRight } from 'lucide-react';
import { useReportPageData } from '../hooks/useReportPageData'; import { useReportPageData } from '../hooks/useReportPageData';
import { ReportNav } from '../components/ReportNav'; import { ReportNav } from '../components/ReportNav';
import { ScreenshotProvider } from '../stores/ScreenshotContext'; import { ScreenshotProvider } from '../stores/ScreenshotContext';
import { REPORT_SECTIONS } from '@/shared/constants/reportSections'; import { REPORT_SECTIONS } from '@/shared/constants/reportSections';
import { buildContactMailto } from '@/shared/lib/contact';
import ReportBody from '../components/ReportBody'; import ReportBody from '../components/ReportBody';
export default function GuestReportPage() { export default function GuestReportPage() {
@ -53,32 +50,6 @@ export default function GuestReportPage() {
)} )}
<ReportBody data={data} /> <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> </div>
</ScreenshotProvider> </ScreenshotProvider>
); );

View File

@ -0,0 +1,58 @@
import type { ComponentType } from 'react';
import {
YoutubeFilled,
InstagramFilled,
FacebookFilled,
GlobeFilled,
VideoFilled,
MessageFilled,
TiktokFilled,
FileTextFilled,
MegaphoneFilled,
} from './FilledIcons';
type IconComponent = ComponentType<{ size?: number; className?: string }>;
// API icon 필드는 채널 키(naverBlog) 또는 일반명(blog/map/star)으로 올 수 있어 둘 다 매핑.
export const channelIconMap: Record<string, IconComponent> = {
youtube: YoutubeFilled,
instagram: InstagramFilled,
facebook: FacebookFilled,
tiktok: TiktokFilled,
naverblog: FileTextFilled,
naver_blog: FileTextFilled,
blog: FileTextFilled,
naverplace: GlobeFilled,
naver_place: GlobeFilled,
map: GlobeFilled,
gangnamunni: MegaphoneFilled,
gangnam_unni: MegaphoneFilled,
star: MegaphoneFilled,
website: GlobeFilled,
homepage: GlobeFilled,
globe: GlobeFilled,
video: VideoFilled,
messagesquare: MessageFilled,
};
export function getChannelIcon(icon: string | undefined | null): IconComponent {
if (!icon) return GlobeFilled;
return channelIconMap[icon.toLowerCase()] ?? GlobeFilled;
}
// icon 필드가 generic('video', 'globe' 등) 으로 와도 채널 라벨로 보정.
// e.g. channel='TikTok', icon='video' → TiktokFilled
export function resolveChannelIcon(channelLabel?: string | null, icon?: string | null): IconComponent {
if (channelLabel) {
const lower = channelLabel.toLowerCase();
if (lower.includes('youtube')) return YoutubeFilled;
if (lower.includes('instagram')) return InstagramFilled;
if (lower.includes('facebook')) return FacebookFilled;
if (lower.includes('tiktok') || lower.includes('틱톡')) return TiktokFilled;
if (lower.includes('blog') || lower.includes('블로그')) return FileTextFilled;
if (lower.includes('place') || lower.includes('플레이스')) return GlobeFilled;
if (lower.includes('gangnam') || lower.includes('강남언니')) return MegaphoneFilled;
if (lower.includes('website') || lower.includes('홈페이지') || lower.includes('웹사이트')) return GlobeFilled;
}
return getChannelIcon(icon ?? undefined);
}

View File

@ -6,16 +6,7 @@
* - * -
* - : white/60 backdrop-blur + (ReportHeader ) * - : white/60 backdrop-blur + (ReportHeader )
*/ */
import { ExternalLink, Heart, MapPin } from 'lucide-react'; import { ExternalLink, Heart, MapPin, Globe, Instagram, Youtube, Facebook, Music, FileText, type LucideIcon } from 'lucide-react';
import type { CSSProperties } from 'react';
import {
InstagramFilled,
YoutubeFilled,
FacebookFilled,
GlobeFilled,
TiktokFilled,
FileTextFilled,
} from '@/shared/icons/FilledIcons';
export interface ChannelHandles { export interface ChannelHandles {
website?: string | null; website?: string | null;
@ -33,7 +24,7 @@ type PlatformKey = keyof ChannelHandles;
interface PlatformMeta { interface PlatformMeta {
label: string; label: string;
color: string; color: string;
Icon: (props: { size?: number; className?: string; style?: CSSProperties }) => React.ReactElement; Icon: LucideIcon;
buildUrl: (handle: string) => string; buildUrl: (handle: string) => string;
} }
@ -41,31 +32,31 @@ const META: Record<PlatformKey, PlatformMeta> = {
website: { website: {
label: '홈페이지', label: '홈페이지',
color: '#6C5CE7', color: '#6C5CE7',
Icon: GlobeFilled, Icon: Globe,
buildUrl: (h) => (/^https?:\/\//.test(h) ? h : `https://${h}`), buildUrl: (h) => (/^https?:\/\//.test(h) ? h : `https://${h}`),
}, },
instagram: { instagram: {
label: 'Instagram', label: 'Instagram',
color: '#833AB4', color: '#833AB4',
Icon: InstagramFilled, Icon: Instagram,
buildUrl: (h) => `https://instagram.com/${h.replace(/^@/, '')}`, buildUrl: (h) => `https://instagram.com/${h.replace(/^@/, '')}`,
}, },
youtube: { youtube: {
label: 'YouTube', label: 'YouTube',
color: '#FF3D3D', color: '#FF3D3D',
Icon: YoutubeFilled, Icon: Youtube,
buildUrl: (h) => `https://youtube.com/${h.startsWith('@') ? h : `@${h}`}`, buildUrl: (h) => `https://youtube.com/${h.startsWith('@') ? h : `@${h}`}`,
}, },
facebook: { facebook: {
label: 'Facebook', label: 'Facebook',
color: '#1877F2', color: '#1877F2',
Icon: FacebookFilled, Icon: Facebook,
buildUrl: (h) => (/^https?:\/\//.test(h) ? h : `https://facebook.com/${h}`), buildUrl: (h) => (/^https?:\/\//.test(h) ? h : `https://facebook.com/${h}`),
}, },
tiktok: { tiktok: {
label: 'TikTok', label: 'TikTok',
color: '#0A1128', color: '#0A1128',
Icon: TiktokFilled, Icon: Music,
buildUrl: (h) => `https://tiktok.com/${h.startsWith('@') ? h : `@${h}`}`, buildUrl: (h) => `https://tiktok.com/${h.startsWith('@') ? h : `@${h}`}`,
}, },
gangnamUnni: { gangnamUnni: {
@ -91,7 +82,7 @@ const META: Record<PlatformKey, PlatformMeta> = {
naverBlog: { naverBlog: {
label: '네이버 블로그', label: '네이버 블로그',
color: '#03C75A', color: '#03C75A',
Icon: FileTextFilled, Icon: FileText,
buildUrl: (h) => { buildUrl: (h) => {
if (/^https?:\/\//.test(h)) return h; if (/^https?:\/\//.test(h)) return h;
if (/blog\.naver\.com\//.test(h)) return `https://${h.replace(/^\/+/, '')}`; if (/blog\.naver\.com\//.test(h)) return `https://${h.replace(/^\/+/, '')}`;

View File

@ -0,0 +1,125 @@
/**
* PlatformIcon (string variant) lucide line icon .
*
* 'youtube', 'YouTube', 'naver-blog', '네이버 블로그'
* normalize . Globe.
*/
import {
Globe,
Instagram,
Youtube,
Facebook,
Music,
FileText,
Heart,
MapPin,
Search,
type LucideIcon,
} from 'lucide-react';
export type PlatformVariant =
| 'website'
| 'instagram'
| 'youtube'
| 'facebook'
| 'tiktok'
| 'naverPlace'
| 'naverBlog'
| 'naver'
| 'gangnamUnni'
| 'blog';
export const PLATFORM_ICONS: Record<PlatformVariant, LucideIcon> = {
website: Globe,
instagram: Instagram,
youtube: Youtube,
facebook: Facebook,
tiktok: Music,
naverPlace: MapPin,
naverBlog: FileText,
naver: Search,
gangnamUnni: Heart,
blog: FileText,
};
export const PLATFORM_COLORS: Record<PlatformVariant, string> = {
website: '#6C5CE7',
instagram: '#833AB4',
youtube: '#FF3D3D',
facebook: '#1877F2',
tiktok: '#0A1128',
naverPlace: '#03C75A',
naverBlog: '#03C75A',
naver: '#03C75A',
gangnamUnni: '#FF6B8A',
blog: '#03C75A',
};
/** 별칭 → canonical key. lowercase / 한글 / 공백·하이픈 변형을 흡수. */
const ALIAS: Record<string, PlatformVariant> = {
website: 'website',
homepage: 'website',
web: 'website',
site: 'website',
: 'website',
instagram: 'instagram',
ig: 'instagram',
: 'instagram',
: 'instagram',
youtube: 'youtube',
yt: 'youtube',
: 'youtube',
facebook: 'facebook',
fb: 'facebook',
: 'facebook',
tiktok: 'tiktok',
: 'tiktok',
naverplace: 'naverPlace',
: 'naverPlace',
: 'naverPlace',
place: 'naverPlace',
naverblog: 'naverBlog',
: 'naverBlog',
naver: 'naver',
: 'naver',
blog: 'blog',
: 'blog',
gangnamunni: 'gangnamUnni',
: 'gangnamUnni',
};
/** 자유 문자열 → PlatformVariant. 못 찾으면 undefined. */
export function resolvePlatformVariant(raw: string | null | undefined): PlatformVariant | undefined {
if (!raw) return undefined;
const norm = raw.toLowerCase().replace(/[\s\-_]+/g, '');
if (norm in ALIAS) return ALIAS[norm];
// 부분 일치 (예: "naver blog" → "naverblog" → naverBlog)
for (const key of Object.keys(ALIAS)) {
if (norm.includes(key)) return ALIAS[key];
}
return undefined;
}
interface PlatformIconProps {
/** 플랫폼 식별 문자열. 'youtube' / 'Instagram' / '네이버 블로그' 등 자유 형식 */
variant: string | null | undefined;
size?: number;
className?: string;
/** 색상을 자동으로 플랫폼 컬러로 채울지 (false 면 현재 텍스트 색 상속) */
colored?: boolean;
/** 매칭 실패 시 사용할 fallback icon. 기본 Globe. */
fallback?: LucideIcon;
}
export function PlatformIcon({
variant,
size = 16,
className,
colored = false,
fallback = Globe,
}: PlatformIconProps) {
const key = resolvePlatformVariant(variant);
const Icon = key ? PLATFORM_ICONS[key] : fallback;
const color = colored && key ? PLATFORM_COLORS[key] : undefined;
return <Icon size={size} className={className} style={color ? { color } : undefined} />;
}