Merge branch 'release/1.7.0'

This commit is contained in:
Sojan 2020-08-11 10:17:32 +05:30
commit 2063e248a9
304 changed files with 5600 additions and 1367 deletions

View File

@ -40,7 +40,8 @@ SMTP_AUTHENTICATION=
SMTP_ENABLE_STARTTLS_AUTO= SMTP_ENABLE_STARTTLS_AUTO=
# Mail Incoming # Mail Incoming
# This is the domain set for the reply emails when conversation continuity is enabled
MAILER_INBOUND_EMAIL_DOMAIN=
# Set this to appropriate ingress channel with regards to incoming emails # Set this to appropriate ingress channel with regards to incoming emails
# Possible values are : # Possible values are :
# :relay for Exim, Postfix, Qmail # :relay for Exim, Postfix, Qmail

View File

@ -46,5 +46,6 @@ module.exports = {
}, },
globals: { globals: {
__WEBPACK_ENV__: true, __WEBPACK_ENV__: true,
bus: true,
}, },
}; };

4
.gitignore vendored
View File

@ -55,3 +55,7 @@ node_modules
package-lock.json package-lock.json
*.dump *.dump
# cypress
test/cypress/videos/*

View File

@ -282,3 +282,4 @@ exclude:
- 'app/javascript/widget/assets/scss/_reset.scss' - 'app/javascript/widget/assets/scss/_reset.scss'
- 'app/javascript/widget/assets/scss/sdk.css' - 'app/javascript/widget/assets/scss/sdk.css'
- 'app/assets/stylesheets/administrate/reset/_normalize.scss' - 'app/assets/stylesheets/administrate/reset/_normalize.scss'
- 'app/javascript/shared/assets/stylesheets/*.scss'

20
Gemfile
View File

@ -8,7 +8,7 @@ gem 'rails'
# Reduces boot times through caching; required in config/boot.rb # Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', require: false gem 'bootsnap', require: false
##-- rails helper gems --## ##-- rails application helper gems --##
gem 'acts-as-taggable-on' gem 'acts-as-taggable-on'
gem 'attr_extras' gem 'attr_extras'
gem 'browser' gem 'browser'
@ -23,6 +23,12 @@ gem 'tzinfo-data'
gem 'valid_email2' gem 'valid_email2'
# compress javascript config.assets.js_compressor # compress javascript config.assets.js_compressor
gem 'uglifier' gem 'uglifier'
##-- used for single column multiple binary flags in notification settings/feature flagging --##
gem 'flag_shih_tzu'
# Random name generator for user names
gem 'haikunator'
# Template parsing safetly
gem 'liquid'
##-- for active storage --## ##-- for active storage --##
gem 'aws-sdk-s3', require: false gem 'aws-sdk-s3', require: false
@ -67,8 +73,6 @@ gem 'twitty'
gem 'koala' gem 'koala'
# slack client # slack client
gem 'slack-ruby-client' gem 'slack-ruby-client'
# Random name generator
gem 'haikunator'
##--- gems for debugging and error reporting ---## ##--- gems for debugging and error reporting ---##
# static analysis # static analysis
@ -79,9 +83,6 @@ gem 'sentry-raven'
##-- background job processing --## ##-- background job processing --##
gem 'sidekiq' gem 'sidekiq'
##-- used for single column multiple binary flags in notification settings/feature flagging --##
gem 'flag_shih_tzu'
##-- Push notification service --## ##-- Push notification service --##
gem 'fcm' gem 'fcm'
gem 'webpush' gem 'webpush'
@ -96,6 +97,13 @@ group :development do
gem 'json_refs', git: 'https://github.com/tzmfreedom/json_refs', ref: 'e32deb0' gem 'json_refs', git: 'https://github.com/tzmfreedom/json_refs', ref: 'e32deb0'
end end
group :test do
# Cypress in rails.
gem 'cypress-on-rails', '~> 1.0'
# fast cleaning of database
gem 'database_cleaner'
end
group :development, :test do group :development, :test do
# locking until https://github.com/codeclimate/test-reporter/issues/418 is resolved # locking until https://github.com/codeclimate/test-reporter/issues/418 is resolved
gem 'action-cable-testing' gem 'action-cable-testing'

View File

@ -146,6 +146,9 @@ GEM
crack (0.4.3) crack (0.4.3)
safe_yaml (~> 1.0.0) safe_yaml (~> 1.0.0)
crass (1.0.6) crass (1.0.6)
cypress-on-rails (1.7.0)
rack
database_cleaner (1.8.5)
datetime_picker_rails (0.0.7) datetime_picker_rails (0.0.7)
momentjs-rails (>= 2.8.1) momentjs-rails (>= 2.8.1)
declarative (0.0.10) declarative (0.0.10)
@ -272,6 +275,7 @@ GEM
addressable (~> 2.7) addressable (~> 2.7)
letter_opener (1.7.0) letter_opener (1.7.0)
launchy (~> 2.2) launchy (~> 2.2)
liquid (4.0.3)
listen (3.2.1) listen (3.2.1)
rb-fsevent (~> 0.10, >= 0.10.3) rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10) rb-inotify (~> 0.9, >= 0.9.10)
@ -560,6 +564,8 @@ DEPENDENCIES
bullet bullet
bundle-audit bundle-audit
byebug byebug
cypress-on-rails (~> 1.0)
database_cleaner
devise devise
devise_token_auth devise_token_auth
dotenv-rails dotenv-rails
@ -579,6 +585,7 @@ DEPENDENCIES
kaminari kaminari
koala koala
letter_opener letter_opener
liquid
listen listen
mini_magick mini_magick
mock_redis! mock_redis!

3
Procfile.test Normal file
View File

@ -0,0 +1,3 @@
backend: RAILS_ENV=test bin/rails s -p 5050
frontend: bin/webpack-dev-server
worker: RAILS_ENV=test bundle exec sidekiq -C config/sidekiq.yml

View File

@ -27,9 +27,9 @@ ___
## Background ## Background
Chatwoot is a customer support tool for instant messaging channels which can help businesses to provide exceptional customer support. The development of Chatwoot started in 2016 and it failed to succeed as a business and eventually shut the shop in 2017. During 2019 #Hacktoberfest, the maintainers decided to make it opensource instead of letting the code rust in a private repo. With a pleasant surprise, Chatwoot became a trending project on Hacker News and best of all, got lots of love from the community. Chatwoot is a customer support tool for instant messaging channels which can help businesses provide exceptional customer support. The development of Chatwoot started in 2016. It failed to succeed as a business and eventually shut up shop in 2017. During 2019 #Hacktoberfest, the maintainers decided to make it opensource, instead of letting the code rust in a private repo. With a pleasant surprise, Chatwoot became a trending project on Hacker News and best of all, got lots of love from the community.
Now, a failed project is back on track and the prospects are looking great. The team is back to working on the project and we are building it in the open. Thanks to the ideas and contributions from the community. Now, a failed project is back on track and the prospects are looking great. The team is back to working on the project and this time, we are building it in the open. Thanks to the ideas and contributions from the community.
## Documentation ## Documentation
@ -39,12 +39,12 @@ You can find the quick setup docs [here](https://www.chatwoot.com/docs/quick-set
## Branching model ## Branching model
We use [git-flow](https://nvie.com/posts/a-successful-git-branching-model/) branching model. The base branch is `develop`. We use the [git-flow](https://nvie.com/posts/a-successful-git-branching-model/) branching model. The base branch is `develop`.
If you are looking for a stable version, please use the `master` or tags labelled as `v1.x.x`. If you are looking for a stable version, please use the `master` or tags labelled as `v1.x.x`.
## Heroku one-click deploy ## Heroku one-click deploy
Deploying chatwoot to heroku, it's a breeze. It's as simple as clicking this button. Deploying chatwoot to heroku is a breeze. It's as simple as clicking this button:
[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/chatwoot/chatwoot/tree/master) [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/chatwoot/chatwoot/tree/master)
@ -54,7 +54,7 @@ Follow this [link](https://www.chatwoot.com/docs/environment-variables) to under
Follow our [docker development guide](https://www.chatwoot.com/docs/installation-guide-docker) to develop and debug the application using `docker-compose`. Follow our [docker development guide](https://www.chatwoot.com/docs/installation-guide-docker) to develop and debug the application using `docker-compose`.
Follow our [environment variables](https://www.chatwoot.com/docs/environment-variables/) guide to setup environment for Docker. Follow our [environment variables](https://www.chatwoot.com/docs/environment-variables/) guide to setup the environment for Docker.
## Contributors ✨ ## Contributors ✨

View File

@ -1,6 +1,6 @@
.logo-brand { .logo-brand {
margin-bottom: $space-normal; margin-bottom: $space-normal;
padding: $space-normal $space-smaller; padding: $space-normal $space-smaller $space-small;
text-align: center; text-align: center;
} }
@ -70,3 +70,9 @@
left: $space-normal; left: $space-normal;
position: fixed; position: fixed;
} }
.app-version {
color: $color-gray;
font-size: $font-size-small;
padding-top: $space-smaller;
}

View File

@ -13,6 +13,7 @@ html {
line-height: 1.15; /* 1 */ line-height: 1.15; /* 1 */
-ms-text-size-adjust: 100%; /* 2 */ -ms-text-size-adjust: 100%; /* 2 */
-webkit-text-size-adjust: 100%; /* 2 */ -webkit-text-size-adjust: 100%; /* 2 */
-moz-osx-font-smoothing: grayscale;
} }
/* Sections /* Sections

View File

@ -2,15 +2,18 @@
class AccountBuilder class AccountBuilder
include CustomExceptions::Account include CustomExceptions::Account
pattr_initialize [:account_name!, :email!, :confirmed!] pattr_initialize [:account_name!, :email!, :confirmed!, :user]
def perform def perform
if @user.nil?
validate_email validate_email
validate_user validate_user
end
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
@account = create_account @account = create_account
@user = create_and_link_user @user = create_and_link_user
end end
[@user, @account]
rescue StandardError => e rescue StandardError => e
@account&.destroy @account&.destroy
puts e.inspect puts e.inspect
@ -42,13 +45,7 @@ class AccountBuilder
end end
def create_and_link_user def create_and_link_user
password = Time.now.to_i if @user.present? || create_user
@user = User.new(email: @email,
password: password,
password_confirmation: password,
name: email_to_name(@email))
@user.confirm if @confirmed
if @user.save!
link_user_to_account(@user, @account) link_user_to_account(@user, @account)
@user @user
else else
@ -68,4 +65,14 @@ class AccountBuilder
name = email[/[^@]+/] name = email[/[^@]+/]
name.split('.').map(&:capitalize).join(' ') name.split('.').map(&:capitalize).join(' ')
end end
def create_user
password = Time.now.to_i
@user = User.new(email: @email,
password: password,
password_confirmation: password,
name: email_to_name(@email))
@user.confirm if @confirmed
@user.save!
end
end end

View File

@ -0,0 +1,139 @@
# This class creates both outgoing messages from chatwoot and echo outgoing messages based on the flag `outgoing_echo`
# Assumptions
# 1. Incase of an outgoing message which is echo, source_id will NOT be nil,
# based on this we are showing "not sent from chatwoot" message in frontend
# Hence there is no need to set user_id in message for outgoing echo messages.
class Messages::Facebook::MessageBuilder
attr_reader :response
def initialize(response, inbox, outgoing_echo = false)
@response = response
@inbox = inbox
@sender_id = (outgoing_echo ? @response.recipient_id : @response.sender_id)
@message_type = (outgoing_echo ? :outgoing : :incoming)
end
def perform
ActiveRecord::Base.transaction do
build_contact
build_message
end
rescue StandardError => e
Raven.capture_exception(e)
true
end
private
def contact
@contact ||= @inbox.contact_inboxes.find_by(source_id: @sender_id)&.contact
end
def build_contact
return if contact.present?
@contact = Contact.create!(contact_params.except(:remote_avatar_url))
ContactAvatarJob.perform_later(@contact, contact_params[:remote_avatar_url]) if contact_params[:remote_avatar_url]
@contact_inbox = ContactInbox.create(contact: contact, inbox: @inbox, source_id: @sender_id)
end
def build_message
@message = conversation.messages.create!(message_params)
(response.attachments || []).each do |attachment|
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
attachment_obj.save!
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
end
end
def attach_file(attachment, file_url)
file_resource = LocalResource.new(file_url)
attachment.file.attach(io: file_resource.file, filename: file_resource.tmp_filename, content_type: file_resource.encoding)
end
def conversation
@conversation ||= Conversation.find_by(conversation_params) || build_conversation
end
def build_conversation
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: @sender_id)
Conversation.create!(conversation_params.merge(
contact_inbox_id: @contact_inbox.id
))
end
def attachment_params(attachment)
file_type = attachment['type'].to_sym
params = { file_type: file_type, account_id: @message.account_id }
if [:image, :file, :audio, :video].include? file_type
params.merge!(file_type_params(attachment))
elsif file_type == :location
params.merge!(location_params(attachment))
elsif file_type == :fallback
params.merge!(fallback_params(attachment))
end
params
end
def file_type_params(attachment)
{
external_url: attachment['payload']['url'],
remote_file_url: attachment['payload']['url']
}
end
def location_params(attachment)
lat = attachment['payload']['coordinates']['lat']
long = attachment['payload']['coordinates']['long']
{
external_url: attachment['url'],
coordinates_lat: lat,
coordinates_long: long,
fallback_title: attachment['title']
}
end
def fallback_params(attachment)
{
fallback_title: attachment['title'],
external_url: attachment['url']
}
end
def conversation_params
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: contact.id
}
end
def message_params
{
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: @message_type,
content: response.content,
source_id: response.identifier,
sender: contact
}
end
def contact_params
begin
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
result = k.get_object(@sender_id) || {}
rescue StandardError => e
result = {}
Raven.capture_exception(e)
end
{
name: "#{result['first_name'] || 'John'} #{result['last_name'] || 'Doe'}",
account_id: @inbox.account_id,
remote_avatar_url: result['profile_pic'] || ''
}
end
end

View File

@ -1,2 +0,0 @@
class Messages::IncomingMessageBuilder < Messages::MessageBuilder
end

View File

@ -1,139 +1,59 @@
# This class creates both outgoing messages from chatwoot and echo outgoing messages based on the flag `outgoing_echo`
# Assumptions
# 1. Incase of an outgoing message which is echo, source_id will NOT be nil,
# based on this we are showing "not sent from chatwoot" message in frontend
# Hence there is no need to set user_id in message for outgoing echo messages.
class Messages::MessageBuilder class Messages::MessageBuilder
attr_reader :response include ::FileTypeHelper
attr_reader :message
def initialize(response, inbox, outgoing_echo = false) def initialize(user, conversation, params)
@response = response @content = params[:content]
@inbox = inbox @private = params[:private] || false
@sender_id = (outgoing_echo ? @response.recipient_id : @response.sender_id) @conversation = conversation
@message_type = (outgoing_echo ? :outgoing : :incoming) @user = user
@message_type = params[:message_type] || 'outgoing'
@content_type = params[:content_type]
@items = params.to_unsafe_h&.dig(:content_attributes, :items)
@attachments = params[:attachments]
@in_reply_to = params.to_unsafe_h&.dig(:content_attributes, :in_reply_to)
end end
def perform def perform
ActiveRecord::Base.transaction do @message = @conversation.messages.build(message_params)
build_contact if @attachments.present?
build_message @attachments.each do |uploaded_attachment|
attachment = @message.attachments.new(
account_id: @message.account_id,
file_type: file_type(uploaded_attachment&.content_type)
)
attachment.file.attach(uploaded_attachment)
end end
rescue StandardError => e end
Raven.capture_exception(e) @message.save
true @message
end end
private private
def contact def message_type
@contact ||= @inbox.contact_inboxes.find_by(source_id: @sender_id)&.contact if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
raise StandardError, 'Incoming messages are only allowed in Api inboxes'
end end
def build_contact @message_type
return if contact.present?
@contact = Contact.create!(contact_params.except(:remote_avatar_url))
ContactAvatarJob.perform_later(@contact, contact_params[:remote_avatar_url]) if contact_params[:remote_avatar_url]
@contact_inbox = ContactInbox.create(contact: contact, inbox: @inbox, source_id: @sender_id)
end end
def build_message def sender
@message = conversation.messages.create!(message_params) message_type == 'outgoing' ? @user : @conversation.contact
(response.attachments || []).each do |attachment|
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
attachment_obj.save!
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
end
end
def attach_file(attachment, file_url)
file_resource = LocalResource.new(file_url)
attachment.file.attach(io: file_resource.file, filename: file_resource.tmp_filename, content_type: file_resource.encoding)
end
def conversation
@conversation ||= Conversation.find_by(conversation_params) || build_conversation
end
def build_conversation
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: @sender_id)
Conversation.create!(conversation_params.merge(
contact_inbox_id: @contact_inbox.id
))
end
def attachment_params(attachment)
file_type = attachment['type'].to_sym
params = { file_type: file_type, account_id: @message.account_id }
if [:image, :file, :audio, :video].include? file_type
params.merge!(file_type_params(attachment))
elsif file_type == :location
params.merge!(location_params(attachment))
elsif file_type == :fallback
params.merge!(fallback_params(attachment))
end
params
end
def file_type_params(attachment)
{
external_url: attachment['payload']['url'],
remote_file_url: attachment['payload']['url']
}
end
def location_params(attachment)
lat = attachment['payload']['coordinates']['lat']
long = attachment['payload']['coordinates']['long']
{
external_url: attachment['url'],
coordinates_lat: lat,
coordinates_long: long,
fallback_title: attachment['title']
}
end
def fallback_params(attachment)
{
fallback_title: attachment['title'],
external_url: attachment['url']
}
end
def conversation_params
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: contact.id
}
end end
def message_params def message_params
{ {
account_id: conversation.account_id, account_id: @conversation.account_id,
inbox_id: conversation.inbox_id, inbox_id: @conversation.inbox_id,
message_type: @message_type, message_type: message_type,
content: response.content, content: @content,
source_id: response.identifier, private: @private,
sender: contact sender: sender,
} content_type: @content_type,
end items: @items,
in_reply_to: @in_reply_to
def contact_params
begin
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
result = k.get_object(@sender_id) || {}
rescue Exception => e
result = {}
Raven.capture_exception(e)
end
{
name: "#{result['first_name'] || 'John'} #{result['last_name'] || 'Doe'}",
account_id: @inbox.account_id,
remote_avatar_url: result['profile_pic'] || ''
} }
end end
end end

View File

@ -1,2 +0,0 @@
class Messages::Outgoing::EchoBuilder < ::Messages::MessageBuilder
end

View File

@ -1,46 +0,0 @@
class Messages::Outgoing::NormalBuilder
include ::FileTypeHelper
attr_reader :message
def initialize(user, conversation, params)
@content = params[:content]
@private = params[:private] || false
@conversation = conversation
@user = user
@fb_id = params[:fb_id]
@content_type = params[:content_type]
@items = params.to_unsafe_h&.dig(:content_attributes, :items)
@attachments = params[:attachments]
end
def perform
@message = @conversation.messages.build(message_params)
if @attachments.present?
@attachments.each do |uploaded_attachment|
attachment = @message.attachments.new(
account_id: @message.account_id,
file_type: file_type(uploaded_attachment&.content_type)
)
attachment.file.attach(uploaded_attachment)
end
end
@message.save
@message
end
private
def message_params
{
account_id: @conversation.account_id,
inbox_id: @conversation.inbox_id,
message_type: :outgoing,
content: @content,
private: @private,
sender: @user,
source_id: @fb_id,
content_type: @content_type,
items: @items
}
end
end

View File

@ -0,0 +1,26 @@
class Api::V1::Accounts::Contacts::ContactInboxesController < Api::V1::Accounts::BaseController
before_action :ensure_contact
before_action :ensure_inbox, only: [:create]
before_action :validate_channel_type
def create
source_id = params[:source_id] || SecureRandom.uuid
@contact_inbox = ContactInbox.create(contact: @contact, inbox: @inbox, source_id: source_id)
end
private
def validate_channel_type
return if @inbox.channel_type == 'Channel::Api'
render json: { error: 'Contact Inbox creation is only allowed in API inboxes' }, status: :unprocessable_entity
end
def ensure_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
end
def ensure_contact
@contact = Current.account.contacts.find(params[:contact_id])
end
end

View File

@ -11,21 +11,37 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def show; end def show; end
def create def create
ActiveRecord::Base.transaction do
@contact = Current.account.contacts.new(contact_create_params) @contact = Current.account.contacts.new(contact_create_params)
@contact.save! @contact.save!
render json: @contact @contact_inbox = build_contact_inbox
end
end end
def update def update
@contact.update!(contact_params) @contact.update!(contact_params)
end end
def search
render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return
@contacts = Current.account.contacts.where('name LIKE :search OR email LIKE :search', search: "%#{params[:q]}%")
end
private private
def check_authorization def check_authorization
authorize(Contact) authorize(Contact)
end end
def build_contact_inbox
return if params[:inbox_id].blank?
inbox = Current.account.inboxes.find(params[:inbox_id])
source_id = params[:source_id] || SecureRandom.uuid
ContactInbox.create(contact: @contact, inbox: inbox, source_id: source_id)
end
def contact_params def contact_params
params.require(:contact).permit(:name, :email, :phone_number) params.require(:contact).permit(:name, :email, :phone_number)
end end

View File

@ -5,8 +5,10 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
def create def create
user = current_user || @resource user = current_user || @resource
mb = Messages::Outgoing::NormalBuilder.new(user, @conversation, params) mb = Messages::MessageBuilder.new(user, @conversation, params)
@message = mb.perform @message = mb.perform
rescue StandardError => e
render_could_not_create_error(e.message)
end end
private private

View File

@ -44,9 +44,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end end
def update_last_seen def update_last_seen
@conversation.agent_last_seen_at = parsed_last_seen_at @conversation.agent_last_seen_at = DateTime.now.utc
@conversation.save! @conversation.save!
head :ok
end end
private private
@ -56,10 +55,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: @conversation, user: user) Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: @conversation, user: user)
end end
def parsed_last_seen_at
DateTime.strptime(params[:agent_last_seen_at].to_s, '%s')
end
def conversation def conversation
@conversation ||= Current.account.conversations.find_by(display_id: params[:id]) @conversation ||= Current.account.conversations.find_by(display_id: params[:id])
end end

View File

@ -4,12 +4,12 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
before_action :check_authorization before_action :check_authorization
def index def index
@inboxes = policy_scope(Current.account.inboxes) @inboxes = policy_scope(Current.account.inboxes.order_by_id.includes(:channel, :avatar_attachment))
end end
def create def create
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
channel = web_widgets.create!(permitted_params[:channel].except(:type)) if permitted_params[:channel][:type] == 'web_widget' channel = create_channel
@inbox = Current.account.inboxes.build( @inbox = Current.account.inboxes.build(
name: permitted_params[:name], name: permitted_params[:name],
greeting_message: permitted_params[:greeting_message], greeting_message: permitted_params[:greeting_message],
@ -23,7 +23,10 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
def update def update
@inbox.update(inbox_update_params.except(:channel)) @inbox.update(inbox_update_params.except(:channel))
@inbox.channel.update!(inbox_update_params[:channel]) if @inbox.channel.is_a?(Channel::WebWidget) && inbox_update_params[:channel].present? return unless @inbox.channel.is_a?(Channel::WebWidget) && inbox_update_params[:channel].present?
@inbox.channel.update!(inbox_update_params[:channel])
update_channel_feature_flags
end end
def set_agent_bot def set_agent_bot
@ -52,21 +55,43 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot] @agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
end end
def web_widgets
Current.account.web_widgets
end
def check_authorization def check_authorization
authorize(Inbox) authorize(Inbox)
end end
def create_channel
case permitted_params[:channel][:type]
when 'web_widget'
Current.account.web_widgets.create!(permitted_params[:channel].except(:type))
when 'api'
Current.account.api_channels.create!(permitted_params[:channel].except(:type))
when 'email'
Current.account.email_channels.create!(permitted_params[:channel].except(:type))
end
end
def update_channel_feature_flags
return unless inbox_update_params[:channel].key? :selected_feature_flags
@inbox.channel.selected_feature_flags = inbox_update_params[:channel][:selected_feature_flags]
@inbox.channel.save!
end
def permitted_params def permitted_params
params.permit(:id, :avatar, :name, :greeting_message, :greeting_enabled, channel: params.permit(:id, :avatar, :name, :greeting_message, :greeting_enabled, channel:
[:type, :website_url, :widget_color, :welcome_title, :welcome_tagline]) [:type, :website_url, :widget_color, :welcome_title, :welcome_tagline, :webhook_url, :email])
end end
def inbox_update_params def inbox_update_params
params.permit(:enable_auto_assignment, :name, :avatar, :greeting_message, :greeting_enabled, params.permit(:enable_auto_assignment, :name, :avatar, :greeting_message, :greeting_enabled,
channel: [:website_url, :widget_color, :welcome_title, :welcome_tagline]) channel: [
:website_url,
:widget_color,
:welcome_title,
:welcome_tagline,
:webhook_url,
:email,
selected_feature_flags: []
])
end end
end end

View File

@ -14,14 +14,15 @@ class Api::V1::AccountsController < Api::BaseController
with: :render_error_response with: :render_error_response
def create def create
@user = AccountBuilder.new( @user, @account = AccountBuilder.new(
account_name: account_params[:account_name], account_name: account_params[:account_name],
email: account_params[:email], email: account_params[:email],
confirmed: confirmed? confirmed: confirmed?,
user: current_user
).perform ).perform
if @user if @user
send_auth_headers(@user) send_auth_headers(@user)
render partial: 'devise/auth.json', locals: { resource: @user } render 'api/v1/accounts/create.json', locals: { resource: @user }
else else
render_error_response(CustomExceptions::Account::SignupFailed.new({})) render_error_response(CustomExceptions::Account::SignupFailed.new({}))
end end
@ -32,7 +33,7 @@ class Api::V1::AccountsController < Api::BaseController
end end
def update def update
@account.update!(account_params.slice(:name, :locale, :domain, :support_email, :domain_emails_enabled)) @account.update!(account_params.slice(:name, :locale, :domain, :support_email))
end end
def update_active_at def update_active_at
@ -57,7 +58,7 @@ class Api::V1::AccountsController < Api::BaseController
end end
def account_params def account_params
params.permit(:account_name, :email, :name, :locale, :domain, :support_email, :domain_emails_enabled) params.permit(:account_name, :email, :name, :locale, :domain, :support_email)
end end
def check_signup_enabled def check_signup_enabled

View File

@ -16,6 +16,14 @@ class Api::V1::ProfilesController < Api::BaseController
end end
def profile_params def profile_params
params.require(:profile).permit(:email, :name, :password, :password_confirmation, :avatar, :availability) params.require(:profile).permit(
:email,
:name,
:display_name,
:password,
:password_confirmation,
:avatar,
:availability
)
end end
end end

View File

@ -30,4 +30,14 @@ class Api::V1::Widget::BaseController < ApplicationController
) )
@contact = @contact_inbox.contact @contact = @contact_inbox.contact
end end
def browser_params
{
browser_name: browser.name,
browser_version: browser.full_version,
device_name: browser.device.name,
platform_name: browser.platform.name,
platform_version: browser.platform.version
}
end
end end

View File

@ -11,7 +11,8 @@ class Api::V1::Widget::EventsController < Api::V1::Widget::BaseController
def event_info def event_info
{ {
widget_language: params[:locale], widget_language: params[:locale],
browser_language: browser.accept_language.first&.code browser_language: browser.accept_language.first&.code,
browser: browser_params
} }
end end

View File

@ -66,16 +66,6 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
} }
end end
def browser_params
{
browser_name: browser.name,
browser_version: browser.full_version,
device_name: browser.device.name,
platform_name: browser.platform.name,
platform_version: browser.platform.version
}
end
def timestamp_params def timestamp_params
{ {
timestamp: permitted_params[:message][:timestamp] timestamp: permitted_params[:message][:timestamp]

View File

@ -15,7 +15,10 @@ class DashboardController < ActionController::Base
'WIDGET_BRAND_URL', 'WIDGET_BRAND_URL',
'TERMS_URL', 'TERMS_URL',
'PRIVACY_URL', 'PRIVACY_URL',
'DISPLAY_MANIFEST' 'DISPLAY_MANIFEST',
'CREATE_NEW_ACCOUNT_FROM_DASHBOARD'
).merge(
APP_VERSION: Chatwoot.config[:version]
) )
end end
end end

View File

@ -11,7 +11,7 @@ class WidgetsController < ActionController::Base
private private
def set_global_config def set_global_config
@global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'INSTALLATION_NAME', 'WIDGET_BRAND_URL') @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL')
end end
def set_web_widget def set_web_widget

View File

@ -24,7 +24,7 @@ class UserDashboard < Administrate::BaseDashboard
confirmation_sent_at: Field::DateTime, confirmation_sent_at: Field::DateTime,
unconfirmed_email: Field::String, unconfirmed_email: Field::String,
name: Field::String, name: Field::String,
nickname: Field::String, display_name: Field::String,
email: Field::String, email: Field::String,
tokens: Field::String.with_options(searchable: false), tokens: Field::String.with_options(searchable: false),
created_at: Field::DateTime, created_at: Field::DateTime,
@ -53,7 +53,7 @@ class UserDashboard < Administrate::BaseDashboard
avatar_url avatar_url
unconfirmed_email unconfirmed_email
name name
nickname display_name
email email
created_at created_at
updated_at updated_at
@ -65,7 +65,7 @@ class UserDashboard < Administrate::BaseDashboard
# on the model's form (`new` and `edit`) pages. # on the model's form (`new` and `edit`) pages.
FORM_ATTRIBUTES = %i[ FORM_ATTRIBUTES = %i[
name name
nickname display_name
email email
password password
].freeze ].freeze

View File

@ -0,0 +1,2 @@
class AccountDrop < BaseDrop
end

13
app/drops/base_drop.rb Normal file
View File

@ -0,0 +1,13 @@
class BaseDrop < Liquid::Drop
def initialize(obj)
@obj = obj
end
def id
@obj.try(:id)
end
def name
@obj.try(:name)
end
end

View File

@ -0,0 +1,5 @@
class ConversationDrop < BaseDrop
def display_id
@obj.try(:display_id)
end
end

2
app/drops/inbox_drop.rb Normal file
View File

@ -0,0 +1,2 @@
class InboxDrop < BaseDrop
end

2
app/drops/user_drop.rb Normal file
View File

@ -0,0 +1,2 @@
class UserDrop < BaseDrop
end

View File

@ -1,9 +1,14 @@
/* global axios */
import ApiClient from './ApiClient'; import ApiClient from './ApiClient';
class AccountAPI extends ApiClient { class AccountAPI extends ApiClient {
constructor() { constructor() {
super('', { accountScoped: true }); super('', { accountScoped: true });
} }
createAccount(data) {
return axios.post(`${this.apiVersion}/accounts`, data);
}
} }
export default new AccountAPI(); export default new AccountAPI();

