feat: support custom domain (#462)

* feat: add custom domain CRUD

* chore: fetch organization in layout in server before render

* debugging

* update log
This commit is contained in:
Rotimi Best
2024-09-04 06:32:54 +01:00
committed by GitHub
parent d1b38ca61e
commit 12d7a1ac3c
25 changed files with 915 additions and 221 deletions
@@ -1,30 +1,51 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { Grid, Row, Column } from 'carbon-components-svelte';
import isValidDomain from 'is-valid-domain';
import { parse } from 'tldts';
import { Grid, Row, Column, CopyButton } from 'carbon-components-svelte';
import Restart from 'carbon-icons-svelte/lib/Restart.svelte';
import TrashCan from 'carbon-icons-svelte/lib/TrashCan.svelte';
import ArrowUpRight from 'carbon-icons-svelte/lib/ArrowUpRight.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import TextChip from '$lib/components/Chip/Text.svelte';
import ComingSoon from '$lib/components/ComingSoon/index.svelte';
import UploadImage from '$lib/components/UploadImage/index.svelte';
import TextArea from '$lib/components/Form/TextArea.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { currentOrg } from '$lib/utils/store/org';
import type { CurrentOrg } from '$lib/utils/types/org';
import { supabase } from '$lib/utils/functions/supabase';
import { blockedSubdomain } from '$lib/utils/constants/app';
import { snackbar } from '$lib/components/Snackbar/store';
import SectionTitle from '../SectionTitle.svelte';
import VisitOrgSiteButton from '$lib/components/Buttons/VisitOrgSite.svelte';
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
import IconButton from '$lib/components/IconButton/index.svelte';
import { updateOrgSiteNameValidation } from '$lib/utils/functions/validator';
import { t } from '$lib/utils/functions/translations';
import { sendDomainRequest } from '$lib/utils/services/org/domain';
let siteName = '';
let customDomain = '';
let customCode = '';
let favicon = '';
let isDomainValid = false;
let isLoading = false;
let isCustomDomainLoading = false;
let isRefreshing = false;
type Error = {
siteName: string;
customDomain: string;
};
let siteName = '';
let errors: Error = {
siteName: ''
siteName: '',
customDomain: ''
};
let isLoading = false;
async function handleChangeDomain() {
async function handleSaveSiteName() {
errors = updateOrgSiteNameValidation(siteName) as Error;
if (Object.values(errors).length) {
@@ -57,20 +78,129 @@
isLoading = false;
}
function setSiteName(_siteName: string) {
if (!siteName) {
siteName = _siteName;
async function handleSaveCustomDomain() {
if (!isDomainValid) return;
const details = parse(customDomain);
if (!details.subdomain) {
errors.customDomain = $t('components.settings.domains.custom_domain_error');
return;
}
isCustomDomainLoading = true;
const { error } = await supabase
.from('organization')
.update({ customDomain })
.match({ id: $currentOrg.id });
if (error) {
console.log('Error: create organisation', error);
errors.customDomain = error.message;
isCustomDomainLoading = false;
return;
}
try {
const response = await sendDomainRequest('add_domain', customDomain);
const data = await response.json();
console.log('added domain to vercel', data);
} catch (error) {
console.log('Error: adding domain to vercel', error);
snackbar.error(error as string);
isCustomDomainLoading = false;
return;
}
snackbar.success('components.settings.domains.custom_domain_success');
$currentOrg.customDomain = customDomain;
isCustomDomainLoading = false;
}
function resetErrors(_siteName: string) {
async function handleRemoveCustomDomain() {
if (!$currentOrg.customDomain) return;
isCustomDomainLoading = true;
const { error } = await supabase
.from('organization')
.update({ customDomain: null, isCustomDomainVerified: false })
.match({ id: $currentOrg.id });
if (error) {
console.log('Error: updating organisation', error);
snackbar.error(error.message);
isCustomDomainLoading = false;
return;
}
try {
const response = await sendDomainRequest('remove_domain', $currentOrg.customDomain);
const data = await response.json();
if (!data.success) {
throw new Error(data.message);
}
console.log('removed domain from vercel', data);
} catch (error) {
console.log('Error: removing domain from vercel', error);
snackbar.error(error as string);
isCustomDomainLoading = false;
return;
}
snackbar.success();
$currentOrg.customDomain = undefined;
$currentOrg.isCustomDomainVerified = false;
isCustomDomainLoading = false;
}
async function handleRefreshCustomDomain() {
isRefreshing = true;
try {
const response = await sendDomainRequest('verify_domain', $currentOrg.customDomain || '');
const data = await response.json();
console.log('data', data);
if (data.verified && !$currentOrg.isCustomDomainVerified) {
$currentOrg.isCustomDomainVerified = true;
await supabase
.from('organization')
.update({ isCustomDomainVerified: true })
.match({ id: $currentOrg.id });
}
} catch (error) {
console.log('Error: refreshing domain', error);
snackbar.error(error as string);
}
isRefreshing = false;
}
function resetErrors(_siteName: string, _customDomain: string) {
if (errors.siteName) {
errors.siteName = '';
}
if (errors.customDomain) {
errors.customDomain = '';
}
}
$: resetErrors(siteName);
$: setSiteName($currentOrg.siteName);
function setDefaults(org: CurrentOrg) {
if (!siteName) {
siteName = org.siteName;
}
}
function getSubdomain() {
const details = parse($currentOrg.customDomain || '');
return details.subdomain;
}
$: setDefaults($currentOrg);
$: resetErrors(siteName, customDomain);
$: isDomainValid = isValidDomain(customDomain, { subdomain: true });
</script>
<Grid class="border rounded border-gray-200 dark:border-neutral-600 w-full mt-5">
@@ -80,7 +210,7 @@
>
<Column sm={2} md={6} lg={8}>
<p class="text-md text-gray-500 dark:text-white mb-5">
{$t('components.settings.domains.work_together')}
{$t('settings.organization.organization_profile.custom_domain.body')}
</p>
<div>
@@ -100,7 +230,7 @@
label={$t('components.settings.domains.update')}
className="py-4"
variant={VARIANTS.OUTLINED}
onClick={handleChangeDomain}
onClick={handleSaveSiteName}
isDisabled={isLoading}
/>
@@ -110,15 +240,168 @@
</Column>
</Row>
<Row class="py-7 border-bottom-c">
<Column sm={2} md={2} lg={4} class="text-lg"
><SectionTitle>{$t('components.settings.domains.custom')}</SectionTitle></Column
>
<Column sm={2} md={6} lg={8}>
<div class="flex items-center">
<p class="dark:text-white mr-3">{$t('components.settings.domains.domain')}</p>
<ComingSoon />
<Row class="py-7">
<UpgradeBanner className="mb-5">{$t('upgrade.domain')}</UpgradeBanner>
<Column sm={2} md={2} lg={4} class="text-lg">
<SectionTitle>
{$t('components.settings.domains.custom')}
</SectionTitle>
</Column>
<Column sm={2} md={6} lg={10}>
<div class="pb-10 border-bottom-c">
<!-- DNS Configuration -->
{#if $currentOrg.customDomain}
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<p class="font-medium text-md flex items-center gap-2">
{$currentOrg.customDomain}
<IconButton
contained={true}
size="small"
onClick={() => goto(`https://${$currentOrg.customDomain}`)}
>
<ArrowUpRight size={16} />
</IconButton>
</p>
<div
class="mt-1 h-2 w-2 rounded-full bg-{$currentOrg.isCustomDomainVerified
? 'green'
: 'yellow'}-400"
></div>
</div>
{#if $currentOrg.isCustomDomainVerified}
<TextChip
value="Verified"
className="bg-green-500 text-white text-xs px-3"
size="sm"
/>
{:else}
<TextChip
value="Pending verification"
className="bg-yellow-500 text-white text-xs px-3"
size="sm"
/>
{/if}
</div>
<div class="text-sm text-gray-500 mb-4">
{$t('components.settings.domains.dns_description')}
</div>
<div class="flex items-center gap-10 mb-4 border rounded-md py-2 px-4">
<div class="h-[72px] justify-evenly flex flex-col">
<p class="font-light text-sm">{$t('components.settings.domains.dns_type')}</p>
<p class="h-[40px] flex items-center">CNAME</p>
</div>
<div class="h-[72px] justify-evenly flex flex-col">
<p class="font-light text-sm">{$t('components.settings.domains.dns_name')}</p>
<p class="h-[40px] flex items-center">
{getSubdomain()}
</p>
</div>
<div class="h-[72px] justify-evenly flex flex-col">
<p class="font-light text-sm">{$t('components.settings.domains.dns_value')}</p>
<p class=" flex items-center gap-1">
cname.vercel-dns.com
<CopyButton text="cname.vercel-dns.com" />
</p>
</div>
</div>
<div class="flex items-center justify-between mt-5">
<PrimaryButton
className="py-4 flex items-center gap-2"
onClick={handleRefreshCustomDomain}
isLoading={isRefreshing}
variant={VARIANTS.OUTLINED}
>
{#if !isRefreshing}
<Restart size={16} />
{/if}
{$t('components.settings.domains.refresh')}
</PrimaryButton>
<PrimaryButton
className="py-4 flex items-center gap-2"
onClick={handleRemoveCustomDomain}
isLoading={isCustomDomainLoading}
variant={VARIANTS.CONTAINED_DANGER}
>
{#if !isCustomDomainLoading}
<TrashCan size={16} />
{/if}
{$t('components.settings.domains.remove')}
</PrimaryButton>
</div>
{:else}
<!-- Add Custom Domain -->
<div class="flex items-center gap-5 mb-4">
<p class="font-bold">{$t('components.settings.domains.your_domain')}</p>
</div>
<TextField
bind:value={customDomain}
type="text"
placeholder="courses.yourwebsite.com"
helperMessage="https://{customDomain || 'course.yourwebsite.com'}"
errorMessage={errors.customDomain}
className="w-4/5"
/>
<div class="flex items-center mt-5">
<PrimaryButton
label={$t('components.settings.domains.save')}
className="py-4"
onClick={handleSaveCustomDomain}
isLoading={isCustomDomainLoading}
isDisabled={isLoading || !isDomainValid}
/>
</div>
{/if}
</div>
<div class="py-10 border-bottom-c">
<div class="flex items-center gap-5 mb-4">
<p class="font-bold">{$t('components.settings.domains.custom_favicon')}</p>
<ComingSoon />
</div>
<div class="flex items-center">
<UploadImage
shape="rounded-md"
bind:avatar={favicon}
src={$currentOrg.favicon ?? '/logo-512.png'}
widthHeight="w-16 h-16 lg:w-24 lg:h-24"
flexDirection="flex-row"
isDisabled={true}
/>
</div>
</div>
<div class="py-10">
<div class="flex items-center gap-5 mb-4">
<p class="font-bold">{$t('components.settings.domains.custom_code')}</p>
<ComingSoon />
</div>
<TextArea
bind:value={customCode}
placeholder="e.g <link rel='stylesheet' href='https://example.com/style.css' />"
className="w-4/5"
rows={7}
disabled={true}
/>
</div>
<!-- <div class="flex items-center">
<p class="dark:text-white mr-3">
{$t('components.settings.domains.domain')}
</p>
<ComingSoon />
</div> -->
</Column>
</Row>
</Grid>
@@ -234,11 +234,15 @@
{$t('settings.organization.organization_profile.custom_domain.body')}
</p>
<PrimaryButton
label={$t('settings.organization.organization_profile.custom_domain.button')}
className="my-7 py-5 px-10"
className="my-7 py-5 px-10 flex items-center gap-2 justify-center"
variant={VARIANTS.OUTLINED}
onClick={() => gotoSetting('/domains')}
/>
>
{#if $isFreePlan}
<FlashFilled size={16} class="text-blue-700" />
{/if}
{$t('settings.organization.organization_profile.custom_domain.button')}
</PrimaryButton>
</Column>
</Row>
<Row class="flex lg:flex-row flex-col py-7 border-bottom-c">