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"
plugins:
rubocop:
enabled: true
enabled: false
channel: rubocop-0-73
eslint:
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_SSL=
FORCE_SSL=false
# This lets you control new sign ups on your chatwoot installation
# true : default option, allows 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
ENABLE_ACCOUNT_SIGNUP=
ENABLE_ACCOUNT_SIGNUP=true
#redis config
# Redis config
REDIS_URL=redis://redis:6379
# 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
@@ -22,7 +27,7 @@ POSTGRES_PASSWORD=
RAILS_ENV=development
RAILS_MAX_THREADS=5
#mail
# Mail outgoing
MAILER_SENDER_EMAIL=accounts@chatwoot.com
SMTP_PORT=1025
SMTP_DOMAIN=chatwoot.com
@@ -34,20 +39,37 @@ SMTP_PASSWORD=
SMTP_AUTHENTICATION=
SMTP_ENABLE_STARTTLS_AUTO=
#misc
FRONTEND_URL=http://0.0.0.0:3000
# Mail Incoming
# 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
#s3
# Amazon S3
S3_BUCKET_NAME=
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_REGION=
#sentry
# Sentry
SENTRY_DSN=
# Log settings
# Disable if you want to write logs to a file
RAILS_LOG_TO_STDOUT=true
LOG_LEVEL=info
LOG_SIZE=500
@@ -61,12 +83,16 @@ FB_VERIFY_TOKEN=
FB_APP_SECRET=
FB_APP_ID=
#twitter
# Twitter
TWITTER_APP_ID=
TWITTER_CONSUMER_KEY=
TWITTER_CONSUMER_SECRET=
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
ENABLE_BILLING=
@@ -75,3 +101,8 @@ CHARGEBEE_API_KEY=
CHARGEBEE_SITE=
CHARGEBEE_WEBHOOK_USERNAME=
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 = {
extends: ['airbnb/base', 'prettier', 'plugin:vue/recommended'],
extends: ['airbnb-base/legacy', 'prettier', 'plugin:vue/recommended'],
parserOptions: {
parser: 'babel-eslint',
ecmaVersion: 2017,
ecmaVersion: 2020,
sourceType: 'module',
},
plugins: ['html', 'prettier', 'babel'],

View File

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

View File

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

View File

@@ -17,6 +17,7 @@ gem 'jbuilder'
gem 'kaminari'
gem 'responders'
gem 'rest-client'
gem 'telephone_number'
gem 'time_diff'
gem 'tzinfo-data'
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/
gem 'wisper', '2.0.0'
##--- gems for reporting ---##
gem 'nightfury'
##--- gems for billing ---##
gem 'chargebee'
@@ -82,6 +80,9 @@ gem 'sidekiq'
##-- used for single column multiple binary flags in notification settings/feature flagging --##
gem 'flag_shih_tzu'
##-- Push notification service --##
gem 'webpush'
group :development do
gem 'annotate'
gem 'bullet'
@@ -100,7 +101,7 @@ group :development, :test do
gem 'factory_bot_rails'
gem 'faker'
gem 'listen'
gem 'mock_redis'
gem 'mock_redis', git: 'https://github.com/sds/mock_redis', ref: '16d00789f0341a3aac35126c0ffe97a596753ff9'
gem 'pry-rails'
gem 'rspec-rails', '~> 4.0.0.beta2'
gem 'rubocop', require: false

View File

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

View File

@@ -1,5 +1,5 @@
<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">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>
</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

View File

@@ -17,7 +17,7 @@ class ContactMergeAction
def validate_contacts
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
def belongs_to_account?(contact)

View File

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

View File

@@ -41,7 +41,7 @@ class Messages::MessageBuilder
def build_message
@message = conversation.messages.create!(message_params)
(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!
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
end

View File

@@ -3,22 +3,26 @@ class Messages::Outgoing::NormalBuilder
attr_reader :message
def initialize(user, conversation, params)
@content = params[:message]
@content = params[:content]
@private = params[:private] || false
@conversation = conversation
@user = user
@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
def perform
@message = @conversation.messages.build(message_params)
if @attachment
@message.attachment = Attachment.new(
account_id: message.account_id,
file_type: file_type(@attachment[:file]&.content_type)
if @attachments.present?
@attachments.each do |uploaded_attachment|
attachment = @message.attachments.new(
account_id: @message.account_id,
file_type: file_type(uploaded_attachment&.content_type)
)
@message.attachment.file.attach(@attachment[:file])
attachment.file.attach(uploaded_attachment)
end
end
@message.save
@message
@@ -34,7 +38,9 @@ class Messages::Outgoing::NormalBuilder
content: @content,
private: @private,
user_id: @user&.id,
source_id: @fb_id
source_id: @fb_id,
content_type: @content_type,
items: @items
}
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
end
# unscoped removes all scopes added to a model previously
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)
.count
end
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)
.count
end

