Merge branch 'develop' into feat/whatsapp-embedded-signup

This commit is contained in:
Muhsin Keloth
2025-06-26 11:08:43 +05:30
committed by GitHub
180 changed files with 2750 additions and 465 deletions
+8 -4
View File
@@ -172,6 +172,8 @@ GEM
bundler (>= 1.2.0, < 3)
thor (~> 1.0)
byebug (11.1.3)
childprocess (5.1.0)
logger (~> 1.5)
climate_control (1.2.0)
coderay (1.1.3)
commonmarker (0.23.10)
@@ -433,10 +435,12 @@ GEM
json (>= 1.8)
rexml
language_server-protocol (3.17.0.5)
launchy (2.5.2)
launchy (3.1.1)
addressable (~> 2.8)
letter_opener (1.8.1)
launchy (>= 2.2, < 3)
childprocess (~> 5.0)
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
line-bot-api (1.28.0)
lint_roller (1.1.0)
liquid (5.4.0)
@@ -563,7 +567,7 @@ GEM
method_source (~> 1.0)
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (6.0.0)
public_suffix (6.0.2)
puma (6.4.3)
nio4r (~> 2.0)
pundit (2.3.0)
+12 -1
View File
@@ -10,7 +10,8 @@ function toggleSecretField(e) {
if (!textElement) return;
if (textElement.dataset.secretMasked === 'false') {
textElement.textContent = '•'.repeat(10);
const maskedLength = secretField.dataset.secretText?.length || 10;
textElement.textContent = '•'.repeat(maskedLength);
textElement.dataset.secretMasked = 'true';
toggler.querySelector('svg use').setAttribute('xlink:href', '#eye-show');
@@ -32,3 +33,13 @@ function copySecretField(e) {
navigator.clipboard.writeText(secretField.dataset.secretText);
}
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.cell-data__secret-field').forEach(field => {
const span = field.querySelector('[data-secret-masked]');
if (span && span.dataset.secretMasked === 'true') {
const len = field.dataset.secretText?.length || 10;
span.textContent = '•'.repeat(len);
}
});
});
@@ -46,17 +46,25 @@
.cell-data__secret-field {
align-items: center;
color: $hint-grey;
display: flex;
span {
flex: 1;
flex: 0 0 auto;
}
button {
margin-left: 5px;
[data-secret-toggler],
[data-secret-copier] {
background: transparent;
border: 0;
color: inherit;
margin-left: 0.5rem;
padding: 0;
svg {
fill: currentColor;
height: 1.25rem;
width: 1.25rem;
}
}
}
@@ -31,7 +31,7 @@ class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder
resolved_counts: fetch_resolved_counts(conversation_filter),
resolution_metrics: fetch_metrics(conversation_filter, 'conversation_resolved', use_business_hours),
first_response_metrics: fetch_metrics(conversation_filter, 'first_response', use_business_hours),
reply_metrics: fetch_metrics(conversation_filter, 'reply', use_business_hours)
reply_metrics: fetch_metrics(conversation_filter, 'reply_time', use_business_hours)
}
end
@@ -63,7 +63,9 @@ class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder
end
def fetch_resolved_counts(conversation_filter)
fetch_counts(conversation_filter.merge(status: :resolved))
# since the base query is ActsAsTaggableOn,
# the status :resolved won't automatically be converted to integer status
fetch_counts(conversation_filter.merge(status: Conversation.statuses[:resolved]))
end
def fetch_counts(conversation_filter)
@@ -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
@@ -1,5 +1,5 @@
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
before_action :fetch_conversation, only: [:link_issue, :linked_issues]
before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues]
before_action :fetch_hook, only: [:destroy]
def destroy
@@ -31,6 +31,12 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else
Linear::ActivityMessageService.new(
conversation: @conversation,
action_type: :issue_created,
issue_data: { id: issue[:data][:identifier] },
user: Current.user
).perform
render json: issue[:data], status: :ok
end
end
@@ -42,17 +48,30 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else
Linear::ActivityMessageService.new(
conversation: @conversation,
action_type: :issue_linked,
issue_data: { id: issue_id },
user: Current.user
).perform
render json: issue[:data], status: :ok
end
end
def unlink_issue
link_id = permitted_params[:link_id]
issue_id = permitted_params[:issue_id]
issue = linear_processor_service.unlink_issue(link_id)
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else
Linear::ActivityMessageService.new(
conversation: @conversation,
action_type: :issue_unlinked,
issue_data: { id: issue_id },
user: Current.user
).perform
render json: issue[:data], status: :ok
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,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
+2 -2
View File
@@ -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
+8 -8
View File
@@ -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
@@ -33,9 +33,11 @@ class LinearAPI extends ApiClient {
);
}
unlinkIssue(linkId) {
unlinkIssue(linkId, issueIdentifier, conversationId) {
return axios.post(`${this.url}/unlink_issue`, {
link_id: linkId,
issue_id: issueIdentifier,
conversation_id: conversationId,
});
}
@@ -91,6 +91,19 @@ describe('#linearAPI', () => {
issueData
);
});
it('creates a valid request with conversation_id', () => {
const issueData = {
title: 'New Issue',
description: 'Issue description',
conversation_id: 123,
};
LinearAPIClient.createIssue(issueData);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/create_issue',
issueData
);
});
});
describe('link_issue', () => {
@@ -120,6 +133,18 @@ describe('#linearAPI', () => {
}
);
});
it('creates a valid request with title', () => {
LinearAPIClient.link_issue(1, 'ENG-123', 'Sample Issue');
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/link_issue',
{
issue_id: 'ENG-123',
conversation_id: 1,
title: 'Sample Issue',
}
);
});
});
describe('getLinkedIssue', () => {
@@ -164,12 +189,26 @@ describe('#linearAPI', () => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.unlinkIssue(1);
it('creates a valid request with link_id only', () => {
LinearAPIClient.unlinkIssue('link123');
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/unlink_issue',
{
link_id: 1,
link_id: 'link123',
issue_id: undefined,
conversation_id: undefined,
}
);
});
it('creates a valid request with all parameters', () => {
LinearAPIClient.unlinkIssue('link123', 'ENG-456', 789);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/unlink_issue',
{
link_id: 'link123',
issue_id: 'ENG-456',
conversation_id: 789,
}
);
});
@@ -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">
@@ -19,6 +19,7 @@ const isConversationRoute = computed(() => {
'conversation_through_mentions',
'conversation_through_unattended',
'conversation_through_participating',
'inbox_view_conversation',
];
return CONVERSATION_ROUTES.includes(route.name);
});
@@ -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;
}
@@ -14,7 +14,8 @@ const fromEmail = computed(() => {
});
const toEmail = computed(() => {
return contentAttributes.value?.email?.to ?? [];
const { toEmails, email } = contentAttributes.value;
return email?.to ?? toEmails ?? [];
});
const ccEmail = computed(() => {
@@ -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);
});
@@ -854,7 +855,7 @@ watch(conversationFilters, (newVal, oldVal) => {
:active-status="activeStatus"
:is-on-expanded-layout="isOnExpandedLayout"
:conversation-stats="conversationStats"
:is-list-loading="chatListLoading"
:is-list-loading="chatListLoading && !conversationList.length"
@add-folders="onClickOpenAddFoldersModal"
@delete-folders="onClickOpenDeleteFoldersModal"
@filters-modal="onToggleAdvanceFiltersModal"
@@ -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);
},
},
@@ -183,13 +183,18 @@ const createIssue = async () => {
state_id: formState.stateId || undefined,
priority: formState.priority || undefined,
label_ids: formState.labelId ? [formState.labelId] : undefined,
conversation_id: props.conversationId,
};
try {
isCreating.value = true;
const response = await LinearAPI.createIssue(payload);
const { id: issueId } = response.data;
await LinearAPI.link_issue(props.conversationId, issueId, props.title);
const { identifier: issueIdentifier } = response.data;
await LinearAPI.link_issue(
props.conversationId,
issueIdentifier,
props.title
);
useAlert(t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_SUCCESS'));
useTrack(LINEAR_EVENTS.CREATE_ISSUE);
onClose();
@@ -46,9 +46,9 @@ const loadLinkedIssues = async () => {
}
};
const unlinkIssue = async linkId => {
const unlinkIssue = async (linkId, issueIdentifier) => {
try {
await LinearAPI.unlinkIssue(linkId);
await LinearAPI.unlinkIssue(linkId, issueIdentifier, props.conversationId);
useTrack(LINEAR_EVENTS.UNLINK_ISSUE);
linkedIssues.value = linkedIssues.value.filter(
issue => issue.id !== linkId
@@ -110,7 +110,7 @@ onMounted(() => {
<LinearIssueItem
v-for="linkedIssue in linkedIssues"
:key="linkedIssue.id"
class="pt-3 px-4 pb-4 border-b border-n-weak last:border-b-0"
class="px-4 pt-3 pb-4 border-b border-n-weak last:border-b-0"
:linked-issue="linkedIssue"
@unlink-issue="unlinkIssue"
/>
@@ -14,6 +14,8 @@ const props = defineProps({
const emit = defineEmits(['unlinkIssue']);
const { linkedIssue } = props;
const priorityMap = {
1: 'Urgent',
2: 'High',
@@ -21,7 +23,7 @@ const priorityMap = {
4: 'Low',
};
const issue = computed(() => props.linkedIssue.issue);
const issue = computed(() => linkedIssue.issue);
const assignee = computed(() => {
const assigneeDetails = issue.value.assignee;
@@ -37,7 +39,7 @@ const labels = computed(() => issue.value.labels?.nodes || []);
const priorityLabel = computed(() => priorityMap[issue.value.priority]);
const unlinkIssue = () => {
emit('unlinkIssue', props.linkedIssue.id);
emit('unlinkIssue', linkedIssue.id, linkedIssue.issue.identifier);
};
</script>
@@ -63,7 +63,7 @@ const onSearch = async value => {
isFetching.value = true;
const response = await LinearAPI.searchIssues(value);
issues.value = response.data.map(issue => ({
id: issue.id,
id: issue.identifier,
name: `${issue.identifier} ${issue.title}`,
icon: 'status',
iconColor: issue.state.color,
@@ -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
View File
@@ -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';
}
@@ -297,6 +297,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.",
@@ -818,7 +858,8 @@
"TELEGRAM": "Telegram",
"LINE": "Line",
"API": "API Channel",
"INSTAGRAM": "Instagram"
"INSTAGRAM": "Instagram",
"VOICE": "Voice"
}
}
}
@@ -537,6 +537,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>
@@ -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>
@@ -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')"
/>
@@ -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')"
/>
@@ -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 = () => {
@@ -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 =>
@@ -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] });
+7 -4
View File
@@ -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>
+1 -1
View File
@@ -35,7 +35,7 @@ class Webhooks::TelegramEventsJob < ApplicationJob
def process_event_params(channel, params)
return unless params[:telegram]
if params.dig(:telegram, :edited_message).present?
if params.dig(:telegram, :edited_message).present? || params.dig(:telegram, :edited_business_message).present?
Telegram::UpdateMessageService.new(inbox: channel.inbox, params: params['telegram'].with_indifferent_access).perform
else
Telegram::IncomingMessageService.new(inbox: channel.inbox, params: params['telegram'].with_indifferent_access).perform
+16 -3
View File
@@ -69,6 +69,10 @@ class Channel::Telegram < ApplicationRecord
message.conversation[:additional_attributes]['chat_id']
end
def business_connection_id(message)
message.conversation[:additional_attributes]['business_connection_id']
end
def reply_to_message_id(message)
message.content_attributes['in_reply_to_external_id']
end
@@ -95,7 +99,13 @@ class Channel::Telegram < ApplicationRecord
end
def send_message(message)
response = message_request(chat_id(message), message.outgoing_content, reply_markup(message), reply_to_message_id(message))
response = message_request(
chat_id(message),
message.outgoing_content,
reply_markup(message),
reply_to_message_id(message),
business_connection_id: business_connection_id(message)
)
process_error(message, response)
response.parsed_response['result']['message_id'] if response.success?
end
@@ -131,9 +141,12 @@ class Channel::Telegram < ApplicationRecord
stripped_html.gsub('&lt;br&gt;', "\n")
end
def message_request(chat_id, text, reply_markup = nil, reply_to_message_id = nil)
def message_request(chat_id, text, reply_markup = nil, reply_to_message_id = nil, business_connection_id: nil)
text_payload = convert_markdown_to_telegram_html(text)
business_body = {}
business_body[:business_connection_id] = business_connection_id if business_connection_id
HTTParty.post("#{telegram_api_url}/sendMessage",
body: {
chat_id: chat_id,
@@ -141,6 +154,6 @@ class Channel::Telegram < ApplicationRecord
reply_markup: reply_markup,
parse_mode: 'HTML',
reply_to_message_id: reply_to_message_id
})
}.merge(business_body))
end
end
@@ -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
@@ -0,0 +1,41 @@
class Linear::ActivityMessageService
attr_reader :conversation, :action_type, :issue_data, :user
def initialize(conversation:, action_type:, user:, issue_data: {})
@conversation = conversation
@action_type = action_type
@issue_data = issue_data
@user = user
end
def perform
return unless conversation && issue_data[:id] && user
content = generate_activity_content
return unless content
::Conversations::ActivityMessageJob.perform_later(conversation, activity_message_params(content))
end
private
def generate_activity_content
case action_type.to_sym
when :issue_created
I18n.t('conversations.activity.linear.issue_created', user_name: user.name, issue_id: issue_data[:id])
when :issue_linked
I18n.t('conversations.activity.linear.issue_linked', user_name: user.name, issue_id: issue_data[:id])
when :issue_unlinked
I18n.t('conversations.activity.linear.issue_unlinked', user_name: user.name, issue_id: issue_data[:id])
end
end
def activity_message_params(content)
{
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: :activity,
content: content
}
end
end
@@ -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,
@@ -8,17 +8,25 @@ class Telegram::IncomingMessageService
def perform
# chatwoot doesn't support group conversations at the moment
transform_business_message!
return unless private_message?
set_contact
update_contact_avatar
set_conversation
# TODO: Since the recent Telegram Business update, we need to explicitly mark messages as read using an additional request.
# Otherwise, the client will see their messages as unread.
# Chatwoot defines a 'read' status in its enum but does not currently update this status for Telegram conversations.
# We have two options:
# 1. Send the read request to Telegram here, immediately when the message is created.
# 2. Properly update the read status in the Chatwoot UI and trigger the Telegram request when the agent actually reads the message.
# See: https://core.telegram.org/bots/api#readbusinessmessage
@message = @conversation.messages.build(
content: telegram_params_message_content,
account_id: @inbox.account_id,
inbox_id: @inbox.id,
message_type: :incoming,
sender: @contact,
message_type: message_type,
sender: message_sender,
content_attributes: telegram_params_content_attributes,
source_id: telegram_params_message_id.to_s
)
@@ -36,6 +44,11 @@ class Telegram::IncomingMessageService
contact_attributes: contact_attributes
).perform
# TODO: Should we update contact_attributes when the user changes their first or last name?
# In business chats, when our Telegram bot initiates the conversation,
# the message does not include a language code.
# This is critical for AI assistants and translation plugins.
@contact_inbox = contact_inbox
@contact = contact_inbox.contact
end
@@ -89,10 +102,19 @@ class Telegram::IncomingMessageService
def conversation_additional_attributes
{
chat_id: telegram_params_chat_id
chat_id: telegram_params_chat_id,
business_connection_id: telegram_params_business_connection_id
}
end
def message_type
business_message_outgoing? ? :outgoing : :incoming
end
def message_sender
business_message_outgoing? ? nil : @contact
end
def file_content_type
return :image if image_message?
return :audio if audio_message?
@@ -191,4 +213,8 @@ class Telegram::IncomingMessageService
params[:message][:video].presence ||
params[:message][:video_note].presence
end
def transform_business_message!
params[:message] = params[:business_message] if params[:business_message] && !params[:message]
end
end
+33 -4
View File
@@ -13,6 +13,17 @@ module Telegram::ParamHelpers
{}
end
def business_message?
telegram_params_business_connection_id.present?
end
# In business bot mode we will receive messages from our telegram.
# This is our messages posted via telegram client.
# Such messages should be outgoing (from us to client)
def business_message_outgoing?
business_message? && telegram_params_base_object[:chat][:id] != telegram_params_base_object[:from][:id]
end
def message_params?
params[:message].present?
end
@@ -29,24 +40,34 @@ module Telegram::ParamHelpers
end
end
def contact_params
if business_message_outgoing?
telegram_params_base_object[:chat]
else
telegram_params_base_object[:from]
end
end
def telegram_params_from_id
return telegram_params_base_object[:chat][:id] if business_message?
telegram_params_base_object[:from][:id]
end
def telegram_params_first_name
telegram_params_base_object[:from][:first_name]
contact_params[:first_name]
end
def telegram_params_last_name
telegram_params_base_object[:from][:last_name]
contact_params[:last_name]
end
def telegram_params_username
telegram_params_base_object[:from][:username]
contact_params[:username]
end
def telegram_params_language_code
telegram_params_base_object[:from][:language_code]
contact_params[:language_code]
end
def telegram_params_chat_id
@@ -57,6 +78,14 @@ module Telegram::ParamHelpers
end
end
def telegram_params_business_connection_id
if callback_query_params?
params[:callback_query][:message][:business_connection_id]
else
telegram_params_base_object[:business_connection_id]
end
end
def telegram_params_message_content
if callback_query_params?
params[:callback_query][:data]
@@ -71,6 +71,7 @@ class Telegram::SendAttachmentsService
HTTParty.post("#{channel.telegram_api_url}/sendMediaGroup",
body: {
chat_id: chat_id,
**business_connection_body,
media: attachments.map { |hash| hash.except(:attachment) }.to_json,
reply_to_message_id: reply_to_message_id
})
@@ -108,6 +109,7 @@ class Telegram::SendAttachmentsService
HTTParty.post("#{channel.telegram_api_url}/sendDocument",
body: {
chat_id: chat_id,
**business_connection_body,
document: file,
reply_to_message_id: reply_to_message_id
},
@@ -135,4 +137,14 @@ class Telegram::SendAttachmentsService
def channel
@channel ||= message.inbox.channel
end
def business_connection_id
@business_connection_id ||= channel.business_connection_id(message)
end
def business_connection_body
body = {}
body[:business_connection_id] = business_connection_id if business_connection_id
body
end
end
@@ -5,6 +5,7 @@ class Telegram::UpdateMessageService
pattr_initialize [:inbox!, :params!]
def perform
transform_business_message!
find_contact_inbox
find_conversation
find_message
@@ -36,4 +37,8 @@ class Telegram::UpdateMessageService
@message.update!(content: edited_message[:caption])
end
end
def transform_business_message!
params[:edited_message] = params[:edited_business_message] if params[:edited_business_message].present?
end
end
@@ -4,7 +4,9 @@
I18n.t('reports.label_csv.label_title'),
I18n.t('reports.label_csv.conversations_count'),
I18n.t('reports.label_csv.avg_first_response_time'),
I18n.t('reports.label_csv.avg_resolution_time')
I18n.t('reports.label_csv.avg_resolution_time'),
I18n.t('reports.label_csv.avg_reply_time'),
I18n.t('reports.label_csv.resolution_count'),
]
%>
<%= CSVSafe.generate_line headers -%>
@@ -3,7 +3,7 @@
%>
<%= javascript_include_tag "secretField" %>
<div data-secret-text="<%= field.data %>" class="cell-data__secret-field">
<span data-secret-masked="true">••••••••••</span>
<span data-secret-masked="true"></span>
<button onclick="toggleSecretField(event)" data-secret-toggler>
<svg width="20" height="20">
<use xlink:href="#eye-show" />
+1 -1
View File
@@ -4,7 +4,7 @@
<%= javascript_include_tag "secretField" %>
<div data-secret-text="<%= field.data %>" class="cell-data__secret-field">
<span data-secret-masked="true">••••••••••</span>
<span data-secret-masked="true"></span>
<button onclick="toggleSecretField(event)" data-secret-toggler>
<svg width="20" height="20">
<use xlink:href="#eye-show" />
@@ -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>
@@ -1,18 +1,18 @@
<header class="sticky top-0 z-50 w-full bg-white shadow-sm dark:bg-slate-900">
<nav class="hidden sm:flex max-w-5xl px-4 mx-auto md:px-8" aria-label="Top">
<div class="flex items-center w-full py-5 overflow-hidden">
<a href="<%= generate_home_link(@portal.slug, @portal.config['default_locale'] || params[:locale], @theme_from_params, @is_plain_layout_enabled) %>" class="flex items-center h-10">
<a href="<%= generate_home_link(@portal.slug, @portal.config['default_locale'] || params[:locale], @theme_from_params, @is_plain_layout_enabled) %>" class="flex items-center min-w-0 h-10">
<% if @portal.logo.present? %>
<img src="<%= url_for(@portal.logo) %>" class="w-auto h-10 ltr:mr-2 rtl:ml-2" />
<img src="<%= url_for(@portal.logo) %>" class="w-auto h-10 ltr:mr-2 rtl:ml-2" draggable="false" />
<% end %>
<span class="text-lg font-semibold text-slate-900 dark:text-white <%= @portal.logo.present? ? 'hidden' : 'hidden sm:block' %>"><%= @portal.name %></span>
<span class="text-lg font-semibold text-slate-900 truncate dark:text-white <%= @portal.logo.present? ? 'hidden lg:block' : 'hidden sm:block' %>"><%= @portal.name %></span>
</a>
</div>
<%# Go to homepage link section %>
<div class="flex items-center justify-between gap-2 sm:gap-5">
<% if @portal.homepage_link %>
<div class="hidden px-1 py-2 ltr:ml-8 rtl:mr-8 cursor-pointer md:block">
<div class="px-1 py-2 ltr:ml-8 rtl:mr-8 cursor-pointer block">
<div class="flex-grow flex-shrink-0">
<a id="header-action-button" target="_blank" rel="noopener noreferrer nofollow" href="<%= @portal.homepage_link %>" class="flex flex-row items-center gap-1 text-sm font-medium whitespace-nowrap text-slate-800 dark:text-slate-100 stroke-slate-700 dark:stroke-slate-200">
<%= render partial: 'icons/redirect' %>
@@ -99,11 +99,11 @@
<nav class="flex sm:hidden max-w-5xl px-4 mx-auto" aria-label="Mobile Top">
<div class="flex items-center justify-between w-full py-5">
<a href="<%= generate_home_link(@portal.slug, @portal.config['default_locale'] || params[:locale], @theme_from_params, @is_plain_layout_enabled) %>" class="flex items-center h-10 text-lg font-semibold text-slate-900 dark:text-white">
<a href="<%= generate_home_link(@portal.slug, @portal.config['default_locale'] || params[:locale], @theme_from_params, @is_plain_layout_enabled) %>" class="flex items-center min-w-0 h-10 text-lg font-semibold text-slate-900 dark:text-white">
<% if @portal.logo.present? %>
<img src="<%= url_for(@portal.logo) %>" class="w-auto h-10 ltr:mr-2 rtl:ml-2" />
<img src="<%= url_for(@portal.logo) %>" class="w-auto h-10 ltr:mr-2 rtl:ml-2" draggable="false" />
<% end %>
<span class="text-lg font-semibold text-slate-900 dark:text-white <%= @portal.logo.present? ? 'hidden' : 'sm:hidden block' %>"><%= @portal.name %></span>
<span class="text-lg font-semibold text-slate-900 truncate dark:text-white <%= @portal.logo.present? ? 'hidden' : 'sm:hidden block' %>"><%= @portal.name %></span>
</a>
<!-- Mobile Menu Component -->
@@ -2,7 +2,7 @@
<section id="portal-bg" class="w-full bg-white dark:bg-slate-900 shadow-inner">
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6 min-h-[240px] md:min-h-[260px]">
<div class="mx-auto max-w-5xl px-4 md:px-8 flex flex-col items-start">
<span class="text-sm leading-[24px] font-semibold text-slate-600 dark:text-slate-300 mb-1 <%= @portal.logo.present? ? 'block' : 'hidden' %>"><%= @portal.name %></span>
<span class="text-sm leading-[24px] font-semibold text-slate-600 dark:text-slate-300 mb-1 <%= @portal.logo.present? ? 'block lg:hidden' : 'hidden' %>"><%= @portal.name %></span>
<h1 class="text-2xl md:text-4xl text-slate-900 dark:text-white font-semibold leading-normal">
<%= portal.header_text %>
</h1>
@@ -1,3 +1,4 @@
<% has_multiple_locales = @portal.config["allowed_locales"].length > 1 %>
<input type="checkbox" id="mobile-menu-toggle" class="peer/menu hidden" />
@@ -46,10 +47,12 @@
</div>
</div>
<span class="h-px bg-slate-100/70 dark:bg-slate-800/70 w-full"></span>
<% if has_multiple_locales %>
<span class="h-px bg-slate-100/70 dark:bg-slate-800/70 w-full"></span>
<% end %>
<!-- Locale Switcher -->
<% if @portal.config["allowed_locales"].length > 1 %>
<% if has_multiple_locales %>
<div id="header-action-button" class="flex flex-col gap-2">
<h3 class="text-base font-medium text-slate-700 dark:text-slate-300 my-2">
<%= I18n.t('public_portal.header.language', default: 'Language') %>
+29 -20
View File
@@ -22,27 +22,36 @@
<% end %>
<div class="bg-white py-2 px-4 xl:px-0">
<div class="mb-4">
<div class="flex items-center gap-2">
<h2 class="h-5 leading-5 text-n-slate-12 font-medium">Current plan</h2>
<a href="<%= refresh_super_admin_settings_url %>" class="inline-flex gap-1 text-xs font-medium items-center text-woot-500 hover:text-woot-700">
<svg width="16" height="16"><use xlink:href="#icon-refresh-line" /></svg>
<span>Refresh</span>
</a>
</div>
<p class="text-n-slate-11 mt-1"><%= SuperAdmin::FeaturesHelper.plan_details.html_safe %></p>
<div class="flex items-center mt-6">
<h4 class="text-sm font-medium leading-5 h-5 text-n-slate-12 mr-4">Installation Identifier</h4>
<span class="text-sm leading-5 h-5 text-n-slate-11"><%= ChatwootHub.installation_identifier %></span>
<%= javascript_include_tag "secretField" %>
<div class="flex items-center mb-6">
<h4 class="text-sm font-medium leading-5 h-5 text-n-slate-12 mr-4">Installation Identifier</h4>
<div data-secret-text="<%= ChatwootHub.installation_identifier %>" class="cell-data__secret-field text-sm leading-5 h-5 text-n-slate-11">
<span data-secret-masked="true" class="select-none"></span>
<button type="button" onclick="toggleSecretField(event)" data-secret-toggler class="inline-flex items-center justify-center h-5 w-5 p-0 rounded text-n-slate-11 hover:text-n-slate-12 transition-colors focus:outline-none">
<svg width="20" height="20" class="w-5 h-5 fill-current">
<use xlink:href="#eye-show" />
</svg>
</button>
<button type="button" onclick="copySecretField(event)" data-secret-copier class="inline-flex items-center justify-center h-5 w-5 p-0 rounded text-n-slate-11 hover:text-n-slate-12 transition-colors focus:outline-none">
<svg width="20" height="20" class="w-5 h-5 fill-current">
<use xlink:href="#icon-copy" />
</svg>
</button>
</div>
</div>
<div class="flex p-4 outline outline-1 outline-n-container rounded-lg mt-8 items-start md:items-center shadow-sm flex-col md:flex-row">
<div class="flex p-4 outline outline-1 outline-n-container rounded-lg items-start md:items-center shadow-sm flex-col md:flex-row">
<div class="flex flex-col flex-grow gap-1">
<h2 class="h-5 leading-5 text-n-slate-12 text-sm font-medium">Current plan</h2>
<div class="flex items-center gap-2">
<h2 class="h-5 leading-5 text-n-slate-12 text-sm font-medium">Current plan</h2>
<a href="<%= refresh_super_admin_settings_url %>" class="inline-flex gap-1 text-xs font-medium items-center text-woot-500 hover:text-woot-700">
<svg width="16" height="16"><use xlink:href="#icon-refresh-line" /></svg>
<span>Refresh</span>
</a>
</div>
<p class="text-n-slate-11 m-0 text-sm"><%= SuperAdmin::FeaturesHelper.plan_details.html_safe %></p>
</div>
<a href="<%= ChatwootHub.billing_url %>" target="_blank" rel="noopener noreferrer">
<button class="mt-4 md:mt-0 flex gap-1 items-center bg-transparent shadow-sm h-9 hover:text-n-slate-12 hover:bg-slate-50 outline outline-1 outline-n-container rounded text-n-slate-11 font-medium p-2">
<button class="mt-4 md:mt-0 flex gap-1 items-center bg-transparent shadow-sm h-9 hover:text-n-slate-12 hover:bg-slate-50 outline outline-1 outline-n-container rounded text-n-slate-11 font-medium p-2 focus:outline-none">
<svg width="16" height="16"><use xlink:href="#icon-settings-2-line" /></svg>
<span class="px-1">Manage</span>
</button>
@@ -64,14 +73,14 @@
<p class="text-n-slate-11 m-0 text-sm">Do you face any issues? We are here to help.</p>
</div>
<a href="https://discord.gg/cJXdrwS" target="_blank">
<button class="flex mt-4 md:mt-0 gap-1 items-center bg-transparent shadow-sm h-9 bg-violet-500 hover:bg-violet-600 text-white border border-solid border-violet-600 rounded font-medium p-2">
<svg class="h-4 w-4" width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10a9.96 9.96 0 0 1-4.587-1.112l-3.826 1.067a1.25 1.25 0 0 1-1.54-1.54l1.068-3.823A9.96 9.96 0 0 1 2 12C2 6.477 6.477 2 12 2Zm0 1.5A8.5 8.5 0 0 0 3.5 12c0 1.47.373 2.883 1.073 4.137l.15.27-1.112 3.984 3.987-1.112.27.15A8.5 8.5 0 1 0 12 3.5ZM8.75 13h4.498a.75.75 0 0 1 .102 1.493l-.102.007H8.75a.75.75 0 0 1-.102-1.493L8.75 13h4.498H8.75Zm0-3.5h6.505a.75.75 0 0 1 .101 1.493l-.101.007H8.75a.75.75 0 0 1-.102-1.493L8.75 9.5h6.505H8.75Z" fill="currentColor"/></svg>
<button class="flex mt-4 md:mt-0 gap-1 items-center bg-transparent shadow-sm h-9 bg-violet-500 hover:bg-violet-600 text-white border border-solid border-violet-600 rounded font-medium p-2 focus:outline-none">
<svg class="h-4 w-4" width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M13.209 3.103c-.495-1.004-1.926-1.004-2.421 0L8.43 7.88l-5.273.766c-1.107.161-1.55 1.522-.748 2.303l3.815 3.72-.9 5.25c-.19 1.103.968 1.944 1.959 1.424l4.715-2.48 4.716 2.48c.99.52 2.148-.32 1.96-1.424l-.902-5.25 3.816-3.72c.8-.78.359-2.142-.748-2.303l-5.273-.766-2.358-4.777ZM9.74 8.615l2.258-4.576 2.259 4.576a1.35 1.35 0 0 0 1.016.738l5.05.734-3.654 3.562a1.35 1.35 0 0 0-.388 1.195l.862 5.03-4.516-2.375a1.35 1.35 0 0 0-1.257 0l-4.516 2.374.862-5.029a1.35 1.35 0 0 0-.388-1.195l-3.654-3.562 5.05-.734c.44-.063.82-.34 1.016-.738ZM1.164 3.782a.75.75 0 0 0 .118 1.054l2.5 2a.75.75 0 1 0 .937-1.172l-2.5-2a.75.75 0 0 0-1.055.118Z" fill="currentColor"/><path d="M22.836 18.218a.75.75 0 0 0-.117-1.054l-2.5-2a.75.75 0 0 0-.938 1.172l2.5 2a.75.75 0 0 0 1.055-.117ZM1.282 17.164a.75.75 0 1 0 .937 1.172l2.5-2a.75.75 0 0 0-.937-1.172l-2.5 2ZM22.836 3.782a.75.75 0 0 1-.117 1.054l-2.5 2a.75.75 0 0 1-.938-1.172l2.5-2a.75.75 0 0 1 1.055.118Z" fill="currentColor"/></svg>
<span class="px-1">Community Support</span>
</button>
</a>
<% if ChatwootHub.pricing_plan !='community' %>
<button class="ml-4 flex gap-1 items-center bg-transparent h-9 hover:text-n-slate-12 hover:bg-slate-50 border border-solid border-slate-100 rounded text-n-slate-11 font-medium p-2" onclick="window.$chatwoot.toggle('open')">
<svg class="h-4 w-4" width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10a9.96 9.96 0 0 1-4.587-1.112l-3.826 1.067a1.25 1.25 0 0 1-1.54-1.54l1.068-3.823A9.96 9.96 0 0 1 2 12C2 6.477 6.477 2 12 2Zm0 1.5A8.5 8.5 0 0 0 3.5 12c0 1.47.373 2.883 1.073 4.137l.15.27-1.112 3.984 3.987-1.112.27.15A8.5 8.5 0 1 0 12 3.5ZM8.75 13h4.498a.75.75 0 0 1 .102 1.493l-.102.007H8.75a.75.75 0 0 1-.102-1.493L8.75 13h4.498H8.75Zm0-3.5h6.505a.75.75 0 0 1 .101 1.493l-.101.007H8.75a.75.75 0 0 1-.102-1.493L8.75 9.5h6.505H8.75Z" fill="currentColor"/></svg>
<button class="ml-4 flex gap-1 items-center bg-transparent h-9 hover:text-n-slate-12 hover:bg-slate-50 border border-solid border-slate-100 rounded text-n-slate-11 font-medium p-2 focus:outline-none" onclick="window.$chatwoot.toggle('open')">
<svg class="h-4 w-4" width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M13.209 3.103c-.495-1.004-1.926-1.004-2.421 0L8.43 7.88l-5.273.766c-1.107.161-1.55 1.522-.748 2.303l3.815 3.72-.9 5.25c-.19 1.103.968 1.944 1.959 1.424l4.715-2.48 4.716 2.48c.99.52 2.148-.32 1.96-1.424l-.902-5.25 3.816-3.72c.8-.78.359-2.142-.748-2.303l-5.273-.766-2.358-4.777ZM9.74 8.615l2.258-4.576 2.259 4.576a1.35 1.35 0 0 0 1.016.738l5.05.734-3.654 3.562a1.35 1.35 0 0 0-.388 1.195l.862 5.03-4.516-2.375a1.35 1.35 0 0 0-1.257 0l-4.516 2.374.862-5.029a1.35 1.35 0 0 0-.388-1.195l-3.654-3.562 5.05-.734c.44-.063.82-.34 1.016-.738ZM1.164 3.782a.75.75 0 0 0 .118 1.054l2.5 2a.75.75 0 1 0 .937-1.172l-2.5-2a.75.75 0 0 0-1.055.118Z" fill="currentColor"/><path d="M22.836 18.218a.75.75 0 0 0-.117-1.054l-2.5-2a.75.75 0 0 0-.938 1.172l2.5 2a.75.75 0 0 0 1.055-.117ZM1.282 17.164a.75.75 0 1 0 .937 1.172l2.5-2a.75.75 0 0 0-.937-1.172l-2.5 2ZM22.836 3.782a.75.75 0 0 1-.117 1.054l-2.5 2a.75.75 0 0 1-.938-1.172l2.5-2a.75.75 0 0 1 1.055.118Z" fill="currentColor"/></svg>
<span class="px-1">Chat Support</span>
</button>
<% end %>
@@ -90,7 +99,7 @@
</div>
<% if !attrs[:enabled] %>
<div class="flex h-9 absolute top-5 items-center invisible group-hover:visible">
<a href="<%= ChatwootHub.billing_url %>" target="_blank" rel="noopener noreferrer" class="flex gap-1 items-center bg-slate-100 h-9 hover:bg-slate-300 hover:text-n-slate-12 border border-solid border-slate-100 rounded text-n-slate-11 font-medium p-2">
<a href="<%= ChatwootHub.billing_url %>" target="_blank" rel="noopener noreferrer" class="flex gap-1 items-center bg-slate-100 h-9 hover:bg-slate-300 hover:text-n-slate-12 border border-solid border-slate-100 rounded text-n-slate-11 font-medium p-2 focus:outline-none">
<svg class="h-4 w-4" width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M13.209 3.103c-.495-1.004-1.926-1.004-2.421 0L8.43 7.88l-5.273.766c-1.107.161-1.55 1.522-.748 2.303l3.815 3.72-.9 5.25c-.19 1.103.968 1.944 1.959 1.424l4.715-2.48 4.716 2.48c.99.52 2.148-.32 1.96-1.424l-.902-5.25 3.816-3.72c.8-.78.359-2.142-.748-2.303l-5.273-.766-2.358-4.777ZM9.74 8.615l2.258-4.576 2.259 4.576a1.35 1.35 0 0 0 1.016.738l5.05.734-3.654 3.562a1.35 1.35 0 0 0-.388 1.195l.862 5.03-4.516-2.375a1.35 1.35 0 0 0-1.257 0l-4.516 2.374.862-5.029a1.35 1.35 0 0 0-.388-1.195l-3.654-3.562 5.05-.734c.44-.063.82-.34 1.016-.738ZM1.164 3.782a.75.75 0 0 0 .118 1.054l2.5 2a.75.75 0 1 0 .937-1.172l-2.5-2a.75.75 0 0 0-1.055.118Z" fill="currentColor"/><path d="M22.836 18.218a.75.75 0 0 0-.117-1.054l-2.5-2a.75.75 0 0 0-.938 1.172l2.5 2a.75.75 0 0 0 1.055-.117ZM1.282 17.164a.75.75 0 1 0 .937 1.172l2.5-2a.75.75 0 0 0-.937-1.172l-2.5 2ZM22.836 3.782a.75.75 0 0 1-.117 1.054l-2.5 2a.75.75 0 0 1-.938-1.172l2.5-2a.75.75 0 0 1 1.055.118Z" fill="currentColor"/></svg>
<span class="px-1">Upgrade now</span>
</a>
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '4.2.0'
version: '4.3.0'
development:
<<: *shared
+5 -1
View File
@@ -168,4 +168,8 @@
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
+8
View File
@@ -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'
+8
View File
@@ -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} كتم صوت المحادثة'
+8
View File
@@ -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'
+8
View File
@@ -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'
+8
View File
@@ -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'
+8
View File
@@ -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'
+8
View File
@@ -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'
+8
View File
@@ -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'
+8
View File
@@ -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} σίγασε την συνομιλία'
+8
View File
@@ -105,6 +105,8 @@ en:
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
@@ -188,8 +190,14 @@ en:
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'
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.'
+8
View File
@@ -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'
+8
View File
@@ -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} مکالمه را بی صدا کرد'
+8
View File
@@ -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'
+8
View File
@@ -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'
+8
View File
@@ -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'
+8
View File
@@ -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'
+8
View File
@@ -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'
+8
View File
@@ -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'

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