Merge branch 'release/2.7.0'

This commit is contained in:
Sojan
2022-07-18 16:48:01 +02:00
756 changed files with 18314 additions and 4376 deletions

View File

@@ -100,6 +100,10 @@ jobs:
- run:
name: Rubocop
command: bundle exec rubocop
# - run:
# name: Brakeman
# command: bundle exec brakeman
- run:
name: eslint

View File

@@ -5,4 +5,4 @@ FROM ghcr.io/chatwoot/chatwoot_codespace:latest
# Do the set up required for chatwoot app
WORKDIR /workspace
COPY . /workspace
RUN yarn && gem install bundler && bundle install
RUN yarn && gem install bundler && bundle install

View File

@@ -1,6 +1,6 @@
# pre-build stage
ARG VARIANT=3
FROM mcr.microsoft.com/vscode/devcontainers/ruby:${VARIANT}
ARG VARIANT=ubuntu-20.04
FROM mcr.microsoft.com/vscode/devcontainers/base:${VARIANT}
# Update args in docker-compose.yaml to set the UID/GID of the "vscode" user.
ARG USER_UID=1000
@@ -11,23 +11,36 @@ RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \
&& chmod -R $USER_UID:$USER_GID /home/vscode; \
fi
# [Option] Install Node.js
ARG INSTALL_NODE="true"
ARG NODE_VERSION="lts/*"
RUN if [ "${INSTALL_NODE}" = "true" ]; then su vscode -c "source /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends \
build-essential \
libssl-dev \
zlib1g-dev \
gnupg2 \
tar \
tzdata \
postgresql-client \
libpq-dev \
yarn \
git \
imagemagick \
tmux \
zsh
zsh \
git-flow \
npm
# Install rbenv and ruby
ARG RUBY_VERSION="3.0.4"
RUN git clone https://github.com/rbenv/rbenv.git ~/.rbenv \
&& echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc \
&& echo 'eval "$(rbenv init -)"' >> ~/.bashrc
ENV PATH "/root/.rbenv/bin/:/root/.rbenv/shims/:$PATH"
RUN git clone https://github.com/rbenv/ruby-build.git && \
PREFIX=/usr/local ./ruby-build/install.sh
RUN rbenv install $RUBY_VERSION && \
rbenv global $RUBY_VERSION && \
rbenv versions
# Install overmind
RUN curl -L https://github.com/DarthSim/overmind/releases/download/v2.1.0/overmind-v2.1.0-linux-amd64.gz > overmind.gz \
@@ -35,11 +48,25 @@ RUN curl -L https://github.com/DarthSim/overmind/releases/download/v2.1.0/overmi
&& sudo mv overmind /usr/local/bin \
&& chmod +x /usr/local/bin/overmind
# Install gh
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
&& sudo apt update \
&& sudo apt install gh
# Do the set up required for chatwoot app
WORKDIR /workspace
COPY . /workspace
RUN yarn
# set up ruby
COPY Gemfile Gemfile.lock ./
RUN gem install bundler && bundle install
# set up node js
RUN npm install npm@latest -g && \
npm install n -g && \
n latest
RUN npm install --global yarn
RUN yarn

View File

@@ -23,17 +23,18 @@
// 5432 postgres
// 6379 redis
// 1025,8025 mailhog
"forwardPorts": [8025],
//your application may need to listen on all interfaces (0.0.0.0) not just localhost for it to be available externally. Defaults to []
"appPort": [3000, 3035],
"forwardPorts": [8025, 3000, 3035],
"postCreateCommand": ".devcontainer/scripts/setup.sh && bundle exec rake db:chatwoot_prepare && yarn",
"portsAttributes": {
"3000": {
"label": "Rails Server"
},
"3035": {
"label": "Webpack Dev Server"
},
"8025": {
"label": "Mailhog UI"
}
},
}
}

View File

@@ -6,3 +6,8 @@ sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.githubpreview.de
sed -i -e "/WEBPACKER_DEV_SERVER_PUBLIC/ s/=.*/=https:\/\/$CODESPACE_NAME-3035.githubpreview.dev/" .env
# uncomment the webpacker env variable
sed -i -e '/WEBPACKER_DEV_SERVER_PUBLIC/s/^# //' .env
# fix the error with webpacker
echo 'export NODE_OPTIONS=--openssl-legacy-provider' >> ~/.zshrc
# codespaces make the ports public
gh codespace ports visibility 3000:public 3035:public 8025:public -c $CODESPACE_NAME

View File

@@ -1,5 +1,10 @@
module.exports = {
extends: ['airbnb-base/legacy', 'prettier', 'plugin:vue/recommended'],
extends: [
'airbnb-base/legacy',
'prettier',
'plugin:vue/recommended',
'plugin:storybook/recommended',
],
parserOptions: {
parser: 'babel-eslint',
ecmaVersion: 2020,

View File

@@ -4,6 +4,7 @@ import Vuex from 'vuex';
import VueI18n from 'vue-i18n';
import Vuelidate from 'vuelidate';
import Multiselect from 'vue-multiselect';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon';
import WootUiKit from '../app/javascript/dashboard/components';
import i18n from '../app/javascript/dashboard/i18n';
@@ -15,6 +16,7 @@ Vue.use(Vuelidate);
Vue.use(WootUiKit);
Vue.use(Vuex);
Vue.component('multiselect', Multiselect);
Vue.component('fluent-icon', FluentIcon);
const store = new Vuex.Store({});
const i18nConfig = new VueI18n({

11
Gemfile
View File

@@ -4,7 +4,7 @@ ruby '3.0.4'
##-- base gems for rails --##
gem 'rack-cors', require: 'rack/cors'
gem 'rails'
gem 'rails', '~>6.1'
# Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', require: false
@@ -78,7 +78,7 @@ gem 'wisper', '2.0.0'
# TODO: bump up gem to 2.0
gem 'facebook-messenger'
gem 'line-bot-api'
gem 'twilio-ruby', '~> 5.32.0'
gem 'twilio-ruby', '~> 5.66'
# twitty will handle subscription of twitter account events
# gem 'twitty', git: 'https://github.com/chatwoot/twitty'
gem 'twitty'
@@ -89,10 +89,6 @@ gem 'slack-ruby-client'
# for dialogflow integrations
gem 'google-cloud-dialogflow'
##--- gems for debugging and error reporting ---##
# static analysis
gem 'brakeman'
##-- apm and error monitoring ---#
gem 'ddtrace'
gem 'newrelic_rpm'
@@ -160,6 +156,9 @@ end
group :development, :test do
gem 'active_record_query_trace'
##--- gems for debugging and error reporting ---##
# static analysis
gem 'brakeman'
gem 'bundle-audit', require: false
gem 'byebug', platform: :mri
gem 'climate_control'

View File

@@ -9,63 +9,63 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (6.1.5.1)
actionpack (= 6.1.5.1)
activesupport (= 6.1.5.1)
actioncable (6.1.6.1)
actionpack (= 6.1.6.1)
activesupport (= 6.1.6.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (6.1.5.1)
actionpack (= 6.1.5.1)
activejob (= 6.1.5.1)
activerecord (= 6.1.5.1)
activestorage (= 6.1.5.1)
activesupport (= 6.1.5.1)
actionmailbox (6.1.6.1)
actionpack (= 6.1.6.1)
activejob (= 6.1.6.1)
activerecord (= 6.1.6.1)
activestorage (= 6.1.6.1)
activesupport (= 6.1.6.1)
mail (>= 2.7.1)
actionmailer (6.1.5.1)
actionpack (= 6.1.5.1)
actionview (= 6.1.5.1)
activejob (= 6.1.5.1)
activesupport (= 6.1.5.1)
actionmailer (6.1.6.1)
actionpack (= 6.1.6.1)
actionview (= 6.1.6.1)
activejob (= 6.1.6.1)
activesupport (= 6.1.6.1)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
actionpack (6.1.5.1)
actionview (= 6.1.5.1)
activesupport (= 6.1.5.1)
actionpack (6.1.6.1)
actionview (= 6.1.6.1)
activesupport (= 6.1.6.1)
rack (~> 2.0, >= 2.0.9)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (6.1.5.1)
actionpack (= 6.1.5.1)
activerecord (= 6.1.5.1)
activestorage (= 6.1.5.1)
activesupport (= 6.1.5.1)
actiontext (6.1.6.1)
actionpack (= 6.1.6.1)
activerecord (= 6.1.6.1)
activestorage (= 6.1.6.1)
activesupport (= 6.1.6.1)
nokogiri (>= 1.8.5)
actionview (6.1.5.1)
activesupport (= 6.1.5.1)
actionview (6.1.6.1)
activesupport (= 6.1.6.1)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_record_query_trace (1.8)
activejob (6.1.5.1)
activesupport (= 6.1.5.1)
activejob (6.1.6.1)
activesupport (= 6.1.6.1)
globalid (>= 0.3.6)
activemodel (6.1.5.1)
activesupport (= 6.1.5.1)
activerecord (6.1.5.1)
activemodel (= 6.1.5.1)
activesupport (= 6.1.5.1)
activerecord-import (1.3.0)
activemodel (6.1.6.1)
activesupport (= 6.1.6.1)
activerecord (6.1.6.1)
activemodel (= 6.1.6.1)
activesupport (= 6.1.6.1)
activerecord-import (1.4.0)
activerecord (>= 4.2)
activestorage (6.1.5.1)
actionpack (= 6.1.5.1)
activejob (= 6.1.5.1)
activerecord (= 6.1.5.1)
activesupport (= 6.1.5.1)
activestorage (6.1.6.1)
actionpack (= 6.1.6.1)
activejob (= 6.1.6.1)
activerecord (= 6.1.6.1)
activesupport (= 6.1.6.1)
marcel (~> 1.0)
mini_mime (>= 1.1.0)
activesupport (6.1.5.1)
activesupport (6.1.6.1)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
@@ -91,20 +91,20 @@ GEM
ast (2.4.2)
attr_extras (6.2.5)
aws-eventstream (1.2.0)
aws-partitions (1.556.0)
aws-sdk-core (3.126.2)
aws-partitions (1.605.0)
aws-sdk-core (3.131.2)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.525.0)
aws-sigv4 (~> 1.1)
jmespath (~> 1.0)
aws-sdk-kms (1.54.0)
aws-sdk-core (~> 3, >= 3.126.0)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.57.0)
aws-sdk-core (~> 3, >= 3.127.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.112.0)
aws-sdk-core (~> 3, >= 3.126.0)
aws-sdk-s3 (1.114.0)
aws-sdk-core (~> 3, >= 3.127.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.4)
aws-sigv4 (1.4.0)
aws-sigv4 (1.5.0)
aws-eventstream (~> 1, >= 1.0.2)
azure-storage-blob (2.0.3)
azure-storage-common (~> 2.0)
@@ -117,31 +117,31 @@ GEM
barnes (0.0.9)
multi_json (~> 1)
statsd-ruby (~> 1.1)
bcrypt (3.1.16)
bcrypt (3.1.18)
bindex (0.8.1)
bootsnap (1.10.3)
bootsnap (1.12.0)
msgpack (~> 1.2)
brakeman (5.2.1)
brakeman (5.2.3)
browser (5.3.1)
builder (3.2.4)
bullet (7.0.1)
bullet (7.0.2)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.11)
bundle-audit (0.1.0)
bundler-audit
bundler-audit (0.9.0.1)
bundler-audit (0.9.1)
bundler (>= 1.2.0, < 3)
thor (~> 1.0)
byebug (11.1.3)
climate_control (1.0.1)
climate_control (1.1.1)
coderay (1.1.3)
commonmarker (0.23.4)
commonmarker (0.23.5)
concurrent-ruby (1.1.10)
connection_pool (2.2.5)
crack (0.4.5)
rexml
crass (1.0.6)
cypress-on-rails (1.12.1)
cypress-on-rails (1.13.1)
rack
database_cleaner (2.0.1)
database_cleaner-active_record (~> 2.0.0)
@@ -151,10 +151,12 @@ GEM
database_cleaner-core (2.0.1)
datetime_picker_rails (0.0.7)
momentjs-rails (>= 2.8.1)
ddtrace (0.54.2)
debase-ruby_core_source (<= 0.10.14)
ddtrace (1.2.0)
debase-ruby_core_source (= 0.10.16)
libddprof (~> 0.6.0.1.0)
libddwaf (~> 1.3.0.2.0)
msgpack
debase-ruby_core_source (0.10.14)
debase-ruby_core_source (0.10.16)
declarative (0.0.20)
devise (4.8.1)
bcrypt (~> 3.0)
@@ -176,7 +178,7 @@ GEM
dotenv-rails (2.7.6)
dotenv (= 2.7.6)
railties (>= 3.2)
down (5.3.0)
down (5.3.1)
addressable (~> 2.8)
ecma-re-validator (0.4.0)
regexp_parser (~> 2.2)
@@ -188,36 +190,60 @@ GEM
facebook-messenger (2.0.1)
httparty (~> 0.13, >= 0.13.7)
rack (>= 1.4.5)
factory_bot (6.2.0)
factory_bot (6.2.1)
activesupport (>= 5.0.0)
factory_bot_rails (6.2.0)
factory_bot (~> 6.2.0)
railties (>= 5.0.0)
faker (2.19.0)
i18n (>= 1.6, < 2)
faraday (1.0.1)
multipart-post (>= 1.2, < 3)
faker (2.21.0)
i18n (>= 1.8.11, < 2)
faraday (1.10.0)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (1.0.1)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday_middleware (1.2.0)
faraday (~> 1.0)
fcm (1.0.5)
faraday (~> 1)
fcm (1.0.8)
faraday (>= 1.0.0, < 3.0)
googleauth (~> 1)
ffi (1.15.5)
flag_shih_tzu (0.3.23)
foreman (0.87.2)
fugit (1.5.3)
et-orbi (~> 1, >= 1.2.7)
raabro (~> 1.4)
gapic-common (0.3.4)
google-protobuf (~> 3.12, >= 3.12.2)
googleapis-common-protos (>= 1.3.9, < 2.0)
googleapis-common-protos-types (>= 1.0.4, < 2.0)
googleauth (~> 0.9)
grpc (~> 1.25)
geocoder (1.7.3)
gapic-common (0.10.0)
faraday (>= 1.9, < 3.a)
faraday-retry (>= 1.0, < 3.a)
google-protobuf (~> 3.14)
googleapis-common-protos (>= 1.3.12, < 2.a)
googleapis-common-protos-types (>= 1.3.1, < 2.a)
googleauth (~> 1.0)
grpc (~> 1.36)
geocoder (1.8.0)
gli (2.21.0)
globalid (1.0.0)
activesupport (>= 5.0)
google-apis-core (0.4.2)
google-apis-core (0.7.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
@@ -226,23 +252,27 @@ GEM
retriable (>= 2.0, < 4.a)
rexml
webrick
google-apis-iamcredentials_v1 (0.10.0)
google-apis-core (>= 0.4, < 2.a)
google-apis-storage_v1 (0.11.0)
google-apis-core (>= 0.4, < 2.a)
google-apis-iamcredentials_v1 (0.13.0)
google-apis-core (>= 0.7, < 2.a)
google-apis-storage_v1 (0.18.0)
google-apis-core (>= 0.7, < 2.a)
google-cloud-core (1.6.0)
google-cloud-env (~> 1.0)
google-cloud-errors (~> 1.0)
google-cloud-dialogflow (1.2.0)
google-cloud-core (~> 1.5)
google-cloud-dialogflow-v2 (~> 0.1)
google-cloud-dialogflow-v2 (0.6.4)
gapic-common (~> 0.3)
google-cloud-dialogflow (1.5.0)
google-cloud-core (~> 1.6)
google-cloud-dialogflow-v2 (>= 0.15, < 2.a)
google-cloud-dialogflow-v2 (0.17.0)
gapic-common (>= 0.10, < 2.a)
google-cloud-errors (~> 1.0)
google-cloud-env (1.5.0)
faraday (>= 0.17.3, < 2.0)
google-cloud-location (>= 0.0, < 2.a)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.2.0)
google-cloud-storage (1.36.1)
google-cloud-location (0.2.0)
gapic-common (>= 0.10, < 2.a)
google-cloud-errors (~> 1.0)
google-cloud-storage (1.37.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1)
@@ -250,32 +280,32 @@ GEM
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
google-protobuf (3.19.4)
google-protobuf (3.19.4-x86_64-darwin)
google-protobuf (3.19.4-x86_64-linux)
google-protobuf (3.21.2)
google-protobuf (3.21.2-x86_64-darwin)
google-protobuf (3.21.2-x86_64-linux)
googleapis-common-protos (1.3.12)
google-protobuf (~> 3.14)
googleapis-common-protos-types (~> 1.2)
grpc (~> 1.27)
googleapis-common-protos-types (1.3.0)
googleapis-common-protos-types (1.3.2)
google-protobuf (~> 3.14)
googleauth (0.17.1)
faraday (>= 0.17.3, < 2.0)
googleauth (1.2.0)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
memoist (~> 0.16)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (~> 0.15)
groupdate (6.0.1)
signet (>= 0.16, < 2.a)
groupdate (6.1.0)
activesupport (>= 5.2)
grpc (1.43.1)
google-protobuf (~> 3.18)
grpc (1.47.0)
google-protobuf (~> 3.19)
googleapis-common-protos-types (~> 1.0)
grpc (1.43.1-universal-darwin)
google-protobuf (~> 3.18)
grpc (1.47.0-x86_64-darwin)
google-protobuf (~> 3.19)
googleapis-common-protos-types (~> 1.0)
grpc (1.43.1-x86_64-linux)
google-protobuf (~> 3.18)
grpc (1.47.0-x86_64-linux)
google-protobuf (~> 3.19)
googleapis-common-protos-types (~> 1.0)
haikunator (1.1.1)
hairtrigger (0.2.25)
@@ -289,13 +319,13 @@ GEM
html2text (0.2.1)
nokogiri (~> 1.6)
http-accept (1.7.0)
http-cookie (1.0.4)
http-cookie (1.0.5)
domain_name (~> 0.5)
httparty (0.20.0)
mime-types (~> 3.0)
multi_xml (>= 0.5.2)
httpclient (2.8.3)
i18n (1.10.0)
i18n (1.11.0)
concurrent-ruby (~> 1.0)
image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5)
@@ -304,19 +334,19 @@ GEM
actionview (>= 5.0.0)
activesupport (>= 5.0.0)
jmespath (1.6.1)
jquery-rails (4.4.0)
jquery-rails (4.5.0)
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.6.1)
json (2.6.2)
json_refs (0.1.7)
hana
json_schemer (0.2.19)
json_schemer (0.2.21)
ecma-re-validator (~> 0.3)
hana (~> 1.3)
regexp_parser (~> 2.0)
uri_template (~> 0.7)
jwt (2.3.0)
jwt (2.4.1)
kaminari (1.2.2)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.2)
@@ -329,21 +359,31 @@ GEM
activerecord
kaminari-core (= 1.2.2)
kaminari-core (1.2.2)
koala (3.1.0)
koala (3.2.0)
addressable
faraday (< 2)
json (>= 1.8)
rexml
launchy (2.5.0)
addressable (~> 2.7)
letter_opener (1.7.0)
launchy (~> 2.2)
line-bot-api (1.23.0)
liquid (5.1.0)
letter_opener (1.8.1)
launchy (>= 2.2, < 3)
libddprof (0.6.0.1.0)
libddprof (0.6.0.1.0-x86_64-linux)
libddwaf (1.3.0.2.0)
ffi (~> 1.0)
libddwaf (1.3.0.2.0-arm64-darwin)
ffi (~> 1.0)
libddwaf (1.3.0.2.0-x86_64-darwin)
ffi (~> 1.0)
libddwaf (1.3.0.2.0-x86_64-linux)
ffi (~> 1.0)
line-bot-api (1.25.0)
liquid (5.3.0)
listen (3.7.1)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
loofah (2.17.0)
loofah (2.18.0)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
mail (2.7.1)
@@ -358,36 +398,36 @@ GEM
mini_magick (4.11.0)
mini_mime (1.1.2)
mini_portile2 (2.8.0)
minitest (5.15.0)
mock_redis (0.30.0)
minitest (5.16.2)
mock_redis (0.32.0)
ruby2_keywords
momentjs-rails (2.29.1.1)
railties (>= 3.1)
msgpack (1.4.5)
msgpack (1.5.3)
multi_json (1.15.0)
multi_xml (0.6.0)
multipart-post (2.1.1)
multipart-post (2.2.3)
net-http-persistent (4.0.1)
connection_pool (~> 2.2)
netrc (0.11.0)
newrelic_rpm (8.7.0)
newrelic_rpm (8.9.0)
nio4r (2.5.8)
nokogiri (1.13.6)
nokogiri (1.13.7)
mini_portile2 (~> 2.8.0)
racc (~> 1.4)
nokogiri (1.13.6-arm64-darwin)
nokogiri (1.13.7-arm64-darwin)
racc (~> 1.4)
nokogiri (1.13.6-x86_64-darwin)
nokogiri (1.13.7-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.13.6-x86_64-linux)
nokogiri (1.13.7-x86_64-linux)
racc (~> 1.4)
oauth (0.5.8)
oauth (0.5.10)
orm_adapter (0.5.0)
os (1.1.4)
parallel (1.21.0)
parser (3.1.1.0)
parallel (1.22.1)
parser (3.1.2.0)
ast (~> 2.4.1)
pg (1.3.2)
pg (1.4.1)
pg_search (2.3.6)
activerecord (>= 5.2)
activesupport (>= 5.2)
@@ -398,46 +438,46 @@ GEM
method_source (~> 1.0)
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (4.0.6)
public_suffix (4.0.7)
puma (5.6.4)
nio4r (~> 2.0)
pundit (2.2.0)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.6.0)
rack (2.2.3.1)
rack-attack (6.6.0)
rack (2.2.4)
rack-attack (6.6.1)
rack (>= 1.0, < 3)
rack-cors (1.1.1)
rack (>= 2.0.0)
rack-proxy (0.7.2)
rack
rack-test (1.1.0)
rack (>= 1.0, < 3)
rack-timeout (0.6.0)
rails (6.1.5.1)
actioncable (= 6.1.5.1)
actionmailbox (= 6.1.5.1)
actionmailer (= 6.1.5.1)
actionpack (= 6.1.5.1)
actiontext (= 6.1.5.1)
actionview (= 6.1.5.1)
activejob (= 6.1.5.1)
activemodel (= 6.1.5.1)
activerecord (= 6.1.5.1)
activestorage (= 6.1.5.1)
activesupport (= 6.1.5.1)
rack-test (2.0.2)
rack (>= 1.3)
rack-timeout (0.6.3)
rails (6.1.6.1)
actioncable (= 6.1.6.1)
actionmailbox (= 6.1.6.1)
actionmailer (= 6.1.6.1)
actionpack (= 6.1.6.1)
actiontext (= 6.1.6.1)
actionview (= 6.1.6.1)
activejob (= 6.1.6.1)
activemodel (= 6.1.6.1)
activerecord (= 6.1.6.1)
activestorage (= 6.1.6.1)
activesupport (= 6.1.6.1)
bundler (>= 1.15.0)
railties (= 6.1.5.1)
railties (= 6.1.6.1)
sprockets-rails (>= 2.0.0)
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
nokogiri (>= 1.6)
rails-html-sanitizer (1.4.2)
rails-html-sanitizer (1.4.3)
loofah (~> 2.3)
railties (6.1.5.1)
actionpack (= 6.1.5.1)
activesupport (= 6.1.5.1)
railties (6.1.6.1)
actionpack (= 6.1.6.1)
activesupport (= 6.1.6.1)
method_source
rake (>= 12.2)
thor (~> 1.0)
@@ -446,11 +486,11 @@ GEM
rb-fsevent (0.11.1)
rb-inotify (0.10.1)
ffi (~> 1.0)
redis (4.6.0)
redis-namespace (1.8.1)
redis (4.7.1)
redis-namespace (1.8.2)
redis (>= 3.0.4)
regexp_parser (2.2.1)
representable (3.1.1)
regexp_parser (2.5.0)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
@@ -469,7 +509,7 @@ GEM
rspec-expectations (3.11.0)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.11.0)
rspec-mocks (3.11.0)
rspec-mocks (3.11.1)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.11.0)
rspec-rails (5.0.3)
@@ -481,26 +521,27 @@ GEM
rspec-mocks (~> 3.10)
rspec-support (~> 3.10)
rspec-support (3.11.0)
rubocop (1.25.1)
rubocop (1.31.2)
json (~> 2.3)
parallel (~> 1.10)
parser (>= 3.1.0.0)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml
rubocop-ast (>= 1.15.1, < 2.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.18.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 3.0)
rubocop-ast (1.16.0)
rubocop-ast (1.19.1)
parser (>= 3.1.1.0)
rubocop-performance (1.13.2)
rubocop-performance (1.14.2)
rubocop (>= 1.7.0, < 2.0)
rubocop-ast (>= 0.4.0)
rubocop-rails (2.13.2)
rubocop-rails (2.15.2)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 1.7.0, < 2.0)
rubocop-rspec (2.8.0)
rubocop (~> 1.19)
rubocop-rspec (2.12.1)
rubocop (~> 1.31)
ruby-progressbar (1.11.0)
ruby-vips (2.1.4)
ffi (~> 1.12)
@@ -508,7 +549,7 @@ GEM
ruby2ruby (2.4.4)
ruby_parser (~> 3.1)
sexp_processor (~> 4.6)
ruby_parser (3.18.1)
ruby_parser (3.19.1)
sexp_processor (~> 4.16)
sassc (2.4.0)
ffi (~> 1.9)
@@ -518,37 +559,37 @@ GEM
sprockets (> 3.0)
sprockets-rails
tilt
scout_apm (5.1.1)
scout_apm (5.2.0)
parser
seed_dump (3.3.1)
activerecord (>= 4)
activesupport (>= 4)
selectize-rails (0.12.6)
semantic_range (3.0.0)
sentry-rails (5.3.0)
sentry-rails (5.3.1)
railties (>= 5.0)
sentry-ruby-core (~> 5.3.0)
sentry-ruby (5.3.0)
sentry-ruby-core (~> 5.3.1)
sentry-ruby (5.3.1)
concurrent-ruby (~> 1.0, >= 1.0.2)
sentry-ruby-core (= 5.3.0)
sentry-ruby-core (5.3.0)
sentry-ruby-core (= 5.3.1)
sentry-ruby-core (5.3.1)
concurrent-ruby
sentry-sidekiq (5.3.0)
sentry-ruby-core (~> 5.3.0)
sentry-sidekiq (5.3.1)
sentry-ruby-core (~> 5.3.1)
sidekiq (>= 3.0)
sexp_processor (4.16.0)
sexp_processor (4.16.1)
shoulda-matchers (5.1.0)
activesupport (>= 5.2.0)
sidekiq (6.4.1)
sidekiq (6.4.2)
connection_pool (>= 2.2.2)
rack (~> 2.0)
redis (>= 4.2.0)
sidekiq-cron (1.4.0)
sidekiq-cron (1.6.0)
fugit (~> 1)
sidekiq (>= 4.2.1)
signet (0.16.0)
signet (0.17.0)
addressable (~> 2.8)
faraday (>= 0.17.3, < 2.0)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
multi_json (~> 1.10)
simplecov (0.17.1)
@@ -566,7 +607,7 @@ GEM
spring-watcher-listen (2.0.1)
listen (>= 2.7, < 4.0)
spring (>= 1.2, < 3.0)
sprockets (4.0.3)
sprockets (4.1.1)
concurrent-ruby (~> 1.0)
rack (> 1, < 3)
sprockets-rails (3.4.2)
@@ -575,31 +616,31 @@ GEM
sprockets (>= 3.0.0)
squasher (0.6.2)
statsd-ruby (1.5.0)
telephone_number (1.4.13)
telephone_number (1.4.16)
thor (1.2.1)
tilt (2.0.10)
time_diff (0.3.0)
activesupport
i18n
trailblazer-option (0.1.2)
twilio-ruby (5.32.0)
faraday (~> 1.0.0)
twilio-ruby (5.68.0)
faraday (>= 0.9, < 3.0)
jwt (>= 1.5, <= 2.5)
nokogiri (>= 1.6, < 2.0)
twitty (0.1.4)
oauth
tzinfo (2.0.4)
concurrent-ruby (~> 1.0)
tzinfo-data (1.2021.5)
tzinfo-data (1.2022.1)
tzinfo (>= 1.0.0)
uber (0.1.0)
uglifier (4.2.0)
execjs (>= 0.3.0, < 3)
unf (0.1.4)
unf_ext
unf_ext (0.0.8)
unicode-display_width (2.1.0)
uniform_notifier (1.14.2)
unf_ext (0.0.8.2)
unicode-display_width (2.2.0)
uniform_notifier (1.16.0)
uri_template (0.7.0)
valid_email2 (4.0.3)
activemodel (>= 3.2)
@@ -631,7 +672,7 @@ GEM
working_hours (1.4.1)
activesupport (>= 3.2)
tzinfo
zeitwerk (2.5.4)
zeitwerk (2.6.0)
PLATFORMS
arm64-darwin-20
@@ -705,7 +746,7 @@ DEPENDENCIES
rack-attack
rack-cors
rack-timeout
rails
rails (~> 6.1)
redis
redis-namespace
responders
@@ -730,7 +771,7 @@ DEPENDENCIES
squasher
telephone_number
time_diff
twilio-ruby (~> 5.32.0)
twilio-ruby (~> 5.66)
twitty
tzinfo-data
uglifier
@@ -746,4 +787,4 @@ RUBY VERSION
ruby 3.0.4p208
BUNDLED WITH
2.3.15
2.3.16

