Merge branch 'release/1.4.0'

This commit is contained in:
Sojan
2020-05-06 14:26:24 +05:30
592 changed files with 18817 additions and 3077 deletions

View File

@@ -1,7 +1,7 @@
version: "2" version: "2"
plugins: plugins:
rubocop: rubocop:
enabled: true enabled: false
channel: rubocop-0-73 channel: rubocop-0-73
eslint: eslint:
enabled: false enabled: false

View File

@@ -1,14 +1,19 @@
SECRET_KEY_BASE= # Used to verify the integrity of signed cookies. so ensure a secure value is set
SECRET_KEY_BASE=replace_with_lengthy_secure_hex
# Replace with the URL you are planning to use for your app
FRONTEND_URL=http://0.0.0.0:3000
# Force all access to the app over SSL, default is set to false # Force all access to the app over SSL, default is set to false
FORCE_SSL= FORCE_SSL=false
# This lets you control new sign ups on your chatwoot installation # This lets you control new sign ups on your chatwoot installation
# true : default option, allows sign ups # true : default option, allows sign ups
# false : disables all the end points related to sign ups # false : disables all the end points related to sign ups
# api_only: disables the UI for signup, but you can create sign ups via the account apis # api_only: disables the UI for signup, but you can create sign ups via the account apis
ENABLE_ACCOUNT_SIGNUP= ENABLE_ACCOUNT_SIGNUP=true
#redis config # Redis config
REDIS_URL=redis://redis:6379 REDIS_URL=redis://redis:6379
# If you are using docker-compose, set this variable's value to be any string, # If you are using docker-compose, set this variable's value to be any string,
# which will be the password for the redis service running inside the docker-compose # which will be the password for the redis service running inside the docker-compose
@@ -22,7 +27,7 @@ POSTGRES_PASSWORD=
RAILS_ENV=development RAILS_ENV=development
RAILS_MAX_THREADS=5 RAILS_MAX_THREADS=5
#mail # Mail outgoing
MAILER_SENDER_EMAIL=accounts@chatwoot.com MAILER_SENDER_EMAIL=accounts@chatwoot.com
SMTP_PORT=1025 SMTP_PORT=1025
SMTP_DOMAIN=chatwoot.com SMTP_DOMAIN=chatwoot.com
@@ -34,39 +39,60 @@ SMTP_PASSWORD=
SMTP_AUTHENTICATION= SMTP_AUTHENTICATION=
SMTP_ENABLE_STARTTLS_AUTO= SMTP_ENABLE_STARTTLS_AUTO=
#misc # Mail Incoming
FRONTEND_URL=http://0.0.0.0:3000
# Set this to appropriate ingress channel with regards to incoming emails
# Possible values are :
# :relay for Exim, Postfix, Qmail
# :mailgun for Mailgun
# :mandrill for Mandrill
# :postmark for Postmark
# :sendgrid for Sendgrid
RAILS_INBOUND_EMAIL_SERVICE=
# Use one of the following based on the email ingress service
# Ref: https://edgeguides.rubyonrails.org/action_mailbox_basics.html
RAILS_INBOUND_EMAIL_PASSWORD=
MAILGUN_INGRESS_SIGNING_KEY=
MANDRILL_INGRESS_API_KEY=
# Storage
ACTIVE_STORAGE_SERVICE=local ACTIVE_STORAGE_SERVICE=local
#s3 # Amazon S3
S3_BUCKET_NAME= S3_BUCKET_NAME=
AWS_ACCESS_KEY_ID= AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY= AWS_SECRET_ACCESS_KEY=
AWS_REGION= AWS_REGION=
#sentry # Sentry
SENTRY_DSN= SENTRY_DSN=
#Log settings # Log settings
# Disable if you want to write logs to a file
RAILS_LOG_TO_STDOUT=true
LOG_LEVEL=info LOG_LEVEL=info
LOG_SIZE=500 LOG_SIZE=500
# Credentials to access sidekiq dashboard in production # Credentials to access sidekiq dashboard in production
SIDEKIQ_AUTH_USERNAME= SIDEKIQ_AUTH_USERNAME=
SIDEKIQ_AUTH_PASSWORD= SIDEKIQ_AUTH_PASSWORD=
### This environment variables are only required if you are setting up social media channels ### This environment variables are only required if you are setting up social media channels
#facebook #facebook
FB_VERIFY_TOKEN= FB_VERIFY_TOKEN=
FB_APP_SECRET= FB_APP_SECRET=
FB_APP_ID= FB_APP_ID=
#twitter # Twitter
TWITTER_APP_ID= TWITTER_APP_ID=
TWITTER_CONSUMER_KEY= TWITTER_CONSUMER_KEY=
TWITTER_CONSUMER_SECRET= TWITTER_CONSUMER_SECRET=
TWITTER_ENVIRONMENT= TWITTER_ENVIRONMENT=
### Change this env variable only if you are using a custom build mobile app
## Mobile app env variables
IOS_APP_ID=6C953F3RX2.com.chatwoot.app
#### This environment variables are only required in hosted version which has billing #### This environment variables are only required in hosted version which has billing
ENABLE_BILLING= ENABLE_BILLING=
@@ -75,3 +101,8 @@ CHARGEBEE_API_KEY=
CHARGEBEE_SITE= CHARGEBEE_SITE=
CHARGEBEE_WEBHOOK_USERNAME= CHARGEBEE_WEBHOOK_USERNAME=
CHARGEBEE_WEBHOOK_PASSWORD= CHARGEBEE_WEBHOOK_PASSWORD=
## Push Notification
## generate a new key value here : https://d3v.one/vapid-key-generator/
# VAPID_PUBLIC_KEY=
# VAPID_PRIVATE_KEY=

View File

@@ -1,8 +1,8 @@
module.exports = { module.exports = {
extends: ['airbnb/base', 'prettier', 'plugin:vue/recommended'], extends: ['airbnb-base/legacy', 'prettier', 'plugin:vue/recommended'],
parserOptions: { parserOptions: {
parser: 'babel-eslint', parser: 'babel-eslint',
ecmaVersion: 2017, ecmaVersion: 2020,
sourceType: 'module', sourceType: 'module',
}, },
plugins: ['html', 'prettier', 'babel'], plugins: ['html', 'prettier', 'babel'],

View File

@@ -4,18 +4,28 @@ require:
- rubocop-rspec - rubocop-rspec
inherit_from: .rubocop_todo.yml inherit_from: .rubocop_todo.yml
Lint/RaiseException:
Enabled: true
Lint/StructNewOverride:
Enabled: true
Layout/LineLength: Layout/LineLength:
Max: 150 Max: 150
Metrics/ClassLength: Metrics/ClassLength:
Max: 125 Max: 125
RSpec/ExampleLength: RSpec/ExampleLength:
Max: 15 Max: 25
Style/Documentation: Style/Documentation:
Enabled: false Enabled: false
Style/FrozenStringLiteralComment: Style/FrozenStringLiteralComment:
Enabled: false Enabled: false
Style/SymbolArray: Style/SymbolArray:
Enabled: false Enabled: false
Style/HashEachMethods:
Enabled: true
Style/HashTransformKeys:
Enabled: true
Style/HashTransformValues:
Enabled: true
Style/GlobalVars: Style/GlobalVars:
Exclude: Exclude:
- 'config/initializers/redis.rb' - 'config/initializers/redis.rb'

View File

@@ -252,7 +252,7 @@ linters:
enabled: false enabled: false
UnnecessaryParentReference: UnnecessaryParentReference:
enabled: true enabled: false
UrlFormat: UrlFormat:
enabled: true enabled: true

View File

@@ -17,6 +17,7 @@ gem 'jbuilder'
gem 'kaminari' gem 'kaminari'
gem 'responders' gem 'responders'
gem 'rest-client' gem 'rest-client'
gem 'telephone_number'
gem 'time_diff' gem 'time_diff'
gem 'tzinfo-data' gem 'tzinfo-data'
gem 'valid_email2' gem 'valid_email2'
@@ -53,9 +54,6 @@ gem 'pundit'
# https://karolgalanciak.com/blog/2019/11/30/from-activerecord-callbacks-to-publish-slash-subscribe-pattern-and-event-driven-design/ # https://karolgalanciak.com/blog/2019/11/30/from-activerecord-callbacks-to-publish-slash-subscribe-pattern-and-event-driven-design/
gem 'wisper', '2.0.0' gem 'wisper', '2.0.0'
##--- gems for reporting ---##
gem 'nightfury'
##--- gems for billing ---## ##--- gems for billing ---##
gem 'chargebee' gem 'chargebee'
@@ -82,6 +80,9 @@ gem 'sidekiq'
##-- used for single column multiple binary flags in notification settings/feature flagging --## ##-- used for single column multiple binary flags in notification settings/feature flagging --##
gem 'flag_shih_tzu' gem 'flag_shih_tzu'
##-- Push notification service --##
gem 'webpush'
group :development do group :development do
gem 'annotate' gem 'annotate'
gem 'bullet' gem 'bullet'
@@ -100,7 +101,7 @@ group :development, :test do
gem 'factory_bot_rails' gem 'factory_bot_rails'
gem 'faker' gem 'faker'
gem 'listen' gem 'listen'
gem 'mock_redis' gem 'mock_redis', git: 'https://github.com/sds/mock_redis', ref: '16d00789f0341a3aac35126c0ffe97a596753ff9'
gem 'pry-rails' gem 'pry-rails'
gem 'rspec-rails', '~> 4.0.0.beta2' gem 'rspec-rails', '~> 4.0.0.beta2'
gem 'rubocop', require: false gem 'rubocop', require: false

View File

@@ -5,6 +5,13 @@ GIT
twitty (0.1.0) twitty (0.1.0)
oauth oauth
GIT
remote: https://github.com/sds/mock_redis
revision: 16d00789f0341a3aac35126c0ffe97a596753ff9
ref: 16d00789f0341a3aac35126c0ffe97a596753ff9
specs:
mock_redis (0.22.0)
GIT GIT
remote: https://github.com/tzmfreedom/json_refs remote: https://github.com/tzmfreedom/json_refs
revision: e32deb073ce9aef39bdd63556bffd7fe7c2a803d revision: e32deb073ce9aef39bdd63556bffd7fe7c2a803d
@@ -82,10 +89,10 @@ GEM
rake (>= 10.4, < 14.0) rake (>= 10.4, < 14.0)
ast (2.4.0) ast (2.4.0)
attr_extras (6.2.3) attr_extras (6.2.3)
aws-eventstream (1.0.3) aws-eventstream (1.1.0)
aws-partitions (1.294.0) aws-partitions (1.296.0)
aws-sdk-core (3.92.0) aws-sdk-core (3.94.0)
aws-eventstream (~> 1.0, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.239.0) aws-partitions (~> 1, >= 1.239.0)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
jmespath (~> 1.0) jmespath (~> 1.0)
@@ -113,7 +120,7 @@ GEM
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.4.6) bootsnap (1.4.6)
msgpack (~> 1.0) msgpack (~> 1.0)
brakeman (4.8.0) brakeman (4.8.1)
browser (4.0.0) browser (4.0.0)
builder (3.2.4) builder (3.2.4)
bullet (6.1.0) bullet (6.1.0)
@@ -179,7 +186,7 @@ GEM
foreman (0.87.1) foreman (0.87.1)
globalid (0.4.2) globalid (0.4.2)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
google-api-client (0.37.2) google-api-client (0.38.0)
addressable (~> 2.5, >= 2.5.1) addressable (~> 2.5, >= 2.5.1)
googleauth (~> 0.9) googleauth (~> 0.9)
httpclient (>= 2.8.1, < 3.0) httpclient (>= 2.8.1, < 3.0)
@@ -193,25 +200,26 @@ GEM
google-cloud-env (1.3.1) google-cloud-env (1.3.1)
faraday (>= 0.17.3, < 2.0) faraday (>= 0.17.3, < 2.0)
google-cloud-errors (1.0.0) google-cloud-errors (1.0.0)
google-cloud-storage (1.25.1) google-cloud-storage (1.26.0)
addressable (~> 2.5) addressable (~> 2.5)
digest-crc (~> 0.4) digest-crc (~> 0.4)
google-api-client (~> 0.33) google-api-client (~> 0.33)
google-cloud-core (~> 1.2) google-cloud-core (~> 1.2)
googleauth (~> 0.9) googleauth (~> 0.9)
mini_mime (~> 1.0) mini_mime (~> 1.0)
googleauth (0.11.0) googleauth (0.12.0)
faraday (>= 0.17.3, < 2.0) faraday (>= 0.17.3, < 2.0)
jwt (>= 1.4, < 3.0) jwt (>= 1.4, < 3.0)
memoist (~> 0.16) memoist (~> 0.16)
multi_json (~> 1.11) multi_json (~> 1.11)
os (>= 0.9, < 2.0) os (>= 0.9, < 2.0)
signet (~> 0.12) signet (~> 0.14)
groupdate (5.0.0) groupdate (5.0.0)
activesupport (>= 5) activesupport (>= 5)
haikunator (1.1.0) haikunator (1.1.0)
hana (1.3.5) hana (1.3.5)
hashie (4.1.0) hashie (4.1.0)
hkdf (0.3.0)
http-accept (1.7.0) http-accept (1.7.0)
http-cookie (1.0.3) http-cookie (1.0.3)
domain_name (~> 0.5) domain_name (~> 0.5)
@@ -253,7 +261,7 @@ GEM
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)
loofah (2.4.0) loofah (2.5.0)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.5.9) nokogiri (>= 1.5.9)
mail (2.7.1) mail (2.7.1)
@@ -270,13 +278,11 @@ GEM
mini_mime (1.0.2) mini_mime (1.0.2)
mini_portile2 (2.4.0) mini_portile2 (2.4.0)
minitest (5.14.0) minitest (5.14.0)
mock_redis (0.22.0)
msgpack (1.3.3) msgpack (1.3.3)
multi_json (1.14.1) multi_json (1.14.1)
multi_xml (0.6.0) multi_xml (0.6.0)
multipart-post (2.1.1) multipart-post (2.1.1)
netrc (0.11.0) netrc (0.11.0)
nightfury (1.0.1)
nio4r (2.5.2) nio4r (2.5.2)
nokogiri (1.10.9) nokogiri (1.10.9)
mini_portile2 (~> 2.4.0) mini_portile2 (~> 2.4.0)
@@ -284,21 +290,21 @@ GEM
orm_adapter (0.5.0) orm_adapter (0.5.0)
os (1.1.0) os (1.1.0)
parallel (1.19.1) parallel (1.19.1)
parser (2.7.1.0) parser (2.7.1.1)
ast (~> 2.4.0) ast (~> 2.4.0)
pg (1.2.3) pg (1.2.3)
pry (0.13.0) pry (0.13.1)
coderay (~> 1.1) coderay (~> 1.1)
method_source (~> 1.0) method_source (~> 1.0)
pry-rails (0.3.9) pry-rails (0.3.9)
pry (>= 0.10.4) pry (>= 0.10.4)
public_suffix (4.0.3) public_suffix (4.0.4)
puma (4.3.3) puma (4.3.3)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.1.0) pundit (2.1.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
rack (2.2.2) rack (2.2.2)
rack-cache (1.11.0) rack-cache (1.11.1)
rack (>= 0.4) rack (>= 0.4)
rack-cors (1.1.1) rack-cors (1.1.1)
rack (>= 2.0.0) rack (>= 2.0.0)
@@ -388,7 +394,7 @@ GEM
unicode-display_width (>= 1.4.0, < 2.0) unicode-display_width (>= 1.4.0, < 2.0)
rubocop-performance (1.5.2) rubocop-performance (1.5.2)
rubocop (>= 0.71.0) rubocop (>= 0.71.0)
rubocop-rails (2.5.1) rubocop-rails (2.5.2)
activesupport activesupport
rack (>= 1.1) rack (>= 1.1)
rubocop (>= 0.72.0) rubocop (>= 0.72.0)
@@ -442,6 +448,7 @@ GEM
faraday faraday
inflecto inflecto
virtus virtus
telephone_number (1.4.6)
thor (0.20.3) thor (0.20.3)
thread_safe (0.3.6) thread_safe (0.3.6)
time_diff (0.3.0) time_diff (0.3.0)
@@ -463,7 +470,7 @@ GEM
unf_ext (0.0.7.7) unf_ext (0.0.7.7)
unicode-display_width (1.7.0) unicode-display_width (1.7.0)
uniform_notifier (1.13.0) uniform_notifier (1.13.0)
valid_email2 (3.2.1) valid_email2 (3.2.2)
activemodel (>= 3.2) activemodel (>= 3.2)
mail (~> 2.5) mail (~> 2.5)
virtus (1.0.5) virtus (1.0.5)
@@ -483,6 +490,9 @@ GEM
rack-proxy (>= 0.6.1) rack-proxy (>= 0.6.1)
railties (>= 5.2) railties (>= 5.2)
semantic_range (>= 2.3.0) semantic_range (>= 2.3.0)
webpush (1.0.0)
hkdf (~> 0.2)
jwt (~> 2.0)
websocket-driver (0.7.1) websocket-driver (0.7.1)
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.4) websocket-extensions (0.1.4)
@@ -526,8 +536,7 @@ DEPENDENCIES
letter_opener letter_opener
listen listen
mini_magick mini_magick
mock_redis mock_redis!
nightfury
pg pg
pry-rails pry-rails
puma puma
@@ -554,6 +563,7 @@ DEPENDENCIES
spring spring
spring-watcher-listen spring-watcher-listen
telegram-bot-ruby telegram-bot-ruby
telephone_number
time_diff time_diff
twilio-ruby (~> 5.32.0) twilio-ruby (~> 5.32.0)
twitty! twitty!
@@ -562,6 +572,7 @@ DEPENDENCIES
valid_email2 valid_email2
web-console web-console
webpacker webpacker
webpush
wisper (= 2.0.0) wisper (= 2.0.0)
RUBY VERSION RUBY VERSION

