Initial commit of the Ad Generator Lite project, including backend services, frontend components, and configuration files. Added core functionalities for ad generation, user management, and image processing, along with a structured matrix system for ad testing.
f201243
| "use client"; | |
| import React, { useState } from "react"; | |
| import { useRouter } from "next/navigation"; | |
| import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/Card"; | |
| import { Button } from "@/components/ui/Button"; | |
| import { Select } from "@/components/ui/Select"; | |
| import { ProgressBar } from "@/components/ui/ProgressBar"; | |
| import { generateTestingMatrix, generateMatrixAd } from "@/lib/api/endpoints"; | |
| import { exportAsJSON, exportAsCSV } from "@/lib/utils/export"; | |
| import { toast } from "react-hot-toast"; | |
| import { Download, FileJson, FileSpreadsheet, Rocket, CheckSquare, Square } from "lucide-react"; | |
| import { IMAGE_MODELS } from "@/lib/constants/models"; | |
| import type { TestingMatrixResponse, CombinationInfo } from "@/types/api"; | |
| import type { Niche } from "@/types/api"; | |
| interface TestingMatrixBuilderProps { | |
| onMatrixGenerated?: (matrix: TestingMatrixResponse) => void; | |
| } | |
| export const TestingMatrixBuilder: React.FC<TestingMatrixBuilderProps> = ({ | |
| onMatrixGenerated, | |
| }) => { | |
| const router = useRouter(); | |
| const [niche, setNiche] = useState<Niche>("home_insurance"); | |
| const [strategy, setStrategy] = useState<"balanced" | "top_performers" | "diverse">("balanced"); | |
| const [angleCount, setAngleCount] = useState(6); | |
| const [conceptCount, setConceptCount] = useState(5); | |
| const [matrix, setMatrix] = useState<TestingMatrixResponse | null>(null); | |
| const [isGenerating, setIsGenerating] = useState(false); | |
| const [selectedCombinations, setSelectedCombinations] = useState<Set<string>>(new Set()); | |
| const [isGeneratingAds, setIsGeneratingAds] = useState(false); | |
| const [generationProgress, setGenerationProgress] = useState({ current: 0, total: 0 }); | |
| const [numVariations, setNumVariations] = useState(1); | |
| const [imageModel, setImageModel] = useState<string | null>(null); | |
| const handleGenerate = async () => { | |
| setIsGenerating(true); | |
| try { | |
| const result = await generateTestingMatrix({ | |
| niche, | |
| angle_count: angleCount, | |
| concept_count: conceptCount, | |
| strategy, | |
| }); | |
| setMatrix(result); | |
| onMatrixGenerated?.(result); | |
| toast.success(`Generated ${result.summary.total_combinations} combinations!`); | |
| } catch (error: any) { | |
| toast.error(error.message || "Failed to generate testing matrix"); | |
| } finally { | |
| setIsGenerating(false); | |
| } | |
| }; | |
| const handleExportJSON = () => { | |
| if (!matrix) return; | |
| exportAsJSON(matrix, `testing-matrix-${niche}-${Date.now()}.json`); | |
| toast.success("Matrix exported as JSON"); | |
| }; | |
| const handleExportCSV = () => { | |
| if (!matrix) return; | |
| const csvData = matrix.combinations.map((combo) => ({ | |
| combination_id: combo.combination_id, | |
| angle_key: combo.angle.key, | |
| angle_name: combo.angle.name, | |
| angle_trigger: combo.angle.trigger, | |
| concept_key: combo.concept.key, | |
| concept_name: combo.concept.name, | |
| compatibility_score: combo.compatibility_score, | |
| })); | |
| exportAsCSV(csvData, `testing-matrix-${niche}-${Date.now()}.csv`); | |
| toast.success("Matrix exported as CSV"); | |
| }; | |
| const toggleCombination = (combinationId: string) => { | |
| const newSelected = new Set(selectedCombinations); | |
| if (newSelected.has(combinationId)) { | |
| newSelected.delete(combinationId); | |
| } else { | |
| newSelected.add(combinationId); | |
| } | |
| setSelectedCombinations(newSelected); | |
| }; | |
| const selectAll = () => { | |
| if (!matrix) return; | |
| if (selectedCombinations.size === matrix.combinations.length) { | |
| setSelectedCombinations(new Set()); | |
| } else { | |
| setSelectedCombinations(new Set(matrix.combinations.map(c => c.combination_id))); | |
| } | |
| }; | |
| const handleGenerateAds = async () => { | |
| if (!matrix) return; | |
| const combinationsToGenerate = selectedCombinations.size > 0 | |
| ? matrix.combinations.filter(c => selectedCombinations.has(c.combination_id)) | |
| : matrix.combinations; | |
| if (combinationsToGenerate.length === 0) { | |
| toast.error("Please select at least one combination"); | |
| return; | |
| } | |
| setIsGeneratingAds(true); | |
| setGenerationProgress({ current: 0, total: combinationsToGenerate.length }); | |
| try { | |
| const generatedAds = []; | |
| for (let i = 0; i < combinationsToGenerate.length; i++) { | |
| const combo = combinationsToGenerate[i]; | |
| setGenerationProgress({ current: i + 1, total: combinationsToGenerate.length }); | |
| try { | |
| const result = await generateMatrixAd({ | |
| niche, | |
| angle_key: combo.angle.key, | |
| concept_key: combo.concept.key, | |
| num_images: numVariations, | |
| image_model: imageModel, | |
| }); | |
| generatedAds.push(result); | |
| toast.success(`Generated ad ${i + 1}/${combinationsToGenerate.length}`); | |
| } catch (error: any) { | |
| console.error(`Failed to generate ad for combination ${combo.combination_id}:`, error); | |
| toast.error(`Failed to generate ad ${i + 1}/${combinationsToGenerate.length}`); | |
| } | |
| } | |
| toast.success(`Successfully generated ${generatedAds.length} ads!`); | |
| // Navigate to gallery to see the results | |
| setTimeout(() => { | |
| router.push("/gallery"); | |
| }, 1500); | |
| } catch (error: any) { | |
| toast.error(error.message || "Failed to generate ads"); | |
| } finally { | |
| setIsGeneratingAds(false); | |
| setGenerationProgress({ current: 0, total: 0 }); | |
| } | |
| }; | |
| return ( | |
| <div className="space-y-6"> | |
| <Card variant="glass"> | |
| <CardHeader> | |
| <CardTitle>Testing Matrix Builder</CardTitle> | |
| <CardDescription> | |
| Generate a systematic testing matrix for ad optimization | |
| </CardDescription> | |
| </CardHeader> | |
| <CardContent className="space-y-6"> | |
| <Select | |
| label="Niche" | |
| options={[ | |
| { value: "home_insurance", label: "Home Insurance" }, | |
| { value: "glp1", label: "GLP-1" }, | |
| ]} | |
| value={niche} | |
| onChange={(e) => setNiche(e.target.value as Niche)} | |
| /> | |
| <Select | |
| label="Strategy" | |
| options={[ | |
| { value: "balanced", label: "Balanced - Mix of top performers and diverse" }, | |
| { value: "top_performers", label: "Top Performers - Focus on proven winners" }, | |
| { value: "diverse", label: "Diverse - Maximum variety" }, | |
| ]} | |
| value={strategy} | |
| onChange={(e) => setStrategy(e.target.value as typeof strategy)} | |
| /> | |
| <div> | |
| <label className="block text-sm font-medium text-gray-700 mb-2"> | |
| Number of Angles: {angleCount} | |
| </label> | |
| <input | |
| type="range" | |
| min="1" | |
| max="10" | |
| step="1" | |
| className="w-full" | |
| value={angleCount} | |
| onChange={(e) => setAngleCount(Number(e.target.value))} | |
| /> | |
| <div className="flex justify-between text-xs text-gray-500 mt-1"> | |
| <span>1</span> | |
| <span>10</span> | |
| </div> | |
| </div> | |
| <div> | |
| <label className="block text-sm font-medium text-gray-700 mb-2"> | |
| Concepts per Angle: {conceptCount} | |
| </label> | |
| <input | |
| type="range" | |
| min="1" | |
| max="10" | |
| step="1" | |
| className="w-full" | |
| value={conceptCount} | |
| onChange={(e) => setConceptCount(Number(e.target.value))} | |
| /> | |
| <div className="flex justify-between text-xs text-gray-500 mt-1"> | |
| <span>1</span> | |
| <span>10</span> | |
| </div> | |
| </div> | |
| <div className="bg-blue-50 border border-blue-200 rounded-lg p-4"> | |
| <p className="text-sm text-blue-800"> | |
| <strong>Total Combinations:</strong> {angleCount} × {conceptCount} = {angleCount * conceptCount} | |
| </p> | |
| </div> | |
| <Button | |
| variant="primary" | |
| size="lg" | |
| className="w-full" | |
| onClick={handleGenerate} | |
| isLoading={isGenerating} | |
| > | |
| Generate Testing Matrix | |
| </Button> | |
| </CardContent> | |
| </Card> | |
| {matrix && ( | |
| <> | |
| <Card variant="glass"> | |
| <CardHeader> | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| <CardTitle>Matrix Summary</CardTitle> | |
| <CardDescription> | |
| {matrix.summary.total_combinations} combinations ready for testing | |
| </CardDescription> | |
| </div> | |
| <div className="flex space-x-2"> | |
| <Button variant="outline" size="sm" onClick={handleExportJSON}> | |
| <FileJson className="h-4 w-4 mr-1" /> | |
| JSON | |
| </Button> | |
| <Button variant="outline" size="sm" onClick={handleExportCSV}> | |
| <FileSpreadsheet className="h-4 w-4 mr-1" /> | |
| CSV | |
| </Button> | |
| </div> | |
| </div> | |
| </CardHeader> | |
| <CardContent> | |
| <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6"> | |
| <div> | |
| <p className="text-sm text-gray-600">Total Combinations</p> | |
| <p className="text-2xl font-bold text-gray-900">{matrix.summary.total_combinations}</p> | |
| </div> | |
| <div> | |
| <p className="text-sm text-gray-600">Unique Angles</p> | |
| <p className="text-2xl font-bold text-gray-900">{matrix.summary.unique_angles}</p> | |
| </div> | |
| <div> | |
| <p className="text-sm text-gray-600">Unique Concepts</p> | |
| <p className="text-2xl font-bold text-gray-900">{matrix.summary.unique_concepts}</p> | |
| </div> | |
| <div> | |
| <p className="text-sm text-gray-600">Avg Compatibility</p> | |
| <p className="text-2xl font-bold text-gray-900"> | |
| {(matrix.summary.average_compatibility * 100).toFixed(0)}% | |
| </p> | |
| </div> | |
| </div> | |
| {/* Generate Ads Section */} | |
| <div className="bg-gradient-to-r from-blue-50 to-cyan-50 border border-blue-200 rounded-xl p-6 mb-6"> | |
| <div className="flex items-center justify-between mb-4"> | |
| <div> | |
| <h3 className="text-lg font-bold text-gray-900 mb-1">Generate Ads from Matrix</h3> | |
| <p className="text-sm text-gray-600"> | |
| Select combinations to generate ads, or generate all | |
| </p> | |
| </div> | |
| </div> | |
| <div className="mb-4"> | |
| <label className="block text-sm font-medium text-gray-700 mb-2"> | |
| Variations per Combination: {numVariations} | |
| </label> | |
| <input | |
| type="range" | |
| min="1" | |
| max="3" | |
| step="1" | |
| className="w-full accent-blue-500" | |
| value={numVariations} | |
| onChange={(e) => setNumVariations(Number(e.target.value))} | |
| /> | |
| <div className="flex justify-between text-xs text-gray-500 mt-1"> | |
| <span>1</span> | |
| <span>3</span> | |
| </div> | |
| </div> | |
| <div className="mb-4"> | |
| <Select | |
| label="Image Model" | |
| options={IMAGE_MODELS.map(model => ({ value: model.value, label: model.label }))} | |
| value={imageModel || ""} | |
| onChange={(e) => setImageModel(e.target.value || null)} | |
| /> | |
| </div> | |
| <div className="flex items-center justify-between mb-4"> | |
| <div className="flex items-center space-x-2"> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={selectAll} | |
| > | |
| {selectedCombinations.size === matrix.combinations.length ? ( | |
| <> | |
| <CheckSquare className="h-4 w-4 mr-1" /> | |
| Deselect All | |
| </> | |
| ) : ( | |
| <> | |
| <Square className="h-4 w-4 mr-1" /> | |
| Select All | |
| </> | |
| )} | |
| </Button> | |
| <span className="text-sm text-gray-600"> | |
| {selectedCombinations.size > 0 | |
| ? `${selectedCombinations.size} selected` | |
| : "All combinations will be generated"} | |
| </span> | |
| </div> | |
| <Button | |
| variant="primary" | |
| size="lg" | |
| onClick={handleGenerateAds} | |
| isLoading={isGeneratingAds} | |
| disabled={isGeneratingAds} | |
| > | |
| <Rocket className="h-5 w-5 mr-2" /> | |
| Generate Ads | |
| </Button> | |
| </div> | |
| {isGeneratingAds && generationProgress.total > 0 && ( | |
| <div className="mt-4"> | |
| <div className="flex items-center justify-between mb-2"> | |
| <span className="text-sm font-medium text-gray-700"> | |
| Generating ads... | |
| </span> | |
| <span className="text-sm text-gray-600"> | |
| {generationProgress.current} / {generationProgress.total} | |
| </span> | |
| </div> | |
| <ProgressBar | |
| progress={(generationProgress.current / generationProgress.total) * 100} | |
| /> | |
| </div> | |
| )} | |
| <div className="bg-blue-100 border border-blue-300 rounded-lg p-3 mt-4"> | |
| <p className="text-xs text-blue-800"> | |
| <strong>Note:</strong> This will generate{" "} | |
| {selectedCombinations.size > 0 | |
| ? `${selectedCombinations.size} × ${numVariations} = ${selectedCombinations.size * numVariations}` | |
| : `${matrix.combinations.length} × ${numVariations} = ${matrix.combinations.length * numVariations}`}{" "} | |
| total ad variations. This may take several minutes. | |
| </p> | |
| </div> | |
| </div> | |
| {/* Combinations List */} | |
| <div className="max-h-96 overflow-y-auto space-y-2"> | |
| {matrix.combinations.map((combo) => { | |
| const isSelected = selectedCombinations.has(combo.combination_id); | |
| return ( | |
| <div | |
| key={combo.combination_id} | |
| onClick={() => toggleCombination(combo.combination_id)} | |
| className={`p-3 border rounded-lg cursor-pointer transition-all ${ | |
| isSelected | |
| ? "border-blue-500 bg-blue-50 shadow-md" | |
| : "border-gray-200 hover:bg-gray-50 hover:border-gray-300" | |
| }`} | |
| > | |
| <div className="flex items-start justify-between"> | |
| <div className="flex items-start space-x-3 flex-1"> | |
| <div className="mt-0.5"> | |
| {isSelected ? ( | |
| <CheckSquare className="h-5 w-5 text-blue-600" /> | |
| ) : ( | |
| <Square className="h-5 w-5 text-gray-400" /> | |
| )} | |
| </div> | |
| <div className="flex-1"> | |
| <div className="flex items-center space-x-2 mb-1"> | |
| <span className="text-sm font-semibold text-gray-900"> | |
| {combo.angle.name} | |
| </span> | |
| <span className="text-gray-400">×</span> | |
| <span className="text-sm font-semibold text-gray-900"> | |
| {combo.concept.name} | |
| </span> | |
| </div> | |
| <p className="text-xs text-gray-500">{combo.angle.trigger}</p> | |
| <p className="text-xs text-gray-500 mt-1">{combo.concept.structure}</p> | |
| </div> | |
| </div> | |
| <div className="ml-4 text-right"> | |
| <span className="text-xs font-medium text-blue-600"> | |
| {(combo.compatibility_score * 100).toFixed(0)}% | |
| </span> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </CardContent> | |
| </Card> | |
| </> | |
| )} | |
| </div> | |
| ); | |
| }; | |