View File

@@ -8,7 +8,7 @@ class Api::BaseController < ApplicationController
private
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
def set_conversation

View File

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

View File

@@ -4,16 +4,18 @@ class Api::V1::Accounts::CallbacksController < Api::BaseController
def register_facebook_page
user_access_token = params[:user_access_token]
page_access_token = params[:page_access_token]
page_name = params[:page_name]
page_id = params[:page_id]
inbox_name = params[:inbox_name]
ActiveRecord::Base.transaction do
facebook_channel = current_account.facebook_pages.create!(
name: page_name, page_id: page_id, user_access_token: user_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)
render json: inbox
@facebook_inbox = current_account.inboxes.create!(name: inbox_name, channel: facebook_channel)
set_avatar(@facebook_inbox, page_id)
rescue StandardError => e
Rails.logger e
end
end
def facebook_pages
@@ -72,13 +74,13 @@ class Api::V1::Accounts::CallbacksController < Api::BaseController
end
end
def set_avatar(facebook_channel, page_id)
def set_avatar(facebook_inbox, page_id)
uri = get_avatar_url(page_id)
return unless 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
def get_avatar_url(page_id)

View File

@@ -2,14 +2,16 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::BaseControlle
before_action :authorize_request
def create
ActiveRecord::Base.transaction do
authenticate_twilio
build_inbox
setup_webhooks
setup_webhooks if @twilio_channel.sms?
rescue Twilio::REST::TwilioError => e
render_could_not_create_error(e.message)
rescue StandardError => e
render_could_not_create_error(e.message)
end
end
private
@@ -26,25 +28,30 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::BaseControlle
::Twilio::WebhookSetupService.new(inbox: @inbox).perform
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
ActiveRecord::Base.transaction do
twilio_sms = current_account.twilio_sms.create(
@twilio_channel = current_account.twilio_sms.create!(
account_sid: permitted_params[:account_sid],
auth_token: permitted_params[:auth_token],
phone_number: permitted_params[:phone_number]
phone_number: phone_number,
medium: medium
)
@inbox = current_account.inboxes.create(
name: permitted_params[:name],
channel: twilio_sms
channel: @twilio_channel
)
rescue StandardError => e
render_could_not_create_error(e.message)
end
end
def permitted_params
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

View File

@@ -4,11 +4,6 @@ class Api::V1::Accounts::ContactsController < Api::BaseController
before_action :check_authorization
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
@contacts = current_account.contacts
end

View File

@@ -1,5 +1,7 @@
class Api::V1::Accounts::ConversationsController < Api::BaseController
include Events::Types
before_action :conversation, except: [:index]
before_action :contact_inbox, only: [:create]
def index
result = conversation_finder.perform
@@ -7,12 +9,30 @@ class Api::V1::Accounts::ConversationsController < Api::BaseController
@conversations_count = result[:count]
end
def meta
result = conversation_finder.perform
@conversations_count = result[:count]
end
def create
@conversation = ::Conversation.create!(conversation_params)
end
def show; end
def toggle_status
@status = @conversation.toggle_status
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
@conversation.agent_last_seen_at = parsed_last_seen_at
@conversation.save!
@@ -21,6 +41,11 @@ class Api::V1::Accounts::ConversationsController < Api::BaseController
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
DateTime.strptime(params[:agent_last_seen_at].to_s, '%s')
end
@@ -29,6 +54,19 @@ class Api::V1::Accounts::ConversationsController < Api::BaseController
@conversation ||= current_account.conversations.find_by(display_id: params[:id])
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
@conversation_finder ||= ConversationFinder.new(current_user, params)
end

View File

@@ -1,13 +1,35 @@
class Api::V1::Accounts::InboxesController < Api::BaseController
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
@inboxes = policy_scope(current_account.inboxes)
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
@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
def destroy
@@ -21,11 +43,24 @@ class Api::V1::Accounts::InboxesController < Api::BaseController
@inbox = current_account.inboxes.find(params[:id])
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
authorize(Inbox)
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
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

View File

@@ -20,10 +20,11 @@ class Api::V1::Accounts::NotificationSettingsController < Api::BaseController
end
def notification_setting_params
params.require(:notification_settings).permit(selected_email_flags: [])
params.require(:notification_settings).permit(selected_email_flags: [], selected_push_flags: [])
end
def update_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

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
def conversation
@conversation ||= @contact_inbox.conversations.find_by(
@conversation ||= @contact_inbox.conversations.where(
inbox_id: auth_token_params[:inbox_id]
)
).last
end
def auth_token_params
@@ -18,6 +18,7 @@ class Api::V1::Widget::BaseController < ApplicationController
def set_web_widget
@web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token])
@account = @web_widget.account
switch_locale @account
end
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
@message = conversation.messages.new(message_params)
@message.save
build_attachment
@message.save!
end
def update
@message.update!(input_submitted_email: contact_email)
if @message.content_type == 'input_email'
@message.update!(submitted_email: contact_email)
update_contact(contact_email)
else
@message.update!(message_update_params[:message])
end
rescue StandardError => e
render json: { error: @contact.errors, message: e.message }.to_json, status: 500
end
@@ -24,13 +28,16 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
private
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|
attachment = @message.attachments.new(
account_id: @message.account_id,
file_type: helpers.file_type(params[:message][:attachment][:file]&.content_type)
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
def set_conversation
@@ -116,6 +123,10 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
contact_email.split('@')[0]
end
def message_update_params
params.permit(message: [submitted_values: [:name, :title, :value]])
end
def permitted_params
params.permit(:id, :before, :website_token, contact: [:email], message: [:content, :referer_url, :timestamp])
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)
account_accessible_for_bot?(account)
end
switch_locale account
account
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)
render_unauthorized('You are not authorized to access this account') unless account.account_users.find_by(user_id: current_user.id)
end

