Restructure omni services and add Chatwoot research snapshot

This commit is contained in:
Ruslan Bakiev
2026-02-21 11:11:27 +07:00
parent edea7a0034
commit b73babbbf6
7732 changed files with 978203 additions and 32 deletions

View File

@@ -0,0 +1,197 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { OnClickOutside } from '@vueuse/components';
import { useUISettings } from 'dashboard/composables/useUISettings';
import {
ARTICLE_TABS,
CATEGORY_ALL,
ARTICLE_TABS_OPTIONS,
} from 'dashboard/helper/portalHelper';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
categories: {
type: Array,
required: true,
},
allowedLocales: {
type: Array,
required: true,
},
meta: {
type: Object,
required: true,
},
});
const emit = defineEmits([
'tabChange',
'localeChange',
'categoryChange',
'newArticle',
]);
const route = useRoute();
const { t } = useI18n();
const { updateUISettings } = useUISettings();
const isCategoryMenuOpen = ref(false);
const isLocaleMenuOpen = ref(false);
const countKey = tab => {
if (tab.value === 'all') {
return 'articlesCount';
}
return `${tab.value}ArticlesCount`;
};
const tabs = computed(() => {
return ARTICLE_TABS_OPTIONS.map(tab => ({
label: t(`HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.TABS.${tab.key}`),
value: tab.value,
count: props.meta[countKey(tab)],
}));
});
const activeTabIndex = computed(() => {
const tabParam = route.params.tab || ARTICLE_TABS.ALL;
return tabs.value.findIndex(tab => tab.value === tabParam);
});
const activeCategoryName = computed(() => {
const activeCategory = props.categories.find(
category => category.slug === route.params.categorySlug
);
if (activeCategory) {
const { icon, name } = activeCategory;
return `${icon} ${name}`;
}
return t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.CATEGORY.ALL');
});
const activeLocaleName = computed(() => {
return props.allowedLocales.find(
locale => locale.code === route.params.locale
)?.name;
});
const categoryMenuItems = computed(() => {
const defaultMenuItem = {
label: t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.CATEGORY.ALL'),
value: CATEGORY_ALL,
action: 'filter',
};
const categoryItems = props.categories.map(category => ({
label: category.name,
value: category.slug,
action: 'filter',
emoji: category.icon,
}));
const hasCategorySlug = !!route.params.categorySlug;
return hasCategorySlug ? [defaultMenuItem, ...categoryItems] : categoryItems;
});
const hasCategoryMenuItems = computed(() => {
return categoryMenuItems.value?.length > 0;
});
const localeMenuItems = computed(() => {
return props.allowedLocales.map(locale => ({
label: locale.name,
value: locale.code,
action: 'filter',
}));
});
const handleLocaleAction = ({ value }) => {
emit('localeChange', value);
isLocaleMenuOpen.value = false;
updateUISettings({
last_active_locale_code: value,
});
};
const handleCategoryAction = ({ value }) => {
emit('categoryChange', value);
isCategoryMenuOpen.value = false;
};
const handleNewArticle = () => {
emit('newArticle');
};
const handleTabChange = value => {
emit('tabChange', value);
};
</script>
<template>
<div class="flex flex-col items-start w-full gap-2 lg:flex-row">
<TabBar
:tabs="tabs"
:initial-active-tab="activeTabIndex"
@tab-changed="handleTabChange"
/>
<div class="flex items-start justify-between w-full gap-2">
<div class="flex items-center gap-2">
<div class="relative group">
<OnClickOutside @trigger="isLocaleMenuOpen = false">
<Button
:label="activeLocaleName"
size="sm"
icon="i-lucide-chevron-down"
color="slate"
trailing-icon
@click="isLocaleMenuOpen = !isLocaleMenuOpen"
/>
<DropdownMenu
v-if="isLocaleMenuOpen"
:menu-items="localeMenuItems"
show-search
class="left-0 w-40 max-w-[300px] mt-2 overflow-y-auto xl:right-0 top-full max-h-60"
@action="handleLocaleAction"
/>
</OnClickOutside>
</div>
<div v-if="hasCategoryMenuItems" class="relative group">
<OnClickOutside @trigger="isCategoryMenuOpen = false">
<Button
:label="activeCategoryName"
icon="i-lucide-chevron-down"
size="sm"
color="slate"
trailing-icon
class="max-w-48"
@click="isCategoryMenuOpen = !isCategoryMenuOpen"
/>
<DropdownMenu
v-if="isCategoryMenuOpen"
:menu-items="categoryMenuItems"
show-search
class="left-0 w-48 mt-2 overflow-y-auto xl:right-0 top-full max-h-60"
@action="handleCategoryAction"
/>
</OnClickOutside>
</div>
</div>
<Button
:label="t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.NEW_ARTICLE')"
icon="i-lucide-plus"
size="sm"
@click="handleNewArticle"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,199 @@
<script setup>
import { ref, computed, watch } from 'vue';
import Draggable from 'vuedraggable';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert, useTrack } from 'dashboard/composables';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { getArticleStatus } from 'dashboard/helper/portalHelper.js';
import wootConstants from 'dashboard/constants/globals';
import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue';
const props = defineProps({
articles: {
type: Array,
required: true,
},
isCategoryArticles: {
type: Boolean,
default: false,
},
});
const { ARTICLE_STATUS_TYPES } = wootConstants;
const router = useRouter();
const route = useRoute();
const store = useStore();
const { t } = useI18n();
const localArticles = ref(props.articles);
const dragEnabled = computed(() => {
// Enable dragging only for category articles and when there's more than one article
return props.isCategoryArticles && localArticles.value?.length > 1;
});
const getCategoryById = useMapGetter('categories/categoryById');
const openArticle = id => {
const { tab, categorySlug, locale } = route.params;
if (props.isCategoryArticles) {
router.push({
name: 'portals_categories_articles_edit',
params: { articleSlug: id },
});
} else {
router.push({
name: 'portals_articles_edit',
params: {
articleSlug: id,
tab,
categorySlug,
locale,
},
});
}
};
const onReorder = reorderedGroup => {
store.dispatch('articles/reorder', {
reorderedGroup,
portalSlug: route.params.portalSlug,
});
};
const onDragEnd = () => {
// Reuse existing positions to maintain order within the current group
const sortedArticlePositions = localArticles.value
.map(article => article.position)
.sort((a, b) => a - b); // Use custom sort to handle numeric values correctly
const orderedArticles = localArticles.value.map(article => article.id);
// Create a map of article IDs to their new positions
const reorderedGroup = orderedArticles.reduce((obj, key, index) => {
obj[key] = sortedArticlePositions[index];
return obj;
}, {});
onReorder(reorderedGroup);
};
const getCategory = categoryId => {
return getCategoryById.value(categoryId) || { name: '', icon: '' };
};
const getStatusMessage = (status, isSuccess) => {
const messageType = isSuccess ? 'SUCCESS' : 'ERROR';
const statusMap = {
[ARTICLE_STATUS_TYPES.PUBLISH]: 'PUBLISH_ARTICLE',
[ARTICLE_STATUS_TYPES.ARCHIVE]: 'ARCHIVE_ARTICLE',
[ARTICLE_STATUS_TYPES.DRAFT]: 'DRAFT_ARTICLE',
};
return statusMap[status]
? t(`HELP_CENTER.${statusMap[status]}.API.${messageType}`)
: '';
};
const updatePortalMeta = () => {
const { portalSlug, locale } = route.params;
return store.dispatch('portals/show', { portalSlug, locale });
};
const updateArticlesMeta = () => {
const { portalSlug, locale } = route.params;
return store.dispatch('articles/updateArticleMeta', {
portalSlug,
locale,
});
};
const handleArticleAction = async (action, { status, id }) => {
const { portalSlug } = route.params;
try {
if (action === 'delete') {
await store.dispatch('articles/delete', {
portalSlug,
articleId: id,
});
useAlert(t('HELP_CENTER.DELETE_ARTICLE.API.SUCCESS_MESSAGE'));
} else {
await store.dispatch('articles/update', {
portalSlug,
articleId: id,
status,
});
useAlert(getStatusMessage(status, true));
if (status === ARTICLE_STATUS_TYPES.ARCHIVE) {
useTrack(PORTALS_EVENTS.ARCHIVE_ARTICLE, { uiFrom: 'header' });
} else if (status === ARTICLE_STATUS_TYPES.PUBLISH) {
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
}
}
await updateArticlesMeta();
await updatePortalMeta();
} catch (error) {
const errorMessage =
error?.message ||
(action === 'delete'
? t('HELP_CENTER.DELETE_ARTICLE.API.ERROR_MESSAGE')
: getStatusMessage(status, false));
useAlert(errorMessage);
}
};
const updateArticle = ({ action, value, id }) => {
const status = action !== 'delete' ? getArticleStatus(value) : null;
handleArticleAction(action, { status, id });
};
// Watch for changes in the articles prop and update the localArticles ref
watch(
() => props.articles,
newArticles => {
localArticles.value = newArticles;
},
{ deep: true }
);
</script>
<template>
<Draggable
v-model="localArticles"
:disabled="!dragEnabled"
item-key="id"
tag="ul"
ghost-class="article-ghost-class"
class="w-full h-full space-y-4"
@end="onDragEnd"
>
<template #item="{ element }">
<li class="list-none rounded-2xl">
<ArticleCard
:id="element.id"
:key="element.id"
:title="element.title"
:status="element.status"
:author="element.author"
:category="getCategory(element.category.id)"
:views="element.views || 0"
:updated-at="element.updatedAt"
:class="{ 'cursor-grab': dragEnabled }"
@open-article="openArticle"
@article-action="updateArticle"
/>
</li>
</template>
</Draggable>
</template>
<style lang="scss" scoped>
.article-ghost-class {
@apply opacity-50 bg-n-solid-1;
}
</style>

