/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable import/no-named-as-default-member */ /* eslint-disable no-empty */ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; import Papa from 'papaparse'; import * as XLSX from 'xlsx'; import { recomaze_ai_personalization_env } from '../env'; import { useAppStateContext } from '../context/user.data.context'; import { getActiveContentGenJob, submitContentGenJob, getContentGenStatus, getContentGenUsage, improvePrompt, detectContentGenColumns, listPromptTemplates, } from '../service/content-generator/content-generator.service'; import { clearCachedJobId, clearCachedJobSnapshot, loadCachedJobId, loadCachedJobSnapshot, saveCachedJobId, saveCachedJobSnapshot, } from '../service/content-generator/job-cache'; import type { ContentGeneratorProduct, ContentGenerateFields, FieldPrompts, ContentGeneratorStatusResponse, ContentGeneratorUsage, DetectColumnsResponse, PromptTemplate, } from '../service/content-generator/content-generator.interface'; import { LANGUAGE_OPTIONS as LANGUAGES } from '../data/locales'; import Button from '../components/widgets/Button'; import SearchableSelect from '../components/widgets/SearchableSelect'; import ErrorWrapper from '../components/alert/ErrorWrapper'; import PromptTemplates from '../components/content-generator/PromptTemplates'; const TONES = [ { value: 'professional', label: 'Professional' }, { value: 'friendly', label: 'Friendly' }, { value: 'luxury', label: 'Luxury' }, { value: 'technical', label: 'Technical' }, { value: 'custom', label: 'Custom' }, ]; const FIELD_LABELS: Record = { titles: 'Titles', descriptions: 'Descriptions', tags: 'Tags', faq: 'FAQ (Frequently Asked Questions)', }; const FIELD_HELP: Record = { titles: 'Optimize product names for click-through rate.', descriptions: 'Generate rich, benefit-focused product copy.', tags: 'Generate relevant keywords for searchability.', faq: 'Generate Q&A pairs based on product information.', }; const FIELD_PLACEHOLDERS: Record = { titles: 'e.g., Always start with the brand name, max 70 characters, include main keyword...', descriptions: 'e.g., Include a benefits section, mention free shipping, use bullet points, max 200 words...', tags: 'e.g., Include seasonal tags, max 8 tags, comma-separated...', faq: 'e.g., Focus on shipping and returns questions, 3-5 Q&A pairs...', }; const POLL_INTERVAL = 3000; const parseCSV = (text: string): string[][] => { const cleaned = text.replace(/^\ufeff/, ''); const result = Papa.parse(cleaned, { skipEmptyLines: true, delimitersToGuess: [',', ';', '\t', '|'], }); return (result.data || []).map(row => row.map(cell => (cell ?? '').toString().trim()) ); }; const parseXLSX = (buffer: ArrayBuffer): string[][] => { const workbook = XLSX.read(buffer, { type: 'array' }); const firstSheetName = workbook.SheetNames[0]; if (!firstSheetName) return []; const sheet = workbook.Sheets[firstSheetName]; const matrix = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '', blankrows: false, }); return matrix.map(row => row.map(cell => (cell ?? '').toString().trim())); }; type ColumnMapping = { product_id: number; sku: number; title: number; description: number; tags: number; product_url: number; category: number; }; const autoDetectColumns = (header: string[]): ColumnMapping | null => { const lower = header.map(h => h.toLowerCase().replace(/[^a-z0-9_]/g, '_')); const find = (needles: string[]): number => lower.findIndex(h => needles.some(n => h.includes(n))); const productIdIdx = find(['product_id', 'product_id', 'product id']); const skuIdx = find(['sku', 'variant_sku', 'variant sku']); const titleIdx = find([ 'title', 'name', 'product_title', 'product title', 'product_name', ]); const descIdx = find(['description', 'body_html', 'body html', 'desc']); const tagsIdx = find(['tag', 'keyword']); const urlIdx = find([ 'url', 'handle', 'slug', 'link', 'custom_url', 'product_url', ]); const categoryIdx = find(['categor', 'collection']); if (titleIdx === -1) return null; return { product_id: productIdIdx, sku: skuIdx, title: titleIdx, description: descIdx, tags: tagsIdx, product_url: urlIdx, category: categoryIdx, }; }; const ContentGeneratorPage = (): JSX.Element => { const { clientId, user } = useAppStateContext(); const fileInputRef = useRef(null); const [activeTab, setActiveTab] = useState<'generator' | 'templates'>( 'generator' ); const [step, setStep] = useState(0); const [backupLoading, setBackupLoading] = useState(false); const [backupError, setBackupError] = useState(null); const [backupDone, setBackupDone] = useState(false); const [categories, setCategories] = useState< { id: number; name: string; count: number }[] >([]); const [selectedCategory, setSelectedCategory] = useState('all'); const [fileName, setFileName] = useState(''); const [products, setProducts] = useState([]); const [uploadError, setUploadError] = useState(null); const [tone, setTone] = useState('professional'); const [language, setLanguage] = useState('en'); // Additional languages (max 2) for multi-language fan-out. Total stays at 3 // on the agent side. const [additionalLanguages, setAdditionalLanguages] = useState([]); // Output shape for multi-language jobs (single merged CSV vs one per // language). Irrelevant when only one language is picked. const [bundleOutput, setBundleOutput] = useState<'single' | 'per_language'>( 'single' ); const [temperature, setTemperature] = useState(0.7); const [fields, setFields] = useState({ titles: true, descriptions: true, tags: true, faq: false, }); const [fieldPrompts, setFieldPrompts] = useState({ titles: '', descriptions: '', tags: '', faq: '', }); const [faqInDescription, setFaqInDescription] = useState(true); const [email, setEmail] = useState(user?.email ?? ''); const [improvingField, setImprovingField] = useState(null); const [jobStatus, setJobStatus] = useState(null); const [submitError, setSubmitError] = useState(null); const [submitting, setSubmitting] = useState(false); const pollRef = useRef(null); /** * Poll the agent's job status every 3s until the job leaves the * ``pending`` / ``processing`` window. Idempotent: clears any previous * interval before scheduling a new one. Mirrors the Shopify plugin's * pattern - on 404 / network error the agent has already deleted the * status snapshot (terminal cleanup releases the per-merchant lock), so * we synthesise a ``completed`` frame from the last known status * instead of freezing on PENDING. * * @param {string} jobId * @return {void} */ const startPolling = useCallback( (jobId: string): void => { if (!clientId) return; if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } const refreshUsage = (): void => { const plan: string = user?.plan_snapshot || user?.plan || 'FREE'; getContentGenUsage(plan, clientId) .then(res => { if (res?.data) setUsage(res.data); }) .catch(() => {}); }; const finishPolling = (): void => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } clearCachedJobId(clientId); }; const tick = async (): Promise => { const res = await getContentGenStatus(jobId, clientId).catch( () => null ); const status: ContentGeneratorStatusResponse | undefined = res?.data; const errored: boolean = !res || !!(res as { error?: string })?.error; if (errored || !status) { // /status returns 404 once terminal cleanup wipes the Redis // blob. Synthesise the completed frame from the last known // status so the UI flips out of PENDING. finishPolling(); setJobStatus(previous => { if ( !previous || (previous.state !== 'pending' && previous.state !== 'processing') ) { return previous; } const synthesised: ContentGeneratorStatusResponse = { ...previous, state: 'completed', progress: 100, processed_count: previous.total_count || previous.processed_count, }; saveCachedJobSnapshot(clientId, synthesised); return synthesised; }); refreshUsage(); return; } setJobStatus(status); if (status.state === 'completed' || status.state === 'failed') { finishPolling(); saveCachedJobSnapshot(clientId, status); if (status.state === 'completed') refreshUsage(); } }; tick(); pollRef.current = setInterval(tick, 3000); }, [clientId, user?.plan_snapshot, user?.plan] ); useEffect( () => () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } }, [] ); // Resume polling for an in-flight job after a refresh - getActiveContentGenJob // wakes the page back into step 3, but the previous mount's setInterval is // gone, so we need to schedule a fresh one keyed on the surfaced job_id. useEffect(() => { if (!clientId) return; if (!jobStatus?.job_id) return; if (pollRef.current) return; if (jobStatus.state !== 'pending' && jobStatus.state !== 'processing') return; startPolling(jobStatus.job_id); }, [clientId, jobStatus?.job_id, jobStatus?.state, startPolling]); const [usage, setUsage] = useState(null); const [dataRowsForDetect, setDataRowsForDetect] = useState([]); const [headersForDetect, setHeadersForDetect] = useState([]); const [promptTemplates, setPromptTemplates] = useState([]); const [templatesLoaded, setTemplatesLoaded] = useState(false); const [selectedTemplateId, setSelectedTemplateId] = useState(''); const apiBase = recomaze_ai_personalization_env?.wp_api_url; const nonce = recomaze_ai_personalization_env?.nonce; useEffect(() => { if (!clientId) return; const plan = user?.plan_snapshot || user?.plan || 'free'; getContentGenUsage(plan, clientId).then(res => { if (res?.data) setUsage(res.data); }); }, [clientId, user?.plan_snapshot, user?.plan]); // On mount, ask the agent whether there's an in-flight job for this // merchant. If yes, surface it on step 3 so the page resumes the // progress bar; the polling-resume effect below picks up from there. // If /active is inactive (job already completed and Redis lock released), // fall back to the cached terminal snapshot so the merchant still lands // on the "Job Complete" card after a refresh or tab change instead of // being dumped back to step 0. useEffect(() => { if (!clientId) return; let cancelled: boolean = false; (async () => { try { const res = await getActiveContentGenJob(clientId); if (cancelled) return; const activePayload = res?.data; if (activePayload?.active && activePayload.status) { setJobStatus(activePayload.status); saveCachedJobId(clientId, activePayload.status.job_id); setStep(3); return; } if (loadCachedJobId(clientId)) clearCachedJobId(clientId); const snapshot = loadCachedJobSnapshot(clientId); if (snapshot) { setJobStatus(snapshot); setStep(3); } } catch { /* network / auth error - leave the cache alone, retry next mount */ } })(); return () => { cancelled = true; }; }, [clientId]); useEffect(() => { if (!apiBase || !nonce) return; fetch(`${apiBase}recomaze/v1/product-categories`, { headers: { 'X-WP-Nonce': nonce }, }) .then(r => r.json()) .then(data => { if (data?.categories) setCategories(data.categories); }) .catch(() => {}); }, [apiBase, nonce]); useEffect(() => { return () => { if (pollRef.current) clearInterval(pollRef.current); }; }, []); useEffect(() => { if (step !== 2 || templatesLoaded || !clientId) return; setTemplatesLoaded(true); listPromptTemplates(clientId).then(res => { if (res?.data?.templates) setPromptTemplates(res.data.templates); }); }, [step, templatesLoaded, clientId]); const downloadBackup = async () => { if (!apiBase || !nonce) { setBackupError('Missing WordPress API configuration.'); return; } try { setBackupLoading(true); setBackupError(null); const backupUrl = selectedCategory === 'all' ? `${apiBase}recomaze/v1/product-improvements/backup` : `${apiBase}recomaze/v1/product-improvements/backup?category=${selectedCategory}`; const resp = await fetch(backupUrl, { headers: { 'X-WP-Nonce': nonce }, }); if (!resp.ok) { const data = await resp.json().catch(() => ({})); throw new Error(data?.error || 'Failed to download backup.'); } const raw = await resp.text(); let csv: string; try { const parsed = JSON.parse(raw); csv = typeof parsed === 'string' ? parsed : raw; } catch { csv = raw; } const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; const catLabel = selectedCategory !== 'all' ? categories.find(c => String(c.id) === selectedCategory)?.name : ''; a.download = catLabel ? `product_backup_${catLabel.replace(/[^a-zA-Z0-9_-]/g, '_')}.csv` : 'product_backup.csv'; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); setBackupDone(true); } catch (err: any) { setBackupError(err?.message || 'Failed to download backup.'); } finally { setBackupLoading(false); } }; const applyAIColumnMapping = useCallback( ( ai: DetectColumnsResponse, currentMapping: ColumnMapping, headers: string[] ): ColumnMapping => { const lower = headers.map(h => h.toLowerCase().trim()); const findHeader = (aiValue: string | null): number => { if (!aiValue) return -1; const exact = lower.indexOf(aiValue.toLowerCase().trim()); if (exact !== -1) return exact; return lower.findIndex(h => h.includes(aiValue.toLowerCase().trim())); }; return { product_id: currentMapping.product_id, sku: ai.sku ? findHeader(ai.sku) : currentMapping.sku, title: ai.title ? findHeader(ai.title) : currentMapping.title, description: ai.description ? findHeader(ai.description) : currentMapping.description, tags: ai.tags ? findHeader(ai.tags) : currentMapping.tags, product_url: ai.handle ? findHeader(ai.handle) : currentMapping.product_url, category: ai.collection ? findHeader(ai.collection) : currentMapping.category, }; }, [] ); const requestAIColumnDetection = useCallback( (headers: string[], dataRows: string[][], staticMapping: ColumnMapping) => { if (!clientId) return; const sampleRows = dataRows.slice(0, 5).map(row => { const obj: Record = {}; headers.forEach((h, i) => { obj[h] = row[i] ?? ''; }); return obj; }); detectContentGenColumns({ headers, sample_rows: sampleRows }, clientId) .then(res => { const ai = res?.data; if (!ai) return; const aiMapping = applyAIColumnMapping(ai, staticMapping, headers); setProducts(prev => { const parsed: ContentGeneratorProduct[] = []; setDataRowsForDetect(currentRows => { for (let i = 0; i < currentRows.length; i++) { const r = currentRows[i]; const title = aiMapping.title >= 0 ? r[aiMapping.title] || '' : ''; if (!title) continue; parsed.push({ row_index: i + 1, product_id: aiMapping.product_id >= 0 ? r[aiMapping.product_id] || '' : '', sku: aiMapping.sku >= 0 ? r[aiMapping.sku] || '' : '', title, description: aiMapping.description >= 0 ? r[aiMapping.description] || '' : '', tags: aiMapping.tags >= 0 ? r[aiMapping.tags] || '' : '', product_url: aiMapping.product_url >= 0 ? r[aiMapping.product_url] || '' : '', category: aiMapping.category >= 0 ? r[aiMapping.category] || '' : '', }); } return currentRows; }); return parsed.length > 0 ? parsed : prev; }); }) .catch(() => {}); }, [clientId, applyAIColumnMapping] ); const handleParsedRows = useCallback( (rows: string[][], uploadedFileName: string) => { if (rows.length < 2) { setUploadError( 'File must have a header row and at least one data row.' ); return; } const mapping = autoDetectColumns(rows[0]); if (!mapping) { setUploadError( 'Could not detect columns. Ensure your file has at least a "Title" column.' ); return; } const dataRows = rows.slice(1); const parsed: ContentGeneratorProduct[] = []; for (let i = 0; i < dataRows.length; i++) { const r = dataRows[i]; const title = mapping.title >= 0 ? r[mapping.title] || '' : ''; if (!title) continue; parsed.push({ row_index: i + 1, product_id: mapping.product_id >= 0 ? r[mapping.product_id] || '' : '', sku: mapping.sku >= 0 ? r[mapping.sku] || '' : '', title, description: mapping.description >= 0 ? r[mapping.description] || '' : '', tags: mapping.tags >= 0 ? r[mapping.tags] || '' : '', product_url: mapping.product_url >= 0 ? r[mapping.product_url] || '' : '', category: mapping.category >= 0 ? r[mapping.category] || '' : '', }); } if (!parsed.length) { setUploadError('No valid product rows found.'); return; } setDataRowsForDetect(dataRows); setHeadersForDetect(rows[0]); setProducts(parsed); setFileName(uploadedFileName); setStep(1); requestAIColumnDetection(rows[0], dataRows, mapping); }, [requestAIColumnDetection] ); const handleFileUpload = useCallback( async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; setUploadError(null); const ext = file.name.split('.').pop()?.toLowerCase(); if (ext === 'xlsx' || ext === 'xls') { const buffer = await file.arrayBuffer(); const rows = parseXLSX(buffer); handleParsedRows(rows, file.name); return; } let text = await file.text(); try { const parsedJson = JSON.parse(text); if (typeof parsedJson === 'string') text = parsedJson; } catch {} const rows = parseCSV(text); handleParsedRows(rows, file.name); }, [handleParsedRows] ); const handleApplyTemplate = useCallback( (templateId: string) => { const template = promptTemplates.find(t => t.template_id === templateId); if (!template) return; setSelectedTemplateId(templateId); setFieldPrompts({ titles: template.titles_prompt ?? '', descriptions: template.descriptions_prompt ?? '', tags: template.tags_prompt ?? '', faq: template.faq_prompt ?? '', }); setLanguage(template.language ?? 'en'); setTone(template.tone ?? 'professional'); setTemperature(template.temperature ?? 0.7); }, [promptTemplates] ); const handleImprovePrompt = async (field: keyof FieldPrompts) => { if (!clientId) return; const current = fieldPrompts[field]; if (!current.trim()) return; setImprovingField(field); try { const res = await improvePrompt(current, field, language, clientId); const improved = res?.data?.improved_prompt; if (improved) { setFieldPrompts(prev => ({ ...prev, [field]: improved, })); } } catch { } finally { setImprovingField(null); } }; const handleSubmit = async () => { if (!clientId || !products.length) return; setSubmitting(true); setSubmitError(null); setJobStatus(null); try { const plan = user?.plan_snapshot || user?.plan || 'FREE'; // When the merchant picked extra languages, send the full list (primary + // extras) on `languages` so the agent fans out per language. Single- // language jobs leave `languages` undefined and keep the legacy single-CSV // pipeline untouched. const dedupedLanguages: string[] = []; const seenLanguageCodes = new Set(); for (const code of [language, ...additionalLanguages]) { const normalized = (code || '').trim().toLowerCase(); if (normalized && !seenLanguageCodes.has(normalized)) { seenLanguageCodes.add(normalized); dedupedLanguages.push(normalized); } } const languagesPayload = dedupedLanguages.length > 1 ? dedupedLanguages : undefined; const res = await submitContentGenJob( { products, config: { platform: 'woocommerce', tone, language, languages: languagesPayload, // Only send bundle_output when multi-language fan-out is on. bundle_output: languagesPayload ? bundleOutput : undefined, temperature, generate_fields: fields, field_prompts: fieldPrompts, faq_in_description: fields.faq ? faqInDescription : undefined, }, email, plan, }, clientId ); if (res?.error || res?.errors) { // Submit rejected — could be 409 (a job is already running). Re-check // via /active and hydrate the UI to that job instead of erroring out. const activeRes = await getActiveContentGenJob(clientId); const activePayload = activeRes?.data; if (activePayload?.active && activePayload.status) { setJobStatus(activePayload.status); saveCachedJobId(clientId, activePayload.status.job_id); setStep(3); startPolling(activePayload.status.job_id); return; } throw new Error( (res.error || String(res.errors) || 'Failed to submit job') as string ); } const jobId = res?.data?.job_id; if (!jobId) throw new Error('No job ID returned.'); setJobStatus({ job_id: jobId, state: 'pending', progress: 0, total_count: products.length, processed_count: 0, }); saveCachedJobId(clientId, jobId); setStep(3); startPolling(jobId); } catch (err: any) { setSubmitError(err?.message || 'Failed to submit generation job.'); } finally { setSubmitting(false); } }; const progressPct = jobStatus?.progress ?? 0; const resetWizard = () => { setStep(0); setProducts([]); setFileName(''); setJobStatus(null); setBackupDone(false); setSubmitError(null); setUploadError(null); setDataRowsForDetect([]); setHeadersForDetect([]); setSelectedTemplateId(''); if (clientId) clearCachedJobSnapshot(clientId); }; const planName = user?.plan_snapshot || user?.plan || 'Free'; const WIZARD_STEPS = ['Backup', 'Upload', 'Configure', 'Generate']; return (

AI Content Generator

Generate optimized titles, descriptions, tags, and FAQs for your products.

{[ { key: 'generator', label: 'Content Generator' }, { key: 'templates', label: 'Prompt Templates' }, ].map(tab => ( ))}
{activeTab === 'templates' && } {activeTab === 'generator' && ( <> {usage && (

Monthly Usage limit

{usage.remaining > 0 ? `You can generate content for ${usage.remaining} more product(s) this month on your ${planName} plan.` : `You've reached your monthly limit on the ${planName} plan. Upgrade to generate more content.`}

{usage.used} / {usage.limit}
)}
{WIZARD_STEPS.map((label, idx) => { const isActive = step === idx; const isCompleted = step > idx; return (
{isCompleted ? ( ) : ( idx + 1 )}
{label}
); })}
{step === 0 && (

Download Product Backup

Highly recommended before generating new content so you can revert changes if needed.

How it works

The AI generates optimized content based on your settings. The result is emailed as a CSV file. Having a backup means you can always restore your original catalog.

{categories.length > 0 && (
)} {backupError && (
)} {backupDone && (

Backup Successfully Downloaded

Your backup CSV is ready. You can now safely proceed with generating new content.

)}
)} {step === 1 && (

Upload Product CSV

Upload the backup you just downloaded or any standard WooCommerce export.

{usage && usage.remaining <= 0 && (

Monthly limit reached

You've used all {usage.limit} product generations on your {planName} plan. Upgrade to continue.

)}

Expected CSV format (first row = headers):

                      {`Title,Description,SKU,Tags,URL,Category
"Blue Widget","A great widget",SKU-001,"blue,widget",/products/blue-widget,Widgets`}
                    

Only "Title" is required. Delimiters (comma, semicolon, tab, pipe) are detected automatically. XLSX files are also supported.

{uploadError && (
)} {products.length > 0 && (

{products.length} products detected

File: {fileName}

)}
)} {step === 2 && (

Configure AI Output

Setup the generation rules for your {products.length}{' '} products.

{promptTemplates.length > 0 && (

Applying a template overwrites the current tone, language, creativity, and field instructions below.

)}
{additionalLanguages.length > 0 && (
{additionalLanguages.map(code => { const matched = LANGUAGES.find( option => option.value === code ); const labelForCode = matched?.label ?? code.toUpperCase(); return ( {labelForCode} ); })}
)}

Up to 2 extras (3 languages total).

{additionalLanguages.length > 0 && (
)}

Target Fields

{( Object.keys(FIELD_LABELS) as Array< keyof ContentGenerateFields > ).map(key => (
setFields(prev => ({ ...prev, [key]: e.target.checked, })) } className="h-4 w-4 rounded border-gray-300 text-gray-900 focus:ring-gray-900" />
{FIELD_LABELS[key]} {FIELD_HELP[key]}
{fields[key] && (
{key === 'faq' && (
)}