Compare commits

...
Author SHA1 Message Date
Shivam Mishra cde6b75c1e feat: colors for dark mode 2023-12-27 16:53:47 +05:30
Shivam Mishra 9aeb5b8db6 fix: input focus states 2023-12-27 16:51:07 +05:30
Nithin David Thomas 2a66cd5eb7 Updates form input import path 2023-12-21 16:29:26 -08:00
Shivam Mishra 80bd0e39b4 feat: dark mode 2023-12-18 17:49:20 +05:30
Shivam Mishra a1334c947d feat: add placeholder 2023-12-18 17:43:58 +05:30
Shivam Mishra 5ce8c1c3d7 feat: add invite page 2023-12-18 17:42:09 +05:30
Shivam Mishra b617f3e4b2 feat: add invite page 2023-12-18 17:33:32 +05:30
Shivam Mishra 29cb6c18be chore: udpate with label margin 2023-12-18 17:33:05 +05:30
Shivam Mishra 49cfe240e3 fix: scroll 2023-12-18 16:20:21 +05:30
Shivam Mishra 218acfd50a fix: UI and copy 2023-12-18 16:11:54 +05:30
Shivam Mishra 2267d85b29 refactor: move onboarding to dashboard routes
Doing this ensures that login checks are run for this page, coz we want this page to be accessible only to individuals who are logged in
2023-12-18 15:53:58 +05:30
Shivam Mishra f1484f1625 refactor: rename ModalStep to OnboardingStep 2023-12-18 15:52:14 +05:30
Shivam Mishra a0ef39fb12 refactor: rename layout 2023-12-18 15:51:36 +05:30
Shivam Mishra 621ebf13bd fix: rounded class 2023-12-18 15:49:18 +05:30
Shivam Mishra 693cc29339 refactor: move component and routes to dashboard 2023-12-18 15:41:52 +05:30
Shivam Mishra cea740ceb7 feat: new onboarding URL 2023-12-15 15:20:48 +05:30
Shivam Mishra 8fe521d66d feat: update frontend to remove dashboard details 2023-12-15 15:14:05 +05:30
Shivam Mishra f728c8089d feat: update signup API to remove account and company name 2023-12-15 15:13:29 +05:30
Shivam Mishra 585ad7d9a4 feat: remove validations from user and account 2023-12-15 15:12:17 +05:30
Shivam Mishra b36a789518 feat: add background 2023-12-11 18:17:01 +05:30
Shivam Mishra 68d36e314f feat: light mode for steps 2023-12-11 17:55:43 +05:30
Shivam Mishra 1b17068948 feat: add steps 2023-12-11 17:52:58 +05:30
Shivam Mishra 2f5ba952f4 feat: add steps UI to modal 2023-12-11 17:36:31 +05:30
Shivam Mishra 489f2b5bcc feat: separate select component 2023-12-04 16:00:31 +05:30
Shivam Mishra 5468e8a625 feat: add caret 2023-12-04 15:46:09 +05:30
Shivam Mishra 476eff7f5d refactor: placeholder color 2023-12-04 15:38:44 +05:30
Shivam Mishra ffcb148626 feat: add select 2023-12-04 15:37:07 +05:30
Shivam Mishra f32aa4b517 feat: add text area 2023-12-04 14:54:17 +05:30
Shivam Mishra a017c51c6b fix: slot behaviour 2023-12-04 14:25:49 +05:30
Shivam Mishra bdc9b63800 refactor: add with-label container 2023-12-04 14:12:08 +05:30
Shivam Mishra 9275328a3a feat: allow compact input 2023-12-04 13:48:21 +05:30
Shivam Mishra ff30c3ea61 fix: shadow 2023-12-04 13:46:22 +05:30
Shivam Mishra 086b131375 fix: typo 2023-12-04 13:40:58 +05:30
Shivam Mishra 12b56e6a2f refactor: update route name 2023-12-04 13:39:48 +05:30
Shivam Mishra b0e6fc155d feat: add founders note 2023-12-04 13:28:55 +05:30
Shivam Mishra 9c75c3b829 feat: add industry 2023-12-04 13:28:01 +05:30
Shivam Mishra 2d02261bbb fix: padding 2023-12-04 13:13:23 +05:30
Shivam Mishra f4f243bd3f fix: button text 2023-12-04 13:13:16 +05:30
Shivam Mishra 944c6c4842 refactor: style for body 2023-12-04 13:07:41 +05:30
Shivam Mishra 115690b88a fix: form names 2023-12-04 13:07:25 +05:30
Shivam Mishra 4f7ed036b0 feat: add setup user 2023-12-04 13:07:17 +05:30
Shivam Mishra 51b845e937 feat: add setup company UI 2023-12-04 12:44:37 +05:30
Shivam Mishra ae0eac9474 chore: add routes 2023-12-01 18:09:46 +05:30
Muhsin KelothandGitHub bc6e3e598f feat: Delete notification by id API (#8430) 2023-11-29 22:45:11 +05:30
683f9d9a28 fix: article meta does not show up on reload (#8415)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2023-11-29 16:15:31 +05:30
Martín GómezandGitHub f9bf0883c6 feat: Allow more image formats in avatar uploads form (#8419) 2023-11-28 11:18:16 +05:30
ad8ba299c9 feat: Author sections in category block and category list (#8414)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2023-11-27 15:13:38 +05:30
Muhsin KelothandGitHub 3ae64822e1 chore: Enforce HTTP Basic Authentication for Twilio medias. (#8396) 2023-11-27 13:24:53 +05:30
Sivin VargheseandGitHub a49aaee9cf fix: Email messages in dark mode (#8300) 2023-11-24 12:14:02 +05:30
Sivin VargheseandGitHub 2d1f70eb79 feat: Update public portal colors with new design (#8230) 2023-11-23 08:16:52 +05:30
Sojan JoseandGitHub 4fc5f765de chore: Make super admin auth error readable (#8406)
- Remove the cryptic error for super admin auth and make it more readable
2023-11-22 17:26:08 -08:00
Nithin David ThomasandGitHub 7efc2726d9 fix: Updates the text alignment on articles table (#8405) 2023-11-23 02:58:00 +05:30
Pranav Raj SandGitHub 29a778697c fix: Remove validation for message signature (#8404) 2023-11-22 13:09:17 -08:00
Pranav Raj SandGitHub 90649e72bb fix: Add a .present? check for sentiment file path to avoid false positives (#8401) 2023-11-22 10:35:51 -08:00
Pranav Raj SandGitHub 1904ec7df4 fix: Update campaign routes to fix the rendering issue (#8400) 2023-11-22 09:39:41 -08:00
Nithin David ThomasandGitHub 96add30331 chore: Add analytics event for insert article feature (#8393) 2023-11-22 08:18:58 -08:00
9c7148e2ad feat: Split dashboard to chunks for build performance (#8394)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-11-21 14:51:05 -08:00
31c709be5c fix: Opens foreign links from article page in new tab [cw-2725] (#8304)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2023-11-22 01:57:58 +05:30
e750ee6d28 chore: [Snyk] Security upgrade administrate-field-active_storage from 0.4.2 to 1.0.0 (#8382)
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2023-11-20 19:42:54 -08:00
Vishnu NarayananandGitHub 5b3f9ac1cd chore: skip node upgrade if latest on cwctl upgrade (#8336) 2023-11-20 12:48:39 +05:30
Muhsin KelothandGitHub 6c8dacfa0d feat: Facebook delivery reports (#8136) 2023-11-20 12:22:45 +05:30
Sojan feead30b0b Merge branch 'release/3.3.1' into develop 2023-11-18 10:24:35 -08:00
Sojan 4244945e7b Bump version to 3.3.1 2023-11-18 10:23:18 -08:00
Clairton Rodrigo HeinzenandGitHub 17725e4dd0 fix: Breakage of message pane when Openai integration isn't enabled (#8381)
Error when the Openai integration isn't enabled

fixes: https://github.com/chatwoot/chatwoot/issues/8379
2023-11-18 10:18:55 -08:00
Sojan d55bb63aa0 Merge branch 'release/3.3.0' into develop 2023-11-17 20:37:44 -08:00
Sojan 6e22695b85 Bump version to 3.3.0 2023-11-17 20:34:39 -08:00
Pranav Raj SandGitHub 146e46d79f fix: Disable AI Assist CTA if not required (#8375)
- Show CTAs only on production
2023-11-17 20:10:46 -08:00
Nithin David ThomasandGitHub 0af27a2387 feat: Adds support for logo in portal settings page [CW-2585] (#8354) 2023-11-17 19:58:27 -08:00
7380f0e7ce chore: Making OpenAI label suggestions optional (#8374)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-11-17 19:54:15 -08:00
Pranav Raj SandGitHub 2c5a0abcce fix: Update the height of the input based on the rows (#8373) 2023-11-17 17:29:44 -08:00
Sojan JoseandGitHub f23e33c1a9 perf: Remove unwanted conversation count query (#8372) 2023-11-17 16:40:40 -08:00
5588d6e344 feat: convert feature_flag to bigint [CW-2767] (#8350)
Co-authored-by: Sojan <sojan@pepalo.com>
2023-11-17 15:50:24 -08:00
59ace66c6f chore: [Snyk] Fix for vulnerabilities (#8340)
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2023-11-17 15:24:00 -08:00
Sojan JoseandGitHub decef1ad52 chore: Add rails_panel gem for query debugging (#8370) 2023-11-18 02:30:35 +05:30
Sojan JoseandGitHub 0e19a4196b chore: Endpoint to purge portal logo (#8365) 2023-11-18 01:13:22 +05:30
Muhsin KelothandGitHub 04dd65687b feat: Changes to include delivery reports for all channels (#8329) 2023-11-16 19:43:35 +05:30
Muhsin KelothandGitHub 5f503b1a57 feat: Change slack bot and activity message appearance (#8349) 2023-11-16 12:59:52 +05:30
Sojan JoseandGitHub 5f6e974531 chore: Add website_token into request logs (#8362)
- Log website_token into request logs to identify specific widgets
2023-11-15 20:32:21 -08:00
Sojan JoseandGitHub a023eabf59 chore: Add logging for facebook errors (#8360)
- Add logging for Facebook errors
2023-11-15 14:56:21 -08:00
Sivin VargheseandGitHub cb1e25a487 feat: Change the highlight in article page on scrolling (#8330) 2023-11-15 13:51:46 +05:30
Sojan JoseandGitHub bcf7c6c45a chore: Improve account seeding (#8357) 2023-11-15 13:29:00 +05:30
Shivam MishraandGitHub b411b6ca21 feat: remove feature flag checks for message reply to (#8352) 2023-11-15 13:05:58 +05:30
145 changed files with 2756 additions and 1043 deletions
+5 -3
View File
@@ -75,7 +75,7 @@ gem 'jwt'
gem 'pundit'
# super admin
gem 'administrate', '>= 0.19.0'
gem 'administrate-field-active_storage'
gem 'administrate-field-active_storage', '>= 1.0.0'
gem 'administrate-field-belongs_to_search'
##--- gems for pubsub service ---##
@@ -109,9 +109,9 @@ gem 'elastic-apm', require: false
gem 'newrelic_rpm', require: false
gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
gem 'scout_apm', require: false
gem 'sentry-rails', '>= 5.12.0', require: false
gem 'sentry-rails', '>= 5.13.0', require: false
gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.12.0', require: false
gem 'sentry-sidekiq', '>= 5.13.0', require: false
##-- background job processing --##
gem 'sidekiq', '>= 7.1.3'
@@ -200,6 +200,8 @@ group :development do
# profiling
gem 'rack-mini-profiler', '>= 3.1.1', require: false
gem 'stackprof'
# Should install the associated chrome extension to view query logs
gem 'meta_request'
end
group :test do
+33 -27
View File
@@ -113,7 +113,7 @@ GEM
kaminari (>= 1.0)
sassc-rails (~> 2.1)
selectize-rails (~> 0.6)
administrate-field-active_storage (0.4.2)
administrate-field-active_storage (1.0.0)
administrate (>= 0.2.2)
rails (>= 7.0)
administrate-field-belongs_to_search (0.8.0)
@@ -183,7 +183,7 @@ GEM
activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1)
date (3.3.3)
date (3.3.4)
ddtrace (1.11.1)
debase-ruby_core_source (>= 0.10.16, <= 3.2.0)
libdatadog (~> 2.0.0.1.0)
@@ -256,7 +256,7 @@ GEM
fcm (1.0.8)
faraday (>= 1.0.0, < 3.0)
googleauth (~> 1)
ffi (1.15.5)
ffi (1.16.3)
ffi-compiler (1.0.1)
ffi (>= 1.0.0)
rake
@@ -440,7 +440,7 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.21.4)
loofah (2.22.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
@@ -451,13 +451,16 @@ GEM
marcel (1.0.2)
maxminddb (0.1.22)
memoist (0.16.2)
meta_request (0.7.4)
rack-contrib (>= 1.1, < 3)
railties (>= 3.0.0, < 7.1)
method_source (1.0.0)
mime-types (3.4.1)
mime-types-data (~> 3.2015)
mime-types-data (3.2023.0218.1)
mini_magick (4.12.0)
mini_mime (1.1.5)
mini_portile2 (2.8.4)
mini_portile2 (2.8.5)
minitest (5.20.0)
mock_redis (0.36.0)
ruby2_keywords
@@ -469,14 +472,14 @@ GEM
activerecord (>= 5.2)
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.3.7)
net-imap (0.4.5)
date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.2.1)
net-protocol (0.2.2)
timeout
net-smtp (0.3.3)
net-smtp (0.4.0)
net-protocol
netrc (0.11.0)
newrelic-sidekiq-metrics (1.6.2)
@@ -484,15 +487,15 @@ GEM
sidekiq
newrelic_rpm (9.6.0)
base64
nio4r (2.5.9)
nokogiri (1.15.4)
nio4r (2.6.0)
nokogiri (1.15.5)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.15.4-arm64-darwin)
nokogiri (1.15.5-arm64-darwin)
racc (~> 1.4)
nokogiri (1.15.4-x86_64-darwin)
nokogiri (1.15.5-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.15.4-x86_64-linux)
nokogiri (1.15.5-x86_64-linux)
racc (~> 1.4)
numo-narray (0.9.2.1)
oauth (1.1.0)
@@ -555,10 +558,12 @@ GEM
pundit (2.3.0)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.7.1)
racc (1.7.3)
rack (2.2.8)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-contrib (2.4.0)
rack (< 4)
rack-cors (2.0.1)
rack (>= 2.0.0)
rack-mini-profiler (3.1.1)
@@ -599,7 +604,7 @@ GEM
thor (~> 1.0)
zeitwerk (~> 2.5)
rainbow (3.1.1)
rake (13.0.6)
rake (13.1.0)
rb-fsevent (0.11.2)
rb-inotify (0.10.1)
ffi (~> 1.0)
@@ -704,13 +709,13 @@ GEM
activesupport (>= 4)
selectize-rails (0.12.6)
semantic_range (3.0.0)
sentry-rails (5.12.0)
sentry-rails (5.13.0)
railties (>= 5.0)
sentry-ruby (~> 5.12.0)
sentry-ruby (5.12.0)
sentry-ruby (~> 5.13.0)
sentry-ruby (5.13.0)
concurrent-ruby (~> 1.0, >= 1.0.2)
sentry-sidekiq (5.12.0)
sentry-ruby (~> 5.12.0)
sentry-sidekiq (5.13.0)
sentry-ruby (~> 5.13.0)
sidekiq (>= 3.0)
sexp_processor (4.17.0)
shoulda-matchers (5.3.0)
@@ -747,7 +752,7 @@ GEM
spring-watcher-listen (2.1.0)
listen (>= 2.7, < 4.0)
spring (>= 4)
sprockets (4.2.0)
sprockets (4.2.1)
concurrent-ruby (~> 1.0)
rack (>= 2.2.4, < 4)
sprockets-rails (3.4.2)
@@ -760,12 +765,12 @@ GEM
stripe (8.5.0)
telephone_number (1.4.20)
test-prof (1.2.1)
thor (1.2.2)
tilt (2.2.0)
thor (1.3.0)
tilt (2.3.0)
time_diff (0.3.0)
activesupport
i18n
timeout (0.4.0)
timeout (0.4.1)
trailblazer-option (0.1.2)
twilio-ruby (5.77.0)
faraday (>= 0.9, < 3.0)
@@ -836,7 +841,7 @@ DEPENDENCIES
activerecord-import
acts-as-taggable-on
administrate (>= 0.19.0)
administrate-field-active_storage
administrate-field-active_storage (>= 1.0.0)
administrate-field-belongs_to_search
annotate
attr_extras
@@ -895,6 +900,7 @@ DEPENDENCIES
listen
lograge (~> 0.14.0)
maxminddb
meta_request
mock_redis
neighbor
newrelic-sidekiq-metrics (>= 1.6.2)
@@ -929,9 +935,9 @@ DEPENDENCIES
scout_apm
scss_lint
seed_dump
sentry-rails (>= 5.12.0)
sentry-rails (>= 5.13.0)
sentry-ruby
sentry-sidekiq (>= 5.12.0)
sentry-sidekiq (>= 5.13.0)
shoulda-matchers
sidekiq (>= 7.1.3)
sidekiq-cron (>= 1.11.0)
+4 -4
View File
@@ -2,7 +2,7 @@
class AccountBuilder
include CustomExceptions::Account
pattr_initialize [:account_name!, :email!, :confirmed, :user, :user_full_name, :user_password, :super_admin, :locale]
pattr_initialize [:email!, :confirmed, :user, :user_password, :super_admin, :locale]
def perform
if @user.nil?
@@ -39,7 +39,7 @@ class AccountBuilder
end
def create_account
@account = Account.create!(name: @account_name, locale: I18n.locale)
@account = Account.create!(name: '', locale: I18n.locale)
Current.account = @account
end
@@ -63,8 +63,8 @@ class AccountBuilder
def create_user
@user = User.new(email: @email,
password: user_password,
password_confirmation: user_password,
name: @user_full_name)
name: '',
password_confirmation: user_password)
@user.type = 'SuperAdmin' if @super_admin
@user.confirm if @confirmed
@user.save!
@@ -1,7 +1,7 @@
class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseController
RESULTS_PER_PAGE = 15
before_action :fetch_notification, only: [:update]
before_action :fetch_notification, only: [:update, :destroy]
before_action :set_primary_actor, only: [:read_all]
before_action :set_current_page, only: [:index]
@@ -28,6 +28,11 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
render json: @notification
end
def destroy
@notification.destroy
head :ok
end
def unread_count
@unread_count = current_user.notifications.where(account_id: current_account.id, read_at: nil).count
render json: @unread_count
@@ -47,6 +47,11 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
head :ok
end
def logo
@portal.logo.purge if @portal.logo.attached?
head :ok
end
def process_attached_logo
blob_id = params[:blob_id]
blob = ActiveStorage::Blob.find_by(id: blob_id)
@@ -21,8 +21,6 @@ class Api::V1::AccountsController < Api::BaseController
def create
@user, @account = AccountBuilder.new(
account_name: account_params[:account_name],
user_full_name: account_params[:user_full_name],
email: account_params[:email],
user_password: account_params[:password],
locale: account_params[:locale],
@@ -11,11 +11,7 @@ class Public::Api::V1::Portals::BaseController < PublicController
end
def set_color_scheme
@theme = if %w[dark light].include?(params[:theme])
params[:theme]
else
'system'
end
@theme_from_params = params[:theme] if %w[dark light].include?(params[:theme])
end
def portal
@@ -27,7 +27,8 @@ class SuperAdmin::Devise::SessionsController < Devise::SessionsController
true
rescue StandardError => e
@error_message = e.message
Rails.logger.error e.message
@error_message = 'Invalid credentials. Please try again.'
false
end
end
+40 -19
View File
@@ -6,7 +6,17 @@ module PortalHelper
def generate_portal_bg(portal_color, theme)
bg_image = theme == 'dark' ? 'hexagon-dark.svg' : 'hexagon-light.svg'
"background: url(/assets/images/hc/#{bg_image}) #{generate_portal_bg_color(portal_color, theme)}"
"url(/assets/images/hc/#{bg_image}) #{generate_portal_bg_color(portal_color, theme)}"
end
def generate_gradient_to_bottom(theme)
base_color = theme == 'dark' ? '#151718' : 'white'
"linear-gradient(to bottom, transparent, #{base_color})"
end
def generate_portal_hover_color(portal_color, theme)
base_color = theme == 'dark' ? '#1B1B1B' : '#F9F9F9'
"color-mix(in srgb, #{portal_color} 5%, #{base_color})"
end
def language_name(locale)
@@ -14,35 +24,46 @@ module PortalHelper
language_map[locale] || locale
end
def get_theme_names(theme)
if theme == 'light'
I18n.t('public_portal.header.appearance.light')
elsif theme == 'dark'
I18n.t('public_portal.header.appearance.dark')
def theme_query_string(theme)
theme.present? && theme != 'system' ? "?theme=#{theme}" : ''
end
def generate_home_link(portal_slug, portal_locale, theme, is_plain_layout_enabled)
if is_plain_layout_enabled
"/hc/#{portal_slug}/#{portal_locale}#{theme_query_string(theme)}"
else
I18n.t('public_portal.header.appearance.system')
"/hc/#{portal_slug}/#{portal_locale}"
end
end
def get_theme_icon(theme)
if theme == 'light'
'icons/sun'
elsif theme == 'dark'
'icons/moon'
def generate_category_link(params)
portal_slug = params[:portal_slug]
category_locale = params[:category_locale]
category_slug = params[:category_slug]
theme = params[:theme]
is_plain_layout_enabled = params[:is_plain_layout_enabled]
if is_plain_layout_enabled
"/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}#{theme_query_string(theme)}"
else
'icons/monitor'
"/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}"
end
end
def generate_gradient_to_bottom(theme)
"background-image: linear-gradient(to bottom, transparent, #{theme == 'dark' ? '#151718' : 'white'})"
end
def generate_article_link(portal_slug, article_slug, theme)
"/hc/#{portal_slug}/articles/#{article_slug}#{theme.present? && theme != 'system' ? "?theme=#{theme}" : ''}"
def generate_article_link(portal_slug, article_slug, theme, is_plain_layout_enabled)
if is_plain_layout_enabled
"/hc/#{portal_slug}/articles/#{article_slug}#{theme_query_string(theme)}"
else
"/hc/#{portal_slug}/articles/#{article_slug}"
end
end
def render_category_content(content)
ChatwootMarkdownRenderer.new(content).render_markdown_to_plain_text
end
def thumbnail_bg_color(username)
colors = ['#6D95BA', '#A4C3C3', '#E19191']
colors[username.length % colors.size]
end
end
@@ -17,6 +17,10 @@ class PortalsAPI extends ApiClient {
deletePortal(portalSlug) {
return axios.delete(`${this.url}/${portalSlug}`);
}
deleteLogo(portalSlug) {
return axios.delete(`${this.url}/${portalSlug}/logo`);
}
}
export default PortalsAPI;
@@ -16,3 +16,23 @@
width: 100%;
}
}
.integration-hooks {
.formulate-input[data-type='checkbox'] {
.formulate-input-wrapper {
@apply flex;
.formulate-input-element {
@apply pr-2;
input {
@apply mb-0;
}
}
}
.formulate-input-element-decorator {
@apply hidden;
}
}
}
@@ -0,0 +1,88 @@
<template>
<with-label
:label="label"
:name="name"
:has-error="hasError"
:error-message="errorMessage"
>
<template #label> {{ label }} <slot /> </template>
<input
:id="name"
:name="name"
:type="type"
autocomplete="off"
:required="required"
:placeholder="placeholder"
:data-testid="dataTestid"
:value="value"
:class="{
'focus:ring-red-600 ring-red-600': hasError,
'dark:ring-slate-600 dark:focus:ring-woot-500 ring-slate-200':
!hasError,
'px-3 py-3': spacing === 'base',
'px-3 py-2 mb-0': spacing === 'compact',
}"
class="block w-full border-none rounded-md shadow-sm appearance-none outline outline-1 outline-slate-200 dark:outline-slate-800 focus:outline-none focus:outline-0 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 focus:ring-2 focus:ring-woot-500 sm:text-sm sm:leading-6 dark:bg-slate-800"
@input="onInput"
@blur="$emit('blur')"
/>
</with-label>
</template>
<script>
import WithLabel from './WithLabel.vue';
export default {
components: {
WithLabel,
},
props: {
label: {
type: String,
default: '',
},
name: {
type: String,
required: true,
},
type: {
type: String,
default: 'text',
},
required: {
type: Boolean,
default: false,
},
placeholder: {
type: String,
default: '',
},
value: {
type: [String, Number],
default: '',
},
hasError: {
type: Boolean,
default: false,
},
errorMessage: {
type: String,
default: '',
},
dataTestid: {
type: String,
default: '',
},
spacing: {
type: String,
default: 'base',
validator: value => ['base', 'compact'].includes(value),
},
},
methods: {
onInput(e) {
this.$emit('input', e.target.value);
},
},
};
</script>
@@ -0,0 +1,88 @@
<template>
<with-label
:label="label"
:name="name"
:has-error="hasError"
:error-message="errorMessage"
>
<select
:id="id"
:selected="value"
:name="name"
:class="{
'text-slate-400': !value,
'text-slate-900 dark:text-slate-100': value,
}"
class="block w-full px-3 py-2 pr-6 border-0 rounded-md shadow-sm outline-none appearance-none select-caret ring-1 ring-inset placeholder:text-slate-400 focus:ring-2 focus:ring-inset focus:ring-woot-500 sm:text-sm sm:leading-6 dark:bg-slate-700 dark:ring-slate-600 dark:focus:ring-woot-500 ring-slate-200"
@input="onInput"
>
<option value="" disabled selected class="hidden">
{{ placeholder }}
</option>
<slot>
<option v-for="opt in options" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</slot>
</select>
</with-label>
</template>
<script>
import WithLabel from './WithLabel.vue';
export default {
components: {
WithLabel,
},
props: {
id: {
type: String,
default: '',
},
options: {
type: Array,
default: () => [],
},
placeholder: {
type: String,
default: '',
},
name: {
type: String,
required: true,
},
label: {
type: String,
required: true,
},
value: {
type: String,
default: '',
},
hasError: {
type: Boolean,
default: false,
},
errorMessage: {
type: String,
default: '',
},
},
methods: {
onInput(e) {
this.$emit('input', e.target.value);
},
},
};
</script>
<style scoped>
.select-caret {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' width='32' height='24' viewBox='0 0 32 24'><polygon points='0,0 32,0 16,24' style='fill: rgb%28110, 111, 115%29'></polygon></svg>");
background-origin: content-box;
background-position: right -1rem center;
background-repeat: no-repeat;
background-size: 9px 6px;
}
</style>
@@ -0,0 +1,93 @@
<template>
<with-label
:label="label"
:name="name"
:has-error="hasError"
:error-message="errorMessage"
>
<template #label> {{ label }} <slot /> </template>
<textarea
:id="name"
:name="name"
autocomplete="off"
:required="required"
:placeholder="placeholder"
:data-testid="dataTestid"
:value="value"
:rows="rows"
:class="{
'focus:ring-red-600 ring-red-600': hasError,
'dark:ring-slate-600 dark:focus:ring-woot-500 ring-slate-200':
!hasError,
'px-3 py-3': spacing === 'base',
'px-3 py-2': spacing === 'compact',
'resize-none': !allowResize,
}"
class="block w-full border-none rounded-md shadow-sm appearance-none outline outline-1 outline-slate-200 dark:outline-slate-800 focus:outline-none focus:outline-0 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 focus:ring-2 focus:ring-woot-500 sm:text-sm sm:leading-6 dark:bg-slate-800"
@input="onInput"
@blur="$emit('blur')"
/>
</with-label>
</template>
<script>
import WithLabel from './WithLabel.vue';
export default {
components: {
WithLabel,
},
props: {
label: {
type: String,
default: '',
},
name: {
type: String,
required: true,
},
required: {
type: Boolean,
default: false,
},
placeholder: {
type: String,
default: '',
},
value: {
type: [String, Number],
default: '',
},
rows: {
type: Number,
default: 3,
},
allowResize: {
type: Boolean,
default: true,
},
hasError: {
type: Boolean,
default: false,
},
errorMessage: {
type: String,
default: '',
},
dataTestid: {
type: String,
default: '',
},
spacing: {
type: String,
default: 'base',
validator: value => ['base', 'compact'].includes(value),
},
},
methods: {
onInput(e) {
this.$emit('input', e.target.value);
},
},
};
</script>
@@ -0,0 +1,45 @@
<template>
<div class="space-y-1">
<label
v-if="label"
:for="name"
class="flex justify-between text-sm font-medium leading-6 text-slate-900 dark:text-white"
:class="{ 'text-red-500': hasError }"
>
<slot name="label">
{{ label }}
</slot>
</label>
<div>
<slot />
<span
v-if="errorMessage && hasError"
class="text-xs text-red-400 leading-2"
>
{{ errorMessage }}
</span>
</div>
</div>
</template>
<script>
export default {
props: {
label: {
type: String,
default: '',
},
name: {
type: String,
required: true,
},
hasError: {
type: Boolean,
default: false,
},
errorMessage: {
type: String,
default: '',
},
},
};
</script>
@@ -61,6 +61,7 @@ export default {
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
isAChatwootInstance: 'globalConfig/isAChatwootInstance',
}),
isAICTAModalDismissed() {
return this.uiSettings.is_open_ai_cta_modal_dismissed;
@@ -70,7 +71,8 @@ export default {
return (
this.isAdmin &&
!this.isAIIntegrationEnabled &&
!this.isAICTAModalDismissed
!this.isAICTAModalDismissed &&
this.isAChatwootInstance
);
},
// Display a AI CTA button for agents and other admins who have not yet opened the AI assistance modal.
@@ -114,7 +114,6 @@ import { LocalStorage } from 'shared/helpers/localStorage';
// constants
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { REPLY_POLICY } from 'shared/constants/links';
import wootConstants from 'dashboard/constants/globals';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
@@ -285,14 +284,6 @@ export default {
return this.currentChat.unread_count || 0;
},
inboxSupportsReplyTo() {
if (
!this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.MESSAGE_REPLY_TO
)
) {
return {};
}
return {
incoming: this.inboxHasFeature(INBOX_FEATURES.REPLY_TO),
outgoing: this.inboxHasFeature(INBOX_FEATURES.REPLY_TO_OUTGOING),
@@ -349,7 +340,7 @@ export default {
// method available in mixin, need to ensure that integrations are present
await this.fetchIntegrationsIfRequired();
if (!this.isAIIntegrationEnabled) {
if (!this.isLabelSuggestionFeatureEnabled) {
return;
}
@@ -194,7 +194,6 @@ import {
replaceSignature,
extractTextFromMarkdown,
} from 'dashboard/helper/editorHelper';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
@@ -276,11 +275,7 @@ export default {
return (
this.inReplyTo?.id &&
!this.isPrivate &&
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO) &&
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.MESSAGE_REPLY_TO
)
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO)
);
},
showRichContentEditor() {
@@ -629,6 +624,8 @@ export default {
`${this.$t('CONVERSATION.REPLYBOX.INSERT_READ_MORE')} ${url}`
);
}
this.$track(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK);
},
toggleRichContentEditor() {
this.updateUISettings({
@@ -192,7 +192,7 @@ export default {
if (!this.showStatusIndicators) {
return false;
}
// Messages will be marked as sent for the Email channel if they have a source ID.
if (this.isAnEmailChannel) {
return !!this.sourceId;
}
@@ -201,11 +201,12 @@ export default {
this.isAWhatsAppChannel ||
this.isATwilioChannel ||
this.isAFacebookInbox ||
this.isASmsInbox
this.isASmsInbox ||
this.isATelegramChannel
) {
return this.sourceId && this.isSent;
}
// There is no source id for the line channel
// All messages will be mark as sent for the Line channel, as there is no source ID.
if (this.isALineChannel) {
return true;
}
@@ -216,15 +217,15 @@ export default {
if (!this.showStatusIndicators) {
return false;
}
if (
this.isAWhatsAppChannel ||
this.isATwilioChannel ||
this.isASmsInbox
this.isASmsInbox ||
this.isAFacebookInbox
) {
return this.sourceId && this.isDelivered;
}
// We will consider messages as delivered for web widget inbox and API inbox if they are sent
// All messages marked as delivered for the web widget inbox and API inbox once they are sent.
if (this.isAWebWidgetInbox || this.isAPIInbox) {
return this.isSent;
}
@@ -238,12 +239,6 @@ export default {
if (!this.showStatusIndicators) {
return false;
}
if (this.isAWebWidgetInbox || this.isAPIInbox) {
const { contact_last_seen_at: contactLastSeenAt } = this.currentChat;
return contactLastSeenAt >= this.createdAt;
}
if (
this.isAWhatsAppChannel ||
this.isATwilioChannel ||
@@ -252,6 +247,10 @@ export default {
return this.sourceId && this.isRead;
}
if (this.isAWebWidgetInbox || this.isAPIInbox) {
return this.isRead;
}
return false;
},
},
@@ -7,7 +7,11 @@
}"
>
<div v-if="!isEmail" v-dompurify-html="message" class="text-content" />
<letter v-else class="text-content" :html="message" />
<letter
v-else
class="text-content bg-white dark:bg-white text-slate-900 dark:text-slate-900 p-2 rounded-[4px]"
:html="message"
/>
<button
v-if="showQuoteToggle"
class="text-slate-300 dark:text-slate-300 cursor-pointer text-xs py-1"
@@ -25,7 +25,7 @@
id="file"
ref="file"
type="file"
accept="image/png, image/jpeg, image/gif"
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
@change="handleImageUpload"
/>
<slot />
@@ -44,3 +44,4 @@ export default {
EXAMPLE_WEBHOOK_URL: 'https://example/api/webhook',
};
export const DEFAULT_REDIRECT_URL = '/app/';
export const ONBOARDING_START_URL = '/app/start/setup-profile';
-1
View File
@@ -16,6 +16,5 @@ export const FEATURE_FLAGS = {
TEAM_MANAGEMENT: 'team_management',
VOICE_RECORDER: 'voice_recorder',
AUDIT_LOGS: 'audit_logs',
MESSAGE_REPLY_TO: 'message_reply_to',
INSERT_ARTICLE_IN_REPLY: 'insert_article_in_reply',
};
@@ -9,6 +9,7 @@ export const CONVERSATION_EVENTS = Object.freeze({
SEARCH_CONVERSATION: 'Searched conversations',
APPLY_FILTER: 'Applied filters in the conversation list',
CHANGE_PRIORITY: 'Assigned priority to a conversation',
INSERT_ARTICLE_LINK: 'Inserted article into reply via article search',
});
export const ACCOUNT_EVENTS = Object.freeze({
@@ -96,3 +96,15 @@ export const getArticleSearchURL = ({
return `${host}/${portalSlug}/articles?${queryParams.toString()}`;
};
export const hasValidAvatarUrl = avatarUrl => {
try {
const { host: avatarUrlHost } = new URL(avatarUrl);
const isFromGravatar = ['www.gravatar.com', 'gravatar'].includes(
avatarUrlHost
);
return avatarUrl && !isFromGravatar;
} catch (error) {
return false;
}
};
@@ -4,6 +4,7 @@ import {
isValidURL,
conversationListPageURL,
getArticleSearchURL,
hasValidAvatarUrl,
} from '../URLHelper';
describe('#URL Helpers', () => {
@@ -164,4 +165,29 @@ describe('#URL Helpers', () => {
expect(url).toBe('myurl.com/news/articles?page=1&locale=en');
});
});
describe('hasValidAvatarUrl', () => {
test('should return true for valid non-Gravatar URL', () => {
expect(hasValidAvatarUrl('https://chatwoot.com/avatar.jpg')).toBe(true);
});
test('should return false for a Gravatar URL (www.gravatar.com)', () => {
expect(hasValidAvatarUrl('https://www.gravatar.com/avatar.jpg')).toBe(
false
);
});
test('should return false for a Gravatar URL (gravatar)', () => {
expect(hasValidAvatarUrl('https://gravatar/avatar.jpg')).toBe(false);
});
test('should handle invalid URL', () => {
expect(hasValidAvatarUrl('invalid-url')).toBe(false); // or expect an error, depending on function design
});
test('should return false for empty or undefined URL', () => {
expect(hasValidAvatarUrl('')).toBe(false);
expect(hasValidAvatarUrl()).toBe(false);
});
});
});
@@ -223,7 +223,10 @@
"LOGO": {
"LABEL": "Logo",
"UPLOAD_BUTTON": "Upload logo",
"HELP_TEXT": "This logo will be displayed on the portal header."
"HELP_TEXT": "This logo will be displayed on the portal header.",
"IMAGE_UPLOAD_SUCCESS": "Logo uploaded successfully",
"IMAGE_UPLOAD_ERROR": "Logo deleted successfully",
"IMAGE_DELETE_ERROR": "Error while deleting logo"
},
"NAME": {
"LABEL": "Name",
@@ -40,5 +40,65 @@
},
"SUBMIT": "Create account",
"HAVE_AN_ACCOUNT": "Already have an account?"
},
"ONBOARDING": {
"PROFILE": {
"TITLE": "Tell us about yourself",
"BODY": "Here, we'll set up your details like profile picture, display name, contact info, etc., visible to end users, to make a positive first impression.",
"SUBMIT": "Continue to company setup",
"FULL_NAME": {
"LABEL": "Full name",
"PLACEHOLDER": "Enter your full name"
},
"DISPLAY_NAME": {
"LABEL": "Display name",
"PLACEHOLDER": "Enter the name you want to display your customers"
},
"PHONE_NUMBER": {
"LABEL": "Phone number (Optional)",
"PLACEHOLDER": "Enter your phone number"
},
"SIGNATURE": {
"LABEL": "Signature",
"PLACEHOLDER": "Add a signature for your messages. You can set this later in your settings."
}
},
"COMPANY": {
"TITLE": "Tell us about your company",
"BODY": "In this section, we'll set up the language, timezone, and company size. This information allows us to provide contextually accurate information and suggest relevant features for you.",
"SUBMIT": "Continue",
"COMPANY_NAME": {
"LABEL": "Company name",
"PLACEHOLDER": "Enter your company name",
"ERROR": "Company name is too short."
},
"INDUSTRY": {
"LABEL": "Industry",
"PLACEHOLDER": "Select your industry"
},
"TIMEZONE": {
"LABEL": "Timezone",
"PLACEHOLDER": "What timezone do you work from?"
},
"LOCALE": {
"LABEL": "Language",
"PLACEHOLDER": "Select the language you want to use"
},
"SIZE": {
"LABEL": "Company size",
"PLACEHOLDER": "How many people work at your company?"
}
},
"INVITE_TEAM": {
"TITLE": "Invite your team",
"BODY": "Invite your team members to Chatwoot. You can add them later as well.",
"SUBMIT": "Send invitations",
"PLACEHOLDER": "robin@chatwoot.com",
"SKIP": "Skip for now"
},
"FOUNDERS_NOTE": {
"TITLE": "A note from the founder",
"SUBMIT": "Go to dashboard"
}
}
}
}
+14 -6
View File
@@ -15,18 +15,26 @@ export default {
currentChat: 'getSelectedChat',
replyMode: 'draftMessages/getReplyEditorMode',
}),
isAIIntegrationEnabled() {
return !!this.appIntegrations.find(
aiIntegration() {
return this.appIntegrations.find(
integration => integration.id === 'openai' && !!integration.hooks.length
);
)?.hooks[0];
},
isAIIntegrationEnabled() {
return !!this.aiIntegration;
},
isLabelSuggestionFeatureEnabled() {
if (this.aiIntegration) {
const { settings = {} } = this.aiIntegration || {};
return settings.label_suggestion;
}
return false;
},
isFetchingAppIntegrations() {
return this.uiFlags.isFetching;
},
hookId() {
return this.appIntegrations.find(
integration => integration.id === 'openai' && !!integration.hooks.length
).hooks[0].id;
return this.aiIntegration.id;
},
draftMessage() {
return this.$store.getters['draftMessages/get'](this.draftKey);
@@ -1,7 +1,8 @@
/* eslint-disable storybook/default-exports */
import SearchView from './components/SearchView.vue';
import { frontendURL } from '../../helper/URLHelper';
const SearchView = () => import('./components/SearchView.vue');
export const routes = [
{
path: frontendURL('accounts/:accountId/search'),
@@ -12,6 +12,7 @@
<div v-else class="chat-message-input is-focused">
<resizable-text-area
id="chat-input"
:rows="1"
:placeholder="
$t('INBOX_MGMT.WIDGET_BUILDER.FOOTER.CHAT_INPUT_PLACEHOLDER')
"
@@ -1,7 +1,7 @@
/* eslint arrow-body-style: 0 */
import ContactsView from './components/ContactsView';
import ContactManageView from './pages/ContactManageView';
import { frontendURL } from '../../../helper/URLHelper';
const ContactsView = () => import('./components/ContactsView.vue');
const ContactManageView = () => import('./pages/ContactManageView.vue');
export const routes = [
{
@@ -1,6 +1,6 @@
/* eslint arrow-body-style: 0 */
import ConversationView from './ConversationView';
import { frontendURL } from '../../../helper/URLHelper';
const ConversationView = () => import('./ConversationView');
export default {
routes: [
@@ -1,4 +1,3 @@
import AppContainer from './Dashboard';
import settings from './settings/settings.routes';
import conversation from './conversation/conversation.routes';
import { routes as searchRoutes } from '../../modules/search/search.routes';
@@ -7,7 +6,12 @@ import { routes as notificationRoutes } from './notifications/routes';
import { frontendURL } from '../../helper/URLHelper';
import helpcenterRoutes from './helpcenter/helpcenter.routes';
const Suspended = () => import('./suspended/Index');
const AppContainer = () => import('./Dashboard.vue');
const Suspended = () => import('./suspended/Index.vue');
const SetupProfile = () => import('./start/SetupProfile.vue');
const SetupCompany = () => import('./start/SetupCompany.vue');
const FoundersNote = () => import('./start/FoundersNote.vue');
const InviteTeam = () => import('./start/InviteTeam.vue');
export default {
routes: [
@@ -29,5 +33,25 @@ export default {
roles: ['administrator', 'agent'],
component: Suspended,
},
{
path: frontendURL('start/setup-profile'),
name: 'onboarding_setup_profile',
component: SetupProfile,
},
{
path: frontendURL('start/setup-company'),
name: 'onboarding_setup_company',
component: SetupCompany,
},
{
path: frontendURL('start/founders-note'),
name: 'onboarding_founders_note',
component: FoundersNote,
},
{
path: frontendURL('start/invite-team'),
name: 'onboarding_invite-team',
component: InviteTeam,
},
],
};
@@ -3,7 +3,7 @@
<resizable-text-area
v-model="articleTitle"
type="text"
rows="1"
:rows="1"
class="article-heading"
:placeholder="$t('HELP_CENTER.EDIT_ARTICLE.TITLE_PLACEHOLDER')"
@focus="onFocus"
@@ -161,7 +161,7 @@ export default {
}
span.article-column {
@apply text-slate-700 dark:text-slate-100 text-sm font-semibold py-2 px-0 text-right capitalize;
@apply text-slate-700 dark:text-slate-100 text-sm font-semibold py-2 px-0 text-left capitalize last:text-right;
&.article-title {
@apply items-start flex gap-2 col-span-4 text-left;
@@ -10,17 +10,17 @@
{{ $t('HELP_CENTER.TABLE.HEADERS.TITLE') }}
</div>
<div
class="font-semibold capitalize text-sm py-2 px-0 text-slate-700 dark:text-slate-100 text-right"
class="font-semibold capitalize text-sm py-2 px-0 text-slate-700 dark:text-slate-100 text-left"
>
{{ $t('HELP_CENTER.TABLE.HEADERS.CATEGORY') }}
</div>
<div
class="font-semibold capitalize text-sm py-2 px-0 text-slate-700 dark:text-slate-100 text-right hidden lg:block"
class="font-semibold capitalize text-sm py-2 px-0 text-slate-700 dark:text-slate-100 text-left hidden lg:block"
>
{{ $t('HELP_CENTER.TABLE.HEADERS.READ_COUNT') }}
</div>
<div
class="font-semibold capitalize text-sm py-2 px-0 text-slate-700 dark:text-slate-100 text-right"
class="font-semibold capitalize text-sm py-2 px-0 text-slate-700 dark:text-slate-100 text-left"
>
{{ $t('HELP_CENTER.TABLE.HEADERS.STATUS') }}
</div>
@@ -14,21 +14,24 @@
>
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<div class="mb-4">
<label>
{{ $t('HELP_CENTER.PORTAL.ADD.LOGO.LABEL') }}
</label>
<div class="flex items-center flex-row">
<thumbnail :username="name" size="56px" variant="square" />
<woot-button
v-if="false"
class="ml-3"
variant="smooth"
color-scheme="secondary"
icon="upload"
size="small"
>
{{ $t('HELP_CENTER.PORTAL.ADD.LOGO.UPLOAD_BUTTON') }}
</woot-button>
<woot-avatar-uploader
ref="imageUpload"
:label="$t('HELP_CENTER.PORTAL.ADD.LOGO.LABEL')"
:src="logoUrl"
@change="onFileChange"
/>
<div v-if="showDeleteButton" class="avatar-delete-btn">
<woot-button
type="button"
color-scheme="alert"
variant="hollow"
size="small"
@click="deleteAvatar"
>
{{ $t('PROFILE_SETTINGS.DELETE_AVATAR') }}
</woot-button>
</div>
</div>
<p
class="mt-1 mb-0 text-xs text-slate-600 dark:text-slate-400 not-italic"
@@ -87,17 +90,20 @@
<script>
import { required, minLength } from 'vuelidate/lib/validators';
import { isDomain } from 'shared/helpers/Validators';
import thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import alertMixin from 'shared/mixins/alertMixin';
import { convertToCategorySlug } from 'dashboard/helper/commons.js';
import { buildPortalURL } from 'dashboard/helper/portalHelper';
import wootConstants from 'dashboard/constants/globals';
import { hasValidAvatarUrl } from 'dashboard/helper/URLHelper';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { uploadFile } from 'dashboard/helper/uploadHelper';
const { EXAMPLE_URL } = wootConstants;
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
export default {
components: {
thumbnail,
},
mixins: [alertMixin],
props: {
portal: {
type: Object,
@@ -118,6 +124,10 @@ export default {
slug: '',
domain: '',
alertMessage: '',
// Logouploader keys
avatarBlobId: '',
logoUrl: '',
};
},
validations: {
@@ -159,6 +169,9 @@ export default {
exampleURL: EXAMPLE_URL,
});
},
showDeleteButton() {
return hasValidAvatarUrl(this.logoUrl);
},
},
mounted() {
const portal = this.portal || {};
@@ -166,6 +179,13 @@ export default {
this.slug = portal.slug || '';
this.domain = portal.custom_domain || '';
this.alertMessage = '';
if (portal.logo) {
const {
logo: { file_url: logoURL, blob_id: blobId },
} = portal;
this.logoUrl = logoURL;
this.avatarBlobId = blobId;
}
},
methods: {
onNameChange() {
@@ -181,9 +201,44 @@ export default {
name: this.name,
slug: this.slug,
custom_domain: this.domain,
blob_id: this.avatarBlobId || null,
};
this.$emit('submit', portal);
},
async deleteAvatar() {
this.logoUrl = '';
this.avatarBlobId = '';
this.$emit('delete-logo');
},
onFileChange({ file }) {
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
this.uploadLogoToStorage(file);
} else {
this.showAlert(
this.$t(
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SIZE_ERROR',
{
size: MAXIMUM_FILE_UPLOAD_SIZE,
}
)
);
}
this.$refs.imageUpload.value = '';
},
async uploadLogoToStorage(file) {
try {
const { fileUrl, blobId } = await uploadFile(file);
if (fileUrl) {
this.logoUrl = fileUrl;
this.avatarBlobId = blobId;
}
} catch (error) {
this.showAlert(
this.$t('HELP_CENTER.PORTAL.ADD.LOGO.IMAGE_DELETE_ERROR')
);
}
},
},
};
</script>
@@ -157,16 +157,23 @@ export default {
return this.metaTags.map(item => item.name);
},
},
watch: {
article: {
handler() {
if (!isEmptyObject(this.article.meta || {})) {
const {
meta: { title = '', description = '', tags = [] },
} = this.article;
this.metaTitle = title;
this.metaDescription = description;
this.metaTags = this.formattedTags({ tags });
}
},
deep: true,
immediate: true,
},
},
mounted() {
if (!isEmptyObject(this.article.meta || {})) {
const {
meta: { title = '', description = '', tags = [] },
} = this.article;
this.metaTitle = title;
this.metaDescription = description;
this.metaTags = this.formattedTags({ tags });
}
this.saveArticle = debounce(
() => {
this.$emit('save-article', {
@@ -7,6 +7,7 @@
$t('HELP_CENTER.PORTAL.EDIT.EDIT_BASIC_INFO.BUTTON_TEXT')
"
@submit="updatePortalSettings"
@delete-logo="deleteLogo"
/>
</template>
@@ -70,6 +71,19 @@ export default {
this.showAlert(this.alertMessage);
}
},
async deleteLogo() {
try {
const portalSlug = this.lastPortalSlug;
await this.$store.dispatch('portals/deleteLogo', {
portalSlug,
});
} catch (error) {
this.alertMessage =
error?.message ||
this.$t('HELP_CENTER.PORTAL.ADD.LOGO.IMAGE_DELETE_ERROR');
this.showAlert(this.alertMessage);
}
},
},
};
</script>
@@ -40,9 +40,12 @@ export default {
methods: {
async createPortal(portal) {
try {
const { blob_id: blobId } = portal;
await this.$store.dispatch('portals/create', {
portal,
blob_id: blobId,
});
this.alertMessage = this.$t(
'HELP_CENTER.PORTAL.ADD.API.SUCCESS_MESSAGE_FOR_BASIC'
);
@@ -1,7 +1,7 @@
/* eslint arrow-body-style: 0 */
import NotificationsView from './components/NotificationsView.vue';
import { frontendURL } from '../../../helper/URLHelper';
import SettingsWrapper from '../settings/Wrapper';
const SettingsWrapper = () => import('../settings/Wrapper.vue');
const NotificationsView = () => import('./components/NotificationsView.vue');
export const routes = [
{
@@ -1,6 +1,6 @@
import SettingsContent from '../Wrapper';
import Index from './Index.vue';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Index = () => import('./Index.vue');
export default {
routes: [
@@ -1,8 +1,8 @@
import SettingsContent from '../Wrapper';
const Bot = () => import('./Index.vue');
const CsmlEditBot = () => import('./csml/Edit.vue');
const CsmlNewBot = () => import('./csml/New.vue');
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
export default {
routes: [
@@ -1,6 +1,6 @@
import SettingsContent from '../Wrapper';
import AgentHome from './Index';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const AgentHome = () => import('./Index.vue');
export default {
routes: [
@@ -1,6 +1,6 @@
import SettingsContent from '../Wrapper';
import AttributesHome from './Index';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const AttributesHome = () => import('./Index.vue');
export default {
routes: [
@@ -1,7 +1,8 @@
import SettingsContent from '../Wrapper';
import AuditLogsHome from './Index';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const AuditLogsHome = () => import('./Index.vue');
export default {
routes: [
{
@@ -1,6 +1,6 @@
import SettingsContent from '../Wrapper';
import Automation from './Index';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Automation = () => import('./Index.vue');
export default {
routes: [
@@ -1,6 +1,6 @@
import SettingsContent from '../Wrapper';
import Index from './Index.vue';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Index = () => import('./Index.vue');
export default {
routes: [
@@ -1,6 +1,6 @@
import Index from './Index';
import SettingsContent from '../Wrapper';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Index = () => import('./Index.vue');
export default {
routes: [
@@ -20,7 +20,7 @@ export default {
path: 'ongoing',
name: 'settings_account_campaigns',
roles: ['administrator'],
component: { ...Index },
component: Index,
},
],
},
@@ -36,7 +36,7 @@ export default {
path: 'one_off',
name: 'one_off',
roles: ['administrator'],
component: { ...Index },
component: Index,
},
],
},
@@ -1,7 +1,8 @@
import SettingsContent from '../Wrapper';
import CannedHome from './Index';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const CannedHome = () => import('./Index.vue');
export default {
routes: [
{
@@ -1,13 +1,14 @@
/* eslint arrow-body-style: 0 */
import SettingsContent from '../Wrapper';
import Settings from './Settings';
import InboxHome from './Index';
import InboxChannel from './InboxChannels';
import ChannelList from './ChannelList';
import channelFactory from './channel-factory';
import AddAgents from './AddAgents';
import FinishSetup from './FinishSetup';
import { frontendURL } from '../../../../helper/URLHelper';
import channelFactory from './channel-factory';
const SettingsContent = () => import('../Wrapper.vue');
const InboxHome = () => import('./Index.vue');
const Settings = () => import('./Settings.vue');
const InboxChannel = () => import('./InboxChannels.vue');
const ChannelList = () => import('./ChannelList.vue');
const AddAgents = () => import('./AddAgents.vue');
const FinishSetup = () => import('./FinishSetup.vue');
export default {
routes: [
@@ -1,6 +1,6 @@
<!-- eslint-disable vue/v-slot-style -->
<template>
<div class="flex flex-col h-auto overflow-auto">
<div class="flex flex-col h-auto overflow-auto integration-hooks">
<woot-modal-header
:header-title="integration.name"
:header-content="integration.description"
@@ -1,7 +1,7 @@
import Index from './Index';
import SettingsContent from '../Wrapper';
import IntegrationHooks from './IntegrationHooks';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const IntegrationHooks = () => import('./IntegrationHooks.vue');
const Index = () => import('./Index.vue');
export default {
routes: [
@@ -1,11 +1,12 @@
import Index from './Index';
import SettingsContent from '../Wrapper';
import Webhook from './Webhooks/Index';
import DashboardApps from './DashboardApps/Index';
import ShowIntegration from './ShowIntegration';
import Slack from './Slack';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Webhook = () => import('./Webhooks/Index.vue');
const DashboardApps = () => import('./DashboardApps/Index.vue');
const ShowIntegration = () => import('./ShowIntegration.vue');
const Slack = () => import('./Slack.vue');
const Index = () => import('./Index.vue');
export default {
routes: [
{
@@ -1,7 +1,8 @@
import SettingsContent from '../Wrapper';
import Index from './Index';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Index = () => import('./Index.vue');
export default {
routes: [
{
@@ -1,6 +1,8 @@
<template>
<div class="flex flex-row h-full">
<div class="w-[60%] macros-canvas">
<div class="flex flex-col md:flex-row h-auto md:h-full w-full">
<div
class="flex-1 w-full md:w-auto macro-gradient-radial dark:macro-dark-gradient-radial macro-gradient-radial-size h-full max-h-full py-4 px-12 overflow-y-auto"
>
<macro-nodes
v-model="macro.actions"
:files="files"
@@ -9,7 +11,7 @@
@resetAction="resetNode"
/>
</div>
<div class="w-[34%]">
<div class="w-full md:w-1/3">
<macro-properties
:macro-name="macro.name"
:macro-visibility="macro.visibility"
@@ -138,7 +140,4 @@ export default {
background-size: 1rem 1rem;
}
}
.macros-canvas {
@apply macro-gradient-radial dark:macro-dark-gradient-radial macro-gradient-radial-size h-full max-h-full py-4 px-12 overflow-y-auto;
}
</style>
@@ -18,7 +18,7 @@
>
{{ $t('MACROS.EDITOR.VISIBILITY.LABEL') }}
</p>
<div class="grid grid-cols-2 gap-3">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<button
class="p-2 relative rounded-md border border-solid text-left cursor-default"
:class="isActive('global')"
@@ -1,8 +1,9 @@
import SettingsContent from '../Wrapper';
import Macros from './Index';
const MacroEditor = () => import('./MacroEditor');
import { frontendURL } from 'dashboard/helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Macros = () => import('./Index.vue');
const MacroEditor = () => import('./MacroEditor.vue');
export default {
routes: [
{
@@ -128,6 +128,7 @@
import { required, minLength, email } from 'vuelidate/lib/validators';
import { mapGetters } from 'vuex';
import { clearCookiesOnLogout } from '../../../../store/utils/api';
import { hasValidAvatarUrl } from 'dashboard/helper/URLHelper';
import NotificationSettings from './NotificationSettings.vue';
import alertMixin from 'shared/mixins/alertMixin';
import ChangePassword from './ChangePassword.vue';
@@ -198,6 +199,9 @@ export default {
currentUserId: 'getCurrentUserID',
globalConfig: 'globalConfig/get',
}),
showDeleteButton() {
return hasValidAvatarUrl(this.avatarUrl);
},
},
watch: {
currentUserId(newCurrentUserId, prevCurrentUserId) {
@@ -265,9 +269,6 @@ export default {
this.showAlert(this.$t('PROFILE_SETTINGS.AVATAR_DELETE_FAILED'));
}
},
showDeleteButton() {
return this.avatarUrl && !this.avatarUrl.includes('www.gravatar.com');
},
toggleEditorMessageKey(key) {
this.updateUISettings({ editor_message_key: key });
this.showAlert(
@@ -22,14 +22,12 @@
:enabled-menu-options="customEditorMenuList"
:enable-suggestions="false"
:show-image-resize-toolbar="true"
@blur="$v.messageSignature.$touch"
/>
</div>
<woot-button
:is-loading="isUpdating"
type="button"
:is-disabled="$v.messageSignature.$invalid"
@click.prevent="updateSignature()"
@click.prevent="updateSignature"
>
{{ $t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.BTN_TEXT') }}
</woot-button>
@@ -38,7 +36,6 @@
</template>
<script>
import { required } from 'vuelidate/lib/validators';
import { mapGetters } from 'vuex';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
@@ -59,11 +56,6 @@ export default {
customEditorMenuList: MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS,
};
},
validations: {
messageSignature: {
required,
},
},
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
@@ -79,15 +71,9 @@ export default {
this.messageSignature = messageSignature || '';
},
async updateSignature() {
this.$v.$touch();
if (this.$v.$invalid) {
this.showAlert(this.$t('PROFILE_SETTINGS.FORM.ERROR'));
return;
}
try {
await this.$store.dispatch('updateProfile', {
message_signature: this.messageSignature,
message_signature: this.messageSignature || '',
});
this.errorMessage = this.$t(
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.API_SUCCESS'
@@ -1,7 +1,8 @@
import SettingsContent from '../Wrapper';
import Index from './Index.vue';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Index = () => import('./Index.vue');
export default {
routes: [
{
@@ -1,13 +1,14 @@
import Index from './Index';
import AgentReports from './AgentReports';
import LabelReports from './LabelReports';
import InboxReports from './InboxReports';
import TeamReports from './TeamReports';
import CsatResponses from './CsatResponses';
import LiveReports from './LiveReports';
import SettingsContent from '../Wrapper';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Index = () => import('./Index.vue');
const AgentReports = () => import('./AgentReports.vue');
const LabelReports = () => import('./LabelReports.vue');
const InboxReports = () => import('./InboxReports.vue');
const TeamReports = () => import('./TeamReports.vue');
const CsatResponses = () => import('./CsatResponses.vue');
const LiveReports = () => import('./LiveReports.vue');
export default {
routes: [
{
@@ -1,15 +1,16 @@
/* eslint arrow-body-style: 0 */
import SettingsContent from '../Wrapper';
import TeamsHome from './Index';
import CreateStepWrap from './Create/Index';
import EditStepWrap from './Edit/Index';
import CreateTeam from './Create/CreateTeam';
import EditTeam from './Edit/EditTeam';
import AddAgents from './Create/AddAgents';
import EditAgents from './Edit/EditAgents';
import FinishSetup from './FinishSetup';
import { frontendURL } from '../../../../helper/URLHelper';
const CreateStepWrap = () => import('./Create/Index.vue');
const EditStepWrap = () => import('./Edit/Index.vue');
const CreateTeam = () => import('./Create/CreateTeam.vue');
const EditTeam = () => import('./Edit/EditTeam.vue');
const AddAgents = () => import('./Create/AddAgents.vue');
const EditAgents = () => import('./Edit/EditAgents.vue');
const FinishSetup = () => import('./FinishSetup.vue');
const SettingsContent = () => import('../Wrapper.vue');
const TeamsHome = () => import('./Index.vue');
export default {
routes: [
{
@@ -0,0 +1,56 @@
<template>
<onboarding-layout
:title="$t('ONBOARDING.FOUNDERS_NOTE.TITLE')"
current-step="founders-note"
>
<template #body>
<section class="mt-4 space-y-3 text-sm leading-relaxed">
<p>👋 Hey {{ user.name }},</p>
<p>
I'm Pranav, one of the co-founders at Chatwoot. Thanks for checking
out Chatwoot. We're eager to understand your needs and expectations.
</p>
<p>
If you are facing trouble using the app or achieving your use case, we
want to help you as quickly as possible.
</p>
<p>
Feel free to schedule a call with our team. We are open to your
feedback. Talk soon!
</p>
</section>
<figure class="mt-5 text-sm leading-relaxed">
<thumbnail
src="https://www.chatwoot.com/images/team/pranav.jpg"
username="Pranav"
/>
<p class="mt-2">Pranav, <br />Co-founder & CEO, Chatwoot</p>
</figure>
</template>
<submit-button
button-class="flex justify-center w-full text-sm text-center"
:button-text="$t('ONBOARDING.FOUNDERS_NOTE.SUBMIT')"
/>
</onboarding-layout>
</template>
<script>
import OnboardingLayout from './components/OnboardingLayout.vue';
import Thumbnail from '../../../../dashboard/components/widgets/Thumbnail.vue';
import SubmitButton from '../../../components/buttons/FormSubmitButton.vue';
export default {
components: {
OnboardingLayout,
Thumbnail,
SubmitButton,
},
data() {
return {
user: {
name: 'Shivam',
},
};
},
};
</script>
@@ -0,0 +1,102 @@
<template>
<onboarding-layout
:title="$t('ONBOARDING.INVITE_TEAM.TITLE')"
:body="$t('ONBOARDING.INVITE_TEAM.BODY')"
current-step="invite"
>
<section class="space-y-5">
<div class="flex gap-2">
<form-input
v-model="emailToAdd"
name="email"
spacing="compact"
class="flex-grow"
:placeholder="$t('ONBOARDING.INVITE_TEAM.PLACEHOLDER')"
@keyup.enter="pushEmail"
/>
<woot-button
variant="smooth"
color-scheme="secondary"
@click="pushEmail"
>
Add
</woot-button>
</div>
<div
class="rounded-md min-h-[10rem] divide-y divide-slate-100"
:class="{
'border border-slate-200 dark:border-slate-700 border-dashed grid place-content-center':
emailsToInvite.length === 0,
}"
>
<div v-if="emailsToInvite.length === 0" class="px-5 py-5">
<p class="text-sm text-slate-400">No members to invite</p>
</div>
<div
v-for="email in emailsToInvite"
:key="email"
class="flex items-center justify-between gap-2 py-2 first:pt-0 last:pb-0"
>
<div class="flex items-center gap-2">
<thumbnail :username="email" size="30px" />
{{ email }}
</div>
<woot-button
variant="clear"
size="small"
color-scheme="alert"
icon="delete"
@click="emailsToInvite.splice(emailsToInvite.indexOf(email), 1)"
/>
</div>
</div>
<div class="space-y-2">
<submit-button
button-class="flex justify-center w-full text-sm text-center"
:button-text="$t('ONBOARDING.INVITE_TEAM.SUBMIT')"
/>
<woot-button
variant="clear"
color-scheme="secondary"
size="expanded"
is-expanded
>
{{ $t('ONBOARDING.INVITE_TEAM.SKIP') }}
</woot-button>
</div>
</section>
</onboarding-layout>
</template>
<script>
import FormInput from '../../../components/form/Input.vue';
import WootButton from '../../../components/ui/WootButton.vue';
import Thumbnail from '../../../../dashboard/components/widgets/Thumbnail.vue';
import OnboardingLayout from './components/OnboardingLayout.vue';
import SubmitButton from '../../../components/buttons/FormSubmitButton.vue';
export default {
components: {
OnboardingLayout,
WootButton,
Thumbnail,
SubmitButton,
FormInput,
},
data() {
return {
emailsToInvite: [],
emailToAdd: '',
};
},
methods: {
pushEmail() {
if (!this.emailToAdd) return;
this.emailsToInvite.push(this.emailToAdd);
this.emailsToInvite = [...new Set(this.emailsToInvite)];
this.emailToAdd = '';
},
},
};
</script>
@@ -0,0 +1,103 @@
<template>
<onboarding-layout
:title="$t('ONBOARDING.COMPANY.TITLE')"
:body="$t('ONBOARDING.COMPANY.BODY')"
current-step="company"
>
<form class="space-y-8">
<div class="space-y-3">
<form-input
v-model="companyName"
name="companyName"
spacing="compact"
:label="$t('ONBOARDING.COMPANY.COMPANY_NAME.LABEL')"
:placeholder="$t('ONBOARDING.COMPANY.COMPANY_NAME.PLACEHOLDER')"
:error-message="$t('ONBOARDING.COMPANY.COMPANY_NAME.ERROR')"
/>
<form-input
v-model="industry"
name="industry"
spacing="compact"
:label="$t('ONBOARDING.COMPANY.INDUSTRY.LABEL')"
:placeholder="$t('ONBOARDING.COMPANY.INDUSTRY.PLACEHOLDER')"
/>
<form-input
v-model="timezone"
name="timezone"
spacing="compact"
:label="$t('ONBOARDING.COMPANY.TIMEZONE.LABEL')"
:placeholder="$t('ONBOARDING.COMPANY.TIMEZONE.PLACEHOLDER')"
/>
<form-select
v-model="locale"
name="locale"
:label="$t('ONBOARDING.COMPANY.LOCALE.LABEL')"
:placeholder="$t('ONBOARDING.COMPANY.LOCALE.PLACEHOLDER')"
>
<option
v-for="lang in languagesSortedByCode"
:key="lang.iso_639_1_code"
:value="lang.iso_639_1_code"
>
{{ lang.name }}
</option>
</form-select>
<form-select
v-model="companySize"
name="companySize"
:label="$t('ONBOARDING.COMPANY.SIZE.LABEL')"
:placeholder="$t('ONBOARDING.COMPANY.SIZE.PLACEHOLDER')"
:options="companySizeOptions"
/>
</div>
<submit-button
button-class="flex justify-center w-full text-sm text-center"
:button-text="$t('ONBOARDING.COMPANY.SUBMIT')"
/>
</form>
</onboarding-layout>
</template>
<script>
import FormInput from '../../../components/form/Input.vue';
import FormSelect from '../../../components/form/Select.vue';
import SubmitButton from '../../../components/buttons/FormSubmitButton.vue';
import OnboardingLayout from './components/OnboardingLayout.vue';
import configMixin from 'shared/mixins/configMixin';
export default {
components: {
FormInput,
SubmitButton,
OnboardingLayout,
FormSelect,
},
mixins: [configMixin],
data() {
return {
companyName: '',
timezone: '',
locale: '',
companySize: '',
industry: '',
companySizeOptions: [
{ value: '1-10', label: '1-10' },
{ value: '11-50', label: '11-50' },
{ value: '51-200', label: '51-200' },
{ value: '201-500', label: '201-500' },
{ value: '501-1000', label: '501-1000' },
{ value: '1001-5000', label: '1001-5000' },
{ value: '5001+', label: 'Over 5000' },
],
};
},
computed: {
languagesSortedByCode() {
const enabledLanguages = [...this.enabledLanguages];
return enabledLanguages.sort((l1, l2) =>
l1.iso_639_1_code.localeCompare(l2.iso_639_1_code)
);
},
},
};
</script>
@@ -0,0 +1,70 @@
<template>
<onboarding-layout
:title="$t('ONBOARDING.PROFILE.TITLE')"
:body="$t('ONBOARDING.PROFILE.BODY')"
current-step="profile"
>
<form class="mt-8 space-y-8">
<div class="space-y-3">
<form-input
v-model="fullName"
name="full_name"
spacing="compact"
:label="$t('ONBOARDING.PROFILE.FULL_NAME.LABEL')"
:placeholder="$t('ONBOARDING.PROFILE.FULL_NAME.PLACEHOLDER')"
:error-message="$t('ONBOARDING.PROFILE.FULL_NAME.ERROR')"
/>
<form-input
v-model="displayName"
name="display_name"
spacing="compact"
:label="$t('ONBOARDING.PROFILE.DISPLAY_NAME.LABEL')"
:placeholder="$t('ONBOARDING.PROFILE.DISPLAY_NAME.PLACEHOLDER')"
/>
<form-input
v-model="phoneNumber"
name="phone_number"
spacing="compact"
:label="$t('ONBOARDING.PROFILE.PHONE_NUMBER.LABEL')"
:placeholder="$t('ONBOARDING.PROFILE.PHONE_NUMBER.PLACEHOLDER')"
/>
<form-text-area
v-model="signature"
name="signature"
spacing="compact"
:allow-resize="false"
:label="$t('ONBOARDING.PROFILE.SIGNATURE.LABEL')"
:placeholder="$t('ONBOARDING.PROFILE.SIGNATURE.PLACEHOLDER')"
/>
</div>
<submit-button
button-class="flex justify-center w-full text-sm text-center"
:button-text="$t('ONBOARDING.PROFILE.SUBMIT')"
/>
</form>
</onboarding-layout>
</template>
<script>
import FormInput from '../../../components/form/Input.vue';
import FormTextArea from '../../../components/form/TextArea.vue';
import SubmitButton from '../../../components/buttons/FormSubmitButton.vue';
import OnboardingLayout from './components/OnboardingLayout.vue';
export default {
components: {
FormInput,
SubmitButton,
OnboardingLayout,
FormTextArea,
},
data() {
return {
fullName: '',
displayName: '',
phoneNumber: '',
signature: '',
};
},
};
</script>
@@ -0,0 +1,118 @@
<template>
<section
class="relative h-screen px-8 py-12 dark:text-white bg-woot-25 dark:bg-slate-900"
>
<div class="absolute inset-0 w-full h-screen overflow-hidden">
<img
id="bg-grad"
src="/onboarding/bg-gradient.png"
class="w-full opacity-50 dark:mix-blend-color"
/>
</div>
<div class="relative max-w-lg mx-auto">
<div
id="steps"
class="rounded-md w-48 -left-52 dark:shadow-[#000] p-1 border shadow-sm border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 absolute grid gap-1"
>
<h6
class="px-2 pt-1 font-bold tracking-widest uppercase text-xxs text-slate-500"
>
Get Started
</h6>
<onboarding-step
v-for="(step, index) in steps"
:key="step.name"
v-bind="step"
:step-number="index + 1"
/>
</div>
<div
id="modal-body"
class="dark:shadow-[#000] rounded-md mx-auto p-9 border shadow border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900"
>
<h2 class="text-lg font-bold tracking-tight dark:text-white">
<slot name="title">
{{ title }}
</slot>
</h2>
<slot name="body">
<p class="mt-2 text-sm dark:text-slate-200">
{{ body }}
</p>
</slot>
<div class="mt-8">
<slot />
</div>
</div>
</div>
</section>
</template>
<script>
import OnboardingStep from './OnboardingStep.vue';
const UISteps = [
{
name: 'profile',
title: 'Setup Profile',
isActive: false,
isComplete: false,
},
{
name: 'company',
title: 'Setup Company',
isActive: false,
isComplete: false,
},
{
name: 'invite',
title: 'Invite your team',
isActive: false,
isComplete: false,
},
{
name: 'founders-note',
title: "Founder's Note",
hidden: true,
isActive: false,
isComplete: false,
},
];
export default {
components: {
OnboardingStep,
},
props: {
title: {
type: String,
default: '',
},
body: {
type: String,
default: '',
},
currentStep: {
type: String,
required: true,
},
},
computed: {
steps() {
let foundCurrentStep = false;
return UISteps.map(step => {
if (step.name === this.currentStep) {
foundCurrentStep = true;
}
return {
...step,
isActive: step.name === this.currentStep,
isComplete: !foundCurrentStep,
};
});
},
},
};
</script>
@@ -0,0 +1,65 @@
<template>
<div
v-if="!hidden"
class="flex items-center gap-2 p-2 text-sm rounded-md"
:class="{
'text-slate-800 dark:text-slate-100 bg-slate-50 dark:bg-slate-800':
isActive,
'opacity-50': !isActive && !isComplete,
hidden: hidden,
}"
>
<div
class="grid w-4 h-4 font-bold rounded-full text-xxs place-content-center"
:class="{
'bg-slate-200 dark:bg-slate-600': !isComplete,
'bg-green-500': isComplete,
}"
>
<fluent-icon
v-if="isComplete"
size="13"
class="text-white"
icon="checkmark"
/>
<template v-else>
{{ stepNumber }}
</template>
</div>
<span
:class="{
'line-through': isComplete,
}"
>
{{ title }}
</span>
</div>
</template>
<script>
export default {
name: 'ModalStep',
props: {
title: {
type: String,
required: true,
},
stepNumber: {
type: Number,
required: true,
},
isActive: {
type: Boolean,
default: false,
},
hidden: {
type: Boolean,
default: false,
},
isComplete: {
type: Boolean,
default: false,
},
},
};
</script>
+8
View File
@@ -45,11 +45,19 @@ export const validateAuthenticateRoutePermission = (to, next, { getters }) => {
return next(frontendURL(`accounts/${user.account_id}/dashboard`));
}
// we can let them access onboarding routes
// we will eventually need guards as onboarding is done, but for now
// we can let them access onboarding routes
if (to.name.startsWith('onboarding')) {
return next();
}
const nextRoute = validateLoggedInRoutes(
to,
getters.getCurrentUser,
window.roleWiseRoutes
);
return nextRoute ? next(frontendURL(nextRoute)) : next();
};
@@ -2,6 +2,7 @@ import PortalAPI from 'dashboard/api/helpCenter/portals';
import { throwErrorMessage } from 'dashboard/store/utils/api';
import { types } from './mutations';
const portalAPIs = new PortalAPI();
export const actions = {
index: async ({ commit }) => {
try {
@@ -89,6 +90,23 @@ export const actions = {
}
},
deleteLogo: async ({ commit }, { portalSlug }) => {
commit(types.SET_HELP_PORTAL_UI_FLAG, {
uiFlags: { isUpdating: true },
portalSlug,
});
try {
await portalAPIs.deleteLogo(portalSlug);
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_HELP_PORTAL_UI_FLAG, {
uiFlags: { isUpdating: false },
portalSlug,
});
}
},
updatePortal: async ({ commit }, portal) => {
commit(types.UPDATE_PORTAL_ENTRY, portal);
},
@@ -2,27 +2,21 @@
<div class="hidden lg:block flex-1 py-6 scroll-mt-24 pl-4">
<div v-if="rows.length > 0" class="sticky top-24 py-2 overflow-auto">
<nav class="max-w-2xl">
<h2
id="on-this-page-title"
class="text-slate-800 pl-6 dark:text-slate-50 font-semibold tracking-wide py-3 leading-7 border-l-2 border-solid border-slate-100 dark:border-slate-800"
:class="{
'!border-slate-400 dark:!border-slate-100': !currentSlug,
}"
<ol
role="list"
class="flex flex-col gap-2 text-base border-l-2 border-solid border-slate-100 dark:border-slate-800"
>
{{ tocHeader }}
</h2>
<ol role="list" class="text-base">
<li
v-for="element in rows"
:key="element.slug"
class="leading-6 py-2 pl-6 border-l-2 border-solid"
class="leading-6 border-l-2 relative -left-0.5 border-solid"
:class="elementBorderStyles(element)"
>
<p :class="getClassName(element)">
<p class="py-2 px-4" :class="getClassName(element)">
<a
:href="`#${element.slug}`"
data-turbolinks="false"
class="text-base cursor-pointer"
class="font-medium text-sm tracking-[0.28px] cursor-pointer"
:class="elementTextStyles(element)"
>
{{ element.title }}
@@ -45,12 +39,10 @@ export default {
data() {
return {
currentSlug: window.location?.hash?.substring(1) || '',
intersectionObserver: null,
};
},
computed: {
tocHeader() {
return window.portalConfig.tocHeader;
},
h1Count() {
return this.rows.filter(el => el.tag === 'h1').length;
},
@@ -59,10 +51,14 @@ export default {
},
},
mounted() {
this.initializeIntersectionObserver();
window.addEventListener('hashchange', this.onURLHashChange);
},
beforeDestroy() {
window.removeEventListener('hashchange', this.onURLHashChange);
if (this.intersectionObserver) {
this.intersectionObserver.disconnect();
}
},
methods: {
getClassName(el) {
@@ -85,18 +81,32 @@ export default {
return '';
},
initializeIntersectionObserver() {
this.intersectionObserver = new IntersectionObserver(
entries => {
const currentSection = entries.find(entry => entry.isIntersecting);
if (currentSection) {
this.currentSlug = currentSection.target.id;
}
},
{
threshold: 0.25,
rootMargin: '0px 0px -20% 0px',
}
);
this.rows.forEach(el => {
const sectionElement = document.getElementById(el.slug);
if (!sectionElement) return;
this.intersectionObserver.observe(sectionElement);
});
},
onURLHashChange() {
this.currentSlug = window.location?.hash?.substring(1) || '';
},
isElementActive(el) {
return this.currentSlug === el.slug;
},
tocHeaderBorderStyles() {
if (this.currentSlug === '') {
return 'border-slate-400 dark:border-slate-100';
}
return 'border-slate-100 dark:border-slate-800';
},
elementBorderStyles(el) {
if (this.isElementActive(el)) {
return 'border-slate-400 dark:border-slate-50 transition-colors duration-200';
@@ -105,7 +115,7 @@ export default {
},
elementTextStyles(el) {
if (this.isElementActive(el)) {
return 'font-semibold text-slate-900 dark:text-slate-25';
return 'text-slate-900 dark:text-slate-25 transition-colors duration-200';
}
return 'text-slate-700 dark:text-slate-100';
},
+33 -107
View File
@@ -3,6 +3,7 @@ import Vue from 'vue';
import PublicArticleSearch from './components/PublicArticleSearch.vue';
import TableOfContents from './components/TableOfContents.vue';
import { initializeTheme } from './portalThemeHelper.js';
export const getHeadingsfromTheArticle = () => {
const rows = [];
@@ -21,82 +22,42 @@ export const getHeadingsfromTheArticle = () => {
return rows;
};
export const generatePortalBgColor = (portalColor, theme) => {
const baseColor = theme === 'dark' ? 'black' : 'white';
return `color-mix(in srgb, ${portalColor} 20%, ${baseColor})`;
};
export const openExternalLinksInNewTab = () => {
const { customDomain, hostURL } = window.portalConfig;
const isSameHost =
window.location.href.includes(customDomain) ||
window.location.href.includes(hostURL);
export const generatePortalBg = (portalColor, theme) => {
const bgImage = theme === 'dark' ? 'hexagon-dark.svg' : 'hexagon-light.svg';
return `background: url(/assets/images/hc/${bgImage}) ${generatePortalBgColor(
portalColor,
theme
)}`;
};
// Modify external links only on articles page
const isOnArticlePage =
isSameHost && document.querySelector('#cw-article-content') !== null;
export const generateGradientToBottom = theme => {
return `background-image: linear-gradient(to bottom, transparent, ${
theme === 'dark' ? '#151718' : 'white'
})`;
};
document.addEventListener('click', function (event) {
if (!isOnArticlePage) return;
export const setPortalStyles = theme => {
const portalColor = window.portalConfig.portalColor;
const portalBgDiv = document.querySelector('#portal-bg');
const portalBgGradientDiv = document.querySelector('#portal-bg-gradient');
// Some of the links come wrapped in strong tag through prosemirror
if (portalBgDiv) {
// Set background for #portal-bg
portalBgDiv.setAttribute('style', generatePortalBg(portalColor, theme));
}
const isTagAnchor = event.target.tagName === 'A';
const isParentTagAnchor =
event.target.tagName === 'STRONG' &&
event.target.parentNode.tagName === 'A';
if (portalBgGradientDiv) {
// Set gradient background for #portal-bg-gradient
portalBgGradientDiv.setAttribute('style', generateGradientToBottom(theme));
}
};
if (isTagAnchor || isParentTagAnchor) {
const link = isTagAnchor ? event.target : event.target.parentNode;
export const setPortalClass = theme => {
const portalDiv = document.querySelector('#portal');
portalDiv.classList.remove('light', 'dark');
if (!portalDiv) return;
portalDiv.classList.add(theme);
};
const isInternalLink =
link.hostname === window.location.hostname ||
link.href.includes(customDomain) ||
link.href.includes(hostURL);
export const updateThemeStyles = theme => {
setPortalStyles(theme);
setPortalClass(theme);
};
export const toggleAppearanceDropdown = () => {
const dropdown = document.getElementById('appearance-dropdown');
if (!dropdown) return;
dropdown.style.display =
dropdown.style.display === 'none' || !dropdown.style.display
? 'flex'
: 'none';
};
export const updateURLParameter = (param, paramVal) => {
const urlObj = new URL(window.location);
urlObj.searchParams.set(param, paramVal);
return urlObj.toString();
};
export const removeURLParameter = parameter => {
const urlObj = new URL(window.location);
urlObj.searchParams.delete(parameter);
return urlObj.toString();
};
export const switchTheme = theme => {
updateThemeStyles(theme);
const newUrl =
theme !== 'system'
? updateURLParameter('theme', theme)
: removeURLParameter('theme');
window.location.href = newUrl;
toggleAppearanceDropdown();
if (!isInternalLink) {
link.target = '_blank';
link.rel = 'noopener noreferrer'; // Security and performance benefits
// Prevent default if you want to stop the link from opening in the current tab
event.stopPropagation();
}
}
});
};
export const InitializationHelpers = {
@@ -146,52 +107,17 @@ export const InitializationHelpers = {
});
},
initializeTheme: () => {
const mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)');
const getThemePreference = () =>
mediaQueryList.matches ? 'dark' : 'light';
const themeFromServer = window.portalConfig.theme;
if (themeFromServer === 'system') {
// Handle dynamic theme changes for system theme
mediaQueryList.addEventListener('change', event => {
const newTheme = event.matches ? 'dark' : 'light';
updateThemeStyles(newTheme);
});
const themePreference = getThemePreference();
updateThemeStyles(themePreference);
}
},
initializeToggleButton: () => {
const toggleButton = document.getElementById('toggle-appearance');
if (toggleButton) {
toggleButton.addEventListener('click', toggleAppearanceDropdown);
}
},
initializeThemeSwitchButtons: () => {
const appearanceDropdown = document.getElementById('appearance-dropdown');
if (!appearanceDropdown) return;
appearanceDropdown.addEventListener('click', event => {
const target = event.target.closest('button[data-theme]');
if (target) {
const theme = target.getAttribute('data-theme');
switchTheme(theme);
}
});
},
initializeThemesInPortal: initializeTheme,
initialize: () => {
openExternalLinksInNewTab();
if (window.portalConfig.isPlainLayoutEnabled === 'true') {
InitializationHelpers.appendPlainParamToURLs();
} else {
InitializationHelpers.initializeThemesInPortal();
InitializationHelpers.navigateToLocalePage();
InitializationHelpers.initializeSearch();
InitializationHelpers.initializeTableOfContents();
InitializationHelpers.initializeTheme();
InitializationHelpers.initializeToggleButton();
InitializationHelpers.initializeThemeSwitchButtons();
}
},
+127
View File
@@ -0,0 +1,127 @@
import { adjustColorForContrast } from '../shared/helpers/colorHelper.js';
export const setPortalHoverColor = theme => {
// This function is to set the hover color for the portal
if (theme === 'system') {
const prefersDarkMode = window.matchMedia(
'(prefers-color-scheme: dark)'
).matches;
theme = prefersDarkMode ? 'dark' : 'light';
}
const portalColor = window.portalConfig.portalColor;
const bgColor = theme === 'dark' ? '#151718' : 'white';
const hoverColor = adjustColorForContrast(portalColor, bgColor);
// Set hover color for border and text dynamically
document.documentElement.style.setProperty(
'--dynamic-hover-color',
hoverColor
);
};
export const removeQueryParamsFromUrl = (queryParam = 'theme') => {
// This function is to remove the theme query param from the URL
// This is done so that the theme is not persisted in the URL
// This is called when the theme is switched from the dropdown
const url = new URL(window.location.href);
const param = url.searchParams.get(queryParam);
if (param) {
url.searchParams.delete(queryParam);
window.history.replaceState({}, '', url.toString()); // Convert URL to string
}
};
export const updateThemeInHeader = theme => {
// This function is to update the theme selection in the header in real time
const themeToggleButton = document.getElementById('toggle-appearance');
if (!themeToggleButton) return;
const allElementInButton =
themeToggleButton.querySelectorAll('.theme-button');
if (!allElementInButton) return;
allElementInButton.forEach(button => {
button.classList.toggle('hidden', button.dataset.theme !== theme);
button.classList.toggle('flex', button.dataset.theme === theme);
});
};
export const switchTheme = theme => {
if (theme === 'system') {
localStorage.removeItem('theme');
const prefersDarkMode = window.matchMedia(
'(prefers-color-scheme: dark)'
).matches;
// remove this so that the system theme is used
document.documentElement.classList.remove('dark', 'light');
document.documentElement.classList.add(prefersDarkMode ? 'dark' : 'light');
} else {
localStorage.theme = theme;
document.documentElement.classList.remove('dark', 'light');
document.documentElement.classList.add(theme);
}
setPortalHoverColor(theme);
updateThemeInHeader(theme);
removeQueryParamsFromUrl();
};
export const initializeThemeSwitchButtons = () => {
const appearanceDropdown = document.getElementById('appearance-dropdown');
appearanceDropdown.dataset.currentTheme = localStorage.theme || 'system';
appearanceDropdown.addEventListener('click', event => {
const target = event.target.closest('button[data-theme]');
if (target) {
const { theme } = target.dataset;
// setting this data property will automatically toggle the checkmark using CSS
appearanceDropdown.dataset.currentTheme = theme;
switchTheme(theme);
// wait for a bit before hiding the dropdown
appearanceDropdown.style.display = 'none';
}
});
};
export const initializeToggleButton = () => {
const themeToggleButton = document.getElementById('toggle-appearance');
themeToggleButton?.addEventListener('click', () => {
const appearanceDropdown = document.getElementById('appearance-dropdown');
const isCurrentlyHidden = appearanceDropdown.style.display === 'none';
// Toggle the appearanceDropdown
appearanceDropdown.style.display = isCurrentlyHidden ? 'flex' : 'none';
});
};
export const initializeMediaQueryListener = () => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', () => {
if (['light', 'dark'].includes(localStorage.theme)) return;
switchTheme('system');
});
};
export const initializeTheme = () => {
if (window.portalConfig.isPlainLayoutEnabled === 'true') return;
// start with updating the theme in the header, this will set the current theme on the button
// and set the hover color at the start of init, this is set again when the theme is switched
setPortalHoverColor(localStorage.theme || 'system');
window.updateThemeInHeader = updateThemeInHeader;
updateThemeInHeader(localStorage.theme || 'system');
// add the event listeners for the dropdown toggle and theme buttons
initializeToggleButton();
initializeThemeSwitchButtons();
// add the media query listener to update the theme when the system theme changes
initializeMediaQueryListener();
};
+1 -312
View File
@@ -1,15 +1,4 @@
import {
InitializationHelpers,
generatePortalBgColor,
generatePortalBg,
generateGradientToBottom,
setPortalStyles,
setPortalClass,
updateThemeStyles,
toggleAppearanceDropdown,
updateURLParameter,
removeURLParameter,
} from '../portalHelpers';
import { InitializationHelpers } from '../portalHelpers';
describe('#navigateToLocalePage', () => {
it('returns correct cookie name', () => {
@@ -32,303 +21,3 @@ describe('#navigateToLocalePage', () => {
);
});
});
describe('Theme Functions', () => {
describe('#generatePortalBgColor', () => {
it('returns mixed color for dark theme', () => {
const result = generatePortalBgColor('#FF5733', 'dark');
expect(result).toBe('color-mix(in srgb, #FF5733 20%, black)');
});
it('returns mixed color for light theme', () => {
const result = generatePortalBgColor('#FF5733', 'light');
expect(result).toBe('color-mix(in srgb, #FF5733 20%, white)');
});
});
describe('#generatePortalBg', () => {
it('returns background for dark theme', () => {
const result = generatePortalBg('#FF5733', 'dark');
expect(result).toBe(
'background: url(/assets/images/hc/hexagon-dark.svg) color-mix(in srgb, #FF5733 20%, black)'
);
});
it('returns background for light theme', () => {
const result = generatePortalBg('#FF5733', 'light');
expect(result).toBe(
'background: url(/assets/images/hc/hexagon-light.svg) color-mix(in srgb, #FF5733 20%, white)'
);
});
});
describe('#generateGradientToBottom', () => {
it('returns gradient for dark theme', () => {
const result = generateGradientToBottom('dark');
expect(result).toBe(
'background-image: linear-gradient(to bottom, transparent, #151718)'
);
});
it('returns gradient for light theme', () => {
const result = generateGradientToBottom('light');
expect(result).toBe(
'background-image: linear-gradient(to bottom, transparent, white)'
);
});
});
describe('#setPortalStyles', () => {
let mockPortalBgDiv;
let mockPortalBgGradientDiv;
beforeEach(() => {
// Mocking portal background div
mockPortalBgDiv = document.createElement('div');
mockPortalBgDiv.id = 'portal-bg';
document.body.appendChild(mockPortalBgDiv);
// Mocking portal background gradient div
mockPortalBgGradientDiv = document.createElement('div');
mockPortalBgGradientDiv.id = 'portal-bg-gradient';
document.body.appendChild(mockPortalBgGradientDiv);
});
afterEach(() => {
document.body.innerHTML = '';
});
it('sets styles for portal background based on theme', () => {
window.portalConfig = { portalColor: '#FF5733' };
setPortalStyles('dark');
const expectedPortalBgStyle =
'background: url(/assets/images/hc/hexagon-dark.svg) color-mix(in srgb, #FF5733 20%, black)';
const expectedGradientStyle =
'background-image: linear-gradient(to bottom, transparent, #151718)';
expect(mockPortalBgDiv.getAttribute('style')).toBe(expectedPortalBgStyle);
expect(mockPortalBgGradientDiv.getAttribute('style')).toBe(
expectedGradientStyle
);
});
});
describe('#setPortalClass', () => {
let mockPortalDiv;
beforeEach(() => {
// Mocking portal div
mockPortalDiv = document.createElement('div');
mockPortalDiv.id = 'portal';
mockPortalDiv.classList.add('light');
document.body.appendChild(mockPortalDiv);
});
afterEach(() => {
document.body.innerHTML = '';
});
it('sets portal class to "dark" based on theme', () => {
setPortalClass('dark');
expect(mockPortalDiv.classList.contains('dark')).toBe(true);
expect(mockPortalDiv.classList.contains('light')).toBe(false);
});
it('sets portal class to "light" based on theme', () => {
setPortalClass('light');
expect(mockPortalDiv.classList.contains('light')).toBe(true);
expect(mockPortalDiv.classList.contains('dark')).toBe(false);
});
});
describe('toggleAppearanceDropdown', () => {
it('sets dropdown display to flex if initially none', () => {
document.body.innerHTML = `<div id="appearance-dropdown" style="display: none;"></div>`;
toggleAppearanceDropdown();
const dropdown = document.getElementById('appearance-dropdown');
expect(dropdown.style.display).toBe('flex');
});
it('sets dropdown display to none if initially flex', () => {
document.body.innerHTML = `<div id="appearance-dropdown" style="display: flex;"></div>`;
toggleAppearanceDropdown();
const dropdown = document.getElementById('appearance-dropdown');
expect(dropdown.style.display).toBe('none');
});
it('does nothing if dropdown element does not exist', () => {
document.body.innerHTML = ``;
expect(() => toggleAppearanceDropdown()).not.toThrow();
});
});
describe('updateURLParameter', () => {
it('updates a given parameter with a new value', () => {
const originalUrl = 'http://example.com?param=oldValue';
delete window.location;
window.location = new URL(originalUrl);
const updatedUrl = updateURLParameter('param', 'newValue');
expect(updatedUrl).toContain('param=newValue');
});
it('adds a new parameter if it does not exist', () => {
const originalUrl = 'http://example.com';
delete window.location;
window.location = new URL(originalUrl);
const updatedUrl = updateURLParameter('newParam', 'value');
expect(updatedUrl).toContain('newParam=value');
});
});
describe('removeURLParameter', () => {
it('removes an existing parameter', () => {
const originalUrl = 'http://example.com?param=value';
delete window.location;
window.location = new URL(originalUrl);
const updatedUrl = removeURLParameter('param');
expect(updatedUrl).not.toContain('param=value');
});
it('does nothing if the parameter does not exist', () => {
const originalUrl = 'http://example.com/';
delete window.location;
window.location = new URL(originalUrl);
const updatedUrl = removeURLParameter('param');
expect(updatedUrl).toBe(originalUrl);
});
});
describe('#updateThemeStyles', () => {
let mockPortalDiv;
let mockPortalBgDiv;
let mockPortalBgGradientDiv;
beforeEach(() => {
// Mocking portal div
mockPortalDiv = document.createElement('div');
mockPortalDiv.id = 'portal';
document.body.appendChild(mockPortalDiv);
// Mocking portal background div
mockPortalBgDiv = document.createElement('div');
mockPortalBgDiv.id = 'portal-bg';
document.body.appendChild(mockPortalBgDiv);
// Mocking portal background gradient div
mockPortalBgGradientDiv = document.createElement('div');
mockPortalBgGradientDiv.id = 'portal-bg-gradient';
document.body.appendChild(mockPortalBgGradientDiv);
});
afterEach(() => {
document.body.innerHTML = '';
});
it('updates theme styles based on theme', () => {
window.portalConfig = { portalColor: '#FF5733' };
updateThemeStyles('dark');
const expectedPortalBgStyle =
'background: url(/assets/images/hc/hexagon-dark.svg) color-mix(in srgb, #FF5733 20%, black)';
const expectedGradientStyle =
'background-image: linear-gradient(to bottom, transparent, #151718)';
expect(mockPortalDiv.classList.contains('dark')).toBe(true);
expect(mockPortalBgDiv.getAttribute('style')).toBe(expectedPortalBgStyle);
expect(mockPortalBgGradientDiv.getAttribute('style')).toBe(
expectedGradientStyle
);
});
});
describe('#initializeTheme', () => {
let mockPortalDiv;
beforeEach(() => {
mockPortalDiv = document.createElement('div');
mockPortalDiv.id = 'portal';
document.body.appendChild(mockPortalDiv);
});
afterEach(() => {
document.body.innerHTML = '';
});
it('updates theme based on system preferences', () => {
const mediaQueryList = {
matches: true,
addEventListener: jest.fn(),
};
window.matchMedia = jest.fn().mockReturnValue(mediaQueryList);
window.portalConfig = { theme: 'system' };
InitializationHelpers.initializeTheme();
expect(mediaQueryList.addEventListener).toBeCalledWith(
'change',
expect.any(Function)
);
expect(mockPortalDiv.classList.contains('dark')).toBe(true);
});
it('does not update theme if themeFromServer is not "system"', () => {
const mediaQueryList = {
matches: true,
addEventListener: jest.fn(),
};
window.matchMedia = jest.fn().mockReturnValue(mediaQueryList);
window.portalConfig = { theme: 'dark' };
InitializationHelpers.initializeTheme();
expect(mediaQueryList.addEventListener).not.toBeCalled();
expect(mockPortalDiv.classList.contains('dark')).toBe(false);
expect(mockPortalDiv.classList.contains('light')).toBe(false);
});
});
describe('initializeToggleButton', () => {
it('adds a click listener to the toggle button', () => {
document.body.innerHTML = `<button id="toggle-appearance"></button>`;
InitializationHelpers.initializeToggleButton();
const toggleButton = document.getElementById('toggle-appearance');
expect(toggleButton.onclick).toBeDefined();
});
it('does nothing if the toggle button is not present', () => {
document.body.innerHTML = ``;
expect(() =>
InitializationHelpers.initializeToggleButton()
).not.toThrow();
});
});
describe('initializeThemeSwitchButtons', () => {
it('adds click listeners to theme switch buttons', () => {
document.body.innerHTML = `<div id="appearance-dropdown"><button data-theme="dark"></button><button data-theme="light"></button></div>`;
InitializationHelpers.initializeThemeSwitchButtons();
const buttons = document.querySelectorAll('button[data-theme]');
buttons.forEach(button => expect(button.onclick).toBeDefined());
});
it('does nothing if theme switch buttons are not present', () => {
document.body.innerHTML = ``;
expect(() =>
InitializationHelpers.initializeThemeSwitchButtons()
).not.toThrow();
});
it('does nothing if appearance-dropdown is not present', () => {
document.body.innerHTML = ``;
expect(() =>
InitializationHelpers.initializeThemeSwitchButtons()
).not.toThrow();
});
});
});
@@ -0,0 +1,279 @@
import {
setPortalHoverColor,
removeQueryParamsFromUrl,
updateThemeInHeader,
switchTheme,
initializeThemeSwitchButtons,
initializeToggleButton,
initializeMediaQueryListener,
initializeTheme,
} from '../portalThemeHelper.js';
import { adjustColorForContrast } from '../../shared/helpers/colorHelper.js';
describe('portalThemeHelper', () => {
let themeToggleButton;
let appearanceDropdown;
beforeEach(() => {
themeToggleButton = document.createElement('div');
themeToggleButton.id = 'toggle-appearance';
document.body.appendChild(themeToggleButton);
appearanceDropdown = document.createElement('div');
appearanceDropdown.id = 'appearance-dropdown';
document.body.appendChild(appearanceDropdown);
window.matchMedia = jest.fn().mockImplementation(query => ({
matches: query === '(prefers-color-scheme: dark)',
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
}));
window.portalConfig = { portalColor: '#ff5733' };
document.documentElement.style.setProperty = jest.fn();
document.documentElement.classList.remove('dark', 'light');
jest.clearAllMocks();
});
afterEach(() => {
themeToggleButton.remove();
appearanceDropdown.remove();
delete window.portalConfig;
document.documentElement.style.setProperty.mockRestore();
document.documentElement.classList.remove('dark', 'light');
localStorage.clear();
});
describe('#setPortalHoverColor', () => {
it('should apply dark hover color in dark theme', () => {
const hoverColor = adjustColorForContrast('#ff5733', '#151718');
setPortalHoverColor('dark');
expect(document.documentElement.style.setProperty).toHaveBeenCalledWith(
'--dynamic-hover-color',
hoverColor
);
});
it('should apply light hover color in light theme', () => {
const hoverColor = adjustColorForContrast('#ff5733', '#ffffff');
setPortalHoverColor('light');
expect(document.documentElement.style.setProperty).toHaveBeenCalledWith(
'--dynamic-hover-color',
hoverColor
);
});
});
describe('#removeQueryParamsFromUrl', () => {
let originalLocation;
beforeEach(() => {
originalLocation = window.location;
delete window.location;
window.location = new URL('http://localhost:3000/');
window.history.replaceState = jest.fn();
});
afterEach(() => {
window.location = originalLocation;
});
it('should not remove query params if theme is not in the URL', () => {
removeQueryParamsFromUrl();
expect(window.history.replaceState).not.toHaveBeenCalled();
});
it('should remove theme query param from the URL', () => {
window.location = new URL(
'http://localhost:3000/?theme=light&show_plain_layout=true'
);
removeQueryParamsFromUrl('theme');
expect(window.history.replaceState).toHaveBeenCalledWith(
{},
'',
'http://localhost:3000/?show_plain_layout=true'
);
});
});
describe('#updateThemeInHeader', () => {
beforeEach(() => {
themeToggleButton.innerHTML = `
<div class="theme-button" data-theme="light"></div>
<div class="theme-button" data-theme="dark"></div>
<div class="theme-button" data-theme="system"></div>
`;
});
it('should not update header if theme toggle button is not found', () => {
themeToggleButton.remove();
updateThemeInHeader('light');
expect(document.querySelector('.theme-button')).toBeNull();
});
it('should show the theme button for the selected theme', () => {
updateThemeInHeader('light');
const lightButton = themeToggleButton.querySelector(
'.theme-button[data-theme="light"]'
);
expect(lightButton.classList).toContain('flex');
});
});
describe('#switchTheme', () => {
it('should set theme to system theme and update classes', () => {
window.matchMedia = jest.fn().mockReturnValue({ matches: true });
switchTheme('system');
expect(localStorage.theme).toBeUndefined();
expect(document.documentElement.classList).toContain('dark');
});
it('should set theme to light theme and update classes', () => {
switchTheme('light');
expect(localStorage.theme).toBe('light');
expect(document.documentElement.classList).toContain('light');
});
it('should set theme to dark theme and update classes', () => {
switchTheme('dark');
expect(localStorage.theme).toBe('dark');
expect(document.documentElement.classList).toContain('dark');
});
});
describe('#initializeThemeSwitchButtons', () => {
beforeEach(() => {
appearanceDropdown.innerHTML = `
<button data-theme="light"><span class="check-mark-icon light-theme"></span></button>
<button data-theme="dark"><span class="check-mark-icon dark-theme"></span></button>
<button data-theme="system"><span class="check-mark-icon system-theme"></span></button>
`;
});
it('does nothing if the appearance dropdown is not found', () => {
appearanceDropdown.remove();
expect(appearanceDropdown.dataset.currentTheme).toBeUndefined();
});
it('should set current theme to system if no theme in localStorage', () => {
localStorage.removeItem('theme');
initializeThemeSwitchButtons();
expect(appearanceDropdown.dataset.currentTheme).toBe('system');
});
it('sets the current theme to the light theme', () => {
localStorage.theme = 'light';
appearanceDropdown.dataset.currentTheme = 'light';
initializeThemeSwitchButtons();
expect(appearanceDropdown.dataset.currentTheme).toBe('light');
});
it('sets the current theme to the dark theme', () => {
localStorage.theme = 'dark';
appearanceDropdown.dataset.currentTheme = 'dark';
initializeThemeSwitchButtons();
expect(appearanceDropdown.dataset.currentTheme).toBe('dark');
});
});
describe('#initializeToggleButton', () => {
it('does nothing if the theme toggle button is not found', () => {
themeToggleButton.remove();
initializeToggleButton();
expect(appearanceDropdown.style.display).toBe('');
});
it('toggles the appearance dropdown show/hide', () => {
themeToggleButton.click();
appearanceDropdown.style.display = 'flex';
expect(appearanceDropdown.style.display).toBe('flex');
themeToggleButton.click();
appearanceDropdown.style.display = 'none';
expect(appearanceDropdown.style.display).toBe('none');
});
});
describe('#initializeMediaQueryListener', () => {
let mediaQuery;
beforeEach(() => {
mediaQuery = {
addEventListener: jest.fn(),
matches: false,
};
window.matchMedia = jest.fn().mockReturnValue(mediaQuery);
});
it('adds a listener to the media query', () => {
initializeMediaQueryListener();
expect(window.matchMedia).toHaveBeenCalledWith(
'(prefers-color-scheme: dark)'
);
expect(mediaQuery.addEventListener).toHaveBeenCalledWith(
'change',
expect.any(Function)
);
});
it('does not switch theme if local storage theme is light or dark', () => {
localStorage.theme = 'light';
initializeMediaQueryListener();
mediaQuery.matches = true;
mediaQuery.addEventListener.mock.calls[0][1]();
expect(localStorage.theme).toBe('light');
});
it('switches to dark theme if system preference changes to dark and no theme is set in local storage', () => {
localStorage.removeItem('theme');
initializeMediaQueryListener();
mediaQuery.matches = true;
mediaQuery.addEventListener.mock.calls[0][1]();
expect(document.documentElement.classList).toContain('dark');
});
it('switches to light theme if system preference changes to light and no theme is set in local storage', () => {
localStorage.removeItem('theme');
initializeMediaQueryListener();
mediaQuery.matches = false;
mediaQuery.addEventListener.mock.calls[0][1]();
expect(document.documentElement.classList).toContain('light');
});
});
describe('#initializeTheme', () => {
it('should not initialize theme if plain layout is enabled', () => {
window.portalConfig.isPlainLayoutEnabled = 'true';
initializeTheme();
expect(localStorage.theme).toBeUndefined();
expect(document.documentElement.classList).not.toContain('light');
expect(document.documentElement.classList).not.toContain('dark');
});
it('sets the theme to the system theme', () => {
initializeTheme();
expect(localStorage.theme).toBeUndefined();
const prefersDarkMode = window.matchMedia(
'(prefers-color-scheme: dark)'
).matches;
expect(document.documentElement.classList.contains('light')).toBe(
!prefersDarkMode
);
});
it('sets the theme to the light theme', () => {
localStorage.theme = 'light';
document.documentElement.classList.add('light');
initializeTheme();
expect(localStorage.theme).toBe('light');
expect(document.documentElement.classList.contains('light')).toBe(true);
});
it('sets the theme to the dark theme', () => {
localStorage.theme = 'dark';
document.documentElement.classList.add('dark');
initializeTheme();
expect(localStorage.theme).toBe('dark');
expect(document.documentElement.classList.contains('dark')).toBe(true);
});
});
});
@@ -17,7 +17,7 @@
<resizable-text-area
v-else
v-model="greetingsMessage"
rows="4"
:rows="4"
type="text"
class="greetings--textarea"
:label="label"
@@ -2,6 +2,7 @@
<textarea
ref="textarea"
:placeholder="placeholder"
:rows="rows"
:value="value"
@input="onInput"
@focus="onFocus"
@@ -36,6 +37,10 @@ export default {
type: String,
default: '',
},
rows: {
type: Number,
default: 2,
},
// add this as a prop, so that we won't have to include uiSettingsMixin
sendWithSignature: {
type: Boolean,
@@ -1,3 +1,5 @@
import { toHex, mix, getLuminance, getContrast } from 'color2k';
export const isWidgetColorLighter = color => {
const colorToCheck = color.replace('#', '');
const c_r = parseInt(colorToCheck.substr(0, 2), 16);
@@ -6,3 +8,21 @@ export const isWidgetColorLighter = color => {
const brightness = (c_r * 299 + c_g * 587 + c_b * 114) / 1000;
return brightness > 225;
};
export const adjustColorForContrast = (color, backgroundColor) => {
const targetRatio = 3.1;
const MAX_ITERATIONS = 20;
let adjustedColor = color;
for (let iteration = 0; iteration < MAX_ITERATIONS; iteration += 1) {
const currentRatio = getContrast(adjustedColor, backgroundColor);
if (currentRatio >= targetRatio) {
break;
}
const adjustmentDirection =
getLuminance(adjustedColor) < 0.5 ? '#fff' : '#151718';
adjustedColor = mix(adjustedColor, adjustmentDirection, 0.05);
}
return toHex(adjustedColor);
};
@@ -1,4 +1,8 @@
import { isWidgetColorLighter } from 'shared/helpers/colorHelper';
import { toHex, getContrast } from 'color2k';
import {
isWidgetColorLighter,
adjustColorForContrast,
} from 'shared/helpers/colorHelper';
describe('#isWidgetColorLighter', () => {
it('returns true if color is lighter', () => {
@@ -8,3 +12,56 @@ describe('#isWidgetColorLighter', () => {
expect(isWidgetColorLighter('#000000')).toEqual(false);
});
});
describe('#adjustColorForContrast', () => {
const targetRatio = 3.1;
const getContrastRatio = (color1, color2) => {
// getContrast from 'color2k'
return getContrast(color1, color2);
};
it('adjusts a color to meet the contrast ratio against a light background', () => {
const color = '#ff0000';
const backgroundColor = '#ffffff';
const adjustedColor = adjustColorForContrast(color, backgroundColor);
const ratio = getContrastRatio(adjustedColor, backgroundColor);
expect(ratio).toBeGreaterThanOrEqual(targetRatio);
});
it('adjusts a color to meet the contrast ratio against a dark background', () => {
const color = '#00ff00';
const backgroundColor = '#000000';
const adjustedColor = adjustColorForContrast(color, backgroundColor);
const ratio = getContrastRatio(adjustedColor, backgroundColor);
expect(ratio).toBeGreaterThanOrEqual(targetRatio);
});
it('returns a string representation of the color', () => {
const color = '#00ff00';
const backgroundColor = '#000000';
const adjustedColor = adjustColorForContrast(color, backgroundColor);
expect(typeof adjustedColor).toEqual('string');
});
it('handles cases where the color already meets the contrast ratio', () => {
const color = '#000000';
const backgroundColor = '#ffffff';
const adjustedColor = adjustColorForContrast(color, backgroundColor);
const ratio = getContrastRatio(adjustedColor, backgroundColor);
expect(ratio).toBeGreaterThanOrEqual(targetRatio);
expect(adjustedColor).toEqual(toHex(color));
});
it('does not modify a color that already exceeds the contrast ratio', () => {
const color = '#000000';
const backgroundColor = '#ffffff';
const adjustedColor = adjustColorForContrast(color, backgroundColor);
expect(adjustedColor).toEqual(toHex(color));
});
});
@@ -50,6 +50,7 @@ export const getters = {
get: $state => $state,
isOnChatwootCloud: $state => $state.deploymentEnv === 'cloud',
isACustomBrandedInstance: $state => $state.installationName !== 'Chatwoot',
isAChatwootInstance: $state => $state.installationName === 'Chatwoot',
};
export const actions = {};
-2
View File
@@ -28,8 +28,6 @@ export const login = async ({
export const register = async creds => {
try {
const response = await wootAPI.post('api/v1/accounts.json', {
account_name: creds.accountName.trim(),
user_full_name: creds.fullName.trim(),
email: creds.email,
password: creds.password,
h_captcha_client_response: creds.hCaptchaClientResponse,
@@ -1,86 +0,0 @@
<template>
<div>
<label
v-if="label"
:for="name"
class="flex justify-between text-sm font-medium leading-6 text-slate-900 dark:text-white"
:class="{ 'text-red-500': hasError }"
>
{{ label }}
<slot />
</label>
<div class="mt-1">
<input
:id="name"
:name="name"
:type="type"
autocomplete="off"
:required="required"
:placeholder="placeholder"
:data-testid="dataTestid"
:value="value"
:class="{
'focus:ring-red-600 ring-red-600': hasError,
'dark:ring-slate-600 dark:focus:ring-woot-500 ring-slate-200':
!hasError,
}"
class="block w-full rounded-md border-0 px-3 py-3 appearance-none shadow-sm ring-1 ring-inset text-slate-900 dark:text-slate-100 placeholder:text-slate-400 focus:ring-2 focus:ring-inset focus:ring-woot-500 sm:text-sm sm:leading-6 outline-none dark:bg-slate-700"
@input="onInput"
@blur="$emit('blur')"
/>
<span
v-if="errorMessage && hasError"
class="text-xs leading-2 text-red-400"
>
{{ errorMessage }}
</span>
</div>
</div>
</template>
<script>
export default {
props: {
label: {
type: String,
default: '',
},
name: {
type: String,
required: true,
},
type: {
type: String,
default: 'text',
},
required: {
type: Boolean,
default: false,
},
placeholder: {
type: String,
default: '',
},
value: {
type: [String, Number],
default: '',
},
hasError: {
type: Boolean,
default: false,
},
errorMessage: {
type: String,
default: '',
},
dataTestid: {
type: String,
default: '',
},
},
methods: {
onInput(e) {
this.$emit('input', e.target.value);
},
},
};
</script>
@@ -49,7 +49,7 @@
<script>
import { required, minLength } from 'vuelidate/lib/validators';
import FormInput from '../../../components/Form/Input.vue';
import FormInput from 'dashboard/components/form/Input.vue';
import SubmitButton from '../../../components/Button/SubmitButton.vue';
import { DEFAULT_REDIRECT_URL } from 'dashboard/constants/globals';
import { setNewPassword } from '../../../api/auth';
@@ -50,7 +50,7 @@
import { required, minLength, email } from 'vuelidate/lib/validators';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { mapGetters } from 'vuex';
import FormInput from '../../../../components/Form/Input.vue';
import FormInput from 'dashboard/components/form/Input.vue';
import { resetPassword } from '../../../../api/auth';
import SubmitButton from '../../../../components/Button/SubmitButton.vue';
@@ -1,52 +1,30 @@
<template>
<div class="flex-1 overflow-auto px-1">
<form class="space-y-3" @submit.prevent="submit">
<div class="flex">
<div class="flex-1 px-1 overflow-auto">
<form class="space-y-8" @submit.prevent="submit">
<section class="space-y-3">
<form-input
v-model.trim="credentials.fullName"
name="full_name"
class="flex-1"
:class="{ error: $v.credentials.fullName.$error }"
:label="$t('REGISTER.FULL_NAME.LABEL')"
:placeholder="$t('REGISTER.FULL_NAME.PLACEHOLDER')"
:has-error="$v.credentials.fullName.$error"
:error-message="$t('REGISTER.FULL_NAME.ERROR')"
@blur="$v.credentials.fullName.$touch"
v-model.trim="credentials.email"
type="email"
name="email_address"
:class="{ error: $v.credentials.email.$error }"
:label="$t('REGISTER.EMAIL.LABEL')"
:placeholder="$t('REGISTER.EMAIL.PLACEHOLDER')"
:has-error="$v.credentials.email.$error"
:error-message="$t('REGISTER.EMAIL.ERROR')"
@blur="$v.credentials.email.$touch"
/>
<form-input
v-model.trim="credentials.accountName"
name="account_name"
class="flex-1 ml-2"
:class="{ error: $v.credentials.accountName.$error }"
:label="$t('REGISTER.COMPANY_NAME.LABEL')"
:placeholder="$t('REGISTER.COMPANY_NAME.PLACEHOLDER')"
:has-error="$v.credentials.accountName.$error"
:error-message="$t('REGISTER.COMPANY_NAME.ERROR')"
@blur="$v.credentials.accountName.$touch"
v-model.trim="credentials.password"
type="password"
name="password"
:class="{ error: $v.credentials.password.$error }"
:label="$t('LOGIN.PASSWORD.LABEL')"
:placeholder="$t('SET_NEW_PASSWORD.PASSWORD.PLACEHOLDER')"
:has-error="$v.credentials.password.$error"
:error-message="passwordErrorText"
@blur="$v.credentials.password.$touch"
/>
</div>
<form-input
v-model.trim="credentials.email"
type="email"
name="email_address"
:class="{ error: $v.credentials.email.$error }"
:label="$t('REGISTER.EMAIL.LABEL')"
:placeholder="$t('REGISTER.EMAIL.PLACEHOLDER')"
:has-error="$v.credentials.email.$error"
:error-message="$t('REGISTER.EMAIL.ERROR')"
@blur="$v.credentials.email.$touch"
/>
<form-input
v-model.trim="credentials.password"
type="password"
name="password"
:class="{ error: $v.credentials.password.$error }"
:label="$t('LOGIN.PASSWORD.LABEL')"
:placeholder="$t('SET_NEW_PASSWORD.PASSWORD.PLACEHOLDER')"
:has-error="$v.credentials.password.$error"
:error-message="passwordErrorText"
@blur="$v.credentials.password.$touch"
/>
</section>
<div v-if="globalConfig.hCaptchaSiteKey" class="mb-3">
<vue-hcaptcha
ref="hCaptcha"
@@ -83,9 +61,9 @@ import { required, minLength, email } from 'vuelidate/lib/validators';
import { mapGetters } from 'vuex';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import alertMixin from 'shared/mixins/alertMixin';
import { DEFAULT_REDIRECT_URL } from 'dashboard/constants/globals';
import { ONBOARDING_START_URL } from 'dashboard/constants/globals';
import VueHcaptcha from '@hcaptcha/vue-hcaptcha';
import FormInput from '../../../../../components/Form/Input.vue';
import FormInput from 'dashboard/components/form/Input.vue';
import SubmitButton from '../../../../../components/Button/SubmitButton.vue';
import { isValidPassword } from 'shared/helpers/Validators';
import GoogleOAuthButton from '../../../../../components/GoogleOauth/Button.vue';
@@ -103,10 +81,8 @@ export default {
data() {
return {
credentials: {
accountName: '',
fullName: '',
email: '',
password: '',
email: 'Testing123@shivam.dev',
password: 'Testing123@shivam.dev',
hCaptchaClientResponse: '',
},
didCaptchaReset: false,
@@ -116,14 +92,6 @@ export default {
},
validations: {
credentials: {
accountName: {
required,
minLength: minLength(2),
},
fullName: {
required,
minLength: minLength(2),
},
email: {
required,
email,
@@ -181,7 +149,7 @@ export default {
this.isSignupInProgress = true;
try {
await register(this.credentials);
window.location = DEFAULT_REDIRECT_URL;
window.location = ONBOARDING_START_URL;
} catch (error) {
let errorMessage =
error?.message || this.$t('REGISTER.API.ERROR_MESSAGE');
+1 -1
View File
@@ -90,7 +90,7 @@ import SubmitButton from '../../components/Button/SubmitButton.vue';
import { mapGetters } from 'vuex';
import { parseBoolean } from '@chatwoot/utils';
import GoogleOAuthButton from '../../components/GoogleOauth/Button.vue';
import FormInput from '../../components/Form/Input.vue';
import FormInput from 'dashboard/components/form/Input.vue';
import { login } from '../../api/auth';
import Spinner from 'shared/components/Spinner.vue';
const ERROR_MESSAGES = {
@@ -22,7 +22,6 @@
<drag-wrapper
class="space-y-2"
direction="right"
:disabled="!allowReplyTo"
@dragged="toggleReply"
>
<AgentMessageBubble
@@ -58,7 +57,6 @@
</drag-wrapper>
<div class="flex flex-col justify-end">
<message-reply-button
v-if="allowReplyTo"
class="transition-opacity delay-75 opacity-0 group-hover:opacity-100 sm:opacity-0"
@click="toggleReply"
/>
@@ -127,7 +125,6 @@ export default {
data() {
return {
hasImageError: false,
allowReplyTo: window.chatwootWebChannel.allowReplyTo || false,
};
},
computed: {
@@ -217,7 +214,6 @@ export default {
};
},
hasReplyTo() {
if (!this.allowReplyTo) return false;
return this.replyTo && (this.replyTo.content || this.replyTo.attachments);
},
},
@@ -68,7 +68,6 @@ export default {
data() {
return {
inReplyTo: null,
allowReplyTo: window.chatwootWebChannel.allowReplyTo || false,
};
},
computed: {
@@ -91,8 +90,6 @@ export default {
return this.currentUser && this.currentUser.email;
},
hasReplyTo() {
if (!this.allowReplyTo) return false;
return (
this.inReplyTo && (this.inReplyTo.content || this.inReplyTo.attachments)
);
@@ -8,6 +8,7 @@
id="chat-input"
ref="chatInput"
v-model="userInput"
:rows="1"
:aria-label="$t('CHAT_PLACEHOLDER')"
:placeholder="$t('CHAT_PLACEHOLDER')"
class="form-input user-message-input is-focused"
@@ -11,16 +11,12 @@
<div class="flex justify-end gap-1">
<div class="flex flex-col justify-end">
<message-reply-button
v-if="!isInProgress && !isFailed && allowReplyTo"
v-if="!isInProgress && !isFailed"
class="transition-opacity delay-75 opacity-0 group-hover:opacity-100 sm:opacity-0"
@click="toggleReply"
/>
</div>
<drag-wrapper
direction="left"
:disabled="!allowReplyTo"
@dragged="toggleReply"
>
<drag-wrapper direction="left" @dragged="toggleReply">
<user-message-bubble
v-if="showTextBubble"
:message="message.content"
@@ -111,7 +107,6 @@ export default {
data() {
return {
hasImageError: false,
allowReplyTo: window.chatwootWebChannel.allowReplyTo || false,
};
},
computed: {
@@ -142,7 +137,6 @@ export default {
: this.$t('COMPONENTS.MESSAGE_BUBBLE.ERROR_MESSAGE');
},
hasReplyTo() {
if (!this.allowReplyTo) return false;
return this.replyTo && (this.replyTo.content || this.replyTo.attachments);
},
},
@@ -0,0 +1,8 @@
class Webhooks::FacebookDeliveryJob < ApplicationJob
queue_as :low
def perform(message)
response = ::Integrations::Facebook::MessageParser.new(message)
Integrations::Facebook::DeliveryStatus.new(params: response).perform
end
end
+1 -2
View File
@@ -6,7 +6,7 @@
# auto_resolve_duration :integer
# custom_attributes :jsonb
# domain :string(100)
# feature_flags :integer default(0), not null
# feature_flags :bigint default(0), not null
# limits :jsonb
# locale :integer default("en")
# name :string not null
@@ -32,7 +32,6 @@ class Account < ApplicationRecord
check_for_column: false
}.freeze
validates :name, presence: true
validates :auto_resolve_duration, numericality: { greater_than_or_equal_to: 1, less_than_or_equal_to: 999, allow_nil: true }
validates :domain, length: { maximum: 100 }
+1 -2
View File
@@ -68,8 +68,7 @@ class User < ApplicationRecord
# work because :validatable in devise overrides this.
# validates_uniqueness_of :email, scope: :account_id
validates :email, :name, presence: true
validates_length_of :name, minimum: 1, maximum: 255
validates :email, presence: true
has_many :account_users, dependent: :destroy_async
has_many :accounts, through: :account_users
+4
View File
@@ -27,6 +27,10 @@ class PortalPolicy < ApplicationPolicy
@account_user.administrator?
end
def logo?
@account_user.administrator?
end
private
def portal_member?
@@ -17,7 +17,10 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService
def send_message_to_facebook(delivery_params)
result = Facebook::Messenger::Bot.deliver(delivery_params, page_id: channel.page_id)
parsed_result = JSON.parse(result)
message.update!(status: :failed, external_error: external_error(parsed_result)) if parsed_result['error'].present?
if parsed_result['error'].present?
message.update!(status: :failed, external_error: external_error(parsed_result))
Rails.logger.info "Facebook::SendOnFacebookService: Error sending message to Facebook : Page - #{channel.page_id} : #{result}"
end
message.update!(source_id: parsed_result['message_id']) if parsed_result['message_id'].present?
end
@@ -108,7 +108,9 @@ class Twilio::IncomingMessageService
return if params[:MediaUrl0].blank?
attachment_file = Down.download(
params[:MediaUrl0]
params[:MediaUrl0],
# https://support.twilio.com/hc/en-us/articles/223183748-Protect-Media-Access-with-HTTP-Basic-Authentication-for-Programmable-Messaging
http_basic_authentication: [twilio_channel.account_sid, twilio_channel.auth_token || twilio_channel.api_key_sid]
)
attachment = @message.attachments.new(
@@ -7,7 +7,6 @@ json.phone_number resource.phone_number
json.identifier resource.identifier
json.thumbnail resource.avatar_url
json.custom_attributes resource.custom_attributes
json.conversations_count resource.conversations_count if resource[:conversations_count].present?
json.last_activity_at resource.last_activity_at.to_i if resource[:last_activity_at].present?
json.created_at resource.created_at.to_i if resource[:created_at].present?
# we only want to output contact inbox when its /contacts endpoints
@@ -1,6 +1,5 @@
json.website_channel_config do
json.allow_messages_after_resolved @web_widget.inbox.allow_messages_after_resolved
json.allow_reply_to @web_widget.inbox.account.feature_enabled?('message_reply_to')
json.api_host ENV.fetch('FRONTEND_URL', nil)
json.auth_token @token
json.avatar_url @web_widget.inbox.avatar_url

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