Farm2Market / src /InvoiceFeatureDemo.tsx
pixel3user
Update Farm2Market demo frontend
806144f
import { useEffect, useMemo, useState } from 'react';
import type { DemoLocale } from './types';
type InvoiceScene = {
id: 'source' | 'ocr' | 'fields';
durationMs: number;
};
type InvoiceFeatureDemoProps = {
locale: DemoLocale;
isBusy: boolean;
};
const COPY = {
en: {
title: 'Invoice Demo',
busy: 'Running',
hidden: 'Hidden',
play: 'Play',
pause: 'Pause',
replay: 'Replay',
hide: 'Hide',
show: 'Show',
videoName: 'invoice_demo.webm',
step: 'Step',
lanes: {
source: 'Source',
ocr: 'OCR',
fields: 'Fields',
},
captions: {
source: 'Upload and validate invoice source',
ocr: 'Extract raw text and parse blocks',
fields: 'Return structured invoice fields',
},
words: {
source: ['File', 'Role', 'Note'],
ocr: ['Read', 'Parse', 'Check'],
fields: ['Total', 'Due', 'Status'],
streamWords: ['Reading', 'Parsing', 'Checking', 'Packing'],
sourceImage: 'https://upload.wikimedia.org/wikipedia/commons/9/90/Standard_Fapiao.jpg',
},
},
'zh-TW': {
title: '發票導覽',
busy: '執行中',
hidden: '已隱藏',
play: '播放',
pause: '暫停',
replay: '重播',
hide: '隱藏',
show: '顯示',
videoName: '發票導覽影片.webm',
step: '步驟',
lanes: {
source: '來源',
ocr: '辨識',
fields: '欄位',
},
captions: {
source: '上傳並驗證發票來源',
ocr: '擷取原文並解析區塊',
fields: '輸出結構化發票欄位',
},
words: {
source: ['檔案', '角色', '備註'],
ocr: ['讀取', '解析', '驗證'],
fields: ['總額', '到期', '狀態'],
streamWords: ['讀取中', '解析中', '驗證中', '封裝中'],
sourceImage: 'https://upload.wikimedia.org/wikipedia/commons/9/90/Standard_Fapiao.jpg',
},
},
} as const;
const SCENES: InvoiceScene[] = [
{ id: 'source', durationMs: 1650 },
{ id: 'ocr', durationMs: 1750 },
{ id: 'fields', durationMs: 1700 },
];
export default function InvoiceFeatureDemo({ locale, isBusy }: InvoiceFeatureDemoProps) {
const copy = COPY[locale];
const scenes = useMemo(() => SCENES, []);
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 sceneCaption = copy.captions[activeScene.id];
const streamOffset = Math.floor(elapsedMs / 420);
const activeStreamWords = copy.words.streamWords.map(
(_word, index) => copy.words.streamWords[(streamOffset + index) % copy.words.streamWords.length]
);
const replay = () => {
setSceneIndex(0);
setElapsedMs(0);
if (!reducedMotion && !isBusy) setIsPlaying(true);
};
if (isCollapsed) {
return (
<section className="invoice-walk-collapsed" aria-live="polite">
<div className="invoice-walk-mini-markers" aria-hidden="true">
<span />
<span />
<span />
</div>
<p>{isBusy ? copy.busy : copy.hidden}</p>
{!isBusy ? (
<button
type="button"
className="ghost-btn"
onClick={() => {
setIsCollapsed(false);
replay();
}}
>
{copy.show}
</button>
) : null}
</section>
);
}
return (
<section className="invoice-walk-video" aria-label={copy.title}>
<header className="invoice-walk-head">
<div>
<h3>{copy.title}</h3>
<small>
{copy.step} {sceneIndex + 1}/{scenes.length}
</small>
</div>
<div className="invoice-walk-actions">
<button
type="button"
className="invoice-walk-icon-btn"
onClick={() => setIsPlaying((prev) => !prev)}
disabled={isBusy || reducedMotion}
aria-label={isPlaying ? copy.pause : copy.play}
>
{isPlaying ? '||' : '>'}
</button>
<button type="button" className="invoice-walk-icon-btn" onClick={replay} disabled={isBusy} aria-label={copy.replay}>
R
</button>
<button type="button" className="invoice-walk-icon-btn" onClick={() => setIsCollapsed(true)} aria-label={copy.hide}>
X
</button>
</div>
</header>
<div className="invoice-walk-frame" data-scene={activeScene.id}>
<div className="invoice-walk-windowbar">
<span />
<span />
<span />
<small>{copy.videoName}</small>
</div>
<div className="invoice-walk-canvas">
<section className="invoice-walk-lane source">
<span className="invoice-walk-lane-index">1</span>
<small className="invoice-walk-lane-label">{copy.lanes.source}</small>
<div className="invoice-walk-chip-row">
{copy.words.source.map((item) => (
<span key={item}>{item}</span>
))}
</div>
<div className="invoice-walk-dropzone">
<img
src={copy.words.sourceImage}
alt={locale === 'zh-TW' ? '發票樣本' : 'invoice sample'}
loading="lazy"
referrerPolicy="no-referrer"
/>
<p>{locale === 'zh-TW' ? 'invoice_sample.jpg' : 'invoice_sample.jpg'}</p>
</div>
</section>
<section className="invoice-walk-lane ocr">
<span className="invoice-walk-lane-index">2</span>
<small className="invoice-walk-lane-label">{copy.lanes.ocr}</small>
<div className="invoice-walk-ocr-nodes">
{copy.words.ocr.map((item) => (
<span key={item}>{item}</span>
))}
</div>
<div className="invoice-walk-ocr-flow">
<span />
</div>
<div className="invoice-walk-ocr-lines">
{activeStreamWords.slice(0, 3).map((word, index) => (
<div key={`${word}-${index}`} className={`invoice-walk-stream-pill ${index === 0 ? 'lg' : index === 1 ? 'md' : 'sm'}`}>
<span>{word}</span>
</div>
))}
</div>
</section>
<section className="invoice-walk-lane fields">
<span className="invoice-walk-lane-index">3</span>
<small className="invoice-walk-lane-label">{copy.lanes.fields}</small>
<div className="invoice-walk-field-grid">
{copy.words.fields.map((item) => (
<article key={item}>
<small>{item}</small>
<strong>{item === copy.words.fields[0] ? 'NT$ 6,665' : item === copy.words.fields[1] ? '2026-03-31' : locale === 'zh-TW' ? '草稿' : 'Draft'}</strong>
</article>
))}
</div>
<p>{locale === 'zh-TW' ? '已產生 JSON 與可讀預覽。' : 'JSON payload and readable preview generated.'}</p>
</section>
</div>
</div>
<footer className="invoice-walk-meta">
<div className="invoice-walk-progress-track" aria-hidden="true">
<span style={{ width: `${Math.min(100, Math.max(0, progress))}%` }} />
</div>
<p className="invoice-walk-caption">{sceneCaption}</p>
<div className="invoice-walk-scene-dots">
{scenes.map((scene, index) => (
<button
key={scene.id}
type="button"
className={`invoice-walk-scene-dot ${index === sceneIndex ? 'active' : index < sceneIndex ? 'complete' : ''}`}
onClick={() => {
setSceneIndex(index);
setElapsedMs(0);
}}
aria-label={`${copy.step} ${index + 1}`}
>
<span />
</button>
))}
</div>
</footer>
</section>
);
}