feat: add clickable source links to all report sections

YouTubeAudit: handle links to youtube.com/@{handle} with ExternalLink icon
InstagramAudit: handle links to instagram.com/{handle} with ExternalLink icon
ClinicSnapshot: domain is now clickable link, phone is tel: link
OtherChannels: Google Maps generates search URL, Naver Blog links to
  first blog post or search results (previously empty string)
transformReport: fills missing URL fields for Google Maps and Naver Blog

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
claude/bold-hawking
Haewon Kam 2026-04-04 11:02:35 +09:00
parent bb7b08e35c
commit 66b4826f55
4 changed files with 54 additions and 12 deletions

View File

@ -1,5 +1,5 @@
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { Calendar, Users, MapPin, Phone, Award, Star, Globe } from 'lucide-react'; import { Calendar, Users, MapPin, Phone, Award, Star, Globe, ExternalLink } from 'lucide-react';
import { SectionWrapper } from './ui/SectionWrapper'; import { SectionWrapper } from './ui/SectionWrapper';
import type { ClinicSnapshot as ClinicSnapshotType } from '../../types/report'; import type { ClinicSnapshot as ClinicSnapshotType } from '../../types/report';
@ -11,16 +11,22 @@ function formatNumber(n: number): string {
return n.toLocaleString(); return n.toLocaleString();
} }
const infoFields = (data: ClinicSnapshotType) => [ interface InfoField {
label: string;
value: string;
icon: typeof Calendar;
href?: string;
}
const infoFields = (data: ClinicSnapshotType): InfoField[] => [
data.established ? { label: '개원', value: `${data.established} (${data.yearsInBusiness}년)`, icon: Calendar } : null, data.established ? { label: '개원', value: `${data.established} (${data.yearsInBusiness}년)`, icon: Calendar } : null,
data.staffCount > 0 ? { label: '의료진', value: `${data.staffCount}`, icon: Users } : null, data.staffCount > 0 ? { label: '의료진', value: `${data.staffCount}`, icon: Users } : null,
data.overallRating > 0 ? { label: '강남언니 평점', value: data.overallRating > 5 ? `${data.overallRating} / 10` : `${data.overallRating} / 5.0`, icon: Star } : null, data.overallRating > 0 ? { label: '강남언니 평점', value: data.overallRating > 5 ? `${data.overallRating} / 10` : `${data.overallRating} / 5.0`, icon: Star } : null,
data.totalReviews > 0 ? { label: '리뷰 수', value: formatNumber(data.totalReviews), icon: Star } : null, data.totalReviews > 0 ? { label: '리뷰 수', value: formatNumber(data.totalReviews), icon: Star } : null,
data.priceRange.min !== '-' ? { label: '시술 가격대', value: `${data.priceRange.min} ~ ${data.priceRange.max}`, icon: Globe } : null,
data.location ? { label: '위치', value: data.nearestStation ? `${data.location} (${data.nearestStation})` : data.location, icon: MapPin } : null, data.location ? { label: '위치', value: data.nearestStation ? `${data.location} (${data.nearestStation})` : data.location, icon: MapPin } : null,
data.phone ? { label: '전화', value: data.phone, icon: Phone } : null, data.phone ? { label: '전화', value: data.phone, icon: Phone, href: `tel:${data.phone.replace(/[^+0-9]/g, '')}` } : null,
data.domain ? { label: '도메인', value: data.domain, icon: Globe } : null, data.domain ? { label: '도메인', value: data.domain, icon: Globe, href: `https://${data.domain.replace(/^https?:\/\//, '')}` } : null,
].filter((f): f is NonNullable<typeof f> => f !== null); ].filter((f): f is NonNullable<InfoField> => f !== null);
export default function ClinicSnapshot({ data }: ClinicSnapshotProps) { export default function ClinicSnapshot({ data }: ClinicSnapshotProps) {
const fields = infoFields(data); const fields = infoFields(data);
@ -45,7 +51,19 @@ export default function ClinicSnapshot({ data }: ClinicSnapshotProps) {
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-xs text-slate-500 uppercase tracking-wide">{field.label}</p> <p className="text-xs text-slate-500 uppercase tracking-wide">{field.label}</p>
<p className="text-lg font-semibold text-[#0A1128] mt-1">{field.value}</p> {field.href ? (
<a
href={field.href}
target={field.href.startsWith('tel:') ? undefined : '_blank'}
rel="noopener noreferrer"
className="text-lg font-semibold text-[#0A1128] mt-1 hover:text-[#6C5CE7] inline-flex items-center gap-1.5"
>
{field.value}
{!field.href.startsWith('tel:') && <ExternalLink size={14} className="text-[#6C5CE7] shrink-0" />}
</a>
) : (
<p className="text-lg font-semibold text-[#0A1128] mt-1">{field.value}</p>
)}
</div> </div>
</div> </div>
</motion.div> </motion.div>

View File

@ -1,5 +1,5 @@
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { Instagram, AlertCircle, FileText, Users, Eye } from 'lucide-react'; import { Instagram, AlertCircle, FileText, Users, Eye, ExternalLink } from 'lucide-react';
import { SectionWrapper } from './ui/SectionWrapper'; import { SectionWrapper } from './ui/SectionWrapper';
import { EmptyState } from './ui/EmptyState'; import { EmptyState } from './ui/EmptyState';
import { MetricCard } from './ui/MetricCard'; import { MetricCard } from './ui/MetricCard';
@ -37,7 +37,19 @@ function AccountCard({ account, index }: { key?: string | number; account: Insta
</div> </div>
</div> </div>
<h3 className="font-bold text-lg text-[#0A1128] mb-1">{account.handle}</h3> <h3 className="font-bold text-lg text-[#0A1128] mb-1">
{account.handle ? (
<a
href={account.profileLink || `https://instagram.com/${account.handle.replace(/^@/, '')}`}
target="_blank"
rel="noopener noreferrer"
className="hover:text-[#6C5CE7] inline-flex items-center gap-1"
>
{account.handle}
<ExternalLink size={13} className="text-[#6C5CE7]" />
</a>
) : account.handle}
</h3>
<p className="text-xs text-slate-500 mb-4">{account.category}</p> <p className="text-xs text-slate-500 mb-4">{account.category}</p>
{/* Compact metrics */} {/* Compact metrics */}

View File

@ -68,7 +68,19 @@ export default function YouTubeAudit({ data }: YouTubeAuditProps) {
</div> </div>
<div> <div>
<p className="font-bold text-[#0A1128]">{data.channelName}</p> <p className="font-bold text-[#0A1128]">{data.channelName}</p>
<p className="text-sm text-slate-500">{data.handle}</p> {data.handle ? (
<a
href={`https://www.youtube.com/${data.handle.startsWith('@') || data.handle.startsWith('UC') ? data.handle : `@${data.handle}`}`}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-[#6C5CE7] hover:underline inline-flex items-center gap-1"
>
{data.handle}
<ExternalLink size={11} />
</a>
) : (
<p className="text-sm text-slate-500">{data.handle}</p>
)}
</div> </div>
</div> </div>
<p className="text-sm text-slate-600 mb-4">{data.channelDescription}</p> <p className="text-sm text-slate-600 mb-4">{data.channelDescription}</p>

View File

@ -994,7 +994,7 @@ export function mergeEnrichment(
name: '구글 지도', name: '구글 지도',
status: 'active' as const, status: 'active' as const,
details: `평점: ${gm.rating ?? '-'} / 리뷰: ${gm.reviewCount ?? '-'}`, details: `평점: ${gm.rating ?? '-'} / 리뷰: ${gm.reviewCount ?? '-'}`,
url: '', url: gm.website || (gm.name ? `https://www.google.com/maps/search/${encodeURIComponent(String(gm.name))}` : ''),
}; };
if (gmChannelIdx >= 0) { if (gmChannelIdx >= 0) {
merged.otherChannels[gmChannelIdx] = gmChannel; merged.otherChannels[gmChannelIdx] = gmChannel;
@ -1089,7 +1089,7 @@ export function mergeEnrichment(
name: '네이버 블로그', name: '네이버 블로그',
status: 'active' as const, status: 'active' as const,
details: `검색 결과: ${nb.totalResults?.toLocaleString() ?? '-'}건 / 최근 포스트 ${nb.posts?.length ?? 0}`, details: `검색 결과: ${nb.totalResults?.toLocaleString() ?? '-'}건 / 최근 포스트 ${nb.posts?.length ?? 0}`,
url: '', url: nb.posts?.[0]?.link || (nb.searchQuery ? `https://search.naver.com/search.naver?query=${encodeURIComponent(String(nb.searchQuery))}` : ''),
}; };
if (nbChannelIdx >= 0) { if (nbChannelIdx >= 0) {
merged.otherChannels[nbChannelIdx] = nbChannel; merged.otherChannels[nbChannelIdx] = nbChannel;