View File

@@ -1,11 +1,12 @@
module AccessTokenAuthHelper
BOT_ACCESSIBLE_ENDPOINTS = {
'api/v1/accounts/conversations' => ['toggle_status'],
'api/v1/accounts/conversations' => %w[toggle_status create],
'api/v1/accounts/conversations/messages' => ['create']
}.freeze
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
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,
:Body,
:ToCountry,
:FromState
:FromState,
:MediaUrl0,
:MediaContentType0
)
end
end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,2 +1,5 @@
module ApplicationHelper
def available_locales_with_name
LANGUAGES_CONFIG.map { |_key, val| val.slice(:name, :iso_639_1_code) }
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 {
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,
});
}
toggleTyping({ conversationId, status }) {
return axios.post(`${this.url}/${conversationId}/toggle_typing_status`, {
typing_status: status,
});
}
}
export default new ConversationApi();

View File

@@ -9,7 +9,7 @@ class MessageApi extends ApiClient {
create({ conversationId, message, private: isPrivate }) {
return axios.post(`${this.url}/${conversationId}/messages`, {
message,
content: message,
private: isPrivate,
});
}
@@ -22,7 +22,7 @@ class MessageApi extends ApiClient {
sendAttachment([conversationId, { file }]) {
const formData = new FormData();
formData.append('attachment[file]', file);
formData.append('attachments[]', file, file.name);
return axios({
method: 'post',
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-lineheight: 1.5;
$foundation-palette: (primary: $color-woot,
secondary: #777,
success: #13ce66,
warning: #ffc82c,
alert: #ff4949);
secondary: #35c5ff,
success: #44ce4b,
warning: #ffc532,
alert: #ff382d);
$light-gray: #c0ccda;
$medium-gray: #8492a6;
$dark-gray: $color-gray;
@@ -127,7 +127,7 @@ $header-styles: (small: ("h1": ("font-size": 24),
$header-text-rendering: optimizeLegibility;
$small-font-size: 80%;
$header-small-font-color: $medium-gray;
$paragraph-lineheight: 1.6;
$paragraph-lineheight: 1.45;
$paragraph-margin-bottom: 1rem;
$paragraph-text-rendering: optimizeLegibility;
$code-color: $black;
@@ -377,8 +377,8 @@ $form-button-radius: $global-radius;
// 20. Label
// ---------
$label-background: $primary-color;
$label-color: $white;
$label-background: lighten($primary-color, 40%);
$label-color: $primary-color;
$label-color-alt: $black;
$label-palette: $foundation-palette;
$label-font-size: $font-size-micro;

View File

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

View File

@@ -46,7 +46,8 @@ $color-gray: #6e6f73;
$color-light-gray: #999a9b;
$color-border: #e0e6ed;
$color-border-light: #f0f4f5;
$color-background: #eff2f7;
$color-background: #f4f6fb;
$color-border-dark: #cad0d4;
$color-background-light: #f9fafc;
$color-white: #fff;
$color-body: #3c4858;
@@ -54,11 +55,10 @@ $color-heading: #1f2d3d;
$color-extra-light-blue: #f5f7f9;
$primary-color: $color-woot;
$secondary-color: #ff5216;
$success-color: #13ce66;
$warning-color: #ffc82c;
$alert-color: #ff4949;
$secondary-color: #35c5ff;
$success-color: #44ce4b;
$warning-color: #ffc532;
$alert-color: #ff382d;
// Color-palettes
$color-primary-light: #c7e3ff;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,23 +1,53 @@
.conversation {
@include flex;
@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;
position: relative;
&.active {
background: $color-background;
}
border-bottom-color: $color-border-light;
border-left-color: $color-woot;
border-top-color: $color-border-light;
.conversation--details {
@include margin($zero $zero $zero $space-one);
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 {
@include margin(0 0 0 $space-one);
@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 {
font-size: $font-size-small;
margin-bottom: $zero;
margin-bottom: 0;
text-transform: capitalize;
.label {
@@ -37,7 +67,7 @@
font-weight: $font-weight-normal;
height: $space-medium;
line-height: $space-medium;
margin: $zero;
margin: 0;
max-width: 96%;
overflow: hidden;
text-overflow: ellipsis;
@@ -52,20 +82,20 @@
.conversation--meta {
@include flex;
display: block;
flex-direction: column;
position: absolute;
right: $space-normal;
top: $space-normal;
.unread {
$unread-size: $space-two - $space-micro;
$unread-size: $space-normal;
@include round-corner;
@include light-shadow;
background: darken($success-color, 3%);
color: $color-white;
display: none;
font-size: $font-size-mini;
font-weight: $font-weight-medium;
font-size: $font-size-micro;
font-weight: $font-weight-black;
height: $unread-size;
line-height: $unread-size;
margin-left: auto;

View File

@@ -1,10 +1,11 @@
@mixin bubble-with-tyes {
@include padding($space-smaller $space-one);
@mixin bubble-with-types {
@include padding($space-small $space-normal);
@include margin($zero);
background: $color-primary-light;
border-radius: $space-small;
color: $color-heading;
background: $color-woot;
border-radius: $space-one;
color: $color-white;
font-size: $font-size-small;
font-weight: $font-weight-normal;
position: relative;
.icon {
@@ -15,6 +16,17 @@
.message-text__wrap {
position: relative;
.time {
color: $color-primary-light;
display: block;
font-size: $font-size-micro;
line-height: 1.8;
}
.link {
color: $color-white;
}
}
.message-text {
@@ -51,8 +63,7 @@
}
&::before {
$color-black: #000;
background-image: linear-gradient(-180deg, transparent 3%, $color-black 70%);
background-image: linear-gradient(-180deg, transparent 3%, $color-heading 130%);
bottom: 0;
content: '';
height: 20%;
@@ -94,6 +105,7 @@
.load-more-conversations {
font-size: $font-size-small;
margin: 0;
padding: $space-normal;
width: 100%;
}
@@ -122,10 +134,10 @@
.status--filter {
@include padding($zero null $zero $space-normal);
@include border-light;
@include round-corner;
@include margin($space-smaller $space-slab $zero $zero);
background-color: $color-background;
background-color: $color-background-light;
border: 1px solid $color-border;
float: right;
font-size: $font-size-mini;
height: $space-medium;
@@ -192,11 +204,14 @@
height: 100%;
margin-bottom: $space-small;
overflow-y: auto;
position: relative;
}
>li {
.conversation-panel>li {
@include flex;
@include flex-shrink;
@include margin($zero $zero $space-smaller);
@include margin($zero $zero $space-micro);
position: relative;
&:first-child {
margin-top: auto;
@@ -220,7 +235,7 @@
}
.bubble {
@include bubble-with-tyes;
@include bubble-with-types;
max-width: 50rem;
text-align: left;
word-wrap: break-word;
@@ -232,19 +247,47 @@
}
&.left {
.bubble {
@include border-normal;
background: $white;
border-bottom-left-radius: 0;
border-top-left-radius: 0;
color: $color-heading;
border-bottom-left-radius: $space-smaller;
border-top-left-radius: $space-smaller;
color: $color-body;
margin-right: auto;
.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-small;
border-top-right-radius: $space-one;
}
}
@@ -259,12 +302,13 @@
}
.bubble {
border-bottom-right-radius: 0;
border-top-right-radius: 0;
border-bottom-right-radius: $space-smaller;
border-top-right-radius: $space-smaller;
margin-left: auto;
&.is-private {
background: lighten($warning-color, 32%);
border: 1px solid $color-border;
color: $color-heading;
padding-right: $space-large;
position: relative;
@@ -276,6 +320,10 @@
right: $space-one;
top: $space-smaller + $space-micro;
}
.time {
color: $color-light-gray;
}
}
}
@@ -283,7 +331,7 @@
margin-top: $space-one;
.bubble {
border-top-left-radius: $space-small;
border-top-left-radius: $space-one;
}
}
}
@@ -309,10 +357,10 @@
.activity-wrap {
@include flex;
@include margin($space-small auto);
@include padding($space-smaller $space-normal);
@include padding($space-small $space-normal);
@include flex-align($x: center, $y: null);
background: lighten($warning-color, 32%);
border: 1px solid lighten($warning-color, 26%);
border: 1px solid lighten($warning-color, 22%);
border-radius: $space-smaller;
font-size: $font-size-small;
@@ -342,18 +390,39 @@
.time {
color: $medium-gray;
font-size: $font-size-micro;
margin-left: $space-slab;
}
}
}
.time {
bottom: -$space-micro;
color: $color-gray;
float: right;
font-size: $font-size-micro;
font-style: italic;
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 {
@include elegant-card;
background: $color-white;
@@ -15,15 +18,15 @@
}
.emojione {
@include margin($zero);
font-size: $font-size-medium;
font-size: $font-size-default;
margin: $zero;
}
.emoji-row {
@include padding($space-small);
box-sizing: border-box;
height: 180px;
overflow-y: auto;
padding: $space-small;
.emoji {
border-radius: 4px;
@@ -52,27 +55,33 @@
}
.emoji-dialog-header {
@include padding($zero $space-smaller);
background-color: $light-gray;
background-color: $color-body;
border-top-left-radius: $space-small;
border-top-right-radius: $space-small;
padding: $zero $space-smaller;
ul {
display: flex;
list-style: none;
margin: 0;
padding: $space-smaller 0 0;
>li {
@include padding($space-smaller $space-small);
box-sizing: border-box;
align-items: center;
cursor: pointer;
display: inline-block;
height: 3.4rem;
text-align: center;
display: flex;
height: $space-medium;
justify-content: center;
padding: $space-smaller $space-small;
}
.emojione {
height: $space-two;
width: $space-normal;
}
>.active {
background: $white;
background: $color-white;
border-top-left-radius: $space-small;
border-top-right-radius: $space-small;
}
@@ -84,6 +93,7 @@
}
.active {
img,
svg {
filter: grayscale(0);

View File

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

View File

@@ -42,15 +42,19 @@
font-size: $font-size-default;
input {
padding: $space-slab;
height: $space-larger;
font-size: $font-size-default;
height: $space-larger;
padding: $space-slab;
}
.error {
font-size: $font-size-small;
}
}
.button {
height: $space-larger;
}
}
.sigin__footer {

View File

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

View File

@@ -1,33 +1,32 @@
.ui-snackbar-container {
position: absolute;
left: 0;
margin: 0 auto;
max-width: 40rem;
overflow: hidden;
z-index: 9999;
top: $space-normal;
left: $space-normal;
width: 100%;
position: absolute;
right: 0;
text-align: center;
top: $space-normal;
z-index: 9999;
}
.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;
min-width: 24rem;
margin-bottom: $space-small;
max-width: 40rem;
min-height: 3rem;
background-color: $woot-snackbar-bg;
@include padding($space-slab $space-medium);
@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);
min-width: 24rem;
text-align: left;
}
.ui-snackbar-text {
font-size: $font-size-small;
color: $color-white;
font-size: $font-size-small;
font-weight: $font-weight-medium;
}
.ui-snackbar-action {
@@ -35,12 +34,12 @@
padding-left: 3rem;
button {
@include margin(0);
@include padding(0);
background: none;
border: 0;
color: $woot-snackbar-button;
font-size: $font-size-small;
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>
<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>
</template>
@@ -9,8 +13,12 @@
import WootSnackbar from './Snackbar';
export default {
components: {
WootSnackbar,
},
props: {
duration: {
type: Number,
default: 2500,
},
},
@@ -22,16 +30,12 @@ export default {
},
mounted() {
bus.$on('newToastMessage', (message) => {
bus.$on('newToastMessage', message => {
this.snackMessages.push(message);
window.setTimeout(() => {
this.snackMessages.splice(0, 1);
}, this.duration);
});
},
components: {
WootSnackbar,
},
};
</script>

View File

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

View File

@@ -33,6 +33,14 @@
:style="badgeStyle"
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>
</template>
<script>

View File

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

View File

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

View File

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

View File

@@ -1,22 +1,26 @@
<template>
<li v-if="data.attachment || data.content" :class="alignBubble">
<li v-if="hasAttachments || data.content" :class="alignBubble">
<div :class="wrapClass">
<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
v-if="data.content"
:message="message"
: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
v-if="isPrivate"
v-tooltip.top-start="toolTipMessage"
@@ -71,10 +75,16 @@ export default {
isBubble() {
return [0, 1, 3].includes(this.data.message_type);
},
hasAttachments() {
return !!(this.data.attachments && this.data.attachments.length > 0);
},
hasImageAttachment() {
const { attachment = {} } = this.data;
const { file_type: fileType } = attachment;
if (this.hasAttachments && this.data.attachments.length > 0) {
const { attachments = [{}] } = this.data;
const { file_type: fileType } = attachments[0];
return fileType === 'image';
}
return false;
},
isPrivate() {
return this.data.private;

View File

@@ -27,11 +27,23 @@
:data="message"
/>
</ul>
<div class="conversation-footer">
<div v-if="isAnyoneTyping" class="typing-indicator-wrap">
<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>
</template>
<script>
@@ -42,6 +54,7 @@ import ConversationHeader from './ConversationHeader';
import ReplyBox from './ReplyBox';
import Message from './Message';
import conversationMixin from '../../../mixins/conversations';
import { getTypingUsersText } from '../../../helper/commons';
export default {
components: {
@@ -81,6 +94,27 @@ export default {
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() {
const [chat] = this.allConversations.filter(
c => c.id === this.currentChat.id

View File

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

View File

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

View File

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

View File

@@ -3,37 +3,39 @@
<header class="emoji-dialog-header" role="menu">
<ul>
<li
v-bind:class="{ 'active': selectedKey === category.key }"
v-for="category in categoryList"
:key="category.key"
:class="{ active: selectedKey === category.key }"
@click="changeCategory(category)"
>
<div
@click="changeCategory(category)"
role="menuitem"
class="emojione"
v-html="getEmojiUnicode(`:${category.emoji}:`)"
>
</div>
@click="changeCategory(category)"
v-html="` ${getEmojiUnicode(`:${category.emoji}:`)}`"
></div>
</li>
</ul>
</header>
<div class="emoji-row">
<h5 class="emoji-category-title">{{selectedKey}}</h5>
<h5 class="emoji-category-title">
{{ selectedKey }}
</h5>
<div
v-for="(emoji, key) in selectedEmojis"
v-for="emoji in filteredSelectedEmojis"
:key="emoji.shortname"
role="menuitem"
:class="`emojione`"
v-html="getEmojiUnicode(emoji[emoji.length - 1].shortname)"
v-if="filterEmoji(emoji[emoji.length - 1].shortname)"
class="emojione"
track-by="$index"
@click="onClick(emoji[emoji.length - 1])"
@click="onClick(emoji)"
v-html="getEmojiUnicode(emoji.shortname)"
/>
</div>
</div>
</div>
</template>
<script>
/* eslint-disable no-restricted-syntax */
import strategy from 'emojione/emoji.json';
import categoryList from './categories';
import { getEmojiUnicode } from './utils';
@@ -44,7 +46,7 @@ export default {
return {
selectedKey: 'people',
categoryList,
selectedEmojis: [],
selectedEmojis: {},
};
},
computed: {
@@ -76,13 +78,29 @@ export default {
}
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
mounted() {
this.getInitialEmoji();
},
methods: {
// Change category and associated emojis
changeCategory(category) {
this.selectedKey = category.key;
@@ -101,3 +119,6 @@ export default {
},
};
</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 {
constructor(app, pubsubToken) {
super(app, pubsubToken);
this.CancelTyping = [];
this.events = {
'message.created': this.onMessageCreated,
'message.updated': this.onMessageUpdated,
'conversation.created': this.onConversationCreated,
'status_change:conversation': this.onStatusChange,
'conversation.opened': this.onStatusChange,
'conversation.resolved': this.onStatusChange,
'user:logout': this.onLogout,
'page:reload': this.onReload,
'assignee.changed': this.onAssigneeChanged,
'conversation.typing_on': this.onTypingOn,
'conversation.typing_off': this.onTypingOff,
};
}
onMessageUpdated = data => {
this.app.$store.dispatch('updateMessage', data);
};
onAssigneeChanged = payload => {
const { meta = {}, id } = payload;
const { assignee } = meta || {};
@@ -35,7 +44,45 @@ class ActionCableConnector extends BaseActionCableConnector {
onReload = () => window.location.reload();
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 de from './de';
import ca from './locale/ca';
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 {
ca,
de,
el,
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