Spaces:
Running
Running
| <!-- ๊ฒ์ ์ธํ ํธ ๋ถ์ ๋์๋ณด๋ โ ๋ฐฑ์๋ /analyze์ ์ฐ๋๋๋ React ๋จ์ผ ํ์ผ ์ฑ --> | |
| <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> | |