feat: add API Dashboard + filled icons + pipeline improvements
- Add /api-dashboard page with API connection status, env var checker, pipeline flow diagram, and cost estimator - Add 15 new filled SVG icons (Shield, Database, Server, Bolt, Eye, Copy, Check, Cross, Warning, Refresh, Flow, Coin, LinkExternal etc.) - Follow INFINITH design system: no emoji, no line icons, semantic status colors, diagonal shadows, brand gradients, font-serif headings - Improve Vision Analysis with base64 encoding fix - Add SectionErrorBoundary for graceful section-level error handling - Add Google Places API utility (prepared for future migration) - Fix Edge Function auth headers and report generation pipeline Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>claude/bold-hawking
parent
2ca9ec0306
commit
ec991057e6
|
|
@ -3,8 +3,9 @@
|
|||
"configurations": [
|
||||
{
|
||||
"name": "infinith-dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeExecutable": "/opt/homebrew/bin/npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"env": { "PATH": "/opt/homebrew/bin:/opt/homebrew/opt/node/bin:/usr/local/bin:/usr/bin:/bin" },
|
||||
"port": 3000
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -13,7 +13,14 @@
|
|||
"WebFetch(domain:socialblade.com)",
|
||||
"WebFetch(domain:www.facebook.com)",
|
||||
"mcp__mcp-registry__search_mcp_registry",
|
||||
"Bash(npx tsc:*)"
|
||||
"Bash(npx tsc:*)",
|
||||
"mcp__Claude_Preview__preview_start",
|
||||
"Bash(export PATH=\"/opt/homebrew/bin:/usr/local/bin:$PATH\")",
|
||||
"Bash(export PATH=\"/usr/local/bin:/opt/homebrew/bin:$PATH\")",
|
||||
"Bash(npx vite:*)",
|
||||
"Bash(vercel --version)",
|
||||
"Bash(find '/Users/haewonkam/Claude/Agentic Marketing/INFINITH' -name *.plan -o -name PLAN* -o -name plan* -o -name TODO* -o -name ROADMAP*)",
|
||||
"WebFetch(domain:api.apify.com)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,3 +7,4 @@ coverage/
|
|||
.env*
|
||||
!.env.example
|
||||
.vercel
|
||||
doc/
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
reference/
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"recommendations": ["denoland.vscode-deno"]
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"deno.enablePaths": [
|
||||
"supabase/functions"
|
||||
],
|
||||
"deno.lint": true,
|
||||
"deno.unstable": [
|
||||
"bare-node-builtins",
|
||||
"byonm",
|
||||
"sloppy-imports",
|
||||
"unsafe-proto",
|
||||
"webgpu",
|
||||
"broadcast-channel",
|
||||
"worker-options",
|
||||
"cron",
|
||||
"kv",
|
||||
"ffi",
|
||||
"fs",
|
||||
"http",
|
||||
"net"
|
||||
],
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "denoland.vscode-deno"
|
||||
}
|
||||
}
|
||||
|
|
@ -17,7 +17,7 @@ export default function Hero() {
|
|||
{/* Background Gradient */}
|
||||
<div className="absolute inset-0 -z-10 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-indigo-100 via-purple-50 to-pink-50 opacity-70"></div>
|
||||
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="max-w-4xl mx-auto relative isolate">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
|
|
@ -45,7 +45,7 @@ export default function Hero() {
|
|||
className="text-lg md:text-xl text-slate-600 mb-10 max-w-3xl mx-auto leading-relaxed"
|
||||
>
|
||||
Marketing that learns, improves, and accelerates — automatically. <br className="hidden md:block"/>
|
||||
쓸수록 더 정교해지는 AI 마케팅 엔진. 콘텐츠 기획, 생성, 영상 제작, 채널 배포, 데이터 분석까지 하나로.
|
||||
<span className="whitespace-nowrap">쓸수록 더 정교해지는</span>{' '}<span className="whitespace-nowrap">AI 마케팅 엔진.</span>{' '}<span className="whitespace-nowrap">콘텐츠 기획,</span>{' '}<span className="whitespace-nowrap">생성,</span>{' '}<span className="whitespace-nowrap">영상 제작,</span>{' '}<span className="whitespace-nowrap">채널 배포,</span>{' '}<span className="whitespace-nowrap">데이터 분석까지</span>{' '}<span className="whitespace-nowrap">하나로.</span>
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
|
|
@ -64,7 +64,7 @@ export default function Hero() {
|
|||
className="w-full px-8 py-5 text-base font-medium bg-white/80 backdrop-blur-sm border border-slate-200 rounded-2xl focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent/40 shadow-sm text-center text-primary-900 placeholder:text-slate-400 transition-all group-hover:border-slate-300"
|
||||
/>
|
||||
</div>
|
||||
<button onClick={handleAnalyze} className={`w-full px-8 py-5 text-base font-bold text-white rounded-2xl transition-all shadow-xl hover:shadow-2xl flex items-center justify-center gap-3 group hover:scale-[1.02] active:scale-[0.98] ${url.trim() ? 'bg-gradient-to-r from-accent to-[#021341]' : 'bg-slate-300 cursor-not-allowed'}`} disabled={!url.trim()}>
|
||||
<button onClick={handleAnalyze} className={`relative z-10 w-full px-8 py-5 text-base font-bold rounded-2xl transition-all flex items-center justify-center gap-3 group ${url.trim() ? 'bg-gradient-to-r from-accent to-[#8B5CF6] text-white shadow-xl hover:shadow-2xl hover:scale-[1.02] active:scale-[0.98]' : 'bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white shadow-sm'}`} disabled={!url.trim()}>
|
||||
Analyze Marketing Performance
|
||||
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
|
|
@ -74,10 +74,12 @@ export default function Hero() {
|
|||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Decorative elements */}
|
||||
<div className="absolute top-1/4 left-10 w-64 h-64 bg-purple-300 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-blob"></div>
|
||||
<div className="absolute top-1/3 right-10 w-64 h-64 bg-pink-300 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-blob animation-delay-2000"></div>
|
||||
<div className="absolute -bottom-8 left-1/2 -translate-x-1/2 w-64 h-64 bg-indigo-300 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-blob animation-delay-4000"></div>
|
||||
{/* Decorative elements — isolate prevents mix-blend-multiply from bleeding into content */}
|
||||
<div className="absolute inset-0 -z-[5] isolate pointer-events-none">
|
||||
<div className="absolute top-1/4 left-10 w-64 h-64 bg-purple-300 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-blob"></div>
|
||||
<div className="absolute top-1/3 right-10 w-64 h-64 bg-pink-300 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-blob animation-delay-2000"></div>
|
||||
<div className="absolute -bottom-8 left-1/2 -translate-x-1/2 w-64 h-64 bg-indigo-300 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-blob animation-delay-4000"></div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -134,6 +134,148 @@ export function MusicFilled({ size = 20, className = '' }: IconProps) {
|
|||
);
|
||||
}
|
||||
|
||||
/* ─── Dashboard / Utility Icons ─── */
|
||||
|
||||
export function ShieldFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<path d="M12 2L3 7V12C3 17.5 6.8 22.7 12 24C17.2 22.7 21 17.5 21 12V7L12 2Z" fill="currentColor" opacity="0.25" />
|
||||
<path d="M12 2L3 7V12C3 17.5 6.8 22.7 12 24C17.2 22.7 21 17.5 21 12V7L12 2Z" fill="currentColor" opacity="0.3" />
|
||||
<path d="M10 14L8.5 12.5L7.5 13.5L10 16L16.5 9.5L15.5 8.5L10 14Z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function DatabaseFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<ellipse cx="12" cy="6" rx="8" ry="3" fill="currentColor" opacity="0.35" />
|
||||
<path d="M4 6V12C4 13.66 7.58 15 12 15C16.42 15 20 13.66 20 12V6" fill="currentColor" opacity="0.25" />
|
||||
<path d="M4 12V18C4 19.66 7.58 21 12 21C16.42 21 20 19.66 20 18V12" fill="currentColor" opacity="0.35" />
|
||||
<ellipse cx="12" cy="18" rx="8" ry="3" fill="currentColor" opacity="0.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ServerFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<rect x="3" y="2" width="18" height="8" rx="2" fill="currentColor" opacity="0.3" />
|
||||
<rect x="3" y="14" width="18" height="8" rx="2" fill="currentColor" opacity="0.3" />
|
||||
<circle cx="7" cy="6" r="1.5" fill="currentColor" />
|
||||
<circle cx="7" cy="18" r="1.5" fill="currentColor" />
|
||||
<rect x="11" y="5" width="6" height="2" rx="1" fill="currentColor" opacity="0.5" />
|
||||
<rect x="11" y="17" width="6" height="2" rx="1" fill="currentColor" opacity="0.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BoltFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<path d="M13 2L4 14H11L10 22L20 10H13L13 2Z" fill="currentColor" opacity="0.25" />
|
||||
<path d="M13 2L4 14H11L10 22L20 10H13L13 2Z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function EyeFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<path d="M12 4.5C7 4.5 2.73 7.61 1 12C2.73 16.39 7 19.5 12 19.5C17 19.5 21.27 16.39 23 12C21.27 7.61 17 4.5 12 4.5Z" fill="currentColor" opacity="0.2" />
|
||||
<circle cx="12" cy="12" r="4" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function EyeOffFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<path d="M12 4.5C7 4.5 2.73 7.61 1 12C2.73 16.39 7 19.5 12 19.5C17 19.5 21.27 16.39 23 12C21.27 7.61 17 4.5 12 4.5Z" fill="currentColor" opacity="0.15" />
|
||||
<circle cx="12" cy="12" r="4" fill="currentColor" opacity="0.3" />
|
||||
<line x1="4" y1="4" x2="20" y2="20" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CopyFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<rect x="8" y="8" width="12" height="14" rx="2" fill="currentColor" opacity="0.25" />
|
||||
<rect x="4" y="2" width="12" height="14" rx="2" fill="currentColor" opacity="0.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CheckFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.25" />
|
||||
<path d="M8 12L11 15L16 9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CrossFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.25" />
|
||||
<path d="M8 8L16 16M16 8L8 16" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function WarningFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<path d="M12 2L1 21H23L12 2Z" fill="currentColor" opacity="0.25" />
|
||||
<rect x="11" y="9" width="2" height="6" rx="1" fill="currentColor" />
|
||||
<circle cx="12" cy="18" r="1.2" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function RefreshFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<circle cx="12" cy="12" r="9" fill="currentColor" opacity="0.15" />
|
||||
<path d="M17.65 6.35A7.95 7.95 0 0012 4C7.58 4 4 7.58 4 12C4 16.42 7.58 20 12 20C15.73 20 18.84 17.45 19.73 14H17.65C16.83 16.33 14.61 18 12 18C8.69 18 6 15.31 6 12C6 8.69 8.69 6 12 6C13.66 6 15.14 6.69 16.22 7.78L13 11H20V4L17.65 6.35Z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function FlowFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<rect x="2" y="3" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.35" />
|
||||
<rect x="9" y="9" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.5" />
|
||||
<rect x="16" y="15" width="6" height="6" rx="1.5" fill="currentColor" />
|
||||
<path d="M8 6H9.5C10.33 6 11 6.67 11 7.5V9" stroke="currentColor" strokeWidth="1.5" opacity="0.4" />
|
||||
<path d="M15 12H16.5C17.33 12 18 12.67 18 13.5V15" stroke="currentColor" strokeWidth="1.5" opacity="0.4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CoinFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.25" />
|
||||
<circle cx="12" cy="12" r="7" fill="currentColor" opacity="0.35" />
|
||||
<text x="12" y="16" textAnchor="middle" fontSize="10" fontWeight="bold" fill="currentColor">$</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LinkExternalFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<rect x="3" y="5" width="14" height="14" rx="2" fill="currentColor" opacity="0.2" />
|
||||
<path d="M14 3H21V10" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M21 3L10 14" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* InfinityLoopFilled — Infinite Marketing loop icon.
|
||||
* HubSpot-style infinity loop with gradient shading.
|
||||
|
|
|
|||
|
|
@ -10,22 +10,23 @@ interface KPIDashboardProps {
|
|||
clinicName?: string;
|
||||
}
|
||||
|
||||
function isNegativeValue(value: string): boolean {
|
||||
const lower = value.toLowerCase();
|
||||
function isNegativeValue(value: string | number): boolean {
|
||||
const lower = String(value).toLowerCase();
|
||||
return lower === '0' || lower.includes('없음') || lower.includes('불가') || lower === 'n/a' || lower === '-' || lower.includes('측정 불가');
|
||||
}
|
||||
|
||||
/** Format large numbers for readability: 150000 → 150K, 1500000 → 1.5M */
|
||||
function formatKpiValue(value: string): string {
|
||||
function formatKpiValue(value: string | number): string {
|
||||
const str = String(value ?? '');
|
||||
// If already formatted (contains K, M, %, ~, 월, 건, etc.) return as-is
|
||||
if (/[KkMm%~월건회개명/]/.test(value)) return value;
|
||||
if (/[KkMm%~월건회개명/]/.test(str)) return str;
|
||||
// Try to parse as pure number
|
||||
const num = parseInt(value.replace(/,/g, ''), 10);
|
||||
if (isNaN(num)) return value;
|
||||
const num = parseInt(str.replace(/,/g, ''), 10);
|
||||
if (isNaN(num)) return str;
|
||||
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
|
||||
if (num >= 10_000) return `${Math.round(num / 1_000)}K`;
|
||||
if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`;
|
||||
return value;
|
||||
return str;
|
||||
}
|
||||
|
||||
export default function KPIDashboard({ metrics, clinicName }: KPIDashboardProps) {
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ function clusterDiagnosis(items: DiagnosisItem[]): {
|
|||
const funnelItems: string[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const cat = item.category.toLowerCase();
|
||||
const det = item.detail.toLowerCase();
|
||||
const cat = String(item.category ?? '').toLowerCase();
|
||||
const det = String(item.detail ?? '').toLowerCase();
|
||||
|
||||
// Brand identity / consistency issues
|
||||
if (
|
||||
|
|
@ -91,6 +91,9 @@ function clusterDiagnosis(items: DiagnosisItem[]): {
|
|||
}
|
||||
|
||||
export default function ProblemDiagnosis({ diagnosis }: ProblemDiagnosisProps) {
|
||||
if (!diagnosis || !Array.isArray(diagnosis) || diagnosis.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const clusters = clusterDiagnosis(diagnosis);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import { Component, type ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
export class SectionErrorBoundary extends Component<Props, State> {
|
||||
state: State = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error) {
|
||||
console.error('[SectionErrorBoundary]', error.message, error.stack);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return this.props.fallback ?? (
|
||||
<div className="px-6 py-4 bg-red-50 border border-red-200 rounded-xl my-4 mx-4">
|
||||
<p className="text-xs font-mono text-red-600">섹션 렌더링 오류 (콘솔 확인)</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
|
@ -124,10 +124,12 @@ function buildDiagnosis(report: ApiReport): DiagnosisItem[] {
|
|||
// AI-generated per-channel diagnosis array (new format)
|
||||
if (ch.diagnosis) {
|
||||
for (const d of ch.diagnosis) {
|
||||
if (d.issue) {
|
||||
const issue = typeof d.issue === 'string' ? d.issue : typeof d === 'string' ? d : JSON.stringify(d.issue ?? d);
|
||||
if (issue) {
|
||||
const rec = typeof d.recommendation === 'string' ? d.recommendation : '';
|
||||
items.push({
|
||||
category: CHANNEL_ICONS[channel] ? channel : channel,
|
||||
detail: d.recommendation ? `${d.issue} — ${d.recommendation}` : d.issue,
|
||||
detail: rec ? `${issue} — ${rec}` : issue,
|
||||
severity: (d.severity as Severity) || 'warning',
|
||||
});
|
||||
}
|
||||
|
|
@ -143,7 +145,7 @@ function buildDiagnosis(report: ApiReport): DiagnosisItem[] {
|
|||
}
|
||||
if (ch.issues) {
|
||||
for (const issue of ch.issues) {
|
||||
items.push({ category: channel, detail: issue, severity: 'warning' });
|
||||
items.push({ category: channel, detail: typeof issue === 'string' ? issue : JSON.stringify(issue), severity: 'warning' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -274,6 +276,13 @@ function buildTransformation(r: ApiReport): import('../types/report').Transforma
|
|||
return { brandIdentity, contentStrategy, platformStrategies, websiteImprovements, newChannelProposals };
|
||||
}
|
||||
|
||||
/** Safely format KPI values — handles numbers, strings, and nulls from AI output */
|
||||
function fmtKpi(v: unknown): string {
|
||||
if (v == null) return '-';
|
||||
if (typeof v === 'number') return fmt(v);
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function fmt(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}K`;
|
||||
|
|
@ -468,15 +477,15 @@ function buildKpiDashboard(r: ApiReport): import('../types/report').KPIMetric[]
|
|||
// Use AI's gangnamunni data — update existing or add
|
||||
const guIdx = metrics.findIndex(m => m.metric.includes('강남언니'));
|
||||
if (guIdx >= 0) {
|
||||
metrics[guIdx] = { metric: k.metric, current: k.current || '-', target3Month: k.target3Month || '-', target12Month: k.target12Month || '-' };
|
||||
metrics[guIdx] = { metric: k.metric, current: fmtKpi(k.current), target3Month: fmtKpi(k.target3Month), target12Month: fmtKpi(k.target12Month) };
|
||||
continue;
|
||||
}
|
||||
}
|
||||
metrics.push({
|
||||
metric: k.metric,
|
||||
current: k.current || '-',
|
||||
target3Month: k.target3Month || '-',
|
||||
target12Month: k.target12Month || '-',
|
||||
current: fmtKpi(k.current),
|
||||
target3Month: fmtKpi(k.target3Month),
|
||||
target12Month: fmtKpi(k.target12Month),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import DistributionPage from './pages/DistributionPage.tsx';
|
|||
import PerformancePage from './pages/PerformancePage.tsx';
|
||||
import DataValidationPage from './pages/DataValidationPage.tsx';
|
||||
import ClinicProfilePage from './pages/ClinicProfilePage.tsx';
|
||||
import ApiDashboardPage from './pages/ApiDashboardPage.tsx';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
|
|
@ -30,6 +31,7 @@ createRoot(document.getElementById('root')!).render(
|
|||
<Route path="performance" element={<PerformancePage />} />
|
||||
<Route path="data-validation" element={<DataValidationPage />} />
|
||||
<Route path="clinic/:id" element={<ClinicProfilePage />} />
|
||||
<Route path="api-dashboard" element={<ApiDashboardPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,621 @@
|
|||
import { useState, useEffect, type ReactNode } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import {
|
||||
ShieldFilled, DatabaseFilled, ServerFilled, BoltFilled,
|
||||
EyeFilled, EyeOffFilled, CopyFilled, CheckFilled, CrossFilled,
|
||||
WarningFilled, RefreshFilled, FlowFilled, CoinFilled,
|
||||
LinkExternalFilled, GlobeFilled, PrismFilled,
|
||||
YoutubeFilled, VideoFilled, MegaphoneFilled,
|
||||
} from '../components/icons/FilledIcons';
|
||||
|
||||
/* ───────────────────────────── Types ───────────────────────────── */
|
||||
|
||||
type ApiStatus = 'connected' | 'missing' | 'error';
|
||||
type ApiLayer = 'frontend' | 'edge-function' | 'both';
|
||||
|
||||
interface ApiConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
envKeys: string[];
|
||||
layer: ApiLayer;
|
||||
docsUrl: string;
|
||||
IconComponent: (props: { size?: number; className?: string }) => ReactNode;
|
||||
category: 'ai' | 'scraping' | 'social' | 'platform';
|
||||
pricingModel: string;
|
||||
usedInPhases: string[];
|
||||
estimatedCostPerRun?: string;
|
||||
rateLimit?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
interface ApiStatusInfo extends ApiConfig {
|
||||
status: ApiStatus;
|
||||
keyPresent: Record<string, boolean>;
|
||||
}
|
||||
|
||||
/* ──────────────────── API Registry (source of truth) ──────────── */
|
||||
|
||||
const API_REGISTRY: ApiConfig[] = [
|
||||
{
|
||||
id: 'supabase',
|
||||
name: 'Supabase',
|
||||
description: 'PostgreSQL DB + Auth + Edge Functions 호스팅',
|
||||
envKeys: ['VITE_SUPABASE_URL', 'VITE_SUPABASE_ANON_KEY'],
|
||||
layer: 'both',
|
||||
docsUrl: 'https://supabase.com/docs',
|
||||
IconComponent: BoltFilled,
|
||||
category: 'platform',
|
||||
pricingModel: 'Free tier → Pro $25/mo',
|
||||
usedInPhases: ['All phases'],
|
||||
rateLimit: 'Edge Functions: 500K invocations/mo (free)',
|
||||
notes: '모든 데이터 저장 및 Edge Function 실행 플랫폼',
|
||||
},
|
||||
{
|
||||
id: 'perplexity',
|
||||
name: 'Perplexity AI',
|
||||
description: 'LLM 기반 시장 분석 및 리포트 생성',
|
||||
envKeys: ['PERPLEXITY_API_KEY'],
|
||||
layer: 'edge-function',
|
||||
docsUrl: 'https://docs.perplexity.ai',
|
||||
IconComponent: PrismFilled,
|
||||
category: 'ai',
|
||||
pricingModel: 'Sonar: $1/1K requests',
|
||||
usedInPhases: ['Phase 1: discover', 'Phase 2: collect', 'Phase 3: generate'],
|
||||
estimatedCostPerRun: '~$0.05-0.15',
|
||||
rateLimit: '50 RPM (rate limit)',
|
||||
notes: 'discover(2회) + collect(4회 병렬) + generate(1회) = 분석당 ~7회 호출',
|
||||
},
|
||||
{
|
||||
id: 'firecrawl',
|
||||
name: 'Firecrawl',
|
||||
description: '웹 스크래핑, 스크린샷 캡처, 구조화 데이터 추출',
|
||||
envKeys: ['FIRECRAWL_API_KEY'],
|
||||
layer: 'edge-function',
|
||||
docsUrl: 'https://docs.firecrawl.dev',
|
||||
IconComponent: GlobeFilled,
|
||||
category: 'scraping',
|
||||
pricingModel: 'Free 500 credits → $19/mo',
|
||||
usedInPhases: ['Phase 1: discover', 'Phase 2: collect'],
|
||||
estimatedCostPerRun: '~5-10 credits',
|
||||
rateLimit: '3 RPM (free), 20 RPM (paid)',
|
||||
notes: 'scrape + map + search + fullPage screenshot',
|
||||
},
|
||||
{
|
||||
id: 'apify',
|
||||
name: 'Apify',
|
||||
description: 'Instagram/Facebook/Google Places 데이터 수집',
|
||||
envKeys: ['APIFY_API_TOKEN'],
|
||||
layer: 'edge-function',
|
||||
docsUrl: 'https://docs.apify.com',
|
||||
IconComponent: ServerFilled,
|
||||
category: 'scraping',
|
||||
pricingModel: 'Free $5/mo → Starter $49/mo',
|
||||
usedInPhases: ['Phase 1: discover', 'Phase 2: collect', 'Enrich'],
|
||||
estimatedCostPerRun: '~$0.10-0.30',
|
||||
rateLimit: 'Actor-dependent (30-120s timeout)',
|
||||
notes: 'IG profile/posts/reels + FB pages + Google Places actors',
|
||||
},
|
||||
{
|
||||
id: 'youtube',
|
||||
name: 'YouTube Data API v3',
|
||||
description: 'YouTube 채널 검색, 통계, 영상 데이터',
|
||||
envKeys: ['YOUTUBE_API_KEY'],
|
||||
layer: 'edge-function',
|
||||
docsUrl: 'https://developers.google.com/youtube/v3',
|
||||
IconComponent: YoutubeFilled,
|
||||
category: 'social',
|
||||
pricingModel: 'Free (10,000 units/day)',
|
||||
usedInPhases: ['Phase 1: discover', 'Phase 2: collect'],
|
||||
estimatedCostPerRun: '~200-400 units',
|
||||
rateLimit: '10,000 units/day',
|
||||
notes: 'search(100u) + channels(5u) + videos(5u each)',
|
||||
},
|
||||
{
|
||||
id: 'naver',
|
||||
name: 'Naver Search API',
|
||||
description: '네이버 블로그/웹/플레이스 검색',
|
||||
envKeys: ['NAVER_CLIENT_ID', 'NAVER_CLIENT_SECRET'],
|
||||
layer: 'edge-function',
|
||||
docsUrl: 'https://developers.naver.com/docs/serviceapi/search/blog/blog.md',
|
||||
IconComponent: MegaphoneFilled,
|
||||
category: 'social',
|
||||
pricingModel: 'Free (25,000/day)',
|
||||
usedInPhases: ['Phase 1: discover', 'Phase 2: collect'],
|
||||
estimatedCostPerRun: 'Free',
|
||||
rateLimit: '25,000 calls/day',
|
||||
notes: 'blog.json + webkr.json + local.json',
|
||||
},
|
||||
{
|
||||
id: 'gemini',
|
||||
name: 'Google Gemini',
|
||||
description: 'AI 이미지 생성 + Vision 분석 (스크린샷 OCR)',
|
||||
envKeys: ['GEMINI_API_KEY'],
|
||||
layer: 'both',
|
||||
docsUrl: 'https://ai.google.dev/docs',
|
||||
IconComponent: DatabaseFilled,
|
||||
category: 'ai',
|
||||
pricingModel: 'Free tier → Pay-as-you-go',
|
||||
usedInPhases: ['Phase 2: collect (Vision)', 'Studio (Image Gen)'],
|
||||
estimatedCostPerRun: '~$0.01-0.05',
|
||||
rateLimit: '15 RPM (free), 1000 RPM (paid)',
|
||||
notes: 'gemini-2.5-flash-image 모델 사용',
|
||||
},
|
||||
{
|
||||
id: 'creatomate',
|
||||
name: 'Creatomate',
|
||||
description: '마케팅 영상 자동 생성 (템플릿 기반)',
|
||||
envKeys: ['VITE_CREATOMATE_API_KEY'],
|
||||
layer: 'frontend',
|
||||
docsUrl: 'https://creatomate.com/docs/api/introduction',
|
||||
IconComponent: VideoFilled,
|
||||
category: 'ai',
|
||||
pricingModel: 'Free 5 renders → Pro $39/mo',
|
||||
usedInPhases: ['Studio'],
|
||||
estimatedCostPerRun: '1 render credit',
|
||||
rateLimit: '2 concurrent renders (free)',
|
||||
notes: 'POST /renders → poll until complete',
|
||||
},
|
||||
];
|
||||
|
||||
/* ────────────────── Environment Variable Checker ─────────────── */
|
||||
|
||||
function checkEnvVars(): ApiStatusInfo[] {
|
||||
return API_REGISTRY.map((api) => {
|
||||
const keyPresent: Record<string, boolean> = {};
|
||||
for (const key of api.envKeys) {
|
||||
if (key.startsWith('VITE_')) {
|
||||
const val = (import.meta.env as Record<string, string | undefined>)[key];
|
||||
keyPresent[key] = !!val && val.length > 0;
|
||||
} else {
|
||||
keyPresent[key] = true;
|
||||
}
|
||||
}
|
||||
|
||||
const allPresent = Object.values(keyPresent).every(Boolean);
|
||||
const nonePresent = Object.values(keyPresent).every((v) => !v);
|
||||
|
||||
return {
|
||||
...api,
|
||||
status: nonePresent ? 'missing' : allPresent ? 'connected' : 'error',
|
||||
keyPresent,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/* ──────────────────────── Sub-components ──────────────────────── */
|
||||
|
||||
function StatusDot({ status }: { status: ApiStatus }) {
|
||||
const styles = {
|
||||
connected: 'bg-status-good-dot',
|
||||
missing: 'bg-status-critical-dot',
|
||||
error: 'bg-status-warning-dot',
|
||||
};
|
||||
return (
|
||||
<span className="relative flex h-3 w-3">
|
||||
{status === 'connected' && (
|
||||
<span className={`animate-ping absolute inline-flex h-full w-full rounded-full opacity-40 ${styles[status]}`} />
|
||||
)}
|
||||
<span className={`relative inline-flex rounded-full h-3 w-3 ${styles[status]}`} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: ApiStatus }) {
|
||||
const config = {
|
||||
connected: { bg: 'bg-status-good-bg', text: 'text-status-good-text', border: 'border-status-good-border', label: 'Connected', Icon: CheckFilled },
|
||||
missing: { bg: 'bg-status-critical-bg', text: 'text-status-critical-text', border: 'border-status-critical-border', label: 'Missing Key', Icon: CrossFilled },
|
||||
error: { bg: 'bg-status-warning-bg', text: 'text-status-warning-text', border: 'border-status-warning-border', label: 'Partial', Icon: WarningFilled },
|
||||
};
|
||||
const c = config[status];
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-semibold rounded-full border ${c.bg} ${c.text} ${c.border}`}>
|
||||
<c.Icon size={12} className={c.text} />
|
||||
{c.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function LayerBadge({ layer }: { layer: ApiLayer }) {
|
||||
const config = {
|
||||
frontend: { bg: 'bg-status-info-bg', text: 'text-status-info-text', border: 'border-status-info-border', label: 'Frontend', Icon: GlobeFilled },
|
||||
'edge-function': { bg: 'bg-status-good-bg', text: 'text-status-good-text', border: 'border-status-good-border', label: 'Edge Function', Icon: ServerFilled },
|
||||
both: { bg: 'bg-status-warning-bg', text: 'text-status-warning-text', border: 'border-status-warning-border', label: 'Both', Icon: BoltFilled },
|
||||
};
|
||||
const c = config[layer];
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 text-[11px] font-medium rounded-md border ${c.bg} ${c.text} ${c.border}`}>
|
||||
<c.Icon size={12} className={c.text} />
|
||||
{c.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryBadge({ category }: { category: ApiConfig['category'] }) {
|
||||
const config = {
|
||||
ai: { bg: 'bg-status-good-bg', text: 'text-status-good-text', label: 'AI' },
|
||||
scraping: { bg: 'bg-status-warning-bg', text: 'text-status-warning-text', label: 'Scraping' },
|
||||
social: { bg: 'bg-status-info-bg', text: 'text-status-info-text', label: 'Social' },
|
||||
platform: { bg: 'bg-primary-50', text: 'text-primary-800', label: 'Platform' },
|
||||
};
|
||||
const c = config[category];
|
||||
return (
|
||||
<span className={`px-2 py-0.5 text-[11px] font-medium rounded-md ${c.bg} ${c.text}`}>
|
||||
{c.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function EnvKeyRow({ envKey, present, isServerSide }: { envKey: string; present: boolean; isServerSide: boolean }) {
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const value = isServerSide
|
||||
? '(Edge Function 환경변수 — 프론트엔드에서 확인 불가)'
|
||||
: showKey
|
||||
? (import.meta.env as Record<string, string | undefined>)[envKey] || ''
|
||||
: '••••••••••••••••';
|
||||
|
||||
const handleCopy = () => {
|
||||
if (!isServerSide) {
|
||||
navigator.clipboard.writeText((import.meta.env as Record<string, string | undefined>)[envKey] || '');
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-1.5 px-3 rounded-lg bg-primary-50 text-xs font-mono group">
|
||||
<StatusDot status={present ? 'connected' : 'missing'} />
|
||||
<span className="text-slate-500 min-w-[200px]">{envKey}</span>
|
||||
<span className="text-slate-400 flex-1 truncate">{value}</span>
|
||||
{!isServerSide && (
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={() => setShowKey(!showKey)} className="p-1 hover:bg-slate-200 rounded">
|
||||
{showKey
|
||||
? <EyeOffFilled size={14} className="text-slate-400" />
|
||||
: <EyeFilled size={14} className="text-slate-400" />}
|
||||
</button>
|
||||
<button onClick={handleCopy} className="p-1 hover:bg-slate-200 rounded">
|
||||
{copied
|
||||
? <CheckFilled size={14} className="text-status-good-dot" />
|
||||
: <CopyFilled size={14} className="text-slate-400" />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiCard({ api, index }: { api: ApiStatusInfo; index: number }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: index * 0.05 }}
|
||||
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)] transition-shadow overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="w-full px-6 py-5 flex items-center gap-4 text-left"
|
||||
>
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-xl bg-accent/10 flex items-center justify-center">
|
||||
<api.IconComponent size={20} className="text-accent" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-serif font-bold text-primary-900 text-base">{api.name}</h3>
|
||||
<StatusDot status={api.status} />
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 truncate">{api.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<CategoryBadge category={api.category} />
|
||||
<LayerBadge layer={api.layer} />
|
||||
<StatusBadge status={api.status} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Expandable detail */}
|
||||
{expanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="border-t border-slate-100 px-6 py-4 space-y-4"
|
||||
>
|
||||
{/* Env Keys */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Environment Variables</h4>
|
||||
<div className="space-y-1.5">
|
||||
{api.envKeys.map((key) => (
|
||||
<EnvKeyRow
|
||||
key={key}
|
||||
envKey={key}
|
||||
present={api.keyPresent[key]}
|
||||
isServerSide={!key.startsWith('VITE_')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="bg-primary-50 rounded-xl p-3">
|
||||
<div className="text-[11px] text-slate-400 font-medium mb-1">Pricing</div>
|
||||
<div className="text-xs font-semibold text-slate-700">{api.pricingModel}</div>
|
||||
</div>
|
||||
{api.estimatedCostPerRun && (
|
||||
<div className="bg-primary-50 rounded-xl p-3">
|
||||
<div className="text-[11px] text-slate-400 font-medium mb-1">Cost / Run</div>
|
||||
<div className="text-xs font-semibold text-slate-700">{api.estimatedCostPerRun}</div>
|
||||
</div>
|
||||
)}
|
||||
{api.rateLimit && (
|
||||
<div className="bg-primary-50 rounded-xl p-3">
|
||||
<div className="text-[11px] text-slate-400 font-medium mb-1">Rate Limit</div>
|
||||
<div className="text-xs font-semibold text-slate-700">{api.rateLimit}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-primary-50 rounded-xl p-3">
|
||||
<div className="text-[11px] text-slate-400 font-medium mb-1">Used In</div>
|
||||
<div className="text-xs font-semibold text-slate-700">{api.usedInPhases.join(', ')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes + Docs link */}
|
||||
<div className="flex items-center justify-between">
|
||||
{api.notes && (
|
||||
<p className="text-xs text-slate-500 italic">{api.notes}</p>
|
||||
)}
|
||||
<a
|
||||
href={api.docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-accent hover:underline flex-shrink-0"
|
||||
>
|
||||
API Docs <LinkExternalFilled size={12} className="text-accent" />
|
||||
</a>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────────── Pipeline Flow Diagram ───────────────────── */
|
||||
|
||||
function PipelineFlow({ apis }: { apis: ApiStatusInfo[] }) {
|
||||
const phases = [
|
||||
{ name: 'Phase 1', label: 'Discover Channels', apis: ['firecrawl', 'youtube', 'naver', 'perplexity', 'apify'] },
|
||||
{ name: 'Phase 2', label: 'Collect Data', apis: ['apify', 'youtube', 'firecrawl', 'naver', 'perplexity', 'gemini'] },
|
||||
{ name: 'Phase 3', label: 'Generate Report', apis: ['perplexity'] },
|
||||
{ name: 'Studio', label: 'Content Creation', apis: ['gemini', 'creatomate'] },
|
||||
];
|
||||
|
||||
const apiMap = Object.fromEntries(apis.map((a) => [a.id, a]));
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6">
|
||||
<h3 className="font-serif font-bold text-primary-900 text-base mb-5 flex items-center gap-2">
|
||||
<FlowFilled size={18} className="text-accent" />
|
||||
Pipeline API Flow
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{phases.map((phase, idx) => (
|
||||
<div key={phase.name} className="relative">
|
||||
<div className="bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white text-xs font-bold px-3 py-1.5 rounded-t-xl">
|
||||
{phase.name}: {phase.label}
|
||||
</div>
|
||||
<div className="border border-t-0 border-slate-200 rounded-b-xl p-3 space-y-1.5 min-h-[100px]">
|
||||
{phase.apis.map((apiId) => {
|
||||
const api = apiMap[apiId];
|
||||
if (!api) return null;
|
||||
return (
|
||||
<div key={apiId} className="flex items-center gap-2 text-xs">
|
||||
<StatusDot status={api.status} />
|
||||
<api.IconComponent size={14} className="text-slate-400" />
|
||||
<span className="text-slate-600">{api.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{idx < phases.length - 1 && (
|
||||
<div className="hidden md:flex absolute -right-3 top-1/2 transform -translate-y-1/2 z-10">
|
||||
<ArrowRight className="w-4 h-4 text-slate-300" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ────────────────────── Cost Estimator ────────────────────────── */
|
||||
|
||||
function CostEstimator({ apis }: { apis: ApiStatusInfo[] }) {
|
||||
const costItems = apis
|
||||
.filter((a) => a.estimatedCostPerRun)
|
||||
.map((a) => ({
|
||||
name: a.name,
|
||||
IconComponent: a.IconComponent,
|
||||
cost: a.estimatedCostPerRun!,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6">
|
||||
<h3 className="font-serif font-bold text-primary-900 text-base mb-4 flex items-center gap-2">
|
||||
<CoinFilled size={18} className="text-accent" />
|
||||
Estimated Cost per Analysis Run
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{costItems.map((item) => (
|
||||
<div key={item.name} className="flex items-center justify-between py-2 px-3 bg-primary-50 rounded-xl">
|
||||
<span className="text-sm text-slate-700 flex items-center gap-2">
|
||||
<item.IconComponent size={16} className="text-slate-400" />
|
||||
{item.name}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-primary-900">{item.cost}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center justify-between py-3 px-3 bg-accent/5 rounded-xl border border-accent/20 mt-3">
|
||||
<span className="text-sm font-bold text-primary-900">Total (estimated)</span>
|
||||
<span className="text-sm font-black text-accent">~$0.26-0.80 / run</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────────────── Main Page ───────────────────────────── */
|
||||
|
||||
export default function ApiDashboardPage() {
|
||||
const [apis, setApis] = useState<ApiStatusInfo[]>([]);
|
||||
const [lastChecked, setLastChecked] = useState<Date | null>(null);
|
||||
const [filter, setFilter] = useState<'all' | 'connected' | 'missing'>('all');
|
||||
|
||||
const refreshStatus = () => {
|
||||
setApis(checkEnvVars());
|
||||
setLastChecked(new Date());
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refreshStatus();
|
||||
}, []);
|
||||
|
||||
const connectedCount = apis.filter((a) => a.status === 'connected').length;
|
||||
const missingCount = apis.filter((a) => a.status === 'missing').length;
|
||||
const errorCount = apis.filter((a) => a.status === 'error').length;
|
||||
|
||||
const filteredApis = filter === 'all' ? apis : apis.filter((a) => a.status === filter);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 pt-28 pb-16 px-6">
|
||||
<div className="max-w-7xl mx-auto space-y-8">
|
||||
|
||||
{/* Page Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2.5 bg-accent/10 rounded-xl">
|
||||
<ShieldFilled size={24} className="text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-serif text-3xl font-bold text-primary-900">API Dashboard</h1>
|
||||
<p className="text-sm text-slate-500">API 연결 상태, 환경변수, 사용량, 비용 현황</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className="grid grid-cols-2 md:grid-cols-4 gap-4"
|
||||
>
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-5">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<DatabaseFilled size={16} className="text-slate-400" />
|
||||
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">Total APIs</span>
|
||||
</div>
|
||||
<div className="text-3xl font-black text-primary-900">{apis.length}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-5">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<CheckFilled size={16} className="text-status-good-dot" />
|
||||
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">Connected</span>
|
||||
</div>
|
||||
<div className="text-3xl font-black text-status-good-text">{connectedCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-5">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<CrossFilled size={16} className="text-status-critical-dot" />
|
||||
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">Missing</span>
|
||||
</div>
|
||||
<div className="text-3xl font-black text-status-critical-text">{missingCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-5">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<WarningFilled size={16} className="text-status-warning-dot" />
|
||||
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">Partial</span>
|
||||
</div>
|
||||
<div className="text-3xl font-black text-status-warning-text">{errorCount}</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Pipeline Flow */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.15 }}
|
||||
>
|
||||
<PipelineFlow apis={apis} />
|
||||
</motion.div>
|
||||
|
||||
{/* Filter + Refresh */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2">
|
||||
{(['all', 'connected', 'missing'] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-5 py-2.5 text-xs font-semibold rounded-full transition-all ${
|
||||
filter === f
|
||||
? 'bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white shadow-[3px_4px_12px_rgba(0,0,0,0.06)]'
|
||||
: 'bg-white text-slate-500 border border-slate-200 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'All' : f === 'connected' ? 'Connected' : 'Missing'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{lastChecked && (
|
||||
<span className="text-xs text-slate-400">
|
||||
Last checked: {lastChecked.toLocaleTimeString('ko-KR')}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={refreshStatus}
|
||||
className="inline-flex items-center gap-1.5 px-5 py-2.5 text-xs font-semibold bg-white border border-slate-200 rounded-full hover:bg-slate-50 transition-all text-slate-600"
|
||||
>
|
||||
<RefreshFilled size={14} className="text-slate-500" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Cards */}
|
||||
<div className="space-y-3">
|
||||
{filteredApis.map((api, idx) => (
|
||||
<ApiCard key={api.id} api={api} index={idx} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Cost Estimator */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
>
|
||||
<CostEstimator apis={apis} />
|
||||
</motion.div>
|
||||
|
||||
{/* Footer note */}
|
||||
<div className="text-center text-xs text-slate-400 pt-4">
|
||||
<PrismFilled size={14} className="text-slate-300 inline mr-1" />
|
||||
VITE_ 접두사 키만 프론트엔드에서 확인 가능합니다. Edge Function 키는 Supabase Dashboard에서 확인하세요.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { ReportNav } from '../components/report/ReportNav';
|
|||
import { ScreenshotProvider } from '../contexts/ScreenshotContext';
|
||||
|
||||
// Report section components
|
||||
import { SectionErrorBoundary } from '../components/report/ui/SectionErrorBoundary';
|
||||
import ReportHeader from '../components/report/ReportHeader';
|
||||
import ClinicSnapshot from '../components/report/ClinicSnapshot';
|
||||
import ChannelOverview from '../components/report/ChannelOverview';
|
||||
|
|
@ -127,28 +128,28 @@ export default function ReportPage() {
|
|||
brandColors={data.clinicSnapshot.brandColors}
|
||||
/>
|
||||
|
||||
<ClinicSnapshot data={data.clinicSnapshot} />
|
||||
<SectionErrorBoundary><ClinicSnapshot data={data.clinicSnapshot} /></SectionErrorBoundary>
|
||||
|
||||
<ChannelOverview channels={data.channelScores} />
|
||||
<SectionErrorBoundary><ChannelOverview channels={data.channelScores} /></SectionErrorBoundary>
|
||||
|
||||
<YouTubeAudit data={data.youtubeAudit} />
|
||||
<SectionErrorBoundary><YouTubeAudit data={data.youtubeAudit} /></SectionErrorBoundary>
|
||||
|
||||
<InstagramAudit data={data.instagramAudit} />
|
||||
<SectionErrorBoundary><InstagramAudit data={data.instagramAudit} /></SectionErrorBoundary>
|
||||
|
||||
<FacebookAudit data={data.facebookAudit} />
|
||||
<SectionErrorBoundary><FacebookAudit data={data.facebookAudit} /></SectionErrorBoundary>
|
||||
|
||||
<OtherChannels
|
||||
<SectionErrorBoundary><OtherChannels
|
||||
channels={data.otherChannels}
|
||||
website={data.websiteAudit}
|
||||
/>
|
||||
/></SectionErrorBoundary>
|
||||
|
||||
<ProblemDiagnosis diagnosis={data.problemDiagnosis} />
|
||||
<SectionErrorBoundary><ProblemDiagnosis diagnosis={data.problemDiagnosis} /></SectionErrorBoundary>
|
||||
|
||||
<TransformationProposal data={data.transformation} />
|
||||
<SectionErrorBoundary><TransformationProposal data={data.transformation} /></SectionErrorBoundary>
|
||||
|
||||
<RoadmapTimeline months={data.roadmap} />
|
||||
<SectionErrorBoundary><RoadmapTimeline months={data.roadmap} /></SectionErrorBoundary>
|
||||
|
||||
<KPIDashboard metrics={data.kpiDashboard} clinicName={data.clinicSnapshot.name} />
|
||||
<SectionErrorBoundary><KPIDashboard metrics={data.kpiDashboard} clinicName={data.clinicSnapshot.name} /></SectionErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</ScreenshotProvider>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,212 @@
|
|||
/**
|
||||
* Google Places API (New) — Direct REST client.
|
||||
*
|
||||
* Replaces Apify `compass~crawler-google-places` actor with official API.
|
||||
* Benefits: 1-2s latency (vs 30-120s), stable contract, ToS compliant.
|
||||
*
|
||||
* API docs: https://developers.google.com/maps/documentation/places/web-service/op-overview
|
||||
*
|
||||
* Pricing (per 1K calls):
|
||||
* - Text Search (Basic): $32 → but we use field mask to reduce
|
||||
* - Place Details (Basic): $0 (id-only fields)
|
||||
* - Place Details (Contact): $3 (phone, website)
|
||||
* - Place Details (Atmos): $5 (reviews, rating)
|
||||
*
|
||||
* Typical cost per clinic: ~$0.04 (1 Text Search + 1 Details)
|
||||
*/
|
||||
|
||||
import { fetchWithRetry } from "./retry.ts";
|
||||
|
||||
// ─── Types ───
|
||||
|
||||
export interface GooglePlaceResult {
|
||||
name: string; // e.g. "뷰성형외과의원"
|
||||
rating: number | null; // e.g. 4.3
|
||||
reviewCount: number; // e.g. 387
|
||||
address: string; // formatted address
|
||||
phone: string; // international format
|
||||
clinicWebsite: string; // clinic's own website
|
||||
mapsUrl: string; // Google Maps URL
|
||||
category: string; // primary type
|
||||
placeId: string; // for future lookups
|
||||
openingHours: Record<string, unknown> | null;
|
||||
topReviews: {
|
||||
stars: number;
|
||||
text: string;
|
||||
publishedAtDate: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
// ─── Constants ───
|
||||
|
||||
const PLACES_BASE = "https://places.googleapis.com/v1";
|
||||
|
||||
// Fields we need from Text Search (covers Basic + Contact + Atmosphere tiers)
|
||||
const TEXT_SEARCH_FIELDS = [
|
||||
"places.id",
|
||||
"places.displayName",
|
||||
"places.formattedAddress",
|
||||
"places.rating",
|
||||
"places.userRatingCount",
|
||||
"places.nationalPhoneNumber",
|
||||
"places.internationalPhoneNumber",
|
||||
"places.websiteUri",
|
||||
"places.googleMapsUri",
|
||||
"places.primaryType",
|
||||
"places.primaryTypeDisplayName",
|
||||
"places.regularOpeningHours",
|
||||
"places.reviews",
|
||||
].join(",");
|
||||
|
||||
// ─── Main Function ───
|
||||
|
||||
/**
|
||||
* Search for a clinic on Google Places and return structured data.
|
||||
* Tries multiple queries in order until a match is found.
|
||||
*/
|
||||
export async function searchGooglePlace(
|
||||
clinicName: string,
|
||||
address: string | undefined,
|
||||
apiKey: string,
|
||||
): Promise<GooglePlaceResult | null> {
|
||||
const queries = [
|
||||
`${clinicName} 성형외과`,
|
||||
clinicName,
|
||||
`${clinicName} ${address || "강남"}`,
|
||||
];
|
||||
|
||||
for (const query of queries) {
|
||||
const result = await textSearch(query, apiKey);
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Places Text Search (New) API call.
|
||||
* Returns the first matching place with full details in a single call.
|
||||
*/
|
||||
async function textSearch(
|
||||
query: string,
|
||||
apiKey: string,
|
||||
): Promise<GooglePlaceResult | null> {
|
||||
const res = await fetchWithRetry(
|
||||
`${PLACES_BASE}/places:searchText`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Goog-Api-Key": apiKey,
|
||||
"X-Goog-FieldMask": TEXT_SEARCH_FIELDS,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
textQuery: query,
|
||||
languageCode: "ko",
|
||||
regionCode: "KR",
|
||||
maxResultCount: 3,
|
||||
}),
|
||||
},
|
||||
{
|
||||
maxRetries: 1,
|
||||
timeoutMs: 15000,
|
||||
label: `google-places:textSearch`,
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text().catch(() => "");
|
||||
throw new Error(`Google Places Text Search ${res.status}: ${errText}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const places = data.places as Record<string, unknown>[] | undefined;
|
||||
|
||||
if (!places || places.length === 0) return null;
|
||||
|
||||
// Use first result (most relevant)
|
||||
return mapPlaceToResult(places[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Google Places API response to our internal structure.
|
||||
* Maintains the same field names as the old Apify-based structure
|
||||
* for backward compatibility with transformReport.ts and UI components.
|
||||
*/
|
||||
function mapPlaceToResult(place: Record<string, unknown>): GooglePlaceResult {
|
||||
const displayName = place.displayName as Record<string, unknown> | undefined;
|
||||
const openingHours = place.regularOpeningHours as Record<string, unknown> | null;
|
||||
const reviews = (place.reviews as Record<string, unknown>[]) || [];
|
||||
const primaryTypeDisplay = place.primaryTypeDisplayName as Record<string, unknown> | undefined;
|
||||
|
||||
return {
|
||||
name: (displayName?.text as string) || "",
|
||||
rating: (place.rating as number) || null,
|
||||
reviewCount: (place.userRatingCount as number) || 0,
|
||||
address: (place.formattedAddress as string) || "",
|
||||
phone: (place.internationalPhoneNumber as string) || (place.nationalPhoneNumber as string) || "",
|
||||
clinicWebsite: (place.websiteUri as string) || "",
|
||||
mapsUrl: (place.googleMapsUri as string) || "",
|
||||
category: (primaryTypeDisplay?.text as string) || (place.primaryType as string) || "",
|
||||
placeId: (place.id as string) || "",
|
||||
openingHours: openingHours,
|
||||
topReviews: reviews.slice(0, 10).map((r) => {
|
||||
const authorAttribution = r.authorAttribution as Record<string, unknown> | undefined;
|
||||
const publishTime = r.publishTime as string || "";
|
||||
return {
|
||||
stars: (r.rating as number) || 0,
|
||||
text: ((r.text as Record<string, unknown>)?.text as string || "").slice(0, 500),
|
||||
publishedAtDate: publishTime,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Place Details by Place ID — for future use when we have cached placeIds
|
||||
* in the clinic_registry table. Cheaper than Text Search.
|
||||
*/
|
||||
export async function getPlaceDetails(
|
||||
placeId: string,
|
||||
apiKey: string,
|
||||
): Promise<GooglePlaceResult | null> {
|
||||
const fieldMask = [
|
||||
"id",
|
||||
"displayName",
|
||||
"formattedAddress",
|
||||
"rating",
|
||||
"userRatingCount",
|
||||
"nationalPhoneNumber",
|
||||
"internationalPhoneNumber",
|
||||
"websiteUri",
|
||||
"googleMapsUri",
|
||||
"primaryType",
|
||||
"primaryTypeDisplayName",
|
||||
"regularOpeningHours",
|
||||
"reviews",
|
||||
].join(",");
|
||||
|
||||
const res = await fetchWithRetry(
|
||||
`${PLACES_BASE}/places/${placeId}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
"X-Goog-Api-Key": apiKey,
|
||||
"X-Goog-FieldMask": fieldMask,
|
||||
},
|
||||
},
|
||||
{
|
||||
maxRetries: 1,
|
||||
timeoutMs: 10000,
|
||||
label: `google-places:details`,
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text().catch(() => "");
|
||||
throw new Error(`Google Places Details ${res.status}: ${errText}`);
|
||||
}
|
||||
|
||||
const place = await res.json();
|
||||
return mapPlaceToResult(place as Record<string, unknown>);
|
||||
}
|
||||
|
|
@ -131,29 +131,46 @@ export function findRelevantPages(
|
|||
): { doctorPage?: string; surgeryPage?: string; aboutPage?: string } {
|
||||
const result: { doctorPage?: string; surgeryPage?: string; aboutPage?: string } = {};
|
||||
|
||||
// Phase 1: Classify each URL into one category (doctor > surgery > about priority)
|
||||
const doctorUrls: string[] = [];
|
||||
const surgeryUrls: string[] = [];
|
||||
const aboutUrls: string[] = [];
|
||||
|
||||
for (const url of siteMap) {
|
||||
const lower = url.toLowerCase();
|
||||
if (!result.doctorPage && (
|
||||
lower.includes('/doctor') || lower.includes('/team') || lower.includes('/staff') ||
|
||||
|
||||
const isDoctor = (
|
||||
lower.includes('/doctor') || lower.includes('/doctors') ||
|
||||
lower.includes('/team') || lower.includes('/staff') ||
|
||||
lower.includes('/specialist') || lower.includes('/professor') ||
|
||||
lower.includes('/의료진') || lower.includes('/원장')
|
||||
)) {
|
||||
result.doctorPage = url;
|
||||
}
|
||||
if (!result.surgeryPage && (
|
||||
lower.includes('/의료진') || lower.includes('/원장') ||
|
||||
lower.includes('meet-our-doctor') // 하이픈 패턴 (그랜드성형외과 등)
|
||||
);
|
||||
const isSurgery = (
|
||||
lower.includes('/surgery') || lower.includes('/service') || lower.includes('/procedure') ||
|
||||
lower.includes('/treatment') || lower.includes('/시술') || lower.includes('/수술')
|
||||
)) {
|
||||
result.surgeryPage = url;
|
||||
}
|
||||
if (!result.aboutPage && (
|
||||
);
|
||||
const isAbout = (
|
||||
lower.includes('/about') || lower.includes('/intro') || lower.includes('/소개') ||
|
||||
lower.includes('/greeting')
|
||||
)) {
|
||||
result.aboutPage = url;
|
||||
lower.includes('/greeting') || lower.includes('/인사말') || lower.includes('/history') ||
|
||||
lower.includes('/연혁')
|
||||
);
|
||||
|
||||
// Priority: specific page type > generic /about
|
||||
if (isDoctor) {
|
||||
doctorUrls.push(url);
|
||||
} else if (isSurgery) {
|
||||
surgeryUrls.push(url);
|
||||
} else if (isAbout) {
|
||||
aboutUrls.push(url);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Pick best URL per category
|
||||
result.doctorPage = doctorUrls[0];
|
||||
result.surgeryPage = surgeryUrls[0];
|
||||
result.aboutPage = aboutUrls[0];
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -205,13 +222,72 @@ export async function captureAllScreenshots(
|
|||
}
|
||||
}
|
||||
|
||||
// Instagram EN (두 번째 계정이 있는 경우)
|
||||
if (igList.length > 1) {
|
||||
const igEn = igList[1];
|
||||
const handleEn = (igEn.handle as string || '').replace(/^@/, '');
|
||||
if (handleEn) {
|
||||
captureTargets.push({ id: 'instagram-en', url: `https://www.instagram.com/${handleEn}/`, channel: 'Instagram', caption: `Instagram @${handleEn} (EN)` });
|
||||
}
|
||||
}
|
||||
|
||||
// Facebook
|
||||
const fb = verifiedChannels.facebook as Record<string, unknown> | null;
|
||||
if (fb?.verified || fb?.verified === 'unverifiable') {
|
||||
const fbUrl = (fb.url as string) || `https://www.facebook.com/${fb.handle}`;
|
||||
captureTargets.push({ id: 'facebook-page', url: fbUrl, channel: 'Facebook', caption: 'Facebook 페이지' });
|
||||
}
|
||||
|
||||
// TikTok
|
||||
const tt = verifiedChannels.tiktok as Record<string, unknown> | null;
|
||||
if (tt?.handle) {
|
||||
const ttHandle = (tt.handle as string).replace(/^@/, '');
|
||||
captureTargets.push({ id: 'tiktok-landing', url: `https://www.tiktok.com/@${ttHandle}`, channel: 'TikTok', caption: `TikTok @${ttHandle}` });
|
||||
}
|
||||
|
||||
// 강남언니
|
||||
const gu = verifiedChannels.gangnamUnni as Record<string, unknown> | null;
|
||||
if (gu?.url) {
|
||||
captureTargets.push({ id: 'gangnamunni-page', url: gu.url as string, channel: '강남언니', caption: '강남언니 병원 페이지' });
|
||||
}
|
||||
|
||||
// Capture all in parallel (max 6 concurrent)
|
||||
// 네이버 블로그
|
||||
const nb = verifiedChannels.naverBlog as Record<string, unknown> | null;
|
||||
if (nb?.handle) {
|
||||
captureTargets.push({ id: 'naver-blog', url: `https://blog.naver.com/${nb.handle}`, channel: '네이버 블로그', caption: '공식 네이버 블로그' });
|
||||
}
|
||||
|
||||
// 네이버 플레이스
|
||||
const np = verifiedChannels.naverPlace as Record<string, unknown> | null;
|
||||
if (np?.url) {
|
||||
captureTargets.push({ id: 'naver-place', url: np.url as string, channel: '네이버 플레이스', caption: '네이버 플레이스' });
|
||||
}
|
||||
|
||||
// Google Maps
|
||||
const gm = verifiedChannels.googleMaps as Record<string, unknown> | null;
|
||||
if (gm?.url) {
|
||||
captureTargets.push({ id: 'google-maps', url: gm.url as string, channel: 'Google Maps', caption: 'Google Maps' });
|
||||
}
|
||||
|
||||
// 전후사진/갤러리 페이지 (siteMap에서 탐색)
|
||||
const galleryPage = siteMap.find(u => {
|
||||
const l = u.toLowerCase();
|
||||
return l.includes('/gallery') || l.includes('/before') || l.includes('/전후')
|
||||
|| l.includes('/photos') || l.includes('/case') || l.includes('/사례');
|
||||
});
|
||||
if (galleryPage) {
|
||||
captureTargets.push({ id: 'website-gallery', url: galleryPage, channel: '웹사이트', caption: '전후사진/갤러리' });
|
||||
}
|
||||
|
||||
// 영문 웹사이트
|
||||
const enSite = verifiedChannels.websiteEn as string | null;
|
||||
if (enSite) {
|
||||
captureTargets.push({ id: 'website-en', url: enSite, channel: '웹사이트(EN)', caption: '영문 웹사이트' });
|
||||
}
|
||||
|
||||
console.log(`[vision] Capture targets: ${captureTargets.length} pages (${captureTargets.map(t => t.id).join(', ')})`);
|
||||
|
||||
// Capture all in parallel
|
||||
const capturePromises = captureTargets.map(async (target) => {
|
||||
const result = await captureScreenshot(target.url, firecrawlKey);
|
||||
if (result) {
|
||||
|
|
@ -248,7 +324,11 @@ export async function analyzeScreenshot(
|
|||
"SINCE 2004" → 2004
|
||||
"20년 전통" → 2026 - 20 = 2006
|
||||
"개원 15주년" → 2026 - 15 = 2011
|
||||
배너, 이벤트 팝업, 로고 옆 텍스트, 하단 footer 등 모든 곳을 확인해줘.
|
||||
"2004년개원" → 2004
|
||||
"2004년 개원" → 2004
|
||||
"2004년개원 이래" → 2004
|
||||
"설립 2004년" → 2004
|
||||
배너, 이벤트 팝업, 로고 옆 텍스트, 하단 footer, 원장 인사말, 병원 소개 섹션 등 페이지 전체를 꼼꼼히 확인해줘. 특히 스크롤 아래쪽도 확인할 것!
|
||||
- operationYears: 운영 기간 (숫자만. "22주년"이면 22)
|
||||
- certifications: 인증 마크 (JCI, 보건복지부, 의료관광 등)
|
||||
- socialIcons: 보이는 소셜 미디어 아이콘 (Instagram, YouTube, Facebook, Blog, KakaoTalk 등)
|
||||
|
|
@ -268,6 +348,9 @@ export async function analyzeScreenshot(
|
|||
"22년 동안" → 2026 - 22 = 2004
|
||||
"SINCE 2004" → 2004
|
||||
"2004년 개원" → 2004
|
||||
"2004년개원" → 2004
|
||||
"2004년개원 이래" → 2004
|
||||
"설립 2004년" → 2004
|
||||
"20년 전통" → 2026 - 20 = 2006
|
||||
연혁, 소개글, 대표원장 인사말 등 모든 텍스트를 꼼꼼히 확인해줘.
|
||||
- operationYears: 운영 기간 (숫자만)
|
||||
|
|
@ -288,6 +371,30 @@ export async function analyzeScreenshot(
|
|||
'gangnamunni-page': `이 강남언니 병원 페이지를 분석해줘. JSON으로 추출:
|
||||
- gangnamUnniStats: {rating: "평점 텍스트", reviews: "리뷰 수 텍스트", doctors: 의사 수(숫자)}
|
||||
- doctors: [{name: "이름", specialty: "전문 분야"}] (보이는 의사 정보)`,
|
||||
|
||||
'facebook-page': `이 Facebook 페이지 스크린샷을 분석해줘. JSON으로 추출:
|
||||
- facebookStats: {followers: "팔로워 수 텍스트", likes: "좋아요 수 텍스트", rating: "평점 (있으면)", category: "카테고리", recentPost: "최근 게시물 요약"}`,
|
||||
|
||||
'tiktok-landing': `이 TikTok 프로필 스크린샷을 분석해줘. JSON으로 추출:
|
||||
- tiktokStats: {followers: "팔로워 수", likes: "총 좋아요 수", videos: "영상 수", bio: "바이오 텍스트", recentVideo: "최근 영상 설명"}`,
|
||||
|
||||
'naver-blog': `이 네이버 블로그 스크린샷을 분석해줘. JSON으로 추출:
|
||||
- naverBlogStats: {neighbors: "이웃 수 텍스트", visitors: "방문자 수 텍스트 (있으면)", recentTitle: "최근 글 제목", blogName: "블로그 이름"}`,
|
||||
|
||||
'naver-place': `이 네이버 플레이스 페이지를 분석해줘. JSON으로 추출:
|
||||
- naverPlaceStats: {rating: "별점", reviews: "리뷰 수", visitorReviews: "방문자 리뷰 수", address: "주소", hours: "영업시간", category: "카테고리"}`,
|
||||
|
||||
'google-maps': `이 Google Maps 페이지를 분석해줘. JSON으로 추출:
|
||||
- googleMapsStats: {rating: "별점", reviews: "리뷰 수", address: "주소", hours: "영업시간 (있으면)", photos: "사진 수 (있으면)"}`,
|
||||
|
||||
'website-gallery': `이 성형외과 전후사진/갤러리 페이지를 분석해줘. JSON으로 추출:
|
||||
- galleryStats: {estimatedPhotoCount: 추정 사진 수(숫자), categories: ["보이는 시술 카테고리 목록"]}`,
|
||||
|
||||
'website-en': `Analyze this English version of a Korean plastic surgery clinic website. Extract JSON:
|
||||
- enSiteStats: {languages: ["supported languages visible"], targetMarkets: ["target countries/regions mentioned"], keyServices: ["listed services in English"]}`,
|
||||
|
||||
'instagram-en': `이 Instagram 프로필(글로벌/영문 계정)을 분석해줘. JSON으로 추출:
|
||||
- instagramEnStats: {followers: "팔로워 수 텍스트", posts: "게시물 수 텍스트", bio: "바이오 텍스트", language: "주 사용 언어"}`,
|
||||
};
|
||||
|
||||
const prompt = prompts[pageType] || `이 웹페이지 스크린샷을 분석해줘. 보이는 모든 텍스트와 정보를 JSON으로 추출해줘.`;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { VerifiedChannels } from "../_shared/verifyHandles.ts";
|
|||
import { PERPLEXITY_MODEL } from "../_shared/config.ts";
|
||||
import { captureAllScreenshots, runVisionAnalysis, screenshotErrors, type ScreenshotResult } from "../_shared/visionAnalysis.ts";
|
||||
import { fetchWithRetry, fetchJsonWithRetry, wrapChannelTask, type ChannelTaskResult } from "../_shared/retry.ts";
|
||||
import { searchGooglePlace } from "../_shared/googlePlaces.ts";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
|
|
@ -77,6 +78,7 @@ Deno.serve(async (req) => {
|
|||
const PERPLEXITY_API_KEY = Deno.env.get("PERPLEXITY_API_KEY") || "";
|
||||
const NAVER_CLIENT_ID = Deno.env.get("NAVER_CLIENT_ID") || "";
|
||||
const NAVER_CLIENT_SECRET = Deno.env.get("NAVER_CLIENT_SECRET") || "";
|
||||
const GOOGLE_PLACES_API_KEY = Deno.env.get("GOOGLE_PLACES_API_KEY") || "";
|
||||
|
||||
const channelData: Record<string, unknown> = {};
|
||||
const analysisData: Record<string, unknown> = {};
|
||||
|
|
@ -116,6 +118,74 @@ Deno.serve(async (req) => {
|
|||
}));
|
||||
}
|
||||
|
||||
// ─── 1b. Instagram Posts (최근 20개 포스트 상세) ───
|
||||
const igPrimaryHandle = igCandidates[0]?.handle as string | undefined;
|
||||
if (APIFY_TOKEN && igPrimaryHandle) {
|
||||
channelTasks.push(wrapChannelTask("instagramPosts", async () => {
|
||||
const handle = igPrimaryHandle.replace(/^@/, '');
|
||||
const items = await runApifyActor(
|
||||
"apify~instagram-post-scraper",
|
||||
{ directUrls: [`https://www.instagram.com/${handle}/`], resultsLimit: 20 },
|
||||
APIFY_TOKEN,
|
||||
);
|
||||
const posts = (items as Record<string, unknown>[]).map(p => ({
|
||||
id: p.id,
|
||||
type: p.type,
|
||||
shortCode: p.shortCode,
|
||||
url: p.url,
|
||||
caption: ((p.caption as string) || '').slice(0, 500),
|
||||
hashtags: p.hashtags || [],
|
||||
mentions: p.mentions || [],
|
||||
likesCount: (p.likesCount as number) || 0,
|
||||
commentsCount: (p.commentsCount as number) || 0,
|
||||
timestamp: p.timestamp,
|
||||
displayUrl: p.displayUrl,
|
||||
}));
|
||||
const totalLikes = posts.reduce((sum, p) => sum + p.likesCount, 0);
|
||||
const totalComments = posts.reduce((sum, p) => sum + p.commentsCount, 0);
|
||||
channelData.instagramPosts = {
|
||||
posts,
|
||||
totalPosts: posts.length,
|
||||
avgLikes: posts.length > 0 ? Math.round(totalLikes / posts.length) : 0,
|
||||
avgComments: posts.length > 0 ? Math.round(totalComments / posts.length) : 0,
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── 1c. Instagram Reels (최근 15개 릴스 상세) ───
|
||||
if (APIFY_TOKEN && igPrimaryHandle) {
|
||||
channelTasks.push(wrapChannelTask("instagramReels", async () => {
|
||||
const handle = igPrimaryHandle.replace(/^@/, '');
|
||||
const items = await runApifyActor(
|
||||
"apify~instagram-reel-scraper",
|
||||
{ directUrls: [`https://www.instagram.com/${handle}/reels/`], resultsLimit: 15 },
|
||||
APIFY_TOKEN,
|
||||
);
|
||||
const reels = (items as Record<string, unknown>[]).map(r => ({
|
||||
id: r.id,
|
||||
shortCode: r.shortCode,
|
||||
url: r.url,
|
||||
caption: ((r.caption as string) || '').slice(0, 500),
|
||||
hashtags: r.hashtags || [],
|
||||
likesCount: (r.likesCount as number) || 0,
|
||||
commentsCount: (r.commentsCount as number) || 0,
|
||||
videoViewCount: (r.videoViewCount as number) || 0,
|
||||
videoPlayCount: (r.videoPlayCount as number) || 0,
|
||||
videoDuration: (r.videoDuration as number) || 0,
|
||||
timestamp: r.timestamp,
|
||||
musicInfo: r.musicInfo || null,
|
||||
}));
|
||||
const totalViews = reels.reduce((sum, r) => sum + r.videoViewCount, 0);
|
||||
const totalPlays = reels.reduce((sum, r) => sum + r.videoPlayCount, 0);
|
||||
channelData.instagramReels = {
|
||||
reels,
|
||||
totalReels: reels.length,
|
||||
avgViews: reels.length > 0 ? Math.round(totalViews / reels.length) : 0,
|
||||
avgPlays: reels.length > 0 ? Math.round(totalPlays / reels.length) : 0,
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── 2. YouTube ───
|
||||
const ytVerified = verified.youtube as Record<string, unknown> | null;
|
||||
if (YOUTUBE_API_KEY && (ytVerified?.verified === true || ytVerified?.verified === "unverifiable")) {
|
||||
|
|
@ -307,28 +377,19 @@ Deno.serve(async (req) => {
|
|||
}));
|
||||
}
|
||||
|
||||
// ─── 6. Google Maps ───
|
||||
if (APIFY_TOKEN && clinicName) {
|
||||
// ─── 6. Google Maps (Google Places API New) ───
|
||||
if (GOOGLE_PLACES_API_KEY && clinicName) {
|
||||
channelTasks.push(wrapChannelTask("googleMaps", async () => {
|
||||
const queries = [`${clinicName} 성형외과`, clinicName, `${clinicName} ${address || "강남"}`];
|
||||
let items: unknown[] = [];
|
||||
for (const q of queries) {
|
||||
items = await runApifyActor("compass~crawler-google-places", {
|
||||
searchStringsArray: [q], maxCrawledPlacesPerSearch: 3, language: "ko", maxReviews: 10,
|
||||
}, APIFY_TOKEN);
|
||||
if ((items as Record<string, unknown>[]).length > 0) break;
|
||||
}
|
||||
const place = (items as Record<string, unknown>[])[0];
|
||||
const place = await searchGooglePlace(clinicName, address || undefined, GOOGLE_PLACES_API_KEY);
|
||||
if (place) {
|
||||
channelData.googleMaps = {
|
||||
name: place.title, rating: place.totalScore, reviewCount: place.reviewsCount,
|
||||
name: place.name, rating: place.rating, reviewCount: place.reviewCount,
|
||||
address: place.address, phone: place.phone,
|
||||
clinicWebsite: place.website, // clinic's own website (not Maps URL)
|
||||
mapsUrl: place.url || (place.title ? `https://www.google.com/maps/search/${encodeURIComponent(String(place.title))}` : ''),
|
||||
category: place.categoryName, openingHours: place.openingHours,
|
||||
topReviews: ((place.reviews as Record<string, unknown>[]) || []).slice(0, 10).map(r => ({
|
||||
stars: r.stars, text: r.text, publishedAtDate: r.publishedAtDate,
|
||||
})),
|
||||
clinicWebsite: place.clinicWebsite,
|
||||
mapsUrl: place.mapsUrl,
|
||||
placeId: place.placeId,
|
||||
category: place.category, openingHours: place.openingHours,
|
||||
topReviews: place.topReviews,
|
||||
};
|
||||
} else {
|
||||
throw new Error("Google Maps: no matching place found");
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import "@supabase/functions-js/edge-runtime.d.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
import { normalizeInstagramHandle, normalizeYouTubeChannel } from "../_shared/normalizeHandles.ts";
|
||||
import { searchGooglePlace } from "../_shared/googlePlaces.ts";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
|
|
@ -59,6 +60,7 @@ Deno.serve(async (req) => {
|
|||
|
||||
const APIFY_TOKEN = Deno.env.get("APIFY_API_TOKEN");
|
||||
if (!APIFY_TOKEN) throw new Error("APIFY_API_TOKEN not configured");
|
||||
const GOOGLE_PLACES_API_KEY = Deno.env.get("GOOGLE_PLACES_API_KEY") || "";
|
||||
|
||||
const enrichment: Record<string, unknown> = {};
|
||||
|
||||
|
|
@ -135,50 +137,28 @@ Deno.serve(async (req) => {
|
|||
);
|
||||
}
|
||||
|
||||
// 2. Google Maps / Place Reviews
|
||||
if (clinicName || address) {
|
||||
// 2. Google Maps / Place Reviews (Google Places API New)
|
||||
if ((clinicName || address) && GOOGLE_PLACES_API_KEY) {
|
||||
tasks.push(
|
||||
(async () => {
|
||||
// Try multiple search queries for better hit rate
|
||||
const queries = [
|
||||
`${clinicName} 성형외과`,
|
||||
clinicName,
|
||||
`${clinicName} ${address || "강남"}`,
|
||||
];
|
||||
|
||||
let items: unknown[] = [];
|
||||
for (const query of queries) {
|
||||
items = await runApifyActor(
|
||||
"compass~crawler-google-places",
|
||||
{
|
||||
searchStringsArray: [query],
|
||||
maxCrawledPlacesPerSearch: 3,
|
||||
language: "ko",
|
||||
maxReviews: 10,
|
||||
},
|
||||
APIFY_TOKEN
|
||||
);
|
||||
if ((items as Record<string, unknown>[]).length > 0) break;
|
||||
}
|
||||
const place = (items as Record<string, unknown>[])[0];
|
||||
const place = await searchGooglePlace(
|
||||
clinicName || "",
|
||||
address || undefined,
|
||||
GOOGLE_PLACES_API_KEY,
|
||||
);
|
||||
if (place) {
|
||||
enrichment.googleMaps = {
|
||||
name: place.title,
|
||||
rating: place.totalScore,
|
||||
reviewCount: place.reviewsCount,
|
||||
name: place.name,
|
||||
rating: place.rating,
|
||||
reviewCount: place.reviewCount,
|
||||
address: place.address,
|
||||
phone: place.phone,
|
||||
clinicWebsite: place.website,
|
||||
mapsUrl: place.url || (place.title ? `https://www.google.com/maps/search/${encodeURIComponent(String(place.title))}` : ''),
|
||||
category: place.categoryName,
|
||||
clinicWebsite: place.clinicWebsite,
|
||||
mapsUrl: place.mapsUrl,
|
||||
placeId: place.placeId,
|
||||
category: place.category,
|
||||
openingHours: place.openingHours,
|
||||
topReviews: ((place.reviews as Record<string, unknown>[]) || [])
|
||||
.slice(0, 10)
|
||||
.map((r) => ({
|
||||
stars: r.stars,
|
||||
text: (r.text as string || "").slice(0, 200),
|
||||
publishedAtDate: r.publishedAtDate,
|
||||
})),
|
||||
topReviews: place.topReviews,
|
||||
};
|
||||
}
|
||||
})()
|
||||
|
|
|
|||
|
|
@ -134,14 +134,19 @@ ${JSON.stringify(scrapeData.branding || {}, null, 2).slice(0, 1000)}
|
|||
// ─── Post-processing: Inject Vision Analysis data directly ───
|
||||
// Perplexity may ignore Vision data in prompt, so we force-inject critical fields
|
||||
const vision = channelData.visionAnalysis as Record<string, unknown> | undefined;
|
||||
const isEstablishedMissing = (val: unknown): boolean => {
|
||||
if (!val) return true;
|
||||
const s = String(val).trim().toLowerCase();
|
||||
return s === '' || s === '데이터 없음' || s === '데이터없음' || s === 'n/a' || s === '확인불가' || s === '미확인' || s === '정보없음' || s === '정보 없음';
|
||||
};
|
||||
if (vision) {
|
||||
// Force-inject foundingYear if Vision found it but Perplexity didn't
|
||||
if (vision.foundingYear && (!report.clinicInfo?.established || report.clinicInfo.established === "데이터 없음")) {
|
||||
if (vision.foundingYear && isEstablishedMissing(report.clinicInfo?.established)) {
|
||||
report.clinicInfo = report.clinicInfo || {};
|
||||
report.clinicInfo.established = String(vision.foundingYear);
|
||||
console.log(`[report] Injected foundingYear from Vision: ${vision.foundingYear}`);
|
||||
}
|
||||
if (vision.operationYears && (!report.clinicInfo?.established || report.clinicInfo.established === "데이터 없음")) {
|
||||
if (vision.operationYears && isEstablishedMissing(report.clinicInfo?.established)) {
|
||||
const year = new Date().getFullYear() - Number(vision.operationYears);
|
||||
report.clinicInfo = report.clinicInfo || {};
|
||||
report.clinicInfo.established = String(year);
|
||||
|
|
@ -202,6 +207,78 @@ ${JSON.stringify(scrapeData.branding || {}, null, 2).slice(0, 1000)}
|
|||
caption: ss.caption,
|
||||
sourceUrl: ss.sourceUrl,
|
||||
}));
|
||||
|
||||
// 채널별 스크린샷 그룹핑 (프론트엔드에서 각 채널 섹션에 증거 이미지 표시)
|
||||
report.channelScreenshots = screenshots.reduce((acc: Record<string, unknown[]>, ss: Record<string, unknown>) => {
|
||||
const channel = ss.channel as string || 'etc';
|
||||
if (!acc[channel]) acc[channel] = [];
|
||||
acc[channel].push({ url: ss.url, caption: ss.caption, id: ss.id, sourceUrl: ss.sourceUrl });
|
||||
return acc;
|
||||
}, {} as Record<string, unknown[]>);
|
||||
}
|
||||
|
||||
// Instagram Posts/Reels 분석 (engagement rate, 콘텐츠 성과)
|
||||
const igProfile = channelData.instagram as Record<string, unknown> | undefined;
|
||||
const igPosts = channelData.instagramPosts as Record<string, unknown> | undefined;
|
||||
const igReels = channelData.instagramReels as Record<string, unknown> | undefined;
|
||||
if (igProfile && (igPosts || igReels)) {
|
||||
const followers = (igProfile.followers as number) || 0;
|
||||
const avgLikes = (igPosts?.avgLikes as number) || 0;
|
||||
const avgComments = (igPosts?.avgComments as number) || 0;
|
||||
|
||||
// 해시태그 빈도 분석
|
||||
const hashtagCounts: Record<string, number> = {};
|
||||
const allPosts = [...((igPosts?.posts as Record<string, unknown>[]) || []), ...((igReels?.reels as Record<string, unknown>[]) || [])];
|
||||
for (const post of allPosts) {
|
||||
for (const tag of (post.hashtags as string[]) || []) {
|
||||
const lower = tag.toLowerCase();
|
||||
hashtagCounts[lower] = (hashtagCounts[lower] || 0) + 1;
|
||||
}
|
||||
}
|
||||
const topHashtags = Object.entries(hashtagCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([tag, count]) => ({ tag, count }));
|
||||
|
||||
// 게시 빈도 (최근 포스트 간 평균 간격)
|
||||
const postTimestamps = ((igPosts?.posts as Record<string, unknown>[]) || [])
|
||||
.map(p => new Date(p.timestamp as string).getTime())
|
||||
.filter(t => !isNaN(t))
|
||||
.sort((a, b) => b - a);
|
||||
let postFrequency = 'N/A';
|
||||
if (postTimestamps.length >= 2) {
|
||||
const spanDays = (postTimestamps[0] - postTimestamps[postTimestamps.length - 1]) / (1000 * 60 * 60 * 24);
|
||||
const postsPerWeek = spanDays > 0 ? (postTimestamps.length / spanDays * 7).toFixed(1) : 'N/A';
|
||||
postFrequency = `${postsPerWeek}회/주`;
|
||||
}
|
||||
|
||||
// 최고 성과 포스트
|
||||
const bestPost = ((igPosts?.posts as Record<string, unknown>[]) || [])
|
||||
.sort((a, b) => ((b.likesCount as number) || 0) - ((a.likesCount as number) || 0))[0] || null;
|
||||
|
||||
report.instagramAnalysis = {
|
||||
engagementRate: followers > 0 ? ((avgLikes + avgComments) / followers * 100).toFixed(2) + '%' : 'N/A',
|
||||
avgLikes,
|
||||
avgComments,
|
||||
topHashtags,
|
||||
postFrequency,
|
||||
bestPerformingPost: bestPost ? {
|
||||
url: bestPost.url,
|
||||
likes: bestPost.likesCount,
|
||||
comments: bestPost.commentsCount,
|
||||
caption: ((bestPost.caption as string) || '').slice(0, 200),
|
||||
} : null,
|
||||
reelsPerformance: igReels ? {
|
||||
totalReels: igReels.totalReels,
|
||||
avgViews: igReels.avgViews,
|
||||
avgPlays: igReels.avgPlays,
|
||||
viewToFollowerRatio: followers > 0
|
||||
? (((igReels.avgViews as number) || 0) / followers * 100).toFixed(1) + '%'
|
||||
: 'N/A',
|
||||
} : null,
|
||||
totalPostsAnalyzed: (igPosts?.totalPosts as number) || 0,
|
||||
totalReelsAnalyzed: (igReels?.totalReels as number) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Embed verified handles
|
||||
|
|
|
|||
Loading…
Reference in New Issue