Spaces:
Sleeping
Sleeping
| 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> | |
| ); | |
| } | |