intentfinder-api / dashboard.html
youngryong's picture
deploy: IntentFinder API (HF Docker Space)
21bdc64
Raw
History Blame Contribute Delete
25.9 kB
<!-- ๊ฒ€์ƒ‰ ์ธํ…ํŠธ ๋ถ„์„ ๋Œ€์‹œ๋ณด๋“œ โ€” ๋ฐฑ์—”๋“œ /analyze์— ์—ฐ๋™๋˜๋Š” React ๋‹จ์ผ ํŒŒ์ผ ์•ฑ -->
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IntentFinder โ€” ๊ฒ€์ƒ‰ ์ธํ…ํŠธ ๋ถ„์„</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Pretendard:wght@400;500;600;700&display=swap');
body { font-family: 'Pretendard', sans-serif; background-color: #f8fafc; }
</style>
</head>
<body class="text-slate-800 antialiased min-h-screen">
<div id="root"></div>
<script type="text/babel-app" id="app">
const { useState, useRef, useEffect } = React;
const API = "http://127.0.0.1:8000";
// ์˜๋„ ์ฝ”๋“œ โ†’ ํ•œ๊ธ€ ๋ผ๋ฒจยท์ƒ‰์ƒ ๋งคํ•‘
const INTENT = {
info: { label: "์ •๋ณด ํƒ์ƒ‰ํ˜•", color: "#0ca5e9" },
transactional: { label: "๊ตฌ๋งค/๊ฑฐ๋ž˜ํ˜•", color: "#f43f5e" },
navigational: { label: "๋ธŒ๋žœ๋“œ/๋‚ด๋น„ํ˜•", color: "#4f46e5" },
mixed: { label: "ํ˜ผํ•ฉํ˜•", color: "#f59e0b" },
};
const INTENT_BADGE = {
info: "bg-blue-50 text-blue-600 border-blue-100",
transactional: "bg-rose-50 text-rose-600 border-rose-100",
navigational: "bg-indigo-50 text-indigo-600 border-indigo-100",
mixed: "bg-amber-50 text-amber-600 border-amber-100",
};
const fmt = (n) => (n ?? 0).toLocaleString();
// ๊ฒ€์ƒ‰๋Ÿ‰ ๋น„์ค‘ ๋„๋„› (์˜๋„ ๋ถ„๋ฅ˜ ๊ฒฐ๊ณผ)
function IntentDonut({ breakdown }) {
const ref = useRef(null);
const chart = useRef(null);
const entries = Object.entries(breakdown || {});
useEffect(() => {
if (chart.current) chart.current.destroy();
if (!entries.length) return;
chart.current = new Chart(ref.current.getContext("2d"), {
type: "doughnut",
data: {
labels: entries.map(([k]) => (INTENT[k]?.label || k)),
datasets: [{
data: entries.map(([, v]) => v),
backgroundColor: entries.map(([k]) => (INTENT[k]?.color || "#94a3b8")),
borderWidth: 2, borderColor: "#fff", hoverOffset: 6,
}],
},
options: {
responsive: true, maintainAspectRatio: false, cutout: "65%",
plugins: {
legend: { position: "bottom", labels: { boxWidth: 12, font: { size: 11, family: "Pretendard" }, padding: 14 } },
tooltip: { callbacks: { label: (c) => {
const tot = c.dataset.data.reduce((a, b) => a + b, 0);
return ` ${c.label}: ${(c.raw / tot * 100).toFixed(1)}%`;
} } },
},
},
});
return () => chart.current && chart.current.destroy();
}, [JSON.stringify(breakdown)]);
if (!entries.length) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-center text-slate-400 text-sm gap-2">
<i className="fa-solid fa-hourglass-half text-2xl"></i>
<p>์˜๋„ ๋ถ„๋ฅ˜ ๋Œ€๊ธฐ ์ค‘<br/><span className="text-xs">(Anthropic ํฌ๋ ˆ๋”ง ์ถฉ์ „ ํ›„ ํ‘œ์‹œ)</span></p>
</div>
);
}
return <div className="flex-1 relative flex items-center justify-center"><canvas ref={ref} className="max-h-[250px]"></canvas></div>;
}
// ํ•ต์‹ฌ ์š”์•ฝ ์นด๋“œ
function Card({ title, value, unit, sub, subColor, icon, iconBg }) {
return (
<div className="bg-white p-5 rounded-xl border border-slate-200 shadow-sm flex items-center justify-between">
<div className="space-y-1">
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider">{title}</p>
<p className="text-2xl font-bold text-slate-900">{value} <span className="text-sm font-normal text-slate-500">{unit}</span></p>
<p className={"text-xs font-medium flex items-center gap-1 " + subColor}>{sub}</p>
</div>
<div className={"p-3.5 rounded-xl " + iconBg}><i className={"text-2xl " + icon}></i></div>
</div>
);
}
// ์„ฑ๋ณ„ร—์—ฐ๋ น ์ถ”์ • ๋น„์ค‘ ๋ง‰๋Œ€๊ทธ๋ž˜ํ”„ (๋ฐ์ดํ„ฐ๋žฉ)
function Demographics({ data }) {
const ref = useRef(null), chart = useRef(null);
const AGES = ["20๋Œ€ ์ดํ•˜", "30๋Œ€", "40๋Œ€", "50๋Œ€ ์ด์ƒ"];
useEffect(() => {
if (chart.current) chart.current.destroy();
if (!data || !data.cells || !data.cells.length) return;
const pick = (g) => AGES.map((a) => {
const c = data.cells.find((x) => x.gender === g && x.age === a);
return c ? c.pct : 0;
});
chart.current = new Chart(ref.current.getContext("2d"), {
type: "bar",
data: {
labels: AGES,
datasets: [
{ label: "๋‚จ์„ฑ", data: pick("๋‚จ์„ฑ"), backgroundColor: "#3b82f6", borderRadius: 6 },
{ label: "์—ฌ์„ฑ", data: pick("์—ฌ์„ฑ"), backgroundColor: "#ec4899", borderRadius: 6 },
],
},
options: {
responsive: true, maintainAspectRatio: false,
scales: {
x: { grid: { display: false }, ticks: { font: { size: 11, family: "Pretendard" } } },
y: { grid: { color: "#f1f5f9" }, beginAtZero: true, ticks: { font: { size: 10 }, callback: (v) => v + "%" } },
},
plugins: { legend: { position: "bottom", labels: { boxWidth: 12, font: { size: 11, family: "Pretendard" }, padding: 14 } } },
},
});
return () => chart.current && chart.current.destroy();
}, [JSON.stringify(data)]);
if (!data || !data.cells || !data.cells.length) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-center text-slate-400 text-sm gap-2">
<i className="fa-solid fa-database text-2xl"></i>
<p>๋ฐ์ดํ„ฐ๋žฉ ํ‚ค ์„ค์ • ํ›„ ํ‘œ์‹œ<br/><span className="text-xs">(NAVER_CLIENT_ID / SECRET)</span></p>
</div>
);
}
return (
<div className="flex-1 flex flex-col">
<div className="flex-1 relative"><canvas ref={ref}></canvas></div>
<p className="text-[10px] text-slate-400 text-center mt-1">์•ต์ปค("{data.anchor}") ์ •๊ทœํ™” ๊ธฐ๋ฐ˜ ์ถ”์ • ๋น„์ค‘ ยท ์ ˆ๋Œ€ ๋น„๊ต ์•„๋‹˜</p>
</div>
);
}
// ์ถ”์ • ์—ฐ๊ด€ ๊ฒฝ๋กœ (์‹œ๋“œ ์ค‘์‹ฌ ํŠธ๋ฆฌ) SVG ์‹œ๊ฐํ™”
function SearchPath({ data }) {
if (!data || !data.nodes || data.nodes.length < 2)
return <div className="text-sm text-slate-400 text-center py-8">์—ฐ๊ด€ ๊ฒฝ๋กœ๋ฅผ ๋งŒ๋“ค ๋ฐ์ดํ„ฐ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค.</div>;
const { nodes, edges } = data;
const COL_W = 210, ROW_H = 62, NODE_W = 168, NODE_H = 44, PAD = 18;
const byDepth = {};
nodes.forEach((n) => { (byDepth[n.depth] = byDepth[n.depth] || []).push(n); });
const depths = Object.keys(byDepth).map(Number).sort((a, b) => a - b);
const pos = {}; let maxCol = 0;
depths.forEach((d) => {
byDepth[d].forEach((n, j) => { pos[n.id] = { x: PAD + d * COL_W, y: PAD + j * ROW_H }; });
maxCol = Math.max(maxCol, byDepth[d].length);
});
const maxDepth = depths[depths.length - 1];
const W = PAD * 2 + maxDepth * COL_W + NODE_W;
const H = PAD * 2 + (maxCol - 1) * ROW_H + NODE_H;
const trunc = (s) => (s.length > 11 ? s.slice(0, 11) + "โ€ฆ" : s);
return (
<div className="overflow-x-auto">
<svg width={W} height={H} style={{ minWidth: "100%" }}>
{edges.map((e, i) => {
const p = pos[e.source], c = pos[e.target];
const x1 = p.x + NODE_W, y1 = p.y + NODE_H / 2, x2 = c.x, y2 = c.y + NODE_H / 2;
const mx = (x1 + x2) / 2;
return (
<g key={i}>
<path d={`M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}`} fill="none" stroke="#cbd5e1" strokeWidth="1.5" />
{e.relation != null && <text x={mx} y={(y1 + y2) / 2 - 4} textAnchor="middle" fontSize="10" fill="#94a3b8">{Math.round(e.relation * 100)}%</text>}
</g>
);
})}
{nodes.map((n) => {
const p = pos[n.id], seed = n.depth === 0;
return (
<g key={n.id} transform={`translate(${p.x},${p.y})`}>
<rect width={NODE_W} height={NODE_H} rx="9" fill={seed ? "#eef2ff" : "#fff"} stroke={seed ? "#6366f1" : "#e2e8f0"} strokeWidth={seed ? "2" : "1"} />
<text x="12" y="19" fontSize="12" fontWeight="700" fontFamily="Pretendard" fill={seed ? "#3730a3" : "#0f172a"}>{trunc(n.keyword)}</text>
<text x="12" y="34" fontSize="10" fontFamily="Pretendard" fill={seed ? "#6366f1" : "#94a3b8"}>{n.volume != null ? "์›” " + fmt(n.volume) : ""}</text>
</g>
);
})}
</svg>
</div>
);
}
function App() {
const [keyword, setKeyword] = useState("์บ ํ•‘์šฉํ’ˆ");
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [data, setData] = useState(null);
const run = async () => {
const kw = keyword.trim();
if (!kw) return;
setLoading(true); setError(null);
try {
const res = await fetch(`${API}/analyze?keyword=${encodeURIComponent(kw)}`);
if (!res.ok) throw new Error((await res.json()).detail || res.statusText);
setData(await res.json());
} catch (e) {
setError(e.message.includes("Failed to fetch")
? "๋ฐฑ์—”๋“œ(127.0.0.1:8000)์— ์—ฐ๊ฒฐํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์„œ๋ฒ„๊ฐ€ ์ผœ์ ธ ์žˆ๋Š”์ง€ ํ™•์ธํ•˜์„ธ์š”."
: e.message);
setData(null);
} finally { setLoading(false); }
};
const comp = data?.competition_breakdown || {};
const compTotal = Object.values(comp).reduce((a, b) => a + b, 0) || 1;
const highPct = Math.round((comp["๋†’์Œ"] || 0) / compTotal * 100);
return (
<div className="min-h-screen flex flex-col">
{/* ํ—ค๋” + ๊ฒ€์ƒ‰ */}
<header className="bg-white border-b border-slate-200 sticky top-0 z-50 shadow-sm px-6 py-4">
<div className="max-w-7xl mx-auto flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div className="flex items-center gap-3">
<div className="bg-indigo-600 text-white p-2 rounded-lg shadow-md shadow-indigo-100"><i className="fa-solid fa-magnifying-glass-chart text-xl px-0.5"></i></div>
<div>
<h1 className="text-xl font-bold text-slate-900 tracking-tight">IntentFinder <span className="text-xs bg-indigo-50 text-indigo-600 font-semibold px-2 py-0.5 rounded ml-2 border border-indigo-100">SMB MVP</span></h1>
<p className="text-xs text-slate-500">ํ‚ค์›Œ๋“œ ์ž…๋ ฅ โ†’ ๋„ค์ด๋ฒ„ ์‹ค์‹œ๊ฐ„ ๋ถ„์„ + AI ํด๋Ÿฌ์Šคํ„ฐ๋ง</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3 bg-slate-50 p-1.5 rounded-xl border border-slate-200 w-full md:w-auto">
<div className="relative flex-1 md:w-64">
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-slate-400"><i className="fa-solid fa-magnifying-glass text-sm"></i></span>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} onKeyDown={(e) => e.key === "Enter" && run()}
className="w-full bg-white border border-slate-200 rounded-lg pl-9 pr-4 py-2 text-sm font-medium focus:outline-none focus:border-indigo-500 text-slate-900" placeholder="์‹œ๋“œ ํ‚ค์›Œ๋“œ ์ž…๋ ฅ..." />
</div>
<button onClick={run} disabled={loading}
className="bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white font-medium text-sm px-4 py-2 rounded-lg transition shadow-sm flex items-center gap-2 shrink-0">
<i className={"text-xs fa-solid " + (loading ? "fa-spinner fa-spin" : "fa-bolt")}></i> {loading ? "๋ถ„์„ ์ค‘" : "๋ถ„์„ ์‹คํ–‰"}
</button>
</div>
</div>
</header>
<main className="flex-1 max-w-7xl w-full mx-auto p-4 md:p-6 space-y-6">
{error && (
<div className="bg-rose-50 border border-rose-200 text-rose-700 px-4 py-3 rounded-xl text-sm flex items-center gap-2">
<i className="fa-solid fa-circle-exclamation"></i> {error}
</div>
)}
{!data && !error && (
<div className="bg-white border border-dashed border-slate-200 rounded-xl p-12 text-center text-slate-400">
<i className="fa-solid fa-keyboard text-3xl mb-3"></i>
<p className="text-sm">ํ‚ค์›Œ๋“œ๋ฅผ ์ž…๋ ฅํ•˜๊ณ  <b>๋ถ„์„ ์‹คํ–‰</b>์„ ๋ˆ„๋ฅด์„ธ์š”.</p>
</div>
)}
{data && (
<>
{/* ๋ฉ”ํƒ€ ๋ฐ” */}
<div className="bg-white p-3 px-4 rounded-xl border border-slate-200 shadow-sm flex flex-wrap items-center justify-between gap-3 text-xs text-slate-500">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-slate-700">"{data.keyword}"</span>
<span className="bg-slate-100 px-2 py-0.5 rounded-full border border-slate-200">์›๋ณธ {fmt(data.raw_keyword_count)}๊ฐœ โ†’ ๊ด€๋ จ {fmt(data.keyword_count)}๊ฐœ</span>
<span className="bg-slate-100 px-2 py-0.5 rounded-full border border-slate-200">๋ฌด๊ด€์–ด {fmt(data.filtered_out)}๊ฐœ ์ œ๊ฑฐ</span>
</div>
<span className="flex items-center gap-1.5">
<span className={"inline-block w-2 h-2 rounded-full " + (data.cached ? "bg-amber-400" : "bg-emerald-500")}></span>
{data.cached ? "์บ์‹œ ๊ฒฐ๊ณผ" : "์‹ค์‹œ๊ฐ„ ์กฐํšŒ"}
</span>
</div>
{/* ์นด๋“œ 3๊ฐœ */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
<Card title="์ด ๊ฒ€์ƒ‰๋Ÿ‰ (๊ด€๋ จ ํ‚ค์›Œ๋“œ)" value={fmt(data.total_search_volume)} unit="๊ฑด"
sub={<><i className="fa-solid fa-chart-simple"></i> ํ‚ค์›Œ๋“œ๋‹น ์ค‘์•™๊ฐ’ {fmt(data.median_volume_per_keyword)}๊ฑด</>}
subColor="text-slate-500" icon="fa-solid fa-chart-line text-indigo-600" iconBg="bg-indigo-50" />
<Card title="๊ฒฝ์Ÿ ๊ฐ•๋„ (๋†’์Œ ๋น„์ค‘)" value={highPct} unit="%"
sub={<><i className="fa-solid fa-layer-group"></i> ๋†’ {fmt(comp["๋†’์Œ"])} ยท ์ค‘ {fmt(comp["์ค‘๊ฐ„"])} ยท ๋‚ฎ {fmt(comp["๋‚ฎ์Œ"])}</>}
subColor="text-amber-600" icon="fa-solid fa-gauge-high text-amber-600" iconBg="bg-amber-50" />
<Card title="AI ์ธํ…ํŠธ ๊ทธ๋ฃน" value={fmt(data.cluster_count)} unit="๊ฐœ ํด๋Ÿฌ์Šคํ„ฐ"
sub={<><i className="fa-solid fa-wand-magic-sparkles"></i> {fmt(data.clustered_keyword_count)}๊ฐœ ํ‚ค์›Œ๋“œ ๊ตฐ์ง‘ํ™”</>}
subColor="text-emerald-600" icon="fa-solid fa-brain text-emerald-600" iconBg="bg-emerald-50" />
</div>
{/* ๋„๋„› + ์ธ๊ตฌํ†ต๊ณ„ ์ž๋ฆฌ */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white p-5 rounded-xl border border-slate-200 shadow-sm flex flex-col h-[360px]">
<h2 className="text-sm font-bold text-slate-900 flex items-center gap-2 mb-4"><i className="fa-solid fa-chart-pie text-indigo-500"></i> ๊ฒ€์ƒ‰ ์˜๋„ ๋น„์ค‘ (๊ฒ€์ƒ‰๋Ÿ‰ ๊ฐ€์ค‘)</h2>
<IntentDonut breakdown={data.intent_breakdown} />
</div>
<div className="bg-white p-5 rounded-xl border border-slate-200 shadow-sm flex flex-col h-[360px]">
<h2 className="text-sm font-bold text-slate-900 flex items-center gap-2 mb-4"><i className="fa-solid fa-users text-indigo-500"></i> ํ•ต์‹ฌ ํƒ€๊นƒ ์ธ๊ตฌํ†ต๊ณ„ (์ถ”์ •)</h2>
<Demographics data={data.demographics} />
</div>
</div>
{/* ํด๋Ÿฌ์Šคํ„ฐ ํ…Œ์ด๋ธ” */}
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
<div className="p-5 border-b border-slate-200 bg-slate-50">
<h2 className="text-sm font-bold text-slate-900 flex items-center gap-2"><i className="fa-solid fa-layer-group text-indigo-500"></i> ์˜๋ฏธ ๊ธฐ๋ฐ˜ ํ‚ค์›Œ๋“œ ํด๋Ÿฌ์Šคํ„ฐ๋ง ๊ฒฐ๊ณผ</h2>
<p className="text-xs text-slate-500 mt-0.5">S-BERT ์ž„๋ฒ ๋”ฉ โ†’ UMAP โ†’ HDBSCAN ๊ตฐ์ง‘ํ™” (๋…ธ์ด์ฆˆ -1 ์ œ์™ธ)</p>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="bg-slate-100/70 border-b border-slate-200 text-slate-600 font-semibold text-xs uppercase tracking-wider">
<th className="p-4 pl-6">์ธํ…ํŠธ ๊ทธ๋ฃน๋ช…</th>
<th className="p-4">์ฃผ์š” ํฌํ•จ ํ‚ค์›Œ๋“œ</th>
<th className="p-4 text-right">๊ทธ๋ฃน ๊ฒ€์ƒ‰๋Ÿ‰</th>
<th className="p-4 text-center">๋Œ€ํ‘œ ์˜๋„</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200 text-slate-700">
{data.clusters.map((c) => (
<tr key={c.cluster_id} className="hover:bg-slate-50/80 transition-colors">
<td className="p-4 pl-6 font-semibold text-indigo-600 flex items-center gap-2"><span className="w-2 h-2 rounded-full bg-indigo-500"></span>{c.theme || c.cluster_label}</td>
<td className="p-4 text-slate-600 font-medium max-w-xs md:max-w-md truncate">{c.top_keywords.join(", ")}</td>
<td className="p-4 text-right font-mono font-bold text-slate-900">{fmt(c.total_search_volume)}</td>
<td className="p-4 text-center">
{c.intent
? <span className={"text-xs font-semibold px-2 py-0.5 rounded border " + (INTENT_BADGE[c.intent] || "")}>{c.intent_label}</span>
: <span className="text-xs text-slate-300">โ€”</span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* ์ถ”์ • ์—ฐ๊ด€ ๊ฒ€์ƒ‰ ๊ฒฝ๋กœ */}
<div className="bg-white p-5 rounded-xl border border-slate-200 shadow-sm space-y-3">
<div>
<h2 className="text-sm font-bold text-slate-900 flex items-center gap-2"><i className="fa-solid fa-diagram-project text-indigo-500"></i> ์—ฐ๊ด€ ๊ฒ€์ƒ‰ ๊ฒฝ๋กœ
<span className={"text-[10px] font-medium px-2 py-0.5 rounded-full border " + (data.search_path?.source === "autocomplete" ? "bg-emerald-50 text-emerald-600 border-emerald-100" : "bg-slate-100 text-slate-500 border-slate-200")}>
{data.search_path?.source === "autocomplete" ? "๋„ค์ด๋ฒ„ ์ž๋™์™„์„ฑ" : "์ž„๋ฒ ๋”ฉ ์ถ”์ •"}
</span>
</h2>
<p className="text-xs text-slate-500">{data.search_path?.source === "autocomplete" ? "๋„ค์ด๋ฒ„ ์ž๋™์™„์„ฑ ๊ธฐ๋ฐ˜ โ€” ์‹ค์ œ ํ•จ๊ป˜ ๊ฒ€์ƒ‰๋˜๋Š” ํ‚ค์›Œ๋“œ ์—ฐ๊ฒฐ๋ง" : "์˜๋ฏธ ์œ ์‚ฌ๋„ ๊ธฐ๋ฐ˜ ์ถ”์ • ์—ฐ๊ฒฐ๋ง (์ž๋™์™„์„ฑ ๋ฐ์ดํ„ฐ ๋ถ€์กฑ ์‹œ ๋Œ€์ฒด)"}</p>
</div>
<div className="bg-slate-50 border border-dashed border-slate-200 rounded-xl p-4">
<SearchPath data={data.search_path} />
</div>
</div>
{/* AI ๋งˆ์ผ€ํŒ… ์•ก์…˜ ์ œ์•ˆ */}
{data.marketing_suggestion && (
<div className="bg-indigo-50/60 border border-indigo-100 rounded-xl p-4 text-sm text-indigo-900 flex gap-3">
<i className="fa-solid fa-lightbulb text-indigo-500 mt-0.5"></i>
<p><strong className="font-semibold">SMB ๋งˆ์ผ€ํŒ… ์•ก์…˜ ์ œ์•ˆ:</strong> {data.marketing_suggestion}</p>
</div>
)}
</>
)}
</main>
<footer className="bg-slate-900 text-slate-400 text-xs py-5 px-6 mt-8">
<div className="max-w-7xl mx-auto">IntentFinder MVP โ€” ๋„ค์ด๋ฒ„ ๊ฒ€์ƒ‰๊ด‘๊ณ  API ยท S-BERT/HDBSCAN ยท Claude Haiku 4.5</div>
</footer>
</div>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
</script>
<script>
// JSX๋ฅผ classic runtime์œผ๋กœ ์ปดํŒŒ์ผํ•ด ์‹คํ–‰ (CDN ํ™˜๊ฒฝ์—์„œ import ๊ตฌ๋ฌธ ํšŒํ”ผ)
(function () {
var src = document.getElementById("app").textContent;
var out = Babel.transform(src, { presets: [["react", { runtime: "classic" }]] }).code;
(0, eval)(out);
})();
</script>
</body>
</html>