mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-31 11:08:04 +00:00 
			
		
		
		
	feat(v4): Update the help center portal design (#10296)
Co-authored-by: Pranav <pranavrajs@gmail.com>
This commit is contained in:
		| @@ -6,13 +6,15 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController | |||||||
|  |  | ||||||
|   def index |   def index | ||||||
|     @portal_articles = @portal.articles |     @portal_articles = @portal.articles | ||||||
|     @all_articles = @portal_articles.search(list_params) |  | ||||||
|     @articles_count = @all_articles.count |     set_article_count | ||||||
|  |  | ||||||
|  |     @articles = @articles.search(list_params) | ||||||
|  |  | ||||||
|     @articles = if list_params[:category_slug].present? |     @articles = if list_params[:category_slug].present? | ||||||
|                   @all_articles.order_by_position.page(@current_page) |                   @articles.order_by_position.page(@current_page) | ||||||
|                 else |                 else | ||||||
|                   @all_articles.order_by_updated_at.page(@current_page) |                   @articles.order_by_updated_at.page(@current_page) | ||||||
|                 end |                 end | ||||||
|   end |   end | ||||||
|  |  | ||||||
| @@ -43,6 +45,19 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController | |||||||
|  |  | ||||||
|   private |   private | ||||||
|  |  | ||||||
|  |   def set_article_count | ||||||
|  |     # Search the params without status and author_id, use this to | ||||||
|  |     # compute mine count published draft etc | ||||||
|  |     base_search_params = list_params.except(:status, :author_id) | ||||||
|  |     @articles = @portal_articles.search(base_search_params) | ||||||
|  |  | ||||||
|  |     @articles_count = @articles.count | ||||||
|  |     @mine_articles_count = @articles.search_by_author(Current.user.id).count | ||||||
|  |     @published_articles_count = @articles.published.count | ||||||
|  |     @draft_articles_count = @articles.draft.count | ||||||
|  |     @archived_articles_count = @articles.archived.count | ||||||
|  |   end | ||||||
|  |  | ||||||
|   def fetch_article |   def fetch_article | ||||||
|     @article = @portal.articles.find(params[:id]) |     @article = @portal.articles.find(params[:id]) | ||||||
|   end |   end | ||||||
| @@ -53,9 +68,10 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController | |||||||
|  |  | ||||||
|   def article_params |   def article_params | ||||||
|     params.require(:article).permit( |     params.require(:article).permit( | ||||||
|       :title, :slug, :position, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status, meta: [:title, |       :title, :slug, :position, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status, | ||||||
|                                                                                                                                      :description, |       :locale, meta: [:title, | ||||||
|                                                                                                                                      { tags: [] }] |                       :description, | ||||||
|  |                       { tags: [] }] | ||||||
|     ) |     ) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   | |||||||
| @@ -20,7 +20,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController | |||||||
|   end |   end | ||||||
|  |  | ||||||
|   def create |   def create | ||||||
|     @portal = Current.account.portals.build(portal_params) |     @portal = Current.account.portals.build(portal_params.merge(live_chat_widget_params)) | ||||||
|     @portal.custom_domain = parsed_custom_domain |     @portal.custom_domain = parsed_custom_domain | ||||||
|     @portal.save! |     @portal.save! | ||||||
|     process_attached_logo |     process_attached_logo | ||||||
| @@ -28,7 +28,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController | |||||||
|  |  | ||||||
|   def update |   def update | ||||||
|     ActiveRecord::Base.transaction do |     ActiveRecord::Base.transaction do | ||||||
|       @portal.update!(portal_params) if params[:portal].present? |       @portal.update!(portal_params.merge(live_chat_widget_params)) if params[:portal].present? | ||||||
|       # @portal.custom_domain = parsed_custom_domain |       # @portal.custom_domain = parsed_custom_domain | ||||||
|       process_attached_logo if params[:blob_id].present? |       process_attached_logo if params[:blob_id].present? | ||||||
|     rescue StandardError => e |     rescue StandardError => e | ||||||
| @@ -70,11 +70,21 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController | |||||||
|  |  | ||||||
|   def portal_params |   def portal_params | ||||||
|     params.require(:portal).permit( |     params.require(:portal).permit( | ||||||
|       :account_id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug, :archived, { config: [:default_locale, |       :account_id, :color, :custom_domain, :header_text, :homepage_link, | ||||||
|                                                                                                                           { allowed_locales: [] }] } |       :name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] } | ||||||
|     ) |     ) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   def live_chat_widget_params | ||||||
|  |     permitted_params = params.permit(:inbox_id) | ||||||
|  |     return {} if permitted_params[:inbox_id].blank? | ||||||
|  |  | ||||||
|  |     inbox = Inbox.find(permitted_params[:inbox_id]) | ||||||
|  |     return {} unless inbox.web_widget? | ||||||
|  |  | ||||||
|  |     { channel_web_widget_id: inbox.channel.id } | ||||||
|  |   end | ||||||
|  |  | ||||||
|   def portal_member_params |   def portal_member_params | ||||||
|     params.require(:portal).permit(:account_id, member_ids: []) |     params.require(:portal).permit(:account_id, member_ids: []) | ||||||
|   end |   end | ||||||
|   | |||||||
| @@ -30,7 +30,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B | |||||||
|  |  | ||||||
|   def set_article |   def set_article | ||||||
|     @article = @portal.articles.find_by(slug: permitted_params[:article_slug]) |     @article = @portal.articles.find_by(slug: permitted_params[:article_slug]) | ||||||
|     @article.increment_view_count |     @article.increment_view_count if @article.published? | ||||||
|     @parsed_content = render_article_content(@article.content) |     @parsed_content = render_article_content(@article.content) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   | |||||||
| @@ -52,12 +52,13 @@ class ArticlesAPI extends PortalsAPI { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   createArticle({ portalSlug, articleObj }) { |   createArticle({ portalSlug, articleObj }) { | ||||||
|     const { content, title, author_id, category_id } = articleObj; |     const { content, title, authorId, categoryId, locale } = articleObj; | ||||||
|     return axios.post(`${this.url}/${portalSlug}/articles`, { |     return axios.post(`${this.url}/${portalSlug}/articles`, { | ||||||
|       content, |       content, | ||||||
|       title, |       title, | ||||||
|       author_id, |       author_id: authorId, | ||||||
|       category_id, |       category_id: categoryId, | ||||||
|  |       locale, | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -7,7 +7,7 @@ const handleClick = () => { | |||||||
|  |  | ||||||
| <template> | <template> | ||||||
|   <div |   <div | ||||||
|     class="relative flex flex-col w-full gap-3 px-6 py-5 group/cardLayout rounded-2xl bg-slate-25 dark:bg-slate-800/50" |     class="relative flex flex-col w-full gap-3 px-6 py-5 shadow-sm group/cardLayout rounded-2xl bg-n-solid-1" | ||||||
|     @click="handleClick" |     @click="handleClick" | ||||||
|   > |   > | ||||||
|     <slot name="header" /> |     <slot name="header" /> | ||||||
|   | |||||||
| @@ -13,7 +13,7 @@ defineProps({ | |||||||
|  |  | ||||||
| <template> | <template> | ||||||
|   <section |   <section | ||||||
|     class="relative flex flex-col items-center justify-center w-full h-full min-h-screen p-4 overflow-hidden" |     class="relative flex flex-col items-center justify-center w-full h-full overflow-hidden" | ||||||
|   > |   > | ||||||
|     <div |     <div | ||||||
|       class="relative w-full max-w-[940px] mx-auto overflow-hidden h-full max-h-[448px]" |       class="relative w-full max-w-[940px] mx-auto overflow-hidden h-full max-h-[448px]" | ||||||
| @@ -24,17 +24,17 @@ defineProps({ | |||||||
|         <slot name="empty-state-item" /> |         <slot name="empty-state-item" /> | ||||||
|       </div> |       </div> | ||||||
|       <div |       <div | ||||||
|         class="absolute inset-x-0 bottom-0 flex flex-col items-center justify-end w-full h-full pb-9 bg-gradient-to-t from-white dark:from-slate-900 to-transparent font-interDisplay" |         class="absolute inset-x-0 bottom-0 flex flex-col items-center justify-end w-full h-full pb-20 bg-gradient-to-t from-n-background from-25% dark:from-n-background to-transparent" | ||||||
|       > |       > | ||||||
|         <div class="flex flex-col items-center justify-center gap-6"> |         <div class="flex flex-col items-center justify-center gap-6"> | ||||||
|           <div class="flex flex-col items-center justify-center gap-2"> |           <div class="flex flex-col items-center justify-center gap-3"> | ||||||
|             <h2 |             <h2 | ||||||
|               class="text-3xl font-medium text-center text-slate-900 dark:text-white" |               class="text-3xl font-medium text-center text-slate-900 dark:text-white font-interDisplay" | ||||||
|             > |             > | ||||||
|               {{ title }} |               {{ title }} | ||||||
|             </h2> |             </h2> | ||||||
|             <p |             <p | ||||||
|               class="max-w-lg text-base text-center text-slate-600 dark:text-slate-300" |               class="max-w-lg text-base text-center text-slate-600 dark:text-slate-300 font-interDisplay tracking-[0.3px]" | ||||||
|             > |             > | ||||||
|               {{ subtitle }} |               {{ subtitle }} | ||||||
|             </p> |             </p> | ||||||
|   | |||||||
| @@ -3,30 +3,60 @@ import ArticleCard from './ArticleCard.vue'; | |||||||
|  |  | ||||||
| const articles = [ | const articles = [ | ||||||
|   { |   { | ||||||
|  |     id: 1, | ||||||
|     title: "How to get an SSL certificate for your Help Center's custom domain", |     title: "How to get an SSL certificate for your Help Center's custom domain", | ||||||
|     status: 'draft', |     status: 'draft', | ||||||
|     updatedAt: '2 days ago', |     updatedAt: 1729048936, | ||||||
|     author: 'Michael', |     author: { | ||||||
|     category: '⚡️ Marketing', |       name: 'John', | ||||||
|  |       thumbnail: 'https://i.pravatar.cc/300', | ||||||
|  |     }, | ||||||
|  |     category: { | ||||||
|  |       title: 'Marketing', | ||||||
|  |       slug: 'marketing', | ||||||
|  |       icon: '📈', | ||||||
|  |     }, | ||||||
|     views: 400, |     views: 400, | ||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|  |     id: 2, | ||||||
|     title: 'Setting up your first Help Center portal', |     title: 'Setting up your first Help Center portal', | ||||||
|     status: '', |     status: '', | ||||||
|     updatedAt: '1 week ago', |     updatedAt: 1729048936, | ||||||
|     author: 'John', |     author: { | ||||||
|     category: '🛠️ Development', |       name: 'John', | ||||||
|  |       thumbnail: 'https://i.pravatar.cc/300', | ||||||
|  |     }, | ||||||
|  |     category: { | ||||||
|  |       title: 'Development', | ||||||
|  |       slug: 'development', | ||||||
|  |       icon: '🛠️', | ||||||
|  |     }, | ||||||
|     views: 1400, |     views: 1400, | ||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|  |     id: 3, | ||||||
|     title: 'Best practices for organizing your Help Center content', |     title: 'Best practices for organizing your Help Center content', | ||||||
|     status: 'archived', |     status: 'archived', | ||||||
|     updatedAt: '3 days ago', |     updatedAt: 1729048936, | ||||||
|     author: 'Fernando', |     author: { | ||||||
|     category: '💰 Finance', |       name: 'Fernando', | ||||||
|  |       thumbnail: 'https://i.pravatar.cc/300', | ||||||
|  |     }, | ||||||
|  |     category: { | ||||||
|  |       title: 'Finance', | ||||||
|  |       slug: 'finance', | ||||||
|  |       icon: '💰', | ||||||
|  |     }, | ||||||
|     views: 4300, |     views: 4300, | ||||||
|   }, |   }, | ||||||
| ]; | ]; | ||||||
|  |  | ||||||
|  | const category = { | ||||||
|  |   name: 'Marketing', | ||||||
|  |   slug: 'marketing', | ||||||
|  |   icon: '📈', | ||||||
|  | }; | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <!-- eslint-disable vue/no-bare-strings-in-template --> | <!-- eslint-disable vue/no-bare-strings-in-template --> | ||||||
| @@ -43,10 +73,11 @@ const articles = [ | |||||||
|         class="px-20 py-4 bg-white dark:bg-slate-900" |         class="px-20 py-4 bg-white dark:bg-slate-900" | ||||||
|       > |       > | ||||||
|         <ArticleCard |         <ArticleCard | ||||||
|  |           :id="article.id" | ||||||
|           :title="article.title" |           :title="article.title" | ||||||
|           :status="article.status" |           :status="article.status" | ||||||
|           :author="article.author" |           :author="article.author" | ||||||
|           :category="article.category" |           :category="category" | ||||||
|           :views="article.views" |           :views="article.views" | ||||||
|           :updated-at="article.updatedAt" |           :updated-at="article.updatedAt" | ||||||
|         /> |         /> | ||||||
|   | |||||||
| @@ -1,13 +1,25 @@ | |||||||
| <script setup> | <script setup> | ||||||
| import { computed, ref } from 'vue'; | import { computed, ref } from 'vue'; | ||||||
| import { OnClickOutside } from '@vueuse/components'; | import { OnClickOutside } from '@vueuse/components'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  | import { dynamicTime } from 'shared/helpers/timeHelper'; | ||||||
|  | import { | ||||||
|  |   ARTICLE_MENU_ITEMS, | ||||||
|  |   ARTICLE_MENU_OPTIONS, | ||||||
|  |   ARTICLE_STATUSES, | ||||||
|  | } from 'dashboard/helper/portalHelper'; | ||||||
|  |  | ||||||
| import CardLayout from 'dashboard/components-next/CardLayout.vue'; | import CardLayout from 'dashboard/components-next/CardLayout.vue'; | ||||||
| import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; | import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; | ||||||
| import Button from 'dashboard/components-next/button/Button.vue'; | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
|  | import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue'; | ||||||
| import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue'; | import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue'; | ||||||
|  |  | ||||||
| const props = defineProps({ | const props = defineProps({ | ||||||
|  |   id: { | ||||||
|  |     type: Number, | ||||||
|  |     required: true, | ||||||
|  |   }, | ||||||
|   title: { |   title: { | ||||||
|     type: String, |     type: String, | ||||||
|     required: true, |     required: true, | ||||||
| @@ -17,11 +29,11 @@ const props = defineProps({ | |||||||
|     required: true, |     required: true, | ||||||
|   }, |   }, | ||||||
|   author: { |   author: { | ||||||
|     type: String, |     type: Object, | ||||||
|     required: true, |     required: true, | ||||||
|   }, |   }, | ||||||
|   category: { |   category: { | ||||||
|     type: String, |     type: Object, | ||||||
|     required: true, |     required: true, | ||||||
|   }, |   }, | ||||||
|   views: { |   views: { | ||||||
| @@ -29,84 +41,112 @@ const props = defineProps({ | |||||||
|     required: true, |     required: true, | ||||||
|   }, |   }, | ||||||
|   updatedAt: { |   updatedAt: { | ||||||
|     type: String, |     type: Number, | ||||||
|     required: true, |     required: true, | ||||||
|   }, |   }, | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['openArticle', 'articleAction']); | ||||||
|  |  | ||||||
|  | const { t } = useI18n(); | ||||||
|  |  | ||||||
| const isOpen = ref(false); | const isOpen = ref(false); | ||||||
|  |  | ||||||
| const menuItems = computed(() => { | const articleMenuItems = computed(() => { | ||||||
|   const baseItems = [{ label: 'Delete', action: 'delete', icon: 'delete' }]; |   const commonItems = Object.entries(ARTICLE_MENU_ITEMS).reduce( | ||||||
|   const menuOptions = { |     (acc, [key, item]) => { | ||||||
|     archived: [ |       acc[key] = { ...item, label: t(item.label) }; | ||||||
|       { label: 'Publish', action: 'publish', icon: 'checkmark' }, |       return acc; | ||||||
|       { label: 'Draft', action: 'draft', icon: 'draft' }, |     }, | ||||||
|     ], |     {} | ||||||
|     draft: [ |   ); | ||||||
|       { label: 'Publish', action: 'publish', icon: 'checkmark' }, |  | ||||||
|       { label: 'Archive', action: 'archive', icon: 'archive' }, |   const statusItems = ( | ||||||
|     ], |     ARTICLE_MENU_OPTIONS[props.status] || | ||||||
|     '': [ |     ARTICLE_MENU_OPTIONS[ARTICLE_STATUSES.PUBLISHED] | ||||||
|       // Empty string represents published status |   ).map(key => commonItems[key]); | ||||||
|       { label: 'Draft', action: 'draft', icon: 'draft' }, |  | ||||||
|       { label: 'Archive', action: 'archive', icon: 'archive' }, |   return [...statusItems, commonItems.delete]; | ||||||
|     ], |  | ||||||
|   }; |  | ||||||
|   return [...(menuOptions[props.status] || menuOptions['']), ...baseItems]; |  | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const statusTextColor = computed(() => { | const statusTextColor = computed(() => { | ||||||
|   switch (props.status) { |   switch (props.status) { | ||||||
|     case 'archived': |     case 'archived': | ||||||
|       return '!text-slate-600 dark:!text-slate-200'; |       return '!text-n-slate-12'; | ||||||
|     case 'draft': |     case 'draft': | ||||||
|       return '!text-amber-700 dark:!text-amber-400'; |       return '!text-n-amber-11'; | ||||||
|     default: |     default: | ||||||
|       return '!text-teal-700 dark:!text-teal-400'; |       return '!text-n-teal-11'; | ||||||
|   } |   } | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const statusText = computed(() => { | const statusText = computed(() => { | ||||||
|   switch (props.status) { |   switch (props.status) { | ||||||
|     case 'archived': |     case 'archived': | ||||||
|       return 'Archived'; |       return t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.STATUS.ARCHIVED'); | ||||||
|     case 'draft': |     case 'draft': | ||||||
|       return 'Draft'; |       return t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.STATUS.DRAFT'); | ||||||
|     default: |     default: | ||||||
|       return 'Published'; |       return t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.STATUS.PUBLISHED'); | ||||||
|   } |   } | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const handleAction = () => { | const categoryName = computed(() => { | ||||||
|  |   if (props.category?.slug) { | ||||||
|  |     return `${props.category.icon} ${props.category.name}`; | ||||||
|  |   } | ||||||
|  |   return t( | ||||||
|  |     'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.CATEGORY.UNCATEGORISED' | ||||||
|  |   ); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const authorName = computed(() => { | ||||||
|  |   return props.author?.name || props.author?.availableName || '-'; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const authorThumbnailSrc = computed(() => { | ||||||
|  |   return props.author?.thumbnail; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const lastUpdatedAt = computed(() => { | ||||||
|  |   return dynamicTime(props.updatedAt); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const handleArticleAction = ({ action, value }) => { | ||||||
|   isOpen.value = false; |   isOpen.value = false; | ||||||
|  |   emit('articleAction', { action, value, id: props.id }); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleClick = id => { | ||||||
|  |   emit('openArticle', id); | ||||||
| }; | }; | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <!-- TODO: Add i18n --> |  | ||||||
| <!-- eslint-disable vue/no-bare-strings-in-template --> |  | ||||||
| <template> | <template> | ||||||
|   <CardLayout> |   <CardLayout> | ||||||
|     <template #header> |     <template #header> | ||||||
|       <div class="flex justify-between gap-1"> |       <div class="flex justify-between gap-1"> | ||||||
|         <span class="text-base text-slate-900 dark:text-slate-50 line-clamp-1"> |         <span | ||||||
|  |           class="text-base cursor-pointer hover:underline text-n-slate-12 line-clamp-1" | ||||||
|  |           @click="handleClick(id)" | ||||||
|  |         > | ||||||
|           {{ title }} |           {{ title }} | ||||||
|         </span> |         </span> | ||||||
|         <div class="relative group"> |         <div class="relative group" @click.stop> | ||||||
|           <Button |  | ||||||
|             variant="ghost" |  | ||||||
|             size="sm" |  | ||||||
|             class="text-xs bg-slate-50 !font-normal group-hover:bg-slate-100/50 dark:group-hover:bg-slate-700/50 !h-6 dark:bg-slate-800 rounded-md border-0 !px-2 !py-0.5" |  | ||||||
|             :label="statusText" |  | ||||||
|             :class="statusTextColor" |  | ||||||
|             @click="isOpen = !isOpen" |  | ||||||
|           /> |  | ||||||
|           <OnClickOutside @trigger="isOpen = false"> |           <OnClickOutside @trigger="isOpen = false"> | ||||||
|  |             <Button | ||||||
|  |               variant="ghost" | ||||||
|  |               size="sm" | ||||||
|  |               class="text-xs font-medium bg-n-alpha-2 hover:bg-n-alpha-1 !h-6 rounded-md border-0 !px-2 !py-0.5" | ||||||
|  |               :label="statusText" | ||||||
|  |               :class="statusTextColor" | ||||||
|  |               @click="isOpen = !isOpen" | ||||||
|  |             /> | ||||||
|             <DropdownMenu |             <DropdownMenu | ||||||
|               v-if="isOpen" |               v-if="isOpen" | ||||||
|               :menu-items="menuItems" |               :menu-items="articleMenuItems" | ||||||
|               class="right-0 mt-2 xl:left-0 top-full" |               class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 top-full" | ||||||
|               @action="handleAction" |               @action="handleArticleAction($event)" | ||||||
|             /> |             /> | ||||||
|           </OnClickOutside> |           </OnClickOutside> | ||||||
|         </div> |         </div> | ||||||
| @@ -116,25 +156,34 @@ const handleAction = () => { | |||||||
|       <div class="flex items-center justify-between gap-4"> |       <div class="flex items-center justify-between gap-4"> | ||||||
|         <div class="flex items-center gap-4"> |         <div class="flex items-center gap-4"> | ||||||
|           <div class="flex items-center gap-1"> |           <div class="flex items-center gap-1"> | ||||||
|             <div class="w-4 h-4 rounded-full bg-slate-100 dark:bg-slate-700" /> |             <Thumbnail | ||||||
|             <span class="text-sm text-slate-500 dark:text-slate-400"> |               v-if="author" | ||||||
|               {{ author }} |               :author="author" | ||||||
|  |               :name="authorName" | ||||||
|  |               :src="authorThumbnailSrc" | ||||||
|  |             /> | ||||||
|  |             <span class="text-sm text-n-slate-11"> | ||||||
|  |               {{ authorName }} | ||||||
|             </span> |             </span> | ||||||
|           </div> |           </div> | ||||||
|           <span |           <span class="block text-sm whitespace-nowrap text-n-slate-11"> | ||||||
|             class="block text-sm whitespace-nowrap text-slate-500 dark:text-slate-400" |             {{ categoryName }} | ||||||
|           > |  | ||||||
|             {{ category }} |  | ||||||
|           </span> |           </span> | ||||||
|           <div |           <div | ||||||
|             class="inline-flex items-center gap-1 text-slate-500 dark:text-slate-400 whitespace-nowrap" |             class="inline-flex items-center gap-1 text-n-slate-11 whitespace-nowrap" | ||||||
|           > |           > | ||||||
|             <FluentIcon icon="eye-show" size="18" /> |             <FluentIcon icon="eye-show" size="18" /> | ||||||
|             <span class="text-sm"> {{ views }} views </span> |             <span class="text-sm"> | ||||||
|  |               {{ | ||||||
|  |                 t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.VIEWS', { | ||||||
|  |                   count: views, | ||||||
|  |                 }) | ||||||
|  |               }} | ||||||
|  |             </span> | ||||||
|           </div> |           </div> | ||||||
|         </div> |         </div> | ||||||
|         <span class="text-sm text-slate-600 dark:text-slate-400 line-clamp-1"> |         <span class="text-sm text-n-slate-11 line-clamp-1"> | ||||||
|           {{ updatedAt }} |           {{ lastUpdatedAt }} | ||||||
|         </span> |         </span> | ||||||
|       </div> |       </div> | ||||||
|     </template> |     </template> | ||||||
|   | |||||||
| @@ -2,17 +2,21 @@ | |||||||
| import CategoryCard from './CategoryCard.vue'; | import CategoryCard from './CategoryCard.vue'; | ||||||
| const categories = [ | const categories = [ | ||||||
|   { |   { | ||||||
|     id: 'getting-started', |     id: 1, | ||||||
|     title: '🚀 Getting started', |     title: 'Getting started', | ||||||
|     description: |     description: | ||||||
|       'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.', |       'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.', | ||||||
|     articlesCount: '5', |     articlesCount: 5, | ||||||
|  |     slug: 'getting-started', | ||||||
|  |     icon: '🚀', | ||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|     id: 'marketing', |     id: 2, | ||||||
|     title: '📈 Marketing', |     title: 'Marketing', | ||||||
|     description: '', |     description: '', | ||||||
|     articlesCount: '4', |     articlesCount: 4, | ||||||
|  |     slug: 'marketing', | ||||||
|  |     icon: '📈', | ||||||
|   }, |   }, | ||||||
| ]; | ]; | ||||||
| </script> | </script> | ||||||
| @@ -31,9 +35,12 @@ const categories = [ | |||||||
|         class="px-20 py-4 bg-white dark:bg-slate-900" |         class="px-20 py-4 bg-white dark:bg-slate-900" | ||||||
|       > |       > | ||||||
|         <CategoryCard |         <CategoryCard | ||||||
|  |           :id="category.id" | ||||||
|  |           :slug="category.slug" | ||||||
|           :title="category.title" |           :title="category.title" | ||||||
|           :description="category.description" |           :description="category.description" | ||||||
|           :articles-count="category.articlesCount" |           :articles-count="category.articlesCount" | ||||||
|  |           :icon="category.icon" | ||||||
|         /> |         /> | ||||||
|       </div> |       </div> | ||||||
|     </Variant> |     </Variant> | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| <script setup> | <script setup> | ||||||
| import { ref, computed } from 'vue'; | import { ref, computed } from 'vue'; | ||||||
| import { OnClickOutside } from '@vueuse/components'; | import { OnClickOutside } from '@vueuse/components'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  |  | ||||||
| import CardLayout from 'dashboard/components-next/CardLayout.vue'; | import CardLayout from 'dashboard/components-next/CardLayout.vue'; | ||||||
| import Button from 'dashboard/components-next/button/Button.vue'; | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
| @@ -8,14 +9,14 @@ import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.v | |||||||
|  |  | ||||||
| const props = defineProps({ | const props = defineProps({ | ||||||
|   id: { |   id: { | ||||||
|     type: String, |     type: Number, | ||||||
|     required: true, |     required: true, | ||||||
|   }, |   }, | ||||||
|   title: { |   title: { | ||||||
|     type: String, |     type: String, | ||||||
|     required: true, |     required: true, | ||||||
|   }, |   }, | ||||||
|   articlesCount: { |   icon: { | ||||||
|     type: String, |     type: String, | ||||||
|     required: true, |     required: true, | ||||||
|   }, |   }, | ||||||
| @@ -23,25 +24,41 @@ const props = defineProps({ | |||||||
|     type: String, |     type: String, | ||||||
|     required: true, |     required: true, | ||||||
|   }, |   }, | ||||||
|  |   articlesCount: { | ||||||
|  |     type: Number, | ||||||
|  |     required: true, | ||||||
|  |   }, | ||||||
|  |   slug: { | ||||||
|  |     type: String, | ||||||
|  |     required: true, | ||||||
|  |   }, | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const emit = defineEmits(['click']); | const emit = defineEmits(['click', 'action']); | ||||||
|  |  | ||||||
|  | const { t } = useI18n(); | ||||||
|  |  | ||||||
| const isOpen = ref(false); | const isOpen = ref(false); | ||||||
|  |  | ||||||
| const menuItems = [ | const categoryMenuItems = [ | ||||||
|   { |   { | ||||||
|     label: 'Edit', |     label: 'Edit', | ||||||
|     action: 'edit', |     action: 'edit', | ||||||
|  |     value: 'edit', | ||||||
|     icon: 'edit', |     icon: 'edit', | ||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|     label: 'Delete', |     label: 'Delete', | ||||||
|     action: 'delete', |     action: 'delete', | ||||||
|  |     value: 'delete', | ||||||
|     icon: 'delete', |     icon: 'delete', | ||||||
|   }, |   }, | ||||||
| ]; | ]; | ||||||
|  |  | ||||||
|  | const categoryTitleWithIcon = computed(() => { | ||||||
|  |   return `${props.icon} ${props.title}`; | ||||||
|  | }); | ||||||
|  |  | ||||||
| const description = computed(() => { | const description = computed(() => { | ||||||
|   return props.description ? props.description : 'No description added'; |   return props.description ? props.description : 'No description added'; | ||||||
| }); | }); | ||||||
| @@ -50,48 +67,51 @@ const hasDescription = computed(() => { | |||||||
|   return props.description.length > 0; |   return props.description.length > 0; | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const handleClick = id => { | const handleClick = slug => { | ||||||
|   emit('click', id); |   emit('click', slug); | ||||||
| }; | }; | ||||||
|  |  | ||||||
| // eslint-disable-next-line no-unused-vars | const handleAction = ({ action, value }) => { | ||||||
| const handleAction = action => { |   emit('action', { action, value, id: props.id }); | ||||||
|   // TODO: Implement action |   isOpen.value = false; | ||||||
| }; | }; | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <!-- TODO: Add i18n --> |  | ||||||
| <!-- eslint-disable vue/no-bare-strings-in-template --> |  | ||||||
| <template> | <template> | ||||||
|   <CardLayout @click="handleClick(id)"> |   <CardLayout> | ||||||
|     <template #header> |     <template #header> | ||||||
|       <div class="flex gap-2"> |       <div class="flex gap-2"> | ||||||
|         <div class="flex justify-between w-full"> |         <div class="flex justify-between w-full gap-1"> | ||||||
|           <div class="flex items-center justify-start gap-2"> |           <div class="flex items-center justify-start gap-2"> | ||||||
|             <span |             <span | ||||||
|               class="text-base cursor-pointer group-hover/cardLayout:underline text-slate-900 dark:text-slate-50 line-clamp-1" |               class="text-base cursor-pointer hover:underline text-slate-900 dark:text-slate-50 line-clamp-1" | ||||||
|  |               @click="handleClick(slug)" | ||||||
|             > |             > | ||||||
|               {{ title }} |               {{ categoryTitleWithIcon }} | ||||||
|             </span> |             </span> | ||||||
|             <span |             <span | ||||||
|               class="inline-flex items-center justify-center h-6 px-2 py-1 text-xs text-center border rounded-lg text-slate-500 w-fit border-slate-200 dark:border-slate-800 dark:text-slate-400" |               class="inline-flex items-center justify-center h-6 px-2 py-1 text-xs text-center truncate border rounded-lg min-w-fit text-slate-500 w-fit border-slate-200 dark:border-slate-800 dark:text-slate-400" | ||||||
|             > |             > | ||||||
|               {{ articlesCount }} articles |               {{ | ||||||
|  |                 t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_CARD.ARTICLES_COUNT', { | ||||||
|  |                   count: articlesCount, | ||||||
|  |                 }) | ||||||
|  |               }} | ||||||
|             </span> |             </span> | ||||||
|           </div> |           </div> | ||||||
|           <div class="relative group" @click.stop> |           <div class="relative group" @click.stop> | ||||||
|             <Button |  | ||||||
|               variant="ghost" |  | ||||||
|               size="icon" |  | ||||||
|               icon="more-vertical" |  | ||||||
|               class="w-8 z-60 group-hover:bg-slate-100 dark:group-hover:bg-slate-800" |  | ||||||
|               @click="isOpen = !isOpen" |  | ||||||
|             /> |  | ||||||
|             <OnClickOutside @trigger="isOpen = false"> |             <OnClickOutside @trigger="isOpen = false"> | ||||||
|  |               <Button | ||||||
|  |                 variant="ghost" | ||||||
|  |                 size="sm" | ||||||
|  |                 icon="more-vertical" | ||||||
|  |                 class="w-8 z-60 group-hover:bg-slate-100 dark:group-hover:bg-slate-800" | ||||||
|  |                 @click="isOpen = !isOpen" | ||||||
|  |               /> | ||||||
|               <DropdownMenu |               <DropdownMenu | ||||||
|                 v-if="isOpen" |                 v-if="isOpen" | ||||||
|                 :menu-items="menuItems" |                 :menu-items="categoryMenuItems" | ||||||
|                 class="right-0 mt-1 xl:left-0 top-full z-60" |                 class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 top-full z-60" | ||||||
|                 @action="handleAction" |                 @action="handleAction" | ||||||
|               /> |               /> | ||||||
|             </OnClickOutside> |             </OnClickOutside> | ||||||
|   | |||||||
| @@ -1,66 +1,42 @@ | |||||||
| <script setup> | <script setup> | ||||||
| // import { ref } from 'vue'; |  | ||||||
| import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue'; | import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue'; | ||||||
| import Button from 'dashboard/components-next/button/Button.vue'; | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
| import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue'; | import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue'; | ||||||
| // import AddLocaleDialog from 'dashboard/playground/HelpCenter/components/AddLocaleDialog.vue'; | import articleContent from 'dashboard/components-next/HelpCenter/EmptyState/Portal/portalEmptyStateContent.js'; | ||||||
|  |  | ||||||
| const articles = [ | defineProps({ | ||||||
|   { |   title: { | ||||||
|     title: "How to get an SSL certificate for your Help Center's custom domain", |     type: String, | ||||||
|     status: 'draft', |     default: '', | ||||||
|     updatedAt: '2 days ago', |  | ||||||
|     author: 'Michael', |  | ||||||
|     category: '⚡️ Marketing', |  | ||||||
|     views: 3400, |  | ||||||
|   }, |   }, | ||||||
|   { |   subtitle: { | ||||||
|     title: 'Setting up your first Help Center portal', |     type: String, | ||||||
|     status: '', |     default: '', | ||||||
|     updatedAt: '1 week ago', |  | ||||||
|     author: 'John', |  | ||||||
|     category: '🛠️ Development', |  | ||||||
|     views: 400, |  | ||||||
|   }, |   }, | ||||||
|   { |   showButton: { | ||||||
|     title: 'Best practices for organizing your Help Center content', |     type: Boolean, | ||||||
|     status: 'archived', |     default: true, | ||||||
|     updatedAt: '3 days ago', |  | ||||||
|     author: 'Fernando', |  | ||||||
|     category: '💰 Finance', |  | ||||||
|     views: 400, |  | ||||||
|   }, |   }, | ||||||
|   { |   buttonLabel: { | ||||||
|     title: 'Customizing the appearance of your Help Center', |     type: String, | ||||||
|     status: '', |     default: '', | ||||||
|     updatedAt: '5 days ago', |  | ||||||
|     author: 'Jane', |  | ||||||
|     category: '💰 Finance', |  | ||||||
|     views: 400, |  | ||||||
|   }, |   }, | ||||||
| ]; | }); | ||||||
|  |  | ||||||
| // const addLocaleDialogRef = ref(null); | const emit = defineEmits(['click']); | ||||||
| // const openDialog = () => { |  | ||||||
| //   addLocaleDialogRef.value.dialogRef.open(); | const onClick = () => { | ||||||
| // }; |   emit('click'); | ||||||
| // const handleDialogConfirm = () => { | }; | ||||||
| //   // Add logic to create a new portal |  | ||||||
| // }; |  | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <!-- TODO: Add i18n --> |  | ||||||
| <!-- eslint-disable vue/no-bare-strings-in-template --> |  | ||||||
|  |  | ||||||
| <template> | <template> | ||||||
|   <EmptyStateLayout |   <EmptyStateLayout :title="title" :subtitle="subtitle"> | ||||||
|     title="Write an article" |  | ||||||
|     subtitle="Write a rich article, let's get started!" |  | ||||||
|   > |  | ||||||
|     <template #empty-state-item> |     <template #empty-state-item> | ||||||
|       <div class="grid grid-cols-1 gap-4"> |       <div class="grid grid-cols-1 gap-4 overflow-hidden"> | ||||||
|         <ArticleCard |         <ArticleCard | ||||||
|           v-for="(article, index) in articles" |           v-for="(article, index) in articleContent.slice(0, 5)" | ||||||
|  |           :id="article.id" | ||||||
|           :key="`article-${index}`" |           :key="`article-${index}`" | ||||||
|           :title="article.title" |           :title="article.title" | ||||||
|           :status="article.status" |           :status="article.status" | ||||||
| @@ -72,16 +48,14 @@ const articles = [ | |||||||
|       </div> |       </div> | ||||||
|     </template> |     </template> | ||||||
|     <template #actions> |     <template #actions> | ||||||
|       <Button |       <div v-if="showButton"> | ||||||
|         variant="default" |         <Button | ||||||
|         label="New article" |           variant="default" | ||||||
|         icon="add" |           :label="buttonLabel" | ||||||
|         @click="openDialog" |           icon="add" | ||||||
|       /> |           @click="onClick" | ||||||
|       <!-- <AddLocaleDialog |         /> | ||||||
|           ref="addLocaleDialogRef" |       </div> | ||||||
|           @confirm="handleDialogConfirm" |  | ||||||
|         /> --> |  | ||||||
|     </template> |     </template> | ||||||
|   </EmptyStateLayout> |   </EmptyStateLayout> | ||||||
| </template> | </template> | ||||||
|   | |||||||
| @@ -0,0 +1,49 @@ | |||||||
|  | <script setup> | ||||||
|  | import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue'; | ||||||
|  | import CategoryCard from 'dashboard/components-next/HelpCenter/CategoryCard/CategoryCard.vue'; | ||||||
|  | import categoryContent from 'dashboard/components-next/HelpCenter/EmptyState/Category/categoryEmptyStateContent.js'; | ||||||
|  |  | ||||||
|  | defineProps({ | ||||||
|  |   title: { | ||||||
|  |     type: String, | ||||||
|  |     default: '', | ||||||
|  |   }, | ||||||
|  |   subtitle: { | ||||||
|  |     type: String, | ||||||
|  |     default: '', | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <EmptyStateLayout :title="title" :subtitle="subtitle"> | ||||||
|  |     <template #empty-state-item> | ||||||
|  |       <div class="grid grid-cols-2 gap-4"> | ||||||
|  |         <div class="space-y-4"> | ||||||
|  |           <CategoryCard | ||||||
|  |             v-for="category in categoryContent" | ||||||
|  |             :id="category.id" | ||||||
|  |             :key="category.id" | ||||||
|  |             :title="category.name" | ||||||
|  |             :icon="category.icon" | ||||||
|  |             :description="category.description" | ||||||
|  |             :articles-count="category.meta.articles_count || 0" | ||||||
|  |             :slug="category.slug" | ||||||
|  |           /> | ||||||
|  |         </div> | ||||||
|  |         <div class="space-y-4"> | ||||||
|  |           <CategoryCard | ||||||
|  |             v-for="category in categoryContent.reverse()" | ||||||
|  |             :id="category.id" | ||||||
|  |             :key="category.id" | ||||||
|  |             :title="category.name" | ||||||
|  |             :icon="category.icon" | ||||||
|  |             :description="category.description" | ||||||
|  |             :articles-count="category.meta.articles_count || 0" | ||||||
|  |             :slug="category.slug" | ||||||
|  |           /> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </template> | ||||||
|  |   </EmptyStateLayout> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,142 @@ | |||||||
|  | export default [ | ||||||
|  |   { | ||||||
|  |     id: 1, | ||||||
|  |     name: 'Getting Started', | ||||||
|  |     icon: '🚀', | ||||||
|  |     description: 'Quick guides to help new users onboard.', | ||||||
|  |     slug: 'getting-started', | ||||||
|  |     meta: { | ||||||
|  |       articles_count: 5, | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     id: 2, | ||||||
|  |     name: 'Advanced Features', | ||||||
|  |     icon: '💡', | ||||||
|  |     description: 'Explore advanced features for power users.', | ||||||
|  |     slug: 'advanced-features', | ||||||
|  |     meta: { | ||||||
|  |       articles_count: 8, | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     id: 3, | ||||||
|  |     name: 'FAQs', | ||||||
|  |     icon: '❓', | ||||||
|  |     description: 'Commonly asked questions and helpful answers.', | ||||||
|  |     slug: 'faqs', | ||||||
|  |     meta: { | ||||||
|  |       articles_count: 3, | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     id: 4, | ||||||
|  |     name: 'Troubleshooting', | ||||||
|  |     icon: '🛠️', | ||||||
|  |     description: 'Resolve common issues with step-by-step guidance.', | ||||||
|  |     slug: 'troubleshooting', | ||||||
|  |     meta: { | ||||||
|  |       articles_count: 6, | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     id: 5, | ||||||
|  |     name: 'Community Guidelines', | ||||||
|  |     icon: '👥', | ||||||
|  |     description: 'Rules and practices for community engagement.', | ||||||
|  |     slug: 'community-guidelines', | ||||||
|  |     meta: { | ||||||
|  |       articles_count: 2, | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     id: 6, | ||||||
|  |     name: 'Account Management', | ||||||
|  |     icon: '🔑', | ||||||
|  |     description: 'Manage your account and settings efficiently.', | ||||||
|  |     slug: 'account-management', | ||||||
|  |     meta: { | ||||||
|  |       articles_count: 7, | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     id: 7, | ||||||
|  |     name: 'Security Tips', | ||||||
|  |     icon: '🔒', | ||||||
|  |     description: 'Best practices for securing your account.', | ||||||
|  |     slug: 'security-tips', | ||||||
|  |     meta: { | ||||||
|  |       articles_count: 4, | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     id: 8, | ||||||
|  |     name: 'Integrations', | ||||||
|  |     icon: '🔗', | ||||||
|  |     description: 'Connect to third-party services and tools easily.', | ||||||
|  |     slug: 'integrations', | ||||||
|  |     meta: { | ||||||
|  |       articles_count: 9, | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     id: 9, | ||||||
|  |     name: 'Billing & Payments', | ||||||
|  |     icon: '💳', | ||||||
|  |     description: 'Manage your billing and payment details seamlessly.', | ||||||
|  |     slug: 'billing-payments', | ||||||
|  |     meta: { | ||||||
|  |       articles_count: 5, | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     id: 10, | ||||||
|  |     name: 'Customization', | ||||||
|  |     icon: '🎨', | ||||||
|  |     description: 'Personalize and customize your user experience.', | ||||||
|  |     slug: 'customization', | ||||||
|  |     meta: { | ||||||
|  |       articles_count: 7, | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     id: 11, | ||||||
|  |     name: 'Notifications', | ||||||
|  |     icon: '🔔', | ||||||
|  |     description: 'Adjust your notification settings and preferences.', | ||||||
|  |     slug: 'notifications', | ||||||
|  |     meta: { | ||||||
|  |       articles_count: 3, | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     id: 12, | ||||||
|  |     name: 'Privacy', | ||||||
|  |     icon: '🛡️', | ||||||
|  |     description: 'Understand how your data is collected and used.', | ||||||
|  |     slug: 'privacy', | ||||||
|  |     meta: { | ||||||
|  |       articles_count: 2, | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     id: 13, | ||||||
|  |     name: 'Mobile App', | ||||||
|  |     icon: '📱', | ||||||
|  |     description: 'Guides for using the mobile app effectively.', | ||||||
|  |     slug: 'mobile-app', | ||||||
|  |     meta: { | ||||||
|  |       articles_count: 6, | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     id: 14, | ||||||
|  |     name: 'Beta Features', | ||||||
|  |     icon: '🧪', | ||||||
|  |     description: 'Learn about new experimental features in beta.', | ||||||
|  |     slug: 'beta-features', | ||||||
|  |     meta: { | ||||||
|  |       articles_count: 4, | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  | ]; | ||||||
| @@ -1,86 +1,38 @@ | |||||||
| <script setup> | <script setup> | ||||||
| // import { ref } from 'vue'; | import { ref } from 'vue'; | ||||||
|  | import { useRouter } from 'vue-router'; | ||||||
| import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue'; | import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue'; | ||||||
| import Button from 'dashboard/components-next/button/Button.vue'; | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
| import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue'; | import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue'; | ||||||
| import CategoryCard from 'dashboard/components-next/HelpCenter/CategoryCard/CategoryCard.vue'; | import articleContent from './portalEmptyStateContent'; | ||||||
| import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue'; | import CreatePortalDialog from 'dashboard/components-next/HelpCenter/PortalSwitcher/CreatePortalDialog.vue'; | ||||||
| // import CreatePortalDialog from 'dashboard/playground/HelpCenter/components/CreatePortalDialog.vue'; |  | ||||||
|  |  | ||||||
| const articles = [ | const createPortalDialogRef = ref(null); | ||||||
|   { | const openDialog = () => { | ||||||
|     title: "How to get an SSL certificate for your Help Center's custom domain", |   createPortalDialogRef.value.dialogRef.open(); | ||||||
|     status: 'draft', | }; | ||||||
|     updatedAt: '2 days ago', |  | ||||||
|     author: 'Michael', |  | ||||||
|     category: '⚡️ Marketing', |  | ||||||
|     views: 3400, |  | ||||||
|   }, |  | ||||||
|   { |  | ||||||
|     title: 'Setting up your first Help Center portal', |  | ||||||
|     status: '', |  | ||||||
|     updatedAt: '1 week ago', |  | ||||||
|     author: 'John', |  | ||||||
|     category: '🛠️ Development', |  | ||||||
|     views: 400, |  | ||||||
|   }, |  | ||||||
|   { |  | ||||||
|     title: 'Best practices for organizing your Help Center content', |  | ||||||
|     status: 'archived', |  | ||||||
|     updatedAt: '3 days ago', |  | ||||||
|     author: 'Fernando', |  | ||||||
|     category: '💰 Finance', |  | ||||||
|     views: 400, |  | ||||||
|   }, |  | ||||||
|   { |  | ||||||
|     title: 'Customizing the appearance of your Help Center', |  | ||||||
|     status: '', |  | ||||||
|     updatedAt: '5 days ago', |  | ||||||
|     author: 'Jane', |  | ||||||
|     category: '💰 Finance', |  | ||||||
|     views: 400, |  | ||||||
|   }, |  | ||||||
| ]; |  | ||||||
| const categories = [ |  | ||||||
|   { |  | ||||||
|     title: 'Getting Started', |  | ||||||
|     description: 'Essential guides for new users', |  | ||||||
|     articlesCount: '5', |  | ||||||
|   }, |  | ||||||
|   { |  | ||||||
|     title: 'Advanced Features', |  | ||||||
|     description: 'In-depth tutorials for power users', |  | ||||||
|     articlesCount: '8', |  | ||||||
|   }, |  | ||||||
| ]; |  | ||||||
|  |  | ||||||
| const locales = [ | const router = useRouter(); | ||||||
|   { name: 'English', isDefault: true }, |  | ||||||
|   { name: 'Spanish', isDefault: false }, |  | ||||||
|   { name: 'Malayalam', isDefault: false }, |  | ||||||
| ]; |  | ||||||
|  |  | ||||||
| // const createPortalDialogRef = ref(null); | const onPortalCreate = ({ slug: portalSlug, locale }) => { | ||||||
| // const openDialog = () => { |   router.push({ | ||||||
| //   createPortalDialogRef.value.dialogRef.open(); |     name: 'portals_articles_index', | ||||||
| // }; |     params: { portalSlug, locale }, | ||||||
| // const handleDialogConfirm = () => { |   }); | ||||||
| //   // Add logic to create a new portal | }; | ||||||
| // }; |  | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <!-- TODO: Add i18n --> |  | ||||||
| <!-- eslint-disable vue/no-bare-strings-in-template --> |  | ||||||
| <template> | <template> | ||||||
|   <EmptyStateLayout |   <EmptyStateLayout | ||||||
|     title="Help Center" |     :title="$t('HELP_CENTER.TITLE')" | ||||||
|     subtitle="Create self-service portals to access articles and information. Streamline queries, enhance agent efficiency, and elevate customer support." |     :subtitle="$t('HELP_CENTER.NEW_PAGE.DESCRIPTION')" | ||||||
|   > |   > | ||||||
|     <template #empty-state-item> |     <template #empty-state-item> | ||||||
|       <div class="grid grid-cols-2 gap-4"> |       <div class="grid grid-cols-2 gap-4"> | ||||||
|         <div class="space-y-4"> |         <div class="space-y-4"> | ||||||
|           <ArticleCard |           <ArticleCard | ||||||
|             v-for="(article, index) in articles" |             v-for="(article, index) in articleContent" | ||||||
|  |             :id="article.id" | ||||||
|             :key="`article-${index}`" |             :key="`article-${index}`" | ||||||
|             :title="article.title" |             :title="article.title" | ||||||
|             :status="article.status" |             :status="article.status" | ||||||
| @@ -91,18 +43,16 @@ const locales = [ | |||||||
|           /> |           /> | ||||||
|         </div> |         </div> | ||||||
|         <div class="space-y-4"> |         <div class="space-y-4"> | ||||||
|           <CategoryCard |           <ArticleCard | ||||||
|             v-for="(category, index) in categories" |             v-for="(article, index) in articleContent.reverse()" | ||||||
|             :key="`category-${index}`" |             :id="article.id" | ||||||
|             :title="category.title" |             :key="`article-${index}`" | ||||||
|             :description="category.description" |             :title="article.title" | ||||||
|             :articles-count="category.articlesCount" |             :status="article.status" | ||||||
|           /> |             :updated-at="article.updatedAt" | ||||||
|           <LocaleCard |             :author="article.author" | ||||||
|             v-for="(locale, index) in locales" |             :category="article.category" | ||||||
|             :key="`locale-${index}`" |             :views="article.views" | ||||||
|             :locale="locale.name" |  | ||||||
|             :is-default="locale.isDefault" |  | ||||||
|           /> |           /> | ||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
| @@ -110,14 +60,14 @@ const locales = [ | |||||||
|     <template #actions> |     <template #actions> | ||||||
|       <Button |       <Button | ||||||
|         variant="default" |         variant="default" | ||||||
|         label="Create Portal" |         :label="$t('HELP_CENTER.NEW_PAGE.CREATE_PORTAL_BUTTON')" | ||||||
|         icon="add" |         icon="add" | ||||||
|         @click="openDialog" |         @click="openDialog" | ||||||
|       /> |       /> | ||||||
|       <!-- <CreatePortalDialog |       <CreatePortalDialog | ||||||
|           ref="createPortalDialogRef" |         ref="createPortalDialogRef" | ||||||
|           @confirm="handleDialogConfirm" |         @create="onPortalCreate" | ||||||
|         /> --> |       /> | ||||||
|     </template> |     </template> | ||||||
|   </EmptyStateLayout> |   </EmptyStateLayout> | ||||||
| </template> | </template> | ||||||
|   | |||||||
| @@ -0,0 +1,172 @@ | |||||||
|  | export default [ | ||||||
|  |   { | ||||||
|  |     id: 1, | ||||||
|  |     title: "How to get an SSL certificate for your Help Center's custom domain", | ||||||
|  |     status: 'draft', | ||||||
|  |     updatedAt: 1729205669, | ||||||
|  |     author: { availableName: 'Michael' }, | ||||||
|  |     category: { | ||||||
|  |       slug: 'configuration', | ||||||
|  |       icon: '📦', | ||||||
|  |       name: 'Setup & Configuration', | ||||||
|  |     }, | ||||||
|  |     views: 3400, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     id: 2, | ||||||
|  |     title: 'Setting up your first Help Center portal', | ||||||
|  |     status: 'published', | ||||||
|  |     updatedAt: 1729205669, | ||||||
|  |     author: { availableName: 'John' }, | ||||||
|  |     category: { slug: 'onboarding', icon: '🧑🍳', name: 'Onboarding' }, | ||||||
|  |     views: 400, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     id: 3, | ||||||
|  |     title: 'Best practices for organizing your Help Center content', | ||||||
|  |     status: 'archived', | ||||||
|  |     updatedAt: 1729205669, | ||||||
|  |     author: { availableName: 'Fernando' }, | ||||||
|  |     category: { slug: 'best-practices', icon: '⛺️', name: 'Best Practices' }, | ||||||
|  |     views: 400, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     id: 4, | ||||||
|  |     title: 'Customizing the appearance of your Help Center', | ||||||
|  |     status: 'draft', | ||||||
|  |     updatedAt: 1729205669, | ||||||
|  |     author: { availableName: 'Jane' }, | ||||||
|  |     category: { slug: 'design', icon: '🎨', name: 'Design' }, | ||||||
|  |     views: 400, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     id: 5, | ||||||
|  |     title: 'Integrating your Help Center with third-party tools', | ||||||
|  |     status: 'published', | ||||||
|  |     updatedAt: 1729205669, | ||||||
|  |     author: { availableName: 'Sarah' }, | ||||||
|  |     category: { | ||||||
|  |       slug: 'integrations', | ||||||
|  |       icon: '🔗', | ||||||
|  |       name: 'Integrations', | ||||||
|  |     }, | ||||||
|  |     views: 2800, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     id: 6, | ||||||
|  |     title: 'Managing user permissions in your Help Center', | ||||||
|  |     status: 'draft', | ||||||
|  |     updatedAt: 1729205669, | ||||||
|  |     author: { availableName: 'Alex' }, | ||||||
|  |     category: { | ||||||
|  |       slug: 'administration', | ||||||
|  |       icon: '🔐', | ||||||
|  |       name: 'Administration', | ||||||
|  |     }, | ||||||
|  |     views: 1200, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     id: 7, | ||||||
|  |     title: 'Creating and managing FAQ sections', | ||||||
|  |     status: 'published', | ||||||
|  |     updatedAt: 1729205669, | ||||||
|  |     author: { availableName: 'Emily' }, | ||||||
|  |     category: { | ||||||
|  |       slug: 'content-management', | ||||||
|  |       icon: '📝', | ||||||
|  |       name: 'Content Management', | ||||||
|  |     }, | ||||||
|  |     views: 5600, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     id: 8, | ||||||
|  |     title: 'Implementing search functionality in your Help Center', | ||||||
|  |     status: 'archived', | ||||||
|  |     updatedAt: 1729205669, | ||||||
|  |     author: { availableName: 'David' }, | ||||||
|  |     category: { | ||||||
|  |       slug: 'features', | ||||||
|  |       icon: '🔍', | ||||||
|  |       name: 'Features', | ||||||
|  |     }, | ||||||
|  |     views: 1800, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     id: 9, | ||||||
|  |     title: 'Analyzing Help Center usage metrics', | ||||||
|  |     status: 'published', | ||||||
|  |     updatedAt: 1729205669, | ||||||
|  |     author: { availableName: 'Rachel' }, | ||||||
|  |     category: { | ||||||
|  |       slug: 'analytics', | ||||||
|  |       icon: '📊', | ||||||
|  |       name: 'Analytics', | ||||||
|  |     }, | ||||||
|  |     views: 3200, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     id: 10, | ||||||
|  |     title: 'Setting up multilingual support in your Help Center', | ||||||
|  |     status: 'draft', | ||||||
|  |     updatedAt: 1729205669, | ||||||
|  |     author: { availableName: 'Carlos' }, | ||||||
|  |     category: { | ||||||
|  |       slug: 'localization', | ||||||
|  |       icon: '🌍', | ||||||
|  |       name: 'Localization', | ||||||
|  |     }, | ||||||
|  |     views: 900, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     id: 11, | ||||||
|  |     title: 'Creating interactive tutorials for your products', | ||||||
|  |     status: 'published', | ||||||
|  |     updatedAt: 1729205669, | ||||||
|  |     author: { availableName: 'Olivia' }, | ||||||
|  |     category: { | ||||||
|  |       slug: 'education', | ||||||
|  |       icon: '🎓', | ||||||
|  |       name: 'Education', | ||||||
|  |     }, | ||||||
|  |     views: 4100, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     id: 12, | ||||||
|  |     title: 'Implementing a feedback system in your Help Center', | ||||||
|  |     status: 'draft', | ||||||
|  |     updatedAt: 1729205669, | ||||||
|  |     author: { availableName: 'Nathan' }, | ||||||
|  |     category: { | ||||||
|  |       slug: 'user-engagement', | ||||||
|  |       icon: '💬', | ||||||
|  |       name: 'User Engagement', | ||||||
|  |     }, | ||||||
|  |     views: 750, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     id: 13, | ||||||
|  |     title: 'Optimizing Help Center content for SEO', | ||||||
|  |     status: 'published', | ||||||
|  |     updatedAt: 1729205669, | ||||||
|  |     author: { availableName: 'Sophia' }, | ||||||
|  |     category: { | ||||||
|  |       slug: 'seo', | ||||||
|  |       icon: '🚀', | ||||||
|  |       name: 'SEO', | ||||||
|  |     }, | ||||||
|  |     views: 2900, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     id: 14, | ||||||
|  |     title: 'Creating a knowledge base for internal teams', | ||||||
|  |     status: 'archived', | ||||||
|  |     updatedAt: 1729205669, | ||||||
|  |     author: { availableName: 'Daniel' }, | ||||||
|  |     category: { | ||||||
|  |       slug: 'internal-resources', | ||||||
|  |       icon: '🏢', | ||||||
|  |       name: 'Internal Resources', | ||||||
|  |     }, | ||||||
|  |     views: 1500, | ||||||
|  |   }, | ||||||
|  | ]; | ||||||
| @@ -1,16 +1,15 @@ | |||||||
| <script setup> | <script setup> | ||||||
| import { ref } from 'vue'; | import { ref, computed } from 'vue'; | ||||||
| import { OnClickOutside } from '@vueuse/components'; | import { OnClickOutside } from '@vueuse/components'; | ||||||
|  | import { useRoute } from 'vue-router'; | ||||||
|  | import { useMapGetter } from 'dashboard/composables/store.js'; | ||||||
|  |  | ||||||
| import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue'; | import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue'; | ||||||
| import Button from 'dashboard/components-next/button/Button.vue'; | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
| import PortalSwitcher from 'dashboard/components-next/HelpCenter/PortalSwitcher/PortalSwitcher.vue'; | import PortalSwitcher from 'dashboard/components-next/HelpCenter/PortalSwitcher/PortalSwitcher.vue'; | ||||||
|  | import CreatePortalDialog from 'dashboard/components-next/HelpCenter/PortalSwitcher/CreatePortalDialog.vue'; | ||||||
|  |  | ||||||
| defineProps({ | defineProps({ | ||||||
|   header: { |  | ||||||
|     type: String, |  | ||||||
|     default: 'Chatwoot Help Center', |  | ||||||
|   }, |  | ||||||
|   currentPage: { |   currentPage: { | ||||||
|     type: Number, |     type: Number, | ||||||
|     default: 1, |     default: 1, | ||||||
| @@ -35,8 +34,21 @@ defineProps({ | |||||||
|  |  | ||||||
| const emit = defineEmits(['update:currentPage']); | const emit = defineEmits(['update:currentPage']); | ||||||
|  |  | ||||||
|  | const route = useRoute(); | ||||||
|  |  | ||||||
|  | const createPortalDialogRef = ref(null); | ||||||
|  |  | ||||||
| const showPortalSwitcher = ref(false); | const showPortalSwitcher = ref(false); | ||||||
|  |  | ||||||
|  | const portals = useMapGetter('portals/allPortals'); | ||||||
|  |  | ||||||
|  | const currentPortalSlug = computed(() => route.params.portalSlug); | ||||||
|  |  | ||||||
|  | const activePortalName = computed(() => { | ||||||
|  |   return portals.value?.find(portal => portal.slug === currentPortalSlug.value) | ||||||
|  |     ?.name; | ||||||
|  | }); | ||||||
|  |  | ||||||
| const updateCurrentPage = page => { | const updateCurrentPage = page => { | ||||||
|   emit('update:currentPage', page); |   emit('update:currentPage', page); | ||||||
| }; | }; | ||||||
| @@ -46,34 +58,37 @@ const togglePortalSwitcher = () => { | |||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <template> | <template> | ||||||
|   <section |   <section class="flex flex-col w-full h-full overflow-hidden bg-n-background"> | ||||||
|     class="flex flex-col w-full h-full overflow-hidden bg-white dark:bg-slate-900" |     <header class="sticky top-0 z-10 px-6 pb-3 lg:px-0"> | ||||||
|   > |  | ||||||
|     <header |  | ||||||
|       class="sticky top-0 z-10 px-6 pb-3 bg-white lg:px-0 dark:bg-slate-900" |  | ||||||
|     > |  | ||||||
|       <div class="w-full max-w-[900px] mx-auto"> |       <div class="w-full max-w-[900px] mx-auto"> | ||||||
|         <div |         <div | ||||||
|           v-if="showHeaderTitle" |           v-if="showHeaderTitle" | ||||||
|           class="flex items-center justify-start h-20 gap-2" |           class="flex items-center justify-start h-20 gap-2" | ||||||
|         > |         > | ||||||
|           <span class="text-xl font-medium text-slate-900 dark:text-white"> |           <span | ||||||
|             {{ header }} |             v-if="activePortalName" | ||||||
|  |             class="text-xl font-medium text-slate-900 dark:text-white" | ||||||
|  |           > | ||||||
|  |             {{ activePortalName }} | ||||||
|           </span> |           </span> | ||||||
|           <div class="relative group"> |           <div v-if="activePortalName" class="relative group"> | ||||||
|             <Button |  | ||||||
|               icon="more-vertical" |  | ||||||
|               variant="ghost" |  | ||||||
|               size="sm" |  | ||||||
|               class="group-hover:bg-slate-100 dark:group-hover:bg-slate-800" |  | ||||||
|               @click="togglePortalSwitcher" |  | ||||||
|             /> |  | ||||||
|             <OnClickOutside @trigger="showPortalSwitcher = false"> |             <OnClickOutside @trigger="showPortalSwitcher = false"> | ||||||
|  |               <Button | ||||||
|  |                 icon="chevron-lucide-down" | ||||||
|  |                 variant="ghost" | ||||||
|  |                 icon-lib="lucide" | ||||||
|  |                 class="!w-6 !h-6 group-hover:bg-n-solid-2 !p-0.5 rounded-md" | ||||||
|  |                 @click="togglePortalSwitcher" | ||||||
|  |               /> | ||||||
|  |  | ||||||
|               <PortalSwitcher |               <PortalSwitcher | ||||||
|                 v-if="showPortalSwitcher" |                 v-if="showPortalSwitcher" | ||||||
|                 class="absolute left-0 top-9" |                 class="absolute ltr:left-0 rtl:right-0 top-9" | ||||||
|  |                 @close="showPortalSwitcher = false" | ||||||
|  |                 @create-portal="createPortalDialogRef.dialogRef.open()" | ||||||
|               /> |               /> | ||||||
|             </OnClickOutside> |             </OnClickOutside> | ||||||
|  |             <CreatePortalDialog ref="createPortalDialogRef" /> | ||||||
|           </div> |           </div> | ||||||
|         </div> |         </div> | ||||||
|         <slot name="header-actions" /> |         <slot name="header-actions" /> | ||||||
| @@ -84,10 +99,7 @@ const togglePortalSwitcher = () => { | |||||||
|         <slot name="content" /> |         <slot name="content" /> | ||||||
|       </div> |       </div> | ||||||
|     </main> |     </main> | ||||||
|     <footer |     <footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 px-4 pb-4"> | ||||||
|       v-if="showPaginationFooter" |  | ||||||
|       class="sticky bottom-0 z-10 px-4 pt-3 pb-4 bg-white dark:bg-slate-900" |  | ||||||
|     > |  | ||||||
|       <PaginationFooter |       <PaginationFooter | ||||||
|         :current-page="currentPage" |         :current-page="currentPage" | ||||||
|         :total-items="totalItems" |         :total-items="totalItems" | ||||||
| @@ -95,5 +107,7 @@ const togglePortalSwitcher = () => { | |||||||
|         @update:current-page="updateCurrentPage" |         @update:current-page="updateCurrentPage" | ||||||
|       /> |       /> | ||||||
|     </footer> |     </footer> | ||||||
|  |     <!-- Do not remove this slot. It can be used to add dialogs. --> | ||||||
|  |     <slot /> | ||||||
|   </section> |   </section> | ||||||
| </template> | </template> | ||||||
|   | |||||||
| @@ -1,12 +1,14 @@ | |||||||
| <script setup> | <script setup> | ||||||
| import { ref } from 'vue'; | import { ref, computed } from 'vue'; | ||||||
| import { OnClickOutside } from '@vueuse/components'; | import { OnClickOutside } from '@vueuse/components'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  | import { LOCALE_MENU_ITEMS } from 'dashboard/helper/portalHelper'; | ||||||
|  |  | ||||||
| import CardLayout from 'dashboard/components-next/CardLayout.vue'; | import CardLayout from 'dashboard/components-next/CardLayout.vue'; | ||||||
| import Button from 'dashboard/components-next/button/Button.vue'; | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
| import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; | import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; | ||||||
|  |  | ||||||
| defineProps({ | const props = defineProps({ | ||||||
|   locale: { |   locale: { | ||||||
|     type: String, |     type: String, | ||||||
|     required: true, |     required: true, | ||||||
| @@ -15,6 +17,10 @@ defineProps({ | |||||||
|     type: Boolean, |     type: Boolean, | ||||||
|     required: true, |     required: true, | ||||||
|   }, |   }, | ||||||
|  |   localeCode: { | ||||||
|  |     type: String, | ||||||
|  |     required: true, | ||||||
|  |   }, | ||||||
|   articleCount: { |   articleCount: { | ||||||
|     type: Number, |     type: Number, | ||||||
|     required: true, |     required: true, | ||||||
| @@ -25,29 +31,26 @@ defineProps({ | |||||||
|   }, |   }, | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const isOpen = ref(false); | const emit = defineEmits(['action']); | ||||||
|  |  | ||||||
| const menuItems = [ | const { t } = useI18n(); | ||||||
|   { |  | ||||||
|     label: 'Make default', |  | ||||||
|     action: 'default', |  | ||||||
|     icon: 'star-emphasis', |  | ||||||
|   }, |  | ||||||
|   { |  | ||||||
|     label: 'Delete', |  | ||||||
|     action: 'delete', |  | ||||||
|     icon: 'delete', |  | ||||||
|   }, |  | ||||||
| ]; |  | ||||||
|  |  | ||||||
| // eslint-disable-next-line no-unused-vars | const showDropdownMenu = ref(false); | ||||||
| const handleAction = action => { |  | ||||||
|   // TODO: Implement action | const localeMenuItems = computed(() => | ||||||
|  |   LOCALE_MENU_ITEMS.map(item => ({ | ||||||
|  |     ...item, | ||||||
|  |     label: t(item.label), | ||||||
|  |     disabled: props.isDefault, | ||||||
|  |   })) | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const handleAction = ({ action, value }) => { | ||||||
|  |   emit('action', { action, value }); | ||||||
|  |   showDropdownMenu.value = false; | ||||||
| }; | }; | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <!-- TODO: Add i18n --> |  | ||||||
| <!-- eslint-disable vue/no-bare-strings-in-template --> |  | ||||||
| <template> | <template> | ||||||
|   <CardLayout class="ltr:pr-2 rtl:pl-2"> |   <CardLayout class="ltr:pr-2 rtl:pl-2"> | ||||||
|     <template #header> |     <template #header> | ||||||
| @@ -56,42 +59,53 @@ const handleAction = action => { | |||||||
|           <span |           <span | ||||||
|             class="text-sm font-medium text-slate-900 dark:text-slate-50 line-clamp-1" |             class="text-sm font-medium text-slate-900 dark:text-slate-50 line-clamp-1" | ||||||
|           > |           > | ||||||
|             {{ locale }} |             {{ locale }} ({{ localeCode }}) | ||||||
|           </span> |           </span> | ||||||
|           <span |           <span | ||||||
|             v-if="isDefault" |             v-if="isDefault" | ||||||
|             class="bg-slate-100 dark:bg-slate-800 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-woot-500 dark:text-woot-400 px-2 py-0.5" |             class="bg-slate-100 dark:bg-slate-800 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-woot-500 dark:text-woot-400 px-2 py-0.5" | ||||||
|           > |           > | ||||||
|             Default |             {{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }} | ||||||
|           </span> |           </span> | ||||||
|         </div> |         </div> | ||||||
|         <div class="flex items-center justify-end gap-1"> |         <div class="flex items-center justify-end gap-2"> | ||||||
|           <div class="flex items-center gap-4"> |           <div class="flex items-center gap-4"> | ||||||
|             <span |             <span | ||||||
|               class="text-sm text-slate-500 dark:text-slate-400 whitespace-nowrap" |               class="text-sm text-slate-500 dark:text-slate-400 whitespace-nowrap" | ||||||
|             > |             > | ||||||
|               {{ articleCount }} articles |               {{ | ||||||
|  |                 $t( | ||||||
|  |                   'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.ARTICLES_COUNT', | ||||||
|  |                   articleCount | ||||||
|  |                 ) | ||||||
|  |               }} | ||||||
|             </span> |             </span> | ||||||
|             <div class="w-px h-3 bg-slate-75 dark:bg-slate-800" /> |             <div class="w-px h-3 bg-slate-75 dark:bg-slate-800" /> | ||||||
|             <span |             <span | ||||||
|               class="text-sm text-slate-500 dark:text-slate-400 whitespace-nowrap" |               class="text-sm text-slate-500 dark:text-slate-400 whitespace-nowrap" | ||||||
|             > |             > | ||||||
|               {{ categoryCount }} categories |               {{ | ||||||
|  |                 $t( | ||||||
|  |                   'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.CATEGORIES_COUNT', | ||||||
|  |                   categoryCount | ||||||
|  |                 ) | ||||||
|  |               }} | ||||||
|             </span> |             </span> | ||||||
|           </div> |           </div> | ||||||
|           <div class="relative group"> |           <div class="relative group"> | ||||||
|             <Button |             <OnClickOutside @trigger="showDropdownMenu = false"> | ||||||
|               variant="ghost" |               <Button | ||||||
|               size="icon" |                 variant="ghost" | ||||||
|               icon="more-vertical" |                 size="sm" | ||||||
|               class="w-8 group-hover:bg-slate-100 dark:group-hover:bg-slate-800" |                 icon="more-vertical" | ||||||
|               @click="isOpen = !isOpen" |                 class="w-8 group-hover:bg-slate-100 dark:group-hover:bg-slate-800" | ||||||
|             /> |                 @click="showDropdownMenu = !showDropdownMenu" | ||||||
|             <OnClickOutside @trigger="isOpen = false"> |               /> | ||||||
|  |  | ||||||
|               <DropdownMenu |               <DropdownMenu | ||||||
|                 v-if="isOpen" |                 v-if="showDropdownMenu" | ||||||
|                 :menu-items="menuItems" |                 :menu-items="localeMenuItems" | ||||||
|                 class="right-0 mt-1 xl:left-0 top-full z-60 min-w-[147px]" |                 class="ltr:right-0 rtl:left-0 mt-1 xl:ltr:left-0 xl:rtl:right-0 top-full z-60 min-w-[150px]" | ||||||
|                 @action="handleAction" |                 @action="handleAction" | ||||||
|               /> |               /> | ||||||
|             </OnClickOutside> |             </OnClickOutside> | ||||||
|   | |||||||
| @@ -1,105 +1,111 @@ | |||||||
| <script setup> | <script setup> | ||||||
| import { computed } from 'vue'; | import { computed } from 'vue'; | ||||||
| import { debounce } from '@chatwoot/utils'; | import { debounce } from '@chatwoot/utils'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
| import { ARTICLE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor'; | import { ARTICLE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor'; | ||||||
| 
 | 
 | ||||||
| import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue'; | import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue'; | ||||||
| import Button from 'dashboard/components-next/button/Button.vue'; |  | ||||||
| import TextArea from 'dashboard/components-next/textarea/TextArea.vue'; | import TextArea from 'dashboard/components-next/textarea/TextArea.vue'; | ||||||
| import FullEditor from 'dashboard/components/widgets/WootWriter/FullEditor.vue'; | import FullEditor from 'dashboard/components/widgets/WootWriter/FullEditor.vue'; | ||||||
|  | import ArticleEditorHeader from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorHeader.vue'; | ||||||
|  | import ArticleEditorControls from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorControls.vue'; | ||||||
| 
 | 
 | ||||||
| const { article } = defineProps({ | const props = defineProps({ | ||||||
|   article: { |   article: { | ||||||
|     type: Object, |     type: Object, | ||||||
|     default: () => ({}), |     default: () => ({}), | ||||||
|   }, |   }, | ||||||
|  |   isUpdating: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, | ||||||
|  |   isSaved: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, | ||||||
| }); | }); | ||||||
| const emit = defineEmits(['saveArticle']); | 
 | ||||||
|  | const emit = defineEmits([ | ||||||
|  |   'saveArticle', | ||||||
|  |   'goBack', | ||||||
|  |   'setAuthor', | ||||||
|  |   'setCategory', | ||||||
|  |   'previewArticle', | ||||||
|  | ]); | ||||||
|  | 
 | ||||||
|  | const { t } = useI18n(); | ||||||
| 
 | 
 | ||||||
| const saveArticle = debounce(value => emit('saveArticle', value), 400, false); | const saveArticle = debounce(value => emit('saveArticle', value), 400, false); | ||||||
| 
 | 
 | ||||||
| const articleTitle = computed({ | const articleTitle = computed({ | ||||||
|   get: () => article.title, |   get: () => props.article.title, | ||||||
|   set: title => { |   set: value => { | ||||||
|     saveArticle({ title }); |     saveArticle({ title: value }); | ||||||
|   }, |   }, | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| const articleContent = computed({ | const articleContent = computed({ | ||||||
|   get: () => article.content, |   get: () => props.article.content, | ||||||
|   set: content => { |   set: content => { | ||||||
|     saveArticle({ content }); |     saveArticle({ content }); | ||||||
|   }, |   }, | ||||||
| }); | }); | ||||||
|  | 
 | ||||||
|  | const onClickGoBack = () => { | ||||||
|  |   emit('goBack'); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const setAuthorId = authorId => { | ||||||
|  |   emit('setAuthor', authorId); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const setCategoryId = categoryId => { | ||||||
|  |   emit('setCategory', categoryId); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const previewArticle = () => { | ||||||
|  |   emit('previewArticle'); | ||||||
|  | }; | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <!-- eslint-disable vue/no-bare-strings-in-template --> |  | ||||||
| <template> | <template> | ||||||
|   <HelpCenterLayout :show-header-title="false" :show-pagination-footer="false"> |   <HelpCenterLayout :show-header-title="false" :show-pagination-footer="false"> | ||||||
|     <template #header-actions> |     <template #header-actions> | ||||||
|       <div class="flex items-center justify-between h-20"> |       <ArticleEditorHeader | ||||||
|         <Button |         :is-updating="isUpdating" | ||||||
|           label="Back to articles" |         :is-saved="isSaved" | ||||||
|           icon="chevron-lucide-left" |         :status="article.status" | ||||||
|           icon-lib="lucide" |         :article-id="article.id" | ||||||
|           variant="link" |         @go-back="onClickGoBack" | ||||||
|           text-variant="info" |         @preview-article="previewArticle" | ||||||
|           size="sm" |       /> | ||||||
|         /> |  | ||||||
|         <div class="flex items-center gap-4"> |  | ||||||
|           <span class="text-xs font-medium text-slate-500 dark:text-slate-400"> |  | ||||||
|             Saved |  | ||||||
|           </span> |  | ||||||
|           <div class="flex items-center gap-2"> |  | ||||||
|             <Button label="Preview" variant="secondary" size="sm" /> |  | ||||||
|             <Button |  | ||||||
|               label="Publish" |  | ||||||
|               icon="chevron-lucide-down" |  | ||||||
|               icon-position="right" |  | ||||||
|               icon-lib="lucide" |  | ||||||
|               size="sm" |  | ||||||
|             /> |  | ||||||
|           </div> |  | ||||||
|         </div> |  | ||||||
|       </div> |  | ||||||
|     </template> |     </template> | ||||||
|     <template #content> |     <template #content> | ||||||
|       <div class="flex flex-col gap-3 pl-4 mb-3 rtl:pr-3 rtl:pl-0"> |       <div class="flex flex-col gap-3 pl-4 mb-3 rtl:pr-3 rtl:pl-0"> | ||||||
|         <TextArea |         <TextArea | ||||||
|           v-model="articleTitle" |           v-model="articleTitle" | ||||||
|           class="h-12" |           auto-height | ||||||
|           custom-text-area-class="border-0 !text-[32px] !bg-transparent !py-0 !px-0 !h-auto !leading-[48px] !font-medium !tracking-[0.2px]" |           min-height="4rem" | ||||||
|  |           custom-text-area-class="!text-[32px] !leading-[48px] !font-medium !tracking-[0.2px]" | ||||||
|  |           custom-text-area-wrapper-class="border-0 !bg-transparent dark:!bg-transparent !py-0 !px-0" | ||||||
|           placeholder="Title" |           placeholder="Title" | ||||||
|  |           autofocus | ||||||
|  |         /> | ||||||
|  |         <ArticleEditorControls | ||||||
|  |           :article="article" | ||||||
|  |           @save-article="saveArticle" | ||||||
|  |           @set-author="setAuthorId" | ||||||
|  |           @set-category="setCategoryId" | ||||||
|         /> |         /> | ||||||
|         <div class="flex items-center gap-4"> |  | ||||||
|           <div class="flex items-center gap-2"> |  | ||||||
|             <div class="w-5 h-5 rounded-full bg-slate-100 dark:bg-slate-700" /> |  | ||||||
|             <span class="text-sm text-slate-500 dark:text-slate-400"> |  | ||||||
|               John Doe |  | ||||||
|             </span> |  | ||||||
|           </div> |  | ||||||
|           <div class="w-px h-3 bg-slate-50 dark:bg-slate-800" /> |  | ||||||
|           <Button |  | ||||||
|             label="Uncategorized" |  | ||||||
|             icon="play-shape" |  | ||||||
|             variant="ghost" |  | ||||||
|             class="!px-2 font-normal" |  | ||||||
|             text-variant="info" |  | ||||||
|           /> |  | ||||||
|           <div class="w-px h-3 bg-slate-50 dark:bg-slate-800" /> |  | ||||||
|           <Button |  | ||||||
|             label="More properties" |  | ||||||
|             icon="add" |  | ||||||
|             variant="ghost" |  | ||||||
|             class="!px-2 font-normal" |  | ||||||
|           /> |  | ||||||
|         </div> |  | ||||||
|       </div> |       </div> | ||||||
|       <FullEditor |       <FullEditor | ||||||
|         v-model="articleContent" |         v-model="articleContent" | ||||||
|         class="py-0 pb-10 pl-4 rtl:pr-4 rtl:pl-0 h-fit" |         class="py-0 pb-10 pl-4 rtl:pr-4 rtl:pl-0 h-fit" | ||||||
|         placeholder="Write something" |         :placeholder=" | ||||||
|  |           t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.EDITOR_PLACEHOLDER') | ||||||
|  |         " | ||||||
|         :enabled-menu-options="ARTICLE_EDITOR_MENU_OPTIONS" |         :enabled-menu-options="ARTICLE_EDITOR_MENU_OPTIONS" | ||||||
|  |         :autofocus="false" | ||||||
|       /> |       /> | ||||||
|     </template> |     </template> | ||||||
|   </HelpCenterLayout> |   </HelpCenterLayout> | ||||||
| @@ -132,8 +138,10 @@ const articleContent = computed({ | |||||||
| 
 | 
 | ||||||
|       .ProseMirror-menuitem { |       .ProseMirror-menuitem { | ||||||
|         @apply mr-0; |         @apply mr-0; | ||||||
|  | 
 | ||||||
|         .ProseMirror-icon { |         .ProseMirror-icon { | ||||||
|           @apply p-0 mt-1 !mr-0; |           @apply p-0 mt-1 !mr-0; | ||||||
|  | 
 | ||||||
|           svg { |           svg { | ||||||
|             width: 20px !important; |             width: 20px !important; | ||||||
|             height: 20px !important; |             height: 20px !important; | ||||||
| @@ -0,0 +1,206 @@ | |||||||
|  | <script setup> | ||||||
|  | import { computed, ref } from 'vue'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  | import { OnClickOutside } from '@vueuse/components'; | ||||||
|  | import { useMapGetter } from 'dashboard/composables/store'; | ||||||
|  |  | ||||||
|  | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
|  | import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue'; | ||||||
|  | import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; | ||||||
|  | import ArticleEditorProperties from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorProperties.vue'; | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   article: { | ||||||
|  |     type: Object, | ||||||
|  |     default: () => ({}), | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['saveArticle', 'setAuthor', 'setCategory']); | ||||||
|  |  | ||||||
|  | const { t } = useI18n(); | ||||||
|  |  | ||||||
|  | const openAgentsList = ref(false); | ||||||
|  | const openCategoryList = ref(false); | ||||||
|  | const openProperties = ref(false); | ||||||
|  | const selectedAuthorId = ref(null); | ||||||
|  | const selectedCategoryId = ref(null); | ||||||
|  |  | ||||||
|  | const agents = useMapGetter('agents/getAgents'); | ||||||
|  | const categories = useMapGetter('categories/allCategories'); | ||||||
|  | const currentUserId = useMapGetter('getCurrentUserID'); | ||||||
|  |  | ||||||
|  | const isNewArticle = computed(() => !props.article?.id); | ||||||
|  |  | ||||||
|  | const currentUser = computed(() => | ||||||
|  |   agents.value.find(agent => agent.id === currentUserId.value) | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const author = computed(() => { | ||||||
|  |   if (isNewArticle.value) { | ||||||
|  |     return selectedAuthorId.value | ||||||
|  |       ? agents.value.find(agent => agent.id === selectedAuthorId.value) | ||||||
|  |       : currentUser.value; | ||||||
|  |   } | ||||||
|  |   return props.article?.author || currentUser.value; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const authorName = computed( | ||||||
|  |   () => author.value?.name || author.value?.available_name || '-' | ||||||
|  | ); | ||||||
|  | const authorThumbnailSrc = computed(() => author.value?.thumbnail); | ||||||
|  |  | ||||||
|  | const agentList = computed(() => { | ||||||
|  |   return [...agents.value] | ||||||
|  |     .sort((a, b) => a.name.localeCompare(b.name)) | ||||||
|  |     .map(agent => ({ | ||||||
|  |       label: agent.name, | ||||||
|  |       value: agent.id, | ||||||
|  |       thumbnail: { name: agent.name, src: agent.thumbnail }, | ||||||
|  |       isSelected: agent.id === props.article?.author?.id, | ||||||
|  |       action: 'assignAuthor', | ||||||
|  |     })) | ||||||
|  |     .sort((a, b) => b.isSelected - a.isSelected); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const hasAgentList = computed(() => { | ||||||
|  |   return agents.value?.length > 0; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const selectedCategory = computed(() => { | ||||||
|  |   if (isNewArticle.value) { | ||||||
|  |     return selectedCategoryId.value | ||||||
|  |       ? categories.value.find( | ||||||
|  |           category => category.id === selectedCategoryId.value | ||||||
|  |         ) | ||||||
|  |       : categories.value[0] || null; | ||||||
|  |   } | ||||||
|  |   return categories.value.find( | ||||||
|  |     category => category.id === props.article?.category?.id | ||||||
|  |   ); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const categoryList = computed(() => { | ||||||
|  |   return categories.value | ||||||
|  |     .map(category => ({ | ||||||
|  |       label: category.name, | ||||||
|  |       value: category.id, | ||||||
|  |       emoji: category.icon, | ||||||
|  |       isSelected: category.id === props.article?.category?.id, | ||||||
|  |       action: 'assignCategory', | ||||||
|  |     })) | ||||||
|  |     .sort((a, b) => b.isSelected - a.isSelected); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const hasCategoryMenuItems = computed(() => { | ||||||
|  |   return categoryList.value?.length > 0; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const handleArticleAction = ({ action, value }) => { | ||||||
|  |   const actions = { | ||||||
|  |     assignAuthor: () => { | ||||||
|  |       if (isNewArticle.value) { | ||||||
|  |         selectedAuthorId.value = value; | ||||||
|  |         emit('setAuthor', value); | ||||||
|  |       } else { | ||||||
|  |         emit('saveArticle', { author_id: value }); | ||||||
|  |       } | ||||||
|  |       openAgentsList.value = false; | ||||||
|  |     }, | ||||||
|  |     assignCategory: () => { | ||||||
|  |       if (isNewArticle.value) { | ||||||
|  |         selectedCategoryId.value = value; | ||||||
|  |         emit('setCategory', value); | ||||||
|  |       } else { | ||||||
|  |         emit('saveArticle', { category_id: value }); | ||||||
|  |       } | ||||||
|  |       openCategoryList.value = false; | ||||||
|  |     }, | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   actions[action]?.(); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const updateMeta = meta => { | ||||||
|  |   emit('saveArticle', { meta }); | ||||||
|  | }; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <div class="flex items-center gap-4"> | ||||||
|  |     <div class="relative flex items-center gap-2"> | ||||||
|  |       <OnClickOutside @trigger="openAgentsList = false"> | ||||||
|  |         <Button | ||||||
|  |           :label="authorName" | ||||||
|  |           variant="ghost" | ||||||
|  |           class="!px-0 font-normal" | ||||||
|  |           text-variant="info" | ||||||
|  |           @click="openAgentsList = !openAgentsList" | ||||||
|  |         > | ||||||
|  |           <template #leftPrefix> | ||||||
|  |             <Thumbnail | ||||||
|  |               v-if="author" | ||||||
|  |               :author="author" | ||||||
|  |               :name="authorName" | ||||||
|  |               :size="20" | ||||||
|  |               :src="authorThumbnailSrc" | ||||||
|  |             /> | ||||||
|  |           </template> | ||||||
|  |         </Button> | ||||||
|  |         <DropdownMenu | ||||||
|  |           v-if="openAgentsList && hasAgentList" | ||||||
|  |           :menu-items="agentList" | ||||||
|  |           class="z-[100] w-48 mt-2 overflow-y-auto ltr:left-0 rtl:right-0 top-full max-h-52" | ||||||
|  |           @action="handleArticleAction" | ||||||
|  |         /> | ||||||
|  |       </OnClickOutside> | ||||||
|  |     </div> | ||||||
|  |     <div class="w-px h-3 bg-slate-50 dark:bg-slate-800" /> | ||||||
|  |     <div class="relative"> | ||||||
|  |       <OnClickOutside @trigger="openCategoryList = false"> | ||||||
|  |         <Button | ||||||
|  |           :label=" | ||||||
|  |             selectedCategory?.name || | ||||||
|  |             t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.UNCATEGORIZED') | ||||||
|  |           " | ||||||
|  |           :emoji="selectedCategory?.icon || ''" | ||||||
|  |           :icon="!selectedCategory?.icon ? 'play-shape' : ''" | ||||||
|  |           variant="ghost" | ||||||
|  |           class="!px-2 font-normal" | ||||||
|  |           text-variant="info" | ||||||
|  |           @click="openCategoryList = !openCategoryList" | ||||||
|  |         /> | ||||||
|  |         <DropdownMenu | ||||||
|  |           v-if="openCategoryList && hasCategoryMenuItems" | ||||||
|  |           :menu-items="categoryList" | ||||||
|  |           class="w-48 mt-2 z-[100] overflow-y-auto left-0 top-full max-h-52" | ||||||
|  |           @action="handleArticleAction" | ||||||
|  |         /> | ||||||
|  |       </OnClickOutside> | ||||||
|  |     </div> | ||||||
|  |  | ||||||
|  |     <div class="w-px h-3 bg-slate-50 dark:bg-slate-800" /> | ||||||
|  |     <div class="relative"> | ||||||
|  |       <OnClickOutside @trigger="openProperties = false"> | ||||||
|  |         <Button | ||||||
|  |           :label=" | ||||||
|  |             t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.MORE_PROPERTIES') | ||||||
|  |           " | ||||||
|  |           icon="add" | ||||||
|  |           variant="ghost" | ||||||
|  |           :disabled="isNewArticle" | ||||||
|  |           text-variant="info" | ||||||
|  |           class="!px-2 font-normal" | ||||||
|  |           @click="openProperties = !openProperties" | ||||||
|  |         /> | ||||||
|  |         <ArticleEditorProperties | ||||||
|  |           v-if="openProperties" | ||||||
|  |           :article="article" | ||||||
|  |           class="right-0 z-[100] mt-2 xl:left-0 top-full" | ||||||
|  |           @save-article="updateMeta" | ||||||
|  |           @close="openProperties = false" | ||||||
|  |         /> | ||||||
|  |       </OnClickOutside> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,178 @@ | |||||||
|  | <script setup> | ||||||
|  | import { computed, ref } from 'vue'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  | import { useRoute } from 'vue-router'; | ||||||
|  | import { useStore } from 'dashboard/composables/store.js'; | ||||||
|  | import { useAlert, useTrack } from 'dashboard/composables'; | ||||||
|  | import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; | ||||||
|  | import { OnClickOutside } from '@vueuse/components'; | ||||||
|  | import { getArticleStatus } from 'dashboard/helper/portalHelper.js'; | ||||||
|  | import { | ||||||
|  |   ARTICLE_EDITOR_STATUS_OPTIONS, | ||||||
|  |   ARTICLE_STATUSES, | ||||||
|  |   ARTICLE_MENU_ITEMS, | ||||||
|  | } from 'dashboard/helper/portalHelper'; | ||||||
|  | import wootConstants from 'dashboard/constants/globals'; | ||||||
|  |  | ||||||
|  | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
|  | import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   isUpdating: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, | ||||||
|  |   isSaved: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, | ||||||
|  |   status: { | ||||||
|  |     type: String, | ||||||
|  |     default: '', | ||||||
|  |   }, | ||||||
|  |   articleId: { | ||||||
|  |     type: Number, | ||||||
|  |     default: 0, | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['goBack', 'previewArticle']); | ||||||
|  |  | ||||||
|  | const { t } = useI18n(); | ||||||
|  | const store = useStore(); | ||||||
|  | const route = useRoute(); | ||||||
|  |  | ||||||
|  | const isArticlePublishing = ref(false); | ||||||
|  |  | ||||||
|  | const { ARTICLE_STATUS_TYPES } = wootConstants; | ||||||
|  |  | ||||||
|  | const showArticleActionMenu = ref(false); | ||||||
|  |  | ||||||
|  | const articleMenuItems = computed(() => { | ||||||
|  |   const statusOptions = ARTICLE_EDITOR_STATUS_OPTIONS[props.status] ?? []; | ||||||
|  |   return statusOptions.map(option => { | ||||||
|  |     const { label, value, icon } = ARTICLE_MENU_ITEMS[option]; | ||||||
|  |     return { | ||||||
|  |       label: t(label), | ||||||
|  |       value, | ||||||
|  |       action: 'update-status', | ||||||
|  |       icon, | ||||||
|  |     }; | ||||||
|  |   }); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const statusText = computed(() => | ||||||
|  |   t( | ||||||
|  |     `HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.STATUS.${props.isUpdating ? 'SAVING' : 'SAVED'}` | ||||||
|  |   ) | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const onClickGoBack = () => emit('goBack'); | ||||||
|  |  | ||||||
|  | const previewArticle = () => emit('previewArticle'); | ||||||
|  |  | ||||||
|  | const getStatusMessage = (status, isSuccess) => { | ||||||
|  |   const messageType = isSuccess ? 'SUCCESS' : 'ERROR'; | ||||||
|  |   const statusMap = { | ||||||
|  |     [ARTICLE_STATUS_TYPES.PUBLISH]: 'PUBLISH_ARTICLE', | ||||||
|  |     [ARTICLE_STATUS_TYPES.ARCHIVE]: 'ARCHIVE_ARTICLE', | ||||||
|  |     [ARTICLE_STATUS_TYPES.DRAFT]: 'DRAFT_ARTICLE', | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   return statusMap[status] | ||||||
|  |     ? t(`HELP_CENTER.${statusMap[status]}.API.${messageType}`) | ||||||
|  |     : ''; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const updateArticleStatus = async ({ value }) => { | ||||||
|  |   showArticleActionMenu.value = false; | ||||||
|  |   const status = getArticleStatus(value); | ||||||
|  |   if (status === ARTICLE_STATUS_TYPES.PUBLISH) { | ||||||
|  |     isArticlePublishing.value = true; | ||||||
|  |   } | ||||||
|  |   const { portalSlug } = route.params; | ||||||
|  |  | ||||||
|  |   try { | ||||||
|  |     await store.dispatch('articles/update', { | ||||||
|  |       portalSlug, | ||||||
|  |       articleId: props.articleId, | ||||||
|  |       status, | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     useAlert(getStatusMessage(status, true)); | ||||||
|  |  | ||||||
|  |     if (status === ARTICLE_STATUS_TYPES.ARCHIVE) { | ||||||
|  |       useTrack(PORTALS_EVENTS.ARCHIVE_ARTICLE, { uiFrom: 'header' }); | ||||||
|  |     } else if (status === ARTICLE_STATUS_TYPES.PUBLISH) { | ||||||
|  |       useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE); | ||||||
|  |     } | ||||||
|  |     isArticlePublishing.value = false; | ||||||
|  |   } catch (error) { | ||||||
|  |     useAlert(error?.message ?? getStatusMessage(status, false)); | ||||||
|  |     isArticlePublishing.value = false; | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <div class="flex items-center justify-between h-20"> | ||||||
|  |     <Button | ||||||
|  |       :label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.BACK_TO_ARTICLES')" | ||||||
|  |       icon="chevron-lucide-left" | ||||||
|  |       icon-lib="lucide" | ||||||
|  |       variant="link" | ||||||
|  |       text-variant="info" | ||||||
|  |       size="sm" | ||||||
|  |       @click="onClickGoBack" | ||||||
|  |     /> | ||||||
|  |     <div class="flex items-center gap-4"> | ||||||
|  |       <span | ||||||
|  |         v-if="isUpdating || isSaved" | ||||||
|  |         class="text-xs font-medium transition-all duration-300 text-slate-500 dark:text-slate-400" | ||||||
|  |       > | ||||||
|  |         {{ statusText }} | ||||||
|  |       </span> | ||||||
|  |       <div class="flex items-center gap-2"> | ||||||
|  |         <Button | ||||||
|  |           :label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PREVIEW')" | ||||||
|  |           variant="secondary" | ||||||
|  |           size="sm" | ||||||
|  |           :disabled="!articleId" | ||||||
|  |           @click="previewArticle" | ||||||
|  |         /> | ||||||
|  |         <div class="flex items-center"> | ||||||
|  |           <Button | ||||||
|  |             :label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')" | ||||||
|  |             size="sm" | ||||||
|  |             class="ltr:rounded-r-none rtl:rounded-l-none" | ||||||
|  |             :is-loading="isArticlePublishing" | ||||||
|  |             :disabled=" | ||||||
|  |               status === ARTICLE_STATUSES.PUBLISHED || | ||||||
|  |               !articleId || | ||||||
|  |               isArticlePublishing | ||||||
|  |             " | ||||||
|  |             @click="updateArticleStatus({ value: ARTICLE_STATUSES.PUBLISHED })" | ||||||
|  |           /> | ||||||
|  |           <div class="relative"> | ||||||
|  |             <OnClickOutside @trigger="showArticleActionMenu = false"> | ||||||
|  |               <Button | ||||||
|  |                 icon="chevron-lucide-down" | ||||||
|  |                 icon-lib="lucide" | ||||||
|  |                 size="sm" | ||||||
|  |                 :disabled="!articleId" | ||||||
|  |                 class="ltr:rounded-l-none rtl:rounded-r-none" | ||||||
|  |                 @click.stop="showArticleActionMenu = !showArticleActionMenu" | ||||||
|  |               /> | ||||||
|  |               <DropdownMenu | ||||||
|  |                 v-if="showArticleActionMenu" | ||||||
|  |                 :menu-items="articleMenuItems" | ||||||
|  |                 class="mt-2 ltr:right-0 rtl:left-0 top-full" | ||||||
|  |                 @action="updateArticleStatus($event)" | ||||||
|  |               /> | ||||||
|  |             </OnClickOutside> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,134 @@ | |||||||
|  | <script setup> | ||||||
|  | import { reactive, watch, onMounted } from 'vue'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  | import { debounce } from '@chatwoot/utils'; | ||||||
|  |  | ||||||
|  | import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue'; | ||||||
|  | import TextArea from 'dashboard/components-next/textarea/TextArea.vue'; | ||||||
|  | import TagInput from 'dashboard/components-next/taginput/TagInput.vue'; | ||||||
|  | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   article: { | ||||||
|  |     type: Object, | ||||||
|  |     required: true, | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['saveArticle', 'close']); | ||||||
|  |  | ||||||
|  | const saveArticle = debounce(value => emit('saveArticle', value), 400, false); | ||||||
|  |  | ||||||
|  | const { t } = useI18n(); | ||||||
|  |  | ||||||
|  | const state = reactive({ | ||||||
|  |   title: '', | ||||||
|  |   description: '', | ||||||
|  |   tags: [], | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const updateState = () => { | ||||||
|  |   state.title = props.article.meta?.title || ''; | ||||||
|  |   state.description = props.article.meta?.description || ''; | ||||||
|  |   state.tags = props.article.meta?.tags || []; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | watch( | ||||||
|  |   state, | ||||||
|  |   newState => { | ||||||
|  |     saveArticle({ | ||||||
|  |       title: newState.title, | ||||||
|  |       description: newState.description, | ||||||
|  |       tags: newState.tags, | ||||||
|  |     }); | ||||||
|  |   }, | ||||||
|  |   { deep: true } | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | onMounted(() => { | ||||||
|  |   updateState(); | ||||||
|  | }); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <div | ||||||
|  |     class="flex flex-col absolute w-[400px] bg-n-alpha-3 backdrop-blur-[100px] shadow-lg gap-6 rounded-xl p-6" | ||||||
|  |   > | ||||||
|  |     <div class="flex items-center justify-between"> | ||||||
|  |       <h3> | ||||||
|  |         {{ | ||||||
|  |           t( | ||||||
|  |             'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.ARTICLE_PROPERTIES' | ||||||
|  |           ) | ||||||
|  |         }} | ||||||
|  |       </h3> | ||||||
|  |       <Button | ||||||
|  |         icon="dismiss" | ||||||
|  |         size="sm" | ||||||
|  |         variant="ghost" | ||||||
|  |         class="w-8 hover:text-n-slate-11" | ||||||
|  |         @click="emit('close')" | ||||||
|  |       /> | ||||||
|  |     </div> | ||||||
|  |     <div class="flex flex-col gap-2"> | ||||||
|  |       <div> | ||||||
|  |         <div class="flex justify-between w-full gap-4 py-2"> | ||||||
|  |           <label | ||||||
|  |             class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 dark:text-slate-50" | ||||||
|  |           > | ||||||
|  |             {{ | ||||||
|  |               t( | ||||||
|  |                 'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_DESCRIPTION' | ||||||
|  |               ) | ||||||
|  |             }} | ||||||
|  |           </label> | ||||||
|  |           <TextArea | ||||||
|  |             v-model="state.description" | ||||||
|  |             :placeholder=" | ||||||
|  |               t( | ||||||
|  |                 'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_DESCRIPTION_PLACEHOLDER' | ||||||
|  |               ) | ||||||
|  |             " | ||||||
|  |             class="w-[224px]" | ||||||
|  |             custom-text-area-wrapper-class="!p-0 !border-0 !rounded-none !bg-transparent transition-none" | ||||||
|  |             custom-text-area-class="max-h-[150px]" | ||||||
|  |             auto-height | ||||||
|  |             min-height="3rem" | ||||||
|  |           /> | ||||||
|  |         </div> | ||||||
|  |         <div class="flex justify-between w-full gap-2 py-2"> | ||||||
|  |           <InlineInput | ||||||
|  |             v-model="state.title" | ||||||
|  |             :placeholder=" | ||||||
|  |               t( | ||||||
|  |                 'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TITLE_PLACEHOLDER' | ||||||
|  |               ) | ||||||
|  |             " | ||||||
|  |             :label=" | ||||||
|  |               t('HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TITLE') | ||||||
|  |             " | ||||||
|  |             custom-label-class="min-w-[120px]" | ||||||
|  |           /> | ||||||
|  |         </div> | ||||||
|  |         <div class="flex justify-between w-full gap-2 py-2"> | ||||||
|  |           <label | ||||||
|  |             class="text-sm font-medium whitespace-nowrap min-w-[120px] text-slate-900 dark:text-slate-50" | ||||||
|  |           > | ||||||
|  |             {{ | ||||||
|  |               t('HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TAGS') | ||||||
|  |             }} | ||||||
|  |           </label> | ||||||
|  |           <TagInput | ||||||
|  |             v-model="state.tags" | ||||||
|  |             :placeholder=" | ||||||
|  |               t( | ||||||
|  |                 'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TAGS_PLACEHOLDER' | ||||||
|  |               ) | ||||||
|  |             " | ||||||
|  |             class="w-[224px]" | ||||||
|  |           /> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,197 @@ | |||||||
|  | <script setup> | ||||||
|  | import { ref, computed } from 'vue'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  | import { useRoute } from 'vue-router'; | ||||||
|  | import { OnClickOutside } from '@vueuse/components'; | ||||||
|  | import { | ||||||
|  |   ARTICLE_TABS, | ||||||
|  |   CATEGORY_ALL, | ||||||
|  |   ARTICLE_TABS_OPTIONS, | ||||||
|  | } from 'dashboard/helper/portalHelper'; | ||||||
|  |  | ||||||
|  | import TabBar from 'dashboard/components-next/tabbar/TabBar.vue'; | ||||||
|  | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
|  | import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   categories: { | ||||||
|  |     type: Array, | ||||||
|  |     required: true, | ||||||
|  |   }, | ||||||
|  |   allowedLocales: { | ||||||
|  |     type: Array, | ||||||
|  |     required: true, | ||||||
|  |   }, | ||||||
|  |   meta: { | ||||||
|  |     type: Object, | ||||||
|  |     required: true, | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits([ | ||||||
|  |   'tabChange', | ||||||
|  |   'localeChange', | ||||||
|  |   'categoryChange', | ||||||
|  |   'newArticle', | ||||||
|  | ]); | ||||||
|  |  | ||||||
|  | const route = useRoute(); | ||||||
|  | const { t } = useI18n(); | ||||||
|  |  | ||||||
|  | const isCategoryMenuOpen = ref(false); | ||||||
|  | const isLocaleMenuOpen = ref(false); | ||||||
|  |  | ||||||
|  | const countKey = tab => { | ||||||
|  |   if (tab.value === 'all') { | ||||||
|  |     return 'articlesCount'; | ||||||
|  |   } | ||||||
|  |   return `${tab.value}ArticlesCount`; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const tabs = computed(() => { | ||||||
|  |   return ARTICLE_TABS_OPTIONS.map(tab => ({ | ||||||
|  |     label: t(`HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.TABS.${tab.key}`), | ||||||
|  |     value: tab.value, | ||||||
|  |     count: props.meta[countKey(tab)], | ||||||
|  |   })); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const activeTabIndex = computed(() => { | ||||||
|  |   const tabParam = route.params.tab || ARTICLE_TABS.ALL; | ||||||
|  |   return tabs.value.findIndex(tab => tab.value === tabParam); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const activeCategoryName = computed(() => { | ||||||
|  |   const activeCategory = props.categories.find( | ||||||
|  |     category => category.slug === route.params.categorySlug | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   if (activeCategory) { | ||||||
|  |     const { icon, name } = activeCategory; | ||||||
|  |     return `${icon} ${name}`; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.CATEGORY.ALL'); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const activeLocaleName = computed(() => { | ||||||
|  |   return props.allowedLocales.find( | ||||||
|  |     locale => locale.code === route.params.locale | ||||||
|  |   )?.name; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const categoryMenuItems = computed(() => { | ||||||
|  |   const defaultMenuItem = { | ||||||
|  |     label: t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.CATEGORY.ALL'), | ||||||
|  |     value: CATEGORY_ALL, | ||||||
|  |     action: 'filter', | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const categoryItems = props.categories.map(category => ({ | ||||||
|  |     label: category.name, | ||||||
|  |     value: category.slug, | ||||||
|  |     action: 'filter', | ||||||
|  |     emoji: category.icon, | ||||||
|  |   })); | ||||||
|  |  | ||||||
|  |   const hasCategorySlug = !!route.params.categorySlug; | ||||||
|  |  | ||||||
|  |   return hasCategorySlug ? [defaultMenuItem, ...categoryItems] : categoryItems; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const hasCategoryMenuItems = computed(() => { | ||||||
|  |   return categoryMenuItems.value?.length > 0; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const localeMenuItems = computed(() => { | ||||||
|  |   return props.allowedLocales.map(locale => ({ | ||||||
|  |     label: locale.name, | ||||||
|  |     value: locale.code, | ||||||
|  |     action: 'filter', | ||||||
|  |   })); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const hasMoreThanOneLocaleMenuItems = computed(() => { | ||||||
|  |   return localeMenuItems.value?.length > 1; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const handleLocaleAction = ({ value }) => { | ||||||
|  |   emit('localeChange', value); | ||||||
|  |   isLocaleMenuOpen.value = false; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleCategoryAction = ({ value }) => { | ||||||
|  |   emit('categoryChange', value); | ||||||
|  |   isCategoryMenuOpen.value = false; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleNewArticle = () => { | ||||||
|  |   emit('newArticle'); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleTabChange = value => { | ||||||
|  |   emit('tabChange', value); | ||||||
|  | }; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <div class="flex flex-col items-start w-full gap-2 lg:flex-row"> | ||||||
|  |     <TabBar | ||||||
|  |       class="bg-n-solid-1" | ||||||
|  |       :tabs="tabs" | ||||||
|  |       :initial-active-tab="activeTabIndex" | ||||||
|  |       @tab-changed="handleTabChange" | ||||||
|  |     /> | ||||||
|  |     <div class="flex items-start justify-between w-full gap-2"> | ||||||
|  |       <div class="flex items-center gap-2"> | ||||||
|  |         <div v-if="hasMoreThanOneLocaleMenuItems" class="relative group"> | ||||||
|  |           <OnClickOutside @trigger="isLocaleMenuOpen = false"> | ||||||
|  |             <Button | ||||||
|  |               :label="activeLocaleName" | ||||||
|  |               size="sm" | ||||||
|  |               icon-position="right" | ||||||
|  |               icon="chevron-lucide-down" | ||||||
|  |               icon-lib="lucide" | ||||||
|  |               variant="secondary" | ||||||
|  |               @click="isLocaleMenuOpen = !isLocaleMenuOpen" | ||||||
|  |             /> | ||||||
|  |  | ||||||
|  |             <DropdownMenu | ||||||
|  |               v-if="isLocaleMenuOpen" | ||||||
|  |               :menu-items="localeMenuItems" | ||||||
|  |               class="left-0 w-40 max-w-[300px] mt-2 overflow-y-auto xl:right-0 top-full max-h-60" | ||||||
|  |               @action="handleLocaleAction" | ||||||
|  |             /> | ||||||
|  |           </OnClickOutside> | ||||||
|  |         </div> | ||||||
|  |         <div v-if="hasCategoryMenuItems" class="relative group"> | ||||||
|  |           <OnClickOutside @trigger="isCategoryMenuOpen = false"> | ||||||
|  |             <Button | ||||||
|  |               :label="activeCategoryName" | ||||||
|  |               size="sm" | ||||||
|  |               icon-position="right" | ||||||
|  |               icon="chevron-lucide-down" | ||||||
|  |               icon-lib="lucide" | ||||||
|  |               variant="secondary" | ||||||
|  |               class="max-w-48" | ||||||
|  |               @click="isCategoryMenuOpen = !isCategoryMenuOpen" | ||||||
|  |             /> | ||||||
|  |  | ||||||
|  |             <DropdownMenu | ||||||
|  |               v-if="isCategoryMenuOpen" | ||||||
|  |               :menu-items="categoryMenuItems" | ||||||
|  |               class="left-0 w-48 mt-2 overflow-y-auto xl:right-0 top-full max-h-60" | ||||||
|  |               @action="handleCategoryAction" | ||||||
|  |             /> | ||||||
|  |           </OnClickOutside> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |       <Button | ||||||
|  |         :label="t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.NEW_ARTICLE')" | ||||||
|  |         icon="add" | ||||||
|  |         size="sm" | ||||||
|  |         @click="handleNewArticle" | ||||||
|  |       /> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
| @@ -1,25 +1,190 @@ | |||||||
| <script setup> | <script setup> | ||||||
|  | import { ref, computed, watch } from 'vue'; | ||||||
|  | import Draggable from 'vuedraggable'; | ||||||
|  | import { useMapGetter, useStore } from 'dashboard/composables/store.js'; | ||||||
|  | import { useRouter, useRoute } from 'vue-router'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  | import { useAlert, useTrack } from 'dashboard/composables'; | ||||||
|  | import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; | ||||||
|  | import { getArticleStatus } from 'dashboard/helper/portalHelper.js'; | ||||||
|  | import wootConstants from 'dashboard/constants/globals'; | ||||||
|  |  | ||||||
| import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue'; | import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue'; | ||||||
|  |  | ||||||
| defineProps({ | const props = defineProps({ | ||||||
|   articles: { |   articles: { | ||||||
|     type: Array, |     type: Array, | ||||||
|     required: true, |     required: true, | ||||||
|   }, |   }, | ||||||
|  |   isCategoryArticles: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  | const { ARTICLE_STATUS_TYPES } = wootConstants; | ||||||
|  |  | ||||||
|  | const router = useRouter(); | ||||||
|  | const route = useRoute(); | ||||||
|  | const store = useStore(); | ||||||
|  | const { t } = useI18n(); | ||||||
|  |  | ||||||
|  | const localArticles = ref(props.articles); | ||||||
|  |  | ||||||
|  | const dragEnabled = computed(() => { | ||||||
|  |   // Enable dragging only for category articles and when there's more than one article | ||||||
|  |   return props.isCategoryArticles && localArticles.value?.length > 1; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const getCategoryById = useMapGetter('categories/categoryById'); | ||||||
|  |  | ||||||
|  | const openArticle = id => { | ||||||
|  |   const { tab, categorySlug, locale } = route.params; | ||||||
|  |   if (props.isCategoryArticles) { | ||||||
|  |     router.push({ | ||||||
|  |       name: 'portals_categories_articles_edit', | ||||||
|  |       params: { articleSlug: id }, | ||||||
|  |     }); | ||||||
|  |   } else { | ||||||
|  |     router.push({ | ||||||
|  |       name: 'portals_articles_edit', | ||||||
|  |       params: { | ||||||
|  |         articleSlug: id, | ||||||
|  |         tab, | ||||||
|  |         categorySlug, | ||||||
|  |         locale, | ||||||
|  |       }, | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const onReorder = reorderedGroup => { | ||||||
|  |   store.dispatch('articles/reorder', { | ||||||
|  |     reorderedGroup, | ||||||
|  |     portalSlug: route.params.portalSlug, | ||||||
|  |   }); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const onDragEnd = () => { | ||||||
|  |   // Reuse existing positions to maintain order within the current group | ||||||
|  |   const sortedArticlePositions = localArticles.value | ||||||
|  |     .map(article => article.position) | ||||||
|  |     .sort((a, b) => a - b); // Use custom sort to handle numeric values correctly | ||||||
|  |  | ||||||
|  |   const orderedArticles = localArticles.value.map(article => article.id); | ||||||
|  |  | ||||||
|  |   // Create a map of article IDs to their new positions | ||||||
|  |   const reorderedGroup = orderedArticles.reduce((obj, key, index) => { | ||||||
|  |     obj[key] = sortedArticlePositions[index]; | ||||||
|  |     return obj; | ||||||
|  |   }, {}); | ||||||
|  |  | ||||||
|  |   onReorder(reorderedGroup); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const getCategory = categoryId => { | ||||||
|  |   return getCategoryById.value(categoryId) || { name: '', icon: '' }; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const getStatusMessage = (status, isSuccess) => { | ||||||
|  |   const messageType = isSuccess ? 'SUCCESS' : 'ERROR'; | ||||||
|  |   const statusMap = { | ||||||
|  |     [ARTICLE_STATUS_TYPES.PUBLISH]: 'PUBLISH_ARTICLE', | ||||||
|  |     [ARTICLE_STATUS_TYPES.ARCHIVE]: 'ARCHIVE_ARTICLE', | ||||||
|  |     [ARTICLE_STATUS_TYPES.DRAFT]: 'DRAFT_ARTICLE', | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   return statusMap[status] | ||||||
|  |     ? t(`HELP_CENTER.${statusMap[status]}.API.${messageType}`) | ||||||
|  |     : ''; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const updateMeta = () => { | ||||||
|  |   const { portalSlug, locale } = route.params; | ||||||
|  |   return store.dispatch('portals/show', { portalSlug, locale }); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleArticleAction = async (action, { status, id }) => { | ||||||
|  |   const { portalSlug } = route.params; | ||||||
|  |   try { | ||||||
|  |     if (action === 'delete') { | ||||||
|  |       await store.dispatch('articles/delete', { | ||||||
|  |         portalSlug, | ||||||
|  |         articleId: id, | ||||||
|  |       }); | ||||||
|  |       useAlert(t('HELP_CENTER.DELETE_ARTICLE.API.SUCCESS_MESSAGE')); | ||||||
|  |     } else { | ||||||
|  |       await store.dispatch('articles/update', { | ||||||
|  |         portalSlug, | ||||||
|  |         articleId: id, | ||||||
|  |         status, | ||||||
|  |       }); | ||||||
|  |       useAlert(getStatusMessage(status, true)); | ||||||
|  |  | ||||||
|  |       if (status === ARTICLE_STATUS_TYPES.ARCHIVE) { | ||||||
|  |         useTrack(PORTALS_EVENTS.ARCHIVE_ARTICLE, { uiFrom: 'header' }); | ||||||
|  |       } else if (status === ARTICLE_STATUS_TYPES.PUBLISH) { | ||||||
|  |         useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     await updateMeta(); | ||||||
|  |   } catch (error) { | ||||||
|  |     const errorMessage = | ||||||
|  |       error?.message || | ||||||
|  |       (action === 'delete' | ||||||
|  |         ? t('HELP_CENTER.DELETE_ARTICLE.API.ERROR_MESSAGE') | ||||||
|  |         : getStatusMessage(status, false)); | ||||||
|  |     useAlert(errorMessage); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const updateArticle = ({ action, value, id }) => { | ||||||
|  |   const status = action !== 'delete' ? getArticleStatus(value) : null; | ||||||
|  |   handleArticleAction(action, { status, id }); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | // Watch for changes in the articles prop and update the localArticles ref | ||||||
|  | watch( | ||||||
|  |   () => props.articles, | ||||||
|  |   newArticles => { | ||||||
|  |     localArticles.value = newArticles; | ||||||
|  |   }, | ||||||
|  |   { deep: true } | ||||||
|  | ); | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <template> | <template> | ||||||
|   <ul role="list" class="w-full h-full space-y-4"> |   <Draggable | ||||||
|     <ArticleCard |     v-model="localArticles" | ||||||
|       v-for="article in articles" |     :disabled="!dragEnabled" | ||||||
|       :key="article.title" |     item-key="id" | ||||||
|       :title="article.title" |     tag="ul" | ||||||
|       :status="article.status" |     ghost-class="article-ghost-class" | ||||||
|       :author="article.author" |     class="w-full h-full space-y-4" | ||||||
|       :category="article.category" |     @end="onDragEnd" | ||||||
|       :views="article.views" |   > | ||||||
|       :updated-at="article.updatedAt" |     <template #item="{ element }"> | ||||||
|     /> |       <li class="list-none rounded-2xl"> | ||||||
|   </ul> |         <ArticleCard | ||||||
|  |           :id="element.id" | ||||||
|  |           :key="element.id" | ||||||
|  |           :title="element.title" | ||||||
|  |           :status="element.status" | ||||||
|  |           :author="element.author" | ||||||
|  |           :category="getCategory(element.category.id)" | ||||||
|  |           :views="element.views || 0" | ||||||
|  |           :updated-at="element.updatedAt" | ||||||
|  |           :class="{ 'cursor-grab': dragEnabled }" | ||||||
|  |           @open-article="openArticle" | ||||||
|  |           @article-action="updateArticle" | ||||||
|  |         /> | ||||||
|  |       </li> | ||||||
|  |     </template> | ||||||
|  |   </Draggable> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
|  | <style lang="scss" scoped> | ||||||
|  | .article-ghost-class { | ||||||
|  |   @apply opacity-50 bg-n-solid-1; | ||||||
|  | } | ||||||
|  | </style> | ||||||
|   | |||||||
| @@ -1,74 +1,182 @@ | |||||||
| <script setup> | <script setup> | ||||||
| import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue'; | import { computed } from 'vue'; | ||||||
| import TabBar from 'dashboard/components-next/tabbar/TabBar.vue'; | import { useRouter, useRoute } from 'vue-router'; | ||||||
| import Button from 'dashboard/components-next/button/Button.vue'; | import { useI18n } from 'vue-i18n'; | ||||||
| import ArticleList from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue'; | import { useMapGetter } from 'dashboard/composables/store.js'; | ||||||
|  | import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper'; | ||||||
|  |  | ||||||
| defineProps({ | import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue'; | ||||||
|  | import ArticleList from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue'; | ||||||
|  | import ArticleHeaderControls from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleHeaderControls.vue'; | ||||||
|  | import CategoryHeaderControls from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryHeaderControls.vue'; | ||||||
|  | import Spinner from 'dashboard/components-next/spinner/Spinner.vue'; | ||||||
|  | import ArticleEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/Article/ArticleEmptyState.vue'; | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|   articles: { |   articles: { | ||||||
|     type: Array, |     type: Array, | ||||||
|     required: true, |     required: true, | ||||||
|   }, |   }, | ||||||
|  |   categories: { | ||||||
|  |     type: Array, | ||||||
|  |     required: true, | ||||||
|  |   }, | ||||||
|  |   allowedLocales: { | ||||||
|  |     type: Array, | ||||||
|  |     required: true, | ||||||
|  |   }, | ||||||
|  |   portalName: { | ||||||
|  |     type: String, | ||||||
|  |     required: true, | ||||||
|  |   }, | ||||||
|  |   meta: { | ||||||
|  |     type: Object, | ||||||
|  |     required: true, | ||||||
|  |   }, | ||||||
|  |   isCategoryArticles: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const tabs = [ | const emit = defineEmits(['pageChange', 'fetchPortal']); | ||||||
|   { label: 'All articles', count: 24 }, |  | ||||||
|   { label: 'Mine', count: 13 }, | const router = useRouter(); | ||||||
|   { label: 'Draft', count: 5 }, | const route = useRoute(); | ||||||
|   { label: 'Archived', count: 11 }, | const { t } = useI18n(); | ||||||
| ]; |  | ||||||
| // TODO: remove comments | const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal'); | ||||||
| // eslint-disable-next-line no-unused-vars | const isFetching = useMapGetter('articles/isFetching'); | ||||||
| const handleTabChange = tab => { |  | ||||||
|   // TODO: Implement tab change logic | const hasNoArticles = computed( | ||||||
|  |   () => !isFetching.value && !props.articles.length | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const isLoading = computed(() => isFetching.value || isSwitchingPortal.value); | ||||||
|  |  | ||||||
|  | const totalArticlesCount = computed(() => props.meta.allArticlesCount); | ||||||
|  |  | ||||||
|  | const hasNoArticlesInPortal = computed( | ||||||
|  |   () => totalArticlesCount.value === 0 && !props.isCategoryArticles | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const shouldShowPaginationFooter = computed(() => { | ||||||
|  |   return !(isFetching.value || isSwitchingPortal.value || hasNoArticles.value); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const updateRoute = newParams => { | ||||||
|  |   const { portalSlug, locale, tab, categorySlug } = route.params; | ||||||
|  |   router.push({ | ||||||
|  |     name: 'portals_articles_index', | ||||||
|  |     params: { | ||||||
|  |       portalSlug, | ||||||
|  |       locale: newParams.locale ?? locale, | ||||||
|  |       tab: newParams.tab ?? tab, | ||||||
|  |       categorySlug: newParams.categorySlug ?? categorySlug, | ||||||
|  |       ...newParams, | ||||||
|  |     }, | ||||||
|  |   }); | ||||||
| }; | }; | ||||||
| // eslint-disable-next-line no-unused-vars |  | ||||||
| const handlePageChange = page => { | const articlesCount = computed(() => { | ||||||
|   // TODO: Implement page change logic |   const { tab } = route.params; | ||||||
|  |   const { meta } = props; | ||||||
|  |   const countMap = { | ||||||
|  |     '': meta.articlesCount, | ||||||
|  |     mine: meta.mineArticlesCount, | ||||||
|  |     draft: meta.draftArticlesCount, | ||||||
|  |     archived: meta.archivedArticlesCount, | ||||||
|  |   }; | ||||||
|  |   return Number(countMap[tab] || countMap['']); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const showArticleHeaderControls = computed( | ||||||
|  |   () => | ||||||
|  |     !hasNoArticlesInPortal.value && | ||||||
|  |     !props.isCategoryArticles && | ||||||
|  |     !isSwitchingPortal.value | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const showCategoryHeaderControls = computed( | ||||||
|  |   () => props.isCategoryArticles && !isSwitchingPortal.value | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const getEmptyStateText = type => { | ||||||
|  |   if (props.isCategoryArticles) { | ||||||
|  |     return t(`HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.CATEGORY.${type}`); | ||||||
|  |   } | ||||||
|  |   const tabName = route.params.tab?.toUpperCase() || 'ALL'; | ||||||
|  |   return t(`HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.${tabName}.${type}`); | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | const getEmptyStateTitle = computed(() => getEmptyStateText('TITLE')); | ||||||
|  | const getEmptyStateSubtitle = computed(() => getEmptyStateText('SUBTITLE')); | ||||||
|  |  | ||||||
|  | const handleTabChange = tab => | ||||||
|  |   updateRoute({ tab: tab.value === ARTICLE_TABS.ALL ? '' : tab.value }); | ||||||
|  | const handleCategoryAction = value => | ||||||
|  |   updateRoute({ categorySlug: value === CATEGORY_ALL ? '' : value }); | ||||||
|  | const handleLocaleAction = value => { | ||||||
|  |   updateRoute({ locale: value, categorySlug: '' }); | ||||||
|  |   emit('fetchPortal', value); | ||||||
|  | }; | ||||||
|  | const handlePageChange = page => emit('pageChange', page); | ||||||
|  | const navigateToNewArticlePage = () => | ||||||
|  |   router.push({ name: 'portals_articles_new' }); | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <template> | <template> | ||||||
|   <HelpCenterLayout |   <HelpCenterLayout | ||||||
|     :current-page="1" |     :current-page="Number(meta.currentPage)" | ||||||
|     :total-items="100" |     :total-items="articlesCount" | ||||||
|     :items-per-page="10" |     :items-per-page="25" | ||||||
|  |     :header="portalName" | ||||||
|  |     :show-pagination-footer="shouldShowPaginationFooter" | ||||||
|     @update:current-page="handlePageChange" |     @update:current-page="handlePageChange" | ||||||
|   > |   > | ||||||
|     <template #header-actions> |     <template #header-actions> | ||||||
|       <div class="flex items-end justify-between"> |       <div class="flex items-end justify-between"> | ||||||
|         <div class="flex flex-col items-start w-full gap-2 lg:flex-row"> |         <ArticleHeaderControls | ||||||
|           <TabBar |           v-if="showArticleHeaderControls" | ||||||
|             :tabs="tabs" |           :categories="categories" | ||||||
|             :initial-active-tab="1" |           :allowed-locales="allowedLocales" | ||||||
|             @tab-changed="handleTabChange" |           :meta="meta" | ||||||
|           /> |           @tab-change="handleTabChange" | ||||||
|           <div class="flex items-start justify-between w-full gap-2"> |           @locale-change="handleLocaleAction" | ||||||
|             <div class="flex items-center gap-2"> |           @category-change="handleCategoryAction" | ||||||
|               <Button |           @new-article="navigateToNewArticlePage" | ||||||
|                 label="English" |         /> | ||||||
|                 size="sm" |         <CategoryHeaderControls | ||||||
|                 icon-position="right" |           v-else-if="showCategoryHeaderControls" | ||||||
|                 icon="chevron-lucide-down" |           :categories="categories" | ||||||
|                 icon-lib="lucide" |           :allowed-locales="allowedLocales" | ||||||
|                 variant="secondary" |           :has-selected-category="isCategoryArticles" | ||||||
|               /> |         /> | ||||||
|               <Button |  | ||||||
|                 label="All categories" |  | ||||||
|                 size="sm" |  | ||||||
|                 icon-position="right" |  | ||||||
|                 icon="chevron-lucide-down" |  | ||||||
|                 icon-lib="lucide" |  | ||||||
|                 variant="secondary" |  | ||||||
|               /> |  | ||||||
|             </div> |  | ||||||
|             <Button label="New article" icon="add" size="sm" /> |  | ||||||
|           </div> |  | ||||||
|         </div> |  | ||||||
|       </div> |       </div> | ||||||
|     </template> |     </template> | ||||||
|     <template #content> |     <template #content> | ||||||
|       <ArticleList :articles="articles" /> |       <div | ||||||
|  |         v-if="isLoading" | ||||||
|  |         class="flex items-center justify-center py-10 text-n-slate-11" | ||||||
|  |       > | ||||||
|  |         <Spinner /> | ||||||
|  |       </div> | ||||||
|  |       <ArticleList | ||||||
|  |         v-else-if="!hasNoArticles" | ||||||
|  |         :articles="articles" | ||||||
|  |         :is-category-articles="isCategoryArticles" | ||||||
|  |       /> | ||||||
|  |       <ArticleEmptyState | ||||||
|  |         v-else | ||||||
|  |         class="pt-14" | ||||||
|  |         :title="getEmptyStateTitle" | ||||||
|  |         :subtitle="getEmptyStateSubtitle" | ||||||
|  |         :show-button="hasNoArticlesInPortal" | ||||||
|  |         :button-label=" | ||||||
|  |           t('HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.ALL.BUTTON_LABEL') | ||||||
|  |         " | ||||||
|  |         @click="navigateToNewArticlePage" | ||||||
|  |       /> | ||||||
|     </template> |     </template> | ||||||
|   </HelpCenterLayout> |   </HelpCenterLayout> | ||||||
| </template> | </template> | ||||||
|   | |||||||
| @@ -1,101 +1,139 @@ | |||||||
| <script setup> | <script setup> | ||||||
| import { ref, computed } from 'vue'; | import { ref, computed } from 'vue'; | ||||||
| // import { OnClickOutside } from '@vueuse/components'; | import { useRoute, useRouter } from 'vue-router'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  | import { useStore, useMapGetter } from 'dashboard/composables/store'; | ||||||
|  | import { useAlert, useTrack } from 'dashboard/composables'; | ||||||
|  | import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; | ||||||
|  |  | ||||||
| import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue'; | import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue'; | ||||||
| import Button from 'dashboard/components-next/button/Button.vue'; |  | ||||||
| import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue'; |  | ||||||
| import CategoryList from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryList.vue'; | import CategoryList from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryList.vue'; | ||||||
| import ArticleList from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue'; | import CategoryHeaderControls from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryHeaderControls.vue'; | ||||||
| // import EditCategory from 'dashboard/playground/HelpCenter/components/EditCategory.vue'; | import CategoryEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/Category/CategoryEmptyState.vue'; | ||||||
|  | import EditCategoryDialog from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/EditCategoryDialog.vue'; | ||||||
|  | import Spinner from 'dashboard/components-next/spinner/Spinner.vue'; | ||||||
|  |  | ||||||
| const props = defineProps({ | const props = defineProps({ | ||||||
|   categories: { |   categories: { | ||||||
|     type: Array, |     type: Array, | ||||||
|     required: true, |     required: true, | ||||||
|   }, |   }, | ||||||
|  |   isFetching: { | ||||||
|  |     type: Boolean, | ||||||
|  |     required: false, | ||||||
|  |   }, | ||||||
|  |   allowedLocales: { | ||||||
|  |     type: Array, | ||||||
|  |     required: true, | ||||||
|  |   }, | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['fetchCategories']); | ||||||
|  |  | ||||||
|  | const route = useRoute(); | ||||||
|  | const router = useRouter(); | ||||||
|  | const store = useStore(); | ||||||
|  | const { t } = useI18n(); | ||||||
|  |  | ||||||
|  | const editCategoryDialog = ref(null); | ||||||
| const selectedCategory = ref(null); | const selectedCategory = ref(null); | ||||||
| // const showEditCategory = ref(false); |  | ||||||
|  |  | ||||||
| // const openEditCategory = () => { | const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal'); | ||||||
| //   showEditCategory.value = true; | const isLoading = computed(() => props.isFetching || isSwitchingPortal.value); | ||||||
| // }; | const hasCategories = computed(() => props.categories?.length > 0); | ||||||
| // const closeEditCategory = () => { |  | ||||||
| //   showEditCategory.value = false; |  | ||||||
| // }; |  | ||||||
|  |  | ||||||
| const breadcrumbItems = computed(() => { | const updateRoute = (newParams, routeName) => { | ||||||
|   const items = [{ label: 'Categories (en-US)', link: '#' }]; |   const { accountId, portalSlug, locale } = route.params; | ||||||
|   if (selectedCategory.value) { |   const baseParams = { accountId, portalSlug, locale }; | ||||||
|     items.push({ |  | ||||||
|       label: selectedCategory.value.title, |   router.push({ | ||||||
|       count: selectedCategory.value.articles.length, |     name: routeName, | ||||||
|  |     params: { | ||||||
|  |       ...baseParams, | ||||||
|  |       ...newParams, | ||||||
|  |       categorySlug: newParams.categorySlug, | ||||||
|  |     }, | ||||||
|  |   }); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const openCategoryArticles = slug => { | ||||||
|  |   updateRoute({ categorySlug: slug }, 'portals_categories_articles_index'); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleLocaleChange = value => { | ||||||
|  |   updateRoute({ locale: value }, 'portals_categories_index'); | ||||||
|  |   emit('fetchCategories', value); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | async function deleteCategory(category) { | ||||||
|  |   try { | ||||||
|  |     await store.dispatch('categories/delete', { | ||||||
|  |       portalSlug: route.params.portalSlug, | ||||||
|  |       categoryId: category.id, | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|  |     useTrack(PORTALS_EVENTS.DELETE_CATEGORY, { | ||||||
|  |       hasArticles: category?.meta?.articles_count > 0, | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     useAlert( | ||||||
|  |       t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.DELETE.API.SUCCESS_MESSAGE') | ||||||
|  |     ); | ||||||
|  |   } catch (error) { | ||||||
|  |     useAlert( | ||||||
|  |       error.message || | ||||||
|  |         t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.DELETE.API.ERROR_MESSAGE') | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const handleAction = ({ action, id, category: categoryData }) => { | ||||||
|  |   if (action === 'edit') { | ||||||
|  |     selectedCategory.value = props.categories.find( | ||||||
|  |       category => category.id === id | ||||||
|  |     ); | ||||||
|  |     editCategoryDialog.value.dialogRef.open(); | ||||||
|  |   } | ||||||
|  |   if (action === 'delete') { | ||||||
|  |     deleteCategory(categoryData); | ||||||
|   } |   } | ||||||
|   return items; |  | ||||||
| }); |  | ||||||
| const openCategoryArticles = id => { |  | ||||||
|   selectedCategory.value = props.categories.find( |  | ||||||
|     category => category.id === id |  | ||||||
|   ); |  | ||||||
| }; | }; | ||||||
| const resetCategory = () => { |  | ||||||
|   selectedCategory.value = null; |  | ||||||
| }; |  | ||||||
| const displayedArticles = computed(() => { |  | ||||||
|   return selectedCategory.value ? selectedCategory.value.articles : []; |  | ||||||
| }); |  | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <!-- eslint-disable vue/no-bare-strings-in-template --> |  | ||||||
| <template> | <template> | ||||||
|   <HelpCenterLayout :show-pagination-footer="false"> |   <HelpCenterLayout :show-pagination-footer="false"> | ||||||
|     <template #header-actions> |     <template #header-actions> | ||||||
|       <div class="flex items-center justify-between"> |       <CategoryHeaderControls | ||||||
|         <div v-if="!selectedCategory" class="flex items-center gap-4"> |         :categories="categories" | ||||||
|           <Button |         :is-category-articles="false" | ||||||
|             label="English" |         :allowed-locales="allowedLocales" | ||||||
|             size="sm" |         @locale-change="handleLocaleChange" | ||||||
|             icon-position="right" |       /> | ||||||
|             icon="chevron-lucide-down" |  | ||||||
|             icon-lib="lucide" |  | ||||||
|             variant="secondary" |  | ||||||
|           /> |  | ||||||
|           <div |  | ||||||
|             class="w-px h-3.5 rounded my-auto bg-slate-75 dark:bg-slate-800" |  | ||||||
|           /> |  | ||||||
|           <span class="text-sm font-medium text-slate-800 dark:text-slate-100"> |  | ||||||
|             {{ categories.length }} categories |  | ||||||
|           </span> |  | ||||||
|         </div> |  | ||||||
|         <Breadcrumb v-else :items="breadcrumbItems" @click="resetCategory" /> |  | ||||||
|         <Button |  | ||||||
|           v-if="!selectedCategory" |  | ||||||
|           label="New category" |  | ||||||
|           icon="add" |  | ||||||
|           size="sm" |  | ||||||
|         /> |  | ||||||
|         <div v-else class="relative"> |  | ||||||
|           <Button |  | ||||||
|             label="Edit category" |  | ||||||
|             variant="secondary" |  | ||||||
|             size="sm" |  | ||||||
|             @click="openEditCategory" |  | ||||||
|           /> |  | ||||||
|           <!-- <OnClickOutside @trigger="closeEditCategory"> |  | ||||||
|             <EditCategory v-if="showEditCategory" @close="closeEditCategory" /> |  | ||||||
|           </OnClickOutside> --> |  | ||||||
|         </div> |  | ||||||
|       </div> |  | ||||||
|     </template> |     </template> | ||||||
|     <template #content> |     <template #content> | ||||||
|  |       <div | ||||||
|  |         v-if="isLoading" | ||||||
|  |         class="flex items-center justify-center py-10 text-n-slate-11" | ||||||
|  |       > | ||||||
|  |         <Spinner /> | ||||||
|  |       </div> | ||||||
|       <CategoryList |       <CategoryList | ||||||
|         v-if="!selectedCategory" |         v-else-if="hasCategories" | ||||||
|         :categories="categories" |         :categories="categories" | ||||||
|         @click="openCategoryArticles" |         @click="openCategoryArticles" | ||||||
|  |         @action="handleAction" | ||||||
|  |       /> | ||||||
|  |       <CategoryEmptyState | ||||||
|  |         v-else | ||||||
|  |         class="pt-14" | ||||||
|  |         :title="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_EMPTY_STATE.TITLE')" | ||||||
|  |         :subtitle="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_EMPTY_STATE.SUBTITLE')" | ||||||
|       /> |       /> | ||||||
|       <ArticleList v-else :articles="displayedArticles" /> |  | ||||||
|     </template> |     </template> | ||||||
|  |     <EditCategoryDialog | ||||||
|  |       ref="editCategoryDialog" | ||||||
|  |       :allowed-locales="allowedLocales" | ||||||
|  |       :selected-category="selectedCategory" | ||||||
|  |     /> | ||||||
|   </HelpCenterLayout> |   </HelpCenterLayout> | ||||||
| </template> | </template> | ||||||
|   | |||||||
| @@ -0,0 +1,112 @@ | |||||||
|  | <script setup> | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  | import { useStore } from 'dashboard/composables/store'; | ||||||
|  | import { useAlert, useTrack } from 'dashboard/composables'; | ||||||
|  | import { useRoute } from 'vue-router'; | ||||||
|  | import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; | ||||||
|  |  | ||||||
|  | import CategoryForm from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryForm.vue'; | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   mode: { | ||||||
|  |     type: String, | ||||||
|  |     default: 'edit', | ||||||
|  |     validator: value => ['edit', 'create'].includes(value), | ||||||
|  |   }, | ||||||
|  |   selectedCategory: { | ||||||
|  |     type: Object, | ||||||
|  |     default: () => ({}), | ||||||
|  |   }, | ||||||
|  |   portalName: { | ||||||
|  |     type: String, | ||||||
|  |     default: '', | ||||||
|  |   }, | ||||||
|  |   activeLocaleName: { | ||||||
|  |     type: String, | ||||||
|  |     default: '', | ||||||
|  |   }, | ||||||
|  |   activeLocaleCode: { | ||||||
|  |     type: String, | ||||||
|  |     default: '', | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['close']); | ||||||
|  |  | ||||||
|  | const store = useStore(); | ||||||
|  | const { t } = useI18n(); | ||||||
|  | const route = useRoute(); | ||||||
|  |  | ||||||
|  | const handleCategory = async formData => { | ||||||
|  |   const { id, name, slug, icon, description, locale } = formData; | ||||||
|  |   const categoryData = { name, icon, slug, description }; | ||||||
|  |  | ||||||
|  |   if (props.mode === 'create') { | ||||||
|  |     categoryData.locale = locale; | ||||||
|  |   } else { | ||||||
|  |     categoryData.id = id; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   try { | ||||||
|  |     const action = props.mode === 'edit' ? 'update' : 'create'; | ||||||
|  |     const payload = { | ||||||
|  |       portalSlug: route.params.portalSlug, | ||||||
|  |       categoryObj: categoryData, | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     if (action === 'update') { | ||||||
|  |       payload.categoryId = id; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     await store.dispatch(`categories/${action}`, payload); | ||||||
|  |  | ||||||
|  |     const successMessage = t( | ||||||
|  |       `HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.${props.mode.toUpperCase()}.API.SUCCESS_MESSAGE` | ||||||
|  |     ); | ||||||
|  |     useAlert(successMessage); | ||||||
|  |  | ||||||
|  |     const trackEvent = | ||||||
|  |       props.mode === 'edit' | ||||||
|  |         ? PORTALS_EVENTS.EDIT_CATEGORY | ||||||
|  |         : PORTALS_EVENTS.CREATE_CATEGORY; | ||||||
|  |     useTrack( | ||||||
|  |       trackEvent, | ||||||
|  |       props.mode === 'create' | ||||||
|  |         ? { hasDescription: Boolean(description) } | ||||||
|  |         : undefined | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     emit('close'); | ||||||
|  |   } catch (error) { | ||||||
|  |     const errorMessage = | ||||||
|  |       error?.message || | ||||||
|  |       t( | ||||||
|  |         `HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.${props.mode.toUpperCase()}.API.ERROR_MESSAGE` | ||||||
|  |       ); | ||||||
|  |     useAlert(errorMessage); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <div | ||||||
|  |     class="w-[400px] absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6" | ||||||
|  |   > | ||||||
|  |     <h3 class="text-base font-medium text-slate-900 dark:text-slate-50"> | ||||||
|  |       {{ | ||||||
|  |         t( | ||||||
|  |           `HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.HEADER.${mode.toUpperCase()}` | ||||||
|  |         ) | ||||||
|  |       }} | ||||||
|  |     </h3> | ||||||
|  |     <CategoryForm | ||||||
|  |       :mode="mode" | ||||||
|  |       :selected-category="selectedCategory" | ||||||
|  |       :active-locale-code="activeLocaleCode" | ||||||
|  |       :portal-name="portalName" | ||||||
|  |       :active-locale-name="activeLocaleName" | ||||||
|  |       @submit="handleCategory" | ||||||
|  |       @cancel="emit('close')" | ||||||
|  |     /> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,272 @@ | |||||||
|  | <script setup> | ||||||
|  | import { | ||||||
|  |   reactive, | ||||||
|  |   ref, | ||||||
|  |   watch, | ||||||
|  |   computed, | ||||||
|  |   defineAsyncComponent, | ||||||
|  |   onMounted, | ||||||
|  | } from 'vue'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  | import { OnClickOutside } from '@vueuse/components'; | ||||||
|  | import { useStoreGetters, useMapGetter } from 'dashboard/composables/store'; | ||||||
|  | import { useRoute } from 'vue-router'; | ||||||
|  | import { useVuelidate } from '@vuelidate/core'; | ||||||
|  | import { required, minLength } from '@vuelidate/validators'; | ||||||
|  | import { convertToCategorySlug } from 'dashboard/helper/commons.js'; | ||||||
|  |  | ||||||
|  | import Input from 'dashboard/components-next/input/Input.vue'; | ||||||
|  | import TextArea from 'dashboard/components-next/textarea/TextArea.vue'; | ||||||
|  | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   mode: { | ||||||
|  |     type: String, | ||||||
|  |     required: true, | ||||||
|  |     validator: value => ['edit', 'create'].includes(value), | ||||||
|  |   }, | ||||||
|  |   selectedCategory: { | ||||||
|  |     type: Object, | ||||||
|  |     default: () => ({}), | ||||||
|  |   }, | ||||||
|  |   activeLocaleCode: { | ||||||
|  |     type: String, | ||||||
|  |     default: '', | ||||||
|  |   }, | ||||||
|  |   showActionButtons: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: true, | ||||||
|  |   }, | ||||||
|  |   portalName: { | ||||||
|  |     type: String, | ||||||
|  |     default: '', | ||||||
|  |   }, | ||||||
|  |   activeLocaleName: { | ||||||
|  |     type: String, | ||||||
|  |     default: '', | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['submit', 'cancel']); | ||||||
|  |  | ||||||
|  | const EmojiInput = defineAsyncComponent( | ||||||
|  |   () => import('shared/components/emoji/EmojiInput.vue') | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const { t } = useI18n(); | ||||||
|  | const route = useRoute(); | ||||||
|  | const getters = useStoreGetters(); | ||||||
|  |  | ||||||
|  | const isCreating = useMapGetter('categories/isCreating'); | ||||||
|  |  | ||||||
|  | const isUpdatingCategory = computed(() => { | ||||||
|  |   const id = props.selectedCategory?.id; | ||||||
|  |   if (id) return getters['categories/uiFlags'].value(id)?.isUpdating; | ||||||
|  |  | ||||||
|  |   return false; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const isEmojiPickerOpen = ref(false); | ||||||
|  |  | ||||||
|  | const state = reactive({ | ||||||
|  |   id: '', | ||||||
|  |   name: '', | ||||||
|  |   icon: '', | ||||||
|  |   slug: '', | ||||||
|  |   description: '', | ||||||
|  |   locale: '', | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const isEditMode = computed(() => props.mode === 'edit'); | ||||||
|  |  | ||||||
|  | const rules = { | ||||||
|  |   name: { required, minLength: minLength(1) }, | ||||||
|  |   slug: { required }, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const v$ = useVuelidate(rules, state); | ||||||
|  |  | ||||||
|  | const isSubmitDisabled = computed(() => v$.value.$invalid); | ||||||
|  |  | ||||||
|  | const nameError = computed(() => | ||||||
|  |   v$.value.name.$error | ||||||
|  |     ? t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.FORM.NAME.ERROR') | ||||||
|  |     : '' | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const slugError = computed(() => | ||||||
|  |   v$.value.slug.$error | ||||||
|  |     ? t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.FORM.SLUG.ERROR') | ||||||
|  |     : '' | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const slugHelpText = computed(() => { | ||||||
|  |   const { portalSlug, locale } = route.params; | ||||||
|  |   return t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.FORM.SLUG.HELP_TEXT', { | ||||||
|  |     portalSlug, | ||||||
|  |     localeCode: locale, | ||||||
|  |     categorySlug: state.slug, | ||||||
|  |   }); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const onClickInsertEmoji = emoji => { | ||||||
|  |   state.icon = emoji; | ||||||
|  |   isEmojiPickerOpen.value = false; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleSubmit = async () => { | ||||||
|  |   const isFormCorrect = await v$.value.$validate(); | ||||||
|  |   if (!isFormCorrect) return; | ||||||
|  |  | ||||||
|  |   emit('submit', { ...state }); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleCancel = () => { | ||||||
|  |   emit('cancel'); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | watch( | ||||||
|  |   () => state.name, | ||||||
|  |   () => { | ||||||
|  |     if (!isEditMode.value) { | ||||||
|  |       state.slug = convertToCategorySlug(state.name); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | watch( | ||||||
|  |   () => props.selectedCategory, | ||||||
|  |   newCategory => { | ||||||
|  |     if (props.mode === 'edit' && newCategory) { | ||||||
|  |       const { id, name, icon, slug, description } = newCategory; | ||||||
|  |       Object.assign(state, { id, name, icon, slug, description }); | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   { immediate: true } | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | onMounted(() => { | ||||||
|  |   if (props.mode === 'create') { | ||||||
|  |     state.locale = props.activeLocaleCode; | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | defineExpose({ state, isSubmitDisabled }); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <div class="flex flex-col gap-4"> | ||||||
|  |     <div | ||||||
|  |       class="flex items-center justify-start gap-8 px-4 py-2 border rounded-lg border-slate-50 dark:border-slate-700/50" | ||||||
|  |     > | ||||||
|  |       <div class="flex flex-col items-start w-full gap-2 py-2"> | ||||||
|  |         <span class="text-sm font-medium text-slate-700 dark:text-slate-300"> | ||||||
|  |           {{ t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.HEADER.PORTAL') }} | ||||||
|  |         </span> | ||||||
|  |         <span class="text-sm text-slate-800 dark:text-slate-100"> | ||||||
|  |           {{ portalName }} | ||||||
|  |         </span> | ||||||
|  |       </div> | ||||||
|  |       <div class="justify-start w-px h-10 bg-slate-50 dark:bg-slate-700/50" /> | ||||||
|  |       <div class="flex flex-col w-full gap-2 py-2"> | ||||||
|  |         <span class="text-sm font-medium text-slate-700 dark:text-slate-300"> | ||||||
|  |           {{ t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.HEADER.LOCALE') }} | ||||||
|  |         </span> | ||||||
|  |         <span | ||||||
|  |           :title="`${activeLocaleName} (${activeLocaleCode})`" | ||||||
|  |           class="text-sm line-clamp-1 text-slate-800 dark:text-slate-100" | ||||||
|  |         > | ||||||
|  |           {{ `${activeLocaleName} (${activeLocaleCode})` }} | ||||||
|  |         </span> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |     <div class="flex flex-col gap-4"> | ||||||
|  |       <div class="relative"> | ||||||
|  |         <Input | ||||||
|  |           v-model="state.name" | ||||||
|  |           :label=" | ||||||
|  |             t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.FORM.NAME.LABEL') | ||||||
|  |           " | ||||||
|  |           :placeholder=" | ||||||
|  |             t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.FORM.NAME.PLACEHOLDER') | ||||||
|  |           " | ||||||
|  |           :message="nameError" | ||||||
|  |           :message-type="nameError ? 'error' : 'info'" | ||||||
|  |           custom-input-class="!h-10 ltr:!pl-12 rtl:!pr-12 !bg-slate-25 dark:!bg-slate-900" | ||||||
|  |         > | ||||||
|  |           <template #prefix> | ||||||
|  |             <OnClickOutside @trigger="isEmojiPickerOpen = false"> | ||||||
|  |               <Button | ||||||
|  |                 :label="state.icon" | ||||||
|  |                 variant="secondary" | ||||||
|  |                 size="sm" | ||||||
|  |                 :icon="!state.icon ? 'emoji-add' : ''" | ||||||
|  |                 class="!h-[38px] !w-[38px] absolute top-[31px] !rounded-[7px] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none" | ||||||
|  |                 @click="isEmojiPickerOpen = !isEmojiPickerOpen" | ||||||
|  |               /> | ||||||
|  |               <EmojiInput | ||||||
|  |                 v-if="isEmojiPickerOpen" | ||||||
|  |                 class="left-0 top-16" | ||||||
|  |                 show-remove-button | ||||||
|  |                 :on-click="onClickInsertEmoji" | ||||||
|  |               /> | ||||||
|  |             </OnClickOutside> | ||||||
|  |           </template> | ||||||
|  |         </Input> | ||||||
|  |       </div> | ||||||
|  |       <Input | ||||||
|  |         v-model="state.slug" | ||||||
|  |         :label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.FORM.SLUG.LABEL')" | ||||||
|  |         :placeholder=" | ||||||
|  |           t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.FORM.SLUG.PLACEHOLDER') | ||||||
|  |         " | ||||||
|  |         :disabled="isEditMode" | ||||||
|  |         :message="slugError ? slugError : slugHelpText" | ||||||
|  |         :message-type="slugError ? 'error' : 'info'" | ||||||
|  |         custom-input-class="!h-10 !bg-slate-25 dark:!bg-slate-900 " | ||||||
|  |       /> | ||||||
|  |       <TextArea | ||||||
|  |         v-model="state.description" | ||||||
|  |         :label=" | ||||||
|  |           t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.FORM.DESCRIPTION.LABEL') | ||||||
|  |         " | ||||||
|  |         :placeholder=" | ||||||
|  |           t( | ||||||
|  |             'HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.FORM.DESCRIPTION.PLACEHOLDER' | ||||||
|  |           ) | ||||||
|  |         " | ||||||
|  |         show-character-count | ||||||
|  |         custom-text-area-wrapper-class="!bg-slate-25 dark:!bg-slate-900" | ||||||
|  |       /> | ||||||
|  |       <div | ||||||
|  |         v-if="showActionButtons" | ||||||
|  |         class="flex items-center justify-between w-full gap-3" | ||||||
|  |       > | ||||||
|  |         <Button | ||||||
|  |           variant="ghost" | ||||||
|  |           :label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.BUTTONS.CANCEL')" | ||||||
|  |           text-variant="default" | ||||||
|  |           class="w-full bg-n-alpha-2 hover:bg-n-alpha-3" | ||||||
|  |           @click="handleCancel" | ||||||
|  |         /> | ||||||
|  |         <Button | ||||||
|  |           :label=" | ||||||
|  |             t( | ||||||
|  |               `HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.BUTTONS.${mode.toUpperCase()}` | ||||||
|  |             ) | ||||||
|  |           " | ||||||
|  |           class="w-full" | ||||||
|  |           :disabled="isSubmitDisabled || isCreating || isUpdatingCategory" | ||||||
|  |           :is-loading="isCreating || isUpdatingCategory" | ||||||
|  |           @click="handleSubmit" | ||||||
|  |         /> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <style scoped lang="scss"> | ||||||
|  | .emoji-dialog::before { | ||||||
|  |   @apply hidden; | ||||||
|  | } | ||||||
|  | </style> | ||||||
| @@ -0,0 +1,201 @@ | |||||||
|  | <script setup> | ||||||
|  | import { ref, computed } from 'vue'; | ||||||
|  | import { useRoute, useRouter } from 'vue-router'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  | import { OnClickOutside } from '@vueuse/components'; | ||||||
|  | import { useStoreGetters } from 'dashboard/composables/store.js'; | ||||||
|  |  | ||||||
|  | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
|  | import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue'; | ||||||
|  | import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; | ||||||
|  | import CategoryDialog from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryDialog.vue'; | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   categories: { | ||||||
|  |     type: Array, | ||||||
|  |     default: () => [], | ||||||
|  |   }, | ||||||
|  |   allowedLocales: { | ||||||
|  |     type: Array, | ||||||
|  |     default: () => [], | ||||||
|  |   }, | ||||||
|  |   hasSelectedCategory: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['localeChange']); | ||||||
|  |  | ||||||
|  | const route = useRoute(); | ||||||
|  | const router = useRouter(); | ||||||
|  | const getters = useStoreGetters(); | ||||||
|  | const { t } = useI18n(); | ||||||
|  |  | ||||||
|  | const isLocaleMenuOpen = ref(false); | ||||||
|  | const isCreateCategoryDialogOpen = ref(false); | ||||||
|  | const isEditCategoryDialogOpen = ref(false); | ||||||
|  |  | ||||||
|  | const currentPortalSlug = computed(() => { | ||||||
|  |   return route.params.portalSlug; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const currentPortal = computed(() => { | ||||||
|  |   const slug = currentPortalSlug.value; | ||||||
|  |   if (slug) return getters['portals/portalBySlug'].value(slug); | ||||||
|  |  | ||||||
|  |   return getters['portals/allPortals'].value[0]; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const currentPortalName = computed(() => { | ||||||
|  |   return currentPortal.value?.name; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const activeLocale = computed(() => { | ||||||
|  |   return props.allowedLocales.find( | ||||||
|  |     locale => locale.code === route.params.locale | ||||||
|  |   ); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const activeLocaleName = computed(() => activeLocale.value?.name ?? ''); | ||||||
|  | const activeLocaleCode = computed(() => activeLocale.value?.code ?? ''); | ||||||
|  |  | ||||||
|  | const localeMenuItems = computed(() => { | ||||||
|  |   return props.allowedLocales.map(locale => ({ | ||||||
|  |     label: locale.name, | ||||||
|  |     value: locale.code, | ||||||
|  |     action: 'filter', | ||||||
|  |   })); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const selectedCategory = computed(() => | ||||||
|  |   props.categories.find(category => category.slug === route.params.categorySlug) | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const selectedCategoryName = computed(() => { | ||||||
|  |   return selectedCategory.value?.name; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const selectedCategoryCount = computed( | ||||||
|  |   () => selectedCategory.value?.meta?.articles_count || 0 | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const selectedCategoryEmoji = computed(() => { | ||||||
|  |   return selectedCategory.value?.icon; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const categoriesCount = computed(() => props.categories?.length); | ||||||
|  |  | ||||||
|  | const breadcrumbItems = computed(() => { | ||||||
|  |   const items = [ | ||||||
|  |     { | ||||||
|  |       label: t( | ||||||
|  |         'HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.BREADCRUMB.CATEGORY_LOCALE', | ||||||
|  |         { localeCode: activeLocaleCode.value } | ||||||
|  |       ), | ||||||
|  |       link: '#', | ||||||
|  |     }, | ||||||
|  |   ]; | ||||||
|  |   if (selectedCategory.value) { | ||||||
|  |     items.push({ | ||||||
|  |       label: t( | ||||||
|  |         'HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.BREADCRUMB.ACTIVE_CATEGORY', | ||||||
|  |         { | ||||||
|  |           categoryName: selectedCategoryName.value, | ||||||
|  |           categoryCount: selectedCategoryCount.value, | ||||||
|  |         } | ||||||
|  |       ), | ||||||
|  |       emoji: selectedCategoryEmoji.value, | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |   return items; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const handleLocaleAction = ({ value }) => { | ||||||
|  |   emit('localeChange', value); | ||||||
|  |   isLocaleMenuOpen.value = false; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleBreadcrumbClick = () => { | ||||||
|  |   const { categorySlug, ...otherParams } = route.params; | ||||||
|  |   router.push({ | ||||||
|  |     name: 'portals_categories_index', | ||||||
|  |     params: otherParams, | ||||||
|  |   }); | ||||||
|  | }; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <div class="flex items-center justify-between w-full"> | ||||||
|  |     <div v-if="!hasSelectedCategory" class="flex items-center gap-4"> | ||||||
|  |       <div class="relative group"> | ||||||
|  |         <OnClickOutside @trigger="isLocaleMenuOpen = false"> | ||||||
|  |           <Button | ||||||
|  |             :label="activeLocaleName" | ||||||
|  |             size="sm" | ||||||
|  |             icon-position="right" | ||||||
|  |             icon="chevron-lucide-down" | ||||||
|  |             icon-lib="lucide" | ||||||
|  |             variant="secondary" | ||||||
|  |             @click="isLocaleMenuOpen = !isLocaleMenuOpen" | ||||||
|  |           /> | ||||||
|  |           <DropdownMenu | ||||||
|  |             v-if="isLocaleMenuOpen" | ||||||
|  |             :menu-items="localeMenuItems" | ||||||
|  |             class="left-0 w-40 mt-2 overflow-y-auto xl:right-0 top-full max-h-60" | ||||||
|  |             @action="handleLocaleAction" | ||||||
|  |           /> | ||||||
|  |         </OnClickOutside> | ||||||
|  |       </div> | ||||||
|  |       <div class="w-px h-3.5 rounded my-auto bg-slate-75 dark:bg-slate-800" /> | ||||||
|  |       <span class="text-sm font-medium text-slate-800 dark:text-slate-100"> | ||||||
|  |         {{ | ||||||
|  |           t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.CATEGORIES_COUNT', { | ||||||
|  |             n: categoriesCount, | ||||||
|  |           }) | ||||||
|  |         }} | ||||||
|  |       </span> | ||||||
|  |     </div> | ||||||
|  |     <Breadcrumb | ||||||
|  |       v-else | ||||||
|  |       :items="breadcrumbItems" | ||||||
|  |       @click="handleBreadcrumbClick" | ||||||
|  |     /> | ||||||
|  |     <div v-if="!hasSelectedCategory" class="relative"> | ||||||
|  |       <OnClickOutside @trigger="isCreateCategoryDialogOpen = false"> | ||||||
|  |         <Button | ||||||
|  |           :label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.NEW_CATEGORY')" | ||||||
|  |           icon="add" | ||||||
|  |           size="sm" | ||||||
|  |           @click="isCreateCategoryDialogOpen = !isCreateCategoryDialogOpen" | ||||||
|  |         /> | ||||||
|  |         <CategoryDialog | ||||||
|  |           v-if="isCreateCategoryDialogOpen" | ||||||
|  |           mode="create" | ||||||
|  |           :portal-name="currentPortalName" | ||||||
|  |           :active-locale-name="activeLocaleName" | ||||||
|  |           :active-locale-code="activeLocaleCode" | ||||||
|  |           @close="isCreateCategoryDialogOpen = false" | ||||||
|  |         /> | ||||||
|  |       </OnClickOutside> | ||||||
|  |     </div> | ||||||
|  |     <div v-else class="relative"> | ||||||
|  |       <OnClickOutside @trigger="isEditCategoryDialogOpen = false"> | ||||||
|  |         <Button | ||||||
|  |           :label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.EDIT_CATEGORY')" | ||||||
|  |           variant="secondary" | ||||||
|  |           size="sm" | ||||||
|  |           @click="isEditCategoryDialogOpen = !isEditCategoryDialogOpen" | ||||||
|  |         /> | ||||||
|  |         <CategoryDialog | ||||||
|  |           v-if="isEditCategoryDialogOpen" | ||||||
|  |           :selected-category="selectedCategory" | ||||||
|  |           :portal-name="currentPortalName" | ||||||
|  |           :active-locale-name="activeLocaleName" | ||||||
|  |           :active-locale-code="activeLocaleCode" | ||||||
|  |           @close="isEditCategoryDialogOpen = false" | ||||||
|  |         /> | ||||||
|  |       </OnClickOutside> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
| @@ -8,10 +8,14 @@ defineProps({ | |||||||
|   }, |   }, | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const emit = defineEmits(['click']); | const emit = defineEmits(['click', 'action']); | ||||||
|  |  | ||||||
| const handleClick = id => { | const handleClick = slug => { | ||||||
|   emit('click', id); |   emit('click', slug); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleAction = ({ action, value, id }, category) => { | ||||||
|  |   emit('action', { action, value, id, category }); | ||||||
| }; | }; | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| @@ -20,11 +24,14 @@ const handleClick = id => { | |||||||
|     <CategoryCard |     <CategoryCard | ||||||
|       v-for="category in categories" |       v-for="category in categories" | ||||||
|       :id="category.id" |       :id="category.id" | ||||||
|       :key="category.title" |       :key="category.id" | ||||||
|       :title="category.title" |       :title="category.name" | ||||||
|  |       :icon="category.icon" | ||||||
|       :description="category.description" |       :description="category.description" | ||||||
|       :articles-count="category.articlesCount" |       :articles-count="category.meta.articles_count || 0" | ||||||
|       @click="handleClick(category.id)" |       :slug="category.slug" | ||||||
|  |       @click="handleClick(category.slug)" | ||||||
|  |       @action="handleAction($event, category)" | ||||||
|     /> |     /> | ||||||
|   </ul> |   </ul> | ||||||
| </template> | </template> | ||||||
|   | |||||||
| @@ -0,0 +1,112 @@ | |||||||
|  | <script setup> | ||||||
|  | import { ref, computed } from 'vue'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  | import { useStore, useStoreGetters } from 'dashboard/composables/store'; | ||||||
|  | import { useAlert, useTrack } from 'dashboard/composables'; | ||||||
|  | import { useRoute } from 'vue-router'; | ||||||
|  | import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; | ||||||
|  | import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; | ||||||
|  | import CategoryForm from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryForm.vue'; | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   selectedCategory: { | ||||||
|  |     type: Object, | ||||||
|  |     default: () => ({}), | ||||||
|  |   }, | ||||||
|  |   allowedLocales: { | ||||||
|  |     type: Array, | ||||||
|  |     default: () => [], | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const { t } = useI18n(); | ||||||
|  | const store = useStore(); | ||||||
|  | const route = useRoute(); | ||||||
|  | const getters = useStoreGetters(); | ||||||
|  |  | ||||||
|  | const dialogRef = ref(null); | ||||||
|  | const categoryFormRef = ref(null); | ||||||
|  |  | ||||||
|  | const isUpdatingCategory = computed(() => { | ||||||
|  |   const id = props.selectedCategory?.id; | ||||||
|  |   if (id) return getters['categories/uiFlags'].value(id)?.isUpdating; | ||||||
|  |  | ||||||
|  |   return false; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const isInvalidForm = computed(() => { | ||||||
|  |   if (!categoryFormRef.value) return false; | ||||||
|  |   const { isSubmitDisabled } = categoryFormRef.value; | ||||||
|  |   return isSubmitDisabled; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const activeLocale = computed(() => { | ||||||
|  |   return props.allowedLocales.find( | ||||||
|  |     locale => locale.code === route.params.locale | ||||||
|  |   ); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const activeLocaleName = computed(() => activeLocale.value?.name ?? ''); | ||||||
|  | const activeLocaleCode = computed(() => activeLocale.value?.code ?? ''); | ||||||
|  |  | ||||||
|  | const onUpdateCategory = async () => { | ||||||
|  |   if (!categoryFormRef.value) return; | ||||||
|  |   const { state } = categoryFormRef.value; | ||||||
|  |   const { id, name, slug, icon, description } = state; | ||||||
|  |   const categoryData = { name, icon, slug, description }; | ||||||
|  |   categoryData.id = id; | ||||||
|  |  | ||||||
|  |   try { | ||||||
|  |     const payload = { | ||||||
|  |       portalSlug: route.params.portalSlug, | ||||||
|  |       categoryObj: categoryData, | ||||||
|  |       categoryId: id, | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     await store.dispatch(`categories/update`, payload); | ||||||
|  |  | ||||||
|  |     const successMessage = t( | ||||||
|  |       `HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.EDIT.API.SUCCESS_MESSAGE` | ||||||
|  |     ); | ||||||
|  |     useAlert(successMessage); | ||||||
|  |     dialogRef.value.close(); | ||||||
|  |  | ||||||
|  |     const trackEvent = PORTALS_EVENTS.EDIT_CATEGORY; | ||||||
|  |     useTrack(trackEvent, { hasDescription: Boolean(description) }); | ||||||
|  |   } catch (error) { | ||||||
|  |     const errorMessage = | ||||||
|  |       error?.message || | ||||||
|  |       t(`HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.EDIT.API.ERROR_MESSAGE`); | ||||||
|  |     useAlert(errorMessage); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | // Expose the dialogRef to the parent component | ||||||
|  | defineExpose({ dialogRef }); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <Dialog | ||||||
|  |     ref="dialogRef" | ||||||
|  |     type="edit" | ||||||
|  |     :title="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.HEADER.EDIT')" | ||||||
|  |     :description=" | ||||||
|  |       t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.HEADER.DESCRIPTION') | ||||||
|  |     " | ||||||
|  |     :is-loading="isUpdatingCategory" | ||||||
|  |     :disable-confirm-button="isUpdatingCategory || isInvalidForm" | ||||||
|  |     @confirm="onUpdateCategory" | ||||||
|  |   > | ||||||
|  |     <template #form> | ||||||
|  |       <CategoryForm | ||||||
|  |         ref="categoryFormRef" | ||||||
|  |         mode="edit" | ||||||
|  |         :selected-category="selectedCategory" | ||||||
|  |         :active-locale-code="activeLocaleCode" | ||||||
|  |         :portal-name="route.params.portalSlug" | ||||||
|  |         :active-locale-name="activeLocaleName" | ||||||
|  |         :show-action-buttons="false" | ||||||
|  |       /> | ||||||
|  |     </template> | ||||||
|  |   </Dialog> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,101 @@ | |||||||
|  | <script setup> | ||||||
|  | import { ref, computed } from 'vue'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  | import { useStore } from 'dashboard/composables/store'; | ||||||
|  | import { useAlert, useTrack } from 'dashboard/composables'; | ||||||
|  | import { useRoute } from 'vue-router'; | ||||||
|  | import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; | ||||||
|  | import allLocales from 'shared/constants/locales.js'; | ||||||
|  | import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; | ||||||
|  | import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue'; | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   portal: { | ||||||
|  |     type: Object, | ||||||
|  |     default: () => ({}), | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const { t } = useI18n(); | ||||||
|  | const store = useStore(); | ||||||
|  | const route = useRoute(); | ||||||
|  |  | ||||||
|  | const dialogRef = ref(null); | ||||||
|  | const isUpdating = ref(false); | ||||||
|  |  | ||||||
|  | const selectedLocale = ref(''); | ||||||
|  |  | ||||||
|  | const addedLocales = computed(() => { | ||||||
|  |   const { allowed_locales: allowedLocales = [] } = props.portal?.config || {}; | ||||||
|  |   return allowedLocales.map(locale => locale.code); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const locales = computed(() => { | ||||||
|  |   return Object.keys(allLocales) | ||||||
|  |     .map(key => { | ||||||
|  |       return { | ||||||
|  |         value: key, | ||||||
|  |         label: `${allLocales[key]} (${key})`, | ||||||
|  |       }; | ||||||
|  |     }) | ||||||
|  |     .filter(locale => !addedLocales.value.includes(locale.value)); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const onCreate = async () => { | ||||||
|  |   if (!selectedLocale.value) return; | ||||||
|  |  | ||||||
|  |   isUpdating.value = true; | ||||||
|  |   const updatedLocales = [...addedLocales.value, selectedLocale.value]; | ||||||
|  |  | ||||||
|  |   try { | ||||||
|  |     await store.dispatch('portals/update', { | ||||||
|  |       portalSlug: props.portal.slug, | ||||||
|  |       config: { allowed_locales: updatedLocales }, | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     useTrack(PORTALS_EVENTS.CREATE_LOCALE, { | ||||||
|  |       localeAdded: selectedLocale.value, | ||||||
|  |       totalLocales: updatedLocales.length, | ||||||
|  |       from: route.name, | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     dialogRef.value?.close(); | ||||||
|  |     useAlert( | ||||||
|  |       t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.API.SUCCESS_MESSAGE') | ||||||
|  |     ); | ||||||
|  |   } catch (error) { | ||||||
|  |     useAlert( | ||||||
|  |       error?.message || | ||||||
|  |         t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.API.ERROR_MESSAGE') | ||||||
|  |     ); | ||||||
|  |   } finally { | ||||||
|  |     isUpdating.value = false; | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | // Expose the dialogRef to the parent component | ||||||
|  | defineExpose({ dialogRef }); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <Dialog | ||||||
|  |     ref="dialogRef" | ||||||
|  |     type="edit" | ||||||
|  |     :title="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.TITLE')" | ||||||
|  |     :description="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.DESCRIPTION')" | ||||||
|  |     @confirm="onCreate" | ||||||
|  |   > | ||||||
|  |     <template #form> | ||||||
|  |       <div class="flex flex-col gap-6"> | ||||||
|  |         <ComboBox | ||||||
|  |           v-model="selectedLocale" | ||||||
|  |           :options="locales" | ||||||
|  |           :placeholder=" | ||||||
|  |             t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.COMBOBOX.PLACEHOLDER') | ||||||
|  |           " | ||||||
|  |           class="[&>div>button]:!border-n-slate-5 [&>div>button]:dark:!border-n-slate-5" | ||||||
|  |         /> | ||||||
|  |       </div> | ||||||
|  |     </template> | ||||||
|  |   </Dialog> | ||||||
|  | </template> | ||||||
| @@ -1,12 +1,94 @@ | |||||||
| <script setup> | <script setup> | ||||||
| import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue'; | import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue'; | ||||||
|  | import { useStore } from 'dashboard/composables/store'; | ||||||
|  | import { useAlert, useTrack } from 'dashboard/composables'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  | import { useRoute } from 'vue-router'; | ||||||
|  | import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; | ||||||
|  |  | ||||||
| defineProps({ | const props = defineProps({ | ||||||
|   locales: { |   locales: { | ||||||
|     type: Array, |     type: Array, | ||||||
|     required: true, |     required: true, | ||||||
|   }, |   }, | ||||||
|  |   portal: { | ||||||
|  |     type: Object, | ||||||
|  |     required: true, | ||||||
|  |   }, | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  | const store = useStore(); | ||||||
|  | const { t } = useI18n(); | ||||||
|  | const route = useRoute(); | ||||||
|  |  | ||||||
|  | const isLocaleDefault = code => { | ||||||
|  |   return props.portal?.meta?.default_locale === code; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const updatePortalLocales = async ({ | ||||||
|  |   newAllowedLocales, | ||||||
|  |   defaultLocale, | ||||||
|  |   messageKey, | ||||||
|  | }) => { | ||||||
|  |   let alertMessage = ''; | ||||||
|  |   try { | ||||||
|  |     await store.dispatch('portals/update', { | ||||||
|  |       portalSlug: props.portal.slug, | ||||||
|  |       config: { | ||||||
|  |         default_locale: defaultLocale, | ||||||
|  |         allowed_locales: newAllowedLocales, | ||||||
|  |       }, | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     alertMessage = t(`HELP_CENTER.PORTAL.${messageKey}.API.SUCCESS_MESSAGE`); | ||||||
|  |   } catch (error) { | ||||||
|  |     alertMessage = | ||||||
|  |       error?.message || t(`HELP_CENTER.PORTAL.${messageKey}.API.ERROR_MESSAGE`); | ||||||
|  |   } finally { | ||||||
|  |     useAlert(alertMessage); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const changeDefaultLocale = ({ localeCode }) => { | ||||||
|  |   const newAllowedLocales = props.locales.map(locale => locale.code); | ||||||
|  |   updatePortalLocales({ | ||||||
|  |     newAllowedLocales, | ||||||
|  |     defaultLocale: localeCode, | ||||||
|  |     messageKey: 'CHANGE_DEFAULT_LOCALE', | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   useTrack(PORTALS_EVENTS.SET_DEFAULT_LOCALE, { | ||||||
|  |     newLocale: localeCode, | ||||||
|  |     from: route.name, | ||||||
|  |   }); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const deletePortalLocale = ({ localeCode }) => { | ||||||
|  |   const updatedLocales = props.locales | ||||||
|  |     .filter(locale => locale.code !== localeCode) | ||||||
|  |     .map(locale => locale.code); | ||||||
|  |  | ||||||
|  |   const defaultLocale = props.portal.meta.default_locale; | ||||||
|  |  | ||||||
|  |   updatePortalLocales({ | ||||||
|  |     newAllowedLocales: updatedLocales, | ||||||
|  |     defaultLocale, | ||||||
|  |     messageKey: 'DELETE_LOCALE', | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   useTrack(PORTALS_EVENTS.DELETE_LOCALE, { | ||||||
|  |     deletedLocale: localeCode, | ||||||
|  |     from: route.name, | ||||||
|  |   }); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleAction = ({ action }, localeCode) => { | ||||||
|  |   if (action === 'change-default') { | ||||||
|  |     changeDefaultLocale({ localeCode: localeCode }); | ||||||
|  |   } else if (action === 'delete') { | ||||||
|  |     deletePortalLocale({ localeCode: localeCode }); | ||||||
|  |   } | ||||||
|  | }; | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <template> | <template> | ||||||
| @@ -15,9 +97,11 @@ defineProps({ | |||||||
|       v-for="(locale, index) in locales" |       v-for="(locale, index) in locales" | ||||||
|       :key="index" |       :key="index" | ||||||
|       :locale="locale.name" |       :locale="locale.name" | ||||||
|       :is-default="locale.isDefault" |       :is-default="isLocaleDefault(locale.code)" | ||||||
|       :article-count="locale.articleCount" |       :locale-code="locale.code" | ||||||
|       :category-count="locale.categoryCount" |       :article-count="locale.articlesCount || 0" | ||||||
|  |       :category-count="locale.categoriesCount || 0" | ||||||
|  |       @action="handleAction($event, locale.code)" | ||||||
|     /> |     /> | ||||||
|   </ul> |   </ul> | ||||||
| </template> | </template> | ||||||
|   | |||||||
| @@ -1,27 +1,28 @@ | |||||||
| <script setup> | <script setup> | ||||||
| import { computed } from 'vue'; | import { computed, ref } from 'vue'; | ||||||
| import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue'; | import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue'; | ||||||
| import Button from 'dashboard/components-next/button/Button.vue'; | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
| import LocaleList from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue'; | import LocaleList from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue'; | ||||||
|  | import AddLocaleDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/AddLocaleDialog.vue'; | ||||||
|  |  | ||||||
| const props = defineProps({ | const props = defineProps({ | ||||||
|   locales: { |   locales: { | ||||||
|     type: Array, |     type: Array, | ||||||
|     required: true, |     required: true, | ||||||
|   }, |   }, | ||||||
|  |   portal: { | ||||||
|  |     type: Object, | ||||||
|  |     default: () => ({}), | ||||||
|  |   }, | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const localeCount = computed(() => props.locales?.length); | const addLocaleDialogRef = ref(null); | ||||||
|  |  | ||||||
| // TODO: remove comments | const openAddLocaleDialog = () => { | ||||||
| // eslint-disable-next-line no-unused-vars |   addLocaleDialogRef.value.dialogRef.open(); | ||||||
| const handleTabChange = tab => { |  | ||||||
|   // TODO: Implement tab change logic |  | ||||||
| }; |  | ||||||
| // eslint-disable-next-line no-unused-vars |  | ||||||
| const handlePageChange = page => { |  | ||||||
|   // TODO: Implement page change logic |  | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | const localeCount = computed(() => props.locales?.length); | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <template> | <template> | ||||||
| @@ -37,11 +38,13 @@ const handlePageChange = page => { | |||||||
|           :label="$t('HELP_CENTER.LOCALES_PAGE.NEW_LOCALE_BUTTON_TEXT')" |           :label="$t('HELP_CENTER.LOCALES_PAGE.NEW_LOCALE_BUTTON_TEXT')" | ||||||
|           icon="add" |           icon="add" | ||||||
|           size="sm" |           size="sm" | ||||||
|  |           @click="openAddLocaleDialog" | ||||||
|         /> |         /> | ||||||
|       </div> |       </div> | ||||||
|     </template> |     </template> | ||||||
|     <template #content> |     <template #content> | ||||||
|       <LocaleList :locales="locales" /> |       <LocaleList :locales="locales" :portal="portal" /> | ||||||
|     </template> |     </template> | ||||||
|  |     <AddLocaleDialog ref="addLocaleDialogRef" :portal="portal" /> | ||||||
|   </HelpCenterLayout> |   </HelpCenterLayout> | ||||||
| </template> | </template> | ||||||
|   | |||||||
| @@ -0,0 +1,74 @@ | |||||||
|  | <script setup> | ||||||
|  | import { ref, reactive, watch } from 'vue'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  |  | ||||||
|  | import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; | ||||||
|  | import Input from 'dashboard/components-next/input/Input.vue'; | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   mode: { | ||||||
|  |     type: String, | ||||||
|  |     default: 'add', | ||||||
|  |   }, | ||||||
|  |   customDomain: { | ||||||
|  |     type: String, | ||||||
|  |     default: '', | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['addCustomDomain']); | ||||||
|  |  | ||||||
|  | const { t } = useI18n(); | ||||||
|  |  | ||||||
|  | const dialogRef = ref(null); | ||||||
|  |  | ||||||
|  | const formState = reactive({ | ||||||
|  |   customDomain: props.customDomain, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | watch( | ||||||
|  |   () => props.customDomain, | ||||||
|  |   newVal => { | ||||||
|  |     formState.customDomain = newVal; | ||||||
|  |   } | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const handleDialogConfirm = () => { | ||||||
|  |   emit('addCustomDomain', formState.customDomain); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | defineExpose({ dialogRef }); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <Dialog | ||||||
|  |     ref="dialogRef" | ||||||
|  |     :title=" | ||||||
|  |       t( | ||||||
|  |         `HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.${props.mode.toUpperCase()}_HEADER` | ||||||
|  |       ) | ||||||
|  |     " | ||||||
|  |     :confirm-button-label=" | ||||||
|  |       t( | ||||||
|  |         `HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.${props.mode.toUpperCase()}_CONFIRM_BUTTON_LABEL` | ||||||
|  |       ) | ||||||
|  |     " | ||||||
|  |     @confirm="handleDialogConfirm" | ||||||
|  |   > | ||||||
|  |     <template #form> | ||||||
|  |       <Input | ||||||
|  |         v-model="formState.customDomain" | ||||||
|  |         :label=" | ||||||
|  |           t( | ||||||
|  |             'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.LABEL' | ||||||
|  |           ) | ||||||
|  |         " | ||||||
|  |         :placeholder=" | ||||||
|  |           t( | ||||||
|  |             'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.PLACEHOLDER' | ||||||
|  |           ) | ||||||
|  |         " | ||||||
|  |       /> | ||||||
|  |     </template> | ||||||
|  |   </Dialog> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,51 @@ | |||||||
|  | <script setup> | ||||||
|  | import { ref } from 'vue'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  |  | ||||||
|  | import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; | ||||||
|  |  | ||||||
|  | defineProps({ | ||||||
|  |   activePortalName: { | ||||||
|  |     type: String, | ||||||
|  |     required: true, | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['deletePortal']); | ||||||
|  |  | ||||||
|  | const { t } = useI18n(); | ||||||
|  |  | ||||||
|  | const dialogRef = ref(null); | ||||||
|  |  | ||||||
|  | const handleDialogConfirm = () => { | ||||||
|  |   emit('deletePortal'); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | defineExpose({ dialogRef }); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <Dialog | ||||||
|  |     ref="dialogRef" | ||||||
|  |     type="alert" | ||||||
|  |     :title=" | ||||||
|  |       t( | ||||||
|  |         'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.DIALOG.HEADER', | ||||||
|  |         { | ||||||
|  |           portalName: activePortalName, | ||||||
|  |         } | ||||||
|  |       ) | ||||||
|  |     " | ||||||
|  |     :description=" | ||||||
|  |       t( | ||||||
|  |         'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.DIALOG.DESCRIPTION' | ||||||
|  |       ) | ||||||
|  |     " | ||||||
|  |     :confirm-button-label=" | ||||||
|  |       t( | ||||||
|  |         'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.DIALOG.CONFIRM_BUTTON_LABEL' | ||||||
|  |       ) | ||||||
|  |     " | ||||||
|  |     @confirm="handleDialogConfirm" | ||||||
|  |   /> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,79 @@ | |||||||
|  | <script setup> | ||||||
|  | import { ref, computed } from 'vue'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  | import { getHostNameFromURL } from 'dashboard/helper/URLHelper'; | ||||||
|  |  | ||||||
|  | import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   customDomain: { | ||||||
|  |     type: String, | ||||||
|  |     default: '', | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['confirm']); | ||||||
|  |  | ||||||
|  | const { t } = useI18n(); | ||||||
|  |  | ||||||
|  | const domain = computed(() => { | ||||||
|  |   const { hostURL, helpCenterURL } = window?.chatwootConfig || {}; | ||||||
|  |   return getHostNameFromURL(helpCenterURL) || getHostNameFromURL(hostURL) || ''; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const subdomainCNAME = computed( | ||||||
|  |   () => `${props.customDomain} CNAME ${domain.value}` | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const dialogRef = ref(null); | ||||||
|  |  | ||||||
|  | const handleDialogConfirm = () => { | ||||||
|  |   emit('confirm'); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | defineExpose({ dialogRef }); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <Dialog | ||||||
|  |     ref="dialogRef" | ||||||
|  |     :title=" | ||||||
|  |       t( | ||||||
|  |         'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HEADER' | ||||||
|  |       ) | ||||||
|  |     " | ||||||
|  |     :confirm-button-label=" | ||||||
|  |       t( | ||||||
|  |         'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.CONFIRM_BUTTON_LABEL' | ||||||
|  |       ) | ||||||
|  |     " | ||||||
|  |     :show-cancel-button="false" | ||||||
|  |     @confirm="handleDialogConfirm" | ||||||
|  |   > | ||||||
|  |     <template #description> | ||||||
|  |       <p class="mb-0 text-sm text-n-slate-12"> | ||||||
|  |         {{ | ||||||
|  |           t( | ||||||
|  |             'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.DESCRIPTION' | ||||||
|  |           ) | ||||||
|  |         }} | ||||||
|  |       </p> | ||||||
|  |     </template> | ||||||
|  |     <template #form> | ||||||
|  |       <div class="flex flex-col gap-6"> | ||||||
|  |         <span | ||||||
|  |           class="h-10 px-3 py-2.5 text-sm select-none bg-transparent border rounded-lg text-n-slate-11 border-n-strong" | ||||||
|  |         > | ||||||
|  |           {{ subdomainCNAME }} | ||||||
|  |         </span> | ||||||
|  |         <p class="text-sm text-n-slate-12"> | ||||||
|  |           {{ | ||||||
|  |             t( | ||||||
|  |               'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HELP_TEXT' | ||||||
|  |             ) | ||||||
|  |           }} | ||||||
|  |         </p> | ||||||
|  |       </div> | ||||||
|  |     </template> | ||||||
|  |   </Dialog> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,320 @@ | |||||||
|  | <script setup> | ||||||
|  | import { reactive, watch, computed } from 'vue'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  | import { buildPortalURL } from 'dashboard/helper/portalHelper'; | ||||||
|  | import { useAlert } from 'dashboard/composables'; | ||||||
|  | import { useStore, useStoreGetters } from 'dashboard/composables/store'; | ||||||
|  | import { uploadFile } from 'dashboard/helper/uploadHelper'; | ||||||
|  | import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; | ||||||
|  | import { useVuelidate } from '@vuelidate/core'; | ||||||
|  | import { required, minLength } from '@vuelidate/validators'; | ||||||
|  | import { shouldBeUrl } from 'shared/helpers/Validators'; | ||||||
|  |  | ||||||
|  | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
|  | import Input from 'dashboard/components-next/input/Input.vue'; | ||||||
|  | import EditableAvatar from 'dashboard/components-next/avatar/EditableAvatar.vue'; | ||||||
|  | import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue'; | ||||||
|  | import ColorPicker from 'dashboard/components-next/colorpicker/ColorPicker.vue'; | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   activePortal: { | ||||||
|  |     type: Object, | ||||||
|  |     required: true, | ||||||
|  |   }, | ||||||
|  |   isFetching: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['updatePortal']); | ||||||
|  |  | ||||||
|  | const { t } = useI18n(); | ||||||
|  | const store = useStore(); | ||||||
|  | const getters = useStoreGetters(); | ||||||
|  |  | ||||||
|  | const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB | ||||||
|  |  | ||||||
|  | const state = reactive({ | ||||||
|  |   name: '', | ||||||
|  |   headerText: '', | ||||||
|  |   pageTitle: '', | ||||||
|  |   slug: '', | ||||||
|  |   widgetColor: '', | ||||||
|  |   homePageLink: '', | ||||||
|  |   liveChatWidgetInboxId: '', | ||||||
|  |   logoUrl: '', | ||||||
|  |   avatarBlobId: '', | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const originalState = reactive({ ...state }); | ||||||
|  |  | ||||||
|  | const liveChatWidgets = computed(() => { | ||||||
|  |   const inboxes = store.getters['inboxes/getInboxes']; | ||||||
|  |   return inboxes | ||||||
|  |     .filter(inbox => inbox.channel_type === 'Channel::WebWidget') | ||||||
|  |     .map(inbox => ({ | ||||||
|  |       value: inbox.id, | ||||||
|  |       label: inbox.name, | ||||||
|  |     })); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const rules = { | ||||||
|  |   name: { required, minLength: minLength(2) }, | ||||||
|  |   slug: { required }, | ||||||
|  |   homePageLink: { shouldBeUrl }, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const v$ = useVuelidate(rules, state); | ||||||
|  |  | ||||||
|  | const nameError = computed(() => | ||||||
|  |   v$.value.name.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.ERROR') : '' | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const slugError = computed(() => | ||||||
|  |   v$.value.slug.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.ERROR') : '' | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const homePageLinkError = computed(() => | ||||||
|  |   v$.value.homePageLink.$error | ||||||
|  |     ? t('HELP_CENTER.PORTAL_SETTINGS.FORM.HOME_PAGE_LINK.ERROR') | ||||||
|  |     : '' | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const isUpdatingPortal = computed(() => { | ||||||
|  |   const slug = props.activePortal?.slug; | ||||||
|  |   if (slug) return getters['portals/uiFlagsIn'].value(slug)?.isUpdating; | ||||||
|  |  | ||||||
|  |   return false; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | watch( | ||||||
|  |   () => props.activePortal, | ||||||
|  |   newVal => { | ||||||
|  |     if (newVal && !props.isFetching) { | ||||||
|  |       Object.assign(state, { | ||||||
|  |         name: newVal.name, | ||||||
|  |         headerText: newVal.header_text, | ||||||
|  |         pageTitle: newVal.page_title, | ||||||
|  |         widgetColor: newVal.color, | ||||||
|  |         homePageLink: newVal.homepage_link, | ||||||
|  |         slug: newVal.slug, | ||||||
|  |         liveChatWidgetInboxId: newVal.inbox?.id, | ||||||
|  |       }); | ||||||
|  |       if (newVal.logo) { | ||||||
|  |         const { | ||||||
|  |           logo: { file_url: logoURL, blob_id: blobId }, | ||||||
|  |         } = newVal; | ||||||
|  |         state.logoUrl = logoURL; | ||||||
|  |         state.avatarBlobId = blobId; | ||||||
|  |       } else { | ||||||
|  |         state.logoUrl = ''; | ||||||
|  |         state.avatarBlobId = ''; | ||||||
|  |       } | ||||||
|  |       Object.assign(originalState, state); | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   { immediate: true, deep: true } | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const hasChanges = computed(() => { | ||||||
|  |   return JSON.stringify(state) !== JSON.stringify(originalState); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const handleUpdatePortal = () => { | ||||||
|  |   const portal = { | ||||||
|  |     id: props.activePortal?.id, | ||||||
|  |     slug: state.slug, | ||||||
|  |     name: state.name, | ||||||
|  |     color: state.widgetColor, | ||||||
|  |     page_title: state.pageTitle, | ||||||
|  |     header_text: state.headerText, | ||||||
|  |     homepage_link: state.homePageLink, | ||||||
|  |     blob_id: state.avatarBlobId, | ||||||
|  |     inbox_id: state.liveChatWidgetInboxId, | ||||||
|  |   }; | ||||||
|  |   emit('updatePortal', portal); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | async function uploadLogoToStorage({ file }) { | ||||||
|  |   try { | ||||||
|  |     const { fileUrl, blobId } = await uploadFile(file); | ||||||
|  |     if (fileUrl) { | ||||||
|  |       state.logoUrl = fileUrl; | ||||||
|  |       state.avatarBlobId = blobId; | ||||||
|  |     } | ||||||
|  |     useAlert(t('HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.IMAGE_UPLOAD_SUCCESS')); | ||||||
|  |   } catch (error) { | ||||||
|  |     useAlert(t('HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.IMAGE_UPLOAD_ERROR')); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | async function deleteLogo() { | ||||||
|  |   try { | ||||||
|  |     const portalSlug = props.activePortal?.slug; | ||||||
|  |     await store.dispatch('portals/deleteLogo', { | ||||||
|  |       portalSlug, | ||||||
|  |     }); | ||||||
|  |     useAlert(t('HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.IMAGE_DELETE_SUCCESS')); | ||||||
|  |   } catch (error) { | ||||||
|  |     useAlert( | ||||||
|  |       error?.message || | ||||||
|  |         t('HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.IMAGE_DELETE_ERROR') | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const handleAvatarUpload = file => { | ||||||
|  |   if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) { | ||||||
|  |     uploadLogoToStorage(file); | ||||||
|  |   } else { | ||||||
|  |     const errorKey = | ||||||
|  |       'HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.IMAGE_UPLOAD_SIZE_ERROR'; | ||||||
|  |     useAlert(t(errorKey, { size: MAXIMUM_FILE_UPLOAD_SIZE })); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleAvatarDelete = () => { | ||||||
|  |   state.logoUrl = ''; | ||||||
|  |   state.avatarBlobId = ''; | ||||||
|  |   deleteLogo(); | ||||||
|  | }; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <div class="flex flex-col w-full gap-4"> | ||||||
|  |     <div class="flex flex-col w-full gap-2"> | ||||||
|  |       <label class="mb-0.5 text-sm font-medium text-gray-900 dark:text-gray-50"> | ||||||
|  |         {{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.LABEL') }} | ||||||
|  |       </label> | ||||||
|  |       <EditableAvatar | ||||||
|  |         label="Avatar" | ||||||
|  |         :src="state.logoUrl" | ||||||
|  |         :name="state.name" | ||||||
|  |         @upload="handleAvatarUpload" | ||||||
|  |         @delete="handleAvatarDelete" | ||||||
|  |       /> | ||||||
|  |     </div> | ||||||
|  |     <div class="flex flex-col w-full gap-4"> | ||||||
|  |       <div class="flex items-start justify-between w-full gap-2"> | ||||||
|  |         <label | ||||||
|  |           class="text-sm font-medium whitespace-nowrap min-w-[100px] py-2.5 text-slate-900 dark:text-slate-50" | ||||||
|  |         > | ||||||
|  |           {{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.NAME.LABEL') }} | ||||||
|  |         </label> | ||||||
|  |         <Input | ||||||
|  |           v-model="state.name" | ||||||
|  |           :placeholder="t('HELP_CENTER.PORTAL_SETTINGS.FORM.NAME.PLACEHOLDER')" | ||||||
|  |           class="w-[432px]" | ||||||
|  |           :message-type="nameError ? 'error' : 'info'" | ||||||
|  |           :message="nameError" | ||||||
|  |           custom-input-class="!bg-transparent dark:!bg-transparent" | ||||||
|  |           @input="v$.name.$touch()" | ||||||
|  |           @blur="v$.name.$touch()" | ||||||
|  |         /> | ||||||
|  |       </div> | ||||||
|  |       <div class="flex items-start justify-between w-full gap-2"> | ||||||
|  |         <label | ||||||
|  |           class="text-sm font-medium whitespace-nowrap min-w-[100px] py-2.5 text-slate-900 dark:text-slate-50" | ||||||
|  |         > | ||||||
|  |           {{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.HEADER_TEXT.LABEL') }} | ||||||
|  |         </label> | ||||||
|  |         <Input | ||||||
|  |           v-model="state.headerText" | ||||||
|  |           :placeholder=" | ||||||
|  |             t('HELP_CENTER.PORTAL_SETTINGS.FORM.HEADER_TEXT.PLACEHOLDER') | ||||||
|  |           " | ||||||
|  |           class="w-[432px]" | ||||||
|  |           custom-input-class="!bg-transparent dark:!bg-transparent" | ||||||
|  |         /> | ||||||
|  |       </div> | ||||||
|  |       <div class="flex items-start justify-between w-full gap-2"> | ||||||
|  |         <label | ||||||
|  |           class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 py-2.5 dark:text-slate-50" | ||||||
|  |         > | ||||||
|  |           {{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.PAGE_TITLE.LABEL') }} | ||||||
|  |         </label> | ||||||
|  |         <Input | ||||||
|  |           v-model="state.pageTitle" | ||||||
|  |           :placeholder=" | ||||||
|  |             t('HELP_CENTER.PORTAL_SETTINGS.FORM.PAGE_TITLE.PLACEHOLDER') | ||||||
|  |           " | ||||||
|  |           class="w-[432px]" | ||||||
|  |           custom-input-class="!bg-transparent dark:!bg-transparent" | ||||||
|  |         /> | ||||||
|  |       </div> | ||||||
|  |       <div class="flex items-start justify-between w-full gap-2"> | ||||||
|  |         <label | ||||||
|  |           class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 py-2.5 dark:text-slate-50" | ||||||
|  |         > | ||||||
|  |           {{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.HOME_PAGE_LINK.LABEL') }} | ||||||
|  |         </label> | ||||||
|  |         <Input | ||||||
|  |           v-model="state.homePageLink" | ||||||
|  |           :placeholder=" | ||||||
|  |             t('HELP_CENTER.PORTAL_SETTINGS.FORM.HOME_PAGE_LINK.PLACEHOLDER') | ||||||
|  |           " | ||||||
|  |           class="w-[432px]" | ||||||
|  |           :message-type="homePageLinkError ? 'error' : 'info'" | ||||||
|  |           :message="homePageLinkError" | ||||||
|  |           custom-input-class="!bg-transparent dark:!bg-transparent" | ||||||
|  |           @input="v$.homePageLink.$touch()" | ||||||
|  |           @blur="v$.homePageLink.$touch()" | ||||||
|  |         /> | ||||||
|  |       </div> | ||||||
|  |       <div class="flex items-start justify-between w-full gap-2"> | ||||||
|  |         <label | ||||||
|  |           class="text-sm font-medium whitespace-nowrap min-w-[100px] py-2.5 text-slate-900 dark:text-slate-50" | ||||||
|  |         > | ||||||
|  |           {{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.SLUG.LABEL') }} | ||||||
|  |         </label> | ||||||
|  |         <Input | ||||||
|  |           v-model="state.slug" | ||||||
|  |           :placeholder="t('HELP_CENTER.PORTAL_SETTINGS.FORM.SLUG.PLACEHOLDER')" | ||||||
|  |           class="w-[432px]" | ||||||
|  |           :message-type="slugError ? 'error' : 'info'" | ||||||
|  |           :message="slugError || buildPortalURL(state.slug)" | ||||||
|  |           custom-input-class="!bg-transparent dark:!bg-transparent" | ||||||
|  |           @input="v$.slug.$touch()" | ||||||
|  |           @blur="v$.slug.$touch()" | ||||||
|  |         /> | ||||||
|  |       </div> | ||||||
|  |       <div class="flex items-start justify-between w-full gap-2"> | ||||||
|  |         <label | ||||||
|  |           class="text-sm font-medium whitespace-nowrap min-w-[100px] py-2.5 text-slate-900 dark:text-slate-50" | ||||||
|  |         > | ||||||
|  |           {{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.LABEL') }} | ||||||
|  |         </label> | ||||||
|  |         <ComboBox | ||||||
|  |           v-model="state.liveChatWidgetInboxId" | ||||||
|  |           :options="liveChatWidgets" | ||||||
|  |           :placeholder=" | ||||||
|  |             t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.PLACEHOLDER') | ||||||
|  |           " | ||||||
|  |           :message=" | ||||||
|  |             t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.HELP_TEXT') | ||||||
|  |           " | ||||||
|  |           class="[&>button]:w-[432px] !w-[432px]" | ||||||
|  |         /> | ||||||
|  |       </div> | ||||||
|  |       <div class="flex items-start justify-between w-full gap-2"> | ||||||
|  |         <label | ||||||
|  |           class="text-sm font-medium whitespace-nowrap min-w-[100px] py-2.5 text-slate-900 dark:text-slate-50" | ||||||
|  |         > | ||||||
|  |           {{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.BRAND_COLOR.LABEL') }} | ||||||
|  |         </label> | ||||||
|  |         <div class="w-[432px] justify-start"> | ||||||
|  |           <ColorPicker v-model="state.widgetColor" /> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |       <div class="flex justify-end w-full gap-2"> | ||||||
|  |         <Button | ||||||
|  |           :label="t('HELP_CENTER.PORTAL_SETTINGS.FORM.SAVE_CHANGES')" | ||||||
|  |           :disabled="!hasChanges || isUpdatingPortal || v$.$invalid" | ||||||
|  |           :is-loading="isUpdatingPortal" | ||||||
|  |           @click="handleUpdatePortal" | ||||||
|  |         /> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,118 @@ | |||||||
|  | <script setup> | ||||||
|  | import { computed, ref } from 'vue'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  |  | ||||||
|  | import AddCustomDomainDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/AddCustomDomainDialog.vue'; | ||||||
|  | import DNSConfigurationDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/DNSConfigurationDialog.vue'; | ||||||
|  | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   activePortal: { | ||||||
|  |     type: Object, | ||||||
|  |     required: true, | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['updatePortalConfiguration']); | ||||||
|  |  | ||||||
|  | const { t } = useI18n(); | ||||||
|  |  | ||||||
|  | const addCustomDomainDialogRef = ref(null); | ||||||
|  | const dnsConfigurationDialogRef = ref(null); | ||||||
|  | const updatedDomainAddress = ref(''); | ||||||
|  |  | ||||||
|  | const customDomainAddress = computed( | ||||||
|  |   () => props.activePortal?.custom_domain || '' | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const updatePortalConfiguration = customDomain => { | ||||||
|  |   const portal = { | ||||||
|  |     id: props.activePortal?.id, | ||||||
|  |     custom_domain: customDomain, | ||||||
|  |   }; | ||||||
|  |   emit('updatePortalConfiguration', portal); | ||||||
|  |   addCustomDomainDialogRef.value.dialogRef.close(); | ||||||
|  |   if (customDomain) { | ||||||
|  |     updatedDomainAddress.value = customDomain; | ||||||
|  |     dnsConfigurationDialogRef.value.dialogRef.open(); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const closeDNSConfigurationDialog = () => { | ||||||
|  |   updatedDomainAddress.value = ''; | ||||||
|  |   dnsConfigurationDialogRef.value.dialogRef.close(); | ||||||
|  | }; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <div class="flex flex-col w-full gap-6"> | ||||||
|  |     <div class="flex flex-col gap-2"> | ||||||
|  |       <h6 class="text-base font-medium text-n-slate-12"> | ||||||
|  |         {{ | ||||||
|  |           t( | ||||||
|  |             'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.HEADER' | ||||||
|  |           ) | ||||||
|  |         }} | ||||||
|  |       </h6> | ||||||
|  |       <span class="text-sm text-n-slate-11"> | ||||||
|  |         {{ | ||||||
|  |           t( | ||||||
|  |             'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DESCRIPTION' | ||||||
|  |           ) | ||||||
|  |         }} | ||||||
|  |       </span> | ||||||
|  |     </div> | ||||||
|  |     <div class="flex flex-col w-full gap-4"> | ||||||
|  |       <div class="flex justify-between w-full gap-2"> | ||||||
|  |         <div | ||||||
|  |           v-if="customDomainAddress" | ||||||
|  |           class="flex items-center w-full h-8 gap-4" | ||||||
|  |         > | ||||||
|  |           <label class="text-sm font-medium text-n-slate-12"> | ||||||
|  |             {{ | ||||||
|  |               t( | ||||||
|  |                 'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.LABEL' | ||||||
|  |               ) | ||||||
|  |             }} | ||||||
|  |           </label> | ||||||
|  |           <span class="text-sm text-n-slate-12"> | ||||||
|  |             {{ customDomainAddress }} | ||||||
|  |           </span> | ||||||
|  |         </div> | ||||||
|  |         <div class="flex items-center justify-end w-full"> | ||||||
|  |           <Button | ||||||
|  |             v-if="customDomainAddress" | ||||||
|  |             :label=" | ||||||
|  |               t( | ||||||
|  |                 'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.EDIT_BUTTON' | ||||||
|  |               ) | ||||||
|  |             " | ||||||
|  |             variant="secondary" | ||||||
|  |             @click="addCustomDomainDialogRef.dialogRef.open()" | ||||||
|  |           /> | ||||||
|  |           <Button | ||||||
|  |             v-else | ||||||
|  |             :label=" | ||||||
|  |               t( | ||||||
|  |                 'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.ADD_BUTTON' | ||||||
|  |               ) | ||||||
|  |             " | ||||||
|  |             variant="secondary" | ||||||
|  |             @click="addCustomDomainDialogRef.dialogRef.open()" | ||||||
|  |           /> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |     <AddCustomDomainDialog | ||||||
|  |       ref="addCustomDomainDialogRef" | ||||||
|  |       :mode="customDomainAddress ? 'edit' : 'add'" | ||||||
|  |       :custom-domain="customDomainAddress" | ||||||
|  |       @add-custom-domain="updatePortalConfiguration" | ||||||
|  |     /> | ||||||
|  |     <DNSConfigurationDialog | ||||||
|  |       ref="dnsConfigurationDialogRef" | ||||||
|  |       :custom-domain="updatedDomainAddress || customDomainAddress" | ||||||
|  |       @confirm="closeDNSConfigurationDialog" | ||||||
|  |     /> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
| @@ -1,113 +1,130 @@ | |||||||
| <script setup> | <script setup> | ||||||
| import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue'; | import { computed, ref } from 'vue'; | ||||||
| import Button from 'dashboard/components-next/button/Button.vue'; | import { useRoute } from 'vue-router'; | ||||||
| import Input from 'dashboard/components-next/input/Input.vue'; | import { useI18n } from 'vue-i18n'; | ||||||
| import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue'; | import { useMapGetter } from 'dashboard/composables/store.js'; | ||||||
| import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; |  | ||||||
|  |  | ||||||
| const handleUploadAvatar = () => {}; | import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue'; | ||||||
|  | import PortalBaseSettings from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue'; | ||||||
|  | import PortalConfigurationSettings from './PortalConfigurationSettings.vue'; | ||||||
|  | import ConfirmDeletePortalDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/ConfirmDeletePortalDialog.vue'; | ||||||
|  | import Spinner from 'dashboard/components-next/spinner/Spinner.vue'; | ||||||
|  | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   portals: { | ||||||
|  |     type: Array, | ||||||
|  |     required: true, | ||||||
|  |   }, | ||||||
|  |   isFetching: { | ||||||
|  |     type: Boolean, | ||||||
|  |     required: true, | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits([ | ||||||
|  |   'updatePortal', | ||||||
|  |   'updatePortalConfiguration', | ||||||
|  |   'deletePortal', | ||||||
|  | ]); | ||||||
|  |  | ||||||
|  | const { t } = useI18n(); | ||||||
|  | const route = useRoute(); | ||||||
|  |  | ||||||
|  | const confirmDeletePortalDialogRef = ref(null); | ||||||
|  |  | ||||||
|  | const currentPortalSlug = computed(() => route.params.portalSlug); | ||||||
|  |  | ||||||
|  | const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal'); | ||||||
|  |  | ||||||
|  | const activePortal = computed(() => { | ||||||
|  |   return props.portals?.find(portal => portal.slug === currentPortalSlug.value); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const activePortalName = computed(() => activePortal.value?.name || ''); | ||||||
|  |  | ||||||
|  | const isLoading = computed(() => props.isFetching || isSwitchingPortal.value); | ||||||
|  |  | ||||||
|  | const handleUpdatePortal = portal => { | ||||||
|  |   emit('updatePortal', portal); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleUpdatePortalConfiguration = portal => { | ||||||
|  |   emit('updatePortalConfiguration', portal); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const openConfirmDeletePortalDialog = () => { | ||||||
|  |   confirmDeletePortalDialogRef.value.dialogRef.open(); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleDeletePortal = () => { | ||||||
|  |   emit('deletePortal', activePortal.value); | ||||||
|  |   confirmDeletePortalDialogRef.value.dialogRef.close(); | ||||||
|  | }; | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <!-- TODO: Add i18n --> |  | ||||||
| <!-- eslint-disable vue/no-bare-strings-in-template --> |  | ||||||
| <template> | <template> | ||||||
|   <HelpCenterLayout :show-pagination-footer="false"> |   <HelpCenterLayout :show-pagination-footer="false"> | ||||||
|     <template #content> |     <template #content> | ||||||
|       <div class="flex flex-col w-full gap-10 max-w-[640px] pt-2 pb-8"> |       <div | ||||||
|         <div class="flex flex-col w-full gap-4"> |         v-if="isLoading" | ||||||
|           <div class="flex flex-col w-full gap-2"> |         class="flex items-center justify-center py-10 pt-2 pb-8 text-n-slate-11" | ||||||
|             <label |       > | ||||||
|               class="mb-0.5 text-sm font-medium text-gray-900 dark:text-gray-50" |         <Spinner /> | ||||||
|             > |       </div> | ||||||
|               Avatar |       <div | ||||||
|             </label> |         v-else-if="activePortal" | ||||||
|             <Avatar |         class="flex flex-col w-full gap-4 max-w-[640px] pb-8" | ||||||
|               label="Avatar" |       > | ||||||
|               src="https://api.dicebear.com/9.x/avataaars/svg?seed=Amaya" |         <PortalBaseSettings | ||||||
|               class="bg-ruby-300 dark:bg-ruby-400" |           :active-portal="activePortal" | ||||||
|               @upload="handleUploadAvatar" |           :is-fetching="isFetching" | ||||||
|             /> |           @update-portal="handleUpdatePortal" | ||||||
|           </div> |         /> | ||||||
|           <div class="flex flex-col w-full gap-2"> |         <div class="w-full h-px bg-slate-50 dark:bg-slate-800/50" /> | ||||||
|             <div class="flex justify-between w-full h-10 gap-2 py-1"> |         <PortalConfigurationSettings | ||||||
|               <label |           :active-portal="activePortal" | ||||||
|                 class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 dark:text-slate-50" |           :is-fetching="isFetching" | ||||||
|               > |           @update-portal-configuration="handleUpdatePortalConfiguration" | ||||||
|                 Name |         /> | ||||||
|               </label> |         <div class="w-full h-px bg-slate-50 dark:bg-slate-800/50" /> | ||||||
|               <Input placeholder="Name" class="w-[432px]" /> |         <div class="flex items-end justify-between w-full gap-4"> | ||||||
|             </div> |           <div class="flex flex-col gap-2"> | ||||||
|             <div class="flex justify-between w-full h-10 gap-2 py-1"> |             <h6 class="text-base font-medium text-n-slate-12"> | ||||||
|               <label |               {{ | ||||||
|                 class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 dark:text-slate-50" |                 t( | ||||||
|               > |                   'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.HEADER' | ||||||
|                 Header text |                 ) | ||||||
|               </label> |               }} | ||||||
|               <Input placeholder="Header text" class="w-[432px]" /> |  | ||||||
|             </div> |  | ||||||
|             <div class="flex justify-between w-full h-10 gap-2 py-1"> |  | ||||||
|               <label |  | ||||||
|                 class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 dark:text-slate-50" |  | ||||||
|               > |  | ||||||
|                 Page title |  | ||||||
|               </label> |  | ||||||
|               <Input placeholder="Page title" class="w-[432px]" /> |  | ||||||
|             </div> |  | ||||||
|             <div class="flex justify-between w-full h-10 gap-2 py-1"> |  | ||||||
|               <label |  | ||||||
|                 class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 dark:text-slate-50" |  | ||||||
|               > |  | ||||||
|                 Widget color |  | ||||||
|               </label> |  | ||||||
|             </div> |  | ||||||
|             <div class="flex justify-end w-full gap-2 py-2"> |  | ||||||
|               <Button label="Save changes" size="sm" /> |  | ||||||
|             </div> |  | ||||||
|           </div> |  | ||||||
|           <div class="w-full h-px bg-slate-50 dark:bg-slate-800/50" /> |  | ||||||
|         </div> |  | ||||||
|         <div class="flex flex-col w-full gap-6"> |  | ||||||
|           <div class="flex flex-col w-full gap-6"> |  | ||||||
|             <h6 class="text-base font-medium text-slate-900 dark:text-slate-50"> |  | ||||||
|               Configuration |  | ||||||
|             </h6> |             </h6> | ||||||
|             <div class="flex flex-col w-full gap-4"> |             <span class="text-sm text-n-slate-11"> | ||||||
|               <div class="flex justify-between w-full gap-2 py-1"> |               {{ | ||||||
|                 <InlineInput |                 t( | ||||||
|                   placeholder="Slug" |                   'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.DESCRIPTION' | ||||||
|                   label="Slug:" |                 ) | ||||||
|                   custom-label-class="min-w-[100px]" |               }} | ||||||
|                   custom-input-class="!w-[430px]" |             </span> | ||||||
|                 /> |  | ||||||
|               </div> |  | ||||||
|               <div class="flex justify-between w-full gap-2 py-1"> |  | ||||||
|                 <InlineInput |  | ||||||
|                   placeholder="Custom domain" |  | ||||||
|                   label="Custom domain:" |  | ||||||
|                   custom-label-class="min-w-[100px]" |  | ||||||
|                   custom-input-class="!w-[430px]" |  | ||||||
|                 /> |  | ||||||
|               </div> |  | ||||||
|               <div class="flex justify-between w-full gap-2 py-1"> |  | ||||||
|                 <InlineInput |  | ||||||
|                   placeholder="Home page link" |  | ||||||
|                   label="Home page link:" |  | ||||||
|                   custom-label-class="min-w-[100px]" |  | ||||||
|                   custom-input-class="!w-[430px]" |  | ||||||
|                 /> |  | ||||||
|               </div> |  | ||||||
|             </div> |  | ||||||
|           </div> |  | ||||||
|           <div class="flex justify-end w-full gap-3 py-4"> |  | ||||||
|             <Button label="Edit configuration" size="sm" variant="secondary" /> |  | ||||||
|             <Button |  | ||||||
|               label="Delete Test-Help Center" |  | ||||||
|               size="sm" |  | ||||||
|               variant="destructive" |  | ||||||
|             /> |  | ||||||
|           </div> |           </div> | ||||||
|  |           <Button | ||||||
|  |             :label=" | ||||||
|  |               t( | ||||||
|  |                 'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.BUTTON', | ||||||
|  |                 { | ||||||
|  |                   portalName: activePortalName, | ||||||
|  |                 } | ||||||
|  |               ) | ||||||
|  |             " | ||||||
|  |             variant="destructive" | ||||||
|  |             class="w-56" | ||||||
|  |             @click="openConfirmDeletePortalDialog" | ||||||
|  |           /> | ||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
|     </template> |     </template> | ||||||
|  |     <ConfirmDeletePortalDialog | ||||||
|  |       ref="confirmDeletePortalDialogRef" | ||||||
|  |       :active-portal-name="activePortalName" | ||||||
|  |       @delete-portal="handleDeletePortal" | ||||||
|  |     /> | ||||||
|   </HelpCenterLayout> |   </HelpCenterLayout> | ||||||
| </template> | </template> | ||||||
|   | |||||||
| @@ -0,0 +1,148 @@ | |||||||
|  | <script setup> | ||||||
|  | import { ref, reactive, watch, computed } from 'vue'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  | import { useStore, useMapGetter } from 'dashboard/composables/store'; | ||||||
|  | import { useAlert, useTrack } from 'dashboard/composables'; | ||||||
|  | import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; | ||||||
|  | import { convertToCategorySlug } from 'dashboard/helper/commons.js'; | ||||||
|  | import { useVuelidate } from '@vuelidate/core'; | ||||||
|  | import { required, minLength } from '@vuelidate/validators'; | ||||||
|  | import { buildPortalURL } from 'dashboard/helper/portalHelper'; | ||||||
|  |  | ||||||
|  | import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; | ||||||
|  | import Input from 'dashboard/components-next/input/Input.vue'; | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['create']); | ||||||
|  |  | ||||||
|  | const { t } = useI18n(); | ||||||
|  | const store = useStore(); | ||||||
|  |  | ||||||
|  | const dialogRef = ref(null); | ||||||
|  |  | ||||||
|  | const isCreatingPortal = useMapGetter('portals/isCreatingPortal'); | ||||||
|  |  | ||||||
|  | const state = reactive({ | ||||||
|  |   name: '', | ||||||
|  |   slug: '', | ||||||
|  |   domain: '', | ||||||
|  |   logoUrl: '', | ||||||
|  |   avatarBlobId: '', | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const rules = { | ||||||
|  |   name: { required, minLength: minLength(2) }, | ||||||
|  |   slug: { required }, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const v$ = useVuelidate(rules, state); | ||||||
|  |  | ||||||
|  | const nameError = computed(() => | ||||||
|  |   v$.value.name.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.ERROR') : '' | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const slugError = computed(() => | ||||||
|  |   v$.value.slug.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.ERROR') : '' | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const isSubmitDisabled = computed(() => v$.value.$invalid); | ||||||
|  |  | ||||||
|  | watch( | ||||||
|  |   () => state.name, | ||||||
|  |   () => { | ||||||
|  |     state.slug = convertToCategorySlug(state.name); | ||||||
|  |   } | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const redirectToPortal = portal => { | ||||||
|  |   emit('create', { slug: portal.slug, locale: 'en' }); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const resetForm = () => { | ||||||
|  |   Object.keys(state).forEach(key => { | ||||||
|  |     state[key] = ''; | ||||||
|  |   }); | ||||||
|  |   v$.value.$reset(); | ||||||
|  | }; | ||||||
|  | const createPortal = async portal => { | ||||||
|  |   try { | ||||||
|  |     await store.dispatch('portals/create', portal); | ||||||
|  |     dialogRef.value.close(); | ||||||
|  |  | ||||||
|  |     const analyticsPayload = { | ||||||
|  |       has_custom_domain: Boolean(portal.custom_domain), | ||||||
|  |     }; | ||||||
|  |     useTrack(PORTALS_EVENTS.ONBOARD_BASIC_INFORMATION, analyticsPayload); | ||||||
|  |     useTrack(PORTALS_EVENTS.CREATE_PORTAL, analyticsPayload); | ||||||
|  |  | ||||||
|  |     useAlert( | ||||||
|  |       t('HELP_CENTER.PORTAL_SETTINGS.API.CREATE_PORTAL.SUCCESS_MESSAGE') | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     resetForm(); | ||||||
|  |     redirectToPortal(portal); | ||||||
|  |   } catch (error) { | ||||||
|  |     dialogRef.value.close(); | ||||||
|  |  | ||||||
|  |     useAlert( | ||||||
|  |       error?.message || | ||||||
|  |         t('HELP_CENTER.PORTAL_SETTINGS.API.CREATE_PORTAL.ERROR_MESSAGE') | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleDialogConfirm = async () => { | ||||||
|  |   const isFormCorrect = await v$.value.$validate(); | ||||||
|  |   if (!isFormCorrect) return; | ||||||
|  |  | ||||||
|  |   const portal = { | ||||||
|  |     name: state.name, | ||||||
|  |     slug: state.slug, | ||||||
|  |     custom_domain: state.domain, | ||||||
|  |     blob_id: state.avatarBlobId || null, | ||||||
|  |     color: '#2781F6', // The default color is set to Chatwoot brand color | ||||||
|  |   }; | ||||||
|  |   await createPortal(portal); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | defineExpose({ dialogRef }); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <Dialog | ||||||
|  |     ref="dialogRef" | ||||||
|  |     type="edit" | ||||||
|  |     :title="t('HELP_CENTER.CREATE_PORTAL_DIALOG.TITLE')" | ||||||
|  |     :confirm-button-label=" | ||||||
|  |       t('HELP_CENTER.CREATE_PORTAL_DIALOG.CONFIRM_BUTTON_LABEL') | ||||||
|  |     " | ||||||
|  |     :description="t('HELP_CENTER.CREATE_PORTAL_DIALOG.DESCRIPTION')" | ||||||
|  |     :disable-confirm-button="isSubmitDisabled || isCreatingPortal" | ||||||
|  |     :is-loading="isCreatingPortal" | ||||||
|  |     @confirm="handleDialogConfirm" | ||||||
|  |   > | ||||||
|  |     <template #form> | ||||||
|  |       <div class="flex flex-col gap-6"> | ||||||
|  |         <Input | ||||||
|  |           id="portal-name" | ||||||
|  |           v-model="state.name" | ||||||
|  |           type="text" | ||||||
|  |           :placeholder="t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.PLACEHOLDER')" | ||||||
|  |           :label="t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.LABEL')" | ||||||
|  |           :message-type="nameError ? 'error' : 'info'" | ||||||
|  |           :message=" | ||||||
|  |             nameError || t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.MESSAGE') | ||||||
|  |           " | ||||||
|  |         /> | ||||||
|  |         <Input | ||||||
|  |           id="portal-slug" | ||||||
|  |           v-model="state.slug" | ||||||
|  |           type="text" | ||||||
|  |           :placeholder="t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.PLACEHOLDER')" | ||||||
|  |           :label="t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.LABEL')" | ||||||
|  |           :message-type="slugError ? 'error' : 'info'" | ||||||
|  |           :message="slugError || buildPortalURL(state.slug)" | ||||||
|  |         /> | ||||||
|  |       </div> | ||||||
|  |     </template> | ||||||
|  |   </Dialog> | ||||||
|  | </template> | ||||||
| @@ -1,111 +1,143 @@ | |||||||
| <script setup> | <script setup> | ||||||
| import { ref } from 'vue'; | import { computed } from 'vue'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  | import { useRoute, useRouter } from 'vue-router'; | ||||||
|  | import { useMapGetter, useStore } from 'dashboard/composables/store.js'; | ||||||
|  |  | ||||||
| import Button from 'dashboard/components-next/button/Button.vue'; | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
|  | import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue'; | ||||||
|  |  | ||||||
| defineProps({ | const emit = defineEmits(['close', 'createPortal']); | ||||||
|   portals: { |  | ||||||
|     type: Array, |  | ||||||
|     default: () => [ |  | ||||||
|       { |  | ||||||
|         id: 1, |  | ||||||
|         name: 'Chatwoot Help Center', |  | ||||||
|         articles: 67, |  | ||||||
|         domain: 'chatwoot.help', |  | ||||||
|         slug: 'help-center', |  | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         id: 2, |  | ||||||
|         name: 'Chatwoot Handbook', |  | ||||||
|         articles: 42, |  | ||||||
|         domain: 'chatwoot.help', |  | ||||||
|         slug: 'handbook', |  | ||||||
|       }, |  | ||||||
|     ], |  | ||||||
|   }, |  | ||||||
|   header: { |  | ||||||
|     type: String, |  | ||||||
|     default: 'Portals', |  | ||||||
|   }, |  | ||||||
|   description: { |  | ||||||
|     type: String, |  | ||||||
|     default: 'Create and manage multiple portals', |  | ||||||
|   }, |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| const selectedPortal = ref(1); | const { t } = useI18n(); | ||||||
|  | const route = useRoute(); | ||||||
|  | const router = useRouter(); | ||||||
|  | const store = useStore(); | ||||||
|  |  | ||||||
| const handlePortalChange = id => { | const DEFAULT_ROUTE = 'portals_articles_index'; | ||||||
|   selectedPortal.value = id; | const CATEGORY_ROUTE = 'portals_categories_index'; | ||||||
|  | const CATEGORY_SUB_ROUTES = [ | ||||||
|  |   'portals_categories_articles_index', | ||||||
|  |   'portals_categories_articles_edit', | ||||||
|  | ]; | ||||||
|  |  | ||||||
|  | const portals = useMapGetter('portals/allPortals'); | ||||||
|  |  | ||||||
|  | const currentPortalSlug = computed(() => route.params.portalSlug); | ||||||
|  |  | ||||||
|  | const isPortalActive = portal => { | ||||||
|  |   return portal.slug === currentPortalSlug.value; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const getPortalThumbnailSrc = portal => { | ||||||
|  |   return portal?.logo?.file_url || ''; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const fetchPortalAndItsCategories = async (slug, locale) => { | ||||||
|  |   await store.dispatch('portals/switchPortal', true); | ||||||
|  |   await store.dispatch('portals/index'); | ||||||
|  |   const selectedPortalParam = { | ||||||
|  |     portalSlug: slug, | ||||||
|  |     locale, | ||||||
|  |   }; | ||||||
|  |   await store.dispatch('portals/show', selectedPortalParam); | ||||||
|  |   await store.dispatch('categories/index', selectedPortalParam); | ||||||
|  |   await store.dispatch('agents/get'); | ||||||
|  |   await store.dispatch('portals/switchPortal', false); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handlePortalChange = async portal => { | ||||||
|  |   if (isPortalActive(portal)) return; | ||||||
|  |   const { | ||||||
|  |     slug, | ||||||
|  |     meta: { default_locale: defaultLocale }, | ||||||
|  |   } = portal; | ||||||
|  |   emit('close'); | ||||||
|  |   await fetchPortalAndItsCategories(slug, defaultLocale); | ||||||
|  |   const targetRouteName = CATEGORY_SUB_ROUTES.includes(route.name) | ||||||
|  |     ? CATEGORY_ROUTE | ||||||
|  |     : route.name || DEFAULT_ROUTE; | ||||||
|  |   router.push({ | ||||||
|  |     name: targetRouteName, | ||||||
|  |     params: { | ||||||
|  |       portalSlug: slug, | ||||||
|  |       locale: defaultLocale, | ||||||
|  |     }, | ||||||
|  |   }); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const openCreatePortalDialog = () => { | ||||||
|  |   emit('createPortal'); | ||||||
|  |   emit('close'); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const redirectToPortalHomePage = () => { | ||||||
|  |   router.push({ | ||||||
|  |     name: 'portals_index', | ||||||
|  |     params: { | ||||||
|  |       navigationPath: DEFAULT_ROUTE, | ||||||
|  |     }, | ||||||
|  |   }); | ||||||
| }; | }; | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <!-- TODO: Add i18n --> |  | ||||||
| <!-- eslint-disable vue/no-bare-strings-in-template --> |  | ||||||
| <template> | <template> | ||||||
|   <div |   <div | ||||||
|     class="pt-5 pb-3 bg-white z-50 dark:bg-slate-800 absolute w-[440px] rounded-xl shadow-md flex flex-col gap-4" |     class="pt-5 pb-3 bg-n-alpha-3 backdrop-blur-[100px] z-50 absolute w-[440px] rounded-xl shadow-md flex flex-col gap-4" | ||||||
|   > |   > | ||||||
|     <div class="flex items-center justify-between gap-4 px-6 pb-2"> |     <div | ||||||
|  |       class="flex items-center justify-between gap-4 px-6 pb-3 border-b border-n-alpha-2" | ||||||
|  |     > | ||||||
|       <div class="flex flex-col gap-1"> |       <div class="flex flex-col gap-1"> | ||||||
|         <h2 class="text-base font-medium text-slate-900 dark:text-slate-50"> |         <h2 | ||||||
|           {{ header }} |           class="text-base font-medium cursor-pointer text-slate-900 dark:text-slate-50 w-fit hover:underline" | ||||||
|  |           @click="redirectToPortalHomePage" | ||||||
|  |         > | ||||||
|  |           {{ t('HELP_CENTER.PORTAL_SWITCHER.PORTALS') }} | ||||||
|         </h2> |         </h2> | ||||||
|         <p class="text-sm text-slate-600 dark:text-slate-300"> |         <p class="text-sm text-slate-600 dark:text-slate-300"> | ||||||
|           {{ description }} |           {{ t('HELP_CENTER.PORTAL_SWITCHER.CREATE_PORTAL') }} | ||||||
|         </p> |         </p> | ||||||
|       </div> |       </div> | ||||||
|       <Button label="New portal" variant="secondary" icon="add" size="sm" /> |       <Button | ||||||
|  |         :label="t('HELP_CENTER.PORTAL_SWITCHER.NEW_PORTAL')" | ||||||
|  |         variant="secondary" | ||||||
|  |         icon="add" | ||||||
|  |         size="sm" | ||||||
|  |         class="!bg-n-alpha-2 hover:!bg-n-alpha-3" | ||||||
|  |         @click="openCreatePortalDialog" | ||||||
|  |       /> | ||||||
|     </div> |     </div> | ||||||
|     <div v-if="portals.length > 0" class="flex flex-col gap-3"> |     <div v-if="portals.length > 0" class="flex flex-col gap-2 px-4"> | ||||||
|       <template v-for="(portal, index) in portals" :key="portal.id"> |       <Button | ||||||
|         <div class="flex flex-col gap-2 px-6 py-2"> |         v-for="(portal, index) in portals" | ||||||
|           <div class="flex items-center justify-between"> |         :key="index" | ||||||
|             <div class="flex items-center"> |         :label="portal.name" | ||||||
|               <input |         variant="ghost" | ||||||
|                 :id="portal.id" |         :icon="isPortalActive(portal) ? 'checkmark-lucide' : ''" | ||||||
|                 v-model="selectedPortal" |         icon-lib="lucide" | ||||||
|                 type="radio" |         icon-position="right" | ||||||
|                 :value="portal.id" |         class="!justify-start !px-2 !py-2 hover:!bg-n-alpha-2 [&>svg]:text-n-teal-10 [&>svg]:w-5 [&>svg]:h-5 h-9" | ||||||
|                 class="mr-3" |         size="sm" | ||||||
|                 @change="handlePortalChange(portal.id)" |         @click="handlePortalChange(portal)" | ||||||
|               /> |       > | ||||||
|               <label |         <template #leftPrefix> | ||||||
|                 :for="portal.id" |           <Thumbnail | ||||||
|                 class="text-sm font-medium text-slate-900 dark:text-slate-100" |             v-if="portal" | ||||||
|               > |             :author="portal" | ||||||
|                 {{ portal.name }} |             :name="portal.name" | ||||||
|               </label> |             :size="20" | ||||||
|             </div> |             :src="getPortalThumbnailSrc(portal)" | ||||||
|             <div class="w-4 h-4 rounded-full bg-slate-100 dark:bg-slate-700" /> |             :show-author-name="false" | ||||||
|           </div> |             icon-name="building-lucide" | ||||||
|           <div class="inline-flex items-center gap-2 py-1 text-sm"> |           /> | ||||||
|             <span class="text-slate-600 dark:text-slate-400"> |         </template> | ||||||
|               articles: |         <template #rightPrefix> | ||||||
|               <span class="text-slate-800 dark:text-slate-200"> |           <span class="text-sm truncate text-n-slate-11"> | ||||||
|                 {{ portal.articles }} |             {{ portal.custom_domain || '' }} | ||||||
|               </span> |           </span> | ||||||
|             </span> |         </template> | ||||||
|             <div class="w-px h-3 bg-slate-50 dark:bg-slate-700" /> |       </Button> | ||||||
|             <span class="text-slate-600 dark:text-slate-400"> |  | ||||||
|               domain: |  | ||||||
|               <span class="text-slate-800 dark:text-slate-200"> |  | ||||||
|                 {{ portal.domain }} |  | ||||||
|               </span> |  | ||||||
|             </span> |  | ||||||
|             <div class="w-px h-3 bg-slate-50 dark:bg-slate-700" /> |  | ||||||
|             <span class="text-slate-600 dark:text-slate-400"> |  | ||||||
|               slug: |  | ||||||
|               <span class="text-slate-800 dark:text-slate-200"> |  | ||||||
|                 {{ portal.slug }} |  | ||||||
|               </span> |  | ||||||
|             </span> |  | ||||||
|           </div> |  | ||||||
|         </div> |  | ||||||
|         <div |  | ||||||
|           v-if="index < portals.length - 1 && portals.length > 1" |  | ||||||
|           class="w-full h-px bg-slate-50 dark:bg-slate-700/50" |  | ||||||
|         /> |  | ||||||
|       </template> |  | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
|   | |||||||
| @@ -24,14 +24,14 @@ import Avatar from './Avatar.vue'; | |||||||
|     </Variant> |     </Variant> | ||||||
|  |  | ||||||
|     <Variant title="Invalid or empty SRC"> |     <Variant title="Invalid or empty SRC"> | ||||||
|       <div class="p-4 bg-white dark:bg-slate-900 space-x-4"> |       <div class="p-4 space-x-4 bg-white dark:bg-slate-900"> | ||||||
|         <Avatar src="https://example.com/ruby.png" name="Ruby" allow-upload /> |         <Avatar src="https://example.com/ruby.png" name="Ruby" allow-upload /> | ||||||
|         <Avatar name="Bruce Wayne" allow-upload /> |         <Avatar name="Bruce Wayne" allow-upload /> | ||||||
|       </div> |       </div> | ||||||
|     </Variant> |     </Variant> | ||||||
|  |  | ||||||
|     <Variant title="Rounded Full"> |     <Variant title="Rounded Full"> | ||||||
|       <div class="p-4 bg-white dark:bg-slate-900 space-x-4"> |       <div class="p-4 space-x-4 bg-white dark:bg-slate-900"> | ||||||
|         <Avatar |         <Avatar | ||||||
|           src="https://api.dicebear.com/9.x/thumbs/svg?seed=Amaya" |           src="https://api.dicebear.com/9.x/thumbs/svg?seed=Amaya" | ||||||
|           allow-upload |           allow-upload | ||||||
|   | |||||||
| @@ -0,0 +1,36 @@ | |||||||
|  | <script setup> | ||||||
|  | import EditableAvatar from './EditableAvatar.vue'; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <Story title="Components/Avatar" :layout="{ type: 'grid', width: '400' }"> | ||||||
|  |     <Variant title="Default"> | ||||||
|  |       <div class="p-4 bg-white dark:bg-slate-900"> | ||||||
|  |         <EditableAvatar | ||||||
|  |           src="https://api.dicebear.com/9.x/avataaars/svg?seed=Amaya" | ||||||
|  |           class="bg-ruby-300 dark:bg-ruby-900" | ||||||
|  |         /> | ||||||
|  |       </div> | ||||||
|  |     </Variant> | ||||||
|  |  | ||||||
|  |     <Variant title="Different Sizes"> | ||||||
|  |       <div class="flex flex-wrap gap-4 p-4 bg-white dark:bg-slate-900"> | ||||||
|  |         <EditableAvatar | ||||||
|  |           src="https://api.dicebear.com/9.x/avataaars/svg?seed=Felix" | ||||||
|  |           :size="48" | ||||||
|  |           class="bg-green-300 dark:bg-green-900" | ||||||
|  |         /> | ||||||
|  |         <EditableAvatar | ||||||
|  |           :size="72" | ||||||
|  |           src="https://api.dicebear.com/9.x/avataaars/svg?seed=Jade" | ||||||
|  |           class="bg-indigo-300 dark:bg-indigo-900" | ||||||
|  |         /> | ||||||
|  |         <EditableAvatar | ||||||
|  |           src="https://api.dicebear.com/9.x/avataaars/svg?seed=Emery" | ||||||
|  |           :size="96" | ||||||
|  |           class="bg-woot-300 dark:bg-woot-900" | ||||||
|  |         /> | ||||||
|  |       </div> | ||||||
|  |     </Variant> | ||||||
|  |   </Story> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,107 @@ | |||||||
|  | <script setup> | ||||||
|  | import { computed, ref } from 'vue'; | ||||||
|  |  | ||||||
|  | import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue'; | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   src: { | ||||||
|  |     type: String, | ||||||
|  |     default: '', | ||||||
|  |   }, | ||||||
|  |   size: { | ||||||
|  |     type: Number, | ||||||
|  |     default: 72, | ||||||
|  |   }, | ||||||
|  |   name: { | ||||||
|  |     type: String, | ||||||
|  |     default: '', | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['upload', 'delete']); | ||||||
|  |  | ||||||
|  | const avatarSize = computed(() => `${props.size}px`); | ||||||
|  | const iconSize = computed(() => `${props.size / 2}px`); | ||||||
|  |  | ||||||
|  | const fileInput = ref(null); | ||||||
|  | const imgError = ref(false); | ||||||
|  |  | ||||||
|  | const shouldShowImage = computed(() => props.src && !imgError.value); | ||||||
|  |  | ||||||
|  | const handleUploadAvatar = () => { | ||||||
|  |   fileInput.value.click(); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleImageUpload = event => { | ||||||
|  |   const [file] = event.target.files; | ||||||
|  |   if (file) { | ||||||
|  |     emit('upload', { | ||||||
|  |       file, | ||||||
|  |       url: file ? URL.createObjectURL(file) : null, | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleDeleteAvatar = () => { | ||||||
|  |   if (fileInput.value) { | ||||||
|  |     fileInput.value.value = null; | ||||||
|  |   } | ||||||
|  |   emit('delete'); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleDismiss = event => { | ||||||
|  |   event.stopPropagation(); | ||||||
|  |   handleDeleteAvatar(); | ||||||
|  | }; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <div | ||||||
|  |     class="relative flex flex-col items-center gap-2 select-none rounded-xl group/avatar" | ||||||
|  |     :style="{ width: avatarSize, height: avatarSize }" | ||||||
|  |   > | ||||||
|  |     <img | ||||||
|  |       v-if="shouldShowImage" | ||||||
|  |       :src="src" | ||||||
|  |       :alt="name || 'avatar'" | ||||||
|  |       class="object-cover w-full h-full shadow-sm rounded-xl" | ||||||
|  |       @error="imgError = true" | ||||||
|  |     /> | ||||||
|  |     <div | ||||||
|  |       v-else | ||||||
|  |       class="flex items-center justify-center w-full h-full rounded-xl bg-n-alpha-2" | ||||||
|  |     > | ||||||
|  |       <FluentIcon | ||||||
|  |         icon="building-lucide" | ||||||
|  |         icon-lib="lucide" | ||||||
|  |         :size="iconSize" | ||||||
|  |         class="dark:text-n-brand/50 text-n-brand/30" | ||||||
|  |       /> | ||||||
|  |     </div> | ||||||
|  |     <div | ||||||
|  |       v-if="src" | ||||||
|  |       class="absolute z-20 flex items-center cursor-pointer justify-center w-6 h-6 transition-all invisible opacity-0 duration-500 ease-in-out -top-2.5 -right-2.5 rounded-xl bg-n-solid-3 group-hover/avatar:visible group-hover/avatar:opacity-100" | ||||||
|  |       @click="handleDismiss" | ||||||
|  |     > | ||||||
|  |       <FluentIcon icon="dismiss" :size="16" class="text-n-slate-11" /> | ||||||
|  |     </div> | ||||||
|  |     <div | ||||||
|  |       class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-500 ease-in-out opacity-0 rounded-xl bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100" | ||||||
|  |       @click="handleUploadAvatar" | ||||||
|  |     > | ||||||
|  |       <FluentIcon | ||||||
|  |         icon="upload-lucide" | ||||||
|  |         icon-lib="lucide" | ||||||
|  |         :size="iconSize" | ||||||
|  |         class="text-white dark:text-white" | ||||||
|  |       /> | ||||||
|  |       <input | ||||||
|  |         ref="fileInput" | ||||||
|  |         type="file" | ||||||
|  |         accept="image/png, image/jpeg, image/jpg, image/gif, image/webp" | ||||||
|  |         class="hidden" | ||||||
|  |         @change="handleImageUpload" | ||||||
|  |       /> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
| @@ -10,14 +10,14 @@ const twoItems = ref([ | |||||||
| const threeItems = ref([ | const threeItems = ref([ | ||||||
|   { label: 'Home', link: '#' }, |   { label: 'Home', link: '#' }, | ||||||
|   { label: 'Categories', link: '#' }, |   { label: 'Categories', link: '#' }, | ||||||
|   { label: 'Marketing', count: 6 }, |   { label: 'Marketing', count: 6, emoji: '📊' }, | ||||||
| ]); | ]); | ||||||
| const longBreadcrumb = ref([ | const longBreadcrumb = ref([ | ||||||
|   { label: 'Home', link: '#' }, |   { label: 'Home', link: '#' }, | ||||||
|   { label: 'Categories', link: '#' }, |   { label: 'Categories', link: '#', emoji: '📁' }, | ||||||
|   { label: 'Marketing', link: '#' }, |   { label: 'Marketing', link: '#' }, | ||||||
|   { label: 'Digital', link: '#' }, |   { label: 'Digital', link: '#', emoji: '💻' }, | ||||||
|   { label: 'Social Media', count: 12 }, |   { label: 'Social Media', count: 12, emoji: '📱' }, | ||||||
| ]); | ]); | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,6 +1,8 @@ | |||||||
| <script setup> | <script setup> | ||||||
| import { defineProps } from 'vue'; | import { defineProps } from 'vue'; | ||||||
| import { useI18n } from 'vue-i18n'; | import { useI18n } from 'vue-i18n'; | ||||||
|  |  | ||||||
|  | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
| import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue'; | import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue'; | ||||||
|  |  | ||||||
| defineProps({ | defineProps({ | ||||||
| @@ -16,44 +18,43 @@ defineProps({ | |||||||
|       ); |       ); | ||||||
|     }, |     }, | ||||||
|   }, |   }, | ||||||
|   countLabel: { |  | ||||||
|     type: String, |  | ||||||
|     default: '', |  | ||||||
|   }, |  | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['click']); | ||||||
|  |  | ||||||
| const { t } = useI18n(); | const { t } = useI18n(); | ||||||
|  |  | ||||||
|  | const onClick = event => { | ||||||
|  |   emit('click', event); | ||||||
|  | }; | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <template> | <template> | ||||||
|   <nav :aria-label="t('BREADCRUMB.ARIA_LABEL')" class="flex items-center h-8"> |   <nav :aria-label="t('BREADCRUMB.ARIA_LABEL')" class="flex items-center h-8"> | ||||||
|     <ol class="flex items-center mb-0"> |     <ol class="flex items-center mb-0"> | ||||||
|       <li |       <li v-for="(item, index) in items" :key="index" class="flex items-center"> | ||||||
|         v-for="(item, index) in items" |         <Button | ||||||
|         :key="index" |           v-if="index === 0" | ||||||
|         class="flex items-center gap-3" |           :label="item.label" | ||||||
|       > |           variant="link" | ||||||
|         <template v-if="index === items.length - 1"> |           text-variant="info" | ||||||
|           <span class="text-sm text-slate-900 dark:text-slate-50"> |           class="!p-0 text-sm !font-normal hover:!no-underline max-w-56 !text-slate-300 dark:!text-slate-500 hover:!text-slate-700 dark:hover:!text-slate-100" | ||||||
|             {{ |           size="sm" | ||||||
|               `${item.label}${item.count ? ` (${item.count} ${countLabel})` : ''}` |           @click="onClick" | ||||||
|             }} |         /> | ||||||
|  |         <template v-else> | ||||||
|  |           <FluentIcon | ||||||
|  |             icon="chevron-lucide-right" | ||||||
|  |             size="18" | ||||||
|  |             icon-lib="lucide" | ||||||
|  |             class="flex-shrink-0 mx-2 text-slate-300 dark:text-slate-500" | ||||||
|  |           /> | ||||||
|  |           <span | ||||||
|  |             class="text-sm truncate text-slate-900 dark:text-slate-50 max-w-56" | ||||||
|  |           > | ||||||
|  |             {{ item.emoji ? item.emoji : '' }} {{ item.label }} | ||||||
|           </span> |           </span> | ||||||
|         </template> |         </template> | ||||||
|         <a |  | ||||||
|           v-else |  | ||||||
|           :href="item.link" |  | ||||||
|           class="text-sm transition-colors duration-200 text-slate-300 dark:text-slate-500 hover:text-slate-700 dark:hover:text-slate-100" |  | ||||||
|         > |  | ||||||
|           {{ item.label }} |  | ||||||
|         </a> |  | ||||||
|         <FluentIcon |  | ||||||
|           v-if="index < items.length - 1" |  | ||||||
|           icon="chevron-lucide-right" |  | ||||||
|           size="18" |  | ||||||
|           icon-lib="lucide" |  | ||||||
|           class="flex-shrink-0 text-slate-300 dark:text-slate-500 ltr:mr-3 rtl:mr-0 rtl:ml-3" |  | ||||||
|         /> |  | ||||||
|       </li> |       </li> | ||||||
|     </ol> |     </ol> | ||||||
|   </nav> |   </nav> | ||||||
|   | |||||||
| @@ -100,7 +100,7 @@ import Button from './Button.vue'; | |||||||
|           icon-position="left" |           icon-position="left" | ||||||
|           size="sm" |           size="sm" | ||||||
|         /> |         /> | ||||||
|         <Button icon="emoji-add" size="icon" /> |         <Button icon="emoji-add" size="sm" /> | ||||||
|       </div> |       </div> | ||||||
|     </Variant> |     </Variant> | ||||||
|  |  | ||||||
| @@ -119,7 +119,7 @@ import Button from './Button.vue'; | |||||||
|           icon-position="right" |           icon-position="right" | ||||||
|           size="sm" |           size="sm" | ||||||
|         /> |         /> | ||||||
|         <Button icon="emoji-add" size="icon" /> |         <Button icon="emoji-add" size="sm" /> | ||||||
|       </div> |       </div> | ||||||
|     </Variant> |     </Variant> | ||||||
|   </Story> |   </Story> | ||||||
|   | |||||||
| @@ -1,6 +1,8 @@ | |||||||
| <script setup> | <script setup> | ||||||
| import { computed } from 'vue'; | import { computed } from 'vue'; | ||||||
|  |  | ||||||
| import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue'; | import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue'; | ||||||
|  | import Spinner from 'dashboard/components-next/spinner/Spinner.vue'; | ||||||
|  |  | ||||||
| const props = defineProps({ | const props = defineProps({ | ||||||
|   label: { |   label: { | ||||||
| @@ -29,12 +31,21 @@ const props = defineProps({ | |||||||
|   size: { |   size: { | ||||||
|     type: String, |     type: String, | ||||||
|     default: 'default', |     default: 'default', | ||||||
|     validator: value => ['default', 'sm', 'lg', 'icon'].includes(value), |     validator: value => ['default', 'sm', 'lg'].includes(value), | ||||||
|  |   }, | ||||||
|  |   type: { | ||||||
|  |     type: String, | ||||||
|  |     default: 'button', | ||||||
|  |     validator: value => ['button', 'submit', 'reset'].includes(value), | ||||||
|   }, |   }, | ||||||
|   icon: { |   icon: { | ||||||
|     type: String, |     type: String, | ||||||
|     default: '', |     default: '', | ||||||
|   }, |   }, | ||||||
|  |   emoji: { | ||||||
|  |     type: String, | ||||||
|  |     default: '', | ||||||
|  |   }, | ||||||
|   iconPosition: { |   iconPosition: { | ||||||
|     type: String, |     type: String, | ||||||
|     default: 'left', |     default: 'left', | ||||||
| @@ -44,6 +55,10 @@ const props = defineProps({ | |||||||
|     type: String, |     type: String, | ||||||
|     default: 'fluent', |     default: 'fluent', | ||||||
|   }, |   }, | ||||||
|  |   isLoading: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const emit = defineEmits(['click']); | const emit = defineEmits(['click']); | ||||||
| @@ -51,33 +66,28 @@ const emit = defineEmits(['click']); | |||||||
| const buttonVariants = { | const buttonVariants = { | ||||||
|   variant: { |   variant: { | ||||||
|     default: |     default: | ||||||
|       'bg-woot-500 dark:bg-woot-500 text-white dark:text-white hover:bg-woot-600 dark:hover:bg-woot-600', |       'bg-n-brand text-white dark:text-white hover:bg-woot-600 dark:hover:bg-woot-600', | ||||||
|     destructive: |     destructive: 'bg-n-ruby-9 text-white dark:text-white hover:bg-n-ruby-10', | ||||||
|       'bg-ruby-700 dark:bg-ruby-700 text-white dark:text-white hover:bg-ruby-800 dark:hover:bg-ruby-800', |  | ||||||
|     outline: |     outline: | ||||||
|       'border border-slate-200 dark:border-slate-700/50 hover:border-slate-300 dark:hover:border-slate-600', |       'border border-n-weak dark:border-n-weak hover:border-n-slate-6 dark:hover:border-n-slate-6', | ||||||
|     secondary: |     secondary: 'bg-n-solid-3 text-n-slate-12 hover:bg-n-solid-2', | ||||||
|       'bg-slate-50 text-slate-900 dark:bg-slate-700/50 dark:text-slate-100 hover:bg-slate-100 dark:hover:bg-slate-600', |     ghost: 'text-n-slate-12', | ||||||
|     ghost: |     link: 'text-n-brand underline-offset-4 hover:underline dark:hover:underline', | ||||||
|       'text-slate-900 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800', |  | ||||||
|     link: 'text-woot-500 underline-offset-4 hover:underline dark:hover:underline', |  | ||||||
|   }, |   }, | ||||||
|   size: { |   size: { | ||||||
|     default: 'h-10 px-4 py-2', |     default: 'h-10 px-4 py-2', | ||||||
|     sm: 'h-8 px-3', |     sm: 'h-8 px-3 py-1', | ||||||
|     lg: 'h-11 px-4', |     lg: 'h-12 px-5 py-3', | ||||||
|     icon: 'h-auto w-auto px-2', |  | ||||||
|   }, |   }, | ||||||
|   text: { |   text: { | ||||||
|     default: |     default: | ||||||
|       '!text-woot-500 dark:!text-woot-500 hover:!text-woot-600 dark:hover:!text-woot-600', |       '!text-n-brand dark:!text-n-brand hover:!text-woot-600 dark:hover:!text-woot-600', | ||||||
|     success: |     success: | ||||||
|       '!text-green-500 dark:!text-green-500 hover:!text-green-600 dark:hover:!text-green-600', |       '!text-green-500 dark:!text-green-500 hover:!text-green-600 dark:hover:!text-green-600', | ||||||
|     warning: |     warning: | ||||||
|       '!text-amber-600 dark:!text-amber-600 hover:!text-amber-600 dark:hover:!text-amber-600', |       '!text-amber-600 dark:!text-amber-600 hover:!text-amber-600 dark:hover:!text-amber-600', | ||||||
|     danger: |     danger: '!text-n-ruby-11 hover:!text-n-ruby-10', | ||||||
|       '!text-ruby-700 dark:!text-ruby-700 hover:!text-ruby-800 dark:hover:!text-ruby-800', |     info: '!text-n-slate-12 hover:!text-n-slate-11', | ||||||
|     info: '!text-slate-500 dark:!text-slate-400 hover:!text-slate-600 dark:hover:!text-slate-500', |  | ||||||
|   }, |   }, | ||||||
| }; | }; | ||||||
|  |  | ||||||
| @@ -100,35 +110,43 @@ const iconSize = computed(() => { | |||||||
|   return 18; |   return 18; | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const handleClick = () => { | const handleClick = e => { | ||||||
|   emit('click'); |   emit('click', e); | ||||||
| }; | }; | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <template> | <template> | ||||||
|   <button |   <button | ||||||
|     :class="buttonClasses" |     :class="buttonClasses" | ||||||
|     class="inline-flex items-center justify-center h-10 min-w-0 gap-2 text-sm font-medium transition-all duration-200 ease-in-out rounded-lg disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50" |     :type="type" | ||||||
|  |     class="inline-flex items-center justify-center min-w-0 gap-2 text-sm font-medium transition-all duration-200 ease-in-out rounded-lg disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50" | ||||||
|     @click="handleClick" |     @click="handleClick" | ||||||
|   > |   > | ||||||
|     <FluentIcon |     <FluentIcon | ||||||
|       v-if="icon && iconPosition === 'left'" |       v-if="icon && iconPosition === 'left' && !isLoading" | ||||||
|       :icon="icon" |       :icon="icon" | ||||||
|       :size="iconSize" |       :size="iconSize" | ||||||
|       :icon-lib="iconLib" |       :icon-lib="iconLib" | ||||||
|       class="flex-shrink-0" |       class="flex-shrink-0" | ||||||
|  |       :class="{ | ||||||
|  |         'text-n-slate-11 dark:text-n-slate-11': variant === 'secondary', | ||||||
|  |       }" | ||||||
|     /> |     /> | ||||||
|     <slot> |     <Spinner v-if="isLoading" class="!w-5 !h-5 flex-shrink-0" /> | ||||||
|       <span v-if="label" class="min-w-0 truncate"> |     <slot name="leftPrefix" /> | ||||||
|         {{ label }} |     <span v-if="emoji">{{ emoji }}</span> | ||||||
|       </span> |     <span v-if="label" class="min-w-0 truncate">{{ label }}</span> | ||||||
|     </slot> |     <slot /> | ||||||
|  |     <slot name="rightPrefix" /> | ||||||
|     <FluentIcon |     <FluentIcon | ||||||
|       v-if="icon && iconPosition === 'right'" |       v-if="icon && iconPosition === 'right'" | ||||||
|       :icon="icon" |       :icon="icon" | ||||||
|       :size="iconSize" |       :size="iconSize" | ||||||
|       :icon-lib="iconLib" |       :icon-lib="iconLib" | ||||||
|       class="flex-shrink-0" |       class="flex-shrink-0" | ||||||
|  |       :class="{ | ||||||
|  |         'text-n-slate-11 dark:text-n-slate-11': variant === 'secondary', | ||||||
|  |       }" | ||||||
|     /> |     /> | ||||||
|   </button> |   </button> | ||||||
| </template> | </template> | ||||||
|   | |||||||
| @@ -0,0 +1,102 @@ | |||||||
|  | <script setup> | ||||||
|  | import { ref, defineProps, defineEmits } from 'vue'; | ||||||
|  | import { Chrome } from '@lk77/vue3-color'; | ||||||
|  | import { OnClickOutside } from '@vueuse/components'; | ||||||
|  |  | ||||||
|  | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
|  |  | ||||||
|  | defineProps({ | ||||||
|  |   modelValue: { | ||||||
|  |     type: String, | ||||||
|  |     default: '', | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['update:modelValue']); | ||||||
|  |  | ||||||
|  | const isPickerOpen = ref(false); | ||||||
|  |  | ||||||
|  | const toggleColorPicker = () => { | ||||||
|  |   isPickerOpen.value = !isPickerOpen.value; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const closeTogglePicker = () => { | ||||||
|  |   if (isPickerOpen.value) { | ||||||
|  |     toggleColorPicker(); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const updateColor = e => { | ||||||
|  |   emit('update:modelValue', e.hex); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const pickerRef = ref(null); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <div ref="pickerRef" class="relative w-fit"> | ||||||
|  |     <OnClickOutside @trigger="closeTogglePicker"> | ||||||
|  |       <Button | ||||||
|  |         :label="modelValue" | ||||||
|  |         variant="secondary" | ||||||
|  |         icon-lib="lucide" | ||||||
|  |         icon-position="right" | ||||||
|  |         icon="pipette-lucide" | ||||||
|  |         class="!px-3 !py-3 [&>svg]:w-4 [&>svg]:h-4" | ||||||
|  |         @click="toggleColorPicker" | ||||||
|  |       > | ||||||
|  |         <template #leftPrefix> | ||||||
|  |           <div | ||||||
|  |             class="w-4 h-4 rounded-sm" | ||||||
|  |             :style="{ backgroundColor: modelValue }" | ||||||
|  |           /> | ||||||
|  |         </template> | ||||||
|  |       </Button> | ||||||
|  |       <Chrome | ||||||
|  |         v-if="isPickerOpen" | ||||||
|  |         disable-alpha | ||||||
|  |         :model-value="modelValue" | ||||||
|  |         class="colorpicker--chrome" | ||||||
|  |         @update:model-value="updateColor" | ||||||
|  |       /> | ||||||
|  |     </OnClickOutside> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <style scoped lang="scss"> | ||||||
|  | .colorpicker--chrome.vc-chrome { | ||||||
|  |   @apply shadow-lg absolute bg-n-background z-[9999] border border-n-weak dark:border-n-weak rounded-[8px]; | ||||||
|  |  | ||||||
|  |   :deep() { | ||||||
|  |     .vc-chrome-saturation-wrap { | ||||||
|  |       @apply rounded-t-[7px]; | ||||||
|  |  | ||||||
|  |       .vc-saturation { | ||||||
|  |         @apply rounded-t-[8px]; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .vc-chrome-body { | ||||||
|  |       @apply rounded-b-[7px] bg-n-alpha-3; | ||||||
|  |  | ||||||
|  |       .vc-chrome-toggle-btn { | ||||||
|  |         .vc-chrome-toggle-icon svg { | ||||||
|  |           @apply [&>path]:fill-n-slate-10 dark:[&>path]:fill-n-slate-10 left-3 relative; | ||||||
|  |         } | ||||||
|  |         .vc-chrome-toggle-icon-highlight { | ||||||
|  |           @apply bg-n-background; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     input, | ||||||
|  |     .vc-input__input { | ||||||
|  |       @apply bg-n-background text-slate-900 dark:text-slate-50 rounded-md shadow-none; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .vc-input__label { | ||||||
|  |       @apply text-n-slate-11 dark:text-n-slate-11; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | </style> | ||||||
| @@ -1,6 +1,6 @@ | |||||||
| <script setup> | <script setup> | ||||||
| import { nextTick, ref, computed, watch } from 'vue'; | import { ref, computed, watch, nextTick } from 'vue'; | ||||||
| import { onClickOutside } from '@vueuse/core'; | import { OnClickOutside } from '@vueuse/components'; | ||||||
| import { useI18n } from 'vue-i18n'; | import { useI18n } from 'vue-i18n'; | ||||||
| import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue'; | import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue'; | ||||||
| import Button from 'dashboard/components-next/button/Button.vue'; | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
| @@ -32,6 +32,10 @@ const props = defineProps({ | |||||||
|     type: String, |     type: String, | ||||||
|     default: '', |     default: '', | ||||||
|   }, |   }, | ||||||
|  |   message: { | ||||||
|  |     type: String, | ||||||
|  |     default: '', | ||||||
|  |   }, | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const emit = defineEmits(['update:modelValue']); | const emit = defineEmits(['update:modelValue']); | ||||||
| @@ -80,10 +84,6 @@ watch( | |||||||
|     selectedValue.value = newValue; |     selectedValue.value = newValue; | ||||||
|   } |   } | ||||||
| ); | ); | ||||||
|  |  | ||||||
| onClickOutside(comboboxRef, () => { |  | ||||||
|   open.value = false; |  | ||||||
| }); |  | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <template> | <template> | ||||||
| @@ -95,69 +95,77 @@ onClickOutside(comboboxRef, () => { | |||||||
|       'group/combobox': !disabled, |       'group/combobox': !disabled, | ||||||
|     }" |     }" | ||||||
|   > |   > | ||||||
|     <Button |     <OnClickOutside @trigger="open = false"> | ||||||
|       variant="outline" |       <Button | ||||||
|       :label="selectedLabel" |         variant="outline" | ||||||
|       icon-position="right" |         :label="selectedLabel" | ||||||
|       size="sm" |         icon-position="right" | ||||||
|       :disabled="disabled" |         :disabled="disabled" | ||||||
|       class="justify-between w-full text-slate-900 dark:text-slate-100 group-hover/combobox:border-slate-300 dark:group-hover/combobox:border-slate-600" |         class="justify-between w-full !px-2 !py-2.5 text-n-slate-12 font-normal group-hover/combobox:border-n-slate-6" | ||||||
|       :icon="open ? 'chevron-up' : 'chevron-down'" |         :icon="open ? 'chevron-lucide-up' : 'chevron-lucide-down'" | ||||||
|       @click="toggleDropdown" |         icon-lib="lucide" | ||||||
|     /> |         @click="toggleDropdown" | ||||||
|     <div |       /> | ||||||
|       v-show="open" |       <div | ||||||
|       class="absolute z-50 w-full mt-1 transition-opacity duration-200 bg-white border rounded-md shadow-lg border-slate-200 dark:bg-slate-900 dark:border-slate-700/50" |         v-show="open" | ||||||
|     > |         class="absolute z-50 w-full mt-1 transition-opacity duration-200 border rounded-md shadow-lg bg-n-solid-1 border-n-strong" | ||||||
|       <div class="relative border-b border-slate-100 dark:border-slate-700/50"> |  | ||||||
|         <FluentIcon |  | ||||||
|           icon="search" |  | ||||||
|           :size="14" |  | ||||||
|           class="absolute text-gray-400 dark:text-slate-500 top-3 left-3" |  | ||||||
|           aria-hidden="true" |  | ||||||
|         /> |  | ||||||
|         <input |  | ||||||
|           ref="searchInput" |  | ||||||
|           v-model="search" |  | ||||||
|           type="search" |  | ||||||
|           :placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')" |  | ||||||
|           class="w-full py-2 pl-10 pr-2 text-sm bg-white border-none rounded-t-md dark:bg-slate-900 text-slate-900 dark:text-slate-50" |  | ||||||
|         /> |  | ||||||
|       </div> |  | ||||||
|       <ul |  | ||||||
|         class="py-1 overflow-auto max-h-60" |  | ||||||
|         role="listbox" |  | ||||||
|         :aria-activedescendant="selectedValue" |  | ||||||
|       > |       > | ||||||
|         <li |         <div class="relative border-b border-n-strong"> | ||||||
|           v-for="option in filteredOptions" |  | ||||||
|           :key="option.value" |  | ||||||
|           class="flex items-center justify-between w-full gap-2 px-3 py-2 text-sm transition-colors duration-150 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/50" |  | ||||||
|           :class="{ |  | ||||||
|             'bg-slate-50 dark:bg-slate-800/50': option.value === selectedValue, |  | ||||||
|           }" |  | ||||||
|           role="option" |  | ||||||
|           :aria-selected="option.value === selectedValue" |  | ||||||
|           @click="selectOption(option)" |  | ||||||
|         > |  | ||||||
|           <span :class="{ 'font-medium': option.value === selectedValue }"> |  | ||||||
|             {{ option.label }} |  | ||||||
|           </span> |  | ||||||
|           <FluentIcon |           <FluentIcon | ||||||
|             v-if="option.value === selectedValue" |             icon="search" | ||||||
|             icon="checkmark" |             :size="14" | ||||||
|             :size="16" |             class="absolute text-gray-400 dark:text-slate-500 top-3 left-3" | ||||||
|             class="flex-shrink-0" |  | ||||||
|             aria-hidden="true" |             aria-hidden="true" | ||||||
|           /> |           /> | ||||||
|         </li> |           <input | ||||||
|         <li |             ref="searchInput" | ||||||
|           v-if="filteredOptions.length === 0" |             v-model="search" | ||||||
|           class="px-3 py-2 text-sm text-slate-600 dark:text-slate-300" |             type="search" | ||||||
|  |             :placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')" | ||||||
|  |             class="w-full py-2 pl-10 pr-2 text-sm border-none rounded-t-md bg-n-solid-1 text-slate-900 dark:text-slate-50" | ||||||
|  |           /> | ||||||
|  |         </div> | ||||||
|  |         <ul | ||||||
|  |           class="py-1 mb-0 overflow-auto max-h-60" | ||||||
|  |           role="listbox" | ||||||
|  |           :aria-activedescendant="selectedValue" | ||||||
|         > |         > | ||||||
|           {{ emptyState || t('COMBOBOX.EMPTY_STATE') }} |           <li | ||||||
|         </li> |             v-for="option in filteredOptions" | ||||||
|       </ul> |             :key="option.value" | ||||||
|     </div> |             class="flex items-center justify-between !text-n-slate-12 w-full gap-2 px-2 py-2 text-sm transition-colors duration-150 cursor-pointer hover:bg-n-solid-2" | ||||||
|  |             :class="{ | ||||||
|  |               'bg-n-solid-2': option.value === selectedValue, | ||||||
|  |             }" | ||||||
|  |             role="option" | ||||||
|  |             :aria-selected="option.value === selectedValue" | ||||||
|  |             @click="selectOption(option)" | ||||||
|  |           > | ||||||
|  |             <span :class="{ 'font-medium': option.value === selectedValue }"> | ||||||
|  |               {{ option.label }} | ||||||
|  |             </span> | ||||||
|  |             <FluentIcon | ||||||
|  |               v-if="option.value === selectedValue" | ||||||
|  |               icon="checkmark" | ||||||
|  |               :size="16" | ||||||
|  |               class="flex-shrink-0 text-n-slate-11 dark:text-n-slate-11" | ||||||
|  |               aria-hidden="true" | ||||||
|  |             /> | ||||||
|  |           </li> | ||||||
|  |           <li | ||||||
|  |             v-if="filteredOptions.length === 0" | ||||||
|  |             class="px-3 py-2 text-sm text-slate-600 dark:text-slate-300" | ||||||
|  |           > | ||||||
|  |             {{ emptyState || t('COMBOBOX.EMPTY_STATE') }} | ||||||
|  |           </li> | ||||||
|  |         </ul> | ||||||
|  |       </div> | ||||||
|  |       <p | ||||||
|  |         v-if="message" | ||||||
|  |         class="mt-2 mb-0 text-xs truncate transition-all duration-500 ease-in-out text-n-slate-11 dark:text-n-slate-11" | ||||||
|  |       > | ||||||
|  |         {{ message }} | ||||||
|  |       </p> | ||||||
|  |     </OnClickOutside> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
|   | |||||||
| @@ -1,7 +1,9 @@ | |||||||
| <script setup> | <script setup> | ||||||
| import { ref } from 'vue'; | import { ref } from 'vue'; | ||||||
| import { onClickOutside } from '@vueuse/core'; | import { OnClickOutside } from '@vueuse/components'; | ||||||
| import { useI18n } from 'vue-i18n'; | import { useI18n } from 'vue-i18n'; | ||||||
|  | import { useMapGetter } from 'dashboard/composables/store.js'; | ||||||
|  |  | ||||||
| import Button from 'dashboard/components-next/button/Button.vue'; | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
|  |  | ||||||
| defineProps({ | defineProps({ | ||||||
| @@ -26,12 +28,30 @@ defineProps({ | |||||||
|     type: String, |     type: String, | ||||||
|     default: '', |     default: '', | ||||||
|   }, |   }, | ||||||
|  |   disableConfirmButton: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, | ||||||
|  |   isLoading: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, | ||||||
|  |   showCancelButton: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: true, | ||||||
|  |   }, | ||||||
|  |   showConfirmButton: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: true, | ||||||
|  |   }, | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const emit = defineEmits(['confirm']); | const emit = defineEmits(['confirm', 'close']); | ||||||
|  |  | ||||||
| const { t } = useI18n(); | const { t } = useI18n(); | ||||||
|  |  | ||||||
|  | const isRTL = useMapGetter('accounts/isRTL'); | ||||||
|  |  | ||||||
| const dialogRef = ref(null); | const dialogRef = ref(null); | ||||||
| const dialogContentRef = ref(null); | const dialogContentRef = ref(null); | ||||||
|  |  | ||||||
| @@ -39,71 +59,69 @@ const open = () => { | |||||||
|   dialogRef.value?.showModal(); |   dialogRef.value?.showModal(); | ||||||
| }; | }; | ||||||
| const close = () => { | const close = () => { | ||||||
|  |   emit('close'); | ||||||
|   dialogRef.value?.close(); |   dialogRef.value?.close(); | ||||||
| }; | }; | ||||||
| const confirm = () => { | const confirm = () => { | ||||||
|   emit('confirm'); |   emit('confirm'); | ||||||
| }; | }; | ||||||
|  |  | ||||||
| defineExpose({ open }); | defineExpose({ open, close }); | ||||||
|  |  | ||||||
| onClickOutside(dialogContentRef, event => { |  | ||||||
|   if ( |  | ||||||
|     dialogRef.value && |  | ||||||
|     dialogRef.value.open && |  | ||||||
|     event.target === dialogRef.value |  | ||||||
|   ) { |  | ||||||
|     close(); |  | ||||||
|   } |  | ||||||
| }); |  | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <template> | <template> | ||||||
|   <Teleport to="body"> |   <Teleport to="body"> | ||||||
|     <dialog |     <dialog | ||||||
|       ref="dialogRef" |       ref="dialogRef" | ||||||
|       class="w-full max-w-lg overflow-visible shadow-xl bg-modal-backdrop-light dark:bg-modal-backdrop-dark rounded-xl" |       class="w-full max-w-lg overflow-visible transition-all duration-300 ease-in-out shadow-xl rounded-xl" | ||||||
|  |       :dir="isRTL ? 'rtl' : 'ltr'" | ||||||
|       @close="close" |       @close="close" | ||||||
|     > |     > | ||||||
|       <div |       <OnClickOutside @trigger="close"> | ||||||
|         ref="dialogContentRef" |         <div | ||||||
|         class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-left align-middle transition-all duration-300 ease-in-out transform bg-white shadow-xl dark:bg-slate-800 rounded-xl" |           ref="dialogContentRef" | ||||||
|         @click.stop |           class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-left align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl" | ||||||
|       > |           @click.stop | ||||||
|         <div class="flex flex-col gap-2"> |         > | ||||||
|           <h3 |           <div class="flex flex-col gap-2"> | ||||||
|             class="text-base font-medium leading-6 text-gray-900 dark:text-white" |             <h3 class="text-base font-medium leading-6 text-n-slate-12"> | ||||||
|           > |               {{ title }} | ||||||
|             {{ title }} |             </h3> | ||||||
|           </h3> |             <slot name="description"> | ||||||
|           <p |               <p v-if="description" class="mb-0 text-sm text-n-slate-11"> | ||||||
|             v-if="description" |                 {{ description }} | ||||||
|             class="mb-0 text-sm text-slate-500 dark:text-slate-400" |               </p> | ||||||
|           > |             </slot> | ||||||
|             {{ description }} |           </div> | ||||||
|           </p> |           <slot name="form"> | ||||||
|  |             <!-- Form content will be injected here --> | ||||||
|  |           </slot> | ||||||
|  |           <div class="flex items-center justify-between w-full gap-3"> | ||||||
|  |             <Button | ||||||
|  |               v-if="showCancelButton" | ||||||
|  |               variant="ghost" | ||||||
|  |               :label="cancelButtonLabel || t('DIALOG.BUTTONS.CANCEL')" | ||||||
|  |               class="w-full bg-n-alpha-2 hover:bg-n-alpha-3" | ||||||
|  |               @click="close" | ||||||
|  |             /> | ||||||
|  |             <Button | ||||||
|  |               v-if="showConfirmButton" | ||||||
|  |               :variant="type === 'edit' ? 'default' : 'destructive'" | ||||||
|  |               :label="confirmButtonLabel || t('DIALOG.BUTTONS.CONFIRM')" | ||||||
|  |               class="w-full" | ||||||
|  |               :is-loading="isLoading" | ||||||
|  |               :disabled="disableConfirmButton || isLoading" | ||||||
|  |               @click="confirm" | ||||||
|  |             /> | ||||||
|  |           </div> | ||||||
|         </div> |         </div> | ||||||
|         <slot name="form"> |       </OnClickOutside> | ||||||
|           <!-- Form content will be injected here --> |  | ||||||
|         </slot> |  | ||||||
|         <div class="flex items-center justify-between w-full gap-3"> |  | ||||||
|           <Button |  | ||||||
|             variant="secondary" |  | ||||||
|             :label="cancelButtonLabel || t('DIALOG.BUTTONS.CANCEL')" |  | ||||||
|             class="w-full" |  | ||||||
|             size="sm" |  | ||||||
|             @click="close" |  | ||||||
|           /> |  | ||||||
|           <Button |  | ||||||
|             v-if="type !== 'alert'" |  | ||||||
|             :variant="type === 'edit' ? 'default' : 'destructive'" |  | ||||||
|             :label="confirmButtonLabel || t('DIALOG.BUTTONS.CONFIRM')" |  | ||||||
|             class="w-full" |  | ||||||
|             size="sm" |  | ||||||
|             @click="confirm" |  | ||||||
|           /> |  | ||||||
|         </div> |  | ||||||
|       </div> |  | ||||||
|     </dialog> |     </dialog> | ||||||
|   </Teleport> |   </Teleport> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
|  | <style scoped> | ||||||
|  | dialog::backdrop { | ||||||
|  |   @apply dark:bg-n-alpha-white bg-n-alpha-black2; | ||||||
|  | } | ||||||
|  | </style> | ||||||
|   | |||||||
| @@ -1,35 +1,57 @@ | |||||||
| <script setup> | <script setup> | ||||||
| import { defineProps, defineEmits } from 'vue'; | import { defineProps, defineEmits } from 'vue'; | ||||||
|  |  | ||||||
| import Button from 'dashboard/components-next/button/Button.vue'; | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
|  | import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue'; | ||||||
|  |  | ||||||
| defineProps({ | defineProps({ | ||||||
|   menuItems: { |   menuItems: { | ||||||
|     type: Array, |     type: Array, | ||||||
|     required: true, |     required: true, | ||||||
|  |     validator: value => { | ||||||
|  |       return value.every(item => item.action && item.value && item.label); | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   thumbnailSize: { | ||||||
|  |     type: Number, | ||||||
|  |     default: 20, | ||||||
|   }, |   }, | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const emit = defineEmits(['action']); | const emit = defineEmits(['action']); | ||||||
|  |  | ||||||
| const handleAction = action => { | const handleAction = (action, value) => { | ||||||
|   emit('action', action); |   emit('action', { action, value }); | ||||||
| }; | }; | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <template> | <template> | ||||||
|   <div |   <div | ||||||
|     class="bg-white dark:bg-slate-800 absolute rounded-xl z-50 py-3 px-1 gap-2 flex flex-col min-w-[136px] shadow-lg" |     class="bg-n-alpha-3 backdrop-blur-[100px] absolute rounded-xl z-50 py-2 px-2 gap-2 flex flex-col min-w-[136px] shadow-lg" | ||||||
|   > |   > | ||||||
|     <Button |     <Button | ||||||
|       v-for="item in menuItems" |       v-for="item in menuItems" | ||||||
|       :key="item.action" |       :key="item.action" | ||||||
|       :label="item.label" |       :label="item.label" | ||||||
|       :icon="item.icon" |       :icon="item.icon" | ||||||
|  |       :emoji="item.emoji" | ||||||
|  |       :disabled="item.disabled" | ||||||
|       variant="ghost" |       variant="ghost" | ||||||
|       size="sm" |       size="sm" | ||||||
|       class="!justify-start w-full hover:bg-white dark:hover:bg-slate-800 z-60 font-normal" |       class="!justify-start w-full hover:!bg-n-slate-3 dark:hover:!bg-n-slate-4 z-60 px-2 font-normal" | ||||||
|  |       :class="item.isSelected ? '!bg-n-alpha-1 dark:!bg-n-solid-active' : ''" | ||||||
|       :text-variant="item.action === 'delete' ? 'danger' : ''" |       :text-variant="item.action === 'delete' ? 'danger' : ''" | ||||||
|       @click="handleAction(item.action)" |       @click="handleAction(item.action, item.value)" | ||||||
|     /> |     > | ||||||
|  |       <template #leftPrefix> | ||||||
|  |         <Thumbnail | ||||||
|  |           v-if="item.thumbnail" | ||||||
|  |           :author="item.thumbnail" | ||||||
|  |           :name="item.thumbnail.name" | ||||||
|  |           :size="thumbnailSize" | ||||||
|  |           :src="item.thumbnail.src" | ||||||
|  |         /> | ||||||
|  |       </template> | ||||||
|  |     </Button> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
|   | |||||||
| @@ -34,12 +34,16 @@ defineProps({ | |||||||
|   }, |   }, | ||||||
| }); | }); | ||||||
|  |  | ||||||
| defineEmits(['update:modelValue']); | const emit = defineEmits(['update:modelValue', 'enterPress']); | ||||||
|  |  | ||||||
|  | const onEnterPress = () => { | ||||||
|  |   emit('enterPress'); | ||||||
|  | }; | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <template> | <template> | ||||||
|   <div |   <div | ||||||
|     class="relative flex items-center justify-between w-full gap-2 whitespace-nowrap" |     class="relative flex items-center justify-between w-full gap-3 whitespace-nowrap" | ||||||
|   > |   > | ||||||
|     <label |     <label | ||||||
|       v-if="label" |       v-if="label" | ||||||
| @@ -60,6 +64,7 @@ defineEmits(['update:modelValue']); | |||||||
|       :class="customInputClass" |       :class="customInputClass" | ||||||
|       class="flex w-full reset-base text-sm h-6 !mb-0 border-0 rounded-lg bg-transparent dark:bg-transparent placeholder:text-slate-200 dark:placeholder:text-slate-500 disabled:cursor-not-allowed disabled:opacity-50 text-slate-900 dark:text-white transition-all duration-500 ease-in-out" |       class="flex w-full reset-base text-sm h-6 !mb-0 border-0 rounded-lg bg-transparent dark:bg-transparent placeholder:text-slate-200 dark:placeholder:text-slate-500 disabled:cursor-not-allowed disabled:opacity-50 text-slate-900 dark:text-white transition-all duration-500 ease-in-out" | ||||||
|       @input="$emit('update:modelValue', $event.target.value)" |       @input="$emit('update:modelValue', $event.target.value)" | ||||||
|  |       @keydown.enter.prevent="onEnterPress" | ||||||
|     /> |     /> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
|   | |||||||
| @@ -39,25 +39,33 @@ const props = defineProps({ | |||||||
|     validator: value => ['info', 'error', 'success'].includes(value), |     validator: value => ['info', 'error', 'success'].includes(value), | ||||||
|   }, |   }, | ||||||
| }); | }); | ||||||
| defineEmits(['update:modelValue']); |  | ||||||
|  | const emit = defineEmits(['update:modelValue', 'blur', 'input']); | ||||||
|  |  | ||||||
| const messageClass = computed(() => { | const messageClass = computed(() => { | ||||||
|   switch (props.messageType) { |   switch (props.messageType) { | ||||||
|     case 'error': |     case 'error': | ||||||
|       return 'text-red-500 dark:text-red-400'; |       return 'text-n-ruby-9 dark:text-n-ruby-9'; | ||||||
|     case 'success': |     case 'success': | ||||||
|       return 'text-green-500 dark:text-green-400'; |       return 'text-green-500 dark:text-green-400'; | ||||||
|     default: |     default: | ||||||
|       return 'text-slate-500 dark:text-slate-400'; |       return 'text-n-slate-11 dark:text-n-slate-11'; | ||||||
|   } |   } | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const inputBorderClass = computed(() => { | const inputBorderClass = computed(() => { | ||||||
|   switch (props.messageType) { |   switch (props.messageType) { | ||||||
|     case 'error': |     case 'error': | ||||||
|       return 'border-red-500 dark:border-red-400'; |       return 'border-n-ruby-8 dark:border-n-ruby-8 hover:border-n-ruby-9 dark:hover:border-n-ruby-9 disabled:border-n-ruby-8 dark:disabled:border-n-ruby-8'; | ||||||
|     default: |     default: | ||||||
|       return 'border-slate-100 dark:border-slate-700/50'; |       return 'border-n-weak dark:border-n-weak hover:border-n-slate-6 dark:hover:border-n-slate-6 disabled:border-n-weak dark:disabled:border-n-weak'; | ||||||
|   } |   } | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  | const handleInput = event => { | ||||||
|  |   emit('update:modelValue', event.target.value); | ||||||
|  |   emit('input', event); | ||||||
|  | }; | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <template> | <template> | ||||||
| @@ -78,12 +86,13 @@ const inputBorderClass = computed(() => { | |||||||
|       :type="type" |       :type="type" | ||||||
|       :placeholder="placeholder" |       :placeholder="placeholder" | ||||||
|       :disabled="disabled" |       :disabled="disabled" | ||||||
|       class="flex w-full reset-base text-sm h-8 pl-3 pr-2 rtl:pr-3 rtl:pl-2 py-1.5 !mb-0 border rounded-lg focus:border-woot-500 dark:focus:border-woot-600 bg-white dark:bg-slate-900 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-200 dark:placeholder:text-slate-500 disabled:cursor-not-allowed disabled:opacity-50 text-slate-900 dark:text-white transition-all duration-500 ease-in-out" |       class="flex w-full reset-base text-sm h-10 !px-2 !py-2.5 !mb-0 border rounded-lg focus:border-n-brand dark:focus:border-n-brand bg-white dark:bg-slate-900 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-200 dark:placeholder:text-slate-500 disabled:cursor-not-allowed disabled:opacity-50 text-slate-900 dark:text-white transition-all duration-500 ease-in-out" | ||||||
|       @input="$emit('update:modelValue', $event.target.value)" |       @input="handleInput" | ||||||
|  |       @blur="emit('blur')" | ||||||
|     /> |     /> | ||||||
|     <p |     <p | ||||||
|       v-if="message" |       v-if="message" | ||||||
|       class="mt-1 mb-0 text-xs transition-all duration-500 ease-in-out" |       class="mt-1 mb-0 text-xs truncate transition-all duration-500 ease-in-out" | ||||||
|       :class="messageClass" |       :class="messageClass" | ||||||
|     > |     > | ||||||
|       {{ message }} |       {{ message }} | ||||||
|   | |||||||
| @@ -56,12 +56,10 @@ const pageInfo = computed(() => { | |||||||
|  |  | ||||||
| <template> | <template> | ||||||
|   <div |   <div | ||||||
|     class="flex justify-between h-12 w-full max-w-[957px] mx-auto bg-slate-25 dark:bg-slate-800/50 rounded-xl py-2 px-3 items-center" |     class="flex justify-between h-12 w-full max-w-[957px] mx-auto bg-n-solid-2 rounded-xl py-2 ltr:pl-4 rtl:pr-4 ltr:pr-3 rtl:pl-3 items-center" | ||||||
|   > |   > | ||||||
|     <div class="flex items-center gap-3"> |     <div class="flex items-center gap-3"> | ||||||
|       <span |       <span class="min-w-0 text-sm font-normal line-clamp-1 text-n-slate-11"> | ||||||
|         class="min-w-0 text-sm font-normal line-clamp-1 text-slate-600 dark:text-slate-300" |  | ||||||
|       > |  | ||||||
|         {{ currentPageInformation }} |         {{ currentPageInformation }} | ||||||
|       </span> |       </span> | ||||||
|     </div> |     </div> | ||||||
| @@ -82,12 +80,8 @@ const pageInfo = computed(() => { | |||||||
|         :disabled="isFirstPage" |         :disabled="isFirstPage" | ||||||
|         @click="changePage(currentPage - 1)" |         @click="changePage(currentPage - 1)" | ||||||
|       /> |       /> | ||||||
|       <div |       <div class="inline-flex items-center gap-2 text-sm text-n-slate-11"> | ||||||
|         class="inline-flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400" |         <span class="px-3 tabular-nums py-0.5 bg-n-alpha-black2 rounded-md"> | ||||||
|       > |  | ||||||
|         <span |  | ||||||
|           class="px-3 tabular-nums py-0.5 bg-white dark:bg-slate-900 rounded-md" |  | ||||||
|         > |  | ||||||
|           {{ currentPage }} |           {{ currentPage }} | ||||||
|         </span> |         </span> | ||||||
|         <span>{{ pageInfo }}</span> |         <span>{{ pageInfo }}</span> | ||||||
|   | |||||||
| @@ -281,29 +281,62 @@ const menuItems = computed(() => { | |||||||
|       name: 'Portals', |       name: 'Portals', | ||||||
|       label: t('SIDEBAR.HELP_CENTER.TITLE'), |       label: t('SIDEBAR.HELP_CENTER.TITLE'), | ||||||
|       icon: 'i-lucide-library-big', |       icon: 'i-lucide-library-big', | ||||||
|       to: accountScopedRoute('default_portal_articles'), |       to: accountScopedRoute('portals_index', { | ||||||
|  |         navigationPath: 'portals_articles_index', | ||||||
|  |       }), | ||||||
|  |       children: [ | ||||||
|  |         { | ||||||
|  |           name: 'Articles', | ||||||
|  |           label: t('SIDEBAR.HELP_CENTER.ARTICLES'), | ||||||
|  |           activeOn: [ | ||||||
|  |             'portals_articles_index', | ||||||
|  |             'portals_articles_new', | ||||||
|  |             'portals_articles_edit', | ||||||
|  |           ], | ||||||
|  |           to: accountScopedRoute('portals_index', { | ||||||
|  |             navigationPath: 'portals_articles_index', | ||||||
|  |           }), | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           name: 'Categories', | ||||||
|  |           label: t('SIDEBAR.HELP_CENTER.CATEGORIES'), | ||||||
|  |           activeOn: [ | ||||||
|  |             'portals_categories_index', | ||||||
|  |             'portals_categories_articles_index', | ||||||
|  |             'portals_categories_articles_edit', | ||||||
|  |           ], | ||||||
|  |           to: accountScopedRoute('portals_index', { | ||||||
|  |             navigationPath: 'portals_categories_index', | ||||||
|  |           }), | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           name: 'Locales', | ||||||
|  |           label: t('SIDEBAR.HELP_CENTER.LOCALES'), | ||||||
|  |           activeOn: ['portals_locales_index'], | ||||||
|  |           to: accountScopedRoute('portals_index', { | ||||||
|  |             navigationPath: 'portals_locales_index', | ||||||
|  |           }), | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           name: 'Settings', | ||||||
|  |           label: t('SIDEBAR.HELP_CENTER.SETTINGS'), | ||||||
|  |           activeOn: ['portals_settings_index'], | ||||||
|  |           to: accountScopedRoute('portals_index', { | ||||||
|  |             navigationPath: 'portals_settings_index', | ||||||
|  |           }), | ||||||
|  |         }, | ||||||
|  |       ], | ||||||
|       activeOn: [ |       activeOn: [ | ||||||
|         'all_locale_categories', |         'portals_new', | ||||||
|         'default_portal_articles', |         'portals_index', | ||||||
|         'edit_article', |         'portals_articles_index', | ||||||
|         'edit_category', |         'portals_articles_new', | ||||||
|         'edit_portal_customization', |         'portals_articles_edit', | ||||||
|         'edit_portal_information', |         'portals_categories_index', | ||||||
|         'edit_portal_locales', |         'portals_categories_articles_index', | ||||||
|         'list_all_locale_articles', |         'portals_categories_articles_edit', | ||||||
|         'list_all_locale_categories', |         'portals_locales_index', | ||||||
|         'list_all_portals', |         'portals_settings_index', | ||||||
|         'list_archived_articles', |  | ||||||
|         'list_draft_articles', |  | ||||||
|         'list_mine_articles', |  | ||||||
|         'new_article', |  | ||||||
|         'new_category_in_locale', |  | ||||||
|         'new_portal_information', |  | ||||||
|         'portalSlug', |  | ||||||
|         'portal_customization', |  | ||||||
|         'portal_finish', |  | ||||||
|         'show_category', |  | ||||||
|         'show_category_articles', |  | ||||||
|       ], |       ], | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
| @@ -412,43 +445,43 @@ const menuItems = computed(() => { | |||||||
|     class="w-[200px] bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak h-screen flex flex-col text-sm pt-2 pb-1" |     class="w-[200px] bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak h-screen flex flex-col text-sm pt-2 pb-1" | ||||||
|   > |   > | ||||||
|     <section class="grid gap-2 mt-2 mb-4"> |     <section class="grid gap-2 mt-2 mb-4"> | ||||||
|       <div class="flex gap-2 px-2 items-center min-w-0"> |       <div class="flex items-center min-w-0 gap-2 px-2"> | ||||||
|         <div class="size-6 grid place-content-center flex-shrink-0"> |         <div class="grid flex-shrink-0 size-6 place-content-center"> | ||||||
|           <Logo /> |           <Logo /> | ||||||
|         </div> |         </div> | ||||||
|         <div class="w-px h-3 bg-n-strong flex-shrink-0" /> |         <div class="flex-shrink-0 w-px h-3 bg-n-strong" /> | ||||||
|         <SidebarAccountSwitcher |         <SidebarAccountSwitcher | ||||||
|           class="-mx-1 flex-grow min-w-0" |           class="flex-grow min-w-0 -mx-1" | ||||||
|           @show-create-account-modal="emit('showCreateAccountModal')" |           @show-create-account-modal="emit('showCreateAccountModal')" | ||||||
|         /> |         /> | ||||||
|       </div> |       </div> | ||||||
|       <div class="gap-2 flex px-2"> |       <div class="flex gap-2 px-2"> | ||||||
|         <RouterLink |         <RouterLink | ||||||
|           :to="{ name: 'search' }" |           :to="{ name: 'search' }" | ||||||
|           class="rounded-lg py-1 flex items-center gap-2 px-2 border-n-weak border bg-n-solid-3 dark:bg-n-black/30 w-full" |           class="flex items-center w-full gap-2 px-2 py-1 border rounded-lg border-n-weak bg-n-solid-3 dark:bg-n-black/30" | ||||||
|         > |         > | ||||||
|           <span class="i-lucide-search size-4 text-n-slate-11 flex-shrink-0" /> |           <span class="flex-shrink-0 i-lucide-search size-4 text-n-slate-11" /> | ||||||
|           <span class="flex-grow text-left"> |           <span class="flex-grow text-left"> | ||||||
|             {{ t('COMBOBOX.SEARCH_PLACEHOLDER') }} |             {{ t('COMBOBOX.SEARCH_PLACEHOLDER') }} | ||||||
|           </span> |           </span> | ||||||
|           <span |           <span | ||||||
|             class="tracking-wide select-none pointer-events-none text-n-slate-10 hidden" |             class="hidden tracking-wide pointer-events-none select-none text-n-slate-10" | ||||||
|           > |           > | ||||||
|             {{ searchShortcut }} |             {{ searchShortcut }} | ||||||
|           </span> |           </span> | ||||||
|         </RouterLink> |         </RouterLink> | ||||||
|         <button |         <button | ||||||
|           v-if="enableNewConversation" |           v-if="enableNewConversation" | ||||||
|           class="rounded-lg py-1 flex items-center gap-2 px-2 border-n-weak border bg-n-solid-3 w-full" |           class="flex items-center w-full gap-2 px-2 py-1 border rounded-lg border-n-weak bg-n-solid-3" | ||||||
|         > |         > | ||||||
|           <span |           <span | ||||||
|             class="i-lucide-square-pen size-4 text-n-slate-11 flex-shrink-0" |             class="flex-shrink-0 i-lucide-square-pen size-4 text-n-slate-11" | ||||||
|           /> |           /> | ||||||
|         </button> |         </button> | ||||||
|       </div> |       </div> | ||||||
|     </section> |     </section> | ||||||
|     <nav class="grid gap-2 overflow-y-scroll no-scrollbar px-2 flex-grow pb-5"> |     <nav class="grid flex-grow gap-2 px-2 pb-5 overflow-y-scroll no-scrollbar"> | ||||||
|       <ul class="flex flex-col gap-2 list-none m-0"> |       <ul class="flex flex-col gap-2 m-0 list-none"> | ||||||
|         <SidebarGroup |         <SidebarGroup | ||||||
|           v-for="item in menuItems" |           v-for="item in menuItems" | ||||||
|           :key="item.name" |           :key="item.name" | ||||||
| @@ -463,7 +496,7 @@ const menuItems = computed(() => { | |||||||
|         @open-key-shortcut-modal="emit('openKeyShortcutModal')" |         @open-key-shortcut-modal="emit('openKeyShortcutModal')" | ||||||
|       /> |       /> | ||||||
|       <div v-if="false" class="flex items-center"> |       <div v-if="false" class="flex items-center"> | ||||||
|         <div class="w-px h-3 bg-n-strong flex-shrink-0" /> |         <div class="flex-shrink-0 w-px h-3 bg-n-strong" /> | ||||||
|         <SidebarNotificationBell |         <SidebarNotificationBell | ||||||
|           @open-notification-panel="emit('openNotificationPanel')" |           @open-notification-panel="emit('openNotificationPanel')" | ||||||
|         /> |         /> | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| <script setup> | <script setup> | ||||||
| import { ref } from 'vue'; | import { computed } from 'vue'; | ||||||
| const props = defineProps({ | const props = defineProps({ | ||||||
|   initialActiveTab: { |   initialActiveTab: { | ||||||
|     type: Number, |     type: Number, | ||||||
| @@ -10,17 +10,22 @@ const props = defineProps({ | |||||||
|     required: true, |     required: true, | ||||||
|     validator: value => { |     validator: value => { | ||||||
|       return value.every( |       return value.every( | ||||||
|         tab => typeof tab.label === 'string' && typeof tab.count === 'number' |         tab => | ||||||
|  |           typeof tab.label === 'string' && | ||||||
|  |           (tab.count ? typeof tab.count === 'number' : true) | ||||||
|       ); |       ); | ||||||
|     }, |     }, | ||||||
|   }, |   }, | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const emit = defineEmits(['tabChanged']); | const emit = defineEmits(['tabChanged']); | ||||||
| const activeTab = ref(props.initialActiveTab); |  | ||||||
|  | const activeTab = computed(() => props.initialActiveTab); | ||||||
|  |  | ||||||
| const selectTab = index => { | const selectTab = index => { | ||||||
|   activeTab.value = index; |  | ||||||
|   emit('tabChanged', props.tabs[index]); |   emit('tabChanged', props.tabs[index]); | ||||||
| }; | }; | ||||||
|  |  | ||||||
| const showDivider = index => { | const showDivider = index => { | ||||||
|   return ( |   return ( | ||||||
|     // Show dividers after the active tab, but not after the last tab |     // Show dividers after the active tab, but not after the last tab | ||||||
| @@ -32,14 +37,14 @@ const showDivider = index => { | |||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <template> | <template> | ||||||
|   <div class="flex h-8 rounded-lg bg-slate-25 dark:bg-slate-800/50 w-fit"> |   <div class="flex h-8 rounded-lg bg-n-solid-1 w-fit"> | ||||||
|     <template v-for="(tab, index) in tabs" :key="index"> |     <template v-for="(tab, index) in tabs" :key="index"> | ||||||
|       <button |       <button | ||||||
|         class="relative px-4 truncate py-1.5 text-sm border-0 rounded-lg transition-colors duration-300 ease-in-out" |         class="relative px-4 truncate py-1.5 text-sm border-0 rounded-lg transition-colors duration-300 ease-in-out hover:text-n-brand" | ||||||
|         :class="[ |         :class="[ | ||||||
|           activeTab === index |           activeTab === index | ||||||
|             ? 'text-woot-500 bg-woot-500/10 dark:bg-woot-500/10' |             ? 'text-n-brand bg-n-solid-active font-medium' | ||||||
|             : 'text-slate-500 dark:text-slate-400 hover:text-woot-500 dark:hover:text-woot-400', |             : 'text-n-slate-10', | ||||||
|         ]" |         ]" | ||||||
|         @click="selectTab(index)" |         @click="selectTab(index)" | ||||||
|       > |       > | ||||||
|   | |||||||
| @@ -0,0 +1,90 @@ | |||||||
|  | <script setup> | ||||||
|  | import { ref, computed, watch } from 'vue'; | ||||||
|  | import { OnClickOutside } from '@vueuse/components'; | ||||||
|  |  | ||||||
|  | import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue'; | ||||||
|  | import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue'; | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   modelValue: { | ||||||
|  |     type: Array, | ||||||
|  |     default: () => [], | ||||||
|  |   }, | ||||||
|  |   placeholder: { | ||||||
|  |     type: String, | ||||||
|  |     default: '', | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['update:modelValue']); | ||||||
|  |  | ||||||
|  | const tags = ref(props.modelValue); | ||||||
|  | const newTag = ref(''); | ||||||
|  | const isFocused = ref(false); | ||||||
|  |  | ||||||
|  | const showInput = computed(() => isFocused.value || tags.value.length === 0); | ||||||
|  |  | ||||||
|  | const addTag = () => { | ||||||
|  |   if (newTag.value.trim()) { | ||||||
|  |     tags.value.push(newTag.value.trim()); | ||||||
|  |     newTag.value = ''; | ||||||
|  |     emit('update:modelValue', tags.value); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const removeTag = index => { | ||||||
|  |   tags.value.splice(index, 1); | ||||||
|  |   emit('update:modelValue', tags.value); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleFocus = () => { | ||||||
|  |   isFocused.value = true; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleClickOutside = () => { | ||||||
|  |   if (tags.value.length > 0) { | ||||||
|  |     isFocused.value = false; | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | watch( | ||||||
|  |   () => props.modelValue, | ||||||
|  |   newValue => { | ||||||
|  |     tags.value = newValue; | ||||||
|  |   } | ||||||
|  | ); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <OnClickOutside @trigger="handleClickOutside"> | ||||||
|  |     <div | ||||||
|  |       class="flex flex-wrap w-full gap-2 border border-transparent focus:outline-none" | ||||||
|  |       tabindex="0" | ||||||
|  |       @focus="handleFocus" | ||||||
|  |       @click="handleFocus" | ||||||
|  |     > | ||||||
|  |       <div | ||||||
|  |         v-for="(tag, index) in tags" | ||||||
|  |         :key="index" | ||||||
|  |         class="flex items-center justify-center max-w-full gap-1 px-3 py-1 rounded-lg h-7 bg-n-alpha-2" | ||||||
|  |       > | ||||||
|  |         <span class="flex-grow min-w-0 text-sm truncate text-n-slate-12"> | ||||||
|  |           {{ tag }} | ||||||
|  |         </span> | ||||||
|  |         <FluentIcon | ||||||
|  |           icon="dismiss" | ||||||
|  |           size="20" | ||||||
|  |           class="flex-shrink-0 p-1 cursor-pointer text-n-slate-11" | ||||||
|  |           @click.stop="removeTag(index)" | ||||||
|  |         /> | ||||||
|  |       </div> | ||||||
|  |       <InlineInput | ||||||
|  |         v-if="showInput" | ||||||
|  |         v-model="newTag" | ||||||
|  |         :placeholder="placeholder" | ||||||
|  |         custom-input-class="flex-grow" | ||||||
|  |         @enter-press="addTag" | ||||||
|  |       /> | ||||||
|  |     </div> | ||||||
|  |   </OnClickOutside> | ||||||
|  | </template> | ||||||
| @@ -1,5 +1,6 @@ | |||||||
| <script setup> | <script setup> | ||||||
| import { computed } from 'vue'; | import { computed, ref, onMounted, nextTick, watch } from 'vue'; | ||||||
|  |  | ||||||
| const props = defineProps({ | const props = defineProps({ | ||||||
|   modelValue: { |   modelValue: { | ||||||
|     type: String, |     type: String, | ||||||
| @@ -29,13 +30,87 @@ const props = defineProps({ | |||||||
|     type: String, |     type: String, | ||||||
|     default: '', |     default: '', | ||||||
|   }, |   }, | ||||||
|  |   customTextAreaWrapperClass: { | ||||||
|  |     type: String, | ||||||
|  |     default: '', | ||||||
|  |   }, | ||||||
|   showCharacterCount: { |   showCharacterCount: { | ||||||
|     type: Boolean, |     type: Boolean, | ||||||
|     default: false, |     default: false, | ||||||
|   }, |   }, | ||||||
|  |   autoHeight: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, | ||||||
|  |   resize: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, | ||||||
|  |   minHeight: { | ||||||
|  |     type: String, | ||||||
|  |     default: '4rem', | ||||||
|  |   }, | ||||||
|  |   maxHeight: { | ||||||
|  |     type: String, | ||||||
|  |     default: '12rem', | ||||||
|  |   }, | ||||||
|  |   autofocus: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, | ||||||
| }); | }); | ||||||
| defineEmits(['update:modelValue']); |  | ||||||
|  | const emit = defineEmits(['update:modelValue']); | ||||||
|  |  | ||||||
|  | const textareaRef = ref(null); | ||||||
|  | const isFocused = ref(false); | ||||||
|  |  | ||||||
| const characterCount = computed(() => props.modelValue.length); | const characterCount = computed(() => props.modelValue.length); | ||||||
|  |  | ||||||
|  | // TODO - use "field-sizing: content" and "height: auto" in future for auto height, when available. | ||||||
|  | const adjustHeight = () => { | ||||||
|  |   if (!props.autoHeight || !textareaRef.value) return; | ||||||
|  |  | ||||||
|  |   // Reset height to auto to get the correct scrollHeight | ||||||
|  |   textareaRef.value.style.height = 'auto'; | ||||||
|  |   // Set the height to the scrollHeight | ||||||
|  |   textareaRef.value.style.height = `${textareaRef.value.scrollHeight}px`; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleInput = event => { | ||||||
|  |   emit('update:modelValue', event.target.value); | ||||||
|  |   if (props.autoHeight) { | ||||||
|  |     nextTick(adjustHeight); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleFocus = () => { | ||||||
|  |   isFocused.value = true; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleBlur = () => { | ||||||
|  |   isFocused.value = false; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | // Watch for changes in modelValue to adjust height | ||||||
|  | watch( | ||||||
|  |   () => props.modelValue, | ||||||
|  |   () => { | ||||||
|  |     if (props.autoHeight) { | ||||||
|  |       nextTick(adjustHeight); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | onMounted(() => { | ||||||
|  |   if (props.autoHeight) { | ||||||
|  |     nextTick(adjustHeight); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (props.autofocus) { | ||||||
|  |     textareaRef.value.focus(); | ||||||
|  |   } | ||||||
|  | }); | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <template> | <template> | ||||||
| @@ -47,23 +122,49 @@ const characterCount = computed(() => props.modelValue.length); | |||||||
|     > |     > | ||||||
|       {{ label }} |       {{ label }} | ||||||
|     </label> |     </label> | ||||||
|     <textarea |  | ||||||
|       :id="id" |  | ||||||
|       :value="modelValue" |  | ||||||
|       :placeholder="placeholder" |  | ||||||
|       :maxlength="maxLength" |  | ||||||
|       class="flex w-full reset-base text-sm h-24 px-3 pt-3 !mb-0 border rounded-lg focus:border-woot-500 dark:focus:border-woot-600 bg-white dark:bg-slate-900 placeholder:text-slate-200 dark:placeholder:text-slate-500 text-slate-900 dark:text-white transition-all duration-500 ease-in-out resize-none disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-slate-25 dark:disabled:bg-slate-900" |  | ||||||
|       :class="[customTextAreaClass, showCharacterCount ? 'pb-9' : 'pb-3']" |  | ||||||
|       :disabled="disabled" |  | ||||||
|       @input="$emit('update:modelValue', $event.target.value)" |  | ||||||
|     /> |  | ||||||
|     <div |     <div | ||||||
|       v-if="showCharacterCount" |       class="flex flex-col gap-2 px-3 pt-3 pb-3 transition-all duration-500 ease-in-out bg-white border rounded-lg border-n-weak dark:border-n-weak dark:bg-slate-900" | ||||||
|       class="absolute flex items-center justify-between mt-1 bottom-3 ltr:right-3 rtl:left-3" |       :class="[ | ||||||
|  |         customTextAreaWrapperClass, | ||||||
|  |         { | ||||||
|  |           'cursor-not-allowed opacity-50 !bg-slate-25 dark:!bg-slate-800 disabled:border-n-weak dark:disabled:border-n-weak': | ||||||
|  |             disabled, | ||||||
|  |           'border-n-brand dark:border-n-brand': isFocused, | ||||||
|  |           'hover:border-n-slate-6 dark:hover:border-n-slate-6': !isFocused, | ||||||
|  |         }, | ||||||
|  |       ]" | ||||||
|     > |     > | ||||||
|       <span class="text-xs tabular-nums text-slate-300 dark:text-slate-600"> |       <textarea | ||||||
|         {{ characterCount }} / {{ maxLength }} |         :id="id" | ||||||
|       </span> |         ref="textareaRef" | ||||||
|  |         :value="modelValue" | ||||||
|  |         :placeholder="placeholder" | ||||||
|  |         :maxlength="showCharacterCount ? maxLength : undefined" | ||||||
|  |         :class="[ | ||||||
|  |           customTextAreaClass, | ||||||
|  |           { | ||||||
|  |             'resize-none': !resize, | ||||||
|  |           }, | ||||||
|  |         ]" | ||||||
|  |         :style="{ | ||||||
|  |           minHeight: autoHeight ? minHeight : undefined, | ||||||
|  |           maxHeight: autoHeight ? maxHeight : undefined, | ||||||
|  |         }" | ||||||
|  |         :disabled="disabled" | ||||||
|  |         rows="1" | ||||||
|  |         class="flex w-full reset-base text-sm p-0 !rounded-none !bg-transparent dark:!bg-transparent !border-0 !mb-0 placeholder:text-slate-200 dark:placeholder:text-slate-500 text-slate-900 dark:text-white disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-slate-25 dark:disabled:bg-slate-900" | ||||||
|  |         @input="handleInput" | ||||||
|  |         @focus="handleFocus" | ||||||
|  |         @blur="handleBlur" | ||||||
|  |       /> | ||||||
|  |       <div | ||||||
|  |         v-if="showCharacterCount" | ||||||
|  |         class="flex items-center justify-end h-4 mt-1 bottom-3 ltr:right-3 rtl:left-3" | ||||||
|  |       > | ||||||
|  |         <span class="text-xs tabular-nums text-slate-300 dark:text-slate-600"> | ||||||
|  |           {{ characterCount }} / {{ maxLength }} | ||||||
|  |         </span> | ||||||
|  |       </div> | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
|   | |||||||
							
								
								
									
										121
									
								
								app/javascript/dashboard/components-next/thumbnail/Thumbnail.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								app/javascript/dashboard/components-next/thumbnail/Thumbnail.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,121 @@ | |||||||
|  | <script setup> | ||||||
|  | import { computed, ref } from 'vue'; | ||||||
|  | import { removeEmoji } from 'shared/helpers/emoji'; | ||||||
|  |  | ||||||
|  | import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue'; | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   author: { | ||||||
|  |     type: Object, | ||||||
|  |     default: null, | ||||||
|  |   }, | ||||||
|  |   name: { | ||||||
|  |     type: String, | ||||||
|  |     default: '', | ||||||
|  |   }, | ||||||
|  |   src: { | ||||||
|  |     type: String, | ||||||
|  |     default: '', | ||||||
|  |   }, | ||||||
|  |   size: { | ||||||
|  |     type: Number, | ||||||
|  |     default: 16, | ||||||
|  |   }, | ||||||
|  |   showAuthorName: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: true, | ||||||
|  |   }, | ||||||
|  |   iconName: { | ||||||
|  |     type: String, | ||||||
|  |     default: '', | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  | const hasImageLoaded = ref(false); | ||||||
|  | const imgError = ref(false); | ||||||
|  |  | ||||||
|  | const authorInitial = computed(() => { | ||||||
|  |   if (!props.name) return ''; | ||||||
|  |   const name = removeEmoji(props.name); | ||||||
|  |   const words = name.split(/\s+/); | ||||||
|  |  | ||||||
|  |   if (words.length === 1) { | ||||||
|  |     return name.substring(0, 2).toUpperCase(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return words | ||||||
|  |     .slice(0, 2) | ||||||
|  |     .map(word => word[0]) | ||||||
|  |     .join('') | ||||||
|  |     .toUpperCase(); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const fontSize = computed(() => { | ||||||
|  |   return props.size / 2; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const iconSize = computed(() => { | ||||||
|  |   return props.size / 2; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const shouldShowImage = computed(() => { | ||||||
|  |   return props.src && !imgError.value; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const onImgError = () => { | ||||||
|  |   imgError.value = true; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const onImgLoad = () => { | ||||||
|  |   hasImageLoaded.value = true; | ||||||
|  | }; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <div | ||||||
|  |     class="flex items-center justify-center rounded-full bg-slate-100 dark:bg-slate-700/50" | ||||||
|  |     :style="{ width: `${size}px`, height: `${size}px` }" | ||||||
|  |   > | ||||||
|  |     <div v-if="author"> | ||||||
|  |       <img | ||||||
|  |         v-if="shouldShowImage" | ||||||
|  |         :src="src" | ||||||
|  |         :alt="name" | ||||||
|  |         class="w-full h-full rounded-full" | ||||||
|  |         @load="onImgLoad" | ||||||
|  |         @error="onImgError" | ||||||
|  |       /> | ||||||
|  |       <template v-else> | ||||||
|  |         <span | ||||||
|  |           v-if="showAuthorName" | ||||||
|  |           class="flex items-center justify-center font-medium text-slate-500 dark:text-slate-400" | ||||||
|  |           :style="{ fontSize: `${fontSize}px` }" | ||||||
|  |         > | ||||||
|  |           {{ authorInitial }} | ||||||
|  |         </span> | ||||||
|  |         <div | ||||||
|  |           v-else | ||||||
|  |           class="flex items-center justify-center w-full h-full rounded-xl" | ||||||
|  |         > | ||||||
|  |           <FluentIcon | ||||||
|  |             v-if="iconName" | ||||||
|  |             :icon="iconName" | ||||||
|  |             icon-lib="lucide" | ||||||
|  |             :size="iconSize" | ||||||
|  |             class="text-n-brand" | ||||||
|  |           /> | ||||||
|  |         </div> | ||||||
|  |       </template> | ||||||
|  |     </div> | ||||||
|  |     <div | ||||||
|  |       v-else | ||||||
|  |       class="flex items-center justify-center w-4 h-4 rounded-full bg-slate-100 dark:bg-slate-700/50" | ||||||
|  |     > | ||||||
|  |       <FluentIcon | ||||||
|  |         icon="person" | ||||||
|  |         type="filled" | ||||||
|  |         size="10" | ||||||
|  |         class="text-woot-500 dark:text-woot-400" | ||||||
|  |       /> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
| @@ -55,8 +55,8 @@ const primaryMenuItems = accountId => [ | |||||||
|     label: 'HELP_CENTER.TITLE', |     label: 'HELP_CENTER.TITLE', | ||||||
|     featureFlag: FEATURE_FLAGS.HELP_CENTER, |     featureFlag: FEATURE_FLAGS.HELP_CENTER, | ||||||
|     alwaysVisibleOnChatwootInstances: true, |     alwaysVisibleOnChatwootInstances: true, | ||||||
|     toState: frontendURL(`accounts/${accountId}/portals`), |     toState: frontendURL(`accounts/${accountId}/portals/portal_articles_index`), | ||||||
|     toStateName: 'default_portal_articles', |     toStateName: 'portals_index', | ||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|     icon: 'settings', |     icon: 'settings', | ||||||
|   | |||||||
| @@ -82,6 +82,7 @@ export default { | |||||||
|       /> |       /> | ||||||
|       <PrimaryNavItem |       <PrimaryNavItem | ||||||
|         v-for="menuItem in menuItems" |         v-for="menuItem in menuItems" | ||||||
|  |         :id="menuItem.key" | ||||||
|         :key="menuItem.toState" |         :key="menuItem.toState" | ||||||
|         :icon="menuItem.icon" |         :icon="menuItem.icon" | ||||||
|         :name="menuItem.label" |         :name="menuItem.label" | ||||||
| @@ -94,7 +95,7 @@ export default { | |||||||
|         v-if="!isACustomBrandedInstance" |         v-if="!isACustomBrandedInstance" | ||||||
|         v-tooltip.right="$t(`SIDEBAR.DOCS`)" |         v-tooltip.right="$t(`SIDEBAR.DOCS`)" | ||||||
|         :href="helpDocsURL" |         :href="helpDocsURL" | ||||||
|         class="text-slate-700 dark:text-slate-100 w-10 h-10 my-2 flex items-center justify-center rounded-lg hover:bg-slate-25 dark:hover:bg-slate-700 dark:hover:text-slate-100 hover:text-slate-600 relative" |         class="relative flex items-center justify-center w-10 h-10 my-2 rounded-lg text-slate-700 dark:text-slate-100 hover:bg-slate-25 dark:hover:bg-slate-700 dark:hover:text-slate-100 hover:text-slate-600" | ||||||
|         rel="noopener noreferrer nofollow" |         rel="noopener noreferrer nofollow" | ||||||
|         target="_blank" |         target="_blank" | ||||||
|       > |       > | ||||||
|   | |||||||
| @@ -1,10 +1,23 @@ | |||||||
| <script> | <script> | ||||||
|  | import { OnClickOutside } from '@vueuse/components'; | ||||||
|  | import { HELP_CENTER_MENU_ITEMS } from 'dashboard/helper/portalHelper'; | ||||||
|  |  | ||||||
|  | import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; | ||||||
|  |  | ||||||
| export default { | export default { | ||||||
|  |   components: { | ||||||
|  |     DropdownMenu, | ||||||
|  |     OnClickOutside, | ||||||
|  |   }, | ||||||
|   props: { |   props: { | ||||||
|     to: { |     to: { | ||||||
|       type: String, |       type: String, | ||||||
|       default: '', |       default: '', | ||||||
|     }, |     }, | ||||||
|  |     id: { | ||||||
|  |       type: String, | ||||||
|  |       default: '', | ||||||
|  |     }, | ||||||
|     name: { |     name: { | ||||||
|       type: String, |       type: String, | ||||||
|       default: '', |       default: '', | ||||||
| @@ -26,15 +39,89 @@ export default { | |||||||
|       default: false, |       default: false, | ||||||
|     }, |     }, | ||||||
|   }, |   }, | ||||||
|  |  | ||||||
|  |   data() { | ||||||
|  |     return { | ||||||
|  |       helpCenterMenu: HELP_CENTER_MENU_ITEMS, | ||||||
|  |       showHelpCenterMenu: false, | ||||||
|  |     }; | ||||||
|  |   }, | ||||||
|  |   computed: { | ||||||
|  |     helpCenterMenuItems() { | ||||||
|  |       return this.helpCenterMenu.map(item => ({ | ||||||
|  |         ...item, | ||||||
|  |         isSelected: this.isSelectedMenuItem(item), | ||||||
|  |       })); | ||||||
|  |     }, | ||||||
|  |     isHelpCenter() { | ||||||
|  |       return this.id === 'helpcenter'; | ||||||
|  |     }, | ||||||
|  |     isHelpCenterSelected() { | ||||||
|  |       const routes = [ | ||||||
|  |         'portals_new', | ||||||
|  |         'portals_index', | ||||||
|  |         'portals_articles_index', | ||||||
|  |         'portals_articles_new', | ||||||
|  |         'portals_articles_edit', | ||||||
|  |         'portals_categories_index', | ||||||
|  |         'portals_categories_articles_index', | ||||||
|  |         'portals_categories_articles_edit', | ||||||
|  |         'portals_locales_index', | ||||||
|  |         'portals_settings_index', | ||||||
|  |       ]; | ||||||
|  |       return routes.includes(this.$route.name); | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   methods: { | ||||||
|  |     isSelectedMenuItem(menuItem) { | ||||||
|  |       return menuItem.value.includes(this.$route.name); | ||||||
|  |     }, | ||||||
|  |     toggleHelpCenterMenu() { | ||||||
|  |       this.showHelpCenterMenu = !this.showHelpCenterMenu; | ||||||
|  |     }, | ||||||
|  |     handleHelpCenterAction({ action }) { | ||||||
|  |       this.$router.push({ | ||||||
|  |         name: 'portals_index', | ||||||
|  |         params: { | ||||||
|  |           navigationPath: action, | ||||||
|  |         }, | ||||||
|  |       }); | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
| }; | }; | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <template> | <template> | ||||||
|   <router-link v-slot="{ href, isActive, navigate }" :to="to" custom> |   <OnClickOutside v-if="isHelpCenter" @trigger="showHelpCenterMenu = false"> | ||||||
|  |     <button | ||||||
|  |       v-tooltip.top="$t(`SIDEBAR.${name}`)" | ||||||
|  |       class="relative flex items-center justify-center w-10 h-10 my-2 rounded-lg text-slate-700 dark:text-slate-100 hover:!bg-slate-25 dark:hover:!bg-slate-700 dark:hover:text-slate-100 hover:text-slate-600" | ||||||
|  |       :class="{ | ||||||
|  |         'bg-woot-50 dark:bg-slate-800 text-woot-500 hover:bg-woot-50': | ||||||
|  |           isHelpCenterSelected, | ||||||
|  |       }" | ||||||
|  |       @click="toggleHelpCenterMenu" | ||||||
|  |     > | ||||||
|  |       <fluent-icon | ||||||
|  |         :icon="icon" | ||||||
|  |         :class="{ | ||||||
|  |           'text-woot-500': isHelpCenterSelected, | ||||||
|  |         }" | ||||||
|  |       /> | ||||||
|  |       <DropdownMenu | ||||||
|  |         v-if="showHelpCenterMenu && isHelpCenter" | ||||||
|  |         :menu-items="helpCenterMenuItems" | ||||||
|  |         class="ltr:left-10 rtl:right-10 w-36 z-[100] top-0 overflow-y-auto max-h-52" | ||||||
|  |         @action="handleHelpCenterAction" | ||||||
|  |       /> | ||||||
|  |     </button> | ||||||
|  |   </OnClickOutside> | ||||||
|  |  | ||||||
|  |   <router-link v-else v-slot="{ href, isActive, navigate }" :to="to" custom> | ||||||
|     <a |     <a | ||||||
|       v-tooltip.right="$t(`SIDEBAR.${name}`)" |       v-tooltip.right="$t(`SIDEBAR.${name}`)" | ||||||
|       :href="href" |       :href="href" | ||||||
|       class="text-slate-700 dark:text-slate-100 w-10 h-10 my-2 flex items-center justify-center rounded-lg hover:bg-slate-25 dark:hover:bg-slate-700 dark:hover:text-slate-100 hover:text-slate-600 relative" |       class="relative flex items-center justify-center w-10 h-10 my-2 rounded-lg text-slate-700 dark:text-slate-100 hover:bg-slate-25 dark:hover:bg-slate-700 dark:hover:text-slate-100 hover:text-slate-600" | ||||||
|       :class="{ |       :class="{ | ||||||
|         'bg-woot-50 dark:bg-slate-800 text-woot-500 hover:bg-woot-50': |         'bg-woot-50 dark:bg-slate-800 text-woot-500 hover:bg-woot-50': | ||||||
|           isActive || isChildMenuActive, |           isActive || isChildMenuActive, | ||||||
| @@ -52,7 +139,7 @@ export default { | |||||||
|       <span class="sr-only">{{ name }}</span> |       <span class="sr-only">{{ name }}</span> | ||||||
|       <span |       <span | ||||||
|         v-if="count" |         v-if="count" | ||||||
|         class="text-black-900 bg-yellow-500 absolute -top-1 -right-1" |         class="absolute bg-yellow-500 text-black-900 -top-1 -right-1" | ||||||
|       > |       > | ||||||
|         {{ count }} |         {{ count }} | ||||||
|       </span> |       </span> | ||||||
|   | |||||||
| @@ -46,6 +46,10 @@ export default { | |||||||
|     editorId: { type: String, default: '' }, |     editorId: { type: String, default: '' }, | ||||||
|     placeholder: { type: String, default: '' }, |     placeholder: { type: String, default: '' }, | ||||||
|     enabledMenuOptions: { type: Array, default: () => [] }, |     enabledMenuOptions: { type: Array, default: () => [] }, | ||||||
|  |     autofocus: { | ||||||
|  |       type: Boolean, | ||||||
|  |       default: true, | ||||||
|  |     }, | ||||||
|   }, |   }, | ||||||
|   emits: ['blur', 'input', 'update:modelValue', 'keyup', 'focus', 'keydown'], |   emits: ['blur', 'input', 'update:modelValue', 'keyup', 'focus', 'keydown'], | ||||||
|   setup() { |   setup() { | ||||||
| @@ -86,7 +90,9 @@ export default { | |||||||
|     this.createEditorView(); |     this.createEditorView(); | ||||||
|  |  | ||||||
|     editorView.updateState(state); |     editorView.updateState(state); | ||||||
|     this.focusEditorInputField(); |     if (this.autofocus) { | ||||||
|  |       this.focusEditorInputField(); | ||||||
|  |     } | ||||||
|   }, |   }, | ||||||
|   methods: { |   methods: { | ||||||
|     contentFromEditor() { |     contentFromEditor() { | ||||||
|   | |||||||
| @@ -117,3 +117,11 @@ export const timeStampAppendedURL = dataUrl => { | |||||||
|  |  | ||||||
|   return url.toString(); |   return url.toString(); | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | export const getHostNameFromURL = url => { | ||||||
|  |   try { | ||||||
|  |     return new URL(url).hostname; | ||||||
|  |   } catch (error) { | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  | }; | ||||||
|   | |||||||
| @@ -13,3 +13,140 @@ export const buildPortalArticleURL = ( | |||||||
|   const portalURL = buildPortalURL(portalSlug); |   const portalURL = buildPortalURL(portalSlug); | ||||||
|   return `${portalURL}/articles/${articleSlug}`; |   return `${portalURL}/articles/${articleSlug}`; | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | export const getArticleStatus = status => { | ||||||
|  |   switch (status) { | ||||||
|  |     case 'draft': | ||||||
|  |       return 0; | ||||||
|  |     case 'published': | ||||||
|  |       return 1; | ||||||
|  |     case 'archived': | ||||||
|  |       return 2; | ||||||
|  |     default: | ||||||
|  |       return undefined; | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | // Constants | ||||||
|  | export const HELP_CENTER_MENU_ITEMS = [ | ||||||
|  |   { | ||||||
|  |     label: 'Articles', | ||||||
|  |     icon: 'book', | ||||||
|  |     action: 'portals_articles_index', | ||||||
|  |     value: [ | ||||||
|  |       'portals_articles_index', | ||||||
|  |       'portals_articles_new', | ||||||
|  |       'portals_articles_edit', | ||||||
|  |     ], | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     label: 'Categories', | ||||||
|  |     icon: 'folder', | ||||||
|  |     action: 'portals_categories_index', | ||||||
|  |     value: [ | ||||||
|  |       'portals_categories_index', | ||||||
|  |       'portals_categories_articles_index', | ||||||
|  |       'portals_categories_articles_edit', | ||||||
|  |     ], | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     label: 'Locales', | ||||||
|  |     icon: 'translate', | ||||||
|  |     action: 'portals_locales_index', | ||||||
|  |     value: ['portals_locales_index'], | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     label: 'Settings', | ||||||
|  |     icon: 'settings', | ||||||
|  |     action: 'portals_settings_index', | ||||||
|  |     value: ['portals_settings_index'], | ||||||
|  |   }, | ||||||
|  | ]; | ||||||
|  |  | ||||||
|  | export const ARTICLE_STATUSES = { | ||||||
|  |   DRAFT: 'draft', | ||||||
|  |   PUBLISHED: 'published', | ||||||
|  |   ARCHIVED: 'archived', | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const ARTICLE_MENU_ITEMS = { | ||||||
|  |   publish: { | ||||||
|  |     label: 'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.PUBLISH', | ||||||
|  |     value: ARTICLE_STATUSES.PUBLISHED, | ||||||
|  |     action: 'publish', | ||||||
|  |     icon: 'checkmark', | ||||||
|  |   }, | ||||||
|  |   draft: { | ||||||
|  |     label: 'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.DRAFT', | ||||||
|  |     value: ARTICLE_STATUSES.DRAFT, | ||||||
|  |     action: 'draft', | ||||||
|  |     icon: 'draft', | ||||||
|  |   }, | ||||||
|  |   archive: { | ||||||
|  |     label: 'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.ARCHIVE', | ||||||
|  |     value: ARTICLE_STATUSES.ARCHIVED, | ||||||
|  |     action: 'archive', | ||||||
|  |     icon: 'archive', | ||||||
|  |   }, | ||||||
|  |   delete: { | ||||||
|  |     label: 'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.DELETE', | ||||||
|  |     value: 'delete', | ||||||
|  |     action: 'delete', | ||||||
|  |     icon: 'delete', | ||||||
|  |   }, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const ARTICLE_MENU_OPTIONS = { | ||||||
|  |   [ARTICLE_STATUSES.ARCHIVED]: ['publish', 'draft'], | ||||||
|  |   [ARTICLE_STATUSES.DRAFT]: ['publish', 'archive'], | ||||||
|  |   [ARTICLE_STATUSES.PUBLISHED]: ['draft', 'archive'], | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const ARTICLE_TABS = { | ||||||
|  |   ALL: 'all', | ||||||
|  |   MINE: 'mine', | ||||||
|  |   DRAFT: 'draft', | ||||||
|  |   ARCHIVED: 'archived', | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const CATEGORY_ALL = 'all'; | ||||||
|  |  | ||||||
|  | export const ARTICLE_TABS_OPTIONS = [ | ||||||
|  |   { | ||||||
|  |     key: 'ALL', | ||||||
|  |     value: 'all', | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     key: 'MINE', | ||||||
|  |     value: 'mine', | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     key: 'DRAFT', | ||||||
|  |     value: 'draft', | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     key: 'ARCHIVED', | ||||||
|  |     value: 'archived', | ||||||
|  |   }, | ||||||
|  | ]; | ||||||
|  |  | ||||||
|  | export const LOCALE_MENU_ITEMS = [ | ||||||
|  |   { | ||||||
|  |     label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MAKE_DEFAULT', | ||||||
|  |     action: 'change-default', | ||||||
|  |     value: 'default', | ||||||
|  |     icon: 'star-emphasis', | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE', | ||||||
|  |     action: 'delete', | ||||||
|  |     value: 'delete', | ||||||
|  |     icon: 'delete', | ||||||
|  |   }, | ||||||
|  | ]; | ||||||
|  |  | ||||||
|  | export const ARTICLE_EDITOR_STATUS_OPTIONS = { | ||||||
|  |   published: ['archive', 'draft'], | ||||||
|  |   archived: ['draft'], | ||||||
|  |   draft: ['archive'], | ||||||
|  | }; | ||||||
|   | |||||||
| @@ -6,6 +6,7 @@ import { | |||||||
|   getArticleSearchURL, |   getArticleSearchURL, | ||||||
|   hasValidAvatarUrl, |   hasValidAvatarUrl, | ||||||
|   timeStampAppendedURL, |   timeStampAppendedURL, | ||||||
|  |   getHostNameFromURL, | ||||||
| } from '../URLHelper'; | } from '../URLHelper'; | ||||||
|  |  | ||||||
| describe('#URL Helpers', () => { | describe('#URL Helpers', () => { | ||||||
| @@ -238,4 +239,28 @@ describe('#URL Helpers', () => { | |||||||
|       expect(() => timeStampAppendedURL(input)).toThrow(); |       expect(() => timeStampAppendedURL(input)).toThrow(); | ||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|  |   describe('getHostNameFromURL', () => { | ||||||
|  |     it('should return the hostname from a valid URL', () => { | ||||||
|  |       expect(getHostNameFromURL('https://example.com/path')).toBe( | ||||||
|  |         'example.com' | ||||||
|  |       ); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should return null for an invalid URL', () => { | ||||||
|  |       expect(getHostNameFromURL('not a valid url')).toBe(null); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should return null for an empty string', () => { | ||||||
|  |       expect(getHostNameFromURL('')).toBe(null); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should return null for undefined input', () => { | ||||||
|  |       expect(getHostNameFromURL(undefined)).toBe(null); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should correctly handle URLs with non-standard TLDs', () => { | ||||||
|  |       expect(getHostNameFromURL('https://chatwoot.help')).toBe('chatwoot.help'); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
| }); | }); | ||||||
|   | |||||||
| @@ -1,5 +1,10 @@ | |||||||
| { | { | ||||||
|   "HELP_CENTER": { |   "HELP_CENTER": { | ||||||
|  |     "TITLE": "Help Center", | ||||||
|  |     "NEW_PAGE": { | ||||||
|  |       "DESCRIPTION": "Create self-service help center portals for your customers. Help them find answers quickly, without waiting. Streamline inquiries, boost agent efficiency, and elevate customer support.", | ||||||
|  |       "CREATE_PORTAL_BUTTON": "Create Portal" | ||||||
|  |     }, | ||||||
|     "HEADER": { |     "HEADER": { | ||||||
|       "FILTER": "Filter by", |       "FILTER": "Filter by", | ||||||
|       "SORT": "Sort by", |       "SORT": "Sort by", | ||||||
| @@ -343,6 +348,12 @@ | |||||||
|         "SUCCESS": "Article archived successfully" |         "SUCCESS": "Article archived successfully" | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|  |     "DRAFT_ARTICLE": { | ||||||
|  |       "API": { | ||||||
|  |         "ERROR": "Error while drafting article", | ||||||
|  |         "SUCCESS": "Article drafted successfully" | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|     "DELETE_ARTICLE": { |     "DELETE_ARTICLE": { | ||||||
|       "MODAL": { |       "MODAL": { | ||||||
|         "CONFIRM": { |         "CONFIRM": { | ||||||
| @@ -478,9 +489,304 @@ | |||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|     "LOADING": "Loading...", |     "LOADING": "Loading...", | ||||||
|  |     "ARTICLES_PAGE": { | ||||||
|  |       "ARTICLE_CARD": { | ||||||
|  |         "CARD": { | ||||||
|  |           "VIEWS": "{count} view | {count} views", | ||||||
|  |           "DROPDOWN_MENU": { | ||||||
|  |             "PUBLISH": "Publish", | ||||||
|  |             "DRAFT": "Draft", | ||||||
|  |             "ARCHIVE": "Archive", | ||||||
|  |             "DELETE": "Delete" | ||||||
|  |           }, | ||||||
|  |           "STATUS": { | ||||||
|  |             "DRAFT": "Draft", | ||||||
|  |             "PUBLISHED": "Published", | ||||||
|  |             "ARCHIVED": "Archived" | ||||||
|  |           }, | ||||||
|  |           "CATEGORY": { | ||||||
|  |             "UNCATEGORISED": "Uncategorised" | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|  |       "ARTICLES_HEADER": { | ||||||
|  |         "TABS": { | ||||||
|  |           "ALL": "All articles", | ||||||
|  |           "MINE": "Mine", | ||||||
|  |           "DRAFT": "Draft", | ||||||
|  |           "PUBLISHED": "Published", | ||||||
|  |           "ARCHIVED": "Archived" | ||||||
|  |         }, | ||||||
|  |         "CATEGORY": { | ||||||
|  |           "ALL": "All categories" | ||||||
|  |         }, | ||||||
|  |         "LOCALE": { | ||||||
|  |           "ALL": "All locales" | ||||||
|  |         }, | ||||||
|  |         "NEW_ARTICLE": "New article" | ||||||
|  |       }, | ||||||
|  |       "EMPTY_STATE": { | ||||||
|  |         "ALL": { | ||||||
|  |           "TITLE": "Write an article", | ||||||
|  |           "SUBTITLE": "Write a rich article, let’s get started!", | ||||||
|  |           "BUTTON_LABEL": "New article" | ||||||
|  |         }, | ||||||
|  |         "MINE": { | ||||||
|  |           "TITLE": "There are no articles in mine", | ||||||
|  |           "SUBTITLE": "Mine articles will appear here" | ||||||
|  |         }, | ||||||
|  |         "DRAFT": { | ||||||
|  |           "TITLE": "There are no articles in draft", | ||||||
|  |           "SUBTITLE": "Draft articles will appear here" | ||||||
|  |         }, | ||||||
|  |         "PUBLISHED": { | ||||||
|  |           "TITLE": "There are no articles in published", | ||||||
|  |           "SUBTITLE": "Published articles will appear here" | ||||||
|  |         }, | ||||||
|  |         "ARCHIVED": { | ||||||
|  |           "TITLE": "There are no articles in archived", | ||||||
|  |           "SUBTITLE": "Archived articles will appear here" | ||||||
|  |         }, | ||||||
|  |         "CATEGORY": { | ||||||
|  |           "TITLE": "There are no articles in this category", | ||||||
|  |           "SUBTITLE": "Articles in this category will appear here" | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     "CATEGORY_PAGE": { | ||||||
|  |       "CATEGORY_HEADER": { | ||||||
|  |         "NEW_CATEGORY": "New category", | ||||||
|  |         "EDIT_CATEGORY": "Edit category", | ||||||
|  |         "CATEGORIES_COUNT": "{n} category | {n} categories", | ||||||
|  |         "BREADCRUMB": { | ||||||
|  |           "CATEGORY_LOCALE": "Categories ({localeCode})", | ||||||
|  |           "ACTIVE_CATEGORY": "{categoryName} ({categoryCount} articles) | {categoryName} ({categoryCount} article)" | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|  |       "CATEGORY_EMPTY_STATE": { | ||||||
|  |         "TITLE": "No categories found", | ||||||
|  |         "SUBTITLE": "Categories will appear here. You can add a category by clicking the 'New Category' button." | ||||||
|  |       }, | ||||||
|  |       "CATEGORY_CARD": { | ||||||
|  |         "ARTICLES_COUNT": "{count} article | {count} articles" | ||||||
|  |       }, | ||||||
|  |       "CATEGORY_DIALOG": { | ||||||
|  |         "CREATE": { | ||||||
|  |           "API": { | ||||||
|  |             "SUCCESS_MESSAGE": "Category created successfully", | ||||||
|  |             "ERROR_MESSAGE": "Unable to create category" | ||||||
|  |           } | ||||||
|  |         }, | ||||||
|  |         "EDIT": { | ||||||
|  |           "API": { | ||||||
|  |             "SUCCESS_MESSAGE": "Category updated successfully", | ||||||
|  |             "ERROR_MESSAGE": "Unable to update category" | ||||||
|  |           } | ||||||
|  |         }, | ||||||
|  |         "DELETE": { | ||||||
|  |           "API": { | ||||||
|  |             "SUCCESS_MESSAGE": "Category deleted successfully", | ||||||
|  |             "ERROR_MESSAGE": "Unable to delete category" | ||||||
|  |           } | ||||||
|  |         }, | ||||||
|  |         "HEADER": { | ||||||
|  |           "CREATE": "Create category", | ||||||
|  |           "EDIT": "Edit category", | ||||||
|  |           "DESCRIPTION": "Editing a category will update the category in the public facing portal.", | ||||||
|  |           "PORTAL": "Portal", | ||||||
|  |           "LOCALE": "Locale" | ||||||
|  |         }, | ||||||
|  |         "FORM": { | ||||||
|  |           "NAME": { | ||||||
|  |             "LABEL": "Name", | ||||||
|  |             "PLACEHOLDER": "Category name", | ||||||
|  |             "ERROR": "Name is required" | ||||||
|  |           }, | ||||||
|  |           "SLUG": { | ||||||
|  |             "LABEL": "Slug", | ||||||
|  |             "PLACEHOLDER": "Category slug for urls", | ||||||
|  |             "ERROR": "Slug is required", | ||||||
|  |             "HELP_TEXT": "app.chatwoot.com/hc/{portalSlug}/{localeCode}/categories/{categorySlug}" | ||||||
|  |           }, | ||||||
|  |           "DESCRIPTION": { | ||||||
|  |             "LABEL": "Description", | ||||||
|  |             "PLACEHOLDER": "Give a short description about the category.", | ||||||
|  |             "ERROR": "Description is required" | ||||||
|  |           } | ||||||
|  |         }, | ||||||
|  |         "BUTTONS": { | ||||||
|  |           "CREATE": "Create", | ||||||
|  |           "EDIT": "Update", | ||||||
|  |           "CANCEL": "Cancel" | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|     "LOCALES_PAGE": { |     "LOCALES_PAGE": { | ||||||
|       "LOCALES_COUNT": "No locales available | {n} locale | {n} locales", |       "LOCALES_COUNT": "No locales available | {n} locale | {n} locales", | ||||||
|       "NEW_LOCALE_BUTTON_TEXT": "New locale" |       "NEW_LOCALE_BUTTON_TEXT": "New locale", | ||||||
|  |       "LOCALE_CARD": { | ||||||
|  |         "ARTICLES_COUNT": "{count} article | {count} articles", | ||||||
|  |         "CATEGORIES_COUNT": "{count} category | {count} categories", | ||||||
|  |         "DEFAULT": "Default", | ||||||
|  |         "DROPDOWN_MENU": { | ||||||
|  |           "MAKE_DEFAULT": "Make default", | ||||||
|  |           "DELETE": "Delete" | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|  |       "ADD_LOCALE_DIALOG": { | ||||||
|  |         "TITLE": "Add a new locale", | ||||||
|  |         "DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.", | ||||||
|  |         "COMBOBOX": { | ||||||
|  |           "PLACEHOLDER": "Select locale..." | ||||||
|  |         }, | ||||||
|  |         "API": { | ||||||
|  |           "SUCCESS_MESSAGE": "Locale added successfully", | ||||||
|  |           "ERROR_MESSAGE": "Unable to add locale. Try again." | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     "EDIT_ARTICLE_PAGE": { | ||||||
|  |       "HEADER": { | ||||||
|  |         "STATUS": { | ||||||
|  |           "SAVING": "Saving...", | ||||||
|  |           "SAVED": "Saved" | ||||||
|  |         }, | ||||||
|  |         "PREVIEW": "Preview", | ||||||
|  |         "PUBLISH": "Publish", | ||||||
|  |         "DRAFT": "Draft", | ||||||
|  |         "ARCHIVE": "Archive", | ||||||
|  |         "BACK_TO_ARTICLES": "Back to articles" | ||||||
|  |       }, | ||||||
|  |       "EDIT_ARTICLE": { | ||||||
|  |         "MORE_PROPERTIES": "More properties", | ||||||
|  |         "UNCATEGORIZED": "Uncategorized", | ||||||
|  |         "EDITOR_PLACEHOLDER": "Write something..." | ||||||
|  |       }, | ||||||
|  |       "ARTICLE_PROPERTIES": { | ||||||
|  |         "ARTICLE_PROPERTIES": "Article properties", | ||||||
|  |         "META_DESCRIPTION": "Meta description", | ||||||
|  |         "META_DESCRIPTION_PLACEHOLDER": "Add meta description", | ||||||
|  |         "META_TITLE": "Meta title", | ||||||
|  |         "META_TITLE_PLACEHOLDER": "Add meta title", | ||||||
|  |         "META_TAGS": "Meta tags", | ||||||
|  |         "META_TAGS_PLACEHOLDER": "Add meta tags" | ||||||
|  |       }, | ||||||
|  |       "API": { | ||||||
|  |         "ERROR": "Error while saving article" | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     "PORTAL_SWITCHER": { | ||||||
|  |       "NEW_PORTAL": "New portal", | ||||||
|  |       "PORTALS": "Portals", | ||||||
|  |       "CREATE_PORTAL": "Create and manage multiple portals", | ||||||
|  |       "ARTICLES": "articles", | ||||||
|  |       "DOMAIN": "domain", | ||||||
|  |       "PORTAL_NAME": "Portal name" | ||||||
|  |     }, | ||||||
|  |     "CREATE_PORTAL_DIALOG": { | ||||||
|  |       "TITLE": "Create new portal", | ||||||
|  |       "DESCRIPTION": "Give your portal a name and create a user-friendly URL slug. You can modify both later in the settings.", | ||||||
|  |       "CONFIRM_BUTTON_LABEL": "Create", | ||||||
|  |       "NAME": { | ||||||
|  |         "LABEL": "Name", | ||||||
|  |         "PLACEHOLDER": "User Guide | Chatwoot", | ||||||
|  |         "MESSAGE": "Choose an name for your portal.", | ||||||
|  |         "ERROR": "Name is required" | ||||||
|  |       }, | ||||||
|  |       "SLUG": { | ||||||
|  |         "LABEL": "Slug", | ||||||
|  |         "PLACEHOLDER": "user-guide", | ||||||
|  |         "ERROR": "Slug is required" | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     "PORTAL_SETTINGS": { | ||||||
|  |       "FORM": { | ||||||
|  |         "AVATAR": { | ||||||
|  |           "LABEL": "Logo", | ||||||
|  |           "IMAGE_UPLOAD_ERROR": "Couldn't upload image! Try again", | ||||||
|  |           "IMAGE_UPLOAD_SUCCESS": "Image added successfully. Please click on save changes to save the logo", | ||||||
|  |           "IMAGE_DELETE_SUCCESS": "Logo deleted successfully", | ||||||
|  |           "IMAGE_DELETE_ERROR": "Unable to delete logo", | ||||||
|  |           "IMAGE_UPLOAD_SIZE_ERROR": "Image size should be less than {size}MB" | ||||||
|  |         }, | ||||||
|  |         "NAME": { | ||||||
|  |           "LABEL": "Name", | ||||||
|  |           "PLACEHOLDER": "Portal name", | ||||||
|  |           "ERROR": "Name is required" | ||||||
|  |         }, | ||||||
|  |         "HEADER_TEXT": { | ||||||
|  |           "LABEL": "Header text", | ||||||
|  |           "PLACEHOLDER": "Portal header text" | ||||||
|  |         }, | ||||||
|  |         "PAGE_TITLE": { | ||||||
|  |           "LABEL": "Page title", | ||||||
|  |           "PLACEHOLDER": "Portal page title" | ||||||
|  |         }, | ||||||
|  |         "HOME_PAGE_LINK": { | ||||||
|  |           "LABEL": "Home page link", | ||||||
|  |           "PLACEHOLDER": "Portal home page link", | ||||||
|  |           "ERROR": "Invalid URL. The Home page link must start with 'http://' or 'https://'." | ||||||
|  |         }, | ||||||
|  |         "SLUG": { | ||||||
|  |           "LABEL": "Slug", | ||||||
|  |           "PLACEHOLDER": "Portal slug" | ||||||
|  |         }, | ||||||
|  |         "LIVE_CHAT_WIDGET": { | ||||||
|  |           "LABEL": "Live chat widget", | ||||||
|  |           "PLACEHOLDER": "Select live chat widget", | ||||||
|  |           "HELP_TEXT": "Select a live chat widget that will appear on your help center" | ||||||
|  |         }, | ||||||
|  |         "BRAND_COLOR": { | ||||||
|  |           "LABEL": "Brand color" | ||||||
|  |         }, | ||||||
|  |         "SAVE_CHANGES": "Save changes" | ||||||
|  |       }, | ||||||
|  |       "CONFIGURATION_FORM": { | ||||||
|  |         "CUSTOM_DOMAIN": { | ||||||
|  |           "HEADER": "Custom domain", | ||||||
|  |           "LABEL": "Custom domain:", | ||||||
|  |           "DESCRIPTION": "You can host your portal on a custom domain. For instance, if your website is yourdomain.com and you want your portal available at docs.yourdomain.com, simply enter that in this field.", | ||||||
|  |           "PLACEHOLDER": "Portal custom domain", | ||||||
|  |           "EDIT_BUTTON": "Edit custom domain", | ||||||
|  |           "ADD_BUTTON": "Add custom domain", | ||||||
|  |           "DIALOG": { | ||||||
|  |             "ADD_HEADER": "Add custom domain", | ||||||
|  |             "EDIT_HEADER": "Edit custom domain", | ||||||
|  |             "ADD_CONFIRM_BUTTON_LABEL": "Add domain", | ||||||
|  |             "EDIT_CONFIRM_BUTTON_LABEL": "Update domain", | ||||||
|  |             "LABEL": "Custom domain", | ||||||
|  |             "PLACEHOLDER": "Portal custom domain", | ||||||
|  |             "ERROR": "Custom domain is required" | ||||||
|  |           }, | ||||||
|  |           "DNS_CONFIGURATION_DIALOG": { | ||||||
|  |             "HEADER": "DNS configuration", | ||||||
|  |             "DESCRIPTION": "Log in to the account you have with your DNS provider, and add a CNAME record for subdomain pointing to chatwoot.help", | ||||||
|  |             "HELP_TEXT": "Once this is done, you can reach out to our support to request for the auto-generated SSL certificate.", | ||||||
|  |             "CONFIRM_BUTTON_LABEL": "Got it!" | ||||||
|  |           } | ||||||
|  |         }, | ||||||
|  |         "DELETE_PORTAL": { | ||||||
|  |           "BUTTON": "Delete {portalName}", | ||||||
|  |           "HEADER": "Delete portal", | ||||||
|  |           "DESCRIPTION": "Permanently delete this portal. This action is irreversible", | ||||||
|  |           "DIALOG": { | ||||||
|  |             "HEADER": "Sure you want to delete {portalName}?", | ||||||
|  |             "DESCRIPTION": "This is a permanent action that cannot be reversed.", | ||||||
|  |             "CONFIRM_BUTTON_LABEL": "Delete" | ||||||
|  |           } | ||||||
|  |         }, | ||||||
|  |         "EDIT_CONFIGURATION": "Edit configuration" | ||||||
|  |       }, | ||||||
|  |       "API": { | ||||||
|  |         "CREATE_PORTAL": { | ||||||
|  |           "SUCCESS_MESSAGE": "Portal created successfully", | ||||||
|  |           "ERROR_MESSAGE": "Unable to create portal" | ||||||
|  |         }, | ||||||
|  |         "UPDATE_PORTAL": { | ||||||
|  |           "SUCCESS_MESSAGE": "Portal updated successfully", | ||||||
|  |           "ERROR_MESSAGE": "Unable to update portal" | ||||||
|  |         } | ||||||
|  |       } | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -284,13 +284,10 @@ | |||||||
|     "REAUTHORIZE": "Your inbox connection has expired, please reconnect\n to continue receiving and sending messages", |     "REAUTHORIZE": "Your inbox connection has expired, please reconnect\n to continue receiving and sending messages", | ||||||
|     "HELP_CENTER": { |     "HELP_CENTER": { | ||||||
|       "TITLE": "Help Center", |       "TITLE": "Help Center", | ||||||
|       "ALL_ARTICLES": "All Articles", |       "ARTICLES": "Articles", | ||||||
|       "MY_ARTICLES": "My Articles", |       "CATEGORIES": "Categories", | ||||||
|       "DRAFT": "Draft", |       "LOCALES": "Locales", | ||||||
|       "ARCHIVED": "Archived", |       "SETTINGS": "Settings" | ||||||
|       "CATEGORY": "Category", |  | ||||||
|       "SETTINGS": "Settings", |  | ||||||
|       "CATEGORY_EMPTY_MESSAGE": "No categories found" |  | ||||||
|     }, |     }, | ||||||
|     "CHANNELS": "Channels", |     "CHANNELS": "Channels", | ||||||
|     "SET_AUTO_OFFLINE": { |     "SET_AUTO_OFFLINE": { | ||||||
|   | |||||||
| @@ -15,7 +15,6 @@ const Suspended = () => import('./suspended/Index.vue'); | |||||||
|  |  | ||||||
| export default { | export default { | ||||||
|   routes: [ |   routes: [ | ||||||
|     ...helpcenterRoutes.routes, |  | ||||||
|     { |     { | ||||||
|       path: frontendURL('accounts/:accountId'), |       path: frontendURL('accounts/:accountId'), | ||||||
|       component: AppContainer, |       component: AppContainer, | ||||||
| @@ -35,6 +34,7 @@ export default { | |||||||
|         ...contactRoutes, |         ...contactRoutes, | ||||||
|         ...searchRoutes, |         ...searchRoutes, | ||||||
|         ...notificationRoutes, |         ...notificationRoutes, | ||||||
|  |         ...helpcenterRoutes.routes, | ||||||
|       ], |       ], | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|   | |||||||
| @@ -1,159 +0,0 @@ | |||||||
| <script> |  | ||||||
| import Modal from 'dashboard/components/Modal.vue'; |  | ||||||
| import { required } from '@vuelidate/validators'; |  | ||||||
| import { useVuelidate } from '@vuelidate/core'; |  | ||||||
| import { useAlert } from 'dashboard/composables'; |  | ||||||
| import allLocales from 'shared/constants/locales.js'; |  | ||||||
| import { PORTALS_EVENTS } from '../../../../helper/AnalyticsHelper/events'; |  | ||||||
| import { useTrack } from 'dashboard/composables'; |  | ||||||
|  |  | ||||||
| export default { |  | ||||||
|   components: { |  | ||||||
|     Modal, |  | ||||||
|   }, |  | ||||||
|   props: { |  | ||||||
|     show: { |  | ||||||
|       type: Boolean, |  | ||||||
|       default: false, |  | ||||||
|     }, |  | ||||||
|     portal: { |  | ||||||
|       type: Object, |  | ||||||
|       default: () => ({}), |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
|   emits: ['cancel', 'update:show'], |  | ||||||
|   setup() { |  | ||||||
|     return { v$: useVuelidate() }; |  | ||||||
|   }, |  | ||||||
|   data() { |  | ||||||
|     return { |  | ||||||
|       selectedLocale: '', |  | ||||||
|       isUpdating: false, |  | ||||||
|     }; |  | ||||||
|   }, |  | ||||||
|   computed: { |  | ||||||
|     localShow: { |  | ||||||
|       get() { |  | ||||||
|         return this.show; |  | ||||||
|       }, |  | ||||||
|       set(value) { |  | ||||||
|         this.$emit('update:show', value); |  | ||||||
|       }, |  | ||||||
|     }, |  | ||||||
|     addedLocales() { |  | ||||||
|       const { allowed_locales: allowedLocales } = this.portal.config; |  | ||||||
|       return allowedLocales.map(locale => locale.code); |  | ||||||
|     }, |  | ||||||
|     locales() { |  | ||||||
|       const addedLocales = this.portal.config.allowed_locales.map( |  | ||||||
|         locale => locale.code |  | ||||||
|       ); |  | ||||||
|       return Object.keys(allLocales) |  | ||||||
|         .map(key => { |  | ||||||
|           return { |  | ||||||
|             id: key, |  | ||||||
|             name: allLocales[key], |  | ||||||
|             code: key, |  | ||||||
|           }; |  | ||||||
|         }) |  | ||||||
|         .filter(locale => { |  | ||||||
|           return !addedLocales.includes(locale.code); |  | ||||||
|         }); |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
|   validations: { |  | ||||||
|     selectedLocale: { |  | ||||||
|       required, |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
|   methods: { |  | ||||||
|     async onCreate() { |  | ||||||
|       this.v$.$touch(); |  | ||||||
|       if (this.v$.$invalid) { |  | ||||||
|         return; |  | ||||||
|       } |  | ||||||
|       const updatedLocales = this.addedLocales; |  | ||||||
|       updatedLocales.push(this.selectedLocale); |  | ||||||
|       this.isUpdating = true; |  | ||||||
|       try { |  | ||||||
|         await this.$store.dispatch('portals/update', { |  | ||||||
|           portalSlug: this.portal.slug, |  | ||||||
|           config: { allowed_locales: updatedLocales }, |  | ||||||
|         }); |  | ||||||
|         this.alertMessage = this.$t( |  | ||||||
|           'HELP_CENTER.PORTAL.ADD_LOCALE.API.SUCCESS_MESSAGE' |  | ||||||
|         ); |  | ||||||
|         this.onClose(); |  | ||||||
|         useTrack(PORTALS_EVENTS.CREATE_LOCALE, { |  | ||||||
|           localeAdded: this.selectedLocale, |  | ||||||
|           totalLocales: updatedLocales.length, |  | ||||||
|           from: this.$route.name, |  | ||||||
|         }); |  | ||||||
|       } catch (error) { |  | ||||||
|         this.alertMessage = |  | ||||||
|           error?.message || |  | ||||||
|           this.$t('HELP_CENTER.PORTAL.ADD_LOCALE.API.ERROR_MESSAGE'); |  | ||||||
|       } finally { |  | ||||||
|         useAlert(this.alertMessage); |  | ||||||
|         this.isUpdating = false; |  | ||||||
|       } |  | ||||||
|     }, |  | ||||||
|     onClose() { |  | ||||||
|       this.$emit('cancel'); |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
| }; |  | ||||||
| </script> |  | ||||||
|  |  | ||||||
| <template> |  | ||||||
|   <Modal v-model:show="localShow" :on-close="onClose"> |  | ||||||
|     <woot-modal-header |  | ||||||
|       :header-title="$t('HELP_CENTER.PORTAL.ADD_LOCALE.TITLE')" |  | ||||||
|       :header-content="$t('HELP_CENTER.PORTAL.ADD_LOCALE.SUB_TITLE')" |  | ||||||
|     /> |  | ||||||
|     <form class="w-full" @submit.prevent="onCreate"> |  | ||||||
|       <div class="w-full"> |  | ||||||
|         <label :class="{ error: v$.selectedLocale.$error }"> |  | ||||||
|           {{ $t('HELP_CENTER.PORTAL.ADD_LOCALE.LOCALE.LABEL') }} |  | ||||||
|           <select v-model="selectedLocale"> |  | ||||||
|             <option |  | ||||||
|               v-for="locale in locales" |  | ||||||
|               :key="locale.name" |  | ||||||
|               :value="locale.id" |  | ||||||
|             > |  | ||||||
|               {{ locale.name }}-{{ locale.code }} |  | ||||||
|             </option> |  | ||||||
|           </select> |  | ||||||
|           <span v-if="v$.selectedLocale.$error" class="message"> |  | ||||||
|             {{ $t('HELP_CENTER.PORTAL.ADD_LOCALE.LOCALE.ERROR') }} |  | ||||||
|           </span> |  | ||||||
|         </label> |  | ||||||
|  |  | ||||||
|         <div class="w-full"> |  | ||||||
|           <div class="flex flex-row justify-end w-full gap-2 px-0 py-2"> |  | ||||||
|             <woot-button class="button clear" @click.prevent="onClose"> |  | ||||||
|               {{ $t('HELP_CENTER.PORTAL.ADD_LOCALE.BUTTONS.CANCEL') }} |  | ||||||
|             </woot-button> |  | ||||||
|             <woot-button> |  | ||||||
|               {{ $t('HELP_CENTER.PORTAL.ADD_LOCALE.BUTTONS.CREATE') }} |  | ||||||
|             </woot-button> |  | ||||||
|           </div> |  | ||||||
|         </div> |  | ||||||
|       </div> |  | ||||||
|     </form> |  | ||||||
|   </Modal> |  | ||||||
| </template> |  | ||||||
|  |  | ||||||
| <style scoped lang="scss"> |  | ||||||
| .input-container::v-deep { |  | ||||||
|   margin: 0 0 var(--space-normal); |  | ||||||
|  |  | ||||||
|   input { |  | ||||||
|     margin-bottom: 0; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   .message { |  | ||||||
|     margin-top: 0; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| </style> |  | ||||||
| @@ -1,71 +0,0 @@ | |||||||
| <script setup> |  | ||||||
| import { computed, defineEmits } from 'vue'; |  | ||||||
| import { debounce } from '@chatwoot/utils'; |  | ||||||
| import ResizableTextArea from 'shared/components/ResizableTextArea.vue'; |  | ||||||
| import FullEditor from 'dashboard/components/widgets/WootWriter/FullEditor.vue'; |  | ||||||
| import { ARTICLE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor'; |  | ||||||
|  |  | ||||||
| const { article } = defineProps({ |  | ||||||
|   article: { |  | ||||||
|     type: Object, |  | ||||||
|     default: () => ({}), |  | ||||||
|   }, |  | ||||||
| }); |  | ||||||
| const emit = defineEmits(['saveArticle']); |  | ||||||
|  |  | ||||||
| const saveArticle = debounce(value => emit('saveArticle', value), 400, false); |  | ||||||
|  |  | ||||||
| const articleTitle = computed({ |  | ||||||
|   get: () => article.title, |  | ||||||
|   set: title => { |  | ||||||
|     saveArticle({ title }); |  | ||||||
|   }, |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| const articleContent = computed({ |  | ||||||
|   get: () => article.content, |  | ||||||
|   set: content => { |  | ||||||
|     saveArticle({ content }); |  | ||||||
|   }, |  | ||||||
| }); |  | ||||||
| </script> |  | ||||||
|  |  | ||||||
| <template> |  | ||||||
|   <div class="edit-article--container"> |  | ||||||
|     <ResizableTextArea |  | ||||||
|       v-model="articleTitle" |  | ||||||
|       type="text" |  | ||||||
|       :rows="1" |  | ||||||
|       class="article-heading" |  | ||||||
|       :placeholder="$t('HELP_CENTER.EDIT_ARTICLE.TITLE_PLACEHOLDER')" |  | ||||||
|     /> |  | ||||||
|     <FullEditor |  | ||||||
|       v-model="articleContent" |  | ||||||
|       class="article-content" |  | ||||||
|       :placeholder="$t('HELP_CENTER.EDIT_ARTICLE.CONTENT_PLACEHOLDER')" |  | ||||||
|       :enabled-menu-options="ARTICLE_EDITOR_MENU_OPTIONS" |  | ||||||
|     /> |  | ||||||
|   </div> |  | ||||||
| </template> |  | ||||||
|  |  | ||||||
| <style lang="scss" scoped> |  | ||||||
| .edit-article--container { |  | ||||||
|   @apply my-8 mx-auto py-0 max-w-[56rem] w-full; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .article-heading { |  | ||||||
|   @apply text-[2.5rem] font-semibold leading-normal w-full text-slate-900 dark:text-slate-75 p-4 hover:bg-slate-25 dark:hover:bg-slate-800 hover:rounded-md resize-none min-h-[4rem] max-h-[40rem] h-auto mb-2 border-0 border-solid border-transparent dark:border-transparent; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .article-content { |  | ||||||
|   @apply py-0 px-4 h-fit; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| ::v-deep { |  | ||||||
|   .ProseMirror-menubar-wrapper { |  | ||||||
|     .ProseMirror-woot-style { |  | ||||||
|       @apply min-h-[15rem] max-h-full; |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| </style> |  | ||||||
| @@ -1,167 +0,0 @@ | |||||||
| <script> |  | ||||||
| import { dynamicTime } from 'shared/helpers/timeHelper'; |  | ||||||
| import portalMixin from '../mixins/portalMixin'; |  | ||||||
| import { frontendURL } from 'dashboard/helper/URLHelper'; |  | ||||||
| import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue'; |  | ||||||
|  |  | ||||||
| export default { |  | ||||||
|   components: { |  | ||||||
|     Thumbnail, |  | ||||||
|   }, |  | ||||||
|   mixins: [portalMixin], |  | ||||||
|   props: { |  | ||||||
|     showDragIcon: { |  | ||||||
|       type: Boolean, |  | ||||||
|       default: false, |  | ||||||
|     }, |  | ||||||
|     id: { |  | ||||||
|       type: Number, |  | ||||||
|       required: true, |  | ||||||
|     }, |  | ||||||
|     title: { |  | ||||||
|       type: String, |  | ||||||
|       required: true, |  | ||||||
|     }, |  | ||||||
|     author: { |  | ||||||
|       type: Object, |  | ||||||
|       default: () => {}, |  | ||||||
|     }, |  | ||||||
|     category: { |  | ||||||
|       type: Object, |  | ||||||
|       default: () => {}, |  | ||||||
|     }, |  | ||||||
|     views: { |  | ||||||
|       type: Number, |  | ||||||
|       default: 0, |  | ||||||
|     }, |  | ||||||
|     status: { |  | ||||||
|       type: String, |  | ||||||
|       default: 'draft', |  | ||||||
|       values: ['archived', 'draft', 'published'], |  | ||||||
|     }, |  | ||||||
|     updatedAt: { |  | ||||||
|       type: Number, |  | ||||||
|       default: 0, |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
|  |  | ||||||
|   computed: { |  | ||||||
|     lastUpdatedAt() { |  | ||||||
|       return dynamicTime(this.updatedAt); |  | ||||||
|     }, |  | ||||||
|     formattedViewCount() { |  | ||||||
|       return Number(this.views || 0).toLocaleString('en'); |  | ||||||
|     }, |  | ||||||
|     readableViewCount() { |  | ||||||
|       return new Intl.NumberFormat('en-US', { |  | ||||||
|         notation: 'compact', |  | ||||||
|         compactDisplay: 'short', |  | ||||||
|       }).format(this.views || 0); |  | ||||||
|     }, |  | ||||||
|     articleAuthorName() { |  | ||||||
|       return this.author?.name || '-'; |  | ||||||
|     }, |  | ||||||
|     labelColor() { |  | ||||||
|       switch (this.status) { |  | ||||||
|         case 'archived': |  | ||||||
|           return 'secondary'; |  | ||||||
|         case 'draft': |  | ||||||
|           return 'warning'; |  | ||||||
|         default: |  | ||||||
|           return 'success'; |  | ||||||
|       } |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
|   methods: { |  | ||||||
|     getCategoryRoute(categorySlug) { |  | ||||||
|       const { portalSlug, locale } = this.$route.params; |  | ||||||
|       return frontendURL( |  | ||||||
|         `accounts/${this.accountId}/portals/${portalSlug}/${locale}/categories/${categorySlug}` |  | ||||||
|       ); |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
| }; |  | ||||||
| </script> |  | ||||||
|  |  | ||||||
| <template> |  | ||||||
|   <div |  | ||||||
|     class="grid grid-cols-1 gap-4 px-6 py-3 my-0 -mx-4 bg-white border-b text-slate-700 dark:text-slate-100 last:border-b-0 dark:bg-slate-900 lg:grid-cols-12 border-slate-50 dark:border-slate-800" |  | ||||||
|   > |  | ||||||
|     <span class="flex items-start col-span-6 gap-2 text-left"> |  | ||||||
|       <fluent-icon |  | ||||||
|         v-if="showDragIcon" |  | ||||||
|         size="20" |  | ||||||
|         class="flex-shrink-0 block w-4 h-4 mt-1 cursor-move text-slate-200 dark:text-slate-700 hover:text-slate-400 hover:dark:text-slate-200" |  | ||||||
|         icon="grab-handle" |  | ||||||
|       /> |  | ||||||
|       <div class="flex flex-col truncate"> |  | ||||||
|         <router-link :to="articleUrl(id)"> |  | ||||||
|           <h6 |  | ||||||
|             :title="title" |  | ||||||
|             class="text-base ltr:text-left rtl:text-right text-slate-800 dark:text-slate-100 mb-0.5 leading-6 font-medium hover:underline overflow-hidden whitespace-nowrap text-ellipsis" |  | ||||||
|           > |  | ||||||
|             {{ title }} |  | ||||||
|           </h6> |  | ||||||
|         </router-link> |  | ||||||
|         <div class="flex items-center gap-1"> |  | ||||||
|           <Thumbnail |  | ||||||
|             v-if="author" |  | ||||||
|             :src="author.thumbnail" |  | ||||||
|             :username="author.name" |  | ||||||
|             size="14px" |  | ||||||
|           /> |  | ||||||
|           <div |  | ||||||
|             v-else |  | ||||||
|             v-tooltip.right=" |  | ||||||
|               $t('HELP_CENTER.TABLE.COLUMNS.AUTHOR_NOT_AVAILABLE') |  | ||||||
|             " |  | ||||||
|             class="flex items-center justify-center rounded w-3.5 h-3.5 bg-woot-100 dark:bg-woot-700" |  | ||||||
|           > |  | ||||||
|             <fluent-icon |  | ||||||
|               icon="person" |  | ||||||
|               type="filled" |  | ||||||
|               size="10" |  | ||||||
|               class="text-woot-300 dark:text-woot-300" |  | ||||||
|             /> |  | ||||||
|           </div> |  | ||||||
|           <span class="text-sm font-normal text-slate-700 dark:text-slate-200"> |  | ||||||
|             {{ articleAuthorName }} |  | ||||||
|           </span> |  | ||||||
|         </div> |  | ||||||
|       </div> |  | ||||||
|     </span> |  | ||||||
|     <span class="flex items-center col-span-2"> |  | ||||||
|       <router-link |  | ||||||
|         class="text-sm hover:underline p-0.5 truncate hover:bg-slate-25 hover:rounded-md" |  | ||||||
|         :to="getCategoryRoute(category.slug)" |  | ||||||
|       > |  | ||||||
|         <span :title="category.name"> |  | ||||||
|           {{ category.name }} |  | ||||||
|         </span> |  | ||||||
|       </router-link> |  | ||||||
|     </span> |  | ||||||
|     <span |  | ||||||
|       class="flex items-center text-xs lg:text-sm" |  | ||||||
|       :title="formattedViewCount" |  | ||||||
|     > |  | ||||||
|       {{ readableViewCount }} |  | ||||||
|       <span class="ml-1 lg:hidden"> |  | ||||||
|         {{ ` ${$t('HELP_CENTER.TABLE.HEADERS.READ_COUNT')}` }} |  | ||||||
|       </span> |  | ||||||
|     </span> |  | ||||||
|     <span class="flex items-center capitalize"> |  | ||||||
|       <woot-label |  | ||||||
|         class="!mb-0" |  | ||||||
|         :title="status" |  | ||||||
|         size="small" |  | ||||||
|         variant="smooth" |  | ||||||
|         :color-scheme="labelColor" |  | ||||||
|       /> |  | ||||||
|     </span> |  | ||||||
|     <span |  | ||||||
|       class="flex items-center justify-end col-span-2 text-xs first-letter:uppercase text-slate-700 dark:text-slate-100" |  | ||||||
|     > |  | ||||||
|       {{ lastUpdatedAt }} |  | ||||||
|     </span> |  | ||||||
|   </div> |  | ||||||
| </template> |  | ||||||
| @@ -1,13 +1,13 @@ | |||||||
| <script> | <script> | ||||||
| import { debounce } from '@chatwoot/utils'; | import { debounce } from '@chatwoot/utils'; | ||||||
| import { useAlert } from 'dashboard/composables'; | import { useAlert } from 'dashboard/composables'; | ||||||
|  | import allLocales from 'shared/constants/locales.js'; | ||||||
|  |  | ||||||
| import SearchHeader from './Header.vue'; | import SearchHeader from './Header.vue'; | ||||||
| import SearchResults from './SearchResults.vue'; | import SearchResults from './SearchResults.vue'; | ||||||
| import ArticleView from './ArticleView.vue'; | import ArticleView from './ArticleView.vue'; | ||||||
| import ArticlesAPI from 'dashboard/api/helpCenter/articles'; | import ArticlesAPI from 'dashboard/api/helpCenter/articles'; | ||||||
| import { buildPortalArticleURL } from 'dashboard/helper/portalHelper'; | import { buildPortalArticleURL } from 'dashboard/helper/portalHelper'; | ||||||
| import portalMixin from '../../mixins/portalMixin'; |  | ||||||
|  |  | ||||||
| export default { | export default { | ||||||
|   name: 'ArticleSearchPopover', |   name: 'ArticleSearchPopover', | ||||||
| @@ -16,7 +16,6 @@ export default { | |||||||
|     SearchResults, |     SearchResults, | ||||||
|     ArticleView, |     ArticleView, | ||||||
|   }, |   }, | ||||||
|   mixins: [portalMixin], |  | ||||||
|   props: { |   props: { | ||||||
|     selectedPortalSlug: { |     selectedPortalSlug: { | ||||||
|       type: String, |       type: String, | ||||||
| @@ -69,6 +68,9 @@ export default { | |||||||
|         article.slug |         article.slug | ||||||
|       ); |       ); | ||||||
|     }, |     }, | ||||||
|  |     localeName(code) { | ||||||
|  |       return allLocales[code]; | ||||||
|  |     }, | ||||||
|     activeArticle(id) { |     activeArticle(id) { | ||||||
|       return this.searchResultsWithUrl.find(article => article.id === id); |       return this.searchResultsWithUrl.find(article => article.id === id); | ||||||
|     }, |     }, | ||||||
|   | |||||||
| @@ -1,168 +0,0 @@ | |||||||
| <script> |  | ||||||
| import ArticleItem from './ArticleItem.vue'; |  | ||||||
| import TableFooter from 'dashboard/components/widgets/TableFooter.vue'; |  | ||||||
| import Draggable from 'vuedraggable'; |  | ||||||
|  |  | ||||||
| export default { |  | ||||||
|   components: { |  | ||||||
|     ArticleItem, |  | ||||||
|     TableFooter, |  | ||||||
|     Draggable, |  | ||||||
|   }, |  | ||||||
|   props: { |  | ||||||
|     articles: { |  | ||||||
|       type: Array, |  | ||||||
|       default: () => [], |  | ||||||
|     }, |  | ||||||
|     totalCount: { |  | ||||||
|       type: Number, |  | ||||||
|       default: 0, |  | ||||||
|     }, |  | ||||||
|     currentPage: { |  | ||||||
|       type: Number, |  | ||||||
|       default: 1, |  | ||||||
|     }, |  | ||||||
|     pageSize: { |  | ||||||
|       type: Number, |  | ||||||
|       default: 25, |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
|   emits: ['reorder', 'pageChange'], |  | ||||||
|   data() { |  | ||||||
|     return { |  | ||||||
|       localArticles: this.articles || [], |  | ||||||
|     }; |  | ||||||
|   }, |  | ||||||
|   computed: { |  | ||||||
|     dragEnabled() { |  | ||||||
|       // dragging allowed only on category page |  | ||||||
|       return this.articles.length > 1 && this.onCategoryPage; |  | ||||||
|     }, |  | ||||||
|     onCategoryPage() { |  | ||||||
|       return this.$route.name === 'show_category'; |  | ||||||
|     }, |  | ||||||
|     showArticleFooter() { |  | ||||||
|       return this.currentPage === 1 |  | ||||||
|         ? this.totalCount > 25 |  | ||||||
|         : this.articles.length > 0; |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
|   watch: { |  | ||||||
|     articles() { |  | ||||||
|       this.localArticles = [...this.articles]; |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
|   methods: { |  | ||||||
|     onDragEnd() { |  | ||||||
|       // why reuse the same positons array, instead of creating a new one? |  | ||||||
|       // this ensures that the shuffling happens within the same group |  | ||||||
|       // itself and does not create any new positions and avoid conflict with existing articles |  | ||||||
|       // so if a user sorts on page number 2, and the positions are say [550, 560, 570, 580, 590] |  | ||||||
|       // the new sorted items will be in the same position range as well |  | ||||||
|       const sortedArticlePositions = this.localArticles |  | ||||||
|         .map(article => article.position) |  | ||||||
|         .sort((a, b) => { |  | ||||||
|           // Why sort like this? Glad you asked! |  | ||||||
|           // because JavaScript is the doom of my existence, and if a `compareFn` is not supplied, |  | ||||||
|           // all non-undefined array elements are sorted by converting them to strings |  | ||||||
|           // and comparing strings in UTF-16 code units order. |  | ||||||
|           // |  | ||||||
|           // so an array [20, 10000, 10, 30, 40] will be sorted as [10, 10000, 20, 30, 40] |  | ||||||
|  |  | ||||||
|           return a - b; |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|       const orderedArticles = this.localArticles.map(article => article.id); |  | ||||||
|  |  | ||||||
|       const reorderedGroup = orderedArticles.reduce((obj, key, index) => { |  | ||||||
|         obj[key] = sortedArticlePositions[index]; |  | ||||||
|         return obj; |  | ||||||
|       }, {}); |  | ||||||
|  |  | ||||||
|       this.$emit('reorder', reorderedGroup); |  | ||||||
|     }, |  | ||||||
|     onPageChange(page) { |  | ||||||
|       this.$emit('pageChange', page); |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
| }; |  | ||||||
| </script> |  | ||||||
|  |  | ||||||
| <template> |  | ||||||
|   <div class="flex-1"> |  | ||||||
|     <div |  | ||||||
|       class="sticky z-10 content-center hidden h-12 grid-cols-12 gap-4 px-6 py-0 bg-white border-b lg:grid border-slate-50 dark:border-slate-700 top-16 dark:bg-slate-900" |  | ||||||
|       :class="{ draggable: onCategoryPage }" |  | ||||||
|     > |  | ||||||
|       <div |  | ||||||
|         class="col-span-6 px-0 py-2 text-sm font-semibold text-left capitalize text-slate-700 dark:text-slate-100 rtl:text-right" |  | ||||||
|       > |  | ||||||
|         {{ $t('HELP_CENTER.TABLE.HEADERS.TITLE') }} |  | ||||||
|       </div> |  | ||||||
|       <div |  | ||||||
|         class="col-span-2 px-0 py-2 text-sm font-semibold text-left capitalize text-slate-700 dark:text-slate-100 rtl:text-right" |  | ||||||
|       > |  | ||||||
|         {{ $t('HELP_CENTER.TABLE.HEADERS.CATEGORY') }} |  | ||||||
|       </div> |  | ||||||
|       <div |  | ||||||
|         class="hidden px-0 py-2 text-sm font-semibold text-left capitalize text-slate-700 dark:text-slate-100 rtl:text-right lg:block" |  | ||||||
|       > |  | ||||||
|         {{ $t('HELP_CENTER.TABLE.HEADERS.READ_COUNT') }} |  | ||||||
|       </div> |  | ||||||
|       <div |  | ||||||
|         class="px-0 py-2 text-sm font-semibold text-left capitalize text-slate-700 dark:text-slate-100 rtl:text-right" |  | ||||||
|       > |  | ||||||
|         {{ $t('HELP_CENTER.TABLE.HEADERS.STATUS') }} |  | ||||||
|       </div> |  | ||||||
|       <div |  | ||||||
|         class="hidden col-span-2 px-0 py-2 text-sm font-semibold text-right capitalize text-slate-700 dark:text-slate-100 rtl:text-left md:block" |  | ||||||
|       > |  | ||||||
|         {{ $t('HELP_CENTER.TABLE.HEADERS.LAST_EDITED') }} |  | ||||||
|       </div> |  | ||||||
|     </div> |  | ||||||
|     <Draggable |  | ||||||
|       tag="div" |  | ||||||
|       class="px-4 pb-4 border-t-0" |  | ||||||
|       :disabled="!dragEnabled" |  | ||||||
|       :list="localArticles" |  | ||||||
|       ghost-class="article-ghost-class" |  | ||||||
|       item-key="id" |  | ||||||
|       @start="dragging = true" |  | ||||||
|       @end="onDragEnd" |  | ||||||
|     > |  | ||||||
|       <template #item="{ element }"> |  | ||||||
|         <ArticleItem |  | ||||||
|           :id="element.id" |  | ||||||
|           :key="element.id" |  | ||||||
|           :class="{ draggable: onCategoryPage }" |  | ||||||
|           :title="element.title" |  | ||||||
|           :author="element.author" |  | ||||||
|           :show-drag-icon="dragEnabled" |  | ||||||
|           :category="element.category" |  | ||||||
|           :views="element.views" |  | ||||||
|           :status="element.status" |  | ||||||
|           :updated-at="element.updated_at" |  | ||||||
|         /> |  | ||||||
|       </template> |  | ||||||
|     </Draggable> |  | ||||||
|  |  | ||||||
|     <TableFooter |  | ||||||
|       v-if="showArticleFooter" |  | ||||||
|       :current-page="currentPage" |  | ||||||
|       :total-count="totalCount" |  | ||||||
|       :page-size="pageSize" |  | ||||||
|       class="bottom-0 border-t dark:bg-slate-900 border-slate-75 dark:border-slate-700/50" |  | ||||||
|       @page-change="onPageChange" |  | ||||||
|     /> |  | ||||||
|   </div> |  | ||||||
| </template> |  | ||||||
|  |  | ||||||
| <style lang="scss" scoped> |  | ||||||
| /* |  | ||||||
| The .article-ghost-class class is maintained as the vueDraggable doesn't allow multiple classes |  | ||||||
| to be passed in the ghost-class prop. |  | ||||||
|  */ |  | ||||||
| .article-ghost-class { |  | ||||||
|   @apply opacity-50 bg-slate-50 dark:bg-slate-800; |  | ||||||
| } |  | ||||||
| </style> |  | ||||||
| @@ -1,237 +0,0 @@ | |||||||
| <script> |  | ||||||
| import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue'; |  | ||||||
| import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue'; |  | ||||||
| import MultiselectDropdownItems from 'shared/components/ui/MultiselectDropdownItems.vue'; |  | ||||||
|  |  | ||||||
| import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue'; |  | ||||||
| export default { |  | ||||||
|   components: { |  | ||||||
|     FluentIcon, |  | ||||||
|     WootDropdownItem, |  | ||||||
|     WootDropdownMenu, |  | ||||||
|     MultiselectDropdownItems, |  | ||||||
|   }, |  | ||||||
|   props: { |  | ||||||
|     headerTitle: { |  | ||||||
|       type: String, |  | ||||||
|       default: '', |  | ||||||
|     }, |  | ||||||
|     count: { |  | ||||||
|       type: Number, |  | ||||||
|       default: 0, |  | ||||||
|     }, |  | ||||||
|     selectedValue: { |  | ||||||
|       type: String, |  | ||||||
|       default: '', |  | ||||||
|     }, |  | ||||||
|     selectedLocale: { |  | ||||||
|       type: String, |  | ||||||
|       default: '', |  | ||||||
|     }, |  | ||||||
|     shouldShowSettings: { |  | ||||||
|       type: Boolean, |  | ||||||
|       default: false, |  | ||||||
|     }, |  | ||||||
|     allLocales: { |  | ||||||
|       type: Array, |  | ||||||
|       default: () => [], |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
|   emits: ['openModal', 'open', 'close', 'newArticlePage', 'changeLocale'], |  | ||||||
|   data() { |  | ||||||
|     return { |  | ||||||
|       showSortByDropdown: false, |  | ||||||
|       showLocaleDropdown: false, |  | ||||||
|     }; |  | ||||||
|   }, |  | ||||||
|   computed: { |  | ||||||
|     shouldShowLocaleDropdown() { |  | ||||||
|       return this.allLocales.length > 1; |  | ||||||
|     }, |  | ||||||
|     switchableLocales() { |  | ||||||
|       return this.allLocales.filter( |  | ||||||
|         locale => locale.name !== this.selectedLocale |  | ||||||
|       ); |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
|   methods: { |  | ||||||
|     openFilterModal() { |  | ||||||
|       this.$emit('openModal'); |  | ||||||
|     }, |  | ||||||
|     openDropdown() { |  | ||||||
|       this.$emit('open'); |  | ||||||
|       this.showSortByDropdown = true; |  | ||||||
|     }, |  | ||||||
|     closeDropdown() { |  | ||||||
|       this.$emit('close'); |  | ||||||
|       this.showSortByDropdown = false; |  | ||||||
|     }, |  | ||||||
|     openLocaleDropdown() { |  | ||||||
|       this.showLocaleDropdown = true; |  | ||||||
|     }, |  | ||||||
|     closeLocaleDropdown() { |  | ||||||
|       this.showLocaleDropdown = false; |  | ||||||
|     }, |  | ||||||
|     onClickNewArticlePage() { |  | ||||||
|       this.$emit('newArticlePage'); |  | ||||||
|     }, |  | ||||||
|     onClickSelectItem(value) { |  | ||||||
|       const { name, code } = value; |  | ||||||
|       this.closeLocaleDropdown(); |  | ||||||
|       if (!name || name === this.selectedLocale) { |  | ||||||
|         return; |  | ||||||
|       } |  | ||||||
|       this.$emit('changeLocale', code); |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
| }; |  | ||||||
| </script> |  | ||||||
|  |  | ||||||
| <template> |  | ||||||
|   <div |  | ||||||
|     class="sticky top-0 z-50 flex items-center justify-between w-full h-16 p-6 bg-white dark:bg-slate-900" |  | ||||||
|   > |  | ||||||
|     <div class="flex items-center"> |  | ||||||
|       <woot-sidemenu-icon /> |  | ||||||
|       <div class="flex items-center mx-2 my-0"> |  | ||||||
|         <h3 class="mb-0 text-xl font-medium text-slate-800 dark:text-slate-100"> |  | ||||||
|           {{ headerTitle }} |  | ||||||
|         </h3> |  | ||||||
|         <span class="text-sm text-slate-600 dark:text-slate-300 mx-2 mt-0.5">{{ |  | ||||||
|           `(${count})` |  | ||||||
|         }}</span> |  | ||||||
|       </div> |  | ||||||
|     </div> |  | ||||||
|     <div class="flex items-center gap-1"> |  | ||||||
|       <woot-button |  | ||||||
|         v-if="shouldShowSettings" |  | ||||||
|         icon="filter" |  | ||||||
|         color-scheme="secondary" |  | ||||||
|         variant="hollow" |  | ||||||
|         size="small" |  | ||||||
|         @click="openFilterModal" |  | ||||||
|       > |  | ||||||
|         {{ $t('HELP_CENTER.HEADER.FILTER') }} |  | ||||||
|       </woot-button> |  | ||||||
|       <woot-button |  | ||||||
|         v-if="shouldShowSettings" |  | ||||||
|         icon="arrow-sort" |  | ||||||
|         color-scheme="secondary" |  | ||||||
|         size="small" |  | ||||||
|         variant="hollow" |  | ||||||
|         @click="openDropdown" |  | ||||||
|       > |  | ||||||
|         {{ $t('HELP_CENTER.HEADER.SORT') }} |  | ||||||
|         <span |  | ||||||
|           class="inline-flex items-center ml-1 rtl:ml-0 rtl:mr-1 text-slate-800 dark:text-slate-100" |  | ||||||
|         > |  | ||||||
|           {{ selectedValue }} |  | ||||||
|           <FluentIcon class="dropdown-arrow" icon="chevron-down" size="14" /> |  | ||||||
|         </span> |  | ||||||
|       </woot-button> |  | ||||||
|       <div |  | ||||||
|         v-if="showSortByDropdown" |  | ||||||
|         v-on-clickaway="closeDropdown" |  | ||||||
|         class="dropdown-pane dropdown-pane--open" |  | ||||||
|       > |  | ||||||
|         <WootDropdownMenu> |  | ||||||
|           <WootDropdownItem> |  | ||||||
|             <woot-button |  | ||||||
|               variant="clear" |  | ||||||
|               color-scheme="secondary" |  | ||||||
|               size="small" |  | ||||||
|               icon="send-clock" |  | ||||||
|             > |  | ||||||
|               {{ $t('HELP_CENTER.HEADER.DROPDOWN_OPTIONS.PUBLISHED') }} |  | ||||||
|             </woot-button> |  | ||||||
|           </WootDropdownItem> |  | ||||||
|           <WootDropdownItem> |  | ||||||
|             <woot-button |  | ||||||
|               variant="clear" |  | ||||||
|               color-scheme="secondary" |  | ||||||
|               size="small" |  | ||||||
|               icon="dual-screen-clock" |  | ||||||
|             > |  | ||||||
|               {{ $t('HELP_CENTER.HEADER.DROPDOWN_OPTIONS.DRAFT') }} |  | ||||||
|             </woot-button> |  | ||||||
|           </WootDropdownItem> |  | ||||||
|           <WootDropdownItem> |  | ||||||
|             <woot-button |  | ||||||
|               variant="clear" |  | ||||||
|               color-scheme="secondary" |  | ||||||
|               size="small" |  | ||||||
|               icon="calendar-clock" |  | ||||||
|             > |  | ||||||
|               {{ $t('HELP_CENTER.HEADER.DROPDOWN_OPTIONS.ARCHIVED') }} |  | ||||||
|             </woot-button> |  | ||||||
|           </WootDropdownItem> |  | ||||||
|         </WootDropdownMenu> |  | ||||||
|       </div> |  | ||||||
|       <woot-button |  | ||||||
|         v-if="shouldShowSettings" |  | ||||||
|         v-tooltip.top-end="$t('HELP_CENTER.HEADER.SETTINGS_BUTTON')" |  | ||||||
|         icon="settings" |  | ||||||
|         variant="hollow" |  | ||||||
|         size="small" |  | ||||||
|         color-scheme="secondary" |  | ||||||
|       /> |  | ||||||
|       <div class="relative"> |  | ||||||
|         <woot-button |  | ||||||
|           v-if="shouldShowLocaleDropdown" |  | ||||||
|           icon="globe" |  | ||||||
|           color-scheme="secondary" |  | ||||||
|           size="small" |  | ||||||
|           variant="hollow" |  | ||||||
|           @click="openLocaleDropdown" |  | ||||||
|         > |  | ||||||
|           <div class="flex items-center justify-between w-full min-w-0"> |  | ||||||
|             <span |  | ||||||
|               class="inline-flex items-center ml-1 rtl:ml-0 rtl:mr-1 text-slate-800 dark:text-slate-100" |  | ||||||
|             > |  | ||||||
|               {{ selectedLocale }} |  | ||||||
|               <FluentIcon |  | ||||||
|                 class="dropdown-arrow" |  | ||||||
|                 icon="chevron-down" |  | ||||||
|                 size="14" |  | ||||||
|               /> |  | ||||||
|             </span> |  | ||||||
|           </div> |  | ||||||
|         </woot-button> |  | ||||||
|         <div |  | ||||||
|           v-if="showLocaleDropdown" |  | ||||||
|           v-on-clickaway="closeLocaleDropdown" |  | ||||||
|           class="dropdown-pane dropdown-pane--open" |  | ||||||
|         > |  | ||||||
|           <MultiselectDropdownItems |  | ||||||
|             :options="switchableLocales" |  | ||||||
|             :has-thumbnail="false" |  | ||||||
|             :selected-items="[selectedLocale]" |  | ||||||
|             :input-placeholder=" |  | ||||||
|               $t('HELP_CENTER.HEADER.LOCALE_SELECT.SEARCH_PLACEHOLDER') |  | ||||||
|             " |  | ||||||
|             :no-search-result="$t('HELP_CENTER.HEADER.LOCALE_SELECT.NO_RESULT')" |  | ||||||
|             @select="onClickSelectItem" |  | ||||||
|           /> |  | ||||||
|         </div> |  | ||||||
|       </div> |  | ||||||
|       <woot-button |  | ||||||
|         size="small" |  | ||||||
|         icon="add" |  | ||||||
|         color-scheme="primary" |  | ||||||
|         @click="onClickNewArticlePage" |  | ||||||
|       > |  | ||||||
|         {{ $t('HELP_CENTER.HEADER.NEW_BUTTON') }} |  | ||||||
|       </woot-button> |  | ||||||
|     </div> |  | ||||||
|   </div> |  | ||||||
| </template> |  | ||||||
|  |  | ||||||
| <style scoped lang="scss"> |  | ||||||
| .dropdown-pane--open { |  | ||||||
|   @apply absolute top-10 right-0 z-50 min-w-[8rem]; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .dropdown-arrow { |  | ||||||
|   @apply ml-1 rtl:ml-0 rtl:mr-1; |  | ||||||
| } |  | ||||||
| </style> |  | ||||||
| @@ -1,252 +0,0 @@ | |||||||
| <script> |  | ||||||
| import { useAlert } from 'dashboard/composables'; |  | ||||||
| import { useTrack } from 'dashboard/composables'; |  | ||||||
| import wootConstants from 'dashboard/constants/globals'; |  | ||||||
| import { PORTALS_EVENTS } from '../../../../../helper/AnalyticsHelper/events'; |  | ||||||
|  |  | ||||||
| const { ARTICLE_STATUS_TYPES } = wootConstants; |  | ||||||
|  |  | ||||||
| export default { |  | ||||||
|   props: { |  | ||||||
|     isSidebarOpen: { |  | ||||||
|       type: Boolean, |  | ||||||
|       default: true, |  | ||||||
|     }, |  | ||||||
|     backButtonLabel: { |  | ||||||
|       type: String, |  | ||||||
|       default: '', |  | ||||||
|     }, |  | ||||||
|     isUpdating: { |  | ||||||
|       type: Boolean, |  | ||||||
|       default: false, |  | ||||||
|     }, |  | ||||||
|     isSaved: { |  | ||||||
|       type: Boolean, |  | ||||||
|       default: false, |  | ||||||
|     }, |  | ||||||
|     enableOpenSidebarButton: { |  | ||||||
|       type: Boolean, |  | ||||||
|       default: false, |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
|   emits: ['back', 'show', 'add', 'updateMeta', 'open', 'close'], |  | ||||||
|   data() { |  | ||||||
|     return { |  | ||||||
|       showActionsDropdown: false, |  | ||||||
|       alertMessage: '', |  | ||||||
|       ARTICLE_STATUS_TYPES, |  | ||||||
|     }; |  | ||||||
|   }, |  | ||||||
|   computed: { |  | ||||||
|     statusText() { |  | ||||||
|       return this.isUpdating |  | ||||||
|         ? this.$t('HELP_CENTER.EDIT_HEADER.SAVING') |  | ||||||
|         : this.$t('HELP_CENTER.EDIT_HEADER.SAVED'); |  | ||||||
|     }, |  | ||||||
|     articleSlug() { |  | ||||||
|       return this.$route.params.articleSlug; |  | ||||||
|     }, |  | ||||||
|     currentPortalSlug() { |  | ||||||
|       return this.$route.params.portalSlug; |  | ||||||
|     }, |  | ||||||
|     currentArticleStatus() { |  | ||||||
|       return this.$store.getters['articles/articleStatus'](this.articleSlug); |  | ||||||
|     }, |  | ||||||
|     isPublishedArticle() { |  | ||||||
|       return this.currentArticleStatus === 'published'; |  | ||||||
|     }, |  | ||||||
|     isArchivedArticle() { |  | ||||||
|       return this.currentArticleStatus === 'archived'; |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
|   methods: { |  | ||||||
|     onClickGoBack() { |  | ||||||
|       this.$emit('back'); |  | ||||||
|     }, |  | ||||||
|     showPreview() { |  | ||||||
|       this.$emit('show'); |  | ||||||
|     }, |  | ||||||
|     onClickAdd() { |  | ||||||
|       this.$emit('add'); |  | ||||||
|     }, |  | ||||||
|     async updateArticleStatus(status) { |  | ||||||
|       try { |  | ||||||
|         await this.$store.dispatch('articles/update', { |  | ||||||
|           portalSlug: this.currentPortalSlug, |  | ||||||
|           articleId: this.articleSlug, |  | ||||||
|           status: status, |  | ||||||
|         }); |  | ||||||
|         this.$emit('updateMeta'); |  | ||||||
|         this.statusUpdateSuccessMessage(status); |  | ||||||
|         this.closeActionsDropdown(); |  | ||||||
|         if (status === this.ARTICLE_STATUS_TYPES.ARCHIVE) { |  | ||||||
|           useTrack(PORTALS_EVENTS.ARCHIVE_ARTICLE, { uiFrom: 'header' }); |  | ||||||
|         } else if (status === this.ARTICLE_STATUS_TYPES.PUBLISH) { |  | ||||||
|           useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE); |  | ||||||
|         } |  | ||||||
|       } catch (error) { |  | ||||||
|         this.alertMessage = |  | ||||||
|           error?.message || this.statusUpdateErrorMessage(status); |  | ||||||
|       } finally { |  | ||||||
|         useAlert(this.alertMessage); |  | ||||||
|       } |  | ||||||
|     }, |  | ||||||
|     statusUpdateSuccessMessage(status) { |  | ||||||
|       if (status === this.ARTICLE_STATUS_TYPES.PUBLISH) { |  | ||||||
|         this.alertMessage = this.$t('HELP_CENTER.PUBLISH_ARTICLE.API.SUCCESS'); |  | ||||||
|       } else if (status === this.ARTICLE_STATUS_TYPES.ARCHIVE) { |  | ||||||
|         this.alertMessage = this.$t('HELP_CENTER.ARCHIVE_ARTICLE.API.SUCCESS'); |  | ||||||
|       } |  | ||||||
|     }, |  | ||||||
|     statusUpdateErrorMessage(status) { |  | ||||||
|       if (status === this.ARTICLE_STATUS_TYPES.PUBLISH) { |  | ||||||
|         this.alertMessage = this.$t('HELP_CENTER.PUBLISH_ARTICLE.API.ERROR'); |  | ||||||
|       } else if (status === this.ARTICLE_STATUS_TYPES.ARCHIVE) { |  | ||||||
|         this.alertMessage = this.$t('HELP_CENTER.ARCHIVE_ARTICLE.API.ERROR'); |  | ||||||
|       } |  | ||||||
|     }, |  | ||||||
|     openSidebar() { |  | ||||||
|       this.$emit('open'); |  | ||||||
|     }, |  | ||||||
|     closeSidebar() { |  | ||||||
|       this.$emit('close'); |  | ||||||
|     }, |  | ||||||
|     openActionsDropdown() { |  | ||||||
|       this.showActionsDropdown = !this.showActionsDropdown; |  | ||||||
|     }, |  | ||||||
|     closeActionsDropdown() { |  | ||||||
|       this.showActionsDropdown = false; |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
| }; |  | ||||||
| </script> |  | ||||||
|  |  | ||||||
| <template> |  | ||||||
|   <div class="flex items-center justify-between w-full h-16"> |  | ||||||
|     <div class="flex items-center"> |  | ||||||
|       <woot-button |  | ||||||
|         icon="chevron-left" |  | ||||||
|         variant="clear" |  | ||||||
|         size="small" |  | ||||||
|         color-scheme="primary" |  | ||||||
|         class="back-button" |  | ||||||
|         @click="onClickGoBack" |  | ||||||
|       > |  | ||||||
|         {{ backButtonLabel }} |  | ||||||
|       </woot-button> |  | ||||||
|     </div> |  | ||||||
|     <div class="flex items-center gap-1"> |  | ||||||
|       <span |  | ||||||
|         v-if="isUpdating || isSaved" |  | ||||||
|         class="items-center ml-4 mr-1 text-xs draft-status rtl:ml-2 rtl:mr-4 text-slate-400 dark:text-slate-300" |  | ||||||
|       > |  | ||||||
|         {{ statusText }} |  | ||||||
|       </span> |  | ||||||
|  |  | ||||||
|       <woot-button |  | ||||||
|         class-names="article--buttons relative" |  | ||||||
|         icon="globe" |  | ||||||
|         color-scheme="secondary" |  | ||||||
|         variant="hollow" |  | ||||||
|         size="small" |  | ||||||
|         @click="showPreview" |  | ||||||
|       > |  | ||||||
|         {{ $t('HELP_CENTER.EDIT_HEADER.PREVIEW') }} |  | ||||||
|       </woot-button> |  | ||||||
|       <!-- Hidden since this is in V2 |  | ||||||
|       <woot-button |  | ||||||
|         v-if="shouldShowAddLocaleButton" |  | ||||||
|         class-names="article--buttons relative" |  | ||||||
|         icon="add" |  | ||||||
|         color-scheme="secondary" |  | ||||||
|         variant="hollow" |  | ||||||
|         size="small" |  | ||||||
|         @click="onClickAdd" |  | ||||||
|       > |  | ||||||
|         {{ $t('HELP_CENTER.EDIT_HEADER.ADD_TRANSLATION') }} |  | ||||||
|       </woot-button> --> |  | ||||||
|       <woot-button |  | ||||||
|         v-if="!isSidebarOpen" |  | ||||||
|         v-tooltip.top-end="$t('HELP_CENTER.EDIT_HEADER.OPEN_SIDEBAR')" |  | ||||||
|         icon="pane-open" |  | ||||||
|         class-names="article--buttons relative sidebar-button" |  | ||||||
|         variant="hollow" |  | ||||||
|         size="small" |  | ||||||
|         color-scheme="secondary" |  | ||||||
|         :is-disabled="enableOpenSidebarButton" |  | ||||||
|         @click="openSidebar" |  | ||||||
|       /> |  | ||||||
|       <woot-button |  | ||||||
|         v-if="isSidebarOpen" |  | ||||||
|         v-tooltip.top-end="$t('HELP_CENTER.EDIT_HEADER.CLOSE_SIDEBAR')" |  | ||||||
|         icon="pane-close" |  | ||||||
|         class-names="article--buttons relative" |  | ||||||
|         variant="hollow" |  | ||||||
|         size="small" |  | ||||||
|         color-scheme="secondary" |  | ||||||
|         @click="closeSidebar" |  | ||||||
|       /> |  | ||||||
|       <div class="relative article--buttons"> |  | ||||||
|         <div class="button-group"> |  | ||||||
|           <woot-button |  | ||||||
|             class-names="publish-button" |  | ||||||
|             size="small" |  | ||||||
|             icon="checkmark" |  | ||||||
|             color-scheme="primary" |  | ||||||
|             :is-disabled="!articleSlug || isPublishedArticle" |  | ||||||
|             @click="updateArticleStatus(ARTICLE_STATUS_TYPES.PUBLISH)" |  | ||||||
|           > |  | ||||||
|             {{ $t('HELP_CENTER.EDIT_HEADER.PUBLISH_BUTTON') }} |  | ||||||
|           </woot-button> |  | ||||||
|           <woot-button |  | ||||||
|             size="small" |  | ||||||
|             icon="chevron-down" |  | ||||||
|             :is-disabled="!articleSlug || isArchivedArticle" |  | ||||||
|             @click="openActionsDropdown" |  | ||||||
|           /> |  | ||||||
|         </div> |  | ||||||
|         <div |  | ||||||
|           v-if="showActionsDropdown" |  | ||||||
|           v-on-clickaway="closeActionsDropdown" |  | ||||||
|           class="dropdown-pane dropdown-pane--open" |  | ||||||
|         > |  | ||||||
|           <woot-dropdown-menu> |  | ||||||
|             <woot-dropdown-item> |  | ||||||
|               <woot-button |  | ||||||
|                 variant="clear" |  | ||||||
|                 color-scheme="secondary" |  | ||||||
|                 size="small" |  | ||||||
|                 icon="book-clock" |  | ||||||
|                 @click="updateArticleStatus(ARTICLE_STATUS_TYPES.ARCHIVE)" |  | ||||||
|               > |  | ||||||
|                 {{ $t('HELP_CENTER.EDIT_HEADER.MOVE_TO_ARCHIVE_BUTTON') }} |  | ||||||
|               </woot-button> |  | ||||||
|             </woot-dropdown-item> |  | ||||||
|           </woot-dropdown-menu> |  | ||||||
|         </div> |  | ||||||
|       </div> |  | ||||||
|     </div> |  | ||||||
|   </div> |  | ||||||
| </template> |  | ||||||
|  |  | ||||||
| <style scoped lang="scss"> |  | ||||||
| .article--buttons { |  | ||||||
|   .dropdown-pane { |  | ||||||
|     @apply absolute right-0; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .draft-status { |  | ||||||
|   animation: fadeIn 1s; |  | ||||||
|  |  | ||||||
|   @keyframes fadeIn { |  | ||||||
|     0% { |  | ||||||
|       opacity: 0; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     100% { |  | ||||||
|       opacity: 1; |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| </style> |  | ||||||
| @@ -1,364 +0,0 @@ | |||||||
| <script> |  | ||||||
| import { defineAsyncComponent } from 'vue'; |  | ||||||
| import { mapGetters } from 'vuex'; |  | ||||||
| import UpgradePage from './UpgradePage.vue'; |  | ||||||
| import NextSidebar from 'next/sidebar/Sidebar.vue'; |  | ||||||
| import { frontendURL } from '../../../../helper/URLHelper'; |  | ||||||
| import Sidebar from 'dashboard/components/layout/Sidebar.vue'; |  | ||||||
| import { BUS_EVENTS } from 'shared/constants/busEvents'; |  | ||||||
| import PortalPopover from '../components/PortalPopover.vue'; |  | ||||||
| import HelpCenterSidebar from '../components/Sidebar/Sidebar.vue'; |  | ||||||
| import WootKeyShortcutModal from 'dashboard/components/widgets/modal/WootKeyShortcutModal.vue'; |  | ||||||
| import AccountSelector from 'dashboard/components/layout/sidebarComponents/AccountSelector.vue'; |  | ||||||
| import NotificationPanel from 'dashboard/routes/dashboard/notifications/components/NotificationPanel.vue'; |  | ||||||
| import { useUISettings } from 'dashboard/composables/useUISettings'; |  | ||||||
| import portalMixin from '../mixins/portalMixin'; |  | ||||||
| import AddCategory from '../pages/categories/AddCategory.vue'; |  | ||||||
| import { FEATURE_FLAGS } from 'dashboard/featureFlags'; |  | ||||||
| import { emitter } from 'shared/helpers/mitt'; |  | ||||||
|  |  | ||||||
| const CommandBar = defineAsyncComponent( |  | ||||||
|   () => import('dashboard/routes/dashboard/commands/commandbar.vue') |  | ||||||
| ); |  | ||||||
|  |  | ||||||
| export default { |  | ||||||
|   components: { |  | ||||||
|     NextSidebar, |  | ||||||
|     AccountSelector, |  | ||||||
|     AddCategory, |  | ||||||
|     CommandBar, |  | ||||||
|     HelpCenterSidebar, |  | ||||||
|     NotificationPanel, |  | ||||||
|     PortalPopover, |  | ||||||
|     Sidebar, |  | ||||||
|     UpgradePage, |  | ||||||
|     WootKeyShortcutModal, |  | ||||||
|   }, |  | ||||||
|   mixins: [portalMixin], |  | ||||||
|   setup() { |  | ||||||
|     const { uiSettings, updateUISettings } = useUISettings(); |  | ||||||
|  |  | ||||||
|     return { |  | ||||||
|       uiSettings, |  | ||||||
|       updateUISettings, |  | ||||||
|     }; |  | ||||||
|   }, |  | ||||||
|   data() { |  | ||||||
|     return { |  | ||||||
|       isOnDesktop: true, |  | ||||||
|       showShortcutModal: false, |  | ||||||
|       showNotificationPanel: false, |  | ||||||
|       showPortalPopover: false, |  | ||||||
|       showAddCategoryModal: false, |  | ||||||
|       lastActivePortalSlug: '', |  | ||||||
|       showAccountModal: false, |  | ||||||
|     }; |  | ||||||
|   }, |  | ||||||
|  |  | ||||||
|   computed: { |  | ||||||
|     ...mapGetters({ |  | ||||||
|       accountId: 'getCurrentAccountId', |  | ||||||
|       portals: 'portals/allPortals', |  | ||||||
|       categories: 'categories/allCategories', |  | ||||||
|       meta: 'portals/getMeta', |  | ||||||
|       isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount', |  | ||||||
|     }), |  | ||||||
|  |  | ||||||
|     isHelpCenterEnabled() { |  | ||||||
|       return this.isFeatureEnabledonAccount( |  | ||||||
|         this.accountId, |  | ||||||
|         FEATURE_FLAGS.HELP_CENTER |  | ||||||
|       ); |  | ||||||
|     }, |  | ||||||
|     showNextSidebar() { |  | ||||||
|       return this.isFeatureEnabledonAccount( |  | ||||||
|         this.accountId, |  | ||||||
|         FEATURE_FLAGS.CHATWOOT_V4 |  | ||||||
|       ); |  | ||||||
|     }, |  | ||||||
|     isSidebarOpen() { |  | ||||||
|       const { show_help_center_secondary_sidebar: showSecondarySidebar } = |  | ||||||
|         this.uiSettings; |  | ||||||
|       return showSecondarySidebar; |  | ||||||
|     }, |  | ||||||
|     showHelpCenterSidebar() { |  | ||||||
|       if (!this.isHelpCenterEnabled) { |  | ||||||
|         return false; |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       return this.portals.length === 0 ? false : this.isSidebarOpen; |  | ||||||
|     }, |  | ||||||
|     selectedPortal() { |  | ||||||
|       const slug = this.$route.params.portalSlug || this.lastActivePortalSlug; |  | ||||||
|       if (slug) return this.$store.getters['portals/portalBySlug'](slug); |  | ||||||
|  |  | ||||||
|       return this.$store.getters['portals/allPortals'][0]; |  | ||||||
|     }, |  | ||||||
|     selectedLocaleInPortal() { |  | ||||||
|       return this.$route.params.locale || this.defaultPortalLocale; |  | ||||||
|     }, |  | ||||||
|     selectedPortalName() { |  | ||||||
|       return this.selectedPortal ? this.selectedPortal.name : ''; |  | ||||||
|     }, |  | ||||||
|     selectedPortalSlug() { |  | ||||||
|       return this.selectedPortal ? this.selectedPortal?.slug : ''; |  | ||||||
|     }, |  | ||||||
|     defaultPortalLocale() { |  | ||||||
|       return this.selectedPortal |  | ||||||
|         ? this.selectedPortal?.meta?.default_locale |  | ||||||
|         : ''; |  | ||||||
|     }, |  | ||||||
|     accessibleMenuItems() { |  | ||||||
|       if (!this.selectedPortal) return []; |  | ||||||
|  |  | ||||||
|       const { |  | ||||||
|         allArticlesCount, |  | ||||||
|         mineArticlesCount, |  | ||||||
|         draftArticlesCount, |  | ||||||
|         archivedArticlesCount, |  | ||||||
|       } = this.meta; |  | ||||||
|  |  | ||||||
|       return [ |  | ||||||
|         { |  | ||||||
|           icon: 'book', |  | ||||||
|           label: 'HELP_CENTER.ALL_ARTICLES', |  | ||||||
|           key: 'list_all_locale_articles', |  | ||||||
|           count: allArticlesCount, |  | ||||||
|           toState: frontendURL( |  | ||||||
|             `accounts/${this.accountId}/portals/${this.selectedPortalSlug}/${this.selectedLocaleInPortal}/articles` |  | ||||||
|           ), |  | ||||||
|           toolTip: 'All Articles', |  | ||||||
|           toStateName: 'list_all_locale_articles', |  | ||||||
|         }, |  | ||||||
|         { |  | ||||||
|           icon: 'pen', |  | ||||||
|           label: 'HELP_CENTER.MY_ARTICLES', |  | ||||||
|           key: 'list_mine_articles', |  | ||||||
|           count: mineArticlesCount, |  | ||||||
|           toState: frontendURL( |  | ||||||
|             `accounts/${this.accountId}/portals/${this.selectedPortalSlug}/${this.selectedLocaleInPortal}/articles/mine` |  | ||||||
|           ), |  | ||||||
|           toolTip: 'My articles', |  | ||||||
|           toStateName: 'list_mine_articles', |  | ||||||
|         }, |  | ||||||
|         { |  | ||||||
|           icon: 'draft', |  | ||||||
|           label: 'HELP_CENTER.DRAFT', |  | ||||||
|           key: 'list_draft_articles', |  | ||||||
|           count: draftArticlesCount, |  | ||||||
|           toState: frontendURL( |  | ||||||
|             `accounts/${this.accountId}/portals/${this.selectedPortalSlug}/${this.selectedLocaleInPortal}/articles/draft` |  | ||||||
|           ), |  | ||||||
|           toolTip: 'Draft', |  | ||||||
|           toStateName: 'list_draft_articles', |  | ||||||
|         }, |  | ||||||
|         { |  | ||||||
|           icon: 'archive', |  | ||||||
|           label: 'HELP_CENTER.ARCHIVED', |  | ||||||
|           key: 'list_archived_articles', |  | ||||||
|           count: archivedArticlesCount, |  | ||||||
|           toState: frontendURL( |  | ||||||
|             `accounts/${this.accountId}/portals/${this.selectedPortalSlug}/${this.selectedLocaleInPortal}/articles/archived` |  | ||||||
|           ), |  | ||||||
|           toolTip: 'Archived', |  | ||||||
|           toStateName: 'list_archived_articles', |  | ||||||
|         }, |  | ||||||
|         { |  | ||||||
|           icon: 'settings', |  | ||||||
|           label: 'HELP_CENTER.SETTINGS', |  | ||||||
|           key: 'edit_portal_information', |  | ||||||
|           toState: frontendURL( |  | ||||||
|             `accounts/${this.accountId}/portals/${this.selectedPortalSlug}/edit` |  | ||||||
|           ), |  | ||||||
|           toStateName: 'edit_portal_information', |  | ||||||
|         }, |  | ||||||
|       ]; |  | ||||||
|     }, |  | ||||||
|     additionalSecondaryMenuItems() { |  | ||||||
|       if (!this.selectedPortal) return []; |  | ||||||
|       return [ |  | ||||||
|         { |  | ||||||
|           icon: 'folder', |  | ||||||
|           label: 'HELP_CENTER.CATEGORY', |  | ||||||
|           hasSubMenu: true, |  | ||||||
|           showNewButton: true, |  | ||||||
|           key: 'category', |  | ||||||
|           children: this.categories.map(category => ({ |  | ||||||
|             id: category.id, |  | ||||||
|             label: category.icon |  | ||||||
|               ? `${category.icon} ${category.name}` |  | ||||||
|               : category.name, |  | ||||||
|             count: category.meta.articles_count, |  | ||||||
|             truncateLabel: true, |  | ||||||
|             toState: frontendURL( |  | ||||||
|               `accounts/${this.accountId}/portals/${this.selectedPortalSlug}/${category.locale}/categories/${category.slug}` |  | ||||||
|             ), |  | ||||||
|           })), |  | ||||||
|         }, |  | ||||||
|       ]; |  | ||||||
|     }, |  | ||||||
|     currentRoute() { |  | ||||||
|       return '  '; |  | ||||||
|     }, |  | ||||||
|     headerTitle() { |  | ||||||
|       return this.selectedPortal ? this.selectedPortal.name : ''; |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
|  |  | ||||||
|   watch: { |  | ||||||
|     '$route.name'() { |  | ||||||
|       const routeName = this.$route?.name; |  | ||||||
|       const routeParams = this.$route?.params; |  | ||||||
|       const updateMetaInAllPortals = routeName === 'list_all_portals'; |  | ||||||
|       const updateMetaInEditArticle = |  | ||||||
|         routeName === 'edit_article' && routeParams?.recentlyCreated; |  | ||||||
|       const updateMetaInLocaleArticles = |  | ||||||
|         routeName === 'list_all_locale_articles' && |  | ||||||
|         routeParams?.recentlyDeleted; |  | ||||||
|       if ( |  | ||||||
|         updateMetaInAllPortals || |  | ||||||
|         updateMetaInEditArticle || |  | ||||||
|         updateMetaInLocaleArticles |  | ||||||
|       ) { |  | ||||||
|         this.fetchPortalAndItsCategories(); |  | ||||||
|       } |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
|  |  | ||||||
|   mounted() { |  | ||||||
|     emitter.on(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar); |  | ||||||
|  |  | ||||||
|     const slug = this.$route.params.portalSlug; |  | ||||||
|     if (slug) this.lastActivePortalSlug = slug; |  | ||||||
|  |  | ||||||
|     this.fetchPortalAndItsCategories(); |  | ||||||
|   }, |  | ||||||
|   unmounted() { |  | ||||||
|     emitter.off(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar); |  | ||||||
|   }, |  | ||||||
|   updated() { |  | ||||||
|     const slug = this.$route.params.portalSlug; |  | ||||||
|     if (slug !== this.lastActivePortalSlug) { |  | ||||||
|       this.lastActivePortalSlug = slug; |  | ||||||
|       this.updateUISettings({ |  | ||||||
|         last_active_portal_slug: slug, |  | ||||||
|         last_active_locale_code: this.selectedLocaleInPortal, |  | ||||||
|       }); |  | ||||||
|     } |  | ||||||
|   }, |  | ||||||
|   methods: { |  | ||||||
|     toggleSidebar() { |  | ||||||
|       if (this.portals.length > 0) { |  | ||||||
|         this.updateUISettings({ |  | ||||||
|           show_help_center_secondary_sidebar: !this.isSidebarOpen, |  | ||||||
|         }); |  | ||||||
|       } |  | ||||||
|     }, |  | ||||||
|     async fetchPortalAndItsCategories() { |  | ||||||
|       await this.$store.dispatch('portals/index'); |  | ||||||
|       const selectedPortalParam = { |  | ||||||
|         portalSlug: this.selectedPortalSlug, |  | ||||||
|         locale: this.selectedLocaleInPortal, |  | ||||||
|       }; |  | ||||||
|       this.$store.dispatch('portals/show', selectedPortalParam); |  | ||||||
|       this.$store.dispatch('categories/index', selectedPortalParam); |  | ||||||
|       this.$store.dispatch('agents/get'); |  | ||||||
|     }, |  | ||||||
|     toggleKeyShortcutModal() { |  | ||||||
|       this.showShortcutModal = true; |  | ||||||
|     }, |  | ||||||
|     closeKeyShortcutModal() { |  | ||||||
|       this.showShortcutModal = false; |  | ||||||
|     }, |  | ||||||
|     openNotificationPanel() { |  | ||||||
|       this.showNotificationPanel = true; |  | ||||||
|     }, |  | ||||||
|     closeNotificationPanel() { |  | ||||||
|       this.showNotificationPanel = false; |  | ||||||
|     }, |  | ||||||
|     openPortalPopover() { |  | ||||||
|       this.showPortalPopover = !this.showPortalPopover; |  | ||||||
|     }, |  | ||||||
|     closePortalPopover() { |  | ||||||
|       this.showPortalPopover = false; |  | ||||||
|     }, |  | ||||||
|     onClickOpenAddCategoryModal() { |  | ||||||
|       this.showAddCategoryModal = true; |  | ||||||
|     }, |  | ||||||
|     onClickCloseAddCategoryModal() { |  | ||||||
|       this.showAddCategoryModal = false; |  | ||||||
|     }, |  | ||||||
|     toggleAccountModal() { |  | ||||||
|       this.showAccountModal = !this.showAccountModal; |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
| }; |  | ||||||
| </script> |  | ||||||
|  |  | ||||||
| <template> |  | ||||||
|   <div class="flex flex-grow-0 w-full h-full min-h-0 app-wrapper"> |  | ||||||
|     <NextSidebar |  | ||||||
|       v-if="showNextSidebar" |  | ||||||
|       @toggle-account-modal="toggleAccountModal" |  | ||||||
|       @open-notification-panel="openNotificationPanel" |  | ||||||
|       @open-key-shortcut-modal="toggleKeyShortcutModal" |  | ||||||
|       @close-key-shortcut-modal="closeKeyShortcutModal" |  | ||||||
|     /> |  | ||||||
|     <Sidebar |  | ||||||
|       v-else |  | ||||||
|       :route="currentRoute" |  | ||||||
|       @toggle-account-modal="toggleAccountModal" |  | ||||||
|       @open-notification-panel="openNotificationPanel" |  | ||||||
|       @open-key-shortcut-modal="toggleKeyShortcutModal" |  | ||||||
|       @close-key-shortcut-modal="closeKeyShortcutModal" |  | ||||||
|     /> |  | ||||||
|     <HelpCenterSidebar |  | ||||||
|       v-if="showHelpCenterSidebar" |  | ||||||
|       :header-title="headerTitle" |  | ||||||
|       :portal-slug="selectedPortalSlug" |  | ||||||
|       :locale-slug="selectedLocaleInPortal" |  | ||||||
|       :sub-title="localeName(selectedLocaleInPortal)" |  | ||||||
|       :accessible-menu-items="accessibleMenuItems" |  | ||||||
|       :additional-secondary-menu-items="additionalSecondaryMenuItems" |  | ||||||
|       @open-popover="openPortalPopover" |  | ||||||
|       @open-modal="onClickOpenAddCategoryModal" |  | ||||||
|     /> |  | ||||||
|     <section |  | ||||||
|       v-if="isHelpCenterEnabled" |  | ||||||
|       class="flex flex-1 h-full px-0 overflow-hidden bg-white dark:bg-slate-900" |  | ||||||
|     > |  | ||||||
|       <router-view @reload-locale="fetchPortalAndItsCategories" /> |  | ||||||
|       <CommandBar /> |  | ||||||
|       <AccountSelector |  | ||||||
|         :show-account-modal="showAccountModal" |  | ||||||
|         @close-account-modal="toggleAccountModal" |  | ||||||
|       /> |  | ||||||
|       <WootKeyShortcutModal |  | ||||||
|         v-if="showShortcutModal" |  | ||||||
|         @close="closeKeyShortcutModal" |  | ||||||
|         @clickaway="closeKeyShortcutModal" |  | ||||||
|       /> |  | ||||||
|       <NotificationPanel |  | ||||||
|         v-if="showNotificationPanel" |  | ||||||
|         @close="closeNotificationPanel" |  | ||||||
|       /> |  | ||||||
|       <PortalPopover |  | ||||||
|         v-if="showPortalPopover" |  | ||||||
|         :portals="portals" |  | ||||||
|         :active-portal-slug="selectedPortalSlug" |  | ||||||
|         :active-locale="selectedLocaleInPortal" |  | ||||||
|         @fetch-portal="fetchPortalAndItsCategories" |  | ||||||
|         @close-popover="closePortalPopover" |  | ||||||
|       /> |  | ||||||
|       <AddCategory |  | ||||||
|         v-if="showAddCategoryModal" |  | ||||||
|         v-model:show="showAddCategoryModal" |  | ||||||
|         :portal-name="selectedPortalName" |  | ||||||
|         :locale="selectedLocaleInPortal" |  | ||||||
|         :portal-slug="selectedPortalSlug" |  | ||||||
|         @cancel="onClickCloseAddCategoryModal" |  | ||||||
|       /> |  | ||||||
|     </section> |  | ||||||
|     <UpgradePage v-else /> |  | ||||||
|   </div> |  | ||||||
| </template> |  | ||||||
| @@ -1,34 +0,0 @@ | |||||||
| <script setup> |  | ||||||
| defineProps({ |  | ||||||
|   title: { |  | ||||||
|     type: String, |  | ||||||
|     default: null, |  | ||||||
|   }, |  | ||||||
| }); |  | ||||||
| </script> |  | ||||||
|  |  | ||||||
| <template> |  | ||||||
|   <div |  | ||||||
|     class="flex-grow-0 flex-shrink-0 w-full h-full max-w-full bg-white border border-transparent border-solid dark:bg-slate-900 dark:border-transparent md:max-w-2xl" |  | ||||||
|   > |  | ||||||
|     <h3 |  | ||||||
|       v-if="$slots.title || title" |  | ||||||
|       class="text-lg text-black-900 dark:text-slate-200" |  | ||||||
|     > |  | ||||||
|       <slot name="title">{{ title }}</slot> |  | ||||||
|     </h3> |  | ||||||
|     <div |  | ||||||
|       class="mx-0 my-4 border-b border-solid border-slate-25 dark:border-slate-800" |  | ||||||
|     > |  | ||||||
|       <slot /> |  | ||||||
|     </div> |  | ||||||
|     <div class="flex justify-between"> |  | ||||||
|       <div> |  | ||||||
|         <slot name="footer-left" /> |  | ||||||
|       </div> |  | ||||||
|       <div> |  | ||||||
|         <slot name="footer-right" /> |  | ||||||
|       </div> |  | ||||||
|     </div> |  | ||||||
|   </div> |  | ||||||
| </template> |  | ||||||
| @@ -1,379 +0,0 @@ | |||||||
| <script> |  | ||||||
| import { useAlert } from 'dashboard/composables'; |  | ||||||
| import { useUISettings } from 'dashboard/composables/useUISettings'; |  | ||||||
| import thumbnail from 'dashboard/components/widgets/Thumbnail.vue'; |  | ||||||
| import LocaleItemTable from './PortalListItemTable.vue'; |  | ||||||
| import { PORTALS_EVENTS } from '../../../../helper/AnalyticsHelper/events'; |  | ||||||
| import { useTrack } from 'dashboard/composables'; |  | ||||||
|  |  | ||||||
| export default { |  | ||||||
|   components: { |  | ||||||
|     Thumbnail: thumbnail, |  | ||||||
|     LocaleItemTable, |  | ||||||
|   }, |  | ||||||
|   props: { |  | ||||||
|     portal: { |  | ||||||
|       type: Object, |  | ||||||
|       default: () => {}, |  | ||||||
|     }, |  | ||||||
|     status: { |  | ||||||
|       type: String, |  | ||||||
|       default: '', |  | ||||||
|       values: ['archived', 'draft', 'published'], |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
|   emits: ['addLocale', 'openSite'], |  | ||||||
|   setup() { |  | ||||||
|     const { updateUISettings } = useUISettings(); |  | ||||||
|  |  | ||||||
|     return { |  | ||||||
|       updateUISettings, |  | ||||||
|     }; |  | ||||||
|   }, |  | ||||||
|   data() { |  | ||||||
|     return { |  | ||||||
|       showDeleteConfirmationPopup: false, |  | ||||||
|       alertMessage: '', |  | ||||||
|       selectedPortalForDelete: {}, |  | ||||||
|     }; |  | ||||||
|   }, |  | ||||||
|   computed: { |  | ||||||
|     labelColor() { |  | ||||||
|       switch (this.status) { |  | ||||||
|         case 'Archived': |  | ||||||
|           return 'warning'; |  | ||||||
|         default: |  | ||||||
|           return 'success'; |  | ||||||
|       } |  | ||||||
|     }, |  | ||||||
|     deleteMessageValue() { |  | ||||||
|       return ` ${this.selectedPortalForDelete.name}?`; |  | ||||||
|     }, |  | ||||||
|     locales() { |  | ||||||
|       return this.portal ? this.portal.config.allowed_locales : []; |  | ||||||
|     }, |  | ||||||
|     allowedLocales() { |  | ||||||
|       return Object.keys(this.locales).map(key => { |  | ||||||
|         return this.locales[key].code; |  | ||||||
|       }); |  | ||||||
|     }, |  | ||||||
|     articleCount() { |  | ||||||
|       const { allowed_locales: allowedLocales } = this.portal.config; |  | ||||||
|       return allowedLocales.reduce((acc, locale) => { |  | ||||||
|         return acc + locale.articles_count; |  | ||||||
|       }, 0); |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
|   methods: { |  | ||||||
|     addLocale() { |  | ||||||
|       this.$emit('addLocale', this.portal.id); |  | ||||||
|     }, |  | ||||||
|     openSite() { |  | ||||||
|       this.$emit('openSite', this.portal.slug); |  | ||||||
|     }, |  | ||||||
|     openSettings() { |  | ||||||
|       this.fetchPortalAndItsCategories(); |  | ||||||
|       this.navigateToPortalEdit(); |  | ||||||
|     }, |  | ||||||
|     onClickOpenDeleteModal(portal) { |  | ||||||
|       this.selectedPortalForDelete = portal; |  | ||||||
|       this.showDeleteConfirmationPopup = true; |  | ||||||
|     }, |  | ||||||
|     closeDeletePopup() { |  | ||||||
|       this.showDeleteConfirmationPopup = false; |  | ||||||
|     }, |  | ||||||
|     async fetchPortalAndItsCategories() { |  | ||||||
|       await this.$store.dispatch('portals/index'); |  | ||||||
|       const { |  | ||||||
|         slug, |  | ||||||
|         config: { allowed_locales: allowedLocales }, |  | ||||||
|       } = this.portal; |  | ||||||
|       const selectedPortalParam = { |  | ||||||
|         portalSlug: slug, |  | ||||||
|         locale: allowedLocales[0].code, |  | ||||||
|       }; |  | ||||||
|       this.$store.dispatch('portals/show', selectedPortalParam); |  | ||||||
|       this.$store.dispatch('categories/index', selectedPortalParam); |  | ||||||
|     }, |  | ||||||
|     async onClickDeletePortal() { |  | ||||||
|       const { slug } = this.selectedPortalForDelete; |  | ||||||
|       try { |  | ||||||
|         await this.$store.dispatch('portals/delete', { |  | ||||||
|           portalSlug: slug, |  | ||||||
|         }); |  | ||||||
|         this.selectedPortalForDelete = {}; |  | ||||||
|         this.closeDeletePopup(); |  | ||||||
|         this.alertMessage = this.$t( |  | ||||||
|           'HELP_CENTER.PORTAL.PORTAL_SETTINGS.DELETE_PORTAL.API.DELETE_SUCCESS' |  | ||||||
|         ); |  | ||||||
|         this.updateUISettings({ |  | ||||||
|           last_active_portal_slug: undefined, |  | ||||||
|           last_active_locale_code: undefined, |  | ||||||
|         }); |  | ||||||
|       } catch (error) { |  | ||||||
|         this.alertMessage = |  | ||||||
|           error?.message || |  | ||||||
|           this.$t( |  | ||||||
|             'HELP_CENTER.PORTAL.PORTAL_SETTINGS.DELETE_PORTAL.API.DELETE_ERROR' |  | ||||||
|           ); |  | ||||||
|       } finally { |  | ||||||
|         useAlert(this.alertMessage); |  | ||||||
|       } |  | ||||||
|     }, |  | ||||||
|     changeDefaultLocale({ localeCode }) { |  | ||||||
|       this.updatePortalLocales({ |  | ||||||
|         allowedLocales: this.allowedLocales, |  | ||||||
|         defaultLocale: localeCode, |  | ||||||
|         successMessage: this.$t( |  | ||||||
|           'HELP_CENTER.PORTAL.CHANGE_DEFAULT_LOCALE.API.SUCCESS_MESSAGE' |  | ||||||
|         ), |  | ||||||
|         errorMessage: this.$t( |  | ||||||
|           'HELP_CENTER.PORTAL.CHANGE_DEFAULT_LOCALE.API.ERROR_MESSAGE' |  | ||||||
|         ), |  | ||||||
|       }); |  | ||||||
|       useTrack(PORTALS_EVENTS.SET_DEFAULT_LOCALE, { |  | ||||||
|         newLocale: localeCode, |  | ||||||
|         from: this.$route.name, |  | ||||||
|       }); |  | ||||||
|     }, |  | ||||||
|     deletePortalLocale({ localeCode }) { |  | ||||||
|       const updatedLocales = this.allowedLocales.filter( |  | ||||||
|         code => code !== localeCode |  | ||||||
|       ); |  | ||||||
|       const defaultLocale = this.portal.meta.default_locale; |  | ||||||
|       this.updatePortalLocales({ |  | ||||||
|         allowedLocales: updatedLocales, |  | ||||||
|         defaultLocale, |  | ||||||
|         successMessage: this.$t( |  | ||||||
|           'HELP_CENTER.PORTAL.DELETE_LOCALE.API.SUCCESS_MESSAGE' |  | ||||||
|         ), |  | ||||||
|         errorMessage: this.$t( |  | ||||||
|           'HELP_CENTER.PORTAL.DELETE_LOCALE.API.ERROR_MESSAGE' |  | ||||||
|         ), |  | ||||||
|       }); |  | ||||||
|       useTrack(PORTALS_EVENTS.DELETE_LOCALE, { |  | ||||||
|         deletedLocale: localeCode, |  | ||||||
|         from: this.$route.name, |  | ||||||
|       }); |  | ||||||
|     }, |  | ||||||
|     async updatePortalLocales({ |  | ||||||
|       allowedLocales, |  | ||||||
|       defaultLocale, |  | ||||||
|       successMessage, |  | ||||||
|       errorMessage, |  | ||||||
|     }) { |  | ||||||
|       try { |  | ||||||
|         await this.$store.dispatch('portals/update', { |  | ||||||
|           portalSlug: this.portal.slug, |  | ||||||
|           config: { |  | ||||||
|             default_locale: defaultLocale, |  | ||||||
|             allowed_locales: allowedLocales, |  | ||||||
|           }, |  | ||||||
|         }); |  | ||||||
|         this.alertMessage = successMessage; |  | ||||||
|       } catch (error) { |  | ||||||
|         this.alertMessage = error?.message || errorMessage; |  | ||||||
|       } finally { |  | ||||||
|         useAlert(this.alertMessage); |  | ||||||
|       } |  | ||||||
|     }, |  | ||||||
|     navigateToPortalEdit() { |  | ||||||
|       this.$router.push({ |  | ||||||
|         name: 'edit_portal_information', |  | ||||||
|         params: { portalSlug: this.portal.slug }, |  | ||||||
|       }); |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
| }; |  | ||||||
| </script> |  | ||||||
|  |  | ||||||
| <template> |  | ||||||
|   <div> |  | ||||||
|     <div |  | ||||||
|       class="relative flex p-4 mb-3 bg-white border border-solid rounded-md dark:bg-slate-900 border-slate-100 dark:border-slate-600" |  | ||||||
|     > |  | ||||||
|       <Thumbnail :username="portal.name" variant="square" /> |  | ||||||
|       <div class="flex-grow ml-2 rtl:ml-0 rtl:mr-2"> |  | ||||||
|         <header class="flex items-start justify-between mb-8"> |  | ||||||
|           <div> |  | ||||||
|             <div class="flex items-center"> |  | ||||||
|               <h2 class="mb-0 text-lg text-slate-800 dark:text-slate-100"> |  | ||||||
|                 {{ portal.name }} |  | ||||||
|               </h2> |  | ||||||
|               <woot-label |  | ||||||
|                 :title="status" |  | ||||||
|                 :color-scheme="labelColor" |  | ||||||
|                 size="small" |  | ||||||
|                 variant="smooth" |  | ||||||
|                 class="mx-2 my-0" |  | ||||||
|               /> |  | ||||||
|             </div> |  | ||||||
|             <p class="mb-0 text-sm text-slate-700 dark:text-slate-200"> |  | ||||||
|               {{ articleCount }} |  | ||||||
|               {{ |  | ||||||
|                 $t( |  | ||||||
|                   'HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.HEADER.COUNT_LABEL' |  | ||||||
|                 ) |  | ||||||
|               }} |  | ||||||
|             </p> |  | ||||||
|           </div> |  | ||||||
|           <div class="flex flex-row gap-1"> |  | ||||||
|             <woot-button |  | ||||||
|               variant="smooth" |  | ||||||
|               size="small" |  | ||||||
|               color-scheme="primary" |  | ||||||
|               @click="addLocale" |  | ||||||
|             > |  | ||||||
|               {{ |  | ||||||
|                 $t('HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.HEADER.ADD') |  | ||||||
|               }} |  | ||||||
|             </woot-button> |  | ||||||
|             <woot-button |  | ||||||
|               variant="hollow" |  | ||||||
|               size="small" |  | ||||||
|               color-scheme="secondary" |  | ||||||
|               @click="openSite" |  | ||||||
|             > |  | ||||||
|               {{ |  | ||||||
|                 $t('HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.HEADER.VISIT') |  | ||||||
|               }} |  | ||||||
|             </woot-button> |  | ||||||
|             <woot-button |  | ||||||
|               v-tooltip.top-end=" |  | ||||||
|                 $t( |  | ||||||
|                   'HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.HEADER.SETTINGS' |  | ||||||
|                 ) |  | ||||||
|               " |  | ||||||
|               variant="hollow" |  | ||||||
|               size="small" |  | ||||||
|               icon="settings" |  | ||||||
|               color-scheme="secondary" |  | ||||||
|               @click="openSettings" |  | ||||||
|             /> |  | ||||||
|             <woot-button |  | ||||||
|               v-tooltip.top-end=" |  | ||||||
|                 $t('HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.HEADER.DELETE') |  | ||||||
|               " |  | ||||||
|               variant="hollow" |  | ||||||
|               color-scheme="alert" |  | ||||||
|               size="small" |  | ||||||
|               icon="delete" |  | ||||||
|               @click="onClickOpenDeleteModal(portal)" |  | ||||||
|             /> |  | ||||||
|           </div> |  | ||||||
|         </header> |  | ||||||
|         <div class="mb-12"> |  | ||||||
|           <h2 |  | ||||||
|             class="mb-2 text-base font-medium text-slate-800 dark:text-slate-100" |  | ||||||
|           > |  | ||||||
|             {{ |  | ||||||
|               $t( |  | ||||||
|                 'HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.PORTAL_CONFIG.TITLE' |  | ||||||
|               ) |  | ||||||
|             }} |  | ||||||
|           </h2> |  | ||||||
|           <div |  | ||||||
|             class="flex justify-between mr-[6.25rem] rtl:mr-0 rtl:ml-[6.25rem] max-w-[80vw]" |  | ||||||
|           > |  | ||||||
|             <div class="flex flex-col"> |  | ||||||
|               <div class="flex flex-col items-start mb-4"> |  | ||||||
|                 <label>{{ |  | ||||||
|                   $t( |  | ||||||
|                     'HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.PORTAL_CONFIG.ITEMS.NAME' |  | ||||||
|                   ) |  | ||||||
|                 }}</label> |  | ||||||
|                 <span class="text-sm text-slate-600 dark:text-slate-300"> |  | ||||||
|                   {{ portal.name }} |  | ||||||
|                 </span> |  | ||||||
|               </div> |  | ||||||
|               <div class="flex flex-col items-start mb-4"> |  | ||||||
|                 <label>{{ |  | ||||||
|                   $t( |  | ||||||
|                     'HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.PORTAL_CONFIG.ITEMS.DOMAIN' |  | ||||||
|                   ) |  | ||||||
|                 }}</label> |  | ||||||
|                 <span class="text-sm text-slate-600 dark:text-slate-300"> |  | ||||||
|                   {{ portal.custom_domain }} |  | ||||||
|                 </span> |  | ||||||
|               </div> |  | ||||||
|             </div> |  | ||||||
|             <div class="flex flex-col"> |  | ||||||
|               <div class="flex flex-col items-start mb-4"> |  | ||||||
|                 <label>{{ |  | ||||||
|                   $t( |  | ||||||
|                     'HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.PORTAL_CONFIG.ITEMS.SLUG' |  | ||||||
|                   ) |  | ||||||
|                 }}</label> |  | ||||||
|                 <span class="text-sm text-slate-600 dark:text-slate-300"> |  | ||||||
|                   {{ portal.slug }} |  | ||||||
|                 </span> |  | ||||||
|               </div> |  | ||||||
|               <div class="flex flex-col items-start mb-4"> |  | ||||||
|                 <label>{{ |  | ||||||
|                   $t( |  | ||||||
|                     'HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.PORTAL_CONFIG.ITEMS.TITLE' |  | ||||||
|                   ) |  | ||||||
|                 }}</label> |  | ||||||
|                 <span class="text-sm text-slate-600 dark:text-slate-300"> |  | ||||||
|                   {{ portal.page_title }} |  | ||||||
|                 </span> |  | ||||||
|               </div> |  | ||||||
|             </div> |  | ||||||
|             <div class="flex flex-col"> |  | ||||||
|               <div class="flex flex-col items-start mb-4"> |  | ||||||
|                 <label>{{ |  | ||||||
|                   $t( |  | ||||||
|                     'HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.PORTAL_CONFIG.ITEMS.THEME' |  | ||||||
|                   ) |  | ||||||
|                 }}</label> |  | ||||||
|                 <div class="flex items-center"> |  | ||||||
|                   <div |  | ||||||
|                     class="w-4 h-4 mr-1 border border-solid rounded-md rtl:mr-0 rtl:ml-1 border-slate-25 dark:border-slate-800" |  | ||||||
|                     :style="{ background: portal.color }" |  | ||||||
|                   /> |  | ||||||
|                 </div> |  | ||||||
|               </div> |  | ||||||
|               <div class="flex flex-col items-start mb-4"> |  | ||||||
|                 <label>{{ |  | ||||||
|                   $t( |  | ||||||
|                     'HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.PORTAL_CONFIG.ITEMS.SUB_TEXT' |  | ||||||
|                   ) |  | ||||||
|                 }}</label> |  | ||||||
|                 <span class="text-sm text-slate-600 dark:text-slate-300"> |  | ||||||
|                   {{ portal.header_text }} |  | ||||||
|                 </span> |  | ||||||
|               </div> |  | ||||||
|             </div> |  | ||||||
|           </div> |  | ||||||
|         </div> |  | ||||||
|         <div class="mb-12"> |  | ||||||
|           <h2 |  | ||||||
|             class="mb-2 text-base font-medium text-slate-800 dark:text-slate-100" |  | ||||||
|           > |  | ||||||
|             {{ |  | ||||||
|               $t( |  | ||||||
|                 'HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.AVAILABLE_LOCALES.TITLE' |  | ||||||
|               ) |  | ||||||
|             }} |  | ||||||
|           </h2> |  | ||||||
|           <LocaleItemTable |  | ||||||
|             :locales="locales" |  | ||||||
|             :selected-locale-code="portal.meta.default_locale" |  | ||||||
|             @change-default-locale="changeDefaultLocale" |  | ||||||
|             @delete="deletePortalLocale" |  | ||||||
|           /> |  | ||||||
|         </div> |  | ||||||
|       </div> |  | ||||||
|     </div> |  | ||||||
|     <woot-delete-modal |  | ||||||
|       v-model:show="showDeleteConfirmationPopup" |  | ||||||
|       :on-close="closeDeletePopup" |  | ||||||
|       :on-confirm="onClickDeletePortal" |  | ||||||
|       :title="$t('HELP_CENTER.PORTAL.PORTAL_SETTINGS.DELETE_PORTAL.TITLE')" |  | ||||||
|       :message="$t('HELP_CENTER.PORTAL.PORTAL_SETTINGS.DELETE_PORTAL.MESSAGE')" |  | ||||||
|       :message-value="deleteMessageValue" |  | ||||||
|       :confirm-text="$t('HELP_CENTER.PORTAL.PORTAL_SETTINGS.DELETE_PORTAL.YES')" |  | ||||||
|       :reject-text="$t('HELP_CENTER.PORTAL.PORTAL_SETTINGS.DELETE_PORTAL.NO')" |  | ||||||
|     /> |  | ||||||
|   </div> |  | ||||||
| </template> |  | ||||||
| @@ -1,147 +0,0 @@ | |||||||
| <script> |  | ||||||
| import portalMixin from '../mixins/portalMixin'; |  | ||||||
| export default { |  | ||||||
|   mixins: [portalMixin], |  | ||||||
|   props: { |  | ||||||
|     locales: { |  | ||||||
|       type: Array, |  | ||||||
|       default: () => [], |  | ||||||
|     }, |  | ||||||
|     selectedLocaleCode: { |  | ||||||
|       type: String, |  | ||||||
|       default: '', |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
|   emits: ['changeDefaultLocale', 'delete'], |  | ||||||
|  |  | ||||||
|   methods: { |  | ||||||
|     changeDefaultLocale(localeCode) { |  | ||||||
|       this.$emit('changeDefaultLocale', { localeCode }); |  | ||||||
|     }, |  | ||||||
|     deleteLocale(localeCode) { |  | ||||||
|       this.$emit('delete', { localeCode }); |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
| }; |  | ||||||
| </script> |  | ||||||
|  |  | ||||||
| <template> |  | ||||||
|   <table class="woot-table"> |  | ||||||
|     <thead> |  | ||||||
|       <tr> |  | ||||||
|         <th scope="col"> |  | ||||||
|           {{ |  | ||||||
|             $t( |  | ||||||
|               'HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.AVAILABLE_LOCALES.TABLE.NAME' |  | ||||||
|             ) |  | ||||||
|           }} |  | ||||||
|         </th> |  | ||||||
|         <th scope="col"> |  | ||||||
|           {{ |  | ||||||
|             $t( |  | ||||||
|               'HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.AVAILABLE_LOCALES.TABLE.CODE' |  | ||||||
|             ) |  | ||||||
|           }} |  | ||||||
|         </th> |  | ||||||
|         <th scope="col"> |  | ||||||
|           {{ |  | ||||||
|             $t( |  | ||||||
|               'HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.AVAILABLE_LOCALES.TABLE.ARTICLE_COUNT' |  | ||||||
|             ) |  | ||||||
|           }} |  | ||||||
|         </th> |  | ||||||
|         <th scope="col"> |  | ||||||
|           {{ |  | ||||||
|             $t( |  | ||||||
|               'HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.AVAILABLE_LOCALES.TABLE.CATEGORIES' |  | ||||||
|             ) |  | ||||||
|           }} |  | ||||||
|         </th> |  | ||||||
|         <th scope="col" /> |  | ||||||
|       </tr> |  | ||||||
|     </thead> |  | ||||||
|     <tr> |  | ||||||
|       <td colspan="100%" class="horizontal-line" /> |  | ||||||
|     </tr> |  | ||||||
|     <tbody> |  | ||||||
|       <tr v-for="locale in locales" :key="locale.code"> |  | ||||||
|         <td> |  | ||||||
|           <span>{{ localeName(locale.code) }}</span> |  | ||||||
|           <woot-label |  | ||||||
|             v-if="locale.code === selectedLocaleCode" |  | ||||||
|             :title=" |  | ||||||
|               $t( |  | ||||||
|                 'HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.AVAILABLE_LOCALES.TABLE.DEFAULT_LOCALE' |  | ||||||
|               ) |  | ||||||
|             " |  | ||||||
|             color-scheme="warning" |  | ||||||
|             small |  | ||||||
|             variant="smooth" |  | ||||||
|             class="default-status" |  | ||||||
|           /> |  | ||||||
|         </td> |  | ||||||
|         <td> |  | ||||||
|           <span>{{ locale.code }}</span> |  | ||||||
|         </td> |  | ||||||
|         <td> |  | ||||||
|           <span>{{ locale.articles_count }}</span> |  | ||||||
|         </td> |  | ||||||
|         <td> |  | ||||||
|           <span>{{ locale.categories_count }}</span> |  | ||||||
|         </td> |  | ||||||
|         <td> |  | ||||||
|           <woot-button |  | ||||||
|             v-tooltip.top-end=" |  | ||||||
|               $t( |  | ||||||
|                 'HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.AVAILABLE_LOCALES.TABLE.SWAP' |  | ||||||
|               ) |  | ||||||
|             " |  | ||||||
|             size="tiny" |  | ||||||
|             variant="smooth" |  | ||||||
|             icon="arrow-swap" |  | ||||||
|             color-scheme="primary" |  | ||||||
|             :disabled="locale.code === selectedLocaleCode" |  | ||||||
|             @click="changeDefaultLocale(locale.code)" |  | ||||||
|           /> |  | ||||||
|           <woot-button |  | ||||||
|             v-tooltip.top-end=" |  | ||||||
|               $t( |  | ||||||
|                 'HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.AVAILABLE_LOCALES.TABLE.DELETE' |  | ||||||
|               ) |  | ||||||
|             " |  | ||||||
|             size="tiny" |  | ||||||
|             variant="smooth" |  | ||||||
|             icon="delete" |  | ||||||
|             color-scheme="alert" |  | ||||||
|             :disabled="locale.code === selectedLocaleCode" |  | ||||||
|             @click="deleteLocale(locale.code)" |  | ||||||
|           /> |  | ||||||
|         </td> |  | ||||||
|       </tr> |  | ||||||
|     </tbody> |  | ||||||
|   </table> |  | ||||||
| </template> |  | ||||||
|  |  | ||||||
| <style lang="scss" scoped> |  | ||||||
| table { |  | ||||||
|   thead tr th { |  | ||||||
|     @apply text-sm font-medium normal-case text-slate-600 dark:text-slate-200 pl-0 rtl:pl-2.5 rtl:pr-0 pt-0; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   tbody tr { |  | ||||||
|     @apply border-b-0; |  | ||||||
|     td { |  | ||||||
|       @apply text-sm pl-0 rtl:pl-2.5 rtl:pr-0; |  | ||||||
|       .default-status { |  | ||||||
|         @apply py-0 pr-0 pl-1; |  | ||||||
|       } |  | ||||||
|       span { |  | ||||||
|         @apply text-slate-700 dark:text-slate-200; |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| .horizontal-line { |  | ||||||
|   @apply border-b border-solid border-slate-75 dark:border-slate-700; |  | ||||||
| } |  | ||||||
| </style> |  | ||||||
| @@ -1,86 +0,0 @@ | |||||||
| <script> |  | ||||||
| import PortalSwitch from './PortalSwitch.vue'; |  | ||||||
| export default { |  | ||||||
|   components: { |  | ||||||
|     PortalSwitch, |  | ||||||
|   }, |  | ||||||
|   props: { |  | ||||||
|     portals: { |  | ||||||
|       type: Array, |  | ||||||
|       default: () => [], |  | ||||||
|     }, |  | ||||||
|     activePortalSlug: { |  | ||||||
|       type: String, |  | ||||||
|       default: '', |  | ||||||
|     }, |  | ||||||
|     activeLocale: { |  | ||||||
|       type: String, |  | ||||||
|       default: '', |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
|   emits: ['closePopover', 'fetchPortal'], |  | ||||||
|  |  | ||||||
|   methods: { |  | ||||||
|     closePortalPopover() { |  | ||||||
|       this.$emit('closePopover'); |  | ||||||
|     }, |  | ||||||
|     openPortalPage() { |  | ||||||
|       this.closePortalPopover(); |  | ||||||
|       this.$router.push({ |  | ||||||
|         name: 'list_all_portals', |  | ||||||
|       }); |  | ||||||
|     }, |  | ||||||
|     fetchPortalAndItsCategories() { |  | ||||||
|       this.$emit('fetchPortal'); |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
| }; |  | ||||||
| </script> |  | ||||||
|  |  | ||||||
| <template> |  | ||||||
|   <div |  | ||||||
|     v-on-clickaway="closePortalPopover" |  | ||||||
|     class="absolute overflow-y-scroll max-h-[96vh] p-4 bg-white dark:bg-slate-800 rounded-md shadow-lg max-w-[30rem] z-[1000]" |  | ||||||
|   > |  | ||||||
|     <header> |  | ||||||
|       <div class="flex items-center justify-between mb-4"> |  | ||||||
|         <h2 class="text-lg text-slate-800 dark:text-slate-100"> |  | ||||||
|           {{ $t('HELP_CENTER.PORTAL.POPOVER.TITLE') }} |  | ||||||
|         </h2> |  | ||||||
|         <div> |  | ||||||
|           <woot-button |  | ||||||
|             variant="smooth" |  | ||||||
|             color-scheme="secondary" |  | ||||||
|             icon="settings" |  | ||||||
|             size="small" |  | ||||||
|             @click="openPortalPage" |  | ||||||
|           > |  | ||||||
|             {{ $t('HELP_CENTER.PORTAL.POPOVER.PORTAL_SETTINGS') }} |  | ||||||
|           </woot-button> |  | ||||||
|           <woot-button |  | ||||||
|             variant="clear" |  | ||||||
|             color-scheme="secondary" |  | ||||||
|             icon="dismiss" |  | ||||||
|             size="small" |  | ||||||
|             @click="closePortalPopover" |  | ||||||
|           /> |  | ||||||
|         </div> |  | ||||||
|       </div> |  | ||||||
|       <p class="mt-2 text-xs text-slate-600 dark:text-slate-300"> |  | ||||||
|         {{ $t('HELP_CENTER.PORTAL.POPOVER.SUBTITLE') }} |  | ||||||
|       </p> |  | ||||||
|     </header> |  | ||||||
|     <div> |  | ||||||
|       <PortalSwitch |  | ||||||
|         v-for="portal in portals" |  | ||||||
|         :key="portal.id" |  | ||||||
|         :portal="portal" |  | ||||||
|         :active-portal-slug="activePortalSlug" |  | ||||||
|         :active-locale="activeLocale" |  | ||||||
|         :active="portal.slug === activePortalSlug" |  | ||||||
|         @open-portal-page="closePortalPopover" |  | ||||||
|         @fetch-portal="fetchPortalAndItsCategories" |  | ||||||
|       /> |  | ||||||
|     </div> |  | ||||||
|   </div> |  | ||||||
| </template> |  | ||||||
| @@ -1,240 +0,0 @@ | |||||||
| <script setup> |  | ||||||
| import { useVuelidate } from '@vuelidate/core'; |  | ||||||
| import { required, minLength } from '@vuelidate/validators'; |  | ||||||
|  |  | ||||||
| import { defineOptions, reactive, computed, onMounted } from 'vue'; |  | ||||||
| import { useI18n } from 'vue-i18n'; |  | ||||||
| import { useAlert } from 'dashboard/composables'; |  | ||||||
|  |  | ||||||
| import { convertToCategorySlug } from 'dashboard/helper/commons.js'; |  | ||||||
| import { buildPortalURL } from 'dashboard/helper/portalHelper'; |  | ||||||
| import wootConstants from 'dashboard/constants/globals'; |  | ||||||
| import { hasValidAvatarUrl } from 'dashboard/helper/URLHelper'; |  | ||||||
| import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; |  | ||||||
| import { uploadFile } from 'dashboard/helper/uploadHelper'; |  | ||||||
| import { isDomain } from 'shared/helpers/Validators'; |  | ||||||
| import SettingsLayout from './Layout/SettingsLayout.vue'; |  | ||||||
|  |  | ||||||
| const props = defineProps({ |  | ||||||
|   portal: { |  | ||||||
|     type: Object, |  | ||||||
|     default: () => {}, |  | ||||||
|   }, |  | ||||||
|   isSubmitting: { |  | ||||||
|     type: Boolean, |  | ||||||
|     default: false, |  | ||||||
|   }, |  | ||||||
|   submitButtonText: { |  | ||||||
|     type: String, |  | ||||||
|     default: '', |  | ||||||
|   }, |  | ||||||
| }); |  | ||||||
| const emit = defineEmits(['submit', 'deleteLogo']); |  | ||||||
|  |  | ||||||
| defineOptions({ |  | ||||||
|   name: 'PortalSettingsBasicForm', |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| const { EXAMPLE_URL } = wootConstants; |  | ||||||
| const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB |  | ||||||
|  |  | ||||||
| const { t } = useI18n(); |  | ||||||
|  |  | ||||||
| const state = reactive({ |  | ||||||
|   name: '', |  | ||||||
|   slug: '', |  | ||||||
|   domain: '', |  | ||||||
|   logoUrl: '', |  | ||||||
|   avatarBlobId: '', |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| const rules = { |  | ||||||
|   name: { |  | ||||||
|     required, |  | ||||||
|     minLength: minLength(2), |  | ||||||
|   }, |  | ||||||
|   slug: { |  | ||||||
|     required, |  | ||||||
|   }, |  | ||||||
|   domain: { |  | ||||||
|     isDomain, |  | ||||||
|   }, |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| const v$ = useVuelidate(rules, state); |  | ||||||
|  |  | ||||||
| const nameError = computed(() => { |  | ||||||
|   if (v$.value.name.$error) { |  | ||||||
|     return t('HELP_CENTER.CATEGORY.ADD.NAME.ERROR'); |  | ||||||
|   } |  | ||||||
|   return ''; |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| const slugError = computed(() => { |  | ||||||
|   if (v$.value.slug.$error) { |  | ||||||
|     return t('HELP_CENTER.CATEGORY.ADD.SLUG.ERROR'); |  | ||||||
|   } |  | ||||||
|   return ''; |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| const domainError = computed(() => { |  | ||||||
|   if (v$.value.domain.$error) { |  | ||||||
|     return t('HELP_CENTER.PORTAL.ADD.DOMAIN.ERROR'); |  | ||||||
|   } |  | ||||||
|   return ''; |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| const domainHelpText = computed(() => { |  | ||||||
|   return buildPortalURL(state.slug); |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| const domainExampleHelpText = computed(() => { |  | ||||||
|   return t('HELP_CENTER.PORTAL.ADD.DOMAIN.HELP_TEXT', { |  | ||||||
|     exampleURL: EXAMPLE_URL, |  | ||||||
|   }); |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| const showDeleteButton = computed(() => { |  | ||||||
|   return hasValidAvatarUrl(state.logoUrl); |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| onMounted(() => { |  | ||||||
|   const portal = props.portal || {}; |  | ||||||
|   state.name = portal.name || ''; |  | ||||||
|   state.slug = portal.slug || ''; |  | ||||||
|   state.domain = portal.custom_domain || ''; |  | ||||||
|  |  | ||||||
|   if (portal.logo) { |  | ||||||
|     const { |  | ||||||
|       logo: { file_url: logoURL, blob_id: blobId }, |  | ||||||
|     } = portal; |  | ||||||
|     state.logoUrl = logoURL; |  | ||||||
|     state.avatarBlobId = blobId; |  | ||||||
|   } |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| function onNameChange() { |  | ||||||
|   state.slug = convertToCategorySlug(state.name); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| function onSubmitClick() { |  | ||||||
|   v$.value.$touch(); |  | ||||||
|   if (v$.value.$invalid) { |  | ||||||
|     return; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   const portal = { |  | ||||||
|     name: state.name, |  | ||||||
|     slug: state.slug, |  | ||||||
|     custom_domain: state.domain, |  | ||||||
|     blob_id: state.avatarBlobId || null, |  | ||||||
|   }; |  | ||||||
|   emit('submit', portal); |  | ||||||
| } |  | ||||||
| async function deleteAvatar() { |  | ||||||
|   state.logoUrl = ''; |  | ||||||
|   state.avatarBlobId = ''; |  | ||||||
|   emit('deleteLogo'); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| async function uploadLogoToStorage(file) { |  | ||||||
|   try { |  | ||||||
|     const { fileUrl, blobId } = await uploadFile(file); |  | ||||||
|     if (fileUrl) { |  | ||||||
|       state.logoUrl = fileUrl; |  | ||||||
|       state.avatarBlobId = blobId; |  | ||||||
|     } |  | ||||||
|   } catch (error) { |  | ||||||
|     useAlert(t('HELP_CENTER.PORTAL.ADD.LOGO.IMAGE_UPLOAD_ERROR')); |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| function onFileChange({ file }) { |  | ||||||
|   if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) { |  | ||||||
|     uploadLogoToStorage(file); |  | ||||||
|   } else { |  | ||||||
|     const errorKey = |  | ||||||
|       'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SIZE_ERROR'; |  | ||||||
|     useAlert(t(errorKey, { size: MAXIMUM_FILE_UPLOAD_SIZE })); |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| </script> |  | ||||||
|  |  | ||||||
| <template> |  | ||||||
|   <SettingsLayout |  | ||||||
|     :title=" |  | ||||||
|       $t('HELP_CENTER.PORTAL.ADD.CREATE_FLOW_PAGE.BASIC_SETTINGS_PAGE.TITLE') |  | ||||||
|     " |  | ||||||
|   > |  | ||||||
|     <div> |  | ||||||
|       <div class="mb-4"> |  | ||||||
|         <div class="flex flex-row items-center"> |  | ||||||
|           <woot-avatar-uploader |  | ||||||
|             :label="$t('HELP_CENTER.PORTAL.ADD.LOGO.LABEL')" |  | ||||||
|             :src="state.logoUrl" |  | ||||||
|             @on-avatar-select="onFileChange" |  | ||||||
|           /> |  | ||||||
|           <div v-if="showDeleteButton" class="avatar-delete-btn"> |  | ||||||
|             <woot-button |  | ||||||
|               type="button" |  | ||||||
|               color-scheme="alert" |  | ||||||
|               variant="hollow" |  | ||||||
|               size="small" |  | ||||||
|               @click="deleteAvatar" |  | ||||||
|             > |  | ||||||
|               {{ $t('PROFILE_SETTINGS.DELETE_AVATAR') }} |  | ||||||
|             </woot-button> |  | ||||||
|           </div> |  | ||||||
|         </div> |  | ||||||
|         <p |  | ||||||
|           class="mt-1 mb-0 text-xs not-italic text-slate-600 dark:text-slate-400" |  | ||||||
|         > |  | ||||||
|           {{ $t('HELP_CENTER.PORTAL.ADD.LOGO.HELP_TEXT') }} |  | ||||||
|         </p> |  | ||||||
|       </div> |  | ||||||
|       <div class="mb-4"> |  | ||||||
|         <woot-input |  | ||||||
|           v-model="state.name" |  | ||||||
|           :class="{ error: v$.name.$error }" |  | ||||||
|           :error="nameError" |  | ||||||
|           :label="$t('HELP_CENTER.PORTAL.ADD.NAME.LABEL')" |  | ||||||
|           :placeholder="$t('HELP_CENTER.PORTAL.ADD.NAME.PLACEHOLDER')" |  | ||||||
|           :help-text="$t('HELP_CENTER.PORTAL.ADD.NAME.HELP_TEXT')" |  | ||||||
|           @blur="v$.name.$touch" |  | ||||||
|           @update:model-value="onNameChange" |  | ||||||
|         /> |  | ||||||
|       </div> |  | ||||||
|       <div class="mb-4"> |  | ||||||
|         <woot-input |  | ||||||
|           v-model="state.slug" |  | ||||||
|           :class="{ error: v$.slug.$error }" |  | ||||||
|           :error="slugError" |  | ||||||
|           :label="$t('HELP_CENTER.PORTAL.ADD.SLUG.LABEL')" |  | ||||||
|           :placeholder="$t('HELP_CENTER.PORTAL.ADD.SLUG.PLACEHOLDER')" |  | ||||||
|           :help-text="domainHelpText" |  | ||||||
|           @blur="v$.slug.$touch" |  | ||||||
|         /> |  | ||||||
|       </div> |  | ||||||
|       <div class="mb-4"> |  | ||||||
|         <woot-input |  | ||||||
|           v-model="state.domain" |  | ||||||
|           :class="{ error: v$.domain.$error }" |  | ||||||
|           :label="$t('HELP_CENTER.PORTAL.ADD.DOMAIN.LABEL')" |  | ||||||
|           :placeholder="$t('HELP_CENTER.PORTAL.ADD.DOMAIN.PLACEHOLDER')" |  | ||||||
|           :help-text="domainExampleHelpText" |  | ||||||
|           :error="domainError" |  | ||||||
|           @blur="v$.domain.$touch" |  | ||||||
|         /> |  | ||||||
|       </div> |  | ||||||
|     </div> |  | ||||||
|     <template #footer-right> |  | ||||||
|       <woot-button |  | ||||||
|         :is-loading="isSubmitting" |  | ||||||
|         :is-disabled="v$.$invalid" |  | ||||||
|         @click="onSubmitClick" |  | ||||||
|       > |  | ||||||
|         {{ submitButtonText }} |  | ||||||
|       </woot-button> |  | ||||||
|     </template> |  | ||||||
|   </SettingsLayout> |  | ||||||
| </template> |  | ||||||
| @@ -1,155 +0,0 @@ | |||||||
| <script setup> |  | ||||||
| import { getRandomColor } from 'dashboard/helper/labelColor'; |  | ||||||
| import SettingsLayout from './Layout/SettingsLayout.vue'; |  | ||||||
| import wootConstants from 'dashboard/constants/globals'; |  | ||||||
| import { useVuelidate } from '@vuelidate/core'; |  | ||||||
| import { url } from '@vuelidate/validators'; |  | ||||||
|  |  | ||||||
| import { defineOptions, reactive, computed, onMounted } from 'vue'; |  | ||||||
| import { useI18n } from 'vue-i18n'; |  | ||||||
| const props = defineProps({ |  | ||||||
|   portal: { |  | ||||||
|     type: Object, |  | ||||||
|     default: () => ({}), |  | ||||||
|   }, |  | ||||||
|   isSubmitting: { |  | ||||||
|     type: Boolean, |  | ||||||
|     default: false, |  | ||||||
|   }, |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| const emit = defineEmits(['submit']); |  | ||||||
|  |  | ||||||
| defineOptions({ |  | ||||||
|   name: 'PortalSettingsCustomizationForm', |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| const { t } = useI18n(); |  | ||||||
|  |  | ||||||
| const { EXAMPLE_URL } = wootConstants; |  | ||||||
|  |  | ||||||
| const state = reactive({ |  | ||||||
|   color: getRandomColor(), |  | ||||||
|   pageTitle: '', |  | ||||||
|   headerText: '', |  | ||||||
|   homePageLink: '', |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| const rules = { |  | ||||||
|   homePageLink: { url }, |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| const homepageExampleHelpText = computed(() => { |  | ||||||
|   return t('HELP_CENTER.PORTAL.ADD.HOME_PAGE_LINK.HELP_TEXT', { |  | ||||||
|     exampleURL: EXAMPLE_URL, |  | ||||||
|   }); |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| const v$ = useVuelidate(rules, state); |  | ||||||
|  |  | ||||||
| function updateDataFromStore() { |  | ||||||
|   const { portal } = props; |  | ||||||
|   if (portal) { |  | ||||||
|     state.color = portal.color || getRandomColor(); |  | ||||||
|     state.pageTitle = portal.page_title || ''; |  | ||||||
|     state.headerText = portal.header_text || ''; |  | ||||||
|     state.homePageLink = portal.homepage_link || ''; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| function onSubmitClick() { |  | ||||||
|   v$.value.$touch(); |  | ||||||
|   if (v$.value.$invalid) { |  | ||||||
|     return; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   const portal = { |  | ||||||
|     id: props.portal.id, |  | ||||||
|     slug: props.portal.slug, |  | ||||||
|     color: state.color, |  | ||||||
|     page_title: state.pageTitle, |  | ||||||
|     header_text: state.headerText, |  | ||||||
|     homepage_link: state.homePageLink, |  | ||||||
|   }; |  | ||||||
|  |  | ||||||
|   emit('submit', portal); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| onMounted(() => { |  | ||||||
|   updateDataFromStore(); |  | ||||||
| }); |  | ||||||
| </script> |  | ||||||
|  |  | ||||||
| <template> |  | ||||||
|   <SettingsLayout |  | ||||||
|     :title=" |  | ||||||
|       $t('HELP_CENTER.PORTAL.ADD.CREATE_FLOW_PAGE.CUSTOMIZATION_PAGE.TITLE') |  | ||||||
|     " |  | ||||||
|   > |  | ||||||
|     <div class="flex-grow-0 flex-shrink-0"> |  | ||||||
|       <div class="mb-4"> |  | ||||||
|         <label> |  | ||||||
|           {{ $t('HELP_CENTER.PORTAL.ADD.THEME_COLOR.LABEL') }} |  | ||||||
|         </label> |  | ||||||
|         <woot-color-picker v-model="state.color" /> |  | ||||||
|         <p |  | ||||||
|           class="mt-1 mb-0 text-xs not-italic text-slate-600 dark:text-slate-400" |  | ||||||
|         > |  | ||||||
|           {{ $t('HELP_CENTER.PORTAL.ADD.THEME_COLOR.HELP_TEXT') }} |  | ||||||
|         </p> |  | ||||||
|       </div> |  | ||||||
|       <div class="mb-4"> |  | ||||||
|         <woot-input |  | ||||||
|           v-model="state.pageTitle" |  | ||||||
|           :label="$t('HELP_CENTER.PORTAL.ADD.PAGE_TITLE.LABEL')" |  | ||||||
|           :placeholder="$t('HELP_CENTER.PORTAL.ADD.PAGE_TITLE.PLACEHOLDER')" |  | ||||||
|           :help-text="$t('HELP_CENTER.PORTAL.ADD.PAGE_TITLE.HELP_TEXT')" |  | ||||||
|         /> |  | ||||||
|       </div> |  | ||||||
|       <div class="mb-4"> |  | ||||||
|         <woot-input |  | ||||||
|           v-model="state.headerText" |  | ||||||
|           :label="$t('HELP_CENTER.PORTAL.ADD.HEADER_TEXT.LABEL')" |  | ||||||
|           :placeholder="$t('HELP_CENTER.PORTAL.ADD.HEADER_TEXT.PLACEHOLDER')" |  | ||||||
|           :help-text="$t('HELP_CENTER.PORTAL.ADD.HEADER_TEXT.HELP_TEXT')" |  | ||||||
|         /> |  | ||||||
|       </div> |  | ||||||
|       <div class="mb-4"> |  | ||||||
|         <woot-input |  | ||||||
|           v-model="state.homePageLink" |  | ||||||
|           :label="$t('HELP_CENTER.PORTAL.ADD.HOME_PAGE_LINK.LABEL')" |  | ||||||
|           :placeholder="$t('HELP_CENTER.PORTAL.ADD.HOME_PAGE_LINK.PLACEHOLDER')" |  | ||||||
|           :help-text="homepageExampleHelpText" |  | ||||||
|           :error=" |  | ||||||
|             v$.homePageLink.$error |  | ||||||
|               ? $t('HELP_CENTER.PORTAL.ADD.HOME_PAGE_LINK.ERROR') |  | ||||||
|               : '' |  | ||||||
|           " |  | ||||||
|           :class="{ error: v$.homePageLink.$error }" |  | ||||||
|           @blur="v$.homePageLink.$touch" |  | ||||||
|         /> |  | ||||||
|       </div> |  | ||||||
|     </div> |  | ||||||
|     <template #footer-right> |  | ||||||
|       <woot-button |  | ||||||
|         :is-loading="isSubmitting" |  | ||||||
|         :is-disabled="v$.$invalid" |  | ||||||
|         @click="onSubmitClick" |  | ||||||
|       > |  | ||||||
|         {{ |  | ||||||
|           $t( |  | ||||||
|             'HELP_CENTER.PORTAL.ADD.CREATE_FLOW_PAGE.CUSTOMIZATION_PAGE.UPDATE_PORTAL_BUTTON' |  | ||||||
|           ) |  | ||||||
|         }} |  | ||||||
|       </woot-button> |  | ||||||
|     </template> |  | ||||||
|   </SettingsLayout> |  | ||||||
| </template> |  | ||||||
|  |  | ||||||
| <style lang="scss" scoped> |  | ||||||
| ::v-deep { |  | ||||||
|   .colorpicker--selected { |  | ||||||
|     @apply mb-0; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| </style> |  | ||||||
| @@ -1,175 +0,0 @@ | |||||||
| <script> |  | ||||||
| import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue'; |  | ||||||
| import portalMixin from '../mixins/portalMixin'; |  | ||||||
|  |  | ||||||
| export default { |  | ||||||
|   components: { |  | ||||||
|     Thumbnail, |  | ||||||
|   }, |  | ||||||
|   mixins: [portalMixin], |  | ||||||
|   props: { |  | ||||||
|     portal: { |  | ||||||
|       type: Object, |  | ||||||
|       default: () => ({}), |  | ||||||
|     }, |  | ||||||
|     active: { |  | ||||||
|       type: Boolean, |  | ||||||
|       default: false, |  | ||||||
|     }, |  | ||||||
|     activePortalSlug: { |  | ||||||
|       type: String, |  | ||||||
|       default: '', |  | ||||||
|     }, |  | ||||||
|     activeLocale: { |  | ||||||
|       type: String, |  | ||||||
|       default: '', |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
|   emits: ['fetchPortal', 'openPortalPage'], |  | ||||||
|   data() { |  | ||||||
|     return { |  | ||||||
|       selectedLocale: null, |  | ||||||
|     }; |  | ||||||
|   }, |  | ||||||
|   computed: { |  | ||||||
|     locales() { |  | ||||||
|       return this.portal?.config?.allowed_locales; |  | ||||||
|     }, |  | ||||||
|     articlesCount() { |  | ||||||
|       const { allowed_locales: allowedLocales } = this.portal.config; |  | ||||||
|       return allowedLocales.reduce((acc, locale) => { |  | ||||||
|         return acc + locale.articles_count; |  | ||||||
|       }, 0); |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
|   mounted() { |  | ||||||
|     this.selectedLocale = this.locale || this.portal?.meta?.default_locale; |  | ||||||
|   }, |  | ||||||
|   methods: { |  | ||||||
|     onClick(event, code, portal) { |  | ||||||
|       event.preventDefault(); |  | ||||||
|       this.$router.push({ |  | ||||||
|         name: 'list_all_locale_articles', |  | ||||||
|         params: { |  | ||||||
|           portalSlug: portal.slug, |  | ||||||
|           locale: code, |  | ||||||
|         }, |  | ||||||
|       }); |  | ||||||
|       this.$emit('fetchPortal'); |  | ||||||
|       this.$emit('openPortalPage'); |  | ||||||
|     }, |  | ||||||
|     isLocaleActive(code, slug) { |  | ||||||
|       const isPortalActive = this.portal.slug === slug; |  | ||||||
|       const isLocaleActive = this.activeLocale === code; |  | ||||||
|       return isPortalActive && isLocaleActive; |  | ||||||
|     }, |  | ||||||
|     isLocaleDefault(code) { |  | ||||||
|       return this.portal?.meta?.default_locale === code; |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
| }; |  | ||||||
| </script> |  | ||||||
|  |  | ||||||
| <template> |  | ||||||
|   <div class="portal" :class="{ active }"> |  | ||||||
|     <Thumbnail :username="portal.name" variant="square" /> |  | ||||||
|     <div class="actions-container"> |  | ||||||
|       <header class="flex items-center justify-between mb-2.5"> |  | ||||||
|         <div> |  | ||||||
|           <h3 class="text-sm mb-0.5 text-slate-700 dark:text-slate-100"> |  | ||||||
|             {{ portal.name }} |  | ||||||
|           </h3> |  | ||||||
|           <p class="mb-0 text-xs text-slate-600 dark:text-slate-200"> |  | ||||||
|             {{ articlesCount }} |  | ||||||
|             {{ $t('HELP_CENTER.PORTAL.ARTICLES_LABEL') }} |  | ||||||
|           </p> |  | ||||||
|         </div> |  | ||||||
|         <woot-label |  | ||||||
|           v-if="active" |  | ||||||
|           variant="smooth" |  | ||||||
|           size="small" |  | ||||||
|           color-scheme="success" |  | ||||||
|           :title="$t('HELP_CENTER.PORTAL.ACTIVE_BADGE')" |  | ||||||
|         /> |  | ||||||
|       </header> |  | ||||||
|       <div class="portal-locales"> |  | ||||||
|         <h5 class="text-base text-slate-700 dark:text-slate-100"> |  | ||||||
|           {{ $t('HELP_CENTER.PORTAL.CHOOSE_LOCALE_LABEL') }} |  | ||||||
|         </h5> |  | ||||||
|         <ul> |  | ||||||
|           <li v-for="locale in locales" :key="locale.code"> |  | ||||||
|             <woot-button |  | ||||||
|               :variant="`locale-item ${ |  | ||||||
|                 isLocaleActive(locale.code, activePortalSlug) |  | ||||||
|                   ? 'smooth' |  | ||||||
|                   : 'clear' |  | ||||||
|               }`" |  | ||||||
|               size="large" |  | ||||||
|               color-scheme="secondary" |  | ||||||
|               @click="event => onClick(event, locale.code, portal)" |  | ||||||
|             > |  | ||||||
|               <div class="flex items-center justify-between w-full"> |  | ||||||
|                 <div class="meta"> |  | ||||||
|                   <h6 class="text-sm text-left mb-0.5"> |  | ||||||
|                     <span class="text-slate-700 dark:text-slate-100"> |  | ||||||
|                       {{ localeName(locale.code) }} |  | ||||||
|                     </span> |  | ||||||
|                     <span |  | ||||||
|                       v-if="isLocaleDefault(locale.code)" |  | ||||||
|                       class="text-sm text-slate-300 dark:text-slate-200" |  | ||||||
|                     > |  | ||||||
|                       {{ `(${$t('HELP_CENTER.PORTAL.DEFAULT')})` }} |  | ||||||
|                     </span> |  | ||||||
|                   </h6> |  | ||||||
|  |  | ||||||
|                   <span |  | ||||||
|                     class="flex w-full text-sm leading-4 text-left text-slate-600 dark:text-slate-200" |  | ||||||
|                   > |  | ||||||
|                     {{ locale.articles_count }} |  | ||||||
|                     {{ $t('HELP_CENTER.PORTAL.ARTICLES_LABEL') }} - |  | ||||||
|                     {{ locale.code }} |  | ||||||
|                   </span> |  | ||||||
|                 </div> |  | ||||||
|                 <div v-if="isLocaleActive(locale.code, activePortalSlug)"> |  | ||||||
|                   <fluent-icon icon="checkmark" class="locale__radio" /> |  | ||||||
|                 </div> |  | ||||||
|               </div> |  | ||||||
|             </woot-button> |  | ||||||
|           </li> |  | ||||||
|         </ul> |  | ||||||
|       </div> |  | ||||||
|     </div> |  | ||||||
|   </div> |  | ||||||
| </template> |  | ||||||
|  |  | ||||||
| <style lang="scss" scoped> |  | ||||||
| .portal { |  | ||||||
|   @apply bg-white dark:bg-slate-800 rounded-md p-4 relative flex mb-4 border border-solid border-slate-100 dark:border-slate-600; |  | ||||||
|  |  | ||||||
|   &.active { |  | ||||||
|     @apply bg-white dark:bg-slate-800 border border-solid border-woot-400 dark:border-woot-500; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   .actions-container { |  | ||||||
|     @apply ml-2.5 rtl:ml-0 rtl:mr-2.5 flex-grow; |  | ||||||
|  |  | ||||||
|     .portal-locales { |  | ||||||
|       ul { |  | ||||||
|         @apply list-none p-0 m-0; |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       .locale__radio { |  | ||||||
|         @apply w-8 text-green-600 dark:text-green-600; |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   .locale-item { |  | ||||||
|     @apply flex items-start py-1 px-4 rounded-md w-full mb-2; |  | ||||||
|  |  | ||||||
|     p { |  | ||||||
|       @apply mb-0 text-left; |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| </style> |  | ||||||
| @@ -1,96 +0,0 @@ | |||||||
| <script> |  | ||||||
| import SecondaryNavItem from 'dashboard/components/layout/sidebarComponents/SecondaryNavItem.vue'; |  | ||||||
| import SidebarHeader from './SidebarHeader.vue'; |  | ||||||
|  |  | ||||||
| export default { |  | ||||||
|   components: { |  | ||||||
|     SecondaryNavItem, |  | ||||||
|     SidebarHeader, |  | ||||||
|   }, |  | ||||||
|   props: { |  | ||||||
|     thumbnailSrc: { |  | ||||||
|       type: String, |  | ||||||
|       default: '', |  | ||||||
|     }, |  | ||||||
|     headerTitle: { |  | ||||||
|       type: String, |  | ||||||
|       default: '', |  | ||||||
|     }, |  | ||||||
|     subTitle: { |  | ||||||
|       type: String, |  | ||||||
|       default: '', |  | ||||||
|     }, |  | ||||||
|     portalSlug: { |  | ||||||
|       type: String, |  | ||||||
|       default: '', |  | ||||||
|     }, |  | ||||||
|     localeSlug: { |  | ||||||
|       type: String, |  | ||||||
|       default: '', |  | ||||||
|     }, |  | ||||||
|     accessibleMenuItems: { |  | ||||||
|       type: Array, |  | ||||||
|       default: () => [], |  | ||||||
|     }, |  | ||||||
|     additionalSecondaryMenuItems: { |  | ||||||
|       type: Array, |  | ||||||
|       default: () => [], |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
|   emits: ['openPopover', 'openModal'], |  | ||||||
|   computed: { |  | ||||||
|     hasCategory() { |  | ||||||
|       return ( |  | ||||||
|         this.additionalSecondaryMenuItems[0] && |  | ||||||
|         this.additionalSecondaryMenuItems[0].children.length > 0 |  | ||||||
|       ); |  | ||||||
|     }, |  | ||||||
|     portalLink() { |  | ||||||
|       return `/hc/${this.portalSlug}/${this.localeSlug}`; |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
|   methods: { |  | ||||||
|     openPortalPopover() { |  | ||||||
|       this.$emit('openPopover'); |  | ||||||
|     }, |  | ||||||
|     onClickOpenAddCatogoryModal() { |  | ||||||
|       this.$emit('openModal'); |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
| }; |  | ||||||
| </script> |  | ||||||
|  |  | ||||||
| <template> |  | ||||||
|   <div |  | ||||||
|     class="flex flex-col h-full overflow-auto text-sm bg-white border-r w-60 dark:bg-slate-900 dark:border-slate-700 rtl:border-r-0 rtl:border-l border-slate-50" |  | ||||||
|   > |  | ||||||
|     <SidebarHeader |  | ||||||
|       :thumbnail-src="thumbnailSrc" |  | ||||||
|       :header-title="headerTitle" |  | ||||||
|       :sub-title="subTitle" |  | ||||||
|       :portal-link="portalLink" |  | ||||||
|       class="px-4" |  | ||||||
|       @open-popover="openPortalPopover" |  | ||||||
|     /> |  | ||||||
|     <transition-group name="menu-list" tag="ul" class="p-2 mb-0 ml-0 list-none"> |  | ||||||
|       <SecondaryNavItem |  | ||||||
|         v-for="menuItem in accessibleMenuItems" |  | ||||||
|         :key="menuItem.toState" |  | ||||||
|         :menu-item="menuItem" |  | ||||||
|       /> |  | ||||||
|       <SecondaryNavItem |  | ||||||
|         v-for="menuItem in additionalSecondaryMenuItems" |  | ||||||
|         :key="menuItem.key" |  | ||||||
|         :menu-item="menuItem" |  | ||||||
|         @open="onClickOpenAddCatogoryModal" |  | ||||||
|       /> |  | ||||||
|       <p |  | ||||||
|         v-if="!hasCategory" |  | ||||||
|         key="empty-category-nessage" |  | ||||||
|         class="p-1.5 px-4 text-slate-300" |  | ||||||
|       > |  | ||||||
|         {{ $t('SIDEBAR.HELP_CENTER.CATEGORY_EMPTY_MESSAGE') }} |  | ||||||
|       </p> |  | ||||||
|     </transition-group> |  | ||||||
|   </div> |  | ||||||
| </template> |  | ||||||
| @@ -1,76 +0,0 @@ | |||||||
| <script> |  | ||||||
| import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue'; |  | ||||||
| export default { |  | ||||||
|   components: { |  | ||||||
|     Thumbnail, |  | ||||||
|   }, |  | ||||||
|   props: { |  | ||||||
|     thumbnailSrc: { |  | ||||||
|       type: String, |  | ||||||
|       default: '', |  | ||||||
|     }, |  | ||||||
|     headerTitle: { |  | ||||||
|       type: String, |  | ||||||
|       default: '', |  | ||||||
|     }, |  | ||||||
|     subTitle: { |  | ||||||
|       type: String, |  | ||||||
|       default: '', |  | ||||||
|     }, |  | ||||||
|     portalLink: { |  | ||||||
|       type: String, |  | ||||||
|       default: '', |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
|   emits: ['openPopover'], |  | ||||||
|   methods: { |  | ||||||
|     popoutHelpCenter() { |  | ||||||
|       window.open(this.portalLink, '_blank'); |  | ||||||
|     }, |  | ||||||
|     openPortalPopover() { |  | ||||||
|       this.$emit('openPopover'); |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
| }; |  | ||||||
| </script> |  | ||||||
|  |  | ||||||
| <template> |  | ||||||
|   <div |  | ||||||
|     class="flex items-center justify-between h-16 px-0 py-4 border-b mb-1/4 border-slate-50 dark:border-slate-700" |  | ||||||
|   > |  | ||||||
|     <div class="flex items-center"> |  | ||||||
|       <Thumbnail |  | ||||||
|         size="32px" |  | ||||||
|         :src="thumbnailSrc" |  | ||||||
|         :username="headerTitle" |  | ||||||
|         variant="square" |  | ||||||
|       /> |  | ||||||
|       <div class="flex flex-col items-start ml-2 rtl:ml-0 rtl:mr-2"> |  | ||||||
|         <h4 |  | ||||||
|           class="h-4 mb-0 overflow-hidden text-sm leading-4 w-28 whitespace-nowrap text-ellipsis text-slate-800 dark:text-slate-100" |  | ||||||
|         > |  | ||||||
|           {{ headerTitle }} |  | ||||||
|         </h4> |  | ||||||
|         <span class="h-4 text-xs leading-4 text-slate-600 dark:text-slate-200"> |  | ||||||
|           {{ subTitle }} |  | ||||||
|         </span> |  | ||||||
|       </div> |  | ||||||
|     </div> |  | ||||||
|     <div class="flex items-end"> |  | ||||||
|       <woot-button |  | ||||||
|         variant="clear" |  | ||||||
|         color-scheme="secondary" |  | ||||||
|         size="small" |  | ||||||
|         icon="arrow-up-right" |  | ||||||
|         @click="popoutHelpCenter" |  | ||||||
|       /> |  | ||||||
|       <woot-button |  | ||||||
|         variant="clear" |  | ||||||
|         size="small" |  | ||||||
|         color-scheme="secondary" |  | ||||||
|         icon="arrow-swap" |  | ||||||
|         @click="openPortalPopover" |  | ||||||
|       /> |  | ||||||
|     </div> |  | ||||||
|   </div> |  | ||||||
| </template> |  | ||||||
| @@ -1,226 +1,113 @@ | |||||||
| import HelpCenterLayout from './components/HelpCenterLayout.vue'; |  | ||||||
| import { getPortalRoute } from './helpers/routeHelper'; | import { getPortalRoute } from './helpers/routeHelper'; | ||||||
|  |  | ||||||
| const ListAllPortals = () => import('./pages/portals/ListAllPortals.vue'); | import HelpCenterPageRouteView from './pages/HelpCenterPageRouteView.vue'; | ||||||
| const NewPortal = () => import('./pages/portals/NewPortal.vue'); |  | ||||||
|  |  | ||||||
| const EditPortal = () => import('./pages/portals/EditPortal.vue'); | const PortalsIndex = () => import('./pages/PortalsIndexPage.vue'); | ||||||
| const EditPortalBasic = () => import('./pages/portals/EditPortalBasic.vue'); | const PortalsNew = () => import('./pages/PortalsNewPage.vue'); | ||||||
| const EditPortalCustomization = () => |  | ||||||
|   import('./pages/portals/EditPortalCustomization.vue'); |  | ||||||
| const EditPortalLocales = () => import('./pages/portals/EditPortalLocales.vue'); |  | ||||||
| const ShowPortal = () => import('./pages/portals/ShowPortal.vue'); |  | ||||||
| const PortalDetails = () => import('./pages/portals/PortalDetails.vue'); |  | ||||||
| const PortalCustomization = () => |  | ||||||
|   import('./pages/portals/PortalCustomization.vue'); |  | ||||||
| const PortalSettingsFinish = () => |  | ||||||
|   import('./pages/portals/PortalSettingsFinish.vue'); |  | ||||||
|  |  | ||||||
| const ListAllCategories = () => | const PortalsArticlesIndexPage = () => | ||||||
|   import('./pages/categories/ListAllCategories.vue'); |   import('./pages/PortalsArticlesIndexPage.vue'); | ||||||
| const NewCategory = () => import('./pages/categories/NewCategory.vue'); | const PortalsArticlesNewPage = () => | ||||||
| const EditCategory = () => import('./pages/categories/EditCategory.vue'); |   import('./pages/PortalsArticlesNewPage.vue'); | ||||||
| const ListCategoryArticles = () => | const PortalsArticlesEditPage = () => | ||||||
|   import('./pages/articles/ListCategoryArticles.vue'); |   import('./pages/PortalsArticlesEditPage.vue'); | ||||||
| const ListAllArticles = () => import('./pages/articles/ListAllArticles.vue'); |  | ||||||
| const DefaultPortalArticles = () => | const PortalsCategoriesIndexPage = () => | ||||||
|   import('./pages/articles/DefaultPortalArticles.vue'); |   import('./pages/PortalsCategoriesIndexPage.vue'); | ||||||
| const NewArticle = () => import('./pages/articles/NewArticle.vue'); |  | ||||||
| const EditArticle = () => import('./pages/articles/EditArticle.vue'); | const PortalsLocalesIndexPage = () => | ||||||
|  |   import('./pages/PortalsLocalesIndexPage.vue'); | ||||||
|  |  | ||||||
|  | const PortalsSettingsIndexPage = () => | ||||||
|  |   import('./pages/PortalsSettingsIndexPage.vue'); | ||||||
|  |  | ||||||
| const portalRoutes = [ | const portalRoutes = [ | ||||||
|   { |   { | ||||||
|     path: getPortalRoute(''), |     path: getPortalRoute(':portalSlug/:locale/:categorySlug?/articles/:tab?'), | ||||||
|     name: 'default_portal_articles', |     name: 'portals_articles_index', | ||||||
|     meta: { |  | ||||||
|       permissions: ['administrator', 'knowledge_base_manage'], |  | ||||||
|     }, |  | ||||||
|     component: DefaultPortalArticles, |  | ||||||
|   }, |  | ||||||
|   { |  | ||||||
|     path: getPortalRoute('all'), |  | ||||||
|     name: 'list_all_portals', |  | ||||||
|     meta: { |     meta: { | ||||||
|       permissions: ['administrator', 'agent', 'knowledge_base_manage'], |       permissions: ['administrator', 'agent', 'knowledge_base_manage'], | ||||||
|     }, |     }, | ||||||
|     component: ListAllPortals, |     component: PortalsArticlesIndexPage, | ||||||
|   }, |  | ||||||
|   { |  | ||||||
|     path: getPortalRoute('new'), |  | ||||||
|     component: NewPortal, |  | ||||||
|     children: [ |  | ||||||
|       { |  | ||||||
|         path: '', |  | ||||||
|         name: 'new_portal_information', |  | ||||||
|         component: PortalDetails, |  | ||||||
|         meta: { |  | ||||||
|           permissions: ['administrator', 'knowledge_base_manage'], |  | ||||||
|         }, |  | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         path: ':portalSlug/customization', |  | ||||||
|         name: 'portal_customization', |  | ||||||
|         component: PortalCustomization, |  | ||||||
|         meta: { |  | ||||||
|           permissions: ['administrator', 'knowledge_base_manage'], |  | ||||||
|         }, |  | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         path: ':portalSlug/finish', |  | ||||||
|         name: 'portal_finish', |  | ||||||
|         component: PortalSettingsFinish, |  | ||||||
|         meta: { |  | ||||||
|           permissions: ['administrator', 'knowledge_base_manage'], |  | ||||||
|         }, |  | ||||||
|       }, |  | ||||||
|     ], |  | ||||||
|   }, |  | ||||||
|   { |  | ||||||
|     path: getPortalRoute(':portalSlug'), |  | ||||||
|     name: 'portalSlug', |  | ||||||
|     meta: { |  | ||||||
|       permissions: ['administrator', 'agent', 'knowledge_base_manage'], |  | ||||||
|     }, |  | ||||||
|     component: ShowPortal, |  | ||||||
|   }, |  | ||||||
|   { |  | ||||||
|     path: getPortalRoute(':portalSlug/edit'), |  | ||||||
|     meta: { |  | ||||||
|       permissions: ['administrator', 'agent', 'knowledge_base_manage'], |  | ||||||
|     }, |  | ||||||
|     component: EditPortal, |  | ||||||
|     children: [ |  | ||||||
|       { |  | ||||||
|         path: '', |  | ||||||
|         name: 'edit_portal_information', |  | ||||||
|         component: EditPortalBasic, |  | ||||||
|         meta: { |  | ||||||
|           permissions: ['administrator', 'knowledge_base_manage'], |  | ||||||
|         }, |  | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         path: 'customizations', |  | ||||||
|         name: 'edit_portal_customization', |  | ||||||
|         component: EditPortalCustomization, |  | ||||||
|         meta: { |  | ||||||
|           permissions: ['administrator', 'knowledge_base_manage'], |  | ||||||
|         }, |  | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         path: 'locales', |  | ||||||
|         name: 'edit_portal_locales', |  | ||||||
|         component: EditPortalLocales, |  | ||||||
|         meta: { |  | ||||||
|           permissions: ['administrator', 'knowledge_base_manage'], |  | ||||||
|         }, |  | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         path: 'categories', |  | ||||||
|         name: 'list_all_locale_categories', |  | ||||||
|         meta: { |  | ||||||
|           permissions: ['administrator', 'agent', 'knowledge_base_manage'], |  | ||||||
|         }, |  | ||||||
|         component: ListAllCategories, |  | ||||||
|       }, |  | ||||||
|     ], |  | ||||||
|   }, |  | ||||||
| ]; |  | ||||||
|  |  | ||||||
| const articleRoutes = [ |  | ||||||
|   { |  | ||||||
|     path: getPortalRoute(':portalSlug/:locale/articles'), |  | ||||||
|     name: 'list_all_locale_articles', |  | ||||||
|     meta: { |  | ||||||
|       permissions: ['administrator', 'agent', 'knowledge_base_manage'], |  | ||||||
|     }, |  | ||||||
|     component: ListAllArticles, |  | ||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|     path: getPortalRoute(':portalSlug/:locale/articles/new'), |     path: getPortalRoute(':portalSlug/:locale/articles/new'), | ||||||
|     name: 'new_article', |     name: 'portals_articles_new', | ||||||
|     meta: { |     meta: { | ||||||
|       permissions: ['administrator', 'agent', 'knowledge_base_manage'], |       permissions: ['administrator', 'agent', 'knowledge_base_manage'], | ||||||
|     }, |     }, | ||||||
|     component: NewArticle, |     component: PortalsArticlesNewPage, | ||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|     path: getPortalRoute(':portalSlug/:locale/articles/mine'), |     path: getPortalRoute( | ||||||
|     name: 'list_mine_articles', |       ':portalSlug/:locale/:categorySlug?/articles/:tab?/edit/:articleSlug' | ||||||
|  |     ), | ||||||
|  |     name: 'portals_articles_edit', | ||||||
|     meta: { |     meta: { | ||||||
|       permissions: ['administrator', 'agent', 'knowledge_base_manage'], |       permissions: ['administrator', 'agent', 'knowledge_base_manage'], | ||||||
|     }, |     }, | ||||||
|     component: ListAllArticles, |     component: PortalsArticlesEditPage, | ||||||
|   }, |  | ||||||
|   { |  | ||||||
|     path: getPortalRoute(':portalSlug/:locale/articles/archived'), |  | ||||||
|     name: 'list_archived_articles', |  | ||||||
|     meta: { |  | ||||||
|       permissions: ['administrator', 'agent', 'knowledge_base_manage'], |  | ||||||
|     }, |  | ||||||
|     component: ListAllArticles, |  | ||||||
|   }, |   }, | ||||||
|  |  | ||||||
|   { |  | ||||||
|     path: getPortalRoute(':portalSlug/:locale/articles/draft'), |  | ||||||
|     name: 'list_draft_articles', |  | ||||||
|     meta: { |  | ||||||
|       permissions: ['administrator', 'agent', 'knowledge_base_manage'], |  | ||||||
|     }, |  | ||||||
|     component: ListAllArticles, |  | ||||||
|   }, |  | ||||||
|  |  | ||||||
|   { |  | ||||||
|     path: getPortalRoute(':portalSlug/:locale/articles/:articleSlug'), |  | ||||||
|     name: 'edit_article', |  | ||||||
|     meta: { |  | ||||||
|       permissions: ['administrator', 'agent', 'knowledge_base_manage'], |  | ||||||
|     }, |  | ||||||
|     component: EditArticle, |  | ||||||
|   }, |  | ||||||
| ]; |  | ||||||
|  |  | ||||||
| const categoryRoutes = [ |  | ||||||
|   { |   { | ||||||
|     path: getPortalRoute(':portalSlug/:locale/categories'), |     path: getPortalRoute(':portalSlug/:locale/categories'), | ||||||
|     name: 'all_locale_categories', |     name: 'portals_categories_index', | ||||||
|     meta: { |     meta: { | ||||||
|       permissions: ['administrator', 'agent', 'knowledge_base_manage'], |       permissions: ['administrator', 'agent', 'knowledge_base_manage'], | ||||||
|     }, |     }, | ||||||
|     component: ListAllCategories, |     component: PortalsCategoriesIndexPage, | ||||||
|   }, |  | ||||||
|   { |  | ||||||
|     path: getPortalRoute(':portalSlug/:locale/categories/new'), |  | ||||||
|     name: 'new_category_in_locale', |  | ||||||
|     meta: { |  | ||||||
|       permissions: ['administrator', 'agent', 'knowledge_base_manage'], |  | ||||||
|     }, |  | ||||||
|     component: NewCategory, |  | ||||||
|   }, |  | ||||||
|   { |  | ||||||
|     path: getPortalRoute(':portalSlug/:locale/categories/:categorySlug'), |  | ||||||
|     name: 'show_category', |  | ||||||
|     meta: { |  | ||||||
|       permissions: ['administrator', 'agent', 'knowledge_base_manage'], |  | ||||||
|     }, |  | ||||||
|     component: ListAllArticles, |  | ||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|     path: getPortalRoute( |     path: getPortalRoute( | ||||||
|       ':portalSlug/:locale/categories/:categorySlug/articles' |       ':portalSlug/:locale/categories/:categorySlug/articles' | ||||||
|     ), |     ), | ||||||
|     name: 'show_category_articles', |     name: 'portals_categories_articles_index', | ||||||
|     meta: { |     meta: { | ||||||
|       permissions: ['administrator', 'agent', 'knowledge_base_manage'], |       permissions: ['administrator', 'agent', 'knowledge_base_manage'], | ||||||
|     }, |     }, | ||||||
|     component: ListCategoryArticles, |     component: PortalsArticlesIndexPage, | ||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|     path: getPortalRoute(':portalSlug/:locale/categories/:categorySlug'), |     path: getPortalRoute( | ||||||
|     name: 'edit_category', |       ':portalSlug/:locale/categories/:categorySlug/articles/:articleSlug' | ||||||
|  |     ), | ||||||
|  |     name: 'portals_categories_articles_edit', | ||||||
|     meta: { |     meta: { | ||||||
|       permissions: ['administrator', 'agent', 'knowledge_base_manage'], |       permissions: ['administrator', 'agent', 'knowledge_base_manage'], | ||||||
|     }, |     }, | ||||||
|     component: EditCategory, |     component: PortalsArticlesEditPage, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     path: getPortalRoute(':portalSlug/locales'), | ||||||
|  |     name: 'portals_locales_index', | ||||||
|  |     meta: { | ||||||
|  |       permissions: ['administrator', 'agent', 'knowledge_base_manage'], | ||||||
|  |     }, | ||||||
|  |     component: PortalsLocalesIndexPage, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     path: getPortalRoute(':portalSlug/settings'), | ||||||
|  |     name: 'portals_settings_index', | ||||||
|  |     meta: { | ||||||
|  |       permissions: ['administrator', 'agent', 'knowledge_base_manage'], | ||||||
|  |     }, | ||||||
|  |     component: PortalsSettingsIndexPage, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     path: getPortalRoute('new'), | ||||||
|  |     name: 'portals_new', | ||||||
|  |     meta: { | ||||||
|  |       permissions: ['administrator', 'knowledge_base_manage'], | ||||||
|  |     }, | ||||||
|  |     component: PortalsNew, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     path: getPortalRoute(':navigationPath'), | ||||||
|  |     name: 'portals_index', | ||||||
|  |     meta: { | ||||||
|  |       permissions: ['administrator', 'knowledge_base_manage'], | ||||||
|  |     }, | ||||||
|  |     component: PortalsIndex, | ||||||
|   }, |   }, | ||||||
| ]; | ]; | ||||||
|  |  | ||||||
| @@ -228,8 +115,8 @@ export default { | |||||||
|   routes: [ |   routes: [ | ||||||
|     { |     { | ||||||
|       path: getPortalRoute(), |       path: getPortalRoute(), | ||||||
|       component: HelpCenterLayout, |       component: HelpCenterPageRouteView, | ||||||
|       children: [...portalRoutes, ...articleRoutes, ...categoryRoutes], |       children: [...portalRoutes], | ||||||
|     }, |     }, | ||||||
|   ], |   ], | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| import { frontendURL } from '../../../../helper/URLHelper'; | import { frontendURL } from 'dashboard/helper/URLHelper'; | ||||||
|  |  | ||||||
| export const getPortalRoute = (path = '') => { | export const getPortalRoute = (path = '') => { | ||||||
|   const slugToBeAdded = path ? `/${path}` : ''; |   const slugToBeAdded = path ? `/${path}` : ''; | ||||||
|   | |||||||
| @@ -1,24 +0,0 @@ | |||||||
| import { mapGetters } from 'vuex'; |  | ||||||
| import { frontendURL } from 'dashboard/helper/URLHelper'; |  | ||||||
| import allLocales from 'shared/constants/locales.js'; |  | ||||||
| export default { |  | ||||||
|   computed: { |  | ||||||
|     ...mapGetters({ accountId: 'getCurrentAccountId' }), |  | ||||||
|     portalSlug() { |  | ||||||
|       return this.$route.params.portalSlug; |  | ||||||
|     }, |  | ||||||
|     locale() { |  | ||||||
|       return this.$route.params.locale; |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
|   methods: { |  | ||||||
|     articleUrl(id) { |  | ||||||
|       return frontendURL( |  | ||||||
|         `accounts/${this.accountId}/portals/${this.portalSlug}/${this.locale}/articles/${id}` |  | ||||||
|       ); |  | ||||||
|     }, |  | ||||||
|     localeName(code) { |  | ||||||
|       return allLocales[code]; |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
| }; |  | ||||||
| @@ -1,78 +0,0 @@ | |||||||
| import { shallowMount } from '@vue/test-utils'; |  | ||||||
| import { createStore } from 'vuex'; |  | ||||||
| import { createRouter, createWebHistory } from 'vue-router'; |  | ||||||
| import portalMixin from '../portalMixin'; |  | ||||||
| import ListAllArticles from '../../pages/portals/ListAllPortals.vue'; |  | ||||||
|  |  | ||||||
| // Create router instance |  | ||||||
| const router = createRouter({ |  | ||||||
|   history: createWebHistory(), |  | ||||||
|   routes: [ |  | ||||||
|     { |  | ||||||
|       path: '/:portalSlug/:locale/articles', // Add leading "/" |  | ||||||
|       name: 'list_all_locale_articles', |  | ||||||
|       component: ListAllArticles, |  | ||||||
|     }, |  | ||||||
|   ], |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| describe('portalMixin', () => { |  | ||||||
|   let getters; |  | ||||||
|   let store; |  | ||||||
|   let wrapper; |  | ||||||
|  |  | ||||||
|   beforeEach(() => { |  | ||||||
|     getters = { |  | ||||||
|       getCurrentAccountId: () => 1, |  | ||||||
|     }; |  | ||||||
|     const Component = { |  | ||||||
|       render() {}, |  | ||||||
|       title: 'TestComponent', |  | ||||||
|       mixins: [portalMixin], |  | ||||||
|     }; |  | ||||||
|     store = createStore({ getters }); |  | ||||||
|     wrapper = shallowMount(Component, { |  | ||||||
|       global: { |  | ||||||
|         plugins: [store, router], |  | ||||||
|       }, |  | ||||||
|     }); |  | ||||||
|   }); |  | ||||||
|  |  | ||||||
|   it('returns account id', () => { |  | ||||||
|     expect(wrapper.vm.accountId).toBe(1); |  | ||||||
|   }); |  | ||||||
|  |  | ||||||
|   it('returns article url', async () => { |  | ||||||
|     await router.push({ |  | ||||||
|       name: 'list_all_locale_articles', |  | ||||||
|       params: { portalSlug: 'fur-rent', locale: 'en' }, |  | ||||||
|     }); |  | ||||||
|     expect(wrapper.vm.articleUrl(1)).toBe( |  | ||||||
|       '/app/accounts/1/portals/fur-rent/en/articles/1' |  | ||||||
|     ); |  | ||||||
|   }); |  | ||||||
|  |  | ||||||
|   it('returns portal locale', async () => { |  | ||||||
|     await router.push({ |  | ||||||
|       name: 'list_all_locale_articles', |  | ||||||
|       params: { portalSlug: 'fur-rent', locale: 'es' }, |  | ||||||
|     }); |  | ||||||
|     expect(wrapper.vm.portalSlug).toBe('fur-rent'); |  | ||||||
|   }); |  | ||||||
|  |  | ||||||
|   it('returns portal slug', async () => { |  | ||||||
|     await router.push({ |  | ||||||
|       name: 'list_all_locale_articles', |  | ||||||
|       params: { portalSlug: 'campaign', locale: 'es' }, |  | ||||||
|     }); |  | ||||||
|     expect(wrapper.vm.portalSlug).toBe('campaign'); |  | ||||||
|   }); |  | ||||||
|  |  | ||||||
|   it('returns locale name', async () => { |  | ||||||
|     await router.push({ |  | ||||||
|       name: 'list_all_locale_articles', |  | ||||||
|       params: { portalSlug: 'fur-rent', locale: 'es' }, |  | ||||||
|     }); |  | ||||||
|     expect(wrapper.vm.localeName('es')).toBe('Spanish'); |  | ||||||
|   }); |  | ||||||
| }); |  | ||||||
| @@ -0,0 +1,76 @@ | |||||||
|  | <script setup> | ||||||
|  | import { computed, onMounted, watch } from 'vue'; | ||||||
|  | import { useRoute } from 'vue-router'; | ||||||
|  | import { useStore } from 'vuex'; | ||||||
|  | import UpgradePage from '../components/UpgradePage.vue'; | ||||||
|  | import { useUISettings } from 'dashboard/composables/useUISettings'; | ||||||
|  | import { FEATURE_FLAGS } from 'dashboard/featureFlags'; | ||||||
|  |  | ||||||
|  | const route = useRoute(); | ||||||
|  | const store = useStore(); | ||||||
|  | const { uiSettings, updateUISettings } = useUISettings(); | ||||||
|  |  | ||||||
|  | const accountId = computed(() => store.getters.getCurrentAccountId); | ||||||
|  | const portals = computed(() => store.getters['portals/allPortals']); | ||||||
|  | const isFeatureEnabledonAccount = (id, flag) => | ||||||
|  |   store.getters['accounts/isFeatureEnabledonAccount'](id, flag); | ||||||
|  |  | ||||||
|  | const isHelpCenterEnabled = computed(() => | ||||||
|  |   isFeatureEnabledonAccount(accountId.value, FEATURE_FLAGS.HELP_CENTER) | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const selectedPortal = computed(() => { | ||||||
|  |   const slug = | ||||||
|  |     route.params.portalSlug || uiSettings.value.last_active_portal_slug; | ||||||
|  |   if (slug) return store.getters['portals/portalBySlug'](slug); | ||||||
|  |   return portals.value[0]; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const defaultPortalLocale = computed(() => | ||||||
|  |   selectedPortal.value ? selectedPortal.value.meta?.default_locale : '' | ||||||
|  | ); | ||||||
|  | const selectedLocaleInPortal = computed( | ||||||
|  |   () => route.params.locale || defaultPortalLocale.value | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const selectedPortalSlug = computed(() => | ||||||
|  |   selectedPortal.value ? selectedPortal.value.slug : '' | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const fetchPortalAndItsCategories = async () => { | ||||||
|  |   await store.dispatch('portals/index'); | ||||||
|  |   const selectedPortalParam = { | ||||||
|  |     portalSlug: selectedPortalSlug.value, | ||||||
|  |     locale: selectedLocaleInPortal.value, | ||||||
|  |   }; | ||||||
|  |   store.dispatch('portals/show', selectedPortalParam); | ||||||
|  |   store.dispatch('categories/index', selectedPortalParam); | ||||||
|  |   store.dispatch('agents/get'); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | onMounted(() => fetchPortalAndItsCategories()); | ||||||
|  |  | ||||||
|  | watch( | ||||||
|  |   () => route.params.portalSlug, | ||||||
|  |   newSlug => { | ||||||
|  |     if (newSlug && newSlug !== uiSettings.value.last_active_portal_slug) { | ||||||
|  |       updateUISettings({ | ||||||
|  |         last_active_portal_slug: newSlug, | ||||||
|  |         last_active_locale_code: selectedLocaleInPortal.value, | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | ); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <div class="flex flex-grow-0 w-full h-full min-h-0 app-wrapper"> | ||||||
|  |     <section | ||||||
|  |       v-if="isHelpCenterEnabled" | ||||||
|  |       class="flex flex-1 h-full px-0 overflow-hidden bg-white dark:bg-slate-900" | ||||||
|  |     > | ||||||
|  |       <router-view /> | ||||||
|  |     </section> | ||||||
|  |     <UpgradePage v-else /> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,109 @@ | |||||||
|  | <script setup> | ||||||
|  | import { ref, computed, onMounted } from 'vue'; | ||||||
|  | import { useRoute, useRouter } from 'vue-router'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  | import { useAlert, useTrack } from 'dashboard/composables'; | ||||||
|  | import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; | ||||||
|  | import { buildPortalArticleURL } from 'dashboard/helper/portalHelper'; | ||||||
|  | import { useStore, useMapGetter } from 'dashboard/composables/store'; | ||||||
|  |  | ||||||
|  | import ArticleEditor from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue'; | ||||||
|  |  | ||||||
|  | const route = useRoute(); | ||||||
|  | const router = useRouter(); | ||||||
|  | const store = useStore(); | ||||||
|  | const { t } = useI18n(); | ||||||
|  |  | ||||||
|  | const { articleSlug, portalSlug } = route.params; | ||||||
|  |  | ||||||
|  | const articleById = useMapGetter('articles/articleById'); | ||||||
|  |  | ||||||
|  | const article = computed(() => articleById.value(articleSlug)); | ||||||
|  |  | ||||||
|  | const isUpdating = ref(false); | ||||||
|  | const isSaved = ref(false); | ||||||
|  |  | ||||||
|  | const portalLink = computed(() => { | ||||||
|  |   const { slug: categorySlug, locale: categoryLocale } = article.value.category; | ||||||
|  |   const { slug: articleSlugValue } = article.value; | ||||||
|  |   return buildPortalArticleURL( | ||||||
|  |     portalSlug, | ||||||
|  |     categorySlug, | ||||||
|  |     categoryLocale, | ||||||
|  |     articleSlugValue | ||||||
|  |   ); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const saveArticle = async ({ ...values }) => { | ||||||
|  |   isUpdating.value = true; | ||||||
|  |   try { | ||||||
|  |     await store.dispatch('articles/update', { | ||||||
|  |       portalSlug, | ||||||
|  |       articleId: articleSlug, | ||||||
|  |       ...values, | ||||||
|  |     }); | ||||||
|  |     isSaved.value = true; | ||||||
|  |   } catch (error) { | ||||||
|  |     const errorMessage = | ||||||
|  |       error?.message || t('HELP_CENTER.EDIT_ARTICLE_PAGE.API.ERROR'); | ||||||
|  |     useAlert(errorMessage); | ||||||
|  |   } finally { | ||||||
|  |     setTimeout(() => { | ||||||
|  |       isUpdating.value = false; | ||||||
|  |       isSaved.value = true; | ||||||
|  |     }, 1500); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const isCategoryArticles = computed(() => { | ||||||
|  |   return ( | ||||||
|  |     route.name === 'portals_categories_articles_index' || | ||||||
|  |     route.name === 'portals_categories_articles_edit' || | ||||||
|  |     route.name === 'portals_categories_index' | ||||||
|  |   ); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const goBackToArticles = () => { | ||||||
|  |   const { tab, categorySlug, locale } = route.params; | ||||||
|  |   if (isCategoryArticles.value) { | ||||||
|  |     router.push({ | ||||||
|  |       name: 'portals_categories_articles_index', | ||||||
|  |       params: { categorySlug, locale }, | ||||||
|  |     }); | ||||||
|  |   } else { | ||||||
|  |     router.push({ | ||||||
|  |       name: 'portals_articles_index', | ||||||
|  |       params: { tab, categorySlug, locale }, | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const fetchArticleDetails = () => { | ||||||
|  |   store.dispatch('articles/show', { | ||||||
|  |     id: articleSlug, | ||||||
|  |     portalSlug, | ||||||
|  |   }); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const previewArticle = () => { | ||||||
|  |   window.open(portalLink.value, '_blank'); | ||||||
|  |   useTrack(PORTALS_EVENTS.PREVIEW_ARTICLE, { | ||||||
|  |     status: article.value?.status, | ||||||
|  |   }); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | onMounted(() => { | ||||||
|  |   fetchArticleDetails(); | ||||||
|  | }); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <ArticleEditor | ||||||
|  |     :article="article" | ||||||
|  |     :is-updating="isUpdating" | ||||||
|  |     :is-saved="isSaved" | ||||||
|  |     @save-article="saveArticle" | ||||||
|  |     @preview-article="previewArticle" | ||||||
|  |     @go-back="goBackToArticles" | ||||||
|  |   /> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,116 @@ | |||||||
|  | <script setup> | ||||||
|  | import { computed, ref, onMounted, watch } from 'vue'; | ||||||
|  | import { useRoute } from 'vue-router'; | ||||||
|  | import { useMapGetter, useStore } from 'dashboard/composables/store.js'; | ||||||
|  | import allLocales from 'shared/constants/locales.js'; | ||||||
|  | import { getArticleStatus } from 'dashboard/helper/portalHelper.js'; | ||||||
|  | import ArticlesPage from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue'; | ||||||
|  |  | ||||||
|  | const route = useRoute(); | ||||||
|  | const store = useStore(); | ||||||
|  |  | ||||||
|  | const pageNumber = ref(1); | ||||||
|  |  | ||||||
|  | const articles = useMapGetter('articles/allArticles'); | ||||||
|  | const categories = useMapGetter('categories/allCategories'); | ||||||
|  | const meta = useMapGetter('articles/getMeta'); | ||||||
|  | const portalMeta = useMapGetter('portals/getMeta'); | ||||||
|  | const currentUserId = useMapGetter('getCurrentUserID'); | ||||||
|  | const getPortalBySlug = useMapGetter('portals/portalBySlug'); | ||||||
|  |  | ||||||
|  | const selectedPortalSlug = computed(() => route.params.portalSlug); | ||||||
|  | const selectedCategorySlug = computed(() => route.params.categorySlug); | ||||||
|  | const status = computed(() => getArticleStatus(route.params.tab)); | ||||||
|  |  | ||||||
|  | const author = computed(() => | ||||||
|  |   route.params.tab === 'mine' ? currentUserId.value : null | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const activeLocale = computed(() => route.params.locale); | ||||||
|  | const portal = computed(() => getPortalBySlug.value(selectedPortalSlug.value)); | ||||||
|  | const allowedLocales = computed(() => { | ||||||
|  |   if (!portal.value) { | ||||||
|  |     return []; | ||||||
|  |   } | ||||||
|  |   const { allowed_locales: allAllowedLocales } = portal.value.config; | ||||||
|  |   return allAllowedLocales.map(locale => { | ||||||
|  |     return { | ||||||
|  |       id: locale.code, | ||||||
|  |       name: allLocales[locale.code], | ||||||
|  |       code: locale.code, | ||||||
|  |     }; | ||||||
|  |   }); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const defaultPortalLocale = computed(() => { | ||||||
|  |   return portal.value?.meta?.default_locale; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const selectedLocaleInPortal = computed(() => { | ||||||
|  |   return route.params.locale || defaultPortalLocale.value; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const isCategoryArticles = computed(() => { | ||||||
|  |   return ( | ||||||
|  |     route.name === 'portals_categories_articles_index' || | ||||||
|  |     route.name === 'portals_categories_articles_edit' || | ||||||
|  |     route.name === 'portals_categories_index' | ||||||
|  |   ); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const fetchArticles = ({ pageNumber: pageNumberParam } = {}) => { | ||||||
|  |   store.dispatch('articles/index', { | ||||||
|  |     pageNumber: pageNumberParam || pageNumber.value, | ||||||
|  |     portalSlug: selectedPortalSlug.value, | ||||||
|  |     locale: activeLocale.value, | ||||||
|  |     status: status.value, | ||||||
|  |     authorId: author.value, | ||||||
|  |     categorySlug: selectedCategorySlug.value, | ||||||
|  |   }); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const onPageChange = pageNumberParam => { | ||||||
|  |   fetchArticles({ pageNumber: pageNumberParam }); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const fetchPortalAndItsCategories = async locale => { | ||||||
|  |   await store.dispatch('portals/index'); | ||||||
|  |   const selectedPortalParam = { | ||||||
|  |     portalSlug: selectedPortalSlug.value, | ||||||
|  |     locale: locale || selectedLocaleInPortal.value, | ||||||
|  |   }; | ||||||
|  |   store.dispatch('portals/show', selectedPortalParam); | ||||||
|  |   store.dispatch('categories/index', selectedPortalParam); | ||||||
|  |   store.dispatch('agents/get'); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | onMounted(() => { | ||||||
|  |   fetchArticles(); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | watch( | ||||||
|  |   () => route.params, | ||||||
|  |   () => { | ||||||
|  |     pageNumber.value = 1; | ||||||
|  |     fetchArticles(); | ||||||
|  |   }, | ||||||
|  |   { deep: true, immediate: true } | ||||||
|  | ); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <div class="w-full h-full"> | ||||||
|  |     <ArticlesPage | ||||||
|  |       v-if="portal" | ||||||
|  |       :articles="articles" | ||||||
|  |       :portal-name="portal.name" | ||||||
|  |       :categories="categories" | ||||||
|  |       :allowed-locales="allowedLocales" | ||||||
|  |       :meta="meta" | ||||||
|  |       :portal-meta="portalMeta" | ||||||
|  |       :is-category-articles="isCategoryArticles" | ||||||
|  |       @page-change="onPageChange" | ||||||
|  |       @fetch-portal="fetchPortalAndItsCategories" | ||||||
|  |     /> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,94 @@ | |||||||
|  | <script setup> | ||||||
|  | import { ref, computed } from 'vue'; | ||||||
|  | import { useRoute, useRouter } from 'vue-router'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  | import { useAlert, useTrack } from 'dashboard/composables'; | ||||||
|  | import { useStore, useMapGetter } from 'dashboard/composables/store'; | ||||||
|  | import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; | ||||||
|  |  | ||||||
|  | import ArticleEditor from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue'; | ||||||
|  |  | ||||||
|  | const route = useRoute(); | ||||||
|  | const router = useRouter(); | ||||||
|  | const store = useStore(); | ||||||
|  | const { t } = useI18n(); | ||||||
|  |  | ||||||
|  | const { portalSlug } = route.params; | ||||||
|  |  | ||||||
|  | const selectedAuthorId = ref(null); | ||||||
|  | const selectedCategoryId = ref(null); | ||||||
|  |  | ||||||
|  | const currentUserId = useMapGetter('getCurrentUserID'); | ||||||
|  | const categories = useMapGetter('categories/allCategories'); | ||||||
|  |  | ||||||
|  | const categoryId = computed(() => categories.value[0]?.id || null); | ||||||
|  |  | ||||||
|  | const article = ref({}); | ||||||
|  | const isUpdating = ref(false); | ||||||
|  | const isSaved = ref(false); | ||||||
|  |  | ||||||
|  | const setAuthorId = authorId => { | ||||||
|  |   selectedAuthorId.value = authorId; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const setCategoryId = newCategoryId => { | ||||||
|  |   selectedCategoryId.value = newCategoryId; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const createNewArticle = async ({ title, content }) => { | ||||||
|  |   if (title) article.value.title = title; | ||||||
|  |   if (content) article.value.content = content; | ||||||
|  |  | ||||||
|  |   if (!article.value.title || !article.value.content) return; | ||||||
|  |  | ||||||
|  |   isUpdating.value = true; | ||||||
|  |   try { | ||||||
|  |     const { locale } = route.params; | ||||||
|  |     const articleId = await store.dispatch('articles/create', { | ||||||
|  |       portalSlug, | ||||||
|  |       content: article.value.content, | ||||||
|  |       title: article.value.title, | ||||||
|  |       locale: locale, | ||||||
|  |       authorId: selectedAuthorId.value || currentUserId.value, | ||||||
|  |       categoryId: selectedCategoryId.value || categoryId.value, | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     useTrack(PORTALS_EVENTS.CREATE_ARTICLE, { locale }); | ||||||
|  |  | ||||||
|  |     router.replace({ | ||||||
|  |       name: 'portals_articles_edit', | ||||||
|  |       params: { | ||||||
|  |         articleSlug: articleId, | ||||||
|  |         portalSlug, | ||||||
|  |         locale, | ||||||
|  |       }, | ||||||
|  |     }); | ||||||
|  |   } catch (error) { | ||||||
|  |     const errorMessage = | ||||||
|  |       error?.message || t('HELP_CENTER.EDIT_ARTICLE_PAGE.API.ERROR'); | ||||||
|  |     useAlert(errorMessage); | ||||||
|  |   } finally { | ||||||
|  |     isUpdating.value = false; | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const goBackToArticles = () => { | ||||||
|  |   const { tab, categorySlug, locale } = route.params; | ||||||
|  |   router.push({ | ||||||
|  |     name: 'portals_articles_index', | ||||||
|  |     params: { tab, categorySlug, locale }, | ||||||
|  |   }); | ||||||
|  | }; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <ArticleEditor | ||||||
|  |     :article="article" | ||||||
|  |     :is-updating="isUpdating" | ||||||
|  |     :is-saved="isSaved" | ||||||
|  |     @save-article="createNewArticle" | ||||||
|  |     @go-back="goBackToArticles" | ||||||
|  |     @set-author="setAuthorId" | ||||||
|  |     @set-category="setCategoryId" | ||||||
|  |   /> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,66 @@ | |||||||
|  | <script setup> | ||||||
|  | import { computed, onMounted } from 'vue'; | ||||||
|  | import { useRoute } from 'vue-router'; | ||||||
|  | import { useMapGetter, useStore } from 'dashboard/composables/store.js'; | ||||||
|  | import allLocales from 'shared/constants/locales.js'; | ||||||
|  |  | ||||||
|  | import CategoriesPage from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoriesPage.vue'; | ||||||
|  |  | ||||||
|  | const store = useStore(); | ||||||
|  | const route = useRoute(); | ||||||
|  |  | ||||||
|  | const categories = useMapGetter('categories/allCategories'); | ||||||
|  |  | ||||||
|  | const selectedPortalSlug = computed(() => route.params.portalSlug); | ||||||
|  | const getPortalBySlug = useMapGetter('portals/portalBySlug'); | ||||||
|  |  | ||||||
|  | const isFetching = useMapGetter('categories/isFetching'); | ||||||
|  |  | ||||||
|  | const portal = computed(() => getPortalBySlug.value(selectedPortalSlug.value)); | ||||||
|  |  | ||||||
|  | const allowedLocales = computed(() => { | ||||||
|  |   if (!portal.value) { | ||||||
|  |     return []; | ||||||
|  |   } | ||||||
|  |   const { allowed_locales: allAllowedLocales } = portal.value.config; | ||||||
|  |   return allAllowedLocales.map(locale => { | ||||||
|  |     return { | ||||||
|  |       id: locale.code, | ||||||
|  |       name: allLocales[locale.code], | ||||||
|  |       code: locale.code, | ||||||
|  |     }; | ||||||
|  |   }); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const fetchCategoriesByPortalSlugAndLocale = async localeCode => { | ||||||
|  |   await store.dispatch('categories/index', { | ||||||
|  |     portalSlug: selectedPortalSlug.value, | ||||||
|  |     locale: localeCode, | ||||||
|  |   }); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const updateMeta = async localeCode => { | ||||||
|  |   return store.dispatch('portals/show', { | ||||||
|  |     portalSlug: selectedPortalSlug.value, | ||||||
|  |     locale: localeCode, | ||||||
|  |   }); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const fetchCategories = async localeCode => { | ||||||
|  |   await fetchCategoriesByPortalSlugAndLocale(localeCode); | ||||||
|  |   await updateMeta(localeCode); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | onMounted(() => { | ||||||
|  |   fetchCategoriesByPortalSlugAndLocale(route.params.locale); | ||||||
|  | }); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <CategoriesPage | ||||||
|  |     :categories="categories" | ||||||
|  |     :is-fetching="isFetching" | ||||||
|  |     :allowed-locales="allowedLocales" | ||||||
|  |     @fetch-categories="fetchCategories" | ||||||
|  |   /> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,76 @@ | |||||||
|  | <script setup> | ||||||
|  | import { computed, nextTick, onMounted } from 'vue'; | ||||||
|  | import { useStore } from 'vuex'; | ||||||
|  | import { useRoute, useRouter } from 'vue-router'; | ||||||
|  | import { useUISettings } from 'dashboard/composables/useUISettings'; | ||||||
|  |  | ||||||
|  | import Spinner from 'dashboard/components-next/spinner/Spinner.vue'; | ||||||
|  |  | ||||||
|  | const store = useStore(); | ||||||
|  | const router = useRouter(); | ||||||
|  | const { uiSettings } = useUISettings(); | ||||||
|  | const route = useRoute(); | ||||||
|  |  | ||||||
|  | const portals = computed(() => store.getters['portals/allPortals']); | ||||||
|  |  | ||||||
|  | const isPortalPresent = portalSlug => { | ||||||
|  |   return !!portals.value.find(portal => portal.slug === portalSlug); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const routeToView = (name, params) => { | ||||||
|  |   router.replace({ name, params, replace: true }); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const generateRouterParams = () => { | ||||||
|  |   const { | ||||||
|  |     last_active_portal_slug: lastActivePortalSlug, | ||||||
|  |     last_active_locale_code: lastActiveLocaleCode, | ||||||
|  |   } = uiSettings.value || {}; | ||||||
|  |   if (isPortalPresent(lastActivePortalSlug)) { | ||||||
|  |     return { | ||||||
|  |       portalSlug: lastActivePortalSlug, | ||||||
|  |       locale: lastActiveLocaleCode, | ||||||
|  |     }; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (portals.value.length > 0) { | ||||||
|  |     const { slug: portalSlug, meta: { default_locale: locale } = {} } = | ||||||
|  |       portals.value[0]; | ||||||
|  |     return { portalSlug, locale }; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return null; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const routeToLastActivePortal = () => { | ||||||
|  |   const params = generateRouterParams(); | ||||||
|  |   const { navigationPath } = route.params; | ||||||
|  |   const isAValidRoute = [ | ||||||
|  |     'portals_articles_index', | ||||||
|  |     'portals_categories_index', | ||||||
|  |     'portals_locales_index', | ||||||
|  |     'portals_settings_index', | ||||||
|  |   ].includes(navigationPath); | ||||||
|  |  | ||||||
|  |   const navigateTo = isAValidRoute ? navigationPath : 'portals_articles_index'; | ||||||
|  |   if (params) { | ||||||
|  |     return routeToView(navigateTo, params); | ||||||
|  |   } | ||||||
|  |   return routeToView('portals_new', {}); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const performRouting = async () => { | ||||||
|  |   await store.dispatch('portals/index'); | ||||||
|  |   nextTick(() => routeToLastActivePortal()); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | onMounted(() => performRouting()); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <div | ||||||
|  |     class="flex items-center justify-center w-full bg-n-background text-slate-600 dark:text-slate-200" | ||||||
|  |   > | ||||||
|  |     <Spinner /> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,34 @@ | |||||||
|  | <script setup> | ||||||
|  | import { computed } from 'vue'; | ||||||
|  | import { useRoute } from 'vue-router'; | ||||||
|  | import { useMapGetter } from 'dashboard/composables/store.js'; | ||||||
|  | import allLocales from 'shared/constants/locales.js'; | ||||||
|  |  | ||||||
|  | import LocalesPage from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocalesPage.vue'; | ||||||
|  |  | ||||||
|  | const route = useRoute(); | ||||||
|  |  | ||||||
|  | const getPortalBySlug = useMapGetter('portals/portalBySlug'); | ||||||
|  |  | ||||||
|  | const portal = computed(() => getPortalBySlug.value(route.params.portalSlug)); | ||||||
|  |  | ||||||
|  | const allowedLocales = computed(() => { | ||||||
|  |   if (!portal.value) { | ||||||
|  |     return []; | ||||||
|  |   } | ||||||
|  |   const { allowed_locales: allAllowedLocales } = portal.value.config; | ||||||
|  |   return allAllowedLocales.map(locale => { | ||||||
|  |     return { | ||||||
|  |       id: locale?.code, | ||||||
|  |       name: allLocales[locale?.code], | ||||||
|  |       code: locale?.code, | ||||||
|  |       articlesCount: locale?.articles_count || 0, | ||||||
|  |       categoriesCount: locale?.categories_count || 0, | ||||||
|  |     }; | ||||||
|  |   }); | ||||||
|  | }); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <LocalesPage :locales="allowedLocales" :portal="portal" /> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,9 @@ | |||||||
|  | <script setup> | ||||||
|  | import PortalEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/Portal/PortalEmptyState.vue'; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <div class="w-full h-full bg-n-background"> | ||||||
|  |     <PortalEmptyState /> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,122 @@ | |||||||
|  | <script setup> | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  | import { useRoute, useRouter } from 'vue-router'; | ||||||
|  | import { useUISettings } from 'dashboard/composables/useUISettings'; | ||||||
|  | import { useAlert } from 'dashboard/composables'; | ||||||
|  | import { useMapGetter, useStore } from 'dashboard/composables/store.js'; | ||||||
|  | import PortalSettings from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalSettings.vue'; | ||||||
|  |  | ||||||
|  | const { t } = useI18n(); | ||||||
|  | const store = useStore(); | ||||||
|  | const route = useRoute(); | ||||||
|  | const router = useRouter(); | ||||||
|  |  | ||||||
|  | const { updateUISettings } = useUISettings(); | ||||||
|  |  | ||||||
|  | const portals = useMapGetter('portals/allPortals'); | ||||||
|  | const isFetching = useMapGetter('portals/isFetchingPortals'); | ||||||
|  | const getPortalBySlug = useMapGetter('portals/portalBySlug'); | ||||||
|  |  | ||||||
|  | const getNextAvailablePortal = deletedPortalSlug => | ||||||
|  |   portals.value?.find(portal => portal.slug !== deletedPortalSlug) ?? null; | ||||||
|  |  | ||||||
|  | const getDefaultLocale = slug => { | ||||||
|  |   return getPortalBySlug.value(slug)?.meta?.default_locale; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const fetchPortalAndItsCategories = async (slug, locale) => { | ||||||
|  |   const selectedPortalParam = { portalSlug: slug, locale }; | ||||||
|  |   await Promise.all([ | ||||||
|  |     store.dispatch('portals/index'), | ||||||
|  |     store.dispatch('portals/show', selectedPortalParam), | ||||||
|  |     store.dispatch('categories/index', selectedPortalParam), | ||||||
|  |     store.dispatch('agents/get'), | ||||||
|  |     store.dispatch('inboxes/get'), | ||||||
|  |   ]); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const updateRouteAfterDeletion = async deletedPortalSlug => { | ||||||
|  |   const nextPortal = getNextAvailablePortal(deletedPortalSlug); | ||||||
|  |   if (nextPortal) { | ||||||
|  |     const { | ||||||
|  |       slug, | ||||||
|  |       meta: { default_locale: defaultLocale }, | ||||||
|  |     } = nextPortal; | ||||||
|  |     await fetchPortalAndItsCategories(slug, defaultLocale); | ||||||
|  |     router.push({ | ||||||
|  |       name: 'portals_articles_index', | ||||||
|  |       params: { portalSlug: slug, locale: defaultLocale }, | ||||||
|  |     }); | ||||||
|  |   } else { | ||||||
|  |     router.push({ name: 'portals_new' }); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const refreshPortalRoute = async (newSlug, defaultLocale) => { | ||||||
|  |   // This is to refresh the portal route and update the UI settings | ||||||
|  |   // If there is slug change, this will be called to refresh the route and UI settings | ||||||
|  |   await fetchPortalAndItsCategories(newSlug, defaultLocale); | ||||||
|  |   updateUISettings({ | ||||||
|  |     last_active_portal_slug: newSlug, | ||||||
|  |     last_active_locale_code: defaultLocale, | ||||||
|  |   }); | ||||||
|  |   await router.replace({ | ||||||
|  |     name: 'portals_settings_index', | ||||||
|  |     params: { portalSlug: newSlug }, | ||||||
|  |   }); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const updatePortalSettings = async portalObj => { | ||||||
|  |   const { portalSlug } = route.params; | ||||||
|  |   try { | ||||||
|  |     const defaultLocale = getDefaultLocale(portalSlug); | ||||||
|  |     await store.dispatch('portals/update', { | ||||||
|  |       ...portalObj, | ||||||
|  |       portalSlug: portalSlug || portalObj?.slug, | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     // If there is a slug change, this will refresh the route and update the UI settings | ||||||
|  |     if (portalObj?.slug && portalSlug !== portalObj.slug) { | ||||||
|  |       await refreshPortalRoute(portalObj.slug, defaultLocale); | ||||||
|  |     } | ||||||
|  |     useAlert( | ||||||
|  |       t('HELP_CENTER.PORTAL_SETTINGS.API.UPDATE_PORTAL.SUCCESS_MESSAGE') | ||||||
|  |     ); | ||||||
|  |   } catch (error) { | ||||||
|  |     useAlert( | ||||||
|  |       error?.message || | ||||||
|  |         t('HELP_CENTER.PORTAL_SETTINGS.API.UPDATE_PORTAL.ERROR_MESSAGE') | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const deletePortal = async selectedPortalForDelete => { | ||||||
|  |   const { slug } = selectedPortalForDelete; | ||||||
|  |   try { | ||||||
|  |     await store.dispatch('portals/delete', { portalSlug: slug }); | ||||||
|  |     await updateRouteAfterDeletion(slug); | ||||||
|  |     useAlert( | ||||||
|  |       t('HELP_CENTER.PORTAL.PORTAL_SETTINGS.DELETE_PORTAL.API.DELETE_SUCCESS') | ||||||
|  |     ); | ||||||
|  |   } catch (error) { | ||||||
|  |     useAlert( | ||||||
|  |       error?.message || | ||||||
|  |         t('HELP_CENTER.PORTAL.PORTAL_SETTINGS.DELETE_PORTAL.API.DELETE_ERROR') | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleUpdatePortal = updatePortalSettings; | ||||||
|  | const handleUpdatePortalConfiguration = updatePortalSettings; | ||||||
|  | const handleDeletePortal = deletePortal; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <PortalSettings | ||||||
|  |     :portals="portals" | ||||||
|  |     :is-fetching="isFetching" | ||||||
|  |     @update-portal="handleUpdatePortal" | ||||||
|  |     @update-portal-configuration="handleUpdatePortalConfiguration" | ||||||
|  |     @delete-portal="handleDeletePortal" | ||||||
|  |   /> | ||||||
|  | </template> | ||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	 Sivin Varghese
					Sivin Varghese