From 47fed51efce896d50e61267b314318fbe5569fc5 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 20 May 2026 11:51:18 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=B1=84=EB=84=90=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EC=BD=98/=EB=A7=81=ED=81=AC=20=ED=86=B5=ED=95=A9=20+=20?= =?UTF-8?q?=EB=A6=AC=ED=8F=AC=ED=8A=B8=C2=B7=ED=94=8C=EB=9E=9C=20=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B8=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 사용 --- .../channels/components/MultiChannelInput.tsx | 6 +- .../plan/components/BrandingGuide.tsx | 4 +- .../plan/components/ChannelStrategy.tsx | 29 +--- src/features/plan/pages/PlanPage.tsx | 2 +- .../report/components/ChannelOverview.tsx | 29 +++- .../report/components/ClinicSnapshot.tsx | 21 ++- src/features/report/components/ReportBody.tsx | 4 +- .../report/components/ReportHeader.tsx | 34 ++--- .../components/TransformationProposal.tsx | 27 ++-- .../components/ui/ExternalLinkButtons.tsx | 73 ++++++++++ src/features/report/pages/GuestReportPage.tsx | 29 ---- src/shared/icons/channelIcons.ts | 58 ++++++++ src/shared/ui/channel-link-buttons.tsx | 25 ++-- src/shared/ui/platform-icon.tsx | 125 ++++++++++++++++++ 14 files changed, 339 insertions(+), 127 deletions(-) create mode 100644 src/features/report/components/ui/ExternalLinkButtons.tsx create mode 100644 src/shared/icons/channelIcons.ts create mode 100644 src/shared/ui/platform-icon.tsx diff --git a/src/features/channels/components/MultiChannelInput.tsx b/src/features/channels/components/MultiChannelInput.tsx index f8c6129..4ad5b9d 100644 --- a/src/features/channels/components/MultiChannelInput.tsx +++ b/src/features/channels/components/MultiChannelInput.tsx @@ -56,6 +56,8 @@ export interface AnalyzePayload { interface MultiChannelInputProps { variant?: 'hero' | 'cta'; onAnalyze: (payload: AnalyzePayload) => void; + /** 디폴트 입력값 — /dev/test 등 목업 시나리오용. 미지정 시 빈 폼. */ + initialUrls?: Partial; } type ChannelKey = keyof Omit; @@ -108,8 +110,8 @@ function validateField(value: string, expected: ChannelKey): 'empty' | 'valid' | return 'invalid'; } -export default function MultiChannelInput({ variant = 'hero', onAnalyze }: MultiChannelInputProps) { - const [urls, setUrls] = useState(EMPTY_URLS); +export default function MultiChannelInput({ variant = 'hero', onAnalyze, initialUrls }: MultiChannelInputProps) { + const [urls, setUrls] = useState(() => ({ ...EMPTY_URLS, ...initialUrls })); // 통합 분류 결과 — 7개 필드 값을 join해 classifyUrls에 한 번에 통과시켜 manualChannels 구성. const aggregated = useMemo(() => { diff --git a/src/features/plan/components/BrandingGuide.tsx b/src/features/plan/components/BrandingGuide.tsx index 4bdacb0..1ebe430 100644 --- a/src/features/plan/components/BrandingGuide.tsx +++ b/src/features/plan/components/BrandingGuide.tsx @@ -14,10 +14,10 @@ import { Input } from '@/shared/ui/input'; import { tabItems, type TabKey, - getChannelIcon, statusColor, statusLabel, } from '../data/brandingGuideConstants'; +import { resolveChannelIcon } from '@/shared/icons/channelIcons'; import type { BrandGuide, ColorSwatch } from '@/features/plan/types/plan'; import type { BrandInconsistency } from '@/features/report/types/report'; @@ -364,7 +364,7 @@ function ChannelRulesTab({ channels }: { channels: BrandGuide['channelBranding'] >
{channels.map((ch) => { - const Icon = getChannelIcon(ch.icon); + const Icon = resolveChannelIcon(ch.channel, ch.icon); return (
{/* Header */} diff --git a/src/features/plan/components/ChannelStrategy.tsx b/src/features/plan/components/ChannelStrategy.tsx index 0176bb0..06b743d 100644 --- a/src/features/plan/components/ChannelStrategy.tsx +++ b/src/features/plan/components/ChannelStrategy.tsx @@ -1,15 +1,6 @@ -import { type ComponentType } from 'react'; import { motion } from 'motion/react'; -import { - YoutubeFilled, - InstagramFilled, - FacebookFilled, - GlobeFilled, - VideoFilled, - MessageFilled, - CalendarFilled, - TiktokFilled, -} from '@/shared/icons/FilledIcons'; +import { CalendarFilled } from '@/shared/icons/FilledIcons'; +import { resolveChannelIcon } from '@/shared/icons/channelIcons'; import { SectionWrapper } from '@/features/report/components/ui/SectionWrapper'; import type { ChannelStrategyCard } from '@/features/plan/types/plan'; @@ -17,20 +8,6 @@ interface ChannelStrategyProps { channels: ChannelStrategyCard[]; } -const channelIconMap: Record> = { - 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 = { 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)]', @@ -47,7 +24,7 @@ export default function ChannelStrategy({ channels }: ChannelStrategyProps) { >
{channels.map((ch, index) => { - const Icon = getChannelIcon(ch.icon); + const Icon = resolveChannelIcon(ch.channelName, ch.icon); return (
- +
); } diff --git a/src/features/report/components/ChannelOverview.tsx b/src/features/report/components/ChannelOverview.tsx index dcde542..32e1279 100644 --- a/src/features/report/components/ChannelOverview.tsx +++ b/src/features/report/components/ChannelOverview.tsx @@ -1,6 +1,6 @@ import type { ComponentType, CSSProperties } from '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 { ScoreRing } from './ui/ScoreRing'; import { SeverityBadge } from './ui/SeverityBadge'; @@ -17,6 +17,9 @@ const iconMap: Record = { @@ -38,8 +41,22 @@ const brandColor: Record = { globe: '#6B2D8B', }; -function getChannelColor(icon: string | undefined): string | undefined { - return brandColor[icon?.toLowerCase() ?? '']; +/** channel 이름 (영문 key 또는 한글) → iconMap·brandColor 의 키로 정규화 */ +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) { @@ -47,8 +64,10 @@ export default function ChannelOverview({ channels }: ChannelOverviewProps) {
{channels.map((ch, i) => { - const Icon = iconMap[ch.icon?.toLowerCase()] ?? Globe; - const color = getChannelColor(ch.icon); + // 1) icon 값으로 매칭 → 2) channel 이름 (한글 포함) 으로 fallback + const key = resolveChannelKey(ch.icon) || resolveChannelKey(ch.channel); + const Icon = key ? iconMap[key] : Globe; + const color = key ? brandColor[key] : undefined; return ( | null; } 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, ].filter(Boolean) as InfoField[]; -export default function ClinicSnapshot({ data }: ClinicSnapshotProps) { +export default function ClinicSnapshot({ data, targetUrl, socialHandles }: ClinicSnapshotProps) { 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 ( -
+ + {/* 외부 채널/플랫폼 링크 버튼 — URL 있는 항목만 색깔 버튼으로 렌더링 */} + +
{fields.map((field, i) => { const Icon = field.icon; return ( diff --git a/src/features/report/components/ReportBody.tsx b/src/features/report/components/ReportBody.tsx index 1581406..584bc5a 100644 --- a/src/features/report/components/ReportBody.tsx +++ b/src/features/report/components/ReportBody.tsx @@ -57,7 +57,7 @@ export default function ReportBody({ data }: ReportBodyProps) { {hasValue(data.clinicSnapshot) && data.clinicSnapshot.name ? ( - + ) : ( )} @@ -129,7 +129,7 @@ export default function ReportBody({ data }: ReportBodyProps) { {nonEmpty(data.kpiDashboard) ? ( - + ) : ( )} diff --git a/src/features/report/components/ReportHeader.tsx b/src/features/report/components/ReportHeader.tsx index 5a28d71..c5d50d7 100644 --- a/src/features/report/components/ReportHeader.tsx +++ b/src/features/report/components/ReportHeader.tsx @@ -1,7 +1,6 @@ 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 { @@ -21,24 +20,21 @@ interface ReportHeaderProps { clinicNameEn: string; overallScore: number; date: string; - targetUrl: string; + targetUrl?: string; location: string; logoImage?: string; brandColors?: { primary: string; accent: string; text: string }; - /** 등록된 채널 핸들/URL — 외부 새 탭으로 이동 버튼 묶음을 렌더링 */ - socialHandles?: ChannelHandles; + socialHandles?: Record; } export default function ReportHeader({ clinicName, logoImage, - brandColors, clinicNameEn, overallScore, date, targetUrl, location, - socialHandles, }: ReportHeaderProps) { return (
@@ -127,32 +123,18 @@ export default function ReportHeader({ {formatDate(date)} - - - {targetUrl} - + {targetUrl && ( + + + {targetUrl} + + )} {location} - {/* 등록된 채널 바로가기 */} - {socialHandles && ( - - - - )} {/* Right: Score ring */} diff --git a/src/features/report/components/TransformationProposal.tsx b/src/features/report/components/TransformationProposal.tsx index f961702..2368ac1 100644 --- a/src/features/report/components/TransformationProposal.tsx +++ b/src/features/report/components/TransformationProposal.tsx @@ -1,9 +1,10 @@ -import { useState, type ComponentType } from 'react'; +import { useState } from '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 { ComparisonRow } from './ui/ComparisonRow'; 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'; interface TransformationProposalProps { @@ -20,18 +21,11 @@ const tabItems = [ type TabKey = (typeof tabItems)[number]['key']; -const platformIconMap: Record> = { - 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 }) { - 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 (
-
- +
+

{strategy.platform}

diff --git a/src/features/report/components/ui/ExternalLinkButtons.tsx b/src/features/report/components/ui/ExternalLinkButtons.tsx new file mode 100644 index 0000000..cefec11 --- /dev/null +++ b/src/features/report/components/ui/ExternalLinkButtons.tsx @@ -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 ( + + {visible.map((item) => ( + + {item.label} + + ))} + + ); +} + +/** 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' }, + ]; +} diff --git a/src/features/report/pages/GuestReportPage.tsx b/src/features/report/pages/GuestReportPage.tsx index 9aa3a0b..6ee0d20 100644 --- a/src/features/report/pages/GuestReportPage.tsx +++ b/src/features/report/pages/GuestReportPage.tsx @@ -2,16 +2,13 @@ * GuestReportPage — `/report/:id` * * 손님(랜딩에서 분석을 실행한 비계약 방문자)이 보는 리포트 화면. - * 본문은 UserReportPage 와 동일하나, 하단에 도입 문의 CTA가 추가되고 * 워크스페이스용 액션바는 노출되지 않습니다. */ import { useParams } from 'react-router'; -import { ArrowRight } 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'; export default function GuestReportPage() { @@ -53,32 +50,6 @@ export default function GuestReportPage() { )} - - {/* Guest 전용 — 도입 문의 CTA */} -
-
-
-
-

- INFINITH 도입 안내 -

-

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

-

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

- - 도입 문의하기 - - -
-
-
); diff --git a/src/shared/icons/channelIcons.ts b/src/shared/icons/channelIcons.ts new file mode 100644 index 0000000..29430ac --- /dev/null +++ b/src/shared/icons/channelIcons.ts @@ -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 = { + 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); +} diff --git a/src/shared/ui/channel-link-buttons.tsx b/src/shared/ui/channel-link-buttons.tsx index 97e1b5b..2d02a33 100644 --- a/src/shared/ui/channel-link-buttons.tsx +++ b/src/shared/ui/channel-link-buttons.tsx @@ -6,16 +6,7 @@ * - 비어있는 플랫폼은 자동 생략 * - 디자인: 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'; +import { ExternalLink, Heart, MapPin, Globe, Instagram, Youtube, Facebook, Music, FileText, type LucideIcon } from 'lucide-react'; export interface ChannelHandles { website?: string | null; @@ -33,7 +24,7 @@ type PlatformKey = keyof ChannelHandles; interface PlatformMeta { label: string; color: string; - Icon: (props: { size?: number; className?: string; style?: CSSProperties }) => React.ReactElement; + Icon: LucideIcon; buildUrl: (handle: string) => string; } @@ -41,31 +32,31 @@ const META: Record = { website: { label: '홈페이지', color: '#6C5CE7', - Icon: GlobeFilled, + Icon: Globe, buildUrl: (h) => (/^https?:\/\//.test(h) ? h : `https://${h}`), }, instagram: { label: 'Instagram', color: '#833AB4', - Icon: InstagramFilled, + Icon: Instagram, buildUrl: (h) => `https://instagram.com/${h.replace(/^@/, '')}`, }, youtube: { label: 'YouTube', color: '#FF3D3D', - Icon: YoutubeFilled, + Icon: Youtube, buildUrl: (h) => `https://youtube.com/${h.startsWith('@') ? h : `@${h}`}`, }, facebook: { label: 'Facebook', color: '#1877F2', - Icon: FacebookFilled, + Icon: Facebook, buildUrl: (h) => (/^https?:\/\//.test(h) ? h : `https://facebook.com/${h}`), }, tiktok: { label: 'TikTok', color: '#0A1128', - Icon: TiktokFilled, + Icon: Music, buildUrl: (h) => `https://tiktok.com/${h.startsWith('@') ? h : `@${h}`}`, }, gangnamUnni: { @@ -91,7 +82,7 @@ const META: Record = { naverBlog: { label: '네이버 블로그', color: '#03C75A', - Icon: FileTextFilled, + Icon: FileText, buildUrl: (h) => { if (/^https?:\/\//.test(h)) return h; if (/blog\.naver\.com\//.test(h)) return `https://${h.replace(/^\/+/, '')}`; diff --git a/src/shared/ui/platform-icon.tsx b/src/shared/ui/platform-icon.tsx new file mode 100644 index 0000000..4fe1205 --- /dev/null +++ b/src/shared/ui/platform-icon.tsx @@ -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 = { + 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 = { + 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 = { + 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 ; +}