View File

@@ -26,6 +26,7 @@ ___
<a href="https://huntr.dev/bounties/disclose"><img src="https://cdn.huntr.dev/huntr_security_badge_mono.svg" alt="Huntr"></a>
<a href="https://status.chatwoot.com"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fchatwoot%2Fstatus%2Fmaster%2Fapi%2Fchatwoot%2Fuptime.json" alt="uptime"></a>
<a href="https://status.chatwoot.com"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fchatwoot%2Fstatus%2Fmaster%2Fapi%2Fchatwoot%2Fresponse-time.json" alt="response time"></a>
<a href="https://artifacthub.io/packages/helm/chatwoot/chatwoot"><img src="https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/artifact-hub" alt="Artifact HUB"></a>
</p>
<img src="https://chatwoot-public-assets.s3.amazonaws.com/github/screenshot.png" width="100%" alt="Chat dashboard"/>

1
VERSION_CW Normal file
View File

@@ -0,0 +1 @@
2.6.0

1
VERSION_CWCTL Normal file
View File

@@ -0,0 +1 @@
2.1.0

View File

@@ -1,7 +1,16 @@
# retain_original_contact_name: false / true
# In case of setUser we want to update the name of the identified contact,
# which is the default behaviour
#
# But, In case of contact merge during prechat form contact update.
# We don't want to update the name of the identified original contact.
class ContactIdentifyAction
pattr_initialize [:contact!, :params!]
pattr_initialize [:contact!, :params!, { retain_original_contact_name: false, discard_invalid_attrs: false }]
def perform
@attributes_to_update = [:identifier, :name, :email, :phone_number]
ActiveRecord::Base.transaction do
merge_if_existing_identified_contact
merge_if_existing_email_contact
@@ -18,49 +27,89 @@ class ContactIdentifyAction
end
def merge_if_existing_identified_contact
@contact = merge_contact(existing_identified_contact, @contact) if merge_contacts?(existing_identified_contact, @contact)
return unless merge_contacts?(existing_identified_contact, :identifier)
process_contact_merge(existing_identified_contact)
end
def merge_if_existing_email_contact
@contact = merge_contact(existing_email_contact, @contact) if merge_contacts?(existing_email_contact, @contact)
return unless merge_contacts?(existing_email_contact, :email)
process_contact_merge(existing_email_contact)
end
def merge_if_existing_phone_number_contact
@contact = merge_contact(existing_phone_number_contact, @contact) if merge_contacts?(existing_phone_number_contact, @contact)
return unless merge_contacts?(existing_phone_number_contact, :phone_number)
return unless mergable_phone_contact?
process_contact_merge(existing_phone_number_contact)
end
def process_contact_merge(mergee_contact)
@contact = merge_contact(mergee_contact, @contact)
@attributes_to_update.delete(:name) if retain_original_contact_name
end
def existing_identified_contact
return if params[:identifier].blank?
@existing_identified_contact ||= Contact.where(account_id: account.id).find_by(identifier: params[:identifier])
@existing_identified_contact ||= account.contacts.find_by(identifier: params[:identifier])
end
def existing_email_contact
return if params[:email].blank?
@existing_email_contact ||= Contact.where(account_id: account.id).find_by(email: params[:email])
@existing_email_contact ||= account.contacts.find_by(email: params[:email])
end
def existing_phone_number_contact
return if params[:phone_number].blank?
@existing_phone_number_contact ||= Contact.where(account_id: account.id).find_by(phone_number: params[:phone_number])
@existing_phone_number_contact ||= account.contacts.find_by(phone_number: params[:phone_number])
end
def merge_contacts?(existing_contact, _contact)
existing_contact && existing_contact.id != @contact.id
def merge_contacts?(existing_contact, key)
return if existing_contact.blank?
return true if params[:identifier].blank?
# we want to prevent merging contacts with different identifiers
if existing_contact.identifier.present? && existing_contact.identifier != params[:identifier]
# we will remove attribute from update list
@attributes_to_update.delete(key)
return false
end
true
end
# case: contact 1: email: 1@test.com, phone: 123456789
# params: email: 2@test.com, phone: 123456789
# we don't want to overwrite 1@test.com since email parameter takes higer priority
def mergable_phone_contact?
return true if params[:email].blank?
if existing_phone_number_contact.email.present? && existing_phone_number_contact.email != params[:email]
@attributes_to_update.delete(:phone_number)
return false
end
true
end
def update_contact
@contact.attributes = params.slice(*@attributes_to_update).reject do |_k, v|
v.blank?
end.merge({ custom_attributes: custom_attributes, additional_attributes: additional_attributes })
# blank identifier or email will throw unique index error
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
@contact.update!(params.slice(:name, :email, :identifier, :phone_number).reject do |_k, v|
v.blank?
end.merge({ custom_attributes: custom_attributes, additional_attributes: additional_attributes }))
@contact.discard_invalid_attrs if discard_invalid_attrs
@contact.save!
ContactAvatarJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present?
end
def merge_contact(base_contact, merge_contact)
return base_contact if base_contact.id == merge_contact.id
ContactMergeAction.new(
account: account,
base_contact: base_contact,
@@ -69,14 +118,14 @@ class ContactIdentifyAction
end
def custom_attributes
params[:custom_attributes] ? @contact.custom_attributes.deep_merge(params[:custom_attributes].stringify_keys) : @contact.custom_attributes
return @contact.custom_attributes if params[:custom_attributes].blank?
(@contact.custom_attributes || {}).deep_merge(params[:custom_attributes].stringify_keys)
end
def additional_attributes
if params[:additional_attributes]
@contact.additional_attributes.deep_merge(params[:additional_attributes].stringify_keys)
else
@contact.additional_attributes
end
return @contact.additional_attributes if params[:additional_attributes].blank?
(@contact.additional_attributes || {}).deep_merge(params[:additional_attributes].stringify_keys)
end
end

View File

@@ -9,12 +9,15 @@ class Campaigns::CampaignConversationBuilder
@contact_inbox.lock!
# We won't send campaigns if a conversation is already present
return if @contact_inbox.reload.conversations.present?
raise 'Conversation alread present' if @contact_inbox.reload.conversations.present?
@conversation = ::Conversation.create!(conversation_params)
Messages::MessageBuilder.new(@campaign.sender, @conversation, message_params).perform
end
@conversation
rescue StandardError => e
Rails.logger.info(e.message)
nil
end
private

View File

@@ -54,6 +54,9 @@ class Messages::Messenger::MessageBuilder
def fetch_story_link(attachment)
message = attachment.message
result = get_story_object_from_source_id(message.source_id)
return if result.blank?
story_id = result['story']['mention']['id']
story_sender = result['from']['username']
message.content_attributes[:story_sender] = story_sender
@@ -68,6 +71,11 @@ class Messages::Messenger::MessageBuilder
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
raise
rescue Koala::Facebook::ClientError => e
# The exception occurs when we are trying fetch the deleted story or blocked story.
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
Rails.logger.error e
{}
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
{}

View File

@@ -30,6 +30,6 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
end
def permitted_params
params.permit(:name, :description, :outgoing_url)
params.permit(:name, :description, :outgoing_url, :bot_type, bot_config: [:csml_content])
end
end

View File

@@ -1,14 +1,18 @@
class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
before_action :portal
before_action :check_authorization
before_action :fetch_article, except: [:index, :create]
def index
@articles = @portal.articles
@articles.search(list_params) if params[:payload].present?
@articles = @articles.search(list_params) if params[:payload].present?
end
def create
@article = @portal.articles.create!(article_params)
@article.associate_root_article(article_params[:associated_article_id])
@article.draft!
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
end
def edit; end
@@ -36,7 +40,7 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
def article_params
params.require(:article).permit(
:title, :content, :description, :position, :category_id, :author_id
:title, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status
)
end

View File

@@ -1,5 +1,6 @@
class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseController
before_action :portal
before_action :check_authorization
before_action :fetch_category, except: [:index, :create]
def index
@@ -8,10 +9,20 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
def create
@category = @portal.categories.create!(category_params)
@category.related_categories << related_categories_records
render json: { error: @category.errors.messages }, status: :unprocessable_entity and return unless @category.valid?
@category.save!
end
def show; end
def update
@category.update!(category_params)
@category.related_categories = related_categories_records if related_categories_records.any?
render json: { error: @category.errors.messages }, status: :unprocessable_entity and return unless @category.valid?
@category.save!
end
def destroy
@@ -29,9 +40,13 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
@portal ||= Current.account.portals.find_by(slug: params[:portal_id])
end
def related_categories_records
@portal.categories.where(id: params[:category][:related_category_ids])
end
def category_params
params.require(:category).permit(
:name, :description, :position, :slug, :locale
:name, :description, :position, :slug, :locale, :parent_category_id, :associated_category_id
)
end
end

View File

@@ -27,6 +27,8 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
end
def phone_number
return if permitted_params[:phone_number].blank?
medium == 'sms' ? permitted_params[:phone_number] : "whatsapp:#{permitted_params[:phone_number]}"
end
@@ -38,6 +40,7 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
@twilio_channel = Current.account.twilio_sms.create!(
account_sid: permitted_params[:account_sid],
auth_token: permitted_params[:auth_token],
messaging_service_sid: permitted_params[:messaging_service_sid].presence,
phone_number: phone_number,
medium: medium
)
@@ -49,7 +52,7 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
def permitted_params
params.require(:twilio_channel).permit(
:account_id, :phone_number, :account_sid, :auth_token, :name, :medium
:account_id, :messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium
)
end
end

View File

@@ -12,7 +12,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :set_current_page, only: [:index, :active, :search, :filter]
before_action :fetch_contact, only: [:show, :update, :destroy, :contactable_inboxes, :destroy_custom_attributes]
before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes]
before_action :set_include_contact_inboxes, only: [:index, :search, :filter]
def index
@@ -72,15 +72,17 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def create
ActiveRecord::Base.transaction do
@contact = Current.account.contacts.new(contact_params)
@contact = Current.account.contacts.new(permitted_params.except(:avatar_url))
@contact.save!
@contact_inbox = build_contact_inbox
process_avatar
end
end
def update
@contact.assign_attributes(contact_update_params)
@contact.save!
process_avatar if permitted_params[:avatar].present? || permitted_params[:avatar_url].present?
end
def destroy
@@ -95,6 +97,11 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
head :ok
end
def avatar
@contact.avatar.purge if @contact.avatar.attached?
@contact
end
private
# TODO: Move this to a finder class
@@ -131,19 +138,19 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
ContactInbox.create(contact: @contact, inbox: inbox, source_id: source_id)
end
def contact_params
params.require(:contact).permit(:name, :identifier, :email, :phone_number, additional_attributes: {}, custom_attributes: {})
def permitted_params
params.permit(:name, :identifier, :email, :phone_number, :avatar, :avatar_url, additional_attributes: {}, custom_attributes: {})
end
def contact_custom_attributes
return @contact.custom_attributes.merge(contact_params[:custom_attributes]) if contact_params[:custom_attributes]
return @contact.custom_attributes.merge(permitted_params[:custom_attributes]) if permitted_params[:custom_attributes]
@contact.custom_attributes
end
def contact_update_params
# we want the merged custom attributes not the original one
contact_params.except(:custom_attributes).merge({ custom_attributes: contact_custom_attributes })
permitted_params.except(:custom_attributes, :avatar_url).merge({ custom_attributes: contact_custom_attributes })
end
def set_include_contact_inboxes
@@ -158,6 +165,16 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
@contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id])
end
def process_avatar
if permitted_params[:avatar].blank? && permitted_params[:avatar_url].present?
::ContactAvatarJob.perform_later(@contact, params[:avatar_url])
elsif permitted_params[:avatar].blank? && permitted_params[:email].present?
hash = Digest::MD5.hexdigest(params[:email])
gravatar_url = "https://www.gravatar.com/avatar/#{hash}?d=404"
::ContactAvatarJob.perform_later(@contact, gravatar_url)
end
end
def render_error(error, error_status)
render json: error, status: error_status
end

