Compare commits
84
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcb5369c48 | ||
|
|
d1596653fd | ||
|
|
4f4dcef4f1 | ||
|
|
e5ecb84277 | ||
|
|
11cf49db43 | ||
|
|
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 | ||
|
|
4e64097421 | ||
|
|
dff57013c3 | ||
|
|
a37d44758b | ||
|
|
c796832a58 | ||
|
|
604c592e89 | ||
|
|
fe2d91e0d3 | ||
|
|
6d40986a64 | ||
|
|
35b21c1cea | ||
|
|
306a6c6ce0 | ||
|
|
902a9aa7d7 | ||
|
|
6a383dc3ca | ||
|
|
e2db5b8cff | ||
|
|
544fe2b913 | ||
|
|
a04f8182a2 | ||
|
|
6d6dc0c153 | ||
|
|
32a9d5b0ce | ||
|
|
0082c6adb9 | ||
|
|
431d533635 | ||
|
|
5fd389e15d | ||
|
|
44be3c9eec | ||
|
|
7be1ecaf96 | ||
|
|
f13644245e | ||
|
|
62f4f127aa | ||
|
|
392e58b0be | ||
|
|
3a0fd9b777 | ||
|
|
dec637ab8a | ||
|
|
e0ef007047 | ||
|
|
593270d10b | ||
|
|
6986d34bd9 | ||
|
|
694302b930 | ||
|
|
1e9959bb65 | ||
|
|
ed9dc6d419 | ||
|
|
2d5afef7c2 | ||
|
|
1fc06f8959 | ||
|
|
16c6ef0e11 | ||
|
|
01cc46b318 | ||
|
|
4c7a539f9d | ||
|
|
a2f32f7232 | ||
|
|
220a947380 | ||
|
|
8505aa48c3 | ||
|
|
b49eaa5c45 | ||
|
|
f18ed01eb7 | ||
|
|
aa5fa0c758 | ||
|
|
42eca69763 | ||
|
|
3a78192e74 | ||
|
|
fd01a5056a | ||
|
|
d70ba8ff40 |
+14
-6
@@ -12,6 +12,20 @@ module.exports = {
|
||||
'vitest-globals/env': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.story.vue'],
|
||||
rules: {
|
||||
'vue/no-undef-components': [
|
||||
'error',
|
||||
{
|
||||
ignorePatterns: ['Variant', 'Story'],
|
||||
},
|
||||
],
|
||||
// Story files can have static strings, it doesn't need to handle i18n always.
|
||||
'vue/no-bare-strings-in-template': 'off',
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
plugins: ['html', 'prettier'],
|
||||
parserOptions: {
|
||||
@@ -223,11 +237,5 @@ module.exports = {
|
||||
globals: {
|
||||
bus: true,
|
||||
vi: true,
|
||||
// beforeEach: true,
|
||||
// afterEach: true,
|
||||
// test: true,
|
||||
// describe: true,
|
||||
// it: true,
|
||||
// expect: true,
|
||||
},
|
||||
};
|
||||
|
||||
+57
-59
@@ -33,70 +33,70 @@ GIT
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (7.0.8.4)
|
||||
actionpack (= 7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
actioncable (7.0.8.5)
|
||||
actionpack (= 7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
actionmailbox (7.0.8.4)
|
||||
actionpack (= 7.0.8.4)
|
||||
activejob (= 7.0.8.4)
|
||||
activerecord (= 7.0.8.4)
|
||||
activestorage (= 7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
actionmailbox (7.0.8.5)
|
||||
actionpack (= 7.0.8.5)
|
||||
activejob (= 7.0.8.5)
|
||||
activerecord (= 7.0.8.5)
|
||||
activestorage (= 7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
mail (>= 2.7.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
actionmailer (7.0.8.4)
|
||||
actionpack (= 7.0.8.4)
|
||||
actionview (= 7.0.8.4)
|
||||
activejob (= 7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
actionmailer (7.0.8.5)
|
||||
actionpack (= 7.0.8.5)
|
||||
actionview (= 7.0.8.5)
|
||||
activejob (= 7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
mail (~> 2.5, >= 2.5.4)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
rails-dom-testing (~> 2.0)
|
||||
actionpack (7.0.8.4)
|
||||
actionview (= 7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
actionpack (7.0.8.5)
|
||||
actionview (= 7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
rack (~> 2.0, >= 2.2.4)
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
||||
actiontext (7.0.8.4)
|
||||
actionpack (= 7.0.8.4)
|
||||
activerecord (= 7.0.8.4)
|
||||
activestorage (= 7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
actiontext (7.0.8.5)
|
||||
actionpack (= 7.0.8.5)
|
||||
activerecord (= 7.0.8.5)
|
||||
activestorage (= 7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
actionview (7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.4)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.1, >= 1.2.0)
|
||||
active_record_query_trace (1.8)
|
||||
activejob (7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
activejob (7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
activerecord (7.0.8.4)
|
||||
activemodel (= 7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
activemodel (7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
activerecord (7.0.8.5)
|
||||
activemodel (= 7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
activerecord-import (1.4.1)
|
||||
activerecord (>= 4.2)
|
||||
activestorage (7.0.8.4)
|
||||
actionpack (= 7.0.8.4)
|
||||
activejob (= 7.0.8.4)
|
||||
activerecord (= 7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
activestorage (7.0.8.5)
|
||||
actionpack (= 7.0.8.5)
|
||||
activejob (= 7.0.8.5)
|
||||
activerecord (= 7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
marcel (~> 1.0)
|
||||
mini_mime (>= 1.1.0)
|
||||
activesupport (7.0.8.4)
|
||||
activesupport (7.0.8.5)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
i18n (>= 1.6, < 2)
|
||||
minitest (>= 5.1)
|
||||
@@ -373,7 +373,7 @@ GEM
|
||||
mini_mime (>= 1.0.0)
|
||||
multi_xml (>= 0.5.2)
|
||||
httpclient (2.8.3)
|
||||
i18n (1.14.5)
|
||||
i18n (1.14.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
image_processing (1.12.2)
|
||||
mini_magick (>= 4.9.5, < 5)
|
||||
@@ -479,7 +479,7 @@ GEM
|
||||
uri
|
||||
net-http-persistent (4.0.2)
|
||||
connection_pool (~> 2.2)
|
||||
net-imap (0.4.14)
|
||||
net-imap (0.4.17)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -557,7 +557,7 @@ GEM
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (2.2.9)
|
||||
rack (2.2.10)
|
||||
rack-attack (6.7.0)
|
||||
rack (>= 1.0, < 4)
|
||||
rack-contrib (2.5.0)
|
||||
@@ -574,20 +574,20 @@ GEM
|
||||
rack-test (2.1.0)
|
||||
rack (>= 1.3)
|
||||
rack-timeout (0.6.3)
|
||||
rails (7.0.8.4)
|
||||
actioncable (= 7.0.8.4)
|
||||
actionmailbox (= 7.0.8.4)
|
||||
actionmailer (= 7.0.8.4)
|
||||
actionpack (= 7.0.8.4)
|
||||
actiontext (= 7.0.8.4)
|
||||
actionview (= 7.0.8.4)
|
||||
activejob (= 7.0.8.4)
|
||||
activemodel (= 7.0.8.4)
|
||||
activerecord (= 7.0.8.4)
|
||||
activestorage (= 7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
rails (7.0.8.5)
|
||||
actioncable (= 7.0.8.5)
|
||||
actionmailbox (= 7.0.8.5)
|
||||
actionmailer (= 7.0.8.5)
|
||||
actionpack (= 7.0.8.5)
|
||||
actiontext (= 7.0.8.5)
|
||||
actionview (= 7.0.8.5)
|
||||
activejob (= 7.0.8.5)
|
||||
activemodel (= 7.0.8.5)
|
||||
activerecord (= 7.0.8.5)
|
||||
activestorage (= 7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 7.0.8.4)
|
||||
railties (= 7.0.8.5)
|
||||
rails-dom-testing (2.2.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
@@ -595,9 +595,9 @@ GEM
|
||||
rails-html-sanitizer (1.6.0)
|
||||
loofah (~> 2.21)
|
||||
nokogiri (~> 1.14)
|
||||
railties (7.0.8.4)
|
||||
actionpack (= 7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
railties (7.0.8.5)
|
||||
actionpack (= 7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
method_source
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0)
|
||||
@@ -633,8 +633,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 +763,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)
|
||||
|
||||
@@ -6,7 +6,7 @@ RAILS_ENV ?= development
|
||||
setup:
|
||||
gem install bundler
|
||||
bundle install
|
||||
yarn install
|
||||
pnpm install
|
||||
|
||||
db_create:
|
||||
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:create
|
||||
@@ -30,7 +30,7 @@ server:
|
||||
RAILS_ENV=$(RAILS_ENV) bundle exec rails server -b 0.0.0.0 -p 3000
|
||||
|
||||
burn:
|
||||
bundle && yarn
|
||||
bundle && pnpm install
|
||||
|
||||
run:
|
||||
@if [ -f ./.overmind.sock ]; then \
|
||||
|
||||
@@ -2,7 +2,7 @@ class RoomChannel < ApplicationCable::Channel
|
||||
def subscribed
|
||||
# TODO: should we only do ensure stream if current account is present?
|
||||
# for now going ahead with guard clauses in update_subscription and broadcast_presence
|
||||
|
||||
log_params
|
||||
ensure_stream
|
||||
current_user
|
||||
current_account
|
||||
@@ -10,7 +10,15 @@ class RoomChannel < ApplicationCable::Channel
|
||||
broadcast_presence
|
||||
end
|
||||
|
||||
def log_params
|
||||
contact_inbox = ContactInbox.find_by(pubsub_token: params[:pubsub_token])
|
||||
inbox_id = contact_inbox&.inbox_id
|
||||
account_id = contact_inbox&.inbox&.account_id
|
||||
Rails.logger.info("update_presence: #{params.inspect}, inbox_id: #{inbox_id}, account_id: #{account_id}")
|
||||
end
|
||||
|
||||
def update_presence
|
||||
log_params
|
||||
update_subscription
|
||||
broadcast_presence
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
class Google::CallbacksController < OauthCallbackController
|
||||
include GoogleConcern
|
||||
|
||||
def find_channel_by_email
|
||||
# find by imap_login first, and then by email
|
||||
# this ensures the legacy users can migrate correctly even if inbox email address doesn't match
|
||||
imap_channel = Channel::Email.find_by(imap_login: users_data['email'], account: account)
|
||||
return imap_channel if imap_channel
|
||||
|
||||
Channel::Email.find_by(email: users_data['email'], account: account)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def provider_name
|
||||
|
||||
@@ -25,7 +25,7 @@ class OauthCallbackController < ApplicationController
|
||||
end
|
||||
|
||||
def find_or_create_inbox
|
||||
channel_email = Channel::Email.find_by(email: users_data['email'], account: account)
|
||||
channel_email = find_channel_by_email
|
||||
# we need this value to know where to redirect on sucessful processing of the callback
|
||||
channel_exists = channel_email.present?
|
||||
|
||||
@@ -39,6 +39,10 @@ class OauthCallbackController < ApplicationController
|
||||
[channel_email.inbox, channel_exists]
|
||||
end
|
||||
|
||||
def find_channel_by_email
|
||||
Channel::Email.find_by(email: users_data['email'], account: account)
|
||||
end
|
||||
|
||||
def update_channel(channel_email)
|
||||
channel_email.update!({
|
||||
imap_login: users_data['email'], imap_address: imap_address,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useStore } from 'dashboard/composables/store';
|
||||
import WootSnackbarBox from './components/SnackbarContainer.vue';
|
||||
import { setColorTheme } from './helper/themeHelper';
|
||||
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import {
|
||||
registerSubscription,
|
||||
verifyServiceWorkerExistence,
|
||||
@@ -35,8 +36,9 @@ export default {
|
||||
setup() {
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { accountId } = useAccount();
|
||||
|
||||
return { router, store };
|
||||
return { router, store, currentAccountId: accountId };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -52,7 +54,6 @@ export default {
|
||||
currentUser: 'getCurrentUser',
|
||||
authUIFlags: 'getAuthUIFlags',
|
||||
accountUIFlags: 'accounts/getUIFlags',
|
||||
currentAccountId: 'getCurrentAccountId',
|
||||
}),
|
||||
hasAccounts() {
|
||||
const { accounts = [] } = this.currentUser || {};
|
||||
@@ -69,10 +70,13 @@ export default {
|
||||
this.showAddAccountModal = true;
|
||||
}
|
||||
},
|
||||
currentAccountId() {
|
||||
if (this.currentAccountId) {
|
||||
this.initializeAccount();
|
||||
}
|
||||
currentAccountId: {
|
||||
immediate: true,
|
||||
handler() {
|
||||
if (this.currentAccountId) {
|
||||
this.initializeAccount();
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -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,8 +46,168 @@
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
.n-blue-border {
|
||||
@apply border-n-blue-border;
|
||||
}
|
||||
|
||||
.n-blue-text {
|
||||
@apply text-n-blue-text;
|
||||
}
|
||||
|
||||
// scss-lint:disable PropertySortOrder
|
||||
@layer base {
|
||||
// scss-lint:disable PropertySortOrder
|
||||
/* NEXT COLORS START */
|
||||
:root {
|
||||
/* slate */
|
||||
--slate-1: 252 252 253;
|
||||
--slate-2: 249 249 251;
|
||||
--slate-3: 240 240 243;
|
||||
--slate-4: 232 232 236;
|
||||
--slate-5: 224 225 230;
|
||||
--slate-6: 217 217 224;
|
||||
--slate-7: 205 206 214;
|
||||
--slate-8: 185 187 198;
|
||||
--slate-9: 139 141 152;
|
||||
--slate-10: 128 131 141;
|
||||
--slate-11: 96 100 108;
|
||||
--slate-12: 28 32 36;
|
||||
|
||||
--ruby-1: 255 252 253;
|
||||
--ruby-2: 255 247 248;
|
||||
--ruby-3: 254 234 237;
|
||||
--ruby-4: 255 220 225;
|
||||
--ruby-5: 255 206 214;
|
||||
--ruby-6: 248 191 200;
|
||||
--ruby-7: 239 172 184;
|
||||
--ruby-8: 229 146 163;
|
||||
--ruby-9: 229 70 102;
|
||||
--ruby-10: 220 59 93;
|
||||
--ruby-11: 202 36 77;
|
||||
--ruby-12: 100 23 43;
|
||||
|
||||
--amber-1: 254 253 251;
|
||||
--amber-2: 254 251 233;
|
||||
--amber-3: 255 247 194;
|
||||
--amber-4: 255 238 156;
|
||||
--amber-5: 251 229 119;
|
||||
--amber-6: 243 214 115;
|
||||
--amber-7: 233 193 98;
|
||||
--amber-8: 226 163 54;
|
||||
--amber-9: 255 197 61;
|
||||
--amber-10: 255 186 24;
|
||||
--amber-11: 171 100 0;
|
||||
--amber-12: 79 52 34;
|
||||
|
||||
--teal-1: 250 254 253;
|
||||
--teal-2: 243 251 249;
|
||||
--teal-3: 224 248 243;
|
||||
--teal-4: 204 243 234;
|
||||
--teal-5: 184 234 224;
|
||||
--teal-6: 161 222 210;
|
||||
--teal-7: 131 205 193;
|
||||
--teal-8: 83 185 171;
|
||||
--teal-9: 18 165 148;
|
||||
--teal-10: 13 155 138;
|
||||
--teal-11: 0 133 115;
|
||||
--teal-12: 13 61 56;
|
||||
|
||||
--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: 255 255 255;
|
||||
--solid-3: 255 255 255;
|
||||
--solid-active: 255 255 255;
|
||||
--solid-amber: 252 232 193;
|
||||
--solid-blue: 218 236 255;
|
||||
|
||||
--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 {
|
||||
/* slate */
|
||||
--slate-1: 17 17 19;
|
||||
--slate-2: 24 25 27;
|
||||
--slate-3: 33 34 37;
|
||||
--slate-4: 39 42 45;
|
||||
--slate-5: 46 49 53;
|
||||
--slate-6: 54 58 63;
|
||||
--slate-7: 67 72 78;
|
||||
--slate-8: 90 97 105;
|
||||
--slate-9: 105 110 119;
|
||||
--slate-10: 119 123 132;
|
||||
--slate-11: 176 180 186;
|
||||
--slate-12: 237 238 240;
|
||||
|
||||
--ruby-1: 25 17 19;
|
||||
--ruby-2: 30 21 23;
|
||||
--ruby-3: 58 20 30;
|
||||
--ruby-4: 78 19 37;
|
||||
--ruby-5: 94 26 46;
|
||||
--ruby-6: 111 37 57;
|
||||
--ruby-7: 136 52 71;
|
||||
--ruby-8: 179 68 90;
|
||||
--ruby-9: 229 70 102;
|
||||
--ruby-10: 236 90 114;
|
||||
--ruby-11: 255 148 157;
|
||||
--ruby-12: 254 210 225;
|
||||
|
||||
--amber-1: 22 18 12;
|
||||
--amber-2: 29 24 15;
|
||||
--amber-3: 48 32 8;
|
||||
--amber-4: 63 39 0;
|
||||
--amber-5: 77 48 0;
|
||||
--amber-6: 92 61 5;
|
||||
--amber-7: 113 79 25;
|
||||
--amber-8: 143 100 36;
|
||||
--amber-9: 255 197 61;
|
||||
--amber-10: 255 214 10;
|
||||
--amber-11: 255 202 22;
|
||||
--amber-12: 255 231 179;
|
||||
|
||||
--teal-1: 13 21 20;
|
||||
--teal-2: 17 28 27;
|
||||
--teal-3: 13 45 42;
|
||||
--teal-4: 2 59 55;
|
||||
--teal-5: 8 72 67;
|
||||
--teal-6: 20 87 80;
|
||||
--teal-7: 28 105 97;
|
||||
--teal-8: 32 126 115;
|
||||
--teal-9: 18 165 148;
|
||||
--teal-10: 14 179 158;
|
||||
--teal-11: 11 216 182;
|
||||
--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: 44 45 54;
|
||||
--solid-active: 53 57 66;
|
||||
--solid-amber: 42 37 30;
|
||||
--solid-blue: 16 49 91;
|
||||
--text-blue: 126 182 255;
|
||||
|
||||
--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 */
|
||||
|
||||
:root {
|
||||
--color-amber-25: 254 253 251;
|
||||
--color-amber-50: 255 249 237;
|
||||
@@ -390,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>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
layout: {
|
||||
type: String,
|
||||
default: 'col',
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['click']);
|
||||
const handleClick = () => {
|
||||
emit('click');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
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" />
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
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]"
|
||||
>
|
||||
<div
|
||||
class="w-full h-full space-y-4 overflow-y-hidden opacity-50 pointer-events-none"
|
||||
>
|
||||
<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-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-3">
|
||||
<h2
|
||||
class="text-3xl font-medium text-center text-slate-900 dark:text-white font-interDisplay"
|
||||
>
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p
|
||||
class="max-w-xl text-base text-center text-slate-600 dark:text-slate-300 font-interDisplay tracking-[0.3px]"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup>
|
||||
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: 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: 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: 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 -->
|
||||
<!-- eslint-disable vue/no-undef-components -->
|
||||
<template>
|
||||
<Story
|
||||
title="Components/HelpCenter/ArticleCard"
|
||||
:layout="{ type: 'grid', width: '700px' }"
|
||||
>
|
||||
<Variant title="Article Card">
|
||||
<div
|
||||
v-for="(article, index) in articles"
|
||||
:key="index"
|
||||
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="category"
|
||||
:views="article.views"
|
||||
:updated-at="article.updatedAt"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,198 @@
|
||||
<script setup>
|
||||
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 Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
author: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
category: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
views: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
updatedAt: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['openArticle', 'articleAction']);
|
||||
|
||||
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-n-slate-12';
|
||||
case 'draft':
|
||||
return 'text-n-amber-11';
|
||||
default:
|
||||
return 'text-n-teal-11';
|
||||
}
|
||||
});
|
||||
|
||||
const statusText = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'archived':
|
||||
return t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.STATUS.ARCHIVED');
|
||||
case 'draft':
|
||||
return t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.STATUS.DRAFT');
|
||||
default:
|
||||
return t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.STATUS.PUBLISHED');
|
||||
}
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
<CardLayout>
|
||||
<template #header>
|
||||
<div class="flex justify-between gap-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="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"
|
||||
>
|
||||
{{ 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()"
|
||||
/>
|
||||
<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>
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-1">
|
||||
<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-n-slate-11">
|
||||
{{ categoryName }}
|
||||
</span>
|
||||
<div
|
||||
class="inline-flex items-center gap-1 text-n-slate-11 whitespace-nowrap"
|
||||
>
|
||||
<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-n-slate-11 line-clamp-1">
|
||||
{{ lastUpdatedAt }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</CardLayout>
|
||||
</template>
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
<script setup>
|
||||
import CategoryCard from './CategoryCard.vue';
|
||||
const categories = [
|
||||
{
|
||||
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,
|
||||
slug: 'getting-started',
|
||||
icon: '🚀',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Marketing',
|
||||
description: '',
|
||||
articlesCount: 4,
|
||||
slug: 'marketing',
|
||||
icon: '📈',
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||
<!-- eslint-disable vue/no-undef-components -->
|
||||
<template>
|
||||
<Story
|
||||
title="Components/HelpCenter/CategoryCard"
|
||||
:layout="{ type: 'grid', width: '800px' }"
|
||||
>
|
||||
<Variant title="Category Card">
|
||||
<div
|
||||
v-for="(category, index) in categories"
|
||||
:key="index"
|
||||
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>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,137 @@
|
||||
<script setup>
|
||||
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';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
articlesCount: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
slug: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click', 'action']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||
|
||||
const categoryMenuItems = [
|
||||
{
|
||||
label: 'Edit',
|
||||
action: 'edit',
|
||||
value: 'edit',
|
||||
icon: 'i-lucide-pencil',
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
action: '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';
|
||||
});
|
||||
|
||||
const hasDescription = computed(() => {
|
||||
return props.description.length > 0;
|
||||
});
|
||||
|
||||
const handleClick = slug => {
|
||||
emit('click', slug);
|
||||
};
|
||||
|
||||
const handleAction = ({ action, value }) => {
|
||||
emit('action', { action, value, id: props.id });
|
||||
toggleDropdown(false);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout>
|
||||
<template #header>
|
||||
<div class="flex 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 truncate cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-text text-n-slate-12"
|
||||
@click="handleClick(slug)"
|
||||
>
|
||||
{{ categoryTitleWithIcon }}
|
||||
</span>
|
||||
<span
|
||||
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"
|
||||
>
|
||||
{{
|
||||
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_CARD.ARTICLES_COUNT', {
|
||||
count: articlesCount,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
class="relative group"
|
||||
>
|
||||
<Button
|
||||
icon="i-lucide-ellipsis-vertical"
|
||||
color="slate"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<span
|
||||
class="text-sm line-clamp-3"
|
||||
:class="
|
||||
hasDescription
|
||||
? 'text-slate-500 dark:text-slate-400'
|
||||
: 'text-slate-400 dark:text-slate-700'
|
||||
"
|
||||
>
|
||||
{{ description }}
|
||||
</span>
|
||||
</template>
|
||||
</CardLayout>
|
||||
</template>
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<script setup>
|
||||
import ArticleEmptyState from './ArticleEmptyState.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/HelpCenter/EmptyState/ArticleEmptyState"
|
||||
:layout="{ type: 'single', width: '1100px' }"
|
||||
>
|
||||
<Variant title="Article Empty State">
|
||||
<div class="w-full h-full px-20 mx-auto bg-white dark:bg-slate-900">
|
||||
<ArticleEmptyState />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
<script setup>
|
||||
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 articleContent from 'dashboard/components-next/HelpCenter/EmptyState/Portal/portalEmptyStateContent.js';
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showButton: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
buttonLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
|
||||
const onClick = () => {
|
||||
emit('click');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EmptyStateLayout :title="title" :subtitle="subtitle">
|
||||
<template #empty-state-item>
|
||||
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
|
||||
<ArticleCard
|
||||
v-for="(article, index) in articleContent.slice(0, 5)"
|
||||
: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>
|
||||
</template>
|
||||
<template #actions>
|
||||
<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,
|
||||
},
|
||||
},
|
||||
];
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<script setup>
|
||||
import PortalEmptyState from './PortalEmptyState.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/HelpCenter/EmptyState/PortalEmptyState"
|
||||
:layout="{ type: 'single', width: '1100px' }"
|
||||
>
|
||||
<Variant title="Portal Empty State">
|
||||
<div class="w-full h-full px-20 mx-auto bg-white dark:bg-slate-900">
|
||||
<PortalEmptyState />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
<script setup>
|
||||
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 articleContent from './portalEmptyStateContent';
|
||||
import CreatePortalDialog from 'dashboard/components-next/HelpCenter/PortalSwitcher/CreatePortalDialog.vue';
|
||||
|
||||
const createPortalDialogRef = ref(null);
|
||||
const openDialog = () => {
|
||||
createPortalDialogRef.value.dialogRef.open();
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const onPortalCreate = ({ slug: portalSlug, locale }) => {
|
||||
router.push({
|
||||
name: 'portals_articles_index',
|
||||
params: { portalSlug, locale },
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EmptyStateLayout
|
||||
:title="$t('HELP_CENTER.TITLE')"
|
||||
:subtitle="$t('HELP_CENTER.NEW_PAGE.DESCRIPTION')"
|
||||
>
|
||||
<template #empty-state-item>
|
||||
<div class="grid grid-cols-2 gap-4 p-px">
|
||||
<div class="space-y-4">
|
||||
<ArticleCard
|
||||
v-for="(article, index) in articleContent"
|
||||
: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 class="space-y-4">
|
||||
<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
|
||||
:label="$t('HELP_CENTER.NEW_PAGE.CREATE_PORTAL_BUTTON')"
|
||||
icon="i-lucide-plus"
|
||||
@click="openDialog"
|
||||
/>
|
||||
<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,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,113 @@
|
||||
<script setup>
|
||||
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({
|
||||
currentPage: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
totalItems: {
|
||||
type: Number,
|
||||
default: 100,
|
||||
},
|
||||
itemsPerPage: {
|
||||
type: Number,
|
||||
default: 25,
|
||||
},
|
||||
showHeaderTitle: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showPaginationFooter: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
};
|
||||
const togglePortalSwitcher = () => {
|
||||
showPortalSwitcher.value = !showPortalSwitcher.value;
|
||||
};
|
||||
</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 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
|
||||
v-if="activePortalName"
|
||||
class="text-xl font-medium text-n-slate-12"
|
||||
>
|
||||
{{ activePortalName }}
|
||||
</span>
|
||||
<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 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" />
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
|
||||
<div class="w-full max-w-[900px] mx-auto py-3">
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</main>
|
||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 px-4 pb-4">
|
||||
<PaginationFooter
|
||||
:current-page="currentPage"
|
||||
:total-items="totalItems"
|
||||
:items-per-page="itemsPerPage"
|
||||
@update:current-page="updateCurrentPage"
|
||||
/>
|
||||
</footer>
|
||||
<!-- Do not remove this slot. It can be used to add dialogs. -->
|
||||
<slot />
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup>
|
||||
import LocaleCard from './LocaleCard.vue';
|
||||
const locales = [
|
||||
{ name: 'English', isDefault: true, articleCount: 29, categoryCount: 5 },
|
||||
{ name: 'Spanish', isDefault: false, articleCount: 29, categoryCount: 5 },
|
||||
];
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||
<!-- eslint-disable vue/no-undef-components -->
|
||||
<template>
|
||||
<Story
|
||||
title="Components/HelpCenter/LocaleCard"
|
||||
:layout="{ type: 'grid', width: '800px' }"
|
||||
>
|
||||
<Variant title="Locale Card">
|
||||
<div class="px-10 py-4 bg-white dark:bg-slate-900">
|
||||
<div v-for="(locale, index) in locales" :key="index" class="px-20 py-2">
|
||||
<LocaleCard
|
||||
:locale="locale.name"
|
||||
:is-default="locale.isDefault"
|
||||
:article-count="locale.articleCount"
|
||||
:category-count="locale.categoryCount"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,118 @@
|
||||
<script setup>
|
||||
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';
|
||||
|
||||
const props = defineProps({
|
||||
locale: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isDefault: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
localeCode: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
articleCount: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
categoryCount: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
<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 }} ({{ localeCode }})
|
||||
</span>
|
||||
<span
|
||||
v-if="isDefault"
|
||||
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"
|
||||
>
|
||||
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }}
|
||||
</span>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
{{
|
||||
$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"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.CATEGORIES_COUNT',
|
||||
categoryCount
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
class="relative group"
|
||||
>
|
||||
<Button
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</CardLayout>
|
||||
</template>
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
<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 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 props = defineProps({
|
||||
article: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
isUpdating: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isSaved: {
|
||||
type: Boolean,
|
||||
default: 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: () => props.article.title,
|
||||
set: value => {
|
||||
saveArticle({ title: value });
|
||||
},
|
||||
});
|
||||
|
||||
const articleContent = computed({
|
||||
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>
|
||||
|
||||
<template>
|
||||
<HelpCenterLayout :show-header-title="false" :show-pagination-footer="false">
|
||||
<template #header-actions>
|
||||
<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"
|
||||
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>
|
||||
<FullEditor
|
||||
v-model="articleContent"
|
||||
class="py-0 pb-10 pl-4 rtl:pr-4 rtl:pl-0 h-fit"
|
||||
:placeholder="
|
||||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.EDITOR_PLACEHOLDER')
|
||||
"
|
||||
:enabled-menu-options="ARTICLE_EDITOR_MENU_OPTIONS"
|
||||
:autofocus="false"
|
||||
/>
|
||||
</template>
|
||||
</HelpCenterLayout>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
::v-deep {
|
||||
.ProseMirror .empty-node::before {
|
||||
@apply text-slate-200 dark:text-slate-500 text-base;
|
||||
}
|
||||
|
||||
.ProseMirror-menubar-wrapper {
|
||||
.ProseMirror-woot-style {
|
||||
@apply min-h-[15rem] max-h-full;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-menubar {
|
||||
display: none; // Hide by default
|
||||
}
|
||||
|
||||
.editor-root .has-selection {
|
||||
.ProseMirror-menubar {
|
||||
@apply h-8 rounded-lg !px-2 z-50 bg-slate-50 dark:bg-slate-800 items-center gap-4 ml-0 mb-0 shadow-md border border-slate-75 dark:border-slate-700/50;
|
||||
display: flex;
|
||||
top: var(--selection-top, auto) !important;
|
||||
left: var(--selection-left, 0) !important;
|
||||
width: fit-content !important;
|
||||
position: absolute !important;
|
||||
|
||||
.ProseMirror-menuitem {
|
||||
@apply mr-0;
|
||||
|
||||
.ProseMirror-icon {
|
||||
@apply p-0 mt-1 !mr-0;
|
||||
|
||||
svg {
|
||||
width: 20px !important;
|
||||
height: 20px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+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>
|
||||
+190
@@ -0,0 +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';
|
||||
|
||||
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>
|
||||
<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>
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
<script setup>
|
||||
import ArticlesPage from './ArticlesPage.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,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
title: 'Best practices for organizing your Help Center content',
|
||||
status: 'archived',
|
||||
updatedAt: '3 days ago',
|
||||
author: 'Fernando',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Pages/HelpCenter/ArticlesPage" :layout="{ type: 'single' }">
|
||||
<Variant title="All Articles">
|
||||
<div class="w-full min-h-screen bg-white dark:bg-slate-900">
|
||||
<ArticlesPage :articles="articles" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
<script setup>
|
||||
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';
|
||||
|
||||
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 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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
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="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">
|
||||
<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>
|
||||
<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>
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
<script setup>
|
||||
import CategoriesPage from './CategoriesPage.vue';
|
||||
|
||||
const categories = [
|
||||
{
|
||||
id: 'getting-started',
|
||||
title: '🚀 Getting started',
|
||||
description:
|
||||
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.',
|
||||
articlesCount: '2',
|
||||
articles: [
|
||||
{
|
||||
variant: 'Draft article',
|
||||
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,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Setting up your first Help Center portal',
|
||||
status: '',
|
||||
updatedAt: '1 week ago',
|
||||
author: 'John',
|
||||
category: '🛠️ Development',
|
||||
views: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'marketing',
|
||||
title: 'Marketing',
|
||||
description:
|
||||
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support.',
|
||||
articlesCount: '4',
|
||||
articles: [
|
||||
{
|
||||
variant: 'Draft article',
|
||||
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,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Setting up your first Help Center portal',
|
||||
status: '',
|
||||
updatedAt: '1 week ago',
|
||||
author: 'John',
|
||||
category: '🛠️ Development',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
variant: 'Archived article',
|
||||
title: 'Best practices for organizing your Help Center content',
|
||||
status: 'archived',
|
||||
updatedAt: '3 days ago',
|
||||
author: 'Fernando',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Customizing the appearance of your Help Center',
|
||||
status: '',
|
||||
updatedAt: '5 days ago',
|
||||
author: 'Jane',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'development',
|
||||
title: 'Development',
|
||||
description: '',
|
||||
articlesCount: '5',
|
||||
articles: [
|
||||
{
|
||||
variant: 'Draft article',
|
||||
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,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Setting up your first Help Center portal',
|
||||
status: '',
|
||||
updatedAt: '1 week ago',
|
||||
author: 'John',
|
||||
category: '🛠️ Development',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
variant: 'Archived article',
|
||||
title: 'Best practices for organizing your Help Center content',
|
||||
status: 'archived',
|
||||
updatedAt: '3 days ago',
|
||||
author: 'Fernando',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
variant: 'Archived article',
|
||||
title: 'Best practices for organizing your Help Center content',
|
||||
status: 'archived',
|
||||
updatedAt: '3 days ago',
|
||||
author: 'Fernando',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Customizing the appearance of your Help Center',
|
||||
status: '',
|
||||
updatedAt: '5 days ago',
|
||||
author: 'Jane',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'roadmap',
|
||||
title: '🛣️ Roadmap',
|
||||
description:
|
||||
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.',
|
||||
articlesCount: '3',
|
||||
articles: [
|
||||
{
|
||||
variant: 'Draft article',
|
||||
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,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Setting up your first Help Center portal',
|
||||
status: '',
|
||||
updatedAt: '1 week ago',
|
||||
author: 'John',
|
||||
category: '🛠️ Development',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Setting up your first Help Center portal',
|
||||
status: '',
|
||||
updatedAt: '1 week ago',
|
||||
author: 'John',
|
||||
category: '🛠️ Development',
|
||||
views: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'finance',
|
||||
title: '💰 Finance',
|
||||
description:
|
||||
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.',
|
||||
articlesCount: '2',
|
||||
articles: [
|
||||
{
|
||||
variant: 'Draft article',
|
||||
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,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Setting up your first Help Center portal',
|
||||
status: '',
|
||||
updatedAt: '1 week ago',
|
||||
author: 'John',
|
||||
category: '🛠️ Development',
|
||||
views: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Pages/HelpCenter/CategoryPage" :layout="{ type: 'single' }">
|
||||
<Variant title="All Categories">
|
||||
<div class="w-full min-h-screen bg-white dark:bg-slate-900">
|
||||
<CategoriesPage :categories="categories" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
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 CategoryList from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryList.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 isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
|
||||
const isLoading = computed(() => props.isFetching || isSwitchingPortal.value);
|
||||
const hasCategories = computed(() => props.categories?.length > 0);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HelpCenterLayout :show-pagination-footer="false">
|
||||
<template #header-actions>
|
||||
<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-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')"
|
||||
/>
|
||||
</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>
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
<script setup>
|
||||
import CategoryCard from 'dashboard/components-next/HelpCenter/CategoryCard/CategoryCard.vue';
|
||||
|
||||
defineProps({
|
||||
categories: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click', 'action']);
|
||||
|
||||
const handleClick = slug => {
|
||||
emit('click', slug);
|
||||
};
|
||||
|
||||
const handleAction = ({ action, value, id }, category) => {
|
||||
emit('action', { action, value, id, category });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul role="list" class="grid w-full h-full grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<CategoryCard
|
||||
v-for="category in categories"
|
||||
: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"
|
||||
@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>
|
||||
@@ -0,0 +1,107 @@
|
||||
<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';
|
||||
|
||||
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>
|
||||
<ul role="list" class="w-full h-full space-y-4">
|
||||
<LocaleCard
|
||||
v-for="(locale, index) in locales"
|
||||
:key="index"
|
||||
:locale="locale.name"
|
||||
: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>
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
<script setup>
|
||||
import LocalesPage from './LocalesPage.vue';
|
||||
|
||||
const locales = [
|
||||
{
|
||||
name: 'English (en-US)',
|
||||
isDefault: true,
|
||||
articleCount: 5,
|
||||
categoryCount: 5,
|
||||
},
|
||||
{
|
||||
name: 'Spanish (es-ES)',
|
||||
isDefault: false,
|
||||
articleCount: 20,
|
||||
categoryCount: 10,
|
||||
},
|
||||
{
|
||||
name: 'English (en-UK)',
|
||||
isDefault: false,
|
||||
articleCount: 15,
|
||||
categoryCount: 7,
|
||||
},
|
||||
{
|
||||
name: 'Malay (ms-MY)',
|
||||
isDefault: false,
|
||||
articleCount: 15,
|
||||
categoryCount: 7,
|
||||
},
|
||||
{
|
||||
name: 'Malayalam (ml-IN)',
|
||||
isDefault: false,
|
||||
articleCount: 10,
|
||||
categoryCount: 5,
|
||||
},
|
||||
{
|
||||
name: 'Hindi (hi-IN)',
|
||||
isDefault: false,
|
||||
articleCount: 15,
|
||||
categoryCount: 7,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Pages/HelpCenter/LocalePage" :layout="{ type: 'single' }">
|
||||
<Variant title="All Locales">
|
||||
<div class="w-full min-h-screen bg-white dark:bg-slate-900">
|
||||
<LocalesPage :locales="locales" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup>
|
||||
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 addLocaleDialogRef = ref(null);
|
||||
|
||||
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
|
||||
|
||||
const openAddLocaleDialog = () => {
|
||||
addLocaleDialogRef.value.dialogRef.open();
|
||||
};
|
||||
|
||||
const localeCount = computed(() => props.locales?.length);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HelpCenterLayout :show-pagination-footer="false">
|
||||
<template #header-actions>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-sm font-medium text-slate-800 dark:text-slate-100">
|
||||
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALES_COUNT', localeCount) }}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
:label="$t('HELP_CENTER.LOCALES_PAGE.NEW_LOCALE_BUTTON_TEXT')"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
@click="openAddLocaleDialog"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<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>
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<script setup>
|
||||
import PortalSettings from './PortalSettings.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Pages/HelpCenter/PortalSettings" :layout="{ type: 'single' }">
|
||||
<Variant title="Default">
|
||||
<div class="w-[1000px] min-h-screen bg-white dark:bg-slate-900">
|
||||
<PortalSettings />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
<HelpCenterLayout :show-pagination-footer="false">
|
||||
<template #content>
|
||||
<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>
|
||||
<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>
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
<script setup>
|
||||
import PortalSwitcher from './PortalSwitcher.vue';
|
||||
|
||||
const portals = [
|
||||
{
|
||||
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',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Developer Documentation',
|
||||
articles: 89,
|
||||
domain: 'dev.chatwoot.com',
|
||||
slug: 'docs',
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/HelpCenter/PortalSwitcher"
|
||||
:layout="{ type: 'grid', width: '510px' }"
|
||||
>
|
||||
<Variant title="Portal Switcher">
|
||||
<div class="h-[500px] p-4 bg-slate-100 dark:bg-slate-900">
|
||||
<PortalSwitcher
|
||||
:portals="portals"
|
||||
header="Choose a Portal"
|
||||
description="Select from available help center portals"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
<script setup>
|
||||
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';
|
||||
|
||||
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 isPortalActive = portal => {
|
||||
return portal.slug === currentPortalSlug.value;
|
||||
};
|
||||
|
||||
const getPortalThumbnailSrc = portal => {
|
||||
return portal?.logo?.file_url || '';
|
||||
};
|
||||
|
||||
const fetchPortalAndItsCategories = async (slug, locale) => {
|
||||
await store.dispatch('portals/switchPortal', true);
|
||||
await store.dispatch('portals/index');
|
||||
const selectedPortalParam = {
|
||||
portalSlug: slug,
|
||||
locale,
|
||||
};
|
||||
await store.dispatch('portals/show', selectedPortalParam);
|
||||
await store.dispatch('categories/index', selectedPortalParam);
|
||||
await store.dispatch('agents/get');
|
||||
await store.dispatch('portals/switchPortal', false);
|
||||
};
|
||||
|
||||
const handlePortalChange = async portal => {
|
||||
if (isPortalActive(portal)) return;
|
||||
const {
|
||||
slug,
|
||||
meta: { default_locale: defaultLocale },
|
||||
} = portal;
|
||||
emit('close');
|
||||
await fetchPortalAndItsCategories(slug, defaultLocale);
|
||||
const targetRouteName = CATEGORY_SUB_ROUTES.includes(route.name)
|
||||
? CATEGORY_ROUTE
|
||||
: route.name || DEFAULT_ROUTE;
|
||||
router.push({
|
||||
name: targetRouteName,
|
||||
params: {
|
||||
portalSlug: slug,
|
||||
locale: defaultLocale,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const openCreatePortalDialog = () => {
|
||||
emit('createPortal');
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const onClickPreviewPortal = () => {
|
||||
window.open(portalLink.value, '_blank');
|
||||
};
|
||||
|
||||
const redirectToPortalHomePage = () => {
|
||||
router.push({
|
||||
name: 'portals_index',
|
||||
params: {
|
||||
navigationPath: DEFAULT_ROUTE,
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
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-3 border-b border-n-alpha-2"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<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">
|
||||
{{ t('HELP_CENTER.PORTAL_SWITCHER.CREATE_PORTAL') }}
|
||||
</p>
|
||||
</div>
|
||||
<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-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>
|
||||
<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"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup>
|
||||
import Avatar from './Avatar.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">
|
||||
<Avatar
|
||||
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/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/thumbs/svg?seed=Jade"
|
||||
class="bg-indigo-300 dark:bg-indigo-900"
|
||||
allow-upload
|
||||
/>
|
||||
<Avatar
|
||||
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>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,219 @@
|
||||
<script setup>
|
||||
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: 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 { t } = useI18n();
|
||||
|
||||
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>
|
||||
<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]"
|
||||
/>
|
||||
</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>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import Breadcrumb from './Breadcrumb.vue';
|
||||
|
||||
const singleItem = ref([{ label: 'Home', link: '#' }]);
|
||||
const twoItems = ref([
|
||||
{ label: 'Home', link: '#' },
|
||||
{ label: 'Categories', link: '#' },
|
||||
]);
|
||||
const threeItems = ref([
|
||||
{ label: 'Home', link: '#' },
|
||||
{ label: 'Categories', link: '#' },
|
||||
{ label: 'Marketing', count: 6, emoji: '📊' },
|
||||
]);
|
||||
const longBreadcrumb = ref([
|
||||
{ label: 'Home', link: '#' },
|
||||
{ label: 'Categories', link: '#', emoji: '📁' },
|
||||
{ label: 'Marketing', link: '#' },
|
||||
{ label: 'Digital', link: '#', emoji: '💻' },
|
||||
{ label: 'Social Media', count: 12, emoji: '📱' },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||
<!-- eslint-disable vue/no-undef-components -->
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Breadcrumb"
|
||||
:layout="{ type: 'grid', width: '800px' }"
|
||||
>
|
||||
<Variant title="Single Item">
|
||||
<div class="w-full p-4 bg-white dark:bg-slate-900">
|
||||
<Breadcrumb :items="singleItem" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Two Items">
|
||||
<div class="w-full p-4 bg-white dark:bg-slate-900">
|
||||
<Breadcrumb :items="twoItems" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Three Items with Count">
|
||||
<div class="w-full p-4 bg-white dark:bg-slate-900">
|
||||
<Breadcrumb :items="threeItems" count-label="articles" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Long Breadcrumb">
|
||||
<div class="w-full p-4 bg-white dark:bg-slate-900">
|
||||
<Breadcrumb :items="longBreadcrumb" count-label="articles" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="RTL Support">
|
||||
<div dir="rtl">
|
||||
<div class="w-full p-4 bg-white dark:bg-slate-900">
|
||||
<Breadcrumb :items="threeItems" count-label="articles" />
|
||||
</div>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup>
|
||||
import { defineProps } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
validator: value => {
|
||||
return value.every(
|
||||
item =>
|
||||
typeof item.label === 'string' &&
|
||||
(item.link === undefined || typeof item.link === 'string') &&
|
||||
(item.count === undefined || typeof item.count === 'number')
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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">
|
||||
<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>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -0,0 +1,124 @@
|
||||
<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: '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>
|
||||
|
||||
<!-- 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 Outline" variant="outline" disabled />
|
||||
<Button label="Disabled Icon" icon="delete" disabled />
|
||||
<Button
|
||||
label="Disabled Destructive"
|
||||
color="ruby"
|
||||
disabled
|
||||
icon="delete"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- Icon Positions -->
|
||||
<Variant title="Icon Positions & Sizes">
|
||||
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
|
||||
<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>
|
||||
|
||||
<!-- Ghost & Link Variants -->
|
||||
<Variant title="Ghost & Link Variants">
|
||||
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
|
||||
<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>
|
||||
</template>
|
||||
@@ -0,0 +1,167 @@
|
||||
<script setup>
|
||||
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: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'solid',
|
||||
validator: value =>
|
||||
['solid', 'outline', 'faded', 'link', 'ghost'].includes(value),
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: 'blue',
|
||||
validator: value =>
|
||||
['blue', 'ruby', 'amber', 'slate', 'teal'].includes(value),
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md',
|
||||
validator: value => ['xs', 'sm', 'md', 'lg'].includes(value),
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
trailingIcon: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const slots = useSlots();
|
||||
|
||||
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',
|
||||
},
|
||||
},
|
||||
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',
|
||||
},
|
||||
},
|
||||
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 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,
|
||||
};
|
||||
|
||||
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 linkButtonClasses = computed(() => {
|
||||
const classes = [
|
||||
variantClasses.value,
|
||||
STYLE_CONFIG.sizes.link[props.size],
|
||||
].filter(Boolean);
|
||||
|
||||
return classes.join(' ');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:class="{
|
||||
[STYLE_CONFIG.base]: true,
|
||||
[isLink ? linkButtonClasses : buttonClasses]: true,
|
||||
[STYLE_CONFIG.fontSize[size]]: true,
|
||||
'flex-row-reverse': trailingIcon && !isIconOnly,
|
||||
}"
|
||||
>
|
||||
<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>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import ComboBox from './ComboBox.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 selectedValue = ref('');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Components/ComboBox" :layout="{ type: 'grid', width: '300px' }">
|
||||
<Variant title="Default">
|
||||
<div class="w-full p-4 bg-white h-80 dark:bg-slate-900">
|
||||
<ComboBox v-model="selectedValue" :options="options" />
|
||||
<p class="mt-2">Selected value: {{ selectedValue }}</p>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Disabled">
|
||||
<div class="w-full p-4 bg-white h-80 dark:bg-slate-900">
|
||||
<ComboBox :options="options" disabled />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,143 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
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: [String, Number],
|
||||
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 selectedValue = 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 selectedLabel = computed(() => {
|
||||
const selected = props.options.find(
|
||||
option => option.value === selectedValue.value
|
||||
);
|
||||
return selected?.label ?? selectPlaceholder.value;
|
||||
});
|
||||
|
||||
const selectOption = option => {
|
||||
selectedValue.value = option.value;
|
||||
emit('update:modelValue', option.value);
|
||||
open.value = false;
|
||||
search.value = '';
|
||||
};
|
||||
|
||||
const toggleDropdown = () => {
|
||||
if (props.disabled) return;
|
||||
open.value = !open.value;
|
||||
if (open.value) {
|
||||
search.value = '';
|
||||
nextTick(() => dropdownRef.value?.focus());
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
newValue => {
|
||||
selectedValue.value = newValue;
|
||||
}
|
||||
);
|
||||
</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">
|
||||
<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,
|
||||
}"
|
||||
>
|
||||
{{ 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>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import Dialog from './Dialog.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
|
||||
const alertDialog = ref(null);
|
||||
const editDialog = ref(null);
|
||||
const confirmDialog = ref(null);
|
||||
|
||||
const openAlertDialog = () => {
|
||||
alertDialog.value.open();
|
||||
};
|
||||
const openEditDialog = () => {
|
||||
editDialog.value.open();
|
||||
};
|
||||
const openConfirmDialog = () => {
|
||||
confirmDialog.value.open();
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const onConfirm = dialog => {};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Components/Dialog" :layout="{ type: 'grid', width: '100%' }">
|
||||
<Variant title="Alert Dialog">
|
||||
<Button label="Open Alert Dialog" @click="openAlertDialog" />
|
||||
<Dialog
|
||||
ref="alertDialog"
|
||||
type="alert"
|
||||
title="Alert"
|
||||
description="This is an alert message."
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Edit Dialog">
|
||||
<Button label="Open Edit Dialog" @click="openEditDialog" />
|
||||
<Dialog
|
||||
ref="editDialog"
|
||||
type="edit"
|
||||
description="You can create a new portal here, by providing a name and a slug."
|
||||
title="Create Portal"
|
||||
confirm-button-label="Save"
|
||||
@confirm="onConfirm()"
|
||||
>
|
||||
<template #form>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Input
|
||||
id="portal-name"
|
||||
type="text"
|
||||
placeholder="User Guide | Chatwoot"
|
||||
label="Name"
|
||||
message="This will be the name of your public facing portal"
|
||||
/>
|
||||
<Input
|
||||
id="portal-slug"
|
||||
type="text"
|
||||
placeholder="user-guide"
|
||||
label="Slug"
|
||||
message="app.chatwoot.com/hc/my-portal/en-US/categories/my-slug"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Confirm Dialog">
|
||||
<Button label="Open Confirm Dialog" @click="openConfirmDialog" />
|
||||
<Dialog
|
||||
ref="confirmDialog"
|
||||
type="confirm"
|
||||
title="Confirm Action"
|
||||
description="Are you sure you want to perform this action?"
|
||||
confirm-button-label="Yes, I'm sure"
|
||||
cancel-button-label="No, cancel"
|
||||
@confirm="onConfirm()"
|
||||
/>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,133 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
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({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'edit',
|
||||
validator: value => ['alert', 'edit'].includes(value),
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
cancelButtonLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
confirmButtonLabel: {
|
||||
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', 'close']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const isRTL = useMapGetter('accounts/isRTL');
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const dialogContentRef = ref(null);
|
||||
|
||||
const open = () => {
|
||||
dialogRef.value?.showModal();
|
||||
};
|
||||
const close = () => {
|
||||
emit('close');
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
const confirm = () => {
|
||||
emit('confirm');
|
||||
};
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<dialog
|
||||
ref="dialogRef"
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
</OnClickOutside>
|
||||
</dialog>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
dialog::backdrop {
|
||||
@apply dark:bg-n-alpha-white bg-n-alpha-black2;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import DropdownMenu from './DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
const menuItems = [
|
||||
{ label: 'Edit', action: 'edit', icon: 'edit' },
|
||||
{ label: 'Publish', action: 'publish', icon: 'checkmark' },
|
||||
{ label: 'Archive', action: 'archive', icon: 'archive' },
|
||||
{ label: 'Delete', action: 'delete', icon: 'delete' },
|
||||
];
|
||||
const isOpen = ref(false);
|
||||
const toggleDropdown = () => {
|
||||
isOpen.value = !isOpen.value;
|
||||
};
|
||||
const handleAction = () => {
|
||||
isOpen.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Components/DropdownMenu" :layout="{ type: 'grid', width: 300 }">
|
||||
<Variant title="Default">
|
||||
<div class="p-4 bg-white h-72 dark:bg-slate-900">
|
||||
<div class="relative">
|
||||
<Button label="Open Menu" size="sm" @click="toggleDropdown" />
|
||||
<DropdownMenu
|
||||
v-if="isOpen"
|
||||
:menu-items="menuItems"
|
||||
class="absolute left-0 top-10"
|
||||
@action="handleAction"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Always Open">
|
||||
<div class="p-4 bg-white h-72 dark:bg-slate-900">
|
||||
<DropdownMenu :menu-items="menuItems" @action="handleAction" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Custom Items">
|
||||
<div class="p-4 bg-white h-72 dark:bg-slate-900">
|
||||
<DropdownMenu
|
||||
:menu-items="[
|
||||
{ label: 'Custom 1', action: 'custom1', icon: 'file-upload' },
|
||||
{ label: 'Custom 2', action: 'custom2', icon: 'document' },
|
||||
{ label: 'Danger', action: 'delete', icon: 'delete' },
|
||||
]"
|
||||
@action="handleAction"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup>
|
||||
import { defineProps, defineEmits } from '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, value) => {
|
||||
emit('action', { action, value });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
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
|
||||
v-for="item in menuItems"
|
||||
:key="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>
|
||||
@@ -0,0 +1,19 @@
|
||||
<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)) {
|
||||
return props.icon;
|
||||
}
|
||||
return h('span', { class: props.icon });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="renderIcon" />
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user