Update header, add a sidebar to the design

This commit is contained in:
Pranav
2026-05-19 11:52:51 -07:00
parent a727c14a2c
commit 2ed7394adc
10 changed files with 219 additions and 162 deletions
@@ -5,6 +5,7 @@ import { useRoute } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store.js';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import PortalAvatar from 'dashboard/components-next/HelpCenter/PortalAvatar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import PortalSwitcher from 'dashboard/components-next/HelpCenter/PortalSwitcher/PortalSwitcher.vue';
import CreatePortalDialog from 'dashboard/components-next/HelpCenter/PortalSwitcher/CreatePortalDialog.vue';
@@ -30,6 +31,10 @@ defineProps({
type: Boolean,
default: true,
},
breadcrumbLabel: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:currentPage']);
@@ -44,10 +49,15 @@ const portals = useMapGetter('portals/allPortals');
const currentPortalSlug = computed(() => route.params.portalSlug);
const activePortalName = computed(() => {
return portals.value?.find(portal => portal.slug === currentPortalSlug.value)
?.name;
});
const activePortal = computed(() =>
portals.value?.find(portal => portal.slug === currentPortalSlug.value)
);
const activePortalName = computed(() => activePortal.value?.name);
const activePortalLogo = computed(
() => activePortal.value?.logo?.file_url || ''
);
const updateCurrentPage = page => {
emit('update:currentPage', page);
@@ -65,32 +75,45 @@ const togglePortalSwitcher = () => {
v-if="showHeaderTitle"
class="flex items-center justify-start h-20 gap-2"
>
<span
v-if="activePortalName"
class="text-xl font-medium text-n-slate-12"
>
{{ activePortalName }}
</span>
<div v-if="activePortalName" class="relative group">
<OnClickOutside @trigger="showPortalSwitcher = false">
<Button
icon="i-lucide-chevron-down"
variant="ghost"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-slate-3 hover:bg-n-slate-3"
@click="togglePortalSwitcher"
<nav v-if="activePortalName" class="flex items-center gap-2">
<div class="flex items-center gap-1.5">
<PortalAvatar
:src="activePortalLogo"
:name="activePortalName"
:size="24"
/>
<span class="text-lg font-medium text-n-slate-12">
{{ activePortalName }}
</span>
<div class="relative group">
<OnClickOutside @trigger="showPortalSwitcher = false">
<Button
icon="i-lucide-chevron-down"
:variant="showPortalSwitcher ? 'faded' : 'ghost'"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-slate-3 hover:bg-n-slate-3 [&>span]:size-4"
@click="togglePortalSwitcher"
/>
<PortalSwitcher
v-if="showPortalSwitcher"
class="absolute ltr:left-0 rtl:right-0 top-9"
@close="showPortalSwitcher = false"
@create-portal="createPortalDialogRef.dialogRef.open()"
/>
</OnClickOutside>
<CreatePortalDialog ref="createPortalDialogRef" />
</div>
<PortalSwitcher
v-if="showPortalSwitcher"
class="absolute ltr:left-0 rtl:right-0 top-9"
@close="showPortalSwitcher = false"
@create-portal="createPortalDialogRef.dialogRef.open()"
/>
</OnClickOutside>
<CreatePortalDialog ref="createPortalDialogRef" />
</div>
</div>
<template v-if="breadcrumbLabel">
<div class="w-0.5 h-4 rounded-2xl bg-n-weak shrink-0" />
<span class="pl-1.5 text-lg font-medium text-n-slate-11">
{{ breadcrumbLabel }}
</span>
</template>
</nav>
</div>
<slot name="header-actions" />
</div>
@@ -256,6 +256,7 @@ watch(
:total-items="articlesCount"
:items-per-page="25"
:header="portalName"
:breadcrumb-label="$t('HELP_CENTER.BREADCRUMB.ARTICLES')"
:show-pagination-footer="shouldShowPaginationFooter"
@update:current-page="handlePageChange"
>
@@ -112,7 +112,10 @@ const reorderCategories = async reorderedGroup => {
</script>
<template>
<HelpCenterLayout :show-pagination-footer="false">
<HelpCenterLayout
:show-pagination-footer="false"
:breadcrumb-label="$t('HELP_CENTER.BREADCRUMB.CATEGORIES')"
>
<template #header-actions>
<CategoryHeaderControls
:categories="categories"
@@ -31,7 +31,10 @@ const localeCount = computed(() => props.locales?.length);
</script>
<template>
<HelpCenterLayout :show-pagination-footer="false">
<HelpCenterLayout
:show-pagination-footer="false"
:breadcrumb-label="$t('HELP_CENTER.BREADCRUMB.LOCALES')"
>
<template #header-actions>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
@@ -12,8 +12,7 @@ import { isValidSlug } from 'shared/helpers/Validators';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import PortalAvatar from 'dashboard/components-next/HelpCenter/PortalAvatar.vue';
import ColorPicker from 'dashboard/components-next/colorpicker/ColorPicker.vue';
const props = defineProps({
@@ -42,31 +41,12 @@ const state = reactive({
slug: '',
widgetColor: '',
homePageLink: '',
liveChatWidgetInboxId: '',
logoUrl: '',
avatarBlobId: '',
});
const originalState = reactive({ ...state });
const liveChatWidgets = computed(() => {
const inboxes = store.getters['inboxes/getInboxes'];
const widgetOptions = inboxes
.filter(inbox => inbox.channel_type === 'Channel::WebWidget')
.map(inbox => ({
value: inbox.id,
label: inbox.name,
}));
return [
{
value: '',
label: t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.NONE_OPTION'),
},
...widgetOptions,
];
});
const rules = {
name: { required, minLength: minLength(2) },
slug: {
@@ -116,7 +96,6 @@ watch(
widgetColor: newVal.color,
homePageLink: newVal.homepage_link,
slug: newVal.slug,
liveChatWidgetInboxId: newVal.inbox?.id || '',
});
if (newVal.logo) {
const {
@@ -148,7 +127,6 @@ const handleUpdatePortal = () => {
header_text: state.headerText,
homepage_link: state.homePageLink,
blob_id: state.avatarBlobId,
inbox_id: state.liveChatWidgetInboxId,
};
emit('updatePortal', portal);
};
@@ -204,12 +182,11 @@ const handleAvatarDelete = () => {
<label class="mb-0.5 text-sm font-medium text-gray-900 dark:text-gray-50">
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.LABEL') }}
</label>
<Avatar
<PortalAvatar
:src="state.logoUrl"
:name="state.name"
:size="72"
allow-upload
icon-name="i-lucide-building-2"
@upload="handleAvatarUpload"
@delete="handleAvatarDelete"
/>
@@ -303,26 +280,6 @@ const handleAvatarDelete = () => {
@blur="v$.slug.$touch()"
/>
</div>
<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-n-slate-12"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.LABEL') }}
</label>
<ComboBox
v-model="state.liveChatWidgetInboxId"
:options="liveChatWidgets"
:placeholder="
t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.PLACEHOLDER')
"
:message="
t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.HELP_TEXT')
"
class="[&>div>button:not(.focused)]:!outline-n-weak"
/>
</div>
<div
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
@@ -1,5 +1,5 @@
<script setup>
import { computed, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
@@ -62,6 +62,7 @@ const { isOnChatwootCloud } = useAccount();
const addCustomDomainDialogRef = ref(null);
const dnsConfigurationDialogRef = ref(null);
const updatedDomainAddress = ref('');
const pendingDomain = ref('');
const customDomainAddress = computed(
() => props.activePortal?.custom_domain || ''
@@ -111,14 +112,23 @@ const updatePortalConfiguration = customDomain => {
id: props.activePortal?.id,
custom_domain: customDomain,
};
pendingDomain.value = customDomain;
emit('updatePortalConfiguration', portal);
addCustomDomainDialogRef.value.dialogRef.close();
if (customDomain) {
updatedDomainAddress.value = customDomain;
dnsConfigurationDialogRef.value.dialogRef.open();
}
};
// The save above is fire-and-forget, so the DNS instructions dialog must not
// open until the domain is actually persisted — otherwise a failed save still
// advances to the next step. We open it once the stored value reflects the
// submitted domain.
watch(customDomainAddress, savedDomain => {
if (!pendingDomain.value || savedDomain !== pendingDomain.value) return;
pendingDomain.value = '';
updatedDomainAddress.value = savedDomain;
dnsConfigurationDialogRef.value.dialogRef.open();
});
const closeDNSConfigurationDialog = () => {
updatedDomainAddress.value = '';
dnsConfigurationDialogRef.value.dialogRef.close();
@@ -5,12 +5,12 @@ import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import PortalBaseSettings from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue';
import PortalGeneralSettings from './PortalGeneralSettings.vue';
import PortalConfigurationSettings from './PortalConfigurationSettings.vue';
import PortalLayoutContentSettings from './PortalLayoutContentSettings.vue';
import ConfirmDeletePortalDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/ConfirmDeletePortalDialog.vue';
import PortalIntegrationsSettings from './PortalIntegrationsSettings.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
portals: {
@@ -34,7 +34,30 @@ const emit = defineEmits([
const { t } = useI18n();
const route = useRoute();
const confirmDeletePortalDialogRef = ref(null);
const SETTINGS_TABS = [
{
id: 'general',
labelKey: 'HELP_CENTER.PORTAL_SETTINGS.NAV.GENERAL',
icon: 'i-lucide-settings-2',
},
{
id: 'domain',
labelKey: 'HELP_CENTER.PORTAL_SETTINGS.NAV.DOMAIN',
icon: 'i-lucide-globe',
},
{
id: 'appearance',
labelKey: 'HELP_CENTER.PORTAL_SETTINGS.NAV.APPEARANCE',
icon: 'i-lucide-palette',
},
{
id: 'integrations',
labelKey: 'HELP_CENTER.PORTAL_SETTINGS.NAV.INTEGRATIONS',
icon: 'i-lucide-blocks',
},
];
const activeTab = ref('general');
const currentPortalSlug = computed(() => route.params.portalSlug);
@@ -45,8 +68,6 @@ const activePortal = computed(() => {
return props.portals?.find(portal => portal.slug === currentPortalSlug.value);
});
const activePortalName = computed(() => activePortal.value?.name || '');
const isLoading = computed(() => props.isFetching || isSwitchingPortal.value);
const handleUpdatePortal = portal => {
@@ -65,18 +86,16 @@ const handleSendCnameInstructions = payload => {
emit('sendCnameInstructions', payload);
};
const openConfirmDeletePortalDialog = () => {
confirmDeletePortalDialogRef.value.dialogRef.open();
};
const handleDeletePortal = () => {
emit('deletePortal', activePortal.value);
confirmDeletePortalDialogRef.value.dialogRef.close();
const handleDeletePortal = portal => {
emit('deletePortal', portal);
};
</script>
<template>
<HelpCenterLayout :show-pagination-footer="false">
<HelpCenterLayout
:show-pagination-footer="false"
:breadcrumb-label="t('HELP_CENTER.BREADCRUMB.SETTINGS')"
>
<template #content>
<div
v-if="isLoading"
@@ -84,68 +103,60 @@ const handleDeletePortal = () => {
>
<Spinner />
</div>
<div
v-else-if="activePortal"
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-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-n-weak" />
<PortalLayoutContentSettings
:active-portal="activePortal"
:is-fetching="isFetching"
@update-portal-configuration="handleUpdatePortalConfiguration"
/>
<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">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.HEADER'
)
}}
</h6>
<span class="text-sm text-n-slate-11">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.DESCRIPTION'
)
}}
</span>
</div>
<Button
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.BUTTON',
{
portalName: activePortalName,
}
)
<div v-else-if="activePortal" class="flex items-start w-full gap-8">
<nav class="sticky top-0 flex flex-col w-48 gap-0.5 shrink-0 py-1">
<button
v-for="tab in SETTINGS_TABS"
:key="tab.id"
type="button"
class="flex items-center w-full h-9 gap-2 px-2.5 text-sm transition-colors rounded-lg"
:class="
activeTab === tab.id
? 'bg-n-alpha-2 text-n-slate-12 font-medium'
: 'text-n-slate-11 hover:bg-n-alpha-1 hover:text-n-slate-12'
"
color="ruby"
class="max-w-56 !w-fit"
@click="openConfirmDeletePortalDialog"
:aria-current="activeTab === tab.id ? 'page' : undefined"
@click="activeTab = tab.id"
>
<Icon :icon="tab.icon" class="shrink-0 size-4" />
{{ t(tab.labelKey) }}
</button>
</nav>
<div class="flex flex-col flex-1 min-w-0 max-w-[40rem] pb-8">
<PortalGeneralSettings
v-if="activeTab === 'general'"
:active-portal="activePortal"
:is-fetching="isFetching"
@update-portal="handleUpdatePortal"
@delete-portal="handleDeletePortal"
/>
<PortalConfigurationSettings
v-else-if="activeTab === 'domain'"
:active-portal="activePortal"
:is-fetching="isFetching"
:is-fetching-status="isFetchingSSLStatus"
@update-portal-configuration="handleUpdatePortalConfiguration"
@refresh-status="fetchSSLStatus"
@send-cname-instructions="handleSendCnameInstructions"
/>
<PortalLayoutContentSettings
v-else-if="activeTab === 'appearance'"
:active-portal="activePortal"
:is-fetching="isFetching"
@update-portal-configuration="handleUpdatePortalConfiguration"
/>
<PortalIntegrationsSettings
v-else-if="activeTab === 'integrations'"
:active-portal="activePortal"
:is-fetching="isFetching"
@update-portal-configuration="handleUpdatePortalConfiguration"
/>
</div>
</div>
</template>
<ConfirmDeletePortalDialog
ref="confirmDeletePortalDialogRef"
:active-portal-name="activePortalName"
@delete-portal="handleDeletePortal"
/>
</HelpCenterLayout>
</template>
@@ -0,0 +1,27 @@
<script setup>
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
// Renders a portal's logo consistently across the help center — the layout
// header, the portal switcher, and portal settings — so the icon fallback
// and shape stay in one place instead of being copied per call site.
defineProps({
src: { type: String, default: '' },
name: { type: String, default: '' },
size: { type: Number, default: 24 },
allowUpload: { type: Boolean, default: false },
});
const emit = defineEmits(['upload', 'delete']);
</script>
<template>
<Avatar
:src="src"
:name="name"
:size="size"
:allow-upload="allowUpload"
icon-name="i-lucide-building-2"
@upload="emit('upload', $event)"
@delete="emit('delete')"
/>
</template>
@@ -6,7 +6,7 @@ import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { buildPortalURL } from 'dashboard/helper/portalHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import PortalAvatar from 'dashboard/components-next/HelpCenter/PortalAvatar.vue';
const emit = defineEmits(['close', 'createPortal']);
@@ -151,13 +151,11 @@ const redirectToPortalHomePage = () => {
<span class="text-sm font-medium truncate text-n-slate-12">
{{ portal.name || '' }}
</span>
<Avatar
<PortalAvatar
v-if="portal"
:name="portal.name"
:src="getPortalThumbnailSrc(portal)"
:size="20"
icon-name="i-lucide-building-2"
rounded-full
/>
</Button>
</div>
@@ -404,6 +404,12 @@
"PLACEHOLDER": "Search for articles"
}
},
"BREADCRUMB": {
"ARTICLES": "Articles",
"CATEGORIES": "Categories",
"LOCALES": "Locales",
"SETTINGS": "Settings"
},
"CATEGORY": {
"ADD": {
"TITLE": "Create a category",
@@ -806,12 +812,6 @@
"LABEL": "Slug",
"PLACEHOLDER": "Portal slug"
},
"LIVE_CHAT_WIDGET": {
"LABEL": "Live chat widget",
"PLACEHOLDER": "Select live chat widget",
"HELP_TEXT": "Select a live chat widget that will appear on your help center",
"NONE_OPTION": "No widget"
},
"BRAND_COLOR": {
"LABEL": "Brand color"
},
@@ -888,6 +888,30 @@
},
"SAVE": "Save changes"
},
"INTEGRATIONS": {
"HEADER": "Integrations",
"DESCRIPTION": "Connect tools that extend what your help center can do.",
"LIVE_CHAT": {
"TITLE": "Live chat with Chatwoot",
"DESCRIPTION": "Show a live chat widget on your help center so visitors can reach you while they read.",
"PLACEHOLDER": "Select live chat widget",
"NONE_OPTION": "No widget"
},
"GTM": {
"TITLE": "Google Tag Manager",
"DESCRIPTION": "Add analytics and marketing tags, like Google Analytics, by connecting your Tag Manager container.",
"PLACEHOLDER": "GTM-XXXXXXX",
"HELP": "Find this in your Google Tag Manager workspace. Leave empty to disable.",
"INVALID": "Enter a valid container ID, for example GTM-XXXXXXX."
},
"SAVE": "Save changes"
},
"NAV": {
"GENERAL": "General",
"DOMAIN": "Domain",
"APPEARANCE": "Appearance",
"INTEGRATIONS": "Integrations"
},
"API": {
"CREATE_PORTAL": {
"SUCCESS_MESSAGE": "Portal created successfully",