Merge branch 'develop' into fix/hc-editor

This commit is contained in:
Muhsin Keloth
2025-08-14 13:56:49 +05:30
committed by GitHub
3651 changed files with 197936 additions and 49575 deletions
@@ -70,7 +70,7 @@ const category = {
<div
v-for="(article, index) in articles"
:key="index"
class="px-20 py-4 bg-white dark:bg-slate-900"
class="px-20 py-4 bg-n-background"
>
<ArticleCard
:id="article.id"
@@ -32,7 +32,7 @@ const categories = [
<div
v-for="(category, index) in categories"
:key="index"
class="px-20 py-4 bg-white dark:bg-slate-900"
class="px-20 py-4 bg-n-background"
>
<CategoryCard
:id="category.id"
@@ -121,11 +121,7 @@ const handleAction = ({ action, value }) => {
</div>
<span
class="text-sm line-clamp-3"
:class="
hasDescription
? 'text-slate-500 dark:text-slate-400'
: 'text-slate-400 dark:text-slate-700'
"
:class="hasDescription ? 'text-n-slate-11' : 'text-n-slate-9'"
>
{{ description }}
</span>
@@ -8,7 +8,7 @@ import ArticleEmptyState from './ArticleEmptyState.vue';
:layout="{ type: 'single', width: '1100px' }"
>
<Variant title="Article Empty State">
<div class="w-full h-full px-20 mx-auto bg-white dark:bg-slate-900">
<div class="w-full h-full px-20 mx-auto bg-n-background">
<ArticleEmptyState />
</div>
</Variant>
@@ -8,7 +8,7 @@ import PortalEmptyState from './PortalEmptyState.vue';
:layout="{ type: 'single', width: '1100px' }"
>
<Variant title="Portal Empty State">
<div class="w-full h-full px-20 mx-auto bg-white dark:bg-slate-900">
<div class="w-full h-full px-20 mx-auto bg-n-background">
<PortalEmptyState />
</div>
</Variant>
@@ -60,7 +60,7 @@ const togglePortalSwitcher = () => {
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6 pb-3 lg:px-0">
<div class="w-full max-w-[960px] mx-auto lg:px-6">
<div class="w-full max-w-[60rem] mx-auto lg:px-6">
<div
v-if="showHeaderTitle"
class="flex items-center justify-start h-20 gap-2"
@@ -96,7 +96,7 @@ const togglePortalSwitcher = () => {
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-[960px] mx-auto py-3 lg:px-6">
<div class="w-full max-w-[60rem] mx-auto py-3 lg:px-6">
<slot name="content" />
</div>
</main>
@@ -14,7 +14,7 @@ const locales = [
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Locale Card">
<div class="px-10 py-4 bg-white dark:bg-slate-900">
<div class="px-10 py-4 bg-n-background">
<div v-for="(locale, index) in locales" :key="index" class="px-20 py-2">
<LocaleCard
:locale="locale.name"
@@ -55,9 +55,7 @@ const handleAction = ({ action, value }) => {
<CardLayout>
<div class="flex justify-between gap-2">
<div class="flex items-center justify-start gap-2">
<span
class="text-sm font-medium text-slate-900 dark:text-slate-50 line-clamp-1"
>
<span class="text-sm font-medium text-n-slate-12 line-clamp-1">
{{ locale }} ({{ localeCode }})
</span>
<span
@@ -69,9 +67,7 @@ const handleAction = ({ action, value }) => {
</div>
<div class="flex items-center justify-end gap-4">
<div class="flex items-center gap-4">
<span
class="text-sm text-slate-500 dark:text-slate-400 whitespace-nowrap"
>
<span class="text-sm text-n-slate-11 whitespace-nowrap">
{{
$t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.ARTICLES_COUNT',
@@ -79,10 +75,8 @@ const handleAction = ({ action, value }) => {
)
}}
</span>
<div class="w-px h-3 bg-slate-75 dark:bg-slate-800" />
<span
class="text-sm text-slate-500 dark:text-slate-400 whitespace-nowrap"
>
<div class="w-px h-3 bg-n-weak" />
<span class="text-sm text-n-slate-11 whitespace-nowrap">
{{
$t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.CATEGORIES_COUNT',
@@ -127,7 +127,7 @@ const previewArticle = () => {
<style lang="scss" scoped>
::v-deep {
.ProseMirror .empty-node::before {
@apply text-slate-200 dark:text-slate-500 text-base;
@apply text-n-slate-10 text-base;
}
.ProseMirror-menubar-wrapper {
@@ -142,7 +142,7 @@ const previewArticle = () => {
.editor-root .has-selection {
.ProseMirror-menubar {
@apply h-8 rounded-lg !px-2 z-50 bg-slate-50 dark:bg-slate-800 items-center gap-4 ml-0 mb-0 shadow-md border border-slate-75 dark:border-slate-700/50;
@apply h-8 rounded-lg !px-2 z-50 bg-n-solid-3 items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak;
display: flex;
top: var(--selection-top, auto) !important;
left: var(--selection-left, 0) !important;
@@ -161,6 +161,10 @@ const previewArticle = () => {
}
}
}
.ProseMirror-menu-active {
@apply bg-n-slate-3;
}
}
}
}
@@ -206,7 +206,7 @@ onMounted(() => {
/>
</OnClickOutside>
</div>
<div class="w-px h-3 bg-slate-50 dark:bg-slate-800" />
<div class="w-px h-3 bg-n-weak" />
<div class="relative">
<OnClickOutside @trigger="openCategoryList = false">
<Button
@@ -239,7 +239,7 @@ onMounted(() => {
</OnClickOutside>
</div>
<div class="w-px h-3 bg-slate-50 dark:bg-slate-800" />
<div class="w-px h-3 bg-n-weak" />
<div class="relative">
<OnClickOutside @trigger="openProperties = false">
<Button
@@ -128,7 +128,7 @@ const updateArticleStatus = async ({ value }) => {
<div class="flex items-center gap-4">
<span
v-if="isUpdating || isSaved"
class="text-xs font-medium transition-all duration-300 text-slate-500 dark:text-slate-400"
class="text-xs font-medium transition-all duration-300 text-n-slate-11"
>
{{ statusText }}
</span>
@@ -52,7 +52,7 @@ onMounted(() => {
<template>
<div
class="flex flex-col absolute w-[400px] bg-n-alpha-3 outline outline-1 outline-n-container backdrop-blur-[100px] shadow-lg gap-6 rounded-xl p-6"
class="flex flex-col absolute w-[25rem] bg-n-alpha-3 outline outline-1 outline-n-container backdrop-blur-[100px] shadow-lg gap-6 rounded-xl p-6"
>
<div class="flex items-center justify-between">
<h3>
@@ -75,7 +75,7 @@ onMounted(() => {
<div>
<div class="flex justify-between w-full gap-4 py-2">
<label
class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 dark:text-slate-50"
class="text-sm font-medium whitespace-nowrap min-w-[6.25rem] text-n-slate-12"
>
{{
t(
@@ -90,9 +90,9 @@ onMounted(() => {
'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_DESCRIPTION_PLACEHOLDER'
)
"
class="w-[220px]"
class="w-[13.75rem]"
custom-text-area-wrapper-class="!p-0 !border-0 !rounded-none !bg-transparent transition-none"
custom-text-area-class="max-h-[150px]"
custom-text-area-class="max-h-[9.375rem]"
auto-height
min-height="3rem"
/>
@@ -108,12 +108,12 @@ onMounted(() => {
:label="
t('HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TITLE')
"
custom-label-class="min-w-[120px]"
custom-label-class="min-w-[7.5rem]"
/>
</div>
<div class="flex justify-between w-full gap-3 py-2">
<label
class="text-sm font-medium whitespace-nowrap min-w-[120px] text-slate-900 dark:text-slate-50"
class="text-sm font-medium whitespace-nowrap min-w-[7.5rem] text-n-slate-12"
>
{{
t('HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TAGS')
@@ -126,7 +126,7 @@ onMounted(() => {
'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TAGS_PLACEHOLDER'
)
"
class="w-[224px]"
class="w-[14rem]"
/>
</div>
</div>
@@ -64,7 +64,7 @@ const articles = [
<template>
<Story title="Pages/HelpCenter/ArticlesPage" :layout="{ type: 'single' }">
<Variant title="All Articles">
<div class="w-full min-h-screen bg-white dark:bg-slate-900">
<div class="w-full min-h-screen bg-n-background">
<ArticlesPage :articles="articles" />
</div>
</Variant>
@@ -201,7 +201,7 @@ const categories = [
<template>
<Story title="Pages/HelpCenter/CategoryPage" :layout="{ type: 'single' }">
<Variant title="All Categories">
<div class="w-full min-h-screen bg-white dark:bg-slate-900">
<div class="w-full min-h-screen bg-n-background">
<CategoriesPage :categories="categories" />
</div>
</Variant>
@@ -90,9 +90,9 @@ const handleCategory = async formData => {
<template>
<div
class="w-[400px] absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6"
class="w-[25rem] absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-n-weak shadow-md flex flex-col gap-6"
>
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
<h3 class="text-base font-medium text-n-slate-12">
{{
t(
`HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.HEADER.${mode.toUpperCase()}`
@@ -200,8 +200,9 @@ defineExpose({ state, isSubmitDisabled });
:label="state.icon"
color="slate"
size="sm"
type="button"
:icon="!state.icon ? 'i-lucide-smile-plus' : ''"
class="!h-[38px] !w-[38px] absolute top-[31px] !outline-none !rounded-[7px] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none"
class="!h-[2.4rem] !w-[2.375rem] absolute top-[1.94rem] !outline-none !rounded-[0.438rem] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none"
@click="isEmojiPickerOpen = !isEmojiPickerOpen"
/>
<EmojiInput
@@ -245,7 +246,7 @@ defineExpose({ state, isSubmitDisabled });
variant="faded"
color="slate"
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -147,10 +147,8 @@ const handleBreadcrumbClick = () => {
/>
</OnClickOutside>
</div>
<div class="w-px h-3.5 rounded my-auto bg-slate-75 dark:bg-slate-800" />
<span
class="min-w-0 text-sm font-medium truncate text-slate-800 dark:text-slate-100"
>
<div class="w-px h-3.5 rounded my-auto bg-n-weak" />
<span class="min-w-0 text-sm font-medium truncate text-n-slate-12">
{{
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.CATEGORIES_COUNT', {
n: categoriesCount,
@@ -95,7 +95,7 @@ defineExpose({ dialogRef });
:placeholder="
t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.COMBOBOX.PLACEHOLDER')
"
class="[&>div>button]:!border-n-slate-5 [&>div>button]:dark:!border-n-slate-5"
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
/>
</div>
</Dialog>
@@ -44,7 +44,7 @@ const locales = [
<template>
<Story title="Pages/HelpCenter/LocalePage" :layout="{ type: 'single' }">
<Variant title="All Locales">
<div class="w-full min-h-screen bg-white dark:bg-slate-900">
<div class="w-full min-h-screen bg-n-background">
<LocalesPage :locales="locales" />
</div>
</Variant>
@@ -35,7 +35,7 @@ const localeCount = computed(() => props.locales?.length);
<template #header-actions>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<span class="text-sm font-medium text-slate-800 dark:text-slate-100">
<span class="text-sm font-medium text-n-slate-12">
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALES_COUNT', localeCount) }}
</span>
</div>
@@ -1,6 +1,9 @@
<script setup>
import { ref, reactive, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { helpers } from '@vuelidate/validators';
import { isValidDomain } from '@chatwoot/utils';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
@@ -26,6 +29,20 @@ const formState = reactive({
customDomain: props.customDomain,
});
const rules = {
customDomain: {
isValidDomain: helpers.withMessage(
() =>
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.FORMAT_ERROR'
),
isValidDomain
),
},
};
const v$ = useVuelidate(rules, formState);
watch(
() => props.customDomain,
newVal => {
@@ -33,7 +50,10 @@ watch(
}
);
const handleDialogConfirm = () => {
const handleDialogConfirm = async () => {
const isFormCorrect = await v$.value.$validate();
if (!isFormCorrect) return;
emit('addCustomDomain', formState.customDomain);
};
@@ -67,6 +87,11 @@ defineExpose({ dialogRef });
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.PLACEHOLDER'
)
"
:message="
v$.customDomain.$error ? v$.customDomain.$errors[0].$message : ''
"
:message-type="v$.customDomain.$error ? 'error' : 'info'"
@blur="v$.customDomain.$touch()"
/>
</Dialog>
</template>
@@ -1,9 +1,15 @@
<script setup>
import { ref, computed } from 'vue';
import { reactive, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { getHostNameFromURL } from 'dashboard/helper/URLHelper';
import { email, required } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
customDomain: {
@@ -12,10 +18,20 @@ const props = defineProps({
},
});
const emit = defineEmits(['confirm']);
const emit = defineEmits(['send', 'close']);
const { t } = useI18n();
const state = reactive({
email: '',
});
const validationRules = {
email: { email, required },
};
const v$ = useVuelidate(validationRules, state);
const domain = computed(() => {
const { hostURL, helpCenterURL } = window?.chatwootConfig || {};
return getHostNameFromURL(helpCenterURL) || getHostNameFromURL(hostURL) || '';
@@ -25,10 +41,34 @@ const subdomainCNAME = computed(
() => `${props.customDomain} CNAME ${domain.value}`
);
const handleCopy = async e => {
e.stopPropagation();
await copyTextToClipboard(subdomainCNAME.value);
useAlert(
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.COPY'
)
);
};
const dialogRef = ref(null);
const handleDialogConfirm = () => {
emit('confirm');
const resetForm = () => {
v$.value.$reset();
state.email = '';
};
const onClose = () => {
resetForm();
emit('close');
};
const handleSend = async () => {
const isFormCorrect = await v$.value.$validate();
if (!isFormCorrect) return;
emit('send', state.email);
onClose();
};
defineExpose({ dialogRef });
@@ -37,42 +77,103 @@ defineExpose({ dialogRef });
<template>
<Dialog
ref="dialogRef"
:title="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HEADER'
)
"
:confirm-button-label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.CONFIRM_BUTTON_LABEL'
)
"
:show-cancel-button="false"
@confirm="handleDialogConfirm"
:show-confirm-button="false"
@close="resetForm"
>
<template #description>
<p class="mb-0 text-sm text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.DESCRIPTION'
)
}}
</p>
</template>
<NextButton
icon="i-lucide-x"
sm
ghost
slate
class="flex-shrink-0 absolute top-2 ltr:right-2 rtl:left-2"
@click="onClose"
/>
<div class="flex flex-col gap-6 divide-y divide-n-strong">
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2 ltr:pr-10 rtl:pl-10">
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HEADER'
)
}}
</h3>
<p class="mb-0 text-sm text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.DESCRIPTION'
)
}}
</p>
</div>
<div class="flex items-center gap-3 w-full">
<span
class="min-h-10 px-3 py-2.5 inline-flex items-center w-full text-sm bg-transparent border rounded-lg text-n-slate-11 border-n-strong"
>
{{ subdomainCNAME }}
</span>
<NextButton
faded
slate
type="button"
icon="i-lucide-copy"
class="flex-shrink-0"
@click="handleCopy"
/>
</div>
</div>
<div class="flex flex-col gap-6">
<span
class="h-10 px-3 py-2.5 text-sm select-none bg-transparent border rounded-lg text-n-slate-11 border-n-strong"
>
{{ subdomainCNAME }}
</span>
<p class="text-sm text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HELP_TEXT'
)
}}
</p>
<div class="flex flex-col gap-6 pt-6">
<div class="flex flex-col gap-2 ltr:pr-10 rtl:pl-10">
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.HEADER'
)
}}
</h3>
<p class="mb-0 text-sm text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.DESCRIPTION'
)
}}
</p>
</div>
<form
class="flex items-start gap-3 w-full"
@submit.prevent="handleSend"
>
<Input
v-model="state.email"
:placeholder="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.PLACEHOLDER'
)
"
:message="
v$.email.$error
? t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.ERROR'
)
: ''
"
:message-type="v$.email.$error ? 'error' : 'info'"
class="w-full"
@blur="v$.email.$touch()"
/>
<NextButton
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.SEND_BUTTON'
)
"
type="submit"
class="flex-shrink-0"
/>
</form>
</div>
</div>
</Dialog>
</template>
@@ -7,8 +7,8 @@ import { useStore, useStoreGetters } from 'dashboard/composables/store';
import { uploadFile } from 'dashboard/helper/uploadHelper';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { shouldBeUrl } from 'shared/helpers/Validators';
import { required, minLength, helpers, url } from '@vuelidate/validators';
import { isValidSlug } from 'shared/helpers/Validators';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
@@ -61,8 +61,17 @@ const liveChatWidgets = computed(() => {
const rules = {
name: { required, minLength: minLength(2) },
slug: { required },
homePageLink: { shouldBeUrl },
slug: {
required: helpers.withMessage(
() => t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.ERROR'),
required
),
isValidSlug: helpers.withMessage(
() => t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.FORMAT_ERROR'),
isValidSlug
),
},
homePageLink: { url },
};
const v$ = useVuelidate(rules, state);
@@ -71,9 +80,9 @@ const nameError = computed(() =>
v$.value.name.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.ERROR') : ''
);
const slugError = computed(() =>
v$.value.slug.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.ERROR') : ''
);
const slugError = computed(() => {
return v$.value.slug.$errors[0]?.$message || '';
});
const homePageLinkError = computed(() =>
v$.value.homePageLink.$error
@@ -202,7 +211,7 @@ const handleAvatarDelete = () => {
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap py-2.5 text-slate-900 dark:text-slate-50"
class="text-sm font-medium whitespace-nowrap py-2.5 text-n-slate-12"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.NAME.LABEL') }}
</label>
@@ -220,7 +229,7 @@ const handleAvatarDelete = () => {
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap py-2.5 text-slate-900 dark:text-slate-50"
class="text-sm font-medium whitespace-nowrap py-2.5 text-n-slate-12"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.HEADER_TEXT.LABEL') }}
</label>
@@ -236,7 +245,7 @@ const handleAvatarDelete = () => {
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap text-slate-900 py-2.5 dark:text-slate-50"
class="text-sm font-medium whitespace-nowrap text-n-slate-12 py-2.5"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.PAGE_TITLE.LABEL') }}
</label>
@@ -252,7 +261,7 @@ const handleAvatarDelete = () => {
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap text-slate-900 py-2.5 dark:text-slate-50"
class="text-sm font-medium whitespace-nowrap text-n-slate-12 py-2.5"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.HOME_PAGE_LINK.LABEL') }}
</label>
@@ -272,7 +281,7 @@ const handleAvatarDelete = () => {
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap py-2.5 text-slate-900 dark:text-slate-50"
class="text-sm font-medium whitespace-nowrap py-2.5 text-n-slate-12"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.SLUG.LABEL') }}
</label>
@@ -290,7 +299,7 @@ const handleAvatarDelete = () => {
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap py-2.5 text-slate-900 dark:text-slate-50"
class="text-sm font-medium whitespace-nowrap py-2.5 text-n-slate-12"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.LABEL') }}
</label>
@@ -303,12 +312,14 @@ const handleAvatarDelete = () => {
:message="
t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.HELP_TEXT')
"
class="[&>div>button]:!outline-n-weak"
class="[&>div>button:not(.focused)]:!outline-n-weak"
/>
</div>
<div class="flex items-start justify-between w-full gap-2">
<div
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap py-2.5 text-slate-900 dark:text-slate-50"
class="text-sm font-medium whitespace-nowrap py-2.5 text-n-slate-12"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.BRAND_COLOR.LABEL') }}
</label>
@@ -1,6 +1,7 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import AddCustomDomainDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/AddCustomDomainDialog.vue';
import DNSConfigurationDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/DNSConfigurationDialog.vue';
@@ -11,11 +12,52 @@ const props = defineProps({
type: Object,
required: true,
},
isFetchingStatus: {
type: Boolean,
required: true,
},
});
const emit = defineEmits(['updatePortalConfiguration']);
const emit = defineEmits([
'updatePortalConfiguration',
'refreshStatus',
'sendCnameInstructions',
]);
const SSL_STATUS = {
LIVE: ['active', 'staging_active'],
PENDING: [
'provisioned',
'pending',
'initializing',
'pending_validation',
'pending_deployment',
'pending_issuance',
'holding_deployment',
'holding_validation',
'pending_expiration',
'pending_cleanup',
'pending_deletion',
'staging_deployment',
'backup_issued',
],
ERROR: [
'blocked',
'inactive',
'moved',
'expired',
'deleted',
'timed_out_initializing',
'timed_out_validation',
'timed_out_issuance',
'timed_out_deployment',
'timed_out_deletion',
'deactivating',
],
};
const { t } = useI18n();
const { isOnChatwootCloud } = useAccount();
const addCustomDomainDialogRef = ref(null);
const dnsConfigurationDialogRef = ref(null);
@@ -25,6 +67,45 @@ const customDomainAddress = computed(
() => props.activePortal?.custom_domain || ''
);
const sslSettings = computed(() => props.activePortal?.ssl_settings || {});
const verificationErrors = computed(
() => sslSettings.value.verification_errors || ''
);
const isLive = computed(() =>
SSL_STATUS.LIVE.includes(sslSettings.value.status)
);
const isPending = computed(() =>
SSL_STATUS.PENDING.includes(sslSettings.value.status)
);
const isError = computed(() =>
SSL_STATUS.ERROR.includes(sslSettings.value.status)
);
const statusText = computed(() => {
if (isLive.value)
return t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.STATUS.LIVE'
);
if (isPending.value)
return t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.STATUS.PENDING'
);
if (isError.value)
return t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.STATUS.ERROR'
);
return '';
});
const statusColors = computed(() => {
if (isLive.value)
return { text: 'text-n-teal-11', bubble: 'outline-n-teal-6 bg-n-teal-9' };
if (isError.value)
return { text: 'text-n-ruby-11', bubble: 'outline-n-ruby-6 bg-n-ruby-9' };
return { text: 'text-n-amber-11', bubble: 'outline-n-amber-6 bg-n-amber-9' };
});
const updatePortalConfiguration = customDomain => {
const portal = {
id: props.activePortal?.id,
@@ -42,6 +123,17 @@ const closeDNSConfigurationDialog = () => {
updatedDomainAddress.value = '';
dnsConfigurationDialogRef.value.dialogRef.close();
};
const onClickRefreshSSLStatus = () => {
emit('refreshStatus');
};
const onClickSend = email => {
emit('sendCnameInstructions', {
portalSlug: props.activePortal?.slug,
email,
});
};
</script>
<template>
@@ -63,33 +155,76 @@ const closeDNSConfigurationDialog = () => {
</span>
</div>
<div class="flex flex-col w-full gap-4">
<div class="flex justify-between w-full gap-2">
<div
v-if="customDomainAddress"
class="flex items-center w-full h-8 gap-4"
>
<label class="text-sm font-medium text-n-slate-12">
<div class="flex items-center justify-between w-full gap-2">
<div v-if="customDomainAddress" class="flex flex-col gap-1">
<div class="flex items-center w-full h-8 gap-4">
<label class="text-sm font-medium text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.LABEL'
)
}}
</label>
<span class="text-sm text-n-slate-12">
{{ customDomainAddress }}
</span>
</div>
<span
v-if="!isLive && isOnChatwootCloud"
class="text-sm text-n-slate-11"
>
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.LABEL'
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.STATUS_DESCRIPTION'
)
}}
</label>
<span class="text-sm text-n-slate-12">
{{ customDomainAddress }}
</span>
</div>
<div class="flex items-center justify-end w-full">
<Button
v-if="customDomainAddress"
color="slate"
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.EDIT_BUTTON'
)
"
@click="addCustomDomainDialogRef.dialogRef.open()"
/>
<div class="flex items-center">
<div v-if="customDomainAddress" class="flex items-center gap-3">
<div
v-if="statusText && isOnChatwootCloud"
v-tooltip="verificationErrors"
class="flex items-center gap-3 flex-shrink-0"
>
<span
class="size-1.5 rounded-full outline outline-2 block flex-shrink-0"
:class="statusColors.bubble"
/>
<span
:class="statusColors.text"
class="text-sm leading-[16px] font-medium"
>
{{ statusText }}
</span>
</div>
<div
v-if="statusText && isOnChatwootCloud"
class="w-px h-3 bg-n-weak"
/>
<Button
slate
sm
link
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.EDIT_BUTTON'
)
"
class="hover:!no-underline flex-shrink-0"
@click="addCustomDomainDialogRef.dialogRef.open()"
/>
<div v-if="isOnChatwootCloud" class="w-px h-3 bg-n-weak" />
<Button
v-if="isOnChatwootCloud"
slate
sm
link
icon="i-lucide-refresh-ccw"
:class="isFetchingStatus && 'animate-spin'"
@click="onClickRefreshSSLStatus"
/>
</div>
<Button
v-else
:label="
@@ -112,7 +247,8 @@ const closeDNSConfigurationDialog = () => {
<DNSConfigurationDialog
ref="dnsConfigurationDialogRef"
:custom-domain="updatedDomainAddress || customDomainAddress"
@confirm="closeDNSConfigurationDialog"
@close="closeDNSConfigurationDialog"
@send="onClickSend"
/>
</div>
</template>
@@ -5,7 +5,7 @@ import PortalSettings from './PortalSettings.vue';
<template>
<Story title="Pages/HelpCenter/PortalSettings" :layout="{ type: 'single' }">
<Variant title="Default">
<div class="w-[1000px] min-h-screen bg-white dark:bg-slate-900">
<div class="w-[1000px] min-h-screen bg-n-background">
<PortalSettings />
</div>
</Variant>
@@ -26,6 +26,8 @@ const emit = defineEmits([
'updatePortal',
'updatePortalConfiguration',
'deletePortal',
'refreshStatus',
'sendCnameInstructions',
]);
const { t } = useI18n();
@@ -36,6 +38,7 @@ const confirmDeletePortalDialogRef = ref(null);
const currentPortalSlug = computed(() => route.params.portalSlug);
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
const isFetchingSSLStatus = useMapGetter('portals/isFetchingSSLStatus');
const activePortal = computed(() => {
return props.portals?.find(portal => portal.slug === currentPortalSlug.value);
@@ -53,6 +56,14 @@ const handleUpdatePortalConfiguration = portal => {
emit('updatePortalConfiguration', portal);
};
const fetchSSLStatus = () => {
emit('refreshStatus');
};
const handleSendCnameInstructions = payload => {
emit('sendCnameInstructions', payload);
};
const openConfirmDeletePortalDialog = () => {
confirmDeletePortalDialogRef.value.dialogRef.open();
};
@@ -74,20 +85,23 @@ const handleDeletePortal = () => {
</div>
<div
v-else-if="activePortal"
class="flex flex-col w-full gap-4 max-w-[640px] pb-8"
class="flex flex-col w-full gap-4 max-w-[40rem] pb-8"
>
<PortalBaseSettings
:active-portal="activePortal"
:is-fetching="isFetching"
@update-portal="handleUpdatePortal"
/>
<div class="w-full h-px bg-slate-50 dark:bg-slate-800/50" />
<div class="w-full h-px bg-n-weak" />
<PortalConfigurationSettings
:active-portal="activePortal"
:is-fetching="isFetching"
:is-fetching-status="isFetchingSSLStatus"
@update-portal-configuration="handleUpdatePortalConfiguration"
@refresh-status="fetchSSLStatus"
@send-cname-instructions="handleSendCnameInstructions"
/>
<div class="w-full h-px bg-slate-50 dark:bg-slate-800/50" />
<div class="w-full h-px bg-n-weak" />
<div class="flex items-end justify-between w-full gap-4">
<div class="flex flex-col gap-2">
<h6 class="text-base font-medium text-n-slate-12">
@@ -6,8 +6,9 @@ import { useAlert, useTrack } from 'dashboard/composables';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { convertToCategorySlug } from 'dashboard/helper/commons.js';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { required, minLength, helpers } from '@vuelidate/validators';
import { buildPortalURL } from 'dashboard/helper/portalHelper';
import { isValidSlug } from 'shared/helpers/Validators';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
@@ -31,7 +32,16 @@ const state = reactive({
const rules = {
name: { required, minLength: minLength(2) },
slug: { required },
slug: {
required: helpers.withMessage(
() => t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.ERROR'),
required
),
isValidSlug: helpers.withMessage(
() => t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.FORMAT_ERROR'),
isValidSlug
),
},
};
const v$ = useVuelidate(rules, state);
@@ -40,9 +50,9 @@ const nameError = computed(() =>
v$.value.name.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.ERROR') : ''
);
const slugError = computed(() =>
v$.value.slug.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.ERROR') : ''
);
const slugError = computed(() => {
return v$.value.slug.$errors[0]?.$message || '';
});
const isSubmitDisabled = computed(() => v$.value.$invalid);
@@ -131,6 +141,7 @@ defineExpose({ dialogRef });
:message="
nameError || t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.MESSAGE')
"
@blur="v$.name.$touch()"
/>
<Input
id="portal-slug"
@@ -140,6 +151,8 @@ defineExpose({ dialogRef });
:label="t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.LABEL')"
:message-type="slugError ? 'error' : 'info'"
:message="slugError || buildPortalURL(state.slug)"
@input="v$.slug.$touch()"
@blur="v$.slug.$touch()"
/>
</div>
</Dialog>
@@ -32,7 +32,7 @@ const portals = [
:layout="{ type: 'grid', width: '510px' }"
>
<Variant title="Portal Switcher">
<div class="h-[500px] p-4 bg-slate-100 dark:bg-slate-900">
<div class="h-[500px] p-4 bg-n-slate-2 dark:bg-n-background">
<PortalSwitcher
:portals="portals"
header="Choose a Portal"
@@ -92,7 +92,7 @@ const redirectToPortalHomePage = () => {
<template>
<div
class="pt-5 pb-3 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[440px] rounded-xl shadow-md flex flex-col gap-4"
class="pt-5 pb-3 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[27.5rem] rounded-xl shadow-md flex flex-col gap-4"
>
<div
class="flex items-center justify-between gap-4 px-6 pb-3 border-b border-n-alpha-2"
@@ -100,7 +100,7 @@ const redirectToPortalHomePage = () => {
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<h2
class="text-base font-medium cursor-pointer text-slate-900 dark:text-slate-50 w-fit hover:underline"
class="text-base font-medium cursor-pointer text-n-slate-12 w-fit hover:underline"
@click="redirectToPortalHomePage"
>
{{ t('HELP_CENTER.PORTAL_SWITCHER.PORTALS') }}
@@ -115,7 +115,7 @@ const redirectToPortalHomePage = () => {
@click="onClickPreviewPortal"
/>
</div>
<p class="text-sm text-slate-600 dark:text-slate-300">
<p class="text-sm text-n-slate-11">
{{ t('HELP_CENTER.PORTAL_SWITCHER.CREATE_PORTAL') }}
</p>
</div>