chore: improve ui experience of custom domain setup

This commit is contained in:
Rotimi Best
2026-05-21 11:30:12 +01:00
parent 61cbb258ea
commit c1bbd02f8e
12 changed files with 209 additions and 77 deletions
@@ -1,10 +1,13 @@
<script lang="ts">
import AlertTriangleIcon from '@lucide/svelte/icons/alert-triangle';
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
import CheckIcon from '@lucide/svelte/icons/check';
import ClockIcon from '@lucide/svelte/icons/clock';
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check';
import TrashIcon from '@lucide/svelte/icons/trash';
import type { DomainRequestData, DomainRequestStatus } from '$features/org/utils/types';
import isValidDomain from 'is-valid-domain';
import { parse as parseDomain } from 'tldts';
import { goto } from '$app/navigation';
import { onMount, untrack } from 'svelte';
@@ -18,6 +21,9 @@
import { BRAND_ROOT_DOMAIN, TENANT_ROOT_DOMAIN } from '@cio/utils/constants';
import { Badge } from '@cio/ui/base/badge';
import { IconButton } from '@cio/ui/custom/icon-button';
import { Spinner } from '@cio/ui/base/spinner';
import { Button } from '@cio/ui/base/button';
import { CopyButton } from '@cio/ui/base/copy-button';
import { Textarea } from '@cio/ui/base/textarea';
@@ -33,6 +39,7 @@
let isCustomDomainLoading = $state(false);
let isRefreshing = $state(false);
let domainSetup = $state<DomainRequestData | null>(null);
let domainApex = $state<string | null>(null);
const isDomainValid = $derived(isValidDomain(sanitizeDomain(customDomain), { subdomain: true }));
const currentDomainStatus = $derived<DomainRequestStatus | null>(
@@ -40,15 +47,20 @@
($currentOrg.customDomain ? ($currentOrg.isCustomDomainVerified ? 'verified' : 'pending_verification') : null)
);
async function refreshDomainApex(hostname: string | null | undefined) {
if (!hostname) {
domainApex = null;
return;
}
const { parse } = await import('tldts/dist/es6/index.js');
domainApex = parse(hostname).domain ?? null;
}
function stripZone(recordName: string): string {
const hostname = $currentOrg.customDomain;
if (!hostname) return recordName;
if (!domainApex) return recordName;
const apex = parseDomain(hostname).domain;
if (!apex) return recordName;
const suffix = '.' + apex;
if (recordName === apex) return recordName;
const suffix = '.' + domainApex;
if (recordName === domainApex) return recordName;
if (recordName.endsWith(suffix)) return recordName.slice(0, -suffix.length);
return recordName;
@@ -102,12 +114,14 @@
if (data.status === 'removed') {
$currentOrg.customDomain = '';
$currentOrg.isCustomDomainVerified = false;
domainApex = null;
return;
}
$currentOrg.customDomain = data.hostname;
$currentOrg.isCustomDomainVerified = data.verified;
customDomain = '';
void refreshDomainApex(data.hostname);
if (data.verified && !wasVerified) {
snackbar.success('components.settings.domains.verified_snackbar');
@@ -282,6 +296,7 @@
onMount(() => {
if ($currentOrg.customDomain) {
void refreshDomainApex($currentOrg.customDomain);
void handleRefreshCustomDomain(true);
}
});
@@ -332,7 +347,13 @@
<div class="flex items-center gap-2">
<p class="text-md flex items-center gap-2 font-medium">
{$currentOrg.customDomain}
<Button variant="outline" size="icon-sm" onclick={() => goto(`https://${$currentOrg.customDomain}`)}>
<Button
variant="outline"
size="icon-sm"
onclick={() => {
window.open(`https://${$currentOrg.customDomain}`, '_blank');
}}
>
<ArrowUpRightIcon size={16} />
</Button>
</p>
@@ -350,40 +371,119 @@
<Badge variant="default" class={`px-3 text-xs ${getDomainStatusClass(currentDomainStatus)}`}>
{getDomainStatusLabel(currentDomainStatus)}
</Badge>
<IconButton onclick={handleRemoveCustomDomain} loading={isCustomDomainLoading}>
{#if isCustomDomainLoading}
<Spinner class="ui:text-muted-foreground size-4" />
{:else}
<TrashIcon size={16} />
{/if}
</IconButton>
</div>
</Field.Field>
<Field.Description>{getDomainStatusDescription(currentDomainStatus)}</Field.Description>
{#if domainSetup?.validationErrors?.length}
<Field.Field>
<div
class="flex items-start gap-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm dark:border-red-900 dark:bg-red-950/40"
>
<AlertTriangleIcon class="mt-0.5 size-4 shrink-0 text-red-600 dark:text-red-400" />
<div class="min-w-0 flex-1">
<p class="font-medium text-red-900 dark:text-red-200">
{$t('components.settings.domains.validation_errors_heading')}
</p>
<ul class="mt-1 space-y-1 text-red-800 dark:text-red-300">
{#each domainSetup.validationErrors as message, i (i)}
<li>{message}</li>
{/each}
</ul>
</div>
</div>
</Field.Field>
{/if}
{#if currentDomainStatus === 'pending_verification'}
<Field.Field>
<div
class="flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm dark:border-amber-900 dark:bg-amber-950/40"
>
<ShieldCheckIcon class="mt-0.5 size-4 shrink-0 text-amber-700 dark:text-amber-400" />
<div class="min-w-0 flex-1">
<p class="font-medium text-amber-900 dark:text-amber-200">
{$t('components.settings.domains.ssl_pending_heading')}
</p>
<p class="mt-1 text-amber-800 dark:text-amber-300">
{$t('components.settings.domains.ssl_pending_body')}
</p>
</div>
</div>
</Field.Field>
{/if}
{#if currentDomainStatus !== 'verified' && domainSetup?.dnsRecords?.length}
<Field.Field>
<div class="flex flex-col gap-3">
<div class="ui:bg-card overflow-hidden rounded-lg border">
{#each domainSetup.dnsRecords as record, i (record.name + i)}
{@const rows = [
{ label: $t('components.settings.domains.dns_type'), value: record.type },
{@const fields = [
{ label: $t('components.settings.domains.dns_name'), value: stripZone(record.name) },
{ label: $t('components.settings.domains.dns_value'), value: record.value }
]}
<div class="ui:bg-card overflow-hidden rounded-lg border">
<div
class="ui:bg-muted/40 ui:text-muted-foreground border-b px-4 py-2 text-xs font-medium tracking-wide uppercase"
>
{$t('components.settings.domains.dns_record')}
<div class={'p-4 ' + (i > 0 ? 'border-t' : '')}>
<div class="mb-3 flex items-center gap-2">
<span
class={'inline-flex items-center rounded-md px-2 py-0.5 font-mono text-xs font-semibold tracking-wide ' +
(record.type === 'CNAME'
? 'bg-blue-50 text-blue-700 ring-1 ring-blue-200 dark:bg-blue-950/40 dark:text-blue-300 dark:ring-blue-900'
: 'bg-slate-100 text-slate-700 ring-1 ring-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:ring-slate-700')}
>
{record.type}
</span>
<span class="ui:text-muted-foreground text-xs">
Record {i + 1} of {domainSetup.dnsRecords.length}
</span>
<span
class={'ml-auto inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ' +
(record.status === 'active'
? 'bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-300 dark:ring-emerald-900'
: 'bg-amber-50 text-amber-800 ring-1 ring-amber-200 dark:bg-amber-950/40 dark:text-amber-300 dark:ring-amber-900')}
aria-label={record.status === 'active'
? $t('components.settings.domains.record_active')
: $t('components.settings.domains.record_pending')}
>
{#if record.status === 'active'}
<CheckIcon class="size-3" />
<span>{$t('components.settings.domains.record_active')}</span>
{:else}
<ClockIcon class="size-3" />
<span>{$t('components.settings.domains.record_pending')}...</span>
{/if}
</span>
</div>
<dl class="divide-y">
{#each rows as row (row.label)}
<div class="grid grid-cols-[80px,1fr,auto] items-center gap-3 px-4 py-2.5">
<dt class="ui:text-muted-foreground text-xs font-medium tracking-wide uppercase">
{row.label}
</dt>
<dd class="min-w-0">
<span class="block truncate font-mono text-sm">{row.value}</span>
</dd>
<CopyButton text={row.value} variant="ghost" size="icon-sm" aria-label={'Copy ' + row.label} />
<div class="flex flex-wrap gap-x-6 gap-y-3">
{#each fields as field (field.label)}
<div class="min-w-[240px] flex-1">
<div class="ui:text-muted-foreground mb-1 text-xs font-medium tracking-wide uppercase">
{field.label}
</div>
<div class="flex items-center gap-2">
<span class="min-w-0 flex-1 truncate font-mono text-sm" title={field.value}
>{field.value}</span
>
<CopyButton
text={field.value}
variant="outline"
size="icon-sm"
aria-label={'Copy ' + field.label}
class="shrink-0"
/>
</div>
</div>
{/each}
</dl>
</div>
</div>
{/each}
</div>
@@ -407,6 +507,7 @@
</Button>
{:else if currentDomainStatus !== 'verified'}
<Button
variant="outline"
class="flex items-center gap-2 py-2"
onclick={() => handleRefreshCustomDomain()}
loading={isRefreshing}
@@ -416,19 +517,18 @@
{/if}
{$t('components.settings.domains.refresh')}
</Button>
<Button
variant="destructive"
class="flex items-center gap-2 py-2"
onclick={handleRemoveCustomDomain}
loading={isCustomDomainLoading}
>
{#if !isCustomDomainLoading}
<TrashIcon size={16} />
{/if}
{$t('components.settings.domains.remove')}
</Button>
{/if}
<Button
variant="outline"
class="ui:text-destructive ui:hover:text-destructive ui:hover:bg-destructive/10 flex items-center gap-2 py-2"
onclick={handleRemoveCustomDomain}
loading={isCustomDomainLoading}
>
{#if !isCustomDomainLoading}
<TrashIcon size={16} />
{/if}
{$t('components.settings.domains.remove')}
</Button>
</Field.Field>
{:else}
<Field.Field>
@@ -2164,9 +2164,7 @@
"depth": "Hvor dybt skal kurset gå?",
"topic": "Emne eller niche",
"expertise_level": "Mål ekspertiseniveau",
"documentation_url": {
"hint": "Vi læser dette websted for at basere kurset på dit rigtige produkt."
}
"documentation_url": "Dokumentations-URL (valgfrit)"
},
"expert_on_x": {
"title": "Bliv ekspert på X",
@@ -2419,7 +2417,12 @@
"error_description": "Dette tilpassede domæne eksisterer, men bekræftelsen er ikke fuldført endnu.",
"page_subtitle": "Run your public learning site on a web address you own.",
"dns_record": "DNS-record",
"verified_snackbar": "Tilpasset domæne bekræftet"
"verified_snackbar": "Tilpasset domæne bekræftet",
"record_active": "Opdaget",
"record_pending": "Afventer",
"ssl_pending_heading": "SSL-certifikat er ved at blive udstedt",
"ssl_pending_body": "Ejerskabet af dit domæne er bekræftet. Vi venter nu på, at Cloudflare udsteder og implementerer SSL-certifikatet. Dette tager normalt et par minutter, når alle nedenstående DNS-poster er på plads.",
"validation_errors_heading": "Certifikatudstedelse er blokeret"
}
},
"quiz": {
@@ -2164,9 +2164,7 @@
"depth": "Wie tief soll der Kurs gehen?",
"topic": "Thema oder Nische",
"expertise_level": "Zielkompetenzniveau",
"documentation_url": {
"hint": "Wir werden diese Website lesen, um den Kurs in Ihrem realen Produkt zu verankern."
}
"documentation_url": "Dokumentations-URL (optional)"
},
"expert_on_x": {
"title": "Werden Sie Experte für X",
@@ -2419,7 +2417,12 @@
"error_description": "Diese benutzerdefinierte Domäne existiert, aber die Überprüfung ist noch nicht abgeschlossen.",
"page_subtitle": "Run your public learning site on a web address you own.",
"dns_record": "DNS-Eintrag",
"verified_snackbar": "Benutzerdefinierte Domain verifiziert"
"verified_snackbar": "Benutzerdefinierte Domain verifiziert",
"record_active": "Erkannt",
"record_pending": "Ausstehend",
"ssl_pending_heading": "SSL-Zertifikat wird ausgestellt",
"ssl_pending_body": "Der Besitz Ihrer Domain wird bestätigt. Wir warten nun darauf, dass Cloudflare das SSL-Zertifikat ausstellt und bereitstellt. Dies dauert normalerweise einige Minuten, sobald alle unten aufgeführten DNS-Einträge vorhanden sind.",
"validation_errors_heading": "Die Ausstellung des Zertifikats ist gesperrt"
}
},
"quiz": {
@@ -1524,6 +1524,11 @@
"dns_value": "Value",
"dns_name": "Name",
"dns_record": "DNS record",
"record_active": "Detected",
"record_pending": "Pending",
"ssl_pending_heading": "SSL certificate is being issued",
"ssl_pending_body": "Ownership of your domain is verified. We're now waiting for Cloudflare to issue and deploy the SSL certificate. This usually takes a few minutes once all DNS records below are in place.",
"validation_errors_heading": "Certificate issuance is blocked",
"refresh": "Refresh",
"reconnect": "Reconnect domain",
"reconnect_hint": "Reconnect this domain to generate new Cloudflare setup records.",
@@ -2164,9 +2164,7 @@
"depth": "¿Qué profundidad debe tener el curso?",
"topic": "Tema o nicho",
"expertise_level": "Nivel de experiencia objetivo",
"documentation_url": {
"hint": "Leeremos este sitio para basar el curso en su producto real."
}
"documentation_url": "URL de documentación (opcional)"
},
"expert_on_x": {
"title": "Conviértete en un experto en X",
@@ -2419,7 +2417,12 @@
"error_description": "Este dominio personalizado existe, pero la verificación aún no se ha completado.",
"page_subtitle": "Run your public learning site on a web address you own.",
"dns_record": "registro DNS",
"verified_snackbar": "Dominio personalizado verificado"
"verified_snackbar": "Dominio personalizado verificado",
"record_active": "Detectado",
"record_pending": "Pendiente",
"ssl_pending_heading": "Se está emitiendo el certificado SSL",
"ssl_pending_body": "Se verifica la propiedad de su dominio. Ahora estamos esperando que Cloudflare emita e implemente el certificado SSL. Esto suele tardar unos minutos una vez que todos los registros DNS siguientes estén en su lugar.",
"validation_errors_heading": "La emisión de certificados está bloqueada"
}
},
"quiz": {
@@ -2164,9 +2164,7 @@
"depth": "Jusqu’où doit aller le cours ?",
"topic": "Sujet ou niche",
"expertise_level": "Niveau d’expertise cible",
"documentation_url": {
"hint": "Nous lirons ce site pour ancrer le cours dans votre produit réel."
}
"documentation_url": "URL de la documentation (facultatif)"
},
"expert_on_x": {
"title": "Devenez un expert sur X",
@@ -2419,7 +2417,12 @@
"error_description": "Ce domaine personnalisé existe, mais la vérification n'est pas encore terminée.",
"page_subtitle": "Run your public learning site on a web address you own.",
"dns_record": "Enregistrement DNS",
"verified_snackbar": "Domaine personnalisé vérifié"
"verified_snackbar": "Domaine personnalisé vérifié",
"record_active": "Détecté",
"record_pending": "En attente",
"ssl_pending_heading": "Le certificat SSL est en cours d'émission",
"ssl_pending_body": "La propriété de votre domaine est vérifiée. Nous attendons maintenant que Cloudflare émette et déploie le certificat SSL. Cela prend généralement quelques minutes une fois que tous les enregistrements DNS ci-dessous sont en place.",
"validation_errors_heading": "La délivrance du certificat est bloquée"
}
},
"quiz": {
@@ -2164,9 +2164,7 @@
"depth": "पाठ्यक्रम कितना गहरा होना चाहिए?",
"topic": "विषय या आला",
"expertise_level": "लक्ष्य विशेषज्ञता स्तर",
"documentation_url": {
"hint": "हम आपके वास्तविक उत्पाद में पाठ्यक्रम को आधार बनाने के लिए इस साइट को पढ़ेंगे।"
}
"documentation_url": "दस्तावेज़ीकरण URL (वैकल्पिक)"
},
"expert_on_x": {
"title": "एक्स पर विशेषज्ञ बनें",
@@ -2419,7 +2417,12 @@
"error_description": "यह कस्टम डोमेन मौजूद है, लेकिन सत्यापन अभी तक पूरा नहीं हुआ है।",
"page_subtitle": "Run your public learning site on a web address you own.",
"dns_record": "डीएनएस रिकॉर्ड",
"verified_snackbar": "कस्टम डोमेन सत्यापित"
"verified_snackbar": "कस्टम डोमेन सत्यापित",
"record_active": "पता चला",
"record_pending": "लंबित",
"ssl_pending_heading": "एसएसएल प्रमाणपत्र जारी किया जा रहा है",
"ssl_pending_body": "आपके डोमेन का स्वामित्व सत्यापित है. अब हम क्लाउडफ्लेयर द्वारा एसएसएल प्रमाणपत्र जारी करने और तैनात करने की प्रतीक्षा कर रहे हैं। नीचे दिए गए सभी DNS रिकॉर्ड सही जगह पर आ जाने पर आमतौर पर इसमें कुछ मिनट लगते हैं।",
"validation_errors_heading": "प्रमाणपत्र जारी करना अवरुद्ध है"
}
},
"quiz": {
@@ -2164,9 +2164,7 @@
"depth": "Jak głęboko powinien sięgać kurs?",
"topic": "Temat lub nisza",
"expertise_level": "Docelowy poziom wiedzy",
"documentation_url": {
"hint": "Przeczytamy tę witrynę, aby oprzeć kurs na Twoim prawdziwym produkcie."
}
"documentation_url": "Adres URL dokumentacji (opcjonalnie)"
},
"expert_on_x": {
"title": "Zostań ekspertem w dziedzinie X",
@@ -2419,7 +2417,12 @@
"error_description": "Ta domena niestandardowa istnieje, ale weryfikacja nie została jeszcze ukończona.",
"page_subtitle": "Run your public learning site on a web address you own.",
"dns_record": "Rekord DNS",
"verified_snackbar": "Zweryfikowano domenę niestandardową"
"verified_snackbar": "Zweryfikowano domenę niestandardową",
"record_active": "Wykryto",
"record_pending": "Oczekujące",
"ssl_pending_heading": "Certyfikat SSL jest wydawany",
"ssl_pending_body": "Własność Twojej domeny została zweryfikowana. Czekamy teraz, aż Cloudflare wystawi i wdroży certyfikat SSL. Zwykle zajmuje to kilka minut, gdy wszystkie poniższe rekordy DNS są już na swoim miejscu.",
"validation_errors_heading": "Wystawienie certyfikatu jest zablokowane"
}
},
"quiz": {
@@ -2164,9 +2164,7 @@
"depth": "Quão profundo o curso deve ir?",
"topic": "Tópico ou nicho",
"expertise_level": "Nível de especialização alvo",
"documentation_url": {
"hint": "Leremos este site para fundamentar o curso em seu produto real."
}
"documentation_url": "URL da documentação (opcional)"
},
"expert_on_x": {
"title": "Torne-se um especialista em X",
@@ -2419,7 +2417,12 @@
"error_description": "Este domínio personalizado existe, mas a verificação ainda não foi concluída.",
"page_subtitle": "Run your public learning site on a web address you own.",
"dns_record": "Registro DNS",
"verified_snackbar": "Domínio personalizado verificado"
"verified_snackbar": "Domínio personalizado verificado",
"record_active": "Detectado",
"record_pending": "Pendente",
"ssl_pending_heading": "O certificado SSL está sendo emitido",
"ssl_pending_body": "A propriedade do seu domínio é verificada. Agora estamos aguardando a Cloudflare emitir e implantar o certificado SSL. Isso geralmente leva alguns minutos depois que todos os registros DNS abaixo estiverem em vigor.",
"validation_errors_heading": "A emissão do certificado está bloqueada"
}
},
"quiz": {
@@ -2164,9 +2164,7 @@
"depth": "Насколько глубоким должен быть курс?",
"topic": "Тема или ниша",
"expertise_level": "Целевой уровень экспертизы",
"documentation_url": {
"hint": "Мы прочитаем этот сайт, чтобы закрепить курс на вашем реальном продукте."
}
"documentation_url": "URL-адрес документации (необязательно)"
},
"expert_on_x": {
"title": "Станьте экспертом по X",
@@ -2419,7 +2417,12 @@
"error_description": "Этот личный домен существует, но проверка еще не завершена.",
"page_subtitle": "Run your public learning site on a web address you own.",
"dns_record": "DNS-запись",
"verified_snackbar": "Персональный домен подтвержден"
"verified_snackbar": "Персональный домен подтвержден",
"record_active": "Обнаружено",
"record_pending": "Ожидается",
"ssl_pending_heading": "SSL-сертификат выдается",
"ssl_pending_body": "Право собственности на ваш домен подтверждено. Теперь мы ждем, пока Cloudflare выпустит и развернет сертификат SSL. Обычно это занимает несколько минут после того, как все указанные ниже записи DNS будут на месте.",
"validation_errors_heading": "Выдача сертификата заблокирована"
}
},
"quiz": {
@@ -2164,9 +2164,7 @@
"depth": "Khóa học nên đi sâu đến mức nào?",
"topic": "Chủ đề hoặc niche",
"expertise_level": "Mục tiêu trình độ chuyên môn",
"documentation_url": {
"hint": "Chúng tôi sẽ đọc trang web này để xây dựng khóa học về sản phẩm thực của bạn."
}
"documentation_url": "URL tài liệu (tùy chọn)"
},
"expert_on_x": {
"title": "Trở thành chuyên gia về X",
@@ -2419,7 +2417,12 @@
"error_description": "Miền tùy chỉnh này tồn tại nhưng quá trình xác minh chưa hoàn tất.",
"page_subtitle": "Run your public learning site on a web address you own.",
"dns_record": "bản ghi DNS",
"verified_snackbar": "Đã xác minh miền tùy chỉnh"
"verified_snackbar": "Đã xác minh miền tùy chỉnh",
"record_active": "Đã phát hiện",
"record_pending": "Đang chờ xử lý",
"ssl_pending_heading": "Chứng chỉ SSL đang được cấp",
"ssl_pending_body": "Quyền sở hữu tên miền của bạn đã được xác minh. Hiện chúng tôi đang chờ Cloudflare phát hành và triển khai chứng chỉ SSL. Quá trình này thường mất vài phút sau khi tất cả bản ghi DNS bên dưới đã sẵn sàng.",
"validation_errors_heading": "Việc cấp chứng chỉ bị chặn"
}
},
"quiz": {