fix: update cost estimate on domain toggle, switch to English, fix toggle overflow

- Cost calculation now reads custom domain price from PlatformSetting
  as fallback when no CUSTOM_DOMAIN_ADDON pricing rule exists
- Convert all custom domain UI text from Persian to English
- Fix toggle switch overflow by adding shrink-0, min-w-0, and proper
  absolute positioning

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-14 00:49:20 +03:30
parent 435cf92817
commit 30ccfa4ee2
4 changed files with 77 additions and 60 deletions
+7 -7
View File
@@ -145,15 +145,15 @@ export class DomainService {
const fullPlatformUrl = `${app.subdomain}.${platformDomain}`; const fullPlatformUrl = `${app.subdomain}.${platformDomain}`;
const instructions = [ const instructions = [
`1. وارد پنل مدیریت دامنه خود شوید (مانند Cloudflare، Namecheap، GoDaddy و غیره)`, `1. Log in to your domain registrar (e.g. Cloudflare, Namecheap, GoDaddy)`,
`2. به بخش مدیریت DNS بروید`, `2. Go to DNS management for your domain`,
`3. یک رکورد CNAME اضافه کنید:`, `3. Add a CNAME record:`,
` - Name/Host: @ یا www (بسته به دامنه‌تان)`, ` - Name/Host: @ or www (depending on your domain)`,
` - Type: CNAME`, ` - Type: CNAME`,
` - Value/Target: ${fullPlatformUrl}`, ` - Value/Target: ${fullPlatformUrl}`,
`4. اگر از دامنه اصلی (root domain) بدون www استفاده می‌کنید، برخی ثبت‌کنندگان از CNAME flattening پشتیبانی می‌کنند (مانند Cloudflare). در غیر این صورت از www استفاده کنید.`, `4. If using a root domain (without www), some registrars support CNAME flattening (e.g. Cloudflare). Otherwise use www.`,
`5. بین ۵ تا ۳۰ دقیقه صبر کنید تا DNS منتشر شود (تا ۴۸ ساعت ممکن است طول بکشد)`, `5. Wait 5-30 minutes for DNS propagation (may take up to 48 hours)`,
`6. دکمه "تأیید DNS" را بزنید`, `6. Click the "Verify DNS" button`,
]; ];
return { return {
+12
View File
@@ -203,6 +203,18 @@ export class BillingService {
} }
} }
// If custom domain is enabled but no CUSTOM_DOMAIN_ADDON rule exists, use PlatformSetting price
if (hasCustomDomain && !breakdown.some((b) => b.label === 'Custom domain + SSL')) {
const setting = await this.settingsRepo.findOne({ where: { key: 'custom_domain_monthly_price_toman' } });
const monthlyPrice = setting ? Number(setting.value) : 0;
if (monthlyPrice > 0) {
const hourly = Math.round(monthlyPrice / 720);
const yearly = monthlyPrice * 12;
breakdown.push({ label: 'Custom domain + SSL', hourly, monthly: monthlyPrice, yearly });
totalBase += baseCycle === 'monthly' ? monthlyPrice : baseCycle === 'hourly' ? hourly : yearly;
}
}
const hourlyTotal = baseCycle === 'hourly' ? totalBase : baseCycle === 'monthly' ? totalBase / 720 : totalBase / 8640; const hourlyTotal = baseCycle === 'hourly' ? totalBase : baseCycle === 'monthly' ? totalBase / 720 : totalBase / 8640;
const monthlyTotal = baseCycle === 'monthly' ? totalBase : baseCycle === 'hourly' ? totalBase * 720 : totalBase / 12; const monthlyTotal = baseCycle === 'monthly' ? totalBase : baseCycle === 'hourly' ? totalBase * 720 : totalBase / 12;
const yearlyTotal = baseCycle === 'yearly' ? totalBase : baseCycle === 'monthly' ? totalBase * 12 : totalBase * 8640; const yearlyTotal = baseCycle === 'yearly' ? totalBase : baseCycle === 'monthly' ? totalBase * 12 : totalBase * 8640;
+46 -40
View File
@@ -252,36 +252,36 @@ export default function AppDetailPage() {
const setDomainMutation = useMutation({ const setDomainMutation = useMutation({
mutationFn: (domain: string) => api.post(`/applications/${appId}/domain`, { domain }), mutationFn: (domain: string) => api.post(`/applications/${appId}/domain`, { domain }),
onSuccess: () => { onSuccess: () => {
toast.success('دامنه تنظیم شد. لطفاً رکورد DNS را اضافه کنید.'); toast.success('Domain set. Please configure your DNS records.');
queryClient.invalidateQueries({ queryKey: ['application', appId] }); queryClient.invalidateQueries({ queryKey: ['application', appId] });
refetchDomainInfo(); refetchDomainInfo();
setCustomDomainInput(''); setCustomDomainInput('');
}, },
onError: (err: any) => toast.error(err.response?.data?.message || 'خطا در تنظیم دامنه'), onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to set domain'),
}); });
const verifyDnsMutation = useMutation({ const verifyDnsMutation = useMutation({
mutationFn: () => api.post(`/applications/${appId}/domain/verify`), mutationFn: () => api.post(`/applications/${appId}/domain/verify`),
onSuccess: (res) => { onSuccess: (res) => {
if (res.data.verified) { if (res.data.verified) {
toast.success('دامنه با موفقیت تأیید شد!'); toast.success('Domain verified successfully!');
} else { } else {
toast.warning(res.data.message || 'DNS هنوز آماده نیست. لطفاً بعداً تلاش کنید.'); toast.warning(res.data.message || 'DNS is not ready yet. Please try again later.');
} }
queryClient.invalidateQueries({ queryKey: ['application', appId] }); queryClient.invalidateQueries({ queryKey: ['application', appId] });
refetchDomainInfo(); refetchDomainInfo();
}, },
onError: (err: any) => toast.error(err.response?.data?.message || 'خطا در تأیید DNS'), onError: (err: any) => toast.error(err.response?.data?.message || 'DNS verification failed'),
}); });
const removeDomainMutation = useMutation({ const removeDomainMutation = useMutation({
mutationFn: () => api.delete(`/applications/${appId}/domain`), mutationFn: () => api.delete(`/applications/${appId}/domain`),
onSuccess: () => { onSuccess: () => {
toast.success('دامنه اختصاصی حذف شد'); toast.success('Custom domain removed');
queryClient.invalidateQueries({ queryKey: ['application', appId] }); queryClient.invalidateQueries({ queryKey: ['application', appId] });
refetchDomainInfo(); refetchDomainInfo();
}, },
onError: (err: any) => toast.error(err.response?.data?.message || 'خطا در حذف دامنه'), onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to remove domain'),
}); });
// ─── Snapshots ────────────────────────────────────── // ─── Snapshots ──────────────────────────────────────
@@ -1268,14 +1268,14 @@ export default function AppDetailPage() {
<div className="card"> <div className="card">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2"> <h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<Globe className="w-5 h-5" /> دامنه <Globe className="w-5 h-5" /> Domain
</h2> </h2>
{!showDomainSetup && (!app.customDomain || app.customDomainStatus === 'none') && ( {!showDomainSetup && (!app.customDomain || app.customDomainStatus === 'none') && (
<button <button
onClick={() => setShowDomainSetup(true)} onClick={() => setShowDomainSetup(true)}
className="btn-primary text-sm" className="btn-primary text-sm"
> >
افزودن دامنه اختصاصی Add Custom Domain
</button> </button>
)} )}
</div> </div>
@@ -1284,12 +1284,12 @@ export default function AppDetailPage() {
<div className="bg-gray-50 rounded-xl p-4 mb-4"> <div className="bg-gray-50 rounded-xl p-4 mb-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-xs text-gray-500 mb-1">دامنه پلتفرم</p> <p className="text-xs text-gray-500 mb-1">Platform Domain</p>
<p className="text-sm font-mono font-medium text-gray-800"> <p className="text-sm font-mono font-medium text-gray-800">
{app.subdomain}.{domainInfo?.platformDomain || 'apps.cloudhost.ir'} {app.subdomain}.{domainInfo?.platformDomain || 'apps.cloudhost.ir'}
</p> </p>
</div> </div>
<span className="badge badge-green text-xs">فعال</span> <span className="badge badge-green text-xs">Active</span>
</div> </div>
</div> </div>
@@ -1298,19 +1298,19 @@ export default function AppDetailPage() {
<div className="bg-emerald-50 rounded-xl p-4 mb-4 border border-emerald-200"> <div className="bg-emerald-50 rounded-xl p-4 mb-4 border border-emerald-200">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-xs text-emerald-600 mb-1">دامنه اختصاصی</p> <p className="text-xs text-emerald-600 mb-1">Custom Domain</p>
<p className="text-sm font-mono font-medium text-emerald-800">{app.customDomain}</p> <p className="text-sm font-mono font-medium text-emerald-800">{app.customDomain}</p>
<p className="text-xs text-emerald-500 mt-1"> <p className="text-xs text-emerald-500 mt-1">
<CheckCircle className="w-3 h-3 inline" /> SSL فعال تأیید شده در{' '} <CheckCircle className="w-3 h-3 inline" /> SSL Active Verified on{' '}
{app.customDomainVerifiedAt ? new Date(app.customDomainVerifiedAt).toLocaleString('fa-IR') : ''} {app.customDomainVerifiedAt ? new Date(app.customDomainVerifiedAt).toLocaleString() : ''}
</p> </p>
</div> </div>
<button <button
onClick={async () => { onClick={async () => {
const ok = await confirm({ const ok = await confirm({
title: 'حذف دامنه اختصاصی', title: 'Remove Custom Domain',
message: `آیا مطمئن هستید که می‌خواهید دامنه "${app.customDomain}" را حذف کنید؟ وبسایت فقط از طریق دامنه پلتفرم قابل دسترسی خواهد بود.`, message: `Are you sure you want to remove "${app.customDomain}"? The website will only be accessible via the platform domain.`,
confirmText: 'حذف', confirmText: 'Remove',
variant: 'danger', variant: 'danger',
}); });
if (ok) removeDomainMutation.mutate(); if (ok) removeDomainMutation.mutate();
@@ -1318,7 +1318,7 @@ export default function AppDetailPage() {
disabled={removeDomainMutation.isPending} disabled={removeDomainMutation.isPending}
className="text-sm px-3 py-1.5 rounded-lg text-red-600 hover:bg-red-50 border border-red-200 transition-colors" className="text-sm px-3 py-1.5 rounded-lg text-red-600 hover:bg-red-50 border border-red-200 transition-colors"
> >
{removeDomainMutation.isPending ? 'در حال حذف...' : 'حذف دامنه'} {removeDomainMutation.isPending ? 'Removing...' : 'Remove Domain'}
</button> </button>
</div> </div>
</div> </div>
@@ -1329,7 +1329,7 @@ export default function AppDetailPage() {
<div className="bg-amber-50 rounded-xl p-4 mb-4 border border-amber-200"> <div className="bg-amber-50 rounded-xl p-4 mb-4 border border-amber-200">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<div> <div>
<p className="text-xs text-amber-600 mb-1">دامنه اختصاصی در انتظار تأیید DNS</p> <p className="text-xs text-amber-600 mb-1">Custom Domain Pending DNS Verification</p>
<p className="text-sm font-mono font-medium text-amber-800">{app.customDomain}</p> <p className="text-sm font-mono font-medium text-amber-800">{app.customDomain}</p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -1338,29 +1338,35 @@ export default function AppDetailPage() {
disabled={verifyDnsMutation.isPending} disabled={verifyDnsMutation.isPending}
className="btn-primary text-sm" className="btn-primary text-sm"
> >
{verifyDnsMutation.isPending ? 'در حال بررسی...' : 'تأیید DNS'} {verifyDnsMutation.isPending ? 'Checking...' : 'Verify DNS'}
</button> </button>
<button <button
onClick={() => removeDomainMutation.mutate()} onClick={() => removeDomainMutation.mutate()}
disabled={removeDomainMutation.isPending} disabled={removeDomainMutation.isPending}
className="text-sm px-3 py-1.5 rounded-lg text-red-600 hover:bg-red-50 border border-red-200 transition-colors" className="text-sm px-3 py-1.5 rounded-lg text-red-600 hover:bg-red-50 border border-red-200 transition-colors"
> >
لغو Cancel
</button> </button>
</div> </div>
</div> </div>
{/* DNS Instructions */} {/* DNS Instructions */}
{domainInfo?.instructions && ( <div className="bg-white rounded-lg p-4 border border-amber-100">
<div className="bg-white rounded-lg p-4 border border-amber-100"> <h4 className="text-sm font-semibold text-gray-800 mb-3">DNS Setup Guide</h4>
<h4 className="text-sm font-semibold text-gray-800 mb-3">راهنمای تنظیم DNS</h4> <div className="space-y-2.5 text-sm text-gray-600">
<div className="space-y-2 text-sm text-gray-600" dir="rtl"> <p>1. Log in to your domain registrar (e.g. Cloudflare, Namecheap, GoDaddy)</p>
{domainInfo.instructions.map((step, i) => ( <p>2. Go to DNS management for your domain</p>
<p key={i} className={step.startsWith(' ') ? 'pr-4 text-xs font-mono bg-gray-50 rounded px-2 py-1' : ''}> <p>3. Add a <strong>CNAME</strong> record:</p>
{step} <div className="pl-4 space-y-1">
</p> <p className="text-xs font-mono bg-gray-50 rounded px-2 py-1">Name/Host: <strong>@</strong> or <strong>www</strong></p>
))} <p className="text-xs font-mono bg-gray-50 rounded px-2 py-1">Type: <strong>CNAME</strong></p>
<p className="text-xs font-mono bg-gray-50 rounded px-2 py-1">Value: <strong>{domainInfo?.fullPlatformUrl || `${app.subdomain}.apps.cloudhost.ir`}</strong></p>
</div> </div>
<p>4. If using a root domain (without www), use a registrar that supports CNAME flattening (e.g. Cloudflare), or use <code className="bg-gray-100 px-1 rounded">www</code> instead.</p>
<p>5. Wait 530 minutes for DNS propagation (up to 48 hours in some cases)</p>
<p>6. Click the <strong>"Verify DNS"</strong> button above</p>
</div>
{domainInfo?.fullPlatformUrl && (
<div className="mt-4 bg-blue-50 rounded-lg p-3 border border-blue-100"> <div className="mt-4 bg-blue-50 rounded-lg p-3 border border-blue-100">
<p className="text-xs text-blue-700 font-medium mb-1">CNAME Target:</p> <p className="text-xs text-blue-700 font-medium mb-1">CNAME Target:</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -1370,7 +1376,7 @@ export default function AppDetailPage() {
<button <button
onClick={() => { onClick={() => {
navigator.clipboard.writeText(domainInfo.fullPlatformUrl); navigator.clipboard.writeText(domainInfo.fullPlatformUrl);
toast.success('کپی شد!'); toast.success('Copied!');
}} }}
className="text-blue-600 hover:text-blue-800 p-1" className="text-blue-600 hover:text-blue-800 p-1"
> >
@@ -1378,27 +1384,27 @@ export default function AppDetailPage() {
</button> </button>
</div> </div>
</div> </div>
</div> )}
)} </div>
</div> </div>
)} )}
{/* Domain setup form */} {/* Domain setup form */}
{showDomainSetup && (!app.customDomain || app.customDomainStatus === 'none') && ( {showDomainSetup && (!app.customDomain || app.customDomainStatus === 'none') && (
<div className="bg-gray-50 rounded-xl p-4 border border-gray-200"> <div className="bg-gray-50 rounded-xl p-4 border border-gray-200">
<h4 className="text-sm font-semibold text-gray-800 mb-3">تنظیم دامنه اختصاصی</h4> <h4 className="text-sm font-semibold text-gray-800 mb-3">Set Up Custom Domain</h4>
{domainPriceData && domainPriceData.monthlyPrice > 0 && ( {domainPriceData && domainPriceData.monthlyPrice > 0 && (
<div className="bg-blue-50 rounded-lg p-3 mb-4 border border-blue-100"> <div className="bg-blue-50 rounded-lg p-3 mb-4 border border-blue-100">
<p className="text-sm text-blue-700"> <p className="text-sm text-blue-700">
<CreditCard className="w-4 h-4 inline ml-1" /> <CreditCard className="w-4 h-4 inline mr-1" />
هزینه دامنه اختصاصی: <strong>{domainPriceData.monthlyPrice.toLocaleString('fa-IR')} تومان / ماهانه</strong> Custom domain fee: <strong>{domainPriceData.monthlyPrice.toLocaleString('en-US')} Toman / month</strong>
</p> </p>
<p className="text-xs text-blue-500 mt-1"> <p className="text-xs text-blue-500 mt-1">
این هزینه در محاسبه کلی هزینهها در نظر گرفته میشود. This fee is included in the total cost calculation.
</p> </p>
</div> </div>
)} )}
<div className="flex gap-2" dir="ltr"> <div className="flex gap-2">
<input <input
type="text" type="text"
value={customDomainInput} value={customDomainInput}
@@ -1413,13 +1419,13 @@ export default function AppDetailPage() {
disabled={!customDomainInput.trim() || setDomainMutation.isPending} disabled={!customDomainInput.trim() || setDomainMutation.isPending}
className="btn-primary text-sm disabled:opacity-50" className="btn-primary text-sm disabled:opacity-50"
> >
{setDomainMutation.isPending ? 'در حال ثبت...' : 'ثبت دامنه'} {setDomainMutation.isPending ? 'Setting up...' : 'Set Domain'}
</button> </button>
<button <button
onClick={() => { setShowDomainSetup(false); setCustomDomainInput(''); }} onClick={() => { setShowDomainSetup(false); setCustomDomainInput(''); }}
className="btn-secondary text-sm" className="btn-secondary text-sm"
> >
انصراف Cancel
</button> </button>
</div> </div>
</div> </div>
+12 -13
View File
@@ -1939,17 +1939,17 @@ export default function DeployPage() {
{/* Custom Domain Option */} {/* Custom Domain Option */}
<div className="bg-white rounded-xl p-5 border border-gray-200"> <div className="bg-white rounded-xl p-5 border border-gray-200">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3 min-w-0">
<div className="w-10 h-10 rounded-xl bg-purple-50 flex items-center justify-center"> <div className="w-10 h-10 rounded-xl bg-purple-50 flex items-center justify-center shrink-0">
<Globe className="w-5 h-5 text-purple-600" /> <Globe className="w-5 h-5 text-purple-600" />
</div> </div>
<div> <div className="min-w-0">
<h3 className="text-sm font-semibold text-gray-800">دامنه اختصاصی</h3> <h3 className="text-sm font-semibold text-gray-800">Custom Domain</h3>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
وبسایت را روی دامنه خود ببینید (با SSL رایگان) Use your own domain with free SSL
{domainPriceData && domainPriceData.monthlyPrice > 0 && ( {domainPriceData && domainPriceData.monthlyPrice > 0 && (
<span className="text-purple-600 font-medium"> {domainPriceData.monthlyPrice.toLocaleString('fa-IR')} تومان/ماه</span> <span className="text-purple-600 font-medium"> {domainPriceData.monthlyPrice.toLocaleString('en-US')} Toman/mo</span>
)} )}
</p> </p>
</div> </div>
@@ -1957,24 +1957,23 @@ export default function DeployPage() {
<button <button
type="button" type="button"
onClick={() => setEnableCustomDomain(!enableCustomDomain)} onClick={() => setEnableCustomDomain(!enableCustomDomain)}
className={`relative w-12 h-6 rounded-full transition-colors ${enableCustomDomain ? 'bg-purple-600' : 'bg-gray-300'}`} className={`relative shrink-0 w-11 h-6 rounded-full transition-colors ${enableCustomDomain ? 'bg-purple-600' : 'bg-gray-300'}`}
> >
<span className={`absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${enableCustomDomain ? 'translate-x-6' : 'translate-x-0.5'}`} /> <span className={`block absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${enableCustomDomain ? 'translate-x-5' : 'translate-x-0'}`} />
</button> </button>
</div> </div>
{enableCustomDomain && ( {enableCustomDomain && (
<div className="mt-4 pt-4 border-t border-gray-100"> <div className="mt-4 pt-4 border-t border-gray-100">
<label className="block text-sm font-medium text-gray-700 mb-2">آدرس دامنه</label> <label className="block text-sm font-medium text-gray-700 mb-2">Domain Address</label>
<input <input
type="text" type="text"
dir="ltr"
value={customDomainInput} value={customDomainInput}
onChange={(e) => setCustomDomainInput(e.target.value)} onChange={(e) => setCustomDomainInput(e.target.value)}
placeholder="example.com or www.example.com" placeholder="example.com or www.example.com"
className="input-field w-full font-mono text-sm" className="input-field w-full font-mono text-sm"
/> />
<p className="text-xs text-gray-400 mt-2" dir="rtl"> <p className="text-xs text-gray-400 mt-2">
بعد از دیپلوی، باید رکورد DNS دامنه خود را تنظیم کنید. راهنمای کامل در صفحه جزئیات اپلیکیشن نمایش داده میشود. After deployment, you will need to configure your DNS records. Full instructions will be shown on the application detail page.
</p> </p>
</div> </div>
)} )}