Merge branch 'release/1.18.0'

This commit is contained in:
Sojan
2021-07-16 00:08:30 +05:30
687 changed files with 12309 additions and 1562 deletions

View File

@@ -7,7 +7,7 @@ defaults: &defaults
working_directory: ~/build
docker:
# specify the version you desire here
- image: circleci/ruby:2.7.2-node-browsers
- image: circleci/ruby:2.7.3-node-browsers
# Specify service dependencies here if necessary
# CircleCI maintains a library of pre-built images

View File

@@ -30,6 +30,5 @@ exclude_patterns:
- "**/*.md"
- "**/*.yml"
- "app/javascript/dashboard/i18n/locale"
- "stories/**/*"
- "**/*.stories.js"
- "**/stories/"
- "stories/"

View File

@@ -1,51 +1,8 @@
# pre-build stage
ARG VARIANT=2.7
FROM mcr.microsoft.com/vscode/devcontainers/ruby:${VARIANT}
# Update args in docker-compose.yaml to set the UID/GID of the "vscode" user.
ARG USER_UID=1000
ARG USER_GID=$USER_UID
RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \
groupmod --gid $USER_GID vscode \
&& usermod --uid $USER_UID --gid $USER_GID vscode \
&& 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
# tmux is for overmind
# TODO : install foreman in future
# packages: postgresql-server-dev-all
# may be postgres in same machine
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends \
libssl-dev \
tar \
tzdata \
postgresql-client \
yarn \
git \
imagemagick \
tmux \
zsh
# [Optional] Uncomment this line to install global node packages.
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1
# The below image is created out of the Dockerfile.base
# It has the dependencies already installed so that codespace will boot up fast
FROM ghcr.io/chatwoot/chatwoot_codespace:latest
# Do the set up required for chatwoot app
WORKDIR /workspace
COPY . /workspace
# TODO: figure out installing rvm
# RUN rvm install
COPY Gemfile Gemfile.lock ./
RUN gem install bundler
RUN bundle install
COPY package.json yarn.lock ./
RUN yarn install
RUN yarn && gem install bundler && bundle install

View File

@@ -0,0 +1,45 @@
# pre-build stage
ARG VARIANT=2.7
FROM mcr.microsoft.com/vscode/devcontainers/ruby:${VARIANT}
# Update args in docker-compose.yaml to set the UID/GID of the "vscode" user.
ARG USER_UID=1000
ARG USER_GID=$USER_UID
RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \
groupmod --gid $USER_GID vscode \
&& usermod --uid $USER_UID --gid $USER_GID vscode \
&& 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 \
libssl-dev \
tar \
tzdata \
postgresql-client \
yarn \
git \
imagemagick \
tmux \
zsh
# Install overmind
RUN curl -L https://github.com/DarthSim/overmind/releases/download/v2.1.0/overmind-v2.1.0-linux-amd64.gz > overmind.gz \
&& gunzip overmind.gz \
&& sudo mv overmind /usr/local/bin \
&& chmod +x /usr/local/bin/overmind
# Do the set up required for chatwoot app
WORKDIR /workspace
COPY . /workspace
RUN yarn
COPY Gemfile Gemfile.lock ./
RUN gem install bundler && bundle install

View File

@@ -12,22 +12,28 @@
"extensions": [
"rebornix.Ruby",
"misogi.ruby-rubocop",
"wingrunr21.vscode-ruby"
"wingrunr21.vscode-ruby",
"davidpallinder.rails-test-runner",
"eamodio.gitlens",
"github.copilot",
"mrmlnc.vscode-duplicate"
],
// TODO: figure whether we can get all this ports work properly
// 3000 rails
// 3035 webpacker
// 5432 postgres
// 6379 redis
// 1025,8025 mailhog
"forwardPorts": [5432, 6379, 1025, 8025],
"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],
// Use 'postCreateCommand' to run commands after the container is created.
// #TODO: can we move logic of copy env file into dockerfile ?
"postCreateCommand": "cp .env.example .env",
"postCreateCommand": ".devcontainer/scripts/setup.sh && bundle exec rake db:chatwoot_prepare && yarn",
"portsAttributes": {
"3000": {
"label": "Rails Server"
},
"8025": {
"label": "Mailhog UI"
}
},
}

8
.devcontainer/scripts/setup.sh Executable file
View File

@@ -0,0 +1,8 @@
cp .env.example .env
sed -i -e '/REDIS_URL/ s/=.*/=redis:\/\/localhost:6379/' .env
sed -i -e '/POSTGRES_HOST/ s/=.*/=localhost/' .env
sed -i -e '/SMTP_ADDRESS/ s/=.*/=localhost/' .env
sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.githubpreview.dev/" .env
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

View File

@@ -155,3 +155,5 @@ USE_INBOX_AVATAR_FOR_BOT=true
## Development Only Config
# if you want to use letter_opener for local emails
# LETTER_OPENER=true
# meant to be used in github codespaces
# WEBPACKER_DEV_SERVER_PUBLIC=

View File

@@ -0,0 +1,23 @@
name: Publish Codespace Base Image
on:
workflow_dispatch:
jobs:
publish-code-space-image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Login to GitHub Container Registry
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build the Codespace Base Image
run: |
docker build . -t ghcr.io/chatwoot/chatwoot_codespace:latest -f .devcontainer/Dockerfile.base
docker push ghcr.io/chatwoot/chatwoot_codespace:latest

View File

@@ -1 +1 @@
2.7.2
2.7.3

View File

