import { ChangeEvent, useEffect, useMemo, useRef, useState } from 'react'; import type { DemoLocale, DemoMode, ProgressStep, ProgressStepStatus, TraceItem } from './types'; import MarketingFeatureDemo from './MarketingFeatureDemo'; import { parseDataUrl, streamMarketingGeneration } from './liveApi'; type MarketingStepTemplate = { id: string; label: string; runningDetail: string; doneDetail: string; kind: TraceItem['kind']; delayMs: number; }; type MarketingProduct = { id: string; name: string; category: string; price: number; stock: number; unit: string; }; type MarketingDeck = { headline: string; caption: string; cta: string; hashtags: string[]; designNotes: string[]; }; type GeneratedImage = { id: string; dataUrl: string; mimeType: string; base64: string; label: string; }; type MarketingStudioPanelProps = { 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 COPY = { en: { title: 'Marketing Studio', subtitle: 'Simulated Gemini marketing enhancement workflow (frontend-only demo)', productLabel: 'Product', briefLabel: 'Creative Brief', promptPlaceholder: 'Describe the campaign angle, tone, and CTA. Example: Weekend promotion for family breakfast packs.', toneLabel: 'Tone', channelLabel: 'Channel', imageLabel: 'Reference Image', uploadButton: 'Upload Image', removeImage: 'Remove', noImage: 'No image uploaded. A synthetic product visual will be generated.', generating: 'Generating assets…', ready: 'Ready', generate: 'Generate Campaign', stop: 'Stop', reset: 'Reset Output', copyDeck: 'Copy Deck', copied: 'Campaign copy copied to clipboard.', outputTitle: 'Generated Campaign', outputSubtitle: 'Streaming copy + image assets', metadataTitle: 'Run Metadata', metadataModel: 'Model', metadataStartedAt: 'Started at', metadataDuration: 'Duration', metadataEvents: 'Events', metadataEventsWithImages: '{count} image + text stream', metadataEventsTextOnly: 'text stream', noOutput: 'Run generation to see marketing copy and visuals.', streamError: 'Generation halted before completion.', stoppedByUser: 'Generation stopped by user.', applyMock: 'Mock: attached generated image to product showcase.', tutorialPrompt: 'Create a polished campaign for this product with a direct CTA.', }, 'zh-TW': { title: '行銷工作室', subtitle: '前端模擬 Gemini 行銷增強流程(不連後端)', productLabel: '產品', briefLabel: '創意需求', promptPlaceholder: '描述活動主軸、語氣與 CTA。例如:週末家庭早餐檔期,強調產地直送。', toneLabel: '語氣', channelLabel: '投放渠道', imageLabel: '參考圖片', uploadButton: '上傳圖片', removeImage: '移除', noImage: '尚未上傳圖片,系統會產生模擬商品視覺。', generating: '生成中…', ready: '就緒', generate: '生成活動素材', stop: '停止', reset: '重置輸出', copyDeck: '複製文案', copied: '活動文案已複製到剪貼簿。', outputTitle: '生成結果', outputSubtitle: '即時文案串流 + 圖像素材', metadataTitle: '執行資訊', metadataModel: '模型', metadataStartedAt: '開始時間', metadataDuration: '耗時', metadataEvents: '事件', metadataEventsWithImages: '{count} 張圖片 + 文字串流', metadataEventsTextOnly: '文字串流', noOutput: '執行生成後可在此查看文案與圖像。', streamError: '生成流程尚未完整結束。', stoppedByUser: '已由使用者停止生成。', applyMock: '模擬:已將生成圖片套用到產品展示。', tutorialPrompt: '請為這個產品建立一套完整活動文案並附上明確 CTA。', }, } as const; const CHANNEL_OPTIONS = { en: ['LINE Message', 'Instagram Post', 'Store Flyer', 'Marketplace Banner'], 'zh-TW': ['LINE 訊息', '社群貼文', '店內海報', '市集橫幅'], } as const; const TONE_OPTIONS = { en: ['Friendly', 'Premium', 'Urgent', 'Seasonal'], 'zh-TW': ['親切', '精品感', '限時感', '季節感'], } as const; const PRODUCTS = { en: [ { id: 'p1', name: 'Free-range Eggs', category: 'Eggs', price: 118, stock: 48, unit: 'box' }, { id: 'p2', name: 'Organic Greens Bundle', category: 'Vegetables', price: 220, stock: 18, unit: 'set' }, { id: 'p3', name: 'Golden Sweet Corn', category: 'Produce', price: 95, stock: 64, unit: 'pack' }, ], 'zh-TW': [ { id: 'p1', name: '放牧雞蛋', category: '蛋品', price: 118, stock: 48, unit: '盒' }, { id: 'p2', name: '有機蔬菜組', category: '蔬菜', price: 220, stock: 18, unit: '組' }, { id: 'p3', name: '黃金甜玉米', category: '農產', price: 95, stock: 64, unit: '包' }, ], } as const; const now = () => new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', }); const sleep = (ms: number) => new Promise((resolve) => { setTimeout(resolve, ms); }); function makeId(prefix: string): string { return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`; } function splitTextChunks(text: string): string[] { const words = text.split(/\s+/).filter(Boolean); const chunks: string[] = []; let current = ''; for (const word of words) { const next = current ? `${current} ${word}` : word; if (next.length > 28) { if (current) chunks.push(`${current} `); current = word; } else { current = next; } } if (current) chunks.push(`${current} `); return chunks; } function makeTrace( kind: TraceItem['kind'], title: string, detail: string, status: TraceItem['status'], payload?: Record ): TraceItem { return { id: makeId('trace'), kind, title, detail, status, payload, timestamp: now(), }; } function buildSteps(locale: DemoLocale): MarketingStepTemplate[] { if (locale === 'zh-TW') { return [ { id: 'mkt-step-1', label: '組裝行銷需求', runningDetail: '整理產品資訊、語氣與渠道參數。', doneDetail: '行銷需求已完成組裝。', kind: 'planner', delayMs: 500, }, { id: 'mkt-step-2', label: '圖片前處理', runningDetail: '分析參考圖片與視覺方向。', doneDetail: '視覺參考已就緒。', kind: 'validator', delayMs: 640, }, { id: 'mkt-step-3', label: '串流生成文案', runningDetail: '逐段輸出標題、內文、CTA 與 Hashtag。', doneDetail: '文案串流輸出完成。', kind: 'tool', delayMs: 0, }, { id: 'mkt-step-4', label: '生成圖片素材', runningDetail: '建立活動主視覺與備用版型。', doneDetail: '圖片素材已完成輸出。', kind: 'tool', delayMs: 0, }, { id: 'mkt-step-5', label: '打包最終輸出', runningDetail: '整理可直接使用的活動結果。', doneDetail: '活動素材已可發布。', kind: 'renderer', delayMs: 420, }, ]; } return [ { id: 'mkt-step-1', label: 'Assemble campaign brief', runningDetail: 'Collecting product context, tone, and channel.', doneDetail: 'Campaign brief assembled.', kind: 'planner', delayMs: 500, }, { id: 'mkt-step-2', label: 'Pre-process visual input', runningDetail: 'Analyzing reference image and visual direction.', doneDetail: 'Visual context prepared.', kind: 'validator', delayMs: 640, }, { id: 'mkt-step-3', label: 'Stream marketing copy', runningDetail: 'Streaming headline, caption, CTA, and hashtags.', doneDetail: 'Copy stream completed.', kind: 'tool', delayMs: 0, }, { id: 'mkt-step-4', label: 'Generate visuals', runningDetail: 'Creating hero and alternate campaign images.', doneDetail: 'Image assets generated.', kind: 'tool', delayMs: 0, }, { id: 'mkt-step-5', label: 'Package final output', runningDetail: 'Preparing publish-ready campaign deck.', doneDetail: 'Campaign output is ready.', kind: 'renderer', delayMs: 420, }, ]; } function createDeck(locale: DemoLocale, product: MarketingProduct, prompt: string, tone: string, channel: string): MarketingDeck { if (locale === 'zh-TW') { const cue = prompt.trim() ? `,聚焦「${prompt.trim().slice(0, 28)}」` : ''; return { headline: `${product.name} 新鮮上架,今天就下單${cue}`, caption: `來自在地農場的 ${product.name},口感穩定、品質透明。${tone}語氣搭配 ${channel} 推廣,強調價格 NT$${product.price}/${product.unit} 與現貨 ${product.stock} ${product.unit}。`, cta: '立即私訊預購,本週優先出貨。', hashtags: ['#產地直送', '#Farm2Market', '#今日新鮮', '#限時供應'], designNotes: ['白底乾淨陳列,強調產品本體', '加入暖色光感提升食慾', '版面留出 CTA 區塊可放價格與庫存'], }; } const cue = prompt.trim() ? `, focused on "${prompt.trim().slice(0, 32)}"` : ''; return { headline: `${product.name} is now in stock${cue}`, caption: `Freshly prepared ${product.category.toLowerCase()} with consistent quality and direct farm sourcing. Use a ${tone.toLowerCase()} tone for ${channel} and highlight NT$${product.price}/${product.unit} with ${product.stock} units available.`, cta: 'Reserve your batch now for this week\'s priority delivery.', hashtags: ['#Farm2Market', '#FarmFresh', '#DirectFromFarm', '#WeeklyDrop'], designNotes: ['Use clean white background and product-focused framing', 'Add gentle warm highlights for freshness', 'Reserve a clear CTA block for price and stock'], }; } function deckToMarkdown(locale: DemoLocale, deck: MarketingDeck): string { if (locale === 'zh-TW') { return [ `# ${deck.headline}`, '', `**文案**: ${deck.caption}`, '', `**CTA**: ${deck.cta}`, '', `**Hashtag**: ${deck.hashtags.join(' ')}`, '', '**設計重點**:', ...deck.designNotes.map((note) => `- ${note}`), ].join('\n'); } return [ `# ${deck.headline}`, '', `**Caption**: ${deck.caption}`, '', `**CTA**: ${deck.cta}`, '', `**Hashtags**: ${deck.hashtags.join(' ')}`, '', '**Design Notes**:', ...deck.designNotes.map((note) => `- ${note}`), ].join('\n'); } function parseDeckFromLiveText(locale: DemoLocale, text: string): MarketingDeck { const fallback = locale === 'zh-TW' ? { headline: '已生成行銷內容', caption: text.trim().slice(0, 220) || '已從後端串流取得文案結果。', cta: '立即查看並套用', hashtags: ['#Farm2Market'], designNotes: ['已使用後端即時生成結果。'], } : { headline: 'Marketing content generated', caption: text.trim().slice(0, 220) || 'Live backend copy has been streamed.', cta: 'Review and apply now', hashtags: ['#Farm2Market'], designNotes: ['Generated from live backend stream.'], }; const normalized = text .split('\n') .map((line) => line.trim()) .filter(Boolean); if (!normalized.length) return fallback; const hashTags = Array.from( new Set( (text.match(/#[\p{L}\p{N}_-]+/gu) || []) .map((tag) => tag.trim()) .filter(Boolean) ) ); const headline = normalized[0]?.replace(/^#+\s*/, '') || fallback.headline; const caption = normalized.slice(1, 4).join(' ') || fallback.caption; const ctaLine = normalized.find((line) => locale === 'zh-TW' ? /(立即|現在|下單|預購|CTA|行動)/.test(line) : /(cta|order|buy now|reserve|shop)/i.test(line) ); const noteLines = normalized .filter((line) => line.startsWith('-') || line.startsWith('•')) .map((line) => line.replace(/^[-•]\s*/, '')) .slice(0, 3); return { headline, caption, cta: ctaLine || fallback.cta, hashtags: hashTags.length ? hashTags.slice(0, 6) : fallback.hashtags, designNotes: noteLines.length ? noteLines : fallback.designNotes, }; } function makePalette(seed: number): [string, string, string] { const palettes: Array<[string, string, string]> = [ ['#1f8f4e', '#7dcf9b', '#edf8e6'], ['#0f6d8b', '#88d7e6', '#e8f7fb'], ['#d97706', '#f6b56d', '#fff4e6'], ['#6b7c2f', '#c8da89', '#f5f9e6'], ]; return palettes[seed % palettes.length]; } function generateMockImageDataUrl(product: MarketingProduct, deck: MarketingDeck, index: number, locale: DemoLocale): string { const canvas = document.createElement('canvas'); canvas.width = 960; canvas.height = 640; const ctx = canvas.getContext('2d'); if (!ctx) return ''; const isZh = locale === 'zh-TW'; const [primary, secondary, light] = makePalette(index + product.name.length); const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height); gradient.addColorStop(0, light); gradient.addColorStop(0.58, secondary); gradient.addColorStop(1, primary); ctx.fillStyle = gradient; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.globalAlpha = 0.2; for (let i = 0; i < 6; i += 1) { ctx.beginPath(); ctx.fillStyle = i % 2 ? '#ffffff' : primary; const radius = 60 + i * 22; ctx.arc(120 + i * 130, 100 + (i % 3) * 170, radius, 0, Math.PI * 2); ctx.fill(); } ctx.globalAlpha = 1; ctx.fillStyle = 'rgba(255,255,255,0.88)'; ctx.fillRect(70, 70, canvas.width - 140, canvas.height - 140); ctx.fillStyle = '#163024'; ctx.font = 'bold 46px "Sora", sans-serif'; ctx.fillText(deck.headline.slice(0, 28), 112, 180); ctx.fillStyle = '#345747'; ctx.font = '28px "IBM Plex Sans", sans-serif'; ctx.fillText(product.name, 112, 238); ctx.fillStyle = '#1c6e43'; ctx.font = 'bold 36px "Sora", sans-serif'; ctx.fillText(`NT$ ${product.price}/${product.unit}`, 112, 308); ctx.fillStyle = '#445b51'; ctx.font = '24px "IBM Plex Sans", sans-serif'; ctx.fillText(deck.cta.slice(0, 44), 112, 372); ctx.fillStyle = '#0f6d8b'; ctx.font = 'bold 20px "IBM Plex Sans", sans-serif'; ctx.fillText(deck.hashtags.slice(0, 3).join(' '), 112, 434); ctx.fillStyle = 'rgba(31,143,78,0.14)'; ctx.fillRect(620, 120, 240, 360); ctx.fillStyle = '#1f8f4e'; ctx.font = 'bold 22px "Sora", sans-serif'; ctx.fillText(isZh ? '農鏈市集' : 'FARM2MARKET', 646, 170); ctx.font = '18px "IBM Plex Sans", sans-serif'; ctx.fillText(isZh ? (index % 2 === 0 ? '主視覺版' : '社群版') : index % 2 === 0 ? 'Hero Variant' : 'Social Variant', 646, 206); ctx.fillText(isZh ? `庫存:${product.stock}` : `Stock: ${product.stock}`, 646, 246); return canvas.toDataURL('image/png'); } export default function MarketingStudioPanel({ mode, locale, onWorkingChange, onWorkflowInit, onStepStatus, onTrace, onClearPanels, queuedPrompt, onConsumeQueuedPrompt, }: MarketingStudioPanelProps) { const copy = COPY[locale]; const products = PRODUCTS[locale]; const channels = CHANNEL_OPTIONS[locale]; const tones = TONE_OPTIONS[locale]; const [selectedProductId, setSelectedProductId] = useState(products[0].id); const [prompt, setPrompt] = useState(''); const [tone, setTone] = useState(tones[0]); const [channel, setChannel] = useState(channels[0]); const [uploadedImage, setUploadedImage] = useState<{ name: string; previewUrl: string } | null>(null); const [isGenerating, setIsGenerating] = useState(false); const [streamText, setStreamText] = useState(''); const [deck, setDeck] = useState(null); const [images, setImages] = useState([]); const [error, setError] = useState(null); const [meta, setMeta] = useState<{ model: string; durationMs: number; startedAt: string } | null>(null); const [note, setNote] = useState(null); const fileInputRef = useRef(null); const abortRef = useRef(null); const runTokenRef = useRef(0); const mountedRef = useRef(true); const currentStepRef = useRef(null); const selectedProduct = useMemo( () => products.find((product) => product.id === selectedProductId) ?? products[0], [products, selectedProductId] ); const canGenerate = Boolean(selectedProduct) && !isGenerating; const runStep = async ( runToken: number, template: MarketingStepTemplate, payload?: Record ): Promise => { if (!mountedRef.current || runTokenRef.current !== runToken) return false; currentStepRef.current = template.id; onStepStatus(template.id, 'in_progress', template.runningDetail); onTrace(makeTrace(template.kind, template.label, template.runningDetail, 'running')); if (template.delayMs > 0) { await sleep(template.delayMs); } if (!mountedRef.current || runTokenRef.current !== runToken) return false; onStepStatus(template.id, 'completed', template.doneDetail); onTrace(makeTrace(template.kind, template.label, template.doneDetail, 'ok', payload)); return true; }; const stopGeneration = () => { if (!isGenerating) return; runTokenRef.current += 1; abortRef.current?.abort(); abortRef.current = null; if (currentStepRef.current) { onStepStatus(currentStepRef.current, 'error', copy.stoppedByUser); } onTrace(makeTrace('tool', locale === 'zh-TW' ? '生成中止' : 'Generation stopped', copy.stoppedByUser, 'error')); setError(copy.stoppedByUser); setIsGenerating(false); onWorkingChange(false); }; const resetOutput = () => { setStreamText(''); setDeck(null); setImages([]); setError(null); setMeta(null); setNote(null); onClearPanels(); }; const runGeneration = async (initialPrompt?: string) => { if (!selectedProduct) return; const activePrompt = (initialPrompt ?? prompt).trim(); const steps = buildSteps(locale); const runToken = runTokenRef.current + 1; runTokenRef.current = runToken; const startedAtMs = Date.now(); onClearPanels(); setIsGenerating(true); onWorkingChange(true); setError(null); setStreamText(''); setImages([]); setDeck(null); setMeta(null); setNote(null); onWorkflowInit( steps.map((step) => ({ id: step.id, label: step.label, detail: step.runningDetail, status: 'pending', })) ); try { const prepared = await runStep(runToken, steps[0], { product: selectedProduct.name, tone, channel, }); if (!prepared) return; const imageReady = await runStep(runToken, steps[1], { hasReferenceImage: Boolean(uploadedImage), }); if (!imageReady) return; if (mode === 'live') { const textStep = steps[2]; const imageStep = steps[3]; const controller = new AbortController(); abortRef.current = controller; currentStepRef.current = textStep.id; onStepStatus(textStep.id, 'in_progress', textStep.runningDetail); onTrace(makeTrace(textStep.kind, textStep.label, textStep.runningDetail, 'running')); let imageStepStarted = false; let streamedImages = 0; let streamedText = ''; await streamMarketingGeneration({ prompt: activePrompt, locale, product: { name: selectedProduct.name, category: selectedProduct.category, description: selectedProduct.name, }, image: uploadedImage ? parseDataUrl(uploadedImage.previewUrl) : undefined, signal: controller.signal, onEvent: (event) => { if (!mountedRef.current || runTokenRef.current !== runToken) return; if (event.type === 'text') { streamedText += event.text; setStreamText((prev) => prev + event.text); return; } if (event.type === 'image') { streamedImages += 1; if (!imageStepStarted) { imageStepStarted = true; currentStepRef.current = imageStep.id; onStepStatus(imageStep.id, 'in_progress', imageStep.runningDetail); onTrace(makeTrace(imageStep.kind, imageStep.label, imageStep.runningDetail, 'running')); } const dataUrl = `data:${event.mimeType || 'image/png'};base64,${event.base64}`; setImages((prev) => [ ...prev, { id: makeId('img'), dataUrl, base64: event.base64, mimeType: event.mimeType || 'image/png', label: locale === 'zh-TW' ? streamedImages === 1 ? '主視覺' : `社群版型 ${streamedImages - 1}` : streamedImages === 1 ? 'Hero visual' : `Social variation ${streamedImages - 1}`, }, ]); onTrace( makeTrace( 'tool', locale === 'zh-TW' ? '圖片片段' : 'Image chunk', locale === 'zh-TW' ? `已接收第 ${streamedImages} 張圖片` : `Received image ${streamedImages}`, 'ok' ) ); return; } if (event.type === 'error') { throw new Error(event.message || copy.streamError); } }, }); if (!mountedRef.current || runTokenRef.current !== runToken) return; abortRef.current = null; onStepStatus(textStep.id, 'completed', textStep.doneDetail); onTrace(makeTrace(textStep.kind, textStep.label, textStep.doneDetail, 'ok', { chars: streamedText.length })); if (imageStepStarted) { onStepStatus(imageStep.id, 'completed', imageStep.doneDetail); onTrace(makeTrace(imageStep.kind, imageStep.label, imageStep.doneDetail, 'ok', { images: streamedImages })); } else { onStepStatus(imageStep.id, 'completed', locale === 'zh-TW' ? '本次未回傳圖片。' : 'No image chunks in this run.'); onTrace( makeTrace( imageStep.kind, imageStep.label, locale === 'zh-TW' ? '本次只回傳文字。' : 'Text-only response returned.', 'ok' ) ); } const finalDeck = parseDeckFromLiveText(locale, streamedText); setDeck(finalDeck); const packaged = await runStep(runToken, steps[4], { output: { text: true, images: streamedImages, mode: 'live', }, }); if (!packaged) return; setMeta({ model: 'gemini-2.5-flash-image (live)', startedAt: new Date(startedAtMs).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }), durationMs: Date.now() - startedAtMs, }); return; } const deckPayload = createDeck(locale, selectedProduct, activePrompt, tone, channel); const markdown = deckToMarkdown(locale, deckPayload); const chunks = splitTextChunks(markdown); const textStep = steps[2]; currentStepRef.current = textStep.id; onStepStatus(textStep.id, 'in_progress', textStep.runningDetail); onTrace(makeTrace(textStep.kind, textStep.label, textStep.runningDetail, 'running')); for (let i = 0; i < chunks.length; i += 1) { if (!mountedRef.current || runTokenRef.current !== runToken) return; setStreamText((prev) => prev + chunks[i]); if (i % 5 === 0) { onTrace( makeTrace('tool', locale === 'zh-TW' ? '文案片段' : 'Copy chunk', locale === 'zh-TW' ? `已輸出第 ${i + 1} 段` : `Chunk ${i + 1} streamed`, 'ok') ); } await sleep(90); } if (!mountedRef.current || runTokenRef.current !== runToken) return; setDeck(deckPayload); onStepStatus(textStep.id, 'completed', textStep.doneDetail); onTrace(makeTrace(textStep.kind, textStep.label, textStep.doneDetail, 'ok', { chars: markdown.length })); const imageStep = steps[3]; currentStepRef.current = imageStep.id; onStepStatus(imageStep.id, 'in_progress', imageStep.runningDetail); onTrace(makeTrace(imageStep.kind, imageStep.label, imageStep.runningDetail, 'running')); await sleep(480); if (!mountedRef.current || runTokenRef.current !== runToken) return; const imageOne = generateMockImageDataUrl(selectedProduct, deckPayload, 0, locale); const imageTwo = generateMockImageDataUrl(selectedProduct, deckPayload, 1, locale); const generated: GeneratedImage[] = [imageOne, imageTwo].map((dataUrl, index) => ({ id: makeId('img'), dataUrl, mimeType: 'image/png', base64: dataUrl.split(',')[1] || '', label: locale === 'zh-TW' ? (index === 0 ? '主視覺' : '社群版型') : index === 0 ? 'Hero visual' : 'Social variation', })); setImages(generated); onTrace( makeTrace('tool', locale === 'zh-TW' ? '圖片事件' : 'Image event', locale === 'zh-TW' ? '已串流 2 張圖片事件。' : '2 image events streamed.', 'ok', { count: 2, mimeType: 'image/png', }) ); await sleep(300); if (!mountedRef.current || runTokenRef.current !== runToken) return; onStepStatus(imageStep.id, 'completed', imageStep.doneDetail); onTrace(makeTrace(imageStep.kind, imageStep.label, imageStep.doneDetail, 'ok')); const packaged = await runStep(runToken, steps[4], { output: { text: true, images: generated.length, }, }); if (!packaged) return; setMeta({ model: 'gemini-2.5-flash-image (simulated)', startedAt: new Date(startedAtMs).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }), durationMs: Date.now() - startedAtMs, }); } catch (runtimeError) { const message = runtimeError instanceof Error ? runtimeError.name === 'AbortError' ? copy.stoppedByUser : runtimeError.message : copy.streamError; setError(message); if (currentStepRef.current) { onStepStatus(currentStepRef.current, 'error', message); } onTrace(makeTrace('tool', locale === 'zh-TW' ? '生成錯誤' : 'Generation error', message, 'error')); } finally { if (runTokenRef.current === runToken) { abortRef.current = null; setIsGenerating(false); onWorkingChange(false); } } }; const handleUpload = async (event: ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; const readErrorMessage = locale === 'zh-TW' ? '無法讀取檔案' : 'Unable to read file'; const dataUrl = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { if (typeof reader.result !== 'string') { reject(new Error(readErrorMessage)); return; } resolve(reader.result); }; reader.onerror = () => reject(new Error(readErrorMessage)); reader.readAsDataURL(file); }); setUploadedImage({ name: file.name, previewUrl: dataUrl }); }; const handleDownloadImage = (image: GeneratedImage, index: number) => { const link = document.createElement('a'); link.href = image.dataUrl; link.download = `marketing-${index + 1}.png`; document.body.appendChild(link); link.click(); document.body.removeChild(link); }; const handleCopyDeck = async () => { if (!deck) return; const text = deckToMarkdown(locale, deck); await navigator.clipboard.writeText(text); setNote(copy.copied); }; const handleMockApply = () => { setNote(copy.applyMock); }; useEffect(() => { if (!queuedPrompt) return; const consume = async () => { setPrompt(queuedPrompt); onConsumeQueuedPrompt(); await runGeneration(queuedPrompt); }; void consume(); }, [queuedPrompt]); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; runTokenRef.current += 1; abortRef.current?.abort(); abortRef.current = null; onWorkingChange(false); }; }, []); useEffect(() => { setSelectedProductId((prev) => { if (products.some((product) => product.id === prev)) return prev; return products[0].id; }); setTone(TONE_OPTIONS[locale][0]); setChannel(CHANNEL_OPTIONS[locale][0]); }, [locale]); return (

{copy.title}

{copy.subtitle}

{isGenerating ? copy.generating : copy.ready}
{products.map((product) => { const selected = product.id === selectedProductId; return ( ); })}