View File

@ -118,7 +118,12 @@ export default {
return axios.post(urlData.url, { email }); return axios.post(urlData.url, { email });
}, },
profileUpdate({ password, password_confirmation, ...profileAttributes }) { profileUpdate({
password,
password_confirmation,
displayName,
...profileAttributes
}) {
const formData = new FormData(); const formData = new FormData();
Object.keys(profileAttributes).forEach(key => { Object.keys(profileAttributes).forEach(key => {
const value = profileAttributes[key]; const value = profileAttributes[key];
@ -126,6 +131,7 @@ export default {
formData.append(`profile[${key}]`, value); formData.append(`profile[${key}]`, value);
} }
}); });
formData.append('profile[display_name]', displayName || '');
if (password && password_confirmation) { if (password && password_confirmation) {
formData.append('profile[password]', password); formData.append('profile[password]', password);
formData.append('profile[password_confirmation]', password_confirmation); formData.append('profile[password_confirmation]', password_confirmation);

View File

@ -29,10 +29,8 @@ class ConversationApi extends ApiClient {
); );
} }
markMessageRead({ id, lastSeen }) { markMessageRead({ id }) {
return axios.post(`${this.url}/${id}/update_last_seen`, { return axios.post(`${this.url}/${id}/update_last_seen`);
agent_last_seen_at: lastSeen,
});
} }
toggleTyping({ conversationId, status }) { toggleTyping({ conversationId, status }) {

View File

@ -7,10 +7,11 @@ class MessageApi extends ApiClient {
super('conversations', { accountScoped: true }); super('conversations', { accountScoped: true });
} }
create({ conversationId, message, private: isPrivate }) { create({ conversationId, message, private: isPrivate, contentAttributes }) {
return axios.post(`${this.url}/${conversationId}/messages`, { return axios.post(`${this.url}/${conversationId}/messages`, {
content: message, content: message,
private: isPrivate, private: isPrivate,
content_attributes: contentAttributes,
}); });
} }
@ -20,9 +21,10 @@ class MessageApi extends ApiClient {
}); });
} }
sendAttachment([conversationId, { file }]) { sendAttachment([conversationId, { file, isPrivate = false }]) {
const formData = new FormData(); const formData = new FormData();
formData.append('attachments[]', file, file.name); formData.append('attachments[]', file, file.name);
formData.append('private', isPrivate);
return axios({ return axios({
method: 'post', method: 'post',
url: `${this.url}/${conversationId}/messages`, url: `${this.url}/${conversationId}/messages`,

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@ -1,11 +1,11 @@
html, html,
body { body {
height: 100%;
width: 100%;
padding: 0;
margin: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
height: 100%;
margin: 0;
padding: 0;
width: 100%;
} }
.app-wrapper { .app-wrapper {
@ -26,36 +26,40 @@ body {
.view-box { .view-box {
@include full-height; @include full-height;
height: 100vh;
@include margin(0); @include margin(0);
@include space-between-column; @include space-between-column;
height: 100vh;
} }
.view-panel { .view-panel {
flex-direction: column;
@include margin($zero); @include margin($zero);
@include padding($space-normal); @include padding($space-normal);
flex-direction: column;
overflow-y: auto; overflow-y: auto;
} }
.content-box { .content-box {
overflow: auto;
@include padding($space-normal); @include padding($space-normal);
overflow: auto;
} }
.back-button { .back-button {
@include flex; @include flex;
align-items: center; align-items: center;
color: $color-woot; color: $color-woot;
cursor: pointer;
font-size: $font-size-default; font-size: $font-size-default;
font-weight: $font-weight-normal; font-weight: $font-weight-normal;
margin-right: $space-normal; margin-right: $space-normal;
cursor: pointer;
&:before { &::before {
vertical-align: text-bottom;
margin-right: $space-smaller;
font-size: $font-size-large; font-size: $font-size-large;
margin-right: $space-small;
vertical-align: text-bottom;
} }
} }
@ -66,12 +70,14 @@ body {
.no-items-error-message { .no-items-error-message {
@include flex; @include flex;
@include full-height; @include full-height;
justify-content: center;
align-items: center; align-items: center;
flex-direction: column; flex-direction: column;
justify-content: center;
img { img {
max-width: $space-mega;
@include padding($space-one); @include padding($space-one);
max-width: $space-mega;
} }
} }

View File

@ -46,8 +46,8 @@ $color-gray: #6e6f73;
$color-light-gray: #999a9b; $color-light-gray: #999a9b;
$color-border: #e0e6ed; $color-border: #e0e6ed;
$color-border-light: #f0f4f5; $color-border-light: #f0f4f5;
$color-background: #f4f6fb;
$color-border-dark: #cad0d4; $color-border-dark: #cad0d4;
$color-background: #f4f6fb;
$color-background-light: #f9fafc; $color-background-light: #f9fafc;
$color-white: #fff; $color-white: #fff;
$color-body: #3c4858; $color-body: #3c4858;

View File

@ -1,5 +1,7 @@
@import 'shared/assets/fonts/inter'; @import 'shared/assets/fonts/inter';
@import 'shared/assets/stylesheets/colors';
@import 'shared/assets/stylesheets/spacing';
@import 'shared/assets/stylesheets/font-size';
@import 'variables'; @import 'variables';
@import '~spinkit/scss/spinners/7-three-bounce'; @import '~spinkit/scss/spinners/7-three-bounce';

View File

@ -202,7 +202,7 @@
} }
.settings--content { .settings--content {
@include margin($space-small $space-larger); @include margin($space-small $space-large);
.title { .title {
font-weight: $font-weight-medium; font-weight: $font-weight-medium;

View File

@ -8,21 +8,9 @@
font-weight: $font-weight-normal; font-weight: $font-weight-normal;
position: relative; position: relative;
.icon {
bottom: $space-smaller;
position: absolute;
right: $space-small;
}
.message-text__wrap { .message-text__wrap {
position: relative; position: relative;
.time {
color: $color-primary-light;
display: block;
font-size: $font-size-micro;
line-height: 1.8;
}
.link { .link {
color: $color-white; color: $color-white;
@ -37,24 +25,10 @@
} }
} }
.audio {
.time {
margin-top: -$space-two;
}
}
.image { .image {
cursor: pointer; cursor: pointer;
position: relative; position: relative;
.time {
bottom: $space-smaller;
color: $color-white;
position: absolute;
right: $space-small;
white-space: nowrap;
}
.modal-container { .modal-container {
text-align: center; text-align: center;
} }
@ -74,30 +48,6 @@
width: 100%; width: 100%;
} }
} }
.map {
@include flex;
flex-direction: column;
text-align: right;
img {
@include padding($space-small);
max-height: 30rem;
max-width: 20rem;
}
.time {
@include padding($space-small);
margin-left: -$space-smaller;
margin-top: -$space-two;
white-space: nowrap;
}
.locname {
font-weight: $font-weight-medium;
padding: $space-smaller;
}
}
} }
.conversations-sidebar { .conversations-sidebar {
@ -257,14 +207,6 @@
color: $color-body; color: $color-body;
margin-right: auto; margin-right: auto;
.time {
color: $color-light-gray;
}
.image .time {
color: $color-white;
}
.link { .link {
color: $color-primary-dark; color: $color-primary-dark;
} }
@ -321,10 +263,6 @@
right: $space-one; right: $space-one;
top: $space-smaller + $space-micro; top: $space-smaller + $space-micro;
} }
.time {
color: $color-light-gray;
}
} }
} }
@ -389,11 +327,6 @@
} }
} }
.time {
color: $medium-gray;
font-size: $font-size-micro;
margin-left: $space-slab;
}
} }
} }

View File

@ -7,15 +7,25 @@
<p v-if="headerContent" class="small-12 column"> <p v-if="headerContent" class="small-12 column">
{{ headerContent }} {{ headerContent }}
</p> </p>
<slot></slot>
</div> </div>
</template> </template>
<script> <script>
export default { export default {
props: { props: {
headerTitle: String, headerTitle: {
headerContent: String, type: String,
headerImage: String, default: '',
},
headerContent: {
type: String,
default: '',
},
headerImage: {
type: String,
default: '',
},
}, },
}; };
</script> </script>

View File

@ -1,6 +1,7 @@
<template> <template>
<button <button
:type="type" :type="type"
data-testid="submit_button"
:disabled="disabled" :disabled="disabled"
:class="computedClass" :class="computedClass"
@click="onClick" @click="onClick"

View File

