mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-31 02:57:57 +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 | ||||
|     @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? | ||||
|                   @all_articles.order_by_position.page(@current_page) | ||||
|                   @articles.order_by_position.page(@current_page) | ||||
|                 else | ||||
|                   @all_articles.order_by_updated_at.page(@current_page) | ||||
|                   @articles.order_by_updated_at.page(@current_page) | ||||
|                 end | ||||
|   end | ||||
|  | ||||
| @@ -43,6 +45,19 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController | ||||
|  | ||||
|   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 | ||||
|     @article = @portal.articles.find(params[:id]) | ||||
|   end | ||||
| @@ -53,7 +68,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController | ||||
|  | ||||
|   def article_params | ||||
|     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, | ||||
|                       { tags: [] }] | ||||
|     ) | ||||
|   | ||||
| @@ -20,7 +20,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController | ||||
|   end | ||||
|  | ||||
|   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.save! | ||||
|     process_attached_logo | ||||
| @@ -28,7 +28,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController | ||||
|  | ||||
|   def update | ||||
|     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 | ||||
|       process_attached_logo if params[:blob_id].present? | ||||
|     rescue StandardError => e | ||||
| @@ -70,11 +70,21 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController | ||||
|  | ||||
|   def portal_params | ||||
|     params.require(:portal).permit( | ||||
|       :account_id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug, :archived, { config: [:default_locale, | ||||
|                                                                                                                           { allowed_locales: [] }] } | ||||
|       :account_id, :color, :custom_domain, :header_text, :homepage_link, | ||||
|       :name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] } | ||||
|     ) | ||||
|   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 | ||||
|     params.require(:portal).permit(:account_id, member_ids: []) | ||||
|   end | ||||
|   | ||||
| @@ -30,7 +30,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B | ||||
|  | ||||
|   def set_article | ||||
|     @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) | ||||
|   end | ||||
|  | ||||
|   | ||||
| @@ -52,12 +52,13 @@ class ArticlesAPI extends PortalsAPI { | ||||
|   } | ||||
|  | ||||
|   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`, { | ||||
|       content, | ||||
|       title, | ||||
|       author_id, | ||||
|       category_id, | ||||
|       author_id: authorId, | ||||
|       category_id: categoryId, | ||||
|       locale, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -7,7 +7,7 @@ const handleClick = () => { | ||||
|  | ||||
| <template> | ||||
|   <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" | ||||
|   > | ||||
|     <slot name="header" /> | ||||
|   | ||||
| @@ -13,7 +13,7 @@ defineProps({ | ||||
|  | ||||
| <template> | ||||
|   <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 | ||||
|       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" /> | ||||
|       </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-2"> | ||||
|           <div class="flex flex-col items-center justify-center gap-3"> | ||||
|             <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 }} | ||||
|             </h2> | ||||
|             <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 }} | ||||
|             </p> | ||||
|   | ||||
| @@ -3,30 +3,60 @@ import ArticleCard from './ArticleCard.vue'; | ||||
|  | ||||
| const articles = [ | ||||
|   { | ||||
|     id: 1, | ||||
|     title: "How to get an SSL certificate for your Help Center's custom domain", | ||||
|     status: 'draft', | ||||
|     updatedAt: '2 days ago', | ||||
|     author: 'Michael', | ||||
|     category: '⚡️ Marketing', | ||||
|     updatedAt: 1729048936, | ||||
|     author: { | ||||
|       name: 'John', | ||||
|       thumbnail: 'https://i.pravatar.cc/300', | ||||
|     }, | ||||
|     category: { | ||||
|       title: 'Marketing', | ||||
|       slug: 'marketing', | ||||
|       icon: '📈', | ||||
|     }, | ||||
|     views: 400, | ||||
|   }, | ||||
|   { | ||||
|     id: 2, | ||||
|     title: 'Setting up your first Help Center portal', | ||||
|     status: '', | ||||
|     updatedAt: '1 week ago', | ||||
|     author: 'John', | ||||
|     category: '🛠️ Development', | ||||
|     updatedAt: 1729048936, | ||||
|     author: { | ||||
|       name: 'John', | ||||
|       thumbnail: 'https://i.pravatar.cc/300', | ||||
|     }, | ||||
|     category: { | ||||
|       title: 'Development', | ||||
|       slug: 'development', | ||||
|       icon: '🛠️', | ||||
|     }, | ||||
|     views: 1400, | ||||
|   }, | ||||
|   { | ||||
|     id: 3, | ||||
|     title: 'Best practices for organizing your Help Center content', | ||||
|     status: 'archived', | ||||
|     updatedAt: '3 days ago', | ||||
|     author: 'Fernando', | ||||
|     category: '💰 Finance', | ||||
|     updatedAt: 1729048936, | ||||
|     author: { | ||||
|       name: 'Fernando', | ||||
|       thumbnail: 'https://i.pravatar.cc/300', | ||||
|     }, | ||||
|     category: { | ||||
|       title: 'Finance', | ||||
|       slug: 'finance', | ||||
|       icon: '💰', | ||||
|     }, | ||||
|     views: 4300, | ||||
|   }, | ||||
| ]; | ||||
|  | ||||
| const category = { | ||||
|   name: 'Marketing', | ||||
|   slug: 'marketing', | ||||
|   icon: '📈', | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <!-- 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" | ||||
|       > | ||||
|         <ArticleCard | ||||
|           :id="article.id" | ||||
|           :title="article.title" | ||||
|           :status="article.status" | ||||
|           :author="article.author" | ||||
|           :category="article.category" | ||||
|           :category="category" | ||||
|           :views="article.views" | ||||
|           :updated-at="article.updatedAt" | ||||
|         /> | ||||
|   | ||||
| @@ -1,13 +1,25 @@ | ||||
| <script setup> | ||||
| import { computed, ref } from 'vue'; | ||||
| 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 DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.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'; | ||||
|  | ||||
| const props = defineProps({ | ||||
|   id: { | ||||
|     type: Number, | ||||
|     required: true, | ||||
|   }, | ||||
|   title: { | ||||
|     type: String, | ||||
|     required: true, | ||||
| @@ -17,11 +29,11 @@ const props = defineProps({ | ||||
|     required: true, | ||||
|   }, | ||||
|   author: { | ||||
|     type: String, | ||||
|     type: Object, | ||||
|     required: true, | ||||
|   }, | ||||
|   category: { | ||||
|     type: String, | ||||
|     type: Object, | ||||
|     required: true, | ||||
|   }, | ||||
|   views: { | ||||
| @@ -29,84 +41,112 @@ const props = defineProps({ | ||||
|     required: true, | ||||
|   }, | ||||
|   updatedAt: { | ||||
|     type: String, | ||||
|     type: Number, | ||||
|     required: true, | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const emit = defineEmits(['openArticle', 'articleAction']); | ||||
|  | ||||
| const { t } = useI18n(); | ||||
|  | ||||
| const isOpen = ref(false); | ||||
|  | ||||
| const menuItems = computed(() => { | ||||
|   const baseItems = [{ label: 'Delete', action: 'delete', icon: 'delete' }]; | ||||
|   const menuOptions = { | ||||
|     archived: [ | ||||
|       { label: 'Publish', action: 'publish', icon: 'checkmark' }, | ||||
|       { label: 'Draft', action: 'draft', icon: 'draft' }, | ||||
|     ], | ||||
|     draft: [ | ||||
|       { label: 'Publish', action: 'publish', icon: 'checkmark' }, | ||||
|       { label: 'Archive', action: 'archive', icon: 'archive' }, | ||||
|     ], | ||||
|     '': [ | ||||
|       // Empty string represents published status | ||||
|       { label: 'Draft', action: 'draft', icon: 'draft' }, | ||||
|       { label: 'Archive', action: 'archive', icon: 'archive' }, | ||||
|     ], | ||||
|   }; | ||||
|   return [...(menuOptions[props.status] || menuOptions['']), ...baseItems]; | ||||
| const articleMenuItems = computed(() => { | ||||
|   const commonItems = Object.entries(ARTICLE_MENU_ITEMS).reduce( | ||||
|     (acc, [key, item]) => { | ||||
|       acc[key] = { ...item, label: t(item.label) }; | ||||
|       return acc; | ||||
|     }, | ||||
|     {} | ||||
|   ); | ||||
|  | ||||
|   const statusItems = ( | ||||
|     ARTICLE_MENU_OPTIONS[props.status] || | ||||
|     ARTICLE_MENU_OPTIONS[ARTICLE_STATUSES.PUBLISHED] | ||||
|   ).map(key => commonItems[key]); | ||||
|  | ||||
|   return [...statusItems, commonItems.delete]; | ||||
| }); | ||||
|  | ||||
| const statusTextColor = computed(() => { | ||||
|   switch (props.status) { | ||||
|     case 'archived': | ||||
|       return '!text-slate-600 dark:!text-slate-200'; | ||||
|       return '!text-n-slate-12'; | ||||
|     case 'draft': | ||||
|       return '!text-amber-700 dark:!text-amber-400'; | ||||
|       return '!text-n-amber-11'; | ||||
|     default: | ||||
|       return '!text-teal-700 dark:!text-teal-400'; | ||||
|       return '!text-n-teal-11'; | ||||
|   } | ||||
| }); | ||||
|  | ||||
| const statusText = computed(() => { | ||||
|   switch (props.status) { | ||||
|     case 'archived': | ||||
|       return 'Archived'; | ||||
|       return t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.STATUS.ARCHIVED'); | ||||
|     case 'draft': | ||||
|       return 'Draft'; | ||||
|       return t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.STATUS.DRAFT'); | ||||
|     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; | ||||
|   emit('articleAction', { action, value, id: props.id }); | ||||
| }; | ||||
|  | ||||
| const handleClick = id => { | ||||
|   emit('openArticle', id); | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <!-- TODO: Add i18n --> | ||||
| <!-- eslint-disable vue/no-bare-strings-in-template --> | ||||
| <template> | ||||
|   <CardLayout> | ||||
|     <template #header> | ||||
|       <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 }} | ||||
|         </span> | ||||
|         <div class="relative group"> | ||||
|         <div class="relative group" @click.stop> | ||||
|           <OnClickOutside @trigger="isOpen = false"> | ||||
|             <Button | ||||
|               variant="ghost" | ||||
|               size="sm" | ||||
|             class="text-xs bg-slate-50 !font-normal group-hover:bg-slate-100/50 dark:group-hover:bg-slate-700/50 !h-6 dark:bg-slate-800 rounded-md border-0 !px-2 !py-0.5" | ||||
|               class="text-xs font-medium bg-n-alpha-2 hover:bg-n-alpha-1 !h-6 rounded-md border-0 !px-2 !py-0.5" | ||||
|               :label="statusText" | ||||
|               :class="statusTextColor" | ||||
|               @click="isOpen = !isOpen" | ||||
|             /> | ||||
|           <OnClickOutside @trigger="isOpen = false"> | ||||
|             <DropdownMenu | ||||
|               v-if="isOpen" | ||||
|               :menu-items="menuItems" | ||||
|               class="right-0 mt-2 xl:left-0 top-full" | ||||
|               @action="handleAction" | ||||
|               :menu-items="articleMenuItems" | ||||
|               class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 top-full" | ||||
|               @action="handleArticleAction($event)" | ||||
|             /> | ||||
|           </OnClickOutside> | ||||
|         </div> | ||||
| @@ -116,25 +156,34 @@ const handleAction = () => { | ||||
|       <div class="flex items-center justify-between gap-4"> | ||||
|         <div class="flex items-center gap-4"> | ||||
|           <div class="flex items-center gap-1"> | ||||
|             <div class="w-4 h-4 rounded-full bg-slate-100 dark:bg-slate-700" /> | ||||
|             <span class="text-sm text-slate-500 dark:text-slate-400"> | ||||
|               {{ author }} | ||||
|             <Thumbnail | ||||
|               v-if="author" | ||||
|               :author="author" | ||||
|               :name="authorName" | ||||
|               :src="authorThumbnailSrc" | ||||
|             /> | ||||
|             <span class="text-sm text-n-slate-11"> | ||||
|               {{ authorName }} | ||||
|             </span> | ||||
|           </div> | ||||
|           <span | ||||
|             class="block text-sm whitespace-nowrap text-slate-500 dark:text-slate-400" | ||||
|           > | ||||
|             {{ category }} | ||||
|           <span class="block text-sm whitespace-nowrap text-n-slate-11"> | ||||
|             {{ categoryName }} | ||||
|           </span> | ||||
|           <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" /> | ||||
|             <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> | ||||
|         <span class="text-sm text-slate-600 dark:text-slate-400 line-clamp-1"> | ||||
|           {{ updatedAt }} | ||||
|         <span class="text-sm text-n-slate-11 line-clamp-1"> | ||||
|           {{ lastUpdatedAt }} | ||||
|         </span> | ||||
|       </div> | ||||
|     </template> | ||||
|   | ||||
| @@ -2,17 +2,21 @@ | ||||
| import CategoryCard from './CategoryCard.vue'; | ||||
| const categories = [ | ||||
|   { | ||||
|     id: 'getting-started', | ||||
|     title: '🚀 Getting started', | ||||
|     id: 1, | ||||
|     title: 'Getting started', | ||||
|     description: | ||||
|       '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', | ||||
|     title: '📈 Marketing', | ||||
|     id: 2, | ||||
|     title: 'Marketing', | ||||
|     description: '', | ||||
|     articlesCount: '4', | ||||
|     articlesCount: 4, | ||||
|     slug: 'marketing', | ||||
|     icon: '📈', | ||||
|   }, | ||||
| ]; | ||||
| </script> | ||||
| @@ -31,9 +35,12 @@ const categories = [ | ||||
|         class="px-20 py-4 bg-white dark:bg-slate-900" | ||||
|       > | ||||
|         <CategoryCard | ||||
|           :id="category.id" | ||||
|           :slug="category.slug" | ||||
|           :title="category.title" | ||||
|           :description="category.description" | ||||
|           :articles-count="category.articlesCount" | ||||
|           :icon="category.icon" | ||||
|         /> | ||||
|       </div> | ||||
|     </Variant> | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| <script setup> | ||||
| import { ref, computed } from 'vue'; | ||||
| import { OnClickOutside } from '@vueuse/components'; | ||||
| import { useI18n } from 'vue-i18n'; | ||||
|  | ||||
| import CardLayout from 'dashboard/components-next/CardLayout.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({ | ||||
|   id: { | ||||
|     type: String, | ||||
|     type: Number, | ||||
|     required: true, | ||||
|   }, | ||||
|   title: { | ||||
|     type: String, | ||||
|     required: true, | ||||
|   }, | ||||
|   articlesCount: { | ||||
|   icon: { | ||||
|     type: String, | ||||
|     required: true, | ||||
|   }, | ||||
| @@ -23,25 +24,41 @@ const props = defineProps({ | ||||
|     type: String, | ||||
|     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 menuItems = [ | ||||
| const categoryMenuItems = [ | ||||
|   { | ||||
|     label: 'Edit', | ||||
|     action: 'edit', | ||||
|     value: 'edit', | ||||
|     icon: 'edit', | ||||
|   }, | ||||
|   { | ||||
|     label: 'Delete', | ||||
|     action: 'delete', | ||||
|     value: 'delete', | ||||
|     icon: 'delete', | ||||
|   }, | ||||
| ]; | ||||
|  | ||||
| const categoryTitleWithIcon = computed(() => { | ||||
|   return `${props.icon} ${props.title}`; | ||||
| }); | ||||
|  | ||||
| const description = computed(() => { | ||||
|   return props.description ? props.description : 'No description added'; | ||||
| }); | ||||
| @@ -50,48 +67,51 @@ const hasDescription = computed(() => { | ||||
|   return props.description.length > 0; | ||||
| }); | ||||
|  | ||||
| const handleClick = id => { | ||||
|   emit('click', id); | ||||
| const handleClick = slug => { | ||||
|   emit('click', slug); | ||||
| }; | ||||
|  | ||||
| // eslint-disable-next-line no-unused-vars | ||||
| const handleAction = action => { | ||||
|   // TODO: Implement action | ||||
| const handleAction = ({ action, value }) => { | ||||
|   emit('action', { action, value, id: props.id }); | ||||
|   isOpen.value = false; | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <!-- TODO: Add i18n --> | ||||
| <!-- eslint-disable vue/no-bare-strings-in-template --> | ||||
| <template> | ||||
|   <CardLayout @click="handleClick(id)"> | ||||
|   <CardLayout> | ||||
|     <template #header> | ||||
|       <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"> | ||||
|             <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 | ||||
|               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> | ||||
|           </div> | ||||
|           <div class="relative group" @click.stop> | ||||
|             <OnClickOutside @trigger="isOpen = false"> | ||||
|               <Button | ||||
|                 variant="ghost" | ||||
|               size="icon" | ||||
|                 size="sm" | ||||
|                 icon="more-vertical" | ||||
|                 class="w-8 z-60 group-hover:bg-slate-100 dark:group-hover:bg-slate-800" | ||||
|                 @click="isOpen = !isOpen" | ||||
|               /> | ||||
|             <OnClickOutside @trigger="isOpen = false"> | ||||
|               <DropdownMenu | ||||
|                 v-if="isOpen" | ||||
|                 :menu-items="menuItems" | ||||
|                 class="right-0 mt-1 xl:left-0 top-full z-60" | ||||
|                 :menu-items="categoryMenuItems" | ||||
|                 class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 top-full z-60" | ||||
|                 @action="handleAction" | ||||
|               /> | ||||
|             </OnClickOutside> | ||||
|   | ||||
| @@ -1,66 +1,42 @@ | ||||
| <script setup> | ||||
| // import { ref } from 'vue'; | ||||
| import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue'; | ||||
| import Button from 'dashboard/components-next/button/Button.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 = [ | ||||
|   { | ||||
|     title: "How to get an SSL certificate for your Help Center's custom domain", | ||||
|     status: 'draft', | ||||
|     updatedAt: '2 days ago', | ||||
|     author: 'Michael', | ||||
|     category: '⚡️ Marketing', | ||||
|     views: 3400, | ||||
| defineProps({ | ||||
|   title: { | ||||
|     type: String, | ||||
|     default: '', | ||||
|   }, | ||||
|   { | ||||
|     title: 'Setting up your first Help Center portal', | ||||
|     status: '', | ||||
|     updatedAt: '1 week ago', | ||||
|     author: 'John', | ||||
|     category: '🛠️ Development', | ||||
|     views: 400, | ||||
|   subtitle: { | ||||
|     type: String, | ||||
|     default: '', | ||||
|   }, | ||||
|   { | ||||
|     title: 'Best practices for organizing your Help Center content', | ||||
|     status: 'archived', | ||||
|     updatedAt: '3 days ago', | ||||
|     author: 'Fernando', | ||||
|     category: '💰 Finance', | ||||
|     views: 400, | ||||
|   showButton: { | ||||
|     type: Boolean, | ||||
|     default: true, | ||||
|   }, | ||||
|   { | ||||
|     title: 'Customizing the appearance of your Help Center', | ||||
|     status: '', | ||||
|     updatedAt: '5 days ago', | ||||
|     author: 'Jane', | ||||
|     category: '💰 Finance', | ||||
|     views: 400, | ||||
|   buttonLabel: { | ||||
|     type: String, | ||||
|     default: '', | ||||
|   }, | ||||
| ]; | ||||
| }); | ||||
|  | ||||
| // const addLocaleDialogRef = ref(null); | ||||
| // const openDialog = () => { | ||||
| //   addLocaleDialogRef.value.dialogRef.open(); | ||||
| // }; | ||||
| // const handleDialogConfirm = () => { | ||||
| //   // Add logic to create a new portal | ||||
| // }; | ||||
| const emit = defineEmits(['click']); | ||||
|  | ||||
| const onClick = () => { | ||||
|   emit('click'); | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <!-- TODO: Add i18n --> | ||||
| <!-- eslint-disable vue/no-bare-strings-in-template --> | ||||
|  | ||||
| <template> | ||||
|   <EmptyStateLayout | ||||
|     title="Write an article" | ||||
|     subtitle="Write a rich article, let's get started!" | ||||
|   > | ||||
|   <EmptyStateLayout :title="title" :subtitle="subtitle"> | ||||
|     <template #empty-state-item> | ||||
|       <div class="grid grid-cols-1 gap-4"> | ||||
|       <div class="grid grid-cols-1 gap-4 overflow-hidden"> | ||||
|         <ArticleCard | ||||
|           v-for="(article, index) in articles" | ||||
|           v-for="(article, index) in articleContent.slice(0, 5)" | ||||
|           :id="article.id" | ||||
|           :key="`article-${index}`" | ||||
|           :title="article.title" | ||||
|           :status="article.status" | ||||
| @@ -72,16 +48,14 @@ const articles = [ | ||||
|       </div> | ||||
|     </template> | ||||
|     <template #actions> | ||||
|       <div v-if="showButton"> | ||||
|         <Button | ||||
|           variant="default" | ||||
|         label="New article" | ||||
|           :label="buttonLabel" | ||||
|           icon="add" | ||||
|         @click="openDialog" | ||||
|           @click="onClick" | ||||
|         /> | ||||
|       <!-- <AddLocaleDialog | ||||
|           ref="addLocaleDialogRef" | ||||
|           @confirm="handleDialogConfirm" | ||||
|         /> --> | ||||
|       </div> | ||||
|     </template> | ||||
|   </EmptyStateLayout> | ||||
| </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> | ||||
| // import { ref } from 'vue'; | ||||
| import { ref } from 'vue'; | ||||
| import { useRouter } from 'vue-router'; | ||||
| import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue'; | ||||
| import Button from 'dashboard/components-next/button/Button.vue'; | ||||
| import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue'; | ||||
| import CategoryCard from 'dashboard/components-next/HelpCenter/CategoryCard/CategoryCard.vue'; | ||||
| import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue'; | ||||
| // import CreatePortalDialog from 'dashboard/playground/HelpCenter/components/CreatePortalDialog.vue'; | ||||
| import articleContent from './portalEmptyStateContent'; | ||||
| import CreatePortalDialog from 'dashboard/components-next/HelpCenter/PortalSwitcher/CreatePortalDialog.vue'; | ||||
|  | ||||
| const articles = [ | ||||
|   { | ||||
|     title: "How to get an SSL certificate for your Help Center's custom domain", | ||||
|     status: 'draft', | ||||
|     updatedAt: '2 days ago', | ||||
|     author: 'Michael', | ||||
|     category: '⚡️ Marketing', | ||||
|     views: 3400, | ||||
|   }, | ||||
|   { | ||||
|     title: 'Setting up your first Help Center portal', | ||||
|     status: '', | ||||
|     updatedAt: '1 week ago', | ||||
|     author: 'John', | ||||
|     category: '🛠️ Development', | ||||
|     views: 400, | ||||
|   }, | ||||
|   { | ||||
|     title: 'Best practices for organizing your Help Center content', | ||||
|     status: 'archived', | ||||
|     updatedAt: '3 days ago', | ||||
|     author: 'Fernando', | ||||
|     category: '💰 Finance', | ||||
|     views: 400, | ||||
|   }, | ||||
|   { | ||||
|     title: 'Customizing the appearance of your Help Center', | ||||
|     status: '', | ||||
|     updatedAt: '5 days ago', | ||||
|     author: 'Jane', | ||||
|     category: '💰 Finance', | ||||
|     views: 400, | ||||
|   }, | ||||
| ]; | ||||
| 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 createPortalDialogRef = ref(null); | ||||
| const openDialog = () => { | ||||
|   createPortalDialogRef.value.dialogRef.open(); | ||||
| }; | ||||
|  | ||||
| const locales = [ | ||||
|   { name: 'English', isDefault: true }, | ||||
|   { name: 'Spanish', isDefault: false }, | ||||
|   { name: 'Malayalam', isDefault: false }, | ||||
| ]; | ||||
| const router = useRouter(); | ||||
|  | ||||
| // const createPortalDialogRef = ref(null); | ||||
| // const openDialog = () => { | ||||
| //   createPortalDialogRef.value.dialogRef.open(); | ||||
| // }; | ||||
| // const handleDialogConfirm = () => { | ||||
| //   // Add logic to create a new portal | ||||
| // }; | ||||
| const onPortalCreate = ({ slug: portalSlug, locale }) => { | ||||
|   router.push({ | ||||
|     name: 'portals_articles_index', | ||||
|     params: { portalSlug, locale }, | ||||
|   }); | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <!-- TODO: Add i18n --> | ||||
| <!-- eslint-disable vue/no-bare-strings-in-template --> | ||||
| <template> | ||||
|   <EmptyStateLayout | ||||
|     title="Help Center" | ||||
|     subtitle="Create self-service portals to access articles and information. Streamline queries, enhance agent efficiency, and elevate customer support." | ||||
|     :title="$t('HELP_CENTER.TITLE')" | ||||
|     :subtitle="$t('HELP_CENTER.NEW_PAGE.DESCRIPTION')" | ||||
|   > | ||||
|     <template #empty-state-item> | ||||
|       <div class="grid grid-cols-2 gap-4"> | ||||
|         <div class="space-y-4"> | ||||
|           <ArticleCard | ||||
|             v-for="(article, index) in articles" | ||||
|             v-for="(article, index) in articleContent" | ||||
|             :id="article.id" | ||||
|             :key="`article-${index}`" | ||||
|             :title="article.title" | ||||
|             :status="article.status" | ||||
| @@ -91,18 +43,16 @@ const locales = [ | ||||
|           /> | ||||
|         </div> | ||||
|         <div class="space-y-4"> | ||||
|           <CategoryCard | ||||
|             v-for="(category, index) in categories" | ||||
|             :key="`category-${index}`" | ||||
|             :title="category.title" | ||||
|             :description="category.description" | ||||
|             :articles-count="category.articlesCount" | ||||
|           /> | ||||
|           <LocaleCard | ||||
|             v-for="(locale, index) in locales" | ||||
|             :key="`locale-${index}`" | ||||
|             :locale="locale.name" | ||||
|             :is-default="locale.isDefault" | ||||
|           <ArticleCard | ||||
|             v-for="(article, index) in articleContent.reverse()" | ||||
|             :id="article.id" | ||||
|             :key="`article-${index}`" | ||||
|             :title="article.title" | ||||
|             :status="article.status" | ||||
|             :updated-at="article.updatedAt" | ||||
|             :author="article.author" | ||||
|             :category="article.category" | ||||
|             :views="article.views" | ||||
|           /> | ||||
|         </div> | ||||
|       </div> | ||||
| @@ -110,14 +60,14 @@ const locales = [ | ||||
|     <template #actions> | ||||
|       <Button | ||||
|         variant="default" | ||||
|         label="Create Portal" | ||||
|         :label="$t('HELP_CENTER.NEW_PAGE.CREATE_PORTAL_BUTTON')" | ||||
|         icon="add" | ||||
|         @click="openDialog" | ||||
|       /> | ||||
|       <!-- <CreatePortalDialog | ||||
|       <CreatePortalDialog | ||||
|         ref="createPortalDialogRef" | ||||
|           @confirm="handleDialogConfirm" | ||||
|         /> --> | ||||
|         @create="onPortalCreate" | ||||
|       /> | ||||
|     </template> | ||||
|   </EmptyStateLayout> | ||||
| </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> | ||||
| import { ref } from 'vue'; | ||||
| import { ref, computed } from 'vue'; | ||||
| 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 Button from 'dashboard/components-next/button/Button.vue'; | ||||
| import PortalSwitcher from 'dashboard/components-next/HelpCenter/PortalSwitcher/PortalSwitcher.vue'; | ||||
| import CreatePortalDialog from 'dashboard/components-next/HelpCenter/PortalSwitcher/CreatePortalDialog.vue'; | ||||
|  | ||||
| defineProps({ | ||||
|   header: { | ||||
|     type: String, | ||||
|     default: 'Chatwoot Help Center', | ||||
|   }, | ||||
|   currentPage: { | ||||
|     type: Number, | ||||
|     default: 1, | ||||
| @@ -35,8 +34,21 @@ defineProps({ | ||||
|  | ||||
| const emit = defineEmits(['update:currentPage']); | ||||
|  | ||||
| const route = useRoute(); | ||||
|  | ||||
| const createPortalDialogRef = ref(null); | ||||
|  | ||||
| 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 => { | ||||
|   emit('update:currentPage', page); | ||||
| }; | ||||
| @@ -46,34 +58,37 @@ const togglePortalSwitcher = () => { | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <section | ||||
|     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 bg-white lg:px-0 dark:bg-slate-900" | ||||
|     > | ||||
|   <section class="flex flex-col w-full h-full overflow-hidden bg-n-background"> | ||||
|     <header class="sticky top-0 z-10 px-6 pb-3 lg:px-0"> | ||||
|       <div class="w-full max-w-[900px] mx-auto"> | ||||
|         <div | ||||
|           v-if="showHeaderTitle" | ||||
|           class="flex items-center justify-start h-20 gap-2" | ||||
|         > | ||||
|           <span class="text-xl font-medium text-slate-900 dark:text-white"> | ||||
|             {{ header }} | ||||
|           <span | ||||
|             v-if="activePortalName" | ||||
|             class="text-xl font-medium text-slate-900 dark:text-white" | ||||
|           > | ||||
|             {{ activePortalName }} | ||||
|           </span> | ||||
|           <div class="relative group"> | ||||
|           <div v-if="activePortalName" class="relative group"> | ||||
|             <OnClickOutside @trigger="showPortalSwitcher = false"> | ||||
|               <Button | ||||
|               icon="more-vertical" | ||||
|                 icon="chevron-lucide-down" | ||||
|                 variant="ghost" | ||||
|               size="sm" | ||||
|               class="group-hover:bg-slate-100 dark:group-hover:bg-slate-800" | ||||
|                 icon-lib="lucide" | ||||
|                 class="!w-6 !h-6 group-hover:bg-n-solid-2 !p-0.5 rounded-md" | ||||
|                 @click="togglePortalSwitcher" | ||||
|               /> | ||||
|             <OnClickOutside @trigger="showPortalSwitcher = false"> | ||||
|  | ||||
|               <PortalSwitcher | ||||
|                 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> | ||||
|             <CreatePortalDialog ref="createPortalDialogRef" /> | ||||
|           </div> | ||||
|         </div> | ||||
|         <slot name="header-actions" /> | ||||
| @@ -84,10 +99,7 @@ const togglePortalSwitcher = () => { | ||||
|         <slot name="content" /> | ||||
|       </div> | ||||
|     </main> | ||||
|     <footer | ||||
|       v-if="showPaginationFooter" | ||||
|       class="sticky bottom-0 z-10 px-4 pt-3 pb-4 bg-white dark:bg-slate-900" | ||||
|     > | ||||
|     <footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 px-4 pb-4"> | ||||
|       <PaginationFooter | ||||
|         :current-page="currentPage" | ||||
|         :total-items="totalItems" | ||||
| @@ -95,5 +107,7 @@ const togglePortalSwitcher = () => { | ||||
|         @update:current-page="updateCurrentPage" | ||||
|       /> | ||||
|     </footer> | ||||
|     <!-- Do not remove this slot. It can be used to add dialogs. --> | ||||
|     <slot /> | ||||
|   </section> | ||||
| </template> | ||||
|   | ||||
| @@ -1,12 +1,14 @@ | ||||
| <script setup> | ||||
| import { ref } from 'vue'; | ||||
| import { ref, computed } from 'vue'; | ||||
| 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 Button from 'dashboard/components-next/button/Button.vue'; | ||||
| import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; | ||||
|  | ||||
| defineProps({ | ||||
| const props = defineProps({ | ||||
|   locale: { | ||||
|     type: String, | ||||
|     required: true, | ||||
| @@ -15,6 +17,10 @@ defineProps({ | ||||
|     type: Boolean, | ||||
|     required: true, | ||||
|   }, | ||||
|   localeCode: { | ||||
|     type: String, | ||||
|     required: true, | ||||
|   }, | ||||
|   articleCount: { | ||||
|     type: Number, | ||||
|     required: true, | ||||
| @@ -25,29 +31,26 @@ defineProps({ | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const isOpen = ref(false); | ||||
| const emit = defineEmits(['action']); | ||||
|  | ||||
| const menuItems = [ | ||||
|   { | ||||
|     label: 'Make default', | ||||
|     action: 'default', | ||||
|     icon: 'star-emphasis', | ||||
|   }, | ||||
|   { | ||||
|     label: 'Delete', | ||||
|     action: 'delete', | ||||
|     icon: 'delete', | ||||
|   }, | ||||
| ]; | ||||
| const { t } = useI18n(); | ||||
|  | ||||
| // eslint-disable-next-line no-unused-vars | ||||
| const handleAction = action => { | ||||
|   // TODO: Implement action | ||||
| const showDropdownMenu = ref(false); | ||||
|  | ||||
| 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> | ||||
|  | ||||
| <!-- TODO: Add i18n --> | ||||
| <!-- eslint-disable vue/no-bare-strings-in-template --> | ||||
| <template> | ||||
|   <CardLayout class="ltr:pr-2 rtl:pl-2"> | ||||
|     <template #header> | ||||
| @@ -56,42 +59,53 @@ const handleAction = action => { | ||||
|           <span | ||||
|             class="text-sm font-medium text-slate-900 dark:text-slate-50 line-clamp-1" | ||||
|           > | ||||
|             {{ locale }} | ||||
|             {{ locale }} ({{ localeCode }}) | ||||
|           </span> | ||||
|           <span | ||||
|             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" | ||||
|           > | ||||
|             Default | ||||
|             {{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }} | ||||
|           </span> | ||||
|         </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"> | ||||
|             <span | ||||
|               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> | ||||
|             <div class="w-px h-3 bg-slate-75 dark:bg-slate-800" /> | ||||
|             <span | ||||
|               class="text-sm text-slate-500 dark:text-slate-400 whitespace-nowrap" | ||||
|             > | ||||
|               {{ categoryCount }} categories | ||||
|               {{ | ||||
|                 $t( | ||||
|                   'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.CATEGORIES_COUNT', | ||||
|                   categoryCount | ||||
|                 ) | ||||
|               }} | ||||
|             </span> | ||||
|           </div> | ||||
|           <div class="relative group"> | ||||
|             <OnClickOutside @trigger="showDropdownMenu = false"> | ||||
|               <Button | ||||
|                 variant="ghost" | ||||
|               size="icon" | ||||
|                 size="sm" | ||||
|                 icon="more-vertical" | ||||
|                 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 | ||||
|                 v-if="isOpen" | ||||
|                 :menu-items="menuItems" | ||||
|                 class="right-0 mt-1 xl:left-0 top-full z-60 min-w-[147px]" | ||||
|                 v-if="showDropdownMenu" | ||||
|                 :menu-items="localeMenuItems" | ||||
|                 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" | ||||
|               /> | ||||
|             </OnClickOutside> | ||||
|   | ||||
| @@ -1,105 +1,111 @@ | ||||
| <script setup> | ||||
| import { computed } from 'vue'; | ||||
| import { debounce } from '@chatwoot/utils'; | ||||
| import { useI18n } from 'vue-i18n'; | ||||
| import { ARTICLE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor'; | ||||
| 
 | ||||
| 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 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: { | ||||
|     type: Object, | ||||
|     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 articleTitle = computed({ | ||||
|   get: () => article.title, | ||||
|   set: title => { | ||||
|     saveArticle({ title }); | ||||
|   get: () => props.article.title, | ||||
|   set: value => { | ||||
|     saveArticle({ title: value }); | ||||
|   }, | ||||
| }); | ||||
| 
 | ||||
| const articleContent = computed({ | ||||
|   get: () => article.content, | ||||
|   get: () => props.article.content, | ||||
|   set: content => { | ||||
|     saveArticle({ content }); | ||||
|   }, | ||||
| }); | ||||
| 
 | ||||
| const onClickGoBack = () => { | ||||
|   emit('goBack'); | ||||
| }; | ||||
| 
 | ||||
| const setAuthorId = authorId => { | ||||
|   emit('setAuthor', authorId); | ||||
| }; | ||||
| 
 | ||||
| const setCategoryId = categoryId => { | ||||
|   emit('setCategory', categoryId); | ||||
| }; | ||||
| 
 | ||||
| const previewArticle = () => { | ||||
|   emit('previewArticle'); | ||||
| }; | ||||
| </script> | ||||
| 
 | ||||
| <!-- eslint-disable vue/no-bare-strings-in-template --> | ||||
| <template> | ||||
|   <HelpCenterLayout :show-header-title="false" :show-pagination-footer="false"> | ||||
|     <template #header-actions> | ||||
|       <div class="flex items-center justify-between h-20"> | ||||
|         <Button | ||||
|           label="Back to articles" | ||||
|           icon="chevron-lucide-left" | ||||
|           icon-lib="lucide" | ||||
|           variant="link" | ||||
|           text-variant="info" | ||||
|           size="sm" | ||||
|       <ArticleEditorHeader | ||||
|         :is-updating="isUpdating" | ||||
|         :is-saved="isSaved" | ||||
|         :status="article.status" | ||||
|         :article-id="article.id" | ||||
|         @go-back="onClickGoBack" | ||||
|         @preview-article="previewArticle" | ||||
|       /> | ||||
|         <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 #content> | ||||
|       <div class="flex flex-col gap-3 pl-4 mb-3 rtl:pr-3 rtl:pl-0"> | ||||
|         <TextArea | ||||
|           v-model="articleTitle" | ||||
|           class="h-12" | ||||
|           custom-text-area-class="border-0 !text-[32px] !bg-transparent !py-0 !px-0 !h-auto !leading-[48px] !font-medium !tracking-[0.2px]" | ||||
|           auto-height | ||||
|           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" | ||||
|           autofocus | ||||
|         /> | ||||
|         <div class="flex items-center gap-4"> | ||||
|           <div class="flex items-center gap-2"> | ||||
|             <div class="w-5 h-5 rounded-full bg-slate-100 dark:bg-slate-700" /> | ||||
|             <span class="text-sm text-slate-500 dark:text-slate-400"> | ||||
|               John Doe | ||||
|             </span> | ||||
|           </div> | ||||
|           <div class="w-px h-3 bg-slate-50 dark:bg-slate-800" /> | ||||
|           <Button | ||||
|             label="Uncategorized" | ||||
|             icon="play-shape" | ||||
|             variant="ghost" | ||||
|             class="!px-2 font-normal" | ||||
|             text-variant="info" | ||||
|         <ArticleEditorControls | ||||
|           :article="article" | ||||
|           @save-article="saveArticle" | ||||
|           @set-author="setAuthorId" | ||||
|           @set-category="setCategoryId" | ||||
|         /> | ||||
|           <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> | ||||
|       <FullEditor | ||||
|         v-model="articleContent" | ||||
|         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" | ||||
|         :autofocus="false" | ||||
|       /> | ||||
|     </template> | ||||
|   </HelpCenterLayout> | ||||
| @@ -132,8 +138,10 @@ const articleContent = computed({ | ||||
| 
 | ||||
|       .ProseMirror-menuitem { | ||||
|         @apply mr-0; | ||||
| 
 | ||||
|         .ProseMirror-icon { | ||||
|           @apply p-0 mt-1 !mr-0; | ||||
| 
 | ||||
|           svg { | ||||
|             width: 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> | ||||
| 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'; | ||||
|  | ||||
| defineProps({ | ||||
| const props = defineProps({ | ||||
|   articles: { | ||||
|     type: Array, | ||||
|     required: true, | ||||
|   }, | ||||
|   isCategoryArticles: { | ||||
|     type: Boolean, | ||||
|     default: false, | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const { ARTICLE_STATUS_TYPES } = wootConstants; | ||||
|  | ||||
| const router = useRouter(); | ||||
| const route = useRoute(); | ||||
| const store = useStore(); | ||||
| const { t } = useI18n(); | ||||
|  | ||||
| const localArticles = ref(props.articles); | ||||
|  | ||||
| const dragEnabled = computed(() => { | ||||
|   // Enable dragging only for category articles and when there's more than one article | ||||
|   return props.isCategoryArticles && localArticles.value?.length > 1; | ||||
| }); | ||||
|  | ||||
| const getCategoryById = useMapGetter('categories/categoryById'); | ||||
|  | ||||
| const openArticle = id => { | ||||
|   const { tab, categorySlug, locale } = route.params; | ||||
|   if (props.isCategoryArticles) { | ||||
|     router.push({ | ||||
|       name: 'portals_categories_articles_edit', | ||||
|       params: { articleSlug: id }, | ||||
|     }); | ||||
|   } else { | ||||
|     router.push({ | ||||
|       name: 'portals_articles_edit', | ||||
|       params: { | ||||
|         articleSlug: id, | ||||
|         tab, | ||||
|         categorySlug, | ||||
|         locale, | ||||
|       }, | ||||
|     }); | ||||
|   } | ||||
| }; | ||||
|  | ||||
| const onReorder = reorderedGroup => { | ||||
|   store.dispatch('articles/reorder', { | ||||
|     reorderedGroup, | ||||
|     portalSlug: route.params.portalSlug, | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| const onDragEnd = () => { | ||||
|   // Reuse existing positions to maintain order within the current group | ||||
|   const sortedArticlePositions = localArticles.value | ||||
|     .map(article => article.position) | ||||
|     .sort((a, b) => a - b); // Use custom sort to handle numeric values correctly | ||||
|  | ||||
|   const orderedArticles = localArticles.value.map(article => article.id); | ||||
|  | ||||
|   // Create a map of article IDs to their new positions | ||||
|   const reorderedGroup = orderedArticles.reduce((obj, key, index) => { | ||||
|     obj[key] = sortedArticlePositions[index]; | ||||
|     return obj; | ||||
|   }, {}); | ||||
|  | ||||
|   onReorder(reorderedGroup); | ||||
| }; | ||||
|  | ||||
| const getCategory = categoryId => { | ||||
|   return getCategoryById.value(categoryId) || { name: '', icon: '' }; | ||||
| }; | ||||
|  | ||||
| const getStatusMessage = (status, isSuccess) => { | ||||
|   const messageType = isSuccess ? 'SUCCESS' : 'ERROR'; | ||||
|   const statusMap = { | ||||
|     [ARTICLE_STATUS_TYPES.PUBLISH]: 'PUBLISH_ARTICLE', | ||||
|     [ARTICLE_STATUS_TYPES.ARCHIVE]: 'ARCHIVE_ARTICLE', | ||||
|     [ARTICLE_STATUS_TYPES.DRAFT]: 'DRAFT_ARTICLE', | ||||
|   }; | ||||
|  | ||||
|   return statusMap[status] | ||||
|     ? t(`HELP_CENTER.${statusMap[status]}.API.${messageType}`) | ||||
|     : ''; | ||||
| }; | ||||
|  | ||||
| const 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> | ||||
|  | ||||
| <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 | ||||
|       v-for="article in articles" | ||||
|       :key="article.title" | ||||
|       :title="article.title" | ||||
|       :status="article.status" | ||||
|       :author="article.author" | ||||
|       :category="article.category" | ||||
|       :views="article.views" | ||||
|       :updated-at="article.updatedAt" | ||||
|           :id="element.id" | ||||
|           :key="element.id" | ||||
|           :title="element.title" | ||||
|           :status="element.status" | ||||
|           :author="element.author" | ||||
|           :category="getCategory(element.category.id)" | ||||
|           :views="element.views || 0" | ||||
|           :updated-at="element.updatedAt" | ||||
|           :class="{ 'cursor-grab': dragEnabled }" | ||||
|           @open-article="openArticle" | ||||
|           @article-action="updateArticle" | ||||
|         /> | ||||
|   </ul> | ||||
|       </li> | ||||
|     </template> | ||||
|   </Draggable> | ||||
| </template> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| .article-ghost-class { | ||||
|   @apply opacity-50 bg-n-solid-1; | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -1,74 +1,182 @@ | ||||
| <script setup> | ||||
| import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue'; | ||||
| import TabBar from 'dashboard/components-next/tabbar/TabBar.vue'; | ||||
| import Button from 'dashboard/components-next/button/Button.vue'; | ||||
| import ArticleList from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue'; | ||||
| import { computed } from 'vue'; | ||||
| import { useRouter, useRoute } from 'vue-router'; | ||||
| import { useI18n } from 'vue-i18n'; | ||||
| import { useMapGetter } from 'dashboard/composables/store.js'; | ||||
| import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper'; | ||||
|  | ||||
| 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: { | ||||
|     type: Array, | ||||
|     required: true, | ||||
|   }, | ||||
|   categories: { | ||||
|     type: Array, | ||||
|     required: true, | ||||
|   }, | ||||
|   allowedLocales: { | ||||
|     type: Array, | ||||
|     required: true, | ||||
|   }, | ||||
|   portalName: { | ||||
|     type: String, | ||||
|     required: true, | ||||
|   }, | ||||
|   meta: { | ||||
|     type: Object, | ||||
|     required: true, | ||||
|   }, | ||||
|   isCategoryArticles: { | ||||
|     type: Boolean, | ||||
|     default: false, | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const tabs = [ | ||||
|   { label: 'All articles', count: 24 }, | ||||
|   { label: 'Mine', count: 13 }, | ||||
|   { label: 'Draft', count: 5 }, | ||||
|   { label: 'Archived', count: 11 }, | ||||
| ]; | ||||
| // TODO: remove comments | ||||
| // eslint-disable-next-line no-unused-vars | ||||
| const handleTabChange = tab => { | ||||
|   // TODO: Implement tab change logic | ||||
| const emit = defineEmits(['pageChange', 'fetchPortal']); | ||||
|  | ||||
| const router = useRouter(); | ||||
| const route = useRoute(); | ||||
| const { t } = useI18n(); | ||||
|  | ||||
| const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal'); | ||||
| const isFetching = useMapGetter('articles/isFetching'); | ||||
|  | ||||
| const hasNoArticles = computed( | ||||
|   () => !isFetching.value && !props.articles.length | ||||
| ); | ||||
|  | ||||
| const isLoading = computed(() => isFetching.value || isSwitchingPortal.value); | ||||
|  | ||||
| const totalArticlesCount = computed(() => props.meta.allArticlesCount); | ||||
|  | ||||
| const hasNoArticlesInPortal = computed( | ||||
|   () => totalArticlesCount.value === 0 && !props.isCategoryArticles | ||||
| ); | ||||
|  | ||||
| const shouldShowPaginationFooter = computed(() => { | ||||
|   return !(isFetching.value || isSwitchingPortal.value || hasNoArticles.value); | ||||
| }); | ||||
|  | ||||
| const updateRoute = newParams => { | ||||
|   const { portalSlug, locale, tab, categorySlug } = route.params; | ||||
|   router.push({ | ||||
|     name: 'portals_articles_index', | ||||
|     params: { | ||||
|       portalSlug, | ||||
|       locale: newParams.locale ?? locale, | ||||
|       tab: newParams.tab ?? tab, | ||||
|       categorySlug: newParams.categorySlug ?? categorySlug, | ||||
|       ...newParams, | ||||
|     }, | ||||
|   }); | ||||
| }; | ||||
| // eslint-disable-next-line no-unused-vars | ||||
| const handlePageChange = page => { | ||||
|   // TODO: Implement page change logic | ||||
|  | ||||
| const articlesCount = computed(() => { | ||||
|   const { tab } = route.params; | ||||
|   const { meta } = props; | ||||
|   const countMap = { | ||||
|     '': meta.articlesCount, | ||||
|     mine: meta.mineArticlesCount, | ||||
|     draft: meta.draftArticlesCount, | ||||
|     archived: meta.archivedArticlesCount, | ||||
|   }; | ||||
|   return Number(countMap[tab] || countMap['']); | ||||
| }); | ||||
|  | ||||
| const showArticleHeaderControls = computed( | ||||
|   () => | ||||
|     !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> | ||||
|  | ||||
| <template> | ||||
|   <HelpCenterLayout | ||||
|     :current-page="1" | ||||
|     :total-items="100" | ||||
|     :items-per-page="10" | ||||
|     :current-page="Number(meta.currentPage)" | ||||
|     :total-items="articlesCount" | ||||
|     :items-per-page="25" | ||||
|     :header="portalName" | ||||
|     :show-pagination-footer="shouldShowPaginationFooter" | ||||
|     @update:current-page="handlePageChange" | ||||
|   > | ||||
|     <template #header-actions> | ||||
|       <div class="flex items-end justify-between"> | ||||
|         <div class="flex flex-col items-start w-full gap-2 lg:flex-row"> | ||||
|           <TabBar | ||||
|             :tabs="tabs" | ||||
|             :initial-active-tab="1" | ||||
|             @tab-changed="handleTabChange" | ||||
|         <ArticleHeaderControls | ||||
|           v-if="showArticleHeaderControls" | ||||
|           :categories="categories" | ||||
|           :allowed-locales="allowedLocales" | ||||
|           :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"> | ||||
|             <div class="flex items-center gap-2"> | ||||
|               <Button | ||||
|                 label="English" | ||||
|                 size="sm" | ||||
|                 icon-position="right" | ||||
|                 icon="chevron-lucide-down" | ||||
|                 icon-lib="lucide" | ||||
|                 variant="secondary" | ||||
|         <CategoryHeaderControls | ||||
|           v-else-if="showCategoryHeaderControls" | ||||
|           :categories="categories" | ||||
|           :allowed-locales="allowedLocales" | ||||
|           :has-selected-category="isCategoryArticles" | ||||
|         /> | ||||
|               <Button | ||||
|                 label="All categories" | ||||
|                 size="sm" | ||||
|                 icon-position="right" | ||||
|                 icon="chevron-lucide-down" | ||||
|                 icon-lib="lucide" | ||||
|                 variant="secondary" | ||||
|               /> | ||||
|             </div> | ||||
|             <Button label="New article" icon="add" size="sm" /> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </template> | ||||
|     <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> | ||||
|   </HelpCenterLayout> | ||||
| </template> | ||||
|   | ||||
| @@ -1,101 +1,139 @@ | ||||
| <script setup> | ||||
| 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 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 ArticleList from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue'; | ||||
| // import EditCategory from 'dashboard/playground/HelpCenter/components/EditCategory.vue'; | ||||
| import CategoryHeaderControls from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryHeaderControls.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({ | ||||
|   categories: { | ||||
|     type: Array, | ||||
|     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 showEditCategory = ref(false); | ||||
|  | ||||
| // const openEditCategory = () => { | ||||
| //   showEditCategory.value = true; | ||||
| // }; | ||||
| // const closeEditCategory = () => { | ||||
| //   showEditCategory.value = false; | ||||
| // }; | ||||
| const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal'); | ||||
| const isLoading = computed(() => props.isFetching || isSwitchingPortal.value); | ||||
| const hasCategories = computed(() => props.categories?.length > 0); | ||||
|  | ||||
| const breadcrumbItems = computed(() => { | ||||
|   const items = [{ label: 'Categories (en-US)', link: '#' }]; | ||||
|   if (selectedCategory.value) { | ||||
|     items.push({ | ||||
|       label: selectedCategory.value.title, | ||||
|       count: selectedCategory.value.articles.length, | ||||
| const updateRoute = (newParams, routeName) => { | ||||
|   const { accountId, portalSlug, locale } = route.params; | ||||
|   const baseParams = { accountId, portalSlug, locale }; | ||||
|  | ||||
|   router.push({ | ||||
|     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( | ||||
|       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> | ||||
|  | ||||
| <!-- eslint-disable vue/no-bare-strings-in-template --> | ||||
| <template> | ||||
|   <HelpCenterLayout :show-pagination-footer="false"> | ||||
|     <template #header-actions> | ||||
|       <div class="flex items-center justify-between"> | ||||
|         <div v-if="!selectedCategory" class="flex items-center gap-4"> | ||||
|           <Button | ||||
|             label="English" | ||||
|             size="sm" | ||||
|             icon-position="right" | ||||
|             icon="chevron-lucide-down" | ||||
|             icon-lib="lucide" | ||||
|             variant="secondary" | ||||
|       <CategoryHeaderControls | ||||
|         :categories="categories" | ||||
|         :is-category-articles="false" | ||||
|         :allowed-locales="allowedLocales" | ||||
|         @locale-change="handleLocaleChange" | ||||
|       /> | ||||
|           <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 #content> | ||||
|       <div | ||||
|         v-if="isLoading" | ||||
|         class="flex items-center justify-center py-10 text-n-slate-11" | ||||
|       > | ||||
|         <Spinner /> | ||||
|       </div> | ||||
|       <CategoryList | ||||
|         v-if="!selectedCategory" | ||||
|         v-else-if="hasCategories" | ||||
|         :categories="categories" | ||||
|         @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> | ||||
|     <EditCategoryDialog | ||||
|       ref="editCategoryDialog" | ||||
|       :allowed-locales="allowedLocales" | ||||
|       :selected-category="selectedCategory" | ||||
|     /> | ||||
|   </HelpCenterLayout> | ||||
| </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 => { | ||||
|   emit('click', id); | ||||
| const handleClick = slug => { | ||||
|   emit('click', slug); | ||||
| }; | ||||
|  | ||||
| const handleAction = ({ action, value, id }, category) => { | ||||
|   emit('action', { action, value, id, category }); | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| @@ -20,11 +24,14 @@ const handleClick = id => { | ||||
|     <CategoryCard | ||||
|       v-for="category in categories" | ||||
|       :id="category.id" | ||||
|       :key="category.title" | ||||
|       :title="category.title" | ||||
|       :key="category.id" | ||||
|       :title="category.name" | ||||
|       :icon="category.icon" | ||||
|       :description="category.description" | ||||
|       :articles-count="category.articlesCount" | ||||
|       @click="handleClick(category.id)" | ||||
|       :articles-count="category.meta.articles_count || 0" | ||||
|       :slug="category.slug" | ||||
|       @click="handleClick(category.slug)" | ||||
|       @action="handleAction($event, category)" | ||||
|     /> | ||||
|   </ul> | ||||
| </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> | ||||
| 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: { | ||||
|     type: Array, | ||||
|     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> | ||||
|  | ||||
| <template> | ||||
| @@ -15,9 +97,11 @@ defineProps({ | ||||
|       v-for="(locale, index) in locales" | ||||
|       :key="index" | ||||
|       :locale="locale.name" | ||||
|       :is-default="locale.isDefault" | ||||
|       :article-count="locale.articleCount" | ||||
|       :category-count="locale.categoryCount" | ||||
|       :is-default="isLocaleDefault(locale.code)" | ||||
|       :locale-code="locale.code" | ||||
|       :article-count="locale.articlesCount || 0" | ||||
|       :category-count="locale.categoriesCount || 0" | ||||
|       @action="handleAction($event, locale.code)" | ||||
|     /> | ||||
|   </ul> | ||||
| </template> | ||||
|   | ||||
| @@ -1,27 +1,28 @@ | ||||
| <script setup> | ||||
| import { computed } from 'vue'; | ||||
| import { computed, ref } from 'vue'; | ||||
| import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue'; | ||||
| import Button from 'dashboard/components-next/button/Button.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({ | ||||
|   locales: { | ||||
|     type: Array, | ||||
|     required: true, | ||||
|   }, | ||||
|   portal: { | ||||
|     type: Object, | ||||
|     default: () => ({}), | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const localeCount = computed(() => props.locales?.length); | ||||
| const addLocaleDialogRef = ref(null); | ||||
|  | ||||
| // TODO: remove comments | ||||
| // eslint-disable-next-line no-unused-vars | ||||
| const handleTabChange = tab => { | ||||
|   // TODO: Implement tab change logic | ||||
| }; | ||||
| // eslint-disable-next-line no-unused-vars | ||||
| const handlePageChange = page => { | ||||
|   // TODO: Implement page change logic | ||||
| const openAddLocaleDialog = () => { | ||||
|   addLocaleDialogRef.value.dialogRef.open(); | ||||
| }; | ||||
|  | ||||
| const localeCount = computed(() => props.locales?.length); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| @@ -37,11 +38,13 @@ const handlePageChange = page => { | ||||
|           :label="$t('HELP_CENTER.LOCALES_PAGE.NEW_LOCALE_BUTTON_TEXT')" | ||||
|           icon="add" | ||||
|           size="sm" | ||||
|           @click="openAddLocaleDialog" | ||||
|         /> | ||||
|       </div> | ||||
|     </template> | ||||
|     <template #content> | ||||
|       <LocaleList :locales="locales" /> | ||||
|       <LocaleList :locales="locales" :portal="portal" /> | ||||
|     </template> | ||||
|     <AddLocaleDialog ref="addLocaleDialogRef" :portal="portal" /> | ||||
|   </HelpCenterLayout> | ||||
| </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> | ||||
| import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue'; | ||||
| import Button from 'dashboard/components-next/button/Button.vue'; | ||||
| import Input from 'dashboard/components-next/input/Input.vue'; | ||||
| import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue'; | ||||
| import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; | ||||
| import { computed, ref } from 'vue'; | ||||
| import { useRoute } from 'vue-router'; | ||||
| import { useI18n } from 'vue-i18n'; | ||||
| import { useMapGetter } from 'dashboard/composables/store.js'; | ||||
|  | ||||
| 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> | ||||
|  | ||||
| <!-- TODO: Add i18n --> | ||||
| <!-- eslint-disable vue/no-bare-strings-in-template --> | ||||
| <template> | ||||
|   <HelpCenterLayout :show-pagination-footer="false"> | ||||
|     <template #content> | ||||
|       <div class="flex flex-col w-full gap-10 max-w-[640px] pt-2 pb-8"> | ||||
|         <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" | ||||
|       <div | ||||
|         v-if="isLoading" | ||||
|         class="flex items-center justify-center py-10 pt-2 pb-8 text-n-slate-11" | ||||
|       > | ||||
|               Avatar | ||||
|             </label> | ||||
|             <Avatar | ||||
|               label="Avatar" | ||||
|               src="https://api.dicebear.com/9.x/avataaars/svg?seed=Amaya" | ||||
|               class="bg-ruby-300 dark:bg-ruby-400" | ||||
|               @upload="handleUploadAvatar" | ||||
|         <Spinner /> | ||||
|       </div> | ||||
|       <div | ||||
|         v-else-if="activePortal" | ||||
|         class="flex flex-col w-full gap-4 max-w-[640px] pb-8" | ||||
|       > | ||||
|         <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> | ||||
|         <div class="flex flex-col w-full gap-6"> | ||||
|           <div class="flex flex-col w-full gap-6"> | ||||
|             <h6 class="text-base font-medium text-slate-900 dark:text-slate-50"> | ||||
|               Configuration | ||||
|         <PortalConfigurationSettings | ||||
|           :active-portal="activePortal" | ||||
|           :is-fetching="isFetching" | ||||
|           @update-portal-configuration="handleUpdatePortalConfiguration" | ||||
|         /> | ||||
|         <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> | ||||
|             <div class="flex flex-col w-full gap-4"> | ||||
|               <div class="flex justify-between w-full gap-2 py-1"> | ||||
|                 <InlineInput | ||||
|                   placeholder="Slug" | ||||
|                   label="Slug:" | ||||
|                   custom-label-class="min-w-[100px]" | ||||
|                   custom-input-class="!w-[430px]" | ||||
|                 /> | ||||
|             <span class="text-sm text-n-slate-11"> | ||||
|               {{ | ||||
|                 t( | ||||
|                   'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.DESCRIPTION' | ||||
|                 ) | ||||
|               }} | ||||
|             </span> | ||||
|           </div> | ||||
|               <div class="flex justify-between w-full gap-2 py-1"> | ||||
|                 <InlineInput | ||||
|                   placeholder="Custom domain" | ||||
|                   label="Custom domain:" | ||||
|                   custom-label-class="min-w-[100px]" | ||||
|                   custom-input-class="!w-[430px]" | ||||
|                 /> | ||||
|               </div> | ||||
|               <div class="flex justify-between w-full gap-2 py-1"> | ||||
|                 <InlineInput | ||||
|                   placeholder="Home page link" | ||||
|                   label="Home page link:" | ||||
|                   custom-label-class="min-w-[100px]" | ||||
|                   custom-input-class="!w-[430px]" | ||||
|                 /> | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|           <div class="flex justify-end w-full gap-3 py-4"> | ||||
|             <Button label="Edit configuration" size="sm" variant="secondary" /> | ||||
|           <Button | ||||
|               label="Delete Test-Help Center" | ||||
|               size="sm" | ||||
|             :label=" | ||||
|               t( | ||||
|                 'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.BUTTON', | ||||
|                 { | ||||
|                   portalName: activePortalName, | ||||
|                 } | ||||
|               ) | ||||
|             " | ||||
|             variant="destructive" | ||||
|             class="w-56" | ||||
|             @click="openConfirmDeletePortalDialog" | ||||
|           /> | ||||
|         </div> | ||||
|       </div> | ||||
|       </div> | ||||
|     </template> | ||||
|     <ConfirmDeletePortalDialog | ||||
|       ref="confirmDeletePortalDialogRef" | ||||
|       :active-portal-name="activePortalName" | ||||
|       @delete-portal="handleDeletePortal" | ||||
|     /> | ||||
|   </HelpCenterLayout> | ||||
| </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> | ||||
| 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 Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue'; | ||||
|  | ||||
| defineProps({ | ||||
|   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 emit = defineEmits(['close', 'createPortal']); | ||||
|  | ||||
| const selectedPortal = ref(1); | ||||
| const { t } = useI18n(); | ||||
| const route = useRoute(); | ||||
| const router = useRouter(); | ||||
| const store = useStore(); | ||||
|  | ||||
| const handlePortalChange = id => { | ||||
|   selectedPortal.value = id; | ||||
| const DEFAULT_ROUTE = 'portals_articles_index'; | ||||
| 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> | ||||
|  | ||||
| <!-- TODO: Add i18n --> | ||||
| <!-- eslint-disable vue/no-bare-strings-in-template --> | ||||
| <template> | ||||
|   <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"> | ||||
|         <h2 class="text-base font-medium text-slate-900 dark:text-slate-50"> | ||||
|           {{ header }} | ||||
|         <h2 | ||||
|           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> | ||||
|         <p class="text-sm text-slate-600 dark:text-slate-300"> | ||||
|           {{ description }} | ||||
|           {{ t('HELP_CENTER.PORTAL_SWITCHER.CREATE_PORTAL') }} | ||||
|         </p> | ||||
|       </div> | ||||
|       <Button label="New portal" variant="secondary" icon="add" size="sm" /> | ||||
|     </div> | ||||
|     <div v-if="portals.length > 0" class="flex flex-col gap-3"> | ||||
|       <template v-for="(portal, index) in portals" :key="portal.id"> | ||||
|         <div class="flex flex-col gap-2 px-6 py-2"> | ||||
|           <div class="flex items-center justify-between"> | ||||
|             <div class="flex items-center"> | ||||
|               <input | ||||
|                 :id="portal.id" | ||||
|                 v-model="selectedPortal" | ||||
|                 type="radio" | ||||
|                 :value="portal.id" | ||||
|                 class="mr-3" | ||||
|                 @change="handlePortalChange(portal.id)" | ||||
|       <Button | ||||
|         :label="t('HELP_CENTER.PORTAL_SWITCHER.NEW_PORTAL')" | ||||
|         variant="secondary" | ||||
|         icon="add" | ||||
|         size="sm" | ||||
|         class="!bg-n-alpha-2 hover:!bg-n-alpha-3" | ||||
|         @click="openCreatePortalDialog" | ||||
|       /> | ||||
|               <label | ||||
|                 :for="portal.id" | ||||
|                 class="text-sm font-medium text-slate-900 dark:text-slate-100" | ||||
|     </div> | ||||
|     <div v-if="portals.length > 0" class="flex flex-col gap-2 px-4"> | ||||
|       <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 }} | ||||
|               </label> | ||||
|             </div> | ||||
|             <div class="w-4 h-4 rounded-full bg-slate-100 dark:bg-slate-700" /> | ||||
|           </div> | ||||
|           <div class="inline-flex items-center gap-2 py-1 text-sm"> | ||||
|             <span class="text-slate-600 dark:text-slate-400"> | ||||
|               articles: | ||||
|               <span class="text-slate-800 dark:text-slate-200"> | ||||
|                 {{ 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 #leftPrefix> | ||||
|           <Thumbnail | ||||
|             v-if="portal" | ||||
|             :author="portal" | ||||
|             :name="portal.name" | ||||
|             :size="20" | ||||
|             :src="getPortalThumbnailSrc(portal)" | ||||
|             :show-author-name="false" | ||||
|             icon-name="building-lucide" | ||||
|           /> | ||||
|         </template> | ||||
|         <template #rightPrefix> | ||||
|           <span class="text-sm truncate text-n-slate-11"> | ||||
|             {{ portal.custom_domain || '' }} | ||||
|           </span> | ||||
|         </template> | ||||
|       </Button> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|   | ||||
| @@ -24,14 +24,14 @@ import Avatar from './Avatar.vue'; | ||||
|     </Variant> | ||||
|  | ||||
|     <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 name="Bruce Wayne" allow-upload /> | ||||
|       </div> | ||||
|     </Variant> | ||||
|  | ||||
|     <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 | ||||
|           src="https://api.dicebear.com/9.x/thumbs/svg?seed=Amaya" | ||||
|           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([ | ||||
|   { label: 'Home', link: '#' }, | ||||
|   { label: 'Categories', link: '#' }, | ||||
|   { label: 'Marketing', count: 6 }, | ||||
|   { label: 'Marketing', count: 6, emoji: '📊' }, | ||||
| ]); | ||||
| const longBreadcrumb = ref([ | ||||
|   { label: 'Home', link: '#' }, | ||||
|   { label: 'Categories', link: '#' }, | ||||
|   { label: 'Categories', link: '#', emoji: '📁' }, | ||||
|   { label: 'Marketing', link: '#' }, | ||||
|   { label: 'Digital', link: '#' }, | ||||
|   { label: 'Social Media', count: 12 }, | ||||
|   { label: 'Digital', link: '#', emoji: '💻' }, | ||||
|   { label: 'Social Media', count: 12, emoji: '📱' }, | ||||
| ]); | ||||
| </script> | ||||
|  | ||||
|   | ||||
| @@ -1,6 +1,8 @@ | ||||
| <script setup> | ||||
| import { defineProps } from 'vue'; | ||||
| import { useI18n } from 'vue-i18n'; | ||||
|  | ||||
| import Button from 'dashboard/components-next/button/Button.vue'; | ||||
| import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue'; | ||||
|  | ||||
| defineProps({ | ||||
| @@ -16,44 +18,43 @@ defineProps({ | ||||
|       ); | ||||
|     }, | ||||
|   }, | ||||
|   countLabel: { | ||||
|     type: String, | ||||
|     default: '', | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const emit = defineEmits(['click']); | ||||
|  | ||||
| const { t } = useI18n(); | ||||
|  | ||||
| const onClick = event => { | ||||
|   emit('click', event); | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <nav :aria-label="t('BREADCRUMB.ARIA_LABEL')" class="flex items-center h-8"> | ||||
|     <ol class="flex items-center mb-0"> | ||||
|       <li | ||||
|         v-for="(item, index) in items" | ||||
|         :key="index" | ||||
|         class="flex items-center gap-3" | ||||
|       > | ||||
|         <template v-if="index === items.length - 1"> | ||||
|           <span class="text-sm text-slate-900 dark:text-slate-50"> | ||||
|             {{ | ||||
|               `${item.label}${item.count ? ` (${item.count} ${countLabel})` : ''}` | ||||
|             }} | ||||
|           </span> | ||||
|         </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> | ||||
|       <li v-for="(item, index) in items" :key="index" class="flex items-center"> | ||||
|         <Button | ||||
|           v-if="index === 0" | ||||
|           :label="item.label" | ||||
|           variant="link" | ||||
|           text-variant="info" | ||||
|           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" | ||||
|           @click="onClick" | ||||
|         /> | ||||
|         <template v-else> | ||||
|           <FluentIcon | ||||
|           v-if="index < items.length - 1" | ||||
|             icon="chevron-lucide-right" | ||||
|             size="18" | ||||
|             icon-lib="lucide" | ||||
|           class="flex-shrink-0 text-slate-300 dark:text-slate-500 ltr:mr-3 rtl:mr-0 rtl:ml-3" | ||||
|             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> | ||||
|     </ol> | ||||
|   </nav> | ||||
|   | ||||
| @@ -100,7 +100,7 @@ import Button from './Button.vue'; | ||||
|           icon-position="left" | ||||
|           size="sm" | ||||
|         /> | ||||
|         <Button icon="emoji-add" size="icon" /> | ||||
|         <Button icon="emoji-add" size="sm" /> | ||||
|       </div> | ||||
|     </Variant> | ||||
|  | ||||
| @@ -119,7 +119,7 @@ import Button from './Button.vue'; | ||||
|           icon-position="right" | ||||
|           size="sm" | ||||
|         /> | ||||
|         <Button icon="emoji-add" size="icon" /> | ||||
|         <Button icon="emoji-add" size="sm" /> | ||||
|       </div> | ||||
|     </Variant> | ||||
|   </Story> | ||||
|   | ||||
| @@ -1,6 +1,8 @@ | ||||
| <script setup> | ||||
| import { computed } from 'vue'; | ||||
|  | ||||
| import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue'; | ||||
| import Spinner from 'dashboard/components-next/spinner/Spinner.vue'; | ||||
|  | ||||
| const props = defineProps({ | ||||
|   label: { | ||||
| @@ -29,12 +31,21 @@ const props = defineProps({ | ||||
|   size: { | ||||
|     type: String, | ||||
|     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: { | ||||
|     type: String, | ||||
|     default: '', | ||||
|   }, | ||||
|   emoji: { | ||||
|     type: String, | ||||
|     default: '', | ||||
|   }, | ||||
|   iconPosition: { | ||||
|     type: String, | ||||
|     default: 'left', | ||||
| @@ -44,6 +55,10 @@ const props = defineProps({ | ||||
|     type: String, | ||||
|     default: 'fluent', | ||||
|   }, | ||||
|   isLoading: { | ||||
|     type: Boolean, | ||||
|     default: false, | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const emit = defineEmits(['click']); | ||||
| @@ -51,33 +66,28 @@ const emit = defineEmits(['click']); | ||||
| const buttonVariants = { | ||||
|   variant: { | ||||
|     default: | ||||
|       'bg-woot-500 dark:bg-woot-500 text-white dark:text-white hover:bg-woot-600 dark:hover:bg-woot-600', | ||||
|     destructive: | ||||
|       'bg-ruby-700 dark:bg-ruby-700 text-white dark:text-white hover:bg-ruby-800 dark:hover:bg-ruby-800', | ||||
|       'bg-n-brand text-white dark:text-white hover:bg-woot-600 dark:hover:bg-woot-600', | ||||
|     destructive: 'bg-n-ruby-9 text-white dark:text-white hover:bg-n-ruby-10', | ||||
|     outline: | ||||
|       'border border-slate-200 dark:border-slate-700/50 hover:border-slate-300 dark:hover:border-slate-600', | ||||
|     secondary: | ||||
|       '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-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', | ||||
|       'border border-n-weak dark:border-n-weak hover:border-n-slate-6 dark:hover:border-n-slate-6', | ||||
|     secondary: 'bg-n-solid-3 text-n-slate-12 hover:bg-n-solid-2', | ||||
|     ghost: 'text-n-slate-12', | ||||
|     link: 'text-n-brand underline-offset-4 hover:underline dark:hover:underline', | ||||
|   }, | ||||
|   size: { | ||||
|     default: 'h-10 px-4 py-2', | ||||
|     sm: 'h-8 px-3', | ||||
|     lg: 'h-11 px-4', | ||||
|     icon: 'h-auto w-auto px-2', | ||||
|     sm: 'h-8 px-3 py-1', | ||||
|     lg: 'h-12 px-5 py-3', | ||||
|   }, | ||||
|   text: { | ||||
|     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: | ||||
|       '!text-green-500 dark:!text-green-500 hover:!text-green-600 dark:hover:!text-green-600', | ||||
|     warning: | ||||
|       '!text-amber-600 dark:!text-amber-600 hover:!text-amber-600 dark:hover:!text-amber-600', | ||||
|     danger: | ||||
|       '!text-ruby-700 dark:!text-ruby-700 hover:!text-ruby-800 dark:hover:!text-ruby-800', | ||||
|     info: '!text-slate-500 dark:!text-slate-400 hover:!text-slate-600 dark:hover:!text-slate-500', | ||||
|     danger: '!text-n-ruby-11 hover:!text-n-ruby-10', | ||||
|     info: '!text-n-slate-12 hover:!text-n-slate-11', | ||||
|   }, | ||||
| }; | ||||
|  | ||||
| @@ -100,35 +110,43 @@ const iconSize = computed(() => { | ||||
|   return 18; | ||||
| }); | ||||
|  | ||||
| const handleClick = () => { | ||||
|   emit('click'); | ||||
| const handleClick = e => { | ||||
|   emit('click', e); | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <button | ||||
|     :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" | ||||
|   > | ||||
|     <FluentIcon | ||||
|       v-if="icon && iconPosition === 'left'" | ||||
|       v-if="icon && iconPosition === 'left' && !isLoading" | ||||
|       :icon="icon" | ||||
|       :size="iconSize" | ||||
|       :icon-lib="iconLib" | ||||
|       class="flex-shrink-0" | ||||
|       :class="{ | ||||
|         'text-n-slate-11 dark:text-n-slate-11': variant === 'secondary', | ||||
|       }" | ||||
|     /> | ||||
|     <slot> | ||||
|       <span v-if="label" class="min-w-0 truncate"> | ||||
|         {{ label }} | ||||
|       </span> | ||||
|     </slot> | ||||
|     <Spinner v-if="isLoading" class="!w-5 !h-5 flex-shrink-0" /> | ||||
|     <slot name="leftPrefix" /> | ||||
|     <span v-if="emoji">{{ emoji }}</span> | ||||
|     <span v-if="label" class="min-w-0 truncate">{{ label }}</span> | ||||
|     <slot /> | ||||
|     <slot name="rightPrefix" /> | ||||
|     <FluentIcon | ||||
|       v-if="icon && iconPosition === 'right'" | ||||
|       :icon="icon" | ||||
|       :size="iconSize" | ||||
|       :icon-lib="iconLib" | ||||
|       class="flex-shrink-0" | ||||
|       :class="{ | ||||
|         'text-n-slate-11 dark:text-n-slate-11': variant === 'secondary', | ||||
|       }" | ||||
|     /> | ||||
|   </button> | ||||
| </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> | ||||
| import { nextTick, ref, computed, watch } from 'vue'; | ||||
| import { onClickOutside } from '@vueuse/core'; | ||||
| import { ref, computed, watch, nextTick } from 'vue'; | ||||
| import { OnClickOutside } from '@vueuse/components'; | ||||
| import { useI18n } from 'vue-i18n'; | ||||
| import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue'; | ||||
| import Button from 'dashboard/components-next/button/Button.vue'; | ||||
| @@ -32,6 +32,10 @@ const props = defineProps({ | ||||
|     type: String, | ||||
|     default: '', | ||||
|   }, | ||||
|   message: { | ||||
|     type: String, | ||||
|     default: '', | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const emit = defineEmits(['update:modelValue']); | ||||
| @@ -80,10 +84,6 @@ watch( | ||||
|     selectedValue.value = newValue; | ||||
|   } | ||||
| ); | ||||
|  | ||||
| onClickOutside(comboboxRef, () => { | ||||
|   open.value = false; | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| @@ -95,21 +95,22 @@ onClickOutside(comboboxRef, () => { | ||||
|       'group/combobox': !disabled, | ||||
|     }" | ||||
|   > | ||||
|     <OnClickOutside @trigger="open = false"> | ||||
|       <Button | ||||
|         variant="outline" | ||||
|         :label="selectedLabel" | ||||
|         icon-position="right" | ||||
|       size="sm" | ||||
|         :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" | ||||
|       :icon="open ? 'chevron-up' : 'chevron-down'" | ||||
|         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-lucide-up' : 'chevron-lucide-down'" | ||||
|         icon-lib="lucide" | ||||
|         @click="toggleDropdown" | ||||
|       /> | ||||
|       <div | ||||
|         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 | ||||
|             icon="search" | ||||
|             :size="14" | ||||
| @@ -121,20 +122,20 @@ onClickOutside(comboboxRef, () => { | ||||
|             v-model="search" | ||||
|             type="search" | ||||
|             :placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')" | ||||
|           class="w-full py-2 pl-10 pr-2 text-sm bg-white border-none rounded-t-md dark:bg-slate-900 text-slate-900 dark:text-slate-50" | ||||
|             class="w-full py-2 pl-10 pr-2 text-sm border-none rounded-t-md bg-n-solid-1 text-slate-900 dark:text-slate-50" | ||||
|           /> | ||||
|         </div> | ||||
|         <ul | ||||
|         class="py-1 overflow-auto max-h-60" | ||||
|           class="py-1 mb-0 overflow-auto max-h-60" | ||||
|           role="listbox" | ||||
|           :aria-activedescendant="selectedValue" | ||||
|         > | ||||
|           <li | ||||
|             v-for="option in filteredOptions" | ||||
|             :key="option.value" | ||||
|           class="flex items-center justify-between w-full gap-2 px-3 py-2 text-sm transition-colors duration-150 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/50" | ||||
|             class="flex items-center justify-between !text-n-slate-12 w-full gap-2 px-2 py-2 text-sm transition-colors duration-150 cursor-pointer hover:bg-n-solid-2" | ||||
|             :class="{ | ||||
|             'bg-slate-50 dark:bg-slate-800/50': option.value === selectedValue, | ||||
|               'bg-n-solid-2': option.value === selectedValue, | ||||
|             }" | ||||
|             role="option" | ||||
|             :aria-selected="option.value === selectedValue" | ||||
| @@ -147,7 +148,7 @@ onClickOutside(comboboxRef, () => { | ||||
|               v-if="option.value === selectedValue" | ||||
|               icon="checkmark" | ||||
|               :size="16" | ||||
|             class="flex-shrink-0" | ||||
|               class="flex-shrink-0 text-n-slate-11 dark:text-n-slate-11" | ||||
|               aria-hidden="true" | ||||
|             /> | ||||
|           </li> | ||||
| @@ -159,5 +160,12 @@ onClickOutside(comboboxRef, () => { | ||||
|           </li> | ||||
|         </ul> | ||||
|       </div> | ||||
|       <p | ||||
|         v-if="message" | ||||
|         class="mt-2 mb-0 text-xs truncate transition-all duration-500 ease-in-out text-n-slate-11 dark:text-n-slate-11" | ||||
|       > | ||||
|         {{ message }} | ||||
|       </p> | ||||
|     </OnClickOutside> | ||||
|   </div> | ||||
| </template> | ||||
|   | ||||
| @@ -1,7 +1,9 @@ | ||||
| <script setup> | ||||
| import { ref } from 'vue'; | ||||
| import { onClickOutside } from '@vueuse/core'; | ||||
| import { OnClickOutside } from '@vueuse/components'; | ||||
| import { useI18n } from 'vue-i18n'; | ||||
| import { useMapGetter } from 'dashboard/composables/store.js'; | ||||
|  | ||||
| import Button from 'dashboard/components-next/button/Button.vue'; | ||||
|  | ||||
| defineProps({ | ||||
| @@ -26,12 +28,30 @@ defineProps({ | ||||
|     type: String, | ||||
|     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 isRTL = useMapGetter('accounts/isRTL'); | ||||
|  | ||||
| const dialogRef = ref(null); | ||||
| const dialogContentRef = ref(null); | ||||
|  | ||||
| @@ -39,71 +59,69 @@ const open = () => { | ||||
|   dialogRef.value?.showModal(); | ||||
| }; | ||||
| const close = () => { | ||||
|   emit('close'); | ||||
|   dialogRef.value?.close(); | ||||
| }; | ||||
| const confirm = () => { | ||||
|   emit('confirm'); | ||||
| }; | ||||
|  | ||||
| defineExpose({ open }); | ||||
|  | ||||
| onClickOutside(dialogContentRef, event => { | ||||
|   if ( | ||||
|     dialogRef.value && | ||||
|     dialogRef.value.open && | ||||
|     event.target === dialogRef.value | ||||
|   ) { | ||||
|     close(); | ||||
|   } | ||||
| }); | ||||
| defineExpose({ open, close }); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <Teleport to="body"> | ||||
|     <dialog | ||||
|       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" | ||||
|     > | ||||
|       <OnClickOutside @trigger="close"> | ||||
|         <div | ||||
|           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 | ||||
|         > | ||||
|           <div class="flex flex-col gap-2"> | ||||
|           <h3 | ||||
|             class="text-base font-medium leading-6 text-gray-900 dark:text-white" | ||||
|           > | ||||
|             <h3 class="text-base font-medium leading-6 text-n-slate-12"> | ||||
|               {{ title }} | ||||
|             </h3> | ||||
|           <p | ||||
|             v-if="description" | ||||
|             class="mb-0 text-sm text-slate-500 dark:text-slate-400" | ||||
|           > | ||||
|             <slot name="description"> | ||||
|               <p v-if="description" class="mb-0 text-sm text-n-slate-11"> | ||||
|                 {{ description }} | ||||
|               </p> | ||||
|             </slot> | ||||
|           </div> | ||||
|           <slot name="form"> | ||||
|             <!-- Form content will be injected here --> | ||||
|           </slot> | ||||
|           <div class="flex items-center justify-between w-full gap-3"> | ||||
|             <Button | ||||
|             variant="secondary" | ||||
|               v-if="showCancelButton" | ||||
|               variant="ghost" | ||||
|               :label="cancelButtonLabel || t('DIALOG.BUTTONS.CANCEL')" | ||||
|             class="w-full" | ||||
|             size="sm" | ||||
|               class="w-full bg-n-alpha-2 hover:bg-n-alpha-3" | ||||
|               @click="close" | ||||
|             /> | ||||
|             <Button | ||||
|             v-if="type !== 'alert'" | ||||
|               v-if="showConfirmButton" | ||||
|               :variant="type === 'edit' ? 'default' : 'destructive'" | ||||
|               :label="confirmButtonLabel || t('DIALOG.BUTTONS.CONFIRM')" | ||||
|               class="w-full" | ||||
|             size="sm" | ||||
|               :is-loading="isLoading" | ||||
|               :disabled="disableConfirmButton || isLoading" | ||||
|               @click="confirm" | ||||
|             /> | ||||
|           </div> | ||||
|         </div> | ||||
|       </OnClickOutside> | ||||
|     </dialog> | ||||
|   </Teleport> | ||||
| </template> | ||||
|  | ||||
| <style scoped> | ||||
| dialog::backdrop { | ||||
|   @apply dark:bg-n-alpha-white bg-n-alpha-black2; | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -1,35 +1,57 @@ | ||||
| <script setup> | ||||
| import { defineProps, defineEmits } from 'vue'; | ||||
|  | ||||
| import Button from 'dashboard/components-next/button/Button.vue'; | ||||
| import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue'; | ||||
|  | ||||
| defineProps({ | ||||
|   menuItems: { | ||||
|     type: Array, | ||||
|     required: true, | ||||
|     validator: value => { | ||||
|       return value.every(item => item.action && item.value && item.label); | ||||
|     }, | ||||
|   }, | ||||
|   thumbnailSize: { | ||||
|     type: Number, | ||||
|     default: 20, | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const emit = defineEmits(['action']); | ||||
|  | ||||
| const handleAction = action => { | ||||
|   emit('action', action); | ||||
| const handleAction = (action, value) => { | ||||
|   emit('action', { action, value }); | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <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 | ||||
|       v-for="item in menuItems" | ||||
|       :key="item.action" | ||||
|       :label="item.label" | ||||
|       :icon="item.icon" | ||||
|       :emoji="item.emoji" | ||||
|       :disabled="item.disabled" | ||||
|       variant="ghost" | ||||
|       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' : ''" | ||||
|       @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> | ||||
| </template> | ||||
|   | ||||
| @@ -34,12 +34,16 @@ defineProps({ | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| defineEmits(['update:modelValue']); | ||||
| const emit = defineEmits(['update:modelValue', 'enterPress']); | ||||
|  | ||||
| const onEnterPress = () => { | ||||
|   emit('enterPress'); | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <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 | ||||
|       v-if="label" | ||||
| @@ -60,6 +64,7 @@ defineEmits(['update:modelValue']); | ||||
|       :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" | ||||
|       @input="$emit('update:modelValue', $event.target.value)" | ||||
|       @keydown.enter.prevent="onEnterPress" | ||||
|     /> | ||||
|   </div> | ||||
| </template> | ||||
|   | ||||
| @@ -39,25 +39,33 @@ const props = defineProps({ | ||||
|     validator: value => ['info', 'error', 'success'].includes(value), | ||||
|   }, | ||||
| }); | ||||
| defineEmits(['update:modelValue']); | ||||
|  | ||||
| const emit = defineEmits(['update:modelValue', 'blur', 'input']); | ||||
|  | ||||
| const messageClass = computed(() => { | ||||
|   switch (props.messageType) { | ||||
|     case 'error': | ||||
|       return 'text-red-500 dark:text-red-400'; | ||||
|       return 'text-n-ruby-9 dark:text-n-ruby-9'; | ||||
|     case 'success': | ||||
|       return 'text-green-500 dark:text-green-400'; | ||||
|     default: | ||||
|       return 'text-slate-500 dark:text-slate-400'; | ||||
|       return 'text-n-slate-11 dark:text-n-slate-11'; | ||||
|   } | ||||
| }); | ||||
|  | ||||
| const inputBorderClass = computed(() => { | ||||
|   switch (props.messageType) { | ||||
|     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: | ||||
|       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> | ||||
|  | ||||
| <template> | ||||
| @@ -78,12 +86,13 @@ const inputBorderClass = computed(() => { | ||||
|       :type="type" | ||||
|       :placeholder="placeholder" | ||||
|       :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" | ||||
|       @input="$emit('update:modelValue', $event.target.value)" | ||||
|       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="handleInput" | ||||
|       @blur="emit('blur')" | ||||
|     /> | ||||
|     <p | ||||
|       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" | ||||
|     > | ||||
|       {{ message }} | ||||
|   | ||||
| @@ -56,12 +56,10 @@ const pageInfo = computed(() => { | ||||
|  | ||||
| <template> | ||||
|   <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"> | ||||
|       <span | ||||
|         class="min-w-0 text-sm font-normal line-clamp-1 text-slate-600 dark:text-slate-300" | ||||
|       > | ||||
|       <span class="min-w-0 text-sm font-normal line-clamp-1 text-n-slate-11"> | ||||
|         {{ currentPageInformation }} | ||||
|       </span> | ||||
|     </div> | ||||
| @@ -82,12 +80,8 @@ const pageInfo = computed(() => { | ||||
|         :disabled="isFirstPage" | ||||
|         @click="changePage(currentPage - 1)" | ||||
|       /> | ||||
|       <div | ||||
|         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-white dark:bg-slate-900 rounded-md" | ||||
|         > | ||||
|       <div class="inline-flex items-center gap-2 text-sm text-n-slate-11"> | ||||
|         <span class="px-3 tabular-nums py-0.5 bg-n-alpha-black2 rounded-md"> | ||||
|           {{ currentPage }} | ||||
|         </span> | ||||
|         <span>{{ pageInfo }}</span> | ||||
|   | ||||
| @@ -281,29 +281,62 @@ const menuItems = computed(() => { | ||||
|       name: 'Portals', | ||||
|       label: t('SIDEBAR.HELP_CENTER.TITLE'), | ||||
|       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: [ | ||||
|         'all_locale_categories', | ||||
|         'default_portal_articles', | ||||
|         'edit_article', | ||||
|         'edit_category', | ||||
|         'edit_portal_customization', | ||||
|         'edit_portal_information', | ||||
|         'edit_portal_locales', | ||||
|         'list_all_locale_articles', | ||||
|         'list_all_locale_categories', | ||||
|         'list_all_portals', | ||||
|         'list_archived_articles', | ||||
|         'list_draft_articles', | ||||
|         'list_mine_articles', | ||||
|         'new_article', | ||||
|         'new_category_in_locale', | ||||
|         'new_portal_information', | ||||
|         'portalSlug', | ||||
|         'portal_customization', | ||||
|         'portal_finish', | ||||
|         'show_category', | ||||
|         'show_category_articles', | ||||
|             'portals_articles_index', | ||||
|             'portals_articles_new', | ||||
|             'portals_articles_edit', | ||||
|           ], | ||||
|           to: accountScopedRoute('portals_index', { | ||||
|             navigationPath: 'portals_articles_index', | ||||
|           }), | ||||
|         }, | ||||
|         { | ||||
|           name: 'Categories', | ||||
|           label: t('SIDEBAR.HELP_CENTER.CATEGORIES'), | ||||
|           activeOn: [ | ||||
|             'portals_categories_index', | ||||
|             'portals_categories_articles_index', | ||||
|             'portals_categories_articles_edit', | ||||
|           ], | ||||
|           to: accountScopedRoute('portals_index', { | ||||
|             navigationPath: 'portals_categories_index', | ||||
|           }), | ||||
|         }, | ||||
|         { | ||||
|           name: 'Locales', | ||||
|           label: t('SIDEBAR.HELP_CENTER.LOCALES'), | ||||
|           activeOn: ['portals_locales_index'], | ||||
|           to: accountScopedRoute('portals_index', { | ||||
|             navigationPath: 'portals_locales_index', | ||||
|           }), | ||||
|         }, | ||||
|         { | ||||
|           name: 'Settings', | ||||
|           label: t('SIDEBAR.HELP_CENTER.SETTINGS'), | ||||
|           activeOn: ['portals_settings_index'], | ||||
|           to: accountScopedRoute('portals_index', { | ||||
|             navigationPath: 'portals_settings_index', | ||||
|           }), | ||||
|         }, | ||||
|       ], | ||||
|       activeOn: [ | ||||
|         '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" | ||||
|   > | ||||
|     <section class="grid gap-2 mt-2 mb-4"> | ||||
|       <div class="flex gap-2 px-2 items-center min-w-0"> | ||||
|         <div class="size-6 grid place-content-center flex-shrink-0"> | ||||
|       <div class="flex items-center min-w-0 gap-2 px-2"> | ||||
|         <div class="grid flex-shrink-0 size-6 place-content-center"> | ||||
|           <Logo /> | ||||
|         </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 | ||||
|           class="-mx-1 flex-grow min-w-0" | ||||
|           class="flex-grow min-w-0 -mx-1" | ||||
|           @show-create-account-modal="emit('showCreateAccountModal')" | ||||
|         /> | ||||
|       </div> | ||||
|       <div class="gap-2 flex px-2"> | ||||
|       <div class="flex gap-2 px-2"> | ||||
|         <RouterLink | ||||
|           :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"> | ||||
|             {{ t('COMBOBOX.SEARCH_PLACEHOLDER') }} | ||||
|           </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 }} | ||||
|           </span> | ||||
|         </RouterLink> | ||||
|         <button | ||||
|           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 | ||||
|             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> | ||||
|       </div> | ||||
|     </section> | ||||
|     <nav class="grid gap-2 overflow-y-scroll no-scrollbar px-2 flex-grow pb-5"> | ||||
|       <ul class="flex flex-col gap-2 list-none m-0"> | ||||
|     <nav class="grid flex-grow gap-2 px-2 pb-5 overflow-y-scroll no-scrollbar"> | ||||
|       <ul class="flex flex-col gap-2 m-0 list-none"> | ||||
|         <SidebarGroup | ||||
|           v-for="item in menuItems" | ||||
|           :key="item.name" | ||||
| @@ -463,7 +496,7 @@ const menuItems = computed(() => { | ||||
|         @open-key-shortcut-modal="emit('openKeyShortcutModal')" | ||||
|       /> | ||||
|       <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 | ||||
|           @open-notification-panel="emit('openNotificationPanel')" | ||||
|         /> | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| <script setup> | ||||
| import { ref } from 'vue'; | ||||
| import { computed } from 'vue'; | ||||
| const props = defineProps({ | ||||
|   initialActiveTab: { | ||||
|     type: Number, | ||||
| @@ -10,17 +10,22 @@ const props = defineProps({ | ||||
|     required: true, | ||||
|     validator: value => { | ||||
|       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 activeTab = ref(props.initialActiveTab); | ||||
|  | ||||
| const activeTab = computed(() => props.initialActiveTab); | ||||
|  | ||||
| const selectTab = index => { | ||||
|   activeTab.value = index; | ||||
|   emit('tabChanged', props.tabs[index]); | ||||
| }; | ||||
|  | ||||
| const showDivider = index => { | ||||
|   return ( | ||||
|     // Show dividers after the active tab, but not after the last tab | ||||
| @@ -32,14 +37,14 @@ const showDivider = index => { | ||||
| </script> | ||||
|  | ||||
| <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"> | ||||
|       <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="[ | ||||
|           activeTab === index | ||||
|             ? 'text-woot-500 bg-woot-500/10 dark:bg-woot-500/10' | ||||
|             : 'text-slate-500 dark:text-slate-400 hover:text-woot-500 dark:hover:text-woot-400', | ||||
|             ? 'text-n-brand bg-n-solid-active font-medium' | ||||
|             : 'text-n-slate-10', | ||||
|         ]" | ||||
|         @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> | ||||
| import { computed } from 'vue'; | ||||
| import { computed, ref, onMounted, nextTick, watch } from 'vue'; | ||||
|  | ||||
| const props = defineProps({ | ||||
|   modelValue: { | ||||
|     type: String, | ||||
| @@ -29,13 +30,87 @@ const props = defineProps({ | ||||
|     type: String, | ||||
|     default: '', | ||||
|   }, | ||||
|   customTextAreaWrapperClass: { | ||||
|     type: String, | ||||
|     default: '', | ||||
|   }, | ||||
|   showCharacterCount: { | ||||
|     type: Boolean, | ||||
|     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); | ||||
|  | ||||
| // 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> | ||||
|  | ||||
| <template> | ||||
| @@ -47,23 +122,49 @@ const characterCount = computed(() => props.modelValue.length); | ||||
|     > | ||||
|       {{ 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 | ||||
|         :id="id" | ||||
|         ref="textareaRef" | ||||
|         :value="modelValue" | ||||
|         :placeholder="placeholder" | ||||
|       :maxlength="maxLength" | ||||
|       class="flex w-full reset-base text-sm h-24 px-3 pt-3 !mb-0 border rounded-lg focus:border-woot-500 dark:focus:border-woot-600 bg-white dark:bg-slate-900 placeholder:text-slate-200 dark:placeholder:text-slate-500 text-slate-900 dark:text-white transition-all duration-500 ease-in-out resize-none disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-slate-25 dark:disabled:bg-slate-900" | ||||
|       :class="[customTextAreaClass, showCharacterCount ? 'pb-9' : 'pb-3']" | ||||
|         :maxlength="showCharacterCount ? maxLength : undefined" | ||||
|         :class="[ | ||||
|           customTextAreaClass, | ||||
|           { | ||||
|             'resize-none': !resize, | ||||
|           }, | ||||
|         ]" | ||||
|         :style="{ | ||||
|           minHeight: autoHeight ? minHeight : undefined, | ||||
|           maxHeight: autoHeight ? maxHeight : undefined, | ||||
|         }" | ||||
|         :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 | ||||
|         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"> | ||||
|           {{ characterCount }} / {{ maxLength }} | ||||
|         </span> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </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', | ||||
|     featureFlag: FEATURE_FLAGS.HELP_CENTER, | ||||
|     alwaysVisibleOnChatwootInstances: true, | ||||
|     toState: frontendURL(`accounts/${accountId}/portals`), | ||||
|     toStateName: 'default_portal_articles', | ||||
|     toState: frontendURL(`accounts/${accountId}/portals/portal_articles_index`), | ||||
|     toStateName: 'portals_index', | ||||
|   }, | ||||
|   { | ||||
|     icon: 'settings', | ||||
|   | ||||
| @@ -82,6 +82,7 @@ export default { | ||||
|       /> | ||||
|       <PrimaryNavItem | ||||
|         v-for="menuItem in menuItems" | ||||
|         :id="menuItem.key" | ||||
|         :key="menuItem.toState" | ||||
|         :icon="menuItem.icon" | ||||
|         :name="menuItem.label" | ||||
| @@ -94,7 +95,7 @@ export default { | ||||
|         v-if="!isACustomBrandedInstance" | ||||
|         v-tooltip.right="$t(`SIDEBAR.DOCS`)" | ||||
|         :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" | ||||
|         target="_blank" | ||||
|       > | ||||
|   | ||||
| @@ -1,10 +1,23 @@ | ||||
| <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 { | ||||
|   components: { | ||||
|     DropdownMenu, | ||||
|     OnClickOutside, | ||||
|   }, | ||||
|   props: { | ||||
|     to: { | ||||
|       type: String, | ||||
|       default: '', | ||||
|     }, | ||||
|     id: { | ||||
|       type: String, | ||||
|       default: '', | ||||
|     }, | ||||
|     name: { | ||||
|       type: String, | ||||
|       default: '', | ||||
| @@ -26,15 +39,89 @@ export default { | ||||
|       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> | ||||
|  | ||||
| <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 | ||||
|       v-tooltip.right="$t(`SIDEBAR.${name}`)" | ||||
|       :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="{ | ||||
|         'bg-woot-50 dark:bg-slate-800 text-woot-500 hover:bg-woot-50': | ||||
|           isActive || isChildMenuActive, | ||||
| @@ -52,7 +139,7 @@ export default { | ||||
|       <span class="sr-only">{{ name }}</span> | ||||
|       <span | ||||
|         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 }} | ||||
|       </span> | ||||
|   | ||||
| @@ -46,6 +46,10 @@ export default { | ||||
|     editorId: { type: String, default: '' }, | ||||
|     placeholder: { type: String, default: '' }, | ||||
|     enabledMenuOptions: { type: Array, default: () => [] }, | ||||
|     autofocus: { | ||||
|       type: Boolean, | ||||
|       default: true, | ||||
|     }, | ||||
|   }, | ||||
|   emits: ['blur', 'input', 'update:modelValue', 'keyup', 'focus', 'keydown'], | ||||
|   setup() { | ||||
| @@ -86,7 +90,9 @@ export default { | ||||
|     this.createEditorView(); | ||||
|  | ||||
|     editorView.updateState(state); | ||||
|     if (this.autofocus) { | ||||
|       this.focusEditorInputField(); | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     contentFromEditor() { | ||||
|   | ||||
| @@ -117,3 +117,11 @@ export const timeStampAppendedURL = dataUrl => { | ||||
|  | ||||
|   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); | ||||
|   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, | ||||
|   hasValidAvatarUrl, | ||||
|   timeStampAppendedURL, | ||||
|   getHostNameFromURL, | ||||
| } from '../URLHelper'; | ||||
|  | ||||
| describe('#URL Helpers', () => { | ||||
| @@ -238,4 +239,28 @@ describe('#URL Helpers', () => { | ||||
|       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": { | ||||
|     "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": { | ||||
|       "FILTER": "Filter by", | ||||
|       "SORT": "Sort by", | ||||
| @@ -343,6 +348,12 @@ | ||||
|         "SUCCESS": "Article archived successfully" | ||||
|       } | ||||
|     }, | ||||
|     "DRAFT_ARTICLE": { | ||||
|       "API": { | ||||
|         "ERROR": "Error while drafting article", | ||||
|         "SUCCESS": "Article drafted successfully" | ||||
|       } | ||||
|     }, | ||||
|     "DELETE_ARTICLE": { | ||||
|       "MODAL": { | ||||
|         "CONFIRM": { | ||||
| @@ -478,9 +489,304 @@ | ||||
|       } | ||||
|     }, | ||||
|     "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_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", | ||||
|     "HELP_CENTER": { | ||||
|       "TITLE": "Help Center", | ||||
|       "ALL_ARTICLES": "All Articles", | ||||
|       "MY_ARTICLES": "My Articles", | ||||
|       "DRAFT": "Draft", | ||||
|       "ARCHIVED": "Archived", | ||||
|       "CATEGORY": "Category", | ||||
|       "SETTINGS": "Settings", | ||||
|       "CATEGORY_EMPTY_MESSAGE": "No categories found" | ||||
|       "ARTICLES": "Articles", | ||||
|       "CATEGORIES": "Categories", | ||||
|       "LOCALES": "Locales", | ||||
|       "SETTINGS": "Settings" | ||||
|     }, | ||||
|     "CHANNELS": "Channels", | ||||
|     "SET_AUTO_OFFLINE": { | ||||
|   | ||||
| @@ -15,7 +15,6 @@ const Suspended = () => import('./suspended/Index.vue'); | ||||
|  | ||||
| export default { | ||||
|   routes: [ | ||||
|     ...helpcenterRoutes.routes, | ||||
|     { | ||||
|       path: frontendURL('accounts/:accountId'), | ||||
|       component: AppContainer, | ||||
| @@ -35,6 +34,7 @@ export default { | ||||
|         ...contactRoutes, | ||||
|         ...searchRoutes, | ||||
|         ...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> | ||||
| import { debounce } from '@chatwoot/utils'; | ||||
| import { useAlert } from 'dashboard/composables'; | ||||
| import allLocales from 'shared/constants/locales.js'; | ||||
|  | ||||
| import SearchHeader from './Header.vue'; | ||||
| import SearchResults from './SearchResults.vue'; | ||||
| import ArticleView from './ArticleView.vue'; | ||||
| import ArticlesAPI from 'dashboard/api/helpCenter/articles'; | ||||
| import { buildPortalArticleURL } from 'dashboard/helper/portalHelper'; | ||||
| import portalMixin from '../../mixins/portalMixin'; | ||||
|  | ||||
| export default { | ||||
|   name: 'ArticleSearchPopover', | ||||
| @@ -16,7 +16,6 @@ export default { | ||||
|     SearchResults, | ||||
|     ArticleView, | ||||
|   }, | ||||
|   mixins: [portalMixin], | ||||
|   props: { | ||||
|     selectedPortalSlug: { | ||||
|       type: String, | ||||
| @@ -69,6 +68,9 @@ export default { | ||||
|         article.slug | ||||
|       ); | ||||
|     }, | ||||
|     localeName(code) { | ||||
|       return allLocales[code]; | ||||
|     }, | ||||
|     activeArticle(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'; | ||||
|  | ||||
| const ListAllPortals = () => import('./pages/portals/ListAllPortals.vue'); | ||||
| const NewPortal = () => import('./pages/portals/NewPortal.vue'); | ||||
| import HelpCenterPageRouteView from './pages/HelpCenterPageRouteView.vue'; | ||||
|  | ||||
| const EditPortal = () => import('./pages/portals/EditPortal.vue'); | ||||
| const EditPortalBasic = () => import('./pages/portals/EditPortalBasic.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 PortalsIndex = () => import('./pages/PortalsIndexPage.vue'); | ||||
| const PortalsNew = () => import('./pages/PortalsNewPage.vue'); | ||||
|  | ||||
| const ListAllCategories = () => | ||||
|   import('./pages/categories/ListAllCategories.vue'); | ||||
| const NewCategory = () => import('./pages/categories/NewCategory.vue'); | ||||
| const EditCategory = () => import('./pages/categories/EditCategory.vue'); | ||||
| const ListCategoryArticles = () => | ||||
|   import('./pages/articles/ListCategoryArticles.vue'); | ||||
| const ListAllArticles = () => import('./pages/articles/ListAllArticles.vue'); | ||||
| const DefaultPortalArticles = () => | ||||
|   import('./pages/articles/DefaultPortalArticles.vue'); | ||||
| const NewArticle = () => import('./pages/articles/NewArticle.vue'); | ||||
| const EditArticle = () => import('./pages/articles/EditArticle.vue'); | ||||
| const PortalsArticlesIndexPage = () => | ||||
|   import('./pages/PortalsArticlesIndexPage.vue'); | ||||
| const PortalsArticlesNewPage = () => | ||||
|   import('./pages/PortalsArticlesNewPage.vue'); | ||||
| const PortalsArticlesEditPage = () => | ||||
|   import('./pages/PortalsArticlesEditPage.vue'); | ||||
|  | ||||
| const PortalsCategoriesIndexPage = () => | ||||
|   import('./pages/PortalsCategoriesIndexPage.vue'); | ||||
|  | ||||
| const PortalsLocalesIndexPage = () => | ||||
|   import('./pages/PortalsLocalesIndexPage.vue'); | ||||
|  | ||||
| const PortalsSettingsIndexPage = () => | ||||
|   import('./pages/PortalsSettingsIndexPage.vue'); | ||||
|  | ||||
| const portalRoutes = [ | ||||
|   { | ||||
|     path: getPortalRoute(''), | ||||
|     name: 'default_portal_articles', | ||||
|     meta: { | ||||
|       permissions: ['administrator', 'knowledge_base_manage'], | ||||
|     }, | ||||
|     component: DefaultPortalArticles, | ||||
|   }, | ||||
|   { | ||||
|     path: getPortalRoute('all'), | ||||
|     name: 'list_all_portals', | ||||
|     path: getPortalRoute(':portalSlug/:locale/:categorySlug?/articles/:tab?'), | ||||
|     name: 'portals_articles_index', | ||||
|     meta: { | ||||
|       permissions: ['administrator', 'agent', 'knowledge_base_manage'], | ||||
|     }, | ||||
|     component: ListAllPortals, | ||||
|   }, | ||||
|   { | ||||
|     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, | ||||
|     component: PortalsArticlesIndexPage, | ||||
|   }, | ||||
|   { | ||||
|     path: getPortalRoute(':portalSlug/:locale/articles/new'), | ||||
|     name: 'new_article', | ||||
|     name: 'portals_articles_new', | ||||
|     meta: { | ||||
|       permissions: ['administrator', 'agent', 'knowledge_base_manage'], | ||||
|     }, | ||||
|     component: NewArticle, | ||||
|     component: PortalsArticlesNewPage, | ||||
|   }, | ||||
|   { | ||||
|     path: getPortalRoute(':portalSlug/:locale/articles/mine'), | ||||
|     name: 'list_mine_articles', | ||||
|     path: getPortalRoute( | ||||
|       ':portalSlug/:locale/:categorySlug?/articles/:tab?/edit/:articleSlug' | ||||
|     ), | ||||
|     name: 'portals_articles_edit', | ||||
|     meta: { | ||||
|       permissions: ['administrator', 'agent', 'knowledge_base_manage'], | ||||
|     }, | ||||
|     component: ListAllArticles, | ||||
|   }, | ||||
|   { | ||||
|     path: getPortalRoute(':portalSlug/:locale/articles/archived'), | ||||
|     name: 'list_archived_articles', | ||||
|     meta: { | ||||
|       permissions: ['administrator', 'agent', 'knowledge_base_manage'], | ||||
|     }, | ||||
|     component: ListAllArticles, | ||||
|     component: PortalsArticlesEditPage, | ||||
|   }, | ||||
|  | ||||
|   { | ||||
|     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'), | ||||
|     name: 'all_locale_categories', | ||||
|     name: 'portals_categories_index', | ||||
|     meta: { | ||||
|       permissions: ['administrator', 'agent', 'knowledge_base_manage'], | ||||
|     }, | ||||
|     component: ListAllCategories, | ||||
|   }, | ||||
|   { | ||||
|     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, | ||||
|     component: PortalsCategoriesIndexPage, | ||||
|   }, | ||||
|   { | ||||
|     path: getPortalRoute( | ||||
|       ':portalSlug/:locale/categories/:categorySlug/articles' | ||||
|     ), | ||||
|     name: 'show_category_articles', | ||||
|     name: 'portals_categories_articles_index', | ||||
|     meta: { | ||||
|       permissions: ['administrator', 'agent', 'knowledge_base_manage'], | ||||
|     }, | ||||
|     component: ListCategoryArticles, | ||||
|     component: PortalsArticlesIndexPage, | ||||
|   }, | ||||
|   { | ||||
|     path: getPortalRoute(':portalSlug/:locale/categories/:categorySlug'), | ||||
|     name: 'edit_category', | ||||
|     path: getPortalRoute( | ||||
|       ':portalSlug/:locale/categories/:categorySlug/articles/:articleSlug' | ||||
|     ), | ||||
|     name: 'portals_categories_articles_edit', | ||||
|     meta: { | ||||
|       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: [ | ||||
|     { | ||||
|       path: getPortalRoute(), | ||||
|       component: HelpCenterLayout, | ||||
|       children: [...portalRoutes, ...articleRoutes, ...categoryRoutes], | ||||
|       component: HelpCenterPageRouteView, | ||||
|       children: [...portalRoutes], | ||||
|     }, | ||||
|   ], | ||||
| }; | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { frontendURL } from '../../../../helper/URLHelper'; | ||||
| import { frontendURL } from 'dashboard/helper/URLHelper'; | ||||
|  | ||||
| export const getPortalRoute = (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
	 Sivin Varghese
					Sivin Varghese