feat: add custom domain support with SSL, DNS verification, and billing

Users can assign a custom domain to their app with automatic SSL via
cert-manager. Includes DNS verification flow (CNAME check), Persian
instructions, admin-configurable pricing via PlatformSetting, and
integration into the deploy wizard cost calculation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-14 00:36:29 +03:30
parent d87b50c6a4
commit 435cf92817
18 changed files with 1082 additions and 89 deletions
@@ -5,7 +5,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import { toast } from 'react-toastify';
import type { ServicePlan, BillingCycle, PricingResourceType, LifecycleSettings } from '@/types';
import { DollarSign, Plus, Trash2, Edit2, ToggleLeft, ToggleRight, ChevronDown, ChevronUp, Shield, Clock } from 'lucide-react';
import { DollarSign, Plus, Trash2, Edit2, ToggleLeft, ToggleRight, ChevronDown, ChevronUp, Shield, Clock, Globe } from 'lucide-react';
import { useConfirm } from '@/components/confirm-modal';
const runtimeOptions = [
@@ -34,9 +34,13 @@ const resourceLabels: Record<PricingResourceType, string> = {
memory_per_gb: 'Memory (per GB)',
storage_per_gb: 'Storage (per GB)',
database_addon: 'Database Addon',
redis_addon: 'Redis Addon',
rabbitmq_addon: 'RabbitMQ Addon',
elasticsearch_addon: 'Elasticsearch Addon',
custom_domain_addon: 'Custom Domain + SSL',
};
const allResourceTypes: PricingResourceType[] = ['base_fee', 'cpu_per_core', 'memory_per_gb', 'storage_per_gb', 'database_addon'];
const allResourceTypes: PricingResourceType[] = ['base_fee', 'cpu_per_core', 'memory_per_gb', 'storage_per_gb', 'database_addon', 'redis_addon', 'rabbitmq_addon', 'elasticsearch_addon', 'custom_domain_addon'];
interface RuleForm {
resourceType: PricingResourceType;
@@ -323,12 +327,108 @@ export default function AdminBillingPage() {
</div>
)}
{/* ─── Custom Domain Pricing ───────────────────── */}
<CustomDomainPricingSection />
{/* ─── Lifecycle Retention Settings ───────────────────── */}
<LifecycleSettingsSection />
</div>
);
}
// ─── Custom Domain Pricing Sub-component ──────────────────────────
function CustomDomainPricingSection() {
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
const [priceInput, setPriceInput] = useState('');
const { data: priceData, isLoading } = useQuery<{ monthlyPrice: number }>({
queryKey: ['custom-domain-price'],
queryFn: () => api.get('/billing/settings/custom-domain-price').then((r) => r.data),
});
const saveMutation = useMutation({
mutationFn: (monthlyPrice: number) => api.patch('/billing/settings/custom-domain-price', { monthlyPrice }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['custom-domain-price'] });
toast.success('Custom domain pricing updated');
setEditing(false);
},
onError: () => toast.error('Failed to update pricing'),
});
const handleEdit = () => {
setPriceInput(String(priceData?.monthlyPrice || 0));
setEditing(true);
};
const handleSave = () => {
const price = Number(priceInput);
if (isNaN(price) || price < 0) {
toast.error('Price must be a non-negative number');
return;
}
saveMutation.mutate(price);
};
return (
<div className="card mt-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<Globe className="w-5 h-5" /> Custom Domain Pricing
</h2>
{!editing && (
<button onClick={handleEdit} className="btn-secondary text-sm flex items-center gap-1.5">
<Edit2 className="w-4 h-4" /> Edit
</button>
)}
</div>
{isLoading ? (
<p className="text-sm text-gray-500">Loading...</p>
) : editing ? (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Monthly Price (Toman)</label>
<input
type="number"
value={priceInput}
onChange={(e) => setPriceInput(e.target.value)}
className="input-field w-full max-w-xs"
min="0"
placeholder="e.g. 50000"
/>
<p className="text-xs text-gray-400 mt-1">
Set to 0 to make custom domains free. This price is added to the total cost when users enable a custom domain.
</p>
</div>
<div className="flex gap-2">
<button onClick={handleSave} disabled={saveMutation.isPending} className="btn-primary text-sm">
{saveMutation.isPending ? 'Saving...' : 'Save'}
</button>
<button onClick={() => setEditing(false)} className="btn-secondary text-sm">Cancel</button>
</div>
</div>
) : (
<div className="bg-gray-50 rounded-xl p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">Monthly price per custom domain</p>
<p className="text-2xl font-bold text-gray-900">
{(priceData?.monthlyPrice || 0).toLocaleString('en-US')} <span className="text-sm font-normal text-gray-500">Toman</span>
</p>
</div>
{priceData?.monthlyPrice === 0 && (
<span className="badge badge-green">Free</span>
)}
</div>
</div>
)}
</div>
);
}
// ─── Lifecycle Settings Sub-component ─────────────────────────────
function LifecycleSettingsSection() {