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:
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user