laconic-deployer-frontend/apps/deploy-fe/src/components/onboarding/connect-step/connect-step.tsx

454 lines
18 KiB
TypeScript

// src/components/onboarding/connect-step/connect-step.tsx
'use client'
import { useState, useEffect } from 'react'
import { Github, Wallet, CheckCircle2, AlertTriangle, Loader2, ExternalLink, ChevronDown } from 'lucide-react'
import { useTheme } from 'next-themes'
import { SignIn } from '@clerk/nextjs'
import { useOnboarding } from '@/components/onboarding/useOnboarding'
import { useAuthStatus } from '@/hooks/useAuthStatus'
import { useRepoData } from '@/hooks/useRepoData'
import { Button } from '@workspace/ui/components/button'
import { Card, CardContent } from '@workspace/ui/components/card'
import { Input } from '@workspace/ui/components/input'
import { Label } from '@workspace/ui/components/label'
import { Alert, AlertDescription } from '@workspace/ui/components/alert'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@workspace/ui/components/collapsible'
import { toast } from 'sonner'
import { GitHubBackendAuth } from '@/components/GitHubBackendAuth'
import { AVAILABLE_TEMPLATES, type TemplateDetail } from '@/constants/templates'
interface Repository {
id: string | number
full_name: string
html_url?: string
description?: string
}
export function ConnectStep() {
const { nextStep, setFormData, formData } = useOnboarding()
const { resolvedTheme } = useTheme()
const [mounted, setMounted] = useState(false)
// Repository vs Template selection
const [selectedRepo, setSelectedRepo] = useState<string>(formData.githubRepo || '')
const [selectedTemplate, setSelectedTemplate] = useState<TemplateDetail | undefined>(
formData.template || undefined
)
const [projectName, setProjectName] = useState<string>(formData.projectName || '')
const [isImportMode, setIsImportMode] = useState(true)
// Auth status and warning display
const [showAuthWarning, setShowAuthWarning] = useState(false)
// Auth status hook
const {
clerk,
wallet,
backend,
isFullyAuthenticated,
isReady,
missing,
progress,
connectWallet,
checkGithubBackendAuth
} = useAuthStatus()
// Repository data
const { repoData: repositories, isLoading: isLoadingRepos } = useRepoData('')
// Handle hydration mismatch by waiting for mount
useEffect(() => {
setMounted(true)
}, [])
// Auto-hide auth warning when fully authenticated
useEffect(() => {
if (isFullyAuthenticated) {
setShowAuthWarning(false)
}
}, [isFullyAuthenticated])
// Handle repository selection
const handleRepoSelect = (repo: string) => {
setSelectedRepo(repo)
setSelectedTemplate(undefined)
setFormData({
githubRepo: repo,
template: undefined,
deploymentMode: 'repository',
projectName
})
}
// Handle template selection
const handleTemplateSelect = (template: TemplateDetail) => {
setSelectedTemplate(template)
setSelectedRepo('')
// Auto-fill project name if empty
if (!projectName) {
const suggestedName = `my-${template.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}`
setProjectName(suggestedName)
}
setFormData({
template: template,
githubRepo: '',
deploymentMode: 'template',
projectName: projectName || `my-${template.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}`
})
}
// Handle mode toggle between import and template
const toggleMode = (mode: 'import' | 'template') => {
setIsImportMode(mode === 'import')
// Clear selections when switching modes
if (mode === 'import') {
setSelectedTemplate(undefined)
setFormData({
template: undefined,
deploymentMode: 'repository',
projectName
})
} else {
setSelectedRepo('')
setFormData({
githubRepo: '',
deploymentMode: 'template',
projectName
})
}
}
// Handle project name change
const handleProjectNameChange = (value: string) => {
setProjectName(value)
setFormData({ projectName: value })
}
// Handle wallet connection
const handleConnectWallet = async () => {
try {
await connectWallet()
toast.success('Wallet connected successfully')
} catch (error) {
console.error('Wallet connection failed:', error)
toast.error('Failed to connect wallet')
}
}
// Handle GitHub backend auth status change
const handleGithubAuthChange = async (isAuthenticated: boolean) => {
await checkGithubBackendAuth()
if (isAuthenticated) {
toast.success('GitHub backend authentication completed!')
}
}
// Handle next step
const handleNext = () => {
if (!isFullyAuthenticated) {
toast.error('Please complete all authentication steps first')
setShowAuthWarning(true)
return
}
if (isImportMode && !selectedRepo) {
toast.error('Please select a repository to continue')
return
}
if (!isImportMode && (!selectedTemplate || !projectName.trim())) {
toast.error('Please select a template and enter a project name')
return
}
// For repository import, project name is optional but we'll use repo name as fallback
const finalProjectName = projectName.trim() || (isImportMode ? selectedRepo.split('/')[1] : '')
// Set final form data and proceed
setFormData({
deploymentMode: isImportMode ? 'repository' : 'template',
githubRepo: isImportMode ? selectedRepo : '',
template: !isImportMode ? selectedTemplate : undefined,
projectName: finalProjectName
})
nextStep()
}
// Don't render UI until after mount to prevent hydration mismatch
if (!mounted || !isReady) {
return (
<div className="w-full h-full flex items-center justify-center">
<div className="text-center">
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-4" />
<p className="text-sm text-zinc-500">Loading authentication status...</p>
</div>
</div>
)
}
// Determine if dark mode is active
const isDarkMode = resolvedTheme === 'dark'
return (
<div className="w-full h-full flex flex-col p-8 overflow-y-auto">
<div className="max-w-2xl w-full mx-auto">
{/* Header */}
<div className="text-center mb-8">
<h2 className={`text-2xl font-medium ${isDarkMode ? "text-white" : "text-zinc-900"} mb-2`}>
Connect
</h2>
<p className="text-zinc-500 mb-6">
Connect and import a GitHub repo or start from a template
</p>
{/* GitHub Account Selector - Only show if multiple accounts */}
{clerk.user?.externalAccounts && clerk.user.externalAccounts.length > 1 && (
<div className="flex items-center justify-center mb-6">
<div className="flex items-center gap-2 px-4 py-2 bg-zinc-100 dark:bg-zinc-800 rounded-md cursor-pointer hover:bg-zinc-200 dark:hover:bg-zinc-700">
<Github className="h-4 w-4" />
<span className="text-sm font-medium">
{clerk.user?.externalAccounts?.find(acc => acc.provider === 'github')?.username || 'git-account'}
</span>
<ChevronDown className="h-4 w-4" />
</div>
</div>
)}
</div>
{/* Authentication Warning - Only show if not fully authenticated */}
{!isFullyAuthenticated && (
<Collapsible open={showAuthWarning} onOpenChange={setShowAuthWarning}>
<CollapsibleTrigger asChild>
<Alert className="mb-6 cursor-pointer hover:bg-amber-50 dark:hover:bg-amber-950/20">
<AlertTriangle className="h-4 w-4" />
<AlertDescription className="flex items-center justify-between w-full">
<span>Authentication required to continue ({progress.completed}/{progress.total} complete)</span>
<ChevronDown className="h-4 w-4" />
</AlertDescription>
</Alert>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 mb-6">
{/* Authentication steps - same as before but in collapsible */}
{missing.clerkSignIn && (
<Card className="border-amber-200 bg-amber-50/50 dark:bg-amber-950/20">
<CardContent className="p-4">
<div className="flex items-center gap-3 mb-3">
<Github className="h-4 w-4" />
<span className="text-sm font-medium">Sign in with Clerk</span>
</div>
<div className="scale-90 origin-top-left">
<SignIn routing="hash" />
</div>
</CardContent>
</Card>
)}
{missing.clerkGithub && !missing.clerkSignIn && (
<Card className="border-amber-200 bg-amber-50/50 dark:bg-amber-950/20">
<CardContent className="p-4">
<div className="flex items-center gap-3 mb-3">
<Github className="h-4 w-4" />
<span className="text-sm font-medium">Connect GitHub Account</span>
</div>
<Button size="sm" variant="outline" onClick={() => window.open('/user-profile', '_blank')}>
<ExternalLink className="h-3 w-3 mr-2" />
Connect GitHub
</Button>
</CardContent>
</Card>
)}
{missing.walletConnection && (
<Card className="border-amber-200 bg-amber-50/50 dark:bg-amber-950/20">
<CardContent className="p-4">
<div className="flex items-center gap-3 mb-3">
<Wallet className="h-4 w-4" />
<span className="text-sm font-medium">Connect Wallet</span>
</div>
<Button size="sm" onClick={handleConnectWallet}>
Connect Wallet
</Button>
</CardContent>
</Card>
)}
{missing.githubBackendSync && !missing.walletConnection && !missing.clerkGithub && (
<Card className="border-amber-200 bg-amber-50/50 dark:bg-amber-950/20">
<CardContent className="p-4">
<div className="flex items-center gap-3 mb-3">
<Github className="h-4 w-4" />
<span className="text-sm font-medium">Sync GitHub Access</span>
</div>
<GitHubBackendAuth onAuthStatusChange={handleGithubAuthChange} />
</CardContent>
</Card>
)}
</CollapsibleContent>
</Collapsible>
)}
{/* Mode Selection Tabs */}
<div className="grid grid-cols-2 gap-1 p-1 bg-zinc-100 dark:bg-zinc-800 rounded-lg mb-6">
<Button
variant={isImportMode ? "default" : "ghost"}
className={`${isImportMode
? 'bg-white dark:bg-zinc-700 shadow-sm'
: 'bg-transparent hover:bg-white/50 dark:hover:bg-zinc-700/50'
}`}
onClick={() => toggleMode('import')}
>
Import a repository
</Button>
<Button
variant={!isImportMode ? "default" : "ghost"}
className={`${!isImportMode
? 'bg-white dark:bg-zinc-700 shadow-sm'
: 'bg-transparent hover:bg-white/50 dark:hover:bg-zinc-700/50'
}`}
onClick={() => toggleMode('template')}
>
Start with a template
</Button>
</div>
{/* Content Area */}
{isImportMode ? (
/* Repository Selection */
<div className="space-y-4">
{isLoadingRepos ? (
<div className="p-8 text-center text-zinc-500">
<div className="animate-spin h-5 w-5 border-2 border-zinc-500 border-t-transparent rounded-full mx-auto mb-2"></div>
Loading repositories...
</div>
) : !repositories || repositories.length === 0 ? (
<div className="p-8 text-center text-zinc-500">
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
No repositories found. Make sure your GitHub account has repositories.
</AlertDescription>
</Alert>
</div>
) : (
<>
<div className="space-y-2 max-h-60 overflow-y-auto">
{repositories.map((repo: Repository) => (
<div
key={repo.id}
className={`flex items-center p-4 rounded-lg border cursor-pointer transition-all ${
selectedRepo === repo.full_name
? 'border-blue-500 bg-blue-50 dark:bg-blue-950/20'
: 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600'
}`}
onClick={() => handleRepoSelect(repo.full_name)}
>
<Github className="h-5 w-5 mr-3 text-zinc-500 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-medium text-sm">{repo.full_name}</div>
{repo.description && (
<div className="text-xs text-zinc-500 truncate">{repo.description}</div>
)}
</div>
{selectedRepo === repo.full_name && (
<CheckCircle2 className="h-5 w-5 text-blue-500 flex-shrink-0" />
)}
</div>
))}
</div>
{/* Project Name Input for Repository Import */}
{selectedRepo && (
<div className="mt-6 space-y-2">
<Label htmlFor="projectName" className="text-sm font-medium">
Project Name
</Label>
<Input
id="projectName"
value={projectName}
onChange={(e) => handleProjectNameChange(e.target.value)}
placeholder="my-project-name"
className="w-full"
/>
<p className="text-xs text-zinc-500">
This will be the name of your deployment project
</p>
</div>
)}
</>
)}
</div>
) : (
/* Template Selection */
<div className="space-y-4">
{AVAILABLE_TEMPLATES.filter(t => !t.isComingSoon).map((template) => (
<div
key={template.id}
className={`flex items-center p-4 rounded-lg border cursor-pointer transition-all ${
selectedTemplate?.id === template.id
? 'border-blue-500 bg-blue-50 dark:bg-blue-950/20'
: 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600'
}`}
onClick={() => handleTemplateSelect(template)}
>
{/* Template Icon */}
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-zinc-100 dark:bg-zinc-800 mr-4">
<div className="w-6 h-6 bg-zinc-600 dark:bg-zinc-400 rounded flex items-center justify-center text-xs font-bold text-white">
{template.icon === 'web' ? 'PWA' : template.icon === 'nextjs' ? 'N' : 'IMG'}
</div>
</div>
{/* Template Info */}
<div className="flex-1 min-w-0">
<div className="font-medium text-sm mb-1">{template.name}</div>
<div className="flex items-center text-xs text-zinc-500">
<Github className="h-3 w-3 mr-1" />
{template.repoFullName}
</div>
</div>
{/* Selection Indicator */}
{selectedTemplate?.id === template.id && (
<CheckCircle2 className="h-5 w-5 text-blue-500 flex-shrink-0" />
)}
</div>
))}
{/* Project Name Input for Templates */}
{selectedTemplate && (
<div className="mt-6 space-y-2">
<Label htmlFor="projectName" className="text-sm font-medium">
Project Name
</Label>
<Input
id="projectName"
value={projectName}
onChange={(e) => handleProjectNameChange(e.target.value)}
placeholder="new-repository-name"
className="w-full"
/>
<p className="text-xs text-zinc-500">
This will be the name of your new GitHub repository
</p>
</div>
)}
</div>
)}
{/* Navigation */}
<div className="flex justify-between items-center mt-8">
<Button variant="outline" disabled>
Previous
</Button>
<Button
onClick={handleNext}
disabled={!isFullyAuthenticated || (isImportMode ? !selectedRepo : (!selectedTemplate || !projectName.trim()))}
>
Next
</Button>
</div>
</div>
</div>
)
}