@ -34,7 +34,7 @@
class="dropdown-pane top" class="dropdown-pane top"
> >
<ul class="vertical dropdown menu"> <ul class="vertical dropdown menu">
<li v-if="currentUser.accounts.length > 1"> <li v-if="showChangeAccountOption">
<button <button
class="button clear change-accounts--button" class="button clear change-accounts--button"
@click="changeAccount" @click="changeAccount"
@ -58,12 +58,12 @@
<div class="current-user" @click.prevent="showOptions()"> <div class="current-user" @click.prevent="showOptions()">
<thumbnail <thumbnail
:src="currentUser.avatar_url" :src="currentUser.avatar_url"
:username="currentUser.name" :username="currentUserAvailableName"
:status="currentUser.availability_status" :status="currentUser.availability_status"
/> />
<div class="current-user--data"> <div class="current-user--data">
<h3 class="current-user--name"> <h3 class="current-user--name">
{{ currentUser.name }} {{ currentUserAvailableName }}
</h3> </h3>
<h5 class="current-user--role"> <h5 class="current-user--role">
{{ currentRole }} {{ currentRole }}
@ -94,6 +94,58 @@
</label> </label>
</a> </a>
</div> </div>
<div
v-if="globalConfig.createNewAccountFromDashboard"
class="modal-footer delete-item"
>
<button
class="button success large expanded nice"
@click="createAccount"
>
{{ $t('CREATE_ACCOUNT.NEW_ACCOUNT') }}
</button>
</div>
</woot-modal>
<woot-modal
:show="showCreateAccountModal"
:on-close="onCloseCreate"
class="account-selector--modal"
>
<div class="column content-box">
<woot-modal-header
:header-title="$t('CREATE_ACCOUNT.NEW_ACCOUNT')"
:header-content="$t('CREATE_ACCOUNT.SELECTOR_SUBTITLE')"
/>
<form class="row" @submit.prevent="addAccount()">
<div class="medium-12 columns">
<label :class="{ error: $v.accountName.$error }">
{{ $t('CREATE_ACCOUNT.FORM.NAME.LABEL') }}
<input
v-model.trim="accountName"
type="text"
:placeholder="$t('CREATE_ACCOUNT.FORM.NAME.PLACEHOLDER')"
@input="$v.accountName.$touch"
/>
</label>
</div>
<div class="modal-footer medium-12 columns">
<div class="medium-12 columns">
<woot-submit-button
:disabled="
$v.accountName.$invalid ||
$v.accountName.$invalid ||
uiFlags.isCreating
"
:button-text="$t('CREATE_ACCOUNT.FORM.SUBMIT')"
:loading="uiFlags.isCreating"
button-class="large expanded"
/>
</div>
</div>
</form>
</div>
</woot-modal> </woot-modal>
</aside> </aside>
</template> </template>
@ -108,13 +160,16 @@ import SidebarItem from './SidebarItem';
import { frontendURL } from '../../helper/URLHelper'; import { frontendURL } from '../../helper/URLHelper';
import Thumbnail from '../widgets/Thumbnail'; import Thumbnail from '../widgets/Thumbnail';
import { getSidebarItems } from '../../i18n/default-sidebar'; import { getSidebarItems } from '../../i18n/default-sidebar';
import { required, minLength } from 'vuelidate/lib/validators';
import alertMixin from 'shared/mixins/alertMixin';
// import accountMixin from '../../../../../mixins/account';
export default { export default {
components: { components: {
SidebarItem, SidebarItem,
Thumbnail, Thumbnail,
}, },
mixins: [clickaway, adminMixin], mixins: [clickaway, adminMixin, alertMixin],
props: { props: {
route: { route: {
type: String, type: String,
@ -125,8 +180,18 @@ export default {
return { return {
showOptionsMenu: false, showOptionsMenu: false,
showAccountModal: false, showAccountModal: false,
showCreateAccountModal: false,
accountName: '',
vertical: 'bottom',
horizontal: 'center',
}; };
}, },
validations: {
accountName: {
required,
minLength: minLength(1),
},
},
computed: { computed: {
...mapGetters({ ...mapGetters({
currentUser: 'getCurrentUser', currentUser: 'getCurrentUser',
@ -134,8 +199,19 @@ export default {
inboxes: 'inboxes/getInboxes', inboxes: 'inboxes/getInboxes',
accountId: 'getCurrentAccountId', accountId: 'getCurrentAccountId',
currentRole: 'getCurrentRole', currentRole: 'getCurrentRole',
uiFlags: 'agents/getUIFlags',
accountLabels: 'labels/getLabelsOnSidebar', accountLabels: 'labels/getLabelsOnSidebar',
}), }),
currentUserAvailableName() {
const { available_name: availableName } = this.currentUser;
return availableName;
},
showChangeAccountOption() {
if (this.globalConfig.createNewAccountFromDashboard) {
return true;
}
return this.currentUser.accounts.length > 1;
},
sidemenuItems() { sidemenuItems() {
return getSidebarItems(this.accountId); return getSidebarItems(this.accountId);
}, },
@ -230,6 +306,29 @@ export default {
onClose() { onClose() {
this.showAccountModal = false; this.showAccountModal = false;
}, },
createAccount() {
this.showAccountModal = false;
this.showCreateAccountModal = true;
},
onCloseCreate() {
this.showCreateAccountModal = false;
},
async addAccount() {
try {
const account_id = await this.$store.dispatch('accounts/create', {
account_name: this.accountName,
});
this.onClose();
this.showAlert(this.$t('CREATE_ACCOUNT.API.SUCCESS_MESSAGE'));
window.location = `/app/accounts/${account_id}/dashboard`;
} catch (error) {
if (error.response.status === 422) {
this.showAlert(this.$t('CREATE_ACCOUNT.API.EXIST_MESSAGE'));
} else {
this.showAlert(this.$t('CREATE_ACCOUNT.API.ERROR_MESSAGE'));
}
}
},
}, },
}; };
</script> </script>

View File

@ -57,13 +57,8 @@ import { mapGetters } from 'vuex';
import router from '../../routes'; import router from '../../routes';
import adminMixin from '../../mixins/isAdmin'; import adminMixin from '../../mixins/isAdmin';
import { INBOX_TYPES } from 'shared/mixins/inboxMixin';
const INBOX_TYPES = {
WEB: 'Channel::WebWidget',
FB: 'Channel::FacebookPage',
TWITTER: 'Channel::TwitterProfile',
TWILIO: 'Channel::TwilioSms',
};
const getInboxClassByType = type => { const getInboxClassByType = type => {
switch (type) { switch (type) {
case INBOX_TYPES.WEB: case INBOX_TYPES.WEB:
@ -78,6 +73,12 @@ const getInboxClassByType = type => {
case INBOX_TYPES.TWILIO: case INBOX_TYPES.TWILIO:
return 'ion-android-textsms'; return 'ion-android-textsms';
case INBOX_TYPES.API:
return 'ion-cloud';
case INBOX_TYPES.EMAIL:
return 'ion-email';
default: default:
return ''; return '';
} }

View File

@ -1,5 +1,3 @@
/* eslint no-unused-vars: ["error", { "args": "none" }] */
export default { export default {
name: 'WootTabs', name: 'WootTabs',
props: { props: {
@ -8,7 +6,7 @@ export default {
default: 0, default: 0,
}, },
}, },
render(h) { render() {
const Tabs = this.$slots.default const Tabs = this.$slots.default
.filter( .filter(
node => node =>

View File

@ -1,7 +1,19 @@
/* eslint no-unused-vars: ["error", { "args": "none" }] */ <template>
/* eslint prefer-template: 0 */ <li
/* eslint no-console: 0 */ :class="{
/* eslint func-names: 0 */ 'tabs-title': true,
'is-active': active,
}"
>
<a @click="onTabClick">
{{ name }}
<span v-if="showBadge" class="badge">
{{ getItemCount }}
</span>
</a>
</li>
</template>
<script>
import TWEEN from 'tween.js'; import TWEEN from 'tween.js';
export default { export default {
@ -23,6 +35,10 @@ export default {
type: Number, type: Number,
default: 0, default: 0,
}, },
showBadge: {
type: Boolean,
default: true,
},
}, },
data() { data() {
@ -48,12 +64,12 @@ export default {
TWEEN.update(time); TWEEN.update(time);
animationFrame = window.requestAnimationFrame(animate); animationFrame = window.requestAnimationFrame(animate);
}; };
const that = this; const tweeningNumber = { value: oldValue };
new TWEEN.Tween({ tweeningNumber: oldValue }) new TWEEN.Tween(tweeningNumber)
.easing(TWEEN.Easing.Quadratic.Out) .easing(TWEEN.Easing.Quadratic.Out)
.to({ tweeningNumber: newValue }, 500) .to({ value: newValue }, 500)
.onUpdate(function() { .onUpdate(() => {
that.animatedNumber = this.tweeningNumber.toFixed(0); this.animatedNumber = tweeningNumber.value.toFixed(0);
}) })
.onComplete(() => { .onComplete(() => {
window.cancelAnimationFrame(animationFrame); window.cancelAnimationFrame(animationFrame);
@ -62,28 +78,13 @@ export default {
animationFrame = window.requestAnimationFrame(animate); animationFrame = window.requestAnimationFrame(animate);
}, },
}, },
methods: {
render(h) { onTabClick(event) {
return (
<li
class={{
'tabs-title': true,
'is-active': this.active,
'uk-disabled': this.disabled,
}}
>
<a
on-click={event => {
event.preventDefault(); event.preventDefault();
if (!this.disabled) { if (!this.disabled) {
this.$parent.$emit('change', this.index); this.$parent.$emit('change', this.index);
} }
}} },
>
{`${this.name}`}
<span class="badge">{this.getItemCount}</span>
</a>
</li>
);
}, },
}; };
</script>

View File

@ -1,35 +1,43 @@
<template> <template>
<div <div
class="small-3 columns channel" class="small-3 columns channel"
:class="{ inactive: !isActive(channel) }" :class="{ inactive: !isActive }"
@click="onItemClick" @click="onItemClick"
> >
<img <img
v-if="channel === 'facebook'" v-if="channel.key === 'facebook'"
src="~dashboard/assets/images/channels/facebook.png" src="~dashboard/assets/images/channels/facebook.png"
/> />
<img <img
v-if="channel === 'twitter'" v-if="channel.key === 'twitter'"
src="~dashboard/assets/images/channels/twitter.png" src="~dashboard/assets/images/channels/twitter.png"
/> />
<img <img
v-if="channel === 'telegram'" v-if="channel.key === 'telegram'"
src="~dashboard/assets/images/channels/telegram.png" src="~dashboard/assets/images/channels/telegram.png"
/> />
<img <img
v-if="channel === 'line'" v-if="channel.key === 'api'"
src="~dashboard/assets/images/channels/api.png"
/>
<img
v-if="channel.key === 'email'"
src="~dashboard/assets/images/channels/email.png"
/>
<img
v-if="channel.key === 'line'"
src="~dashboard/assets/images/channels/line.png" src="~dashboard/assets/images/channels/line.png"
/> />
<img <img
v-if="channel === 'website'" v-if="channel.key === 'website'"
src="~dashboard/assets/images/channels/website.png" src="~dashboard/assets/images/channels/website.png"
/> />
<img <img
v-if="channel === 'twilio'" v-if="channel.key === 'twilio'"
src="~dashboard/assets/images/channels/twilio.png" src="~dashboard/assets/images/channels/twilio.png"
/> />
<h3 class="channel__title"> <h3 class="channel__title">
{{ channel }} {{ channel.name }}
</h3> </h3>
</div> </div>
</template> </template>
@ -37,7 +45,7 @@
export default { export default {
props: { props: {
channel: { channel: {
type: String, type: Object,
required: true, required: true,
}, },
enabledFeatures: { enabledFeatures: {
@ -45,22 +53,28 @@ export default {
required: true, required: true,
}, },
}, },
methods: { computed: {
isActive(channel) { isActive() {
const { key } = this.channel;
if (Object.keys(this.enabledFeatures) === 0) { if (Object.keys(this.enabledFeatures) === 0) {
return false; return false;
} }
if (channel === 'facebook') { if (key === 'facebook') {
return this.enabledFeatures.channel_facebook; return this.enabledFeatures.channel_facebook;
} }
if (channel === 'twitter') { if (key === 'twitter') {
return this.enabledFeatures.channel_facebook; return this.enabledFeatures.channel_twitter;
} }
return ['website', 'twilio'].includes(channel); if (key === 'email') {
return this.enabledFeatures.channel_email;
}
return ['website', 'twilio', 'api'].includes(key);
}, },
},
methods: {
onItemClick() { onItemClick() {
if (this.isActive(this.channel)) { if (this.isActive) {
this.$emit('channel-item-click', this.channel); this.$emit('channel-item-click', this.channel.key);
} }
}, },
}, },

View File

@ -25,7 +25,7 @@
<multiselect <multiselect
v-model="currentChat.meta.assignee" v-model="currentChat.meta.assignee"
:options="agentList" :options="agentList"
label="name" label="available_name"
:allow-empty="true" :allow-empty="true"
deselect-label="Remove" deselect-label="Remove"
placeholder="Select Agent" placeholder="Select Agent"
@ -95,7 +95,7 @@ export default {
return [ return [
{ {
confirmed: true, confirmed: true,
name: 'None', available_name: 'None',
id: 0, id: 0,
role: 'agent', role: 'agent',
account_id: 0, account_id: 0,

View File

@ -30,7 +30,7 @@
</span> </span>
</div> </div>
<!-- No conversation selected --> <!-- No conversation selected -->
<div v-else-if="allConversations.length && currentChat.id === null"> <div v-else-if="allConversations.length && !currentChat.id">
<img src="~dashboard/assets/images/chat.svg" alt="No Chat" /> <img src="~dashboard/assets/images/chat.svg" alt="No Chat" />
<span>{{ $t('CONVERSATION.404') }}</span> <span>{{ $t('CONVERSATION.404') }}</span>
</div> </div>

View File

@ -5,6 +5,7 @@
<bubble-text <bubble-text
v-if="data.content" v-if="data.content"
:message="message" :message="message"
:is-email="isEmailContentType"
:readable-time="readableTime" :readable-time="readableTime"
/> />
<span v-if="hasAttachments"> <span v-if="hasAttachments">
@ -21,19 +22,29 @@
/> />
</span> </span>
</span> </span>
<i <bubble-actions
v-if="isPrivate" :id="data.id"
v-tooltip.top-start="toolTipMessage" :sender="data.sender"
class="icon ion-android-lock" :is-a-tweet="isATweet"
@mouseenter="isHovered = true" :is-email="isEmailContentType"
@mouseleave="isHovered = false" :is-private="data.private"
:message-type="data.message_type"
:readable-time="readableTime"
:source-id="data.source_id"
/> />
</p> </p>
<div v-if="isATweet && isIncoming && sender" class="sender--info">
<woot-thumbnail
:src="sender.thumbnail"
:username="sender.name"
size="16px"
/>
<div class="sender--available-name">
{{ sender.available_name || sender.name }}
</div>
</div>
</div> </div>
<!-- <img
src="https://randomuser.me/api/portraits/women/94.jpg"
class="sender--thumbnail"
/> -->
</li> </li>
</template> </template>
<script> <script>
@ -43,19 +54,27 @@ import timeMixin from '../../../mixins/time';
import BubbleText from './bubble/Text'; import BubbleText from './bubble/Text';
import BubbleImage from './bubble/Image'; import BubbleImage from './bubble/Image';
import BubbleFile from './bubble/File'; import BubbleFile from './bubble/File';
import contentTypeMixin from 'shared/mixins/contentTypeMixin';
import BubbleActions from './bubble/Actions';
import { MESSAGE_TYPE } from 'shared/constants/messageTypes';
export default { export default {
components: { components: {
BubbleActions,
BubbleText, BubbleText,
BubbleImage, BubbleImage,
BubbleFile, BubbleFile,
}, },
mixins: [timeMixin, messageFormatterMixin], mixins: [timeMixin, messageFormatterMixin, contentTypeMixin],
props: { props: {
data: { data: {
type: Object, type: Object,
required: true, required: true,
}, },
isATweet: {
type: Boolean,
default: false,
},
}, },
data() { data() {
return { return {
@ -64,7 +83,16 @@ export default {
}, },
computed: { computed: {
message() { message() {
return this.formatMessage(this.data.content); return this.formatMessage(this.data.content, this.isATweet);
},
sender() {
return this.data.sender || {};
},
contentType() {
const {
data: { content_type: contentType },
} = this;
return contentType;
}, },
alignBubble() { alignBubble() {
return !this.data.message_type ? 'left' : 'right'; return !this.data.message_type ? 'left' : 'right';
@ -75,6 +103,9 @@ export default {
isBubble() { isBubble() {
return [0, 1, 3].includes(this.data.message_type); return [0, 1, 3].includes(this.data.message_type);
}, },
isIncoming() {
return this.data.message_type === MESSAGE_TYPE.INCOMING;
},
hasAttachments() { hasAttachments() {
return !!(this.data.attachments && this.data.attachments.length > 0); return !!(this.data.attachments && this.data.attachments.length > 0);
}, },
@ -86,19 +117,14 @@ export default {
} }
return false; return false;
}, },
isPrivate() {
return this.data.private;
},
toolTipMessage() {
return this.data.private
? { content: this.$t('CONVERSATION.VISIBLE_TO_AGENTS'), classes: 'top' }
: false;
},
sentByMessage() { sentByMessage() {
return this.data.message_type === 1 && const { sender } = this;
!this.isHovered &&
this.data.sender !== undefined return this.data.message_type === 1 && !this.isHovered && sender
? { content: `Sent by: ${this.data.sender.name}`, classes: 'top' } ? {
content: `Sent by: ${sender.available_name || sender.name}`,
classes: 'top',
}
: false; : false;
}, },
wrapClass() { wrapClass() {
@ -110,7 +136,7 @@ export default {
bubbleClass() { bubbleClass() {
return { return {
bubble: this.isBubble, bubble: this.isBubble,
'is-private': this.isPrivate, 'is-private': this.data.private,
'is-image': this.hasImageAttachment, 'is-image': this.hasImageAttachment,
}; };
}, },
@ -120,17 +146,25 @@ export default {
}, },
}; };
</script> </script>
<style lang="scss" scoped> <style lang="scss">
@import '~dashboard/assets/scss/variables.scss'; .wrap > .is-image.bubble {
.wrap {
.is-image {
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
}
.image { .image {
max-width: 32rem; max-width: 32rem;
padding: 0; padding: 0;
} }
} }
.sender--info {
display: flex;
align-items: center;
padding: var(--space-smaller) 0;
.sender--available-name {
font-size: var(--font-size-mini);
margin-left: var(--space-smaller);
}
}
</style> </style>

View File

@ -5,9 +5,38 @@
:is-contact-panel-open="isContactPanelOpen" :is-contact-panel-open="isContactPanelOpen"
@contactPanelToggle="onToggleContactPanel" @contactPanelToggle="onToggleContactPanel"
/> />
<div v-if="!currentChat.can_reply" class="banner messenger-policy--banner">
<span>
{{ $t('CONVERSATION.CANNOT_REPLY') }}
<a
href="https://developers.facebook.com/docs/messenger-platform/policy/policy-overview/"
rel="noopener noreferrer nofollow"
target="_blank"
>
{{ $t('CONVERSATION.24_HOURS_WINDOW') }}
</a>
</span>
</div>
<div v-if="isATweet" class="banner">
<span v-if="!selectedTweetId">
{{ $t('CONVERSATION.LAST_INCOMING_TWEET') }}
</span>
<span v-else>
{{ $t('CONVERSATION.REPLYING_TO') }}
{{ selectedTweet }}
</span>
<button
v-if="selectedTweetId"
class="banner-close-button"
@click="removeTweetSelection"
>
<i v-tooltip="$t('CONVERSATION.REMOVE_SELECTION')" class="ion-close" />
</button>
</div>
<ul class="conversation-panel"> <ul class="conversation-panel">
<transition name="slide-up"> <transition name="slide-up">
<li> <li class="spinner--container">
<span v-if="shouldShowSpinner" class="spinner message" /> <span v-if="shouldShowSpinner" class="spinner message" />
</li> </li>
</transition> </transition>
@ -15,6 +44,7 @@
v-for="message in getReadMessages" v-for="message in getReadMessages"
:key="message.id" :key="message.id"
:data="message" :data="message"
:is-a-tweet="isATweet"
/> />
<li v-show="getUnreadCount != 0" class="unread--toast"> <li v-show="getUnreadCount != 0" class="unread--toast">
<span> <span>
@ -25,6 +55,7 @@
v-for="message in getUnReadMessages" v-for="message in getUnReadMessages"
:key="message.id" :key="message.id"
:data="message" :data="message"
:is-a-tweet="isATweet"
/> />
</ul> </ul>
<div class="conversation-footer"> <div class="conversation-footer">
@ -40,14 +71,14 @@
</div> </div>
<ReplyBox <ReplyBox
:conversation-id="currentChat.id" :conversation-id="currentChat.id"
@scrollToMessage="focusLastMessage" :in-reply-to="selectedTweetId"
@scrollToMessage="scrollToBottom"
/> />
</div> </div>
</div> </div>
</template> </template>
<script> <script>
/* global bus */
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import ConversationHeader from './ConversationHeader'; import ConversationHeader from './ConversationHeader';
@ -55,6 +86,7 @@ import ReplyBox from './ReplyBox';
import Message from './Message'; import Message from './Message';
import conversationMixin from '../../../mixins/conversations'; import conversationMixin from '../../../mixins/conversations';
import { getTypingUsersText } from '../../../helper/commons'; import { getTypingUsersText } from '../../../helper/commons';
import { BUS_EVENTS } from 'shared/constants/busEvents';
export default { export default {
components: { components: {
@ -81,6 +113,7 @@ export default {
isLoadingPrevious: true, isLoadingPrevious: true,
heightBeforeLoad: null, heightBeforeLoad: null,
conversationPanel: null, conversationPanel: null,
selectedTweetId: null,
}; };
}, },
@ -139,39 +172,82 @@ export default {
shouldLoadMoreChats() { shouldLoadMoreChats() {
return !this.listLoadingStatus && !this.isLoadingPrevious; return !this.listLoadingStatus && !this.isLoadingPrevious;
}, },
conversationType() {
const { additional_attributes: additionalAttributes } = this.currentChat;
const type = additionalAttributes ? additionalAttributes.type : '';
return type || '';
},
isATweet() {
return this.conversationType === 'tweet';
},
selectedTweet() {
if (this.selectedTweetId) {
const { messages = [] } = this.getMessages;
const [selectedMessage = {}] = messages.filter(
message => message.id === this.selectedTweetId
);
return selectedMessage.content || '';
}
return '';
},
},
watch: {
currentChat(newChat, oldChat) {
if (newChat.id === oldChat.id) {
return;
}
this.selectedTweetId = null;
},
}, },
created() { created() {
bus.$on('scrollToMessage', () => { bus.$on('scrollToMessage', () => {
this.focusLastMessage(); setTimeout(() => this.scrollToBottom(), 0);
this.makeMessagesRead(); this.makeMessagesRead();
}); });
bus.$on(BUS_EVENTS.SET_TWEET_REPLY, selectedTweetId => {
this.selectedTweetId = selectedTweetId;
});
}, },
methods: { mounted() {
focusLastMessage() { this.addScrollListener();
setTimeout(() => {
this.attachListner();
}, 0);
}, },
unmounted() {
this.removeScrollListener();
},
methods: {
addScrollListener() {
this.conversationPanel = this.$el.querySelector('.conversation-panel');
this.setScrollParams();
this.conversationPanel.addEventListener('scroll', this.handleScroll);
this.scrollToBottom();
this.isLoadingPrevious = false;
},
removeScrollListener() {
this.conversationPanel.removeEventListener('scroll', this.handleScroll);
},
scrollToBottom() {
this.conversationPanel.scrollTop = this.conversationPanel.scrollHeight;
},
onToggleContactPanel() { onToggleContactPanel() {
this.$emit('contactPanelToggle'); this.$emit('contactPanelToggle');
}, },
setScrollParams() {
attachListner() { this.heightBeforeLoad = this.conversationPanel.scrollHeight;
this.conversationPanel = this.$el.querySelector('.conversation-panel'); this.scrollTopBeforeLoad = this.conversationPanel.scrollTop;
this.heightBeforeLoad =
this.getUnreadCount === 0
? this.conversationPanel.scrollHeight
: this.$el.querySelector('.conversation-panel .unread--toast')
.offsetTop - 56;
this.conversationPanel.scrollTop = this.heightBeforeLoad;
this.conversationPanel.addEventListener('scroll', this.handleScroll);
this.isLoadingPrevious = false;
}, },
handleScroll(e) { handleScroll(e) {
this.setScrollParams();
const dataFetchCheck = const dataFetchCheck =
this.getMessages.dataFetched === true && this.shouldLoadMoreChats; this.getMessages.dataFetched === true && this.shouldLoadMoreChats;
if ( if (
@ -186,27 +262,53 @@ export default {
before: this.getMessages.messages[0].id, before: this.getMessages.messages[0].id,
}) })
.then(() => { .then(() => {
const heightDifference =
this.conversationPanel.scrollHeight - this.heightBeforeLoad;
this.conversationPanel.scrollTop = this.conversationPanel.scrollTop =
this.conversationPanel.scrollHeight - this.scrollTopBeforeLoad + heightDifference;
(this.heightBeforeLoad - this.conversationPanel.scrollTop);
this.isLoadingPrevious = false; this.isLoadingPrevious = false;
this.heightBeforeLoad = this.setScrollParams();
this.getUnreadCount === 0
? this.conversationPanel.scrollHeight
: this.$el.querySelector('.conversation-panel .unread--toast')
.offsetTop - 56;
}); });
} }
}, },
makeMessagesRead() { makeMessagesRead() {
if (this.getUnreadCount !== 0 && this.getMessages !== undefined) { this.$store.dispatch('markMessagesRead', { id: this.currentChat.id });
this.$store.dispatch('markMessagesRead', { },
id: this.currentChat.id, removeTweetSelection() {
lastSeen: this.getMessages.messages.last().created_at, this.selectedTweetId = null;
});
}
}, },
}, },
}; };
</script> </script>
<style scoped lang="scss">
.banner {
background: var(--b-500);
color: var(--white);
font-size: var(--font-size-mini);
padding: var(--space-slab) var(--space-normal);
text-align: center;
position: relative;
a {
text-decoration: underline;
color: var(--white);
font-size: var(--font-size-mini);
}
&.messenger-policy--banner {
background: var(--r-400);
}
.banner-close-button {
cursor: pointer;
margin-left: var(--space--two);
color: var(--white);
}
}
.spinner--container {
min-height: var(--space-jumbo);
}
</style>

View File

@ -17,7 +17,7 @@
ref="messageInput" ref="messageInput"
v-model="message" v-model="message"
class="input" class="input"
:placeholder="$t(messagePlaceHolder())" :placeholder="messagePlaceHolder"
:min-height="4" :min-height="4"
@focus="onFocus" @focus="onFocus"
@blur="onBlur" @blur="onBlur"
@ -25,46 +25,43 @@
<file-upload <file-upload
v-if="showFileUpload" v-if="showFileUpload"
:size="4096 * 4096" :size="4096 * 4096"
accept="jpg,jpeg,png,mp3,ogg,amr,pdf,mp4" accept="image/*, application/pdf, audio/mpeg, video/mp4, audio/ogg"
@input-file="onFileUpload" @input-file="onFileUpload"
> >
<i <i v-if="!isUploading" class="icon ion-android-attach attachment" />
v-if="!isUploading.image" <woot-spinner v-if="isUploading" />
class="icon ion-android-attach attachment"
/>
<woot-spinner v-if="isUploading.image" />
</file-upload> </file-upload>
<i <i
class="icon ion-happy-outline" class="icon ion-happy-outline"
:class="{ active: showEmojiPicker }" :class="{ active: showEmojiPicker }"
@click="toggleEmojiPicker()" @click="toggleEmojiPicker"
/> />
</div> </div>
<div class="reply-box__bottom"> <div class="reply-box__bottom">
<ul class="tabs"> <ul class="tabs">
<li class="tabs-title" :class="{ 'is-active': !isPrivate }"> <li class="tabs-title" :class="{ 'is-active': !isPrivate }">
<a href="#" @click="makeReply">{{ <a href="#" @click="setReplyMode">{{
$t('CONVERSATION.REPLYBOX.REPLY') $t('CONVERSATION.REPLYBOX.REPLY')
}}</a> }}</a>
</li> </li>
<li class="tabs-title is-private" :class="{ 'is-active': isPrivate }"> <li class="tabs-title is-private" :class="{ 'is-active': isPrivate }">
<a href="#" @click="makePrivate">{{ <a href="#" @click="setPrivateReplyMode">
$t('CONVERSATION.REPLYBOX.PRIVATE_NOTE') {{ $t('CONVERSATION.REPLYBOX.PRIVATE_NOTE') }}
}}</a> </a>
</li> </li>
<li v-if="message.length" class="tabs-title message-length"> <li v-if="message.length" class="tabs-title message-length">
<a :class="{ 'message-error': message.length > maxLength - 40 }"> <a :class="{ 'message-error': isMessageLengthReachingThreshold }">
{{ message.length }} / {{ maxLength }} {{ characterCountIndicator }}
</a> </a>
</li> </li>
</ul> </ul>
<button <button
type="button" type="button"
class="button send-button" class="button send-button"
:disabled="disableButton()" :disabled="isReplyButtonDisabled"
:class="{ :class="{
disabled: message.length === 0 || message.length > maxLength, disabled: isReplyButtonDisabled,
warning: isPrivate, warning: isPrivate,
}" }"
@click="sendMessage" @click="sendMessage"
@ -93,6 +90,13 @@ import FileUpload from 'vue-upload-component';
import EmojiInput from '../emoji/EmojiInput'; import EmojiInput from '../emoji/EmojiInput';
import CannedResponse from './CannedResponse'; import CannedResponse from './CannedResponse';
import ResizableTextArea from 'shared/components/ResizableTextArea'; import ResizableTextArea from 'shared/components/ResizableTextArea';
import {
isEscape,
isEnter,
hasPressedShift,
} from 'shared/helpers/KeyboardHelpers';
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
import inboxMixin from 'shared/mixins/inboxMixin';
export default { export default {
components: { components: {
@ -101,27 +105,55 @@ export default {
FileUpload, FileUpload,
ResizableTextArea, ResizableTextArea,
}, },
mixins: [clickaway], mixins: [clickaway, inboxMixin],
props: {
inReplyTo: {
type: [String, Number],
default: '',
},
},
data() { data() {
return { return {
message: '', message: '',
isPrivate: false, isPrivateTabActive: false,
isFocused: false, isFocused: false,
showEmojiPicker: false, showEmojiPicker: false,
showCannedResponsesList: false, showCannedResponsesList: false,
isUploading: { isUploading: false,
audio: false,
video: false,
image: false,
},
}; };
}, },
computed: { computed: {
...mapGetters({ ...mapGetters({ currentChat: 'getSelectedChat' }),
currentChat: 'getSelectedChat', isPrivate() {
}), if (this.currentChat.can_reply) {
channelType() { return this.isPrivateTabActive;
return this.currentChat.meta.channel; }
return true;
},
inboxId() {
return this.currentChat.inbox_id;
},
inbox() {
return this.$store.getters['inboxes/getInbox'](this.inboxId);
},
messagePlaceHolder() {
return this.isPrivate
? this.$t('CONVERSATION.FOOTER.PRIVATE_MSG_INPUT')
: this.$t('CONVERSATION.FOOTER.MSG_INPUT');
},
isMessageLengthReachingThreshold() {
return this.message.length > this.maxLength - 40;
},
characterCountIndicator() {
return `${this.message.length} / ${this.maxLength}`;
},
isReplyButtonDisabled() {
const isMessageEmpty = !this.message.trim().replace(/\n/g, '').length;
return (
isMessageEmpty ||
this.message.length === 0 ||
this.message.length > this.maxLength
);
}, },
conversationType() { conversationType() {
const { additional_attributes: additionalAttributes } = this.currentChat; const { additional_attributes: additionalAttributes } = this.currentChat;
@ -129,18 +161,29 @@ export default {
return type || ''; return type || '';
}, },
maxLength() { maxLength() {
if (this.channelType === 'Channel::FacebookPage') { if (this.isPrivate) {
return 640; return MESSAGE_MAX_LENGTH.GENERAL;
} }
if (this.channelType === 'Channel::TwitterProfile') {
if (this.isAFacebookInbox) {
return MESSAGE_MAX_LENGTH.FACEBOOK;
}
if (this.isATwilioSMSChannel) {
return MESSAGE_MAX_LENGTH.TWILIO_SMS;
}
if (this.isATwitterInbox) {
if (this.conversationType === 'tweet') { if (this.conversationType === 'tweet') {
return 280; return MESSAGE_MAX_LENGTH.TWEET;
} }
} }
return 10000; return MESSAGE_MAX_LENGTH.GENERAL;
}, },
showFileUpload() { showFileUpload() {
return this.channelType === 'Channel::WebWidget'; return (
this.isAWebWidgetInbox ||
this.isAFacebookInbox ||
this.isATwilioWhatsappChannel
);
}, },
replyButtonLabel() { replyButtonLabel() {
if (this.isPrivate) { if (this.isPrivate) {
@ -158,20 +201,25 @@ export default {
}, },
}, },
watch: { watch: {
message(val) { currentChat(conversation) {
if (conversation.can_reply) {
this.isPrivateTabActive = false;
} else {
this.isPrivateTabActive = true;
}
},
message(updatedMessage) {
if (this.isPrivate) { if (this.isPrivate) {
return; return;
} }
const isSlashCommand = val[0] === '/'; const isSlashCommand = updatedMessage[0] === '/';
const hasNextWord = val.includes(' '); const hasNextWord = updatedMessage.includes(' ');
const isShortCodeActive = isSlashCommand && !hasNextWord; const isShortCodeActive = isSlashCommand && !hasNextWord;
if (isShortCodeActive) { if (isShortCodeActive) {
this.showCannedResponsesList = true; this.showCannedResponsesList = true;
if (val.length > 1) { if (updatedMessage.length > 1) {
const searchKey = val.substr(1, val.length); const searchKey = updatedMessage.substr(1, updatedMessage.length);
this.$store.dispatch('getCannedResponse', { this.$store.dispatch('getCannedResponse', { searchKey });
searchKey,
});
} else { } else {
this.$store.dispatch('getCannedResponse'); this.$store.dispatch('getCannedResponse');
} }
@ -188,37 +236,33 @@ export default {
}, },
methods: { methods: {
handleKeyEvents(e) { handleKeyEvents(e) {
if (this.isEscape(e)) { if (isEscape(e)) {
this.hideEmojiPicker(); this.hideEmojiPicker();
this.hideCannedResponse(); this.hideCannedResponse();
} else if (this.isEnter(e)) { } else if (isEnter(e)) {
if (!e.shiftKey) { if (!hasPressedShift(e)) {
e.preventDefault(); e.preventDefault();
this.sendMessage(); this.sendMessage();
} }
} }
}, },
isEnter(e) {
return e.keyCode === 13;
},
isEscape(e) {
return e.keyCode === 27; // ESCAPE
},
async sendMessage() { async sendMessage() {
const isMessageEmpty = !this.message.replace(/\n/g, '').length; if (this.isReplyButtonDisabled) {
if (isMessageEmpty) return;
if (this.message.length > this.maxLength) {
return; return;
} }
const newMessage = this.message; const newMessage = this.message;
if (!this.showCannedResponsesList) { if (!this.showCannedResponsesList) {
this.clearMessage(); this.clearMessage();
try { try {
await this.$store.dispatch('sendMessage', { const messagePayload = {
conversationId: this.currentChat.id, conversationId: this.currentChat.id,
message: newMessage, message: newMessage,
private: this.isPrivate, private: this.isPrivate,
}); };
if (this.inReplyTo) {
messagePayload.contentAttributes = { in_reply_to: this.inReplyTo };
}
await this.$store.dispatch('sendMessage', messagePayload);
this.$emit('scrollToMessage'); this.$emit('scrollToMessage');
} catch (error) { } catch (error) {
// Error // Error
@ -231,12 +275,12 @@ export default {
this.message = message; this.message = message;
}, 100); }, 100);
}, },
makePrivate() { setPrivateReplyMode() {
this.isPrivate = true; this.isPrivateTabActive = true;
this.$refs.messageInput.focus(); this.$refs.messageInput.focus();
}, },
makeReply() { setReplyMode() {
this.isPrivate = false; this.isPrivateTabActive = false;
this.$refs.messageInput.focus(); this.$refs.messageInput.focus();
}, },
emojiOnClick(emoji) { emojiOnClick(emoji) {
@ -258,7 +302,6 @@ export default {
hideCannedResponse() { hideCannedResponse() {
this.showCannedResponsesList = false; this.showCannedResponsesList = false;
}, },
onBlur() { onBlur() {
this.isFocused = false; this.isFocused = false;
this.toggleTyping('off'); this.toggleTyping('off');
@ -267,9 +310,8 @@ export default {
this.isFocused = true; this.isFocused = true;
this.toggleTyping('on'); this.toggleTyping('on');
}, },
toggleTyping(status) { toggleTyping(status) {
if (this.channelType === 'Channel::WebWidget' && !this.isPrivate) { if (this.isAWebWidgetInbox && !this.isPrivate) {
const conversationId = this.currentChat.id; const conversationId = this.currentChat.id;
this.$store.dispatch('conversationTypingStatus/toggleTyping', { this.$store.dispatch('conversationTypingStatus/toggleTyping', {
status, status,
@ -277,35 +319,22 @@ export default {
}); });
} }
}, },
disableButton() {
const messageHasOnlyNewLines = !this.message.replace(/\n/g, '').length;
return (
this.message.length === 0 ||
this.message.length > 640 ||
messageHasOnlyNewLines
);
},
messagePlaceHolder() {
const placeHolder = this.isPrivate
? 'CONVERSATION.FOOTER.PRIVATE_MSG_INPUT'
: 'CONVERSATION.FOOTER.MSG_INPUT';
return placeHolder;
},
onFileUpload(file) { onFileUpload(file) {
if (!file) { if (!file) {
return; return;
} }
this.isUploading.image = true; this.isUploading = true;
this.$store this.$store
.dispatch('sendAttachment', [this.currentChat.id, { file: file.file }]) .dispatch('sendAttachment', [
this.currentChat.id,
{ file: file.file, isPrivate: this.isPrivate },
])
.then(() => { .then(() => {
this.isUploading.image = false; this.isUploading = false;
this.$emit('scrollToMessage'); this.$emit('scrollToMessage');
}) })
.catch(() => { .catch(() => {
this.isUploading.image = false; this.isUploading = false;
this.$emit('scrollToMessage'); this.$emit('scrollToMessage');
}); });
}, },

View File

@ -0,0 +1,173 @@
<template>
<div class="message-text--metadata">
<span class="time">{{ readableTime }}</span>
<i
v-if="isEmail"
v-tooltip.top-start="$t('CHAT_LIST.RECEIVED_VIA_EMAIL')"
class="ion ion-android-mail"
/>
<i
v-if="isPrivate"
v-tooltip.top-start="$t('CONVERSATION.VISIBLE_TO_AGENTS')"
class="icon ion-android-lock"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
/>
<i
v-if="isATweet && isIncoming"
v-tooltip.top-start="$t('CHAT_LIST.REPLY_TO_TWEET')"
class="icon ion-reply cursor-pointer"
@click="onTweetReply"
/>
<a :href="linkToTweet" target="_blank" rel="noopener noreferrer nofollow">
<i
v-if="isATweet && isIncoming"
v-tooltip.top-start="$t('CHAT_LIST.VIEW_TWEET_IN_TWITTER')"
class="icon ion-android-open cursor-pointer"
/>
</a>
</div>
</template>
<script>
import { MESSAGE_TYPE } from 'shared/constants/messageTypes';
import { BUS_EVENTS } from 'shared/constants/busEvents';
export default {
props: {
sender: {
type: Object,
default: () => ({}),
},
readableTime: {
type: String,
default: '',
},
isEmail: {
type: Boolean,
default: true,
},
isPrivate: {
type: Boolean,
default: true,
},
isATweet: {
type: Boolean,
default: true,
},
messageType: {
type: Number,
default: 1,
},
sourceId: {
type: String,
default: '',
},
id: {
type: [String, Number],
default: '',
},
},
computed: {
isIncoming() {
return MESSAGE_TYPE.INCOMING === this.messageType;
},
screenName() {
const { additional_attributes: additionalAttributes = {} } =
this.sender || {};
return additionalAttributes?.screen_name || '';
},
linkToTweet() {
const { screenName, sourceId } = this;
return `https://twitter.com/${screenName}/status/${sourceId}`;
},
},
methods: {
onTweetReply() {
bus.$emit(BUS_EVENTS.SET_TWEET_REPLY, this.id);
},
},
};
</script>
<style lang="scss" scoped>
.right {
.message-text--metadata {
.time {
color: var(--w-100);
}
}
}
.left {
.message-text--metadata {
.time {
color: var(--s-400);
}
}
}
.message-text--metadata {
align-items: flex-end;
display: flex;
.time {
margin-right: var(--space-small);
display: block;
font-size: var(--font-size-micro);
line-height: 1.8;
}
i {
line-height: 1.4;
padding-right: var(--space-small);
padding-left: var(--space-small);
color: var(--s-900);
}
a {
color: var(--s-900);
}
}
.activity-wrap {
.message-text--metadata {
display: inline-block;
.time {
color: var(--s-300);
font-size: var(--font-size-micro);
margin-left: var(--space-small);
}
}
}
.is-image {
.message-text--metadata {
.time {
bottom: var(--space-smaller);
color: var(--white);
position: absolute;
right: var(--space-small);
white-space: nowrap;
}
}
}
.is-private {
.message-text--metadata {
align-items: flex-end;
.time {
color: var(--s-400);
}
}
&.is-image {
.time {
position: inherit;
padding-left: var(--space-one);
}
}
}
</style>

View File

@ -1,41 +0,0 @@
<template>
<div class="audio message-text__wrap">
<a-player
:music="playerOptions"
mode="order"
/>
<span class="time">{{readableTime}}</span>
</div>
</template>
<script>
import APlayer from 'vue-aplayer';
export default {
components: {
APlayer,
},
props: [
'url',
'readableTime',
],
data() {
return {
musicObj: {
title: ' ',
author: ' ',
autoplay: false,
narrow: true,
},
};
},
computed: {
playerOptions() {
return {
...this.musicObj,
url: this.url,
};
},
},
};
</script>

View File

@ -16,13 +16,17 @@
{{ $t('CONVERSATION.DOWNLOAD') }} {{ $t('CONVERSATION.DOWNLOAD') }}
</a> </a>
</div> </div>
<span class="time">{{ readableTime }}</span>
</div> </div>
</template> </template>
<script> <script>
export default { export default {
props: ['url', 'readableTime'], props: {
url: {
type: String,
required: true,
},
},
computed: { computed: {
fileName() { fileName() {
const filename = this.url.substring(this.url.lastIndexOf('/') + 1); const filename = this.url.substring(this.url.lastIndexOf('/') + 1);
@ -31,7 +35,7 @@ export default {
}, },
methods: { methods: {
openLink() { openLink() {
const win = window.open(this.url, '_blank'); const win = window.open(this.url, '_blank', 'noopener');
win.focus(); win.focus();
}, },
}, },

View File

@ -1,25 +1,20 @@
<template> <template>
<div class="image message-text__wrap"> <div class="image message-text__wrap">
<img <img :src="url" @click="onClick" />
:src="url"
v-on:click="onClick"
/>
<span class="time">{{readableTime}}</span>
<woot-modal :show.sync="show" :on-close="onClose"> <woot-modal :show.sync="show" :on-close="onClose">
<img <img :src="url" class="modal-image" />
:src="url"
class="modal-image"
/>
</woot-modal> </woot-modal>
</div> </div>
</template> </template>
<script> <script>
export default { export default {
props: [ props: {
'url', url: {
'readableTime', type: String,
], required: true,
},
},
data() { data() {
return { return {
show: false, show: false,

View File

@ -1,36 +0,0 @@
<template>
<div class="map message-text__wrap">
<img
:src="locUrl"
/>
<span class="locname">{{label || ' '}}</span>
<span class="time">{{readableTime}}</span>
</div>
</template>
<script>
export default {
props: [
'lat',
'lng',
'label',
'readableTime',
],
data() {
return {
accessToken: 'pk.eyJ1IjoiY2hhdHdvb3QiLCJhIjoiY2oyazVsM3d0MDBmYjJxbmkyYXlwY3hzZyJ9.uWUdfItb0sSZQ4nfwlmuPg',
zoomLevel: 14,
mapType: 'mapbox.streets',
apiEndPoint: 'https://api.mapbox.com/v4/',
h: 100,
w: 150,
};
},
computed: {
locUrl() {
const { apiEndPoint, mapType, lat, lng, zoomLevel, h, w, accessToken } = this;
return `${apiEndPoint}${mapType}/${lng},${lat},${zoomLevel}/${w}x${h}.png?access_token=${accessToken}`;
},
},
};
</script>

View File

@ -1,12 +1,24 @@
<template> <template>
<span class="message-text__wrap"> <span class="message-text__wrap">
<span v-html="message"></span> <span v-html="message"></span>
<span class="time">{{ readableTime }}</span>
</span> </span>
</template> </template>
<script> <script>
export default { export default {
props: ['message', 'readableTime'], props: {
message: {
type: String,
default: '',
},
readableTime: {
type: String,
default: '',
},
isEmail: {
type: Boolean,
default: true,
},
},
}; };
</script> </script>

View File

@ -12,5 +12,6 @@ export default {
STATUS_TYPE: { STATUS_TYPE: {
OPEN: 'open', OPEN: 'open',
RESOLVED: 'resolved', RESOLVED: 'resolved',
BOT: 'bot',
}, },
}; };

View File

@ -1,6 +1,5 @@
import AuthAPI from '../api/auth'; import AuthAPI from '../api/auth';
import BaseActionCableConnector from '../../shared/helpers/BaseActionCableConnector'; import BaseActionCableConnector from '../../shared/helpers/BaseActionCableConnector';
/* global bus */
class ActionCableConnector extends BaseActionCableConnector { class ActionCableConnector extends BaseActionCableConnector {
constructor(app, pubsubToken) { constructor(app, pubsubToken) {

View File

@ -9,6 +9,7 @@ import ml from './locale/ml';
import pt from './locale/pt'; import pt from './locale/pt';
import pt_BR from './locale/pt_BR'; import pt_BR from './locale/pt_BR';
import ro from './locale/ro'; import ro from './locale/ro';
import fa from './locale/fa';
import ta from './locale/ta'; import ta from './locale/ta';
import it from './locale/it'; import it from './locale/it';
@ -24,6 +25,7 @@ export default {
pt_BR, pt_BR,
pt, pt,
ro, ro,
fa,
ta, ta,
it, it,
}; };

View File

@ -52,15 +52,15 @@
"LABEL": "Διαθεσιμότητα", "LABEL": "Διαθεσιμότητα",
"STATUSES_LIST": [ "STATUSES_LIST": [
{ {
"value": "στη γραμμή", "value": "online",
"label": "Στην Γραμμή" "label": "Στην Γραμμή"
}, },
{ {
"value": "απασχολημένος", "value": "busy",
"label": "Απασχολημένος" "label": "Απασχολημένος"
}, },
{ {
"value": "εκτός", "value": "offline",
"label": "Εκτός" "label": "Εκτός"
} }
] ]

View File

@ -10,7 +10,8 @@
"SEARCH": { "SEARCH": {
"INPUT": "Search for People, Chats, Saved Replies .." "INPUT": "Search for People, Chats, Saved Replies .."
}, },
"STATUS_TABS": [{ "STATUS_TABS": [
{
"NAME": "Open", "NAME": "Open",
"KEY": "openCount" "KEY": "openCount"
}, },
@ -19,8 +20,8 @@
"KEY": "allConvCount" "KEY": "allConvCount"
} }
], ],
"ASSIGNEE_TYPE_TABS": [
"ASSIGNEE_TYPE_TABS": [{ {
"NAME": "Mine", "NAME": "Mine",
"KEY": "me", "KEY": "me",
"COUNT_KEY": "mineCount" "COUNT_KEY": "mineCount"
@ -36,17 +37,20 @@
"COUNT_KEY": "allCount" "COUNT_KEY": "allCount"
} }
], ],
"CHAT_STATUS_ITEMS": [
"CHAT_STATUS_ITEMS": [{ {
"TEXT": "Open", "TEXT": "Open",
"VALUE": "open" "VALUE": "open"
}, },
{ {
"TEXT": "Resolved", "TEXT": "Resolved",
"VALUE": "resolved" "VALUE": "resolved"
},
{
"TEXT": "Bot",
"VALUE": "bot"
} }
], ],
"ATTACHMENTS": { "ATTACHMENTS": {
"image": { "image": {
"ICON": "ion-image", "ICON": "ion-image",
@ -72,6 +76,9 @@
"ICON": "ion-link", "ICON": "ion-link",
"CONTENT": "has shared a url" "CONTENT": "has shared a url"
} }
} },
"RECEIVED_VIA_EMAIL": "Received via email",
"VIEW_TWEET_IN_TWITTER": "View tweet in Twitter",
"REPLY_TO_TWEET": "Reply to this tweet"
} }
} }

View File

@ -9,6 +9,11 @@
"CLICK_HERE": "Click here", "CLICK_HERE": "Click here",
"LOADING_INBOXES": "Loading inboxes", "LOADING_INBOXES": "Loading inboxes",
"LOADING_CONVERSATIONS": "Loading Conversations", "LOADING_CONVERSATIONS": "Loading Conversations",
"CANNOT_REPLY": "You cannot reply due to",
"24_HOURS_WINDOW": "24 hour message window restriction",
"LAST_INCOMING_TWEET": "You are replying to the last incoming tweet",
"REPLYING_TO": "You are replying to:",
"REMOVE_SELECTION": "Remove Selection",
"DOWNLOAD": "Download", "DOWNLOAD": "Download",
"HEADER": { "HEADER": {
"RESOLVE_ACTION": "Resolve", "RESOLVE_ACTION": "Resolve",

View File

@ -24,8 +24,8 @@
"ERROR": "" "ERROR": ""
}, },
"DOMAIN": { "DOMAIN": {
"LABEL": "Domain", "LABEL": "Incoming Email Domain",
"PLACEHOLDER": "Your website domain", "PLACEHOLDER": "The domain where you will receive the emails",
"ERROR": "" "ERROR": ""
}, },
"SUPPORT_EMAIL": { "SUPPORT_EMAIL": {
@ -33,14 +33,9 @@
"PLACEHOLDER": "Your company's support email", "PLACEHOLDER": "Your company's support email",
"ERROR": "" "ERROR": ""
}, },
"ENABLE_DOMAIN_EMAIL": { "FEATURES": {
"LABEL": "Enable domain email", "INBOUND_EMAIL_ENABLED": "Conversation continuity with emails is enabled for your account.",
"PLACEHOLDER": "Enable the custom domain email", "CUSTOM_EMAIL_DOMAIN_ENABLED": "You can receive emails in your custom domain now."
"ERROR": "",
"OPTIONS": {
"ENABLED": "Enabled",
"DISABLED": "Disabled"
}
} }
} }
} }

View File

@ -115,6 +115,43 @@
"ERROR_MESSAGE": "We were not able to authenticate Twilio credentials, please try again" "ERROR_MESSAGE": "We were not able to authenticate Twilio credentials, please try again"
} }
}, },
"API_CHANNEL": {
"TITLE": "API Channel",
"DESC": "Integrate with API channel and start supporting your customers.",
"CHANNEL_NAME": {
"LABEL": "Channel Name",
"PLACEHOLDER": "Please enter a channel name",
"ERROR": "This field is required"
},
"WEBHOOK_URL": {
"LABEL": "Webhook URL",
"SUBTITLE": "Configure the URL where you want to recieve callbacks on events.",
"PLACEHOLDER": "Webhook URL"
},
"SUBMIT_BUTTON": "Create API Channel",
"API": {
"ERROR_MESSAGE": "We were not able to save the api channel"
}
},
"EMAIL_CHANNEL": {
"TITLE": "Email Channel",
"DESC": "Integrate you email inbox.",
"CHANNEL_NAME": {
"LABEL": "Channel Name",
"PLACEHOLDER": "Please enter a channel name",
"ERROR": "This field is required"
},
"EMAIL": {
"LABEL": "Email",
"SUBTITLE": "Email where your customers sends you support tickets",
"PLACEHOLDER": "Email"
},
"SUBMIT_BUTTON": "Create Email Channel",
"API": {
"ERROR_MESSAGE": "We were not able to save the email channel"
},
"FINISH_MESSAGE": "Start forwarding your emails to the following email address."
},
"AUTH": { "AUTH": {
"TITLE": "Channels", "TITLE": "Channels",
"DESC": "Currently we support Website live chat widgets, Facebook Pages and Twitter profiles as platforms. We have more platforms like Whatsapp, Email, Telegram and Line in the works, which will be out soon." "DESC": "Currently we support Website live chat widgets, Facebook Pages and Twitter profiles as platforms. We have more platforms like Whatsapp, Email, Telegram and Line in the works, which will be out soon."
@ -175,7 +212,17 @@
"ERROR_MESSAGE": "Could not delete inbox. Please try again later." "ERROR_MESSAGE": "Could not delete inbox. Please try again later."
} }
}, },
"TABS": {
"SETTINGS": "Settings", "SETTINGS": "Settings",
"COLLABORATORS": "Collaborators",
"CONFIGURATION": "Configuration"
},
"SETTINGS": "Settings",
"FEATURES": {
"LABEL": "Features",
"DISPLAY_FILE_PICKER": "Display file picker on the widget",
"DISPLAY_EMOJI_PICKER": "Display emoji picker on the widget"
},
"SETTINGS_POPUP": { "SETTINGS_POPUP": {
"MESSENGER_HEADING": "Messenger Script", "MESSENGER_HEADING": "Messenger Script",
"MESSENGER_SUB_HEAD": "Place this button inside your body tag", "MESSENGER_SUB_HEAD": "Place this button inside your body tag",

View File

@ -44,9 +44,14 @@
"LABEL": "Profile Image" "LABEL": "Profile Image"
}, },
"NAME": { "NAME": {
"LABEL": "Your name", "LABEL": "Your full name",
"ERROR": "Please enter a valid name", "ERROR": "Please enter a valid full name",
"PLACEHOLDER": "Please enter your name, this would be displayed in conversations" "PLACEHOLDER": "Please enter your full name"
},
"DISPLAY_NAME": {
"LABEL": "Display name",
"ERROR": "Please enter a valid display name",
"PLACEHOLDER": "Please enter a display name, this would be displayed in conversations"
}, },
"AVAILABILITY": { "AVAILABILITY": {
"LABEL": "Availability", "LABEL": "Availability",
@ -122,5 +127,22 @@
"INTEGRATIONS": "Integrations", "INTEGRATIONS": "Integrations",
"ACCOUNT_SETTINGS": "Account Settings", "ACCOUNT_SETTINGS": "Account Settings",
"LABELS": "Labels" "LABELS": "Labels"
},
"CREATE_ACCOUNT": {
"NEW_ACCOUNT": "New Account",
"SELECTOR_SUBTITLE": "Create a new account",
"API": {
"SUCCESS_MESSAGE": "Account created successfully",
"EXIST_MESSAGE": "Account already exists",
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
},
"FORM": {
"NAME": {
"LABEL": "Account Name",
"PLACEHOLDER": "Wayne Enterprises"
},
"SUBMIT": "Submit"
}
} }
} }

View File

@ -0,0 +1,102 @@
{
"AGENT_MGMT": {
"HEADER": "اپراتورها",
"HEADER_BTN_TXT": "اضافه کردن اپراتور",
"LOADING": "دریافت لیست اپراتورها",
"SIDEBAR_TXT": "<p><b>اپراتورها</b></p> <p> یک <b>اپراتور</b> یکی از اعضای تیم پشتیبانی است. </p><p> اپراتورها می‌توانند پیام‌های کاربران را ببینند و به آن‌ها پاسخ بدهند. این لیست حاوی تمام اپراتورهایی است که در حساب شما تعریف شده اند. </p><p> با زدن روی دکمه <b>اضافه کردن اپراتور</b> می‌توانید یک اپراتور جدید معرفی کنید. به ایمیل اپراتوری که معرفی می‌کنید یک دعوتنامه ارسال می‌شود که بعد از پذیرفتن آن اپراتور می‌تواند به پیام‌های کاربران پاسخ بدهد. </p><p> بسته به سطح دسترسی تعیین شده یک اپراتور می‌تواند به بخش‌های مشخصی از اکانت دسترسی پیدا کند </p><p> <b>اپراتور</b> - اپراتورهایی که این نقش را داشته باشند تنها می‌توانند به صندوق‌های ورودی، گزارشات و گفتگوها دسترسی داشته باشند. آن‌ها می‌توانند یک مکالمه را به اپراتور دیگر یا خودشان تخصیص دهند و یا یک مکالمه را حل شده اعلام کنند.</p><p> <b>مدیر</b> - مدیران می‌توانند علاوه بر تمام بخش‌هایی که یک اپراتور دسترسی دارد، به تمام بخش‌هایی که در حساب کاربری شما وجود دارد دسترسی داشته باشند.</p>",
"AGENT_TYPES": [
{
"name": "administrator",
"label": "مدیر"
},
{
"name": "agent",
"label": "اپراتور"
}
],
"LIST": {
"404": "در حال حاضر هیچ اپراتوری برای این حساب معرفی نشده است.",
"TITLE": "مدیریت اپراتورها",
"DESC": "می‌توانید به تیم‌تان اپراتور اضافه کرده یا اپراتورهای فعلی را حذف کنید",
"NAME": "نام",
"EMAIL": "ایمیل",
"STATUS": "وضعیت",
"ACTIONS": "عملیات",
"VERIFIED": "تایید شده",
"VERIFICATION_PENDING": "در انتظار تایید"
},
"ADD": {
"TITLE": "اضافه کردن اپراتور به تیم",
"DESC": "می‌توانید افرادی را معرفی کنید که مسئول پشتیبانی آنلاین صندوق‌های ورودی باشند",
"CANCEL_BUTTON_TEXT": "انصراف",
"FORM": {
"NAME": {
"LABEL": "اسم اپراتور",
"PLACEHOLDER": "لطفا اسم اپراتور را وارد نمایید"
},
"AGENT_TYPE": {
"LABEL": "نوع اپراتور",
"PLACEHOLDER": "لطفا نوع دسترسی اپراتور را مشخص کنید",
"ERROR": "تعیین کردن نوع اپراتور الزامی است"
},
"EMAIL": {
"LABEL": "ایمیل",
"PLACEHOLDER": "لطفا آدرس ایمیل اپراتور را وارد نمایید"
},
"SUBMIT": "اضافه کردن اپراتور"
},
"API": {
"SUCCESS_MESSAGE": "اپراتور معرفی شد",
"EXIST_MESSAGE": "این اپراتور قبلا معرفی شده، لطفا ایمیل دیگری را امتحان کنید",
"ERROR_MESSAGE": "متاسفانه ارتباط با سرور برقرار نشد، مجددا امتحان کنید"
}
},
"DELETE": {
"BUTTON_TEXT": "حذف",
"API": {
"SUCCESS_MESSAGE": "اپراتور حذف شد",
"ERROR_MESSAGE": "متاسفانه ارتباط با سرور برقرار نشد، مجددا امتحان کنید"
},
"CONFIRM": {
"TITLE": "تاییدیه حذف",
"MESSAGE": "مطمئن هستید که حذف شود ",
"YES": "بله، حذف شود ",
"NO": "نه، بماند "
}
},
"EDIT": {
"TITLE": "تغییر مشخصات اپراتور",
"FORM": {
"NAME": {
"LABEL": "اسم اپراتور",
"PLACEHOLDER": "لطفا اسم اپراتور را وارد کنید"
},
"AGENT_TYPE": {
"LABEL": "نوع اپراتور",
"PLACEHOLDER": "لطفا نوع اپراتور را انتخاب کنید",
"ERROR": "تعیین کردن نوع اپراتور الزامی است"
},
"EMAIL": {
"LABEL": "ایمیل",
"PLACEHOLDER": "لطفا ایمیل اپراتور را وارد کنید"
},
"SUBMIT": "تغییر اپراتور"
},
"BUTTON_TEXT": "ویرایش",
"CANCEL_BUTTON_TEXT": "انصراف",
"API": {
"SUCCESS_MESSAGE": "اطلاعات اپراتور تغییر یافت",
"ERROR_MESSAGE": "متاسفانه ارتباط با سرور برقرار نشد، مجددا امتحان کنید"
},
"PASSWORD_RESET": {
"ADMIN_RESET_BUTTON": "تغییر رمز عبور",
"ADMIN_SUCCESS_MESSAGE": "یک ایمیل حاوی روش تغییر دادن رمز عبور برای اپراتور ارسال شد",
"SUCCESS_MESSAGE": "تغییر رمز عبور اپراتور با موفقیت انجام شد",
"ERROR_MESSAGE": "متاسفانه ارتباط با سرور برقرار نشد، مجددا امتحان کنید"
}
},
"SEARCH": {
"NO_RESULTS": "اپراتوری یافت نشد."
}
}
}

View File

@ -0,0 +1,76 @@
{
"CANNED_MGMT": {
"HEADER": "پاسخ‌های آماده",
"HEADER_BTN_TXT": "اضافه کردن پاسخ آماده",
"LOADING": "دریافت پاسخ‌های آماده",
"SEARCH_404": "هیچ آیتمی با این مشخصات یافت نشد",
"SIDEBAR_TXT": "<p><b>پاسخ‌های آماده</b> </p><p> پاسخ‌های آماده قالب‌هایی متنی هستند که برای جواب دادن سریع به یک گفتگو به کار می‌آیند. </p><p> برای ساختن یک جواب آماده، روی دکمه <b>اضافه کردن جواب آماده</b> کلیک کنید. همچنین با زدن روی دکمه «تغییر» یا «حذف» می‌توانید یک پاسخ آماده را تغییر داده یا حذف کنید. </p><p> پاسخ‌های آماده با استفاده و با کمک <b>کدهای کوتاه</b> ساخته شده‌اند. اپراتورها با زدن کلید <b>'/'</b> از صفحه کلید می‌توانند به لیست پاسخ‌های آماده دسترسی پیدا کنند. </p>",
"LIST": {
"404": "هیچ پاسخ آماده‌ای برای این حساب تعریف نشده است",
"TITLE": "مدیریت پاسخ‌های آماده",
"DESC": "پاسخ‌های آماده قالب‌های متنی پیش آماده‌ای هستند که برای پاسخگویی سریع به یک گفتگو می‌توانند مفید واقع شوند",
"TABLE_HEADER": [
"کدهای کوتاه",
"محتوا",
"عملیات"
]
},
"ADD": {
"TITLE": "اضافه کردن پاسخ آماده",
"DESC": "پاسخ‌های آماده قالب‌های متنی پیش آماده‌ای هستند که برای پاسخگویی سریع به یک گفتگو می‌توانند مفید واقع شوند",
"CANCEL_BUTTON_TEXT": "انصراف",
"FORM": {
"SHORT_CODE": {
"LABEL": "کد کوتاه",
"PLACEHOLDER": "لطفا یک کد کوتاه وارد کنید",
"ERROR": "وجود کد کوتاه ضروری است"
},
"CONTENT": {
"LABEL": "محتوا",
"PLACEHOLDER": "لطفا محتوای این پاسخ را تایپ کنید",
"ERROR": "محتوا ضروری است"
},
"SUBMIT": "ثبت"
},
"API": {
"SUCCESS_MESSAGE": "پاسخ آماده با موفقیت ثبت شد",
"ERROR_MESSAGE": "متاسفانه ارتباط با سرور برقرار نشد، مجددا امتحان کنید"
}
},
"EDIT": {
"TITLE": "ویرایش پاسخ آماده",
"CANCEL_BUTTON_TEXT": "انصراف",
"FORM": {
"SHORT_CODE": {
"LABEL": "کد کوتاه",
"PLACEHOLDER": "لطفا یک کد کوتاه وارد کنید",
"ERROR": "وجود کد کوتاه ضروری است"
},
"CONTENT": {
"LABEL": "محتوا",
"PLACEHOLDER": "لطفا محتوای این پاسخ را تایپ کنید",
"ERROR": "محتوا ضروری است"
},
"SUBMIT": "ثبت"
},
"BUTTON_TEXT": "ویرایش",
"API": {
"SUCCESS_MESSAGE": "پاسخ آماده تغییر داده شد",
"ERROR_MESSAGE": "متاسفانه ارتباط با سرور برقرار نشد، مجددا امتحان کنید"
}
},
"DELETE": {
"BUTTON_TEXT": "حذف",
"API": {
"SUCCESS_MESSAGE": "پاسخ آماده با موفقیت حذف شد",
"ERROR_MESSAGE": "متاسفانه ارتباط با سرور برقرار نشد، مجددا امتحان کنید"
},
"CONFIRM": {
"TITLE": "تاییدیه حذف",
"MESSAGE": "مطمئن هستید حذف شود؟ ",
"YES": "بله، حذف شود ",
"NO": "نه، بماند "
}
}
}
}

View File

@ -0,0 +1,77 @@
{
"CHAT_LIST": {
"LOADING": "در حال دریافت گفتگوها",
"LOAD_MORE_CONVERSATIONS": "دریافت گفتگوهای بیشتر",
"EOF": "همه گفتگوها دریافت شدند 🎉",
"LIST": {
"404": "هیچ گفتگوی فعالی در این گروه نیست"
},
"TAB_HEADING": "گفتگوها",
"SEARCH": {
"INPUT": "پیدا کردن افراد، گفتگوها و پاسخ‌های از پیش نوشته شده..."
},
"STATUS_TABS": [{
"NAME": "باز",
"KEY": "openCount"
},
{
"NAME": "حل شده",
"KEY": "allConvCount"
}
],
"ASSIGNEE_TYPE_TABS": [{
"NAME": "من",
"KEY": "me",
"COUNT_KEY": "mineCount"
},
{
"NAME": "تخصیص داده نشده",
"KEY": "unassigned",
"COUNT_KEY": "unAssignedCount"
},
{
"NAME": "همه",
"KEY": "all",
"COUNT_KEY": "allCount"
}
],
"CHAT_STATUS_ITEMS": [{
"TEXT": "باز",
"VALUE": "open"
},
{
"TEXT": "حل شده",
"VALUE": "resolved"
}
],
"ATTACHMENTS": {
"image": {
"ICON": "ion-image",
"CONTENT": "پیام تصویری"
},
"audio": {
"ICON": "ion-volume-high",
"CONTENT": "پیام صوتی"
},
"video": {
"ICON": "ion-ios-videocam",
"CONTENT": "پیام ویدیویی"
},
"file": {
"ICON": "ion-document",
"CONTENT": "فایل الصاقی"
},
"location": {
"ICON": "ion-ios-location",
"CONTENT": "Location"
},
"fallback": {
"ICON": "ion-link",
"CONTENT": "یک آدرس URL به اشتراک گذاشته شده"
}
}
}
}

View File

@ -0,0 +1,21 @@
{
"CONTACT_PANEL": {
"CONVERSATION_TITLE": "جزئیات مکالمه",
"BROWSER": "مرورگر",
"OS": "سیستم عامل",
"INITIATED_FROM": "شروع شده از",
"INITIATED_AT": "شروع شده در",
"CONVERSATIONS": {
"NO_RECORDS_FOUND": "این اولین گفتگوی این کاربر است",
"TITLE": "گفتگوهای قبلی"
},
"LABELS": {
"TITLE": "برچسب‌های گفتگو",
"UPDATE_BUTTON": "تغییر برچسب‌ها",
"UPDATE_ERROR": "برچسب‌ها تغییری نکردند، لطفا بعدا امتحان کنید",
"TAG_PLACEHOLDER": "برچسب جدید",
"PLACEHOLDER": "پیدا کردن یا اضافه کردن برچسب"
},
"MUTE_CONTACT": "بی‌صدا کردن گفتگو"
}
}

View File

@ -0,0 +1,35 @@
{
"CONVERSATION": {
"404": "Please select a conversation from left pane",
"NO_MESSAGE_1": "Uh oh! Looks like there are no messages from customers in your inbox.",
"NO_MESSAGE_2": " to send a message to your page!",
"NO_INBOX_1": "Hola! Looks like you haven't added any inboxes yet.",
"NO_INBOX_2": " to get started",
"NO_INBOX_AGENT": "Uh Oh! Looks like you are not part of any inbox. Please contact your administrator",
"CLICK_HERE": "Click here",
"LOADING_INBOXES": "Loading inboxes",
"LOADING_CONVERSATIONS": "Loading Conversations",
"DOWNLOAD": "Download",
"HEADER": {
"RESOLVE_ACTION": "Resolve",
"REOPEN_ACTION": "Reopen",
"OPEN": "More",
"CLOSE": "Close",
"DETAILS": "details"
},
"FOOTER": {
"MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.",
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents"
},
"REPLYBOX": {
"REPLY": "Reply",
"PRIVATE_NOTE": "Private Note",
"SEND": "Send",
"CREATE": "Add Note",
"TWEET": "Tweet"
},
"VISIBLE_TO_AGENTS": "Private Note: Only visible to you and your team",
"CHANGE_STATUS": "Conversation status changed",
"CHANGE_AGENT": "Conversation Assignee changed"
}
}

View File

@ -0,0 +1,46 @@
{
"GENERAL_SETTINGS": {
"TITLE": "تنظیمات حساب",
"SUBMIT": "تغییر تنظیمات",
"UPDATE": {
"ERROR": "تنظیمات تغییری نکرد، دوباره امتحان کنید!",
"SUCCESS": "تنظیمات با موفقیت اعمال شد"
},
"FORM": {
"ERROR": "لطفا ایرادات فرم را برطرف کنید",
"GENERAL_SECTION": {
"TITLE": "تنظیمات عمومی",
"NOTE": ""
},
"NAME": {
"LABEL": "عنوان حساب",
"PLACEHOLDER": "عنوان حساب شما",
"ERROR": "لطفا عنوان حساب را به درستی وارد نمایید"
},
"LANGUAGE": {
"LABEL": "زبان سایت (آزمایشی)",
"PLACEHOLDER": "زبان نمایش المان‌های متنی سایت",
"ERROR": ""
},
"DOMAIN": {
"LABEL": "دامنه",
"PLACEHOLDER": "دامنه سایت شما",
"ERROR": ""
},
"SUPPORT_EMAIL": {
"LABEL": "ایمیل پشتیبانی",
"PLACEHOLDER": "ایمیل پشتیبانی شرکت شما",
"ERROR": ""
},
"ENABLE_DOMAIN_EMAIL": {
"LABEL": "فعال کردن ایمیل دامنه",
"PLACEHOLDER": "فعال کردن ایمیل دامنه‌های اختصاصی",
"ERROR": "",
"OPTIONS": {
"ENABLED": "فعال",
"DISABLED": "غیرفعال"
}
}
}
}
}

View File

@ -0,0 +1,191 @@
{
"INBOX_MGMT": {
"HEADER": "صندوق‌های ورودی",
"SIDEBAR_TXT": "<p><b>صندوق ورودی</b></p> <p> وقتی چت ووت به یک وب سایت یا یک صفحه فیس بوک متصل شود به آن <b>صندوق ورودی</b> می‌گوید. شما در حساب چت ووت خود می‌توانید بی‌نهایت صندوق ورودی داشته باشید. </p><p> روی دکمه <b>اضافه کردن صندوق ورودی</b> کلیک کنید تا به یک وب سایت یا یک صفحه فیس بوک وصل شوید. </p><p> در داشبورد، می‌توانید گفتگوهای همه صندوق‌های ورودی را یکجا ببینید و در تب «گفتگوها» به آن‌ها پاسخ بدهید. </p><p> همچنین می‌توانید با کلیک کردن روی اسم صندوق ورودی از قسمت سمت چپ، فقط گفتگوهای همان صندوق را ببینید. </p>",
"LIST": {
"404": "برای این حساب هیچ صندوق ورودی معرفی نشده است."
},
"CREATE_FLOW": [
{
"title": "کانال ورودی را انتخاب کنید",
"route": "settings_inbox_new",
"body": "جایی که قرار است امکان گفتگوی آنلاین در آنجا فراهم شود را انتخاب کنید"
},
{
"title": "ساخت صندوق ورودی",
"route": "settings_inboxes_page_channel",
"body": "به حساب کاربری وارد شوید و صندوق ورودی بسازید."
},
{
"title": "معرفی اپراتور",
"route": "settings_inboxes_add_agents",
"body": "اپراتورها را به صندوق ورودی ساخته شده تخصیص می‌دهد"
},
{
"title": "ماشالله!",
"route": "settings_inbox_finish",
"body": "دیگه می‌تونی بترکونی"
}
],
"ADD": {
"FB": {
"HELP": "پانویس: با وارد شدن ما فقط به پیام‌های صفحه دسترسی پیدا می‌کنیم. پیام‌های خصوصی شما را هرگز نخواهیم دید",
"CHOOSE_PAGE": "انتخاب صفحه",
"CHOOSE_PLACEHOLDER": "از لیست صفحه مورد نظر را انتخاب کنید",
"INBOX_NAME": "عنوان صندوق ورودی",
"ADD_NAME": "یک اسم به صندوق ورودی خود اضافه کنید",
"PICK_NAME": "یک اسم برای صندوق ورودی خود انتخاب کنید",
"PICK_A_VALUE": "یک مقدار انتخاب کنید"
},
"TWITTER": {
"HELP": "برای اضافه کردن امکان گفتگو از صفحه پروفایل توییترتان، لازم است با زدن دکمه `ورود با توییتر` پروفایل توییتر خود را شناسایی کنید' "
},
"WEBSITE_CHANNEL": {
"TITLE": "کانال وب سایت",
"DESC": "یک کانال به وب سایت خود بسازید تا مشتریان بتوانند از طریق ویجت سایت با شما گفتگو کنند.",
"LOADING_MESSAGE": "در حال ساخت کانال پشتیبانی آنلاین سایت",
"CHANNEL_AVATAR": {
"LABEL": "آواتار کانال"
},
"CHANNEL_NAME": {
"LABEL": "عنوان سایت",
"PLACEHOLDER": "عنوان سایت خود را وارد کنید (به عنوان مثال: Acme Inc)"
},
"CHANNEL_DOMAIN": {
"LABEL": "دامنه سایت",
"PLACEHOLDER": "دامنه سایت خود را وارد کنید (به عنوان مثال: acme.com)"
},
"CHANNEL_WELCOME_TITLE": {
"LABEL": "تیتر خوش آمدگویی",
"PLACEHOLDER": "سلام!"
},
"CHANNEL_WELCOME_TAGLINE": {
"LABEL": "زیرتیتر خوش آمدگویی",
"PLACEHOLDER": "دسترسی به ما ساده است. هر سوالی پیش آمد همینجا از ما بپرسید."
},
"CHANNEL_GREETING_MESSAGE": {
"LABEL": "پیام پاسخگویی کانال",
"PLACEHOLDER": "شرکت ما در اسرع وقت به پیام‌ها پاسخ می‌دهد"
},
"CHANNEL_GREETING_TOGGLE": {
"LABEL": "فعال کردن پیام پاسخگویی",
"HELP_TEXT": "به محض اینکه کاربر گفتگویی را شروع کرد، پیام مشخصی در جواب او ارسال می‌شود",
"ENABLED": "فعال",
"DISABLED": "غیرفعال"
},
"WIDGET_COLOR": {
"LABEL": "رنگ ویجت",
"PLACEHOLDER": "رنگی که در ویجت استفاده می‌شود را تعیین کنید"
},
"SUBMIT_BUTTON": "ساختن صندوق ورودی"
},
"TWILIO": {
"TITLE": "کانال اس ام اس تولیو/واتساپ",
"DESC": "به Twilio متصل شوید و مشتریان خود را از طریق پیامک یا واتساپ پشتیبانی کنید",
"ACCOUNT_SID": {
"LABEL": "شناسه SID",
"PLACEHOLDER": "لطفا شناسه SID حساب Twilio خود را وارد کنید",
"ERROR": "پر کردن این فیلد ضروری است"
},
"CHANNEL_TYPE": {
"LABEL": "نوع کانال",
"ERROR": "لطفا نوع کانال را انتخاب کنید"
},
"AUTH_TOKEN": {
"LABEL": "Auth توکن",
"PLACEHOLDER": "لطفا توکن Auth حساب Twilio خود را وارد کنید",
"ERROR": "پر کردن این فیلد ضروری است"
},
"CHANNEL_NAME": {
"LABEL": "عنوان کانال",
"PLACEHOLDER": "لطفا اسم یک کانال را وارد کنید",
"ERROR": "پر کردن این فیلد ضروری است"
},
"PHONE_NUMBER": {
"LABEL": "شماره تلفن",
"PLACEHOLDER": "لطفا شماره‌ای که پیام‌ می‌بایست به آن ارسال شود را وارد کنید",
"ERROR": "لطفا شماره تلفن را به شکل صحیح وارد کنید. شماره می‌بایست با کاراکتر `+` شروع شود"
},
"API_CALLBACK": {
"TITLE": "آدرس URL مربوط به API",
"SUBTITLE": "لازم است آدرس Callback URL موجود در Twilio را به آنچه که در اینجا ذکر شده تنظیم کنید"
},
"SUBMIT_BUTTON": "ساختن کانال Twilio",
"API": {
"ERROR_MESSAGE": "متاسفانه Twilio اطلاعات ارائه شده را تایید نمی کند، لطفا اصلاح و مجددا تلاش کنید"
}
},
"AUTH": {
"TITLE": "کانال‌ها",
"DESC": "در حال حاضر ما ویجت‌های پشتیبانی آنلاین سایت‌ها، صفحات فیس بوک و پروفایل‌های توییتر را پشتیبانی می‌کنیم. پلتفرم‌های دیگری مثل واتساپ، ایمیل، تلگرام و لاین در برنامه کاری ما قرار دارد و به زودی آماده خواهند شد."
},
"AGENTS": {
"TITLE": "اپراتورها",
"DESC": "در اینجا می‌توانید اپراتورها را به صندوق‌ ورودی خود اختصاص دهید. توجه داشته باشید که فقط اپراتورهایی که در اینجا معرفی شده باشند می‌توانند به پیام‌های این صندوق پاسخ بدهند.دیگر اپراتورها نخواهند توانست پیام‌های این صندوق را ببینید یا به آن‌ها پاسخی بدهند. <br> <b>پانویس:</b>به عنوان مدیر اگر می‌خواهید به همه صندوق‌های ورودی دسترسی داشته باشید می‌بایست خود را به عنوان اپراتور به همه صندوق‌ها اضافه کنید."
},
"DETAILS": {
"TITLE": "جزئیات صندوق‌ ورودی",
"DESC": "از کادر زیر صفحه فیس بوکی که می‌خواهید به چت ووت متصل شود انتخاب کنید. همچنین می‌توانید برای تشخیص بهتر یک اسم مشخص برای این صندوق تعیین کنید."
},
"FINISH": {
"TITLE": "ماشالله!",
"DESC": "چت ووت با موفقیت به فیس بوک متصل شد. از به بعد هر مشتری که پیامی در این صفحه بنویسید، آن پیام در صندوق ورودی چت ووت ظاهر می‌شود و گفتگویی ایجاد می‌شود..<br>فراموش نکنید که ما یک ویجت برای سایت‌ها هم داریم که با قرار دادن آن در سایت، مشتری می‌تواند به صورت آنلاین با شما گفتگو کند و پیام‌ها در صندوق ورودی آن ظاهر می‌شود<br>باحال نیست؟ ما همیشه باحالیم! :)"
}
},
"DETAILS": {
"LOADING_FB": "در حال احراز هویت با فیس بوک...",
"ERROR_FB_AUTH": "اشکالی پیش آمد.. لطفا دوباره سعی کنید...",
"CREATING_CHANNEL": "در حال ساخت صندوق ورودی...",
"TITLE": "تنظیمات صفحه ورودی",
"DESC": ""
},
"AGENTS": {
"BUTTON_TEXT": "اضافه کردن اپراتور",
"ADD_AGENTS": "اضافه کردن اپراتور به صندوق ورودی..."
},
"FINISH": {
"TITLE": "صندوق ورودی حاضره!",
"MESSAGE": "حالا از طریق این کانال جدید می‌توانید با مشتریان صحبت کنید. به امید موفقیت ",
"BUTTON_TEXT": "نشانم بده",
"WEBSITE_SUCCESS": "ساختن کانال وب سایت با موفقیت انجام شد. قطعه کد زیر را کپی کرده و در سایت خود قرار دهید. در صورتیکه مشتری از ویجت پشتیبانی آنلاین استفاده کند گفتگوی شما در این صندوق ورودی ظاهر می‌شود."
},
"REAUTH": "احراز هویت مجدد",
"VIEW": "نمایش",
"EDIT": {
"API": {
"SUCCESS_MESSAGE": "تنظمیات صندوق ورودی اعمال شد",
"AUTO_ASSIGNMENT_SUCCESS_MESSAGE": "وضعیت واگذاری خودکار گفتگو به اپراتورها تنظیم شد",
"ERROR_MESSAGE": "در حال حاضر امکان تغییر رنگ ویجت امکان‌پذیر نیست. لطفا بعدا امتحان کنید."
},
"AUTO_ASSIGNMENT": {
"ENABLED": "فعال",
"DISABLED": "غیرفعال"
}
},
"DELETE": {
"BUTTON_TEXT": "حذف",
"CONFIRM": {
"TITLE": "تاییدیه حذف",
"MESSAGE": "مطمئن هستید که حذف شود ",
"YES": "بله، حذف شود ",
"NO": "نه، بماند "
},
"API": {
"SUCCESS_MESSAGE": "صندوق ورودی حذف شد",
"ERROR_MESSAGE": "صندوق ورودی حذف نشد، لطفا بعدا امتحان کنید"
}
},
"SETTINGS": "تنظیمات",
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "اسکریپت ویجت",
"MESSENGER_SUB_HEAD": "این دکمه را در تگ body قرار دهید",
"INBOX_AGENTS": "اپراتورها",
"INBOX_AGENTS_SUB_TEXT": "اضافه کردن یا حذف کردن دسترسی اپراتور به صندوق ورودی",
"UPDATE": "اعمال شود",
"AUTO_ASSIGNMENT": "فعال کردن واگذاری خودکار گفتگو به اپراتورها",
"INBOX_UPDATE_TITLE": "تنظیمات صندوق ورودی",
"INBOX_UPDATE_SUB_TEXT": "تغییر پارامترهای صندوق ورودی",
"AUTO_ASSIGNMENT_SUB_TEXT": "فعال کردن یا غیرفعال کردن واگذاری خودکار گفتگوها به اپراتورهای عضو این صندوق ورودی."
}
}
}

View File

@ -0,0 +1,32 @@
/* eslint-disable */
import { default as _agentMgmt } from './agentMgmt.json';
import { default as _cannedMgmt } from './cannedMgmt.json';
import { default as _chatlist } from './chatlist.json';
import { default as _contact } from './contact.json';
import { default as _conversation } from './conversation.json';
import { default as _inboxMgmt } from './inboxMgmt.json';
import { default as _login } from './login.json';
import { default as _report } from './report.json';
import { default as _resetPassword } from './resetPassword.json';
import { default as _setNewPassword } from './setNewPassword.json';
import { default as _settings } from './settings.json';
import { default as _signup } from './signup.json';
import { default as _integrations } from './integrations.json';
import { default as _generalSettings } from './generalSettings.json';
export default {
..._agentMgmt,
..._cannedMgmt,
..._chatlist,
..._contact,
..._conversation,
..._inboxMgmt,
..._login,
..._report,
..._resetPassword,
..._setNewPassword,
..._settings,
..._signup,
..._integrations,
..._generalSettings,
};

View File

@ -0,0 +1,54 @@
{
"INTEGRATION_SETTINGS": {
"HEADER": "برنامه‌های تلفیق شده",
"WEBHOOK": {
"TITLE": "وب هوک",
"CONFIGURE": "تنظیمات",
"HEADER": "تنظیمات وب هوک",
"HEADER_BTN_TXT": "اضافه کردن یک وب هوک جدید",
"INTEGRATION_TXT": "وب هوک، امکان دسترسی لحظه‌ای به اطلاعات حساب چت وت شما را فراهم می‌کند. وب هوک‌ها را می‌توان به دیگر ابزارها مثل اسلک یا گیت‌هاب متصل کرد. دکمه تنظیمات را بزنید تا وب هوک خود را تنظیم کنید ",
"LOADING": "درحال دریافت اطلاعات وب هوک",
"SEARCH_404": "هیچ گزینه‌ای با این شرایط پیدا نشد",
"SIDEBAR_TXT": "<p><b>وب هوک‌ها</b> </p> <p>وب هوک‌ها اجرا کننده‌ی درخواست‌های HTTP هستند که برای هر حسابی قابل تنظیم شدن هستند. به عنوان مثال می‌توان وقتی گفتگوی جدیدی ایجاد شد یک وب سرویس صدا زده شود. برای هر حساب می‌توان چند وب هوک ایجاد کرد. <br /><br /> برای ساختن یک <b>وب هوک</b>, روی دکمه <b>اضافه کردن وب هوک جدید</b> کلیک کنید. همچنین با زدن دکمه «حذف» می‌توانید وب هوک ساخته شده را حذف کنید.</p>",
"LIST": {
"404": "هیچ وب هوکی برای این حساب ساخته نشده است",
"TITLE": "مدیریت وب هوک‌ها",
"DESC": "وب هوک‌ها قالب‌های پاسخگویی آماده‌ای هستند که می‌توانند برای پاسخگویی سریع به تیکت‌ها به کار گرفته شوند.",
"TABLE_HEADER": [
"آدرس مقصد وب هوک",
"رویدادها"
]
},
"ADD": {
"CANCEL": "انصراف",
"TITLE": "اضافه کردن وب هوک جدید",
"DESC": "رویدادهای وب هوک اطلاعات لحظه‌ای حساب چت ووت شما را منتقل می‌کنند. لطفا آدرس URL صحیحی وارد کنید.",
"FORM": {
"END_POINT": {
"LABEL": "آدرس URL وب هوک",
"PLACEHOLDER": "به عنوان مثال: https://example/api/webhook",
"ERROR": "لطفا آدرس URL صحیحی وارد کنید"
},
"SUBMIT": "ساخت وب هوک"
},
"API": {
"SUCCESS_MESSAGE": "وب هوک ساخته شد",
"ERROR_MESSAGE": "متاسفانه ارتباط با سرور برقرار نشد، مجددا امتحان کنید"
}
},
"DELETE": {
"BUTTON_TEXT": "حذف",
"API": {
"SUCCESS_MESSAGE": "وب هوک حذف شد",
"ERROR_MESSAGE": "متاسفانه ارتباط با سرور برقرار نشد، مجددا امتحان کنید"
},
"CONFIRM": {
"TITLE": "تاییدیه حذف",
"MESSAGE": "مطمئن هستید که می‌خواهید حذف شود ",
"YES": "بله، حذف شود",
"NO": "خیر، بماند"
}
}
}
}
}

View File

@ -0,0 +1,21 @@
{
"LOGIN": {
"TITLE": "ورود به چت ووت",
"EMAIL": {
"LABEL": "ایمیل",
"PLACEHOLDER": "ایمیل به عنوان مثال: someone@example.com"
},
"PASSWORD": {
"LABEL": "رمز عبور",
"PLACEHOLDER": "رمز عبور"
},
"API": {
"SUCCESS_MESSAGE": "ورود موفق",
"ERROR_MESSAGE": "متاسفانه ارتباط با سرور برقرار نشد، مجددا امتحان کنید",
"UNAUTH": "این نام کاربری و رمز عبور صحیح نیست"
},
"FORGOT_PASSWORD": "رمز عبورتان را فراموش کردید؟",
"CREATE_NEW_ACCOUNT": "حساب جدید بسازید",
"SUBMIT": "ورود"
}
}

View File

@ -0,0 +1,19 @@
{
"REPORT": {
"HEADER": "گزارشات",
"LOADING_CHART": "در حال دریافت اطلاعات...",
"NO_ENOUGH_DATA": "متاسفانه اطلاعات کافی دریافت نشد، لطفا بعدا دوباره امتحان کنید",
"METRICS": [
{ "NAME": "گفتگوها", "KEY": "conversations_count", "DESC": "( جمع کل )" },
{ "NAME": "پیام‌های ورودی", "KEY": "incoming_messages_count", "DESC": "( جمع کل )" },
{ "NAME": "پیام‌های خروجی", "KEY": "outgoing_messages_count", "DESC": "( جمع کل )" },
{ "NAME": "زمان تا اولین پاسخ", "KEY": "avg_first_response_time", "DESC": "( میانگین )" },
{ "NAME": "زمان تا حل شدن مساله", "KEY": "avg_resolution_time", "DESC": "( میانگین )" },
{ "NAME": "تعداد مسائل حل شده", "KEY": "resolutions_count", "DESC": "( جمع کل )" }
],
"DATE_RANGE": [
{ "id": 0, "name": "در ۷ روز گذشته" },
{ "id": 1, "name": "در ۳۰ روز گذشته" }
]
}
}

View File

@ -0,0 +1,15 @@
{
"RESET_PASSWORD": {
"TITLE": "ریست کردن رمز عبور",
"EMAIL": {
"LABEL": "ایمیل",
"PLACEHOLDER": "لطفا ایمیل خود را وارد کنید",
"ERROR": "ظاهرا این ایمیل صحیح نیست، لطفا اصلاح کنید"
},
"API": {
"SUCCESS_MESSAGE": "لینک ریست کردن رمز عبور به ایمیلتان ارسال شد",
"ERROR_MESSAGE": "ارتباط با سرور برقرار نشد، لطفا مجددا امتحان کنید"
},
"SUBMIT": "ثبت"
}
}

View File

@ -0,0 +1,20 @@
{
"SET_NEW_PASSWORD": {
"TITLE": "گذاشتن رمز جدید",
"PASSWORD": {
"LABEL": "رمز عبور",
"PLACEHOLDER": "رمز عبور",
"ERROR": "رمز عبور خیلی کوتاه است"
},
"CONFIRM_PASSWORD": {
"LABEL": "تکرار رمز عبور",
"PLACEHOLDER": "لطفا رمز عبور را مجددا وارد کنید",
"ERROR": "تکرار رمز عبور می‌بایست با رمز عبور یکسان باشد"
},
"API": {
"SUCCESS_MESSAGE": "رمز عوض شد",
"ERROR_MESSAGE": "ارتباط با سرور برقرار نشد، لطفا مجددا امتحان کنید"
},
"SUBMIT": "ثبت"
}
}

View File

@ -0,0 +1,108 @@
{
"PROFILE_SETTINGS": {
"LINK": "تنظیمات پروفایل",
"TITLE": "تنظیمات پروفایل",
"BTN_TEXT": "تغییر دادن پروفایل",
"AFTER_EMAIL_CHANGED": "پروفایلتان با موفقیت تغییر یافت، اطلاعات ورود به سیستم تغییر کرده لذا لطفا مجددا به سیستم وارد شوید",
"FORM": {
"AVATAR": "عکس پروفایل",
"ERROR": "لطفا ایرادات ذکر شده را برطرف کنید",
"REMOVE_IMAGE": "حذف",
"UPLOAD_IMAGE": "آپلود عکس",
"UPDATE_IMAGE": "تغییر عکس",
"PROFILE_SECTION": {
"TITLE": "پروفایل",
"NOTE": "ایمیل عامل شناسایی شما برای ورود به سیستم است"
},
"PASSWORD_SECTION": {
"TITLE": "رمز عبور",
"NOTE": "تغییر دادن رمز عبور باعث می‌شود مجبور شوید دوباره به سیستم وارد شوید"
},
"ACCESS_TOKEN": {
"TITLE": "توکن دسترسی",
"NOTE": "از این توکن برای دسترسی از طریق API استفاده می‌شود"
},
"EMAIL_NOTIFICATIONS_SECTION": {
"TITLE": "اعلامیه‌ به ایمیل",
"NOTE": "اینجا می‌توانید تنظیمات اعلامیه‌هایی که به ایمیل ارسال می‌شود تغییر دهید",
"CONVERSATION_ASSIGNMENT": "هر وقت گفتگویی به من اختصاص داده شد،‌ ایمیل بفرست",
"CONVERSATION_CREATION": "هر وقت گفتگوی جدیدی شروع شد برای من ایمیل بفرست"
},
"API": {
"UPDATE_SUCCESS": "تغییرات تنظیمات اعلامیه‌ها با موفقیت ثبت شد",
"UPDATE_ERROR": "در ثبت تغییرات اعلامیه‌ها اشکالی پیش آمد، لطفا دوباره امتحان کنید"
},
"PUSH_NOTIFICATIONS_SECTION": {
"TITLE": "پوش نوتیفیکیشن",
"NOTE": "اینجا می‌توانید تنظیمات پوش نوتیفیکیشن را تغییر دهید",
"CONVERSATION_ASSIGNMENT": "هر وقت گفتگویی به من اختصاص داده شد، برای من پوش نوتیفیکیشن بفرست",
"CONVERSATION_CREATION": "هر وقت گفتگوی جدیدی شروع شد برای من پوش نوتیفیکیشن بفرست",
"HAS_ENABLED_PUSH": "در این مرورگر پوش نوتیفیکیشن را فعال کرده‌اید",
"REQUEST_PUSH": "فعال کردن پوش نوتیفیکیشن"
},
"PROFILE_IMAGE": {
"LABEL": "عکس پروفایل"
},
"NAME": {
"LABEL": "اسم شما",
"ERROR": "لطفا اسم خود را به شکل صحیح وارد کنید",
"PLACEHOLDER": "لطفا اسم خود را وارد کنید، این اسم در گفتگوها دیده می‌شود"
},
"EMAIL": {
"LABEL": "ایمیل شما",
"ERROR": "لطفا ایمیل خود را به شکل صحیح وارد کنید",
"PLACEHOLDER": "لطفا ایمیل خود را وارد کنید، این ایمیل در گفتگوها دیده می‌شود"
},
"PASSWORD": {
"LABEL": "رمز عبور",
"ERROR": "رمز عبور می‌بایست ۶ کاراکتر یا بیشتر باشد",
"PLACEHOLDER": "لطفا رمز عبور جدیدی وارد کنید"
},
"PASSWORD_CONFIRMATION": {
"LABEL": "تکرار رمز عبور",
"ERROR": "تکرار رمز عبور می‌بایست با رمز عبور یکسان باشد",
"PLACEHOLDER": "لطفا رمز عبور را مجددا وارد کنید"
}
}
},
"SIDEBAR_ITEMS": {
"CHANGE_ACCOUNTS": "سوییچ به یک حساب دیگر",
"SELECTOR_SUBTITLE": "از لیست یکی از حساب‌ها را انتخاب کنید",
"PROFILE_SETTINGS": "تنظیمات پروفایل",
"LOGOUT": "خروج از حساب"
},
"APP_GLOBAL": {
"TRIAL_MESSAGE": "روز تا اتمام دوره آزمایشی باقی است.",
"TRAIL_BUTTON": "الان بخرید"
},
"COMPONENTS": {
"CODE": {
"BUTTON_TEXT": "کپی",
"COPY_SUCCESSFUL": "کد به حافظه کپی شد"
},
"FILE_BUBBLE": {
"DOWNLOAD": "دانلود",
"UPLOADING": "در حال آپلود..."
},
"FORM_BUBBLE": {
"SUBMIT": "ثبت"
}
},
"CONFIRM_EMAIL": "در حال تایید...",
"SETTINGS": {
"INBOXES": {
"NEW_INBOX": "اضافه کردن صندوق ورودی"
}
},
"SIDEBAR": {
"CONVERSATIONS": "گفتگوها",
"REPORTS": "گزارشات",
"SETTINGS": "تنظیمات",
"HOME": "صفحه اصلی",
"AGENTS": "اپراتورها",
"INBOXES": "صندوق‌های ورودی",
"CANNED_RESPONSES": "پاسخ‌های آماده",
"INTEGRATIONS": "برنامه‌های تلفیق شده",
"ACCOUNT_SETTINGS": "تنظیمات حساب"
}
}

View File

@ -0,0 +1,32 @@
{
"REGISTER": {
"TRY_WOOT": "ثبت حساب",
"TITLE": "ثبت نام",
"TERMS_ACCEPT": "با ثبت نام، اعلام می‌دارید که <a href=\"https://www.chatwoot.com/terms\">قوانین</a> و <a href=\"https://www.chatwoot.com/privacy-policy\">شرایط استفاده</a> از ما را تایید کرده و می‌پذیرید.",
"ACCOUNT_NAME": {
"LABEL": "عنوان حساب",
"PLACEHOLDER": "شرکت ایران ناسیونال",
"ERROR": "عنوان خیلی کوتاه است"
},
"EMAIL": {
"LABEL": "ایمیل",
"PLACEHOLDER": "bruce@wayne.enterprises",
"ERROR": "ایمیل اشتباه است"
},
"PASSWORD": {
"LABEL": "رمز عبور",
"PLACEHOLDER": "رمز عبور",
"ERROR": "رمز عبور خیلی کوتاه است"
},
"CONFIRM_PASSWORD": {
"LABEL": "تکرار رمز عبور",
"PLACEHOLDER": "تکرار رمز عبور",
"ERROR": "رمز عبور و تکرار رمز عبور یکسان نیستند"
},
"API": {
"SUCCESS_MESSAGE": "ثبت نام با موفقیت انجام شد",
"ERROR_MESSAGE": "ارتباط با سرور برقرار نشد، لطفا بعدا امتحان کنید"
},
"SUBMIT": "ثبت"
}
}

View File

@ -0,0 +1,5 @@
{
"WEBHOOKS_SETTINGS": {
"HEADER": "تنظیمات وب هوک"
}
}

View File

@ -13,7 +13,7 @@
<div class="row align-center"> <div class="row align-center">
<div class="medium-5 column"> <div class="medium-5 column">
<ul class="signup--features"> <ul class="signup--features">
<li><i class="ion-beer beer"></i>Unlimited Facebook Pages</li> <li><i class="ion-beer beer"></i>Unlimited inboxes</li>
<li><i class="ion-stats-bars report"></i>Robust Reporting</li> <li><i class="ion-stats-bars report"></i>Robust Reporting</li>
<li><i class="ion-chatbox-working canned"></i>Canned Responses</li> <li><i class="ion-chatbox-working canned"></i>Canned Responses</li>
<li><i class="ion-loop uptime"></i>Auto Assignment</li> <li><i class="ion-loop uptime"></i>Auto Assignment</li>

View File

@ -106,6 +106,9 @@ export default {
this.isEditing = false; this.isEditing = false;
}, },
async fetchLabels(conversationId) { async fetchLabels(conversationId) {
if (!conversationId) {
return;
}
this.$store.dispatch('conversationLabels/get', conversationId); this.$store.dispatch('conversationLabels/get', conversationId);
}, },
}, },

View File

@ -36,7 +36,15 @@
{{ $t('GENERAL_SETTINGS.FORM.LANGUAGE.ERROR') }} {{ $t('GENERAL_SETTINGS.FORM.LANGUAGE.ERROR') }}
</span> </span>
</label> </label>
<label> <label v-if="featureInboundEmailEnabled">
{{ $t('GENERAL_SETTINGS.FORM.FEATURES.INBOUND_EMAIL_ENABLED') }}
</label>
<label v-if="featureCustomDomainEmailEnabled">
{{
$t('GENERAL_SETTINGS.FORM.FEATURES.CUSTOM_EMAIL_DOMAIN_ENABLED')
}}
</label>
<label v-if="featureCustomDomainEmailEnabled">
{{ $t('GENERAL_SETTINGS.FORM.DOMAIN.LABEL') }} {{ $t('GENERAL_SETTINGS.FORM.DOMAIN.LABEL') }}
<input <input
v-model="domain" v-model="domain"
@ -44,29 +52,7 @@
:placeholder="$t('GENERAL_SETTINGS.FORM.DOMAIN.PLACEHOLDER')" :placeholder="$t('GENERAL_SETTINGS.FORM.DOMAIN.PLACEHOLDER')"
/> />
</label> </label>
<label v-if="featureInboundEmailEnabled"> <label v-if="featureCustomDomainEmailEnabled">
{{ $t('GENERAL_SETTINGS.FORM.ENABLE_DOMAIN_EMAIL.LABEL') }}
<select v-model="domainEmailsEnabled">
<option value="true">
{{
$t(
'GENERAL_SETTINGS.FORM.ENABLE_DOMAIN_EMAIL.OPTIONS.ENABLED'
)
}}
</option>
<option value="false">
{{
$t(
'GENERAL_SETTINGS.FORM.ENABLE_DOMAIN_EMAIL.OPTIONS.DISABLED'
)
}}
</option>
</select>
<p class="help-text">
{{ $t('GENERAL_SETTINGS.FORM.ENABLE_DOMAIN_EMAIL.PLACEHOLDER') }}
</p>
</label>
<label v-if="featureInboundEmailEnabled">
{{ $t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.LABEL') }} {{ $t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.LABEL') }}
<input <input
v-model="supportEmail" v-model="supportEmail"
@ -78,6 +64,10 @@
</label> </label>
</div> </div>
</div> </div>
<div class="current-version">
{{ `v${globalConfig.appVersion}` }}
</div>
<woot-submit-button <woot-submit-button
class="button nice success button--fixed-right-top" class="button nice success button--fixed-right-top"
:button-text="$t('GENERAL_SETTINGS.SUBMIT')" :button-text="$t('GENERAL_SETTINGS.SUBMIT')"
@ -106,7 +96,6 @@ export default {
name: '', name: '',
locale: 'en', locale: 'en',
domain: '', domain: '',
domainEmailsEnabled: false,
supportEmail: '', supportEmail: '',
features: {}, features: {},
}; };
@ -121,6 +110,7 @@ export default {
}, },
computed: { computed: {
...mapGetters({ ...mapGetters({
globalConfig: 'globalConfig/get',
getAccount: 'accounts/getAccount', getAccount: 'accounts/getAccount',
uiFlags: 'accounts/getUIFlags', uiFlags: 'accounts/getUIFlags',
}), }),
@ -132,6 +122,10 @@ export default {
featureInboundEmailEnabled() { featureInboundEmailEnabled() {
return !!this.features.inbound_emails; return !!this.features.inbound_emails;
}, },
featureCustomDomainEmailEnabled() {
return this.featureInboundEmailEnabled && !!this.customEmailDomainEnabled;
},
}, },
mounted() { mounted() {
if (!this.id) { if (!this.id) {
@ -148,7 +142,7 @@ export default {
id, id,
domain, domain,
support_email, support_email,
domain_emails_enabled, custom_email_domain_enabled,
features, features,
} = this.getAccount(this.accountId); } = this.getAccount(this.accountId);
@ -158,7 +152,7 @@ export default {
this.id = id; this.id = id;
this.domain = domain; this.domain = domain;
this.supportEmail = support_email; this.supportEmail = support_email;
this.domainEmailsEnabled = domain_emails_enabled; this.customEmailDomainEnabled = custom_email_domain_enabled;
this.features = features; this.features = features;
} catch (error) { } catch (error) {
// Ignore error // Ignore error
@ -177,7 +171,6 @@ export default {
name: this.name, name: this.name,
domain: this.domain, domain: this.domain,
support_email: this.supportEmail, support_email: this.supportEmail,
domain_emails_enabled: this.domainEmailsEnabled,
}); });
Vue.config.lang = this.locale; Vue.config.lang = this.locale;
this.showAlert(this.$t('GENERAL_SETTINGS.UPDATE.SUCCESS')); this.showAlert(this.$t('GENERAL_SETTINGS.UPDATE.SUCCESS'));
@ -208,4 +201,10 @@ export default {
padding: $space-normal; padding: $space-normal;
} }
} }
.current-version {
font-size: var(--font-size-small);
text-align: center;
padding: var(--space-normal);
}
</style> </style>

View File

@ -15,7 +15,7 @@
v-model="selectedAgents" v-model="selectedAgents"
:options="agentList" :options="agentList"
track-by="id" track-by="id"
label="name" label="available_name"
:multiple="true" :multiple="true"
:close-on-select="false" :close-on-select="false"
:clear-on-select="false" :clear-on-select="false"

View File

@ -30,12 +30,14 @@ export default {
data() { data() {
return { return {
channelList: [ channelList: [
'website', { key: 'website', name: 'Website' },
'facebook', { key: 'facebook', name: 'Facebook' },
'twitter', { key: 'twitter', name: 'Twitter' },
'twilio', { key: 'twilio', name: 'Twilio' },
'telegram', { key: 'email', name: 'Email' },
'line', { key: 'api', name: 'API' },
{ key: 'telegram', name: 'Telegram' },
{ key: 'line', name: 'Line' },
], ],
enabledFeatures: {}, enabledFeatures: {},
}; };

View File

@ -21,6 +21,14 @@
> >
</woot-code> </woot-code>
</div> </div>
<div class="medium-6 small-offset-3">
<woot-code
v-if="isAEmailInbox"
lang="html"
:script="currentInbox.forward_to_address"
>
</woot-code>
</div>
<router-link <router-link
class="button success nice" class="button success nice"
:to="{ :to="{
@ -53,6 +61,9 @@ export default {
isATwilioInbox() { isATwilioInbox() {
return this.currentInbox.channel_type === 'Channel::TwilioSms'; return this.currentInbox.channel_type === 'Channel::TwilioSms';
}, },
isAEmailInbox() {
return this.currentInbox.channel_type === 'Channel::Email';
},
message() { message() {
if (this.isATwilioInbox) { if (this.isATwilioInbox) {
return `${this.$t('INBOX_MGMT.FINISH.MESSAGE')}. ${this.$t( return `${this.$t('INBOX_MGMT.FINISH.MESSAGE')}. ${this.$t(
@ -60,6 +71,10 @@ export default {
)}`; )}`;
} }
if (this.isAEmailInbox) {
return this.$t('INBOX_MGMT.ADD.EMAIL_CHANNEL.FINISH_MESSAGE');
}
if (!this.currentInbox.website_token) { if (!this.currentInbox.website_token) {
return this.$t('INBOX_MGMT.FINISH.MESSAGE'); return this.$t('INBOX_MGMT.FINISH.MESSAGE');
} }

View File

@ -45,6 +45,12 @@
<span v-if="item.channel_type === 'Channel::TwilioSms'"> <span v-if="item.channel_type === 'Channel::TwilioSms'">
Twilio SMS Twilio SMS
</span> </span>
<span v-if="item.channel_type === 'Channel::Email'">
Email
</span>
<span v-if="item.channel_type === 'Channel::Api'">
Api
</span>
</td> </td>
<!-- Action Buttons --> <!-- Action Buttons -->

View File

@ -3,9 +3,18 @@
<woot-modal-header <woot-modal-header
:header-image="inbox.avatarUrl" :header-image="inbox.avatarUrl"
:header-title="inboxName" :header-title="inboxName"
>
<woot-tabs :index="selectedTabIndex" @change="onTabChange">
<woot-tabs-item
v-for="tab in tabs"
:key="tab.key"
:name="tab.name"
:show-badge="false"
/> />
</woot-tabs>
</woot-modal-header>
<div class="settings--content"> <div v-if="selectedTabKey === 'inbox_settings'" class="settings--content">
<settings-section <settings-section
:title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_UPDATE_TITLE')" :title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_UPDATE_TITLE')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_UPDATE_SUB_TEXT')" :sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_UPDATE_SUB_TEXT')"
@ -16,7 +25,7 @@
@change="handleImageUpload" @change="handleImageUpload"
/> />
<woot-input <woot-input
v-if="isAWidgetInbox" v-if="isAWebWidgetInbox"
v-model.trim="selectedInboxName" v-model.trim="selectedInboxName"
class="medium-9 columns" class="medium-9 columns"
:label="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_NAME.LABEL')" :label="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_NAME.LABEL')"
@ -25,7 +34,7 @@
" "
/> />
<woot-input <woot-input
v-if="isAWidgetInbox" v-if="isAWebWidgetInbox"
v-model.trim="channelWebsiteUrl" v-model.trim="channelWebsiteUrl"
class="medium-9 columns" class="medium-9 columns"
:label="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_DOMAIN.LABEL')" :label="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_DOMAIN.LABEL')"
@ -34,7 +43,7 @@
" "
/> />
<woot-input <woot-input
v-if="isAWidgetInbox" v-if="isAWebWidgetInbox"
v-model.trim="channelWelcomeTitle" v-model.trim="channelWelcomeTitle"
class="medium-9 columns" class="medium-9 columns"
:label=" :label="
@ -48,7 +57,7 @@
/> />
<woot-input <woot-input
v-if="isAWidgetInbox" v-if="isAWebWidgetInbox"
v-model.trim="channelWelcomeTagline" v-model.trim="channelWelcomeTagline"
class="medium-9 columns" class="medium-9 columns"
:label=" :label="
@ -61,7 +70,7 @@
" "
/> />
<label v-if="isAWidgetInbox" class="medium-9 columns"> <label v-if="isAWebWidgetInbox" class="medium-9 columns">
{{ $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.WIDGET_COLOR.LABEL') }} {{ $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.WIDGET_COLOR.LABEL') }}
<woot-color-picker v-model="inbox.widget_color" /> <woot-color-picker v-model="inbox.widget_color" />
</label> </label>
@ -94,7 +103,6 @@
}} }}
</p> </p>
</label> </label>
<woot-input <woot-input
v-if="greetingEnabled" v-if="greetingEnabled"
v-model.trim="greetingMessage" v-model.trim="greetingMessage"
@ -122,6 +130,33 @@
{{ $t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT_SUB_TEXT') }} {{ $t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT_SUB_TEXT') }}
</p> </p>
</label> </label>
<label v-if="isAWebWidgetInbox">
{{ $t('INBOX_MGMT.FEATURES.LABEL') }}
</label>
<div v-if="isAWebWidgetInbox" class="widget--feature-flag">
<input
v-model="selectedFeatureFlags"
type="checkbox"
value="attachments"
@input="handleFeatureFlag"
/>
<label for="attachments">
{{ $t('INBOX_MGMT.FEATURES.DISPLAY_FILE_PICKER') }}
</label>
</div>
<div v-if="isAWebWidgetInbox">
<input
v-model="selectedFeatureFlags"
type="checkbox"
value="emoji_picker"
@input="handleFeatureFlag"
/>
<label for="emoji_picker">
{{ $t('INBOX_MGMT.FEATURES.DISPLAY_EMOJI_PICKER') }}
</label>
</div>
<woot-submit-button <woot-submit-button
:button-text="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')" :button-text="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
:loading="uiFlags.isUpdatingInbox" :loading="uiFlags.isUpdatingInbox"
@ -132,7 +167,7 @@
<!-- update agents in inbox --> <!-- update agents in inbox -->
<div class="settings--content"> <div v-if="selectedTabKey === 'collaborators'" class="settings--content">
<settings-section <settings-section
:title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_AGENTS')" :title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_AGENTS')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_AGENTS_SUB_TEXT')" :sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_AGENTS_SUB_TEXT')"
@ -141,7 +176,7 @@
v-model="selectedAgents" v-model="selectedAgents"
:options="agentList" :options="agentList"
track-by="id" track-by="id"
label="name" label="available_name"
:multiple="true" :multiple="true"
:close-on-select="false" :close-on-select="false"
:clear-on-select="false" :clear-on-select="false"
@ -157,11 +192,8 @@
/> />
</settings-section> </settings-section>
</div> </div>
<div v-if="selectedTabKey === 'configuration'">
<div <div v-if="isATwilioChannel" class="settings--content">
v-if="inbox.channel_type === 'Channel::TwilioSms'"
class="settings--content"
>
<settings-section <settings-section
:title="$t('INBOX_MGMT.ADD.TWILIO.API_CALLBACK.TITLE')" :title="$t('INBOX_MGMT.ADD.TWILIO.API_CALLBACK.TITLE')"
:sub-title="$t('INBOX_MGMT.ADD.TWILIO.API_CALLBACK.SUBTITLE')" :sub-title="$t('INBOX_MGMT.ADD.TWILIO.API_CALLBACK.SUBTITLE')"
@ -169,19 +201,7 @@
<woot-code :script="twilioCallbackURL" lang="html"></woot-code> <woot-code :script="twilioCallbackURL" lang="html"></woot-code>
</settings-section> </settings-section>
</div> </div>
<div v-else-if="isAWebWidgetInbox">
<div
v-if="inbox.channel_type === 'Channel::FacebookPage'"
class="settings--content"
>
<settings-section
:title="$t('INBOX_MGMT.SETTINGS_POPUP.MESSENGER_HEADING')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.MESSENGER_SUB_HEAD')"
>
<woot-code :script="messengerScript"></woot-code>
</settings-section>
</div>
<div v-else-if="inbox.channel_type === 'Channel::WebWidget'">
<div class="settings--content"> <div class="settings--content">
<settings-section <settings-section
:title="$t('INBOX_MGMT.SETTINGS_POPUP.MESSENGER_HEADING')" :title="$t('INBOX_MGMT.SETTINGS_POPUP.MESSENGER_HEADING')"
@ -192,21 +212,23 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>
<script> <script>
/* eslint no-console: 0 */ /* eslint no-console: 0 */
/* global bus */
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { createMessengerScript } from 'dashboard/helper/scriptGenerator'; import { createMessengerScript } from 'dashboard/helper/scriptGenerator';
import configMixin from 'shared/mixins/configMixin'; import configMixin from 'shared/mixins/configMixin';
import alertMixin from 'shared/mixins/alertMixin';
import SettingsSection from '../../../../components/SettingsSection'; import SettingsSection from '../../../../components/SettingsSection';
import inboxMixin from 'shared/mixins/inboxMixin';
export default { export default {
components: { components: {
SettingsSection, SettingsSection,
}, },
mixins: [configMixin], mixins: [alertMixin, configMixin, inboxMixin],
data() { data() {
return { return {
avatarFile: null, avatarFile: null,
@ -220,6 +242,7 @@ export default {
channelWebsiteUrl: '', channelWebsiteUrl: '',
channelWelcomeTitle: '', channelWelcomeTitle: '',
channelWelcomeTagline: '', channelWelcomeTagline: '',
selectedFeatureFlags: [],
autoAssignmentOptions: [ autoAssignmentOptions: [
{ {
value: true, value: true,
@ -230,6 +253,7 @@ export default {
label: this.$t('INBOX_MGMT.EDIT.AUTO_ASSIGNMENT.DISABLED'), label: this.$t('INBOX_MGMT.EDIT.AUTO_ASSIGNMENT.DISABLED'),
}, },
], ],
selectedTabIndex: 0,
}; };
}, },
computed: { computed: {
@ -237,17 +261,41 @@ export default {
agentList: 'agents/getAgents', agentList: 'agents/getAgents',
uiFlags: 'inboxes/getUIFlags', uiFlags: 'inboxes/getUIFlags',
}), }),
selectedTabKey() {
return this.tabs[this.selectedTabIndex]?.key;
},
tabs() {
const visibleToAllChannelTabs = [
{
key: 'inbox_settings',
name: this.$t('INBOX_MGMT.TABS.SETTINGS'),
},
{
key: 'collaborators',
name: this.$t('INBOX_MGMT.TABS.COLLABORATORS'),
},
];
if (this.isAWebWidgetInbox || this.isATwilioChannel) {
return [
...visibleToAllChannelTabs,
{
key: 'configuration',
name: this.$t('INBOX_MGMT.TABS.CONFIGURATION'),
},
];
}
return visibleToAllChannelTabs;
},
currentInboxId() { currentInboxId() {
return this.$route.params.inboxId; return this.$route.params.inboxId;
}, },
inbox() { inbox() {
return this.$store.getters['inboxes/getInbox'](this.currentInboxId); return this.$store.getters['inboxes/getInbox'](this.currentInboxId);
}, },
isAWidgetInbox() {
return this.inbox.channel_type === 'Channel::WebWidget';
},
inboxName() { inboxName() {
if (this.inbox.channel_type === 'Channel::TwilioSms') { if (this.isATwilioSMSChannel || this.isATwilioWhatsappChannel) {
return `${this.inbox.name} (${this.inbox.phone_number})`; return `${this.inbox.name} (${this.inbox.phone_number})`;
} }
return this.inbox.name; return this.inbox.name;
@ -267,10 +315,25 @@ export default {
this.fetchInboxSettings(); this.fetchInboxSettings();
}, },
methods: { methods: {
showAlert(message) { handleFeatureFlag(e) {
bus.$emit('newToastMessage', message); console.log(e.target.value);
this.selectedFeatureFlags = this.toggleInput(
this.selectedFeatureFlags,
e.target.value
);
},
toggleInput(selected, current) {
if (selected.includes(current)) {
const newSelectedFlags = selected.filter(flag => flag !== current);
return newSelectedFlags;
}
return [...selected, current];
},
onTabChange(selectedTabIndex) {
this.selectedTabIndex = selectedTabIndex;
}, },
fetchInboxSettings() { fetchInboxSettings() {
this.selectedTabIndex = 0;
this.selectedAgents = []; this.selectedAgents = [];
this.$store.dispatch('agents/get'); this.$store.dispatch('agents/get');
this.$store.dispatch('inboxes/get').then(() => { this.$store.dispatch('inboxes/get').then(() => {
@ -283,6 +346,7 @@ export default {
this.channelWebsiteUrl = this.inbox.website_url; this.channelWebsiteUrl = this.inbox.website_url;
this.channelWelcomeTitle = this.inbox.welcome_title; this.channelWelcomeTitle = this.inbox.welcome_title;
this.channelWelcomeTagline = this.inbox.welcome_tagline; this.channelWelcomeTagline = this.inbox.welcome_tagline;
this.selectedFeatureFlags = this.inbox.selected_feature_flags || [];
}); });
}, },
async fetchAttachedAgents() { async fetchAttachedAgents() {
@ -291,16 +355,9 @@ export default {
inboxId: this.currentInboxId, inboxId: this.currentInboxId,
}); });
const { const {
data: { payload }, data: { payload: inboxMembers },
} = response; } = response;
payload.forEach(el => { this.selectedAgents = inboxMembers;
const [item] = this.agentList.filter(
agent => agent.id === el.user_id
);
if (item) {
this.selectedAgents.push(item);
}
});
} catch (error) { } catch (error) {
console.log(error); console.log(error);
} }
@ -332,6 +389,7 @@ export default {
website_url: this.channelWebsiteUrl, website_url: this.channelWebsiteUrl,
welcome_title: this.channelWelcomeTitle || '', welcome_title: this.channelWelcomeTitle || '',
welcome_tagline: this.channelWelcomeTagline || '', welcome_tagline: this.channelWelcomeTagline || '',
selectedFeatureFlags: this.selectedFeatureFlags,
}, },
}; };
if (this.avatarFile) { if (this.avatarFile) {
@ -377,7 +435,16 @@ export default {
.page-top-bar { .page-top-bar {
@include background-light; @include background-light;
@include border-normal-bottom; @include border-normal-bottom;
padding: $space-normal $space-larger; padding: $space-normal $space-large 0;
.tabs {
padding: 0;
margin-bottom: -1px;
}
} }
} }
.widget--feature-flag {
padding-top: var(--space-small);
}
</style> </style>

View File

@ -2,12 +2,16 @@ import Facebook from './channels/Facebook';
import Website from './channels/Website'; import Website from './channels/Website';
import Twitter from './channels/Twitter'; import Twitter from './channels/Twitter';
import Twilio from './channels/Twilio'; import Twilio from './channels/Twilio';
import Api from './channels/Api';
import Email from './channels/Email';
const channelViewList = { const channelViewList = {
facebook: Facebook, facebook: Facebook,
website: Website, website: Website,
twitter: Twitter, twitter: Twitter,
twilio: Twilio, twilio: Twilio,
api: Api,
email: Email,
}; };
export default { export default {

View File

@ -0,0 +1,110 @@
<template>
<div class="wizard-body small-9 columns">
<page-header
:header-title="$t('INBOX_MGMT.ADD.API_CHANNEL.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.API_CHANNEL.DESC')"
/>
<form class="row" @submit.prevent="createChannel()">
<div class="medium-8 columns">
<label :class="{ error: $v.channelName.$error }">
{{ $t('INBOX_MGMT.ADD.API_CHANNEL.CHANNEL_NAME.LABEL') }}
<input
v-model.trim="channelName"
type="text"
:placeholder="
$t('INBOX_MGMT.ADD.API_CHANNEL.CHANNEL_NAME.PLACEHOLDER')
"
@blur="$v.channelName.$touch"
/>
<span v-if="$v.channelName.$error" class="message">{{
$t('INBOX_MGMT.ADD.API_CHANNEL.CHANNEL_NAME.ERROR')
}}</span>
</label>
</div>
<div class="medium-8 columns">
<label :class="{ error: $v.webhookUrl.$error }">
{{ $t('INBOX_MGMT.ADD.API_CHANNEL.WEBHOOK_URL.LABEL') }}
<input
v-model.trim="webhookUrl"
type="text"
:placeholder="
$t('INBOX_MGMT.ADD.API_CHANNEL.WEBHOOK_URL.PLACEHOLDER')
"
@blur="$v.webhookUrl.$touch"
/>
</label>
<p class="help-text">
{{ $t('INBOX_MGMT.ADD.API_CHANNEL.WEBHOOK_URL.SUBTITLE') }}
</p>
</div>
<div class="medium-12 columns">
<woot-submit-button
:loading="uiFlags.isCreating"
:button-text="$t('INBOX_MGMT.ADD.API_CHANNEL.SUBMIT_BUTTON')"
/>
</div>
</form>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
import { required } from 'vuelidate/lib/validators';
import router from '../../../../index';
import PageHeader from '../../SettingsSubPageHeader';
const shouldBeWebhookUrl = (value = '') => value.startsWith('http');
export default {
components: {
PageHeader,
},
mixins: [alertMixin],
data() {
return {
channelName: '',
webhookUrl: '',
};
},
computed: {
...mapGetters({
uiFlags: 'inboxes/getUIFlags',
}),
},
validations: {
channelName: { required },
webhookUrl: { required, shouldBeWebhookUrl },
},
methods: {
async createChannel() {
this.$v.$touch();
if (this.$v.$invalid) {
return;
}
try {
const apiChannel = await this.$store.dispatch('inboxes/createChannel', {
name: this.channelName,
channel: {
type: 'api',
webhook_url: this.webhookUrl,
},
});
router.replace({
name: 'settings_inboxes_add_agents',
params: {
page: 'new',
inbox_id: apiChannel.id,
},
});
} catch (error) {
this.showAlert(this.$t('INBOX_MGMT.ADD.API_CHANNEL.API.ERROR_MESSAGE'));
}
},
},
};
</script>

View File

@ -0,0 +1,113 @@
<template>
<div class="wizard-body small-9 columns">
<page-header
:header-title="$t('INBOX_MGMT.ADD.EMAIL_CHANNEL.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.EMAIL_CHANNEL.DESC')"
/>
<form class="row" @submit.prevent="createChannel()">
<div class="medium-8 columns">
<label :class="{ error: $v.channelName.$error }">
{{ $t('INBOX_MGMT.ADD.EMAIL_CHANNEL.CHANNEL_NAME.LABEL') }}
<input
v-model.trim="channelName"
type="text"
:placeholder="
$t('INBOX_MGMT.ADD.EMAIL_CHANNEL.CHANNEL_NAME.PLACEHOLDER')
"
@blur="$v.channelName.$touch"
/>
<span v-if="$v.channelName.$error" class="message">{{
$t('INBOX_MGMT.ADD.EMAIL_CHANNEL.CHANNEL_NAME.ERROR')
}}</span>
</label>
</div>
<div class="medium-8 columns">
<label :class="{ error: $v.email.$error }">
{{ $t('INBOX_MGMT.ADD.EMAIL_CHANNEL.EMAIL.LABEL') }}
<input
v-model.trim="email"
type="text"
:placeholder="$t('INBOX_MGMT.ADD.EMAIL_CHANNEL.EMAIL.PLACEHOLDER')"
@blur="$v.email.$touch"
/>
</label>
<p class="help-text">
{{ $t('INBOX_MGMT.ADD.EMAIL_CHANNEL.EMAIL.SUBTITLE') }}
</p>
</div>
<div class="medium-12 columns">
<woot-submit-button
:loading="uiFlags.isCreating"
:button-text="$t('INBOX_MGMT.ADD.EMAIL_CHANNEL.SUBMIT_BUTTON')"
/>
</div>
</form>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
import { required } from 'vuelidate/lib/validators';
import router from '../../../../index';
import PageHeader from '../../SettingsSubPageHeader';
const validEmail = (value = '') => value.includes('@');
export default {
components: {
PageHeader,
},
mixins: [alertMixin],
data() {
return {
channelName: '',
email: '',
};
},
computed: {
...mapGetters({
uiFlags: 'inboxes/getUIFlags',
}),
},
validations: {
channelName: { required },
email: { required, validEmail },
},
methods: {
async createChannel() {
this.$v.$touch();
if (this.$v.$invalid) {
return;
}
try {
const emailChannel = await this.$store.dispatch(
'inboxes/createChannel',
{
name: this.channelName,
channel: {
type: 'email',
email: this.email,
},
}
);
router.replace({
name: 'settings_inboxes_add_agents',
params: {
page: 'new',
inbox_id: emailChannel.id,
},
});
} catch (error) {
this.showAlert(
this.$t('INBOX_MGMT.ADD.EMAIL_CHANNEL.API.ERROR_MESSAGE')
);
}
},
},
};
</script>

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="column content-box"> <div class="column content-box">
<div class="row"> <div class="row">
<div class="small-8 columns integrations-wrap"> <div class="small-12 columns integrations-wrap">
<div class="row integrations"> <div class="row integrations">
<div <div
v-for="item in integrationsList" v-for="item in integrationsList"

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