Farm2Market / src /MarketplaceDemoPanel.tsx
pixel3user
Update Farm2Market demo frontend
e46c4bd
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>
);
}