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:
@@ -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: '마케팅 에셋',
|
||||
|
||||
140
app/admin/documents/page.tsx
Normal file
140
app/admin/documents/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
app/api/admin/documents/[filename]/route.ts
Normal file
44
app/api/admin/documents/[filename]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
85
app/api/tools/ebay-parts/search/route.ts
Normal file
85
app/api/tools/ebay-parts/search/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
568
app/tools/ebay-parts/page.tsx
Normal file
568
app/tools/ebay-parts/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user