Farm2Market / src /MarketingStudioPanel.tsx
pixel3user
Update Farm2Market demo frontend
e46c4bd
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<void>((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<string, unknown>
): 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<string>(products[0].id);
const [prompt, setPrompt] = useState('');
const [tone, setTone] = useState<string>(tones[0]);
const [channel, setChannel] = useState<string>(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<MarketingDeck | null>(null);
const [images, setImages] = useState<GeneratedImage[]>([]);
const [error, setError] = useState<string | null>(null);
const [meta, setMeta] = useState<{ model: string; durationMs: number; startedAt: string } | null>(null);
const [note, setNote] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const abortRef = useRef<AbortController | null>(null);
const runTokenRef = useRef(0);
const mountedRef = useRef(true);
const currentStepRef = useRef<string | null>(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<string, unknown>
): Promise<boolean> => {
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<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const readErrorMessage = locale === 'zh-TW' ? '無法讀取檔案' : 'Unable to read file';
const dataUrl = await new Promise<string>((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 (
<div className="marketing-studio-root">
<MarketingFeatureDemo locale={locale} isBusy={isGenerating} />
<header className="marketing-studio-header">
<div>
<h3>{copy.title}</h3>
<p>{copy.subtitle}</p>
</div>
<div className={`status-pill ${isGenerating ? 'busy' : 'idle'}`}>{isGenerating ? copy.generating : copy.ready}</div>
</header>
<div className="marketing-studio-layout">
<section className="marketing-controls">
<div className="marketing-field-block">
<label>{copy.productLabel}</label>
<div className="marketing-product-grid">
{products.map((product) => {
const selected = product.id === selectedProductId;
return (
<button
key={product.id}
type="button"
className={`marketing-product-card ${selected ? 'selected' : ''}`}
onClick={() => setSelectedProductId(product.id)}
>
<strong>{product.name}</strong>
<small>{product.category}</small>
<span>
NT$ {product.price}/{product.unit}
</span>
<em>{locale === 'zh-TW' ? `庫存 ${product.stock}` : `Stock ${product.stock}`}</em>
</button>
);
})}
</div>
</div>
<div className="marketing-config-row">
<label>
<span>{copy.toneLabel}</span>
<select value={tone} onChange={(event) => setTone(event.target.value)} disabled={isGenerating}>
{tones.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<label>
<span>{copy.channelLabel}</span>
<select value={channel} onChange={(event) => setChannel(event.target.value)} disabled={isGenerating}>
{channels.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
</div>
<div className="marketing-field-block">
<label>{copy.briefLabel}</label>
<textarea
rows={6}
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder={copy.promptPlaceholder}
disabled={isGenerating}
/>
</div>
<div className="marketing-field-block">
<label>{copy.imageLabel}</label>
<input ref={fileInputRef} type="file" accept="image/*" className="hidden-input" onChange={handleUpload} />
<div className="marketing-upload-row">
<button type="button" className="ghost-btn" onClick={() => fileInputRef.current?.click()} disabled={isGenerating}>
{copy.uploadButton}
</button>
{uploadedImage ? (
<button
type="button"
className="ghost-btn"
onClick={() => {
setUploadedImage(null);
if (fileInputRef.current) fileInputRef.current.value = '';
}}
disabled={isGenerating}
>
{copy.removeImage}
</button>
) : null}
</div>
{uploadedImage ? (
<div className="marketing-upload-preview">
<img src={uploadedImage.previewUrl} alt={uploadedImage.name} />
<small>{uploadedImage.name}</small>
</div>
) : (
<p className="marketing-help-text">{copy.noImage}</p>
)}
</div>
<div className="marketing-action-row">
<button type="button" className="primary-btn" disabled={!canGenerate} onClick={() => void runGeneration()}>
{copy.generate}
</button>
{isGenerating ? (
<button type="button" className="ghost-btn" onClick={stopGeneration}>
{copy.stop}
</button>
) : null}
<button type="button" className="ghost-btn" onClick={resetOutput} disabled={isGenerating}>
{copy.reset}
</button>
</div>
</section>
<section className="marketing-output">
<header>
<h4>{copy.outputTitle}</h4>
<p>{copy.outputSubtitle}</p>
</header>
{note ? <div className="marketing-note">{note}</div> : null}
{error ? <div className="marketing-error">{error}</div> : null}
{!streamText && images.length === 0 && !isGenerating ? (
<div className="marketing-empty">{copy.noOutput}</div>
) : null}
{streamText ? (
<article className="marketing-stream-card">
<div className="marketing-stream-actions">
<strong>{locale === 'zh-TW' ? '串流文案' : 'Streaming Copy'}</strong>
<button type="button" className="ghost-btn" onClick={() => void handleCopyDeck()} disabled={!deck}>
{copy.copyDeck}
</button>
</div>
<pre>{streamText}</pre>
</article>
) : null}
{deck ? (
<article className="marketing-deck-grid">
<section>
<h5>{locale === 'zh-TW' ? '標題' : 'Headline'}</h5>
<p>{deck.headline}</p>
</section>
<section>
<h5>{locale === 'zh-TW' ? 'CTA' : 'CTA'}</h5>
<p>{deck.cta}</p>
</section>
<section>
<h5>{locale === 'zh-TW' ? 'Hashtag' : 'Hashtags'}</h5>
<p>{deck.hashtags.join(' ')}</p>
</section>
<section>
<h5>{locale === 'zh-TW' ? '設計重點' : 'Design Notes'}</h5>
<ul>
{deck.designNotes.map((noteItem) => (
<li key={noteItem}>{noteItem}</li>
))}
</ul>
</section>
</article>
) : null}
{images.length > 0 ? (
<div className="marketing-image-grid">
{images.map((image, index) => (
<article key={image.id}>
<img src={image.dataUrl} alt={image.label} />
<div className="marketing-image-meta">
<strong>{image.label}</strong>
<div>
<button type="button" className="ghost-btn" onClick={() => handleDownloadImage(image, index)}>
{locale === 'zh-TW' ? '下載' : 'Download'}
</button>
<button type="button" className="ghost-btn" onClick={handleMockApply}>
{locale === 'zh-TW' ? '套用' : 'Apply'}
</button>
</div>
</div>
</article>
))}
</div>
) : null}
{meta ? (
<article className="marketing-meta-card">
<h5>{copy.metadataTitle}</h5>
<dl>
<div>
<dt>{copy.metadataModel}</dt>
<dd>{meta.model}</dd>
</div>
<div>
<dt>{copy.metadataStartedAt}</dt>
<dd>{meta.startedAt}</dd>
</div>
<div>
<dt>{copy.metadataDuration}</dt>
<dd>{meta.durationMs} ms</dd>
</div>
<div>
<dt>{copy.metadataEvents}</dt>
<dd>
{images.length > 0
? copy.metadataEventsWithImages.replace('{count}', String(images.length))
: copy.metadataEventsTextOnly}
</dd>
</div>
</dl>
</article>
) : null}
</section>
</div>
</div>
);
}