View File

@@ -1,18 +1,36 @@
class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
include ::FileTypeHelper
before_action :fetch_portal, except: [:index, :create]
before_action :check_authorization
def index
@portals = Current.account.portals
end
def add_members
agents = Current.account.agents.where(id: portal_member_params[:member_ids])
@portal.members << agents
end
def show; end
def create
@portal = Current.account.portals.create!(portal_params)
@portal = Current.account.portals.build(portal_params)
render json: { error: @portal.errors.messages }, status: :unprocessable_entity and return unless @portal.valid?
@portal.save!
process_attached_logo
end
def update
@portal.update!(portal_params)
ActiveRecord::Base.transaction do
@portal.update!(portal_params) if params[:portal].present?
process_attached_logo
rescue StandardError => e
Rails.logger.error e
render json: { error: @portal.errors.messages }.to_json, status: :unprocessable_entity
end
end
def destroy
@@ -20,6 +38,15 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
head :ok
end
def archive
@portal.update(archive: true)
head :ok
end
def process_attached_logo
@portal.logo.attach(params[:logo])
end
private
def fetch_portal
@@ -32,7 +59,11 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def portal_params
params.require(:portal).permit(
:account_id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug, :archived
:account_id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug, :archived, config: { allowed_locales: [] }
)
end
def portal_member_params
params.require(:portal).permit(:account_id, member_ids: [])
end
end

View File

@@ -44,38 +44,6 @@ class Api::V1::Widget::BaseController < ApplicationController
}
end
def update_contact(email)
contact_with_email = @current_account.contacts.find_by(email: email)
if contact_with_email
@contact = ::ContactMergeAction.new(
account: @current_account,
base_contact: contact_with_email,
mergee_contact: @contact
).perform
else
@contact.update!(email: email)
update_contact_name
end
end
def update_contact_phone_number(phone_number)
contact_with_phone_number = @current_account.contacts.find_by(phone_number: phone_number)
if contact_with_phone_number
@contact = ::ContactMergeAction.new(
account: @current_account,
base_contact: contact_with_phone_number,
mergee_contact: @contact
).perform
else
@contact.update!(phone_number: phone_number)
update_contact_name
end
end
def update_contact_name
@contact.update!(name: contact_name) if contact_name.present?
end
def contact_email
permitted_params.dig(:contact, :email)&.downcase
end

View File

@@ -1,14 +1,27 @@
class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
before_action :process_hmac, only: [:update]
include WidgetHelper
before_action :validate_hmac, only: [:set_user]
def show; end
def update
contact_identify_action = ContactIdentifyAction.new(
contact: @contact,
params: permitted_params.to_h.deep_symbolize_keys
)
@contact = contact_identify_action.perform
identify_contact(@contact)
end
def set_user
contact = nil
if a_different_contact?
@contact_inbox, @widget_auth_token = build_contact_inbox_with_token(@web_widget)
contact = @contact_inbox.contact
else
contact = @contact
end
@contact_inbox.update(hmac_verified: true) if should_verify_hmac? && valid_hmac?
identify_contact(contact)
end
# TODO : clean up this with proper routes delete contacts/custom_attributes
@@ -20,12 +33,23 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
private
def process_hmac
def identify_contact(contact)
contact_identify_action = ContactIdentifyAction.new(
contact: contact,
params: permitted_params.to_h.deep_symbolize_keys,
discard_invalid_attrs: true
)
@contact = contact_identify_action.perform
end
def a_different_contact?
@contact.identifier.present? && @contact.identifier != permitted_params[:identifier]
end
def validate_hmac
return unless should_verify_hmac?
render json: { error: 'HMAC failed: Invalid Identifier Hash Provided' }, status: :unauthorized unless valid_hmac?
@contact_inbox.update(hmac_verified: true)
end
def should_verify_hmac?

View File

@@ -14,8 +14,11 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
end
def process_update_contact
update_contact(contact_email) if @contact.email.blank? && contact_email.present?
update_contact_phone_number(contact_phone_number) if @contact.phone_number.blank? && contact_phone_number.present?
@contact = ContactIdentifyAction.new(
contact: @contact,
params: { email: contact_email, phone_number: contact_phone_number, name: contact_name },
retain_original_contact_name: true
).perform
end
def update_last_seen

View File

@@ -15,7 +15,10 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
def update
if @message.content_type == 'input_email'
@message.update!(submitted_email: contact_email)
update_contact(contact_email)
ContactIdentifyAction.new(
contact: @contact,
params: { email: contact_email }
).perform
else
@message.update!(message_update_params[:message])
end

View File

@@ -0,0 +1,20 @@
# services from Meta (Prev: Facebook) needs a token verification step for webhook subscriptions,
# This concern handles the token verification step.
module MetaTokenVerifyConcern
def verify
service = is_a?(Webhooks::WhatsappController) ? 'whatsapp' : 'instagram'
if valid_token?(params['hub.verify_token'])
Rails.logger.info("#{service.capitalize} webhook verified")
render json: params['hub.challenge']
else
render status: :unauthorized, json: { error: 'Error; wrong verify token' }
end
end
private
def valid_token?(_token)
raise 'Overwrite this method your controller'
end
end

View File

@@ -14,7 +14,7 @@ class Platform::Api::V1::UsersController < PlatformController
def login
encoded_email = ERB::Util.url_encode(@resource.email)
render json: { url: "#{ENV['FRONTEND_URL']}/app/login?email=#{encoded_email}&sso_auth_token=#{@resource.generate_sso_auth_token}" }
render json: { url: "#{ENV.fetch('FRONTEND_URL', nil)}/app/login?email=#{encoded_email}&sso_auth_token=#{@resource.generate_sso_auth_token}" }
end
def show; end

View File

@@ -0,0 +1,25 @@
class Public::Api::V1::Portals::ArticlesController < ApplicationController
before_action :set_portal
before_action :set_article, only: [:show]
def index
@articles = @portal.articles
@articles = @articles.search(list_params) if params[:payload].present?
end
def show; end
private
def set_article
@article = @portal.articles.find(params[:id])
end
def set_portal
@portal = ::Portal.find_by!(slug: params[:portal_slug], archived: false)
end
def list_params
params.require(:payload).permit(:query)
end
end

View File

@@ -0,0 +1,20 @@
class Public::Api::V1::Portals::CategoriesController < PublicController
before_action :set_portal
before_action :set_category, only: [:show]
def index
@categories = @portal.categories
end
def show; end
private
def set_category
@category = @portal.categories.find_by!(slug: params[:slug])
end
def set_portal
@portal = ::Portal.find_by!(slug: params[:portal_slug], archived: false)
end
end

View File

@@ -0,0 +1,11 @@
class Public::Api::V1::PortalsController < PublicController
before_action :set_portal
def show; end
private
def set_portal
@portal = ::Portal.find_by!(slug: params[:slug], archived: false)
end
end

View File

@@ -7,7 +7,7 @@ class Twilio::CallbackController < ApplicationController
private
def permitted_params
def permitted_params # rubocop:disable Metrics/MethodLength
params.permit(
:ApiVersion,
:SmsSid,
@@ -25,7 +25,8 @@ class Twilio::CallbackController < ApplicationController
:ToCountry,
:FromState,
:MediaUrl0,
:MediaContentType0
:MediaContentType0,
:MessagingServiceSid
)
end
end

View File

@@ -1,15 +1,5 @@
class Webhooks::InstagramController < ApplicationController
skip_before_action :authenticate_user!, raise: false
skip_before_action :set_current_user
def verify
if valid_instagram_token?(params['hub.verify_token'])
Rails.logger.info('Instagram webhook verified')
render json: params['hub.challenge']
else
render json: { error: 'Error; wrong verify token', status: 403 }
end
end
class Webhooks::InstagramController < ActionController::API
include MetaTokenVerifyConcern
def events
Rails.logger.info('Instagram webhook received events')
@@ -24,7 +14,7 @@ class Webhooks::InstagramController < ApplicationController
private
def valid_instagram_token?(token)
def valid_token?(token)
token == GlobalConfigService.load('IG_VERIFY_TOKEN', '')
end
end

View File

@@ -1,6 +1,16 @@
class Webhooks::WhatsappController < ActionController::API
include MetaTokenVerifyConcern
def process_payload
Webhooks::WhatsappEventsJob.perform_later(params.to_unsafe_hash)
head :ok
end
private
def valid_token?(token)
channel = Channel::Whatsapp.find_by(phone_number: params[:phone_number])
whatsapp_webhook_verify_token = channel.provider_config['webhook_verify_token'] if channel.present?
token == whatsapp_webhook_verify_token if whatsapp_webhook_verify_token.present?
end
end

View File

@@ -1,5 +1,7 @@
# TODO : Delete this and associated spec once 'api/widget/config' end point is merged
class WidgetsController < ActionController::Base
include WidgetHelper
before_action :set_global_config
before_action :set_web_widget
before_action :set_token
@@ -40,11 +42,8 @@ class WidgetsController < ActionController::Base
def build_contact
return if @contact.present?
@contact_inbox = @web_widget.create_contact_inbox(additional_attributes)
@contact_inbox, @token = build_contact_inbox_with_token(@web_widget, additional_attributes)
@contact = @contact_inbox.contact
payload = { source_id: @contact_inbox.source_id, inbox_id: @web_widget.inbox.id }
@token = ::Widget::TokenService.new(payload: payload).generate_token
end
def additional_attributes

View File

@@ -2,6 +2,11 @@ class ConversationFinder
attr_reader :current_user, :current_account, :params
DEFAULT_STATUS = 'open'.freeze
SORT_OPTIONS = {
latest: 'latest',
sort_on_created_at: 'sort_on_created_at',
last_user_message_at: 'last_user_message_at'
}.with_indifferent_access
# assumptions
# inbox_id if not given, take from all conversations, else specific to inbox
@@ -133,10 +138,7 @@ class ConversationFinder
@conversations = @conversations.includes(
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :contact_inbox
)
if params[:conversation_type] == 'mention'
@conversations.page(current_page)
else
@conversations.latest.page(current_page)
end
sort_by = SORT_OPTIONS[params[:sort_by]] || SORT_OPTIONS['latest']
@conversations.send(sort_by).page(current_page)
end
end

View File

@@ -59,7 +59,7 @@ module Api::V1::InboxesHelper
def check_smtp_connection(channel_data, smtp)
smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password],
channel_data[:smtp_authentication]&.to_sym || :login)
smtp.finish unless smtp&.nil?
smtp.finish
end
def set_smtp_encryption(channel_data, smtp)

View File

@@ -0,0 +1,9 @@
module WidgetHelper
def build_contact_inbox_with_token(web_widget, additional_attributes = {})
contact_inbox = web_widget.create_contact_inbox(additional_attributes)
payload = { source_id: contact_inbox.source_id, inbox_id: web_widget.inbox.id }
token = ::Widget::TokenService.new(payload: payload).generate_token
[contact_inbox, token]
end
end

View File

@@ -71,6 +71,10 @@ class ContactAPI extends ApiClient {
custom_attributes: customAttributes,
});
}
destroyAvatar(contactId) {
return axios.delete(`${this.url}/${contactId}/avatar`);
}
}
export default new ContactAPI();

View File

@@ -12,6 +12,7 @@ describe('#ContactsAPI', () => {
expect(contactAPI).toHaveProperty('delete');
expect(contactAPI).toHaveProperty('getConversations');
expect(contactAPI).toHaveProperty('filter');
expect(contactAPI).toHaveProperty('destroyAvatar');
});
describeWithAPIMock('API calls', context => {
@@ -100,6 +101,13 @@ describe('#ContactsAPI', () => {
queryPayload
);
});
it('#destroyAvatar', () => {
contactAPI.destroyAvatar(1);
expect(context.axiosMock.delete).toHaveBeenCalledWith(
'/api/v1/contacts/1/avatar'
);
});
});
});

View File

@@ -383,8 +383,8 @@ $form-button-radius: var(--border-radius-normal);
// 20. Label
// ---------
$label-background: $primary-color;
$label-color: $white;
$label-background: $white;
$label-color: $black;
$label-color-alt: $black;
$label-palette: $foundation-palette;
$label-font-size: $font-size-mini;

View File

