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
parent
317cd61519
commit
47fed51efc
|
|
@ -56,6 +56,8 @@ export interface AnalyzePayload {
|
|||
interface MultiChannelInputProps {
|
||||
variant?: 'hero' | 'cta';
|
||||
onAnalyze: (payload: AnalyzePayload) => void;
|
||||
/** 디폴트 입력값 — /dev/test 등 목업 시나리오용. 미지정 시 빈 폼. */
|
||||
initialUrls?: Partial<ChannelUrlInputs>;
|
||||
}
|
||||
|
||||
type ChannelKey = keyof Omit<ClassifiedUrls, 'unknown'>;
|
||||
|
|
@ -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<ChannelUrlInputs>(EMPTY_URLS);
|
||||
export default function MultiChannelInput({ variant = 'hero', onAnalyze, initialUrls }: MultiChannelInputProps) {
|
||||
const [urls, setUrls] = useState<ChannelUrlInputs>(() => ({ ...EMPTY_URLS, ...initialUrls }));
|
||||
|
||||
// 통합 분류 결과 — 7개 필드 값을 join해 classifyUrls에 한 번에 통과시켜 manualChannels 구성.
|
||||
const aggregated = useMemo(() => {
|
||||
|
|
|
|||
|
|
@ -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']
|
|||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{channels.map((ch) => {
|
||||
const Icon = getChannelIcon(ch.icon);
|
||||
const Icon = resolveChannelIcon(ch.channel, ch.icon);
|
||||
return (
|
||||
<div key={ch.channel} className="rounded-2xl border border-slate-100 p-5">
|
||||
{/* Header */}
|
||||
|
|
|
|||
|
|
@ -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<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> = {
|
||||
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) {
|
|||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{channels.map((ch, index) => {
|
||||
const Icon = getChannelIcon(ch.icon);
|
||||
const Icon = resolveChannelIcon(ch.channelName, ch.icon);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ export default function PlanPage() {
|
|||
<MyAssetUpload analysisRunId={id} />
|
||||
</div>
|
||||
|
||||
<PlanCTA />
|
||||
<PlanCTA plan={data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, ComponentType<{ size?: number; className?: string;
|
|||
star: Star,
|
||||
globe: Globe,
|
||||
search: Search,
|
||||
map: MapPin,
|
||||
blog: FileText,
|
||||
video: Music,
|
||||
};
|
||||
|
||||
const channelLabel: Record<string, string> = {
|
||||
|
|
@ -38,8 +41,22 @@ const brandColor: Record<string, string> = {
|
|||
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) {
|
|||
<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">
|
||||
{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 (
|
||||
<motion.div
|
||||
key={ch.channel}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { motion } from 'motion/react';
|
||||
import { Calendar, Users, MapPin, Phone, BadgeCheck, Star, Globe, ExternalLink, Building2 } from 'lucide-react';
|
||||
import { SectionWrapper } from './ui/SectionWrapper';
|
||||
import { ExternalLinkButtons, buildClinicLinks } from './ui/ExternalLinkButtons';
|
||||
import type { ClinicSnapshot as ClinicSnapshotType } from '@/features/report/types/report';
|
||||
|
||||
interface ClinicSnapshotProps {
|
||||
data: ClinicSnapshotType;
|
||||
targetUrl?: string;
|
||||
socialHandles?: Record<string, string | null> | 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 (
|
||||
<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) => {
|
||||
const Icon = field.icon;
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ export default function ReportBody({ data }: ReportBodyProps) {
|
|||
|
||||
<SectionErrorBoundary>
|
||||
{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="병원 기본 정보" />
|
||||
)}
|
||||
|
|
@ -129,7 +129,7 @@ export default function ReportBody({ data }: ReportBodyProps) {
|
|||
|
||||
<SectionErrorBoundary>
|
||||
{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="핵심 지표" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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<string, string | null>;
|
||||
}
|
||||
|
||||
export default function ReportHeader({
|
||||
clinicName,
|
||||
logoImage,
|
||||
brandColors,
|
||||
clinicNameEn,
|
||||
overallScore,
|
||||
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">
|
||||
|
|
@ -127,32 +123,18 @@ export default function ReportHeader({
|
|||
<Calendar size={14} className="text-slate-400" />
|
||||
{formatDate(date)}
|
||||
</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">
|
||||
<Globe size={14} className="text-slate-400" />
|
||||
{targetUrl}
|
||||
</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">
|
||||
<MapPin size={14} className="text-slate-400" />
|
||||
{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 */}
|
||||
|
|
|
|||
|
|
@ -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<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 }) {
|
||||
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 (
|
||||
<motion.div
|
||||
|
|
@ -42,8 +36,11 @@ function PlatformStrategyCard({ strategy, index }: { key?: string | number; stra
|
|||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
>
|
||||
<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">
|
||||
<Icon size={20} className="text-brand-purple-vivid" />
|
||||
<div
|
||||
className="w-10 h-10 rounded-xl flex items-center justify-center"
|
||||
style={{ backgroundColor: `${platformColor}14` }}
|
||||
>
|
||||
<PlatformIcon variant={iconVariant} size={20} colored />
|
||||
</div>
|
||||
<h4 className="font-bold text-brand-navy">{strategy.platform}</h4>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
];
|
||||
}
|
||||
|
|
@ -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() {
|
|||
)}
|
||||
|
||||
<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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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<PlatformKey, PlatformMeta> = {
|
|||
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<PlatformKey, PlatformMeta> = {
|
|||
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(/^\/+/, '')}`;
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
Loading…
Reference in New Issue