Spaces:
Sleeping
Sleeping
| import { useEffect, useMemo, useRef, useState } from 'react'; | |
| import type { DemoLocale, DemoMode, ProgressStep, ProgressStepStatus, TraceItem } from './types'; | |
| import MarketplaceFeatureDemo from './MarketplaceFeatureDemo'; | |
| import { callVoiceTool } from './liveApi'; | |
| import type { LiveVoiceToolUi } from './liveApi'; | |
| type MarketplaceMode = 'hybrid' | 'products' | 'stores' | 'stats'; | |
| type ProductRecord = { | |
| id: string; | |
| name: string; | |
| category: string; | |
| price: number; | |
| unit: string; | |
| stock: number; | |
| tags: string[]; | |
| location: string; | |
| seller: string; | |
| trend: number; | |
| }; | |
| type StoreRecord = { | |
| id: string; | |
| name: string; | |
| type: 'retail' | 'restaurant' | 'wholesale' | 'co-op'; | |
| location: string; | |
| buyingFocus: string[]; | |
| responseRate: number; | |
| activeListings: number; | |
| rating: number; | |
| }; | |
| type RankedProduct = ProductRecord & { score: number }; | |
| type RankedStore = StoreRecord & { score: number }; | |
| type MarketplaceStats = { | |
| sellers: number; | |
| buyers: number; | |
| products: number; | |
| stores: number; | |
| avgResponseRate: number; | |
| weeklyDemandIndex: number; | |
| }; | |
| type MarketplaceStepTemplate = { | |
| id: string; | |
| label: string; | |
| runningDetail: string; | |
| doneDetail: string; | |
| kind: TraceItem['kind']; | |
| delayMs: number; | |
| }; | |
| type MarketplaceDemoPanelProps = { | |
| mode: DemoMode; | |
| locale: DemoLocale; | |
| onWorkingChange: (working: boolean) => void; | |
| onWorkflowInit: (steps: ProgressStep[]) => void; | |
| onStepStatus: (stepId: string, status: ProgressStepStatus, detail?: string) => void; | |
| onTrace: (item: TraceItem) => void; | |
| onClearPanels: () => void; | |
| queuedPrompt: string | null; | |
| onConsumeQueuedPrompt: () => void; | |
| }; | |
| const PRODUCT_DATA: ProductRecord[] = [ | |
| { | |
| id: 'prd-101', | |
| name: 'Free-range Eggs', | |
| category: 'Eggs', | |
| price: 118, | |
| unit: 'box', | |
| stock: 48, | |
| tags: ['egg', 'protein', 'fresh'], | |
| location: 'Hsinchu', | |
| seller: 'Beipu Greenfield Farm', | |
| trend: 84, | |
| }, | |
| { | |
| id: 'prd-102', | |
| name: 'Organic Spinach Bundle', | |
| category: 'Vegetables', | |
| price: 72, | |
| unit: 'bundle', | |
| stock: 63, | |
| tags: ['leafy', 'organic', 'salad'], | |
| location: 'Taoyuan', | |
| seller: 'Morning Leaf Cooperative', | |
| trend: 69, | |
| }, | |
| { | |
| id: 'prd-103', | |
| name: 'Golden Sweet Corn', | |
| category: 'Produce', | |
| price: 95, | |
| unit: 'pack', | |
| stock: 64, | |
| tags: ['corn', 'sweet', 'seasonal'], | |
| location: 'Miaoli', | |
| seller: 'Golden Ridge Farm', | |
| trend: 77, | |
| }, | |
| { | |
| id: 'prd-104', | |
| name: 'Ariake Seaweed Tofu', | |
| category: 'Tofu', | |
| price: 145, | |
| unit: 'set', | |
| stock: 18, | |
| tags: ['tofu', 'protein', 'vegan'], | |
| location: 'Taichung', | |
| seller: 'Blue Harbor Foods', | |
| trend: 58, | |
| }, | |
| { | |
| id: 'prd-105', | |
| name: 'Premium Cherry Tomatoes', | |
| category: 'Produce', | |
| price: 160, | |
| unit: 'box', | |
| stock: 32, | |
| tags: ['tomato', 'snack', 'fruit'], | |
| location: 'Tainan', | |
| seller: 'Red Orchard Collective', | |
| trend: 73, | |
| }, | |
| { | |
| id: 'prd-106', | |
| name: 'Jasmine Rice 2kg', | |
| category: 'Grains', | |
| price: 210, | |
| unit: 'bag', | |
| stock: 25, | |
| tags: ['rice', 'staple', 'bulk'], | |
| location: 'Yunlin', | |
| seller: 'Riverbank Fields', | |
| trend: 66, | |
| }, | |
| { | |
| id: 'prd-107', | |
| name: 'Fresh Strawberries', | |
| category: 'Fruit', | |
| price: 245, | |
| unit: 'box', | |
| stock: 16, | |
| tags: ['fruit', 'dessert', 'seasonal'], | |
| location: 'Nantou', | |
| seller: 'Suncrest Berry Farm', | |
| trend: 88, | |
| }, | |
| { | |
| id: 'prd-108', | |
| name: 'Young Ginger Pack', | |
| category: 'Produce', | |
| price: 86, | |
| unit: 'pack', | |
| stock: 40, | |
| tags: ['ginger', 'spice', 'seasoning'], | |
| location: 'Pingtung', | |
| seller: 'South Harvest Garden', | |
| trend: 55, | |
| }, | |
| ]; | |
| const STORE_DATA: StoreRecord[] = [ | |
| { | |
| id: 'str-201', | |
| name: 'Fresh Table Market', | |
| type: 'retail', | |
| location: 'Taipei', | |
| buyingFocus: ['Eggs', 'Fruit', 'Vegetables'], | |
| responseRate: 0.92, | |
| activeListings: 18, | |
| rating: 4.7, | |
| }, | |
| { | |
| id: 'str-202', | |
| name: 'Harvest Bento Kitchen', | |
| type: 'restaurant', | |
| location: 'Hsinchu', | |
| buyingFocus: ['Eggs', 'Tofu', 'Produce'], | |
| responseRate: 0.87, | |
| activeListings: 11, | |
| rating: 4.5, | |
| }, | |
| { | |
| id: 'str-203', | |
| name: 'Island Bulk Foods', | |
| type: 'wholesale', | |
| location: 'Taichung', | |
| buyingFocus: ['Grains', 'Eggs', 'Produce'], | |
| responseRate: 0.84, | |
| activeListings: 22, | |
| rating: 4.4, | |
| }, | |
| { | |
| id: 'str-204', | |
| name: 'Green Basket Co-op', | |
| type: 'co-op', | |
| location: 'Tainan', | |
| buyingFocus: ['Vegetables', 'Fruit', 'Grains'], | |
| responseRate: 0.89, | |
| activeListings: 14, | |
| rating: 4.6, | |
| }, | |
| { | |
| id: 'str-205', | |
| name: 'Seaside Family Mart', | |
| type: 'retail', | |
| location: 'Keelung', | |
| buyingFocus: ['Fruit', 'Produce'], | |
| responseRate: 0.81, | |
| activeListings: 9, | |
| rating: 4.1, | |
| }, | |
| { | |
| id: 'str-206', | |
| name: 'Lotus Garden Restaurant', | |
| type: 'restaurant', | |
| location: 'Taoyuan', | |
| buyingFocus: ['Tofu', 'Vegetables', 'Rice'], | |
| responseRate: 0.9, | |
| activeListings: 13, | |
| rating: 4.8, | |
| }, | |
| ]; | |
| const now = () => | |
| new Date().toLocaleTimeString([], { | |
| hour: '2-digit', | |
| minute: '2-digit', | |
| second: '2-digit', | |
| }); | |
| const sleep = (ms: number) => | |
| new Promise<void>((resolve) => { | |
| setTimeout(resolve, ms); | |
| }); | |
| function makeId(prefix: string): string { | |
| return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`; | |
| } | |
| function normalize(text: string): string { | |
| return text.trim().toLowerCase(); | |
| } | |
| function tokenize(query: string): string[] { | |
| return normalize(query) | |
| .split(/[\s,]+/) | |
| .map((token) => token.trim()) | |
| .filter(Boolean); | |
| } | |
| function buildTrace( | |
| kind: TraceItem['kind'], | |
| title: string, | |
| detail: string, | |
| status: TraceItem['status'], | |
| payload?: Record<string, unknown> | |
| ): TraceItem { | |
| return { | |
| id: makeId('trace'), | |
| kind, | |
| title, | |
| detail, | |
| status, | |
| payload, | |
| timestamp: now(), | |
| }; | |
| } | |
| function categoryLabel(locale: DemoLocale, value: string): string { | |
| if (locale !== 'zh-TW') return value; | |
| const labels: Record<string, string> = { | |
| all: '全部', | |
| Eggs: '蛋品', | |
| Vegetables: '蔬菜', | |
| Produce: '農產', | |
| Tofu: '豆製品', | |
| Grains: '穀物', | |
| Fruit: '水果', | |
| }; | |
| return labels[value] ?? value; | |
| } | |
| function storeTypeLabel(locale: DemoLocale, value: string): string { | |
| if (locale !== 'zh-TW') return value; | |
| const labels: Record<string, string> = { | |
| all: '全部', | |
| retail: '零售', | |
| restaurant: '餐飲', | |
| wholesale: '批發', | |
| 'co-op': '合作社', | |
| }; | |
| return labels[value] ?? value; | |
| } | |
| function focusLabel(locale: DemoLocale, value: string): string { | |
| if (locale !== 'zh-TW') return value; | |
| const labels: Record<string, string> = { | |
| Eggs: '蛋品', | |
| Fruit: '水果', | |
| Vegetables: '蔬菜', | |
| Tofu: '豆製品', | |
| Produce: '農產', | |
| Grains: '穀物', | |
| Rice: '米', | |
| }; | |
| return labels[value] ?? value; | |
| } | |
| function computeMarketplaceStats(products: ProductRecord[], stores: StoreRecord[]): MarketplaceStats { | |
| const responseRateAverage = | |
| stores.length > 0 ? stores.reduce((sum, store) => sum + store.responseRate, 0) / stores.length : 0; | |
| const weeklyDemandIndex = | |
| products.length > 0 ? Math.round(products.reduce((sum, product) => sum + product.trend, 0) / products.length) : 0; | |
| return { | |
| sellers: 42, | |
| buyers: 67, | |
| products: products.length, | |
| stores: stores.length, | |
| avgResponseRate: responseRateAverage, | |
| weeklyDemandIndex, | |
| }; | |
| } | |
| function toRankedProductsFromUi(ui: LiveVoiceToolUi | undefined, limit: number): RankedProduct[] { | |
| const rows = Array.isArray(ui?.items) ? ui.items : []; | |
| return rows.slice(0, limit).map((item, index) => { | |
| const row = typeof item === 'object' && item ? (item as Record<string, unknown>) : {}; | |
| return { | |
| id: typeof row.id === 'string' ? row.id : `live-product-${index}`, | |
| name: typeof row.name === 'string' ? row.name : `Product ${index + 1}`, | |
| category: typeof row.category === 'string' ? row.category : 'General', | |
| price: typeof row.price === 'number' ? row.price : 0, | |
| unit: typeof row.unit === 'string' ? row.unit : 'unit', | |
| stock: typeof row.stock === 'number' ? row.stock : 0, | |
| tags: [], | |
| location: typeof row.location === 'string' ? row.location : '-', | |
| seller: typeof row.seller === 'string' ? row.seller : '-', | |
| trend: Math.max(10, 100 - index * 8), | |
| score: Math.max(18, 100 - index * 12), | |
| }; | |
| }); | |
| } | |
| function toRankedStoresFromUi(ui: LiveVoiceToolUi | undefined, limit: number): RankedStore[] { | |
| const rows = Array.isArray(ui?.items) ? ui.items : []; | |
| return rows.slice(0, limit).map((item, index) => { | |
| const row = typeof item === 'object' && item ? (item as Record<string, unknown>) : {}; | |
| const sourceType = | |
| typeof row.type === 'string' && | |
| (row.type === 'retail' || row.type === 'restaurant' || row.type === 'wholesale' || row.type === 'co-op') | |
| ? row.type | |
| : 'retail'; | |
| return { | |
| id: typeof row.id === 'string' ? row.id : `live-store-${index}`, | |
| name: typeof row.name === 'string' ? row.name : `Store ${index + 1}`, | |
| type: sourceType, | |
| location: typeof row.location === 'string' ? row.location : '-', | |
| buyingFocus: [], | |
| responseRate: 0.8, | |
| activeListings: typeof row.activeListings === 'number' ? row.activeListings : 0, | |
| rating: typeof row.rating === 'number' ? row.rating : 4, | |
| score: Math.max(20, 100 - index * 12), | |
| }; | |
| }); | |
| } | |
| function toMarketplaceStatsFromUi(ui: LiveVoiceToolUi | undefined): MarketplaceStats | null { | |
| const rows = Array.isArray(ui?.items) ? ui.items : []; | |
| if (!rows.length) return null; | |
| const map: Record<string, number> = {}; | |
| for (const row of rows) { | |
| if (typeof row !== 'object' || !row) continue; | |
| const item = row as Record<string, unknown>; | |
| const label = typeof item.label === 'string' ? item.label.toLowerCase() : ''; | |
| const value = typeof item.value === 'number' ? item.value : typeof item.value === 'string' ? Number(item.value) : NaN; | |
| if (!label || Number.isNaN(value)) continue; | |
| map[label] = value; | |
| } | |
| return { | |
| sellers: map.sellers ?? map['賣家'] ?? 0, | |
| buyers: map.buyers ?? map['買家'] ?? 0, | |
| products: map.products ?? map['產品'] ?? map['商品'] ?? 0, | |
| stores: map.stores ?? map['店家'] ?? 0, | |
| avgResponseRate: 0, | |
| weeklyDemandIndex: 0, | |
| }; | |
| } | |
| function scoreProduct( | |
| record: ProductRecord, | |
| queryTokens: string[], | |
| category: string, | |
| minPrice: number, | |
| maxPrice: number | |
| ): number { | |
| if (category !== 'all' && record.category !== category) return -1; | |
| if (record.price < minPrice || record.price > maxPrice) return -1; | |
| let score = 20 + Math.round(record.trend / 2); | |
| if (record.stock > 0) score += 12; | |
| if (queryTokens.length === 0) return score; | |
| for (const token of queryTokens) { | |
| const inName = normalize(record.name).includes(token); | |
| const inCategory = normalize(record.category).includes(token); | |
| const inTags = record.tags.some((tag) => normalize(tag).includes(token)); | |
| const inSeller = normalize(record.seller).includes(token); | |
| const inLocation = normalize(record.location).includes(token); | |
| if (inName) score += 28; | |
| if (inCategory) score += 16; | |
| if (inTags) score += 12; | |
| if (inSeller) score += 9; | |
| if (inLocation) score += 8; | |
| } | |
| return score; | |
| } | |
| function scoreStore(record: StoreRecord, queryTokens: string[], type: string, location: string): number { | |
| if (type !== 'all' && record.type !== type) return -1; | |
| if (location.trim() && !normalize(record.location).includes(normalize(location))) return -1; | |
| let score = 26 + Math.round(record.responseRate * 30) + Math.round(record.rating * 5); | |
| if (queryTokens.length === 0) return score; | |
| for (const token of queryTokens) { | |
| const inName = normalize(record.name).includes(token); | |
| const inType = normalize(record.type).includes(token); | |
| const inFocus = record.buyingFocus.some((item) => normalize(item).includes(token)); | |
| const inLocation = normalize(record.location).includes(token); | |
| if (inName) score += 26; | |
| if (inType) score += 14; | |
| if (inFocus) score += 12; | |
| if (inLocation) score += 10; | |
| } | |
| return score; | |
| } | |
| function buildSteps(locale: DemoLocale, mode: MarketplaceMode): MarketplaceStepTemplate[] { | |
| const isZh = locale === 'zh-TW'; | |
| const steps: MarketplaceStepTemplate[] = [ | |
| { | |
| id: 'market-step-1', | |
| label: isZh ? '解析搜尋意圖' : 'Parse marketplace intent', | |
| runningDetail: isZh ? '整理查詢、價格與位置條件。' : 'Normalizing query, price and location filters.', | |
| doneDetail: isZh ? '搜尋意圖已解析。' : 'Search intent resolved.', | |
| kind: 'planner', | |
| delayMs: 420, | |
| }, | |
| ]; | |
| if (mode === 'hybrid' || mode === 'products') { | |
| steps.push({ | |
| id: 'market-step-2', | |
| label: isZh ? '呼叫商品搜尋' : 'Call product search', | |
| runningDetail: isZh ? '執行 product_search_public。' : 'Executing product_search_public route.', | |
| doneDetail: isZh ? '商品資料已回傳。' : 'Product results received.', | |
| kind: 'tool', | |
| delayMs: 520, | |
| }); | |
| } | |
| if (mode === 'hybrid' || mode === 'stores') { | |
| steps.push({ | |
| id: 'market-step-3', | |
| label: isZh ? '呼叫店家搜尋' : 'Call store search', | |
| runningDetail: isZh ? '執行 store_search_public。' : 'Executing store_search_public route.', | |
| doneDetail: isZh ? '店家資料已回傳。' : 'Store results received.', | |
| kind: 'tool', | |
| delayMs: 500, | |
| }); | |
| } | |
| if (mode === 'hybrid' || mode === 'stats') { | |
| steps.push({ | |
| id: 'market-step-4', | |
| label: isZh ? '取得市集統計' : 'Fetch marketplace stats', | |
| runningDetail: isZh ? '執行 marketplace_get_stats。' : 'Executing marketplace_get_stats route.', | |
| doneDetail: isZh ? '市集統計已更新。' : 'Marketplace stats updated.', | |
| kind: 'tool', | |
| delayMs: 420, | |
| }); | |
| } | |
| steps.push({ | |
| id: 'market-step-5', | |
| label: isZh ? '排名與渲染結果' : 'Rank and render results', | |
| runningDetail: isZh ? '進行相關性排序與展示組裝。' : 'Running relevance ranking and building output cards.', | |
| doneDetail: isZh ? '市集結果已就緒。' : 'Marketplace output is ready.', | |
| kind: 'renderer', | |
| delayMs: 360, | |
| }); | |
| return steps; | |
| } | |
| const COPY = { | |
| en: { | |
| title: 'Marketplace Discovery', | |
| subtitle: 'Simulated public marketplace search and ranking workflow', | |
| statusRunning: 'Searching…', | |
| statusReady: 'Ready', | |
| modeLabel: 'Search Mode', | |
| modeHybrid: 'Hybrid (products + stores + stats)', | |
| modeProducts: 'Products only', | |
| modeStores: 'Stores only', | |
| modeStats: 'Stats only', | |
| queryLabel: 'Marketplace Query', | |
| queryPlaceholder: 'Search by keyword, category, location, or buyer intent.', | |
| filtersLabel: 'Filters', | |
| categoryLabel: 'Category', | |
| storeTypeLabel: 'Store Type', | |
| locationLabel: 'Location', | |
| minPriceLabel: 'Min Price', | |
| maxPriceLabel: 'Max Price', | |
| limitLabel: 'Result Limit', | |
| run: 'Run Search', | |
| stop: 'Stop', | |
| reset: 'Reset Output', | |
| recentLabel: 'Recent Queries', | |
| outputTitle: 'Marketplace Results', | |
| outputSubtitle: 'Ranked product/store cards with summary stats', | |
| emptyOutput: 'Run a search to preview marketplace discovery output.', | |
| noMatches: 'No records matched current filters. Try a broader query.', | |
| stopped: 'Marketplace search was stopped by user.', | |
| summary: 'Summary', | |
| statsTitle: 'Marketplace Stats', | |
| productsTitle: 'Ranked Products', | |
| storesTitle: 'Ranked Stores', | |
| metaTitle: 'Run Metadata', | |
| rawPayload: 'Structured payload', | |
| product: { | |
| price: 'Price', | |
| stock: 'Stock', | |
| location: 'Location', | |
| seller: 'Seller', | |
| trend: 'Trend', | |
| open: 'Open product', | |
| shortlist: 'Shortlist', | |
| }, | |
| store: { | |
| type: 'Type', | |
| location: 'Location', | |
| focus: 'Buying focus', | |
| response: 'Response', | |
| listings: 'Listings', | |
| score: 'Score', | |
| open: 'Open store', | |
| contact: 'Draft outreach', | |
| }, | |
| stat: { | |
| sellers: 'Sellers', | |
| buyers: 'Buyers', | |
| products: 'Products', | |
| stores: 'Stores', | |
| demand: 'Demand Index', | |
| response: 'Avg Response', | |
| }, | |
| labels: { | |
| query: 'Query', | |
| mode: 'Mode', | |
| startedAt: 'Started At', | |
| duration: 'Duration', | |
| toolCalls: 'Tool Calls', | |
| }, | |
| }, | |
| 'zh-TW': { | |
| title: '市集探索中心', | |
| subtitle: '前端模擬公開市集搜尋與排序流程', | |
| statusRunning: '搜尋中…', | |
| statusReady: '就緒', | |
| modeLabel: '搜尋模式', | |
| modeHybrid: '混合(商品 + 店家 + 統計)', | |
| modeProducts: '僅商品', | |
| modeStores: '僅店家', | |
| modeStats: '僅統計', | |
| queryLabel: '市集查詢', | |
| queryPlaceholder: '輸入關鍵字、品類、地區或買家需求。', | |
| filtersLabel: '篩選條件', | |
| categoryLabel: '商品分類', | |
| storeTypeLabel: '店家類型', | |
| locationLabel: '地區', | |
| minPriceLabel: '最低價格', | |
| maxPriceLabel: '最高價格', | |
| limitLabel: '顯示筆數', | |
| run: '開始搜尋', | |
| stop: '停止', | |
| reset: '重置輸出', | |
| recentLabel: '最近查詢', | |
| outputTitle: '市集結果', | |
| outputSubtitle: '含排序商品/店家卡片與統計摘要', | |
| emptyOutput: '請先執行搜尋以查看市集探索輸出。', | |
| noMatches: '目前條件找不到資料,可放寬條件再試。', | |
| stopped: '市集搜尋已由使用者停止。', | |
| summary: '摘要', | |
| statsTitle: '市集統計', | |
| productsTitle: '商品排名', | |
| storesTitle: '店家排名', | |
| metaTitle: '執行資訊', | |
| rawPayload: '結構化輸出', | |
| product: { | |
| price: '價格', | |
| stock: '庫存', | |
| location: '地區', | |
| seller: '賣家', | |
| trend: '趨勢', | |
| open: '開啟商品', | |
| shortlist: '加入候選', | |
| }, | |
| store: { | |
| type: '類型', | |
| location: '地區', | |
| focus: '採購重點', | |
| response: '回覆率', | |
| listings: '需求數', | |
| score: '分數', | |
| open: '開啟店家', | |
| contact: '草擬聯絡', | |
| }, | |
| stat: { | |
| sellers: '賣家', | |
| buyers: '買家', | |
| products: '商品', | |
| stores: '店家', | |
| demand: '需求指數', | |
| response: '平均回覆', | |
| }, | |
| labels: { | |
| query: '查詢', | |
| mode: '模式', | |
| startedAt: '開始時間', | |
| duration: '耗時', | |
| toolCalls: '工具呼叫', | |
| }, | |
| }, | |
| } as const; | |
| export default function MarketplaceDemoPanel({ | |
| mode: demoMode, | |
| locale, | |
| onWorkingChange, | |
| onWorkflowInit, | |
| onStepStatus, | |
| onTrace, | |
| onClearPanels, | |
| queuedPrompt, | |
| onConsumeQueuedPrompt, | |
| }: MarketplaceDemoPanelProps) { | |
| const copy = COPY[locale]; | |
| const modeLabels: Record<MarketplaceMode, string> = { | |
| hybrid: copy.modeHybrid, | |
| products: copy.modeProducts, | |
| stores: copy.modeStores, | |
| stats: copy.modeStats, | |
| }; | |
| const [mode, setMode] = useState<MarketplaceMode>('hybrid'); | |
| const [query, setQuery] = useState(''); | |
| const [category, setCategory] = useState('all'); | |
| const [storeType, setStoreType] = useState('all'); | |
| const [location, setLocation] = useState(''); | |
| const [minPrice, setMinPrice] = useState(0); | |
| const [maxPrice, setMaxPrice] = useState(260); | |
| const [limit, setLimit] = useState(6); | |
| const [isRunning, setIsRunning] = useState(false); | |
| const [products, setProducts] = useState<RankedProduct[]>([]); | |
| const [stores, setStores] = useState<RankedStore[]>([]); | |
| const [stats, setStats] = useState<MarketplaceStats | null>(null); | |
| const [summary, setSummary] = useState<string | null>(null); | |
| const [error, setError] = useState<string | null>(null); | |
| const [recentQueries, setRecentQueries] = useState<string[]>([]); | |
| const [meta, setMeta] = useState<{ | |
| query: string; | |
| mode: MarketplaceMode; | |
| startedAt: string; | |
| durationMs: number; | |
| toolCalls: string[]; | |
| } | null>(null); | |
| const runTokenRef = useRef(0); | |
| const mountedRef = useRef(true); | |
| const currentStepRef = useRef<string | null>(null); | |
| const categoryOptions = useMemo(() => { | |
| const unique = Array.from(new Set(PRODUCT_DATA.map((record) => record.category))); | |
| return ['all', ...unique]; | |
| }, []); | |
| const stepRunner = async ( | |
| runToken: number, | |
| step: MarketplaceStepTemplate, | |
| payload?: Record<string, unknown> | |
| ): Promise<boolean> => { | |
| if (!mountedRef.current || runTokenRef.current !== runToken) return false; | |
| currentStepRef.current = step.id; | |
| onStepStatus(step.id, 'in_progress', step.runningDetail); | |
| onTrace(buildTrace(step.kind, step.label, step.runningDetail, 'running')); | |
| if (step.delayMs > 0) { | |
| await sleep(step.delayMs); | |
| } | |
| if (!mountedRef.current || runTokenRef.current !== runToken) return false; | |
| onStepStatus(step.id, 'completed', step.doneDetail); | |
| onTrace(buildTrace(step.kind, step.label, step.doneDetail, 'ok', payload)); | |
| return true; | |
| }; | |
| const stopSearch = () => { | |
| if (!isRunning) return; | |
| runTokenRef.current += 1; | |
| if (currentStepRef.current) { | |
| onStepStatus(currentStepRef.current, 'error', copy.stopped); | |
| } | |
| onTrace(buildTrace('tool', locale === 'zh-TW' ? '搜尋中止' : 'Search stopped', copy.stopped, 'error')); | |
| setError(copy.stopped); | |
| setIsRunning(false); | |
| onWorkingChange(false); | |
| }; | |
| const resetOutput = () => { | |
| setProducts([]); | |
| setStores([]); | |
| setStats(null); | |
| setSummary(null); | |
| setError(null); | |
| setMeta(null); | |
| onClearPanels(); | |
| }; | |
| const runSearch = async (forcedQuery?: string) => { | |
| const activeQuery = (forcedQuery ?? query).trim(); | |
| const steps = buildSteps(locale, mode); | |
| const runToken = runTokenRef.current + 1; | |
| runTokenRef.current = runToken; | |
| const startMs = Date.now(); | |
| const tokens = tokenize(activeQuery); | |
| const toolCalls: string[] = []; | |
| onClearPanels(); | |
| setIsRunning(true); | |
| onWorkingChange(true); | |
| setProducts([]); | |
| setStores([]); | |
| setStats(null); | |
| setSummary(null); | |
| setError(null); | |
| setMeta(null); | |
| if (activeQuery) { | |
| setRecentQueries((prev) => [activeQuery, ...prev.filter((item) => item !== activeQuery)].slice(0, 8)); | |
| } | |
| onWorkflowInit( | |
| steps.map((step) => ({ | |
| id: step.id, | |
| label: step.label, | |
| detail: step.runningDetail, | |
| status: 'pending', | |
| })) | |
| ); | |
| try { | |
| const parsed = await stepRunner(runToken, steps[0], { | |
| query: activeQuery || '*', | |
| tokens, | |
| filters: { category, storeType, location, minPrice, maxPrice, limit }, | |
| }); | |
| if (!parsed) return; | |
| if (demoMode === 'live') { | |
| let liveProducts: RankedProduct[] = []; | |
| let liveStores: RankedStore[] = []; | |
| let liveStats: MarketplaceStats | null = null; | |
| const productStep = steps.find((step) => step.id === 'market-step-2'); | |
| if (productStep) { | |
| toolCalls.push('product_search_public'); | |
| const productReady = await stepRunner(runToken, productStep, { | |
| args: { | |
| nameContains: activeQuery || undefined, | |
| categoryEquals: category !== 'all' ? category : undefined, | |
| minPrice, | |
| maxPrice, | |
| limit, | |
| }, | |
| }); | |
| if (!productReady) return; | |
| const tool = await callVoiceTool({ | |
| name: 'product_search_public', | |
| locale, | |
| args: { | |
| nameContains: activeQuery || undefined, | |
| categoryEquals: category !== 'all' ? category : undefined, | |
| minPrice, | |
| maxPrice, | |
| limit, | |
| }, | |
| }); | |
| liveProducts = toRankedProductsFromUi(tool.ui, limit); | |
| setProducts(liveProducts); | |
| onTrace( | |
| buildTrace( | |
| 'tool', | |
| locale === 'zh-TW' ? '商品結果' : 'Product results', | |
| locale === 'zh-TW' ? `回傳 ${liveProducts.length} 筆商品` : `${liveProducts.length} products returned`, | |
| 'ok', | |
| { count: liveProducts.length, uiKind: tool.ui?.kind || null } | |
| ) | |
| ); | |
| } | |
| const storeStep = steps.find((step) => step.id === 'market-step-3'); | |
| if (storeStep) { | |
| toolCalls.push('store_search_public'); | |
| const storeReady = await stepRunner(runToken, storeStep, { | |
| args: { | |
| nameContains: activeQuery || undefined, | |
| typeEquals: storeType !== 'all' ? storeType : undefined, | |
| locationContains: location || undefined, | |
| limit, | |
| }, | |
| }); | |
| if (!storeReady) return; | |
| const tool = await callVoiceTool({ | |
| name: 'store_search_public', | |
| locale, | |
| args: { | |
| nameContains: activeQuery || undefined, | |
| typeEquals: storeType !== 'all' ? storeType : undefined, | |
| locationContains: location || undefined, | |
| limit, | |
| }, | |
| }); | |
| liveStores = toRankedStoresFromUi(tool.ui, limit); | |
| setStores(liveStores); | |
| onTrace( | |
| buildTrace( | |
| 'tool', | |
| locale === 'zh-TW' ? '店家結果' : 'Store results', | |
| locale === 'zh-TW' ? `回傳 ${liveStores.length} 筆店家` : `${liveStores.length} stores returned`, | |
| 'ok', | |
| { count: liveStores.length, uiKind: tool.ui?.kind || null } | |
| ) | |
| ); | |
| } | |
| const statsStep = steps.find((step) => step.id === 'market-step-4'); | |
| if (statsStep) { | |
| toolCalls.push('marketplace_get_stats'); | |
| const statsReady = await stepRunner(runToken, statsStep); | |
| if (!statsReady) return; | |
| const tool = await callVoiceTool({ | |
| name: 'marketplace_get_stats', | |
| locale, | |
| }); | |
| liveStats = toMarketplaceStatsFromUi(tool.ui) || computeMarketplaceStats(liveProducts, liveStores); | |
| setStats(liveStats); | |
| onTrace( | |
| buildTrace( | |
| 'tool', | |
| locale === 'zh-TW' ? '市集統計' : 'Marketplace stats', | |
| locale === 'zh-TW' ? '已取得即時統計。' : 'Fetched live marketplace stats.', | |
| 'ok', | |
| { uiKind: tool.ui?.kind || null } | |
| ) | |
| ); | |
| } | |
| const renderStep = steps.find((step) => step.id === 'market-step-5'); | |
| if (renderStep) { | |
| const rendered = await stepRunner(runToken, renderStep, { | |
| products: liveProducts.length, | |
| stores: liveStores.length, | |
| stats: Boolean(liveStats), | |
| mode: 'live', | |
| }); | |
| if (!rendered) return; | |
| } | |
| const summaryText = | |
| locale === 'zh-TW' | |
| ? `已完成即時市集搜尋:商品 ${liveProducts.length} 筆,店家 ${liveStores.length} 筆。` | |
| : `Live marketplace search completed: ${liveProducts.length} products and ${liveStores.length} stores.`; | |
| setSummary(summaryText); | |
| setMeta({ | |
| query: activeQuery || '*', | |
| mode, | |
| startedAt: new Date(startMs).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }), | |
| durationMs: Date.now() - startMs, | |
| toolCalls, | |
| }); | |
| return; | |
| } | |
| const productStep = steps.find((step) => step.id === 'market-step-2'); | |
| if (productStep) { | |
| toolCalls.push('product_search_public'); | |
| const productReady = await stepRunner(runToken, productStep, { | |
| args: { | |
| nameContains: activeQuery || undefined, | |
| categoryEquals: category !== 'all' ? category : undefined, | |
| minPrice, | |
| maxPrice, | |
| limit, | |
| }, | |
| }); | |
| if (!productReady) return; | |
| const rankedProducts = PRODUCT_DATA.map((record) => ({ | |
| ...record, | |
| score: scoreProduct(record, tokens, category, minPrice, maxPrice), | |
| })) | |
| .filter((record) => record.score > 0) | |
| .sort((a, b) => b.score - a.score) | |
| .slice(0, limit); | |
| setProducts(rankedProducts); | |
| onTrace( | |
| buildTrace( | |
| 'tool', | |
| locale === 'zh-TW' ? '商品結果' : 'Product results', | |
| locale === 'zh-TW' ? `回傳 ${rankedProducts.length} 筆商品` : `${rankedProducts.length} products returned`, | |
| 'ok', | |
| { count: rankedProducts.length } | |
| ) | |
| ); | |
| } | |
| const storeStep = steps.find((step) => step.id === 'market-step-3'); | |
| if (storeStep) { | |
| toolCalls.push('store_search_public'); | |
| const storeReady = await stepRunner(runToken, storeStep, { | |
| args: { | |
| nameContains: activeQuery || undefined, | |
| typeEquals: storeType !== 'all' ? storeType : undefined, | |
| locationContains: location || undefined, | |
| limit, | |
| }, | |
| }); | |
| if (!storeReady) return; | |
| const rankedStores = STORE_DATA.map((record) => ({ | |
| ...record, | |
| score: scoreStore(record, tokens, storeType, location), | |
| })) | |
| .filter((record) => record.score > 0) | |
| .sort((a, b) => b.score - a.score) | |
| .slice(0, limit); | |
| setStores(rankedStores); | |
| onTrace( | |
| buildTrace( | |
| 'tool', | |
| locale === 'zh-TW' ? '店家結果' : 'Store results', | |
| locale === 'zh-TW' ? `回傳 ${rankedStores.length} 筆店家` : `${rankedStores.length} stores returned`, | |
| 'ok', | |
| { count: rankedStores.length } | |
| ) | |
| ); | |
| } | |
| const statsStep = steps.find((step) => step.id === 'market-step-4'); | |
| if (statsStep) { | |
| toolCalls.push('marketplace_get_stats'); | |
| const statsReady = await stepRunner(runToken, statsStep); | |
| if (!statsReady) return; | |
| setStats(computeMarketplaceStats(PRODUCT_DATA, STORE_DATA)); | |
| } | |
| const renderStep = steps.find((step) => step.id === 'market-step-5'); | |
| if (renderStep) { | |
| const rendered = await stepRunner(runToken, renderStep, { | |
| products: mode === 'stores' || mode === 'stats' ? undefined : 'ranked', | |
| stores: mode === 'products' || mode === 'stats' ? undefined : 'ranked', | |
| stats: mode === 'products' || mode === 'stores' ? undefined : 'included', | |
| }); | |
| if (!rendered) return; | |
| } | |
| const productCount = mode === 'stores' || mode === 'stats' ? 0 : PRODUCT_DATA.filter((record) => scoreProduct(record, tokens, category, minPrice, maxPrice) > 0).slice(0, limit).length; | |
| const storeCount = mode === 'products' || mode === 'stats' ? 0 : STORE_DATA.filter((record) => scoreStore(record, tokens, storeType, location) > 0).slice(0, limit).length; | |
| const summaryText = | |
| locale === 'zh-TW' | |
| ? `已完成市集搜尋:商品 ${productCount} 筆,店家 ${storeCount} 筆。` | |
| : `Marketplace search completed: ${productCount} products and ${storeCount} stores ranked.`; | |
| setSummary(summaryText); | |
| setMeta({ | |
| query: activeQuery || '*', | |
| mode, | |
| startedAt: new Date(startMs).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }), | |
| durationMs: Date.now() - startMs, | |
| toolCalls, | |
| }); | |
| } catch (runtimeError) { | |
| const message = runtimeError instanceof Error ? runtimeError.message : copy.stopped; | |
| setError(message); | |
| if (currentStepRef.current) { | |
| onStepStatus(currentStepRef.current, 'error', message); | |
| } | |
| onTrace(buildTrace('tool', locale === 'zh-TW' ? '搜尋錯誤' : 'Search error', message, 'error')); | |
| } finally { | |
| if (runTokenRef.current === runToken) { | |
| setIsRunning(false); | |
| onWorkingChange(false); | |
| } | |
| } | |
| }; | |
| useEffect(() => { | |
| if (!queuedPrompt) return; | |
| const consume = async () => { | |
| setQuery(queuedPrompt); | |
| onConsumeQueuedPrompt(); | |
| await runSearch(queuedPrompt); | |
| }; | |
| void consume(); | |
| }, [queuedPrompt]); | |
| useEffect(() => { | |
| mountedRef.current = true; | |
| return () => { | |
| mountedRef.current = false; | |
| runTokenRef.current += 1; | |
| onWorkingChange(false); | |
| }; | |
| }, []); | |
| const hasResults = products.length > 0 || stores.length > 0 || Boolean(stats); | |
| return ( | |
| <div className="marketplace-demo-root"> | |
| <MarketplaceFeatureDemo locale={locale} isBusy={isRunning} /> | |
| <header className="marketplace-demo-header"> | |
| <div> | |
| <h3>{copy.title}</h3> | |
| <p>{copy.subtitle}</p> | |
| </div> | |
| <div className={`status-pill ${isRunning ? 'busy' : 'idle'}`}>{isRunning ? copy.statusRunning : copy.statusReady}</div> | |
| </header> | |
| <div className="marketplace-demo-layout"> | |
| <section className="marketplace-input-panel"> | |
| <label className="marketplace-field-block"> | |
| <span>{copy.modeLabel}</span> | |
| <select value={mode} onChange={(event) => setMode(event.target.value as MarketplaceMode)} disabled={isRunning}> | |
| <option value="hybrid">{copy.modeHybrid}</option> | |
| <option value="products">{copy.modeProducts}</option> | |
| <option value="stores">{copy.modeStores}</option> | |
| <option value="stats">{copy.modeStats}</option> | |
| </select> | |
| </label> | |
| <label className="marketplace-field-block"> | |
| <span>{copy.queryLabel}</span> | |
| <textarea | |
| rows={4} | |
| value={query} | |
| onChange={(event) => setQuery(event.target.value)} | |
| placeholder={copy.queryPlaceholder} | |
| disabled={isRunning} | |
| /> | |
| </label> | |
| <div className="marketplace-filter-block"> | |
| <h4>{copy.filtersLabel}</h4> | |
| {(mode === 'hybrid' || mode === 'products') ? ( | |
| <div className="marketplace-filter-grid"> | |
| <label> | |
| <span>{copy.categoryLabel}</span> | |
| <select value={category} onChange={(event) => setCategory(event.target.value)} disabled={isRunning}> | |
| {categoryOptions.map((item) => ( | |
| <option key={item} value={item}> | |
| {categoryLabel(locale, item)} | |
| </option> | |
| ))} | |
| </select> | |
| </label> | |
| <label> | |
| <span>{copy.minPriceLabel}</span> | |
| <input | |
| type="number" | |
| value={minPrice} | |
| onChange={(event) => setMinPrice(Number(event.target.value) || 0)} | |
| disabled={isRunning} | |
| /> | |
| </label> | |
| <label> | |
| <span>{copy.maxPriceLabel}</span> | |
| <input | |
| type="number" | |
| value={maxPrice} | |
| onChange={(event) => setMaxPrice(Number(event.target.value) || 0)} | |
| disabled={isRunning} | |
| /> | |
| </label> | |
| </div> | |
| ) : null} | |
| {(mode === 'hybrid' || mode === 'stores') ? ( | |
| <div className="marketplace-filter-grid"> | |
| <label> | |
| <span>{copy.storeTypeLabel}</span> | |
| <select value={storeType} onChange={(event) => setStoreType(event.target.value)} disabled={isRunning}> | |
| <option value="all">{storeTypeLabel(locale, 'all')}</option> | |
| <option value="retail">{storeTypeLabel(locale, 'retail')}</option> | |
| <option value="restaurant">{storeTypeLabel(locale, 'restaurant')}</option> | |
| <option value="wholesale">{storeTypeLabel(locale, 'wholesale')}</option> | |
| <option value="co-op">{storeTypeLabel(locale, 'co-op')}</option> | |
| </select> | |
| </label> | |
| <label> | |
| <span>{copy.locationLabel}</span> | |
| <input | |
| type="text" | |
| value={location} | |
| onChange={(event) => setLocation(event.target.value)} | |
| placeholder={locale === 'zh-TW' ? '例如:台中' : 'e.g. Taichung'} | |
| disabled={isRunning} | |
| /> | |
| </label> | |
| </div> | |
| ) : null} | |
| <label className="marketplace-field-block"> | |
| <span>{copy.limitLabel}</span> | |
| <select value={limit} onChange={(event) => setLimit(Number(event.target.value))} disabled={isRunning}> | |
| <option value={4}>4</option> | |
| <option value={6}>6</option> | |
| <option value={8}>8</option> | |
| <option value={10}>10</option> | |
| </select> | |
| </label> | |
| </div> | |
| <div className="marketplace-action-row"> | |
| <button type="button" className="primary-btn" onClick={() => void runSearch()} disabled={isRunning}> | |
| {copy.run} | |
| </button> | |
| {isRunning ? ( | |
| <button type="button" className="ghost-btn" onClick={stopSearch}> | |
| {copy.stop} | |
| </button> | |
| ) : null} | |
| <button type="button" className="ghost-btn" onClick={resetOutput} disabled={isRunning}> | |
| {copy.reset} | |
| </button> | |
| </div> | |
| {recentQueries.length > 0 ? ( | |
| <section className="marketplace-recent-block"> | |
| <h4>{copy.recentLabel}</h4> | |
| <div className="marketplace-chip-row"> | |
| {recentQueries.map((item) => ( | |
| <button key={item} type="button" className="prompt-chip" onClick={() => setQuery(item)} disabled={isRunning}> | |
| {item} | |
| </button> | |
| ))} | |
| </div> | |
| </section> | |
| ) : null} | |
| </section> | |
| <section className="marketplace-output-panel"> | |
| <header> | |
| <h4>{copy.outputTitle}</h4> | |
| <p>{copy.outputSubtitle}</p> | |
| </header> | |
| {summary ? <div className="marketplace-note">{summary}</div> : null} | |
| {error ? <div className="marketplace-error">{error}</div> : null} | |
| {!hasResults && !isRunning ? <div className="marketplace-empty">{copy.emptyOutput}</div> : null} | |
| {stats ? ( | |
| <article className="marketplace-stats-card"> | |
| <h5>{copy.statsTitle}</h5> | |
| <div className="marketplace-stat-grid"> | |
| <div> | |
| <span>{copy.stat.sellers}</span> | |
| <strong>{stats.sellers}</strong> | |
| </div> | |
| <div> | |
| <span>{copy.stat.buyers}</span> | |
| <strong>{stats.buyers}</strong> | |
| </div> | |
| <div> | |
| <span>{copy.stat.products}</span> | |
| <strong>{stats.products}</strong> | |
| </div> | |
| <div> | |
| <span>{copy.stat.stores}</span> | |
| <strong>{stats.stores}</strong> | |
| </div> | |
| <div> | |
| <span>{copy.stat.demand}</span> | |
| <strong>{stats.weeklyDemandIndex}</strong> | |
| </div> | |
| <div> | |
| <span>{copy.stat.response}</span> | |
| <strong>{Math.round(stats.avgResponseRate * 100)}%</strong> | |
| </div> | |
| </div> | |
| </article> | |
| ) : null} | |
| {(mode === 'hybrid' || mode === 'products') ? ( | |
| <article className="marketplace-result-card"> | |
| <h5>{copy.productsTitle}</h5> | |
| {products.length > 0 ? ( | |
| <div className="marketplace-card-grid"> | |
| {products.map((item) => ( | |
| <section key={item.id} className="marketplace-entity-card"> | |
| <header> | |
| <strong>{item.name}</strong> | |
| <span>{categoryLabel(locale, item.category)}</span> | |
| </header> | |
| <div className="marketplace-metric-row"> | |
| <span>{copy.product.price}</span> | |
| <strong>NT$ {item.price}/{item.unit}</strong> | |
| </div> | |
| <div className="marketplace-metric-row"> | |
| <span>{copy.product.stock}</span> | |
| <strong>{item.stock}</strong> | |
| </div> | |
| <div className="marketplace-metric-row"> | |
| <span>{copy.product.location}</span> | |
| <strong>{item.location}</strong> | |
| </div> | |
| <div className="marketplace-metric-row"> | |
| <span>{copy.product.seller}</span> | |
| <strong>{item.seller}</strong> | |
| </div> | |
| <div className="marketplace-score-track"> | |
| <small>{copy.product.trend}</small> | |
| <div> | |
| <span style={{ width: `${Math.min(100, item.score)}%` }} /> | |
| </div> | |
| </div> | |
| <div className="action-row"> | |
| <button type="button" className="mini-btn"> | |
| {copy.product.open} | |
| </button> | |
| <button type="button" className="mini-btn"> | |
| {copy.product.shortlist} | |
| </button> | |
| </div> | |
| </section> | |
| ))} | |
| </div> | |
| ) : ( | |
| <p className="marketplace-help-text">{copy.noMatches}</p> | |
| )} | |
| </article> | |
| ) : null} | |
| {(mode === 'hybrid' || mode === 'stores') ? ( | |
| <article className="marketplace-result-card"> | |
| <h5>{copy.storesTitle}</h5> | |
| {stores.length > 0 ? ( | |
| <div className="marketplace-card-grid"> | |
| {stores.map((item) => ( | |
| <section key={item.id} className="marketplace-entity-card"> | |
| <header> | |
| <strong>{item.name}</strong> | |
| <span>{storeTypeLabel(locale, item.type)}</span> | |
| </header> | |
| <div className="marketplace-metric-row"> | |
| <span>{copy.store.location}</span> | |
| <strong>{item.location}</strong> | |
| </div> | |
| <div className="marketplace-metric-row"> | |
| <span>{copy.store.response}</span> | |
| <strong>{Math.round(item.responseRate * 100)}%</strong> | |
| </div> | |
| <div className="marketplace-metric-row"> | |
| <span>{copy.store.listings}</span> | |
| <strong>{item.activeListings}</strong> | |
| </div> | |
| <div className="marketplace-metric-row"> | |
| <span>{copy.store.focus}</span> | |
| <strong>{item.buyingFocus.slice(0, 2).map((focus) => focusLabel(locale, focus)).join(', ')}</strong> | |
| </div> | |
| <div className="marketplace-score-track"> | |
| <small>{copy.store.score}</small> | |
| <div> | |
| <span style={{ width: `${Math.min(100, item.score)}%` }} /> | |
| </div> | |
| </div> | |
| <div className="action-row"> | |
| <button type="button" className="mini-btn"> | |
| {copy.store.open} | |
| </button> | |
| <button type="button" className="mini-btn"> | |
| {copy.store.contact} | |
| </button> | |
| </div> | |
| </section> | |
| ))} | |
| </div> | |
| ) : ( | |
| <p className="marketplace-help-text">{copy.noMatches}</p> | |
| )} | |
| </article> | |
| ) : null} | |
| {meta ? ( | |
| <article className="marketplace-meta-card"> | |
| <h5>{copy.metaTitle}</h5> | |
| <dl> | |
| <div> | |
| <dt>{copy.labels.query}</dt> | |
| <dd>{meta.query}</dd> | |
| </div> | |
| <div> | |
| <dt>{copy.labels.mode}</dt> | |
| <dd>{modeLabels[meta.mode]}</dd> | |
| </div> | |
| <div> | |
| <dt>{copy.labels.startedAt}</dt> | |
| <dd>{meta.startedAt}</dd> | |
| </div> | |
| <div> | |
| <dt>{copy.labels.duration}</dt> | |
| <dd>{meta.durationMs} ms</dd> | |
| </div> | |
| <div> | |
| <dt>{copy.labels.toolCalls}</dt> | |
| <dd>{meta.toolCalls.join(', ') || '-'}</dd> | |
| </div> | |
| </dl> | |
| <details> | |
| <summary>{copy.rawPayload}</summary> | |
| <pre>{JSON.stringify({ products, stores, stats, meta }, null, 2)}</pre> | |
| </details> | |
| </article> | |
| ) : null} | |
| </section> | |
| </div> | |
| </div> | |
| ); | |
| } | |