diff --git a/src/components/ai/AICustomFeatureCreator.tsx b/src/components/ai/AICustomFeatureCreator.tsx index 0e893c0..147ee05 100644 --- a/src/components/ai/AICustomFeatureCreator.tsx +++ b/src/components/ai/AICustomFeatureCreator.tsx @@ -31,23 +31,80 @@ export function AICustomFeatureCreator({ projectType, onAdd, onClose, + editingFeature, }: { projectType?: string onAdd: (feature: { name: string; description: string; complexity: Complexity; logic_rules?: string[]; requirements?: Array<{ text: string; rules: string[] }>; business_rules?: Array<{ requirement: string; rules: string[] }> }) => void onClose: () => void + editingFeature?: { + id: string; + name: string; + description: string; + complexity: Complexity; + business_rules?: any; + technical_requirements?: any; + additional_business_rules?: any; + } }) { - const [featureName, setFeatureName] = useState('') - const [featureDescription, setFeatureDescription] = useState('') - const [selectedComplexity, setSelectedComplexity] = useState(undefined) + const [featureName, setFeatureName] = useState(editingFeature?.name || '') + const [featureDescription, setFeatureDescription] = useState(editingFeature?.description || '') + const [selectedComplexity, setSelectedComplexity] = useState(editingFeature?.complexity || undefined) const [isAnalyzing, setIsAnalyzing] = useState(false) - const [aiAnalysis, setAiAnalysis] = useState(null) + const [aiAnalysis, setAiAnalysis] = useState(() => { + if (editingFeature) { + return { + suggested_name: editingFeature.name, + complexity: editingFeature.complexity, + confidence_score: 1, + } + } + return null + }) const [analysisError, setAnalysisError] = useState(null) - const [requirements, setRequirements] = useState>([ - { text: '', rules: [] }, - ]) + const [requirements, setRequirements] = useState>(() => { + // Initialize requirements from existing feature data + if (editingFeature) { + console.log('🔍 Editing feature data:', editingFeature) + try { + // Try to get business rules from multiple sources + let businessRules = null; + + // First try direct business_rules field + if (editingFeature.business_rules) { + console.log('📋 Found business_rules field:', editingFeature.business_rules) + businessRules = Array.isArray(editingFeature.business_rules) + ? editingFeature.business_rules + : JSON.parse(editingFeature.business_rules) + } + // Then try additional_business_rules from feature_business_rules table + else if ((editingFeature as any).additional_business_rules) { + console.log('📋 Found additional_business_rules field:', (editingFeature as any).additional_business_rules) + businessRules = Array.isArray((editingFeature as any).additional_business_rules) + ? (editingFeature as any).additional_business_rules + : JSON.parse((editingFeature as any).additional_business_rules) + } + + console.log('📋 Parsed business rules:', businessRules) + + if (businessRules && Array.isArray(businessRules) && businessRules.length > 0) { + const requirements = businessRules.map((rule: any) => ({ + text: rule.requirement || rule.text || '', + rules: rule.rules || [] + })) + console.log('📋 Mapped requirements:', requirements) + return requirements + } + } catch (error) { + console.error('Error parsing business rules:', error) + } + } + return [{ text: '', rules: [] }] + }) const [analyzingIdx, setAnalyzingIdx] = useState(null) + const hasAnyAnalysis = !!aiAnalysis || requirements.some(r => (r.rules || []).length > 0) const handleAnalyze = async () => { + if (hasAnyAnalysis) return if (!featureDescription.trim() && requirements.every(r => !r.text.trim())) return setIsAnalyzing(true) setAnalysisError(null) @@ -100,7 +157,9 @@ export function AICustomFeatureCreator({ const handleAnalyzeRequirement = async (idx: number) => { const req = requirements[idx] + if (hasAnyAnalysis) return if (!req?.text?.trim()) return + if ((req.rules || []).length > 0) return setAnalyzingIdx(idx) setAnalysisError(null) try { @@ -138,14 +197,10 @@ export function AICustomFeatureCreator({ const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() - if (!aiAnalysis) { - await handleAnalyze() - return - } onAdd({ - name: aiAnalysis.suggested_name || featureName.trim() || 'Custom Feature', + name: aiAnalysis?.suggested_name || featureName.trim() || 'Custom Feature', description: featureDescription.trim(), - complexity: aiAnalysis.complexity || selectedComplexity || 'medium', + complexity: aiAnalysis?.complexity || selectedComplexity || 'medium', logic_rules: requirements.flatMap(r => r.rules || []), requirements: requirements, business_rules: requirements.map(r => ({ requirement: r.text, rules: r.rules || [] })), @@ -158,7 +213,9 @@ export function AICustomFeatureCreator({
-

AI-Powered Feature Creator

+

+ {editingFeature ? 'Edit Custom Feature' : 'AI-Powered Feature Creator'} +

@@ -197,10 +254,10 @@ export function AICustomFeatureCreator({ type="button" variant="outline" onClick={() => handleAnalyzeRequirement(idx)} - disabled={isAnalyzing || analyzingIdx === idx || !r.text.trim()} + disabled={isAnalyzing || analyzingIdx === idx || !r.text.trim() || hasAnyAnalysis || (r.rules || []).length > 0} className="border-white/20 text-white hover:bg-white/10" > - {analyzingIdx === idx ? 'Analyzing…' : (r.rules?.length ? 'Re-analyze' : 'Analyze With AI')} + {analyzingIdx === idx ? 'Analyzing…' : (((r.rules || []).length > 0) || hasAnyAnalysis ? 'Analyzed' : 'Analyze With AI')}
@@ -286,6 +346,4 @@ export function AICustomFeatureCreator({ ) } -export default AICustomFeatureCreator - - +export default AICustomFeatureCreator \ No newline at end of file diff --git a/src/components/apis/authApiClients.tsx b/src/components/apis/authApiClients.tsx index 6920936..0cd12a1 100644 --- a/src/components/apis/authApiClients.tsx +++ b/src/components/apis/authApiClients.tsx @@ -100,7 +100,8 @@ const addTokenRefreshInterceptor = (client: typeof authApiClient) => { (response) => response, async (error) => { const originalRequest = error.config; - if (error.response?.status === 401 && !originalRequest._retry) { + const isRefreshEndpoint = originalRequest?.url?.includes('/api/auth/refresh'); + if (error.response?.status === 401 && !originalRequest._retry && !isRefreshEndpoint) { originalRequest._retry = true; try { if (refreshToken) { @@ -112,6 +113,11 @@ const addTokenRefreshInterceptor = (client: typeof authApiClient) => { originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; return client(originalRequest); } + // No refresh token available + clearTokens(); + safeLocalStorage.removeItem('codenuk_user'); + window.location.href = '/signin'; + return Promise.reject(error); } catch (refreshError) { console.error('Token refresh failed:', refreshError); clearTokens(); diff --git a/src/components/dual-canvas-editor.tsx b/src/components/dual-canvas-editor.tsx index d80057f..01688c2 100644 --- a/src/components/dual-canvas-editor.tsx +++ b/src/components/dual-canvas-editor.tsx @@ -21,6 +21,7 @@ interface DualCanvasEditorProps { onGenerationStart?: () => void selectedDevice?: 'desktop' | 'tablet' | 'mobile' onDeviceChange?: (device: 'desktop' | 'tablet' | 'mobile') => void + initialPrompt?: string } export function DualCanvasEditor({ @@ -28,7 +29,8 @@ export function DualCanvasEditor({ onWireframeGenerated, onGenerationStart, selectedDevice = 'desktop', - onDeviceChange + onDeviceChange, + initialPrompt }: DualCanvasEditorProps) { const [activeId, setActiveId] = useState(null) const [canvasMode, setCanvasMode] = useState<'wireframe' | 'components'>('wireframe') @@ -216,6 +218,7 @@ export function DualCanvasEditor({ className="shrink-0" selectedDevice={selectedDevice} onDeviceChange={handleDeviceChange} + initialPrompt={initialPrompt} /> ) : ( diff --git a/src/components/main-dashboard.tsx b/src/components/main-dashboard.tsx index 64eb0d5..8178550 100644 --- a/src/components/main-dashboard.tsx +++ b/src/components/main-dashboard.tsx @@ -7,7 +7,6 @@ import { Input } from "@/components/ui/input" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" import { Checkbox } from "@/components/ui/checkbox" -import { EditFeatureForm } from "@/components/edit-feature-form" import { ArrowRight, Plus, Globe, BarChart3, Zap, Code, Search, Star, Clock, Users, Layers, AlertCircle, Edit, Trash2, User, Palette } from "lucide-react" import { useTemplates } from "@/hooks/useTemplates" import { CustomTemplateForm } from "@/components/custom-template-form" @@ -20,6 +19,7 @@ import { Tooltip } from "@/components/ui/tooltip" import WireframeCanvas from "@/components/wireframe-canvas" import PromptSidePanel from "@/components/prompt-side-panel" import { DualCanvasEditor } from "@/components/dual-canvas-editor" +import { getAccessToken } from "@/components/apis/authApiClients" interface Template { id: string @@ -857,14 +857,14 @@ function FeatureSelectionStep({ const [editingFeature, setEditingFeature] = useState(null) const handleUpdate = async (f: TemplateFeature, updates: Partial) => { - const idForApi = f.feature_type === 'custom' ? (f.feature_id?.replace(/^custom_/, '') || f.id) : f.id - await updateFeature(idForApi, { ...updates, isCustom: f.feature_type === 'custom' }) + // Use the actual id field directly (no need to extract from feature_id) + await updateFeature(f.id, { ...updates, isCustom: f.feature_type === 'custom' }) await load() } const handleDelete = async (f: TemplateFeature) => { - const idForApi = f.feature_type === 'custom' ? (f.feature_id?.replace(/^custom_/, '') || f.id) : f.id - await deleteFeature(idForApi, { isCustom: f.feature_type === 'custom' }) + // Use the actual id field directly (no need to extract from feature_id) + await deleteFeature(f.id, { isCustom: f.feature_type === 'custom' }) setSelectedIds((prev) => { const next = new Set(prev) next.delete(f.id) @@ -977,23 +977,25 @@ function FeatureSelectionStep({ {section('Your Custom Features', custom)} - {showAIModal && ( + {(showAIModal || editingFeature) && ( { await handleAddAIAnalyzed(f); setShowAIModal(false) }} - onClose={() => setShowAIModal(false)} - /> - )} - - {editingFeature && ( - { - await handleUpdate(editingFeature, updates) + onAdd={async (f) => { + if (editingFeature) { + // Update existing feature + await handleUpdate(editingFeature, f) + setEditingFeature(null) + } else { + // Add new feature + await handleAddAIAnalyzed(f) + setShowAIModal(false) + } + }} + onClose={() => { + setShowAIModal(false) setEditingFeature(null) }} - onCancel={() => setEditingFeature(null)} - isOpen={!!editingFeature} + editingFeature={editingFeature || undefined} /> )} @@ -1037,9 +1039,10 @@ function BusinessQuestionsStep({ setError('No features selected') return } - const resp = await fetch(`${BACKEND_URL}/api/v1/generate-comprehensive-business-questions`, { + const token = getAccessToken() + const resp = await fetch(`${BACKEND_URL}/api/questions/generate-comprehensive-business-questions`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify({ allFeatures: selected, projectName: template.title, @@ -1327,6 +1330,7 @@ function AIMockupStep({ const [wireframeData, setWireframeData] = useState(null) const [isGenerating, setIsGenerating] = useState(false) const [selectedDevice, setSelectedDevice] = useState<'desktop' | 'tablet' | 'mobile'>('desktop') + const [initialPrompt, setInitialPrompt] = useState("") // Load state from localStorage after component mounts useEffect(() => { @@ -1352,6 +1356,17 @@ function AIMockupStep({ } }, []) + // Build prompt from selected features and template + useEffect(() => { + if (!template) return + const featureNames = (selectedFeatures || []).map(f => f.name).filter(Boolean) + const base = `${template.title} dashboard` + const parts = featureNames.length > 0 ? ` with ${featureNames.join(', ')}` : '' + const footer = '. Optimize layout and spacing. Include navigation.' + const prompt = `${base}${parts}${footer}` + setInitialPrompt(prompt) + }, [template, JSON.stringify(selectedFeatures)]) + const handleWireframeGenerated = (data: any) => { setWireframeData(data) setIsGenerating(false) @@ -1414,6 +1429,7 @@ function AIMockupStep({ onGenerationStart={handleWireframeGenerationStart} selectedDevice={selectedDevice} onDeviceChange={handleDeviceChange} + initialPrompt={initialPrompt} /> diff --git a/src/components/prompt-side-panel.tsx b/src/components/prompt-side-panel.tsx index b7a114c..302fe04 100644 --- a/src/components/prompt-side-panel.tsx +++ b/src/components/prompt-side-panel.tsx @@ -1,6 +1,6 @@ "use client" -import { useMemo, useState, useEffect } from "react" +import { useMemo, useState, useEffect, useRef } from "react" import { Button } from "@/components/ui/button" import { Textarea } from "@/components/ui/textarea" import { ScrollArea } from "@/components/ui/scroll-area" @@ -11,11 +11,13 @@ import { getAIMockupHealthUrl, AI_MOCKUP_CONFIG } from "@/lib/api-config" export function PromptSidePanel({ className, selectedDevice = 'desktop', - onDeviceChange + onDeviceChange, + initialPrompt }: { className?: string selectedDevice?: 'desktop' | 'tablet' | 'mobile' onDeviceChange?: (device: 'desktop' | 'tablet' | 'mobile') => void + initialPrompt?: string }) { const [collapsed, setCollapsed] = useState(false) const [prompt, setPrompt] = useState( @@ -23,6 +25,7 @@ export function PromptSidePanel({ ) const [isGenerating, setIsGenerating] = useState(false) const [backendStatus, setBackendStatus] = useState<'connected' | 'disconnected' | 'checking'>('checking') + const autoTriggeredRef = useRef(false) const examples = useMemo( () => [ @@ -66,6 +69,25 @@ export function PromptSidePanel({ return () => clearInterval(interval) }, []) + // Update prompt from parent when provided + useEffect(() => { + if (typeof initialPrompt === 'string' && initialPrompt.trim().length > 0) { + setPrompt(initialPrompt) + } + }, [initialPrompt]) + + // Auto-generate once when an initial prompt arrives + useEffect(() => { + if (!autoTriggeredRef.current && typeof initialPrompt === 'string' && initialPrompt.trim().length > 0) { + autoTriggeredRef.current = true + // Slight delay to ensure canvas listeners are mounted + const id = setTimeout(() => { + dispatchGenerate(initialPrompt) + }, 300) + return () => clearTimeout(id) + } + }, [initialPrompt]) + const dispatchGenerate = async (text: string) => { setIsGenerating(true) @@ -208,7 +230,7 @@ export function PromptSidePanel({ )} -
+ {/*

Examples

    @@ -226,7 +248,7 @@ export function PromptSidePanel({ ))}
-
+
*/} {/* AI Features Info */}
diff --git a/src/components/wireframe-canvas.tsx b/src/components/wireframe-canvas.tsx index ffadfee..c679c5e 100644 --- a/src/components/wireframe-canvas.tsx +++ b/src/components/wireframe-canvas.tsx @@ -1375,6 +1375,19 @@ export default function WireframeCanvas({ return (
+ {/* Centered loading overlay during generation */} + {busy && ( +
+
+
+ Generating wireframe… +
+
+ )} {error && (
@@ -1391,7 +1404,7 @@ export default function WireframeCanvas({ )} {busy && ( -
+
Generating wireframe with AI... diff --git a/src/lib/api/admin.ts b/src/lib/api/admin.ts index 6accafc..6bc5a52 100644 --- a/src/lib/api/admin.ts +++ b/src/lib/api/admin.ts @@ -409,9 +409,31 @@ export const featureApi = { technical_requirements?: Record; created_by_user_session?: string; }): Promise<{ data: AdminFeature; similarityInfo?: Record }> => { + const normalizeJsonField = (value: unknown): unknown => { + if (value === undefined || value === null || value === '') return undefined; + if (typeof value === 'string') { + try { + const t = value.trim(); + if ((t.startsWith('{') && t.endsWith('}')) || (t.startsWith('[') && t.endsWith(']'))) { + return JSON.parse(t); + } + return value; + } catch { + return undefined; + } + } + return value; + }; + + const cleaned = { + ...featureData, + business_rules: normalizeJsonField(featureData.business_rules) as Record, + technical_requirements: normalizeJsonField(featureData.technical_requirements) as Record, + }; + const response = await apiCall('/api/features/custom', { method: 'POST', - body: JSON.stringify(featureData), + body: JSON.stringify(cleaned), }); return { diff --git a/src/lib/template-service.ts b/src/lib/template-service.ts index 9c73043..7f79de0 100644 --- a/src/lib/template-service.ts +++ b/src/lib/template-service.ts @@ -442,9 +442,37 @@ class TemplateService { } async createFeature(featureData: CreateFeaturePayload): Promise { + // Normalize potentially stringified JSON fields to proper JSON before sending + const normalizeJsonField = (value: unknown): unknown => { + if (value === undefined || value === null || value === '') return undefined + if (typeof value === 'string') { + try { + const trimmed = value.trim() + if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) { + return JSON.parse(trimmed) + } + // If it's a plain string not JSON, leave as-is for non-JSONB fields + return value + } catch { + // Leave as-is; backend will reject invalid JSON, but we prefer to avoid sending broken JSONB + return undefined + } + } + return value + } + + const cleanedFeatureData: CreateFeaturePayload = { + ...featureData, + // Only include normalized fields if valid + business_rules: normalizeJsonField(featureData.business_rules) as CreateFeaturePayload['business_rules'], + logic_rules: normalizeJsonField(featureData.logic_rules) as CreateFeaturePayload['logic_rules'], + // @ts-expect-error: allow passthrough of optional technical_requirements if present in callers + technical_requirements: normalizeJsonField((featureData as any).technical_requirements), + } + if ( - featureData && - (featureData.feature_type === 'custom') + cleanedFeatureData && + (cleanedFeatureData.feature_type === 'custom') ) { const customHeaders: Record = { 'Content-Type': 'application/json' } const customToken = getAccessToken() @@ -454,7 +482,7 @@ class TemplateService { const response = await fetch(`${BACKEND_URL}/api/features/custom`, { method: 'POST', headers: customHeaders, - body: JSON.stringify(featureData), + body: JSON.stringify(cleanedFeatureData), }) if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`) const data = await response.json() @@ -469,7 +497,7 @@ class TemplateService { const response = await fetch(`${BACKEND_URL}/api/features`, { method: 'POST', headers, - body: JSON.stringify(featureData), + body: JSON.stringify(cleanedFeatureData), }) if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`) const data = await response.json()