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
373 changed files with 7579 additions and 15807 deletions
+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,
},
};
-3
View File
@@ -175,9 +175,6 @@ gem 'pgvector'
# Convert Website HTML to Markdown
gem 'reverse_markdown'
gem 'opensearch-ruby'
gem 'searchkick'
### Gems required only in specific deployment environments ###
##############################################################
+59 -65
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)
@@ -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)
@@ -479,7 +479,7 @@ GEM
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)
@@ -532,9 +532,6 @@ GEM
omniauth-rails_csrf_protection (1.0.2)
actionpack (>= 4.2)
omniauth (~> 2.0)
opensearch-ruby (3.4.0)
faraday (>= 1.0, < 3)
multi_json (>= 1.0)
openssl (3.2.0)
orm_adapter (0.5.0)
os (1.1.4)
@@ -560,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)
@@ -577,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
@@ -598,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)
@@ -636,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)
@@ -706,9 +704,6 @@ GEM
parser
scss_lint (0.60.0)
sass (~> 3.5, >= 3.5.5)
searchkick (5.4.0)
activemodel (>= 6.1)
hashie
seed_dump (3.3.1)
activerecord (>= 4)
activesupport (>= 4)
@@ -769,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)
@@ -916,7 +912,6 @@ DEPENDENCIES
omniauth-google-oauth2 (>= 1.1.3)
omniauth-oauth2
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
opensearch-ruby
pg
pg_search
pgvector
@@ -942,7 +937,6 @@ DEPENDENCIES
rubocop-rspec
scout_apm
scss_lint
searchkick
seed_dump
sentry-rails (>= 5.19.0)
sentry-ruby
+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
@@ -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,
@@ -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
+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');
@@ -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,
});
}
+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 */
}
}
@@ -1,141 +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 class="flex flex-row justify-between flex-1 gap-8" layout="row">
<template #header>
<div class="flex flex-col items-start gap-2">
<div class="flex justify-between gap-3 w-fit">
<span
class="text-base font-medium capitalize text-n-slate-12 line-clamp-1"
>
{{ title }}
</span>
<span
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
:class="statusTextColor"
>
{{ campaignStatus }}
</span>
</div>
<div
v-dompurify-html="formatMessage(message)"
class="text-sm text-n-slate-11 line-clamp-1 [&>p]:mb-0 h-6"
/>
<div class="flex items-center w-full h-6 gap-2 overflow-hidden">
<LiveChatCampaignDetails
v-if="isLiveChatType"
:sender="sender"
:inbox-name="inboxName"
:inbox-icon="inboxIcon"
/>
<SMSCampaignDetails
v-else
:inbox-name="inboxName"
:inbox-icon="inboxIcon"
:scheduled-at="scheduledAt"
/>
</div>
</div>
</template>
<template #footer>
<div class="flex items-center justify-end w-20 gap-2">
<Button
v-if="isLiveChatType"
variant="faded"
size="sm"
color="slate"
icon="i-lucide-sliders-vertical"
@click="emit('edit')"
/>
<Button
variant="faded"
color="ruby"
size="sm"
icon="i-lucide-trash"
@click="emit('delete')"
/>
</div>
</template>
</CardLayout>
</template>
@@ -1,54 +0,0 @@
<script setup>
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
sender: {
type: Object,
default: null,
},
inboxName: {
type: String,
default: '',
},
inboxIcon: {
type: String,
default: '',
},
});
const { t } = useI18n();
const senderName = computed(
() => props.sender?.name || t('CAMPAIGN.LIVE_CHAT.CARD.CAMPAIGN_DETAILS.BOT')
);
const senderThumbnailSrc = computed(() => props.sender?.thumbnail);
</script>
<template>
<span class="flex-shrink-0 text-sm text-n-slate-11 whitespace-nowrap">
{{ t('CAMPAIGN.LIVE_CHAT.CARD.CAMPAIGN_DETAILS.SENT_BY') }}
</span>
<div class="flex items-center gap-1.5 flex-shrink-0">
<Thumbnail
:author="sender || { name: senderName }"
:name="senderName"
:src="senderThumbnailSrc"
/>
<span class="text-sm font-medium text-n-slate-12">
{{ senderName }}
</span>
</div>
<span class="flex-shrink-0 text-sm text-n-slate-11 whitespace-nowrap">
{{ t('CAMPAIGN.LIVE_CHAT.CARD.CAMPAIGN_DETAILS.FROM') }}
</span>
<div class="flex items-center gap-1.5 flex-shrink-0">
<Icon :icon="inboxIcon" class="flex-shrink-0 text-n-slate-12 size-3" />
<span class="text-sm font-medium text-n-slate-12">
{{ inboxName }}
</span>
</div>
</template>
@@ -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-[900px] mx-auto">
<div class="flex items-center justify-between w-full h-20 gap-2">
<span class="text-xl font-medium text-n-slate-12">
{{ headerTitle }}
</span>
<div
v-on-clickaway="() => emit('close')"
class="relative group/campaign-button"
>
<Button
:label="buttonLabel"
icon="i-lucide-plus"
size="sm"
class="group-hover/campaign-button:brightness-110"
@click="handleButtonClick"
/>
<slot name="action" />
</div>
</div>
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-[900px] mx-auto py-4">
<slot name="default" />
</div>
</main>
</section>
</template>
@@ -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,39 +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"
:trigger-rules="campaign.trigger_rules"
:sender="campaign.sender"
:inbox="campaign.inbox"
:scheduled-at="campaign.scheduled_at"
is-live-chat-type
/>
</div>
</template>
</EmptyStateLayout>
</template>
@@ -1,38 +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"
:trigger-rules="campaign.trigger_rules"
:sender="campaign.sender"
:inbox="campaign.inbox"
:scheduled-at="campaign.scheduled_at"
/>
</div>
</template>
</EmptyStateLayout>
</template>
@@ -1,39 +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"
:trigger-rules="campaign.trigger_rules"
:sender="campaign.sender"
:inbox="campaign.inbox"
:scheduled-at="campaign.scheduled_at"
:is-live-chat-type="isLiveChatType"
@edit="handleEdit(campaign)"
@delete="handleDelete(campaign)"
/>
</div>
</template>
@@ -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,76 +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"
>
<template #form>
<LiveChatCampaignForm
ref="liveChatCampaignFormRef"
mode="edit"
:selected-campaign="selectedCampaign"
:show-action-buttons="false"
@submit="handleSubmit"
/>
</template>
</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,23 +0,0 @@
<script setup>
const props = defineProps({
layout: {
type: String,
default: 'col',
},
});
const emit = defineEmits(['click']);
const handleClick = () => {
emit('click');
};
</script>
<template>
<div
class="relative flex w-full gap-3 px-6 py-5 shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2"
:class="props.layout === 'col' ? 'flex-col' : 'flex-row'"
@click="handleClick"
>
<slot name="header" />
<slot name="footer" />
</div>
</template>
@@ -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,198 +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 Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
const props = defineProps({
id: {
type: Number,
required: true,
},
title: {
type: String,
required: true,
},
status: {
type: String,
required: true,
},
author: {
type: Object,
default: null,
},
category: {
type: Object,
required: true,
},
views: {
type: Number,
required: true,
},
updatedAt: {
type: Number,
required: true,
},
});
const emit = defineEmits(['openArticle', 'articleAction']);
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const articleMenuItems = computed(() => {
const commonItems = Object.entries(ARTICLE_MENU_ITEMS).reduce(
(acc, [key, item]) => {
acc[key] = { ...item, label: t(item.label) };
return acc;
},
{}
);
const statusItems = (
ARTICLE_MENU_OPTIONS[props.status] ||
ARTICLE_MENU_OPTIONS[ARTICLE_STATUSES.PUBLISHED]
).map(key => commonItems[key]);
return [...statusItems, commonItems.delete];
});
const statusTextColor = computed(() => {
switch (props.status) {
case 'archived':
return 'text-n-slate-12';
case 'draft':
return 'text-n-amber-11';
default:
return 'text-n-teal-11';
}
});
const statusText = computed(() => {
switch (props.status) {
case 'archived':
return t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.STATUS.ARCHIVED');
case 'draft':
return t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.STATUS.DRAFT');
default:
return t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.STATUS.PUBLISHED');
}
});
const categoryName = computed(() => {
if (props.category?.slug) {
return `${props.category.icon} ${props.category.name}`;
}
return t(
'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.CATEGORY.UNCATEGORISED'
);
});
const authorName = computed(() => {
return props.author?.name || props.author?.availableName || '-';
});
const authorThumbnailSrc = computed(() => {
return props.author?.thumbnail;
});
const lastUpdatedAt = computed(() => {
return dynamicTime(props.updatedAt);
});
const handleArticleAction = ({ action, value }) => {
toggleDropdown(false);
emit('articleAction', { action, value, id: props.id });
};
const handleClick = id => {
emit('openArticle', id);
};
</script>
<template>
<CardLayout>
<template #header>
<div class="flex justify-between gap-1">
<span
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-text text-n-slate-12 line-clamp-1"
@click="handleClick(id)"
>
{{ title }}
</span>
<div class="flex items-center gap-2">
<span
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
:class="statusTextColor"
>
{{ statusText }}
</span>
<div
v-on-clickaway="() => toggleDropdown(false)"
class="relative flex items-center group"
>
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="articleMenuItems"
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 top-full"
@action="handleArticleAction($event)"
/>
</div>
</div>
</div>
</template>
<template #footer>
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-4">
<div class="flex items-center gap-1">
<Thumbnail
:author="author"
:name="authorName"
:src="authorThumbnailSrc"
/>
<span class="text-sm text-n-slate-11">
{{ authorName }}
</span>
</div>
<span class="block text-sm whitespace-nowrap text-n-slate-11">
{{ categoryName }}
</span>
<div
class="inline-flex items-center gap-1 text-n-slate-11 whitespace-nowrap"
>
<Icon icon="i-lucide-eye" class="size-4" />
<span class="text-sm">
{{
t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.VIEWS', {
count: views,
})
}}
</span>
</div>
</div>
<span class="text-sm text-n-slate-11 line-clamp-1">
{{ lastUpdatedAt }}
</span>
</div>
</template>
</CardLayout>
</template>
@@ -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,137 +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>
<template #header>
<div class="flex gap-2">
<div class="flex justify-between w-full gap-2">
<div class="flex items-center justify-start w-full min-w-0 gap-2">
<span
class="text-base truncate cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-text text-n-slate-12"
@click="handleClick(slug)"
>
{{ categoryTitleWithIcon }}
</span>
<span
class="inline-flex items-center justify-center h-6 px-2 py-1 text-xs text-center border rounded-lg bg-n-slate-1 whitespace-nowrap shrink-0 text-n-slate-11 border-n-slate-4"
>
{{
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_CARD.ARTICLES_COUNT', {
count: articlesCount,
})
}}
</span>
</div>
<div
v-on-clickaway="() => toggleDropdown(false)"
class="relative group"
>
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
size="xs"
variant="ghost"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="categoryMenuItems"
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 top-full z-60"
@action="handleAction"
/>
</div>
</div>
</div>
</template>
<template #footer>
<span
class="text-sm line-clamp-3"
:class="
hasDescription
? 'text-slate-500 dark:text-slate-400'
: 'text-slate-400 dark:text-slate-700'
"
>
{{ description }}
</span>
</template>
</CardLayout>
</template>
@@ -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-[900px] mx-auto">
<div
v-if="showHeaderTitle"
class="flex items-center justify-start h-20 gap-2"
>
<span
v-if="activePortalName"
class="text-xl font-medium text-n-slate-12"
>
{{ activePortalName }}
</span>
<div v-if="activePortalName" class="relative group">
<OnClickOutside @trigger="showPortalSwitcher = false">
<Button
icon="i-lucide-chevron-down"
variant="ghost"
size="xs"
class="rounded-md group-hover:bg-n-slate-3 hover:bg-n-slate-3"
@click="togglePortalSwitcher"
/>
<PortalSwitcher
v-if="showPortalSwitcher"
class="absolute ltr:left-0 rtl:right-0 top-9"
@close="showPortalSwitcher = false"
@create-portal="createPortalDialogRef.dialogRef.open()"
/>
</OnClickOutside>
<CreatePortalDialog ref="createPortalDialogRef" />
</div>
</div>
<slot name="header-actions" />
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-[900px] mx-auto py-3">
<slot name="content" />
</div>
</main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 px-4 pb-4">
<PaginationFooter
:current-page="currentPage"
:total-items="totalItems"
:items-per-page="itemsPerPage"
@update:current-page="updateCurrentPage"
/>
</footer>
<!-- Do not remove this slot. It can be used to add dialogs. -->
<slot />
</section>
</template>
@@ -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,118 +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>
<template #header>
<div class="flex justify-between gap-2">
<div class="flex items-center justify-start gap-2">
<span
class="text-sm font-medium text-slate-900 dark:text-slate-50 line-clamp-1"
>
{{ locale }} ({{ localeCode }})
</span>
<span
v-if="isDefault"
class="bg-n-alpha-2 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-n-blue-text px-2 py-0.5"
>
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }}
</span>
</div>
<div class="flex items-center justify-end gap-4">
<div class="flex items-center gap-4">
<span
class="text-sm text-slate-500 dark:text-slate-400 whitespace-nowrap"
>
{{
$t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.ARTICLES_COUNT',
articleCount
)
}}
</span>
<div class="w-px h-3 bg-slate-75 dark:bg-slate-800" />
<span
class="text-sm text-slate-500 dark:text-slate-400 whitespace-nowrap"
>
{{
$t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.CATEGORIES_COUNT',
categoryCount
)
}}
</span>
</div>
<div
v-on-clickaway="() => toggleDropdown(false)"
class="relative group"
>
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showDropdownMenu"
:menu-items="localeMenuItems"
class="ltr:right-0 rtl:left-0 mt-1 xl:ltr:left-0 xl:rtl:right-0 top-full z-60 min-w-[150px]"
@action="handleAction"
/>
</div>
</div>
</div>
</template>
</CardLayout>
</template>
@@ -1,154 +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',
'goBack',
'setAuthor',
'setCategory',
'previewArticle',
]);
const { t } = useI18n();
const saveArticle = debounce(value => emit('saveArticle', value), 400, false);
const articleTitle = computed({
get: () => props.article.title,
set: value => {
saveArticle({ title: value });
},
});
const articleContent = computed({
get: () => props.article.content,
set: content => {
saveArticle({ content });
},
});
const onClickGoBack = () => {
emit('goBack');
};
const setAuthorId = authorId => {
emit('setAuthor', authorId);
};
const setCategoryId = categoryId => {
emit('setCategory', categoryId);
};
const previewArticle = () => {
emit('previewArticle');
};
</script>
<template>
<HelpCenterLayout :show-header-title="false" :show-pagination-footer="false">
<template #header-actions>
<ArticleEditorHeader
:is-updating="isUpdating"
:is-saved="isSaved"
:status="article.status"
:article-id="article.id"
@go-back="onClickGoBack"
@preview-article="previewArticle"
/>
</template>
<template #content>
<div class="flex flex-col gap-3 pl-4 mb-3 rtl:pr-3 rtl:pl-0">
<TextArea
v-model="articleTitle"
auto-height
min-height="4rem"
custom-text-area-class="!text-[32px] !leading-[48px] !font-medium !tracking-[0.2px]"
custom-text-area-wrapper-class="border-0 !bg-transparent dark:!bg-transparent !py-0 !px-0"
placeholder="Title"
autofocus
/>
<ArticleEditorControls
:article="article"
@save-article="saveArticle"
@set-author="setAuthorId"
@set-category="setCategoryId"
/>
</div>
<FullEditor
v-model="articleContent"
class="py-0 pb-10 pl-4 rtl:pr-4 rtl:pl-0 h-fit"
:placeholder="
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.EDITOR_PLACEHOLDER')
"
:enabled-menu-options="ARTICLE_EDITOR_MENU_OPTIONS"
:autofocus="false"
/>
</template>
</HelpCenterLayout>
</template>
<style lang="scss" scoped>
::v-deep {
.ProseMirror .empty-node::before {
@apply text-slate-200 dark:text-slate-500 text-base;
}
.ProseMirror-menubar-wrapper {
.ProseMirror-woot-style {
@apply min-h-[15rem] max-h-full;
}
}
.ProseMirror-menubar {
display: none; // Hide by default
}
.editor-root .has-selection {
.ProseMirror-menubar {
@apply h-8 rounded-lg !px-2 z-50 bg-slate-50 dark:bg-slate-800 items-center gap-4 ml-0 mb-0 shadow-md border border-slate-75 dark:border-slate-700/50;
display: flex;
top: var(--selection-top, auto) !important;
left: var(--selection-left, 0) !important;
width: fit-content !important;
position: absolute !important;
.ProseMirror-menuitem {
@apply mr-0;
.ProseMirror-icon {
@apply p-0 mt-1 !mr-0;
svg {
width: 20px !important;
height: 20px !important;
}
}
}
}
}
}
</style>
@@ -1,264 +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 Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import ArticleEditorProperties from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorProperties.vue';
const props = defineProps({
article: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['saveArticle', 'setAuthor', 'setCategory']);
const { t } = useI18n();
const route = useRoute();
const openAgentsList = ref(false);
const openCategoryList = ref(false);
const openProperties = ref(false);
const selectedAuthorId = ref(null);
const selectedCategoryId = ref(null);
const agents = useMapGetter('agents/getAgents');
const categories = useMapGetter('categories/allCategories');
const currentUserId = useMapGetter('getCurrentUserID');
const isNewArticle = computed(() => !props.article?.id);
const currentUser = computed(() =>
agents.value.find(agent => agent.id === currentUserId.value)
);
const categorySlugFromRoute = computed(() => route.params.categorySlug);
const author = computed(() => {
if (isNewArticle.value) {
return selectedAuthorId.value
? agents.value.find(agent => agent.id === selectedAuthorId.value)
: currentUser.value;
}
return props.article?.author || null;
});
const authorName = computed(
() => author.value?.name || author.value?.available_name || '-'
);
const authorThumbnailSrc = computed(() => author.value?.thumbnail);
const agentList = computed(() => {
return (
agents.value
?.map(({ name, id, thumbnail }) => ({
label: name,
value: id,
thumbnail: { name, src: thumbnail },
isSelected: props.article?.author?.id
? id === props.article.author.id
: id === (selectedAuthorId.value || currentUserId.value),
action: 'assignAuthor',
}))
// Sort the list by isSelected first, then by name(label)
.toSorted((a, b) => {
if (a.isSelected !== b.isSelected) {
return Number(b.isSelected) - Number(a.isSelected);
}
return a.label.localeCompare(b.label);
}) ?? []
);
});
const hasAgentList = computed(() => {
return agents.value?.length > 1;
});
const findCategoryFromSlug = slug => {
return categories.value?.find(category => category.slug === slug);
};
const assignCategoryFromSlug = slug => {
const categoryFromSlug = findCategoryFromSlug(slug);
if (categoryFromSlug) {
selectedCategoryId.value = categoryFromSlug.id;
return categoryFromSlug;
}
return null;
};
const selectedCategory = computed(() => {
if (isNewArticle.value) {
if (categorySlugFromRoute.value) {
const categoryFromSlug = assignCategoryFromSlug(
categorySlugFromRoute.value
);
if (categoryFromSlug) return categoryFromSlug;
}
return selectedCategoryId.value
? categories.value.find(
category => category.id === selectedCategoryId.value
)
: categories.value[0] || null;
}
return categories.value.find(
category => category.id === props.article?.category?.id
);
});
const categoryList = computed(() => {
return (
categories.value
.map(({ name, id, icon }) => ({
label: name,
value: id,
emoji: icon,
isSelected: isNewArticle.value
? id === (selectedCategoryId.value || selectedCategory.value?.id)
: id === props.article?.category?.id,
action: 'assignCategory',
}))
// Sort categories by isSelected
.toSorted((a, b) => Number(b.isSelected) - Number(a.isSelected))
);
});
const hasCategoryMenuItems = computed(() => {
return categoryList.value?.length > 0;
});
const handleArticleAction = ({ action, value }) => {
const actions = {
assignAuthor: () => {
if (isNewArticle.value) {
selectedAuthorId.value = value;
emit('setAuthor', value);
} else {
emit('saveArticle', { author_id: value });
}
openAgentsList.value = false;
},
assignCategory: () => {
if (isNewArticle.value) {
selectedCategoryId.value = value;
emit('setCategory', value);
} else {
emit('saveArticle', { category_id: value });
}
openCategoryList.value = false;
},
};
actions[action]?.();
};
const updateMeta = meta => {
emit('saveArticle', { meta });
};
onMounted(() => {
if (categorySlugFromRoute.value && isNewArticle.value) {
// Assign category from slug if there is one
const categoryFromSlug = findCategoryFromSlug(categorySlugFromRoute.value);
if (categoryFromSlug) {
handleArticleAction({
action: 'assignCategory',
value: categoryFromSlug?.id,
});
}
}
});
</script>
<template>
<div class="flex items-center gap-4">
<div class="relative flex items-center gap-2">
<OnClickOutside @trigger="openAgentsList = false">
<Button
variant="ghost"
class="!px-0 font-normal hover:!bg-transparent"
text-variant="info"
@click="openAgentsList = !openAgentsList"
>
<Thumbnail
:author="author"
:name="authorName"
:size="20"
:src="authorThumbnailSrc"
/>
<span
v-if="author"
class="text-sm text-n-slate-12 hover:text-n-slate-11"
>
{{ author.available_name }}
</span>
</Button>
<DropdownMenu
v-if="openAgentsList && hasAgentList"
:menu-items="agentList"
class="z-[100] w-48 mt-2 overflow-y-auto ltr:left-0 rtl:right-0 top-full max-h-52"
@action="handleArticleAction"
/>
</OnClickOutside>
</div>
<div class="w-px h-3 bg-slate-50 dark:bg-slate-800" />
<div class="relative">
<OnClickOutside @trigger="openCategoryList = false">
<Button
:label="
selectedCategory?.name ||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.UNCATEGORIZED')
"
:icon="!selectedCategory?.icon ? 'i-lucide-shapes' : ''"
variant="ghost"
class="!px-2 font-normal hover:!bg-transparent"
@click="openCategoryList = !openCategoryList"
>
<span
v-if="selectedCategory"
class="text-sm text-n-slate-12 hover:text-n-slate-11"
>
{{
`${selectedCategory.icon || ''} ${selectedCategory.name || t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.UNCATEGORIZED')}`
}}
</span>
</Button>
<DropdownMenu
v-if="openCategoryList && hasCategoryMenuItems"
:menu-items="categoryList"
class="w-48 mt-2 z-[100] overflow-y-auto left-0 top-full max-h-52"
@action="handleArticleAction"
/>
</OnClickOutside>
</div>
<div class="w-px h-3 bg-slate-50 dark:bg-slate-800" />
<div class="relative">
<OnClickOutside @trigger="openProperties = false">
<Button
:label="
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.MORE_PROPERTIES')
"
icon="i-lucide-plus"
variant="ghost"
:disabled="isNewArticle"
class="!px-2 font-normal hover:!bg-transparent hover:!text-n-slate-11"
@click="openProperties = !openProperties"
/>
<ArticleEditorProperties
v-if="openProperties"
:article="article"
class="right-0 z-[100] mt-2 xl:left-0 top-full"
@save-article="updateMeta"
@close="openProperties = false"
/>
</OnClickOutside>
</div>
</div>
</template>
@@ -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,112 +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"
>
<template #form>
<CategoryForm
ref="categoryFormRef"
mode="edit"
:selected-category="selectedCategory"
:active-locale-code="activeLocaleCode"
:portal-name="route.params.portalSlug"
:active-locale-name="activeLocaleName"
:show-action-buttons="false"
/>
</template>
</Dialog>
</template>
@@ -1,101 +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"
>
<template #form>
<div class="flex flex-col gap-6">
<ComboBox
v-model="selectedLocale"
:options="locales"
:placeholder="
t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.COMBOBOX.PLACEHOLDER')
"
class="[&>div>button]:!border-n-slate-5 [&>div>button]:dark:!border-n-slate-5"
/>
</div>
</template>
</Dialog>
</template>
@@ -1,107 +0,0 @@
<script setup>
import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue';
import { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
const props = defineProps({
locales: {
type: Array,
required: true,
},
portal: {
type: Object,
required: true,
},
});
const store = useStore();
const { t } = useI18n();
const route = useRoute();
const isLocaleDefault = code => {
return props.portal?.meta?.default_locale === code;
};
const updatePortalLocales = async ({
newAllowedLocales,
defaultLocale,
messageKey,
}) => {
let alertMessage = '';
try {
await store.dispatch('portals/update', {
portalSlug: props.portal.slug,
config: {
default_locale: defaultLocale,
allowed_locales: newAllowedLocales,
},
});
alertMessage = t(`HELP_CENTER.PORTAL.${messageKey}.API.SUCCESS_MESSAGE`);
} catch (error) {
alertMessage =
error?.message || t(`HELP_CENTER.PORTAL.${messageKey}.API.ERROR_MESSAGE`);
} finally {
useAlert(alertMessage);
}
};
const changeDefaultLocale = ({ localeCode }) => {
const newAllowedLocales = props.locales.map(locale => locale.code);
updatePortalLocales({
newAllowedLocales,
defaultLocale: localeCode,
messageKey: 'CHANGE_DEFAULT_LOCALE',
});
useTrack(PORTALS_EVENTS.SET_DEFAULT_LOCALE, {
newLocale: localeCode,
from: route.name,
});
};
const deletePortalLocale = ({ localeCode }) => {
const updatedLocales = props.locales
.filter(locale => locale.code !== localeCode)
.map(locale => locale.code);
const defaultLocale = props.portal.meta.default_locale;
updatePortalLocales({
newAllowedLocales: updatedLocales,
defaultLocale,
messageKey: 'DELETE_LOCALE',
});
useTrack(PORTALS_EVENTS.DELETE_LOCALE, {
deletedLocale: localeCode,
from: route.name,
});
};
const handleAction = ({ action }, localeCode) => {
if (action === 'change-default') {
changeDefaultLocale({ localeCode: localeCode });
} else if (action === 'delete') {
deletePortalLocale({ localeCode: localeCode });
}
};
</script>
<template>
<ul role="list" class="w-full h-full space-y-4">
<LocaleCard
v-for="(locale, index) in locales"
:key="index"
:locale="locale.name"
:is-default="isLocaleDefault(locale.code)"
:locale-code="locale.code"
:article-count="locale.articlesCount || 0"
:category-count="locale.categoriesCount || 0"
@action="handleAction($event, locale.code)"
/>
</ul>
</template>
@@ -1,52 +0,0 @@
<script setup>
import LocalesPage from './LocalesPage.vue';
const locales = [
{
name: 'English (en-US)',
isDefault: true,
articleCount: 5,
categoryCount: 5,
},
{
name: 'Spanish (es-ES)',
isDefault: false,
articleCount: 20,
categoryCount: 10,
},
{
name: 'English (en-UK)',
isDefault: false,
articleCount: 15,
categoryCount: 7,
},
{
name: 'Malay (ms-MY)',
isDefault: false,
articleCount: 15,
categoryCount: 7,
},
{
name: 'Malayalam (ml-IN)',
isDefault: false,
articleCount: 10,
categoryCount: 5,
},
{
name: 'Hindi (hi-IN)',
isDefault: false,
articleCount: 15,
categoryCount: 7,
},
];
</script>
<template>
<Story title="Pages/HelpCenter/LocalePage" :layout="{ type: 'single' }">
<Variant title="All Locales">
<div class="w-full min-h-screen bg-white dark:bg-slate-900">
<LocalesPage :locales="locales" />
</div>
</Variant>
</Story>
</template>
@@ -1,61 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useMapGetter } from 'dashboard/composables/store.js';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import LocaleList from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue';
import AddLocaleDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/AddLocaleDialog.vue';
const props = defineProps({
locales: {
type: Array,
required: true,
},
portal: {
type: Object,
default: () => ({}),
},
});
const addLocaleDialogRef = ref(null);
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
const openAddLocaleDialog = () => {
addLocaleDialogRef.value.dialogRef.open();
};
const localeCount = computed(() => props.locales?.length);
</script>
<template>
<HelpCenterLayout :show-pagination-footer="false">
<template #header-actions>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<span class="text-sm font-medium text-slate-800 dark:text-slate-100">
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALES_COUNT', localeCount) }}
</span>
</div>
<Button
:label="$t('HELP_CENTER.LOCALES_PAGE.NEW_LOCALE_BUTTON_TEXT')"
icon="i-lucide-plus"
size="sm"
@click="openAddLocaleDialog"
/>
</div>
</template>
<template #content>
<div
v-if="isSwitchingPortal"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<LocaleList v-else :locales="locales" :portal="portal" />
</template>
<AddLocaleDialog ref="addLocaleDialogRef" :portal="portal" />
</HelpCenterLayout>
</template>
@@ -1,74 +0,0 @@
<script setup>
import { ref, reactive, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
const props = defineProps({
mode: {
type: String,
default: 'add',
},
customDomain: {
type: String,
default: '',
},
});
const emit = defineEmits(['addCustomDomain']);
const { t } = useI18n();
const dialogRef = ref(null);
const formState = reactive({
customDomain: props.customDomain,
});
watch(
() => props.customDomain,
newVal => {
formState.customDomain = newVal;
}
);
const handleDialogConfirm = () => {
emit('addCustomDomain', formState.customDomain);
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
:title="
t(
`HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.${props.mode.toUpperCase()}_HEADER`
)
"
:confirm-button-label="
t(
`HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.${props.mode.toUpperCase()}_CONFIRM_BUTTON_LABEL`
)
"
@confirm="handleDialogConfirm"
>
<template #form>
<Input
v-model="formState.customDomain"
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.LABEL'
)
"
:placeholder="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.PLACEHOLDER'
)
"
/>
</template>
</Dialog>
</template>
@@ -1,51 +0,0 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
defineProps({
activePortalName: {
type: String,
required: true,
},
});
const emit = defineEmits(['deletePortal']);
const { t } = useI18n();
const dialogRef = ref(null);
const handleDialogConfirm = () => {
emit('deletePortal');
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="alert"
:title="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.DIALOG.HEADER',
{
portalName: activePortalName,
}
)
"
:description="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.DIALOG.DESCRIPTION'
)
"
:confirm-button-label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.DIALOG.CONFIRM_BUTTON_LABEL'
)
"
@confirm="handleDialogConfirm"
/>
</template>
@@ -1,79 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { getHostNameFromURL } from 'dashboard/helper/URLHelper';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
customDomain: {
type: String,
default: '',
},
});
const emit = defineEmits(['confirm']);
const { t } = useI18n();
const domain = computed(() => {
const { hostURL, helpCenterURL } = window?.chatwootConfig || {};
return getHostNameFromURL(helpCenterURL) || getHostNameFromURL(hostURL) || '';
});
const subdomainCNAME = computed(
() => `${props.customDomain} CNAME ${domain.value}`
);
const dialogRef = ref(null);
const handleDialogConfirm = () => {
emit('confirm');
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
:title="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HEADER'
)
"
:confirm-button-label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.CONFIRM_BUTTON_LABEL'
)
"
:show-cancel-button="false"
@confirm="handleDialogConfirm"
>
<template #description>
<p class="mb-0 text-sm text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.DESCRIPTION'
)
}}
</p>
</template>
<template #form>
<div class="flex flex-col gap-6">
<span
class="h-10 px-3 py-2.5 text-sm select-none bg-transparent border rounded-lg text-n-slate-11 border-n-strong"
>
{{ subdomainCNAME }}
</span>
<p class="text-sm text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HELP_TEXT'
)
}}
</p>
</div>
</template>
</Dialog>
</template>
@@ -1,327 +0,0 @@
<script setup>
import { reactive, watch, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { buildPortalURL } from 'dashboard/helper/portalHelper';
import { useAlert } from 'dashboard/composables';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
import { uploadFile } from 'dashboard/helper/uploadHelper';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { shouldBeUrl } from 'shared/helpers/Validators';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import EditableAvatar from 'dashboard/components-next/avatar/EditableAvatar.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import ColorPicker from 'dashboard/components-next/colorpicker/ColorPicker.vue';
const props = defineProps({
activePortal: {
type: Object,
required: true,
},
isFetching: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['updatePortal']);
const { t } = useI18n();
const store = useStore();
const getters = useStoreGetters();
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
const state = reactive({
name: '',
headerText: '',
pageTitle: '',
slug: '',
widgetColor: '',
homePageLink: '',
liveChatWidgetInboxId: '',
logoUrl: '',
avatarBlobId: '',
});
const originalState = reactive({ ...state });
const liveChatWidgets = computed(() => {
const inboxes = store.getters['inboxes/getInboxes'];
return inboxes
.filter(inbox => inbox.channel_type === 'Channel::WebWidget')
.map(inbox => ({
value: inbox.id,
label: inbox.name,
}));
});
const rules = {
name: { required, minLength: minLength(2) },
slug: { required },
homePageLink: { shouldBeUrl },
};
const v$ = useVuelidate(rules, state);
const nameError = computed(() =>
v$.value.name.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.ERROR') : ''
);
const slugError = computed(() =>
v$.value.slug.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.ERROR') : ''
);
const homePageLinkError = computed(() =>
v$.value.homePageLink.$error
? t('HELP_CENTER.PORTAL_SETTINGS.FORM.HOME_PAGE_LINK.ERROR')
: ''
);
const isUpdatingPortal = computed(() => {
const slug = props.activePortal?.slug;
if (slug) return getters['portals/uiFlagsIn'].value(slug)?.isUpdating;
return false;
});
watch(
() => props.activePortal,
newVal => {
if (newVal && !props.isFetching) {
Object.assign(state, {
name: newVal.name,
headerText: newVal.header_text,
pageTitle: newVal.page_title,
widgetColor: newVal.color,
homePageLink: newVal.homepage_link,
slug: newVal.slug,
liveChatWidgetInboxId: newVal.inbox?.id,
});
if (newVal.logo) {
const {
logo: { file_url: logoURL, blob_id: blobId },
} = newVal;
state.logoUrl = logoURL;
state.avatarBlobId = blobId;
} else {
state.logoUrl = '';
state.avatarBlobId = '';
}
Object.assign(originalState, state);
}
},
{ immediate: true, deep: true }
);
const hasChanges = computed(() => {
return JSON.stringify(state) !== JSON.stringify(originalState);
});
const handleUpdatePortal = () => {
const portal = {
id: props.activePortal?.id,
slug: state.slug,
name: state.name,
color: state.widgetColor,
page_title: state.pageTitle,
header_text: state.headerText,
homepage_link: state.homePageLink,
blob_id: state.avatarBlobId,
inbox_id: state.liveChatWidgetInboxId,
};
emit('updatePortal', portal);
};
async function uploadLogoToStorage({ file }) {
try {
const { fileUrl, blobId } = await uploadFile(file);
if (fileUrl) {
state.logoUrl = fileUrl;
state.avatarBlobId = blobId;
}
useAlert(t('HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.IMAGE_UPLOAD_SUCCESS'));
} catch (error) {
useAlert(t('HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.IMAGE_UPLOAD_ERROR'));
}
}
async function deleteLogo() {
try {
const portalSlug = props.activePortal?.slug;
await store.dispatch('portals/deleteLogo', {
portalSlug,
});
useAlert(t('HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.IMAGE_DELETE_SUCCESS'));
} catch (error) {
useAlert(
error?.message ||
t('HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.IMAGE_DELETE_ERROR')
);
}
}
const handleAvatarUpload = file => {
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
uploadLogoToStorage(file);
} else {
const errorKey =
'HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.IMAGE_UPLOAD_SIZE_ERROR';
useAlert(t(errorKey, { size: MAXIMUM_FILE_UPLOAD_SIZE }));
}
};
const handleAvatarDelete = () => {
state.logoUrl = '';
state.avatarBlobId = '';
deleteLogo();
};
</script>
<template>
<div class="flex flex-col w-full gap-4">
<div class="flex flex-col w-full gap-2">
<label class="mb-0.5 text-sm font-medium text-gray-900 dark:text-gray-50">
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.LABEL') }}
</label>
<EditableAvatar
label="Avatar"
:src="state.logoUrl"
:name="state.name"
@upload="handleAvatarUpload"
@delete="handleAvatarDelete"
/>
</div>
<div class="flex flex-col w-full gap-4">
<div
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap py-2.5 text-slate-900 dark:text-slate-50"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.NAME.LABEL') }}
</label>
<Input
v-model="state.name"
:placeholder="t('HELP_CENTER.PORTAL_SETTINGS.FORM.NAME.PLACEHOLDER')"
:message-type="nameError ? 'error' : 'info'"
:message="nameError"
custom-input-class="!bg-transparent dark:!bg-transparent"
@input="v$.name.$touch()"
@blur="v$.name.$touch()"
/>
</div>
<div
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap py-2.5 text-slate-900 dark:text-slate-50"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.HEADER_TEXT.LABEL') }}
</label>
<Input
v-model="state.headerText"
:placeholder="
t('HELP_CENTER.PORTAL_SETTINGS.FORM.HEADER_TEXT.PLACEHOLDER')
"
custom-input-class="!bg-transparent dark:!bg-transparent"
/>
</div>
<div
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap text-slate-900 py-2.5 dark:text-slate-50"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.PAGE_TITLE.LABEL') }}
</label>
<Input
v-model="state.pageTitle"
:placeholder="
t('HELP_CENTER.PORTAL_SETTINGS.FORM.PAGE_TITLE.PLACEHOLDER')
"
custom-input-class="!bg-transparent dark:!bg-transparent"
/>
</div>
<div
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap text-slate-900 py-2.5 dark:text-slate-50"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.HOME_PAGE_LINK.LABEL') }}
</label>
<Input
v-model="state.homePageLink"
:placeholder="
t('HELP_CENTER.PORTAL_SETTINGS.FORM.HOME_PAGE_LINK.PLACEHOLDER')
"
:message-type="homePageLinkError ? 'error' : 'info'"
:message="homePageLinkError"
custom-input-class="!bg-transparent dark:!bg-transparent"
@input="v$.homePageLink.$touch()"
@blur="v$.homePageLink.$touch()"
/>
</div>
<div
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap py-2.5 text-slate-900 dark:text-slate-50"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.SLUG.LABEL') }}
</label>
<Input
v-model="state.slug"
:placeholder="t('HELP_CENTER.PORTAL_SETTINGS.FORM.SLUG.PLACEHOLDER')"
:message-type="slugError ? 'error' : 'info'"
:message="slugError || buildPortalURL(state.slug)"
custom-input-class="!bg-transparent dark:!bg-transparent"
@input="v$.slug.$touch()"
@blur="v$.slug.$touch()"
/>
</div>
<div
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap py-2.5 text-slate-900 dark:text-slate-50"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.LABEL') }}
</label>
<ComboBox
v-model="state.liveChatWidgetInboxId"
:options="liveChatWidgets"
:placeholder="
t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.PLACEHOLDER')
"
:message="
t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.HELP_TEXT')
"
class="[&>div>button]:!outline-n-weak"
/>
</div>
<div class="flex items-start justify-between w-full gap-2">
<label
class="text-sm font-medium whitespace-nowrap py-2.5 text-slate-900 dark:text-slate-50"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.BRAND_COLOR.LABEL') }}
</label>
<div class="w-[432px] justify-start">
<ColorPicker v-model="state.widgetColor" />
</div>
</div>
<div class="flex justify-end w-full gap-2">
<Button
:label="t('HELP_CENTER.PORTAL_SETTINGS.FORM.SAVE_CHANGES')"
:disabled="!hasChanges || isUpdatingPortal || v$.$invalid"
:is-loading="isUpdatingPortal"
@click="handleUpdatePortal"
/>
</div>
</div>
</div>
</template>
@@ -1,118 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import AddCustomDomainDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/AddCustomDomainDialog.vue';
import DNSConfigurationDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/DNSConfigurationDialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
activePortal: {
type: Object,
required: true,
},
});
const emit = defineEmits(['updatePortalConfiguration']);
const { t } = useI18n();
const addCustomDomainDialogRef = ref(null);
const dnsConfigurationDialogRef = ref(null);
const updatedDomainAddress = ref('');
const customDomainAddress = computed(
() => props.activePortal?.custom_domain || ''
);
const updatePortalConfiguration = customDomain => {
const portal = {
id: props.activePortal?.id,
custom_domain: customDomain,
};
emit('updatePortalConfiguration', portal);
addCustomDomainDialogRef.value.dialogRef.close();
if (customDomain) {
updatedDomainAddress.value = customDomain;
dnsConfigurationDialogRef.value.dialogRef.open();
}
};
const closeDNSConfigurationDialog = () => {
updatedDomainAddress.value = '';
dnsConfigurationDialogRef.value.dialogRef.close();
};
</script>
<template>
<div class="flex flex-col w-full gap-6">
<div class="flex flex-col gap-2">
<h6 class="text-base font-medium text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.HEADER'
)
}}
</h6>
<span class="text-sm text-n-slate-11">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DESCRIPTION'
)
}}
</span>
</div>
<div class="flex flex-col w-full gap-4">
<div class="flex justify-between w-full gap-2">
<div
v-if="customDomainAddress"
class="flex items-center w-full h-8 gap-4"
>
<label class="text-sm font-medium text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.LABEL'
)
}}
</label>
<span class="text-sm text-n-slate-12">
{{ customDomainAddress }}
</span>
</div>
<div class="flex items-center justify-end w-full">
<Button
v-if="customDomainAddress"
color="slate"
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.EDIT_BUTTON'
)
"
@click="addCustomDomainDialogRef.dialogRef.open()"
/>
<Button
v-else
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.ADD_BUTTON'
)
"
color="slate"
@click="addCustomDomainDialogRef.dialogRef.open()"
/>
</div>
</div>
</div>
<AddCustomDomainDialog
ref="addCustomDomainDialogRef"
:mode="customDomainAddress ? 'edit' : 'add'"
:custom-domain="customDomainAddress"
@add-custom-domain="updatePortalConfiguration"
/>
<DNSConfigurationDialog
ref="dnsConfigurationDialogRef"
:custom-domain="updatedDomainAddress || customDomainAddress"
@confirm="closeDNSConfigurationDialog"
/>
</div>
</template>
@@ -1,13 +0,0 @@
<script setup>
import PortalSettings from './PortalSettings.vue';
</script>
<template>
<Story title="Pages/HelpCenter/PortalSettings" :layout="{ type: 'single' }">
<Variant title="Default">
<div class="w-[1000px] min-h-screen bg-white dark:bg-slate-900">
<PortalSettings />
</div>
</Variant>
</Story>
</template>
@@ -1,130 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import PortalBaseSettings from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue';
import PortalConfigurationSettings from './PortalConfigurationSettings.vue';
import ConfirmDeletePortalDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/ConfirmDeletePortalDialog.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
portals: {
type: Array,
required: true,
},
isFetching: {
type: Boolean,
required: true,
},
});
const emit = defineEmits([
'updatePortal',
'updatePortalConfiguration',
'deletePortal',
]);
const { t } = useI18n();
const route = useRoute();
const confirmDeletePortalDialogRef = ref(null);
const currentPortalSlug = computed(() => route.params.portalSlug);
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
const activePortal = computed(() => {
return props.portals?.find(portal => portal.slug === currentPortalSlug.value);
});
const activePortalName = computed(() => activePortal.value?.name || '');
const isLoading = computed(() => props.isFetching || isSwitchingPortal.value);
const handleUpdatePortal = portal => {
emit('updatePortal', portal);
};
const handleUpdatePortalConfiguration = portal => {
emit('updatePortalConfiguration', portal);
};
const openConfirmDeletePortalDialog = () => {
confirmDeletePortalDialogRef.value.dialogRef.open();
};
const handleDeletePortal = () => {
emit('deletePortal', activePortal.value);
confirmDeletePortalDialogRef.value.dialogRef.close();
};
</script>
<template>
<HelpCenterLayout :show-pagination-footer="false">
<template #content>
<div
v-if="isLoading"
class="flex items-center justify-center py-10 pt-2 pb-8 text-n-slate-11"
>
<Spinner />
</div>
<div
v-else-if="activePortal"
class="flex flex-col w-full gap-4 max-w-[640px] pb-8"
>
<PortalBaseSettings
:active-portal="activePortal"
:is-fetching="isFetching"
@update-portal="handleUpdatePortal"
/>
<div class="w-full h-px bg-slate-50 dark:bg-slate-800/50" />
<PortalConfigurationSettings
:active-portal="activePortal"
:is-fetching="isFetching"
@update-portal-configuration="handleUpdatePortalConfiguration"
/>
<div class="w-full h-px bg-slate-50 dark:bg-slate-800/50" />
<div class="flex items-end justify-between w-full gap-4">
<div class="flex flex-col gap-2">
<h6 class="text-base font-medium text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.HEADER'
)
}}
</h6>
<span class="text-sm text-n-slate-11">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.DESCRIPTION'
)
}}
</span>
</div>
<Button
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.BUTTON',
{
portalName: activePortalName,
}
)
"
color="ruby"
class="max-w-56 !w-fit"
@click="openConfirmDeletePortalDialog"
/>
</div>
</div>
</template>
<ConfirmDeletePortalDialog
ref="confirmDeletePortalDialogRef"
:active-portal-name="activePortalName"
@delete-portal="handleDeletePortal"
/>
</HelpCenterLayout>
</template>
@@ -1,148 +0,0 @@
<script setup>
import { ref, reactive, watch, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { convertToCategorySlug } from 'dashboard/helper/commons.js';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { buildPortalURL } from 'dashboard/helper/portalHelper';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
const emit = defineEmits(['create']);
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const isCreatingPortal = useMapGetter('portals/isCreatingPortal');
const state = reactive({
name: '',
slug: '',
domain: '',
logoUrl: '',
avatarBlobId: '',
});
const rules = {
name: { required, minLength: minLength(2) },
slug: { required },
};
const v$ = useVuelidate(rules, state);
const nameError = computed(() =>
v$.value.name.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.ERROR') : ''
);
const slugError = computed(() =>
v$.value.slug.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.ERROR') : ''
);
const isSubmitDisabled = computed(() => v$.value.$invalid);
watch(
() => state.name,
() => {
state.slug = convertToCategorySlug(state.name);
}
);
const redirectToPortal = portal => {
emit('create', { slug: portal.slug, locale: 'en' });
};
const resetForm = () => {
Object.keys(state).forEach(key => {
state[key] = '';
});
v$.value.$reset();
};
const createPortal = async portal => {
try {
await store.dispatch('portals/create', portal);
dialogRef.value.close();
const analyticsPayload = {
has_custom_domain: Boolean(portal.custom_domain),
};
useTrack(PORTALS_EVENTS.ONBOARD_BASIC_INFORMATION, analyticsPayload);
useTrack(PORTALS_EVENTS.CREATE_PORTAL, analyticsPayload);
useAlert(
t('HELP_CENTER.PORTAL_SETTINGS.API.CREATE_PORTAL.SUCCESS_MESSAGE')
);
resetForm();
redirectToPortal(portal);
} catch (error) {
dialogRef.value.close();
useAlert(
error?.message ||
t('HELP_CENTER.PORTAL_SETTINGS.API.CREATE_PORTAL.ERROR_MESSAGE')
);
}
};
const handleDialogConfirm = async () => {
const isFormCorrect = await v$.value.$validate();
if (!isFormCorrect) return;
const portal = {
name: state.name,
slug: state.slug,
custom_domain: state.domain,
blob_id: state.avatarBlobId || null,
color: '#2781F6', // The default color is set to Chatwoot brand color
};
await createPortal(portal);
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="t('HELP_CENTER.CREATE_PORTAL_DIALOG.TITLE')"
:confirm-button-label="
t('HELP_CENTER.CREATE_PORTAL_DIALOG.CONFIRM_BUTTON_LABEL')
"
:description="t('HELP_CENTER.CREATE_PORTAL_DIALOG.DESCRIPTION')"
:disable-confirm-button="isSubmitDisabled || isCreatingPortal"
:is-loading="isCreatingPortal"
@confirm="handleDialogConfirm"
>
<template #form>
<div class="flex flex-col gap-6">
<Input
id="portal-name"
v-model="state.name"
type="text"
:placeholder="t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.PLACEHOLDER')"
:label="t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.LABEL')"
:message-type="nameError ? 'error' : 'info'"
:message="
nameError || t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.MESSAGE')
"
/>
<Input
id="portal-slug"
v-model="state.slug"
type="text"
:placeholder="t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.PLACEHOLDER')"
:label="t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.LABEL')"
:message-type="slugError ? 'error' : 'info'"
:message="slugError || buildPortalURL(state.slug)"
/>
</div>
</template>
</Dialog>
</template>
@@ -1,44 +0,0 @@
<script setup>
import PortalSwitcher from './PortalSwitcher.vue';
const portals = [
{
id: 1,
name: 'Chatwoot Help Center',
articles: 67,
domain: 'chatwoot.help',
slug: 'help-center',
},
{
id: 2,
name: 'Chatwoot Handbook',
articles: 42,
domain: 'chatwoot.help',
slug: 'handbook',
},
{
id: 3,
name: 'Developer Documentation',
articles: 89,
domain: 'dev.chatwoot.com',
slug: 'docs',
},
];
</script>
<template>
<Story
title="Components/HelpCenter/PortalSwitcher"
:layout="{ type: 'grid', width: '510px' }"
>
<Variant title="Portal Switcher">
<div class="h-[500px] p-4 bg-slate-100 dark:bg-slate-900">
<PortalSwitcher
:portals="portals"
header="Choose a Portal"
description="Select from available help center portals"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,163 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { buildPortalURL } from 'dashboard/helper/portalHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
const emit = defineEmits(['close', 'createPortal']);
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const store = useStore();
const DEFAULT_ROUTE = 'portals_articles_index';
const CATEGORY_ROUTE = 'portals_categories_index';
const CATEGORY_SUB_ROUTES = [
'portals_categories_articles_index',
'portals_categories_articles_edit',
];
const portals = useMapGetter('portals/allPortals');
const currentPortalSlug = computed(() => route.params.portalSlug);
const portalLink = computed(() => {
return buildPortalURL(currentPortalSlug.value);
});
const isPortalActive = portal => {
return portal.slug === currentPortalSlug.value;
};
const getPortalThumbnailSrc = portal => {
return portal?.logo?.file_url || '';
};
const fetchPortalAndItsCategories = async (slug, locale) => {
await store.dispatch('portals/switchPortal', true);
await store.dispatch('portals/index');
const selectedPortalParam = {
portalSlug: slug,
locale,
};
await store.dispatch('portals/show', selectedPortalParam);
await store.dispatch('categories/index', selectedPortalParam);
await store.dispatch('agents/get');
await store.dispatch('portals/switchPortal', false);
};
const handlePortalChange = async portal => {
if (isPortalActive(portal)) return;
const {
slug,
meta: { default_locale: defaultLocale },
} = portal;
emit('close');
await fetchPortalAndItsCategories(slug, defaultLocale);
const targetRouteName = CATEGORY_SUB_ROUTES.includes(route.name)
? CATEGORY_ROUTE
: route.name || DEFAULT_ROUTE;
router.push({
name: targetRouteName,
params: {
portalSlug: slug,
locale: defaultLocale,
},
});
};
const openCreatePortalDialog = () => {
emit('createPortal');
emit('close');
};
const onClickPreviewPortal = () => {
window.open(portalLink.value, '_blank');
};
const redirectToPortalHomePage = () => {
router.push({
name: 'portals_index',
params: {
navigationPath: DEFAULT_ROUTE,
},
});
};
</script>
<template>
<div
class="pt-5 pb-3 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[440px] rounded-xl shadow-md flex flex-col gap-4"
>
<div
class="flex items-center justify-between gap-4 px-6 pb-3 border-b border-n-alpha-2"
>
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<h2
class="text-base font-medium cursor-pointer text-slate-900 dark:text-slate-50 w-fit hover:underline"
@click="redirectToPortalHomePage"
>
{{ t('HELP_CENTER.PORTAL_SWITCHER.PORTALS') }}
</h2>
<Button
icon="i-lucide-arrow-up-right"
variant="ghost"
icon-lib="lucide"
size="sm"
class="!w-6 !h-6 hover:bg-n-slate-2 text-n-slate-11 !p-0.5 rounded-md"
@click="onClickPreviewPortal"
/>
</div>
<p class="text-sm text-slate-600 dark:text-slate-300">
{{ t('HELP_CENTER.PORTAL_SWITCHER.CREATE_PORTAL') }}
</p>
</div>
<Button
:label="t('HELP_CENTER.PORTAL_SWITCHER.NEW_PORTAL')"
color="slate"
icon="i-lucide-plus"
size="sm"
class="!bg-n-alpha-2 hover:!bg-n-alpha-3"
@click="openCreatePortalDialog"
/>
</div>
<div v-if="portals.length > 0" class="flex flex-col gap-2 px-4">
<Button
v-for="(portal, index) in portals"
:key="index"
:label="portal.name"
variant="ghost"
trailing-icon
:icon="isPortalActive(portal) ? 'i-lucide-check' : ''"
class="!justify-end !px-2 !py-2 hover:!bg-n-alpha-2 [&>.i-lucide-check]:text-n-teal-10 h-9"
size="sm"
@click="handlePortalChange(portal)"
>
<div v-if="portal.custom_domain" class="flex items-center gap-1">
<span class="i-lucide-link size-3" />
<span class="text-sm truncate text-n-slate-11">
{{ portal.custom_domain || '' }}
</span>
</div>
<span class="text-sm font-medium truncate text-n-slate-12">
{{ portal.name || '' }}
</span>
<Thumbnail
v-if="portal"
:author="portal"
:name="portal.name"
:size="20"
:src="getPortalThumbnailSrc(portal)"
:show-author-name="false"
icon-name="i-lucide-building-2"
/>
</Button>
</div>
</div>
</template>
@@ -1,66 +0,0 @@
<script setup>
import Avatar from './Avatar.vue';
</script>
<template>
<Story title="Components/Avatar" :layout="{ type: 'grid', width: '400' }">
<Variant title="Default">
<div class="p-4 bg-white dark:bg-slate-900">
<Avatar
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Amaya"
class="bg-ruby-300 dark:bg-ruby-900"
/>
</div>
</Variant>
<Variant title="Default with upload">
<div class="p-4 bg-white dark:bg-slate-900">
<Avatar
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Amaya"
class="bg-ruby-300 dark:bg-ruby-900"
allow-upload
/>
</div>
</Variant>
<Variant title="Invalid or empty SRC">
<div class="p-4 space-x-4 bg-white dark:bg-slate-900">
<Avatar src="https://example.com/ruby.png" name="Ruby" allow-upload />
<Avatar name="Bruce Wayne" allow-upload />
</div>
</Variant>
<Variant title="Rounded Full">
<div class="p-4 space-x-4 bg-white dark:bg-slate-900">
<Avatar
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Amaya"
allow-upload
rounded-full
/>
</div>
</Variant>
<Variant title="Different Sizes">
<div class="flex flex-wrap gap-4 p-4 bg-white dark:bg-slate-900">
<Avatar
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Felix"
:size="48"
class="bg-green-300 dark:bg-green-900"
allow-upload
/>
<Avatar
:size="72"
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Jade"
class="bg-indigo-300 dark:bg-indigo-900"
allow-upload
/>
<Avatar
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Emery"
:size="96"
class="bg-woot-300 dark:bg-woot-900"
allow-upload
/>
</div>
</Variant>
</Story>
</template>
@@ -1,122 +0,0 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { computed, ref, watch } from 'vue';
import wootConstants from 'dashboard/constants/globals';
const props = defineProps({
src: {
type: String,
default: '',
},
name: {
type: String,
required: true,
},
size: {
type: Number,
default: 32,
},
allowUpload: {
type: Boolean,
default: false,
},
roundedFull: {
type: Boolean,
default: false,
},
status: {
type: String,
default: null,
validator: value => {
if (!value) return true;
return wootConstants.AVAILABILITY_STATUS_KEYS.includes(value);
},
},
});
const emit = defineEmits(['upload']);
const isImageValid = ref(true);
function invalidateCurrentImage() {
isImageValid.value = false;
}
const initials = computed(() => {
const splitNames = props.name.split(' ');
if (splitNames.length > 1) {
const firstName = splitNames[0];
const lastName = splitNames[splitNames.length - 1];
return firstName[0] + lastName[0];
}
const firstName = splitNames[0];
return firstName[0];
});
watch(
() => props.src,
() => {
isImageValid.value = true;
}
);
</script>
<template>
<span
class="relative inline"
:style="{
width: `${size}px`,
height: `${size}px`,
}"
>
<slot name="badge" :size>
<div
class="rounded-full w-2.5 h-2.5 absolute z-20"
:style="{
top: `${size - 10}px`,
left: `${size - 10}px`,
}"
:class="{
'bg-n-teal-10': status === 'online',
'bg-n-amber-10': status === 'busy',
'bg-n-slate-10': status === 'offline',
}"
/>
</slot>
<span
role="img"
class="inline-flex relative items-center justify-center object-cover overflow-hidden font-medium bg-woot-50 text-woot-500 group/avatar"
:class="{
'rounded-full': roundedFull,
'rounded-xl': !roundedFull,
}"
:style="{
width: `${size}px`,
height: `${size}px`,
}"
>
<img
v-if="src && isImageValid"
:src="src"
:alt="name"
@error="invalidateCurrentImage"
/>
<span v-else>
{{ initials }}
</span>
<div
v-if="allowUpload"
role="button"
class="absolute inset-0 flex items-center justify-center invisible w-full h-full transition-all duration-500 ease-in-out opacity-0 rounded-xl dark:bg-slate-900/50 bg-slate-900/20 group-hover/avatar:visible group-hover/avatar:opacity-100"
@click="emit('upload')"
>
<Icon
icon="i-lucide-upload"
class="text-white dark:text-white size-4"
/>
</div>
</span>
</span>
</template>
@@ -1,36 +0,0 @@
<script setup>
import EditableAvatar from './EditableAvatar.vue';
</script>
<template>
<Story title="Components/Avatar" :layout="{ type: 'grid', width: '400' }">
<Variant title="Default">
<div class="p-4 bg-white dark:bg-slate-900">
<EditableAvatar
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Amaya"
class="bg-ruby-300 dark:bg-ruby-900"
/>
</div>
</Variant>
<Variant title="Different Sizes">
<div class="flex flex-wrap gap-4 p-4 bg-white dark:bg-slate-900">
<EditableAvatar
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Felix"
:size="48"
class="bg-green-300 dark:bg-green-900"
/>
<EditableAvatar
:size="72"
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Jade"
class="bg-indigo-300 dark:bg-indigo-900"
/>
<EditableAvatar
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Emery"
:size="96"
class="bg-woot-300 dark:bg-woot-900"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,105 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
src: {
type: String,
default: '',
},
size: {
type: Number,
default: 72,
},
name: {
type: String,
default: '',
},
});
const emit = defineEmits(['upload', 'delete']);
const avatarSize = computed(() => `${props.size}px`);
const iconSize = computed(() => `${props.size / 2}px`);
const fileInput = ref(null);
const imgError = ref(false);
const shouldShowImage = computed(() => props.src && !imgError.value);
const handleUploadAvatar = () => {
fileInput.value.click();
};
const handleImageUpload = event => {
const [file] = event.target.files;
if (file) {
emit('upload', {
file,
url: file ? URL.createObjectURL(file) : null,
});
}
};
const handleDeleteAvatar = () => {
if (fileInput.value) {
fileInput.value.value = null;
}
emit('delete');
};
const handleDismiss = event => {
event.stopPropagation();
handleDeleteAvatar();
};
</script>
<template>
<div
class="relative flex flex-col items-center gap-2 select-none rounded-xl outline outline-1 outline-n-container group/avatar"
:style="{ width: avatarSize, height: avatarSize }"
>
<img
v-if="shouldShowImage"
:src="src"
:alt="name || 'avatar'"
class="object-cover w-full h-full shadow-sm rounded-xl"
@error="imgError = true"
/>
<div
v-else
class="flex items-center justify-center w-full h-full rounded-xl bg-n-alpha-2"
>
<Icon
icon="i-lucide-building-2"
class="text-n-brand/50"
:style="{ width: `${iconSize}`, height: `${iconSize}` }"
/>
</div>
<div
v-if="src"
class="absolute z-20 outline outline-1 outline-n-container flex items-center cursor-pointer justify-center w-6 h-6 transition-all invisible opacity-0 duration-500 ease-in-out -top-2.5 -right-2.5 rounded-xl bg-n-solid-3 group-hover/avatar:visible group-hover/avatar:opacity-100"
@click="handleDismiss"
>
<Icon icon="i-lucide-x" class="text-n-slate-11 size-4" />
</div>
<div
class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-500 ease-in-out opacity-0 rounded-xl bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100"
@click="handleUploadAvatar"
>
<Icon
icon="i-lucide-upload"
class="text-white"
:style="{ width: `${iconSize}`, height: `${iconSize}` }"
/>
<input
ref="fileInput"
type="file"
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
class="hidden"
@change="handleImageUpload"
/>
</div>
</div>
</template>
@@ -1,63 +0,0 @@
<script setup>
import { ref } from 'vue';
import Breadcrumb from './Breadcrumb.vue';
const singleItem = ref([{ label: 'Home', link: '#' }]);
const twoItems = ref([
{ label: 'Home', link: '#' },
{ label: 'Categories', link: '#' },
]);
const threeItems = ref([
{ label: 'Home', link: '#' },
{ label: 'Categories', link: '#' },
{ label: 'Marketing', count: 6, emoji: '📊' },
]);
const longBreadcrumb = ref([
{ label: 'Home', link: '#' },
{ label: 'Categories', link: '#', emoji: '📁' },
{ label: 'Marketing', link: '#' },
{ label: 'Digital', link: '#', emoji: '💻' },
{ label: 'Social Media', count: 12, emoji: '📱' },
]);
</script>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<!-- eslint-disable vue/no-undef-components -->
<template>
<Story
title="Components/Breadcrumb"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Single Item">
<div class="w-full p-4 bg-white dark:bg-slate-900">
<Breadcrumb :items="singleItem" />
</div>
</Variant>
<Variant title="Two Items">
<div class="w-full p-4 bg-white dark:bg-slate-900">
<Breadcrumb :items="twoItems" />
</div>
</Variant>
<Variant title="Three Items with Count">
<div class="w-full p-4 bg-white dark:bg-slate-900">
<Breadcrumb :items="threeItems" count-label="articles" />
</div>
</Variant>
<Variant title="Long Breadcrumb">
<div class="w-full p-4 bg-white dark:bg-slate-900">
<Breadcrumb :items="longBreadcrumb" count-label="articles" />
</div>
</Variant>
<Variant title="RTL Support">
<div dir="rtl">
<div class="w-full p-4 bg-white dark:bg-slate-900">
<Breadcrumb :items="threeItems" count-label="articles" />
</div>
</div>
</Variant>
</Story>
</template>
@@ -1,56 +0,0 @@
<script setup>
import { defineProps } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
items: {
type: Array,
required: true,
validator: value => {
return value.every(
item =>
typeof item.label === 'string' &&
(item.link === undefined || typeof item.link === 'string') &&
(item.count === undefined || typeof item.count === 'number')
);
},
},
});
const emit = defineEmits(['click']);
const { t } = useI18n();
const onClick = event => {
emit('click', event);
};
</script>
<template>
<nav :aria-label="t('BREADCRUMB.ARIA_LABEL')" class="flex items-center h-8">
<ol class="flex items-center mb-0">
<li v-for="(item, index) in items" :key="index" class="flex items-center">
<button
v-if="index === 0"
class="inline-flex items-center justify-center min-w-0 gap-2 p-0 text-sm font-medium transition-all duration-200 ease-in-out border-0 rounded-lg text-n-slate-11 hover:text-n-slate-12 outline-transparent max-w-56"
@click="onClick"
>
<span class="min-w-0 truncate">{{ item.label }}</span>
</button>
<template v-else>
<Icon
icon="i-lucide-chevron-right"
class="flex-shrink-0 mx-2 size-4 text-n-slate-11 dark:text-n-slate-11"
/>
<span
class="text-sm truncate text-slate-900 dark:text-slate-50 max-w-56"
>
{{ item.emoji ? item.emoji : '' }} {{ item.label }}
</span>
</template>
</li>
</ol>
</nav>
</template>
@@ -1,124 +0,0 @@
<script setup>
import Button from './Button.vue';
// Constants for documentation
const VARIANTS = ['solid', 'outline', 'faded', 'link', 'ghost'];
const COLORS = ['blue', 'ruby', 'amber', 'slate', 'teal'];
const SIZES = ['default', 'sm', 'lg'];
</script>
<template>
<Story title="Components/Button" :layout="{ type: 'grid', width: '800px' }">
<!-- Basic Variants -->
<Variant title="Basic Variants">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<Button
v-for="variant in VARIANTS"
:key="variant"
:label="variant"
:variant="variant"
/>
</div>
</Variant>
<!-- Colors -->
<Variant title="Color Variants">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<Button
v-for="color in COLORS"
:key="color"
:label="color"
:color="color"
/>
</div>
</Variant>
<!-- Sizes -->
<Variant title="Size Variants">
<div
class="flex flex-wrap items-center gap-2 p-4 bg-white dark:bg-slate-900"
>
<Button v-for="size in SIZES" :key="size" :label="size" :size="size" />
</div>
</Variant>
<!-- Icons -->
<Variant title="Icons">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<Button label="Leading Icon" icon="i-lucide-plus" />
<Button label="Trailing Icon" icon="i-lucide-plus" trailing-icon />
<Button icon="i-lucide-plus" />
</div>
</Variant>
<!-- Loading State -->
<Variant title="Loading State">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<Button label="Loading" is-loading />
<Button label="Loading" variant="outline" is-loading />
<Button is-loading icon="i-lucide-plus" />
</div>
</Variant>
<!-- Disabled State -->
<Variant title="Disabled State">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<Button label="Disabled" disabled />
<Button label="Disabled Outline" variant="outline" disabled />
<Button label="Disabled Icon" icon="delete" disabled />
<Button
label="Disabled Destructive"
color="ruby"
disabled
icon="delete"
size="sm"
/>
</div>
</Variant>
<!-- Color Combinations -->
<Variant title="Color & Variant Combinations">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<template v-for="color in COLORS" :key="color">
<Button
v-for="variant in VARIANTS"
:key="`${color}-${variant}`"
:label="`${color} ${variant}`"
:color="color"
:variant="variant"
/>
</template>
</div>
</Variant>
<!-- Icon Positions -->
<Variant title="Icon Positions & Sizes">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<template v-for="size in SIZES" :key="size">
<Button
:label="`${size} Leading`"
icon="i-lucide-plus"
:size="size"
/>
<Button
:label="`${size} Trailing`"
icon="i-lucide-plus"
trailing-icon
:size="size"
/>
<Button icon="i-lucide-plus" :size="size" />
</template>
</div>
</Variant>
<!-- Ghost & Link Variants -->
<Variant title="Ghost & Link Variants">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<Button label="Ghost Button" variant="ghost" />
<Button label="Ghost with Icon" variant="ghost" icon="i-lucide-plus" />
<Button label="Link Button" variant="link" />
<Button label="Link with Icon" variant="link" icon="i-lucide-plus" />
</div>
</Variant>
</Story>
</template>
@@ -1,167 +0,0 @@
<script setup>
import { computed, useSlots } from 'vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
label: {
type: String,
default: '',
},
variant: {
type: String,
default: 'solid',
validator: value =>
['solid', 'outline', 'faded', 'link', 'ghost'].includes(value),
},
color: {
type: String,
default: 'blue',
validator: value =>
['blue', 'ruby', 'amber', 'slate', 'teal'].includes(value),
},
size: {
type: String,
default: 'md',
validator: value => ['xs', 'sm', 'md', 'lg'].includes(value),
},
icon: {
type: String,
default: '',
},
trailingIcon: {
type: Boolean,
default: false,
},
isLoading: {
type: Boolean,
default: false,
},
});
const slots = useSlots();
const STYLE_CONFIG = {
colors: {
blue: {
solid: 'bg-n-brand text-white hover:brightness-110 outline-transparent',
faded:
'bg-n-brand/10 text-n-slate-12 hover:bg-n-brand/20 outline-transparent',
outline: 'text-n-blue-text outline-n-blue-border',
link: 'text-n-brand hover:underline outline-transparent',
},
ruby: {
solid: 'bg-n-ruby-9 text-white hover:bg-n-ruby-10 outline-transparent',
faded:
'bg-n-ruby-9/10 text-n-ruby-11 hover:bg-n-ruby-9/20 outline-transparent',
outline: 'text-n-ruby-11 hover:bg-n-ruby-9/10 outline-n-ruby-8',
link: 'text-n-ruby-9 hover:underline outline-transparent',
},
amber: {
solid: 'bg-n-amber-9 text-white hover:bg-n-amber-10 outline-transparent',
faded:
'bg-n-amber-9/10 text-n-slate-12 hover:bg-n-amber-9/20 outline-transparent',
outline: 'text-n-amber-11 hover:bg-n-amber-9/10 outline-n-amber-9',
link: 'text-n-amber-9 hover:underline outline-transparent',
},
slate: {
solid:
'bg-n-solid-3 dark:hover:bg-n-solid-2 hover:bg-n-alpha-2 text-n-slate-12 outline-n-container',
faded:
'bg-n-slate-9/10 text-n-slate-12 hover:bg-n-slate-9/20 outline-transparent',
outline: 'text-n-slate-11 outline-n-strong hover:bg-n-slate-9/10',
link: 'text-n-slate-11 hover:text-n-slate-12 hover:underline outline-transparent',
},
teal: {
solid: 'bg-n-teal-9 text-white hover:bg-n-teal-10 outline-transparent',
faded:
'bg-n-teal-9/10 text-n-slate-12 hover:bg-n-teal-9/20 outline-transparent',
outline: 'text-n-teal-11 hover:bg-n-teal-9/10 outline-n-teal-9',
link: 'text-n-teal-9 hover:underline outline-transparent',
},
},
sizes: {
regular: {
xs: 'h-6 px-2',
sm: 'h-8 px-3',
md: 'h-10 px-4',
lg: 'h-12 px-5',
},
iconOnly: {
xs: 'h-6 w-6 p-0',
sm: 'h-8 w-8 p-0',
md: 'h-10 w-10 p-0',
lg: 'h-12 w-12 p-0',
},
link: {
xs: 'p-0',
sm: 'p-0',
md: 'p-0',
lg: 'p-0',
},
},
fontSize: {
xs: 'text-xs',
sm: 'text-sm',
md: 'text-sm font-medium',
lg: 'text-base',
},
base: 'inline-flex items-center justify-center min-w-0 gap-2 transition-all duration-200 ease-in-out border-0 rounded-lg outline-1 outline disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50',
};
const variantClasses = computed(() => {
const variantMap = {
ghost: 'text-n-slate-12 hover:bg-n-alpha-2 outline-transparent',
link: `${STYLE_CONFIG.colors[props.color].link} p-0 font-medium underline-offset-4`,
outline: STYLE_CONFIG.colors[props.color].outline,
faded: STYLE_CONFIG.colors[props.color].faded,
solid: STYLE_CONFIG.colors[props.color].solid,
};
return variantMap[props.variant];
});
const isIconOnly = computed(() => !props.label && !slots.default);
const isLink = computed(() => props.variant === 'link');
const buttonClasses = computed(() => {
const sizeConfig = isIconOnly.value ? 'iconOnly' : 'regular';
const classes = [
variantClasses.value,
props.variant !== 'link' && STYLE_CONFIG.sizes[sizeConfig][props.size],
].filter(Boolean);
return classes.join(' ');
});
const linkButtonClasses = computed(() => {
const classes = [
variantClasses.value,
STYLE_CONFIG.sizes.link[props.size],
].filter(Boolean);
return classes.join(' ');
});
</script>
<template>
<button
:class="{
[STYLE_CONFIG.base]: true,
[isLink ? linkButtonClasses : buttonClasses]: true,
[STYLE_CONFIG.fontSize[size]]: true,
'flex-row-reverse': trailingIcon && !isIconOnly,
}"
>
<slot v-if="(icon || $slots.icon) && !isLoading" name="icon">
<Icon :icon="icon" class="flex-shrink-0" />
</slot>
<Spinner v-if="isLoading" class="!w-5 !h-5 flex-shrink-0" />
<slot v-if="label || $slots.default" name="default">
<span class="min-w-0 truncate">{{ label }}</span>
</slot>
</button>
</template>
@@ -1,101 +0,0 @@
<script setup>
import { ref, defineProps, defineEmits } from 'vue';
import { Chrome } from '@lk77/vue3-color';
import { OnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
modelValue: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:modelValue']);
const isPickerOpen = ref(false);
const toggleColorPicker = () => {
isPickerOpen.value = !isPickerOpen.value;
};
const closeTogglePicker = () => {
if (isPickerOpen.value) {
toggleColorPicker();
}
};
const updateColor = e => {
emit('update:modelValue', e.hex);
};
const pickerRef = ref(null);
</script>
<template>
<div ref="pickerRef" class="relative w-fit">
<OnClickOutside @trigger="closeTogglePicker">
<Button
color="slate"
icon="i-lucide-pipette"
trailing-icon
class="!px-3 !py-3 [&>svg]:w-4 [&>svg]:h-4"
@click="toggleColorPicker"
>
<div class="flex items-center flex-grow gap-2">
<span
class="rounded-md size-4"
:style="{ backgroundColor: modelValue }"
/>
<span class="min-w-0 truncate">{{ modelValue }}</span>
</div>
</Button>
<Chrome
v-if="isPickerOpen"
disable-alpha
:model-value="modelValue"
class="colorpicker--chrome"
@update:model-value="updateColor"
/>
</OnClickOutside>
</div>
</template>
<style scoped lang="scss">
.colorpicker--chrome.vc-chrome {
@apply shadow-lg absolute bg-n-background z-[9999] border border-n-weak dark:border-n-weak rounded-[8px];
:deep() {
.vc-chrome-saturation-wrap {
@apply rounded-t-[7px];
.vc-saturation {
@apply rounded-t-[8px];
}
}
.vc-chrome-body {
@apply rounded-b-[7px] bg-n-alpha-3;
.vc-chrome-toggle-btn {
.vc-chrome-toggle-icon svg {
@apply [&>path]:fill-n-slate-10 dark:[&>path]:fill-n-slate-10 left-3 relative;
}
.vc-chrome-toggle-icon-highlight {
@apply bg-n-background;
}
}
}
input,
.vc-input__input {
@apply bg-n-background text-slate-900 dark:text-slate-50 rounded-md shadow-none;
}
.vc-input__label {
@apply text-n-slate-11 dark:text-n-slate-11;
}
}
}
</style>
@@ -1,30 +0,0 @@
<script setup>
import { ref } from 'vue';
import ComboBox from './ComboBox.vue';
const options = [
{ value: 1, label: 'Option 1' },
{ value: 2, label: 'Option 2' },
{ value: 3, label: 'Option 3' },
{ value: 4, label: 'Option 4' },
{ value: 5, label: 'Option 5' },
];
const selectedValue = ref('');
</script>
<template>
<Story title="Components/ComboBox" :layout="{ type: 'grid', width: '300px' }">
<Variant title="Default">
<div class="w-full p-4 bg-white h-80 dark:bg-slate-900">
<ComboBox v-model="selectedValue" :options="options" />
<p class="mt-2">Selected value: {{ selectedValue }}</p>
</div>
</Variant>
<Variant title="Disabled">
<div class="w-full p-4 bg-white h-80 dark:bg-slate-900">
<ComboBox :options="options" disabled />
</div>
</Variant>
</Story>
</template>
@@ -1,143 +0,0 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { OnClickOutside } from '@vueuse/components';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBoxDropdown from 'dashboard/components-next/combobox/ComboBoxDropdown.vue';
const props = defineProps({
options: {
type: Array,
required: true,
validator: value =>
value.every(option => 'value' in option && 'label' in option),
},
placeholder: {
type: String,
default: '',
},
modelValue: {
type: [String, Number],
default: '',
},
disabled: {
type: Boolean,
default: false,
},
searchPlaceholder: {
type: String,
default: '',
},
emptyState: {
type: String,
default: '',
},
message: {
type: String,
default: '',
},
hasError: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:modelValue']);
const { t } = useI18n();
const selectedValue = ref(props.modelValue);
const open = ref(false);
const search = ref('');
const dropdownRef = ref(null);
const comboboxRef = ref(null);
const filteredOptions = computed(() => {
const searchTerm = search.value.toLowerCase();
return props.options.filter(option =>
option.label.toLowerCase().includes(searchTerm)
);
});
const selectPlaceholder = computed(() => {
return props.placeholder || t('COMBOBOX.PLACEHOLDER');
});
const selectedLabel = computed(() => {
const selected = props.options.find(
option => option.value === selectedValue.value
);
return selected?.label ?? selectPlaceholder.value;
});
const selectOption = option => {
selectedValue.value = option.value;
emit('update:modelValue', option.value);
open.value = false;
search.value = '';
};
const toggleDropdown = () => {
if (props.disabled) return;
open.value = !open.value;
if (open.value) {
search.value = '';
nextTick(() => dropdownRef.value?.focus());
}
};
watch(
() => props.modelValue,
newValue => {
selectedValue.value = newValue;
}
);
</script>
<template>
<div
ref="comboboxRef"
class="relative w-full min-w-0"
:class="{
'cursor-not-allowed': disabled,
'group/combobox': !disabled,
}"
@click.prevent
>
<OnClickOutside @trigger="open = false">
<Button
variant="outline"
:color="hasError && !open ? 'ruby' : open ? 'blue' : 'slate'"
:label="selectedLabel"
trailing-icon
:disabled="disabled"
class="justify-between w-full !px-3 !py-2.5 text-n-slate-12 font-normal group-hover/combobox:border-n-slate-6"
:class="{ focused: open }"
:icon="open ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
@click="toggleDropdown"
/>
<ComboBoxDropdown
ref="dropdownRef"
:open="open"
:options="filteredOptions"
:search-value="search"
:search-placeholder="searchPlaceholder"
:empty-state="emptyState"
:selected-values="selectedValue"
@update:search-value="search = $event"
@select="selectOption"
/>
<p
v-if="message"
class="mt-2 mb-0 text-xs truncate transition-all duration-500 ease-in-out"
:class="{
'text-n-ruby-9': hasError,
'text-n-slate-11': !hasError,
}"
>
{{ message }}
</p>
</OnClickOutside>
</div>
</template>
@@ -1,107 +0,0 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
open: {
type: Boolean,
required: true,
},
options: {
type: Array,
required: true,
},
searchValue: {
type: String,
required: true,
},
searchPlaceholder: {
type: String,
default: '',
},
emptyState: {
type: String,
default: '',
},
multiple: {
type: Boolean,
default: false,
},
selectedValues: {
type: [String, Number, Array],
default: () => [],
},
});
const emit = defineEmits(['update:searchValue', 'select']);
const { t } = useI18n();
const searchInput = ref(null);
const isSelected = option => {
if (Array.isArray(props.selectedValues)) {
return props.selectedValues.includes(option.value);
}
return option.value === props.selectedValues;
};
defineExpose({
focus: () => searchInput.value?.focus(),
});
</script>
<template>
<div
v-show="open"
class="absolute z-50 w-full mt-1 transition-opacity duration-200 border rounded-md shadow-lg bg-n-solid-1 border-n-strong"
>
<div class="relative border-b border-n-strong">
<span class="absolute i-lucide-search top-2.5 size-4 left-3" />
<input
ref="searchInput"
:value="searchValue"
type="search"
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
class="w-full py-2 pl-10 pr-2 text-sm border-none rounded-t-md bg-n-solid-1 text-slate-900 dark:text-slate-50"
@input="emit('update:searchValue', $event.target.value)"
/>
</div>
<ul
class="py-1 mb-0 overflow-auto max-h-60"
role="listbox"
:aria-multiselectable="multiple"
>
<li
v-for="option in options"
:key="option.value"
class="flex items-center justify-between w-full gap-2 px-3 py-2 text-sm transition-colors duration-150 cursor-pointer hover:bg-n-alpha-2"
:class="{
'bg-n-alpha-2': isSelected(option),
}"
role="option"
:aria-selected="isSelected(option)"
@click="emit('select', option)"
>
<span
:class="{
'font-medium': isSelected(option),
}"
class="text-n-slate-12"
>
{{ option.label }}
</span>
<span
v-if="isSelected(option)"
class="flex-shrink-0 i-lucide-check size-4 text-n-slate-11"
/>
</li>
<li
v-if="options.length === 0"
class="px-3 py-2 text-sm text-slate-600 dark:text-slate-300"
>
{{ emptyState || t('COMBOBOX.EMPTY_STATE') }}
</li>
</ul>
</div>
</template>
@@ -1,183 +0,0 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { OnClickOutside } from '@vueuse/components';
import { useI18n } from 'vue-i18n';
import ComboBoxDropdown from 'dashboard/components-next/combobox/ComboBoxDropdown.vue';
const props = defineProps({
options: {
type: Array,
required: true,
validator: value =>
value.every(option => 'value' in option && 'label' in option),
},
placeholder: {
type: String,
default: '',
},
modelValue: {
type: Array,
default: () => [],
},
disabled: {
type: Boolean,
default: false,
},
searchPlaceholder: {
type: String,
default: '',
},
emptyState: {
type: String,
default: '',
},
message: {
type: String,
default: '',
},
hasError: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:modelValue']);
const { t } = useI18n();
const selectedValues = ref(props.modelValue);
const open = ref(false);
const search = ref('');
const dropdownRef = ref(null);
const comboboxRef = ref(null);
const filteredOptions = computed(() => {
const searchTerm = search.value.toLowerCase();
return props.options.filter(option =>
option.label?.toLowerCase().includes(searchTerm)
);
});
const selectPlaceholder = computed(() => {
return props.placeholder || t('COMBOBOX.PLACEHOLDER');
});
const selectedTags = computed(() => {
return selectedValues.value.map(value => {
const option = props.options.find(opt => opt.value === value);
return option || { value, label: value };
});
});
const toggleOption = option => {
const index = selectedValues.value.indexOf(option.value);
if (index === -1) {
selectedValues.value.push(option.value);
} else {
selectedValues.value.splice(index, 1);
}
emit('update:modelValue', selectedValues.value);
};
const removeTag = value => {
const index = selectedValues.value.indexOf(value);
if (index !== -1) {
selectedValues.value.splice(index, 1);
emit('update:modelValue', selectedValues.value);
}
};
const toggleDropdown = () => {
if (props.disabled) return;
open.value = !open.value;
if (open.value) {
search.value = '';
nextTick(() => dropdownRef.value?.focus());
}
};
watch(
() => props.modelValue,
newValue => {
selectedValues.value = newValue;
}
);
defineExpose({
toggleDropdown,
open,
disabled: props.disabled,
});
</script>
<template>
<div
ref="comboboxRef"
class="relative w-full min-w-0"
:class="{
'cursor-not-allowed': disabled,
'group/combobox': !disabled,
}"
@click.prevent
>
<OnClickOutside @trigger="open = false">
<div
class="flex flex-wrap w-full gap-2 px-3 py-2.5 border rounded-lg cursor-pointer bg-n-alpha-black2 min-h-[42px] transition-all duration-500 ease-in-out"
:class="{
'border-n-ruby-8': hasError,
'border-n-weak dark:border-n-weak hover:border-n-slate-6 dark:hover:border-n-slate-6':
!hasError && !open,
'border-n-brand': open,
'cursor-not-allowed pointer-events-none opacity-50': disabled,
}"
@click="toggleDropdown"
>
<div
v-for="tag in selectedTags"
:key="tag.value"
class="flex items-center justify-center max-w-full gap-1 px-2 py-0.5 rounded-lg bg-n-alpha-black1"
@click.stop
>
<span class="flex-grow min-w-0 text-sm truncate text-n-slate-12">
{{ tag.label }}
</span>
<span
class="flex-shrink-0 cursor-pointer i-lucide-x size-3 text-n-slate-11"
@click="removeTag(tag.value)"
/>
</div>
<span
v-if="selectedTags.length === 0"
class="flex items-center text-sm text-n-slate-11"
>
{{ selectPlaceholder }}
</span>
</div>
<ComboBoxDropdown
ref="dropdownRef"
:open="open"
:options="filteredOptions"
:search-value="search"
:search-placeholder="searchPlaceholder"
:empty-state="emptyState"
multiple
:selected-values="selectedValues"
@update:search-value="search = $event"
@select="toggleOption"
/>
<p
v-if="message"
class="mt-2 mb-0 text-xs truncate transition-all duration-500 ease-in-out"
:class="{
'text-n-ruby-9': hasError,
'text-n-slate-11': !hasError,
}"
>
{{ message }}
</p>
</OnClickOutside>
</div>
</template>
@@ -1,55 +0,0 @@
<script setup>
import { ref } from 'vue';
import TagMultiSelectComboBox from './TagMultiSelectComboBox.vue';
const options = [
{ value: 1, label: 'Option 1' },
{ value: 2, label: 'Option 2' },
{ value: 3, label: 'Option 3' },
{ value: 4, label: 'Option 4' },
{ value: 5, label: 'Option 5' },
];
const selectedValues = ref([]);
const preselectedValues = ref([1, 2]);
</script>
<template>
<Story
title="Components/TagMultiSelectComboBox"
:layout="{ type: 'grid', width: '300px' }"
>
<Variant title="Default">
<div class="w-full p-4 bg-white h-80 dark:bg-slate-900">
<TagMultiSelectComboBox v-model="selectedValues" :options="options" />
<p class="mt-2">Selected values: {{ selectedValues }}</p>
</div>
</Variant>
<Variant title="With Preselected Values">
<div class="w-full p-4 bg-white h-80 dark:bg-slate-900">
<TagMultiSelectComboBox
v-model="preselectedValues"
:options="options"
placeholder="Select multiple options"
/>
</div>
</Variant>
<Variant title="Disabled">
<div class="w-full p-4 bg-white h-80 dark:bg-slate-900">
<TagMultiSelectComboBox :options="options" disabled />
</div>
</Variant>
<Variant title="With Error">
<div class="w-full p-4 bg-white h-80 dark:bg-slate-900">
<TagMultiSelectComboBox
:options="options"
has-error
message="This field is required"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,81 +0,0 @@
<script setup>
import { ref } from 'vue';
import Dialog from './Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
const alertDialog = ref(null);
const editDialog = ref(null);
const confirmDialog = ref(null);
const openAlertDialog = () => {
alertDialog.value.open();
};
const openEditDialog = () => {
editDialog.value.open();
};
const openConfirmDialog = () => {
confirmDialog.value.open();
};
// eslint-disable-next-line no-unused-vars
const onConfirm = dialog => {};
</script>
<template>
<Story title="Components/Dialog" :layout="{ type: 'grid', width: '100%' }">
<Variant title="Alert Dialog">
<Button label="Open Alert Dialog" @click="openAlertDialog" />
<Dialog
ref="alertDialog"
type="alert"
title="Alert"
description="This is an alert message."
/>
</Variant>
<Variant title="Edit Dialog">
<Button label="Open Edit Dialog" @click="openEditDialog" />
<Dialog
ref="editDialog"
type="edit"
description="You can create a new portal here, by providing a name and a slug."
title="Create Portal"
confirm-button-label="Save"
@confirm="onConfirm()"
>
<template #form>
<div class="flex flex-col gap-6">
<Input
id="portal-name"
type="text"
placeholder="User Guide | Chatwoot"
label="Name"
message="This will be the name of your public facing portal"
/>
<Input
id="portal-slug"
type="text"
placeholder="user-guide"
label="Slug"
message="app.chatwoot.com/hc/my-portal/en-US/categories/my-slug"
/>
</div>
</template>
</Dialog>
</Variant>
<Variant title="Confirm Dialog">
<Button label="Open Confirm Dialog" @click="openConfirmDialog" />
<Dialog
ref="confirmDialog"
type="confirm"
title="Confirm Action"
description="Are you sure you want to perform this action?"
confirm-button-label="Yes, I'm sure"
cancel-button-label="No, cancel"
@confirm="onConfirm()"
/>
</Variant>
</Story>
</template>
@@ -1,133 +0,0 @@
<script setup>
import { ref } from 'vue';
import { OnClickOutside } from '@vueuse/components';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
type: {
type: String,
default: 'edit',
validator: value => ['alert', 'edit'].includes(value),
},
title: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
cancelButtonLabel: {
type: String,
default: '',
},
confirmButtonLabel: {
type: String,
default: '',
},
disableConfirmButton: {
type: Boolean,
default: false,
},
isLoading: {
type: Boolean,
default: false,
},
showCancelButton: {
type: Boolean,
default: true,
},
showConfirmButton: {
type: Boolean,
default: true,
},
overflowYAuto: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['confirm', 'close']);
const { t } = useI18n();
const isRTL = useMapGetter('accounts/isRTL');
const dialogRef = ref(null);
const dialogContentRef = ref(null);
const open = () => {
dialogRef.value?.showModal();
};
const close = () => {
emit('close');
dialogRef.value?.close();
};
const confirm = () => {
emit('confirm');
};
defineExpose({ open, close });
</script>
<template>
<Teleport to="body">
<dialog
ref="dialogRef"
class="w-full max-w-lg transition-all duration-300 ease-in-out shadow-xl rounded-xl"
:class="overflowYAuto ? 'overflow-y-auto' : 'overflow-visible'"
:dir="isRTL ? 'rtl' : 'ltr'"
@close="close"
>
<OnClickOutside @trigger="close">
<div
ref="dialogContentRef"
class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-left align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
@click.stop
>
<div class="flex flex-col gap-2">
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{ title }}
</h3>
<slot name="description">
<p v-if="description" class="mb-0 text-sm text-n-slate-11">
{{ description }}
</p>
</slot>
</div>
<slot name="form">
<!-- Form content will be injected here -->
</slot>
<div class="flex items-center justify-between w-full gap-3">
<Button
v-if="showCancelButton"
variant="faded"
color="slate"
:label="cancelButtonLabel || t('DIALOG.BUTTONS.CANCEL')"
class="w-full"
@click="close"
/>
<Button
v-if="showConfirmButton"
:color="type === 'edit' ? 'blue' : 'ruby'"
:label="confirmButtonLabel || t('DIALOG.BUTTONS.CONFIRM')"
class="w-full"
:is-loading="isLoading"
:disabled="disableConfirmButton || isLoading"
@click="confirm"
/>
</div>
</div>
</OnClickOutside>
</dialog>
</Teleport>
</template>
<style scoped>
dialog::backdrop {
@apply dark:bg-n-alpha-white bg-n-alpha-black2;
}
</style>
@@ -1,55 +0,0 @@
<script setup>
import { ref } from 'vue';
import DropdownMenu from './DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const menuItems = [
{ label: 'Edit', action: 'edit', icon: 'edit' },
{ label: 'Publish', action: 'publish', icon: 'checkmark' },
{ label: 'Archive', action: 'archive', icon: 'archive' },
{ label: 'Delete', action: 'delete', icon: 'delete' },
];
const isOpen = ref(false);
const toggleDropdown = () => {
isOpen.value = !isOpen.value;
};
const handleAction = () => {
isOpen.value = false;
};
</script>
<template>
<Story title="Components/DropdownMenu" :layout="{ type: 'grid', width: 300 }">
<Variant title="Default">
<div class="p-4 bg-white h-72 dark:bg-slate-900">
<div class="relative">
<Button label="Open Menu" size="sm" @click="toggleDropdown" />
<DropdownMenu
v-if="isOpen"
:menu-items="menuItems"
class="absolute left-0 top-10"
@action="handleAction"
/>
</div>
</div>
</Variant>
<Variant title="Always Open">
<div class="p-4 bg-white h-72 dark:bg-slate-900">
<DropdownMenu :menu-items="menuItems" @action="handleAction" />
</div>
</Variant>
<Variant title="Custom Items">
<div class="p-4 bg-white h-72 dark:bg-slate-900">
<DropdownMenu
:menu-items="[
{ label: 'Custom 1', action: 'custom1', icon: 'file-upload' },
{ label: 'Custom 2', action: 'custom2', icon: 'document' },
{ label: 'Danger', action: 'delete', icon: 'delete' },
]"
@action="handleAction"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,58 +0,0 @@
<script setup>
import { defineProps, defineEmits } from 'vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
defineProps({
menuItems: {
type: Array,
required: true,
validator: value => {
return value.every(item => item.action && item.value && item.label);
},
},
thumbnailSize: {
type: Number,
default: 20,
},
});
const emit = defineEmits(['action']);
const handleAction = (action, value) => {
emit('action', { action, value });
};
</script>
<template>
<div
class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container absolute rounded-xl z-50 py-2 px-2 gap-2 flex flex-col min-w-[136px] shadow-lg"
>
<button
v-for="item in menuItems"
:key="item.action"
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
:class="{
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
'text-n-ruby-11': item.action === 'delete',
'text-n-slate-12': item.action !== 'delete',
}"
:disabled="item.disabled"
@click="handleAction(item.action, item.value)"
>
<Thumbnail
v-if="item.thumbnail"
:author="item.thumbnail"
:name="item.thumbnail.name"
:size="thumbnailSize"
:src="item.thumbnail.src"
/>
<Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0" />
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span v-if="item.label" class="min-w-0 text-sm truncate">{{
item.label
}}</span>
</button>
</div>
</template>
@@ -1,19 +0,0 @@
<script setup>
import { h, isVNode } from 'vue';
const props = defineProps({
icon: { type: [String, Object, Function], required: true },
});
const renderIcon = () => {
if (!props.icon) return null;
if (typeof props.icon === 'function' || isVNode(props.icon)) {
return props.icon;
}
return h('span', { class: props.icon });
};
</script>
<template>
<component :is="renderIcon" />
</template>
@@ -1,28 +0,0 @@
<template>
<svg
v-once
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#woot-logo-clip-2342424e23u32098)">
<path
d="M8 16C12.4183 16 16 12.4183 16 8C16 3.58172 12.4183 0 8 0C3.58172 0 0 3.58172 0 8C0 12.4183 3.58172 16 8 16Z"
fill="#2781F6"
/>
<path
d="M11.4172 11.4172H7.70831C5.66383 11.4172 4 9.75328 4 7.70828C4 5.66394 5.66383 4 7.70835 4C9.75339 4 11.4172 5.66394 11.4172 7.70828V11.4172Z"
fill="white"
stroke="white"
stroke-width="0.1875"
/>
</g>
<defs>
<clipPath id="woot-logo-clip-2342424e23u32098">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
</template>
@@ -1,82 +0,0 @@
<script setup>
import InlineInput from './InlineInput.vue';
</script>
<template>
<Story
title="Components/InlineInput"
:layout="{ type: 'grid', width: '400' }"
>
<Variant title="Default">
<div class="p-4 bg-white dark:bg-slate-800">
<InlineInput id="inline-input-1" placeholder="Default InlineInput" />
</div>
</Variant>
<Variant title="With Label">
<div class="p-4 bg-white dark:bg-slate-800">
<InlineInput
id="inline-input-2"
label="Username"
placeholder="Enter your username"
/>
</div>
</Variant>
<Variant title="Disabled">
<div class="p-4 bg-white dark:bg-slate-800">
<InlineInput
id="inline-input-3"
label="Disabled InlineInput"
placeholder="Can't type here"
disabled
/>
</div>
</Variant>
<Variant title="With Custom Classes">
<div class="flex flex-col gap-4 p-4 bg-white dark:bg-slate-800">
<InlineInput
id="inline-input-4"
label="Custom Input Class"
placeholder="Custom input style"
custom-input-class="placeholder:text-green-200 dark:placeholder:text-green-800"
/>
<InlineInput
id="inline-input-5"
label="Custom Label Class"
placeholder="Custom label style"
custom-label-class="text-green-600 dark:text-green-400"
/>
</div>
</Variant>
<Variant title="Different Types">
<div class="flex flex-col gap-4 p-4 bg-white dark:bg-slate-800">
<InlineInput
id="inline-input-6"
label="Text"
placeholder="Text input"
/>
<InlineInput
id="inline-input-7"
label="Number"
placeholder="Number input"
type="number"
/>
<InlineInput
id="inline-input-8"
label="Password"
placeholder="Password input"
type="password"
/>
<InlineInput
id="inline-input-9"
label="Email"
placeholder="Email input"
type="email"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,70 +0,0 @@
<script setup>
defineProps({
modelValue: {
type: [String, Number],
default: '',
},
type: {
type: String,
default: 'text',
},
customInputClass: {
type: [String, Object, Array],
default: '',
},
customLabelClass: {
type: [String, Object, Array],
default: '',
},
placeholder: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
id: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:modelValue', 'enterPress']);
const onEnterPress = () => {
emit('enterPress');
};
</script>
<template>
<div
class="relative flex items-center justify-between w-full gap-3 whitespace-nowrap"
>
<label
v-if="label"
:for="id"
:class="customLabelClass"
class="mb-0.5 text-sm font-medium text-gray-900 dark:text-gray-50"
>
{{ label }}
</label>
<!-- Added prefix slot to allow adding custom labels to the input -->
<slot name="prefix" />
<input
:id="id"
:value="modelValue"
:type="type"
:placeholder="placeholder"
:disabled="disabled"
:class="customInputClass"
class="flex w-full reset-base text-sm h-6 !mb-0 border-0 rounded-lg bg-transparent dark:bg-transparent placeholder:text-slate-200 dark:placeholder:text-slate-500 disabled:cursor-not-allowed disabled:opacity-50 text-slate-900 dark:text-white transition-all duration-500 ease-in-out"
@input="$emit('update:modelValue', $event.target.value)"
@keydown.enter.prevent="onEnterPress"
/>
</div>
</template>
@@ -1,68 +0,0 @@
<script setup>
import Input from './Input.vue';
</script>
<template>
<Story title="Components/Input" :layout="{ type: 'grid', width: '400' }">
<Variant title="Default">
<div class="p-4 bg-white dark:bg-slate-800">
<Input placeholder="Default Input" />
</div>
</Variant>
<Variant title="With Label">
<div class="p-4 bg-white dark:bg-slate-800">
<Input label="Username" placeholder="Enter your username" />
</div>
</Variant>
<Variant title="Disabled">
<div class="p-4 bg-white dark:bg-slate-800">
<Input label="Disabled Input" placeholder="Can't type here" disabled />
</div>
</Variant>
<Variant title="With Message">
<div class="flex flex-col gap-4 p-4 bg-white dark:bg-slate-800">
<Input
label="Email"
placeholder="Enter your email"
message="We'll never share your email."
/>
<Input
label="Password"
type="password"
placeholder="Enter your password"
message="Password is incorrect"
message-type="error"
/>
<Input
label="Verification Code"
placeholder="Enter the code"
message="Code verified successfully"
message-type="success"
/>
</div>
</Variant>
<Variant title="Different Types">
<div class="flex flex-col gap-4 p-4 bg-white dark:bg-slate-800">
<Input label="Text" type="text" placeholder="Text input" />
<Input label="Number" type="number" placeholder="Number input" />
<Input label="Password" type="password" placeholder="Password input" />
<Input label="Email" type="email" placeholder="Email input" />
<Input label="Date" type="date" />
</div>
</Variant>
<Variant title="Custom Input Class">
<div class="p-4 bg-white dark:bg-slate-800">
<Input
label="Custom Style"
placeholder="Custom input class"
custom-input-class="border-yellow-500 dark:border-yellow-700"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,106 +0,0 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: {
type: [String, Number],
default: '',
},
type: {
type: String,
default: 'text',
},
customInputClass: {
type: [String, Object, Array],
default: '',
},
placeholder: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
id: {
type: String,
default: '',
},
message: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: false,
},
messageType: {
type: String,
default: 'info',
validator: value => ['info', 'error', 'success'].includes(value),
},
min: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:modelValue', 'blur', 'input']);
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 inputBorderClass = computed(() => {
switch (props.messageType) {
case 'error':
return 'border-n-ruby-8 dark:border-n-ruby-8 hover:border-n-ruby-9 dark:hover:border-n-ruby-9 disabled:border-n-ruby-8 dark:disabled:border-n-ruby-8';
default:
return 'border-n-weak dark:border-n-weak hover:border-n-slate-6 dark:hover:border-n-slate-6 disabled:border-n-weak dark:disabled:border-n-weak';
}
});
const handleInput = event => {
emit('update:modelValue', event.target.value);
emit('input', event);
};
</script>
<template>
<div class="relative flex flex-col min-w-0 gap-1">
<label
v-if="label"
:for="id"
class="mb-0.5 text-sm font-medium text-n-slate-12"
>
{{ label }}
</label>
<!-- Added prefix slot to allow adding icons to the input -->
<slot name="prefix" />
<input
:id="id"
:value="modelValue"
:class="[customInputClass, inputBorderClass]"
:type="type"
:placeholder="placeholder"
:disabled="disabled"
:min="['date', 'datetime-local', 'time'].includes(type) ? min : undefined"
class="block w-full reset-base text-sm h-10 !px-3 !py-2.5 !mb-0 border rounded-lg focus:border-n-brand dark:focus:border-n-brand bg-n-alpha-black2 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-n-slate-11 dark:placeholder:text-n-slate-11 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 transition-all duration-500 ease-in-out"
@input="handleInput"
@blur="emit('blur')"
/>
<p
v-if="message"
class="min-w-0 mt-1 mb-0 text-xs truncate transition-all duration-500 ease-in-out"
:class="messageClass"
>
{{ message }}
</p>
</div>
</template>
@@ -1,80 +0,0 @@
<script setup>
import { ref } from 'vue';
import PaginationFooter from './PaginationFooter.vue';
const createPaginationState = (initialPage, totalItems, itemsPerPage) => {
const currentPage = ref(initialPage);
const handlePageChange = newPage => {
currentPage.value = newPage;
};
return { currentPage, totalItems, itemsPerPage, handlePageChange };
};
const defaultState = createPaginationState(1, 100, 16);
const middlePageState = createPaginationState(3, 100, 16);
const lastPageState = createPaginationState(7, 100, 16);
const customItemsState = createPaginationState(2, 100, 10);
const singlePageState = createPaginationState(1, 10, 16);
</script>
<template>
<Story
title="Components/PaginationFooter"
:layout="{ type: 'grid', width: '957' }"
>
<Variant title="Default">
<div class="p-4 bg-white dark:bg-slate-900">
<PaginationFooter
:current-page="defaultState.currentPage.value"
:total-items="defaultState.totalItems"
:items-per-page="defaultState.itemsPerPage"
@update:current-page="defaultState.handlePageChange"
/>
</div>
</Variant>
<Variant title="Middle Page">
<div class="p-4 bg-white dark:bg-slate-900">
<PaginationFooter
:current-page="middlePageState.currentPage.value"
:total-items="middlePageState.totalItems"
:items-per-page="middlePageState.itemsPerPage"
@update:current-page="middlePageState.handlePageChange"
/>
</div>
</Variant>
<Variant title="Last Page">
<div class="p-4 bg-white dark:bg-slate-900">
<PaginationFooter
:current-page="lastPageState.currentPage.value"
:total-items="lastPageState.totalItems"
:items-per-page="lastPageState.itemsPerPage"
@update:current-page="lastPageState.handlePageChange"
/>
</div>
</Variant>
<Variant title="Custom Items Per Page">
<div class="p-4 bg-white dark:bg-slate-900">
<PaginationFooter
:current-page="customItemsState.currentPage.value"
:total-items="customItemsState.totalItems"
:items-per-page="customItemsState.itemsPerPage"
@update:current-page="customItemsState.handlePageChange"
/>
</div>
</Variant>
<Variant title="Single Page">
<div class="p-4 bg-white dark:bg-slate-900">
<PaginationFooter
:current-page="singlePageState.currentPage.value"
:total-items="singlePageState.totalItems"
:items-per-page="singlePageState.itemsPerPage"
@update:current-page="singlePageState.handlePageChange"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,107 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
currentPage: {
type: Number,
required: true,
},
totalItems: {
type: Number,
required: true,
},
itemsPerPage: {
type: Number,
default: 16,
},
});
const emit = defineEmits(['update:currentPage']);
const { t } = useI18n();
const totalPages = computed(() =>
Math.ceil(props.totalItems / props.itemsPerPage)
);
const startItem = computed(
() => (props.currentPage - 1) * props.itemsPerPage + 1
);
const endItem = computed(() =>
Math.min(startItem.value + props.itemsPerPage - 1, props.totalItems)
);
const isFirstPage = computed(() => props.currentPage === 1);
const isLastPage = computed(() => props.currentPage === totalPages.value);
const changePage = newPage => {
if (newPage >= 1 && newPage <= totalPages.value) {
emit('update:currentPage', newPage);
}
};
const currentPageInformation = computed(() => {
return t('PAGINATION_FOOTER.SHOWING', {
startItem: startItem.value,
endItem: endItem.value,
totalItems: props.totalItems,
});
});
const pageInfo = computed(() => {
return t('PAGINATION_FOOTER.CURRENT_PAGE_INFO', {
currentPage: '',
totalPages: totalPages.value,
});
});
</script>
<template>
<div
class="flex justify-between h-12 w-full max-w-[957px] outline outline-n-container outline-1 mx-auto bg-n-solid-2 rounded-xl py-2 ltr:pl-4 rtl:pr-4 ltr:pr-3 rtl:pl-3 items-center"
>
<div class="flex items-center gap-3">
<span class="min-w-0 text-sm font-normal line-clamp-1 text-n-slate-11">
{{ currentPageInformation }}
</span>
</div>
<div class="flex items-center gap-2">
<Button
icon="i-lucide-chevrons-left"
variant="ghost"
size="sm"
class="!w-8 !h-6"
:disabled="isFirstPage"
@click="changePage(1)"
/>
<Button
icon="i-lucide-chevron-left"
variant="ghost"
size="sm"
class="!w-8 !h-6"
:disabled="isFirstPage"
@click="changePage(currentPage - 1)"
/>
<div class="inline-flex items-center gap-2 text-sm text-n-slate-11">
<span class="px-3 tabular-nums py-0.5 bg-n-alpha-black2 rounded-md">
{{ currentPage }}
</span>
<span>{{ pageInfo }}</span>
</div>
<Button
icon="i-lucide-chevron-right"
variant="ghost"
size="sm"
class="!w-8 !h-6"
:disabled="isLastPage"
@click="changePage(currentPage + 1)"
/>
<Button
icon="i-lucide-chevrons-right"
variant="ghost"
size="sm"
class="!w-8 !h-6"
:disabled="isLastPage"
@click="changePage(totalPages)"
/>
</div>
</div>
</template>
@@ -1,71 +0,0 @@
<script setup>
import { computed } from 'vue';
import Icon from 'next/icon/Icon.vue';
const props = defineProps({
label: {
type: String,
required: true,
},
active: {
type: Boolean,
default: false,
},
inbox: {
type: Object,
required: true,
},
});
const channelTypeIconMap = {
'Channel::Api': 'i-ri-cloudy-fill',
'Channel::Email': 'i-ri-mail-fill',
'Channel::FacebookPage': 'i-ri-messenger-fill',
'Channel::Line': 'i-ri-line-fill',
'Channel::Sms': 'i-ri-chat-1-fill',
'Channel::Telegram': 'i-ri-telegram-fill',
'Channel::TwilioSms': 'i-ri-chat-1-fill',
'Channel::TwitterProfile': 'i-ri-twitter-x-fill',
'Channel::WebWidget': 'i-ri-global-fill',
'Channel::Whatsapp': 'i-ri-whatsapp-fill',
};
const providerIconMap = {
microsoft: 'i-ri-microsoft-fill',
google: 'i-ri-google-fill',
};
const channelIcon = computed(() => {
const type = props.inbox.channel_type;
let icon = channelTypeIconMap[type];
if (type === 'Channel::Email' && props.inbox.provider) {
if (Object.keys(providerIconMap).includes(props.inbox.provider)) {
icon = providerIconMap[props.inbox.provider];
}
}
return icon ?? 'i-ri-global-fill';
});
const reauthorizationRequired = computed(() => {
return props.inbox.reauthorization_required;
});
</script>
<template>
<span
class="size-4 grid place-content-center rounded-full bg-n-alpha-2"
:class="{ 'bg-n-solid-blue': active }"
>
<Icon :icon="channelIcon" class="size-3" />
</span>
<div class="flex-1 truncate min-w-0">{{ label }}</div>
<div
v-if="reauthorizationRequired"
v-tooltip.top-end="$t('SIDEBAR.REAUTHORIZE')"
class="grid place-content-center size-5 bg-n-ruby-5/60 rounded-full"
>
<Icon icon="i-woot-alert" class="size-3 text-n-ruby-9" />
</div>
</template>
@@ -1,503 +0,0 @@
<script setup>
import { h, computed, onMounted } from 'vue';
import { provideSidebarContext } from './provider';
import { useAccount } from 'dashboard/composables/useAccount';
import { useKbd } from 'dashboard/composables/utils/useKbd';
import { useMapGetter } from 'dashboard/composables/store';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useStorage } from '@vueuse/core';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import SidebarGroup from './SidebarGroup.vue';
import SidebarProfileMenu from './SidebarProfileMenu.vue';
import ChannelLeaf from './ChannelLeaf.vue';
import SidebarNotificationBell from './SidebarNotificationBell.vue';
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
import Logo from 'next/icon/Logo.vue';
const emit = defineEmits([
'openNotificationPanel',
'closeKeyShortcutModal',
'openKeyShortcutModal',
'showCreateAccountModal',
]);
const { accountScopedRoute } = useAccount();
const store = useStore();
const searchShortcut = useKbd([`$mod`, 'k']);
const { t } = useI18n();
const enableNewConversation = false;
const toggleShortcutModalFn = show => {
if (show) {
emit('openKeyShortcutModal');
} else {
emit('closeKeyShortcutModal');
}
};
useSidebarKeyboardShortcuts(toggleShortcutModalFn);
// We're using localStorage to store the expanded item in the sidebar
// This helps preserve context when navigating between portal and dashboard layouts
// and also when the user refreshes the page
const expandedItem = useStorage(
'next-sidebar-expanded-item',
null,
localStorage
);
const setExpandedItem = name => {
expandedItem.value = expandedItem.value === name ? null : name;
};
provideSidebarContext({
expandedItem,
setExpandedItem,
});
const inboxes = useMapGetter('inboxes/getInboxes');
const labels = useMapGetter('labels/getLabelsOnSidebar');
const teams = useMapGetter('teams/getMyTeams');
const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
const conversationCustomViews = useMapGetter(
'customViews/getConversationCustomViews'
);
onMounted(() => {
store.dispatch('labels/get');
store.dispatch('inboxes/get');
store.dispatch('notifications/unReadCount');
store.dispatch('teams/get');
store.dispatch('attributes/get');
store.dispatch('customViews/get', 'conversation');
store.dispatch('customViews/get', 'contact');
});
const sortedInboxes = computed(() =>
inboxes.value.slice().sort((a, b) => a.name.localeCompare(b.name))
);
const menuItems = computed(() => {
return [
{
name: 'Inbox',
label: t('SIDEBAR.INBOX'),
icon: 'i-lucide-inbox',
to: accountScopedRoute('inbox_view'),
},
{
name: 'Conversation',
label: t('SIDEBAR.CONVERSATIONS'),
icon: 'i-lucide-message-circle',
children: [
{
name: 'All',
label: t('SIDEBAR.ALL_CONVERSATIONS'),
activeOn: ['inbox_conversation'],
to: accountScopedRoute('home'),
},
{
name: 'Mentions',
label: t('SIDEBAR.MENTIONED_CONVERSATIONS'),
activeOn: ['conversation_through_mentions'],
to: accountScopedRoute('conversation_mentions'),
},
{
name: 'Unattended',
activeOn: ['conversation_through_unattended'],
label: t('SIDEBAR.UNATTENDED_CONVERSATIONS'),
to: accountScopedRoute('conversation_unattended'),
},
{
name: 'Folders',
label: t('SIDEBAR.CUSTOM_VIEWS_FOLDER'),
icon: 'i-lucide-folder',
activeOn: ['conversations_through_folders'],
children: conversationCustomViews.value.map(view => ({
name: `${view.name}-${view.id}`,
label: view.name,
to: accountScopedRoute('folder_conversations', { id: view.id }),
})),
},
{
name: 'Teams',
label: t('SIDEBAR.TEAMS'),
icon: 'i-lucide-users',
activeOn: ['conversations_through_team'],
children: teams.value.map(team => ({
name: `${team.name}-${team.id}`,
label: team.name,
to: accountScopedRoute('team_conversations', { teamId: team.id }),
})),
},
{
name: 'Channels',
label: t('SIDEBAR.CHANNELS'),
icon: 'i-lucide-mailbox',
activeOn: ['conversation_through_inbox'],
children: sortedInboxes.value.map(inbox => ({
name: `${inbox.name}-${inbox.id}`,
label: inbox.name,
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
component: leafProps => h(ChannelLeaf, { ...leafProps, inbox }),
})),
},
{
name: 'Labels',
label: t('SIDEBAR.LABELS'),
icon: 'i-lucide-tag',
activeOn: ['conversations_through_label'],
children: labels.value.map(label => ({
name: `${label.title}-${label.id}`,
label: label.title,
icon: h('span', {
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
style: { backgroundColor: label.color },
}),
to: accountScopedRoute('label_conversations', {
label: label.title,
}),
})),
},
],
},
{
name: 'Captain',
icon: 'i-lucide-bot',
label: t('SIDEBAR.CAPTAIN'),
to: accountScopedRoute('captain'),
},
{
name: 'Contacts',
label: t('SIDEBAR.CONTACTS'),
icon: 'i-lucide-contact',
children: [
{
name: 'All Contacts',
label: t('SIDEBAR.ALL_CONTACTS'),
to: accountScopedRoute('contacts_dashboard'),
},
{
name: 'Segments',
icon: 'i-lucide-group',
label: t('SIDEBAR.CUSTOM_VIEWS_SEGMENTS'),
children: contactCustomViews.value.map(view => ({
name: `${view.name}-${view.id}`,
label: view.name,
to: accountScopedRoute('contacts_segments_dashboard', {
id: view.id,
}),
})),
},
{
name: 'Tagged With',
icon: 'i-lucide-tag',
label: t('SIDEBAR.TAGGED_WITH'),
children: labels.value.map(label => ({
name: `${label.title}-${label.id}`,
label: label.title,
icon: h('span', {
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
style: { backgroundColor: label.color },
}),
to: accountScopedRoute('contacts_labels_dashboard', {
label: label.title,
}),
})),
},
],
},
{
name: 'Reports',
label: t('SIDEBAR.REPORTS'),
icon: 'i-lucide-chart-spline',
children: [
{
name: 'Report Overview',
label: t('SIDEBAR.REPORTS_OVERVIEW'),
to: accountScopedRoute('account_overview_reports'),
},
{
name: 'Report Conversation',
label: t('SIDEBAR.REPORTS_CONVERSATION'),
to: accountScopedRoute('conversation_reports'),
},
{
name: 'Reports CSAT',
label: t('SIDEBAR.CSAT'),
to: accountScopedRoute('csat_reports'),
},
{
name: 'Reports Bot',
label: t('SIDEBAR.REPORTS_BOT'),
to: accountScopedRoute('bot_reports'),
},
{
name: 'Reports Agent',
label: t('SIDEBAR.REPORTS_AGENT'),
to: accountScopedRoute('agent_reports'),
},
{
name: 'Reports Label',
label: t('SIDEBAR.REPORTS_LABEL'),
to: accountScopedRoute('label_reports'),
},
{
name: 'Reports Inbox',
label: t('SIDEBAR.REPORTS_INBOX'),
to: accountScopedRoute('inbox_reports'),
},
{
name: 'Reports Team',
label: t('SIDEBAR.REPORTS_TEAM'),
to: accountScopedRoute('team_reports'),
},
{
name: 'Reports SLA',
label: t('SIDEBAR.REPORTS_SLA'),
to: accountScopedRoute('sla_reports'),
},
],
},
{
name: 'Campaigns',
label: t('SIDEBAR.CAMPAIGNS'),
icon: 'i-lucide-megaphone',
children: [
{
name: 'Live chat',
label: t('SIDEBAR.LIVE_CHAT'),
to: accountScopedRoute('campaigns_livechat_index'),
},
{
name: 'SMS',
label: t('SIDEBAR.SMS'),
to: accountScopedRoute('campaigns_sms_index'),
},
],
},
{
name: 'Portals',
label: t('SIDEBAR.HELP_CENTER.TITLE'),
icon: 'i-lucide-library-big',
children: [
{
name: 'Articles',
label: t('SIDEBAR.HELP_CENTER.ARTICLES'),
activeOn: [
'portals_articles_index',
'portals_articles_new',
'portals_articles_edit',
],
to: accountScopedRoute('portals_index', {
navigationPath: 'portals_articles_index',
}),
},
{
name: 'Categories',
label: t('SIDEBAR.HELP_CENTER.CATEGORIES'),
activeOn: [
'portals_categories_index',
'portals_categories_articles_index',
'portals_categories_articles_edit',
],
to: accountScopedRoute('portals_index', {
navigationPath: 'portals_categories_index',
}),
},
{
name: 'Locales',
label: t('SIDEBAR.HELP_CENTER.LOCALES'),
activeOn: ['portals_locales_index'],
to: accountScopedRoute('portals_index', {
navigationPath: 'portals_locales_index',
}),
},
{
name: 'Settings',
label: t('SIDEBAR.HELP_CENTER.SETTINGS'),
activeOn: ['portals_settings_index'],
to: accountScopedRoute('portals_index', {
navigationPath: 'portals_settings_index',
}),
},
],
activeOn: [
'portals_new',
'portals_index',
'portals_articles_index',
'portals_articles_new',
'portals_articles_edit',
'portals_categories_index',
'portals_categories_articles_index',
'portals_categories_articles_edit',
'portals_locales_index',
'portals_settings_index',
],
},
{
name: 'Settings',
label: t('SIDEBAR.SETTINGS'),
icon: 'i-lucide-bolt',
children: [
{
name: 'Settings Account Settings',
label: t('SIDEBAR.ACCOUNT_SETTINGS'),
icon: 'i-lucide-briefcase',
to: accountScopedRoute('general_settings_index'),
},
{
name: 'Settings Agents',
label: t('SIDEBAR.AGENTS'),
icon: 'i-lucide-square-user',
to: accountScopedRoute('agent_list'),
},
{
name: 'Settings Teams',
label: t('SIDEBAR.TEAMS'),
icon: 'i-lucide-users',
to: accountScopedRoute('settings_teams_list'),
},
{
name: 'Settings Inboxes',
label: t('SIDEBAR.INBOXES'),
icon: 'i-lucide-inbox',
to: accountScopedRoute('settings_inbox_list'),
},
{
name: 'Settings Labels',
label: t('SIDEBAR.LABELS'),
icon: 'i-lucide-tags',
to: accountScopedRoute('labels_list'),
},
{
name: 'Settings Custom Attributes',
label: t('SIDEBAR.CUSTOM_ATTRIBUTES'),
icon: 'i-lucide-code',
to: accountScopedRoute('attributes_list'),
},
{
name: 'Settings Automation',
label: t('SIDEBAR.AUTOMATION'),
icon: 'i-lucide-workflow',
to: accountScopedRoute('automation_list'),
},
{
name: 'Settings Agent Bots',
label: t('SIDEBAR.AGENT_BOTS'),
icon: 'i-lucide-bot',
to: accountScopedRoute('agent_bots'),
},
{
name: 'Settings Macros',
label: t('SIDEBAR.MACROS'),
icon: 'i-lucide-toy-brick',
to: accountScopedRoute('macros_wrapper'),
},
{
name: 'Settings Canned Responses',
label: t('SIDEBAR.CANNED_RESPONSES'),
icon: 'i-lucide-message-square-quote',
to: accountScopedRoute('canned_list'),
},
{
name: 'Settings Integrations',
label: t('SIDEBAR.INTEGRATIONS'),
icon: 'i-lucide-blocks',
to: accountScopedRoute('settings_applications'),
},
{
name: 'Settings Audit Logs',
label: t('SIDEBAR.AUDIT_LOGS'),
icon: 'i-lucide-briefcase',
to: accountScopedRoute('auditlogs_list'),
},
{
name: 'Settings Custom Roles',
label: t('SIDEBAR.CUSTOM_ROLES'),
icon: 'i-lucide-shield-plus',
to: accountScopedRoute('custom_roles_list'),
},
{
name: 'Settings Sla',
label: t('SIDEBAR.SLA'),
icon: 'i-lucide-clock-alert',
to: accountScopedRoute('sla_list'),
},
{
name: 'Settings Billing',
label: t('SIDEBAR.BILLING'),
icon: 'i-lucide-credit-card',
to: accountScopedRoute('billing_settings_index'),
},
],
},
];
});
</script>
<template>
<aside
class="w-[200px] bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak h-screen flex flex-col text-sm pb-1"
>
<section class="grid gap-2 mt-2 mb-4">
<div class="flex items-center min-w-0 gap-2 px-2">
<div class="grid flex-shrink-0 size-6 place-content-center">
<Logo />
</div>
<div class="flex-shrink-0 w-px h-3 bg-n-strong" />
<SidebarAccountSwitcher
class="flex-grow min-w-0 -mx-1"
@show-create-account-modal="emit('showCreateAccountModal')"
/>
</div>
<div class="flex gap-2 px-2">
<RouterLink
:to="{ name: 'search' }"
class="flex items-center w-full gap-2 px-2 py-1 border rounded-lg border-n-weak bg-n-solid-3 dark:bg-n-black/30"
>
<span class="flex-shrink-0 i-lucide-search size-4 text-n-slate-11" />
<span class="flex-grow text-left">
{{ t('COMBOBOX.SEARCH_PLACEHOLDER') }}
</span>
<span
class="hidden tracking-wide pointer-events-none select-none text-n-slate-10"
>
{{ searchShortcut }}
</span>
</RouterLink>
<button
v-if="enableNewConversation"
class="flex items-center w-full gap-2 px-2 py-1 border rounded-lg border-n-weak bg-n-solid-3"
>
<span
class="flex-shrink-0 i-lucide-square-pen size-4 text-n-slate-11"
/>
</button>
</div>
</section>
<nav class="grid flex-grow gap-2 px-2 pb-5 overflow-y-scroll no-scrollbar">
<ul class="flex flex-col gap-2 m-0 list-none">
<SidebarGroup
v-for="item in menuItems"
:key="item.name"
v-bind="item"
/>
</ul>
</nav>
<section
class="p-1 border-t border-n-weak shadow-[0px_-2px_4px_0px_rgba(27,28,29,0.02)] flex-shrink-0 flex justify-between gap-2 items-center"
>
<SidebarProfileMenu
@open-key-shortcut-modal="emit('openKeyShortcutModal')"
/>
<div v-if="false" class="flex items-center">
<div class="flex-shrink-0 w-px h-3 bg-n-strong" />
<SidebarNotificationBell
@open-notification-panel="emit('openNotificationPanel')"
/>
</div>
</section>
</aside>
</template>

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