View File

@@ -1,5 +1,5 @@
<p align="center"> <p align="center">
<img src="https://storage.googleapis.com/chatwoot-assets/woot-logo.svg" alt="Woot-logo" width="240"> <img src="https://s3.us-west-2.amazonaws.com/gh-assets.chatwoot.com/brand.svg" alt="Woot-logo" width="240">
<div align="center">A simple and elegant live chat software</div> <div align="center">A simple and elegant live chat software</div>
<div align="center">An opensource alternative to Intercom, Zendesk, Drift, Crisp etc.</div> <div align="center">An opensource alternative to Intercom, Zendesk, Drift, Crisp etc.</div>
@@ -23,7 +23,7 @@ ___
<a href="https://discord.gg/cJXdrwS"><img src="https://img.shields.io/badge/chat-Discord-violet?logo=discord" alt="Chat on Discord"></a> <a href="https://discord.gg/cJXdrwS"><img src="https://img.shields.io/badge/chat-Discord-violet?logo=discord" alt="Chat on Discord"></a>
</p> </p>
![ChatUI progess](https://storage.googleapis.com/chatwoot-assets/dashboard-screen.png) ![ChatUI progess](https://s3.us-west-2.amazonaws.com/gh-assets.chatwoot.com/chatwoot-dashboard-assets.png)
## Background ## Background

View File

@@ -17,7 +17,7 @@ class ContactMergeAction
def validate_contacts def validate_contacts
return if belongs_to_account?(@base_contact) && belongs_to_account?(@mergee_contact) return if belongs_to_account?(@base_contact) && belongs_to_account?(@mergee_contact)
raise Exception, 'contact does not belong to the account' raise StandardError, 'contact does not belong to the account'
end end
def belongs_to_account?(contact) def belongs_to_account?(contact)

View File

@@ -21,13 +21,14 @@ class ContactBuilder
phone_number: contact_attributes[:phone_number], phone_number: contact_attributes[:phone_number],
email: contact_attributes[:email], email: contact_attributes[:email],
identifier: contact_attributes[:identifier], identifier: contact_attributes[:identifier],
additional_attributes: contact_attributes[:identifier] additional_attributes: contact_attributes[:additional_attributes]
) )
contact_inbox = ::ContactInbox.create!( contact_inbox = ::ContactInbox.create!(
contact_id: contact.id, contact_id: contact.id,
inbox_id: inbox.id, inbox_id: inbox.id,
source_id: source_id source_id: source_id
) )
::ContactAvatarJob.perform_later(contact, contact_attributes[:avatar_url]) if contact_attributes[:avatar_url] ::ContactAvatarJob.perform_later(contact, contact_attributes[:avatar_url]) if contact_attributes[:avatar_url]
contact_inbox contact_inbox
rescue StandardError => e rescue StandardError => e

View File

@@ -41,7 +41,7 @@ class Messages::MessageBuilder
def build_message def build_message
@message = conversation.messages.create!(message_params) @message = conversation.messages.create!(message_params)
(response.attachments || []).each do |attachment| (response.attachments || []).each do |attachment|
attachment_obj = @message.build_attachment(attachment_params(attachment).except(:remote_file_url)) attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
attachment_obj.save! attachment_obj.save!
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url] attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
end end

View File

@@ -3,22 +3,26 @@ class Messages::Outgoing::NormalBuilder
attr_reader :message attr_reader :message
def initialize(user, conversation, params) def initialize(user, conversation, params)
@content = params[:message] @content = params[:content]
@private = params[:private] || false @private = params[:private] || false
@conversation = conversation @conversation = conversation
@user = user @user = user
@fb_id = params[:fb_id] @fb_id = params[:fb_id]
@attachment = params[:attachment] @content_type = params[:content_type]
@items = params.to_unsafe_h&.dig(:content_attributes, :items)
@attachments = params[:attachments]
end end
def perform def perform
@message = @conversation.messages.build(message_params) @message = @conversation.messages.build(message_params)
if @attachment if @attachments.present?
@message.attachment = Attachment.new( @attachments.each do |uploaded_attachment|
account_id: message.account_id, attachment = @message.attachments.new(
file_type: file_type(@attachment[:file]&.content_type) account_id: @message.account_id,
) file_type: file_type(uploaded_attachment&.content_type)
@message.attachment.file.attach(@attachment[:file]) )
attachment.file.attach(uploaded_attachment)
end
end end
@message.save @message.save
@message @message
@@ -34,7 +38,9 @@ class Messages::Outgoing::NormalBuilder
content: @content, content: @content,
private: @private, private: @private,
user_id: @user&.id, user_id: @user&.id,
source_id: @fb_id source_id: @fb_id,
content_type: @content_type,
items: @items
} }
end end
end end

View File

@@ -0,0 +1,32 @@
class NotificationBuilder
pattr_initialize [:notification_type!, :user!, :account!, :primary_actor!]
def perform
return unless user_subscribed_to_notification?
build_notification
end
private
def secondary_actor
Current.user
end
def user_subscribed_to_notification?
notification_setting = user.notification_settings.find_by(account_id: account.id)
return true if notification_setting.public_send("email_#{notification_type}?")
return true if notification_setting.public_send("push_#{notification_type}?")
false
end
def build_notification
user.notifications.create!(
notification_type: notification_type,
account: account,
primary_actor: primary_actor,
secondary_actor: secondary_actor
)
end
end

View File

@@ -0,0 +1,28 @@
class NotificationSubscriptionBuilder
pattr_initialize [:params, :user!]
def perform
# if multiple accounts were used to login in same browser
move_subscription_to_user if identifier_subscription && identifier_subscription.user_id != user.id
build_identifier_subscription if identifier_subscription.blank?
identifier_subscription
end
private
def identifier
@identifier ||= params[:subscription_attributes][:endpoint] if params[:subscription_type] == 'browser_push'
end
def identifier_subscription
@identifier_subscription ||= NotificationSubscription.find_by(identifier: identifier)
end
def move_subscription_to_user
@identifier_subscription.update(user_id: user.id)
end
def build_identifier_subscription
user.notification_subscriptions.create(params.merge(identifier: identifier))
end
end

View File

@@ -1,77 +0,0 @@
class ReportBuilder
include CustomExceptions::Report
# Usage
# rb = ReportBuilder.new a, { metric: 'conversations_count', type: :account, id: 1}
# rb = ReportBuilder.new a, { metric: 'avg_first_response_time', type: :agent, id: 1}
IDENTITY_MAPPING = {
account: AccountIdentity,
agent: AgentIdentity
}.freeze
def initialize(account, params)
@account = account
@params = params
@identity = get_identity
@start_time, @end_time = validate_times
end
def build
metric = @identity.send(@params[:metric])
if metric.get.nil?
metric.delete
result = {}
else
result = metric.get_padded_range(@start_time, @end_time) || {}
end
formatted_hash(result)
end
private
def get_identity
identity_class = IDENTITY_MAPPING[@params[:type]]
raise InvalidIdentity if identity_class.nil?
@params[:id] = @account.id if identity_class == AccountIdentity
identity_id = @params[:id]
raise IdentityNotFound if identity_id.nil?
tags = identity_class == AccountIdentity ? nil : { account_id: @account.id }
identity = identity_class.new(identity_id, tags: tags)
raise MetricNotFound if @params[:metric].blank?
raise MetricNotFound unless identity.respond_to?(@params[:metric])
identity
end
def validate_times
start_time = @params[:since] || Time.now.end_of_day - 30.days
end_time = @params[:until] || Time.now.end_of_day
start_time = begin
parse_date_time(start_time)
rescue StandardError
raise(InvalidStartTime)
end
end_time = begin
parse_date_time(end_time)
rescue StandardError
raise(InvalidEndTime)
end
[start_time, end_time]
end
def parse_date_time(datetime)
return datetime if datetime.is_a?(DateTime)
return datetime.to_datetime if datetime.is_a?(Time) || datetime.is_a?(Date)
DateTime.strptime(datetime, '%s')
end
def formatted_hash(hash)
hash.each_with_object([]) do |p, arr|
arr << { value: p[1], timestamp: p[0] }
end
end
end

View File

@@ -50,14 +50,15 @@ class V2::ReportBuilder
.count .count
end end
# unscoped removes all scopes added to a model previously
def incoming_messages_count def incoming_messages_count
scope.messages.unscoped.incoming scope.messages.unscoped.where(account_id: account.id).incoming
.group_by_day(:created_at, range: range, default_value: 0) .group_by_day(:created_at, range: range, default_value: 0)
.count .count
end end
def outgoing_messages_count def outgoing_messages_count
scope.messages.unscoped.outgoing scope.messages.unscoped.where(account_id: account.id).outgoing
.group_by_day(:created_at, range: range, default_value: 0) .group_by_day(:created_at, range: range, default_value: 0)
.count .count
end end

View File

@@ -8,7 +8,7 @@ class Api::BaseController < ApplicationController
private private
def authenticate_by_access_token? def authenticate_by_access_token?
request.headers[:api_access_token].present? request.headers[:api_access_token].present? || request.headers[:HTTP_API_ACCESS_TOKEN].present?
end end
def set_conversation def set_conversation

View File

@@ -31,7 +31,7 @@ class Api::V1::Accounts::AccountsController < Api::BaseController
end end
def update def update
@account.update!(account_params.slice(:name, :locale)) @account.update!(account_params.slice(:name, :locale, :domain, :support_email, :domain_emails_enabled))
end end
private private
@@ -45,7 +45,7 @@ class Api::V1::Accounts::AccountsController < Api::BaseController
end end
def account_params def account_params
params.permit(:account_name, :email, :name, :locale) params.permit(:account_name, :email, :name, :locale, :domain, :support_email, :domain_emails_enabled)
end end
def check_signup_enabled def check_signup_enabled

View File

@@ -4,16 +4,18 @@ class Api::V1::Accounts::CallbacksController < Api::BaseController
def register_facebook_page def register_facebook_page
user_access_token = params[:user_access_token] user_access_token = params[:user_access_token]
page_access_token = params[:page_access_token] page_access_token = params[:page_access_token]
page_name = params[:page_name]
page_id = params[:page_id] page_id = params[:page_id]
inbox_name = params[:inbox_name] inbox_name = params[:inbox_name]
facebook_channel = current_account.facebook_pages.create!( ActiveRecord::Base.transaction do
name: page_name, page_id: page_id, user_access_token: user_access_token, facebook_channel = current_account.facebook_pages.create!(
page_access_token: page_access_token page_id: page_id, user_access_token: user_access_token,
) page_access_token: page_access_token
set_avatar(facebook_channel, page_id) )
inbox = current_account.inboxes.create!(name: inbox_name, channel: facebook_channel) @facebook_inbox = current_account.inboxes.create!(name: inbox_name, channel: facebook_channel)
render json: inbox set_avatar(@facebook_inbox, page_id)
rescue StandardError => e
Rails.logger e
end
end end
def facebook_pages def facebook_pages
@@ -72,13 +74,13 @@ class Api::V1::Accounts::CallbacksController < Api::BaseController
end end
end end
def set_avatar(facebook_channel, page_id) def set_avatar(facebook_inbox, page_id)
uri = get_avatar_url(page_id) uri = get_avatar_url(page_id)
return unless uri return unless uri
avatar_resource = LocalResource.new(uri) avatar_resource = LocalResource.new(uri)
facebook_channel.avatar.attach(io: avatar_resource.file, filename: avatar_resource.tmp_filename, content_type: avatar_resource.encoding) facebook_inbox.avatar.attach(io: avatar_resource.file, filename: avatar_resource.tmp_filename, content_type: avatar_resource.encoding)
end end
def get_avatar_url(page_id) def get_avatar_url(page_id)

View File

@@ -2,13 +2,15 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::BaseControlle
before_action :authorize_request before_action :authorize_request
def create def create
authenticate_twilio ActiveRecord::Base.transaction do
build_inbox authenticate_twilio
setup_webhooks build_inbox
rescue Twilio::REST::TwilioError => e setup_webhooks if @twilio_channel.sms?
render_could_not_create_error(e.message) rescue Twilio::REST::TwilioError => e
rescue StandardError => e render_could_not_create_error(e.message)
render_could_not_create_error(e.message) rescue StandardError => e
render_could_not_create_error(e.message)
end
end end
private private
@@ -26,25 +28,30 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::BaseControlle
::Twilio::WebhookSetupService.new(inbox: @inbox).perform ::Twilio::WebhookSetupService.new(inbox: @inbox).perform
end end
def phone_number
medium == 'sms' ? permitted_params[:phone_number] : "whatsapp:#{permitted_params[:phone_number]}"
end
def medium
permitted_params[:medium]
end
def build_inbox def build_inbox
ActiveRecord::Base.transaction do @twilio_channel = current_account.twilio_sms.create!(
twilio_sms = current_account.twilio_sms.create( account_sid: permitted_params[:account_sid],
account_sid: permitted_params[:account_sid], auth_token: permitted_params[:auth_token],
auth_token: permitted_params[:auth_token], phone_number: phone_number,
phone_number: permitted_params[:phone_number] medium: medium
) )
@inbox = current_account.inboxes.create( @inbox = current_account.inboxes.create(
name: permitted_params[:name], name: permitted_params[:name],
channel: twilio_sms channel: @twilio_channel
) )
rescue StandardError => e
render_could_not_create_error(e.message)
end
end end
def permitted_params def permitted_params
params.require(:twilio_channel).permit( params.require(:twilio_channel).permit(
:account_id, :phone_number, :account_sid, :auth_token, :name :account_id, :phone_number, :account_sid, :auth_token, :name, :medium
) )
end end
end end

View File

@@ -4,11 +4,6 @@ class Api::V1::Accounts::ContactsController < Api::BaseController
before_action :check_authorization before_action :check_authorization
before_action :fetch_contact, only: [:show, :update] before_action :fetch_contact, only: [:show, :update]
skip_before_action :authenticate_user!, only: [:create]
skip_before_action :set_current_user, only: [:create]
skip_before_action :check_subscription, only: [:create]
skip_around_action :handle_with_exception, only: [:create]
def index def index
@contacts = current_account.contacts @contacts = current_account.contacts
end end

View File

