Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fda7c5761 | ||
|
|
54afed9fb4 | ||
|
|
8773929c0e | ||
|
|
933ae8aa49 | ||
|
|
4cfa7e4c97 | ||
|
|
ac729cf0cf | ||
|
|
7dc0eba48a | ||
|
|
97d7b9d754 | ||
|
|
7a45144526 | ||
|
|
db327378fa | ||
|
|
54740e3bb9 | ||
|
|
8cdbdaaa07 | ||
|
|
579efd933b | ||
|
|
6e6c5a2f02 | ||
|
|
2d35fa135b | ||
|
|
6da6a80ae7 | ||
|
|
87719a8fcd | ||
|
|
aa57431c48 | ||
|
|
55dfd7db50 | ||
|
|
0689f59a05 | ||
|
|
f73798a1aa | ||
|
|
035a037313 | ||
|
|
c6c36b1b36 | ||
|
|
7ba6c1d87d | ||
|
|
6df2d76c1e | ||
|
|
80c9434069 | ||
|
|
73b6e2cf37 | ||
|
|
a3855a8d1d | ||
|
|
6d3ecfe3c1 | ||
|
|
601a0f8a76 | ||
|
|
c49f5ed800 | ||
|
|
5133f4e579 | ||
|
|
c8657c55a8 | ||
|
|
2a832f8ed5 | ||
|
|
35a1dcce43 | ||
|
|
3fe771df6f | ||
|
|
604c592e89 | ||
|
|
fd01a5056a | ||
|
|
d70ba8ff40 |
@@ -181,6 +181,9 @@ gem 'reverse_markdown'
|
||||
group :production do
|
||||
# we dont want request timing out in development while using byebug
|
||||
gem 'rack-timeout'
|
||||
# for heroku autoscaling
|
||||
gem 'judoscale-rails', require: false
|
||||
gem 'judoscale-sidekiq', require: false
|
||||
end
|
||||
|
||||
group :development do
|
||||
|
||||
+10
-3
@@ -397,6 +397,13 @@ GEM
|
||||
hana (~> 1.3)
|
||||
regexp_parser (~> 2.0)
|
||||
uri_template (~> 0.7)
|
||||
judoscale-rails (1.8.2)
|
||||
judoscale-ruby (= 1.8.2)
|
||||
railties
|
||||
judoscale-ruby (1.8.2)
|
||||
judoscale-sidekiq (1.8.2)
|
||||
judoscale-ruby (= 1.8.2)
|
||||
sidekiq (>= 5.0)
|
||||
jwt (2.8.1)
|
||||
base64
|
||||
kaminari (1.2.2)
|
||||
@@ -633,8 +640,7 @@ GEM
|
||||
retriable (3.1.2)
|
||||
reverse_markdown (2.1.1)
|
||||
nokogiri
|
||||
rexml (3.3.6)
|
||||
strscan
|
||||
rexml (3.3.9)
|
||||
rspec-core (3.13.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-expectations (3.13.2)
|
||||
@@ -764,7 +770,6 @@ GEM
|
||||
stackprof (0.2.25)
|
||||
statsd-ruby (1.5.0)
|
||||
stripe (8.5.0)
|
||||
strscan (3.1.0)
|
||||
telephone_number (1.4.20)
|
||||
test-prof (1.2.1)
|
||||
thor (1.3.1)
|
||||
@@ -893,6 +898,8 @@ DEPENDENCIES
|
||||
jbuilder
|
||||
json_refs
|
||||
json_schemer
|
||||
judoscale-rails
|
||||
judoscale-sidekiq
|
||||
jwt
|
||||
kaminari
|
||||
koala
|
||||
|
||||
@@ -6,13 +6,15 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
|
||||
def index
|
||||
@portal_articles = @portal.articles
|
||||
@all_articles = @portal_articles.search(list_params)
|
||||
@articles_count = @all_articles.count
|
||||
|
||||
set_article_count
|
||||
|
||||
@articles = @articles.search(list_params)
|
||||
|
||||
@articles = if list_params[:category_slug].present?
|
||||
@all_articles.order_by_position.page(@current_page)
|
||||
@articles.order_by_position.page(@current_page)
|
||||
else
|
||||
@all_articles.order_by_updated_at.page(@current_page)
|
||||
@articles.order_by_updated_at.page(@current_page)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -43,6 +45,19 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
|
||||
private
|
||||
|
||||
def set_article_count
|
||||
# Search the params without status and author_id, use this to
|
||||
# compute mine count published draft etc
|
||||
base_search_params = list_params.except(:status, :author_id)
|
||||
@articles = @portal_articles.search(base_search_params)
|
||||
|
||||
@articles_count = @articles.count
|
||||
@mine_articles_count = @articles.search_by_author(Current.user.id).count
|
||||
@published_articles_count = @articles.published.count
|
||||
@draft_articles_count = @articles.draft.count
|
||||
@archived_articles_count = @articles.archived.count
|
||||
end
|
||||
|
||||
def fetch_article
|
||||
@article = @portal.articles.find(params[:id])
|
||||
end
|
||||
@@ -53,9 +68,10 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
|
||||
def article_params
|
||||
params.require(:article).permit(
|
||||
:title, :slug, :position, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status, meta: [:title,
|
||||
:description,
|
||||
{ tags: [] }]
|
||||
:title, :slug, :position, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status,
|
||||
:locale, meta: [:title,
|
||||
:description,
|
||||
{ tags: [] }]
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -1,22 +1,53 @@
|
||||
class Api::V1::Accounts::Integrations::CaptainController < Api::V1::Accounts::BaseController
|
||||
before_action :check_admin_authorization?
|
||||
before_action :fetch_hook
|
||||
before_action :hook
|
||||
|
||||
def sso_url
|
||||
params_string =
|
||||
"token=#{URI.encode_www_form_component(@hook['settings']['access_token'])}" \
|
||||
"&email=#{URI.encode_www_form_component(@hook['settings']['account_email'])}" \
|
||||
"&account_id=#{URI.encode_www_form_component(@hook['settings']['account_id'])}"
|
||||
|
||||
installation_config = InstallationConfig.find_by(name: 'CAPTAIN_APP_URL')
|
||||
|
||||
sso_url = "#{installation_config.value}/sso?#{params_string}"
|
||||
render json: { sso_url: sso_url }, status: :ok
|
||||
def proxy
|
||||
response = HTTParty.send(request_method, request_url, body: permitted_params[:body].to_json, headers: headers)
|
||||
render plain: response.body, status: response.code
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_hook
|
||||
@hook = Current.account.hooks.find_by!(app_id: 'captain')
|
||||
def headers
|
||||
{
|
||||
'X-User-Email' => hook.settings['account_email'],
|
||||
'X-User-Token' => hook.settings['access_token'],
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => '*/*'
|
||||
}
|
||||
end
|
||||
|
||||
def request_path
|
||||
request_route = with_leading_hash_on_route(params[:route])
|
||||
|
||||
return 'api/sessions/profile' if request_route == '/sessions/profile'
|
||||
|
||||
"api/accounts/#{hook.settings['account_id']}#{request_route}"
|
||||
end
|
||||
|
||||
def request_url
|
||||
base_url = InstallationConfig.find_by(name: 'CAPTAIN_API_URL').value
|
||||
URI.join(base_url, request_path).to_s
|
||||
end
|
||||
|
||||
def hook
|
||||
@hook ||= Current.account.hooks.find_by!(app_id: 'captain')
|
||||
end
|
||||
|
||||
def request_method
|
||||
method = permitted_params[:method].downcase
|
||||
raise 'Invalid or missing HTTP method' unless %w[get post put patch delete options head].include?(method)
|
||||
|
||||
method
|
||||
end
|
||||
|
||||
def with_leading_hash_on_route(request_route)
|
||||
return '' if request_route.blank?
|
||||
|
||||
request_route.start_with?('/') ? request_route : "/#{request_route}"
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:method, :route, body: {})
|
||||
end
|
||||
end
|
||||
|
||||
@@ -20,7 +20,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def create
|
||||
@portal = Current.account.portals.build(portal_params)
|
||||
@portal = Current.account.portals.build(portal_params.merge(live_chat_widget_params))
|
||||
@portal.custom_domain = parsed_custom_domain
|
||||
@portal.save!
|
||||
process_attached_logo
|
||||
@@ -28,7 +28,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
|
||||
def update
|
||||
ActiveRecord::Base.transaction do
|
||||
@portal.update!(portal_params) if params[:portal].present?
|
||||
@portal.update!(portal_params.merge(live_chat_widget_params)) if params[:portal].present?
|
||||
# @portal.custom_domain = parsed_custom_domain
|
||||
process_attached_logo if params[:blob_id].present?
|
||||
rescue StandardError => e
|
||||
@@ -70,11 +70,21 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
|
||||
def portal_params
|
||||
params.require(:portal).permit(
|
||||
:account_id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug, :archived, { config: [:default_locale,
|
||||
{ allowed_locales: [] }] }
|
||||
:account_id, :color, :custom_domain, :header_text, :homepage_link,
|
||||
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
|
||||
)
|
||||
end
|
||||
|
||||
def live_chat_widget_params
|
||||
permitted_params = params.permit(:inbox_id)
|
||||
return {} if permitted_params[:inbox_id].blank?
|
||||
|
||||
inbox = Inbox.find(permitted_params[:inbox_id])
|
||||
return {} unless inbox.web_widget?
|
||||
|
||||
{ channel_web_widget_id: inbox.channel.id }
|
||||
end
|
||||
|
||||
def portal_member_params
|
||||
params.require(:portal).permit(:account_id, member_ids: [])
|
||||
end
|
||||
|
||||
@@ -30,7 +30,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
||||
|
||||
def set_article
|
||||
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
|
||||
@article.increment_view_count
|
||||
@article.increment_view_count if @article.published?
|
||||
@parsed_content = render_article_content(@article.content)
|
||||
end
|
||||
|
||||
|
||||
@@ -52,12 +52,13 @@ class ArticlesAPI extends PortalsAPI {
|
||||
}
|
||||
|
||||
createArticle({ portalSlug, articleObj }) {
|
||||
const { content, title, author_id, category_id } = articleObj;
|
||||
const { content, title, authorId, categoryId, locale } = articleObj;
|
||||
return axios.post(`${this.url}/${portalSlug}/articles`, {
|
||||
content,
|
||||
title,
|
||||
author_id,
|
||||
category_id,
|
||||
author_id: authorId,
|
||||
category_id: categoryId,
|
||||
locale,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -33,8 +33,8 @@ class IntegrationsAPI extends ApiClient {
|
||||
return axios.delete(`${this.baseUrl()}/integrations/hooks/${hookId}`);
|
||||
}
|
||||
|
||||
fetchCaptainURL() {
|
||||
return axios.get(`${this.baseUrl()}/integrations/captain/sso_url`);
|
||||
requestCaptain(body) {
|
||||
return axios.post(`${this.baseUrl()}/integrations/captain/proxy`, body);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,14 @@
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
.n-blue-border {
|
||||
@apply border-n-blue-border;
|
||||
}
|
||||
|
||||
.n-blue-text {
|
||||
@apply text-n-blue-text;
|
||||
}
|
||||
|
||||
// scss-lint:disable PropertySortOrder
|
||||
@layer base {
|
||||
/* NEXT COLORS START */
|
||||
@@ -103,24 +111,25 @@
|
||||
--teal-11: 0 133 115;
|
||||
--teal-12: 13 61 56;
|
||||
|
||||
--background-color: 248 248 248;
|
||||
--background-color: 253 253 253;
|
||||
--text-blue: 8 109 224;
|
||||
--border-container: 236 236 236;
|
||||
--border-strong: 235 235 235;
|
||||
--border-weak: 234 234 234;
|
||||
--solid-1: 255 255 255;
|
||||
--solid-2: 252 252 252;
|
||||
--solid-2: 255 255 255;
|
||||
--solid-3: 255 255 255;
|
||||
--solid-active: 250 251 251;
|
||||
--solid-active: 255 255 255;
|
||||
--solid-amber: 252 232 193;
|
||||
--solid-blue: 218 236 255;
|
||||
--white-alpha: 255 255 255 0.1;
|
||||
--border-weak: 231 231 231;
|
||||
--border-strong: 235 235 235;
|
||||
--blue: 39 129 246;
|
||||
|
||||
/* alpha is added by default */
|
||||
--alpha-1: 36, 38, 48, 0.06;
|
||||
--alpha-2: 130, 134, 150, 0.12;
|
||||
--alpha-3: 255, 255, 255, 0.9;
|
||||
--alpha-1: 67, 67, 67, 0.06;
|
||||
--alpha-2: 201, 202, 207, 0.15;
|
||||
--alpha-3: 255, 255, 255, 0.96;
|
||||
--black-alpha-1: 0, 0, 0, 0.12;
|
||||
--black-alpha-2: 0, 0, 0, 0.04;
|
||||
--border-blue: 39, 129, 246, 0.5;
|
||||
--white-alpha: 255, 255, 255, 0.1;
|
||||
}
|
||||
|
||||
body.dark {
|
||||
@@ -178,22 +187,23 @@
|
||||
--teal-12: 173 240 221;
|
||||
|
||||
--background-color: 18 18 19;
|
||||
--border-strong: 52 52 52;
|
||||
--border-weak: 38 38 42;
|
||||
--solid-1: 23 23 26;
|
||||
--solid-2: 29 30 36;
|
||||
--solid-3: 36 38 48;
|
||||
--solid-3: 44 45 54;
|
||||
--solid-active: 53 57 66;
|
||||
--solid-amber: 42 37 30;
|
||||
--solid-blue: 16 49 91;
|
||||
--solid-active: 51 53 64;
|
||||
--border-weak: 34 34 37;
|
||||
--border-strong: 46 47 49;
|
||||
--blue: 126 182 255;
|
||||
--text-blue: 126 182 255;
|
||||
|
||||
/* alpha is added by default */
|
||||
--alpha-1: 35, 37, 45, 0.8;
|
||||
--alpha-2: 130, 134, 150, 0.15;
|
||||
--alpha-3: 32, 33, 37, 0.9;
|
||||
--alpha-1: 36, 36, 36, 0.8;
|
||||
--alpha-2: 139, 147, 182, 0.15;
|
||||
--alpha-3: 36, 38, 45, 0.9;
|
||||
--black-alpha-1: 0, 0, 0, 0.3;
|
||||
--black-alpha-2: 0, 0, 0, 0.2;
|
||||
--border-blue: 39, 129, 246, 0.5;
|
||||
--border-container: 236, 236, 236, 0;
|
||||
--white-alpha: 255, 255, 255, 0.1;
|
||||
}
|
||||
/* NEXT COLORS END */
|
||||
@@ -540,3 +550,15 @@
|
||||
--color-orange-900: 255 224 194;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
/* Hide scrollbar for IE, Edge and Firefox */
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import { getInboxIconByType } from 'dashboard/helper/inbox';
|
||||
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import LiveChatCampaignDetails from './LiveChatCampaignDetails.vue';
|
||||
import SMSCampaignDetails from './SMSCampaignDetails.vue';
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isLiveChatType: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isEnabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
sender: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
scheduledAt: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['edit', 'delete']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const STATUS_COMPLETED = 'completed';
|
||||
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
|
||||
const isActive = computed(() =>
|
||||
props.isLiveChatType ? props.isEnabled : props.status !== STATUS_COMPLETED
|
||||
);
|
||||
|
||||
const statusTextColor = computed(() => ({
|
||||
'text-n-teal-11': isActive.value,
|
||||
'text-n-slate-12': !isActive.value,
|
||||
}));
|
||||
|
||||
const campaignStatus = computed(() => {
|
||||
if (props.isLiveChatType) {
|
||||
return props.isEnabled
|
||||
? t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.ENABLED')
|
||||
: t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.DISABLED');
|
||||
}
|
||||
|
||||
return props.status === STATUS_COMPLETED
|
||||
? t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED')
|
||||
: t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED');
|
||||
});
|
||||
|
||||
const inboxName = computed(() => props.inbox?.name || '');
|
||||
|
||||
const inboxIcon = computed(() => {
|
||||
const { phone_number: phoneNumber, channel_type: type } = props.inbox;
|
||||
return getInboxIconByType(type, phoneNumber);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout class="flex flex-row justify-between flex-1 gap-8" layout="row">
|
||||
<template #header>
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<div class="flex justify-between gap-3 w-fit">
|
||||
<span
|
||||
class="text-base font-medium capitalize text-n-slate-12 line-clamp-1"
|
||||
>
|
||||
{{ title }}
|
||||
</span>
|
||||
<span
|
||||
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
|
||||
:class="statusTextColor"
|
||||
>
|
||||
{{ campaignStatus }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-dompurify-html="formatMessage(message)"
|
||||
class="text-sm text-n-slate-11 line-clamp-1 [&>p]:mb-0 h-6"
|
||||
/>
|
||||
<div class="flex items-center w-full h-6 gap-2 overflow-hidden">
|
||||
<LiveChatCampaignDetails
|
||||
v-if="isLiveChatType"
|
||||
:sender="sender"
|
||||
:inbox-name="inboxName"
|
||||
:inbox-icon="inboxIcon"
|
||||
/>
|
||||
<SMSCampaignDetails
|
||||
v-else
|
||||
:inbox-name="inboxName"
|
||||
:inbox-icon="inboxIcon"
|
||||
:scheduled-at="scheduledAt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-end w-20 gap-2">
|
||||
<Button
|
||||
v-if="isLiveChatType"
|
||||
variant="faded"
|
||||
size="sm"
|
||||
color="slate"
|
||||
icon="i-lucide-sliders-vertical"
|
||||
@click="emit('edit')"
|
||||
/>
|
||||
<Button
|
||||
variant="faded"
|
||||
color="ruby"
|
||||
size="sm"
|
||||
icon="i-lucide-trash"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</CardLayout>
|
||||
</template>
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
<script setup>
|
||||
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
sender: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
inboxName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
inboxIcon: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const senderName = computed(
|
||||
() => props.sender?.name || t('CAMPAIGN.LIVE_CHAT.CARD.CAMPAIGN_DETAILS.BOT')
|
||||
);
|
||||
|
||||
const senderThumbnailSrc = computed(() => props.sender?.thumbnail);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="flex-shrink-0 text-sm text-n-slate-11 whitespace-nowrap">
|
||||
{{ t('CAMPAIGN.LIVE_CHAT.CARD.CAMPAIGN_DETAILS.SENT_BY') }}
|
||||
</span>
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0">
|
||||
<Thumbnail
|
||||
:author="sender || { name: senderName }"
|
||||
:name="senderName"
|
||||
:src="senderThumbnailSrc"
|
||||
/>
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ senderName }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="flex-shrink-0 text-sm text-n-slate-11 whitespace-nowrap">
|
||||
{{ t('CAMPAIGN.LIVE_CHAT.CARD.CAMPAIGN_DETAILS.FROM') }}
|
||||
</span>
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0">
|
||||
<Icon :icon="inboxIcon" class="flex-shrink-0 text-n-slate-12 size-3" />
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ inboxName }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
<script setup>
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import { messageStamp } from 'shared/helpers/timeHelper';
|
||||
|
||||
import { useI18n } from 'vue-i18n';
|
||||
defineProps({
|
||||
inboxName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
inboxIcon: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
scheduledAt: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="flex-shrink-0 text-sm text-n-slate-11 whitespace-nowrap">
|
||||
{{ t('CAMPAIGN.SMS.CARD.CAMPAIGN_DETAILS.SENT_FROM') }}
|
||||
</span>
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0">
|
||||
<Icon :icon="inboxIcon" class="flex-shrink-0 text-n-slate-12 size-3" />
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ inboxName }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span class="flex-shrink-0 text-sm text-n-slate-11 whitespace-nowrap">
|
||||
{{ t('CAMPAIGN.SMS.CARD.CAMPAIGN_DETAILS.ON') }}
|
||||
</span>
|
||||
<span class="flex-1 text-sm font-medium truncate text-n-slate-12">
|
||||
{{ messageStamp(new Date(scheduledAt), 'LLL d, h:mm a') }}
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
headerTitle: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
buttonLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click', 'close']);
|
||||
|
||||
const handleButtonClick = () => {
|
||||
emit('click');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
|
||||
<header class="sticky top-0 z-10 px-6 lg:px-0">
|
||||
<div class="w-full max-w-[900px] mx-auto">
|
||||
<div class="flex items-center justify-between w-full h-20 gap-2">
|
||||
<span class="text-xl font-medium text-n-slate-12">
|
||||
{{ headerTitle }}
|
||||
</span>
|
||||
<div
|
||||
v-on-clickaway="() => emit('close')"
|
||||
class="relative group/campaign-button"
|
||||
>
|
||||
<Button
|
||||
:label="buttonLabel"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
class="group-hover/campaign-button:brightness-110"
|
||||
@click="handleButtonClick"
|
||||
/>
|
||||
<slot name="action" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
|
||||
<div class="w-full max-w-[900px] mx-auto py-4">
|
||||
<slot name="default" />
|
||||
</div>
|
||||
</main>
|
||||
</section>
|
||||
</template>
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
export const ONGOING_CAMPAIGN_EMPTY_STATE_CONTENT = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Chatbot Assistance',
|
||||
inbox: {
|
||||
id: 2,
|
||||
name: 'PaperLayer Website',
|
||||
channel_type: 'Channel::WebWidget',
|
||||
phone_number: '',
|
||||
},
|
||||
sender: {
|
||||
id: 1,
|
||||
name: 'Alexa Rivera',
|
||||
},
|
||||
message: 'Hello! 👋 Need help with our chatbot features? Feel free to ask!',
|
||||
campaign_status: 'active',
|
||||
enabled: true,
|
||||
campaign_type: 'ongoing',
|
||||
trigger_rules: {
|
||||
url: 'https://www.chatwoot.com/features/chatbot/',
|
||||
time_on_page: 10,
|
||||
},
|
||||
trigger_only_during_business_hours: true,
|
||||
created_at: '2024-10-24T13:10:26.636Z',
|
||||
updated_at: '2024-10-24T13:10:26.636Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Pricing Information Support',
|
||||
inbox: {
|
||||
id: 2,
|
||||
name: 'PaperLayer Website',
|
||||
channel_type: 'Channel::WebWidget',
|
||||
phone_number: '',
|
||||
},
|
||||
sender: {
|
||||
id: 1,
|
||||
name: 'Jamie Lee',
|
||||
},
|
||||
message: 'Hello! 👋 Any questions on pricing? I’m here to help!',
|
||||
campaign_status: 'active',
|
||||
enabled: false,
|
||||
campaign_type: 'ongoing',
|
||||
trigger_rules: {
|
||||
url: 'https://www.chatwoot.com/pricings',
|
||||
time_on_page: 10,
|
||||
},
|
||||
trigger_only_during_business_hours: false,
|
||||
created_at: '2024-10-24T13:11:08.763Z',
|
||||
updated_at: '2024-10-24T13:11:08.763Z',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Product Setup Assistance',
|
||||
inbox: {
|
||||
id: 2,
|
||||
name: 'PaperLayer Website',
|
||||
channel_type: 'Channel::WebWidget',
|
||||
phone_number: '',
|
||||
},
|
||||
sender: {
|
||||
id: 1,
|
||||
name: 'Chatwoot',
|
||||
},
|
||||
message: 'Hi! Chatwoot here. Need help setting up? Let me know!',
|
||||
campaign_status: 'active',
|
||||
enabled: false,
|
||||
campaign_type: 'ongoing',
|
||||
trigger_rules: {
|
||||
url: 'https://{*.}?chatwoot.com/apps/account/*/settings/inboxes/new/',
|
||||
time_on_page: 10,
|
||||
},
|
||||
trigger_only_during_business_hours: false,
|
||||
created_at: '2024-10-24T13:11:44.285Z',
|
||||
updated_at: '2024-10-24T13:11:44.285Z',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'General Assistance Campaign',
|
||||
inbox: {
|
||||
id: 2,
|
||||
name: 'PaperLayer Website',
|
||||
channel_type: 'Channel::WebWidget',
|
||||
phone_number: '',
|
||||
},
|
||||
sender: {
|
||||
id: 1,
|
||||
name: 'Chris Barlow',
|
||||
},
|
||||
message:
|
||||
'Hi there! 👋 I’m here for any questions you may have. Let’s chat!',
|
||||
campaign_status: 'active',
|
||||
enabled: true,
|
||||
campaign_type: 'ongoing',
|
||||
trigger_rules: {
|
||||
url: 'https://siv.com',
|
||||
time_on_page: 200,
|
||||
},
|
||||
trigger_only_during_business_hours: false,
|
||||
created_at: '2024-10-29T19:54:33.741Z',
|
||||
updated_at: '2024-10-29T19:56:26.296Z',
|
||||
},
|
||||
];
|
||||
|
||||
export const ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Customer Feedback Request',
|
||||
inbox: {
|
||||
id: 6,
|
||||
name: 'PaperLayer Mobile',
|
||||
channel_type: 'Channel::Sms',
|
||||
phone_number: '+29818373149903',
|
||||
provider: 'default',
|
||||
},
|
||||
message:
|
||||
'Hello! Enjoying our product? Share your feedback on G2 and earn a $25 Amazon coupon: https://chwt.app/g2-review',
|
||||
campaign_status: 'active',
|
||||
enabled: true,
|
||||
campaign_type: 'one_off',
|
||||
scheduled_at: 1729775588,
|
||||
audience: [
|
||||
{ id: 4, type: 'Label' },
|
||||
{ id: 5, type: 'Label' },
|
||||
{ id: 6, type: 'Label' },
|
||||
],
|
||||
trigger_rules: {},
|
||||
trigger_only_during_business_hours: false,
|
||||
created_at: '2024-10-24T13:13:08.496Z',
|
||||
updated_at: '2024-10-24T13:15:38.698Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Welcome New Customer',
|
||||
inbox: {
|
||||
id: 6,
|
||||
name: 'PaperLayer Mobile',
|
||||
channel_type: 'Channel::Sms',
|
||||
phone_number: '+29818373149903',
|
||||
provider: 'default',
|
||||
},
|
||||
message: 'Welcome aboard! 🎉 Let us know if you have any questions.',
|
||||
campaign_status: 'completed',
|
||||
enabled: true,
|
||||
campaign_type: 'one_off',
|
||||
scheduled_at: 1729732500,
|
||||
audience: [
|
||||
{ id: 1, type: 'Label' },
|
||||
{ id: 6, type: 'Label' },
|
||||
{ id: 5, type: 'Label' },
|
||||
{ id: 2, type: 'Label' },
|
||||
{ id: 4, type: 'Label' },
|
||||
],
|
||||
trigger_rules: {},
|
||||
trigger_only_during_business_hours: false,
|
||||
created_at: '2024-10-24T13:14:00.168Z',
|
||||
updated_at: '2024-10-24T13:15:38.707Z',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'New Business Welcome',
|
||||
inbox: {
|
||||
id: 6,
|
||||
name: 'PaperLayer Mobile',
|
||||
channel_type: 'Channel::Sms',
|
||||
phone_number: '+29818373149903',
|
||||
provider: 'default',
|
||||
},
|
||||
message: 'Hello! We’re excited to have your business with us!',
|
||||
campaign_status: 'active',
|
||||
enabled: true,
|
||||
campaign_type: 'one_off',
|
||||
scheduled_at: 1730368440,
|
||||
audience: [
|
||||
{ id: 1, type: 'Label' },
|
||||
{ id: 3, type: 'Label' },
|
||||
{ id: 6, type: 'Label' },
|
||||
{ id: 4, type: 'Label' },
|
||||
{ id: 2, type: 'Label' },
|
||||
{ id: 5, type: 'Label' },
|
||||
],
|
||||
trigger_rules: {},
|
||||
trigger_only_during_business_hours: false,
|
||||
created_at: '2024-10-30T07:54:49.915Z',
|
||||
updated_at: '2024-10-30T07:54:49.915Z',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'New Member Onboarding',
|
||||
inbox: {
|
||||
id: 6,
|
||||
name: 'PaperLayer Mobile',
|
||||
channel_type: 'Channel::Sms',
|
||||
phone_number: '+29818373149903',
|
||||
provider: 'default',
|
||||
},
|
||||
message: 'Welcome to the team! Reach out if you have questions.',
|
||||
campaign_status: 'completed',
|
||||
enabled: true,
|
||||
campaign_type: 'one_off',
|
||||
scheduled_at: 1730304840,
|
||||
audience: [
|
||||
{ id: 1, type: 'Label' },
|
||||
{ id: 3, type: 'Label' },
|
||||
{ id: 6, type: 'Label' },
|
||||
],
|
||||
trigger_rules: {},
|
||||
trigger_only_during_business_hours: false,
|
||||
created_at: '2024-10-29T16:14:10.374Z',
|
||||
updated_at: '2024-10-30T16:15:03.157Z',
|
||||
},
|
||||
];
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<script setup>
|
||||
import { ONGOING_CAMPAIGN_EMPTY_STATE_CONTENT } from './CampaignEmptyStateContent';
|
||||
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import CampaignCard from 'dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue';
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EmptyStateLayout :title="title" :subtitle="subtitle">
|
||||
<template #empty-state-item>
|
||||
<div class="flex flex-col gap-4 p-px">
|
||||
<CampaignCard
|
||||
v-for="campaign in ONGOING_CAMPAIGN_EMPTY_STATE_CONTENT"
|
||||
:key="campaign.id"
|
||||
:title="campaign.title"
|
||||
:message="campaign.message"
|
||||
:is-enabled="campaign.enabled"
|
||||
:status="campaign.campaign_status"
|
||||
:trigger-rules="campaign.trigger_rules"
|
||||
:sender="campaign.sender"
|
||||
:inbox="campaign.inbox"
|
||||
:scheduled-at="campaign.scheduled_at"
|
||||
is-live-chat-type
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</EmptyStateLayout>
|
||||
</template>
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
<script setup>
|
||||
import { ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT } from './CampaignEmptyStateContent';
|
||||
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import CampaignCard from 'dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue';
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EmptyStateLayout :title="title" :subtitle="subtitle">
|
||||
<template #empty-state-item>
|
||||
<div class="flex flex-col gap-4 p-px">
|
||||
<CampaignCard
|
||||
v-for="campaign in ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT"
|
||||
:key="campaign.id"
|
||||
:title="campaign.title"
|
||||
:message="campaign.message"
|
||||
:is-enabled="campaign.enabled"
|
||||
:status="campaign.campaign_status"
|
||||
:trigger-rules="campaign.trigger_rules"
|
||||
:sender="campaign.sender"
|
||||
:inbox="campaign.inbox"
|
||||
:scheduled-at="campaign.scheduled_at"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</EmptyStateLayout>
|
||||
</template>
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<script setup>
|
||||
import CampaignCard from 'dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue';
|
||||
|
||||
defineProps({
|
||||
campaigns: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
isLiveChatType: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['edit', 'delete']);
|
||||
|
||||
const handleEdit = campaign => emit('edit', campaign);
|
||||
const handleDelete = campaign => emit('delete', campaign);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<CampaignCard
|
||||
v-for="campaign in campaigns"
|
||||
:key="campaign.id"
|
||||
:title="campaign.title"
|
||||
:message="campaign.message"
|
||||
:is-enabled="campaign.enabled"
|
||||
:status="campaign.campaign_status"
|
||||
:trigger-rules="campaign.trigger_rules"
|
||||
:sender="campaign.sender"
|
||||
:inbox="campaign.inbox"
|
||||
:scheduled-at="campaign.scheduled_at"
|
||||
:is-live-chat-type="isLiveChatType"
|
||||
@edit="handleEdit(campaign)"
|
||||
@delete="handleDelete(campaign)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
selectedCampaign: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
|
||||
const deleteCampaign = async id => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
await store.dispatch('campaigns/delete', id);
|
||||
useAlert(t('CAMPAIGN.CONFIRM_DELETE.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(t('CAMPAIGN.CONFIRM_DELETE.API.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDialogConfirm = async () => {
|
||||
await deleteCampaign(props.selectedCampaign.id);
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
defineExpose({ dialogRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="alert"
|
||||
:title="t('CAMPAIGN.CONFIRM_DELETE.TITLE')"
|
||||
:description="t('CAMPAIGN.CONFIRM_DELETE.DESCRIPTION')"
|
||||
:confirm-button-label="t('CAMPAIGN.CONFIRM_DELETE.CONFIRM')"
|
||||
@confirm="handleDialogConfirm"
|
||||
/>
|
||||
</template>
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import LiveChatCampaignForm from 'dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/LiveChatCampaignForm.vue';
|
||||
|
||||
const props = defineProps({
|
||||
selectedCampaign: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const liveChatCampaignFormRef = ref(null);
|
||||
|
||||
const uiFlags = useMapGetter('campaigns/getUIFlags');
|
||||
const isUpdatingCampaign = computed(() => uiFlags.value.isUpdating);
|
||||
|
||||
const isInvalidForm = computed(
|
||||
() => liveChatCampaignFormRef.value?.isSubmitDisabled
|
||||
);
|
||||
|
||||
const selectedCampaignId = computed(() => props.selectedCampaign.id);
|
||||
|
||||
const updateCampaign = async campaignDetails => {
|
||||
try {
|
||||
await store.dispatch('campaigns/update', {
|
||||
id: selectedCampaignId.value,
|
||||
...campaignDetails,
|
||||
});
|
||||
|
||||
useAlert(t('CAMPAIGN.LIVE_CHAT.EDIT.FORM.API.SUCCESS_MESSAGE'));
|
||||
dialogRef.value.close();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.response?.message ||
|
||||
t('CAMPAIGN.LIVE_CHAT.EDIT.FORM.API.ERROR_MESSAGE');
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
updateCampaign(liveChatCampaignFormRef.value.prepareCampaignDetails());
|
||||
};
|
||||
|
||||
defineExpose({ dialogRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
:title="t('CAMPAIGN.LIVE_CHAT.EDIT.TITLE')"
|
||||
:is-loading="isUpdatingCampaign"
|
||||
:disable-confirm-button="isUpdatingCampaign || isInvalidForm"
|
||||
overflow-y-auto
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<template #form>
|
||||
<LiveChatCampaignForm
|
||||
ref="liveChatCampaignFormRef"
|
||||
mode="edit"
|
||||
:selected-campaign="selectedCampaign"
|
||||
:show-action-buttons="false"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { CAMPAIGN_TYPES } from 'shared/constants/campaign.js';
|
||||
import { CAMPAIGNS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events.js';
|
||||
|
||||
import LiveChatCampaignForm from 'dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/LiveChatCampaignForm.vue';
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const addCampaign = async campaignDetails => {
|
||||
try {
|
||||
await store.dispatch('campaigns/create', campaignDetails);
|
||||
|
||||
// tracking this here instead of the store to track the type of campaign
|
||||
useTrack(CAMPAIGNS_EVENTS.CREATE_CAMPAIGN, {
|
||||
type: CAMPAIGN_TYPES.ONGOING,
|
||||
});
|
||||
|
||||
useAlert(t('CAMPAIGN.SMS.CREATE.FORM.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.response?.message ||
|
||||
t('CAMPAIGN.SMS.CREATE.FORM.API.ERROR_MESSAGE');
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => emit('close');
|
||||
|
||||
const handleSubmit = campaignDetails => {
|
||||
addCampaign(campaignDetails);
|
||||
handleClose();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-[400px] z-50 min-w-0 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 max-h-[85vh] overflow-y-auto"
|
||||
>
|
||||
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
|
||||
{{ t(`CAMPAIGN.LIVE_CHAT.CREATE.TITLE`) }}
|
||||
</h3>
|
||||
<LiveChatCampaignForm
|
||||
mode="create"
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+323
@@ -0,0 +1,323 @@
|
||||
<script setup>
|
||||
import { reactive, computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength } from '@vuelidate/validators';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { URLPattern } from 'urlpattern-polyfill';
|
||||
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: value => ['edit', 'create'].includes(value),
|
||||
},
|
||||
selectedCampaign: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
showActionButtons: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['submit', 'cancel']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const formState = {
|
||||
uiFlags: useMapGetter('campaigns/getUIFlags'),
|
||||
inboxes: useMapGetter('inboxes/getWebsiteInboxes'),
|
||||
};
|
||||
|
||||
const senderList = ref([]);
|
||||
|
||||
const initialState = {
|
||||
title: '',
|
||||
message: '',
|
||||
inboxId: null,
|
||||
senderId: 0,
|
||||
enabled: true,
|
||||
triggerOnlyDuringBusinessHours: false,
|
||||
endPoint: '',
|
||||
timeOnPage: 10,
|
||||
};
|
||||
|
||||
const state = reactive({ ...initialState });
|
||||
|
||||
const urlValidators = {
|
||||
shouldBeAValidURLPattern: value => {
|
||||
try {
|
||||
// eslint-disable-next-line
|
||||
new URLPattern(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
shouldStartWithHTTP: value =>
|
||||
value ? value.startsWith('https://') || value.startsWith('http://') : false,
|
||||
};
|
||||
|
||||
const validationRules = {
|
||||
title: { required, minLength: minLength(1) },
|
||||
message: { required, minLength: minLength(1) },
|
||||
inboxId: { required },
|
||||
senderId: { required },
|
||||
endPoint: { required, ...urlValidators },
|
||||
timeOnPage: { required },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(validationRules, state);
|
||||
|
||||
const isCreating = computed(() => formState.uiFlags.value.isCreating);
|
||||
const isSubmitDisabled = computed(() => v$.value.$invalid);
|
||||
|
||||
const mapToOptions = (items, valueKey, labelKey) =>
|
||||
items?.map(item => ({
|
||||
value: item[valueKey],
|
||||
label: item[labelKey],
|
||||
})) ?? [];
|
||||
|
||||
const inboxOptions = computed(() =>
|
||||
mapToOptions(formState.inboxes.value, 'id', 'name')
|
||||
);
|
||||
|
||||
const sendersAndBotList = computed(() => [
|
||||
{ value: 0, label: 'Bot' },
|
||||
...mapToOptions(senderList.value, 'id', 'name'),
|
||||
]);
|
||||
|
||||
const getErrorMessage = (field, errorKey) => {
|
||||
const baseKey = 'CAMPAIGN.LIVE_CHAT.CREATE.FORM';
|
||||
return v$.value[field].$error ? t(`${baseKey}.${errorKey}.ERROR`) : '';
|
||||
};
|
||||
|
||||
const formErrors = computed(() => ({
|
||||
title: getErrorMessage('title', 'TITLE'),
|
||||
message: getErrorMessage('message', 'MESSAGE'),
|
||||
inbox: getErrorMessage('inboxId', 'INBOX'),
|
||||
endPoint: getErrorMessage('endPoint', 'END_POINT'),
|
||||
timeOnPage: getErrorMessage('timeOnPage', 'TIME_ON_PAGE'),
|
||||
sender: getErrorMessage('senderId', 'SENT_BY'),
|
||||
}));
|
||||
|
||||
const resetState = () => Object.assign(state, initialState);
|
||||
|
||||
const handleCancel = () => emit('cancel');
|
||||
|
||||
const handleInboxChange = async inboxId => {
|
||||
if (!inboxId) {
|
||||
senderList.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await store.dispatch('inboxMembers/get', { inboxId });
|
||||
senderList.value = response?.data?.payload ?? [];
|
||||
} catch (error) {
|
||||
senderList.value = [];
|
||||
useAlert(
|
||||
error?.response?.message ??
|
||||
t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.API.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const prepareCampaignDetails = () => ({
|
||||
title: state.title,
|
||||
message: state.message,
|
||||
inbox_id: state.inboxId,
|
||||
sender_id: state.senderId || null,
|
||||
enabled: state.enabled,
|
||||
trigger_only_during_business_hours: state.triggerOnlyDuringBusinessHours,
|
||||
trigger_rules: {
|
||||
url: state.endPoint,
|
||||
time_on_page: state.timeOnPage,
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const isFormValid = await v$.value.$validate();
|
||||
if (!isFormValid) return;
|
||||
|
||||
emit('submit', prepareCampaignDetails());
|
||||
if (props.mode === 'create') {
|
||||
resetState();
|
||||
handleCancel();
|
||||
}
|
||||
};
|
||||
|
||||
const updateStateFromCampaign = campaign => {
|
||||
if (!campaign) return;
|
||||
|
||||
const {
|
||||
title,
|
||||
message,
|
||||
inbox: { id: inboxId },
|
||||
sender,
|
||||
enabled,
|
||||
trigger_only_during_business_hours: triggerOnlyDuringBusinessHours,
|
||||
trigger_rules: { url: endPoint, time_on_page: timeOnPage },
|
||||
} = campaign;
|
||||
|
||||
Object.assign(state, {
|
||||
title,
|
||||
message,
|
||||
inboxId,
|
||||
senderId: sender?.id ?? 0,
|
||||
enabled,
|
||||
triggerOnlyDuringBusinessHours,
|
||||
endPoint,
|
||||
timeOnPage,
|
||||
});
|
||||
};
|
||||
|
||||
watch(
|
||||
() => state.inboxId,
|
||||
newInboxId => {
|
||||
if (newInboxId) {
|
||||
handleInboxChange(newInboxId);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.selectedCampaign,
|
||||
newCampaign => {
|
||||
if (props.mode === 'edit' && newCampaign) {
|
||||
updateStateFromCampaign(newCampaign);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
defineExpose({ prepareCampaignDetails, isSubmitDisabled });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
||||
<Input
|
||||
v-model="state.title"
|
||||
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.TITLE.LABEL')"
|
||||
:placeholder="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.TITLE.PLACEHOLDER')"
|
||||
:message="formErrors.title"
|
||||
:message-type="formErrors.title ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<Editor
|
||||
v-model="state.message"
|
||||
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.MESSAGE.LABEL')"
|
||||
:placeholder="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.MESSAGE.PLACEHOLDER')"
|
||||
:message="formErrors.message"
|
||||
:message-type="formErrors.message ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="inbox" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.INBOX.LABEL') }}
|
||||
</label>
|
||||
<ComboBox
|
||||
id="inbox"
|
||||
v-model="state.inboxId"
|
||||
:options="inboxOptions"
|
||||
:has-error="!!formErrors.inbox"
|
||||
:placeholder="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.INBOX.PLACEHOLDER')"
|
||||
:message="formErrors.inbox"
|
||||
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="sentBy" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.SENT_BY.LABEL') }}
|
||||
</label>
|
||||
<ComboBox
|
||||
id="sentBy"
|
||||
v-model="state.senderId"
|
||||
:options="sendersAndBotList"
|
||||
:has-error="!!formErrors.sender"
|
||||
:disabled="!state.inboxId"
|
||||
:placeholder="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.SENT_BY.PLACEHOLDER')"
|
||||
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
|
||||
:message="formErrors.sender"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
v-model="state.endPoint"
|
||||
type="url"
|
||||
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.END_POINT.LABEL')"
|
||||
:placeholder="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.END_POINT.PLACEHOLDER')"
|
||||
:message="formErrors.endPoint"
|
||||
:message-type="formErrors.endPoint ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<Input
|
||||
v-model="state.timeOnPage"
|
||||
type="number"
|
||||
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.TIME_ON_PAGE.LABEL')"
|
||||
:placeholder="
|
||||
t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.TIME_ON_PAGE.PLACEHOLDER')
|
||||
"
|
||||
:message="formErrors.timeOnPage"
|
||||
:message-type="formErrors.timeOnPage ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<fieldset class="flex flex-col gap-2.5">
|
||||
<legend class="mb-2.5 text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.OTHER_PREFERENCES.TITLE') }}
|
||||
</legend>
|
||||
|
||||
<label class="flex items-center gap-2">
|
||||
<input v-model="state.enabled" type="checkbox" />
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.OTHER_PREFERENCES.ENABLED') }}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2">
|
||||
<input v-model="state.triggerOnlyDuringBusinessHours" type="checkbox" />
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{
|
||||
t(
|
||||
'CAMPAIGN.LIVE_CHAT.CREATE.FORM.OTHER_PREFERENCES.TRIGGER_ONLY_BUSINESS_HOURS'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div
|
||||
v-if="showActionButtons"
|
||||
class="flex items-center justify-between w-full gap-3"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.BUTTONS.CANCEL')"
|
||||
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
:label="
|
||||
t(`CAMPAIGN.LIVE_CHAT.CREATE.FORM.BUTTONS.${mode.toUpperCase()}`)
|
||||
"
|
||||
class="w-full"
|
||||
:is-loading="isCreating"
|
||||
:disabled="isCreating || isSubmitDisabled"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { CAMPAIGN_TYPES } from 'shared/constants/campaign.js';
|
||||
import { CAMPAIGNS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events.js';
|
||||
|
||||
import SMSCampaignForm from 'dashboard/components-next/Campaigns/Pages/CampaignPage/SMSCampaign/SMSCampaignForm.vue';
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const addCampaign = async campaignDetails => {
|
||||
try {
|
||||
await store.dispatch('campaigns/create', campaignDetails);
|
||||
|
||||
// tracking this here instead of the store to track the type of campaign
|
||||
useTrack(CAMPAIGNS_EVENTS.CREATE_CAMPAIGN, {
|
||||
type: CAMPAIGN_TYPES.ONE_OFF,
|
||||
});
|
||||
|
||||
useAlert(t('CAMPAIGN.SMS.CREATE.FORM.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.response?.message ||
|
||||
t('CAMPAIGN.SMS.CREATE.FORM.API.ERROR_MESSAGE');
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = campaignDetails => {
|
||||
addCampaign(campaignDetails);
|
||||
};
|
||||
|
||||
const handleClose = () => emit('close');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-[400px] z-50 min-w-0 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(`CAMPAIGN.SMS.CREATE.TITLE`) }}
|
||||
</h3>
|
||||
<SMSCampaignForm @submit="handleSubmit" @cancel="handleClose" />
|
||||
</div>
|
||||
</template>
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
<script setup>
|
||||
import { reactive, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength } from '@vuelidate/validators';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
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';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import TagMultiSelectComboBox from 'dashboard/components-next/combobox/TagMultiSelectComboBox.vue';
|
||||
|
||||
const emit = defineEmits(['submit', 'cancel']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const formState = {
|
||||
uiFlags: useMapGetter('campaigns/getUIFlags'),
|
||||
labels: useMapGetter('labels/getLabels'),
|
||||
inboxes: useMapGetter('inboxes/getSMSInboxes'),
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
title: '',
|
||||
message: '',
|
||||
inboxId: null,
|
||||
scheduledAt: null,
|
||||
selectedAudience: [],
|
||||
};
|
||||
|
||||
const state = reactive({ ...initialState });
|
||||
|
||||
const rules = {
|
||||
title: { required, minLength: minLength(1) },
|
||||
message: { required, minLength: minLength(1) },
|
||||
inboxId: { required },
|
||||
scheduledAt: { required },
|
||||
selectedAudience: { required },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(rules, state);
|
||||
|
||||
const isCreating = computed(() => formState.uiFlags.value.isCreating);
|
||||
|
||||
const currentDateTime = computed(() => {
|
||||
// Added to disable the scheduled at field from being set to the current time
|
||||
const now = new Date();
|
||||
const localTime = new Date(now.getTime() - now.getTimezoneOffset() * 60000);
|
||||
return localTime.toISOString().slice(0, 16);
|
||||
});
|
||||
|
||||
const mapToOptions = (items, valueKey, labelKey) =>
|
||||
items?.map(item => ({
|
||||
value: item[valueKey],
|
||||
label: item[labelKey],
|
||||
})) ?? [];
|
||||
|
||||
const audienceList = computed(() =>
|
||||
mapToOptions(formState.labels.value, 'id', 'title')
|
||||
);
|
||||
|
||||
const inboxOptions = computed(() =>
|
||||
mapToOptions(formState.inboxes.value, 'id', 'name')
|
||||
);
|
||||
|
||||
const getErrorMessage = (field, errorKey) => {
|
||||
const baseKey = 'CAMPAIGN.SMS.CREATE.FORM';
|
||||
return v$.value[field].$error ? t(`${baseKey}.${errorKey}.ERROR`) : '';
|
||||
};
|
||||
|
||||
const formErrors = computed(() => ({
|
||||
title: getErrorMessage('title', 'TITLE'),
|
||||
message: getErrorMessage('message', 'MESSAGE'),
|
||||
inbox: getErrorMessage('inboxId', 'INBOX'),
|
||||
scheduledAt: getErrorMessage('scheduledAt', 'SCHEDULED_AT'),
|
||||
audience: getErrorMessage('selectedAudience', 'AUDIENCE'),
|
||||
}));
|
||||
|
||||
const isSubmitDisabled = computed(() => v$.value.$invalid);
|
||||
|
||||
const formatToUTCString = localDateTime =>
|
||||
localDateTime ? new Date(localDateTime).toISOString() : null;
|
||||
|
||||
const resetState = () => {
|
||||
Object.assign(state, initialState);
|
||||
};
|
||||
|
||||
const handleCancel = () => emit('cancel');
|
||||
|
||||
const prepareCampaignDetails = () => ({
|
||||
title: state.title,
|
||||
message: state.message,
|
||||
inbox_id: state.inboxId,
|
||||
scheduled_at: formatToUTCString(state.scheduledAt),
|
||||
audience: state.selectedAudience?.map(id => ({
|
||||
id,
|
||||
type: 'Label',
|
||||
})),
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const isFormValid = await v$.value.$validate();
|
||||
if (!isFormValid) return;
|
||||
|
||||
emit('submit', prepareCampaignDetails());
|
||||
resetState();
|
||||
handleCancel();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
||||
<Input
|
||||
v-model="state.title"
|
||||
:label="t('CAMPAIGN.SMS.CREATE.FORM.TITLE.LABEL')"
|
||||
:placeholder="t('CAMPAIGN.SMS.CREATE.FORM.TITLE.PLACEHOLDER')"
|
||||
:message="formErrors.title"
|
||||
:message-type="formErrors.title ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
v-model="state.message"
|
||||
:label="t('CAMPAIGN.SMS.CREATE.FORM.MESSAGE.LABEL')"
|
||||
:placeholder="t('CAMPAIGN.SMS.CREATE.FORM.MESSAGE.PLACEHOLDER')"
|
||||
show-character-count
|
||||
:message="formErrors.message"
|
||||
:message-type="formErrors.message ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="inbox" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAMPAIGN.SMS.CREATE.FORM.INBOX.LABEL') }}
|
||||
</label>
|
||||
<ComboBox
|
||||
id="inbox"
|
||||
v-model="state.inboxId"
|
||||
:options="inboxOptions"
|
||||
:has-error="!!formErrors.inbox"
|
||||
:placeholder="t('CAMPAIGN.SMS.CREATE.FORM.INBOX.PLACEHOLDER')"
|
||||
:message="formErrors.inbox"
|
||||
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="audience" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAMPAIGN.SMS.CREATE.FORM.AUDIENCE.LABEL') }}
|
||||
</label>
|
||||
<TagMultiSelectComboBox
|
||||
v-model="state.selectedAudience"
|
||||
:options="audienceList"
|
||||
:label="t('CAMPAIGN.SMS.CREATE.FORM.AUDIENCE.LABEL')"
|
||||
:placeholder="t('CAMPAIGN.SMS.CREATE.FORM.AUDIENCE.PLACEHOLDER')"
|
||||
:has-error="!!formErrors.audience"
|
||||
:message="formErrors.audience"
|
||||
class="[&>div>button]:bg-n-alpha-black2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
v-model="state.scheduledAt"
|
||||
:label="t('CAMPAIGN.SMS.CREATE.FORM.SCHEDULED_AT.LABEL')"
|
||||
type="datetime-local"
|
||||
:min="currentDateTime"
|
||||
:placeholder="t('CAMPAIGN.SMS.CREATE.FORM.SCHEDULED_AT.PLACEHOLDER')"
|
||||
:message="formErrors.scheduledAt"
|
||||
:message-type="formErrors.scheduledAt ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-between w-full gap-3">
|
||||
<Button
|
||||
variant="faded"
|
||||
color="slate"
|
||||
type="button"
|
||||
:label="t('CAMPAIGN.SMS.CREATE.FORM.BUTTONS.CANCEL')"
|
||||
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
<Button
|
||||
:label="t('CAMPAIGN.SMS.CREATE.FORM.BUTTONS.CREATE')"
|
||||
class="w-full"
|
||||
type="submit"
|
||||
:is-loading="isCreating"
|
||||
:disabled="isCreating || isSubmitDisabled"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -1,4 +1,10 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
layout: {
|
||||
type: String,
|
||||
default: 'col',
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['click']);
|
||||
const handleClick = () => {
|
||||
emit('click');
|
||||
@@ -7,7 +13,8 @@ const handleClick = () => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex flex-col w-full gap-3 px-6 py-5 group/cardLayout rounded-2xl bg-slate-25 dark:bg-slate-800/50"
|
||||
class="relative flex w-full gap-3 px-6 py-5 shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2"
|
||||
:class="props.layout === 'col' ? 'flex-col' : 'flex-row'"
|
||||
@click="handleClick"
|
||||
>
|
||||
<slot name="header" />
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
conversationLabels: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
accountLabels: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const WIDTH_CONFIG = Object.freeze({
|
||||
DEFAULT_WIDTH: 80,
|
||||
CHAR_WIDTH: {
|
||||
SHORT: 8, // For labels <= 5 chars
|
||||
LONG: 6, // For labels > 5 chars
|
||||
},
|
||||
BASE_WIDTH: 12, // dot + gap
|
||||
THRESHOLD: 5, // character length threshold
|
||||
});
|
||||
|
||||
const containerRef = ref(null);
|
||||
const maxLabels = ref(1);
|
||||
|
||||
const activeLabels = computed(() => {
|
||||
const labelSet = new Set(props.conversationLabels);
|
||||
return props.accountLabels?.filter(({ title }) => labelSet.has(title));
|
||||
});
|
||||
|
||||
const calculateLabelWidth = ({ title = '' }) => {
|
||||
const charWidth =
|
||||
title.length > WIDTH_CONFIG.THRESHOLD
|
||||
? WIDTH_CONFIG.CHAR_WIDTH.LONG
|
||||
: WIDTH_CONFIG.CHAR_WIDTH.SHORT;
|
||||
|
||||
return title.length * charWidth + WIDTH_CONFIG.BASE_WIDTH;
|
||||
};
|
||||
|
||||
const getAverageWidth = labels => {
|
||||
if (!labels.length) return WIDTH_CONFIG.DEFAULT_WIDTH;
|
||||
|
||||
const totalWidth = labels.reduce(
|
||||
(sum, label) => sum + calculateLabelWidth(label),
|
||||
0
|
||||
);
|
||||
|
||||
return totalWidth / labels.length;
|
||||
};
|
||||
|
||||
const visibleLabels = computed(() =>
|
||||
activeLabels.value?.slice(0, maxLabels.value)
|
||||
);
|
||||
|
||||
const updateVisibleLabels = () => {
|
||||
if (!containerRef.value) return;
|
||||
|
||||
const containerWidth = containerRef.value.offsetWidth;
|
||||
const avgWidth = getAverageWidth(activeLabels.value);
|
||||
|
||||
maxLabels.value = Math.max(1, Math.floor(containerWidth / avgWidth));
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
v-resize="updateVisibleLabels"
|
||||
class="flex items-center gap-2.5 w-full min-w-0 h-6 overflow-hidden"
|
||||
>
|
||||
<template v-for="(label, index) in visibleLabels" :key="label.id">
|
||||
<div
|
||||
class="flex items-center gap-1.5 min-w-0"
|
||||
:class="[
|
||||
index !== visibleLabels.length - 1
|
||||
? 'flex-shrink-0 text-ellipsis'
|
||||
: 'flex-shrink',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:style="{ backgroundColor: label.color }"
|
||||
class="size-1.5 rounded-full flex-shrink-0"
|
||||
/>
|
||||
<span
|
||||
class="text-sm text-n-slate-10 whitespace-nowrap"
|
||||
:class="{ truncate: index === visibleLabels.length - 1 }"
|
||||
>
|
||||
{{ label.title }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
|
||||
const props = defineProps({
|
||||
conversation: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const lastNonActivityMessageContent = computed(() => {
|
||||
const { lastNonActivityMessage = {} } = props.conversation;
|
||||
return lastNonActivityMessage?.content || t('CHAT_LIST.NO_CONTENT');
|
||||
});
|
||||
|
||||
const assignee = computed(() => {
|
||||
const { meta: { assignee: agent = {} } = {} } = props.conversation;
|
||||
return {
|
||||
name: agent.name ?? agent.availableName,
|
||||
thumbnail: agent.thumbnail,
|
||||
status: agent.availabilityStatus,
|
||||
};
|
||||
});
|
||||
|
||||
const unreadMessagesCount = computed(() => {
|
||||
const { unreadCount } = props.conversation;
|
||||
return unreadCount;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-end w-full gap-2 pb-1">
|
||||
<p class="w-full mb-0 text-sm leading-7 text-n-slate-12 line-clamp-2">
|
||||
{{ lastNonActivityMessageContent }}
|
||||
</p>
|
||||
<div class="flex items-center flex-shrink-0 gap-2 pb-2">
|
||||
<Avatar
|
||||
:name="assignee.name"
|
||||
:src="assignee.thumbnail"
|
||||
:size="20"
|
||||
:status="assignee.status"
|
||||
rounded-full
|
||||
/>
|
||||
<div
|
||||
v-if="unreadMessagesCount > 0"
|
||||
class="inline-flex items-center justify-center rounded-full size-5 bg-n-brand"
|
||||
>
|
||||
<span class="text-xs font-semibold text-white">
|
||||
{{ unreadMessagesCount }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import CardLabels from 'dashboard/components-next/Conversation/ConversationCard/CardLabels.vue';
|
||||
import SLACardLabel from 'dashboard/components-next/Conversation/ConversationCard/SLACardLabel.vue';
|
||||
|
||||
const props = defineProps({
|
||||
conversation: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
accountLabels: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const lastNonActivityMessageContent = computed(() => {
|
||||
const { lastNonActivityMessage = {} } = props.conversation;
|
||||
return lastNonActivityMessage?.content || t('CHAT_LIST.NO_CONTENT');
|
||||
});
|
||||
|
||||
const assignee = computed(() => {
|
||||
const { meta: { assignee: agent = {} } = {} } = props.conversation;
|
||||
return {
|
||||
name: agent.name ?? agent.availableName,
|
||||
thumbnail: agent.thumbnail,
|
||||
status: agent.availabilityStatus,
|
||||
};
|
||||
});
|
||||
|
||||
const unreadMessagesCount = computed(() => {
|
||||
const { unreadCount } = props.conversation;
|
||||
return unreadCount;
|
||||
});
|
||||
|
||||
const hasSlaThreshold = computed(() => props.conversation?.slaPolicyId);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col w-full gap-1">
|
||||
<div class="flex items-center justify-between w-full gap-2 py-1 h-7">
|
||||
<p class="mb-0 text-sm leading-7 text-n-slate-12 line-clamp-1">
|
||||
{{ lastNonActivityMessageContent }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="unreadMessagesCount > 0"
|
||||
class="inline-flex items-center justify-center flex-shrink-0 rounded-full size-5 bg-n-brand"
|
||||
>
|
||||
<span class="text-xs font-semibold text-white">
|
||||
{{ unreadMessagesCount }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid items-center gap-2.5 h-7"
|
||||
:class="
|
||||
hasSlaThreshold
|
||||
? 'grid-cols-[auto_auto_1fr_20px]'
|
||||
: 'grid-cols-[1fr_20px]'
|
||||
"
|
||||
>
|
||||
<SLACardLabel v-if="hasSlaThreshold" :conversation="conversation" />
|
||||
<div v-if="hasSlaThreshold" class="w-px h-3 bg-n-slate-4" />
|
||||
<div class="overflow-hidden">
|
||||
<CardLabels
|
||||
:conversation-labels="conversation.labels"
|
||||
:account-labels="accountLabels"
|
||||
/>
|
||||
</div>
|
||||
<Avatar
|
||||
:name="assignee.name"
|
||||
:src="assignee.thumbnail"
|
||||
:size="20"
|
||||
:status="assignee.status"
|
||||
rounded-full
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
<script setup>
|
||||
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
|
||||
|
||||
defineProps({
|
||||
priority: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-static-inline-styles -->
|
||||
<template>
|
||||
<div class="inline-flex items-center justify-center rounded-md">
|
||||
<!-- Low Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.LOW"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-slate-6"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-slate-6"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- Medium Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.MEDIUM"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-slate-6"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- High Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.HIGH"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- Urgent Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.URGENT"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-ruby-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-ruby-9"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-ruby-9"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
+477
@@ -0,0 +1,477 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import ConversationCard from './ConversationCard.vue';
|
||||
|
||||
// Base conversation object
|
||||
const conversationWithoutMeta = {
|
||||
meta: {
|
||||
sender: {
|
||||
additionalAttributes: {},
|
||||
availabilityStatus: 'offline',
|
||||
email: 'candice@chatwoot.com',
|
||||
id: 29,
|
||||
name: 'Candice Matherson',
|
||||
phone_number: '+918585858585',
|
||||
identifier: null,
|
||||
thumbnail: '',
|
||||
customAttributes: {
|
||||
linkContact: 'https://apple.com',
|
||||
listContact: 'Not spam',
|
||||
textContact: 'hey',
|
||||
checkboxContact: true,
|
||||
},
|
||||
last_activity_at: 1712127410,
|
||||
created_at: 1712127389,
|
||||
},
|
||||
channel: 'Channel::Email',
|
||||
assignee: {
|
||||
id: 1,
|
||||
accountId: 2,
|
||||
availabilityStatus: 'online',
|
||||
autoOffline: false,
|
||||
confirmed: true,
|
||||
email: 'sivin@chatwoot.com',
|
||||
availableName: 'Sivin',
|
||||
name: 'Sivin',
|
||||
role: 'administrator',
|
||||
thumbnail: '',
|
||||
customRoleId: null,
|
||||
},
|
||||
hmacVerified: false,
|
||||
},
|
||||
id: 38,
|
||||
messages: [
|
||||
{
|
||||
id: 3597,
|
||||
content: 'Sivin set the priority to low',
|
||||
accountId: 2,
|
||||
inboxId: 7,
|
||||
conversationId: 38,
|
||||
messageType: 2,
|
||||
createdAt: 1730885168,
|
||||
updatedAt: '2024-11-06T09:26:08.565Z',
|
||||
private: false,
|
||||
status: 'sent',
|
||||
source_id: null,
|
||||
contentType: 'text',
|
||||
contentAttributes: {},
|
||||
senderType: null,
|
||||
senderId: null,
|
||||
externalSourceIds: {},
|
||||
additionalAttributes: {},
|
||||
processedMessageContent: 'Sivin set the priority to low',
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assigneeId: 1,
|
||||
unreadCount: 0,
|
||||
lastActivityAt: 1730885168,
|
||||
contactInbox: {
|
||||
sourceId: 'candice@chatwoot.com',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
accountId: 2,
|
||||
uuid: '21bd8638-a711-4080-b4ac-7fda1bc71837',
|
||||
additionalAttributes: {
|
||||
mail_subject: 'Test email',
|
||||
},
|
||||
agentLastSeenAt: 0,
|
||||
assigneeLastSeenAt: 0,
|
||||
canReply: true,
|
||||
contactLastSeenAt: 0,
|
||||
customAttributes: {},
|
||||
inboxId: 7,
|
||||
labels: [],
|
||||
status: 'open',
|
||||
createdAt: 1730836533,
|
||||
timestamp: 1730885168,
|
||||
firstReplyCreatedAt: 1730836533,
|
||||
unreadCount: 0,
|
||||
lastNonActivityMessage: {
|
||||
id: 3591,
|
||||
content:
|
||||
'Hello, I bought some paper but they did not come with the indices as we had assumed. Was there a change in the product line?',
|
||||
account_id: 2,
|
||||
inbox_id: 7,
|
||||
conversation_id: 38,
|
||||
message_type: 1,
|
||||
created_at: 1730836533,
|
||||
updated_at: '2024-11-05T19:55:37.158Z',
|
||||
private: false,
|
||||
status: 'sent',
|
||||
source_id:
|
||||
'conversation/21bd8638-a711-4080-b4ac-7fda1bc71837/messages/3591@paperlayer.test',
|
||||
content_type: 'text',
|
||||
content_attributes: {
|
||||
cc_emails: ['test@gmail.com'],
|
||||
bcc_emails: [],
|
||||
to_emails: [],
|
||||
},
|
||||
sender_type: 'User',
|
||||
sender_id: 1,
|
||||
external_source_ids: {},
|
||||
additional_attributes: {},
|
||||
processed_message_content:
|
||||
'Hello, I bought some paper but they did not come with the indices as we had assumed. Was there a change in the product line?',
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assignee_id: 1,
|
||||
unread_count: 0,
|
||||
last_activity_at: 1730885168,
|
||||
contact_inbox: {
|
||||
source_id: 'candice@chatwoot.com',
|
||||
},
|
||||
},
|
||||
sender: {
|
||||
id: 1,
|
||||
name: 'Sivin',
|
||||
available_name: 'Sivin',
|
||||
avatar_url: '',
|
||||
type: 'user',
|
||||
availability_status: 'online',
|
||||
thumbnail: '',
|
||||
},
|
||||
},
|
||||
lastActivityAt: 1730885168,
|
||||
priority: 'low',
|
||||
waitingSince: 0,
|
||||
slaPolicyId: null,
|
||||
slaEvents: [],
|
||||
};
|
||||
|
||||
const conversationWithMeta = {
|
||||
meta: {
|
||||
sender: {
|
||||
additionalAttributes: {},
|
||||
availabilityStatus: 'offline',
|
||||
email: 'willy@chatwoot.com',
|
||||
id: 29,
|
||||
name: 'Willy Castelot',
|
||||
phoneNumber: '+918585858585',
|
||||
identifier: null,
|
||||
thumbnail: '',
|
||||
customAttributes: {
|
||||
linkContact: 'https://apple.com',
|
||||
listContact: 'Not spam',
|
||||
textContact: 'hey',
|
||||
checkboxContact: true,
|
||||
},
|
||||
lastActivityAt: 1712127410,
|
||||
createdAt: 1712127389,
|
||||
},
|
||||
channel: 'Channel::Email',
|
||||
assignee: {
|
||||
id: 1,
|
||||
accountId: 2,
|
||||
availabilityStatus: 'online',
|
||||
autoOffline: false,
|
||||
confirmed: true,
|
||||
email: 'sivin@chatwoot.com',
|
||||
availableName: 'Sivin',
|
||||
name: 'Sivin',
|
||||
role: 'administrator',
|
||||
thumbnail: '',
|
||||
customRoleId: null,
|
||||
},
|
||||
hmacVerified: false,
|
||||
},
|
||||
id: 37,
|
||||
messages: [
|
||||
{
|
||||
id: 3599,
|
||||
content:
|
||||
'If you want to buy our premium supplies,we can offer you a 20% discount! They come with indices and lazer beams!',
|
||||
accountId: 2,
|
||||
inboxId: 7,
|
||||
conversationId: 37,
|
||||
messageType: 1,
|
||||
createdAt: 1730885428,
|
||||
updatedAt: '2024-11-06T09:30:30.619Z',
|
||||
private: false,
|
||||
status: 'sent',
|
||||
sourceId:
|
||||
'conversation/53df668d-329d-420e-8fe9-980cb0e4d63c/messages/3599@paperlayer.test',
|
||||
contentType: 'text',
|
||||
contentAttributes: {
|
||||
ccEmails: [],
|
||||
bccEmails: [],
|
||||
toEmails: [],
|
||||
},
|
||||
sender_type: 'User',
|
||||
senderId: 1,
|
||||
externalSourceIds: {},
|
||||
additionalAttributes: {},
|
||||
processedMessageContent:
|
||||
'If you want to buy our premium supplies,we can offer you a 20% discount! They come with indices and lazer beams!',
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assignee_id: 1,
|
||||
unread_count: 0,
|
||||
last_activity_at: 1730885428,
|
||||
contact_inbox: {
|
||||
source_id: 'candice@chatwoot.com',
|
||||
},
|
||||
},
|
||||
sender: {
|
||||
id: 1,
|
||||
name: 'Sivin',
|
||||
availableName: 'Sivin',
|
||||
avatarUrl: '',
|
||||
type: 'user',
|
||||
availabilityStatus: 'online',
|
||||
thumbnail: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
accountId: 2,
|
||||
uuid: '53df668d-329d-420e-8fe9-980cb0e4d63c',
|
||||
additionalAttributes: {
|
||||
mail_subject: 'we',
|
||||
},
|
||||
agentLastSeenAt: 1730885428,
|
||||
assigneeLastSeenAt: 1730885428,
|
||||
canReply: true,
|
||||
contactLastSeenAt: 0,
|
||||
customAttributes: {},
|
||||
inboxId: 7,
|
||||
labels: [
|
||||
'billing',
|
||||
'delivery',
|
||||
'lead',
|
||||
'premium-customer',
|
||||
'software',
|
||||
'ops-handover',
|
||||
],
|
||||
muted: false,
|
||||
snoozedUntil: null,
|
||||
status: 'open',
|
||||
createdAt: 1722487645,
|
||||
timestamp: 1730885428,
|
||||
firstReplyCreatedAt: 1722487645,
|
||||
unreadCount: 0,
|
||||
lastNonActivityMessage: {
|
||||
id: 3599,
|
||||
content:
|
||||
'If you want to buy our premium supplies,we can offer you a 20% discount! They come with indices and lazer beams!',
|
||||
account_id: 2,
|
||||
inbox_id: 7,
|
||||
conversation_id: 37,
|
||||
message_type: 1,
|
||||
created_at: 1730885428,
|
||||
updated_at: '2024-11-06T09:30:30.619Z',
|
||||
private: false,
|
||||
status: 'sent',
|
||||
source_id:
|
||||
'conversation/53df668d-329d-420e-8fe9-980cb0e4d63c/messages/3599@paperlayer.test',
|
||||
content_type: 'text',
|
||||
content_attributes: {
|
||||
cc_emails: [],
|
||||
bcc_emails: [],
|
||||
to_emails: [],
|
||||
},
|
||||
sender_type: 'User',
|
||||
sender_id: 1,
|
||||
external_source_ids: {},
|
||||
additional_attributes: {},
|
||||
processed_message_content:
|
||||
'If you want to buy our premium supplies,we can offer you a 20% discount! They come with indices and lazer beams!',
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assignee_id: 1,
|
||||
unread_count: 2,
|
||||
last_activity_at: 1730885428,
|
||||
contact_inbox: {
|
||||
source_id: 'willy@chatwoot.com',
|
||||
},
|
||||
},
|
||||
sender: {
|
||||
id: 1,
|
||||
name: 'Sivin',
|
||||
available_name: 'Sivin',
|
||||
avatar_url: '',
|
||||
type: 'user',
|
||||
availability_status: 'online',
|
||||
thumbnail: '',
|
||||
},
|
||||
},
|
||||
lastActivityAt: 1730885428,
|
||||
priority: 'urgent',
|
||||
waitingSince: 1730885428,
|
||||
slaPolicyId: 3,
|
||||
appliedSla: {
|
||||
id: 4,
|
||||
sla_id: 3,
|
||||
sla_status: 'active_with_misses',
|
||||
created_at: 1712127410,
|
||||
updated_at: 1712127545,
|
||||
sla_description:
|
||||
'Premium Service Level Agreements (SLAs) are contracts that define clear expectations ',
|
||||
sla_name: 'Premium SLA',
|
||||
sla_first_response_time_threshold: 120,
|
||||
sla_next_response_time_threshold: 180,
|
||||
sla_only_during_business_hours: false,
|
||||
sla_resolution_time_threshold: 360,
|
||||
},
|
||||
slaEvents: [
|
||||
{
|
||||
id: 8,
|
||||
event_type: 'frt',
|
||||
meta: {},
|
||||
updated_at: 1712127545,
|
||||
created_at: 1712127545,
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
event_type: 'rt',
|
||||
meta: {},
|
||||
updated_at: 1712127790,
|
||||
created_at: 1712127790,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const contactForConversationWithoutMeta = computed(() => ({
|
||||
availabilityStatus: null,
|
||||
email: 'candice@chatwoot.com',
|
||||
id: 29,
|
||||
name: 'Candice Matherson',
|
||||
phoneNumber: '+918585858585',
|
||||
identifier: null,
|
||||
thumbnail: 'https://api.dicebear.com/9.x/dylan/svg?seed=George',
|
||||
customAttributes: {},
|
||||
last_activity_at: 1712127410,
|
||||
createdAt: 1712127389,
|
||||
contactInboxes: [],
|
||||
}));
|
||||
|
||||
const contactForConversationWithMeta = computed(() => ({
|
||||
availabilityStatus: null,
|
||||
email: 'willy@chatwoot.com',
|
||||
id: 29,
|
||||
name: 'Willy Castelot',
|
||||
phoneNumber: '+918585858585',
|
||||
identifier: null,
|
||||
thumbnail: 'https://api.dicebear.com/9.x/dylan/svg?seed=Liam',
|
||||
customAttributes: {},
|
||||
lastActivityAt: 1712127410,
|
||||
createdAt: 1712127389,
|
||||
contactInboxes: [],
|
||||
}));
|
||||
|
||||
const webWidgetInbox = computed(() => ({
|
||||
phone_number: '+918585858585',
|
||||
channel_type: 'Channel::WebWidget',
|
||||
}));
|
||||
|
||||
const accountLabels = computed(() => [
|
||||
{
|
||||
id: 1,
|
||||
title: 'billing',
|
||||
description: 'Label is used for tagging billing related conversations',
|
||||
color: '#28AD21',
|
||||
show_on_sidebar: true,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'delivery',
|
||||
description: null,
|
||||
color: '#A2FDD5',
|
||||
show_on_sidebar: true,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: 'lead',
|
||||
description: null,
|
||||
color: '#F161C8',
|
||||
show_on_sidebar: true,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'ops-handover',
|
||||
description: null,
|
||||
color: '#A53326',
|
||||
show_on_sidebar: true,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: 'premium-customer',
|
||||
description: null,
|
||||
color: '#6FD4EF',
|
||||
show_on_sidebar: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'software',
|
||||
description: null,
|
||||
color: '#8F6EF2',
|
||||
show_on_sidebar: true,
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/ConversationCard"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Conversation without meta">
|
||||
<div class="flex flex-col">
|
||||
<ConversationCard
|
||||
:key="conversationWithoutMeta.id"
|
||||
:conversation="conversationWithoutMeta"
|
||||
:contact="contactForConversationWithoutMeta"
|
||||
:state-inbox="webWidgetInbox"
|
||||
:account-labels="accountLabels"
|
||||
class="hover:bg-n-alpha-1"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Conversation with meta (SLA, Labels)">
|
||||
<div class="flex flex-col">
|
||||
<ConversationCard
|
||||
:key="conversationWithMeta.id"
|
||||
:conversation="{
|
||||
...conversationWithMeta,
|
||||
priority: 'medium',
|
||||
}"
|
||||
:contact="contactForConversationWithMeta"
|
||||
:state-inbox="webWidgetInbox"
|
||||
:account-labels="accountLabels"
|
||||
class="hover:bg-n-alpha-1"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Conversation without meta (Unread count)">
|
||||
<div class="flex flex-col">
|
||||
<ConversationCard
|
||||
:key="conversationWithoutMeta.id"
|
||||
:conversation="{
|
||||
...conversationWithoutMeta,
|
||||
unreadCount: 2,
|
||||
priority: 'high',
|
||||
}"
|
||||
:contact="contactForConversationWithoutMeta"
|
||||
:state-inbox="webWidgetInbox"
|
||||
:account-labels="accountLabels"
|
||||
class="hover:bg-n-alpha-1"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Conversation with meta (SLA, Labels, Unread count)">
|
||||
<div class="flex flex-col">
|
||||
<ConversationCard
|
||||
:key="conversationWithMeta.id"
|
||||
:conversation="{
|
||||
...conversationWithMeta,
|
||||
unreadCount: 2,
|
||||
}"
|
||||
:contact="contactForConversationWithMeta"
|
||||
:state-inbox="webWidgetInbox"
|
||||
:account-labels="accountLabels"
|
||||
class="hover:bg-n-alpha-1"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { getInboxIconByType } from 'dashboard/helper/inbox';
|
||||
import { dynamicTime, shortTimestamp } from 'shared/helpers/timeHelper';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import CardMessagePreview from './CardMessagePreview.vue';
|
||||
import CardMessagePreviewWithMeta from './CardMessagePreviewWithMeta.vue';
|
||||
import CardPriorityIcon from './CardPriorityIcon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
conversation: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
contact: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
stateInbox: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
accountLabels: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const currentContact = computed(() => props.contact);
|
||||
|
||||
const currentContactName = computed(() => currentContact.value?.name);
|
||||
const currentContactThumbnail = computed(() => currentContact.value?.thumbnail);
|
||||
const currentContactStatus = computed(
|
||||
() => currentContact.value?.availabilityStatus
|
||||
);
|
||||
|
||||
const inbox = computed(() => props.stateInbox);
|
||||
|
||||
const inboxName = computed(() => inbox.value?.name);
|
||||
|
||||
const inboxIcon = computed(() => {
|
||||
const { phoneNumber, channelType } = inbox.value;
|
||||
return getInboxIconByType(channelType, phoneNumber);
|
||||
});
|
||||
|
||||
const lastActivityAt = computed(() => {
|
||||
const timestamp = props.conversation?.timestamp;
|
||||
return timestamp ? shortTimestamp(dynamicTime(timestamp)) : '';
|
||||
});
|
||||
|
||||
const showMessagePreviewWithoutMeta = computed(() => {
|
||||
const { slaPolicyId, labels = [] } = props.conversation;
|
||||
return !slaPolicyId && labels.length === 0;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full gap-3 px-3 py-4 transition-colors duration-300 ease-in-out rounded-xl"
|
||||
>
|
||||
<Avatar
|
||||
:name="currentContactName"
|
||||
:src="currentContactThumbnail"
|
||||
:size="24"
|
||||
:status="currentContactStatus"
|
||||
rounded-full
|
||||
/>
|
||||
<div class="flex flex-col w-full gap-1">
|
||||
<div class="flex items-center justify-between h-6 gap-2">
|
||||
<h4 class="text-base font-medium truncate text-n-slate-12">
|
||||
{{ currentContactName }}
|
||||
</h4>
|
||||
<div class="flex items-center gap-2">
|
||||
<CardPriorityIcon :priority="conversation.priority || null" />
|
||||
<div
|
||||
v-tooltip.top-start="inboxName"
|
||||
class="flex items-center justify-center flex-shrink-0 rounded-full bg-n-alpha-2 size-5"
|
||||
>
|
||||
<Icon
|
||||
:icon="inboxIcon"
|
||||
class="flex-shrink-0 text-n-slate-11 size-3"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-sm text-n-slate-10">
|
||||
{{ lastActivityAt }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<CardMessagePreview
|
||||
v-if="showMessagePreviewWithoutMeta"
|
||||
:conversation="conversation"
|
||||
/>
|
||||
<CardMessagePreviewWithMeta
|
||||
v-else
|
||||
:conversation="conversation"
|
||||
:account-labels="accountLabels"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { evaluateSLAStatus } from '@chatwoot/utils';
|
||||
|
||||
const props = defineProps({
|
||||
conversation: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const REFRESH_INTERVAL = 60000;
|
||||
|
||||
const timer = ref(null);
|
||||
const slaStatus = ref({
|
||||
threshold: null,
|
||||
isSlaMissed: false,
|
||||
type: null,
|
||||
icon: null,
|
||||
});
|
||||
|
||||
// TODO: Remove this once we update the helper from utils
|
||||
// https://github.com/chatwoot/utils/blob/main/src/sla.ts#L73
|
||||
const convertObjectCamelCaseToSnakeCase = object => {
|
||||
return Object.keys(object).reduce((acc, key) => {
|
||||
acc[key.replace(/([A-Z])/g, '_$1').toLowerCase()] = object[key];
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const appliedSLA = computed(() => props.conversation?.appliedSla);
|
||||
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
|
||||
|
||||
const slaStatusText = computed(() => {
|
||||
return slaStatus.value?.type?.toUpperCase();
|
||||
});
|
||||
|
||||
const updateSlaStatus = () => {
|
||||
slaStatus.value = evaluateSLAStatus({
|
||||
appliedSla: convertObjectCamelCaseToSnakeCase(appliedSLA.value),
|
||||
chat: props.conversation,
|
||||
});
|
||||
};
|
||||
|
||||
const createTimer = () => {
|
||||
timer.value = setTimeout(() => {
|
||||
updateSlaStatus();
|
||||
createTimer();
|
||||
}, REFRESH_INTERVAL);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
updateSlaStatus();
|
||||
createTimer();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer.value) {
|
||||
clearTimeout(timer.value);
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => props.conversation, updateSlaStatus);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center min-w-fit gap-0.5 h-6">
|
||||
<div class="flex items-center justify-center size-4">
|
||||
<svg
|
||||
width="10"
|
||||
height="13"
|
||||
viewBox="0 0 10 13"
|
||||
fill="none"
|
||||
:class="isSlaMissed ? 'fill-n-ruby-10' : 'fill-n-slate-9'"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4.55091 12.412C7.44524 12.412 9.37939 10.4571 9.37939 7.51446C9.37939 2.63072 5.21405 0.599854 2.36808 0.599854C1.81546 0.599854 1.45626 0.800176 1.45626 1.1801C1.45626 1.32516 1.52534 1.48404 1.64277 1.62219C2.27828 2.38204 2.92069 3.27314 2.93451 4.36455C2.93451 4.5925 2.9276 4.78592 2.76181 5.08295L3.05194 5.03459C2.81017 4.21949 2.18848 3.63234 1.5806 3.63234C1.32501 3.63234 1.15232 3.81884 1.15232 4.09514C1.15232 4.23331 1.19377 4.56488 1.19377 4.79974C1.19377 5.95332 0.26123 6.69935 0.26123 8.67495C0.26123 10.92 1.97434 12.412 4.55091 12.412ZM4.68906 10.8923C3.65982 10.8923 2.96905 10.2637 2.96905 9.33119C2.96905 8.3572 3.66672 8.01181 3.75652 7.38322C3.76344 7.32796 3.79107 7.31414 3.83251 7.34867C4.08809 7.57663 4.24697 7.85293 4.37822 8.1776C4.67525 7.77696 4.81341 6.9204 4.73051 6.0293C4.72361 5.97404 4.75814 5.94642 4.80649 5.96713C6.02916 6.53357 6.65085 7.74241 6.65085 8.82693C6.65085 9.92527 6.00844 10.8923 4.68906 10.8923Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="text-sm truncate"
|
||||
:class="isSlaMissed ? 'text-n-ruby-11' : 'text-n-slate-11'"
|
||||
>
|
||||
{{ `${slaStatusText}: ${slaStatus.threshold}` }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,173 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import WootEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
focusOnMount: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
maxLength: {
|
||||
type: Number,
|
||||
default: 200,
|
||||
},
|
||||
showCharacterCount: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
messageType: {
|
||||
type: String,
|
||||
default: 'info',
|
||||
validator: value => ['info', 'error', 'success'].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const isFocused = ref(false);
|
||||
|
||||
const characterCount = computed(() => props.modelValue.length);
|
||||
|
||||
const messageClass = computed(() => {
|
||||
switch (props.messageType) {
|
||||
case 'error':
|
||||
return 'text-n-ruby-9 dark:text-n-ruby-9';
|
||||
case 'success':
|
||||
return 'text-green-500 dark:text-green-400';
|
||||
default:
|
||||
return 'text-n-slate-11 dark:text-n-slate-11';
|
||||
}
|
||||
});
|
||||
|
||||
const handleInput = value => {
|
||||
if (!props.disabled) {
|
||||
emit('update:modelValue', value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
if (!props.disabled) {
|
||||
isFocused.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
if (!props.disabled) {
|
||||
isFocused.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
newValue => {
|
||||
if (props.maxLength && props.showCharacterCount) {
|
||||
if (characterCount.value >= props.maxLength) {
|
||||
emit('update:modelValue', newValue.slice(0, props.maxLength));
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col min-w-0 gap-1">
|
||||
<label v-if="label" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
{{ label }}
|
||||
</label>
|
||||
<div
|
||||
class="flex flex-col w-full gap-2 px-3 py-3 transition-all duration-500 ease-in-out border rounded-lg editor-wrapper bg-n-alpha-black2"
|
||||
:class="[
|
||||
{
|
||||
'cursor-not-allowed opacity-50 pointer-events-none !bg-n-alpha-black2 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 border-n-weak dark:border-n-weak':
|
||||
!isFocused && messageType !== 'error',
|
||||
'border-n-ruby-8 dark:border-n-ruby-8 hover:border-n-ruby-9 dark:hover:border-n-ruby-9':
|
||||
messageType === 'error' && !isFocused,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<WootEditor
|
||||
:model-value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:focus-on-mount="focusOnMount"
|
||||
:disabled="disabled"
|
||||
@input="handleInput"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
/>
|
||||
<div
|
||||
v-if="showCharacterCount"
|
||||
class="flex items-center justify-end h-4 ltr:right-3 rtl:left-3"
|
||||
>
|
||||
<span class="text-xs tabular-nums text-n-slate-10">
|
||||
{{ characterCount }} / {{ maxLength }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="message"
|
||||
class="min-w-0 mt-1 mb-0 text-xs truncate transition-all duration-500 ease-in-out"
|
||||
:class="messageClass"
|
||||
>
|
||||
{{ message }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.editor-wrapper {
|
||||
::v-deep {
|
||||
.ProseMirror-menubar-wrapper {
|
||||
@apply gap-2 !important;
|
||||
|
||||
.ProseMirror-menubar {
|
||||
@apply bg-transparent dark:bg-transparent w-fit left-1 pt-0 h-5 !important;
|
||||
|
||||
.ProseMirror-menuitem {
|
||||
@apply h-5 !important;
|
||||
}
|
||||
|
||||
.ProseMirror-icon {
|
||||
@apply p-1 w-3 h-3 text-n-slate-12 dark:text-n-slate-12 !important;
|
||||
}
|
||||
}
|
||||
.ProseMirror.ProseMirror-woot-style {
|
||||
p {
|
||||
@apply first:mt-0 !important;
|
||||
}
|
||||
|
||||
.empty-node {
|
||||
@apply m-0 !important;
|
||||
|
||||
&::before {
|
||||
@apply text-n-slate-11 dark:text-n-slate-11 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -13,7 +13,7 @@ defineProps({
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="relative flex flex-col items-center justify-center w-full h-full min-h-screen p-4 overflow-hidden"
|
||||
class="relative flex flex-col items-center justify-center w-full h-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="relative w-full max-w-[940px] mx-auto overflow-hidden h-full max-h-[448px]"
|
||||
@@ -24,17 +24,17 @@ defineProps({
|
||||
<slot name="empty-state-item" />
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 flex flex-col items-center justify-end w-full h-full pb-9 bg-gradient-to-t from-white dark:from-slate-900 to-transparent font-interDisplay"
|
||||
class="absolute inset-x-0 bottom-0 flex flex-col items-center justify-end w-full h-full pb-20 bg-gradient-to-t from-n-background from-25% dark:from-n-background to-transparent"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center gap-6">
|
||||
<div class="flex flex-col items-center justify-center gap-2">
|
||||
<div class="flex flex-col items-center justify-center gap-3">
|
||||
<h2
|
||||
class="text-3xl font-medium text-center text-slate-900 dark:text-white"
|
||||
class="text-3xl font-medium text-center text-slate-900 dark:text-white font-interDisplay"
|
||||
>
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p
|
||||
class="max-w-lg text-base text-center text-slate-600 dark:text-slate-300"
|
||||
class="max-w-xl text-base text-center text-slate-600 dark:text-slate-300 font-interDisplay tracking-[0.3px]"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
|
||||
+41
-10
@@ -3,30 +3,60 @@ import ArticleCard from './ArticleCard.vue';
|
||||
|
||||
const articles = [
|
||||
{
|
||||
id: 1,
|
||||
title: "How to get an SSL certificate for your Help Center's custom domain",
|
||||
status: 'draft',
|
||||
updatedAt: '2 days ago',
|
||||
author: 'Michael',
|
||||
category: '⚡️ Marketing',
|
||||
updatedAt: 1729048936,
|
||||
author: {
|
||||
name: 'John',
|
||||
thumbnail: 'https://i.pravatar.cc/300',
|
||||
},
|
||||
category: {
|
||||
title: 'Marketing',
|
||||
slug: 'marketing',
|
||||
icon: '📈',
|
||||
},
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Setting up your first Help Center portal',
|
||||
status: '',
|
||||
updatedAt: '1 week ago',
|
||||
author: 'John',
|
||||
category: '🛠️ Development',
|
||||
updatedAt: 1729048936,
|
||||
author: {
|
||||
name: 'John',
|
||||
thumbnail: 'https://i.pravatar.cc/300',
|
||||
},
|
||||
category: {
|
||||
title: 'Development',
|
||||
slug: 'development',
|
||||
icon: '🛠️',
|
||||
},
|
||||
views: 1400,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Best practices for organizing your Help Center content',
|
||||
status: 'archived',
|
||||
updatedAt: '3 days ago',
|
||||
author: 'Fernando',
|
||||
category: '💰 Finance',
|
||||
updatedAt: 1729048936,
|
||||
author: {
|
||||
name: 'Fernando',
|
||||
thumbnail: 'https://i.pravatar.cc/300',
|
||||
},
|
||||
category: {
|
||||
title: 'Finance',
|
||||
slug: 'finance',
|
||||
icon: '💰',
|
||||
},
|
||||
views: 4300,
|
||||
},
|
||||
];
|
||||
|
||||
const category = {
|
||||
name: 'Marketing',
|
||||
slug: 'marketing',
|
||||
icon: '📈',
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||
@@ -43,10 +73,11 @@ const articles = [
|
||||
class="px-20 py-4 bg-white dark:bg-slate-900"
|
||||
>
|
||||
<ArticleCard
|
||||
:id="article.id"
|
||||
:title="article.title"
|
||||
:status="article.status"
|
||||
:author="article.author"
|
||||
:category="article.category"
|
||||
:category="category"
|
||||
:views="article.views"
|
||||
:updated-at="article.updatedAt"
|
||||
/>
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import { computed } from 'vue';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
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 Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
|
||||
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
@@ -17,11 +29,11 @@ const props = defineProps({
|
||||
required: true,
|
||||
},
|
||||
author: {
|
||||
type: String,
|
||||
required: true,
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
category: {
|
||||
type: String,
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
views: {
|
||||
@@ -29,86 +41,122 @@ const props = defineProps({
|
||||
required: true,
|
||||
},
|
||||
updatedAt: {
|
||||
type: String,
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const isOpen = ref(false);
|
||||
const emit = defineEmits(['openArticle', 'articleAction']);
|
||||
|
||||
const menuItems = computed(() => {
|
||||
const baseItems = [{ label: 'Delete', action: 'delete', icon: 'delete' }];
|
||||
const menuOptions = {
|
||||
archived: [
|
||||
{ label: 'Publish', action: 'publish', icon: 'checkmark' },
|
||||
{ label: 'Draft', action: 'draft', icon: 'draft' },
|
||||
],
|
||||
draft: [
|
||||
{ label: 'Publish', action: 'publish', icon: 'checkmark' },
|
||||
{ label: 'Archive', action: 'archive', icon: 'archive' },
|
||||
],
|
||||
'': [
|
||||
// Empty string represents published status
|
||||
{ label: 'Draft', action: 'draft', icon: 'draft' },
|
||||
{ label: 'Archive', action: 'archive', icon: 'archive' },
|
||||
],
|
||||
};
|
||||
return [...(menuOptions[props.status] || menuOptions['']), ...baseItems];
|
||||
const { t } = useI18n();
|
||||
|
||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||
|
||||
const articleMenuItems = computed(() => {
|
||||
const commonItems = Object.entries(ARTICLE_MENU_ITEMS).reduce(
|
||||
(acc, [key, item]) => {
|
||||
acc[key] = { ...item, label: t(item.label) };
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
const statusItems = (
|
||||
ARTICLE_MENU_OPTIONS[props.status] ||
|
||||
ARTICLE_MENU_OPTIONS[ARTICLE_STATUSES.PUBLISHED]
|
||||
).map(key => commonItems[key]);
|
||||
|
||||
return [...statusItems, commonItems.delete];
|
||||
});
|
||||
|
||||
const statusTextColor = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'archived':
|
||||
return '!text-slate-600 dark:!text-slate-200';
|
||||
return 'text-n-slate-12';
|
||||
case 'draft':
|
||||
return '!text-amber-700 dark:!text-amber-400';
|
||||
return 'text-n-amber-11';
|
||||
default:
|
||||
return '!text-teal-700 dark:!text-teal-400';
|
||||
return 'text-n-teal-11';
|
||||
}
|
||||
});
|
||||
|
||||
const statusText = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'archived':
|
||||
return 'Archived';
|
||||
return t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.STATUS.ARCHIVED');
|
||||
case 'draft':
|
||||
return 'Draft';
|
||||
return t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.STATUS.DRAFT');
|
||||
default:
|
||||
return 'Published';
|
||||
return t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.STATUS.PUBLISHED');
|
||||
}
|
||||
});
|
||||
|
||||
const handleAction = () => {
|
||||
isOpen.value = false;
|
||||
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 }) => {
|
||||
toggleDropdown(false);
|
||||
emit('articleAction', { action, value, id: props.id });
|
||||
};
|
||||
|
||||
const handleClick = id => {
|
||||
emit('openArticle', id);
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- TODO: Add i18n -->
|
||||
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||
<template>
|
||||
<CardLayout>
|
||||
<template #header>
|
||||
<div class="flex justify-between gap-1">
|
||||
<span class="text-base text-slate-900 dark:text-slate-50 line-clamp-1">
|
||||
<span
|
||||
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-text text-n-slate-12 line-clamp-1"
|
||||
@click="handleClick(id)"
|
||||
>
|
||||
{{ title }}
|
||||
</span>
|
||||
<div class="relative group">
|
||||
<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"
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
|
||||
:class="statusTextColor"
|
||||
@click="isOpen = !isOpen"
|
||||
/>
|
||||
<OnClickOutside @trigger="isOpen = false">
|
||||
<DropdownMenu
|
||||
v-if="isOpen"
|
||||
:menu-items="menuItems"
|
||||
class="right-0 mt-2 xl:left-0 top-full"
|
||||
@action="handleAction"
|
||||
>
|
||||
{{ statusText }}
|
||||
</span>
|
||||
<div
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
class="relative flex items-center group"
|
||||
>
|
||||
<Button
|
||||
icon="i-lucide-ellipsis-vertical"
|
||||
color="slate"
|
||||
size="xs"
|
||||
class="rounded-md group-hover:bg-n-alpha-2"
|
||||
@click="toggleDropdown()"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
<DropdownMenu
|
||||
v-if="showActionsDropdown"
|
||||
:menu-items="articleMenuItems"
|
||||
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 top-full"
|
||||
@action="handleArticleAction($event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -116,25 +164,33 @@ const handleAction = () => {
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="w-4 h-4 rounded-full bg-slate-100 dark:bg-slate-700" />
|
||||
<span class="text-sm text-slate-500 dark:text-slate-400">
|
||||
{{ author }}
|
||||
<Thumbnail
|
||||
:author="author"
|
||||
:name="authorName"
|
||||
:src="authorThumbnailSrc"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ authorName }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="block text-sm whitespace-nowrap text-slate-500 dark:text-slate-400"
|
||||
>
|
||||
{{ category }}
|
||||
<span class="block text-sm whitespace-nowrap text-n-slate-11">
|
||||
{{ categoryName }}
|
||||
</span>
|
||||
<div
|
||||
class="inline-flex items-center gap-1 text-slate-500 dark:text-slate-400 whitespace-nowrap"
|
||||
class="inline-flex items-center gap-1 text-n-slate-11 whitespace-nowrap"
|
||||
>
|
||||
<FluentIcon icon="eye-show" size="18" />
|
||||
<span class="text-sm"> {{ views }} views </span>
|
||||
<Icon icon="i-lucide-eye" class="size-4" />
|
||||
<span class="text-sm">
|
||||
{{
|
||||
t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.VIEWS', {
|
||||
count: views,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm text-slate-600 dark:text-slate-400 line-clamp-1">
|
||||
{{ updatedAt }}
|
||||
<span class="text-sm text-n-slate-11 line-clamp-1">
|
||||
{{ lastUpdatedAt }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+13
-6
@@ -2,17 +2,21 @@
|
||||
import CategoryCard from './CategoryCard.vue';
|
||||
const categories = [
|
||||
{
|
||||
id: 'getting-started',
|
||||
title: '🚀 Getting started',
|
||||
id: 1,
|
||||
title: 'Getting started',
|
||||
description:
|
||||
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.',
|
||||
articlesCount: '5',
|
||||
articlesCount: 5,
|
||||
slug: 'getting-started',
|
||||
icon: '🚀',
|
||||
},
|
||||
{
|
||||
id: 'marketing',
|
||||
title: '📈 Marketing',
|
||||
id: 2,
|
||||
title: 'Marketing',
|
||||
description: '',
|
||||
articlesCount: '4',
|
||||
articlesCount: 4,
|
||||
slug: 'marketing',
|
||||
icon: '📈',
|
||||
},
|
||||
];
|
||||
</script>
|
||||
@@ -31,9 +35,12 @@ const categories = [
|
||||
class="px-20 py-4 bg-white dark:bg-slate-900"
|
||||
>
|
||||
<CategoryCard
|
||||
:id="category.id"
|
||||
:slug="category.slug"
|
||||
:title="category.title"
|
||||
:description="category.description"
|
||||
:articles-count="category.articlesCount"
|
||||
:icon="category.icon"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
@@ -8,14 +9,14 @@ import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.v
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
articlesCount: {
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
@@ -23,25 +24,41 @@ const props = defineProps({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
articlesCount: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
slug: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
const emit = defineEmits(['click', 'action']);
|
||||
|
||||
const isOpen = ref(false);
|
||||
const { t } = useI18n();
|
||||
|
||||
const menuItems = [
|
||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||
|
||||
const categoryMenuItems = [
|
||||
{
|
||||
label: 'Edit',
|
||||
action: 'edit',
|
||||
icon: 'edit',
|
||||
value: 'edit',
|
||||
icon: 'i-lucide-pencil',
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
action: 'delete',
|
||||
icon: 'delete',
|
||||
value: 'delete',
|
||||
icon: 'i-lucide-trash',
|
||||
},
|
||||
];
|
||||
|
||||
const categoryTitleWithIcon = computed(() => {
|
||||
return `${props.icon} ${props.title}`;
|
||||
});
|
||||
|
||||
const description = computed(() => {
|
||||
return props.description ? props.description : 'No description added';
|
||||
});
|
||||
@@ -50,51 +67,56 @@ const hasDescription = computed(() => {
|
||||
return props.description.length > 0;
|
||||
});
|
||||
|
||||
const handleClick = id => {
|
||||
emit('click', id);
|
||||
const handleClick = slug => {
|
||||
emit('click', slug);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const handleAction = action => {
|
||||
// TODO: Implement action
|
||||
const handleAction = ({ action, value }) => {
|
||||
emit('action', { action, value, id: props.id });
|
||||
toggleDropdown(false);
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- TODO: Add i18n -->
|
||||
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||
<template>
|
||||
<CardLayout @click="handleClick(id)">
|
||||
<CardLayout>
|
||||
<template #header>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex justify-between w-full">
|
||||
<div class="flex items-center justify-start gap-2">
|
||||
<div class="flex justify-between w-full gap-2">
|
||||
<div class="flex items-center justify-start w-full min-w-0 gap-2">
|
||||
<span
|
||||
class="text-base cursor-pointer group-hover/cardLayout:underline text-slate-900 dark:text-slate-50 line-clamp-1"
|
||||
class="text-base truncate cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-text text-n-slate-12"
|
||||
@click="handleClick(slug)"
|
||||
>
|
||||
{{ title }}
|
||||
{{ categoryTitleWithIcon }}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center justify-center h-6 px-2 py-1 text-xs text-center border rounded-lg text-slate-500 w-fit border-slate-200 dark:border-slate-800 dark:text-slate-400"
|
||||
class="inline-flex items-center justify-center h-6 px-2 py-1 text-xs text-center border rounded-lg bg-n-slate-1 whitespace-nowrap shrink-0 text-n-slate-11 border-n-slate-4"
|
||||
>
|
||||
{{ articlesCount }} articles
|
||||
{{
|
||||
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_CARD.ARTICLES_COUNT', {
|
||||
count: articlesCount,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="relative group" @click.stop>
|
||||
<div
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
class="relative group"
|
||||
>
|
||||
<Button
|
||||
icon="i-lucide-ellipsis-vertical"
|
||||
color="slate"
|
||||
size="xs"
|
||||
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"
|
||||
class="rounded-md group-hover:bg-n-alpha-2"
|
||||
@click="toggleDropdown()"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="showActionsDropdown"
|
||||
:menu-items="categoryMenuItems"
|
||||
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 top-full z-60"
|
||||
@action="handleAction"
|
||||
/>
|
||||
<OnClickOutside @trigger="isOpen = false">
|
||||
<DropdownMenu
|
||||
v-if="isOpen"
|
||||
:menu-items="menuItems"
|
||||
class="right-0 mt-1 xl:left-0 top-full z-60"
|
||||
@action="handleAction"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+27
-58
@@ -1,66 +1,42 @@
|
||||
<script setup>
|
||||
// import { ref } from 'vue';
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue';
|
||||
// import AddLocaleDialog from 'dashboard/playground/HelpCenter/components/AddLocaleDialog.vue';
|
||||
import articleContent from 'dashboard/components-next/HelpCenter/EmptyState/Portal/portalEmptyStateContent.js';
|
||||
|
||||
const articles = [
|
||||
{
|
||||
title: "How to get an SSL certificate for your Help Center's custom domain",
|
||||
status: 'draft',
|
||||
updatedAt: '2 days ago',
|
||||
author: 'Michael',
|
||||
category: '⚡️ Marketing',
|
||||
views: 3400,
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
title: 'Setting up your first Help Center portal',
|
||||
status: '',
|
||||
updatedAt: '1 week ago',
|
||||
author: 'John',
|
||||
category: '🛠️ Development',
|
||||
views: 400,
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
title: 'Best practices for organizing your Help Center content',
|
||||
status: 'archived',
|
||||
updatedAt: '3 days ago',
|
||||
author: 'Fernando',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
showButton: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
title: 'Customizing the appearance of your Help Center',
|
||||
status: '',
|
||||
updatedAt: '5 days ago',
|
||||
author: 'Jane',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
buttonLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
// const addLocaleDialogRef = ref(null);
|
||||
// const openDialog = () => {
|
||||
// addLocaleDialogRef.value.dialogRef.open();
|
||||
// };
|
||||
// const handleDialogConfirm = () => {
|
||||
// // Add logic to create a new portal
|
||||
// };
|
||||
const emit = defineEmits(['click']);
|
||||
|
||||
const onClick = () => {
|
||||
emit('click');
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- TODO: Add i18n -->
|
||||
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||
|
||||
<template>
|
||||
<EmptyStateLayout
|
||||
title="Write an article"
|
||||
subtitle="Write a rich article, let's get started!"
|
||||
>
|
||||
<EmptyStateLayout :title="title" :subtitle="subtitle">
|
||||
<template #empty-state-item>
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
|
||||
<ArticleCard
|
||||
v-for="(article, index) in articles"
|
||||
v-for="(article, index) in articleContent.slice(0, 5)"
|
||||
:id="article.id"
|
||||
:key="`article-${index}`"
|
||||
:title="article.title"
|
||||
:status="article.status"
|
||||
@@ -72,16 +48,9 @@ const articles = [
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<Button
|
||||
variant="default"
|
||||
label="New article"
|
||||
icon="add"
|
||||
@click="openDialog"
|
||||
/>
|
||||
<!-- <AddLocaleDialog
|
||||
ref="addLocaleDialogRef"
|
||||
@confirm="handleDialogConfirm"
|
||||
/> -->
|
||||
<div v-if="showButton">
|
||||
<Button :label="buttonLabel" icon="i-lucide-plus" @click="onClick" />
|
||||
</div>
|
||||
</template>
|
||||
</EmptyStateLayout>
|
||||
</template>
|
||||
|
||||
+49
@@ -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 p-px">
|
||||
<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>
|
||||
+142
@@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
+36
-87
@@ -1,86 +1,38 @@
|
||||
<script setup>
|
||||
// import { ref } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue';
|
||||
import CategoryCard from 'dashboard/components-next/HelpCenter/CategoryCard/CategoryCard.vue';
|
||||
import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue';
|
||||
// import CreatePortalDialog from 'dashboard/playground/HelpCenter/components/CreatePortalDialog.vue';
|
||||
import articleContent from './portalEmptyStateContent';
|
||||
import CreatePortalDialog from 'dashboard/components-next/HelpCenter/PortalSwitcher/CreatePortalDialog.vue';
|
||||
|
||||
const articles = [
|
||||
{
|
||||
title: "How to get an SSL certificate for your Help Center's custom domain",
|
||||
status: 'draft',
|
||||
updatedAt: '2 days ago',
|
||||
author: 'Michael',
|
||||
category: '⚡️ Marketing',
|
||||
views: 3400,
|
||||
},
|
||||
{
|
||||
title: 'Setting up your first Help Center portal',
|
||||
status: '',
|
||||
updatedAt: '1 week ago',
|
||||
author: 'John',
|
||||
category: '🛠️ Development',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
title: 'Best practices for organizing your Help Center content',
|
||||
status: 'archived',
|
||||
updatedAt: '3 days ago',
|
||||
author: 'Fernando',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
title: 'Customizing the appearance of your Help Center',
|
||||
status: '',
|
||||
updatedAt: '5 days ago',
|
||||
author: 'Jane',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
},
|
||||
];
|
||||
const categories = [
|
||||
{
|
||||
title: 'Getting Started',
|
||||
description: 'Essential guides for new users',
|
||||
articlesCount: '5',
|
||||
},
|
||||
{
|
||||
title: 'Advanced Features',
|
||||
description: 'In-depth tutorials for power users',
|
||||
articlesCount: '8',
|
||||
},
|
||||
];
|
||||
const createPortalDialogRef = ref(null);
|
||||
const openDialog = () => {
|
||||
createPortalDialogRef.value.dialogRef.open();
|
||||
};
|
||||
|
||||
const locales = [
|
||||
{ name: 'English', isDefault: true },
|
||||
{ name: 'Spanish', isDefault: false },
|
||||
{ name: 'Malayalam', isDefault: false },
|
||||
];
|
||||
const router = useRouter();
|
||||
|
||||
// const createPortalDialogRef = ref(null);
|
||||
// const openDialog = () => {
|
||||
// createPortalDialogRef.value.dialogRef.open();
|
||||
// };
|
||||
// const handleDialogConfirm = () => {
|
||||
// // Add logic to create a new portal
|
||||
// };
|
||||
const onPortalCreate = ({ slug: portalSlug, locale }) => {
|
||||
router.push({
|
||||
name: 'portals_articles_index',
|
||||
params: { portalSlug, locale },
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- TODO: Add i18n -->
|
||||
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||
<template>
|
||||
<EmptyStateLayout
|
||||
title="Help Center"
|
||||
subtitle="Create self-service portals to access articles and information. Streamline queries, enhance agent efficiency, and elevate customer support."
|
||||
:title="$t('HELP_CENTER.TITLE')"
|
||||
:subtitle="$t('HELP_CENTER.NEW_PAGE.DESCRIPTION')"
|
||||
>
|
||||
<template #empty-state-item>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-2 gap-4 p-px">
|
||||
<div class="space-y-4">
|
||||
<ArticleCard
|
||||
v-for="(article, index) in articles"
|
||||
v-for="(article, index) in articleContent"
|
||||
:id="article.id"
|
||||
:key="`article-${index}`"
|
||||
:title="article.title"
|
||||
:status="article.status"
|
||||
@@ -91,33 +43,30 @@ const locales = [
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<CategoryCard
|
||||
v-for="(category, index) in categories"
|
||||
:key="`category-${index}`"
|
||||
:title="category.title"
|
||||
:description="category.description"
|
||||
:articles-count="category.articlesCount"
|
||||
/>
|
||||
<LocaleCard
|
||||
v-for="(locale, index) in locales"
|
||||
:key="`locale-${index}`"
|
||||
:locale="locale.name"
|
||||
:is-default="locale.isDefault"
|
||||
<ArticleCard
|
||||
v-for="(article, index) in articleContent.reverse()"
|
||||
:id="article.id"
|
||||
:key="`article-${index}`"
|
||||
:title="article.title"
|
||||
:status="article.status"
|
||||
:updated-at="article.updatedAt"
|
||||
:author="article.author"
|
||||
:category="article.category"
|
||||
:views="article.views"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<Button
|
||||
variant="default"
|
||||
label="Create Portal"
|
||||
icon="add"
|
||||
:label="$t('HELP_CENTER.NEW_PAGE.CREATE_PORTAL_BUTTON')"
|
||||
icon="i-lucide-plus"
|
||||
@click="openDialog"
|
||||
/>
|
||||
<!-- <CreatePortalDialog
|
||||
ref="createPortalDialogRef"
|
||||
@confirm="handleDialogConfirm"
|
||||
/> -->
|
||||
<CreatePortalDialog
|
||||
ref="createPortalDialogRef"
|
||||
@create="onPortalCreate"
|
||||
/>
|
||||
</template>
|
||||
</EmptyStateLayout>
|
||||
</template>
|
||||
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
export default [
|
||||
{
|
||||
id: 1,
|
||||
title: "How to get an SSL certificate for your Help Center's custom domain",
|
||||
status: 'draft',
|
||||
updatedAt: 1729205669,
|
||||
author: { availableName: 'Michael' },
|
||||
category: {
|
||||
slug: 'configuration',
|
||||
icon: '📦',
|
||||
name: 'Setup & Configuration',
|
||||
},
|
||||
views: 3400,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Setting up your first Help Center portal',
|
||||
status: 'published',
|
||||
updatedAt: 1729205669,
|
||||
author: { availableName: 'John' },
|
||||
category: { slug: 'onboarding', icon: '🧑🍳', name: 'Onboarding' },
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Best practices for organizing your Help Center content',
|
||||
status: 'archived',
|
||||
updatedAt: 1729205669,
|
||||
author: { availableName: 'Fernando' },
|
||||
category: { slug: 'best-practices', icon: '⛺️', name: 'Best Practices' },
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'Customizing the appearance of your Help Center',
|
||||
status: 'draft',
|
||||
updatedAt: 1729205669,
|
||||
author: { availableName: 'Jane' },
|
||||
category: { slug: 'design', icon: '🎨', name: 'Design' },
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: 'Integrating your Help Center with third-party tools',
|
||||
status: 'published',
|
||||
updatedAt: 1729205669,
|
||||
author: { availableName: 'Sarah' },
|
||||
category: {
|
||||
slug: 'integrations',
|
||||
icon: '🔗',
|
||||
name: 'Integrations',
|
||||
},
|
||||
views: 2800,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: 'Managing user permissions in your Help Center',
|
||||
status: 'draft',
|
||||
updatedAt: 1729205669,
|
||||
author: { availableName: 'Alex' },
|
||||
category: {
|
||||
slug: 'administration',
|
||||
icon: '🔐',
|
||||
name: 'Administration',
|
||||
},
|
||||
views: 1200,
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
title: 'Creating and managing FAQ sections',
|
||||
status: 'published',
|
||||
updatedAt: 1729205669,
|
||||
author: { availableName: 'Emily' },
|
||||
category: {
|
||||
slug: 'content-management',
|
||||
icon: '📝',
|
||||
name: 'Content Management',
|
||||
},
|
||||
views: 5600,
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
title: 'Implementing search functionality in your Help Center',
|
||||
status: 'archived',
|
||||
updatedAt: 1729205669,
|
||||
author: { availableName: 'David' },
|
||||
category: {
|
||||
slug: 'features',
|
||||
icon: '🔍',
|
||||
name: 'Features',
|
||||
},
|
||||
views: 1800,
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
title: 'Analyzing Help Center usage metrics',
|
||||
status: 'published',
|
||||
updatedAt: 1729205669,
|
||||
author: { availableName: 'Rachel' },
|
||||
category: {
|
||||
slug: 'analytics',
|
||||
icon: '📊',
|
||||
name: 'Analytics',
|
||||
},
|
||||
views: 3200,
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
title: 'Setting up multilingual support in your Help Center',
|
||||
status: 'draft',
|
||||
updatedAt: 1729205669,
|
||||
author: { availableName: 'Carlos' },
|
||||
category: {
|
||||
slug: 'localization',
|
||||
icon: '🌍',
|
||||
name: 'Localization',
|
||||
},
|
||||
views: 900,
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
title: 'Creating interactive tutorials for your products',
|
||||
status: 'published',
|
||||
updatedAt: 1729205669,
|
||||
author: { availableName: 'Olivia' },
|
||||
category: {
|
||||
slug: 'education',
|
||||
icon: '🎓',
|
||||
name: 'Education',
|
||||
},
|
||||
views: 4100,
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
title: 'Implementing a feedback system in your Help Center',
|
||||
status: 'draft',
|
||||
updatedAt: 1729205669,
|
||||
author: { availableName: 'Nathan' },
|
||||
category: {
|
||||
slug: 'user-engagement',
|
||||
icon: '💬',
|
||||
name: 'User Engagement',
|
||||
},
|
||||
views: 750,
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
title: 'Optimizing Help Center content for SEO',
|
||||
status: 'published',
|
||||
updatedAt: 1729205669,
|
||||
author: { availableName: 'Sophia' },
|
||||
category: {
|
||||
slug: 'seo',
|
||||
icon: '🚀',
|
||||
name: 'SEO',
|
||||
},
|
||||
views: 2900,
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
title: 'Creating a knowledge base for internal teams',
|
||||
status: 'archived',
|
||||
updatedAt: 1729205669,
|
||||
author: { availableName: 'Daniel' },
|
||||
category: {
|
||||
slug: 'internal-resources',
|
||||
icon: '🏢',
|
||||
name: 'Internal Resources',
|
||||
},
|
||||
views: 1500,
|
||||
},
|
||||
];
|
||||
@@ -1,16 +1,15 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
|
||||
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import PortalSwitcher from 'dashboard/components-next/HelpCenter/PortalSwitcher/PortalSwitcher.vue';
|
||||
import CreatePortalDialog from 'dashboard/components-next/HelpCenter/PortalSwitcher/CreatePortalDialog.vue';
|
||||
|
||||
defineProps({
|
||||
header: {
|
||||
type: String,
|
||||
default: 'Chatwoot Help Center',
|
||||
},
|
||||
currentPage: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
@@ -35,8 +34,21 @@ defineProps({
|
||||
|
||||
const emit = defineEmits(['update:currentPage']);
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const createPortalDialogRef = ref(null);
|
||||
|
||||
const showPortalSwitcher = ref(false);
|
||||
|
||||
const portals = useMapGetter('portals/allPortals');
|
||||
|
||||
const currentPortalSlug = computed(() => route.params.portalSlug);
|
||||
|
||||
const activePortalName = computed(() => {
|
||||
return portals.value?.find(portal => portal.slug === currentPortalSlug.value)
|
||||
?.name;
|
||||
});
|
||||
|
||||
const updateCurrentPage = page => {
|
||||
emit('update:currentPage', page);
|
||||
};
|
||||
@@ -46,34 +58,37 @@ const togglePortalSwitcher = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="flex flex-col w-full h-full overflow-hidden bg-white dark:bg-slate-900"
|
||||
>
|
||||
<header
|
||||
class="sticky top-0 z-10 px-6 pb-3 bg-white lg:px-0 dark:bg-slate-900"
|
||||
>
|
||||
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
|
||||
<header class="sticky top-0 z-10 px-6 pb-3 lg:px-0">
|
||||
<div class="w-full max-w-[900px] mx-auto">
|
||||
<div
|
||||
v-if="showHeaderTitle"
|
||||
class="flex items-center justify-start h-20 gap-2"
|
||||
>
|
||||
<span class="text-xl font-medium text-slate-900 dark:text-white">
|
||||
{{ header }}
|
||||
<span
|
||||
v-if="activePortalName"
|
||||
class="text-xl font-medium text-n-slate-12"
|
||||
>
|
||||
{{ activePortalName }}
|
||||
</span>
|
||||
<div 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"
|
||||
/>
|
||||
<div v-if="activePortalName" class="relative group">
|
||||
<OnClickOutside @trigger="showPortalSwitcher = false">
|
||||
<Button
|
||||
icon="i-lucide-chevron-down"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
class="rounded-md group-hover:bg-n-slate-3 hover:bg-n-slate-3"
|
||||
@click="togglePortalSwitcher"
|
||||
/>
|
||||
|
||||
<PortalSwitcher
|
||||
v-if="showPortalSwitcher"
|
||||
class="absolute left-0 top-9"
|
||||
class="absolute ltr:left-0 rtl:right-0 top-9"
|
||||
@close="showPortalSwitcher = false"
|
||||
@create-portal="createPortalDialogRef.dialogRef.open()"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
<CreatePortalDialog ref="createPortalDialogRef" />
|
||||
</div>
|
||||
</div>
|
||||
<slot name="header-actions" />
|
||||
@@ -84,10 +99,7 @@ const togglePortalSwitcher = () => {
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</main>
|
||||
<footer
|
||||
v-if="showPaginationFooter"
|
||||
class="sticky bottom-0 z-10 px-4 pt-3 pb-4 bg-white dark:bg-slate-900"
|
||||
>
|
||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 px-4 pb-4">
|
||||
<PaginationFooter
|
||||
:current-page="currentPage"
|
||||
:total-items="totalItems"
|
||||
@@ -95,5 +107,7 @@ const togglePortalSwitcher = () => {
|
||||
@update:current-page="updateCurrentPage"
|
||||
/>
|
||||
</footer>
|
||||
<!-- Do not remove this slot. It can be used to add dialogs. -->
|
||||
<slot />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { LOCALE_MENU_ITEMS } from 'dashboard/helper/portalHelper';
|
||||
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
locale: {
|
||||
type: String,
|
||||
required: true,
|
||||
@@ -15,6 +17,10 @@ defineProps({
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
localeCode: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
articleCount: {
|
||||
type: Number,
|
||||
required: true,
|
||||
@@ -25,76 +31,85 @@ defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const isOpen = ref(false);
|
||||
const emit = defineEmits(['action']);
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
label: 'Make default',
|
||||
action: 'default',
|
||||
icon: 'star-emphasis',
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
action: 'delete',
|
||||
icon: 'delete',
|
||||
},
|
||||
];
|
||||
const { t } = useI18n();
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const handleAction = action => {
|
||||
// TODO: Implement action
|
||||
const [showDropdownMenu, toggleDropdown] = useToggle();
|
||||
|
||||
const localeMenuItems = computed(() =>
|
||||
LOCALE_MENU_ITEMS.map(item => ({
|
||||
...item,
|
||||
label: t(item.label),
|
||||
disabled: props.isDefault,
|
||||
}))
|
||||
);
|
||||
|
||||
const handleAction = ({ action, value }) => {
|
||||
emit('action', { action, value });
|
||||
toggleDropdown(false);
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- TODO: Add i18n -->
|
||||
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||
<template>
|
||||
<CardLayout class="ltr:pr-2 rtl:pl-2">
|
||||
<CardLayout>
|
||||
<template #header>
|
||||
<div class="flex justify-between gap-2">
|
||||
<div class="flex items-center justify-start gap-2">
|
||||
<span
|
||||
class="text-sm font-medium text-slate-900 dark:text-slate-50 line-clamp-1"
|
||||
>
|
||||
{{ locale }}
|
||||
{{ locale }} ({{ localeCode }})
|
||||
</span>
|
||||
<span
|
||||
v-if="isDefault"
|
||||
class="bg-slate-100 dark:bg-slate-800 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-woot-500 dark:text-woot-400 px-2 py-0.5"
|
||||
class="bg-n-alpha-2 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-n-blue-text px-2 py-0.5"
|
||||
>
|
||||
Default
|
||||
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<div class="flex items-center justify-end gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<span
|
||||
class="text-sm text-slate-500 dark:text-slate-400 whitespace-nowrap"
|
||||
>
|
||||
{{ articleCount }} articles
|
||||
{{
|
||||
$t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.ARTICLES_COUNT',
|
||||
articleCount
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<div class="w-px h-3 bg-slate-75 dark:bg-slate-800" />
|
||||
<span
|
||||
class="text-sm text-slate-500 dark:text-slate-400 whitespace-nowrap"
|
||||
>
|
||||
{{ categoryCount }} categories
|
||||
{{
|
||||
$t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.CATEGORIES_COUNT',
|
||||
categoryCount
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="relative group">
|
||||
<div
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
class="relative group"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
icon="more-vertical"
|
||||
class="w-8 group-hover:bg-slate-100 dark:group-hover:bg-slate-800"
|
||||
@click="isOpen = !isOpen"
|
||||
icon="i-lucide-ellipsis-vertical"
|
||||
color="slate"
|
||||
size="xs"
|
||||
class="rounded-md group-hover:bg-n-alpha-2"
|
||||
@click="toggleDropdown()"
|
||||
/>
|
||||
|
||||
<DropdownMenu
|
||||
v-if="showDropdownMenu"
|
||||
:menu-items="localeMenuItems"
|
||||
class="ltr:right-0 rtl:left-0 mt-1 xl:ltr:left-0 xl:rtl:right-0 top-full z-60 min-w-[150px]"
|
||||
@action="handleAction"
|
||||
/>
|
||||
<OnClickOutside @trigger="isOpen = false">
|
||||
<DropdownMenu
|
||||
v-if="isOpen"
|
||||
:menu-items="menuItems"
|
||||
class="right-0 mt-1 xl:left-0 top-full z-60 min-w-[147px]"
|
||||
@action="handleAction"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+68
-60
@@ -1,105 +1,111 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { ARTICLE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
|
||||
|
||||
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
import FullEditor from 'dashboard/components/widgets/WootWriter/FullEditor.vue';
|
||||
import ArticleEditorHeader from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorHeader.vue';
|
||||
import ArticleEditorControls from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorControls.vue';
|
||||
|
||||
const { article } = defineProps({
|
||||
const props = defineProps({
|
||||
article: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
isUpdating: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isSaved: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['saveArticle']);
|
||||
|
||||
const saveArticle = debounce(value => emit('saveArticle', value), 400, false);
|
||||
const emit = defineEmits([
|
||||
'saveArticle',
|
||||
'goBack',
|
||||
'setAuthor',
|
||||
'setCategory',
|
||||
'previewArticle',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const saveArticle = debounce(value => emit('saveArticle', value), 600, false);
|
||||
|
||||
const articleTitle = computed({
|
||||
get: () => article.title,
|
||||
set: title => {
|
||||
saveArticle({ title });
|
||||
get: () => props.article.title,
|
||||
set: value => {
|
||||
saveArticle({ title: value });
|
||||
},
|
||||
});
|
||||
|
||||
const articleContent = computed({
|
||||
get: () => article.content,
|
||||
get: () => props.article.content,
|
||||
set: content => {
|
||||
saveArticle({ content });
|
||||
},
|
||||
});
|
||||
|
||||
const onClickGoBack = () => {
|
||||
emit('goBack');
|
||||
};
|
||||
|
||||
const setAuthorId = authorId => {
|
||||
emit('setAuthor', authorId);
|
||||
};
|
||||
|
||||
const setCategoryId = categoryId => {
|
||||
emit('setCategory', categoryId);
|
||||
};
|
||||
|
||||
const previewArticle = () => {
|
||||
emit('previewArticle');
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||
<template>
|
||||
<HelpCenterLayout :show-header-title="false" :show-pagination-footer="false">
|
||||
<template #header-actions>
|
||||
<div class="flex items-center justify-between h-20">
|
||||
<Button
|
||||
label="Back to articles"
|
||||
icon="chevron-lucide-left"
|
||||
icon-lib="lucide"
|
||||
variant="link"
|
||||
text-variant="info"
|
||||
size="sm"
|
||||
/>
|
||||
<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>
|
||||
<ArticleEditorHeader
|
||||
:is-updating="isUpdating"
|
||||
:is-saved="isSaved"
|
||||
:status="article.status"
|
||||
:article-id="article.id"
|
||||
@go-back="onClickGoBack"
|
||||
@preview-article="previewArticle"
|
||||
/>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="flex flex-col gap-3 pl-4 mb-3 rtl:pr-3 rtl:pl-0">
|
||||
<TextArea
|
||||
v-model="articleTitle"
|
||||
class="h-12"
|
||||
custom-text-area-class="border-0 !text-[32px] !bg-transparent !py-0 !px-0 !h-auto !leading-[48px] !font-medium !tracking-[0.2px]"
|
||||
auto-height
|
||||
min-height="4rem"
|
||||
custom-text-area-class="!text-[32px] !leading-[48px] !font-medium !tracking-[0.2px]"
|
||||
custom-text-area-wrapper-class="border-0 !bg-transparent dark:!bg-transparent !py-0 !px-0"
|
||||
placeholder="Title"
|
||||
autofocus
|
||||
/>
|
||||
<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>
|
||||
<FullEditor
|
||||
v-model="articleContent"
|
||||
class="py-0 pb-10 pl-4 rtl:pr-4 rtl:pl-0 h-fit"
|
||||
placeholder="Write something"
|
||||
:placeholder="
|
||||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.EDITOR_PLACEHOLDER')
|
||||
"
|
||||
:enabled-menu-options="ARTICLE_EDITOR_MENU_OPTIONS"
|
||||
:autofocus="false"
|
||||
/>
|
||||
</template>
|
||||
</HelpCenterLayout>
|
||||
@@ -132,8 +138,10 @@ const articleContent = computed({
|
||||
|
||||
.ProseMirror-menuitem {
|
||||
@apply mr-0;
|
||||
|
||||
.ProseMirror-icon {
|
||||
@apply p-0 mt-1 !mr-0;
|
||||
|
||||
svg {
|
||||
width: 20px !important;
|
||||
height: 20px !important;
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
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 route = useRoute();
|
||||
|
||||
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 categorySlugFromRoute = computed(() => route.params.categorySlug);
|
||||
|
||||
const author = computed(() => {
|
||||
if (isNewArticle.value) {
|
||||
return selectedAuthorId.value
|
||||
? agents.value.find(agent => agent.id === selectedAuthorId.value)
|
||||
: currentUser.value;
|
||||
}
|
||||
return props.article?.author || null;
|
||||
});
|
||||
|
||||
const authorName = computed(
|
||||
() => author.value?.name || author.value?.available_name || '-'
|
||||
);
|
||||
const authorThumbnailSrc = computed(() => author.value?.thumbnail);
|
||||
|
||||
const agentList = computed(() => {
|
||||
return (
|
||||
agents.value
|
||||
?.map(({ name, id, thumbnail }) => ({
|
||||
label: name,
|
||||
value: id,
|
||||
thumbnail: { name, src: thumbnail },
|
||||
isSelected: props.article?.author?.id
|
||||
? id === props.article.author.id
|
||||
: id === (selectedAuthorId.value || currentUserId.value),
|
||||
action: 'assignAuthor',
|
||||
}))
|
||||
// Sort the list by isSelected first, then by name(label)
|
||||
.toSorted((a, b) => {
|
||||
if (a.isSelected !== b.isSelected) {
|
||||
return Number(b.isSelected) - Number(a.isSelected);
|
||||
}
|
||||
return a.label.localeCompare(b.label);
|
||||
}) ?? []
|
||||
);
|
||||
});
|
||||
|
||||
const hasAgentList = computed(() => {
|
||||
return agents.value?.length > 1;
|
||||
});
|
||||
|
||||
const findCategoryFromSlug = slug => {
|
||||
return categories.value?.find(category => category.slug === slug);
|
||||
};
|
||||
|
||||
const assignCategoryFromSlug = slug => {
|
||||
const categoryFromSlug = findCategoryFromSlug(slug);
|
||||
if (categoryFromSlug) {
|
||||
selectedCategoryId.value = categoryFromSlug.id;
|
||||
return categoryFromSlug;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const selectedCategory = computed(() => {
|
||||
if (isNewArticle.value) {
|
||||
if (categorySlugFromRoute.value) {
|
||||
const categoryFromSlug = assignCategoryFromSlug(
|
||||
categorySlugFromRoute.value
|
||||
);
|
||||
if (categoryFromSlug) return categoryFromSlug;
|
||||
}
|
||||
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(({ name, id, icon }) => ({
|
||||
label: name,
|
||||
value: id,
|
||||
emoji: icon,
|
||||
isSelected: isNewArticle.value
|
||||
? id === (selectedCategoryId.value || selectedCategory.value?.id)
|
||||
: id === props.article?.category?.id,
|
||||
action: 'assignCategory',
|
||||
}))
|
||||
// Sort categories by isSelected
|
||||
.toSorted((a, b) => Number(b.isSelected) - Number(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 });
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (categorySlugFromRoute.value && isNewArticle.value) {
|
||||
// Assign category from slug if there is one
|
||||
const categoryFromSlug = findCategoryFromSlug(categorySlugFromRoute.value);
|
||||
if (categoryFromSlug) {
|
||||
handleArticleAction({
|
||||
action: 'assignCategory',
|
||||
value: categoryFromSlug?.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="relative flex items-center gap-2">
|
||||
<OnClickOutside @trigger="openAgentsList = false">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="!px-0 font-normal hover:!bg-transparent"
|
||||
text-variant="info"
|
||||
@click="openAgentsList = !openAgentsList"
|
||||
>
|
||||
<Thumbnail
|
||||
:author="author"
|
||||
:name="authorName"
|
||||
:size="20"
|
||||
:src="authorThumbnailSrc"
|
||||
/>
|
||||
<span
|
||||
v-if="author"
|
||||
class="text-sm text-n-slate-12 hover:text-n-slate-11"
|
||||
>
|
||||
{{ author.available_name }}
|
||||
</span>
|
||||
</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')
|
||||
"
|
||||
:icon="!selectedCategory?.icon ? 'i-lucide-shapes' : ''"
|
||||
variant="ghost"
|
||||
class="!px-2 font-normal hover:!bg-transparent"
|
||||
@click="openCategoryList = !openCategoryList"
|
||||
>
|
||||
<span
|
||||
v-if="selectedCategory"
|
||||
class="text-sm text-n-slate-12 hover:text-n-slate-11"
|
||||
>
|
||||
{{
|
||||
`${selectedCategory.icon || ''} ${selectedCategory.name || t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.UNCATEGORIZED')}`
|
||||
}}
|
||||
</span>
|
||||
</Button>
|
||||
<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="i-lucide-plus"
|
||||
variant="ghost"
|
||||
:disabled="isNewArticle"
|
||||
class="!px-2 font-normal hover:!bg-transparent hover:!text-n-slate-11"
|
||||
@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>
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
<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="i-lucide-chevron-left"
|
||||
variant="link"
|
||||
color="slate"
|
||||
size="sm"
|
||||
class="ltr:pl-3 rtl:pr-3"
|
||||
@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')"
|
||||
color="slate"
|
||||
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="i-lucide-chevron-down"
|
||||
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>
|
||||
+134
@@ -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 outline outline-1 outline-n-container 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="i-lucide-x"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="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-[220px]"
|
||||
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>
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
<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
|
||||
: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="i-lucide-chevron-down"
|
||||
color="slate"
|
||||
trailing-icon
|
||||
@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"
|
||||
icon="i-lucide-chevron-down"
|
||||
size="sm"
|
||||
color="slate"
|
||||
trailing-icon
|
||||
class="max-w-48"
|
||||
@click="isCategoryMenuOpen = !isCategoryMenuOpen"
|
||||
/>
|
||||
|
||||
<DropdownMenu
|
||||
v-if="isCategoryMenuOpen"
|
||||
:menu-items="categoryMenuItems"
|
||||
class="left-0 w-48 mt-2 overflow-y-auto xl:right-0 top-full max-h-60"
|
||||
@action="handleCategoryAction"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
:label="t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.NEW_ARTICLE')"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
@click="handleNewArticle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+178
-13
@@ -1,25 +1,190 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import Draggable from 'vuedraggable';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { getArticleStatus } from 'dashboard/helper/portalHelper.js';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue';
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
articles: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
isCategoryArticles: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { ARTICLE_STATUS_TYPES } = wootConstants;
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const localArticles = ref(props.articles);
|
||||
|
||||
const dragEnabled = computed(() => {
|
||||
// Enable dragging only for category articles and when there's more than one article
|
||||
return props.isCategoryArticles && localArticles.value?.length > 1;
|
||||
});
|
||||
|
||||
const getCategoryById = useMapGetter('categories/categoryById');
|
||||
|
||||
const openArticle = id => {
|
||||
const { tab, categorySlug, locale } = route.params;
|
||||
if (props.isCategoryArticles) {
|
||||
router.push({
|
||||
name: 'portals_categories_articles_edit',
|
||||
params: { articleSlug: id },
|
||||
});
|
||||
} else {
|
||||
router.push({
|
||||
name: 'portals_articles_edit',
|
||||
params: {
|
||||
articleSlug: id,
|
||||
tab,
|
||||
categorySlug,
|
||||
locale,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onReorder = reorderedGroup => {
|
||||
store.dispatch('articles/reorder', {
|
||||
reorderedGroup,
|
||||
portalSlug: route.params.portalSlug,
|
||||
});
|
||||
};
|
||||
|
||||
const onDragEnd = () => {
|
||||
// Reuse existing positions to maintain order within the current group
|
||||
const sortedArticlePositions = localArticles.value
|
||||
.map(article => article.position)
|
||||
.sort((a, b) => a - b); // Use custom sort to handle numeric values correctly
|
||||
|
||||
const orderedArticles = localArticles.value.map(article => article.id);
|
||||
|
||||
// Create a map of article IDs to their new positions
|
||||
const reorderedGroup = orderedArticles.reduce((obj, key, index) => {
|
||||
obj[key] = sortedArticlePositions[index];
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
onReorder(reorderedGroup);
|
||||
};
|
||||
|
||||
const getCategory = categoryId => {
|
||||
return getCategoryById.value(categoryId) || { name: '', icon: '' };
|
||||
};
|
||||
|
||||
const getStatusMessage = (status, isSuccess) => {
|
||||
const messageType = isSuccess ? 'SUCCESS' : 'ERROR';
|
||||
const statusMap = {
|
||||
[ARTICLE_STATUS_TYPES.PUBLISH]: 'PUBLISH_ARTICLE',
|
||||
[ARTICLE_STATUS_TYPES.ARCHIVE]: 'ARCHIVE_ARTICLE',
|
||||
[ARTICLE_STATUS_TYPES.DRAFT]: 'DRAFT_ARTICLE',
|
||||
};
|
||||
|
||||
return statusMap[status]
|
||||
? t(`HELP_CENTER.${statusMap[status]}.API.${messageType}`)
|
||||
: '';
|
||||
};
|
||||
|
||||
const updateMeta = () => {
|
||||
const { portalSlug, locale } = route.params;
|
||||
return store.dispatch('portals/show', { portalSlug, locale });
|
||||
};
|
||||
|
||||
const handleArticleAction = async (action, { status, id }) => {
|
||||
const { portalSlug } = route.params;
|
||||
try {
|
||||
if (action === 'delete') {
|
||||
await store.dispatch('articles/delete', {
|
||||
portalSlug,
|
||||
articleId: id,
|
||||
});
|
||||
useAlert(t('HELP_CENTER.DELETE_ARTICLE.API.SUCCESS_MESSAGE'));
|
||||
} else {
|
||||
await store.dispatch('articles/update', {
|
||||
portalSlug,
|
||||
articleId: id,
|
||||
status,
|
||||
});
|
||||
useAlert(getStatusMessage(status, true));
|
||||
|
||||
if (status === ARTICLE_STATUS_TYPES.ARCHIVE) {
|
||||
useTrack(PORTALS_EVENTS.ARCHIVE_ARTICLE, { uiFrom: 'header' });
|
||||
} else if (status === ARTICLE_STATUS_TYPES.PUBLISH) {
|
||||
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
|
||||
}
|
||||
}
|
||||
await updateMeta();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.message ||
|
||||
(action === 'delete'
|
||||
? t('HELP_CENTER.DELETE_ARTICLE.API.ERROR_MESSAGE')
|
||||
: getStatusMessage(status, false));
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const updateArticle = ({ action, value, id }) => {
|
||||
const status = action !== 'delete' ? getArticleStatus(value) : null;
|
||||
handleArticleAction(action, { status, id });
|
||||
};
|
||||
|
||||
// Watch for changes in the articles prop and update the localArticles ref
|
||||
watch(
|
||||
() => props.articles,
|
||||
newArticles => {
|
||||
localArticles.value = newArticles;
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul role="list" class="w-full h-full space-y-4">
|
||||
<ArticleCard
|
||||
v-for="article in articles"
|
||||
:key="article.title"
|
||||
:title="article.title"
|
||||
:status="article.status"
|
||||
:author="article.author"
|
||||
:category="article.category"
|
||||
:views="article.views"
|
||||
:updated-at="article.updatedAt"
|
||||
/>
|
||||
</ul>
|
||||
<Draggable
|
||||
v-model="localArticles"
|
||||
:disabled="!dragEnabled"
|
||||
item-key="id"
|
||||
tag="ul"
|
||||
ghost-class="article-ghost-class"
|
||||
class="w-full h-full space-y-4"
|
||||
@end="onDragEnd"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<li class="list-none rounded-2xl">
|
||||
<ArticleCard
|
||||
:id="element.id"
|
||||
:key="element.id"
|
||||
:title="element.title"
|
||||
:status="element.status"
|
||||
:author="element.author"
|
||||
:category="getCategory(element.category.id)"
|
||||
:views="element.views || 0"
|
||||
:updated-at="element.updatedAt"
|
||||
:class="{ 'cursor-grab': dragEnabled }"
|
||||
@open-article="openArticle"
|
||||
@article-action="updateArticle"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
</Draggable>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.article-ghost-class {
|
||||
@apply opacity-50 bg-n-solid-1;
|
||||
}
|
||||
</style>
|
||||
|
||||
+166
-50
@@ -1,74 +1,190 @@
|
||||
<script setup>
|
||||
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ArticleList from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue';
|
||||
import { computed } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
|
||||
|
||||
defineProps({
|
||||
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
||||
import ArticleList from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue';
|
||||
import ArticleHeaderControls from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleHeaderControls.vue';
|
||||
import CategoryHeaderControls from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryHeaderControls.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import ArticleEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/Article/ArticleEmptyState.vue';
|
||||
|
||||
const props = defineProps({
|
||||
articles: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
categories: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
allowedLocales: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
portalName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
meta: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isCategoryArticles: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const tabs = [
|
||||
{ label: 'All articles', count: 24 },
|
||||
{ label: 'Mine', count: 13 },
|
||||
{ label: 'Draft', count: 5 },
|
||||
{ label: 'Archived', count: 11 },
|
||||
];
|
||||
// TODO: remove comments
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const handleTabChange = tab => {
|
||||
// TODO: Implement tab change logic
|
||||
const emit = defineEmits(['pageChange', 'fetchPortal']);
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
|
||||
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
|
||||
const isFetching = useMapGetter('articles/isFetching');
|
||||
|
||||
const hasNoArticles = computed(
|
||||
() => !isFetching.value && !props.articles.length
|
||||
);
|
||||
|
||||
const isLoading = computed(() => isFetching.value || isSwitchingPortal.value);
|
||||
|
||||
const totalArticlesCount = computed(() => props.meta.allArticlesCount);
|
||||
|
||||
const hasNoArticlesInPortal = computed(
|
||||
() => totalArticlesCount.value === 0 && !props.isCategoryArticles
|
||||
);
|
||||
|
||||
const shouldShowPaginationFooter = computed(() => {
|
||||
return !(isFetching.value || isSwitchingPortal.value || hasNoArticles.value);
|
||||
});
|
||||
|
||||
const updateRoute = newParams => {
|
||||
const { portalSlug, locale, tab, categorySlug } = route.params;
|
||||
router.push({
|
||||
name: 'portals_articles_index',
|
||||
params: {
|
||||
portalSlug,
|
||||
locale: newParams.locale ?? locale,
|
||||
tab: newParams.tab ?? tab,
|
||||
categorySlug: newParams.categorySlug ?? categorySlug,
|
||||
...newParams,
|
||||
},
|
||||
});
|
||||
};
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const handlePageChange = page => {
|
||||
// TODO: Implement page change logic
|
||||
|
||||
const articlesCount = computed(() => {
|
||||
const { tab } = route.params;
|
||||
const { meta } = props;
|
||||
const countMap = {
|
||||
'': meta.articlesCount,
|
||||
mine: meta.mineArticlesCount,
|
||||
draft: meta.draftArticlesCount,
|
||||
archived: meta.archivedArticlesCount,
|
||||
};
|
||||
return Number(countMap[tab] || countMap['']);
|
||||
});
|
||||
|
||||
const showArticleHeaderControls = computed(
|
||||
() =>
|
||||
!hasNoArticlesInPortal.value &&
|
||||
!props.isCategoryArticles &&
|
||||
!isSwitchingPortal.value
|
||||
);
|
||||
|
||||
const showCategoryHeaderControls = computed(
|
||||
() => props.isCategoryArticles && !isSwitchingPortal.value
|
||||
);
|
||||
|
||||
const getEmptyStateText = type => {
|
||||
if (props.isCategoryArticles) {
|
||||
return t(`HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.CATEGORY.${type}`);
|
||||
}
|
||||
const tabName = route.params.tab?.toUpperCase() || 'ALL';
|
||||
return t(`HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.${tabName}.${type}`);
|
||||
};
|
||||
|
||||
const getEmptyStateTitle = computed(() => getEmptyStateText('TITLE'));
|
||||
const getEmptyStateSubtitle = computed(() => getEmptyStateText('SUBTITLE'));
|
||||
|
||||
const handleTabChange = tab =>
|
||||
updateRoute({ tab: tab.value === ARTICLE_TABS.ALL ? '' : tab.value });
|
||||
|
||||
const handleCategoryAction = value =>
|
||||
updateRoute({ categorySlug: value === CATEGORY_ALL ? '' : value });
|
||||
|
||||
const handleLocaleAction = value => {
|
||||
updateRoute({ locale: value, categorySlug: '' });
|
||||
emit('fetchPortal', value);
|
||||
};
|
||||
const handlePageChange = page => emit('pageChange', page);
|
||||
|
||||
const navigateToNewArticlePage = () => {
|
||||
const { categorySlug, locale } = route.params;
|
||||
router.push({
|
||||
name: 'portals_articles_new',
|
||||
params: { categorySlug, locale },
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HelpCenterLayout
|
||||
:current-page="1"
|
||||
:total-items="100"
|
||||
:items-per-page="10"
|
||||
:current-page="Number(meta.currentPage)"
|
||||
:total-items="articlesCount"
|
||||
:items-per-page="25"
|
||||
:header="portalName"
|
||||
:show-pagination-footer="shouldShowPaginationFooter"
|
||||
@update:current-page="handlePageChange"
|
||||
>
|
||||
<template #header-actions>
|
||||
<div class="flex items-end justify-between">
|
||||
<div class="flex flex-col items-start w-full gap-2 lg:flex-row">
|
||||
<TabBar
|
||||
:tabs="tabs"
|
||||
:initial-active-tab="1"
|
||||
@tab-changed="handleTabChange"
|
||||
/>
|
||||
<div class="flex items-start justify-between w-full gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
label="English"
|
||||
size="sm"
|
||||
icon-position="right"
|
||||
icon="chevron-lucide-down"
|
||||
icon-lib="lucide"
|
||||
variant="secondary"
|
||||
/>
|
||||
<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>
|
||||
<ArticleHeaderControls
|
||||
v-if="showArticleHeaderControls"
|
||||
:categories="categories"
|
||||
:allowed-locales="allowedLocales"
|
||||
:meta="meta"
|
||||
@tab-change="handleTabChange"
|
||||
@locale-change="handleLocaleAction"
|
||||
@category-change="handleCategoryAction"
|
||||
@new-article="navigateToNewArticlePage"
|
||||
/>
|
||||
<CategoryHeaderControls
|
||||
v-else-if="showCategoryHeaderControls"
|
||||
:categories="categories"
|
||||
:allowed-locales="allowedLocales"
|
||||
:has-selected-category="isCategoryArticles"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<ArticleList :articles="articles" />
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<ArticleList
|
||||
v-else-if="!hasNoArticles"
|
||||
:articles="articles"
|
||||
:is-category-articles="isCategoryArticles"
|
||||
/>
|
||||
<ArticleEmptyState
|
||||
v-else
|
||||
class="pt-14"
|
||||
:title="getEmptyStateTitle"
|
||||
:subtitle="getEmptyStateSubtitle"
|
||||
:show-button="hasNoArticlesInPortal"
|
||||
:button-label="
|
||||
t('HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.ALL.BUTTON_LABEL')
|
||||
"
|
||||
@click="navigateToNewArticlePage"
|
||||
/>
|
||||
</template>
|
||||
</HelpCenterLayout>
|
||||
</template>
|
||||
|
||||
+107
-69
@@ -1,101 +1,139 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
// import { OnClickOutside } from '@vueuse/components';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
|
||||
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
||||
import CategoryList from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryList.vue';
|
||||
import ArticleList from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue';
|
||||
// import EditCategory from 'dashboard/playground/HelpCenter/components/EditCategory.vue';
|
||||
import CategoryHeaderControls from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryHeaderControls.vue';
|
||||
import CategoryEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/Category/CategoryEmptyState.vue';
|
||||
import EditCategoryDialog from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/EditCategoryDialog.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const props = defineProps({
|
||||
categories: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
isFetching: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
},
|
||||
allowedLocales: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['fetchCategories']);
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const editCategoryDialog = ref(null);
|
||||
const selectedCategory = ref(null);
|
||||
// const showEditCategory = ref(false);
|
||||
|
||||
// const openEditCategory = () => {
|
||||
// showEditCategory.value = true;
|
||||
// };
|
||||
// const closeEditCategory = () => {
|
||||
// showEditCategory.value = false;
|
||||
// };
|
||||
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
|
||||
const isLoading = computed(() => props.isFetching || isSwitchingPortal.value);
|
||||
const hasCategories = computed(() => props.categories?.length > 0);
|
||||
|
||||
const breadcrumbItems = computed(() => {
|
||||
const items = [{ label: 'Categories (en-US)', link: '#' }];
|
||||
if (selectedCategory.value) {
|
||||
items.push({
|
||||
label: selectedCategory.value.title,
|
||||
count: selectedCategory.value.articles.length,
|
||||
const updateRoute = (newParams, routeName) => {
|
||||
const { accountId, portalSlug, locale } = route.params;
|
||||
const baseParams = { accountId, portalSlug, locale };
|
||||
|
||||
router.push({
|
||||
name: routeName,
|
||||
params: {
|
||||
...baseParams,
|
||||
...newParams,
|
||||
categorySlug: newParams.categorySlug,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const openCategoryArticles = slug => {
|
||||
updateRoute({ categorySlug: slug }, 'portals_categories_articles_index');
|
||||
};
|
||||
|
||||
const handleLocaleChange = value => {
|
||||
updateRoute({ locale: value }, 'portals_categories_index');
|
||||
emit('fetchCategories', value);
|
||||
};
|
||||
|
||||
async function deleteCategory(category) {
|
||||
try {
|
||||
await store.dispatch('categories/delete', {
|
||||
portalSlug: route.params.portalSlug,
|
||||
categoryId: category.id,
|
||||
});
|
||||
|
||||
useTrack(PORTALS_EVENTS.DELETE_CATEGORY, {
|
||||
hasArticles: category?.meta?.articles_count > 0,
|
||||
});
|
||||
|
||||
useAlert(
|
||||
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.DELETE.API.SUCCESS_MESSAGE')
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error.message ||
|
||||
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.DELETE.API.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||
<template>
|
||||
<HelpCenterLayout :show-pagination-footer="false">
|
||||
<template #header-actions>
|
||||
<div class="flex items-center justify-between">
|
||||
<div v-if="!selectedCategory" class="flex items-center gap-4">
|
||||
<Button
|
||||
label="English"
|
||||
size="sm"
|
||||
icon-position="right"
|
||||
icon="chevron-lucide-down"
|
||||
icon-lib="lucide"
|
||||
variant="secondary"
|
||||
/>
|
||||
<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>
|
||||
<CategoryHeaderControls
|
||||
:categories="categories"
|
||||
:is-category-articles="false"
|
||||
:allowed-locales="allowedLocales"
|
||||
@locale-change="handleLocaleChange"
|
||||
/>
|
||||
</template>
|
||||
<template #content>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<CategoryList
|
||||
v-if="!selectedCategory"
|
||||
v-else-if="hasCategories"
|
||||
:categories="categories"
|
||||
@click="openCategoryArticles"
|
||||
@action="handleAction"
|
||||
/>
|
||||
<CategoryEmptyState
|
||||
v-else
|
||||
class="pt-14"
|
||||
:title="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_EMPTY_STATE.TITLE')"
|
||||
:subtitle="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_EMPTY_STATE.SUBTITLE')"
|
||||
/>
|
||||
<ArticleList v-else :articles="displayedArticles" />
|
||||
</template>
|
||||
<EditCategoryDialog
|
||||
ref="editCategoryDialog"
|
||||
:allowed-locales="allowedLocales"
|
||||
:selected-category="selectedCategory"
|
||||
/>
|
||||
</HelpCenterLayout>
|
||||
</template>
|
||||
|
||||
+112
@@ -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>
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
<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-n-strong"
|
||||
>
|
||||
<div class="flex flex-col items-start w-full gap-2 py-2">
|
||||
<span class="text-sm font-medium text-n-slate-11">
|
||||
{{ t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.HEADER.PORTAL') }}
|
||||
</span>
|
||||
<span class="text-sm text-n-slate-12">
|
||||
{{ portalName }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="justify-start w-px h-10 bg-n-strong" />
|
||||
<div class="flex flex-col w-full gap-2 py-2">
|
||||
<span class="text-sm font-medium text-n-slate-11">
|
||||
{{ t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.HEADER.LOCALE') }}
|
||||
</span>
|
||||
<span
|
||||
:title="`${activeLocaleName} (${activeLocaleCode})`"
|
||||
class="text-sm line-clamp-1 text-n-slate-12"
|
||||
>
|
||||
{{ `${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"
|
||||
>
|
||||
<template #prefix>
|
||||
<OnClickOutside @trigger="isEmojiPickerOpen = false">
|
||||
<Button
|
||||
:label="state.icon"
|
||||
color="slate"
|
||||
size="sm"
|
||||
:icon="!state.icon ? 'i-lucide-smile-plus' : ''"
|
||||
class="!h-[38px] !w-[38px] absolute top-[31px] !outline-none !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"
|
||||
/>
|
||||
<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
|
||||
/>
|
||||
<div
|
||||
v-if="showActionButtons"
|
||||
class="flex items-center justify-between w-full gap-3"
|
||||
>
|
||||
<Button
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.BUTTONS.CANCEL')"
|
||||
class="w-full bg-n-alpha-2 n-blue-text 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>
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
<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"
|
||||
trailing-icon
|
||||
icon="i-lucide-chevron-down"
|
||||
color="slate"
|
||||
@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="min-w-0 text-sm font-medium truncate 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="i-lucide-plus"
|
||||
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')"
|
||||
color="slate"
|
||||
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>
|
||||
+14
-7
@@ -8,10 +8,14 @@ defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
const emit = defineEmits(['click', 'action']);
|
||||
|
||||
const handleClick = id => {
|
||||
emit('click', id);
|
||||
const handleClick = slug => {
|
||||
emit('click', slug);
|
||||
};
|
||||
|
||||
const handleAction = ({ action, value, id }, category) => {
|
||||
emit('action', { action, value, id, category });
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -20,11 +24,14 @@ const handleClick = id => {
|
||||
<CategoryCard
|
||||
v-for="category in categories"
|
||||
:id="category.id"
|
||||
:key="category.title"
|
||||
:title="category.title"
|
||||
:key="category.id"
|
||||
:title="category.name"
|
||||
:icon="category.icon"
|
||||
:description="category.description"
|
||||
:articles-count="category.articlesCount"
|
||||
@click="handleClick(category.id)"
|
||||
:articles-count="category.meta.articles_count || 0"
|
||||
:slug="category.slug"
|
||||
@click="handleClick(category.slug)"
|
||||
@action="handleAction($event, category)"
|
||||
/>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
+112
@@ -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>
|
||||
+101
@@ -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>
|
||||
+88
-4
@@ -1,12 +1,94 @@
|
||||
<script setup>
|
||||
import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
locales: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
portal: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
const isLocaleDefault = code => {
|
||||
return props.portal?.meta?.default_locale === code;
|
||||
};
|
||||
|
||||
const updatePortalLocales = async ({
|
||||
newAllowedLocales,
|
||||
defaultLocale,
|
||||
messageKey,
|
||||
}) => {
|
||||
let alertMessage = '';
|
||||
try {
|
||||
await store.dispatch('portals/update', {
|
||||
portalSlug: props.portal.slug,
|
||||
config: {
|
||||
default_locale: defaultLocale,
|
||||
allowed_locales: newAllowedLocales,
|
||||
},
|
||||
});
|
||||
|
||||
alertMessage = t(`HELP_CENTER.PORTAL.${messageKey}.API.SUCCESS_MESSAGE`);
|
||||
} catch (error) {
|
||||
alertMessage =
|
||||
error?.message || t(`HELP_CENTER.PORTAL.${messageKey}.API.ERROR_MESSAGE`);
|
||||
} finally {
|
||||
useAlert(alertMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const changeDefaultLocale = ({ localeCode }) => {
|
||||
const newAllowedLocales = props.locales.map(locale => locale.code);
|
||||
updatePortalLocales({
|
||||
newAllowedLocales,
|
||||
defaultLocale: localeCode,
|
||||
messageKey: 'CHANGE_DEFAULT_LOCALE',
|
||||
});
|
||||
|
||||
useTrack(PORTALS_EVENTS.SET_DEFAULT_LOCALE, {
|
||||
newLocale: localeCode,
|
||||
from: route.name,
|
||||
});
|
||||
};
|
||||
|
||||
const deletePortalLocale = ({ localeCode }) => {
|
||||
const updatedLocales = props.locales
|
||||
.filter(locale => locale.code !== localeCode)
|
||||
.map(locale => locale.code);
|
||||
|
||||
const defaultLocale = props.portal.meta.default_locale;
|
||||
|
||||
updatePortalLocales({
|
||||
newAllowedLocales: updatedLocales,
|
||||
defaultLocale,
|
||||
messageKey: 'DELETE_LOCALE',
|
||||
});
|
||||
|
||||
useTrack(PORTALS_EVENTS.DELETE_LOCALE, {
|
||||
deletedLocale: localeCode,
|
||||
from: route.name,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAction = ({ action }, localeCode) => {
|
||||
if (action === 'change-default') {
|
||||
changeDefaultLocale({ localeCode: localeCode });
|
||||
} else if (action === 'delete') {
|
||||
deletePortalLocale({ localeCode: localeCode });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -15,9 +97,11 @@ defineProps({
|
||||
v-for="(locale, index) in locales"
|
||||
:key="index"
|
||||
:locale="locale.name"
|
||||
:is-default="locale.isDefault"
|
||||
:article-count="locale.articleCount"
|
||||
:category-count="locale.categoryCount"
|
||||
:is-default="isLocaleDefault(locale.code)"
|
||||
:locale-code="locale.code"
|
||||
:article-count="locale.articlesCount || 0"
|
||||
:category-count="locale.categoriesCount || 0"
|
||||
@action="handleAction($event, locale.code)"
|
||||
/>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
+26
-12
@@ -1,27 +1,33 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
|
||||
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import LocaleList from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue';
|
||||
import AddLocaleDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/AddLocaleDialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
locales: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
portal: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const localeCount = computed(() => props.locales?.length);
|
||||
const addLocaleDialogRef = ref(null);
|
||||
|
||||
// TODO: remove comments
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const handleTabChange = tab => {
|
||||
// TODO: Implement tab change logic
|
||||
};
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const handlePageChange = page => {
|
||||
// TODO: Implement page change logic
|
||||
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
|
||||
|
||||
const openAddLocaleDialog = () => {
|
||||
addLocaleDialogRef.value.dialogRef.open();
|
||||
};
|
||||
|
||||
const localeCount = computed(() => props.locales?.length);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -35,13 +41,21 @@ const handlePageChange = page => {
|
||||
</div>
|
||||
<Button
|
||||
:label="$t('HELP_CENTER.LOCALES_PAGE.NEW_LOCALE_BUTTON_TEXT')"
|
||||
icon="add"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
@click="openAddLocaleDialog"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<LocaleList :locales="locales" />
|
||||
<div
|
||||
v-if="isSwitchingPortal"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<LocaleList v-else :locales="locales" :portal="portal" />
|
||||
</template>
|
||||
<AddLocaleDialog ref="addLocaleDialogRef" :portal="portal" />
|
||||
</HelpCenterLayout>
|
||||
</template>
|
||||
|
||||
+74
@@ -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>
|
||||
+51
@@ -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>
|
||||
+79
@@ -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>
|
||||
+327
@@ -0,0 +1,327 @@
|
||||
<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="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
|
||||
>
|
||||
<label
|
||||
class="text-sm font-medium whitespace-nowrap 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')"
|
||||
: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="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
|
||||
>
|
||||
<label
|
||||
class="text-sm font-medium whitespace-nowrap 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')
|
||||
"
|
||||
custom-input-class="!bg-transparent dark:!bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
|
||||
>
|
||||
<label
|
||||
class="text-sm font-medium whitespace-nowrap 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')
|
||||
"
|
||||
custom-input-class="!bg-transparent dark:!bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
|
||||
>
|
||||
<label
|
||||
class="text-sm font-medium whitespace-nowrap 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')
|
||||
"
|
||||
: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="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
|
||||
>
|
||||
<label
|
||||
class="text-sm font-medium whitespace-nowrap 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')"
|
||||
: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="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
|
||||
>
|
||||
<label
|
||||
class="text-sm font-medium whitespace-nowrap 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="[&>div>button]:!outline-n-weak"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-start justify-between w-full gap-2">
|
||||
<label
|
||||
class="text-sm font-medium whitespace-nowrap 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>
|
||||
+118
@@ -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"
|
||||
color="slate"
|
||||
:label="
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.EDIT_BUTTON'
|
||||
)
|
||||
"
|
||||
@click="addCustomDomainDialogRef.dialogRef.open()"
|
||||
/>
|
||||
<Button
|
||||
v-else
|
||||
:label="
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.ADD_BUTTON'
|
||||
)
|
||||
"
|
||||
color="slate"
|
||||
@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>
|
||||
+116
-99
@@ -1,113 +1,130 @@
|
||||
<script setup>
|
||||
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
|
||||
const handleUploadAvatar = () => {};
|
||||
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
||||
import PortalBaseSettings from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue';
|
||||
import PortalConfigurationSettings from './PortalConfigurationSettings.vue';
|
||||
import ConfirmDeletePortalDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/ConfirmDeletePortalDialog.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
portals: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
isFetching: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'updatePortal',
|
||||
'updatePortalConfiguration',
|
||||
'deletePortal',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
const confirmDeletePortalDialogRef = ref(null);
|
||||
|
||||
const currentPortalSlug = computed(() => route.params.portalSlug);
|
||||
|
||||
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
|
||||
|
||||
const activePortal = computed(() => {
|
||||
return props.portals?.find(portal => portal.slug === currentPortalSlug.value);
|
||||
});
|
||||
|
||||
const activePortalName = computed(() => activePortal.value?.name || '');
|
||||
|
||||
const isLoading = computed(() => props.isFetching || isSwitchingPortal.value);
|
||||
|
||||
const handleUpdatePortal = portal => {
|
||||
emit('updatePortal', portal);
|
||||
};
|
||||
|
||||
const handleUpdatePortalConfiguration = portal => {
|
||||
emit('updatePortalConfiguration', portal);
|
||||
};
|
||||
|
||||
const openConfirmDeletePortalDialog = () => {
|
||||
confirmDeletePortalDialogRef.value.dialogRef.open();
|
||||
};
|
||||
|
||||
const handleDeletePortal = () => {
|
||||
emit('deletePortal', activePortal.value);
|
||||
confirmDeletePortalDialogRef.value.dialogRef.close();
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- TODO: Add i18n -->
|
||||
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||
<template>
|
||||
<HelpCenterLayout :show-pagination-footer="false">
|
||||
<template #content>
|
||||
<div class="flex flex-col w-full gap-10 max-w-[640px] pt-2 pb-8">
|
||||
<div class="flex flex-col w-full gap-4">
|
||||
<div class="flex flex-col w-full gap-2">
|
||||
<label
|
||||
class="mb-0.5 text-sm font-medium text-gray-900 dark:text-gray-50"
|
||||
>
|
||||
Avatar
|
||||
</label>
|
||||
<Avatar
|
||||
label="Avatar"
|
||||
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Amaya"
|
||||
class="bg-ruby-300 dark:bg-ruby-400"
|
||||
@upload="handleUploadAvatar"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col w-full gap-2">
|
||||
<div class="flex justify-between w-full h-10 gap-2 py-1">
|
||||
<label
|
||||
class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 dark:text-slate-50"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<Input placeholder="Name" class="w-[432px]" />
|
||||
</div>
|
||||
<div class="flex justify-between w-full h-10 gap-2 py-1">
|
||||
<label
|
||||
class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 dark:text-slate-50"
|
||||
>
|
||||
Header text
|
||||
</label>
|
||||
<Input placeholder="Header text" class="w-[432px]" />
|
||||
</div>
|
||||
<div class="flex justify-between w-full h-10 gap-2 py-1">
|
||||
<label
|
||||
class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 dark:text-slate-50"
|
||||
>
|
||||
Page title
|
||||
</label>
|
||||
<Input placeholder="Page title" class="w-[432px]" />
|
||||
</div>
|
||||
<div class="flex justify-between w-full h-10 gap-2 py-1">
|
||||
<label
|
||||
class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 dark:text-slate-50"
|
||||
>
|
||||
Widget color
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex justify-end w-full gap-2 py-2">
|
||||
<Button label="Save changes" size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full h-px bg-slate-50 dark:bg-slate-800/50" />
|
||||
</div>
|
||||
<div class="flex flex-col w-full gap-6">
|
||||
<div class="flex flex-col w-full gap-6">
|
||||
<h6 class="text-base font-medium text-slate-900 dark:text-slate-50">
|
||||
Configuration
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex items-center justify-center py-10 pt-2 pb-8 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="activePortal"
|
||||
class="flex flex-col w-full gap-4 max-w-[640px] pb-8"
|
||||
>
|
||||
<PortalBaseSettings
|
||||
:active-portal="activePortal"
|
||||
:is-fetching="isFetching"
|
||||
@update-portal="handleUpdatePortal"
|
||||
/>
|
||||
<div class="w-full h-px bg-slate-50 dark:bg-slate-800/50" />
|
||||
<PortalConfigurationSettings
|
||||
:active-portal="activePortal"
|
||||
:is-fetching="isFetching"
|
||||
@update-portal-configuration="handleUpdatePortalConfiguration"
|
||||
/>
|
||||
<div class="w-full h-px bg-slate-50 dark:bg-slate-800/50" />
|
||||
<div class="flex items-end justify-between w-full gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h6 class="text-base font-medium text-n-slate-12">
|
||||
{{
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.HEADER'
|
||||
)
|
||||
}}
|
||||
</h6>
|
||||
<div class="flex flex-col w-full gap-4">
|
||||
<div class="flex justify-between w-full gap-2 py-1">
|
||||
<InlineInput
|
||||
placeholder="Slug"
|
||||
label="Slug:"
|
||||
custom-label-class="min-w-[100px]"
|
||||
custom-input-class="!w-[430px]"
|
||||
/>
|
||||
</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"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.DESCRIPTION'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
:label="
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.BUTTON',
|
||||
{
|
||||
portalName: activePortalName,
|
||||
}
|
||||
)
|
||||
"
|
||||
color="ruby"
|
||||
class="max-w-56 !w-fit"
|
||||
@click="openConfirmDeletePortalDialog"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<ConfirmDeletePortalDialog
|
||||
ref="confirmDeletePortalDialogRef"
|
||||
:active-portal-name="activePortalName"
|
||||
@delete-portal="handleDeletePortal"
|
||||
/>
|
||||
</HelpCenterLayout>
|
||||
</template>
|
||||
|
||||
+148
@@ -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>
|
||||
+142
-90
@@ -1,111 +1,163 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import Button from 'dashboard/components-next/button/Button.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 { buildPortalURL } from 'dashboard/helper/portalHelper';
|
||||
|
||||
defineProps({
|
||||
portals: {
|
||||
type: Array,
|
||||
default: () => [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Chatwoot Help Center',
|
||||
articles: 67,
|
||||
domain: 'chatwoot.help',
|
||||
slug: 'help-center',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Chatwoot Handbook',
|
||||
articles: 42,
|
||||
domain: 'chatwoot.help',
|
||||
slug: 'handbook',
|
||||
},
|
||||
],
|
||||
},
|
||||
header: {
|
||||
type: String,
|
||||
default: 'Portals',
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: 'Create and manage multiple portals',
|
||||
},
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
|
||||
|
||||
const emit = defineEmits(['close', 'createPortal']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
const DEFAULT_ROUTE = 'portals_articles_index';
|
||||
const CATEGORY_ROUTE = 'portals_categories_index';
|
||||
const CATEGORY_SUB_ROUTES = [
|
||||
'portals_categories_articles_index',
|
||||
'portals_categories_articles_edit',
|
||||
];
|
||||
|
||||
const portals = useMapGetter('portals/allPortals');
|
||||
|
||||
const currentPortalSlug = computed(() => route.params.portalSlug);
|
||||
|
||||
const portalLink = computed(() => {
|
||||
return buildPortalURL(currentPortalSlug.value);
|
||||
});
|
||||
|
||||
const selectedPortal = ref(1);
|
||||
const isPortalActive = portal => {
|
||||
return portal.slug === currentPortalSlug.value;
|
||||
};
|
||||
|
||||
const handlePortalChange = id => {
|
||||
selectedPortal.value = id;
|
||||
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 onClickPreviewPortal = () => {
|
||||
window.open(portalLink.value, '_blank');
|
||||
};
|
||||
|
||||
const redirectToPortalHomePage = () => {
|
||||
router.push({
|
||||
name: 'portals_index',
|
||||
params: {
|
||||
navigationPath: DEFAULT_ROUTE,
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- TODO: Add i18n -->
|
||||
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||
<template>
|
||||
<div
|
||||
class="pt-5 pb-3 bg-white z-50 dark:bg-slate-800 absolute w-[440px] rounded-xl shadow-md flex flex-col gap-4"
|
||||
class="pt-5 pb-3 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 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">
|
||||
<h2 class="text-base font-medium text-slate-900 dark:text-slate-50">
|
||||
{{ header }}
|
||||
</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<h2
|
||||
class="text-base font-medium cursor-pointer text-slate-900 dark:text-slate-50 w-fit hover:underline"
|
||||
@click="redirectToPortalHomePage"
|
||||
>
|
||||
{{ t('HELP_CENTER.PORTAL_SWITCHER.PORTALS') }}
|
||||
</h2>
|
||||
<Button
|
||||
icon="i-lucide-arrow-up-right"
|
||||
variant="ghost"
|
||||
icon-lib="lucide"
|
||||
size="sm"
|
||||
class="!w-6 !h-6 hover:bg-n-slate-2 text-n-slate-11 !p-0.5 rounded-md"
|
||||
@click="onClickPreviewPortal"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-sm text-slate-600 dark:text-slate-300">
|
||||
{{ description }}
|
||||
{{ t('HELP_CENTER.PORTAL_SWITCHER.CREATE_PORTAL') }}
|
||||
</p>
|
||||
</div>
|
||||
<Button label="New portal" variant="secondary" icon="add" size="sm" />
|
||||
<Button
|
||||
:label="t('HELP_CENTER.PORTAL_SWITCHER.NEW_PORTAL')"
|
||||
color="slate"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
class="!bg-n-alpha-2 hover:!bg-n-alpha-3"
|
||||
@click="openCreatePortalDialog"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="portals.length > 0" class="flex flex-col gap-3">
|
||||
<template v-for="(portal, index) in portals" :key="portal.id">
|
||||
<div class="flex flex-col gap-2 px-6 py-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
:id="portal.id"
|
||||
v-model="selectedPortal"
|
||||
type="radio"
|
||||
:value="portal.id"
|
||||
class="mr-3"
|
||||
@change="handlePortalChange(portal.id)"
|
||||
/>
|
||||
<label
|
||||
:for="portal.id"
|
||||
class="text-sm font-medium text-slate-900 dark:text-slate-100"
|
||||
>
|
||||
{{ portal.name }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="w-4 h-4 rounded-full bg-slate-100 dark:bg-slate-700" />
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-2 py-1 text-sm">
|
||||
<span class="text-slate-600 dark:text-slate-400">
|
||||
articles:
|
||||
<span class="text-slate-800 dark:text-slate-200">
|
||||
{{ portal.articles }}
|
||||
</span>
|
||||
</span>
|
||||
<div class="w-px h-3 bg-slate-50 dark:bg-slate-700" />
|
||||
<span class="text-slate-600 dark:text-slate-400">
|
||||
domain:
|
||||
<span class="text-slate-800 dark:text-slate-200">
|
||||
{{ portal.domain }}
|
||||
</span>
|
||||
</span>
|
||||
<div class="w-px h-3 bg-slate-50 dark:bg-slate-700" />
|
||||
<span class="text-slate-600 dark:text-slate-400">
|
||||
slug:
|
||||
<span class="text-slate-800 dark:text-slate-200">
|
||||
{{ portal.slug }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="portals.length > 0" class="flex flex-col gap-2 px-4">
|
||||
<Button
|
||||
v-for="(portal, index) in portals"
|
||||
:key="index"
|
||||
:label="portal.name"
|
||||
variant="ghost"
|
||||
trailing-icon
|
||||
:icon="isPortalActive(portal) ? 'i-lucide-check' : ''"
|
||||
class="!justify-end !px-2 !py-2 hover:!bg-n-alpha-2 [&>.i-lucide-check]:text-n-teal-10 h-9"
|
||||
size="sm"
|
||||
@click="handlePortalChange(portal)"
|
||||
>
|
||||
<div v-if="portal.custom_domain" class="flex items-center gap-1">
|
||||
<span class="i-lucide-link size-3" />
|
||||
<span class="text-sm truncate text-n-slate-11">
|
||||
{{ portal.custom_domain || '' }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="index < portals.length - 1 && portals.length > 1"
|
||||
class="w-full h-px bg-slate-50 dark:bg-slate-700/50"
|
||||
<span class="text-sm font-medium truncate text-n-slate-12">
|
||||
{{ portal.name || '' }}
|
||||
</span>
|
||||
<Thumbnail
|
||||
v-if="portal"
|
||||
:author="portal"
|
||||
:name="portal.name"
|
||||
:size="20"
|
||||
:src="getPortalThumbnailSrc(portal)"
|
||||
:show-author-name="false"
|
||||
icon-name="i-lucide-building-2"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -7,28 +7,58 @@ import Avatar from './Avatar.vue';
|
||||
<Variant title="Default">
|
||||
<div class="p-4 bg-white dark:bg-slate-900">
|
||||
<Avatar
|
||||
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Amaya"
|
||||
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Amaya"
|
||||
class="bg-ruby-300 dark:bg-ruby-900"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Default with upload">
|
||||
<div class="p-4 bg-white dark:bg-slate-900">
|
||||
<Avatar
|
||||
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Amaya"
|
||||
class="bg-ruby-300 dark:bg-ruby-900"
|
||||
allow-upload
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Invalid or empty SRC">
|
||||
<div class="p-4 space-x-4 bg-white dark:bg-slate-900">
|
||||
<Avatar src="https://example.com/ruby.png" name="Ruby" allow-upload />
|
||||
<Avatar name="Bruce Wayne" allow-upload />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Rounded Full">
|
||||
<div class="p-4 space-x-4 bg-white dark:bg-slate-900">
|
||||
<Avatar
|
||||
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Amaya"
|
||||
allow-upload
|
||||
rounded-full
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Different Sizes">
|
||||
<div class="flex flex-wrap gap-4 p-4 bg-white dark:bg-slate-900">
|
||||
<Avatar
|
||||
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Felix"
|
||||
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Felix"
|
||||
:size="48"
|
||||
class="bg-green-300 dark:bg-green-900"
|
||||
allow-upload
|
||||
/>
|
||||
<Avatar
|
||||
:size="72"
|
||||
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Jade"
|
||||
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Jade"
|
||||
class="bg-indigo-300 dark:bg-indigo-900"
|
||||
allow-upload
|
||||
/>
|
||||
<Avatar
|
||||
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Emery"
|
||||
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Emery"
|
||||
:size="96"
|
||||
class="bg-woot-300 dark:bg-woot-900"
|
||||
allow-upload
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
@@ -1,51 +1,219 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import Icon from '../icon/Icon.vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { removeEmoji } from 'shared/helpers/emoji';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
const props = defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
default: 72,
|
||||
default: 32,
|
||||
},
|
||||
allowUpload: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
roundedFull: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
default: null,
|
||||
validator: value =>
|
||||
!value || wootConstants.AVAILABILITY_STATUS_KEYS.includes(value),
|
||||
},
|
||||
iconName: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['upload']);
|
||||
|
||||
const avatarSize = computed(() => `${props.size}px`);
|
||||
const iconSize = computed(() => `${props.size / 2}px`);
|
||||
const { t } = useI18n();
|
||||
|
||||
const handleUploadAvatar = () => {
|
||||
emit('upload');
|
||||
const isImageValid = ref(true);
|
||||
|
||||
const AVATAR_COLORS = {
|
||||
dark: [
|
||||
['#4B143D', '#FF8DCC'],
|
||||
['#3F220D', '#FFA366'],
|
||||
['#2A2A2A', '#ADB1B8'],
|
||||
['#023B37', '#0BD8B6'],
|
||||
['#27264D', '#A19EFF'],
|
||||
['#1D2E62', '#9EB1FF'],
|
||||
],
|
||||
light: [
|
||||
['#FBDCEF', '#C2298A'],
|
||||
['#FFE0BB', '#99543A'],
|
||||
['#E8E8E8', '#60646C'],
|
||||
['#CCF3EA', '#008573'],
|
||||
['#EBEBFE', '#4747C2'],
|
||||
['#E1E9FF', '#3A5BC7'],
|
||||
],
|
||||
default: { bg: '#E8E8E8', text: '#60646C' },
|
||||
};
|
||||
|
||||
const STATUS_CLASSES = {
|
||||
online: 'bg-n-teal-10',
|
||||
busy: 'bg-n-amber-10',
|
||||
offline: 'bg-n-slate-10',
|
||||
};
|
||||
|
||||
const showDefaultAvatar = computed(() => !props.src && !props.name);
|
||||
|
||||
const initials = computed(() => {
|
||||
if (!props.name) return '';
|
||||
const words = removeEmoji(props.name).split(/\s+/);
|
||||
return words.length === 1
|
||||
? words[0].charAt(0).toUpperCase()
|
||||
: words
|
||||
.slice(0, 2)
|
||||
.map(word => word.charAt(0))
|
||||
.join('')
|
||||
.toUpperCase();
|
||||
});
|
||||
|
||||
const getColorsByNameLength = computed(() => {
|
||||
if (!props.name) return AVATAR_COLORS.default;
|
||||
|
||||
const index = props.name.length % AVATAR_COLORS.light.length;
|
||||
return {
|
||||
bg: AVATAR_COLORS.light[index][0],
|
||||
darkBg: AVATAR_COLORS.dark[index][0],
|
||||
text: AVATAR_COLORS.light[index][1],
|
||||
darkText: AVATAR_COLORS.dark[index][1],
|
||||
};
|
||||
});
|
||||
|
||||
const containerStyles = computed(() => ({
|
||||
width: `${props.size}px`,
|
||||
height: `${props.size}px`,
|
||||
}));
|
||||
|
||||
const avatarStyles = computed(() => ({
|
||||
...containerStyles.value,
|
||||
backgroundColor:
|
||||
!showDefaultAvatar.value && (!props.src || !isImageValid.value)
|
||||
? getColorsByNameLength.value.bg
|
||||
: undefined,
|
||||
color:
|
||||
!showDefaultAvatar.value && (!props.src || !isImageValid.value)
|
||||
? getColorsByNameLength.value.text
|
||||
: undefined,
|
||||
'--dark-bg': getColorsByNameLength.value.darkBg,
|
||||
'--dark-text': getColorsByNameLength.value.darkText,
|
||||
}));
|
||||
|
||||
const badgeStyles = computed(() => {
|
||||
const badgeSize = Math.max(props.size * 0.35, 8); // 35% of avatar size, minimum 8px
|
||||
return {
|
||||
width: `${badgeSize}px`,
|
||||
height: `${badgeSize}px`,
|
||||
top: `${props.size - badgeSize / 1.1}px`,
|
||||
left: `${props.size - badgeSize / 1.1}px`,
|
||||
};
|
||||
});
|
||||
|
||||
const iconStyles = computed(() => ({
|
||||
fontSize: `${props.size / 1.6}px`,
|
||||
}));
|
||||
|
||||
const initialsStyles = computed(() => ({
|
||||
fontSize: `${props.size / 1.8}px`,
|
||||
}));
|
||||
|
||||
const invalidateCurrentImage = () => {
|
||||
isImageValid.value = false;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.src,
|
||||
() => {
|
||||
isImageValid.value = true;
|
||||
}
|
||||
);
|
||||
</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="src"
|
||||
:src="props.src"
|
||||
alt="avatar"
|
||||
class="w-full h-full shadow-sm rounded-xl"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center invisible w-full h-full transition-all duration-500 ease-in-out opacity-0 rounded-xl dark:bg-slate-900/50 bg-slate-900/20 group-hover/avatar:visible group-hover/avatar:opacity-100"
|
||||
@click="handleUploadAvatar"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-upload"
|
||||
:size="iconSize"
|
||||
class="text-white dark:text-white"
|
||||
<span class="relative inline-flex" :style="containerStyles">
|
||||
<!-- Status Badge -->
|
||||
<slot name="badge" :size="size">
|
||||
<div
|
||||
v-if="status"
|
||||
class="absolute z-20 border rounded-full border-n-slate-3"
|
||||
:style="badgeStyles"
|
||||
:class="STATUS_CLASSES[status]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<!-- Avatar Container -->
|
||||
<span
|
||||
role="img"
|
||||
class="relative inline-flex items-center justify-center object-cover overflow-hidden font-medium"
|
||||
:class="[
|
||||
roundedFull ? 'rounded-full' : 'rounded-xl',
|
||||
{
|
||||
'dark:!bg-[var(--dark-bg)] dark:!text-[var(--dark-text)]':
|
||||
!showDefaultAvatar && (!src || !isImageValid),
|
||||
'bg-n-slate-3 dark:bg-n-slate-4': showDefaultAvatar,
|
||||
},
|
||||
]"
|
||||
:style="avatarStyles"
|
||||
>
|
||||
<!-- Avatar Content -->
|
||||
<img
|
||||
v-if="src && isImageValid"
|
||||
:src="src"
|
||||
:alt="name"
|
||||
@error="invalidateCurrentImage"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<!-- Custom Icon -->
|
||||
<Icon v-if="iconName" :icon="iconName" :style="iconStyles" />
|
||||
|
||||
<!-- Initials -->
|
||||
<span
|
||||
v-else-if="!showDefaultAvatar"
|
||||
:style="initialsStyles"
|
||||
class="select-none"
|
||||
>
|
||||
{{ initials }}
|
||||
</span>
|
||||
|
||||
<!-- Fallback Icon if no name or image -->
|
||||
<Icon
|
||||
v-else
|
||||
v-tooltip.top-start="t('THUMBNAIL.AUTHOR.NOT_AVAILABLE')"
|
||||
icon="i-lucide-user"
|
||||
:style="iconStyles"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Upload Overlay -->
|
||||
<div
|
||||
v-if="allowUpload"
|
||||
role="button"
|
||||
class="absolute inset-0 flex items-center justify-center invisible w-full h-full transition-all duration-500 ease-in-out opacity-0 rounded-xl dark:bg-slate-900/50 bg-slate-900/20 group-hover/avatar:visible group-hover/avatar:opacity-100"
|
||||
@click="emit('upload')"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-upload"
|
||||
class="text-white dark:text-white size-4"
|
||||
/>
|
||||
</div>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -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,105 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.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 outline outline-1 outline-n-container 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"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-building-2"
|
||||
class="text-n-brand/50"
|
||||
:style="{ width: `${iconSize}`, height: `${iconSize}` }"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="src"
|
||||
class="absolute z-20 outline outline-1 outline-n-container 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"
|
||||
>
|
||||
<Icon icon="i-lucide-x" class="text-n-slate-11 size-4" />
|
||||
</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"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-upload"
|
||||
class="text-white"
|
||||
:style="{ width: `${iconSize}`, height: `${iconSize}` }"
|
||||
/>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
|
||||
class="hidden"
|
||||
@change="handleImageUpload"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -10,14 +10,14 @@ const twoItems = ref([
|
||||
const threeItems = ref([
|
||||
{ label: 'Home', link: '#' },
|
||||
{ label: 'Categories', link: '#' },
|
||||
{ label: 'Marketing', count: 6 },
|
||||
{ label: 'Marketing', count: 6, emoji: '📊' },
|
||||
]);
|
||||
const longBreadcrumb = ref([
|
||||
{ label: 'Home', link: '#' },
|
||||
{ label: 'Categories', link: '#' },
|
||||
{ label: 'Categories', link: '#', emoji: '📁' },
|
||||
{ label: 'Marketing', link: '#' },
|
||||
{ label: 'Digital', link: '#' },
|
||||
{ label: 'Social Media', count: 12 },
|
||||
{ label: 'Digital', link: '#', emoji: '💻' },
|
||||
{ label: 'Social Media', count: 12, emoji: '📱' },
|
||||
]);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup>
|
||||
import { defineProps } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
defineProps({
|
||||
items: {
|
||||
@@ -16,44 +17,39 @@ defineProps({
|
||||
);
|
||||
},
|
||||
},
|
||||
countLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const onClick = event => {
|
||||
emit('click', event);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav :aria-label="t('BREADCRUMB.ARIA_LABEL')" class="flex items-center h-8">
|
||||
<ol class="flex items-center mb-0">
|
||||
<li
|
||||
v-for="(item, index) in items"
|
||||
:key="index"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<template v-if="index === items.length - 1">
|
||||
<span class="text-sm text-slate-900 dark:text-slate-50">
|
||||
{{
|
||||
`${item.label}${item.count ? ` (${item.count} ${countLabel})` : ''}`
|
||||
}}
|
||||
<li v-for="(item, index) in items" :key="index" class="flex items-center">
|
||||
<button
|
||||
v-if="index === 0"
|
||||
class="inline-flex items-center justify-center min-w-0 gap-2 p-0 text-sm font-medium transition-all duration-200 ease-in-out border-0 rounded-lg text-n-slate-11 hover:text-n-slate-12 outline-transparent max-w-56"
|
||||
@click="onClick"
|
||||
>
|
||||
<span class="min-w-0 truncate">{{ item.label }}</span>
|
||||
</button>
|
||||
<template v-else>
|
||||
<Icon
|
||||
icon="i-lucide-chevron-right"
|
||||
class="flex-shrink-0 mx-2 size-4 text-n-slate-11 dark:text-n-slate-11"
|
||||
/>
|
||||
<span
|
||||
class="text-sm truncate text-slate-900 dark:text-slate-50 max-w-56"
|
||||
>
|
||||
{{ item.emoji ? item.emoji : '' }} {{ item.label }}
|
||||
</span>
|
||||
</template>
|
||||
<a
|
||||
v-else
|
||||
:href="item.link"
|
||||
class="text-sm transition-colors duration-200 text-slate-300 dark:text-slate-500 hover:text-slate-700 dark:hover:text-slate-100"
|
||||
>
|
||||
{{ item.label }}
|
||||
</a>
|
||||
<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>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
@@ -1,125 +1,123 @@
|
||||
<script setup>
|
||||
import Button from './Button.vue';
|
||||
|
||||
// Constants for documentation
|
||||
const VARIANTS = ['solid', 'outline', 'faded', 'link', 'ghost'];
|
||||
const COLORS = ['blue', 'ruby', 'amber', 'slate', 'teal'];
|
||||
const SIZES = ['default', 'sm', 'lg'];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Components/Button" :layout="{ type: 'grid', width: '400' }">
|
||||
<Variant title="Default">
|
||||
<div class="p-4 bg-white dark:bg-slate-900">
|
||||
<Button label="Default Button" />
|
||||
<Story title="Components/Button" :layout="{ type: 'grid', width: '800px' }">
|
||||
<!-- Basic Variants -->
|
||||
<Variant title="Basic Variants">
|
||||
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
|
||||
<Button
|
||||
v-for="variant in VARIANTS"
|
||||
:key="variant"
|
||||
:label="variant"
|
||||
:variant="variant"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Disabled">
|
||||
<!-- Colors -->
|
||||
<Variant title="Color Variants">
|
||||
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
|
||||
<Button
|
||||
v-for="color in COLORS"
|
||||
:key="color"
|
||||
:label="color"
|
||||
:color="color"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<!-- Sizes -->
|
||||
<Variant title="Size Variants">
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2 p-4 bg-white dark:bg-slate-900"
|
||||
>
|
||||
<Button v-for="size in SIZES" :key="size" :label="size" :size="size" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<!-- Icons -->
|
||||
<Variant title="Icons">
|
||||
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
|
||||
<Button label="Leading Icon" icon="i-lucide-plus" />
|
||||
<Button label="Trailing Icon" icon="i-lucide-plus" trailing-icon />
|
||||
<Button icon="i-lucide-plus" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<!-- Loading State -->
|
||||
<Variant title="Loading State">
|
||||
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
|
||||
<Button label="Loading" is-loading />
|
||||
<Button label="Loading" variant="outline" is-loading />
|
||||
<Button is-loading icon="i-lucide-plus" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<!-- Disabled State -->
|
||||
<Variant title="Disabled State">
|
||||
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
|
||||
<Button label="Disabled" disabled />
|
||||
<Button label="Disabled" variant="outline" disabled />
|
||||
<Button label="Disabled" disabled icon="delete" variant="outline" />
|
||||
<Button label="Disabled Outline" variant="outline" disabled />
|
||||
<Button label="Disabled Icon" icon="delete" disabled />
|
||||
<Button
|
||||
label="Disabled"
|
||||
label="Disabled Destructive"
|
||||
color="ruby"
|
||||
disabled
|
||||
icon="delete"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
/>
|
||||
<Button
|
||||
label="Disabled"
|
||||
disabled
|
||||
icon="delete"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
/>
|
||||
<Button
|
||||
label="Disabled"
|
||||
disabled
|
||||
icon="delete"
|
||||
variant="link"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Disabled with icon">
|
||||
<div class="p-4 bg-white dark:bg-slate-900">
|
||||
<Button label="Disabled Button" icon="emoji-add" disabled />
|
||||
<!-- Color Combinations -->
|
||||
<Variant title="Color & Variant Combinations">
|
||||
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
|
||||
<template v-for="color in COLORS" :key="color">
|
||||
<Button
|
||||
v-for="variant in VARIANTS"
|
||||
:key="`${color}-${variant}`"
|
||||
:label="`${color} ${variant}`"
|
||||
:color="color"
|
||||
:variant="variant"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Different variant">
|
||||
<!-- Icon Positions -->
|
||||
<Variant title="Icon Positions & Sizes">
|
||||
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
|
||||
<Button label="Default" variant="default" />
|
||||
<Button label="Destructive" variant="destructive" />
|
||||
<Button label="Outline" variant="outline" />
|
||||
<Button label="Secondary" variant="secondary" />
|
||||
<Button label="Ghost" variant="ghost" />
|
||||
<Button label="Link" variant="link" />
|
||||
<template v-for="size in SIZES" :key="size">
|
||||
<Button
|
||||
:label="`${size} Leading`"
|
||||
icon="i-lucide-plus"
|
||||
:size="size"
|
||||
/>
|
||||
<Button
|
||||
:label="`${size} Trailing`"
|
||||
icon="i-lucide-plus"
|
||||
trailing-icon
|
||||
:size="size"
|
||||
/>
|
||||
<Button icon="i-lucide-plus" :size="size" />
|
||||
</template>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Different variant with icon only">
|
||||
<!-- Ghost & Link Variants -->
|
||||
<Variant title="Ghost & Link Variants">
|
||||
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
|
||||
<Button icon="emoji-add" variant="default" />
|
||||
<Button icon="emoji-add" variant="destructive" />
|
||||
<Button icon="emoji-add" variant="outline" />
|
||||
<Button icon="emoji-add" variant="secondary" />
|
||||
<Button icon="emoji-add" variant="ghost" />
|
||||
<Button icon="emoji-add" variant="link" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Different size">
|
||||
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
|
||||
<Button label="Default" />
|
||||
<Button label="Large" size="lg" />
|
||||
<Button label="Small" size="sm" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Different text variant">
|
||||
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
|
||||
<Button label="Default" text-variant="default" variant="outline" />
|
||||
<Button label="Success" text-variant="success" variant="outline" />
|
||||
<Button label="Warning" text-variant="warning" variant="outline" />
|
||||
<Button label="Danger" text-variant="danger" variant="outline" />
|
||||
<Button label="Info" text-variant="info" variant="outline" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Button with left icon with different sizes and icon only">
|
||||
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
|
||||
<Button label="Default" icon="emoji-add" icon-position="left" />
|
||||
<Button
|
||||
label="Default LG"
|
||||
icon="emoji-add"
|
||||
icon-position="left"
|
||||
size="lg"
|
||||
/>
|
||||
<Button
|
||||
label="Default SM"
|
||||
icon="emoji-add"
|
||||
icon-position="left"
|
||||
size="sm"
|
||||
/>
|
||||
<Button icon="emoji-add" size="icon" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Button with right icon with different sizes and icon only">
|
||||
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
|
||||
<Button label="Default" icon="emoji-add" icon-position="right" />
|
||||
<Button
|
||||
label="Default LG"
|
||||
icon="emoji-add"
|
||||
icon-position="right"
|
||||
size="lg"
|
||||
/>
|
||||
<Button
|
||||
label="Default SM"
|
||||
icon="emoji-add"
|
||||
icon-position="right"
|
||||
size="sm"
|
||||
/>
|
||||
<Button icon="emoji-add" size="icon" />
|
||||
<Button label="Ghost Button" variant="ghost" />
|
||||
<Button label="Ghost with Icon" variant="ghost" icon="i-lucide-plus" />
|
||||
<Button label="Link Button" variant="link" />
|
||||
<Button label="Link with Icon" variant="link" icon="i-lucide-plus" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
|
||||
import { computed, useSlots } from 'vue';
|
||||
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
@@ -9,122 +11,157 @@ const props = defineProps({
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
default: 'solid',
|
||||
validator: value =>
|
||||
[
|
||||
'default',
|
||||
'destructive',
|
||||
'outline',
|
||||
'secondary',
|
||||
'ghost',
|
||||
'link',
|
||||
].includes(value),
|
||||
['solid', 'outline', 'faded', 'link', 'ghost'].includes(value),
|
||||
},
|
||||
textVariant: {
|
||||
color: {
|
||||
type: String,
|
||||
default: '',
|
||||
default: 'blue',
|
||||
validator: value =>
|
||||
['', 'default', 'success', 'warning', 'danger', 'info'].includes(value),
|
||||
['blue', 'ruby', 'amber', 'slate', 'teal'].includes(value),
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
validator: value => ['default', 'sm', 'lg', 'icon'].includes(value),
|
||||
default: 'md',
|
||||
validator: value => ['xs', 'sm', 'md', 'lg'].includes(value),
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
iconPosition: {
|
||||
type: String,
|
||||
default: 'left',
|
||||
validator: value => ['left', 'right'].includes(value),
|
||||
trailingIcon: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
iconLib: {
|
||||
type: String,
|
||||
default: 'fluent',
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
const slots = useSlots();
|
||||
|
||||
const buttonVariants = {
|
||||
variant: {
|
||||
default:
|
||||
'bg-woot-500 dark:bg-woot-500 text-white dark:text-white hover:bg-woot-600 dark:hover:bg-woot-600',
|
||||
destructive:
|
||||
'bg-ruby-700 dark:bg-ruby-700 text-white dark:text-white hover:bg-ruby-800 dark:hover:bg-ruby-800',
|
||||
outline:
|
||||
'border border-slate-200 dark:border-slate-700/50 hover:border-slate-300 dark:hover:border-slate-600',
|
||||
secondary:
|
||||
'bg-slate-50 text-slate-900 dark:bg-slate-700/50 dark:text-slate-100 hover:bg-slate-100 dark:hover:bg-slate-600',
|
||||
ghost:
|
||||
'text-slate-900 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800',
|
||||
link: 'text-woot-500 underline-offset-4 hover:underline dark:hover:underline',
|
||||
const STYLE_CONFIG = {
|
||||
colors: {
|
||||
blue: {
|
||||
solid: 'bg-n-brand text-white hover:brightness-110 outline-transparent',
|
||||
faded:
|
||||
'bg-n-brand/10 text-n-slate-12 hover:bg-n-brand/20 outline-transparent',
|
||||
outline: 'text-n-blue-text outline-n-blue-border',
|
||||
link: 'text-n-brand hover:underline outline-transparent',
|
||||
},
|
||||
ruby: {
|
||||
solid: 'bg-n-ruby-9 text-white hover:bg-n-ruby-10 outline-transparent',
|
||||
faded:
|
||||
'bg-n-ruby-9/10 text-n-ruby-11 hover:bg-n-ruby-9/20 outline-transparent',
|
||||
outline: 'text-n-ruby-11 hover:bg-n-ruby-9/10 outline-n-ruby-8',
|
||||
link: 'text-n-ruby-9 hover:underline outline-transparent',
|
||||
},
|
||||
amber: {
|
||||
solid: 'bg-n-amber-9 text-white hover:bg-n-amber-10 outline-transparent',
|
||||
faded:
|
||||
'bg-n-amber-9/10 text-n-slate-12 hover:bg-n-amber-9/20 outline-transparent',
|
||||
outline: 'text-n-amber-11 hover:bg-n-amber-9/10 outline-n-amber-9',
|
||||
link: 'text-n-amber-9 hover:underline outline-transparent',
|
||||
},
|
||||
slate: {
|
||||
solid:
|
||||
'bg-n-solid-3 dark:hover:bg-n-solid-2 hover:bg-n-alpha-2 text-n-slate-12 outline-n-container',
|
||||
faded:
|
||||
'bg-n-slate-9/10 text-n-slate-12 hover:bg-n-slate-9/20 outline-transparent',
|
||||
outline: 'text-n-slate-11 outline-n-strong hover:bg-n-slate-9/10',
|
||||
link: 'text-n-slate-11 hover:text-n-slate-12 hover:underline outline-transparent',
|
||||
},
|
||||
teal: {
|
||||
solid: 'bg-n-teal-9 text-white hover:bg-n-teal-10 outline-transparent',
|
||||
faded:
|
||||
'bg-n-teal-9/10 text-n-slate-12 hover:bg-n-teal-9/20 outline-transparent',
|
||||
outline: 'text-n-teal-11 hover:bg-n-teal-9/10 outline-n-teal-9',
|
||||
link: 'text-n-teal-9 hover:underline outline-transparent',
|
||||
},
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-8 px-3',
|
||||
lg: 'h-11 px-4',
|
||||
icon: 'h-auto w-auto px-2',
|
||||
sizes: {
|
||||
regular: {
|
||||
xs: 'h-6 px-2',
|
||||
sm: 'h-8 px-3',
|
||||
md: 'h-10 px-4',
|
||||
lg: 'h-12 px-5',
|
||||
},
|
||||
iconOnly: {
|
||||
xs: 'h-6 w-6 p-0',
|
||||
sm: 'h-8 w-8 p-0',
|
||||
md: 'h-10 w-10 p-0',
|
||||
lg: 'h-12 w-12 p-0',
|
||||
},
|
||||
link: {
|
||||
xs: 'p-0',
|
||||
sm: 'p-0',
|
||||
md: 'p-0',
|
||||
lg: 'p-0',
|
||||
},
|
||||
},
|
||||
text: {
|
||||
default:
|
||||
'!text-woot-500 dark:!text-woot-500 hover:!text-woot-600 dark:hover:!text-woot-600',
|
||||
success:
|
||||
'!text-green-500 dark:!text-green-500 hover:!text-green-600 dark:hover:!text-green-600',
|
||||
warning:
|
||||
'!text-amber-600 dark:!text-amber-600 hover:!text-amber-600 dark:hover:!text-amber-600',
|
||||
danger:
|
||||
'!text-ruby-700 dark:!text-ruby-700 hover:!text-ruby-800 dark:hover:!text-ruby-800',
|
||||
info: '!text-slate-500 dark:!text-slate-400 hover:!text-slate-600 dark:hover:!text-slate-500',
|
||||
fontSize: {
|
||||
xs: 'text-xs',
|
||||
sm: 'text-sm',
|
||||
md: 'text-sm font-medium',
|
||||
lg: 'text-base',
|
||||
},
|
||||
base: 'inline-flex items-center justify-center min-w-0 gap-2 transition-all duration-200 ease-in-out border-0 rounded-lg outline-1 outline disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50',
|
||||
};
|
||||
|
||||
const buttonClasses = computed(() => {
|
||||
const classes = [
|
||||
buttonVariants.variant[props.variant],
|
||||
buttonVariants.size[props.size],
|
||||
];
|
||||
const variantClasses = computed(() => {
|
||||
const variantMap = {
|
||||
ghost: 'text-n-slate-12 hover:bg-n-alpha-2 outline-transparent',
|
||||
link: `${STYLE_CONFIG.colors[props.color].link} p-0 font-medium underline-offset-4`,
|
||||
outline: STYLE_CONFIG.colors[props.color].outline,
|
||||
faded: STYLE_CONFIG.colors[props.color].faded,
|
||||
solid: STYLE_CONFIG.colors[props.color].solid,
|
||||
};
|
||||
|
||||
if (props.textVariant && buttonVariants.text[props.textVariant]) {
|
||||
classes.push(buttonVariants.text[props.textVariant]);
|
||||
}
|
||||
return variantMap[props.variant];
|
||||
});
|
||||
|
||||
const isIconOnly = computed(() => !props.label && !slots.default);
|
||||
const isLink = computed(() => props.variant === 'link');
|
||||
|
||||
const buttonClasses = computed(() => {
|
||||
const sizeConfig = isIconOnly.value ? 'iconOnly' : 'regular';
|
||||
const classes = [
|
||||
variantClasses.value,
|
||||
props.variant !== 'link' && STYLE_CONFIG.sizes[sizeConfig][props.size],
|
||||
].filter(Boolean);
|
||||
|
||||
return classes.join(' ');
|
||||
});
|
||||
|
||||
const iconSize = computed(() => {
|
||||
if (props.size === 'sm') return 16;
|
||||
if (props.size === 'lg') return 20;
|
||||
return 18;
|
||||
});
|
||||
const linkButtonClasses = computed(() => {
|
||||
const classes = [
|
||||
variantClasses.value,
|
||||
STYLE_CONFIG.sizes.link[props.size],
|
||||
].filter(Boolean);
|
||||
|
||||
const handleClick = () => {
|
||||
emit('click');
|
||||
};
|
||||
return classes.join(' ');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:class="buttonClasses"
|
||||
class="inline-flex items-center justify-center h-10 min-w-0 gap-2 text-sm font-medium transition-all duration-200 ease-in-out rounded-lg disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
|
||||
@click="handleClick"
|
||||
:class="{
|
||||
[STYLE_CONFIG.base]: true,
|
||||
[isLink ? linkButtonClasses : buttonClasses]: true,
|
||||
[STYLE_CONFIG.fontSize[size]]: true,
|
||||
'flex-row-reverse': trailingIcon && !isIconOnly,
|
||||
}"
|
||||
>
|
||||
<FluentIcon
|
||||
v-if="icon && iconPosition === 'left'"
|
||||
:icon="icon"
|
||||
:size="iconSize"
|
||||
:icon-lib="iconLib"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
<span v-if="label" class="min-w-0 truncate">{{ label }}</span>
|
||||
<FluentIcon
|
||||
v-if="icon && iconPosition === 'right'"
|
||||
:icon="icon"
|
||||
:size="iconSize"
|
||||
:icon-lib="iconLib"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
<slot v-if="(icon || $slots.icon) && !isLoading" name="icon">
|
||||
<Icon :icon="icon" class="flex-shrink-0" />
|
||||
</slot>
|
||||
|
||||
<Spinner v-if="isLoading" class="!w-5 !h-5 flex-shrink-0" />
|
||||
|
||||
<slot v-if="label || $slots.default" name="default">
|
||||
<span class="min-w-0 truncate">{{ label }}</span>
|
||||
</slot>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<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
|
||||
color="slate"
|
||||
icon="i-lucide-pipette"
|
||||
trailing-icon
|
||||
class="!px-3 !py-3 [&>svg]:w-4 [&>svg]:h-4"
|
||||
@click="toggleColorPicker"
|
||||
>
|
||||
<div class="flex items-center flex-grow gap-2">
|
||||
<span
|
||||
class="rounded-md size-4"
|
||||
:style="{ backgroundColor: modelValue }"
|
||||
/>
|
||||
<span class="min-w-0 truncate">{{ modelValue }}</span>
|
||||
</div>
|
||||
</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,9 +1,10 @@
|
||||
<script setup>
|
||||
import { nextTick, ref, computed, watch } from 'vue';
|
||||
import { onClickOutside } from '@vueuse/core';
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ComboBoxDropdown from 'dashboard/components-next/combobox/ComboBoxDropdown.vue';
|
||||
|
||||
const props = defineProps({
|
||||
options: {
|
||||
@@ -32,6 +33,14 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
hasError: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
@@ -41,7 +50,7 @@ const { t } = useI18n();
|
||||
const selectedValue = ref(props.modelValue);
|
||||
const open = ref(false);
|
||||
const search = ref('');
|
||||
const searchInput = ref(null);
|
||||
const dropdownRef = ref(null);
|
||||
const comboboxRef = ref(null);
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
@@ -66,11 +75,13 @@ const selectOption = option => {
|
||||
open.value = false;
|
||||
search.value = '';
|
||||
};
|
||||
|
||||
const toggleDropdown = () => {
|
||||
if (props.disabled) return;
|
||||
open.value = !open.value;
|
||||
if (open.value) {
|
||||
search.value = '';
|
||||
nextTick(() => searchInput.value.focus());
|
||||
nextTick(() => dropdownRef.value?.focus());
|
||||
}
|
||||
};
|
||||
|
||||
@@ -80,84 +91,53 @@ watch(
|
||||
selectedValue.value = newValue;
|
||||
}
|
||||
);
|
||||
|
||||
onClickOutside(comboboxRef, () => {
|
||||
open.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="comboboxRef"
|
||||
class="relative w-full"
|
||||
class="relative w-full min-w-0"
|
||||
:class="{
|
||||
'cursor-not-allowed': disabled,
|
||||
'group/combobox': !disabled,
|
||||
}"
|
||||
@click.prevent
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
:label="selectedLabel"
|
||||
icon-position="right"
|
||||
size="sm"
|
||||
:disabled="disabled"
|
||||
class="justify-between w-full text-slate-900 dark:text-slate-100 group-hover/combobox:border-slate-300 dark:group-hover/combobox:border-slate-600"
|
||||
:icon="open ? 'chevron-up' : 'chevron-down'"
|
||||
@click="toggleDropdown"
|
||||
/>
|
||||
<div
|
||||
v-show="open"
|
||||
class="absolute z-50 w-full mt-1 transition-opacity duration-200 bg-white border rounded-md shadow-lg border-slate-200 dark:bg-slate-900 dark:border-slate-700/50"
|
||||
>
|
||||
<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"
|
||||
<OnClickOutside @trigger="open = false">
|
||||
<Button
|
||||
variant="outline"
|
||||
:color="hasError && !open ? 'ruby' : open ? 'blue' : 'slate'"
|
||||
:label="selectedLabel"
|
||||
trailing-icon
|
||||
:disabled="disabled"
|
||||
class="justify-between w-full !px-3 !py-2.5 text-n-slate-12 font-normal group-hover/combobox:border-n-slate-6"
|
||||
:class="{ focused: open }"
|
||||
:icon="open ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
@click="toggleDropdown"
|
||||
/>
|
||||
|
||||
<ComboBoxDropdown
|
||||
ref="dropdownRef"
|
||||
:open="open"
|
||||
:options="filteredOptions"
|
||||
:search-value="search"
|
||||
:search-placeholder="searchPlaceholder"
|
||||
:empty-state="emptyState"
|
||||
:selected-values="selectedValue"
|
||||
@update:search-value="search = $event"
|
||||
@select="selectOption"
|
||||
/>
|
||||
|
||||
<p
|
||||
v-if="message"
|
||||
class="mt-2 mb-0 text-xs truncate transition-all duration-500 ease-in-out"
|
||||
:class="{
|
||||
'text-n-ruby-9': hasError,
|
||||
'text-n-slate-11': !hasError,
|
||||
}"
|
||||
>
|
||||
<li
|
||||
v-for="option in filteredOptions"
|
||||
:key="option.value"
|
||||
class="flex items-center justify-between w-full gap-2 px-3 py-2 text-sm transition-colors duration-150 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/50"
|
||||
:class="{
|
||||
'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
|
||||
v-if="option.value === selectedValue"
|
||||
icon="checkmark"
|
||||
:size="16"
|
||||
class="flex-shrink-0"
|
||||
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>
|
||||
{{ message }}
|
||||
</p>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
open: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
searchValue: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
searchPlaceholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
emptyState: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
selectedValues: {
|
||||
type: [String, Number, Array],
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:searchValue', 'select']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const searchInput = ref(null);
|
||||
|
||||
const isSelected = option => {
|
||||
if (Array.isArray(props.selectedValues)) {
|
||||
return props.selectedValues.includes(option.value);
|
||||
}
|
||||
return option.value === props.selectedValues;
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
focus: () => searchInput.value?.focus(),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
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-n-strong">
|
||||
<span class="absolute i-lucide-search top-2.5 size-4 left-3" />
|
||||
<input
|
||||
ref="searchInput"
|
||||
:value="searchValue"
|
||||
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"
|
||||
@input="emit('update:searchValue', $event.target.value)"
|
||||
/>
|
||||
</div>
|
||||
<ul
|
||||
class="py-1 mb-0 overflow-auto max-h-60"
|
||||
role="listbox"
|
||||
:aria-multiselectable="multiple"
|
||||
>
|
||||
<li
|
||||
v-for="option in options"
|
||||
: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-n-alpha-2"
|
||||
:class="{
|
||||
'bg-n-alpha-2': isSelected(option),
|
||||
}"
|
||||
role="option"
|
||||
:aria-selected="isSelected(option)"
|
||||
@click="emit('select', option)"
|
||||
>
|
||||
<span
|
||||
:class="{
|
||||
'font-medium': isSelected(option),
|
||||
}"
|
||||
class="text-n-slate-12"
|
||||
>
|
||||
{{ option.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="isSelected(option)"
|
||||
class="flex-shrink-0 i-lucide-check size-4 text-n-slate-11"
|
||||
/>
|
||||
</li>
|
||||
<li
|
||||
v-if="options.length === 0"
|
||||
class="px-3 py-2 text-sm text-slate-600 dark:text-slate-300"
|
||||
>
|
||||
{{ emptyState || t('COMBOBOX.EMPTY_STATE') }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,183 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import ComboBoxDropdown from 'dashboard/components-next/combobox/ComboBoxDropdown.vue';
|
||||
|
||||
const props = defineProps({
|
||||
options: {
|
||||
type: Array,
|
||||
required: true,
|
||||
validator: value =>
|
||||
value.every(option => 'value' in option && 'label' in option),
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
searchPlaceholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
emptyState: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
hasError: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const selectedValues = ref(props.modelValue);
|
||||
const open = ref(false);
|
||||
const search = ref('');
|
||||
const dropdownRef = ref(null);
|
||||
const comboboxRef = ref(null);
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
const searchTerm = search.value.toLowerCase();
|
||||
return props.options.filter(option =>
|
||||
option.label?.toLowerCase().includes(searchTerm)
|
||||
);
|
||||
});
|
||||
|
||||
const selectPlaceholder = computed(() => {
|
||||
return props.placeholder || t('COMBOBOX.PLACEHOLDER');
|
||||
});
|
||||
|
||||
const selectedTags = computed(() => {
|
||||
return selectedValues.value.map(value => {
|
||||
const option = props.options.find(opt => opt.value === value);
|
||||
return option || { value, label: value };
|
||||
});
|
||||
});
|
||||
|
||||
const toggleOption = option => {
|
||||
const index = selectedValues.value.indexOf(option.value);
|
||||
if (index === -1) {
|
||||
selectedValues.value.push(option.value);
|
||||
} else {
|
||||
selectedValues.value.splice(index, 1);
|
||||
}
|
||||
emit('update:modelValue', selectedValues.value);
|
||||
};
|
||||
|
||||
const removeTag = value => {
|
||||
const index = selectedValues.value.indexOf(value);
|
||||
if (index !== -1) {
|
||||
selectedValues.value.splice(index, 1);
|
||||
emit('update:modelValue', selectedValues.value);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDropdown = () => {
|
||||
if (props.disabled) return;
|
||||
open.value = !open.value;
|
||||
if (open.value) {
|
||||
search.value = '';
|
||||
nextTick(() => dropdownRef.value?.focus());
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
newValue => {
|
||||
selectedValues.value = newValue;
|
||||
}
|
||||
);
|
||||
|
||||
defineExpose({
|
||||
toggleDropdown,
|
||||
open,
|
||||
disabled: props.disabled,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="comboboxRef"
|
||||
class="relative w-full min-w-0"
|
||||
:class="{
|
||||
'cursor-not-allowed': disabled,
|
||||
'group/combobox': !disabled,
|
||||
}"
|
||||
@click.prevent
|
||||
>
|
||||
<OnClickOutside @trigger="open = false">
|
||||
<div
|
||||
class="flex flex-wrap w-full gap-2 px-3 py-2.5 border rounded-lg cursor-pointer bg-n-alpha-black2 min-h-[42px] transition-all duration-500 ease-in-out"
|
||||
:class="{
|
||||
'border-n-ruby-8': hasError,
|
||||
'border-n-weak dark:border-n-weak hover:border-n-slate-6 dark:hover:border-n-slate-6':
|
||||
!hasError && !open,
|
||||
'border-n-brand': open,
|
||||
'cursor-not-allowed pointer-events-none opacity-50': disabled,
|
||||
}"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<div
|
||||
v-for="tag in selectedTags"
|
||||
:key="tag.value"
|
||||
class="flex items-center justify-center max-w-full gap-1 px-2 py-0.5 rounded-lg bg-n-alpha-black1"
|
||||
@click.stop
|
||||
>
|
||||
<span class="flex-grow min-w-0 text-sm truncate text-n-slate-12">
|
||||
{{ tag.label }}
|
||||
</span>
|
||||
<span
|
||||
class="flex-shrink-0 cursor-pointer i-lucide-x size-3 text-n-slate-11"
|
||||
@click="removeTag(tag.value)"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
v-if="selectedTags.length === 0"
|
||||
class="flex items-center text-sm text-n-slate-11"
|
||||
>
|
||||
{{ selectPlaceholder }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ComboBoxDropdown
|
||||
ref="dropdownRef"
|
||||
:open="open"
|
||||
:options="filteredOptions"
|
||||
:search-value="search"
|
||||
:search-placeholder="searchPlaceholder"
|
||||
:empty-state="emptyState"
|
||||
multiple
|
||||
:selected-values="selectedValues"
|
||||
@update:search-value="search = $event"
|
||||
@select="toggleOption"
|
||||
/>
|
||||
|
||||
<p
|
||||
v-if="message"
|
||||
class="mt-2 mb-0 text-xs truncate transition-all duration-500 ease-in-out"
|
||||
:class="{
|
||||
'text-n-ruby-9': hasError,
|
||||
'text-n-slate-11': !hasError,
|
||||
}"
|
||||
>
|
||||
{{ message }}
|
||||
</p>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import TagMultiSelectComboBox from './TagMultiSelectComboBox.vue';
|
||||
|
||||
const options = [
|
||||
{ value: 1, label: 'Option 1' },
|
||||
{ value: 2, label: 'Option 2' },
|
||||
{ value: 3, label: 'Option 3' },
|
||||
{ value: 4, label: 'Option 4' },
|
||||
{ value: 5, label: 'Option 5' },
|
||||
];
|
||||
const selectedValues = ref([]);
|
||||
|
||||
const preselectedValues = ref([1, 2]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/TagMultiSelectComboBox"
|
||||
:layout="{ type: 'grid', width: '300px' }"
|
||||
>
|
||||
<Variant title="Default">
|
||||
<div class="w-full p-4 bg-white h-80 dark:bg-slate-900">
|
||||
<TagMultiSelectComboBox v-model="selectedValues" :options="options" />
|
||||
<p class="mt-2">Selected values: {{ selectedValues }}</p>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="With Preselected Values">
|
||||
<div class="w-full p-4 bg-white h-80 dark:bg-slate-900">
|
||||
<TagMultiSelectComboBox
|
||||
v-model="preselectedValues"
|
||||
:options="options"
|
||||
placeholder="Select multiple options"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Disabled">
|
||||
<div class="w-full p-4 bg-white h-80 dark:bg-slate-900">
|
||||
<TagMultiSelectComboBox :options="options" disabled />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="With Error">
|
||||
<div class="w-full p-4 bg-white h-80 dark:bg-slate-900">
|
||||
<TagMultiSelectComboBox
|
||||
:options="options"
|
||||
has-error
|
||||
message="This field is required"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { onClickOutside } from '@vueuse/core';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
@@ -26,12 +28,34 @@ defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
disableConfirmButton: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showCancelButton: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showConfirmButton: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
overflowYAuto: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['confirm']);
|
||||
const emit = defineEmits(['confirm', 'close']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const isRTL = useMapGetter('accounts/isRTL');
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const dialogContentRef = ref(null);
|
||||
|
||||
@@ -39,71 +63,71 @@ const open = () => {
|
||||
dialogRef.value?.showModal();
|
||||
};
|
||||
const close = () => {
|
||||
emit('close');
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
const confirm = () => {
|
||||
emit('confirm');
|
||||
};
|
||||
|
||||
defineExpose({ open });
|
||||
|
||||
onClickOutside(dialogContentRef, event => {
|
||||
if (
|
||||
dialogRef.value &&
|
||||
dialogRef.value.open &&
|
||||
event.target === dialogRef.value
|
||||
) {
|
||||
close();
|
||||
}
|
||||
});
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<dialog
|
||||
ref="dialogRef"
|
||||
class="w-full max-w-lg overflow-visible shadow-xl bg-modal-backdrop-light dark:bg-modal-backdrop-dark rounded-xl"
|
||||
class="w-full max-w-lg transition-all duration-300 ease-in-out shadow-xl rounded-xl"
|
||||
:class="overflowYAuto ? 'overflow-y-auto' : 'overflow-visible'"
|
||||
:dir="isRTL ? 'rtl' : 'ltr'"
|
||||
@close="close"
|
||||
>
|
||||
<div
|
||||
ref="dialogContentRef"
|
||||
class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-left align-middle transition-all duration-300 ease-in-out transform bg-white shadow-xl dark:bg-slate-800 rounded-xl"
|
||||
@click.stop
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3
|
||||
class="text-base font-medium leading-6 text-gray-900 dark:text-white"
|
||||
>
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p
|
||||
v-if="description"
|
||||
class="mb-0 text-sm text-slate-500 dark:text-slate-400"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
<OnClickOutside @trigger="close">
|
||||
<div
|
||||
ref="dialogContentRef"
|
||||
class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-left align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
|
||||
@click.stop
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="text-base font-medium leading-6 text-n-slate-12">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<slot name="description">
|
||||
<p v-if="description" class="mb-0 text-sm text-n-slate-11">
|
||||
{{ description }}
|
||||
</p>
|
||||
</slot>
|
||||
</div>
|
||||
<slot name="form">
|
||||
<!-- Form content will be injected here -->
|
||||
</slot>
|
||||
<div class="flex items-center justify-between w-full gap-3">
|
||||
<Button
|
||||
v-if="showCancelButton"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:label="cancelButtonLabel || t('DIALOG.BUTTONS.CANCEL')"
|
||||
class="w-full"
|
||||
@click="close"
|
||||
/>
|
||||
<Button
|
||||
v-if="showConfirmButton"
|
||||
:color="type === 'edit' ? 'blue' : 'ruby'"
|
||||
:label="confirmButtonLabel || t('DIALOG.BUTTONS.CONFIRM')"
|
||||
class="w-full"
|
||||
:is-loading="isLoading"
|
||||
:disabled="disableConfirmButton || isLoading"
|
||||
@click="confirm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<slot name="form">
|
||||
<!-- Form content will be injected here -->
|
||||
</slot>
|
||||
<div class="flex items-center justify-between w-full gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
: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>
|
||||
</OnClickOutside>
|
||||
</dialog>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
dialog::backdrop {
|
||||
@apply dark:bg-n-alpha-white bg-n-alpha-black2;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,35 +1,58 @@
|
||||
<script setup>
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
|
||||
|
||||
defineProps({
|
||||
menuItems: {
|
||||
type: Array,
|
||||
required: true,
|
||||
validator: value => {
|
||||
return value.every(item => item.action && item.value && item.label);
|
||||
},
|
||||
},
|
||||
thumbnailSize: {
|
||||
type: Number,
|
||||
default: 20,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action']);
|
||||
|
||||
const handleAction = action => {
|
||||
emit('action', action);
|
||||
const handleAction = (action, value) => {
|
||||
emit('action', { action, value });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-white dark:bg-slate-800 absolute rounded-xl z-50 py-3 px-1 gap-2 flex flex-col min-w-[136px] shadow-lg"
|
||||
class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container 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"
|
||||
:key="item.action"
|
||||
:label="item.label"
|
||||
:icon="item.icon"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="!justify-start w-full hover:bg-white dark:hover:bg-slate-800 z-60 font-normal"
|
||||
:text-variant="item.action === 'delete' ? 'danger' : ''"
|
||||
@click="handleAction(item.action)"
|
||||
/>
|
||||
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
|
||||
:class="{
|
||||
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
|
||||
'text-n-ruby-11': item.action === 'delete',
|
||||
'text-n-slate-12': item.action !== 'delete',
|
||||
}"
|
||||
:disabled="item.disabled"
|
||||
@click="handleAction(item.action, item.value)"
|
||||
>
|
||||
<Thumbnail
|
||||
v-if="item.thumbnail"
|
||||
:author="item.thumbnail"
|
||||
:name="item.thumbnail.name"
|
||||
:size="thumbnailSize"
|
||||
:src="item.thumbnail.src"
|
||||
/>
|
||||
<Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0" />
|
||||
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
|
||||
<span v-if="item.label" class="min-w-0 text-sm truncate">{{
|
||||
item.label
|
||||
}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup>
|
||||
import { h, isVNode } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
icon: { type: [String, Object, Function], required: true },
|
||||
});
|
||||
|
||||
const renderIcon = () => {
|
||||
if (!props.icon) return null;
|
||||
if (typeof props.icon === 'function' || isVNode(props.icon)) {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<svg
|
||||
v-once
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clip-path="url(#woot-logo-clip-2342424e23u32098)">
|
||||
<path
|
||||
d="M8 16C12.4183 16 16 12.4183 16 8C16 3.58172 12.4183 0 8 0C3.58172 0 0 3.58172 0 8C0 12.4183 3.58172 16 8 16Z"
|
||||
fill="#2781F6"
|
||||
/>
|
||||
<path
|
||||
d="M11.4172 11.4172H7.70831C5.66383 11.4172 4 9.75328 4 7.70828C4 5.66394 5.66383 4 7.70835 4C9.75339 4 11.4172 5.66394 11.4172 7.70828V11.4172Z"
|
||||
fill="white"
|
||||
stroke="white"
|
||||
stroke-width="0.1875"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="woot-logo-clip-2342424e23u32098">
|
||||
<rect width="16" height="16" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -34,12 +34,16 @@ defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(['update:modelValue']);
|
||||
const emit = defineEmits(['update:modelValue', 'enterPress']);
|
||||
|
||||
const onEnterPress = () => {
|
||||
emit('enterPress');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex items-center justify-between w-full gap-2 whitespace-nowrap"
|
||||
class="relative flex items-center justify-between w-full gap-3 whitespace-nowrap"
|
||||
>
|
||||
<label
|
||||
v-if="label"
|
||||
@@ -60,6 +64,7 @@ defineEmits(['update:modelValue']);
|
||||
:class="customInputClass"
|
||||
class="flex w-full reset-base text-sm h-6 !mb-0 border-0 rounded-lg bg-transparent dark:bg-transparent placeholder:text-slate-200 dark:placeholder:text-slate-500 disabled:cursor-not-allowed disabled:opacity-50 text-slate-900 dark:text-white transition-all duration-500 ease-in-out"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
@keydown.enter.prevent="onEnterPress"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -38,34 +38,46 @@ const props = defineProps({
|
||||
default: 'info',
|
||||
validator: value => ['info', 'error', 'success'].includes(value),
|
||||
},
|
||||
min: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
defineEmits(['update:modelValue']);
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'blur', 'input']);
|
||||
|
||||
const messageClass = computed(() => {
|
||||
switch (props.messageType) {
|
||||
case 'error':
|
||||
return 'text-red-500 dark:text-red-400';
|
||||
return 'text-n-ruby-9 dark:text-n-ruby-9';
|
||||
case 'success':
|
||||
return 'text-green-500 dark:text-green-400';
|
||||
default:
|
||||
return 'text-slate-500 dark:text-slate-400';
|
||||
return 'text-n-slate-11 dark:text-n-slate-11';
|
||||
}
|
||||
});
|
||||
|
||||
const inputBorderClass = computed(() => {
|
||||
switch (props.messageType) {
|
||||
case 'error':
|
||||
return 'border-red-500 dark:border-red-400';
|
||||
return 'border-n-ruby-8 dark:border-n-ruby-8 hover:border-n-ruby-9 dark:hover:border-n-ruby-9 disabled:border-n-ruby-8 dark:disabled:border-n-ruby-8';
|
||||
default:
|
||||
return 'border-slate-100 dark:border-slate-700/50';
|
||||
return 'border-n-weak dark:border-n-weak hover:border-n-slate-6 dark:hover:border-n-slate-6 disabled:border-n-weak dark:disabled:border-n-weak';
|
||||
}
|
||||
});
|
||||
|
||||
const handleInput = event => {
|
||||
emit('update:modelValue', event.target.value);
|
||||
emit('input', event);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex flex-col gap-1">
|
||||
<div class="relative flex flex-col min-w-0 gap-1">
|
||||
<label
|
||||
v-if="label"
|
||||
:for="id"
|
||||
class="mb-0.5 text-sm font-medium text-gray-900 dark:text-gray-50"
|
||||
class="mb-0.5 text-sm font-medium text-n-slate-12"
|
||||
>
|
||||
{{ label }}
|
||||
</label>
|
||||
@@ -78,12 +90,14 @@ const inputBorderClass = computed(() => {
|
||||
:type="type"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
class="flex w-full reset-base text-sm h-8 pl-3 pr-2 rtl:pr-3 rtl:pl-2 py-1.5 !mb-0 border rounded-lg focus:border-woot-500 dark:focus:border-woot-600 bg-white dark:bg-slate-900 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-200 dark:placeholder:text-slate-500 disabled:cursor-not-allowed disabled:opacity-50 text-slate-900 dark:text-white transition-all duration-500 ease-in-out"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
:min="['date', 'datetime-local', 'time'].includes(type) ? min : undefined"
|
||||
class="block w-full reset-base text-sm h-10 !px-3 !py-2.5 !mb-0 border rounded-lg focus:border-n-brand dark:focus:border-n-brand bg-n-alpha-black2 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-n-slate-11 dark:placeholder:text-n-slate-11 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 transition-all duration-500 ease-in-out"
|
||||
@input="handleInput"
|
||||
@blur="emit('blur')"
|
||||
/>
|
||||
<p
|
||||
v-if="message"
|
||||
class="mt-1 mb-0 text-xs transition-all duration-500 ease-in-out"
|
||||
class="min-w-0 mt-1 mb-0 text-xs truncate transition-all duration-500 ease-in-out"
|
||||
:class="messageClass"
|
||||
>
|
||||
{{ message }}
|
||||
|
||||
@@ -56,55 +56,49 @@ const pageInfo = computed(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex justify-between h-12 w-full max-w-[957px] mx-auto bg-slate-25 dark:bg-slate-800/50 rounded-xl py-2 px-3 items-center"
|
||||
class="flex justify-between h-12 w-full max-w-[957px] outline outline-n-container outline-1 mx-auto bg-n-solid-2 rounded-xl py-2 ltr:pl-4 rtl:pr-4 ltr:pr-3 rtl:pl-3 items-center"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="min-w-0 text-sm font-normal line-clamp-1 text-slate-600 dark:text-slate-300"
|
||||
>
|
||||
<span class="min-w-0 text-sm font-normal line-clamp-1 text-n-slate-11">
|
||||
{{ currentPageInformation }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
icon="chevrons-lucide-left"
|
||||
icon-lib="lucide"
|
||||
icon="i-lucide-chevrons-left"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="!w-8 !h-6"
|
||||
:disabled="isFirstPage"
|
||||
@click="changePage(1)"
|
||||
/>
|
||||
<Button
|
||||
icon="chevron-lucide-left"
|
||||
icon-lib="lucide"
|
||||
icon="i-lucide-chevron-left"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="!w-8 !h-6"
|
||||
:disabled="isFirstPage"
|
||||
@click="changePage(currentPage - 1)"
|
||||
/>
|
||||
<div
|
||||
class="inline-flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400"
|
||||
>
|
||||
<span
|
||||
class="px-3 tabular-nums py-0.5 bg-white dark:bg-slate-900 rounded-md"
|
||||
>
|
||||
<div class="inline-flex items-center gap-2 text-sm text-n-slate-11">
|
||||
<span class="px-3 tabular-nums py-0.5 bg-n-alpha-black2 rounded-md">
|
||||
{{ currentPage }}
|
||||
</span>
|
||||
<span>{{ pageInfo }}</span>
|
||||
</div>
|
||||
<Button
|
||||
icon="chevron-lucide-right"
|
||||
icon-lib="lucide"
|
||||
icon="i-lucide-chevron-right"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="!w-8 !h-6"
|
||||
:disabled="isLastPage"
|
||||
@click="changePage(currentPage + 1)"
|
||||
/>
|
||||
<Button
|
||||
icon="chevrons-lucide-right"
|
||||
icon-lib="lucide"
|
||||
icon="i-lucide-chevrons-right"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="!w-8 !h-6"
|
||||
:disabled="isLastPage"
|
||||
@click="changePage(totalPages)"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
inbox: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const channelTypeIconMap = {
|
||||
'Channel::Api': 'i-ri-cloudy-fill',
|
||||
'Channel::Email': 'i-ri-mail-fill',
|
||||
'Channel::FacebookPage': 'i-ri-messenger-fill',
|
||||
'Channel::Line': 'i-ri-line-fill',
|
||||
'Channel::Sms': 'i-ri-chat-1-fill',
|
||||
'Channel::Telegram': 'i-ri-telegram-fill',
|
||||
'Channel::TwilioSms': 'i-ri-chat-1-fill',
|
||||
'Channel::TwitterProfile': 'i-ri-twitter-x-fill',
|
||||
'Channel::WebWidget': 'i-ri-global-fill',
|
||||
'Channel::Whatsapp': 'i-ri-whatsapp-fill',
|
||||
};
|
||||
|
||||
const providerIconMap = {
|
||||
microsoft: 'i-ri-microsoft-fill',
|
||||
google: 'i-ri-google-fill',
|
||||
};
|
||||
|
||||
const channelIcon = computed(() => {
|
||||
const type = props.inbox.channel_type;
|
||||
let icon = channelTypeIconMap[type];
|
||||
|
||||
if (type === 'Channel::Email' && props.inbox.provider) {
|
||||
if (Object.keys(providerIconMap).includes(props.inbox.provider)) {
|
||||
icon = providerIconMap[props.inbox.provider];
|
||||
}
|
||||
}
|
||||
|
||||
return icon ?? 'i-ri-global-fill';
|
||||
});
|
||||
|
||||
const reauthorizationRequired = computed(() => {
|
||||
return props.inbox.reauthorization_required;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="size-4 grid place-content-center rounded-full bg-n-alpha-2"
|
||||
:class="{ 'bg-n-solid-blue': active }"
|
||||
>
|
||||
<Icon :icon="channelIcon" class="size-3" />
|
||||
</span>
|
||||
<div class="flex-1 truncate min-w-0">{{ label }}</div>
|
||||
<div
|
||||
v-if="reauthorizationRequired"
|
||||
v-tooltip.top-end="$t('SIDEBAR.REAUTHORIZE')"
|
||||
class="grid place-content-center size-5 bg-n-ruby-5/60 rounded-full"
|
||||
>
|
||||
<Icon icon="i-woot-alert" class="size-3 text-n-ruby-9" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,519 @@
|
||||
<script setup>
|
||||
import { h, computed, onMounted } from 'vue';
|
||||
import { provideSidebarContext } from './provider';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useKbd } from 'dashboard/composables/utils/useKbd';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
|
||||
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
||||
import SidebarGroup from './SidebarGroup.vue';
|
||||
import SidebarProfileMenu from './SidebarProfileMenu.vue';
|
||||
import ChannelLeaf from './ChannelLeaf.vue';
|
||||
import SidebarNotificationBell from './SidebarNotificationBell.vue';
|
||||
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
|
||||
import Logo from 'next/icon/Logo.vue';
|
||||
|
||||
const emit = defineEmits([
|
||||
'openNotificationPanel',
|
||||
'closeKeyShortcutModal',
|
||||
'openKeyShortcutModal',
|
||||
'showCreateAccountModal',
|
||||
]);
|
||||
|
||||
const { accountScopedRoute } = useAccount();
|
||||
const store = useStore();
|
||||
const searchShortcut = useKbd([`$mod`, 'k']);
|
||||
const { t } = useI18n();
|
||||
const enableNewConversation = false;
|
||||
|
||||
const toggleShortcutModalFn = show => {
|
||||
if (show) {
|
||||
emit('openKeyShortcutModal');
|
||||
} else {
|
||||
emit('closeKeyShortcutModal');
|
||||
}
|
||||
};
|
||||
|
||||
useSidebarKeyboardShortcuts(toggleShortcutModalFn);
|
||||
|
||||
// We're using localStorage to store the expanded item in the sidebar
|
||||
// This helps preserve context when navigating between portal and dashboard layouts
|
||||
// and also when the user refreshes the page
|
||||
const expandedItem = useStorage(
|
||||
'next-sidebar-expanded-item',
|
||||
null,
|
||||
localStorage
|
||||
);
|
||||
|
||||
const setExpandedItem = name => {
|
||||
expandedItem.value = expandedItem.value === name ? null : name;
|
||||
};
|
||||
provideSidebarContext({
|
||||
expandedItem,
|
||||
setExpandedItem,
|
||||
});
|
||||
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
const labels = useMapGetter('labels/getLabelsOnSidebar');
|
||||
const teams = useMapGetter('teams/getMyTeams');
|
||||
const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
|
||||
const conversationCustomViews = useMapGetter(
|
||||
'customViews/getConversationCustomViews'
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('labels/get');
|
||||
store.dispatch('inboxes/get');
|
||||
store.dispatch('notifications/unReadCount');
|
||||
store.dispatch('teams/get');
|
||||
store.dispatch('attributes/get');
|
||||
store.dispatch('customViews/get', 'conversation');
|
||||
store.dispatch('customViews/get', 'contact');
|
||||
});
|
||||
|
||||
const sortedInboxes = computed(() =>
|
||||
inboxes.value.slice().sort((a, b) => a.name.localeCompare(b.name))
|
||||
);
|
||||
|
||||
const menuItems = computed(() => {
|
||||
return [
|
||||
{
|
||||
name: 'Inbox',
|
||||
label: t('SIDEBAR.INBOX'),
|
||||
icon: 'i-lucide-inbox',
|
||||
to: accountScopedRoute('inbox_view'),
|
||||
},
|
||||
{
|
||||
name: 'Conversation',
|
||||
label: t('SIDEBAR.CONVERSATIONS'),
|
||||
icon: 'i-lucide-message-circle',
|
||||
children: [
|
||||
{
|
||||
name: 'All',
|
||||
label: t('SIDEBAR.ALL_CONVERSATIONS'),
|
||||
activeOn: ['inbox_conversation'],
|
||||
to: accountScopedRoute('home'),
|
||||
},
|
||||
{
|
||||
name: 'Mentions',
|
||||
label: t('SIDEBAR.MENTIONED_CONVERSATIONS'),
|
||||
activeOn: ['conversation_through_mentions'],
|
||||
to: accountScopedRoute('conversation_mentions'),
|
||||
},
|
||||
{
|
||||
name: 'Unattended',
|
||||
activeOn: ['conversation_through_unattended'],
|
||||
label: t('SIDEBAR.UNATTENDED_CONVERSATIONS'),
|
||||
to: accountScopedRoute('conversation_unattended'),
|
||||
},
|
||||
{
|
||||
name: 'Folders',
|
||||
label: t('SIDEBAR.CUSTOM_VIEWS_FOLDER'),
|
||||
icon: 'i-lucide-folder',
|
||||
activeOn: ['conversations_through_folders'],
|
||||
children: conversationCustomViews.value.map(view => ({
|
||||
name: `${view.name}-${view.id}`,
|
||||
label: view.name,
|
||||
to: accountScopedRoute('folder_conversations', { id: view.id }),
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: 'Teams',
|
||||
label: t('SIDEBAR.TEAMS'),
|
||||
icon: 'i-lucide-users',
|
||||
activeOn: ['conversations_through_team'],
|
||||
children: teams.value.map(team => ({
|
||||
name: `${team.name}-${team.id}`,
|
||||
label: team.name,
|
||||
to: accountScopedRoute('team_conversations', { teamId: team.id }),
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: 'Channels',
|
||||
label: t('SIDEBAR.CHANNELS'),
|
||||
icon: 'i-lucide-mailbox',
|
||||
activeOn: ['conversation_through_inbox'],
|
||||
children: sortedInboxes.value.map(inbox => ({
|
||||
name: `${inbox.name}-${inbox.id}`,
|
||||
label: inbox.name,
|
||||
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
|
||||
component: leafProps => h(ChannelLeaf, { ...leafProps, inbox }),
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: 'Labels',
|
||||
label: t('SIDEBAR.LABELS'),
|
||||
icon: 'i-lucide-tag',
|
||||
activeOn: ['conversations_through_label'],
|
||||
children: labels.value.map(label => ({
|
||||
name: `${label.title}-${label.id}`,
|
||||
label: label.title,
|
||||
icon: h('span', {
|
||||
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
|
||||
style: { backgroundColor: label.color },
|
||||
}),
|
||||
to: accountScopedRoute('label_conversations', {
|
||||
label: label.title,
|
||||
}),
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Captain',
|
||||
icon: 'i-woot-captain',
|
||||
label: t('SIDEBAR.CAPTAIN'),
|
||||
children: [
|
||||
{
|
||||
name: 'Documents',
|
||||
label: 'Documents',
|
||||
to: accountScopedRoute('captain', { page: 'documents' }),
|
||||
},
|
||||
{
|
||||
name: 'Responses',
|
||||
label: 'Responses',
|
||||
to: accountScopedRoute('captain', { page: 'responses' }),
|
||||
},
|
||||
{
|
||||
name: 'Playground',
|
||||
label: 'Playground',
|
||||
to: accountScopedRoute('captain', { page: 'playground' }),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Contacts',
|
||||
label: t('SIDEBAR.CONTACTS'),
|
||||
icon: 'i-lucide-contact',
|
||||
children: [
|
||||
{
|
||||
name: 'All Contacts',
|
||||
label: t('SIDEBAR.ALL_CONTACTS'),
|
||||
to: accountScopedRoute('contacts_dashboard'),
|
||||
},
|
||||
{
|
||||
name: 'Segments',
|
||||
icon: 'i-lucide-group',
|
||||
label: t('SIDEBAR.CUSTOM_VIEWS_SEGMENTS'),
|
||||
children: contactCustomViews.value.map(view => ({
|
||||
name: `${view.name}-${view.id}`,
|
||||
label: view.name,
|
||||
to: accountScopedRoute('contacts_segments_dashboard', {
|
||||
id: view.id,
|
||||
}),
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: 'Tagged With',
|
||||
icon: 'i-lucide-tag',
|
||||
label: t('SIDEBAR.TAGGED_WITH'),
|
||||
children: labels.value.map(label => ({
|
||||
name: `${label.title}-${label.id}`,
|
||||
label: label.title,
|
||||
icon: h('span', {
|
||||
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
|
||||
style: { backgroundColor: label.color },
|
||||
}),
|
||||
to: accountScopedRoute('contacts_labels_dashboard', {
|
||||
label: label.title,
|
||||
}),
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Reports',
|
||||
label: t('SIDEBAR.REPORTS'),
|
||||
icon: 'i-lucide-chart-spline',
|
||||
children: [
|
||||
{
|
||||
name: 'Report Overview',
|
||||
label: t('SIDEBAR.REPORTS_OVERVIEW'),
|
||||
to: accountScopedRoute('account_overview_reports'),
|
||||
},
|
||||
{
|
||||
name: 'Report Conversation',
|
||||
label: t('SIDEBAR.REPORTS_CONVERSATION'),
|
||||
to: accountScopedRoute('conversation_reports'),
|
||||
},
|
||||
{
|
||||
name: 'Reports CSAT',
|
||||
label: t('SIDEBAR.CSAT'),
|
||||
to: accountScopedRoute('csat_reports'),
|
||||
},
|
||||
{
|
||||
name: 'Reports Bot',
|
||||
label: t('SIDEBAR.REPORTS_BOT'),
|
||||
to: accountScopedRoute('bot_reports'),
|
||||
},
|
||||
{
|
||||
name: 'Reports Agent',
|
||||
label: t('SIDEBAR.REPORTS_AGENT'),
|
||||
to: accountScopedRoute('agent_reports'),
|
||||
},
|
||||
{
|
||||
name: 'Reports Label',
|
||||
label: t('SIDEBAR.REPORTS_LABEL'),
|
||||
to: accountScopedRoute('label_reports'),
|
||||
},
|
||||
{
|
||||
name: 'Reports Inbox',
|
||||
label: t('SIDEBAR.REPORTS_INBOX'),
|
||||
to: accountScopedRoute('inbox_reports'),
|
||||
},
|
||||
{
|
||||
name: 'Reports Team',
|
||||
label: t('SIDEBAR.REPORTS_TEAM'),
|
||||
to: accountScopedRoute('team_reports'),
|
||||
},
|
||||
{
|
||||
name: 'Reports SLA',
|
||||
label: t('SIDEBAR.REPORTS_SLA'),
|
||||
to: accountScopedRoute('sla_reports'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Campaigns',
|
||||
label: t('SIDEBAR.CAMPAIGNS'),
|
||||
icon: 'i-lucide-megaphone',
|
||||
children: [
|
||||
{
|
||||
name: 'Live chat',
|
||||
label: t('SIDEBAR.LIVE_CHAT'),
|
||||
to: accountScopedRoute('campaigns_livechat_index'),
|
||||
},
|
||||
{
|
||||
name: 'SMS',
|
||||
label: t('SIDEBAR.SMS'),
|
||||
to: accountScopedRoute('campaigns_sms_index'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Portals',
|
||||
label: t('SIDEBAR.HELP_CENTER.TITLE'),
|
||||
icon: 'i-lucide-library-big',
|
||||
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: [
|
||||
'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',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
label: t('SIDEBAR.SETTINGS'),
|
||||
icon: 'i-lucide-bolt',
|
||||
children: [
|
||||
{
|
||||
name: 'Settings Account Settings',
|
||||
label: t('SIDEBAR.ACCOUNT_SETTINGS'),
|
||||
icon: 'i-lucide-briefcase',
|
||||
to: accountScopedRoute('general_settings_index'),
|
||||
},
|
||||
{
|
||||
name: 'Settings Agents',
|
||||
label: t('SIDEBAR.AGENTS'),
|
||||
icon: 'i-lucide-square-user',
|
||||
to: accountScopedRoute('agent_list'),
|
||||
},
|
||||
{
|
||||
name: 'Settings Teams',
|
||||
label: t('SIDEBAR.TEAMS'),
|
||||
icon: 'i-lucide-users',
|
||||
to: accountScopedRoute('settings_teams_list'),
|
||||
},
|
||||
{
|
||||
name: 'Settings Inboxes',
|
||||
label: t('SIDEBAR.INBOXES'),
|
||||
icon: 'i-lucide-inbox',
|
||||
to: accountScopedRoute('settings_inbox_list'),
|
||||
},
|
||||
{
|
||||
name: 'Settings Labels',
|
||||
label: t('SIDEBAR.LABELS'),
|
||||
icon: 'i-lucide-tags',
|
||||
to: accountScopedRoute('labels_list'),
|
||||
},
|
||||
{
|
||||
name: 'Settings Custom Attributes',
|
||||
label: t('SIDEBAR.CUSTOM_ATTRIBUTES'),
|
||||
icon: 'i-lucide-code',
|
||||
to: accountScopedRoute('attributes_list'),
|
||||
},
|
||||
{
|
||||
name: 'Settings Automation',
|
||||
label: t('SIDEBAR.AUTOMATION'),
|
||||
icon: 'i-lucide-workflow',
|
||||
to: accountScopedRoute('automation_list'),
|
||||
},
|
||||
{
|
||||
name: 'Settings Agent Bots',
|
||||
label: t('SIDEBAR.AGENT_BOTS'),
|
||||
icon: 'i-lucide-bot',
|
||||
to: accountScopedRoute('agent_bots'),
|
||||
},
|
||||
{
|
||||
name: 'Settings Macros',
|
||||
label: t('SIDEBAR.MACROS'),
|
||||
icon: 'i-lucide-toy-brick',
|
||||
to: accountScopedRoute('macros_wrapper'),
|
||||
},
|
||||
{
|
||||
name: 'Settings Canned Responses',
|
||||
label: t('SIDEBAR.CANNED_RESPONSES'),
|
||||
icon: 'i-lucide-message-square-quote',
|
||||
to: accountScopedRoute('canned_list'),
|
||||
},
|
||||
{
|
||||
name: 'Settings Integrations',
|
||||
label: t('SIDEBAR.INTEGRATIONS'),
|
||||
icon: 'i-lucide-blocks',
|
||||
to: accountScopedRoute('settings_applications'),
|
||||
},
|
||||
{
|
||||
name: 'Settings Audit Logs',
|
||||
label: t('SIDEBAR.AUDIT_LOGS'),
|
||||
icon: 'i-lucide-briefcase',
|
||||
to: accountScopedRoute('auditlogs_list'),
|
||||
},
|
||||
{
|
||||
name: 'Settings Custom Roles',
|
||||
label: t('SIDEBAR.CUSTOM_ROLES'),
|
||||
icon: 'i-lucide-shield-plus',
|
||||
to: accountScopedRoute('custom_roles_list'),
|
||||
},
|
||||
{
|
||||
name: 'Settings Sla',
|
||||
label: t('SIDEBAR.SLA'),
|
||||
icon: 'i-lucide-clock-alert',
|
||||
to: accountScopedRoute('sla_list'),
|
||||
},
|
||||
{
|
||||
name: 'Settings Billing',
|
||||
label: t('SIDEBAR.BILLING'),
|
||||
icon: 'i-lucide-credit-card',
|
||||
to: accountScopedRoute('billing_settings_index'),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
class="w-[200px] bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak h-screen flex flex-col text-sm pb-1"
|
||||
>
|
||||
<section class="grid gap-2 mt-2 mb-4">
|
||||
<div class="flex items-center min-w-0 gap-2 px-2">
|
||||
<div class="grid flex-shrink-0 size-6 place-content-center">
|
||||
<Logo />
|
||||
</div>
|
||||
<div class="flex-shrink-0 w-px h-3 bg-n-strong" />
|
||||
<SidebarAccountSwitcher
|
||||
class="flex-grow min-w-0 -mx-1"
|
||||
@show-create-account-modal="emit('showCreateAccountModal')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2 px-2">
|
||||
<RouterLink
|
||||
:to="{ name: 'search' }"
|
||||
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="flex-shrink-0 i-lucide-search size-4 text-n-slate-11" />
|
||||
<span class="flex-grow text-left">
|
||||
{{ t('COMBOBOX.SEARCH_PLACEHOLDER') }}
|
||||
</span>
|
||||
<span
|
||||
class="hidden tracking-wide pointer-events-none select-none text-n-slate-10"
|
||||
>
|
||||
{{ searchShortcut }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
<button
|
||||
v-if="enableNewConversation"
|
||||
class="flex items-center w-full gap-2 px-2 py-1 border rounded-lg border-n-weak bg-n-solid-3"
|
||||
>
|
||||
<span
|
||||
class="flex-shrink-0 i-lucide-square-pen size-4 text-n-slate-11"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<nav class="grid flex-grow gap-2 px-2 pb-5 overflow-y-scroll no-scrollbar">
|
||||
<ul class="flex flex-col gap-2 m-0 list-none">
|
||||
<SidebarGroup
|
||||
v-for="item in menuItems"
|
||||
:key="item.name"
|
||||
v-bind="item"
|
||||
/>
|
||||
</ul>
|
||||
</nav>
|
||||
<section
|
||||
class="p-1 border-t border-n-weak shadow-[0px_-2px_4px_0px_rgba(27,28,29,0.02)] flex-shrink-0 flex justify-between gap-2 items-center"
|
||||
>
|
||||
<SidebarProfileMenu
|
||||
@open-key-shortcut-modal="emit('openKeyShortcutModal')"
|
||||
/>
|
||||
<div v-if="false" class="flex items-center">
|
||||
<div class="flex-shrink-0 w-px h-3 bg-n-strong" />
|
||||
<SidebarNotificationBell
|
||||
@open-notification-panel="emit('openNotificationPanel')"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1,116 @@
|
||||
<script setup>
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import ButtonNext from 'next/button/Button.vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
const emit = defineEmits(['showCreateAccountModal']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const { accountId, currentAccount } = useAccount();
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const globalConfig = useMapGetter('globalConfig/get');
|
||||
const [showDropdown, toggleDropdown] = useToggle(false);
|
||||
|
||||
const close = () => {
|
||||
if (showDropdown.value) {
|
||||
toggleDropdown(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onChangeAccount = newId => {
|
||||
const accountUrl = `/app/accounts/${newId}/dashboard`;
|
||||
window.location.href = accountUrl;
|
||||
};
|
||||
|
||||
const emitNewAccount = () => {
|
||||
close();
|
||||
emit('showCreateAccountModal');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative z-20">
|
||||
<button
|
||||
id="sidebar-account-switcher"
|
||||
:data-account-id="accountId"
|
||||
aria-haspopup="listbox"
|
||||
aria-controls="account-options"
|
||||
class="flex items-center gap-2 justify-between w-full rounded-lg hover:bg-n-alpha-1 px-2"
|
||||
:class="{ 'bg-n-alpha-1': showDropdown }"
|
||||
@click="toggleDropdown()"
|
||||
>
|
||||
<span
|
||||
class="text-sm font-medium leading-5 text-n-slate-12 truncate"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ currentAccount.name }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="i-lucide-chevron-down size-4 text-n-slate-10 flex-shrink-0"
|
||||
/>
|
||||
</button>
|
||||
<div v-if="showDropdown" v-on-clickaway="close" class="absolute top-8 z-50">
|
||||
<div
|
||||
class="min-w-72 max-w-96 text-sm bg-n-solid-1 border border-n-weak rounded-xl shadow-sm py-4 px-2 flex flex-col gap-2"
|
||||
>
|
||||
<div
|
||||
class="px-4 leading-4 font-medium tracking-[0.2px] text-n-slate-10 text-xs"
|
||||
>
|
||||
{{ t('SIDEBAR_ITEMS.CHANGE_ACCOUNTS') }}
|
||||
</div>
|
||||
<div class="px-1 gap-1 grid">
|
||||
<button
|
||||
v-for="account in currentUser.accounts"
|
||||
:id="`account-${account.id}`"
|
||||
:key="account.id"
|
||||
class="flex w-full hover:bg-n-alpha-1 space-x-4"
|
||||
@click="onChangeAccount(account.id)"
|
||||
>
|
||||
<div
|
||||
:for="account.name"
|
||||
class="text-left rtl:text-right flex gap-2 items-center"
|
||||
>
|
||||
<span
|
||||
class="text-n-slate-12 max-w-36 truncate min-w-0"
|
||||
:title="account.name"
|
||||
>
|
||||
{{ account.name }}
|
||||
</span>
|
||||
<div class="flex-shrink-0 w-px h-3 bg-n-strong" />
|
||||
<span
|
||||
class="text-n-slate-11 max-w-24 truncate capitalize"
|
||||
:title="account.name"
|
||||
>
|
||||
{{
|
||||
account.custom_role_id
|
||||
? account.custom_role.name
|
||||
: account.role
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<Icon
|
||||
v-show="account.id === accountId"
|
||||
icon="i-lucide-check"
|
||||
class="text-n-teal-11 size-5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="globalConfig.createNewAccountFromDashboard" class="px-2">
|
||||
<ButtonNext
|
||||
variant="secondary"
|
||||
class="w-full"
|
||||
size="sm"
|
||||
@click="emitNewAccount"
|
||||
>
|
||||
{{ t('CREATE_ACCOUNT.NEW_ACCOUNT') }}
|
||||
</ButtonNext>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,199 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useSidebarContext } from './provider';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import SidebarGroupHeader from './SidebarGroupHeader.vue';
|
||||
import SidebarGroupLeaf from './SidebarGroupLeaf.vue';
|
||||
import SidebarSubGroup from './SidebarSubGroup.vue';
|
||||
import SidebarGroupEmptyLeaf from './SidebarGroupEmptyLeaf.vue';
|
||||
|
||||
const props = defineProps({
|
||||
name: { type: String, required: true },
|
||||
label: { type: String, required: true },
|
||||
icon: { type: [String, Object, Function], default: null },
|
||||
to: { type: Object, default: null },
|
||||
activeOn: { type: Array, default: () => [] },
|
||||
children: { type: Array, default: undefined },
|
||||
});
|
||||
|
||||
const {
|
||||
expandedItem,
|
||||
setExpandedItem,
|
||||
resolvePath,
|
||||
resolvePermissions,
|
||||
resolveFeatureFlag,
|
||||
isAllowed,
|
||||
} = useSidebarContext();
|
||||
|
||||
const navigableChildren = computed(() => {
|
||||
return props.children?.flatMap(child => child.children || child) || [];
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const isExpanded = computed(() => expandedItem.value === props.name);
|
||||
const isExpandable = computed(() => props.children);
|
||||
const hasChildren = computed(
|
||||
() => Array.isArray(props.children) && props.children.length > 0
|
||||
);
|
||||
|
||||
const accessibleItems = computed(() => {
|
||||
if (!hasChildren.value) return [];
|
||||
return props.children.filter(child => isAllowed(child.to));
|
||||
});
|
||||
|
||||
const hasAccessibleChildren = computed(() => {
|
||||
return accessibleItems.value.length > 0;
|
||||
});
|
||||
|
||||
const isActive = computed(() => {
|
||||
if (props.to) {
|
||||
if (route.path === resolvePath(props.to)) return true;
|
||||
|
||||
return props.activeOn.includes(route.name);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
// We could use the RouterLink isActive too, but our routes are not always
|
||||
// nested correctly, so we need to check the active state ourselves
|
||||
// TODO: Audit the routes and fix the nesting and remove this
|
||||
const activeChild = computed(() => {
|
||||
const pathSame = navigableChildren.value.find(
|
||||
child => child.to && route.path === resolvePath(child.to)
|
||||
);
|
||||
if (pathSame) return pathSame;
|
||||
|
||||
const pathSatrtsWith = navigableChildren.value.find(
|
||||
child => child.to && route.path.startsWith(resolvePath(child.to))
|
||||
);
|
||||
if (pathSatrtsWith) return pathSatrtsWith;
|
||||
|
||||
return navigableChildren.value.find(child =>
|
||||
child.activeOn?.includes(route.name)
|
||||
);
|
||||
});
|
||||
|
||||
const hasActiveChild = computed(() => {
|
||||
return activeChild.value !== undefined;
|
||||
});
|
||||
|
||||
const toggleTrigger = () => {
|
||||
if (
|
||||
hasAccessibleChildren.value &&
|
||||
!isExpanded.value &&
|
||||
!hasActiveChild.value
|
||||
) {
|
||||
// if not already expanded, navigate to the first child
|
||||
const firstItem = accessibleItems.value[0];
|
||||
router.push(firstItem.to);
|
||||
}
|
||||
setExpandedItem(props.name);
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<Policy
|
||||
v-if="!hasChildren || hasAccessibleChildren"
|
||||
:permissions="resolvePermissions(to)"
|
||||
:feature-flag="resolveFeatureFlag(to)"
|
||||
as="li"
|
||||
class="text-sm cursor-pointer select-none gap-1 grid"
|
||||
>
|
||||
<SidebarGroupHeader
|
||||
:icon
|
||||
:name
|
||||
:label
|
||||
:to
|
||||
:is-active="isActive"
|
||||
:has-active-child="hasActiveChild"
|
||||
:expandable="hasChildren"
|
||||
:is-expanded="isExpanded"
|
||||
@toggle="toggleTrigger"
|
||||
/>
|
||||
<ul
|
||||
v-if="hasChildren"
|
||||
v-show="isExpanded || hasActiveChild"
|
||||
class="list-none m-0 grid sidebar-group-children"
|
||||
>
|
||||
<template v-for="child in children" :key="child.name">
|
||||
<SidebarSubGroup
|
||||
v-if="child.children"
|
||||
:label="child.label"
|
||||
:icon="child.icon"
|
||||
:children="child.children"
|
||||
:is-expanded="isExpanded"
|
||||
:active-child="activeChild"
|
||||
/>
|
||||
<SidebarGroupLeaf
|
||||
v-else
|
||||
v-show="isExpanded || activeChild?.name === child.name"
|
||||
v-bind="child"
|
||||
:active="activeChild?.name === child.name"
|
||||
/>
|
||||
</template>
|
||||
</ul>
|
||||
<ul v-else-if="isExpandable && isExpanded">
|
||||
<SidebarGroupEmptyLeaf />
|
||||
</ul>
|
||||
</Policy>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.sidebar-group-children .child-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 0.125rem;
|
||||
/* 0.5px */
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sidebar-group-children .child-item:first-child::before {
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
|
||||
/* This selects the last child in a group */
|
||||
/* https://codepen.io/scmmishra/pen/yLmKNLW */
|
||||
.sidebar-group-children > .child-item:last-child::before,
|
||||
.sidebar-group-children
|
||||
> *:last-child
|
||||
> *:last-child
|
||||
> .child-item:last-child::before {
|
||||
height: 20%;
|
||||
}
|
||||
|
||||
.sidebar-group-children > .child-item:last-child::after,
|
||||
.sidebar-group-children
|
||||
> *:last-child
|
||||
> *:last-child
|
||||
> .child-item:last-child::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 12px;
|
||||
bottom: calc(50% - 2px);
|
||||
border-bottom-width: 0.125rem;
|
||||
border-left-width: 0.125rem;
|
||||
border-right-width: 0px;
|
||||
border-top-width: 0px;
|
||||
border-radius: 0 0 0 4px;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.app-rtl--wrapper .sidebar-group-children > .child-item:last-child::after,
|
||||
.app-rtl--wrapper
|
||||
.sidebar-group-children
|
||||
> *:last-child
|
||||
> *:last-child
|
||||
> .child-item:last-child::after {
|
||||
right: 0;
|
||||
border-bottom-width: 0.125rem;
|
||||
border-right-width: 0.125rem;
|
||||
border-left-width: 0px;
|
||||
border-top-width: 0px;
|
||||
border-radius: 0 0 4px 0px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
class="py-1 pl-3 text-n-slate-10 border rounded-lg border-dashed text-center border-n-alpha-2 text-xs h-8 grid place-content-center select-none pointer-events-none"
|
||||
>
|
||||
<slot>{{ t('SIDEBAR.NO_ITEMS') }}</slot>
|
||||
</li>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup>
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
defineProps({
|
||||
to: { type: [Object, String], default: '' },
|
||||
label: { type: String, default: '' },
|
||||
icon: { type: [String, Object], default: '' },
|
||||
expandable: { type: Boolean, default: false },
|
||||
isExpanded: { type: Boolean, default: false },
|
||||
isActive: { type: Boolean, default: false },
|
||||
hasActiveChild: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['toggle']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="to ? 'router-link' : 'div'"
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded-lg h-8"
|
||||
role="button"
|
||||
:to="to"
|
||||
:title="label"
|
||||
:class="{
|
||||
'n-blue-text bg-n-alpha-2 font-medium': isActive && !hasActiveChild,
|
||||
'text-n-slate-12 font-medium': hasActiveChild,
|
||||
'text-n-slate-11 hover:bg-n-alpha-2': !isActive && !hasActiveChild,
|
||||
}"
|
||||
@click.stop="emit('toggle')"
|
||||
>
|
||||
<Icon v-if="icon" :icon="icon" class="size-4" />
|
||||
<span class="text-sm font-medium leading-5 flex-grow">
|
||||
{{ label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="expandable"
|
||||
v-show="isExpanded"
|
||||
class="i-lucide-chevron-up size-3"
|
||||
@click.stop="emit('toggle')"
|
||||
/>
|
||||
</component>
|
||||
</template>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup>
|
||||
import { isVNode, computed } from 'vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import { useSidebarContext } from './provider';
|
||||
|
||||
const props = defineProps({
|
||||
label: { type: String, required: true },
|
||||
to: { type: [String, Object], required: true },
|
||||
icon: { type: [String, Object], default: null },
|
||||
active: { type: Boolean, default: false },
|
||||
component: { type: Function, default: null },
|
||||
});
|
||||
|
||||
const { resolvePermissions, resolveFeatureFlag } = useSidebarContext();
|
||||
|
||||
const shouldRenderComponent = computed(() => {
|
||||
return typeof props.component === 'function' || isVNode(props.component);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Policy
|
||||
:permissions="resolvePermissions(to)"
|
||||
:feature-flag="resolveFeatureFlag(to)"
|
||||
as="li"
|
||||
class="py-0.5 ltr:pl-3 rtl:pr-3 rtl:mr-3 ltr:ml-3 relative text-n-slate-11 child-item before:bg-n-slate-4 after:bg-transparent after:border-n-slate-4 before:left-0 rtl:before:right-0"
|
||||
>
|
||||
<component
|
||||
:is="to ? 'router-link' : 'div'"
|
||||
:to="to"
|
||||
:title="label"
|
||||
class="flex h-8 items-center gap-2 px-2 py-1 rounded-lg max-w-[151px] hover:bg-gradient-to-r from-transparent via-n-slate-3/70 to-n-slate-3/70 group"
|
||||
:class="{
|
||||
'n-blue-text bg-n-alpha-2 active': active,
|
||||
}"
|
||||
>
|
||||
<component
|
||||
:is="component"
|
||||
v-if="shouldRenderComponent"
|
||||
:label
|
||||
:icon
|
||||
:active
|
||||
/>
|
||||
<template v-else>
|
||||
<Icon v-if="icon" :icon="icon" class="size-4 inline-block" />
|
||||
<div class="flex-1 truncate min-w-0">{{ label }}</div>
|
||||
</template>
|
||||
</component>
|
||||
</Policy>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded-lg h-8 text-n-slate-10 select-none pointer-events-none"
|
||||
>
|
||||
<Icon v-if="icon" :icon="icon" class="size-4" />
|
||||
<span class="text-sm font-medium leading-5 flex-grow">
|
||||
{{ label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
const emit = defineEmits(['openNotificationPanel']);
|
||||
|
||||
const notificationMetadata = useMapGetter('notifications/getMeta');
|
||||
const route = useRoute();
|
||||
const unreadCount = computed(() => {
|
||||
if (!notificationMetadata.value.unreadCount) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return notificationMetadata.value.unreadCount < 100
|
||||
? `${notificationMetadata.value.unreadCount}`
|
||||
: '99+';
|
||||
});
|
||||
|
||||
function openNotificationPanel() {
|
||||
if (route.name !== 'notifications_index') {
|
||||
emit('openNotificationPanel');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="size-8 rounded-lg hover:bg-n-alpha-1 flex-shrink-0 grid place-content-center relative"
|
||||
@click="openNotificationPanel"
|
||||
>
|
||||
<span class="i-lucide-bell size-4" />
|
||||
<span
|
||||
v-if="unreadCount"
|
||||
class="min-h-2 min-w-2 p-0.5 px-1 bg-n-ruby-9 rounded-lg absolute -top-1 -right-1.5 grid place-items-center text-[9px] leading-none text-n-ruby-3"
|
||||
>
|
||||
{{ unreadCount }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,146 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import Auth from 'dashboard/api/auth';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import SidebarProfileMenuStatus from './SidebarProfileMenuStatus.vue';
|
||||
|
||||
const emit = defineEmits(['close', 'openKeyShortcutModal']);
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
const globalConfig = useMapGetter('globalConfig/get');
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
|
||||
const [showProfileMenu, toggleProfileMenu] = useToggle(false);
|
||||
|
||||
const closeMenu = () => {
|
||||
if (showProfileMenu.value) {
|
||||
emit('close');
|
||||
toggleProfileMenu(false);
|
||||
}
|
||||
};
|
||||
|
||||
const menuItems = computed(() => {
|
||||
return [
|
||||
{
|
||||
show: !!globalConfig.value.chatwootInboxToken,
|
||||
label: t('SIDEBAR_ITEMS.CONTACT_SUPPORT'),
|
||||
icon: 'i-lucide-life-buoy',
|
||||
click: () => {
|
||||
closeMenu();
|
||||
window.$chatwoot.toggle();
|
||||
},
|
||||
},
|
||||
{
|
||||
show: true,
|
||||
label: t('SIDEBAR_ITEMS.KEYBOARD_SHORTCUTS'),
|
||||
icon: 'i-lucide-keyboard',
|
||||
click: () => {
|
||||
closeMenu();
|
||||
emit('openKeyShortcutModal');
|
||||
},
|
||||
},
|
||||
{
|
||||
show: true,
|
||||
label: t('SIDEBAR_ITEMS.PROFILE_SETTINGS'),
|
||||
icon: 'i-lucide-user-pen',
|
||||
click: () => {
|
||||
closeMenu();
|
||||
router.push({ name: 'profile_settings_index' });
|
||||
},
|
||||
},
|
||||
{
|
||||
show: true,
|
||||
label: t('SIDEBAR_ITEMS.APPEARANCE'),
|
||||
icon: 'i-lucide-swatch-book',
|
||||
click: () => {
|
||||
closeMenu();
|
||||
const ninja = document.querySelector('ninja-keys');
|
||||
ninja.open({ parent: 'appearance_settings' });
|
||||
},
|
||||
},
|
||||
{
|
||||
show: currentUser.value.type === 'SuperAdmin',
|
||||
label: t('SIDEBAR_ITEMS.SUPER_ADMIN_CONSOLE'),
|
||||
icon: 'i-lucide-castle',
|
||||
link: '/super_admin',
|
||||
target: '_blank',
|
||||
},
|
||||
{
|
||||
show: true,
|
||||
label: t('SIDEBAR_ITEMS.LOGOUT'),
|
||||
icon: 'i-lucide-log-out',
|
||||
click: Auth.logout,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const allowedMenuItems = computed(() => {
|
||||
return menuItems.value.filter(item => item.show);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative z-20 w-full min-w-0">
|
||||
<button
|
||||
class="flex gap-2 items-center rounded-lg cursor-pointer text-left w-full hover:bg-n-alpha-1 p-1"
|
||||
v-bind="$attrs"
|
||||
:class="{
|
||||
'bg-n-alpha-1': showProfileMenu,
|
||||
}"
|
||||
@click="toggleProfileMenu"
|
||||
>
|
||||
<Avatar
|
||||
:size="32"
|
||||
:name="currentUser.available_name"
|
||||
:src="currentUser.avatar_url"
|
||||
:status="currentUserAvailability"
|
||||
class="flex-shrink-0"
|
||||
rounded-full
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="text-n-slate-12 text-sm leading-4 font-medium truncate">
|
||||
{{ currentUser.available_name }}
|
||||
</div>
|
||||
<div class="text-n-slate-11 text-xs truncate">
|
||||
{{ currentUser.email }}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
v-if="showProfileMenu"
|
||||
v-on-clickaway="closeMenu"
|
||||
class="absolute left-0 bottom-12 z-50"
|
||||
>
|
||||
<div
|
||||
class="w-72 min-h-32 bg-n-solid-1 border border-n-weak rounded-xl shadow-sm"
|
||||
>
|
||||
<SidebarProfileMenuStatus />
|
||||
<div class="border-t border-n-strong mx-2 my-0" />
|
||||
<ul class="list-none m-0 grid gap-1 p-1 text-n-slate-12">
|
||||
<li v-for="item in allowedMenuItems" :key="item.label" class="m-0">
|
||||
<component
|
||||
:is="item.link ? 'a' : 'button'"
|
||||
v-bind="item.link ? { target: item.target, href: item.link } : {}"
|
||||
class="text-left hover:bg-n-alpha-1 px-2 py-1.5 w-full flex items-center gap-2"
|
||||
@click="item.click"
|
||||
>
|
||||
<Icon :icon="item.icon" class="size-4" />
|
||||
{{ item.label }}
|
||||
</component>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,119 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const currentUserAutoOffline = useMapGetter('getCurrentUserAutoOffline');
|
||||
|
||||
const { AVAILABILITY_STATUS_KEYS } = wootConstants;
|
||||
const statusList = computed(() => {
|
||||
return [
|
||||
t('PROFILE_SETTINGS.FORM.AVAILABILITY.STATUS.ONLINE'),
|
||||
t('PROFILE_SETTINGS.FORM.AVAILABILITY.STATUS.BUSY'),
|
||||
t('PROFILE_SETTINGS.FORM.AVAILABILITY.STATUS.OFFLINE'),
|
||||
];
|
||||
});
|
||||
|
||||
const availabilityStatuses = computed(() => {
|
||||
return statusList.value.map((statusLabel, index) => ({
|
||||
label: statusLabel,
|
||||
value: AVAILABILITY_STATUS_KEYS[index],
|
||||
active: currentUserAvailability.value === AVAILABILITY_STATUS_KEYS[index],
|
||||
}));
|
||||
});
|
||||
|
||||
function changeAvailabilityStatus(availability) {
|
||||
try {
|
||||
store.dispatch('updateAvailability', {
|
||||
availability,
|
||||
account_id: currentAccountId.value,
|
||||
});
|
||||
} catch (error) {
|
||||
useAlert(t('PROFILE_SETTINGS.FORM.AVAILABILITY.SET_AVAILABILITY_ERROR'));
|
||||
}
|
||||
}
|
||||
|
||||
function updateAutoOffline(autoOffline) {
|
||||
store.dispatch('updateAutoOffline', {
|
||||
accountId: currentAccountId.value,
|
||||
autoOffline,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pt-2 text-n-slate-12">
|
||||
<span
|
||||
class="px-3 leading-4 font-medium tracking-[0.2px] text-n-slate-10 text-xs"
|
||||
>
|
||||
{{ t('SIDEBAR.SET_AVAILABILITY_TITLE') }}
|
||||
</span>
|
||||
<ul class="list-none m-0 grid gap-1 p-1">
|
||||
<li
|
||||
v-for="status in availabilityStatuses"
|
||||
:key="status.value"
|
||||
class="flex items-baseline"
|
||||
>
|
||||
<button
|
||||
class="text-left rtl:text-right hover:bg-n-alpha-1 px-2 py-1.5 w-full flex items-center gap-2"
|
||||
:class="{
|
||||
'pointer-events-none bg-n-amber-10/10': status.active,
|
||||
'bg-n-teal-3': status.active && status.value === 'online',
|
||||
'bg-n-amber-3': status.active && status.value === 'busy',
|
||||
'bg-n-slate-3': status.active && status.value === 'offline',
|
||||
}"
|
||||
@click="changeAvailabilityStatus(status.value)"
|
||||
>
|
||||
<div
|
||||
class="rounded-full w-2.5 h-2.5"
|
||||
:class="{
|
||||
'bg-n-teal-10': status.value === 'online',
|
||||
'bg-n-amber-10': status.value === 'busy',
|
||||
'bg-n-slate-10': status.value === 'offline',
|
||||
}"
|
||||
/>
|
||||
<span class="flex-grow">{{ status.label }}</span>
|
||||
<Icon
|
||||
v-if="status.active"
|
||||
icon="i-lucide-check"
|
||||
class="size-4 flex-shrink-0"
|
||||
:class="{
|
||||
'text-n-teal-11': status.value === 'online',
|
||||
'text-n-amber-11': status.value === 'busy',
|
||||
'text-n-slate-11': status.value === 'offline',
|
||||
}"
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="border-t border-n-strong mx-2 my-0" />
|
||||
<ul class="list-none m-0 grid gap-1 p-1">
|
||||
<li class="px-2 py-1.5 flex items-start w-full gap-2">
|
||||
<div class="h-5 flex items-center flex-shrink-0">
|
||||
<Icon icon="i-lucide-info" class="size-4" />
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<div class="h-5 leading-none flex place-items-center text-n-slate-12">
|
||||
{{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }}
|
||||
</div>
|
||||
<div class="text-xs leading-tight text-n-slate-10 mt-1">
|
||||
{{ t('SIDEBAR.SET_AUTO_OFFLINE.INFO_SHORT') }}
|
||||
</div>
|
||||
</div>
|
||||
<woot-switch
|
||||
class="flex-shrink-0"
|
||||
:model-value="currentUserAutoOffline"
|
||||
@input="updateAutoOffline"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
@@ -0,0 +1,114 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import SidebarGroupLeaf from './SidebarGroupLeaf.vue';
|
||||
import SidebarGroupSeparator from './SidebarGroupSeparator.vue';
|
||||
import SidebarGroupEmptyLeaf from './SidebarGroupEmptyLeaf.vue';
|
||||
|
||||
import { useSidebarContext } from './provider';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
|
||||
const props = defineProps({
|
||||
isExpanded: { type: Boolean, default: false },
|
||||
label: { type: String, required: true },
|
||||
icon: { type: [Object, String], required: true },
|
||||
children: { type: Array, default: undefined },
|
||||
activeChild: { type: Object, default: undefined },
|
||||
});
|
||||
|
||||
const { isAllowed } = useSidebarContext();
|
||||
const scrollableContainer = ref(null);
|
||||
|
||||
const accessibleItems = computed(() =>
|
||||
props.children.filter(child => isAllowed(child.to))
|
||||
);
|
||||
|
||||
const hasAccessibleItems = computed(() => {
|
||||
if (props.children.length === 0) {
|
||||
// cases like segment, folder and labels where users can create new items
|
||||
return true;
|
||||
}
|
||||
|
||||
return accessibleItems.value.length > 0;
|
||||
});
|
||||
|
||||
const isScrollable = computed(() => {
|
||||
return accessibleItems.value.length > 7;
|
||||
});
|
||||
|
||||
const scrollEnd = ref(false);
|
||||
|
||||
// set scrollEnd to true when the scroll reaches the end
|
||||
useEventListener(scrollableContainer, 'scroll', () => {
|
||||
const { scrollHeight, scrollTop, clientHeight } = scrollableContainer.value;
|
||||
scrollEnd.value = scrollHeight - scrollTop === clientHeight;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarGroupSeparator
|
||||
v-if="hasAccessibleItems"
|
||||
v-show="isExpanded"
|
||||
:label
|
||||
:icon
|
||||
class="my-1"
|
||||
/>
|
||||
<ul class="m-0 list-none relative group">
|
||||
<!-- Each element has h-8, which is 32px, we will show 7 items with one hidden at the end,
|
||||
which is 14rem. Then we add 16px so that we have some text visible from the next item -->
|
||||
<div
|
||||
ref="scrollableContainer"
|
||||
:class="{
|
||||
'max-h-[calc(14rem+16px)] overflow-y-scroll no-scrollbar': isScrollable,
|
||||
}"
|
||||
>
|
||||
<template v-if="children.length">
|
||||
<SidebarGroupLeaf
|
||||
v-for="child in children"
|
||||
v-show="isExpanded || activeChild?.name === child.name"
|
||||
v-bind="child"
|
||||
:key="child.name"
|
||||
:active="activeChild?.name === child.name"
|
||||
/>
|
||||
</template>
|
||||
<SidebarGroupEmptyLeaf v-else v-show="isExpanded" class="ml-3 rtl:mr-3" />
|
||||
</div>
|
||||
<div
|
||||
v-if="isScrollable && isExpanded"
|
||||
v-show="!scrollEnd"
|
||||
class="absolute bg-gradient-to-t from-n-solid-2 w-full h-12 to-transparent -bottom-1 pointer-events-none flex items-end justify-end px-2 animate-fade-in-up"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="24"
|
||||
viewBox="0 0 16 24"
|
||||
fill="none"
|
||||
class="text-n-slate-9 opacity-50 group-hover:opacity-100"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4 4L8 8L12 4"
|
||||
stroke="currentColor"
|
||||
opacity="0.5"
|
||||
stroke-width="1.33333"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4 10L8 14L12 10"
|
||||
stroke="currentColor"
|
||||
opacity="0.75"
|
||||
stroke-width="1.33333"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4 16L8 20L12 16"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.33333"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</ul>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user