516 lines
20 KiB
TypeScript
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>
|
|
)
|
|
} |