Spaces:
Sleeping
Sleeping
| import { useEffect, useMemo, useState } from 'react'; | |
| import type { DemoLocale } from './types'; | |
| type MarketplaceScene = { | |
| id: 'query' | 'rank' | 'match'; | |
| durationMs: number; | |
| }; | |
| type MarketplaceFeatureDemoProps = { | |
| locale: DemoLocale; | |
| isBusy: boolean; | |
| }; | |
| const COPY = { | |
| en: { | |
| title: 'Marketplace Demo', | |
| busy: 'Running', | |
| hidden: 'Hidden', | |
| play: 'Play', | |
| pause: 'Pause', | |
| replay: 'Replay', | |
| hide: 'Hide', | |
| show: 'Show', | |
| videoName: 'marketplace_demo.webm', | |
| step: 'Step', | |
| lanes: { | |
| query: 'Query', | |
| rank: 'Rank', | |
| match: 'Match', | |
| }, | |
| captions: { | |
| query: 'Collect search and filter intent', | |
| rank: 'Score records by relevance', | |
| match: 'Render product + store cards', | |
| }, | |
| words: { | |
| query: ['Vegetables', 'Taipei', 'Under200'], | |
| rank: ['Parse', 'Score', 'Sort'], | |
| match: ['Product', 'Store', 'Stats'], | |
| streamWords: ['Searching', 'Filtering', 'Ranking', 'Matching'], | |
| queryImage: 'https://upload.wikimedia.org/wikipedia/commons/c/c2/Fresh_Vegetables_2.jpg', | |
| }, | |
| }, | |
| 'zh-TW': { | |
| title: '市集導覽', | |
| busy: '執行中', | |
| hidden: '已隱藏', | |
| play: '播放', | |
| pause: '暫停', | |
| replay: '重播', | |
| hide: '隱藏', | |
| show: '顯示', | |
| videoName: '市集導覽影片.webm', | |
| step: '步驟', | |
| lanes: { | |
| query: '查詢', | |
| rank: '排序', | |
| match: '結果', | |
| }, | |
| captions: { | |
| query: '收集搜尋與篩選條件', | |
| rank: '依相關性計分排序', | |
| match: '輸出商品與店家卡片', | |
| }, | |
| words: { | |
| query: ['蔬菜', '台北', '200內'], | |
| rank: ['解析', '計分', '排序'], | |
| match: ['商品', '店家', '統計'], | |
| streamWords: ['搜尋中', '篩選中', '排序中', '配對中'], | |
| queryImage: 'https://upload.wikimedia.org/wikipedia/commons/c/c2/Fresh_Vegetables_2.jpg', | |
| }, | |
| }, | |
| } as const; | |
| const SCENES: MarketplaceScene[] = [ | |
| { id: 'query', durationMs: 1650 }, | |
| { id: 'rank', durationMs: 1750 }, | |
| { id: 'match', durationMs: 1700 }, | |
| ]; | |
| export default function MarketplaceFeatureDemo({ locale, isBusy }: MarketplaceFeatureDemoProps) { | |
| 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="market-walk-collapsed" aria-live="polite"> | |
| <div className="market-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="market-walk-video" aria-label={copy.title}> | |
| <header className="market-walk-head"> | |
| <div> | |
| <h3>{copy.title}</h3> | |
| <small> | |
| {copy.step} {sceneIndex + 1}/{scenes.length} | |
| </small> | |
| </div> | |
| <div className="market-walk-actions"> | |
| <button | |
| type="button" | |
| className="market-walk-icon-btn" | |
| onClick={() => setIsPlaying((prev) => !prev)} | |
| disabled={isBusy || reducedMotion} | |
| aria-label={isPlaying ? copy.pause : copy.play} | |
| > | |
| {isPlaying ? '||' : '>'} | |
| </button> | |
| <button type="button" className="market-walk-icon-btn" onClick={replay} disabled={isBusy} aria-label={copy.replay}> | |
| R | |
| </button> | |
| <button type="button" className="market-walk-icon-btn" onClick={() => setIsCollapsed(true)} aria-label={copy.hide}> | |
| X | |
| </button> | |
| </div> | |
| </header> | |
| <div className="market-walk-frame" data-scene={activeScene.id}> | |
| <div className="market-walk-windowbar"> | |
| <span /> | |
| <span /> | |
| <span /> | |
| <small>{copy.videoName}</small> | |
| </div> | |
| <div className="market-walk-canvas"> | |
| <section className="market-walk-lane query"> | |
| <span className="market-walk-lane-index">1</span> | |
| <small className="market-walk-lane-label">{copy.lanes.query}</small> | |
| <div className="market-walk-chip-row"> | |
| {copy.words.query.map((item) => ( | |
| <span key={item}>{item}</span> | |
| ))} | |
| </div> | |
| <img | |
| src={copy.words.queryImage} | |
| alt={locale === 'zh-TW' ? '市集查詢示意圖' : 'market query sample'} | |
| loading="lazy" | |
| referrerPolicy="no-referrer" | |
| className="market-walk-query-thumb" | |
| /> | |
| <div className="market-walk-stream-pill lg"> | |
| <span>{activeStreamWords[0]}</span> | |
| </div> | |
| <div className="market-walk-stream-pill md"> | |
| <span>{activeStreamWords[1]}</span> | |
| </div> | |
| <p>{locale === 'zh-TW' ? '查詢與價格區間已套用。' : 'Query text and price range applied.'}</p> | |
| </section> | |
| <section className="market-walk-lane rank"> | |
| <span className="market-walk-lane-index">2</span> | |
| <small className="market-walk-lane-label">{copy.lanes.rank}</small> | |
| <div className="market-walk-rank-row"> | |
| {copy.words.rank.map((item) => ( | |
| <span key={item}>{item}</span> | |
| ))} | |
| </div> | |
| <div className="market-walk-rank-flow"> | |
| <span /> | |
| </div> | |
| <div className="market-walk-stream-pill sm"> | |
| <span>{activeStreamWords[2]}</span> | |
| </div> | |
| </section> | |
| <section className="market-walk-lane match"> | |
| <span className="market-walk-lane-index">3</span> | |
| <small className="market-walk-lane-label">{copy.lanes.match}</small> | |
| <div className="market-walk-result-grid"> | |
| {copy.words.match.map((item) => ( | |
| <article key={item}> | |
| <small>{item}</small> | |
| <strong>{item === copy.words.match[0] ? '6' : item === copy.words.match[1] ? '4' : '42'}</strong> | |
| </article> | |
| ))} | |
| </div> | |
| <p>{locale === 'zh-TW' ? '已輸出排序卡片與統計摘要。' : 'Ranked cards and stats summary rendered.'}</p> | |
| </section> | |
| </div> | |
| </div> | |
| <footer className="market-walk-meta"> | |
| <div className="market-walk-progress-track" aria-hidden="true"> | |
| <span style={{ width: `${Math.min(100, Math.max(0, progress))}%` }} /> | |
| </div> | |
| <p className="market-walk-caption">{sceneCaption}</p> | |
| <div className="market-walk-scene-dots"> | |
| {scenes.map((scene, index) => ( | |
| <button | |
| key={scene.id} | |
| type="button" | |
| className={`market-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> | |
| ); | |
| } | |