View File

@@ -0,0 +1,72 @@
<script setup>
import ArticlesPage from './ArticlesPage.vue';
const articles = [
{
title: "How to get an SSL certificate for your Help Center's custom domain",
status: 'draft',
updatedAt: '2 days ago',
author: 'Michael',
category: '⚡️ Marketing',
views: 3400,
},
{
title: 'Setting up your first Help Center portal',
status: '',
updatedAt: '1 week ago',
author: 'John',
category: '🛠️ Development',
views: 400,
},
{
title: 'Best practices for organizing your Help Center content',
status: 'archived',
updatedAt: '3 days ago',
author: 'Fernando',
category: '💰 Finance',
views: 400,
},
{
title: 'Customizing the appearance of your Help Center',
status: '',
updatedAt: '5 days ago',
author: 'Jane',
category: '💰 Finance',
views: 400,
},
{
title: 'Best practices for organizing your Help Center content',
status: 'archived',
updatedAt: '3 days ago',
author: 'Fernando',
category: '💰 Finance',
views: 400,
},
{
title: 'Customizing the appearance of your Help Center',
status: '',
updatedAt: '5 days ago',
author: 'Jane',
category: '💰 Finance',
views: 400,
},
{
title: 'Best practices for organizing your Help Center content',
status: 'archived',
updatedAt: '3 days ago',
author: 'Fernando',
category: '💰 Finance',
views: 400,
},
];
</script>
<template>
<Story title="Pages/HelpCenter/ArticlesPage" :layout="{ type: 'single' }">
<Variant title="All Articles">
<div class="w-full min-h-screen bg-n-background">
<ArticlesPage :articles="articles" />
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,187 @@
<script setup>
import { computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import ArticleList from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue';
import ArticleHeaderControls from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleHeaderControls.vue';
import CategoryHeaderControls from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryHeaderControls.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ArticleEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/Article/ArticleEmptyState.vue';
const props = defineProps({
articles: {
type: Array,
required: true,
},
categories: {
type: Array,
required: true,
},
allowedLocales: {
type: Array,
required: true,
},
portalName: {
type: String,
required: true,
},
meta: {
type: Object,
required: true,
},
isCategoryArticles: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['pageChange', 'fetchPortal']);
const router = useRouter();
const route = useRoute();
const { t } = useI18n();
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
const isFetching = useMapGetter('articles/isFetching');
const hasNoArticles = computed(
() => !isFetching.value && !props.articles.length
);
const isLoading = computed(() => isFetching.value || isSwitchingPortal.value);
const totalArticlesCount = computed(() => props.meta.allArticlesCount);
const hasNoArticlesInPortal = computed(
() => totalArticlesCount.value === 0 && !props.isCategoryArticles
);
const shouldShowPaginationFooter = computed(() => {
return !(isFetching.value || isSwitchingPortal.value || hasNoArticles.value);
});
const updateRoute = newParams => {
const { portalSlug, locale, tab, categorySlug } = route.params;
router.push({
name: 'portals_articles_index',
params: {
portalSlug,
locale: newParams.locale ?? locale,
tab: newParams.tab ?? tab,
categorySlug: newParams.categorySlug ?? categorySlug,
...newParams,
},
});
};
const articlesCount = computed(() => {
const { tab } = route.params;
const { meta } = props;
const countMap = {
'': meta.articlesCount,
mine: meta.mineArticlesCount,
draft: meta.draftArticlesCount,
archived: meta.archivedArticlesCount,
};
return Number(countMap[tab] || countMap['']);
});
const showArticleHeaderControls = computed(
() => !props.isCategoryArticles && !isSwitchingPortal.value
);
const showCategoryHeaderControls = computed(
() => props.isCategoryArticles && !isSwitchingPortal.value
);
const getEmptyStateText = type => {
if (props.isCategoryArticles) {
return t(`HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.CATEGORY.${type}`);
}
const tabName = route.params.tab?.toUpperCase() || 'ALL';
return t(`HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.${tabName}.${type}`);
};
const getEmptyStateTitle = computed(() => getEmptyStateText('TITLE'));
const getEmptyStateSubtitle = computed(() => getEmptyStateText('SUBTITLE'));
const handleTabChange = tab =>
updateRoute({ tab: tab.value === ARTICLE_TABS.ALL ? '' : tab.value });
const handleCategoryAction = value =>
updateRoute({ categorySlug: value === CATEGORY_ALL ? '' : value });
const handleLocaleAction = value => {
updateRoute({ locale: value, categorySlug: '' });
emit('fetchPortal', value);
};
const handlePageChange = page => emit('pageChange', page);
const navigateToNewArticlePage = () => {
const { categorySlug, locale } = route.params;
router.push({
name: 'portals_articles_new',
params: { categorySlug, locale },
});
};
</script>
<template>
<HelpCenterLayout
:current-page="Number(meta.currentPage)"
:total-items="articlesCount"
:items-per-page="25"
:header="portalName"
:show-pagination-footer="shouldShowPaginationFooter"
@update:current-page="handlePageChange"
>
<template #header-actions>
<div class="flex items-end justify-between">
<ArticleHeaderControls
v-if="showArticleHeaderControls"
:categories="categories"
:allowed-locales="allowedLocales"
:meta="meta"
@tab-change="handleTabChange"
@locale-change="handleLocaleAction"
@category-change="handleCategoryAction"
@new-article="navigateToNewArticlePage"
/>
<CategoryHeaderControls
v-else-if="showCategoryHeaderControls"
:categories="categories"
:allowed-locales="allowedLocales"
:has-selected-category="isCategoryArticles"
/>
</div>
</template>
<template #content>
<div
v-if="isLoading"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<ArticleList
v-else-if="!hasNoArticles"
:articles="articles"
:is-category-articles="isCategoryArticles"
/>
<ArticleEmptyState
v-else
class="pt-14"
:title="getEmptyStateTitle"
:subtitle="getEmptyStateSubtitle"
:show-button="hasNoArticlesInPortal"
:button-label="
t('HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.ALL.BUTTON_LABEL')
"
@click="navigateToNewArticlePage"
/>
</template>
</HelpCenterLayout>
</template>