2792 lines
114 KiB
TypeScript
2792 lines
114 KiB
TypeScript
"use client"
|
||
|
||
import { useState, useEffect } from "react"
|
||
import { Button } from "@/components/ui/button"
|
||
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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
|
||
import { ArrowRight, Plus, Globe, BarChart3, Zap, Code, Search, Star, Clock, Users, Layers, AlertCircle, Edit, Trash2, User, Palette, GitBranch } from "lucide-react"
|
||
import { useTemplates } from "@/hooks/useTemplates"
|
||
import { CustomTemplateForm } from "@/components/custom-template-form"
|
||
import { EditTemplateForm } from "@/components/edit-template-form"
|
||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"
|
||
import { DatabaseTemplate, TemplateFeature } from "@/lib/template-service"
|
||
import AICustomFeatureCreator from "@/components/ai/AICustomFeatureCreator"
|
||
import { BACKEND_URL } from "@/config/backend"
|
||
// Removed Tooltip import as we are no longer using tooltips for title/description
|
||
import WireframeCanvas from "@/components/wireframe-canvas"
|
||
import TypeformSurvey, { SurveySummary } from "@/components/business-context/typeform-survey"
|
||
import PromptSidePanel from "@/components/prompt-side-panel"
|
||
import { DualCanvasEditor } from "@/components/dual-canvas-editor"
|
||
import { getAccessToken } from "@/components/apis/authApiClients"
|
||
import TechStackSummary from "@/components/tech-stack-summary"
|
||
import { attachRepository, detectProvider, connectProvider, AttachRepositoryResponse } from "@/lib/api/vcs"
|
||
import { getGitHubAuthStatus } from "@/lib/api/github"
|
||
import ViewUserReposButton from "@/components/github/ViewUserReposButton"
|
||
import { ErrorBanner } from "@/components/ui/error-banner"
|
||
import { useAuth } from "@/contexts/auth-context"
|
||
import { authApiClient } from "@/components/apis/authApiClients"
|
||
|
||
interface Template {
|
||
id: string
|
||
title: string
|
||
description: string
|
||
category: string
|
||
features: string[]
|
||
complexity: number
|
||
timeEstimate: string
|
||
techStack: string[]
|
||
popularity?: number
|
||
lastUpdated?: string
|
||
type?: string
|
||
icon?: string | null
|
||
gradient?: string | null
|
||
border?: string | null
|
||
text?: string | null
|
||
subtext?: string | null
|
||
is_active?: boolean
|
||
created_at?: string
|
||
updated_at?: string
|
||
featureCount?: number
|
||
is_custom?: boolean; // Add this field to identify custom templates
|
||
}
|
||
|
||
function TemplateSelectionStep({ onNext }: { onNext: (template: Template) => void }) {
|
||
const { user } = useAuth()
|
||
const [selectedCategory, setSelectedCategory] = useState("all")
|
||
const [searchQuery, setSearchQuery] = useState("")
|
||
const [showCustomForm, setShowCustomForm] = useState(false)
|
||
const [showCreateOptionDialog, setShowCreateOptionDialog] = useState(false)
|
||
const [showGitForm, setShowGitForm] = useState(false)
|
||
const [gitProvider, setGitProvider] = useState('')
|
||
const [gitUrl, setGitUrl] = useState('')
|
||
const [gitBranch, setGitBranch] = useState('main')
|
||
const [gitAuthMethod, setGitAuthMethod] = useState('')
|
||
const [gitCredentials, setGitCredentials] = useState({
|
||
username: '',
|
||
password: '',
|
||
token: '',
|
||
sshKey: ''
|
||
})
|
||
const [authLoading, setAuthLoading] = useState(false)
|
||
const [gitStep, setGitStep] = useState<'provider' | 'url'>('provider')
|
||
|
||
// Sync progress state for private repositories
|
||
const [syncProgress, setSyncProgress] = useState<{
|
||
show: boolean;
|
||
provider: string;
|
||
repositoryUrl: string;
|
||
branchName: string;
|
||
status: string;
|
||
stage: string;
|
||
} | null>(null)
|
||
|
||
// Monitor private repository sync progress
|
||
const monitorPrivateRepoSync = async (provider: string, repositoryUrl: string) => {
|
||
let pollCount = 0;
|
||
const maxPolls = 60; // 2 minutes with 2-second intervals (reduced from 5 minutes)
|
||
|
||
const pollInterval = setInterval(async () => {
|
||
pollCount++;
|
||
|
||
try {
|
||
// Poll for repository sync status
|
||
const response = await authApiClient.get(`/api/vcs/${provider}/repositories?t=${Date.now()}`, {
|
||
headers: { 'x-user-id': user?.id }
|
||
});
|
||
|
||
if (response.data?.success) {
|
||
const repositories = response.data.data || [];
|
||
const repo = repositories.find((r: any) => r.repository_url === repositoryUrl);
|
||
|
||
if (repo) {
|
||
const status = repo.sync_status;
|
||
let stage = '';
|
||
|
||
switch (status) {
|
||
case 'authenticating':
|
||
stage = 'Authenticating with provider...';
|
||
break;
|
||
case 'syncing':
|
||
stage = 'Downloading repository files...';
|
||
break;
|
||
case 'synced':
|
||
stage = 'Repository sync completed!';
|
||
clearInterval(pollInterval);
|
||
setSyncProgress(null);
|
||
alert(`✅ Repository attached successfully!\n\nProvider: ${provider.toUpperCase()}\nRepository: ${repositoryUrl}\n\nYour repository is now available in your repositories list.`);
|
||
// Refresh repositories list
|
||
window.location.reload();
|
||
return;
|
||
case 'error':
|
||
stage = 'Sync failed. Please try again.';
|
||
clearInterval(pollInterval);
|
||
setTimeout(() => {
|
||
setSyncProgress(null);
|
||
}, 3000);
|
||
alert(`❌ Repository sync failed!\n\nProvider: ${provider.toUpperCase()}\nRepository: ${repositoryUrl}\n\nPlease try again or contact support.`);
|
||
return;
|
||
default:
|
||
stage = 'Processing...';
|
||
}
|
||
|
||
setSyncProgress(prev => prev ? { ...prev, status, stage } : null);
|
||
} else {
|
||
// Repository not found in list yet, continue polling
|
||
setSyncProgress(prev => prev ? { ...prev, stage: 'Processing...' } : null);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Error monitoring sync:', error);
|
||
// If we get too many errors, stop polling
|
||
if (pollCount > 10) {
|
||
clearInterval(pollInterval);
|
||
setSyncProgress(null);
|
||
alert(`⚠️ Unable to monitor repository sync status.\n\nRepository: ${repositoryUrl}\n\nPlease check your repositories list manually.`);
|
||
}
|
||
}
|
||
|
||
// Stop polling after max attempts
|
||
if (pollCount >= maxPolls) {
|
||
clearInterval(pollInterval);
|
||
setSyncProgress(null);
|
||
alert(`⏰ Repository sync is taking longer than expected.\n\nRepository: ${repositoryUrl}\n\nPlease check your repositories list manually.`);
|
||
}
|
||
}, 2000); // Poll every 2 seconds
|
||
}
|
||
const [isGithubConnected, setIsGithubConnected] = useState<boolean | null>(null)
|
||
const [connectionError, setConnectionError] = useState<string | null>(null)
|
||
|
||
// Cleanup sync progress on component unmount
|
||
useEffect(() => {
|
||
return () => {
|
||
setSyncProgress(null);
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
(async () => {
|
||
try {
|
||
setConnectionError(null)
|
||
const status = await getGitHubAuthStatus()
|
||
setIsGithubConnected(!!status?.data?.connected)
|
||
} catch (error: any) {
|
||
console.warn('Failed to check GitHub auth status:', error)
|
||
setIsGithubConnected(false)
|
||
|
||
// Check if it's a connectivity issue
|
||
if (error?.code === 'ECONNREFUSED' || error?.code === 'ENOTFOUND' || error?.code === 'ETIMEDOUT') {
|
||
setConnectionError('Unable to connect to the server. Please ensure the backend is running.')
|
||
} else if (error?.response?.status >= 500) {
|
||
setConnectionError('Server error. Please try again later.')
|
||
}
|
||
}
|
||
})()
|
||
}, [])
|
||
|
||
// Handle OAuth callback for all providers with enhanced private repo flow
|
||
useEffect(() => {
|
||
const handleVcsCallback = async () => {
|
||
// Check for private repo sync info from project-builder redirect
|
||
try {
|
||
const syncInfo = sessionStorage.getItem('private_repo_sync');
|
||
if (syncInfo) {
|
||
const { provider, repositoryUrl, branchName, syncStatus } = JSON.parse(syncInfo);
|
||
console.log(`🔄 [Main Dashboard] Restoring private repo sync:`, { provider, repositoryUrl, syncStatus });
|
||
|
||
setSyncProgress({
|
||
show: true,
|
||
provider,
|
||
repositoryUrl,
|
||
branchName,
|
||
status: syncStatus,
|
||
stage: 'Starting sync...'
|
||
});
|
||
|
||
// Start monitoring sync progress
|
||
monitorPrivateRepoSync(provider, repositoryUrl);
|
||
|
||
// Clear the stored info
|
||
sessionStorage.removeItem('private_repo_sync');
|
||
}
|
||
} catch (e) {
|
||
console.warn('Failed to restore sync info:', e);
|
||
}
|
||
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
const oauthSuccess = urlParams.get('oauth_success');
|
||
const provider = urlParams.get('provider');
|
||
const syncPrivateRepo = urlParams.get('sync_private_repo');
|
||
const repositoryUrl = urlParams.get('repository_url');
|
||
const branchName = urlParams.get('branch_name');
|
||
const syncStatus = urlParams.get('sync_status');
|
||
|
||
// Handle OAuth success redirect from backend
|
||
if (oauthSuccess === 'true' && provider) {
|
||
console.log(`🔐 [VCS OAuth] OAuth success for ${provider.toUpperCase()}`);
|
||
|
||
if (syncPrivateRepo === 'true' && repositoryUrl) {
|
||
console.log(`🔄 [VCS OAuth] Starting private repository sync monitoring: ${repositoryUrl}`);
|
||
|
||
// Show progress notification for private repo sync
|
||
setSyncProgress({
|
||
show: true,
|
||
provider: provider,
|
||
repositoryUrl: repositoryUrl,
|
||
branchName: branchName || 'main',
|
||
status: syncStatus || 'authenticating',
|
||
stage: 'Starting sync...'
|
||
});
|
||
|
||
// Start monitoring sync progress
|
||
monitorPrivateRepoSync(provider, repositoryUrl);
|
||
|
||
// Clean up URL parameters but keep sync progress visible
|
||
const newUrl = window.location.pathname;
|
||
window.history.replaceState({}, document.title, newUrl);
|
||
|
||
} else {
|
||
const attachRepo = urlParams.get('attach_repo');
|
||
|
||
if (attachRepo === 'true' && repositoryUrl) {
|
||
console.log(`🔄 [VCS OAuth] Auto-attaching repository after OAuth: ${repositoryUrl}`);
|
||
|
||
try {
|
||
// Automatically attach the repository
|
||
const response = await authApiClient.post(`/api/vcs/${provider}/attach-repository`, {
|
||
repository_url: repositoryUrl,
|
||
branch_name: branchName || undefined,
|
||
user_id: user?.id
|
||
}, {
|
||
headers: {
|
||
'x-user-id': user?.id
|
||
}
|
||
});
|
||
|
||
if (response.data?.success) {
|
||
alert(`✅ ${provider.toUpperCase()} account connected and repository attached successfully!`);
|
||
|
||
// Clean up URL parameters
|
||
const newUrl = window.location.pathname;
|
||
window.history.replaceState({}, document.title, newUrl);
|
||
|
||
// Reset form and close dialogs
|
||
setShowGitForm(false);
|
||
setShowCreateOptionDialog(false);
|
||
setGitUrl('');
|
||
setGitBranch('main');
|
||
setGitProvider('');
|
||
} else {
|
||
alert(`${provider.toUpperCase()} account connected, but failed to attach repository. Please try again.`);
|
||
}
|
||
} catch (attachError) {
|
||
console.error('Failed to attach repository after OAuth:', attachError);
|
||
alert(`${provider.toUpperCase()} account connected, but failed to attach repository. Please try again.`);
|
||
}
|
||
} else {
|
||
console.log(`🔐 [VCS OAuth] ${provider.toUpperCase()} account connected successfully`);
|
||
alert(`${provider.toUpperCase()} account connected successfully!`);
|
||
|
||
// Clean up URL parameters
|
||
const newUrl = window.location.pathname;
|
||
window.history.replaceState({}, document.title, newUrl);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
handleVcsCallback();
|
||
}, [user])
|
||
const [editingTemplate, setEditingTemplate] = useState<DatabaseTemplate | null>(null)
|
||
|
||
|
||
// Authentication handlers for different providers
|
||
const handleOAuthAuth = async (provider: string) => {
|
||
setAuthLoading(true)
|
||
try {
|
||
console.log(`🔐 [handleOAuthAuth] Starting OAuth for provider: ${provider}`)
|
||
|
||
// Use the new VCS API for all providers
|
||
await connectProvider(provider)
|
||
} catch (error) {
|
||
console.error('OAuth error:', error)
|
||
alert('OAuth authentication failed')
|
||
} finally {
|
||
setAuthLoading(false)
|
||
}
|
||
}
|
||
|
||
const handleTokenAuth = async (provider: string, token: string) => {
|
||
setAuthLoading(true)
|
||
try {
|
||
const providerConfig = gitProviders[provider as keyof typeof gitProviders]
|
||
|
||
// Validate token with provider API
|
||
const response = await fetch(`${providerConfig.apiEndpoint}/user`, {
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Accept': 'application/json'
|
||
}
|
||
})
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Invalid token')
|
||
}
|
||
|
||
const userData = await response.json()
|
||
alert(`Authenticated as ${userData.login || userData.username || userData.name}`)
|
||
return true
|
||
} catch (error) {
|
||
console.error('Token auth error:', error)
|
||
alert('Token authentication failed')
|
||
return false
|
||
} finally {
|
||
setAuthLoading(false)
|
||
}
|
||
}
|
||
|
||
const handleUsernamePasswordAuth = async (provider: string, username: string, password: string) => {
|
||
setAuthLoading(true)
|
||
try {
|
||
// For username/password auth, you'd typically use Basic Auth
|
||
// or convert to token-based auth
|
||
const credentials = btoa(`${username}:${password}`)
|
||
|
||
const response = await fetch(`${gitProviders[provider as keyof typeof gitProviders].apiEndpoint}/user`, {
|
||
headers: {
|
||
'Authorization': `Basic ${credentials}`,
|
||
'Accept': 'application/json'
|
||
}
|
||
})
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Invalid credentials')
|
||
}
|
||
|
||
const userData = await response.json()
|
||
alert(`Authenticated as ${userData.login || userData.username || userData.name}`)
|
||
return true
|
||
} catch (error) {
|
||
console.error('Username/password auth error:', error)
|
||
alert('Authentication failed')
|
||
return false
|
||
} finally {
|
||
setAuthLoading(false)
|
||
}
|
||
}
|
||
|
||
// Provider-specific configuration
|
||
const gitProviders = {
|
||
github: {
|
||
name: 'GitHub',
|
||
icon: 'M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z',
|
||
placeholder: 'https://github.com/org/repo.git',
|
||
authMethods: ['token', 'ssh', 'oauth'],
|
||
oauthEndpoint: '/api/vcs/github/auth/start',
|
||
apiEndpoint: 'https://api.github.com'
|
||
},
|
||
bitbucket: {
|
||
name: 'Bitbucket',
|
||
icon: 'M.778 1.213a.768.768 0 00-.768.892l3.263 19.81c.084.5.515.868 1.022.873H19.95a.772.772 0 00.77-.646l3.27-20.03a.768.768 0 00-.768-.891L.778 1.213zM14.518 18.197H9.482l-1.4-8.893h7.436l-1.4 8.893z',
|
||
placeholder: 'https://bitbucket.org/org/repo.git',
|
||
authMethods: ['username_password', 'app_password', 'oauth'],
|
||
oauthEndpoint: '/api/vcs/bitbucket/auth/start',
|
||
apiEndpoint: 'https://api.bitbucket.org/2.0'
|
||
},
|
||
gitlab: {
|
||
name: 'GitLab',
|
||
icon: 'M23.6004 9.5927l-.0337-.0862L20.3.9814a.851.851 0 0 0-.3362-.405.874.874 0 0 0-.9997.0539.874.874 0 0 0-.29.4399l-2.5465 7.7838H7.2162l-2.5465-7.7838a.857.857 0 0 0-.29-.4412.874.874 0 0 0-.9997-.0537.858.858 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.065 6.065 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.008 1.008 0 0 0 1.2197 0l1.4995-1.1321 2.4619-1.8633 5.006-3.7466.0125-.01a6.068 6.068 0 0 0 2.0094-7.003z',
|
||
placeholder: 'https://gitlab.com/org/repo.git',
|
||
authMethods: ['token', 'oauth', 'ssh'],
|
||
oauthEndpoint: '/api/vcs/gitlab/auth/start',
|
||
apiEndpoint: 'https://gitlab.com/api/v4'
|
||
},
|
||
gitea: {
|
||
name: 'Gitea',
|
||
icon: 'M12 0C5.374 0 0 5.373 0 12s5.374 12 12 12 12-5.373 12-12S18.626 0 12 0zm0 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm-1-6h2v2h-2v-2zm0-8h2v6h-2V8z',
|
||
placeholder: 'https://gitea.com/org/repo.git',
|
||
authMethods: ['token', 'oauth', 'ssh'],
|
||
oauthEndpoint: '/api/vcs/gitea/auth/start',
|
||
apiEndpoint: 'https://gitea.com/api/v1'
|
||
}
|
||
}
|
||
const [deletingTemplate, setDeletingTemplate] = useState<DatabaseTemplate | null>(null)
|
||
const [deleteLoading, setDeleteLoading] = useState(false)
|
||
// Keep a stable list of all categories seen so the filter chips don't disappear
|
||
const [knownCategories, setKnownCategories] = useState<Set<string>>(new Set(["all"]))
|
||
// Cache counts per category using the API totals for each filtered fetch
|
||
// Use undefined to indicate "unknown" instead of showing wrong initial counts
|
||
const [categoryCounts, setCategoryCounts] = useState<Record<string, number | undefined>>({ all: 0 })
|
||
// Track per-card expanded state for descriptions
|
||
const [expandedDescriptions, setExpandedDescriptions] = useState<Record<string, boolean>>({})
|
||
const [descDialogOpen, setDescDialogOpen] = useState(false)
|
||
const [descDialogData, setDescDialogData] = useState<{ title: string; description: string }>({ title: '', description: '' })
|
||
|
||
const {
|
||
user: templateUser,
|
||
combined,
|
||
loading,
|
||
error,
|
||
paginationState,
|
||
createTemplate,
|
||
updateTemplate,
|
||
deleteTemplate,
|
||
fetchTemplatesWithPagination,
|
||
loadMoreTemplates,
|
||
categories: hookCategories,
|
||
} = useTemplates()
|
||
|
||
// Initial fetch is handled inside useTemplates hook; avoid duplicate fetch here
|
||
|
||
// Handle category changes immediately (no debounce) so switching chips is snappy
|
||
useEffect(() => {
|
||
if (selectedCategory !== paginationState.selectedCategory) {
|
||
console.log('[TemplateSelectionStep] Triggering fetch due to category change:', { selectedCategory });
|
||
fetchTemplatesWithPagination({
|
||
page: 0,
|
||
pageSize: paginationState.pageSize,
|
||
category: selectedCategory,
|
||
search: searchQuery,
|
||
resetPagination: true,
|
||
});
|
||
}
|
||
}, [selectedCategory, paginationState.selectedCategory, paginationState.pageSize, searchQuery, fetchTemplatesWithPagination]);
|
||
|
||
// Handle search changes with debouncing
|
||
useEffect(() => {
|
||
const timeoutId = setTimeout(() => {
|
||
if (searchQuery !== paginationState.searchQuery) {
|
||
console.log('[TemplateSelectionStep] Triggering fetch due to search change:', { searchQuery });
|
||
fetchTemplatesWithPagination({
|
||
page: 0,
|
||
pageSize: paginationState.pageSize,
|
||
category: selectedCategory,
|
||
search: searchQuery,
|
||
resetPagination: true,
|
||
});
|
||
}
|
||
}, 500);
|
||
return () => clearTimeout(timeoutId);
|
||
}, [searchQuery, selectedCategory, paginationState.searchQuery, paginationState.pageSize, fetchTemplatesWithPagination]);
|
||
|
||
// Track categories seen across any fetch so the chips remain visible
|
||
useEffect(() => {
|
||
if (!combined?.data?.length) return;
|
||
setKnownCategories((prev) => {
|
||
const next = new Set(prev);
|
||
combined.data.forEach((t) => {
|
||
if (t.category) next.add(t.category);
|
||
});
|
||
return next;
|
||
});
|
||
}, [combined?.data]);
|
||
|
||
// Seed known categories and counts from hookCategories (pre-fetched full counts)
|
||
useEffect(() => {
|
||
if (!hookCategories || hookCategories.length === 0) return;
|
||
setKnownCategories((prev) => {
|
||
const next = new Set(prev);
|
||
hookCategories.forEach((c) => next.add(c.id));
|
||
return next;
|
||
});
|
||
setCategoryCounts((prev) => {
|
||
const next: Record<string, number | undefined> = { ...prev };
|
||
hookCategories.forEach((c) => {
|
||
next[c.id] = c.count;
|
||
});
|
||
// Ensure 'all' exists if provided by hook
|
||
const allFromHook = hookCategories.find((c) => c.id === 'all')?.count;
|
||
if (typeof allFromHook === 'number') next['all'] = allFromHook;
|
||
return next;
|
||
});
|
||
}, [hookCategories]);
|
||
|
||
// Update counts cache based on API totals for the currently selected category
|
||
useEffect(() => {
|
||
const currentCat = paginationState.selectedCategory || 'all';
|
||
const totalForFilter = paginationState.total || 0;
|
||
setCategoryCounts((prev) => ({
|
||
...prev,
|
||
[currentCat]: totalForFilter,
|
||
}));
|
||
}, [paginationState.selectedCategory, paginationState.total]);
|
||
|
||
const templates: Template[] = combined?.data?.length
|
||
? combined.data.map((t) => {
|
||
console.log('[TemplateSelectionStep] Processing template:', {
|
||
id: t.id,
|
||
title: t.title,
|
||
category: t.category,
|
||
feature_count: t.feature_count
|
||
});
|
||
return {
|
||
id: t.id,
|
||
title: t.title,
|
||
description: t.description || "No description available",
|
||
category: t.category,
|
||
features: [],
|
||
complexity: 3, // Default complexity since DatabaseTemplate doesn't have this property
|
||
timeEstimate: "2-4 weeks",
|
||
techStack: ["Next.js", "PostgreSQL", "Tailwind CSS"],
|
||
popularity: t.avg_rating ? Math.round(t.avg_rating * 20) : 75,
|
||
lastUpdated: t.updated_at ? new Date(t.updated_at).toISOString().split('T')[0] : undefined,
|
||
type: t.type,
|
||
icon: t.icon,
|
||
gradient: t.gradient,
|
||
border: t.border,
|
||
text: t.text,
|
||
subtext: t.subtext,
|
||
is_active: t.is_active,
|
||
created_at: t.created_at,
|
||
updated_at: t.updated_at,
|
||
featureCount: t.feature_count || 0, // Add feature count from API
|
||
is_custom: t.is_custom, // Add is_custom field
|
||
};
|
||
})
|
||
: [];
|
||
|
||
// Debug logging
|
||
console.log('[TemplateSelectionStep] Debug:', {
|
||
hasCombined: !!combined,
|
||
hasData: !!combined?.data,
|
||
dataType: typeof combined?.data,
|
||
isArray: Array.isArray(combined?.data),
|
||
dataLength: combined?.data?.length || 0,
|
||
templatesLength: templates.length,
|
||
paginationState,
|
||
templates: templates.map((t) => ({ id: t.id, title: t.title, category: t.category })),
|
||
});
|
||
|
||
const getCategories = () => {
|
||
const categoryMap = new Map<string, { name: string; icon: React.ComponentType<{ className?: string }>; count: number }>();
|
||
// All category: prefer live total when currently filtered by 'all',
|
||
// otherwise show the last cached overall total (if any)
|
||
const liveAllTotal = paginationState.selectedCategory === 'all' ? (paginationState.total || 0) : 0;
|
||
const cachedAllTotal = categoryCounts['all'] ?? 0;
|
||
categoryMap.set("all", { name: "All Templates", icon: Globe, count: Math.max(liveAllTotal, cachedAllTotal) });
|
||
|
||
// Build chips from the union of all categories we have ever seen
|
||
Array.from(knownCategories)
|
||
.filter((c) => c !== 'all')
|
||
.forEach((categoryId) => {
|
||
const lower = categoryId.toLowerCase();
|
||
let icon = Code;
|
||
if (lower.includes('marketing') || lower.includes('branding')) icon = Zap;
|
||
else if (lower.includes('seo') || lower.includes('content')) icon = BarChart3;
|
||
else if (lower.includes('food') || lower.includes('delivery')) icon = Users;
|
||
|
||
// Prefer cached count for this category (set when that filter is active).
|
||
// Do NOT fallback to visible page count to avoid misleading numbers.
|
||
const count = categoryCounts[categoryId];
|
||
|
||
categoryMap.set(categoryId, {
|
||
name: categoryId,
|
||
icon,
|
||
// If count is unknown, represent as 0 in data and handle placeholder in UI
|
||
count: typeof count === 'number' ? count : 0,
|
||
});
|
||
});
|
||
|
||
return Array.from(categoryMap.entries()).map(([id, data]) => ({ id, ...data }));
|
||
};
|
||
|
||
const categories = getCategories();
|
||
|
||
const getComplexityColor = (complexity: number) => {
|
||
if (complexity <= 2) return "bg-emerald-900/40 text-emerald-300 border border-emerald-800"
|
||
if (complexity <= 3) return "bg-amber-900/40 text-amber-300 border border-amber-800"
|
||
return "bg-rose-900/40 text-rose-300 border border-rose-800"
|
||
}
|
||
|
||
const getComplexityLabel = (complexity: number) => {
|
||
if (complexity <= 2) return "Simple"
|
||
if (complexity <= 3) return "Moderate"
|
||
return "Complex"
|
||
}
|
||
|
||
// Truncate helper to restrict displayed characters
|
||
const TITLE_MAX_CHARS = 15;
|
||
const DESC_MAX_CHARS = 120;
|
||
const truncate = (value: string | undefined | null, max: number) => {
|
||
const v = (value || '').trim();
|
||
if (v.length <= max) return v;
|
||
return v.slice(0, Math.max(0, max - 1)) + '…';
|
||
};
|
||
|
||
const handleCreateTemplate = async (templateData: Partial<DatabaseTemplate>): Promise<DatabaseTemplate> => {
|
||
try {
|
||
const created = await createTemplate({
|
||
...(templateData as any),
|
||
// Ensure backend routes to custom_templates table
|
||
is_custom: true,
|
||
source: 'custom',
|
||
// Attach user id so custom template is associated with creator
|
||
user_id: (templateUser as any)?.id,
|
||
} as any);
|
||
setShowCustomForm(false);
|
||
return created;
|
||
} catch (error) {
|
||
console.error('[TemplateSelectionStep] Error creating template:', error);
|
||
// Re-throw to satisfy the return type contract when errors should propagate
|
||
throw error as Error;
|
||
}
|
||
};
|
||
|
||
const handleCreateFromGit = async () => {
|
||
try {
|
||
if (!gitUrl.trim()) {
|
||
alert('Please enter a Git repository URL');
|
||
return;
|
||
}
|
||
|
||
// Detect provider from URL
|
||
const detectedProvider = detectProvider(gitUrl.trim());
|
||
console.log('🔍 [handleCreateFromGit] Detected provider:', detectedProvider);
|
||
console.log('🔍 [handleCreateFromGit] Git URL:', gitUrl.trim());
|
||
|
||
// Check if repository already exists for this user
|
||
try {
|
||
const existingReposResponse = await authApiClient.get(`/api/vcs/${detectedProvider}/repositories?t=${Date.now()}`, {
|
||
headers: { 'x-user-id': user?.id }
|
||
});
|
||
|
||
if (existingReposResponse.data?.success) {
|
||
const repositories = existingReposResponse.data.data || [];
|
||
const existingRepo = repositories.find((r: any) => r.repository_url === gitUrl.trim());
|
||
|
||
if (existingRepo) {
|
||
alert(`✅ Repository already exists!\n\nProvider: ${detectedProvider.toUpperCase()}\nRepository: ${gitUrl.trim()}\nStatus: ${existingRepo.sync_status}\n\nThis repository is already available in your repositories list.`);
|
||
setShowCreateOptionDialog(false);
|
||
setShowGitForm(false);
|
||
return;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.log('🔍 [handleCreateFromGit] Could not check existing repositories, proceeding with attachment:', error);
|
||
// Continue with attachment even if we can't check existing repos
|
||
}
|
||
|
||
// Attach the repository via backend (skip template creation)
|
||
try {
|
||
const attachResult = await attachRepository({
|
||
repository_url: gitUrl.trim(),
|
||
branch_name: (gitBranch?.trim() || undefined),
|
||
})
|
||
|
||
// Check if authentication is required
|
||
if (attachResult.requires_auth) {
|
||
console.log('🔐 Private repository detected, auto-redirecting to OAuth:', attachResult)
|
||
|
||
// Store the auth_url for OAuth redirect
|
||
try {
|
||
sessionStorage.setItem('pending_git_attach', JSON.stringify({
|
||
repository_url: gitUrl.trim(),
|
||
branch_name: (gitBranch?.trim() || undefined),
|
||
provider: detectedProvider,
|
||
auth_url: attachResult.auth_url
|
||
}))
|
||
console.log('💾 Stored pending git attach with auth_url in sessionStorage')
|
||
} catch (e) {
|
||
console.warn('⚠️ Failed to store pending git attach:', e)
|
||
}
|
||
|
||
// Auto-redirect to OAuth
|
||
console.log('🔐 Private repository detected - auto-redirecting to OAuth:', attachResult.auth_url)
|
||
if (attachResult.auth_url) {
|
||
window.location.replace(attachResult.auth_url)
|
||
} else {
|
||
alert('Authentication URL not available. Please try again.')
|
||
}
|
||
return
|
||
}
|
||
|
||
// Show success message and reset form
|
||
alert('Repository attached successfully! You can now proceed with your project.');
|
||
setShowCreateOptionDialog(false)
|
||
setShowGitForm(false)
|
||
setGitProvider('')
|
||
setGitUrl('')
|
||
setGitBranch('main')
|
||
|
||
// Return a mock template object to proceed to next step
|
||
return {
|
||
id: 'git-imported',
|
||
title: `Imported from ${detectedProvider.charAt(0).toUpperCase() + detectedProvider.slice(1)}`,
|
||
description: `Template imported from ${detectedProvider.charAt(0).toUpperCase() + detectedProvider.slice(1)}: ${gitUrl}`,
|
||
type: 'custom',
|
||
category: 'imported',
|
||
is_custom: true,
|
||
source: 'git',
|
||
git_url: gitUrl.trim(),
|
||
git_branch: gitBranch?.trim() || 'main',
|
||
git_provider: detectedProvider
|
||
}
|
||
} catch (attachErr) {
|
||
console.error('[TemplateSelectionStep] attachRepository failed:', attachErr)
|
||
|
||
const err: any = attachErr
|
||
const status = err?.response?.status
|
||
const data = err?.response?.data
|
||
|
||
console.log('🔍 HandleCreateFromGit error response:', { status, data })
|
||
console.log('🔍 Error details:', {
|
||
message: err?.message,
|
||
requires_auth: data?.requires_auth,
|
||
auth_url: data?.auth_url
|
||
})
|
||
console.log('🔍 Full error object:', err)
|
||
console.log('🔍 Error response object:', err?.response)
|
||
|
||
// If backend signals auth required, show authentication button instead of auto-redirect
|
||
if ((status === 401 || status === 200) && (data?.requires_auth || data?.message?.includes('authentication'))) {
|
||
console.log('🔐 Private repository detected, showing authentication button:', { status, requires_auth: data?.requires_auth, message: data?.message })
|
||
|
||
// Store the auth_url for when user clicks the authenticate button
|
||
try {
|
||
sessionStorage.setItem('pending_git_attach', JSON.stringify({
|
||
repository_url: gitUrl.trim(),
|
||
branch_name: (gitBranch?.trim() || undefined),
|
||
provider: detectedProvider,
|
||
auth_url: data?.auth_url
|
||
}))
|
||
console.log('💾 Stored pending git attach with auth_url in sessionStorage')
|
||
} catch (e) {
|
||
console.warn('⚠️ Failed to store pending git attach:', e)
|
||
}
|
||
|
||
// Don't auto-redirect, let the user click the authenticate button
|
||
console.log('🔐 Private repository detected - user must click authenticate button')
|
||
return
|
||
}
|
||
|
||
if (status === 403) {
|
||
alert('Repository not accessible - you may not have permission to access this repository')
|
||
return
|
||
}
|
||
|
||
// Fallback: if we have an auth_url in the error, try to redirect anyway
|
||
if (data?.auth_url && !data?.success) {
|
||
console.log('🔐 Fallback OAuth redirect - auth_url found in error response:', data.auth_url)
|
||
window.location.replace(data.auth_url)
|
||
return
|
||
}
|
||
|
||
// Additional fallback: check if the error message contains auth_url
|
||
if (err?.message && err.message.includes('authentication') && data?.auth_url) {
|
||
console.log('🔐 Additional fallback OAuth redirect - authentication message with auth_url:', data.auth_url)
|
||
window.location.replace(data.auth_url)
|
||
return
|
||
}
|
||
|
||
if (status === 404) {
|
||
alert('Repository not found - please check the URL and try again')
|
||
return
|
||
}
|
||
|
||
alert(data?.message || 'Failed to attach repository. Please verify the URL/branch and your auth.');
|
||
return;
|
||
}
|
||
} catch (error) {
|
||
console.error('[TemplateSelectionStep] Error importing from Git:', error)
|
||
const errorMessage = error instanceof Error ? error.message : 'Failed to import from Git'
|
||
alert(`Error: ${errorMessage}`)
|
||
throw error as Error
|
||
}
|
||
}
|
||
|
||
const handleUpdateTemplate = async (id: string, templateData: Partial<DatabaseTemplate>) => {
|
||
try {
|
||
// Find the template to determine if it's custom
|
||
const template = templates.find(t => t.id === id);
|
||
const isCustom = template?.is_custom || false;
|
||
|
||
await updateTemplate(id, templateData, isCustom);
|
||
setEditingTemplate(null);
|
||
} catch (error) {
|
||
console.error('[TemplateSelectionStep] Error updating template:', error);
|
||
// Show user-friendly error message
|
||
const errorMessage = error instanceof Error ? error.message : 'Failed to update template';
|
||
alert(`Error updating template: ${errorMessage}`);
|
||
}
|
||
};
|
||
|
||
const handleDeleteTemplate = async () => {
|
||
if (!deletingTemplate) return;
|
||
setDeleteLoading(true);
|
||
try {
|
||
// Find the template to determine if it's custom
|
||
const template = templates.find(t => t.id === deletingTemplate.id);
|
||
const isCustom = template?.is_custom || false;
|
||
|
||
await deleteTemplate(deletingTemplate.id, isCustom);
|
||
setDeletingTemplate(null);
|
||
} catch (error) {
|
||
console.error('[TemplateSelectionStep] Error deleting template:', error);
|
||
// Show user-friendly error message
|
||
const errorMessage = error instanceof Error ? error.message : 'Failed to delete template';
|
||
alert(`Error deleting template: ${errorMessage}`);
|
||
} finally {
|
||
setDeleteLoading(false);
|
||
}
|
||
};
|
||
|
||
if (loading && templates.length === 0) {
|
||
return (
|
||
<div className="max-w-7xl mx-auto space-y-6">
|
||
<div className="text-center space-y-3">
|
||
<h1 className="text-4xl font-bold text-white">Loading Templates...</h1>
|
||
<p className="text-xl text-white/60">Fetching templates from /merged API</p>
|
||
</div>
|
||
<div className="flex justify-center">
|
||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-orange-500"></div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
// Check if this is an authentication error
|
||
const isAuthError = error.includes('Please sign in') || error.includes('Authentication required');
|
||
|
||
return (
|
||
<div className="max-w-7xl mx-auto space-y-6">
|
||
<div className="text-center space-y-3">
|
||
{isAuthError ? (
|
||
<>
|
||
<div className="flex items-center justify-center space-x-2 text-orange-400">
|
||
<AlertCircle className="h-6 w-6" />
|
||
<h1 className="text-2xl font-bold">Sign In Required</h1>
|
||
</div>
|
||
<p className="text-white/60">{error}</p>
|
||
<div className="flex justify-center space-x-4">
|
||
<Button
|
||
onClick={() => {
|
||
const returnUrl = encodeURIComponent(window.location.pathname + window.location.search)
|
||
window.location.href = `/signin?returnUrl=${returnUrl}`
|
||
}}
|
||
className="bg-orange-500 hover:bg-orange-400 text-black"
|
||
>
|
||
Sign In
|
||
</Button>
|
||
<Button
|
||
onClick={() => {
|
||
// Try to fetch public templates without user ID
|
||
fetchTemplatesWithPagination({
|
||
page: 0,
|
||
pageSize: paginationState.pageSize,
|
||
category: selectedCategory,
|
||
search: searchQuery,
|
||
resetPagination: true,
|
||
});
|
||
}}
|
||
variant="outline"
|
||
className="border-white/20 text-white hover:bg-white/10"
|
||
>
|
||
Browse Public Templates
|
||
</Button>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<div className="flex items-center justify-center space-x-2 text-red-400">
|
||
<AlertCircle className="h-6 w-6" />
|
||
<h1 className="text-2xl font-bold">Error Loading Templates</h1>
|
||
</div>
|
||
<p className="text-white/60">{error}</p>
|
||
<Button
|
||
onClick={() =>
|
||
fetchTemplatesWithPagination({
|
||
page: 0,
|
||
pageSize: paginationState.pageSize,
|
||
category: selectedCategory,
|
||
search: searchQuery,
|
||
resetPagination: true,
|
||
})
|
||
}
|
||
className="bg-orange-500 hover:bg-orange-400 text-black"
|
||
>
|
||
Retry
|
||
</Button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Show custom template form
|
||
if (showCustomForm) {
|
||
return (
|
||
<div className="max-w-4xl mx-auto space-y-6">
|
||
<div className="text-center space-y-3">
|
||
<h1 className="text-4xl font-bold text-white">Create Custom Template</h1>
|
||
<p className="text-xl text-white/60">Design your own project template</p>
|
||
</div>
|
||
<CustomTemplateForm
|
||
onSubmit={async (templateData) => {
|
||
await handleCreateTemplate(templateData);
|
||
}}
|
||
onCancel={() => setShowCustomForm(false)}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Show edit template form
|
||
if (editingTemplate) {
|
||
return (
|
||
<div className="max-w-4xl mx-auto space-y-6">
|
||
<div className="text-center space-y-3">
|
||
<h1 className="text-4xl font-bold text-white">Edit Template</h1>
|
||
<p className="text-xl text-white/60">Modify your template settings</p>
|
||
</div>
|
||
<EditTemplateForm
|
||
template={editingTemplate}
|
||
onSubmit={handleUpdateTemplate}
|
||
onCancel={() => setEditingTemplate(null)}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Show delete confirmation dialog
|
||
if (deletingTemplate) {
|
||
return (
|
||
<>
|
||
<div className="max-w-7xl mx-auto space-y-6">
|
||
{/* Header */}
|
||
<div className="text-center space-y-3">
|
||
<h1 className="text-4xl font-bold text-white">Choose Your Project Template</h1>
|
||
<p className="text-xl text-white/60 max-w-3xl mx-auto">
|
||
Select from our comprehensive library of professionally designed templates
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<DeleteConfirmationDialog
|
||
templateTitle={deletingTemplate.title}
|
||
onConfirm={handleDeleteTemplate}
|
||
onCancel={() => setDeletingTemplate(null)}
|
||
loading={deleteLoading}
|
||
/>
|
||
</>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="max-w-7xl mx-auto space-y-6">
|
||
{/* Sync Progress Notification */}
|
||
{syncProgress?.show && (
|
||
<div className="bg-orange-500/10 border border-orange-500/20 rounded-lg p-4 mb-6">
|
||
<div className="flex items-center space-x-3">
|
||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-orange-500"></div>
|
||
<div className="flex-1">
|
||
<h3 className="text-lg font-semibold text-white">
|
||
Syncing {syncProgress.provider.toUpperCase()} Repository
|
||
</h3>
|
||
<p className="text-white/80 text-sm">
|
||
{syncProgress.stage}
|
||
</p>
|
||
<div className="mt-2 text-xs text-white/60">
|
||
Repository: {syncProgress.repositoryUrl}
|
||
{syncProgress.branchName !== 'main' && ` | Branch: ${syncProgress.branchName}`}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center space-x-2">
|
||
<div className={`px-2 py-1 rounded text-xs font-medium ${
|
||
syncProgress.status === 'synced' ? 'bg-green-500/20 text-green-400' :
|
||
syncProgress.status === 'error' ? 'bg-red-500/20 text-red-400' :
|
||
'bg-orange-500/20 text-orange-400'
|
||
}`}>
|
||
{syncProgress.status}
|
||
</div>
|
||
<button
|
||
onClick={() => setSyncProgress(null)}
|
||
className="text-white/60 hover:text-white/80 transition-colors"
|
||
title="Dismiss sync progress"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Header */}
|
||
<div className="text-center space-y-3">
|
||
<h1 className="text-4xl font-bold text-white">Choose Your Project Template</h1>
|
||
<p className="text-xl text-white/60 max-w-3xl mx-auto">
|
||
Select from our comprehensive library of professionally designed templates
|
||
</p>
|
||
|
||
{/* Connection Error Banner */}
|
||
{connectionError && (
|
||
<div className="max-w-2xl mx-auto">
|
||
<ErrorBanner
|
||
title="Connection Issue"
|
||
message={connectionError}
|
||
onRetry={async () => {
|
||
setConnectionError(null)
|
||
// Retry the GitHub auth status check
|
||
try {
|
||
const status = await getGitHubAuthStatus()
|
||
setIsGithubConnected(!!status?.data?.connected)
|
||
} catch (error: any) {
|
||
console.warn('Retry failed:', error)
|
||
setIsGithubConnected(false)
|
||
if (error?.code === 'ECONNREFUSED' || error?.code === 'ENOTFOUND' || error?.code === 'ETIMEDOUT') {
|
||
setConnectionError('Unable to connect to the server. Please ensure the backend is running.')
|
||
}
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
{!templateUser?.id && (
|
||
<div className="bg-orange-500/10 border border-orange-500/20 rounded-lg p-4 max-w-2xl mx-auto">
|
||
<p className="text-orange-300 text-sm">
|
||
You're currently viewing public templates.
|
||
<Button
|
||
variant="link"
|
||
onClick={() => {
|
||
const returnUrl = encodeURIComponent(window.location.pathname + window.location.search)
|
||
window.location.href = `/signin?returnUrl=${returnUrl}`
|
||
}}
|
||
className="text-orange-400 hover:text-orange-300 p-0 h-auto font-semibold ml-1"
|
||
>
|
||
Sign in
|
||
</Button>
|
||
{' '}to access your personal templates and create custom ones.
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Right-aligned quick navigation to user repos */}
|
||
<div className="flex justify-end space-x-2">
|
||
<ViewUserReposButton className="bg-orange-500 hover:bg-orange-400 text-black" label="My Repository" />
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<div className="max-w-2xl mx-auto relative">
|
||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-white/40 h-5 w-5" />
|
||
<Input
|
||
placeholder="Search templates, features, or technologies..."
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
className="pl-10 h-12 text-lg border border-white/10 bg-white/5 text-white placeholder:text-white/40 focus:border-white/30 focus:ring-white/30 rounded-xl"
|
||
/>
|
||
{paginationState.loading && (
|
||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-orange-500"></div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{(() => {
|
||
const renderChip = (category: { id: string; name: string; icon: React.ComponentType<{ className?: string }>; count: number }) => {
|
||
const Icon = category.icon;
|
||
const active = selectedCategory === category.id;
|
||
const knownCount = category.id === 'all'
|
||
? Math.max(
|
||
selectedCategory === 'all' ? (paginationState.total || 0) : 0,
|
||
categoryCounts['all'] ?? 0
|
||
)
|
||
: categoryCounts[category.id];
|
||
// Fallback to currently visible items count for initial render if unknown
|
||
const fallbackCount = category.id === 'all' ? templates.length : templates.filter((t) => t.category === category.id).length;
|
||
const displayCount = typeof knownCount === 'number' ? knownCount : fallbackCount;
|
||
return (
|
||
<button
|
||
key={`cat-${category.id}`}
|
||
onClick={() => setSelectedCategory(category.id)}
|
||
className={`shrink-0 whitespace-nowrap flex items-center space-x-3 px-6 py-3 rounded-xl border transition-all cursor-pointer ${
|
||
active ? "bg-orange-500 text-black border-orange-500" : "bg-white/5 text-white/80 border-white/10 hover:bg-white/10"
|
||
}`}
|
||
>
|
||
<div className={`p-2 rounded-lg ${active ? "bg-white/10" : "bg-white/10"}`}>
|
||
<Icon className="h-5 w-5" />
|
||
</div>
|
||
<div className="text-left">
|
||
<div className="font-semibold">{category.name}</div>
|
||
<div className={`text-sm ${active ? "text-black/80" : "text-white/60"}`}>
|
||
{`${displayCount} templates`}
|
||
</div>
|
||
</div>
|
||
</button>
|
||
);
|
||
};
|
||
|
||
const allChip = categories.find((c) => c.id === 'all');
|
||
const rest = categories.filter((c) => c.id !== 'all');
|
||
|
||
return (
|
||
<div className="flex items-center gap-3">
|
||
{allChip && (
|
||
<div className="shrink-0">
|
||
{renderChip(allChip)}
|
||
</div>
|
||
)}
|
||
<div className="relative overflow-hidden flex-1 pb-2">
|
||
<div className="flex gap-3 whitespace-nowrap animate-scroll-x will-change-transform">
|
||
{rest.map(renderChip)}
|
||
{rest.map((c) => ({ ...c, id: `${c.id}-dup` })).map(renderChip)}
|
||
</div>
|
||
<style jsx>{`
|
||
@keyframes scroll-x { from { transform: translateX(0); } to { transform: translateX(-50%); } }
|
||
.animate-scroll-x { animation: scroll-x 20s linear infinite; }
|
||
`}</style>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
|
||
{templates.length === 0 && !paginationState.loading ? (
|
||
<div className="text-center py-12 text-white/60">
|
||
<p>No templates found for the current filters.</p>
|
||
<Button
|
||
onClick={() =>
|
||
fetchTemplatesWithPagination({
|
||
page: 0,
|
||
pageSize: paginationState.pageSize,
|
||
category: 'all',
|
||
search: '',
|
||
resetPagination: true,
|
||
})
|
||
}
|
||
className="mt-4 bg-orange-500 hover:bg-orange-400 text-black"
|
||
>
|
||
Reset Filters
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||
{templates.map((template) => (
|
||
<Card key={template.id} className="group bg-white/5 border-white/10 hover:border-white/20 rounded-xl shadow-sm hover:shadow-md transition-all">
|
||
<div className="bg-white/5 px-4 py-4 border-b border-white/10">
|
||
<div className="flex items-start justify-between mb-2">
|
||
<div className="space-y-2 flex-1 min-w-0">
|
||
<CardTitle className="text-xl font-bold text-white group-hover:text-orange-400 transition-colors">
|
||
<div className="whitespace-nowrap overflow-hidden text-ellipsis max-w-full break-words hyphens-auto">
|
||
{truncate(template.title, TITLE_MAX_CHARS)}
|
||
</div>
|
||
</CardTitle>
|
||
<div className="flex items-center space-x-2">
|
||
<Badge className={`${getComplexityColor(template.complexity)} font-medium px-3 py-1 rounded-full`}>{getComplexityLabel(template.complexity)}</Badge>
|
||
{template.popularity && (
|
||
<div className="flex items-center space-x-1 bg-white/10 px-2 py-1 rounded-full text-white/80">
|
||
<Star className="h-4 w-4 text-amber-400 fill-current" />
|
||
<span className="text-sm font-semibold">{template.popularity}%</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{/* Edit and Delete buttons - only show for database templates */}
|
||
{template.id && template.id !== "marketing-website" && template.id !== "saas-platform" && (
|
||
<div className="flex items-center space-x-1 ml-2">
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
setEditingTemplate(template as DatabaseTemplate)
|
||
}}
|
||
className="h-8 w-8 p-0 text-white/60 hover:text-white hover:bg-white/10 cursor-pointer"
|
||
>
|
||
<Edit className="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
setDeletingTemplate(template as DatabaseTemplate)
|
||
}}
|
||
className="h-8 w-8 p-0 text-white/60 hover:text-red-400 hover:bg-red-400/10 cursor-pointer"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{(() => {
|
||
const raw = (template.description || '').trim()
|
||
const needsToggle = raw.length > DESC_MAX_CHARS
|
||
const displayText = truncate(template.description, DESC_MAX_CHARS)
|
||
return (
|
||
<div>
|
||
<p className={`text-white/80 text-sm leading-relaxed break-words hyphens-auto line-clamp-2 overflow-hidden`}>
|
||
{displayText}
|
||
</p>
|
||
{needsToggle && (
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
setDescDialogData({ title: template.title, description: raw })
|
||
setDescDialogOpen(true)
|
||
}}
|
||
className="mt-1 text-xs text-orange-400 hover:text-orange-300 font-medium"
|
||
>
|
||
Show more
|
||
</button>
|
||
)}
|
||
</div>
|
||
)
|
||
})()}
|
||
|
||
</div>
|
||
|
||
<CardContent className="p-4 flex flex-col h-full text-white/80">
|
||
<div className="flex-1 space-y-4">
|
||
<div className="flex items-center justify-between text-sm bg-white/5 px-3 py-2 rounded-lg">
|
||
<div className="flex items-center space-x-2">
|
||
<Clock className="h-4 w-4 text-orange-400" />
|
||
<span className="font-medium">{template.timeEstimate}</span>
|
||
</div>
|
||
<div className="flex items-center space-x-2">
|
||
<Layers className="h-4 w-4 text-emerald-400" />
|
||
<span className="font-medium">{template.featureCount || 0} features</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<div>
|
||
<h4 className="font-semibold text-sm text-white mb-1 flex items-center">
|
||
<span className="w-2 h-2 bg-orange-500 rounded-full mr-2"></span>
|
||
Key Features
|
||
</h4>
|
||
<div className="flex flex-wrap gap-1">
|
||
{template.features.slice(0, 3).map((feature, index) => (
|
||
<Badge key={index} variant="outline" className="text-xs bg-white/5 border-white/10 text-white/80 px-3 py-2 rounded-full">
|
||
{feature}
|
||
</Badge>
|
||
))}
|
||
{template.features.length > 3 && (
|
||
<Badge variant="outline" className="text-xs bg-white/5 border-white/10 text-white/70 px-3 py-2 rounded-full">
|
||
+{template.features.length - 3} more
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4">
|
||
<Button onClick={() => onNext(template)} className="w-full bg-orange-500 hover:bg-orange-400 text-black font-semibold py-2 rounded-lg shadow">
|
||
<span className="flex items-center justify-center cursor-pointer">
|
||
Select Template
|
||
<ArrowRight className="ml-2 h-4 w-4" />
|
||
</span>
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex justify-center pt-6">
|
||
<div className="flex items-center space-x-2">
|
||
<Button
|
||
onClick={() => {
|
||
if (paginationState.currentPage > 0) {
|
||
fetchTemplatesWithPagination({
|
||
page: paginationState.currentPage - 1,
|
||
pageSize: paginationState.pageSize,
|
||
category: paginationState.selectedCategory,
|
||
search: paginationState.searchQuery,
|
||
});
|
||
}
|
||
}}
|
||
disabled={paginationState.currentPage === 0 || paginationState.loading}
|
||
variant="outline"
|
||
size="sm"
|
||
className="border-white/20 text-white hover:bg-white/10 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
‹
|
||
</Button>
|
||
|
||
{(() => {
|
||
const totalPages = Math.max(1, Math.ceil(paginationState.total / paginationState.pageSize));
|
||
const currentPage = paginationState.currentPage;
|
||
const pages = [];
|
||
|
||
// First page
|
||
pages.push(
|
||
<Button
|
||
key={1}
|
||
onClick={() => {
|
||
if (currentPage !== 0) {
|
||
fetchTemplatesWithPagination({
|
||
page: 0,
|
||
pageSize: paginationState.pageSize,
|
||
category: paginationState.selectedCategory,
|
||
search: paginationState.searchQuery,
|
||
});
|
||
}
|
||
}}
|
||
variant={currentPage === 0 ? "default" : "outline"}
|
||
size="sm"
|
||
className={currentPage === 0 ? "bg-orange-500 text-black border-orange-500 hover:bg-orange-400" : "border-white/20 text-white hover:bg-white/10"}
|
||
>
|
||
1
|
||
</Button>
|
||
);
|
||
|
||
// Ellipsis before current page
|
||
if (currentPage > 3) {
|
||
pages.push(<span key="ellipsis1" className="px-2 text-white/40">...</span>);
|
||
}
|
||
|
||
// Pages around current
|
||
for (let i = Math.max(1, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) {
|
||
if (i > 0 && i < totalPages) {
|
||
pages.push(
|
||
<Button
|
||
key={i + 1}
|
||
onClick={() => {
|
||
fetchTemplatesWithPagination({
|
||
page: i,
|
||
pageSize: paginationState.pageSize,
|
||
category: paginationState.selectedCategory,
|
||
search: paginationState.searchQuery,
|
||
});
|
||
}}
|
||
variant={currentPage === i ? "default" : "outline"}
|
||
size="sm"
|
||
className={currentPage === i ? "bg-orange-500 text-black border-orange-500 hover:bg-orange-400" : "border-white/20 text-white hover:bg-white/10"}
|
||
>
|
||
{i + 1}
|
||
</Button>
|
||
);
|
||
}
|
||
}
|
||
|
||
// Ellipsis after current page
|
||
if (currentPage < totalPages - 3) {
|
||
pages.push(<span key="ellipsis2" className="px-2 text-white/40">...</span>);
|
||
}
|
||
|
||
// Last page (only if not already rendered by the middle range)
|
||
if (totalPages > 1 && currentPage < totalPages - 2) {
|
||
pages.push(
|
||
<Button
|
||
key={totalPages}
|
||
onClick={() => {
|
||
fetchTemplatesWithPagination({
|
||
page: totalPages - 1,
|
||
pageSize: paginationState.pageSize,
|
||
category: paginationState.selectedCategory,
|
||
search: paginationState.searchQuery,
|
||
});
|
||
}}
|
||
variant={currentPage === totalPages - 1 ? "default" : "outline"}
|
||
size="sm"
|
||
className={currentPage === totalPages - 1 ? "bg-orange-500 text-black border-orange-500 hover:bg-orange-400" : "border-white/20 text-white hover:bg-white/10"}
|
||
>
|
||
{totalPages}
|
||
</Button>
|
||
);
|
||
}
|
||
|
||
return pages;
|
||
})()}
|
||
|
||
<Button
|
||
onClick={() => loadMoreTemplates()}
|
||
disabled={paginationState.total <= paginationState.pageSize || !paginationState.hasMore || paginationState.loading}
|
||
variant="outline"
|
||
size="sm"
|
||
className="border-white/20 text-white hover:bg-white/10 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
›
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<Card className="group border-dashed border-2 border-white/15 bg-white/5 hover:border-white/25 transition-all cursor-pointer" onClick={() => {
|
||
if (!templateUser?.id) { window.location.href = '/signin'; return }
|
||
setShowCreateOptionDialog(true)
|
||
}}>
|
||
<CardContent className="text-center py-16 px-8 text-white/80">
|
||
<div className="w-20 h-20 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-6">
|
||
<Plus className="h-10 w-10 text-orange-400" />
|
||
</div>
|
||
<h3 className="text-2xl font-bold text-white mb-3">
|
||
{templateUser?.id ? 'Create Custom Template' : 'Sign In to Create Templates'}
|
||
</h3>
|
||
<p className="mb-8 max-w-md mx-auto text-lg leading-relaxed">
|
||
{templateUser?.id
|
||
? "Don't worry, we'll guide you through each step. a custom project type with your specific requirements and tech stack."
|
||
: "Sign in to create custom project templates with your specific requirements and tech stack."
|
||
}
|
||
</p>
|
||
<Button variant="outline" className="border-white/20 text-white hover:bg-white/10" onClick={(e) => {
|
||
e.stopPropagation()
|
||
if (!templateUser?.id) { window.location.href = '/signin'; return }
|
||
setShowCreateOptionDialog(true)
|
||
}}>
|
||
{templateUser?.id ? (
|
||
<>
|
||
<Plus className="mr-2 h-5 w-5" />
|
||
Create Custom Template
|
||
</>
|
||
) : (
|
||
<>
|
||
<User className="mr-2 h-5 w-5" />
|
||
Sign In to Create
|
||
</>
|
||
)}
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<div className="text-center py-4">
|
||
<p className="text-white/70">
|
||
{searchQuery ? (
|
||
<>
|
||
Showing {templates.length} template{templates.length !== 1 ? "s" : ""} matching "{searchQuery}"
|
||
{paginationState.total > 0 && ` (${paginationState.total} total)`}
|
||
</>
|
||
) : (
|
||
<>
|
||
Showing {templates.length} template{templates.length !== 1 ? "s" : ""}
|
||
{paginationState.total > 0 && ` of ${paginationState.total} total`}
|
||
</>
|
||
)}
|
||
{paginationState.total > 0 && (
|
||
<span className="block text-sm text-white/50 mt-1">
|
||
Page {paginationState.currentPage + 1} of {Math.ceil(paginationState.total / paginationState.pageSize)}
|
||
</span>
|
||
)}
|
||
</p>
|
||
</div>
|
||
<Dialog open={descDialogOpen} onOpenChange={setDescDialogOpen}>
|
||
<DialogContent className="bg-white/10 border-white/20 text-white" showCloseButton>
|
||
<DialogHeader>
|
||
<DialogTitle className="text-white">{descDialogData.title}</DialogTitle>
|
||
<DialogDescription className="text-white/80 whitespace-pre-wrap">
|
||
{descDialogData.description}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* Create Template Options Modal */}
|
||
<Dialog open={showCreateOptionDialog} onOpenChange={(open) => {
|
||
setShowCreateOptionDialog(open)
|
||
if (!open) {
|
||
setShowGitForm(false)
|
||
setGitProvider('')
|
||
setGitUrl('')
|
||
setGitBranch('main')
|
||
setGitAuthMethod('')
|
||
setGitCredentials({ username: '', password: '', token: '', sshKey: '' })
|
||
setGitStep('provider')
|
||
}
|
||
}}>
|
||
<DialogContent className="bg-white/10 border-white/20 text-white" showCloseButton>
|
||
<DialogHeader>
|
||
<DialogTitle className="text-white">Create Template</DialogTitle>
|
||
<DialogDescription className="text-white/80">
|
||
Choose how you want to create a template.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
{!showGitForm ? (
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||
<Button className="bg-orange-500 hover:bg-orange-400 text-black" onClick={() => {
|
||
setShowCreateOptionDialog(false)
|
||
setShowCustomForm(true)
|
||
}}>Create Manually</Button>
|
||
<Button variant="outline" className="border-white/20 text-white hover:bg-white/10" onClick={() => {
|
||
setShowGitForm(true)
|
||
setGitStep('provider')
|
||
}}>
|
||
Import from Git
|
||
</Button>
|
||
</div>
|
||
) : gitStep === 'provider' ? (
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm text-white/70 mb-3">Select Git Provider</label>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
{Object.entries(gitProviders).map(([key, provider]) => (
|
||
<Button
|
||
key={key}
|
||
variant="outline"
|
||
className="border-white/20 text-white hover:bg-white/10 h-12 flex flex-col items-center gap-1"
|
||
onClick={() => {
|
||
setGitProvider(key)
|
||
setGitStep('url')
|
||
}}
|
||
>
|
||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||
<path d={provider.icon}/>
|
||
</svg>
|
||
{provider.name}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-3 justify-end pt-2">
|
||
<Button variant="outline" className="border-white/20 text-white hover:bg-white/10" onClick={() => { setShowGitForm(false) }}>Back</Button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center gap-2 mb-4">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="text-white/70 hover:text-white p-1"
|
||
onClick={() => setGitStep('provider')}
|
||
>
|
||
← Back to Provider
|
||
</Button>
|
||
<span className="text-sm text-white/70">
|
||
Import from {gitProviders[gitProvider as keyof typeof gitProviders]?.name}
|
||
</span>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm text-white/70 mb-1">Repository URL</label>
|
||
<Input
|
||
value={gitUrl}
|
||
onChange={(e) => {
|
||
setGitUrl(e.target.value)
|
||
}}
|
||
placeholder={gitProviders[gitProvider as keyof typeof gitProviders]?.placeholder}
|
||
className="bg-white/10 border-white/20 text-white"
|
||
/>
|
||
<p className="text-xs text-white/50 mt-1">
|
||
Enter the full URL to your {gitProviders[gitProvider as keyof typeof gitProviders]?.name} repository
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm text-white/70 mb-1">Branch (optional)</label>
|
||
<Input
|
||
value={gitBranch}
|
||
onChange={(e) => setGitBranch(e.target.value)}
|
||
placeholder="main"
|
||
className="bg-white/10 border-white/20 text-white"
|
||
/>
|
||
<p className="text-xs text-white/50 mt-1">
|
||
Leave empty to use the default branch
|
||
</p>
|
||
</div>
|
||
|
||
|
||
<div className="flex gap-3 justify-end pt-2">
|
||
<Button variant="outline" className="border-white/20 text-white hover:bg-white/10" onClick={() => setGitStep('provider')}>Back</Button>
|
||
<Button
|
||
className="bg-orange-500 hover:bg-orange-400 text-black"
|
||
type="button"
|
||
onClick={(e) => { e.preventDefault(); handleCreateFromGit(); }}
|
||
disabled={!gitUrl.trim()}
|
||
>
|
||
Import Template
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Feature Selection Step Component
|
||
function FeatureSelectionStep({
|
||
template,
|
||
onNext,
|
||
onBack,
|
||
}: { template: Template; onNext: (selected: TemplateFeature[]) => void; onBack: () => void }) {
|
||
const { fetchFeatures, createFeature, updateFeature, deleteFeature } = useTemplates()
|
||
const [features, setFeatures] = useState<TemplateFeature[]>([])
|
||
const [essentialFeatures, setEssentialFeatures] = useState<TemplateFeature[]>([])
|
||
const [customFeatures, setCustomFeatures] = useState<TemplateFeature[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||
const [showAIModal, setShowAIModal] = useState(false)
|
||
const [expandedFeatureDescriptions, setExpandedFeatureDescriptions] = useState<Record<string, boolean>>({})
|
||
const [featureDescDialogOpen, setFeatureDescDialogOpen] = useState(false)
|
||
const [featureDescDialogData, setFeatureDescDialogData] = useState<{ title: string; description: string }>({ title: '', description: '' })
|
||
const [rulesDialogOpen, setRulesDialogOpen] = useState(false)
|
||
const [rulesForFeature, setRulesForFeature] = useState<{ featureId: string; featureName: string } | null>(null)
|
||
const [featureRules, setFeatureRules] = useState<Array<string | { requirement?: string; rules?: string[] }>>([])
|
||
const FEATURE_DESC_MAX_CHARS = 180
|
||
const truncateFeatureText = (value: string | undefined | null, max: number) => {
|
||
const v = (value || '').trim()
|
||
if (v.length <= max) return v
|
||
return v.slice(0, Math.max(0, max - 1)) + '…'
|
||
}
|
||
|
||
const load = async () => {
|
||
try {
|
||
setLoading(true)
|
||
console.log('[FeatureSelectionStep] Loading features for template:', template.id)
|
||
console.log('[FeatureSelectionStep] Template object:', template)
|
||
const data = await fetchFeatures(template.id)
|
||
console.log('[FeatureSelectionStep] Raw features received:', data)
|
||
console.log('[FeatureSelectionStep] API endpoint called:', `/api/templates/${template.id}/features`)
|
||
console.log('[FeatureSelectionStep] Features by type:', {
|
||
essential: data.filter(f => f.feature_type === 'essential').length,
|
||
suggested: data.filter(f => f.feature_type === 'suggested').length,
|
||
custom: data.filter(f => f.feature_type === 'custom').length,
|
||
total: data.length
|
||
})
|
||
console.log('[FeatureSelectionStep] All features with types:', data.map(f => ({ name: f.name, type: f.feature_type })))
|
||
setFeatures(data)
|
||
// Separate custom features from essential features
|
||
setEssentialFeatures(data.filter(f => f.feature_type !== 'custom'))
|
||
setCustomFeatures(data.filter(f => f.feature_type === 'custom'))
|
||
} catch (e) {
|
||
console.error('[FeatureSelectionStep] Error loading features:', e)
|
||
setError(e instanceof Error ? e.message : 'Failed to load features')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
// Initial load
|
||
useEffect(() => { load() }, [template.id])
|
||
|
||
|
||
const handleAddAIAnalyzed = async (payload: { name: string; description: string; complexity: 'low' | 'medium' | 'high'; logic_rules?: string[]; requirements?: Array<{ text: string; rules: string[] }>; business_rules?: Array<{ requirement: string; rules: string[] }> }) => {
|
||
await createFeature(template.id, {
|
||
name: payload.name,
|
||
description: payload.description,
|
||
feature_type: 'custom',
|
||
complexity: payload.complexity,
|
||
is_default: false,
|
||
created_by_user: true,
|
||
// @ts-expect-error backend accepts additional fields
|
||
logic_rules: payload.logic_rules,
|
||
business_rules: payload.business_rules ?? (payload.requirements ? payload.requirements.map(r => ({ requirement: r.text, rules: r.rules || [] })) : undefined),
|
||
})
|
||
await load()
|
||
}
|
||
|
||
const [editingFeature, setEditingFeature] = useState<TemplateFeature | null>(null)
|
||
|
||
const handleUpdate = async (f: TemplateFeature, updates: Partial<TemplateFeature>) => {
|
||
// 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) => {
|
||
// 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)
|
||
return next
|
||
})
|
||
await load()
|
||
}
|
||
|
||
const toggleSelect = (f: TemplateFeature) => {
|
||
setSelectedIds((prev) => {
|
||
const next = new Set(prev)
|
||
if (next.has(f.id)) next.delete(f.id)
|
||
else next.add(f.id)
|
||
return next
|
||
})
|
||
}
|
||
|
||
const extractRules = (f: TemplateFeature): Array<string | { requirement?: string; rules?: string[] }> => {
|
||
// Prefer structured business_rules if available; fall back to additional_business_rules
|
||
const candidate = (f as any).business_rules ?? (f as any).additional_business_rules ?? []
|
||
if (Array.isArray(candidate)) return candidate
|
||
if (candidate && typeof candidate === 'object') return Object.entries(candidate).map(([k, v]) => `${k}: ${v as string}`)
|
||
if (typeof candidate === 'string') return [candidate]
|
||
return []
|
||
}
|
||
|
||
const section = (title: string, list: TemplateFeature[]) => (
|
||
<div>
|
||
<h3 className="text-lg font-semibold text-white mb-3">{title} ({list.length})</h3>
|
||
<div className={`${list.length > 6 ? 'max-h-[480px] overflow-y-auto pr-2' : ''}`}>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||
{list.map((f) => (
|
||
<Card key={f.id} className={`bg-white/5 ${selectedIds.has(f.id) ? 'border-orange-400' : 'border-white/10'}`}>
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-white flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<Checkbox
|
||
checked={selectedIds.has(f.id)}
|
||
onCheckedChange={() => toggleSelect(f)}
|
||
className="border-white/20 data-[state=checked]:bg-orange-500 data-[state=checked]:border-orange-500"
|
||
/>
|
||
<span>{f.name}</span>
|
||
</div>
|
||
{f.feature_type === 'custom' && (
|
||
<div className="flex gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="border-white/20 text-white hover:bg-white/10"
|
||
onClick={() => setEditingFeature(f)}
|
||
>
|
||
Edit
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="border-red-500 text-red-300 hover:bg-red-500/10"
|
||
onClick={() => handleDelete(f)}
|
||
>
|
||
Delete
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="text-white/80 text-sm space-y-2">
|
||
{(() => {
|
||
const raw = (f.description || 'No description provided.').trim()
|
||
const needsToggle = raw.length > FEATURE_DESC_MAX_CHARS
|
||
const displayText = truncateFeatureText(raw, FEATURE_DESC_MAX_CHARS)
|
||
return (
|
||
<div>
|
||
<p className={`leading-relaxed break-words hyphens-auto line-clamp-2 overflow-hidden`}>{displayText}</p>
|
||
{needsToggle && (
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
setFeatureDescDialogData({ title: f.name, description: raw })
|
||
setFeatureDescDialogOpen(true)
|
||
}}
|
||
className="mt-1 text-xs text-orange-400 hover:text-orange-300 font-medium"
|
||
>
|
||
Show more
|
||
</button>
|
||
)}
|
||
</div>
|
||
)
|
||
})()}
|
||
<div className="flex gap-2 text-xs">
|
||
<Badge variant="outline" className="bg-white/5 border-white/10">{f.feature_type}</Badge>
|
||
<Badge variant="outline" className="bg-white/5 border-white/10">{f.complexity}</Badge>
|
||
{typeof f.usage_count === 'number' && <Badge variant="outline" className="bg-white/5 border-white/10">used {f.usage_count}</Badge>}
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="ml-auto h-6 px-2 border-orange-500 text-orange-300 hover:bg-orange-500/10"
|
||
onClick={async (e) => {
|
||
e.stopPropagation()
|
||
setRulesForFeature({ featureId: f.id, featureName: f.name })
|
||
setFeatureRules(extractRules(f))
|
||
setRulesDialogOpen(true)
|
||
}}
|
||
>
|
||
Rules
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
|
||
if (loading) {
|
||
return <div className="text-center py-20 text-white/60">Loading features...</div>
|
||
}
|
||
if (error) {
|
||
return <div className="text-center py-20 text-red-400">{error}</div>
|
||
}
|
||
|
||
const essentials = features.filter(f => f.feature_type === 'essential')
|
||
const suggested = features.filter(f => f.feature_type === 'suggested')
|
||
const custom = features.filter(f => f.feature_type === 'custom')
|
||
|
||
return (
|
||
<div className="max-w-7xl mx-auto space-y-8">
|
||
<div className="text-center space-y-4">
|
||
<h1 className="text-4xl font-bold text-white">Select Features for {template.title}</h1>
|
||
<p className="text-xl text-white/60 max-w-3xl mx-auto">Choose from essential and suggested features.</p>
|
||
</div>
|
||
|
||
{essentialFeatures.length > 0 && section('Essential Features', essentialFeatures)}
|
||
|
||
{/* Add custom feature with AI */}
|
||
<div className="bg-white/5 border border-white/10 rounded-xl p-6 space-y-4">
|
||
<div className="text-center space-y-2">
|
||
<h3 className="text-white font-semibold text-lg">Add Custom Feature</h3>
|
||
<p className="text-white/60 text-sm">Use AI to analyze and create custom features for your project</p>
|
||
</div>
|
||
<div className="flex justify-center">
|
||
<Button
|
||
onClick={() => setShowAIModal(true)}
|
||
className="bg-orange-500 hover:bg-orange-400 text-black font-semibold px-8 py-3 rounded-lg cursor-pointer"
|
||
>
|
||
<Zap className="mr-2 h-5 w-5" />
|
||
Analyze with AI
|
||
</Button>
|
||
</div>
|
||
<div className="text-center text-white/60 text-sm">
|
||
AI will analyze your requirements and create optimized features
|
||
</div>
|
||
</div>
|
||
|
||
{customFeatures.length > 0 && section('Custom Features', customFeatures)}
|
||
|
||
{(showAIModal || editingFeature) && (
|
||
<AICustomFeatureCreator
|
||
projectType={template.type || template.title}
|
||
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)
|
||
}}
|
||
editingFeature={editingFeature || undefined}
|
||
/>
|
||
)}
|
||
|
||
<div className="text-center py-4">
|
||
<div className="space-x-4">
|
||
<Button variant="outline" onClick={onBack} className="border-white/20 text-white hover:bg-white/10 cursor-pointer">Back</Button>
|
||
<Button
|
||
onClick={() => onNext([...essentialFeatures, ...customFeatures].filter(f => selectedIds.has(f.id)))}
|
||
disabled={selectedIds.size < 3}
|
||
className={`bg-orange-500 hover:bg-orange-400 text-black cursor-pointer font-semibold py-2 rounded-lg shadow ${selectedIds.size < 3 ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||
>
|
||
Continue
|
||
</Button>
|
||
</div>
|
||
<div className="text-white/60 text-sm mt-2">Select at least 3 features to continue. Selected {selectedIds.size}/3.</div>
|
||
</div>
|
||
<Dialog open={featureDescDialogOpen} onOpenChange={setFeatureDescDialogOpen}>
|
||
<DialogContent className="bg-white/10 border-white/20 text-white" showCloseButton>
|
||
<DialogHeader>
|
||
<DialogTitle className="text-white">{featureDescDialogData.title}</DialogTitle>
|
||
<DialogDescription className="text-white/80 whitespace-pre-wrap">
|
||
{featureDescDialogData.description}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
</DialogContent>
|
||
</Dialog>
|
||
<Dialog open={rulesDialogOpen} onOpenChange={setRulesDialogOpen}>
|
||
<DialogContent className="bg-white/10 border-white/20 text-white" showCloseButton>
|
||
<DialogHeader>
|
||
<DialogTitle className="text-white">{rulesForFeature?.featureName || 'Feature Rules'}</DialogTitle>
|
||
<DialogDescription className="text-white/80">
|
||
{featureRules.length === 0 ? (
|
||
<div className="py-2">No rules found for this feature.</div>
|
||
) : (
|
||
<ul className="list-disc pl-5 space-y-2">
|
||
{featureRules.map((r, idx) => {
|
||
if (typeof r === 'string') return <li key={idx} className="text-sm leading-relaxed">{r}</li>
|
||
const req = (r as any).requirement
|
||
const rules = (r as any).rules
|
||
return (
|
||
<li key={idx} className="text-sm leading-relaxed">
|
||
{req ? <div className="font-medium text-white/90">{req}</div> : null}
|
||
{Array.isArray(rules) && rules.length > 0 && (
|
||
<ul className="list-disc pl-5 mt-1 space-y-1">
|
||
{rules.map((rr: string, j: number) => (
|
||
<li key={j} className="text-xs text-white/80">{rr}</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
)}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Business Questions Step Component
|
||
function BusinessQuestionsStep({
|
||
template,
|
||
selected,
|
||
onBack,
|
||
onDone,
|
||
}: { template: Template; selected: TemplateFeature[]; onBack: () => void; onDone: (completeData: any, recommendations: any) => void }) {
|
||
const [businessQuestions, setBusinessQuestions] = useState<string[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [showSummary, setShowSummary] = useState(false)
|
||
const [answers, setAnswers] = useState<any[]>([])
|
||
const [submitting, setSubmitting] = useState(false)
|
||
const [answeredQuestionsCount, setAnsweredQuestionsCount] = useState(0)
|
||
const selectedKey = selected.map(s => s.id).join(',')
|
||
|
||
useEffect(() => {
|
||
const load = async () => {
|
||
try {
|
||
setLoading(true)
|
||
setError(null)
|
||
if (selected.length === 0) {
|
||
setError('No features selected')
|
||
return
|
||
}
|
||
const token = getAccessToken()
|
||
const resp = await fetch(`${BACKEND_URL}/api/questions/generate-comprehensive-business-questions`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||
body: JSON.stringify({
|
||
allFeatures: selected,
|
||
projectName: template.title,
|
||
projectType: template.type || template.category,
|
||
totalFeatures: selected.length,
|
||
}),
|
||
})
|
||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||
const data = await resp.json()
|
||
const qs: string[] = data?.data?.businessQuestions || []
|
||
setBusinessQuestions(qs)
|
||
} catch (e: any) {
|
||
setError(e?.message || 'Failed to load questions')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
load()
|
||
}, [template.id, selectedKey])
|
||
|
||
const handleSurveyComplete = (surveyAnswers: any[]) => {
|
||
setAnswers(surveyAnswers)
|
||
setAnsweredQuestionsCount(surveyAnswers.length)
|
||
setShowSummary(true)
|
||
}
|
||
|
||
const handleSurveyProgress = (currentAnswers: any[]) => {
|
||
const answeredCount = currentAnswers.filter(answer => answer.answer && answer.answer.trim().length > 0).length
|
||
setAnsweredQuestionsCount(answeredCount)
|
||
// Store the current answers so they're available for submission
|
||
setAnswers(currentAnswers)
|
||
}
|
||
|
||
const handleSubmit = async () => {
|
||
try {
|
||
setSubmitting(true)
|
||
setError(null)
|
||
|
||
const token = getAccessToken()
|
||
// Filter to only include answered questions
|
||
const answeredQuestions = answers.filter(answer => answer.answer && answer.answer.trim().length > 0)
|
||
const questionsWithAnswers = answeredQuestions.map(answer => ({
|
||
question: answer.question,
|
||
answer: answer.answer,
|
||
}))
|
||
|
||
const completeData = {
|
||
template,
|
||
features: selected,
|
||
businessContext: {
|
||
questions: questionsWithAnswers,
|
||
},
|
||
projectName: template.title,
|
||
projectType: template.type || template.category,
|
||
}
|
||
|
||
console.log('🚀 Submitting comprehensive recommendations request...')
|
||
console.log('📊 Total answers received:', answers.length)
|
||
console.log('📊 Answered questions:', answeredQuestions.length)
|
||
console.log('📊 Questions with answers:', questionsWithAnswers)
|
||
console.log('📊 Complete data:', completeData)
|
||
|
||
const response = await fetch(`${BACKEND_URL}/api/unified/comprehensive-recommendations`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||
},
|
||
body: JSON.stringify({
|
||
template,
|
||
features: selected,
|
||
businessContext: {
|
||
questions: questionsWithAnswers,
|
||
},
|
||
projectName: template.title,
|
||
projectType: template.type || template.category,
|
||
templateId: template.id,
|
||
budget: 15000, // Default budget - could be made configurable
|
||
domain: template.category?.toLowerCase() || 'general',
|
||
includeClaude: true,
|
||
includeTemplateBased: true,
|
||
includeDomainBased: true,
|
||
}),
|
||
})
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||
}
|
||
|
||
const recommendations = await response.json()
|
||
console.log('✅ Comprehensive recommendations received:', recommendations)
|
||
|
||
// Extract the tech recommendations from the response for the frontend component
|
||
const techRecommendations = recommendations?.data?.claude?.data?.claude_recommendations || null
|
||
console.log('🎯 Extracted tech recommendations:', techRecommendations)
|
||
|
||
// Proceed to next step with complete data and recommendations
|
||
onDone(completeData, techRecommendations)
|
||
} catch (error: any) {
|
||
console.error('❌ Error submitting business context:', error)
|
||
setError(error?.message || 'Failed to submit business context')
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex items-center justify-center py-24">
|
||
<div className="text-center text-white/80">
|
||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-orange-500 mx-auto mb-4"></div>
|
||
<p>AI is generating comprehensive business questions...</p>
|
||
<p className="text-white/50 text-sm mt-2">Analyzing {selected.length} features as integrated system</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="text-center py-20">
|
||
<div className="max-w-md mx-auto bg-red-500/10 border border-red-500/30 rounded-lg p-6 text-red-300">
|
||
<div className="font-semibold mb-2">Error Loading Questions</div>
|
||
<div className="mb-4">{error}</div>
|
||
<div className="space-x-3">
|
||
<Button onClick={() => location.reload()} className="bg-red-500 hover:bg-red-400 text-black">Try Again</Button>
|
||
<Button variant="outline" onClick={onBack} className="border-white/20 text-white hover:bg-white/10">Go Back</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (showSummary) {
|
||
return (
|
||
<SurveySummary
|
||
questions={answers}
|
||
onBack={() => setShowSummary(false)}
|
||
onSubmit={handleSubmit}
|
||
loading={submitting}
|
||
/>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="max-w-6xl mx-auto space-y-4">
|
||
<div className="text-center space-y-2">
|
||
<h1 className="text-3xl font-bold text-white">Business Context Questions</h1>
|
||
<p className="text-lg text-white/60 max-w-2xl mx-auto">
|
||
Help us understand your {template.title} project better with these AI-generated questions
|
||
</p>
|
||
</div>
|
||
|
||
<TypeformSurvey
|
||
questions={businessQuestions}
|
||
onComplete={handleSurveyComplete}
|
||
onProgress={handleSurveyProgress}
|
||
onBack={onBack}
|
||
projectName={template.title}
|
||
/>
|
||
|
||
{/* Navigation buttons */}
|
||
<div className="text-center py-4">
|
||
<div className="space-x-4">
|
||
<Button
|
||
variant="outline"
|
||
onClick={onBack}
|
||
className="border-white/20 text-white hover:bg-white/10 cursor-pointer"
|
||
>
|
||
← Back to Features
|
||
</Button>
|
||
<Button
|
||
onClick={handleSubmit}
|
||
disabled={submitting || answeredQuestionsCount < 3}
|
||
className={`bg-orange-500 hover:bg-orange-400 text-black font-semibold py-2 rounded-lg shadow cursor-pointer ${
|
||
submitting || answeredQuestionsCount < 3 ? 'opacity-50 cursor-not-allowed' : ''
|
||
}`}
|
||
>
|
||
{submitting ? (
|
||
<div className="flex items-center gap-2">
|
||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-black"></div>
|
||
Generating Tech Stack...
|
||
</div>
|
||
) : (
|
||
<>
|
||
Generate Tech Stack →
|
||
</>
|
||
)}
|
||
</Button>
|
||
</div>
|
||
<div className="text-white/60 text-sm mt-2">
|
||
{answeredQuestionsCount < 3
|
||
? `Answer at least 3 questions to generate your tech stack recommendations (${answeredQuestionsCount}/3 answered)`
|
||
: `Ready to generate tech stack for ${template.title} (${answeredQuestionsCount} questions answered)`
|
||
}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
|
||
// AI Mockup Step Component
|
||
// function AIMockupStep({
|
||
// template,
|
||
// selectedFeatures,
|
||
// onNext,
|
||
// onBack,
|
||
// }: {
|
||
// template: Template
|
||
// selectedFeatures: TemplateFeature[]
|
||
// onNext: () => void
|
||
// onBack: () => void
|
||
// }) {
|
||
// const [mounted, setMounted] = useState(false)
|
||
// const [wireframeData, setWireframeData] = useState<any>(null)
|
||
// const [isGenerating, setIsGenerating] = useState(false)
|
||
// const [selectedDevice, setSelectedDevice] = useState<'desktop' | 'tablet' | 'mobile'>('desktop')
|
||
// const [initialPrompt, setInitialPrompt] = useState<string>("")
|
||
|
||
// // Load state from localStorage after component mounts
|
||
// useEffect(() => {
|
||
// setMounted(true)
|
||
|
||
// if (typeof window !== 'undefined') {
|
||
// // Load device type
|
||
// const savedDevice = localStorage.getItem('wireframe_device_type')
|
||
// if (savedDevice && ['desktop', 'tablet', 'mobile'].includes(savedDevice)) {
|
||
// setSelectedDevice(savedDevice as 'desktop' | 'tablet' | 'mobile')
|
||
// }
|
||
|
||
// // Load wireframe data
|
||
// const savedWireframeData = localStorage.getItem('wireframe_data')
|
||
// if (savedWireframeData) {
|
||
// try {
|
||
// const parsed = JSON.parse(savedWireframeData)
|
||
// setWireframeData(parsed)
|
||
// } catch (error) {
|
||
// console.error('Failed to parse saved wireframe data:', error)
|
||
// }
|
||
// }
|
||
// }
|
||
// }, [])
|
||
|
||
// // 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)
|
||
// }
|
||
|
||
// const handleWireframeGenerationStart = () => {
|
||
// setIsGenerating(true)
|
||
// }
|
||
|
||
// const handleDeviceChange = (device: 'desktop' | 'tablet' | 'mobile') => {
|
||
// console.log('DEBUG: AIMockupStep handleDeviceChange called with:', device)
|
||
// console.log('DEBUG: Previous selectedDevice state:', selectedDevice)
|
||
|
||
// setSelectedDevice(device)
|
||
|
||
// // Save to localStorage (only after mounting)
|
||
// if (mounted) {
|
||
// localStorage.setItem('wireframe_device_type', device)
|
||
// console.log('DEBUG: Saved device type to localStorage:', device)
|
||
// }
|
||
|
||
// console.log('DEBUG: New selectedDevice state:', device)
|
||
// }
|
||
|
||
// // Save wireframe data to localStorage when it changes (only after mounting)
|
||
// useEffect(() => {
|
||
// if (wireframeData && mounted) {
|
||
// localStorage.setItem('wireframe_data', JSON.stringify(wireframeData))
|
||
// }
|
||
// }, [wireframeData, mounted])
|
||
|
||
// // Debug: Log when selectedDevice prop changes
|
||
// useEffect(() => {
|
||
// console.log('DEBUG: AIMockupStep selectedDevice state changed to:', selectedDevice)
|
||
// }, [selectedDevice])
|
||
|
||
// // Debug: Log when selectedDevice prop changes
|
||
// useEffect(() => {
|
||
// console.log('DEBUG: WireframeCanvas selectedDevice prop changed to:', selectedDevice)
|
||
// }, [selectedDevice])
|
||
|
||
// return (
|
||
// <div className="max-w-7xl mx-auto space-y-6">
|
||
// <div className="text-center space-y-4">
|
||
// <h1 className="text-4xl font-bold text-white">AI Wireframe Mockup</h1>
|
||
// <p className="text-xl text-white/60 max-w-3xl mx-auto">
|
||
// Generate and customize wireframes for {template.title} using AI-powered design tools
|
||
// </p>
|
||
// <div className="flex items-center justify-center gap-2 text-orange-400">
|
||
// <Palette className="h-5 w-5" />
|
||
// <span className="text-sm font-medium">AI-Powered Wireframe Generation</span>
|
||
// </div>
|
||
// </div>
|
||
|
||
// {/* Dual Canvas Editor */}
|
||
// <div className="bg-white/5 border border-white/10 rounded-xl overflow-hidden h-[80vh] min-h-[600px]">
|
||
// <DualCanvasEditor
|
||
// className="h-full w-full"
|
||
// onWireframeGenerated={handleWireframeGenerated}
|
||
// onGenerationStart={handleWireframeGenerationStart}
|
||
// selectedDevice={selectedDevice}
|
||
// onDeviceChange={handleDeviceChange}
|
||
// initialPrompt={initialPrompt}
|
||
// />
|
||
// </div>
|
||
|
||
// {/* Wireframe Status and Controls */}
|
||
// <div className="bg-white/5 border border-white/10 rounded-xl p-6">
|
||
// <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||
// <div className="text-center">
|
||
// <div className="text-2xl font-bold text-white mb-2">
|
||
// {wireframeData ? '✅' : '⏳'}
|
||
// </div>
|
||
// <div className="text-white/80 font-medium">
|
||
// {wireframeData ? 'Wireframe Generated' : 'Ready to Generate'}
|
||
// </div>
|
||
// <div className="text-white/60 text-sm">
|
||
// {wireframeData ? 'Click Continue to proceed' : 'Use the AI panel to create wireframes'}
|
||
// </div>
|
||
// </div>
|
||
|
||
// <div className="text-center">
|
||
// <div className="text-2xl font-bold text-white mb-2">
|
||
// {selectedFeatures.length}
|
||
// </div>
|
||
// <div className="text-white/80 font-medium">Features Selected</div>
|
||
// <div className="text-white/60 text-sm">
|
||
// {template.title} template
|
||
// </div>
|
||
// </div>
|
||
|
||
// <div className="text-center">
|
||
// <div className="text-2xl font-bold text-white mb-2">
|
||
// {isGenerating ? '🔄' : '🎨'}
|
||
// </div>
|
||
// <div className="text-white/80 font-medium">
|
||
// {isGenerating ? 'Generating...' : 'AI Ready'}
|
||
// </div>
|
||
// <div className="text-white/60 text-sm">
|
||
// {isGenerating ? 'Creating wireframe layout' : 'Claude AI powered'}
|
||
// </div>
|
||
// </div>
|
||
// </div>
|
||
// </div>
|
||
|
||
// {/* Navigation */}
|
||
// <div className="text-center py-6">
|
||
// <div className="space-x-4">
|
||
// <Button variant="outline" onClick={onBack} className="border-white/20 text-white hover:bg-white/10">
|
||
// ← Back to Features
|
||
// </Button>
|
||
// <Button
|
||
// onClick={onNext}
|
||
// disabled={!wireframeData}
|
||
// className={`bg-orange-500 hover:bg-orange-400 text-black font-semibold py-2 rounded-lg shadow ${!wireframeData ? 'opacity-50 cursor-not-allowed' : ''
|
||
// }`}
|
||
// >
|
||
// Continue to Business Context →
|
||
// </Button>
|
||
// </div>
|
||
// <div className="text-white/60 text-sm mt-2">
|
||
// {wireframeData
|
||
// ? 'Wireframe generated successfully! Continue to define business requirements.'
|
||
// : 'Generate a wireframe first to continue to the next step.'
|
||
// }
|
||
// </div>
|
||
// </div>
|
||
// </div>
|
||
// )
|
||
// }
|
||
|
||
// Main Dashboard Component
|
||
export function MainDashboard() {
|
||
const [mounted, setMounted] = useState(false)
|
||
const [currentStep, setCurrentStep] = useState(1)
|
||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(() => {
|
||
// Only access localStorage after component mounts to prevent hydration mismatch
|
||
return null
|
||
})
|
||
const [selectedFeatures, setSelectedFeatures] = useState<TemplateFeature[]>(() => {
|
||
// Only access localStorage after component mounts to prevent hydration mismatch
|
||
return []
|
||
})
|
||
const [finalProjectData, setFinalProjectData] = useState<any>(() => {
|
||
// Only access localStorage after component mounts to prevent hydration mismatch
|
||
return null
|
||
})
|
||
const [techStackRecommendations, setTechStackRecommendations] = useState<any>(() => {
|
||
// Only access localStorage after component mounts to prevent hydration mismatch
|
||
return null
|
||
})
|
||
|
||
// Load state from localStorage after component mounts
|
||
useEffect(() => {
|
||
setMounted(true)
|
||
|
||
if (typeof window !== 'undefined') {
|
||
// Load current step
|
||
const savedStep = localStorage.getItem('dashboard_current_step')
|
||
if (savedStep) {
|
||
setCurrentStep(parseInt(savedStep) || 1)
|
||
}
|
||
|
||
// Load selected template
|
||
const savedTemplate = localStorage.getItem('dashboard_selected_template')
|
||
if (savedTemplate) {
|
||
try {
|
||
setSelectedTemplate(JSON.parse(savedTemplate))
|
||
} catch (error) {
|
||
console.error('Failed to parse saved template:', error)
|
||
}
|
||
}
|
||
|
||
// Load selected features
|
||
const savedFeatures = localStorage.getItem('dashboard_selected_features')
|
||
if (savedFeatures) {
|
||
try {
|
||
setSelectedFeatures(JSON.parse(savedFeatures))
|
||
} catch (error) {
|
||
console.error('Failed to parse saved features:', error)
|
||
}
|
||
}
|
||
|
||
// Load final project data
|
||
const savedProjectData = localStorage.getItem('dashboard_final_project_data')
|
||
if (savedProjectData) {
|
||
try {
|
||
setFinalProjectData(JSON.parse(savedProjectData))
|
||
} catch (error) {
|
||
console.error('Failed to parse saved project data:', error)
|
||
}
|
||
}
|
||
|
||
// Load tech stack recommendations
|
||
const savedRecommendations = localStorage.getItem('dashboard_tech_stack_recommendations')
|
||
if (savedRecommendations) {
|
||
try {
|
||
setTechStackRecommendations(JSON.parse(savedRecommendations))
|
||
} catch (error) {
|
||
console.error('Failed to parse saved recommendations:', error)
|
||
}
|
||
}
|
||
}
|
||
}, [mounted])
|
||
|
||
const steps = [
|
||
{ id: 1, name: "Project Type", description: "Choose template" },
|
||
{ id: 2, name: "Features", description: "Select features" },
|
||
// { id: 3, name: "AI Mockup", description: "Generate wireframes" },
|
||
{ id: 3, name: "Business Context", description: "Define requirements" },
|
||
{ id: 4, name: "Generate", description: "Create project" },
|
||
{ id: 5, name: "Architecture", description: "Review & deploy" },
|
||
]
|
||
|
||
// Save state to localStorage when it changes (only after mounting)
|
||
useEffect(() => {
|
||
if (mounted) {
|
||
localStorage.setItem('dashboard_current_step', currentStep.toString())
|
||
}
|
||
}, [currentStep, mounted])
|
||
|
||
useEffect(() => {
|
||
if (mounted) {
|
||
if (selectedTemplate) {
|
||
localStorage.setItem('dashboard_selected_template', JSON.stringify(selectedTemplate))
|
||
} else {
|
||
localStorage.removeItem('dashboard_selected_template')
|
||
}
|
||
}
|
||
}, [selectedTemplate, mounted])
|
||
|
||
useEffect(() => {
|
||
if (mounted) {
|
||
if (selectedFeatures.length > 0) {
|
||
localStorage.setItem('dashboard_selected_features', JSON.stringify(selectedFeatures))
|
||
} else {
|
||
localStorage.removeItem('dashboard_selected_features')
|
||
}
|
||
}
|
||
}, [selectedFeatures, mounted])
|
||
|
||
useEffect(() => {
|
||
if (mounted) {
|
||
if (finalProjectData) {
|
||
localStorage.setItem('dashboard_final_project_data', JSON.stringify(finalProjectData))
|
||
} else {
|
||
localStorage.removeItem('dashboard_final_project_data')
|
||
}
|
||
}
|
||
}, [finalProjectData, mounted])
|
||
|
||
useEffect(() => {
|
||
if (mounted) {
|
||
if (techStackRecommendations) {
|
||
localStorage.setItem('dashboard_tech_stack_recommendations', JSON.stringify(techStackRecommendations))
|
||
} else {
|
||
localStorage.removeItem('dashboard_tech_stack_recommendations')
|
||
}
|
||
}
|
||
}, [techStackRecommendations, mounted])
|
||
|
||
const renderStep = () => {
|
||
switch (currentStep) {
|
||
case 1:
|
||
return (
|
||
<TemplateSelectionStep
|
||
onNext={(template) => {
|
||
setSelectedTemplate(template)
|
||
setCurrentStep(2)
|
||
}}
|
||
/>
|
||
)
|
||
case 2:
|
||
return selectedTemplate ? (
|
||
<FeatureSelectionStep
|
||
template={selectedTemplate}
|
||
onNext={(sel) => { setSelectedFeatures(sel); setCurrentStep(3) }}
|
||
onBack={() => setCurrentStep(1)}
|
||
/>
|
||
) : null
|
||
case 3:
|
||
return selectedTemplate ? (
|
||
<BusinessQuestionsStep
|
||
template={selectedTemplate}
|
||
selected={selectedFeatures}
|
||
onBack={() => setCurrentStep(2)}
|
||
onDone={(data, recs) => { setFinalProjectData(data); setTechStackRecommendations(recs); setCurrentStep(4) }}
|
||
/>
|
||
) : null
|
||
case 4:
|
||
console.log('🔍 MainDashboard - techStackRecommendations:', techStackRecommendations)
|
||
console.log('🔍 MainDashboard - finalProjectData:', finalProjectData)
|
||
return (
|
||
<TechStackSummary
|
||
recommendations={techStackRecommendations}
|
||
completeData={finalProjectData}
|
||
onBack={() => setCurrentStep(3)}
|
||
onGenerate={(userTechStack) => {
|
||
// Store user's tech stack if provided
|
||
if (userTechStack) {
|
||
console.log('Storing user tech stack:', userTechStack)
|
||
// You can store this in state or pass it to the next step
|
||
}
|
||
setCurrentStep(5)
|
||
}}
|
||
/>
|
||
)
|
||
case 5:
|
||
return (
|
||
<ArchitectureDesignerStep
|
||
recommendations={techStackRecommendations}
|
||
onBack={() => setCurrentStep(4)}
|
||
/>
|
||
)
|
||
default:
|
||
return null
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-black">
|
||
|
||
{/* Progress Steps */}
|
||
{mounted && (
|
||
<div className="bg-white/5 border-b border-white/10">
|
||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||
<div className="py-4">
|
||
<nav className="flex justify-center">
|
||
<ol className="flex items-center space-x-8 text-white/60">
|
||
{steps.map((step, index) => (
|
||
<li key={`step-${step.id}`} className="flex items-center">
|
||
<div className="flex items-center">
|
||
<div
|
||
className={`flex items-center justify-center w-10 h-10 rounded-full border-2 transition-all ${currentStep >= step.id
|
||
? "bg-orange-500 border-orange-500 text-white shadow-lg"
|
||
: currentStep === step.id - 1
|
||
? "border-orange-300 text-orange-400"
|
||
: "border-white/30 text-white/40"
|
||
}`}
|
||
>
|
||
<span className="text-sm font-semibold">{step.id}</span>
|
||
</div>
|
||
<div className="ml-4">
|
||
<p
|
||
className={`text-sm font-semibold ${currentStep >= step.id ? "text-orange-400" : "text-white/60"
|
||
}`}
|
||
>
|
||
{step.name}
|
||
</p>
|
||
<p className="text-xs text-white/40">{step.description}</p>
|
||
</div>
|
||
</div>
|
||
{index < steps.length - 1 && (
|
||
<ArrowRight
|
||
key={`arrow-${step.id}`}
|
||
className={`ml-8 h-5 w-5 ${currentStep > step.id ? "text-orange-400" : "text-white/40"}`}
|
||
/>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ol>
|
||
</nav>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Main Content */}
|
||
<main className="py-8">
|
||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||
{!mounted ? (
|
||
<div className="flex items-center justify-center py-12">
|
||
<div className="text-center space-y-4">
|
||
<div className="animate-spin w-8 h-8 border-2 border-orange-500 border-t-transparent rounded-full mx-auto"></div>
|
||
<p className="text-white/60">Loading...</p>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
renderStep()
|
||
)}
|
||
</div>
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ArchitectureDesignerStep({ recommendations, onBack }: { recommendations: any; onBack: () => void }) {
|
||
const [architectureDesign, setArchitectureDesign] = useState<any>(null)
|
||
const [loading, setLoading] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [activeTab, setActiveTab] = useState<'overview' | 'frontend' | 'backend' | 'database' | 'deployment'>('overview')
|
||
|
||
useEffect(() => {
|
||
const generate = async () => {
|
||
if (!recommendations) {
|
||
setError('Missing technology recommendations')
|
||
return
|
||
}
|
||
try {
|
||
setLoading(true)
|
||
setError(null)
|
||
const resp = await fetch(`${BACKEND_URL}/api/v1/design-architecture`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ tech_stack_recommendations: recommendations }),
|
||
})
|
||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||
const data = await resp.json()
|
||
setArchitectureDesign(data)
|
||
} catch (e: any) {
|
||
setError(e?.message || 'Failed to generate architecture')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
generate()
|
||
}, [JSON.stringify(recommendations)])
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex items-center justify-center py-24">
|
||
<div className="text-center text-white/80">
|
||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-orange-500 mx-auto mb-4"></div>
|
||
<div className="font-semibold mb-1">AI Designing Your Architecture</div>
|
||
<div className="text-white/60 text-sm">Creating React components, Node.js APIs, and PostgreSQL schema…</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="text-center py-20">
|
||
<div className="max-w-md mx-auto bg-red-500/10 border border-red-500/30 rounded-lg p-6 text-red-300">
|
||
<div className="font-semibold mb-2">Architecture Generation Failed</div>
|
||
<div className="mb-4">{error}</div>
|
||
<div className="space-x-3">
|
||
<Button onClick={() => location.reload()} className="bg-red-500 hover:bg-red-400 text-black">Try Again</Button>
|
||
<Button variant="outline" onClick={onBack} className="border-white/20 text-white hover:bg-white/10">Back</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!architectureDesign) {
|
||
return (
|
||
<div className="text-center py-20">
|
||
<div className="max-w-md mx-auto bg-white/5 border border-white/10 rounded-lg p-6 text-white/80">
|
||
<div className="font-semibold mb-2">No Architecture Data</div>
|
||
<div className="mb-4">Please complete the tech stack selection first.</div>
|
||
<Button variant="outline" onClick={onBack} className="border-white/20 text-white hover:bg-white/10">Go Back</Button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const projectMeta = architectureDesign?.project_metadata
|
||
const arch = architectureDesign?.architecture_design
|
||
const tech = architectureDesign?.technology_specifications
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="bg-white/5 border-b border-white/10">
|
||
<div className="max-w-7xl mx-auto px-4 py-6 flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-3xl font-bold text-white">Architecture Design</h1>
|
||
<p className="text-white/60">AI-generated architecture for <span className="text-orange-400 font-semibold">{projectMeta?.project_name}</span></p>
|
||
</div>
|
||
<div className="space-x-3">
|
||
<Button variant="outline" onClick={onBack} className="border-white/20 text-white hover:bg-white/10">Back</Button>
|
||
<Button onClick={() => location.reload()} className="bg-orange-500 hover:bg-orange-400 text-black">Regenerate</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="max-w-7xl mx-auto px-4">
|
||
<div className="border-b border-white/10 mb-6">
|
||
<nav className="-mb-px flex space-x-8">
|
||
{[
|
||
{ id: 'overview', name: 'Overview' },
|
||
{ id: 'frontend', name: 'Frontend (React)' },
|
||
{ id: 'backend', name: 'Backend (Node.js)' },
|
||
{ id: 'database', name: 'Database (PostgreSQL)' },
|
||
{ id: 'deployment', name: 'Deployment' },
|
||
].map((tab) => (
|
||
<button
|
||
key={tab.id}
|
||
onClick={() => setActiveTab(tab.id as any)}
|
||
className={`py-2 px-1 border-b-2 text-sm transition-colors ${activeTab === (tab.id as any)
|
||
? 'border-orange-400 text-orange-400'
|
||
: 'border-transparent text-white/60 hover:text-white/80 hover:border-white/20'
|
||
}`}
|
||
>
|
||
{tab.name}
|
||
</button>
|
||
))}
|
||
</nav>
|
||
</div>
|
||
|
||
{activeTab === 'overview' && (
|
||
<div className="space-y-6">
|
||
<div className="bg-white/5 border border-white/10 rounded-xl p-6">
|
||
<h3 className="text-xl font-bold text-white mb-4">Project Overview</h3>
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-white/80">
|
||
<div className="bg-white/5 rounded-lg p-4">
|
||
<div className="font-semibold">Project Name</div>
|
||
<div>{projectMeta?.project_name}</div>
|
||
</div>
|
||
<div className="bg-white/5 rounded-lg p-4">
|
||
<div className="font-semibold">Complexity</div>
|
||
<div className="capitalize">{projectMeta?.complexity}</div>
|
||
</div>
|
||
<div className="bg-white/5 rounded-lg p-4">
|
||
<div className="font-semibold">Generated</div>
|
||
<div>{projectMeta?.architecture_generated_at ? new Date(projectMeta.architecture_generated_at).toLocaleDateString() : '-'}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white/5 border border-white/10 rounded-xl p-6">
|
||
<h3 className="text-xl font-bold text-white mb-4">Technology Stack</h3>
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-white/80">
|
||
<div className="bg-white/5 rounded-lg p-4">
|
||
<div className="font-semibold">Frontend</div>
|
||
<div>{tech?.frontend_framework}</div>
|
||
</div>
|
||
<div className="bg-white/5 rounded-lg p-4">
|
||
<div className="font-semibold">Backend</div>
|
||
<div>{tech?.backend_language}</div>
|
||
</div>
|
||
<div className="bg-white/5 rounded-lg p-4">
|
||
<div className="font-semibold">Database</div>
|
||
<div>{tech?.database_system}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'frontend' && (
|
||
<div className="bg-white/5 border border-white/10 rounded-xl p-6">
|
||
<h3 className="text-xl font-bold text-white mb-4">React Architecture</h3>
|
||
<pre className="bg-black/30 rounded-lg p-4 overflow-x-auto text-sm text-white/80">{JSON.stringify(arch?.frontend_architecture, null, 2)}</pre>
|
||
</div>
|
||
)}
|
||
{activeTab === 'backend' && (
|
||
<div className="bg-white/5 border border-white/10 rounded-xl p-6">
|
||
<h3 className="text-xl font-bold text-white mb-4">Node.js Architecture</h3>
|
||
<pre className="bg-black/30 rounded-lg p-4 overflow-x-auto text-sm text-white/80">{JSON.stringify(arch?.backend_architecture, null, 2)}</pre>
|
||
</div>
|
||
)}
|
||
{activeTab === 'database' && (
|
||
<div className="bg-white/5 border border-white/10 rounded-xl p-6">
|
||
<h3 className="text-xl font-bold text-white mb-4">PostgreSQL Architecture</h3>
|
||
<pre className="bg-black/30 rounded-lg p-4 overflow-x-auto text-sm text-white/80">{JSON.stringify(arch?.database_architecture, null, 2)}</pre>
|
||
</div>
|
||
)}
|
||
{activeTab === 'deployment' && (
|
||
<div className="bg-white/5 border border-white/10 rounded-xl p-6 text-white/80">
|
||
<h3 className="text-xl font-bold text-white mb-4">Deployment Configuration</h3>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||
<div className="bg-white/5 rounded-lg p-4">
|
||
<div className="font-semibold mb-2">Frontend Deployment</div>
|
||
<ul className="text-sm space-y-1">
|
||
<li>• Vercel/Netlify hosting</li>
|
||
<li>• React build optimization</li>
|
||
<li>• CDN distribution</li>
|
||
</ul>
|
||
</div>
|
||
<div className="bg-white/5 rounded-lg p-4">
|
||
<div className="font-semibold mb-2">Backend Deployment</div>
|
||
<ul className="text-sm space-y-1">
|
||
<li>• Docker containerization</li>
|
||
<li>• AWS/GCP hosting</li>
|
||
<li>• Auto-scaling setup</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
} |