Files
cloud-host/frontend/src/app/dashboard/deploy/page.tsx
T
2026-04-23 15:26:49 +03:30

2079 lines
103 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useRef, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { useMutation, useQuery } from '@tanstack/react-query';
import api from '@/lib/api';
import { useAuthStore } from '@/lib/store';
import { toast } from 'react-toastify';
import type { CreateApplicationDto, ClusterPublic, ClusterPoolPublic, CostBreakdown, BillingCycle } from '@/types';
import { Hexagon, FolderUp, Link as LinkIcon, CheckCircle, Package, Server, Scale, Home, Target, BarChart3, RotateCw, KeyRound, Rocket, Clock, Upload, XCircle, Database, Eye, EyeOff, RefreshCw, DollarSign, Wallet, CreditCard, Loader2 } from 'lucide-react';
const steps = ['Basic Info', 'Versions & Database', 'Resources', 'Review'];
type DeployStage = 'idle' | 'creating' | 'uploading-source' | 'uploading-db' | 'paying' | 'deploying' | 'done' | 'error';
const stageLabels: Record<DeployStage, string> = {
idle: '',
creating: 'Creating application...',
'uploading-source': 'Uploading source code...',
'uploading-db': 'Uploading database dump...',
paying: 'Processing payment...',
deploying: 'Starting deployment...',
done: 'Redirecting...',
error: 'An error occurred',
};
export default function DeployPage() {
const router = useRouter();
const user = useAuthStore((s) => s.user);
const isAdmin = user?.role === 'admin' || user?.role === 'technical';
const [step, setStep] = useState(0);
const [form, setForm] = useState<CreateApplicationDto>({
name: '',
description: '',
runtime: 'nodejs',
databaseType: 'none',
runtimeVersion: '20',
phpVersion: '',
dbVersion: '',
gitUrl: '',
gitToken: '',
gitBranch: '',
envVars: {},
cpuRequest: '100m',
cpuLimit: '500m',
memoryRequest: '128Mi',
memoryLimit: '512Mi',
replicas: 1,
port: 3000,
dbStorageSize: '1',
appStorageSize: '2',
enableRedis: false,
enableRabbitmq: false,
enableElasticsearch: false,
});
const [envKey, setEnvKey] = useState('');
const [envVal, setEnvVal] = useState('');
const [sourceMethod, setSourceMethod] = useState<'git' | 'upload'>('upload');
const [zipFile, setZipFile] = useState<File | null>(null);
const [uploadProgress, setUploadProgress] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [clusterMode, setClusterMode] = useState<'default' | 'manual' | 'pool'>('default');
const [showDbPassword, setShowDbPassword] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [dbDumpFile, setDbDumpFile] = useState<File | null>(null);
const dbDumpInputRef = useRef<HTMLInputElement>(null);
const [dbUploadProgress, setDbUploadProgress] = useState(0);
const [deployStage, setDeployStage] = useState<DeployStage>('idle');
const [wpMode, setWpMode] = useState<'fresh' | 'migrate' | 'public_html'>('fresh');
const [wpContentFile, setWpContentFile] = useState<File | null>(null);
const [isWpDragging, setIsWpDragging] = useState(false);
const wpFileInputRef = useRef<HTMLInputElement>(null);
const [selectedCycle, setSelectedCycle] = useState<BillingCycle>('monthly');
const [paymentMethod, setPaymentMethod] = useState<'wallet' | 'gateway'>('wallet');
const [isPaid, setIsPaid] = useState(false);
const { data: clusters = [] } = useQuery<ClusterPublic[]>({
queryKey: ['clusters-public'],
queryFn: () => api.get('/clusters/public').then((r) => r.data),
enabled: isAdmin,
});
const { data: pools = [] } = useQuery<ClusterPoolPublic[]>({
queryKey: ['pools-public'],
queryFn: () => api.get('/clusters/pools/public').then((r) => r.data),
enabled: isAdmin,
});
// Cost calculation for the review step
const { data: costData, isLoading: costLoading } = useQuery<CostBreakdown>({
queryKey: ['deploy-cost', form.runtime, form.databaseType, form.cpuLimit, form.memoryLimit, form.replicas, form.dbStorageSize, form.appStorageSize, form.enableRedis, form.enableRabbitmq, form.enableElasticsearch],
queryFn: () => api.post('/billing/calculate', {
runtime: form.runtime,
databaseType: form.databaseType,
cpuLimit: form.cpuLimit,
memoryLimit: form.memoryLimit,
replicas: form.replicas,
dbStorageSize: form.databaseType !== 'none' ? `${parseInt(form.dbStorageSize || '1', 10) || 1}` : undefined,
appStorageSize: `${parseInt(form.appStorageSize || '2', 10) || 2}`,
enableRedis: form.enableRedis,
enableRabbitmq: form.enableRabbitmq,
enableElasticsearch: form.enableElasticsearch,
}).then((r) => r.data),
enabled: step === 3,
});
// Wallet balance for the review step payment
const { data: walletData } = useQuery<{ balance: number }>({
queryKey: ['wallet-balance'],
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
enabled: step === 3,
});
const payAmount = costData ? costData[selectedCycle] : 0;
const walletBalance = walletData?.balance ?? 0;
const hasEnoughBalance = walletBalance >= payAmount;
const walletPayMutation = useMutation({
mutationFn: async () => {
// First create the app
setDeployStage('creating');
const payload = { ...form };
if (payload.databaseType !== 'none' && payload.dbStorageSize) {
payload.dbStorageSize = `${parseInt(payload.dbStorageSize, 10) || 1}Gi`;
}
if (payload.appStorageSize) {
payload.appStorageSize = `${parseInt(payload.appStorageSize, 10) || 2}Gi`;
}
const res = await api.post('/applications', payload);
const appId = res.data.id;
// Upload source (regular apps or WordPress migrate/public_html)
const fileToUpload = form.runtime === 'wordpress' ? ((wpMode === 'migrate' || wpMode === 'public_html') ? wpContentFile : null) : (sourceMethod === 'upload' ? zipFile : null);
if (fileToUpload) {
setDeployStage('uploading-source');
setUploadProgress(0);
const formData = new FormData();
formData.append('file', fileToUpload);
await api.post(`/applications/${appId}/upload`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e) => { if (e.total) setUploadProgress(Math.round((e.loaded * 100) / e.total)); },
});
}
// Upload DB dump
if (form.databaseType !== 'none' && dbDumpFile) {
setDeployStage('uploading-db');
setDbUploadProgress(0);
const formData = new FormData();
formData.append('file', dbDumpFile);
await api.post(`/applications/${appId}/db-upload`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e) => { if (e.total) setDbUploadProgress(Math.round((e.loaded * 100) / e.total)); },
});
}
// Deduct from wallet
setDeployStage('paying');
await api.post(`/billing/wallet/pay/${appId}`, { amount: payAmount, cycle: selectedCycle });
return res;
},
onSuccess: (res) => {
setDeployStage('deploying');
toast.success('Payment successful! Deploying...');
api.post(`/deployments/applications/${res.data.id}/deploy`).catch(() => {});
setDeployStage('done');
router.push(`/dashboard/apps/${res.data.id}`);
},
onError: (err: any) => {
setDeployStage('error');
toast.error(err.response?.data?.message || 'Payment or deployment failed');
setUploadProgress(0);
setDbUploadProgress(0);
setTimeout(() => setDeployStage('idle'), 2000);
},
});
const gatewayPayMutation = useMutation({
mutationFn: async () => {
// Initiate gateway
setDeployStage('paying');
const { data: gw } = await api.post('/billing/gateway/initiate', {
amount: payAmount,
description: `Deploy: ${form.name} (${selectedCycle})`,
callbackUrl: `${window.location.origin}/dashboard/deploy`,
});
// In production, redirect to gw.gatewayUrl
// For now, auto-verify (simulated)
await api.post('/billing/gateway/verify', {
trackingCode: gw.trackingCode,
amount: payAmount,
});
// Now create the app
setDeployStage('creating');
const payload = { ...form };
if (payload.databaseType !== 'none' && payload.dbStorageSize) {
payload.dbStorageSize = `${parseInt(payload.dbStorageSize, 10) || 1}Gi`;
}
if (payload.appStorageSize) {
payload.appStorageSize = `${parseInt(payload.appStorageSize, 10) || 2}Gi`;
}
const res = await api.post('/applications', payload);
const appId = res.data.id;
// Upload source (regular apps or WordPress migrate/public_html)
const fileToUpload = form.runtime === 'wordpress' ? ((wpMode === 'migrate' || wpMode === 'public_html') ? wpContentFile : null) : (sourceMethod === 'upload' ? zipFile : null);
if (fileToUpload) {
setDeployStage('uploading-source');
setUploadProgress(0);
const formData = new FormData();
formData.append('file', fileToUpload);
await api.post(`/applications/${appId}/upload`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e) => { if (e.total) setUploadProgress(Math.round((e.loaded * 100) / e.total)); },
});
}
// Upload DB dump
if (form.databaseType !== 'none' && dbDumpFile) {
setDeployStage('uploading-db');
setDbUploadProgress(0);
const formData = new FormData();
formData.append('file', dbDumpFile);
await api.post(`/applications/${appId}/db-upload`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e) => { if (e.total) setDbUploadProgress(Math.round((e.loaded * 100) / e.total)); },
});
}
// Deduct from the wallet (which was just charged by gateway)
setDeployStage('paying');
await api.post(`/billing/wallet/pay/${appId}`, { amount: payAmount, cycle: selectedCycle });
return res;
},
onSuccess: (res) => {
setDeployStage('deploying');
toast.success('Payment successful! Deploying...');
api.post(`/deployments/applications/${res.data.id}/deploy`).catch(() => {});
setDeployStage('done');
router.push(`/dashboard/apps/${res.data.id}`);
},
onError: (err: any) => {
setDeployStage('error');
toast.error(err.response?.data?.message || 'Payment failed');
setUploadProgress(0);
setDbUploadProgress(0);
setTimeout(() => setDeployStage('idle'), 2000);
},
});
const createMutation = useMutation({
mutationFn: async (data: CreateApplicationDto) => {
setDeployStage('creating');
const res = await api.post('/applications', data);
const appId = res.data.id;
// Upload zip file if selected
// Upload source (regular apps or WordPress migrate/public_html)
const fileToUpload = data.runtime === 'wordpress' ? ((wpMode === 'migrate' || wpMode === 'public_html') ? wpContentFile : null) : (sourceMethod === 'upload' ? zipFile : null);
if (fileToUpload) {
setDeployStage('uploading-source');
setUploadProgress(0);
const formData = new FormData();
formData.append('file', fileToUpload);
await api.post(`/applications/${appId}/upload`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e) => {
if (e.total) setUploadProgress(Math.round((e.loaded * 100) / e.total));
},
});
}
// Upload DB dump if provided and a database was requested
if (data.databaseType && data.databaseType !== 'none' && dbDumpFile) {
setDeployStage('uploading-db');
setDbUploadProgress(0);
const formData = new FormData();
formData.append('file', dbDumpFile);
await api.post(`/applications/${appId}/db-upload`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e) => {
if (e.total) setDbUploadProgress(Math.round((e.loaded * 100) / e.total));
},
});
}
return res;
},
onSuccess: (res) => {
setDeployStage('deploying');
toast.success('Application created! Triggering deployment...');
api.post(`/deployments/applications/${res.data.id}/deploy`).catch(() => {});
setDeployStage('done');
router.push(`/dashboard/apps/${res.data.id}`);
},
onError: (err: any) => {
setDeployStage('error');
toast.error(err.response?.data?.message || 'Failed to create application');
setUploadProgress(0);
setDbUploadProgress(0);
setTimeout(() => setDeployStage('idle'), 2000);
},
});
const addEnvVar = () => {
if (envKey.trim()) {
setForm({ ...form, envVars: { ...form.envVars, [envKey]: envVal } });
setEnvKey('');
setEnvVal('');
}
};
const removeEnvVar = (key: string) => {
const updated = { ...form.envVars };
delete updated[key];
setForm({ ...form, envVars: updated });
};
const handleSubmit = () => {
const payload = { ...form };
// Format dbStorageSize with Gi suffix
if (payload.databaseType !== 'none' && payload.dbStorageSize) {
payload.dbStorageSize = `${parseInt(payload.dbStorageSize, 10) || 1}Gi`;
}
// Format appStorageSize with Gi suffix (all app types)
if (payload.appStorageSize) {
payload.appStorageSize = `${parseInt(payload.appStorageSize, 10) || 2}Gi`;
}
createMutation.mutate(payload);
};
const handleFileSelect = useCallback((file: File) => {
const validTypes = ['application/zip', 'application/x-zip-compressed', 'application/gzip', 'application/x-tar'];
const validExtensions = ['.zip', '.tar.gz', '.tgz'];
const hasValidExt = validExtensions.some((ext) => file.name.toLowerCase().endsWith(ext));
if (!hasValidExt && !validTypes.includes(file.type)) {
toast.error('Only .zip or .tar.gz files are allowed');
return;
}
if (file.size > 100 * 1024 * 1024) {
toast.error('File size must be less than 100MB');
return;
}
setZipFile(file);
toast.success(`Selected: ${file.name}`);
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (file) handleFileSelect(file);
}, [handleFileSelect]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
}, []);
const handleWpFileSelect = useCallback((file: File) => {
const validExtensions = ['.zip', '.tar.gz', '.tgz'];
const hasValidExt = validExtensions.some((ext) => file.name.toLowerCase().endsWith(ext));
if (!hasValidExt) {
toast.error('Only .zip or .tar.gz files are allowed');
return;
}
if (file.size > 200 * 1024 * 1024) {
toast.error('WordPress files must be less than 200MB');
return;
}
setWpContentFile(file);
// Auto-suggest app storage size based on file size (add 50% buffer, minimum 2GB)
const fileSizeGb = file.size / (1024 * 1024 * 1024);
const suggestedSize = Math.max(2, Math.ceil(fileSizeGb * 1.5));
setForm((prev) => ({ ...prev, appStorageSize: String(suggestedSize) }));
toast.success(`WordPress files selected: ${file.name}`);
}, []);
const handleWpDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsWpDragging(false);
const file = e.dataTransfer.files[0];
if (file) handleWpFileSelect(file);
}, [handleWpFileSelect]);
const canNext = () => {
if (step === 0) {
if (form.name.length < 2) return false;
// WordPress: migrate or public_html mode requires wp-content file
if (form.runtime === 'wordpress') {
if ((wpMode === 'migrate' || wpMode === 'public_html') && !wpContentFile) return false;
} else {
if (sourceMethod === 'upload' && !zipFile) return false;
if (sourceMethod === 'git' && !form.gitUrl) return false;
}
return true;
}
return true;
};
return (
<div className="max-w-2xl mx-auto space-y-8 animate-fade-in">
<div>
<h1 className="page-title">Deploy New Application</h1>
<p className="page-subtitle">Follow the steps to deploy your app to the cloud.</p>
</div>
{/* Step indicator */}
<div className="flex items-center justify-between">
{steps.map((label, i) => (
<div key={label} className="flex items-center flex-1 last:flex-none">
<div className="flex flex-col items-center">
<div className={`flex items-center justify-center w-9 h-9 rounded-full text-sm font-bold transition-all duration-300 ${
i < step
? 'bg-emerald-500 text-white shadow-md'
: i === step
? 'bg-primary-600 text-white shadow-lg ring-4 ring-primary-100'
: 'bg-gray-200 text-gray-500'
}`}>
{i < step ? '✓' : i + 1}
</div>
<span className={`mt-1.5 text-xs font-medium hidden sm:block ${
i <= step ? 'text-gray-900' : 'text-gray-400'
}`}>
{label}
</span>
</div>
{i < steps.length - 1 && (
<div className={`flex-1 h-0.5 mx-2 sm:mx-3 rounded-full transition-colors duration-300 ${
i < step ? 'bg-emerald-400' : 'bg-gray-200'
}`} />
)}
</div>
))}
</div>
<div className="card">
{/* Step 0: Basic Info */}
{step === 0 && (
<div className="space-y-5">
<h2 className="text-lg font-semibold text-gray-900">Basic Information</h2>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Application Name</label>
<input
className="input-field"
placeholder="my-awesome-app"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '-') })}
/>
<p className="mt-1 text-xs text-gray-400">Lowercase letters, numbers, and hyphens only</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description (optional)</label>
<textarea
className="input-field"
rows={3}
placeholder="What does this app do?"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
/>
</div>
{/* Application Runtime */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">Application Type</label>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ value: 'nodejs', label: 'Node.js', icon: <Hexagon className="w-5 h-5 text-green-500" />, desc: 'Express, NestJS, Fastify' },
{ value: 'laravel', label: 'Laravel', icon: <Hexagon className="w-5 h-5 text-orange-500" />, desc: 'PHP, Composer, Artisan' },
{ value: 'wordpress', label: 'WordPress', icon: <Hexagon className="w-5 h-5 text-blue-600" />, desc: 'Official image' },
{ value: 'go', label: 'Go', icon: <Hexagon className="w-5 h-5 text-cyan-500" />, desc: 'Gin, Echo, Fiber' },
{ value: 'python', label: 'Python', icon: <Hexagon className="w-5 h-5 text-yellow-500" />, desc: 'Flask, FastAPI' },
{ value: 'django', label: 'Django', icon: <Hexagon className="w-5 h-5 text-green-700" />, desc: 'Python web framework' },
{ value: 'php', label: 'PHP', icon: <Hexagon className="w-5 h-5 text-indigo-500" />, desc: 'Plain PHP apps' },
{ value: 'dotnet', label: '.NET', icon: <Hexagon className="w-5 h-5 text-purple-500" />, desc: 'ASP.NET Core' },
].map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => {
const updates: any = { runtime: opt.value as any, phpVersion: '', runtimeVersion: '' };
if (opt.value === 'nodejs') { updates.port = 3000; updates.runtimeVersion = '20'; }
else if (opt.value === 'laravel') { updates.port = 8000; updates.phpVersion = '8.3'; }
else if (opt.value === 'wordpress') { updates.port = 80; updates.databaseType = 'mysql'; updates.runtimeVersion = '6.7'; updates.phpVersion = '8.3'; }
else if (opt.value === 'go') { updates.port = 8080; updates.runtimeVersion = '1.22'; }
else if (opt.value === 'python') { updates.port = 8000; updates.runtimeVersion = '3.12'; }
else if (opt.value === 'django') { updates.port = 8000; updates.runtimeVersion = '3.12'; }
else if (opt.value === 'php') { updates.port = 80; updates.phpVersion = '8.3'; }
else if (opt.value === 'dotnet') { updates.port = 5000; updates.runtimeVersion = '8.0'; }
setForm({ ...form, ...updates });
// Reset WordPress-specific state when switching types
if (opt.value !== 'wordpress') { setWpMode('fresh'); setWpContentFile(null); }
}}
className={`p-3 rounded-xl border-2 text-left transition-colors ${
form.runtime === opt.value ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
}`}
>
{opt.icon}
<p className="mt-1.5 font-semibold text-sm text-gray-900">{opt.label}</p>
<p className="text-xs text-gray-500">{opt.desc}</p>
</button>
))}
</div>
</div>
{/* Source Code Method — hidden for WordPress */}
{form.runtime !== 'wordpress' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">Source Code</label>
<div className="grid grid-cols-2 gap-3 mb-4">
<button
type="button"
onClick={() => setSourceMethod('upload')}
className={`p-3 rounded-xl border-2 text-center transition-colors ${
sourceMethod === 'upload'
? 'border-primary-500 bg-primary-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<FolderUp className="w-5 h-5 mx-auto text-gray-500" />
<p className="mt-1 font-semibold text-sm text-gray-900">Upload ZIP</p>
</button>
<button
type="button"
onClick={() => setSourceMethod('git')}
className={`p-3 rounded-xl border-2 text-center transition-colors ${
sourceMethod === 'git'
? 'border-primary-500 bg-primary-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<LinkIcon className="w-5 h-5 mx-auto text-gray-500" />
<p className="mt-1 font-semibold text-sm text-gray-900">Git Repository</p>
</button>
</div>
{sourceMethod === 'git' ? (
<div className="space-y-3">
<div>
<label className="block text-xs text-gray-500 mb-1">Repository URL</label>
<input
className="input-field"
placeholder="https://github.com/user/repo.git"
value={form.gitUrl}
onChange={(e) => setForm({ ...form, gitUrl: e.target.value })}
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">
Access Token <span className="text-gray-400">(for private repos)</span>
</label>
<input
className="input-field"
type="password"
placeholder="ghp_xxxxxxxxxxxx or glpat-xxxxxxxxxxxx"
value={form.gitToken || ''}
onChange={(e) => setForm({ ...form, gitToken: e.target.value })}
/>
<p className="mt-1 text-xs text-gray-400">
GitHub: Personal Access Token · GitLab: Project Access Token · Leave empty for public repos
</p>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">
Branch <span className="text-gray-400">(default: main)</span>
</label>
<input
className="input-field"
placeholder="main"
value={form.gitBranch || ''}
onChange={(e) => setForm({ ...form, gitBranch: e.target.value })}
/>
</div>
</div>
) : (
<div>
{zipFile ? (
<div className="flex items-center justify-between p-4 bg-green-50 border border-green-200 rounded-xl">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center text-green-600">
<CheckCircle className="w-5 h-5" />
</div>
<div>
<p className="text-sm font-medium text-green-800">{zipFile.name}</p>
<p className="text-xs text-green-600">
{(zipFile.size / (1024 * 1024)).toFixed(2)} MB
</p>
</div>
</div>
<button
type="button"
onClick={() => {
setZipFile(null);
if (fileInputRef.current) fileInputRef.current.value = '';
}}
className="text-sm text-red-500 hover:text-red-700 font-medium"
>
Remove
</button>
</div>
) : (
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => fileInputRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all ${
isDragging
? 'border-primary-500 bg-primary-50'
: 'border-gray-300 hover:border-primary-400 hover:bg-gray-50'
}`}
>
<div className="space-y-2">
<Package className="w-8 h-8 mx-auto text-gray-400" />
<p className="text-sm font-medium text-gray-700">
Drag & drop your project ZIP here
</p>
<p className="text-xs text-gray-500">
or click to browse <strong>.zip</strong> files only Max 100MB
</p>
</div>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept=".zip,.tar.gz,.tgz"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileSelect(file);
e.target.value = '';
}}
/>
</div>
)}
</div>
)}
{/* WordPress deployment mode — shown when WordPress is selected */}
{form.runtime === 'wordpress' && (
<div className="space-y-4">
<label className="block text-sm font-medium text-gray-700 mb-2">Deployment Mode</label>
<div className="grid grid-cols-3 gap-3">
<button
type="button"
onClick={() => { setWpMode('fresh'); setWpContentFile(null); }}
className={`p-4 rounded-xl border-2 text-left transition-colors ${
wpMode === 'fresh' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
}`}
>
<Rocket className="w-5 h-5 text-blue-600" />
<p className="mt-2 font-semibold text-sm text-gray-900">Fresh Install</p>
<p className="text-xs text-gray-500 mt-1">
Start with a clean WordPress. Install themes & plugins via the admin panel.
</p>
</button>
<button
type="button"
onClick={() => { setWpMode('migrate'); setWpContentFile(null); }}
className={`p-4 rounded-xl border-2 text-left transition-colors ${
wpMode === 'migrate' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
}`}
>
<Upload className="w-5 h-5 text-emerald-600" />
<p className="mt-2 font-semibold text-sm text-gray-900">Migrate Existing Site</p>
<p className="text-xs text-gray-500 mt-1">
Upload your WordPress files (wp-content, themes, plugins) and optionally a DB dump.
</p>
</button>
<button
type="button"
onClick={() => { setWpMode('public_html'); setWpContentFile(null); }}
className={`p-4 rounded-xl border-2 text-left transition-colors ${
wpMode === 'public_html' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
}`}
>
<FolderUp className="w-5 h-5 text-purple-600" />
<p className="mt-2 font-semibold text-sm text-gray-900">Upload public_html</p>
<p className="text-xs text-gray-500 mt-1">
Upload your entire public_html directory (full WordPress root) and deploy.
</p>
</button>
</div>
{wpMode === 'fresh' && (
<div className="p-4 bg-blue-50/50 border border-blue-200 rounded-xl">
<div className="flex items-center gap-2 mb-2">
<Hexagon className="w-5 h-5 text-blue-600" />
<h3 className="text-sm font-semibold text-gray-800">WordPress (Official Image)</h3>
</div>
<p className="text-xs text-gray-500">
A fresh WordPress installation will be deployed using the official Docker image.
You can install themes and plugins via the WordPress admin panel after deployment.
</p>
</div>
)}
{wpMode === 'migrate' && (
<div className="space-y-3">
<div className="p-3 bg-amber-50 border border-amber-200 rounded-xl">
<p className="text-xs text-gray-600">
<strong>Upload a ZIP</strong> containing your WordPress files. Supported structures:
</p>
<ul className="text-xs text-gray-500 mt-1 ml-4 list-disc space-y-0.5">
<li><code className="bg-amber-100 px-1 rounded">wp-content/</code> themes, plugins, uploads</li>
<li><code className="bg-amber-100 px-1 rounded">wp-config.php</code> custom configuration (optional)</li>
<li>Any custom <code className="bg-amber-100 px-1 rounded">.php</code> files at root level</li>
</ul>
<p className="text-xs text-gray-500 mt-1.5">
You can also upload a SQL database dump in the next step to restore your data.
</p>
</div>
{wpContentFile ? (
<div className="flex items-center justify-between p-4 bg-green-50 border border-green-200 rounded-xl">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center text-green-600">
<CheckCircle className="w-5 h-5" />
</div>
<div>
<p className="text-sm font-medium text-green-800">{wpContentFile.name}</p>
<p className="text-xs text-green-600">
{(wpContentFile.size / (1024 * 1024)).toFixed(2)} MB
</p>
</div>
</div>
<button
type="button"
onClick={() => {
setWpContentFile(null);
if (wpFileInputRef.current) wpFileInputRef.current.value = '';
}}
className="text-sm text-red-500 hover:text-red-700 font-medium"
>
Remove
</button>
</div>
) : (
<div
onDrop={handleWpDrop}
onDragOver={(e) => { e.preventDefault(); setIsWpDragging(true); }}
onDragLeave={(e) => { e.preventDefault(); setIsWpDragging(false); }}
onClick={() => wpFileInputRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all ${
isWpDragging
? 'border-primary-500 bg-primary-50'
: 'border-gray-300 hover:border-primary-400 hover:bg-gray-50'
}`}
>
<div className="space-y-2">
<FolderUp className="w-8 h-8 mx-auto text-gray-400" />
<p className="text-sm font-medium text-gray-700">
Drag & drop your WordPress ZIP here
</p>
<p className="text-xs text-gray-500">
ZIP with <strong>wp-content/</strong> folder Max 200MB
</p>
</div>
</div>
)}
<input
ref={wpFileInputRef}
type="file"
accept=".zip,.tar.gz,.tgz"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleWpFileSelect(file);
e.target.value = '';
}}
/>
</div>
)}
{wpMode === 'public_html' && (
<div className="space-y-3">
<div className="p-3 bg-purple-50 border border-purple-200 rounded-xl">
<p className="text-xs text-gray-600">
<strong>Upload a ZIP</strong> of your entire <code className="bg-purple-100 px-1 rounded">public_html</code> directory (the full WordPress root):
</p>
<ul className="text-xs text-gray-500 mt-1 ml-4 list-disc space-y-0.5">
<li><code className="bg-purple-100 px-1 rounded">wp-admin/</code>, <code className="bg-purple-100 px-1 rounded">wp-includes/</code>, <code className="bg-purple-100 px-1 rounded">wp-content/</code></li>
<li><code className="bg-purple-100 px-1 rounded">wp-config.php</code>, <code className="bg-purple-100 px-1 rounded">.htaccess</code>, and all root PHP files</li>
</ul>
<p className="text-xs text-gray-500 mt-1.5">
The system auto-detects the full WordPress root and deploys it accordingly.
You can also upload a SQL database dump in the next step to restore your data.
</p>
</div>
{wpContentFile ? (
<div className="flex items-center justify-between p-4 bg-green-50 border border-green-200 rounded-xl">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center text-green-600">
<CheckCircle className="w-5 h-5" />
</div>
<div>
<p className="text-sm font-medium text-green-800">{wpContentFile.name}</p>
<p className="text-xs text-green-600">
{(wpContentFile.size / (1024 * 1024)).toFixed(2)} MB
</p>
</div>
</div>
<button
type="button"
onClick={() => {
setWpContentFile(null);
if (wpFileInputRef.current) wpFileInputRef.current.value = '';
}}
className="text-sm text-red-500 hover:text-red-700 font-medium"
>
Remove
</button>
</div>
) : (
<div
onDrop={handleWpDrop}
onDragOver={(e) => { e.preventDefault(); setIsWpDragging(true); }}
onDragLeave={(e) => { e.preventDefault(); setIsWpDragging(false); }}
onClick={() => wpFileInputRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all ${
isWpDragging
? 'border-primary-500 bg-primary-50'
: 'border-gray-300 hover:border-primary-400 hover:bg-gray-50'
}`}
>
<div className="space-y-2">
<FolderUp className="w-8 h-8 mx-auto text-gray-400" />
<p className="text-sm font-medium text-gray-700">
Drag & drop your public_html ZIP here
</p>
<p className="text-xs text-gray-500">
ZIP with full WordPress root (<strong>wp-admin/</strong>, <strong>wp-content/</strong>, ...) Max 200MB
</p>
</div>
</div>
)}
<input
ref={wpFileInputRef}
type="file"
accept=".zip,.tar.gz,.tgz"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleWpFileSelect(file);
e.target.value = '';
}}
/>
</div>
)}
</div>
)}
</div>
)}
{/* Step 1: Versions & Database */}
{step === 1 && (
<div className="space-y-6">
<h2 className="text-lg font-semibold text-gray-900">Versions & Database</h2>
{/* Runtime Version Selectors */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{form.runtime === 'nodejs' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Node.js Version</label>
<select
className="input-field"
value={form.runtimeVersion || '20'}
onChange={(e) => setForm({ ...form, runtimeVersion: e.target.value })}
>
<option value="22">Node.js 22 (LTS)</option>
<option value="20">Node.js 20 (LTS)</option>
<option value="18">Node.js 18</option>
<option value="16">Node.js 16</option>
</select>
</div>
)}
{form.runtime === 'laravel' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">PHP Version</label>
<select
className="input-field"
value={form.phpVersion || '8.3'}
onChange={(e) => setForm({ ...form, phpVersion: e.target.value })}
>
<option value="8.4">PHP 8.4</option>
<option value="8.3">PHP 8.3 (Recommended)</option>
<option value="8.2">PHP 8.2</option>
<option value="8.1">PHP 8.1</option>
</select>
</div>
)}
{form.runtime === 'wordpress' && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">WordPress Version</label>
<select
className="input-field"
value={form.runtimeVersion || '6.7'}
onChange={(e) => setForm({ ...form, runtimeVersion: e.target.value })}
>
<option value="6.7">WordPress 6.7 (Latest)</option>
<option value="6.6">WordPress 6.6</option>
<option value="6.5">WordPress 6.5</option>
<option value="6.4">WordPress 6.4</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">PHP Version</label>
<select
className="input-field"
value={form.phpVersion || '8.3'}
onChange={(e) => setForm({ ...form, phpVersion: e.target.value })}
>
<option value="8.3">PHP 8.3 (Recommended)</option>
<option value="8.2">PHP 8.2</option>
<option value="8.1">PHP 8.1</option>
</select>
</div>
</>
)}
{form.runtime === 'go' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Go Version</label>
<select
className="input-field"
value={form.runtimeVersion || '1.22'}
onChange={(e) => setForm({ ...form, runtimeVersion: e.target.value })}
>
<option value="1.23">Go 1.23 (Latest)</option>
<option value="1.22">Go 1.22 (Recommended)</option>
<option value="1.21">Go 1.21</option>
</select>
</div>
)}
{(form.runtime === 'python' || form.runtime === 'django') && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Python Version</label>
<select
className="input-field"
value={form.runtimeVersion || '3.12'}
onChange={(e) => setForm({ ...form, runtimeVersion: e.target.value })}
>
<option value="3.13">Python 3.13 (Latest)</option>
<option value="3.12">Python 3.12 (Recommended)</option>
<option value="3.11">Python 3.11</option>
<option value="3.10">Python 3.10</option>
</select>
</div>
)}
{form.runtime === 'php' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">PHP Version</label>
<select
className="input-field"
value={form.phpVersion || '8.3'}
onChange={(e) => setForm({ ...form, phpVersion: e.target.value })}
>
<option value="8.4">PHP 8.4 (Latest)</option>
<option value="8.3">PHP 8.3 (Recommended)</option>
<option value="8.2">PHP 8.2</option>
<option value="8.1">PHP 8.1</option>
</select>
</div>
)}
{form.runtime === 'dotnet' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">.NET Version</label>
<select
className="input-field"
value={form.runtimeVersion || '8.0'}
onChange={(e) => setForm({ ...form, runtimeVersion: e.target.value })}
>
<option value="9.0">.NET 9.0 (Latest)</option>
<option value="8.0">.NET 8.0 LTS (Recommended)</option>
<option value="7.0">.NET 7.0</option>
<option value="6.0">.NET 6.0 LTS</option>
</select>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
Database
{form.runtime === 'wordpress' && (
<span className="text-xs text-blue-500 mr-2"> WordPress requires MySQL/MariaDB</span>
)}
</label>
<div className="grid grid-cols-3 sm:grid-cols-5 gap-3 sm:gap-4">
{[
{ value: 'none', label: 'None', icon: <XCircle className="w-6 h-6 text-gray-400" /> },
{ value: 'postgresql', label: 'PostgreSQL', icon: <svg className="w-6 h-6 text-blue-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19C3 20.66 7.03 22 12 22C16.97 22 21 20.66 21 19V5"/><path d="M3 12C3 13.66 7.03 15 12 15C16.97 15 21 13.66 21 12"/></svg> },
{ value: 'mysql', label: 'MySQL', icon: <svg className="w-6 h-6 text-orange-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19C3 20.66 7.03 22 12 22C16.97 22 21 20.66 21 19V5"/><path d="M3 12C3 13.66 7.03 15 12 15C16.97 15 21 13.66 21 12"/></svg> },
{ value: 'mariadb', label: 'MariaDB', icon: <svg className="w-6 h-6 text-teal-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19C3 20.66 7.03 22 12 22C16.97 22 21 20.66 21 19V5"/><path d="M3 12C3 13.66 7.03 15 12 15C16.97 15 21 13.66 21 12"/></svg> },
{ value: 'mongodb', label: 'MongoDB', icon: <svg className="w-6 h-6 text-green-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 2L12 22M12 2C7.58 4 5 8 5 12C5 16 7.58 20 12 22M12 2C16.42 4 19 8 19 12C19 16 16.42 20 12 22"/></svg> },
].map((opt) => {
const isWordPress = form.runtime === 'wordpress';
const disabled = isWordPress && !['mysql', 'mariadb'].includes(opt.value);
return (
<button
key={opt.value}
type="button"
disabled={disabled}
onClick={() => {
let dbVersion = '';
if (opt.value === 'postgresql') dbVersion = '16';
else if (opt.value === 'mysql') dbVersion = '8.0';
else if (opt.value === 'mariadb') dbVersion = '11.4';
else if (opt.value === 'mongodb') dbVersion = '7.0';
setForm({ ...form, databaseType: opt.value as any, dbVersion });
}}
className={`p-4 rounded-xl border-2 text-center transition-colors ${
form.databaseType === opt.value ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
} ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
>
<div className="flex justify-center">{opt.icon}</div>
<p className="mt-2 font-semibold text-sm text-gray-900">{opt.label}</p>
</button>
);
})}
</div>
</div>
{/* Database Version — shown when a DB is selected */}
{form.databaseType !== 'none' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{form.databaseType === 'postgresql' ? 'PostgreSQL' : form.databaseType === 'mysql' ? 'MySQL' : form.databaseType === 'mariadb' ? 'MariaDB' : 'MongoDB'} Version
</label>
<select
className="input-field max-w-xs"
value={form.dbVersion || (form.databaseType === 'postgresql' ? '16' : form.databaseType === 'mysql' ? '8.0' : form.databaseType === 'mariadb' ? '11.4' : '7.0')}
onChange={(e) => setForm({ ...form, dbVersion: e.target.value })}
>
{form.databaseType === 'postgresql' ? (
<>
<option value="17">PostgreSQL 17 (Latest)</option>
<option value="16">PostgreSQL 16 (LTS)</option>
<option value="15">PostgreSQL 15</option>
<option value="14">PostgreSQL 14</option>
</>
) : form.databaseType === 'mysql' ? (
<>
<option value="9.0">MySQL 9.0 (Latest)</option>
<option value="8.4">MySQL 8.4 (LTS)</option>
<option value="8.0">MySQL 8.0</option>
<option value="5.7">MySQL 5.7</option>
</>
) : form.databaseType === 'mariadb' ? (
<>
<option value="11.4">MariaDB 11.4 (LTS)</option>
<option value="11.3">MariaDB 11.3</option>
<option value="10.11">MariaDB 10.11 (LTS)</option>
<option value="10.6">MariaDB 10.6</option>
</>
) : (
<>
<option value="7.0">MongoDB 7.0 (Latest)</option>
<option value="6.0">MongoDB 6.0</option>
<option value="5.0">MongoDB 5.0</option>
</>
)}
</select>
</div>
)}
{/* Database Credentials — shown when a DB is selected */}
{form.databaseType !== 'none' && (
<div className="p-4 bg-blue-50/50 border border-blue-200 rounded-xl space-y-4">
<div className="flex items-center gap-2">
<Database className="w-5 h-5 text-blue-500" />
<h3 className="text-sm font-semibold text-gray-800">Database Credentials</h3>
<span className="text-xs text-gray-400">(optional auto-generated if empty)</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs text-gray-500 mb-1">Username</label>
<input
className="input-field"
placeholder="appuser"
value={form.dbUsername || ''}
onChange={(e) => setForm({ ...form, dbUsername: e.target.value })}
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Password</label>
<div className="relative">
<input
className="input-field pr-20"
type={showDbPassword ? 'text' : 'password'}
placeholder="Auto-generated"
value={form.dbPassword || ''}
onChange={(e) => setForm({ ...form, dbPassword: e.target.value })}
/>
<div className="absolute inset-y-0 right-0 flex items-center gap-1 pr-2">
<button
type="button"
onClick={() => {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let pass = '';
for (let i = 0; i < 20; i++) pass += chars.charAt(Math.floor(Math.random() * chars.length));
setForm({ ...form, dbPassword: pass });
setShowDbPassword(true);
}}
className="p-1 text-gray-400 hover:text-blue-500 transition-colors"
title="Generate random password"
>
<RefreshCw className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => setShowDbPassword(!showDbPassword)}
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
>
{showDbPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
</div>
</div>
<p className="text-xs text-gray-400">
These credentials are used for internal cluster communication only. The database is not exposed externally.
</p>
{/* Optional DB dump upload at creation time */}
<div className="pt-2">
<label className="block text-xs text-gray-500 mb-2">Optional: Upload DB dump to restore at creation</label>
<div
onDrop={(e) => {
e.preventDefault();
setIsDragging(false);
const f = e.dataTransfer.files[0];
if (f) {
if (!f.name.endsWith('.sql') && !f.name.endsWith('.gz') && !f.name.endsWith('.dump')) {
toast.error('Allowed: .sql, .gz, .dump');
} else if (f.size > 500 * 1024 * 1024) {
toast.error('Max 500MB');
} else {
setDbDumpFile(f);
const sizeGb = Math.max(1, Math.ceil((f.size / (1024 * 1024 * 1024)) * 3));
setForm((prev) => ({ ...prev, dbStorageSize: String(sizeGb) }));
}
}
}}
onDragOver={(e) => e.preventDefault()}
onClick={() => dbDumpInputRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-3 text-center cursor-pointer transition-colors ${dbDumpFile ? 'border-blue-400 bg-blue-50' : 'border-gray-300 hover:border-blue-400 hover:bg-gray-50'}`}
>
<input
ref={dbDumpInputRef}
type="file"
accept=".sql,.gz,.dump"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) {
if (!f.name.endsWith('.sql') && !f.name.endsWith('.gz') && !f.name.endsWith('.dump')) {
toast.error('Allowed: .sql, .gz, .dump');
} else if (f.size > 500 * 1024 * 1024) {
toast.error('Max 500MB');
} else {
setDbDumpFile(f);
const sizeGb = Math.max(1, Math.ceil((f.size / (1024 * 1024 * 1024)) * 3));
setForm((prev) => ({ ...prev, dbStorageSize: String(sizeGb) }));
}
}
e.target.value = '';
}}
/>
{dbDumpFile ? (
<div className="flex items-center justify-between">
<div className="text-sm text-left">
<p className="font-medium text-gray-800">{dbDumpFile.name}</p>
<p className="text-xs text-gray-500">{(dbDumpFile.size / (1024 * 1024)).toFixed(2)} MB</p>
</div>
<div className="flex items-center gap-2">
<button type="button" onClick={() => setDbDumpFile(null)} className="text-sm text-red-500">Remove</button>
</div>
</div>
) : (
<div>
<p className="text-sm text-gray-700">Upload a SQL dump to be restored after the database is created</p>
<p className="text-xs text-gray-400">Optional Max 500MB</p>
</div>
)}
</div>
</div>
{/* Database Storage Size */}
<div className="pt-2">
<label className="block text-xs text-gray-500 mb-2">Database Storage Size</label>
<div className="flex items-center gap-3">
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden">
<button
type="button"
onClick={() => {
const current = parseInt(form.dbStorageSize || '1', 10);
if (current > 1) setForm({ ...form, dbStorageSize: String(current - 1) });
}}
disabled={parseInt(form.dbStorageSize || '1', 10) <= 1}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
</button>
<input
type="number"
min={1}
max={100}
value={form.dbStorageSize || '1'}
onChange={(e) => {
const val = Math.max(1, Math.min(100, parseInt(e.target.value, 10) || 1));
setForm({ ...form, dbStorageSize: String(val) });
}}
className="w-16 text-center py-2 border-x border-gray-300 text-sm font-semibold focus:outline-none"
/>
<button
type="button"
onClick={() => {
const current = parseInt(form.dbStorageSize || '1', 10);
if (current < 100) setForm({ ...form, dbStorageSize: String(current + 1) });
}}
disabled={parseInt(form.dbStorageSize || '1', 10) >= 100}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
+
</button>
</div>
<span className="text-sm font-medium text-gray-700">GB</span>
{dbDumpFile && (
<span className="text-xs text-blue-500">
Suggested based on dump size ({(dbDumpFile.size / (1024 * 1024 * 1024)).toFixed(2)} GB)
</span>
)}
</div>
<p className="mt-1 text-xs text-gray-400">Minimum 1GB Only expansion is allowed after creation</p>
</div>
</div>
)}
{/* Optional Services Section */}
<div className="bg-purple-50 rounded-xl p-5 border border-purple-200">
<div className="flex items-center gap-2 mb-4">
<Server className="w-5 h-5 text-purple-600" />
<h3 className="font-semibold text-gray-900">Optional Services</h3>
<span className="text-xs text-gray-400">(Enable additional services for your app)</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{/* Redis */}
<button
type="button"
onClick={() => setForm({ ...form, enableRedis: !form.enableRedis })}
className={`p-4 rounded-xl border-2 text-left transition-all ${
form.enableRedis
? 'border-red-400 bg-red-50 shadow-sm'
: 'border-gray-200 bg-white hover:border-gray-300'
}`}
>
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${form.enableRedis ? 'bg-red-100' : 'bg-gray-100'}`}>
<svg className={`w-6 h-6 ${form.enableRedis ? 'text-red-500' : 'text-gray-400'}`} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2L2 7L12 12L22 7L12 2ZM2 17L12 22L22 17M2 12L12 17L22 12" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
</div>
<div>
<p className="font-semibold text-gray-900">Redis</p>
<p className="text-xs text-gray-500">In-memory cache & store</p>
</div>
</div>
{form.enableRedis && (
<div className="mt-3 pt-3 border-t border-red-200 text-xs text-red-600">
<p>REDIS_HOST, REDIS_PASSWORD, REDIS_URL will be available</p>
</div>
)}
</button>
{/* RabbitMQ */}
<button
type="button"
onClick={() => setForm({ ...form, enableRabbitmq: !form.enableRabbitmq })}
className={`p-4 rounded-xl border-2 text-left transition-all ${
form.enableRabbitmq
? 'border-orange-400 bg-orange-50 shadow-sm'
: 'border-gray-200 bg-white hover:border-gray-300'
}`}
>
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${form.enableRabbitmq ? 'bg-orange-100' : 'bg-gray-100'}`}>
<svg className={`w-6 h-6 ${form.enableRabbitmq ? 'text-orange-500' : 'text-gray-400'}`} viewBox="0 0 24 24" fill="currentColor">
<path d="M21 3H3v18h18V3zM8 17H5v-3h3v3zm5-4h-3v-3h3v3zm5 0h-3v-3h3v3zm0-4h-8V6h8v3z"/>
</svg>
</div>
<div>
<p className="font-semibold text-gray-900">RabbitMQ</p>
<p className="text-xs text-gray-500">Message broker</p>
</div>
</div>
{form.enableRabbitmq && (
<div className="mt-3 pt-3 border-t border-orange-200 text-xs text-orange-600">
<p>RABBITMQ_HOST, RABBITMQ_USER, AMQP_URL will be available</p>
</div>
)}
</button>
{/* Elasticsearch */}
<button
type="button"
onClick={() => setForm({ ...form, enableElasticsearch: !form.enableElasticsearch })}
className={`p-4 rounded-xl border-2 text-left transition-all ${
form.enableElasticsearch
? 'border-yellow-400 bg-yellow-50 shadow-sm'
: 'border-gray-200 bg-white hover:border-gray-300'
}`}
>
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${form.enableElasticsearch ? 'bg-yellow-100' : 'bg-gray-100'}`}>
<svg className={`w-6 h-6 ${form.enableElasticsearch ? 'text-yellow-500' : 'text-gray-400'}`} viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="2" fill="none"/>
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" stroke="currentColor" strokeWidth="2"/>
</svg>
</div>
<div>
<p className="font-semibold text-gray-900">Elasticsearch</p>
<p className="text-xs text-gray-500">Logging & search</p>
</div>
</div>
{form.enableElasticsearch && (
<div className="mt-3 pt-3 border-t border-yellow-200 text-xs text-yellow-600">
<p>Logs collected via Fluent Bit sidecar</p>
</div>
)}
</button>
</div>
{(form.enableRedis || form.enableRabbitmq || form.enableElasticsearch) && (
<p className="mt-4 text-xs text-gray-500">
Each enabled service adds to the monthly cost. Services are deployed in your namespace and not shared.
</p>
)}
</div>
{/* App Storage Size (all app types) */}
<div className="bg-green-50 rounded-xl p-5 border border-green-200">
<div className="flex items-center gap-2 mb-3">
<FolderUp className="w-5 h-5 text-green-600" />
<h3 className="font-semibold text-gray-900">
{form.runtime === 'wordpress' ? 'Upload Storage (wp-content)'
: form.runtime === 'laravel' ? 'Storage Directory'
: 'Application Data Storage'}
</h3>
</div>
<p className="text-sm text-gray-600 mb-4">
{form.runtime === 'wordpress'
? 'This space is used for uploads, plugins, themes, and other WordPress files.'
: form.runtime === 'laravel'
? 'This space is used for uploads, logs, cache, and other Laravel storage files.'
: 'This space is used for persistent application data, uploads, and files.'}
</p>
<div className="flex items-center gap-3">
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden bg-white">
<button
type="button"
onClick={() => {
const current = parseInt(form.appStorageSize || '2', 10);
if (current > 1) setForm({ ...form, appStorageSize: String(current - 1) });
}}
disabled={parseInt(form.appStorageSize || '2', 10) <= 1}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
</button>
<input
type="number"
min={1}
max={100}
value={form.appStorageSize || '2'}
onChange={(e) => {
const val = Math.max(1, Math.min(100, parseInt(e.target.value, 10) || 2));
setForm({ ...form, appStorageSize: String(val) });
}}
className="w-16 text-center py-2 border-x border-gray-300 text-sm font-semibold focus:outline-none"
/>
<button
type="button"
onClick={() => {
const current = parseInt(form.appStorageSize || '2', 10);
if (current < 100) setForm({ ...form, appStorageSize: String(current + 1) });
}}
disabled={parseInt(form.appStorageSize || '2', 10) >= 100}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
+
</button>
</div>
<span className="text-sm font-medium text-gray-700">GB</span>
{wpContentFile && form.runtime === 'wordpress' && (
<span className="text-xs text-green-600">
Suggested based on wp-content ({(wpContentFile.size / (1024 * 1024 * 1024)).toFixed(2)} GB)
</span>
)}
</div>
<p className="mt-2 text-xs text-gray-500">Minimum 1GB Recommended: 2GB or more</p>
</div>
</div>
)}
{/* Step 2: Resources */}
{step === 2 && (
<div className="space-y-6">
<h2 className="text-lg font-semibold text-gray-900">Resources & Configuration</h2>
{/* Cluster Assignment Mode — Admin only */}
{isAdmin ? (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Cluster Assignment</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-4">
<button
type="button"
onClick={() => {
setClusterMode('default');
setForm({ ...form, clusterId: undefined, poolId: undefined });
}}
className={`p-3 rounded-xl border-2 text-center transition-colors ${
clusterMode === 'default'
? 'border-primary-500 bg-primary-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<Home className="w-5 h-5 mx-auto text-gray-500" />
<p className="mt-1 font-semibold text-sm text-gray-900">Default</p>
<p className="text-xs text-gray-500">Use default cluster</p>
</button>
<button
type="button"
onClick={() => {
setClusterMode('manual');
setForm({ ...form, poolId: undefined });
}}
className={`p-3 rounded-xl border-2 text-center transition-colors ${
clusterMode === 'manual'
? 'border-primary-500 bg-primary-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<Target className="w-5 h-5 mx-auto text-gray-500" />
<p className="mt-1 font-semibold text-sm text-gray-900">Manual</p>
<p className="text-xs text-gray-500">Pick a specific cluster</p>
</button>
<button
type="button"
onClick={() => {
setClusterMode('pool');
setForm({ ...form, clusterId: undefined });
}}
className={`p-3 rounded-xl border-2 text-center transition-colors ${
clusterMode === 'pool'
? 'border-primary-500 bg-primary-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<Scale className="w-5 h-5 mx-auto text-gray-500" />
<p className="mt-1 font-semibold text-sm text-gray-900">Load Balanced</p>
<p className="text-xs text-gray-500">Pick a cluster pool</p>
</button>
</div>
{/* Manual: show cluster list */}
{clusterMode === 'manual' && (
<div className="space-y-2">
{clusters.length === 0 ? (
<p className="text-sm text-gray-400 text-center py-4">No clusters available</p>
) : (
clusters.map((cluster) => (
<button
key={cluster.id}
type="button"
onClick={() => setForm({ ...form, clusterId: cluster.id })}
className={`w-full p-3 rounded-xl border-2 text-left transition-colors ${
form.clusterId === cluster.id
? 'border-primary-500 bg-primary-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Server className="w-5 h-5 text-gray-400" />
<div>
<p className="font-semibold text-sm text-gray-900">
{cluster.name}
{cluster.isDefault && (
<span className="ml-2 text-xs bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded">Default</span>
)}
</p>
<p className="text-xs text-gray-500">
{[cluster.provider, cluster.region].filter(Boolean).join(' · ') || 'No region info'}
</p>
</div>
</div>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
cluster.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'
}`}>
{cluster.status}
</span>
</div>
</button>
))
)}
</div>
)}
{/* Pool: show pool list */}
{clusterMode === 'pool' && (
<div className="space-y-2">
{pools.length === 0 ? (
<div className="text-center py-4">
<p className="text-sm text-gray-400">No cluster pools configured</p>
<p className="text-xs text-gray-400 mt-1">Ask your admin to create a cluster pool</p>
</div>
) : (
pools.map((pool) => (
<button
key={pool.id}
type="button"
onClick={() => setForm({ ...form, poolId: pool.id })}
className={`w-full p-3 rounded-xl border-2 text-left transition-colors ${
form.poolId === pool.id
? 'border-primary-500 bg-primary-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Scale className="w-5 h-5 text-gray-400" />
<div>
<p className="font-semibold text-sm text-gray-900">{pool.name}</p>
{pool.description && (
<p className="text-xs text-gray-500">{pool.description}</p>
)}
<div className="flex items-center space-x-2 mt-1">
<span className="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded flex items-center gap-1">
{pool.strategy === 'least-apps' ? <><BarChart3 className="w-3 h-3" /> Least Apps</> : <><RotateCw className="w-3 h-3" /> Round Robin</>}
</span>
<span className="text-xs text-gray-400">
{pool.clusters.length} cluster{pool.clusters.length !== 1 ? 's' : ''}:
{' '}{pool.clusters.map((c) => c.name).join(', ')}
</span>
</div>
</div>
</div>
</div>
</button>
))
)}
</div>
)}
{/* Default: info text */}
{clusterMode === 'default' && (
<div className="p-3 bg-gray-50 rounded-xl border border-gray-200">
<div className="flex items-center space-x-3">
<Home className="w-5 h-5 text-gray-400" />
<div>
<p className="text-sm font-medium text-gray-700">Default cluster will be used</p>
<p className="text-xs text-gray-500">
Your app will be deployed to the platform&apos;s default cluster
{clusters.find((c) => c.isDefault) && (
<> <strong>{clusters.find((c) => c.isDefault)?.name}</strong></>
)}
</p>
</div>
</div>
</div>
)}
</div>
) : (
<div className="p-3 bg-gray-50 rounded-xl border border-gray-200">
<div className="flex items-center space-x-3">
<Home className="w-5 h-5 text-gray-400" />
<div>
<p className="text-sm font-medium text-gray-700">Cluster Assignment</p>
<p className="text-xs text-gray-500">
Your app will be automatically deployed to the platform&apos;s default cluster
</p>
</div>
</div>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">CPU Request</label>
<select className="input-field" value={form.cpuRequest} onChange={(e) => setForm({ ...form, cpuRequest: e.target.value })}>
<option value="50m">50m (0.05 core)</option>
<option value="100m">100m (0.1 core)</option>
<option value="250m">250m (0.25 core)</option>
<option value="500m">500m (0.5 core)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">CPU Limit</label>
<select className="input-field" value={form.cpuLimit} onChange={(e) => setForm({ ...form, cpuLimit: e.target.value })}>
<option value="250m">250m (0.25 core)</option>
<option value="500m">500m (0.5 core)</option>
<option value="1">1 core</option>
<option value="2">2 cores</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Memory Request</label>
<select className="input-field" value={form.memoryRequest} onChange={(e) => setForm({ ...form, memoryRequest: e.target.value })}>
<option value="64Mi">64 Mi</option>
<option value="128Mi">128 Mi</option>
<option value="256Mi">256 Mi</option>
<option value="512Mi">512 Mi</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Memory Limit</label>
<select className="input-field" value={form.memoryLimit} onChange={(e) => setForm({ ...form, memoryLimit: e.target.value })}>
<option value="256Mi">256 Mi</option>
<option value="512Mi">512 Mi</option>
<option value="1Gi">1 Gi</option>
<option value="2Gi">2 Gi</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Replicas</label>
<input
type="number"
className="input-field"
min={1}
max={10}
value={form.replicas}
onChange={(e) => setForm({ ...form, replicas: parseInt(e.target.value) || 1 })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Port</label>
<input
type="number"
className="input-field"
value={form.port}
onChange={(e) => setForm({ ...form, port: parseInt(e.target.value) || 3000 })}
/>
</div>
</div>
{/* Environment Variables */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Environment Variables</label>
<div className="flex flex-col sm:flex-row gap-2 mb-3">
<input
className="input-field flex-1"
placeholder="KEY"
value={envKey}
onChange={(e) => setEnvKey(e.target.value)}
/>
<input
className="input-field flex-1"
placeholder="value"
value={envVal}
onChange={(e) => setEnvVal(e.target.value)}
/>
<button type="button" onClick={addEnvVar} className="btn-secondary shrink-0">Add</button>
</div>
{Object.entries(form.envVars || {}).map(([key, value]) => (
<div key={key} className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2 mb-2">
<span className="text-sm font-mono">
<strong>{key}</strong> = {value}
</span>
<button onClick={() => removeEnvVar(key)} className="text-red-500 text-sm">Remove</button>
</div>
))}
</div>
</div>
)}
{/* Step 3: Review */}
{step === 3 && (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Review & Deploy</h2>
<div className="bg-gray-50 rounded-xl p-5 sm:p-6 space-y-3">
<div className="flex justify-between">
<span className="text-sm text-gray-500">Name</span>
<span className="text-sm font-medium">{form.name}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">Runtime</span>
<span className="text-sm font-medium">
{form.runtime}
{form.runtime === 'nodejs' && form.runtimeVersion ? ` v${form.runtimeVersion}` : ''}
{form.runtime === 'wordpress' && form.runtimeVersion ? ` v${form.runtimeVersion}` : ''}
{(form.runtime === 'laravel' || form.runtime === 'wordpress') && form.phpVersion ? ` — PHP ${form.phpVersion}` : ''}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">Database</span>
<span className="text-sm font-medium">
{form.databaseType}
{form.databaseType !== 'none' && form.dbVersion ? ` v${form.dbVersion}` : ''}
</span>
</div>
{form.databaseType !== 'none' && (
<>
<div className="flex justify-between">
<span className="text-sm text-gray-500">DB Username</span>
<span className="text-sm font-medium">{form.dbUsername || 'appuser (default)'}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">DB Password</span>
<span className="text-sm font-medium">{form.dbPassword ? '••••••••' : 'Auto-generated'}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">DB Storage</span>
<span className="text-sm font-medium">{form.dbStorageSize || '1'} GB</span>
</div>
{dbDumpFile && (
<div className="flex justify-between">
<span className="text-sm text-gray-500">DB Dump</span>
<span className="text-sm font-medium">{dbDumpFile.name} ({(dbDumpFile.size / (1024 * 1024)).toFixed(1)} MB)</span>
</div>
)}
</>
)}
<div className="flex justify-between">
<span className="text-sm text-gray-500">Source</span>
<span className="text-sm font-medium">
{form.runtime === 'wordpress'
? wpMode === 'migrate' && wpContentFile
? `Migrate: ${wpContentFile.name} (${(wpContentFile.size / (1024 * 1024)).toFixed(1)} MB)`
: 'WordPress (Fresh Install)'
: sourceMethod === 'upload'
? zipFile
? zipFile.name
: '—'
: form.gitUrl || '—'}
</span>
</div>
{form.runtime !== 'wordpress' && sourceMethod === 'git' && form.gitBranch && (
<div className="flex justify-between">
<span className="text-sm text-gray-500">Branch</span>
<span className="text-sm font-medium">{form.gitBranch}</span>
</div>
)}
{form.runtime !== 'wordpress' && sourceMethod === 'git' && form.gitToken && (
<div className="flex justify-between">
<span className="text-sm text-gray-500">Auth</span>
<span className="text-sm font-medium text-green-600">Token provided</span>
</div>
)}
<div className="flex justify-between">
<span className="text-sm text-gray-500">Cluster</span>
<span className="text-sm font-medium">
{isAdmin && clusterMode === 'manual' && form.clusterId
? `${clusters.find((c) => c.id === form.clusterId)?.name || form.clusterId}`
: isAdmin && clusterMode === 'pool' && form.poolId
? `${pools.find((p) => p.id === form.poolId)?.name || 'Pool'} (Load Balanced)`
: 'Default Cluster'}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">CPU</span>
<span className="text-sm font-medium">{form.cpuRequest} / {form.cpuLimit}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">Memory</span>
<span className="text-sm font-medium">{form.memoryRequest} / {form.memoryLimit}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">Replicas</span>
<span className="text-sm font-medium">{form.replicas}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">Port</span>
<span className="text-sm font-medium">{form.port}</span>
</div>
{(form.enableRedis || form.enableRabbitmq || form.enableElasticsearch) && (
<div className="flex justify-between">
<span className="text-sm text-gray-500">Optional Services</span>
<span className="text-sm font-medium">
{[
form.enableRedis && 'Redis',
form.enableRabbitmq && 'RabbitMQ',
form.enableElasticsearch && 'Elasticsearch',
].filter(Boolean).join(', ')}
</span>
</div>
)}
{Object.keys(form.envVars || {}).length > 0 && (
<div className="flex justify-between">
<span className="text-sm text-gray-500">Env Vars</span>
<span className="text-sm font-medium">{Object.keys(form.envVars!).length} defined</span>
</div>
)}
</div>
{/* Cost Breakdown */}
<div className="bg-gradient-to-br from-emerald-50 to-teal-50 rounded-xl p-5 sm:p-6 border border-emerald-200">
<h3 className="text-sm font-semibold text-gray-700 flex items-center gap-2 mb-3">
<DollarSign className="w-4 h-4 text-emerald-600" /> Cost Estimate
</h3>
{costLoading ? (
<div className="text-sm text-gray-400 text-center py-3">Calculating...</div>
) : costData && costData.monthly > 0 ? (
<div className="space-y-3">
{/* Billing cycle selector */}
<div className="grid grid-cols-3 gap-2">
{(['hourly', 'monthly', 'yearly'] as BillingCycle[]).map((cycle) => (
<button
key={cycle}
type="button"
onClick={() => setSelectedCycle(cycle)}
className={`rounded-lg p-3 text-center transition-all ${
selectedCycle === cycle
? 'bg-white ring-2 ring-emerald-400 shadow-md'
: 'bg-white/60 hover:bg-white'
}`}
>
<p className="text-xs text-gray-500">
{cycle === 'hourly' ? 'Hourly' : cycle === 'monthly' ? 'Monthly' : 'Yearly'}
</p>
<p className="text-lg font-bold text-emerald-700">
{Number(costData[cycle]).toLocaleString('en-US')}
</p>
<p className="text-xs text-gray-400">Toman</p>
</button>
))}
</div>
{costData.breakdown && costData.breakdown.length > 0 && (
<div className="mt-2 pt-3 border-t border-emerald-200/50">
<p className="text-xs font-medium text-gray-500 mb-2">Breakdown</p>
{costData.breakdown.map((item, i) => (
<div key={i} className="flex justify-between text-xs py-1">
<span className="text-gray-600">{item.label}</span>
<span className="text-gray-900 font-medium">
{Number(item[selectedCycle]).toLocaleString('en-US')} T/{selectedCycle === 'hourly' ? 'hr' : selectedCycle === 'monthly' ? 'mo' : 'yr'}
</span>
</div>
))}
</div>
)}
</div>
) : (
<div className="text-sm text-gray-400 text-center py-3">No pricing plans defined yet</div>
)}
</div>
{/* Payment Method */}
{costData && costData.monthly > 0 && (
<div className="bg-white rounded-xl p-5 border border-gray-200">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Payment Method</h3>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setPaymentMethod('wallet')}
className={`p-4 rounded-xl border-2 text-left transition-colors ${
paymentMethod === 'wallet' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
}`}
>
<Wallet className="w-5 h-5 text-primary-600" />
<p className="mt-2 font-semibold text-sm text-gray-900">Pay from Wallet</p>
<p className="text-xs text-gray-500 mt-1">
Balance: {Number(walletBalance).toLocaleString('en-US')} T
{!hasEnoughBalance && <span className="text-red-500 block mt-0.5">Insufficient balance</span>}
</p>
</button>
<button
type="button"
onClick={() => setPaymentMethod('gateway')}
className={`p-4 rounded-xl border-2 text-left transition-colors ${
paymentMethod === 'gateway' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
}`}
>
<CreditCard className="w-5 h-5 text-emerald-600" />
<p className="mt-2 font-semibold text-sm text-gray-900">Pay Now</p>
<p className="text-xs text-gray-500 mt-1">Online payment gateway</p>
</button>
</div>
<div className="mt-4 p-3 bg-gray-50 rounded-lg flex items-center justify-between">
<span className="text-sm text-gray-600">Amount to pay ({selectedCycle})</span>
<span className="text-lg font-bold text-gray-900">{Number(payAmount).toLocaleString('en-US')} Toman</span>
</div>
</div>
)}
</div>
)}
{/* Navigation */}
<div className="flex justify-between mt-8 pt-6 border-t border-gray-100">
<button
onClick={() => setStep(step - 1)}
disabled={step === 0}
className="btn-ghost disabled:opacity-0 disabled:pointer-events-none"
>
Back
</button>
{step < steps.length - 1 ? (
<button
onClick={() => setStep(step + 1)}
disabled={!canNext()}
className="btn-primary disabled:opacity-50"
>
Next
</button>
) : (
<button
onClick={() => {
if (!costData || costData.monthly === 0) {
// No pricing — deploy directly
handleSubmit();
} else if (paymentMethod === 'wallet') {
if (!hasEnoughBalance) {
toast.error('Insufficient wallet balance. Please top up or use payment gateway.');
return;
}
walletPayMutation.mutate();
} else {
gatewayPayMutation.mutate();
}
}}
disabled={createMutation.isPending || walletPayMutation.isPending || gatewayPayMutation.isPending}
className="btn-primary disabled:opacity-50"
>
{(createMutation.isPending || walletPayMutation.isPending || gatewayPayMutation.isPending)
? uploadProgress > 0 && uploadProgress < 100
? `Uploading... ${uploadProgress}%`
: 'Processing...'
: costData && costData.monthly > 0
? <><CreditCard className="w-4 h-4 inline" /> Pay & Deploy</>
: <><Rocket className="w-4 h-4 inline" /> Deploy Application</>}
</button>
)}
</div>
</div>
{/* Deploy Progress Overlay */}
{deployStage !== 'idle' && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl p-8 max-w-md w-full mx-4 space-y-6">
<div className="text-center">
{deployStage === 'error' ? (
<XCircle className="w-12 h-12 text-red-500 mx-auto mb-3" />
) : deployStage === 'done' ? (
<CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-3" />
) : (
<Loader2 className="w-12 h-12 text-primary-600 mx-auto mb-3 animate-spin" />
)}
<h3 className="text-lg font-semibold text-gray-900">
{deployStage === 'error' ? 'Deployment Failed' : deployStage === 'done' ? 'Success!' : 'Deploying Application'}
</h3>
<p className="text-sm text-gray-500 mt-1">{stageLabels[deployStage]}</p>
</div>
{/* Stage Progress Steps */}
<div className="space-y-3">
{/* Creating */}
<div className="flex items-center gap-3">
{deployStage === 'creating' ? (
<Loader2 className="w-5 h-5 text-primary-600 animate-spin shrink-0" />
) : ['uploading-source', 'uploading-db', 'paying', 'deploying', 'done'].includes(deployStage) ? (
<CheckCircle className="w-5 h-5 text-green-500 shrink-0" />
) : deployStage === 'error' ? (
<XCircle className="w-5 h-5 text-red-400 shrink-0" />
) : (
<div className="w-5 h-5 rounded-full border-2 border-gray-200 shrink-0" />
)}
<span className={`text-sm ${deployStage === 'creating' ? 'text-gray-900 font-medium' : 'text-gray-500'}`}>
Creating application
</span>
</div>
{/* Uploading source (only if we have a file) */}
{((form.runtime === 'wordpress' && (wpMode === 'migrate' || wpMode === 'public_html') && wpContentFile) || (form.runtime !== 'wordpress' && sourceMethod === 'upload' && zipFile)) && (
<div className="space-y-1">
<div className="flex items-center gap-3">
{deployStage === 'uploading-source' ? (
<Loader2 className="w-5 h-5 text-primary-600 animate-spin shrink-0" />
) : ['uploading-db', 'paying', 'deploying', 'done'].includes(deployStage) ? (
<CheckCircle className="w-5 h-5 text-green-500 shrink-0" />
) : (
<div className="w-5 h-5 rounded-full border-2 border-gray-200 shrink-0" />
)}
<span className={`text-sm flex-1 ${deployStage === 'uploading-source' ? 'text-gray-900 font-medium' : 'text-gray-500'}`}>
Uploading source code
{deployStage === 'uploading-source' && uploadProgress > 0 && (
<span className="text-primary-600 font-semibold ml-2">{uploadProgress}%</span>
)}
</span>
</div>
{deployStage === 'uploading-source' && (
<div className="ml-8 h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-primary-500 rounded-full transition-all duration-300 ease-out"
style={{ width: `${uploadProgress}%` }}
/>
</div>
)}
</div>
)}
{/* Uploading DB dump (only if we have a dump) */}
{dbDumpFile && form.databaseType !== 'none' && (
<div className="space-y-1">
<div className="flex items-center gap-3">
{deployStage === 'uploading-db' ? (
<Loader2 className="w-5 h-5 text-primary-600 animate-spin shrink-0" />
) : ['paying', 'deploying', 'done'].includes(deployStage) ? (
<CheckCircle className="w-5 h-5 text-green-500 shrink-0" />
) : (
<div className="w-5 h-5 rounded-full border-2 border-gray-200 shrink-0" />
)}
<span className={`text-sm flex-1 ${deployStage === 'uploading-db' ? 'text-gray-900 font-medium' : 'text-gray-500'}`}>
Uploading database dump
{deployStage === 'uploading-db' && dbUploadProgress > 0 && (
<span className="text-primary-600 font-semibold ml-2">{dbUploadProgress}%</span>
)}
</span>
</div>
{deployStage === 'uploading-db' && (
<div className="ml-8 h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-primary-500 rounded-full transition-all duration-300 ease-out"
style={{ width: `${dbUploadProgress}%` }}
/>
</div>
)}
</div>
)}
{/* Payment (only if cost > 0) */}
{costData && costData.monthly > 0 && (
<div className="flex items-center gap-3">
{deployStage === 'paying' ? (
<Loader2 className="w-5 h-5 text-primary-600 animate-spin shrink-0" />
) : ['deploying', 'done'].includes(deployStage) ? (
<CheckCircle className="w-5 h-5 text-green-500 shrink-0" />
) : (
<div className="w-5 h-5 rounded-full border-2 border-gray-200 shrink-0" />
)}
<span className={`text-sm ${deployStage === 'paying' ? 'text-gray-900 font-medium' : 'text-gray-500'}`}>
Processing payment
</span>
</div>
)}
{/* Deploying */}
<div className="flex items-center gap-3">
{deployStage === 'deploying' ? (
<Loader2 className="w-5 h-5 text-primary-600 animate-spin shrink-0" />
) : deployStage === 'done' ? (
<CheckCircle className="w-5 h-5 text-green-500 shrink-0" />
) : (
<div className="w-5 h-5 rounded-full border-2 border-gray-200 shrink-0" />
)}
<span className={`text-sm ${deployStage === 'deploying' ? 'text-gray-900 font-medium' : 'text-gray-500'}`}>
Starting deployment
</span>
</div>
</div>
</div>
</div>
)}
</div>
);
}