feat(frontend): show active app domain with click-to-copy

Surface the domain the app is actually reachable on right now (verified
custom domain, else the platform subdomain) as a single "Application
Domain" field. Clicking it copies the address to the clipboard and shows
a "Copied" toast. Centralizes the active-domain logic so the page header
and the verified-custom-domain block stay in sync, and always loads
domain-info so the platform host is correct even without a custom domain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
keyhan
2026-06-18 14:31:45 +03:30
parent a3a7e17cca
commit 04b2f040e0
3 changed files with 44 additions and 14 deletions
@@ -303,7 +303,9 @@ export default function AppDetailPage() {
}>({ }>({
queryKey: ['domain-info', appId], queryKey: ['domain-info', appId],
queryFn: () => api.get(`/applications/${appId}/domain`).then((r) => r.data), queryFn: () => api.get(`/applications/${appId}/domain`).then((r) => r.data),
enabled: showDomainSetup || (!!app && (app.customDomainStatus === 'pending_dns' || app.customDomainStatus === 'verified')), // Always load: we surface the active app domain (platform host included) even
// when no custom domain is configured, so the platform host must be available.
enabled: !!app,
}); });
const { data: domainPriceData } = useQuery<{ monthlyPrice: number }>({ const { data: domainPriceData } = useQuery<{ monthlyPrice: number }>({
@@ -1045,6 +1047,18 @@ export default function AppDetailPage() {
const pageLocked = isAnyDeleting; const pageLocked = isAnyDeleting;
// The host the app is actually reachable on right now: the verified custom
// domain when present, otherwise the platform-assigned subdomain. Single source
// of truth so every place that shows "the app's domain" stays in sync.
const platformHost = `${app.subdomain}.${domainInfo?.platformDomain || 'apps.cloudhost.ir'}`;
const currentDomain =
app.customDomain && app.customDomainStatus === 'verified' ? app.customDomain : platformHost;
const copyDomain = (domain: string) => {
navigator.clipboard.writeText(domain);
notify.success(ad.copied);
};
return ( return (
<> <>
<DeletingModal open={pageLocked} resourceName={app.name} resourceKind="application" /> <DeletingModal open={pageLocked} resourceName={app.name} resourceKind="application" />
@@ -1067,10 +1081,7 @@ export default function AppDetailPage() {
</div> </div>
<p className="text-sm text-gray-500 truncate"> <p className="text-sm text-gray-500 truncate">
{app.runtime}{app.runtimeVersion ? ` v${app.runtimeVersion}` : ''}{app.phpVersion ? ` — PHP ${app.phpVersion}` : ''} ·{' '} {app.runtime}{app.runtimeVersion ? ` v${app.runtimeVersion}` : ''}{app.phpVersion ? ` — PHP ${app.phpVersion}` : ''} ·{' '}
{app.customDomain && app.customDomainStatus === 'verified' <a href={`https://${currentDomain}`} target="_blank" rel="noopener noreferrer" dir="ltr" className="text-primary-600 hover:underline">{currentDomain}</a>
? <a href={`https://${app.customDomain}`} target="_blank" rel="noopener noreferrer" className="text-emerald-600 hover:underline">{app.customDomain}</a>
: <span>{app.subdomain}.{domainInfo?.platformDomain || 'apps.cloudhost.ir'}</span>
}
</p> </p>
</div> </div>
</div> </div>
@@ -1604,16 +1615,24 @@ export default function AppDetailPage() {
)} )}
</div> </div>
{/* Platform domain (always shown) */} {/* Application domain — the host the app is reachable on right now
(verified custom domain, else the platform subdomain). Click to copy. */}
<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 gap-3">
<div> <div className="min-w-0">
<p className="text-xs text-gray-500 mb-1">{ad.platformDomain}</p> <p className="text-xs text-gray-500 mb-1">{ad.appDomain}</p>
<p className="text-sm font-mono font-medium text-gray-800"> <button
{app.subdomain}.{domainInfo?.platformDomain || 'apps.cloudhost.ir'} type="button"
</p> onClick={() => copyDomain(currentDomain)}
title={ad.copyDomainHint}
dir="ltr"
className="group inline-flex items-center gap-1.5 max-w-full text-sm font-mono font-medium text-gray-800 hover:text-primary-600 transition-colors"
>
<span className="truncate">{currentDomain}</span>
<Copy className="w-3.5 h-3.5 shrink-0 opacity-50 group-hover:opacity-100 transition-opacity" />
</button>
</div> </div>
<span className="badge badge-green text-xs">{ad.active}</span> <span className="badge badge-green text-xs shrink-0">{ad.active}</span>
</div> </div>
</div> </div>
@@ -1623,7 +1642,16 @@ export default function AppDetailPage() {
<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">{ad.customDomain}</p> <p className="text-xs text-emerald-600 mb-1">{ad.customDomain}</p>
<p className="text-sm font-mono font-medium text-emerald-800">{app.customDomain}</p> <button
type="button"
onClick={() => copyDomain(app.customDomain!)}
title={ad.copyDomainHint}
dir="ltr"
className="group inline-flex items-center gap-1.5 max-w-full text-sm font-mono font-medium text-emerald-800 hover:text-emerald-600 transition-colors"
>
<span className="truncate">{app.customDomain}</span>
<Copy className="w-3.5 h-3.5 shrink-0 opacity-50 group-hover:opacity-100 transition-opacity" />
</button>
<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 Active Verified on{' '} <CheckCircle className="w-3 h-3 inline" /> SSL Active Verified on{' '}
{app.customDomainVerifiedAt ? new Date(app.customDomainVerifiedAt).toLocaleString(locale) : ''} {app.customDomainVerifiedAt ? new Date(app.customDomainVerifiedAt).toLocaleString(locale) : ''}
+1
View File
@@ -1286,6 +1286,7 @@ const en: Dictionary = {
uploadToStart: 'Upload source code and click Deploy to get started', uploadToStart: 'Upload source code and click Deploy to get started',
sourceCode: 'Source Code', replace: 'Replace', uploadSourceClick: 'Upload source code and click Deploy to get started', sourceCode: 'Source Code', replace: 'Replace', uploadSourceClick: 'Upload source code and click Deploy to get started',
customDomain: 'Custom Domain', addCustomDomain: 'Add Custom Domain', setUpCustomDomain: 'Set Up Custom Domain', customDomain: 'Custom Domain', addCustomDomain: 'Add Custom Domain', setUpCustomDomain: 'Set Up Custom Domain',
appDomain: 'Application Domain', copyDomainHint: 'Click to copy',
platformDomain: 'Platform Domain', domainPlaceholder: 'example.com or www.example.com', dnsSetupGuide: 'DNS Setup Guide', platformDomain: 'Platform Domain', domainPlaceholder: 'example.com or www.example.com', dnsSetupGuide: 'DNS Setup Guide',
removeCustomDomain: 'Remove Custom Domain', removeCustomDomain: 'Remove Custom Domain',
resourcesScaling: 'Resources & Scaling', monitor: 'Monitor', hide: 'Hide', show: 'Show', resourcesScaling: 'Resources & Scaling', monitor: 'Monitor', hide: 'Hide', show: 'Show',
+1
View File
@@ -1291,6 +1291,7 @@ const fa = {
sourceCode: 'سورس‌کد', replace: 'جایگزینی', uploadSourceClick: 'سورس‌کد را آپلود و روی «انتشار» بزن تا شروع کنی', sourceCode: 'سورس‌کد', replace: 'جایگزینی', uploadSourceClick: 'سورس‌کد را آپلود و روی «انتشار» بزن تا شروع کنی',
// domain // domain
customDomain: 'دامنهٔ اختصاصی', addCustomDomain: 'افزودن دامنهٔ اختصاصی', setUpCustomDomain: 'راه‌اندازی دامنهٔ اختصاصی', customDomain: 'دامنهٔ اختصاصی', addCustomDomain: 'افزودن دامنهٔ اختصاصی', setUpCustomDomain: 'راه‌اندازی دامنهٔ اختصاصی',
appDomain: 'دامنهٔ اپلیکیشن', copyDomainHint: 'برای کپی کلیک کن',
platformDomain: 'دامنهٔ پلتفرم', domainPlaceholder: 'example.com یا www.example.com', dnsSetupGuide: 'راهنمای تنظیم DNS', platformDomain: 'دامنهٔ پلتفرم', domainPlaceholder: 'example.com یا www.example.com', dnsSetupGuide: 'راهنمای تنظیم DNS',
removeCustomDomain: 'حذف دامنهٔ اختصاصی', removeCustomDomain: 'حذف دامنهٔ اختصاصی',
// resources // resources