@@ -1,5 +1,7 @@
class Api::V1::Accounts::ConversationsController < Api::BaseController class Api::V1::Accounts::ConversationsController < Api::BaseController
include Events::Types
before_action :conversation, except: [:index] before_action :conversation, except: [:index]
before_action :contact_inbox, only: [:create]
def index def index
result = conversation_finder.perform result = conversation_finder.perform
@@ -7,12 +9,30 @@ class Api::V1::Accounts::ConversationsController < Api::BaseController
@conversations_count = result[:count] @conversations_count = result[:count]
end end
def meta
result = conversation_finder.perform
@conversations_count = result[:count]
end
def create
@conversation = ::Conversation.create!(conversation_params)
end
def show; end def show; end
def toggle_status def toggle_status
@status = @conversation.toggle_status @status = @conversation.toggle_status
end end
def toggle_typing_status
if params[:typing_status] == 'on'
trigger_typing_event(CONVERSATION_TYPING_ON)
elsif params[:typing_status] == 'off'
trigger_typing_event(CONVERSATION_TYPING_OFF)
end
head :ok
end
def update_last_seen def update_last_seen
@conversation.agent_last_seen_at = parsed_last_seen_at @conversation.agent_last_seen_at = parsed_last_seen_at
@conversation.save! @conversation.save!
@@ -21,6 +41,11 @@ class Api::V1::Accounts::ConversationsController < Api::BaseController
private private
def trigger_typing_event(event)
user = current_user.presence || @resource
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: @conversation, user: user)
end
def parsed_last_seen_at def parsed_last_seen_at
DateTime.strptime(params[:agent_last_seen_at].to_s, '%s') DateTime.strptime(params[:agent_last_seen_at].to_s, '%s')
end end
@@ -29,6 +54,19 @@ class Api::V1::Accounts::ConversationsController < Api::BaseController
@conversation ||= current_account.conversations.find_by(display_id: params[:id]) @conversation ||= current_account.conversations.find_by(display_id: params[:id])
end end
def contact_inbox
@contact_inbox ||= ::ContactInbox.find_by!(source_id: params[:source_id])
end
def conversation_params
{
account_id: current_account.id,
inbox_id: @contact_inbox.inbox_id,
contact_id: @contact_inbox.contact_id,
contact_inbox_id: @contact_inbox.id
}
end
def conversation_finder def conversation_finder
@conversation_finder ||= ConversationFinder.new(current_user, params) @conversation_finder ||= ConversationFinder.new(current_user, params)
end end

View File

@@ -1,13 +1,35 @@
class Api::V1::Accounts::InboxesController < Api::BaseController class Api::V1::Accounts::InboxesController < Api::BaseController
before_action :check_authorization before_action :check_authorization
before_action :fetch_inbox, only: [:destroy, :update] before_action :fetch_inbox, except: [:index, :create]
before_action :fetch_agent_bot, only: [:set_agent_bot]
def index def index
@inboxes = policy_scope(current_account.inboxes) @inboxes = policy_scope(current_account.inboxes)
end end
def create
ActiveRecord::Base.transaction do
channel = web_widgets.create!(permitted_params[:channel].except(:type)) if permitted_params[:channel][:type] == 'web_widget'
@inbox = current_account.inboxes.build(name: permitted_params[:name], channel: channel)
@inbox.avatar.attach(permitted_params[:avatar])
@inbox.save!
end
end
def update def update
@inbox.update(inbox_update_params) @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?
end
def set_agent_bot
if @agent_bot
agent_bot_inbox = @inbox.agent_bot_inbox || AgentBotInbox.new(inbox: @inbox)
agent_bot_inbox.agent_bot = @agent_bot
agent_bot_inbox.save!
elsif @inbox.agent_bot_inbox.present?
@inbox.agent_bot_inbox.destroy!
end
head :ok
end end
def destroy def destroy
@@ -21,11 +43,24 @@ class Api::V1::Accounts::InboxesController < Api::BaseController
@inbox = current_account.inboxes.find(params[:id]) @inbox = current_account.inboxes.find(params[:id])
end end
def fetch_agent_bot
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
end
def web_widgets
current_account.web_widgets
end
def check_authorization def check_authorization
authorize(Inbox) authorize(Inbox)
end end
def permitted_params
params.permit(:id, :avatar, :name, channel: [:type, :website_url, :widget_color, :welcome_title, :welcome_tagline, :agent_away_message])
end
def inbox_update_params def inbox_update_params
params.require(:inbox).permit(:enable_auto_assignment) params.permit(:enable_auto_assignment, :name, :avatar, channel: [:website_url, :widget_color, :welcome_title,
:welcome_tagline, :agent_away_message])
end end
end end

View File

@@ -20,10 +20,11 @@ class Api::V1::Accounts::NotificationSettingsController < Api::BaseController
end end
def notification_setting_params def notification_setting_params
params.require(:notification_settings).permit(selected_email_flags: []) params.require(:notification_settings).permit(selected_email_flags: [], selected_push_flags: [])
end end
def update_flags def update_flags
@notification_setting.selected_email_flags = notification_setting_params[:selected_email_flags] @notification_setting.selected_email_flags = notification_setting_params[:selected_email_flags]
@notification_setting.selected_push_flags = notification_setting_params[:selected_push_flags]
end end
end end

View File

@@ -0,0 +1,21 @@
class Api::V1::Accounts::NotificationsController < Api::BaseController
protect_from_forgery with: :null_session
before_action :fetch_notification, only: [:update]
def index
@notifications = current_user.notifications.where(account_id: current_account.id)
render json: @notifications
end
def update
@notification.update(read_at: DateTime.now.utc)
render json: @notification
end
private
def fetch_notification
@notification = current_user.notifications.find(params[:id])
end
end

View File

@@ -1,99 +0,0 @@
class Api::V1::Accounts::ReportsController < Api::BaseController
include CustomExceptions::Report
include Constants::Report
around_action :report_exception
def account
builder = ReportBuilder.new(current_account, account_report_params)
data = builder.build
render json: data
end
def agent
builder = ReportBuilder.new(current_account, agent_report_params)
data = builder.build
render json: data
end
def account_summary
render json: account_summary_metrics
end
def agent_summary
render json: agent_summary_metrics
end
private
def report_exception
yield
rescue InvalidIdentity, IdentityNotFound, MetricNotFound, InvalidStartTime, InvalidEndTime => e
render_error_response(e)
end
def current_account
current_user.account
end
def account_summary_metrics
summary_metrics(ACCOUNT_METRICS, :account_summary_params, AVG_ACCOUNT_METRICS)
end
def agent_summary_metrics
summary_metrics(AGENT_METRICS, :agent_summary_params, AVG_AGENT_METRICS)
end
def summary_metrics(metrics, calc_function, avg_metrics)
metrics.each_with_object({}) do |metric, result|
data = ReportBuilder.new(current_account, send(calc_function, metric)).build
result[metric] = calculate_metric(data, metric, avg_metrics)
end
end
def calculate_metric(data, metric, avg_metrics)
sum = data.inject(0) { |val, hash| val + hash[:value].to_i }
if avg_metrics.include?(metric)
sum /= data.length unless sum.zero?
end
sum
end
def account_summary_params(metric)
{
metric: metric.to_s,
type: :account,
since: params[:since],
until: params[:until]
}
end
def agent_summary_params(metric)
{
metric: metric.to_s,
type: :agent,
since: params[:since],
until: params[:until],
id: params[:id]
}
end
def account_report_params
{
metric: params[:metric],
type: :account,
since: params[:since],
until: params[:until]
}
end
def agent_report_params
{
metric: params[:metric],
type: :agent,
id: params[:id],
since: params[:since],
until: params[:until]
}
end
end

View File

@@ -1,48 +0,0 @@
class Api::V1::Accounts::Widget::InboxesController < Api::BaseController
before_action :authorize_request
before_action :set_web_widget_channel, only: [:update]
before_action :set_inbox, only: [:update]
def create
ActiveRecord::Base.transaction do
channel = web_widgets.create!(
website_name: permitted_params[:website][:website_name],
website_url: permitted_params[:website][:website_url],
widget_color: permitted_params[:website][:widget_color]
)
@inbox = inboxes.create!(name: permitted_params[:website][:website_name], channel: channel)
end
end
def update
@channel.update!(
widget_color: permitted_params[:website][:widget_color]
)
end
private
def authorize_request
authorize ::Inbox
end
def inboxes
current_account.inboxes
end
def web_widgets
current_account.web_widgets
end
def set_web_widget_channel
@channel = web_widgets.find_by(id: permitted_params[:id])
end
def set_inbox
@inbox = @channel.inbox
end
def permitted_params
params.permit(:id, website: [:website_name, :website_url, :widget_color])
end
end

View File

@@ -0,0 +1,8 @@
class Api::V1::AgentBotsController < Api::BaseController
skip_before_action :authenticate_user!
skip_before_action :check_subscription
def index
render json: AgentBot.all
end
end

View File

@@ -0,0 +1,19 @@
class Api::V1::NotificationSubscriptionsController < Api::BaseController
before_action :set_user
def create
notification_subscription = NotificationSubscriptionBuilder.new(user: @user, params: notification_subscription_params).perform
render json: notification_subscription
end
private
def set_user
@user = current_user
end
def notification_subscription_params
params.require(:notification_subscription).permit(:subscription_type, subscription_attributes: {})
end
end

View File

@@ -2,9 +2,9 @@ class Api::V1::Widget::BaseController < ApplicationController
private private
def conversation def conversation
@conversation ||= @contact_inbox.conversations.find_by( @conversation ||= @contact_inbox.conversations.where(
inbox_id: auth_token_params[:inbox_id] inbox_id: auth_token_params[:inbox_id]
) ).last
end end
def auth_token_params def auth_token_params
@@ -18,6 +18,7 @@ class Api::V1::Widget::BaseController < ApplicationController
def set_web_widget def set_web_widget
@web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token]) @web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token])
@account = @web_widget.account @account = @web_widget.account
switch_locale @account
end end
def set_contact def set_contact

View File

@@ -0,0 +1,27 @@
class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
include Events::Types
before_action :set_web_widget
before_action :set_contact
def toggle_typing
head :ok && return if conversation.nil?
if permitted_params[:typing_status] == 'on'
trigger_typing_event(CONVERSATION_TYPING_ON)
elsif permitted_params[:typing_status] == 'off'
trigger_typing_event(CONVERSATION_TYPING_OFF)
end
head :ok
end
private
def trigger_typing_event(event)
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: conversation, user: @contact)
end
def permitted_params
params.permit(:id, :typing_status, :website_token)
end
end

View File

@@ -0,0 +1,16 @@
class Api::V1::Widget::EventsController < Api::V1::Widget::BaseController
include Events::Types
before_action :set_web_widget
before_action :set_contact
def create
Rails.configuration.dispatcher.dispatch(permitted_params[:name], Time.zone.now, contact_inbox: @contact_inbox)
head :no_content
end
private
def permitted_params
params.permit(:name, :website_token)
end
end

View File

@@ -10,13 +10,17 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
def create def create
@message = conversation.messages.new(message_params) @message = conversation.messages.new(message_params)
@message.save
build_attachment build_attachment
@message.save!
end end
def update def update
@message.update!(input_submitted_email: contact_email) if @message.content_type == 'input_email'
update_contact(contact_email) @message.update!(submitted_email: contact_email)
update_contact(contact_email)
else
@message.update!(message_update_params[:message])
end
rescue StandardError => e rescue StandardError => e
render json: { error: @contact.errors, message: e.message }.to_json, status: 500 render json: { error: @contact.errors, message: e.message }.to_json, status: 500
end end
@@ -24,13 +28,16 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
private private
def build_attachment def build_attachment
return if params[:message][:attachment].blank? return if params[:message][:attachments].blank?
@message.attachment = Attachment.new( params[:message][:attachments].each do |uploaded_attachment|
account_id: @message.account_id, attachment = @message.attachments.new(
file_type: helpers.file_type(params[:message][:attachment][:file]&.content_type) account_id: @message.account_id,
) file_type: helpers.file_type(uploaded_attachment&.content_type)
@message.attachment.file.attach(params[:message][:attachment][:file]) )
attachment.file.attach(uploaded_attachment)
end
@message.save!
end end
def set_conversation def set_conversation
@@ -116,6 +123,10 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
contact_email.split('@')[0] contact_email.split('@')[0]
end end
def message_update_params
params.permit(message: [submitted_values: [:name, :title, :value]])
end
def permitted_params def permitted_params
params.permit(:id, :before, :website_token, contact: [:email], message: [:content, :referer_url, :timestamp]) params.permit(:id, :before, :website_token, contact: [:email], message: [:content, :referer_url, :timestamp])
end end

View File

@@ -0,0 +1,8 @@
class ApiController < ApplicationController
skip_before_action :set_current_user, only: [:index]
skip_before_action :check_subscription, only: [:index]
def index
render json: { version: Chatwoot.config[:version], timestamp: Time.now.utc.to_formatted_s(:db) }
end
end

View File

@@ -0,0 +1,6 @@
class AppleAppController < ApplicationController
def site_association
site_association_json = render_to_string action: 'site_association', layout: false
send_data site_association_json, filename: 'apple-app-site-association', type: 'application/json'
end
end

View File

@@ -24,9 +24,14 @@ class ApplicationController < ActionController::Base
elsif @resource&.is_a?(AgentBot) elsif @resource&.is_a?(AgentBot)
account_accessible_for_bot?(account) account_accessible_for_bot?(account)
end end
switch_locale account
account account
end end
def switch_locale(account)
I18n.locale = (I18n.available_locales.map(&:to_s).include?(account.locale) ? account.locale : nil) || I18n.default_locale
end
def account_accessible_for_user?(account) def account_accessible_for_user?(account)
render_unauthorized('You are not authorized to access this account') unless account.account_users.find_by(user_id: current_user.id) render_unauthorized('You are not authorized to access this account') unless account.account_users.find_by(user_id: current_user.id)
end end

View File

@@ -1,11 +1,12 @@
module AccessTokenAuthHelper module AccessTokenAuthHelper
BOT_ACCESSIBLE_ENDPOINTS = { BOT_ACCESSIBLE_ENDPOINTS = {
'api/v1/accounts/conversations' => ['toggle_status'], 'api/v1/accounts/conversations' => %w[toggle_status create],
'api/v1/accounts/conversations/messages' => ['create'] 'api/v1/accounts/conversations/messages' => ['create']
}.freeze }.freeze
def authenticate_access_token! def authenticate_access_token!
access_token = AccessToken.find_by(token: request.headers[:api_access_token]) token = request.headers[:api_access_token] || request.headers[:HTTP_API_ACCESS_TOKEN]
access_token = AccessToken.find_by(token: token)
render_unauthorized('Invalid Access Token') && return unless access_token render_unauthorized('Invalid Access Token') && return unless access_token
token_owner = access_token.owner token_owner = access_token.owner

View File

@@ -1,9 +0,0 @@
require 'rest-client'
require 'telegram/bot'
class HomeController < ApplicationController
skip_before_action :verify_authenticity_token, only: [:telegram]
skip_before_action :authenticate_user!, only: [:telegram], raise: false
skip_before_action :set_current_user
skip_before_action :check_subscription
end

View File

@@ -23,7 +23,9 @@ class Twilio::CallbackController < ApplicationController
:FromZip, :FromZip,
:Body, :Body,
:ToCountry, :ToCountry,
:FromState :FromState,
:MediaUrl0,
:MediaContentType0
) )
end end
end end

View File

@@ -6,7 +6,7 @@ class Twitter::AuthorizationsController < Twitter::BaseController
::Redis::Alfred.setex(oauth_token, account.id) ::Redis::Alfred.setex(oauth_token, account.id)
redirect_to oauth_authorize_endpoint(oauth_token) redirect_to oauth_authorize_endpoint(oauth_token)
else else
redirect_to app_new_twitter_inbox_url redirect_to app_new_twitter_inbox_url(account_id: account.id)
end end
end end

View File

