Spaces:
Sleeping
Sleeping
| import React, { useState, useMemo, useEffect, useRef } from 'react'; | |
| import ReactMarkdown from 'react-markdown'; | |
| import { ResultRow, DetailedQaReport, QaSectionResult } from '../types'; | |
| import { DownloadIcon, CheckCircleIcon, XCircleIcon, EyeIcon } from './Icons'; | |
| import jsPDF from 'jspdf'; | |
| import { Document, Packer, Paragraph, TextRun, HeadingLevel, Table, TableRow, TableCell, WidthType, AlignmentType, BorderStyle } from 'docx'; | |
| // This tells TypeScript that `Papa` is available on the global window object. | |
| declare const Papa: any; | |
| interface ResultsTableProps { | |
| results: ResultRow[]; | |
| } | |
| type SortConfig = { | |
| key: keyof ResultRow; | |
| direction: 'ascending' | 'descending'; | |
| } | null; | |
| const QAReportModal: React.FC<{ report: DetailedQaReport; onClose: () => void }> = ({ report, onClose }) => { | |
| const Section: React.FC<{ title: string; data: QaSectionResult }> = ({ title, data }) => ( | |
| <div className="bg-gray-900/70 p-4 rounded-lg mb-4 border border-gray-700"> | |
| <h4 className="text-lg font-semibold text-white mb-3 flex items-center gap-3"> | |
| {data.pass | |
| ? <CheckCircleIcon className="w-6 h-6 text-green-400 flex-shrink-0" /> | |
| : <XCircleIcon className="w-6 h-6 text-red-400 flex-shrink-0" /> | |
| } | |
| {title} | |
| </h4> | |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-2 text-sm pl-9"> | |
| <p><span className="font-semibold text-gray-400">Grade:</span> <span className="text-gray-200">{data.grade}</span></p> | |
| <p><span className="font-semibold text-gray-400">Pass:</span> <span className={`font-medium ${data.pass ? 'text-green-400' : 'text-red-400'}`}>{data.pass ? 'Yes' : 'No'}</span></p> | |
| </div> | |
| {/* Enhanced Content Display */} | |
| {data.detailedAssessment && ( | |
| <div className="pl-9 mt-3 text-sm"> | |
| <p className="font-semibold text-gray-400">Detailed Assessment:</p> | |
| <div className="text-gray-300 text-xs mt-1 bg-gray-950/50 p-3 rounded-md border border-gray-600/50"> | |
| <ReactMarkdown | |
| components={{ | |
| p: ({ children }) => <p className="mb-2 last:mb-0 text-gray-300">{children}</p>, | |
| ul: ({ children }) => <ul className="list-disc ml-4 mb-2 text-gray-300">{children}</ul>, | |
| ol: ({ children }) => <ol className="list-decimal ml-4 mb-2 text-gray-300">{children}</ol>, | |
| li: ({ children }) => <li className="mb-1 text-gray-300">{children}</li>, | |
| code: ({ children }) => <code className="bg-gray-800 px-1 py-0.5 rounded text-blue-300 text-xs">{children}</code>, | |
| strong: ({ children }) => <strong className="font-semibold text-white">{children}</strong>, | |
| em: ({ children }) => <em className="italic text-gray-200">{children}</em>, | |
| }} | |
| > | |
| {data.detailedAssessment} | |
| </ReactMarkdown> | |
| </div> | |
| </div> | |
| )} | |
| {data.keyStrengths && data.keyStrengths.length > 0 && ( | |
| <div className="pl-9 mt-3 text-sm"> | |
| <p className="font-semibold text-gray-400">Key Strengths:</p> | |
| <ul className="list-disc list-inside text-gray-300 pl-2 mt-1 space-y-1"> | |
| {data.keyStrengths.map((strength, i) => <li key={i}>{strength}</li>)} | |
| </ul> | |
| </div> | |
| )} | |
| {data.recommendations && data.recommendations.length > 0 && ( | |
| <div className="pl-9 mt-3 text-sm"> | |
| <p className="font-semibold text-gray-400">Recommendations:</p> | |
| <ul className="list-disc list-inside text-gray-300 pl-2 mt-1 space-y-1"> | |
| {data.recommendations.map((rec, i) => <li key={i}>{rec}</li>)} | |
| </ul> | |
| </div> | |
| )} | |
| {data.explanations && ( | |
| <div className="pl-9 mt-3 text-sm"> | |
| <p className="font-semibold text-gray-400">Explanations:</p> | |
| <div className="text-gray-300 text-xs mt-1 bg-gray-950/50 p-3 rounded-md border border-gray-600/50"> | |
| <ReactMarkdown | |
| components={{ | |
| p: ({ children }) => <p className="mb-2 last:mb-0 text-gray-300">{children}</p>, | |
| ul: ({ children }) => <ul className="list-disc ml-4 mb-2 text-gray-300">{children}</ul>, | |
| ol: ({ children }) => <ol className="list-decimal ml-4 mb-2 text-gray-300">{children}</ol>, | |
| li: ({ children }) => <li className="mb-1 text-gray-300">{children}</li>, | |
| code: ({ children }) => <code className="bg-gray-800 px-1 py-0.5 rounded text-blue-300 text-xs">{children}</code>, | |
| strong: ({ children }) => <strong className="font-semibold text-white">{children}</strong>, | |
| em: ({ children }) => <em className="italic text-gray-200">{children}</em>, | |
| }} | |
| > | |
| {data.explanations} | |
| </ReactMarkdown> | |
| </div> | |
| </div> | |
| )} | |
| <div className="pl-9 mt-3 text-sm"> | |
| <p className="font-semibold text-gray-400">Errors:</p> | |
| <ul className="list-disc list-inside text-gray-300 pl-2 mt-1 space-y-1"> | |
| {data.errors.map((err, i) => <li key={i}>{err}</li>)} | |
| </ul> | |
| </div> | |
| {data.corrected && ( | |
| <div className="pl-9 mt-3 text-sm"> | |
| <p className="font-semibold text-gray-400">Corrected Content:</p> | |
| <div className="text-gray-300 text-xs mt-1 bg-gray-950/50 p-3 rounded-md border border-gray-600/50 overflow-auto max-h-48"> | |
| <div className="prose prose-sm prose-invert max-w-none"> | |
| <ReactMarkdown | |
| components={{ | |
| p: ({ children }) => <p className="mb-2 last:mb-0 text-gray-300">{children}</p>, | |
| ul: ({ children }) => <ul className="list-disc ml-4 mb-2 text-gray-300">{children}</ul>, | |
| ol: ({ children }) => <ol className="list-decimal ml-4 mb-2 text-gray-300">{children}</ol>, | |
| li: ({ children }) => <li className="mb-1 text-gray-300">{children}</li>, | |
| code: ({ children }) => <code className="bg-gray-800 px-1 py-0.5 rounded text-blue-300 text-xs">{children}</code>, | |
| strong: ({ children }) => <strong className="font-semibold text-white">{children}</strong>, | |
| em: ({ children }) => <em className="italic text-gray-200">{children}</em>, | |
| h1: ({ children }) => <h1 className="text-lg font-bold mb-2 text-white">{children}</h1>, | |
| h2: ({ children }) => <h2 className="text-base font-semibold mb-2 text-white">{children}</h2>, | |
| h3: ({ children }) => <h3 className="text-sm font-semibold mb-1 text-white">{children}</h3>, | |
| }} | |
| > | |
| {data.corrected} | |
| </ReactMarkdown> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Raw Content for complete transparency */} | |
| {data.rawContent && ( | |
| <div className="pl-9 mt-3 text-sm"> | |
| <p className="font-semibold text-gray-400">Raw QA Content:</p> | |
| <div className="text-gray-300 text-xs mt-1 bg-gray-950/50 p-3 rounded-md border border-gray-600/50 overflow-auto max-h-32"> | |
| <pre className="whitespace-pre-wrap text-gray-400">{data.rawContent}</pre> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| const OverallSection: React.FC<{ data: DetailedQaReport['overall'] }> = ({ data }) => ( | |
| <div className="bg-gray-900/70 p-4 rounded-lg mb-4 border border-gray-700"> | |
| <h4 className="text-lg font-semibold text-white mb-3 flex items-center gap-3"> | |
| {data.pass | |
| ? <CheckCircleIcon className="w-6 h-6 text-green-400 flex-shrink-0" /> | |
| : <XCircleIcon className="w-6 h-6 text-red-400 flex-shrink-0" /> | |
| } | |
| Overall Assessment | |
| </h4> | |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-2 text-sm pl-9"> | |
| <p><span className="font-semibold text-gray-400">Grade:</span> <span className="text-gray-200">{data.grade}</span></p> | |
| <p><span className="font-semibold text-gray-400">Pass:</span> <span className={`font-medium ${data.pass ? 'text-green-400' : 'text-red-400'}`}>{data.pass ? 'Yes' : 'No'}</span></p> | |
| </div> | |
| <div className="pl-9 mt-3 text-sm"> | |
| <p className="font-semibold text-gray-400">Primary Issue:</p> | |
| <p className="text-gray-300 mt-1">{data.primaryIssue}</p> | |
| </div> | |
| {/* Enhanced Overall Content Display */} | |
| {data.detailedAssessment && ( | |
| <div className="pl-9 mt-3 text-sm"> | |
| <p className="font-semibold text-gray-400">Detailed Assessment:</p> | |
| <div className="text-gray-300 text-xs mt-1 bg-gray-950/50 p-3 rounded-md border border-gray-600/50"> | |
| <ReactMarkdown | |
| components={{ | |
| p: ({ children }) => <p className="mb-2 last:mb-0 text-gray-300">{children}</p>, | |
| ul: ({ children }) => <ul className="list-disc ml-4 mb-2 text-gray-300">{children}</ul>, | |
| ol: ({ children }) => <ol className="list-decimal ml-4 mb-2 text-gray-300">{children}</ol>, | |
| li: ({ children }) => <li className="mb-1 text-gray-300">{children}</li>, | |
| code: ({ children }) => <code className="bg-gray-800 px-1 py-0.5 rounded text-blue-300 text-xs">{children}</code>, | |
| strong: ({ children }) => <strong className="font-semibold text-white">{children}</strong>, | |
| em: ({ children }) => <em className="italic text-gray-200">{children}</em>, | |
| }} | |
| > | |
| {data.detailedAssessment} | |
| </ReactMarkdown> | |
| </div> | |
| </div> | |
| )} | |
| {data.keyStrengths && data.keyStrengths.length > 0 && ( | |
| <div className="pl-9 mt-3 text-sm"> | |
| <p className="font-semibold text-gray-400">Key Strengths:</p> | |
| <ul className="list-disc list-inside text-gray-300 pl-2 mt-1 space-y-1"> | |
| {data.keyStrengths.map((strength, i) => <li key={i}>{strength}</li>)} | |
| </ul> | |
| </div> | |
| )} | |
| {data.recommendations && data.recommendations.length > 0 && ( | |
| <div className="pl-9 mt-3 text-sm"> | |
| <p className="font-semibold text-gray-400">Recommendations:</p> | |
| <ul className="list-disc list-inside text-gray-300 pl-2 mt-1 space-y-1"> | |
| {data.recommendations.map((rec, i) => <li key={i}>{rec}</li>)} | |
| </ul> | |
| </div> | |
| )} | |
| {data.explanations && ( | |
| <div className="pl-9 mt-3 text-sm"> | |
| <p className="font-semibold text-gray-400">Explanations:</p> | |
| <div className="text-gray-300 text-xs mt-1 bg-gray-950/50 p-3 rounded-md border border-gray-600/50"> | |
| <ReactMarkdown | |
| components={{ | |
| p: ({ children }) => <p className="mb-2 last:mb-0 text-gray-300">{children}</p>, | |
| ul: ({ children }) => <ul className="list-disc ml-4 mb-2 text-gray-300">{children}</ul>, | |
| ol: ({ children }) => <ol className="list-decimal ml-4 mb-2 text-gray-300">{children}</ol>, | |
| li: ({ children }) => <li className="mb-1 text-gray-300">{children}</li>, | |
| code: ({ children }) => <code className="bg-gray-800 px-1 py-0.5 rounded text-blue-300 text-xs">{children}</code>, | |
| strong: ({ children }) => <strong className="font-semibold text-white">{children}</strong>, | |
| em: ({ children }) => <em className="italic text-gray-200">{children}</em>, | |
| }} | |
| > | |
| {data.explanations} | |
| </ReactMarkdown> | |
| </div> | |
| </div> | |
| )} | |
| {/* Raw Content for complete transparency */} | |
| {data.rawContent && ( | |
| <div className="pl-9 mt-3 text-sm"> | |
| <p className="font-semibold text-gray-400">Raw Overall Content:</p> | |
| <div className="text-gray-300 text-xs mt-1 bg-gray-950/50 p-3 rounded-md border border-gray-600/50 overflow-auto max-h-32"> | |
| <pre className="whitespace-pre-wrap text-gray-400">{data.rawContent}</pre> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| const AdditionalSections: React.FC<{ sections: DetailedQaReport['additionalSections'] }> = ({ sections }) => { | |
| if (!sections) return null; | |
| return ( | |
| <div className="bg-gray-900/70 p-4 rounded-lg mb-4 border border-gray-700"> | |
| <h4 className="text-lg font-semibold text-white mb-3">Additional QA Sections</h4> | |
| <div className="space-y-3"> | |
| {Object.entries(sections).map(([sectionName, sectionData]) => ( | |
| <div key={sectionName} className="border-l-2 border-gray-600 pl-3"> | |
| <h5 className="text-sm font-semibold text-gray-300 mb-2 capitalize">{sectionName}</h5> | |
| <div className="text-gray-300 text-xs bg-gray-950/50 p-3 rounded-md border border-gray-600/50 overflow-auto max-h-32"> | |
| <ReactMarkdown | |
| components={{ | |
| p: ({ children }) => <p className="mb-2 last:mb-0 text-gray-300">{children}</p>, | |
| ul: ({ children }) => <ul className="list-disc ml-4 mb-2 text-gray-300">{children}</ul>, | |
| ol: ({ children }) => <ol className="list-decimal ml-4 mb-2 text-gray-300">{children}</ol>, | |
| li: ({ children }) => <li className="mb-1 text-gray-300">{children}</li>, | |
| code: ({ children }) => <code className="bg-gray-800 px-1 py-0.5 rounded text-blue-300 text-xs">{children}</code>, | |
| strong: ({ children }) => <strong className="font-semibold text-white">{children}</strong>, | |
| em: ({ children }) => <em className="italic text-gray-200">{children}</em>, | |
| }} | |
| > | |
| {sectionData.content} | |
| </ReactMarkdown> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| return ( | |
| <div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4 z-50" onClick={onClose}> | |
| <div className="bg-gray-800 rounded-lg shadow-xl p-6 max-w-6xl w-full max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}> | |
| <div className="flex justify-between items-center mb-4 pb-4 border-b border-gray-700"> | |
| <h3 className="text-2xl font-bold text-white">Complete QA Report Details</h3> | |
| <button onClick={onClose} className="text-gray-400 hover:text-white text-3xl font-bold">×</button> | |
| </div> | |
| <div className="space-y-2"> | |
| <OverallSection data={report.overall} /> | |
| <AdditionalSections sections={report.additionalSections} /> | |
| {/* Complete Raw Report */} | |
| {report.completeRawReport && ( | |
| <div className="bg-gray-900/70 p-4 rounded-lg mb-4 border border-gray-700"> | |
| <h4 className="text-lg font-semibold text-white mb-3">Complete Raw QA Report</h4> | |
| <div className="text-gray-300 text-xs bg-gray-950/50 p-3 rounded-md border border-gray-600/50 overflow-auto max-h-64"> | |
| <pre className="whitespace-pre-wrap text-gray-400">{report.completeRawReport}</pre> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| const ResultsTable: React.FC<ResultsTableProps> = ({ results }) => { | |
| const [sortConfig, setSortConfig] = useState<SortConfig>(null); | |
| const [selectedReport, setSelectedReport] = useState<DetailedQaReport | null>(null); | |
| const [showDownloadMenu, setShowDownloadMenu] = useState(false); | |
| const downloadMenuRef = useRef<HTMLDivElement>(null); | |
| // Close dropdown when clicking outside | |
| useEffect(() => { | |
| const handleClickOutside = (event: MouseEvent) => { | |
| if (downloadMenuRef.current && !downloadMenuRef.current.contains(event.target as Node)) { | |
| setShowDownloadMenu(false); | |
| } | |
| }; | |
| document.addEventListener('mousedown', handleClickOutside); | |
| return () => { | |
| document.removeEventListener('mousedown', handleClickOutside); | |
| }; | |
| }, []); | |
| const handleDownloadCSV = () => { | |
| if (results.length === 0) return; | |
| const csvData = results.map(row => { | |
| const report = row.detailedQaReport; | |
| return { | |
| 'URL': row.URL, | |
| 'Page': row.Page, | |
| 'Keywords': row.Keywords, | |
| 'Original Title': row.Recommended_Title, | |
| 'Original H1': row.Recommended_H1, | |
| 'Original Copy': row.Copy, | |
| 'Internal Links': row.Internal_Links, | |
| 'Generated Title': row.generatedTitle, | |
| 'Generated H1': row.generatedH1, | |
| 'Generated Meta': row.generatedMeta, | |
| 'Generated Copy': row.generatedCopy, | |
| 'Overall Pass': report?.overall.pass, | |
| 'Overall Grade': report?.overall.grade, | |
| 'Overall Primary Issue': report?.overall.primaryIssue, | |
| 'Overall Detailed Assessment': report?.overall.detailedAssessment, | |
| 'Overall Key Strengths': report?.overall.keyStrengths?.join('; '), | |
| 'Overall Recommendations': report?.overall.recommendations?.join('; '), | |
| 'Overall Explanations': report?.overall.explanations, | |
| 'Additional QA Sections': report?.additionalSections ? | |
| Object.entries(report.additionalSections) | |
| .map(([name, data]) => `${name}: ${data.content.replace(/\r?\n|\r/g, ' ')}`) // Sanitize newlines | |
| .join(' | ') : '', | |
| 'Complete Raw QA Report': report?.completeRawReport?.replace(/\r?\n|\r/g, ' ') || row.qaReport.replace(/\r?\n|\r/g, ' '), // Sanitize newlines | |
| }; | |
| }); | |
| const csv = Papa.unparse(csvData); | |
| const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); | |
| const link = document.createElement('a'); | |
| const url = URL.createObjectURL(blob); | |
| link.setAttribute('href', url); | |
| link.setAttribute('download', 'ace_copywriting_results.csv'); | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| setShowDownloadMenu(false); | |
| }; | |
| const handleDownloadPDF = () => { | |
| if (results.length === 0) return; | |
| const pdf = new jsPDF(); | |
| const pageWidth = pdf.internal.pageSize.getWidth(); | |
| const pageHeight = pdf.internal.pageSize.getHeight(); | |
| const margin = 20; | |
| const maxLineWidth = pageWidth - (margin * 2); | |
| let currentY = margin; | |
| const lineHeight = 6; | |
| const sectionSpacing = 10; | |
| // Helper function to add text with word wrapping and automatic page breaks | |
| const addWrappedText = (text: string, x: number, y: number, maxWidth: number, fontSize: number = 12): number => { | |
| pdf.setFontSize(fontSize); | |
| const lines = pdf.splitTextToSize(text, maxWidth); | |
| let currentLineY = y; | |
| for (let i = 0; i < lines.length; i++) { | |
| // Check if we need a new page for this line | |
| if (currentLineY + lineHeight > pageHeight - margin) { | |
| pdf.addPage(); | |
| currentLineY = margin; | |
| } | |
| pdf.text(lines[i], x, currentLineY); | |
| currentLineY += lineHeight; | |
| } | |
| return currentLineY; | |
| }; | |
| // Helper function to ensure minimum space on page for section headers | |
| const ensureMinSpace = (minHeight: number = 40): number => { | |
| if (currentY + minHeight > pageHeight - margin) { | |
| pdf.addPage(); | |
| return margin; | |
| } | |
| return currentY; | |
| }; | |
| // Title | |
| pdf.setFontSize(20); | |
| pdf.setFont('helvetica', 'bold'); | |
| pdf.text('ACE Copywriting Pipeline Results', margin, currentY); | |
| currentY += 15; | |
| // Summary | |
| pdf.setFontSize(12); | |
| pdf.setFont('helvetica', 'normal'); | |
| const totalResults = results.length; | |
| const passedResults = results.filter(r => r.overallPass).length; | |
| const summaryText = `Generated ${totalResults} results | ${passedResults} passed QA | ${totalResults - passedResults} failed QA`; | |
| currentY = addWrappedText(summaryText, margin, currentY, maxLineWidth); | |
| currentY += sectionSpacing; | |
| // Results | |
| results.forEach((row, index) => { | |
| // Estimate space needed for this entry based on content length | |
| const estimatedHeight = 50 + // Base height for headers and metadata | |
| Math.ceil((row.generatedTitle?.length || 0) / 80) * 6 + // Title | |
| Math.ceil((row.generatedH1?.length || 0) / 80) * 6 + // H1 | |
| Math.ceil((row.generatedMeta?.length || 0) / 80) * 6 + // Meta | |
| Math.ceil((row.generatedCopy?.length || 0) / 100) * 5; // Copy (smaller font) | |
| // Ensure minimum space for section header | |
| currentY = ensureMinSpace(40); | |
| // Page/URL Header | |
| pdf.setFontSize(14); | |
| pdf.setFont('helvetica', 'bold'); | |
| currentY = addWrappedText(`${index + 1}. ${row.Page || 'Page'} (${row.URL})`, margin, currentY, maxLineWidth, 14); | |
| currentY += 5; | |
| // Keywords | |
| pdf.setFontSize(10); | |
| pdf.setFont('helvetica', 'normal'); | |
| currentY = addWrappedText(`Keywords: ${row.Keywords}`, margin, currentY, maxLineWidth, 10); | |
| currentY += 3; | |
| // Overall QA Status | |
| pdf.setFont('helvetica', 'bold'); | |
| if (row.overallPass) { | |
| pdf.setTextColor(0, 128, 0); | |
| } else { | |
| pdf.setTextColor(255, 0, 0); | |
| } | |
| currentY = addWrappedText(`Overall QA: ${row.overallPass ? 'PASS' : 'FAIL'} (${row.overallGrade})`, margin, currentY, maxLineWidth, 10); | |
| pdf.setTextColor(0, 0, 0); | |
| currentY += 5; | |
| // Generated Content | |
| pdf.setFont('helvetica', 'bold'); | |
| currentY = addWrappedText('Generated Title:', margin, currentY, maxLineWidth, 10); | |
| pdf.setFont('helvetica', 'normal'); | |
| currentY = addWrappedText(row.generatedTitle || '', margin, currentY, maxLineWidth, 9); | |
| currentY += 3; | |
| pdf.setFont('helvetica', 'bold'); | |
| currentY = addWrappedText('Generated H1:', margin, currentY, maxLineWidth, 10); | |
| pdf.setFont('helvetica', 'normal'); | |
| currentY = addWrappedText(row.generatedH1 || '', margin, currentY, maxLineWidth, 9); | |
| currentY += 3; | |
| pdf.setFont('helvetica', 'bold'); | |
| currentY = addWrappedText('Generated Meta:', margin, currentY, maxLineWidth, 10); | |
| pdf.setFont('helvetica', 'normal'); | |
| currentY = addWrappedText(row.generatedMeta || '', margin, currentY, maxLineWidth, 9); | |
| currentY += 3; | |
| // Generated Copy (full content) | |
| pdf.setFont('helvetica', 'bold'); | |
| currentY = addWrappedText('Generated Copy:', margin, currentY, maxLineWidth, 10); | |
| pdf.setFont('helvetica', 'normal'); | |
| // Add the full copy content with proper spacing | |
| if (row.generatedCopy) { | |
| currentY = addWrappedText(row.generatedCopy, margin, currentY, maxLineWidth, 9); | |
| } | |
| // Add QA Details if available | |
| if (row.detailedQaReport) { | |
| const report = row.detailedQaReport; | |
| currentY = ensureMinSpace(60); // Ensure space for QA header + at least one section | |
| currentY += 5; | |
| pdf.setFontSize(12); | |
| pdf.setFont('helvetica', 'bold'); | |
| currentY = addWrappedText('QA Report Details:', margin + 5, currentY, maxLineWidth - 5, 12); | |
| currentY += 2; | |
| const addDetailedSection = (title: string, section: QaSectionResult | DetailedQaReport['overall']) => { | |
| const isOverall = 'primaryIssue' in section; | |
| currentY = ensureMinSpace(30); | |
| pdf.setFontSize(10); | |
| pdf.setFont('helvetica', 'bold'); | |
| if (section.pass) { | |
| pdf.setTextColor(0, 128, 0); // Green for PASS | |
| } else { | |
| pdf.setTextColor(255, 0, 0); // Red for FAIL | |
| } | |
| currentY = addWrappedText(`${title}: ${section.pass ? 'PASS' : 'FAIL'} (Grade: ${section.grade})`, margin + 5, currentY, maxLineWidth - 5, 10); | |
| pdf.setTextColor(0, 0, 0); // Reset color | |
| pdf.setFont('helvetica', 'normal'); | |
| if (isOverall && section.primaryIssue) { | |
| currentY = addWrappedText(`Primary Issue: ${section.primaryIssue}`, margin + 10, currentY, maxLineWidth - 10, 8); | |
| } | |
| if (!isOverall && section.errors.length > 0) { | |
| currentY = addWrappedText(`Errors: ${section.errors.join(', ')}`, margin + 10, currentY, maxLineWidth - 10, 8); | |
| } | |
| if (section.detailedAssessment) { | |
| currentY = ensureMinSpace(20); | |
| pdf.setFont('helvetica', 'bold'); | |
| currentY = addWrappedText('Detailed Assessment:', margin + 10, currentY, maxLineWidth - 10, 8); | |
| pdf.setFont('helvetica', 'normal'); | |
| currentY = addWrappedText(section.detailedAssessment, margin + 15, currentY, maxLineWidth - 15, 7); | |
| } | |
| if (section.keyStrengths && section.keyStrengths.length > 0) { | |
| currentY = ensureMinSpace(20); | |
| pdf.setFont('helvetica', 'bold'); | |
| currentY = addWrappedText('Key Strengths:', margin + 10, currentY, maxLineWidth - 10, 8); | |
| pdf.setFont('helvetica', 'normal'); | |
| section.keyStrengths.forEach(strength => { | |
| currentY = addWrappedText(`• ${strength}`, margin + 15, currentY, maxLineWidth - 15, 7); | |
| }); | |
| } | |
| if (section.recommendations && section.recommendations.length > 0) { | |
| currentY = ensureMinSpace(20); | |
| pdf.setFont('helvetica', 'bold'); | |
| currentY = addWrappedText('Recommendations:', margin + 10, currentY, maxLineWidth - 10, 8); | |
| pdf.setFont('helvetica', 'normal'); | |
| section.recommendations.forEach(rec => { | |
| currentY = addWrappedText(`• ${rec}`, margin + 15, currentY, maxLineWidth - 15, 7); | |
| }); | |
| } | |
| if (section.explanations) { | |
| currentY = ensureMinSpace(20); | |
| pdf.setFont('helvetica', 'bold'); | |
| currentY = addWrappedText('Explanations:', margin + 10, currentY, maxLineWidth - 10, 8); | |
| pdf.setFont('helvetica', 'normal'); | |
| currentY = addWrappedText(section.explanations, margin + 15, currentY, maxLineWidth - 15, 7); | |
| } | |
| if (!isOverall && section.corrected) { | |
| currentY = ensureMinSpace(20); | |
| pdf.setFont('helvetica', 'italic'); | |
| currentY = addWrappedText(`Correction/Analysis: ${section.corrected}`, margin + 10, currentY, maxLineWidth - 10, 8); | |
| pdf.setFont('helvetica', 'normal'); | |
| } | |
| currentY += 4; | |
| }; | |
| addDetailedSection('Overall Assessment', report.overall); | |
| // Add Additional Sections if available | |
| if (report.additionalSections) { | |
| currentY = ensureMinSpace(30); | |
| pdf.setFontSize(10); | |
| pdf.setFont('helvetica', 'bold'); | |
| currentY = addWrappedText('Additional QA Sections:', margin + 5, currentY, maxLineWidth - 5, 10); | |
| currentY += 2; | |
| Object.entries(report.additionalSections).forEach(([sectionName, sectionData]) => { | |
| currentY = ensureMinSpace(25); | |
| pdf.setFontSize(9); | |
| pdf.setFont('helvetica', 'bold'); | |
| currentY = addWrappedText(`${sectionName}:`, margin + 10, currentY, maxLineWidth - 10, 9); | |
| pdf.setFont('helvetica', 'normal'); | |
| currentY = addWrappedText(sectionData.content, margin + 15, currentY, maxLineWidth - 15, 7); | |
| currentY += 2; | |
| }); | |
| } | |
| // Add Complete Raw Report | |
| if (report.completeRawReport) { | |
| currentY = ensureMinSpace(30); | |
| pdf.setFontSize(10); | |
| pdf.setFont('helvetica', 'bold'); | |
| currentY = addWrappedText('Complete Raw QA Report:', margin + 5, currentY, maxLineWidth - 5, 10); | |
| currentY += 2; | |
| pdf.setFontSize(7); | |
| pdf.setFont('helvetica', 'normal'); | |
| pdf.setTextColor(100, 100, 100); // Lighter text for raw content | |
| currentY = addWrappedText(report.completeRawReport, margin + 10, currentY, maxLineWidth - 10, 7); | |
| pdf.setTextColor(0, 0, 0); // Reset color | |
| } | |
| } | |
| currentY += sectionSpacing * 2; // Extra spacing between entries | |
| }); | |
| // Footer on last page | |
| pdf.setFontSize(8); | |
| pdf.setTextColor(128, 128, 128); | |
| pdf.text('Generated by ACE Copywriting Pipeline', margin, pageHeight - 10); | |
| // Save the PDF | |
| pdf.save('ace_copywriting_results.pdf'); | |
| setShowDownloadMenu(false); | |
| }; | |
| const handleDownloadJSON = () => { | |
| if (results.length === 0) return; | |
| const jsonData = { | |
| metadata: { | |
| generatedAt: new Date().toISOString(), | |
| totalResults: results.length, | |
| passedResults: results.filter(r => r.overallPass).length, | |
| failedResults: results.filter(r => !r.overallPass).length | |
| }, | |
| results: results.map(row => ({ | |
| url: row.URL, | |
| page: row.Page, | |
| keywords: row.Keywords, | |
| original: { | |
| title: row.Recommended_Title, | |
| h1: row.Recommended_H1, | |
| copy: row.Copy, | |
| internalLinks: row.Internal_Links | |
| }, | |
| generated: { | |
| title: row.generatedTitle, | |
| h1: row.generatedH1, | |
| meta: row.generatedMeta, | |
| copy: row.generatedCopy | |
| }, | |
| qa: { | |
| overallPass: row.overallPass, | |
| overallGrade: row.overallGrade, | |
| sections: { | |
| title: { | |
| pass: row.detailedQaReport?.title.pass, | |
| grade: row.detailedQaReport?.title.grade, | |
| errors: row.detailedQaReport?.title.errors | |
| }, | |
| meta: { | |
| pass: row.detailedQaReport?.meta.pass, | |
| grade: row.detailedQaReport?.meta.grade, | |
| errors: row.detailedQaReport?.meta.errors | |
| }, | |
| h1: { | |
| pass: row.detailedQaReport?.h1.pass, | |
| grade: row.detailedQaReport?.h1.grade, | |
| errors: row.detailedQaReport?.h1.errors | |
| }, | |
| copy: { | |
| pass: row.detailedQaReport?.copy.pass, | |
| grade: row.detailedQaReport?.copy.grade, | |
| errors: row.detailedQaReport?.copy.errors | |
| } | |
| }, | |
| fullReport: row.qaReport | |
| } | |
| })) | |
| }; | |
| const jsonString = JSON.stringify(jsonData, null, 2); | |
| const blob = new Blob([jsonString], { type: 'application/json;charset=utf-8;' }); | |
| const link = document.createElement('a'); | |
| const url = URL.createObjectURL(blob); | |
| link.setAttribute('href', url); | |
| link.setAttribute('download', 'ace_copywriting_results.json'); | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| setShowDownloadMenu(false); | |
| }; | |
| const handleDownloadDOCX = async () => { | |
| if (results.length === 0) return; | |
| // Helper function to clean HTML tags and format text | |
| const cleanText = (text: string): string => { | |
| if (!text) return ''; | |
| return text | |
| .replace(/<[^>]*>/g, '') // Remove HTML tags | |
| .replace(/\*\*([^*]+)\*\*/g, '$1') // Remove markdown bold | |
| .replace(/\*([^*]+)\*/g, '$1') // Remove markdown italic | |
| .replace(/---/g, '') // Remove markdown separators | |
| .replace(/\n\s*\n/g, '\n') // Remove extra line breaks | |
| .trim(); | |
| }; | |
| // Helper function to split text into paragraphs | |
| const splitIntoParagraphs = (text: string): string[] => { | |
| const cleaned = cleanText(text); | |
| return cleaned | |
| .split(/\n+/) | |
| .map(p => p.trim()) | |
| .filter(p => p.length > 0); | |
| }; | |
| const children: any[] = []; | |
| // Title | |
| children.push( | |
| new Paragraph({ | |
| text: "ACE Copywriting Pipeline Results", | |
| heading: HeadingLevel.HEADING_1, | |
| alignment: AlignmentType.CENTER, | |
| spacing: { after: 400 } | |
| }) | |
| ); | |
| // Summary | |
| const totalResults = results.length; | |
| const passedResults = results.filter(r => r.overallPass).length; | |
| const summaryText = `Generated ${totalResults} results | ${passedResults} passed QA | ${totalResults - passedResults} failed QA`; | |
| children.push( | |
| new Paragraph({ | |
| text: summaryText, | |
| spacing: { after: 400 } | |
| }) | |
| ); | |
| // Results | |
| results.forEach((row, index) => { | |
| // Page/URL Header | |
| children.push( | |
| new Paragraph({ | |
| text: `${index + 1}. ${row.Page || 'Page'} (${row.URL})`, | |
| heading: HeadingLevel.HEADING_2, | |
| spacing: { before: 400, after: 200 } | |
| }) | |
| ); | |
| // Keywords | |
| children.push( | |
| new Paragraph({ | |
| text: `Keywords: ${row.Keywords}`, | |
| spacing: { after: 200 } | |
| }) | |
| ); | |
| // Overall QA Status | |
| children.push( | |
| new Paragraph({ | |
| children: [ | |
| new TextRun({ | |
| text: `Overall QA: ${row.overallPass ? 'PASS' : 'FAIL'} (${row.overallGrade})`, | |
| color: row.overallPass ? '008000' : 'FF0000', | |
| bold: true | |
| }) | |
| ], | |
| spacing: { after: 200 } | |
| }) | |
| ); | |
| // Generated Content | |
| children.push( | |
| new Paragraph({ | |
| text: "Generated Title:", | |
| heading: HeadingLevel.HEADING_3, | |
| spacing: { before: 300, after: 100 } | |
| }), | |
| new Paragraph({ | |
| text: cleanText(row.generatedTitle || ''), | |
| spacing: { after: 200 } | |
| }), | |
| new Paragraph({ | |
| text: "Generated H1:", | |
| heading: HeadingLevel.HEADING_3, | |
| spacing: { before: 300, after: 100 } | |
| }), | |
| new Paragraph({ | |
| text: cleanText(row.generatedH1 || ''), | |
| spacing: { after: 200 } | |
| }), | |
| new Paragraph({ | |
| text: "Generated Meta:", | |
| heading: HeadingLevel.HEADING_3, | |
| spacing: { before: 300, after: 100 } | |
| }), | |
| new Paragraph({ | |
| text: cleanText(row.generatedMeta || ''), | |
| spacing: { after: 200 } | |
| }), | |
| new Paragraph({ | |
| text: "Generated Copy:", | |
| heading: HeadingLevel.HEADING_3, | |
| spacing: { before: 300, after: 100 } | |
| }) | |
| ); | |
| // Handle generated copy with proper paragraph breaks | |
| if (row.generatedCopy) { | |
| const copyParagraphs = splitIntoParagraphs(row.generatedCopy); | |
| copyParagraphs.forEach(paragraph => { | |
| children.push( | |
| new Paragraph({ | |
| text: paragraph, | |
| spacing: { after: 150 } | |
| }) | |
| ); | |
| }); | |
| } | |
| // QA Details if available | |
| if (row.detailedQaReport) { | |
| const report = row.detailedQaReport; | |
| children.push( | |
| new Paragraph({ | |
| text: "QA Report Details:", | |
| heading: HeadingLevel.HEADING_3, | |
| spacing: { before: 400, after: 200 } | |
| }) | |
| ); | |
| const addDetailedSection = (title: string, section: QaSectionResult | DetailedQaReport['overall']) => { | |
| const isOverall = 'primaryIssue' in section; | |
| children.push( | |
| new Paragraph({ | |
| children: [ | |
| new TextRun({ | |
| text: `${title}: ${section.pass ? 'PASS' : 'FAIL'} (Grade: ${section.grade})`, | |
| color: section.pass ? '008000' : 'FF0000', | |
| bold: true | |
| }) | |
| ], | |
| heading: HeadingLevel.HEADING_4, | |
| spacing: { before: 300, after: 100 } | |
| }) | |
| ); | |
| if (isOverall && section.primaryIssue) { | |
| children.push(new Paragraph({ text: `Primary Issue: ${cleanText(section.primaryIssue)}`, spacing: { after: 100 } })); | |
| } | |
| if (!isOverall && section.errors && section.errors.length > 0) { | |
| children.push(new Paragraph({ text: `Errors: ${section.errors.join(', ')}`, spacing: { after: 100 } })); | |
| } | |
| if (section.detailedAssessment) { | |
| children.push(new Paragraph({ children: [new TextRun({ text: "Detailed Assessment:", bold: true })], spacing: { after: 50 } })); | |
| splitIntoParagraphs(section.detailedAssessment).forEach(p => children.push(new Paragraph({ text: p, spacing: { after: 100 } }))); | |
| } | |
| if (section.keyStrengths && section.keyStrengths.length > 0) { | |
| children.push(new Paragraph({ children: [new TextRun({ text: "Key Strengths:", bold: true })], spacing: { after: 50 } })); | |
| section.keyStrengths.forEach(s => children.push(new Paragraph({ text: `• ${cleanText(s)}`, spacing: { after: 50 }, indent: { left: 400 } }))); | |
| } | |
| if (section.recommendations && section.recommendations.length > 0) { | |
| children.push(new Paragraph({ children: [new TextRun({ text: "Recommendations:", bold: true })], spacing: { after: 50 } })); | |
| section.recommendations.forEach(r => children.push(new Paragraph({ text: `• ${cleanText(r)}`, spacing: { after: 50 }, indent: { left: 400 } }))); | |
| } | |
| if (section.explanations) { | |
| children.push(new Paragraph({ children: [new TextRun({ text: "Explanations:", bold: true })], spacing: { after: 50 } })); | |
| splitIntoParagraphs(section.explanations).forEach(p => children.push(new Paragraph({ text: p, spacing: { after: 100 } }))); | |
| } | |
| if (!isOverall && section.corrected) { | |
| children.push(new Paragraph({ children: [new TextRun({ text: "Corrected Content:", bold: true })], spacing: { after: 50 } })); | |
| splitIntoParagraphs(section.corrected).forEach(p => children.push(new Paragraph({ text: p, spacing: { after: 100 } }))); | |
| } | |
| }; | |
| addDetailedSection('Overall Assessment', report.overall); | |
| // Add Additional Sections | |
| if (report.additionalSections && Object.keys(report.additionalSections).length > 0) { | |
| children.push(new Paragraph({ text: "Additional QA Sections", heading: HeadingLevel.HEADING_4, spacing: { before: 300, after: 100 } })); | |
| Object.entries(report.additionalSections).forEach(([name, data]) => { | |
| children.push(new Paragraph({ children: [new TextRun({ text: `${name}:`, bold: true })], spacing: { after: 50 } })); | |
| splitIntoParagraphs(data.content).forEach(p => children.push(new Paragraph({ text: p, spacing: { after: 100 }, indent: { left: 400 } }))); | |
| }); | |
| } | |
| // Add Complete Raw Report | |
| if (report.completeRawReport) { | |
| children.push(new Paragraph({ text: "Complete Raw QA Report", heading: HeadingLevel.HEADING_4, spacing: { before: 300, after: 100 } })); | |
| splitIntoParagraphs(report.completeRawReport).forEach(p => { | |
| children.push(new Paragraph({ | |
| children: [ new TextRun({ text: p, size: 16 }) ], // smaller font | |
| spacing: { after: 100 } | |
| })); | |
| }); | |
| } | |
| } | |
| // Add spacing between entries | |
| children.push( | |
| new Paragraph({ | |
| text: "", | |
| spacing: { after: 400 } | |
| }) | |
| ); | |
| }); | |
| // Footer | |
| children.push( | |
| new Paragraph({ | |
| children: [ | |
| new TextRun({ | |
| text: "Generated by ACE Copywriting Pipeline", | |
| color: '808080', | |
| size: 16 | |
| }) | |
| ], | |
| alignment: AlignmentType.CENTER, | |
| spacing: { before: 400 } | |
| }) | |
| ); | |
| const doc = new Document({ | |
| sections: [{ | |
| properties: {}, | |
| children: children | |
| }] | |
| }); | |
| const blob = await Packer.toBlob(doc); | |
| const url = URL.createObjectURL(blob); | |
| const link = document.createElement('a'); | |
| link.setAttribute('href', url); | |
| link.setAttribute('download', 'ace_copywriting_results.docx'); | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| URL.revokeObjectURL(url); | |
| setShowDownloadMenu(false); | |
| }; | |
| const sortedResults = useMemo(() => { | |
| let sortableItems = [...results]; | |
| if (sortConfig !== null) { | |
| sortableItems.sort((a, b) => { | |
| const key = sortConfig.key; | |
| const valA = a[key as keyof typeof a]; | |
| const valB = b[key as keyof typeof b]; | |
| if (typeof valA === 'boolean' && typeof valB === 'boolean') { | |
| if (valA === valB) return 0; | |
| return sortConfig.direction === 'ascending' ? (valA ? -1 : 1) : (valA ? 1 : -1); | |
| } | |
| if (valA < valB) { | |
| return sortConfig.direction === 'ascending' ? -1 : 1; | |
| } | |
| if (valA > valB) { | |
| return sortConfig.direction === 'ascending' ? 1 : -1; | |
| } | |
| return 0; | |
| }); | |
| } | |
| return sortableItems; | |
| }, [results, sortConfig]); | |
| const requestSort = (key: keyof ResultRow) => { | |
| let direction: 'ascending' | 'descending' = 'ascending'; | |
| if (sortConfig && sortConfig.key === key && sortConfig.direction === 'ascending') { | |
| direction = 'descending'; | |
| } | |
| setSortConfig({ key, direction }); | |
| }; | |
| const getSortIndicator = (key: keyof ResultRow) => { | |
| if (!sortConfig || sortConfig.key !== key) { | |
| return ' ↕'; | |
| } | |
| return sortConfig.direction === 'ascending' ? ' ▲' : ' ▼'; | |
| }; | |
| if (results.length === 0) { | |
| return ( | |
| <div className="bg-gray-800 rounded-xl shadow-lg p-6 text-center"> | |
| <h2 className="text-2xl font-semibold text-white">Generated Content</h2> | |
| <p className="mt-4 text-gray-400">No results to display yet. Process a file to see the output here.</p> | |
| </div> | |
| ) | |
| } | |
| return ( | |
| <> | |
| <div className="bg-gray-800 rounded-xl shadow-lg p-6 w-full"> | |
| <div className="flex justify-between items-center mb-4"> | |
| <h2 className="text-2xl font-semibold text-white">Generated Content</h2> | |
| <div className="relative" ref={downloadMenuRef}> | |
| <button | |
| onClick={() => setShowDownloadMenu(!showDownloadMenu)} | |
| className="flex items-center gap-2 bg-green-600 hover:bg-green-700 text-white font-bold py-2 px-4 rounded-lg transition-colors duration-200" | |
| > | |
| <DownloadIcon className="w-5 h-5"/> | |
| Download | |
| <svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> | |
| </svg> | |
| </button> | |
| {showDownloadMenu && ( | |
| <div className="absolute right-0 mt-2 w-48 bg-gray-700 rounded-lg shadow-lg z-10 border border-gray-600"> | |
| <div className="py-1"> | |
| <button | |
| onClick={handleDownloadCSV} | |
| className="w-full text-left px-4 py-2 text-white hover:bg-gray-600 flex items-center gap-2" | |
| > | |
| 📊 Download CSV | |
| </button> | |
| <button | |
| onClick={handleDownloadPDF} | |
| className="w-full text-left px-4 py-2 text-white hover:bg-gray-600 flex items-center gap-2" | |
| > | |
| 📄 Download PDF | |
| </button> | |
| <button | |
| onClick={handleDownloadJSON} | |
| className="w-full text-left px-4 py-2 text-white hover:bg-gray-600 flex items-center gap-2" | |
| > | |
| 💾 Download JSON | |
| </button> | |
| <button | |
| onClick={handleDownloadDOCX} | |
| className="w-full text-left px-4 py-2 text-white hover:bg-gray-600 flex items-center gap-2" | |
| > | |
| 📝 Download DOCX | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| <div className="overflow-x-auto"> | |
| <table className="w-full text-sm text-left text-gray-300"> | |
| <thead className="text-xs text-gray-400 uppercase bg-gray-700"> | |
| <tr> | |
| <th scope="col" className="px-6 py-3 cursor-pointer" onClick={() => requestSort('URL')}>URL{getSortIndicator('URL')}</th> | |
| <th scope="col" className="px-6 py-3">Generated Title</th> | |
| <th scope="col" className="px-6 py-3">Generated H1</th> | |
| <th scope="col" className="px-6 py-3 cursor-pointer" onClick={() => requestSort('overallPass')}>Overall Pass{getSortIndicator('overallPass')}</th> | |
| <th scope="col" className="px-6 py-3 text-center">Details</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {sortedResults | |
| .filter((row, index, arr) => arr.findIndex(r => r.id === row.id) === index) // Remove any runtime duplicates | |
| .map((row) => ( | |
| <tr key={row.id} className="bg-gray-800 border-b border-gray-700 hover:bg-gray-700/50"> | |
| <td className="px-6 py-4 font-medium text-white max-w-xs truncate" title={row.URL}>{row.URL}</td> | |
| <td className="px-6 py-4 max-w-xs truncate" title={row.generatedTitle}>{row.generatedTitle}</td> | |
| <td className="px-6 py-4 max-w-xs truncate" title={row.generatedH1}>{row.generatedH1}</td> | |
| <td className="px-6 py-4"> | |
| <div className="flex justify-center"> | |
| {row.overallPass | |
| ? <CheckCircleIcon className="w-6 h-6 text-green-400" /> | |
| : <XCircleIcon className="w-6 h-6 text-red-400" /> | |
| } | |
| </div> | |
| </td> | |
| <td className="px-6 py-4 text-center"> | |
| <button | |
| onClick={() => { | |
| console.log('Opening QA modal with data:', row.detailedQaReport); | |
| setSelectedReport(row.detailedQaReport || null); | |
| }} | |
| disabled={!row.detailedQaReport} | |
| className="flex items-center justify-center mx-auto gap-1 text-blue-400 hover:text-blue-300 disabled:text-gray-500 disabled:cursor-not-allowed transition-colors" | |
| > | |
| <EyeIcon className="w-5 h-5" /> View | |
| </button> | |
| </td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| {selectedReport && <QAReportModal report={selectedReport} onClose={() => setSelectedReport(null)} />} | |
| </> | |
| ); | |
| }; | |
| export default ResultsTable; |