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
parent
bb7b08e35c
commit
66b4826f55
|
|
@ -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>
|
||||||
|
{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>
|
<p className="text-lg font-semibold text-[#0A1128] mt-1">{field.value}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
|
||||||
|
|
@ -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 */}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
{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>
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue