c2c6a32ae8
- ServicePlan now has 'runtime' field (nodejs/laravel/wordpress) - Admin billing page: Application Type dropdown, English UI, Toman prices - calculateCost filters active plans by matching runtime - Wallet page: English UI, payment gateway integration (Pay Now button) - Deploy page Review step: billing cycle selector (hourly/monthly/yearly), payment method choice (wallet or payment gateway), Pay & Deploy button - Payment gateway endpoints: POST /billing/gateway/initiate + /verify (simulated — ready for Zarinpal/IDPay integration) - Deploy requires payment: wallet deduction or gateway charge before deploy
1379 lines
65 KiB
TypeScript
1379 lines
65 KiB
TypeScript
'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 } from 'lucide-react';
|
||
|
||
const steps = ['Basic Info', 'Versions & Database', 'Resources', 'Review'];
|
||
|
||
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',
|
||
});
|
||
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 [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],
|
||
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,
|
||
}).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
|
||
const payload = { ...form };
|
||
if (payload.databaseType !== 'none' && payload.dbStorageSize) {
|
||
payload.dbStorageSize = `${parseInt(payload.dbStorageSize, 10) || 1}Gi`;
|
||
}
|
||
const res = await api.post('/applications', payload);
|
||
const appId = res.data.id;
|
||
|
||
// Upload source
|
||
if (sourceMethod === 'upload' && zipFile) {
|
||
const formData = new FormData();
|
||
formData.append('file', zipFile);
|
||
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) {
|
||
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
|
||
await api.post(`/billing/wallet/pay/${appId}`, { amount: payAmount, cycle: selectedCycle });
|
||
|
||
return res;
|
||
},
|
||
onSuccess: (res) => {
|
||
toast.success('Payment successful! Deploying...');
|
||
api.post(`/deployments/applications/${res.data.id}/deploy`).catch(() => {});
|
||
router.push(`/dashboard/apps/${res.data.id}`);
|
||
},
|
||
onError: (err: any) => {
|
||
toast.error(err.response?.data?.message || 'Payment or deployment failed');
|
||
setUploadProgress(0);
|
||
},
|
||
});
|
||
|
||
const gatewayPayMutation = useMutation({
|
||
mutationFn: async () => {
|
||
// Initiate gateway
|
||
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
|
||
const payload = { ...form };
|
||
if (payload.databaseType !== 'none' && payload.dbStorageSize) {
|
||
payload.dbStorageSize = `${parseInt(payload.dbStorageSize, 10) || 1}Gi`;
|
||
}
|
||
const res = await api.post('/applications', payload);
|
||
const appId = res.data.id;
|
||
|
||
// Upload source
|
||
if (sourceMethod === 'upload' && zipFile) {
|
||
const formData = new FormData();
|
||
formData.append('file', zipFile);
|
||
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) {
|
||
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)
|
||
await api.post(`/billing/wallet/pay/${appId}`, { amount: payAmount, cycle: selectedCycle });
|
||
|
||
return res;
|
||
},
|
||
onSuccess: (res) => {
|
||
toast.success('Payment successful! Deploying...');
|
||
api.post(`/deployments/applications/${res.data.id}/deploy`).catch(() => {});
|
||
router.push(`/dashboard/apps/${res.data.id}`);
|
||
},
|
||
onError: (err: any) => {
|
||
toast.error(err.response?.data?.message || 'Payment failed');
|
||
setUploadProgress(0);
|
||
},
|
||
});
|
||
|
||
const createMutation = useMutation({
|
||
mutationFn: async (data: CreateApplicationDto) => {
|
||
const res = await api.post('/applications', data);
|
||
const appId = res.data.id;
|
||
|
||
// Upload zip file if selected
|
||
if (sourceMethod === 'upload' && zipFile) {
|
||
const formData = new FormData();
|
||
formData.append('file', zipFile);
|
||
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) {
|
||
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) => {
|
||
toast.success('Application created! Triggering deployment...');
|
||
api.post(`/deployments/applications/${res.data.id}/deploy`).catch(() => {});
|
||
router.push(`/dashboard/apps/${res.data.id}`);
|
||
},
|
||
onError: (err: any) => {
|
||
toast.error(err.response?.data?.message || 'Failed to create application');
|
||
setUploadProgress(0);
|
||
},
|
||
});
|
||
|
||
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`;
|
||
}
|
||
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 canNext = () => {
|
||
if (step === 0) {
|
||
if (form.name.length < 2) return false;
|
||
// WordPress doesn't require source code
|
||
if (form.runtime !== 'wordpress') {
|
||
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-1 sm:grid-cols-3 gap-4">
|
||
{[
|
||
{ value: 'nodejs', label: 'Node.js', icon: <Hexagon className="w-6 h-6 text-green-500" />, desc: 'Express, NestJS, Fastify...' },
|
||
{ value: 'laravel', label: 'Laravel', icon: <Hexagon className="w-6 h-6 text-orange-500" />, desc: 'PHP, Composer, Artisan' },
|
||
{ value: 'wordpress', label: 'WordPress', icon: <Hexagon className="w-6 h-6 text-blue-600" />, desc: 'Official image, wp-content' },
|
||
].map((opt) => (
|
||
<button
|
||
key={opt.value}
|
||
type="button"
|
||
onClick={() => {
|
||
const updates: any = { runtime: opt.value as any, phpVersion: '' };
|
||
if (opt.value === 'nodejs') { updates.port = 3000; updates.runtimeVersion = '20'; }
|
||
else if (opt.value === 'laravel') { updates.port = 8000; updates.runtimeVersion = ''; updates.phpVersion = '8.3'; }
|
||
else if (opt.value === 'wordpress') { updates.port = 80; updates.databaseType = 'mysql'; updates.runtimeVersion = '6.7'; updates.phpVersion = '8.3'; }
|
||
setForm({ ...form, ...updates });
|
||
}}
|
||
className={`p-4 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-2 font-semibold 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 info — shown when WordPress is selected */}
|
||
{form.runtime === 'wordpress' && (
|
||
<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">
|
||
وردپرس از ایمیج رسمی Docker استفاده میکند و نیازی به آپلود سورسکد ندارد.
|
||
قالبها و افزونهها از طریق پنل مدیریت وردپرس نصب میشوند.
|
||
</p>
|
||
</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>
|
||
</>
|
||
)}
|
||
</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"> — وردپرس به MySQL نیاز دارد</span>
|
||
)}
|
||
</label>
|
||
<div className="grid grid-cols-3 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> },
|
||
].map((opt) => {
|
||
const isWordPress = form.runtime === 'wordpress';
|
||
const disabled = isWordPress && opt.value !== 'mysql';
|
||
return (
|
||
<button
|
||
key={opt.value}
|
||
type="button"
|
||
disabled={disabled}
|
||
onClick={() => setForm({ ...form, databaseType: opt.value as any, dbVersion: opt.value === 'postgresql' ? '16' : opt.value === 'mysql' ? '8.0' : '' })}
|
||
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' : 'MySQL'} Version
|
||
</label>
|
||
<select
|
||
className="input-field max-w-xs"
|
||
value={form.dbVersion || (form.databaseType === 'postgresql' ? '16' : '8.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>
|
||
</>
|
||
) : (
|
||
<>
|
||
<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>
|
||
</>
|
||
)}
|
||
</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">
|
||
پیشنهاد بر اساس حجم دامپ ({(dbDumpFile.size / (1024 * 1024 * 1024)).toFixed(2)} GB)
|
||
</span>
|
||
)}
|
||
</div>
|
||
<p className="mt-1 text-xs text-gray-400">حداقل ۱ گیگابایت • بعد از ساخت فقط امکان افزایش حجم وجود دارد</p>
|
||
</div>
|
||
</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'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'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'
|
||
? 'WordPress (Official Image)'
|
||
: 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>
|
||
{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>
|
||
</div>
|
||
);
|
||
}
|