Merge branch 'release/1.1.0'
This commit is contained in:
@@ -13,6 +13,7 @@ defaults: &defaults
|
|||||||
# CircleCI maintains a library of pre-built images
|
# CircleCI maintains a library of pre-built images
|
||||||
# documented at https://circleci.com/docs/2.0/circleci-images/
|
# documented at https://circleci.com/docs/2.0/circleci-images/
|
||||||
- image: circleci/postgres:9.4
|
- image: circleci/postgres:9.4
|
||||||
|
- image: circleci/redis:5.0.7-alpine
|
||||||
environment:
|
environment:
|
||||||
- CC_TEST_REPORTER_ID: b1b5c4447bf93f6f0b06a64756e35afd0810ea83649f03971cbf303b4449456f
|
- CC_TEST_REPORTER_ID: b1b5c4447bf93f6f0b06a64756e35afd0810ea83649f03971cbf303b4449456f
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ plugins:
|
|||||||
scss-lint:
|
scss-lint:
|
||||||
enabled: true
|
enabled: true
|
||||||
brakeman:
|
brakeman:
|
||||||
enabled: true
|
enabled: false
|
||||||
checks:
|
checks:
|
||||||
similar-code:
|
similar-code:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|||||||
10
.env.example
10
.env.example
@@ -19,10 +19,16 @@ FB_VERIFY_TOKEN=
|
|||||||
FB_APP_SECRET=
|
FB_APP_SECRET=
|
||||||
FB_APP_ID=
|
FB_APP_ID=
|
||||||
|
|
||||||
|
#twitter app
|
||||||
|
TWITTER_CONSUMER_KEY=
|
||||||
|
TWITTER_CONSUMER_SECRET=
|
||||||
|
|
||||||
#mail
|
#mail
|
||||||
MAILER_SENDER_EMAIL=accounts@chatwoot.com
|
MAILER_SENDER_EMAIL=accounts@chatwoot.com
|
||||||
SMTP_PORT=1025
|
SMTP_PORT=1025
|
||||||
SMTP_DOMAIN=chatwoot.com
|
SMTP_DOMAIN=chatwoot.com
|
||||||
|
# if you are running docker-compose, set SMTP_ADDRESS value as "mailhog",
|
||||||
|
# else set the value as "localhost"
|
||||||
SMTP_ADDRESS=mailhog
|
SMTP_ADDRESS=mailhog
|
||||||
SMTP_USERNAME=
|
SMTP_USERNAME=
|
||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
@@ -42,6 +48,10 @@ AWS_REGION=
|
|||||||
#sentry
|
#sentry
|
||||||
SENTRY_DSN=
|
SENTRY_DSN=
|
||||||
|
|
||||||
|
# Credentials to access sidekiq dashboard in production
|
||||||
|
SIDEKIQ_AUTH_USERNAME=
|
||||||
|
SIDEKIQ_AUTH_PASSWORD=
|
||||||
|
|
||||||
#### This environment variables are only required in hosted version which has billing
|
#### This environment variables are only required in hosted version which has billing
|
||||||
ENABLE_BILLING=
|
ENABLE_BILLING=
|
||||||
|
|
||||||
|
|||||||
13
.rubocop.yml
13
.rubocop.yml
@@ -4,16 +4,25 @@ require:
|
|||||||
- rubocop-rspec
|
- rubocop-rspec
|
||||||
inherit_from: .rubocop_todo.yml
|
inherit_from: .rubocop_todo.yml
|
||||||
|
|
||||||
Metrics/LineLength:
|
Layout/LineLength:
|
||||||
Max: 150
|
Max: 150
|
||||||
|
Metrics/ClassLength:
|
||||||
|
Max: 125
|
||||||
RSpec/ExampleLength:
|
RSpec/ExampleLength:
|
||||||
Max: 15
|
Max: 15
|
||||||
Documentation:
|
Style/Documentation:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
Style/FrozenStringLiteralComment:
|
Style/FrozenStringLiteralComment:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
Style/SymbolArray:
|
Style/SymbolArray:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
Style/GlobalVars:
|
||||||
|
Exclude:
|
||||||
|
- 'config/initializers/redis.rb'
|
||||||
|
- 'lib/redis/alfred.rb'
|
||||||
|
- 'app/controllers/api/v1/webhooks_controller.rb'
|
||||||
|
- 'app/services/twitter/send_reply_service.rb'
|
||||||
|
- 'spec/services/twitter/send_reply_service_spec.rb'
|
||||||
Metrics/BlockLength:
|
Metrics/BlockLength:
|
||||||
Exclude:
|
Exclude:
|
||||||
- spec/**/*
|
- spec/**/*
|
||||||
|
|||||||
10
Gemfile
10
Gemfile
@@ -23,6 +23,9 @@ gem 'valid_email2'
|
|||||||
gem 'uglifier'
|
gem 'uglifier'
|
||||||
|
|
||||||
##-- for active storage --##
|
##-- for active storage --##
|
||||||
|
gem 'aws-sdk-s3', require: false
|
||||||
|
gem 'azure-storage', require: false
|
||||||
|
gem 'google-cloud-storage', require: false
|
||||||
gem 'mini_magick'
|
gem 'mini_magick'
|
||||||
|
|
||||||
##-- gems for database --#
|
##-- gems for database --#
|
||||||
@@ -58,6 +61,9 @@ gem 'chargebee'
|
|||||||
gem 'facebook-messenger'
|
gem 'facebook-messenger'
|
||||||
gem 'telegram-bot-ruby'
|
gem 'telegram-bot-ruby'
|
||||||
gem 'twitter'
|
gem 'twitter'
|
||||||
|
# twitty will handle subscription of twitter account events
|
||||||
|
gem 'twitty', git: 'https://github.com/chatwoot/twitty'
|
||||||
|
|
||||||
# facebook client
|
# facebook client
|
||||||
gem 'koala'
|
gem 'koala'
|
||||||
# Random name generator
|
# Random name generator
|
||||||
@@ -68,9 +74,7 @@ gem 'haikunator'
|
|||||||
gem 'brakeman'
|
gem 'brakeman'
|
||||||
gem 'sentry-raven'
|
gem 'sentry-raven'
|
||||||
|
|
||||||
##-- TODO: move these gems to appropriate groups --##
|
##-- background job processing --##
|
||||||
# remove this gem in favor of active storage - github #158
|
|
||||||
gem 'carrierwave-aws'
|
|
||||||
gem 'sidekiq'
|
gem 'sidekiq'
|
||||||
|
|
||||||
group :development do
|
group :development do
|
||||||
|
|||||||
171
Gemfile.lock
171
Gemfile.lock
@@ -1,3 +1,10 @@
|
|||||||
|
GIT
|
||||||
|
remote: https://github.com/chatwoot/twitty
|
||||||
|
revision: a005f8f6740fc8d2d3500701e1ab4ab0f1416c26
|
||||||
|
specs:
|
||||||
|
twitty (0.1.0)
|
||||||
|
oauth
|
||||||
|
|
||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
@@ -66,15 +73,15 @@ GEM
|
|||||||
activerecord (>= 3.2, < 7.0)
|
activerecord (>= 3.2, < 7.0)
|
||||||
rake (>= 10.4, < 14.0)
|
rake (>= 10.4, < 14.0)
|
||||||
ast (2.4.0)
|
ast (2.4.0)
|
||||||
attr_extras (6.2.1)
|
attr_extras (6.2.2)
|
||||||
aws-eventstream (1.0.3)
|
aws-eventstream (1.0.3)
|
||||||
aws-partitions (1.259.0)
|
aws-partitions (1.268.0)
|
||||||
aws-sdk-core (3.86.0)
|
aws-sdk-core (3.89.1)
|
||||||
aws-eventstream (~> 1.0, >= 1.0.2)
|
aws-eventstream (~> 1.0, >= 1.0.2)
|
||||||
aws-partitions (~> 1, >= 1.239.0)
|
aws-partitions (~> 1, >= 1.239.0)
|
||||||
aws-sigv4 (~> 1.1)
|
aws-sigv4 (~> 1.1)
|
||||||
jmespath (~> 1.0)
|
jmespath (~> 1.0)
|
||||||
aws-sdk-kms (1.27.0)
|
aws-sdk-kms (1.28.0)
|
||||||
aws-sdk-core (~> 3, >= 3.71.0)
|
aws-sdk-core (~> 3, >= 3.71.0)
|
||||||
aws-sigv4 (~> 1.1)
|
aws-sigv4 (~> 1.1)
|
||||||
aws-sdk-s3 (1.60.1)
|
aws-sdk-s3 (1.60.1)
|
||||||
@@ -87,15 +94,24 @@ GEM
|
|||||||
descendants_tracker (~> 0.0.4)
|
descendants_tracker (~> 0.0.4)
|
||||||
ice_nine (~> 0.11.0)
|
ice_nine (~> 0.11.0)
|
||||||
thread_safe (~> 0.3, >= 0.3.1)
|
thread_safe (~> 0.3, >= 0.3.1)
|
||||||
|
azure-core (0.1.15)
|
||||||
|
faraday (~> 0.9)
|
||||||
|
faraday_middleware (~> 0.10)
|
||||||
|
nokogiri (~> 1.6)
|
||||||
|
azure-storage (0.15.0.preview)
|
||||||
|
azure-core (~> 0.1)
|
||||||
|
faraday (~> 0.9)
|
||||||
|
faraday_middleware (~> 0.10)
|
||||||
|
nokogiri (~> 1.6, >= 1.6.8)
|
||||||
bcrypt (3.1.13)
|
bcrypt (3.1.13)
|
||||||
bindex (0.8.1)
|
bindex (0.8.1)
|
||||||
bootsnap (1.4.5)
|
bootsnap (1.4.5)
|
||||||
msgpack (~> 1.0)
|
msgpack (~> 1.0)
|
||||||
brakeman (4.7.2)
|
brakeman (4.7.2)
|
||||||
browser (2.7.1)
|
browser (3.0.3)
|
||||||
buftok (0.2.0)
|
buftok (0.2.0)
|
||||||
builder (3.2.4)
|
builder (3.2.4)
|
||||||
bullet (6.0.2)
|
bullet (6.1.0)
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
uniform_notifier (~> 1.11)
|
uniform_notifier (~> 1.11)
|
||||||
bundle-audit (0.1.0)
|
bundle-audit (0.1.0)
|
||||||
@@ -103,18 +119,8 @@ GEM
|
|||||||
bundler-audit (0.6.1)
|
bundler-audit (0.6.1)
|
||||||
bundler (>= 1.2.0, < 3)
|
bundler (>= 1.2.0, < 3)
|
||||||
thor (~> 0.18)
|
thor (~> 0.18)
|
||||||
byebug (11.0.1)
|
byebug (11.1.1)
|
||||||
carrierwave (2.0.2)
|
chargebee (2.7.3)
|
||||||
activemodel (>= 5.0.0)
|
|
||||||
activesupport (>= 5.0.0)
|
|
||||||
addressable (~> 2.6)
|
|
||||||
image_processing (~> 1.1)
|
|
||||||
mimemagic (>= 0.3.0)
|
|
||||||
mini_mime (>= 0.1.3)
|
|
||||||
carrierwave-aws (1.4.0)
|
|
||||||
aws-sdk-s3 (~> 1.0)
|
|
||||||
carrierwave (>= 0.7, < 2.1)
|
|
||||||
chargebee (2.7.1)
|
|
||||||
json_pure (~> 2.1)
|
json_pure (~> 2.1)
|
||||||
rest-client (>= 1.8, < 3.0)
|
rest-client (>= 1.8, < 3.0)
|
||||||
coderay (1.1.2)
|
coderay (1.1.2)
|
||||||
@@ -122,7 +128,9 @@ GEM
|
|||||||
descendants_tracker (~> 0.0.1)
|
descendants_tracker (~> 0.0.1)
|
||||||
concurrent-ruby (1.1.5)
|
concurrent-ruby (1.1.5)
|
||||||
connection_pool (2.2.2)
|
connection_pool (2.2.2)
|
||||||
crass (1.0.5)
|
crass (1.0.6)
|
||||||
|
declarative (0.0.10)
|
||||||
|
declarative-option (0.1.0)
|
||||||
descendants_tracker (0.0.4)
|
descendants_tracker (0.0.4)
|
||||||
thread_safe (~> 0.3, >= 0.3.1)
|
thread_safe (~> 0.3, >= 0.3.1)
|
||||||
devise (4.7.1)
|
devise (4.7.1)
|
||||||
@@ -136,6 +144,7 @@ GEM
|
|||||||
devise (> 3.5.2, < 5)
|
devise (> 3.5.2, < 5)
|
||||||
rails (>= 4.2.0, < 6.1)
|
rails (>= 4.2.0, < 6.1)
|
||||||
diff-lcs (1.3)
|
diff-lcs (1.3)
|
||||||
|
digest-crc (0.4.1)
|
||||||
docile (1.3.2)
|
docile (1.3.2)
|
||||||
domain_name (0.5.20190701)
|
domain_name (0.5.20190701)
|
||||||
unf (>= 0.0.5, < 1.0.0)
|
unf (>= 0.0.5, < 1.0.0)
|
||||||
@@ -154,14 +163,44 @@ GEM
|
|||||||
factory_bot_rails (5.1.1)
|
factory_bot_rails (5.1.1)
|
||||||
factory_bot (~> 5.1.0)
|
factory_bot (~> 5.1.0)
|
||||||
railties (>= 4.2.0)
|
railties (>= 4.2.0)
|
||||||
faker (2.9.0)
|
faker (2.10.1)
|
||||||
i18n (>= 1.6, < 1.8)
|
i18n (>= 1.6, < 2)
|
||||||
faraday (0.17.1)
|
faraday (0.17.3)
|
||||||
multipart-post (>= 1.2, < 3)
|
multipart-post (>= 1.2, < 3)
|
||||||
ffi (1.11.3)
|
faraday_middleware (0.14.0)
|
||||||
foreman (0.86.0)
|
faraday (>= 0.7.4, < 1.0)
|
||||||
|
ffi (1.12.1)
|
||||||
|
foreman (0.87.0)
|
||||||
globalid (0.4.2)
|
globalid (0.4.2)
|
||||||
activesupport (>= 4.2.0)
|
activesupport (>= 4.2.0)
|
||||||
|
google-api-client (0.36.4)
|
||||||
|
addressable (~> 2.5, >= 2.5.1)
|
||||||
|
googleauth (~> 0.9)
|
||||||
|
httpclient (>= 2.8.1, < 3.0)
|
||||||
|
mini_mime (~> 1.0)
|
||||||
|
representable (~> 3.0)
|
||||||
|
retriable (>= 2.0, < 4.0)
|
||||||
|
signet (~> 0.12)
|
||||||
|
google-cloud-core (1.5.0)
|
||||||
|
google-cloud-env (~> 1.0)
|
||||||
|
google-cloud-errors (~> 1.0)
|
||||||
|
google-cloud-env (1.3.0)
|
||||||
|
faraday (~> 0.11)
|
||||||
|
google-cloud-errors (1.0.0)
|
||||||
|
google-cloud-storage (1.25.1)
|
||||||
|
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.10.0)
|
||||||
|
faraday (~> 0.12)
|
||||||
|
jwt (>= 1.4, < 3.0)
|
||||||
|
memoist (~> 0.16)
|
||||||
|
multi_json (~> 1.11)
|
||||||
|
os (>= 0.9, < 2.0)
|
||||||
|
signet (~> 0.12)
|
||||||
haikunator (1.1.0)
|
haikunator (1.1.0)
|
||||||
hashie (4.0.0)
|
hashie (4.0.0)
|
||||||
http (3.3.0)
|
http (3.3.0)
|
||||||
@@ -172,17 +211,15 @@ GEM
|
|||||||
http-accept (1.7.0)
|
http-accept (1.7.0)
|
||||||
http-cookie (1.0.3)
|
http-cookie (1.0.3)
|
||||||
domain_name (~> 0.5)
|
domain_name (~> 0.5)
|
||||||
http-form_data (2.1.1)
|
http-form_data (2.2.0)
|
||||||
http_parser.rb (0.6.0)
|
http_parser.rb (0.6.0)
|
||||||
httparty (0.17.3)
|
httparty (0.17.3)
|
||||||
mime-types (~> 3.0)
|
mime-types (~> 3.0)
|
||||||
multi_xml (>= 0.5.2)
|
multi_xml (>= 0.5.2)
|
||||||
i18n (1.7.0)
|
httpclient (2.8.3)
|
||||||
|
i18n (1.8.2)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
ice_nine (0.11.2)
|
ice_nine (0.11.2)
|
||||||
image_processing (1.10.0)
|
|
||||||
mini_magick (>= 4.9.5, < 5)
|
|
||||||
ruby-vips (>= 2.0.13, < 3)
|
|
||||||
inflecto (0.0.2)
|
inflecto (0.0.2)
|
||||||
jaro_winkler (1.5.4)
|
jaro_winkler (1.5.4)
|
||||||
jbuilder (2.9.1)
|
jbuilder (2.9.1)
|
||||||
@@ -221,19 +258,21 @@ GEM
|
|||||||
mini_mime (>= 0.1.1)
|
mini_mime (>= 0.1.1)
|
||||||
marcel (0.3.3)
|
marcel (0.3.3)
|
||||||
mimemagic (~> 0.3.2)
|
mimemagic (~> 0.3.2)
|
||||||
|
memoist (0.16.2)
|
||||||
memoizable (0.4.2)
|
memoizable (0.4.2)
|
||||||
thread_safe (~> 0.3, >= 0.3.1)
|
thread_safe (~> 0.3, >= 0.3.1)
|
||||||
method_source (0.9.2)
|
method_source (0.9.2)
|
||||||
mime-types (3.3)
|
mime-types (3.3.1)
|
||||||
mime-types-data (~> 3.2015)
|
mime-types-data (~> 3.2015)
|
||||||
mime-types-data (3.2019.1009)
|
mime-types-data (3.2019.1009)
|
||||||
mimemagic (0.3.3)
|
mimemagic (0.3.3)
|
||||||
mini_magick (4.9.5)
|
mini_magick (4.10.1)
|
||||||
mini_mime (1.0.2)
|
mini_mime (1.0.2)
|
||||||
mini_portile2 (2.4.0)
|
mini_portile2 (2.4.0)
|
||||||
minitest (5.13.0)
|
minitest (5.14.0)
|
||||||
mock_redis (0.22.0)
|
mock_redis (0.22.0)
|
||||||
msgpack (1.3.1)
|
msgpack (1.3.1)
|
||||||
|
multi_json (1.14.1)
|
||||||
multi_xml (0.6.0)
|
multi_xml (0.6.0)
|
||||||
multipart-post (2.1.1)
|
multipart-post (2.1.1)
|
||||||
naught (1.1.0)
|
naught (1.1.0)
|
||||||
@@ -242,27 +281,29 @@ GEM
|
|||||||
nio4r (2.5.2)
|
nio4r (2.5.2)
|
||||||
nokogiri (1.10.7)
|
nokogiri (1.10.7)
|
||||||
mini_portile2 (~> 2.4.0)
|
mini_portile2 (~> 2.4.0)
|
||||||
|
oauth (0.5.4)
|
||||||
orm_adapter (0.5.0)
|
orm_adapter (0.5.0)
|
||||||
|
os (1.0.1)
|
||||||
parallel (1.19.1)
|
parallel (1.19.1)
|
||||||
parser (2.6.5.0)
|
parser (2.7.0.2)
|
||||||
ast (~> 2.4.0)
|
ast (~> 2.4.0)
|
||||||
pg (1.1.4)
|
pg (1.2.2)
|
||||||
pry (0.12.2)
|
pry (0.12.2)
|
||||||
coderay (~> 1.1.0)
|
coderay (~> 1.1.0)
|
||||||
method_source (~> 0.9.0)
|
method_source (~> 0.9.0)
|
||||||
pry-rails (0.3.9)
|
pry-rails (0.3.9)
|
||||||
pry (>= 0.10.4)
|
pry (>= 0.10.4)
|
||||||
public_suffix (4.0.1)
|
public_suffix (4.0.3)
|
||||||
puma (4.3.1)
|
puma (4.3.1)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
pundit (2.1.0)
|
pundit (2.1.0)
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
rack (2.0.8)
|
rack (2.1.1)
|
||||||
rack-cache (1.10.0)
|
rack-cache (1.11.0)
|
||||||
rack (>= 0.4)
|
rack (>= 0.4)
|
||||||
rack-cors (1.1.0)
|
rack-cors (1.1.1)
|
||||||
rack (>= 2.0.0)
|
rack (>= 2.0.0)
|
||||||
rack-protection (2.0.7)
|
rack-protection (2.0.8.1)
|
||||||
rack
|
rack
|
||||||
rack-proxy (0.6.5)
|
rack-proxy (0.6.5)
|
||||||
rack
|
rack
|
||||||
@@ -297,7 +338,7 @@ GEM
|
|||||||
rainbow (3.0.0)
|
rainbow (3.0.0)
|
||||||
rake (13.0.1)
|
rake (13.0.1)
|
||||||
rb-fsevent (0.10.3)
|
rb-fsevent (0.10.3)
|
||||||
rb-inotify (0.10.0)
|
rb-inotify (0.10.1)
|
||||||
ffi (~> 1.0)
|
ffi (~> 1.0)
|
||||||
redis (4.1.3)
|
redis (4.1.3)
|
||||||
redis-namespace (1.7.0)
|
redis-namespace (1.7.0)
|
||||||
@@ -307,6 +348,10 @@ GEM
|
|||||||
redis-store (>= 1.6, < 2)
|
redis-store (>= 1.6, < 2)
|
||||||
redis-store (1.8.1)
|
redis-store (1.8.1)
|
||||||
redis (>= 4, < 5)
|
redis (>= 4, < 5)
|
||||||
|
representable (3.0.4)
|
||||||
|
declarative (< 0.1.0)
|
||||||
|
declarative-option (< 0.2.0)
|
||||||
|
uber (< 0.2.0)
|
||||||
responders (3.0.0)
|
responders (3.0.0)
|
||||||
actionpack (>= 5.0)
|
actionpack (>= 5.0)
|
||||||
railties (>= 5.0)
|
railties (>= 5.0)
|
||||||
@@ -315,52 +360,56 @@ GEM
|
|||||||
http-cookie (>= 1.0.2, < 2.0)
|
http-cookie (>= 1.0.2, < 2.0)
|
||||||
mime-types (>= 1.16, < 4.0)
|
mime-types (>= 1.16, < 4.0)
|
||||||
netrc (~> 0.8)
|
netrc (~> 0.8)
|
||||||
rspec-core (3.9.0)
|
retriable (3.1.2)
|
||||||
rspec-support (~> 3.9.0)
|
rspec-core (3.9.1)
|
||||||
|
rspec-support (~> 3.9.1)
|
||||||
rspec-expectations (3.9.0)
|
rspec-expectations (3.9.0)
|
||||||
diff-lcs (>= 1.2.0, < 2.0)
|
diff-lcs (>= 1.2.0, < 2.0)
|
||||||
rspec-support (~> 3.9.0)
|
rspec-support (~> 3.9.0)
|
||||||
rspec-mocks (3.9.0)
|
rspec-mocks (3.9.1)
|
||||||
diff-lcs (>= 1.2.0, < 2.0)
|
diff-lcs (>= 1.2.0, < 2.0)
|
||||||
rspec-support (~> 3.9.0)
|
rspec-support (~> 3.9.0)
|
||||||
rspec-rails (4.0.0.beta3)
|
rspec-rails (4.0.0.beta4)
|
||||||
actionpack (>= 4.2)
|
actionpack (>= 4.2)
|
||||||
activesupport (>= 4.2)
|
activesupport (>= 4.2)
|
||||||
railties (>= 4.2)
|
railties (>= 4.2)
|
||||||
rspec-core (~> 3.8)
|
rspec-core (~> 3.9)
|
||||||
rspec-expectations (~> 3.8)
|
rspec-expectations (~> 3.9)
|
||||||
rspec-mocks (~> 3.8)
|
rspec-mocks (~> 3.9)
|
||||||
rspec-support (~> 3.8)
|
rspec-support (~> 3.9)
|
||||||
rspec-support (3.9.0)
|
rspec-support (3.9.2)
|
||||||
rubocop (0.78.0)
|
rubocop (0.79.0)
|
||||||
jaro_winkler (~> 1.5.1)
|
jaro_winkler (~> 1.5.1)
|
||||||
parallel (~> 1.10)
|
parallel (~> 1.10)
|
||||||
parser (>= 2.6)
|
parser (>= 2.7.0.1)
|
||||||
rainbow (>= 2.2.2, < 4.0)
|
rainbow (>= 2.2.2, < 4.0)
|
||||||
ruby-progressbar (~> 1.7)
|
ruby-progressbar (~> 1.7)
|
||||||
unicode-display_width (>= 1.4.0, < 1.7)
|
unicode-display_width (>= 1.4.0, < 1.7)
|
||||||
rubocop-performance (1.5.1)
|
rubocop-performance (1.5.2)
|
||||||
rubocop (>= 0.71.0)
|
rubocop (>= 0.71.0)
|
||||||
rubocop-rails (2.4.0)
|
rubocop-rails (2.4.1)
|
||||||
rack (>= 1.1)
|
rack (>= 1.1)
|
||||||
rubocop (>= 0.72.0)
|
rubocop (>= 0.72.0)
|
||||||
rubocop-rspec (1.37.1)
|
rubocop-rspec (1.37.1)
|
||||||
rubocop (>= 0.68.1)
|
rubocop (>= 0.68.1)
|
||||||
ruby-progressbar (1.10.1)
|
ruby-progressbar (1.10.1)
|
||||||
ruby-vips (2.0.16)
|
|
||||||
ffi (~> 1.9)
|
|
||||||
seed_dump (3.3.1)
|
seed_dump (3.3.1)
|
||||||
activerecord (>= 4)
|
activerecord (>= 4)
|
||||||
activesupport (>= 4)
|
activesupport (>= 4)
|
||||||
sentry-raven (2.13.0)
|
sentry-raven (2.13.0)
|
||||||
faraday (>= 0.7.6, < 1.0)
|
faraday (>= 0.7.6, < 1.0)
|
||||||
shoulda-matchers (4.1.2)
|
shoulda-matchers (4.2.0)
|
||||||
activesupport (>= 4.2.0)
|
activesupport (>= 4.2.0)
|
||||||
sidekiq (6.0.4)
|
sidekiq (6.0.4)
|
||||||
connection_pool (>= 2.2.2)
|
connection_pool (>= 2.2.2)
|
||||||
rack (>= 2.0.0)
|
rack (>= 2.0.0)
|
||||||
rack-protection (>= 2.0.0)
|
rack-protection (>= 2.0.0)
|
||||||
redis (>= 4.1.0)
|
redis (>= 4.1.0)
|
||||||
|
signet (0.12.0)
|
||||||
|
addressable (~> 2.3)
|
||||||
|
faraday (~> 0.9)
|
||||||
|
jwt (>= 1.5, < 3.0)
|
||||||
|
multi_json (~> 1.10)
|
||||||
simple_oauth (0.3.1)
|
simple_oauth (0.3.1)
|
||||||
simplecov (0.17.1)
|
simplecov (0.17.1)
|
||||||
docile (~> 1.1)
|
docile (~> 1.1)
|
||||||
@@ -398,16 +447,17 @@ GEM
|
|||||||
multipart-post (~> 2.0)
|
multipart-post (~> 2.0)
|
||||||
naught (~> 1.0)
|
naught (~> 1.0)
|
||||||
simple_oauth (~> 0.3.0)
|
simple_oauth (~> 0.3.0)
|
||||||
tzinfo (1.2.5)
|
tzinfo (1.2.6)
|
||||||
thread_safe (~> 0.1)
|
thread_safe (~> 0.1)
|
||||||
tzinfo-data (1.2019.3)
|
tzinfo-data (1.2019.3)
|
||||||
tzinfo (>= 1.0.0)
|
tzinfo (>= 1.0.0)
|
||||||
|
uber (0.1.0)
|
||||||
uglifier (4.2.0)
|
uglifier (4.2.0)
|
||||||
execjs (>= 0.3.0, < 3)
|
execjs (>= 0.3.0, < 3)
|
||||||
unf (0.1.4)
|
unf (0.1.4)
|
||||||
unf_ext
|
unf_ext
|
||||||
unf_ext (0.0.7.6)
|
unf_ext (0.0.7.6)
|
||||||
unicode-display_width (1.6.0)
|
unicode-display_width (1.6.1)
|
||||||
uniform_notifier (1.13.0)
|
uniform_notifier (1.13.0)
|
||||||
valid_email2 (3.1.3)
|
valid_email2 (3.1.3)
|
||||||
activemodel (>= 3.2)
|
activemodel (>= 3.2)
|
||||||
@@ -442,13 +492,14 @@ DEPENDENCIES
|
|||||||
acts-as-taggable-on
|
acts-as-taggable-on
|
||||||
annotate
|
annotate
|
||||||
attr_extras
|
attr_extras
|
||||||
|
aws-sdk-s3
|
||||||
|
azure-storage
|
||||||
bootsnap
|
bootsnap
|
||||||
brakeman
|
brakeman
|
||||||
browser
|
browser
|
||||||
bullet
|
bullet
|
||||||
bundle-audit
|
bundle-audit
|
||||||
byebug
|
byebug
|
||||||
carrierwave-aws
|
|
||||||
chargebee
|
chargebee
|
||||||
devise
|
devise
|
||||||
devise_token_auth
|
devise_token_auth
|
||||||
@@ -457,6 +508,7 @@ DEPENDENCIES
|
|||||||
factory_bot_rails
|
factory_bot_rails
|
||||||
faker
|
faker
|
||||||
foreman
|
foreman
|
||||||
|
google-cloud-storage
|
||||||
haikunator
|
haikunator
|
||||||
hashie
|
hashie
|
||||||
jbuilder
|
jbuilder
|
||||||
@@ -493,6 +545,7 @@ DEPENDENCIES
|
|||||||
telegram-bot-ruby
|
telegram-bot-ruby
|
||||||
time_diff
|
time_diff
|
||||||
twitter
|
twitter
|
||||||
|
twitty!
|
||||||
tzinfo-data
|
tzinfo-data
|
||||||
uglifier
|
uglifier
|
||||||
valid_email2
|
valid_email2
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
backend: bin/rails s -p 3000
|
backend: bin/rails s -p 3000
|
||||||
frontend: bin/webpack-dev-server
|
frontend: bin/webpack-dev-server
|
||||||
worker: bundle exec sidekiq
|
worker: bundle exec sidekiq -C config/sidekiq.yml
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ Detailed documentation is available at [www.chatwoot.com/docs](https://www.chatw
|
|||||||
|
|
||||||
You can find the quick setup docs [here](https://www.chatwoot.com/docs/quick-setup).
|
You can find the quick setup docs [here](https://www.chatwoot.com/docs/quick-setup).
|
||||||
|
|
||||||
|
## Branching model
|
||||||
|
|
||||||
|
We use [git-flow](https://nvie.com/posts/a-successful-git-branching-model/) branching model. The base branch is `develop`.
|
||||||
|
If you are looking for a stable version, please use the `master` or tags labelled as `v1.x.x`.
|
||||||
|
|
||||||
## Heroku one-click deploy
|
## Heroku one-click deploy
|
||||||
|
|
||||||
Deploying chatwoot to heroku, it's a breeze. It's as simple as clicking this button.
|
Deploying chatwoot to heroku, it's a breeze. It's as simple as clicking this button.
|
||||||
|
|||||||
@@ -6,125 +6,137 @@ require 'open-uri'
|
|||||||
# based on this we are showing "not sent from chatwoot" message in frontend
|
# based on this we are showing "not sent from chatwoot" message in frontend
|
||||||
# Hence there is no need to set user_id in message for outgoing echo messages.
|
# Hence there is no need to set user_id in message for outgoing echo messages.
|
||||||
|
|
||||||
module Messages
|
class Messages::MessageBuilder
|
||||||
class MessageBuilder
|
attr_reader :response
|
||||||
attr_reader :response
|
|
||||||
|
|
||||||
def initialize(response, inbox, outgoing_echo = false)
|
def initialize(response, inbox, outgoing_echo = false)
|
||||||
@response = response
|
@response = response
|
||||||
@inbox = inbox
|
@inbox = inbox
|
||||||
@sender_id = (outgoing_echo ? @response.recipient_id : @response.sender_id)
|
@sender_id = (outgoing_echo ? @response.recipient_id : @response.sender_id)
|
||||||
@message_type = (outgoing_echo ? :outgoing : :incoming)
|
@message_type = (outgoing_echo ? :outgoing : :incoming)
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
build_contact
|
||||||
|
build_message
|
||||||
end
|
end
|
||||||
|
rescue StandardError => e
|
||||||
|
Raven.capture_exception(e)
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
def perform
|
private
|
||||||
ActiveRecord::Base.transaction do
|
|
||||||
build_contact
|
|
||||||
build_message
|
|
||||||
end
|
|
||||||
rescue StandardError => e
|
|
||||||
Raven.capture_exception(e)
|
|
||||||
true
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
def contact
|
||||||
|
@contact ||= @inbox.contact_inboxes.find_by(source_id: @sender_id)&.contact
|
||||||
|
end
|
||||||
|
|
||||||
def contact
|
def build_contact
|
||||||
@contact ||= @inbox.contact_inboxes.find_by(source_id: @sender_id)&.contact
|
return if contact.present?
|
||||||
end
|
|
||||||
|
|
||||||
def build_contact
|
@contact = Contact.create!(contact_params.except(:remote_avatar_url))
|
||||||
return if contact.present?
|
avatar_resource = LocalResource.new(contact_params[:remote_avatar_url])
|
||||||
|
@contact.avatar.attach(io: avatar_resource.file, filename: avatar_resource.tmp_filename, content_type: avatar_resource.encoding)
|
||||||
|
|
||||||
@contact = Contact.create!(contact_params)
|
@contact_inbox = ContactInbox.create(contact: contact, inbox: @inbox, source_id: @sender_id)
|
||||||
ContactInbox.create(contact: contact, inbox: @inbox, source_id: @sender_id)
|
end
|
||||||
end
|
|
||||||
|
|
||||||
def build_message
|
def build_message
|
||||||
@message = conversation.messages.new(message_params)
|
@message = conversation.messages.create!(message_params)
|
||||||
(response.attachments || []).each do |attachment|
|
(response.attachments || []).each do |attachment|
|
||||||
@message.build_attachment(attachment_params(attachment))
|
attachment_obj = @message.build_attachment(attachment_params(attachment).except(:remote_file_url))
|
||||||
end
|
attachment_obj.save!
|
||||||
@message.save!
|
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
|
||||||
end
|
|
||||||
|
|
||||||
def build_attachment; end
|
|
||||||
|
|
||||||
def conversation
|
|
||||||
@conversation ||= Conversation.find_by(conversation_params) || Conversation.create!(conversation_params)
|
|
||||||
end
|
|
||||||
|
|
||||||
def attachment_params(attachment)
|
|
||||||
file_type = attachment['type'].to_sym
|
|
||||||
params = { file_type: file_type, account_id: @message.account_id }
|
|
||||||
|
|
||||||
if [:image, :file, :audio, :video].include? file_type
|
|
||||||
params.merge!(file_type_params(attachment))
|
|
||||||
elsif file_type == :location
|
|
||||||
params.merge!(location_params(attachment))
|
|
||||||
elsif file_type == :fallback
|
|
||||||
params.merge!(fallback_params(attachment))
|
|
||||||
end
|
|
||||||
|
|
||||||
params
|
|
||||||
end
|
|
||||||
|
|
||||||
def file_type_params(attachment)
|
|
||||||
{
|
|
||||||
external_url: attachment['payload']['url'],
|
|
||||||
remote_file_url: attachment['payload']['url']
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def location_params(attachment)
|
|
||||||
lat = attachment['payload']['coordinates']['lat']
|
|
||||||
long = attachment['payload']['coordinates']['long']
|
|
||||||
{
|
|
||||||
external_url: attachment['url'],
|
|
||||||
coordinates_lat: lat,
|
|
||||||
coordinates_long: long,
|
|
||||||
fallback_title: attachment['title']
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def fallback_params(attachment)
|
|
||||||
{
|
|
||||||
fallback_title: attachment['title'],
|
|
||||||
external_url: attachment['url']
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def conversation_params
|
|
||||||
{
|
|
||||||
account_id: @inbox.account_id,
|
|
||||||
inbox_id: @inbox.id,
|
|
||||||
contact_id: contact.id
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def message_params
|
|
||||||
{
|
|
||||||
account_id: conversation.account_id,
|
|
||||||
inbox_id: conversation.inbox_id,
|
|
||||||
message_type: @message_type,
|
|
||||||
content: response.content,
|
|
||||||
fb_id: response.identifier
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def contact_params
|
|
||||||
begin
|
|
||||||
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
|
|
||||||
result = k.get_object(@sender_id) || {}
|
|
||||||
rescue Exception => e
|
|
||||||
result = {}
|
|
||||||
Raven.capture_exception(e)
|
|
||||||
end
|
|
||||||
{
|
|
||||||
name: "#{result['first_name'] || 'John'} #{result['last_name'] || 'Doe'}",
|
|
||||||
account_id: @inbox.account_id,
|
|
||||||
remote_avatar_url: result['profile_pic'] || nil
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def attach_file(attachment, file_url)
|
||||||
|
file_resource = LocalResource.new(file_url)
|
||||||
|
attachment.file.attach(io: file_resource.file, filename: file_resource.tmp_filename, content_type: file_resource.encoding)
|
||||||
|
end
|
||||||
|
|
||||||
|
def conversation
|
||||||
|
@conversation ||= Conversation.find_by(conversation_params) || build_conversation
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_conversation
|
||||||
|
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: @sender_id)
|
||||||
|
Conversation.create!(conversation_params.merge(
|
||||||
|
contact_inbox_id: @contact_inbox.id
|
||||||
|
))
|
||||||
|
end
|
||||||
|
|
||||||
|
def attachment_params(attachment)
|
||||||
|
file_type = attachment['type'].to_sym
|
||||||
|
params = { file_type: file_type, account_id: @message.account_id }
|
||||||
|
|
||||||
|
if [:image, :file, :audio, :video].include? file_type
|
||||||
|
params.merge!(file_type_params(attachment))
|
||||||
|
elsif file_type == :location
|
||||||
|
params.merge!(location_params(attachment))
|
||||||
|
elsif file_type == :fallback
|
||||||
|
params.merge!(fallback_params(attachment))
|
||||||
|
end
|
||||||
|
|
||||||
|
params
|
||||||
|
end
|
||||||
|
|
||||||
|
def file_type_params(attachment)
|
||||||
|
{
|
||||||
|
external_url: attachment['payload']['url'],
|
||||||
|
remote_file_url: attachment['payload']['url']
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def location_params(attachment)
|
||||||
|
lat = attachment['payload']['coordinates']['lat']
|
||||||
|
long = attachment['payload']['coordinates']['long']
|
||||||
|
{
|
||||||
|
external_url: attachment['url'],
|
||||||
|
coordinates_lat: lat,
|
||||||
|
coordinates_long: long,
|
||||||
|
fallback_title: attachment['title']
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def fallback_params(attachment)
|
||||||
|
{
|
||||||
|
fallback_title: attachment['title'],
|
||||||
|
external_url: attachment['url']
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def conversation_params
|
||||||
|
{
|
||||||
|
account_id: @inbox.account_id,
|
||||||
|
inbox_id: @inbox.id,
|
||||||
|
contact_id: contact.id
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def message_params
|
||||||
|
{
|
||||||
|
account_id: conversation.account_id,
|
||||||
|
inbox_id: conversation.inbox_id,
|
||||||
|
message_type: @message_type,
|
||||||
|
content: response.content,
|
||||||
|
fb_id: response.identifier
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def contact_params
|
||||||
|
begin
|
||||||
|
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
|
||||||
|
result = k.get_object(@sender_id) || {}
|
||||||
|
rescue Exception => e
|
||||||
|
result = {}
|
||||||
|
Raven.capture_exception(e)
|
||||||
|
end
|
||||||
|
{
|
||||||
|
name: "#{result['first_name'] || 'John'} #{result['last_name'] || 'Doe'}",
|
||||||
|
account_id: @inbox.account_id,
|
||||||
|
remote_avatar_url: result['profile_pic'] || ''
|
||||||
|
}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
class RoomChannel < ApplicationCable::Channel
|
class RoomChannel < ApplicationCable::Channel
|
||||||
def subscribed
|
def subscribed
|
||||||
stream_from params[:pubsub_token]
|
stream_from params[:pubsub_token]
|
||||||
|
::OnlineStatusTracker.add_subscription(params[:pubsub_token])
|
||||||
|
end
|
||||||
|
|
||||||
|
def unsubscribed
|
||||||
|
::OnlineStatusTracker.remove_subscription(params[:pubsub_token])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ class Api::V1::AgentsController < Api::BaseController
|
|||||||
before_action :build_agent, only: [:create]
|
before_action :build_agent, only: [:create]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
render json: agents
|
@agents = agents
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ class Api::V1::CallbacksController < ApplicationController
|
|||||||
inbox_name = params[:inbox_name]
|
inbox_name = params[:inbox_name]
|
||||||
facebook_channel = current_account.facebook_pages.create!(
|
facebook_channel = current_account.facebook_pages.create!(
|
||||||
name: page_name, page_id: page_id, user_access_token: user_access_token,
|
name: page_name, page_id: page_id, user_access_token: user_access_token,
|
||||||
page_access_token: page_access_token, remote_avatar_url: set_avatar(page_id)
|
page_access_token: page_access_token
|
||||||
)
|
)
|
||||||
|
set_avatar(facebook_channel, page_id)
|
||||||
inbox = current_account.inboxes.create!(name: inbox_name, channel: facebook_channel)
|
inbox = current_account.inboxes.create!(name: inbox_name, channel: facebook_channel)
|
||||||
render json: inbox
|
render json: inbox
|
||||||
end
|
end
|
||||||
@@ -79,7 +80,12 @@ class Api::V1::CallbacksController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_avatar(page_id)
|
def set_avatar(facebook_channel, page_id)
|
||||||
|
avatar_resource = LocalResource.new(get_avatar_url(page_id))
|
||||||
|
facebook_channel.avatar.attach(io: avatar_resource.file, filename: avatar_resource.tmp_filename, content_type: avatar_resource.encoding)
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_avatar_url(page_id)
|
||||||
begin
|
begin
|
||||||
url = 'http://graph.facebook.com/' << page_id << '/picture?type=large'
|
url = 'http://graph.facebook.com/' << page_id << '/picture?type=large'
|
||||||
uri = URI.parse(url)
|
uri = URI.parse(url)
|
||||||
|
|||||||
23
app/controllers/api/v1/contacts/conversations_controller.rb
Normal file
23
app/controllers/api/v1/contacts/conversations_controller.rb
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
class Api::V1::Contacts::ConversationsController < Api::BaseController
|
||||||
|
def index
|
||||||
|
@conversations = current_account.conversations.includes(
|
||||||
|
:assignee, :contact, :inbox
|
||||||
|
).where(inbox_id: inbox_ids, contact_id: permitted_params[:contact_id])
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def inbox_ids
|
||||||
|
if current_user.administrator?
|
||||||
|
current_account.inboxes.pluck(:id)
|
||||||
|
elsif current_user.agent?
|
||||||
|
current_user.assigned_inboxes.pluck(:id)
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def permitted_params
|
||||||
|
params.permit(:contact_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,55 +1,51 @@
|
|||||||
module Api
|
class Api::V1::InboxMembersController < Api::BaseController
|
||||||
module V1
|
before_action :fetch_inbox, only: [:create, :show]
|
||||||
class InboxMembersController < Api::BaseController
|
before_action :current_agents_ids, only: [:create]
|
||||||
before_action :fetch_inbox, only: [:create, :show]
|
|
||||||
before_action :current_agents_ids, only: [:create]
|
|
||||||
|
|
||||||
def create
|
def create
|
||||||
# update also done via same action
|
# update also done via same action
|
||||||
if @inbox
|
if @inbox
|
||||||
begin
|
begin
|
||||||
update_agents_list
|
update_agents_list
|
||||||
head :ok
|
head :ok
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Rails.logger.debug "Rescued: #{e.inspect}"
|
Rails.logger.debug "Rescued: #{e.inspect}"
|
||||||
render_could_not_create_error('Could not add agents to inbox')
|
render_could_not_create_error('Could not add agents to inbox')
|
||||||
end
|
|
||||||
else
|
|
||||||
render_not_found_error('Agents or inbox not found')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def show
|
|
||||||
@agents = current_account.users.where(id: @inbox.members.pluck(:user_id))
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def update_agents_list
|
|
||||||
# get all the user_ids which the inbox currently has as members.
|
|
||||||
# get the list of user_ids from params
|
|
||||||
# the missing ones are the agents which are to be deleted from the inbox
|
|
||||||
# the new ones are the agents which are to be added to the inbox
|
|
||||||
|
|
||||||
agents_to_be_added_ids.each { |user_id| @inbox.add_member(user_id) }
|
|
||||||
agents_to_be_removed_ids.each { |user_id| @inbox.remove_member(user_id) }
|
|
||||||
end
|
|
||||||
|
|
||||||
def agents_to_be_added_ids
|
|
||||||
params[:user_ids] - @current_agents_ids
|
|
||||||
end
|
|
||||||
|
|
||||||
def agents_to_be_removed_ids
|
|
||||||
@current_agents_ids - params[:user_ids]
|
|
||||||
end
|
|
||||||
|
|
||||||
def current_agents_ids
|
|
||||||
@current_agents_ids = @inbox.members.pluck(:id)
|
|
||||||
end
|
|
||||||
|
|
||||||
def fetch_inbox
|
|
||||||
@inbox = current_account.inboxes.find(params[:inbox_id])
|
|
||||||
end
|
end
|
||||||
|
else
|
||||||
|
render_not_found_error('Agents or inbox not found')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
@agents = current_account.users.where(id: @inbox.members.pluck(:user_id))
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def update_agents_list
|
||||||
|
# get all the user_ids which the inbox currently has as members.
|
||||||
|
# get the list of user_ids from params
|
||||||
|
# the missing ones are the agents which are to be deleted from the inbox
|
||||||
|
# the new ones are the agents which are to be added to the inbox
|
||||||
|
|
||||||
|
agents_to_be_added_ids.each { |user_id| @inbox.add_member(user_id) }
|
||||||
|
agents_to_be_removed_ids.each { |user_id| @inbox.remove_member(user_id) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def agents_to_be_added_ids
|
||||||
|
params[:user_ids] - @current_agents_ids
|
||||||
|
end
|
||||||
|
|
||||||
|
def agents_to_be_removed_ids
|
||||||
|
@current_agents_ids - params[:user_ids]
|
||||||
|
end
|
||||||
|
|
||||||
|
def current_agents_ids
|
||||||
|
@current_agents_ids = @inbox.members.pluck(:id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_inbox
|
||||||
|
@inbox = current_account.inboxes.find(params[:inbox_id])
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,4 +2,8 @@ class Api::V1::LabelsController < Api::BaseController
|
|||||||
def index # list all labels in account
|
def index # list all labels in account
|
||||||
@labels = current_account.all_conversation_tags
|
@labels = current_account.all_conversation_tags
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def most_used
|
||||||
|
@labels = ActsAsTaggableOn::Tag.most_used(params[:count] || 10)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ class Api::V1::WebhooksController < ApplicationController
|
|||||||
skip_before_action :set_current_user
|
skip_before_action :set_current_user
|
||||||
skip_before_action :check_subscription
|
skip_before_action :check_subscription
|
||||||
|
|
||||||
before_action :login_from_basic_auth
|
before_action :login_from_basic_auth, only: [:chargebee]
|
||||||
def chargebee
|
def chargebee
|
||||||
chargebee_consumer.consume
|
chargebee_consumer.consume
|
||||||
head :ok
|
head :ok
|
||||||
@@ -12,6 +12,18 @@ class Api::V1::WebhooksController < ApplicationController
|
|||||||
head :ok
|
head :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def twitter_crc
|
||||||
|
render json: { response_token: "sha256=#{$twitter.generate_crc(params[:crc_token])}" }
|
||||||
|
end
|
||||||
|
|
||||||
|
def twitter_events
|
||||||
|
twitter_consumer.consume
|
||||||
|
head :ok
|
||||||
|
rescue StandardError => e
|
||||||
|
Raven.capture_exception(e)
|
||||||
|
head :ok
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def login_from_basic_auth
|
def login_from_basic_auth
|
||||||
@@ -21,6 +33,10 @@ class Api::V1::WebhooksController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def chargebee_consumer
|
def chargebee_consumer
|
||||||
@consumer ||= ::Webhooks::Chargebee.new(params)
|
@chargebee_consumer ||= ::Webhooks::Chargebee.new(params)
|
||||||
|
end
|
||||||
|
|
||||||
|
def twitter_consumer
|
||||||
|
@twitter_consumer ||= ::Webhooks::Twitter.new(params)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
29
app/controllers/api/v1/widget/base_controller.rb
Normal file
29
app/controllers/api/v1/widget/base_controller.rb
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
class Api::V1::Widget::BaseController < ApplicationController
|
||||||
|
private
|
||||||
|
|
||||||
|
def conversation
|
||||||
|
@conversation ||= @contact_inbox.conversations.find_by(
|
||||||
|
inbox_id: auth_token_params[:inbox_id]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def auth_token_params
|
||||||
|
@auth_token_params ||= ::Widget::TokenService.new(token: request.headers[header_name]).decode_token
|
||||||
|
end
|
||||||
|
|
||||||
|
def header_name
|
||||||
|
'X-Auth-Token'
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_web_widget
|
||||||
|
@web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token])
|
||||||
|
@account = @web_widget.account
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_contact
|
||||||
|
@contact_inbox = @web_widget.inbox.contact_inboxes.find_by(
|
||||||
|
source_id: auth_token_params[:source_id]
|
||||||
|
)
|
||||||
|
@contact = @contact_inbox.contact
|
||||||
|
end
|
||||||
|
end
|
||||||
13
app/controllers/api/v1/widget/inbox_members_controller.rb
Normal file
13
app/controllers/api/v1/widget/inbox_members_controller.rb
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
class Api::V1::Widget::InboxMembersController < Api::V1::Widget::BaseController
|
||||||
|
before_action :set_web_widget
|
||||||
|
|
||||||
|
def index
|
||||||
|
@inbox_members = @web_widget.inbox.inbox_members.includes(:user)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def permitted_params
|
||||||
|
params.permit(:website_token)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
class Api::V1::Widget::MessagesController < ActionController::Base
|
class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
||||||
skip_before_action :verify_authenticity_token
|
before_action :set_web_widget
|
||||||
|
before_action :set_contact
|
||||||
before_action :set_conversation, only: [:create]
|
before_action :set_conversation, only: [:create]
|
||||||
|
before_action :set_message, only: [:update]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@messages = conversation.nil? ? [] : message_finder.perform
|
@messages = conversation.nil? ? [] : message_finder.perform
|
||||||
@@ -9,17 +11,19 @@ class Api::V1::Widget::MessagesController < ActionController::Base
|
|||||||
def create
|
def create
|
||||||
@message = conversation.messages.new(message_params)
|
@message = conversation.messages.new(message_params)
|
||||||
@message.save!
|
@message.save!
|
||||||
|
render json: @message
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
@message.update!(input_submitted_email: contact_email)
|
||||||
|
update_contact(contact_email)
|
||||||
|
head :no_content
|
||||||
|
rescue StandardError => e
|
||||||
|
render json: { error: @contact.errors, message: e.message }.to_json, status: 500
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def conversation
|
|
||||||
@conversation ||= ::Conversation.find_by(
|
|
||||||
contact_id: cookie_params[:contact_id],
|
|
||||||
inbox_id: cookie_params[:inbox_id]
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_conversation
|
def set_conversation
|
||||||
@conversation = ::Conversation.create!(conversation_params) if conversation.nil?
|
@conversation = ::Conversation.create!(conversation_params) if conversation.nil?
|
||||||
end
|
end
|
||||||
@@ -37,7 +41,8 @@ class Api::V1::Widget::MessagesController < ActionController::Base
|
|||||||
{
|
{
|
||||||
account_id: inbox.account_id,
|
account_id: inbox.account_id,
|
||||||
inbox_id: inbox.id,
|
inbox_id: inbox.id,
|
||||||
contact_id: cookie_params[:contact_id],
|
contact_id: @contact.id,
|
||||||
|
contact_inbox_id: @contact_inbox.id,
|
||||||
additional_attributes: {
|
additional_attributes: {
|
||||||
browser: browser_params,
|
browser: browser_params,
|
||||||
referer: permitted_params[:message][:referer_url],
|
referer: permitted_params[:message][:referer_url],
|
||||||
@@ -63,13 +68,7 @@ class Api::V1::Widget::MessagesController < ActionController::Base
|
|||||||
end
|
end
|
||||||
|
|
||||||
def inbox
|
def inbox
|
||||||
@inbox ||= ::Inbox.find_by(id: cookie_params[:inbox_id])
|
@inbox ||= ::Inbox.find_by(id: auth_token_params[:inbox_id])
|
||||||
end
|
|
||||||
|
|
||||||
def cookie_params
|
|
||||||
@cookie_params ||= JWT.decode(
|
|
||||||
request.headers[header_name], secret_key, true, algorithm: 'HS256'
|
|
||||||
).first.symbolize_keys
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def message_finder_params
|
def message_finder_params
|
||||||
@@ -83,15 +82,31 @@ class Api::V1::Widget::MessagesController < ActionController::Base
|
|||||||
@message_finder ||= MessageFinder.new(conversation, message_finder_params)
|
@message_finder ||= MessageFinder.new(conversation, message_finder_params)
|
||||||
end
|
end
|
||||||
|
|
||||||
def header_name
|
def update_contact(email)
|
||||||
'X-Auth-Token'
|
contact_with_email = @account.contacts.find_by(email: email)
|
||||||
|
if contact_with_email
|
||||||
|
::ContactMergeAction.new(account: @account, base_contact: contact_with_email, mergee_contact: @contact).perform
|
||||||
|
else
|
||||||
|
@contact.update!(
|
||||||
|
email: email,
|
||||||
|
name: contact_name
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def contact_email
|
||||||
|
permitted_params[:contact][:email].downcase
|
||||||
|
end
|
||||||
|
|
||||||
|
def contact_name
|
||||||
|
contact_email.split('@')[0]
|
||||||
end
|
end
|
||||||
|
|
||||||
def permitted_params
|
def permitted_params
|
||||||
params.permit(:before, message: [:content, :referer_url, :timestamp])
|
params.permit(:id, :before, :website_token, contact: [:email], message: [:content, :referer_url, :timestamp])
|
||||||
end
|
end
|
||||||
|
|
||||||
def secret_key
|
def set_message
|
||||||
Rails.application.secrets.secret_key_base
|
@message = @web_widget.inbox.messages.find(permitted_params[:id])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
class ConfirmationsController < Devise::ConfirmationsController
|
class DeviseOverrides::ConfirmationsController < Devise::ConfirmationsController
|
||||||
skip_before_action :require_no_authentication, raise: false
|
skip_before_action :require_no_authentication, raise: false
|
||||||
skip_before_action :authenticate_user!, raise: false
|
skip_before_action :authenticate_user!, raise: false
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
class PasswordsController < Devise::PasswordsController
|
class DeviseOverrides::PasswordsController < Devise::PasswordsController
|
||||||
include AuthHelper
|
include AuthHelper
|
||||||
|
|
||||||
skip_before_action :require_no_authentication, raise: false
|
skip_before_action :require_no_authentication, raise: false
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
class SessionsController < ::DeviseTokenAuth::SessionsController
|
class DeviseOverrides::SessionsController < ::DeviseTokenAuth::SessionsController
|
||||||
# Prevent session parameter from being passed
|
# Prevent session parameter from being passed
|
||||||
# Unpermitted parameter: session
|
# Unpermitted parameter: session
|
||||||
wrap_parameters format: []
|
wrap_parameters format: []
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
class DeviseOverrides::TokenValidationsController < ::DeviseTokenAuth::TokenValidationsController
|
||||||
|
def validate_token
|
||||||
|
# @resource will have been set by set_user_by_token concern
|
||||||
|
if @resource
|
||||||
|
render 'devise/token.json'
|
||||||
|
else
|
||||||
|
render_validate_token_error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -4,66 +4,47 @@ class WidgetsController < ActionController::Base
|
|||||||
before_action :set_contact
|
before_action :set_contact
|
||||||
before_action :build_contact
|
before_action :build_contact
|
||||||
|
|
||||||
|
def index
|
||||||
|
render
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_contact
|
|
||||||
return if cookie_params[:source_id].nil?
|
|
||||||
|
|
||||||
contact_inbox = ::ContactInbox.find_by(
|
|
||||||
inbox_id: @web_widget.inbox.id,
|
|
||||||
source_id: cookie_params[:source_id]
|
|
||||||
)
|
|
||||||
|
|
||||||
@contact = contact_inbox ? contact_inbox.contact : nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_token
|
|
||||||
@token = conversation_token
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_web_widget
|
def set_web_widget
|
||||||
@web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token])
|
@web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def set_token
|
||||||
|
@token = permitted_params[:cw_conversation]
|
||||||
|
@auth_token_params = if @token.present?
|
||||||
|
::Widget::TokenService.new(token: @token).decode_token
|
||||||
|
else
|
||||||
|
{}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_contact
|
||||||
|
return if @auth_token_params[:source_id].nil?
|
||||||
|
|
||||||
|
contact_inbox = ::ContactInbox.find_by(
|
||||||
|
inbox_id: @web_widget.inbox.id,
|
||||||
|
source_id: @auth_token_params[:source_id]
|
||||||
|
)
|
||||||
|
|
||||||
|
@contact = contact_inbox ? contact_inbox.contact : nil
|
||||||
|
end
|
||||||
|
|
||||||
def build_contact
|
def build_contact
|
||||||
return if @contact.present?
|
return if @contact.present?
|
||||||
|
|
||||||
contact_inbox = @web_widget.create_contact_inbox
|
contact_inbox = @web_widget.create_contact_inbox
|
||||||
@contact = contact_inbox.contact
|
@contact = contact_inbox.contact
|
||||||
|
|
||||||
payload = {
|
payload = { source_id: contact_inbox.source_id, inbox_id: @web_widget.inbox.id }
|
||||||
source_id: contact_inbox.source_id,
|
@token = ::Widget::TokenService.new(payload: payload).generate_token
|
||||||
contact_id: @contact.id,
|
|
||||||
inbox_id: @web_widget.inbox.id
|
|
||||||
}
|
|
||||||
@token = JWT.encode payload, secret_key, 'HS256'
|
|
||||||
end
|
|
||||||
|
|
||||||
def cookie_params
|
|
||||||
return @cookie_params if @cookie_params.present?
|
|
||||||
|
|
||||||
if conversation_token.present?
|
|
||||||
begin
|
|
||||||
@cookie_params = JWT.decode(
|
|
||||||
conversation_token, secret_key, true, algorithm: 'HS256'
|
|
||||||
).first.symbolize_keys
|
|
||||||
rescue StandardError
|
|
||||||
@cookie_params = {}
|
|
||||||
end
|
|
||||||
return @cookie_params
|
|
||||||
end
|
|
||||||
{}
|
|
||||||
end
|
|
||||||
|
|
||||||
def conversation_token
|
|
||||||
permitted_params[:cw_conversation]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def permitted_params
|
def permitted_params
|
||||||
params.permit(:website_token, :cw_conversation)
|
params.permit(:website_token, :cw_conversation)
|
||||||
end
|
end
|
||||||
|
|
||||||
def secret_key
|
|
||||||
Rails.application.secrets.secret_key_base
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class ConversationFinder
|
|||||||
|
|
||||||
find_all_conversations
|
find_all_conversations
|
||||||
filter_by_status
|
filter_by_status
|
||||||
|
filter_by_labels if params[:labels]
|
||||||
|
|
||||||
mine_count, unassigned_count, all_count = set_count_for_all_conversations
|
mine_count, unassigned_count, all_count = set_count_for_all_conversations
|
||||||
|
|
||||||
@@ -62,7 +63,6 @@ class ConversationFinder
|
|||||||
|
|
||||||
def set_assignee_type
|
def set_assignee_type
|
||||||
@assignee_type_id = ASSIGNEE_TYPES[ASSIGNEE_TYPES_BY_ID[params[:assignee_type_id].to_i]]
|
@assignee_type_id = ASSIGNEE_TYPES[ASSIGNEE_TYPES_BY_ID[params[:assignee_type_id].to_i]]
|
||||||
# ente budhiparamaya neekam kandit enthu tonunu? ;)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_all_conversations
|
def find_all_conversations
|
||||||
@@ -86,6 +86,10 @@ class ConversationFinder
|
|||||||
@conversations = @conversations.where(status: params[:status] || DEFAULT_STATUS)
|
@conversations = @conversations.where(status: params[:status] || DEFAULT_STATUS)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def filter_by_labels
|
||||||
|
@conversations = @conversations.tagged_with(params[:labels], any: true)
|
||||||
|
end
|
||||||
|
|
||||||
def set_count_for_all_conversations
|
def set_count_for_all_conversations
|
||||||
[
|
[
|
||||||
@conversations.assigned_to(current_user).count,
|
@conversations.assigned_to(current_user).count,
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
|
/* global axios */
|
||||||
import ApiClient from './ApiClient';
|
import ApiClient from './ApiClient';
|
||||||
|
|
||||||
class ContactAPI extends ApiClient {
|
class ContactAPI extends ApiClient {
|
||||||
constructor() {
|
constructor() {
|
||||||
super('contacts');
|
super('contacts');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getConversations(contactId) {
|
||||||
|
return axios.get(`${this.url}/${contactId}/conversations`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new ContactAPI();
|
export default new ContactAPI();
|
||||||
|
|||||||
18
app/javascript/dashboard/api/conversations.js
Normal file
18
app/javascript/dashboard/api/conversations.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/* global axios */
|
||||||
|
import ApiClient from './ApiClient';
|
||||||
|
|
||||||
|
class ConversationApi extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('conversations');
|
||||||
|
}
|
||||||
|
|
||||||
|
getLabels(conversationID) {
|
||||||
|
return axios.get(`${this.url}/${conversationID}/labels`);
|
||||||
|
}
|
||||||
|
|
||||||
|
createLabels(conversationID) {
|
||||||
|
return axios.get(`${this.url}/${conversationID}/labels`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new ConversationApi();
|
||||||
14
app/javascript/dashboard/api/specs/contacts.spec.js
Normal file
14
app/javascript/dashboard/api/specs/contacts.spec.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import agents from '../contacts';
|
||||||
|
import ApiClient from '../ApiClient';
|
||||||
|
|
||||||
|
describe('#ContactsAPI', () => {
|
||||||
|
it('creates correct instance', () => {
|
||||||
|
expect(agents).toBeInstanceOf(ApiClient);
|
||||||
|
expect(agents).toHaveProperty('get');
|
||||||
|
expect(agents).toHaveProperty('show');
|
||||||
|
expect(agents).toHaveProperty('create');
|
||||||
|
expect(agents).toHaveProperty('update');
|
||||||
|
expect(agents).toHaveProperty('delete');
|
||||||
|
expect(agents).toHaveProperty('getConversations');
|
||||||
|
});
|
||||||
|
});
|
||||||
15
app/javascript/dashboard/api/specs/conversations.spec.js
Normal file
15
app/javascript/dashboard/api/specs/conversations.spec.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import conversations from '../conversations';
|
||||||
|
import ApiClient from '../ApiClient';
|
||||||
|
|
||||||
|
describe('#ConversationApi', () => {
|
||||||
|
it('creates correct instance', () => {
|
||||||
|
expect(conversations).toBeInstanceOf(ApiClient);
|
||||||
|
expect(conversations).toHaveProperty('get');
|
||||||
|
expect(conversations).toHaveProperty('show');
|
||||||
|
expect(conversations).toHaveProperty('create');
|
||||||
|
expect(conversations).toHaveProperty('update');
|
||||||
|
expect(conversations).toHaveProperty('delete');
|
||||||
|
expect(conversations).toHaveProperty('getLabels');
|
||||||
|
expect(conversations).toHaveProperty('createLabels');
|
||||||
|
});
|
||||||
|
});
|
||||||
BIN
app/javascript/dashboard/assets/images/Mask.png
Normal file
BIN
app/javascript/dashboard/assets/images/Mask.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
app/javascript/dashboard/assets/images/chatwoot_bot.png
Normal file
BIN
app/javascript/dashboard/assets/images/chatwoot_bot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
BIN
app/javascript/dashboard/assets/images/twitter-badge.png
Normal file
BIN
app/javascript/dashboard/assets/images/twitter-badge.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -41,6 +41,7 @@
|
|||||||
color: $color-body;
|
color: $color-body;
|
||||||
width: 27rem;
|
width: 27rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
max-width: 96%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.conversation--meta {
|
.conversation--meta {
|
||||||
@@ -91,4 +92,12 @@
|
|||||||
font-weight: $font-weight-medium;
|
font-weight: $font-weight-medium;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.compact {
|
||||||
|
padding-left: 0;
|
||||||
|
|
||||||
|
.conversation--details {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ table {
|
|||||||
tr {
|
tr {
|
||||||
.show-if-hover {
|
.show-if-hover {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: all .2s $ease-in-out-cubic;
|
transition: all 0.2s $ease-in-out-cubic;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
<template>
|
|
||||||
<span class="spinner small"></span>
|
|
||||||
</template>
|
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import Spinner from '../Spinner';
|
import Spinner from 'shared/components/Spinner';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
/* eslint no-console: 0 */
|
/* eslint no-console: 0 */
|
||||||
/* global bus */
|
/* global bus */
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
import Spinner from '../Spinner';
|
import Spinner from 'shared/components/Spinner';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: ['conversationId'],
|
props: ['conversationId'],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/* eslint no-plusplus: 0 */
|
/* eslint no-plusplus: 0 */
|
||||||
/* eslint-env browser */
|
/* eslint-env browser */
|
||||||
|
|
||||||
|
import Spinner from 'shared/components/Spinner';
|
||||||
import Bar from './widgets/chart/BarChart';
|
import Bar from './widgets/chart/BarChart';
|
||||||
import Code from './Code';
|
import Code from './Code';
|
||||||
import LoadingState from './widgets/LoadingState';
|
import LoadingState from './widgets/LoadingState';
|
||||||
@@ -8,7 +9,6 @@ import Modal from './Modal';
|
|||||||
import ModalHeader from './ModalHeader';
|
import ModalHeader from './ModalHeader';
|
||||||
import ReportStatsCard from './widgets/ReportStatsCard';
|
import ReportStatsCard from './widgets/ReportStatsCard';
|
||||||
import SidemenuIcon from './SidemenuIcon';
|
import SidemenuIcon from './SidemenuIcon';
|
||||||
import Spinner from './Spinner';
|
|
||||||
import SubmitButton from './buttons/FormSubmitButton';
|
import SubmitButton from './buttons/FormSubmitButton';
|
||||||
import Tabs from './ui/Tabs/Tabs';
|
import Tabs from './ui/Tabs/Tabs';
|
||||||
import TabsItem from './ui/Tabs/TabsItem';
|
import TabsItem from './ui/Tabs/TabsItem';
|
||||||
|
|||||||
@@ -16,12 +16,24 @@
|
|||||||
:size="avatarSize"
|
:size="avatarSize"
|
||||||
/>
|
/>
|
||||||
<img
|
<img
|
||||||
v-if="badge === 'Channel::FacebookPage'"
|
v-if="badge === 'Channel::FacebookPage' && status !== ''"
|
||||||
id="badge"
|
id="badge"
|
||||||
class="source-badge"
|
class="source-badge"
|
||||||
:style="badgeStyle"
|
:style="badgeStyle"
|
||||||
src="~dashboard/assets/images/fb-badge.png"
|
src="~dashboard/assets/images/fb-badge.png"
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
v-else-if="status === 'online'"
|
||||||
|
class="source-badge user--online"
|
||||||
|
:style="statusStyle"
|
||||||
|
></div>
|
||||||
|
<img
|
||||||
|
v-if="badge === 'Channel::TwitterProfile' && status !== ''"
|
||||||
|
id="badge"
|
||||||
|
class="source-badge"
|
||||||
|
:style="badgeStyle"
|
||||||
|
src="~dashboard/assets/images/twitter-badge.png"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script>
|
<script>
|
||||||
@@ -41,6 +53,7 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
src: {
|
src: {
|
||||||
type: String,
|
type: String,
|
||||||
|
default: '',
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -52,6 +65,11 @@ export default {
|
|||||||
},
|
},
|
||||||
username: {
|
username: {
|
||||||
type: String,
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -67,6 +85,10 @@ export default {
|
|||||||
const badgeSize = `${this.avatarSize / 3}px`;
|
const badgeSize = `${this.avatarSize / 3}px`;
|
||||||
return { width: badgeSize, height: badgeSize };
|
return { width: badgeSize, height: badgeSize };
|
||||||
},
|
},
|
||||||
|
statusStyle() {
|
||||||
|
const statusSize = `${this.avatarSize / 4}px`;
|
||||||
|
return { width: statusSize, height: statusSize };
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
onImgError() {
|
onImgError() {
|
||||||
@@ -78,6 +100,7 @@ export default {
|
|||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@import '~dashboard/assets/scss/variables';
|
@import '~dashboard/assets/scss/variables';
|
||||||
|
@import '~dashboard/assets/scss/foundation-settings';
|
||||||
@import '~dashboard/assets/scss/mixins';
|
@import '~dashboard/assets/scss/mixins';
|
||||||
|
|
||||||
.user-thumbnail-box {
|
.user-thumbnail-box {
|
||||||
@@ -91,11 +114,21 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.source-badge {
|
.source-badge {
|
||||||
bottom: -$space-micro / 2;
|
bottom: -$space-micro;
|
||||||
height: $space-slab;
|
height: $space-slab;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: $zero;
|
right: $zero;
|
||||||
width: $space-slab;
|
width: $space-slab;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user--online {
|
||||||
|
background: $success-color;
|
||||||
|
border-radius: 50%;
|
||||||
|
bottom: $space-micro;
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
content: ' ';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
@click="cardClick(chat)"
|
@click="cardClick(chat)"
|
||||||
>
|
>
|
||||||
<Thumbnail
|
<Thumbnail
|
||||||
|
v-if="!hideThumbnail"
|
||||||
:src="chat.meta.sender.thumbnail"
|
:src="chat.meta.sender.thumbnail"
|
||||||
:badge="chat.meta.sender.channel"
|
:badge="chat.meta.sender.channel"
|
||||||
class="columns"
|
class="columns"
|
||||||
@@ -15,7 +16,7 @@
|
|||||||
<h4 class="conversation--user">
|
<h4 class="conversation--user">
|
||||||
{{ chat.meta.sender.name }}
|
{{ chat.meta.sender.name }}
|
||||||
<span
|
<span
|
||||||
v-if="isInboxNameVisible"
|
v-if="!hideInboxName && isInboxNameVisible"
|
||||||
v-tooltip.bottom="inboxName(chat.inbox_id)"
|
v-tooltip.bottom="inboxName(chat.inbox_id)"
|
||||||
class="label"
|
class="label"
|
||||||
>
|
>
|
||||||
@@ -58,6 +59,14 @@ export default {
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: () => {},
|
default: () => {},
|
||||||
},
|
},
|
||||||
|
hideInboxName: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
hideThumbnail: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
|
|||||||
@@ -74,13 +74,13 @@ export default {
|
|||||||
return this.formatMessage(this.data.content);
|
return this.formatMessage(this.data.content);
|
||||||
},
|
},
|
||||||
alignBubble() {
|
alignBubble() {
|
||||||
return this.data.message_type === 1 ? 'right' : 'left';
|
return !this.data.message_type ? 'left' : 'right';
|
||||||
},
|
},
|
||||||
readableTime() {
|
readableTime() {
|
||||||
return this.messageStamp(this.data.created_at);
|
return this.messageStamp(this.data.created_at);
|
||||||
},
|
},
|
||||||
isBubble() {
|
isBubble() {
|
||||||
return this.data.message_type === 1 || this.data.message_type === 0;
|
return [0, 1, 3].includes(this.data.message_type);
|
||||||
},
|
},
|
||||||
isPrivate() {
|
isPrivate() {
|
||||||
return this.data.private;
|
return this.data.private;
|
||||||
|
|||||||
@@ -3,6 +3,14 @@
|
|||||||
"BROWSER": "Browser",
|
"BROWSER": "Browser",
|
||||||
"OS": "Operating System",
|
"OS": "Operating System",
|
||||||
"INITIATED_FROM": "Initiated from",
|
"INITIATED_FROM": "Initiated from",
|
||||||
"INITIATED_AT": "Initiated at"
|
"INITIATED_AT": "Initiated at",
|
||||||
|
"CONVERSATIONS": {
|
||||||
|
"NO_RECORDS_FOUND": "There are no previous conversations associated to this contact.",
|
||||||
|
"TITLE": "Previous Conversations"
|
||||||
|
},
|
||||||
|
"LABELS": {
|
||||||
|
"NO_RECORDS_FOUND": "There are no labels associated to this conversation.",
|
||||||
|
"TITLE": "Labels"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<template>
|
||||||
|
<div class="contact-conversation--panel">
|
||||||
|
<contact-details-item
|
||||||
|
icon="ion-chatbubbles"
|
||||||
|
:title="$t('CONTACT_PANEL.CONVERSATIONS.TITLE')"
|
||||||
|
/>
|
||||||
|
<div v-if="!uiFlags.isFetching">
|
||||||
|
<i v-if="!previousConversations.length">
|
||||||
|
{{ $t('CONTACT_PANEL.CONVERSATIONS.NO_RECORDS_FOUND') }}
|
||||||
|
</i>
|
||||||
|
<div v-else class="contact-conversation--list">
|
||||||
|
<conversation-card
|
||||||
|
v-for="conversation in previousConversations"
|
||||||
|
:key="conversation.id"
|
||||||
|
:chat="conversation"
|
||||||
|
:hide-inbox-name="true"
|
||||||
|
:hide-thumbnail="true"
|
||||||
|
class="compact"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<spinner v-else></spinner>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import ConversationCard from 'dashboard/components/widgets/conversation/ConversationCard.vue';
|
||||||
|
import { mapGetters } from 'vuex';
|
||||||
|
import Spinner from 'shared/components/Spinner.vue';
|
||||||
|
import ContactDetailsItem from './ContactDetailsItem.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
ConversationCard,
|
||||||
|
ContactDetailsItem,
|
||||||
|
Spinner,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
contactId: {
|
||||||
|
type: [String, Number],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
conversationId: {
|
||||||
|
type: [String, Number],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
conversations() {
|
||||||
|
return this.$store.getters['contactConversations/getContactConversation'](
|
||||||
|
this.contactId
|
||||||
|
);
|
||||||
|
},
|
||||||
|
previousConversations() {
|
||||||
|
return this.conversations.filter(
|
||||||
|
conversation => conversation.id !== Number(this.conversationId)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
...mapGetters({
|
||||||
|
uiFlags: 'contactConversations/getUIFlags',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
contactId(newContactId, prevContactId) {
|
||||||
|
if (newContactId && newContactId !== prevContactId) {
|
||||||
|
this.$store.dispatch('contactConversations/get', newContactId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.$store.dispatch('contactConversations/get', this.contactId);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import '~dashboard/assets/scss/variables';
|
||||||
|
@import '~dashboard/assets/scss/mixins';
|
||||||
|
|
||||||
|
.contact-conversation--panel {
|
||||||
|
@include border-normal-top;
|
||||||
|
padding: $space-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-conversation--list {
|
||||||
|
margin-top: -$space-normal;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<i :class="icon" class="conv-details--item__icon"></i>
|
<i :class="icon" class="conv-details--item__icon"></i>
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</div>
|
</div>
|
||||||
<div class="conv-details--item__value">
|
<div v-if="value" class="conv-details--item__value">
|
||||||
{{ value }}
|
{{ value }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
<div class="contact--profile">
|
<div class="contact--profile">
|
||||||
<div class="contact--info">
|
<div class="contact--info">
|
||||||
<thumbnail
|
<thumbnail
|
||||||
:src="contact.avatar_url"
|
:src="contact.thumbnail"
|
||||||
size="56px"
|
size="56px"
|
||||||
:badge="contact.channel"
|
:badge="contact.channel"
|
||||||
:username="contact.name"
|
:username="contact.name"
|
||||||
|
:status="contact.availability_status"
|
||||||
/>
|
/>
|
||||||
<div class="contact--details">
|
<div class="contact--details">
|
||||||
<div class="contact--name">
|
<div class="contact--name">
|
||||||
@@ -54,16 +55,27 @@
|
|||||||
icon="ion-clock"
|
icon="ion-clock"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<contact-conversations
|
||||||
|
v-if="contact.id"
|
||||||
|
:contact-id="contact.id"
|
||||||
|
:conversation-id="conversationId"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<conversation-labels :conversation-id="conversationId" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||||
|
import ContactConversations from './ContactConversations.vue';
|
||||||
import ContactDetailsItem from './ContactDetailsItem.vue';
|
import ContactDetailsItem from './ContactDetailsItem.vue';
|
||||||
|
import ConversationLabels from './ConversationLabels.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
|
ContactConversations,
|
||||||
ContactDetailsItem,
|
ContactDetailsItem,
|
||||||
|
ConversationLabels,
|
||||||
Thumbnail,
|
Thumbnail,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
@@ -179,7 +191,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.conversation--details {
|
.conversation--details {
|
||||||
padding: $space-normal $space-medium;
|
padding: $space-medium;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<template>
|
||||||
|
<div class="contact-conversation--panel">
|
||||||
|
<contact-details-item
|
||||||
|
icon="ion-pricetags"
|
||||||
|
:title="$t('CONTACT_PANEL.LABELS.TITLE')"
|
||||||
|
/>
|
||||||
|
<div v-if="!uiFlags.isFetching">
|
||||||
|
<i v-if="!labels.length">
|
||||||
|
{{ $t('CONTACT_PANEL.LABELS.NO_RECORDS_FOUND') }}
|
||||||
|
</i>
|
||||||
|
<div v-else class="contact-conversation--list">
|
||||||
|
<span
|
||||||
|
v-for="label in labels"
|
||||||
|
:key="label"
|
||||||
|
class="conversation--label label primary"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<spinner v-else></spinner>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { mapGetters } from 'vuex';
|
||||||
|
import Spinner from 'shared/components/Spinner.vue';
|
||||||
|
import ContactDetailsItem from './ContactDetailsItem.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
ContactDetailsItem,
|
||||||
|
Spinner,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
conversationId: {
|
||||||
|
type: [String, Number],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
labels() {
|
||||||
|
return this.$store.getters['conversationLabels/getConversationLabels'](
|
||||||
|
this.conversationId
|
||||||
|
);
|
||||||
|
},
|
||||||
|
...mapGetters({
|
||||||
|
uiFlags: 'contactConversations/getUIFlags',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
conversationId(newConversationId, prevConversationId) {
|
||||||
|
if (newConversationId && newConversationId !== prevConversationId) {
|
||||||
|
this.$store.dispatch('conversationLabels/get', newConversationId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.$store.dispatch('conversationLabels/get', this.conversationId);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import '~dashboard/assets/scss/variables';
|
||||||
|
@import '~dashboard/assets/scss/mixins';
|
||||||
|
|
||||||
|
.contact-conversation--panel {
|
||||||
|
@include border-normal-top;
|
||||||
|
padding: $space-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-conversation--list {
|
||||||
|
margin-top: -$space-normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation--label {
|
||||||
|
color: $color-white;
|
||||||
|
margin-right: $space-small;
|
||||||
|
font-size: $font-size-small;
|
||||||
|
padding: $space-smaller;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -26,10 +26,11 @@
|
|||||||
<!-- Gravtar Image -->
|
<!-- Gravtar Image -->
|
||||||
<td>
|
<td>
|
||||||
<thumbnail
|
<thumbnail
|
||||||
:src="gravatarUrl(agent.email)"
|
:src="agent.thumbnail"
|
||||||
class="columns"
|
class="columns"
|
||||||
:username="agent.name"
|
:username="agent.name"
|
||||||
size="40px"
|
size="40px"
|
||||||
|
:status="agent.availability_status"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<!-- Agent Name + Email -->
|
<!-- Agent Name + Email -->
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import billing from './modules/billing';
|
|||||||
import cannedResponse from './modules/cannedResponse';
|
import cannedResponse from './modules/cannedResponse';
|
||||||
import Channel from './modules/channels';
|
import Channel from './modules/channels';
|
||||||
import contacts from './modules/contacts';
|
import contacts from './modules/contacts';
|
||||||
|
import contactConversations from './modules/contactConversations';
|
||||||
import conversationMetadata from './modules/conversationMetadata';
|
import conversationMetadata from './modules/conversationMetadata';
|
||||||
|
import conversationLabels from './modules/conversationLabels';
|
||||||
import conversations from './modules/conversations';
|
import conversations from './modules/conversations';
|
||||||
import inboxes from './modules/inboxes';
|
import inboxes from './modules/inboxes';
|
||||||
import inboxMembers from './modules/inboxMembers';
|
import inboxMembers from './modules/inboxMembers';
|
||||||
@@ -22,6 +24,8 @@ export default new Vuex.Store({
|
|||||||
cannedResponse,
|
cannedResponse,
|
||||||
Channel,
|
Channel,
|
||||||
contacts,
|
contacts,
|
||||||
|
contactConversations,
|
||||||
|
conversationLabels,
|
||||||
conversationMetadata,
|
conversationMetadata,
|
||||||
conversations,
|
conversations,
|
||||||
inboxes,
|
inboxes,
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import Vue from 'vue';
|
||||||
|
import * as types from '../mutation-types';
|
||||||
|
import ContactAPI from '../../api/contacts';
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
records: {},
|
||||||
|
uiFlags: {
|
||||||
|
isFetching: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getters = {
|
||||||
|
getUIFlags($state) {
|
||||||
|
return $state.uiFlags;
|
||||||
|
},
|
||||||
|
getContactConversation: $state => id => {
|
||||||
|
return $state.records[Number(id)] || [];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
get: async ({ commit }, contactId) => {
|
||||||
|
commit(types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, {
|
||||||
|
isFetching: true,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const response = await ContactAPI.getConversations(contactId);
|
||||||
|
commit(types.default.SET_CONTACT_CONVERSATIONS, {
|
||||||
|
id: contactId,
|
||||||
|
data: response.data.payload,
|
||||||
|
});
|
||||||
|
commit(types.default.SET_ALL_CONVERSATION, response.data.payload, {
|
||||||
|
root: true,
|
||||||
|
});
|
||||||
|
commit(types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, {
|
||||||
|
isFetching: false,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
commit(types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, {
|
||||||
|
isFetching: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mutations = {
|
||||||
|
[types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG]($state, data) {
|
||||||
|
$state.uiFlags = {
|
||||||
|
...$state.uiFlags,
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[types.default.SET_CONTACT_CONVERSATIONS]: ($state, { id, data }) => {
|
||||||
|
Vue.set($state.records, id, data);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
namespaced: true,
|
||||||
|
state,
|
||||||
|
getters,
|
||||||
|
actions,
|
||||||
|
mutations,
|
||||||
|
};
|
||||||
61
app/javascript/dashboard/store/modules/conversationLabels.js
Normal file
61
app/javascript/dashboard/store/modules/conversationLabels.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import Vue from 'vue';
|
||||||
|
import * as types from '../mutation-types';
|
||||||
|
import ConversationAPI from '../../api/conversations';
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
records: {},
|
||||||
|
uiFlags: {
|
||||||
|
isFetching: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getters = {
|
||||||
|
getUIFlags($state) {
|
||||||
|
return $state.uiFlags;
|
||||||
|
},
|
||||||
|
getConversationLabels: $state => id => {
|
||||||
|
return $state.records[Number(id)] || [];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
get: async ({ commit }, conversationId) => {
|
||||||
|
commit(types.default.SET_CONVERSATION_LABELS_UI_FLAG, {
|
||||||
|
isFetching: true,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const response = await ConversationAPI.getLabels(conversationId);
|
||||||
|
commit(types.default.SET_CONVERSATION_LABELS, {
|
||||||
|
id: conversationId,
|
||||||
|
data: response.data.payload,
|
||||||
|
});
|
||||||
|
commit(types.default.SET_CONVERSATION_LABELS_UI_FLAG, {
|
||||||
|
isFetching: false,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
commit(types.default.SET_CONVERSATION_LABELS_UI_FLAG, {
|
||||||
|
isFetching: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mutations = {
|
||||||
|
[types.default.SET_CONVERSATION_LABELS_UI_FLAG]($state, data) {
|
||||||
|
$state.uiFlags = {
|
||||||
|
...$state.uiFlags,
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[types.default.SET_CONVERSATION_LABELS]: ($state, { id, data }) => {
|
||||||
|
Vue.set($state.records, id, data);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
namespaced: true,
|
||||||
|
state,
|
||||||
|
getters,
|
||||||
|
actions,
|
||||||
|
mutations,
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { actions } from '../../contactConversations';
|
||||||
|
import * as types from '../../../mutation-types';
|
||||||
|
import conversationList from './fixtures';
|
||||||
|
|
||||||
|
const commit = jest.fn();
|
||||||
|
global.axios = axios;
|
||||||
|
jest.mock('axios');
|
||||||
|
|
||||||
|
describe('#actions', () => {
|
||||||
|
describe('#get', () => {
|
||||||
|
it('sends correct actions if API is success', async () => {
|
||||||
|
axios.get.mockResolvedValue({ data: { payload: conversationList } });
|
||||||
|
await actions.get({ commit }, 1);
|
||||||
|
expect(commit.mock.calls).toEqual([
|
||||||
|
[types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, { isFetching: true }],
|
||||||
|
|
||||||
|
[
|
||||||
|
types.default.SET_CONTACT_CONVERSATIONS,
|
||||||
|
{ id: 1, data: conversationList },
|
||||||
|
],
|
||||||
|
[types.default.SET_ALL_CONVERSATION, conversationList, { root: true }],
|
||||||
|
[
|
||||||
|
types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG,
|
||||||
|
{ isFetching: false },
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it('sends correct actions if API is error', async () => {
|
||||||
|
axios.get.mockRejectedValue({ message: 'Incorrect header' });
|
||||||
|
await actions.get({ commit });
|
||||||
|
expect(commit.mock.calls).toEqual([
|
||||||
|
[types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, { isFetching: true }],
|
||||||
|
[
|
||||||
|
types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG,
|
||||||
|
{ isFetching: false },
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
export default [
|
||||||
|
{
|
||||||
|
meta: {
|
||||||
|
sender: {
|
||||||
|
id: 1,
|
||||||
|
name: 'sender1',
|
||||||
|
thumbnail: '',
|
||||||
|
channel: 'Channel::WebWidget',
|
||||||
|
},
|
||||||
|
assignee: null,
|
||||||
|
},
|
||||||
|
id: 1,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
content: 'Hello',
|
||||||
|
account_id: 1,
|
||||||
|
inbox_id: 1,
|
||||||
|
conversation_id: 1,
|
||||||
|
message_type: 1,
|
||||||
|
created_at: 1578555084,
|
||||||
|
updated_at: '2020-01-09T07:31:24.419Z',
|
||||||
|
private: false,
|
||||||
|
user_id: 1,
|
||||||
|
status: 'sent',
|
||||||
|
fb_id: null,
|
||||||
|
content_type: 'text',
|
||||||
|
content_attributes: {},
|
||||||
|
sender: {
|
||||||
|
name: 'Sender 1',
|
||||||
|
avatar_url: 'random_url',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
inbox_id: 1,
|
||||||
|
status: 0,
|
||||||
|
timestamp: 1578555084,
|
||||||
|
user_last_seen_at: 0,
|
||||||
|
agent_last_seen_at: 1578555084,
|
||||||
|
unread_count: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
meta: {
|
||||||
|
sender: {
|
||||||
|
id: 2,
|
||||||
|
name: 'sender1',
|
||||||
|
thumbnail: '',
|
||||||
|
channel: 'Channel::WebWidget',
|
||||||
|
},
|
||||||
|
assignee: null,
|
||||||
|
},
|
||||||
|
id: 2,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
content: 'Hello',
|
||||||
|
account_id: 1,
|
||||||
|
inbox_id: 2,
|
||||||
|
conversation_id: 2,
|
||||||
|
message_type: 1,
|
||||||
|
created_at: 1578555084,
|
||||||
|
updated_at: '2020-01-09T07:31:24.419Z',
|
||||||
|
private: false,
|
||||||
|
user_id: 2,
|
||||||
|
status: 'sent',
|
||||||
|
fb_id: null,
|
||||||
|
content_type: 'text',
|
||||||
|
content_attributes: {},
|
||||||
|
sender: {
|
||||||
|
name: 'Sender 1',
|
||||||
|
avatar_url: 'random_url',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
inbox_id: 2,
|
||||||
|
status: 0,
|
||||||
|
timestamp: 1578555084,
|
||||||
|
user_last_seen_at: 0,
|
||||||
|
agent_last_seen_at: 1578555084,
|
||||||
|
unread_count: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { getters } from '../../contactConversations';
|
||||||
|
|
||||||
|
describe('#getters', () => {
|
||||||
|
it('getContactConversation', () => {
|
||||||
|
const state = {
|
||||||
|
records: { 1: [{ id: 1, contact_id: 1, message: 'Hello' }] },
|
||||||
|
};
|
||||||
|
expect(getters.getContactConversation(state)(1)).toEqual([
|
||||||
|
{ id: 1, contact_id: 1, message: 'Hello' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getUIFlags', () => {
|
||||||
|
const state = {
|
||||||
|
uiFlags: {
|
||||||
|
isFetching: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(getters.getUIFlags(state)).toEqual({
|
||||||
|
isFetching: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import * as types from '../../../mutation-types';
|
||||||
|
import { mutations } from '../../contactConversations';
|
||||||
|
|
||||||
|
describe('#mutations', () => {
|
||||||
|
describe('#SET_CONTACT_CONVERSATIONS_UI_FLAG', () => {
|
||||||
|
it('set ui flags', () => {
|
||||||
|
const state = { uiFlags: { isFetching: true } };
|
||||||
|
mutations[types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG](state, {
|
||||||
|
isFetching: false,
|
||||||
|
});
|
||||||
|
expect(state.uiFlags).toEqual({
|
||||||
|
isFetching: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#SET_CONTACT_CONVERSATIONS', () => {
|
||||||
|
it('set contact conversation records', () => {
|
||||||
|
const state = { records: {} };
|
||||||
|
mutations[types.default.SET_CONTACT_CONVERSATIONS](state, {
|
||||||
|
id: 1,
|
||||||
|
data: [{ id: 1, contact_id: 1, message: 'hello' }],
|
||||||
|
});
|
||||||
|
expect(state.records).toEqual({
|
||||||
|
1: [{ id: 1, contact_id: 1, message: 'hello' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { actions } from '../../conversationLabels';
|
||||||
|
import * as types from '../../../mutation-types';
|
||||||
|
|
||||||
|
const commit = jest.fn();
|
||||||
|
global.axios = axios;
|
||||||
|
jest.mock('axios');
|
||||||
|
|
||||||
|
describe('#actions', () => {
|
||||||
|
describe('#get', () => {
|
||||||
|
it('sends correct actions if API is success', async () => {
|
||||||
|
axios.get.mockResolvedValue({
|
||||||
|
data: { payload: ['customer-success', 'on-hold'] },
|
||||||
|
});
|
||||||
|
await actions.get({ commit }, 1);
|
||||||
|
expect(commit.mock.calls).toEqual([
|
||||||
|
[types.default.SET_CONVERSATION_LABELS_UI_FLAG, { isFetching: true }],
|
||||||
|
|
||||||
|
[
|
||||||
|
types.default.SET_CONVERSATION_LABELS,
|
||||||
|
{ id: 1, data: ['customer-success', 'on-hold'] },
|
||||||
|
],
|
||||||
|
[types.default.SET_CONVERSATION_LABELS_UI_FLAG, { isFetching: false }],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it('sends correct actions if API is error', async () => {
|
||||||
|
axios.get.mockRejectedValue({ message: 'Incorrect header' });
|
||||||
|
await actions.get({ commit });
|
||||||
|
expect(commit.mock.calls).toEqual([
|
||||||
|
[types.default.SET_CONVERSATION_LABELS_UI_FLAG, { isFetching: true }],
|
||||||
|
[types.default.SET_CONVERSATION_LABELS_UI_FLAG, { isFetching: false }],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { getters } from '../../conversationLabels';
|
||||||
|
|
||||||
|
describe('#getters', () => {
|
||||||
|
it('getConversationLabels', () => {
|
||||||
|
const state = {
|
||||||
|
records: { 1: ['customer-success', 'on-hold'] },
|
||||||
|
};
|
||||||
|
expect(getters.getConversationLabels(state)(1)).toEqual([
|
||||||
|
'customer-success',
|
||||||
|
'on-hold',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getUIFlags', () => {
|
||||||
|
const state = {
|
||||||
|
uiFlags: {
|
||||||
|
isFetching: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(getters.getUIFlags(state)).toEqual({
|
||||||
|
isFetching: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import * as types from '../../../mutation-types';
|
||||||
|
import { mutations } from '../../conversationLabels';
|
||||||
|
|
||||||
|
describe('#mutations', () => {
|
||||||
|
describe('#SET_CONVERSATION_LABELS_UI_FLAG', () => {
|
||||||
|
it('set ui flags', () => {
|
||||||
|
const state = { uiFlags: { isFetching: true } };
|
||||||
|
mutations[types.default.SET_CONVERSATION_LABELS_UI_FLAG](state, {
|
||||||
|
isFetching: false,
|
||||||
|
});
|
||||||
|
expect(state.uiFlags).toEqual({
|
||||||
|
isFetching: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#SET_CONVERSATION_LABELS', () => {
|
||||||
|
it('set contact conversation records', () => {
|
||||||
|
const state = { records: {} };
|
||||||
|
mutations[types.default.SET_CONVERSATION_LABELS](state, {
|
||||||
|
id: 1,
|
||||||
|
data: ['customer-success', 'on-hold'],
|
||||||
|
});
|
||||||
|
expect(state.records).toEqual({
|
||||||
|
1: ['customer-success', 'on-hold'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -62,6 +62,14 @@ export default {
|
|||||||
SET_CONTACTS: 'SET_CONTACTS',
|
SET_CONTACTS: 'SET_CONTACTS',
|
||||||
EDIT_CONTACT: 'EDIT_CONTACT',
|
EDIT_CONTACT: 'EDIT_CONTACT',
|
||||||
|
|
||||||
|
// Contact Conversation
|
||||||
|
SET_CONTACT_CONVERSATIONS_UI_FLAG: 'SET_CONTACT_CONVERSATIONS_UI_FLAG',
|
||||||
|
SET_CONTACT_CONVERSATIONS: 'SET_CONTACT_CONVERSATIONS',
|
||||||
|
|
||||||
|
// Conversation Label
|
||||||
|
SET_CONVERSATION_LABELS_UI_FLAG: 'SET_CONVERSATION_LABELS_UI_FLAG',
|
||||||
|
SET_CONVERSATION_LABELS: 'SET_CONVERSATION_LABELS',
|
||||||
|
|
||||||
// Reports
|
// Reports
|
||||||
SET_ACCOUNT_REPORTS: 'SET_ACCOUNT_REPORTS',
|
SET_ACCOUNT_REPORTS: 'SET_ACCOUNT_REPORTS',
|
||||||
SET_ACCOUNT_SUMMARY: 'SET_ACCOUNT_SUMMARY',
|
SET_ACCOUNT_SUMMARY: 'SET_ACCOUNT_SUMMARY',
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import { SDK_CSS } from '../widget/assets/scss/sdk';
|
|||||||
/* eslint-disable no-param-reassign */
|
/* eslint-disable no-param-reassign */
|
||||||
const bubbleImg =
|
const bubbleImg =
|
||||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAMAAABg3Am1AAAAUVBMVEUAAAD///////////////////////////////////////////////////////////////////////////////////////////////////////8IN+deAAAAGnRSTlMAAwgJEBk0TVheY2R5eo+ut8jb5OXs8fX2+cjRDTIAAADsSURBVHgBldZbkoMgFIThRgQv8SKKgGf/C51UnJqaRI30/9zfe+NQUQ3TvG7bOk9DVeCmshmj/CuOTYnrdBfkUOg0zlOtl9OWVuEk4+QyZ3DIevmSt/ioTvK1VH/s5bY3YdM9SBZ/mUUyWgx+U06ycgp7D8msxSvtc4HXL9BLdj2elSEfhBJAI0QNgJEBI1BEBsQClVBVGDgwYOLAhJkDM1YOrNg4sLFAsLJgZsHEgoEFFQt0JAFGFjQsKAMJ0LFAexKgZYFyJIDxJIBNJEDNAtSJBLCeBDCOBFAPzwFA94ED+zmhwDO9358r8ANtIsMXi7qVAwAAAABJRU5ErkJggg==';
|
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAMAAABg3Am1AAAAUVBMVEUAAAD///////////////////////////////////////////////////////////////////////////////////////////////////////8IN+deAAAAGnRSTlMAAwgJEBk0TVheY2R5eo+ut8jb5OXs8fX2+cjRDTIAAADsSURBVHgBldZbkoMgFIThRgQv8SKKgGf/C51UnJqaRI30/9zfe+NQUQ3TvG7bOk9DVeCmshmj/CuOTYnrdBfkUOg0zlOtl9OWVuEk4+QyZ3DIevmSt/ioTvK1VH/s5bY3YdM9SBZ/mUUyWgx+U06ycgp7D8msxSvtc4HXL9BLdj2elSEfhBJAI0QNgJEBI1BEBsQClVBVGDgwYOLAhJkDM1YOrNg4sLFAsLJgZsHEgoEFFQt0JAFGFjQsKAMJ0LFAexKgZYFyJIDxJIBNJEDNAtSJBLCeBDCOBFAPzwFA94ED+zmhwDO9358r8ANtIsMXi7qVAwAAAABJRU5ErkJggg==';
|
||||||
const closeImg =
|
|
||||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAMAAACdt4HsAAAAP1BMVEUAAAD///////////////////////////////////////////////////////////////////////////////9Du/pqAAAAFHRSTlMACBstLi8wMVB+mcbT2err7O3w8n+sjtQAAAEuSURBVHgBtNLdcoMgGITh1SCGH9DId//X2mnTg7hYxj0oh8w8r+MqgDnmlsIE6UwhtRxnAHge9n2KV7wvP+h4AvPbm73W+359/aJjRjQTCuTNIrJJBfKW0UwqkLeGZJ8Ff2O/T28JwZQCewuYilJgX6buavdDv188br1RIE+jc2H5yy+9VwrXXij0nsflwth7YFRw7N3Y88BcYL+z7wubO/lt6AcFwQMLF9irP8r2eF8/ei8VLrxUkDzguMDejX03WK3dsGJB9lxgrxd0T8PTRxUL5OUCealQz76KXg/or/CvI36VXgcEAAAgCMP6t16IZVDg3zPuI+0rb5g2zlsoW2lbqlvrOyw7bTuuO+8LGIs4C1mLeQuai7oL2437LRytPC1drX0tnq2+Ld+r/wDPIIIJkfdlbQAAAABJRU5ErkJggg==';
|
|
||||||
|
|
||||||
const body = document.getElementsByTagName('body')[0];
|
const body = document.getElementsByTagName('body')[0];
|
||||||
const holder = document.createElement('div');
|
const holder = document.createElement('div');
|
||||||
@@ -120,7 +118,7 @@ const IFrameHelper = {
|
|||||||
createFrame: ({ baseUrl, websiteToken }) => {
|
createFrame: ({ baseUrl, websiteToken }) => {
|
||||||
const iframe = document.createElement('iframe');
|
const iframe = document.createElement('iframe');
|
||||||
const cwCookie = Cookies.get('cw_conversation');
|
const cwCookie = Cookies.get('cw_conversation');
|
||||||
let widgetUrl = `${baseUrl}/widgets?website_token=${websiteToken}`;
|
let widgetUrl = `${baseUrl}/widget?website_token=${websiteToken}`;
|
||||||
if (cwCookie) {
|
if (cwCookie) {
|
||||||
widgetUrl = `${widgetUrl}&cw_conversation=${cwCookie}`;
|
widgetUrl = `${widgetUrl}&cw_conversation=${cwCookie}`;
|
||||||
}
|
}
|
||||||
@@ -142,6 +140,20 @@ const IFrameHelper = {
|
|||||||
'*'
|
'*'
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
events: {
|
||||||
|
loaded: message => {
|
||||||
|
Cookies.set('cw_conversation', message.config.authToken);
|
||||||
|
IFrameHelper.sendMessage('config-set', {});
|
||||||
|
IFrameHelper.onLoad(message.config.channelConfig);
|
||||||
|
IFrameHelper.setCurrentUrl();
|
||||||
|
},
|
||||||
|
set_auth_token: message => {
|
||||||
|
Cookies.set('cw_conversation', message.authToken);
|
||||||
|
},
|
||||||
|
toggleBubble: () => {
|
||||||
|
bubbleClickCallback();
|
||||||
|
},
|
||||||
|
},
|
||||||
initPostMessageCommunication: () => {
|
initPostMessageCommunication: () => {
|
||||||
window.onmessage = e => {
|
window.onmessage = e => {
|
||||||
if (
|
if (
|
||||||
@@ -151,11 +163,8 @@ const IFrameHelper = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const message = JSON.parse(e.data.replace('chatwoot-widget:', ''));
|
const message = JSON.parse(e.data.replace('chatwoot-widget:', ''));
|
||||||
if (message.event === 'loaded') {
|
if (typeof IFrameHelper.events[message.event] === 'function') {
|
||||||
Cookies.set('cw_conversation', message.config.authToken);
|
IFrameHelper.events[message.event](message);
|
||||||
IFrameHelper.sendMessage('config-set', {});
|
|
||||||
IFrameHelper.onLoad(message.config.channelConfig);
|
|
||||||
IFrameHelper.setCurrentUrl();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -180,11 +189,8 @@ const IFrameHelper = {
|
|||||||
target: chatBubble,
|
target: chatBubble,
|
||||||
});
|
});
|
||||||
|
|
||||||
const closeIcon = createBubbleIcon({
|
const closeIcon = closeBubble;
|
||||||
className: 'woot-widget-bubble woot--close woot--hide',
|
closeIcon.className = 'woot-widget-bubble woot--close woot--hide';
|
||||||
src: closeImg,
|
|
||||||
target: closeBubble,
|
|
||||||
});
|
|
||||||
|
|
||||||
chatIcon.style.background = widgetColor;
|
chatIcon.style.background = widgetColor;
|
||||||
closeIcon.style.background = widgetColor;
|
closeIcon.style.background = widgetColor;
|
||||||
@@ -195,7 +201,6 @@ const IFrameHelper = {
|
|||||||
onClickChatBubble();
|
onClickChatBubble();
|
||||||
},
|
},
|
||||||
setCurrentUrl: () => {
|
setCurrentUrl: () => {
|
||||||
console.log(IFrameHelper.getAppFrame(), document);
|
|
||||||
IFrameHelper.sendMessage('set-current-url', {
|
IFrameHelper.sendMessage('set-current-url', {
|
||||||
refererURL: window.location.href,
|
refererURL: window.location.href,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import Vue from 'vue';
|
import Vue from 'vue';
|
||||||
|
import Vuelidate from 'vuelidate';
|
||||||
import store from '../widget/store';
|
import store from '../widget/store';
|
||||||
import App from '../widget/App.vue';
|
import App from '../widget/App.vue';
|
||||||
import router from '../widget/router';
|
import router from '../widget/router';
|
||||||
import ActionCableConnector from '../widget/helpers/actionCable';
|
import ActionCableConnector from '../widget/helpers/actionCable';
|
||||||
|
|
||||||
|
Vue.use(Vuelidate);
|
||||||
|
|
||||||
Vue.config.productionTip = false;
|
Vue.config.productionTip = false;
|
||||||
window.onload = () => {
|
window.onload = () => {
|
||||||
window.WOOT_WIDGET = new Vue({
|
window.WOOT_WIDGET = new Vue({
|
||||||
|
|||||||
@@ -6,17 +6,8 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { mapActions } from 'vuex';
|
import { mapActions } from 'vuex';
|
||||||
import { setHeader } from './helpers/axios';
|
import { setHeader } from 'widget/helpers/axios';
|
||||||
|
import { IFrameHelper } from 'widget/helpers/utils';
|
||||||
export const IFrameHelper = {
|
|
||||||
isIFrame: () => window.self !== window.top,
|
|
||||||
sendMessage: msg => {
|
|
||||||
window.parent.postMessage(
|
|
||||||
`chatwoot-widget:${JSON.stringify({ ...msg })}`,
|
|
||||||
'*'
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'App',
|
name: 'App',
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import authEndPoint from 'widget/api/endPoints';
|
|
||||||
import { API } from 'widget/helpers/axios';
|
|
||||||
|
|
||||||
const createContact = async (inboxId, accountId) => {
|
|
||||||
const urlData = authEndPoint.createContact(inboxId, accountId);
|
|
||||||
const result = await API.post(urlData.url, urlData.params);
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default {
|
|
||||||
createContact,
|
|
||||||
};
|
|
||||||
10
app/javascript/widget/api/contact.js
Executable file
10
app/javascript/widget/api/contact.js
Executable file
@@ -0,0 +1,10 @@
|
|||||||
|
import authEndPoint from 'widget/api/endPoints';
|
||||||
|
import { API } from 'widget/helpers/axios';
|
||||||
|
|
||||||
|
export const updateContact = async ({ messageId, email }) => {
|
||||||
|
const urlData = authEndPoint.updateContact(messageId);
|
||||||
|
const result = await API.patch(urlData.url, {
|
||||||
|
contact: { email },
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
};
|
||||||
@@ -14,7 +14,12 @@ const getConversation = ({ before }) => ({
|
|||||||
params: { before },
|
params: { before },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateContact = id => ({
|
||||||
|
url: `/api/v1/widget/messages/${id}${window.location.search}`,
|
||||||
|
});
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
sendMessage,
|
sendMessage,
|
||||||
getConversation,
|
getConversation,
|
||||||
|
updateContact,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ $input-height: $space-two * 2;
|
|||||||
|
|
||||||
appearance: none;
|
appearance: none;
|
||||||
background: $color-white;
|
background: $color-white;
|
||||||
|
border: 1px solid $color-border;
|
||||||
border-radius: $border-radius;
|
border-radius: $border-radius;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
color: $color-body;
|
color: $color-body;
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ $shadow-color-2: rgba(0, 0, 0, 0.07);
|
|||||||
$shadow-color-3: rgba(50, 50, 93, .08);
|
$shadow-color-3: rgba(50, 50, 93, .08);
|
||||||
$shadow-color-4: rgba(0, 0, 0, .05);
|
$shadow-color-4: rgba(0, 0, 0, .05);
|
||||||
|
|
||||||
|
$color-shadow-medium: rgba(0, 0, 0, 0.1);
|
||||||
|
$color-shadow-light: rgba(0, 0, 0, 0.06);
|
||||||
|
$color-shadow-large: rgba(0, 0, 0, 0.25);
|
||||||
|
$color-shadow-outline: rgba(66, 153, 225, 0.5);
|
||||||
|
|
||||||
@mixin normal-shadow {
|
@mixin normal-shadow {
|
||||||
box-shadow: 0 $space-small $space-normal $shadow-color-1, 0 $space-smaller $space-slab $shadow-color-2;
|
box-shadow: 0 $space-small $space-normal $shadow-color-1, 0 $space-smaller $space-slab $shadow-color-2;
|
||||||
}
|
}
|
||||||
@@ -13,8 +18,59 @@ $shadow-color-4: rgba(0, 0, 0, .05);
|
|||||||
}
|
}
|
||||||
|
|
||||||
@mixin placeholder {
|
@mixin placeholder {
|
||||||
&::-webkit-input-placeholder {@content}
|
&::-webkit-input-placeholder {
|
||||||
&:-moz-placeholder {@content}
|
@content
|
||||||
&::-moz-placeholder {@content}
|
}
|
||||||
&:-ms-input-placeholder {@content}
|
|
||||||
|
&:-moz-placeholder {
|
||||||
|
@content
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-moz-placeholder {
|
||||||
|
@content
|
||||||
|
}
|
||||||
|
|
||||||
|
&:-ms-input-placeholder {
|
||||||
|
@content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin shadow {
|
||||||
|
box-shadow: 0 1px 10px -4 $color-shadow-medium,
|
||||||
|
0 1px 5px 2px $color-shadow-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@mixin shadow-medium {
|
||||||
|
box-shadow: 0 4px 6px -8px $color-shadow-medium,
|
||||||
|
0 2px 4px -4px $color-shadow-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@mixin shadow-large {
|
||||||
|
box-shadow: 0 10px 15px -16px $color-shadow-medium,
|
||||||
|
0 4px 6px -8px $color-shadow-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@mixin shadow-big {
|
||||||
|
box-shadow: 0 20px 25px -20px $color-shadow-medium,
|
||||||
|
0 10px 10px -10px $color-shadow-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@mixin shadow-mega {
|
||||||
|
box-shadow: 0 25px 50px -12px $color-shadow-big;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin shadow-inner {
|
||||||
|
box-shadow: inset 0 2px 4px 0 $color-shadow-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin shadow-outline {
|
||||||
|
box-shadow: 0 0 0 3px $color-shadow-outline;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin shadow-none {
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ $color-background-light: #fafafa;
|
|||||||
$color-white: #fff;
|
$color-white: #fff;
|
||||||
$color-body: #3c4858;
|
$color-body: #3c4858;
|
||||||
$color-heading: #1f2d3d;
|
$color-heading: #1f2d3d;
|
||||||
|
$color-error: #ff4949;
|
||||||
|
|
||||||
|
|
||||||
// Thumbnail
|
// Thumbnail
|
||||||
$thumbnail-radius: 4rem;
|
$thumbnail-radius: 4rem;
|
||||||
|
|
||||||
@@ -88,4 +91,20 @@ $line-height: 1;
|
|||||||
$footer-height: 11.2rem;
|
$footer-height: 11.2rem;
|
||||||
$header-expanded-height: $space-medium * 10;
|
$header-expanded-height: $space-medium * 10;
|
||||||
|
|
||||||
$font-family: 'Inter', -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
$font-family: 'Inter',
|
||||||
|
-apple-system,
|
||||||
|
system-ui,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
"Segoe UI",
|
||||||
|
Roboto,
|
||||||
|
"Helvetica Neue",
|
||||||
|
Arial,
|
||||||
|
sans-serif;
|
||||||
|
$ionicons-font-path: '~ionicons/fonts';
|
||||||
|
|
||||||
|
$spinkit-spinner-color: $color-white !default;
|
||||||
|
$spinkit-spinner-margin: 0 0 0 1.6rem !default;
|
||||||
|
$spinkit-size: 1.6rem !default;
|
||||||
|
|
||||||
|
// Break points
|
||||||
|
$break-point-medium: 667px;
|
||||||
|
|||||||
@@ -1,44 +1,47 @@
|
|||||||
export const SDK_CSS = `
|
export const SDK_CSS = ` .woot-widget-holder {
|
||||||
.woot-widget-holder {
|
z-index: 2147483000 !important;
|
||||||
z-index: 2147483000!important;
|
position: fixed !important;
|
||||||
position: fixed!important;
|
-moz-box-shadow: 0 5px 40px rgba(0, 0, 0, .16) !important;
|
||||||
-moz-box-shadow: 0 5px 40px rgba(0,0,0,.16)!important;
|
-o-box-shadow: 0 5px 40px rgba(0, 0, 0, .16) !important;
|
||||||
-o-box-shadow: 0 5px 40px rgba(0,0,0,.16)!important;
|
-webkit-box-shadow: 0 5px 40px rgba(0, 0, 0, .16) !important;
|
||||||
-webkit-box-shadow: 0 5px 40px rgba(0,0,0,.16)!important;
|
box-shadow: 0 5px 40px rgba(0, 0, 0, .16) !important;
|
||||||
box-shadow: 0 5px 40px rgba(0,0,0,.16)!important;
|
overflow: hidden !important;
|
||||||
overflow: hidden!important;
|
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transition-property: opacity, bottom;
|
transition-property: opacity, bottom;
|
||||||
transition-duration: 0.5s, 0.5s;
|
transition-duration: 0.5s, 0.5s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.woot-widget-holder iframe { width: 100% !important; height: 100% !important; border: 0; }
|
.woot-widget-holder iframe {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.woot-widget-bubble {
|
.woot-widget-bubble {
|
||||||
z-index: 2147483000!important;
|
z-index: 2147483000 !important;
|
||||||
-moz-box-shadow: 0 8px 24px rgba(0,0,0,.16)!important;
|
-moz-box-shadow: 0 8px 24px rgba(0, 0, 0, .16) !important;
|
||||||
-o-box-shadow: 0 8px 24px rgba(0,0,0,.16)!important;
|
-o-box-shadow: 0 8px 24px rgba(0, 0, 0, .16) !important;
|
||||||
-webkit-box-shadow: 0 8px 24px rgba(0,0,0,.16)!important;
|
-webkit-box-shadow: 0 8px 24px rgba(0, 0, 0, .16) !important;
|
||||||
box-shadow: 0 8px 24px rgba(0,0,0,.16)!important;
|
box-shadow: 0 8px 24px rgba(0, 0, 0, .16) !important;
|
||||||
-o-border-radius: 100px!important;
|
-o-border-radius: 100px !important;
|
||||||
-moz-border-radius: 100px!important;
|
-moz-border-radius: 100px !important;
|
||||||
-webkit-border-radius: 100px!important;
|
-webkit-border-radius: 100px !important;
|
||||||
border-radius: 100px!important;
|
border-radius: 100px !important;
|
||||||
background: #1f93ff;
|
background: #1f93ff;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
right: 20px;
|
right: 20px;
|
||||||
bottom: 20px;
|
bottom: 20px;
|
||||||
width: 64px!important;
|
width: 64px !important;
|
||||||
height: 64px!important;
|
height: 64px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.woot-widget-bubble:hover {
|
.woot-widget-bubble:hover {
|
||||||
background: #1f93ff;
|
background: #1f93ff;
|
||||||
-moz-box-shadow: 0 8px 32px rgba(0,0,0,.4)!important;
|
-moz-box-shadow: 0 8px 32px rgba(0, 0, 0, .4) !important;
|
||||||
-o-box-shadow: 0 8px 32px rgba(0,0,0,.4)!important;
|
-o-box-shadow: 0 8px 32px rgba(0, 0, 0, .4) !important;
|
||||||
-webkit-box-shadow: 0 8px 32px rgba(0,0,0,.4)!important;
|
-webkit-box-shadow: 0 8px 32px rgba(0, 0, 0, .4) !important;
|
||||||
box-shadow: 0 8px 32px rgba(0,0,0,.4)!important;
|
box-shadow: 0 8px 32px rgba(0, 0, 0, .4) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.woot-widget-bubble img {
|
.woot-widget-bubble img {
|
||||||
@@ -47,15 +50,29 @@ export const SDK_CSS = `
|
|||||||
margin: 20px;
|
margin: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.woot-widget-bubble.woot--close img {
|
.woot--close:hover {
|
||||||
width: 16px;
|
opacity: 1;
|
||||||
height: 16px;
|
|
||||||
margin: 24px;
|
|
||||||
}
|
}
|
||||||
|
.woot--close:before, .woot--close:after {
|
||||||
|
position: absolute;
|
||||||
|
left: 32px;
|
||||||
|
top: 20px;
|
||||||
|
content: ' ';
|
||||||
|
height: 24px;
|
||||||
|
width: 2px;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
.woot--close:before {
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
.woot--close:after {
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.woot--hide {
|
.woot--hide {
|
||||||
visibility: hidden !important;
|
visibility: hidden !important;
|
||||||
z-index: -1!important;
|
z-index: -1 !important;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
bottom: 60px;
|
bottom: 60px;
|
||||||
}
|
}
|
||||||
@@ -69,12 +86,10 @@ export const SDK_CSS = `
|
|||||||
}
|
}
|
||||||
|
|
||||||
.woot-widget-bubble.woot--close {
|
.woot-widget-bubble.woot--close {
|
||||||
top: 0;
|
visibility: hidden !important;
|
||||||
right: 0;
|
z-index: -1 !important;
|
||||||
box-shadow: none !important;
|
opacity: 0;
|
||||||
-moz-box-shadow: none !important;
|
bottom: 60px;
|
||||||
-o-box-shadow: none !important;
|
|
||||||
-webkit-box-shadow: none !important;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,13 +98,13 @@ export const SDK_CSS = `
|
|||||||
bottom: 104px;
|
bottom: 104px;
|
||||||
right: 20px;
|
right: 20px;
|
||||||
height: calc(85% - 64px - 20px);
|
height: calc(85% - 64px - 20px);
|
||||||
width: 370px!important;
|
width: 400px !important;
|
||||||
min-height: 250px!important;
|
min-height: 250px !important;
|
||||||
max-height: 590px!important;
|
max-height: 590px !important;
|
||||||
-o-border-radius: 8px!important;
|
-o-border-radius: 8px !important;
|
||||||
-moz-border-radius: 8px!important;
|
-moz-border-radius: 8px !important;
|
||||||
-webkit-border-radius: 8px!important;
|
-webkit-border-radius: 8px !important;
|
||||||
border-radius: 8px!important;
|
border-radius: 8px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
@import 'mixins';
|
@import 'mixins';
|
||||||
@import 'forms';
|
@import 'forms';
|
||||||
@import 'shared/assets/fonts/inter';
|
@import 'shared/assets/fonts/inter';
|
||||||
|
@import '~ionicons/scss/ionicons';
|
||||||
|
@import '~spinkit/scss/spinners/7-three-bounce';
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
|
|||||||
@@ -9,7 +9,13 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="message-wrap">
|
<div class="message-wrap">
|
||||||
<AgentMessageBubble :message="message" />
|
<AgentMessageBubble
|
||||||
|
:content-type="contentType"
|
||||||
|
:message-content-attributes="messageContentAttributes"
|
||||||
|
:message-id="messageId"
|
||||||
|
:message-type="messageType"
|
||||||
|
:message="message"
|
||||||
|
/>
|
||||||
<p v-if="showAvatar" class="agent-name">
|
<p v-if="showAvatar" class="agent-name">
|
||||||
{{ agentName }}
|
{{ agentName }}
|
||||||
</p>
|
</p>
|
||||||
@@ -32,7 +38,22 @@ export default {
|
|||||||
avatarUrl: String,
|
avatarUrl: String,
|
||||||
agentName: String,
|
agentName: String,
|
||||||
showAvatar: Boolean,
|
showAvatar: Boolean,
|
||||||
createdAt: Number,
|
contentType: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
messageContentAttributes: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {},
|
||||||
|
},
|
||||||
|
messageType: {
|
||||||
|
type: Number,
|
||||||
|
default: 1,
|
||||||
|
},
|
||||||
|
messageId: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@@ -50,7 +71,7 @@ export default {
|
|||||||
max-width: 88%;
|
max-width: 88%;
|
||||||
|
|
||||||
& + .agent-message {
|
& + .agent-message {
|
||||||
margin-bottom: $space-smaller;
|
margin-bottom: $space-micro;
|
||||||
|
|
||||||
.chat-bubble {
|
.chat-bubble {
|
||||||
border-top-left-radius: $space-smaller;
|
border-top-left-radius: $space-smaller;
|
||||||
@@ -80,10 +101,10 @@ export default {
|
|||||||
|
|
||||||
.agent-name {
|
.agent-name {
|
||||||
color: $color-body;
|
color: $color-body;
|
||||||
font-size: $font-size-default;
|
font-size: $font-size-small;
|
||||||
font-weight: $font-weight-medium;
|
font-weight: $font-weight-medium;
|
||||||
margin-bottom: $space-small;
|
margin: $space-small 0;
|
||||||
margin-top: $space-small;
|
padding-left: $space-micro;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,20 +1,42 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="chat-bubble agent" v-html="formatMessage(message)"></div>
|
<div class="chat-bubble agent">
|
||||||
|
<span v-html="formatMessage(message)"></span>
|
||||||
|
<email-input
|
||||||
|
v-if="shouldShowInput"
|
||||||
|
:message-id="messageId"
|
||||||
|
:message-content-attributes="messageContentAttributes"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
||||||
|
import EmailInput from './template/EmailInput';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'AgentMessageBubble',
|
name: 'AgentMessageBubble',
|
||||||
|
components: {
|
||||||
|
EmailInput,
|
||||||
|
},
|
||||||
mixins: [messageFormatterMixin],
|
mixins: [messageFormatterMixin],
|
||||||
props: {
|
props: {
|
||||||
message: String,
|
message: String,
|
||||||
|
contentType: String,
|
||||||
|
messageType: Number,
|
||||||
|
messageId: Number,
|
||||||
|
messageContentAttributes: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
shouldShowInput() {
|
||||||
|
return this.contentType === 'input_email' && this.messageType === 3;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@import '~widget/assets/scss/variables.scss';
|
@import '~widget/assets/scss/variables.scss';
|
||||||
|
|
||||||
|
|||||||
@@ -16,22 +16,26 @@
|
|||||||
|
|
||||||
.branding {
|
.branding {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: $color-gray;
|
color: $color-light-gray;
|
||||||
|
opacity: 0.9;
|
||||||
display: flex;
|
display: flex;
|
||||||
filter: grayscale(1);
|
filter: grayscale(1);
|
||||||
font-size: $font-size-default;
|
font-size: $font-size-small;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: $space-one;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
padding: $space-normal 0 $space-slab;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
filter: grayscale(0);
|
filter: grayscale(0);
|
||||||
|
opacity: 1;
|
||||||
|
color: $color-gray;
|
||||||
}
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
margin-right: $space-small;
|
margin-right: $space-smaller;
|
||||||
max-width: $space-two;
|
max-width: $space-slab;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -12,8 +12,14 @@ export default {
|
|||||||
ChatInputWrap,
|
ChatInputWrap,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
msg: String,
|
msg: {
|
||||||
onSendMessage: Function,
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
onSendMessage: {
|
||||||
|
type: Function,
|
||||||
|
default: () => {},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@@ -21,14 +27,15 @@ export default {
|
|||||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@import '~widget/assets/scss/variables.scss';
|
@import '~widget/assets/scss/variables.scss';
|
||||||
|
@import '~widget/assets/scss/mixins.scss';
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
background: $color-white;
|
background: $color-white;
|
||||||
box-shadow: 0 -$space-micro 3px rgba(50, 50, 93, 0.04),
|
|
||||||
0 -1px 2px rgba(0, 0, 0, 0.03);
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: $space-small;
|
padding: $space-small $space-slab;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
border-radius: 7px;
|
||||||
|
@include shadow-big;
|
||||||
}
|
}
|
||||||
|
|
||||||
.branding {
|
.branding {
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<header class="header-collapsed" :style="{ background: widgetColor }">
|
<header class="header-collapsed">
|
||||||
<div>
|
<h2 class="title">
|
||||||
<h2 class="title">
|
{{ title }}
|
||||||
{{ title }}
|
</h2>
|
||||||
</h2>
|
<span class="close" @click="closeWindow"></span>
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
|
import { IFrameHelper } from 'widget/helpers/utils';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'ChatHeader',
|
name: 'ChatHeader',
|
||||||
@@ -24,22 +24,60 @@ export default {
|
|||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
methods: {
|
||||||
|
closeWindow() {
|
||||||
|
if (IFrameHelper.isIFrame()) {
|
||||||
|
IFrameHelper.sendMessage({
|
||||||
|
event: 'toggleBubble',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@import '~widget/assets/scss/variables.scss';
|
@import '~widget/assets/scss/variables.scss';
|
||||||
|
@import '~widget/assets/scss/mixins.scss';
|
||||||
|
|
||||||
.header-collapsed {
|
.header-collapsed {
|
||||||
background: $color-woot;
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: $color-white;
|
||||||
padding: $space-two $space-medium;
|
padding: $space-two $space-medium;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
color: $color-white;
|
color: $color-white;
|
||||||
|
border-bottom-left-radius: $space-small;
|
||||||
|
border-bottom-right-radius: $space-small;
|
||||||
|
@include shadow-large;
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: $font-size-big;
|
font-size: $font-size-large;
|
||||||
font-weight: $font-weight-medium;
|
font-weight: $font-weight-medium;
|
||||||
|
color: $color-heading;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
position: relative;
|
||||||
|
margin-right: $space-small;
|
||||||
|
|
||||||
|
&:before,
|
||||||
|
&:after {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: $space-smaller;
|
||||||
|
content: ' ';
|
||||||
|
height: $space-normal;
|
||||||
|
width: 2px;
|
||||||
|
background-color: $color-heading;
|
||||||
|
}
|
||||||
|
&:before {
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
&:after {
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<header class="header-expanded" :style="{ background: widgetColor }">
|
<header class="header-expanded">
|
||||||
<div>
|
<div>
|
||||||
|
<!-- <img
|
||||||
|
class="logo"
|
||||||
|
src="http://www.hennigcompany.com/wp-content/uploads/2014/06/starbucks-logo.png"
|
||||||
|
/> -->
|
||||||
<h2 class="title">
|
<h2 class="title">
|
||||||
{{ introHeading }}
|
{{ introHeading }}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -37,23 +41,37 @@ export default {
|
|||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@import '~widget/assets/scss/variables.scss';
|
@import '~widget/assets/scss/variables.scss';
|
||||||
|
@import '~widget/assets/scss/mixins.scss';
|
||||||
|
|
||||||
.header-expanded {
|
.header-expanded {
|
||||||
background: $color-woot;
|
background: $color-white;
|
||||||
padding: $space-large;
|
padding: $space-larger $space-medium $space-large;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
color: $color-white;
|
border-radius: $space-normal;
|
||||||
|
@include shadow-large;
|
||||||
|
|
||||||
|
@media only screen and (min-device-width: 320px) and (max-device-width: 480px) {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
|
color: $color-heading;
|
||||||
font-size: $font-size-mega;
|
font-size: $font-size-mega;
|
||||||
font-weight: $font-weight-medium;
|
font-weight: $font-weight-normal;
|
||||||
margin-bottom: $space-two;
|
margin-bottom: $space-slab;
|
||||||
|
margin-top: $space-large;
|
||||||
}
|
}
|
||||||
|
|
||||||
.body {
|
.body {
|
||||||
font-size: $font-size-medium;
|
color: $color-body;
|
||||||
line-height: 1.5;
|
font-size: 1.8rem;
|
||||||
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<textarea
|
<resizable-textarea>
|
||||||
class="form-input user-message-input"
|
<textarea
|
||||||
:placeholder="placeholder"
|
class="form-input user-message-input"
|
||||||
:value="value"
|
:placeholder="placeholder"
|
||||||
@input="$emit('input', $event.target.value)"
|
:value="value"
|
||||||
/>
|
@input="$emit('input', $event.target.value)"
|
||||||
|
/>
|
||||||
|
</resizable-textarea>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import ResizableTextarea from 'widget/components/ResizableTextarea.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
components: {
|
||||||
|
ResizableTextarea,
|
||||||
|
},
|
||||||
props: {
|
props: {
|
||||||
placeholder: String,
|
placeholder: String,
|
||||||
value: String,
|
value: String,
|
||||||
@@ -24,5 +31,6 @@ export default {
|
|||||||
border: 0;
|
border: 0;
|
||||||
height: $space-large;
|
height: $space-large;
|
||||||
resize: none;
|
resize: none;
|
||||||
|
padding-top: $space-small;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -4,11 +4,13 @@
|
|||||||
<ChatSendButton
|
<ChatSendButton
|
||||||
:on-click="handleButtonClick"
|
:on-click="handleButtonClick"
|
||||||
:disabled="!userInput.length"
|
:disabled="!userInput.length"
|
||||||
|
:color="widgetColor"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { mapGetters } from 'vuex';
|
||||||
import ChatSendButton from 'widget/components/ChatSendButton.vue';
|
import ChatSendButton from 'widget/components/ChatSendButton.vue';
|
||||||
import ChatInputArea from 'widget/components/ChatInputArea.vue';
|
import ChatInputArea from 'widget/components/ChatInputArea.vue';
|
||||||
|
|
||||||
@@ -42,6 +44,11 @@ export default {
|
|||||||
document.addEventListener('keypress', this.handleEnterKeyPress);
|
document.addEventListener('keypress', this.handleEnterKeyPress);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
...mapGetters({
|
||||||
|
widgetColor: 'appConfig/getWidgetColor',
|
||||||
|
}),
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
handleButtonClick() {
|
handleButtonClick() {
|
||||||
if (this.userInput && this.userInput.trim()) {
|
if (this.userInput && this.userInput.trim()) {
|
||||||
|
|||||||
@@ -7,9 +7,13 @@
|
|||||||
<AgentMessage
|
<AgentMessage
|
||||||
v-else
|
v-else
|
||||||
:agent-name="agentName"
|
:agent-name="agentName"
|
||||||
|
:avatar-url="avatarUrl"
|
||||||
|
:content-type="message.content_type"
|
||||||
|
:message-content-attributes="message.content_attributes"
|
||||||
|
:message-id="message.id"
|
||||||
|
:message-type="message.message_type"
|
||||||
:message="message.content"
|
:message="message.content"
|
||||||
:show-avatar="message.showAvatar"
|
:show-avatar="message.showAvatar"
|
||||||
:avatar-url="avatarUrl"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -32,9 +36,18 @@ export default {
|
|||||||
return this.message.message_type === MESSAGE_TYPE.INCOMING;
|
return this.message.message_type === MESSAGE_TYPE.INCOMING;
|
||||||
},
|
},
|
||||||
agentName() {
|
agentName() {
|
||||||
|
if (this.message.message_type === MESSAGE_TYPE.TEMPLATE) {
|
||||||
|
return 'Bot';
|
||||||
|
}
|
||||||
|
|
||||||
return this.message.sender ? this.message.sender.name : '';
|
return this.message.sender ? this.message.sender.name : '';
|
||||||
},
|
},
|
||||||
avatarUrl() {
|
avatarUrl() {
|
||||||
|
if (this.message.message_type === MESSAGE_TYPE.TEMPLATE) {
|
||||||
|
// eslint-disable-next-line
|
||||||
|
return require('dashboard/assets/images/chatwoot_bot.png');
|
||||||
|
}
|
||||||
|
|
||||||
return this.message.sender ? this.message.sender.avatar_url : '';
|
return this.message.sender ? this.message.sender.avatar_url : '';
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,9 +5,11 @@
|
|||||||
class="send-button"
|
class="send-button"
|
||||||
@click="onClick"
|
@click="onClick"
|
||||||
>
|
>
|
||||||
<span v-if="!loading" class="icon-holder">
|
<span
|
||||||
<img src="~widget/assets/images/message-send.svg" />
|
v-if="!loading"
|
||||||
</span>
|
:style="`background-color: ${color}`"
|
||||||
|
class="icon-holder"
|
||||||
|
></span>
|
||||||
<spinner v-else size="small" />
|
<spinner v-else size="small" />
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
@@ -32,6 +34,10 @@ export default {
|
|||||||
type: Function,
|
type: Function,
|
||||||
default: () => {},
|
default: () => {},
|
||||||
},
|
},
|
||||||
|
color: {
|
||||||
|
type: String,
|
||||||
|
default: '#6e6f73',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@@ -45,13 +51,19 @@ export default {
|
|||||||
border: 0;
|
border: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
padding-right: $space-smaller;
|
||||||
|
|
||||||
.icon-holder {
|
.icon-holder {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
fill: $color-white;
|
fill: $color-white;
|
||||||
|
font-size: $font-size-big;
|
||||||
font-weight: $font-weight-medium;
|
font-weight: $font-weight-medium;
|
||||||
|
width: $space-two;
|
||||||
|
height: $space-two;
|
||||||
|
-webkit-mask-image: url("data:image/svg+xml;charset=utf8,%3Csvg width='20' height='20' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M18.34 7.32l-14-7a3 3 0 00-4.08 3.9l2.4 5.37c.11.262.11.558 0 .82l-2.4 5.37A3 3 0 003 20a3.14 3.14 0 001.35-.32l14-7a3 3 0 000-5.36h-.01zm-.89 3.57l-14 7a1 1 0 01-1.35-1.3l2.39-5.37a2 2 0 00.08-.22h6.89a1 1 0 000-2H4.57a2 2 0 00-.08-.22L2.1 3.41a1 1 0 011.35-1.3l14 7a1 1 0 010 1.78z' fill='%23999A9B' fill-rule='nonzero'/%3E%3C/svg%3E");
|
||||||
|
mask-image: url("data:image/svg+xml;charset=utf8,%3Csvg width='20' height='20' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M18.34 7.32l-14-7a3 3 0 00-4.08 3.9l2.4 5.37c.11.262.11.558 0 .82l-2.4 5.37A3 3 0 003 20a3.14 3.14 0 001.35-.32l14-7a3 3 0 000-5.36h-.01zm-.89 3.57l-14 7a1 1 0 01-1.35-1.3l2.39-5.37a2 2 0 00.08-.22h6.89a1 1 0 000-2H4.57a2 2 0 00-.08-.22L2.1 3.41a1 1 0 011.35-1.3l14 7a1 1 0 010 1.78z' fill='%23999A9B' fill-rule='nonzero'/%3E%3C/svg%3E");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -13,12 +13,10 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<branding></branding>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import Branding from 'widget/components/Branding.vue';
|
|
||||||
import ChatMessage from 'widget/components/ChatMessage.vue';
|
import ChatMessage from 'widget/components/ChatMessage.vue';
|
||||||
import DateSeparator from 'shared/components/DateSeparator.vue';
|
import DateSeparator from 'shared/components/DateSeparator.vue';
|
||||||
import Spinner from 'shared/components/Spinner.vue';
|
import Spinner from 'shared/components/Spinner.vue';
|
||||||
@@ -27,7 +25,6 @@ import { mapActions, mapGetters } from 'vuex';
|
|||||||
export default {
|
export default {
|
||||||
name: 'ConversationWrap',
|
name: 'ConversationWrap',
|
||||||
components: {
|
components: {
|
||||||
Branding,
|
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
DateSeparator,
|
DateSeparator,
|
||||||
Spinner,
|
Spinner,
|
||||||
|
|||||||
26
app/javascript/widget/components/ResizableTextarea.vue
Normal file
26
app/javascript/widget/components/ResizableTextarea.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
mounted() {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.$el.setAttribute(
|
||||||
|
'style',
|
||||||
|
`height: ${this.$el.scrollHeight}px;overflow-y:hidden;`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.$el.addEventListener('input', this.resizeTextarea);
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.$el.removeEventListener('input', this.resizeTextarea);
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
resizeTextarea(event) {
|
||||||
|
event.target.style.height = '3.2rem';
|
||||||
|
event.target.style.height = `${event.target.scrollHeight}px`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
render() {
|
||||||
|
return this.$slots.default[0];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -46,6 +46,7 @@ export default {
|
|||||||
}
|
}
|
||||||
& + .agent-message {
|
& + .agent-message {
|
||||||
margin-top: $space-normal;
|
margin-top: $space-normal;
|
||||||
|
margin-bottom: $space-micro;
|
||||||
}
|
}
|
||||||
.message-wrap {
|
.message-wrap {
|
||||||
margin-right: $space-small;
|
margin-right: $space-small;
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default {
|
|||||||
font-size: $font-size-default;
|
font-size: $font-size-default;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
max-width: 80%;
|
max-width: 80%;
|
||||||
padding: $space-small $space-two;
|
padding: $space-small $space-normal;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|
||||||
a {
|
a {
|
||||||
|
|||||||
115
app/javascript/widget/components/template/EmailInput.vue
Normal file
115
app/javascript/widget/components/template/EmailInput.vue
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<form
|
||||||
|
v-if="!hasSubmitted"
|
||||||
|
class="email-input-group"
|
||||||
|
@submit.prevent="onSubmit()"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model.trim="email"
|
||||||
|
class="form-input small"
|
||||||
|
placeholder="Please enter your email"
|
||||||
|
:class="{ error: $v.email.$error }"
|
||||||
|
@input="$v.email.$touch"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="button"
|
||||||
|
:disabled="$v.email.$invalid"
|
||||||
|
:style="{ background: widgetColor, borderColor: widgetColor }"
|
||||||
|
>
|
||||||
|
<i v-if="!uiFlags.isUpdating" class="ion-ios-arrow-forward" />
|
||||||
|
<spinner v-else />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<span v-else>
|
||||||
|
<i>{{ messageContentAttributes.submitted_email }}</i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { mapGetters } from 'vuex';
|
||||||
|
import Spinner from 'shared/components/Spinner';
|
||||||
|
import { required, email } from 'vuelidate/lib/validators';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
Spinner,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
messageId: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
messageContentAttributes: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
email: '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapGetters({
|
||||||
|
uiFlags: 'contact/getUIFlags',
|
||||||
|
widgetColor: 'appConfig/getWidgetColor',
|
||||||
|
}),
|
||||||
|
hasSubmitted() {
|
||||||
|
return (
|
||||||
|
this.messageContentAttributes &&
|
||||||
|
this.messageContentAttributes.submitted_email
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
validations: {
|
||||||
|
email: {
|
||||||
|
required,
|
||||||
|
email,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onSubmit() {
|
||||||
|
this.$store.dispatch('contact/updateContactAttributes', {
|
||||||
|
email: this.email,
|
||||||
|
messageId: this.messageId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import '~widget/assets/scss/variables.scss';
|
||||||
|
|
||||||
|
.email-input-group {
|
||||||
|
display: flex;
|
||||||
|
margin: $space-small 0;
|
||||||
|
min-width: 200px;
|
||||||
|
|
||||||
|
input {
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
border-color: $color-error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
font-size: $font-size-large;
|
||||||
|
height: auto;
|
||||||
|
margin-left: -1px;
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: block;
|
||||||
|
padding: 0;
|
||||||
|
height: auto;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -9,4 +9,6 @@ export const MESSAGE_STATUS = {
|
|||||||
export const MESSAGE_TYPE = {
|
export const MESSAGE_TYPE = {
|
||||||
INCOMING: 0,
|
INCOMING: 0,
|
||||||
OUTGOING: 1,
|
OUTGOING: 1,
|
||||||
|
ACTIVITY: 2,
|
||||||
|
TEMPLATE: 3,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,3 +8,13 @@ export const arrayToHashById = array =>
|
|||||||
newMap[obj.id] = obj;
|
newMap[obj.id] = obj;
|
||||||
return newMap;
|
return newMap;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
|
export const IFrameHelper = {
|
||||||
|
isIFrame: () => window.self !== window.top,
|
||||||
|
sendMessage: msg => {
|
||||||
|
window.parent.postMessage(
|
||||||
|
`chatwoot-widget:${JSON.stringify({ ...msg })}`,
|
||||||
|
'*'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import Vue from 'vue';
|
import Vue from 'vue';
|
||||||
import Vuex from 'vuex';
|
import Vuex from 'vuex';
|
||||||
import conversation from 'widget/store/modules/conversation';
|
|
||||||
import appConfig from 'widget/store/modules/appConfig';
|
import appConfig from 'widget/store/modules/appConfig';
|
||||||
|
import contact from 'widget/store/modules/contact';
|
||||||
|
import conversation from 'widget/store/modules/conversation';
|
||||||
|
|
||||||
Vue.use(Vuex);
|
Vue.use(Vuex);
|
||||||
|
|
||||||
export default new Vuex.Store({
|
export default new Vuex.Store({
|
||||||
modules: {
|
modules: {
|
||||||
appConfig,
|
appConfig,
|
||||||
|
contact,
|
||||||
conversation,
|
conversation,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
45
app/javascript/widget/store/modules/contact.js
Normal file
45
app/javascript/widget/store/modules/contact.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { updateContact } from 'widget/api/contact';
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
uiFlags: {
|
||||||
|
isUpdating: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const getters = {
|
||||||
|
getUIFlags: $state => $state.uiFlags,
|
||||||
|
};
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
updateContactAttributes: async ({ commit }, { email, messageId }) => {
|
||||||
|
commit('toggleUpdateStatus', true);
|
||||||
|
try {
|
||||||
|
await updateContact({ email, messageId });
|
||||||
|
commit(
|
||||||
|
'conversation/updateMessage',
|
||||||
|
{
|
||||||
|
id: messageId,
|
||||||
|
content_attributes: { submitted_email: email },
|
||||||
|
},
|
||||||
|
{ root: true }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore error
|
||||||
|
}
|
||||||
|
commit('toggleUpdateStatus', false);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mutations = {
|
||||||
|
toggleUpdateStatus($state, status) {
|
||||||
|
$state.uiFlags.isUpdating = status;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
namespaced: true,
|
||||||
|
state,
|
||||||
|
getters,
|
||||||
|
actions,
|
||||||
|
mutations,
|
||||||
|
};
|
||||||
@@ -49,7 +49,6 @@ export const getters = {
|
|||||||
Object.values(_state.conversations),
|
Object.values(_state.conversations),
|
||||||
message => new DateHelper(message.created_at).format()
|
message => new DateHelper(message.created_at).format()
|
||||||
);
|
);
|
||||||
|
|
||||||
return Object.keys(conversationGroupedByDate).map(date => {
|
return Object.keys(conversationGroupedByDate).map(date => {
|
||||||
const messages = conversationGroupedByDate[date].map((message, index) => {
|
const messages = conversationGroupedByDate[date].map((message, index) => {
|
||||||
let showAvatar = false;
|
let showAvatar = false;
|
||||||
@@ -59,12 +58,11 @@ export const getters = {
|
|||||||
const nextMessage = conversationGroupedByDate[date][index + 1];
|
const nextMessage = conversationGroupedByDate[date][index + 1];
|
||||||
const currentSender = message.sender ? message.sender.name : '';
|
const currentSender = message.sender ? message.sender.name : '';
|
||||||
const nextSender = nextMessage.sender ? nextMessage.sender.name : '';
|
const nextSender = nextMessage.sender ? nextMessage.sender.name : '';
|
||||||
showAvatar = currentSender !== nextSender;
|
showAvatar =
|
||||||
|
currentSender !== nextSender ||
|
||||||
|
message.message_type !== nextMessage.message_type;
|
||||||
}
|
}
|
||||||
return {
|
return { showAvatar, ...message };
|
||||||
showAvatar,
|
|
||||||
...message,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -135,6 +133,13 @@ export const mutations = {
|
|||||||
|
|
||||||
payload.map(message => Vue.set($state.conversations, message.id, message));
|
payload.map(message => Vue.set($state.conversations, message.id, message));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateMessage($state, { id, content_attributes }) {
|
||||||
|
$state.conversations[id] = {
|
||||||
|
...$state.conversations[id],
|
||||||
|
content_attributes,
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|||||||
@@ -61,19 +61,27 @@ describe('#getters', () => {
|
|||||||
id: 1,
|
id: 1,
|
||||||
content: 'Thanks for the help',
|
content: 'Thanks for the help',
|
||||||
created_at: 1574075964,
|
created_at: 1574075964,
|
||||||
|
message_type: 0,
|
||||||
},
|
},
|
||||||
2: {
|
2: {
|
||||||
id: 2,
|
id: 2,
|
||||||
content: 'Yes, It makes sense',
|
content: 'Yes, It makes sense',
|
||||||
created_at: 1574092218,
|
created_at: 1574092218,
|
||||||
|
message_type: 0,
|
||||||
},
|
},
|
||||||
3: {
|
3: {
|
||||||
id: 3,
|
id: 3,
|
||||||
content: 'Hey',
|
content: 'Hey',
|
||||||
created_at: 1576340623,
|
created_at: 1574092218,
|
||||||
|
message_type: 1,
|
||||||
},
|
},
|
||||||
4: {
|
4: {
|
||||||
id: 4,
|
id: 4,
|
||||||
|
content: 'Hey',
|
||||||
|
created_at: 1576340623,
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
id: 5,
|
||||||
content: 'How may I help you',
|
content: 'How may I help you',
|
||||||
created_at: 1576340626,
|
created_at: 1576340626,
|
||||||
},
|
},
|
||||||
@@ -88,12 +96,21 @@ describe('#getters', () => {
|
|||||||
content: 'Thanks for the help',
|
content: 'Thanks for the help',
|
||||||
created_at: 1574075964,
|
created_at: 1574075964,
|
||||||
showAvatar: false,
|
showAvatar: false,
|
||||||
|
message_type: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
content: 'Yes, It makes sense',
|
content: 'Yes, It makes sense',
|
||||||
created_at: 1574092218,
|
created_at: 1574092218,
|
||||||
showAvatar: true,
|
showAvatar: true,
|
||||||
|
message_type: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
content: 'Hey',
|
||||||
|
created_at: 1574092218,
|
||||||
|
showAvatar: true,
|
||||||
|
message_type: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -101,13 +118,13 @@ describe('#getters', () => {
|
|||||||
date: 'Dec 14, 2019',
|
date: 'Dec 14, 2019',
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 4,
|
||||||
content: 'Hey',
|
content: 'Hey',
|
||||||
created_at: 1576340623,
|
created_at: 1576340623,
|
||||||
showAvatar: false,
|
showAvatar: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 4,
|
id: 5,
|
||||||
content: 'How may I help you',
|
content: 'How may I help you',
|
||||||
created_at: 1576340626,
|
created_at: 1576340626,
|
||||||
showAvatar: true,
|
showAvatar: true,
|
||||||
|
|||||||
@@ -6,7 +6,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<ConversationWrap :grouped-messages="groupedMessages" />
|
<ConversationWrap :grouped-messages="groupedMessages" />
|
||||||
<div class="footer-wrap">
|
<div class="footer-wrap">
|
||||||
<ChatFooter :on-send-message="handleSendMessage" />
|
<div class="input-wrap">
|
||||||
|
<ChatFooter :on-send-message="handleSendMessage" />
|
||||||
|
</div>
|
||||||
|
<branding></branding>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -14,6 +17,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { mapActions, mapGetters } from 'vuex';
|
import { mapActions, mapGetters } from 'vuex';
|
||||||
|
|
||||||
|
import Branding from 'widget/components/Branding.vue';
|
||||||
import ChatFooter from 'widget/components/ChatFooter.vue';
|
import ChatFooter from 'widget/components/ChatFooter.vue';
|
||||||
import ChatHeaderExpanded from 'widget/components/ChatHeaderExpanded.vue';
|
import ChatHeaderExpanded from 'widget/components/ChatHeaderExpanded.vue';
|
||||||
import ChatHeader from 'widget/components/ChatHeader.vue';
|
import ChatHeader from 'widget/components/ChatHeader.vue';
|
||||||
@@ -26,6 +30,7 @@ export default {
|
|||||||
ChatHeaderExpanded,
|
ChatHeaderExpanded,
|
||||||
ConversationWrap,
|
ConversationWrap,
|
||||||
ChatHeader,
|
ChatHeader,
|
||||||
|
Branding,
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions('conversation', ['sendMessage']),
|
...mapActions('conversation', ['sendMessage']),
|
||||||
@@ -67,6 +72,29 @@ export default {
|
|||||||
|
|
||||||
.footer-wrap {
|
.footer-wrap {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -$space-normal;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: $space-normal;
|
||||||
|
opacity: 0.1;
|
||||||
|
background: linear-gradient(
|
||||||
|
to top,
|
||||||
|
$color-background,
|
||||||
|
rgba($color-background, 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrap {
|
||||||
|
padding: 0 $space-medium;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
9
app/jobs/action_cable_broadcast_job.rb
Normal file
9
app/jobs/action_cable_broadcast_job.rb
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
class ActionCableBroadcastJob < ApplicationJob
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
def perform(members, event_name, data)
|
||||||
|
members.each do |member|
|
||||||
|
ActionCable.server.broadcast(member, event: event_name, data: data)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -45,9 +45,7 @@ class ActionCableListener < BaseListener
|
|||||||
def send_to_members(members, event_name, data)
|
def send_to_members(members, event_name, data)
|
||||||
return if members.blank?
|
return if members.blank?
|
||||||
|
|
||||||
members.each do |member|
|
::ActionCableBroadcastJob.perform_later(members, event_name, data)
|
||||||
ActionCable.server.broadcast(member, event: event_name, data: data)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def send_to_contact(contact, event_name, message)
|
def send_to_contact(contact, event_name, message)
|
||||||
@@ -55,7 +53,7 @@ class ActionCableListener < BaseListener
|
|||||||
return if message.activity?
|
return if message.activity?
|
||||||
return if contact.nil?
|
return if contact.nil?
|
||||||
|
|
||||||
ActionCable.server.broadcast(contact.pubsub_token, event: event_name, data: message.push_event_data)
|
::ActionCableBroadcastJob.perform_later([contact.pubsub_token], event_name, message.push_event_data)
|
||||||
end
|
end
|
||||||
|
|
||||||
def push(pubsub_token, data)
|
def push(pubsub_token, data)
|
||||||
|
|||||||
@@ -25,10 +25,10 @@ class ReportingListener < BaseListener
|
|||||||
|
|
||||||
def message_created(event)
|
def message_created(event)
|
||||||
message, account, timestamp = extract_message_and_account(event)
|
message, account, timestamp = extract_message_and_account(event)
|
||||||
if message.outgoing?
|
|
||||||
::Reports::UpdateAccountIdentity.new(account, timestamp).incr_outgoing_messages_count
|
return unless message.reportable?
|
||||||
else
|
|
||||||
::Reports::UpdateAccountIdentity.new(account, timestamp).incr_incoming_messages_count
|
identity = ::Reports::UpdateAccountIdentity.new(account, timestamp)
|
||||||
end
|
message.outgoing? ? identity.incr_outgoing_messages_count : identity.incr_incoming_messages_count
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,4 +4,8 @@ class ApplicationMailer < ActionMailer::Base
|
|||||||
|
|
||||||
# helpers
|
# helpers
|
||||||
helper :frontend_urls
|
helper :frontend_urls
|
||||||
|
|
||||||
|
def smtp_config_set_or_development?
|
||||||
|
ENV.fetch('SMTP_ADDRESS', nil).present? || Rails.env.development?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ class AssignmentMailer < ApplicationMailer
|
|||||||
layout 'mailer'
|
layout 'mailer'
|
||||||
|
|
||||||
def conversation_assigned(conversation, agent)
|
def conversation_assigned(conversation, agent)
|
||||||
return if ENV.fetch('SMTP_ADDRESS', nil).blank?
|
return unless smtp_config_set_or_development?
|
||||||
|
|
||||||
@agent = agent
|
@agent = agent
|
||||||
@conversation = conversation
|
@conversation = conversation
|
||||||
|
|||||||
26
app/mailers/conversation_mailer.rb
Normal file
26
app/mailers/conversation_mailer.rb
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
class ConversationMailer < ApplicationMailer
|
||||||
|
default from: ENV.fetch('MAILER_SENDER_EMAIL', 'accounts@chatwoot.com')
|
||||||
|
layout 'mailer'
|
||||||
|
|
||||||
|
def new_message(conversation, message_queued_time)
|
||||||
|
return unless smtp_config_set_or_development?
|
||||||
|
|
||||||
|
@conversation = conversation
|
||||||
|
@contact = @conversation.contact
|
||||||
|
@agent = @conversation.assignee
|
||||||
|
|
||||||
|
recap_messages = @conversation.messages.where('created_at < ?', message_queued_time).order(created_at: :asc).last(10)
|
||||||
|
new_messages = @conversation.messages.where('created_at >= ?', message_queued_time)
|
||||||
|
|
||||||
|
@messages = recap_messages + new_messages
|
||||||
|
@messages = @messages.select(&:reportable?)
|
||||||
|
|
||||||
|
mail(to: @contact&.email, reply_to: @agent&.email, subject: mail_subject(@messages.last))
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def mail_subject(last_message, trim_length = 30)
|
||||||
|
"[##{@conversation.display_id}] #{last_message.content.truncate(trim_length)}"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -8,7 +8,6 @@
|
|||||||
# extension :string
|
# extension :string
|
||||||
# external_url :string
|
# external_url :string
|
||||||
# fallback_title :string
|
# fallback_title :string
|
||||||
# file :string
|
|
||||||
# file_type :integer default("image")
|
# file_type :integer default("image")
|
||||||
# created_at :datetime not null
|
# created_at :datetime not null
|
||||||
# updated_at :datetime not null
|
# updated_at :datetime not null
|
||||||
@@ -19,12 +18,12 @@
|
|||||||
require 'uri'
|
require 'uri'
|
||||||
require 'open-uri'
|
require 'open-uri'
|
||||||
class Attachment < ApplicationRecord
|
class Attachment < ApplicationRecord
|
||||||
|
include Rails.application.routes.url_helpers
|
||||||
belongs_to :account
|
belongs_to :account
|
||||||
belongs_to :message
|
belongs_to :message
|
||||||
mount_uploader :file, AttachmentUploader # used for images
|
has_one_attached :file
|
||||||
enum file_type: [:image, :audio, :video, :file, :location, :fallback]
|
|
||||||
|
|
||||||
before_create :set_file_extension
|
enum file_type: [:image, :audio, :video, :file, :location, :fallback]
|
||||||
|
|
||||||
def push_event_data
|
def push_event_data
|
||||||
return base_data.merge(location_metadata) if file_type.to_sym == :location
|
return base_data.merge(location_metadata) if file_type.to_sym == :location
|
||||||
@@ -68,13 +67,7 @@ class Attachment < ApplicationRecord
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_file_extension
|
def file_url
|
||||||
if external_url && !fallback?
|
file.attached? ? url_for(file) : ''
|
||||||
self.extension = begin
|
|
||||||
Pathname.new(URI(external_url).path).extname
|
|
||||||
rescue StandardError
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
# Table name: channel_facebook_pages
|
# Table name: channel_facebook_pages
|
||||||
#
|
#
|
||||||
# id :integer not null, primary key
|
# id :integer not null, primary key
|
||||||
# avatar :string
|
|
||||||
# name :string not null
|
# name :string not null
|
||||||
# page_access_token :string not null
|
# page_access_token :string not null
|
||||||
# user_access_token :string not null
|
# user_access_token :string not null
|
||||||
@@ -18,29 +17,29 @@
|
|||||||
# index_channel_facebook_pages_on_page_id_and_account_id (page_id,account_id) UNIQUE
|
# index_channel_facebook_pages_on_page_id_and_account_id (page_id,account_id) UNIQUE
|
||||||
#
|
#
|
||||||
|
|
||||||
module Channel
|
class Channel::FacebookPage < ApplicationRecord
|
||||||
class FacebookPage < ApplicationRecord
|
include Avatarable
|
||||||
self.table_name = 'channel_facebook_pages'
|
|
||||||
|
|
||||||
validates :account_id, presence: true
|
self.table_name = 'channel_facebook_pages'
|
||||||
validates :page_id, uniqueness: { scope: :account_id }
|
|
||||||
mount_uploader :avatar, AvatarUploader
|
|
||||||
belongs_to :account
|
|
||||||
|
|
||||||
has_one :inbox, as: :channel, dependent: :destroy
|
validates :account_id, presence: true
|
||||||
|
validates :page_id, uniqueness: { scope: :account_id }
|
||||||
|
has_one_attached :avatar
|
||||||
|
belongs_to :account
|
||||||
|
|
||||||
before_destroy :unsubscribe
|
has_one :inbox, as: :channel, dependent: :destroy
|
||||||
|
|
||||||
def name
|
before_destroy :unsubscribe
|
||||||
'Facebook'
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
def name
|
||||||
|
'Facebook'
|
||||||
|
end
|
||||||
|
|
||||||
def unsubscribe
|
private
|
||||||
Facebook::Messenger::Subscriptions.unsubscribe(access_token: page_access_token)
|
|
||||||
rescue => e
|
def unsubscribe
|
||||||
true
|
Facebook::Messenger::Subscriptions.unsubscribe(access_token: page_access_token)
|
||||||
end
|
rescue => e
|
||||||
|
true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user