@@ -4,6 +4,7 @@
.page-sub-title {
font-size: $font-size-large;
word-wrap: break-word;
}
.block-title {

View File

@@ -99,3 +99,7 @@ $ionicons-font-path: '~ionicons/fonts';
// Transitions
$transition-ease-in: all 0.250s ease-in;
:root {
--dashboard-app-tabs-height: 3.9rem;
}

View File

@@ -1,9 +1,34 @@
.tabs--container {
display: flex;
}
.tabs--container--with-border {
@include border-normal-bottom;
}
.tabs {
@include padding($zero $space-normal);
@include border-normal-bottom;
border-left-width: 0;
border-right-width: 0;
border-top-width: 0;
display: flex;
min-width: var(--space-mega);
}
.tabs--with-scroll {
max-width: calc(100% - 64px);
overflow: hidden;
padding: 0 var(--space-smaller);
}
.tabs--scroll-button {
align-items: center;
border-radius: 0;
cursor: pointer;
display: flex;
height: auto;
justify-content: center;
min-width: var(--space-large);
}
// Tab chat type
@@ -22,6 +47,7 @@
.tabs-title {
@include margin($zero $space-slab);
flex-shrink: 0;
.badge {
background: $color-background;

View File

@@ -4,7 +4,7 @@
<h2 class="page-sub-title">
{{ headerTitle }}
</h2>
<p v-if="headerContent" class="small-12 column">
<p v-if="headerContent" class="small-12 column wrap-content">
{{ headerContent }}
</p>
<slot />
@@ -29,3 +29,8 @@ export default {
},
};
</script>
<style scoped lang="scss">
.wrap-content {
word-wrap: break-word;
}
</style>

View File

@@ -0,0 +1,63 @@
import ArticleItemComponent from './ArticleItem.vue';
const STATUS_LIST = {
published: 'published',
draft: 'draft',
archived: 'archived',
};
export default {
title: 'Components/Help Center',
component: ArticleItemComponent,
argTypes: {
title: {
defaultValue: 'Setup your account',
control: {
type: 'text',
},
},
readCount: {
defaultValue: 13,
control: {
type: 'number',
},
},
category: {
defaultValue: 'Getting started',
control: {
type: 'text',
},
},
status: {
defaultValue: 'Status',
control: {
type: 'select',
options: STATUS_LIST,
},
},
updatedAt: {
defaultValue: '1657255863',
control: {
type: 'number',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { ArticleItemComponent },
template:
'<article-item-component v-bind="$props" ></article-item-component>',
});
export const ArticleItem = Template.bind({});
ArticleItem.args = {
title: 'Setup your account',
author: {
name: 'John Doe',
},
category: 'Getting started',
readCount: 12,
status: 'published',
updatedAt: 1657255863,
};

View File

@@ -0,0 +1,129 @@
<template>
<tr>
<td>
<div class="row--article-block">
<div class="article-block">
<h6 class="sub-block-title text-truncate">
<router-link class="article-name" :to="articlePath">
{{ title }}
</router-link>
</h6>
<div class="author">
<span class="by">{{ $t('HELP_CENTER.TABLE.COLUMNS.BY') }}</span>
<span class="name">{{ articleAuthorName }}</span>
</div>
</div>
</div>
</td>
<td>{{ category }}</td>
<td>{{ readCount }}</td>
<td>
<Label :title="status" :color-scheme="labelColor" />
</td>
<td>{{ lastUpdatedAt }}</td>
</tr>
</template>
<script>
import { frontendURL } from 'dashboard/helper/URLHelper';
import Label from 'dashboard/components/ui/Label';
import timeMixin from 'dashboard/mixins/time';
export default {
components: {
Label,
},
mixins: [timeMixin],
props: {
title: {
type: String,
default: '',
required: true,
},
author: {
type: Object,
default: () => {},
},
category: {
type: String,
default: '',
},
readCount: {
type: Number,
default: 0,
},
status: {
type: String,
default: 'draft',
values: ['archived', 'draft', 'published'],
},
updatedAt: {
type: Number,
default: 0,
},
},
computed: {
lastUpdatedAt() {
return this.dynamicTime(this.updatedAt);
},
articleAuthorName() {
return this.author.name;
},
labelColor() {
switch (this.status) {
case 'archived':
return 'secondary';
case 'draft':
return 'warning';
default:
return 'success';
}
},
articlePath() {
return frontendURL(`accounts/${this.accountId}/hc/articles/${this.id}`);
},
},
};
</script>
<style lang="scss" scoped>
td {
font-weight: var(--font-weight-normal);
color: var(--s-700);
font-size: var(--font-size-mini);
padding-left: 0;
}
.row--article-block {
align-items: center;
display: flex;
text-align: left;
.article-block {
min-width: 0;
}
.sub-block-title {
margin-bottom: 0;
}
.article-name {
font-size: var(--font-size-small);
font-weight: var(--font-weight-default);
margin: 0;
text-transform: capitalize;
color: var(--s-900);
}
.author {
.by {
font-weight: var(--font-weight-normal);
color: var(--s-500);
font-size: var(--font-size-small);
}
.name {
font-weight: var(--font-weight-medium);
color: var(--s-600);
font-size: var(--font-size-mini);
}
}
}
</style>

View File

@@ -0,0 +1,72 @@
import ArticleTableComponent from './ArticleTable.vue';
import { action } from '@storybook/addon-actions';
export default {
title: 'Components/Help Center',
component: ArticleTableComponent,
argTypes: {
articles: {
defaultValue: [],
control: {
type: 'array',
},
},
articleCount: {
defaultValue: 10,
control: {
type: 'number',
},
},
currentPage: {
defaultValue: 1,
control: {
type: 'number',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { ArticleTableComponent },
template:
'<article-table-component @onPageChange="onPageChange" v-bind="$props" ></article-table-component>',
});
export const ArticleTable = Template.bind({});
ArticleTable.args = {
articles: [
{
title: 'Setup your account',
author: {
name: 'John Doe',
},
readCount: 13,
category: 'Getting started',
status: 'published',
updatedAt: 1657255863,
},
{
title: 'Docker Configuration',
author: {
name: 'Sam Manuel',
},
readCount: 13,
category: 'Engineering',
status: 'draft',
updatedAt: 1656658046,
},
{
title: 'Campaigns',
author: {
name: 'Sam Manuel',
},
readCount: 28,
category: 'Engineering',
status: 'archived',
updatedAt: 1657590446,
},
],
articleCount: 10,
currentPage: 1,
onPageChange: action('onPageChange'),
};

View File

@@ -0,0 +1,84 @@
<template>
<div class="article-container">
<table>
<thead>
<tr>
<th scope="col">{{ $t('HELP_CENTER.TABLE.HEADERS.TITLE') }}</th>
<th scope="col">{{ $t('HELP_CENTER.TABLE.HEADERS.CATEGORY') }}</th>
<th scope="col">{{ $t('HELP_CENTER.TABLE.HEADERS.READ_COUNT') }}</th>
<th scope="col">{{ $t('HELP_CENTER.TABLE.HEADERS.STATUS') }}</th>
<th scope="col">{{ $t('HELP_CENTER.TABLE.HEADERS.LAST_EDITED') }}</th>
</tr>
</thead>
<tr>
<td colspan="100%" class="horizontal-line" />
</tr>
<tbody>
<ArticleItem
v-for="article in articles"
:key="article.id"
:title="article.title"
:author="article.author"
:category="article.category"
:read-count="article.readCount"
:status="article.status"
:updated-at="article.updatedAt"
/>
</tbody>
</table>
<table-footer
:on-page-change="onPageChange"
:current-page="Number(currentPage)"
:total-count="articleCount"
/>
</div>
</template>
<script>
import ArticleItem from './ArticleItem.vue';
import TableFooter from 'dashboard/components/widgets/TableFooter';
export default {
components: {
ArticleItem,
TableFooter,
},
props: {
articles: {
type: Array,
default: () => {},
},
articleCount: {
type: Number,
default: 0,
},
currentPage: {
type: Number,
default: 1,
},
},
methods: {
onPageChange() {
this.$emit('onPageChange');
},
},
};
</script>
<style lang="scss" scoped>
.article-container {
width: 100%;
table thead th {
font-weight: var(--font-weight-bold);
text-transform: capitalize;
color: var(--s-700);
font-size: var(--font-size-small);
padding-left: 0;
}
.horizontal-line {
border-bottom: 1px solid var(--color-border);
}
.footer {
padding: 0;
}
}
</style>

View File

@@ -0,0 +1,44 @@
import { action } from '@storybook/addon-actions';
import ArticleHeader from './ArticleHeader';
export default {
title: 'Components/Help Center/Header',
component: ArticleHeader,
argTypes: {
headerTitle: {
defaultValue: 'All articles',
control: {
type: 'text',
},
},
count: {
defaultValue: 112,
control: {
type: 'number',
},
},
selectedValue: {
defaultValue: 'Status',
control: {
type: 'text',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { ArticleHeader },
template:
'<article-header v-bind="$props" @openModal="openFilterModal" @open="openDropdown" @close="closeDropdown" ></article-header>',
});
export const ArticleHeaderView = Template.bind({});
ArticleHeaderView.args = {
headerTitle: 'All articles',
count: 112,
selectedValue: 'Status',
openFilterModal: action('openedFilterModal'),
openDropdown: action('opened'),
closeDropdown: action('closed'),
};

View File

@@ -0,0 +1,169 @@
<template>
<div class="header--wrap">
<div class="header-left--wrap">
<h3 class="page-title">{{ headerTitle }}</h3>
<span class="text-block-title count-view">{{ `(${count})` }}</span>
</div>
<div class="header-right--wrap">
<woot-button
class-names="article--buttons"
icon="filter"
color-scheme="secondary"
variant="hollow"
size="small"
@click="openFilterModal"
>
{{ $t('HELP_CENTER.HEADER.FILTER') }}
</woot-button>
<woot-button
class-names="article--buttons"
icon="arrow-sort"
color-scheme="secondary"
size="small"
variant="hollow"
@click="openDropdown"
>
{{ $t('HELP_CENTER.HEADER.SORT') }}
<span class="selected-value">
{{ selectedValue }}
<Fluent-icon class="dropdown-arrow" icon="chevron-down" size="14" />
</span>
</woot-button>
<div
v-if="showSortByDropdown"
v-on-clickaway="closeDropdown"
class="dropdown-pane dropdown-pane--open"
>
<woot-dropdown-menu>
<woot-dropdown-item>
<woot-button
variant="clear"
color-scheme="secondary"
size="small"
icon="send-clock"
>
{{ 'Status' }}
</woot-button>
</woot-dropdown-item>
<woot-dropdown-item>
<woot-button
variant="clear"
color-scheme="secondary"
size="small"
icon="dual-screen-clock"
>
{{ 'Created' }}
</woot-button>
</woot-dropdown-item>
<woot-dropdown-item>
<woot-button
variant="clear"
color-scheme="secondary"
size="small"
icon="calendar-clock"
>
{{ 'Last edited' }}
</woot-button>
</woot-dropdown-item>
</woot-dropdown-menu>
</div>
<woot-button
v-tooltip.top-end="$t('HELP_CENTER.HEADER.SETTINGS_BUTTON')"
icon="settings"
class-names="article--buttons"
variant="hollow"
size="small"
color-scheme="secondary"
/>
<woot-button
class-names="article--buttons"
size="small"
color-scheme="primary"
>
{{ $t('HELP_CENTER.HEADER.NEW_BUTTON') }}
</woot-button>
</div>
</div>
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon';
export default {
components: {
FluentIcon,
WootDropdownItem,
WootDropdownMenu,
},
mixins: [clickaway],
props: {
headerTitle: {
type: String,
default: '',
},
count: {
type: Number,
default: 0,
},
selectedValue: {
type: String,
default: '',
},
},
data() {
return {
showSortByDropdown: false,
};
},
methods: {
openFilterModal() {
this.$emit('openModal');
},
openDropdown() {
this.$emit('open');
this.showSortByDropdown = true;
},
closeDropdown() {
this.$emit('close');
this.showSortByDropdown = false;
},
},
};
</script>
<style scoped lang="scss">
.header--wrap {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-small) var(--space-normal);
width: 100%;
height: var(--space-larger);
}
.header-left--wrap {
display: flex;
align-items: center;
}
.header-right--wrap {
display: flex;
align-items: center;
}
.count-view {
margin-left: var(--space-smaller);
}
.selected-value {
display: inline-flex;
margin-left: var(--space-smaller);
color: var(--b-900);
align-items: center;
}
.dropdown-arrow {
margin-left: var(--space-smaller);
}
.article--buttons {
margin-left: var(--space-smaller);
}
</style>

View File

@@ -0,0 +1,39 @@
import { action } from '@storybook/addon-actions';
import EditArticleHeader from './EditArticleHeader';
export default {
title: 'Components/Help Center/Header',
component: EditArticleHeader,
argTypes: {
backButtonLabel: {
defaultValue: 'Articles',
control: {
type: 'text',
},
},
draftState: {
defaultValue: 'saving',
control: {
type: 'text',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { EditArticleHeader },
template:
'<edit-article-header v-bind="$props" @back="onClickGoBack" @show="showPreview" @add="onClickAdd" @open="openSidebar" @close="closeSidebar" ></edit-article-header>',
});
export const EditArticleHeaderView = Template.bind({});
EditArticleHeaderView.args = {
backButtonLabel: 'Articles',
draftState: 'saving',
onClickGoBack: action('goBack'),
showPreview: action('previewOpened'),
onClickAdd: action('added'),
openSidebar: action('openedSidebar'),
closeSidebar: action('closedSidebar'),
};

View File

@@ -0,0 +1,152 @@
<template>
<div class="header--wrap">
<div class="header-left--wrap">
<woot-button
icon="chevron-left"
class-names="article--buttons"
variant="clear"
color-scheme="primary"
@click="onClickGoBack"
>
{{ backButtonLabel }}
</woot-button>
</div>
<div class="header-right--wrap">
<span v-if="showDraftStatus" class="draft-status">
{{ draftStatusText }}
</span>
<woot-button
class-names="article--buttons"
icon="globe"
color-scheme="secondary"
variant="hollow"
size="small"
@click="showPreview"
>
{{ $t('HELP_CENTER.EDIT_HEADER.PREVIEW') }}
</woot-button>
<woot-button
class-names="article--buttons"
icon="add"
color-scheme="secondary"
variant="hollow"
size="small"
@click="onClickAdd"
>
{{ $t('HELP_CENTER.EDIT_HEADER.ADD_TRANSLATION') }}
</woot-button>
<woot-button
v-if="isSidebarOpen"
v-tooltip.top-end="$t('HELP_CENTER.EDIT_HEADER.OPEN_SIDEBAR')"
icon="pane-open"
class-names="article--buttons"
variant="hollow"
size="small"
color-scheme="secondary"
@click="openSidebar"
/>
<woot-button
v-else
v-tooltip.top-end="$t('HELP_CENTER.EDIT_HEADER.CLOSE_SIDEBAR')"
icon="pane-close"
class-names="article--buttons"
variant="hollow"
size="small"
color-scheme="secondary"
@click="closeSidebar"
/>
<woot-button
class-names="article--buttons"
size="small"
color-scheme="primary"
>
{{ $t('HELP_CENTER.EDIT_HEADER.PUBLISH_BUTTON') }}
</woot-button>
</div>
</div>
</template>
<script>
export default {
props: {
backButtonLabel: {
type: String,
default: '',
},
draftState: {
type: String,
default: '',
},
},
data() {
return {
isSidebarOpen: true,
};
},
computed: {
isDraftStatusSavingOrSaved() {
return this.draftState === 'saving' || 'saved';
},
draftStatusText() {
if (this.draftState === 'saving') {
return this.$t('HELP_CENTER.EDIT_HEADER.SAVING');
}
if (this.draftState === 'saved') {
return this.$t('HELP_CENTER.EDIT_HEADER.SAVED');
}
return '';
},
showDraftStatus() {
return this.isDraftStatusSavingOrSaved;
},
},
methods: {
onClickGoBack() {
this.$emit('back');
},
showPreview() {
this.$emit('show');
},
onClickAdd() {
this.$emit('add');
},
openSidebar() {
this.$emit('open');
this.isSidebarOpen = true;
},
closeSidebar() {
this.$emit('close');
this.isSidebarOpen = false;
},
},
};
</script>
<style scoped lang="scss">
.header--wrap {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-small) var(--space-normal);
width: 100%;
height: var(--space-larger);
}
.header-left--wrap {
display: flex;
align-items: center;
}
.header-right--wrap {
display: flex;
align-items: center;
}
.article--buttons {
margin-left: var(--space-smaller);
}
.draft-status {
margin-right: var(--space-smaller);
margin-left: var(--space-normal);
color: var(--s-400);
align-items: center;
font-size: var(--font-size-mini);
}
</style>

View File

@@ -0,0 +1,134 @@
import { action } from '@storybook/addon-actions';
import Sidebar from './Sidebar';
import Thumbnail from 'dashboard/components/widgets/Thumbnail';
export default {
title: 'Components/Help Center/Sidebar',
component: { Sidebar, Thumbnail },
argTypes: {
thumbnailSrc: {
defaultValue: '',
control: {
type: 'text',
},
},
headerTitle: {
defaultValue: '',
control: {
type: 'text',
},
},
subTitle: {
defaultValue: '',
control: {
type: 'text',
},
},
accessibleMenuItems: [],
additionalSecondaryMenuItems: [],
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { Sidebar },
template: '<sidebar v-bind="$props" @input="onSearch"></sidebar>',
});
export const HelpCenterSidebarView = Template.bind({});
HelpCenterSidebarView.args = {
onSearch: action('search'),
thumbnailSrc: '',
headerTitle: 'Help Center',
subTitle: 'English',
accessibleMenuItems: [
{
icon: 'book',
label: 'HELP_CENTER.ALL_ARTICLES',
key: 'helpcenter_all',
count: 199,
toState: 'accounts/1/articles/all',
toolTip: 'All Articles',
toStateName: 'helpcenter_all',
},
{
icon: 'pen',
label: 'HELP_CENTER.MY_ARTICLES',
key: 'helpcenter_mine',
count: 112,
toState: 'accounts/1/articles/mine',
toolTip: 'My articles',
toStateName: 'helpcenter_mine',
},
{
icon: 'draft',
label: 'HELP_CENTER.DRAFT',
key: 'helpcenter_draft',
count: 32,
toState: 'accounts/1/articles/draft',
toolTip: 'Draft',
toStateName: 'helpcenter_draft',
},
{
icon: 'archive',
label: 'HELP_CENTER.ARCHIVED',
key: 'helpcenter_archive',
count: 10,
toState: 'accounts/1/articles/archived',
toolTip: 'Archived',
toStateName: 'helpcenter_archive',
},
],
additionalSecondaryMenuItems: [
{
icon: 'folder',
label: 'HELP_CENTER.CATEGORY',
hasSubMenu: true,
key: 'category',
children: [
{
id: 1,
label: 'Getting started',
count: 12,
truncateLabel: true,
toState: 'accounts/1/articles/categories/new',
},
{
id: 2,
label: 'Channel',
count: 19,
truncateLabel: true,
toState: 'accounts/1/articles/categories/channel',
},
{
id: 3,
label: 'Feature',
count: 24,
truncateLabel: true,
toState: 'accounts/1/articles/categories/feature',
},
{
id: 4,
label: 'Advanced',
count: 8,
truncateLabel: true,
toState: 'accounts/1/articles/categories/advanced',
},
{
id: 5,
label: 'Mobile app',
count: 3,
truncateLabel: true,
toState: 'accounts/1/articles/categories/mobile-app',
},
{
id: 6,
label: 'Others',
count: 39,
truncateLabel: true,
toState: 'accounts/1/articles/categories/others',
},
],
},
],
};

View File

@@ -0,0 +1,88 @@
<template>
<div class="main-nav secondary-menu">
<sidebar-header
:thumbnail-src="thumbnailSrc"
:header-title="headerTitle"
:sub-title="subTitle"
/>
<sidebar-search @input="onSearch" />
<!-- <transition-group name="menu-list" tag="ul" class="menu vertical"> -->
<div name="menu-list" tag="ul" class="menu vertical">
<secondary-nav-item
v-for="menuItem in accessibleMenuItems"
:key="menuItem.toState"
:menu-item="menuItem"
:is-help-center-sidebar="true"
/>
</div>
<div name="menu-list" tag="ul" class="menu vertical">
<secondary-nav-item
v-for="menuItem in additionalSecondaryMenuItems"
:key="menuItem.key"
:menu-item="menuItem"
:is-help-center-sidebar="true"
/>
</div>
<!-- </transition-group> -->
</div>
</template>
<script>
import SecondaryNavItem from 'dashboard/components/layout/sidebarComponents/SecondaryNavItem';
import SidebarSearch from './SidebarSearch';
import SidebarHeader from './SidebarHeader';
export default {
components: {
SecondaryNavItem,
SidebarSearch,
SidebarHeader,
},
props: {
thumbnailSrc: {
type: String,
default: '',
},
headerTitle: {
type: String,
default: '',
},
subTitle: {
type: String,
default: '',
},
accessibleMenuItems: {
type: Array,
default: () => [],
},
additionalSecondaryMenuItems: {
type: Array,
default: () => [],
},
},
data() {
return {};
},
methods: {
onSearch(value) {
this.$emit('input', value);
},
},
};
</script>
<style scoped lang="scss">
.secondary-menu {
background: var(--white);
border-right: 1px solid var(--s-50);
height: 100%;
width: var(--space-giga);
flex-shrink: 0;
overflow: hidden;
padding: var(--space-small);
&:hover {
overflow: auto;
}
}
</style>

View File

@@ -0,0 +1,113 @@
<template>
<div class="sidebar-header--wrap">
<div class="header-left--side">
<thumbnail
size="40px"
:src="thumbnailSrc"
:username="headerTitle"
variant="square"
/>
<div class="header-title--wrap">
<h4 class="sub-block-title title-view">{{ headerTitle }}</h4>
<span class="sub-title--view">{{ subTitle }}</span>
</div>
</div>
<div class="header-right--side">
<fluent-icon
icon="arrow-up-right"
size="28px"
class="pop-out--icon"
@click="popOutHelpCenter"
/>
<fluent-icon
icon="arrow-swap"
size="28px"
class="portal-switch--icon"
@click="openSwitchPortalModal"
/>
</div>
</div>
</template>
<script>
import Thumbnail from 'dashboard/components/widgets/Thumbnail';
export default {
components: {
Thumbnail,
},
props: {
thumbnailSrc: {
type: String,
default: '',
},
headerTitle: {
type: String,
default: '',
},
subTitle: {
type: String,
default: '',
},
},
methods: {
popOutHelpCenter() {
this.$emit('pop-out');
},
openSwitchPortalModal() {
this.$emit('open');
},
},
};
</script>
<style lang="scss" scoped>
.sidebar-header--wrap {
display: flex;
height: var(--space-jumbo);
align-items: center;
justify-content: space-between;
padding: var(--space-normal) 0;
border-bottom: 1px solid var(--color-border-light);
}
.header-title--wrap {
display: flex;
align-items: flex-start;
flex-direction: column;
margin-left: var(--space-small);
}
.title-view {
margin-bottom: var(--space-zero);
}
.sub-title--view {
font-size: var(--font-size-mini);
color: var(--b-600);
}
.header-left--side {
display: flex;
align-items: center;
}
.header-right--side {
display: flex;
align-items: center;
}
.pop-out--icon {
padding: var(--space-smaller);
}
.portal-switch--icon {
padding: var(--space-smaller);
margin-left: var(--space-small);
&:hover {
cursor: pointer;
background: var(--s-50);
border-radius: var(--border-radius-normal);
}
}
</style>

View File

@@ -0,0 +1,62 @@
<template>
<div class="search-input--wrap">
<div class="search-icon--wrap">
<fluent-icon icon="search" size="18" class="search-icon" />
</div>
<input
v-model="searchValue"
class="search-input"
:placeholder="$t('HELP_CENTER.SIDEBAR.SEARCH.PLACEHOLDER')"
@input="onSearch"
/>
</div>
</template>
<script>
export default {
data() {
return {
searchValue: '',
};
},
methods: {
onSearch(e) {
this.$emit('input', e.target.value);
},
},
};
</script>
<style lang="scss" scoped>
.search-input--wrap {
display: flex;
padding: var(--space-small) var(--space-zero);
width: 100%;
}
.search-input {
width: 100%;
height: var(--space-large);
border-radius: var(--border-radius-normal);
background: var(--s-25);
font-size: var(--font-size-small);
padding: var(--space-small) var(--space-small) var(--space-small)
var(--space-large);
border: 1px solid var(--s-50);
&:focus {
border-color: var(--w-500);
}
}
.search-icon--wrap {
position: relative;
}
.search-icon {
position: absolute;
color: var(--s-500);
top: var(--space-small);
left: var(--space-small);
}
</style>

View File

@@ -11,6 +11,7 @@
@open-notification-panel="openNotificationPanel"
/>
<secondary-sidebar
v-if="showSecondarySidebar"
:account-id="accountId"
:inboxes="inboxes"
:labels="labels"
@@ -50,6 +51,12 @@ export default {
SecondarySidebar,
},
mixins: [adminMixin, alertMixin, eventListenerMixins],
props: {
showSecondarySidebar: {
type: Boolean,
default: true,
},
},
data() {
return {
showOptionsMenu: false,
@@ -191,6 +198,7 @@ export default {
display: flex;
min-height: 0;
height: 100%;
width: fit-content;
}
</style>

View File

@@ -18,6 +18,7 @@ const settings = accountId => ({
'settings_integrations_webhook',
'settings_integrations_integration',
'settings_applications',
'settings_integrations_dashboard_apps',
'settings_applications_webhook',
'settings_applications_integration',
'general_settings',

View File

@@ -83,7 +83,7 @@ export default {
border-bottom-right-radius: var(--border-radius-normal);
display: flex;
height: 100%;
justify-content: end;
justify-content: flex-end;
opacity: 1;
position: absolute;
right: 0;

View File

@@ -26,6 +26,9 @@
:class="{ 'text-truncate': shouldTruncate }"
>
{{ label }}
<span v-if="isHelpCenterSidebar && childItemCount" class="count-view">
{{ childItemCount }}
</span>
</span>
<span v-if="count" class="badge" :class="{ secondary: !isActive }">
{{ count }}
@@ -73,6 +76,14 @@ export default {
type: String,
default: '',
},
isHelpCenterSidebar: {
type: Boolean,
default: false,
},
childItemCount: {
type: Number,
default: 0,
},
},
computed: {
showIcon() {
@@ -146,6 +157,7 @@ $label-badge-size: var(--space-slab);
height: $label-badge-size;
min-width: $label-badge-size;
margin-left: var(--space-smaller);
border: 1px solid var(--color-border-light);
}
.badge.secondary {
@@ -154,4 +166,19 @@ $label-badge-size: var(--space-slab);
color: var(--s-600);
font-weight: var(--font-weight-bold);
}
.count-view {
background: var(--s-50);
border-radius: var(--border-radius-normal);
color: var(--s-600);
font-size: var(--font-size-micro);
font-weight: var(--font-weight-bold);
margin-left: var(--space-smaller);
padding: var(--space-zero) var(--space-smaller);
&.is-active {
background: var(--w-50);
color: var(--w-500);
}
}
</style>

View File

@@ -1,8 +1,14 @@
<template>
<li class="sidebar-item">
<span v-if="hasSubMenu" class="secondary-menu--title fs-small">
{{ $t(`SIDEBAR.${menuItem.label}`) }}
</span>
<div v-if="hasSubMenu" class="secondary-menu--wrap">
<span class="secondary-menu--title fs-small">
{{ $t(`SIDEBAR.${menuItem.label}`) }}
</span>
<div v-if="isHelpCenterSidebar" class="submenu-icons">
<fluent-icon icon="search" class="submenu-icon" size="16" />
<fluent-icon icon="add" class="submenu-icon" size="16" />
</div>
</div>
<router-link
v-else
class="secondary-menu--title secondary-menu--link fs-small"
@@ -15,6 +21,13 @@
size="14"
/>
{{ $t(`SIDEBAR.${menuItem.label}`) }}
<span
v-if="isHelpCenterSidebar"
class="count-view"
:class="computedClass"
>
{{ `${menuItem.count}` }}
</span>
<span
v-if="menuItem.label === 'AUTOMATION'"
data-view-component="true"
@@ -35,6 +48,8 @@
:should-truncate="child.truncateLabel"
:icon="computedInboxClass(child)"
:warning-icon="computedInboxErrorClass(child)"
:is-help-center-sidebar="isHelpCenterSidebar"
:child-item-count="child.count"
/>
<router-link
v-if="showItem(menuItem)"
@@ -79,6 +94,10 @@ export default {
type: Object,
default: () => ({}),
},
isHelpCenterSidebar: {
type: Boolean,
default: false,
},
},
computed: {
...mapGetters({ activeInbox: 'getSelectedInbox' }),
@@ -120,17 +139,19 @@ export default {
// If active Inbox is present
// donot highlight conversations
if (this.activeInbox) return ' ';
if (
this.isInboxConversation ||
this.isTeamsSettings ||
this.isInboxsSettings ||
this.isIntegrationsSettings ||
this.isApplicationsSettings
) {
return 'is-active';
if (this.hasSubMenu) {
if (
this.isInboxConversation ||
this.isTeamsSettings ||
this.isInboxsSettings ||
this.isIntegrationsSettings ||
this.isApplicationsSettings
) {
return 'is-active';
}
return ' ';
}
return ' ';
return '';
},
},
methods: {
@@ -170,6 +191,11 @@ export default {
margin: var(--space-smaller) 0 0;
}
.secondary-menu--wrap {
display: flex;
justify-content: space-between;
}
.secondary-menu--title {
color: var(--s-600);
display: flex;
@@ -242,6 +268,7 @@ export default {
color: var(--w-500);
}
}
.beta {
padding-right: var(--space-smaller) !important;
padding-left: var(--space-smaller) !important;
@@ -255,4 +282,34 @@ export default {
color: var(--g-800);
border-color: var(--g-700);
}
.count-view {
background: var(--s-50);
border-radius: var(--border-radius-normal);
color: var(--s-600);
font-size: var(--font-size-micro);
font-weight: var(--font-weight-bold);
margin-left: var(--space-smaller);
padding: var(--space-zero) var(--space-smaller);
&.is-active {
background: var(--w-50);
color: var(--w-500);
}
}
.submenu-icons {
display: flex;
align-items: center;
.submenu-icon {
margin-left: var(--space-small);
color: var(--s-600);
&:hover {
cursor: pointer;
color: var(--w-500);
}
}
}
</style>

View File

@@ -1,13 +1,18 @@
<template>
<div :class="labelClass" :style="labelStyle" :title="description">
<button v-if="icon" class="label-action--button" @click="onClick">
<span v-if="icon" class="label-action--button">
<fluent-icon :icon="icon" size="12" class="label--icon" />
</button>
</span>
<span
v-if="variant === 'smooth'"
:style="{ background: color }"
class="label-color-dot"
/>
<span v-if="!href">{{ title }}</span>
<a v-else :href="href" :style="anchorStyle">{{ title }}</a>
<button
v-if="showClose"
class="label-action--button"
class="label-close--button "
:style="{ color: textColor }"
@click="onClick"
>
@@ -48,14 +53,23 @@ export default {
type: String,
default: '',
},
color: {
type: String,
default: '',
},
colorScheme: {
type: String,
default: '',
},
variant: {
type: String,
default: '',
},
},
computed: {
textColor() {
return getContrastingTextColor(this.bgColor);
if (this.variant === 'smooth') return '';
return this.color || getContrastingTextColor(this.bgColor);
},
labelClass() {
return `label ${this.colorScheme} ${this.small ? 'small' : ''}`;
@@ -94,9 +108,17 @@ export default {
font-weight: var(--font-weight-medium);
margin-right: var(--space-smaller);
margin-bottom: var(--space-smaller);
padding: var(--space-smaller);
background: var(--s-50);
color: var(--s-800);
border: 1px solid var(--s-75);
height: var(--space-medium);
&.small {
font-size: var(--font-size-micro);
padding: var(--space-micro) var(--space-smaller);
line-height: 1.2;
letter-spacing: 0.15px;
}
.label--icon {
@@ -104,11 +126,6 @@ export default {
margin-right: var(--space-smaller);
}
.close--icon {
cursor: pointer;
margin-left: var(--space-smaller);
}
&.small .label--icon,
&.small .close--icon {
font-size: var(--font-size-nano);
@@ -164,7 +181,31 @@ export default {
}
}
.label-close--button {
color: var(--s-800);
margin-bottom: var(--space-minus-micro);
margin-left: var(--space-smaller);
border-radius: var(--border-radius-small);
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
&:hover {
background: var(--s-100);
}
}
.label-action--button {
margin-bottom: var(--space-minus-micro);
}
.label-color-dot {
display: inline-block;
width: var(--space-one);
height: var(--space-one);
border-radius: var(--border-radius-small);
margin-right: var(--space-smaller);
}
</style>

View File

@@ -5,8 +5,61 @@ export default {
type: Number,
default: 0,
},
border: {
type: Boolean,
default: true,
},
},
render() {
data() {
return { hasScroll: false };
},
created() {
window.addEventListener('resize', this.computeScrollWidth);
},
beforeDestroy() {
window.removeEventListener('resize', this.computeScrollWidth);
},
mounted() {
this.computeScrollWidth();
},
methods: {
computeScrollWidth() {
const tabElement = this.$el.getElementsByClassName('tabs')[0];
this.hasScroll = tabElement.scrollWidth > tabElement.clientWidth;
},
onScrollClick(direction) {
const tabElement = this.$el.getElementsByClassName('tabs')[0];
let scrollPosition = tabElement.scrollLeft;
if (direction === 'left') {
scrollPosition -= 100;
} else {
scrollPosition += 100;
}
tabElement.scrollTo({
top: 0,
left: scrollPosition,
behavior: 'smooth',
});
},
createScrollButton(createElement, direction) {
if (!this.hasScroll) {
return false;
}
return createElement(
'button',
{
class: 'tabs--scroll-button button clear secondary button--only-icon',
on: { click: () => this.onScrollClick(direction) },
},
[
createElement('fluent-icon', {
props: { icon: `chevron-${direction}`, size: 16 },
}),
]
);
},
},
render(createElement) {
const Tabs = this.$slots.default
.filter(
node =>
@@ -18,14 +71,21 @@ export default {
data.index = index;
return node;
});
const leftButton = this.createScrollButton(createElement, 'left');
const rightButton = this.createScrollButton(createElement, 'right');
return (
<ul
<div
class={{
tabs: true,
'tabs--container--with-border': this.border,
'tabs--container': true,
}}
>
{Tabs}
</ul>
{leftButton}
<ul class={{ tabs: true, 'tabs--with-scroll': this.hasScroll }}>
{Tabs}
</ul>
{rightButton}
</div>
);
},
};

View File

@@ -1,6 +1,7 @@
<template>
<button
class="button"
:type="type"
:class="buttonClasses"
:disabled="isDisabled || isLoading"
@click="handleClick"
@@ -24,6 +25,10 @@ export default {
name: 'WootButton',
components: { EmojiOrIcon, Spinner },
props: {
type: {
type: String,
default: 'submit',
},
variant: {
type: String,
default: '',

View File

@@ -40,13 +40,18 @@ export default {
type: Boolean,
default: true,
},
variant: {
type: String,
default: 'circle',
},
},
computed: {
style() {
let style = {
width: `${this.size}px`,
height: `${this.size}px`,
borderRadius: this.rounded ? '50%' : 0,
borderRadius:
this.variant === 'square' ? 'var(--border-radius-large)' : '50%',
lineHeight: `${this.size + Math.floor(this.size / 20)}px`,
fontSize: `${Math.floor(this.size / 2.5)}px`,
};

View File

@@ -7,7 +7,8 @@
:title="label.title"
:description="label.description"
:show-close="true"
:bg-color="label.color"
:color="label.color"
variant="smooth"
@click="removeItem"
/>
<div class="dropdown-wrap">

View File

@@ -0,0 +1,61 @@
import Thumbnail from './Thumbnail.vue';
export default {
title: 'Components/Thumbnail',
component: Thumbnail,
argTypes: {
src: {
control: {
type: 'text',
},
},
size: {
control: {
type: 'text',
},
},
badge: {
control: {
type: 'select',
options: ['fb', 'whatsapp', 'sms', 'twitter-tweet', 'twitter-dm'],
},
},
variant: {
control: {
type: 'select',
options: ['circle', 'square'],
},
},
username: {
defaultValue: 'John Doe',
control: {
type: 'text',
},
},
status: {
defaultValue: 'circle',
control: {
type: 'select',
options: ['online', 'busy'],
},
},
hasBorder: {
control: {
type: 'boolean',
},
},
shouldShowStatusAlways: {
control: {
type: 'boolean',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { Thumbnail },
template: '<thumbnail v-bind="$props" @click="onClick">{{label}}</thumbnail>',
});
export const Primary = Template.bind({});

View File

@@ -12,6 +12,7 @@
:username="username"
:class="thumbnailClass"
:size="avatarSize"
:variant="variant"
/>
<img
v-if="badge === 'instagram_direct_message'"
@@ -119,6 +120,10 @@ export default {
type: Boolean,
default: false,
},
variant: {
type: String,
default: 'circle',
},
},
data() {
return {
@@ -145,7 +150,9 @@ export default {
},
thumbnailClass() {
const classname = this.hasBorder ? 'border' : '';
return `user-thumbnail ${classname}`;
const variant =
this.variant === 'circle' ? 'thumbnail-rounded' : 'thumbnail-square';
return `user-thumbnail ${classname} ${variant}`;
},
},
watch: {
@@ -173,6 +180,9 @@ export default {
.user-thumbnail {
border-radius: 50%;
&.thumbnail-square {
border-radius: var(--border-radius-large);
}
height: 100%;
width: 100%;
box-sizing: border-box;

View File

@@ -141,6 +141,7 @@ export default {
.dashboard-app--tabs {
background: var(--white);
margin-top: -1px;
min-height: var(--dashboard-app-tabs-height);
}
.messages-and-sidebar {

View File

@@ -66,7 +66,7 @@
<spinner v-if="isPending" size="tiny" />
<div
v-if="showAvatar"
v-tooltip.top="tooltipForSender"
v-tooltip.left="tooltipForSender"
class="sender--info"
>
<woot-thumbnail
@@ -313,7 +313,6 @@ export default {
return showTooltip
? {
content: `${this.$t('CONVERSATION.SENT_BY')} ${name}`,
classes: 'top',
}
: false;
},

View File

@@ -22,7 +22,10 @@
/>
</div>
<div class="container">
<div v-if="uiFlags.isUpdating" class="agent__list-loading">
<div
v-if="assignableAgentsUiFlags.isFetching"
class="agent__list-loading"
>
<spinner />
<p>{{ $t('BULK_ACTION.AGENT_LIST_LOADING') }}</p>
</div>
@@ -43,7 +46,8 @@
<li v-for="agent in filteredAgents" :key="agent.id">
<div class="agent-list-item" @click="assignAgent(agent)">
<thumbnail
src="agent.thumbnail"
:src="agent.thumbnail"
:status="agent.availability_status"
:username="agent.name"
size="22px"
class="margin-right-small"
@@ -171,7 +175,7 @@ export default {
transform-origin: top right;
width: auto;
z-index: var(--z-index-twenty);
min-width: var(--space-giga);
.header {
padding: var(--space-one);
@@ -182,7 +186,7 @@ export default {
}
.container {
max-height: 24rem;
max-height: var(--space-giga);
overflow-y: auto;
.agent__list-container {
height: 100%;
@@ -264,5 +268,6 @@ ul {
align-items: center;
justify-content: center;
flex-direction: column;
padding: var(--space-two);
}
</style>

View File

@@ -50,7 +50,7 @@
:value="label.title"
class="label-checkbox"
/>
<span class="label-title">{{ label.title }}</span>
<span class="label-title text-truncate">{{ label.title }}</span>
<span
class="label-pill"
:style="{ backgroundColor: label.color }"
@@ -110,7 +110,7 @@ export default {
.labels-list {
display: flex;
flex-direction: column;
max-height: 24rem;
max-height: var(--space-giga);
min-height: auto;
.labels-list__header {
@@ -157,8 +157,8 @@ export default {
border-radius: var(--border-radius-large);
border: 1px solid var(--s-50);
box-shadow: var(--shadow-dropdown-pane);
max-width: 24rem;
min-width: 24rem;
max-width: var(--space-giga);
min-width: var(--space-giga);
position: absolute;
right: 4.5rem;
top: var(--space-larger);
@@ -176,7 +176,7 @@ export default {
}
.container {
max-height: 24rem;
max-height: var(--space-giga);
overflow-y: auto;
.label__list-container {
@@ -204,7 +204,7 @@ export default {
.triangle {
display: block;
position: absolute;
right: 2rem;
right: var(--space-two);
text-align: left;
top: calc(var(--space-slab) * -1);
z-index: var(--z-index-one);
@@ -249,6 +249,7 @@ ul {
.label-title {
flex-grow: 1;
width: 100%;
}
.label-pill {
@@ -256,6 +257,8 @@ ul {
border-radius: var(--border-radius-medium);
height: var(--space-slab);
width: var(--space-slab);
flex-shrink: 0;
border: 1px solid var(--color-border-light);
}
}
}

View File

@@ -3,12 +3,18 @@
<label>
<span v-if="label">{{ label }}</span>
</label>
<woot-thumbnail v-if="src" size="80px" :src="src" />
<woot-thumbnail
v-if="src"
size="80px"
:src="src"
:username="usernameAvatar"
/>
<div v-if="src && deleteAvatar" class="avatar-delete-btn">
<woot-button
color-scheme="alert"
variant="hollow"
size="tiny"
type="button"
@click="onAvatarDelete"
>
{{ this.$t('INBOX_MGMT.DELETE.AVATAR_DELETE_BUTTON_TEXT') }}
@@ -38,6 +44,10 @@ export default {
type: String,
default: '',
},
usernameAvatar: {
type: String,
default: '',
},
deleteAvatar: {
type: Boolean,
default: false,
@@ -50,7 +60,7 @@ export default {
this.$emit('change', {
file,
url: URL.createObjectURL(file),
url: file ? URL.createObjectURL(file) : null,
});
},
onAvatarDelete() {

View File

@@ -2,10 +2,10 @@
<modal :show.sync="show" :on-close="onClose">
<woot-modal-header :header-title="title" :header-content="message" />
<div class="modal-footer delete-item">
<button class="alert button nice" @click="onConfirm">
<button class="alert button nice text-truncate" @click="onConfirm">
{{ confirmText }}
</button>
<button class="button clear" @click="onClose">
<button class="button clear text-truncate" @click="onClose">
{{ rejectText }}
</button>
</div>

View File

@@ -12,7 +12,7 @@ export const getInboxClassByType = (type, phoneNumber) => {
return 'brand-twitter';
case INBOX_TYPES.TWILIO:
return phoneNumber.startsWith('whatsapp')
return phoneNumber?.startsWith('whatsapp')
? 'brand-whatsapp'
: 'brand-sms';

View File

@@ -1,17 +1,29 @@
{
"BULK_ACTION": {
"CONVERSATIONS_SELECTED": "%{conversationCount} المحادثات المحددة",
"AGENT_SELECT_LABEL": "اختر وكيل",
"ASSIGN_CONFIRMATION_LABEL": "هل أنت متأكد من أنك تريد تعيين %{conversationCount} %{conversationLabel} إلى",
"GO_BACK_LABEL": "العودة للخلف",
"ASSIGN_LABEL": "تكليف",
"ASSIGN_AGENT_TOOLTIP": "إسناد وكيل",
"RESOLVE_TOOLTIP": "إغلاق المحادثة",
"ASSIGN_SUCCESFUL": "تم تعيين المحادثات بنجاح",
"ASSIGN_FAILED": "فشل في تعيين المحادثات، الرجاء المحاولة مرة أخرى",
"RESOLVE_SUCCESFUL": "تم تسوية المحادثات بنجاح",
"RESOLVE_FAILED": "فشل في حل المحادثات، يرجى المحاولة مرة أخرى",
"ALL_CONVERSATIONS_SELECTED_ALERT": "المحادثات المرئية في هذه الصفحة هي المحددة فقط.",
"AGENT_LIST_LOADING": "تحميل الوكلاء"
"BULK_ACTION": {
"CONVERSATIONS_SELECTED": "%{conversationCount} المحادثات المحددة",
"AGENT_SELECT_LABEL": "اختر وكيل",
"ASSIGN_CONFIRMATION_LABEL": "هل أنت متأكد من أنك تريد تعيين %{conversationCount} %{conversationLabel} إلى",
"GO_BACK_LABEL": "العودة للخلف",
"ASSIGN_LABEL": "تكليف",
"ASSIGN_AGENT_TOOLTIP": "إسناد وكيل",
"ASSIGN_SUCCESFUL": "تم تعيين المحادثات بنجاح",
"ASSIGN_FAILED": "فشل في تعيين المحادثات، الرجاء المحاولة مرة أخرى",
"RESOLVE_SUCCESFUL": "تم تسوية المحادثات بنجاح",
"RESOLVE_FAILED": "فشل في حل المحادثات، يرجى المحاولة مرة أخرى",
"ALL_CONVERSATIONS_SELECTED_ALERT": "المحادثات المرئية في هذه الصفحة هي المحددة فقط.",
"AGENT_LIST_LOADING": "تحميل الوكلاء",
"UPDATE": {
"CHANGE_STATUS": "تغيير الحالة",
"SNOOZE_UNTIL_NEXT_REPLY": "غفوة حتى الرد القادم",
"UPDATE_SUCCESFUL": "تم تحديث حالة المحادثة بنجاح.",
"UPDATE_FAILED": "فشل تحديث المحادثات، الرجاء المحاولة مرة أخرى"
},
"LABELS": {
"ASSIGN_LABELS": "تعيين التسميات",
"NO_LABELS_FOUND": "لم يتم العثور على تسميات لـ",
"ASSIGN_SELECTED_LABELS": "تعيين التسميات المحددة",
"ASSIGN_SUCCESFUL": "تم تعيين التسميات بنجاح",
"ASSIGN_FAILED": "فشل في تعيين التسميات ، الرجاء المحاولة مرة أخرى"
}
}
}

View File

@@ -111,7 +111,8 @@
"EMAIL_ADDRESS": {
"PLACEHOLDER": "أدخل عنوان البريد الإلكتروني الخاص بجهة الاتصال",
"LABEL": "عنوان البريد الإلكتروني",
"DUPLICATE": "عنوان البريد الإلكتروني هذا مستخدم لجهة اتصال أخرى."
"DUPLICATE": "عنوان البريد الإلكتروني هذا مستخدم لجهة اتصال أخرى.",
"ERROR": "الرجاء إدخال عنوان بريد إلكتروني صحيح."
},
"PHONE_NUMBER": {
"PLACEHOLDER": "أدخل رقم الهاتف الخاص بجهة الاتصال",
@@ -147,6 +148,12 @@
}
}
},
"DELETE_AVATAR": {
"API": {
"SUCCESS_MESSAGE": "Contact avatar deleted successfully",
"ERROR_MESSAGE": "Could not delete the contact avatar. Please try again later."
}
},
"SUCCESS_MESSAGE": "تم حفظ جهة الاتصال بنجاح",
"ERROR_MESSAGE": "حدث خطأ، الرجاء المحاولة مرة أخرى"
},

View File

@@ -38,7 +38,8 @@
"CUSTOM_ATTRIBUTE_LINK": "الرابط",
"CUSTOM_ATTRIBUTE_CHECKBOX": "مربع",
"CREATED_AT": "تم إنشاؤها في",
"LAST_ACTIVITY": "آخر نشاط"
"LAST_ACTIVITY": "آخر نشاط",
"REFERER_LINK": "Referrer link"
},
"GROUPS": {
"STANDARD_FILTERS": "الفلاتر القياسية",

View File

@@ -0,0 +1,24 @@
{
"HELP_CENTER": {
"HEADER": {
"FILTER": "Filter by",
"SORT": "Sort by",
"SETTINGS_BUTTON": "الإعدادات",
"NEW_BUTTON": "New Article"
},
"EDIT_HEADER": {
"PUBLISH_BUTTON": "Publish",
"PREVIEW": "Preview",
"ADD_TRANSLATION": "Add translation",
"OPEN_SIDEBAR": "Open sidebar",
"CLOSE_SIDEBAR": "Close sidebar",
"SAVING": "Draft saving...",
"SAVED": "Draft saved"
},
"TABLE": {
"COLUMNS": {
"BY": "by"
}
}
}
}

View File

@@ -30,7 +30,8 @@
"ADD": {
"CHANNEL_NAME": {
"LABEL": "اسم صندوق الوارد لقناة التواصل",
"PLACEHOLDER": "أدخل اسم صندوق الوارد الخاص بك (مثال: Acme Inc)"
"PLACEHOLDER": "أدخل اسم صندوق الوارد الخاص بك (مثال: Acme Inc)",
"ERROR": "الرجاء إدخال اسم صندوق الوارد صالح"
},
"WEBSITE_NAME": {
"LABEL": "اسم الموقع",
@@ -82,7 +83,7 @@
},
"CHANNEL_GREETING_TOGGLE": {
"LABEL": "تفعيل رسالة الترحيب التلقائية",
"HELP_TEXT": "إرسال رسالة ترحيب إلى المستخدم عند بدء المحادثة.",
"HELP_TEXT": "إرسال رسالة تحية تلقائياً عند إنشاء محادثة جديدة.",
"ENABLED": "مفعل",
"DISABLED": "معطّل"
},
@@ -97,7 +98,10 @@
"LABEL": "لون صندوق الدردشة",
"PLACEHOLDER": "تحديث اللون الرئيسي لصندوق الدردشة"
},
"SUBMIT_BUTTON": "إنشاء قناة تواصل"
"SUBMIT_BUTTON": "إنشاء قناة تواصل",
"API": {
"ERROR_MESSAGE": "لم نتمكن من إنشاء قناة موقع، الرجاء المحاولة مرة أخرى"
}
},
"TWILIO": {
"TITLE": "قناة Twilio SMS/WhatsApp",
@@ -107,6 +111,12 @@
"PLACEHOLDER": "الرجاء إدخال معرف حساب Twilio الخاص بك (يعرف أيضاً بـ Account SID)",
"ERROR": "هذا الحقل مطلوب"
},
"MESSAGING_SERVICE_SID": {
"LABEL": "Messaging Service SID",
"PLACEHOLDER": "Please enter your Twilio Messaging Service SID",
"ERROR": "هذا الحقل مطلوب",
"USE_MESSAGING_SERVICE": "Use a Twilio Messaging Service"
},
"CHANNEL_TYPE": {
"LABEL": "نوع القناة",
"ERROR": "الرجاء تحديد نوع القناة"
@@ -193,6 +203,7 @@
"PROVIDERS": {
"LABEL": "API Provider",
"TWILIO": "تويليو",
"WHATSAPP_CLOUD": "واتساب السحابة",
"360_DIALOG": "360dialog"
},
"INBOX_NAME": {
@@ -205,13 +216,31 @@
"PLACEHOLDER": "الرجاء إدخال رقم الهاتف الذي سيتم إرسال الرسائل منه.",
"ERROR": "الرجاء إدخال قيمة صحيحة. يجب أن يبدأ رقم الهاتف بعلامة `+`."
},
"PHONE_NUMBER_ID": {
"LABEL": "رقم الهاتف",
"PLACEHOLDER": "الرجاء إدخال رقم الهاتف الذي تم الحصول عليه من لوحة تحكم مطور الفيسبوك.",
"ERROR": "الرجاء إدخال اسم صالح."
},
"BUSINESS_ACCOUNT_ID": {
"LABEL": "معرف حساب الأعمال",
"PLACEHOLDER": "الرجاء إدخال معرف حساب الأعمال الذي تم الحصول عليه من لوحة تحكم مطور الفيسبوك.",
"ERROR": "الرجاء إدخال اسم صالح."
},
"WEBHOOK_VERIFY_TOKEN": {
"LABEL": "رمز التحقق من Webhook",
"PLACEHOLDER": "أدخل رمز التحقق الذي تريد إعداده لفيسبوك على شبكة الويب.",
"ERROR": "الرجاء إدخال اسم صالح."
},
"API_KEY": {
"LABEL": "مفتاح API",
"SUBTITLE": "تكوين مفتاح واتسآب API.",
"PLACEHOLDER": "مفتاح API",
"APPLY_FOR_ACCESS": "ليس لديك أي مفتاح API؟ تقدم بطلب الوصول إلى هنا",
"ERROR": "الرجاء إدخال اسم صالح."
},
"API_CALLBACK": {
"TITLE": "عنوان Callback URL",
"SUBTITLE": "يجب عليك تكوين URL webhook في بوابة مطور فيسبوك مع عنوان URL المذكور هنا."
},
"SUBMIT_BUTTON": "إنشاء قناة واتساب",
"API": {
"ERROR_MESSAGE": "لم نتمكن من حفظ قناة واتساب"
@@ -298,7 +327,7 @@
},
"AUTH": {
"TITLE": "اختر قناة",
"DESC": "ندعم أداة الدردشة المباشرة، صفحة الفيسبوك، ملف تويتر الشخصي، واتسب، البريد الإلكتروني وما إلى ذلك، كقنوات. إذا كنت ترغب في إنشاء قناة مخصصة، يمكنك إنشاءها باستخدام قناة API. حدد قناة واحدة من الخيارات أدناه للمتابعة."
"DESC": "يدعم أدوات الدردشة الحية، والميسنجر الفيسبوك، وملفات التويتر الشخصية، و WhatsApp، ورسائل البريد الإلكتروني، إلخ، كقنوات. إذا كنت ترغب في إنشاء قناة مخصصة، يمكنك إنشاءها باستخدام قناة API. للبدء، اختر إحدى القنوات أدناه."
},
"AGENTS": {
"TITLE": "موظف الدعم",
@@ -339,7 +368,7 @@
"API": {
"SUCCESS_MESSAGE": "تم تحديث إعدادات قناة التواصل بنجاح",
"AUTO_ASSIGNMENT_SUCCESS_MESSAGE": "تم تحديث إعدادات الإسناد التلقائي بنجاح",
"ERROR_MESSAGE": "تعذر تحديث لون صندوق الدردشة. الرجاء المحاولة مرة أخرى لاحقاً."
"ERROR_MESSAGE": "تعذر تحديث إعدادات صندوق الوارد. الرجاء المحاولة مرة أخرى لاحقاً."
},
"EMAIL_COLLECT_BOX": {
"ENABLED": "مفعل",
@@ -412,15 +441,22 @@
"INBOX_UPDATE_SUB_TEXT": "تحديث إعدادات قناة التواصل",
"AUTO_ASSIGNMENT_SUB_TEXT": "تمكين أو تعطيل الإسناد التلقائي للمحادثات الجديدة إلى الموظفين المضافين إلى قناة التواصل هذه.",
"HMAC_VERIFICATION": "التحقق من هوية المستخدم",
"HMAC_DESCRIPTION": "من أجل التحقق من هوية المستخدم، يسمح لك SDK بتمرير 'المعرفة_hash' لكل مستخدم. يمكنك إنشاء HMAC باستخدام 'sha256' مع المفتاح المعروض هنا.",
"HMAC_DESCRIPTION": "من أجل التحقق من هوية المستخدم، يمكنك تمرير 'IDer_hash` لكل مستخدم. يمكنك إنشاء تجزئة HMAC sha256 باستخدام \"المعرف\" مع المفتاح المعروض هنا.",
"HMAC_MANDATORY_VERIFICATION": "فرض التحقق من هوية المستخدم",
"HMAC_MANDATORY_DESCRIPTION": "في حالة التمكين، لن تعمل طريقة حساب SDKs إلا إذا تم توفير `IDer_hash` لكل مستخدم.",
"HMAC_MANDATORY_DESCRIPTION": "في حالة التمكين، سيتم رفض الطلبات المفقودة 'IDer_hash'.",
"INBOX_IDENTIFIER": "معرف صندوق الوارد",
"INBOX_IDENTIFIER_SUB_TEXT": "استخدم رمز 'inbox_identifier' المعروض هنا للمصادقة على عملاء API الخاص بك.",
"FORWARD_EMAIL_TITLE": "إعادة التوجيه إلى البريد الإلكتروني",
"FORWARD_EMAIL_SUB_TEXT": "بدء إعادة توجيه رسائل البريد الإلكتروني الخاصة بك إلى عنوان البريد الإلكتروني التالي.",
"ALLOW_MESSAGES_AFTER_RESOLVED": "السماح بالرسائل بعد حل المحادثة",
"ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT": "السماح للمستخدمين النهائيين بإرسال رسائل حتى بعد تسوية المحادثة."
"ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT": "السماح للمستخدمين النهائيين بإرسال رسائل حتى بعد تسوية المحادثة.",
"WHATSAPP_SECTION_SUBHEADER": "يتم استخدام مفتاح API هذا للتكامل مع واتسب APIs.",
"WHATSAPP_SECTION_TITLE": "مفتاح API"
},
"AUTO_ASSIGNMENT": {
"MAX_ASSIGNMENT_LIMIT": "حد الإسناد التلقائي",
"MAX_ASSIGNMENT_LIMIT_RANGE_ERROR": "الرجاء إدخال قيمة أكبر من 0",
"MAX_ASSIGNMENT_LIMIT_SUB_TEXT": "تحديد الحد الأقصى لعدد المحادثات من علبة الوارد هذه التي يمكن تعيينها تلقائياً إلى وكيل"
},
"FACEBOOK_REAUTHORIZE": {
"TITLE": "إعادة التصريح",

View File

@@ -84,6 +84,52 @@
},
"CONNECT": {
"BUTTON_TEXT": "ربط الاتصال"
},
"DASHBOARD_APPS": {
"TITLE": "Dashboard Apps",
"HEADER_BTN_TXT": "Add a new dashboard app",
"SIDEBAR_TXT": "<p><b>Dashboard Apps</b></p><p>Dashboard Apps allow organizations to embed an application inside the Chatwoot dashboard to provide the context for customer support agents. This feature allows you to create an application independently and embed that inside the dashboard to provide user information, their orders, or their previous payment history.</p><p>When you embed your application using the dashboard in Chatwoot, your application will get the context of the conversation and contact as a window event. Implement a listener for the message event on your page to receive the context.</p><p>To add a new dashboard app, click on the button 'Add a new dashboard app'.</p>",
"DESCRIPTION": "Dashboard Apps allow organizations to embed an application inside the dashboard to provide the context for customer support agents. This feature allows you to create an application independently and embed that to provide user information, their orders, or their previous payment history.",
"LIST": {
"404": "There are no dashboard apps configured on this account yet",
"LOADING": "Fetching dashboard apps...",
"TABLE_HEADER": [
"الاسم",
"Endpoint"
],
"EDIT_TOOLTIP": "Edit app",
"DELETE_TOOLTIP": "Delete app"
},
"FORM": {
"TITLE_LABEL": "الاسم",
"TITLE_PLACEHOLDER": "Enter a name for your dashboard app",
"TITLE_ERROR": "A name for the dashboard app is required",
"URL_LABEL": "Endpoint",
"URL_PLACEHOLDER": "Enter the endpoint URL where your app is hosted",
"URL_ERROR": "A valid URL is required"
},
"CREATE": {
"HEADER": "Add a new dashboard app",
"FORM_SUBMIT": "إرسال",
"FORM_CANCEL": "إلغاء",
"API_SUCCESS": "Dashboard app configured successfully",
"API_ERROR": "We couldn't create an app. Please try again later"
},
"UPDATE": {
"HEADER": "Edit dashboard app",
"FORM_SUBMIT": "تحديث",
"FORM_CANCEL": "إلغاء",
"API_SUCCESS": "Dashboard app updated successfully",
"API_ERROR": "We couldn't update the app. Please try again later"
},
"DELETE": {
"CONFIRM_YES": "Yes, delete it",
"CONFIRM_NO": "No, keep it",
"TITLE": "تأكيد الحذف",
"MESSAGE": "Are you sure to delete the app - %{appName}?",
"API_SUCCESS": "Dashboard app deleted successfully",
"API_ERROR": "We couldn't delete the app. Please try again later"
}
}
}
}

View File

@@ -65,6 +65,7 @@
"PLACEHOLDER": "اختر نطاق المدة"
},
"GROUP_BY_FILTER_DROPDOWN_LABEL": "تجميع بواسطة",
"DURATION_FILTER_LABEL": "المدة",
"GROUP_BY_DAY_OPTIONS": [
{
"id": 1,

View File

@@ -1,17 +1,29 @@
{
"BULK_ACTION": {
"CONVERSATIONS_SELECTED": "%{conversationCount} conversations selected",
"AGENT_SELECT_LABEL": "Select Agent",
"ASSIGN_CONFIRMATION_LABEL": "Are you sure you want to assign %{conversationCount} %{conversationLabel} to",
"GO_BACK_LABEL": "Go back",
"ASSIGN_LABEL": "Assign",
"ASSIGN_AGENT_TOOLTIP": "Assign Agent",
"RESOLVE_TOOLTIP": "Resolve",
"ASSIGN_SUCCESFUL": "Conversations assigned successfully",
"ASSIGN_FAILED": "Failed to assign conversations, please try again",
"RESOLVE_SUCCESFUL": "Conversations resolved successfully",
"RESOLVE_FAILED": "Failed to resolve conversations, please try again",
"ALL_CONVERSATIONS_SELECTED_ALERT": "Conversations visible on this page are only selected.",
"AGENT_LIST_LOADING": "Loading Agents"
"BULK_ACTION": {
"CONVERSATIONS_SELECTED": "%{conversationCount} conversations selected",
"AGENT_SELECT_LABEL": "Select Agent",
"ASSIGN_CONFIRMATION_LABEL": "Are you sure you want to assign %{conversationCount} %{conversationLabel} to",
"GO_BACK_LABEL": "Go back",
"ASSIGN_LABEL": "Assign",
"ASSIGN_AGENT_TOOLTIP": "Assign Agent",
"ASSIGN_SUCCESFUL": "Conversations assigned successfully",
"ASSIGN_FAILED": "Failed to assign conversations, please try again",
"RESOLVE_SUCCESFUL": "Conversations resolved successfully",
"RESOLVE_FAILED": "Failed to resolve conversations, please try again",
"ALL_CONVERSATIONS_SELECTED_ALERT": "Conversations visible on this page are only selected.",
"AGENT_LIST_LOADING": "Loading Agents",
"UPDATE": {
"CHANGE_STATUS": "Change status",
"SNOOZE_UNTIL_NEXT_REPLY": "Snooze until next reply",
"UPDATE_SUCCESFUL": "Conversation status updated successfully.",
"UPDATE_FAILED": "Failed to update conversations, please try again"
},
"LABELS": {
"ASSIGN_LABELS": "Assign Labels",
"NO_LABELS_FOUND": "No labels found for",
"ASSIGN_SELECTED_LABELS": "Assign selected labels",
"ASSIGN_SUCCESFUL": "Labels assigned successfully",
"ASSIGN_FAILED": "Failed to assign labels, please try again"
}
}
}

View File

@@ -111,7 +111,8 @@
"EMAIL_ADDRESS": {
"PLACEHOLDER": "Добавете имейл на контакта",
"LABEL": "Имейл адрес",
"DUPLICATE": "Този имейл се използва от друг контакт."
"DUPLICATE": "Този имейл се използва от друг контакт.",
"ERROR": "Please enter a valid email address."
},
"PHONE_NUMBER": {
"PLACEHOLDER": "Добавете телефона на контакта",
@@ -147,6 +148,12 @@
}
}
},
"DELETE_AVATAR": {
"API": {
"SUCCESS_MESSAGE": "Contact avatar deleted successfully",
"ERROR_MESSAGE": "Could not delete the contact avatar. Please try again later."
}
},
"SUCCESS_MESSAGE": "Успешно запазване на контакта",
"ERROR_MESSAGE": "Възникна грешка, моля опитайте отново"
},

View File

@@ -38,7 +38,8 @@
"CUSTOM_ATTRIBUTE_LINK": "Link",
"CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox",
"CREATED_AT": "Created At",
"LAST_ACTIVITY": "Последна активност"
"LAST_ACTIVITY": "Последна активност",
"REFERER_LINK": "Referrer link"
},
"GROUPS": {
"STANDARD_FILTERS": "Standard Filters",

View File

@@ -0,0 +1,24 @@
{
"HELP_CENTER": {
"HEADER": {
"FILTER": "Filter by",
"SORT": "Sort by",
"SETTINGS_BUTTON": "Settings",
"NEW_BUTTON": "New Article"
},
"EDIT_HEADER": {
"PUBLISH_BUTTON": "Publish",
"PREVIEW": "Preview",
"ADD_TRANSLATION": "Add translation",
"OPEN_SIDEBAR": "Open sidebar",
"CLOSE_SIDEBAR": "Close sidebar",
"SAVING": "Draft saving...",
"SAVED": "Draft saved"
},
"TABLE": {
"COLUMNS": {
"BY": "by"
}
}
}
}

View File

@@ -30,7 +30,8 @@
"ADD": {
"CHANNEL_NAME": {
"LABEL": "Inbox Name",
"PLACEHOLDER": "Enter your inbox name (eg: Acme Inc)"
"PLACEHOLDER": "Enter your inbox name (eg: Acme Inc)",
"ERROR": "Please enter a valid inbox name"
},
"WEBSITE_NAME": {
"LABEL": "Website Name",
@@ -82,7 +83,7 @@
},
"CHANNEL_GREETING_TOGGLE": {
"LABEL": "Enable channel greeting",
"HELP_TEXT": "Send a greeting message to the users when they starts the conversation.",
"HELP_TEXT": "Automatically send a greeting message when a new conversation is created.",
"ENABLED": "Enabled",
"DISABLED": "Disabled"
},
@@ -97,7 +98,10 @@
"LABEL": "Widget Color",
"PLACEHOLDER": "Update the widget color used in widget"
},
"SUBMIT_BUTTON": "Create inbox"
"SUBMIT_BUTTON": "Create inbox",
"API": {
"ERROR_MESSAGE": "We were not able to create a website channel, please try again"
}
},
"TWILIO": {
"TITLE": "Twilio SMS/WhatsApp Channel",
@@ -107,6 +111,12 @@
"PLACEHOLDER": "Please enter your Twilio Account SID",
"ERROR": "This field is required"
},
"MESSAGING_SERVICE_SID": {
"LABEL": "Messaging Service SID",
"PLACEHOLDER": "Please enter your Twilio Messaging Service SID",
"ERROR": "This field is required",
"USE_MESSAGING_SERVICE": "Use a Twilio Messaging Service"
},
"CHANNEL_TYPE": {
"LABEL": "Channel Type",
"ERROR": "Please select your Channel Type"
@@ -193,6 +203,7 @@
"PROVIDERS": {
"LABEL": "API Provider",
"TWILIO": "Twilio",
"WHATSAPP_CLOUD": "WhatsApp Cloud",
"360_DIALOG": "360Dialog"
},
"INBOX_NAME": {
@@ -205,13 +216,31 @@
"PLACEHOLDER": "Please enter the phone number from which message will be sent.",
"ERROR": "Please enter a valid value. Phone number should start with `+` sign."
},
"PHONE_NUMBER_ID": {
"LABEL": "Phone number ID",
"PLACEHOLDER": "Please enter the Phone number ID obtained from Facebook developer dashboard.",
"ERROR": "Please enter a valid value."
},
"BUSINESS_ACCOUNT_ID": {
"LABEL": "Business Account ID",
"PLACEHOLDER": "Please enter the Business Account ID obtained from Facebook developer dashboard.",
"ERROR": "Please enter a valid value."
},
"WEBHOOK_VERIFY_TOKEN": {
"LABEL": "Webhook Verify Token",
"PLACEHOLDER": "Enter a verify token which you want to configure for facebook webhooks.",
"ERROR": "Please enter a valid value."
},
"API_KEY": {
"LABEL": "API key",
"SUBTITLE": "Configure the WhatsApp API key.",
"PLACEHOLDER": "API key",
"APPLY_FOR_ACCESS": "Don't have any API key? Apply for access here",
"ERROR": "Please enter a valid value."
},
"API_CALLBACK": {
"TITLE": "Callback URL",
"SUBTITLE": "You have to configure the webhook URL in facebook developer portal with the URL mentioned here."
},
"SUBMIT_BUTTON": "Create WhatsApp Channel",
"API": {
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
@@ -298,7 +327,7 @@
},
"AUTH": {
"TITLE": "Choose a channel",
"DESC": "Chatwoot supports live-chat widget, Facebook page, Twitter profile, WhatsApp, Email etc., as channels. If you want to build a custom channel, you can create it using the API channel. Select one channel from the options below to proceed."
"DESC": "Chatwoot supports live-chat widgets, Facebook Messenger, Twitter profiles, WhatsApp, Emails, etc., as channels. If you want to build a custom channel, you can create it using the API channel. To get started, choose one of the channels below."
},
"AGENTS": {
"TITLE": "Агенти",
@@ -339,7 +368,7 @@
"API": {
"SUCCESS_MESSAGE": "Inbox settings updated successfully",
"AUTO_ASSIGNMENT_SUCCESS_MESSAGE": "Auto assignment updated successfully",
"ERROR_MESSAGE": "Could not update widget color. Please try again later."
"ERROR_MESSAGE": "We couldn't update inbox settings. Please try again later."
},
"EMAIL_COLLECT_BOX": {
"ENABLED": "Enabled",
@@ -412,15 +441,22 @@
"INBOX_UPDATE_SUB_TEXT": "Update your inbox settings",
"AUTO_ASSIGNMENT_SUB_TEXT": "Enable or disable the automatic assignment of new conversations to the agents added to this inbox.",
"HMAC_VERIFICATION": "User Identity Validation",
"HMAC_DESCRIPTION": "Inorder to validate the user's identity, the SDK allows you to pass an `identifier_hash` for each user. You can generate HMAC using 'sha256' with the key shown here.",
"HMAC_DESCRIPTION": "In order to validate the user's identity, you can pass an `identifier_hash` for each user. You can generate a HMAC sha256 hash using the `identifier` with the key shown here.",
"HMAC_MANDATORY_VERIFICATION": "Enforce User Identity Validation",
"HMAC_MANDATORY_DESCRIPTION": "If enabled, Chatwoot SDKs setUser method will not work unless the `identifier_hash` is provided for each user.",
"HMAC_MANDATORY_DESCRIPTION": "If enabled, requests missing the `identifier_hash` will be rejected.",
"INBOX_IDENTIFIER": "Inbox Identifier",
"INBOX_IDENTIFIER_SUB_TEXT": "Use the `inbox_identifier` token shown here to authentication your API clients.",
"FORWARD_EMAIL_TITLE": "Forward to Email",
"FORWARD_EMAIL_SUB_TEXT": "Start forwarding your emails to the following email address.",
"ALLOW_MESSAGES_AFTER_RESOLVED": "Allow messages after conversation resolved",
"ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT": "Allow the end-users to send messages even after the conversation is resolved."
"ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT": "Allow the end-users to send messages even after the conversation is resolved.",
"WHATSAPP_SECTION_SUBHEADER": "This API Key is used for the integration with the WhatsApp APIs.",
"WHATSAPP_SECTION_TITLE": "API Key"
},
"AUTO_ASSIGNMENT": {
"MAX_ASSIGNMENT_LIMIT": "Auto assignment limit",
"MAX_ASSIGNMENT_LIMIT_RANGE_ERROR": "Please enter a value greater than 0",
"MAX_ASSIGNMENT_LIMIT_SUB_TEXT": "Limit the maximum number of conversations from this inbox that can be auto assigned to an agent"
},
"FACEBOOK_REAUTHORIZE": {
"TITLE": "Reauthorize",

View File

@@ -84,6 +84,52 @@
},
"CONNECT": {
"BUTTON_TEXT": "Connect"
},
"DASHBOARD_APPS": {
"TITLE": "Dashboard Apps",
"HEADER_BTN_TXT": "Add a new dashboard app",
"SIDEBAR_TXT": "<p><b>Dashboard Apps</b></p><p>Dashboard Apps allow organizations to embed an application inside the Chatwoot dashboard to provide the context for customer support agents. This feature allows you to create an application independently and embed that inside the dashboard to provide user information, their orders, or their previous payment history.</p><p>When you embed your application using the dashboard in Chatwoot, your application will get the context of the conversation and contact as a window event. Implement a listener for the message event on your page to receive the context.</p><p>To add a new dashboard app, click on the button 'Add a new dashboard app'.</p>",
"DESCRIPTION": "Dashboard Apps allow organizations to embed an application inside the dashboard to provide the context for customer support agents. This feature allows you to create an application independently and embed that to provide user information, their orders, or their previous payment history.",
"LIST": {
"404": "There are no dashboard apps configured on this account yet",
"LOADING": "Fetching dashboard apps...",
"TABLE_HEADER": [
"Име",
"Endpoint"
],
"EDIT_TOOLTIP": "Edit app",
"DELETE_TOOLTIP": "Delete app"
},
"FORM": {
"TITLE_LABEL": "Име",
"TITLE_PLACEHOLDER": "Enter a name for your dashboard app",
"TITLE_ERROR": "A name for the dashboard app is required",
"URL_LABEL": "Endpoint",
"URL_PLACEHOLDER": "Enter the endpoint URL where your app is hosted",
"URL_ERROR": "A valid URL is required"
},
"CREATE": {
"HEADER": "Add a new dashboard app",
"FORM_SUBMIT": "Изпращане",
"FORM_CANCEL": "Отмени",
"API_SUCCESS": "Dashboard app configured successfully",
"API_ERROR": "We couldn't create an app. Please try again later"
},
"UPDATE": {
"HEADER": "Edit dashboard app",
"FORM_SUBMIT": "Обновяване",
"FORM_CANCEL": "Отмени",
"API_SUCCESS": "Dashboard app updated successfully",
"API_ERROR": "We couldn't update the app. Please try again later"
},
"DELETE": {
"CONFIRM_YES": "Yes, delete it",
"CONFIRM_NO": "No, keep it",
"TITLE": "Потвърди изтриването",
"MESSAGE": "Are you sure to delete the app - %{appName}?",
"API_SUCCESS": "Dashboard app deleted successfully",
"API_ERROR": "We couldn't delete the app. Please try again later"
}
}
}
}

View File

@@ -65,6 +65,7 @@
"PLACEHOLDER": "Select date range"
},
"GROUP_BY_FILTER_DROPDOWN_LABEL": "Group By",
"DURATION_FILTER_LABEL": "Duration",
"GROUP_BY_DAY_OPTIONS": [
{
"id": 1,

View File

@@ -1,17 +1,29 @@
{
"BULK_ACTION": {
"CONVERSATIONS_SELECTED": "%{conversationCount} conversations selected",
"AGENT_SELECT_LABEL": "Seleccionar Agent",
"ASSIGN_CONFIRMATION_LABEL": "Are you sure you want to assign %{conversationCount} %{conversationLabel} to",
"GO_BACK_LABEL": "Go back",
"ASSIGN_LABEL": "Assignar",
"ASSIGN_AGENT_TOOLTIP": "Assign Agent",
"RESOLVE_TOOLTIP": "Resoldre",
"ASSIGN_SUCCESFUL": "Conversations assigned successfully",
"ASSIGN_FAILED": "Failed to assign conversations, please try again",
"RESOLVE_SUCCESFUL": "Conversations resolved successfully",
"RESOLVE_FAILED": "Failed to resolve conversations, please try again",
"ALL_CONVERSATIONS_SELECTED_ALERT": "Conversations visible on this page are only selected.",
"AGENT_LIST_LOADING": "Loading Agents"
"BULK_ACTION": {
"CONVERSATIONS_SELECTED": "%{conversationCount} conversations selected",
"AGENT_SELECT_LABEL": "Seleccionar Agent",
"ASSIGN_CONFIRMATION_LABEL": "Are you sure you want to assign %{conversationCount} %{conversationLabel} to",
"GO_BACK_LABEL": "Go back",
"ASSIGN_LABEL": "Assignar",
"ASSIGN_AGENT_TOOLTIP": "Assign Agent",
"ASSIGN_SUCCESFUL": "Conversations assigned successfully",
"ASSIGN_FAILED": "Failed to assign conversations, please try again",
"RESOLVE_SUCCESFUL": "Conversations resolved successfully",
"RESOLVE_FAILED": "Failed to resolve conversations, please try again",
"ALL_CONVERSATIONS_SELECTED_ALERT": "Conversations visible on this page are only selected.",
"AGENT_LIST_LOADING": "Loading Agents",
"UPDATE": {
"CHANGE_STATUS": "Change status",
"SNOOZE_UNTIL_NEXT_REPLY": "Snooze until next reply",
"UPDATE_SUCCESFUL": "Conversation status updated successfully.",
"UPDATE_FAILED": "Failed to update conversations, please try again"
},
"LABELS": {
"ASSIGN_LABELS": "Assign Labels",
"NO_LABELS_FOUND": "No labels found for",
"ASSIGN_SELECTED_LABELS": "Assign selected labels",
"ASSIGN_SUCCESFUL": "Labels assigned successfully",
"ASSIGN_FAILED": "Failed to assign labels, please try again"
}
}
}

View File

@@ -111,7 +111,8 @@
"EMAIL_ADDRESS": {
"PLACEHOLDER": "Introdueix l'adreça de correu electrònic del contacte",
"LABEL": "Adreça de correu electrònic",
"DUPLICATE": "Aquesta adreça de correu electrònic sutilitza per a un altre contacte."
"DUPLICATE": "Aquesta adreça de correu electrònic sutilitza per a un altre contacte.",
"ERROR": "Introduïu una adreça de correu electrònic vàlida."
},
"PHONE_NUMBER": {
"PLACEHOLDER": "Introdueix el número de telèfon del contacte",
@@ -147,6 +148,12 @@
}
}
},
"DELETE_AVATAR": {
"API": {
"SUCCESS_MESSAGE": "Contact avatar deleted successfully",
"ERROR_MESSAGE": "Could not delete the contact avatar. Please try again later."
}
},
"SUCCESS_MESSAGE": "Contacte guardat correctament",
"ERROR_MESSAGE": "S'ha produït un error; tornau-ho a provar"
},

View File

@@ -38,7 +38,8 @@
"CUSTOM_ATTRIBUTE_LINK": "Link",
"CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox",
"CREATED_AT": "Created At",
"LAST_ACTIVITY": "Last Activity"
"LAST_ACTIVITY": "Last Activity",
"REFERER_LINK": "Referrer link"
},
"GROUPS": {
"STANDARD_FILTERS": "Standard Filters",

View File

@@ -0,0 +1,24 @@
{
"HELP_CENTER": {
"HEADER": {
"FILTER": "Filter by",
"SORT": "Sort by",
"SETTINGS_BUTTON": "Configuracions",
"NEW_BUTTON": "New Article"
},
"EDIT_HEADER": {
"PUBLISH_BUTTON": "Publish",
"PREVIEW": "Preview",
"ADD_TRANSLATION": "Add translation",
"OPEN_SIDEBAR": "Open sidebar",
"CLOSE_SIDEBAR": "Close sidebar",
"SAVING": "Draft saving...",
"SAVED": "Draft saved"
},
"TABLE": {
"COLUMNS": {
"BY": "by"
}
}
}
}

View File

@@ -30,7 +30,8 @@
"ADD": {
"CHANNEL_NAME": {
"LABEL": "Nom de la safata d'entrada",
"PLACEHOLDER": "Enter your inbox name (eg: Acme Inc)"
"PLACEHOLDER": "Enter your inbox name (eg: Acme Inc)",
"ERROR": "Please enter a valid inbox name"
},
"WEBSITE_NAME": {
"LABEL": "Nom del lloc web",
@@ -82,7 +83,7 @@
},
"CHANNEL_GREETING_TOGGLE": {
"LABEL": "Activa la salutació del canal",
"HELP_TEXT": "Send a greeting message to the users when they starts the conversation.",
"HELP_TEXT": "Automatically send a greeting message when a new conversation is created.",
"ENABLED": "Habilita",
"DISABLED": "Inhabilita"
},
@@ -97,7 +98,10 @@
"LABEL": "Color del Widget",
"PLACEHOLDER": "Actualitza el color del widget"
},
"SUBMIT_BUTTON": "Crea la safata entrada"
"SUBMIT_BUTTON": "Crea la safata entrada",
"API": {
"ERROR_MESSAGE": "We were not able to create a website channel, please try again"
}
},
"TWILIO": {
"TITLE": "Twilio SMS/WhatsApp Channel",
@@ -107,6 +111,12 @@
"PLACEHOLDER": "Introduïu el vostre compte Twilio SID",
"ERROR": "Aquest camp és obligatori"
},
"MESSAGING_SERVICE_SID": {
"LABEL": "Messaging Service SID",
"PLACEHOLDER": "Please enter your Twilio Messaging Service SID",
"ERROR": "Aquest camp és obligatori",
"USE_MESSAGING_SERVICE": "Use a Twilio Messaging Service"
},
"CHANNEL_TYPE": {
"LABEL": "Tipus de canal",
"ERROR": "Seleccioneu el teu tipus de canal"
@@ -193,6 +203,7 @@
"PROVIDERS": {
"LABEL": "API Provider",
"TWILIO": "Twilio",
"WHATSAPP_CLOUD": "WhatsApp Cloud",
"360_DIALOG": "360Dialog"
},
"INBOX_NAME": {
@@ -205,13 +216,31 @@
"PLACEHOLDER": "Introduïu el número de telèfon des del qual serà enviat el missatge.",
"ERROR": "Introduïu un valor vàlid. El número de telèfon hauria de començar amb el signe `+`."
},
"PHONE_NUMBER_ID": {
"LABEL": "Phone number ID",
"PLACEHOLDER": "Please enter the Phone number ID obtained from Facebook developer dashboard.",
"ERROR": "Please enter a valid value."
},
"BUSINESS_ACCOUNT_ID": {
"LABEL": "Business Account ID",
"PLACEHOLDER": "Please enter the Business Account ID obtained from Facebook developer dashboard.",
"ERROR": "Please enter a valid value."
},
"WEBHOOK_VERIFY_TOKEN": {
"LABEL": "Webhook Verify Token",
"PLACEHOLDER": "Enter a verify token which you want to configure for facebook webhooks.",
"ERROR": "Please enter a valid value."
},
"API_KEY": {
"LABEL": "API key",
"SUBTITLE": "Configure the WhatsApp API key.",
"PLACEHOLDER": "API key",
"APPLY_FOR_ACCESS": "Don't have any API key? Apply for access here",
"ERROR": "Please enter a valid value."
},
"API_CALLBACK": {
"TITLE": "Callback URL",
"SUBTITLE": "You have to configure the webhook URL in facebook developer portal with the URL mentioned here."
},
"SUBMIT_BUTTON": "Create WhatsApp Channel",
"API": {
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
@@ -298,7 +327,7 @@
},
"AUTH": {
"TITLE": "Choose a channel",
"DESC": "Chatwoot supports live-chat widget, Facebook page, Twitter profile, WhatsApp, Email etc., as channels. If you want to build a custom channel, you can create it using the API channel. Select one channel from the options below to proceed."
"DESC": "Chatwoot supports live-chat widgets, Facebook Messenger, Twitter profiles, WhatsApp, Emails, etc., as channels. If you want to build a custom channel, you can create it using the API channel. To get started, choose one of the channels below."
},
"AGENTS": {
"TITLE": "Agents",
@@ -339,7 +368,7 @@
"API": {
"SUCCESS_MESSAGE": "El color del widget s'ha actualitzat correctament",
"AUTO_ASSIGNMENT_SUCCESS_MESSAGE": "Assignació automàtica actualitzada correctament",
"ERROR_MESSAGE": "No s'ha pogut actualitzar el color del widget. Torneu-ho a provar més endavant."
"ERROR_MESSAGE": "We couldn't update inbox settings. Please try again later."
},
"EMAIL_COLLECT_BOX": {
"ENABLED": "Habilita",
@@ -412,15 +441,22 @@
"INBOX_UPDATE_SUB_TEXT": "Actualitza la configuració de la safata d'entrada",
"AUTO_ASSIGNMENT_SUB_TEXT": "Activa o desactiva l'assignació automàtica d'agents disponibles a les noves converses",
"HMAC_VERIFICATION": "Validació de la Identitat del Usuari",
"HMAC_DESCRIPTION": "Inorder to validate the user's identity, the SDK allows you to pass an `identifier_hash` for each user. You can generate HMAC using 'sha256' with the key shown here.",
"HMAC_DESCRIPTION": "In order to validate the user's identity, you can pass an `identifier_hash` for each user. You can generate a HMAC sha256 hash using the `identifier` with the key shown here.",
"HMAC_MANDATORY_VERIFICATION": "Enforce User Identity Validation",
"HMAC_MANDATORY_DESCRIPTION": "If enabled, Chatwoot SDKs setUser method will not work unless the `identifier_hash` is provided for each user.",
"HMAC_MANDATORY_DESCRIPTION": "If enabled, requests missing the `identifier_hash` will be rejected.",
"INBOX_IDENTIFIER": "Inbox Identifier",
"INBOX_IDENTIFIER_SUB_TEXT": "Use the `inbox_identifier` token shown here to authentication your API clients.",
"FORWARD_EMAIL_TITLE": "Forward to Email",
"FORWARD_EMAIL_SUB_TEXT": "Comença a reenviar els teus correus electrònics a la següent adreça electrònica.",
"ALLOW_MESSAGES_AFTER_RESOLVED": "Allow messages after conversation resolved",
"ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT": "Allow the end-users to send messages even after the conversation is resolved."
"ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT": "Allow the end-users to send messages even after the conversation is resolved.",
"WHATSAPP_SECTION_SUBHEADER": "This API Key is used for the integration with the WhatsApp APIs.",
"WHATSAPP_SECTION_TITLE": "API Key"
},
"AUTO_ASSIGNMENT": {
"MAX_ASSIGNMENT_LIMIT": "Auto assignment limit",
"MAX_ASSIGNMENT_LIMIT_RANGE_ERROR": "Please enter a value greater than 0",
"MAX_ASSIGNMENT_LIMIT_SUB_TEXT": "Limit the maximum number of conversations from this inbox that can be auto assigned to an agent"
},
"FACEBOOK_REAUTHORIZE": {
"TITLE": "Reautoritza",

View File

@@ -84,6 +84,52 @@
},
"CONNECT": {
"BUTTON_TEXT": "Connectar"
},
"DASHBOARD_APPS": {
"TITLE": "Dashboard Apps",
"HEADER_BTN_TXT": "Add a new dashboard app",
"SIDEBAR_TXT": "<p><b>Dashboard Apps</b></p><p>Dashboard Apps allow organizations to embed an application inside the Chatwoot dashboard to provide the context for customer support agents. This feature allows you to create an application independently and embed that inside the dashboard to provide user information, their orders, or their previous payment history.</p><p>When you embed your application using the dashboard in Chatwoot, your application will get the context of the conversation and contact as a window event. Implement a listener for the message event on your page to receive the context.</p><p>To add a new dashboard app, click on the button 'Add a new dashboard app'.</p>",
"DESCRIPTION": "Dashboard Apps allow organizations to embed an application inside the dashboard to provide the context for customer support agents. This feature allows you to create an application independently and embed that to provide user information, their orders, or their previous payment history.",
"LIST": {
"404": "There are no dashboard apps configured on this account yet",
"LOADING": "Fetching dashboard apps...",
"TABLE_HEADER": [
"Nom",
"Endpoint"
],
"EDIT_TOOLTIP": "Edit app",
"DELETE_TOOLTIP": "Delete app"
},
"FORM": {
"TITLE_LABEL": "Nom",
"TITLE_PLACEHOLDER": "Enter a name for your dashboard app",
"TITLE_ERROR": "A name for the dashboard app is required",
"URL_LABEL": "Endpoint",
"URL_PLACEHOLDER": "Enter the endpoint URL where your app is hosted",
"URL_ERROR": "A valid URL is required"
},
"CREATE": {
"HEADER": "Add a new dashboard app",
"FORM_SUBMIT": "Envia",
"FORM_CANCEL": "Cancel·la",
"API_SUCCESS": "Dashboard app configured successfully",
"API_ERROR": "We couldn't create an app. Please try again later"
},
"UPDATE": {
"HEADER": "Edit dashboard app",
"FORM_SUBMIT": "Actualitza",
"FORM_CANCEL": "Cancel·la",
"API_SUCCESS": "Dashboard app updated successfully",
"API_ERROR": "We couldn't update the app. Please try again later"
},
"DELETE": {
"CONFIRM_YES": "Yes, delete it",
"CONFIRM_NO": "No, keep it",
"TITLE": "Confirm deletion",
"MESSAGE": "Are you sure to delete the app - %{appName}?",
"API_SUCCESS": "Dashboard app deleted successfully",
"API_ERROR": "We couldn't delete the app. Please try again later"
}
}
}
}

View File

@@ -65,6 +65,7 @@
"PLACEHOLDER": "Select date range"
},
"GROUP_BY_FILTER_DROPDOWN_LABEL": "Group By",
"DURATION_FILTER_LABEL": "Duration",
"GROUP_BY_DAY_OPTIONS": [
{
"id": 1,

View File

@@ -1,17 +1,29 @@
{
"BULK_ACTION": {
"CONVERSATIONS_SELECTED": "%{conversationCount} conversations selected",
"AGENT_SELECT_LABEL": "Vybrat agenta",
"ASSIGN_CONFIRMATION_LABEL": "Are you sure you want to assign %{conversationCount} %{conversationLabel} to",
"GO_BACK_LABEL": "Go back",
"ASSIGN_LABEL": "Přiřadit",
"ASSIGN_AGENT_TOOLTIP": "Assign Agent",
"RESOLVE_TOOLTIP": "Vyřešit",
"ASSIGN_SUCCESFUL": "Conversations assigned successfully",
"ASSIGN_FAILED": "Failed to assign conversations, please try again",
"RESOLVE_SUCCESFUL": "Conversations resolved successfully",
"RESOLVE_FAILED": "Failed to resolve conversations, please try again",
"ALL_CONVERSATIONS_SELECTED_ALERT": "Conversations visible on this page are only selected.",
"AGENT_LIST_LOADING": "Loading Agents"
"BULK_ACTION": {
"CONVERSATIONS_SELECTED": "%{conversationCount} conversations selected",
"AGENT_SELECT_LABEL": "Vybrat agenta",
"ASSIGN_CONFIRMATION_LABEL": "Are you sure you want to assign %{conversationCount} %{conversationLabel} to",
"GO_BACK_LABEL": "Go back",
"ASSIGN_LABEL": "Přiřadit",
"ASSIGN_AGENT_TOOLTIP": "Assign Agent",
"ASSIGN_SUCCESFUL": "Conversations assigned successfully",
"ASSIGN_FAILED": "Failed to assign conversations, please try again",
"RESOLVE_SUCCESFUL": "Conversations resolved successfully",
"RESOLVE_FAILED": "Failed to resolve conversations, please try again",
"ALL_CONVERSATIONS_SELECTED_ALERT": "Conversations visible on this page are only selected.",
"AGENT_LIST_LOADING": "Loading Agents",
"UPDATE": {
"CHANGE_STATUS": "Change status",
"SNOOZE_UNTIL_NEXT_REPLY": "Snooze until next reply",
"UPDATE_SUCCESFUL": "Conversation status updated successfully.",
"UPDATE_FAILED": "Failed to update conversations, please try again"
},
"LABELS": {
"ASSIGN_LABELS": "Assign Labels",
"NO_LABELS_FOUND": "No labels found for",
"ASSIGN_SELECTED_LABELS": "Assign selected labels",
"ASSIGN_SUCCESFUL": "Labels assigned successfully",
"ASSIGN_FAILED": "Failed to assign labels, please try again"
}
}
}

View File

@@ -111,7 +111,8 @@
"EMAIL_ADDRESS": {
"PLACEHOLDER": "Zadejte e-mailovou adresu kontaktu",
"LABEL": "E-mailová adresa",
"DUPLICATE": "Tuto e-mailovou adresu již používá jiný kontakt."
"DUPLICATE": "Tuto e-mailovou adresu již používá jiný kontakt.",
"ERROR": "Zadejte prosím platnou e-mailovou adresu."
},
"PHONE_NUMBER": {
"PLACEHOLDER": "Zadejte telefonní číslo kontaktu",
@@ -147,6 +148,12 @@
}
}
},
"DELETE_AVATAR": {
"API": {
"SUCCESS_MESSAGE": "Contact avatar deleted successfully",
"ERROR_MESSAGE": "Could not delete the contact avatar. Please try again later."
}
},
"SUCCESS_MESSAGE": "Kontakt byl úspěšně uložen",
"ERROR_MESSAGE": "Došlo k chybě, zkuste to prosím znovu"
},

View File

@@ -38,7 +38,8 @@
"CUSTOM_ATTRIBUTE_LINK": "Link",
"CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox",
"CREATED_AT": "Created At",
"LAST_ACTIVITY": "Poslední aktivita"
"LAST_ACTIVITY": "Poslední aktivita",
"REFERER_LINK": "Referrer link"
},
"GROUPS": {
"STANDARD_FILTERS": "Standard Filters",

View File

@@ -0,0 +1,24 @@
{
"HELP_CENTER": {
"HEADER": {
"FILTER": "Filter by",
"SORT": "Sort by",
"SETTINGS_BUTTON": "Nastavení",
"NEW_BUTTON": "New Article"
},
"EDIT_HEADER": {
"PUBLISH_BUTTON": "Publish",
"PREVIEW": "Preview",
"ADD_TRANSLATION": "Add translation",
"OPEN_SIDEBAR": "Open sidebar",
"CLOSE_SIDEBAR": "Close sidebar",
"SAVING": "Draft saving...",
"SAVED": "Draft saved"
},
"TABLE": {
"COLUMNS": {
"BY": "by"
}
}
}
}

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