feat(v4): Update the help center portal design (#10296)

Co-authored-by: Pranav <pranavrajs@gmail.com>
This commit is contained in:
Sivin Varghese
2024-10-24 10:39:36 +05:30
committed by GitHub
parent 6d3ecfe3c1
commit a3855a8d1d
144 changed files with 6376 additions and 6604 deletions

View File

@@ -6,13 +6,15 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
def index def index
@portal_articles = @portal.articles @portal_articles = @portal.articles
@all_articles = @portal_articles.search(list_params)
@articles_count = @all_articles.count set_article_count
@articles = @articles.search(list_params)
@articles = if list_params[:category_slug].present? @articles = if list_params[:category_slug].present?
@all_articles.order_by_position.page(@current_page) @articles.order_by_position.page(@current_page)
else else
@all_articles.order_by_updated_at.page(@current_page) @articles.order_by_updated_at.page(@current_page)
end end
end end
@@ -43,6 +45,19 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
private private
def set_article_count
# Search the params without status and author_id, use this to
# compute mine count published draft etc
base_search_params = list_params.except(:status, :author_id)
@articles = @portal_articles.search(base_search_params)
@articles_count = @articles.count
@mine_articles_count = @articles.search_by_author(Current.user.id).count
@published_articles_count = @articles.published.count
@draft_articles_count = @articles.draft.count
@archived_articles_count = @articles.archived.count
end
def fetch_article def fetch_article
@article = @portal.articles.find(params[:id]) @article = @portal.articles.find(params[:id])
end end
@@ -53,9 +68,10 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
def article_params def article_params
params.require(:article).permit( params.require(:article).permit(
:title, :slug, :position, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status, meta: [:title, :title, :slug, :position, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status,
:description, :locale, meta: [:title,
{ tags: [] }] :description,
{ tags: [] }]
) )
end end

View File

@@ -20,7 +20,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
end end
def create def create
@portal = Current.account.portals.build(portal_params) @portal = Current.account.portals.build(portal_params.merge(live_chat_widget_params))
@portal.custom_domain = parsed_custom_domain @portal.custom_domain = parsed_custom_domain
@portal.save! @portal.save!
process_attached_logo process_attached_logo
@@ -28,7 +28,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def update def update
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
@portal.update!(portal_params) if params[:portal].present? @portal.update!(portal_params.merge(live_chat_widget_params)) if params[:portal].present?
# @portal.custom_domain = parsed_custom_domain # @portal.custom_domain = parsed_custom_domain
process_attached_logo if params[:blob_id].present? process_attached_logo if params[:blob_id].present?
rescue StandardError => e rescue StandardError => e
@@ -70,11 +70,21 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def portal_params def portal_params
params.require(:portal).permit( params.require(:portal).permit(
:account_id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug, :archived, { config: [:default_locale, :account_id, :color, :custom_domain, :header_text, :homepage_link,
{ allowed_locales: [] }] } :name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
) )
end end
def live_chat_widget_params
permitted_params = params.permit(:inbox_id)
return {} if permitted_params[:inbox_id].blank?
inbox = Inbox.find(permitted_params[:inbox_id])
return {} unless inbox.web_widget?
{ channel_web_widget_id: inbox.channel.id }
end
def portal_member_params def portal_member_params
params.require(:portal).permit(:account_id, member_ids: []) params.require(:portal).permit(:account_id, member_ids: [])
end end

View File

@@ -30,7 +30,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def set_article def set_article
@article = @portal.articles.find_by(slug: permitted_params[:article_slug]) @article = @portal.articles.find_by(slug: permitted_params[:article_slug])
@article.increment_view_count @article.increment_view_count if @article.published?
@parsed_content = render_article_content(@article.content) @parsed_content = render_article_content(@article.content)
end end

View File

@@ -52,12 +52,13 @@ class ArticlesAPI extends PortalsAPI {
} }
createArticle({ portalSlug, articleObj }) { createArticle({ portalSlug, articleObj }) {
const { content, title, author_id, category_id } = articleObj; const { content, title, authorId, categoryId, locale } = articleObj;
return axios.post(`${this.url}/${portalSlug}/articles`, { return axios.post(`${this.url}/${portalSlug}/articles`, {
content, content,
title, title,
author_id, author_id: authorId,
category_id, category_id: categoryId,
locale,
}); });
} }

View File