@@ -39,8 +39,7 @@ class Twitter::CallbacksController < Twitter::BaseController
twitter_profile = account.twitter_profiles.create( twitter_profile = account.twitter_profiles.create(
twitter_access_token: parsed_body['oauth_token'], twitter_access_token: parsed_body['oauth_token'],
twitter_access_token_secret: parsed_body['oauth_token_secret'], twitter_access_token_secret: parsed_body['oauth_token_secret'],
profile_id: parsed_body['user_id'], profile_id: parsed_body['user_id']
name: parsed_body['screen_name']
) )
account.inboxes.create( account.inboxes.create(
name: parsed_body['screen_name'], name: parsed_body['screen_name'],

View File

@@ -9,8 +9,7 @@ class AsyncDispatcher < BaseDispatcher
end end
def listeners def listeners
listeners = [AgentBotListener.instance, EmailNotificationListener.instance, ReportingListener.instance, WebhookListener.instance] listeners = [EventListener.instance, WebhookListener.instance]
listeners << EventListener.instance
listeners << SubscriptionListener.instance if ENV['BILLING_ENABLED'] listeners << SubscriptionListener.instance if ENV['BILLING_ENABLED']
listeners listeners
end end

View File

@@ -5,6 +5,6 @@ class SyncDispatcher < BaseDispatcher
end end
def listeners def listeners
[ActionCableListener.instance] [ActionCableListener.instance, AgentBotListener.instance, NotificationListener.instance]
end end
end end

View File

@@ -11,7 +11,7 @@ class MessageFinder
private private
def conversation_messages def conversation_messages
@conversation.messages.includes(:attachment, user: { avatar_attachment: :blob }) @conversation.messages.includes(:attachments, user: { avatar_attachment: :blob })
end end
def messages def messages

View File

@@ -1,2 +1,5 @@
module ApplicationHelper module ApplicationHelper
def available_locales_with_name
LANGUAGES_CONFIG.map { |_key, val| val.slice(:name, :iso_639_1_code) }
end
end end

View File

@@ -1,10 +0,0 @@
class AccountIdentity < Nightfury::Identity::Base
metric :conversations_count, :count_time_series, store_as: :b, step: :day
metric :incoming_messages_count, :count_time_series, step: :day
metric :outgoing_messages_count, :count_time_series, step: :day
metric :avg_first_response_time, :avg_time_series, store_as: :d, step: :day
metric :avg_resolution_time, :avg_time_series, store_as: :f, step: :day
metric :resolutions_count, :count_time_series, store_as: :g, step: :day
end
AccountIdentity.store_as = :ci

View File

@@ -1,8 +0,0 @@
class AgentIdentity < Nightfury::Identity::Base
metric :avg_first_response_time, :avg_time_series, store_as: :d, step: :day
metric :avg_resolution_time, :avg_time_series, store_as: :f, step: :day
metric :resolutions_count, :count_time_series, store_as: :g, step: :day
tag :account_id, store_as: :co
end
AgentIdentity.store_as = :ai

View File

@@ -2,7 +2,7 @@ import ApiClient from '../ApiClient';
class WebChannel extends ApiClient { class WebChannel extends ApiClient {
constructor() { constructor() {
super('widget/inboxes', { accountScoped: true }); super('inboxes', { accountScoped: true });
} }
} }

View File

@@ -33,6 +33,12 @@ class ConversationApi extends ApiClient {
agent_last_seen_at: lastSeen, agent_last_seen_at: lastSeen,
}); });
} }
toggleTyping({ conversationId, status }) {
return axios.post(`${this.url}/${conversationId}/toggle_typing_status`, {
typing_status: status,
});
}
} }
export default new ConversationApi(); export default new ConversationApi();

View File

