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

516 lines
20 KiB
TypeScript

// src/components/onboarding/configure-step/configure-step.tsx
'use client'
import { useState, useEffect } from 'react'
import { PlusCircle, Loader2, AlertTriangle, Info } from 'lucide-react'
import { useTheme } from 'next-themes'
import { useOnboarding } from '@/components/onboarding/store'
import { useGQLClient } from '@/context'
import { useWallet } from '@/context/WalletContext'
import { Button } from '@workspace/ui/components/button'
import { Input } from '@workspace/ui/components/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@workspace/ui/components/select'
import { Checkbox } from '@workspace/ui/components/checkbox'
import { Label } from '@workspace/ui/components/label'
import { Alert, AlertDescription } from '@workspace/ui/components/alert'
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card'
import { Badge } from '@workspace/ui/components/badge'
import { toast } from 'sonner'
import { adaptDeployers } from '../../../utils/typeAdapters';
interface Deployer {
deployerLrn: string
deployerApiUrl: string
minimumPayment?: string
baseDomain: string
}
interface Organization {
id: string
name: string
slug: string
}
export function ConfigureStep() {
const { nextStep, previousStep, setFormData, formData } = useOnboarding()
const { resolvedTheme } = useTheme()
const [mounted, setMounted] = useState(false)
// Backend data
const [deployers, setDeployers] = useState<Deployer[]>([])
const [organizations, setOrganizations] = useState<Organization[]>([])
const [isLoadingDeployers, setIsLoadingDeployers] = useState(true)
const [isLoadingOrgs, setIsLoadingOrgs] = useState(true)
// Form state
const [deployOption, setDeployOption] = useState<'auction' | 'lrn'>(
formData.deploymentType as ('auction' | 'lrn') || 'lrn' // Default to LRN for simplicity
)
const [numberOfDeployers, setNumberOfDeployers] = useState<string>(
formData.deployerCount || "1"
)
const [maxPrice, setMaxPrice] = useState<string>(
formData.maxPrice || "1000"
)
const [selectedLrn, setSelectedLrn] = useState<string>(
formData.selectedLrn || ""
)
const [selectedOrg, setSelectedOrg] = useState<string>(
formData.selectedOrg || ""
)
const [envVars, setEnvVars] = useState<{ key: string; value: string; environments: string[] }[]>([
{ key: '', value: '', environments: ['Production'] }
])
// Contexts
const gqlClient = useGQLClient()
const { wallet } = useWallet()
// Handle hydration mismatch by waiting for mount
useEffect(() => {
setMounted(true)
}, [])
// Fetch deployers and organizations on mount
useEffect(() => {
if (mounted) {
fetchDeployers()
fetchOrganizations()
}
}, [mounted])
// Initialize environment variables from formData if available
useEffect(() => {
if (formData.environmentVariables && Array.isArray(formData.environmentVariables)) {
setEnvVars(formData.environmentVariables.length > 0 ? formData.environmentVariables : [
{ key: '', value: '', environments: ['Production'] }
])
}
}, [formData.environmentVariables])
// Fetch deployers from backend
const fetchDeployers = async () => {
try {
setIsLoadingDeployers(true)
const deployersData = await gqlClient.getDeployers()
console.log('Available deployers:', deployersData)
setDeployers(adaptDeployers(deployersData.deployers || []));
// Auto-select first deployer if available and none selected
if (deployersData.deployers && deployersData.deployers.length > 0 && !selectedLrn) {
setSelectedLrn(deployersData.deployers[0]!.deployerLrn)
}
} catch (error) {
console.error('Error fetching deployers:', error)
toast.error('Failed to load deployers')
} finally {
setIsLoadingDeployers(false)
}
}
// Fetch organizations from backend
const fetchOrganizations = async () => {
try {
setIsLoadingOrgs(true)
const orgsData = await gqlClient.getOrganizations()
console.log('Available organizations:', orgsData)
setOrganizations(orgsData.organizations || [])
// Auto-select first organization if available and none selected
if (orgsData.organizations && orgsData.organizations.length > 0 && !selectedOrg) {
setSelectedOrg(orgsData.organizations[0]!.slug)
}
} catch (error) {
console.error('Error fetching organizations:', error)
toast.error('Failed to load organizations')
} finally {
setIsLoadingOrgs(false)
}
}
// Add an empty environment variable row
const addEnvVar = () => {
setEnvVars([...envVars, { key: '', value: '', environments: ['Production'] }])
}
// Remove environment variable row
const removeEnvVar = (index: number) => {
if (envVars.length > 1) {
setEnvVars(envVars.filter((_, i) => i !== index))
}
}
// Update environment variable
const updateEnvVar = (index: number, field: 'key' | 'value', value: string) => {
const newEnvVars = [...envVars]
if (newEnvVars[index]) {
newEnvVars[index][field] = value
}
setEnvVars(newEnvVars)
}
// Toggle environment for variable
const toggleEnvironment = (index: number, environment: string) => {
const newEnvVars = [...envVars]
if (newEnvVars[index]?.environments) {
const currentEnvs = newEnvVars[index].environments
if (currentEnvs.includes(environment)) {
newEnvVars[index].environments = currentEnvs.filter(env => env !== environment)
} else {
newEnvVars[index].environments = [...currentEnvs, environment]
}
// Ensure at least one environment is selected
if (newEnvVars[index].environments.length === 0) {
newEnvVars[index].environments = ['Production']
}
setEnvVars(newEnvVars)
}
}
// Toggle deployment option
const toggleDeployOption = (option: 'auction' | 'lrn') => {
setDeployOption(option)
}
// Get selected deployer details
const selectedDeployer = deployers.find(d => d.deployerLrn === selectedLrn)
// Validate form
const canProceed = () => {
if (deployOption === 'lrn' && !selectedLrn) return false
if (!selectedOrg) return false
if (!wallet?.address) return false
return true
}
// Handle next step
const handleNext = () => {
if (!canProceed()) {
toast.error('Please complete all required fields')
return
}
// Filter out empty environment variables
const validEnvVars = envVars.filter(env => env.key.trim() && env.value.trim())
// Save configuration to form data
setFormData({
deploymentType: deployOption,
deployerCount: numberOfDeployers,
maxPrice: maxPrice,
selectedLrn: selectedLrn,
selectedOrg: selectedOrg,
paymentAddress: wallet?.address,
environmentVariables: validEnvVars
})
nextStep()
}
// Don't render UI until after mount to prevent hydration mismatch
if (!mounted) {
return null
}
// Determine if dark mode is active
const isDarkMode = resolvedTheme === 'dark'
// Get deployment mode info
const isTemplateMode = formData.deploymentMode === 'template'
const selectedItem = isTemplateMode ? formData.template?.name : formData.githubRepo
return (
<div className="w-full h-full flex flex-col p-8 overflow-y-auto">
{/* Configure icon and header */}
<div className="flex flex-col items-center justify-center mb-8">
<div className="mb-4">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" className={isDarkMode ? "text-white" : "text-black"}>
<path d="M12 20h9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<h2 className={`text-2xl font-medium text-center mb-2 ${isDarkMode ? "text-white" : "text-zinc-900"}`}>Configure</h2>
<p className={`text-center text-zinc-500 max-w-md`}>
Define the deployment type
</p>
</div>
<div className="max-w-xl mx-auto w-full">
{/* Project Summary */}
<Card className="mb-6">
<CardHeader className="pb-3">
<CardTitle className="text-sm flex items-center gap-2">
<Info className="h-4 w-4" />
Project Summary
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Type:</span>
<Badge variant="secondary">{isTemplateMode ? 'Template' : 'Repository'}</Badge>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Source:</span>
<span className="font-mono text-xs">{selectedItem}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Project Name:</span>
<span>{formData.projectName}</span>
</div>
</div>
</CardContent>
</Card>
{/* Organization Selection */}
<div className="mb-6">
<Label htmlFor="organization" className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}>
Organization *
</Label>
{isLoadingOrgs ? (
<div className="flex items-center justify-center p-3 border rounded-md">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
<span className="text-sm text-muted-foreground">Loading organizations...</span>
</div>
) : organizations.length === 0 ? (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
No organizations found. You need to be part of at least one organization.
</AlertDescription>
</Alert>
) : (
<Select value={selectedOrg} onValueChange={setSelectedOrg}>
<SelectTrigger id="organization" className={isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}>
<SelectValue placeholder="Select organization" />
</SelectTrigger>
<SelectContent>
{organizations.map((org) => (
<SelectItem key={org.id} value={org.slug}>
{org.name} ({org.slug})
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* Deployment options */}
<div className="mb-6">
<Label className={`text-sm mb-3 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}>
Deployment Type
</Label>
<div className="grid grid-cols-2 gap-2">
<Button
variant={deployOption === 'lrn' ? "default" : "outline"}
className={`py-3 ${deployOption === 'lrn'
? (isDarkMode ? 'bg-zinc-800 text-white' : 'bg-zinc-800 text-white')
: (isDarkMode ? 'bg-transparent border-zinc-700 text-zinc-400' : 'bg-transparent border-zinc-300 text-zinc-600')}`}
onClick={() => toggleDeployOption('lrn')}
>
Deployer LRN
</Button>
<Button
variant={deployOption === 'auction' ? "default" : "outline"}
className={`py-3 ${deployOption === 'auction'
? (isDarkMode ? 'bg-zinc-800 text-white' : 'bg-zinc-800 text-white')
: (isDarkMode ? 'bg-transparent border-zinc-700 text-zinc-400' : 'bg-transparent border-zinc-300 text-zinc-600')}`}
onClick={() => toggleDeployOption('auction')}
>
Create Auction
</Button>
</div>
</div>
{deployOption === 'lrn' ? (
/* LRN Deployment Settings */
<div className="mb-6">
<Label htmlFor="lrn" className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}>
Select Deployer LRN *
</Label>
{isLoadingDeployers ? (
<div className="flex items-center justify-center p-3 border rounded-md">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
<span className="text-sm text-muted-foreground">Loading deployers...</span>
</div>
) : deployers.length === 0 ? (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
No deployers available. Please contact support.
</AlertDescription>
</Alert>
) : (
<>
<Select value={selectedLrn} onValueChange={setSelectedLrn}>
<SelectTrigger id="lrn" className={isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}>
<SelectValue placeholder="Select a deployer" />
</SelectTrigger>
<SelectContent>
{deployers.map((deployer) => (
<SelectItem key={deployer.deployerLrn} value={deployer.deployerLrn}>
<div className="flex flex-col">
<span>{deployer.deployerLrn}</span>
{deployer.minimumPayment && (
<span className="text-xs text-muted-foreground">
Min payment: {deployer.minimumPayment}
</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{/* Deployer Details */}
{selectedDeployer && (
<div className="mt-3 p-3 bg-muted rounded-md">
<div className="text-sm space-y-1">
<div><strong>API URL:</strong> {selectedDeployer.deployerApiUrl}</div>
<div><strong>Base Domain:</strong> {selectedDeployer.baseDomain}</div>
{selectedDeployer.minimumPayment && (
<div><strong>Minimum Payment:</strong> {selectedDeployer.minimumPayment}</div>
)}
</div>
</div>
)}
</>
)}
</div>
) : (
/* Auction Settings */
<div className="grid grid-cols-2 gap-4 mb-6">
<div>
<Label htmlFor="deployers" className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}>
Number of Deployers
</Label>
<Select value={numberOfDeployers} onValueChange={setNumberOfDeployers}>
<SelectTrigger id="deployers" className={isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}>
<SelectValue placeholder="Select number" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1</SelectItem>
<SelectItem value="2">2</SelectItem>
<SelectItem value="3">3</SelectItem>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="maxPrice" className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}>
Maximum Price (aint)
</Label>
<Select value={maxPrice} onValueChange={setMaxPrice}>
<SelectTrigger id="maxPrice" className={isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}>
<SelectValue placeholder="Select price" />
</SelectTrigger>
<SelectContent>
<SelectItem value="500">500</SelectItem>
<SelectItem value="1000">1000</SelectItem>
<SelectItem value="2000">2000</SelectItem>
<SelectItem value="5000">5000</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
{/* Payment Address */}
<div className="mb-6">
<Label className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}>
Payment Address
</Label>
<div className={`p-3 border rounded-md bg-muted ${isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}`}>
<div className="text-sm font-mono break-all">
{wallet?.address || 'No wallet connected'}
</div>
</div>
</div>
{/* Environment Variables */}
<div className="mb-6">
<Label className={`text-sm font-medium mb-2 block ${isDarkMode ? 'text-white' : 'text-zinc-900'}`}>
Environment Variables
</Label>
<div className={`border rounded-md p-4 ${isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}`}>
{envVars.map((envVar, index) => (
<div key={index} className="space-y-2 mb-4 pb-4 border-b border-muted last:border-b-0 last:mb-0 last:pb-0">
<div className="grid grid-cols-2 gap-2">
<Input
placeholder="KEY"
value={envVar.key}
onChange={(e) => updateEnvVar(index, 'key', e.target.value)}
className={`bg-transparent ${isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}`}
/>
<Input
placeholder="VALUE"
value={envVar.value}
onChange={(e) => updateEnvVar(index, 'value', e.target.value)}
className={`bg-transparent ${isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}`}
/>
</div>
<div className="flex items-center gap-4">
<span className="text-xs text-muted-foreground">Environments:</span>
{['Production', 'Preview', 'Development'].map((env) => (
<div key={env} className="flex items-center gap-1">
<Checkbox
id={`${index}-${env}`}
checked={envVar.environments.includes(env)}
onCheckedChange={() => toggleEnvironment(index, env)}
/>
<Label htmlFor={`${index}-${env}`} className="text-xs">
{env}
</Label>
</div>
))}
{envVars.length > 1 && (
<Button
variant="ghost"
size="sm"
onClick={() => removeEnvVar(index)}
className="ml-auto text-red-500 hover:text-red-700 h-6 px-2"
>
Remove
</Button>
)}
</div>
</div>
))}
<Button
variant="outline"
className={`w-full mt-2 ${isDarkMode ? 'text-zinc-400 border-zinc-700' : 'text-zinc-600 border-zinc-300'}`}
onClick={addEnvVar}
>
<PlusCircle className="h-4 w-4 mr-2" />
Add Variable
</Button>
</div>
</div>
{/* Navigation buttons */}
<div className="flex justify-between items-center mt-4">
<Button
variant="outline"
className={`bg-transparent ${isDarkMode ? 'text-zinc-400 border-zinc-700' : 'text-zinc-600 border-zinc-300'}`}
onClick={previousStep}
>
Previous
</Button>
<Button
variant="default"
className={`${isDarkMode ? 'bg-zinc-800' : 'bg-zinc-800'} text-white hover:bg-zinc-700`}
onClick={handleNext}
disabled={!canProceed()}
>
Next
</Button>
</div>
</div>
</div>
)
}