@@ -7,7 +7,7 @@ const handleClick = () => {
<template> <template>
<div <div
class="relative flex flex-col w-full gap-3 px-6 py-5 group/cardLayout rounded-2xl bg-slate-25 dark:bg-slate-800/50" class="relative flex flex-col w-full gap-3 px-6 py-5 shadow-sm group/cardLayout rounded-2xl bg-n-solid-1"
@click="handleClick" @click="handleClick"
> >
<slot name="header" /> <slot name="header" />

View File

@@ -13,7 +13,7 @@ defineProps({
<template> <template>
<section <section
class="relative flex flex-col items-center justify-center w-full h-full min-h-screen p-4 overflow-hidden" class="relative flex flex-col items-center justify-center w-full h-full overflow-hidden"
> >
<div <div
class="relative w-full max-w-[940px] mx-auto overflow-hidden h-full max-h-[448px]" class="relative w-full max-w-[940px] mx-auto overflow-hidden h-full max-h-[448px]"
@@ -24,17 +24,17 @@ defineProps({
<slot name="empty-state-item" /> <slot name="empty-state-item" />
</div> </div>
<div <div
class="absolute inset-x-0 bottom-0 flex flex-col items-center justify-end w-full h-full pb-9 bg-gradient-to-t from-white dark:from-slate-900 to-transparent font-interDisplay" class="absolute inset-x-0 bottom-0 flex flex-col items-center justify-end w-full h-full pb-20 bg-gradient-to-t from-n-background from-25% dark:from-n-background to-transparent"
> >
<div class="flex flex-col items-center justify-center gap-6"> <div class="flex flex-col items-center justify-center gap-6">
<div class="flex flex-col items-center justify-center gap-2"> <div class="flex flex-col items-center justify-center gap-3">
<h2 <h2
class="text-3xl font-medium text-center text-slate-900 dark:text-white" class="text-3xl font-medium text-center text-slate-900 dark:text-white font-interDisplay"
> >
{{ title }} {{ title }}
</h2> </h2>
<p <p
class="max-w-lg text-base text-center text-slate-600 dark:text-slate-300" class="max-w-lg text-base text-center text-slate-600 dark:text-slate-300 font-interDisplay tracking-[0.3px]"
> >
{{ subtitle }} {{ subtitle }}
</p> </p>

View File

@@ -3,30 +3,60 @@ import ArticleCard from './ArticleCard.vue';
const articles = [ const articles = [
{ {
id: 1,
title: "How to get an SSL certificate for your Help Center's custom domain", title: "How to get an SSL certificate for your Help Center's custom domain",
status: 'draft', status: 'draft',
updatedAt: '2 days ago', updatedAt: 1729048936,
author: 'Michael', author: {
category: '⚡️ Marketing', name: 'John',
thumbnail: 'https://i.pravatar.cc/300',
},
category: {
title: 'Marketing',
slug: 'marketing',
icon: '📈',
},
views: 400, views: 400,
}, },
{ {
id: 2,
title: 'Setting up your first Help Center portal', title: 'Setting up your first Help Center portal',
status: '', status: '',
updatedAt: '1 week ago', updatedAt: 1729048936,
author: 'John', author: {
category: '🛠️ Development', name: 'John',
thumbnail: 'https://i.pravatar.cc/300',
},
category: {
title: 'Development',
slug: 'development',
icon: '🛠️',
},
views: 1400, views: 1400,
}, },
{ {
id: 3,
title: 'Best practices for organizing your Help Center content', title: 'Best practices for organizing your Help Center content',
status: 'archived', status: 'archived',
updatedAt: '3 days ago', updatedAt: 1729048936,
author: 'Fernando', author: {
category: '💰 Finance', name: 'Fernando',
thumbnail: 'https://i.pravatar.cc/300',
},
category: {
title: 'Finance',
slug: 'finance',
icon: '💰',
},
views: 4300, views: 4300,
}, },
]; ];
const category = {
name: 'Marketing',
slug: 'marketing',
icon: '📈',
};
</script> </script>
<!-- eslint-disable vue/no-bare-strings-in-template --> <!-- eslint-disable vue/no-bare-strings-in-template -->
@@ -43,10 +73,11 @@ const articles = [
class="px-20 py-4 bg-white dark:bg-slate-900" class="px-20 py-4 bg-white dark:bg-slate-900"
> >
<ArticleCard <ArticleCard
:id="article.id"
:title="article.title" :title="article.title"
:status="article.status" :status="article.status"
:author="article.author" :author="article.author"
:category="article.category" :category="category"
:views="article.views" :views="article.views"
:updated-at="article.updatedAt" :updated-at="article.updatedAt"
/> />

View File

@@ -1,13 +1,25 @@
<script setup> <script setup>
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { OnClickOutside } from '@vueuse/components'; import { OnClickOutside } from '@vueuse/components';
import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import {
ARTICLE_MENU_ITEMS,
ARTICLE_MENU_OPTIONS,
ARTICLE_STATUSES,
} from 'dashboard/helper/portalHelper';
import CardLayout from 'dashboard/components-next/CardLayout.vue'; import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue'; import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
const props = defineProps({ const props = defineProps({
id: {
type: Number,
required: true,
},
title: { title: {
type: String, type: String,
required: true, required: true,
@@ -17,11 +29,11 @@ const props = defineProps({
required: true, required: true,
}, },
author: { author: {
type: String, type: Object,
required: true, required: true,
}, },
category: { category: {
type: String, type: Object,
required: true, required: true,
}, },
views: { views: {
@@ -29,84 +41,112 @@ const props = defineProps({
required: true, required: true,
}, },
updatedAt: { updatedAt: {
type: String, type: Number,
required: true, required: true,
}, },
}); });
const emit = defineEmits(['openArticle', 'articleAction']);
const { t } = useI18n();
const isOpen = ref(false); const isOpen = ref(false);
const menuItems = computed(() => { const articleMenuItems = computed(() => {
const baseItems = [{ label: 'Delete', action: 'delete', icon: 'delete' }]; const commonItems = Object.entries(ARTICLE_MENU_ITEMS).reduce(
const menuOptions = { (acc, [key, item]) => {
archived: [ acc[key] = { ...item, label: t(item.label) };
{ label: 'Publish', action: 'publish', icon: 'checkmark' }, return acc;
{ label: 'Draft', action: 'draft', icon: 'draft' }, },
], {}
draft: [ );
{ label: 'Publish', action: 'publish', icon: 'checkmark' },
{ label: 'Archive', action: 'archive', icon: 'archive' }, const statusItems = (
], ARTICLE_MENU_OPTIONS[props.status] ||
'': [ ARTICLE_MENU_OPTIONS[ARTICLE_STATUSES.PUBLISHED]
// Empty string represents published status ).map(key => commonItems[key]);
{ label: 'Draft', action: 'draft', icon: 'draft' },
{ label: 'Archive', action: 'archive', icon: 'archive' }, return [...statusItems, commonItems.delete];
],
};
return [...(menuOptions[props.status] || menuOptions['']), ...baseItems];
}); });
const statusTextColor = computed(() => { const statusTextColor = computed(() => {
switch (props.status) { switch (props.status) {
case 'archived': case 'archived':
return '!text-slate-600 dark:!text-slate-200'; return '!text-n-slate-12';
case 'draft': case 'draft':
return '!text-amber-700 dark:!text-amber-400'; return '!text-n-amber-11';
default: default:
return '!text-teal-700 dark:!text-teal-400'; return '!text-n-teal-11';
} }
}); });
const statusText = computed(() => { const statusText = computed(() => {
switch (props.status) { switch (props.status) {
case 'archived': case 'archived':
return 'Archived'; return t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.STATUS.ARCHIVED');
case 'draft': case 'draft':
return 'Draft'; return t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.STATUS.DRAFT');
default: default:
return 'Published'; return t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.STATUS.PUBLISHED');
} }
}); });
const handleAction = () => { const categoryName = computed(() => {
if (props.category?.slug) {
return `${props.category.icon} ${props.category.name}`;
}
return t(
'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.CATEGORY.UNCATEGORISED'
);
});
const authorName = computed(() => {
return props.author?.name || props.author?.availableName || '-';
});
const authorThumbnailSrc = computed(() => {
return props.author?.thumbnail;
});
const lastUpdatedAt = computed(() => {
return dynamicTime(props.updatedAt);
});
const handleArticleAction = ({ action, value }) => {
isOpen.value = false; isOpen.value = false;
emit('articleAction', { action, value, id: props.id });
};
const handleClick = id => {
emit('openArticle', id);
}; };
</script> </script>
<!-- TODO: Add i18n -->
<!-- eslint-disable vue/no-bare-strings-in-template -->
<template> <template>
<CardLayout> <CardLayout>
<template #header> <template #header>
<div class="flex justify-between gap-1"> <div class="flex justify-between gap-1">
<span class="text-base text-slate-900 dark:text-slate-50 line-clamp-1"> <span
class="text-base cursor-pointer hover:underline text-n-slate-12 line-clamp-1"
@click="handleClick(id)"
>
{{ title }} {{ title }}
</span> </span>
<div class="relative group"> <div class="relative group" @click.stop>
<Button
variant="ghost"
size="sm"
class="text-xs bg-slate-50 !font-normal group-hover:bg-slate-100/50 dark:group-hover:bg-slate-700/50 !h-6 dark:bg-slate-800 rounded-md border-0 !px-2 !py-0.5"
:label="statusText"
:class="statusTextColor"
@click="isOpen = !isOpen"
/>
<OnClickOutside @trigger="isOpen = false"> <OnClickOutside @trigger="isOpen = false">
<Button
variant="ghost"
size="sm"
class="text-xs font-medium bg-n-alpha-2 hover:bg-n-alpha-1 !h-6 rounded-md border-0 !px-2 !py-0.5"
:label="statusText"
:class="statusTextColor"
@click="isOpen = !isOpen"
/>
<DropdownMenu <DropdownMenu
v-if="isOpen" v-if="isOpen"
:menu-items="menuItems" :menu-items="articleMenuItems"
class="right-0 mt-2 xl:left-0 top-full" class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 top-full"
@action="handleAction" @action="handleArticleAction($event)"
/> />
</OnClickOutside> </OnClickOutside>
</div> </div>
@@ -116,25 +156,34 @@ const handleAction = () => {
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<div class="w-4 h-4 rounded-full bg-slate-100 dark:bg-slate-700" /> <Thumbnail
<span class="text-sm text-slate-500 dark:text-slate-400"> v-if="author"
{{ author }} :author="author"
:name="authorName"
:src="authorThumbnailSrc"
/>
<span class="text-sm text-n-slate-11">
{{ authorName }}
</span> </span>
</div> </div>
<span <span class="block text-sm whitespace-nowrap text-n-slate-11">
class="block text-sm whitespace-nowrap text-slate-500 dark:text-slate-400" {{ categoryName }}
>
{{ category }}
</span> </span>
<div <div
class="inline-flex items-center gap-1 text-slate-500 dark:text-slate-400 whitespace-nowrap" class="inline-flex items-center gap-1 text-n-slate-11 whitespace-nowrap"
> >
<FluentIcon icon="eye-show" size="18" /> <FluentIcon icon="eye-show" size="18" />
<span class="text-sm"> {{ views }} views </span> <span class="text-sm">
{{
t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.VIEWS', {
count: views,
})
}}
</span>
</div> </div>
</div> </div>
<span class="text-sm text-slate-600 dark:text-slate-400 line-clamp-1"> <span class="text-sm text-n-slate-11 line-clamp-1">
{{ updatedAt }} {{ lastUpdatedAt }}
</span> </span>
</div> </div>
</template> </template>

View File

@@ -2,17 +2,21 @@
import CategoryCard from './CategoryCard.vue'; import CategoryCard from './CategoryCard.vue';
const categories = [ const categories = [
{ {
id: 'getting-started', id: 1,
title: '🚀 Getting started', title: 'Getting started',
description: description:
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.', 'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.',
articlesCount: '5', articlesCount: 5,
slug: 'getting-started',
icon: '🚀',
}, },
{ {
id: 'marketing', id: 2,
title: '📈 Marketing', title: 'Marketing',
description: '', description: '',
articlesCount: '4', articlesCount: 4,
slug: 'marketing',
icon: '📈',
}, },
]; ];
</script> </script>
@@ -31,9 +35,12 @@ const categories = [
class="px-20 py-4 bg-white dark:bg-slate-900" class="px-20 py-4 bg-white dark:bg-slate-900"
> >
<CategoryCard <CategoryCard
:id="category.id"
:slug="category.slug"
:title="category.title" :title="category.title"
:description="category.description" :description="category.description"
:articles-count="category.articlesCount" :articles-count="category.articlesCount"
:icon="category.icon"
/> />
</div> </div>
</Variant> </Variant>

View File

@@ -1,6 +1,7 @@
<script setup> <script setup>
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { OnClickOutside } from '@vueuse/components'; import { OnClickOutside } from '@vueuse/components';
import { useI18n } from 'vue-i18n';
import CardLayout from 'dashboard/components-next/CardLayout.vue'; import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
@@ -8,14 +9,14 @@ import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.v
const props = defineProps({ const props = defineProps({
id: { id: {
type: String, type: Number,
required: true, required: true,
}, },
title: { title: {
type: String, type: String,
required: true, required: true,
}, },
articlesCount: { icon: {
type: String, type: String,
required: true, required: true,
}, },
@@ -23,25 +24,41 @@ const props = defineProps({
type: String, type: String,
required: true, required: true,
}, },
articlesCount: {
type: Number,
required: true,
},
slug: {
type: String,
required: true,
},
}); });
const emit = defineEmits(['click']); const emit = defineEmits(['click', 'action']);
const { t } = useI18n();
const isOpen = ref(false); const isOpen = ref(false);
const menuItems = [ const categoryMenuItems = [
{ {
label: 'Edit', label: 'Edit',
action: 'edit', action: 'edit',
value: 'edit',
icon: 'edit', icon: 'edit',
}, },
{ {
label: 'Delete', label: 'Delete',
action: 'delete', action: 'delete',
value: 'delete',
icon: 'delete', icon: 'delete',
}, },
]; ];
const categoryTitleWithIcon = computed(() => {
return `${props.icon} ${props.title}`;
});
const description = computed(() => { const description = computed(() => {
return props.description ? props.description : 'No description added'; return props.description ? props.description : 'No description added';
}); });
@@ -50,48 +67,51 @@ const hasDescription = computed(() => {
return props.description.length > 0; return props.description.length > 0;
}); });
const handleClick = id => { const handleClick = slug => {
emit('click', id); emit('click', slug);
}; };
// eslint-disable-next-line no-unused-vars const handleAction = ({ action, value }) => {
const handleAction = action => { emit('action', { action, value, id: props.id });
// TODO: Implement action isOpen.value = false;
}; };
</script> </script>
<!-- TODO: Add i18n -->
<!-- eslint-disable vue/no-bare-strings-in-template -->
<template> <template>
<CardLayout @click="handleClick(id)"> <CardLayout>
<template #header> <template #header>
<div class="flex gap-2"> <div class="flex gap-2">
<div class="flex justify-between w-full"> <div class="flex justify-between w-full gap-1">
<div class="flex items-center justify-start gap-2"> <div class="flex items-center justify-start gap-2">
<span <span
class="text-base cursor-pointer group-hover/cardLayout:underline text-slate-900 dark:text-slate-50 line-clamp-1" class="text-base cursor-pointer hover:underline text-slate-900 dark:text-slate-50 line-clamp-1"
@click="handleClick(slug)"
> >
{{ title }} {{ categoryTitleWithIcon }}
</span> </span>
<span <span
class="inline-flex items-center justify-center h-6 px-2 py-1 text-xs text-center border rounded-lg text-slate-500 w-fit border-slate-200 dark:border-slate-800 dark:text-slate-400" class="inline-flex items-center justify-center h-6 px-2 py-1 text-xs text-center truncate border rounded-lg min-w-fit text-slate-500 w-fit border-slate-200 dark:border-slate-800 dark:text-slate-400"
> >
{{ articlesCount }} articles {{
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_CARD.ARTICLES_COUNT', {
count: articlesCount,
})
}}
</span> </span>
</div> </div>
<div class="relative group" @click.stop> <div class="relative group" @click.stop>
<Button
variant="ghost"
size="icon"
icon="more-vertical"
class="w-8 z-60 group-hover:bg-slate-100 dark:group-hover:bg-slate-800"
@click="isOpen = !isOpen"
/>
<OnClickOutside @trigger="isOpen = false"> <OnClickOutside @trigger="isOpen = false">
<Button
variant="ghost"
size="sm"
icon="more-vertical"
class="w-8 z-60 group-hover:bg-slate-100 dark:group-hover:bg-slate-800"
@click="isOpen = !isOpen"
/>
<DropdownMenu <DropdownMenu
v-if="isOpen" v-if="isOpen"
:menu-items="menuItems" :menu-items="categoryMenuItems"
class="right-0 mt-1 xl:left-0 top-full z-60" class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 top-full z-60"
@action="handleAction" @action="handleAction"
/> />
</OnClickOutside> </OnClickOutside>

View File

@@ -1,66 +1,42 @@
<script setup> <script setup>
// import { ref } from 'vue';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue'; import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue'; import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue';
// import AddLocaleDialog from 'dashboard/playground/HelpCenter/components/AddLocaleDialog.vue'; import articleContent from 'dashboard/components-next/HelpCenter/EmptyState/Portal/portalEmptyStateContent.js';
const articles = [ defineProps({
{ title: {
title: "How to get an SSL certificate for your Help Center's custom domain", type: String,
status: 'draft', default: '',
updatedAt: '2 days ago',
author: 'Michael',
category: '⚡️ Marketing',
views: 3400,
}, },
{ subtitle: {
title: 'Setting up your first Help Center portal', type: String,
status: '', default: '',
updatedAt: '1 week ago',
author: 'John',
category: '🛠️ Development',
views: 400,
}, },
{ showButton: {
title: 'Best practices for organizing your Help Center content', type: Boolean,
status: 'archived', default: true,
updatedAt: '3 days ago',
author: 'Fernando',
category: '💰 Finance',
views: 400,
}, },
{ buttonLabel: {
title: 'Customizing the appearance of your Help Center', type: String,
status: '', default: '',
updatedAt: '5 days ago',
author: 'Jane',
category: '💰 Finance',
views: 400,
}, },
]; });
// const addLocaleDialogRef = ref(null); const emit = defineEmits(['click']);
// const openDialog = () => {
// addLocaleDialogRef.value.dialogRef.open(); const onClick = () => {
// }; emit('click');
// const handleDialogConfirm = () => { };
// // Add logic to create a new portal
// };
</script> </script>
<!-- TODO: Add i18n -->
<!-- eslint-disable vue/no-bare-strings-in-template -->
<template> <template>
<EmptyStateLayout <EmptyStateLayout :title="title" :subtitle="subtitle">
title="Write an article"
subtitle="Write a rich article, let's get started!"
>
<template #empty-state-item> <template #empty-state-item>
<div class="grid grid-cols-1 gap-4"> <div class="grid grid-cols-1 gap-4 overflow-hidden">
<ArticleCard <ArticleCard
v-for="(article, index) in articles" v-for="(article, index) in articleContent.slice(0, 5)"
:id="article.id"
:key="`article-${index}`" :key="`article-${index}`"
:title="article.title" :title="article.title"
:status="article.status" :status="article.status"
@@ -72,16 +48,14 @@ const articles = [
</div> </div>
</template> </template>
<template #actions> <template #actions>
<Button <div v-if="showButton">
variant="default" <Button
label="New article" variant="default"
icon="add" :label="buttonLabel"
@click="openDialog" icon="add"
/> @click="onClick"
<!-- <AddLocaleDialog />
ref="addLocaleDialogRef" </div>
@confirm="handleDialogConfirm"
/> -->
</template> </template>
</EmptyStateLayout> </EmptyStateLayout>
</template> </template>

View File

@@ -0,0 +1,49 @@
<script setup>
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import CategoryCard from 'dashboard/components-next/HelpCenter/CategoryCard/CategoryCard.vue';
import categoryContent from 'dashboard/components-next/HelpCenter/EmptyState/Category/categoryEmptyStateContent.js';
defineProps({
title: {
type: String,
default: '',
},
subtitle: {
type: String,
default: '',
},
});
</script>
<template>
<EmptyStateLayout :title="title" :subtitle="subtitle">
<template #empty-state-item>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-4">
<CategoryCard
v-for="category in categoryContent"
:id="category.id"
:key="category.id"
:title="category.name"
:icon="category.icon"
:description="category.description"
:articles-count="category.meta.articles_count || 0"
:slug="category.slug"
/>
</div>
<div class="space-y-4">
<CategoryCard
v-for="category in categoryContent.reverse()"
:id="category.id"
:key="category.id"
:title="category.name"
:icon="category.icon"
:description="category.description"
:articles-count="category.meta.articles_count || 0"
:slug="category.slug"
/>
</div>
</div>
</template>
</EmptyStateLayout>
</template>

View File

@@ -0,0 +1,142 @@
export default [
{
id: 1,
name: 'Getting Started',
icon: '🚀',
description: 'Quick guides to help new users onboard.',
slug: 'getting-started',
meta: {
articles_count: 5,
},
},
{
id: 2,
name: 'Advanced Features',
icon: '💡',
description: 'Explore advanced features for power users.',
slug: 'advanced-features',
meta: {
articles_count: 8,
},
},
{
id: 3,
name: 'FAQs',
icon: '❓',
description: 'Commonly asked questions and helpful answers.',
slug: 'faqs',
meta: {
articles_count: 3,
},
},
{
id: 4,
name: 'Troubleshooting',
icon: '🛠️',
description: 'Resolve common issues with step-by-step guidance.',
slug: 'troubleshooting',
meta: {
articles_count: 6,
},
},
{
id: 5,
name: 'Community Guidelines',
icon: '👥',
description: 'Rules and practices for community engagement.',
slug: 'community-guidelines',
meta: {
articles_count: 2,
},
},
{
id: 6,
name: 'Account Management',
icon: '🔑',
description: 'Manage your account and settings efficiently.',
slug: 'account-management',
meta: {
articles_count: 7,
},
},
{
id: 7,
name: 'Security Tips',
icon: '🔒',
description: 'Best practices for securing your account.',
slug: 'security-tips',
meta: {
articles_count: 4,
},
},
{
id: 8,
name: 'Integrations',
icon: '🔗',
description: 'Connect to third-party services and tools easily.',
slug: 'integrations',
meta: {
articles_count: 9,
},
},
{
id: 9,
name: 'Billing & Payments',
icon: '💳',
description: 'Manage your billing and payment details seamlessly.',
slug: 'billing-payments',
meta: {
articles_count: 5,
},
},
{
id: 10,
name: 'Customization',
icon: '🎨',
description: 'Personalize and customize your user experience.',
slug: 'customization',
meta: {
articles_count: 7,
},
},
{
id: 11,
name: 'Notifications',
icon: '🔔',
description: 'Adjust your notification settings and preferences.',
slug: 'notifications',
meta: {
articles_count: 3,
},
},
{
id: 12,
name: 'Privacy',
icon: '🛡️',
description: 'Understand how your data is collected and used.',
slug: 'privacy',
meta: {
articles_count: 2,
},
},
{
id: 13,
name: 'Mobile App',
icon: '📱',
description: 'Guides for using the mobile app effectively.',
slug: 'mobile-app',
meta: {
articles_count: 6,
},
},
{
id: 14,
name: 'Beta Features',
icon: '🧪',
description: 'Learn about new experimental features in beta.',
slug: 'beta-features',
meta: {
articles_count: 4,
},
},
];

View File

@@ -1,86 +1,38 @@
<script setup> <script setup>
// import { ref } from 'vue'; import { ref } from 'vue';
import { useRouter } from 'vue-router';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue'; import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue'; import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue';
import CategoryCard from 'dashboard/components-next/HelpCenter/CategoryCard/CategoryCard.vue'; import articleContent from './portalEmptyStateContent';
import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue'; import CreatePortalDialog from 'dashboard/components-next/HelpCenter/PortalSwitcher/CreatePortalDialog.vue';
// import CreatePortalDialog from 'dashboard/playground/HelpCenter/components/CreatePortalDialog.vue';
const articles = [ const createPortalDialogRef = ref(null);
{ const openDialog = () => {
title: "How to get an SSL certificate for your Help Center's custom domain", createPortalDialogRef.value.dialogRef.open();
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,
},
];
const categories = [
{
title: 'Getting Started',
description: 'Essential guides for new users',
articlesCount: '5',
},
{
title: 'Advanced Features',
description: 'In-depth tutorials for power users',
articlesCount: '8',
},
];
const locales = [ const router = useRouter();
{ name: 'English', isDefault: true },
{ name: 'Spanish', isDefault: false },
{ name: 'Malayalam', isDefault: false },
];
// const createPortalDialogRef = ref(null); const onPortalCreate = ({ slug: portalSlug, locale }) => {
// const openDialog = () => { router.push({
// createPortalDialogRef.value.dialogRef.open(); name: 'portals_articles_index',
// }; params: { portalSlug, locale },
// const handleDialogConfirm = () => { });
// // Add logic to create a new portal };
// };
</script> </script>
<!-- TODO: Add i18n -->
<!-- eslint-disable vue/no-bare-strings-in-template -->
<template> <template>
<EmptyStateLayout <EmptyStateLayout
title="Help Center" :title="$t('HELP_CENTER.TITLE')"
subtitle="Create self-service portals to access articles and information. Streamline queries, enhance agent efficiency, and elevate customer support." :subtitle="$t('HELP_CENTER.NEW_PAGE.DESCRIPTION')"
> >
<template #empty-state-item> <template #empty-state-item>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div class="space-y-4"> <div class="space-y-4">
<ArticleCard <ArticleCard
v-for="(article, index) in articles" v-for="(article, index) in articleContent"
:id="article.id"
:key="`article-${index}`" :key="`article-${index}`"
:title="article.title" :title="article.title"
:status="article.status" :status="article.status"
@@ -91,18 +43,16 @@ const locales = [
/> />
</div> </div>
<div class="space-y-4"> <div class="space-y-4">
<CategoryCard <ArticleCard
v-for="(category, index) in categories" v-for="(article, index) in articleContent.reverse()"
:key="`category-${index}`" :id="article.id"
:title="category.title" :key="`article-${index}`"
:description="category.description" :title="article.title"
:articles-count="category.articlesCount" :status="article.status"
/> :updated-at="article.updatedAt"
<LocaleCard :author="article.author"
v-for="(locale, index) in locales" :category="article.category"
:key="`locale-${index}`" :views="article.views"
:locale="locale.name"
:is-default="locale.isDefault"
/> />
</div> </div>
</div> </div>
@@ -110,14 +60,14 @@ const locales = [
<template #actions> <template #actions>
<Button <Button
variant="default" variant="default"
label="Create Portal" :label="$t('HELP_CENTER.NEW_PAGE.CREATE_PORTAL_BUTTON')"
icon="add" icon="add"
@click="openDialog" @click="openDialog"
/> />
<!-- <CreatePortalDialog <CreatePortalDialog
ref="createPortalDialogRef" ref="createPortalDialogRef"
@confirm="handleDialogConfirm" @create="onPortalCreate"
/> --> />
</template> </template>
</EmptyStateLayout> </EmptyStateLayout>
</template> </template>

View File

@@ -0,0 +1,172 @@
export default [
{
id: 1,
title: "How to get an SSL certificate for your Help Center's custom domain",
status: 'draft',
updatedAt: 1729205669,
author: { availableName: 'Michael' },
category: {
slug: 'configuration',
icon: '📦',
name: 'Setup & Configuration',
},
views: 3400,
},
{
id: 2,
title: 'Setting up your first Help Center portal',
status: 'published',
updatedAt: 1729205669,
author: { availableName: 'John' },
category: { slug: 'onboarding', icon: '🧑‍🍳', name: 'Onboarding' },
views: 400,
},
{
id: 3,
title: 'Best practices for organizing your Help Center content',
status: 'archived',
updatedAt: 1729205669,
author: { availableName: 'Fernando' },
category: { slug: 'best-practices', icon: '⛺️', name: 'Best Practices' },
views: 400,
},
{
id: 4,
title: 'Customizing the appearance of your Help Center',
status: 'draft',
updatedAt: 1729205669,
author: { availableName: 'Jane' },
category: { slug: 'design', icon: '🎨', name: 'Design' },
views: 400,
},
{
id: 5,
title: 'Integrating your Help Center with third-party tools',
status: 'published',
updatedAt: 1729205669,
author: { availableName: 'Sarah' },
category: {
slug: 'integrations',
icon: '🔗',
name: 'Integrations',
},
views: 2800,
},
{
id: 6,
title: 'Managing user permissions in your Help Center',
status: 'draft',
updatedAt: 1729205669,
author: { availableName: 'Alex' },
category: {
slug: 'administration',
icon: '🔐',
name: 'Administration',
},
views: 1200,
},
{
id: 7,
title: 'Creating and managing FAQ sections',
status: 'published',
updatedAt: 1729205669,
author: { availableName: 'Emily' },
category: {
slug: 'content-management',
icon: '📝',
name: 'Content Management',
},
views: 5600,
},
{
id: 8,
title: 'Implementing search functionality in your Help Center',
status: 'archived',
updatedAt: 1729205669,
author: { availableName: 'David' },
category: {
slug: 'features',
icon: '🔍',
name: 'Features',
},
views: 1800,
},
{
id: 9,
title: 'Analyzing Help Center usage metrics',
status: 'published',
updatedAt: 1729205669,
author: { availableName: 'Rachel' },
category: {
slug: 'analytics',
icon: '📊',
name: 'Analytics',
},
views: 3200,
},
{
id: 10,
title: 'Setting up multilingual support in your Help Center',
status: 'draft',
updatedAt: 1729205669,
author: { availableName: 'Carlos' },
category: {
slug: 'localization',
icon: '🌍',
name: 'Localization',
},
views: 900,
},
{
id: 11,
title: 'Creating interactive tutorials for your products',
status: 'published',
updatedAt: 1729205669,
author: { availableName: 'Olivia' },
category: {
slug: 'education',
icon: '🎓',
name: 'Education',
},
views: 4100,
},
{
id: 12,
title: 'Implementing a feedback system in your Help Center',
status: 'draft',
updatedAt: 1729205669,
author: { availableName: 'Nathan' },
category: {
slug: 'user-engagement',
icon: '💬',
name: 'User Engagement',
},
views: 750,
},
{
id: 13,
title: 'Optimizing Help Center content for SEO',
status: 'published',
updatedAt: 1729205669,
author: { availableName: 'Sophia' },
category: {
slug: 'seo',
icon: '🚀',
name: 'SEO',
},
views: 2900,
},
{
id: 14,
title: 'Creating a knowledge base for internal teams',
status: 'archived',
updatedAt: 1729205669,
author: { availableName: 'Daniel' },
category: {
slug: 'internal-resources',
icon: '🏢',
name: 'Internal Resources',
},
views: 1500,
},
];

View File

@@ -1,16 +1,15 @@
<script setup> <script setup>
import { ref } from 'vue'; import { ref, computed } from 'vue';
import { OnClickOutside } from '@vueuse/components'; import { OnClickOutside } from '@vueuse/components';
import { useRoute } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store.js';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue'; import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import PortalSwitcher from 'dashboard/components-next/HelpCenter/PortalSwitcher/PortalSwitcher.vue'; import PortalSwitcher from 'dashboard/components-next/HelpCenter/PortalSwitcher/PortalSwitcher.vue';
import CreatePortalDialog from 'dashboard/components-next/HelpCenter/PortalSwitcher/CreatePortalDialog.vue';
defineProps({ defineProps({
header: {
type: String,
default: 'Chatwoot Help Center',
},
currentPage: { currentPage: {
type: Number, type: Number,
default: 1, default: 1,
@@ -35,8 +34,21 @@ defineProps({
const emit = defineEmits(['update:currentPage']); const emit = defineEmits(['update:currentPage']);
const route = useRoute();
const createPortalDialogRef = ref(null);
const showPortalSwitcher = ref(false); const showPortalSwitcher = ref(false);
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 updateCurrentPage = page => { const updateCurrentPage = page => {
emit('update:currentPage', page); emit('update:currentPage', page);
}; };
@@ -46,34 +58,37 @@ const togglePortalSwitcher = () => {
</script> </script>
<template> <template>
<section <section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
class="flex flex-col w-full h-full overflow-hidden bg-white dark:bg-slate-900" <header class="sticky top-0 z-10 px-6 pb-3 lg:px-0">
>
<header
class="sticky top-0 z-10 px-6 pb-3 bg-white lg:px-0 dark:bg-slate-900"
>
<div class="w-full max-w-[900px] mx-auto"> <div class="w-full max-w-[900px] mx-auto">
<div <div
v-if="showHeaderTitle" v-if="showHeaderTitle"
class="flex items-center justify-start h-20 gap-2" class="flex items-center justify-start h-20 gap-2"
> >
<span class="text-xl font-medium text-slate-900 dark:text-white"> <span
{{ header }} v-if="activePortalName"
class="text-xl font-medium text-slate-900 dark:text-white"
>
{{ activePortalName }}
</span> </span>
<div class="relative group"> <div v-if="activePortalName" class="relative group">
<Button
icon="more-vertical"
variant="ghost"
size="sm"
class="group-hover:bg-slate-100 dark:group-hover:bg-slate-800"
@click="togglePortalSwitcher"
/>
<OnClickOutside @trigger="showPortalSwitcher = false"> <OnClickOutside @trigger="showPortalSwitcher = false">
<Button
icon="chevron-lucide-down"
variant="ghost"
icon-lib="lucide"
class="!w-6 !h-6 group-hover:bg-n-solid-2 !p-0.5 rounded-md"
@click="togglePortalSwitcher"
/>
<PortalSwitcher <PortalSwitcher
v-if="showPortalSwitcher" v-if="showPortalSwitcher"
class="absolute left-0 top-9" class="absolute ltr:left-0 rtl:right-0 top-9"
@close="showPortalSwitcher = false"
@create-portal="createPortalDialogRef.dialogRef.open()"
/> />
</OnClickOutside> </OnClickOutside>
<CreatePortalDialog ref="createPortalDialogRef" />
</div> </div>
</div> </div>
<slot name="header-actions" /> <slot name="header-actions" />
@@ -84,10 +99,7 @@ const togglePortalSwitcher = () => {
<slot name="content" /> <slot name="content" />
</div> </div>
</main> </main>
<footer <footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 px-4 pb-4">
v-if="showPaginationFooter"
class="sticky bottom-0 z-10 px-4 pt-3 pb-4 bg-white dark:bg-slate-900"
>
<PaginationFooter <PaginationFooter
:current-page="currentPage" :current-page="currentPage"
:total-items="totalItems" :total-items="totalItems"
@@ -95,5 +107,7 @@ const togglePortalSwitcher = () => {
@update:current-page="updateCurrentPage" @update:current-page="updateCurrentPage"
/> />
</footer> </footer>
<!-- Do not remove this slot. It can be used to add dialogs. -->
<slot />
</section> </section>
</template> </template>

View File

@@ -1,12 +1,14 @@
<script setup> <script setup>
import { ref } from 'vue'; import { ref, computed } from 'vue';
import { OnClickOutside } from '@vueuse/components'; import { OnClickOutside } from '@vueuse/components';
import { useI18n } from 'vue-i18n';
import { LOCALE_MENU_ITEMS } from 'dashboard/helper/portalHelper';
import CardLayout from 'dashboard/components-next/CardLayout.vue'; import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
defineProps({ const props = defineProps({
locale: { locale: {
type: String, type: String,
required: true, required: true,
@@ -15,6 +17,10 @@ defineProps({
type: Boolean, type: Boolean,
required: true, required: true,
}, },
localeCode: {
type: String,
required: true,
},
articleCount: { articleCount: {
type: Number, type: Number,
required: true, required: true,
@@ -25,29 +31,26 @@ defineProps({
}, },
}); });
const isOpen = ref(false); const emit = defineEmits(['action']);
const menuItems = [ const { t } = useI18n();
{
label: 'Make default',
action: 'default',
icon: 'star-emphasis',
},
{
label: 'Delete',
action: 'delete',
icon: 'delete',
},
];
// eslint-disable-next-line no-unused-vars const showDropdownMenu = ref(false);
const handleAction = action => {
// TODO: Implement action const localeMenuItems = computed(() =>
LOCALE_MENU_ITEMS.map(item => ({
...item,
label: t(item.label),
disabled: props.isDefault,
}))
);
const handleAction = ({ action, value }) => {
emit('action', { action, value });
showDropdownMenu.value = false;
}; };
</script> </script>
<!-- TODO: Add i18n -->
<!-- eslint-disable vue/no-bare-strings-in-template -->
<template> <template>
<CardLayout class="ltr:pr-2 rtl:pl-2"> <CardLayout class="ltr:pr-2 rtl:pl-2">
<template #header> <template #header>
@@ -56,42 +59,53 @@ const handleAction = action => {
<span <span
class="text-sm font-medium text-slate-900 dark:text-slate-50 line-clamp-1" class="text-sm font-medium text-slate-900 dark:text-slate-50 line-clamp-1"
> >
{{ locale }} {{ locale }} ({{ localeCode }})
</span> </span>
<span <span
v-if="isDefault" v-if="isDefault"
class="bg-slate-100 dark:bg-slate-800 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-woot-500 dark:text-woot-400 px-2 py-0.5" class="bg-slate-100 dark:bg-slate-800 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-woot-500 dark:text-woot-400 px-2 py-0.5"
> >
Default {{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }}
</span> </span>
</div> </div>
<div class="flex items-center justify-end gap-1"> <div class="flex items-center justify-end gap-2">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<span <span
class="text-sm text-slate-500 dark:text-slate-400 whitespace-nowrap" class="text-sm text-slate-500 dark:text-slate-400 whitespace-nowrap"
> >
{{ articleCount }} articles {{
$t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.ARTICLES_COUNT',
articleCount
)
}}
</span> </span>
<div class="w-px h-3 bg-slate-75 dark:bg-slate-800" /> <div class="w-px h-3 bg-slate-75 dark:bg-slate-800" />
<span <span
class="text-sm text-slate-500 dark:text-slate-400 whitespace-nowrap" class="text-sm text-slate-500 dark:text-slate-400 whitespace-nowrap"
> >
{{ categoryCount }} categories {{
$t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.CATEGORIES_COUNT',
categoryCount
)
}}
</span> </span>
</div> </div>
<div class="relative group"> <div class="relative group">
<Button <OnClickOutside @trigger="showDropdownMenu = false">
variant="ghost" <Button
size="icon" variant="ghost"
icon="more-vertical" size="sm"
class="w-8 group-hover:bg-slate-100 dark:group-hover:bg-slate-800" icon="more-vertical"
@click="isOpen = !isOpen" class="w-8 group-hover:bg-slate-100 dark:group-hover:bg-slate-800"
/> @click="showDropdownMenu = !showDropdownMenu"
<OnClickOutside @trigger="isOpen = false"> />
<DropdownMenu <DropdownMenu
v-if="isOpen" v-if="showDropdownMenu"
:menu-items="menuItems" :menu-items="localeMenuItems"
class="right-0 mt-1 xl:left-0 top-full z-60 min-w-[147px]" class="ltr:right-0 rtl:left-0 mt-1 xl:ltr:left-0 xl:rtl:right-0 top-full z-60 min-w-[150px]"
@action="handleAction" @action="handleAction"
/> />
</OnClickOutside> </OnClickOutside>

View File

@@ -1,105 +1,111 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { debounce } from '@chatwoot/utils'; import { debounce } from '@chatwoot/utils';
import { useI18n } from 'vue-i18n';
import { ARTICLE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor'; import { ARTICLE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue'; import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue'; import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import FullEditor from 'dashboard/components/widgets/WootWriter/FullEditor.vue'; import FullEditor from 'dashboard/components/widgets/WootWriter/FullEditor.vue';
import ArticleEditorHeader from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorHeader.vue';
import ArticleEditorControls from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorControls.vue';
const { article } = defineProps({ const props = defineProps({
article: { article: {
type: Object, type: Object,
default: () => ({}), default: () => ({}),
}, },
isUpdating: {
type: Boolean,
default: false,
},
isSaved: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits(['saveArticle']);
const emit = defineEmits([
'saveArticle',
'goBack',
'setAuthor',
'setCategory',
'previewArticle',
]);
const { t } = useI18n();
const saveArticle = debounce(value => emit('saveArticle', value), 400, false); const saveArticle = debounce(value => emit('saveArticle', value), 400, false);
const articleTitle = computed({ const articleTitle = computed({
get: () => article.title, get: () => props.article.title,
set: title => { set: value => {
saveArticle({ title }); saveArticle({ title: value });
}, },
}); });
const articleContent = computed({ const articleContent = computed({
get: () => article.content, get: () => props.article.content,
set: content => { set: content => {
saveArticle({ content }); saveArticle({ content });
}, },
}); });
const onClickGoBack = () => {
emit('goBack');
};
const setAuthorId = authorId => {
emit('setAuthor', authorId);
};
const setCategoryId = categoryId => {
emit('setCategory', categoryId);
};
const previewArticle = () => {
emit('previewArticle');
};
</script> </script>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<template> <template>
<HelpCenterLayout :show-header-title="false" :show-pagination-footer="false"> <HelpCenterLayout :show-header-title="false" :show-pagination-footer="false">
<template #header-actions> <template #header-actions>
<div class="flex items-center justify-between h-20"> <ArticleEditorHeader
<Button :is-updating="isUpdating"
label="Back to articles" :is-saved="isSaved"
icon="chevron-lucide-left" :status="article.status"
icon-lib="lucide" :article-id="article.id"
variant="link" @go-back="onClickGoBack"
text-variant="info" @preview-article="previewArticle"
size="sm" />
/>
<div class="flex items-center gap-4">
<span class="text-xs font-medium text-slate-500 dark:text-slate-400">
Saved
</span>
<div class="flex items-center gap-2">
<Button label="Preview" variant="secondary" size="sm" />
<Button
label="Publish"
icon="chevron-lucide-down"
icon-position="right"
icon-lib="lucide"
size="sm"
/>
</div>
</div>
</div>
</template> </template>
<template #content> <template #content>
<div class="flex flex-col gap-3 pl-4 mb-3 rtl:pr-3 rtl:pl-0"> <div class="flex flex-col gap-3 pl-4 mb-3 rtl:pr-3 rtl:pl-0">
<TextArea <TextArea
v-model="articleTitle" v-model="articleTitle"
class="h-12" auto-height
custom-text-area-class="border-0 !text-[32px] !bg-transparent !py-0 !px-0 !h-auto !leading-[48px] !font-medium !tracking-[0.2px]" min-height="4rem"
custom-text-area-class="!text-[32px] !leading-[48px] !font-medium !tracking-[0.2px]"
custom-text-area-wrapper-class="border-0 !bg-transparent dark:!bg-transparent !py-0 !px-0"
placeholder="Title" placeholder="Title"
autofocus
/>
<ArticleEditorControls
:article="article"
@save-article="saveArticle"
@set-author="setAuthorId"
@set-category="setCategoryId"
/> />
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<div class="w-5 h-5 rounded-full bg-slate-100 dark:bg-slate-700" />
<span class="text-sm text-slate-500 dark:text-slate-400">
John Doe
</span>
</div>
<div class="w-px h-3 bg-slate-50 dark:bg-slate-800" />
<Button
label="Uncategorized"
icon="play-shape"
variant="ghost"
class="!px-2 font-normal"
text-variant="info"
/>
<div class="w-px h-3 bg-slate-50 dark:bg-slate-800" />
<Button
label="More properties"
icon="add"
variant="ghost"
class="!px-2 font-normal"
/>
</div>
</div> </div>
<FullEditor <FullEditor
v-model="articleContent" v-model="articleContent"
class="py-0 pb-10 pl-4 rtl:pr-4 rtl:pl-0 h-fit" class="py-0 pb-10 pl-4 rtl:pr-4 rtl:pl-0 h-fit"
placeholder="Write something" :placeholder="
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.EDITOR_PLACEHOLDER')
"
:enabled-menu-options="ARTICLE_EDITOR_MENU_OPTIONS" :enabled-menu-options="ARTICLE_EDITOR_MENU_OPTIONS"
:autofocus="false"
/> />
</template> </template>
</HelpCenterLayout> </HelpCenterLayout>
@@ -132,8 +138,10 @@ const articleContent = computed({
.ProseMirror-menuitem { .ProseMirror-menuitem {
@apply mr-0; @apply mr-0;
.ProseMirror-icon { .ProseMirror-icon {
@apply p-0 mt-1 !mr-0; @apply p-0 mt-1 !mr-0;
svg { svg {
width: 20px !important; width: 20px !important;
height: 20px !important; height: 20px !important;

View File

@@ -0,0 +1,206 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { OnClickOutside } from '@vueuse/components';
import { useMapGetter } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import ArticleEditorProperties from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorProperties.vue';
const props = defineProps({
article: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['saveArticle', 'setAuthor', 'setCategory']);
const { t } = useI18n();
const openAgentsList = ref(false);
const openCategoryList = ref(false);
const openProperties = ref(false);
const selectedAuthorId = ref(null);
const selectedCategoryId = ref(null);
const agents = useMapGetter('agents/getAgents');
const categories = useMapGetter('categories/allCategories');
const currentUserId = useMapGetter('getCurrentUserID');
const isNewArticle = computed(() => !props.article?.id);
const currentUser = computed(() =>
agents.value.find(agent => agent.id === currentUserId.value)
);
const author = computed(() => {
if (isNewArticle.value) {
return selectedAuthorId.value
? agents.value.find(agent => agent.id === selectedAuthorId.value)
: currentUser.value;
}
return props.article?.author || currentUser.value;
});
const authorName = computed(
() => author.value?.name || author.value?.available_name || '-'
);
const authorThumbnailSrc = computed(() => author.value?.thumbnail);
const agentList = computed(() => {
return [...agents.value]
.sort((a, b) => a.name.localeCompare(b.name))
.map(agent => ({
label: agent.name,
value: agent.id,
thumbnail: { name: agent.name, src: agent.thumbnail },
isSelected: agent.id === props.article?.author?.id,
action: 'assignAuthor',
}))
.sort((a, b) => b.isSelected - a.isSelected);
});
const hasAgentList = computed(() => {
return agents.value?.length > 0;
});
const selectedCategory = computed(() => {
if (isNewArticle.value) {
return selectedCategoryId.value
? categories.value.find(
category => category.id === selectedCategoryId.value
)
: categories.value[0] || null;
}
return categories.value.find(
category => category.id === props.article?.category?.id
);
});
const categoryList = computed(() => {
return categories.value
.map(category => ({
label: category.name,
value: category.id,
emoji: category.icon,
isSelected: category.id === props.article?.category?.id,
action: 'assignCategory',
}))
.sort((a, b) => b.isSelected - a.isSelected);
});
const hasCategoryMenuItems = computed(() => {
return categoryList.value?.length > 0;
});
const handleArticleAction = ({ action, value }) => {
const actions = {
assignAuthor: () => {
if (isNewArticle.value) {
selectedAuthorId.value = value;
emit('setAuthor', value);
} else {
emit('saveArticle', { author_id: value });
}
openAgentsList.value = false;
},
assignCategory: () => {
if (isNewArticle.value) {
selectedCategoryId.value = value;
emit('setCategory', value);
} else {
emit('saveArticle', { category_id: value });
}
openCategoryList.value = false;
},
};
actions[action]?.();
};
const updateMeta = meta => {
emit('saveArticle', { meta });
};
</script>
<template>
<div class="flex items-center gap-4">
<div class="relative flex items-center gap-2">
<OnClickOutside @trigger="openAgentsList = false">
<Button
:label="authorName"
variant="ghost"
class="!px-0 font-normal"
text-variant="info"
@click="openAgentsList = !openAgentsList"
>
<template #leftPrefix>
<Thumbnail
v-if="author"
:author="author"
:name="authorName"
:size="20"
:src="authorThumbnailSrc"
/>
</template>
</Button>
<DropdownMenu
v-if="openAgentsList && hasAgentList"
:menu-items="agentList"
class="z-[100] w-48 mt-2 overflow-y-auto ltr:left-0 rtl:right-0 top-full max-h-52"
@action="handleArticleAction"
/>
</OnClickOutside>
</div>
<div class="w-px h-3 bg-slate-50 dark:bg-slate-800" />
<div class="relative">
<OnClickOutside @trigger="openCategoryList = false">
<Button
:label="
selectedCategory?.name ||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.UNCATEGORIZED')
"
:emoji="selectedCategory?.icon || ''"
:icon="!selectedCategory?.icon ? 'play-shape' : ''"
variant="ghost"
class="!px-2 font-normal"
text-variant="info"
@click="openCategoryList = !openCategoryList"
/>
<DropdownMenu
v-if="openCategoryList && hasCategoryMenuItems"
:menu-items="categoryList"
class="w-48 mt-2 z-[100] overflow-y-auto left-0 top-full max-h-52"
@action="handleArticleAction"
/>
</OnClickOutside>
</div>
<div class="w-px h-3 bg-slate-50 dark:bg-slate-800" />
<div class="relative">
<OnClickOutside @trigger="openProperties = false">
<Button
:label="
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.MORE_PROPERTIES')
"
icon="add"
variant="ghost"
:disabled="isNewArticle"
text-variant="info"
class="!px-2 font-normal"
@click="openProperties = !openProperties"
/>
<ArticleEditorProperties
v-if="openProperties"
:article="article"
class="right-0 z-[100] mt-2 xl:left-0 top-full"
@save-article="updateMeta"
@close="openProperties = false"
/>
</OnClickOutside>
</div>
</div>
</template>

View File

@@ -0,0 +1,178 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useStore } from 'dashboard/composables/store.js';
import { useAlert, useTrack } from 'dashboard/composables';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { OnClickOutside } from '@vueuse/components';
import { getArticleStatus } from 'dashboard/helper/portalHelper.js';
import {
ARTICLE_EDITOR_STATUS_OPTIONS,
ARTICLE_STATUSES,
ARTICLE_MENU_ITEMS,
} from 'dashboard/helper/portalHelper';
import wootConstants from 'dashboard/constants/globals';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
isUpdating: {
type: Boolean,
default: false,
},
isSaved: {
type: Boolean,
default: false,
},
status: {
type: String,
default: '',
},
articleId: {
type: Number,
default: 0,
},
});
const emit = defineEmits(['goBack', 'previewArticle']);
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const isArticlePublishing = ref(false);
const { ARTICLE_STATUS_TYPES } = wootConstants;
const showArticleActionMenu = ref(false);
const articleMenuItems = computed(() => {
const statusOptions = ARTICLE_EDITOR_STATUS_OPTIONS[props.status] ?? [];
return statusOptions.map(option => {
const { label, value, icon } = ARTICLE_MENU_ITEMS[option];
return {
label: t(label),
value,
action: 'update-status',
icon,
};
});
});
const statusText = computed(() =>
t(
`HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.STATUS.${props.isUpdating ? 'SAVING' : 'SAVED'}`
)
);
const onClickGoBack = () => emit('goBack');
const previewArticle = () => emit('previewArticle');
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 updateArticleStatus = async ({ value }) => {
showArticleActionMenu.value = false;
const status = getArticleStatus(value);
if (status === ARTICLE_STATUS_TYPES.PUBLISH) {
isArticlePublishing.value = true;
}
const { portalSlug } = route.params;
try {
await store.dispatch('articles/update', {
portalSlug,
articleId: props.articleId,
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);
}
isArticlePublishing.value = false;
} catch (error) {
useAlert(error?.message ?? getStatusMessage(status, false));
isArticlePublishing.value = false;
}
};
</script>
<template>
<div class="flex items-center justify-between h-20">
<Button
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.BACK_TO_ARTICLES')"
icon="chevron-lucide-left"
icon-lib="lucide"
variant="link"
text-variant="info"
size="sm"
@click="onClickGoBack"
/>
<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"
>
{{ statusText }}
</span>
<div class="flex items-center gap-2">
<Button
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PREVIEW')"
variant="secondary"
size="sm"
:disabled="!articleId"
@click="previewArticle"
/>
<div class="flex items-center">
<Button
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')"
size="sm"
class="ltr:rounded-r-none rtl:rounded-l-none"
:is-loading="isArticlePublishing"
:disabled="
status === ARTICLE_STATUSES.PUBLISHED ||
!articleId ||
isArticlePublishing
"
@click="updateArticleStatus({ value: ARTICLE_STATUSES.PUBLISHED })"
/>
<div class="relative">
<OnClickOutside @trigger="showArticleActionMenu = false">
<Button
icon="chevron-lucide-down"
icon-lib="lucide"
size="sm"
:disabled="!articleId"
class="ltr:rounded-l-none rtl:rounded-r-none"
@click.stop="showArticleActionMenu = !showArticleActionMenu"
/>
<DropdownMenu
v-if="showArticleActionMenu"
:menu-items="articleMenuItems"
class="mt-2 ltr:right-0 rtl:left-0 top-full"
@action="updateArticleStatus($event)"
/>
</OnClickOutside>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,134 @@
<script setup>
import { reactive, watch, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { debounce } from '@chatwoot/utils';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
article: {
type: Object,
required: true,
},
});
const emit = defineEmits(['saveArticle', 'close']);
const saveArticle = debounce(value => emit('saveArticle', value), 400, false);
const { t } = useI18n();
const state = reactive({
title: '',
description: '',
tags: [],
});
const updateState = () => {
state.title = props.article.meta?.title || '';
state.description = props.article.meta?.description || '';
state.tags = props.article.meta?.tags || [];
};
watch(
state,
newState => {
saveArticle({
title: newState.title,
description: newState.description,
tags: newState.tags,
});
},
{ deep: true }
);
onMounted(() => {
updateState();
});
</script>
<template>
<div
class="flex flex-col absolute w-[400px] bg-n-alpha-3 backdrop-blur-[100px] shadow-lg gap-6 rounded-xl p-6"
>
<div class="flex items-center justify-between">
<h3>
{{
t(
'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.ARTICLE_PROPERTIES'
)
}}
</h3>
<Button
icon="dismiss"
size="sm"
variant="ghost"
class="w-8 hover:text-n-slate-11"
@click="emit('close')"
/>
</div>
<div class="flex flex-col gap-2">
<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"
>
{{
t(
'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_DESCRIPTION'
)
}}
</label>
<TextArea
v-model="state.description"
:placeholder="
t(
'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_DESCRIPTION_PLACEHOLDER'
)
"
class="w-[224px]"
custom-text-area-wrapper-class="!p-0 !border-0 !rounded-none !bg-transparent transition-none"
custom-text-area-class="max-h-[150px]"
auto-height
min-height="3rem"
/>
</div>
<div class="flex justify-between w-full gap-2 py-2">
<InlineInput
v-model="state.title"
:placeholder="
t(
'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TITLE_PLACEHOLDER'
)
"
:label="
t('HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TITLE')
"
custom-label-class="min-w-[120px]"
/>
</div>
<div class="flex justify-between w-full gap-2 py-2">
<label
class="text-sm font-medium whitespace-nowrap min-w-[120px] text-slate-900 dark:text-slate-50"
>
{{
t('HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TAGS')
}}
</label>
<TagInput
v-model="state.tags"
:placeholder="
t(
'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TAGS_PLACEHOLDER'
)
"
class="w-[224px]"
/>
</div>
</div>
</div>
</div>
</template>

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 {
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 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 hasMoreThanOneLocaleMenuItems = computed(() => {
return localeMenuItems.value?.length > 1;
});
const handleLocaleAction = ({ value }) => {
emit('localeChange', value);
isLocaleMenuOpen.value = false;
};
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
class="bg-n-solid-1"
: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 v-if="hasMoreThanOneLocaleMenuItems" class="relative group">
<OnClickOutside @trigger="isLocaleMenuOpen = false">
<Button
:label="activeLocaleName"
size="sm"
icon-position="right"
icon="chevron-lucide-down"
icon-lib="lucide"
variant="secondary"
@click="isLocaleMenuOpen = !isLocaleMenuOpen"
/>
<DropdownMenu
v-if="isLocaleMenuOpen"
:menu-items="localeMenuItems"
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"
size="sm"
icon-position="right"
icon="chevron-lucide-down"
icon-lib="lucide"
variant="secondary"
class="max-w-48"
@click="isCategoryMenuOpen = !isCategoryMenuOpen"
/>
<DropdownMenu
v-if="isCategoryMenuOpen"
:menu-items="categoryMenuItems"
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="add"
size="sm"
@click="handleNewArticle"
/>
</div>
</div>
</template>

View File

@@ -1,25 +1,190 @@
<script setup> <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'; import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue';
defineProps({ const props = defineProps({
articles: { articles: {
type: Array, type: Array,
required: true, 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 updateMeta = () => {
const { portalSlug, locale } = route.params;
return store.dispatch('portals/show', { 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 updateMeta();
} 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> </script>
<template> <template>
<ul role="list" class="w-full h-full space-y-4"> <Draggable
<ArticleCard v-model="localArticles"
v-for="article in articles" :disabled="!dragEnabled"
:key="article.title" item-key="id"
:title="article.title" tag="ul"
:status="article.status" ghost-class="article-ghost-class"
:author="article.author" class="w-full h-full space-y-4"
:category="article.category" @end="onDragEnd"
:views="article.views" >
:updated-at="article.updatedAt" <template #item="{ element }">
/> <li class="list-none rounded-2xl">
</ul> <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> </template>
<style lang="scss" scoped>
.article-ghost-class {
@apply opacity-50 bg-n-solid-1;
}
</style>

View File

@@ -1,74 +1,182 @@
<script setup> <script setup>
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue'; import { computed } from 'vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue'; import { useRouter, useRoute } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue'; import { useI18n } from 'vue-i18n';
import ArticleList from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue'; import { useMapGetter } from 'dashboard/composables/store.js';
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
defineProps({ 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: { articles: {
type: Array, type: Array,
required: true, 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 tabs = [ const emit = defineEmits(['pageChange', 'fetchPortal']);
{ label: 'All articles', count: 24 },
{ label: 'Mine', count: 13 }, const router = useRouter();
{ label: 'Draft', count: 5 }, const route = useRoute();
{ label: 'Archived', count: 11 }, const { t } = useI18n();
];
// TODO: remove comments const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
// eslint-disable-next-line no-unused-vars const isFetching = useMapGetter('articles/isFetching');
const handleTabChange = tab => {
// TODO: Implement tab change logic 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,
},
});
}; };
// eslint-disable-next-line no-unused-vars
const handlePageChange = page => { const articlesCount = computed(() => {
// TODO: Implement page change logic 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(
() =>
!hasNoArticlesInPortal.value &&
!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 = () =>
router.push({ name: 'portals_articles_new' });
</script> </script>
<template> <template>
<HelpCenterLayout <HelpCenterLayout
:current-page="1" :current-page="Number(meta.currentPage)"
:total-items="100" :total-items="articlesCount"
:items-per-page="10" :items-per-page="25"
:header="portalName"
:show-pagination-footer="shouldShowPaginationFooter"
@update:current-page="handlePageChange" @update:current-page="handlePageChange"
> >
<template #header-actions> <template #header-actions>
<div class="flex items-end justify-between"> <div class="flex items-end justify-between">
<div class="flex flex-col items-start w-full gap-2 lg:flex-row"> <ArticleHeaderControls
<TabBar v-if="showArticleHeaderControls"
:tabs="tabs" :categories="categories"
:initial-active-tab="1" :allowed-locales="allowedLocales"
@tab-changed="handleTabChange" :meta="meta"
/> @tab-change="handleTabChange"
<div class="flex items-start justify-between w-full gap-2"> @locale-change="handleLocaleAction"
<div class="flex items-center gap-2"> @category-change="handleCategoryAction"
<Button @new-article="navigateToNewArticlePage"
label="English" />
size="sm" <CategoryHeaderControls
icon-position="right" v-else-if="showCategoryHeaderControls"
icon="chevron-lucide-down" :categories="categories"
icon-lib="lucide" :allowed-locales="allowedLocales"
variant="secondary" :has-selected-category="isCategoryArticles"
/> />
<Button
label="All categories"
size="sm"
icon-position="right"
icon="chevron-lucide-down"
icon-lib="lucide"
variant="secondary"
/>
</div>
<Button label="New article" icon="add" size="sm" />
</div>
</div>
</div> </div>
</template> </template>
<template #content> <template #content>
<ArticleList :articles="articles" /> <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> </template>
</HelpCenterLayout> </HelpCenterLayout>
</template> </template>

View File

@@ -1,101 +1,139 @@
<script setup> <script setup>
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
// import { OnClickOutside } from '@vueuse/components'; import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue'; import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import CategoryList from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryList.vue'; import CategoryList from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryList.vue';
import ArticleList from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue'; import CategoryHeaderControls from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryHeaderControls.vue';
// import EditCategory from 'dashboard/playground/HelpCenter/components/EditCategory.vue'; import CategoryEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/Category/CategoryEmptyState.vue';
import EditCategoryDialog from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/EditCategoryDialog.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({ const props = defineProps({
categories: { categories: {
type: Array, type: Array,
required: true, required: true,
}, },
isFetching: {
type: Boolean,
required: false,
},
allowedLocales: {
type: Array,
required: true,
},
}); });
const emit = defineEmits(['fetchCategories']);
const route = useRoute();
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const editCategoryDialog = ref(null);
const selectedCategory = ref(null); const selectedCategory = ref(null);
// const showEditCategory = ref(false);
// const openEditCategory = () => { const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
// showEditCategory.value = true; const isLoading = computed(() => props.isFetching || isSwitchingPortal.value);
// }; const hasCategories = computed(() => props.categories?.length > 0);
// const closeEditCategory = () => {
// showEditCategory.value = false;
// };
const breadcrumbItems = computed(() => { const updateRoute = (newParams, routeName) => {
const items = [{ label: 'Categories (en-US)', link: '#' }]; const { accountId, portalSlug, locale } = route.params;
if (selectedCategory.value) { const baseParams = { accountId, portalSlug, locale };
items.push({
label: selectedCategory.value.title, router.push({
count: selectedCategory.value.articles.length, name: routeName,
params: {
...baseParams,
...newParams,
categorySlug: newParams.categorySlug,
},
});
};
const openCategoryArticles = slug => {
updateRoute({ categorySlug: slug }, 'portals_categories_articles_index');
};
const handleLocaleChange = value => {
updateRoute({ locale: value }, 'portals_categories_index');
emit('fetchCategories', value);
};
async function deleteCategory(category) {
try {
await store.dispatch('categories/delete', {
portalSlug: route.params.portalSlug,
categoryId: category.id,
}); });
useTrack(PORTALS_EVENTS.DELETE_CATEGORY, {
hasArticles: category?.meta?.articles_count > 0,
});
useAlert(
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.DELETE.API.SUCCESS_MESSAGE')
);
} catch (error) {
useAlert(
error.message ||
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.DELETE.API.ERROR_MESSAGE')
);
}
}
const handleAction = ({ action, id, category: categoryData }) => {
if (action === 'edit') {
selectedCategory.value = props.categories.find(
category => category.id === id
);
editCategoryDialog.value.dialogRef.open();
}
if (action === 'delete') {
deleteCategory(categoryData);
} }
return items;
});
const openCategoryArticles = id => {
selectedCategory.value = props.categories.find(
category => category.id === id
);
}; };
const resetCategory = () => {
selectedCategory.value = null;
};
const displayedArticles = computed(() => {
return selectedCategory.value ? selectedCategory.value.articles : [];
});
</script> </script>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<template> <template>
<HelpCenterLayout :show-pagination-footer="false"> <HelpCenterLayout :show-pagination-footer="false">
<template #header-actions> <template #header-actions>
<div class="flex items-center justify-between"> <CategoryHeaderControls
<div v-if="!selectedCategory" class="flex items-center gap-4"> :categories="categories"
<Button :is-category-articles="false"
label="English" :allowed-locales="allowedLocales"
size="sm" @locale-change="handleLocaleChange"
icon-position="right" />
icon="chevron-lucide-down"
icon-lib="lucide"
variant="secondary"
/>
<div
class="w-px h-3.5 rounded my-auto bg-slate-75 dark:bg-slate-800"
/>
<span class="text-sm font-medium text-slate-800 dark:text-slate-100">
{{ categories.length }} categories
</span>
</div>
<Breadcrumb v-else :items="breadcrumbItems" @click="resetCategory" />
<Button
v-if="!selectedCategory"
label="New category"
icon="add"
size="sm"
/>
<div v-else class="relative">
<Button
label="Edit category"
variant="secondary"
size="sm"
@click="openEditCategory"
/>
<!-- <OnClickOutside @trigger="closeEditCategory">
<EditCategory v-if="showEditCategory" @close="closeEditCategory" />
</OnClickOutside> -->
</div>
</div>
</template> </template>
<template #content> <template #content>
<div
v-if="isLoading"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<CategoryList <CategoryList
v-if="!selectedCategory" v-else-if="hasCategories"
:categories="categories" :categories="categories"
@click="openCategoryArticles" @click="openCategoryArticles"
@action="handleAction"
/>
<CategoryEmptyState
v-else
class="pt-14"
:title="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_EMPTY_STATE.TITLE')"
:subtitle="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_EMPTY_STATE.SUBTITLE')"
/> />
<ArticleList v-else :articles="displayedArticles" />
</template> </template>
<EditCategoryDialog
ref="editCategoryDialog"
:allowed-locales="allowedLocales"
:selected-category="selectedCategory"
/>
</HelpCenterLayout> </HelpCenterLayout>
</template> </template>

View File

@@ -0,0 +1,112 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { useRoute } from 'vue-router';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import CategoryForm from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryForm.vue';
const props = defineProps({
mode: {
type: String,
default: 'edit',
validator: value => ['edit', 'create'].includes(value),
},
selectedCategory: {
type: Object,
default: () => ({}),
},
portalName: {
type: String,
default: '',
},
activeLocaleName: {
type: String,
default: '',
},
activeLocaleCode: {
type: String,
default: '',
},
});
const emit = defineEmits(['close']);
const store = useStore();
const { t } = useI18n();
const route = useRoute();
const handleCategory = async formData => {
const { id, name, slug, icon, description, locale } = formData;
const categoryData = { name, icon, slug, description };
if (props.mode === 'create') {
categoryData.locale = locale;
} else {
categoryData.id = id;
}
try {
const action = props.mode === 'edit' ? 'update' : 'create';
const payload = {
portalSlug: route.params.portalSlug,
categoryObj: categoryData,
};
if (action === 'update') {
payload.categoryId = id;
}
await store.dispatch(`categories/${action}`, payload);
const successMessage = t(
`HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.${props.mode.toUpperCase()}.API.SUCCESS_MESSAGE`
);
useAlert(successMessage);
const trackEvent =
props.mode === 'edit'
? PORTALS_EVENTS.EDIT_CATEGORY
: PORTALS_EVENTS.CREATE_CATEGORY;
useTrack(
trackEvent,
props.mode === 'create'
? { hasDescription: Boolean(description) }
: undefined
);
emit('close');
} catch (error) {
const errorMessage =
error?.message ||
t(
`HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.${props.mode.toUpperCase()}.API.ERROR_MESSAGE`
);
useAlert(errorMessage);
}
};
</script>
<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"
>
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
{{
t(
`HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.HEADER.${mode.toUpperCase()}`
)
}}
</h3>
<CategoryForm
:mode="mode"
:selected-category="selectedCategory"
:active-locale-code="activeLocaleCode"
:portal-name="portalName"
:active-locale-name="activeLocaleName"
@submit="handleCategory"
@cancel="emit('close')"
/>
</div>
</template>

View File

@@ -0,0 +1,272 @@
<script setup>
import {
reactive,
ref,
watch,
computed,
defineAsyncComponent,
onMounted,
} from 'vue';
import { useI18n } from 'vue-i18n';
import { OnClickOutside } from '@vueuse/components';
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { convertToCategorySlug } from 'dashboard/helper/commons.js';
import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
mode: {
type: String,
required: true,
validator: value => ['edit', 'create'].includes(value),
},
selectedCategory: {
type: Object,
default: () => ({}),
},
activeLocaleCode: {
type: String,
default: '',
},
showActionButtons: {
type: Boolean,
default: true,
},
portalName: {
type: String,
default: '',
},
activeLocaleName: {
type: String,
default: '',
},
});
const emit = defineEmits(['submit', 'cancel']);
const EmojiInput = defineAsyncComponent(
() => import('shared/components/emoji/EmojiInput.vue')
);
const { t } = useI18n();
const route = useRoute();
const getters = useStoreGetters();
const isCreating = useMapGetter('categories/isCreating');
const isUpdatingCategory = computed(() => {
const id = props.selectedCategory?.id;
if (id) return getters['categories/uiFlags'].value(id)?.isUpdating;
return false;
});
const isEmojiPickerOpen = ref(false);
const state = reactive({
id: '',
name: '',
icon: '',
slug: '',
description: '',
locale: '',
});
const isEditMode = computed(() => props.mode === 'edit');
const rules = {
name: { required, minLength: minLength(1) },
slug: { required },
};
const v$ = useVuelidate(rules, state);
const isSubmitDisabled = computed(() => v$.value.$invalid);
const nameError = computed(() =>
v$.value.name.$error
? t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.FORM.NAME.ERROR')
: ''
);
const slugError = computed(() =>
v$.value.slug.$error
? t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.FORM.SLUG.ERROR')
: ''
);
const slugHelpText = computed(() => {
const { portalSlug, locale } = route.params;
return t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.FORM.SLUG.HELP_TEXT', {
portalSlug,
localeCode: locale,
categorySlug: state.slug,
});
});
const onClickInsertEmoji = emoji => {
state.icon = emoji;
isEmojiPickerOpen.value = false;
};
const handleSubmit = async () => {
const isFormCorrect = await v$.value.$validate();
if (!isFormCorrect) return;
emit('submit', { ...state });
};
const handleCancel = () => {
emit('cancel');
};
watch(
() => state.name,
() => {
if (!isEditMode.value) {
state.slug = convertToCategorySlug(state.name);
}
}
);
watch(
() => props.selectedCategory,
newCategory => {
if (props.mode === 'edit' && newCategory) {
const { id, name, icon, slug, description } = newCategory;
Object.assign(state, { id, name, icon, slug, description });
}
},
{ immediate: true }
);
onMounted(() => {
if (props.mode === 'create') {
state.locale = props.activeLocaleCode;
}
});
defineExpose({ state, isSubmitDisabled });
</script>
<template>
<div class="flex flex-col gap-4">
<div
class="flex items-center justify-start gap-8 px-4 py-2 border rounded-lg border-slate-50 dark:border-slate-700/50"
>
<div class="flex flex-col items-start w-full gap-2 py-2">
<span class="text-sm font-medium text-slate-700 dark:text-slate-300">
{{ t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.HEADER.PORTAL') }}
</span>
<span class="text-sm text-slate-800 dark:text-slate-100">
{{ portalName }}
</span>
</div>
<div class="justify-start w-px h-10 bg-slate-50 dark:bg-slate-700/50" />
<div class="flex flex-col w-full gap-2 py-2">
<span class="text-sm font-medium text-slate-700 dark:text-slate-300">
{{ t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.HEADER.LOCALE') }}
</span>
<span
:title="`${activeLocaleName} (${activeLocaleCode})`"
class="text-sm line-clamp-1 text-slate-800 dark:text-slate-100"
>
{{ `${activeLocaleName} (${activeLocaleCode})` }}
</span>
</div>
</div>
<div class="flex flex-col gap-4">
<div class="relative">
<Input
v-model="state.name"
:label="
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.FORM.NAME.LABEL')
"
:placeholder="
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.FORM.NAME.PLACEHOLDER')
"
:message="nameError"
:message-type="nameError ? 'error' : 'info'"
custom-input-class="!h-10 ltr:!pl-12 rtl:!pr-12 !bg-slate-25 dark:!bg-slate-900"
>
<template #prefix>
<OnClickOutside @trigger="isEmojiPickerOpen = false">
<Button
:label="state.icon"
variant="secondary"
size="sm"
:icon="!state.icon ? 'emoji-add' : ''"
class="!h-[38px] !w-[38px] absolute top-[31px] !rounded-[7px] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none"
@click="isEmojiPickerOpen = !isEmojiPickerOpen"
/>
<EmojiInput
v-if="isEmojiPickerOpen"
class="left-0 top-16"
show-remove-button
:on-click="onClickInsertEmoji"
/>
</OnClickOutside>
</template>
</Input>
</div>
<Input
v-model="state.slug"
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.FORM.SLUG.LABEL')"
:placeholder="
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.FORM.SLUG.PLACEHOLDER')
"
:disabled="isEditMode"
:message="slugError ? slugError : slugHelpText"
:message-type="slugError ? 'error' : 'info'"
custom-input-class="!h-10 !bg-slate-25 dark:!bg-slate-900 "
/>
<TextArea
v-model="state.description"
:label="
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.FORM.DESCRIPTION.LABEL')
"
:placeholder="
t(
'HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.FORM.DESCRIPTION.PLACEHOLDER'
)
"
show-character-count
custom-text-area-wrapper-class="!bg-slate-25 dark:!bg-slate-900"
/>
<div
v-if="showActionButtons"
class="flex items-center justify-between w-full gap-3"
>
<Button
variant="ghost"
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.BUTTONS.CANCEL')"
text-variant="default"
class="w-full bg-n-alpha-2 hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
:label="
t(
`HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.BUTTONS.${mode.toUpperCase()}`
)
"
class="w-full"
:disabled="isSubmitDisabled || isCreating || isUpdatingCategory"
:is-loading="isCreating || isUpdatingCategory"
@click="handleSubmit"
/>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.emoji-dialog::before {
@apply hidden;
}
</style>

View File

@@ -0,0 +1,201 @@
<script setup>
import { ref, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { OnClickOutside } from '@vueuse/components';
import { useStoreGetters } from 'dashboard/composables/store.js';
import Button from 'dashboard/components-next/button/Button.vue';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import CategoryDialog from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryDialog.vue';
const props = defineProps({
categories: {
type: Array,
default: () => [],
},
allowedLocales: {
type: Array,
default: () => [],
},
hasSelectedCategory: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['localeChange']);
const route = useRoute();
const router = useRouter();
const getters = useStoreGetters();
const { t } = useI18n();
const isLocaleMenuOpen = ref(false);
const isCreateCategoryDialogOpen = ref(false);
const isEditCategoryDialogOpen = ref(false);
const currentPortalSlug = computed(() => {
return route.params.portalSlug;
});
const currentPortal = computed(() => {
const slug = currentPortalSlug.value;
if (slug) return getters['portals/portalBySlug'].value(slug);
return getters['portals/allPortals'].value[0];
});
const currentPortalName = computed(() => {
return currentPortal.value?.name;
});
const activeLocale = computed(() => {
return props.allowedLocales.find(
locale => locale.code === route.params.locale
);
});
const activeLocaleName = computed(() => activeLocale.value?.name ?? '');
const activeLocaleCode = computed(() => activeLocale.value?.code ?? '');
const localeMenuItems = computed(() => {
return props.allowedLocales.map(locale => ({
label: locale.name,
value: locale.code,
action: 'filter',
}));
});
const selectedCategory = computed(() =>
props.categories.find(category => category.slug === route.params.categorySlug)
);
const selectedCategoryName = computed(() => {
return selectedCategory.value?.name;
});
const selectedCategoryCount = computed(
() => selectedCategory.value?.meta?.articles_count || 0
);
const selectedCategoryEmoji = computed(() => {
return selectedCategory.value?.icon;
});
const categoriesCount = computed(() => props.categories?.length);
const breadcrumbItems = computed(() => {
const items = [
{
label: t(
'HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.BREADCRUMB.CATEGORY_LOCALE',
{ localeCode: activeLocaleCode.value }
),
link: '#',
},
];
if (selectedCategory.value) {
items.push({
label: t(
'HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.BREADCRUMB.ACTIVE_CATEGORY',
{
categoryName: selectedCategoryName.value,
categoryCount: selectedCategoryCount.value,
}
),
emoji: selectedCategoryEmoji.value,
});
}
return items;
});
const handleLocaleAction = ({ value }) => {
emit('localeChange', value);
isLocaleMenuOpen.value = false;
};
const handleBreadcrumbClick = () => {
const { categorySlug, ...otherParams } = route.params;
router.push({
name: 'portals_categories_index',
params: otherParams,
});
};
</script>
<template>
<div class="flex items-center justify-between w-full">
<div v-if="!hasSelectedCategory" class="flex items-center gap-4">
<div class="relative group">
<OnClickOutside @trigger="isLocaleMenuOpen = false">
<Button
:label="activeLocaleName"
size="sm"
icon-position="right"
icon="chevron-lucide-down"
icon-lib="lucide"
variant="secondary"
@click="isLocaleMenuOpen = !isLocaleMenuOpen"
/>
<DropdownMenu
v-if="isLocaleMenuOpen"
:menu-items="localeMenuItems"
class="left-0 w-40 mt-2 overflow-y-auto xl:right-0 top-full max-h-60"
@action="handleLocaleAction"
/>
</OnClickOutside>
</div>
<div class="w-px h-3.5 rounded my-auto bg-slate-75 dark:bg-slate-800" />
<span class="text-sm font-medium text-slate-800 dark:text-slate-100">
{{
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.CATEGORIES_COUNT', {
n: categoriesCount,
})
}}
</span>
</div>
<Breadcrumb
v-else
:items="breadcrumbItems"
@click="handleBreadcrumbClick"
/>
<div v-if="!hasSelectedCategory" class="relative">
<OnClickOutside @trigger="isCreateCategoryDialogOpen = false">
<Button
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.NEW_CATEGORY')"
icon="add"
size="sm"
@click="isCreateCategoryDialogOpen = !isCreateCategoryDialogOpen"
/>
<CategoryDialog
v-if="isCreateCategoryDialogOpen"
mode="create"
:portal-name="currentPortalName"
:active-locale-name="activeLocaleName"
:active-locale-code="activeLocaleCode"
@close="isCreateCategoryDialogOpen = false"
/>
</OnClickOutside>
</div>
<div v-else class="relative">
<OnClickOutside @trigger="isEditCategoryDialogOpen = false">
<Button
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.EDIT_CATEGORY')"
variant="secondary"
size="sm"
@click="isEditCategoryDialogOpen = !isEditCategoryDialogOpen"
/>
<CategoryDialog
v-if="isEditCategoryDialogOpen"
:selected-category="selectedCategory"
:portal-name="currentPortalName"
:active-locale-name="activeLocaleName"
:active-locale-code="activeLocaleCode"
@close="isEditCategoryDialogOpen = false"
/>
</OnClickOutside>
</div>
</div>
</template>

View File

@@ -8,10 +8,14 @@ defineProps({
}, },
}); });
const emit = defineEmits(['click']); const emit = defineEmits(['click', 'action']);
const handleClick = id => { const handleClick = slug => {
emit('click', id); emit('click', slug);
};
const handleAction = ({ action, value, id }, category) => {
emit('action', { action, value, id, category });
}; };
</script> </script>
@@ -20,11 +24,14 @@ const handleClick = id => {
<CategoryCard <CategoryCard
v-for="category in categories" v-for="category in categories"
:id="category.id" :id="category.id"
:key="category.title" :key="category.id"
:title="category.title" :title="category.name"
:icon="category.icon"
:description="category.description" :description="category.description"
:articles-count="category.articlesCount" :articles-count="category.meta.articles_count || 0"
@click="handleClick(category.id)" :slug="category.slug"
@click="handleClick(category.slug)"
@action="handleAction($event, category)"
/> />
</ul> </ul>
</template> </template>

View File

@@ -0,0 +1,112 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { useRoute } from 'vue-router';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import CategoryForm from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryForm.vue';
const props = defineProps({
selectedCategory: {
type: Object,
default: () => ({}),
},
allowedLocales: {
type: Array,
default: () => [],
},
});
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const getters = useStoreGetters();
const dialogRef = ref(null);
const categoryFormRef = ref(null);
const isUpdatingCategory = computed(() => {
const id = props.selectedCategory?.id;
if (id) return getters['categories/uiFlags'].value(id)?.isUpdating;
return false;
});
const isInvalidForm = computed(() => {
if (!categoryFormRef.value) return false;
const { isSubmitDisabled } = categoryFormRef.value;
return isSubmitDisabled;
});
const activeLocale = computed(() => {
return props.allowedLocales.find(
locale => locale.code === route.params.locale
);
});
const activeLocaleName = computed(() => activeLocale.value?.name ?? '');
const activeLocaleCode = computed(() => activeLocale.value?.code ?? '');
const onUpdateCategory = async () => {
if (!categoryFormRef.value) return;
const { state } = categoryFormRef.value;
const { id, name, slug, icon, description } = state;
const categoryData = { name, icon, slug, description };
categoryData.id = id;
try {
const payload = {
portalSlug: route.params.portalSlug,
categoryObj: categoryData,
categoryId: id,
};
await store.dispatch(`categories/update`, payload);
const successMessage = t(
`HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.EDIT.API.SUCCESS_MESSAGE`
);
useAlert(successMessage);
dialogRef.value.close();
const trackEvent = PORTALS_EVENTS.EDIT_CATEGORY;
useTrack(trackEvent, { hasDescription: Boolean(description) });
} catch (error) {
const errorMessage =
error?.message ||
t(`HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.EDIT.API.ERROR_MESSAGE`);
useAlert(errorMessage);
}
};
// Expose the dialogRef to the parent component
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.HEADER.EDIT')"
:description="
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.HEADER.DESCRIPTION')
"
:is-loading="isUpdatingCategory"
:disable-confirm-button="isUpdatingCategory || isInvalidForm"
@confirm="onUpdateCategory"
>
<template #form>
<CategoryForm
ref="categoryFormRef"
mode="edit"
:selected-category="selectedCategory"
:active-locale-code="activeLocaleCode"
:portal-name="route.params.portalSlug"
:active-locale-name="activeLocaleName"
:show-action-buttons="false"
/>
</template>
</Dialog>
</template>

View File

@@ -0,0 +1,101 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { useRoute } from 'vue-router';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import allLocales from 'shared/constants/locales.js';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
const props = defineProps({
portal: {
type: Object,
default: () => ({}),
},
});
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const dialogRef = ref(null);
const isUpdating = ref(false);
const selectedLocale = ref('');
const addedLocales = computed(() => {
const { allowed_locales: allowedLocales = [] } = props.portal?.config || {};
return allowedLocales.map(locale => locale.code);
});
const locales = computed(() => {
return Object.keys(allLocales)
.map(key => {
return {
value: key,
label: `${allLocales[key]} (${key})`,
};
})
.filter(locale => !addedLocales.value.includes(locale.value));
});
const onCreate = async () => {
if (!selectedLocale.value) return;
isUpdating.value = true;
const updatedLocales = [...addedLocales.value, selectedLocale.value];
try {
await store.dispatch('portals/update', {
portalSlug: props.portal.slug,
config: { allowed_locales: updatedLocales },
});
useTrack(PORTALS_EVENTS.CREATE_LOCALE, {
localeAdded: selectedLocale.value,
totalLocales: updatedLocales.length,
from: route.name,
});
dialogRef.value?.close();
useAlert(
t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.API.SUCCESS_MESSAGE')
);
} catch (error) {
useAlert(
error?.message ||
t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.API.ERROR_MESSAGE')
);
} finally {
isUpdating.value = false;
}
};
// Expose the dialogRef to the parent component
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.TITLE')"
:description="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.DESCRIPTION')"
@confirm="onCreate"
>
<template #form>
<div class="flex flex-col gap-6">
<ComboBox
v-model="selectedLocale"
:options="locales"
: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"
/>
</div>
</template>
</Dialog>
</template>

View File

@@ -1,12 +1,94 @@
<script setup> <script setup>
import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue'; import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue';
import { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
defineProps({ const props = defineProps({
locales: { locales: {
type: Array, type: Array,
required: true, required: true,
}, },
portal: {
type: Object,
required: true,
},
}); });
const store = useStore();
const { t } = useI18n();
const route = useRoute();
const isLocaleDefault = code => {
return props.portal?.meta?.default_locale === code;
};
const updatePortalLocales = async ({
newAllowedLocales,
defaultLocale,
messageKey,
}) => {
let alertMessage = '';
try {
await store.dispatch('portals/update', {
portalSlug: props.portal.slug,
config: {
default_locale: defaultLocale,
allowed_locales: newAllowedLocales,
},
});
alertMessage = t(`HELP_CENTER.PORTAL.${messageKey}.API.SUCCESS_MESSAGE`);
} catch (error) {
alertMessage =
error?.message || t(`HELP_CENTER.PORTAL.${messageKey}.API.ERROR_MESSAGE`);
} finally {
useAlert(alertMessage);
}
};
const changeDefaultLocale = ({ localeCode }) => {
const newAllowedLocales = props.locales.map(locale => locale.code);
updatePortalLocales({
newAllowedLocales,
defaultLocale: localeCode,
messageKey: 'CHANGE_DEFAULT_LOCALE',
});
useTrack(PORTALS_EVENTS.SET_DEFAULT_LOCALE, {
newLocale: localeCode,
from: route.name,
});
};
const deletePortalLocale = ({ localeCode }) => {
const updatedLocales = props.locales
.filter(locale => locale.code !== localeCode)
.map(locale => locale.code);
const defaultLocale = props.portal.meta.default_locale;
updatePortalLocales({
newAllowedLocales: updatedLocales,
defaultLocale,
messageKey: 'DELETE_LOCALE',
});
useTrack(PORTALS_EVENTS.DELETE_LOCALE, {
deletedLocale: localeCode,
from: route.name,
});
};
const handleAction = ({ action }, localeCode) => {
if (action === 'change-default') {
changeDefaultLocale({ localeCode: localeCode });
} else if (action === 'delete') {
deletePortalLocale({ localeCode: localeCode });
}
};
</script> </script>
<template> <template>
@@ -15,9 +97,11 @@ defineProps({
v-for="(locale, index) in locales" v-for="(locale, index) in locales"
:key="index" :key="index"
:locale="locale.name" :locale="locale.name"
:is-default="locale.isDefault" :is-default="isLocaleDefault(locale.code)"
:article-count="locale.articleCount" :locale-code="locale.code"
:category-count="locale.categoryCount" :article-count="locale.articlesCount || 0"
:category-count="locale.categoriesCount || 0"
@action="handleAction($event, locale.code)"
/> />
</ul> </ul>
</template> </template>

View File

@@ -1,27 +1,28 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed, ref } from 'vue';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue'; import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import LocaleList from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue'; import LocaleList from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue';
import AddLocaleDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/AddLocaleDialog.vue';
const props = defineProps({ const props = defineProps({
locales: { locales: {
type: Array, type: Array,
required: true, required: true,
}, },
portal: {
type: Object,
default: () => ({}),
},
}); });
const localeCount = computed(() => props.locales?.length); const addLocaleDialogRef = ref(null);
// TODO: remove comments const openAddLocaleDialog = () => {
// eslint-disable-next-line no-unused-vars addLocaleDialogRef.value.dialogRef.open();
const handleTabChange = tab => {
// TODO: Implement tab change logic
};
// eslint-disable-next-line no-unused-vars
const handlePageChange = page => {
// TODO: Implement page change logic
}; };
const localeCount = computed(() => props.locales?.length);
</script> </script>
<template> <template>
@@ -37,11 +38,13 @@ const handlePageChange = page => {
:label="$t('HELP_CENTER.LOCALES_PAGE.NEW_LOCALE_BUTTON_TEXT')" :label="$t('HELP_CENTER.LOCALES_PAGE.NEW_LOCALE_BUTTON_TEXT')"
icon="add" icon="add"
size="sm" size="sm"
@click="openAddLocaleDialog"
/> />
</div> </div>
</template> </template>
<template #content> <template #content>
<LocaleList :locales="locales" /> <LocaleList :locales="locales" :portal="portal" />
</template> </template>
<AddLocaleDialog ref="addLocaleDialogRef" :portal="portal" />
</HelpCenterLayout> </HelpCenterLayout>
</template> </template>

View File

@@ -0,0 +1,74 @@
<script setup>
import { ref, reactive, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
const props = defineProps({
mode: {
type: String,
default: 'add',
},
customDomain: {
type: String,
default: '',
},
});
const emit = defineEmits(['addCustomDomain']);
const { t } = useI18n();
const dialogRef = ref(null);
const formState = reactive({
customDomain: props.customDomain,
});
watch(
() => props.customDomain,
newVal => {
formState.customDomain = newVal;
}
);
const handleDialogConfirm = () => {
emit('addCustomDomain', formState.customDomain);
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
:title="
t(
`HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.${props.mode.toUpperCase()}_HEADER`
)
"
:confirm-button-label="
t(
`HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.${props.mode.toUpperCase()}_CONFIRM_BUTTON_LABEL`
)
"
@confirm="handleDialogConfirm"
>
<template #form>
<Input
v-model="formState.customDomain"
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.LABEL'
)
"
:placeholder="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.PLACEHOLDER'
)
"
/>
</template>
</Dialog>
</template>

View File

@@ -0,0 +1,51 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
defineProps({
activePortalName: {
type: String,
required: true,
},
});
const emit = defineEmits(['deletePortal']);
const { t } = useI18n();
const dialogRef = ref(null);
const handleDialogConfirm = () => {
emit('deletePortal');
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="alert"
:title="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.DIALOG.HEADER',
{
portalName: activePortalName,
}
)
"
:description="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.DIALOG.DESCRIPTION'
)
"
:confirm-button-label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.DIALOG.CONFIRM_BUTTON_LABEL'
)
"
@confirm="handleDialogConfirm"
/>
</template>

View File

@@ -0,0 +1,79 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { getHostNameFromURL } from 'dashboard/helper/URLHelper';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
customDomain: {
type: String,
default: '',
},
});
const emit = defineEmits(['confirm']);
const { t } = useI18n();
const domain = computed(() => {
const { hostURL, helpCenterURL } = window?.chatwootConfig || {};
return getHostNameFromURL(helpCenterURL) || getHostNameFromURL(hostURL) || '';
});
const subdomainCNAME = computed(
() => `${props.customDomain} CNAME ${domain.value}`
);
const dialogRef = ref(null);
const handleDialogConfirm = () => {
emit('confirm');
};
defineExpose({ dialogRef });
</script>
<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"
>
<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>
<template #form>
<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>
</template>
</Dialog>
</template>

View File

@@ -0,0 +1,320 @@
<script setup>
import { reactive, watch, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { buildPortalURL } from 'dashboard/helper/portalHelper';
import { useAlert } from 'dashboard/composables';
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 Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import EditableAvatar from 'dashboard/components-next/avatar/EditableAvatar.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import ColorPicker from 'dashboard/components-next/colorpicker/ColorPicker.vue';
const props = defineProps({
activePortal: {
type: Object,
required: true,
},
isFetching: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['updatePortal']);
const { t } = useI18n();
const store = useStore();
const getters = useStoreGetters();
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
const state = reactive({
name: '',
headerText: '',
pageTitle: '',
slug: '',
widgetColor: '',
homePageLink: '',
liveChatWidgetInboxId: '',
logoUrl: '',
avatarBlobId: '',
});
const originalState = reactive({ ...state });
const liveChatWidgets = computed(() => {
const inboxes = store.getters['inboxes/getInboxes'];
return inboxes
.filter(inbox => inbox.channel_type === 'Channel::WebWidget')
.map(inbox => ({
value: inbox.id,
label: inbox.name,
}));
});
const rules = {
name: { required, minLength: minLength(2) },
slug: { required },
homePageLink: { shouldBeUrl },
};
const v$ = useVuelidate(rules, state);
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 homePageLinkError = computed(() =>
v$.value.homePageLink.$error
? t('HELP_CENTER.PORTAL_SETTINGS.FORM.HOME_PAGE_LINK.ERROR')
: ''
);
const isUpdatingPortal = computed(() => {
const slug = props.activePortal?.slug;
if (slug) return getters['portals/uiFlagsIn'].value(slug)?.isUpdating;
return false;
});
watch(
() => props.activePortal,
newVal => {
if (newVal && !props.isFetching) {
Object.assign(state, {
name: newVal.name,
headerText: newVal.header_text,
pageTitle: newVal.page_title,
widgetColor: newVal.color,
homePageLink: newVal.homepage_link,
slug: newVal.slug,
liveChatWidgetInboxId: newVal.inbox?.id,
});
if (newVal.logo) {
const {
logo: { file_url: logoURL, blob_id: blobId },
} = newVal;
state.logoUrl = logoURL;
state.avatarBlobId = blobId;
} else {
state.logoUrl = '';
state.avatarBlobId = '';
}
Object.assign(originalState, state);
}
},
{ immediate: true, deep: true }
);
const hasChanges = computed(() => {
return JSON.stringify(state) !== JSON.stringify(originalState);
});
const handleUpdatePortal = () => {
const portal = {
id: props.activePortal?.id,
slug: state.slug,
name: state.name,
color: state.widgetColor,
page_title: state.pageTitle,
header_text: state.headerText,
homepage_link: state.homePageLink,
blob_id: state.avatarBlobId,
inbox_id: state.liveChatWidgetInboxId,
};
emit('updatePortal', portal);
};
async function uploadLogoToStorage({ file }) {
try {
const { fileUrl, blobId } = await uploadFile(file);
if (fileUrl) {
state.logoUrl = fileUrl;
state.avatarBlobId = blobId;
}
useAlert(t('HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.IMAGE_UPLOAD_SUCCESS'));
} catch (error) {
useAlert(t('HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.IMAGE_UPLOAD_ERROR'));
}
}
async function deleteLogo() {
try {
const portalSlug = props.activePortal?.slug;
await store.dispatch('portals/deleteLogo', {
portalSlug,
});
useAlert(t('HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.IMAGE_DELETE_SUCCESS'));
} catch (error) {
useAlert(
error?.message ||
t('HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.IMAGE_DELETE_ERROR')
);
}
}
const handleAvatarUpload = file => {
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
uploadLogoToStorage(file);
} else {
const errorKey =
'HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.IMAGE_UPLOAD_SIZE_ERROR';
useAlert(t(errorKey, { size: MAXIMUM_FILE_UPLOAD_SIZE }));
}
};
const handleAvatarDelete = () => {
state.logoUrl = '';
state.avatarBlobId = '';
deleteLogo();
};
</script>
<template>
<div class="flex flex-col w-full gap-4">
<div class="flex flex-col w-full gap-2">
<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>
<EditableAvatar
label="Avatar"
:src="state.logoUrl"
:name="state.name"
@upload="handleAvatarUpload"
@delete="handleAvatarDelete"
/>
</div>
<div class="flex flex-col w-full gap-4">
<div class="flex items-start justify-between w-full gap-2">
<label
class="text-sm font-medium whitespace-nowrap min-w-[100px] py-2.5 text-slate-900 dark:text-slate-50"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.NAME.LABEL') }}
</label>
<Input
v-model="state.name"
:placeholder="t('HELP_CENTER.PORTAL_SETTINGS.FORM.NAME.PLACEHOLDER')"
class="w-[432px]"
:message-type="nameError ? 'error' : 'info'"
:message="nameError"
custom-input-class="!bg-transparent dark:!bg-transparent"
@input="v$.name.$touch()"
@blur="v$.name.$touch()"
/>
</div>
<div class="flex items-start justify-between w-full gap-2">
<label
class="text-sm font-medium whitespace-nowrap min-w-[100px] py-2.5 text-slate-900 dark:text-slate-50"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.HEADER_TEXT.LABEL') }}
</label>
<Input
v-model="state.headerText"
:placeholder="
t('HELP_CENTER.PORTAL_SETTINGS.FORM.HEADER_TEXT.PLACEHOLDER')
"
class="w-[432px]"
custom-input-class="!bg-transparent dark:!bg-transparent"
/>
</div>
<div class="flex items-start justify-between w-full gap-2">
<label
class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 py-2.5 dark:text-slate-50"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.PAGE_TITLE.LABEL') }}
</label>
<Input
v-model="state.pageTitle"
:placeholder="
t('HELP_CENTER.PORTAL_SETTINGS.FORM.PAGE_TITLE.PLACEHOLDER')
"
class="w-[432px]"
custom-input-class="!bg-transparent dark:!bg-transparent"
/>
</div>
<div class="flex items-start justify-between w-full gap-2">
<label
class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 py-2.5 dark:text-slate-50"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.HOME_PAGE_LINK.LABEL') }}
</label>
<Input
v-model="state.homePageLink"
:placeholder="
t('HELP_CENTER.PORTAL_SETTINGS.FORM.HOME_PAGE_LINK.PLACEHOLDER')
"
class="w-[432px]"
:message-type="homePageLinkError ? 'error' : 'info'"
:message="homePageLinkError"
custom-input-class="!bg-transparent dark:!bg-transparent"
@input="v$.homePageLink.$touch()"
@blur="v$.homePageLink.$touch()"
/>
</div>
<div class="flex items-start justify-between w-full gap-2">
<label
class="text-sm font-medium whitespace-nowrap min-w-[100px] py-2.5 text-slate-900 dark:text-slate-50"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.SLUG.LABEL') }}
</label>
<Input
v-model="state.slug"
:placeholder="t('HELP_CENTER.PORTAL_SETTINGS.FORM.SLUG.PLACEHOLDER')"
class="w-[432px]"
:message-type="slugError ? 'error' : 'info'"
:message="slugError || buildPortalURL(state.slug)"
custom-input-class="!bg-transparent dark:!bg-transparent"
@input="v$.slug.$touch()"
@blur="v$.slug.$touch()"
/>
</div>
<div class="flex items-start justify-between w-full gap-2">
<label
class="text-sm font-medium whitespace-nowrap min-w-[100px] py-2.5 text-slate-900 dark:text-slate-50"
>
{{ 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="[&>button]:w-[432px] !w-[432px]"
/>
</div>
<div class="flex items-start justify-between w-full gap-2">
<label
class="text-sm font-medium whitespace-nowrap min-w-[100px] py-2.5 text-slate-900 dark:text-slate-50"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.BRAND_COLOR.LABEL') }}
</label>
<div class="w-[432px] justify-start">
<ColorPicker v-model="state.widgetColor" />
</div>
</div>
<div class="flex justify-end w-full gap-2">
<Button
:label="t('HELP_CENTER.PORTAL_SETTINGS.FORM.SAVE_CHANGES')"
:disabled="!hasChanges || isUpdatingPortal || v$.$invalid"
:is-loading="isUpdatingPortal"
@click="handleUpdatePortal"
/>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,118 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import AddCustomDomainDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/AddCustomDomainDialog.vue';
import DNSConfigurationDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/DNSConfigurationDialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
activePortal: {
type: Object,
required: true,
},
});
const emit = defineEmits(['updatePortalConfiguration']);
const { t } = useI18n();
const addCustomDomainDialogRef = ref(null);
const dnsConfigurationDialogRef = ref(null);
const updatedDomainAddress = ref('');
const customDomainAddress = computed(
() => props.activePortal?.custom_domain || ''
);
const updatePortalConfiguration = customDomain => {
const portal = {
id: props.activePortal?.id,
custom_domain: customDomain,
};
emit('updatePortalConfiguration', portal);
addCustomDomainDialogRef.value.dialogRef.close();
if (customDomain) {
updatedDomainAddress.value = customDomain;
dnsConfigurationDialogRef.value.dialogRef.open();
}
};
const closeDNSConfigurationDialog = () => {
updatedDomainAddress.value = '';
dnsConfigurationDialogRef.value.dialogRef.close();
};
</script>
<template>
<div class="flex flex-col w-full gap-6">
<div class="flex flex-col gap-2">
<h6 class="text-base font-medium text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.HEADER'
)
}}
</h6>
<span class="text-sm text-n-slate-11">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DESCRIPTION'
)
}}
</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">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.LABEL'
)
}}
</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"
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.EDIT_BUTTON'
)
"
variant="secondary"
@click="addCustomDomainDialogRef.dialogRef.open()"
/>
<Button
v-else
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.ADD_BUTTON'
)
"
variant="secondary"
@click="addCustomDomainDialogRef.dialogRef.open()"
/>
</div>
</div>
</div>
<AddCustomDomainDialog
ref="addCustomDomainDialogRef"
:mode="customDomainAddress ? 'edit' : 'add'"
:custom-domain="customDomainAddress"
@add-custom-domain="updatePortalConfiguration"
/>
<DNSConfigurationDialog
ref="dnsConfigurationDialogRef"
:custom-domain="updatedDomainAddress || customDomainAddress"
@confirm="closeDNSConfigurationDialog"
/>
</div>
</template>

View File

@@ -1,113 +1,130 @@
<script setup> <script setup>
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue'; import { computed, ref } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue'; import { useRoute } from 'vue-router';
import Input from 'dashboard/components-next/input/Input.vue'; import { useI18n } from 'vue-i18n';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue'; import { useMapGetter } from 'dashboard/composables/store.js';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const handleUploadAvatar = () => {}; import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import PortalBaseSettings from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue';
import PortalConfigurationSettings from './PortalConfigurationSettings.vue';
import ConfirmDeletePortalDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/ConfirmDeletePortalDialog.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
portals: {
type: Array,
required: true,
},
isFetching: {
type: Boolean,
required: true,
},
});
const emit = defineEmits([
'updatePortal',
'updatePortalConfiguration',
'deletePortal',
]);
const { t } = useI18n();
const route = useRoute();
const confirmDeletePortalDialogRef = ref(null);
const currentPortalSlug = computed(() => route.params.portalSlug);
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
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 => {
emit('updatePortal', portal);
};
const handleUpdatePortalConfiguration = portal => {
emit('updatePortalConfiguration', portal);
};
const openConfirmDeletePortalDialog = () => {
confirmDeletePortalDialogRef.value.dialogRef.open();
};
const handleDeletePortal = () => {
emit('deletePortal', activePortal.value);
confirmDeletePortalDialogRef.value.dialogRef.close();
};
</script> </script>
<!-- TODO: Add i18n -->
<!-- eslint-disable vue/no-bare-strings-in-template -->
<template> <template>
<HelpCenterLayout :show-pagination-footer="false"> <HelpCenterLayout :show-pagination-footer="false">
<template #content> <template #content>
<div class="flex flex-col w-full gap-10 max-w-[640px] pt-2 pb-8"> <div
<div class="flex flex-col w-full gap-4"> v-if="isLoading"
<div class="flex flex-col w-full gap-2"> class="flex items-center justify-center py-10 pt-2 pb-8 text-n-slate-11"
<label >
class="mb-0.5 text-sm font-medium text-gray-900 dark:text-gray-50" <Spinner />
> </div>
Avatar <div
</label> v-else-if="activePortal"
<Avatar class="flex flex-col w-full gap-4 max-w-[640px] pb-8"
label="Avatar" >
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Amaya" <PortalBaseSettings
class="bg-ruby-300 dark:bg-ruby-400" :active-portal="activePortal"
@upload="handleUploadAvatar" :is-fetching="isFetching"
/> @update-portal="handleUpdatePortal"
</div> />
<div class="flex flex-col w-full gap-2"> <div class="w-full h-px bg-slate-50 dark:bg-slate-800/50" />
<div class="flex justify-between w-full h-10 gap-2 py-1"> <PortalConfigurationSettings
<label :active-portal="activePortal"
class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 dark:text-slate-50" :is-fetching="isFetching"
> @update-portal-configuration="handleUpdatePortalConfiguration"
Name />
</label> <div class="w-full h-px bg-slate-50 dark:bg-slate-800/50" />
<Input placeholder="Name" class="w-[432px]" /> <div class="flex items-end justify-between w-full gap-4">
</div> <div class="flex flex-col gap-2">
<div class="flex justify-between w-full h-10 gap-2 py-1"> <h6 class="text-base font-medium text-n-slate-12">
<label {{
class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 dark:text-slate-50" t(
> 'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.HEADER'
Header text )
</label> }}
<Input placeholder="Header text" class="w-[432px]" />
</div>
<div class="flex justify-between w-full h-10 gap-2 py-1">
<label
class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 dark:text-slate-50"
>
Page title
</label>
<Input placeholder="Page title" class="w-[432px]" />
</div>
<div class="flex justify-between w-full h-10 gap-2 py-1">
<label
class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 dark:text-slate-50"
>
Widget color
</label>
</div>
<div class="flex justify-end w-full gap-2 py-2">
<Button label="Save changes" size="sm" />
</div>
</div>
<div class="w-full h-px bg-slate-50 dark:bg-slate-800/50" />
</div>
<div class="flex flex-col w-full gap-6">
<div class="flex flex-col w-full gap-6">
<h6 class="text-base font-medium text-slate-900 dark:text-slate-50">
Configuration
</h6> </h6>
<div class="flex flex-col w-full gap-4"> <span class="text-sm text-n-slate-11">
<div class="flex justify-between w-full gap-2 py-1"> {{
<InlineInput t(
placeholder="Slug" 'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.DESCRIPTION'
label="Slug:" )
custom-label-class="min-w-[100px]" }}
custom-input-class="!w-[430px]" </span>
/>
</div>
<div class="flex justify-between w-full gap-2 py-1">
<InlineInput
placeholder="Custom domain"
label="Custom domain:"
custom-label-class="min-w-[100px]"
custom-input-class="!w-[430px]"
/>
</div>
<div class="flex justify-between w-full gap-2 py-1">
<InlineInput
placeholder="Home page link"
label="Home page link:"
custom-label-class="min-w-[100px]"
custom-input-class="!w-[430px]"
/>
</div>
</div>
</div>
<div class="flex justify-end w-full gap-3 py-4">
<Button label="Edit configuration" size="sm" variant="secondary" />
<Button
label="Delete Test-Help Center"
size="sm"
variant="destructive"
/>
</div> </div>
<Button
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.BUTTON',
{
portalName: activePortalName,
}
)
"
variant="destructive"
class="w-56"
@click="openConfirmDeletePortalDialog"
/>
</div> </div>
</div> </div>
</template> </template>
<ConfirmDeletePortalDialog
ref="confirmDeletePortalDialogRef"
:active-portal-name="activePortalName"
@delete-portal="handleDeletePortal"
/>
</HelpCenterLayout> </HelpCenterLayout>
</template> </template>

View File

@@ -0,0 +1,148 @@
<script setup>
import { ref, reactive, watch, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
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 { buildPortalURL } from 'dashboard/helper/portalHelper';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
const emit = defineEmits(['create']);
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const isCreatingPortal = useMapGetter('portals/isCreatingPortal');
const state = reactive({
name: '',
slug: '',
domain: '',
logoUrl: '',
avatarBlobId: '',
});
const rules = {
name: { required, minLength: minLength(2) },
slug: { required },
};
const v$ = useVuelidate(rules, state);
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 isSubmitDisabled = computed(() => v$.value.$invalid);
watch(
() => state.name,
() => {
state.slug = convertToCategorySlug(state.name);
}
);
const redirectToPortal = portal => {
emit('create', { slug: portal.slug, locale: 'en' });
};
const resetForm = () => {
Object.keys(state).forEach(key => {
state[key] = '';
});
v$.value.$reset();
};
const createPortal = async portal => {
try {
await store.dispatch('portals/create', portal);
dialogRef.value.close();
const analyticsPayload = {
has_custom_domain: Boolean(portal.custom_domain),
};
useTrack(PORTALS_EVENTS.ONBOARD_BASIC_INFORMATION, analyticsPayload);
useTrack(PORTALS_EVENTS.CREATE_PORTAL, analyticsPayload);
useAlert(
t('HELP_CENTER.PORTAL_SETTINGS.API.CREATE_PORTAL.SUCCESS_MESSAGE')
);
resetForm();
redirectToPortal(portal);
} catch (error) {
dialogRef.value.close();
useAlert(
error?.message ||
t('HELP_CENTER.PORTAL_SETTINGS.API.CREATE_PORTAL.ERROR_MESSAGE')
);
}
};
const handleDialogConfirm = async () => {
const isFormCorrect = await v$.value.$validate();
if (!isFormCorrect) return;
const portal = {
name: state.name,
slug: state.slug,
custom_domain: state.domain,
blob_id: state.avatarBlobId || null,
color: '#2781F6', // The default color is set to Chatwoot brand color
};
await createPortal(portal);
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="t('HELP_CENTER.CREATE_PORTAL_DIALOG.TITLE')"
:confirm-button-label="
t('HELP_CENTER.CREATE_PORTAL_DIALOG.CONFIRM_BUTTON_LABEL')
"
:description="t('HELP_CENTER.CREATE_PORTAL_DIALOG.DESCRIPTION')"
:disable-confirm-button="isSubmitDisabled || isCreatingPortal"
:is-loading="isCreatingPortal"
@confirm="handleDialogConfirm"
>
<template #form>
<div class="flex flex-col gap-6">
<Input
id="portal-name"
v-model="state.name"
type="text"
:placeholder="t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.PLACEHOLDER')"
:label="t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.LABEL')"
:message-type="nameError ? 'error' : 'info'"
:message="
nameError || t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.MESSAGE')
"
/>
<Input
id="portal-slug"
v-model="state.slug"
type="text"
:placeholder="t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.PLACEHOLDER')"
:label="t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.LABEL')"
:message-type="slugError ? 'error' : 'info'"
:message="slugError || buildPortalURL(state.slug)"
/>
</div>
</template>
</Dialog>
</template>

View File

@@ -1,111 +1,143 @@
<script setup> <script setup>
import { ref } from 'vue'; import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
defineProps({ const emit = defineEmits(['close', 'createPortal']);
portals: {
type: Array,
default: () => [
{
id: 1,
name: 'Chatwoot Help Center',
articles: 67,
domain: 'chatwoot.help',
slug: 'help-center',
},
{
id: 2,
name: 'Chatwoot Handbook',
articles: 42,
domain: 'chatwoot.help',
slug: 'handbook',
},
],
},
header: {
type: String,
default: 'Portals',
},
description: {
type: String,
default: 'Create and manage multiple portals',
},
});
const selectedPortal = ref(1); const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const store = useStore();
const handlePortalChange = id => { const DEFAULT_ROUTE = 'portals_articles_index';
selectedPortal.value = id; const CATEGORY_ROUTE = 'portals_categories_index';
const CATEGORY_SUB_ROUTES = [
'portals_categories_articles_index',
'portals_categories_articles_edit',
];
const portals = useMapGetter('portals/allPortals');
const currentPortalSlug = computed(() => route.params.portalSlug);
const isPortalActive = portal => {
return portal.slug === currentPortalSlug.value;
};
const getPortalThumbnailSrc = portal => {
return portal?.logo?.file_url || '';
};
const fetchPortalAndItsCategories = async (slug, locale) => {
await store.dispatch('portals/switchPortal', true);
await store.dispatch('portals/index');
const selectedPortalParam = {
portalSlug: slug,
locale,
};
await store.dispatch('portals/show', selectedPortalParam);
await store.dispatch('categories/index', selectedPortalParam);
await store.dispatch('agents/get');
await store.dispatch('portals/switchPortal', false);
};
const handlePortalChange = async portal => {
if (isPortalActive(portal)) return;
const {
slug,
meta: { default_locale: defaultLocale },
} = portal;
emit('close');
await fetchPortalAndItsCategories(slug, defaultLocale);
const targetRouteName = CATEGORY_SUB_ROUTES.includes(route.name)
? CATEGORY_ROUTE
: route.name || DEFAULT_ROUTE;
router.push({
name: targetRouteName,
params: {
portalSlug: slug,
locale: defaultLocale,
},
});
};
const openCreatePortalDialog = () => {
emit('createPortal');
emit('close');
};
const redirectToPortalHomePage = () => {
router.push({
name: 'portals_index',
params: {
navigationPath: DEFAULT_ROUTE,
},
});
}; };
</script> </script>
<!-- TODO: Add i18n -->
<!-- eslint-disable vue/no-bare-strings-in-template -->
<template> <template>
<div <div
class="pt-5 pb-3 bg-white z-50 dark:bg-slate-800 absolute w-[440px] rounded-xl shadow-md flex flex-col gap-4" class="pt-5 pb-3 bg-n-alpha-3 backdrop-blur-[100px] z-50 absolute w-[440px] rounded-xl shadow-md flex flex-col gap-4"
> >
<div class="flex items-center justify-between gap-4 px-6 pb-2"> <div
class="flex items-center justify-between gap-4 px-6 pb-3 border-b border-n-alpha-2"
>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<h2 class="text-base font-medium text-slate-900 dark:text-slate-50"> <h2
{{ header }} class="text-base font-medium cursor-pointer text-slate-900 dark:text-slate-50 w-fit hover:underline"
@click="redirectToPortalHomePage"
>
{{ t('HELP_CENTER.PORTAL_SWITCHER.PORTALS') }}
</h2> </h2>
<p class="text-sm text-slate-600 dark:text-slate-300"> <p class="text-sm text-slate-600 dark:text-slate-300">
{{ description }} {{ t('HELP_CENTER.PORTAL_SWITCHER.CREATE_PORTAL') }}
</p> </p>
</div> </div>
<Button label="New portal" variant="secondary" icon="add" size="sm" /> <Button
:label="t('HELP_CENTER.PORTAL_SWITCHER.NEW_PORTAL')"
variant="secondary"
icon="add"
size="sm"
class="!bg-n-alpha-2 hover:!bg-n-alpha-3"
@click="openCreatePortalDialog"
/>
</div> </div>
<div v-if="portals.length > 0" class="flex flex-col gap-3"> <div v-if="portals.length > 0" class="flex flex-col gap-2 px-4">
<template v-for="(portal, index) in portals" :key="portal.id"> <Button
<div class="flex flex-col gap-2 px-6 py-2"> v-for="(portal, index) in portals"
<div class="flex items-center justify-between"> :key="index"
<div class="flex items-center"> :label="portal.name"
<input variant="ghost"
:id="portal.id" :icon="isPortalActive(portal) ? 'checkmark-lucide' : ''"
v-model="selectedPortal" icon-lib="lucide"
type="radio" icon-position="right"
:value="portal.id" class="!justify-start !px-2 !py-2 hover:!bg-n-alpha-2 [&>svg]:text-n-teal-10 [&>svg]:w-5 [&>svg]:h-5 h-9"
class="mr-3" size="sm"
@change="handlePortalChange(portal.id)" @click="handlePortalChange(portal)"
/> >
<label <template #leftPrefix>
:for="portal.id" <Thumbnail
class="text-sm font-medium text-slate-900 dark:text-slate-100" v-if="portal"
> :author="portal"
{{ portal.name }} :name="portal.name"
</label> :size="20"
</div> :src="getPortalThumbnailSrc(portal)"
<div class="w-4 h-4 rounded-full bg-slate-100 dark:bg-slate-700" /> :show-author-name="false"
</div> icon-name="building-lucide"
<div class="inline-flex items-center gap-2 py-1 text-sm"> />
<span class="text-slate-600 dark:text-slate-400"> </template>
articles: <template #rightPrefix>
<span class="text-slate-800 dark:text-slate-200"> <span class="text-sm truncate text-n-slate-11">
{{ portal.articles }} {{ portal.custom_domain || '' }}
</span> </span>
</span> </template>
<div class="w-px h-3 bg-slate-50 dark:bg-slate-700" /> </Button>
<span class="text-slate-600 dark:text-slate-400">
domain:
<span class="text-slate-800 dark:text-slate-200">
{{ portal.domain }}
</span>
</span>
<div class="w-px h-3 bg-slate-50 dark:bg-slate-700" />
<span class="text-slate-600 dark:text-slate-400">
slug:
<span class="text-slate-800 dark:text-slate-200">
{{ portal.slug }}
</span>
</span>
</div>
</div>
<div
v-if="index < portals.length - 1 && portals.length > 1"
class="w-full h-px bg-slate-50 dark:bg-slate-700/50"
/>
</template>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -24,14 +24,14 @@ import Avatar from './Avatar.vue';
</Variant> </Variant>
<Variant title="Invalid or empty SRC"> <Variant title="Invalid or empty SRC">
<div class="p-4 bg-white dark:bg-slate-900 space-x-4"> <div class="p-4 space-x-4 bg-white dark:bg-slate-900">
<Avatar src="https://example.com/ruby.png" name="Ruby" allow-upload /> <Avatar src="https://example.com/ruby.png" name="Ruby" allow-upload />
<Avatar name="Bruce Wayne" allow-upload /> <Avatar name="Bruce Wayne" allow-upload />
</div> </div>
</Variant> </Variant>
<Variant title="Rounded Full"> <Variant title="Rounded Full">
<div class="p-4 bg-white dark:bg-slate-900 space-x-4"> <div class="p-4 space-x-4 bg-white dark:bg-slate-900">
<Avatar <Avatar
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Amaya" src="https://api.dicebear.com/9.x/thumbs/svg?seed=Amaya"
allow-upload allow-upload

View File

@@ -0,0 +1,36 @@
<script setup>
import EditableAvatar from './EditableAvatar.vue';
</script>
<template>
<Story title="Components/Avatar" :layout="{ type: 'grid', width: '400' }">
<Variant title="Default">
<div class="p-4 bg-white dark:bg-slate-900">
<EditableAvatar
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Amaya"
class="bg-ruby-300 dark:bg-ruby-900"
/>
</div>
</Variant>
<Variant title="Different Sizes">
<div class="flex flex-wrap gap-4 p-4 bg-white dark:bg-slate-900">
<EditableAvatar
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Felix"
:size="48"
class="bg-green-300 dark:bg-green-900"
/>
<EditableAvatar
:size="72"
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Jade"
class="bg-indigo-300 dark:bg-indigo-900"
/>
<EditableAvatar
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Emery"
:size="96"
class="bg-woot-300 dark:bg-woot-900"
/>
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,107 @@
<script setup>
import { computed, ref } from 'vue';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
const props = defineProps({
src: {
type: String,
default: '',
},
size: {
type: Number,
default: 72,
},
name: {
type: String,
default: '',
},
});
const emit = defineEmits(['upload', 'delete']);
const avatarSize = computed(() => `${props.size}px`);
const iconSize = computed(() => `${props.size / 2}px`);
const fileInput = ref(null);
const imgError = ref(false);
const shouldShowImage = computed(() => props.src && !imgError.value);
const handleUploadAvatar = () => {
fileInput.value.click();
};
const handleImageUpload = event => {
const [file] = event.target.files;
if (file) {
emit('upload', {
file,
url: file ? URL.createObjectURL(file) : null,
});
}
};
const handleDeleteAvatar = () => {
if (fileInput.value) {
fileInput.value.value = null;
}
emit('delete');
};
const handleDismiss = event => {
event.stopPropagation();
handleDeleteAvatar();
};
</script>
<template>
<div
class="relative flex flex-col items-center gap-2 select-none rounded-xl group/avatar"
:style="{ width: avatarSize, height: avatarSize }"
>
<img
v-if="shouldShowImage"
:src="src"
:alt="name || 'avatar'"
class="object-cover w-full h-full shadow-sm rounded-xl"
@error="imgError = true"
/>
<div
v-else
class="flex items-center justify-center w-full h-full rounded-xl bg-n-alpha-2"
>
<FluentIcon
icon="building-lucide"
icon-lib="lucide"
:size="iconSize"
class="dark:text-n-brand/50 text-n-brand/30"
/>
</div>
<div
v-if="src"
class="absolute z-20 flex items-center cursor-pointer justify-center w-6 h-6 transition-all invisible opacity-0 duration-500 ease-in-out -top-2.5 -right-2.5 rounded-xl bg-n-solid-3 group-hover/avatar:visible group-hover/avatar:opacity-100"
@click="handleDismiss"
>
<FluentIcon icon="dismiss" :size="16" class="text-n-slate-11" />
</div>
<div
class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-500 ease-in-out opacity-0 rounded-xl bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100"
@click="handleUploadAvatar"
>
<FluentIcon
icon="upload-lucide"
icon-lib="lucide"
:size="iconSize"
class="text-white dark:text-white"
/>
<input
ref="fileInput"
type="file"
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
class="hidden"
@change="handleImageUpload"
/>
</div>
</div>
</template>

View File

@@ -10,14 +10,14 @@ const twoItems = ref([
const threeItems = ref([ const threeItems = ref([
{ label: 'Home', link: '#' }, { label: 'Home', link: '#' },
{ label: 'Categories', link: '#' }, { label: 'Categories', link: '#' },
{ label: 'Marketing', count: 6 }, { label: 'Marketing', count: 6, emoji: '📊' },
]); ]);
const longBreadcrumb = ref([ const longBreadcrumb = ref([
{ label: 'Home', link: '#' }, { label: 'Home', link: '#' },
{ label: 'Categories', link: '#' }, { label: 'Categories', link: '#', emoji: '📁' },
{ label: 'Marketing', link: '#' }, { label: 'Marketing', link: '#' },
{ label: 'Digital', link: '#' }, { label: 'Digital', link: '#', emoji: '💻' },
{ label: 'Social Media', count: 12 }, { label: 'Social Media', count: 12, emoji: '📱' },
]); ]);
</script> </script>

View File

@@ -1,6 +1,8 @@
<script setup> <script setup>
import { defineProps } from 'vue'; import { defineProps } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue'; import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
defineProps({ defineProps({
@@ -16,44 +18,43 @@ defineProps({
); );
}, },
}, },
countLabel: {
type: String,
default: '',
},
}); });
const emit = defineEmits(['click']);
const { t } = useI18n(); const { t } = useI18n();
const onClick = event => {
emit('click', event);
};
</script> </script>
<template> <template>
<nav :aria-label="t('BREADCRUMB.ARIA_LABEL')" class="flex items-center h-8"> <nav :aria-label="t('BREADCRUMB.ARIA_LABEL')" class="flex items-center h-8">
<ol class="flex items-center mb-0"> <ol class="flex items-center mb-0">
<li <li v-for="(item, index) in items" :key="index" class="flex items-center">
v-for="(item, index) in items" <Button
:key="index" v-if="index === 0"
class="flex items-center gap-3" :label="item.label"
> variant="link"
<template v-if="index === items.length - 1"> text-variant="info"
<span class="text-sm text-slate-900 dark:text-slate-50"> class="!p-0 text-sm !font-normal hover:!no-underline max-w-56 !text-slate-300 dark:!text-slate-500 hover:!text-slate-700 dark:hover:!text-slate-100"
{{ size="sm"
`${item.label}${item.count ? ` (${item.count} ${countLabel})` : ''}` @click="onClick"
}} />
<template v-else>
<FluentIcon
icon="chevron-lucide-right"
size="18"
icon-lib="lucide"
class="flex-shrink-0 mx-2 text-slate-300 dark:text-slate-500"
/>
<span
class="text-sm truncate text-slate-900 dark:text-slate-50 max-w-56"
>
{{ item.emoji ? item.emoji : '' }} {{ item.label }}
</span> </span>
</template> </template>
<a
v-else
:href="item.link"
class="text-sm transition-colors duration-200 text-slate-300 dark:text-slate-500 hover:text-slate-700 dark:hover:text-slate-100"
>
{{ item.label }}
</a>
<FluentIcon
v-if="index < items.length - 1"
icon="chevron-lucide-right"
size="18"
icon-lib="lucide"
class="flex-shrink-0 text-slate-300 dark:text-slate-500 ltr:mr-3 rtl:mr-0 rtl:ml-3"
/>
</li> </li>
</ol> </ol>
</nav> </nav>

View File

@@ -100,7 +100,7 @@ import Button from './Button.vue';
icon-position="left" icon-position="left"
size="sm" size="sm"
/> />
<Button icon="emoji-add" size="icon" /> <Button icon="emoji-add" size="sm" />
</div> </div>
</Variant> </Variant>
@@ -119,7 +119,7 @@ import Button from './Button.vue';
icon-position="right" icon-position="right"
size="sm" size="sm"
/> />
<Button icon="emoji-add" size="icon" /> <Button icon="emoji-add" size="sm" />
</div> </div>
</Variant> </Variant>
</Story> </Story>

View File

@@ -1,6 +1,8 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue'; import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({ const props = defineProps({
label: { label: {
@@ -29,12 +31,21 @@ const props = defineProps({
size: { size: {
type: String, type: String,
default: 'default', default: 'default',
validator: value => ['default', 'sm', 'lg', 'icon'].includes(value), validator: value => ['default', 'sm', 'lg'].includes(value),
},
type: {
type: String,
default: 'button',
validator: value => ['button', 'submit', 'reset'].includes(value),
}, },
icon: { icon: {
type: String, type: String,
default: '', default: '',
}, },
emoji: {
type: String,
default: '',
},
iconPosition: { iconPosition: {
type: String, type: String,
default: 'left', default: 'left',
@@ -44,6 +55,10 @@ const props = defineProps({
type: String, type: String,
default: 'fluent', default: 'fluent',
}, },
isLoading: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits(['click']); const emit = defineEmits(['click']);
@@ -51,33 +66,28 @@ const emit = defineEmits(['click']);
const buttonVariants = { const buttonVariants = {
variant: { variant: {
default: default:
'bg-woot-500 dark:bg-woot-500 text-white dark:text-white hover:bg-woot-600 dark:hover:bg-woot-600', 'bg-n-brand text-white dark:text-white hover:bg-woot-600 dark:hover:bg-woot-600',
destructive: destructive: 'bg-n-ruby-9 text-white dark:text-white hover:bg-n-ruby-10',
'bg-ruby-700 dark:bg-ruby-700 text-white dark:text-white hover:bg-ruby-800 dark:hover:bg-ruby-800',
outline: outline:
'border border-slate-200 dark:border-slate-700/50 hover:border-slate-300 dark:hover:border-slate-600', 'border border-n-weak dark:border-n-weak hover:border-n-slate-6 dark:hover:border-n-slate-6',
secondary: secondary: 'bg-n-solid-3 text-n-slate-12 hover:bg-n-solid-2',
'bg-slate-50 text-slate-900 dark:bg-slate-700/50 dark:text-slate-100 hover:bg-slate-100 dark:hover:bg-slate-600', ghost: 'text-n-slate-12',
ghost: link: 'text-n-brand underline-offset-4 hover:underline dark:hover:underline',
'text-slate-900 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800',
link: 'text-woot-500 underline-offset-4 hover:underline dark:hover:underline',
}, },
size: { size: {
default: 'h-10 px-4 py-2', default: 'h-10 px-4 py-2',
sm: 'h-8 px-3', sm: 'h-8 px-3 py-1',
lg: 'h-11 px-4', lg: 'h-12 px-5 py-3',
icon: 'h-auto w-auto px-2',
}, },
text: { text: {
default: default:
'!text-woot-500 dark:!text-woot-500 hover:!text-woot-600 dark:hover:!text-woot-600', '!text-n-brand dark:!text-n-brand hover:!text-woot-600 dark:hover:!text-woot-600',
success: success:
'!text-green-500 dark:!text-green-500 hover:!text-green-600 dark:hover:!text-green-600', '!text-green-500 dark:!text-green-500 hover:!text-green-600 dark:hover:!text-green-600',
warning: warning:
'!text-amber-600 dark:!text-amber-600 hover:!text-amber-600 dark:hover:!text-amber-600', '!text-amber-600 dark:!text-amber-600 hover:!text-amber-600 dark:hover:!text-amber-600',
danger: danger: '!text-n-ruby-11 hover:!text-n-ruby-10',
'!text-ruby-700 dark:!text-ruby-700 hover:!text-ruby-800 dark:hover:!text-ruby-800', info: '!text-n-slate-12 hover:!text-n-slate-11',
info: '!text-slate-500 dark:!text-slate-400 hover:!text-slate-600 dark:hover:!text-slate-500',
}, },
}; };
@@ -100,35 +110,43 @@ const iconSize = computed(() => {
return 18; return 18;
}); });
const handleClick = () => { const handleClick = e => {
emit('click'); emit('click', e);
}; };
</script> </script>
<template> <template>
<button <button
:class="buttonClasses" :class="buttonClasses"
class="inline-flex items-center justify-center h-10 min-w-0 gap-2 text-sm font-medium transition-all duration-200 ease-in-out rounded-lg disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50" :type="type"
class="inline-flex items-center justify-center min-w-0 gap-2 text-sm font-medium transition-all duration-200 ease-in-out rounded-lg disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
@click="handleClick" @click="handleClick"
> >
<FluentIcon <FluentIcon
v-if="icon && iconPosition === 'left'" v-if="icon && iconPosition === 'left' && !isLoading"
:icon="icon" :icon="icon"
:size="iconSize" :size="iconSize"
:icon-lib="iconLib" :icon-lib="iconLib"
class="flex-shrink-0" class="flex-shrink-0"
:class="{
'text-n-slate-11 dark:text-n-slate-11': variant === 'secondary',
}"
/> />
<slot> <Spinner v-if="isLoading" class="!w-5 !h-5 flex-shrink-0" />
<span v-if="label" class="min-w-0 truncate"> <slot name="leftPrefix" />
{{ label }} <span v-if="emoji">{{ emoji }}</span>
</span> <span v-if="label" class="min-w-0 truncate">{{ label }}</span>
</slot> <slot />
<slot name="rightPrefix" />
<FluentIcon <FluentIcon
v-if="icon && iconPosition === 'right'" v-if="icon && iconPosition === 'right'"
:icon="icon" :icon="icon"
:size="iconSize" :size="iconSize"
:icon-lib="iconLib" :icon-lib="iconLib"
class="flex-shrink-0" class="flex-shrink-0"
:class="{
'text-n-slate-11 dark:text-n-slate-11': variant === 'secondary',
}"
/> />
</button> </button>
</template> </template>

View File

@@ -0,0 +1,102 @@
<script setup>
import { ref, defineProps, defineEmits } from 'vue';
import { Chrome } from '@lk77/vue3-color';
import { OnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
modelValue: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:modelValue']);
const isPickerOpen = ref(false);
const toggleColorPicker = () => {
isPickerOpen.value = !isPickerOpen.value;
};
const closeTogglePicker = () => {
if (isPickerOpen.value) {
toggleColorPicker();
}
};
const updateColor = e => {
emit('update:modelValue', e.hex);
};
const pickerRef = ref(null);
</script>
<template>
<div ref="pickerRef" class="relative w-fit">
<OnClickOutside @trigger="closeTogglePicker">
<Button
:label="modelValue"
variant="secondary"
icon-lib="lucide"
icon-position="right"
icon="pipette-lucide"
class="!px-3 !py-3 [&>svg]:w-4 [&>svg]:h-4"
@click="toggleColorPicker"
>
<template #leftPrefix>
<div
class="w-4 h-4 rounded-sm"
:style="{ backgroundColor: modelValue }"
/>
</template>
</Button>
<Chrome
v-if="isPickerOpen"
disable-alpha
:model-value="modelValue"
class="colorpicker--chrome"
@update:model-value="updateColor"
/>
</OnClickOutside>
</div>
</template>
<style scoped lang="scss">
.colorpicker--chrome.vc-chrome {
@apply shadow-lg absolute bg-n-background z-[9999] border border-n-weak dark:border-n-weak rounded-[8px];
:deep() {
.vc-chrome-saturation-wrap {
@apply rounded-t-[7px];
.vc-saturation {
@apply rounded-t-[8px];
}
}
.vc-chrome-body {
@apply rounded-b-[7px] bg-n-alpha-3;
.vc-chrome-toggle-btn {
.vc-chrome-toggle-icon svg {
@apply [&>path]:fill-n-slate-10 dark:[&>path]:fill-n-slate-10 left-3 relative;
}
.vc-chrome-toggle-icon-highlight {
@apply bg-n-background;
}
}
}
input,
.vc-input__input {
@apply bg-n-background text-slate-900 dark:text-slate-50 rounded-md shadow-none;
}
.vc-input__label {
@apply text-n-slate-11 dark:text-n-slate-11;
}
}
}
</style>

View File

@@ -1,6 +1,6 @@
<script setup> <script setup>
import { nextTick, ref, computed, watch } from 'vue'; import { ref, computed, watch, nextTick } from 'vue';
import { onClickOutside } from '@vueuse/core'; import { OnClickOutside } from '@vueuse/components';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue'; import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
@@ -32,6 +32,10 @@ const props = defineProps({
type: String, type: String,
default: '', default: '',
}, },
message: {
type: String,
default: '',
},
}); });
const emit = defineEmits(['update:modelValue']); const emit = defineEmits(['update:modelValue']);
@@ -80,10 +84,6 @@ watch(
selectedValue.value = newValue; selectedValue.value = newValue;
} }
); );
onClickOutside(comboboxRef, () => {
open.value = false;
});
</script> </script>
<template> <template>
@@ -95,69 +95,77 @@ onClickOutside(comboboxRef, () => {
'group/combobox': !disabled, 'group/combobox': !disabled,
}" }"
> >
<Button <OnClickOutside @trigger="open = false">
variant="outline" <Button
:label="selectedLabel" variant="outline"
icon-position="right" :label="selectedLabel"
size="sm" icon-position="right"
:disabled="disabled" :disabled="disabled"
class="justify-between w-full text-slate-900 dark:text-slate-100 group-hover/combobox:border-slate-300 dark:group-hover/combobox:border-slate-600" class="justify-between w-full !px-2 !py-2.5 text-n-slate-12 font-normal group-hover/combobox:border-n-slate-6"
:icon="open ? 'chevron-up' : 'chevron-down'" :icon="open ? 'chevron-lucide-up' : 'chevron-lucide-down'"
@click="toggleDropdown" icon-lib="lucide"
/> @click="toggleDropdown"
<div />
v-show="open" <div
class="absolute z-50 w-full mt-1 transition-opacity duration-200 bg-white border rounded-md shadow-lg border-slate-200 dark:bg-slate-900 dark:border-slate-700/50" v-show="open"
> class="absolute z-50 w-full mt-1 transition-opacity duration-200 border rounded-md shadow-lg bg-n-solid-1 border-n-strong"
<div class="relative border-b border-slate-100 dark:border-slate-700/50">
<FluentIcon
icon="search"
:size="14"
class="absolute text-gray-400 dark:text-slate-500 top-3 left-3"
aria-hidden="true"
/>
<input
ref="searchInput"
v-model="search"
type="search"
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
class="w-full py-2 pl-10 pr-2 text-sm bg-white border-none rounded-t-md dark:bg-slate-900 text-slate-900 dark:text-slate-50"
/>
</div>
<ul
class="py-1 overflow-auto max-h-60"
role="listbox"
:aria-activedescendant="selectedValue"
> >
<li <div class="relative border-b border-n-strong">
v-for="option in filteredOptions"
:key="option.value"
class="flex items-center justify-between w-full gap-2 px-3 py-2 text-sm transition-colors duration-150 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/50"
:class="{
'bg-slate-50 dark:bg-slate-800/50': option.value === selectedValue,
}"
role="option"
:aria-selected="option.value === selectedValue"
@click="selectOption(option)"
>
<span :class="{ 'font-medium': option.value === selectedValue }">
{{ option.label }}
</span>
<FluentIcon <FluentIcon
v-if="option.value === selectedValue" icon="search"
icon="checkmark" :size="14"
:size="16" class="absolute text-gray-400 dark:text-slate-500 top-3 left-3"
class="flex-shrink-0"
aria-hidden="true" aria-hidden="true"
/> />
</li> <input
<li ref="searchInput"
v-if="filteredOptions.length === 0" v-model="search"
class="px-3 py-2 text-sm text-slate-600 dark:text-slate-300" type="search"
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
class="w-full py-2 pl-10 pr-2 text-sm border-none rounded-t-md bg-n-solid-1 text-slate-900 dark:text-slate-50"
/>
</div>
<ul
class="py-1 mb-0 overflow-auto max-h-60"
role="listbox"
:aria-activedescendant="selectedValue"
> >
{{ emptyState || t('COMBOBOX.EMPTY_STATE') }} <li
</li> v-for="option in filteredOptions"
</ul> :key="option.value"
</div> class="flex items-center justify-between !text-n-slate-12 w-full gap-2 px-2 py-2 text-sm transition-colors duration-150 cursor-pointer hover:bg-n-solid-2"
:class="{
'bg-n-solid-2': option.value === selectedValue,
}"
role="option"
:aria-selected="option.value === selectedValue"
@click="selectOption(option)"
>
<span :class="{ 'font-medium': option.value === selectedValue }">
{{ option.label }}
</span>
<FluentIcon
v-if="option.value === selectedValue"
icon="checkmark"
:size="16"
class="flex-shrink-0 text-n-slate-11 dark:text-n-slate-11"
aria-hidden="true"
/>
</li>
<li
v-if="filteredOptions.length === 0"
class="px-3 py-2 text-sm text-slate-600 dark:text-slate-300"
>
{{ emptyState || t('COMBOBOX.EMPTY_STATE') }}
</li>
</ul>
</div>
<p
v-if="message"
class="mt-2 mb-0 text-xs truncate transition-all duration-500 ease-in-out text-n-slate-11 dark:text-n-slate-11"
>
{{ message }}
</p>
</OnClickOutside>
</div> </div>
</template> </template>

View File

@@ -1,7 +1,9 @@
<script setup> <script setup>
import { ref } from 'vue'; import { ref } from 'vue';
import { onClickOutside } from '@vueuse/core'; import { OnClickOutside } from '@vueuse/components';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
defineProps({ defineProps({
@@ -26,12 +28,30 @@ defineProps({
type: String, type: String,
default: '', default: '',
}, },
disableConfirmButton: {
type: Boolean,
default: false,
},
isLoading: {
type: Boolean,
default: false,
},
showCancelButton: {
type: Boolean,
default: true,
},
showConfirmButton: {
type: Boolean,
default: true,
},
}); });
const emit = defineEmits(['confirm']); const emit = defineEmits(['confirm', 'close']);
const { t } = useI18n(); const { t } = useI18n();
const isRTL = useMapGetter('accounts/isRTL');
const dialogRef = ref(null); const dialogRef = ref(null);
const dialogContentRef = ref(null); const dialogContentRef = ref(null);
@@ -39,71 +59,69 @@ const open = () => {
dialogRef.value?.showModal(); dialogRef.value?.showModal();
}; };
const close = () => { const close = () => {
emit('close');
dialogRef.value?.close(); dialogRef.value?.close();
}; };
const confirm = () => { const confirm = () => {
emit('confirm'); emit('confirm');
}; };
defineExpose({ open }); defineExpose({ open, close });
onClickOutside(dialogContentRef, event => {
if (
dialogRef.value &&
dialogRef.value.open &&
event.target === dialogRef.value
) {
close();
}
});
</script> </script>
<template> <template>
<Teleport to="body"> <Teleport to="body">
<dialog <dialog
ref="dialogRef" ref="dialogRef"
class="w-full max-w-lg overflow-visible shadow-xl bg-modal-backdrop-light dark:bg-modal-backdrop-dark rounded-xl" class="w-full max-w-lg overflow-visible transition-all duration-300 ease-in-out shadow-xl rounded-xl"
:dir="isRTL ? 'rtl' : 'ltr'"
@close="close" @close="close"
> >
<div <OnClickOutside @trigger="close">
ref="dialogContentRef" <div
class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-left align-middle transition-all duration-300 ease-in-out transform bg-white shadow-xl dark:bg-slate-800 rounded-xl" ref="dialogContentRef"
@click.stop class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-left align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
> @click.stop
<div class="flex flex-col gap-2"> >
<h3 <div class="flex flex-col gap-2">
class="text-base font-medium leading-6 text-gray-900 dark:text-white" <h3 class="text-base font-medium leading-6 text-n-slate-12">
> {{ title }}
{{ title }} </h3>
</h3> <slot name="description">
<p <p v-if="description" class="mb-0 text-sm text-n-slate-11">
v-if="description" {{ description }}
class="mb-0 text-sm text-slate-500 dark:text-slate-400" </p>
> </slot>
{{ description }} </div>
</p> <slot name="form">
<!-- Form content will be injected here -->
</slot>
<div class="flex items-center justify-between w-full gap-3">
<Button
v-if="showCancelButton"
variant="ghost"
:label="cancelButtonLabel || t('DIALOG.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 hover:bg-n-alpha-3"
@click="close"
/>
<Button
v-if="showConfirmButton"
:variant="type === 'edit' ? 'default' : 'destructive'"
:label="confirmButtonLabel || t('DIALOG.BUTTONS.CONFIRM')"
class="w-full"
:is-loading="isLoading"
:disabled="disableConfirmButton || isLoading"
@click="confirm"
/>
</div>
</div> </div>
<slot name="form"> </OnClickOutside>
<!-- Form content will be injected here -->
</slot>
<div class="flex items-center justify-between w-full gap-3">
<Button
variant="secondary"
:label="cancelButtonLabel || t('DIALOG.BUTTONS.CANCEL')"
class="w-full"
size="sm"
@click="close"
/>
<Button
v-if="type !== 'alert'"
:variant="type === 'edit' ? 'default' : 'destructive'"
:label="confirmButtonLabel || t('DIALOG.BUTTONS.CONFIRM')"
class="w-full"
size="sm"
@click="confirm"
/>
</div>
</div>
</dialog> </dialog>
</Teleport> </Teleport>
</template> </template>
<style scoped>
dialog::backdrop {
@apply dark:bg-n-alpha-white bg-n-alpha-black2;
}
</style>

View File

@@ -1,35 +1,57 @@
<script setup> <script setup>
import { defineProps, defineEmits } from 'vue'; import { defineProps, defineEmits } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
defineProps({ defineProps({
menuItems: { menuItems: {
type: Array, type: Array,
required: true, required: true,
validator: value => {
return value.every(item => item.action && item.value && item.label);
},
},
thumbnailSize: {
type: Number,
default: 20,
}, },
}); });
const emit = defineEmits(['action']); const emit = defineEmits(['action']);
const handleAction = action => { const handleAction = (action, value) => {
emit('action', action); emit('action', { action, value });
}; };
</script> </script>
<template> <template>
<div <div
class="bg-white dark:bg-slate-800 absolute rounded-xl z-50 py-3 px-1 gap-2 flex flex-col min-w-[136px] shadow-lg" class="bg-n-alpha-3 backdrop-blur-[100px] absolute rounded-xl z-50 py-2 px-2 gap-2 flex flex-col min-w-[136px] shadow-lg"
> >
<Button <Button
v-for="item in menuItems" v-for="item in menuItems"
:key="item.action" :key="item.action"
:label="item.label" :label="item.label"
:icon="item.icon" :icon="item.icon"
:emoji="item.emoji"
:disabled="item.disabled"
variant="ghost" variant="ghost"
size="sm" size="sm"
class="!justify-start w-full hover:bg-white dark:hover:bg-slate-800 z-60 font-normal" class="!justify-start w-full hover:!bg-n-slate-3 dark:hover:!bg-n-slate-4 z-60 px-2 font-normal"
:class="item.isSelected ? '!bg-n-alpha-1 dark:!bg-n-solid-active' : ''"
:text-variant="item.action === 'delete' ? 'danger' : ''" :text-variant="item.action === 'delete' ? 'danger' : ''"
@click="handleAction(item.action)" @click="handleAction(item.action, item.value)"
/> >
<template #leftPrefix>
<Thumbnail
v-if="item.thumbnail"
:author="item.thumbnail"
:name="item.thumbnail.name"
:size="thumbnailSize"
:src="item.thumbnail.src"
/>
</template>
</Button>
</div> </div>
</template> </template>

View File

@@ -34,12 +34,16 @@ defineProps({
}, },
}); });
defineEmits(['update:modelValue']); const emit = defineEmits(['update:modelValue', 'enterPress']);
const onEnterPress = () => {
emit('enterPress');
};
</script> </script>
<template> <template>
<div <div
class="relative flex items-center justify-between w-full gap-2 whitespace-nowrap" class="relative flex items-center justify-between w-full gap-3 whitespace-nowrap"
> >
<label <label
v-if="label" v-if="label"
@@ -60,6 +64,7 @@ defineEmits(['update:modelValue']);
:class="customInputClass" :class="customInputClass"
class="flex w-full reset-base text-sm h-6 !mb-0 border-0 rounded-lg bg-transparent dark:bg-transparent placeholder:text-slate-200 dark:placeholder:text-slate-500 disabled:cursor-not-allowed disabled:opacity-50 text-slate-900 dark:text-white transition-all duration-500 ease-in-out" class="flex w-full reset-base text-sm h-6 !mb-0 border-0 rounded-lg bg-transparent dark:bg-transparent placeholder:text-slate-200 dark:placeholder:text-slate-500 disabled:cursor-not-allowed disabled:opacity-50 text-slate-900 dark:text-white transition-all duration-500 ease-in-out"
@input="$emit('update:modelValue', $event.target.value)" @input="$emit('update:modelValue', $event.target.value)"
@keydown.enter.prevent="onEnterPress"
/> />
</div> </div>
</template> </template>

View File

@@ -39,25 +39,33 @@ const props = defineProps({
validator: value => ['info', 'error', 'success'].includes(value), validator: value => ['info', 'error', 'success'].includes(value),
}, },
}); });
defineEmits(['update:modelValue']);
const emit = defineEmits(['update:modelValue', 'blur', 'input']);
const messageClass = computed(() => { const messageClass = computed(() => {
switch (props.messageType) { switch (props.messageType) {
case 'error': case 'error':
return 'text-red-500 dark:text-red-400'; return 'text-n-ruby-9 dark:text-n-ruby-9';
case 'success': case 'success':
return 'text-green-500 dark:text-green-400'; return 'text-green-500 dark:text-green-400';
default: default:
return 'text-slate-500 dark:text-slate-400'; return 'text-n-slate-11 dark:text-n-slate-11';
} }
}); });
const inputBorderClass = computed(() => { const inputBorderClass = computed(() => {
switch (props.messageType) { switch (props.messageType) {
case 'error': case 'error':
return 'border-red-500 dark:border-red-400'; return 'border-n-ruby-8 dark:border-n-ruby-8 hover:border-n-ruby-9 dark:hover:border-n-ruby-9 disabled:border-n-ruby-8 dark:disabled:border-n-ruby-8';
default: default:
return 'border-slate-100 dark:border-slate-700/50'; return 'border-n-weak dark:border-n-weak hover:border-n-slate-6 dark:hover:border-n-slate-6 disabled:border-n-weak dark:disabled:border-n-weak';
} }
}); });
const handleInput = event => {
emit('update:modelValue', event.target.value);
emit('input', event);
};
</script> </script>
<template> <template>
@@ -78,12 +86,13 @@ const inputBorderClass = computed(() => {
:type="type" :type="type"
:placeholder="placeholder" :placeholder="placeholder"
:disabled="disabled" :disabled="disabled"
class="flex w-full reset-base text-sm h-8 pl-3 pr-2 rtl:pr-3 rtl:pl-2 py-1.5 !mb-0 border rounded-lg focus:border-woot-500 dark:focus:border-woot-600 bg-white dark:bg-slate-900 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-200 dark:placeholder:text-slate-500 disabled:cursor-not-allowed disabled:opacity-50 text-slate-900 dark:text-white transition-all duration-500 ease-in-out" class="flex w-full reset-base text-sm h-10 !px-2 !py-2.5 !mb-0 border rounded-lg focus:border-n-brand dark:focus:border-n-brand bg-white dark:bg-slate-900 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-200 dark:placeholder:text-slate-500 disabled:cursor-not-allowed disabled:opacity-50 text-slate-900 dark:text-white transition-all duration-500 ease-in-out"
@input="$emit('update:modelValue', $event.target.value)" @input="handleInput"
@blur="emit('blur')"
/> />
<p <p
v-if="message" v-if="message"
class="mt-1 mb-0 text-xs transition-all duration-500 ease-in-out" class="mt-1 mb-0 text-xs truncate transition-all duration-500 ease-in-out"
:class="messageClass" :class="messageClass"
> >
{{ message }} {{ message }}

View File

@@ -56,12 +56,10 @@ const pageInfo = computed(() => {
<template> <template>
<div <div
class="flex justify-between h-12 w-full max-w-[957px] mx-auto bg-slate-25 dark:bg-slate-800/50 rounded-xl py-2 px-3 items-center" class="flex justify-between h-12 w-full max-w-[957px] mx-auto bg-n-solid-2 rounded-xl py-2 ltr:pl-4 rtl:pr-4 ltr:pr-3 rtl:pl-3 items-center"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span <span class="min-w-0 text-sm font-normal line-clamp-1 text-n-slate-11">
class="min-w-0 text-sm font-normal line-clamp-1 text-slate-600 dark:text-slate-300"
>
{{ currentPageInformation }} {{ currentPageInformation }}
</span> </span>
</div> </div>
@@ -82,12 +80,8 @@ const pageInfo = computed(() => {
:disabled="isFirstPage" :disabled="isFirstPage"
@click="changePage(currentPage - 1)" @click="changePage(currentPage - 1)"
/> />
<div <div class="inline-flex items-center gap-2 text-sm text-n-slate-11">
class="inline-flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400" <span class="px-3 tabular-nums py-0.5 bg-n-alpha-black2 rounded-md">
>
<span
class="px-3 tabular-nums py-0.5 bg-white dark:bg-slate-900 rounded-md"
>
{{ currentPage }} {{ currentPage }}
</span> </span>
<span>{{ pageInfo }}</span> <span>{{ pageInfo }}</span>

View File

@@ -281,29 +281,62 @@ const menuItems = computed(() => {
name: 'Portals', name: 'Portals',
label: t('SIDEBAR.HELP_CENTER.TITLE'), label: t('SIDEBAR.HELP_CENTER.TITLE'),
icon: 'i-lucide-library-big', icon: 'i-lucide-library-big',
to: accountScopedRoute('default_portal_articles'), to: accountScopedRoute('portals_index', {
navigationPath: 'portals_articles_index',
}),
children: [
{
name: 'Articles',
label: t('SIDEBAR.HELP_CENTER.ARTICLES'),
activeOn: [
'portals_articles_index',
'portals_articles_new',
'portals_articles_edit',
],
to: accountScopedRoute('portals_index', {
navigationPath: 'portals_articles_index',
}),
},
{
name: 'Categories',
label: t('SIDEBAR.HELP_CENTER.CATEGORIES'),
activeOn: [
'portals_categories_index',
'portals_categories_articles_index',
'portals_categories_articles_edit',
],
to: accountScopedRoute('portals_index', {
navigationPath: 'portals_categories_index',
}),
},
{
name: 'Locales',
label: t('SIDEBAR.HELP_CENTER.LOCALES'),
activeOn: ['portals_locales_index'],
to: accountScopedRoute('portals_index', {
navigationPath: 'portals_locales_index',
}),
},
{
name: 'Settings',
label: t('SIDEBAR.HELP_CENTER.SETTINGS'),
activeOn: ['portals_settings_index'],
to: accountScopedRoute('portals_index', {
navigationPath: 'portals_settings_index',
}),
},
],
activeOn: [ activeOn: [
'all_locale_categories', 'portals_new',
'default_portal_articles', 'portals_index',
'edit_article', 'portals_articles_index',
'edit_category', 'portals_articles_new',
'edit_portal_customization', 'portals_articles_edit',
'edit_portal_information', 'portals_categories_index',
'edit_portal_locales', 'portals_categories_articles_index',
'list_all_locale_articles', 'portals_categories_articles_edit',
'list_all_locale_categories', 'portals_locales_index',
'list_all_portals', 'portals_settings_index',
'list_archived_articles',
'list_draft_articles',
'list_mine_articles',
'new_article',
'new_category_in_locale',
'new_portal_information',
'portalSlug',
'portal_customization',
'portal_finish',
'show_category',
'show_category_articles',
], ],
}, },
{ {
@@ -412,43 +445,43 @@ const menuItems = computed(() => {
class="w-[200px] bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak h-screen flex flex-col text-sm pt-2 pb-1" class="w-[200px] bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak h-screen flex flex-col text-sm pt-2 pb-1"
> >
<section class="grid gap-2 mt-2 mb-4"> <section class="grid gap-2 mt-2 mb-4">
<div class="flex gap-2 px-2 items-center min-w-0"> <div class="flex items-center min-w-0 gap-2 px-2">
<div class="size-6 grid place-content-center flex-shrink-0"> <div class="grid flex-shrink-0 size-6 place-content-center">
<Logo /> <Logo />
</div> </div>
<div class="w-px h-3 bg-n-strong flex-shrink-0" /> <div class="flex-shrink-0 w-px h-3 bg-n-strong" />
<SidebarAccountSwitcher <SidebarAccountSwitcher
class="-mx-1 flex-grow min-w-0" class="flex-grow min-w-0 -mx-1"
@show-create-account-modal="emit('showCreateAccountModal')" @show-create-account-modal="emit('showCreateAccountModal')"
/> />
</div> </div>
<div class="gap-2 flex px-2"> <div class="flex gap-2 px-2">
<RouterLink <RouterLink
:to="{ name: 'search' }" :to="{ name: 'search' }"
class="rounded-lg py-1 flex items-center gap-2 px-2 border-n-weak border bg-n-solid-3 dark:bg-n-black/30 w-full" class="flex items-center w-full gap-2 px-2 py-1 border rounded-lg border-n-weak bg-n-solid-3 dark:bg-n-black/30"
> >
<span class="i-lucide-search size-4 text-n-slate-11 flex-shrink-0" /> <span class="flex-shrink-0 i-lucide-search size-4 text-n-slate-11" />
<span class="flex-grow text-left"> <span class="flex-grow text-left">
{{ t('COMBOBOX.SEARCH_PLACEHOLDER') }} {{ t('COMBOBOX.SEARCH_PLACEHOLDER') }}
</span> </span>
<span <span
class="tracking-wide select-none pointer-events-none text-n-slate-10 hidden" class="hidden tracking-wide pointer-events-none select-none text-n-slate-10"
> >
{{ searchShortcut }} {{ searchShortcut }}
</span> </span>
</RouterLink> </RouterLink>
<button <button
v-if="enableNewConversation" v-if="enableNewConversation"
class="rounded-lg py-1 flex items-center gap-2 px-2 border-n-weak border bg-n-solid-3 w-full" class="flex items-center w-full gap-2 px-2 py-1 border rounded-lg border-n-weak bg-n-solid-3"
> >
<span <span
class="i-lucide-square-pen size-4 text-n-slate-11 flex-shrink-0" class="flex-shrink-0 i-lucide-square-pen size-4 text-n-slate-11"
/> />
</button> </button>
</div> </div>
</section> </section>
<nav class="grid gap-2 overflow-y-scroll no-scrollbar px-2 flex-grow pb-5"> <nav class="grid flex-grow gap-2 px-2 pb-5 overflow-y-scroll no-scrollbar">
<ul class="flex flex-col gap-2 list-none m-0"> <ul class="flex flex-col gap-2 m-0 list-none">
<SidebarGroup <SidebarGroup
v-for="item in menuItems" v-for="item in menuItems"
:key="item.name" :key="item.name"
@@ -463,7 +496,7 @@ const menuItems = computed(() => {
@open-key-shortcut-modal="emit('openKeyShortcutModal')" @open-key-shortcut-modal="emit('openKeyShortcutModal')"
/> />
<div v-if="false" class="flex items-center"> <div v-if="false" class="flex items-center">
<div class="w-px h-3 bg-n-strong flex-shrink-0" /> <div class="flex-shrink-0 w-px h-3 bg-n-strong" />
<SidebarNotificationBell <SidebarNotificationBell
@open-notification-panel="emit('openNotificationPanel')" @open-notification-panel="emit('openNotificationPanel')"
/> />

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref } from 'vue'; import { computed } from 'vue';
const props = defineProps({ const props = defineProps({
initialActiveTab: { initialActiveTab: {
type: Number, type: Number,
@@ -10,17 +10,22 @@ const props = defineProps({
required: true, required: true,
validator: value => { validator: value => {
return value.every( return value.every(
tab => typeof tab.label === 'string' && typeof tab.count === 'number' tab =>
typeof tab.label === 'string' &&
(tab.count ? typeof tab.count === 'number' : true)
); );
}, },
}, },
}); });
const emit = defineEmits(['tabChanged']); const emit = defineEmits(['tabChanged']);
const activeTab = ref(props.initialActiveTab);
const activeTab = computed(() => props.initialActiveTab);
const selectTab = index => { const selectTab = index => {
activeTab.value = index;
emit('tabChanged', props.tabs[index]); emit('tabChanged', props.tabs[index]);
}; };
const showDivider = index => { const showDivider = index => {
return ( return (
// Show dividers after the active tab, but not after the last tab // Show dividers after the active tab, but not after the last tab
@@ -32,14 +37,14 @@ const showDivider = index => {
</script> </script>
<template> <template>
<div class="flex h-8 rounded-lg bg-slate-25 dark:bg-slate-800/50 w-fit"> <div class="flex h-8 rounded-lg bg-n-solid-1 w-fit">
<template v-for="(tab, index) in tabs" :key="index"> <template v-for="(tab, index) in tabs" :key="index">
<button <button
class="relative px-4 truncate py-1.5 text-sm border-0 rounded-lg transition-colors duration-300 ease-in-out" class="relative px-4 truncate py-1.5 text-sm border-0 rounded-lg transition-colors duration-300 ease-in-out hover:text-n-brand"
:class="[ :class="[
activeTab === index activeTab === index
? 'text-woot-500 bg-woot-500/10 dark:bg-woot-500/10' ? 'text-n-brand bg-n-solid-active font-medium'
: 'text-slate-500 dark:text-slate-400 hover:text-woot-500 dark:hover:text-woot-400', : 'text-n-slate-10',
]" ]"
@click="selectTab(index)" @click="selectTab(index)"
> >

View File

@@ -0,0 +1,90 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { OnClickOutside } from '@vueuse/components';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
const props = defineProps({
modelValue: {
type: Array,
default: () => [],
},
placeholder: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:modelValue']);
const tags = ref(props.modelValue);
const newTag = ref('');
const isFocused = ref(false);
const showInput = computed(() => isFocused.value || tags.value.length === 0);
const addTag = () => {
if (newTag.value.trim()) {
tags.value.push(newTag.value.trim());
newTag.value = '';
emit('update:modelValue', tags.value);
}
};
const removeTag = index => {
tags.value.splice(index, 1);
emit('update:modelValue', tags.value);
};
const handleFocus = () => {
isFocused.value = true;
};
const handleClickOutside = () => {
if (tags.value.length > 0) {
isFocused.value = false;
}
};
watch(
() => props.modelValue,
newValue => {
tags.value = newValue;
}
);
</script>
<template>
<OnClickOutside @trigger="handleClickOutside">
<div
class="flex flex-wrap w-full gap-2 border border-transparent focus:outline-none"
tabindex="0"
@focus="handleFocus"
@click="handleFocus"
>
<div
v-for="(tag, index) in tags"
:key="index"
class="flex items-center justify-center max-w-full gap-1 px-3 py-1 rounded-lg h-7 bg-n-alpha-2"
>
<span class="flex-grow min-w-0 text-sm truncate text-n-slate-12">
{{ tag }}
</span>
<FluentIcon
icon="dismiss"
size="20"
class="flex-shrink-0 p-1 cursor-pointer text-n-slate-11"
@click.stop="removeTag(index)"
/>
</div>
<InlineInput
v-if="showInput"
v-model="newTag"
:placeholder="placeholder"
custom-input-class="flex-grow"
@enter-press="addTag"
/>
</div>
</OnClickOutside>
</template>

View File

@@ -1,5 +1,6 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed, ref, onMounted, nextTick, watch } from 'vue';
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
type: String, type: String,
@@ -29,13 +30,87 @@ const props = defineProps({
type: String, type: String,
default: '', default: '',
}, },
customTextAreaWrapperClass: {
type: String,
default: '',
},
showCharacterCount: { showCharacterCount: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
autoHeight: {
type: Boolean,
default: false,
},
resize: {
type: Boolean,
default: false,
},
minHeight: {
type: String,
default: '4rem',
},
maxHeight: {
type: String,
default: '12rem',
},
autofocus: {
type: Boolean,
default: false,
},
}); });
defineEmits(['update:modelValue']);
const emit = defineEmits(['update:modelValue']);
const textareaRef = ref(null);
const isFocused = ref(false);
const characterCount = computed(() => props.modelValue.length); const characterCount = computed(() => props.modelValue.length);
// TODO - use "field-sizing: content" and "height: auto" in future for auto height, when available.
const adjustHeight = () => {
if (!props.autoHeight || !textareaRef.value) return;
// Reset height to auto to get the correct scrollHeight
textareaRef.value.style.height = 'auto';
// Set the height to the scrollHeight
textareaRef.value.style.height = `${textareaRef.value.scrollHeight}px`;
};
const handleInput = event => {
emit('update:modelValue', event.target.value);
if (props.autoHeight) {
nextTick(adjustHeight);
}
};
const handleFocus = () => {
isFocused.value = true;
};
const handleBlur = () => {
isFocused.value = false;
};
// Watch for changes in modelValue to adjust height
watch(
() => props.modelValue,
() => {
if (props.autoHeight) {
nextTick(adjustHeight);
}
}
);
onMounted(() => {
if (props.autoHeight) {
nextTick(adjustHeight);
}
if (props.autofocus) {
textareaRef.value.focus();
}
});
</script> </script>
<template> <template>
@@ -47,23 +122,49 @@ const characterCount = computed(() => props.modelValue.length);
> >
{{ label }} {{ label }}
</label> </label>
<textarea
:id="id"
:value="modelValue"
:placeholder="placeholder"
:maxlength="maxLength"
class="flex w-full reset-base text-sm h-24 px-3 pt-3 !mb-0 border rounded-lg focus:border-woot-500 dark:focus:border-woot-600 bg-white dark:bg-slate-900 placeholder:text-slate-200 dark:placeholder:text-slate-500 text-slate-900 dark:text-white transition-all duration-500 ease-in-out resize-none disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-slate-25 dark:disabled:bg-slate-900"
:class="[customTextAreaClass, showCharacterCount ? 'pb-9' : 'pb-3']"
:disabled="disabled"
@input="$emit('update:modelValue', $event.target.value)"
/>
<div <div
v-if="showCharacterCount" class="flex flex-col gap-2 px-3 pt-3 pb-3 transition-all duration-500 ease-in-out bg-white border rounded-lg border-n-weak dark:border-n-weak dark:bg-slate-900"
class="absolute flex items-center justify-between mt-1 bottom-3 ltr:right-3 rtl:left-3" :class="[
customTextAreaWrapperClass,
{
'cursor-not-allowed opacity-50 !bg-slate-25 dark:!bg-slate-800 disabled:border-n-weak dark:disabled:border-n-weak':
disabled,
'border-n-brand dark:border-n-brand': isFocused,
'hover:border-n-slate-6 dark:hover:border-n-slate-6': !isFocused,
},
]"
> >
<span class="text-xs tabular-nums text-slate-300 dark:text-slate-600"> <textarea
{{ characterCount }} / {{ maxLength }} :id="id"
</span> ref="textareaRef"
:value="modelValue"
:placeholder="placeholder"
:maxlength="showCharacterCount ? maxLength : undefined"
:class="[
customTextAreaClass,
{
'resize-none': !resize,
},
]"
:style="{
minHeight: autoHeight ? minHeight : undefined,
maxHeight: autoHeight ? maxHeight : undefined,
}"
:disabled="disabled"
rows="1"
class="flex w-full reset-base text-sm p-0 !rounded-none !bg-transparent dark:!bg-transparent !border-0 !mb-0 placeholder:text-slate-200 dark:placeholder:text-slate-500 text-slate-900 dark:text-white disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-slate-25 dark:disabled:bg-slate-900"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
/>
<div
v-if="showCharacterCount"
class="flex items-center justify-end h-4 mt-1 bottom-3 ltr:right-3 rtl:left-3"
>
<span class="text-xs tabular-nums text-slate-300 dark:text-slate-600">
{{ characterCount }} / {{ maxLength }}
</span>
</div>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -0,0 +1,121 @@
<script setup>
import { computed, ref } from 'vue';
import { removeEmoji } from 'shared/helpers/emoji';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
const props = defineProps({
author: {
type: Object,
default: null,
},
name: {
type: String,
default: '',
},
src: {
type: String,
default: '',
},
size: {
type: Number,
default: 16,
},
showAuthorName: {
type: Boolean,
default: true,
},
iconName: {
type: String,
default: '',
},
});
const hasImageLoaded = ref(false);
const imgError = ref(false);
const authorInitial = computed(() => {
if (!props.name) return '';
const name = removeEmoji(props.name);
const words = name.split(/\s+/);
if (words.length === 1) {
return name.substring(0, 2).toUpperCase();
}
return words
.slice(0, 2)
.map(word => word[0])
.join('')
.toUpperCase();
});
const fontSize = computed(() => {
return props.size / 2;
});
const iconSize = computed(() => {
return props.size / 2;
});
const shouldShowImage = computed(() => {
return props.src && !imgError.value;
});
const onImgError = () => {
imgError.value = true;
};
const onImgLoad = () => {
hasImageLoaded.value = true;
};
</script>
<template>
<div
class="flex items-center justify-center rounded-full bg-slate-100 dark:bg-slate-700/50"
:style="{ width: `${size}px`, height: `${size}px` }"
>
<div v-if="author">
<img
v-if="shouldShowImage"
:src="src"
:alt="name"
class="w-full h-full rounded-full"
@load="onImgLoad"
@error="onImgError"
/>
<template v-else>
<span
v-if="showAuthorName"
class="flex items-center justify-center font-medium text-slate-500 dark:text-slate-400"
:style="{ fontSize: `${fontSize}px` }"
>
{{ authorInitial }}
</span>
<div
v-else
class="flex items-center justify-center w-full h-full rounded-xl"
>
<FluentIcon
v-if="iconName"
:icon="iconName"
icon-lib="lucide"
:size="iconSize"
class="text-n-brand"
/>
</div>
</template>
</div>
<div
v-else
class="flex items-center justify-center w-4 h-4 rounded-full bg-slate-100 dark:bg-slate-700/50"
>
<FluentIcon
icon="person"
type="filled"
size="10"
class="text-woot-500 dark:text-woot-400"
/>
</div>
</div>
</template>

View File

@@ -55,8 +55,8 @@ const primaryMenuItems = accountId => [
label: 'HELP_CENTER.TITLE', label: 'HELP_CENTER.TITLE',
featureFlag: FEATURE_FLAGS.HELP_CENTER, featureFlag: FEATURE_FLAGS.HELP_CENTER,
alwaysVisibleOnChatwootInstances: true, alwaysVisibleOnChatwootInstances: true,
toState: frontendURL(`accounts/${accountId}/portals`), toState: frontendURL(`accounts/${accountId}/portals/portal_articles_index`),
toStateName: 'default_portal_articles', toStateName: 'portals_index',
}, },
{ {
icon: 'settings', icon: 'settings',

View File

@@ -82,6 +82,7 @@ export default {
/> />
<PrimaryNavItem <PrimaryNavItem
v-for="menuItem in menuItems" v-for="menuItem in menuItems"
:id="menuItem.key"
:key="menuItem.toState" :key="menuItem.toState"
:icon="menuItem.icon" :icon="menuItem.icon"
:name="menuItem.label" :name="menuItem.label"
@@ -94,7 +95,7 @@ export default {
v-if="!isACustomBrandedInstance" v-if="!isACustomBrandedInstance"
v-tooltip.right="$t(`SIDEBAR.DOCS`)" v-tooltip.right="$t(`SIDEBAR.DOCS`)"
:href="helpDocsURL" :href="helpDocsURL"
class="text-slate-700 dark:text-slate-100 w-10 h-10 my-2 flex items-center justify-center rounded-lg hover:bg-slate-25 dark:hover:bg-slate-700 dark:hover:text-slate-100 hover:text-slate-600 relative" class="relative flex items-center justify-center w-10 h-10 my-2 rounded-lg text-slate-700 dark:text-slate-100 hover:bg-slate-25 dark:hover:bg-slate-700 dark:hover:text-slate-100 hover:text-slate-600"
rel="noopener noreferrer nofollow" rel="noopener noreferrer nofollow"
target="_blank" target="_blank"
> >

View File

@@ -1,10 +1,23 @@
<script> <script>
import { OnClickOutside } from '@vueuse/components';
import { HELP_CENTER_MENU_ITEMS } from 'dashboard/helper/portalHelper';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
export default { export default {
components: {
DropdownMenu,
OnClickOutside,
},
props: { props: {
to: { to: {
type: String, type: String,
default: '', default: '',
}, },
id: {
type: String,
default: '',
},
name: { name: {
type: String, type: String,
default: '', default: '',
@@ -26,15 +39,89 @@ export default {
default: false, default: false,
}, },
}, },
data() {
return {
helpCenterMenu: HELP_CENTER_MENU_ITEMS,
showHelpCenterMenu: false,
};
},
computed: {
helpCenterMenuItems() {
return this.helpCenterMenu.map(item => ({
...item,
isSelected: this.isSelectedMenuItem(item),
}));
},
isHelpCenter() {
return this.id === 'helpcenter';
},
isHelpCenterSelected() {
const routes = [
'portals_new',
'portals_index',
'portals_articles_index',
'portals_articles_new',
'portals_articles_edit',
'portals_categories_index',
'portals_categories_articles_index',
'portals_categories_articles_edit',
'portals_locales_index',
'portals_settings_index',
];
return routes.includes(this.$route.name);
},
},
methods: {
isSelectedMenuItem(menuItem) {
return menuItem.value.includes(this.$route.name);
},
toggleHelpCenterMenu() {
this.showHelpCenterMenu = !this.showHelpCenterMenu;
},
handleHelpCenterAction({ action }) {
this.$router.push({
name: 'portals_index',
params: {
navigationPath: action,
},
});
},
},
}; };
</script> </script>
<template> <template>
<router-link v-slot="{ href, isActive, navigate }" :to="to" custom> <OnClickOutside v-if="isHelpCenter" @trigger="showHelpCenterMenu = false">
<button
v-tooltip.top="$t(`SIDEBAR.${name}`)"
class="relative flex items-center justify-center w-10 h-10 my-2 rounded-lg text-slate-700 dark:text-slate-100 hover:!bg-slate-25 dark:hover:!bg-slate-700 dark:hover:text-slate-100 hover:text-slate-600"
:class="{
'bg-woot-50 dark:bg-slate-800 text-woot-500 hover:bg-woot-50':
isHelpCenterSelected,
}"
@click="toggleHelpCenterMenu"
>
<fluent-icon
:icon="icon"
:class="{
'text-woot-500': isHelpCenterSelected,
}"
/>
<DropdownMenu
v-if="showHelpCenterMenu && isHelpCenter"
:menu-items="helpCenterMenuItems"
class="ltr:left-10 rtl:right-10 w-36 z-[100] top-0 overflow-y-auto max-h-52"
@action="handleHelpCenterAction"
/>
</button>
</OnClickOutside>
<router-link v-else v-slot="{ href, isActive, navigate }" :to="to" custom>
<a <a
v-tooltip.right="$t(`SIDEBAR.${name}`)" v-tooltip.right="$t(`SIDEBAR.${name}`)"
:href="href" :href="href"
class="text-slate-700 dark:text-slate-100 w-10 h-10 my-2 flex items-center justify-center rounded-lg hover:bg-slate-25 dark:hover:bg-slate-700 dark:hover:text-slate-100 hover:text-slate-600 relative" class="relative flex items-center justify-center w-10 h-10 my-2 rounded-lg text-slate-700 dark:text-slate-100 hover:bg-slate-25 dark:hover:bg-slate-700 dark:hover:text-slate-100 hover:text-slate-600"
:class="{ :class="{
'bg-woot-50 dark:bg-slate-800 text-woot-500 hover:bg-woot-50': 'bg-woot-50 dark:bg-slate-800 text-woot-500 hover:bg-woot-50':
isActive || isChildMenuActive, isActive || isChildMenuActive,
@@ -52,7 +139,7 @@ export default {
<span class="sr-only">{{ name }}</span> <span class="sr-only">{{ name }}</span>
<span <span
v-if="count" v-if="count"
class="text-black-900 bg-yellow-500 absolute -top-1 -right-1" class="absolute bg-yellow-500 text-black-900 -top-1 -right-1"
> >
{{ count }} {{ count }}
</span> </span>

View File

@@ -46,6 +46,10 @@ export default {
editorId: { type: String, default: '' }, editorId: { type: String, default: '' },
placeholder: { type: String, default: '' }, placeholder: { type: String, default: '' },
enabledMenuOptions: { type: Array, default: () => [] }, enabledMenuOptions: { type: Array, default: () => [] },
autofocus: {
type: Boolean,
default: true,
},
}, },
emits: ['blur', 'input', 'update:modelValue', 'keyup', 'focus', 'keydown'], emits: ['blur', 'input', 'update:modelValue', 'keyup', 'focus', 'keydown'],
setup() { setup() {
@@ -86,7 +90,9 @@ export default {
this.createEditorView(); this.createEditorView();
editorView.updateState(state); editorView.updateState(state);
this.focusEditorInputField(); if (this.autofocus) {
this.focusEditorInputField();
}
}, },
methods: { methods: {
contentFromEditor() { contentFromEditor() {

View File

@@ -117,3 +117,11 @@ export const timeStampAppendedURL = dataUrl => {
return url.toString(); return url.toString();
}; };
export const getHostNameFromURL = url => {
try {
return new URL(url).hostname;
} catch (error) {
return null;
}
};

View File

@@ -13,3 +13,140 @@ export const buildPortalArticleURL = (
const portalURL = buildPortalURL(portalSlug); const portalURL = buildPortalURL(portalSlug);
return `${portalURL}/articles/${articleSlug}`; return `${portalURL}/articles/${articleSlug}`;
}; };
export const getArticleStatus = status => {
switch (status) {
case 'draft':
return 0;
case 'published':
return 1;
case 'archived':
return 2;
default:
return undefined;
}
};
// Constants
export const HELP_CENTER_MENU_ITEMS = [
{
label: 'Articles',
icon: 'book',
action: 'portals_articles_index',
value: [
'portals_articles_index',
'portals_articles_new',
'portals_articles_edit',
],
},
{
label: 'Categories',
icon: 'folder',
action: 'portals_categories_index',
value: [
'portals_categories_index',
'portals_categories_articles_index',
'portals_categories_articles_edit',
],
},
{
label: 'Locales',
icon: 'translate',
action: 'portals_locales_index',
value: ['portals_locales_index'],
},
{
label: 'Settings',
icon: 'settings',
action: 'portals_settings_index',
value: ['portals_settings_index'],
},
];
export const ARTICLE_STATUSES = {
DRAFT: 'draft',
PUBLISHED: 'published',
ARCHIVED: 'archived',
};
export const ARTICLE_MENU_ITEMS = {
publish: {
label: 'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.PUBLISH',
value: ARTICLE_STATUSES.PUBLISHED,
action: 'publish',
icon: 'checkmark',
},
draft: {
label: 'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.DRAFT',
value: ARTICLE_STATUSES.DRAFT,
action: 'draft',
icon: 'draft',
},
archive: {
label: 'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.ARCHIVE',
value: ARTICLE_STATUSES.ARCHIVED,
action: 'archive',
icon: 'archive',
},
delete: {
label: 'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.DELETE',
value: 'delete',
action: 'delete',
icon: 'delete',
},
};
export const ARTICLE_MENU_OPTIONS = {
[ARTICLE_STATUSES.ARCHIVED]: ['publish', 'draft'],
[ARTICLE_STATUSES.DRAFT]: ['publish', 'archive'],
[ARTICLE_STATUSES.PUBLISHED]: ['draft', 'archive'],
};
export const ARTICLE_TABS = {
ALL: 'all',
MINE: 'mine',
DRAFT: 'draft',
ARCHIVED: 'archived',
};
export const CATEGORY_ALL = 'all';
export const ARTICLE_TABS_OPTIONS = [
{
key: 'ALL',
value: 'all',
},
{
key: 'MINE',
value: 'mine',
},
{
key: 'DRAFT',
value: 'draft',
},
{
key: 'ARCHIVED',
value: 'archived',
},
];
export const LOCALE_MENU_ITEMS = [
{
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MAKE_DEFAULT',
action: 'change-default',
value: 'default',
icon: 'star-emphasis',
},
{
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE',
action: 'delete',
value: 'delete',
icon: 'delete',
},
];
export const ARTICLE_EDITOR_STATUS_OPTIONS = {
published: ['archive', 'draft'],
archived: ['draft'],
draft: ['archive'],
};

View File

@@ -6,6 +6,7 @@ import {
getArticleSearchURL, getArticleSearchURL,
hasValidAvatarUrl, hasValidAvatarUrl,
timeStampAppendedURL, timeStampAppendedURL,
getHostNameFromURL,
} from '../URLHelper'; } from '../URLHelper';
describe('#URL Helpers', () => { describe('#URL Helpers', () => {
@@ -238,4 +239,28 @@ describe('#URL Helpers', () => {
expect(() => timeStampAppendedURL(input)).toThrow(); expect(() => timeStampAppendedURL(input)).toThrow();
}); });
}); });
describe('getHostNameFromURL', () => {
it('should return the hostname from a valid URL', () => {
expect(getHostNameFromURL('https://example.com/path')).toBe(
'example.com'
);
});
it('should return null for an invalid URL', () => {
expect(getHostNameFromURL('not a valid url')).toBe(null);
});
it('should return null for an empty string', () => {
expect(getHostNameFromURL('')).toBe(null);
});
it('should return null for undefined input', () => {
expect(getHostNameFromURL(undefined)).toBe(null);
});
it('should correctly handle URLs with non-standard TLDs', () => {
expect(getHostNameFromURL('https://chatwoot.help')).toBe('chatwoot.help');
});
});
}); });

View File

@@ -1,5 +1,10 @@
{ {
"HELP_CENTER": { "HELP_CENTER": {
"TITLE": "Help Center",
"NEW_PAGE": {
"DESCRIPTION": "Create self-service help center portals for your customers. Help them find answers quickly, without waiting. Streamline inquiries, boost agent efficiency, and elevate customer support.",
"CREATE_PORTAL_BUTTON": "Create Portal"
},
"HEADER": { "HEADER": {
"FILTER": "Filter by", "FILTER": "Filter by",
"SORT": "Sort by", "SORT": "Sort by",
@@ -343,6 +348,12 @@
"SUCCESS": "Article archived successfully" "SUCCESS": "Article archived successfully"
} }
}, },
"DRAFT_ARTICLE": {
"API": {
"ERROR": "Error while drafting article",
"SUCCESS": "Article drafted successfully"
}
},
"DELETE_ARTICLE": { "DELETE_ARTICLE": {
"MODAL": { "MODAL": {
"CONFIRM": { "CONFIRM": {
@@ -478,9 +489,304 @@
} }
}, },
"LOADING": "Loading...", "LOADING": "Loading...",
"ARTICLES_PAGE": {
"ARTICLE_CARD": {
"CARD": {
"VIEWS": "{count} view | {count} views",
"DROPDOWN_MENU": {
"PUBLISH": "Publish",
"DRAFT": "Draft",
"ARCHIVE": "Archive",
"DELETE": "Delete"
},
"STATUS": {
"DRAFT": "Draft",
"PUBLISHED": "Published",
"ARCHIVED": "Archived"
},
"CATEGORY": {
"UNCATEGORISED": "Uncategorised"
}
}
},
"ARTICLES_HEADER": {
"TABS": {
"ALL": "All articles",
"MINE": "Mine",
"DRAFT": "Draft",
"PUBLISHED": "Published",
"ARCHIVED": "Archived"
},
"CATEGORY": {
"ALL": "All categories"
},
"LOCALE": {
"ALL": "All locales"
},
"NEW_ARTICLE": "New article"
},
"EMPTY_STATE": {
"ALL": {
"TITLE": "Write an article",
"SUBTITLE": "Write a rich article, lets get started!",
"BUTTON_LABEL": "New article"
},
"MINE": {
"TITLE": "There are no articles in mine",
"SUBTITLE": "Mine articles will appear here"
},
"DRAFT": {
"TITLE": "There are no articles in draft",
"SUBTITLE": "Draft articles will appear here"
},
"PUBLISHED": {
"TITLE": "There are no articles in published",
"SUBTITLE": "Published articles will appear here"
},
"ARCHIVED": {
"TITLE": "There are no articles in archived",
"SUBTITLE": "Archived articles will appear here"
},
"CATEGORY": {
"TITLE": "There are no articles in this category",
"SUBTITLE": "Articles in this category will appear here"
}
}
},
"CATEGORY_PAGE": {
"CATEGORY_HEADER": {
"NEW_CATEGORY": "New category",
"EDIT_CATEGORY": "Edit category",
"CATEGORIES_COUNT": "{n} category | {n} categories",
"BREADCRUMB": {
"CATEGORY_LOCALE": "Categories ({localeCode})",
"ACTIVE_CATEGORY": "{categoryName} ({categoryCount} articles) | {categoryName} ({categoryCount} article)"
}
},
"CATEGORY_EMPTY_STATE": {
"TITLE": "No categories found",
"SUBTITLE": "Categories will appear here. You can add a category by clicking the 'New Category' button."
},
"CATEGORY_CARD": {
"ARTICLES_COUNT": "{count} article | {count} articles"
},
"CATEGORY_DIALOG": {
"CREATE": {
"API": {
"SUCCESS_MESSAGE": "Category created successfully",
"ERROR_MESSAGE": "Unable to create category"
}
},
"EDIT": {
"API": {
"SUCCESS_MESSAGE": "Category updated successfully",
"ERROR_MESSAGE": "Unable to update category"
}
},
"DELETE": {
"API": {
"SUCCESS_MESSAGE": "Category deleted successfully",
"ERROR_MESSAGE": "Unable to delete category"
}
},
"HEADER": {
"CREATE": "Create category",
"EDIT": "Edit category",
"DESCRIPTION": "Editing a category will update the category in the public facing portal.",
"PORTAL": "Portal",
"LOCALE": "Locale"
},
"FORM": {
"NAME": {
"LABEL": "Name",
"PLACEHOLDER": "Category name",
"ERROR": "Name is required"
},
"SLUG": {
"LABEL": "Slug",
"PLACEHOLDER": "Category slug for urls",
"ERROR": "Slug is required",
"HELP_TEXT": "app.chatwoot.com/hc/{portalSlug}/{localeCode}/categories/{categorySlug}"
},
"DESCRIPTION": {
"LABEL": "Description",
"PLACEHOLDER": "Give a short description about the category.",
"ERROR": "Description is required"
}
},
"BUTTONS": {
"CREATE": "Create",
"EDIT": "Update",
"CANCEL": "Cancel"
}
}
},
"LOCALES_PAGE": { "LOCALES_PAGE": {
"LOCALES_COUNT": "No locales available | {n} locale | {n} locales", "LOCALES_COUNT": "No locales available | {n} locale | {n} locales",
"NEW_LOCALE_BUTTON_TEXT": "New locale" "NEW_LOCALE_BUTTON_TEXT": "New locale",
"LOCALE_CARD": {
"ARTICLES_COUNT": "{count} article | {count} articles",
"CATEGORIES_COUNT": "{count} category | {count} categories",
"DEFAULT": "Default",
"DROPDOWN_MENU": {
"MAKE_DEFAULT": "Make default",
"DELETE": "Delete"
}
},
"ADD_LOCALE_DIALOG": {
"TITLE": "Add a new locale",
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
"COMBOBOX": {
"PLACEHOLDER": "Select locale..."
},
"API": {
"SUCCESS_MESSAGE": "Locale added successfully",
"ERROR_MESSAGE": "Unable to add locale. Try again."
}
}
},
"EDIT_ARTICLE_PAGE": {
"HEADER": {
"STATUS": {
"SAVING": "Saving...",
"SAVED": "Saved"
},
"PREVIEW": "Preview",
"PUBLISH": "Publish",
"DRAFT": "Draft",
"ARCHIVE": "Archive",
"BACK_TO_ARTICLES": "Back to articles"
},
"EDIT_ARTICLE": {
"MORE_PROPERTIES": "More properties",
"UNCATEGORIZED": "Uncategorized",
"EDITOR_PLACEHOLDER": "Write something..."
},
"ARTICLE_PROPERTIES": {
"ARTICLE_PROPERTIES": "Article properties",
"META_DESCRIPTION": "Meta description",
"META_DESCRIPTION_PLACEHOLDER": "Add meta description",
"META_TITLE": "Meta title",
"META_TITLE_PLACEHOLDER": "Add meta title",
"META_TAGS": "Meta tags",
"META_TAGS_PLACEHOLDER": "Add meta tags"
},
"API": {
"ERROR": "Error while saving article"
}
},
"PORTAL_SWITCHER": {
"NEW_PORTAL": "New portal",
"PORTALS": "Portals",
"CREATE_PORTAL": "Create and manage multiple portals",
"ARTICLES": "articles",
"DOMAIN": "domain",
"PORTAL_NAME": "Portal name"
},
"CREATE_PORTAL_DIALOG": {
"TITLE": "Create new portal",
"DESCRIPTION": "Give your portal a name and create a user-friendly URL slug. You can modify both later in the settings.",
"CONFIRM_BUTTON_LABEL": "Create",
"NAME": {
"LABEL": "Name",
"PLACEHOLDER": "User Guide | Chatwoot",
"MESSAGE": "Choose an name for your portal.",
"ERROR": "Name is required"
},
"SLUG": {
"LABEL": "Slug",
"PLACEHOLDER": "user-guide",
"ERROR": "Slug is required"
}
},
"PORTAL_SETTINGS": {
"FORM": {
"AVATAR": {
"LABEL": "Logo",
"IMAGE_UPLOAD_ERROR": "Couldn't upload image! Try again",
"IMAGE_UPLOAD_SUCCESS": "Image added successfully. Please click on save changes to save the logo",
"IMAGE_DELETE_SUCCESS": "Logo deleted successfully",
"IMAGE_DELETE_ERROR": "Unable to delete logo",
"IMAGE_UPLOAD_SIZE_ERROR": "Image size should be less than {size}MB"
},
"NAME": {
"LABEL": "Name",
"PLACEHOLDER": "Portal name",
"ERROR": "Name is required"
},
"HEADER_TEXT": {
"LABEL": "Header text",
"PLACEHOLDER": "Portal header text"
},
"PAGE_TITLE": {
"LABEL": "Page title",
"PLACEHOLDER": "Portal page title"
},
"HOME_PAGE_LINK": {
"LABEL": "Home page link",
"PLACEHOLDER": "Portal home page link",
"ERROR": "Invalid URL. The Home page link must start with 'http://' or 'https://'."
},
"SLUG": {
"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"
},
"BRAND_COLOR": {
"LABEL": "Brand color"
},
"SAVE_CHANGES": "Save changes"
},
"CONFIGURATION_FORM": {
"CUSTOM_DOMAIN": {
"HEADER": "Custom domain",
"LABEL": "Custom domain:",
"DESCRIPTION": "You can host your portal on a custom domain. For instance, if your website is yourdomain.com and you want your portal available at docs.yourdomain.com, simply enter that in this field.",
"PLACEHOLDER": "Portal custom domain",
"EDIT_BUTTON": "Edit custom domain",
"ADD_BUTTON": "Add custom domain",
"DIALOG": {
"ADD_HEADER": "Add custom domain",
"EDIT_HEADER": "Edit custom domain",
"ADD_CONFIRM_BUTTON_LABEL": "Add domain",
"EDIT_CONFIRM_BUTTON_LABEL": "Update domain",
"LABEL": "Custom domain",
"PLACEHOLDER": "Portal custom domain",
"ERROR": "Custom domain is required"
},
"DNS_CONFIGURATION_DIALOG": {
"HEADER": "DNS configuration",
"DESCRIPTION": "Log in to the account you have with your DNS provider, and add a CNAME record for subdomain pointing to chatwoot.help",
"HELP_TEXT": "Once this is done, you can reach out to our support to request for the auto-generated SSL certificate.",
"CONFIRM_BUTTON_LABEL": "Got it!"
}
},
"DELETE_PORTAL": {
"BUTTON": "Delete {portalName}",
"HEADER": "Delete portal",
"DESCRIPTION": "Permanently delete this portal. This action is irreversible",
"DIALOG": {
"HEADER": "Sure you want to delete {portalName}?",
"DESCRIPTION": "This is a permanent action that cannot be reversed.",
"CONFIRM_BUTTON_LABEL": "Delete"
}
},
"EDIT_CONFIGURATION": "Edit configuration"
},
"API": {
"CREATE_PORTAL": {
"SUCCESS_MESSAGE": "Portal created successfully",
"ERROR_MESSAGE": "Unable to create portal"
},
"UPDATE_PORTAL": {
"SUCCESS_MESSAGE": "Portal updated successfully",
"ERROR_MESSAGE": "Unable to update portal"
}
}
} }
} }
} }

View File

@@ -284,13 +284,10 @@
"REAUTHORIZE": "Your inbox connection has expired, please reconnect\n to continue receiving and sending messages", "REAUTHORIZE": "Your inbox connection has expired, please reconnect\n to continue receiving and sending messages",
"HELP_CENTER": { "HELP_CENTER": {
"TITLE": "Help Center", "TITLE": "Help Center",
"ALL_ARTICLES": "All Articles", "ARTICLES": "Articles",
"MY_ARTICLES": "My Articles", "CATEGORIES": "Categories",
"DRAFT": "Draft", "LOCALES": "Locales",
"ARCHIVED": "Archived", "SETTINGS": "Settings"
"CATEGORY": "Category",
"SETTINGS": "Settings",
"CATEGORY_EMPTY_MESSAGE": "No categories found"
}, },
"CHANNELS": "Channels", "CHANNELS": "Channels",
"SET_AUTO_OFFLINE": { "SET_AUTO_OFFLINE": {

View File

@@ -15,7 +15,6 @@ const Suspended = () => import('./suspended/Index.vue');
export default { export default {
routes: [ routes: [
...helpcenterRoutes.routes,
{ {
path: frontendURL('accounts/:accountId'), path: frontendURL('accounts/:accountId'),
component: AppContainer, component: AppContainer,
@@ -35,6 +34,7 @@ export default {
...contactRoutes, ...contactRoutes,
...searchRoutes, ...searchRoutes,
...notificationRoutes, ...notificationRoutes,
...helpcenterRoutes.routes,
], ],
}, },
{ {

View File

@@ -1,159 +0,0 @@
<script>
import Modal from 'dashboard/components/Modal.vue';
import { required } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import { useAlert } from 'dashboard/composables';
import allLocales from 'shared/constants/locales.js';
import { PORTALS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
import { useTrack } from 'dashboard/composables';
export default {
components: {
Modal,
},
props: {
show: {
type: Boolean,
default: false,
},
portal: {
type: Object,
default: () => ({}),
},
},
emits: ['cancel', 'update:show'],
setup() {
return { v$: useVuelidate() };
},
data() {
return {
selectedLocale: '',
isUpdating: false,
};
},
computed: {
localShow: {
get() {
return this.show;
},
set(value) {
this.$emit('update:show', value);
},
},
addedLocales() {
const { allowed_locales: allowedLocales } = this.portal.config;
return allowedLocales.map(locale => locale.code);
},
locales() {
const addedLocales = this.portal.config.allowed_locales.map(
locale => locale.code
);
return Object.keys(allLocales)
.map(key => {
return {
id: key,
name: allLocales[key],
code: key,
};
})
.filter(locale => {
return !addedLocales.includes(locale.code);
});
},
},
validations: {
selectedLocale: {
required,
},
},
methods: {
async onCreate() {
this.v$.$touch();
if (this.v$.$invalid) {
return;
}
const updatedLocales = this.addedLocales;
updatedLocales.push(this.selectedLocale);
this.isUpdating = true;
try {
await this.$store.dispatch('portals/update', {
portalSlug: this.portal.slug,
config: { allowed_locales: updatedLocales },
});
this.alertMessage = this.$t(
'HELP_CENTER.PORTAL.ADD_LOCALE.API.SUCCESS_MESSAGE'
);
this.onClose();
useTrack(PORTALS_EVENTS.CREATE_LOCALE, {
localeAdded: this.selectedLocale,
totalLocales: updatedLocales.length,
from: this.$route.name,
});
} catch (error) {
this.alertMessage =
error?.message ||
this.$t('HELP_CENTER.PORTAL.ADD_LOCALE.API.ERROR_MESSAGE');
} finally {
useAlert(this.alertMessage);
this.isUpdating = false;
}
},
onClose() {
this.$emit('cancel');
},
},
};
</script>
<template>
<Modal v-model:show="localShow" :on-close="onClose">
<woot-modal-header
:header-title="$t('HELP_CENTER.PORTAL.ADD_LOCALE.TITLE')"
:header-content="$t('HELP_CENTER.PORTAL.ADD_LOCALE.SUB_TITLE')"
/>
<form class="w-full" @submit.prevent="onCreate">
<div class="w-full">
<label :class="{ error: v$.selectedLocale.$error }">
{{ $t('HELP_CENTER.PORTAL.ADD_LOCALE.LOCALE.LABEL') }}
<select v-model="selectedLocale">
<option
v-for="locale in locales"
:key="locale.name"
:value="locale.id"
>
{{ locale.name }}-{{ locale.code }}
</option>
</select>
<span v-if="v$.selectedLocale.$error" class="message">
{{ $t('HELP_CENTER.PORTAL.ADD_LOCALE.LOCALE.ERROR') }}
</span>
</label>
<div class="w-full">
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<woot-button class="button clear" @click.prevent="onClose">
{{ $t('HELP_CENTER.PORTAL.ADD_LOCALE.BUTTONS.CANCEL') }}
</woot-button>
<woot-button>
{{ $t('HELP_CENTER.PORTAL.ADD_LOCALE.BUTTONS.CREATE') }}
</woot-button>
</div>
</div>
</div>
</form>
</Modal>
</template>
<style scoped lang="scss">
.input-container::v-deep {
margin: 0 0 var(--space-normal);
input {
margin-bottom: 0;
}
.message {
margin-top: 0;
}
}
</style>

View File

@@ -1,71 +0,0 @@
<script setup>
import { computed, defineEmits } from 'vue';
import { debounce } from '@chatwoot/utils';
import ResizableTextArea from 'shared/components/ResizableTextArea.vue';
import FullEditor from 'dashboard/components/widgets/WootWriter/FullEditor.vue';
import { ARTICLE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
const { article } = defineProps({
article: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['saveArticle']);
const saveArticle = debounce(value => emit('saveArticle', value), 400, false);
const articleTitle = computed({
get: () => article.title,
set: title => {
saveArticle({ title });
},
});
const articleContent = computed({
get: () => article.content,
set: content => {
saveArticle({ content });
},
});
</script>
<template>
<div class="edit-article--container">
<ResizableTextArea
v-model="articleTitle"
type="text"
:rows="1"
class="article-heading"
:placeholder="$t('HELP_CENTER.EDIT_ARTICLE.TITLE_PLACEHOLDER')"
/>
<FullEditor
v-model="articleContent"
class="article-content"
:placeholder="$t('HELP_CENTER.EDIT_ARTICLE.CONTENT_PLACEHOLDER')"
:enabled-menu-options="ARTICLE_EDITOR_MENU_OPTIONS"
/>
</div>
</template>
<style lang="scss" scoped>
.edit-article--container {
@apply my-8 mx-auto py-0 max-w-[56rem] w-full;
}
.article-heading {
@apply text-[2.5rem] font-semibold leading-normal w-full text-slate-900 dark:text-slate-75 p-4 hover:bg-slate-25 dark:hover:bg-slate-800 hover:rounded-md resize-none min-h-[4rem] max-h-[40rem] h-auto mb-2 border-0 border-solid border-transparent dark:border-transparent;
}
.article-content {
@apply py-0 px-4 h-fit;
}
::v-deep {
.ProseMirror-menubar-wrapper {
.ProseMirror-woot-style {
@apply min-h-[15rem] max-h-full;
}
}
}
</style>

View File

@@ -1,167 +0,0 @@
<script>
import { dynamicTime } from 'shared/helpers/timeHelper';
import portalMixin from '../mixins/portalMixin';
import { frontendURL } from 'dashboard/helper/URLHelper';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
export default {
components: {
Thumbnail,
},
mixins: [portalMixin],
props: {
showDragIcon: {
type: Boolean,
default: false,
},
id: {
type: Number,
required: true,
},
title: {
type: String,
required: true,
},
author: {
type: Object,
default: () => {},
},
category: {
type: Object,
default: () => {},
},
views: {
type: Number,
default: 0,
},
status: {
type: String,
default: 'draft',
values: ['archived', 'draft', 'published'],
},
updatedAt: {
type: Number,
default: 0,
},
},
computed: {
lastUpdatedAt() {
return dynamicTime(this.updatedAt);
},
formattedViewCount() {
return Number(this.views || 0).toLocaleString('en');
},
readableViewCount() {
return new Intl.NumberFormat('en-US', {
notation: 'compact',
compactDisplay: 'short',
}).format(this.views || 0);
},
articleAuthorName() {
return this.author?.name || '-';
},
labelColor() {
switch (this.status) {
case 'archived':
return 'secondary';
case 'draft':
return 'warning';
default:
return 'success';
}
},
},
methods: {
getCategoryRoute(categorySlug) {
const { portalSlug, locale } = this.$route.params;
return frontendURL(
`accounts/${this.accountId}/portals/${portalSlug}/${locale}/categories/${categorySlug}`
);
},
},
};
</script>
<template>
<div
class="grid grid-cols-1 gap-4 px-6 py-3 my-0 -mx-4 bg-white border-b text-slate-700 dark:text-slate-100 last:border-b-0 dark:bg-slate-900 lg:grid-cols-12 border-slate-50 dark:border-slate-800"
>
<span class="flex items-start col-span-6 gap-2 text-left">
<fluent-icon
v-if="showDragIcon"
size="20"
class="flex-shrink-0 block w-4 h-4 mt-1 cursor-move text-slate-200 dark:text-slate-700 hover:text-slate-400 hover:dark:text-slate-200"
icon="grab-handle"
/>
<div class="flex flex-col truncate">
<router-link :to="articleUrl(id)">
<h6
:title="title"
class="text-base ltr:text-left rtl:text-right text-slate-800 dark:text-slate-100 mb-0.5 leading-6 font-medium hover:underline overflow-hidden whitespace-nowrap text-ellipsis"
>
{{ title }}
</h6>
</router-link>
<div class="flex items-center gap-1">
<Thumbnail
v-if="author"
:src="author.thumbnail"
:username="author.name"
size="14px"
/>
<div
v-else
v-tooltip.right="
$t('HELP_CENTER.TABLE.COLUMNS.AUTHOR_NOT_AVAILABLE')
"
class="flex items-center justify-center rounded w-3.5 h-3.5 bg-woot-100 dark:bg-woot-700"
>
<fluent-icon
icon="person"
type="filled"
size="10"
class="text-woot-300 dark:text-woot-300"
/>
</div>
<span class="text-sm font-normal text-slate-700 dark:text-slate-200">
{{ articleAuthorName }}
</span>
</div>
</div>
</span>
<span class="flex items-center col-span-2">
<router-link
class="text-sm hover:underline p-0.5 truncate hover:bg-slate-25 hover:rounded-md"
:to="getCategoryRoute(category.slug)"
>
<span :title="category.name">
{{ category.name }}
</span>
</router-link>
</span>
<span
class="flex items-center text-xs lg:text-sm"
:title="formattedViewCount"
>
{{ readableViewCount }}
<span class="ml-1 lg:hidden">
{{ ` ${$t('HELP_CENTER.TABLE.HEADERS.READ_COUNT')}` }}
</span>
</span>
<span class="flex items-center capitalize">
<woot-label
class="!mb-0"
:title="status"
size="small"
variant="smooth"
:color-scheme="labelColor"
/>
</span>
<span
class="flex items-center justify-end col-span-2 text-xs first-letter:uppercase text-slate-700 dark:text-slate-100"
>
{{ lastUpdatedAt }}
</span>
</div>
</template>

View File

@@ -1,13 +1,13 @@
<script> <script>
import { debounce } from '@chatwoot/utils'; import { debounce } from '@chatwoot/utils';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import allLocales from 'shared/constants/locales.js';
import SearchHeader from './Header.vue'; import SearchHeader from './Header.vue';
import SearchResults from './SearchResults.vue'; import SearchResults from './SearchResults.vue';
import ArticleView from './ArticleView.vue'; import ArticleView from './ArticleView.vue';
import ArticlesAPI from 'dashboard/api/helpCenter/articles'; import ArticlesAPI from 'dashboard/api/helpCenter/articles';
import { buildPortalArticleURL } from 'dashboard/helper/portalHelper'; import { buildPortalArticleURL } from 'dashboard/helper/portalHelper';
import portalMixin from '../../mixins/portalMixin';
export default { export default {
name: 'ArticleSearchPopover', name: 'ArticleSearchPopover',
@@ -16,7 +16,6 @@ export default {
SearchResults, SearchResults,
ArticleView, ArticleView,
}, },
mixins: [portalMixin],
props: { props: {
selectedPortalSlug: { selectedPortalSlug: {
type: String, type: String,
@@ -69,6 +68,9 @@ export default {
article.slug article.slug
); );
}, },
localeName(code) {
return allLocales[code];
},
activeArticle(id) { activeArticle(id) {
return this.searchResultsWithUrl.find(article => article.id === id); return this.searchResultsWithUrl.find(article => article.id === id);
}, },

View File

@@ -1,168 +0,0 @@
<script>
import ArticleItem from './ArticleItem.vue';
import TableFooter from 'dashboard/components/widgets/TableFooter.vue';
import Draggable from 'vuedraggable';
export default {
components: {
ArticleItem,
TableFooter,
Draggable,
},
props: {
articles: {
type: Array,
default: () => [],
},
totalCount: {
type: Number,
default: 0,
},
currentPage: {
type: Number,
default: 1,
},
pageSize: {
type: Number,
default: 25,
},
},
emits: ['reorder', 'pageChange'],
data() {
return {
localArticles: this.articles || [],
};
},
computed: {
dragEnabled() {
// dragging allowed only on category page
return this.articles.length > 1 && this.onCategoryPage;
},
onCategoryPage() {
return this.$route.name === 'show_category';
},
showArticleFooter() {
return this.currentPage === 1
? this.totalCount > 25
: this.articles.length > 0;
},
},
watch: {
articles() {
this.localArticles = [...this.articles];
},
},
methods: {
onDragEnd() {
// why reuse the same positons array, instead of creating a new one?
// this ensures that the shuffling happens within the same group
// itself and does not create any new positions and avoid conflict with existing articles
// so if a user sorts on page number 2, and the positions are say [550, 560, 570, 580, 590]
// the new sorted items will be in the same position range as well
const sortedArticlePositions = this.localArticles
.map(article => article.position)
.sort((a, b) => {
// Why sort like this? Glad you asked!
// because JavaScript is the doom of my existence, and if a `compareFn` is not supplied,
// all non-undefined array elements are sorted by converting them to strings
// and comparing strings in UTF-16 code units order.
//
// so an array [20, 10000, 10, 30, 40] will be sorted as [10, 10000, 20, 30, 40]
return a - b;
});
const orderedArticles = this.localArticles.map(article => article.id);
const reorderedGroup = orderedArticles.reduce((obj, key, index) => {
obj[key] = sortedArticlePositions[index];
return obj;
}, {});
this.$emit('reorder', reorderedGroup);
},
onPageChange(page) {
this.$emit('pageChange', page);
},
},
};
</script>
<template>
<div class="flex-1">
<div
class="sticky z-10 content-center hidden h-12 grid-cols-12 gap-4 px-6 py-0 bg-white border-b lg:grid border-slate-50 dark:border-slate-700 top-16 dark:bg-slate-900"
:class="{ draggable: onCategoryPage }"
>
<div
class="col-span-6 px-0 py-2 text-sm font-semibold text-left capitalize text-slate-700 dark:text-slate-100 rtl:text-right"
>
{{ $t('HELP_CENTER.TABLE.HEADERS.TITLE') }}
</div>
<div
class="col-span-2 px-0 py-2 text-sm font-semibold text-left capitalize text-slate-700 dark:text-slate-100 rtl:text-right"
>
{{ $t('HELP_CENTER.TABLE.HEADERS.CATEGORY') }}
</div>
<div
class="hidden px-0 py-2 text-sm font-semibold text-left capitalize text-slate-700 dark:text-slate-100 rtl:text-right lg:block"
>
{{ $t('HELP_CENTER.TABLE.HEADERS.READ_COUNT') }}
</div>
<div
class="px-0 py-2 text-sm font-semibold text-left capitalize text-slate-700 dark:text-slate-100 rtl:text-right"
>
{{ $t('HELP_CENTER.TABLE.HEADERS.STATUS') }}
</div>
<div
class="hidden col-span-2 px-0 py-2 text-sm font-semibold text-right capitalize text-slate-700 dark:text-slate-100 rtl:text-left md:block"
>
{{ $t('HELP_CENTER.TABLE.HEADERS.LAST_EDITED') }}
</div>
</div>
<Draggable
tag="div"
class="px-4 pb-4 border-t-0"
:disabled="!dragEnabled"
:list="localArticles"
ghost-class="article-ghost-class"
item-key="id"
@start="dragging = true"
@end="onDragEnd"
>
<template #item="{ element }">
<ArticleItem
:id="element.id"
:key="element.id"
:class="{ draggable: onCategoryPage }"
:title="element.title"
:author="element.author"
:show-drag-icon="dragEnabled"
:category="element.category"
:views="element.views"
:status="element.status"
:updated-at="element.updated_at"
/>
</template>
</Draggable>
<TableFooter
v-if="showArticleFooter"
:current-page="currentPage"
:total-count="totalCount"
:page-size="pageSize"
class="bottom-0 border-t dark:bg-slate-900 border-slate-75 dark:border-slate-700/50"
@page-change="onPageChange"
/>
</div>
</template>
<style lang="scss" scoped>
/*
The .article-ghost-class class is maintained as the vueDraggable doesn't allow multiple classes
to be passed in the ghost-class prop.
*/
.article-ghost-class {
@apply opacity-50 bg-slate-50 dark:bg-slate-800;
}
</style>

View File

@@ -1,237 +0,0 @@
<script>
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import MultiselectDropdownItems from 'shared/components/ui/MultiselectDropdownItems.vue';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
export default {
components: {
FluentIcon,
WootDropdownItem,
WootDropdownMenu,
MultiselectDropdownItems,
},
props: {
headerTitle: {
type: String,
default: '',
},
count: {
type: Number,
default: 0,
},
selectedValue: {
type: String,
default: '',
},
selectedLocale: {
type: String,
default: '',
},
shouldShowSettings: {
type: Boolean,
default: false,
},
allLocales: {
type: Array,
default: () => [],
},
},
emits: ['openModal', 'open', 'close', 'newArticlePage', 'changeLocale'],
data() {
return {
showSortByDropdown: false,
showLocaleDropdown: false,
};
},
computed: {
shouldShowLocaleDropdown() {
return this.allLocales.length > 1;
},
switchableLocales() {
return this.allLocales.filter(
locale => locale.name !== this.selectedLocale
);
},
},
methods: {
openFilterModal() {
this.$emit('openModal');
},
openDropdown() {
this.$emit('open');
this.showSortByDropdown = true;
},
closeDropdown() {
this.$emit('close');
this.showSortByDropdown = false;
},
openLocaleDropdown() {
this.showLocaleDropdown = true;
},
closeLocaleDropdown() {
this.showLocaleDropdown = false;
},
onClickNewArticlePage() {
this.$emit('newArticlePage');
},
onClickSelectItem(value) {
const { name, code } = value;
this.closeLocaleDropdown();
if (!name || name === this.selectedLocale) {
return;
}
this.$emit('changeLocale', code);
},
},
};
</script>
<template>
<div
class="sticky top-0 z-50 flex items-center justify-between w-full h-16 p-6 bg-white dark:bg-slate-900"
>
<div class="flex items-center">
<woot-sidemenu-icon />
<div class="flex items-center mx-2 my-0">
<h3 class="mb-0 text-xl font-medium text-slate-800 dark:text-slate-100">
{{ headerTitle }}
</h3>
<span class="text-sm text-slate-600 dark:text-slate-300 mx-2 mt-0.5">{{
`(${count})`
}}</span>
</div>
</div>
<div class="flex items-center gap-1">
<woot-button
v-if="shouldShowSettings"
icon="filter"
color-scheme="secondary"
variant="hollow"
size="small"
@click="openFilterModal"
>
{{ $t('HELP_CENTER.HEADER.FILTER') }}
</woot-button>
<woot-button
v-if="shouldShowSettings"
icon="arrow-sort"
color-scheme="secondary"
size="small"
variant="hollow"
@click="openDropdown"
>
{{ $t('HELP_CENTER.HEADER.SORT') }}
<span
class="inline-flex items-center ml-1 rtl:ml-0 rtl:mr-1 text-slate-800 dark:text-slate-100"
>
{{ selectedValue }}
<FluentIcon class="dropdown-arrow" icon="chevron-down" size="14" />
</span>
</woot-button>
<div
v-if="showSortByDropdown"
v-on-clickaway="closeDropdown"
class="dropdown-pane dropdown-pane--open"
>
<WootDropdownMenu>
<WootDropdownItem>
<woot-button
variant="clear"
color-scheme="secondary"
size="small"
icon="send-clock"
>
{{ $t('HELP_CENTER.HEADER.DROPDOWN_OPTIONS.PUBLISHED') }}
</woot-button>
</WootDropdownItem>
<WootDropdownItem>
<woot-button
variant="clear"
color-scheme="secondary"
size="small"
icon="dual-screen-clock"
>
{{ $t('HELP_CENTER.HEADER.DROPDOWN_OPTIONS.DRAFT') }}
</woot-button>
</WootDropdownItem>
<WootDropdownItem>
<woot-button
variant="clear"
color-scheme="secondary"
size="small"
icon="calendar-clock"
>
{{ $t('HELP_CENTER.HEADER.DROPDOWN_OPTIONS.ARCHIVED') }}
</woot-button>
</WootDropdownItem>
</WootDropdownMenu>
</div>
<woot-button
v-if="shouldShowSettings"
v-tooltip.top-end="$t('HELP_CENTER.HEADER.SETTINGS_BUTTON')"
icon="settings"
variant="hollow"
size="small"
color-scheme="secondary"
/>
<div class="relative">
<woot-button
v-if="shouldShowLocaleDropdown"
icon="globe"
color-scheme="secondary"
size="small"
variant="hollow"
@click="openLocaleDropdown"
>
<div class="flex items-center justify-between w-full min-w-0">
<span
class="inline-flex items-center ml-1 rtl:ml-0 rtl:mr-1 text-slate-800 dark:text-slate-100"
>
{{ selectedLocale }}
<FluentIcon
class="dropdown-arrow"
icon="chevron-down"
size="14"
/>
</span>
</div>
</woot-button>
<div
v-if="showLocaleDropdown"
v-on-clickaway="closeLocaleDropdown"
class="dropdown-pane dropdown-pane--open"
>
<MultiselectDropdownItems
:options="switchableLocales"
:has-thumbnail="false"
:selected-items="[selectedLocale]"
:input-placeholder="
$t('HELP_CENTER.HEADER.LOCALE_SELECT.SEARCH_PLACEHOLDER')
"
:no-search-result="$t('HELP_CENTER.HEADER.LOCALE_SELECT.NO_RESULT')"
@select="onClickSelectItem"
/>
</div>
</div>
<woot-button
size="small"
icon="add"
color-scheme="primary"
@click="onClickNewArticlePage"
>
{{ $t('HELP_CENTER.HEADER.NEW_BUTTON') }}
</woot-button>
</div>
</div>
</template>
<style scoped lang="scss">
.dropdown-pane--open {
@apply absolute top-10 right-0 z-50 min-w-[8rem];
}
.dropdown-arrow {
@apply ml-1 rtl:ml-0 rtl:mr-1;
}
</style>

View File

@@ -1,252 +0,0 @@
<script>
import { useAlert } from 'dashboard/composables';
import { useTrack } from 'dashboard/composables';
import wootConstants from 'dashboard/constants/globals';
import { PORTALS_EVENTS } from '../../../../../helper/AnalyticsHelper/events';
const { ARTICLE_STATUS_TYPES } = wootConstants;
export default {
props: {
isSidebarOpen: {
type: Boolean,
default: true,
},
backButtonLabel: {
type: String,
default: '',
},
isUpdating: {
type: Boolean,
default: false,
},
isSaved: {
type: Boolean,
default: false,
},
enableOpenSidebarButton: {
type: Boolean,
default: false,
},
},
emits: ['back', 'show', 'add', 'updateMeta', 'open', 'close'],
data() {
return {
showActionsDropdown: false,
alertMessage: '',
ARTICLE_STATUS_TYPES,
};
},
computed: {
statusText() {
return this.isUpdating
? this.$t('HELP_CENTER.EDIT_HEADER.SAVING')
: this.$t('HELP_CENTER.EDIT_HEADER.SAVED');
},
articleSlug() {
return this.$route.params.articleSlug;
},
currentPortalSlug() {
return this.$route.params.portalSlug;
},
currentArticleStatus() {
return this.$store.getters['articles/articleStatus'](this.articleSlug);
},
isPublishedArticle() {
return this.currentArticleStatus === 'published';
},
isArchivedArticle() {
return this.currentArticleStatus === 'archived';
},
},
methods: {
onClickGoBack() {
this.$emit('back');
},
showPreview() {
this.$emit('show');
},
onClickAdd() {
this.$emit('add');
},
async updateArticleStatus(status) {
try {
await this.$store.dispatch('articles/update', {
portalSlug: this.currentPortalSlug,
articleId: this.articleSlug,
status: status,
});
this.$emit('updateMeta');
this.statusUpdateSuccessMessage(status);
this.closeActionsDropdown();
if (status === this.ARTICLE_STATUS_TYPES.ARCHIVE) {
useTrack(PORTALS_EVENTS.ARCHIVE_ARTICLE, { uiFrom: 'header' });
} else if (status === this.ARTICLE_STATUS_TYPES.PUBLISH) {
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
}
} catch (error) {
this.alertMessage =
error?.message || this.statusUpdateErrorMessage(status);
} finally {
useAlert(this.alertMessage);
}
},
statusUpdateSuccessMessage(status) {
if (status === this.ARTICLE_STATUS_TYPES.PUBLISH) {
this.alertMessage = this.$t('HELP_CENTER.PUBLISH_ARTICLE.API.SUCCESS');
} else if (status === this.ARTICLE_STATUS_TYPES.ARCHIVE) {
this.alertMessage = this.$t('HELP_CENTER.ARCHIVE_ARTICLE.API.SUCCESS');
}
},
statusUpdateErrorMessage(status) {
if (status === this.ARTICLE_STATUS_TYPES.PUBLISH) {
this.alertMessage = this.$t('HELP_CENTER.PUBLISH_ARTICLE.API.ERROR');
} else if (status === this.ARTICLE_STATUS_TYPES.ARCHIVE) {
this.alertMessage = this.$t('HELP_CENTER.ARCHIVE_ARTICLE.API.ERROR');
}
},
openSidebar() {
this.$emit('open');
},
closeSidebar() {
this.$emit('close');
},
openActionsDropdown() {
this.showActionsDropdown = !this.showActionsDropdown;
},
closeActionsDropdown() {
this.showActionsDropdown = false;
},
},
};
</script>
<template>
<div class="flex items-center justify-between w-full h-16">
<div class="flex items-center">
<woot-button
icon="chevron-left"
variant="clear"
size="small"
color-scheme="primary"
class="back-button"
@click="onClickGoBack"
>
{{ backButtonLabel }}
</woot-button>
</div>
<div class="flex items-center gap-1">
<span
v-if="isUpdating || isSaved"
class="items-center ml-4 mr-1 text-xs draft-status rtl:ml-2 rtl:mr-4 text-slate-400 dark:text-slate-300"
>
{{ statusText }}
</span>
<woot-button
class-names="article--buttons relative"
icon="globe"
color-scheme="secondary"
variant="hollow"
size="small"
@click="showPreview"
>
{{ $t('HELP_CENTER.EDIT_HEADER.PREVIEW') }}
</woot-button>
<!-- Hidden since this is in V2
<woot-button
v-if="shouldShowAddLocaleButton"
class-names="article--buttons relative"
icon="add"
color-scheme="secondary"
variant="hollow"
size="small"
@click="onClickAdd"
>
{{ $t('HELP_CENTER.EDIT_HEADER.ADD_TRANSLATION') }}
</woot-button> -->
<woot-button
v-if="!isSidebarOpen"
v-tooltip.top-end="$t('HELP_CENTER.EDIT_HEADER.OPEN_SIDEBAR')"
icon="pane-open"
class-names="article--buttons relative sidebar-button"
variant="hollow"
size="small"
color-scheme="secondary"
:is-disabled="enableOpenSidebarButton"
@click="openSidebar"
/>
<woot-button
v-if="isSidebarOpen"
v-tooltip.top-end="$t('HELP_CENTER.EDIT_HEADER.CLOSE_SIDEBAR')"
icon="pane-close"
class-names="article--buttons relative"
variant="hollow"
size="small"
color-scheme="secondary"
@click="closeSidebar"
/>
<div class="relative article--buttons">
<div class="button-group">
<woot-button
class-names="publish-button"
size="small"
icon="checkmark"
color-scheme="primary"
:is-disabled="!articleSlug || isPublishedArticle"
@click="updateArticleStatus(ARTICLE_STATUS_TYPES.PUBLISH)"
>
{{ $t('HELP_CENTER.EDIT_HEADER.PUBLISH_BUTTON') }}
</woot-button>
<woot-button
size="small"
icon="chevron-down"
:is-disabled="!articleSlug || isArchivedArticle"
@click="openActionsDropdown"
/>
</div>
<div
v-if="showActionsDropdown"
v-on-clickaway="closeActionsDropdown"
class="dropdown-pane dropdown-pane--open"
>
<woot-dropdown-menu>
<woot-dropdown-item>
<woot-button
variant="clear"
color-scheme="secondary"
size="small"
icon="book-clock"
@click="updateArticleStatus(ARTICLE_STATUS_TYPES.ARCHIVE)"
>
{{ $t('HELP_CENTER.EDIT_HEADER.MOVE_TO_ARCHIVE_BUTTON') }}
</woot-button>
</woot-dropdown-item>
</woot-dropdown-menu>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.article--buttons {
.dropdown-pane {
@apply absolute right-0;
}
}
.draft-status {
animation: fadeIn 1s;
@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
}
</style>

View File

@@ -1,364 +0,0 @@
<script>
import { defineAsyncComponent } from 'vue';
import { mapGetters } from 'vuex';
import UpgradePage from './UpgradePage.vue';
import NextSidebar from 'next/sidebar/Sidebar.vue';
import { frontendURL } from '../../../../helper/URLHelper';
import Sidebar from 'dashboard/components/layout/Sidebar.vue';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import PortalPopover from '../components/PortalPopover.vue';
import HelpCenterSidebar from '../components/Sidebar/Sidebar.vue';
import WootKeyShortcutModal from 'dashboard/components/widgets/modal/WootKeyShortcutModal.vue';
import AccountSelector from 'dashboard/components/layout/sidebarComponents/AccountSelector.vue';
import NotificationPanel from 'dashboard/routes/dashboard/notifications/components/NotificationPanel.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import portalMixin from '../mixins/portalMixin';
import AddCategory from '../pages/categories/AddCategory.vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { emitter } from 'shared/helpers/mitt';
const CommandBar = defineAsyncComponent(
() => import('dashboard/routes/dashboard/commands/commandbar.vue')
);
export default {
components: {
NextSidebar,
AccountSelector,
AddCategory,
CommandBar,
HelpCenterSidebar,
NotificationPanel,
PortalPopover,
Sidebar,
UpgradePage,
WootKeyShortcutModal,
},
mixins: [portalMixin],
setup() {
const { uiSettings, updateUISettings } = useUISettings();
return {
uiSettings,
updateUISettings,
};
},
data() {
return {
isOnDesktop: true,
showShortcutModal: false,
showNotificationPanel: false,
showPortalPopover: false,
showAddCategoryModal: false,
lastActivePortalSlug: '',
showAccountModal: false,
};
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
portals: 'portals/allPortals',
categories: 'categories/allCategories',
meta: 'portals/getMeta',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
isHelpCenterEnabled() {
return this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.HELP_CENTER
);
},
showNextSidebar() {
return this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.CHATWOOT_V4
);
},
isSidebarOpen() {
const { show_help_center_secondary_sidebar: showSecondarySidebar } =
this.uiSettings;
return showSecondarySidebar;
},
showHelpCenterSidebar() {
if (!this.isHelpCenterEnabled) {
return false;
}
return this.portals.length === 0 ? false : this.isSidebarOpen;
},
selectedPortal() {
const slug = this.$route.params.portalSlug || this.lastActivePortalSlug;
if (slug) return this.$store.getters['portals/portalBySlug'](slug);
return this.$store.getters['portals/allPortals'][0];
},
selectedLocaleInPortal() {
return this.$route.params.locale || this.defaultPortalLocale;
},
selectedPortalName() {
return this.selectedPortal ? this.selectedPortal.name : '';
},
selectedPortalSlug() {
return this.selectedPortal ? this.selectedPortal?.slug : '';
},
defaultPortalLocale() {
return this.selectedPortal
? this.selectedPortal?.meta?.default_locale
: '';
},
accessibleMenuItems() {
if (!this.selectedPortal) return [];
const {
allArticlesCount,
mineArticlesCount,
draftArticlesCount,
archivedArticlesCount,
} = this.meta;
return [
{
icon: 'book',
label: 'HELP_CENTER.ALL_ARTICLES',
key: 'list_all_locale_articles',
count: allArticlesCount,
toState: frontendURL(
`accounts/${this.accountId}/portals/${this.selectedPortalSlug}/${this.selectedLocaleInPortal}/articles`
),
toolTip: 'All Articles',
toStateName: 'list_all_locale_articles',
},
{
icon: 'pen',
label: 'HELP_CENTER.MY_ARTICLES',
key: 'list_mine_articles',
count: mineArticlesCount,
toState: frontendURL(
`accounts/${this.accountId}/portals/${this.selectedPortalSlug}/${this.selectedLocaleInPortal}/articles/mine`
),
toolTip: 'My articles',
toStateName: 'list_mine_articles',
},
{
icon: 'draft',
label: 'HELP_CENTER.DRAFT',
key: 'list_draft_articles',
count: draftArticlesCount,
toState: frontendURL(
`accounts/${this.accountId}/portals/${this.selectedPortalSlug}/${this.selectedLocaleInPortal}/articles/draft`
),
toolTip: 'Draft',
toStateName: 'list_draft_articles',
},
{
icon: 'archive',
label: 'HELP_CENTER.ARCHIVED',
key: 'list_archived_articles',
count: archivedArticlesCount,
toState: frontendURL(
`accounts/${this.accountId}/portals/${this.selectedPortalSlug}/${this.selectedLocaleInPortal}/articles/archived`
),
toolTip: 'Archived',
toStateName: 'list_archived_articles',
},
{
icon: 'settings',
label: 'HELP_CENTER.SETTINGS',
key: 'edit_portal_information',
toState: frontendURL(
`accounts/${this.accountId}/portals/${this.selectedPortalSlug}/edit`
),
toStateName: 'edit_portal_information',
},
];
},
additionalSecondaryMenuItems() {
if (!this.selectedPortal) return [];
return [
{
icon: 'folder',
label: 'HELP_CENTER.CATEGORY',
hasSubMenu: true,
showNewButton: true,
key: 'category',
children: this.categories.map(category => ({
id: category.id,
label: category.icon
? `${category.icon} ${category.name}`
: category.name,
count: category.meta.articles_count,
truncateLabel: true,
toState: frontendURL(
`accounts/${this.accountId}/portals/${this.selectedPortalSlug}/${category.locale}/categories/${category.slug}`
),
})),
},
];
},
currentRoute() {
return ' ';
},
headerTitle() {
return this.selectedPortal ? this.selectedPortal.name : '';
},
},
watch: {
'$route.name'() {
const routeName = this.$route?.name;
const routeParams = this.$route?.params;
const updateMetaInAllPortals = routeName === 'list_all_portals';
const updateMetaInEditArticle =
routeName === 'edit_article' && routeParams?.recentlyCreated;
const updateMetaInLocaleArticles =
routeName === 'list_all_locale_articles' &&
routeParams?.recentlyDeleted;
if (
updateMetaInAllPortals ||
updateMetaInEditArticle ||
updateMetaInLocaleArticles
) {
this.fetchPortalAndItsCategories();
}
},
},
mounted() {
emitter.on(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar);
const slug = this.$route.params.portalSlug;
if (slug) this.lastActivePortalSlug = slug;
this.fetchPortalAndItsCategories();
},
unmounted() {
emitter.off(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar);
},
updated() {
const slug = this.$route.params.portalSlug;
if (slug !== this.lastActivePortalSlug) {
this.lastActivePortalSlug = slug;
this.updateUISettings({
last_active_portal_slug: slug,
last_active_locale_code: this.selectedLocaleInPortal,
});
}
},
methods: {
toggleSidebar() {
if (this.portals.length > 0) {
this.updateUISettings({
show_help_center_secondary_sidebar: !this.isSidebarOpen,
});
}
},
async fetchPortalAndItsCategories() {
await this.$store.dispatch('portals/index');
const selectedPortalParam = {
portalSlug: this.selectedPortalSlug,
locale: this.selectedLocaleInPortal,
};
this.$store.dispatch('portals/show', selectedPortalParam);
this.$store.dispatch('categories/index', selectedPortalParam);
this.$store.dispatch('agents/get');
},
toggleKeyShortcutModal() {
this.showShortcutModal = true;
},
closeKeyShortcutModal() {
this.showShortcutModal = false;
},
openNotificationPanel() {
this.showNotificationPanel = true;
},
closeNotificationPanel() {
this.showNotificationPanel = false;
},
openPortalPopover() {
this.showPortalPopover = !this.showPortalPopover;
},
closePortalPopover() {
this.showPortalPopover = false;
},
onClickOpenAddCategoryModal() {
this.showAddCategoryModal = true;
},
onClickCloseAddCategoryModal() {
this.showAddCategoryModal = false;
},
toggleAccountModal() {
this.showAccountModal = !this.showAccountModal;
},
},
};
</script>
<template>
<div class="flex flex-grow-0 w-full h-full min-h-0 app-wrapper">
<NextSidebar
v-if="showNextSidebar"
@toggle-account-modal="toggleAccountModal"
@open-notification-panel="openNotificationPanel"
@open-key-shortcut-modal="toggleKeyShortcutModal"
@close-key-shortcut-modal="closeKeyShortcutModal"
/>
<Sidebar
v-else
:route="currentRoute"
@toggle-account-modal="toggleAccountModal"
@open-notification-panel="openNotificationPanel"
@open-key-shortcut-modal="toggleKeyShortcutModal"
@close-key-shortcut-modal="closeKeyShortcutModal"
/>
<HelpCenterSidebar
v-if="showHelpCenterSidebar"
:header-title="headerTitle"
:portal-slug="selectedPortalSlug"
:locale-slug="selectedLocaleInPortal"
:sub-title="localeName(selectedLocaleInPortal)"
:accessible-menu-items="accessibleMenuItems"
:additional-secondary-menu-items="additionalSecondaryMenuItems"
@open-popover="openPortalPopover"
@open-modal="onClickOpenAddCategoryModal"
/>
<section
v-if="isHelpCenterEnabled"
class="flex flex-1 h-full px-0 overflow-hidden bg-white dark:bg-slate-900"
>
<router-view @reload-locale="fetchPortalAndItsCategories" />
<CommandBar />
<AccountSelector
:show-account-modal="showAccountModal"
@close-account-modal="toggleAccountModal"
/>
<WootKeyShortcutModal
v-if="showShortcutModal"
@close="closeKeyShortcutModal"
@clickaway="closeKeyShortcutModal"
/>
<NotificationPanel
v-if="showNotificationPanel"
@close="closeNotificationPanel"
/>
<PortalPopover
v-if="showPortalPopover"
:portals="portals"
:active-portal-slug="selectedPortalSlug"
:active-locale="selectedLocaleInPortal"
@fetch-portal="fetchPortalAndItsCategories"
@close-popover="closePortalPopover"
/>
<AddCategory
v-if="showAddCategoryModal"
v-model:show="showAddCategoryModal"
:portal-name="selectedPortalName"
:locale="selectedLocaleInPortal"
:portal-slug="selectedPortalSlug"
@cancel="onClickCloseAddCategoryModal"
/>
</section>
<UpgradePage v-else />
</div>
</template>

View File

@@ -1,34 +0,0 @@
<script setup>
defineProps({
title: {
type: String,
default: null,
},
});
</script>
<template>
<div
class="flex-grow-0 flex-shrink-0 w-full h-full max-w-full bg-white border border-transparent border-solid dark:bg-slate-900 dark:border-transparent md:max-w-2xl"
>
<h3
v-if="$slots.title || title"
class="text-lg text-black-900 dark:text-slate-200"
>
<slot name="title">{{ title }}</slot>
</h3>
<div
class="mx-0 my-4 border-b border-solid border-slate-25 dark:border-slate-800"
>
<slot />
</div>
<div class="flex justify-between">
<div>
<slot name="footer-left" />
</div>
<div>
<slot name="footer-right" />
</div>
</div>
</div>
</template>

View File

@@ -1,379 +0,0 @@
<script>
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import LocaleItemTable from './PortalListItemTable.vue';
import { PORTALS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
import { useTrack } from 'dashboard/composables';
export default {
components: {
Thumbnail: thumbnail,
LocaleItemTable,
},
props: {
portal: {
type: Object,
default: () => {},
},
status: {
type: String,
default: '',
values: ['archived', 'draft', 'published'],
},
},
emits: ['addLocale', 'openSite'],
setup() {
const { updateUISettings } = useUISettings();
return {
updateUISettings,
};
},
data() {
return {
showDeleteConfirmationPopup: false,
alertMessage: '',
selectedPortalForDelete: {},
};
},
computed: {
labelColor() {
switch (this.status) {
case 'Archived':
return 'warning';
default:
return 'success';
}
},
deleteMessageValue() {
return ` ${this.selectedPortalForDelete.name}?`;
},
locales() {
return this.portal ? this.portal.config.allowed_locales : [];
},
allowedLocales() {
return Object.keys(this.locales).map(key => {
return this.locales[key].code;
});
},
articleCount() {
const { allowed_locales: allowedLocales } = this.portal.config;
return allowedLocales.reduce((acc, locale) => {
return acc + locale.articles_count;
}, 0);
},
},
methods: {
addLocale() {
this.$emit('addLocale', this.portal.id);
},
openSite() {
this.$emit('openSite', this.portal.slug);
},
openSettings() {
this.fetchPortalAndItsCategories();
this.navigateToPortalEdit();
},
onClickOpenDeleteModal(portal) {
this.selectedPortalForDelete = portal;
this.showDeleteConfirmationPopup = true;
},
closeDeletePopup() {
this.showDeleteConfirmationPopup = false;
},
async fetchPortalAndItsCategories() {
await this.$store.dispatch('portals/index');
const {
slug,
config: { allowed_locales: allowedLocales },
} = this.portal;
const selectedPortalParam = {
portalSlug: slug,
locale: allowedLocales[0].code,
};
this.$store.dispatch('portals/show', selectedPortalParam);
this.$store.dispatch('categories/index', selectedPortalParam);
},
async onClickDeletePortal() {
const { slug } = this.selectedPortalForDelete;
try {
await this.$store.dispatch('portals/delete', {
portalSlug: slug,
});
this.selectedPortalForDelete = {};
this.closeDeletePopup();
this.alertMessage = this.$t(
'HELP_CENTER.PORTAL.PORTAL_SETTINGS.DELETE_PORTAL.API.DELETE_SUCCESS'
);
this.updateUISettings({
last_active_portal_slug: undefined,
last_active_locale_code: undefined,
});
} catch (error) {
this.alertMessage =
error?.message ||
this.$t(
'HELP_CENTER.PORTAL.PORTAL_SETTINGS.DELETE_PORTAL.API.DELETE_ERROR'
);
} finally {
useAlert(this.alertMessage);
}
},
changeDefaultLocale({ localeCode }) {
this.updatePortalLocales({
allowedLocales: this.allowedLocales,
defaultLocale: localeCode,
successMessage: this.$t(
'HELP_CENTER.PORTAL.CHANGE_DEFAULT_LOCALE.API.SUCCESS_MESSAGE'
),
errorMessage: this.$t(
'HELP_CENTER.PORTAL.CHANGE_DEFAULT_LOCALE.API.ERROR_MESSAGE'
),
});
useTrack(PORTALS_EVENTS.SET_DEFAULT_LOCALE, {
newLocale: localeCode,
from: this.$route.name,
});
},
deletePortalLocale({ localeCode }) {
const updatedLocales = this.allowedLocales.filter(
code => code !== localeCode
);
const defaultLocale = this.portal.meta.default_locale;
this.updatePortalLocales({
allowedLocales: updatedLocales,
defaultLocale,
successMessage: this.$t(
'HELP_CENTER.PORTAL.DELETE_LOCALE.API.SUCCESS_MESSAGE'
),
errorMessage: this.$t(
'HELP_CENTER.PORTAL.DELETE_LOCALE.API.ERROR_MESSAGE'
),
});
useTrack(PORTALS_EVENTS.DELETE_LOCALE, {
deletedLocale: localeCode,
from: this.$route.name,
});
},
async updatePortalLocales({
allowedLocales,
defaultLocale,
successMessage,
errorMessage,
}) {
try {
await this.$store.dispatch('portals/update', {
portalSlug: this.portal.slug,
config: {
default_locale: defaultLocale,
allowed_locales: allowedLocales,
},
});
this.alertMessage = successMessage;
} catch (error) {
this.alertMessage = error?.message || errorMessage;
} finally {
useAlert(this.alertMessage);
}
},
navigateToPortalEdit() {
this.$router.push({
name: 'edit_portal_information',
params: { portalSlug: this.portal.slug },
});
},
},
};
</script>
<template>
<div>
<div
class="relative flex p-4 mb-3 bg-white border border-solid rounded-md dark:bg-slate-900 border-slate-100 dark:border-slate-600"
>
<Thumbnail :username="portal.name" variant="square" />
<div class="flex-grow ml-2 rtl:ml-0 rtl:mr-2">
<header class="flex items-start justify-between mb-8">
<div>
<div class="flex items-center">
<h2 class="mb-0 text-lg text-slate-800 dark:text-slate-100">
{{ portal.name }}
</h2>
<woot-label
:title="status"
:color-scheme="labelColor"
size="small"
variant="smooth"
class="mx-2 my-0"
/>
</div>
<p class="mb-0 text-sm text-slate-700 dark:text-slate-200">
{{ articleCount }}
{{
$t(
'HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.HEADER.COUNT_LABEL'
)
}}
</p>
</div>
<div class="flex flex-row gap-1">
<woot-button
variant="smooth"
size="small"
color-scheme="primary"
@click="addLocale"
>
{{
$t('HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.HEADER.ADD')
}}
</woot-button>
<woot-button
variant="hollow"
size="small"
color-scheme="secondary"
@click="openSite"
>
{{
$t('HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.HEADER.VISIT')
}}
</woot-button>
<woot-button
v-tooltip.top-end="
$t(
'HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.HEADER.SETTINGS'
)
"
variant="hollow"
size="small"
icon="settings"
color-scheme="secondary"
@click="openSettings"
/>
<woot-button
v-tooltip.top-end="
$t('HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.HEADER.DELETE')
"
variant="hollow"
color-scheme="alert"
size="small"
icon="delete"
@click="onClickOpenDeleteModal(portal)"
/>
</div>
</header>
<div class="mb-12">
<h2
class="mb-2 text-base font-medium text-slate-800 dark:text-slate-100"
>
{{
$t(
'HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.PORTAL_CONFIG.TITLE'
)
}}
</h2>
<div
class="flex justify-between mr-[6.25rem] rtl:mr-0 rtl:ml-[6.25rem] max-w-[80vw]"
>
<div class="flex flex-col">
<div class="flex flex-col items-start mb-4">
<label>{{
$t(
'HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.PORTAL_CONFIG.ITEMS.NAME'
)
}}</label>
<span class="text-sm text-slate-600 dark:text-slate-300">
{{ portal.name }}
</span>
</div>
<div class="flex flex-col items-start mb-4">
<label>{{
$t(
'HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.PORTAL_CONFIG.ITEMS.DOMAIN'
)
}}</label>
<span class="text-sm text-slate-600 dark:text-slate-300">
{{ portal.custom_domain }}
</span>
</div>
</div>
<div class="flex flex-col">
<div class="flex flex-col items-start mb-4">
<label>{{
$t(
'HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.PORTAL_CONFIG.ITEMS.SLUG'
)
}}</label>
<span class="text-sm text-slate-600 dark:text-slate-300">
{{ portal.slug }}
</span>
</div>
<div class="flex flex-col items-start mb-4">
<label>{{
$t(
'HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.PORTAL_CONFIG.ITEMS.TITLE'
)
}}</label>
<span class="text-sm text-slate-600 dark:text-slate-300">
{{ portal.page_title }}
</span>
</div>
</div>
<div class="flex flex-col">
<div class="flex flex-col items-start mb-4">
<label>{{
$t(
'HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.PORTAL_CONFIG.ITEMS.THEME'
)
}}</label>
<div class="flex items-center">
<div
class="w-4 h-4 mr-1 border border-solid rounded-md rtl:mr-0 rtl:ml-1 border-slate-25 dark:border-slate-800"
:style="{ background: portal.color }"
/>
</div>
</div>
<div class="flex flex-col items-start mb-4">
<label>{{
$t(
'HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.PORTAL_CONFIG.ITEMS.SUB_TEXT'
)
}}</label>
<span class="text-sm text-slate-600 dark:text-slate-300">
{{ portal.header_text }}
</span>
</div>
</div>
</div>
</div>
<div class="mb-12">
<h2
class="mb-2 text-base font-medium text-slate-800 dark:text-slate-100"
>
{{
$t(
'HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.AVAILABLE_LOCALES.TITLE'
)
}}
</h2>
<LocaleItemTable
:locales="locales"
:selected-locale-code="portal.meta.default_locale"
@change-default-locale="changeDefaultLocale"
@delete="deletePortalLocale"
/>
</div>
</div>
</div>
<woot-delete-modal
v-model:show="showDeleteConfirmationPopup"
:on-close="closeDeletePopup"
:on-confirm="onClickDeletePortal"
:title="$t('HELP_CENTER.PORTAL.PORTAL_SETTINGS.DELETE_PORTAL.TITLE')"
:message="$t('HELP_CENTER.PORTAL.PORTAL_SETTINGS.DELETE_PORTAL.MESSAGE')"
:message-value="deleteMessageValue"
:confirm-text="$t('HELP_CENTER.PORTAL.PORTAL_SETTINGS.DELETE_PORTAL.YES')"
:reject-text="$t('HELP_CENTER.PORTAL.PORTAL_SETTINGS.DELETE_PORTAL.NO')"
/>
</div>
</template>

View File

@@ -1,147 +0,0 @@
<script>
import portalMixin from '../mixins/portalMixin';
export default {
mixins: [portalMixin],
props: {
locales: {
type: Array,
default: () => [],
},
selectedLocaleCode: {
type: String,
default: '',
},
},
emits: ['changeDefaultLocale', 'delete'],
methods: {
changeDefaultLocale(localeCode) {
this.$emit('changeDefaultLocale', { localeCode });
},
deleteLocale(localeCode) {
this.$emit('delete', { localeCode });
},
},
};
</script>
<template>
<table class="woot-table">
<thead>
<tr>
<th scope="col">
{{
$t(
'HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.AVAILABLE_LOCALES.TABLE.NAME'
)
}}
</th>
<th scope="col">
{{
$t(
'HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.AVAILABLE_LOCALES.TABLE.CODE'
)
}}
</th>
<th scope="col">
{{
$t(
'HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.AVAILABLE_LOCALES.TABLE.ARTICLE_COUNT'
)
}}
</th>
<th scope="col">
{{
$t(
'HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.AVAILABLE_LOCALES.TABLE.CATEGORIES'
)
}}
</th>
<th scope="col" />
</tr>
</thead>
<tr>
<td colspan="100%" class="horizontal-line" />
</tr>
<tbody>
<tr v-for="locale in locales" :key="locale.code">
<td>
<span>{{ localeName(locale.code) }}</span>
<woot-label
v-if="locale.code === selectedLocaleCode"
:title="
$t(
'HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.AVAILABLE_LOCALES.TABLE.DEFAULT_LOCALE'
)
"
color-scheme="warning"
small
variant="smooth"
class="default-status"
/>
</td>
<td>
<span>{{ locale.code }}</span>
</td>
<td>
<span>{{ locale.articles_count }}</span>
</td>
<td>
<span>{{ locale.categories_count }}</span>
</td>
<td>
<woot-button
v-tooltip.top-end="
$t(
'HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.AVAILABLE_LOCALES.TABLE.SWAP'
)
"
size="tiny"
variant="smooth"
icon="arrow-swap"
color-scheme="primary"
:disabled="locale.code === selectedLocaleCode"
@click="changeDefaultLocale(locale.code)"
/>
<woot-button
v-tooltip.top-end="
$t(
'HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.AVAILABLE_LOCALES.TABLE.DELETE'
)
"
size="tiny"
variant="smooth"
icon="delete"
color-scheme="alert"
:disabled="locale.code === selectedLocaleCode"
@click="deleteLocale(locale.code)"
/>
</td>
</tr>
</tbody>
</table>
</template>
<style lang="scss" scoped>
table {
thead tr th {
@apply text-sm font-medium normal-case text-slate-600 dark:text-slate-200 pl-0 rtl:pl-2.5 rtl:pr-0 pt-0;
}
tbody tr {
@apply border-b-0;
td {
@apply text-sm pl-0 rtl:pl-2.5 rtl:pr-0;
.default-status {
@apply py-0 pr-0 pl-1;
}
span {
@apply text-slate-700 dark:text-slate-200;
}
}
}
}
.horizontal-line {
@apply border-b border-solid border-slate-75 dark:border-slate-700;
}
</style>

View File

@@ -1,86 +0,0 @@
<script>
import PortalSwitch from './PortalSwitch.vue';
export default {
components: {
PortalSwitch,
},
props: {
portals: {
type: Array,
default: () => [],
},
activePortalSlug: {
type: String,
default: '',
},
activeLocale: {
type: String,
default: '',
},
},
emits: ['closePopover', 'fetchPortal'],
methods: {
closePortalPopover() {
this.$emit('closePopover');
},
openPortalPage() {
this.closePortalPopover();
this.$router.push({
name: 'list_all_portals',
});
},
fetchPortalAndItsCategories() {
this.$emit('fetchPortal');
},
},
};
</script>
<template>
<div
v-on-clickaway="closePortalPopover"
class="absolute overflow-y-scroll max-h-[96vh] p-4 bg-white dark:bg-slate-800 rounded-md shadow-lg max-w-[30rem] z-[1000]"
>
<header>
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg text-slate-800 dark:text-slate-100">
{{ $t('HELP_CENTER.PORTAL.POPOVER.TITLE') }}
</h2>
<div>
<woot-button
variant="smooth"
color-scheme="secondary"
icon="settings"
size="small"
@click="openPortalPage"
>
{{ $t('HELP_CENTER.PORTAL.POPOVER.PORTAL_SETTINGS') }}
</woot-button>
<woot-button
variant="clear"
color-scheme="secondary"
icon="dismiss"
size="small"
@click="closePortalPopover"
/>
</div>
</div>
<p class="mt-2 text-xs text-slate-600 dark:text-slate-300">
{{ $t('HELP_CENTER.PORTAL.POPOVER.SUBTITLE') }}
</p>
</header>
<div>
<PortalSwitch
v-for="portal in portals"
:key="portal.id"
:portal="portal"
:active-portal-slug="activePortalSlug"
:active-locale="activeLocale"
:active="portal.slug === activePortalSlug"
@open-portal-page="closePortalPopover"
@fetch-portal="fetchPortalAndItsCategories"
/>
</div>
</div>
</template>

View File

@@ -1,240 +0,0 @@
<script setup>
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { defineOptions, reactive, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { convertToCategorySlug } from 'dashboard/helper/commons.js';
import { buildPortalURL } from 'dashboard/helper/portalHelper';
import wootConstants from 'dashboard/constants/globals';
import { hasValidAvatarUrl } from 'dashboard/helper/URLHelper';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { uploadFile } from 'dashboard/helper/uploadHelper';
import { isDomain } from 'shared/helpers/Validators';
import SettingsLayout from './Layout/SettingsLayout.vue';
const props = defineProps({
portal: {
type: Object,
default: () => {},
},
isSubmitting: {
type: Boolean,
default: false,
},
submitButtonText: {
type: String,
default: '',
},
});
const emit = defineEmits(['submit', 'deleteLogo']);
defineOptions({
name: 'PortalSettingsBasicForm',
});
const { EXAMPLE_URL } = wootConstants;
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
const { t } = useI18n();
const state = reactive({
name: '',
slug: '',
domain: '',
logoUrl: '',
avatarBlobId: '',
});
const rules = {
name: {
required,
minLength: minLength(2),
},
slug: {
required,
},
domain: {
isDomain,
},
};
const v$ = useVuelidate(rules, state);
const nameError = computed(() => {
if (v$.value.name.$error) {
return t('HELP_CENTER.CATEGORY.ADD.NAME.ERROR');
}
return '';
});
const slugError = computed(() => {
if (v$.value.slug.$error) {
return t('HELP_CENTER.CATEGORY.ADD.SLUG.ERROR');
}
return '';
});
const domainError = computed(() => {
if (v$.value.domain.$error) {
return t('HELP_CENTER.PORTAL.ADD.DOMAIN.ERROR');
}
return '';
});
const domainHelpText = computed(() => {
return buildPortalURL(state.slug);
});
const domainExampleHelpText = computed(() => {
return t('HELP_CENTER.PORTAL.ADD.DOMAIN.HELP_TEXT', {
exampleURL: EXAMPLE_URL,
});
});
const showDeleteButton = computed(() => {
return hasValidAvatarUrl(state.logoUrl);
});
onMounted(() => {
const portal = props.portal || {};
state.name = portal.name || '';
state.slug = portal.slug || '';
state.domain = portal.custom_domain || '';
if (portal.logo) {
const {
logo: { file_url: logoURL, blob_id: blobId },
} = portal;
state.logoUrl = logoURL;
state.avatarBlobId = blobId;
}
});
function onNameChange() {
state.slug = convertToCategorySlug(state.name);
}
function onSubmitClick() {
v$.value.$touch();
if (v$.value.$invalid) {
return;
}
const portal = {
name: state.name,
slug: state.slug,
custom_domain: state.domain,
blob_id: state.avatarBlobId || null,
};
emit('submit', portal);
}
async function deleteAvatar() {
state.logoUrl = '';
state.avatarBlobId = '';
emit('deleteLogo');
}
async function uploadLogoToStorage(file) {
try {
const { fileUrl, blobId } = await uploadFile(file);
if (fileUrl) {
state.logoUrl = fileUrl;
state.avatarBlobId = blobId;
}
} catch (error) {
useAlert(t('HELP_CENTER.PORTAL.ADD.LOGO.IMAGE_UPLOAD_ERROR'));
}
}
function onFileChange({ file }) {
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
uploadLogoToStorage(file);
} else {
const errorKey =
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SIZE_ERROR';
useAlert(t(errorKey, { size: MAXIMUM_FILE_UPLOAD_SIZE }));
}
}
</script>
<template>
<SettingsLayout
:title="
$t('HELP_CENTER.PORTAL.ADD.CREATE_FLOW_PAGE.BASIC_SETTINGS_PAGE.TITLE')
"
>
<div>
<div class="mb-4">
<div class="flex flex-row items-center">
<woot-avatar-uploader
:label="$t('HELP_CENTER.PORTAL.ADD.LOGO.LABEL')"
:src="state.logoUrl"
@on-avatar-select="onFileChange"
/>
<div v-if="showDeleteButton" class="avatar-delete-btn">
<woot-button
type="button"
color-scheme="alert"
variant="hollow"
size="small"
@click="deleteAvatar"
>
{{ $t('PROFILE_SETTINGS.DELETE_AVATAR') }}
</woot-button>
</div>
</div>
<p
class="mt-1 mb-0 text-xs not-italic text-slate-600 dark:text-slate-400"
>
{{ $t('HELP_CENTER.PORTAL.ADD.LOGO.HELP_TEXT') }}
</p>
</div>
<div class="mb-4">
<woot-input
v-model="state.name"
:class="{ error: v$.name.$error }"
:error="nameError"
:label="$t('HELP_CENTER.PORTAL.ADD.NAME.LABEL')"
:placeholder="$t('HELP_CENTER.PORTAL.ADD.NAME.PLACEHOLDER')"
:help-text="$t('HELP_CENTER.PORTAL.ADD.NAME.HELP_TEXT')"
@blur="v$.name.$touch"
@update:model-value="onNameChange"
/>
</div>
<div class="mb-4">
<woot-input
v-model="state.slug"
:class="{ error: v$.slug.$error }"
:error="slugError"
:label="$t('HELP_CENTER.PORTAL.ADD.SLUG.LABEL')"
:placeholder="$t('HELP_CENTER.PORTAL.ADD.SLUG.PLACEHOLDER')"
:help-text="domainHelpText"
@blur="v$.slug.$touch"
/>
</div>
<div class="mb-4">
<woot-input
v-model="state.domain"
:class="{ error: v$.domain.$error }"
:label="$t('HELP_CENTER.PORTAL.ADD.DOMAIN.LABEL')"
:placeholder="$t('HELP_CENTER.PORTAL.ADD.DOMAIN.PLACEHOLDER')"
:help-text="domainExampleHelpText"
:error="domainError"
@blur="v$.domain.$touch"
/>
</div>
</div>
<template #footer-right>
<woot-button
:is-loading="isSubmitting"
:is-disabled="v$.$invalid"
@click="onSubmitClick"
>
{{ submitButtonText }}
</woot-button>
</template>
</SettingsLayout>
</template>

View File

@@ -1,155 +0,0 @@
<script setup>
import { getRandomColor } from 'dashboard/helper/labelColor';
import SettingsLayout from './Layout/SettingsLayout.vue';
import wootConstants from 'dashboard/constants/globals';
import { useVuelidate } from '@vuelidate/core';
import { url } from '@vuelidate/validators';
import { defineOptions, reactive, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
portal: {
type: Object,
default: () => ({}),
},
isSubmitting: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['submit']);
defineOptions({
name: 'PortalSettingsCustomizationForm',
});
const { t } = useI18n();
const { EXAMPLE_URL } = wootConstants;
const state = reactive({
color: getRandomColor(),
pageTitle: '',
headerText: '',
homePageLink: '',
});
const rules = {
homePageLink: { url },
};
const homepageExampleHelpText = computed(() => {
return t('HELP_CENTER.PORTAL.ADD.HOME_PAGE_LINK.HELP_TEXT', {
exampleURL: EXAMPLE_URL,
});
});
const v$ = useVuelidate(rules, state);
function updateDataFromStore() {
const { portal } = props;
if (portal) {
state.color = portal.color || getRandomColor();
state.pageTitle = portal.page_title || '';
state.headerText = portal.header_text || '';
state.homePageLink = portal.homepage_link || '';
}
}
function onSubmitClick() {
v$.value.$touch();
if (v$.value.$invalid) {
return;
}
const portal = {
id: props.portal.id,
slug: props.portal.slug,
color: state.color,
page_title: state.pageTitle,
header_text: state.headerText,
homepage_link: state.homePageLink,
};
emit('submit', portal);
}
onMounted(() => {
updateDataFromStore();
});
</script>
<template>
<SettingsLayout
:title="
$t('HELP_CENTER.PORTAL.ADD.CREATE_FLOW_PAGE.CUSTOMIZATION_PAGE.TITLE')
"
>
<div class="flex-grow-0 flex-shrink-0">
<div class="mb-4">
<label>
{{ $t('HELP_CENTER.PORTAL.ADD.THEME_COLOR.LABEL') }}
</label>
<woot-color-picker v-model="state.color" />
<p
class="mt-1 mb-0 text-xs not-italic text-slate-600 dark:text-slate-400"
>
{{ $t('HELP_CENTER.PORTAL.ADD.THEME_COLOR.HELP_TEXT') }}
</p>
</div>
<div class="mb-4">
<woot-input
v-model="state.pageTitle"
:label="$t('HELP_CENTER.PORTAL.ADD.PAGE_TITLE.LABEL')"
:placeholder="$t('HELP_CENTER.PORTAL.ADD.PAGE_TITLE.PLACEHOLDER')"
:help-text="$t('HELP_CENTER.PORTAL.ADD.PAGE_TITLE.HELP_TEXT')"
/>
</div>
<div class="mb-4">
<woot-input
v-model="state.headerText"
:label="$t('HELP_CENTER.PORTAL.ADD.HEADER_TEXT.LABEL')"
:placeholder="$t('HELP_CENTER.PORTAL.ADD.HEADER_TEXT.PLACEHOLDER')"
:help-text="$t('HELP_CENTER.PORTAL.ADD.HEADER_TEXT.HELP_TEXT')"
/>
</div>
<div class="mb-4">
<woot-input
v-model="state.homePageLink"
:label="$t('HELP_CENTER.PORTAL.ADD.HOME_PAGE_LINK.LABEL')"
:placeholder="$t('HELP_CENTER.PORTAL.ADD.HOME_PAGE_LINK.PLACEHOLDER')"
:help-text="homepageExampleHelpText"
:error="
v$.homePageLink.$error
? $t('HELP_CENTER.PORTAL.ADD.HOME_PAGE_LINK.ERROR')
: ''
"
:class="{ error: v$.homePageLink.$error }"
@blur="v$.homePageLink.$touch"
/>
</div>
</div>
<template #footer-right>
<woot-button
:is-loading="isSubmitting"
:is-disabled="v$.$invalid"
@click="onSubmitClick"
>
{{
$t(
'HELP_CENTER.PORTAL.ADD.CREATE_FLOW_PAGE.CUSTOMIZATION_PAGE.UPDATE_PORTAL_BUTTON'
)
}}
</woot-button>
</template>
</SettingsLayout>
</template>
<style lang="scss" scoped>
::v-deep {
.colorpicker--selected {
@apply mb-0;
}
}
</style>

View File

@@ -1,175 +0,0 @@
<script>
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import portalMixin from '../mixins/portalMixin';
export default {
components: {
Thumbnail,
},
mixins: [portalMixin],
props: {
portal: {
type: Object,
default: () => ({}),
},
active: {
type: Boolean,
default: false,
},
activePortalSlug: {
type: String,
default: '',
},
activeLocale: {
type: String,
default: '',
},
},
emits: ['fetchPortal', 'openPortalPage'],
data() {
return {
selectedLocale: null,
};
},
computed: {
locales() {
return this.portal?.config?.allowed_locales;
},
articlesCount() {
const { allowed_locales: allowedLocales } = this.portal.config;
return allowedLocales.reduce((acc, locale) => {
return acc + locale.articles_count;
}, 0);
},
},
mounted() {
this.selectedLocale = this.locale || this.portal?.meta?.default_locale;
},
methods: {
onClick(event, code, portal) {
event.preventDefault();
this.$router.push({
name: 'list_all_locale_articles',
params: {
portalSlug: portal.slug,
locale: code,
},
});
this.$emit('fetchPortal');
this.$emit('openPortalPage');
},
isLocaleActive(code, slug) {
const isPortalActive = this.portal.slug === slug;
const isLocaleActive = this.activeLocale === code;
return isPortalActive && isLocaleActive;
},
isLocaleDefault(code) {
return this.portal?.meta?.default_locale === code;
},
},
};
</script>
<template>
<div class="portal" :class="{ active }">
<Thumbnail :username="portal.name" variant="square" />
<div class="actions-container">
<header class="flex items-center justify-between mb-2.5">
<div>
<h3 class="text-sm mb-0.5 text-slate-700 dark:text-slate-100">
{{ portal.name }}
</h3>
<p class="mb-0 text-xs text-slate-600 dark:text-slate-200">
{{ articlesCount }}
{{ $t('HELP_CENTER.PORTAL.ARTICLES_LABEL') }}
</p>
</div>
<woot-label
v-if="active"
variant="smooth"
size="small"
color-scheme="success"
:title="$t('HELP_CENTER.PORTAL.ACTIVE_BADGE')"
/>
</header>
<div class="portal-locales">
<h5 class="text-base text-slate-700 dark:text-slate-100">
{{ $t('HELP_CENTER.PORTAL.CHOOSE_LOCALE_LABEL') }}
</h5>
<ul>
<li v-for="locale in locales" :key="locale.code">
<woot-button
:variant="`locale-item ${
isLocaleActive(locale.code, activePortalSlug)
? 'smooth'
: 'clear'
}`"
size="large"
color-scheme="secondary"
@click="event => onClick(event, locale.code, portal)"
>
<div class="flex items-center justify-between w-full">
<div class="meta">
<h6 class="text-sm text-left mb-0.5">
<span class="text-slate-700 dark:text-slate-100">
{{ localeName(locale.code) }}
</span>
<span
v-if="isLocaleDefault(locale.code)"
class="text-sm text-slate-300 dark:text-slate-200"
>
{{ `(${$t('HELP_CENTER.PORTAL.DEFAULT')})` }}
</span>
</h6>
<span
class="flex w-full text-sm leading-4 text-left text-slate-600 dark:text-slate-200"
>
{{ locale.articles_count }}
{{ $t('HELP_CENTER.PORTAL.ARTICLES_LABEL') }} -
{{ locale.code }}
</span>
</div>
<div v-if="isLocaleActive(locale.code, activePortalSlug)">
<fluent-icon icon="checkmark" class="locale__radio" />
</div>
</div>
</woot-button>
</li>
</ul>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.portal {
@apply bg-white dark:bg-slate-800 rounded-md p-4 relative flex mb-4 border border-solid border-slate-100 dark:border-slate-600;
&.active {
@apply bg-white dark:bg-slate-800 border border-solid border-woot-400 dark:border-woot-500;
}
.actions-container {
@apply ml-2.5 rtl:ml-0 rtl:mr-2.5 flex-grow;
.portal-locales {
ul {
@apply list-none p-0 m-0;
}
.locale__radio {
@apply w-8 text-green-600 dark:text-green-600;
}
}
}
.locale-item {
@apply flex items-start py-1 px-4 rounded-md w-full mb-2;
p {
@apply mb-0 text-left;
}
}
}
</style>

View File

@@ -1,96 +0,0 @@
<script>
import SecondaryNavItem from 'dashboard/components/layout/sidebarComponents/SecondaryNavItem.vue';
import SidebarHeader from './SidebarHeader.vue';
export default {
components: {
SecondaryNavItem,
SidebarHeader,
},
props: {
thumbnailSrc: {
type: String,
default: '',
},
headerTitle: {
type: String,
default: '',
},
subTitle: {
type: String,
default: '',
},
portalSlug: {
type: String,
default: '',
},
localeSlug: {
type: String,
default: '',
},
accessibleMenuItems: {
type: Array,
default: () => [],
},
additionalSecondaryMenuItems: {
type: Array,
default: () => [],
},
},
emits: ['openPopover', 'openModal'],
computed: {
hasCategory() {
return (
this.additionalSecondaryMenuItems[0] &&
this.additionalSecondaryMenuItems[0].children.length > 0
);
},
portalLink() {
return `/hc/${this.portalSlug}/${this.localeSlug}`;
},
},
methods: {
openPortalPopover() {
this.$emit('openPopover');
},
onClickOpenAddCatogoryModal() {
this.$emit('openModal');
},
},
};
</script>
<template>
<div
class="flex flex-col h-full overflow-auto text-sm bg-white border-r w-60 dark:bg-slate-900 dark:border-slate-700 rtl:border-r-0 rtl:border-l border-slate-50"
>
<SidebarHeader
:thumbnail-src="thumbnailSrc"
:header-title="headerTitle"
:sub-title="subTitle"
:portal-link="portalLink"
class="px-4"
@open-popover="openPortalPopover"
/>
<transition-group name="menu-list" tag="ul" class="p-2 mb-0 ml-0 list-none">
<SecondaryNavItem
v-for="menuItem in accessibleMenuItems"
:key="menuItem.toState"
:menu-item="menuItem"
/>
<SecondaryNavItem
v-for="menuItem in additionalSecondaryMenuItems"
:key="menuItem.key"
:menu-item="menuItem"
@open="onClickOpenAddCatogoryModal"
/>
<p
v-if="!hasCategory"
key="empty-category-nessage"
class="p-1.5 px-4 text-slate-300"
>
{{ $t('SIDEBAR.HELP_CENTER.CATEGORY_EMPTY_MESSAGE') }}
</p>
</transition-group>
</div>
</template>

View File

@@ -1,76 +0,0 @@
<script>
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
export default {
components: {
Thumbnail,
},
props: {
thumbnailSrc: {
type: String,
default: '',
},
headerTitle: {
type: String,
default: '',
},
subTitle: {
type: String,
default: '',
},
portalLink: {
type: String,
default: '',
},
},
emits: ['openPopover'],
methods: {
popoutHelpCenter() {
window.open(this.portalLink, '_blank');
},
openPortalPopover() {
this.$emit('openPopover');
},
},
};
</script>
<template>
<div
class="flex items-center justify-between h-16 px-0 py-4 border-b mb-1/4 border-slate-50 dark:border-slate-700"
>
<div class="flex items-center">
<Thumbnail
size="32px"
:src="thumbnailSrc"
:username="headerTitle"
variant="square"
/>
<div class="flex flex-col items-start ml-2 rtl:ml-0 rtl:mr-2">
<h4
class="h-4 mb-0 overflow-hidden text-sm leading-4 w-28 whitespace-nowrap text-ellipsis text-slate-800 dark:text-slate-100"
>
{{ headerTitle }}
</h4>
<span class="h-4 text-xs leading-4 text-slate-600 dark:text-slate-200">
{{ subTitle }}
</span>
</div>
</div>
<div class="flex items-end">
<woot-button
variant="clear"
color-scheme="secondary"
size="small"
icon="arrow-up-right"
@click="popoutHelpCenter"
/>
<woot-button
variant="clear"
size="small"
color-scheme="secondary"
icon="arrow-swap"
@click="openPortalPopover"
/>
</div>
</div>
</template>

View File

@@ -1,226 +1,113 @@
import HelpCenterLayout from './components/HelpCenterLayout.vue';
import { getPortalRoute } from './helpers/routeHelper'; import { getPortalRoute } from './helpers/routeHelper';
const ListAllPortals = () => import('./pages/portals/ListAllPortals.vue'); import HelpCenterPageRouteView from './pages/HelpCenterPageRouteView.vue';
const NewPortal = () => import('./pages/portals/NewPortal.vue');
const EditPortal = () => import('./pages/portals/EditPortal.vue'); const PortalsIndex = () => import('./pages/PortalsIndexPage.vue');
const EditPortalBasic = () => import('./pages/portals/EditPortalBasic.vue'); const PortalsNew = () => import('./pages/PortalsNewPage.vue');
const EditPortalCustomization = () =>
import('./pages/portals/EditPortalCustomization.vue');
const EditPortalLocales = () => import('./pages/portals/EditPortalLocales.vue');
const ShowPortal = () => import('./pages/portals/ShowPortal.vue');
const PortalDetails = () => import('./pages/portals/PortalDetails.vue');
const PortalCustomization = () =>
import('./pages/portals/PortalCustomization.vue');
const PortalSettingsFinish = () =>
import('./pages/portals/PortalSettingsFinish.vue');
const ListAllCategories = () => const PortalsArticlesIndexPage = () =>
import('./pages/categories/ListAllCategories.vue'); import('./pages/PortalsArticlesIndexPage.vue');
const NewCategory = () => import('./pages/categories/NewCategory.vue'); const PortalsArticlesNewPage = () =>
const EditCategory = () => import('./pages/categories/EditCategory.vue'); import('./pages/PortalsArticlesNewPage.vue');
const ListCategoryArticles = () => const PortalsArticlesEditPage = () =>
import('./pages/articles/ListCategoryArticles.vue'); import('./pages/PortalsArticlesEditPage.vue');
const ListAllArticles = () => import('./pages/articles/ListAllArticles.vue');
const DefaultPortalArticles = () => const PortalsCategoriesIndexPage = () =>
import('./pages/articles/DefaultPortalArticles.vue'); import('./pages/PortalsCategoriesIndexPage.vue');
const NewArticle = () => import('./pages/articles/NewArticle.vue');
const EditArticle = () => import('./pages/articles/EditArticle.vue'); const PortalsLocalesIndexPage = () =>
import('./pages/PortalsLocalesIndexPage.vue');
const PortalsSettingsIndexPage = () =>
import('./pages/PortalsSettingsIndexPage.vue');
const portalRoutes = [ const portalRoutes = [
{ {
path: getPortalRoute(''), path: getPortalRoute(':portalSlug/:locale/:categorySlug?/articles/:tab?'),
name: 'default_portal_articles', name: 'portals_articles_index',
meta: {
permissions: ['administrator', 'knowledge_base_manage'],
},
component: DefaultPortalArticles,
},
{
path: getPortalRoute('all'),
name: 'list_all_portals',
meta: { meta: {
permissions: ['administrator', 'agent', 'knowledge_base_manage'], permissions: ['administrator', 'agent', 'knowledge_base_manage'],
}, },
component: ListAllPortals, component: PortalsArticlesIndexPage,
},
{
path: getPortalRoute('new'),
component: NewPortal,
children: [
{
path: '',
name: 'new_portal_information',
component: PortalDetails,
meta: {
permissions: ['administrator', 'knowledge_base_manage'],
},
},
{
path: ':portalSlug/customization',
name: 'portal_customization',
component: PortalCustomization,
meta: {
permissions: ['administrator', 'knowledge_base_manage'],
},
},
{
path: ':portalSlug/finish',
name: 'portal_finish',
component: PortalSettingsFinish,
meta: {
permissions: ['administrator', 'knowledge_base_manage'],
},
},
],
},
{
path: getPortalRoute(':portalSlug'),
name: 'portalSlug',
meta: {
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
},
component: ShowPortal,
},
{
path: getPortalRoute(':portalSlug/edit'),
meta: {
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
},
component: EditPortal,
children: [
{
path: '',
name: 'edit_portal_information',
component: EditPortalBasic,
meta: {
permissions: ['administrator', 'knowledge_base_manage'],
},
},
{
path: 'customizations',
name: 'edit_portal_customization',
component: EditPortalCustomization,
meta: {
permissions: ['administrator', 'knowledge_base_manage'],
},
},
{
path: 'locales',
name: 'edit_portal_locales',
component: EditPortalLocales,
meta: {
permissions: ['administrator', 'knowledge_base_manage'],
},
},
{
path: 'categories',
name: 'list_all_locale_categories',
meta: {
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
},
component: ListAllCategories,
},
],
},
];
const articleRoutes = [
{
path: getPortalRoute(':portalSlug/:locale/articles'),
name: 'list_all_locale_articles',
meta: {
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
},
component: ListAllArticles,
}, },
{ {
path: getPortalRoute(':portalSlug/:locale/articles/new'), path: getPortalRoute(':portalSlug/:locale/articles/new'),
name: 'new_article', name: 'portals_articles_new',
meta: { meta: {
permissions: ['administrator', 'agent', 'knowledge_base_manage'], permissions: ['administrator', 'agent', 'knowledge_base_manage'],
}, },
component: NewArticle, component: PortalsArticlesNewPage,
}, },
{ {
path: getPortalRoute(':portalSlug/:locale/articles/mine'), path: getPortalRoute(
name: 'list_mine_articles', ':portalSlug/:locale/:categorySlug?/articles/:tab?/edit/:articleSlug'
),
name: 'portals_articles_edit',
meta: { meta: {
permissions: ['administrator', 'agent', 'knowledge_base_manage'], permissions: ['administrator', 'agent', 'knowledge_base_manage'],
}, },
component: ListAllArticles, component: PortalsArticlesEditPage,
},
{
path: getPortalRoute(':portalSlug/:locale/articles/archived'),
name: 'list_archived_articles',
meta: {
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
},
component: ListAllArticles,
}, },
{
path: getPortalRoute(':portalSlug/:locale/articles/draft'),
name: 'list_draft_articles',
meta: {
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
},
component: ListAllArticles,
},
{
path: getPortalRoute(':portalSlug/:locale/articles/:articleSlug'),
name: 'edit_article',
meta: {
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
},
component: EditArticle,
},
];
const categoryRoutes = [
{ {
path: getPortalRoute(':portalSlug/:locale/categories'), path: getPortalRoute(':portalSlug/:locale/categories'),
name: 'all_locale_categories', name: 'portals_categories_index',
meta: { meta: {
permissions: ['administrator', 'agent', 'knowledge_base_manage'], permissions: ['administrator', 'agent', 'knowledge_base_manage'],
}, },
component: ListAllCategories, component: PortalsCategoriesIndexPage,
},
{
path: getPortalRoute(':portalSlug/:locale/categories/new'),
name: 'new_category_in_locale',
meta: {
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
},
component: NewCategory,
},
{
path: getPortalRoute(':portalSlug/:locale/categories/:categorySlug'),
name: 'show_category',
meta: {
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
},
component: ListAllArticles,
}, },
{ {
path: getPortalRoute( path: getPortalRoute(
':portalSlug/:locale/categories/:categorySlug/articles' ':portalSlug/:locale/categories/:categorySlug/articles'
), ),
name: 'show_category_articles', name: 'portals_categories_articles_index',
meta: { meta: {
permissions: ['administrator', 'agent', 'knowledge_base_manage'], permissions: ['administrator', 'agent', 'knowledge_base_manage'],
}, },
component: ListCategoryArticles, component: PortalsArticlesIndexPage,
}, },
{ {
path: getPortalRoute(':portalSlug/:locale/categories/:categorySlug'), path: getPortalRoute(
name: 'edit_category', ':portalSlug/:locale/categories/:categorySlug/articles/:articleSlug'
),
name: 'portals_categories_articles_edit',
meta: { meta: {
permissions: ['administrator', 'agent', 'knowledge_base_manage'], permissions: ['administrator', 'agent', 'knowledge_base_manage'],
}, },
component: EditCategory, component: PortalsArticlesEditPage,
},
{
path: getPortalRoute(':portalSlug/locales'),
name: 'portals_locales_index',
meta: {
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
},
component: PortalsLocalesIndexPage,
},
{
path: getPortalRoute(':portalSlug/settings'),
name: 'portals_settings_index',
meta: {
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
},
component: PortalsSettingsIndexPage,
},
{
path: getPortalRoute('new'),
name: 'portals_new',
meta: {
permissions: ['administrator', 'knowledge_base_manage'],
},
component: PortalsNew,
},
{
path: getPortalRoute(':navigationPath'),
name: 'portals_index',
meta: {
permissions: ['administrator', 'knowledge_base_manage'],
},
component: PortalsIndex,
}, },
]; ];
@@ -228,8 +115,8 @@ export default {
routes: [ routes: [
{ {
path: getPortalRoute(), path: getPortalRoute(),
component: HelpCenterLayout, component: HelpCenterPageRouteView,
children: [...portalRoutes, ...articleRoutes, ...categoryRoutes], children: [...portalRoutes],
}, },
], ],
}; };

View File

@@ -1,4 +1,4 @@
import { frontendURL } from '../../../../helper/URLHelper'; import { frontendURL } from 'dashboard/helper/URLHelper';
export const getPortalRoute = (path = '') => { export const getPortalRoute = (path = '') => {
const slugToBeAdded = path ? `/${path}` : ''; const slugToBeAdded = path ? `/${path}` : '';

View File

@@ -1,24 +0,0 @@
import { mapGetters } from 'vuex';
import { frontendURL } from 'dashboard/helper/URLHelper';
import allLocales from 'shared/constants/locales.js';
export default {
computed: {
...mapGetters({ accountId: 'getCurrentAccountId' }),
portalSlug() {
return this.$route.params.portalSlug;
},
locale() {
return this.$route.params.locale;
},
},
methods: {
articleUrl(id) {
return frontendURL(
`accounts/${this.accountId}/portals/${this.portalSlug}/${this.locale}/articles/${id}`
);
},
localeName(code) {
return allLocales[code];
},
},
};

View File

@@ -1,78 +0,0 @@
import { shallowMount } from '@vue/test-utils';
import { createStore } from 'vuex';
import { createRouter, createWebHistory } from 'vue-router';
import portalMixin from '../portalMixin';
import ListAllArticles from '../../pages/portals/ListAllPortals.vue';
// Create router instance
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/:portalSlug/:locale/articles', // Add leading "/"
name: 'list_all_locale_articles',
component: ListAllArticles,
},
],
});
describe('portalMixin', () => {
let getters;
let store;
let wrapper;
beforeEach(() => {
getters = {
getCurrentAccountId: () => 1,
};
const Component = {
render() {},
title: 'TestComponent',
mixins: [portalMixin],
};
store = createStore({ getters });
wrapper = shallowMount(Component, {
global: {
plugins: [store, router],
},
});
});
it('returns account id', () => {
expect(wrapper.vm.accountId).toBe(1);
});
it('returns article url', async () => {
await router.push({
name: 'list_all_locale_articles',
params: { portalSlug: 'fur-rent', locale: 'en' },
});
expect(wrapper.vm.articleUrl(1)).toBe(
'/app/accounts/1/portals/fur-rent/en/articles/1'
);
});
it('returns portal locale', async () => {
await router.push({
name: 'list_all_locale_articles',
params: { portalSlug: 'fur-rent', locale: 'es' },
});
expect(wrapper.vm.portalSlug).toBe('fur-rent');
});
it('returns portal slug', async () => {
await router.push({
name: 'list_all_locale_articles',
params: { portalSlug: 'campaign', locale: 'es' },
});
expect(wrapper.vm.portalSlug).toBe('campaign');
});
it('returns locale name', async () => {
await router.push({
name: 'list_all_locale_articles',
params: { portalSlug: 'fur-rent', locale: 'es' },
});
expect(wrapper.vm.localeName('es')).toBe('Spanish');
});
});

View File

@@ -0,0 +1,76 @@
<script setup>
import { computed, onMounted, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
import UpgradePage from '../components/UpgradePage.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const route = useRoute();
const store = useStore();
const { uiSettings, updateUISettings } = useUISettings();
const accountId = computed(() => store.getters.getCurrentAccountId);
const portals = computed(() => store.getters['portals/allPortals']);
const isFeatureEnabledonAccount = (id, flag) =>
store.getters['accounts/isFeatureEnabledonAccount'](id, flag);
const isHelpCenterEnabled = computed(() =>
isFeatureEnabledonAccount(accountId.value, FEATURE_FLAGS.HELP_CENTER)
);
const selectedPortal = computed(() => {
const slug =
route.params.portalSlug || uiSettings.value.last_active_portal_slug;
if (slug) return store.getters['portals/portalBySlug'](slug);
return portals.value[0];
});
const defaultPortalLocale = computed(() =>
selectedPortal.value ? selectedPortal.value.meta?.default_locale : ''
);
const selectedLocaleInPortal = computed(
() => route.params.locale || defaultPortalLocale.value
);
const selectedPortalSlug = computed(() =>
selectedPortal.value ? selectedPortal.value.slug : ''
);
const fetchPortalAndItsCategories = async () => {
await store.dispatch('portals/index');
const selectedPortalParam = {
portalSlug: selectedPortalSlug.value,
locale: selectedLocaleInPortal.value,
};
store.dispatch('portals/show', selectedPortalParam);
store.dispatch('categories/index', selectedPortalParam);
store.dispatch('agents/get');
};
onMounted(() => fetchPortalAndItsCategories());
watch(
() => route.params.portalSlug,
newSlug => {
if (newSlug && newSlug !== uiSettings.value.last_active_portal_slug) {
updateUISettings({
last_active_portal_slug: newSlug,
last_active_locale_code: selectedLocaleInPortal.value,
});
}
}
);
</script>
<template>
<div class="flex flex-grow-0 w-full h-full min-h-0 app-wrapper">
<section
v-if="isHelpCenterEnabled"
class="flex flex-1 h-full px-0 overflow-hidden bg-white dark:bg-slate-900"
>
<router-view />
</section>
<UpgradePage v-else />
</div>
</template>

View File

@@ -0,0 +1,109 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert, useTrack } from 'dashboard/composables';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { buildPortalArticleURL } from 'dashboard/helper/portalHelper';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import ArticleEditor from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue';
const route = useRoute();
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const { articleSlug, portalSlug } = route.params;
const articleById = useMapGetter('articles/articleById');
const article = computed(() => articleById.value(articleSlug));
const isUpdating = ref(false);
const isSaved = ref(false);
const portalLink = computed(() => {
const { slug: categorySlug, locale: categoryLocale } = article.value.category;
const { slug: articleSlugValue } = article.value;
return buildPortalArticleURL(
portalSlug,
categorySlug,
categoryLocale,
articleSlugValue
);
});
const saveArticle = async ({ ...values }) => {
isUpdating.value = true;
try {
await store.dispatch('articles/update', {
portalSlug,
articleId: articleSlug,
...values,
});
isSaved.value = true;
} catch (error) {
const errorMessage =
error?.message || t('HELP_CENTER.EDIT_ARTICLE_PAGE.API.ERROR');
useAlert(errorMessage);
} finally {
setTimeout(() => {
isUpdating.value = false;
isSaved.value = true;
}, 1500);
}
};
const isCategoryArticles = computed(() => {
return (
route.name === 'portals_categories_articles_index' ||
route.name === 'portals_categories_articles_edit' ||
route.name === 'portals_categories_index'
);
});
const goBackToArticles = () => {
const { tab, categorySlug, locale } = route.params;
if (isCategoryArticles.value) {
router.push({
name: 'portals_categories_articles_index',
params: { categorySlug, locale },
});
} else {
router.push({
name: 'portals_articles_index',
params: { tab, categorySlug, locale },
});
}
};
const fetchArticleDetails = () => {
store.dispatch('articles/show', {
id: articleSlug,
portalSlug,
});
};
const previewArticle = () => {
window.open(portalLink.value, '_blank');
useTrack(PORTALS_EVENTS.PREVIEW_ARTICLE, {
status: article.value?.status,
});
};
onMounted(() => {
fetchArticleDetails();
});
</script>
<template>
<ArticleEditor
:article="article"
:is-updating="isUpdating"
:is-saved="isSaved"
@save-article="saveArticle"
@preview-article="previewArticle"
@go-back="goBackToArticles"
/>
</template>

View File

@@ -0,0 +1,116 @@
<script setup>
import { computed, ref, onMounted, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import allLocales from 'shared/constants/locales.js';
import { getArticleStatus } from 'dashboard/helper/portalHelper.js';
import ArticlesPage from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue';
const route = useRoute();
const store = useStore();
const pageNumber = ref(1);
const articles = useMapGetter('articles/allArticles');
const categories = useMapGetter('categories/allCategories');
const meta = useMapGetter('articles/getMeta');
const portalMeta = useMapGetter('portals/getMeta');
const currentUserId = useMapGetter('getCurrentUserID');
const getPortalBySlug = useMapGetter('portals/portalBySlug');
const selectedPortalSlug = computed(() => route.params.portalSlug);
const selectedCategorySlug = computed(() => route.params.categorySlug);
const status = computed(() => getArticleStatus(route.params.tab));
const author = computed(() =>
route.params.tab === 'mine' ? currentUserId.value : null
);
const activeLocale = computed(() => route.params.locale);
const portal = computed(() => getPortalBySlug.value(selectedPortalSlug.value));
const allowedLocales = computed(() => {
if (!portal.value) {
return [];
}
const { allowed_locales: allAllowedLocales } = portal.value.config;
return allAllowedLocales.map(locale => {
return {
id: locale.code,
name: allLocales[locale.code],
code: locale.code,
};
});
});
const defaultPortalLocale = computed(() => {
return portal.value?.meta?.default_locale;
});
const selectedLocaleInPortal = computed(() => {
return route.params.locale || defaultPortalLocale.value;
});
const isCategoryArticles = computed(() => {
return (
route.name === 'portals_categories_articles_index' ||
route.name === 'portals_categories_articles_edit' ||
route.name === 'portals_categories_index'
);
});
const fetchArticles = ({ pageNumber: pageNumberParam } = {}) => {
store.dispatch('articles/index', {
pageNumber: pageNumberParam || pageNumber.value,
portalSlug: selectedPortalSlug.value,
locale: activeLocale.value,
status: status.value,
authorId: author.value,
categorySlug: selectedCategorySlug.value,
});
};
const onPageChange = pageNumberParam => {
fetchArticles({ pageNumber: pageNumberParam });
};
const fetchPortalAndItsCategories = async locale => {
await store.dispatch('portals/index');
const selectedPortalParam = {
portalSlug: selectedPortalSlug.value,
locale: locale || selectedLocaleInPortal.value,
};
store.dispatch('portals/show', selectedPortalParam);
store.dispatch('categories/index', selectedPortalParam);
store.dispatch('agents/get');
};
onMounted(() => {
fetchArticles();
});
watch(
() => route.params,
() => {
pageNumber.value = 1;
fetchArticles();
},
{ deep: true, immediate: true }
);
</script>
<template>
<div class="w-full h-full">
<ArticlesPage
v-if="portal"
:articles="articles"
:portal-name="portal.name"
:categories="categories"
:allowed-locales="allowedLocales"
:meta="meta"
:portal-meta="portalMeta"
:is-category-articles="isCategoryArticles"
@page-change="onPageChange"
@fetch-portal="fetchPortalAndItsCategories"
/>
</div>
</template>

View File

@@ -0,0 +1,94 @@
<script setup>
import { ref, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert, useTrack } from 'dashboard/composables';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import ArticleEditor from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue';
const route = useRoute();
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const { portalSlug } = route.params;
const selectedAuthorId = ref(null);
const selectedCategoryId = ref(null);
const currentUserId = useMapGetter('getCurrentUserID');
const categories = useMapGetter('categories/allCategories');
const categoryId = computed(() => categories.value[0]?.id || null);
const article = ref({});
const isUpdating = ref(false);
const isSaved = ref(false);
const setAuthorId = authorId => {
selectedAuthorId.value = authorId;
};
const setCategoryId = newCategoryId => {
selectedCategoryId.value = newCategoryId;
};
const createNewArticle = async ({ title, content }) => {
if (title) article.value.title = title;
if (content) article.value.content = content;
if (!article.value.title || !article.value.content) return;
isUpdating.value = true;
try {
const { locale } = route.params;
const articleId = await store.dispatch('articles/create', {
portalSlug,
content: article.value.content,
title: article.value.title,
locale: locale,
authorId: selectedAuthorId.value || currentUserId.value,
categoryId: selectedCategoryId.value || categoryId.value,
});
useTrack(PORTALS_EVENTS.CREATE_ARTICLE, { locale });
router.replace({
name: 'portals_articles_edit',
params: {
articleSlug: articleId,
portalSlug,
locale,
},
});
} catch (error) {
const errorMessage =
error?.message || t('HELP_CENTER.EDIT_ARTICLE_PAGE.API.ERROR');
useAlert(errorMessage);
} finally {
isUpdating.value = false;
}
};
const goBackToArticles = () => {
const { tab, categorySlug, locale } = route.params;
router.push({
name: 'portals_articles_index',
params: { tab, categorySlug, locale },
});
};
</script>
<template>
<ArticleEditor
:article="article"
:is-updating="isUpdating"
:is-saved="isSaved"
@save-article="createNewArticle"
@go-back="goBackToArticles"
@set-author="setAuthorId"
@set-category="setCategoryId"
/>
</template>

View File

@@ -0,0 +1,66 @@
<script setup>
import { computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import allLocales from 'shared/constants/locales.js';
import CategoriesPage from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoriesPage.vue';
const store = useStore();
const route = useRoute();
const categories = useMapGetter('categories/allCategories');
const selectedPortalSlug = computed(() => route.params.portalSlug);
const getPortalBySlug = useMapGetter('portals/portalBySlug');
const isFetching = useMapGetter('categories/isFetching');
const portal = computed(() => getPortalBySlug.value(selectedPortalSlug.value));
const allowedLocales = computed(() => {
if (!portal.value) {
return [];
}
const { allowed_locales: allAllowedLocales } = portal.value.config;
return allAllowedLocales.map(locale => {
return {
id: locale.code,
name: allLocales[locale.code],
code: locale.code,
};
});
});
const fetchCategoriesByPortalSlugAndLocale = async localeCode => {
await store.dispatch('categories/index', {
portalSlug: selectedPortalSlug.value,
locale: localeCode,
});
};
const updateMeta = async localeCode => {
return store.dispatch('portals/show', {
portalSlug: selectedPortalSlug.value,
locale: localeCode,
});
};
const fetchCategories = async localeCode => {
await fetchCategoriesByPortalSlugAndLocale(localeCode);
await updateMeta(localeCode);
};
onMounted(() => {
fetchCategoriesByPortalSlugAndLocale(route.params.locale);
});
</script>
<template>
<CategoriesPage
:categories="categories"
:is-fetching="isFetching"
:allowed-locales="allowedLocales"
@fetch-categories="fetchCategories"
/>
</template>

View File

@@ -0,0 +1,76 @@
<script setup>
import { computed, nextTick, onMounted } from 'vue';
import { useStore } from 'vuex';
import { useRoute, useRouter } from 'vue-router';
import { useUISettings } from 'dashboard/composables/useUISettings';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const store = useStore();
const router = useRouter();
const { uiSettings } = useUISettings();
const route = useRoute();
const portals = computed(() => store.getters['portals/allPortals']);
const isPortalPresent = portalSlug => {
return !!portals.value.find(portal => portal.slug === portalSlug);
};
const routeToView = (name, params) => {
router.replace({ name, params, replace: true });
};
const generateRouterParams = () => {
const {
last_active_portal_slug: lastActivePortalSlug,
last_active_locale_code: lastActiveLocaleCode,
} = uiSettings.value || {};
if (isPortalPresent(lastActivePortalSlug)) {
return {
portalSlug: lastActivePortalSlug,
locale: lastActiveLocaleCode,
};
}
if (portals.value.length > 0) {
const { slug: portalSlug, meta: { default_locale: locale } = {} } =
portals.value[0];
return { portalSlug, locale };
}
return null;
};
const routeToLastActivePortal = () => {
const params = generateRouterParams();
const { navigationPath } = route.params;
const isAValidRoute = [
'portals_articles_index',
'portals_categories_index',
'portals_locales_index',
'portals_settings_index',
].includes(navigationPath);
const navigateTo = isAValidRoute ? navigationPath : 'portals_articles_index';
if (params) {
return routeToView(navigateTo, params);
}
return routeToView('portals_new', {});
};
const performRouting = async () => {
await store.dispatch('portals/index');
nextTick(() => routeToLastActivePortal());
};
onMounted(() => performRouting());
</script>
<template>
<div
class="flex items-center justify-center w-full bg-n-background text-slate-600 dark:text-slate-200"
>
<Spinner />
</div>
</template>

View File

@@ -0,0 +1,34 @@
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store.js';
import allLocales from 'shared/constants/locales.js';
import LocalesPage from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocalesPage.vue';
const route = useRoute();
const getPortalBySlug = useMapGetter('portals/portalBySlug');
const portal = computed(() => getPortalBySlug.value(route.params.portalSlug));
const allowedLocales = computed(() => {
if (!portal.value) {
return [];
}
const { allowed_locales: allAllowedLocales } = portal.value.config;
return allAllowedLocales.map(locale => {
return {
id: locale?.code,
name: allLocales[locale?.code],
code: locale?.code,
articlesCount: locale?.articles_count || 0,
categoriesCount: locale?.categories_count || 0,
};
});
});
</script>
<template>
<LocalesPage :locales="allowedLocales" :portal="portal" />
</template>

View File

@@ -0,0 +1,9 @@
<script setup>
import PortalEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/Portal/PortalEmptyState.vue';
</script>
<template>
<div class="w-full h-full bg-n-background">
<PortalEmptyState />
</div>
</template>

View File

@@ -0,0 +1,122 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAlert } from 'dashboard/composables';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import PortalSettings from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalSettings.vue';
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const router = useRouter();
const { updateUISettings } = useUISettings();
const portals = useMapGetter('portals/allPortals');
const isFetching = useMapGetter('portals/isFetchingPortals');
const getPortalBySlug = useMapGetter('portals/portalBySlug');
const getNextAvailablePortal = deletedPortalSlug =>
portals.value?.find(portal => portal.slug !== deletedPortalSlug) ?? null;
const getDefaultLocale = slug => {
return getPortalBySlug.value(slug)?.meta?.default_locale;
};
const fetchPortalAndItsCategories = async (slug, locale) => {
const selectedPortalParam = { portalSlug: slug, locale };
await Promise.all([
store.dispatch('portals/index'),
store.dispatch('portals/show', selectedPortalParam),
store.dispatch('categories/index', selectedPortalParam),
store.dispatch('agents/get'),
store.dispatch('inboxes/get'),
]);
};
const updateRouteAfterDeletion = async deletedPortalSlug => {
const nextPortal = getNextAvailablePortal(deletedPortalSlug);
if (nextPortal) {
const {
slug,
meta: { default_locale: defaultLocale },
} = nextPortal;
await fetchPortalAndItsCategories(slug, defaultLocale);
router.push({
name: 'portals_articles_index',
params: { portalSlug: slug, locale: defaultLocale },
});
} else {
router.push({ name: 'portals_new' });
}
};
const refreshPortalRoute = async (newSlug, defaultLocale) => {
// This is to refresh the portal route and update the UI settings
// If there is slug change, this will be called to refresh the route and UI settings
await fetchPortalAndItsCategories(newSlug, defaultLocale);
updateUISettings({
last_active_portal_slug: newSlug,
last_active_locale_code: defaultLocale,
});
await router.replace({
name: 'portals_settings_index',
params: { portalSlug: newSlug },
});
};
const updatePortalSettings = async portalObj => {
const { portalSlug } = route.params;
try {
const defaultLocale = getDefaultLocale(portalSlug);
await store.dispatch('portals/update', {
...portalObj,
portalSlug: portalSlug || portalObj?.slug,
});
// If there is a slug change, this will refresh the route and update the UI settings
if (portalObj?.slug && portalSlug !== portalObj.slug) {
await refreshPortalRoute(portalObj.slug, defaultLocale);
}
useAlert(
t('HELP_CENTER.PORTAL_SETTINGS.API.UPDATE_PORTAL.SUCCESS_MESSAGE')
);
} catch (error) {
useAlert(
error?.message ||
t('HELP_CENTER.PORTAL_SETTINGS.API.UPDATE_PORTAL.ERROR_MESSAGE')
);
}
};
const deletePortal = async selectedPortalForDelete => {
const { slug } = selectedPortalForDelete;
try {
await store.dispatch('portals/delete', { portalSlug: slug });
await updateRouteAfterDeletion(slug);
useAlert(
t('HELP_CENTER.PORTAL.PORTAL_SETTINGS.DELETE_PORTAL.API.DELETE_SUCCESS')
);
} catch (error) {
useAlert(
error?.message ||
t('HELP_CENTER.PORTAL.PORTAL_SETTINGS.DELETE_PORTAL.API.DELETE_ERROR')
);
}
};
const handleUpdatePortal = updatePortalSettings;
const handleUpdatePortalConfiguration = updatePortalSettings;
const handleDeletePortal = deletePortal;
</script>
<template>
<PortalSettings
:portals="portals"
:is-fetching="isFetching"
@update-portal="handleUpdatePortal"
@update-portal-configuration="handleUpdatePortalConfiguration"
@delete-portal="handleDeletePortal"
/>
</template>

Some files were not shown because too many files have changed in this diff Show More