mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-02 03:57:52 +00:00
feat(v4): Update the help center portal design (#10296)
Co-authored-by: Pranav <pranavrajs@gmail.com>
This commit is contained in:
@@ -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,7 +68,8 @@ 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,
|
||||||
|
:locale, meta: [:title,
|
||||||
:description,
|
:description,
|
||||||
{ tags: [] }]
|
{ tags: [] }]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<OnClickOutside @trigger="isOpen = false">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
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"
|
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"
|
:label="statusText"
|
||||||
:class="statusTextColor"
|
:class="statusTextColor"
|
||||||
@click="isOpen = !isOpen"
|
@click="isOpen = !isOpen"
|
||||||
/>
|
/>
|
||||||
<OnClickOutside @trigger="isOpen = false">
|
|
||||||
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<OnClickOutside @trigger="isOpen = false">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="sm"
|
||||||
icon="more-vertical"
|
icon="more-vertical"
|
||||||
class="w-8 z-60 group-hover:bg-slate-100 dark:group-hover:bg-slate-800"
|
class="w-8 z-60 group-hover:bg-slate-100 dark:group-hover:bg-slate-800"
|
||||||
@click="isOpen = !isOpen"
|
@click="isOpen = !isOpen"
|
||||||
/>
|
/>
|
||||||
<OnClickOutside @trigger="isOpen = false">
|
|
||||||
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<div v-if="showButton">
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
label="New article"
|
:label="buttonLabel"
|
||||||
icon="add"
|
icon="add"
|
||||||
@click="openDialog"
|
@click="onClick"
|
||||||
/>
|
/>
|
||||||
<!-- <AddLocaleDialog
|
</div>
|
||||||
ref="addLocaleDialogRef"
|
|
||||||
@confirm="handleDialogConfirm"
|
|
||||||
/> -->
|
|
||||||
</template>
|
</template>
|
||||||
</EmptyStateLayout>
|
</EmptyStateLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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">
|
||||||
|
<OnClickOutside @trigger="showPortalSwitcher = false">
|
||||||
<Button
|
<Button
|
||||||
icon="more-vertical"
|
icon="chevron-lucide-down"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
icon-lib="lucide"
|
||||||
class="group-hover:bg-slate-100 dark:group-hover:bg-slate-800"
|
class="!w-6 !h-6 group-hover:bg-n-solid-2 !p-0.5 rounded-md"
|
||||||
@click="togglePortalSwitcher"
|
@click="togglePortalSwitcher"
|
||||||
/>
|
/>
|
||||||
<OnClickOutside @trigger="showPortalSwitcher = false">
|
|
||||||
<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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
<OnClickOutside @trigger="showDropdownMenu = false">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="sm"
|
||||||
icon="more-vertical"
|
icon="more-vertical"
|
||||||
class="w-8 group-hover:bg-slate-100 dark:group-hover:bg-slate-800"
|
class="w-8 group-hover:bg-slate-100 dark:group-hover:bg-slate-800"
|
||||||
@click="isOpen = !isOpen"
|
@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>
|
||||||
|
|||||||
@@ -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
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center gap-4">
|
<ArticleEditorControls
|
||||||
<div class="flex items-center gap-2">
|
:article="article"
|
||||||
<div class="w-5 h-5 rounded-full bg-slate-100 dark:bg-slate-700" />
|
@save-article="saveArticle"
|
||||||
<span class="text-sm text-slate-500 dark:text-slate-400">
|
@set-author="setAuthorId"
|
||||||
John Doe
|
@set-category="setCategoryId"
|
||||||
</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;
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
v-model="localArticles"
|
||||||
|
:disabled="!dragEnabled"
|
||||||
|
item-key="id"
|
||||||
|
tag="ul"
|
||||||
|
ghost-class="article-ghost-class"
|
||||||
|
class="w-full h-full space-y-4"
|
||||||
|
@end="onDragEnd"
|
||||||
|
>
|
||||||
|
<template #item="{ element }">
|
||||||
|
<li class="list-none rounded-2xl">
|
||||||
<ArticleCard
|
<ArticleCard
|
||||||
v-for="article in articles"
|
:id="element.id"
|
||||||
:key="article.title"
|
:key="element.id"
|
||||||
:title="article.title"
|
:title="element.title"
|
||||||
:status="article.status"
|
:status="element.status"
|
||||||
:author="article.author"
|
:author="element.author"
|
||||||
:category="article.category"
|
:category="getCategory(element.category.id)"
|
||||||
:views="article.views"
|
:views="element.views || 0"
|
||||||
:updated-at="article.updatedAt"
|
:updated-at="element.updatedAt"
|
||||||
|
:class="{ 'cursor-grab': dragEnabled }"
|
||||||
|
@open-article="openArticle"
|
||||||
|
@article-action="updateArticle"
|
||||||
/>
|
/>
|
||||||
</ul>
|
</li>
|
||||||
|
</template>
|
||||||
|
</Draggable>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.article-ghost-class {
|
||||||
|
@apply opacity-50 bg-n-solid-1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
@locale-change="handleLocaleAction"
|
||||||
|
@category-change="handleCategoryAction"
|
||||||
|
@new-article="navigateToNewArticlePage"
|
||||||
/>
|
/>
|
||||||
<div class="flex items-start justify-between w-full gap-2">
|
<CategoryHeaderControls
|
||||||
<div class="flex items-center gap-2">
|
v-else-if="showCategoryHeaderControls"
|
||||||
<Button
|
:categories="categories"
|
||||||
label="English"
|
:allowed-locales="allowedLocales"
|
||||||
size="sm"
|
:has-selected-category="isCategoryArticles"
|
||||||
icon-position="right"
|
|
||||||
icon="chevron-lucide-down"
|
|
||||||
icon-lib="lucide"
|
|
||||||
variant="secondary"
|
|
||||||
/>
|
/>
|
||||||
<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>
|
||||||
|
|||||||
@@ -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')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return items;
|
}
|
||||||
});
|
|
||||||
const openCategoryArticles = id => {
|
const handleAction = ({ action, id, category: categoryData }) => {
|
||||||
|
if (action === 'edit') {
|
||||||
selectedCategory.value = props.categories.find(
|
selectedCategory.value = props.categories.find(
|
||||||
category => category.id === id
|
category => category.id === id
|
||||||
);
|
);
|
||||||
|
editCategoryDialog.value.dialogRef.open();
|
||||||
|
}
|
||||||
|
if (action === 'delete') {
|
||||||
|
deleteCategory(categoryData);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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"
|
|
||||||
>
|
>
|
||||||
Avatar
|
<Spinner />
|
||||||
</label>
|
</div>
|
||||||
<Avatar
|
<div
|
||||||
label="Avatar"
|
v-else-if="activePortal"
|
||||||
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Amaya"
|
class="flex flex-col w-full gap-4 max-w-[640px] pb-8"
|
||||||
class="bg-ruby-300 dark:bg-ruby-400"
|
>
|
||||||
@upload="handleUploadAvatar"
|
<PortalBaseSettings
|
||||||
|
:active-portal="activePortal"
|
||||||
|
:is-fetching="isFetching"
|
||||||
|
@update-portal="handleUpdatePortal"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div class="flex flex-col w-full gap-2">
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
Name
|
|
||||||
</label>
|
|
||||||
<Input placeholder="Name" 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"
|
|
||||||
>
|
|
||||||
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 class="w-full h-px bg-slate-50 dark:bg-slate-800/50" />
|
||||||
</div>
|
<PortalConfigurationSettings
|
||||||
<div class="flex flex-col w-full gap-6">
|
:active-portal="activePortal"
|
||||||
<div class="flex flex-col w-full gap-6">
|
:is-fetching="isFetching"
|
||||||
<h6 class="text-base font-medium text-slate-900 dark:text-slate-50">
|
@update-portal-configuration="handleUpdatePortalConfiguration"
|
||||||
Configuration
|
/>
|
||||||
|
<div class="w-full h-px bg-slate-50 dark:bg-slate-800/50" />
|
||||||
|
<div class="flex items-end justify-between w-full gap-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<h6 class="text-base font-medium text-n-slate-12">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.HEADER'
|
||||||
|
)
|
||||||
|
}}
|
||||||
</h6>
|
</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>
|
||||||
<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
|
<Button
|
||||||
label="Delete Test-Help Center"
|
:label="
|
||||||
size="sm"
|
t(
|
||||||
|
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.BUTTON',
|
||||||
|
{
|
||||||
|
portalName: activePortalName,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
|
class="w-56"
|
||||||
|
@click="openConfirmDeletePortalDialog"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
<ConfirmDeletePortalDialog
|
||||||
|
ref="confirmDeletePortalDialogRef"
|
||||||
|
:active-portal-name="activePortalName"
|
||||||
|
@delete-portal="handleDeletePortal"
|
||||||
|
/>
|
||||||
</HelpCenterLayout>
|
</HelpCenterLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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-3 border-b border-n-alpha-2"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between gap-4 px-6 pb-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
|
||||||
</div>
|
:label="t('HELP_CENTER.PORTAL_SWITCHER.NEW_PORTAL')"
|
||||||
<div v-if="portals.length > 0" class="flex flex-col gap-3">
|
variant="secondary"
|
||||||
<template v-for="(portal, index) in portals" :key="portal.id">
|
icon="add"
|
||||||
<div class="flex flex-col gap-2 px-6 py-2">
|
size="sm"
|
||||||
<div class="flex items-center justify-between">
|
class="!bg-n-alpha-2 hover:!bg-n-alpha-3"
|
||||||
<div class="flex items-center">
|
@click="openCreatePortalDialog"
|
||||||
<input
|
|
||||||
:id="portal.id"
|
|
||||||
v-model="selectedPortal"
|
|
||||||
type="radio"
|
|
||||||
:value="portal.id"
|
|
||||||
class="mr-3"
|
|
||||||
@change="handlePortalChange(portal.id)"
|
|
||||||
/>
|
/>
|
||||||
<label
|
</div>
|
||||||
:for="portal.id"
|
<div v-if="portals.length > 0" class="flex flex-col gap-2 px-4">
|
||||||
class="text-sm font-medium text-slate-900 dark:text-slate-100"
|
<Button
|
||||||
|
v-for="(portal, index) in portals"
|
||||||
|
:key="index"
|
||||||
|
:label="portal.name"
|
||||||
|
variant="ghost"
|
||||||
|
:icon="isPortalActive(portal) ? 'checkmark-lucide' : ''"
|
||||||
|
icon-lib="lucide"
|
||||||
|
icon-position="right"
|
||||||
|
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"
|
||||||
|
size="sm"
|
||||||
|
@click="handlePortalChange(portal)"
|
||||||
>
|
>
|
||||||
{{ portal.name }}
|
<template #leftPrefix>
|
||||||
</label>
|
<Thumbnail
|
||||||
</div>
|
v-if="portal"
|
||||||
<div class="w-4 h-4 rounded-full bg-slate-100 dark:bg-slate-700" />
|
:author="portal"
|
||||||
</div>
|
:name="portal.name"
|
||||||
<div class="inline-flex items-center gap-2 py-1 text-sm">
|
:size="20"
|
||||||
<span class="text-slate-600 dark:text-slate-400">
|
:src="getPortalThumbnailSrc(portal)"
|
||||||
articles:
|
:show-author-name="false"
|
||||||
<span class="text-slate-800 dark:text-slate-200">
|
icon-name="building-lucide"
|
||||||
{{ portal.articles }}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<div class="w-px h-3 bg-slate-50 dark:bg-slate-700" />
|
|
||||||
<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>
|
</template>
|
||||||
|
<template #rightPrefix>
|
||||||
|
<span class="text-sm truncate text-n-slate-11">
|
||||||
|
{{ portal.custom_domain || '' }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}}
|
/>
|
||||||
</span>
|
<template v-else>
|
||||||
</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
|
<FluentIcon
|
||||||
v-if="index < items.length - 1"
|
|
||||||
icon="chevron-lucide-right"
|
icon="chevron-lucide-right"
|
||||||
size="18"
|
size="18"
|
||||||
icon-lib="lucide"
|
icon-lib="lucide"
|
||||||
class="flex-shrink-0 text-slate-300 dark:text-slate-500 ltr:mr-3 rtl:mr-0 rtl:ml-3"
|
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>
|
||||||
|
</template>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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,21 +95,22 @@ onClickOutside(comboboxRef, () => {
|
|||||||
'group/combobox': !disabled,
|
'group/combobox': !disabled,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
|
<OnClickOutside @trigger="open = false">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
:label="selectedLabel"
|
:label="selectedLabel"
|
||||||
icon-position="right"
|
icon-position="right"
|
||||||
size="sm"
|
|
||||||
: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'"
|
||||||
|
icon-lib="lucide"
|
||||||
@click="toggleDropdown"
|
@click="toggleDropdown"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-show="open"
|
v-show="open"
|
||||||
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"
|
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">
|
<div class="relative border-b border-n-strong">
|
||||||
<FluentIcon
|
<FluentIcon
|
||||||
icon="search"
|
icon="search"
|
||||||
:size="14"
|
:size="14"
|
||||||
@@ -121,20 +122,20 @@ onClickOutside(comboboxRef, () => {
|
|||||||
v-model="search"
|
v-model="search"
|
||||||
type="search"
|
type="search"
|
||||||
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
|
: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"
|
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>
|
</div>
|
||||||
<ul
|
<ul
|
||||||
class="py-1 overflow-auto max-h-60"
|
class="py-1 mb-0 overflow-auto max-h-60"
|
||||||
role="listbox"
|
role="listbox"
|
||||||
:aria-activedescendant="selectedValue"
|
:aria-activedescendant="selectedValue"
|
||||||
>
|
>
|
||||||
<li
|
<li
|
||||||
v-for="option in filteredOptions"
|
v-for="option in filteredOptions"
|
||||||
:key="option.value"
|
: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="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="{
|
:class="{
|
||||||
'bg-slate-50 dark:bg-slate-800/50': option.value === selectedValue,
|
'bg-n-solid-2': option.value === selectedValue,
|
||||||
}"
|
}"
|
||||||
role="option"
|
role="option"
|
||||||
:aria-selected="option.value === selectedValue"
|
:aria-selected="option.value === selectedValue"
|
||||||
@@ -147,7 +148,7 @@ onClickOutside(comboboxRef, () => {
|
|||||||
v-if="option.value === selectedValue"
|
v-if="option.value === selectedValue"
|
||||||
icon="checkmark"
|
icon="checkmark"
|
||||||
:size="16"
|
:size="16"
|
||||||
class="flex-shrink-0"
|
class="flex-shrink-0 text-n-slate-11 dark:text-n-slate-11"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
@@ -159,5 +160,12 @@ onClickOutside(comboboxRef, () => {
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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>
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
<OnClickOutside @trigger="close">
|
||||||
<div
|
<div
|
||||||
ref="dialogContentRef"
|
ref="dialogContentRef"
|
||||||
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"
|
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
|
@click.stop
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<h3
|
<h3 class="text-base font-medium leading-6 text-n-slate-12">
|
||||||
class="text-base font-medium leading-6 text-gray-900 dark:text-white"
|
|
||||||
>
|
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</h3>
|
</h3>
|
||||||
<p
|
<slot name="description">
|
||||||
v-if="description"
|
<p v-if="description" class="mb-0 text-sm text-n-slate-11">
|
||||||
class="mb-0 text-sm text-slate-500 dark:text-slate-400"
|
|
||||||
>
|
|
||||||
{{ description }}
|
{{ description }}
|
||||||
</p>
|
</p>
|
||||||
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
<slot name="form">
|
<slot name="form">
|
||||||
<!-- Form content will be injected here -->
|
<!-- Form content will be injected here -->
|
||||||
</slot>
|
</slot>
|
||||||
<div class="flex items-center justify-between w-full gap-3">
|
<div class="flex items-center justify-between w-full gap-3">
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
v-if="showCancelButton"
|
||||||
|
variant="ghost"
|
||||||
:label="cancelButtonLabel || t('DIALOG.BUTTONS.CANCEL')"
|
:label="cancelButtonLabel || t('DIALOG.BUTTONS.CANCEL')"
|
||||||
class="w-full"
|
class="w-full bg-n-alpha-2 hover:bg-n-alpha-3"
|
||||||
size="sm"
|
|
||||||
@click="close"
|
@click="close"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
v-if="type !== 'alert'"
|
v-if="showConfirmButton"
|
||||||
:variant="type === 'edit' ? 'default' : 'destructive'"
|
:variant="type === 'edit' ? 'default' : 'destructive'"
|
||||||
:label="confirmButtonLabel || t('DIALOG.BUTTONS.CONFIRM')"
|
:label="confirmButtonLabel || t('DIALOG.BUTTONS.CONFIRM')"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
size="sm"
|
:is-loading="isLoading"
|
||||||
|
:disabled="disableConfirmButton || isLoading"
|
||||||
@click="confirm"
|
@click="confirm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</OnClickOutside>
|
||||||
</dialog>
|
</dialog>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
dialog::backdrop {
|
||||||
|
@apply dark:bg-n-alpha-white bg-n-alpha-black2;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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: [
|
activeOn: [
|
||||||
'all_locale_categories',
|
'portals_articles_index',
|
||||||
'default_portal_articles',
|
'portals_articles_new',
|
||||||
'edit_article',
|
'portals_articles_edit',
|
||||||
'edit_category',
|
],
|
||||||
'edit_portal_customization',
|
to: accountScopedRoute('portals_index', {
|
||||||
'edit_portal_information',
|
navigationPath: 'portals_articles_index',
|
||||||
'edit_portal_locales',
|
}),
|
||||||
'list_all_locale_articles',
|
},
|
||||||
'list_all_locale_categories',
|
{
|
||||||
'list_all_portals',
|
name: 'Categories',
|
||||||
'list_archived_articles',
|
label: t('SIDEBAR.HELP_CENTER.CATEGORIES'),
|
||||||
'list_draft_articles',
|
activeOn: [
|
||||||
'list_mine_articles',
|
'portals_categories_index',
|
||||||
'new_article',
|
'portals_categories_articles_index',
|
||||||
'new_category_in_locale',
|
'portals_categories_articles_edit',
|
||||||
'new_portal_information',
|
],
|
||||||
'portalSlug',
|
to: accountScopedRoute('portals_index', {
|
||||||
'portal_customization',
|
navigationPath: 'portals_categories_index',
|
||||||
'portal_finish',
|
}),
|
||||||
'show_category',
|
},
|
||||||
'show_category_articles',
|
{
|
||||||
|
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: [
|
||||||
|
'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',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -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')"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
<div
|
||||||
|
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="[
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
>
|
||||||
<textarea
|
<textarea
|
||||||
:id="id"
|
:id="id"
|
||||||
|
ref="textareaRef"
|
||||||
:value="modelValue"
|
:value="modelValue"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
:maxlength="maxLength"
|
:maxlength="showCharacterCount ? maxLength : undefined"
|
||||||
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="[
|
||||||
:class="[customTextAreaClass, showCharacterCount ? 'pb-9' : 'pb-3']"
|
customTextAreaClass,
|
||||||
|
{
|
||||||
|
'resize-none': !resize,
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
:style="{
|
||||||
|
minHeight: autoHeight ? minHeight : undefined,
|
||||||
|
maxHeight: autoHeight ? maxHeight : undefined,
|
||||||
|
}"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
@input="$emit('update:modelValue', $event.target.value)"
|
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
|
<div
|
||||||
v-if="showCharacterCount"
|
v-if="showCharacterCount"
|
||||||
class="absolute flex items-center justify-between mt-1 bottom-3 ltr:right-3 rtl:left-3"
|
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">
|
<span class="text-xs tabular-nums text-slate-300 dark:text-slate-600">
|
||||||
{{ characterCount }} / {{ maxLength }}
|
{{ characterCount }} / {{ maxLength }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
121
app/javascript/dashboard/components-next/thumbnail/Thumbnail.vue
Normal file
121
app/javascript/dashboard/components-next/thumbnail/Thumbnail.vue
Normal 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>
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
if (this.autofocus) {
|
||||||
this.focusEditorInputField();
|
this.focusEditorInputField();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
contentFromEditor() {
|
contentFromEditor() {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
};
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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, let’s 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}` : '';
|
||||||
|
|||||||
@@ -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];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
Reference in New Issue
Block a user