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
175 changed files with 1739 additions and 2450 deletions
+5 -5
View File
@@ -109,14 +109,14 @@ 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.14.0', require: false
gem 'sentry-rails', '>= 5.13.0', require: false
gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.14.0', require: false
gem 'sentry-sidekiq', '>= 5.13.0', require: false
##-- background job processing --##
gem 'sidekiq', '>= 7.1.3'
# We want cron jobs
gem 'sidekiq-cron', '>= 1.12.0'
gem 'sidekiq-cron', '>= 1.11.0'
##-- Push notification service --##
gem 'fcm'
@@ -198,7 +198,7 @@ group :development do
gem 'squasher'
# profiling
gem 'rack-mini-profiler', '>= 3.2.0', require: false
gem 'rack-mini-profiler', '>= 3.1.1', require: false
gem 'stackprof'
# Should install the associated chrome extension to view query logs
gem 'meta_request'
@@ -224,7 +224,7 @@ group :development, :test do
gem 'byebug', platform: :mri
gem 'climate_control'
gem 'debug', '~> 1.8'
gem 'factory_bot_rails', '>= 6.4.2'
gem 'factory_bot_rails'
gem 'listen'
gem 'mock_redis'
gem 'pry-rails'
+16 -16
View File
@@ -230,10 +230,10 @@ GEM
facebook-messenger (2.0.1)
httparty (~> 0.13, >= 0.13.7)
rack (>= 1.4.5)
factory_bot (6.4.2)
factory_bot (6.2.1)
activesupport (>= 5.0.0)
factory_bot_rails (6.4.2)
factory_bot (~> 6.4)
factory_bot_rails (6.2.0)
factory_bot (~> 6.2.0)
railties (>= 5.0.0)
faker (3.2.0)
i18n (>= 1.8.11, < 2)
@@ -566,7 +566,7 @@ GEM
rack (< 4)
rack-cors (2.0.1)
rack (>= 2.0.0)
rack-mini-profiler (3.2.0)
rack-mini-profiler (3.1.1)
rack (>= 1.2.0)
rack-protection (3.0.6)
rack
@@ -610,7 +610,7 @@ GEM
ffi (~> 1.0)
redis (5.0.6)
redis-client (>= 0.9.0)
redis-client (0.19.0)
redis-client (0.18.0)
connection_pool
redis-namespace (1.10.0)
redis (>= 4)
@@ -709,13 +709,13 @@ GEM
activesupport (>= 4)
selectize-rails (0.12.6)
semantic_range (3.0.0)
sentry-rails (5.14.0)
sentry-rails (5.13.0)
railties (>= 5.0)
sentry-ruby (~> 5.14.0)
sentry-ruby (5.14.0)
sentry-ruby (~> 5.13.0)
sentry-ruby (5.13.0)
concurrent-ruby (~> 1.0, >= 1.0.2)
sentry-sidekiq (5.14.0)
sentry-ruby (~> 5.14.0)
sentry-sidekiq (5.13.0)
sentry-ruby (~> 5.13.0)
sidekiq (>= 3.0)
sexp_processor (4.17.0)
shoulda-matchers (5.3.0)
@@ -725,7 +725,7 @@ GEM
connection_pool (>= 2.3.0)
rack (>= 2.2.4)
redis-client (>= 0.14.0)
sidekiq-cron (1.12.0)
sidekiq-cron (1.11.0)
fugit (~> 1.8)
globalid (>= 1.0.1)
sidekiq (>= 6)
@@ -870,7 +870,7 @@ DEPENDENCIES
elastic-apm
email_reply_trimmer
facebook-messenger
factory_bot_rails (>= 6.4.2)
factory_bot_rails
faker
fcm
flag_shih_tzu
@@ -918,7 +918,7 @@ DEPENDENCIES
pundit
rack-attack (>= 6.7.0)
rack-cors
rack-mini-profiler (>= 3.2.0)
rack-mini-profiler (>= 3.1.1)
rack-timeout
rails (~> 7.0.8.0)
redis
@@ -935,12 +935,12 @@ DEPENDENCIES
scout_apm
scss_lint
seed_dump
sentry-rails (>= 5.14.0)
sentry-rails (>= 5.13.0)
sentry-ruby
sentry-sidekiq (>= 5.14.0)
sentry-sidekiq (>= 5.13.0)
shoulda-matchers
sidekiq (>= 7.1.3)
sidekiq-cron (>= 1.12.0)
sidekiq-cron (>= 1.11.0)
simplecov (= 0.17.1)
slack-ruby-client (~> 2.2.0)
spring
+1 -1
View File
@@ -1 +1 @@
3.3.1
3.1.0
+1 -1
View File
@@ -1 +1 @@
2.7.0
2.6.0
@@ -25,6 +25,7 @@
@import 'components/flashes';
@import 'components/form-actions';
@import 'components/main-content';
@import 'components/navigation';
@import 'components/pagination';
@import 'components/search';
@import 'components/reports';
@@ -1,7 +1,7 @@
html {
background-color: $color-white;
box-sizing: border-box;
font-size: 16px;
font-size: 10px;
-webkit-font-smoothing: antialiased;
}
@@ -16,8 +16,8 @@
.attribute-data {
float: left;
margin-bottom: $base-spacing;
margin-left: 1.25rem;
width: calc(84% - 0.625rem);
margin-left: 2rem;
width: calc(84% - 1rem);
}
.attribute--nested {
@@ -9,22 +9,22 @@
.field-unit__label {
float: left;
margin-left: 0.625rem;
margin-left: 1rem;
text-align: right;
width: calc(15% - 0.625rem);
width: calc(15% - 1rem);
}
.field-unit__field {
float: left;
margin-left: 1.25rem;
max-width: 31.15rem;
margin-left: 2rem;
max-width: 50rem;
width: 100%;
}
.field-unit--nested {
border: $base-border;
margin-left: 7.5%;
max-width: 37.5rem;
max-width: 60rem;
padding: $small-spacing;
width: 100%;
@@ -1,3 +1,3 @@
.form-actions {
margin-left: calc(15% + 1.25rem);
margin-left: calc(15% + 2rem);
}
@@ -13,10 +13,6 @@
table {
font-size: $font-size-small;
}
form {
margin-top: $space-two;
}
}
.main-content__header {
@@ -24,7 +20,7 @@
background-color: $color-white;
border-bottom: 1px solid $color-border;
display: flex;
min-height: 3.5rem;
min-height: 5.6rem;
padding: $space-small $space-normal;
}
@@ -0,0 +1,88 @@
.logo-brand {
margin-bottom: $space-normal;
padding: $space-normal $space-smaller $space-small;
text-align: left;
img {
margin-bottom: $space-smaller;
max-height: 3rem;
}
}
.navigation {
background: $white;
border-right: 1px solid $color-border;
display: flex;
flex-direction: column;
font-size: $font-size-default;
font-weight: $font-weight-medium;
height: 100%;
justify-content: flex-start;
left: 0;
margin: 0;
overflow: auto;
padding: $space-normal;
position: fixed;
top: 0;
width: 21rem;
z-index: 1023;
li {
align-items: center;
display: flex;
font-size: $font-size-small;
a {
color: $color-gray;
text-decoration: none;
}
i {
min-width: $space-medium;
}
}
hr {
margin: $space-slab;
}
}
.navigation__link {
background-color: transparent;
color: $color-gray;
display: block;
line-height: 1;
margin-bottom: $space-smaller;
padding: $space-small;
&:hover {
color: $blue;
a {
color: $blue;
}
}
&.navigation__link--active {
background-color: $color-background;
border-radius: $base-border-radius;
color: $blue;
a {
color: $blue;
}
}
}
.logout {
bottom: $space-normal;
left: $space-normal;
position: fixed;
}
.app-version {
color: $color-gray;
font-size: $font-size-small;
padding-top: $space-smaller;
}
@@ -1,7 +1,7 @@
.search {
margin-left: auto;
margin-right: 1.25rem;
max-width: 27.5rem;
margin-right: 2rem;
max-width: 44rem;
position: relative;
width: 100%;
}
@@ -1,10 +1,10 @@
// Typography
$base-font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
$base-font-family: PlusJakarta, Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif !default;
$heading-font-family: $base-font-family !default;
$base-font-size: 16px !default;
$base-font-size: 14px !default;
$base-line-height: 1.5 !default;
$heading-line-height: 1.2 !default;
@@ -1,30 +1,30 @@
// Font sizes
$font-size-nano: 0.5rem;
$font-size-micro: 0.675rem;
$font-size-mini: 0.75rem;
$font-size-small: 0.875rem;
$font-size-default: 1rem;
$font-size-medium: 1.125rem;
$font-size-large: 1.375rem;
$font-size-big: 1.5rem;
$font-size-bigger: 1.75rem;
$font-size-mega: 2.125rem;
$font-size-giga: 2.5rem;
$font-size-nano: 0.8rem;
$font-size-micro: 1.0rem;
$font-size-mini: 1.2rem;
$font-size-small: 1.4rem;
$font-size-default: 1.6rem;
$font-size-medium: 1.8rem;
$font-size-large: 2.2rem;
$font-size-big: 2.4rem;
$font-size-bigger: 3.0rem;
$font-size-mega: 3.4rem;
$font-size-giga: 4.0rem;
// spaces
$zero: 0;
$space-micro: 0.125rem;
$space-smaller: 0.25rem;
$space-small: 0.5rem;
$space-one: 0.675rem;
$space-slab: 0.75rem;
$space-normal: 1rem;
$space-two: 1.25rem;
$space-medium: 1.5rem;
$space-large: 2rem;
$space-larger: 3rem;
$space-jumbo: 4rem;
$space-mega: 6.25rem;
$space-micro: 0.2rem;
$space-smaller: 0.4rem;
$space-small: 0.8rem;
$space-one: 1rem;
$space-slab: 1.2rem;
$space-normal: 1.6rem;
$space-two: 2.0rem;
$space-medium: 2.4rem;
$space-large: 3.2rem;
$space-larger: 4.8rem;
$space-jumbo: 6.4rem;
$space-mega: 10.0rem;
// font-weight
$font-weight-feather: 100;
+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!
@@ -25,9 +25,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
build_contact_inbox
build_message
end
rescue Koala::Facebook::AuthenticationError => e
Rails.logger.warn("Facebook authentication error for inbox: #{@inbox.id} with error: #{e.message}")
Rails.logger.error e
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
@@ -110,15 +108,11 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
}
end
# rubocop:disable Metrics/AbcSize
# rubocop:disable Metrics/MethodLength
def contact_params
begin
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
result = k.get_object(@sender_id) || {}
rescue Koala::Facebook::AuthenticationError => e
Rails.logger.warn("Facebook authentication error for inbox: #{@inbox.id} with error: #{e.message}")
Rails.logger.error e
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
raise
rescue Koala::Facebook::ClientError => e
@@ -136,6 +130,4 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
end
process_contact_params_result(result)
end
# rubocop:enable Metrics/AbcSize
# rubocop:enable Metrics/MethodLength
end
@@ -20,9 +20,7 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
ActiveRecord::Base.transaction do
build_message
end
rescue Koala::Facebook::AuthenticationError => e
Rails.logger.warn("Instagram authentication error for inbox: #{@inbox.id} with error: #{e.message}")
Rails.logger.error e
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
raise
rescue StandardError => e
+1 -2
View File
@@ -149,8 +149,7 @@ class Messages::MessageBuilder
content_type: @params[:content_type],
items: @items,
in_reply_to: @in_reply_to,
echo_id: @params[:echo_id],
source_id: @params[:source_id]
echo_id: @params[:echo_id]
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
end
end
+3 -4
View File
@@ -1,5 +1,5 @@
class NotificationBuilder
pattr_initialize [:notification_type!, :user!, :account!, :primary_actor!, :secondary_actor]
pattr_initialize [:notification_type!, :user!, :account!, :primary_actor!]
def perform
return unless user_subscribed_to_notification?
@@ -9,7 +9,7 @@ class NotificationBuilder
private
def current_user
def secondary_actor
Current.user
end
@@ -29,8 +29,7 @@ class NotificationBuilder
notification_type: notification_type,
account: account,
primary_actor: primary_actor,
# secondary_actor is secondary_actor if present, else current_user
secondary_actor: secondary_actor || current_user
secondary_actor: secondary_actor
)
end
end
@@ -10,18 +10,11 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
def show; end
def create
@agent_bot = Current.account.agent_bots.create!(permitted_params.except(:avatar_url))
process_avatar_from_url
@agent_bot = Current.account.agent_bots.create!(permitted_params)
end
def update
@agent_bot.update!(permitted_params.except(:avatar_url))
process_avatar_from_url
end
def avatar
@agent_bot.avatar.purge if @agent_bot.avatar.attached?
@agent_bot
@agent_bot.update!(permitted_params)
end
def destroy
@@ -37,10 +30,6 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
end
def permitted_params
params.permit(:name, :description, :outgoing_url, :avatar, :avatar_url, :bot_type, bot_config: [:csml_content])
end
def process_avatar_from_url
::Avatar::AvatarFromUrlJob.perform_later(@agent_bot, params[:avatar_url]) if params[:avatar_url].present?
params.permit(:name, :description, :outgoing_url, :bot_type, bot_config: [:csml_content])
end
end
@@ -83,14 +83,14 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
@contact = Current.account.contacts.new(permitted_params.except(:avatar_url))
@contact.save!
@contact_inbox = build_contact_inbox
process_avatar_from_url
process_avatar
end
end
def update
@contact.assign_attributes(contact_update_params)
@contact.save!
process_avatar_from_url
process_avatar if permitted_params[:avatar].present? || permitted_params[:avatar_url].present?
end
def destroy
@@ -174,7 +174,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
@contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id])
end
def process_avatar_from_url
def process_avatar
::Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present?
end
@@ -14,8 +14,7 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
def set_agent
@agent = Current.account.users.find_by(id: params[:assignee_id])
@conversation.assignee = @agent
@conversation.save!
@conversation.update_assignee(@agent)
render_agent
end
@@ -18,15 +18,6 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
end
end
def retry
return if message.blank?
message.update!(status: :sent, content_attributes: {})
::SendReplyJob.perform_later(message.id)
rescue StandardError => e
render_could_not_create_error(e.message)
end
def translate
return head :ok if already_translated_content_available?
@@ -110,8 +110,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def assign_conversation
@conversation.assignee = current_user
@conversation.save!
@agent = Current.account.users.find(current_user.id)
@conversation.update_assignee(@agent)
end
def conversation
@@ -1,8 +1,7 @@
class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseController
RESULTS_PER_PAGE = 15
include DateRangeHelper
before_action :fetch_notification, only: [:update, :destroy, :snooze]
before_action :fetch_notification, only: [:update, :destroy]
before_action :set_primary_actor, only: [:read_all]
before_action :set_current_page, only: [:index]
@@ -39,11 +38,6 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
render json: @unread_count
end
def snooze
@notification.update(snoozed_until: parse_date_time(params[:snoozed_until].to_s)) if params[:snoozed_until]
render json: @notification
end
private
def set_primary_actor
@@ -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],
@@ -9,15 +9,13 @@ class Platform::Api::V1::AgentBotsController < PlatformController
def show; end
def create
@resource = AgentBot.new(agent_bot_params.except(:avatar_url))
@resource = AgentBot.new(agent_bot_params)
@resource.save!
process_avatar_from_url
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
end
def update
@resource.update!(agent_bot_params.except(:avatar_url))
process_avatar_from_url
@resource.update!(agent_bot_params)
end
def destroy
@@ -25,11 +23,6 @@ class Platform::Api::V1::AgentBotsController < PlatformController
head :ok
end
def avatar
@resource.avatar.purge if @resource.avatar.attached?
@resource
end
private
def set_resource
@@ -37,10 +30,6 @@ class Platform::Api::V1::AgentBotsController < PlatformController
end
def agent_bot_params
params.permit(:name, :description, :account_id, :outgoing_url, :avatar, :avatar_url)
end
def process_avatar_from_url
::Avatar::AvatarFromUrlJob.perform_later(@resource, params[:avatar_url]) if params[:avatar_url].present?
params.permit(:name, :description, :account_id, :outgoing_url)
end
end
@@ -1,38 +1,21 @@
class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
before_action :set_config
before_action :allowed_configs
def show
@allowed_configs = %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET]
# ref: https://github.com/rubocop/rubocop/issues/7767
# rubocop:disable Style/HashTransformValues
@app_config = InstallationConfig.where(name: @allowed_configs)
.pluck(:name, :serialized_value)
.map { |name, serialized_value| [name, serialized_value['value']] }
.to_h
@fb_config = InstallationConfig.where(name: @allowed_configs)
.pluck(:name, :serialized_value)
.map { |name, serialized_value| [name, serialized_value['value']] }
.to_h
# rubocop:enable Style/HashTransformValues
end
def create
params['app_config'].each do |key, value|
next unless @allowed_configs.include?(key)
i = InstallationConfig.where(name: key).first_or_create(value: value, locked: false)
i.value = value
i.save!
end
# rubocop:disable Rails/I18nLocaleTexts
redirect_to super_admin_settings_path, notice: 'App Configs updated successfully'
# rubocop:enable Rails/I18nLocaleTexts
end
private
def set_config
@config = params[:config]
end
def allowed_configs
@allowed_configs = %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET]
redirect_to super_admin_app_config_url
end
end
SuperAdmin::AppConfigsController.prepend_mod_with('SuperAdmin::AppConfigsController')
@@ -20,13 +20,4 @@ class SuperAdmin::ApplicationController < Administrate::ApplicationController
params.fetch(resource_name, {}).fetch(:direction, 'desc')
)
end
private
def invalid_action_perfomed
# rubocop:disable Rails/I18nLocaleTexts
flash[:error] = 'Invalid action performed'
# rubocop:enable Rails/I18nLocaleTexts
redirect_back(fallback_location: root_path)
end
end
@@ -1,5 +1,4 @@
class SuperAdmin::InstallationConfigsController < SuperAdmin::ApplicationController
rescue_from ActiveRecord::RecordNotUnique, :with => :invalid_action_perfomed
# Overwrite any of the RESTful controller actions to implement custom behavior
# For example, you may want to send an email after a foo is updated.
#
@@ -1,10 +0,0 @@
class SuperAdmin::SettingsController < SuperAdmin::ApplicationController
def show; end
def refresh
Internal::CheckNewVersionsJob.perform_now
# rubocop:disable Rails/I18nLocaleTexts
redirect_to super_admin_settings_path, notice: 'Instance status refreshed'
# rubocop:enable Rails/I18nLocaleTexts
end
end
+4
View File
@@ -30,7 +30,11 @@ class AccessTokenDashboard < Administrate::BaseDashboard
# SHOW_PAGE_ATTRIBUTES
# an array of attributes that will be displayed on the model's show page.
SHOW_PAGE_ATTRIBUTES = %i[
owner
id
token
created_at
updated_at
].freeze
# FORM_ATTRIBUTES
-1
View File
@@ -46,7 +46,6 @@ class AgentBotDashboard < Administrate::BaseDashboard
name
description
outgoing_url
access_token
].freeze
# FORM_ATTRIBUTES
-1
View File
@@ -32,7 +32,6 @@ class PlatformAppDashboard < Administrate::BaseDashboard
name
created_at
updated_at
access_token
].freeze
# FORM_ATTRIBUTES
+1 -3
View File
@@ -36,8 +36,7 @@ class UserDashboard < Administrate::BaseDashboard
updated_at: Field::DateTime,
pubsub_token: Field::String,
type: Field::Select.with_options(collection: [nil, 'SuperAdmin']),
accounts: CountField,
access_token: Field::HasOne
accounts: CountField
}.freeze
# COLLECTION_ATTRIBUTES
@@ -68,7 +67,6 @@ class UserDashboard < Administrate::BaseDashboard
updated_at
confirmed_at
account_users
access_token
].freeze
# FORM_ATTRIBUTES
-5
View File
@@ -18,9 +18,4 @@ class ContactDrop < BaseDrop
def last_name
@obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
end
def custom_attribute
custom_attributes = @obj.try(:custom_attributes) || {}
custom_attributes.transform_keys(&:to_s)
end
end
-5
View File
@@ -19,11 +19,6 @@ class ConversationDrop < BaseDrop
end
end
def custom_attribute
custom_attributes = @obj.try(:custom_attributes) || {}
custom_attributes.transform_keys(&:to_s)
end
private
def message_sender_name(sender)
+8 -17
View File
@@ -3,21 +3,13 @@ class ConversationFinder
DEFAULT_STATUS = 'open'.freeze
SORT_OPTIONS = {
'last_activity_at_asc' => %w[sort_on_last_activity_at asc],
'last_activity_at_desc' => %w[sort_on_last_activity_at desc],
'created_at_asc' => %w[sort_on_created_at asc],
'created_at_desc' => %w[sort_on_created_at desc],
'priority_asc' => %w[sort_on_priority asc],
'priority_desc' => %w[sort_on_priority desc],
'waiting_since_asc' => %w[sort_on_waiting_since asc],
'waiting_since_desc' => %w[sort_on_waiting_since desc],
# To be removed in v3.5.0
'latest' => %w[sort_on_last_activity_at desc],
'sort_on_created_at' => %w[sort_on_created_at asc],
'sort_on_priority' => %w[sort_on_priority desc],
'sort_on_waiting_since' => %w[sort_on_waiting_since asc]
latest: 'latest',
sort_on_created_at: 'sort_on_created_at',
last_user_message_at: 'last_user_message_at',
sort_on_priority: 'sort_on_priority',
sort_on_waiting_since: 'sort_on_waiting_since'
}.with_indifferent_access
# assumptions
# inbox_id if not given, take from all conversations, else specific to inbox
# assignee_type if not given, take 'all'
@@ -167,8 +159,7 @@ class ConversationFinder
@conversations = @conversations.includes(
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :contact_inbox
)
sort_by, sort_order = SORT_OPTIONS[params[:sort_by]] || SORT_OPTIONS['last_activity_at_desc']
@conversations.send(sort_by, sort_order).page(current_page).per(ENV.fetch('CONVERSATION_RESULTS_PER_PAGE', '25').to_i)
sort_by = SORT_OPTIONS[params[:sort_by]] || SORT_OPTIONS['latest']
@conversations.send(sort_by).page(current_page)
end
end
-2
View File
@@ -64,8 +64,6 @@ module PortalHelper
def thumbnail_bg_color(username)
colors = ['#6D95BA', '#A4C3C3', '#E19191']
return colors.sample if username.blank?
colors[username.length % colors.size]
end
end
@@ -86,12 +86,6 @@ class MessageApi extends ApiClient {
return axios.delete(`${this.url}/${conversationID}/messages/${messageId}`);
}
retry(conversationID, messageId) {
return axios.post(
`${this.url}/${conversationID}/messages/${messageId}/retry`
);
}
getPreviousMessages({ conversationId, after, before }) {
const params = { before };
if (after && Number(after) !== Number(before)) {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 16 KiB

@@ -1,8 +1,39 @@
@import 'shared/assets/fonts/inter';
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
@import '../variables';
body {
font-family: Inter, -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !important;
.superadmin-body {
background: var(--color-background);
.hero--title {
font-size: var(--font-size-mega);
font-weight: var(--font-weight-light);
margin-top: var(--space-large);
}
.update-subscription--checkbox {
display: flex;
input {
line-height: 1.5;
margin-right: var(--space-one);
margin-top: var(--space-smaller);
}
label {
font-size: var(--font-size-small);
line-height: 1.5;
margin-bottom: var(--space-normal);
}
}
}
.alert-box {
background-color: var(--r-500);
border-radius: 5px;
color: var(--color-white);
font-size: 14px;
margin-bottom: 14px;
padding: 10px;
text-align: center;
}
@@ -0,0 +1,3 @@
@import 'shared/assets/fonts/plus-jakarta';
@import '../variables';
@import '~shared/assets/stylesheets/ionicons';
@@ -126,33 +126,49 @@
@assign-team="onAssignTeamsForBulk"
/>
<div
ref="conversationList"
ref="activeConversation"
class="conversations-list flex-1"
:class="{ 'overflow-hidden': isContextMenuOpen }"
>
<virtual-list
ref="conversationVirtualList"
:data-key="'id'"
:data-sources="conversationList"
:data-component="itemComponent"
:extra-props="virtualListExtraProps"
class="w-full overflow-auto h-full"
footer-tag="div"
<div>
<conversation-card
v-for="chat in conversationList"
:key="chat.id"
:active-label="label"
:team-id="teamId"
:folders-id="foldersId"
:chat="chat"
:conversation-type="conversationType"
:show-assignee="showAssigneeInConversationCard"
:selected="isConversationSelected(chat.id)"
@select-conversation="selectConversation"
@de-select-conversation="deSelectConversation"
@assign-agent="onAssignAgent"
@assign-team="onAssignTeam"
@assign-label="onAssignLabels"
@update-conversation-status="toggleConversationStatus"
@context-menu-toggle="onContextMenuToggle"
@mark-as-unread="markAsUnread"
@assign-priority="assignPriority"
/>
</div>
<div v-if="chatListLoading" class="text-center">
<span class="spinner mt-4 mb-4" />
</div>
<woot-button
v-if="!hasCurrentPageEndReached && !chatListLoading"
variant="clear"
size="expanded"
class="load-more--button"
@click="loadMoreConversations"
>
<template #footer>
<div v-if="chatListLoading" class="text-center">
<span class="spinner mt-4 mb-4" />
</div>
<p v-if="showEndOfListMessage" class="text-center text-muted p-4">
{{ $t('CHAT_LIST.EOF') }}
</p>
<intersection-observer
v-if="!showEndOfListMessage && !chatListLoading"
:options="infiniteLoaderOptions"
@observed="loadMoreConversations"
/>
</template>
</virtual-list>
{{ $t('CHAT_LIST.LOAD_MORE_CONVERSATIONS') }}
</woot-button>
<p v-if="showEndOfListMessage" class="text-center text-muted p-4">
{{ $t('CHAT_LIST.EOF') }}
</p>
</div>
<woot-modal
:show.sync="showAdvancedFilters"
@@ -175,12 +191,11 @@
<script>
import { mapGetters } from 'vuex';
import VirtualList from 'vue-virtual-scroll-list';
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter.vue';
import ConversationBasicFilter from './widgets/conversation/ConversationBasicFilter.vue';
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
import ConversationItem from './ConversationItem.vue';
import ConversationCard from './widgets/conversation/ConversationCard.vue';
import timeMixin from '../mixins/time';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import conversationMixin from '../mixins/conversations';
@@ -207,20 +222,16 @@ import {
isOnUnattendedView,
} from '../store/modules/conversations/helpers/actionHelpers';
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
import IntersectionObserver from './IntersectionObserver.vue';
export default {
components: {
AddCustomViews,
ChatTypeTabs,
// eslint-disable-next-line vue/no-unused-components
ConversationItem,
ConversationCard,
ConversationAdvancedFilter,
DeleteCustomViews,
ConversationBulkActions,
ConversationBasicFilter,
IntersectionObserver,
VirtualList,
},
mixins: [
timeMixin,
@@ -230,20 +241,6 @@ export default {
filterMixin,
uiSettingsMixin,
],
provide() {
return {
// Actions to be performed on virtual list item and context menu.
selectConversation: this.selectConversation,
deSelectConversation: this.deSelectConversation,
assignAgent: this.onAssignAgent,
assignTeam: this.onAssignTeam,
assignLabels: this.onAssignLabels,
updateConversationStatus: this.toggleConversationStatus,
toggleContextMenu: this.onContextMenuToggle,
markAsUnread: this.markAsUnread,
assignPriority: this.assignPriority,
};
},
props: {
conversationInbox: {
type: [String, Number],
@@ -278,7 +275,7 @@ export default {
return {
activeAssigneeTab: wootConstants.ASSIGNEE_TYPE.ME,
activeStatus: wootConstants.STATUS_TYPE.OPEN,
activeSortBy: wootConstants.SORT_BY_TYPE.LAST_ACTIVITY_AT_DESC,
activeSortBy: wootConstants.SORT_BY_TYPE.LATEST,
showAdvancedFilters: false,
advancedFilterTypes: advancedFilterTypes.map(filter => ({
...filter,
@@ -294,21 +291,6 @@ export default {
selectedInboxes: [],
isContextMenuOpen: false,
appliedFilter: [],
infiniteLoaderOptions: {
root: this.$refs.conversationList,
rootMargin: '100px 0px 100px 0px',
},
itemComponent: ConversationItem,
// virtualListExtraProps is to pass the props to the conversationItem component.
virtualListExtraProps: {
label: this.label,
teamId: this.teamId,
foldersId: this.foldersId,
conversationType: this.conversationType,
showAssignee: false,
isConversationSelected: this.isConversationSelected,
},
};
},
computed: {
@@ -527,22 +509,16 @@ export default {
},
label() {
this.resetAndFetchData();
this.updateVirtualListProps('label', this.label);
},
conversationType() {
this.resetAndFetchData();
this.updateVirtualListProps('conversationType', this.conversationType);
},
activeFolder() {
this.resetAndFetchData();
this.updateVirtualListProps('foldersId', this.foldersId);
},
chatLists() {
this.chatsOnView = this.conversationList;
},
showAssigneeInConversationCard(newVal) {
this.updateVirtualListProps('showAssignee', newVal);
},
},
mounted() {
this.setFiltersFromUISettings();
@@ -559,12 +535,6 @@ export default {
});
},
methods: {
updateVirtualListProps(key, value) {
this.virtualListExtraProps = {
...this.virtualListExtraProps,
[key]: value,
};
},
onApplyFilter(payload) {
this.resetBulkActions();
this.foldersQuery = filterQueryGenerator(payload);
@@ -585,10 +555,7 @@ export default {
const { conversations_filter_by: filterBy = {} } = this.uiSettings;
const { status, order_by: orderBy } = filterBy;
this.activeStatus = status || wootConstants.STATUS_TYPE.OPEN;
this.activeSortBy =
Object.keys(wootConstants.SORT_BY_TYPE).find(
sortField => sortField === orderBy
) || wootConstants.SORT_BY_TYPE.LAST_ACTIVITY_AT_DESC;
this.activeSortBy = orderBy || wootConstants.SORT_BY_TYPE.LATEST;
},
onClickOpenAddFoldersModal() {
this.showAddFoldersModal = true;
@@ -668,10 +635,10 @@ export default {
);
},
getKeyboardListenerParams() {
const allConversations = this.$refs.conversationList.querySelectorAll(
const allConversations = this.$refs.activeConversation.querySelectorAll(
'div.conversations-list div.conversation'
);
const activeConversation = this.$refs.conversationList.querySelector(
const activeConversation = this.$refs.activeConversation.querySelector(
'div.conversations-list div.conversation.active'
);
const activeConversationIndex = [...allConversations].indexOf(
@@ -727,12 +694,9 @@ export default {
fetchConversations() {
this.$store
.dispatch('fetchAllConversations', this.conversationFilters)
.then(this.emitConversationLoaded);
.then(() => this.$emit('conversation-load'));
},
loadMoreConversations() {
if (this.hasCurrentPageEndReached || this.chatListLoading) {
return;
}
if (!this.hasAppliedFiltersOrActiveFolders) {
this.fetchConversations();
}
@@ -751,7 +715,7 @@ export default {
queryData: filterQueryGenerator(payload),
page,
})
.then(this.emitConversationLoaded);
.then(() => this.$emit('conversation-load'));
this.showAdvancedFilters = false;
},
fetchSavedFilteredConversations(payload) {
@@ -761,7 +725,7 @@ export default {
queryData: payload,
page,
})
.then(this.emitConversationLoaded);
.then(() => this.$emit('conversation-load'));
},
updateAssigneeTab(selectedTab) {
if (this.activeAssigneeTab !== selectedTab) {
@@ -773,20 +737,6 @@ export default {
}
}
},
emitConversationLoaded() {
this.$emit('conversation-load');
this.$nextTick(() => {
// Addressing a known issue in the virtual list library where dynamically added items
// might not render correctly. This workaround involves a slight manual adjustment
// to the scroll position, triggering the list to refresh its rendering.
const virtualList = this.$refs.conversationVirtualList;
const scrollToOffset = virtualList?.scrollToOffset;
const currentOffset = virtualList?.getOffset() || 0;
if (scrollToOffset) {
scrollToOffset(currentOffset + 1);
}
});
},
resetBulkActions() {
this.selectedConversations = [];
this.selectedInboxes = [];
@@ -1,72 +0,0 @@
<template>
<conversation-card
:key="source.id"
:active-label="label"
:team-id="teamId"
:folders-id="foldersId"
:chat="source"
:conversation-type="conversationType"
:selected="isConversationSelected(source.id)"
:show-assignee="showAssignee"
:enable-context-menu="true"
@select-conversation="selectConversation"
@de-select-conversation="deSelectConversation"
@assign-agent="assignAgent"
@assign-team="assignTeam"
@assign-label="assignLabels"
@update-conversation-status="updateConversationStatus"
@context-menu-toggle="toggleContextMenu"
@mark-as-unread="markAsUnread"
@assign-priority="assignPriority"
/>
</template>
<script>
import ConversationCard from './widgets/conversation/ConversationCard.vue';
export default {
components: {
ConversationCard,
},
inject: [
'selectConversation',
'deSelectConversation',
'assignAgent',
'assignTeam',
'assignLabels',
'updateConversationStatus',
'toggleContextMenu',
'markAsUnread',
'assignPriority',
],
props: {
source: {
type: Object,
required: true,
},
teamId: {
type: [String, Number],
default: 0,
},
label: {
type: String,
default: '',
},
conversationType: {
type: String,
default: '',
},
foldersId: {
type: [String, Number],
default: 0,
},
isConversationSelected: {
type: Function,
default: () => {},
},
showAssignee: {
type: Boolean,
default: false,
},
},
};
</script>
@@ -1,34 +0,0 @@
<template>
<div ref="observedElement" class="h-6 w-full" />
</template>
<script>
export default {
props: {
options: {
type: Object,
default: () => ({ root: document, rootMargin: '100px 0 100px 0)' }),
},
},
mounted() {
this.intersectionObserver = null;
this.registerInfiniteLoader();
},
beforeDestroy() {
this.unobserveInfiniteLoadObserver();
},
methods: {
registerInfiniteLoader() {
this.intersectionObserver = new IntersectionObserver(entries => {
if (entries && entries[0].isIntersecting) {
this.$emit('observed');
}
}, this.options);
this.intersectionObserver.observe(this.$refs.observedElement);
},
unobserveInfiniteLoadObserver() {
this.intersectionObserver.unobserve(this.$refs.observedElement);
},
},
};
</script>
@@ -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>
@@ -144,7 +144,6 @@ import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import {
ALLOWED_FILE_TYPES,
ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP,
ALLOWED_FILE_TYPES_FOR_LINE,
} from 'shared/constants/messages';
import VideoCallButton from '../VideoCallButton.vue';
import AIAssistanceButton from '../AIAssistanceButton.vue';
@@ -271,9 +270,6 @@ export default {
return this.showFileUpload || this.isNote;
},
showAudioRecorderButton() {
if (this.isALineChannel) {
return false;
}
// Disable audio recorder for safari browser as recording is not supported
const isSafari = /^((?!chrome|android|crios|fxios).)*safari/i.test(
navigator.userAgent
@@ -295,9 +291,6 @@ export default {
if (this.isATwilioWhatsAppChannel) {
return ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP;
}
if (this.isALineChannel) {
return ALLOWED_FILE_TYPES_FOR_LINE;
}
return ALLOWED_FILE_TYPES;
},
enableDragAndDrop() {
@@ -34,7 +34,7 @@
type="sort"
:selected-value="sortFilter"
:items="chatSortItems"
path-prefix="CHAT_LIST.SORT_ORDER_ITEMS"
path-prefix="CHAT_LIST.CHAT_SORT_FILTER_ITEMS"
@onChangeFilter="onChangeFilter"
/>
</div>
@@ -58,7 +58,7 @@ export default {
return {
showActionsDropdown: false,
chatStatusItems: this.$t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS'),
chatSortItems: this.$t('CHAT_LIST.SORT_ORDER_ITEMS'),
chatSortItems: this.$t('CHAT_LIST.CHAT_SORT_FILTER_ITEMS'),
};
},
computed: {
@@ -70,9 +70,7 @@ export default {
return this.chatStatusFilter || wootConstants.STATUS_TYPE.OPEN;
},
sortFilter() {
return (
this.chatSortFilter || wootConstants.SORT_BY_TYPE.LAST_ACTIVITY_AT_DESC
);
return this.chatSortFilter || wootConstants.SORT_BY_TYPE.LATEST;
},
},
methods: {
@@ -31,7 +31,7 @@
size="40px"
/>
<div
class="px-0 py-3 border-b group-hover:border-transparent border-slate-50 dark:border-slate-800/75 columns"
class="px-0 py-3 border-b group-last:border-transparent group-hover:border-transparent border-slate-50 dark:border-slate-800/75 columns"
>
<div class="flex justify-between">
<inbox-name v-if="showInboxName" :inbox="inbox" />
@@ -175,10 +175,6 @@ export default {
type: Boolean,
default: false,
},
enableContextMenu: {
type: Boolean,
default: false,
},
},
data() {
return {
@@ -293,7 +289,6 @@ export default {
this.$emit(action, this.chat.id, this.inbox.id);
},
openContextMenu(e) {
if (!this.enableContextMenu) return;
e.preventDefault();
this.$emit('context-menu-toggle', true);
this.contextMenu.x = e.pageX || e.clientX;
@@ -1,7 +1,7 @@
<template>
<li v-if="shouldRenderMessage" :id="`message${data.id}`" :class="alignBubble">
<div :class="wrapClass">
<div v-if="isFailed && !hasOneDayPassed" class="message-failed--alert">
<div v-if="isFailed" class="message-failed--alert">
<woot-button
v-tooltip.top-end="$t('CONVERSATION.TRY_AGAIN')"
size="tiny"
@@ -148,7 +148,6 @@ import { BUS_EVENTS } from 'shared/constants/busEvents';
import { ACCOUNT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import { getDayDifferenceFromNow } from 'shared/helpers/DateHelper';
export default {
components: {
@@ -210,10 +209,6 @@ export default {
created_at: this.data.created_at || '',
}));
},
hasOneDayPassed() {
// Disable retry button if the message is failed and the message is older than 24 hours
return getDayDifferenceFromNow(new Date(), this.data?.created_at) >= 1;
},
shouldRenderMessage() {
return (
this.hasAttachments ||
@@ -20,15 +20,7 @@
icon="info"
/>
</template>
<span v-if="message.content && isMessageSticker">
<fluent-icon
size="16"
class="-mt-0.5 align-middle inline-block text-slate-600 dark:text-slate-300"
icon="image"
/>
{{ $t('CHAT_LIST.ATTACHMENTS.image.CONTENT') }}
</span>
<span v-else-if="message.content">
<span v-if="message.content">
{{ parsedLastMessage }}
</span>
<span v-else-if="message.attachments">
@@ -96,9 +88,6 @@ export default {
attachmentMessageContent() {
return `CHAT_LIST.ATTACHMENTS.${this.lastMessageFileType}.CONTENT`;
},
isMessageSticker() {
return this.message && this.message.content_type === 'sticker';
},
},
};
</script>
@@ -284,12 +284,10 @@ export default {
return this.currentChat.unread_count || 0;
},
inboxSupportsReplyTo() {
const incoming = this.inboxHasFeature(INBOX_FEATURES.REPLY_TO);
const outgoing =
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO_OUTGOING) &&
!this.is360DialogWhatsAppChannel;
return { incoming, outgoing };
return {
incoming: this.inboxHasFeature(INBOX_FEATURES.REPLY_TO),
outgoing: this.inboxHasFeature(INBOX_FEATURES.REPLY_TO_OUTGOING),
};
},
},
@@ -275,8 +275,7 @@ export default {
return (
this.inReplyTo?.id &&
!this.isPrivate &&
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO) &&
!this.is360DialogWhatsAppChannel
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO)
);
},
showRichContentEditor() {
@@ -393,8 +392,7 @@ export default {
this.isAPIInbox ||
this.isAnEmailChannel ||
this.isASmsInbox ||
this.isATelegramChannel ||
this.isALineChannel
this.isATelegramChannel
);
},
replyButtonLabel() {
@@ -497,11 +495,7 @@ export default {
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
},
audioRecordFormat() {
if (
this.isAWhatsAppChannel ||
this.isAPIInbox ||
this.isATelegramChannel
) {
if (this.isAWhatsAppChannel || this.isAPIInbox) {
return AUDIO_FORMATS.OGG;
}
return AUDIO_FORMATS.WAV;
@@ -46,25 +46,9 @@
</span>
</div>
<div
class="items-center flex gap-2 justify-end min-w-[8rem] sm:min-w-[15rem]"
class="items-center flex gap-2 justify-end min-w-[15rem]"
@click.stop
>
<woot-button
v-if="isImage"
size="large"
color-scheme="secondary"
variant="clear"
icon="arrow-rotate-counter-clockwise"
@click="onRotate('counter-clockwise')"
/>
<woot-button
v-if="isImage"
size="large"
color-scheme="secondary"
variant="clear"
icon="arrow-rotate-clockwise"
@click="onRotate('clockwise')"
/>
<woot-button
size="large"
color-scheme="secondary"
@@ -105,7 +89,6 @@
:key="activeAttachment.message_id"
:src="activeAttachment.data_url"
class="modal-image skip-context-menu my-0 mx-auto"
:style="imageRotationStyle"
@click.stop
/>
<video
@@ -203,7 +186,6 @@ export default {
this.allAttachments.findIndex(
attachment => attachment.message_id === this.attachment.message_id
) || 0,
activeImageRotation: 0,
};
},
computed: {
@@ -250,11 +232,6 @@ export default {
const fileName = dataUrl?.split('/').pop();
return fileName || '';
},
imageRotationStyle() {
return {
transform: `rotate(${this.activeImageRotation}deg)`,
};
},
},
mounted() {
this.setImageAndVideoSrc(this.attachment);
@@ -269,7 +246,6 @@ export default {
}
this.activeImageIndex = index;
this.setImageAndVideoSrc(attachment);
this.activeImageRotation = 0;
},
setImageAndVideoSrc(attachment) {
const { file_type: type } = attachment;
@@ -304,20 +280,6 @@ export default {
link.download = `attachment.${type}`;
link.click();
},
onRotate(type) {
if (!this.isImage) {
return;
}
const rotation = type === 'clockwise' ? 90 : -90;
// Reset rotation if it is 360
if (Math.abs(this.activeImageRotation) === 360) {
this.activeImageRotation = rotation;
} else {
this.activeImageRotation += rotation;
}
},
},
};
</script>
@@ -13,14 +13,10 @@ export default {
ALL: 'all',
},
SORT_BY_TYPE: {
LAST_ACTIVITY_AT_ASC: 'last_activity_at_asc',
LAST_ACTIVITY_AT_DESC: 'last_activity_at_desc',
CREATED_AT_ASC: 'created_at_asc',
CREATED_AT_DESC: 'created_at_desc',
PRIORITY_ASC: 'priority_asc',
PRIORITY_DESC: 'priority_desc',
WAITING_SINCE_ASC: 'waiting_since_asc',
WAITING_SINCE_DESC: 'waiting_since_desc',
LATEST: 'latest',
CREATED_AT: 'sort_on_created_at',
PRIORITY: 'sort_on_priority',
WATIING_SINCE: 'waiting_since',
},
ARTICLE_STATUS_TYPES: {
DRAFT: 0,
@@ -48,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';
@@ -23,7 +23,6 @@ class ActionCableConnector extends BaseActionCableConnector {
'contact.updated': this.onContactUpdate,
'conversation.mentioned': this.onConversationMentioned,
'notification.created': this.onNotificationCreated,
'notification.deleted': this.onNotificationDeleted,
'first.reply.created': this.onFirstReplyCreated,
'conversation.read': this.onConversationRead,
'conversation.updated': this.onConversationUpdated,
@@ -196,10 +195,6 @@ class ActionCableConnector extends BaseActionCableConnector {
this.app.$store.dispatch('notifications/addNotification', data);
};
onNotificationDeleted = data => {
this.app.$store.dispatch('notifications/deleteNotification', data);
};
// eslint-disable-next-line class-methods-use-this
onFirstReplyCreated = () => {
bus.$emit('fetch_overview_reports');
@@ -51,30 +51,18 @@
"ACTIVE": "Last activity"
}
},
"SORT_ORDER_ITEMS": {
"last_activity_at_asc": {
"TEXT": "Last activity: Oldest first"
"CHAT_SORT_FILTER_ITEMS": {
"latest": {
"TEXT": "Last activity"
},
"last_activity_at_desc": {
"TEXT": "Last activity: Newest first"
"sort_on_created_at": {
"TEXT": "Created at"
},
"created_at_desc": {
"TEXT": "Created at: Newest first"
"sort_on_priority": {
"TEXT": "Priority"
},
"created_at_asc": {
"TEXT": "Created at: Oldest first"
},
"priority_desc": {
"TEXT": "Priority: Highest first"
},
"priority_asc": {
"TEXT": "Priority: Lowest first"
},
"waiting_since_asc": {
"TEXT": "Pending Response: Longest first"
},
"waiting_since_desc": {
"TEXT": "Pending Response: Shortest first"
"sort_on_waiting_since": {
"TEXT": "Pending Response"
}
},
"ATTACHMENTS": {
@@ -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"
}
}
}
}
@@ -8,6 +8,10 @@ import helpcenterRoutes from './helpcenter/helpcenter.routes';
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,
},
],
};
@@ -91,7 +91,6 @@
@search-change="handleSearchChange"
@close="onBlur"
@tag="addTagValue"
@remove="removeTag"
/>
</label>
</div>
@@ -204,9 +203,6 @@ export default {
this.metaTags.push(...this.formattedTags({ tags: [...new Set(tags)] }));
this.saveArticle();
},
removeTag() {
this.saveArticle();
},
handleSearchChange(value) {
this.tagInputValue = value;
},
@@ -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();
};
@@ -11,19 +11,6 @@ import {
} from './helpers/actionHelpers';
import messageReadActions from './actions/messageReadActions';
import messageTranslateActions from './actions/messageTranslateActions';
export const hasMessageFailedWithExternalError = pendingMessage => {
// This helper is used to check if the message has failed with an external error.
// We have two cases
// 1. Messages that fail from the UI itself (due to large attachments or a failed network):
// In this case, the message will have a status of failed but no external error. So we need to create that message again
// 2. Messages sent from Chatwoot but failed to deliver to the customer for some reason (user blocking or client system down):
// In this case, the message will have a status of failed and an external error. So we need to retry that message
const { content_attributes: contentAttributes, status } = pendingMessage;
const externalError = contentAttributes?.external_error ?? '';
return status === MESSAGE_STATUS.FAILED && externalError !== '';
};
// actions
const actions = {
getConversation: async ({ commit }, conversationId) => {
@@ -255,15 +242,12 @@ const actions = {
},
sendMessageWithData: async ({ commit }, pendingMessage) => {
const { conversation_id: conversationId, id } = pendingMessage;
try {
commit(types.ADD_MESSAGE, {
...pendingMessage,
status: MESSAGE_STATUS.PROGRESS,
});
const response = hasMessageFailedWithExternalError(pendingMessage)
? await MessageApi.retry(conversationId, id)
: await MessageApi.create(pendingMessage);
const response = await MessageApi.create(pendingMessage);
commit(types.ADD_MESSAGE, {
...response.data,
status: MESSAGE_STATUS.SENT,
@@ -1,5 +1,8 @@
import { MESSAGE_TYPE } from 'shared/constants/messages';
import { applyPageFilters, sortComparator } from './helpers';
import {
MESSAGE_TYPE,
CONVERSATION_PRIORITY_ORDER,
} from 'shared/constants/messages';
import { applyPageFilters } from './helpers';
export const getSelectedChatConversation = ({
allConversations,
@@ -7,9 +10,36 @@ export const getSelectedChatConversation = ({
}) =>
allConversations.filter(conversation => conversation.id === selectedChatId);
const sortComparator = {
latest: (a, b) => b.last_activity_at - a.last_activity_at,
sort_on_created_at: (a, b) => a.created_at - b.created_at,
sort_on_priority: (a, b) => {
return (
CONVERSATION_PRIORITY_ORDER[a.priority] -
CONVERSATION_PRIORITY_ORDER[b.priority]
);
},
sort_on_waiting_since: (a, b) => {
if (!a.waiting_since && !b.waiting_since) {
return a.created_at - b.created_at;
}
if (!a.waiting_since) {
return 1;
}
if (!b.waiting_since) {
return -1;
}
return a.waiting_since - b.waiting_since;
},
};
// getters
const getters = {
getAllConversations: ({ allConversations, chatSortFilter: sortKey }) => {
return allConversations.sort((a, b) => sortComparator(a, b, sortKey));
getAllConversations: ({ allConversations, chatSortFilter }) => {
return allConversations.sort(sortComparator[chatSortFilter]);
},
getSelectedChat: ({ selectedChatId, allConversations }) => {
const selectedChat = allConversations.find(
@@ -1,5 +1,3 @@
import { CONVERSATION_PRIORITY_ORDER } from 'shared/constants/messages';
export const findPendingMessageIndex = (chat, message) => {
const { echo_id: tempMessageId } = message;
return chat.messages.findIndex(
@@ -61,53 +59,3 @@ export const applyPageFilters = (conversation, filters) => {
return shouldFilter;
};
const SORT_OPTIONS = {
last_activity_at_asc: ['sortOnLastActivityAt', 'asc'],
last_activity_at_desc: ['sortOnLastActivityAt', 'desc'],
created_at_asc: ['sortOnCreatedAt', 'asc'],
created_at_desc: ['sortOnCreatedAt', 'desc'],
priority_asc: ['sortOnPriority', 'asc'],
priority_desc: ['sortOnPriority', 'desc'],
waiting_since_asc: ['sortOnWaitingSince', 'asc'],
waiting_since_desc: ['sortOnWaitingSince', 'desc'],
};
const sortAscending = (valueA, valueB) => valueA - valueB;
const sortDescending = (valueA, valueB) => valueB - valueA;
const getSortOrderFunction = sortOrder =>
sortOrder === 'asc' ? sortAscending : sortDescending;
const sortConfig = {
sortOnLastActivityAt: (a, b, sortDirection) =>
getSortOrderFunction(sortDirection)(a.last_activity_at, b.last_activity_at),
sortOnCreatedAt: (a, b, sortDirection) =>
getSortOrderFunction(sortDirection)(a.created_at, b.created_at),
sortOnPriority: (a, b, sortDirection) => {
const DEFAULT_FOR_NULL = sortDirection === 'asc' ? 5 : 0;
const p1 = CONVERSATION_PRIORITY_ORDER[a.priority] || DEFAULT_FOR_NULL;
const p2 = CONVERSATION_PRIORITY_ORDER[b.priority] || DEFAULT_FOR_NULL;
return getSortOrderFunction(sortDirection)(p1, p2);
},
sortOnWaitingSince: (a, b, sortDirection) => {
const sortFunc = getSortOrderFunction(sortDirection);
if (!a.waiting_since || !b.waiting_since) {
if (!a.waiting_since && !b.waiting_since) {
return sortFunc(a.created_at, b.created_at);
}
return sortFunc(a.waiting_since ? 0 : 1, b.waiting_since ? 0 : 1);
}
return sortFunc(a.waiting_since, b.waiting_since);
},
};
export const sortComparator = (a, b, sortKey) => {
const [sortMethod, sortDirection] = SORT_OPTIONS[sortKey] || [];
return sortConfig[sortMethod](a, b, sortDirection);
};
@@ -56,7 +56,4 @@ export const actions = {
addNotification({ commit }, data) {
commit(types.ADD_NOTIFICATION, data);
},
deleteNotification({ commit }, data) {
commit(types.DELETE_NOTIFICATION, data);
},
};
@@ -55,10 +55,4 @@ export const mutations = {
Vue.set($state.meta, 'unreadCount', unreadCount);
Vue.set($state.meta, 'count', count);
},
[types.DELETE_NOTIFICATION]($state, data) {
const { notification, unread_count: unreadCount, count } = data;
Vue.delete($state.records, notification.id);
Vue.set($state.meta, 'unreadCount', unreadCount);
Vue.set($state.meta, 'count', count);
},
};
@@ -1,7 +1,5 @@
import axios from 'axios';
import actions, {
hasMessageFailedWithExternalError,
} from '../../conversations/actions';
import actions from '../../conversations/actions';
import types from '../../../mutation-types';
const dataToSend = {
payload: [
@@ -20,41 +18,6 @@ const dispatch = jest.fn();
global.axios = axios;
jest.mock('axios');
describe('#hasMessageFailedWithExternalError', () => {
it('returns false if message is sent', () => {
const pendingMessage = {
status: 'sent',
content_attributes: {},
};
expect(hasMessageFailedWithExternalError(pendingMessage)).toBe(false);
});
it('returns false if status is not failed', () => {
const pendingMessage = {
status: 'progress',
content_attributes: {},
};
expect(hasMessageFailedWithExternalError(pendingMessage)).toBe(false);
});
it('returns false if status is failed but no external error', () => {
const pendingMessage = {
status: 'failed',
content_attributes: {},
};
expect(hasMessageFailedWithExternalError(pendingMessage)).toBe(false);
});
it('returns true if status is failed and has external error', () => {
const pendingMessage = {
status: 'failed',
content_attributes: {
external_error: 'error',
},
};
expect(hasMessageFailedWithExternalError(pendingMessage)).toBe(true);
});
});
describe('#actions', () => {
describe('#getConversation', () => {
it('sends correct actions if API is success', async () => {
@@ -47,49 +47,6 @@ describe('#getters', () => {
},
]);
});
it('order conversations based on last activity with ascending order', () => {
const state = {
allConversations: [
{
id: 1,
messages: [
{
content: 'test1',
},
],
created_at: 2466424490,
last_activity_at: 2466424490,
},
{
id: 2,
messages: [{ content: 'test2' }],
created_at: 1466424480,
last_activity_at: 1466424480,
},
],
chatSortFilter: 'latest_last',
};
expect(getters.getAllConversations(state)).toEqual([
{
id: 2,
messages: [{ content: 'test2' }],
created_at: 1466424480,
last_activity_at: 1466424480,
},
{
id: 1,
messages: [
{
content: 'test1',
},
],
created_at: 2466424490,
last_activity_at: 2466424490,
},
]);
});
it('order conversations based on created at', () => {
const state = {
allConversations: [
@@ -110,7 +67,7 @@ describe('#getters', () => {
last_activity_at: 1466424480,
},
],
chatSortFilter: 'created_at_last',
chatSortFilter: 'sort_on_created_at',
};
expect(getters.getAllConversations(state)).toEqual([
@@ -132,50 +89,6 @@ describe('#getters', () => {
},
]);
});
it('order conversations based on created at with descending order', () => {
const state = {
allConversations: [
{
id: 1,
messages: [
{
content: 'test1',
},
],
created_at: 1683645801, // Tuesday, 9 May 2023
last_activity_at: 2466424490,
},
{
id: 2,
messages: [{ content: 'test2' }],
created_at: 1652109801, // Monday, 9 May 2022
last_activity_at: 1466424480,
},
],
chatSortFilter: 'created_at_first',
};
expect(getters.getAllConversations(state)).toEqual([
{
id: 1,
messages: [
{
content: 'test1',
},
],
created_at: 1683645801,
last_activity_at: 2466424490,
},
{
id: 2,
messages: [{ content: 'test2' }],
created_at: 1652109801,
last_activity_at: 1466424480,
},
]);
});
it('order conversations based on default order', () => {
const state = {
allConversations: [
@@ -246,7 +159,7 @@ describe('#getters', () => {
last_activity_at: 1466421280,
},
],
chatSortFilter: 'priority_first',
chatSortFilter: 'sort_on_priority',
};
expect(getters.getAllConversations(state)).toEqual([
@@ -277,68 +190,6 @@ describe('#getters', () => {
},
]);
});
it('order conversations based on with descending order', () => {
const state = {
allConversations: [
{
id: 1,
messages: [
{
content: 'test1',
},
],
priority: 'low',
created_at: 1683645801,
last_activity_at: 2466424490,
},
{
id: 2,
messages: [{ content: 'test2' }],
priority: 'urgent',
created_at: 1652109801,
last_activity_at: 1466424480,
},
{
id: 3,
messages: [{ content: 'test3' }],
priority: 'medium',
created_at: 1652109801,
last_activity_at: 1466421280,
},
],
chatSortFilter: 'priority_last',
};
expect(getters.getAllConversations(state)).toEqual([
{
id: 1,
messages: [
{
content: 'test1',
},
],
priority: 'low',
created_at: 1683645801,
last_activity_at: 2466424490,
},
{
id: 3,
messages: [{ content: 'test3' }],
priority: 'medium',
created_at: 1652109801,
last_activity_at: 1466421280,
},
{
id: 2,
messages: [{ content: 'test2' }],
priority: 'urgent',
created_at: 1652109801,
last_activity_at: 1466424480,
},
]);
});
it('order conversations based on waiting_since', () => {
const state = {
allConversations: [
@@ -363,7 +214,7 @@ describe('#getters', () => {
waiting_since: 1683645800,
},
],
chatSortFilter: 'waiting_since_last',
chatSortFilter: 'sort_on_waiting_since',
};
expect(getters.getAllConversations(state)).toEqual([
@@ -98,13 +98,4 @@ describe('#actions', () => {
]);
});
});
describe('#deleteNotification', () => {
it('sends correct actions', async () => {
await actions.deleteNotification({ commit }, { data: 1 });
expect(commit.mock.calls).toEqual([
[types.DELETE_NOTIFICATION, { data: 1 }],
]);
});
});
});
@@ -118,27 +118,4 @@ describe('#mutations', () => {
expect(state.meta.count).toEqual(232);
});
});
describe('#DELETE_NOTIFICATION', () => {
it('delete notification', () => {
const state = {
meta: { unreadCount: 4, count: 231 },
records: {
1: { id: 1, primary_actor_id: 1 },
2: { id: 2, primary_actor_id: 2 },
},
};
const data = {
notification: { id: 1, primary_actor_id: 1 },
unread_count: 5,
count: 232,
};
mutations[types.DELETE_NOTIFICATION](state, data);
expect(state.records).toEqual({
2: { id: 2, primary_actor_id: 2 },
});
expect(state.meta.unreadCount).toEqual(5);
expect(state.meta.count).toEqual(232);
});
});
});
@@ -133,7 +133,6 @@ export default {
SET_NOTIFICATIONS_UI_FLAG: 'SET_NOTIFICATIONS_UI_FLAG',
UPDATE_NOTIFICATION: 'UPDATE_NOTIFICATION',
ADD_NOTIFICATION: 'ADD_NOTIFICATION',
DELETE_NOTIFICATION: 'DELETE_NOTIFICATION',
UPDATE_ALL_NOTIFICATIONS: 'UPDATE_ALL_NOTIFICATIONS',
SET_NOTIFICATIONS_ITEM: 'SET_NOTIFICATIONS_ITEM',
SET_NOTIFICATIONS: 'SET_NOTIFICATIONS',
+1
View File
@@ -1 +1,2 @@
import '../dashboard/assets/scss/app.scss';
import '../dashboard/assets/scss/super_admin/index.scss';
+1
View File
@@ -1 +1,2 @@
import '../dashboard/assets/scss/super_admin/pages.scss';
import 'chart.js';
@@ -18,8 +18,6 @@
"arrow-trending-lines-outline": "M16.749 2h4.554l.1.014.099.028.06.026c.08.034.153.085.219.15l.04.044.044.057.054.09.039.09.019.064.014.064.009.095v4.532a.75.75 0 0 1-1.493.102l-.007-.102V4.559l-6.44 6.44a.75.75 0 0 1-.976.073L13 11 9.97 8.09l-5.69 5.689a.75.75 0 0 1-1.133-.977l.073-.084 6.22-6.22a.75.75 0 0 1 .976-.072l.084.072 3.03 2.91L19.438 3.5h-2.69a.75.75 0 0 1-.742-.648l-.007-.102a.75.75 0 0 1 .648-.743L16.75 2ZM3.75 17a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5a.75.75 0 0 1 .75-.75Zm5.75-3.25a.75.75 0 0 0-1.5 0v7.5a.75.75 0 0 0 1.5 0v-7.5ZM13.75 15a.75.75 0 0 1 .75.75v5.5a.75.75 0 0 1-1.5 0v-5.5a.75.75 0 0 1 .75-.75Zm5.75-4.25a.75.75 0 0 0-1.5 0v10.5a.75.75 0 0 0 1.5 0v-10.5Z",
"arrow-up-outline": "M4.21 10.733a.75.75 0 0 0 1.086 1.034l5.954-6.251V20.25a.75.75 0 0 0 1.5 0V5.516l5.955 6.251a.75.75 0 0 0 1.086-1.034l-7.067-7.42a.995.995 0 0 0-.58-.3.754.754 0 0 0-.29.001.995.995 0 0 0-.578.3L4.21 10.733Z",
"arrow-up-right-outline": "M12.748 3.001h7.554l.1.014.099.028.06.026a.72.72 0 0 1 .219.15l.04.044.061.082.037.065.04.09.018.064.014.064.01.093v7.534a.75.75 0 0 1-1.493.102l-.007-.102V5.559L4.28 20.784a.75.75 0 0 1-.977.073l-.084-.073a.75.75 0 0 1-.073-.976l.073-.084L18.439 4.5l-5.69.001a.75.75 0 0 1-.744-.648l-.007-.102a.75.75 0 0 1 .648-.743L12.748 3Z",
"arrow-rotate-clockwise-outline": "M12 3a9 9 0 0 1 9 9 9.005 9.005 0 0 1-4.873 8.001L18 20a1 1 0 0 1 .117 1.993L18 22h-4a1 1 0 0 1-.993-.883L13 21v-4a1 1 0 0 1 1.993-.117L15 17l.001 1.327A7.006 7.006 0 0 0 19 12a7 7 0 1 0-14 0 1 1 0 1 1-2 0 9 9 0 0 1 9-9Zm0 6a3 3 0 1 1 0 6 3 3 0 0 1 0-6Zm0 2a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z",
"arrow-rotate-counter-clockwise-outline": "M12 3a9 9 0 0 0-4.977 16.5H5.25a.75.75 0 0 0-.102 1.493L5.25 21h4a.75.75 0 0 0 .743-.648L10 20.25v-4a.75.75 0 0 0-1.493-.102l-.007.102-.001 2.385A7.501 7.501 0 0 1 12 4.5a7.5 7.5 0 0 1 7.5 7.5.75.75 0 0 0 1.5 0 9 9 0 0 0-9-9Zm0 6.25a2.75 2.75 0 1 0 0 5.5 2.75 2.75 0 0 0 0-5.5Zm0 1.5a1.25 1.25 0 1 1 0 2.5 1.25 1.25 0 0 1 0-2.5Z",
"attach-outline": "M11.772 3.743a6 6 0 0 1 8.66 8.302l-.19.197-8.8 8.798-.036.03a3.723 3.723 0 0 1-5.489-4.973.764.764 0 0 1 .085-.13l.054-.06.086-.088.142-.148.002.003 7.436-7.454a.75.75 0 0 1 .977-.074l.084.073a.75.75 0 0 1 .074.976l-.073.084-7.594 7.613a2.23 2.23 0 0 0 3.174 3.106l8.832-8.83A4.502 4.502 0 0 0 13 4.644l-.168.16-.013.014-9.536 9.536a.75.75 0 0 1-1.133-.977l.072-.084 9.549-9.55h.002Z",
"autocorrect-outline": "M13.461 4.934c.293.184.548.42.752.698l.117.171 2.945 4.696H21.5a.75.75 0 0 1 .743.649l.007.102a.75.75 0 0 1-.75.75l-3.284-.001.006.009-.009-.01a4.75 4.75 0 1 1-3.463-1.5h.756L13.059 6.6a1.25 1.25 0 0 0-2.04-.112l-.078.112-7.556 12.048a.75.75 0 0 1-1.322-.699l.052-.098L9.67 5.803a2.75 2.75 0 0 1 3.791-.869ZM14.751 12a3.25 3.25 0 1 0 0 6.5 3.25 3.25 0 0 0 0-6.5Z",
"automation-outline": [
+5 -6
View File
@@ -28,10 +28,11 @@ export const CONVERSATION_PRIORITY = {
};
export const CONVERSATION_PRIORITY_ORDER = {
urgent: 4,
high: 3,
medium: 2,
low: 1,
urgent: 1,
high: 2,
medium: 3,
low: 4,
null: 5,
};
// Size in mega bytes
@@ -54,8 +55,6 @@ export const ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP =
'audio/mpeg, audio/opus, audio/ogg, audio/amr,' +
'video/mp4,' +
'application/pdf,';
// https://developers.line.biz/en/reference/messaging-api/#image-message, https://developers.line.biz/en/reference/messaging-api/#video-message
export const ALLOWED_FILE_TYPES_FOR_LINE = 'image/png, image/jpeg,video/mp4';
export const CSAT_RATINGS = [
{
+1 -6
View File
@@ -2,7 +2,7 @@ import fromUnixTime from 'date-fns/fromUnixTime';
import format from 'date-fns/format';
import isToday from 'date-fns/isToday';
import isYesterday from 'date-fns/isYesterday';
import { endOfDay, getUnixTime, startOfDay, differenceInDays } from 'date-fns';
import { endOfDay, getUnixTime, startOfDay } from 'date-fns';
export const formatUnixDate = (date, dateFormat = 'MMM dd, yyyy') => {
const unixDate = fromUnixTime(date);
@@ -45,8 +45,3 @@ export const generateRelativeTime = (value, unit, languageCode) => {
});
return rtf.format(value, unit);
};
export const getDayDifferenceFromNow = (now, timestampInSeconds) => {
const date = new Date(timestampInSeconds * 1000);
return differenceInDays(now, date);
};
@@ -4,7 +4,6 @@ import {
formatDigitToString,
isTimeAfter,
generateRelativeTime,
getDayDifferenceFromNow,
} from '../DateHelper';
describe('#DateHelper', () => {
@@ -121,25 +120,3 @@ describe('generateRelativeTime', () => {
expect(actualResult).toBe(expectedResult);
});
});
describe('#getDayDifferenceFromNow', () => {
it('should return the difference if in same day', () => {
const now = new Date('2023-12-08T00:00:00.000Z');
const timestampInSeconds = 1702020305; // 08/12/2023, 12:55:05 (GMT+05:30)
const expectedResult = 0;
const actualResult = getDayDifferenceFromNow(now, timestampInSeconds);
expect(actualResult).toBe(expectedResult);
});
it('should return the difference if in different day', () => {
const now = new Date('2023-12-11T00:00:00.000Z');
const timestampInSeconds = 1702020305; // 08/12/2023, 12:55:05 (GMT+05:30)
const expectedResult = 2;
const actualResult = getDayDifferenceFromNow(now, timestampInSeconds);
expect(actualResult).toBe(expectedResult);
});
});
-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 = {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 16 KiB

@@ -5,7 +5,7 @@
<div class="message-wrap mt-2">
<div
class="typing-bubble chat-bubble agent"
:class="$dm('bg-white', 'dark:bg-slate-700')"
:class="$dm('bg-white', 'dark:bg-slate-50')"
>
<img
src="~widget/assets/images/typing.gif"
+3 -15
View File
@@ -4,21 +4,9 @@ class Internal::CheckNewVersionsJob < ApplicationJob
def perform
return unless Rails.env.production?
instance_info = ChatwootHub.sync_with_hub
return unless instance_info
latest_version = ChatwootHub.latest_version
return unless latest_version
::Redis::Alfred.set(::Redis::Alfred::LATEST_CHATWOOT_VERSION, instance_info['version'])
update_installation_config(key: 'INSTALLATION_PRICING_PLAN', value: instance_info['plan'])
update_installation_config(key: 'INSTALLATION_PRICING_PLAN_QUANTITY', value: instance_info['plan_quantity'])
update_installation_config(key: 'CHATWOOT_SUPPORT_WEBSITE_TOKEN', value: instance_info['chatwoot_support_website_token'])
update_installation_config(key: 'CHATWOOT_SUPPORT_IDENTIFIER_HASH', value: instance_info['chatwoot_support_identifier_hash'])
update_installation_config(key: 'CHATWOOT_SUPPORT_SCRIPT_URL', value: instance_info['chatwoot_support_script_url'])
end
def update_installation_config(key:, value:)
config = InstallationConfig.find_or_initialize_by(name: key)
config.value = value
config.locked = true
config.save!
::Redis::Alfred.set(::Redis::Alfred::LATEST_CHATWOOT_VERSION, latest_version)
end
end
@@ -1,14 +0,0 @@
class Migration::ConversationBatchCacheLabelJob < ApplicationJob
queue_as :async_database_migration
# To cache the label, we simply access it from the object and save it. Anytime the object is
# saved in the future, ActsAsTaggable will automatically recompute it. This process is done
# initially when the user has not performed any action.
# Reference: https://github.com/mbleigh/acts-as-taggable-on/wiki/Caching
def perform(conversation_batch)
conversation_batch.each do |conversation|
conversation.label_list
conversation.save!
end
end
end
@@ -1,9 +0,0 @@
class Migration::ConversationCacheLabelJob < ApplicationJob
queue_as :async_database_migration
def perform(account)
account.conversations.find_in_batches(batch_size: 100) do |conversation_batch|
Migration::ConversationBatchCacheLabelJob.perform_later(conversation_batch)
end
end
end
@@ -1,8 +0,0 @@
# Delete migration and spec after 2 consecutive releases.
class Migration::RemoveMessageNotifications < ApplicationJob
queue_as :scheduled_jobs
def perform
Notification.where(primary_actor_type: 'Message').in_batches(of: 100).delete_all
end
end
@@ -1,16 +0,0 @@
class Notification::RemoveDuplicateNotificationJob < ApplicationJob
queue_as :default
def perform(notification)
return unless notification.is_a?(Notification)
user_id = notification.user_id
primary_actor_id = notification.primary_actor_id
# Find older notifications with the same user and primary_actor_id, excluding the new one
duplicate_notifications = Notification.where(user_id: user_id, primary_actor_id: primary_actor_id)
.where.not(id: notification.id)
duplicate_notifications.each(&:destroy)
end
end

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