Spaces:
Sleeping
Sleeping
| "use client"; | |
| import React, { useState, useEffect } from "react"; | |
| import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; | |
| import { Input } from "@/components/ui/Input"; | |
| import { Select } from "@/components/ui/Select"; | |
| import { LoadingSpinner } from "@/components/ui/LoadingSpinner"; | |
| import { getAllConcepts, getCompatibleConcepts } from "@/lib/api/endpoints"; | |
| import { useMatrixStore } from "@/store/matrixStore"; | |
| import type { ConceptInfo, ConceptsResponse } from "@/types/api"; | |
| import { InfoButton } from "@/components/ui/InfoButton"; | |
| interface ConceptSelectorProps { | |
| onSelect?: (concept: ConceptInfo) => void; | |
| selectedConcept?: ConceptInfo | null; | |
| angleKey?: string | null; | |
| } | |
| export const ConceptSelector: React.FC<ConceptSelectorProps> = ({ | |
| onSelect, | |
| selectedConcept, | |
| angleKey, | |
| }) => { | |
| const { concepts, compatibleConcepts, isLoading, setConcepts, setCompatibleConcepts, setIsLoading } = useMatrixStore(); | |
| const [searchTerm, setSearchTerm] = useState(""); | |
| const [selectedCategory, setSelectedCategory] = useState<string>(""); | |
| const [showCompatibleOnly, setShowCompatibleOnly] = useState(false); | |
| useEffect(() => { | |
| if (!concepts) { | |
| loadConcepts(); | |
| } | |
| }, []); | |
| useEffect(() => { | |
| if (angleKey && showCompatibleOnly) { | |
| loadCompatibleConcepts(angleKey); | |
| } | |
| }, [angleKey, showCompatibleOnly]); | |
| const loadConcepts = async () => { | |
| setIsLoading(true); | |
| try { | |
| const data = await getAllConcepts(); | |
| setConcepts(data); | |
| } catch (error) { | |
| console.error("Failed to load concepts:", error); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| const loadCompatibleConcepts = async (key: string) => { | |
| try { | |
| const data = await getCompatibleConcepts(key); | |
| // Convert compatible concepts to full ConceptInfo by fetching from all concepts | |
| if (concepts) { | |
| const fullConcepts: ConceptInfo[] = []; | |
| for (const compat of data.compatible_concepts) { | |
| // Find full concept info from all concepts | |
| let found = false; | |
| for (const [catKey, cat] of Object.entries(concepts.categories)) { | |
| const fullConcept = cat.concepts.find((c) => c.key === compat.key); | |
| if (fullConcept) { | |
| // Add category to the concept | |
| fullConcepts.push({ | |
| ...fullConcept, | |
| category: cat.name, | |
| }); | |
| found = true; | |
| break; | |
| } | |
| } | |
| // If not found, create a minimal version | |
| if (!found) { | |
| fullConcepts.push({ | |
| key: compat.key, | |
| name: compat.name, | |
| structure: compat.structure, | |
| visual: "", | |
| category: "", | |
| }); | |
| } | |
| } | |
| setCompatibleConcepts(fullConcepts); | |
| } else { | |
| // Fallback to minimal concepts if full concepts not loaded | |
| const minimalConcepts: ConceptInfo[] = data.compatible_concepts.map((c): ConceptInfo => ({ | |
| key: c.key, | |
| name: c.name, | |
| structure: c.structure, | |
| visual: "", | |
| category: "", | |
| })); | |
| setCompatibleConcepts(minimalConcepts); | |
| } | |
| } catch (error) { | |
| console.error("Failed to load compatible concepts:", error); | |
| } | |
| }; | |
| if (isLoading && !concepts) { | |
| return ( | |
| <Card> | |
| <CardContent className="pt-6"> | |
| <LoadingSpinner size="lg" /> | |
| </CardContent> | |
| </Card> | |
| ); | |
| } | |
| if (!concepts) { | |
| return ( | |
| <Card> | |
| <CardContent className="pt-6"> | |
| <p className="text-gray-500">Failed to load concepts</p> | |
| </CardContent> | |
| </Card> | |
| ); | |
| } | |
| // Filter concepts | |
| let filteredConcepts: Array<{ category: string; concept: any }> = []; | |
| const conceptsToUse = showCompatibleOnly && compatibleConcepts.length > 0 | |
| ? compatibleConcepts | |
| : Object.values(concepts.categories).flatMap((cat) => cat.concepts); | |
| Object.entries(concepts.categories).forEach(([catKey, catData]) => { | |
| if (selectedCategory && catKey !== selectedCategory) return; | |
| catData.concepts.forEach((concept) => { | |
| if ( | |
| (!showCompatibleOnly || compatibleConcepts.some((c) => c.key === concept.key)) && | |
| (!searchTerm || | |
| concept.name.toLowerCase().includes(searchTerm.toLowerCase()) || | |
| concept.structure.toLowerCase().includes(searchTerm.toLowerCase()) || | |
| concept.key.toLowerCase().includes(searchTerm.toLowerCase())) | |
| ) { | |
| filteredConcepts.push({ category: catData.name, concept }); | |
| } | |
| }); | |
| }); | |
| const categories = Object.entries(concepts.categories).map(([key, data]) => ({ | |
| value: key, | |
| label: data.name, | |
| })); | |
| return ( | |
| <Card variant="glass" className="border-2 border-transparent hover:border-cyan-200/50 transition-all duration-300"> | |
| <CardHeader> | |
| <div className="flex items-center gap-2"> | |
| <CardTitle className="bg-gradient-to-r from-cyan-600 to-pink-600 bg-clip-text text-transparent"> | |
| Select Concept | |
| </CardTitle> | |
| <InfoButton | |
| title="What is a Concept?" | |
| content="A concept is the creative execution style or storyline you use to deliver your angle. It defines how your ad will look and feel visually. | |
| Concepts include things like: | |
| - Before/After comparisons | |
| - Testimonials | |
| - Problem/Solution narratives | |
| - Visual metaphors | |
| - Lifestyle imagery | |
| Each concept has a specific structure and visual direction. When combined with an angle, they create a powerful ad that both hooks attention and drives action. Some concepts work better with certain angles - use the 'Show compatible concepts only' option to see recommended pairings." | |
| position="bottom" | |
| /> | |
| </div> | |
| {angleKey && ( | |
| <div className="mt-2"> | |
| <label className="flex items-center space-x-2"> | |
| <input | |
| type="checkbox" | |
| checked={showCompatibleOnly} | |
| onChange={(e) => setShowCompatibleOnly(e.target.checked)} | |
| className="rounded" | |
| /> | |
| <span className="text-sm text-gray-600">Show compatible concepts only</span> | |
| </label> | |
| </div> | |
| )} | |
| </CardHeader> | |
| <CardContent className="space-y-4"> | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> | |
| <Input | |
| placeholder="Search concepts..." | |
| value={searchTerm} | |
| onChange={(e) => setSearchTerm(e.target.value)} | |
| /> | |
| <Select | |
| options={[{ value: "", label: "All Categories" }, ...categories]} | |
| value={selectedCategory} | |
| onChange={(e) => setSelectedCategory(e.target.value)} | |
| /> | |
| </div> | |
| <div className="max-h-96 overflow-y-auto space-y-2"> | |
| {filteredConcepts.length === 0 ? ( | |
| <p className="text-center text-gray-500 py-8">No concepts found</p> | |
| ) : ( | |
| filteredConcepts.map(({ category, concept }) => ( | |
| <div | |
| key={concept.key} | |
| onClick={() => onSelect?.(concept)} | |
| className={`p-3 rounded-lg border cursor-pointer transition-all duration-300 ${ | |
| selectedConcept?.key === concept.key | |
| ? "border-cyan-500 bg-gradient-to-r from-cyan-50 to-pink-50 shadow-md ring-2 ring-cyan-200" | |
| : "border-gray-200 hover:border-cyan-300 hover:bg-gradient-to-r hover:from-gray-50 hover:to-cyan-50/30 hover:shadow-sm" | |
| }`} | |
| > | |
| <div className="flex items-start justify-between"> | |
| <div className="flex-1"> | |
| <h4 className={`font-semibold transition-colors ${ | |
| selectedConcept?.key === concept.key | |
| ? "text-cyan-700" | |
| : "text-gray-900" | |
| }`}> | |
| {concept.name} | |
| </h4> | |
| <p className={`text-sm mt-1 transition-colors ${ | |
| selectedConcept?.key === concept.key | |
| ? "text-cyan-600" | |
| : "text-gray-600" | |
| }`}> | |
| {concept.structure} | |
| </p> | |
| <p className="text-xs text-gray-500 mt-1">{category}</p> | |
| </div> | |
| </div> | |
| </div> | |
| )) | |
| )} | |
| </div> | |
| </CardContent> | |
| </Card> | |
| ); | |
| }; | |