Merge branch 'develop' into chore/update-rails
This commit is contained in:
+2
-2
@@ -443,7 +443,7 @@ GEM
|
||||
faraday-multipart
|
||||
json (>= 1.8)
|
||||
rexml
|
||||
language_server-protocol (3.17.0.4)
|
||||
language_server-protocol (3.17.0.5)
|
||||
launchy (3.1.1)
|
||||
addressable (~> 2.8)
|
||||
childprocess (~> 5.0)
|
||||
@@ -584,7 +584,7 @@ GEM
|
||||
psych (5.2.6)
|
||||
date
|
||||
stringio
|
||||
public_suffix (6.0.1)
|
||||
public_suffix (6.0.2)
|
||||
puma (6.6.0)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.5.0)
|
||||
|
||||
@@ -1,32 +1,23 @@
|
||||
class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::BaseController
|
||||
class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
|
||||
include GoogleConcern
|
||||
before_action :check_authorization
|
||||
|
||||
def create
|
||||
email = params[:authorization][:email]
|
||||
redirect_url = google_client.auth_code.authorize_url(
|
||||
{
|
||||
redirect_uri: "#{base_url}/google/callback",
|
||||
scope: 'email profile https://mail.google.com/',
|
||||
scope: scope,
|
||||
response_type: 'code',
|
||||
prompt: 'consent', # the oauth flow does not return a refresh token, this is supposed to fix it
|
||||
access_type: 'offline', # the default is 'online'
|
||||
state: state,
|
||||
client_id: GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil)
|
||||
}
|
||||
)
|
||||
|
||||
if redirect_url
|
||||
cache_key = "google::#{email.downcase}"
|
||||
::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes)
|
||||
render json: { success: true, url: redirect_url }
|
||||
else
|
||||
render json: { success: false }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_authorization
|
||||
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -81,11 +81,15 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def create_channel
|
||||
return unless %w[web_widget api email line telegram whatsapp sms].include?(permitted_params[:channel][:type])
|
||||
return unless allowed_channel_types.include?(permitted_params[:channel][:type])
|
||||
|
||||
account_channels_method.create!(permitted_params(channel_type_from_params::EDITABLE_ATTRS)[:channel].except(:type))
|
||||
end
|
||||
|
||||
def allowed_channel_types
|
||||
%w[web_widget api email line telegram whatsapp sms]
|
||||
end
|
||||
|
||||
def update_inbox_working_hours
|
||||
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
|
||||
end
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::BaseController
|
||||
class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
|
||||
include InstagramConcern
|
||||
include Instagram::IntegrationHelper
|
||||
before_action :check_authorization
|
||||
|
||||
def create
|
||||
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#step-1--get-authorization
|
||||
@@ -21,10 +20,4 @@ class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts
|
||||
render json: { success: false }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_authorization
|
||||
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_hook, only: [:destroy]
|
||||
|
||||
def destroy
|
||||
@hook.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_hook
|
||||
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'notion')
|
||||
end
|
||||
end
|
||||
@@ -1,28 +1,19 @@
|
||||
class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::BaseController
|
||||
class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
|
||||
include MicrosoftConcern
|
||||
before_action :check_authorization
|
||||
|
||||
def create
|
||||
email = params[:authorization][:email]
|
||||
redirect_url = microsoft_client.auth_code.authorize_url(
|
||||
{
|
||||
redirect_uri: "#{base_url}/microsoft/callback",
|
||||
scope: 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile',
|
||||
scope: scope,
|
||||
state: state,
|
||||
prompt: 'consent'
|
||||
}
|
||||
)
|
||||
if redirect_url
|
||||
cache_key = "microsoft::#{email.downcase}"
|
||||
::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes)
|
||||
render json: { success: true, url: redirect_url }
|
||||
else
|
||||
render json: { success: false }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_authorization
|
||||
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
class Api::V1::Accounts::Notion::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
|
||||
include NotionConcern
|
||||
|
||||
def create
|
||||
redirect_url = notion_client.auth_code.authorize_url(
|
||||
{
|
||||
redirect_uri: "#{base_url}/notion/callback",
|
||||
response_type: 'code',
|
||||
owner: 'user',
|
||||
state: state,
|
||||
client_id: GlobalConfigService.load('NOTION_CLIENT_ID', nil)
|
||||
}
|
||||
)
|
||||
|
||||
if redirect_url
|
||||
render json: { success: true, url: redirect_url }
|
||||
else
|
||||
render json: { success: false }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
class Api::V1::Accounts::OauthAuthorizationController < Api::V1::Accounts::BaseController
|
||||
before_action :check_authorization
|
||||
|
||||
protected
|
||||
|
||||
def scope
|
||||
''
|
||||
end
|
||||
|
||||
def state
|
||||
Current.account.to_sgid(expires_in: 15.minutes).to_s
|
||||
end
|
||||
|
||||
def base_url
|
||||
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_authorization
|
||||
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
|
||||
end
|
||||
end
|
||||
@@ -14,7 +14,7 @@ module GoogleConcern
|
||||
|
||||
private
|
||||
|
||||
def base_url
|
||||
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
|
||||
def scope
|
||||
'email profile https://mail.google.com/'
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,7 +15,7 @@ module MicrosoftConcern
|
||||
|
||||
private
|
||||
|
||||
def base_url
|
||||
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
|
||||
def scope
|
||||
'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile email'
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
module NotionConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def notion_client
|
||||
app_id = GlobalConfigService.load('NOTION_CLIENT_ID', nil)
|
||||
app_secret = GlobalConfigService.load('NOTION_CLIENT_SECRET', nil)
|
||||
|
||||
::OAuth2::Client.new(app_id, app_secret, {
|
||||
site: 'https://api.notion.com',
|
||||
authorize_url: 'https://api.notion.com/v1/oauth/authorize',
|
||||
token_url: 'https://api.notion.com/v1/oauth/token',
|
||||
auth_scheme: :basic_auth
|
||||
})
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def scope
|
||||
''
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,36 @@
|
||||
class Notion::CallbacksController < OauthCallbackController
|
||||
include NotionConcern
|
||||
|
||||
private
|
||||
|
||||
def provider_name
|
||||
'notion'
|
||||
end
|
||||
|
||||
def oauth_client
|
||||
notion_client
|
||||
end
|
||||
|
||||
def handle_response
|
||||
hook = account.hooks.new(
|
||||
access_token: parsed_body['access_token'],
|
||||
status: 'enabled',
|
||||
app_id: 'notion',
|
||||
settings: {
|
||||
token_type: parsed_body['token_type'],
|
||||
workspace_name: parsed_body['workspace_name'],
|
||||
workspace_id: parsed_body['workspace_id'],
|
||||
workspace_icon: parsed_body['workspace_icon'],
|
||||
bot_id: parsed_body['bot_id'],
|
||||
owner: parsed_body['owner']
|
||||
}
|
||||
)
|
||||
|
||||
hook.save!
|
||||
redirect_to notion_redirect_uri
|
||||
end
|
||||
|
||||
def notion_redirect_uri
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/notion"
|
||||
end
|
||||
end
|
||||
@@ -6,7 +6,6 @@ class OauthCallbackController < ApplicationController
|
||||
)
|
||||
|
||||
handle_response
|
||||
::Redis::Alfred.delete(cache_key)
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e).capture_exception
|
||||
redirect_to '/'
|
||||
@@ -64,10 +63,6 @@ class OauthCallbackController < ApplicationController
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
def cache_key
|
||||
"#{provider_name}::#{users_data['email'].downcase}"
|
||||
end
|
||||
|
||||
def create_channel_with_inbox
|
||||
ActiveRecord::Base.transaction do
|
||||
channel_email = Channel::Email.create!(email: users_data['email'], account: account)
|
||||
@@ -86,12 +81,17 @@ class OauthCallbackController < ApplicationController
|
||||
decoded_token[0]
|
||||
end
|
||||
|
||||
def account_id
|
||||
::Redis::Alfred.get(cache_key)
|
||||
def account_from_signed_id
|
||||
raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank?
|
||||
|
||||
account = GlobalID::Locator.locate_signed(params[:state])
|
||||
raise 'Invalid or expired state' if account.nil?
|
||||
|
||||
account
|
||||
end
|
||||
|
||||
def account
|
||||
@account ||= Account.find(account_id)
|
||||
@account ||= account_from_signed_id
|
||||
end
|
||||
|
||||
# Fallback name, for when name field is missing from users_data
|
||||
|
||||
@@ -7,7 +7,11 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
||||
|
||||
def index
|
||||
@articles = @portal.articles.published.includes(:category, :author)
|
||||
|
||||
@articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present?
|
||||
|
||||
@articles_count = @articles.count
|
||||
|
||||
search_articles
|
||||
order_by_sort_param
|
||||
limit_results
|
||||
|
||||
@@ -39,6 +39,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
||||
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
|
||||
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
|
||||
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
|
||||
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
|
||||
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class NotionOAuthClient extends ApiClient {
|
||||
constructor() {
|
||||
super('notion', { accountScoped: true });
|
||||
}
|
||||
|
||||
generateAuthorization() {
|
||||
return axios.post(`${this.url}/authorization`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new NotionOAuthClient();
|
||||
@@ -47,7 +47,7 @@
|
||||
@apply max-w-full;
|
||||
|
||||
.multiselect__option {
|
||||
@apply text-sm font-normal;
|
||||
@apply text-sm font-normal flex justify-between items-center;
|
||||
|
||||
span {
|
||||
@apply inline-block overflow-hidden text-ellipsis whitespace-nowrap w-fit;
|
||||
@@ -58,7 +58,7 @@
|
||||
}
|
||||
|
||||
&::after {
|
||||
@apply bottom-0 flex items-center justify-center text-center;
|
||||
@apply bottom-0 flex items-center justify-center text-center relative px-1 leading-tight;
|
||||
}
|
||||
|
||||
&.multiselect__option--highlight {
|
||||
@@ -74,7 +74,7 @@
|
||||
}
|
||||
|
||||
&.multiselect__option--highlight::after {
|
||||
@apply bg-transparent;
|
||||
@apply bg-transparent text-n-slate-12;
|
||||
}
|
||||
|
||||
&.multiselect__option--selected {
|
||||
|
||||
@@ -123,7 +123,7 @@ const handleDocumentableClick = () => {
|
||||
@mouseenter="emit('hover', true)"
|
||||
@mouseleave="emit('hover', false)"
|
||||
>
|
||||
<div v-show="selectable" class="absolute top-7 ltr:left-4 rtl:right-4">
|
||||
<div v-show="selectable" class="absolute top-7 ltr:left-3 rtl:right-3">
|
||||
<Checkbox v-model="modelValue" />
|
||||
</div>
|
||||
<div class="flex relative justify-between w-full gap-1">
|
||||
|
||||
@@ -13,6 +13,7 @@ export function useChannelIcon(inbox) {
|
||||
'Channel::WebWidget': 'i-ri-global-fill',
|
||||
'Channel::Whatsapp': 'i-ri-whatsapp-fill',
|
||||
'Channel::Instagram': 'i-ri-instagram-fill',
|
||||
'Channel::Voice': 'i-ri-phone-fill',
|
||||
};
|
||||
|
||||
const providerIconMap = {
|
||||
|
||||
@@ -19,6 +19,12 @@ describe('useChannelIcon', () => {
|
||||
expect(icon).toBe('i-ri-whatsapp-fill');
|
||||
});
|
||||
|
||||
it('returns correct icon for Voice channel', () => {
|
||||
const inbox = { channel_type: 'Channel::Voice' };
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-ri-phone-fill');
|
||||
});
|
||||
|
||||
describe('Email channel', () => {
|
||||
it('returns mail icon for generic email channel', () => {
|
||||
const inbox = { channel_type: 'Channel::Email' };
|
||||
|
||||
@@ -1,51 +1,21 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, nextTick } from 'vue';
|
||||
import { computed, ref, onMounted, nextTick, getCurrentInstance } from 'vue';
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'text',
|
||||
},
|
||||
customInputClass: {
|
||||
type: [String, Object, Array],
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
id: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
modelValue: { type: [String, Number], default: '' },
|
||||
type: { type: String, default: 'text' },
|
||||
customInputClass: { type: [String, Object, Array], default: '' },
|
||||
placeholder: { type: String, default: '' },
|
||||
label: { type: String, default: '' },
|
||||
id: { type: String, default: '' },
|
||||
message: { type: String, default: '' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
messageType: {
|
||||
type: String,
|
||||
default: 'info',
|
||||
validator: value => ['info', 'error', 'success'].includes(value),
|
||||
},
|
||||
min: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
autofocus: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
min: { type: String, default: '' },
|
||||
autofocus: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -56,6 +26,10 @@ const emit = defineEmits([
|
||||
'enter',
|
||||
]);
|
||||
|
||||
// Generate a unique ID per component instance when `id` prop is not provided.
|
||||
const { uid } = getCurrentInstance();
|
||||
const uniqueId = computed(() => props.id || `input-${uid}`);
|
||||
|
||||
const isFocused = ref(false);
|
||||
const inputRef = ref(null);
|
||||
|
||||
@@ -111,7 +85,7 @@ onMounted(() => {
|
||||
<div class="relative flex flex-col min-w-0 gap-1">
|
||||
<label
|
||||
v-if="label"
|
||||
:for="id"
|
||||
:for="uniqueId"
|
||||
class="mb-0.5 text-sm font-medium text-n-slate-12"
|
||||
>
|
||||
{{ label }}
|
||||
@@ -119,7 +93,7 @@ onMounted(() => {
|
||||
<!-- Added prefix slot to allow adding icons to the input -->
|
||||
<slot name="prefix" />
|
||||
<input
|
||||
:id="id"
|
||||
:id="uniqueId"
|
||||
ref="inputRef"
|
||||
:value="modelValue"
|
||||
:class="[
|
||||
|
||||
@@ -379,7 +379,7 @@ const shouldRenderMessage = computed(() => {
|
||||
function openContextMenu(e) {
|
||||
const shouldSkipContextMenu =
|
||||
e.target?.classList.contains('skip-context-menu') ||
|
||||
e.target?.tagName.toLowerCase() === 'a';
|
||||
['a', 'img'].includes(e.target?.tagName.toLowerCase());
|
||||
if (shouldSkipContextMenu || getSelection().toString()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export default {
|
||||
<button
|
||||
class="bg-white dark:bg-slate-900 cursor-pointer flex flex-col justify-end transition-all duration-200 ease-in -m-px py-4 px-0 items-center border border-solid border-slate-25 dark:border-slate-800 hover:border-woot-500 dark:hover:border-woot-500 hover:shadow-md hover:z-50 disabled:opacity-60"
|
||||
>
|
||||
<img :src="src" :alt="title" class="w-1/2 my-4 mx-auto" />
|
||||
<img :src="src" :alt="title" draggable="false" class="w-1/2 my-4 mx-auto" />
|
||||
<h3
|
||||
class="text-slate-800 dark:text-slate-100 text-base text-center capitalize"
|
||||
>
|
||||
|
||||
@@ -756,6 +756,7 @@ function toggleSelectAll(check) {
|
||||
}
|
||||
|
||||
useEmitter('fetch_conversation_stats', () => {
|
||||
if (hasAppliedFiltersOrActiveFolders.value) return;
|
||||
store.dispatch('conversationStats/get', conversationFilters.value);
|
||||
});
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ const props = defineProps({
|
||||
const isRelaxed = computed(() => props.type === 'relaxed');
|
||||
const headerClass = computed(() =>
|
||||
isRelaxed.value
|
||||
? 'first:rounded-bl-lg first:rounded-tl-lg last:rounded-br-lg last:rounded-tr-lg'
|
||||
? 'ltr:first:rounded-bl-lg ltr:first:rounded-tl-lg ltr:last:rounded-br-lg ltr:last:rounded-tr-lg rtl:first:rounded-br-lg rtl:first:rounded-tr-lg rtl:last:rounded-bl-lg rtl:last:rounded-tl-lg'
|
||||
: ''
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -41,6 +41,10 @@ export default {
|
||||
);
|
||||
}
|
||||
|
||||
if (key === 'voice') {
|
||||
return this.enabledFeatures.channel_voice;
|
||||
}
|
||||
|
||||
return [
|
||||
'website',
|
||||
'twilio',
|
||||
@@ -50,6 +54,7 @@ export default {
|
||||
'telegram',
|
||||
'line',
|
||||
'instagram',
|
||||
'voice',
|
||||
].includes(key);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -125,6 +125,10 @@ export const useInbox = () => {
|
||||
return channelType.value === INBOX_TYPES.INSTAGRAM;
|
||||
});
|
||||
|
||||
const isAVoiceChannel = computed(() => {
|
||||
return channelType.value === INBOX_TYPES.VOICE;
|
||||
});
|
||||
|
||||
return {
|
||||
inbox,
|
||||
isAFacebookInbox,
|
||||
@@ -142,5 +146,6 @@ export const useInbox = () => {
|
||||
is360DialogWhatsAppChannel,
|
||||
isAnEmailChannel,
|
||||
isAnInstagramChannel,
|
||||
isAVoiceChannel,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ export const INBOX_TYPES = {
|
||||
LINE: 'Channel::Line',
|
||||
SMS: 'Channel::Sms',
|
||||
INSTAGRAM: 'Channel::Instagram',
|
||||
VOICE: 'Channel::Voice',
|
||||
};
|
||||
|
||||
const INBOX_ICON_MAP_FILL = {
|
||||
@@ -22,6 +23,7 @@ const INBOX_ICON_MAP_FILL = {
|
||||
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-fill',
|
||||
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
|
||||
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
|
||||
[INBOX_TYPES.VOICE]: 'i-ri-phone-fill',
|
||||
};
|
||||
|
||||
const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill';
|
||||
@@ -36,6 +38,7 @@ const INBOX_ICON_MAP_LINE = {
|
||||
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-line',
|
||||
[INBOX_TYPES.LINE]: 'i-ri-line-line',
|
||||
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-line',
|
||||
[INBOX_TYPES.VOICE]: 'i-ri-phone-line',
|
||||
};
|
||||
|
||||
const DEFAULT_ICON_LINE = 'i-ri-chat-1-line';
|
||||
@@ -47,6 +50,7 @@ export const getInboxSource = (type, phoneNumber, inbox) => {
|
||||
|
||||
case INBOX_TYPES.TWILIO:
|
||||
case INBOX_TYPES.WHATSAPP:
|
||||
case INBOX_TYPES.VOICE:
|
||||
return phoneNumber || '';
|
||||
|
||||
case INBOX_TYPES.EMAIL:
|
||||
@@ -85,6 +89,9 @@ export const getReadableInboxByType = (type, phoneNumber) => {
|
||||
case INBOX_TYPES.LINE:
|
||||
return 'line';
|
||||
|
||||
case INBOX_TYPES.VOICE:
|
||||
return 'voice';
|
||||
|
||||
default:
|
||||
return 'chat';
|
||||
}
|
||||
@@ -124,6 +131,9 @@ export const getInboxClassByType = (type, phoneNumber) => {
|
||||
case INBOX_TYPES.INSTAGRAM:
|
||||
return 'brand-instagram';
|
||||
|
||||
case INBOX_TYPES.VOICE:
|
||||
return 'phone';
|
||||
|
||||
default:
|
||||
return 'chat';
|
||||
}
|
||||
|
||||
@@ -268,6 +268,46 @@
|
||||
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
|
||||
}
|
||||
},
|
||||
"VOICE": {
|
||||
"TITLE": "Voice Channel",
|
||||
"DESC": "Integrate Twilio Voice and start supporting your customers via phone calls.",
|
||||
"PHONE_NUMBER": {
|
||||
"LABEL": "Phone Number",
|
||||
"PLACEHOLDER": "Enter your phone number (e.g. +1234567890)",
|
||||
"ERROR": "Please provide a valid phone number in E.164 format (e.g. +1234567890)"
|
||||
},
|
||||
"TWILIO": {
|
||||
"ACCOUNT_SID": {
|
||||
"LABEL": "Account SID",
|
||||
"PLACEHOLDER": "Enter your Twilio Account SID",
|
||||
"REQUIRED": "Account SID is required"
|
||||
},
|
||||
"AUTH_TOKEN": {
|
||||
"LABEL": "Auth Token",
|
||||
"PLACEHOLDER": "Enter your Twilio Auth Token",
|
||||
"REQUIRED": "Auth Token is required"
|
||||
},
|
||||
"API_KEY_SID": {
|
||||
"LABEL": "API Key SID",
|
||||
"PLACEHOLDER": "Enter your Twilio API Key SID",
|
||||
"REQUIRED": "API Key SID is required"
|
||||
},
|
||||
"API_KEY_SECRET": {
|
||||
"LABEL": "API Key Secret",
|
||||
"PLACEHOLDER": "Enter your Twilio API Key Secret",
|
||||
"REQUIRED": "API Key Secret is required"
|
||||
},
|
||||
"TWIML_APP_SID": {
|
||||
"LABEL": "TwiML App SID",
|
||||
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
|
||||
"REQUIRED": "TwiML App SID is required"
|
||||
}
|
||||
},
|
||||
"SUBMIT_BUTTON": "Create Voice Channel",
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "We were not able to create the voice channel"
|
||||
}
|
||||
},
|
||||
"API_CHANNEL": {
|
||||
"TITLE": "API Channel",
|
||||
"DESC": "Integrate with API channel and start supporting your customers.",
|
||||
@@ -789,7 +829,8 @@
|
||||
"TELEGRAM": "Telegram",
|
||||
"LINE": "Line",
|
||||
"API": "API Channel",
|
||||
"INSTAGRAM": "Instagram"
|
||||
"INSTAGRAM": "Instagram",
|
||||
"VOICE": "Voice"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,6 +328,14 @@
|
||||
"DESCRIPTION": "Linear workspace is not connected. Click the button below to connect your workspace to use this integration.",
|
||||
"BUTTON_TEXT": "Connect Linear workspace"
|
||||
}
|
||||
},
|
||||
"NOTION": {
|
||||
"DELETE": {
|
||||
"TITLE": "Are you sure you want to delete the Notion integration?",
|
||||
"MESSAGE": "Deleting this integration will remove access to your Notion workspace and stop all related functionality.",
|
||||
"CONFIRM": "Yes, delete",
|
||||
"CANCEL": "Cancel"
|
||||
}
|
||||
}
|
||||
},
|
||||
"CAPTAIN": {
|
||||
@@ -537,6 +545,8 @@
|
||||
"CONVERSATION": "Conversation #{id}"
|
||||
},
|
||||
"SELECTED": "{count} selected",
|
||||
"SELECT_ALL": "Select all ({count})",
|
||||
"UNSELECT_ALL": "Unselect all ({count})",
|
||||
"BULK_APPROVE_BUTTON": "Approve",
|
||||
"BULK_DELETE_BUTTON": "Delete",
|
||||
"BULK_APPROVE": {
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
"ATTRIBUTES": {
|
||||
"MESSAGE_TYPE": "Tipo da Mensagem",
|
||||
"MESSAGE_CONTAINS": "A mensagem contém",
|
||||
"EMAIL": "e-mail",
|
||||
"EMAIL": "E-mail",
|
||||
"INBOX": "Caixa de Entrada",
|
||||
"CONVERSATION_LANGUAGE": "Idioma da conversa",
|
||||
"PHONE_NUMBER": "Número de Telefone",
|
||||
|
||||
@@ -130,15 +130,15 @@ export default {
|
||||
</div>
|
||||
<div class="flex multiselect-wrap--medium">
|
||||
<div
|
||||
class="w-8 relative text-base text-slate-100 dark:text-slate-600 after:content-[''] after:h-12 after:w-0 after:left-4 after:absolute after:border-l after:border-solid after:border-slate-100 after:dark:border-slate-600 before:content-[''] before:h-0 before:w-4 before:left-4 before:top-12 before:absolute before:border-b before:border-solid before:border-slate-100 before:dark:border-slate-600"
|
||||
class="w-8 relative text-base text-slate-100 dark:text-slate-600 after:content-[''] after:h-12 after:w-0 ltr:after:left-4 rtl:after:right-4 after:absolute after:border-l after:border-solid after:border-slate-100 after:dark:border-slate-600 before:content-[''] before:h-0 before:w-4 ltr:before:left-4 rtl:before:right-4 before:top-12 before:absolute before:border-b before:border-solid before:border-slate-100 before:dark:border-slate-600"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="arrow-up"
|
||||
class="absolute -top-1 left-2"
|
||||
class="absolute -top-1 ltr:left-2 rtl:right-2"
|
||||
size="17"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex flex-col w-full ltr:pl-8 rtl:pr-8">
|
||||
<label class="multiselect__label">
|
||||
{{ $t('MERGE_CONTACTS.PRIMARY.TITLE') }}
|
||||
<woot-label
|
||||
|
||||
@@ -157,6 +157,13 @@ const bulkCheckbox = computed({
|
||||
},
|
||||
});
|
||||
|
||||
const buildSelectedCountLabel = computed(() => {
|
||||
const count = responses.value?.length || 0;
|
||||
return bulkSelectionState.value.allSelected
|
||||
? t('CAPTAIN.RESPONSES.UNSELECT_ALL', { count })
|
||||
: t('CAPTAIN.RESPONSES.SELECT_ALL', { count });
|
||||
});
|
||||
|
||||
const handleCardHover = (isHovered, id) => {
|
||||
hoveredCard.value = isHovered ? id : null;
|
||||
};
|
||||
@@ -270,7 +277,11 @@ onMounted(() => {
|
||||
<template #controls>
|
||||
<div
|
||||
v-if="shouldShowDropdown"
|
||||
class="mb-4 -mt-3 flex justify-between items-center"
|
||||
class="mb-4 -mt-3 flex justify-between items-center w-fit py-1"
|
||||
:class="{
|
||||
'ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3':
|
||||
bulkSelectionState.hasSelected,
|
||||
}"
|
||||
>
|
||||
<div v-if="!bulkSelectionState.hasSelected" class="flex gap-3">
|
||||
<OnClickOutside @trigger="isStatusFilterOpen = false">
|
||||
@@ -306,13 +317,18 @@ onMounted(() => {
|
||||
>
|
||||
<div
|
||||
v-if="bulkSelectionState.hasSelected"
|
||||
class="flex items-center gap-3 ltr:pl-4 rtl:pr-4"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
v-model="bulkCheckbox"
|
||||
:indeterminate="bulkSelectionState.isIndeterminate"
|
||||
/>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
v-model="bulkCheckbox"
|
||||
:indeterminate="bulkSelectionState.isIndeterminate"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-12 font-medium tabular-nums">
|
||||
{{ buildSelectedCountLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm text-n-slate-10 tabular-nums">
|
||||
{{
|
||||
$t('CAPTAIN.RESPONSES.SELECTED', {
|
||||
@@ -322,17 +338,23 @@ onMounted(() => {
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-4 w-px bg-n-strong" />
|
||||
<div class="flex gap-2">
|
||||
<div class="flex gap-3 items-center">
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.BULK_APPROVE_BUTTON')"
|
||||
sm
|
||||
slate
|
||||
ghost
|
||||
icon="i-lucide-check"
|
||||
class="!px-1.5"
|
||||
@click="handleBulkApprove"
|
||||
/>
|
||||
<div class="h-4 w-px bg-n-strong" />
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')"
|
||||
sm
|
||||
slate
|
||||
ruby
|
||||
ghost
|
||||
class="!px-1.5"
|
||||
icon="i-lucide-trash"
|
||||
@click="bulkDeleteDialog.dialogRef.open()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+4
-1
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import IframeLoader from 'shared/components/IframeLoader.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
defineProps({
|
||||
url: {
|
||||
@@ -11,6 +12,8 @@ defineProps({
|
||||
|
||||
const emit = defineEmits(['back', 'insert']);
|
||||
|
||||
const isRTL = useMapGetter('accounts/isRTL');
|
||||
|
||||
const onBack = e => {
|
||||
e.stopPropagation();
|
||||
emit('back');
|
||||
@@ -35,7 +38,7 @@ const onInsert = e => {
|
||||
</div>
|
||||
<div class="-ml-4 h-full overflow-y-auto">
|
||||
<div class="w-full h-full min-h-0">
|
||||
<IframeLoader :url="url" />
|
||||
<IframeLoader :url="url" :is-rtl="isRTL" is-dir-applied />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -71,6 +71,12 @@ export default {
|
||||
FEATURE_FLAGS.AUTO_RESOLVE_CONVERSATIONS
|
||||
);
|
||||
},
|
||||
showAudioTranscriptionConfig() {
|
||||
return this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.CAPTAIN
|
||||
);
|
||||
},
|
||||
languagesSortedByCode() {
|
||||
const enabledLanguages = [...this.enabledLanguages];
|
||||
return enabledLanguages.sort((l1, l2) =>
|
||||
@@ -237,7 +243,7 @@ export default {
|
||||
<woot-loading-state v-if="uiFlags.isFetchingItem" />
|
||||
</div>
|
||||
<AutoResolve v-if="showAutoResolutionConfig" />
|
||||
<AudioTranscription v-if="isOnChatwootCloud" />
|
||||
<AudioTranscription v-if="showAudioTranscriptionConfig" />
|
||||
<AccountId />
|
||||
<div v-if="!uiFlags.isFetchingItem && isOnChatwootCloud">
|
||||
<AccountDelete />
|
||||
|
||||
@@ -10,6 +10,7 @@ import Whatsapp from './channels/Whatsapp.vue';
|
||||
import Line from './channels/Line.vue';
|
||||
import Telegram from './channels/Telegram.vue';
|
||||
import Instagram from './channels/Instagram.vue';
|
||||
import Voice from './channels/Voice.vue';
|
||||
|
||||
const channelViewList = {
|
||||
facebook: Facebook,
|
||||
@@ -22,6 +23,7 @@ const channelViewList = {
|
||||
line: Line,
|
||||
telegram: Telegram,
|
||||
instagram: Instagram,
|
||||
voice: Voice,
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
|
||||
@@ -36,6 +36,7 @@ export default {
|
||||
{ key: 'telegram', name: 'Telegram' },
|
||||
{ key: 'line', name: 'Line' },
|
||||
{ key: 'instagram', name: 'Instagram' },
|
||||
{ key: 'voice', name: 'Voice' },
|
||||
];
|
||||
},
|
||||
...mapGetters({
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
<script setup>
|
||||
import { reactive, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { isPhoneE164 } from 'shared/helpers/Validators';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import PageHeader from '../../SettingsSubPageHeader.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
|
||||
const state = reactive({
|
||||
phoneNumber: '',
|
||||
accountSid: '',
|
||||
authToken: '',
|
||||
apiKeySid: '',
|
||||
apiKeySecret: '',
|
||||
twimlAppSid: '',
|
||||
});
|
||||
|
||||
const uiFlags = useMapGetter('inboxes/getUIFlags');
|
||||
|
||||
const validationRules = {
|
||||
phoneNumber: { required, isPhoneE164 },
|
||||
accountSid: { required },
|
||||
authToken: { required },
|
||||
apiKeySid: { required },
|
||||
apiKeySecret: { required },
|
||||
twimlAppSid: { required },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(validationRules, state);
|
||||
const isSubmitDisabled = computed(() => v$.value.$invalid);
|
||||
|
||||
const formErrors = computed(() => ({
|
||||
phoneNumber: v$.value.phoneNumber?.$error
|
||||
? t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.ERROR')
|
||||
: '',
|
||||
accountSid: v$.value.accountSid?.$error
|
||||
? t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.REQUIRED')
|
||||
: '',
|
||||
authToken: v$.value.authToken?.$error
|
||||
? t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.REQUIRED')
|
||||
: '',
|
||||
apiKeySid: v$.value.apiKeySid?.$error
|
||||
? t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.REQUIRED')
|
||||
: '',
|
||||
apiKeySecret: v$.value.apiKeySecret?.$error
|
||||
? t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.REQUIRED')
|
||||
: '',
|
||||
twimlAppSid: v$.value.twimlAppSid?.$error
|
||||
? t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.REQUIRED')
|
||||
: '',
|
||||
}));
|
||||
|
||||
function getProviderConfig() {
|
||||
const config = {
|
||||
account_sid: state.accountSid,
|
||||
auth_token: state.authToken,
|
||||
api_key_sid: state.apiKeySid,
|
||||
api_key_secret: state.apiKeySecret,
|
||||
};
|
||||
if (state.twimlAppSid) config.outgoing_application_sid = state.twimlAppSid;
|
||||
return config;
|
||||
}
|
||||
|
||||
async function createChannel() {
|
||||
const isFormValid = await v$.value.$validate();
|
||||
if (!isFormValid) return;
|
||||
|
||||
try {
|
||||
const channel = await store.dispatch('inboxes/createVoiceChannel', {
|
||||
name: `Voice (${state.phoneNumber})`,
|
||||
voice: {
|
||||
phone_number: state.phoneNumber,
|
||||
provider: 'twilio',
|
||||
provider_config: getProviderConfig(),
|
||||
},
|
||||
});
|
||||
|
||||
router.replace({
|
||||
name: 'settings_inboxes_add_agents',
|
||||
params: { page: 'new', inbox_id: channel.id },
|
||||
});
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error.response?.data?.message ||
|
||||
t('INBOX_MGMT.ADD.VOICE.API.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="overflow-auto col-span-6 p-6 w-full h-full rounded-t-lg border border-b-0 border-n-weak bg-n-solid-1"
|
||||
>
|
||||
<PageHeader
|
||||
:header-title="t('INBOX_MGMT.ADD.VOICE.TITLE')"
|
||||
:header-content="t('INBOX_MGMT.ADD.VOICE.DESC')"
|
||||
/>
|
||||
|
||||
<form
|
||||
class="flex flex-col gap-4 flex-wrap mx-0"
|
||||
@submit.prevent="createChannel"
|
||||
>
|
||||
<Input
|
||||
v-model="state.phoneNumber"
|
||||
:label="t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.LABEL')"
|
||||
:placeholder="t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.PLACEHOLDER')"
|
||||
:message="formErrors.phoneNumber"
|
||||
:message-type="formErrors.phoneNumber ? 'error' : 'info'"
|
||||
@blur="v$.phoneNumber?.$touch"
|
||||
/>
|
||||
|
||||
<Input
|
||||
v-model="state.accountSid"
|
||||
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.LABEL')"
|
||||
:placeholder="t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.PLACEHOLDER')"
|
||||
:message="formErrors.accountSid"
|
||||
:message-type="formErrors.accountSid ? 'error' : 'info'"
|
||||
@blur="v$.accountSid?.$touch"
|
||||
/>
|
||||
|
||||
<Input
|
||||
v-model="state.authToken"
|
||||
type="password"
|
||||
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.LABEL')"
|
||||
:placeholder="t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.PLACEHOLDER')"
|
||||
:message="formErrors.authToken"
|
||||
:message-type="formErrors.authToken ? 'error' : 'info'"
|
||||
@blur="v$.authToken?.$touch"
|
||||
/>
|
||||
|
||||
<Input
|
||||
v-model="state.apiKeySid"
|
||||
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.LABEL')"
|
||||
:placeholder="t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.PLACEHOLDER')"
|
||||
:message="formErrors.apiKeySid"
|
||||
:message-type="formErrors.apiKeySid ? 'error' : 'info'"
|
||||
@blur="v$.apiKeySid?.$touch"
|
||||
/>
|
||||
|
||||
<Input
|
||||
v-model="state.apiKeySecret"
|
||||
type="password"
|
||||
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.LABEL')"
|
||||
:placeholder="
|
||||
t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.PLACEHOLDER')
|
||||
"
|
||||
:message="formErrors.apiKeySecret"
|
||||
:message-type="formErrors.apiKeySecret ? 'error' : 'info'"
|
||||
@blur="v$.apiKeySecret?.$touch"
|
||||
/>
|
||||
|
||||
<Input
|
||||
v-model="state.twimlAppSid"
|
||||
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.LABEL')"
|
||||
:placeholder="
|
||||
t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.PLACEHOLDER')
|
||||
"
|
||||
:message="formErrors.twimlAppSid"
|
||||
:message-type="formErrors.twimlAppSid ? 'error' : 'info'"
|
||||
@blur="v$.twimlAppSid?.$touch"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<NextButton
|
||||
:is-loading="uiFlags.isCreating"
|
||||
:disabled="isSubmitDisabled"
|
||||
:label="t('INBOX_MGMT.ADD.VOICE.SUBMIT_BUTTON')"
|
||||
type="submit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
-1
@@ -12,7 +12,6 @@ defineOptions({
|
||||
provider="google"
|
||||
:title="$t('INBOX_MGMT.ADD.GOOGLE.TITLE')"
|
||||
:description="$t('INBOX_MGMT.ADD.GOOGLE.DESCRIPTION')"
|
||||
:input-placeholder="$t('INBOX_MGMT.ADD.GOOGLE.EMAIL_PLACEHOLDER')"
|
||||
:submit-button-text="$t('INBOX_MGMT.ADD.GOOGLE.SIGN_IN')"
|
||||
:error-message="$t('INBOX_MGMT.ADD.GOOGLE.ERROR_MESSAGE')"
|
||||
/>
|
||||
|
||||
-1
@@ -12,7 +12,6 @@ defineOptions({
|
||||
provider="microsoft"
|
||||
:title="$t('INBOX_MGMT.ADD.MICROSOFT.TITLE')"
|
||||
:description="$t('INBOX_MGMT.ADD.MICROSOFT.DESCRIPTION')"
|
||||
:input-placeholder="$t('INBOX_MGMT.ADD.MICROSOFT.EMAIL_PLACEHOLDER')"
|
||||
:submit-button-text="$t('INBOX_MGMT.ADD.MICROSOFT.SIGN_IN')"
|
||||
:error-message="$t('INBOX_MGMT.ADD.MICROSOFT.ERROR_MESSAGE')"
|
||||
/>
|
||||
|
||||
+1
-13
@@ -30,14 +30,9 @@ const props = defineProps({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
inputPlaceholder: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const isRequestingAuthorization = ref(false);
|
||||
const email = ref('');
|
||||
|
||||
const client = computed(() => {
|
||||
if (props.provider === 'microsoft') {
|
||||
@@ -50,9 +45,7 @@ const client = computed(() => {
|
||||
async function requestAuthorization() {
|
||||
try {
|
||||
isRequestingAuthorization.value = true;
|
||||
const response = await client.value.generateAuthorization({
|
||||
email: email.value,
|
||||
});
|
||||
const response = await client.value.generateAuthorization();
|
||||
const {
|
||||
data: { url },
|
||||
} = response;
|
||||
@@ -75,11 +68,6 @@ async function requestAuthorization() {
|
||||
:header-content="description"
|
||||
/>
|
||||
<form class="mt-6" @submit.prevent="requestAuthorization">
|
||||
<woot-input
|
||||
v-model="email"
|
||||
type="email"
|
||||
:placeholder="inputPlaceholder"
|
||||
/>
|
||||
<NextButton
|
||||
:is-loading="isRequestingAuthorization"
|
||||
type="submit"
|
||||
|
||||
@@ -29,6 +29,7 @@ const i18nMap = {
|
||||
'Channel::Line': 'LINE',
|
||||
'Channel::Api': 'API',
|
||||
'Channel::Instagram': 'INSTAGRAM',
|
||||
'Channel::Voice': 'VOICE',
|
||||
};
|
||||
|
||||
const twilioChannelName = () => {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import {
|
||||
useFunctionGetter,
|
||||
useMapGetter,
|
||||
useStore,
|
||||
} from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import ButtonNext from 'next/button/Button.vue';
|
||||
import notionClient from 'dashboard/api/notion_auth.js';
|
||||
|
||||
import Integration from './Integration.vue';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const integrationLoaded = ref(false);
|
||||
|
||||
const integration = useFunctionGetter('integrations/getIntegration', 'notion');
|
||||
|
||||
const uiFlags = useMapGetter('integrations/getUIFlags');
|
||||
|
||||
const integrationAction = computed(() => {
|
||||
if (integration.value.enabled) {
|
||||
return 'disconnect';
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
|
||||
const authorize = async () => {
|
||||
const response = await notionClient.generateAuthorization();
|
||||
const {
|
||||
data: { url },
|
||||
} = response;
|
||||
|
||||
window.location.href = url;
|
||||
};
|
||||
|
||||
const initializeNotionIntegration = async () => {
|
||||
await store.dispatch('integrations/get', 'notion');
|
||||
integrationLoaded.value = true;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeNotionIntegration();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-grow flex-shrink p-4 overflow-auto mx-auto">
|
||||
<div v-if="integrationLoaded && !uiFlags.isCreatingNotion">
|
||||
<Integration
|
||||
:integration-id="integration.id"
|
||||
:integration-logo="integration.logo"
|
||||
:integration-name="integration.name"
|
||||
:integration-description="integration.description"
|
||||
:integration-enabled="integration.enabled"
|
||||
:integration-action="integrationAction"
|
||||
:delete-confirmation-text="{
|
||||
title: t('INTEGRATION_SETTINGS.NOTION.DELETE.TITLE'),
|
||||
message: t('INTEGRATION_SETTINGS.NOTION.DELETE.MESSAGE'),
|
||||
}"
|
||||
>
|
||||
<template #action>
|
||||
<ButtonNext
|
||||
faded
|
||||
blue
|
||||
:label="t('INTEGRATION_SETTINGS.CONNECT.BUTTON_TEXT')"
|
||||
@click="authorize"
|
||||
/>
|
||||
</template>
|
||||
</Integration>
|
||||
</div>
|
||||
<div v-else class="flex items-center justify-center flex-1">
|
||||
<Spinner size="" color-scheme="primary" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+10
@@ -8,6 +8,7 @@ import DashboardApps from './DashboardApps/Index.vue';
|
||||
import Slack from './Slack.vue';
|
||||
import SettingsContent from '../Wrapper.vue';
|
||||
import Linear from './Linear.vue';
|
||||
import Notion from './Notion.vue';
|
||||
import Shopify from './Shopify.vue';
|
||||
|
||||
export default {
|
||||
@@ -90,6 +91,15 @@ export default {
|
||||
},
|
||||
props: route => ({ code: route.query.code }),
|
||||
},
|
||||
{
|
||||
path: 'notion',
|
||||
name: 'settings_integrations_notion',
|
||||
component: Notion,
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
props: route => ({ code: route.query.code }),
|
||||
},
|
||||
{
|
||||
path: 'shopify',
|
||||
name: 'settings_integrations_shopify',
|
||||
|
||||
+28
-4
@@ -75,10 +75,34 @@ export default {
|
||||
onRegistrationSuccess() {
|
||||
this.hasEnabledPushPermissions = true;
|
||||
},
|
||||
onRequestPermissions() {
|
||||
requestPushPermissions({
|
||||
onSuccess: this.onRegistrationSuccess,
|
||||
});
|
||||
onRequestPermissions(value) {
|
||||
if (value) {
|
||||
// Enable / re-enable push notifications
|
||||
requestPushPermissions({
|
||||
onSuccess: this.onRegistrationSuccess,
|
||||
});
|
||||
} else {
|
||||
// Disable push notifications
|
||||
this.disablePushPermissions();
|
||||
}
|
||||
},
|
||||
disablePushPermissions() {
|
||||
verifyServiceWorkerExistence(registration =>
|
||||
registration.pushManager
|
||||
.getSubscription()
|
||||
.then(subscription => {
|
||||
if (subscription) {
|
||||
return subscription.unsubscribe();
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
this.hasEnabledPushPermissions = false;
|
||||
})
|
||||
.catch(() => {
|
||||
// error
|
||||
})
|
||||
);
|
||||
},
|
||||
getPushSubscription() {
|
||||
verifyServiceWorkerExistence(registration =>
|
||||
|
||||
+5
-3
@@ -59,7 +59,7 @@ const defaulSpanRender = cellProps =>
|
||||
cellProps.getValue()
|
||||
);
|
||||
|
||||
const columns = [
|
||||
const columns = computed(() => [
|
||||
columnHelper.accessor('name', {
|
||||
header: t(`SUMMARY_REPORTS.${props.type.toUpperCase()}`),
|
||||
width: 300,
|
||||
@@ -90,7 +90,7 @@ const columns = [
|
||||
width: 200,
|
||||
cell: defaulSpanRender,
|
||||
}),
|
||||
];
|
||||
]);
|
||||
|
||||
const renderAvgTime = value => (value ? formatTime(value) : '--');
|
||||
|
||||
@@ -142,7 +142,9 @@ const table = useVueTable({
|
||||
get data() {
|
||||
return tableData.value;
|
||||
},
|
||||
columns,
|
||||
get columns() {
|
||||
return columns.value;
|
||||
},
|
||||
enableSorting: false,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
@@ -9,29 +9,7 @@ import { throwErrorMessage } from '../utils/api';
|
||||
import AnalyticsHelper from '../../helper/AnalyticsHelper';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
import { ACCOUNT_EVENTS } from '../../helper/AnalyticsHelper/events';
|
||||
|
||||
const buildInboxData = inboxParams => {
|
||||
const formData = new FormData();
|
||||
const { channel = {}, ...inboxProperties } = inboxParams;
|
||||
Object.keys(inboxProperties).forEach(key => {
|
||||
formData.append(key, inboxProperties[key]);
|
||||
});
|
||||
const { selectedFeatureFlags, ...channelParams } = channel;
|
||||
// selectedFeatureFlags needs to be empty when creating a website channel
|
||||
if (selectedFeatureFlags) {
|
||||
if (selectedFeatureFlags.length) {
|
||||
selectedFeatureFlags.forEach(featureFlag => {
|
||||
formData.append(`channel[selected_feature_flags][]`, featureFlag);
|
||||
});
|
||||
} else {
|
||||
formData.append('channel[selected_feature_flags][]', '');
|
||||
}
|
||||
}
|
||||
Object.keys(channelParams).forEach(key => {
|
||||
formData.append(`channel[${key}]`, channel[key]);
|
||||
});
|
||||
return formData;
|
||||
};
|
||||
import { channelActions, buildInboxData } from './inboxes/channelActions';
|
||||
|
||||
export const state = {
|
||||
records: [],
|
||||
@@ -220,6 +198,12 @@ export const actions = {
|
||||
throw new Error(error);
|
||||
}
|
||||
},
|
||||
...channelActions,
|
||||
// TODO: Extract other create channel methods to separate files to reduce file size
|
||||
// - createChannel
|
||||
// - createWebsiteChannel
|
||||
// - createTwilioChannel
|
||||
// - createFBChannel
|
||||
updateInbox: async ({ commit }, { id, formData = true, ...inboxParams }) => {
|
||||
commit(types.default.SET_INBOXES_UI_FLAG, { isUpdating: true });
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as types from '../../mutation-types';
|
||||
import InboxesAPI from '../../../api/inboxes';
|
||||
import AnalyticsHelper from '../../../helper/AnalyticsHelper';
|
||||
import { ACCOUNT_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
||||
|
||||
export const buildInboxData = inboxParams => {
|
||||
const formData = new FormData();
|
||||
const { channel = {}, ...inboxProperties } = inboxParams;
|
||||
Object.keys(inboxProperties).forEach(key => {
|
||||
formData.append(key, inboxProperties[key]);
|
||||
});
|
||||
const { selectedFeatureFlags, ...channelParams } = channel;
|
||||
// selectedFeatureFlags needs to be empty when creating a website channel
|
||||
if (selectedFeatureFlags) {
|
||||
if (selectedFeatureFlags.length) {
|
||||
selectedFeatureFlags.forEach(featureFlag => {
|
||||
formData.append(`channel[selected_feature_flags][]`, featureFlag);
|
||||
});
|
||||
} else {
|
||||
formData.append('channel[selected_feature_flags][]', '');
|
||||
}
|
||||
}
|
||||
Object.keys(channelParams).forEach(key => {
|
||||
formData.append(`channel[${key}]`, channel[key]);
|
||||
});
|
||||
return formData;
|
||||
};
|
||||
|
||||
const sendAnalyticsEvent = channelType => {
|
||||
AnalyticsHelper.track(ACCOUNT_EVENTS.ADDED_AN_INBOX, {
|
||||
channelType,
|
||||
});
|
||||
};
|
||||
|
||||
export const channelActions = {
|
||||
createVoiceChannel: async ({ commit }, params) => {
|
||||
try {
|
||||
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true });
|
||||
const response = await InboxesAPI.create({
|
||||
name: params.name,
|
||||
channel: { ...params.voice, type: 'voice' },
|
||||
});
|
||||
commit(types.default.ADD_INBOXES, response.data);
|
||||
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
|
||||
sendAnalyticsEvent('voice');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -62,6 +62,28 @@ describe('#actions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#createVoiceChannel', () => {
|
||||
it('sends correct actions if API is success', async () => {
|
||||
axios.post.mockResolvedValue({ data: inboxList[0] });
|
||||
await actions.createVoiceChannel({ commit }, inboxList[0]);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.default.SET_INBOXES_UI_FLAG, { isCreating: true }],
|
||||
[types.default.ADD_INBOXES, inboxList[0]],
|
||||
[types.default.SET_INBOXES_UI_FLAG, { isCreating: false }],
|
||||
]);
|
||||
});
|
||||
it('sends correct actions if API is error', async () => {
|
||||
axios.post.mockRejectedValue({ message: 'Incorrect header' });
|
||||
await expect(actions.createVoiceChannel({ commit })).rejects.toThrow(
|
||||
Error
|
||||
);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.default.SET_INBOXES_UI_FLAG, { isCreating: true }],
|
||||
[types.default.SET_INBOXES_UI_FLAG, { isCreating: false }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#createFBChannel', () => {
|
||||
it('sends correct actions if API is success', async () => {
|
||||
axios.post.mockResolvedValue({ data: inboxList[0] });
|
||||
|
||||
@@ -112,11 +112,14 @@ export const InitializationHelpers = {
|
||||
},
|
||||
|
||||
setDirectionAttribute: () => {
|
||||
const portalElement = document.getElementById('portal');
|
||||
if (!portalElement) return;
|
||||
const htmlElement = document.querySelector('html');
|
||||
// If direction is already applied through props, do not apply again (iframe case)
|
||||
const hasDirApplied = htmlElement.getAttribute('data-dir-applied');
|
||||
if (!htmlElement || hasDirApplied) return;
|
||||
|
||||
const locale = document.querySelector('.locale-switcher')?.value;
|
||||
portalElement.dir = locale && getLanguageDirection(locale) ? 'rtl' : 'ltr';
|
||||
const localeFromHtml = htmlElement.lang;
|
||||
htmlElement.dir =
|
||||
localeFromHtml && getLanguageDirection(localeFromHtml) ? 'rtl' : 'ltr';
|
||||
},
|
||||
|
||||
initializeThemesInPortal: initializeTheme,
|
||||
|
||||
@@ -1,64 +1,56 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref, useTemplateRef, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import ArticleSkeletonLoader from 'shared/components/ArticleSkeletonLoader.vue';
|
||||
|
||||
export default {
|
||||
name: 'IframeLoader',
|
||||
components: {
|
||||
ArticleSkeletonLoader,
|
||||
const props = defineProps({
|
||||
url: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
props: {
|
||||
url: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isRtl: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isRtl: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isLoading: true,
|
||||
showEmptyState: !this.url,
|
||||
};
|
||||
isDirApplied: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
watch: {
|
||||
isRtl: {
|
||||
immediate: true,
|
||||
handler(value) {
|
||||
this.$nextTick(() => {
|
||||
const iframeElement = this.$el.querySelector('iframe');
|
||||
if (iframeElement) {
|
||||
iframeElement.onload = () => {
|
||||
try {
|
||||
const iframeDocument =
|
||||
iframeElement.contentDocument ||
|
||||
(iframeElement.contentWindow &&
|
||||
iframeElement.contentWindow.document);
|
||||
});
|
||||
|
||||
if (iframeDocument) {
|
||||
iframeDocument.documentElement.dir = value ? 'rtl' : 'ltr';
|
||||
}
|
||||
} catch (e) {
|
||||
// error
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handleIframeLoad() {
|
||||
// Once loaded, the loading state is hidden
|
||||
this.isLoading = false;
|
||||
},
|
||||
handleIframeError() {
|
||||
// Hide the loading state and show the empty state when an error occurs
|
||||
this.isLoading = false;
|
||||
this.showEmptyState = true;
|
||||
},
|
||||
},
|
||||
const { t } = useI18n();
|
||||
|
||||
const iframe = useTemplateRef('iframe');
|
||||
const isLoading = ref(true);
|
||||
const showEmptyState = ref(!props.url);
|
||||
|
||||
const direction = computed(() => (props.isRtl ? 'rtl' : 'ltr'));
|
||||
|
||||
const applyDirection = () => {
|
||||
if (!iframe.value) return;
|
||||
if (!props.isDirApplied) return; // If direction is already applied through props, do not apply again (iframe case)
|
||||
try {
|
||||
const doc =
|
||||
iframe.value.contentDocument || iframe.value.contentWindow?.document;
|
||||
if (doc?.documentElement) {
|
||||
doc.documentElement.dir = direction.value;
|
||||
doc.documentElement.setAttribute('data-dir-applied', 'true');
|
||||
}
|
||||
} catch (e) {
|
||||
// error
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => props.isRtl, applyDirection);
|
||||
|
||||
const handleIframeLoad = () => {
|
||||
isLoading.value = false;
|
||||
applyDirection();
|
||||
};
|
||||
|
||||
const handleIframeError = () => {
|
||||
isLoading.value = false;
|
||||
showEmptyState.value = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -66,6 +58,7 @@ export default {
|
||||
<div class="relative overflow-hidden pb-1/2 h-full">
|
||||
<iframe
|
||||
v-if="url"
|
||||
ref="iframe"
|
||||
:src="url"
|
||||
class="absolute w-full h-full top-0 left-0"
|
||||
@load="handleIframeLoad"
|
||||
@@ -79,7 +72,7 @@ export default {
|
||||
v-if="showEmptyState"
|
||||
class="absolute w-full h-full top-0 left-0 flex justify-center items-center"
|
||||
>
|
||||
<p>{{ $t('PORTAL.IFRAME_ERROR') }}</p>
|
||||
<p>{{ t('PORTAL.IFRAME_ERROR') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Determine the best-matching locale from the list of locales allowed by the portal.
|
||||
*
|
||||
* The matching happens in the following order:
|
||||
* 1. Exact match – the visitor-selected locale equals one in the `allowedLocales` list
|
||||
* (e.g., `fr` ➜ `fr`).
|
||||
* 2. Base language match – the base part of a compound locale (before the underscore)
|
||||
* matches (e.g., `fr_CA` ➜ `fr`).
|
||||
* 3. Variant match – when the base language is selected but a regional variant exists
|
||||
* in the portal list (e.g., `fr` ➜ `fr_BE`).
|
||||
*
|
||||
* If none of these rules find a match, the function returns `null`,
|
||||
* Don't show popular articles if locale doesn't match with allowed locales
|
||||
*
|
||||
* @export
|
||||
* @param {string} selectedLocale The locale selected by the visitor (e.g., `fr_CA`).
|
||||
* @param {string[]} allowedLocales Array of locales enabled for the portal.
|
||||
* @returns {(string|null)} A locale string that should be used, or `null` if no suitable match.
|
||||
*/
|
||||
export const getMatchingLocale = (selectedLocale = '', allowedLocales = []) => {
|
||||
// Ensure inputs are valid
|
||||
if (
|
||||
!selectedLocale ||
|
||||
!Array.isArray(allowedLocales) ||
|
||||
!allowedLocales.length
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [lang] = selectedLocale.split('_');
|
||||
|
||||
const priorityMatches = [
|
||||
selectedLocale, // exact match
|
||||
lang, // base language match
|
||||
allowedLocales.find(l => l.startsWith(`${lang}_`)), // first variant match
|
||||
];
|
||||
|
||||
// Return the first match that exists in the allowed list, or null
|
||||
return priorityMatches.find(l => l && allowedLocales.includes(l)) ?? null;
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { getMatchingLocale } from 'shared/helpers/portalHelper';
|
||||
|
||||
describe('portalHelper - getMatchingLocale', () => {
|
||||
it('returns exact match when present', () => {
|
||||
const result = getMatchingLocale('fr', ['en', 'fr']);
|
||||
expect(result).toBe('fr');
|
||||
});
|
||||
|
||||
it('returns base language match when exact variant not present', () => {
|
||||
const result = getMatchingLocale('fr_CA', ['en', 'fr']);
|
||||
expect(result).toBe('fr');
|
||||
});
|
||||
|
||||
it('returns variant match when base language not present', () => {
|
||||
const result = getMatchingLocale('fr', ['en', 'fr_BE']);
|
||||
expect(result).toBe('fr_BE');
|
||||
});
|
||||
|
||||
it('returns null when no match found', () => {
|
||||
const result = getMatchingLocale('de', ['en', 'fr']);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for invalid inputs', () => {
|
||||
expect(getMatchingLocale('', [])).toBeNull();
|
||||
expect(getMatchingLocale(null, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -63,6 +63,9 @@ export default {
|
||||
isATelegramChannel() {
|
||||
return this.channelType === INBOX_TYPES.TELEGRAM;
|
||||
},
|
||||
isAVoiceChannel() {
|
||||
return this.channelType === INBOX_TYPES.VOICE;
|
||||
},
|
||||
isATwilioSMSChannel() {
|
||||
const { medium: medium = '' } = this.inbox;
|
||||
return this.isATwilioChannel && medium === 'sms';
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useRouter } from 'vue-router';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useDarkMode } from 'widget/composables/useDarkMode';
|
||||
import { getMatchingLocale } from 'shared/helpers/portalHelper';
|
||||
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
@@ -20,17 +21,8 @@ const articleUiFlags = useMapGetter('article/uiFlags');
|
||||
|
||||
const locale = computed(() => {
|
||||
const { locale: selectedLocale } = i18n;
|
||||
const {
|
||||
allowed_locales: allowedLocales,
|
||||
default_locale: defaultLocale = 'en',
|
||||
} = portal.value.config;
|
||||
// IMPORTANT: Variation strict locale matching, Follow iso_639_1_code
|
||||
// If the exact match of a locale is available in the list of portal locales, return it
|
||||
// Else return the default locale. Eg: `es` will not work if `es_ES` is available in the list
|
||||
if (allowedLocales.includes(selectedLocale)) {
|
||||
return locale;
|
||||
}
|
||||
return defaultLocale;
|
||||
const { allowed_locales: allowedLocales } = portal.value.config;
|
||||
return getMatchingLocale(selectedLocale.value, allowedLocales);
|
||||
});
|
||||
|
||||
const fetchArticles = () => {
|
||||
@@ -46,6 +38,7 @@ const openArticleInArticleViewer = link => {
|
||||
const params = new URLSearchParams({
|
||||
show_plain_layout: 'true',
|
||||
theme: prefersDarkMode.value ? 'dark' : 'light',
|
||||
...(locale.value && { locale: locale.value }),
|
||||
});
|
||||
|
||||
// Combine link with query parameters
|
||||
@@ -64,7 +57,8 @@ const hasArticles = computed(
|
||||
() =>
|
||||
!articleUiFlags.value.isFetching &&
|
||||
!articleUiFlags.value.isError &&
|
||||
!!popularArticles.value.length
|
||||
!!popularArticles.value.length &&
|
||||
!!locale.value
|
||||
);
|
||||
onMounted(() => fetchArticles());
|
||||
</script>
|
||||
|
||||
@@ -23,6 +23,7 @@ export const actions = {
|
||||
commit('setError', false);
|
||||
|
||||
try {
|
||||
if (!locale) return;
|
||||
const cachedData = getFromCache(`${CACHE_KEY_PREFIX}${slug}_${locale}`);
|
||||
if (cachedData) {
|
||||
commit('setArticles', cachedData);
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
<script>
|
||||
import IframeLoader from 'shared/components/IframeLoader.vue';
|
||||
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
|
||||
|
||||
export default {
|
||||
name: 'ArticleViewer',
|
||||
components: {
|
||||
IframeLoader,
|
||||
},
|
||||
computed: {
|
||||
isRTL() {
|
||||
return this.$root.$i18n.locale
|
||||
? getLanguageDirection(this.$root.$i18n.locale)
|
||||
: false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-white h-full">
|
||||
<IframeLoader :url="$route.query.link" :is-rtl="isRTL" />
|
||||
<IframeLoader :url="$route.query.link" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -47,6 +47,10 @@ class ReportingEventListener < BaseListener
|
||||
message = extract_message_and_account(event)[0]
|
||||
conversation = message.conversation
|
||||
waiting_since = event.data[:waiting_since]
|
||||
|
||||
return if waiting_since.blank?
|
||||
|
||||
# When waiting_since is nil, set reply_time to 0
|
||||
reply_time = message.created_at.to_i - waiting_since.to_i
|
||||
|
||||
reporting_event = ReportingEvent.new(
|
||||
|
||||
@@ -198,11 +198,21 @@ class Conversation < ApplicationRecord
|
||||
private
|
||||
|
||||
def execute_after_update_commit_callbacks
|
||||
handle_resolved_status_change
|
||||
notify_status_change
|
||||
create_activity
|
||||
notify_conversation_updation
|
||||
end
|
||||
|
||||
def handle_resolved_status_change
|
||||
# When conversation is resolved, clear waiting_since using update_column to avoid callbacks
|
||||
return unless saved_change_to_status? && status == 'resolved'
|
||||
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
update_column(:waiting_since, nil)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
|
||||
def ensure_snooze_until_reset
|
||||
self.snoozed_until = nil unless snoozed?
|
||||
end
|
||||
|
||||
@@ -55,9 +55,11 @@ class Integrations::App
|
||||
when 'linear'
|
||||
GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
|
||||
when 'shopify'
|
||||
account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present?
|
||||
shopify_enabled?(account)
|
||||
when 'leadsquared'
|
||||
account.feature_enabled?('crm_integration')
|
||||
when 'notion'
|
||||
notion_enabled?(account)
|
||||
else
|
||||
true
|
||||
end
|
||||
@@ -113,4 +115,14 @@ class Integrations::App
|
||||
all.detect { |app| app.id == params[:id] }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def shopify_enabled?(account)
|
||||
account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present?
|
||||
end
|
||||
|
||||
def notion_enabled?(account)
|
||||
account.feature_enabled?('notion_integration') && GlobalConfigService.load('NOTION_CLIENT_ID', nil).present?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -53,6 +53,10 @@ class Integrations::Hook < ApplicationRecord
|
||||
app_id == 'dialogflow'
|
||||
end
|
||||
|
||||
def notion?
|
||||
app_id == 'notion'
|
||||
end
|
||||
|
||||
def disable
|
||||
update(status: 'disabled')
|
||||
end
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# idx_messages_account_content_created (account_id,content_type,created_at)
|
||||
# index_messages_on_account_created_type (account_id,created_at,message_type)
|
||||
# index_messages_on_account_id (account_id)
|
||||
# index_messages_on_account_id_and_inbox_id (account_id,inbox_id)
|
||||
|
||||
@@ -24,6 +24,15 @@ class Instagram::Messenger::MessageText < Instagram::BaseMessageText
|
||||
end
|
||||
|
||||
def handle_client_error(error)
|
||||
# Handle error code 230: User consent is required to access user profile
|
||||
# This typically occurs when the connected Instagram account attempts to send a message to a user
|
||||
# who has never messaged this Instagram account before.
|
||||
# We can safely ignore this error as per Facebook documentation.
|
||||
if error.message.include?('230')
|
||||
Rails.logger.warn error
|
||||
return
|
||||
end
|
||||
|
||||
Rails.logger.warn("[FacebookUserFetchClientError]: account_id #{@inbox.account_id} inbox_id #{@inbox.id}")
|
||||
Rails.logger.warn("[FacebookUserFetchClientError]: #{error.message}")
|
||||
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
|
||||
|
||||
@@ -5,22 +5,34 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
|
||||
sections << "Channel: #{@record.inbox.channel.name}"
|
||||
sections << 'Message History:'
|
||||
sections << if @record.messages.any?
|
||||
build_messages
|
||||
build_messages(config)
|
||||
else
|
||||
'No messages in this conversation'
|
||||
end
|
||||
|
||||
sections << "Contact Details: #{@record.contact.to_llm_text}" if config[:include_contact_details]
|
||||
|
||||
attributes = build_attributes
|
||||
if attributes.present?
|
||||
sections << 'Conversation Attributes:'
|
||||
sections << attributes
|
||||
end
|
||||
|
||||
sections.join("\n")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_messages
|
||||
def build_messages(config = {})
|
||||
return "No messages in this conversation\n" if @record.messages.empty?
|
||||
|
||||
message_text = ''
|
||||
@record.messages.chat.order(created_at: :asc).each do |message|
|
||||
messages = @record.messages.where.not(message_type: :activity).order(created_at: :asc)
|
||||
|
||||
messages.each do |message|
|
||||
# Skip private messages unless explicitly included in config
|
||||
next if message.private? && !config[:include_private_messages]
|
||||
|
||||
message_text << format_message(message)
|
||||
end
|
||||
message_text
|
||||
@@ -28,6 +40,14 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
|
||||
|
||||
def format_message(message)
|
||||
sender = message.message_type == 'incoming' ? 'User' : 'Support agent'
|
||||
sender = "[Private Note] #{sender}" if message.private?
|
||||
"#{sender}: #{message.content}\n"
|
||||
end
|
||||
|
||||
def build_attributes
|
||||
attributes = @record.account.custom_attribute_definitions.with_attribute_model('conversation_attribute').map do |attribute|
|
||||
"#{attribute.attribute_display_name}: #{@record.custom_attributes[attribute.attribute_key]}"
|
||||
end
|
||||
attributes.join("\n")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,8 +4,10 @@ class MessageTemplates::Template::AutoResolve
|
||||
def perform
|
||||
return if conversation.account.auto_resolve_message.blank?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
if within_messaging_window?
|
||||
conversation.messages.create!(auto_resolve_message_params)
|
||||
else
|
||||
create_auto_resolve_not_sent_activity_message
|
||||
end
|
||||
end
|
||||
|
||||
@@ -14,6 +16,21 @@ class MessageTemplates::Template::AutoResolve
|
||||
delegate :contact, :account, to: :conversation
|
||||
delegate :inbox, to: :message
|
||||
|
||||
def within_messaging_window?
|
||||
conversation.can_reply?
|
||||
end
|
||||
|
||||
def create_auto_resolve_not_sent_activity_message
|
||||
content = I18n.t('conversations.activity.auto_resolve.not_sent_due_to_messaging_window')
|
||||
activity_message_params = {
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
message_type: :activity,
|
||||
content: content
|
||||
}
|
||||
::Conversations::ActivityMessageJob.perform_later(conversation, activity_message_params) if content
|
||||
end
|
||||
|
||||
def auto_resolve_message_params
|
||||
{
|
||||
account_id: @conversation.account_id,
|
||||
|
||||
+1
-1
@@ -6,8 +6,8 @@
|
||||
<strong>Chatwoot Installation:</strong> {{ meta.instance_url }}<br>
|
||||
<strong>Account ID:</strong> {{ meta.account_id }}<br>
|
||||
<strong>Account Name:</strong> {{ meta.account_name }}<br>
|
||||
<strong>Deletion due at:</strong> {{ meta.marked_for_deletion_at }}<br>
|
||||
<strong>Deleted At:</strong> {{ meta.deleted_at }}<br>
|
||||
<strong>Marked for Deletion at:</strong> {{ meta.marked_for_deletion_at }}<br>
|
||||
<strong>Deletion Reason:</strong> {{ meta.deletion_reason }}
|
||||
</p>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<% if @message.content %>
|
||||
<%= ChatwootMarkdownRenderer.new(@message.content).render_message %>
|
||||
<%= ChatwootMarkdownRenderer.new(@message.outgoing_content).render_message %>
|
||||
<% end %>
|
||||
<% if @large_attachments.present? %>
|
||||
<p>Attachments:</p>
|
||||
|
||||
@@ -156,9 +156,14 @@
|
||||
<path d="M 8 3 C 5.243 3 3 5.243 3 8 L 3 16 C 3 18.757 5.243 21 8 21 L 16 21 C 18.757 21 21 18.757 21 16 L 21 8 C 21 5.243 18.757 3 16 3 L 8 3 z M 8 5 L 16 5 C 17.654 5 19 6.346 19 8 L 19 16 C 19 17.654 17.654 19 16 19 L 8 19 C 6.346 19 5 17.654 5 16 L 5 8 C 5 6.346 6.346 5 8 5 z M 17 6 A 1 1 0 0 0 16 7 A 1 1 0 0 0 17 8 A 1 1 0 0 0 18 7 A 1 1 0 0 0 17 6 z M 12 7 C 9.243 7 7 9.243 7 12 C 7 14.757 9.243 17 12 17 C 14.757 17 17 14.757 17 12 C 17 9.243 14.757 7 12 7 z M 12 9 C 13.654 9 15 10.346 15 12 C 15 13.654 13.654 15 12 15 C 10.346 15 9 13.654 9 12 C 9 10.346 10.346 9 12 9 z"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="icon-notion" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M6.104 5.91c.584.474.802.438 1.898.365l10.332-.62c.22 0 .037-.22-.036-.256l-1.716-1.24c-.329-.255-.767-.548-1.606-.475l-10.005.73c-.364.036-.437.219-.292.365zm.62 2.408v10.87c0 .585.292.803.95.767l11.354-.657c.657-.036.73-.438.73-.913V7.588c0-.474-.182-.73-.584-.693l-11.866.693c-.438.036-.584.255-.584.73m11.21.583c.072.328 0 .657-.33.694l-.547.109v8.025c-.475.256-.913.401-1.278.401c-.584 0-.73-.182-1.168-.729l-3.579-5.618v5.436l1.133.255s0 .656-.914.656l-2.519.146c-.073-.146 0-.51.256-.583l.657-.182v-7.187l-.913-.073c-.073-.329.11-.803.621-.84l2.702-.182l3.724 5.692V9.886l-.95-.109c-.072-.402.22-.693.585-.73zM4.131 3.429l10.406-.766c1.277-.11 1.606-.036 2.41.547l3.321 2.335c.548.401.731.51.731.948v12.805c0 .803-.292 1.277-1.314 1.35l-12.085.73c-.767.036-1.132-.073-1.534-.584L3.62 17.62c-.438-.584-.62-1.021-.62-1.533V4.705c0-.656.292-1.203 1.132-1.276"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="icon-shopify" viewBox="0 0 32 32">
|
||||
<path fill="currentColor" d="m20.448 31.974l9.625-2.083s-3.474-23.484-3.5-23.641s-.156-.255-.281-.255c-.13 0-2.573-.182-2.573-.182s-1.703-1.698-1.922-1.88a.4.4 0 0 0-.161-.099l-1.219 28.141zm-4.833-16.901s-1.083-.563-2.365-.563c-1.932 0-2.005 1.203-2.005 1.521c0 1.641 4.318 2.286 4.318 6.172c0 3.057-1.922 5.01-4.542 5.01c-3.141 0-4.719-1.953-4.719-1.953l.859-2.781s1.661 1.422 3.042 1.422c.901 0 1.302-.724 1.302-1.245c0-2.156-3.542-2.255-3.542-5.807c-.047-2.984 2.094-5.891 6.438-5.891c1.677 0 2.5.479 2.5.479l-1.26 3.625zm-.719-13.969c.177 0 .359.052.536.182c-1.313.62-2.75 2.188-3.344 5.323a76 76 0 0 1-2.516.771c.688-2.38 2.359-6.26 5.323-6.26zm1.646 3.932v.182c-1.005.307-2.115.646-3.193.979c.62-2.37 1.776-3.526 2.781-3.958c.255.667.411 1.568.411 2.797zm.718-2.973c.922.094 1.521 1.151 1.901 2.339c-.464.151-.979.307-1.542.484v-.333c0-1.005-.13-1.828-.359-2.495zm3.99 1.718c-.031 0-.083.026-.104.026c-.026 0-.385.099-.953.281C19.63 2.442 18.625.927 16.849.927h-.156C16.183.281 15.558 0 15.021 0c-4.141 0-6.12 5.172-6.74 7.797c-1.594.484-2.75.844-2.88.896c-.901.286-.927.313-1.031 1.161c-.099.615-2.438 18.75-2.438 18.75L20.01 32z"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="icon-slack" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M6.527 14.514A1.973 1.973 0 0 1 4.56 16.48a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h1.967zm.992 0c0-1.083.885-1.968 1.968-1.968s1.967.885 1.967 1.968v4.927a1.973 1.973 0 0 1-1.967 1.968a1.973 1.973 0 0 1-1.968-1.968zm1.968-7.987A1.973 1.973 0 0 1 7.519 4.56c0-1.083.885-1.967 1.968-1.967s1.967.884 1.967 1.967v1.968zm0 .992c1.083 0 1.967.884 1.967 1.967a1.973 1.973 0 0 1-1.967 1.968H4.56a1.973 1.973 0 0 1-1.968-1.968c0-1.083.885-1.967 1.968-1.967zm7.986 1.967c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967a1.973 1.973 0 0 1-1.968 1.968h-1.968zm-.991 0a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968V4.56c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967zm-1.968 7.987c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968v-1.968zm0-.992a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h4.927c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.967z"/>
|
||||
</symbol>
|
||||
|
||||
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 42 KiB |
+1
-1
@@ -1,5 +1,5 @@
|
||||
shared: &shared
|
||||
version: '4.2.0'
|
||||
version: '4.3.0'
|
||||
|
||||
development:
|
||||
<<: *shared
|
||||
|
||||
+8
-1
@@ -168,4 +168,11 @@
|
||||
enabled: true
|
||||
- name: crm_integration
|
||||
display_name: CRM Integration
|
||||
enabled: false
|
||||
enabled: false
|
||||
- name: channel_voice
|
||||
display_name: Voice Channel
|
||||
enabled: false
|
||||
chatwoot_internal: true
|
||||
- name: notion_integration
|
||||
display_name: Notion Integration
|
||||
enabled: false
|
||||
|
||||
@@ -288,6 +288,25 @@
|
||||
type: secret
|
||||
## ------ End of Configs added for Linear ------ ##
|
||||
|
||||
## ------ Configs added for Notion ------ ##
|
||||
- name: NOTION_CLIENT_ID
|
||||
display_title: 'Notion Client ID'
|
||||
value:
|
||||
locked: false
|
||||
description: 'Notion client ID'
|
||||
- name: NOTION_CLIENT_SECRET
|
||||
display_title: 'Notion Client Secret'
|
||||
value:
|
||||
locked: false
|
||||
description: 'Notion client secret'
|
||||
type: secret
|
||||
- name: NOTION_VERSION
|
||||
display_title: 'Notion Version'
|
||||
value: '2022-06-28'
|
||||
locked: false
|
||||
description: 'Notion version'
|
||||
## ------ End of Configs added for Notion ------ ##
|
||||
|
||||
## ------ Configs added for Slack ------ ##
|
||||
- name: SLACK_CLIENT_ID
|
||||
display_title: 'Slack Client ID'
|
||||
|
||||
@@ -63,6 +63,12 @@ linear:
|
||||
action: https://linear.app/oauth/authorize
|
||||
hook_type: account
|
||||
allow_multiple_hooks: false
|
||||
notion:
|
||||
id: notion
|
||||
logo: notion.png
|
||||
i18n_key: notion
|
||||
hook_type: account
|
||||
allow_multiple_hooks: false
|
||||
slack:
|
||||
id: slack
|
||||
logo: slack.png
|
||||
|
||||
@@ -91,6 +91,8 @@ am:
|
||||
conversations_count: No. of conversations
|
||||
avg_first_response_time: Avg first response time
|
||||
avg_resolution_time: Avg resolution time
|
||||
avg_reply_time: Avg reply time
|
||||
resolution_count: Resolution Count
|
||||
team_csv:
|
||||
team_name: Team name
|
||||
conversations_count: Conversations count
|
||||
@@ -138,6 +140,8 @@ am:
|
||||
instagram_story_content: '%{story_sender} mentioned you in the story: '
|
||||
instagram_deleted_story_content: This story is no longer available.
|
||||
deleted: This message was deleted
|
||||
whatsapp:
|
||||
list_button_label: 'Choose an item'
|
||||
delivery_status:
|
||||
error_code: 'Error code: %{error_code}'
|
||||
activity:
|
||||
@@ -172,6 +176,10 @@ am:
|
||||
sla:
|
||||
added: '%{user_name} added SLA policy %{sla_name}'
|
||||
removed: '%{user_name} removed SLA policy %{sla_name}'
|
||||
linear:
|
||||
issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
|
||||
issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
|
||||
issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
|
||||
csat:
|
||||
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
|
||||
muted: '%{user_name} has muted the conversation'
|
||||
|
||||
@@ -91,6 +91,8 @@ ar:
|
||||
conversations_count: عدد المحادثات
|
||||
avg_first_response_time: متوسط وقت الرد الأول
|
||||
avg_resolution_time: متوسط وقت الحل
|
||||
avg_reply_time: Avg reply time
|
||||
resolution_count: عدد مرات الإغلاق
|
||||
team_csv:
|
||||
team_name: اسم الفريق
|
||||
conversations_count: عدد المحادثات
|
||||
@@ -138,6 +140,8 @@ ar:
|
||||
instagram_story_content: 'أشار %{story_sender} إليك في القصة: '
|
||||
instagram_deleted_story_content: هذه القصة لم تعد متاحة.
|
||||
deleted: تم حذف هذه الرسالة
|
||||
whatsapp:
|
||||
list_button_label: 'Choose an item'
|
||||
delivery_status:
|
||||
error_code: 'رمز الخطأ: %{error_code}'
|
||||
activity:
|
||||
@@ -172,6 +176,10 @@ ar:
|
||||
sla:
|
||||
added: '%{user_name} أضاف سياسة مستوى الخدمة %{sla_name}'
|
||||
removed: '%{user_name} أزال سياسة مستوى الخدمة %{sla_name}'
|
||||
linear:
|
||||
issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
|
||||
issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
|
||||
issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
|
||||
csat:
|
||||
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
|
||||
muted: '%{user_name} كتم صوت المحادثة'
|
||||
|
||||
@@ -91,6 +91,8 @@ az:
|
||||
conversations_count: No. of conversations
|
||||
avg_first_response_time: Avg first response time
|
||||
avg_resolution_time: Avg resolution time
|
||||
avg_reply_time: Avg reply time
|
||||
resolution_count: Resolution Count
|
||||
team_csv:
|
||||
team_name: Team name
|
||||
conversations_count: Conversations count
|
||||
@@ -138,6 +140,8 @@ az:
|
||||
instagram_story_content: '%{story_sender} mentioned you in the story: '
|
||||
instagram_deleted_story_content: This story is no longer available.
|
||||
deleted: This message was deleted
|
||||
whatsapp:
|
||||
list_button_label: 'Choose an item'
|
||||
delivery_status:
|
||||
error_code: 'Error code: %{error_code}'
|
||||
activity:
|
||||
@@ -172,6 +176,10 @@ az:
|
||||
sla:
|
||||
added: '%{user_name} added SLA policy %{sla_name}'
|
||||
removed: '%{user_name} removed SLA policy %{sla_name}'
|
||||
linear:
|
||||
issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
|
||||
issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
|
||||
issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
|
||||
csat:
|
||||
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
|
||||
muted: '%{user_name} has muted the conversation'
|
||||
|
||||
@@ -91,6 +91,8 @@ bg:
|
||||
conversations_count: No. of conversations
|
||||
avg_first_response_time: Avg first response time
|
||||
avg_resolution_time: Avg resolution time
|
||||
avg_reply_time: Avg reply time
|
||||
resolution_count: Resolution Count
|
||||
team_csv:
|
||||
team_name: Team name
|
||||
conversations_count: Conversations count
|
||||
@@ -138,6 +140,8 @@ bg:
|
||||
instagram_story_content: '%{story_sender} mentioned you in the story: '
|
||||
instagram_deleted_story_content: This story is no longer available.
|
||||
deleted: This message was deleted
|
||||
whatsapp:
|
||||
list_button_label: 'Choose an item'
|
||||
delivery_status:
|
||||
error_code: 'Error code: %{error_code}'
|
||||
activity:
|
||||
@@ -172,6 +176,10 @@ bg:
|
||||
sla:
|
||||
added: '%{user_name} added SLA policy %{sla_name}'
|
||||
removed: '%{user_name} removed SLA policy %{sla_name}'
|
||||
linear:
|
||||
issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
|
||||
issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
|
||||
issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
|
||||
csat:
|
||||
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
|
||||
muted: '%{user_name} has muted the conversation'
|
||||
|
||||
@@ -91,6 +91,8 @@ ca:
|
||||
conversations_count: Nre. de converses
|
||||
avg_first_response_time: Temps mitjà de primera resposta
|
||||
avg_resolution_time: Temps mitjà de resolució
|
||||
avg_reply_time: Avg reply time
|
||||
resolution_count: Total de resolucions
|
||||
team_csv:
|
||||
team_name: Nom de l'equip
|
||||
conversations_count: Recompte de converses
|
||||
@@ -138,6 +140,8 @@ ca:
|
||||
instagram_story_content: '%{story_sender} t''ha mencionat a la història: '
|
||||
instagram_deleted_story_content: Aquesta història ja no està disponible.
|
||||
deleted: Aquest missatge a sigut eliminat
|
||||
whatsapp:
|
||||
list_button_label: 'Choose an item'
|
||||
delivery_status:
|
||||
error_code: 'Codi d''error: %{error_code}'
|
||||
activity:
|
||||
@@ -172,6 +176,10 @@ ca:
|
||||
sla:
|
||||
added: '%{user_name} ha afegit la política de SLA %{sla_name}'
|
||||
removed: '%{user_name} ha eliminat la política de SLA %{sla_name}'
|
||||
linear:
|
||||
issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
|
||||
issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
|
||||
issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
|
||||
csat:
|
||||
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
|
||||
muted: '%{user_name} ha silenciat la conversa'
|
||||
|
||||
@@ -91,6 +91,8 @@ cs:
|
||||
conversations_count: No. of conversations
|
||||
avg_first_response_time: Avg first response time
|
||||
avg_resolution_time: Avg resolution time
|
||||
avg_reply_time: Avg reply time
|
||||
resolution_count: Počet rozlišení
|
||||
team_csv:
|
||||
team_name: Team name
|
||||
conversations_count: Conversations count
|
||||
@@ -138,6 +140,8 @@ cs:
|
||||
instagram_story_content: '%{story_sender} mentioned you in the story: '
|
||||
instagram_deleted_story_content: This story is no longer available.
|
||||
deleted: Tato zpráva byla smazána
|
||||
whatsapp:
|
||||
list_button_label: 'Choose an item'
|
||||
delivery_status:
|
||||
error_code: 'Error code: %{error_code}'
|
||||
activity:
|
||||
@@ -172,6 +176,10 @@ cs:
|
||||
sla:
|
||||
added: '%{user_name} added SLA policy %{sla_name}'
|
||||
removed: '%{user_name} removed SLA policy %{sla_name}'
|
||||
linear:
|
||||
issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
|
||||
issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
|
||||
issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
|
||||
csat:
|
||||
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
|
||||
muted: '%{user_name} ztlumil/a konverzaci'
|
||||
|
||||
@@ -91,6 +91,8 @@ da:
|
||||
conversations_count: Antal samtaler
|
||||
avg_first_response_time: Avg first response time
|
||||
avg_resolution_time: Avg resolution time
|
||||
avg_reply_time: Avg reply time
|
||||
resolution_count: Antal Afsluttede
|
||||
team_csv:
|
||||
team_name: Team navn
|
||||
conversations_count: Samtaler tæller
|
||||
@@ -138,6 +140,8 @@ da:
|
||||
instagram_story_content: '%{story_sender} nævnte dig i historien: '
|
||||
instagram_deleted_story_content: Denne historie er ikke længere tilgængelig.
|
||||
deleted: Denne besked blev slettet
|
||||
whatsapp:
|
||||
list_button_label: 'Choose an item'
|
||||
delivery_status:
|
||||
error_code: 'Error code: %{error_code}'
|
||||
activity:
|
||||
@@ -172,6 +176,10 @@ da:
|
||||
sla:
|
||||
added: '%{user_name} added SLA policy %{sla_name}'
|
||||
removed: '%{user_name} removed SLA policy %{sla_name}'
|
||||
linear:
|
||||
issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
|
||||
issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
|
||||
issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
|
||||
csat:
|
||||
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
|
||||
muted: '%{user_name} har slukket for samtalen'
|
||||
|
||||
@@ -91,6 +91,8 @@ de:
|
||||
conversations_count: Anzahl der Konversationen
|
||||
avg_first_response_time: Durchschnittliche Zeit bis zur ersten Antwort
|
||||
avg_resolution_time: Durchschnittliche Auflösung
|
||||
avg_reply_time: Avg reply time
|
||||
resolution_count: Auflösungsanzahl
|
||||
team_csv:
|
||||
team_name: Teamname
|
||||
conversations_count: Anzahl Gespräche
|
||||
@@ -138,6 +140,8 @@ de:
|
||||
instagram_story_content: '%{story_sender} erwähnte sie in der Geschichte: '
|
||||
instagram_deleted_story_content: Diese Geschichte ist nicht mehr verfügbar.
|
||||
deleted: Diese Nachricht wurde gelöscht
|
||||
whatsapp:
|
||||
list_button_label: 'Choose an item'
|
||||
delivery_status:
|
||||
error_code: 'Fehlercode: %{error_code}'
|
||||
activity:
|
||||
@@ -172,6 +176,10 @@ de:
|
||||
sla:
|
||||
added: '%{user_name} hat SLA-Richtlinie %{sla_name} hinzugefügt'
|
||||
removed: '%{user_name} hat SLA-Richtlinie %{sla_name} entfernt'
|
||||
linear:
|
||||
issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
|
||||
issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
|
||||
issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
|
||||
csat:
|
||||
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
|
||||
muted: '%{user_name} hat das Gespräch stumm geschaltet'
|
||||
|
||||
@@ -91,6 +91,8 @@ el:
|
||||
conversations_count: Αριθμός συνομιλιών
|
||||
avg_first_response_time: Avg first response time
|
||||
avg_resolution_time: Avg resolution time
|
||||
avg_reply_time: Avg reply time
|
||||
resolution_count: Αριθμός Αναλύσεων
|
||||
team_csv:
|
||||
team_name: Όνομα ομάδας
|
||||
conversations_count: Αριθμός συνομιλιών
|
||||
@@ -138,6 +140,8 @@ el:
|
||||
instagram_story_content: 'Ο %{story_sender} σας ανέφερε στην ιστορία: '
|
||||
instagram_deleted_story_content: Η ιστορία δεν είναι πλέον διαθέσιμη.
|
||||
deleted: Το μήνυμα διαγράφηκε
|
||||
whatsapp:
|
||||
list_button_label: 'Choose an item'
|
||||
delivery_status:
|
||||
error_code: 'Error code: %{error_code}'
|
||||
activity:
|
||||
@@ -172,6 +176,10 @@ el:
|
||||
sla:
|
||||
added: '%{user_name} added SLA policy %{sla_name}'
|
||||
removed: '%{user_name} removed SLA policy %{sla_name}'
|
||||
linear:
|
||||
issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
|
||||
issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
|
||||
issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
|
||||
csat:
|
||||
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
|
||||
muted: 'Ο χρήστης %{user_name} σίγασε την συνομιλία'
|
||||
|
||||
@@ -196,6 +196,8 @@ en:
|
||||
issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
|
||||
csat:
|
||||
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
|
||||
auto_resolve:
|
||||
not_sent_due_to_messaging_window: 'Auto-resolve message not sent due to outgoing message restrictions'
|
||||
muted: '%{user_name} has muted the conversation'
|
||||
unmuted: '%{user_name} has unmuted the conversation'
|
||||
auto_resolution_message: 'Resolving the conversation as it has been inactive for a while. Please start a new conversation if you need further assistance.'
|
||||
@@ -255,6 +257,10 @@ en:
|
||||
name: 'Linear'
|
||||
short_description: 'Create and link Linear issues directly from conversations.'
|
||||
description: 'Create issues in Linear directly from your conversation window. Alternatively, link existing Linear issues for a more streamlined and efficient issue tracking process.'
|
||||
notion:
|
||||
name: 'Notion'
|
||||
short_description: 'Integrate databases, documents and pages directly with Captain.'
|
||||
description: 'Connect your Notion workspace to enable Captain to access and generate intelligent responses using content from your databases, documents, and pages to provide more contextual customer support.'
|
||||
shopify:
|
||||
name: 'Shopify'
|
||||
short_description: 'Access order details and customer data from your Shopify store.'
|
||||
|
||||
@@ -91,6 +91,8 @@ es:
|
||||
conversations_count: Núm. de conversaciones
|
||||
avg_first_response_time: Promedio de tiempo de la primera respuesta
|
||||
avg_resolution_time: Tiempo promedio de resolución
|
||||
avg_reply_time: Avg reply time
|
||||
resolution_count: Número de resoluciones
|
||||
team_csv:
|
||||
team_name: Nombre del equipo
|
||||
conversations_count: Cantidad de conversaciones
|
||||
@@ -138,6 +140,8 @@ es:
|
||||
instagram_story_content: '%{story_sender} te mencionó en la historia: '
|
||||
instagram_deleted_story_content: Esta historia ya no está disponible.
|
||||
deleted: Este mensaje se ha eliminado
|
||||
whatsapp:
|
||||
list_button_label: 'Choose an item'
|
||||
delivery_status:
|
||||
error_code: 'Código de error: %{error_code}'
|
||||
activity:
|
||||
@@ -172,6 +176,10 @@ es:
|
||||
sla:
|
||||
added: '%{user_name} agregó la política de SLA %{sla_name}'
|
||||
removed: '%{user_name} eliminó la política de SLA %{sla_name}'
|
||||
linear:
|
||||
issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
|
||||
issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
|
||||
issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
|
||||
csat:
|
||||
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
|
||||
muted: '%{user_name} ha silenciado la conversación'
|
||||
|
||||
@@ -91,6 +91,8 @@ fa:
|
||||
conversations_count: تعداد گفتگوها
|
||||
avg_first_response_time: میانگین زمان تا اولین پاسخ
|
||||
avg_resolution_time: میانگین زمان حل مشکل
|
||||
avg_reply_time: Avg reply time
|
||||
resolution_count: تعداد مسائل حل شده
|
||||
team_csv:
|
||||
team_name: نام تیم
|
||||
conversations_count: Conversations count
|
||||
@@ -138,6 +140,8 @@ fa:
|
||||
instagram_story_content: '%{story_sender} در داستان به شما اشاره کرده: '
|
||||
instagram_deleted_story_content: این داستان دیگر در دسترس نیست.
|
||||
deleted: این پیام حذف شد
|
||||
whatsapp:
|
||||
list_button_label: 'Choose an item'
|
||||
delivery_status:
|
||||
error_code: 'کد خطا " %{error_code}'
|
||||
activity:
|
||||
@@ -172,6 +176,10 @@ fa:
|
||||
sla:
|
||||
added: '%{user_name} سیاست SLA %{sla_name} را اضافه کرد'
|
||||
removed: '%{user_name} سیاست SLA %{sla_name} را حذف کرد'
|
||||
linear:
|
||||
issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
|
||||
issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
|
||||
issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
|
||||
csat:
|
||||
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
|
||||
muted: '%{user_name} مکالمه را بی صدا کرد'
|
||||
|
||||
@@ -91,6 +91,8 @@ fi:
|
||||
conversations_count: No. of conversations
|
||||
avg_first_response_time: Avg first response time
|
||||
avg_resolution_time: Avg resolution time
|
||||
avg_reply_time: Avg reply time
|
||||
resolution_count: Selvitysmäärä
|
||||
team_csv:
|
||||
team_name: Team name
|
||||
conversations_count: Conversations count
|
||||
@@ -138,6 +140,8 @@ fi:
|
||||
instagram_story_content: '%{story_sender} mentioned you in the story: '
|
||||
instagram_deleted_story_content: This story is no longer available.
|
||||
deleted: This message was deleted
|
||||
whatsapp:
|
||||
list_button_label: 'Choose an item'
|
||||
delivery_status:
|
||||
error_code: 'Error code: %{error_code}'
|
||||
activity:
|
||||
@@ -172,6 +176,10 @@ fi:
|
||||
sla:
|
||||
added: '%{user_name} added SLA policy %{sla_name}'
|
||||
removed: '%{user_name} removed SLA policy %{sla_name}'
|
||||
linear:
|
||||
issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
|
||||
issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
|
||||
issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
|
||||
csat:
|
||||
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
|
||||
muted: '%{user_name} mykisti keskustelun'
|
||||
|
||||
@@ -91,6 +91,8 @@ fr:
|
||||
conversations_count: Nbre de conversations
|
||||
avg_first_response_time: Temps moyen pour une première réponse
|
||||
avg_resolution_time: Temps nécessaire pour résoudre une demande (en moyenne)
|
||||
avg_reply_time: Avg reply time
|
||||
resolution_count: Nombre de résolutions
|
||||
team_csv:
|
||||
team_name: Nom de l'équipe
|
||||
conversations_count: Nombre de conversations
|
||||
@@ -138,6 +140,8 @@ fr:
|
||||
instagram_story_content: '%{story_sender} vous a mentionné dans la story: '
|
||||
instagram_deleted_story_content: Cette Story n'est plus disponible.
|
||||
deleted: Ce message a été supprimé
|
||||
whatsapp:
|
||||
list_button_label: 'Choose an item'
|
||||
delivery_status:
|
||||
error_code: 'Code d''erreur : %{error_code}'
|
||||
activity:
|
||||
@@ -172,6 +176,10 @@ fr:
|
||||
sla:
|
||||
added: '%{user_name} added SLA policy %{sla_name}'
|
||||
removed: '%{user_name} removed SLA policy %{sla_name}'
|
||||
linear:
|
||||
issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
|
||||
issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
|
||||
issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
|
||||
csat:
|
||||
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
|
||||
muted: '%{user_name} a mis la conversation en sourdine'
|
||||
|
||||
@@ -91,6 +91,8 @@ he:
|
||||
conversations_count: No. of conversations
|
||||
avg_first_response_time: Avg first response time
|
||||
avg_resolution_time: Avg resolution time
|
||||
avg_reply_time: Avg reply time
|
||||
resolution_count: ספירת רזולוציות
|
||||
team_csv:
|
||||
team_name: שם קבוצה
|
||||
conversations_count: Conversations count
|
||||
@@ -138,6 +140,8 @@ he:
|
||||
instagram_story_content: '%{story_sender} mentioned you in the story: '
|
||||
instagram_deleted_story_content: This story is no longer available.
|
||||
deleted: This message was deleted
|
||||
whatsapp:
|
||||
list_button_label: 'Choose an item'
|
||||
delivery_status:
|
||||
error_code: 'Error code: %{error_code}'
|
||||
activity:
|
||||
@@ -172,6 +176,10 @@ he:
|
||||
sla:
|
||||
added: '%{user_name} added SLA policy %{sla_name}'
|
||||
removed: '%{user_name} removed SLA policy %{sla_name}'
|
||||
linear:
|
||||
issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
|
||||
issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
|
||||
issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
|
||||
csat:
|
||||
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
|
||||
muted: '%{user_name} has muted the conversation'
|
||||
|
||||
@@ -91,6 +91,8 @@ hi:
|
||||
conversations_count: No. of conversations
|
||||
avg_first_response_time: Avg first response time
|
||||
avg_resolution_time: Avg resolution time
|
||||
avg_reply_time: Avg reply time
|
||||
resolution_count: Resolution Count
|
||||
team_csv:
|
||||
team_name: Team name
|
||||
conversations_count: Conversations count
|
||||
@@ -138,6 +140,8 @@ hi:
|
||||
instagram_story_content: '%{story_sender} mentioned you in the story: '
|
||||
instagram_deleted_story_content: This story is no longer available.
|
||||
deleted: This message was deleted
|
||||
whatsapp:
|
||||
list_button_label: 'Choose an item'
|
||||
delivery_status:
|
||||
error_code: 'Error code: %{error_code}'
|
||||
activity:
|
||||
@@ -172,6 +176,10 @@ hi:
|
||||
sla:
|
||||
added: '%{user_name} added SLA policy %{sla_name}'
|
||||
removed: '%{user_name} removed SLA policy %{sla_name}'
|
||||
linear:
|
||||
issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
|
||||
issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
|
||||
issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
|
||||
csat:
|
||||
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
|
||||
muted: '%{user_name} has muted the conversation'
|
||||
|
||||
@@ -91,6 +91,8 @@ hr:
|
||||
conversations_count: No. of conversations
|
||||
avg_first_response_time: Avg first response time
|
||||
avg_resolution_time: Avg resolution time
|
||||
avg_reply_time: Avg reply time
|
||||
resolution_count: Resolution Count
|
||||
team_csv:
|
||||
team_name: Team name
|
||||
conversations_count: Conversations count
|
||||
@@ -138,6 +140,8 @@ hr:
|
||||
instagram_story_content: '%{story_sender} mentioned you in the story: '
|
||||
instagram_deleted_story_content: This story is no longer available.
|
||||
deleted: This message was deleted
|
||||
whatsapp:
|
||||
list_button_label: 'Choose an item'
|
||||
delivery_status:
|
||||
error_code: 'Error code: %{error_code}'
|
||||
activity:
|
||||
@@ -172,6 +176,10 @@ hr:
|
||||
sla:
|
||||
added: '%{user_name} added SLA policy %{sla_name}'
|
||||
removed: '%{user_name} removed SLA policy %{sla_name}'
|
||||
linear:
|
||||
issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
|
||||
issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
|
||||
issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
|
||||
csat:
|
||||
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
|
||||
muted: '%{user_name} has muted the conversation'
|
||||
|
||||
@@ -91,6 +91,8 @@ hu:
|
||||
conversations_count: Beszélgetések száma
|
||||
avg_first_response_time: Avg first response time
|
||||
avg_resolution_time: Avg resolution time
|
||||
avg_reply_time: Avg reply time
|
||||
resolution_count: Megoldások száma
|
||||
team_csv:
|
||||
team_name: Csapatnév
|
||||
conversations_count: Beszélgetésszám
|
||||
@@ -138,6 +140,8 @@ hu:
|
||||
instagram_story_content: '%{story_sender} megemlített egy storyban: '
|
||||
instagram_deleted_story_content: Ez a story már nem érhető el.
|
||||
deleted: Az üzenet törölve lett
|
||||
whatsapp:
|
||||
list_button_label: 'Choose an item'
|
||||
delivery_status:
|
||||
error_code: 'Hibakód: %{error_code}'
|
||||
activity:
|
||||
@@ -172,6 +176,10 @@ hu:
|
||||
sla:
|
||||
added: '%{user_name} added SLA policy %{sla_name}'
|
||||
removed: '%{user_name} removed SLA policy %{sla_name}'
|
||||
linear:
|
||||
issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
|
||||
issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
|
||||
issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
|
||||
csat:
|
||||
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
|
||||
muted: '%{user_name} elnémította a beszélgetést'
|
||||
|
||||
@@ -91,6 +91,8 @@ hy:
|
||||
conversations_count: No. of conversations
|
||||
avg_first_response_time: Avg first response time
|
||||
avg_resolution_time: Avg resolution time
|
||||
avg_reply_time: Avg reply time
|
||||
resolution_count: Resolution Count
|
||||
team_csv:
|
||||
team_name: Team name
|
||||
conversations_count: Conversations count
|
||||
@@ -138,6 +140,8 @@ hy:
|
||||
instagram_story_content: '%{story_sender} mentioned you in the story: '
|
||||
instagram_deleted_story_content: This story is no longer available.
|
||||
deleted: This message was deleted
|
||||
whatsapp:
|
||||
list_button_label: 'Choose an item'
|
||||
delivery_status:
|
||||
error_code: 'Error code: %{error_code}'
|
||||
activity:
|
||||
@@ -172,6 +176,10 @@ hy:
|
||||
sla:
|
||||
added: '%{user_name} added SLA policy %{sla_name}'
|
||||
removed: '%{user_name} removed SLA policy %{sla_name}'
|
||||
linear:
|
||||
issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
|
||||
issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
|
||||
issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
|
||||
csat:
|
||||
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
|
||||
muted: '%{user_name} has muted the conversation'
|
||||
|
||||
@@ -91,6 +91,8 @@ id:
|
||||
conversations_count: Jumlah percakapan
|
||||
avg_first_response_time: Avg first response time
|
||||
avg_resolution_time: Avg resolution time
|
||||
avg_reply_time: Avg reply time
|
||||
resolution_count: Jumlah Terselesaikan
|
||||
team_csv:
|
||||
team_name: Nama Tim
|
||||
conversations_count: Jumlah percakapan
|
||||
@@ -138,6 +140,8 @@ id:
|
||||
instagram_story_content: '%{story_sender} menyebutmu dalam story: '
|
||||
instagram_deleted_story_content: Story ini tidak lagi tersedia.
|
||||
deleted: Pesan ini telah terhapus
|
||||
whatsapp:
|
||||
list_button_label: 'Choose an item'
|
||||
delivery_status:
|
||||
error_code: 'Error code: %{error_code}'
|
||||
activity:
|
||||
@@ -172,6 +176,10 @@ id:
|
||||
sla:
|
||||
added: '%{user_name} added SLA policy %{sla_name}'
|
||||
removed: '%{user_name} removed SLA policy %{sla_name}'
|
||||
linear:
|
||||
issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
|
||||
issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
|
||||
issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
|
||||
csat:
|
||||
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
|
||||
muted: '%{user_name} me-mute percakapan'
|
||||
|
||||
@@ -91,6 +91,8 @@ is:
|
||||
conversations_count: No. of conversations
|
||||
avg_first_response_time: Avg first response time
|
||||
avg_resolution_time: Avg resolution time
|
||||
avg_reply_time: Avg reply time
|
||||
resolution_count: Resolution Count
|
||||
team_csv:
|
||||
team_name: Team name
|
||||
conversations_count: Conversations count
|
||||
@@ -138,6 +140,8 @@ is:
|
||||
instagram_story_content: '%{story_sender} minntist á þig í sögunni: '
|
||||
instagram_deleted_story_content: This story is no longer available.
|
||||
deleted: This message was deleted
|
||||
whatsapp:
|
||||
list_button_label: 'Choose an item'
|
||||
delivery_status:
|
||||
error_code: 'Error code: %{error_code}'
|
||||
activity:
|
||||
@@ -172,6 +176,10 @@ is:
|
||||
sla:
|
||||
added: '%{user_name} added SLA policy %{sla_name}'
|
||||
removed: '%{user_name} removed SLA policy %{sla_name}'
|
||||
linear:
|
||||
issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
|
||||
issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
|
||||
issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
|
||||
csat:
|
||||
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
|
||||
muted: '%{user_name} has muted the conversation'
|
||||
|
||||
@@ -91,6 +91,8 @@ it:
|
||||
conversations_count: Numero di conversazioni
|
||||
avg_first_response_time: Avg first response time
|
||||
avg_resolution_time: Avg resolution time
|
||||
avg_reply_time: Avg reply time
|
||||
resolution_count: Conteggio risoluzioni
|
||||
team_csv:
|
||||
team_name: Nome del team
|
||||
conversations_count: Numero di conversazioni
|
||||
@@ -138,6 +140,8 @@ it:
|
||||
instagram_story_content: '%{story_sender} ti ha menzionato nella storia: '
|
||||
instagram_deleted_story_content: Questa storia non è più disponibile.
|
||||
deleted: Questo messaggio è stato eliminato
|
||||
whatsapp:
|
||||
list_button_label: 'Choose an item'
|
||||
delivery_status:
|
||||
error_code: 'Error code: %{error_code}'
|
||||
activity:
|
||||
@@ -172,6 +176,10 @@ it:
|
||||
sla:
|
||||
added: '%{user_name} added SLA policy %{sla_name}'
|
||||
removed: '%{user_name} removed SLA policy %{sla_name}'
|
||||
linear:
|
||||
issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
|
||||
issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
|
||||
issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
|
||||
csat:
|
||||
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
|
||||
muted: '%{user_name} ha silenziato la conversazione'
|
||||
|
||||
@@ -91,6 +91,8 @@ ja:
|
||||
conversations_count: 会話数
|
||||
avg_first_response_time: 初回応答の平均時間
|
||||
avg_resolution_time: 解決までの平均時間
|
||||
avg_reply_time: Avg reply time
|
||||
resolution_count: 処理件数
|
||||
team_csv:
|
||||
team_name: チーム名
|
||||
conversations_count: 会話回数
|
||||
@@ -138,6 +140,8 @@ ja:
|
||||
instagram_story_content: '%{story_sender} さんがストーリーであなたについて言及しました: '
|
||||
instagram_deleted_story_content: このストーリーはもう利用できません。
|
||||
deleted: このメッセージは削除されました
|
||||
whatsapp:
|
||||
list_button_label: 'Choose an item'
|
||||
delivery_status:
|
||||
error_code: 'エラーコード: %{error_code}'
|
||||
activity:
|
||||
@@ -172,6 +176,10 @@ ja:
|
||||
sla:
|
||||
added: '%{user_name} がSLAポリシー "%{sla_name}" を追加しました'
|
||||
removed: '%{user_name} がSLAポリシー "%{sla_name}" を削除しました'
|
||||
linear:
|
||||
issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
|
||||
issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
|
||||
issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
|
||||
csat:
|
||||
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
|
||||
muted: '%{user_name} が会話をミュートしました'
|
||||
|
||||
@@ -91,6 +91,8 @@ ka:
|
||||
conversations_count: No. of conversations
|
||||
avg_first_response_time: Avg first response time
|
||||
avg_resolution_time: Avg resolution time
|
||||
avg_reply_time: Avg reply time
|
||||
resolution_count: Resolution Count
|
||||
team_csv:
|
||||
team_name: Team name
|
||||
conversations_count: Conversations count
|
||||
@@ -138,6 +140,8 @@ ka:
|
||||
instagram_story_content: '%{story_sender} mentioned you in the story: '
|
||||
instagram_deleted_story_content: This story is no longer available.
|
||||
deleted: This message was deleted
|
||||
whatsapp:
|
||||
list_button_label: 'Choose an item'
|
||||
delivery_status:
|
||||
error_code: 'Error code: %{error_code}'
|
||||
activity:
|
||||
@@ -172,6 +176,10 @@ ka:
|
||||
sla:
|
||||
added: '%{user_name} added SLA policy %{sla_name}'
|
||||
removed: '%{user_name} removed SLA policy %{sla_name}'
|
||||
linear:
|
||||
issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
|
||||
issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
|
||||
issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
|
||||
csat:
|
||||
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
|
||||
muted: '%{user_name} has muted the conversation'
|
||||
|
||||
@@ -91,6 +91,8 @@ ko:
|
||||
conversations_count: No. of conversations
|
||||
avg_first_response_time: Avg first response time
|
||||
avg_resolution_time: Avg resolution time
|
||||
avg_reply_time: Avg reply time
|
||||
resolution_count: 해결 수
|
||||
team_csv:
|
||||
team_name: Team name
|
||||
conversations_count: Conversations count
|
||||
@@ -138,6 +140,8 @@ ko:
|
||||
instagram_story_content: '%{story_sender} mentioned you in the story: '
|
||||
instagram_deleted_story_content: This story is no longer available.
|
||||
deleted: This message was deleted
|
||||
whatsapp:
|
||||
list_button_label: 'Choose an item'
|
||||
delivery_status:
|
||||
error_code: 'Error code: %{error_code}'
|
||||
activity:
|
||||
@@ -172,6 +176,10 @@ ko:
|
||||
sla:
|
||||
added: '%{user_name} added SLA policy %{sla_name}'
|
||||
removed: '%{user_name} removed SLA policy %{sla_name}'
|
||||
linear:
|
||||
issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
|
||||
issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
|
||||
issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
|
||||
csat:
|
||||
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
|
||||
muted: '%{user_name} has muted the conversation'
|
||||
|
||||
@@ -91,6 +91,8 @@ lt:
|
||||
conversations_count: Pokalbių kiekis
|
||||
avg_first_response_time: Avg first response time
|
||||
avg_resolution_time: Avg resolution time
|
||||
avg_reply_time: Avg reply time
|
||||
resolution_count: Sprendimų skaičius
|
||||
team_csv:
|
||||
team_name: Komandos pavadinimas
|
||||
conversations_count: Pokalbių skaičius
|
||||
@@ -138,6 +140,8 @@ lt:
|
||||
instagram_story_content: '%{story_sender} paminėjo jus pasakojime: '
|
||||
instagram_deleted_story_content: Šis pasakojimas nebepasiekiamas.
|
||||
deleted: Šis pranešimas buvo ištrintas
|
||||
whatsapp:
|
||||
list_button_label: 'Choose an item'
|
||||
delivery_status:
|
||||
error_code: 'Klaidos kodas: %{error_code}'
|
||||
activity:
|
||||
@@ -172,6 +176,10 @@ lt:
|
||||
sla:
|
||||
added: '%{user_name} added SLA policy %{sla_name}'
|
||||
removed: '%{user_name} removed SLA policy %{sla_name}'
|
||||
linear:
|
||||
issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
|
||||
issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
|
||||
issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
|
||||
csat:
|
||||
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
|
||||
muted: '%{user_name} nutildė pokalbį'
|
||||
|
||||
@@ -91,6 +91,8 @@ lv:
|
||||
conversations_count: Sarunu skaits
|
||||
avg_first_response_time: Vidējais pirmās reakcijas laiks
|
||||
avg_resolution_time: Vidējais atrisināšanas laiks
|
||||
avg_reply_time: Avg reply time
|
||||
resolution_count: Atrisināšanas Skaits
|
||||
team_csv:
|
||||
team_name: Komandas nosaukums
|
||||
conversations_count: Sarunu skaits
|
||||
@@ -138,6 +140,8 @@ lv:
|
||||
instagram_story_content: '%{story_sender} pieminēja jūs stāstā: '
|
||||
instagram_deleted_story_content: Šis stāsts vairs nav pieejams.
|
||||
deleted: Šis ziņojums ir izdzēsts
|
||||
whatsapp:
|
||||
list_button_label: 'Choose an item'
|
||||
delivery_status:
|
||||
error_code: 'Kļūdas kods: %{error_code}'
|
||||
activity:
|
||||
@@ -172,6 +176,10 @@ lv:
|
||||
sla:
|
||||
added: '%{user_name} pievienoja SLA politiku %{sla_name}'
|
||||
removed: '%{user_name} noņēma SLA politiku %{sla_name}'
|
||||
linear:
|
||||
issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
|
||||
issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
|
||||
issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
|
||||
csat:
|
||||
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
|
||||
muted: '%{user_name} izslēdza sarunu'
|
||||
|
||||
@@ -91,6 +91,8 @@ ml:
|
||||
conversations_count: No. of conversations
|
||||
avg_first_response_time: Avg first response time
|
||||
avg_resolution_time: Avg resolution time
|
||||
avg_reply_time: Avg reply time
|
||||
resolution_count: മിഴിവ് എണ്ണം
|
||||
team_csv:
|
||||
team_name: ടീമിന്റെ പേര്
|
||||
conversations_count: സംഭാഷണങ്ങളുടെ എണ്ണം
|
||||
@@ -138,6 +140,8 @@ ml:
|
||||
instagram_story_content: '%{story_sender} mentioned you in the story: '
|
||||
instagram_deleted_story_content: This story is no longer available.
|
||||
deleted: ഈ സന്ദേശം ഇല്ലാതാക്കി
|
||||
whatsapp:
|
||||
list_button_label: 'Choose an item'
|
||||
delivery_status:
|
||||
error_code: 'Error code: %{error_code}'
|
||||
activity:
|
||||
@@ -172,6 +176,10 @@ ml:
|
||||
sla:
|
||||
added: '%{user_name} added SLA policy %{sla_name}'
|
||||
removed: '%{user_name} removed SLA policy %{sla_name}'
|
||||
linear:
|
||||
issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
|
||||
issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
|
||||
issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
|
||||
csat:
|
||||
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
|
||||
muted: '%{user_name} has muted the conversation'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user