Farm2Market / src /ChatFeatureDemo.tsx
pixel3user
Update Farm2Market demo frontend
806144f
import { useEffect, useMemo, useState } from 'react';
import type { DemoLocale } from './types';
type ChatDemoScene = {
id: 'prompt' | 'workflow' | 'output';
title: string;
caption: string;
prompt: string;
tool: string;
result: string;
durationMs: number;
};
type ChatFeatureDemoProps = {
locale: DemoLocale;
isBusy: boolean;
};
const COPY = {
en: {
title: 'Feature Walkthrough',
subtitle: 'Video-style preview of how the Chat Agent works',
stateBusy: 'Live run active',
statePlaying: 'Playing demo',
statePaused: 'Demo paused',
play: 'Play',
pause: 'Pause',
replay: 'Replay',
hide: 'Hide',
show: 'Show Demo',
hiddenBusy: 'Walkthrough hidden while the agent is running.',
hiddenIdle: 'Walkthrough hidden.',
reducedMotion: 'Autoplay is disabled because reduced motion is enabled.',
videoName: 'chat_agent_walkthrough.mp4',
lanePrompt: 'Prompt',
laneWorkflow: 'Workflow',
laneOutput: 'Output',
quickPromptLabel: 'quick prompt',
workflowProgress: 'progress panel update',
workflowTrace: 'trace event recorded',
resultCardTitle: 'Result Card',
payloadCardTitle: 'Structured Payload',
payloadCardText: 'JSON preview + action chips',
},
'zh-TW': {
title: '功能導覽',
subtitle: '以影片風格快速示範 Chat Agent 使用方式',
stateBusy: '執行中',
statePlaying: '導覽播放中',
statePaused: '導覽已暫停',
play: '播放',
pause: '暫停',
replay: '重播',
hide: '隱藏',
show: '顯示導覽',
hiddenBusy: '模型執行中,已暫時隱藏導覽。',
hiddenIdle: '導覽已隱藏。',
reducedMotion: '你啟用了減少動畫,已停用自動播放。',
videoName: '聊天導覽影片.mp4',
lanePrompt: '輸入',
laneWorkflow: '流程',
laneOutput: '輸出',
quickPromptLabel: '快速提示',
workflowProgress: '進度面板更新',
workflowTrace: '追蹤事件記錄',
resultCardTitle: '結果卡片',
payloadCardTitle: '結構化載荷',
payloadCardText: 'JSON 預覽 + 動作標籤',
},
} as const;
const SCENES = {
en: [
{
id: 'prompt',
title: 'Step 1: Prompt and context',
caption: 'User chooses a quick prompt or types intent in natural language.',
prompt: 'Find eggs under NT$200 and rank by freshness.',
tool: 'Prompt parser prepares query tokens and constraints',
result: 'Input normalized and queued',
durationMs: 1700,
},
{
id: 'workflow',
title: 'Step 2: Tool orchestration',
caption: 'Planner selects tools and the progress panel updates in real time.',
prompt: 'Routing: product_search_public + score ranking',
tool: 'Planner validates filters and runs tool chain',
result: 'Structured candidate set is ready',
durationMs: 1750,
},
{
id: 'output',
title: 'Step 3: Structured answer',
caption: 'Assistant responds with cards, metrics, actions, and JSON payload.',
prompt: 'Rendering response bundle for UI cards',
tool: 'Renderer composes summary and recommendation cards',
result: 'Chat output card with payload attached',
durationMs: 1750,
},
],
'zh-TW': [
{
id: 'prompt',
title: '步驟 1:輸入需求',
caption: '使用者可點擊快速提示,或直接輸入自然語句。',
prompt: '找出 200 元以下雞蛋,並依新鮮度排序。',
tool: '系統先解析條件與語意',
result: '已建立查詢上下文',
durationMs: 1700,
},
{
id: 'workflow',
title: '步驟 2:工具編排',
caption: '規劃器決定工具路徑,進度面板同步顯示執行狀態。',
prompt: '路由:product_search_public + 排名計算',
tool: '規劃器驗證條件並串接工具',
result: '已取得結構化候選結果',
durationMs: 1750,
},
{
id: 'output',
title: '步驟 3:輸出答案',
caption: '最後輸出摘要、卡片與 JSON 結構化資料。',
prompt: '生成可視化回覆內容',
tool: 'Renderer 整理摘要與推薦卡片',
result: '可直接展示的結果已完成',
durationMs: 1750,
},
],
} as const satisfies Record<DemoLocale, ChatDemoScene[]>;
function ChatFeatureDemo({ locale, isBusy }: ChatFeatureDemoProps) {
const copy = COPY[locale];
const scenes = useMemo(() => SCENES[locale], [locale]);
const [sceneIndex, setSceneIndex] = useState(0);
const [elapsedMs, setElapsedMs] = useState(0);
const [isPlaying, setIsPlaying] = useState(true);
const [isCollapsed, setIsCollapsed] = useState(false);
const [reducedMotion, setReducedMotion] = useState(false);
useEffect(() => {
const media = window.matchMedia('(prefers-reduced-motion: reduce)');
const apply = () => setReducedMotion(media.matches);
apply();
if (media.addEventListener) {
media.addEventListener('change', apply);
return () => media.removeEventListener('change', apply);
}
media.addListener(apply);
return () => media.removeListener(apply);
}, []);
useEffect(() => {
if (reducedMotion) {
setIsPlaying(false);
}
}, [reducedMotion]);
useEffect(() => {
setSceneIndex(0);
setElapsedMs(0);
setIsCollapsed(false);
if (!reducedMotion) {
setIsPlaying(true);
}
}, [locale, reducedMotion]);
useEffect(() => {
if (!isBusy) return;
setIsCollapsed(true);
setIsPlaying(false);
}, [isBusy]);
useEffect(() => {
if (!isPlaying || isCollapsed || isBusy || reducedMotion) return;
const timer = window.setInterval(() => {
setElapsedMs((prev) => {
const scene = scenes[sceneIndex];
if (!scene) return 0;
const next = prev + 120;
if (next < scene.durationMs) return next;
setSceneIndex((current) => (current + 1) % scenes.length);
return 0;
});
}, 120);
return () => window.clearInterval(timer);
}, [isPlaying, isCollapsed, isBusy, reducedMotion, scenes, sceneIndex]);
const activeScene = scenes[sceneIndex] ?? scenes[0];
const totalDuration = scenes.reduce((sum, scene) => sum + scene.durationMs, 0);
const previousDuration = scenes.slice(0, sceneIndex).reduce((sum, scene) => sum + scene.durationMs, 0);
const progress = totalDuration > 0 ? Math.round(((previousDuration + elapsedMs) / totalDuration) * 100) : 0;
const replay = () => {
setSceneIndex(0);
setElapsedMs(0);
if (!reducedMotion && !isBusy) {
setIsPlaying(true);
}
};
if (isCollapsed) {
return (
<section className="chat-demo-collapsed" aria-live="polite">
<p>{isBusy ? copy.hiddenBusy : copy.hiddenIdle}</p>
{!isBusy ? (
<button
type="button"
className="ghost-btn"
onClick={() => {
setIsCollapsed(false);
replay();
}}
>
{copy.show}
</button>
) : null}
</section>
);
}
return (
<section className="chat-demo-video" aria-label={copy.title}>
<header className="chat-demo-head">
<div>
<h3>{copy.title}</h3>
<p>{copy.subtitle}</p>
{reducedMotion ? <small>{copy.reducedMotion}</small> : null}
</div>
<div className="chat-demo-head-actions">
<span className={`chat-demo-state ${isBusy ? 'busy' : isPlaying ? 'playing' : 'paused'}`}>
{isBusy ? copy.stateBusy : isPlaying ? copy.statePlaying : copy.statePaused}
</span>
<button
type="button"
className="ghost-btn"
onClick={() => setIsPlaying((prev) => !prev)}
disabled={isBusy || reducedMotion}
>
{isPlaying ? copy.pause : copy.play}
</button>
<button type="button" className="ghost-btn" onClick={replay} disabled={isBusy}>
{copy.replay}
</button>
<button type="button" className="ghost-btn" onClick={() => setIsCollapsed(true)}>
{copy.hide}
</button>
</div>
</header>
<div className="chat-demo-frame" data-scene={activeScene.id}>
<div className="chat-demo-windowbar">
<span />
<span />
<span />
<small>{copy.videoName}</small>
</div>
<div className="chat-demo-canvas">
<section className="chat-demo-lane prompts">
<h4>{copy.lanePrompt}</h4>
<div className="chat-demo-prompt-chip">{copy.quickPromptLabel}</div>
<div className="chat-demo-prompt-bubble">{activeScene.prompt}</div>
</section>
<section className="chat-demo-lane workflow">
<h4>{copy.laneWorkflow}</h4>
<div className="chat-demo-step active">{activeScene.tool}</div>
<div className="chat-demo-step">{copy.workflowProgress}</div>
<div className="chat-demo-step">{copy.workflowTrace}</div>
</section>
<section className="chat-demo-lane output">
<h4>{copy.laneOutput}</h4>
<article className="chat-demo-output-card">
<strong>{copy.resultCardTitle}</strong>
<p>{activeScene.result}</p>
</article>
<article className="chat-demo-output-card">
<strong>{copy.payloadCardTitle}</strong>
<p>{copy.payloadCardText}</p>
</article>
</section>
<span className="chat-demo-cursor" aria-hidden="true" />
</div>
</div>
<footer className="chat-demo-meta">
<div className="chat-demo-caption">
<strong>{activeScene.title}</strong>
<p>{activeScene.caption}</p>
</div>
<div className="chat-demo-progress-track" aria-hidden="true">
<span style={{ width: `${Math.min(100, Math.max(0, progress))}%` }} />
</div>
<div className="chat-demo-scene-row">
{scenes.map((scene, index) => (
<button
key={scene.id}
type="button"
className={`chat-demo-scene-pill ${index === sceneIndex ? 'active' : index < sceneIndex ? 'complete' : ''}`}
onClick={() => {
setSceneIndex(index);
setElapsedMs(0);
}}
>
{scene.title}
</button>
))}
</div>
</footer>
</section>
);
}
export default ChatFeatureDemo;