Compare commits

..
Author SHA1 Message Date
Shivam Mishra 68ed5a4b4f chore: source account id directly 2024-10-08 18:52:34 +05:30
Shivam Mishra 7ecb993d06 chore: force init account 2024-10-08 18:49:26 +05:30
Shivam Mishra c00a157c03 chore: more log 2024-10-08 18:45:08 +05:30
Shivam Mishra 3750689bee chore: debug debug 2024-10-08 18:43:37 +05:30
Shivam Mishra 0511bf4ba6 chore: more debug 2024-10-08 18:34:06 +05:30
Shivam Mishra 9f50638a40 chore: more logs 2024-10-08 18:30:59 +05:30
Shivam Mishra f5ac272e3b chore: logs 2024-10-08 18:18:49 +05:30
Shivam Mishra 2ba7318817 chore: more try 2024-10-08 18:14:58 +05:30
Shivam Mishra 412303d1cd chore: try i18n 2024-10-08 15:20:35 +05:30
Shivam Mishra 47b9399208 feat: setup fallback 2024-10-08 14:01:57 +05:30
Shivam Mishra 0830ad78b6 chore: try 2024-10-08 13:53:58 +05:30
Shivam Mishra 0b8df762f9 chore: more debug 2024-10-08 12:38:03 +05:30
Shivam Mishra f24c87f9b2 chore: try 2024-10-08 12:28:48 +05:30
Shivam Mishra f62a4170b6 chore: debug 2024-10-08 12:20:14 +05:30
Shivam Mishra 2f090dbc61 Revert "chore: don't use async"
This reverts commit 9101e1e221.
2024-10-08 11:54:02 +05:30
Shivam Mishra ec17682d7a chore: move to created 2024-10-08 11:53:55 +05:30
Shivam Mishra 7861ce8c47 chore: revert iframe helper 2024-10-08 11:42:09 +05:30
Shivam Mishra e5de41fea4 chore: revert console.logs 2024-10-08 11:41:40 +05:30
Shivam Mishra adb0e94cbd chore: remove logs 2024-10-08 11:40:53 +05:30
Shivam Mishra 42fb083491 chore: debug 2024-10-08 11:31:33 +05:30
Shivam Mishra 9101e1e221 chore: don't use async 2024-10-08 11:18:13 +05:30
Shivam Mishra 91bac16c29 chore: add more error log 2024-10-08 11:00:21 +05:30
Shivam Mishra 97699f3087 chore: log errors 2024-10-08 10:59:42 +05:30
Shivam Mishra 1242cfd704 chore: add logs for iframhelper 2024-10-08 10:57:13 +05:30
Shivam Mishra 6963c668bf chore: add console logs 2024-10-08 10:49:56 +05:30
501 changed files with 8331 additions and 23405 deletions
-2
View File
@@ -57,5 +57,3 @@ exclude_patterns:
- 'app/javascript/shared/constants/locales.js'
- 'app/javascript/dashboard/helper/specs/macrosFixtures.js'
- 'app/javascript/dashboard/routes/dashboard/settings/macros/constants.js'
- '**/fixtures/**'
- '**/*/fixtures.js'
+6 -14
View File
@@ -12,20 +12,6 @@ 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: {
@@ -237,5 +223,11 @@ module.exports = {
globals: {
bus: true,
vi: true,
// beforeEach: true,
// afterEach: true,
// test: true,
// describe: true,
// it: true,
// expect: true,
},
};
+1 -4
View File
@@ -49,7 +49,7 @@ gem 'aws-sdk-s3', require: false
# original gem isn't maintained actively
# we wanted updated version of faraday which is a dependency for slack-ruby-client
gem 'azure-storage-blob', git: 'https://github.com/chatwoot/azure-storage-ruby', branch: 'chatwoot', require: false
gem 'google-cloud-storage', '>= 1.48.0', require: false
gem 'google-cloud-storage', require: false
gem 'image_processing'
##-- gems for database --#
@@ -181,9 +181,6 @@ gem 'reverse_markdown'
group :production do
# we dont want request timing out in development while using byebug
gem 'rack-timeout'
# for heroku autoscaling
gem 'judoscale-rails', require: false
gem 'judoscale-sidekiq', require: false
end
group :development do
+83 -89
View File
@@ -33,70 +33,70 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (7.0.8.5)
actionpack (= 7.0.8.5)
activesupport (= 7.0.8.5)
actioncable (7.0.8.4)
actionpack (= 7.0.8.4)
activesupport (= 7.0.8.4)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
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)
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)
mail (>= 2.7.1)
net-imap
net-pop
net-smtp
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)
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)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
rails-dom-testing (~> 2.0)
actionpack (7.0.8.5)
actionview (= 7.0.8.5)
activesupport (= 7.0.8.5)
actionpack (7.0.8.4)
actionview (= 7.0.8.4)
activesupport (= 7.0.8.4)
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.5)
actionpack (= 7.0.8.5)
activerecord (= 7.0.8.5)
activestorage (= 7.0.8.5)
activesupport (= 7.0.8.5)
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)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.0.8.5)
activesupport (= 7.0.8.5)
actionview (7.0.8.4)
activesupport (= 7.0.8.4)
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.5)
activesupport (= 7.0.8.5)
activejob (7.0.8.4)
activesupport (= 7.0.8.4)
globalid (>= 0.3.6)
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)
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)
activerecord-import (1.4.1)
activerecord (>= 4.2)
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)
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)
marcel (~> 1.0)
mini_mime (>= 1.1.0)
activesupport (7.0.8.5)
activesupport (7.0.8.4)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
@@ -209,7 +209,7 @@ GEM
devise (> 3.5.2, < 5)
rails (>= 4.2.0, < 7.2)
diff-lcs (1.5.1)
digest-crc (0.6.5)
digest-crc (0.6.4)
rake (>= 12.0.0, < 14.0.0)
docile (1.4.0)
domain_name (0.5.20190701)
@@ -284,39 +284,39 @@ GEM
activesupport (>= 6.1)
gmail_xoauth (0.4.3)
oauth (>= 0.3.6)
google-apis-core (0.15.1)
google-apis-core (0.11.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (~> 1.9)
httpclient (>= 2.8.3, < 3.a)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
mini_mime (~> 1.0)
mutex_m
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
google-apis-iamcredentials_v1 (0.22.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.47.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.7.1)
google-cloud-env (>= 1.0, < 3.a)
rexml
webrick
google-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.19.0)
google-apis-core (>= 0.9.0, < 2.a)
google-cloud-core (1.6.0)
google-cloud-env (~> 1.0)
google-cloud-errors (~> 1.0)
google-cloud-dialogflow-v2 (0.31.0)
gapic-common (>= 0.20.0, < 2.a)
google-cloud-errors (~> 1.0)
google-cloud-location (>= 0.4, < 2.a)
google-cloud-env (2.2.1)
faraday (>= 1.0, < 3.a)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.3.1)
google-cloud-location (0.6.0)
gapic-common (>= 0.20.0, < 2.a)
google-cloud-errors (~> 1.0)
google-cloud-storage (1.52.0)
google-cloud-storage (1.44.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-core (~> 0.13)
google-apis-iamcredentials_v1 (~> 0.18)
google-apis-storage_v1 (~> 0.38)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.19.0)
google-cloud-core (~> 1.6)
googleauth (~> 1.9)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
google-cloud-translate-v3 (0.10.0)
gapic-common (>= 0.20.0, < 2.a)
@@ -331,10 +331,10 @@ GEM
grpc (~> 1.41)
googleapis-common-protos-types (1.14.0)
google-protobuf (~> 3.18)
googleauth (1.11.2)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.1)
googleauth (1.5.2)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
memoist (~> 0.16)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
@@ -373,7 +373,7 @@ GEM
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
httpclient (2.8.3)
i18n (1.14.6)
i18n (1.14.5)
concurrent-ruby (~> 1.0)
image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5)
@@ -397,13 +397,6 @@ GEM
hana (~> 1.3)
regexp_parser (~> 2.0)
uri_template (~> 0.7)
judoscale-rails (1.8.2)
judoscale-ruby (= 1.8.2)
railties
judoscale-ruby (1.8.2)
judoscale-sidekiq (1.8.2)
judoscale-ruby (= 1.8.2)
sidekiq (>= 5.0)
jwt (2.8.1)
base64
kaminari (1.2.2)
@@ -462,6 +455,7 @@ GEM
net-smtp
marcel (1.0.4)
maxminddb (0.1.22)
memoist (0.16.2)
meta_request (0.8.3)
rack-contrib (>= 1.1, < 3)
railties (>= 3.0.0, < 8)
@@ -479,14 +473,13 @@ GEM
multi_json (1.15.0)
multi_xml (0.6.0)
multipart-post (2.3.0)
mutex_m (0.3.0)
neighbor (0.2.3)
activerecord (>= 5.2)
net-http (0.4.1)
uri
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.4.17)
net-imap (0.4.14)
date
net-protocol
net-pop (0.1.2)
@@ -564,7 +557,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (2.2.10)
rack (2.2.9)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-contrib (2.5.0)
@@ -581,20 +574,20 @@ GEM
rack-test (2.1.0)
rack (>= 1.3)
rack-timeout (0.6.3)
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)
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)
bundler (>= 1.15.0)
railties (= 7.0.8.5)
railties (= 7.0.8.4)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
@@ -602,9 +595,9 @@ GEM
rails-html-sanitizer (1.6.0)
loofah (~> 2.21)
nokogiri (~> 1.14)
railties (7.0.8.5)
actionpack (= 7.0.8.5)
activesupport (= 7.0.8.5)
railties (7.0.8.4)
actionpack (= 7.0.8.4)
activesupport (= 7.0.8.4)
method_source
rake (>= 12.2)
thor (~> 1.0)
@@ -640,7 +633,8 @@ GEM
retriable (3.1.2)
reverse_markdown (2.1.1)
nokogiri
rexml (3.3.9)
rexml (3.3.6)
strscan
rspec-core (3.13.0)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.2)
@@ -770,6 +764,7 @@ 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)
@@ -824,6 +819,7 @@ GEM
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.8.2)
websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
@@ -885,7 +881,7 @@ DEPENDENCIES
geocoder
gmail_xoauth
google-cloud-dialogflow-v2 (>= 0.24.0)
google-cloud-storage (>= 1.48.0)
google-cloud-storage
google-cloud-translate-v3 (>= 0.7.0)
groupdate
grpc
@@ -897,8 +893,6 @@ DEPENDENCIES
jbuilder
json_refs
json_schemer
judoscale-rails
judoscale-sidekiq
jwt
kaminari
koala
+2 -2
View File
@@ -6,7 +6,7 @@ RAILS_ENV ?= development
setup:
gem install bundler
bundle install
pnpm install
yarn 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 && pnpm install
bundle && yarn
run:
@if [ -f ./.overmind.sock ]; then \
@@ -6,15 +6,13 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
def index
@portal_articles = @portal.articles
set_article_count
@articles = @articles.search(list_params)
@all_articles = @portal_articles.search(list_params)
@articles_count = @all_articles.count
@articles = if list_params[:category_slug].present?
@articles.order_by_position.page(@current_page)
@all_articles.order_by_position.page(@current_page)
else
@articles.order_by_updated_at.page(@current_page)
@all_articles.order_by_updated_at.page(@current_page)
end
end
@@ -45,19 +43,6 @@ 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
@@ -68,10 +53,9 @@ 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,
:locale, meta: [:title,
:description,
{ tags: [] }]
:title, :slug, :position, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status, meta: [:title,
:description,
{ tags: [] }]
)
end
@@ -1,53 +1,22 @@
class Api::V1::Accounts::Integrations::CaptainController < Api::V1::Accounts::BaseController
before_action :hook
before_action :check_admin_authorization?
before_action :fetch_hook
def proxy
response = HTTParty.send(request_method, request_url, body: permitted_params[:body].to_json, headers: headers)
render plain: response.body, status: response.code
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
end
private
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: {})
def fetch_hook
@hook = Current.account.hooks.find_by!(app_id: 'captain')
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.merge(live_chat_widget_params))
@portal = Current.account.portals.build(portal_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.merge(live_chat_widget_params)) if params[:portal].present?
@portal.update!(portal_params) if params[:portal].present?
# @portal.custom_domain = parsed_custom_domain
process_attached_logo if params[:blob_id].present?
rescue StandardError => e
@@ -70,21 +70,11 @@ 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,15 +1,6 @@
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
+1 -5
View File
@@ -25,7 +25,7 @@ class OauthCallbackController < ApplicationController
end
def find_or_create_inbox
channel_email = find_channel_by_email
channel_email = Channel::Email.find_by(email: users_data['email'], account: account)
# we need this value to know where to redirect on sucessful processing of the callback
channel_exists = channel_email.present?
@@ -39,10 +39,6 @@ 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,
@@ -4,7 +4,6 @@ class Platform::Api::V1::AccountsController < PlatformController
def create
@resource = Account.create!(account_params)
update_resource_features
@resource.save!
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
end
@@ -30,7 +30,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def set_article
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
@article.increment_view_count if @article.published?
@article.increment_view_count
@parsed_content = render_article_content(@article.content)
end
+2 -3
View File
@@ -26,8 +26,7 @@ class AccountDashboard < Administrate::BaseDashboard
conversations: CountField,
locale: Field::Select.with_options(collection: LANGUAGES_CONFIG.map { |_x, y| y[:iso_639_1_code] }),
status: Field::Select.with_options(collection: [%w[Active active], %w[Suspended suspended]]),
account_users: Field::HasMany,
custom_attributes: Field::String
account_users: Field::HasMany
}.merge(enterprise_attribute_types).freeze
# COLLECTION_ATTRIBUTES
@@ -46,7 +45,7 @@ class AccountDashboard < Administrate::BaseDashboard
# SHOW_PAGE_ATTRIBUTES
# an array of attributes that will be displayed on the model's show page.
enterprise_show_page_attributes = ChatwootApp.enterprise? ? %i[custom_attributes limits all_features] : []
enterprise_show_page_attributes = ChatwootApp.enterprise? ? %i[limits all_features] : []
SHOW_PAGE_ATTRIBUTES = (%i[
id
name
+19 -13
View File
@@ -13,7 +13,6 @@ 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,
@@ -36,9 +35,8 @@ export default {
setup() {
const router = useRouter();
const store = useStore();
const { accountId } = useAccount();
return { router, store, currentAccountId: accountId };
return { router, store };
},
data() {
return {
@@ -54,7 +52,11 @@ export default {
currentUser: 'getCurrentUser',
authUIFlags: 'getAuthUIFlags',
accountUIFlags: 'accounts/getUIFlags',
// currentAccountId: 'getCurrentAccountId',
}),
currentAccountId() {
return this.$route.params.accountId;
},
hasAccounts() {
const { accounts = [] } = this.currentUser || {};
return accounts.length > 0;
@@ -70,19 +72,23 @@ export default {
this.showAddAccountModal = true;
}
},
currentAccountId: {
immediate: true,
handler() {
if (this.currentAccountId) {
this.initializeAccount();
}
},
currentAccountId() {
console.log('THIS CALL', this.currentAccountId);
if (this.currentAccountId) {
this.initializeAccount();
} else {
console.log('NO ACCOUNT ID');
}
},
},
mounted() {
created() {
this.setLocale(window.chatwootConfig.selectedLocale);
},
async mounted() {
this.setLocale(window.chatwootConfig.selectedLocale);
this.initializeColorTheme();
this.listenToThemeChanges();
this.setLocale(window.chatwootConfig.selectedLocale);
await this.$store.dispatch('accounts/get');
},
unmounted() {
if (this.reconnectService) {
@@ -98,7 +104,7 @@ export default {
mql.onchange = e => setColorTheme(e.matches);
},
setLocale(locale) {
this.$root.$i18n.locale = locale;
this.$i18n.locale = locale;
},
async initializeAccount() {
await this.$store.dispatch('accounts/get');
+12 -101
View File
@@ -1,20 +1,12 @@
/* global axios */
const DEFAULT_API_VERSION = 'v1';
const DEFAULT_OPTIONS = {
apiVersion: DEFAULT_API_VERSION,
enterprise: false,
accountScoped: false,
cancelPreviousRequests: false,
};
class ApiClient {
constructor(resource, options = {}) {
this.options = { ...DEFAULT_OPTIONS, ...options };
this.apiVersion = `/api/${this.options.apiVersion}`;
this.apiVersion = `/api/${options.apiVersion || DEFAULT_API_VERSION}`;
this.options = options;
this.resource = resource;
this.abortControllers = new Map();
this.activeRequests = new Map();
}
get url() {
@@ -47,105 +39,24 @@ class ApiClient {
return url;
}
generateRequestKey(method, id = '', customRequestKey = '') {
if (customRequestKey) return customRequestKey;
return `${method}-${this.url}${id ? `/${id}` : ''}`;
get() {
return axios.get(this.url);
}
cancelRequest(requestKey) {
const controller = this.abortControllers.get(requestKey);
if (controller) {
controller.abort();
this.abortControllers.delete(requestKey);
return true;
}
return false;
show(id) {
return axios.get(`${this.url}/${id}`);
}
setupRequest(method, options = {}) {
if (!this.options.cancelPreviousRequests) {
return {};
}
const { id, customRequestKey } = options;
const requestKey = this.generateRequestKey(method, id, customRequestKey);
const previousRequests = this.activeRequests.get(method) || [];
const remainingRequests = previousRequests.filter(key => {
if (key === requestKey) {
this.cancelRequest(key);
return false;
}
return true;
});
this.activeRequests.set(method, [...remainingRequests, requestKey]);
const controller = new AbortController();
this.abortControllers.set(requestKey, controller);
return { requestKey, signal: controller.signal };
create(data) {
return axios.post(this.url, data);
}
async request(method, config = {}) {
const { id, data, customRequestKey, ...axiosConfig } = config;
const { requestKey, signal } = this.setupRequest(method, {
id,
customRequestKey,
});
try {
const url = id ? `${this.url}/${id}` : this.url;
return await axios({
method,
url,
data,
signal,
...axiosConfig,
});
} finally {
this.abortControllers.delete(requestKey);
if (this.options.cancelPreviousRequests) {
const methodRequests = this.activeRequests.get(method) || [];
this.activeRequests.set(
method,
methodRequests.filter(key => key !== requestKey)
);
}
}
update(id, data) {
return axios.patch(`${this.url}/${id}`, data);
}
get(config = {}) {
return this.request('GET', config);
}
show(id, config = {}) {
return this.request('GET', { ...config, id });
}
create(data, config = {}) {
return this.request('POST', { ...config, data });
}
update(id, data, config = {}) {
return this.request('PATCH', { ...config, id, data });
}
delete(id, config = {}) {
return this.request('DELETE', { ...config, id });
}
cancelAllRequests() {
this.abortControllers.forEach(controller => controller.abort());
this.abortControllers.clear();
this.activeRequests.clear();
}
// Get all active request keys
getActiveRequests() {
return Array.from(this.abortControllers.keys());
}
destroy() {
this.cancelAllRequests();
delete(id) {
return axios.delete(`${this.url}/${id}`);
}
}
@@ -52,13 +52,12 @@ class ArticlesAPI extends PortalsAPI {
}
createArticle({ portalSlug, articleObj }) {
const { content, title, authorId, categoryId, locale } = articleObj;
const { content, title, author_id, category_id } = articleObj;
return axios.post(`${this.url}/${portalSlug}/articles`, {
content,
title,
author_id: authorId,
category_id: categoryId,
locale,
author_id,
category_id,
});
}
+2 -2
View File
@@ -33,8 +33,8 @@ class IntegrationsAPI extends ApiClient {
return axios.delete(`${this.baseUrl()}/integrations/hooks/${hookId}`);
}
requestCaptain(body) {
return axios.post(`${this.baseUrl()}/integrations/captain/proxy`, body);
fetchCaptainURL() {
return axios.get(`${this.baseUrl()}/integrations/captain/sso_url`);
}
}
+3 -10
View File
@@ -5,11 +5,7 @@ const getTimeOffset = () => -new Date().getTimezoneOffset() / 60;
class ReportsAPI extends ApiClient {
constructor() {
super('reports', {
accountScoped: true,
apiVersion: 'v2',
cancelPreviousRequests: true,
});
super('reports', { accountScoped: true, apiVersion: 'v2' });
}
getReports({
@@ -21,8 +17,7 @@ class ReportsAPI extends ApiClient {
groupBy,
businessHours,
}) {
return this.request('GET', {
customRequestKey: `${metric}-report-GET`,
return axios.get(`${this.url}`, {
params: {
metric,
since: from,
@@ -52,9 +47,7 @@ class ReportsAPI extends ApiClient {
}
getConversationMetric(type = 'account', page = 1) {
return this.request('GET', {
requestPath: `${this.url}/conversations`,
return axios.get(`${this.url}/conversations`, {
params: {
type,
page,
@@ -25,7 +25,7 @@
}
.mx-input {
@apply h-[2.5rem] flex border border-solid border-n-weak rounded-md shadow-none;
@apply h-[2.5rem] flex border border-solid border-slate-200 dark:border-slate-600 rounded-md shadow-none;
}
.mx-input:disabled,
@@ -39,7 +39,7 @@
}
.mx-datepicker-main {
@apply border-0 bg-n-solid-2 rounded-xl;
@apply border-0 bg-white dark:bg-slate-800;
.cell {
&.disabled {
@@ -53,14 +53,6 @@
}
}
.mx-calendar+.mx-calendar {
@apply border-l border-n-weak;
}
.mx-datepicker-footer {
@apply border border-n-weak;
}
.mx-time {
@apply border-0 bg-white dark:bg-slate-800;
+1 -173
View File
@@ -46,168 +46,8 @@
@apply hidden;
}
.n-blue-border {
@apply border-n-blue-border;
}
.n-blue-text {
@apply text-n-blue-text;
}
// scss-lint:disable PropertySortOrder
@layer base {
/* NEXT COLORS START */
: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 */
// scss-lint:disable PropertySortOrder
:root {
--color-amber-25: 254 253 251;
--color-amber-50: 255 249 237;
@@ -550,15 +390,3 @@
--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 */
}
}
@@ -31,13 +31,7 @@ hr {
ul,
ol,
dl {
@apply list-disc list-outside leading-[1.65];
}
ul:not(.reset-base),
ol:not(.reset-base),
dl:not(.reset-base) {
@apply mb-0;
@apply mb-2 list-disc list-outside leading-[1.65];
}
// Form elements
@@ -96,7 +96,7 @@ button {
}
// @TODDO - Remove after moving all buttons to woot-button
.icon + .button__content {
.icon+.button__content {
@apply w-auto;
}
@@ -1,137 +0,0 @@
<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 layout="row">
<div class="flex flex-col items-start justify-between flex-1 min-w-0 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>
<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>
</CardLayout>
</template>
@@ -1,56 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
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">
<Avatar
:name="senderName"
:src="senderThumbnailSrc"
:size="16"
rounded-full
/>
<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>
@@ -1,41 +0,0 @@
<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>
@@ -1,52 +0,0 @@
<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-[960px] 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-[960px] mx-auto py-4">
<slot name="default" />
</div>
</main>
</section>
</template>
@@ -1,212 +0,0 @@
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? Im 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! 👋 Im here for any questions you may have. Lets 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! Were 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',
},
];
@@ -1,38 +0,0 @@
<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"
:sender="campaign.sender"
:inbox="campaign.inbox"
:scheduled-at="campaign.scheduled_at"
is-live-chat-type
/>
</div>
</template>
</EmptyStateLayout>
</template>
@@ -1,37 +0,0 @@
<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"
:sender="campaign.sender"
:inbox="campaign.inbox"
:scheduled-at="campaign.scheduled_at"
/>
</div>
</template>
</EmptyStateLayout>
</template>
@@ -1,38 +0,0 @@
<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"
:sender="campaign.sender"
:inbox="campaign.inbox"
:scheduled-at="campaign.scheduled_at"
:is-live-chat-type="isLiveChatType"
@edit="handleEdit(campaign)"
@delete="handleDelete(campaign)"
/>
</div>
</template>
@@ -1,49 +0,0 @@
<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>
@@ -1,74 +0,0 @@
<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"
>
<LiveChatCampaignForm
ref="liveChatCampaignFormRef"
mode="edit"
:selected-campaign="selectedCampaign"
:show-action-buttons="false"
@submit="handleSubmit"
/>
</Dialog>
</template>
@@ -1,54 +0,0 @@
<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>
@@ -1,323 +0,0 @@
<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>
@@ -1,49 +0,0 @@
<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>
@@ -1,189 +0,0 @@
<script setup>
import { reactive, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import TagMultiSelectComboBox from 'dashboard/components-next/combobox/TagMultiSelectComboBox.vue';
const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n();
const formState = {
uiFlags: useMapGetter('campaigns/getUIFlags'),
labels: useMapGetter('labels/getLabels'),
inboxes: useMapGetter('inboxes/getSMSInboxes'),
};
const initialState = {
title: '',
message: '',
inboxId: null,
scheduledAt: null,
selectedAudience: [],
};
const state = reactive({ ...initialState });
const rules = {
title: { required, minLength: minLength(1) },
message: { required, minLength: minLength(1) },
inboxId: { required },
scheduledAt: { required },
selectedAudience: { required },
};
const v$ = useVuelidate(rules, state);
const isCreating = computed(() => formState.uiFlags.value.isCreating);
const currentDateTime = computed(() => {
// Added to disable the scheduled at field from being set to the current time
const now = new Date();
const localTime = new Date(now.getTime() - now.getTimezoneOffset() * 60000);
return localTime.toISOString().slice(0, 16);
});
const mapToOptions = (items, valueKey, labelKey) =>
items?.map(item => ({
value: item[valueKey],
label: item[labelKey],
})) ?? [];
const audienceList = computed(() =>
mapToOptions(formState.labels.value, 'id', 'title')
);
const inboxOptions = computed(() =>
mapToOptions(formState.inboxes.value, 'id', 'name')
);
const getErrorMessage = (field, errorKey) => {
const baseKey = 'CAMPAIGN.SMS.CREATE.FORM';
return v$.value[field].$error ? t(`${baseKey}.${errorKey}.ERROR`) : '';
};
const formErrors = computed(() => ({
title: getErrorMessage('title', 'TITLE'),
message: getErrorMessage('message', 'MESSAGE'),
inbox: getErrorMessage('inboxId', 'INBOX'),
scheduledAt: getErrorMessage('scheduledAt', 'SCHEDULED_AT'),
audience: getErrorMessage('selectedAudience', 'AUDIENCE'),
}));
const isSubmitDisabled = computed(() => v$.value.$invalid);
const formatToUTCString = localDateTime =>
localDateTime ? new Date(localDateTime).toISOString() : null;
const resetState = () => {
Object.assign(state, initialState);
};
const handleCancel = () => emit('cancel');
const prepareCampaignDetails = () => ({
title: state.title,
message: state.message,
inbox_id: state.inboxId,
scheduled_at: formatToUTCString(state.scheduledAt),
audience: state.selectedAudience?.map(id => ({
id,
type: 'Label',
})),
});
const handleSubmit = async () => {
const isFormValid = await v$.value.$validate();
if (!isFormValid) return;
emit('submit', prepareCampaignDetails());
resetState();
handleCancel();
};
</script>
<template>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<Input
v-model="state.title"
:label="t('CAMPAIGN.SMS.CREATE.FORM.TITLE.LABEL')"
:placeholder="t('CAMPAIGN.SMS.CREATE.FORM.TITLE.PLACEHOLDER')"
:message="formErrors.title"
:message-type="formErrors.title ? 'error' : 'info'"
/>
<TextArea
v-model="state.message"
:label="t('CAMPAIGN.SMS.CREATE.FORM.MESSAGE.LABEL')"
:placeholder="t('CAMPAIGN.SMS.CREATE.FORM.MESSAGE.PLACEHOLDER')"
show-character-count
:message="formErrors.message"
:message-type="formErrors.message ? 'error' : 'info'"
/>
<div class="flex flex-col gap-1">
<label for="inbox" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.SMS.CREATE.FORM.INBOX.LABEL') }}
</label>
<ComboBox
id="inbox"
v-model="state.inboxId"
:options="inboxOptions"
:has-error="!!formErrors.inbox"
:placeholder="t('CAMPAIGN.SMS.CREATE.FORM.INBOX.PLACEHOLDER')"
:message="formErrors.inbox"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
/>
</div>
<div class="flex flex-col gap-1">
<label for="audience" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.SMS.CREATE.FORM.AUDIENCE.LABEL') }}
</label>
<TagMultiSelectComboBox
v-model="state.selectedAudience"
:options="audienceList"
:label="t('CAMPAIGN.SMS.CREATE.FORM.AUDIENCE.LABEL')"
:placeholder="t('CAMPAIGN.SMS.CREATE.FORM.AUDIENCE.PLACEHOLDER')"
:has-error="!!formErrors.audience"
:message="formErrors.audience"
class="[&>div>button]:bg-n-alpha-black2"
/>
</div>
<Input
v-model="state.scheduledAt"
:label="t('CAMPAIGN.SMS.CREATE.FORM.SCHEDULED_AT.LABEL')"
type="datetime-local"
:min="currentDateTime"
:placeholder="t('CAMPAIGN.SMS.CREATE.FORM.SCHEDULED_AT.PLACEHOLDER')"
:message="formErrors.scheduledAt"
:message-type="formErrors.scheduledAt ? 'error' : 'info'"
/>
<div class="flex items-center justify-between w-full gap-3">
<Button
variant="faded"
color="slate"
type="button"
:label="t('CAMPAIGN.SMS.CREATE.FORM.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
:label="t('CAMPAIGN.SMS.CREATE.FORM.BUTTONS.CREATE')"
class="w-full"
type="submit"
:is-loading="isCreating"
:disabled="isCreating || isSubmitDisabled"
/>
</div>
</form>
</template>
@@ -1,32 +0,0 @@
<script setup>
defineProps({
layout: {
type: String,
default: 'col',
},
});
const emit = defineEmits(['click']);
const handleClick = () => {
emit('click');
};
</script>
<template>
<div
class="flex flex-col w-full shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2"
>
<div
class="flex w-full gap-3 px-6 py-5"
:class="
layout === 'col' ? 'flex-col' : 'flex-row justify-between items-center'
"
@click="handleClick"
>
<slot />
</div>
<slot name="after" />
</div>
</template>
@@ -1,102 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const props = defineProps({
id: { type: Number, required: true },
name: { type: String, default: '' },
email: { type: String, default: '' },
additionalAttributes: { type: Object, default: () => ({}) },
phoneNumber: { type: String, default: '' },
thumbnail: { type: String, default: '' },
isExpanded: { type: Boolean, default: false },
});
const emit = defineEmits(['toggle', 'updateContact', 'showContact']);
const { t } = useI18n();
const handleFormUpdate = updatedData => {
emit('updateContact', { id: props.id, updatedData });
};
const onClickViewDetails = async () => {
emit('showContact', props.id);
};
</script>
<template>
<CardLayout :key="id" layout="row">
<div class="flex items-center justify-between gap-4">
<Avatar :name="name" :src="thumbnail" :size="48" rounded-full />
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<span class="text-sm font-medium truncate text-n-slate-12">
{{ name }}
</span>
<template v-if="additionalAttributes?.companyName">
<span class="text-sm text-n-slate-11">
{{ t('CONTACTS_LAYOUT.CARD.OF') }}
</span>
<span class="text-sm font-medium truncate text-n-slate-12">
{{ additionalAttributes.companyName }}
</span>
</template>
</div>
<div class="flex items-center gap-3">
<span v-if="email" class="text-sm text-n-slate-11">{{ email }}</span>
<div v-if="email" class="w-px h-3 bg-n-slate-6" />
<span v-if="phoneNumber" class="text-sm text-n-slate-11">
{{ phoneNumber }}
</span>
<div v-if="phoneNumber" class="w-px h-3 bg-n-slate-6" />
<Button
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
variant="link"
size="xs"
@click="onClickViewDetails"
/>
</div>
</div>
</div>
<Button
icon="i-lucide-chevron-down"
variant="ghost"
color="slate"
size="xs"
:class="{ 'rotate-180': isExpanded }"
@click="emit('toggle')"
/>
<template #after>
<transition
enter-active-class="overflow-hidden transition-all duration-300 ease-out"
leave-active-class="overflow-hidden transition-all duration-300 ease-in"
enter-from-class="overflow-hidden opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-[360px]"
leave-from-class="opacity-100 max-h-[360px]"
leave-to-class="overflow-hidden opacity-0 max-h-0"
>
<div v-show="isExpanded" class="w-full">
<div class="p-6 border-t border-n-strong">
<ContactsForm
:contact-data="{
id,
name,
email,
phoneNumber,
additionalAttributes,
}"
@update="handleFormUpdate"
/>
</div>
</div>
</transition>
</template>
</CardLayout>
</template>
@@ -1,67 +0,0 @@
<script setup>
import { ref } from 'vue';
import ContactsCard from '../ContactsCard.vue';
import contacts from './fixtures';
const expandedCardId = ref(null);
const toggleExpanded = id => {
expandedCardId.value = expandedCardId.value === id ? null : id;
};
</script>
<template>
<Story
title="Components/Contacts/ContactsCard"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Default with expandable function">
<div class="flex flex-col p-4">
<ContactsCard
v-bind="contacts[0]"
:is-expanded="expandedCardId === contacts[0].id"
@toggle="toggleExpanded(contacts[0].id)"
@update-contact="() => {}"
@show-contact="() => {}"
/>
</div>
</Variant>
<Variant title="With Company Name and without phone number">
<div class="flex flex-col p-4">
<ContactsCard
v-bind="{ ...contacts[1], phoneNumber: '' }"
:is-expanded="false"
@toggle="() => {}"
@update-contact="() => {}"
@show-contact="() => {}"
/>
</div>
</Variant>
<Variant title="Expanded State">
<div class="flex flex-col p-4">
<ContactsCard
v-bind="contacts[2]"
is-expanded
@toggle="() => {}"
@update-contact="() => {}"
@show-contact="() => {}"
/>
</div>
</Variant>
<Variant title="Without Email and Phone">
<div class="flex flex-col p-4">
<ContactsCard
v-bind="{ ...contacts[3], email: '', phoneNumber: '' }"
:is-expanded="false"
@toggle="() => {}"
@update-contact="() => {}"
@show-contact="() => {}"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,149 +0,0 @@
export default [
{
additionalAttributes: {
socialProfiles: {},
},
availabilityStatus: null,
email: 'johndoe@chatwoot.com',
id: 370,
name: 'John Doe',
phoneNumber: '+918634322418',
identifier: null,
thumbnail: 'https://api.dicebear.com/9.x/thumbs/svg?seed=Felix',
customAttributes: {},
lastActivityAt: 1731608270,
createdAt: 1731586271,
},
{
additionalAttributes: {
city: 'kerala',
country: 'India',
description: 'Curious about the web. ',
companyName: 'Chatwoot',
countryCode: '',
socialProfiles: {
github: 'abozler',
twitter: 'ozler',
facebook: 'abozler',
linkedin: 'abozler',
instagram: 'ozler',
},
},
availabilityStatus: null,
email: 'ozler@chatwoot.com',
id: 29,
name: 'Abraham Ozlers',
phoneNumber: '+246232222222',
identifier: null,
thumbnail: 'https://api.dicebear.com/9.x/thumbs/svg?seed=Upload',
customAttributes: {
dateContact: '2024-02-01T00:00:00.000Z',
linkContact: 'https://staging.chatwoot.com/app/accounts/3/contacts-new',
listContact: 'Not spam',
numberContact: '12',
},
lastActivityAt: 1712127410,
createdAt: 1712127389,
},
{
additionalAttributes: {
city: 'Kerala',
country: 'India',
description:
"I'm Candice developer focusing on building things for the web 🌍. Currently, Im working as a Product Developer here at @chatwootapp ⚡️🔥",
companyName: 'Chatwoot',
countryCode: 'IN',
socialProfiles: {
github: 'cmathersonj',
twitter: 'cmather',
facebook: 'cmathersonj',
linkedin: 'cmathersonj',
instagram: 'cmathersonjs',
},
},
availabilityStatus: null,
email: 'cmathersonj@va.test',
id: 22,
name: 'Candice Matherson',
phoneNumber: '+917474774742',
identifier: null,
thumbnail: 'https://api.dicebear.com/9.x/thumbs/svg?seed=Emery',
customAttributes: {
dateContact: '2024-11-12T03:23:06.963Z',
linkContact: 'https://sd.sd',
textContact: 'hey',
numberContact: '12',
checkboxContact: true,
},
lastActivityAt: 1712123233,
createdAt: 1712123233,
},
{
additionalAttributes: {
city: '',
country: '',
description: '',
companyName: '',
countryCode: '',
socialProfiles: {
github: '',
twitter: '',
facebook: '',
linkedin: '',
instagram: '',
},
},
availabilityStatus: null,
email: 'ofolkardi@taobao.test',
id: 21,
name: 'Ophelia Folkard',
phoneNumber: '',
identifier: null,
thumbnail:
'https://sivin-tunnel.chatwoot.dev/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBPZz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--08dcac8eb72ef12b2cad92d58dddd04cd8a5f513/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJYW5CbkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--df796c2af3c0153e55236c2f3cf3a199ac2cb6f7/32.jpg',
customAttributes: {},
lastActivityAt: 1712123233,
createdAt: 1712123233,
},
{
additionalAttributes: {
socialProfiles: {},
},
availabilityStatus: null,
email: 'wcasteloth@exblog.jp',
id: 20,
name: 'Willy Castelot',
phoneNumber: '+919384',
identifier: null,
thumbnail: 'https://api.dicebear.com/9.x/thumbs/svg?seed=Jade',
customAttributes: {},
lastActivityAt: 1712123233,
createdAt: 1712123233,
},
{
additionalAttributes: {
city: '',
country: '',
description: '',
companyName: '',
countryCode: '',
socialProfiles: {
github: '',
twitter: '',
facebook: '',
linkedin: '',
instagram: '',
},
},
availabilityStatus: null,
email: 'ederingtong@printfriendly.test',
id: 19,
name: 'Elisabeth Derington',
phoneNumber: '',
identifier: null,
thumbnail: 'https://api.dicebear.com/9.x/avataaars/svg?seed=Jade',
customAttributes: {},
lastActivityAt: 1712123232,
createdAt: 1712123232,
},
];
@@ -1,112 +0,0 @@
<script setup>
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import { useI18n } from 'vue-i18n';
defineProps({
selectedContact: {
type: Object,
required: true,
},
primaryContactId: {
type: [Number, null],
default: null,
},
primaryContactList: {
type: Array,
default: () => [],
},
isSearching: {
type: Boolean,
default: false,
},
hasError: {
type: Boolean,
default: false,
},
errorMessage: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:primaryContactId', 'search']);
const { t } = useI18n();
</script>
<template>
<div class="flex flex-col">
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between h-5 gap-2">
<label class="text-sm text-n-slate-12">
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PRIMARY') }}
</label>
<span
class="flex items-center justify-center w-24 h-5 text-xs rounded-md text-n-teal-11 bg-n-alpha-2"
>
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PRIMARY_HELP_LABEL') }}
</span>
</div>
<ComboBox
id="inbox"
:model-value="primaryContactId"
:options="primaryContactList"
:empty-state="
isSearching
? t('CONTACTS_LAYOUT.SIDEBAR.MERGE.IS_SEARCHING')
: t('CONTACTS_LAYOUT.SIDEBAR.MERGE.EMPTY_STATE')
"
:search-placeholder="
t('CONTACTS_LAYOUT.SIDEBAR.MERGE.SEARCH_PLACEHOLDER')
"
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PLACEHOLDER')"
:has-error="hasError"
:message="errorMessage"
class="[&>div>button]:bg-n-alpha-black2"
@update:model-value="value => emit('update:primaryContactId', value)"
@search="query => emit('search', query)"
/>
</div>
<div class="relative flex justify-center gap-2 top-4">
<div v-for="i in 3" :key="i" class="relative w-4 h-8">
<div
class="absolute w-0 h-0 border-l-[4px] border-r-[4px] border-b-[6px] border-l-transparent border-r-transparent border-n-strong ltr:translate-x-[4px] rtl:-translate-x-[4px] -translate-y-[4px]"
/>
<div
class="absolute w-[1px] h-full bg-n-strong left-1/2 transform -translate-x-1/2"
/>
</div>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between h-5 gap-2">
<label class="text-sm text-n-slate-12">
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PARENT') }}
</label>
<span
class="flex items-center justify-center w-24 h-5 text-xs rounded-md text-n-ruby-11 bg-n-alpha-2"
>
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PARENT_HELP_LABEL') }}
</span>
</div>
<div
class="border border-n-strong h-[60px] gap-2 flex items-center rounded-xl p-3"
>
<Avatar
:name="selectedContact.name || ''"
:src="selectedContact.thumbnail || ''"
:size="32"
rounded-full
/>
<div class="flex flex-col gap-1">
<span class="text-sm leading-4 truncate text-n-slate-11">
{{ selectedContact.name }}
</span>
<span class="text-sm leading-4 truncate text-n-slate-11">
{{ selectedContact.email }}
</span>
</div>
</div>
</div>
</div>
</template>
@@ -1,297 +0,0 @@
<script setup>
import { computed, reactive, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { required, email, minLength } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import countries from 'shared/constants/countries.js';
import Input from 'dashboard/components-next/input/Input.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import PhoneNumberInput from 'dashboard/components-next/phonenumberinput/PhoneNumberInput.vue';
const props = defineProps({
contactData: {
type: Object,
default: null,
},
isDetailsView: {
type: Boolean,
default: false,
},
isNewContact: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update']);
const { t } = useI18n();
const FORM_CONFIG = {
FIRST_NAME: { field: 'firstName' },
LAST_NAME: { field: 'lastName' },
EMAIL_ADDRESS: { field: 'email' },
PHONE_NUMBER: { field: 'phoneNumber' },
CITY: { field: 'additionalAttributes.city' },
COUNTRY: { field: 'additionalAttributes.country' },
BIO: { field: 'additionalAttributes.description' },
COMPANY_NAME: { field: 'additionalAttributes.companyName' },
};
const SOCIAL_CONFIG = {
FACEBOOK: 'i-ri-facebook-circle-fill',
GITHUB: 'i-ri-github-fill',
INSTAGRAM: 'i-ri-instagram-line',
LINKEDIN: 'i-ri-linkedin-box-fill',
TWITTER: 'i-ri-twitter-x-fill',
};
const defaultState = {
id: 0,
name: '',
email: '',
firstName: '',
lastName: '',
phoneNumber: '',
additionalAttributes: {
description: '',
companyName: '',
countryCode: '',
country: '',
city: '',
socialProfiles: {
facebook: '',
github: '',
instagram: '',
linkedin: '',
twitter: '',
},
},
};
const state = reactive({ ...defaultState });
const validationRules = {
firstName: { required, minLength: minLength(2) },
email: { email },
};
const v$ = useVuelidate(validationRules, state);
const prepareStateBasedOnProps = () => {
if (props.isNewContact) {
return; // Added to prevent state update for new contact form
}
const {
id,
name = '',
email: emailAddress,
phoneNumber,
additionalAttributes = {},
} = props.contactData || {};
const [firstName = '', lastName = ''] = name.split(' ');
const {
description,
companyName,
countryCode,
country,
city,
socialProfiles = {},
} = additionalAttributes || {};
Object.assign(state, {
id,
name,
firstName,
lastName,
email: emailAddress,
phoneNumber,
additionalAttributes: {
description,
companyName,
countryCode,
country,
city,
socialProfiles,
},
});
};
const countryOptions = computed(() =>
countries.map(({ name }) => ({ label: name, value: name }))
);
const editDetailsForm = computed(() =>
Object.keys(FORM_CONFIG).map(key => ({
key,
placeholder: t(
`CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.FORM.${key}.PLACEHOLDER`
),
}))
);
const socialProfilesForm = computed(() =>
Object.entries(SOCIAL_CONFIG).map(([key, icon]) => ({
key,
placeholder: t(`CONTACTS_LAYOUT.CARD.SOCIAL_MEDIA.FORM.${key}.PLACEHOLDER`),
icon,
}))
);
const isValidationField = key => {
const field = FORM_CONFIG[key]?.field;
return ['firstName', 'email'].includes(field);
};
const getValidationKey = key => {
return FORM_CONFIG[key]?.field;
};
// Creates a computed property for two-way form field binding
const getFormBinding = key => {
const field = FORM_CONFIG[key]?.field;
if (!field) return null;
return computed({
get: () => {
// Handle firstName/lastName fields
if (field === 'firstName' || field === 'lastName') {
return state[field]?.toString() || '';
}
// Handle nested vs non-nested fields
const [base, nested] = field.split('.');
// Example: 'email' → state.email
// Example: 'additionalAttributes.city' → state.additionalAttributes.city
return (nested ? state[base][nested] : state[base])?.toString() || '';
},
set: async value => {
// Handle name fields specially to maintain the combined 'name' field
if (field === 'firstName' || field === 'lastName') {
state[field] = value;
// Example: firstName="John", lastName="Doe" → name="John Doe"
state.name = `${state.firstName} ${state.lastName}`.trim();
} else {
// Handle nested vs non-nested fields
const [base, nested] = field.split('.');
if (nested) {
// Example: additionalAttributes.city = "New York"
state[base][nested] = value;
} else {
// Example: email = "test@example.com"
state[base] = value;
}
}
const isFormValid = await v$.value.$validate();
if (isFormValid) {
const { firstName, lastName, ...stateWithoutNames } = state;
emit('update', stateWithoutNames);
}
},
});
};
const getMessageType = key => {
return isValidationField(key) && v$.value[getValidationKey(key)]?.$error
? 'error'
: 'info';
};
watch(() => props.contactData, prepareStateBasedOnProps, {
immediate: true,
deep: true,
});
// Expose state to parent component for avatar upload
defineExpose({
state,
});
</script>
<template>
<div class="flex flex-col gap-6">
<div class="flex flex-col items-start gap-2">
<span class="py-1 text-sm font-medium text-n-slate-12">
{{ t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.TITLE') }}
</span>
<div class="grid w-full grid-cols-2 gap-4">
<template v-for="item in editDetailsForm" :key="item.key">
<ComboBox
v-if="item.key === 'COUNTRY'"
v-model="state.additionalAttributes.country"
:options="countryOptions"
:placeholder="item.placeholder"
class="[&>div>button]:h-8"
:class="{
'[&>div>button]:bg-n-alpha-black2 [&>div>button]:!outline-transparent':
!isDetailsView,
'[&>div>button]:!outline-n-weak [&>div>button]:hover:!outline-n-strong [&>div>button]:!bg-n-alpha-black2':
isDetailsView,
}"
@update:model-value="emit('update', state)"
/>
<PhoneNumberInput
v-else-if="item.key === 'PHONE_NUMBER'"
v-model="getFormBinding(item.key).value"
:placeholder="item.placeholder"
:show-border="isDetailsView"
/>
<Input
v-else
v-model="getFormBinding(item.key).value"
:placeholder="item.placeholder"
:message-type="getMessageType(item.key)"
:custom-input-class="`h-8 !pt-1 !pb-1 ${
!isDetailsView ? '[&:not(.error)]:!border-transparent' : ''
}`"
class="w-full"
@input="
isValidationField(item.key) &&
v$[getValidationKey(item.key)].$touch()
"
@blur="
isValidationField(item.key) &&
v$[getValidationKey(item.key)].$touch()
"
/>
</template>
</div>
</div>
<div class="flex flex-col items-start gap-2">
<span class="py-1 text-sm font-medium text-n-slate-12">
{{ t('CONTACTS_LAYOUT.CARD.SOCIAL_MEDIA.TITLE') }}
</span>
<div class="flex flex-wrap gap-2">
<div
v-for="item in socialProfilesForm"
:key="item.key"
class="flex items-center h-8 gap-2 px-2 rounded-lg"
:class="{
'bg-n-alpha-2 dark:bg-n-solid-2': isDetailsView,
'bg-n-alpha-2 dark:bg-n-solid-3': !isDetailsView,
}"
>
<Icon
:icon="item.icon"
class="flex-shrink-0 text-n-slate-11 size-4"
/>
<input
v-model="
state.additionalAttributes.socialProfiles[item.key.toLowerCase()]
"
class="w-auto min-w-[100px] text-sm bg-transparent reset-base text-n-slate-12 dark:text-n-slate-12 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10"
:placeholder="item.placeholder"
:size="item.placeholder.length"
@input="emit('update', state)"
/>
</div>
</div>
</div>
</div>
</template>
@@ -1,73 +0,0 @@
<script setup>
import ContactMergeForm from '../ContactMergeForm.vue';
import { contactData, primaryContactList } from './fixtures';
const handleSearch = query => {
console.log('Searching for:', query);
};
const handleUpdate = value => {
console.log('Primary contact updated:', value);
};
</script>
<template>
<Story
title="Components/Contacts/ContactMergeForm"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Default">
<div class="p-6 border rounded-lg border-n-strong">
<ContactMergeForm
:selected-contact="contactData"
:primary-contact-list="primaryContactList"
:primary-contact-id="null"
:is-searching="false"
@update:primary-contact-id="handleUpdate"
@search="handleSearch"
/>
</div>
</Variant>
<Variant title="With Selected Primary Contact">
<div class="p-6 border rounded-lg border-n-strong">
<ContactMergeForm
:selected-contact="contactData"
:primary-contact-list="primaryContactList"
:primary-contact-id="1"
:is-searching="false"
@update:primary-contact-id="handleUpdate"
@search="handleSearch"
/>
</div>
</Variant>
<Variant title="Error State">
<div class="p-6 border rounded-lg border-n-strong">
<ContactMergeForm
:selected-contact="contactData"
:primary-contact-list="primaryContactList"
:primary-contact-id="null"
:is-searching="false"
has-error
error-message="Please select a primary contact"
@update:primary-contact-id="handleUpdate"
@search="handleSearch"
/>
</div>
</Variant>
<Variant title="Empty Primary Contact List">
<div class="p-6 border rounded-lg border-n-strong">
<ContactMergeForm
:selected-contact="contactData"
:primary-contact-list="[]"
:primary-contact-id="null"
:is-searching="false"
@update:primary-contact-id="handleUpdate"
@search="handleSearch"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,80 +0,0 @@
<script setup>
import ContactsForm from '../ContactsForm.vue';
import { contactData } from './fixtures';
const handleUpdate = updatedData => {
console.log('Form updated:', updatedData);
};
</script>
<template>
<Story
title="Components/Contacts/ContactsForm"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Default without border">
<div class="p-6 border rounded-lg border-n-strong">
<ContactsForm :contact-data="contactData" @update="handleUpdate" />
</div>
</Variant>
<Variant title="Details View with border">
<div class="p-6 border rounded-lg border-n-strong">
<ContactsForm
:contact-data="contactData"
is-details-view
@update="handleUpdate"
/>
</div>
</Variant>
<Variant title="Minimal Data">
<div class="p-6 border rounded-lg border-n-strong">
<ContactsForm
:contact-data="{
id: 21,
name: 'Ophelia Folkard',
email: 'ofolkardi@taobao.test',
phoneNumber: '',
additionalAttributes: {
city: '',
country: '',
description: '',
companyName: '',
countryCode: '',
socialProfiles: {
github: '',
twitter: '',
facebook: '',
linkedin: '',
instagram: '',
},
},
}"
@update="handleUpdate"
/>
</div>
</Variant>
<Variant title="With All Social Profiles">
<div class="p-6 border rounded-lg border-n-strong">
<ContactsForm
:contact-data="{
...contactData,
additionalAttributes: {
...contactData.additionalAttributes,
socialProfiles: {
github: 'cmathersonj',
twitter: 'cmather',
facebook: 'cmathersonj',
linkedin: 'cmathersonj',
instagram: 'cmathersonjs',
},
},
}"
@update="handleUpdate"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,47 +0,0 @@
export const contactData = {
id: 370,
name: 'John Doe',
email: 'johndoe@chatwoot.com',
phoneNumber: '+918634322418',
additionalAttributes: {
city: 'Kerala',
country: 'India',
description: 'Curious about the web.',
companyName: 'Chatwoot',
countryCode: 'IN',
socialProfiles: {
github: 'johndoe',
twitter: 'johndoe',
facebook: 'johndoe',
linkedin: 'johndoe',
instagram: 'johndoe',
},
},
};
export const primaryContactList = [
{
id: 1,
name: 'Jane Smith',
email: 'jane@chatwoot.com',
thumbnail: '',
label: '(ID: 1) Jane Smith',
value: 1,
},
{
id: 2,
name: 'Mike Johnson',
email: 'mike@chatwoot.com',
thumbnail: '',
label: '(ID: 2) Mike Johnson',
value: 2,
},
{
id: 3,
name: 'Sarah Wilson',
email: 'sarah@chatwoot.com',
thumbnail: '',
label: '(ID: 3) Sarah Wilson',
value: 3,
},
];
@@ -1,97 +0,0 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import ContactSortMenu from './components/ContactSortMenu.vue';
import ContactMoreActions from './components/ContactMoreActions.vue';
defineProps({
showSearch: {
type: Boolean,
default: true,
},
searchValue: {
type: String,
default: '',
},
headerTitle: {
type: String,
required: true,
},
buttonLabel: {
type: String,
required: true,
},
activeSort: {
type: String,
default: 'last_activity_at',
},
activeOrdering: {
type: String,
default: '',
},
});
const emit = defineEmits([
'search',
'filter',
'update:sort',
'message',
'add',
'import',
'export',
]);
</script>
<template>
<header class="sticky top-0 z-10 px-6 xl:px-0">
<div
class="flex items-center justify-between w-full h-20 gap-2 mx-auto max-w-[960px]"
>
<span class="text-xl font-medium truncate text-n-slate-12">
{{ headerTitle }}
</span>
<div class="flex items-center flex-shrink-0 gap-4">
<div v-if="showSearch" class="flex items-center gap-2">
<Input
:model-value="searchValue"
type="search"
:placeholder="$t('CONTACTS_LAYOUT.HEADER.SEARCH_PLACEHOLDER')"
:custom-input-class="[
'h-8 [&:not(.focus)]:!border-transparent bg-n-alpha-2 dark:bg-n-solid-1 ltr:!pl-8 !py-1 rtl:!pr-8',
]"
@input="emit('search', $event.target.value)"
>
<template #prefix>
<Icon
icon="i-lucide-search"
class="absolute -translate-y-1/2 text-n-slate-11 size-4 top-1/2 ltr:left-2 rtl:right-2"
/>
</template>
</Input>
</div>
<div class="flex items-center gap-2">
<Button
icon="i-lucide-list-filter"
color="slate"
size="sm"
variant="ghost"
@click="emit('filter')"
/>
<ContactSortMenu
:active-sort="activeSort"
:active-ordering="activeOrdering"
@update:sort="emit('update:sort', $event)"
/>
<ContactMoreActions
@add="emit('add')"
@import="emit('import')"
@export="emit('export')"
/>
</div>
<div class="w-px h-4 bg-n-strong" />
<Button :label="buttonLabel" size="sm" @click="emit('message')" />
</div>
</div>
</header>
</template>
@@ -1,62 +0,0 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const emit = defineEmits(['add', 'import', 'export']);
const { t } = useI18n();
const contactMenuItems = [
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.ADD_CONTACT'),
action: 'add',
value: 'add',
icon: 'i-lucide-plus',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.EXPORT_CONTACT'),
action: 'export',
value: 'export',
icon: 'i-lucide-upload',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.IMPORT_CONTACT'),
action: 'import',
value: 'import',
icon: 'i-lucide-download',
},
];
const showActionsDropdown = ref(false);
const handleContactAction = ({ action }) => {
if (action === 'add') {
emit('add');
} else if (action === 'import') {
emit('import');
} else if (action === 'export') {
emit('export');
}
};
</script>
<template>
<div v-on-clickaway="() => (showActionsDropdown = false)" class="relative">
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
variant="ghost"
size="sm"
:class="showActionsDropdown ? 'bg-n-alpha-2' : ''"
@click="showActionsDropdown = !showActionsDropdown"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="contactMenuItems"
class="right-0 mt-1 w-52 top-full"
@action="handleContactAction($event)"
/>
</div>
</template>
@@ -1,138 +0,0 @@
<script setup>
import { ref, computed, toRef } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import SelectMenu from 'dashboard/components-next/selectmenu/SelectMenu.vue';
const props = defineProps({
activeSort: {
type: String,
default: 'last_activity_at',
},
activeOrdering: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:sort']);
const { t } = useI18n();
const isMenuOpen = ref(false);
const sortMenus = [
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.NAME'),
value: 'name',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.EMAIL'),
value: 'email',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.PHONE_NUMBER'),
value: 'phone_number',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.COMPANY'),
value: 'company_name',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.COUNTRY'),
value: 'country',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.CITY'),
value: 'city',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.LAST_ACTIVITY'),
value: 'last_activity_at',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.CREATED_AT'),
value: 'created_at',
},
];
const orderingMenus = [
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.ORDER.OPTIONS.ASCENDING'),
value: '',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.ORDER.OPTIONS.DESCENDING'),
value: '-',
},
];
// Converted the props to refs for better reactivity
const activeSort = toRef(props, 'activeSort');
const activeOrdering = toRef(props, 'activeOrdering');
const activeSortLabel = computed(() => {
const selectedMenu = sortMenus.find(menu => menu.value === activeSort.value);
return (
selectedMenu?.label || t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.LABEL')
);
});
const activeOrderingLabel = computed(() => {
const selectedMenu = orderingMenus.find(
menu => menu.value === activeOrdering.value
);
return selectedMenu?.label || t('CONTACTS_LAYOUT.HEADER.ACTIONS.ORDER.LABEL');
});
const handleSortChange = value => {
emit('update:sort', { sort: value, order: props.activeOrdering });
};
const handleOrderChange = value => {
emit('update:sort', { sort: props.activeSort, order: value });
};
</script>
<template>
<div class="relative">
<Button
icon="i-lucide-arrow-down-up"
color="slate"
size="sm"
variant="ghost"
:class="isMenuOpen ? 'bg-n-alpha-2' : ''"
@click="isMenuOpen = !isMenuOpen"
/>
<div
v-if="isMenuOpen"
v-on-clickaway="() => (isMenuOpen = false)"
class="absolute top-full mt-1 right-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-n-slate-12">
{{ t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.LABEL') }}
</span>
<SelectMenu
:model-value="activeSort"
:options="sortMenus"
:label="activeSortLabel"
@update:model-value="handleSortChange"
/>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-n-slate-12">
{{ t('CONTACTS_LAYOUT.HEADER.ACTIONS.ORDER.LABEL') }}
</span>
<SelectMenu
:model-value="activeOrdering"
:options="orderingMenus"
:label="activeOrderingLabel"
@update:model-value="handleOrderChange"
/>
</div>
</div>
</div>
</template>
@@ -1,106 +0,0 @@
<script setup>
import { ref } from 'vue';
import ContactHeader from '../ContactHeader.vue';
// Base state controls
const searchValue = ref('');
const activeSort = ref('last_activity_at');
const activeOrdering = ref('');
const onSearch = value => {
searchValue.value = value;
console.log('🔍 Search:', value);
};
const onSort = ({ sort, order }) => {
activeSort.value = sort;
activeOrdering.value = order;
console.log('🔄 Sort changed:', { sort, order });
};
const onFilter = () => {
console.log('🏷️ Filter clicked');
};
const onMessage = () => {
console.log('💬 Message clicked');
};
const onAdd = () => {
console.log(' Add contact clicked');
};
const onImport = () => {
console.log('📥 Import contacts clicked');
};
const onExport = () => {
console.log('📤 Export contacts clicked');
};
</script>
<template>
<Story
title="Components/Contacts/ContactHeader"
:layout="{ type: 'grid', width: '900px' }"
>
<Variant title="Default">
<div class="w-full h-[400px]">
<ContactHeader
header-title="Contacts"
button-label="Message"
:search-value="searchValue"
:active-sort="activeSort"
:active-ordering="activeOrdering"
@search="onSearch"
@filter="onFilter"
@update:sort="onSort"
@message="onMessage"
@add="onAdd"
@import="onImport"
@export="onExport"
/>
</div>
</Variant>
<Variant title="Empty State">
<div class="w-full">
<ContactHeader
:show-search="false"
header-title="Contacts"
button-label="Message"
:search-value="searchValue"
:active-sort="activeSort"
:active-ordering="activeOrdering"
@search="onSearch"
@filter="onFilter"
@update:sort="onSort"
@message="onMessage"
@add="onAdd"
@import="onImport"
@export="onExport"
/>
</div>
</Variant>
<Variant title="Segment View">
<div class="w-full">
<ContactHeader
:show-search="false"
header-title="Segment: VIP Customers"
button-label="Message"
:search-value="searchValue"
:active-sort="activeSort"
:active-ordering="activeOrdering"
@search="onSearch"
@filter="onFilter"
@update:sort="onSort"
@message="onMessage"
@add="onAdd"
@import="onImport"
@export="onExport"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,63 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
note: {
type: Object,
required: true,
},
writtenBy: {
type: String,
required: true,
},
});
const emit = defineEmits(['delete']);
const { t } = useI18n();
const { formatMessage } = useMessageFormatter();
const handleDelete = () => {
emit('delete', props.note.id);
};
</script>
<template>
<div
class="flex flex-col gap-2 px-6 py-2 border-b border-n-strong group/note"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5 py-2.5 min-w-0">
<Avatar
:name="note.user.name"
:src="note.user.thumbnail"
:size="16"
rounded-full
/>
<div class="min-w-0 truncate">
<span class="inline-flex items-center gap-1 text-sm text-n-slate-11">
<span class="font-medium">{{ writtenBy }}</span>
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.WROTE') }}
<span class="font-medium">{{ dynamicTime(note.createdAt) }}</span>
</span>
</div>
</div>
<Button
variant="faded"
color="ruby"
size="xs"
icon="i-lucide-trash"
class="opacity-0 group-hover/note:opacity-100"
@click="handleDelete"
/>
</div>
<p
v-dompurify-html="formatMessage(note.content || '')"
class="mb-0 prose-sm prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
/>
</div>
</template>
@@ -1,39 +0,0 @@
<script setup>
import ContactNoteItem from '../ContactNoteItem.vue';
import notes from './fixtures';
const controls = {
writtenBy: {
type: 'text',
default: 'You',
},
};
// Example delete handler
const onDelete = noteId => {
console.log('Note deleted:', noteId);
};
</script>
<template>
<Story
title="Components/Contacts/ContactNoteItem"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Multiple Notes">
<div class="flex flex-col border rounded-lg border-n-strong">
<ContactNoteItem
v-for="note in notes"
:key="note.id"
:note="note"
:written-by="
note.id === notes[1].id
? controls.writtenBy.default
: note.user.name
"
@delete="onDelete"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,69 +0,0 @@
export default [
{
id: 12,
content:
'This tutorial will show you how to use Chatwoot and, hence, ensure you practice effective customer communication. We will explain in detail the following:\n\n* Step-by-step setup of your account, with illustrative screenshots.\n\n* An in-depth explanation of all the core features of Chatwoot.\n\n* Get your account up and running by the end of this tutorial.\n\n* Basic concepts of customer communication.',
accountId: null,
contactId: null,
user: {
id: 30,
account_id: 2,
availability_status: 'offline',
auto_offline: true,
confirmed: true,
email: 'bruce@paperlayer.test',
available_name: 'Bruce',
name: 'Bruce',
role: 'administrator',
thumbnail:
'https://sivin-tunnel.chatwoot.dev/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBJZz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--515dbb35e9ba3c36d14f4c4b77220a675513c1fb/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJYW5CbkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--df796c2af3c0153e55236c2f3cf3a199ac2cb6f7/2.jpg',
custom_role_id: null,
},
createdAt: 1730786556,
updatedAt: 1730786556,
},
{
id: 10,
content:
'We discussed a couple of things:\n\n* Product offering and how it can be useful to talk with people.\n\n* Theyll reach out to us after an internal review.',
accountId: null,
contactId: null,
user: {
id: 1,
account_id: 2,
availability_status: 'online',
auto_offline: false,
confirmed: true,
email: 'hillary@chatwoot.com',
available_name: 'Hillary',
name: 'Hillary',
role: 'administrator',
thumbnail: '',
custom_role_id: null,
},
createdAt: 1730782566,
updatedAt: 1730782566,
},
{
id: 9,
content:
'We discussed a couple of things:\n\n* Product offering and how it can be useful to talk with people.\n\n* Theyll reach out to us after an internal review.',
accountId: null,
contactId: null,
user: {
id: 1,
account_id: 2,
availability_status: 'online',
auto_offline: false,
confirmed: true,
email: 'john@chatwoot.com',
available_name: 'John',
name: 'John',
role: 'administrator',
thumbnail: '',
custom_role_id: null,
},
createdAt: 1730782564,
updatedAt: 1730782564,
},
];
@@ -1,28 +0,0 @@
<script setup>
import ContactEmptyState from './ContactEmptyState.vue';
</script>
<template>
<Story
title="Components/Contacts/EmptyState"
:layout="{ type: 'grid', width: '900px' }"
>
<!-- Default Story -->
<Variant title="Default">
<ContactEmptyState
title="No contacts found"
subtitle="Create your first contact to get started"
button-label="Add Contact"
/>
</Variant>
<!-- Without Button -->
<Variant title="Without Button">
<ContactEmptyState
title="No contacts"
subtitle="These are your current contacts"
:show-button="false"
/>
</Variant>
</Story>
</template>
@@ -1,51 +0,0 @@
<script setup>
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue';
import contactContent from 'dashboard/components-next/Contacts/EmptyState/contactEmptyStateContent';
defineProps({
title: {
type: String,
default: '',
},
subtitle: {
type: String,
default: '',
},
showButton: {
type: Boolean,
default: true,
},
buttonLabel: {
type: String,
default: '',
},
});
</script>
<template>
<EmptyStateLayout :title="title" :subtitle="subtitle">
<template #empty-state-item>
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
<ContactsCard
v-for="contact in contactContent.slice(0, 5)"
:id="contact.id"
:key="contact.id"
:name="contact.name"
:email="contact.email"
:thumbnail="contact.thumbnail"
:phone-number="contact.phoneNumber"
:additional-attributes="contact.additionalAttributes"
:is-expanded="0 === contact.id"
@toggle="toggleExpanded(contact.id)"
/>
</div>
</template>
<template #actions>
<div v-if="showButton">
<Button :label="buttonLabel" icon="i-lucide-plus" @click="onClick" />
</div>
</template>
</EmptyStateLayout>
</template>
@@ -1,228 +0,0 @@
export default [
{
additionalAttributes: {
city: 'Los Angeles',
country: 'United States',
description:
"I'm Candice, a developer focusing on building web solutions. Currently, Im working as a Product Developer at Chatwoot.",
companyName: 'Chatwoot',
countryCode: 'US',
socialProfiles: {
github: 'candice-dev',
twitter: 'candice_w_dev',
facebook: 'candice.dev',
linkedin: 'candice-matherson',
instagram: 'candice.codes',
},
},
availabilityStatus: 'offline',
email: 'candice.matherson@chatwoot.com',
id: 22,
name: 'Candice Matherson',
phoneNumber: '+14155552671',
identifier: null,
thumbnail: '',
customAttributes: {
dateContact: '2024-11-11T11:53:09.299Z',
linkContact: 'https://example.com',
listContact: 'Follow-Up',
textContact: 'Hi there!',
numberContact: '42',
checkboxContact: false,
},
lastActivityAt: 1712123233,
createdAt: 1712123233,
},
{
additionalAttributes: {
city: 'San Francisco',
country: 'United States',
description: 'Passionate about design and user experience.',
companyName: 'Designify',
countryCode: 'US',
socialProfiles: {
github: 'ophelia-folkard',
twitter: 'oph_designs',
facebook: 'ophelia.folkard',
linkedin: 'ophelia-folkard',
instagram: 'ophelia.design',
},
},
availabilityStatus: 'offline',
email: 'ophelia.folkard@designify.com',
id: 21,
name: 'Ophelia Folkard',
phoneNumber: '+14155552672',
identifier: null,
thumbnail: '',
customAttributes: {
dateContact: '2024-10-05T10:12:34.567Z',
linkContact: 'https://designify.com',
listContact: 'Prospects',
textContact: 'Looking forward to connecting!',
},
lastActivityAt: 1712123233,
createdAt: 1712123233,
},
{
additionalAttributes: {
city: 'Austin',
country: 'United States',
description: 'Avid coder and tech enthusiast.',
companyName: 'CodeHub',
countryCode: 'US',
socialProfiles: {
github: 'willy_castelot',
twitter: 'willy_code',
facebook: 'willy.castelot',
linkedin: 'willy-castelot',
instagram: 'willy.coder',
},
},
availabilityStatus: 'offline',
email: 'willy.castelot@codehub.io',
id: 20,
name: 'Willy Castelot',
phoneNumber: '+14155552673',
identifier: null,
thumbnail: '',
customAttributes: {
textContact: 'Lets collaborate!',
checkboxContact: true,
},
lastActivityAt: 1712123233,
createdAt: 1712123233,
},
{
additionalAttributes: {
city: 'Seattle',
country: 'United States',
description: 'Product manager with a love for innovation.',
companyName: 'InnovaTech',
countryCode: 'US',
socialProfiles: {
github: 'elisabeth-d',
twitter: 'elisabeth_innova',
facebook: 'elisabeth.derington',
linkedin: 'elisabeth-derington',
instagram: 'elisabeth.innovates',
},
},
availabilityStatus: 'offline',
email: 'elisabeth.derington@innova.com',
id: 19,
name: 'Elisabeth Derington',
phoneNumber: '+14155552674',
identifier: null,
thumbnail: '',
customAttributes: {
textContact: 'Lets schedule a call.',
},
lastActivityAt: 1712123232,
createdAt: 1712123232,
},
{
additionalAttributes: {
city: 'Chicago',
country: 'United States',
description: 'Marketing specialist and content creator.',
companyName: 'Contently',
countryCode: 'US',
socialProfiles: {
github: 'olia-olenchenko',
twitter: 'olia_content',
facebook: 'olia.olenchenko',
linkedin: 'olia-olenchenko',
instagram: 'olia.creates',
},
},
availabilityStatus: 'offline',
email: 'olia.olenchenko@contently.com',
id: 18,
name: 'Olia Olenchenko',
phoneNumber: '+14155552675',
identifier: null,
thumbnail: '',
customAttributes: {},
lastActivityAt: 1712123232,
createdAt: 1712123232,
},
{
additionalAttributes: {
city: 'Boston',
country: 'United States',
description: 'SEO expert and analytics enthusiast.',
companyName: 'OptiSearch',
countryCode: 'US',
socialProfiles: {
github: 'nate-vannuchi',
twitter: 'nate_seo',
facebook: 'nathaniel.vannuchi',
linkedin: 'nathaniel-vannuchi',
instagram: 'nate.optimizes',
},
},
availabilityStatus: 'offline',
email: 'nathaniel.vannuchi@optisearch.com',
id: 17,
name: 'Nathaniel Vannuchi',
phoneNumber: '+14155552676',
identifier: null,
thumbnail: '',
customAttributes: {},
lastActivityAt: 1712123232,
createdAt: 1712123232,
},
{
additionalAttributes: {
city: 'Denver',
country: 'United States',
description: 'UI/UX designer with a flair for minimalist designs.',
companyName: 'Minimal Designs',
countryCode: 'US',
socialProfiles: {
github: 'merrile-petruk',
twitter: 'merrile_ux',
facebook: 'merrile.petruk',
linkedin: 'merrile-petruk',
instagram: 'merrile.designs',
},
},
availabilityStatus: 'offline',
email: 'merrile.petruk@minimal.com',
id: 16,
name: 'Merrile Petruk',
phoneNumber: '+14155552677',
identifier: null,
thumbnail: '',
customAttributes: {},
lastActivityAt: 1712123232,
createdAt: 1712123232,
},
{
additionalAttributes: {
city: 'Miami',
country: 'United States',
description: 'Entrepreneur with a background in e-commerce.',
companyName: 'Ecom Solutions',
countryCode: 'US',
socialProfiles: {
github: 'cordell-d',
twitter: 'cordell_entrepreneur',
facebook: 'cordell.dalinder',
linkedin: 'cordell-dalinder',
instagram: 'cordell.ecom',
},
},
availabilityStatus: 'offline',
email: 'cordell.dalinder@ecomsolutions.com',
id: 15,
name: 'Cordell Dalinder',
phoneNumber: '+14155552678',
identifier: null,
thumbnail: '',
customAttributes: {},
lastActivityAt: 1712123232,
createdAt: 1712123232,
},
];
@@ -1,95 +0,0 @@
<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>
@@ -1,59 +0,0 @@
<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>
@@ -1,86 +0,0 @@
<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>
@@ -1,207 +0,0 @@
<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>
@@ -1,477 +0,0 @@
<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>
@@ -1,102 +0,0 @@
<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>
@@ -1,90 +0,0 @@
<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>
@@ -1,46 +0,0 @@
<script setup>
import { ref } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Switch from 'dashboard/components-next/switch/Switch.vue';
const props = defineProps({
attribute: {
type: Object,
required: true,
},
isEditingView: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update', 'delete']);
const attributeValue = ref(Boolean(props.attribute.value));
const handleChange = value => {
emit('update', value);
};
</script>
<template>
<div
class="flex items-center w-full gap-2"
:class="{
'justify-start': isEditingView,
'justify-end': !isEditingView,
}"
>
<Switch v-model="attributeValue" @change="handleChange" />
<Button
v-if="isEditingView"
variant="faded"
color="ruby"
icon="i-lucide-trash"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="emit('delete')"
/>
</div>
</template>
@@ -1,148 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { parseISO } from 'date-fns';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
attribute: {
type: Object,
required: true,
},
isEditingView: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update', 'delete']);
const { t } = useI18n();
const isEditingValue = ref(false);
const editedValue = ref(props.attribute.value || '');
const rules = {
editedValue: {
required,
isDate: value => new Date(value).toISOString(),
},
};
const v$ = useVuelidate(rules, { editedValue });
const formattedDate = computed(() => {
return props.attribute.value
? new Date(props.attribute.value).toLocaleDateString()
: t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.TRIGGER.INPUT');
});
const hasError = computed(() => v$.value.$errors.length > 0);
const defaultDateValue = computed({
get() {
const existingDate = editedValue.value ?? props.attribute.value;
if (existingDate) return new Date(existingDate).toISOString().slice(0, 10);
return isEditingValue.value && !hasError.value
? new Date().toISOString().slice(0, 10)
: '';
},
set(value) {
editedValue.value = value ? new Date(value).toISOString() : value;
},
});
const toggleEditValue = value => {
isEditingValue.value =
typeof value === 'boolean' ? value : !isEditingValue.value;
if (isEditingValue.value && !editedValue.value) {
v$.value.$reset();
editedValue.value = new Date().toISOString();
}
};
const handleInputUpdate = async () => {
const isValid = await v$.value.$validate();
if (!isValid) return;
emit('update', parseISO(editedValue.value));
isEditingValue.value = false;
};
</script>
<template>
<div
class="flex items-center w-full min-w-0 gap-2"
:class="{
'justify-start': isEditingView,
'justify-end': !isEditingView,
}"
>
<span
v-if="!isEditingValue"
class="min-w-0 text-sm"
:class="{
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
!isEditingView,
'text-n-slate-12 truncate flex-1': isEditingView,
}"
@click="toggleEditValue(!isEditingView)"
>
{{ formattedDate }}
</span>
<div
v-if="isEditingView && !isEditingValue"
class="flex items-center gap-1"
>
<Button
variant="faded"
color="slate"
icon="i-lucide-pencil"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="toggleEditValue(true)"
/>
<Button
variant="faded"
color="ruby"
icon="i-lucide-trash"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="emit('delete')"
/>
</div>
<div
v-if="isEditingValue"
v-on-clickaway="() => toggleEditValue(false)"
class="flex items-center w-full"
>
<Input
v-model="defaultDateValue"
type="date"
class="w-full [&>p]:absolute [&>p]:mt-0.5 [&>p]:top-8 ltr:[&>p]:left-0 rtl:[&>p]:right-0"
:message="
hasError
? t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.VALIDATIONS.INVALID_DATE')
: ''
"
:message-type="hasError ? 'error' : 'info'"
autofocus
custom-input-class="h-8 ltr:rounded-r-none rtl:rounded-l-none"
@keyup.enter="handleInputUpdate"
/>
<Button
icon="i-lucide-check"
:color="hasError ? 'ruby' : 'blue'"
size="sm"
class="flex-shrink-0 ltr:rounded-l-none rtl:rounded-r-none"
@click="handleInputUpdate"
/>
</div>
</div>
</template>
@@ -1,100 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
attribute: {
type: Object,
required: true,
},
isEditingView: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update', 'delete']);
const { t } = useI18n();
const [showAttributeListDropdown, toggleAttributeListDropdown] = useToggle();
const attributeListMenuItems = computed(() => {
return (
props.attribute.attributeValues?.map(value => ({
label: value,
value,
action: 'select',
isSelected: value === props.attribute.value,
})) || []
);
});
const handleAttributeAction = async action => {
emit('update', action.value);
toggleAttributeListDropdown(false);
};
</script>
<template>
<div
class="flex items-center w-full min-w-0 gap-2"
:class="{
'justify-start': isEditingView,
'justify-end': !isEditingView,
}"
>
<div
v-on-clickaway="() => toggleAttributeListDropdown(false)"
class="relative flex items-center"
>
<span
class="min-w-0 text-sm"
:class="{
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
!isEditingView,
'text-n-slate-12 truncate flex-1': isEditingView,
}"
@click="toggleAttributeListDropdown(!props.isEditingView)"
>
{{
attribute.value ||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.TRIGGER.SELECT')
}}
</span>
<DropdownMenu
v-if="showAttributeListDropdown"
:menu-items="attributeListMenuItems"
show-search
class="w-48 mt-2 top-full"
:class="{
'ltr:right-0 rtl:left-0': !isEditingView,
'ltr:left-0 rtl:right-0': isEditingView,
}"
@action="handleAttributeAction($event)"
/>
</div>
<div v-if="isEditingView" class="flex items-center gap-1">
<Button
variant="faded"
color="slate"
icon="i-lucide-pencil"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="toggleAttributeListDropdown()"
/>
<Button
variant="faded"
color="ruby"
icon="i-lucide-trash"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="emit('delete')"
/>
</div>
</div>
</template>
@@ -1,202 +0,0 @@
<!-- Attribute type "Text, URL, Number" -->
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { isValidURL } from 'dashboard/helper/URLHelper.js';
import { getRegexp } from 'shared/helpers/Validators';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
attribute: {
type: Object,
required: true,
},
isEditingView: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update', 'delete']);
const { t } = useI18n();
const isEditingValue = ref(false);
const editedValue = ref(props.attribute.value || '');
const isAttributeTypeLink = computed(
() => props.attribute.attributeDisplayType === 'link'
);
const isAttributeTypeText = computed(
() => props.attribute.attributeDisplayType === 'text'
);
const isAttributeTypeNumber = computed(
() => props.attribute.attributeDisplayType === 'number'
);
const rules = computed(() => ({
editedValue: {
required,
...(isAttributeTypeLink.value && {
url: value => !value || isValidURL(value),
}),
...(isAttributeTypeText.value &&
props.attribute.regexPattern && {
regexValidation: value => {
if (!value) return true;
return getRegexp(props.attribute.regexPattern).test(value);
},
}),
},
}));
const v$ = useVuelidate(rules, { editedValue });
const hasError = computed(() => v$.value.$error);
const attributeErrorMessage = computed(() => {
if (!hasError.value) return '';
if (isAttributeTypeLink.value && v$.value.editedValue.url?.$invalid) {
return t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.VALIDATIONS.INVALID_URL');
}
if (
isAttributeTypeText.value &&
props.attribute.regexPattern &&
v$.value.editedValue.regexValidation?.$invalid
) {
return (
props.attribute.regexCue ||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.VALIDATIONS.INVALID_INPUT')
);
}
if (isAttributeTypeNumber.value && v$.value.editedValue.required?.$invalid) {
return t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.VALIDATIONS.INVALID_NUMBER');
}
return t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.VALIDATIONS.REQUIRED');
});
const getInputType = computed(() => {
switch (props.attribute.attributeDisplayType) {
case 'link':
return 'url';
case 'number':
return 'number';
default:
return 'text';
}
});
const toggleEditValue = value => {
isEditingValue.value =
typeof value === 'boolean' ? value : !isEditingValue.value;
if (isEditingValue.value) {
v$.value.$reset();
editedValue.value = props.attribute.value || '';
}
};
const handleInputUpdate = async () => {
const isValid = await v$.value.$validate();
if (!isValid) return;
emit('update', editedValue.value);
toggleEditValue(false);
};
</script>
<template>
<div
class="flex items-center w-full min-w-0 gap-2"
:class="{
'justify-start': isEditingView,
'justify-end': !isEditingView,
}"
>
<span
v-if="!isEditingValue"
class="min-w-0 text-sm"
:class="{
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
!isEditingView,
'text-n-slate-12 truncate flex-1':
isEditingView && !isAttributeTypeLink,
'truncate flex-1 hover:text-n-brand text-n-blue-text':
isEditingView && isAttributeTypeLink,
}"
@click="toggleEditValue(!isEditingView)"
>
<a
v-if="isAttributeTypeLink && attribute.value && isEditingView"
:href="attribute.value"
target="_blank"
rel="noopener noreferrer"
class="hover:underline"
@click.stop
>
{{ attribute.value }}
</a>
<template v-else>
{{
attribute.value ||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.TRIGGER.INPUT')
}}
</template>
</span>
<div
v-if="isEditingView && !isEditingValue"
class="flex items-center gap-1"
>
<Button
variant="faded"
color="slate"
icon="i-lucide-pencil"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="toggleEditValue(true)"
/>
<Button
variant="faded"
color="ruby"
icon="i-lucide-trash"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="emit('delete')"
/>
</div>
<div
v-if="isEditingValue"
v-on-clickaway="() => toggleEditValue(false)"
class="flex items-center w-full"
>
<Input
v-model="editedValue"
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.TRIGGER.INPUT')"
:type="getInputType"
class="w-full [&>p]:absolute [&>p]:mt-0.5 [&>p]:top-8 ltr:[&>p]:left-0 rtl:[&>p]:right-0"
autofocus
:message="attributeErrorMessage"
:message-type="hasError ? 'error' : 'info'"
custom-input-class="h-8 ltr:rounded-r-none rtl:rounded-l-none"
@keyup.enter="handleInputUpdate"
/>
<Button
icon="i-lucide-check"
:color="hasError ? 'ruby' : 'blue'"
size="sm"
class="flex-shrink-0 ltr:rounded-l-none rtl:rounded-r-none"
@click="handleInputUpdate"
/>
</div>
</div>
</template>
@@ -1,83 +0,0 @@
<script setup>
import Attributes from './fixtures';
import OtherAttribute from '../OtherAttribute.vue';
import ListAttribute from '../ListAttribute.vue';
import DateAttribute from '../DateAttribute.vue';
import CheckboxAttribute from '../CheckboxAttribute.vue';
const componentMap = {
list: ListAttribute,
checkbox: CheckboxAttribute,
date: DateAttribute,
default: OtherAttribute,
};
const getCurrentComponent = type => {
return componentMap[type] || componentMap.default;
};
const handleUpdate = (type, value) => {
console.log(`${type} updated:`, value);
};
const handleDelete = type => {
console.log(`${type} deleted`);
};
</script>
<template>
<Story
title="Components/CustomAttributes"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Create View">
<div class="flex flex-col gap-4 p-4 border rounded-lg border-n-strong">
<div
v-for="attribute in Attributes"
:key="attribute.attributeKey"
class="grid grid-cols-[140px,1fr] group-hover/attribute items-center gap-1 min-h-10"
>
<div class="flex items-center justify-between truncate">
<span class="text-sm font-medium text-n-slate-12">
{{ attribute.attributeDisplayName }}
</span>
</div>
<component
:is="getCurrentComponent(attribute.attributeDisplayType)"
:attribute="attribute"
@update="
value => handleUpdate(attribute.attributeDisplayType, value)
"
@delete="() => handleDelete(attribute.attributeDisplayType)"
/>
</div>
</div>
</Variant>
<Variant title="Saved View">
<div class="flex flex-col gap-4 p-4 border rounded-lg border-n-strong">
<div
v-for="attribute in Attributes"
:key="attribute.attributeKey"
class="grid grid-cols-[140px,1fr] group-hover/attribute items-center gap-1 min-h-10"
>
<div class="flex items-center justify-between truncate">
<span class="text-sm font-medium text-n-slate-12">
{{ attribute.attributeDisplayName }}
</span>
</div>
<component
:is="getCurrentComponent(attribute.attributeDisplayType)"
:attribute="attribute"
is-editing-view
@update="
value => handleUpdate(attribute.attributeDisplayType, value)
"
@delete="() => handleDelete(attribute.attributeDisplayType)"
/>
</div>
</div>
</Variant>
</Story>
</template>
@@ -1,39 +0,0 @@
export default [
{
attributeKey: 'textContact',
attributeDisplayName: 'Text Input',
attributeDisplayType: 'text',
value: 'Sample text value',
},
{
attributeKey: 'linkContact',
attributeDisplayName: 'URL Input',
attributeDisplayType: 'link',
value: 'https://www.chatwoot.com',
},
{
attributeKey: 'numberContact',
attributeDisplayName: 'Number Input',
attributeDisplayType: 'number',
value: '42',
},
{
attributeKey: 'listContact',
attributeDisplayName: 'List Input',
attributeDisplayType: 'list',
value: 'Option 2',
attributeValues: ['Option 1', 'Option 2', 'Option 3'],
},
{
attributeKey: 'dateContact',
attributeDisplayName: 'Date Input',
attributeDisplayType: 'date',
value: '2024-03-25T00:00:00.000Z',
},
{
attributeKey: 'checkboxContact',
attributeDisplayName: 'Checkbox Input',
attributeDisplayType: 'checkbox',
value: true,
},
];
@@ -1,173 +0,0 @@
<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>
@@ -1,47 +0,0 @@
<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>
@@ -1,87 +0,0 @@
<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>
@@ -1,195 +0,0 @@
<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 Avatar from 'dashboard/components-next/avatar/Avatar.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>
<div class="flex justify-between w-full 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>
<div class="flex items-center justify-between w-full gap-4">
<div class="flex items-center gap-4">
<div class="flex items-center gap-1">
<Avatar
:name="authorName"
:src="authorThumbnailSrc"
:size="16"
rounded-full
/>
<span class="text-sm truncate 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>
</CardLayout>
</template>
@@ -1,48 +0,0 @@
<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>
@@ -1,133 +0,0 @@
<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>
<div class="flex w-full 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>
<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>
</CardLayout>
</template>
@@ -1,16 +0,0 @@
<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>
@@ -1,56 +0,0 @@
<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>
@@ -1,49 +0,0 @@
<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>
@@ -1,142 +0,0 @@
export default [
{
id: 1,
name: 'Getting Started',
icon: '🚀',
description: 'Quick guides to help new users onboard.',
slug: 'getting-started',
meta: {
articles_count: 5,
},
},
{
id: 2,
name: 'Advanced Features',
icon: '💡',
description: 'Explore advanced features for power users.',
slug: 'advanced-features',
meta: {
articles_count: 8,
},
},
{
id: 3,
name: 'FAQs',
icon: '❓',
description: 'Commonly asked questions and helpful answers.',
slug: 'faqs',
meta: {
articles_count: 3,
},
},
{
id: 4,
name: 'Troubleshooting',
icon: '🛠️',
description: 'Resolve common issues with step-by-step guidance.',
slug: 'troubleshooting',
meta: {
articles_count: 6,
},
},
{
id: 5,
name: 'Community Guidelines',
icon: '👥',
description: 'Rules and practices for community engagement.',
slug: 'community-guidelines',
meta: {
articles_count: 2,
},
},
{
id: 6,
name: 'Account Management',
icon: '🔑',
description: 'Manage your account and settings efficiently.',
slug: 'account-management',
meta: {
articles_count: 7,
},
},
{
id: 7,
name: 'Security Tips',
icon: '🔒',
description: 'Best practices for securing your account.',
slug: 'security-tips',
meta: {
articles_count: 4,
},
},
{
id: 8,
name: 'Integrations',
icon: '🔗',
description: 'Connect to third-party services and tools easily.',
slug: 'integrations',
meta: {
articles_count: 9,
},
},
{
id: 9,
name: 'Billing & Payments',
icon: '💳',
description: 'Manage your billing and payment details seamlessly.',
slug: 'billing-payments',
meta: {
articles_count: 5,
},
},
{
id: 10,
name: 'Customization',
icon: '🎨',
description: 'Personalize and customize your user experience.',
slug: 'customization',
meta: {
articles_count: 7,
},
},
{
id: 11,
name: 'Notifications',
icon: '🔔',
description: 'Adjust your notification settings and preferences.',
slug: 'notifications',
meta: {
articles_count: 3,
},
},
{
id: 12,
name: 'Privacy',
icon: '🛡️',
description: 'Understand how your data is collected and used.',
slug: 'privacy',
meta: {
articles_count: 2,
},
},
{
id: 13,
name: 'Mobile App',
icon: '📱',
description: 'Guides for using the mobile app effectively.',
slug: 'mobile-app',
meta: {
articles_count: 6,
},
},
{
id: 14,
name: 'Beta Features',
icon: '🧪',
description: 'Learn about new experimental features in beta.',
slug: 'beta-features',
meta: {
articles_count: 4,
},
},
];
@@ -1,16 +0,0 @@
<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>
@@ -1,72 +0,0 @@
<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>
@@ -1,172 +0,0 @@
export default [
{
id: 1,
title: "How to get an SSL certificate for your Help Center's custom domain",
status: 'draft',
updatedAt: 1729205669,
author: { availableName: 'Michael' },
category: {
slug: 'configuration',
icon: '📦',
name: 'Setup & Configuration',
},
views: 3400,
},
{
id: 2,
title: 'Setting up your first Help Center portal',
status: 'published',
updatedAt: 1729205669,
author: { availableName: 'John' },
category: { slug: 'onboarding', icon: '🧑‍🍳', name: 'Onboarding' },
views: 400,
},
{
id: 3,
title: 'Best practices for organizing your Help Center content',
status: 'archived',
updatedAt: 1729205669,
author: { availableName: 'Fernando' },
category: { slug: 'best-practices', icon: '⛺️', name: 'Best Practices' },
views: 400,
},
{
id: 4,
title: 'Customizing the appearance of your Help Center',
status: 'draft',
updatedAt: 1729205669,
author: { availableName: 'Jane' },
category: { slug: 'design', icon: '🎨', name: 'Design' },
views: 400,
},
{
id: 5,
title: 'Integrating your Help Center with third-party tools',
status: 'published',
updatedAt: 1729205669,
author: { availableName: 'Sarah' },
category: {
slug: 'integrations',
icon: '🔗',
name: 'Integrations',
},
views: 2800,
},
{
id: 6,
title: 'Managing user permissions in your Help Center',
status: 'draft',
updatedAt: 1729205669,
author: { availableName: 'Alex' },
category: {
slug: 'administration',
icon: '🔐',
name: 'Administration',
},
views: 1200,
},
{
id: 7,
title: 'Creating and managing FAQ sections',
status: 'published',
updatedAt: 1729205669,
author: { availableName: 'Emily' },
category: {
slug: 'content-management',
icon: '📝',
name: 'Content Management',
},
views: 5600,
},
{
id: 8,
title: 'Implementing search functionality in your Help Center',
status: 'archived',
updatedAt: 1729205669,
author: { availableName: 'David' },
category: {
slug: 'features',
icon: '🔍',
name: 'Features',
},
views: 1800,
},
{
id: 9,
title: 'Analyzing Help Center usage metrics',
status: 'published',
updatedAt: 1729205669,
author: { availableName: 'Rachel' },
category: {
slug: 'analytics',
icon: '📊',
name: 'Analytics',
},
views: 3200,
},
{
id: 10,
title: 'Setting up multilingual support in your Help Center',
status: 'draft',
updatedAt: 1729205669,
author: { availableName: 'Carlos' },
category: {
slug: 'localization',
icon: '🌍',
name: 'Localization',
},
views: 900,
},
{
id: 11,
title: 'Creating interactive tutorials for your products',
status: 'published',
updatedAt: 1729205669,
author: { availableName: 'Olivia' },
category: {
slug: 'education',
icon: '🎓',
name: 'Education',
},
views: 4100,
},
{
id: 12,
title: 'Implementing a feedback system in your Help Center',
status: 'draft',
updatedAt: 1729205669,
author: { availableName: 'Nathan' },
category: {
slug: 'user-engagement',
icon: '💬',
name: 'User Engagement',
},
views: 750,
},
{
id: 13,
title: 'Optimizing Help Center content for SEO',
status: 'published',
updatedAt: 1729205669,
author: { availableName: 'Sophia' },
category: {
slug: 'seo',
icon: '🚀',
name: 'SEO',
},
views: 2900,
},
{
id: 14,
title: 'Creating a knowledge base for internal teams',
status: 'archived',
updatedAt: 1729205669,
author: { availableName: 'Daniel' },
category: {
slug: 'internal-resources',
icon: '🏢',
name: 'Internal Resources',
},
views: 1500,
},
];
@@ -1,113 +0,0 @@
<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-[960px] 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-[960px] 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>
@@ -1,29 +0,0 @@
<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>
@@ -1,116 +0,0 @@
<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>
<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>
</CardLayout>
</template>
@@ -1,173 +0,0 @@
<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',
'saveArticleAsync',
'goBack',
'setAuthor',
'setCategory',
'previewArticle',
]);
const { t } = useI18n();
const saveAndSync = value => {
emit('saveArticle', value);
};
// this will only send the data to the backend
// but will not update the local state preventing unnecessary re-renders
// since the data is already saved and we keep the editor text as the source of truth
const quickSave = debounce(
value => emit('saveArticleAsync', value),
400,
false
);
// 2.5 seconds is enough to know that the user has stopped typing and is taking a pause
// so we can save the data to the backend and retrieve the updated data
// this will update the local state with response data
const saveAndSyncDebounced = debounce(saveAndSync, 2500, false);
const articleTitle = computed({
get: () => props.article.title,
set: value => {
quickSave({ title: value });
saveAndSyncDebounced({ title: value });
},
});
const articleContent = computed({
get: () => props.article.content,
set: content => {
quickSave({ content });
saveAndSyncDebounced({ 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="saveAndSync"
@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>
@@ -1,261 +0,0 @@
<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 Avatar from 'dashboard/components-next/avatar/Avatar.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"
>
<Avatar
:name="authorName"
:src="authorThumbnailSrc"
:size="20"
rounded-full
/>
<span class="text-sm text-n-slate-12 hover:text-n-slate-11">
{{ authorName || '-' }}
</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>
@@ -1,177 +0,0 @@
<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>
@@ -1,134 +0,0 @@
<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>
@@ -1,194 +0,0 @@
<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>
@@ -1,190 +0,0 @@
<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>
@@ -1,72 +0,0 @@
<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>
@@ -1,190 +0,0 @@
<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>
@@ -1,209 +0,0 @@
<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>
@@ -1,139 +0,0 @@
<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>
@@ -1,112 +0,0 @@
<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>
@@ -1,271 +0,0 @@
<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>
@@ -1,202 +0,0 @@
<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>
@@ -1,37 +0,0 @@
<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>
@@ -1,110 +0,0 @@
<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"
>
<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"
/>
</Dialog>
</template>
@@ -1,99 +0,0 @@
<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"
>
<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>
</Dialog>
</template>

Some files were not shown because too many files have changed in this diff Show More