feat: 이베이 부품 AI 리스팅 툴 — 기획·설계·견적서·MVP 스캐폴딩

[기획/설계 문서]
- CONTENT/ARCHITECTURE_EBAY_PARTS_TOOL.md: 3-tier 아키텍처 설계서
- CONTENT/ebay-tool-proposal.html: 공식 제안서 (3단 패키지 120/198/330만원)
- CONTENT/ebay-tool-questionnaire.html: 사전 요구사항 질문지 (17항목)

[관리자 문서 뷰어]
- admin/documents/page.tsx: 프로젝트 문서 카드 목록 + iframe 미리보기
- api/admin/documents/[filename]: 인증 기반 HTML 문서 서빙 API
- AdminSidebar: "프로젝트 문서" 메뉴 추가

[MVP 스캐폴딩]
- tools/ebay-parts/page.tsx: 품번 입력 → 5탭 결과 UI (Mock 데이터)
- api/tools/ebay-parts/search: POST 검색 API (Mock 반환)
- Sidebar: "이베이 부품 검색" 메뉴 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-02 13:49:06 +09:00
parent fe1e8ffcf0
commit 244781f96a
9 changed files with 4250 additions and 0 deletions

View File

@@ -56,6 +56,18 @@ const NAV_ITEMS = [
</svg>
),
},
{
href: '/admin/documents',
label: '프로젝트 문서',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M13 3v5a2 2 0 002 2h4M9 13h6M9 17h4" />
</svg>
),
},
{
href: '/admin/marketing',
label: '마케팅 에셋',

View File

@@ -0,0 +1,140 @@
'use client';
import { useState } from 'react';
interface Document {
id: string;
title: string;
description: string;
category: '제안서' | '질문지' | '계약서';
fileName: string;
updatedAt: string;
status: 'draft' | 'sent' | 'accepted';
}
const documents: Document[] = [
{
id: 'ebay-proposal',
title: '이베이 부품 AI 자동화 — 제안서',
description: '프로젝트 개요, 3단 패키지 견적(120/198/330만원), 기술 스택, 진행 절차',
category: '제안서',
fileName: 'ebay-tool-proposal.html',
updatedAt: '2026-04-02',
status: 'draft',
},
{
id: 'ebay-questionnaire',
title: '이베이 부품 AI 자동화 — 요구사항 질문지',
description: '고객 사전 확인 17항목 (타겟 사이트, 샘플 품번, eBay 셀러 티어 등)',
category: '질문지',
fileName: 'ebay-tool-questionnaire.html',
updatedAt: '2026-04-02',
status: 'draft',
},
];
const CATEGORY_COLORS: Record<string, string> = {
'제안서': 'bg-blue-900/40 text-blue-400 border-blue-500/30',
'질문지': 'bg-amber-900/40 text-amber-400 border-amber-500/30',
'계약서': 'bg-green-900/40 text-green-400 border-green-500/30',
};
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
draft: { label: '초안', color: 'bg-slate-700/60 text-slate-300' },
sent: { label: '발송', color: 'bg-blue-900/40 text-blue-400' },
accepted: { label: '수락', color: 'bg-green-900/40 text-green-400' },
};
export default function AdminDocumentsPage() {
const [previewDoc, setPreviewDoc] = useState<Document | null>(null);
return (
<div className="p-6 max-w-6xl mx-auto">
{/* 헤더 */}
<div className="mb-6">
<h1 className="text-white text-2xl font-bold"> </h1>
<p className="text-slate-400 text-sm mt-0.5">
, ,
</p>
</div>
{/* 문서 카드 그리드 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
{documents.map((doc) => (
<div
key={doc.id}
className="bg-slate-900 rounded-2xl border border-slate-700/50 p-5 flex flex-col"
>
{/* 카테고리 + 상태 뱃지 */}
<div className="flex items-center gap-2 mb-3">
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium border ${CATEGORY_COLORS[doc.category]}`}>
{doc.category}
</span>
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium ${STATUS_CONFIG[doc.status].color}`}>
{STATUS_CONFIG[doc.status].label}
</span>
</div>
{/* 제목 + 설명 */}
<h3 className="text-white font-semibold text-sm mb-1.5">{doc.title}</h3>
<p className="text-slate-400 text-xs leading-relaxed mb-4 flex-1">{doc.description}</p>
{/* 수정일 + 버튼 */}
<div className="flex items-center justify-between">
<span className="text-slate-600 text-xs">: {doc.updatedAt}</span>
<div className="flex gap-2">
<button
onClick={() => setPreviewDoc(doc)}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-red-600/20 text-red-400 hover:bg-red-600/30 transition border border-red-500/20"
>
</button>
<button
onClick={() => window.open(`/api/admin/documents/${doc.fileName}`, '_blank')}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-slate-700 text-slate-300 hover:bg-slate-600 hover:text-white transition"
>
</button>
</div>
</div>
</div>
))}
</div>
{/* 미리보기 섹션 */}
{previewDoc && (
<div className="bg-slate-900 rounded-2xl border border-slate-700/50 overflow-hidden">
{/* 미리보기 헤더 */}
<div className="flex items-center justify-between px-5 py-3 border-b border-slate-700/50">
<div className="flex items-center gap-3">
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<span className="text-white text-sm font-medium">{previewDoc.title}</span>
</div>
<button
onClick={() => setPreviewDoc(null)}
className="p-1.5 rounded-lg text-slate-500 hover:text-white hover:bg-slate-800 transition"
aria-label="미리보기 닫기"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* iframe */}
<iframe
src={`/api/admin/documents/${previewDoc.fileName}`}
className="w-full bg-white"
style={{ height: '80vh' }}
title={previewDoc.title}
/>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,44 @@
import { NextResponse } from 'next/server';
import { readFile } from 'fs/promises';
import path from 'path';
import { verifyAdminTokenNode } from '@/lib/admin-auth';
import { cookies } from 'next/headers';
export const runtime = 'nodejs';
const ALLOWED_FILES = [
'ebay-tool-proposal.html',
'ebay-tool-questionnaire.html',
];
async function checkAuth() {
const cookieStore = await cookies();
const token = cookieStore.get('admin_token')?.value;
return token && verifyAdminTokenNode(token);
}
export async function GET(
request: Request,
{ params }: { params: Promise<{ filename: string }> }
) {
if (!(await checkAuth())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { filename } = await params;
if (!ALLOWED_FILES.includes(filename)) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
try {
const filePath = path.join(process.cwd(), 'CONTENT', filename);
const content = await readFile(filePath, 'utf-8');
return new NextResponse(content, {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
});
} catch {
return NextResponse.json({ error: 'File not found' }, { status: 404 });
}
}

View File

@@ -0,0 +1,85 @@
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
try {
const body = await request.json();
const { partNumber, partName } = body;
if (!partNumber || typeof partNumber !== 'string' || partNumber.trim().length === 0) {
return NextResponse.json(
{ success: false, error: '품번을 입력해주세요.' },
{ status: 400 }
);
}
// MVP: 1.5초 딜레이로 실제 크롤링 소요 시간 시뮬레이션
await new Promise((resolve) => setTimeout(resolve, 1500));
const trimmedPart = partNumber.trim();
const trimmedName = partName?.trim() || 'Fuel Pump Assembly';
const mockData = {
basicInfo: {
partNumber: trimmedPart,
partName: trimmedName,
brand: 'Toyota / Denso',
oemNumbers: [trimmedPart, '23220-0H040'],
category:
'eBay Motors > Parts & Accessories > Car & Truck Parts > Fuel System > Fuel Pumps',
},
listing: {
title: `${trimmedName} For Toyota Camry 2007-2011 2.4L ${trimmedPart} OEM Denso`,
category: '33549',
itemSpecifics: {
Brand: 'Denso',
'Manufacturer Part Number': trimmedPart,
Type: trimmedName,
'Placement on Vehicle': 'In-Tank',
Voltage: '12V',
Warranty: '1 Year',
},
},
fitment: [
{ year: '2007', make: 'Toyota', model: 'Camry', engine: '2.4L L4', confidence: 'high' },
{ year: '2008', make: 'Toyota', model: 'Camry', engine: '2.4L L4', confidence: 'high' },
{ year: '2009', make: 'Toyota', model: 'Camry', engine: '2.4L L4', confidence: 'high' },
{ year: '2010', make: 'Toyota', model: 'Camry', engine: '2.4L L4', confidence: 'high' },
{ year: '2011', make: 'Toyota', model: 'Camry', engine: '2.4L L4', confidence: 'high' },
{ year: '2007', make: 'Toyota', model: 'Camry', engine: '3.5L V6', confidence: 'medium' },
],
pricing: {
sources: [
{ site: 'RockAuto', price: 89.99, currency: 'USD', url: 'https://www.rockauto.com/en/catalog/toyota,2009,camry,2.4l+l4,1443745,fuel+&+air,fuel+pump+&+housing+assembly,6256' },
{ site: 'AutoZone', price: 129.99, currency: 'USD', url: 'https://www.autozone.com/fuel-delivery/fuel-pump-assembly' },
{ site: 'Amazon', price: 95.5, currency: 'USD', url: 'https://www.amazon.com/dp/B07EXAMPLE' },
],
exchangeRate: { rate: 1380, source: '한국은행', date: '2026-04-02' },
customs: { hsCode: '8413.30', dutyRate: '8%', estimatedDuty: 9920 },
},
rawData: {
crawledSources: ['RockAuto', 'AutoZone', 'Amazon'],
rawResults: {
rockauto: { found: true, listings: 3, avgPrice: 89.99 },
autozone: { found: true, listings: 1, avgPrice: 129.99 },
amazon: { found: true, listings: 5, avgPrice: 95.5 },
},
fitmentSources: ['PartsFinder DB', 'eBay Catalog'],
timestamp: new Date().toISOString(),
},
meta: {
searchedAt: new Date().toISOString(),
sourcesChecked: ['RockAuto', 'AutoZone', 'Amazon'],
processingTime: '12.3s',
aiModel: 'claude-sonnet-4-20250514',
},
};
return NextResponse.json({ success: true, data: mockData }, { status: 200 });
} catch (error) {
console.error('[EbayParts] Search error:', error);
return NextResponse.json(
{ success: false, error: '검색 처리 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}

View File

@@ -63,6 +63,17 @@ const navItems = [
</svg>
),
},
{
href: '/tools/ebay-parts',
label: '이베이 부품 검색',
badge: 'NEW',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 11h4m-2-2v4" />
</svg>
),
},
];
interface SidebarProps {

View File

@@ -0,0 +1,568 @@
'use client';
import { useState, useCallback } from 'react';
/* ── Types ──────────────────────────────────────────────────── */
interface FitmentEntry {
year: string;
make: string;
model: string;
engine: string;
confidence: 'high' | 'medium' | 'low';
}
interface PricingSource {
site: string;
price: number;
currency: string;
url: string;
}
interface SearchResult {
basicInfo: {
partNumber: string;
partName: string;
brand: string;
oemNumbers: string[];
category: string;
};
listing: {
title: string;
category: string;
itemSpecifics: Record<string, string>;
};
fitment: FitmentEntry[];
pricing: {
sources: PricingSource[];
exchangeRate: { rate: number; source: string; date: string };
customs: { hsCode: string; dutyRate: string; estimatedDuty: number };
};
rawData: Record<string, unknown>;
meta: {
searchedAt: string;
sourcesChecked: string[];
processingTime: string;
aiModel: string;
};
}
interface HistoryItem {
partNumber: string;
partName?: string;
time: string;
resultSummary: string;
}
/* ── Tab IDs ────────────────────────────────────────────────── */
const TABS = [
{ id: 'basic', label: '기본 정보' },
{ id: 'listing', label: '이베이 리스팅' },
{ id: 'fitment', label: '호환 차종' },
{ id: 'pricing', label: '가격 비교' },
{ id: 'raw', label: '원본 데이터' },
] as const;
type TabId = (typeof TABS)[number]['id'];
/* ── Icons (inline SVGs) ────────────────────────────────────── */
const SearchIcon = () => (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
);
const SpinnerIcon = () => (
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
);
const CopyIcon = () => (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
);
const ClockIcon = () => (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
/* ── Component ──────────────────────────────────────────────── */
export default function EbayPartsPage() {
const [partNumber, setPartNumber] = useState('');
const [partName, setPartName] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<SearchResult | null>(null);
const [activeTab, setActiveTab] = useState<TabId>('basic');
const [history, setHistory] = useState<HistoryItem[]>([]);
const [copied, setCopied] = useState(false);
const [rawExpanded, setRawExpanded] = useState(false);
/* ── Search ─────────────────────────────────────────────── */
const handleSearch = useCallback(
async (pn?: string, pnm?: string) => {
const searchPartNumber = pn ?? partNumber;
const searchPartName = pnm ?? partName;
if (!searchPartNumber.trim()) {
setError('품번을 입력해주세요.');
return;
}
setLoading(true);
setError(null);
setResult(null);
setActiveTab('basic');
try {
const res = await fetch('/api/tools/ebay-parts/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
partNumber: searchPartNumber.trim(),
partName: searchPartName.trim() || undefined,
}),
});
const json = await res.json();
if (!res.ok || !json.success) {
setError(json.error || '검색에 실패했습니다.');
return;
}
setResult(json.data);
// Update history
setHistory((prev) => {
const entry: HistoryItem = {
partNumber: searchPartNumber.trim(),
partName: searchPartName.trim() || undefined,
time: new Date().toLocaleTimeString('ko-KR'),
resultSummary: `${json.data.fitment.length}개 차종, ${json.data.pricing.sources.length}개 소스`,
};
return [entry, ...prev.filter((h) => h.partNumber !== entry.partNumber)].slice(0, 5);
});
} catch {
setError('네트워크 오류가 발생했습니다. 다시 시도해주세요.');
} finally {
setLoading(false);
}
},
[partNumber, partName]
);
const handleHistoryClick = (item: HistoryItem) => {
setPartNumber(item.partNumber);
setPartName(item.partName || '');
handleSearch(item.partNumber, item.partName);
};
const handleCopy = async (text: string) => {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
/* ── Confidence Badge ──────────────────────────────────── */
const ConfidenceBadge = ({ level }: { level: string }) => {
const styles: Record<string, string> = {
high: 'bg-emerald-50 text-emerald-700 border-emerald-200',
medium: 'bg-amber-50 text-amber-700 border-amber-200',
low: 'bg-red-50 text-red-700 border-red-200',
};
const labels: Record<string, string> = { high: 'High', medium: 'Medium', low: 'Low' };
return (
<span className={`text-xs font-medium px-2 py-0.5 rounded border ${styles[level] || styles.low}`}>
{labels[level] || level}
</span>
);
};
/* ── Tab Content Renderers ─────────────────────────────── */
const renderBasicInfo = () => {
if (!result) return null;
const { basicInfo } = result;
return (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[
['부품명', basicInfo.partName],
['브랜드', basicInfo.brand],
['품번', basicInfo.partNumber],
['카테고리', basicInfo.category],
].map(([label, value]) => (
<div key={label} className="bg-slate-50 rounded-lg p-4 border border-slate-100">
<p className="text-xs text-slate-500 font-medium mb-1">{label}</p>
<p className="text-sm text-slate-900 font-semibold">{value}</p>
</div>
))}
</div>
<div className="bg-slate-50 rounded-lg p-4 border border-slate-100">
<p className="text-xs text-slate-500 font-medium mb-2">OEM </p>
<div className="flex flex-wrap gap-2">
{basicInfo.oemNumbers.map((num) => (
<span key={num} className="bg-white text-sm font-mono text-slate-700 px-3 py-1 rounded border border-slate-200">
{num}
</span>
))}
</div>
</div>
</div>
);
};
const renderListing = () => {
if (!result) return null;
const { listing } = result;
return (
<div className="space-y-4">
{/* Title */}
<div className="bg-slate-50 rounded-lg p-4 border border-slate-100">
<div className="flex items-center justify-between mb-1">
<p className="text-xs text-slate-500 font-medium"> </p>
<button
onClick={() => handleCopy(listing.title)}
className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800 transition-colors"
>
<CopyIcon />
{copied ? '복사됨' : '복사'}
</button>
</div>
<p className="text-sm text-slate-900 font-semibold">{listing.title}</p>
</div>
{/* Category */}
<div className="bg-slate-50 rounded-lg p-4 border border-slate-100">
<p className="text-xs text-slate-500 font-medium mb-1"> </p>
<p className="text-sm text-slate-900 font-semibold">{listing.category}</p>
</div>
{/* Item Specifics */}
<div className="bg-slate-50 rounded-lg p-4 border border-slate-100">
<p className="text-xs text-slate-500 font-medium mb-3">Item Specifics</p>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-2 px-3 font-semibold text-slate-600">Key</th>
<th className="text-left py-2 px-3 font-semibold text-slate-600">Value</th>
</tr>
</thead>
<tbody>
{Object.entries(listing.itemSpecifics).map(([key, value]) => (
<tr key={key} className="border-b border-slate-100">
<td className="py-2 px-3 text-slate-600">{key}</td>
<td className="py-2 px-3 text-slate-900 font-medium">{value}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
};
const renderFitment = () => {
if (!result) return null;
return (
<div className="bg-slate-50 rounded-lg p-4 border border-slate-100">
<p className="text-xs text-slate-500 font-medium mb-3"> </p>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-2 px-3 font-semibold text-slate-600">Year</th>
<th className="text-left py-2 px-3 font-semibold text-slate-600">Make</th>
<th className="text-left py-2 px-3 font-semibold text-slate-600">Model</th>
<th className="text-left py-2 px-3 font-semibold text-slate-600">Engine</th>
<th className="text-left py-2 px-3 font-semibold text-slate-600"></th>
</tr>
</thead>
<tbody>
{result.fitment.map((f, i) => (
<tr key={i} className="border-b border-slate-100">
<td className="py-2 px-3 text-slate-900">{f.year}</td>
<td className="py-2 px-3 text-slate-900">{f.make}</td>
<td className="py-2 px-3 text-slate-900">{f.model}</td>
<td className="py-2 px-3 text-slate-700 font-mono text-xs">{f.engine}</td>
<td className="py-2 px-3">
<ConfidenceBadge level={f.confidence} />
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
const renderPricing = () => {
if (!result) return null;
const { pricing } = result;
return (
<div className="space-y-4">
{/* Price table */}
<div className="bg-slate-50 rounded-lg p-4 border border-slate-100">
<p className="text-xs text-slate-500 font-medium mb-3"> </p>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-2 px-3 font-semibold text-slate-600"></th>
<th className="text-right py-2 px-3 font-semibold text-slate-600"> (USD)</th>
<th className="text-right py-2 px-3 font-semibold text-slate-600"> </th>
<th className="text-left py-2 px-3 font-semibold text-slate-600"></th>
</tr>
</thead>
<tbody>
{pricing.sources.map((s) => (
<tr key={s.site} className="border-b border-slate-100">
<td className="py-2 px-3 text-slate-900 font-medium">{s.site}</td>
<td className="py-2 px-3 text-right text-slate-900 font-mono">
${s.price.toFixed(2)}
</td>
<td className="py-2 px-3 text-right text-slate-600 font-mono">
{Math.round(s.price * pricing.exchangeRate.rate).toLocaleString()}
</td>
<td className="py-2 px-3">
<a
href={s.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 text-xs underline"
>
</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Exchange + customs */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-slate-50 rounded-lg p-4 border border-slate-100">
<p className="text-xs text-slate-500 font-medium mb-2"> </p>
<p className="text-lg font-bold text-slate-900">
1 USD = {pricing.exchangeRate.rate.toLocaleString()}
</p>
<p className="text-xs text-slate-500 mt-1">
{pricing.exchangeRate.source} ({pricing.exchangeRate.date})
</p>
</div>
<div className="bg-slate-50 rounded-lg p-4 border border-slate-100">
<p className="text-xs text-slate-500 font-medium mb-2"> </p>
<p className="text-sm text-slate-900">
HS Code: <span className="font-mono font-bold">{pricing.customs.hsCode}</span>
</p>
<p className="text-sm text-slate-900">
: <span className="font-bold">{pricing.customs.dutyRate}</span>
</p>
<p className="text-sm text-slate-900">
: <span className="font-bold text-blue-700">{pricing.customs.estimatedDuty.toLocaleString()}</span>
</p>
</div>
</div>
</div>
);
};
const renderRawData = () => {
if (!result) return null;
return (
<div className="bg-slate-50 rounded-lg border border-slate-100">
<button
onClick={() => setRawExpanded(!rawExpanded)}
className="w-full flex items-center justify-between p-4 text-sm font-medium text-slate-700 hover:text-slate-900 transition-colors"
>
<span> JSON </span>
<svg
className={`w-4 h-4 transition-transform ${rawExpanded ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{rawExpanded && (
<div className="px-4 pb-4">
<pre className="bg-slate-900 text-slate-100 rounded-lg p-4 overflow-x-auto text-xs leading-relaxed">
{JSON.stringify(result.rawData, null, 2)}
</pre>
</div>
)}
</div>
);
};
const renderTabContent = () => {
switch (activeTab) {
case 'basic':
return renderBasicInfo();
case 'listing':
return renderListing();
case 'fitment':
return renderFitment();
case 'pricing':
return renderPricing();
case 'raw':
return renderRawData();
}
};
/* ── Skeleton ──────────────────────────────────────────── */
const Skeleton = () => (
<div className="space-y-6 animate-pulse">
<div className="flex items-center gap-3 p-4 bg-blue-50 border border-blue-100 rounded-lg">
<SpinnerIcon />
<p className="text-sm text-blue-700 font-medium">AI가 ...</p>
</div>
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="bg-slate-100 rounded-lg h-20" />
))}
</div>
</div>
);
/* ── Render ────────────────────────────────────────────── */
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-8 space-y-8">
{/* ── Header ──────────────────────────────────────── */}
<div>
<div className="flex items-center gap-3 mb-2">
<h1 className="text-2xl font-bold text-slate-900"> AI </h1>
<span className="text-xs font-bold px-2 py-1 rounded bg-blue-100 text-blue-700 border border-blue-200">
MVP
</span>
</div>
<p className="text-slate-500 text-sm">
AI가
</p>
</div>
{/* ── Input Form ──────────────────────────────────── */}
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5">
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex-1">
<label className="block text-xs font-semibold text-slate-600 mb-1.5"> *</label>
<input
type="text"
value={partNumber}
onChange={(e) => setPartNumber(e.target.value)}
placeholder="예: 16610-0H040"
className="w-full px-3 py-2.5 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500 transition text-slate-900 placeholder:text-slate-400"
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
</div>
<div className="flex-1">
<label className="block text-xs font-semibold text-slate-600 mb-1.5">
<span className="text-slate-400 font-normal">()</span>
</label>
<input
type="text"
value={partName}
onChange={(e) => setPartName(e.target.value)}
placeholder="예: Fuel Pump Assembly"
className="w-full px-3 py-2.5 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500 transition text-slate-900 placeholder:text-slate-400"
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
</div>
<div className="flex items-end">
<button
onClick={() => handleSearch()}
disabled={loading}
className="flex items-center gap-2 px-5 py-2.5 bg-[#1a56db] text-white text-sm font-semibold rounded-lg hover:bg-blue-800 disabled:opacity-60 disabled:cursor-not-allowed transition-colors whitespace-nowrap"
>
{loading ? <SpinnerIcon /> : <SearchIcon />}
{loading ? '검색 중...' : '검색 시작'}
</button>
</div>
</div>
</div>
{/* ── Error ───────────────────────────────────────── */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-sm text-red-700 font-medium">{error}</p>
</div>
)}
{/* ── Loading ─────────────────────────────────────── */}
{loading && <Skeleton />}
{/* ── Result Tabs ─────────────────────────────────── */}
{result && !loading && (
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
{/* Tabs */}
<div className="flex border-b border-slate-200 overflow-x-auto">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-3 text-sm font-medium whitespace-nowrap transition-colors border-b-2 ${
activeTab === tab.id
? 'border-blue-600 text-blue-700 bg-blue-50/50'
: 'border-transparent text-slate-500 hover:text-slate-700 hover:bg-slate-50'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Tab Content */}
<div className="p-5">{renderTabContent()}</div>
{/* Meta footer */}
<div className="border-t border-slate-100 px-5 py-3 flex flex-wrap gap-4 text-xs text-slate-400">
<span> : {new Date(result.meta.searchedAt).toLocaleString('ko-KR')}</span>
<span> : {result.meta.processingTime}</span>
<span>: {result.meta.sourcesChecked.join(', ')}</span>
<span>: {result.meta.aiModel}</span>
</div>
</div>
)}
{/* ── Search History ──────────────────────────────── */}
{history.length > 0 && (
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5">
<div className="flex items-center gap-2 mb-3">
<ClockIcon />
<h3 className="text-sm font-semibold text-slate-700"> </h3>
</div>
<div className="space-y-2">
{history.map((item, i) => (
<button
key={i}
onClick={() => handleHistoryClick(item)}
className="w-full flex items-center justify-between px-3 py-2.5 rounded-lg bg-slate-50 hover:bg-slate-100 transition-colors text-left"
>
<div>
<span className="text-sm font-mono font-semibold text-slate-800">{item.partNumber}</span>
{item.partName && (
<span className="text-xs text-slate-500 ml-2">{item.partName}</span>
)}
</div>
<div className="flex items-center gap-3 text-xs text-slate-400">
<span>{item.resultSummary}</span>
<span>{item.time}</span>
</div>
</button>
))}
</div>
</div>
)}
</div>
);
}