@@ -3,6 +3,7 @@ import Vue from 'vue';
import Vuex from 'vuex';
import VueI18n from 'vue-i18n';
import Vuelidate from 'vuelidate';
import Multiselect from 'vue-multiselect';
import WootUiKit from '../app/javascript/dashboard/components';
import i18n from '../app/javascript/dashboard/i18n';
@@ -13,6 +14,7 @@ Vue.use(VueI18n);
Vue.use(Vuelidate);
Vue.use(WootUiKit);
Vue.use(Vuex);
Vue.component('multiselect', Multiselect);
const store = new Vuex.Store({});
const i18nConfig = new VueI18n({

View File

@@ -1,6 +1,6 @@
source 'https://rubygems.org'
ruby '2.7.2'
ruby '2.7.3'
##-- base gems for rails --##
gem 'rack-cors', require: 'rack/cors'
@@ -53,6 +53,7 @@ gem 'activerecord-import'
gem 'dotenv-rails'
gem 'foreman'
gem 'puma'
gem 'rack-timeout'
gem 'webpacker', '~> 5.x'
# metrics on heroku
gem 'barnes'

View File

@@ -75,7 +75,7 @@ GEM
zeitwerk (~> 2.2, >= 2.2.2)
acts-as-taggable-on (6.5.0)
activerecord (>= 5.0, < 6.1)
addressable (2.7.0)
addressable (2.8.0)
public_suffix (>= 2.0.2, < 5.0)
administrate (0.16.0)
actionpack (>= 5.0)
@@ -384,6 +384,7 @@ GEM
rack
rack-test (1.1.0)
rack (>= 1.0, < 3)
rack-timeout (0.6.0)
rails (6.0.3.7)
actioncable (= 6.0.3.7)
actionmailbox (= 6.0.3.7)
@@ -666,6 +667,7 @@ DEPENDENCIES
puma
pundit
rack-cors
rack-timeout
rails
redis
redis-namespace
@@ -704,7 +706,7 @@ DEPENDENCIES
wisper (= 2.0.0)
RUBY VERSION
ruby 2.7.2p137
ruby 2.7.3p183
BUNDLED WITH
2.1.4

View File

@@ -2,6 +2,10 @@ class ContactMergeAction
pattr_initialize [:account!, :base_contact!, :mergee_contact!]
def perform
# This case happens when an agent updates a contact email in dashboard,
# while the contact also update his email via email collect box
return @base_contact if base_contact.id == mergee_contact.id
ActiveRecord::Base.transaction do
validate_contacts
merge_conversations

View File

@@ -0,0 +1,28 @@
class CsatSurveys::ResponseBuilder
pattr_initialize [:message]
def perform
raise 'Invalid Message' unless message.input_csat?
conversation = message.conversation
rating = message.content_attributes.dig('submitted_values', 'csat_survey_response', 'rating')
feedback_message = message.content_attributes.dig('submitted_values', 'csat_survey_response', 'feedback_message')
return if rating.blank?
process_csat_response(conversation, rating, feedback_message)
end
private
def process_csat_response(conversation, rating, feedback_message)
csat_survey_response = message.csat_survey_response || CsatSurveyResponse.new(
message_id: message.id, account_id: message.account_id, conversation_id: message.conversation_id,
contact_id: conversation.contact_id, assigned_agent: conversation.assignee
)
csat_survey_response.rating = rating
csat_survey_response.feedback_message = feedback_message
csat_survey_response.save!
csat_survey_response
end
end

View File

@@ -1,4 +1,5 @@
class V2::ReportBuilder
include DateRangeHelper
attr_reader :account, :params
def initialize(account, params)
@@ -83,10 +84,6 @@ class V2::ReportBuilder
.average(:value)
end
def range
parse_date_time(params[:since])..parse_date_time(params[:until])
end
# Taking average of average is not too accurate
# https://en.wikipedia.org/wiki/Simpson's_paradox
# TODO: Will optimize this later
@@ -101,11 +98,4 @@ class V2::ReportBuilder
(avg_first_response_time.values.sum / avg_first_response_time.values.length)
end
def parse_date_time(datetime)
return datetime if datetime.is_a?(DateTime)
return datetime.to_datetime if datetime.is_a?(Time) || datetime.is_a?(Date)
DateTime.strptime(datetime, '%s')
end
end

View File

@@ -28,6 +28,7 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController
end
def campaign_params
params.require(:campaign).permit(:title, :description, :message, :enabled, :inbox_id, :sender_id, trigger_rules: {})
params.require(:campaign).permit(:title, :description, :message, :enabled, :inbox_id, :sender_id,
:scheduled_at, audience: [:type, :id], trigger_rules: {})
end
end

View File

@@ -7,7 +7,6 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
sort_on :last_activity_at, type: :datetime
RESULTS_PER_PAGE = 15
protect_from_forgery with: :null_session
before_action :check_authorization
before_action :set_current_page, only: [:index, :active, :search]
@@ -15,7 +14,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def index
@contacts_count = resolved_contacts.count
@contacts = fetch_contact_last_seen_at(resolved_contacts)
@contacts = fetch_contacts_with_conversation_count(resolved_contacts)
end
def search
@@ -26,7 +25,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
search: "%#{params[:q]}%"
)
@contacts_count = contacts.count
@contacts = fetch_contact_last_seen_at(contacts)
@contacts = fetch_contacts_with_conversation_count(contacts)
end
def import
@@ -72,17 +71,22 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
private
# TODO: Move this to a finder class
def resolved_contacts
@resolved_contacts ||= Current.account.contacts
.where.not(email: [nil, ''])
.or(Current.account.contacts.where.not(phone_number: [nil, '']))
return @resolved_contacts if @resolved_contacts
@resolved_contacts = Current.account.contacts
.where.not(email: [nil, ''])
.or(Current.account.contacts.where.not(phone_number: [nil, '']))
@resolved_contacts = @resolved_contacts.tagged_with(params[:labels], any: true) if params[:labels].present?
@resolved_contacts
end
def set_current_page
@current_page = params[:page] || 1
end
def fetch_contact_last_seen_at(contacts)
def fetch_contacts_with_conversation_count(contacts)
filtrate(contacts).left_outer_joins(:conversations)
.select('contacts.*, COUNT(conversations.id) as conversations_count')
.group('contacts.id')

View File

@@ -0,0 +1,44 @@
class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::BaseController
include Sift
include DateRangeHelper
RESULTS_PER_PAGE = 25
before_action :check_authorization
before_action :set_csat_survey_responses, only: [:index, :metrics]
before_action :set_current_page, only: [:index]
before_action :set_current_page_surveys, only: [:index]
before_action :set_total_sent_messages_count, only: [:metrics]
sort_on :created_at, type: :datetime
def index; end
def metrics
@total_count = @csat_survey_responses.count
@ratings_count = @csat_survey_responses.group(:rating).count
end
private
def set_total_sent_messages_count
@csat_messages = Current.account.messages.input_csat
@csat_messages = @csat_messages.where(created_at: range) if range.present?
@total_sent_messages_count = @csat_messages.count
end
def set_csat_survey_responses
@csat_survey_responses = filtrate(
Current.account.csat_survey_responses.includes([:conversation, :assigned_agent, :contact])
)
@csat_survey_responses = @csat_survey_responses.where(created_at: range) if range.present?
end
def set_current_page_surveys
@csat_survey_responses = @csat_survey_responses.page(@current_page).per(RESULTS_PER_PAGE)
end
def set_current_page
@current_page = params[:page] || 1
end
end

View File

@@ -0,0 +1,49 @@
class Api::V1::Accounts::CustomFiltersController < Api::V1::Accounts::BaseController
before_action :fetch_custom_filters, except: [:create]
before_action :fetch_custom_filter, only: [:show, :update, :destroy]
DEFAULT_FILTER_TYPE = 'conversation'.freeze
def index; end
def show; end
def create
@custom_filter = current_user.custom_filters.create!(
permitted_payload.merge(account_id: Current.account.id)
)
end
def update
@custom_filter.update!(permitted_payload)
end
def destroy
@custom_filter.destroy
head :no_content
end
private
def fetch_custom_filters
@custom_filters = current_user.custom_filters.where(
account_id: Current.account.id,
filter_type: permitted_params[:filter_type] || DEFAULT_FILTER_TYPE
)
end
def fetch_custom_filter
@custom_filter = @custom_filters.find(permitted_params[:id])
end
def permitted_payload
params.require(:custom_filter).permit(
:name,
:filter_type,
query: {}
)
end
def permitted_params
params.permit(:id, :filter_type)
end
end

View File

@@ -88,12 +88,12 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def permitted_params
params.permit(:id, :avatar, :name, :greeting_message, :greeting_enabled, :enable_email_collect, channel:
params.permit(:id, :avatar, :name, :greeting_message, :greeting_enabled, :enable_email_collect, :csat_survey_enabled, channel:
[:type, :website_url, :widget_color, :welcome_title, :welcome_tagline, :webhook_url, :email, :reply_time])
end
def inbox_update_params
params.permit(:enable_auto_assignment, :enable_email_collect, :name, :avatar, :greeting_message, :greeting_enabled,
params.permit(:enable_auto_assignment, :enable_email_collect, :name, :avatar, :greeting_message, :greeting_enabled, :csat_survey_enabled,
:working_hours_enabled, :out_of_office_message, :timezone,
channel: [
:website_url,

View File

@@ -1,7 +1,6 @@
class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseController
RESULTS_PER_PAGE = 15
protect_from_forgery with: :null_session
before_action :fetch_notification, only: [:update]
before_action :set_primary_actor, only: [:read_all]
before_action :set_current_page, only: [:index]

View File

@@ -1,7 +1,6 @@
class Api::V1::AccountsController < Api::BaseController
include AuthHelper
skip_before_action :verify_authenticity_token, only: [:create]
skip_before_action :authenticate_user!, :set_current_user, :handle_with_exception,
only: [:create], raise: false
before_action :check_signup_enabled, only: [:create]

View File

@@ -54,7 +54,7 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
end
def message_update_params
params.permit(message: [{ submitted_values: [:name, :title, :value] }])
params.permit(message: [{ submitted_values: [:name, :title, :value, { csat_survey_response: [:feedback_message, :rating] }] }])
end
def permitted_params

View File

@@ -3,13 +3,12 @@ class ApplicationController < ActionController::Base
include Pundit
include SwitchLocale
protect_from_forgery with: :null_session
skip_before_action :verify_authenticity_token
before_action :set_current_user, unless: :devise_controller?
around_action :switch_locale
around_action :handle_with_exception, unless: :devise_controller?
# after_action :verify_authorized
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
private

View File

@@ -27,7 +27,13 @@ class Installation::OnboardingController < ApplicationController
def finish_onboarding
::Redis::Alfred.delete(::Redis::Alfred::CHATWOOT_INSTALLATION_ONBOARDING)
ChatwootHub.register_instance(onboarding_params) if onboarding_params[:subscribe_to_updates]
return if onboarding_params[:subscribe_to_updates].blank?
ChatwootHub.register_instance(
onboarding_params.dig(:user, :company),
onboarding_params.dig(:user, :name),
onboarding_params.dig(:user, :email)
)
end
def ensure_installation_onboarding

View File

@@ -1,6 +1,4 @@
class PlatformController < ActionController::Base
protect_from_forgery with: :null_session
class PlatformController < ActionController::API
before_action :ensure_access_token
before_action :set_platform_app
before_action :set_resource, only: [:update, :show, :destroy]

View File

@@ -1,3 +1,5 @@
# TODO: we should switch to ActionController::API for the base classes
# One of the specs is failing when I tried doing that, lets revisit in future
class PublicController < ActionController::Base
skip_before_action :verify_authenticity_token
end

View File

@@ -10,10 +10,12 @@ class AsyncDispatcher < BaseDispatcher
def listeners
[
CampaignListener.instance,
CsatSurveyListener.instance,
EventListener.instance,
WebhookListener.instance,
InstallationWebhookListener.instance, HookListener.instance,
CampaignListener.instance
HookListener.instance,
InstallationWebhookListener.instance,
WebhookListener.instance
]
end
end

View File

@@ -73,6 +73,8 @@ class ConversationFinder
@conversations = @conversations.assigned_to(current_user)
when 'unassigned'
@conversations = @conversations.unassigned
when 'assigned'
@conversations = @conversations.where.not(assignee_id: nil)
end
@conversations
end

View File

@@ -0,0 +1,19 @@
##############################################
# Helpers to implement date range filtering to APIs
# Include in your controller or service class where params is available
##############################################
module DateRangeHelper
def range
return if params[:since].blank? || params[:until].blank?
parse_date_time(params[:since])..parse_date_time(params[:until])
end
def parse_date_time(datetime)
return datetime if datetime.is_a?(DateTime)
return datetime.to_datetime if datetime.is_a?(Time) || datetime.is_a?(Date)
DateTime.strptime(datetime, '%s')
end
end

View File

@@ -0,0 +1,6 @@
module MessageFormatHelper
include RegexHelper
def transform_user_mention_content(message_content)
message_content.gsub(MENTION_REGEX, '\1')
end
end

View File

@@ -1,13 +1,30 @@
/* global axios */
import ApiClient from './ApiClient';
export const buildContactParams = (page, sortAttr, label, search) => {
let params = `page=${page}&sort=${sortAttr}`;
if (search) {
params = `${params}&q=${search}`;
}
if (label) {
params = `${params}&labels[]=${label}`;
}
return params;
};
class ContactAPI extends ApiClient {
constructor() {
super('contacts', { accountScoped: true });
}
get(page, sortAttr = 'name') {
return axios.get(`${this.url}?page=${page}&sort=${sortAttr}`);
get(page, sortAttr = 'name', label = '') {
let requestURL = `${this.url}?${buildContactParams(
page,
sortAttr,
label,
''
)}`;
return axios.get(requestURL);
}
getConversations(contactId) {
@@ -26,10 +43,14 @@ class ContactAPI extends ApiClient {
return axios.post(`${this.url}/${contactId}/labels`, { labels });
}
search(search = '', page = 1, sortAttr = 'name') {
return axios.get(
`${this.url}/search?q=${search}&page=${page}&sort=${sortAttr}`
);
search(search = '', page = 1, sortAttr = 'name', label = '') {
let requestURL = `${this.url}/search?${buildContactParams(
page,
sortAttr,
label,
search
)}`;
return axios.get(requestURL);
}
}

View File

@@ -0,0 +1,22 @@
/* global axios */
import ApiClient from './ApiClient';
class CSATReportsAPI extends ApiClient {
constructor() {
super('csat_survey_responses', { accountScoped: true });
}
get({ page, from, to } = {}) {
return axios.get(this.url, {
params: { page, since: from, until: to, sort: '-created_at' },
});
}
getMetrics({ from, to } = {}) {
return axios.get(`${this.url}/metrics`, {
params: { since: from, until: to },
});
}
}
export default new CSATReportsAPI();

View File

@@ -30,6 +30,10 @@ class MessageApi extends ApiClient {
});
}
delete(conversationID, messageId) {
return axios.delete(`${this.url}/${conversationID}/messages/${messageId}`);
}
getPreviousMessages({ conversationId, before }) {
return axios.get(`${this.url}/${conversationId}/messages`, {
params: { before },

View File

@@ -1,4 +1,4 @@
import contactAPI from '../contacts';
import contactAPI, { buildContactParams } from '../contacts';
import ApiClient from '../ApiClient';
import describeWithAPIMock from './apiSpecHelper';
@@ -15,9 +15,9 @@ describe('#ContactsAPI', () => {
describeWithAPIMock('API calls', context => {
it('#get', () => {
contactAPI.get(1, 'name');
contactAPI.get(1, 'name', 'customer-support');
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v1/contacts?page=1&sort=name'
'/api/v1/contacts?page=1&sort=name&labels[]=customer-support'
);
});
@@ -54,10 +54,22 @@ describe('#ContactsAPI', () => {
});
it('#search', () => {
contactAPI.search('leads', 1, 'date');
contactAPI.search('leads', 1, 'date', 'customer-support');
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v1/contacts/search?q=leads&page=1&sort=date'
'/api/v1/contacts/search?page=1&sort=date&q=leads&labels[]=customer-support'
);
});
});
});
describe('#buildContactParams', () => {
it('returns correct string', () => {
expect(buildContactParams(1, 'name', '', '')).toBe('page=1&sort=name');
expect(buildContactParams(1, 'name', 'customer-support', '')).toBe(
'page=1&sort=name&labels[]=customer-support'
);
expect(
buildContactParams(1, 'name', 'customer-support', 'message-content')
).toBe('page=1&sort=name&q=message-content&labels[]=customer-support');
});
});

View File

@@ -0,0 +1,37 @@
import csatReportsAPI from '../csatReports';
import ApiClient from '../ApiClient';
import describeWithAPIMock from './apiSpecHelper';
describe('#Reports API', () => {
it('creates correct instance', () => {
expect(csatReportsAPI).toBeInstanceOf(ApiClient);
expect(csatReportsAPI.apiVersion).toBe('/api/v1');
expect(csatReportsAPI).toHaveProperty('get');
expect(csatReportsAPI).toHaveProperty('getMetrics');
});
describeWithAPIMock('API calls', context => {
it('#get', () => {
csatReportsAPI.get({ page: 1, from: 1622485800, to: 1623695400 });
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v1/csat_survey_responses',
{
params: {
page: 1,
since: 1622485800,
until: 1623695400,
sort: '-created_at',
},
}
);
});
it('#getMetrics', () => {
csatReportsAPI.getMetrics({ from: 1622485800, to: 1623695400 });
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v1/csat_survey_responses/metrics',
{
params: { since: 1622485800, until: 1623695400 },
}
);
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,29 @@
@import '~vue2-datepicker/scss/index';
.mx-datepicker-popup {
z-index: 99999;
}
.date-picker {
.mx-datepicker {
width: 100%;
}
.mx-datepicker-range {
width: 320px;
}
.mx-input {
border: 1px solid var(--color-border);
border-radius: var(--border-radius-normal);
box-shadow: none;
display: flex;
height: 4.6rem;
}
.mx-input:disabled,
.mx-input[readonly] {
background-color: var(--white);
cursor: pointer;
}
}

View File

@@ -42,6 +42,14 @@ code {
white-space: nowrap;
}
.text-capitalize {
text-transform: capitalize;
}
.cursor-pointer {
cursor: pointer;
}
// remove when grid gutters are fixed
.columns.with-right-space {
padding-right: var(--space-normal);
}

View File

@@ -12,6 +12,7 @@
@import 'foundation-settings';
@import 'helper-classes';
@import 'formulate';
@import 'date-picker';
@import 'foundation-sites/scss/foundation';
@import '~bourbon/core/bourbon';

View File

@@ -137,6 +137,7 @@
}
.sidebar-labels-wrap {
&.has-edited,
&:hover {
.multiselect {
@@ -162,6 +163,12 @@
.multiselect-wrap--small {
$multiselect-height: 3.8rem;
.multiselect__tags,
.multiselect__input {
align-items: center;
display: flex;
}
.multiselect__tags,
.multiselect__input,
.multiselect {
@@ -177,15 +184,24 @@
}
.multiselect__single {
align-items: center;
display: flex;
font-size: var(--font-size-small);
padding: var(--space-small) 0;
margin: 0;
padding: var(--space-smaller) var(--space-micro);
}
.multiselect__placeholder {
padding: var(--space-small) 0;
margin: 0;
padding: var(--space-smaller) var(--space-micro);
}
.multiselect__select {
min-height: $multiselect-height;
}
.multiselect--disabled .multiselect__current,
.multiselect--disabled .multiselect__select {
background: transparent;
}
}

View File

@@ -8,6 +8,7 @@
@import 'variables';
@import '~spinkit/scss/spinners/7-three-bounce';
@import '~vue-multiselect/dist/vue-multiselect.min.css';
@import '~shared/assets/stylesheets/ionicons';
@import 'mixins';
@@ -27,3 +28,5 @@
@import 'foundation-custom';
@import 'widgets/buttons';
@import 'widgets/forms';
@import 'plugins/multiselect';

View File

@@ -8,12 +8,12 @@
.channel {
@include flex;
@include padding($space-normal $zero);
@include margin($zero);
@include background-white;
@include border-light;
flex-direction: column;
cursor: pointer;
border-right-color: $color-white;
flex-direction: column;
margin: -1px;
transition: all 0.200s ease-in;
&:last-child {
@@ -32,14 +32,13 @@
img {
@include margin($space-normal auto);
flex: 1;
width: 50%;
}
.channel__title{
font-size: $font-size-large;
text-align: center;
.channel__title {
color: $color-body;
font-size: var(--font-size-default);
text-align: center;
text-transform: capitalize;
}

View File

@@ -6,6 +6,10 @@ $default-button-height: 4.0rem;
height: $default-button-height;
margin-bottom: 0;
.button__content {
width: 100%;
}
.spinner {
padding: 0 var(--space-small);
}

View File

@@ -1,7 +1,6 @@
.report-card {
@include padding($space-normal $space-small $space-normal $space-two);
@include margin($zero);
@include background-light;
cursor: pointer;
@include custom-border-top(3px, transparent);

View File

@@ -1,14 +1,14 @@
/* eslint no-plusplus: 0 */
/* eslint-env browser */
import AvatarUploader from './widgets/forms/AvatarUploader.vue';
import Bar from './widgets/chart/BarChart';
import Button from './ui/WootButton';
import Code from './Code';
import ColorPicker from './widgets/ColorPicker';
import DeleteModal from './widgets/modal/DeleteModal.vue';
import ConfirmDeleteModal from './widgets/modal/ConfirmDeleteModal.vue';
import DeleteModal from './widgets/modal/DeleteModal.vue';
import DropdownItem from 'shared/components/ui/dropdown/DropdownItem';
import DropdownMenu from 'shared/components/ui/dropdown/DropdownMenu';
import HorizontalBar from './widgets/chart/HorizontalBarChart';
import Input from './widgets/forms/Input.vue';
import Label from './ui/Label';
import LoadingState from './widgets/LoadingState';
@@ -28,12 +28,14 @@ const WootUIKit = {
Button,
Code,
ColorPicker,
ConfirmDeleteModal,
DeleteModal,
DropdownItem,
DropdownMenu,
HorizontalBar,
Input,
LoadingState,
Label,
LoadingState,
Modal,
ModalHeader,
ReportStatsCard,
@@ -43,7 +45,6 @@ const WootUIKit = {
Tabs,
TabsItem,
Thumbnail,
ConfirmDeleteModal,
install(Vue) {
const keys = Object.keys(this);
keys.pop(); // remove 'install' from keys

View File

@@ -27,6 +27,13 @@
v-if="shouldShowSidebarItem"
:key="labelSection.toState"
:menu-item="labelSection"
@add-label="showAddLabelPopup"
/>
<sidebar-item
v-if="showShowContactSideMenu"
:key="contactLabelSection.key"
:menu-item="contactLabelSection"
@add-label="showAddLabelPopup"
/>
</transition-group>
</div>
@@ -57,6 +64,10 @@
:show="showCreateAccountModal"
@close-account-create-modal="closeCreateAccountModal"
/>
<woot-modal :show.sync="showAddLabelModal" :on-close="hideAddLabelPopup">
<add-label-modal @close="hideAddLabelPopup" />
</woot-modal>
</aside>
</template>
@@ -74,6 +85,7 @@ import AgentDetails from './sidebarComponents/AgentDetails.vue';
import OptionsMenu from './sidebarComponents/OptionsMenu.vue';
import AccountSelector from './sidebarComponents/AccountSelector.vue';
import AddAccountModal from './sidebarComponents/AddAccountModal.vue';
import AddLabelModal from '../../routes/dashboard/settings/labels/AddLabel';
export default {
components: {
@@ -84,6 +96,7 @@ export default {
OptionsMenu,
AccountSelector,
AddAccountModal,
AddLabelModal,
},
mixins: [adminMixin, alertMixin],
data() {
@@ -91,6 +104,7 @@ export default {
showOptionsMenu: false,
showAccountModal: false,
showCreateAccountModal: false,
showAddLabelModal: false,
};
},
@@ -131,6 +145,9 @@ export default {
shouldShowSidebarItem() {
return this.sidemenuItems.common.routes.includes(this.currentRoute);
},
showShowContactSideMenu() {
return this.sidemenuItems.contacts.routes.includes(this.currentRoute);
},
shouldShowTeams() {
return this.shouldShowSidebarItem && this.teams.length;
},
@@ -177,6 +194,29 @@ export default {
})),
};
},
contactLabelSection() {
return {
icon: 'ion-pound',
label: 'TAGGED_WITH',
hasSubMenu: true,
key: 'label',
newLink: false,
cssClass: 'menu-title align-justify',
toState: frontendURL(`accounts/${this.accountId}/settings/labels`),
toStateName: 'labels_list',
showModalForNewItem: true,
modalName: 'AddLabel',
children: this.accountLabels.map(label => ({
id: label.id,
label: label.title,
color: label.color,
truncateLabel: true,
toState: frontendURL(
`accounts/${this.accountId}/labels/${label.title}/contacts`
),
})),
};
},
teamSection() {
return {
icon: 'ion-ios-people',
@@ -253,6 +293,12 @@ export default {
closeCreateAccountModal() {
this.showCreateAccountModal = false;
},
showAddLabelPopup() {
this.showAddLabelModal = true;
},
hideAddLabelPopup() {
this.showAddLabelModal = false;
},
},
};
</script>

View File

@@ -52,11 +52,6 @@
</a>
</router-link>
</ul>
<add-label-modal
v-if="showAddLabel"
:show.sync="showAddLabel"
:on-close="hideAddLabelPopup"
/>
</router-link>
</template>
@@ -66,17 +61,8 @@ import { mapGetters } from 'vuex';
import router from '../../routes';
import adminMixin from '../../mixins/isAdmin';
import { getInboxClassByType } from 'dashboard/helper/inbox';
import AddLabelModal from '../../routes/dashboard/settings/labels/AddLabel';
export default {
components: {
AddLabelModal,
},
mixins: [adminMixin],
data() {
return {
showAddLabel: false,
};
},
props: {
menuItem: {
type: Object,
@@ -127,19 +113,13 @@ export default {
router.push({ name: item.newLinkRouteName, params: { page: 'new' } });
} else if (item.showModalForNewItem) {
if (item.modalName === 'AddLabel') {
this.showAddLabelPopup();
this.$emit('add-label');
}
}
},
showItem(item) {
return this.isAdmin && item.newLink !== undefined;
},
showAddLabelPopup() {
this.showAddLabel = true;
},
hideAddLabelPopup() {
this.showAddLabel = false;
},
},
};
</script>

View File

@@ -0,0 +1,40 @@
<template>
<div class="date-picker">
<date-picker
:range="true"
:confirm="true"
:clearable="false"
:editable="false"
:confirm-text="confirmText"
:placeholder="placeholder"
:value="value"
@change="handleChange"
/>
</div>
</template>
<script>
import DatePicker from 'vue2-datepicker';
export default {
components: { DatePicker },
props: {
confirmText: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
value: {
type: Array,
default: () => [],
},
},
methods: {
handleChange(value) {
this.$emit('change', value);
},
},
};
</script>

View File

@@ -0,0 +1,47 @@
<template>
<div class="date-picker">
<date-picker
type="datetime"
:confirm="true"
:clearable="false"
:editable="false"
:confirm-text="confirmText"
:placeholder="placeholder"
:value="value"
:disabled-date="disableBeforeToday"
@change="handleChange"
/>
</div>
</template>
<script>
import addDays from 'date-fns/addDays';
import DatePicker from 'vue2-datepicker';
export default {
components: { DatePicker },
props: {
confirmText: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
value: {
type: Date,
default: () => [],
},
},
methods: {
handleChange(value) {
this.$emit('change', value);
},
disableBeforeToday(date) {
const yesterdayDate = addDays(new Date(), -1);
return date < yesterdayDate;
},
},
};
</script>

View File

@@ -0,0 +1,38 @@
import { action } from '@storybook/addon-actions';
import WootDateRangePicker from '../DateRangePicker.vue';
export default {
title: 'Components/Date Picker/Date Range Picker',
argTypes: {
confirmText: {
defaultValue: 'Apply',
control: {
type: 'text',
},
},
placeholder: {
defaultValue: 'Select date range',
control: {
type: 'text',
},
},
value: {
control: {
type: 'text',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { WootDateRangePicker },
template:
'<woot-date-range-picker v-bind="$props" @change="onChange"></woot-date-range-picker>',
});
export const DateRangePicker = Template.bind({});
DateRangePicker.args = {
onChange: action('applied'),
value: new Date(),
};

View File

@@ -0,0 +1,38 @@
import { action } from '@storybook/addon-actions';
import WootDateTimePicker from '../DateTimePicker.vue';
export default {
title: 'Components/Date Picker/Date Time Picker',
argTypes: {
confirmText: {
defaultValue: 'Apply',
control: {
type: 'text',
},
},
placeholder: {
defaultValue: 'Select date time',
control: {
type: 'text',
},
},
value: {
control: {
type: 'text',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { WootDateTimePicker },
template:
'<woot-date-time-picker v-bind="$props" @change="onChange"></woot-date-time-picker>',
});
export const DateTimePicker = Template.bind({});
DateTimePicker.args = {
onChange: action('applied'),
value: new Date(),
};

View File

@@ -65,7 +65,11 @@ export default {
display: flex;
padding: var(--space-slab) 0 0;
background: var(--color-background-light);
background: transparent;
background: var(--b-50);
border-radius: var(--border-radius-normal);
width: fit-content;
padding: var(--space-smaller);
margin-top: var(--space-normal);
}
.thumb-wrap {
@@ -114,6 +118,8 @@ export default {
max-width: 50%;
overflow: hidden;
text-overflow: ellipsis;
margin-left: var(--space-small);
.item {
height: var(--space-normal);
overflow: hidden;
@@ -123,7 +129,7 @@ export default {
}
.file-size-wrap {
width: 20%;
width: 30%;
justify-content: center;
}

View File

@@ -1,6 +1,6 @@
<template>
<div
class="small-3 columns channel"
class="small-6 medium-4 large-3 columns channel"
:class="{ inactive: !isActive }"
@click="onItemClick"
>
@@ -37,8 +37,12 @@
src="~dashboard/assets/images/channels/website.png"
/>
<img
v-if="channel.key === 'twilio'"
src="~dashboard/assets/images/channels/twilio.png"
v-if="channel.key === 'sms'"
src="~dashboard/assets/images/channels/sms.png"
/>
<img
v-if="channel.key === 'whatsapp'"
src="~dashboard/assets/images/channels/whatsapp.png"
/>
<h3 class="channel__title">
{{ channel.name }}
@@ -72,7 +76,7 @@ export default {
if (key === 'email') {
return this.enabledFeatures.channel_email;
}
return ['website', 'twilio', 'api'].includes(key);
return ['website', 'twilio', 'api', 'whatsapp', 'sms'].includes(key);
},
},
methods: {

View File

@@ -1,12 +1,21 @@
<template>
<div class="small-2 report-card" :class="{ 'active': selected }" v-on:click="onClick(index)">
<h3 class="heading">{{heading}}</h3>
<h4 class="metric">{{point}}</h4>
<p class="desc">{{desc}}</p>
<div
class="small-2 report-card"
:class="{ active: selected }"
@click="onClick(index)"
>
<h3 class="heading">
{{ heading }}
</h3>
<h4 class="metric">
{{ point }}
</h4>
<p class="desc">
{{ desc }}
</p>
</div>
</template>
<script>
export default {
props: {
heading: String,

View File

@@ -1,36 +1,37 @@
<template>
<div class="row--user-block">
<Thumbnail
:src="sender.thumbnail"
size="20px"
:username="sender.name"
:status="sender.availability_status"
<thumbnail
:src="user.thumbnail"
:size="size"
:username="user.name"
:status="user.availability_status"
/>
<div>
<h6 class="text-block-title text-truncate">
{{ sender.name }}
</h6>
</div>
<h6 class="text-block-title text-truncate text-capitalize">
{{ user.name }}
</h6>
</div>
</template>
<script>
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
export default {
components: {
Thumbnail,
},
props: {
sender: {
user: {
type: Object,
default: () => {},
},
size: {
type: String,
default: '20px',
},
},
};
</script>
<style scoped lang="scss">
@import '~dashboard/assets/scss/mixins';
.row--user-block {
align-items: center;
display: flex;

View File

@@ -12,8 +12,11 @@
/>
<file-upload
ref="upload"
:size="4096 * 4096"
accept="image/*, application/pdf, audio/mpeg, video/mp4, audio/ogg, text/csv"
:drop="true"
:drop-directory="false"
@input-file="onFileUpload"
>
<woot-button
@@ -37,6 +40,17 @@
:title="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
@click="toggleFormatMode"
/>
<transition name="modal-fade">
<div
v-show="$refs.upload && $refs.upload.dropActive"
class="modal-mask"
>
<i class="ion-ios-cloud-upload-outline icon"></i>
<h4 class="page-sub-title">
{{ $t('CONVERSATION.REPLYBOX.DRAG_DROP') }}
</h4>
</div>
</transition>
</div>
<div class="right-wrap">
<div v-if="isFormatMode" class="enter-to-send--checkbox">
@@ -198,4 +212,18 @@ export default {
background: var(--s-100);
}
}
.modal-mask {
color: var(--s-600);
background: var(--white-transparent);
flex-direction: column;
}
.page-sub-title {
color: var(--s-600);
}
.icon {
font-size: 8rem;
}
</style>

View File

@@ -0,0 +1,54 @@
import { HorizontalBar } from 'vue-chartjs';
const chartOptions = {
responsive: true,
legend: {
display: false,
},
title: {
display: false,
},
tooltips: {
enabled: false,
},
scales: {
xAxes: [
{
gridLines: {
offsetGridLines: false,
},
display: false,
stacked: true,
},
],
yAxes: [
{
gridLines: {
offsetGridLines: false,
},
display: false,
stacked: true,
},
],
},
};
export default {
extends: HorizontalBar,
props: {
collection: {
type: Object,
default: () => {},
},
chartOptions: {
type: Object,
default: () => {},
},
},
mounted() {
this.renderChart(this.collection, {
...chartOptions,
...this.chartOptions,
});
},
};

View File

@@ -39,7 +39,7 @@
:loading="uiFlags.isFetching"
:allow-empty="true"
deselect-label=""
:options="agentList"
:options="agentsList"
:placeholder="$t('CONVERSATION.ASSIGNMENT.SELECT_AGENT')"
select-label=""
label="name"
@@ -67,6 +67,7 @@
import { mapGetters } from 'vuex';
import MoreActions from './MoreActions';
import Thumbnail from '../Thumbnail';
import agentMixin from '../../../mixins/agentMixin.js';
import AvailabilityStatusBadge from '../conversation/AvailabilityStatusBadge';
export default {
@@ -75,7 +76,7 @@ export default {
Thumbnail,
AvailabilityStatusBadge,
},
mixins: [agentMixin],
props: {
chat: {
type: Object,
@@ -90,12 +91,12 @@ export default {
data() {
return {
currentChatAssignee: null,
inboxId: null,
};
},
computed: {
...mapGetters({
getAgents: 'inboxAssignableAgents/getAssignableAgents',
uiFlags: 'inboxAssignableAgents/getUIFlags',
currentChat: 'getSelectedChat',
}),
@@ -109,22 +110,10 @@ export default {
this.chat.meta.sender.id
);
},
agentList() {
const { inbox_id: inboxId } = this.chat;
const agents = this.getAgents(inboxId) || [];
return [
{
confirmed: true,
name: 'None',
id: 0,
role: 'agent',
account_id: 0,
email: 'None',
},
...agents,
];
},
},
mounted() {
const { inbox_id: inboxId } = this.chat;
this.inboxId = inboxId;
},
methods: {

View File

@@ -44,7 +44,6 @@
/>
</div>
<spinner v-if="isPending" size="tiny" />
<a
v-if="isATweet && isIncoming && sender"
class="sender--info"
@@ -62,29 +61,47 @@
</div>
</a>
</div>
<div class="context-menu-wrap">
<context-menu
v-if="isBubble && !isMessageDeleted"
:is-open="showContextMenu"
:show-copy="hasText"
:menu-position="contextMenuPosition"
@toggle="handleContextMenuClick"
@delete="handleDelete"
@copy="handleCopy"
/>
</div>
</li>
</template>
<script>
import copy from 'copy-text-to-clipboard';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import timeMixin from '../../../mixins/time';
import BubbleText from './bubble/Text';
import BubbleImage from './bubble/Image';
import BubbleFile from './bubble/File';
import Spinner from 'shared/components/Spinner';
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu';
import { isEmptyObject } from 'dashboard/helper/commons';
import alertMixin from 'shared/mixins/alertMixin';
import contentTypeMixin from 'shared/mixins/contentTypeMixin';
import BubbleActions from './bubble/Actions';
import { MESSAGE_TYPE, MESSAGE_STATUS } from 'shared/constants/messages';
import { generateBotMessageContent } from './helpers/botMessageContentHelper';
export default {
components: {
BubbleActions,
BubbleText,
BubbleImage,
BubbleFile,
ContextMenu,
Spinner,
},
mixins: [timeMixin, messageFormatterMixin, contentTypeMixin],
mixins: [alertMixin, timeMixin, messageFormatterMixin, contentTypeMixin],
props: {
data: {
type: Object,
@@ -97,7 +114,7 @@ export default {
},
data() {
return {
isHovered: false,
showContextMenu: false,
};
},
computed: {
@@ -105,7 +122,13 @@ export default {
const botMessageContent = generateBotMessageContent(
this.contentType,
this.contentAttributes,
this.$t('CONVERSATION.NO_RESPONSE')
{
noResponseText: this.$t('CONVERSATION.NO_RESPONSE'),
csat: {
ratingTitle: this.$t('CONVERSATION.RATING_TITLE'),
feedbackTitle: this.$t('CONVERSATION.FEEDBACK_TITLE'),
},
}
);
const {
@@ -115,12 +138,10 @@ export default {
} = this.contentAttributes;
if ((replyHTMLContent || fullHTMLContent) && this.isIncoming) {
let parsedContent = new DOMParser().parseFromString(
replyHTMLContent || fullHTMLContent || '',
'text/html'
);
if (!parsedContent.getElementsByTagName('parsererror').length) {
return parsedContent.body.innerHTML;
let contentToBeParsed = replyHTMLContent || fullHTMLContent || '';
const parsedContent = this.stripStyleCharacters(contentToBeParsed);
if (parsedContent) {
return parsedContent;
}
}
return (
@@ -146,10 +167,19 @@ export default {
},
alignBubble() {
const { message_type: messageType } = this.data;
if (messageType === MESSAGE_TYPE.ACTIVITY) {
return 'center';
}
return !messageType ? 'left' : 'right';
const isCentered = messageType === MESSAGE_TYPE.ACTIVITY;
const isLeftAligned = messageType === MESSAGE_TYPE.INCOMING;
const isRightAligned =
messageType === MESSAGE_TYPE.OUTGOING ||
messageType === MESSAGE_TYPE.TEMPLATE;
return {
center: isCentered,
left: isLeftAligned,
right: isRightAligned,
'has-context-menu': this.showContextMenu,
'has-tweet-menu': this.isATweet,
};
},
readableTime() {
return this.messageStamp(
@@ -166,6 +196,9 @@ export default {
hasAttachments() {
return !!(this.data.attachments && this.data.attachments.length > 0);
},
isMessageDeleted() {
return this.contentAttributes.deleted;
},
hasImageAttachment() {
if (this.hasAttachments && this.data.attachments.length > 0) {
const { attachments = [{}] } = this.data;
@@ -178,9 +211,11 @@ export default {
return !!this.data.content;
},
sentByMessage() {
if (this.isMessageDeleted) {
return false;
}
const { sender } = this;
return this.data.message_type === 1 && !this.isHovered && sender
return this.data.message_type === 1 && !isEmptyObject(sender)
? {
content: `${this.$t('CONVERSATION.SENT_BY')} ${sender.name}`,
classes: 'top',
@@ -210,6 +245,33 @@ export default {
if (this.isPending) return false;
return !this.sender.type || this.sender.type === 'agent_bot';
},
contextMenuPosition() {
const { message_type: messageType } = this.data;
return messageType ? 'right' : 'left';
},
},
methods: {
handleContextMenuClick() {
this.showContextMenu = !this.showContextMenu;
},
async handleDelete() {
const { conversation_id: conversationId, id: messageId } = this.data;
try {
await this.$store.dispatch('deleteMessage', {
conversationId,
messageId,
});
this.showAlert(this.$t('CONVERSATION.SUCCESS_DELETE_MESSAGE'));
this.showContextMenu = false;
} catch (error) {
this.showAlert(this.$t('CONVERSATION.FAIL_DELETE_MESSSAGE'));
}
},
handleCopy() {
copy(this.data.content);
this.showAlert(this.$t('CONTACT_PANEL.COPY_SUCCESSFUL'));
this.showContextMenu = false;
},
},
};
</script>
@@ -294,4 +356,42 @@ export default {
margin-left: var(--space-smaller);
}
}
.button--delete-message {
visibility: hidden;
}
li.left,
li.right {
display: flex;
align-items: flex-end;
&:hover .button--delete-message {
visibility: visible;
}
}
li.left.has-tweet-menu .context-menu {
margin-bottom: var(--space-medium);
}
li.right .context-menu-wrap {
margin-left: auto;
}
li.right {
flex-direction: row-reverse;
justify-content: flex-end;
}
.has-context-menu {
background: var(--color-background);
.button--delete-message {
visibility: visible;
}
}
.context-menu {
position: relative;
}
</style>

View File

@@ -56,6 +56,7 @@
<message
v-for="message in getReadMessages"
:key="message.id"
class="message--read"
:data="message"
:is-a-tweet="isATweet"
/>
@@ -72,6 +73,7 @@
<message
v-for="message in getUnReadMessages"
:key="message.id"
class="message--unread"
:data="message"
:is-a-tweet="isATweet"
/>
@@ -106,6 +108,7 @@ import { getTypingUsersText } from '../../../helper/commons';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { REPLY_POLICY } from 'shared/constants/links';
import inboxMixin from 'shared/mixins/inboxMixin';
import { calculateScrollTop } from './helpers/scrollTopCalculationHelper';
export default {
components: {
@@ -259,7 +262,23 @@ export default {
this.conversationPanel.removeEventListener('scroll', this.handleScroll);
},
scrollToBottom() {
this.conversationPanel.scrollTop = this.conversationPanel.scrollHeight;
let relevantMessages = [];
if (this.getUnreadCount > 0) {
// capturing only the unread messages
relevantMessages = this.conversationPanel.querySelectorAll(
'.message--unread'
);
} else {
// capturing last message from the messages list
relevantMessages = Array.from(
this.conversationPanel.querySelectorAll('.message--read')
).slice(-1);
}
this.conversationPanel.scrollTop = calculateScrollTop(
this.conversationPanel.scrollHeight,
this.$el.scrollHeight,
relevantMessages
);
},
onToggleContactPanel() {
this.$emit('contact-panel-toggle');

View File

@@ -1,4 +1,6 @@
const generateInputSelectContent = (contentType, contentAttributes) => {
import { CSAT_RATINGS } from '../../../../../shared/constants/messages';
const generateInputSelectContent = contentAttributes => {
const { submitted_values: submittedValues = [] } = contentAttributes;
const [selectedOption] = submittedValues;
@@ -8,7 +10,7 @@ const generateInputSelectContent = (contentType, contentAttributes) => {
return '';
};
const generateInputEmailContent = (contentType, contentAttributes) => {
const generateInputEmailContent = contentAttributes => {
const { submitted_email: submittedEmail = '' } = contentAttributes;
if (submittedEmail) {
return `<strong>${submittedEmail}</strong>`;
@@ -16,11 +18,7 @@ const generateInputEmailContent = (contentType, contentAttributes) => {
return '';
};
const generateFormContent = (
contentType,
contentAttributes,
noResponseText
) => {
const generateFormContent = (contentAttributes, { noResponseText }) => {
const { items, submitted_values: submittedValues = [] } = contentAttributes;
if (submittedValues.length) {
const submittedObject = submittedValues.reduce((acc, keyValuePair) => {
@@ -38,20 +36,52 @@ const generateFormContent = (
return '';
};
const generateCSATContent = (
contentAttributes,
{ ratingTitle, feedbackTitle }
) => {
const {
submitted_values: { csat_survey_response: surveyResponse = {} } = {},
} = contentAttributes;
const { rating, feedback_message } = surveyResponse || {};
let messageContent = '';
if (rating) {
const [ratingObject = {}] = CSAT_RATINGS.filter(
csatRating => csatRating.value === rating
);
messageContent += `<div><strong>${ratingTitle}</strong></div>`;
messageContent += `<p>${ratingObject.emoji}</p>`;
}
if (feedback_message) {
messageContent += `<div><strong>${feedbackTitle}</strong></div>`;
messageContent += `<p>${feedback_message}</p>`;
}
return messageContent;
};
export const generateBotMessageContent = (
contentType,
contentAttributes,
noResponseText = 'No response'
{
noResponseText = 'No response',
csat: { ratingTitle = 'Rating', feedbackTitle = 'Feedback' } = {},
} = {}
) => {
const contentTypeMethods = {
input_select: generateInputSelectContent,
input_email: generateInputEmailContent,
form: generateFormContent,
input_csat: generateCSATContent,
};
const contentTypeMethod = contentTypeMethods[contentType];
if (contentTypeMethod && typeof contentTypeMethod === 'function') {
return contentTypeMethod(contentType, contentAttributes, noResponseText);
return contentTypeMethod(contentAttributes, {
noResponseText,
ratingTitle,
feedbackTitle,
});
}
return '';
};

View File

@@ -0,0 +1,18 @@
const totalMessageHeight = (total, element) => {
return total + element.scrollHeight;
};
export const calculateScrollTop = (
conversationPanelHeight,
parentHeight,
relevantMessages
) => {
// add up scrollHeight of a `relevantMessages`
let combinedMessageScrollHeight = [...relevantMessages].reduce(
totalMessageHeight,
0
);
return (
conversationPanelHeight - combinedMessageScrollHeight - parentHeight / 2
);
};

View File

@@ -17,6 +17,33 @@ describe('#generateBotMessageContent', () => {
).toEqual('<strong>hello@chatwoot.com</strong>');
});
it('return correct input_csat content', () => {
expect(
generateBotMessageContent('input_csat', {
submitted_values: {
csat_survey_response: {
rating: 5,
feedback_message: 'Great Service',
},
},
})
).toEqual(
'<div><strong>Rating</strong></div><p>😍</p><div><strong>Feedback</strong></div><p>Great Service</p>'
);
expect(
generateBotMessageContent(
'input_csat',
{
submitted_values: {
csat_survey_response: { rating: 1, feedback_message: '' },
},
},
{ csat: { ratingTitle: 'റേറ്റിംഗ്', feedbackTitle: 'പ്രതികരണം' } }
)
).toEqual('<div><strong>റേറ്റിംഗ്</strong></div><p>😞</p>');
});
it('return correct form content', () => {
expect(
generateBotMessageContent('form', {

View File

@@ -0,0 +1,18 @@
import { calculateScrollTop } from '../scrollTopCalculationHelper';
describe('#calculateScrollTop', () => {
it('returns calculated value of the scrollTop property', () => {
class DOMElement {
constructor(scrollHeight) {
this.scrollHeight = scrollHeight;
}
}
let count = 3;
let relevantMessages = [];
while (count > 0) {
relevantMessages.push(new DOMElement(100));
count -= 1;
}
expect(calculateScrollTop(1000, 300, relevantMessages)).toEqual(550);
});
});

View File

@@ -7,10 +7,7 @@ export const getSidebarItems = accountId => ({
'inbox_dashboard',
'inbox_conversation',
'conversation_through_inbox',
'contacts_dashboard',
'contacts_dashboard_manage',
'notifications_dashboard',
'settings_account_reports',
'profile_settings',
'profile_settings_index',
'label_conversations',
@@ -59,6 +56,55 @@ export const getSidebarItems = accountId => ({
},
},
},
contacts: {
routes: [
'contacts_dashboard',
'contacts_dashboard_manage',
'contacts_labels_dashboard',
],
menuItems: {
back: {
icon: 'ion-ios-arrow-back',
label: 'HOME',
hasSubMenu: false,
toStateName: 'home',
toState: frontendURL(`accounts/${accountId}/dashboard`),
},
contacts: {
icon: 'ion-person',
label: 'ALL_CONTACTS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/contacts`),
toStateName: 'contacts_dashboard',
},
},
},
reports: {
routes: ['settings_account_reports', 'csat_reports'],
menuItems: {
back: {
icon: 'ion-ios-arrow-back',
label: 'HOME',
hasSubMenu: false,
toStateName: 'home',
toState: frontendURL(`accounts/${accountId}/dashboard`),
},
reportOverview: {
icon: 'ion-arrow-graph-up-right',
label: 'REPORTS_OVERVIEW',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/overview`),
toStateName: 'settings_account_reports',
},
csatReports: {
icon: 'ion-happy',
label: 'CSAT',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/csat`),
toStateName: 'csat_reports',
},
},
},
settings: {
routes: [
'agent_list',

View File

@@ -90,7 +90,7 @@
}
},
"SEARCH": {
"NO_RESULTS": "لم يتم العثور على موظفين."
"NO_RESULTS": "لم يتم العثور على النتائج."
}
}
}

View File

@@ -14,6 +14,17 @@
"PLACEHOLDER": "Please enter the title of campaign",
"ERROR": "Title is required"
},
"SCHEDULED_AT": {
"LABEL": "Scheduled time",
"PLACEHOLDER": "Please select the time",
"CONFIRM": "Confirm",
"ERROR": "Scheduled time is required"
},
"AUDIENCE": {
"LABEL": "Audience",
"PLACEHOLDER": "Select the customer labels",
"ERROR": "Audience is required"
},
"MESSAGE": {
"LABEL": "رسالة",
"PLACEHOLDER": "Please enter the message of campaign",
@@ -72,6 +83,7 @@
"STATUS": "الحالة",
"SENDER": "Sender",
"URL": "URL",
"SCHEDULED_AT": "Scheduled time",
"TIME_ON_PAGE": "Time(Seconds)",
"CREATED_AT": "Created at"
},
@@ -82,7 +94,9 @@
},
"STATUS": {
"ENABLED": "مفعل",
"DISABLED": "معطّل"
"DISABLED": "معطّل",
"COMPLETED": "Completed",
"ACTIVE": "Active"
},
"SENDER": {
"BOT": "رد آلي"

View File

@@ -131,11 +131,13 @@
},
"CONTACTS_PAGE": {
"HEADER": "جهات الاتصال",
"FIELDS": "Contact fields",
"SEARCH_BUTTON": "بحث",
"SEARCH_INPUT_PLACEHOLDER": "بحث عن جهات الاتصال",
"LIST": {
"LOADING_MESSAGE": "جاري تحميل جهات الاتصال...",
"404": "لا توجد جهات اتصال تطابق بحثك 🔍",
"NO_CONTACTS": "There are no available contacts",
"TABLE_HEADER": {
"NAME": "الاسم",
"PHONE_NUMBER": "رقم الهاتف",
@@ -203,5 +205,33 @@
"PLACEHOLDER": "Eg: 11901 "
}
}
},
"MERGE_CONTACTS": {
"TITLE": "Merge contacts",
"DESCRIPTION": "Merge contact is helpful when you have duplicated entries of the same contact. Merging action takes a primary contact and a child contact. After merging, all details in the primary contact will remain the same. If the primary contact doesn't have a field, then the value from the child contact will be used after merging. If a conflict happens, fields in primary contact will remain unaffected, but fields from secondary will be copied to the custom attributes in the primary contact.",
"PRIMARY": {
"TITLE": "Primary contact"
},
"CHILD": {
"TITLE": "Contact to merge",
"PLACEHOLDER": "Choose a contact"
},
"SUMMARY": {
"TITLE": "Summary",
"DELETE_WARNING": "Contact of <strong>%{childContactName}</strong>will be deleted.",
"ATTRIBUTE_WARNING": "Contact details of <strong>%{childContactName}</strong> will be copied to <strong>%{primaryContactName}</strong>."
},
"SEARCH": {
"ERROR": "ERROR_MESSAGE"
},
"FORM": {
"SUBMIT": " Merge contacts",
"CANCEL": "إلغاء",
"CHILD_CONTACT": {
"ERROR": "Select a child contact to merge"
},
"SUCCESS_MESSAGE": "Contact merged successfully",
"ERROR_MESSAGE": "Could not merge contcts, try again!"
}
}
}

View File

@@ -27,7 +27,11 @@
"REMOVE_SELECTION": "إزالة التحديد",
"DOWNLOAD": "تنزيل",
"UPLOADING_ATTACHMENTS": "جاري تحميل المرفقات...",
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
"FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again",
"NO_RESPONSE": "لا توجد استجابة",
"RATING_TITLE": "Rating",
"FEEDBACK_TITLE": "Feedback",
"HEADER": {
"RESOLVE_ACTION": "إغلاق المحادثة",
"REOPEN_ACTION": "إعادة فتح",
@@ -52,7 +56,8 @@
"TIP_FORMAT_ICON": "عرض محرر النصوص",
"TIP_EMOJI_ICON": "إظهار قائمة الرموز التعبيرية",
"TIP_ATTACH_ICON": "إرفاق الملفات",
"ENTER_TO_SEND": "زر الإدخل للإرسال"
"ENTER_TO_SEND": "زر الإدخل للإرسال",
"DRAG_DROP": "Drag and drop here to attach"
},
"VISIBLE_TO_AGENTS": "ملاحظة خاصة: مرئية فقط لأعضاء فريق العمل والموظفين",
"CHANGE_STATUS": "تم تغيير حالة المحادثة",
@@ -64,6 +69,10 @@
"SELECT_AGENT": "اختر وكيل",
"REMOVE": "حذف",
"ASSIGN": "تكليف"
},
"CONTEXT_MENU": {
"COPY": "نسخ",
"DELETE": "حذف"
}
},
"EMAIL_TRANSCRIPT": {

View File

@@ -0,0 +1,6 @@
{
"CSAT": {
"TITLE": "Rate your conversation",
"PLACEHOLDER": "Tell us more..."
}
}

View File

@@ -127,6 +127,14 @@
"ERROR_MESSAGE": "تعذر تكوين المصادقة بواسطة بيانات الاعتماد الخاصة بحسابك على Twilio، يرجى المحاولة مرة أخرى"
}
},
"SMS": {
"TITLE": "SMS Channel via Twilio",
"DESC": "Start supporting your customers via SMS with Twilio integration."
},
"WHATSAPP": {
"TITLE": "Whatsapp Channel via Twilio",
"DESC": "Start supporting your customers via Whatsapp with Twilio integration."
},
"API_CHANNEL": {
"TITLE": "قناة API",
"DESC": "اربط مع قناة API وابدأ في دعم عملائك.",
@@ -165,8 +173,8 @@
"FINISH_MESSAGE": "بدء إعادة توجيه رسائل البريد الإلكتروني الخاصة بك إلى عنوان البريد الإلكتروني التالي."
},
"AUTH": {
"TITLE": "القنوات",
"DESC": "ندعم حالياً تثبيت برنامج الدردشة المباشرة على موقعك وربط صفحات الفيس بوك وحسابات تويتر. جار العمل على دعم المزيد من المنصات الأخرى مثل واتساب، و البريد الإلكتروني، و تلغرام و لاين، والتي ستكون متاحة قريباً."
"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."
},
"AGENTS": {
"TITLE": "موظف الدعم",
@@ -216,6 +224,10 @@
"EMAIL_COLLECT_BOX": {
"ENABLED": "مفعل",
"DISABLED": "معطّل"
},
"ENABLE_CSAT": {
"ENABLED": "مفعل",
"DISABLED": "معطّل"
}
},
"DELETE": {
@@ -255,6 +267,8 @@
"ENABLE_EMAIL_COLLECT_BOX": "Enable email collect box",
"ENABLE_EMAIL_COLLECT_BOX_SUB_TEXT": "Enable or disable email collect box on new conversation",
"AUTO_ASSIGNMENT": "تفعيل الإسناد التلقائي",
"ENABLE_CSAT": "Enable CSAT",
"ENABLE_CSAT_SUB_TEXT": "Enable/Disable CSAT(Customer satisfaction) survey after resolving a conversation",
"INBOX_UPDATE_TITLE": "إعدادات قناة التواصل",
"INBOX_UPDATE_SUB_TEXT": "تحديث إعدادات قناة التواصل",
"AUTO_ASSIGNMENT_SUB_TEXT": "تمكين أو تعطيل الإسناد التلقائي للمحادثات الجديدة إلى الموظفين المضافين إلى قناة التواصل هذه.",

View File

@@ -66,6 +66,12 @@
}
}
},
"SLACK": {
"HELP_TEXT": {
"TITLE": "Using Slack Integration",
"BODY": "<br/><p>Chatwoot will now sync all the incoming conversations into the <b><i>customer-conversations</i></b> channel inside your slack workplace.</p><p>Replying to a conversation thread in <b><i>customer-conversations</i></b> slack channel will create a response back to the customer through chatwoot.</p><p>Start the replies with <b><i>note:</i></b> to create private notes instead of replies.</p><p>If the replier on slack has an agent profile in chatwoot under the same email, the replies will be associated accordingly.</p><p>When the replier doesn't have an associated agent profile, the replies will be made from the bot profile.</p>"
}
},
"DELETE": {
"BUTTON_TEXT": "حذف",
"API": {

View File

@@ -1,6 +1,6 @@
{
"REPORT": {
"HEADER": "التقارير",
"HEADER": "Overview",
"LOADING_CHART": "تحميل بيانات الرسم البياني...",
"NO_ENOUGH_DATA": "لم يتم جمع بيانات بقدر كافي لإنشاء التقرير، الرجاء المحاولة مرة أخرى لاحقاً.",
"DOWNLOAD_AGENT_REPORTS": "تنزيل تقارير الوكيل",
@@ -50,7 +50,41 @@
{
"id": 4,
"name": "Last year"
},
{
"id": 5,
"name": "Custom date range"
}
]
],
"CUSTOM_DATE_RANGE": {
"CONFIRM": "Apply",
"PLACEHOLDER": "Select date range"
}
},
"CSAT_REPORTS": {
"HEADER": "CSAT Reports",
"NO_RECORDS": "There are no CSAT survey responses available.",
"TABLE": {
"HEADER": {
"CONTACT_NAME": "Contact",
"AGENT_NAME": "Assigned agent",
"RATING": "Rating",
"FEEDBACK_TEXT": "Feedback comment"
}
},
"METRIC": {
"TOTAL_RESPONSES": {
"LABEL": "Total responses",
"TOOLTIP": "Total number of responses collected"
},
"SATISFACTION_SCORE": {
"LABEL": "Satisfaction score",
"TOOLTIP": "Total number of positive responses / Total number of responses * 100"
},
"RESPONSE_RATE": {
"LABEL": "Response rate",
"TOOLTIP": "Total number of responses / Total number of CSAT survey messages sent * 100"
}
}
}
}

View File

@@ -28,7 +28,9 @@
"AUDIO_NOTIFICATIONS_SECTION": {
"TITLE": "الإشعارات الصوتية",
"NOTE": "تمكين التنبيهات الصوتية في لوحة التحكم للرسائل والمحادثات الجديدة.",
"ENABLE_AUDIO": "تشغيل إشعار صوتي عند إنشاء محادثة جديدة أو وصول رسائل جديدة"
"NONE": "لا شيء",
"ASSIGNED": "Assigned Conversations",
"ALL_CONVERSATIONS": "All Conversations"
},
"EMAIL_NOTIFICATIONS_SECTION": {
"TITLE": "إشعارات البريد الإلكتروني",
@@ -139,7 +141,11 @@
"ACCOUNT_SETTINGS": "إعدادات الحساب",
"APPLICATIONS": "Applications",
"LABELS": "الوسوم",
"TEAMS": "الفرق"
"TEAMS": "الفرق",
"ALL_CONTACTS": "All Contacts",
"TAGGED_WITH": "Tagged with",
"REPORTS_OVERVIEW": "Overview",
"CSAT": "CSAT"
},
"CREATE_ACCOUNT": {
"NEW_ACCOUNT": "حساب جديد",

View File

@@ -90,7 +90,7 @@
}
},
"SEARCH": {
"NO_RESULTS": "No s'han trobat agents."
"NO_RESULTS": "No results found."
}
}
}

View File

@@ -14,6 +14,17 @@
"PLACEHOLDER": "Please enter the title of campaign",
"ERROR": "Title is required"
},
"SCHEDULED_AT": {
"LABEL": "Scheduled time",
"PLACEHOLDER": "Please select the time",
"CONFIRM": "Confirm",
"ERROR": "Scheduled time is required"
},
"AUDIENCE": {
"LABEL": "Audience",
"PLACEHOLDER": "Select the customer labels",
"ERROR": "Audience is required"
},
"MESSAGE": {
"LABEL": "Missatge",
"PLACEHOLDER": "Please enter the message of campaign",
@@ -72,6 +83,7 @@
"STATUS": "Estat",
"SENDER": "Sender",
"URL": "URL",
"SCHEDULED_AT": "Scheduled time",
"TIME_ON_PAGE": "Time(Seconds)",
"CREATED_AT": "Created at"
},
@@ -82,7 +94,9 @@
},
"STATUS": {
"ENABLED": "Habilita",
"DISABLED": "Inhabilita"
"DISABLED": "Inhabilita",
"COMPLETED": "Completed",
"ACTIVE": "Active"
},
"SENDER": {
"BOT": "Bot"

View File

@@ -131,11 +131,13 @@
},
"CONTACTS_PAGE": {
"HEADER": "Contactes",
"FIELDS": "Contact fields",
"SEARCH_BUTTON": "Cercar",
"SEARCH_INPUT_PLACEHOLDER": "Cerca de contactes",
"LIST": {
"LOADING_MESSAGE": "Carregant contactes...",
"404": "No hi ha cap contacte que coincideixi amb la vostra cerca 🔍",
"NO_CONTACTS": "There are no available contacts",
"TABLE_HEADER": {
"NAME": "Nom",
"PHONE_NUMBER": "Número de telèfon",
@@ -203,5 +205,33 @@
"PLACEHOLDER": "Eg: 11901 "
}
}
},
"MERGE_CONTACTS": {
"TITLE": "Merge contacts",
"DESCRIPTION": "Merge contact is helpful when you have duplicated entries of the same contact. Merging action takes a primary contact and a child contact. After merging, all details in the primary contact will remain the same. If the primary contact doesn't have a field, then the value from the child contact will be used after merging. If a conflict happens, fields in primary contact will remain unaffected, but fields from secondary will be copied to the custom attributes in the primary contact.",
"PRIMARY": {
"TITLE": "Primary contact"
},
"CHILD": {
"TITLE": "Contact to merge",
"PLACEHOLDER": "Choose a contact"
},
"SUMMARY": {
"TITLE": "Summary",
"DELETE_WARNING": "Contact of <strong>%{childContactName}</strong>will be deleted.",
"ATTRIBUTE_WARNING": "Contact details of <strong>%{childContactName}</strong> will be copied to <strong>%{primaryContactName}</strong>."
},
"SEARCH": {
"ERROR": "ERROR_MESSAGE"
},
"FORM": {
"SUBMIT": " Merge contacts",
"CANCEL": "Cancel·la",
"CHILD_CONTACT": {
"ERROR": "Select a child contact to merge"
},
"SUCCESS_MESSAGE": "Contact merged successfully",
"ERROR_MESSAGE": "Could not merge contcts, try again!"
}
}
}

View File

@@ -27,7 +27,11 @@
"REMOVE_SELECTION": "Elimina la selecció",
"DOWNLOAD": "Descarrega",
"UPLOADING_ATTACHMENTS": "Pujant fitxers adjunts...",
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
"FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again",
"NO_RESPONSE": "Sense resposta",
"RATING_TITLE": "Rating",
"FEEDBACK_TITLE": "Feedback",
"HEADER": {
"RESOLVE_ACTION": "Resoldre",
"REOPEN_ACTION": "Tornar a obrir",
@@ -52,7 +56,8 @@
"TIP_FORMAT_ICON": "Mostra l'editor de text enriquit",
"TIP_EMOJI_ICON": "Mostra la selecció d'emoticones",
"TIP_ATTACH_ICON": "Ajuntar fitxers",
"ENTER_TO_SEND": "Intro per enviar"
"ENTER_TO_SEND": "Intro per enviar",
"DRAG_DROP": "Drag and drop here to attach"
},
"VISIBLE_TO_AGENTS": "Nota privada: Només és visible per tu i el vostre equip",
"CHANGE_STATUS": "Estat de la conversa canviat",
@@ -64,6 +69,10 @@
"SELECT_AGENT": "Seleccionar Agent",
"REMOVE": "Suprimeix",
"ASSIGN": "Assignar"
},
"CONTEXT_MENU": {
"COPY": "Copia",
"DELETE": "Esborrar"
}
},
"EMAIL_TRANSCRIPT": {

View File

@@ -0,0 +1,6 @@
{
"CSAT": {
"TITLE": "Rate your conversation",
"PLACEHOLDER": "Tell us more..."
}
}

View File

@@ -127,6 +127,14 @@
"ERROR_MESSAGE": "No hem pogut autenticar les credencials de Twilio, prova de nou"
}
},
"SMS": {
"TITLE": "SMS Channel via Twilio",
"DESC": "Start supporting your customers via SMS with Twilio integration."
},
"WHATSAPP": {
"TITLE": "Whatsapp Channel via Twilio",
"DESC": "Start supporting your customers via Whatsapp with Twilio integration."
},
"API_CHANNEL": {
"TITLE": "Canal de l'API",
"DESC": "Integrat amb el canal API i comença a donar suport als teus clients.",
@@ -165,8 +173,8 @@
"FINISH_MESSAGE": "Comença a reenviar els teus correus electrònics a la següent adreça electrònica."
},
"AUTH": {
"TITLE": "Canals",
"DESC": "Actualment estan suportats widgets de xat en directe per a llocs web, pàgines de Facebook i perfils de Twitter. Estem treballant en més plataformes com Whatsapp, correu electrònic, Telegram i Line, que estaran disponibles en breu."
"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."
},
"AGENTS": {
"TITLE": "Agents",
@@ -216,6 +224,10 @@
"EMAIL_COLLECT_BOX": {
"ENABLED": "Habilita",
"DISABLED": "Inhabilita"
},
"ENABLE_CSAT": {
"ENABLED": "Habilita",
"DISABLED": "Inhabilita"
}
},
"DELETE": {
@@ -255,6 +267,8 @@
"ENABLE_EMAIL_COLLECT_BOX": "Enable email collect box",
"ENABLE_EMAIL_COLLECT_BOX_SUB_TEXT": "Enable or disable email collect box on new conversation",
"AUTO_ASSIGNMENT": "Activa l'assignació automàtica",
"ENABLE_CSAT": "Enable CSAT",
"ENABLE_CSAT_SUB_TEXT": "Enable/Disable CSAT(Customer satisfaction) survey after resolving a conversation",
"INBOX_UPDATE_TITLE": "Configuració de la safata d'entrada",
"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",

View File

@@ -66,6 +66,12 @@
}
}
},
"SLACK": {
"HELP_TEXT": {
"TITLE": "Using Slack Integration",
"BODY": "<br/><p>Chatwoot will now sync all the incoming conversations into the <b><i>customer-conversations</i></b> channel inside your slack workplace.</p><p>Replying to a conversation thread in <b><i>customer-conversations</i></b> slack channel will create a response back to the customer through chatwoot.</p><p>Start the replies with <b><i>note:</i></b> to create private notes instead of replies.</p><p>If the replier on slack has an agent profile in chatwoot under the same email, the replies will be associated accordingly.</p><p>When the replier doesn't have an associated agent profile, the replies will be made from the bot profile.</p>"
}
},
"DELETE": {
"BUTTON_TEXT": "Suprimeix",
"API": {

View File

@@ -1,6 +1,6 @@
{
"REPORT": {
"HEADER": "Informes",
"HEADER": "Overview",
"LOADING_CHART": "S'estan carregant dades del gràfic...",
"NO_ENOUGH_DATA": "No hem rebut suficients punts de dades per generar l'informe. Torneu-ho a provar més endavant.",
"DOWNLOAD_AGENT_REPORTS": "Descarregar Informes d'Agent",
@@ -50,7 +50,41 @@
{
"id": 4,
"name": "Last year"
},
{
"id": 5,
"name": "Custom date range"
}
]
],
"CUSTOM_DATE_RANGE": {
"CONFIRM": "Apply",
"PLACEHOLDER": "Select date range"
}
},
"CSAT_REPORTS": {
"HEADER": "CSAT Reports",
"NO_RECORDS": "There are no CSAT survey responses available.",
"TABLE": {
"HEADER": {
"CONTACT_NAME": "Contact",
"AGENT_NAME": "Assigned agent",
"RATING": "Rating",
"FEEDBACK_TEXT": "Feedback comment"
}
},
"METRIC": {
"TOTAL_RESPONSES": {
"LABEL": "Total responses",
"TOOLTIP": "Total number of responses collected"
},
"SATISFACTION_SCORE": {
"LABEL": "Satisfaction score",
"TOOLTIP": "Total number of positive responses / Total number of responses * 100"
},
"RESPONSE_RATE": {
"LABEL": "Response rate",
"TOOLTIP": "Total number of responses / Total number of CSAT survey messages sent * 100"
}
}
}
}

View File

@@ -28,7 +28,9 @@
"AUDIO_NOTIFICATIONS_SECTION": {
"TITLE": "Audio Notifications",
"NOTE": "Enable audio notifications in dashboard for new messages and conversations.",
"ENABLE_AUDIO": "Play audio notification when a new conversation is created or new messages arrives"
"NONE": "None",
"ASSIGNED": "Assigned Conversations",
"ALL_CONVERSATIONS": "All Conversations"
},
"EMAIL_NOTIFICATIONS_SECTION": {
"TITLE": "Notificacions per correu electrònic",
@@ -139,7 +141,11 @@
"ACCOUNT_SETTINGS": "Configuració del compte",
"APPLICATIONS": "Applications",
"LABELS": "Etiquetes",
"TEAMS": "Equips"
"TEAMS": "Equips",
"ALL_CONTACTS": "All Contacts",
"TAGGED_WITH": "Tagged with",
"REPORTS_OVERVIEW": "Overview",
"CSAT": "CSAT"
},
"CREATE_ACCOUNT": {
"NEW_ACCOUNT": "Compte nou",

View File

@@ -90,7 +90,7 @@
}
},
"SEARCH": {
"NO_RESULTS": "Nebyli nalezeni žádní agenti."
"NO_RESULTS": "Žádné výsledky."
}
}
}

View File

@@ -14,6 +14,17 @@
"PLACEHOLDER": "Please enter the title of campaign",
"ERROR": "Title is required"
},
"SCHEDULED_AT": {
"LABEL": "Scheduled time",
"PLACEHOLDER": "Please select the time",
"CONFIRM": "Confirm",
"ERROR": "Scheduled time is required"
},
"AUDIENCE": {
"LABEL": "Audience",
"PLACEHOLDER": "Select the customer labels",
"ERROR": "Audience is required"
},
"MESSAGE": {
"LABEL": "Zpráva",
"PLACEHOLDER": "Please enter the message of campaign",
@@ -72,6 +83,7 @@
"STATUS": "Stav",
"SENDER": "Sender",
"URL": "URL",
"SCHEDULED_AT": "Scheduled time",
"TIME_ON_PAGE": "Time(Seconds)",
"CREATED_AT": "Created at"
},
@@ -82,7 +94,9 @@
},
"STATUS": {
"ENABLED": "Povoleno",
"DISABLED": "Zakázáno"
"DISABLED": "Zakázáno",
"COMPLETED": "Completed",
"ACTIVE": "Active"
},
"SENDER": {
"BOT": "Bot"

View File

@@ -131,11 +131,13 @@
},
"CONTACTS_PAGE": {
"HEADER": "Kontakty",
"FIELDS": "Contact fields",
"SEARCH_BUTTON": "Hledat",
"SEARCH_INPUT_PLACEHOLDER": "Hledat kontakty",
"LIST": {
"LOADING_MESSAGE": "Načítání kontaktů...",
"404": "Vašemu hledání neodpovídají žádné kontakty 🔍",
"NO_CONTACTS": "There are no available contacts",
"TABLE_HEADER": {
"NAME": "Název",
"PHONE_NUMBER": "Telefonní číslo",
@@ -203,5 +205,33 @@
"PLACEHOLDER": "Eg: 11901 "
}
}
},
"MERGE_CONTACTS": {
"TITLE": "Merge contacts",
"DESCRIPTION": "Merge contact is helpful when you have duplicated entries of the same contact. Merging action takes a primary contact and a child contact. After merging, all details in the primary contact will remain the same. If the primary contact doesn't have a field, then the value from the child contact will be used after merging. If a conflict happens, fields in primary contact will remain unaffected, but fields from secondary will be copied to the custom attributes in the primary contact.",
"PRIMARY": {
"TITLE": "Primary contact"
},
"CHILD": {
"TITLE": "Contact to merge",
"PLACEHOLDER": "Choose a contact"
},
"SUMMARY": {
"TITLE": "Summary",
"DELETE_WARNING": "Contact of <strong>%{childContactName}</strong>will be deleted.",
"ATTRIBUTE_WARNING": "Contact details of <strong>%{childContactName}</strong> will be copied to <strong>%{primaryContactName}</strong>."
},
"SEARCH": {
"ERROR": "ERROR_MESSAGE"
},
"FORM": {
"SUBMIT": " Merge contacts",
"CANCEL": "Zrušit",
"CHILD_CONTACT": {
"ERROR": "Select a child contact to merge"
},
"SUCCESS_MESSAGE": "Contact merged successfully",
"ERROR_MESSAGE": "Could not merge contcts, try again!"
}
}
}

View File

@@ -27,7 +27,11 @@
"REMOVE_SELECTION": "Odstranit výběr",
"DOWNLOAD": "Stáhnout",
"UPLOADING_ATTACHMENTS": "Nahrávání příloh...",
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
"FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again",
"NO_RESPONSE": "Bez odpovědi",
"RATING_TITLE": "Rating",
"FEEDBACK_TITLE": "Feedback",
"HEADER": {
"RESOLVE_ACTION": "Vyřešit",
"REOPEN_ACTION": "Znovu otevřít",
@@ -52,7 +56,8 @@
"TIP_FORMAT_ICON": "Zobrazit formátovaný textový editor",
"TIP_EMOJI_ICON": "Zobrazit výběr emoji",
"TIP_ATTACH_ICON": "Přiložit soubory",
"ENTER_TO_SEND": "Enter to send"
"ENTER_TO_SEND": "Enter to send",
"DRAG_DROP": "Drag and drop here to attach"
},
"VISIBLE_TO_AGENTS": "Soukromá poznámka: Viditelné pouze pro vás a váš tým",
"CHANGE_STATUS": "Stav konverzace byl změněn",
@@ -64,6 +69,10 @@
"SELECT_AGENT": "Vybrat agenta",
"REMOVE": "Odebrat",
"ASSIGN": "Přiřadit"
},
"CONTEXT_MENU": {
"COPY": "Kopírovat",
"DELETE": "Vymazat"
}
},
"EMAIL_TRANSCRIPT": {

View File

@@ -0,0 +1,6 @@
{
"CSAT": {
"TITLE": "Rate your conversation",
"PLACEHOLDER": "Tell us more..."
}
}

View File

@@ -127,6 +127,14 @@
"ERROR_MESSAGE": "Nebyli jsme schopni ověřit přihlašovací údaje Twilia, zkuste to prosím znovu"
}
},
"SMS": {
"TITLE": "SMS Channel via Twilio",
"DESC": "Start supporting your customers via SMS with Twilio integration."
},
"WHATSAPP": {
"TITLE": "Whatsapp Channel via Twilio",
"DESC": "Start supporting your customers via Whatsapp with Twilio integration."
},
"API_CHANNEL": {
"TITLE": "API Channel",
"DESC": "Integrate with API channel and start supporting your customers.",
@@ -165,8 +173,8 @@
"FINISH_MESSAGE": "Start forwarding your emails to the following email address."
},
"AUTH": {
"TITLE": "Kanály",
"DESC": "V současné době podporujeme jako platformu widgety živého chatu, Facebook stránky a Twitter profily. Máme více platforem jako Whatsapp, Email, Telegram a Line v dílech, což bude brzy ven."
"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."
},
"AGENTS": {
"TITLE": "Agenti",
@@ -216,6 +224,10 @@
"EMAIL_COLLECT_BOX": {
"ENABLED": "Povoleno",
"DISABLED": "Zakázáno"
},
"ENABLE_CSAT": {
"ENABLED": "Povoleno",
"DISABLED": "Zakázáno"
}
},
"DELETE": {
@@ -255,6 +267,8 @@
"ENABLE_EMAIL_COLLECT_BOX": "Enable email collect box",
"ENABLE_EMAIL_COLLECT_BOX_SUB_TEXT": "Enable or disable email collect box on new conversation",
"AUTO_ASSIGNMENT": "Povolit automatické přiřazení",
"ENABLE_CSAT": "Enable CSAT",
"ENABLE_CSAT_SUB_TEXT": "Enable/Disable CSAT(Customer satisfaction) survey after resolving a conversation",
"INBOX_UPDATE_TITLE": "Nastavení doručené pošty",
"INBOX_UPDATE_SUB_TEXT": "Aktualizujte nastavení doručené pošty",
"AUTO_ASSIGNMENT_SUB_TEXT": "Povolit nebo zakázat automatické přiřazování nových konverzací agentům přidaným do této schránky.",

View File

@@ -66,6 +66,12 @@
}
}
},
"SLACK": {
"HELP_TEXT": {
"TITLE": "Using Slack Integration",
"BODY": "<br/><p>Chatwoot will now sync all the incoming conversations into the <b><i>customer-conversations</i></b> channel inside your slack workplace.</p><p>Replying to a conversation thread in <b><i>customer-conversations</i></b> slack channel will create a response back to the customer through chatwoot.</p><p>Start the replies with <b><i>note:</i></b> to create private notes instead of replies.</p><p>If the replier on slack has an agent profile in chatwoot under the same email, the replies will be associated accordingly.</p><p>When the replier doesn't have an associated agent profile, the replies will be made from the bot profile.</p>"
}
},
"DELETE": {
"BUTTON_TEXT": "Vymazat",
"API": {

View File

@@ -1,6 +1,6 @@
{
"REPORT": {
"HEADER": "Zprávy",
"HEADER": "Overview",
"LOADING_CHART": "Načítání dat mapy...",
"NO_ENOUGH_DATA": "Pro vytvoření hlášení jsme neobdrželi dostatek dat, zkuste to prosím později.",
"DOWNLOAD_AGENT_REPORTS": "Stáhnout reporty agentů",
@@ -50,7 +50,41 @@
{
"id": 4,
"name": "Last year"
},
{
"id": 5,
"name": "Custom date range"
}
]
],
"CUSTOM_DATE_RANGE": {
"CONFIRM": "Apply",
"PLACEHOLDER": "Select date range"
}
},
"CSAT_REPORTS": {
"HEADER": "CSAT Reports",
"NO_RECORDS": "There are no CSAT survey responses available.",
"TABLE": {
"HEADER": {
"CONTACT_NAME": "Contact",
"AGENT_NAME": "Assigned agent",
"RATING": "Rating",
"FEEDBACK_TEXT": "Feedback comment"
}
},
"METRIC": {
"TOTAL_RESPONSES": {
"LABEL": "Total responses",
"TOOLTIP": "Total number of responses collected"
},
"SATISFACTION_SCORE": {
"LABEL": "Satisfaction score",
"TOOLTIP": "Total number of positive responses / Total number of responses * 100"
},
"RESPONSE_RATE": {
"LABEL": "Response rate",
"TOOLTIP": "Total number of responses / Total number of CSAT survey messages sent * 100"
}
}
}
}

View File

@@ -28,7 +28,9 @@
"AUDIO_NOTIFICATIONS_SECTION": {
"TITLE": "Audio Notifications",
"NOTE": "Enable audio notifications in dashboard for new messages and conversations.",
"ENABLE_AUDIO": "Play audio notification when a new conversation is created or new messages arrives"
"NONE": "Nic",
"ASSIGNED": "Assigned Conversations",
"ALL_CONVERSATIONS": "All Conversations"
},
"EMAIL_NOTIFICATIONS_SECTION": {
"TITLE": "E-mailová oznámení",
@@ -139,7 +141,11 @@
"ACCOUNT_SETTINGS": "Nastavení účtu",
"APPLICATIONS": "Applications",
"LABELS": "Štítky",
"TEAMS": "Týmy"
"TEAMS": "Týmy",
"ALL_CONTACTS": "All Contacts",
"TAGGED_WITH": "Tagged with",
"REPORTS_OVERVIEW": "Overview",
"CSAT": "CSAT"
},
"CREATE_ACCOUNT": {
"NEW_ACCOUNT": "Nový účet",

View File

@@ -90,7 +90,7 @@
}
},
"SEARCH": {
"NO_RESULTS": "Ingen agenter fundet."
"NO_RESULTS": "No results found."
}
}
}

View File

@@ -14,6 +14,17 @@
"PLACEHOLDER": "Please enter the title of campaign",
"ERROR": "Title is required"
},
"SCHEDULED_AT": {
"LABEL": "Scheduled time",
"PLACEHOLDER": "Please select the time",
"CONFIRM": "Confirm",
"ERROR": "Scheduled time is required"
},
"AUDIENCE": {
"LABEL": "Audience",
"PLACEHOLDER": "Select the customer labels",
"ERROR": "Audience is required"
},
"MESSAGE": {
"LABEL": "Message",
"PLACEHOLDER": "Please enter the message of campaign",
@@ -72,6 +83,7 @@
"STATUS": "Status",
"SENDER": "Sender",
"URL": "URL",
"SCHEDULED_AT": "Scheduled time",
"TIME_ON_PAGE": "Time(Seconds)",
"CREATED_AT": "Created at"
},
@@ -82,7 +94,9 @@
},
"STATUS": {
"ENABLED": "Aktiveret",
"DISABLED": "Deaktiveret"
"DISABLED": "Deaktiveret",
"COMPLETED": "Completed",
"ACTIVE": "Active"
},
"SENDER": {
"BOT": "Bot"

View File

@@ -131,11 +131,13 @@
},
"CONTACTS_PAGE": {
"HEADER": "Kontakter",
"FIELDS": "Contact fields",
"SEARCH_BUTTON": "Søg",
"SEARCH_INPUT_PLACEHOLDER": "Søg efter kontakter",
"LIST": {
"LOADING_MESSAGE": "Indlæser kontakter...",
"404": "Ingen kontakter matcher din søgning 🔍",
"NO_CONTACTS": "There are no available contacts",
"TABLE_HEADER": {
"NAME": "Navn",
"PHONE_NUMBER": "Telefonnummer",
@@ -203,5 +205,33 @@
"PLACEHOLDER": "Eg: 11901 "
}
}
},
"MERGE_CONTACTS": {
"TITLE": "Merge contacts",
"DESCRIPTION": "Merge contact is helpful when you have duplicated entries of the same contact. Merging action takes a primary contact and a child contact. After merging, all details in the primary contact will remain the same. If the primary contact doesn't have a field, then the value from the child contact will be used after merging. If a conflict happens, fields in primary contact will remain unaffected, but fields from secondary will be copied to the custom attributes in the primary contact.",
"PRIMARY": {
"TITLE": "Primary contact"
},
"CHILD": {
"TITLE": "Contact to merge",
"PLACEHOLDER": "Choose a contact"
},
"SUMMARY": {
"TITLE": "Summary",
"DELETE_WARNING": "Contact of <strong>%{childContactName}</strong>will be deleted.",
"ATTRIBUTE_WARNING": "Contact details of <strong>%{childContactName}</strong> will be copied to <strong>%{primaryContactName}</strong>."
},
"SEARCH": {
"ERROR": "ERROR_MESSAGE"
},
"FORM": {
"SUBMIT": " Merge contacts",
"CANCEL": "Annuller",
"CHILD_CONTACT": {
"ERROR": "Select a child contact to merge"
},
"SUCCESS_MESSAGE": "Contact merged successfully",
"ERROR_MESSAGE": "Could not merge contcts, try again!"
}
}
}

View File

@@ -27,7 +27,11 @@
"REMOVE_SELECTION": "Fjern Markering",
"DOWNLOAD": "Download",
"UPLOADING_ATTACHMENTS": "Uploading attachments...",
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
"FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again",
"NO_RESPONSE": "No response",
"RATING_TITLE": "Rating",
"FEEDBACK_TITLE": "Feedback",
"HEADER": {
"RESOLVE_ACTION": "Løs",
"REOPEN_ACTION": "Genåben",
@@ -52,7 +56,8 @@
"TIP_FORMAT_ICON": "Show rich text editor",
"TIP_EMOJI_ICON": "Show emoji selector",
"TIP_ATTACH_ICON": "Attach files",
"ENTER_TO_SEND": "Enter to send"
"ENTER_TO_SEND": "Enter to send",
"DRAG_DROP": "Drag and drop here to attach"
},
"VISIBLE_TO_AGENTS": "Privat Note: Kun synlig for dig og dit team",
"CHANGE_STATUS": "Samtalestatus ændret",
@@ -64,6 +69,10 @@
"SELECT_AGENT": "Select Agent",
"REMOVE": "Fjern",
"ASSIGN": "Assign"
},
"CONTEXT_MENU": {
"COPY": "Kopiér",
"DELETE": "Slet"
}
},
"EMAIL_TRANSCRIPT": {

View File

@@ -0,0 +1,6 @@
{
"CSAT": {
"TITLE": "Rate your conversation",
"PLACEHOLDER": "Tell us more..."
}
}

View File

@@ -127,6 +127,14 @@
"ERROR_MESSAGE": "Vi var ikke i stand til at godkende Twilio legitimationsoplysninger, prøv igen"
}
},
"SMS": {
"TITLE": "SMS Channel via Twilio",
"DESC": "Start supporting your customers via SMS with Twilio integration."
},
"WHATSAPP": {
"TITLE": "Whatsapp Channel via Twilio",
"DESC": "Start supporting your customers via Whatsapp with Twilio integration."
},
"API_CHANNEL": {
"TITLE": "API Kanal",
"DESC": "Integrer med API-kanal og begynd at supportere dine kunder.",
@@ -165,8 +173,8 @@
"FINISH_MESSAGE": "Begynd at videresende dine e-mails til følgende e-mailadresse."
},
"AUTH": {
"TITLE": "Kanaler",
"DESC": "Currently we support Website live chat widgets, Facebook Pages and Twitter profiles as platforms. We have more platforms like Whatsapp, Email, Telegram and Line in the works, which will be out soon."
"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."
},
"AGENTS": {
"TITLE": "Agenter",
@@ -216,6 +224,10 @@
"EMAIL_COLLECT_BOX": {
"ENABLED": "Aktiveret",
"DISABLED": "Deaktiveret"
},
"ENABLE_CSAT": {
"ENABLED": "Aktiveret",
"DISABLED": "Deaktiveret"
}
},
"DELETE": {
@@ -255,6 +267,8 @@
"ENABLE_EMAIL_COLLECT_BOX": "Enable email collect box",
"ENABLE_EMAIL_COLLECT_BOX_SUB_TEXT": "Enable or disable email collect box on new conversation",
"AUTO_ASSIGNMENT": "Aktiver automatisk tildeling",
"ENABLE_CSAT": "Enable CSAT",
"ENABLE_CSAT_SUB_TEXT": "Enable/Disable CSAT(Customer satisfaction) survey after resolving a conversation",
"INBOX_UPDATE_TITLE": "Indbakke Indstillinger",
"INBOX_UPDATE_SUB_TEXT": "Opdater dine indbakkeindstillinger",
"AUTO_ASSIGNMENT_SUB_TEXT": "Aktiver eller deaktiver automatisk tildeling af nye samtaler til agenter tilføjet til denne indbakke.",

View File

@@ -66,6 +66,12 @@
}
}
},
"SLACK": {
"HELP_TEXT": {
"TITLE": "Using Slack Integration",
"BODY": "<br/><p>Chatwoot will now sync all the incoming conversations into the <b><i>customer-conversations</i></b> channel inside your slack workplace.</p><p>Replying to a conversation thread in <b><i>customer-conversations</i></b> slack channel will create a response back to the customer through chatwoot.</p><p>Start the replies with <b><i>note:</i></b> to create private notes instead of replies.</p><p>If the replier on slack has an agent profile in chatwoot under the same email, the replies will be associated accordingly.</p><p>When the replier doesn't have an associated agent profile, the replies will be made from the bot profile.</p>"
}
},
"DELETE": {
"BUTTON_TEXT": "Slet",
"API": {

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