@@ -9,7 +9,7 @@ class MessageApi extends ApiClient {
create({ conversationId, message, private: isPrivate }) { create({ conversationId, message, private: isPrivate }) {
return axios.post(`${this.url}/${conversationId}/messages`, { return axios.post(`${this.url}/${conversationId}/messages`, {
message, content: message,
private: isPrivate, private: isPrivate,
}); });
} }
@@ -22,7 +22,7 @@ class MessageApi extends ApiClient {
sendAttachment([conversationId, { file }]) { sendAttachment([conversationId, { file }]) {
const formData = new FormData(); const formData = new FormData();
formData.append('attachment[file]', file); formData.append('attachments[]', file, file.name);
return axios({ return axios({
method: 'post', method: 'post',
url: `${this.url}/${conversationId}/messages`, url: `${this.url}/${conversationId}/messages`,

View File

@@ -0,0 +1,9 @@
import ApiClient from './ApiClient';
class NotificationSubscriptions extends ApiClient {
constructor() {
super('notification_subscriptions');
}
}
export default new NotificationSubscriptions();

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -49,10 +49,10 @@ $global-font-size: 10px;
$global-width: 100%; $global-width: 100%;
$global-lineheight: 1.5; $global-lineheight: 1.5;
$foundation-palette: (primary: $color-woot, $foundation-palette: (primary: $color-woot,
secondary: #777, secondary: #35c5ff,
success: #13ce66, success: #44ce4b,
warning: #ffc82c, warning: #ffc532,
alert: #ff4949); alert: #ff382d);
$light-gray: #c0ccda; $light-gray: #c0ccda;
$medium-gray: #8492a6; $medium-gray: #8492a6;
$dark-gray: $color-gray; $dark-gray: $color-gray;
@@ -127,7 +127,7 @@ $header-styles: (small: ("h1": ("font-size": 24),
$header-text-rendering: optimizeLegibility; $header-text-rendering: optimizeLegibility;
$small-font-size: 80%; $small-font-size: 80%;
$header-small-font-color: $medium-gray; $header-small-font-color: $medium-gray;
$paragraph-lineheight: 1.6; $paragraph-lineheight: 1.45;
$paragraph-margin-bottom: 1rem; $paragraph-margin-bottom: 1rem;
$paragraph-text-rendering: optimizeLegibility; $paragraph-text-rendering: optimizeLegibility;
$code-color: $black; $code-color: $black;
@@ -377,8 +377,8 @@ $form-button-radius: $global-radius;
// 20. Label // 20. Label
// --------- // ---------
$label-background: $primary-color; $label-background: lighten($primary-color, 40%);
$label-color: $white; $label-color: $primary-color;
$label-color-alt: $black; $label-color-alt: $black;
$label-palette: $foundation-palette; $label-palette: $foundation-palette;
$label-font-size: $font-size-micro; $label-font-size: $font-size-micro;

View File

@@ -29,7 +29,7 @@
background: $color-white; background: $color-white;
border-radius: $space-large; border-radius: $space-large;
left: 0; left: 0;
margin: $space-slab 0 auto; margin: $space-slab auto;
padding: $space-normal; padding: $space-normal;
top: 0; top: 0;

View File

@@ -46,7 +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: #eff2f7; $color-background: #f4f6fb;
$color-border-dark: #cad0d4;
$color-background-light: #f9fafc; $color-background-light: #f9fafc;
$color-white: #fff; $color-white: #fff;
$color-body: #3c4858; $color-body: #3c4858;
@@ -54,11 +55,10 @@ $color-heading: #1f2d3d;
$color-extra-light-blue: #f5f7f9; $color-extra-light-blue: #f5f7f9;
$primary-color: $color-woot; $primary-color: $color-woot;
$secondary-color: #ff5216; $secondary-color: #35c5ff;
$success-color: #13ce66; $success-color: #44ce4b;
$warning-color: #ffc82c; $warning-color: #ffc532;
$alert-color: #ff4949; $alert-color: #ff382d;
// Color-palettes // Color-palettes
$color-primary-light: #c7e3ff; $color-primary-light: #c7e3ff;

View File

@@ -9,7 +9,6 @@
@import 'widgets/conv-header'; @import 'widgets/conv-header';
@import 'widgets/conversation-card'; @import 'widgets/conversation-card';
@import 'widgets/conversation-view'; @import 'widgets/conversation-view';
@import 'widgets/emojiinput';
@import 'widgets/forms'; @import 'widgets/forms';
@import 'widgets/login'; @import 'widgets/login';
@import 'widgets/modal'; @import 'widgets/modal';

View File

@@ -5,6 +5,7 @@
@include padding($space-normal $space-two $zero); @include padding($space-normal $space-two $zero);
} }
} }
// Conversation header - Light BG // Conversation header - Light BG
.settings-header { .settings-header {
@include padding($space-small $space-normal); @include padding($space-small $space-normal);
@@ -14,6 +15,7 @@
@include border-normal-bottom; @include border-normal-bottom;
height: $header-height; height: $header-height;
min-height: $header-height; min-height: $header-height;
// Resolve Button // Resolve Button
.button { .button {
@include margin(0); @include margin(0);
@@ -31,42 +33,39 @@
.wizard-box { .wizard-box {
.item { .item {
@include padding($space-normal $space-normal $space-normal $space-medium); @include padding($space-normal $space-normal $space-normal $space-medium);
position: relative;
@include background-light; @include background-light;
cursor: pointer;
&:before, cursor: pointer;
&:after { position: relative;
content: '';
position: absolute; &::before,
width: 2px; &::after {
height: 100%;
background: $color-border; background: $color-border;
content: '';
height: 100%;
position: absolute;
top: $space-normal; top: $space-normal;
width: 2px;
} }
&:before { &::before {
top: $zero;
height: $space-normal; height: $space-normal;
top: $zero;
} }
&:first-child { &:first-child {
&:before { &::before {
height: 0; height: 0;
} }
} }
&:last-child { &:last-child {
&:after { &::after {
height: $zero; height: $zero;
} }
} }
&.active { &.active {
// left: 1px;
// @include background-white;
// @include border-light;
// border-right: 0;
h3 { h3 {
color: $color-woot; color: $color-woot;
} }
@@ -78,7 +77,7 @@
&.over { &.over {
&:after { &::after {
background: $color-woot; background: $color-woot;
} }
@@ -87,17 +86,17 @@
} }
&+.item { &+.item {
&:before { &::before {
background: $color-woot; background: $color-woot;
} }
} }
} }
h3 { h3 {
font-size: $font-size-default;
padding-left: $space-medium;
line-height: 1;
color: $color-body; color: $color-body;
font-size: $font-size-default;
line-height: 1;
padding-left: $space-medium;
.completed { .completed {
color: $success-color; color: $success-color;
@@ -105,25 +104,25 @@
} }
p { p {
font-size: $font-size-small;
color: $color-light-gray; color: $color-light-gray;
padding-left: $space-medium; font-size: $font-size-small;
margin: 0; margin: 0;
padding-left: $space-medium;
} }
.step { .step {
position: absolute;
left: $space-normal;
top: $space-normal;
font-size: $font-size-small;
font-weight: $font-weight-medium;
background: $color-border; background: $color-border;
border-radius: 20px; border-radius: 20px;
width: $space-normal; color: $color-white;
font-size: $font-size-micro;
font-weight: $font-weight-medium;
height: $space-normal; height: $space-normal;
text-align: center; left: $space-normal;
line-height: $space-normal; line-height: $space-normal;
color: #fff; position: absolute;
text-align: center;
top: $space-normal;
width: $space-normal;
z-index: 999; z-index: 999;
i { i {
@@ -141,10 +140,6 @@
} }
.inoboxes-list { .inoboxes-list {
// @include margin(auto);
// @include background-white;
// @include border-light;
// width: 50%;
.inbox-item { .inbox-item {
@include margin($space-normal); @include margin($space-normal);
@@ -152,20 +147,23 @@
@include flex-shrink; @include flex-shrink;
@include padding($space-normal $space-normal); @include padding($space-normal $space-normal);
@include border-light-bottom(); @include border-light-bottom();
flex-direction: column;
background: $color-white; background: $color-white;
cursor: pointer; cursor: pointer;
width: 20%; flex-direction: column;
float: left; float: left;
min-height: 10rem; min-height: 10rem;
width: 20%;
&:last-child { &:last-child {
margin-bottom: $zero;
@include border-nil; @include border-nil;
margin-bottom: $zero;
} }
&:hover { &:hover {
@include background-gray; @include background-gray;
.arrow { .arrow {
opacity: 1; opacity: 1;
transform: translateX($space-small); transform: translateX($space-small);
@@ -174,8 +172,8 @@
.switch { .switch {
align-self: center; align-self: center;
margin-right: $space-normal;
margin-bottom: $zero; margin-bottom: $zero;
margin-right: $space-normal;
} }
.item--details { .item--details {
@@ -187,15 +185,15 @@
} }
.item--sub { .item--sub {
margin-bottom: 0;
font-size: $font-size-small; font-size: $font-size-small;
margin-bottom: 0;
} }
} }
.arrow { .arrow {
align-self: center; align-self: center;
font-size: $font-size-small;
color: $medium-gray; color: $medium-gray;
font-size: $font-size-small;
opacity: .7; opacity: .7;
transform: translateX(0); transform: translateX(0);
transition: opacity 0.100s ease-in 0s, transform 0.200s ease-in 0.030s; transition: opacity 0.100s ease-in 0s, transform 0.200s ease-in 0.030s;
@@ -204,18 +202,19 @@
} }
.settings--content { .settings--content {
@include margin($space-small $space-medium); @include margin($space-small $space-larger);
.title { .title {
font-weight: $font-weight-medium; font-weight: $font-weight-medium;
} }
.code { .code {
@include padding($space-one);
background: $color-background;
max-height: $space-mega; max-height: $space-mega;
overflow: auto; overflow: auto;
white-space: nowrap; white-space: nowrap;
@include padding($space-one);
background: $color-background;
code { code {
background: transparent; background: transparent;
@@ -225,14 +224,14 @@
} }
.login-init { .login-init {
text-align: center;
padding-top: 30%; padding-top: 30%;
text-align: center;
p { p {
@include padding($space-medium); @include padding($space-medium);
} }
> a > img { >a>img {
width: $space-larger * 5; width: $space-larger * 5;
} }
} }

View File

@@ -1,8 +1,8 @@
.integrations-wrap { .integrations-wrap {
.integration { .integration {
background: $color-white; background: $color-white;
border: 2px solid $color-border; border: 1px solid $color-border;
border-radius: $space-slab; border-radius: $space-smaller;
padding: $space-normal; padding: $space-normal;
.integration--image { .integration--image {

View File

@@ -45,6 +45,7 @@
.user--name { .user--name {
@include margin(0); @include margin(0);
font-size: $font-size-medium; font-size: $font-size-medium;
line-height: 1.3;
text-transform: capitalize; text-transform: capitalize;
} }
@@ -65,6 +66,8 @@
} }
.button.resolve--button { .button.resolve--button {
width: 13.2rem;
>.icon { >.icon {
font-size: $font-size-default; font-size: $font-size-default;
padding-right: $space-small; padding-right: $space-small;

View File

@@ -1,23 +1,53 @@
.conversation { .conversation {
@include flex; @include flex;
@include flex-shrink; @include flex-shrink;
@include padding($space-normal $zero $zero $space-normal); @include padding(0 0 0 $space-normal);
align-items: center;
border-bottom: 1px solid transparent;
border-left: $space-micro solid transparent;
border-top: 1px solid transparent;
cursor: pointer; cursor: pointer;
position: relative; position: relative;
&.active { &.active {
background: $color-background; background: $color-background;
border-bottom-color: $color-border-light;
border-left-color: $color-woot;
border-top-color: $color-border-light;
.conversation--details {
border-top-color: transparent;
}
+.conversation .conversation--details {
border-top-color: transparent;
}
} }
&:first-child {
.conversation--details {
border-top-color: transparent;
}
}
&:nth-last-child(2) {
.conversation--details {
border-bottom-color: $color-border-light;
}
}
.conversation--details { .conversation--details {
@include margin($zero $zero $zero $space-one); @include margin(0 0 0 $space-one);
@include border-light-bottom; @include border-light-bottom;
@include padding($zero $zero $space-slab $zero); @include border-light-top;
@include padding($space-slab 0);
border-bottom-color: transparent;
} }
.conversation--user { .conversation--user {
font-size: $font-size-small; font-size: $font-size-small;
margin-bottom: $zero; margin-bottom: 0;
text-transform: capitalize; text-transform: capitalize;
.label { .label {
@@ -37,7 +67,7 @@
font-weight: $font-weight-normal; font-weight: $font-weight-normal;
height: $space-medium; height: $space-medium;
line-height: $space-medium; line-height: $space-medium;
margin: $zero; margin: 0;
max-width: 96%; max-width: 96%;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -52,20 +82,20 @@
.conversation--meta { .conversation--meta {
@include flex; @include flex;
display: block;
flex-direction: column; flex-direction: column;
position: absolute; position: absolute;
right: $space-normal; right: $space-normal;
top: $space-normal; top: $space-normal;
.unread { .unread {
$unread-size: $space-two - $space-micro; $unread-size: $space-normal;
@include round-corner; @include round-corner;
@include light-shadow;
background: darken($success-color, 3%); background: darken($success-color, 3%);
color: $color-white; color: $color-white;
display: none; display: none;
font-size: $font-size-mini; font-size: $font-size-micro;
font-weight: $font-weight-medium; font-weight: $font-weight-black;
height: $unread-size; height: $unread-size;
line-height: $unread-size; line-height: $unread-size;
margin-left: auto; margin-left: auto;

View File

@@ -1,10 +1,11 @@
@mixin bubble-with-tyes { @mixin bubble-with-types {
@include padding($space-smaller $space-one); @include padding($space-small $space-normal);
@include margin($zero); @include margin($zero);
background: $color-primary-light; background: $color-woot;
border-radius: $space-small; border-radius: $space-one;
color: $color-heading; color: $color-white;
font-size: $font-size-small; font-size: $font-size-small;
font-weight: $font-weight-normal;
position: relative; position: relative;
.icon { .icon {
@@ -15,6 +16,17 @@
.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 {
color: $color-white;
}
} }
.message-text { .message-text {
@@ -51,8 +63,7 @@
} }
&::before { &::before {
$color-black: #000; background-image: linear-gradient(-180deg, transparent 3%, $color-heading 130%);
background-image: linear-gradient(-180deg, transparent 3%, $color-black 70%);
bottom: 0; bottom: 0;
content: ''; content: '';
height: 20%; height: 20%;
@@ -94,6 +105,7 @@
.load-more-conversations { .load-more-conversations {
font-size: $font-size-small; font-size: $font-size-small;
margin: 0;
padding: $space-normal; padding: $space-normal;
width: 100%; width: 100%;
} }
@@ -122,10 +134,10 @@
.status--filter { .status--filter {
@include padding($zero null $zero $space-normal); @include padding($zero null $zero $space-normal);
@include border-light;
@include round-corner; @include round-corner;
@include margin($space-smaller $space-slab $zero $zero); @include margin($space-smaller $space-slab $zero $zero);
background-color: $color-background; background-color: $color-background-light;
border: 1px solid $color-border;
float: right; float: right;
font-size: $font-size-mini; font-size: $font-size-mini;
height: $space-medium; height: $space-medium;
@@ -192,168 +204,225 @@
height: 100%; height: 100%;
margin-bottom: $space-small; margin-bottom: $space-small;
overflow-y: auto; overflow-y: auto;
position: relative;
}
>li { .conversation-panel>li {
@include flex; @include flex;
@include flex-shrink; @include flex-shrink;
@include margin($zero $zero $space-smaller); @include margin($zero $zero $space-micro);
position: relative;
&:first-child { &:first-child {
margin-top: auto; margin-top: auto;
}
&:last-child {
margin-bottom: $space-small;
}
&.unread--toast {
span {
@include elegant-card;
@include round-corner;
background: $color-woot;
color: $color-white;
font-size: $font-size-mini;
font-weight: $font-weight-medium;
margin: $space-one auto;
padding: $space-smaller $space-two;
} }
}
&:last-child { .bubble {
margin-bottom: $space-small; @include bubble-with-types;
max-width: 50rem;
text-align: left;
word-wrap: break-word;
.aplayer {
box-shadow: none;
font-family: inherit;
} }
}
&.unread--toast { &.left {
span {
@include elegant-card; .bubble {
@include round-corner; @include border-normal;
background: $color-woot; background: $white;
color: $color-white; border-bottom-left-radius: $space-smaller;
font-size: $font-size-mini; border-top-left-radius: $space-smaller;
font-weight: $font-weight-medium; color: $color-body;
margin: $space-one auto; margin-right: auto;
padding: $space-smaller $space-two;
.time {
color: $color-light-gray;
} }
.image .time {
color: $color-white;
}
.link {
color: $color-primary-dark;
}
.file {
.text-block-title {
color: $color-body;
}
.icon-wrap {
color: $color-woot;
}
.download {
color: $color-primary-dark;
}
}
}
+.right {
margin-top: $space-one;
.bubble {
border-top-right-radius: $space-one;
}
}
}
&.right {
@include flex-align(right, null);
.wrap {
margin-right: $space-normal;
text-align: right;
} }
.bubble { .bubble {
@include bubble-with-tyes; border-bottom-right-radius: $space-smaller;
max-width: 50rem; border-top-right-radius: $space-smaller;
text-align: left; margin-left: auto;
word-wrap: break-word;
.aplayer { &.is-private {
box-shadow: none; background: lighten($warning-color, 32%);
font-family: inherit; border: 1px solid $color-border;
}
}
&.left {
.bubble {
background: $white;
border-bottom-left-radius: 0;
border-top-left-radius: 0;
color: $color-heading; color: $color-heading;
margin-right: auto; padding-right: $space-large;
} position: relative;
+.right { &::before {
margin-top: $space-one; bottom: 0;
color: $medium-gray;
position: absolute;
right: $space-one;
top: $space-smaller + $space-micro;
}
.bubble { .time {
border-top-right-radius: $space-small; color: $color-light-gray;
} }
} }
} }
&.right { +.left {
@include flex-align(right, null); margin-top: $space-one;
.wrap {
margin-right: $space-normal;
text-align: right;
}
.bubble { .bubble {
border-bottom-right-radius: 0; border-top-left-radius: $space-one;
border-top-right-radius: 0;
margin-left: auto;
&.is-private {
background: lighten($warning-color, 32%);
color: $color-heading;
padding-right: $space-large;
position: relative;
&::before {
bottom: 0;
color: $medium-gray;
position: absolute;
right: $space-one;
top: $space-smaller + $space-micro;
}
}
}
+.left {
margin-top: $space-one;
.bubble {
border-top-left-radius: $space-small;
}
} }
} }
}
.wrap { .wrap {
@include margin($zero $space-normal); @include margin($zero $space-normal);
max-width: 69%; max-width: 69%;
.sender--name { .sender--name {
font-size: $font-size-mini; font-size: $font-size-mini;
margin-bottom: $space-smaller; margin-bottom: $space-smaller;
}
} }
}
.sender--thumbnail { .sender--thumbnail {
@include round-corner(); @include round-corner();
height: $space-slab; height: $space-slab;
margin-right: $space-one; margin-right: $space-one;
margin-top: $space-micro; margin-top: $space-micro;
width: $space-slab; width: $space-slab;
} }
.activity-wrap { .activity-wrap {
@include flex; @include flex;
@include margin($space-small auto); @include margin($space-small auto);
@include padding($space-smaller $space-normal); @include padding($space-small $space-normal);
@include flex-align($x: center, $y: null); @include flex-align($x: center, $y: null);
background: lighten($warning-color, 32%); background: lighten($warning-color, 32%);
border: 1px solid lighten($warning-color, 26%); border: 1px solid lighten($warning-color, 22%);
border-radius: $space-smaller; border-radius: $space-smaller;
font-size: $font-size-small; font-size: $font-size-small;
p { p {
color: $color-heading; color: $color-heading;
margin-bottom: $zero; margin-bottom: $zero;
.ion-person { .ion-person {
color: $color-body; color: $color-body;
font-size: $font-size-default; font-size: $font-size-default;
margin-right: $space-small; margin-right: $space-small;
position: relative; position: relative;
top: $space-micro; top: $space-micro;
}
.message-text__wrap {
position: relative;
}
.message-text {
&::after {
content: ' \00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0';
display: inline;
}
}
} }
.time { .message-text__wrap {
color: $medium-gray; position: relative;
}
.message-text {
&::after {
content: ' \00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0';
display: inline;
}
} }
} }
.time { .time {
bottom: -$space-micro; color: $medium-gray;
color: $color-gray;
float: right;
font-size: $font-size-micro; font-size: $font-size-micro;
font-style: italic;
margin-left: $space-slab; margin-left: $space-slab;
right: -$space-micro; }
text-align: right; }
}
.conversation-footer {
display: flex;
flex-direction: column;
position: relative;
}
.typing-indicator-wrap {
align-items: center;
display: flex;
height: 0;
position: absolute;
top: -$space-large;
width: 100%;
.typing-indicator {
@include elegant-card;
@include round-corner;
background: $color-white;
color: $color-light-gray;
font-size: $font-size-mini;
font-weight: $font-weight-bold;
margin: $space-one auto;
padding: $space-small $space-normal $space-small $space-two;
.gif {
margin-left: $space-small;
width: $space-medium;
} }
} }
} }

View File

@@ -1,3 +1,6 @@
@import '../variables';
@import '../mixins';
.emoji-dialog { .emoji-dialog {
@include elegant-card; @include elegant-card;
background: $color-white; background: $color-white;
@@ -15,15 +18,15 @@
} }
.emojione { .emojione {
@include margin($zero); font-size: $font-size-default;
font-size: $font-size-medium; margin: $zero;
} }
.emoji-row { .emoji-row {
@include padding($space-small);
box-sizing: border-box; box-sizing: border-box;
height: 180px; height: 180px;
overflow-y: auto; overflow-y: auto;
padding: $space-small;
.emoji { .emoji {
border-radius: 4px; border-radius: 4px;
@@ -52,27 +55,33 @@
} }
.emoji-dialog-header { .emoji-dialog-header {
@include padding($zero $space-smaller); background-color: $color-body;
background-color: $light-gray;
border-top-left-radius: $space-small; border-top-left-radius: $space-small;
border-top-right-radius: $space-small; border-top-right-radius: $space-small;
padding: $zero $space-smaller;
ul { ul {
display: flex;
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: $space-smaller 0 0; padding: $space-smaller 0 0;
> li { >li {
@include padding($space-smaller $space-small); align-items: center;
box-sizing: border-box;
cursor: pointer; cursor: pointer;
display: inline-block; display: flex;
height: 3.4rem; height: $space-medium;
text-align: center; justify-content: center;
padding: $space-smaller $space-small;
} }
> .active { .emojione {
background: $white; height: $space-two;
width: $space-normal;
}
>.active {
background: $color-white;
border-top-left-radius: $space-small; border-top-left-radius: $space-small;
border-top-right-radius: $space-small; border-top-right-radius: $space-small;
} }
@@ -84,13 +93,14 @@
} }
.active { .active {
img, img,
svg { svg {
filter: grayscale(0); filter: grayscale(0);
} }
} }
> * { >* {
display: table-cell; display: table-cell;
vertical-align: middle; vertical-align: middle;
} }

View File

@@ -1,18 +1,17 @@
.error { .error {
#{$all-text-inputs}, #{$all-text-inputs},
select,
.multiselect > .multiselect__tags { .multiselect > .multiselect__tags {
@include thin-border( darken(get-color(alert), 25%)); @include thin-border(darken(get-color(alert), 25%));
} }
}
.error {
.message { .message {
display: block;
width: 100%;
margin-top: -$space-normal;
margin-bottom: $space-one;
color: darken(get-color(alert), 25%); color: darken(get-color(alert), 25%);
display: block;
font-weight: $font-weight-normal; font-weight: $font-weight-normal;
margin-bottom: $space-one;
margin-top: -$space-normal;
width: 100%;
} }
} }
@@ -25,7 +24,7 @@ input {
} }
.input-wrap { .input-wrap {
font-size: $font-size-small;
color: $color-heading; color: $color-heading;
font-size: $font-size-small;
font-weight: $font-weight-medium; font-weight: $font-weight-medium;
} }

View File

@@ -42,22 +42,26 @@
font-size: $font-size-default; font-size: $font-size-default;
input { input {
padding: $space-slab;
height: $space-larger;
font-size: $font-size-default; font-size: $font-size-default;
height: $space-larger;
padding: $space-slab;
} }
.error { .error {
font-size: $font-size-small; font-size: $font-size-small;
} }
} }
.button {
height: $space-larger;
}
} }
.sigin__footer { .sigin__footer {
font-size: $font-size-default; font-size: $font-size-default;
padding: $space-medium; padding: $space-medium;
> a { >a {
font-weight: $font-weight-bold; font-weight: $font-weight-bold;
} }
} }

View File

@@ -48,7 +48,7 @@
margin-bottom: $space-micro; margin-bottom: $space-micro;
margin-top: $space-micro; margin-top: $space-micro;
>.inbox-icon { .inbox-icon {
display: inline-block; display: inline-block;
margin-right: $space-micro; margin-right: $space-micro;
min-width: $space-normal; min-width: $space-normal;

View File

@@ -1,33 +1,32 @@
.ui-snackbar-container { .ui-snackbar-container {
position: absolute; left: 0;
margin: 0 auto;
max-width: 40rem;
overflow: hidden; overflow: hidden;
z-index: 9999; position: absolute;
top: $space-normal; right: 0;
left: $space-normal;
width: 100%;
text-align: center; text-align: center;
top: $space-normal;
z-index: 9999;
} }
.ui-snackbar { .ui-snackbar {
text-align: left; @include padding($space-slab $space-medium);
@include shadow;
background-color: $woot-snackbar-bg;
border-radius: $space-smaller;
display: inline-block; display: inline-block;
min-width: 24rem; margin-bottom: $space-small;
max-width: 40rem; max-width: 40rem;
min-height: 3rem; min-height: 3rem;
background-color: $woot-snackbar-bg; min-width: 24rem;
@include padding($space-slab $space-medium); text-align: left;
@include border-top-radius($space-micro);
@include border-right-radius($space-micro);
@include border-bottom-radius($space-micro);
@include border-left-radius($space-micro);
margin-bottom: $space-small;
// box-shadow: 0 1px 3px alpha(black, 0.12), 0 1px 2px alpha(black, 0.24);
} }
.ui-snackbar-text { .ui-snackbar-text {
font-size: $font-size-small;
color: $color-white; color: $color-white;
font-size: $font-size-small;
font-weight: $font-weight-medium;
} }
.ui-snackbar-action { .ui-snackbar-action {
@@ -35,12 +34,12 @@
padding-left: 3rem; padding-left: 3rem;
button { button {
@include margin(0);
@include padding(0);
background: none; background: none;
border: 0; border: 0;
color: $woot-snackbar-button; color: $woot-snackbar-button;
font-size: $font-size-small; font-size: $font-size-small;
text-transform: uppercase; text-transform: uppercase;
@include margin(0);
@include padding(0);
} }
} }

View File

@@ -1,69 +0,0 @@
<template>
<div class="row settings--form--header">
<div class="medium-8">
<p class="title">
{{ title }}
</p>
<p class="sub-head">
{{ subTitle }}
</p>
</div>
<div v-if="buttonText" class="medium-4 text-right">
<woot-submit-button
class="default"
:button-text="buttonText"
:loading="isUpdating"
@click="onClick()"
/>
</div>
</div>
</template>
<script>
export default {
props: {
title: {
type: String,
required: true,
},
subTitle: {
type: String,
required: true,
},
buttonText: {
type: String,
default: '',
},
isUpdating: {
type: Boolean,
default: false,
},
},
methods: {
onClick() {
this.$emit('update');
},
},
};
</script>
<style lang="scss">
@import '~dashboard/assets/scss/variables';
.settings--form--header {
align-items: center;
border-bottom: 1px solid $color-border;
display: flex;
margin-bottom: $space-normal;
padding: $space-normal 0;
.button {
margin-bottom: 0;
}
.title {
margin-bottom: 0;
font-size: $font-size-default;
}
}
</style>

View File

@@ -0,0 +1,46 @@
<template>
<div class="row settings--section">
<div class="medium-4">
<p class="sub-block-title">
{{ title }}
</p>
<p class="sub-head">
{{ subTitle }}
</p>
</div>
<div class="medium-6">
<slot></slot>
</div>
</div>
</template>
<script>
export default {
props: {
title: {
type: String,
required: true,
},
subTitle: {
type: String,
required: true,
},
},
};
</script>
<style lang="scss">
@import '~dashboard/assets/scss/variables';
.settings--section {
border-bottom: 1px solid $color-border;
display: flex;
padding: $space-normal 0;
.sub-block-title {
color: $color-woot;
font-weight: $font-weight-medium;
margin-bottom: 0;
}
}
</style>

View File

@@ -1,6 +1,10 @@
<template> <template>
<transition-group name="toast-fade" tag="div" class="ui-snackbar-container"> <transition-group name="toast-fade" tag="div" class="ui-snackbar-container">
<woot-snackbar :message="snackMessage" v-for="snackMessage in snackMessages" v-bind:key="snackMessage" /> <woot-snackbar
v-for="snackMessage in snackMessages"
:key="snackMessage"
:message="snackMessage"
/>
</transition-group> </transition-group>
</template> </template>
@@ -9,8 +13,12 @@
import WootSnackbar from './Snackbar'; import WootSnackbar from './Snackbar';
export default { export default {
components: {
WootSnackbar,
},
props: { props: {
duration: { duration: {
type: Number,
default: 2500, default: 2500,
}, },
}, },
@@ -22,16 +30,12 @@ export default {
}, },
mounted() { mounted() {
bus.$on('newToastMessage', (message) => { bus.$on('newToastMessage', message => {
this.snackMessages.push(message); this.snackMessages.push(message);
window.setTimeout(() => { window.setTimeout(() => {
this.snackMessages.splice(0, 1); this.snackMessages.splice(0, 1);
}, this.duration); }, this.duration);
}); });
}, },
components: {
WootSnackbar,
},
}; };
</script> </script>

View File

@@ -1,6 +1,6 @@
<template> <template>
<button <button
type="submit" :type="type"
:disabled="disabled" :disabled="disabled"
:class="computedClass" :class="computedClass"
@click="onClick" @click="onClick"
@@ -39,6 +39,10 @@ export default {
type: String, type: String,
default: '', default: '',
}, },
type: {
type: String,
default: 'submit',
},
}, },
computed: { computed: {
computedClass() { computedClass() {

View File

@@ -33,6 +33,14 @@
:style="badgeStyle" :style="badgeStyle"
src="~dashboard/assets/images/twitter-badge.png" src="~dashboard/assets/images/twitter-badge.png"
/> />
<img
v-if="badge === 'Channel::TwilioSms'"
id="badge"
class="source-badge"
:style="badgeStyle"
src="~dashboard/assets/images/channels/whatsapp.png"
/>
</div> </div>
</template> </template>
<script> <script>

View File

@@ -14,7 +14,7 @@ const chartOptions = {
scales: { scales: {
xAxes: [ xAxes: [
{ {
barPercentage: 1.9, barPercentage: 1.26,
ticks: { ticks: {
fontFamily, fontFamily,
}, },
@@ -27,6 +27,7 @@ const chartOptions = {
{ {
ticks: { ticks: {
fontFamily, fontFamily,
beginAtZero: true,
}, },
gridLines: { gridLines: {
display: false, display: false,

View File

@@ -105,15 +105,17 @@ export default {
router.push({ path: frontendURL(path) }); router.push({ path: frontendURL(path) });
}, },
extractMessageText(chatItem) { extractMessageText(chatItem) {
if (chatItem.content) { const { content, attachments } = chatItem;
return chatItem.content;
if (content) {
return content;
} }
let fileType = ''; if (!attachments) {
if (chatItem.attachment) {
fileType = chatItem.attachment.file_type;
} else {
return ' '; return ' ';
} }
const [attachment] = attachments;
const { file_type: fileType } = attachment;
const key = `CHAT_LIST.ATTACHMENTS.${fileType}`; const key = `CHAT_LIST.ATTACHMENTS.${fileType}`;
return ` return `
<i class="small-icon ${this.$t(`${key}.ICON`)}"></i> <i class="small-icon ${this.$t(`${key}.ICON`)}"></i>

View File

@@ -40,10 +40,10 @@
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import adminMixin from '../../../mixins/isAdmin'; import adminMixin from '../../../mixins/isAdmin';
import { frontendURL } from '../../../helper/URLHelper'; import accountMixin from '../../../mixins/account';
export default { export default {
mixins: [adminMixin], mixins: [accountMixin, adminMixin],
computed: { computed: {
...mapGetters({ ...mapGetters({
@@ -60,7 +60,7 @@ export default {
return this.$t('CONVERSATION.LOADING_CONVERSATIONS'); return this.$t('CONVERSATION.LOADING_CONVERSATIONS');
}, },
newInboxURL() { newInboxURL() {
return frontendURL('settings/inboxes/new'); return this.addAccountScoping('settings/inboxes/new');
}, },
}, },
}; };

View File

@@ -1,22 +1,26 @@
<template> <template>
<li v-if="data.attachment || data.content" :class="alignBubble"> <li v-if="hasAttachments || data.content" :class="alignBubble">
<div :class="wrapClass"> <div :class="wrapClass">
<p v-tooltip.top-start="sentByMessage" :class="bubbleClass"> <p v-tooltip.top-start="sentByMessage" :class="bubbleClass">
<bubble-image
v-if="data.attachment && data.attachment.file_type === 'image'"
:url="data.attachment.data_url"
:readable-time="readableTime"
/>
<bubble-file
v-if="data.attachment && data.attachment.file_type !== 'image'"
:url="data.attachment.data_url"
:readable-time="readableTime"
/>
<bubble-text <bubble-text
v-if="data.content" v-if="data.content"
:message="message" :message="message"
:readable-time="readableTime" :readable-time="readableTime"
/> />
<span v-if="hasAttachments">
<span v-for="attachment in data.attachments" :key="attachment.id">
<bubble-image
v-if="attachment.file_type === 'image'"
:url="attachment.data_url"
:readable-time="readableTime"
/>
<bubble-file
v-if="attachment.file_type !== 'image'"
:url="attachment.data_url"
:readable-time="readableTime"
/>
</span>
</span>
<i <i
v-if="isPrivate" v-if="isPrivate"
v-tooltip.top-start="toolTipMessage" v-tooltip.top-start="toolTipMessage"
@@ -71,10 +75,16 @@ export default {
isBubble() { isBubble() {
return [0, 1, 3].includes(this.data.message_type); return [0, 1, 3].includes(this.data.message_type);
}, },
hasAttachments() {
return !!(this.data.attachments && this.data.attachments.length > 0);
},
hasImageAttachment() { hasImageAttachment() {
const { attachment = {} } = this.data; if (this.hasAttachments && this.data.attachments.length > 0) {
const { file_type: fileType } = attachment; const { attachments = [{}] } = this.data;
return fileType === 'image'; const { file_type: fileType } = attachments[0];
return fileType === 'image';
}
return false;
}, },
isPrivate() { isPrivate() {
return this.data.private; return this.data.private;

View File

@@ -27,10 +27,22 @@
:data="message" :data="message"
/> />
</ul> </ul>
<ReplyBox <div class="conversation-footer">
:conversation-id="currentChat.id" <div v-if="isAnyoneTyping" class="typing-indicator-wrap">
@scrollToMessage="focusLastMessage" <div class="typing-indicator">
/> {{ typingUserNames }}
<img
class="gif"
src="~dashboard/assets/images/typing.gif"
alt="Someone is typing"
/>
</div>
</div>
<ReplyBox
:conversation-id="currentChat.id"
@scrollToMessage="focusLastMessage"
/>
</div>
</div> </div>
</template> </template>
@@ -42,6 +54,7 @@ import ConversationHeader from './ConversationHeader';
import ReplyBox from './ReplyBox'; 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';
export default { export default {
components: { components: {
@@ -81,6 +94,27 @@ export default {
loadingChatList: 'getChatListLoadingStatus', loadingChatList: 'getChatListLoadingStatus',
}), }),
typingUsersList() {
const userList = this.$store.getters[
'conversationTypingStatus/getUserList'
](this.currentChat.id);
return userList;
},
isAnyoneTyping() {
const userList = this.typingUsersList;
return userList.length !== 0;
},
typingUserNames() {
const userList = this.typingUsersList;
if (this.isAnyoneTyping) {
const userListAsName = getTypingUsersText(userList);
return userListAsName;
}
return '';
},
getMessages() { getMessages() {
const [chat] = this.allConversations.filter( const [chat] = this.allConversations.filter(
c => c.id === this.currentChat.id c => c.id === this.currentChat.id

View File

@@ -20,12 +20,13 @@
class="input" class="input"
type="text" type="text"
:placeholder="$t(messagePlaceHolder())" :placeholder="$t(messagePlaceHolder())"
@click="onClick()" @focus="onFocus"
@blur="onBlur()" @blur="onBlur"
/> />
<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"
@input-file="onFileUpload" @input-file="onFileUpload"
> >
<i <i
@@ -142,7 +143,10 @@ export default {
return 10000; return 10000;
}, },
showFileUpload() { showFileUpload() {
return this.channelType === 'Channel::WebWidget'; return (
this.channelType === 'Channel::WebWidget' ||
this.channelType === 'Channel::TwilioSms'
);
}, },
replyButtonLabel() { replyButtonLabel() {
if (this.isPrivate) { if (this.isPrivate) {
@@ -256,25 +260,16 @@ export default {
onBlur() { onBlur() {
this.toggleTyping('off'); this.toggleTyping('off');
}, },
onClick() { onFocus() {
this.markSeen();
this.toggleTyping('on'); this.toggleTyping('on');
}, },
markSeen() {
if (this.channelType === 'Channel::FacebookPage') {
this.$store.dispatch('markSeen', {
inboxId: this.currentChat.inbox_id,
contactId: this.currentChat.meta.sender.id,
});
}
},
toggleTyping(status) { toggleTyping(status) {
if (this.channelType === 'Channel::FacebookPage') { if (this.channelType === 'Channel::WebWidget' && !this.isPrivate) {
const conversationId = this.currentChat.id;
this.$store.dispatch('toggleTyping', { this.$store.dispatch('toggleTyping', {
status, status,
inboxId: this.currentChat.inbox_id, conversationId,
contactId: this.currentChat.meta.sender.id,
}); });
} }
}, },
@@ -295,6 +290,9 @@ export default {
}, },
onFileUpload(file) { onFileUpload(file) {
if (!file) {
return;
}
this.isUploading.image = true; this.isUploading.image = true;
this.$store this.$store
.dispatch('sendAttachment', [this.currentChat.id, { file: file.file }]) .dispatch('sendAttachment', [this.currentChat.id, { file: file.file }])

View File

@@ -44,12 +44,12 @@ export default {
.file { .file {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
padding: $space-normal; padding: $space-smaller 0;
cursor: pointer; cursor: pointer;
.icon-wrap { .icon-wrap {
font-size: $font-size-giga; font-size: $font-size-giga;
color: $color-woot; color: $color-white;
line-height: 1; line-height: 1;
margin-left: $space-smaller; margin-left: $space-smaller;
margin-right: $space-slab; margin-right: $space-slab;
@@ -57,15 +57,22 @@ export default {
.text-block-title { .text-block-title {
margin: 0; margin: 0;
color: $color-white;
font-weight: $font-weight-bold;
} }
.button { .button {
padding: 0; padding: 0;
margin: 0; margin: 0;
color: $color-primary-light;
} }
.meta { .meta {
padding-right: $space-two; padding-right: $space-two;
} }
.time {
min-width: $space-larger;
}
} }
</style> </style>

View File

@@ -1,7 +1,7 @@
<template> <template>
<span class="message-text__wrap"> <span class="message-text__wrap">
<span class="time">{{ readableTime }}</span>
<span v-html="message"></span> <span v-html="message"></span>
<span class="time">{{ readableTime }}</span>
</span> </span>
</template> </template>

View File

@@ -3,37 +3,39 @@
<header class="emoji-dialog-header" role="menu"> <header class="emoji-dialog-header" role="menu">
<ul> <ul>
<li <li
v-bind:class="{ 'active': selectedKey === category.key }"
v-for="category in categoryList" v-for="category in categoryList"
:key="category.key"
:class="{ active: selectedKey === category.key }"
@click="changeCategory(category)" @click="changeCategory(category)"
> >
<div <div
@click="changeCategory(category)"
role="menuitem" role="menuitem"
class="emojione" class="emojione"
v-html="getEmojiUnicode(`:${category.emoji}:`)" @click="changeCategory(category)"
> v-html="` ${getEmojiUnicode(`:${category.emoji}:`)}`"
</div> ></div>
</li> </li>
</ul> </ul>
</header> </header>
<div class="emoji-row"> <div class="emoji-row">
<h5 class="emoji-category-title">{{selectedKey}}</h5> <h5 class="emoji-category-title">
{{ selectedKey }}
</h5>
<div <div
v-for="(emoji, key) in selectedEmojis" v-for="emoji in filteredSelectedEmojis"
:key="emoji.shortname"
role="menuitem" role="menuitem"
:class="`emojione`" class="emojione"
v-html="getEmojiUnicode(emoji[emoji.length - 1].shortname)"
v-if="filterEmoji(emoji[emoji.length - 1].shortname)"
track-by="$index" track-by="$index"
@click="onClick(emoji[emoji.length - 1])" @click="onClick(emoji)"
v-html="getEmojiUnicode(emoji.shortname)"
/> />
</div>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
/* eslint-disable no-restricted-syntax */
import strategy from 'emojione/emoji.json'; import strategy from 'emojione/emoji.json';
import categoryList from './categories'; import categoryList from './categories';
import { getEmojiUnicode } from './utils'; import { getEmojiUnicode } from './utils';
@@ -44,7 +46,7 @@ export default {
return { return {
selectedKey: 'people', selectedKey: 'people',
categoryList, categoryList,
selectedEmojis: [], selectedEmojis: {},
}; };
}, },
computed: { computed: {
@@ -76,13 +78,29 @@ export default {
} }
return emojiArr; return emojiArr;
}, },
filteredSelectedEmojis() {
const emojis = this.selectedEmojis;
const filteredEmojis = Object.keys(emojis)
.map(key => {
const emoji = emojis[key];
const [lastEmoji] = emoji.slice(-1);
return { ...lastEmoji, key };
})
.filter(emoji => {
const { shortname } = emoji;
if (shortname) {
return this.filterEmoji(shortname);
}
return false;
});
return filteredEmojis;
},
}, },
// On mount render initial emoji // On mount render initial emoji
mounted() { mounted() {
this.getInitialEmoji(); this.getInitialEmoji();
}, },
methods: { methods: {
// Change category and associated emojis // Change category and associated emojis
changeCategory(category) { changeCategory(category) {
this.selectedKey = category.key; this.selectedKey = category.key;
@@ -101,3 +119,6 @@ export default {
}, },
}; };
</script> </script>
<style lang="scss" scoped>
@import '~dashboard/assets/scss/widgets/emojiinput';
</style>

View File

@@ -4,16 +4,25 @@ import BaseActionCableConnector from '../../shared/helpers/BaseActionCableConnec
class ActionCableConnector extends BaseActionCableConnector { class ActionCableConnector extends BaseActionCableConnector {
constructor(app, pubsubToken) { constructor(app, pubsubToken) {
super(app, pubsubToken); super(app, pubsubToken);
this.CancelTyping = [];
this.events = { this.events = {
'message.created': this.onMessageCreated, 'message.created': this.onMessageCreated,
'message.updated': this.onMessageUpdated,
'conversation.created': this.onConversationCreated, 'conversation.created': this.onConversationCreated,
'status_change:conversation': this.onStatusChange, 'conversation.opened': this.onStatusChange,
'conversation.resolved': this.onStatusChange,
'user:logout': this.onLogout, 'user:logout': this.onLogout,
'page:reload': this.onReload, 'page:reload': this.onReload,
'assignee.changed': this.onAssigneeChanged, 'assignee.changed': this.onAssigneeChanged,
'conversation.typing_on': this.onTypingOn,
'conversation.typing_off': this.onTypingOff,
}; };
} }
onMessageUpdated = data => {
this.app.$store.dispatch('updateMessage', data);
};
onAssigneeChanged = payload => { onAssigneeChanged = payload => {
const { meta = {}, id } = payload; const { meta = {}, id } = payload;
const { assignee } = meta || {}; const { assignee } = meta || {};
@@ -35,7 +44,45 @@ class ActionCableConnector extends BaseActionCableConnector {
onReload = () => window.location.reload(); onReload = () => window.location.reload();
onStatusChange = data => { onStatusChange = data => {
this.app.$store.dispatch('addConversation', data); this.app.$store.dispatch('updateConversation', data);
};
onTypingOn = ({ conversation, user }) => {
const conversationId = conversation.id;
this.clearTimer(conversationId);
this.app.$store.dispatch('conversationTypingStatus/create', {
conversationId,
user,
});
this.initTimer({ conversation, user });
};
onTypingOff = ({ conversation, user }) => {
const conversationId = conversation.id;
this.clearTimer(conversationId);
this.app.$store.dispatch('conversationTypingStatus/destroy', {
conversationId,
user,
});
};
clearTimer = conversationId => {
const timerEvent = this.CancelTyping[conversationId];
if (timerEvent) {
clearTimeout(timerEvent);
this.CancelTyping[conversationId] = null;
}
};
initTimer = ({ conversation, user }) => {
const conversationId = conversation.id;
// Turn off typing automatically after 30 seconds
this.CancelTyping[conversationId] = setTimeout(() => {
this.onTypingOff({ conversation, user });
}, 30000);
}; };
} }

View File

@@ -9,3 +9,20 @@ export default () => {
}); });
} }
}; };
export const getTypingUsersText = (users = []) => {
const count = users.length;
if (count === 1) {
const [user] = users;
return `${user.name} is typing`;
}
if (count === 2) {
const [first, second] = users;
return `${first.name} and ${second.name} are typing`;
}
const [user] = users;
const rest = users.length - 1;
return `${user.name} and ${rest} others are typing`;
};

View File

@@ -0,0 +1,93 @@
/* eslint-disable no-console */
import NotificationSubscriptions from '../api/notificationSubscription';
import auth from '../api/auth';
export const verifyServiceWorkerExistence = (callback = () => {}) => {
if (!('serviceWorker' in navigator)) {
// Service Worker isn't supported on this browser, disable or hide UI.
return;
}
if (!('PushManager' in window)) {
// Push isn't supported on this browser, disable or hide UI.
return;
}
navigator.serviceWorker
.register('/sw.js')
.then(registration => callback(registration))
.catch(registrationError => {
// eslint-disable-next-line
console.log('SW registration failed: ', registrationError);
});
};
export const hasPushPermissions = () => {
if ('Notification' in window) {
return Notification.permission === 'granted';
}
return false;
};
const generateKeys = str =>
btoa(String.fromCharCode.apply(null, new Uint8Array(str)))
.replace(/\+/g, '-')
.replace(/\//g, '_');
export const getPushSubscriptionPayload = subscription => ({
subscription_type: 'browser_push',
subscription_attributes: {
endpoint: subscription.endpoint,
p256dh: generateKeys(subscription.getKey('p256dh')),
auth: generateKeys(subscription.getKey('auth')),
},
});
export const sendRegistrationToServer = subscription => {
if (auth.isLoggedIn()) {
return NotificationSubscriptions.create(
getPushSubscriptionPayload(subscription)
);
}
return null;
};
export const registerSubscription = (onSuccess = () => {}) => {
if (!window.chatwootConfig.vapidPublicKey) {
return;
}
navigator.serviceWorker.ready
.then(serviceWorkerRegistration =>
serviceWorkerRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: window.chatwootConfig.vapidPublicKey,
})
)
.then(sendRegistrationToServer)
.then(() => {
onSuccess();
})
.catch(() => {
window.bus.$emit(
'newToastMessage',
'This browser does not support desktop notification'
);
});
};
export const requestPushPermissions = ({ onSuccess }) => {
if (!('Notification' in window)) {
window.bus.$emit(
'newToastMessage',
'This browser does not support desktop notification'
);
} else if (Notification.permission === 'granted') {
registerSubscription(onSuccess);
} else if (Notification.permission !== 'denied') {
Notification.requestPermission(permission => {
if (permission === 'granted') {
registerSubscription(onSuccess);
}
});
}
};

View File

@@ -0,0 +1,26 @@
import { getTypingUsersText } from '../commons';
describe('#getTypingUsersText', () => {
it('returns the correct text is there is only one typing user', () => {
expect(getTypingUsersText([{ name: 'Pranav' }])).toEqual(
'Pranav is typing'
);
});
it('returns the correct text is there are two typing users', () => {
expect(
getTypingUsersText([{ name: 'Pranav' }, { name: 'Nithin' }])
).toEqual('Pranav and Nithin are typing');
});
it('returns the correct text is there are more than two users are typing', () => {
expect(
getTypingUsersText([
{ name: 'Pranav' },
{ name: 'Nithin' },
{ name: 'Subin' },
{ name: 'Sojan' },
])
).toEqual('Pranav and 3 others are typing');
});
});

View File

@@ -1,37 +0,0 @@
import de from './locale/de';
export default {
...de,
APP_GLOBAL: {
TRIAL_MESSAGE: 'verbleibende Tage Probezeit.',
TRAIL_BUTTON: 'Kaufe jetzt',
},
COMPONENTS: {
CODE: {
BUTTON_TEXT: 'Kopieren',
COPY_SUCCESSFUL: 'Code erfolgreich in die Zwischenablage kopiert',
},
FILE_BUBBLE: {
DOWNLOAD: 'Herunterladen',
UPLOADING: 'Hochladen...',
},
},
CONFIRM_EMAIL: 'Überprüfen...',
SETTINGS: {
INBOXES: {
NEW_INBOX: 'Posteingang hinzufügen',
},
},
SIDEBAR: {
CONVERSATIONS: 'Gespräche',
REPORTS: 'Berichte',
SETTINGS: 'Die Einstellungen',
HOME: 'Zuhause',
AGENTS: 'Agenten',
INBOXES: 'Posteingänge',
CANNED_RESPONSES: 'Vorgefertigte Antworten',
BILLING: 'Abrechnung',
INTEGRATIONS: 'Integrationen',
ACCOUNT_SETTINGS: 'Kontoeinstellungen',
},
};

View File

@@ -1,37 +0,0 @@
import en from './locale/en';
export default {
...en,
APP_GLOBAL: {
TRIAL_MESSAGE: 'days trial remaining.',
TRAIL_BUTTON: 'Buy Now',
},
COMPONENTS: {
CODE: {
BUTTON_TEXT: 'Copy',
COPY_SUCCESSFUL: 'Code copied to clipboard successfully',
},
FILE_BUBBLE: {
DOWNLOAD: 'Download',
UPLOADING: 'Uploading...',
},
},
CONFIRM_EMAIL: 'Verifying...',
SETTINGS: {
INBOXES: {
NEW_INBOX: 'Add Inbox',
},
},
SIDEBAR: {
CONVERSATIONS: 'Conversations',
REPORTS: 'Reports',
SETTINGS: 'Settings',
HOME: 'Home',
AGENTS: 'Agents',
INBOXES: 'Inboxes',
CANNED_RESPONSES: 'Canned Responses',
BILLING: 'Billing',
INTEGRATIONS: 'Integrations',
ACCOUNT_SETTINGS: 'Account Settings',
},
};

View File

@@ -1,7 +1,21 @@
import en from './en'; import ca from './locale/ca';
import de from './de'; import ro from './locale/ro';
import fr from './locale/fr';
import pt_BR from './locale/pt_BR';
import de from './locale/de';
import el from './locale/el';
import en from './locale/en';
import ml from './locale/ml';
import pt from './locale/pt';
export default { export default {
ca,
de, de,
el,
en, en,
fr,
ml,
pt_BR,
pt,
ro,
}; };

View File

@@ -0,0 +1,101 @@
{
"AGENT_MGMT": {
"HEADER": "Agents",
"HEADER_BTN_TXT": "Afegir Agent",
"LOADING": "S'està recollint la llista d'agents",
"SIDEBAR_TXT": "<p><b>Agents</b></p> <p> Un <b>Agent</b> és un membre del teu equip de suport al client. </p><p> Els agents podran veure i respondre als missatges dels vostres usuaris. La llista mostra tots els agents que hi ha actualment al vostre compte. </p><p> Clica en <b>Afegir Agent</b> per afegir un nou agent. Lagent que afegiu rebrà un correu electrònic amb un enllaç de confirmació per activar el seu compte, després del qual podran accedir a Chatwoot i respondre als missatges. </p><p> Laccés a les funcions de Chatwoot es basa en els següents rols. </p><p> <b>Agent</b> - els agents amb aquest rol només poden accedir a bústies de sortida, informes i converses. </p><p> <b>Administrador/a</b> - Ladministrador/a tindrà accés a totes les funcions de Chatwoot habilitades per al vostre compte, incloses les configuracions i la facturació, juntament amb tots els privilegis dels agents normals.</p>",
"AGENT_TYPES": [
{
"name": "administrador/a",
"label": "Administrador/a"
},
{
"name": "agent",
"label": "Agent"
}
],
"LIST": {
"404": "No hi ha agents associats a aquest compte",
"TITLE": "Gestiona agents en el teu equip",
"DESC": "Pots afegir/esborrar agents del teu equip.",
"NAME": "Nom",
"EMAIL": "Correu electrònic",
"STATUS": "Estat",
"ACTIONS": "Accions",
"VERIFIED": "Verificat",
"VERIFICATION_PENDING": "Verificació pendent"
},
"ADD": {
"TITLE": "Afegir agent al teu equip",
"DESC": "Podeu afegir persones que podran gestionar suport per a les vostres safates d'entrada.",
"FORM": {
"NAME": {
"LABEL": "Nom de l'Agent",
"PLACEHOLDER": "Introduïu el nom de l'agent"
},
"AGENT_TYPE": {
"LABEL": "Tipus d'Agent",
"PLACEHOLDER": "Selecciona un tipus",
"ERROR": "El tipus d'Agent és necessari"
},
"EMAIL": {
"LABEL": "Adreça de correu electrònic",
"PLACEHOLDER": "Introduïu l'adreça de correu electrònic de l'agent"
},
"SUBMIT": "Afegir Agent"
},
"API": {
"SUCCESS_MESSAGE": "Agent afegit correctament",
"EXIST_MESSAGE": "L'adreça de correu electrònic de l'agent ja està en ús. Introduïu una altre adreça",
"ERROR_MESSAGE": "No s'ha pogut connectar amb el servidor Woot. Torna-ho a provar més endavant"
}
},
"DELETE": {
"BUTTON_TEXT": "Esborrar",
"API": {
"SUCCESS_MESSAGE": "Agent esborrat correctament",
"ERROR_MESSAGE": "No s'ha pogut connectar amb el servidor Woot. Torna-ho a provar més endavant"
},
"CONFIRM": {
"TITLE": "Confirma l'esborrat",
"MESSAGE": "N'estas segur? ",
"YES": "Si, esborra ",
"NO": "No, manten-la "
}
},
"EDIT": {
"TITLE": "Edita l'agent",
"FORM": {
"NAME": {
"LABEL": "Nom de l'Agent",
"PLACEHOLDER": "Introduïu el nom de l'agent"
},
"AGENT_TYPE": {
"LABEL": "Tipus d'Agent",
"PLACEHOLDER": "Selecciona un tipus",
"ERROR": "El tipus d'Agent és necessari"
},
"EMAIL": {
"LABEL": "Adreça de correu electrònic",
"PLACEHOLDER": "Introduïu l'adreça de correu electrònic de l'agent"
},
"SUBMIT": "Editar l'agent"
},
"BUTTON_TEXT": "Edita",
"CANCEL_BUTTON_TEXT": "Cancel·la",
"API": {
"SUCCESS_MESSAGE": "Agent actualitzat correctament",
"ERROR_MESSAGE": "No s'ha pogut connectar amb el servidor Woot. Torna-ho a provar més endavant"
},
"PASSWORD_RESET": {
"ADMIN_RESET_BUTTON": "Reinicialització de la contrasenya",
"ADMIN_SUCCESS_MESSAGE": "S'ha enviat a l'agent un correu electrònic amb instruccions per restablir la contrasenya",
"SUCCESS_MESSAGE": "La contrasenya de l'agent s'ha restablit correctament",
"ERROR_MESSAGE": "No s'ha pogut connectar amb el servidor Woot. Torna-ho a provar més endavant"
}
},
"SEARCH": {
"NO_RESULTS": "No s'han trobat agents."
}
}
}

View File

@@ -0,0 +1,19 @@
{
"BILLING": {
"HEADER": "Facturació",
"LOADING": "S'estan obtenin les suscripcions",
"ACCOUNT_STATE": "Estat del compte",
"AGENT_COUNT": "Compte d'agent",
"PER_AGENT_COST": "Per cost d'agent",
"TOTAL_COST": "Cost total",
"BUTTON": {
"ADD": "Afegir mètode de pagament",
"EDIT": "EDITAR Mètode de pagament"
},
"TRIAL": {
"TITLE": "S'ha acabat el període de prova",
"MESSAGE": "Afegiu un mètode de pagament per continuar utilitzant Chatwoot."
},
"ACCOUNT_LOCKED": "El seu compte no està disponible de moment. <br>Poseu-vos en contacte amb l'administrador per reactivar-lo."
}
}

View File

@@ -0,0 +1,74 @@
{
"CANNED_MGMT": {
"HEADER": "Respostes predeterminades",
"HEADER_BTN_TXT": "Afegeix una resposta predeterminada",
"LOADING": "S'estan recollint les respostes predeterminades",
"SEARCH_404": "No hi ha cap resposta que coincideixi amb aquesta consulta",
"SIDEBAR_TXT": "<p><b>Respostes predeterminades</b> </p><p> Les respostes predeterminades són plantilles de resposta que es poden utilitzar per enviar ràpidament una resposta a una conversa .</p><p> Per crear una Resposta Predeterminada, clica en <b>Afegir Resposta Predeterminada</b>. També pots editar o suprimir una resposta predeterminada fent clic al botó Edita o Suprimeix </p><p> Les respostes predeterminades s'utilitzen amb l'ajuda dels <b>Codi curt</b>. Els agents poden accedir a les respostes predeterminades en un xat escrivint <b>'/'</b> seguit del codi curt. </p>",
"LIST": {
"404": "No hi ha respostes predeterminades disponibles en aquest compte.",
"TITLE": "Gestiona les respostes predeterminades",
"DESC": "Les respostes predeterminades són plantilles de resposta predefinides que es poden utilitzar per enviar ràpidament respostes a les converses.",
"TABLE_HEADER": [
"Codi curt",
"Contingut",
"Accions"
]
},
"ADD": {
"TITLE": "Afegeix Resposta Predeterminada",
"DESC": "Les respostes predeterminades són plantilles de resposta que es poden utilitzar per enviar ràpidament les respostes a les converses.",
"FORM": {
"SHORT_CODE": {
"LABEL": "Codi curt",
"PLACEHOLDER": "Introduïu un codi curt",
"ERROR": "És necessari el codi curt"
},
"CONTENT": {
"LABEL": "Contingut",
"PLACEHOLDER": "Introduïu un contingut",
"ERROR": "És necessari un contingut"
},
"SUBMIT": "Envia"
},
"API": {
"SUCCESS_MESSAGE": "Resposta predeterminada afegida correctament",
"ERROR_MESSAGE": "No s'ha pogut connectar amb el servidor Woot. Torna-ho a provar més endavant"
}
},
"EDIT": {
"TITLE": "Edita la resposta predeterminada",
"FORM": {
"SHORT_CODE": {
"LABEL": "Codi curt",
"PLACEHOLDER": "Introduïu un codi curt",
"ERROR": "És necessari el codi curt"
},
"CONTENT": {
"LABEL": "Contingut",
"PLACEHOLDER": "Introduïu un contingut",
"ERROR": "És necessari un contingut"
},
"SUBMIT": "Envia"
},
"BUTTON_TEXT": "Edita",
"API": {
"SUCCESS_MESSAGE": "Resposta predeterminada actualitzada correctament",
"ERROR_MESSAGE": "No s'ha pogut connectar amb el servidor Woot. Torna-ho a provar més endavant"
}
},
"DELETE": {
"BUTTON_TEXT": "Esborra",
"API": {
"SUCCESS_MESSAGE": "Resposta predeterminada eliminada correctament",
"ERROR_MESSAGE": "No s'ha pogut connectar amb el servidor Woot. Torna-ho a provar més endavant"
},
"CONFIRM": {
"TITLE": "Confirma esborrat",
"MESSAGE": "N'estas segur ",
"YES": "Si, esborra ",
"NO": "No, mantén-la "
}
}
}
}

View File

@@ -0,0 +1,77 @@
{
"CHAT_LIST": {
"LOADING": "S'estan obtenint converses",
"LOAD_MORE_CONVERSATIONS": "Carrega més converses",
"EOF": "Totes les converses carregades 🎉",
"LIST": {
"404": "No hi ha converses actives en aquest grup."
},
"TAB_HEADING": "Converses",
"SEARCH": {
"INPUT": "Cerca persones, xats, respostes desades .."
},
"STATUS_TABS": [
{
"NAME": "Obrir",
"KEY": "openCount"
},
{
"NAME": "Resoltes",
"KEY": "allConvCount"
}
],
"ASSIGNEE_TYPE_TABS": [
{
"NAME": "Meves",
"KEY": "me",
"COUNT_KEY": "mineCount"
},
{
"NAME": "Sense assignar",
"KEY": "unassigned",
"COUNT_KEY": "unAssignedCount"
},
{
"NAME": "Totes",
"KEY": "all",
"COUNT_KEY": "allCount"
}
],
"CHAT_STATUS_ITEMS": [
{
"TEXT": "Obertes",
"VALUE": "open"
},
{
"TEXT": "Resoltes",
"VALUE": "resolved"
}
],
"ATTACHMENTS": {
"image": {
"ICON": "ion-image",
"CONTENT": "Missatge d'imatge"
},
"audio": {
"ICON": "ion-volume-high",
"CONTENT": "Missatge d'àudio"
},
"video": {
"ICON": "ion-ios-videocam",
"CONTENT": "Missatge de vídeo"
},
"file": {
"ICON": "ion-document",
"CONTENT": "Fitxer adjunt"
},
"location": {
"ICON": "ion-ios-location",
"CONTENT": "Ubicació"
},
"fallback": {
"ICON": "ion-link",
"CONTENT": "ha compartit una URL"
}
}
}
}

View File

@@ -0,0 +1,20 @@
{
"CONTACT_PANEL": {
"CONVERSATION_TITLE": "Detalls de les converses",
"BROWSER": "Navegador",
"OS": "Sistema operatiu",
"INITIATED_FROM": "Iniciada des de",
"INITIATED_AT": "Iniciada a les",
"CONVERSATIONS": {
"NO_RECORDS_FOUND": "No hi han converses prèvies associades a aquest contacte.",
"TITLE": "Converses prèvies"
},
"LABELS": {
"TITLE": "Etiquetes de converses",
"UPDATE_BUTTON": "Actualitza etiquetes",
"UPDATE_ERROR": "No s'han pogut actualitzar les etiquetes, torna-ho a provar.",
"TAG_PLACEHOLDER": "Afegeix una etiqueta nova",
"PLACEHOLDER": "Cerca o afegeix una etiqueta"
}
}
}

View File

@@ -0,0 +1,35 @@
{
"CONVERSATION": {
"404": "Si us plau, selecciona una conversa al panell de lesquerra",
"NO_MESSAGE_1": "Uh oh! Sembla que no hi ha missatges de clients a la safata d'entrada.",
"NO_MESSAGE_2": " per enviar un missatge a la vostra pàgina!",
"NO_INBOX_1": "Hola! Sembla que encara no heu afegit cap safata d'entrada.",
"NO_INBOX_2": " per començar",
"NO_INBOX_AGENT": "Uh Oh! Sembla que no ets a cap safata d'entrada. Si us plau, poseu-vos en contacte amb l'administrador",
"CLICK_HERE": "Clica aquí",
"LOADING_INBOXES": "S'estan carregant les safates d'entrada",
"LOADING_CONVERSATIONS": "S'estan carregant les converses",
"DOWNLOAD": "Descarrega",
"HEADER": {
"RESOLVE_ACTION": "Resoldre",
"REOPEN_ACTION": "Tornar a obrir",
"OPEN": "Més",
"CLOSE": "Tanca",
"DETAILS": "detalls"
},
"FOOTER": {
"MSG_INPUT": "Shift + enter per a una línia nova. Comença amb '/' per seleccionar una resposta predeterminada.",
"PRIVATE_MSG_INPUT": "Shift + enter per una línia nova. Això serà visible només per als Agents"
},
"REPLYBOX": {
"REPLY": "Respon",
"PRIVATE_NOTE": "Nota privada",
"SEND": "Envia",
"CREATE": "Afegeix una nota",
"TWEET": "Tuit"
},
"VISIBLE_TO_AGENTS": "Nota privada: Només és visible per tu i el vostre equip",
"CHANGE_STATUS": "Estat de la conversa canviat",
"CHANGE_AGENT": "Assignació de la conversa canviat"
}
}

View File

@@ -0,0 +1,27 @@
{
"GENERAL_SETTINGS": {
"TITLE": "Configuració del compte",
"SUBMIT": "Actualització de la configuració",
"UPDATE": {
"ERROR": "No s'ha pogut actualitzar la configuració, torna-ho a provar!",
"SUCCESS": "La configuració del compte s'ha actualitzat correctament"
},
"FORM": {
"ERROR": "Corregiu els errors del formulari",
"GENERAL_SECTION": {
"TITLE": "Configuració general",
"NOTE": ""
},
"NAME": {
"LABEL": "Nom del compte",
"PLACEHOLDER": "El nom del vostre compte",
"ERROR": "Introduïu un nom de compte vàlid"
},
"LANGUAGE": {
"LABEL": "Idioma del lloc (Beta)",
"PLACEHOLDER": "El nom del vostre compte",
"ERROR": ""
}
}
}
}

View File

@@ -0,0 +1,138 @@
{
"INBOX_MGMT": {
"HEADER": "Safates d'entrada",
"SIDEBAR_TXT": "<p><b>Safata d'entrada</b></p> <p> Quan connecteu un lloc web o una pàgina de facebook a Chatwoot, es diu <b>Safata d'entrada</b>. Teniu bústies d'entrada il·limitades al vostre compte de Chatwoot. </p><p> Feu click a <b>Afegir safata d'entrada</b> per connectar-vos a un lloc web o a una pàgina de Facebook. </p><p> Al <a href=\"/app/dashboard\">Tauler de control</a>, pots veure totes les converses de totes les teves safates d'entrada en un sol lloc i respondre-les a la pestanya `Converses`. </p><p> També pots veure converses específiques per a una safata dentrada si feu clic al nom de la safata d'entrada, al panell esquerre de la taula. </p>",
"LIST": {
"404": "No hi ha cap safata d'entrada connectat a aquest compte."
},
"CREATE_FLOW": [
{ "title": "Triar canal", "route": "settings_inbox_new", "body": "Trieu el proveïdor que vulgueu integrar amb Chatwoot." },
{ "title": "Crear safata d'entrada", "route": "settings_inboxes_page_channel", "body": "Autentiqueu el vostre compte i creeu una safata d'entrada." },
{ "title": "Afegir agents", "route": "settings_inboxes_add_agents", "body": "Afegir agents a la safata d'entrada creada." },
{ "title": "Voila!", "route": "settings_inbox_finish", "body": "Ja estàs preparat!" }
],
"ADD": {
"FB": {
"HELP": "PD: Al iniciar la sessió, només accediu als missatges de la vostra pàgina. Chatwoot mai no podrà accedir als vostres missatges privats."
},
"TWITTER": {
"HELP": "Per afegir el teu perfil de Twitter com a canal, has d'autentificar el vostre perfil de Twitter fent clic a 'Inicieu la sessió amb Twitter' "
},
"WEBSITE_CHANNEL": {
"TITLE": "Canal Web",
"DESC": "Crea un canal per al vostre lloc web i comença a donar suport als vostres clients mitjançant el teu widget del lloc web.",
"LOADING_MESSAGE": "S'està creant el canal de suport web",
"CHANNEL_NAME": {
"LABEL": "Nom del lloc web",
"PLACEHOLDER": "Introduïu el nom del vostre lloc web (per exemple, Acme Inc)"
},
"CHANNEL_DOMAIN": {
"LABEL": "Domini del lloc web",
"PLACEHOLDER": "Introduïu el vostre domini de lloc web (pe: acme.com)"
},
"WIDGET_COLOR": {
"LABEL": "Color del Widget",
"PLACEHOLDER": "Actualitza el color del widget"
},
"SUBMIT_BUTTON":"Crea la safata entrada"
},
"TWILIO": {
"TITLE": "Canal Twilio SMS",
"DESC": "Integra Twilio i comença a donar suport als teus clients mitjançant SMS.",
"ACCOUNT_SID": {
"LABEL": "Compte SID",
"PLACEHOLDER": "Introduïu el vostre compte Twilio SID",
"ERROR": "Aquest camp és obligatori"
},
"AUTH_TOKEN": {
"LABEL": "Auth Token",
"PLACEHOLDER": "Introduïu el vostre Twilio Auth Token",
"ERROR": "Aquest camp és obligatori"
},
"CHANNEL_NAME": {
"LABEL": "Nom del canal",
"PLACEHOLDER": "Introduïu el nom del canal",
"ERROR": "Aquest camp és obligatori"
},
"PHONE_NUMBER": {
"LABEL": "Número de telèfon",
"PLACEHOLDER": "Introduïu el número de telèfon des del qual serà enviat el missatge.",
"ERROR": "Introduïu un valor vàlid. El número de telèfon hauria de començar amb el signe `+`."
},
"SUBMIT_BUTTON": "Crear un canal Twilio",
"API": {
"ERROR_MESSAGE": "No hem pogut autenticar les credencials de Twilio, prova de nou"
}
},
"AUTH": {
"TITLE": "Canals",
"DESC": "Actualment estan suportats widgets de xat en directe per a llocs web, pàgines de Facebook i perfils de Twitter. Estem treballant en més plataformes com Whatsapp, correu electrònic, Telegram i Line, que estaran disponibles en breu"
},
"AGENTS": {
"TITLE": "Agents",
"DESC": "Aquí podeu afegir agents per gestionar la vostra safata d'entrada de nova creació. Només aquests agents seleccionats tindran accés a la vostra safata d'entrada. Els agents que no formen part d'aquesta safata d'entrada no podran veure ni respondre als missatges d'aquesta safata d'entrada quan sinicien. <br><b>PD:</b> Com a administrador, si necessiteu accés a totes les bústies dentrada, heu dafegir-vos com a agent a totes les bústies de sortida que creeu."
},
"DETAILS": {
"TITLE": "Detalls de la safata d'entrada",
"DESC": "Des del següent menú desplegable, seleccioneu la pàgina de Facebook que voleu connectar a Chatwoot. També podeu donar un nom personalitzat a la safata d'entrada per a una millor identificació."
},
"FINISH":{
"TITLE": "L'has clavat!",
"DESC": "Heu acabat d'integrar la vostra pàgina de Facebook amb Chatwoot. La propera vegada que un client escrigui un missatge a la vostra pàgina, la conversa apareixerà automàticament a la safata d'entrada. <br>També us proporcionem un script del widget que podeu afegir fàcilment al vostre web. Una vegada que estigui operatiu al vostre web, els clients us podran enviar missatges des del web sense lajuda de cap eina externa i la conversa apareixerà aquí mateix, a Chatwoot. <br>Genial, eh? Bé, segur que intentem ser-ho :)"
}
},
"DETAILS": {
"LOADING_FB": "S'està autenticant amb Facebook...",
"ERROR_FB_AUTH": "Alguna cosa ha anat malament, actualitza la pàgina ...",
"CREATING_CHANNEL": "S'està creant la safata d'entrada...",
"TITLE": "Configura els detalls de la safata d'entrada",
"DESC": ""
},
"AGENTS": {
"BUTTON_TEXT": "Afegir agents",
"ADD_AGENTS": "S'estan afegint agents a la vostre safata d'entrada..."
},
"FINISH": {
"TITLE": "La vostra safata d'entrada està a punt!",
"MESSAGE": "Ja podeu interactuar amb els vostres clients a través del vostre canal nou. Feliç suport",
"BUTTON_TEXT": "Porta'm allà",
"WEBSITE_SUCCESS": "Heu finalitzat amb èxit la creació d'un canal web. Copieu el codi que es mostra a continuació i enganxeu-lo al lloc web. La propera vegada que un client utilitzi el xat en directe, la conversa apareixerà automàticament a la safata d'entrada."
},
"REAUTH": "Reautoritza",
"VIEW": "Veure",
"EDIT": {
"API": {
"SUCCESS_MESSAGE": "El color del widget s'ha actualitzat correctament",
"AUTO_ASSIGNMENT_SUCCESS_MESSAGE": "Assignació automàtica actualitzada correctament",
"ERROR_MESSAGE": "No s'ha pogut actualitzar el color del widget. Torneu-ho a provar més endavant."
},
"AUTO_ASSIGNMENT": {
"ENABLED": "Habilita",
"DISABLED": "Inhabilita"
}
},
"DELETE": {
"BUTTON_TEXT": "Suprimeix",
"CONFIRM": {
"TITLE": "Confirma esborrat",
"MESSAGE": "N'estas segur? ",
"YES": "Si, esborra ",
"NO": "No, manten-la "
},
"API": {
"SUCCESS_MESSAGE": "S'ha suprimit la safata d'entrada correctament",
"ERROR_MESSAGE": "No s'ha pogut eliminar la safata d'entrada. Torneu-ho a provar més endavant."
}
},
"SETTINGS": "Configuracions",
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "Script del missatger",
"MESSENGER_SUB_HEAD": "Col·loca aquest botó dins de l'etiqueta body",
"INBOX_AGENTS": "Agents",
"INBOX_AGENTS_SUB_TEXT": "Afegir o eliminar agents d'aquesta safata d'entrada",
"UPDATE": "Actualitza",
"AUTO_ASSIGNMENT": "Activa l'assignació automàtica",
"AUTO_ASSIGNMENT_SUB_TEXT": "Activa o desactiva l'assignació automàtica d'agents disponibles a les noves converses"
}
}
}

View File

@@ -0,0 +1,34 @@
/* eslint-disable */
import { default as _agentMgmt } from './agentMgmt.json';
import { default as _billing } from './billing.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,
..._billing,
..._cannedMgmt,
..._chatlist,
..._contact,
..._conversation,
..._inboxMgmt,
..._login,
..._report,
..._resetPassword,
..._setNewPassword,
..._settings,
..._signup,
..._integrations,
..._generalSettings,
};

View File

@@ -0,0 +1,54 @@
{
"INTEGRATION_SETTINGS": {
"HEADER": "Integracions",
"WEBHOOK": {
"TITLE": "Webhook",
"CONFIGURE": "Configura",
"HEADER": "Configuració Webhook",
"HEADER_BTN_TXT": "Afegeix un nou webhook",
"INTEGRATION_TXT": "Els esdeveniments de Webhook us proporcionen informació en temps real sobre el que passa al vostre compte de Chatwoot. Podeu utilitzar els webhooks per comunicar els esdeveniments a les vostres aplicacions preferides com Slack o Github. Feu clic a Configura per configurar els enllaços web.",
"LOADING": "S'estan recollint els webhooks adjunts",
"SEARCH_404": "No hi ha articles que coincideixin amb aquesta consulta",
"SIDEBAR_TXT": "<p><b>Webhooks</b> </p> <p>Els webhooks són callbacks HTTP que es poden definir per a cada compte. Es produeixen per esdeveniments com la creació de missatges a Chatwoot. Podeu crear més d'un webhook per a aquest compte. <br /><br /> Per crear un <b>webhook</b>, feu clic al botó <b> Afegir nou webhook </b>. També podeu eliminar qualsevol webhook existent fent clic al botó Elimina.</p>",
"LIST": {
"404": "No hi ha cap webhooks configurat per a aquest compte.",
"TITLE": "Gestiona els webhooks",
"DESC": "Els webhooks són plantilles de resposta predefinides que es poden utilitzar per enviar ràpidament respostes a una conversa.",
"TABLE_HEADER": [
"Punt final del webhook",
"Accions"
]
},
"ADD": {
"CANCEL": "Cancel·la",
"TITLE": "Afegir un nou webhook",
"DESC": "Els esdeveniments de Webhook us proporcionen informació en temps real sobre el que passa al vostre compte de Chatwoot. Introduïu una URL vàlid per configurar un callback.",
"FORM": {
"END_POINT": {
"LABEL": "URL del webhook",
"PLACEHOLDER": "Exemple: https://example/api/webhook",
"ERROR": "Introduïu una URL vàlid"
},
"SUBMIT": "Crear webhook"
},
"API": {
"SUCCESS_MESSAGE": "S'ha afegit el Webhook correctament",
"ERROR_MESSAGE": "No s'ha pogut connectar amb el servidor Woot. Torna-ho a provar més endavant"
}
},
"DELETE": {
"BUTTON_TEXT": "Suprimeix",
"API": {
"SUCCESS_MESSAGE": "S'ha esborrat el Webhook correctament",
"ERROR_MESSAGE": "No s'ha pogut connectar amb el servidor Woot. Torna-ho a provar més endavant"
},
"CONFIRM": {
"TITLE": "Confirma l'esborrat",
"MESSAGE": "N'estàs segur ",
"YES": "Si, esborra ",
"NO": "No, mantén-la "
}
}
}
}
}

View File

@@ -0,0 +1,21 @@
{
"LOGIN": {
"TITLE": "Entra a Chatwoot",
"EMAIL": {
"LABEL": "Correu electrònic",
"PLACEHOLDER": "Correu electrònic p.e.: someone@exemple.com"
},
"PASSWORD": {
"LABEL": "Contrasenya",
"PLACEHOLDER": "Contrasenya"
},
"API": {
"SUCCESS_MESSAGE": "Iniciada la sessió amb èxit",
"ERROR_MESSAGE": "No s'ha pogut connectar amb el servidor Woot. Torna-ho a provar més endavant",
"UNAUTH": "Nom d'usuari / contrasenya incorrecte. Torna-ho a provar-ho"
},
"FORGOT_PASSWORD": "Has oblidat la contrasenya?",
"CREATE_NEW_ACCOUNT": "Crear un nou compte",
"SUBMIT": "Inicia la sessió"
}
}

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