Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f04c7c36e | ||
|
|
8218880424 | ||
|
|
dec5468cd5 | ||
|
|
d4dd5c64a7 | ||
|
|
4b2abcfbb2 | ||
|
|
261deaaeec | ||
|
|
59d869d1ed | ||
|
|
8a3b129292 | ||
|
|
af1dfc21f6 | ||
|
|
d1c482cb64 | ||
|
|
d8656edc61 | ||
|
|
e919a2cef5 | ||
|
|
7718f2a62c | ||
|
|
45f4b423ae | ||
|
|
78a6b2457d | ||
|
|
73ba0b26e5 | ||
|
|
f9385a31fc | ||
|
|
9d808a18df | ||
|
|
ec43975f3f | ||
|
|
64c5aeebee | ||
|
|
cd9192f7d1 | ||
|
|
eaffad12e7 | ||
|
|
18ef019cd4 | ||
|
|
ea910227ac | ||
|
|
94ddd98050 | ||
|
|
0e87519ecd | ||
|
|
de89391031 | ||
|
|
8e42307bdc | ||
|
|
1beaa284c6 | ||
|
|
d028cc1984 | ||
|
|
0d59fb4459 | ||
|
|
7acbe8b3ff | ||
|
|
b791d75b30 | ||
|
|
36a05097fa | ||
|
|
0a181b0cea | ||
|
|
87df43bdd0 | ||
|
|
28f87d2fca |
@@ -76,7 +76,7 @@ gem 'faraday_middleware-aws-sigv4'
|
||||
##--- gems for server & infra configuration ---##
|
||||
gem 'dotenv-rails', '>= 3.0.0'
|
||||
gem 'foreman'
|
||||
gem 'puma'
|
||||
gem 'puma', '~> 7.2', '>= 7.2.1'
|
||||
gem 'vite_rails'
|
||||
# metrics on heroku
|
||||
gem 'barnes'
|
||||
|
||||
+3
-3
@@ -593,7 +593,7 @@ GEM
|
||||
sidekiq
|
||||
newrelic_rpm (9.6.0)
|
||||
base64
|
||||
nio4r (2.7.3)
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.19.3)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
@@ -682,7 +682,7 @@ GEM
|
||||
pry-rails (0.3.9)
|
||||
pry (>= 0.10.4)
|
||||
public_suffix (7.0.5)
|
||||
puma (6.4.3)
|
||||
puma (7.2.1)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.3.0)
|
||||
activesupport (>= 3.0.0)
|
||||
@@ -1132,7 +1132,7 @@ DEPENDENCIES
|
||||
pgvector
|
||||
procore-sift
|
||||
pry-rails
|
||||
puma
|
||||
puma (~> 7.2, >= 7.2.1)
|
||||
pundit
|
||||
rack-attack (>= 6.7.0)
|
||||
rack-cors (= 2.0.0)
|
||||
|
||||
@@ -7,6 +7,7 @@ class RoomChannel < ApplicationCable::Channel
|
||||
ensure_stream
|
||||
update_subscription
|
||||
broadcast_presence
|
||||
transmit_cache_keys
|
||||
end
|
||||
|
||||
def update_presence
|
||||
@@ -24,6 +25,19 @@ class RoomChannel < ApplicationCable::Channel
|
||||
ActionCable.server.broadcast(pubsub_token, { event: 'presence.update', data: data })
|
||||
end
|
||||
|
||||
# Push the authoritative cache-key map to this subscriber on every
|
||||
# (re)subscribe. Boot and reconnect cache freshness ride the same
|
||||
# account.cache_invalidated event the dashboard already handles for live
|
||||
# invalidations — the client never pulls /cache_keys itself.
|
||||
def transmit_cache_keys
|
||||
return if @current_account.blank? || !@current_user.is_a?(User)
|
||||
|
||||
transmit({
|
||||
event: Events::Types::ACCOUNT_CACHE_INVALIDATED,
|
||||
data: { account_id: @current_account.id, cache_keys: @current_account.cache_keys }
|
||||
})
|
||||
end
|
||||
|
||||
def ensure_stream
|
||||
stream_from pubsub_token
|
||||
stream_from "account_#{@current_account.id}" if @current_account.present? && @current_user.is_a?(User)
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
class Api::V1::Accounts::Conversations::UnreadCountsController < Api::V1::Accounts::BaseController
|
||||
before_action :ensure_unread_counts_enabled
|
||||
|
||||
def index
|
||||
counts = ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
|
||||
render json: { payload: counts }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_unread_counts_enabled
|
||||
return if Current.account.feature_enabled?('conversation_unread_counts')
|
||||
|
||||
render json: { error: I18n.t('errors.conversations.unread_counts.feature_not_enabled') }, status: :forbidden
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,8 +6,7 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
|
||||
{
|
||||
redirect_uri: "#{base_url}/microsoft/callback",
|
||||
scope: scope,
|
||||
state: state,
|
||||
prompt: 'consent'
|
||||
state: state
|
||||
}
|
||||
)
|
||||
if redirect_url
|
||||
|
||||
@@ -80,10 +80,15 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
:id, :color, :custom_domain, :header_text, :homepage_link,
|
||||
:name, :page_title, :slug, :archived,
|
||||
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
|
||||
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] }] }
|
||||
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] },
|
||||
{ locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } }] }
|
||||
)
|
||||
end
|
||||
|
||||
def locale_translation_keys
|
||||
params.dig(:portal, :config, :locale_translations)&.keys || []
|
||||
end
|
||||
|
||||
def live_chat_widget_params
|
||||
permitted_params = params.permit(:inbox_id)
|
||||
return {} unless permitted_params.key?(:inbox_id)
|
||||
|
||||
@@ -50,7 +50,6 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
end
|
||||
|
||||
def cache_keys
|
||||
expires_in 10.seconds, public: false, stale_while_revalidate: 5.minutes
|
||||
render json: { cache_keys: cache_keys_for_account }, status: :ok
|
||||
end
|
||||
|
||||
@@ -93,11 +92,7 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
end
|
||||
|
||||
def cache_keys_for_account
|
||||
{
|
||||
label: fetch_value_for_key(params[:id], Label.name.underscore),
|
||||
inbox: fetch_value_for_key(params[:id], Inbox.name.underscore),
|
||||
team: fetch_value_for_key(params[:id], Team.name.underscore)
|
||||
}
|
||||
@account.cache_keys
|
||||
end
|
||||
|
||||
def fetch_account
|
||||
|
||||
@@ -13,11 +13,17 @@ class Api::V1::ProfilesController < Api::BaseController
|
||||
@user.assign_attributes(profile_params)
|
||||
@user.custom_attributes.merge!(custom_attributes_params)
|
||||
@user.save!
|
||||
|
||||
# Profile updates can change cached agent fields, including avatar-backed thumbnails.
|
||||
@user.invalidate_avatar_cache
|
||||
end
|
||||
|
||||
def avatar
|
||||
@user.avatar.attachment.destroy! if @user.avatar.attached?
|
||||
@user.reload
|
||||
|
||||
# Agent thumbnails are cached separately, and avatar attachment deletes do not dirty user columns.
|
||||
@user.invalidate_avatar_cache
|
||||
end
|
||||
|
||||
def auto_offline
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
module AccessTokenAuthHelper
|
||||
BOT_ACCESSIBLE_ENDPOINTS = {
|
||||
'api/v1/accounts/conversations' => %w[toggle_status toggle_typing_status toggle_priority create update custom_attributes],
|
||||
'api/v1/accounts/conversations' => %w[show toggle_status toggle_typing_status toggle_priority create update custom_attributes],
|
||||
'api/v1/accounts/conversations/messages' => ['create'],
|
||||
'api/v1/accounts/conversations/assignments' => ['create']
|
||||
'api/v1/accounts/conversations/assignments' => ['create'],
|
||||
'api/v1/accounts/conversations/labels' => %w[index create]
|
||||
}.freeze
|
||||
|
||||
def ensure_access_token
|
||||
|
||||
@@ -9,7 +9,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
|
||||
layout 'portal'
|
||||
|
||||
def show
|
||||
@og_image_url = helpers.set_og_image_url('', @portal.header_text)
|
||||
@og_image_url = helpers.set_og_image_url('', @portal.localized_value('header_text', @locale))
|
||||
end
|
||||
|
||||
def sitemap
|
||||
|
||||
@@ -12,7 +12,7 @@ class ContactDrop < BaseDrop
|
||||
end
|
||||
|
||||
def first_name
|
||||
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
|
||||
@obj.try(:name).try(:split).try(:first).try(:capitalize)
|
||||
end
|
||||
|
||||
def last_name
|
||||
|
||||
@@ -12,7 +12,7 @@ class UserDrop < BaseDrop
|
||||
end
|
||||
|
||||
def first_name
|
||||
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size).to_i > 1
|
||||
@obj.try(:name).try(:split).try(:first).try(:capitalize)
|
||||
end
|
||||
|
||||
def last_name
|
||||
|
||||
@@ -34,6 +34,12 @@ disable_branding:
|
||||
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
|
||||
icon: 'icon-sailbot-fill'
|
||||
enterprise: true
|
||||
voice_calls:
|
||||
name: 'Voice Calls'
|
||||
description: 'Enable voice calling capabilities for your agents and customers.'
|
||||
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
|
||||
icon: 'icon-voice-line'
|
||||
enterprise: true
|
||||
|
||||
# ------- Product Features ------- #
|
||||
help_center:
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
verifyServiceWorkerExistence,
|
||||
} from './helper/pushHelper';
|
||||
import ReconnectService from 'dashboard/helper/ReconnectService';
|
||||
import paintStoresFromCache from 'dashboard/helper/CacheHelper/paintStoresFromCache';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
|
||||
export default {
|
||||
@@ -108,14 +109,21 @@ export default {
|
||||
this.$store.dispatch('setActiveAccount', {
|
||||
accountId: this.currentAccountId,
|
||||
});
|
||||
const { pubsub_token: pubsubToken } = this.currentUser || {};
|
||||
vueActionCable.init(this.store, pubsubToken);
|
||||
|
||||
// Paint cached config from IndexedDB instantly while the cable
|
||||
// connects. Freshness needs no orchestration here: RoomChannel pushes
|
||||
// the cache-key map on every (re)subscribe and on every server-side
|
||||
// change, all through the same account.cache_invalidated event.
|
||||
await paintStoresFromCache(this.$store, this.currentAccountId);
|
||||
|
||||
const account = this.getAccount(this.currentAccountId);
|
||||
const { locale, latest_chatwoot_version: latestChatwootVersion } =
|
||||
account;
|
||||
const { pubsub_token: pubsubToken } = this.currentUser || {};
|
||||
// If user locale is set, use it; otherwise use account locale
|
||||
this.setLocale(this.uiSettings?.locale || locale);
|
||||
this.latestChatwootVersion = latestChatwootVersion;
|
||||
vueActionCable.init(this.store, pubsubToken);
|
||||
this.reconnectService = new ReconnectService(this.store, this.router);
|
||||
window.reconnectService = this.reconnectService;
|
||||
|
||||
|
||||
@@ -5,14 +5,15 @@ import ApiClient from './ApiClient';
|
||||
class CacheEnabledApiClient extends ApiClient {
|
||||
constructor(resource, options = {}) {
|
||||
super(resource, options);
|
||||
// `cacheModel` is the Rails Model.name.underscore value — simultaneously
|
||||
// the server cache-key name and the IDB object-store name.
|
||||
this.cacheModelName = options.cacheModel;
|
||||
// inbox/label endpoints wrap collections in { payload }; the rest return
|
||||
// the bare array.
|
||||
this.payloadEnvelope = options.payloadEnvelope || false;
|
||||
this.dataManager = new DataManager(this.accountIdFromRoute);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
get cacheModelName() {
|
||||
throw new Error('cacheModelName is not defined');
|
||||
}
|
||||
|
||||
get(cache = false) {
|
||||
if (cache) {
|
||||
return this.getFromCache();
|
||||
@@ -25,14 +26,14 @@ class CacheEnabledApiClient extends ApiClient {
|
||||
return axios.get(this.url);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
extractDataFromResponse(response) {
|
||||
return response.data.payload;
|
||||
return this.payloadEnvelope ? response.data.payload : response.data;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
marshallData(dataToParse) {
|
||||
return { data: { payload: dataToParse } };
|
||||
return this.payloadEnvelope
|
||||
? { data: { payload: dataToParse } }
|
||||
: { data: dataToParse };
|
||||
}
|
||||
|
||||
async getFromCache() {
|
||||
@@ -43,24 +44,23 @@ class CacheEnabledApiClient extends ApiClient {
|
||||
return this.getFromNetwork();
|
||||
}
|
||||
|
||||
const { data } = await axios.get(
|
||||
`/api/v1/accounts/${this.accountIdFromRoute}/cache_keys`
|
||||
);
|
||||
const cacheKeyFromApi = data.cache_keys[this.cacheModelName];
|
||||
const isCacheValid = await this.validateCacheKey(cacheKeyFromApi);
|
||||
// Trust the IDB cache. Freshness is maintained by the
|
||||
// account.cache_invalidated event alone: RoomChannel pushes the cache-key
|
||||
// map on every (re)subscribe — boot and reconnect included — and the
|
||||
// server broadcasts it on every change. Skipping a per-call /cache_keys
|
||||
// preflight eliminates N GET requests per cold settings-page load.
|
||||
const localData = await this.dataManager.get({
|
||||
modelName: this.cacheModelName,
|
||||
});
|
||||
|
||||
let localData = [];
|
||||
if (isCacheValid) {
|
||||
localData = await this.dataManager.get({
|
||||
modelName: this.cacheModelName,
|
||||
});
|
||||
if (localData.length > 0) {
|
||||
return this.marshallData(localData);
|
||||
}
|
||||
|
||||
if (localData.length === 0) {
|
||||
return this.refetchAndCommit(cacheKeyFromApi);
|
||||
}
|
||||
|
||||
return this.marshallData(localData);
|
||||
// Empty IDB (first load or wiped): fetch data without a cache key. The
|
||||
// next pushed key map won't match the missing key and will refetch once,
|
||||
// stamping the authoritative key — the client never pulls keys itself.
|
||||
return this.refetchAndCommit(null);
|
||||
}
|
||||
|
||||
async refetchAndCommit(newKey = null) {
|
||||
@@ -69,7 +69,9 @@ class CacheEnabledApiClient extends ApiClient {
|
||||
try {
|
||||
await this.dataManager.initDb();
|
||||
|
||||
this.dataManager.replace({
|
||||
// Await replace so data is persisted before the cache key is — otherwise
|
||||
// a concurrent reader could see a fresh key paired with stale data.
|
||||
await this.dataManager.replace({
|
||||
modelName: this.cacheModelName,
|
||||
data: this.extractDataFromResponse(response),
|
||||
});
|
||||
@@ -89,8 +91,15 @@ class CacheEnabledApiClient extends ApiClient {
|
||||
await this.dataManager.initDb();
|
||||
}
|
||||
|
||||
const cachekey = await this.dataManager.getCacheKey(this.cacheModelName);
|
||||
return cacheKeyFromApi === cachekey;
|
||||
const cacheKey = await this.dataManager.getCacheKey(this.cacheModelName);
|
||||
if (cacheKey === undefined) {
|
||||
const localData = await this.dataManager.get({
|
||||
modelName: this.cacheModelName,
|
||||
});
|
||||
return localData.length === 0;
|
||||
}
|
||||
|
||||
return cacheKeyFromApi === cacheKey;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,13 +9,6 @@ class AccountAPI extends ApiClient {
|
||||
createAccount(data) {
|
||||
return axios.post(`${this.apiVersion}/accounts`, data);
|
||||
}
|
||||
|
||||
async getCacheKeys() {
|
||||
const response = await axios.get(
|
||||
`/api/v1/accounts/${this.accountIdFromRoute}/cache_keys`
|
||||
);
|
||||
return response.data.cache_keys;
|
||||
}
|
||||
}
|
||||
|
||||
export default new AccountAPI();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/* global axios */
|
||||
|
||||
import ApiClient from './ApiClient';
|
||||
import CacheEnabledApiClient from './CacheEnabledApiClient';
|
||||
|
||||
class Agents extends ApiClient {
|
||||
class Agents extends CacheEnabledApiClient {
|
||||
constructor() {
|
||||
super('agents', { accountScoped: true });
|
||||
super('agents', { accountScoped: true, cacheModel: 'account_user' });
|
||||
}
|
||||
|
||||
bulkInvite({ emails }) {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
import CacheEnabledApiClient from './CacheEnabledApiClient';
|
||||
|
||||
class AttributeAPI extends ApiClient {
|
||||
class AttributeAPI extends CacheEnabledApiClient {
|
||||
constructor() {
|
||||
super('custom_attribute_definitions', { accountScoped: true });
|
||||
super('custom_attribute_definitions', {
|
||||
accountScoped: true,
|
||||
cacheModel: 'custom_attribute_definition',
|
||||
});
|
||||
}
|
||||
|
||||
getAttributesByModel() {
|
||||
return axios.get(this.url);
|
||||
return super.get(true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
/* global axios */
|
||||
|
||||
import ApiClient from './ApiClient';
|
||||
import CacheEnabledApiClient from './CacheEnabledApiClient';
|
||||
|
||||
class CannedResponse extends ApiClient {
|
||||
class CannedResponse extends CacheEnabledApiClient {
|
||||
constructor() {
|
||||
super('canned_responses', { accountScoped: true });
|
||||
super('canned_responses', {
|
||||
accountScoped: true,
|
||||
cacheModel: 'canned_response',
|
||||
});
|
||||
}
|
||||
|
||||
get({ searchKey }) {
|
||||
const url = searchKey ? `${this.url}?search=${searchKey}` : this.url;
|
||||
return axios.get(url);
|
||||
get({ searchKey } = {}) {
|
||||
if (searchKey) {
|
||||
return axios.get(`${this.url}?search=${searchKey}`);
|
||||
}
|
||||
return super.get(true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,12 @@ class TwilioVoiceClient extends EventTarget {
|
||||
return !!this.activeConnection;
|
||||
}
|
||||
|
||||
setMuted(shouldMute) {
|
||||
if (!this.activeConnection) return false;
|
||||
this.activeConnection.mute(shouldMute);
|
||||
return shouldMute;
|
||||
}
|
||||
|
||||
endClientCall() {
|
||||
if (this.activeConnection) {
|
||||
this.activeConnection.disconnect();
|
||||
|
||||
@@ -3,12 +3,11 @@ import CacheEnabledApiClient from './CacheEnabledApiClient';
|
||||
|
||||
class Inboxes extends CacheEnabledApiClient {
|
||||
constructor() {
|
||||
super('inboxes', { accountScoped: true });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
get cacheModelName() {
|
||||
return 'inbox';
|
||||
super('inboxes', {
|
||||
accountScoped: true,
|
||||
cacheModel: 'inbox',
|
||||
payloadEnvelope: true,
|
||||
});
|
||||
}
|
||||
|
||||
getCampaigns(inboxId) {
|
||||
|
||||
@@ -2,12 +2,11 @@ import CacheEnabledApiClient from './CacheEnabledApiClient';
|
||||
|
||||
class LabelsAPI extends CacheEnabledApiClient {
|
||||
constructor() {
|
||||
super('labels', { accountScoped: true });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
get cacheModelName() {
|
||||
return 'label';
|
||||
super('labels', {
|
||||
accountScoped: true,
|
||||
cacheModel: 'label',
|
||||
payloadEnvelope: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +1,9 @@
|
||||
/* global axios */
|
||||
// import ApiClient from './ApiClient';
|
||||
import CacheEnabledApiClient from './CacheEnabledApiClient';
|
||||
|
||||
export class TeamsAPI extends CacheEnabledApiClient {
|
||||
constructor() {
|
||||
super('teams', { accountScoped: true });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
get cacheModelName() {
|
||||
return 'team';
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
extractDataFromResponse(response) {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
marshallData(dataToParse) {
|
||||
return { data: dataToParse };
|
||||
super('teams', { accountScoped: true, cacheModel: 'team' });
|
||||
}
|
||||
|
||||
getAgents({ teamId }) {
|
||||
|
||||
@@ -46,9 +46,8 @@ const formattedLastActivityAt = computed(() => {
|
||||
:src="avatarSource"
|
||||
class="shrink-0"
|
||||
:name="name"
|
||||
:size="48"
|
||||
:size="42"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<div class="flex flex-col gap-0.5 flex-1 min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 min-w-0">
|
||||
|
||||
-1
@@ -141,7 +141,6 @@ const handleUpdateCompany = async () => {
|
||||
:src="avatarSource"
|
||||
:size="72"
|
||||
:allow-upload="!isAvatarBusy"
|
||||
rounded-full
|
||||
hide-offline-status
|
||||
@upload="handleAvatarUpload"
|
||||
@delete="handleAvatarDelete"
|
||||
|
||||
@@ -124,10 +124,9 @@ const handleAvatarHover = isHovered => {
|
||||
<Avatar
|
||||
:name="name"
|
||||
:src="thumbnail"
|
||||
:size="48"
|
||||
:size="42"
|
||||
:status="availabilityStatus"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
>
|
||||
<template v-if="selectable" #overlay="{ size }">
|
||||
<label
|
||||
|
||||
@@ -53,6 +53,9 @@ const localeMenuLabels = computed(() => ({
|
||||
'publish-locale': t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.PUBLISH_LOCALE'
|
||||
),
|
||||
'customize-content': t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.CUSTOMIZE_CONTENT'
|
||||
),
|
||||
delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'),
|
||||
}));
|
||||
|
||||
@@ -128,7 +131,7 @@ const handleAction = ({ action, value }) => {
|
||||
<DropdownMenu
|
||||
v-if="showDropdownMenu"
|
||||
:menu-items="localeMenuItems"
|
||||
class="ltr:right-0 rtl:left-0 mt-1 xl:ltr:left-0 xl:rtl:right-0 top-full z-60 min-w-[150px]"
|
||||
class="ltr:right-0 rtl:left-0 mt-1 top-full z-60 min-w-[150px]"
|
||||
@action="handleAction"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
|
||||
const props = defineProps({
|
||||
portal: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const activeLocale = ref('');
|
||||
const name = ref('');
|
||||
const pageTitle = ref('');
|
||||
const headerText = ref('');
|
||||
|
||||
const localeTranslations = computed(
|
||||
() => props.portal?.config?.locale_translations || {}
|
||||
);
|
||||
|
||||
const openForLocale = localeCode => {
|
||||
const existing = localeTranslations.value[localeCode] || {};
|
||||
activeLocale.value = localeCode;
|
||||
name.value = existing.name || '';
|
||||
pageTitle.value = existing.page_title || '';
|
||||
headerText.value = existing.header_text || '';
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
const onConfirm = async () => {
|
||||
const translations = { ...localeTranslations.value };
|
||||
const fields = {};
|
||||
if (name.value.trim()) fields.name = name.value.trim();
|
||||
if (pageTitle.value.trim()) fields.page_title = pageTitle.value.trim();
|
||||
if (headerText.value.trim()) fields.header_text = headerText.value.trim();
|
||||
|
||||
if (Object.keys(fields).length) {
|
||||
translations[activeLocale.value] = fields;
|
||||
} else {
|
||||
delete translations[activeLocale.value];
|
||||
}
|
||||
|
||||
try {
|
||||
await store.dispatch('portals/update', {
|
||||
portalSlug: props.portal?.slug,
|
||||
config: { locale_translations: translations },
|
||||
});
|
||||
dialogRef.value?.close();
|
||||
useAlert(t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.message ||
|
||||
t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.API.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ openForLocale });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
:title="t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.TITLE')"
|
||||
:description="t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.DESCRIPTION')"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.NAME.LABEL') }}
|
||||
</label>
|
||||
<Input v-model="name" :placeholder="portal.name" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.PAGE_TITLE.LABEL') }}
|
||||
</label>
|
||||
<Input v-model="pageTitle" :placeholder="portal.page_title" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.HEADER_TEXT.LABEL') }}
|
||||
</label>
|
||||
<Input v-model="headerText" :placeholder="portal.header_text" />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue';
|
||||
import LocaleContentDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleContentDialog.vue';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
@@ -23,6 +25,8 @@ const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
|
||||
const contentDialogRef = ref(null);
|
||||
|
||||
const isLocaleDefault = code => {
|
||||
return props.portal?.meta?.default_locale === code;
|
||||
};
|
||||
@@ -148,6 +152,8 @@ const handleAction = ({ action }, localeCode) => {
|
||||
moveLocaleToDraft({ localeCode: localeCode });
|
||||
} else if (action === 'publish-locale') {
|
||||
publishLocale({ localeCode: localeCode });
|
||||
} else if (action === 'customize-content') {
|
||||
contentDialogRef.value.openForLocale(localeCode);
|
||||
} else if (action === 'delete') {
|
||||
deletePortalLocale({ localeCode: localeCode });
|
||||
}
|
||||
@@ -167,5 +173,6 @@ const handleAction = ({ action }, localeCode) => {
|
||||
:category-count="locale.categoriesCount || 0"
|
||||
@action="handleAction($event, locale.code)"
|
||||
/>
|
||||
<LocaleContentDialog ref="contentDialogRef" :portal="portal" />
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
+6
-1
@@ -182,7 +182,10 @@ const MIN_SEARCH_LENGTH = 2;
|
||||
export const createContactSearcher = () => {
|
||||
let controller = null;
|
||||
|
||||
return async (query, { skipMinLength = false } = {}) => {
|
||||
return async (
|
||||
query,
|
||||
{ skipMinLength = false, reachableOnly = true } = {}
|
||||
) => {
|
||||
const trimmed = typeof query === 'string' ? query.trim() : '';
|
||||
|
||||
controller?.abort();
|
||||
@@ -199,6 +202,8 @@ export const createContactSearcher = () => {
|
||||
} = await ContactAPI.search(trimmed, 1, 'name', '', { signal });
|
||||
|
||||
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
|
||||
if (!reachableOnly) return camelCasedPayload || [];
|
||||
|
||||
// Filter contacts that have either phone_number or email
|
||||
const filteredPayload = camelCasedPayload?.filter(
|
||||
contact => contact.phoneNumber || contact.email
|
||||
|
||||
@@ -197,9 +197,9 @@ const channelIcon = computed(() => {
|
||||
? $t('CONVERSATION.VOICE_WIDGET.END_CALL')
|
||||
: $t('CONVERSATION.VOICE_WIDGET.REJECT_CALL')
|
||||
"
|
||||
icon="i-ph-phone-x-bold"
|
||||
icon="i-ph-phone-bold"
|
||||
ruby
|
||||
class="!rounded-full rotate-[134deg]"
|
||||
class="!rounded-full rotate-[135deg]"
|
||||
@click="isOngoing ? $emit('end') : $emit('reject')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
import { useCallSession } from 'dashboard/composables/useCallSession';
|
||||
import { setWhatsappCallMuted } from 'dashboard/composables/useWhatsappCallSession';
|
||||
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
|
||||
import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants';
|
||||
@@ -11,7 +12,7 @@ import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilit
|
||||
import CallCard from 'dashboard/components-next/call/CallCard.vue';
|
||||
import countriesList from 'shared/constants/countries.js';
|
||||
|
||||
const RINGTONE_URL = '/audio/dashboard/bell.mp3';
|
||||
const RINGTONE_URL = '/audio/dashboard/ringtone.mp3';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -29,8 +30,8 @@ const {
|
||||
formattedCallDuration,
|
||||
} = useCallSession();
|
||||
|
||||
// Mute is currently WhatsApp-only — Twilio calls are mediated server-side and
|
||||
// don't expose a mic track on the browser side.
|
||||
// Mute routes by provider: WhatsApp toggles the local mic track, Twilio uses
|
||||
// the Voice SDK connection's native mute. Both surface the same button.
|
||||
const isMuted = ref(false);
|
||||
const isWhatsappActive = computed(
|
||||
() => activeCall.value?.provider === VOICE_CALL_PROVIDERS.WHATSAPP
|
||||
@@ -63,7 +64,11 @@ const stackedCardState = call =>
|
||||
|
||||
const toggleMute = () => {
|
||||
isMuted.value = !isMuted.value;
|
||||
setWhatsappCallMuted(isMuted.value);
|
||||
if (isWhatsappActive.value) {
|
||||
setWhatsappCallMuted(isMuted.value);
|
||||
} else {
|
||||
TwilioVoiceClient.setMuted(isMuted.value);
|
||||
}
|
||||
};
|
||||
|
||||
watch(hasActiveCall, active => {
|
||||
@@ -256,7 +261,7 @@ onBeforeUnmount(stopRingtone);
|
||||
:call-info="getCallInfo(activeCall || primaryIncomingCall)"
|
||||
:duration="hasActiveCall ? formattedCallDuration : ''"
|
||||
:is-muted="isMuted"
|
||||
:show-mute="hasActiveCall && isWhatsappActive"
|
||||
:show-mute="hasActiveCall"
|
||||
@accept="handleJoinCall(primaryIncomingCall)"
|
||||
@reject="rejectIncomingCall(primaryIncomingCall?.callSid)"
|
||||
@dismiss="dismissCall(primaryIncomingCall?.callSid)"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, h, watch, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import Button from 'next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import FilterSelect from './inputs/FilterSelect.vue';
|
||||
@@ -109,6 +110,34 @@ const inputFieldType = computed(() => {
|
||||
return 'text';
|
||||
});
|
||||
|
||||
const asyncOptions = ref([]);
|
||||
const isSearching = ref(false);
|
||||
const lastSearchQuery = ref('');
|
||||
|
||||
const performAsyncSearch = async query => {
|
||||
let results;
|
||||
try {
|
||||
results = await currentFilter.value.searchOptions(query);
|
||||
} catch {
|
||||
results = [];
|
||||
}
|
||||
// skip stale responses — a newer search in this row owns the UI
|
||||
if (query !== lastSearchQuery.value) return;
|
||||
// null means another row's search aborted ours, reset instead of staying stuck on the searching state
|
||||
if (results !== null) asyncOptions.value = results;
|
||||
isSearching.value = false;
|
||||
};
|
||||
|
||||
const debouncedAsyncSearch = debounce(performAsyncSearch, 300);
|
||||
|
||||
const onAsyncSearch = query => {
|
||||
const hasQuery = !!query.trim();
|
||||
lastSearchQuery.value = query;
|
||||
if (!hasQuery) asyncOptions.value = [];
|
||||
isSearching.value = hasQuery;
|
||||
debouncedAsyncSearch(query);
|
||||
};
|
||||
|
||||
const resetModelOnAttributeKeyChange = newAttributeKey => {
|
||||
/**
|
||||
* Resets the filter values and operator when the attribute key changes. This ensures that
|
||||
@@ -121,11 +150,16 @@ const resetModelOnAttributeKeyChange = newAttributeKey => {
|
||||
const newInputType = getInputType(newOperator, filter);
|
||||
if (newInputType === 'multiSelect') {
|
||||
values.value = [];
|
||||
} else if (['searchSelect', 'booleanSelect'].includes(newInputType)) {
|
||||
} else if (
|
||||
['searchSelect', 'asyncSearchSelect', 'booleanSelect'].includes(
|
||||
newInputType
|
||||
)
|
||||
) {
|
||||
values.value = {};
|
||||
} else {
|
||||
values.value = '';
|
||||
}
|
||||
asyncOptions.value = [];
|
||||
filterOperator.value = newOperator.value;
|
||||
};
|
||||
|
||||
@@ -185,6 +219,16 @@ defineExpose({ validate, resetValidation });
|
||||
:options="currentFilter.options"
|
||||
dropdown-max-height="max-h-64"
|
||||
/>
|
||||
<SingleSelect
|
||||
v-else-if="inputType === 'asyncSearchSelect'"
|
||||
v-model="values"
|
||||
async-search
|
||||
:options="asyncOptions"
|
||||
:is-searching="isSearching"
|
||||
:search-placeholder="currentFilter.searchPlaceholder"
|
||||
dropdown-max-height="max-h-64"
|
||||
@search="onAsyncSearch"
|
||||
/>
|
||||
<SingleSelect
|
||||
v-else-if="inputType === 'booleanSelect'"
|
||||
v-model="values"
|
||||
|
||||
@@ -7,6 +7,7 @@ export const CONVERSATION_ATTRIBUTES = {
|
||||
ASSIGNEE_ID: 'assignee_id',
|
||||
INBOX_ID: 'inbox_id',
|
||||
TEAM_ID: 'team_id',
|
||||
CONTACT_ID: 'contact_id',
|
||||
DISPLAY_ID: 'display_id',
|
||||
CAMPAIGN_ID: 'campaign_id',
|
||||
LABELS: 'labels',
|
||||
|
||||
@@ -1,9 +1,37 @@
|
||||
import { ref } from 'vue';
|
||||
import ContactAPI from 'dashboard/api/contacts';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useConversationFilterContext } from '../provider';
|
||||
import {
|
||||
CONVERSATION_ATTRIBUTES,
|
||||
getCustomAttributeInputType,
|
||||
buildAttributesFilterTypes,
|
||||
replaceUnderscoreWithSpace,
|
||||
} from './filterHelper';
|
||||
|
||||
vi.mock('dashboard/api/contacts', () => ({
|
||||
default: {
|
||||
search: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables/store.js', () => ({
|
||||
useMapGetter: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('next/icon/provider', () => ({
|
||||
useChannelIcon: () => ref('i-test-channel'),
|
||||
}));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key, params = {}) => {
|
||||
if (key === 'FILTER.CONTACT_FALLBACK') return `Contact #${params.id}`;
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('filterHelper', () => {
|
||||
describe('getCustomAttributeInputType', () => {
|
||||
it('returns date for date type', () => {
|
||||
@@ -135,3 +163,64 @@ describe('filterHelper', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const storeValues = {
|
||||
'attributes/getConversationAttributes': ref([]),
|
||||
'labels/getLabels': ref([]),
|
||||
'agents/getAgents': ref([]),
|
||||
'inboxes/getInboxes': ref([]),
|
||||
'teams/getTeams': ref([]),
|
||||
'campaigns/getAllCampaigns': ref([]),
|
||||
};
|
||||
|
||||
describe('useConversationFilterContext', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useMapGetter.mockImplementation(key => storeValues[key] || ref([]));
|
||||
});
|
||||
|
||||
it('exposes contact as an async searchable conversation filter', () => {
|
||||
const { filterTypes } = useConversationFilterContext();
|
||||
const contactFilter = filterTypes.value.find(
|
||||
filter => filter.attributeKey === CONVERSATION_ATTRIBUTES.CONTACT_ID
|
||||
);
|
||||
|
||||
expect(contactFilter).toMatchObject({
|
||||
attributeKey: 'contact_id',
|
||||
label: 'FILTER.ATTRIBUTES.CONTACT',
|
||||
inputType: 'asyncSearchSelect',
|
||||
dataType: 'number',
|
||||
attributeModel: 'standard',
|
||||
});
|
||||
expect(
|
||||
contactFilter.filterOperators.map(operator => operator.value)
|
||||
).toEqual(['equal_to', 'not_equal_to']);
|
||||
});
|
||||
|
||||
it('uses the existing contact search API for contact filter options', async () => {
|
||||
ContactAPI.search.mockResolvedValue({
|
||||
data: {
|
||||
payload: [
|
||||
{ id: 1, name: 'Jane Doe' },
|
||||
{ id: 2, email: 'alex@example.com' },
|
||||
{ id: 3 },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const { filterTypes } = useConversationFilterContext();
|
||||
const contactFilter = filterTypes.value.find(
|
||||
filter => filter.attributeKey === CONVERSATION_ATTRIBUTES.CONTACT_ID
|
||||
);
|
||||
const options = await contactFilter.searchOptions('jane');
|
||||
|
||||
expect(ContactAPI.search).toHaveBeenCalledWith('jane', 1, 'name', '', {
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(options).toEqual([
|
||||
{ id: 1, name: 'Jane Doe' },
|
||||
{ id: 2, name: 'alex@example.com' },
|
||||
{ id: 3, name: 'Contact #3' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,8 @@ import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
|
||||
|
||||
const {
|
||||
options,
|
||||
asyncSearch,
|
||||
isSearching,
|
||||
disableSearch,
|
||||
disableDeselect,
|
||||
placeholderIcon,
|
||||
@@ -23,6 +25,14 @@ const {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
asyncSearch: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isSearching: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disableSearch: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -53,6 +63,12 @@ const {
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['search']);
|
||||
|
||||
// the input is re-inserted on every dropdown open (v-if),
|
||||
// where the native autofocus attribute is ignored so focus it via a directive instead
|
||||
const vFocus = { mounted: el => el.focus() };
|
||||
|
||||
const { t } = useI18n();
|
||||
const selected = defineModel({
|
||||
type: Object,
|
||||
@@ -60,7 +76,9 @@ const selected = defineModel({
|
||||
});
|
||||
|
||||
const searchTerm = ref('');
|
||||
|
||||
const searchResults = computed(() => {
|
||||
if (asyncSearch) return options;
|
||||
if (!options) return [];
|
||||
return picoSearch(options, searchTerm.value, ['name']);
|
||||
});
|
||||
@@ -77,7 +95,11 @@ const selectedItem = computed(() => {
|
||||
if (!optionToSearch) return null;
|
||||
// extract the selected item from the options array
|
||||
// this ensures that options like icon is also included
|
||||
return options.find(option => option.id === optionToSearch.id);
|
||||
return (
|
||||
options.find(option => option.id === optionToSearch.id) ||
|
||||
// async options may not include the selected option, fall back to it
|
||||
(asyncSearch && optionToSearch.id !== undefined ? optionToSearch : null)
|
||||
);
|
||||
});
|
||||
|
||||
const toggleSelected = option => {
|
||||
@@ -131,13 +153,19 @@ const toggleSelected = option => {
|
||||
<Icon class="absolute size-4 left-2 top-2" icon="i-lucide-search" />
|
||||
<input
|
||||
v-model="searchTerm"
|
||||
autofocus
|
||||
v-focus
|
||||
class="p-1.5 pl-8 text-n-slate-11 bg-n-alpha-1 rounded-lg w-full"
|
||||
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
|
||||
@input="emit('search', $event.target.value)"
|
||||
/>
|
||||
</div>
|
||||
<DropdownSection :height="dropdownMaxHeight">
|
||||
<template v-if="searchResults.length">
|
||||
<template v-if="isSearching">
|
||||
<DropdownItem disabled>
|
||||
{{ t('DROPDOWN_MENU.SEARCHING') }}
|
||||
</DropdownItem>
|
||||
</template>
|
||||
<template v-else-if="searchResults.length">
|
||||
<DropdownItem
|
||||
v-for="option in searchResults"
|
||||
:key="option.id"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useOperators } from './operators';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useChannelIcon } from 'next/icon/provider';
|
||||
import { createContactSearcher } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
|
||||
import {
|
||||
buildAttributesFilterTypes,
|
||||
CONVERSATION_ATTRIBUTES,
|
||||
@@ -30,7 +31,7 @@ import languages from 'dashboard/components/widgets/conversation/advancedFilterI
|
||||
* @property {string} value - This is a proxy for the attribute key used in FilterSelect
|
||||
* @property {string} attributeName - The attribute name used to display on the UI
|
||||
* @property {string} label - This is a proxy for the attribute name used in FilterSelect
|
||||
* @property {'multiSelect'|'searchSelect'|'plainText'|'date'|'booleanSelect'} inputType - The input type for the attribute
|
||||
* @property {'multiSelect'|'searchSelect'|'asyncSearchSelect'|'plainText'|'date'|'booleanSelect'} inputType - The input type for the attribute
|
||||
* @property {FilterOption[]} [options] - The options available for the attribute if it is a multiSelect or singleSelect type
|
||||
* @property {'text'|'number'} dataType
|
||||
* @property {FilterOperator[]} filterOperators - The operators available for the attribute
|
||||
@@ -68,6 +69,30 @@ export function useConversationFilterContext() {
|
||||
getOperatorTypes,
|
||||
} = useOperators();
|
||||
|
||||
const searchContacts = createContactSearcher();
|
||||
|
||||
const contactOptionName = contact =>
|
||||
contact.name ||
|
||||
contact.email ||
|
||||
contact.phoneNumber ||
|
||||
contact.identifier ||
|
||||
t('FILTER.CONTACT_FALLBACK', { id: contact.id });
|
||||
|
||||
const searchContactOptions = async query => {
|
||||
const contacts = await searchContacts(query, {
|
||||
skipMinLength: true,
|
||||
reachableOnly: false,
|
||||
});
|
||||
|
||||
// null means the request was aborted (a newer search is in-flight)
|
||||
if (contacts === null) return null;
|
||||
|
||||
return contacts.map(contact => ({
|
||||
id: contact.id,
|
||||
name: contactOptionName(contact),
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* @type {import('vue').ComputedRef<FilterType[]>}
|
||||
*/
|
||||
@@ -158,6 +183,18 @@ export function useConversationFilterContext() {
|
||||
filterOperators: presenceOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.CONTACT_ID,
|
||||
value: CONVERSATION_ATTRIBUTES.CONTACT_ID,
|
||||
attributeName: t('FILTER.ATTRIBUTES.CONTACT'),
|
||||
label: t('FILTER.ATTRIBUTES.CONTACT'),
|
||||
inputType: 'asyncSearchSelect',
|
||||
searchOptions: searchContactOptions,
|
||||
searchPlaceholder: t('FILTER.CONTACT_SEARCH_PLACEHOLDER'),
|
||||
dataType: 'number',
|
||||
filterOperators: equalityOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.DISPLAY_ID,
|
||||
value: CONVERSATION_ATTRIBUTES.DISPLAY_ID,
|
||||
|
||||
@@ -61,9 +61,21 @@ const hasAdvancedAssignment = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const fetchConversationUnreadCounts = currentAccountId => {
|
||||
const hasConversationUnreadCounts = computed(() => {
|
||||
return isFeatureEnabledonAccount.value(
|
||||
accountId.value,
|
||||
FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
|
||||
);
|
||||
});
|
||||
|
||||
const fetchConversationUnreadCounts = ([currentAccountId, isEnabled]) => {
|
||||
if (!currentAccountId) return;
|
||||
|
||||
if (!isEnabled) {
|
||||
store.dispatch('conversationUnreadCounts/clear');
|
||||
return;
|
||||
}
|
||||
|
||||
store.dispatch('conversationUnreadCounts/get');
|
||||
};
|
||||
|
||||
@@ -188,7 +200,7 @@ onMounted(() => {
|
||||
store.dispatch('customViews/get', 'contact');
|
||||
});
|
||||
|
||||
watch(accountId, fetchConversationUnreadCounts, {
|
||||
watch([accountId, hasConversationUnreadCounts], fetchConversationUnreadCounts, {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -145,7 +145,6 @@ const allowedMenuItems = computed(() => {
|
||||
:src="currentUser.avatar_url"
|
||||
:status="currentUserAvailability"
|
||||
class="flex-shrink-0"
|
||||
rounded-full
|
||||
/>
|
||||
<div v-if="!isCollapsed" class="min-w-0">
|
||||
<div class="text-sm font-medium leading-4 truncate text-n-slate-12">
|
||||
|
||||
@@ -151,6 +151,9 @@ const activeFolder = computed(() => {
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const getContact = useMapGetter('contacts/getContact');
|
||||
const folderContactId = useMapGetter('customViews/getActiveFolderContactId');
|
||||
|
||||
const activeFolderName = computed(() => {
|
||||
return activeFolder.value?.name;
|
||||
});
|
||||
@@ -456,6 +459,7 @@ function setParamsForEditFolderModal() {
|
||||
inboxes: inboxesList.value,
|
||||
labels: labels.value,
|
||||
campaigns: campaigns.value,
|
||||
contacts: [getContact.value(folderContactId.value)],
|
||||
languages: languages,
|
||||
countries: countries,
|
||||
priority: [
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
CONVERSATION_EVENTS,
|
||||
CAPTAIN_EVENTS,
|
||||
} from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor';
|
||||
|
||||
import {
|
||||
messageSchema,
|
||||
@@ -43,6 +42,7 @@ import {
|
||||
MessageMarkdownSerializer,
|
||||
EditorState,
|
||||
Selection,
|
||||
imageResizeView,
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import {
|
||||
suggestionsPlugin,
|
||||
@@ -57,7 +57,6 @@ import {
|
||||
insertAtCursor,
|
||||
removeSignature as removeSignatureHelper,
|
||||
scrollCursorIntoView,
|
||||
setURLWithQueryAndSize,
|
||||
getFormattingForEditor,
|
||||
getSelectionCoords,
|
||||
calculateMenuPosition,
|
||||
@@ -72,6 +71,7 @@ import {
|
||||
import { createTypingIndicator } from '@chatwoot/utils';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { uploadFile } from 'dashboard/helper/uploadHelper';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
@@ -93,7 +93,6 @@ const props = defineProps({
|
||||
channelType: { type: String, default: '' },
|
||||
conversationId: { type: Number, default: null },
|
||||
medium: { type: String, default: '' },
|
||||
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
|
||||
focusOnMount: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
@@ -119,6 +118,14 @@ const TYPING_INDICATOR_IDLE_TIME = 4000;
|
||||
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
|
||||
const DEFAULT_FORMATTING = 'Context::Default';
|
||||
const PRIVATE_NOTE_FORMATTING = 'Context::PrivateNote';
|
||||
const MESSAGE_SIGNATURE_FORMATTING = 'Context::MessageSignature';
|
||||
const INLINE_IMAGE_PASTE_TYPES = [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
];
|
||||
|
||||
const effectiveChannelType = computed(() =>
|
||||
getEffectiveChannelType(props.channelType, props.medium)
|
||||
@@ -192,12 +199,8 @@ const cannedSearchTerm = ref('');
|
||||
const variableSearchTerm = ref('');
|
||||
const emojiSearchTerm = ref('');
|
||||
const range = ref(null);
|
||||
const isImageNodeSelected = ref(false);
|
||||
const toolbarPosition = ref({ top: 0, left: 0 });
|
||||
const selectedImageNode = ref(null);
|
||||
const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection
|
||||
const showSelectionMenu = ref(false);
|
||||
const sizes = MESSAGE_EDITOR_IMAGE_RESIZES;
|
||||
|
||||
// element ref
|
||||
const editorRoot = useTemplateRef('editorRoot');
|
||||
@@ -475,16 +478,6 @@ function removeSignature() {
|
||||
reloadState(content);
|
||||
}
|
||||
|
||||
function setToolbarPosition() {
|
||||
const editorRect = editorRoot.value.getBoundingClientRect();
|
||||
const rect = selectedImageNode.value.getBoundingClientRect();
|
||||
|
||||
toolbarPosition.value = {
|
||||
top: `${rect.top - editorRect.top - 30}px`,
|
||||
left: `${rect.left - editorRect.left - 4}px`,
|
||||
};
|
||||
}
|
||||
|
||||
function setMenubarPosition({ selection } = {}) {
|
||||
const wrapper = editorRoot.value;
|
||||
if (!selection || !wrapper) return;
|
||||
@@ -520,30 +513,6 @@ function checkSelection(editorState) {
|
||||
if (hasSelection) setMenubarPosition(editorState);
|
||||
}
|
||||
|
||||
function setURLWithQueryAndImageSize(size) {
|
||||
if (!props.showImageResizeToolbar) {
|
||||
return;
|
||||
}
|
||||
setURLWithQueryAndSize(selectedImageNode.value, size, editorView);
|
||||
isImageNodeSelected.value = false;
|
||||
}
|
||||
|
||||
function isEditorMouseFocusedOnAnImage() {
|
||||
if (!props.showImageResizeToolbar) {
|
||||
return;
|
||||
}
|
||||
selectedImageNode.value = document.querySelector(
|
||||
'img.ProseMirror-selectednode'
|
||||
);
|
||||
if (selectedImageNode.value) {
|
||||
isImageNodeSelected.value = !!selectedImageNode.value;
|
||||
// Get the position of the selected node
|
||||
setToolbarPosition();
|
||||
} else {
|
||||
isImageNodeSelected.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function emitOnChange() {
|
||||
emit('input', contentFromEditor());
|
||||
emit('update:modelValue', contentFromEditor());
|
||||
@@ -563,21 +532,6 @@ function toggleSignatureInEditor(signatureEnabled) {
|
||||
emitOnChange();
|
||||
}
|
||||
|
||||
function updateImgToolbarOnDelete() {
|
||||
// check if the selected node is present or not on keyup
|
||||
// this is needed because the user can select an image and then delete it
|
||||
// in that case, the selected node will be null and we need to hide the toolbar
|
||||
// otherwise, the toolbar will be visible even when the image is deleted and cause some errors
|
||||
if (selectedImageNode.value) {
|
||||
const hasImgSelectedNode = document.querySelector(
|
||||
'img.ProseMirror-selectednode'
|
||||
);
|
||||
if (!hasImgSelectedNode) {
|
||||
isImageNodeSelected.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isEnterToSendEnabled() {
|
||||
return isEditorHotKeyEnabled('enter');
|
||||
}
|
||||
@@ -586,17 +540,6 @@ function isCmdPlusEnterToSendEnabled() {
|
||||
return isEditorHotKeyEnabled('cmd_enter');
|
||||
}
|
||||
|
||||
useKeyboardEvents({
|
||||
'Alt+KeyP': {
|
||||
action: focusEditorInputField,
|
||||
allowOnFocusedInput: false,
|
||||
},
|
||||
'Alt+KeyL': {
|
||||
action: focusEditorInputField,
|
||||
allowOnFocusedInput: false,
|
||||
},
|
||||
});
|
||||
|
||||
function onImageInsertInEditor(fileUrl) {
|
||||
const { tr } = editorView.state;
|
||||
|
||||
@@ -617,7 +560,11 @@ async function uploadImageToStorage(file) {
|
||||
onImageInsertInEditor(fileUrl);
|
||||
}
|
||||
useAlert(
|
||||
t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SUCCESS')
|
||||
props.channelType === MESSAGE_SIGNATURE_FORMATTING
|
||||
? t(
|
||||
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SUCCESS'
|
||||
)
|
||||
: t('CONVERSATION.REPLYBOX.IMAGE_UPLOAD_SUCCESS')
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
@@ -626,8 +573,8 @@ async function uploadImageToStorage(file) {
|
||||
}
|
||||
}
|
||||
|
||||
function onFileChange() {
|
||||
const file = imageUpload.value.files[0];
|
||||
function uploadImageIfWithinSizeLimit(file) {
|
||||
if (!file) return;
|
||||
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
|
||||
uploadImageToStorage(file);
|
||||
} else {
|
||||
@@ -640,10 +587,61 @@ function onFileChange() {
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
imageUpload.value = '';
|
||||
}
|
||||
|
||||
function onFileChange() {
|
||||
const input = imageUpload.value;
|
||||
uploadImageIfWithinSizeLimit(input.files[0]);
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
const allowsInlineImagePaste = computed(
|
||||
() =>
|
||||
!props.isPrivate &&
|
||||
(props.channelType === INBOX_TYPES.EMAIL ||
|
||||
props.channelType === INBOX_TYPES.WEB)
|
||||
);
|
||||
|
||||
// Shift+Cmd/Ctrl+V on email/website: upload a clipboard image inline. This
|
||||
// gesture's native paste event carries no image, so clipboard.read() is the
|
||||
// only way to get the bytes. No preventDefault: text still pastes natively.
|
||||
async function pasteInlineImageFromClipboard() {
|
||||
if (!editorView?.hasFocus()) return;
|
||||
if (!allowsInlineImagePaste.value || !navigator.clipboard?.read) return;
|
||||
try {
|
||||
const items = await navigator.clipboard.read();
|
||||
const imageItem = items.find(item =>
|
||||
item.types.some(type => INLINE_IMAGE_PASTE_TYPES.includes(type))
|
||||
);
|
||||
if (!imageItem) return;
|
||||
const imageType = imageItem.types.find(type =>
|
||||
INLINE_IMAGE_PASTE_TYPES.includes(type)
|
||||
);
|
||||
const blob = await imageItem.getType(imageType);
|
||||
uploadImageIfWithinSizeLimit(
|
||||
new File([blob], 'pasted-image', { type: imageType })
|
||||
);
|
||||
} catch (error) {
|
||||
// clipboard-read denied/unfocused (NotAllowedError): image can't be read.
|
||||
// Text paste is unaffected — ProseMirror handles it from the native event.
|
||||
}
|
||||
}
|
||||
|
||||
useKeyboardEvents({
|
||||
'Alt+KeyP': {
|
||||
action: focusEditorInputField,
|
||||
allowOnFocusedInput: false,
|
||||
},
|
||||
'Alt+KeyL': {
|
||||
action: focusEditorInputField,
|
||||
allowOnFocusedInput: false,
|
||||
},
|
||||
'$mod+Shift+KeyV': {
|
||||
action: pasteInlineImageFromClipboard,
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
});
|
||||
|
||||
function handleLineBreakWhenEnterToSendEnabled(event) {
|
||||
if (
|
||||
hasPressedEnterAndNotCmdOrShift(event) &&
|
||||
@@ -736,6 +734,9 @@ function createEditorView() {
|
||||
editorView = new EditorView(editor.value, {
|
||||
state: state,
|
||||
editable: () => !props.disabled,
|
||||
nodeViews: {
|
||||
image: imageResizeView,
|
||||
},
|
||||
dispatchTransaction: tx => {
|
||||
state = state.apply(tx);
|
||||
editorView.updateState(state);
|
||||
@@ -748,12 +749,10 @@ function createEditorView() {
|
||||
keyup: () => {
|
||||
if (!props.disabled) {
|
||||
typingIndicator.start();
|
||||
updateImgToolbarOnDelete();
|
||||
}
|
||||
},
|
||||
keydown: (view, event) => !props.disabled && onKeydown(event),
|
||||
focus: () => !props.disabled && emit('focus'),
|
||||
click: () => !props.disabled && isEditorMouseFocusedOnAnImage(),
|
||||
blur: () => {
|
||||
if (props.disabled) return;
|
||||
typingIndicator.stop();
|
||||
@@ -918,23 +917,6 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<div ref="editor" />
|
||||
<div
|
||||
v-show="isImageNodeSelected && showImageResizeToolbar"
|
||||
class="absolute shadow-md rounded-[6px] flex gap-1 py-1 px-1 bg-n-solid-3 outline outline-1 outline-n-weak text-n-slate-12"
|
||||
:style="{
|
||||
top: toolbarPosition.top,
|
||||
left: toolbarPosition.left,
|
||||
}"
|
||||
>
|
||||
<button
|
||||
v-for="size in sizes"
|
||||
:key="size.name"
|
||||
class="text-xs font-medium rounded-[4px] outline outline-1 outline-n-strong px-1.5 py-0.5 hover:bg-n-slate-5"
|
||||
@click="setURLWithQueryAndImageSize(size)"
|
||||
>
|
||||
{{ size.name }}
|
||||
</button>
|
||||
</div>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -997,10 +979,6 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
@apply text-n-slate-11;
|
||||
}
|
||||
}
|
||||
|
||||
ol li {
|
||||
@apply list-item list-decimal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -124,7 +124,6 @@ const copyConversationId = async () => {
|
||||
:size="32"
|
||||
:status="currentContact.availability_status"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<div
|
||||
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2"
|
||||
|
||||
@@ -46,6 +46,14 @@ const filterTypes = [
|
||||
filterOperators: OPERATOR_TYPES_2,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'contact_id',
|
||||
attributeI18nKey: 'CONTACT',
|
||||
inputType: 'search_select',
|
||||
dataType: 'number',
|
||||
filterOperators: OPERATOR_TYPES_1,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'display_id',
|
||||
attributeI18nKey: 'CONVERSATION_IDENTIFIER',
|
||||
@@ -133,6 +141,10 @@ export const filterAttributeGroups = [
|
||||
key: 'team_id',
|
||||
i18nKey: 'TEAM_NAME',
|
||||
},
|
||||
{
|
||||
key: 'contact_id',
|
||||
i18nKey: 'CONTACT',
|
||||
},
|
||||
{
|
||||
key: 'display_id',
|
||||
i18nKey: 'CONVERSATION_IDENTIFIER',
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useFacebookPageConnect } from '../useFacebookPageConnect';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import ChannelApi from 'dashboard/api/channels';
|
||||
import { setupFacebookSdk } from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
|
||||
|
||||
vi.mock('dashboard/composables/store', () => ({ useMapGetter: vi.fn() }));
|
||||
vi.mock('dashboard/api/channels', () => ({
|
||||
default: { fetchFacebookPages: vi.fn() },
|
||||
}));
|
||||
vi.mock(
|
||||
'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils',
|
||||
() => ({ setupFacebookSdk: vi.fn() })
|
||||
);
|
||||
|
||||
const flushPromises = () =>
|
||||
new Promise(resolve => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
|
||||
const createDeferred = () => {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
const ACCOUNT_ID = 42;
|
||||
const PAGES = [
|
||||
{ id: 'p1', name: 'Page One', access_token: 'page-token-1' },
|
||||
{ id: 'p2', name: 'Page Two', access_token: 'page-token-2', exists: true },
|
||||
];
|
||||
|
||||
const pagesResponse = {
|
||||
data: { data: { page_details: PAGES, user_access_token: 'long-token' } },
|
||||
};
|
||||
|
||||
// FB.login invokes its callback with the given response.
|
||||
const stubLogin = response => {
|
||||
window.FB = { login: vi.fn(callback => callback(response)) };
|
||||
};
|
||||
|
||||
const connected = {
|
||||
status: 'connected',
|
||||
authResponse: { accessToken: 'user-token' },
|
||||
};
|
||||
|
||||
describe('useFacebookPageConnect', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.chatwootConfig = { fbAppId: 'fb-app', fbApiVersion: 'v22.0' };
|
||||
useMapGetter.mockReturnValue({ value: ACCOUNT_ID });
|
||||
setupFacebookSdk.mockResolvedValue();
|
||||
ChannelApi.fetchFacebookPages.mockResolvedValue(pagesResponse);
|
||||
stubLogin(connected);
|
||||
});
|
||||
|
||||
it('resolves the user token and pages on a connected login', async () => {
|
||||
const { loginAndFetchPages } = useFacebookPageConnect();
|
||||
|
||||
await expect(loginAndFetchPages()).resolves.toEqual({
|
||||
userAccessToken: 'long-token',
|
||||
pages: PAGES,
|
||||
});
|
||||
expect(setupFacebookSdk).toHaveBeenCalledWith('fb-app', 'v22.0');
|
||||
expect(ChannelApi.fetchFacebookPages).toHaveBeenCalledWith(
|
||||
'user-token',
|
||||
ACCOUNT_ID
|
||||
);
|
||||
expect(window.FB.login).toHaveBeenCalledWith(expect.any(Function), {
|
||||
scope: expect.stringContaining('pages_show_list'),
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves null when the user is not authorized', async () => {
|
||||
stubLogin({ status: 'not_authorized' });
|
||||
const { loginAndFetchPages } = useFacebookPageConnect();
|
||||
|
||||
await expect(loginAndFetchPages()).resolves.toBeNull();
|
||||
expect(ChannelApi.fetchFacebookPages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves null on an unknown login status', async () => {
|
||||
stubLogin({ status: 'unknown' });
|
||||
const { loginAndFetchPages } = useFacebookPageConnect();
|
||||
|
||||
await expect(loginAndFetchPages()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('rejects when fetching pages fails', async () => {
|
||||
ChannelApi.fetchFacebookPages.mockRejectedValue(new Error('fetch failed'));
|
||||
const { loginAndFetchPages } = useFacebookPageConnect();
|
||||
|
||||
await expect(loginAndFetchPages()).rejects.toThrow('fetch failed');
|
||||
});
|
||||
|
||||
it('rejects when the SDK fails to load', async () => {
|
||||
const error = new Error('script load failed');
|
||||
error.name = 'ScriptLoaderError';
|
||||
setupFacebookSdk.mockRejectedValue(error);
|
||||
const { loginAndFetchPages } = useFacebookPageConnect();
|
||||
|
||||
await expect(loginAndFetchPages()).rejects.toThrow('script load failed');
|
||||
});
|
||||
|
||||
it('ignores a second call while a run is in flight', async () => {
|
||||
const pending = createDeferred();
|
||||
ChannelApi.fetchFacebookPages.mockReturnValue(pending.promise);
|
||||
|
||||
const { loginAndFetchPages } = useFacebookPageConnect();
|
||||
const first = loginAndFetchPages();
|
||||
const second = loginAndFetchPages();
|
||||
|
||||
await expect(second).resolves.toBeNull();
|
||||
|
||||
pending.resolve(pagesResponse);
|
||||
await first;
|
||||
expect(window.FB.login).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('toggles isAuthenticating across a run', async () => {
|
||||
const pending = createDeferred();
|
||||
ChannelApi.fetchFacebookPages.mockReturnValue(pending.promise);
|
||||
|
||||
const { isAuthenticating, loginAndFetchPages } = useFacebookPageConnect();
|
||||
expect(isAuthenticating.value).toBe(false);
|
||||
|
||||
const result = loginAndFetchPages();
|
||||
await flushPromises();
|
||||
expect(isAuthenticating.value).toBe(true);
|
||||
|
||||
pending.resolve(pagesResponse);
|
||||
await result;
|
||||
expect(isAuthenticating.value).toBe(false);
|
||||
});
|
||||
|
||||
it('preloads the SDK once and reuses it for login', async () => {
|
||||
const { preloadSdk, loginAndFetchPages } = useFacebookPageConnect();
|
||||
|
||||
preloadSdk();
|
||||
preloadSdk();
|
||||
await loginAndFetchPages();
|
||||
|
||||
expect(setupFacebookSdk).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useWhatsappEmbeddedSignup } from '../useWhatsappEmbeddedSignup';
|
||||
import {
|
||||
setupFacebookSdk,
|
||||
initWhatsAppEmbeddedSignup,
|
||||
createMessageHandler,
|
||||
} from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
|
||||
|
||||
vi.mock(
|
||||
'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils',
|
||||
() => ({
|
||||
setupFacebookSdk: vi.fn(),
|
||||
initWhatsAppEmbeddedSignup: vi.fn(),
|
||||
createMessageHandler: vi.fn(),
|
||||
isValidBusinessData: vi.fn(data =>
|
||||
Boolean(data && data.business_id && data.waba_id)
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
const flushPromises = () =>
|
||||
new Promise(resolve => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
|
||||
const createDeferred = () => {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
const VALID_BUSINESS = {
|
||||
business_id: 'biz-1',
|
||||
waba_id: 'waba-1',
|
||||
phone_number_id: 'phone-1',
|
||||
};
|
||||
|
||||
describe('useWhatsappEmbeddedSignup', () => {
|
||||
// The mocked createMessageHandler captures the callback the composable
|
||||
// registers, so tests can simulate Meta's WA_EMBEDDED_SIGNUP postMessages
|
||||
// directly without the window-event + origin plumbing (that is covered by
|
||||
// the utils' own tests).
|
||||
let signupCallback;
|
||||
let registeredListener;
|
||||
|
||||
const emit = data => signupCallback(data);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
window.chatwootConfig = {
|
||||
whatsappAppId: 'app-id',
|
||||
whatsappConfigurationId: 'config-id',
|
||||
whatsappApiVersion: 'v22.0',
|
||||
};
|
||||
|
||||
setupFacebookSdk.mockResolvedValue();
|
||||
createMessageHandler.mockImplementation(callback => {
|
||||
signupCallback = callback;
|
||||
registeredListener = () => {};
|
||||
return registeredListener;
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves credentials when the auth code arrives before the business data', async () => {
|
||||
initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code');
|
||||
|
||||
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
const result = runEmbeddedSignup();
|
||||
|
||||
await flushPromises(); // SDK setup + FB.login resolve the code first
|
||||
emit({ event: 'FINISH', data: VALID_BUSINESS });
|
||||
|
||||
await expect(result).resolves.toEqual({
|
||||
code: 'auth-code',
|
||||
business_id: 'biz-1',
|
||||
waba_id: 'waba-1',
|
||||
phone_number_id: 'phone-1',
|
||||
});
|
||||
expect(setupFacebookSdk).toHaveBeenCalledWith('app-id', 'v22.0');
|
||||
expect(initWhatsAppEmbeddedSignup).toHaveBeenCalledWith('config-id');
|
||||
});
|
||||
|
||||
it('resolves credentials when the business data arrives before the auth code', async () => {
|
||||
const code = createDeferred();
|
||||
initWhatsAppEmbeddedSignup.mockReturnValue(code.promise);
|
||||
|
||||
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
const result = runEmbeddedSignup();
|
||||
|
||||
// Business data lands first, while FB.login is still pending.
|
||||
emit({
|
||||
event: 'FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING',
|
||||
data: VALID_BUSINESS,
|
||||
});
|
||||
code.resolve('late-code');
|
||||
|
||||
await expect(result).resolves.toEqual({
|
||||
code: 'late-code',
|
||||
business_id: 'biz-1',
|
||||
waba_id: 'waba-1',
|
||||
phone_number_id: 'phone-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults phone_number_id to an empty string when absent', async () => {
|
||||
initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code');
|
||||
|
||||
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
const result = runEmbeddedSignup();
|
||||
|
||||
await flushPromises();
|
||||
emit({
|
||||
event: 'FINISH',
|
||||
data: { business_id: 'biz-1', waba_id: 'waba-1' },
|
||||
});
|
||||
|
||||
await expect(result).resolves.toMatchObject({ phone_number_id: '' });
|
||||
});
|
||||
|
||||
it('resolves null when FB.login is cancelled', async () => {
|
||||
initWhatsAppEmbeddedSignup.mockRejectedValue(new Error('Login cancelled'));
|
||||
|
||||
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
|
||||
await expect(runEmbeddedSignup()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('resolves null on a CANCEL event', async () => {
|
||||
initWhatsAppEmbeddedSignup.mockReturnValue(createDeferred().promise);
|
||||
|
||||
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
const result = runEmbeddedSignup();
|
||||
|
||||
emit({ event: 'CANCEL' });
|
||||
|
||||
await expect(result).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('rejects with the Meta error message on an error event', async () => {
|
||||
initWhatsAppEmbeddedSignup.mockReturnValue(createDeferred().promise);
|
||||
|
||||
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
const result = runEmbeddedSignup();
|
||||
|
||||
emit({ event: 'error', error_message: 'WABA not eligible' });
|
||||
|
||||
await expect(result).rejects.toThrow('WABA not eligible');
|
||||
});
|
||||
|
||||
it('rejects when the business data is invalid', async () => {
|
||||
initWhatsAppEmbeddedSignup.mockReturnValue(createDeferred().promise);
|
||||
|
||||
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
const result = runEmbeddedSignup();
|
||||
|
||||
emit({ event: 'FINISH', data: { business_id: 'biz-1' } }); // no waba_id
|
||||
|
||||
await expect(result).rejects.toThrow('Invalid business data');
|
||||
});
|
||||
|
||||
it('rejects when the SDK or login fails for a non-cancel reason', async () => {
|
||||
initWhatsAppEmbeddedSignup.mockRejectedValue(new Error('popup blocked'));
|
||||
|
||||
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
|
||||
await expect(runEmbeddedSignup()).rejects.toThrow('popup blocked');
|
||||
});
|
||||
|
||||
it('ignores a second call while a run is in flight', async () => {
|
||||
initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code');
|
||||
|
||||
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
const first = runEmbeddedSignup();
|
||||
const second = runEmbeddedSignup();
|
||||
|
||||
await expect(second).resolves.toBeNull();
|
||||
|
||||
// Let the first run finish so it doesn't leak into the next test.
|
||||
await flushPromises();
|
||||
emit({ event: 'FINISH', data: VALID_BUSINESS });
|
||||
await first;
|
||||
|
||||
expect(setupFacebookSdk).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('toggles isAuthenticating and removes the listener once settled', async () => {
|
||||
initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code');
|
||||
const removeSpy = vi.spyOn(window, 'removeEventListener');
|
||||
|
||||
const { isAuthenticating, runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
expect(isAuthenticating.value).toBe(false);
|
||||
|
||||
const result = runEmbeddedSignup();
|
||||
expect(isAuthenticating.value).toBe(true);
|
||||
|
||||
await flushPromises();
|
||||
emit({ event: 'FINISH', data: VALID_BUSINESS });
|
||||
await result;
|
||||
|
||||
expect(isAuthenticating.value).toBe(false);
|
||||
expect(removeSpy).toHaveBeenCalledWith('message', registeredListener);
|
||||
removeSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ref } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import ChannelApi from 'dashboard/api/channels';
|
||||
import { setupFacebookSdk } from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
|
||||
|
||||
// Page-management + messaging scopes required to list pages and create a
|
||||
// Channel::FacebookPage inbox (mirrors the standalone settings flow).
|
||||
const FB_PAGE_SCOPES =
|
||||
'pages_manage_metadata,business_management,pages_messaging,instagram_basic,pages_show_list,pages_read_engagement,instagram_manage_messages';
|
||||
|
||||
// Headless half of the Facebook Page connect flow: load the Meta SDK, run
|
||||
// FB.login for page scopes, and fetch the user's pages. The caller owns the
|
||||
// page-picker UI and the channel creation, because choosing a page is an
|
||||
// interactive step (a user can manage several pages).
|
||||
//
|
||||
// Split into preloadSdk() + loginAndFetchPages() for popup safety: FB.login
|
||||
// opens a popup and needs the click's transient activation. Preloading the SDK
|
||||
// when the picker opens means the click-time `await` resolves within that
|
||||
// activation window; a cold load resolves on the script's `load` task seconds
|
||||
// later, after activation has expired, and the popup gets blocked.
|
||||
export function useFacebookPageConnect() {
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
const isAuthenticating = ref(false);
|
||||
|
||||
let sdkSetupPromise = null;
|
||||
|
||||
// Idempotent — call this when the picker UI opens. A failed load clears the
|
||||
// cache so a later attempt can retry instead of being stuck on a rejection.
|
||||
const preloadSdk = () => {
|
||||
if (!sdkSetupPromise) {
|
||||
sdkSetupPromise = setupFacebookSdk(
|
||||
window.chatwootConfig?.fbAppId,
|
||||
window.chatwootConfig?.fbApiVersion
|
||||
).catch(error => {
|
||||
sdkSetupPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return sdkSetupPromise;
|
||||
};
|
||||
|
||||
// FB.login never rejects; resolve the user access token on success and null
|
||||
// for any other status (closed popup, not_authorized, unknown).
|
||||
const login = () =>
|
||||
new Promise(resolve => {
|
||||
window.FB.login(
|
||||
response => {
|
||||
resolve(
|
||||
response.status === 'connected'
|
||||
? response.authResponse?.accessToken || null
|
||||
: null
|
||||
);
|
||||
},
|
||||
{ scope: FB_PAGE_SCOPES }
|
||||
);
|
||||
});
|
||||
|
||||
// Resolves { userAccessToken, pages } on success, null when the user cancels,
|
||||
// and rejects on SDK-load or page-fetch failure (the caller maps it to UI).
|
||||
const loginAndFetchPages = async () => {
|
||||
if (isAuthenticating.value) return null;
|
||||
isAuthenticating.value = true;
|
||||
try {
|
||||
await preloadSdk();
|
||||
const token = await login();
|
||||
if (!token) return null;
|
||||
|
||||
const response = await ChannelApi.fetchFacebookPages(
|
||||
token,
|
||||
accountId.value
|
||||
);
|
||||
const { page_details: pages, user_access_token: userAccessToken } =
|
||||
response.data.data;
|
||||
return { userAccessToken, pages };
|
||||
} finally {
|
||||
isAuthenticating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
return { isAuthenticating, preloadSdk, loginAndFetchPages };
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
setupFacebookSdk,
|
||||
initWhatsAppEmbeddedSignup,
|
||||
createMessageHandler,
|
||||
isValidBusinessData,
|
||||
} from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
|
||||
|
||||
// Drives Meta's WhatsApp embedded-signup popup (Facebook JS SDK). FB.login()
|
||||
// resolves an auth `code` while the WABA identifiers (waba_id, phone_number_id)
|
||||
// arrive separately over a postMessage event — order isn't guaranteed, so we
|
||||
// hold both and resolve once both are present.
|
||||
//
|
||||
// `runEmbeddedSignup` returns the signup credentials; the caller exchanges them
|
||||
// for an inbox via `inboxes/createWhatsAppEmbeddedSignup` and owns its own UX
|
||||
// (alerts, navigation, etc). Resolves `null` when the user cancels the popup;
|
||||
// rejects on SDK load or signup errors. The window listener is scoped to a
|
||||
// single run, so this is safe to call from anywhere without lifecycle wiring.
|
||||
export function useWhatsappEmbeddedSignup() {
|
||||
const isAuthenticating = ref(false);
|
||||
|
||||
const runEmbeddedSignup = () => {
|
||||
if (isAuthenticating.value) return Promise.resolve(null);
|
||||
isAuthenticating.value = true;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let authCode = null;
|
||||
let businessData = null;
|
||||
let settled = false;
|
||||
let messageHandler;
|
||||
|
||||
const settle = (fn, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
window.removeEventListener('message', messageHandler);
|
||||
isAuthenticating.value = false;
|
||||
fn(value);
|
||||
};
|
||||
|
||||
// Both the auth code and the business data arrive asynchronously and in
|
||||
// no fixed order; only resolve once we're holding both.
|
||||
const resolveIfReady = () => {
|
||||
if (!authCode || !businessData) return;
|
||||
settle(resolve, {
|
||||
code: authCode,
|
||||
business_id: businessData.business_id,
|
||||
waba_id: businessData.waba_id,
|
||||
phone_number_id: businessData.phone_number_id || '',
|
||||
});
|
||||
};
|
||||
|
||||
messageHandler = createMessageHandler(data => {
|
||||
if (
|
||||
data.event === 'FINISH' ||
|
||||
data.event === 'FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING'
|
||||
) {
|
||||
if (!isValidBusinessData(data.data)) {
|
||||
settle(reject, new Error('Invalid business data'));
|
||||
return;
|
||||
}
|
||||
businessData = data.data;
|
||||
resolveIfReady();
|
||||
} else if (data.event === 'CANCEL') {
|
||||
settle(resolve, null);
|
||||
} else if (data.event === 'error') {
|
||||
settle(reject, new Error(data.error_message || 'Signup error'));
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('message', messageHandler);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await setupFacebookSdk(
|
||||
window.chatwootConfig?.whatsappAppId,
|
||||
window.chatwootConfig?.whatsappApiVersion
|
||||
);
|
||||
authCode = await initWhatsAppEmbeddedSignup(
|
||||
window.chatwootConfig?.whatsappConfigurationId
|
||||
);
|
||||
resolveIfReady();
|
||||
} catch (error) {
|
||||
// FB.login() rejects with 'Login cancelled' when the user dismisses
|
||||
// the popup — treat it as a cancel rather than an error.
|
||||
if (error.message === 'Login cancelled') {
|
||||
settle(resolve, null);
|
||||
} else {
|
||||
settle(reject, error);
|
||||
}
|
||||
}
|
||||
})();
|
||||
});
|
||||
};
|
||||
|
||||
return { isAuthenticating, runEmbeddedSignup };
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export const FORMATTING = {
|
||||
'link',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'imageUpload',
|
||||
'undo',
|
||||
'redo',
|
||||
],
|
||||
@@ -30,6 +31,7 @@ export const FORMATTING = {
|
||||
'strike',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'imageUpload',
|
||||
'undo',
|
||||
'redo',
|
||||
],
|
||||
@@ -263,23 +265,3 @@ export const MARKDOWN_PATTERNS = [
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Editor image resize options for Message Editor
|
||||
export const MESSAGE_EDITOR_IMAGE_RESIZES = [
|
||||
{
|
||||
name: 'Small',
|
||||
height: '24px',
|
||||
},
|
||||
{
|
||||
name: 'Medium',
|
||||
height: '48px',
|
||||
},
|
||||
{
|
||||
name: 'Large',
|
||||
height: '72px',
|
||||
},
|
||||
{
|
||||
name: 'Original Size',
|
||||
height: 'auto',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -46,6 +46,7 @@ export const FEATURE_FLAGS = {
|
||||
COMPANIES: 'companies',
|
||||
ADVANCED_SEARCH: 'advanced_search',
|
||||
CONVERSATION_REQUIRED_ATTRIBUTES: 'conversation_required_attributes',
|
||||
CONVERSATION_UNREAD_COUNTS: 'conversation_unread_counts',
|
||||
};
|
||||
|
||||
export const PREMIUM_FEATURES = [
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { openDB } from 'idb';
|
||||
import { DATA_VERSION } from './version';
|
||||
import { cacheableModels, cacheableModelNames } from './cacheableModels';
|
||||
|
||||
export class DataManager {
|
||||
constructor(accountId) {
|
||||
this.modelsToSync = ['inbox', 'label', 'team'];
|
||||
this.modelsToSync = cacheableModelNames;
|
||||
this.accountId = accountId;
|
||||
this.db = null;
|
||||
}
|
||||
@@ -11,12 +12,26 @@ export class DataManager {
|
||||
async initDb() {
|
||||
if (this.db) return this.db;
|
||||
const dbName = `cw-store-${this.accountId}`;
|
||||
this.db = await openDB(`cw-store-${this.accountId}`, DATA_VERSION, {
|
||||
upgrade(db) {
|
||||
db.createObjectStore('cache-keys');
|
||||
db.createObjectStore('inbox', { keyPath: 'id' });
|
||||
db.createObjectStore('label', { keyPath: 'id' });
|
||||
db.createObjectStore('team', { keyPath: 'id' });
|
||||
this.db = await openDB(dbName, DATA_VERSION, {
|
||||
upgrade(db, oldVersion, _newVersion, tx) {
|
||||
// Flush data carried over from a previous schema version so a
|
||||
// DATA_VERSION bump acts as a global cache reset. oldVersion === 0 on
|
||||
// first install, so fresh devices skip this. Clearing before creating
|
||||
// means we only ever clear stores that pre-existed this upgrade.
|
||||
if (oldVersion > 0) {
|
||||
for (let index = 0; index < db.objectStoreNames.length; index += 1) {
|
||||
tx.objectStore(db.objectStoreNames.item(index)).clear();
|
||||
}
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains('cache-keys')) {
|
||||
db.createObjectStore('cache-keys');
|
||||
}
|
||||
cacheableModels.forEach(model => {
|
||||
if (!db.objectStoreNames.contains(model.name)) {
|
||||
db.createObjectStore(model.name, { keyPath: 'id' });
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -41,7 +56,7 @@ export class DataManager {
|
||||
async replace({ modelName, data }) {
|
||||
this.validateModel(modelName);
|
||||
|
||||
this.db.clear(modelName);
|
||||
await this.db.clear(modelName);
|
||||
return this.push({ modelName, data });
|
||||
}
|
||||
|
||||
@@ -65,9 +80,11 @@ export class DataManager {
|
||||
}
|
||||
|
||||
async setCacheKeys(cacheKeys) {
|
||||
Object.keys(cacheKeys).forEach(async modelName => {
|
||||
this.db.put('cache-keys', cacheKeys[modelName], modelName);
|
||||
});
|
||||
await Promise.all(
|
||||
Object.entries(cacheKeys).map(([modelName, value]) =>
|
||||
this.db.put('cache-keys', value, modelName)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async getCacheKey(modelName) {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// Single source of truth for IDB-cached workspace config.
|
||||
//
|
||||
// Each entry must keep `name` equal to the Rails `Model.name.underscore` value
|
||||
// so the server's `cache_keys` payload (and the IDB object store name) lines up
|
||||
// with what the client looks up.
|
||||
//
|
||||
// `setMutation` is the full commit path used to seed Vuex from IDB (boot
|
||||
// paint) and to swap in refetched rows (event-driven revalidation). Every
|
||||
// SET_* mutation must REPLACE its records (not merge) so rows deleted
|
||||
// server-side never survive as phantoms.
|
||||
export const cacheableModels = [
|
||||
{ name: 'inbox', setMutation: 'inboxes/SET_INBOXES' },
|
||||
{ name: 'label', setMutation: 'labels/SET_LABELS' },
|
||||
{ name: 'team', setMutation: 'teams/SET_TEAMS' },
|
||||
{ name: 'canned_response', setMutation: 'SET_CANNED' },
|
||||
{ name: 'account_user', setMutation: 'agents/SET_AGENTS' },
|
||||
{
|
||||
name: 'custom_attribute_definition',
|
||||
setMutation: 'attributes/SET_CUSTOM_ATTRIBUTE',
|
||||
},
|
||||
];
|
||||
|
||||
export const cacheableModelNames = cacheableModels.map(model => model.name);
|
||||
@@ -0,0 +1,42 @@
|
||||
import AgentAPI from 'dashboard/api/agents';
|
||||
import AttributeAPI from 'dashboard/api/attributes';
|
||||
import CannedResponseAPI from 'dashboard/api/cannedResponse';
|
||||
import InboxesAPI from 'dashboard/api/inboxes';
|
||||
import LabelsAPI from 'dashboard/api/labels';
|
||||
import TeamsAPI from 'dashboard/api/teams';
|
||||
import { cacheableModels } from './cacheableModels';
|
||||
|
||||
// model name → cache-enabled API client. Lives here rather than in
|
||||
// cacheableModels to keep that module import-cycle-free: the API clients
|
||||
// import DataManager, which imports cacheableModels.
|
||||
const apiByModel = {
|
||||
inbox: InboxesAPI,
|
||||
label: LabelsAPI,
|
||||
team: TeamsAPI,
|
||||
canned_response: CannedResponseAPI,
|
||||
account_user: AgentAPI,
|
||||
custom_attribute_definition: AttributeAPI,
|
||||
};
|
||||
|
||||
const revalidateModel = async (store, model, newKey) => {
|
||||
try {
|
||||
const api = apiByModel[model.name];
|
||||
if (await api.validateCacheKey(newKey)) return;
|
||||
|
||||
const response = await api.refetchAndCommit(newKey);
|
||||
store.commit(model.setMutation, api.extractDataFromResponse(response));
|
||||
} catch {
|
||||
// Ignore error — a failed refetch leaves the painted data in place; the
|
||||
// next pushed key map retries.
|
||||
}
|
||||
};
|
||||
|
||||
// The single freshness engine: given a pushed { model_name => key } map
|
||||
// (RoomChannel transmits one on every (re)subscribe, the server broadcasts
|
||||
// one on every change), diff each key against IDB and refetch mismatches.
|
||||
export const dispatchCacheRevalidations = (store, keys = {}) =>
|
||||
Promise.all(
|
||||
cacheableModels
|
||||
.filter(model => keys[model.name] !== undefined)
|
||||
.map(model => revalidateModel(store, model, keys[model.name]))
|
||||
);
|
||||
@@ -0,0 +1,31 @@
|
||||
import { DataManager } from './DataManager';
|
||||
import { cacheableModels } from './cacheableModels';
|
||||
|
||||
// Seed Vuex from IndexedDB before the dashboard renders so warm boots paint
|
||||
// cached config instantly. This is purely local — zero network calls.
|
||||
//
|
||||
// Freshness is handled entirely by the account.cache_invalidated event:
|
||||
// RoomChannel transmits the current cache-key map on every (re)subscribe, and
|
||||
// the server broadcasts it on every change. dispatchCacheRevalidations diffs
|
||||
// those keys against IDB and refetches mismatches — the client never pulls
|
||||
// cache keys itself.
|
||||
export default async function paintStoresFromCache(store, accountId) {
|
||||
let dm;
|
||||
try {
|
||||
dm = new DataManager(accountId);
|
||||
await dm.initDb();
|
||||
} catch {
|
||||
// IDB unsupported (e.g. Firefox private mode) — silent no-op. Components
|
||||
// will fetch from the network normally via the cache-enabled API client.
|
||||
return;
|
||||
}
|
||||
|
||||
// Stale-while-revalidate paint: commit cached data into Vuex immediately.
|
||||
await Promise.all(
|
||||
cacheableModels.map(async model => {
|
||||
const localData = await dm.get({ modelName: model.name });
|
||||
if (localData.length === 0) return;
|
||||
store.commit(model.setMutation, localData);
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
// Monday, 13 March 2023
|
||||
// Change this version if you want to invalidate old data
|
||||
export const DATA_VERSION = '1678706392';
|
||||
// Bump DATA_VERSION to (a) add new object stores to the IDB schema or (b)
|
||||
// flush bad/stale cache globally. The `upgrade()` callback in DataManager runs
|
||||
// only when the stored DB version is less than the requested version; on any
|
||||
// such bump it clears every existing store (a full cache reset) and then
|
||||
// idempotently creates any missing stores. So bump this whenever a cached
|
||||
// model's serializer shape changes, or to force all clients to refetch.
|
||||
//
|
||||
// Thursday, 28 May 2026 — bumped to add canned_response + account_user stores + custom_attribute_definition store
|
||||
export const DATA_VERSION = '1748390400';
|
||||
|
||||
@@ -98,17 +98,6 @@ class ReconnectService {
|
||||
await this.store.dispatch('notifications/index', { ...filter, page: 1 });
|
||||
};
|
||||
|
||||
revalidateCaches = async () => {
|
||||
const { label, inbox, team } = await this.store.dispatch(
|
||||
'accounts/getCacheKeys'
|
||||
);
|
||||
await Promise.all([
|
||||
this.store.dispatch('labels/revalidate', { newKey: label }),
|
||||
this.store.dispatch('inboxes/revalidate', { newKey: inbox }),
|
||||
this.store.dispatch('teams/revalidate', { newKey: team }),
|
||||
]);
|
||||
};
|
||||
|
||||
handleRouteSpecificFetch = async () => {
|
||||
const currentRoute = this.router.currentRoute.value.name;
|
||||
if (isAConversationRoute(currentRoute, true)) {
|
||||
@@ -138,9 +127,11 @@ class ReconnectService {
|
||||
this.setConversationLastMessageId();
|
||||
};
|
||||
|
||||
// Cached workspace config needs no explicit revalidation here: ActionCable
|
||||
// auto-resubscribes after a drop, and RoomChannel pushes the cache-key map
|
||||
// on every subscribe via the account.cache_invalidated event.
|
||||
onReconnect = async () => {
|
||||
await this.handleRouteSpecificFetch();
|
||||
await this.revalidateCaches();
|
||||
emitter.emit(BUS_EVENTS.WEBSOCKET_RECONNECT_COMPLETED);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
} from 'dashboard/composables/useWhatsappCallSession';
|
||||
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
|
||||
import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { dispatchCacheRevalidations } from './CacheHelper/dispatchCacheRevalidations';
|
||||
|
||||
const { isImpersonating } = useImpersonation();
|
||||
const UNREAD_COUNTS_REFETCH_THROTTLE_MS = 5000;
|
||||
@@ -171,10 +173,23 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
};
|
||||
|
||||
fetchConversationUnreadCounts = () => {
|
||||
if (!this.isConversationUnreadCountsEnabled()) return;
|
||||
|
||||
this.lastUnreadCountsFetchAt = Date.now();
|
||||
this.app.$store.dispatch('conversationUnreadCounts/get');
|
||||
};
|
||||
|
||||
isConversationUnreadCountsEnabled = () => {
|
||||
const accountId = this.app.$store.getters.getCurrentAccountId;
|
||||
const isFeatureEnabled =
|
||||
this.app.$store.getters['accounts/isFeatureEnabledonAccount'];
|
||||
|
||||
return isFeatureEnabled?.(
|
||||
accountId,
|
||||
FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
|
||||
);
|
||||
};
|
||||
|
||||
onTypingOn = ({ conversation, user }) => {
|
||||
const conversationId = conversation.id;
|
||||
|
||||
@@ -255,10 +270,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
};
|
||||
|
||||
onCacheInvalidate = data => {
|
||||
const keys = data.cache_keys;
|
||||
this.app.$store.dispatch('labels/revalidate', { newKey: keys.label });
|
||||
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
|
||||
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
|
||||
dispatchCacheRevalidations(this.app.$store, data.cache_keys);
|
||||
};
|
||||
|
||||
onVoiceCallIncoming = data => {
|
||||
|
||||
@@ -37,6 +37,13 @@ export const getValuesName = (values, list, idKey, nameKey) => {
|
||||
};
|
||||
};
|
||||
|
||||
const getValuesForContact = (values, contacts) => ({
|
||||
id: values[0],
|
||||
name:
|
||||
contacts?.find(contact => contact.id === values[0])?.name ||
|
||||
`Contact #${values[0]}`,
|
||||
});
|
||||
|
||||
export const getValuesForStatus = values => {
|
||||
return values.map(value => ({ id: value, name: value }));
|
||||
};
|
||||
@@ -84,6 +91,7 @@ export const getValuesForFilter = (filter, params) => {
|
||||
campaigns,
|
||||
labels,
|
||||
priority,
|
||||
contacts,
|
||||
} = params;
|
||||
switch (attribute_key) {
|
||||
case 'status':
|
||||
@@ -94,6 +102,8 @@ export const getValuesForFilter = (filter, params) => {
|
||||
return getValuesName(values, inboxes, 'id', 'name');
|
||||
case 'team_id':
|
||||
return getValuesName(values, teams, 'id', 'name');
|
||||
case 'contact_id':
|
||||
return getValuesForContact(values, contacts);
|
||||
case 'campaign_id':
|
||||
return getValuesName(values, campaigns, 'id', 'title');
|
||||
case 'labels':
|
||||
|
||||
@@ -379,31 +379,6 @@ export const findNodeToInsertImage = (editorState, fileUrl) => {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Set URL with query and size.
|
||||
*
|
||||
* @param {Object} selectedImageNode - The current selected node.
|
||||
* @param {Object} size - The size to set.
|
||||
* @param {Object} editorView - The editor view.
|
||||
*/
|
||||
export function setURLWithQueryAndSize(selectedImageNode, size, editorView) {
|
||||
if (selectedImageNode) {
|
||||
// Create and apply the transaction
|
||||
const tr = editorView.state.tr.setNodeMarkup(
|
||||
editorView.state.selection.from,
|
||||
null,
|
||||
{
|
||||
src: selectedImageNode.src,
|
||||
height: size.height,
|
||||
}
|
||||
);
|
||||
|
||||
if (tr.docChanged) {
|
||||
editorView.dispatch(tr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips unsupported markdown formatting from content based on the editor schema.
|
||||
* This ensures canned responses with rich formatting can be inserted into channels
|
||||
|
||||
@@ -159,6 +159,13 @@ export const LOCALE_MENU_ITEMS = {
|
||||
value: 'publish',
|
||||
icon: 'i-lucide-eye',
|
||||
},
|
||||
customizeContent: {
|
||||
label:
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.CUSTOMIZE_CONTENT',
|
||||
action: 'customize-content',
|
||||
value: 'customize-content',
|
||||
icon: 'i-lucide-pencil',
|
||||
},
|
||||
delete: {
|
||||
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE',
|
||||
action: 'delete',
|
||||
@@ -172,20 +179,28 @@ const disableLocaleMenuItems = menuItems =>
|
||||
|
||||
export const buildLocaleMenuItems = ({ isDefault, isDraft }) => {
|
||||
if (isDefault) {
|
||||
return disableLocaleMenuItems([
|
||||
LOCALE_MENU_ITEMS.makeDefault,
|
||||
LOCALE_MENU_ITEMS.moveToDraft,
|
||||
LOCALE_MENU_ITEMS.delete,
|
||||
]);
|
||||
return [
|
||||
...disableLocaleMenuItems([
|
||||
LOCALE_MENU_ITEMS.makeDefault,
|
||||
LOCALE_MENU_ITEMS.moveToDraft,
|
||||
]),
|
||||
LOCALE_MENU_ITEMS.customizeContent,
|
||||
...disableLocaleMenuItems([LOCALE_MENU_ITEMS.delete]),
|
||||
];
|
||||
}
|
||||
|
||||
if (isDraft) {
|
||||
return [LOCALE_MENU_ITEMS.publishLocale, LOCALE_MENU_ITEMS.delete];
|
||||
return [
|
||||
LOCALE_MENU_ITEMS.publishLocale,
|
||||
LOCALE_MENU_ITEMS.customizeContent,
|
||||
LOCALE_MENU_ITEMS.delete,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
LOCALE_MENU_ITEMS.makeDefault,
|
||||
LOCALE_MENU_ITEMS.moveToDraft,
|
||||
LOCALE_MENU_ITEMS.customizeContent,
|
||||
LOCALE_MENU_ITEMS.delete,
|
||||
];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { dispatchCacheRevalidations } from '../../CacheHelper/dispatchCacheRevalidations';
|
||||
import InboxesAPI from 'dashboard/api/inboxes';
|
||||
import LabelsAPI from 'dashboard/api/labels';
|
||||
import CannedResponseAPI from 'dashboard/api/cannedResponse';
|
||||
import TeamsAPI from 'dashboard/api/teams';
|
||||
|
||||
vi.mock('dashboard/api/inboxes', () => ({
|
||||
default: {
|
||||
validateCacheKey: vi.fn(),
|
||||
refetchAndCommit: vi.fn(),
|
||||
extractDataFromResponse: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('dashboard/api/labels', () => ({
|
||||
default: {
|
||||
validateCacheKey: vi.fn(),
|
||||
refetchAndCommit: vi.fn(),
|
||||
extractDataFromResponse: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('dashboard/api/teams', () => ({
|
||||
default: {
|
||||
validateCacheKey: vi.fn(),
|
||||
refetchAndCommit: vi.fn(),
|
||||
extractDataFromResponse: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('dashboard/api/cannedResponse', () => ({
|
||||
default: {
|
||||
validateCacheKey: vi.fn(),
|
||||
refetchAndCommit: vi.fn(),
|
||||
extractDataFromResponse: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('dashboard/api/agents', () => ({
|
||||
default: {
|
||||
validateCacheKey: vi.fn(),
|
||||
refetchAndCommit: vi.fn(),
|
||||
extractDataFromResponse: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('dashboard/api/attributes', () => ({
|
||||
default: {
|
||||
validateCacheKey: vi.fn(),
|
||||
refetchAndCommit: vi.fn(),
|
||||
extractDataFromResponse: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('dispatchCacheRevalidations', () => {
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
store = { commit: vi.fn() };
|
||||
});
|
||||
|
||||
it('refetches stale models and commits via their setMutation', async () => {
|
||||
InboxesAPI.validateCacheKey.mockResolvedValue(false);
|
||||
InboxesAPI.refetchAndCommit.mockResolvedValue({ data: { payload: [] } });
|
||||
InboxesAPI.extractDataFromResponse.mockReturnValue([{ id: 1 }]);
|
||||
LabelsAPI.validateCacheKey.mockResolvedValue(true);
|
||||
|
||||
await dispatchCacheRevalidations(store, {
|
||||
inbox: 'inbox-key',
|
||||
label: 'label-key',
|
||||
});
|
||||
|
||||
expect(InboxesAPI.refetchAndCommit).toHaveBeenCalledWith('inbox-key');
|
||||
expect(store.commit).toHaveBeenCalledWith('inboxes/SET_INBOXES', [
|
||||
{ id: 1 },
|
||||
]);
|
||||
expect(LabelsAPI.refetchAndCommit).not.toHaveBeenCalled();
|
||||
expect(store.commit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('skips models absent from the key payload', async () => {
|
||||
InboxesAPI.validateCacheKey.mockResolvedValue(true);
|
||||
|
||||
await dispatchCacheRevalidations(store, { inbox: 'inbox-key' });
|
||||
|
||||
expect(TeamsAPI.validateCacheKey).not.toHaveBeenCalled();
|
||||
expect(store.commit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats missing keys as an empty payload', async () => {
|
||||
await dispatchCacheRevalidations(store);
|
||||
|
||||
expect(InboxesAPI.validateCacheKey).not.toHaveBeenCalled();
|
||||
expect(store.commit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows per-model errors so one failure does not block the rest', async () => {
|
||||
InboxesAPI.validateCacheKey.mockResolvedValue(false);
|
||||
InboxesAPI.refetchAndCommit.mockRejectedValue(new Error('network down'));
|
||||
CannedResponseAPI.validateCacheKey.mockResolvedValue(false);
|
||||
CannedResponseAPI.refetchAndCommit.mockResolvedValue({ data: [] });
|
||||
CannedResponseAPI.extractDataFromResponse.mockReturnValue([{ id: 7 }]);
|
||||
|
||||
await dispatchCacheRevalidations(store, {
|
||||
inbox: 'inbox-key',
|
||||
canned_response: 'canned-key',
|
||||
});
|
||||
|
||||
expect(store.commit).toHaveBeenCalledWith('SET_CANNED', [{ id: 7 }]);
|
||||
expect(store.commit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import paintStoresFromCache from '../../CacheHelper/paintStoresFromCache';
|
||||
import { DataManager } from '../../CacheHelper/DataManager';
|
||||
|
||||
describe('paintStoresFromCache', () => {
|
||||
const accountId = 'paint-test-account';
|
||||
const originalAxios = window.axios;
|
||||
let axiosMock;
|
||||
let dm;
|
||||
let storeMock;
|
||||
|
||||
beforeEach(async () => {
|
||||
axiosMock = {
|
||||
get: vi.fn(),
|
||||
};
|
||||
window.axios = axiosMock;
|
||||
|
||||
storeMock = {
|
||||
commit: vi.fn(),
|
||||
dispatch: vi.fn(),
|
||||
};
|
||||
|
||||
dm = new DataManager(accountId);
|
||||
await dm.initDb();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const tx = dm.db.transaction(
|
||||
[...dm.modelsToSync, 'cache-keys'],
|
||||
'readwrite'
|
||||
);
|
||||
[...dm.modelsToSync, 'cache-keys'].forEach(name => {
|
||||
tx.objectStore(name).clear();
|
||||
});
|
||||
await tx.done;
|
||||
window.axios = originalAxios;
|
||||
});
|
||||
|
||||
it('does nothing when IDB is empty (first ever load)', async () => {
|
||||
await paintStoresFromCache(storeMock, accountId);
|
||||
|
||||
expect(storeMock.commit).not.toHaveBeenCalled();
|
||||
expect(storeMock.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('seeds Vuex from IDB without any network interaction', async () => {
|
||||
await dm.push({
|
||||
modelName: 'inbox',
|
||||
data: [{ id: 1, name: 'Support' }],
|
||||
});
|
||||
await dm.push({
|
||||
modelName: 'label',
|
||||
data: [{ id: 9, title: 'Bug' }],
|
||||
});
|
||||
|
||||
await paintStoresFromCache(storeMock, accountId);
|
||||
|
||||
expect(storeMock.commit).toHaveBeenCalledWith('inboxes/SET_INBOXES', [
|
||||
{ id: 1, name: 'Support' },
|
||||
]);
|
||||
expect(storeMock.commit).toHaveBeenCalledWith('labels/SET_LABELS', [
|
||||
{ id: 9, title: 'Bug' },
|
||||
]);
|
||||
expect(axiosMock.get).not.toHaveBeenCalled();
|
||||
expect(storeMock.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('seeds teams via SET_TEAMS', async () => {
|
||||
await dm.push({
|
||||
modelName: 'team',
|
||||
data: [{ id: 1, name: 'Sales' }],
|
||||
});
|
||||
|
||||
await paintStoresFromCache(storeMock, accountId);
|
||||
|
||||
expect(storeMock.commit).toHaveBeenCalledWith('teams/SET_TEAMS', [
|
||||
{ id: 1, name: 'Sales' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -253,27 +253,6 @@ describe('ReconnectService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('revalidateCaches', () => {
|
||||
it('should dispatch revalidate actions for labels, inboxes, and teams', async () => {
|
||||
storeMock.dispatch.mockResolvedValueOnce({
|
||||
label: 'labelKey',
|
||||
inbox: 'inboxKey',
|
||||
team: 'teamKey',
|
||||
});
|
||||
await reconnectService.revalidateCaches();
|
||||
expect(storeMock.dispatch).toHaveBeenCalledWith('accounts/getCacheKeys');
|
||||
expect(storeMock.dispatch).toHaveBeenCalledWith('labels/revalidate', {
|
||||
newKey: 'labelKey',
|
||||
});
|
||||
expect(storeMock.dispatch).toHaveBeenCalledWith('inboxes/revalidate', {
|
||||
newKey: 'inboxKey',
|
||||
});
|
||||
expect(storeMock.dispatch).toHaveBeenCalledWith('teams/revalidate', {
|
||||
newKey: 'teamKey',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleRouteSpecificFetch', () => {
|
||||
it('should fetch conversations and messages if current route is a conversation route', async () => {
|
||||
isAConversationRoute.mockReturnValue(true);
|
||||
@@ -335,12 +314,10 @@ describe('ReconnectService', () => {
|
||||
});
|
||||
|
||||
describe('onReconnect', () => {
|
||||
it('should handle route-specific fetch, revalidate caches, and emit WEBSOCKET_RECONNECT_COMPLETED event', async () => {
|
||||
it('should handle route-specific fetch and emit WEBSOCKET_RECONNECT_COMPLETED event', async () => {
|
||||
reconnectService.handleRouteSpecificFetch = vi.fn();
|
||||
reconnectService.revalidateCaches = vi.fn();
|
||||
await reconnectService.onReconnect();
|
||||
expect(reconnectService.handleRouteSpecificFetch).toHaveBeenCalled();
|
||||
expect(reconnectService.revalidateCaches).toHaveBeenCalled();
|
||||
expect(emitter.emit).toHaveBeenCalledWith(
|
||||
BUS_EVENTS.WEBSOCKET_RECONNECT_COMPLETED
|
||||
);
|
||||
|
||||
@@ -30,6 +30,7 @@ describe('ActionCableConnector - Copilot Tests', () => {
|
||||
dispatch: mockDispatch,
|
||||
getters: {
|
||||
getCurrentAccountId: 1,
|
||||
'accounts/isFeatureEnabledonAccount': vi.fn(() => true),
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -88,6 +89,21 @@ describe('ActionCableConnector - Copilot Tests', () => {
|
||||
expect(mockDispatch).toHaveBeenCalledWith('conversationUnreadCounts/get');
|
||||
});
|
||||
|
||||
it('does not refetch unread counts when unread count feature is disabled', () => {
|
||||
store.$store.getters[
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
].mockReturnValue(false);
|
||||
|
||||
actionCable.onReceived({
|
||||
event: 'conversation.unread_count_changed',
|
||||
data: { account_id: 1 },
|
||||
});
|
||||
|
||||
expect(mockDispatch).not.toHaveBeenCalledWith(
|
||||
'conversationUnreadCounts/get'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throttle unread count refetches for repeated events', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
|
||||
|
||||
@@ -273,6 +273,41 @@ describe('customViewsHelper', () => {
|
||||
};
|
||||
expect(generateValuesForEditCustomViews(filter, params)).toEqual('1');
|
||||
});
|
||||
|
||||
it('returns contact name for contact filters when contact is available', () => {
|
||||
const filter = {
|
||||
attribute_key: 'contact_id',
|
||||
filter_operator: 'equal_to',
|
||||
values: [123],
|
||||
};
|
||||
const params = {
|
||||
contacts: [{ id: 123, name: 'John Doe' }],
|
||||
filterTypes: advancedFilterTypes,
|
||||
allCustomAttributes: [],
|
||||
};
|
||||
|
||||
expect(generateValuesForEditCustomViews(filter, params)).toEqual({
|
||||
id: 123,
|
||||
name: 'John Doe',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns fallback contact display value when contact is not available', () => {
|
||||
const filter = {
|
||||
attribute_key: 'contact_id',
|
||||
filter_operator: 'equal_to',
|
||||
values: [123],
|
||||
};
|
||||
const params = {
|
||||
filterTypes: advancedFilterTypes,
|
||||
allCustomAttributes: [],
|
||||
};
|
||||
|
||||
expect(generateValuesForEditCustomViews(filter, params)).toEqual({
|
||||
id: 123,
|
||||
name: 'Contact #123',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#generateCustomAttributesInputType', () => {
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
insertAtCursor,
|
||||
removeSignature,
|
||||
replaceSignature,
|
||||
setURLWithQueryAndSize,
|
||||
stripInlineBase64Images,
|
||||
stripUnsupportedFormatting,
|
||||
stripUnsupportedMarkdown,
|
||||
@@ -653,71 +652,6 @@ describe('findNodeToInsertImage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('setURLWithQueryAndSize', () => {
|
||||
let selectedNode;
|
||||
let editorView;
|
||||
|
||||
beforeEach(() => {
|
||||
selectedNode = {
|
||||
setAttribute: vi.fn(),
|
||||
};
|
||||
|
||||
const tr = {
|
||||
setNodeMarkup: vi.fn().mockReturnValue({
|
||||
docChanged: true,
|
||||
}),
|
||||
};
|
||||
|
||||
const state = {
|
||||
selection: { from: 0 },
|
||||
tr,
|
||||
};
|
||||
|
||||
editorView = {
|
||||
state,
|
||||
dispatch: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
it('updates the URL with the given size and updates the editor view', () => {
|
||||
const size = { height: '20px' };
|
||||
|
||||
setURLWithQueryAndSize(selectedNode, size, editorView);
|
||||
|
||||
// Check if the editor view is updated
|
||||
expect(editorView.dispatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('updates the URL with the given size and updates the editor view with original size', () => {
|
||||
const size = { height: 'auto' };
|
||||
|
||||
setURLWithQueryAndSize(selectedNode, size, editorView);
|
||||
|
||||
// Check if the editor view is updated
|
||||
expect(editorView.dispatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not update the editor view if the document has not changed', () => {
|
||||
editorView.state.tr.setNodeMarkup = vi.fn().mockReturnValue({
|
||||
docChanged: false,
|
||||
});
|
||||
|
||||
const size = { height: '20px' };
|
||||
|
||||
setURLWithQueryAndSize(selectedNode, size, editorView);
|
||||
|
||||
// Check if the editor view dispatch was not called
|
||||
expect(editorView.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not perform any operations if selectedNode is not provided', () => {
|
||||
setURLWithQueryAndSize(null, { height: '20px' }, editorView);
|
||||
|
||||
// Ensure the dispatch method wasn't called
|
||||
expect(editorView.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContentNode', () => {
|
||||
let mockEditorView;
|
||||
|
||||
|
||||
@@ -64,4 +64,25 @@ describe('#filterQueryGenerator', () => {
|
||||
filterQueryGenerator(testData).payload.every(i => Array.isArray(i.values))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('serializes a selected contact object to contact id', () => {
|
||||
const result = filterQueryGenerator([
|
||||
{
|
||||
attribute_key: 'contact_id',
|
||||
filter_operator: 'equal_to',
|
||||
values: { id: 123, name: 'Jane Doe' },
|
||||
query_operator: 'and',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
payload: [
|
||||
{
|
||||
attribute_key: 'contact_id',
|
||||
filter_operator: 'equal_to',
|
||||
values: [123],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,37 +74,40 @@ describe('PortalHelper', () => {
|
||||
});
|
||||
|
||||
describe('buildLocaleMenuItems', () => {
|
||||
it('returns disabled actions for the default locale', () => {
|
||||
it('disables other actions but keeps customize enabled for the default locale', () => {
|
||||
const items = buildLocaleMenuItems({ isDefault: true, isDraft: false });
|
||||
const customize = items.find(item => item.action === 'customize-content');
|
||||
|
||||
expect(customize).toBeTruthy();
|
||||
expect(customize.disabled).toBeFalsy();
|
||||
expect(
|
||||
buildLocaleMenuItems({
|
||||
isDefault: true,
|
||||
isDraft: false,
|
||||
})
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ action: 'change-default', disabled: true }),
|
||||
expect.objectContaining({ action: 'move-to-draft', disabled: true }),
|
||||
expect.objectContaining({ action: 'delete', disabled: true }),
|
||||
])
|
||||
);
|
||||
items
|
||||
.filter(item => item.action !== 'customize-content')
|
||||
.every(item => item.disabled)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns publish and delete actions for draft locales', () => {
|
||||
it('returns publish, customize, and delete actions for draft locales', () => {
|
||||
expect(
|
||||
buildLocaleMenuItems({
|
||||
isDefault: false,
|
||||
isDraft: true,
|
||||
}).map(({ action }) => action)
|
||||
).toEqual(['publish-locale', 'delete']);
|
||||
).toEqual(['publish-locale', 'customize-content', 'delete']);
|
||||
});
|
||||
|
||||
it('returns default, draft, and delete actions for live locales', () => {
|
||||
it('returns default, draft, customize, and delete actions for live locales', () => {
|
||||
expect(
|
||||
buildLocaleMenuItems({
|
||||
isDefault: false,
|
||||
isDraft: false,
|
||||
}).map(({ action }) => action)
|
||||
).toEqual(['change-default', 'move-to-draft', 'delete']);
|
||||
).toEqual([
|
||||
'change-default',
|
||||
'move-to-draft',
|
||||
'customize-content',
|
||||
'delete',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
{
|
||||
"BULK_ACTION": {
|
||||
"CONVERSATIONS_SELECTED": "{conversationCount} conversations selected",
|
||||
"AGENT_SELECT_LABEL": "Select agent",
|
||||
"ASSIGN_CONFIRMATION_LABEL": "Are you sure to assign {conversationCount} {conversationLabel} to",
|
||||
"UNASSIGN_CONFIRMATION_LABEL": "Are you sure to unassign {conversationCount} {conversationLabel}?",
|
||||
"GO_BACK_LABEL": "Go back",
|
||||
"ASSIGN_LABEL": "Assign",
|
||||
"NONE": "None",
|
||||
"CLEAR_SELECTION": "Clear",
|
||||
"ASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {agentName}? | Are you sure you want to assign {n} conversations to {agentName}?",
|
||||
@@ -20,7 +15,6 @@
|
||||
"RESOLVE_SUCCESFUL": "Conversations resolved successfully.",
|
||||
"RESOLVE_FAILED": "Failed to resolve conversations. Please try again.",
|
||||
"ALL_CONVERSATIONS_SELECTED_ALERT": "Conversations visible on this page are only selected.",
|
||||
"AGENT_LIST_LOADING": "Loading agents",
|
||||
"UPDATE": {
|
||||
"CHANGE_STATUS": "Change status",
|
||||
"SNOOZE_UNTIL": "Snooze",
|
||||
@@ -33,16 +27,16 @@
|
||||
},
|
||||
"LABELS": {
|
||||
"ASSIGN_LABELS": "Assign labels",
|
||||
"NO_LABELS_FOUND": "No labels found",
|
||||
"REMOVE_LABELS": "Remove labels",
|
||||
"ASSIGN_SELECTED_LABELS": "Assign selected labels",
|
||||
"REMOVE_SELECTED_LABELS": "Remove selected labels",
|
||||
"ASSIGN_SUCCESFUL": "Labels assigned successfully.",
|
||||
"ASSIGN_FAILED": "Failed to assign labels. Please try again."
|
||||
"ASSIGN_FAILED": "Failed to assign labels. Please try again.",
|
||||
"REMOVE_SUCCESFUL": "Labels removed successfully.",
|
||||
"REMOVE_FAILED": "Failed to remove labels. Please try again."
|
||||
},
|
||||
"TEAMS": {
|
||||
"TEAM_SELECT_LABEL": "Select team",
|
||||
"NONE": "None",
|
||||
"NO_TEAMS_AVAILABLE": "There are no teams added to this account yet.",
|
||||
"ASSIGN_SELECTED_TEAMS": "Assign selected team.",
|
||||
"ASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {teamName}? | Are you sure you want to assign {n} conversations to {teamName}?",
|
||||
"UNASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?",
|
||||
"ASSIGN_SUCCESFUL": "Teams assigned successfully.",
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
},
|
||||
"CARD": {
|
||||
"STATUS": {
|
||||
"PROCESSING": "በሂደት ላይ",
|
||||
"COMPLETED": "Completed",
|
||||
"SCHEDULED": "Scheduled"
|
||||
},
|
||||
@@ -146,6 +147,7 @@
|
||||
},
|
||||
"CARD": {
|
||||
"STATUS": {
|
||||
"PROCESSING": "በሂደት ላይ",
|
||||
"COMPLETED": "Completed",
|
||||
"SCHEDULED": "Scheduled"
|
||||
},
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"CODE": "Code",
|
||||
"BULLET_LIST": "Bullet List",
|
||||
"ORDERED_LIST": "Ordered List",
|
||||
"TABLE": "Table"
|
||||
"TABLE": "Table",
|
||||
"IMAGE": "Image"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
"CALL": "ደውል",
|
||||
"CALL_INITIATED": "ለእውነተኛው እውቀት መደወል እየተከናወነ ነው…",
|
||||
"CALL_FAILED": "ጥሪውን መጀመር አልቻልንም። እባክዎ ደግመው ይሞክሩ።.",
|
||||
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
|
||||
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
|
||||
"CLICK_TO_EDIT": "Click to edit",
|
||||
"VOICE_INBOX_PICKER": {
|
||||
"TITLE": "የድምፅ ኢንቦክስ ይምረጡ"
|
||||
@@ -583,8 +585,11 @@
|
||||
},
|
||||
"CONTACTS_BULK_ACTIONS": {
|
||||
"ASSIGN_LABELS": "መለያዎችን መሰጠት",
|
||||
"REMOVE_LABELS": "Remove Labels",
|
||||
"ASSIGN_LABELS_SUCCESS": "መለያዎች በተሳካ ሁኔታ ተመዝግበዋል።.",
|
||||
"ASSIGN_LABELS_FAILED": "መለያዎችን ማስመዝገብ አልተሳካም",
|
||||
"REMOVE_LABELS_SUCCESS": "Labels removed successfully.",
|
||||
"REMOVE_LABELS_FAILED": "Failed to remove labels",
|
||||
"DESCRIPTION": "ለተመረጡት እውነተኛዎች የሚያክሉትን መለያዎች ይምረጡ።.",
|
||||
"NO_LABELS_FOUND": "እስካሁን መለያዎች አልተገኙም።.",
|
||||
"SELECTED_COUNT": "{count} ተመርጧል",
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
"UNSUPPORTED_MESSAGE_FACEBOOK": "This message is unsupported. You can view this message on the Facebook Messenger app.",
|
||||
"UNSUPPORTED_MESSAGE_INSTAGRAM": "This message is unsupported. You can view this message on the Instagram app.",
|
||||
"UNSUPPORTED_MESSAGE_TIKTOK": "This message is unsupported. You can view this message on the TikTok app.",
|
||||
"UNSUPPORTED_MESSAGE_WHATSAPP": "This message is unsupported. You can view this message on the WhatsApp app.",
|
||||
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
|
||||
"FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again",
|
||||
"NO_RESPONSE": "No response",
|
||||
@@ -79,13 +80,22 @@
|
||||
"OUTGOING_CALL": "Outgoing call",
|
||||
"CALL_IN_PROGRESS": "Call in progress",
|
||||
"NO_ANSWER": "No answer",
|
||||
"NO_ANSWER_OUTBOUND_LABEL": "No answer",
|
||||
"NO_ANSWER_OUTBOUND_SUBTEXT": "Contact didn't pick up",
|
||||
"MISSED_CALL": "Missed call",
|
||||
"MISSED_CALL_INBOUND_SUBTEXT": "No agent picked up",
|
||||
"MISSED_CALL_DECLINED_BY": "Declined by {agentName}",
|
||||
"CALL_ENDED": "Call ended",
|
||||
"HANDLED_BY": "Handled by {agentName}",
|
||||
"NOT_ANSWERED_YET": "Not answered yet",
|
||||
"CALLING": "Calling…",
|
||||
"THEY_ANSWERED": "They answered",
|
||||
"YOU_ANSWERED": "You answered",
|
||||
"AGENT_ANSWERED": "{agentName} answered",
|
||||
"JOIN_CALL": "Join call"
|
||||
"JOIN_CALL": "Join call",
|
||||
"CALL_BACK": "Call back",
|
||||
"TRANSCRIPT_SHOW_MORE": "Show more",
|
||||
"TRANSCRIPT_SHOW_LESS": "Show less"
|
||||
},
|
||||
"HEADER": {
|
||||
"RESOLVE_ACTION": "ተፈትኗል",
|
||||
@@ -100,6 +110,12 @@
|
||||
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
|
||||
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week",
|
||||
"SNOOZED_UNTIL_NEXT_REPLY": "Snoozed until next reply",
|
||||
"WHATSAPP_CALL": "Start WhatsApp call",
|
||||
"WHATSAPP_CALL_FAILED": "Could not start the WhatsApp call.",
|
||||
"VOICE_CALL": "Start call",
|
||||
"VOICE_CALL_FAILED": "Could not start the call.",
|
||||
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
|
||||
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
|
||||
"SLA_STATUS": {
|
||||
"FRT": "FRT {status}",
|
||||
"NRT": "NRT {status}",
|
||||
@@ -215,6 +231,7 @@
|
||||
"TIP_AUDIORECORDER_ICON": "Record audio",
|
||||
"TIP_AUDIORECORDER_PERMISSION": "Allow access to audio",
|
||||
"TIP_AUDIORECORDER_ERROR": "Could not open the audio",
|
||||
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
|
||||
"DRAG_DROP": "Drag and drop here to attach",
|
||||
"START_AUDIO_RECORDING": "Start audio recording",
|
||||
"STOP_AUDIO_RECORDING": "Stop audio recording",
|
||||
@@ -299,8 +316,13 @@
|
||||
"NOT_ANSWERED_YET": "Not answered yet",
|
||||
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
|
||||
"REJECT_CALL": "Reject",
|
||||
"DISMISS_CALL": "Dismiss",
|
||||
"JOIN_CALL": "Join call",
|
||||
"END_CALL": "End call"
|
||||
"END_CALL": "End call",
|
||||
"MUTE": "Mute mic",
|
||||
"UNMUTE": "Unmute mic",
|
||||
"VIEW_CHAT_HISTORY": "View chat history",
|
||||
"GO_TO_CONVERSATION": "Go to conversation thread"
|
||||
}
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
|
||||
@@ -607,9 +607,12 @@
|
||||
"DRAFT": "እቅድ",
|
||||
"ARCHIVE": "አርክቭ",
|
||||
"TRANSLATE": "Translate",
|
||||
"MOVE_TO_CATEGORY": "Category",
|
||||
"DELETE": "Delete",
|
||||
"STATUS_SUCCESS": "Articles updated successfully",
|
||||
"STATUS_ERROR": "Failed to update articles",
|
||||
"CATEGORY_SUCCESS": "Articles moved successfully",
|
||||
"CATEGORY_ERROR": "Failed to move articles",
|
||||
"DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles",
|
||||
"DELETE_CONFIRM_DESCRIPTION": "This will permanently delete the selected article. This action cannot be undone. | This will permanently delete {count} selected articles. This action cannot be undone.",
|
||||
"DELETE_CONFIRM": "Delete",
|
||||
@@ -866,6 +869,28 @@
|
||||
},
|
||||
"EDIT_CONFIGURATION": "ቅንብር አርትዕ"
|
||||
},
|
||||
"LAYOUT_CONTENT": {
|
||||
"HEADER": "Appearance",
|
||||
"DESCRIPTION": "Pick the layout that fits how your visitors read.",
|
||||
"LAYOUT": {
|
||||
"CLASSIC": {
|
||||
"TITLE": "Classic",
|
||||
"DESCRIPTION": "A welcoming home page with search and featured topics."
|
||||
},
|
||||
"SIDEBAR": {
|
||||
"TITLE": "Documentation",
|
||||
"DESCRIPTION": "Side-by-side navigation that keeps every guide a click away."
|
||||
}
|
||||
},
|
||||
"SOCIAL_LINKS": {
|
||||
"HEADER": "Social links",
|
||||
"DESCRIPTION": "Add the handle for each network and your help center builds the full link. Shown in the documentation layout footer.",
|
||||
"PLACEHOLDER": "handle",
|
||||
"ADD": "Add social link",
|
||||
"REMOVE": "አስወግድ"
|
||||
},
|
||||
"SAVE": "ለውጦች አስቀምጥ"
|
||||
},
|
||||
"API": {
|
||||
"CREATE_PORTAL": {
|
||||
"SUCCESS_MESSAGE": "ፖርታል በተሳካ ሁኔታ ተፈጥሯል",
|
||||
|
||||
@@ -308,6 +308,7 @@
|
||||
"AUTH_PROCESSING": "ከMeta ጋር በመረጋገጥ ላይ ነው",
|
||||
"WAITING_FOR_BUSINESS_INFO": "እባክዎ በMeta መስኮት ውስጥ የንግድ ቅንብር ያርኩ...",
|
||||
"PROCESSING": "የWhatsApp ቢዝነስ መለያዎን እየተዘጋጀ ነው",
|
||||
"ENABLING_CALLING": "Enabling WhatsApp Calling on your number…",
|
||||
"LOADING_SDK": "Facebook SDK እየተጫነ ነው...",
|
||||
"CANCELLED": "የWhatsApp ምዝገባ ተሰርዟል",
|
||||
"SUCCESS_TITLE": "የWhatsApp ቢዝነስ መለያ ተገናኝቷል!",
|
||||
@@ -317,7 +318,8 @@
|
||||
"AUTH_NOT_COMPLETED": "ማረጋገጫ አልተጠናቀቀም። ሂደቱን እባክዎ ዳግም ይጀምሩ።.",
|
||||
"SUCCESS_FALLBACK": "የWhatsApp የንግድ መለያ በተሳካ ሁኔታ ተቋቋመ",
|
||||
"MANUAL_FALLBACK": "ቁጥርዎ ከWhatsApp Business Platform (API) ጋር ከተገናኘ እና ወይም እርስዎ የቴክኖሎጂ አቅራቢ ከሆኑ እና የእርስዎን ቁጥር በራስዎ ሲያስገቡ፣ እባክዎ የ{link} ሂደትን ይጠቀሙ",
|
||||
"MANUAL_LINK_TEXT": "የእጅ ማቀናበሪያ ሂደት"
|
||||
"MANUAL_LINK_TEXT": "የእጅ ማቀናበሪያ ሂደት",
|
||||
"CALLING_ENABLE_FAILED": "Your WhatsApp inbox is ready, but voice calling couldn't be turned on — this number isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then turn calling on from the inbox's Calls settings."
|
||||
},
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "WhatsApp ቻናሉን ማስቀመጥ አልተቻለም"
|
||||
@@ -465,6 +467,10 @@
|
||||
"TITLE": "ዋትስአፕ",
|
||||
"DESCRIPTION": "በWhatsApp ላይ ደንበኞችዎን ድጋፍ ያድርጉ"
|
||||
},
|
||||
"WHATSAPP_CALL": {
|
||||
"TITLE": "WhatsApp Call",
|
||||
"DESCRIPTION": "Take voice calls on your WhatsApp number"
|
||||
},
|
||||
"EMAIL": {
|
||||
"TITLE": "ኢሜይል",
|
||||
"DESCRIPTION": "ከGmail, Outlook ወይም ከሌሎች አቅራቢዎች ጋር ያገናኙ"
|
||||
@@ -637,7 +643,8 @@
|
||||
"BOT_CONFIGURATION": "የቦት ቅንብሮች",
|
||||
"ACCOUNT_HEALTH": "የመለያ ጤና",
|
||||
"CSAT": "የደንበኞች ደህንነት ግምገማ (CSAT)",
|
||||
"VOICE": "ድምጽ"
|
||||
"VOICE": "ድምጽ",
|
||||
"CALLS": "Calls"
|
||||
},
|
||||
"VOICE_CONFIGURATION": {
|
||||
"ENABLE_VOICE": {
|
||||
@@ -648,6 +655,26 @@
|
||||
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
|
||||
}
|
||||
},
|
||||
"WHATSAPP_CALLING": {
|
||||
"ENABLE": {
|
||||
"LABEL": "Enable WhatsApp Calling",
|
||||
"DESCRIPTION": "Allow agents to receive and place WhatsApp Cloud calls on this inbox. Customers can call this business number directly from WhatsApp."
|
||||
},
|
||||
"ENABLE_FAILED": "Voice calling couldn't be turned on for this number — it isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then try again.",
|
||||
"PHONE_NUMBER": {
|
||||
"LABEL": "Business phone number",
|
||||
"HELP_TEXT": "WhatsApp number that customers will call."
|
||||
},
|
||||
"HOW_IT_WORKS": {
|
||||
"LABEL": "How it works",
|
||||
"DESCRIPTION": "Calls are placed peer-to-peer between the agent's browser and Meta — no extra credentials are required. Make sure the agent's browser has microphone permission for this site."
|
||||
},
|
||||
"PERMISSION_REQUEST_BODY": {
|
||||
"LABEL": "Call permission request message",
|
||||
"HELP_TEXT": "Shown to the contact when they haven't yet consented to receive calls. Leave blank to use the default.",
|
||||
"PLACEHOLDER": "We would like to call you regarding your conversation."
|
||||
}
|
||||
},
|
||||
"CHANNEL_PREFERENCES": "የቻናል ቅድሚያዎች",
|
||||
"WIDGET_FEATURES": "የዊጅት ባህሪያት",
|
||||
"ACCOUNT_HEALTH": {
|
||||
@@ -800,6 +827,10 @@
|
||||
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "ከWhatsApp መልእክት አብነቶች እጅግ በእጅ ማስተካከል ለእንደገና የሚገኙ አብነቶችን ያዘምኑ።.",
|
||||
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "አብነቶችን ያዘምኑ",
|
||||
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "የአብነት ማስተካከያ በተሳካ ሁኔታ ተጀምሯል። ለማዘመን ጥቂት ደቂቃዎች ሊወስድ ይችላል።.",
|
||||
"WHATSAPP_CALLING_ENABLED": {
|
||||
"LABEL": "Enable voice calling",
|
||||
"DESCRIPTION": "Allow agents to start and receive WhatsApp voice calls on this inbox. Available only on embedded-signup WhatsApp Cloud channels with calling permission granted by Meta."
|
||||
},
|
||||
"UPDATE_PRE_CHAT_FORM_SETTINGS": "የቀደም ቻት ቅጥያ ቅንብሮችን አዘምን"
|
||||
},
|
||||
"HELP_CENTER": {
|
||||
|
||||
@@ -57,7 +57,8 @@
|
||||
"CONTACT_CREATED": "እውቂያ ተፈጥሯል",
|
||||
"CONTACT_UPDATED": "እውቂያ ተሻሽሏል",
|
||||
"CONVERSATION_TYPING_ON": "ውይይት ማስተካከያ በተጠቃሚ ላይ ነው",
|
||||
"CONVERSATION_TYPING_OFF": "ውይይት ማስተካከያ ከተጠቃሚ ላይ አልተጠቀሰም"
|
||||
"CONVERSATION_TYPING_OFF": "ውይይት ማስተካከያ ከተጠቃሚ ላይ አልተጠቀሰም",
|
||||
"INBOX_UPDATED": "Inbox updated"
|
||||
}
|
||||
},
|
||||
"NAME": {
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
{
|
||||
"BULK_ACTION": {
|
||||
"CONVERSATIONS_SELECTED": "{conversationCount} المحادثات المحددة",
|
||||
"AGENT_SELECT_LABEL": "اختر وكيل",
|
||||
"ASSIGN_CONFIRMATION_LABEL": "هل أنت متأكد من أنك تريد تعيين {conversationCount} {conversationLabel} إلى",
|
||||
"UNASSIGN_CONFIRMATION_LABEL": "هل أنت متأكد من إلغاء تعيين {conversationCount} {conversationLabel}؟",
|
||||
"GO_BACK_LABEL": "العودة للخلف",
|
||||
"ASSIGN_LABEL": "تكليف",
|
||||
"NONE": "لا شيء",
|
||||
"CLEAR_SELECTION": "مسح",
|
||||
"ASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {agentName}? | Are you sure you want to assign {n} conversations to {agentName}?",
|
||||
@@ -20,7 +15,6 @@
|
||||
"RESOLVE_SUCCESFUL": "تم تسوية المحادثات بنجاح.",
|
||||
"RESOLVE_FAILED": "فشل في حل المحادثات، يرجى المحاولة مرة أخرى.",
|
||||
"ALL_CONVERSATIONS_SELECTED_ALERT": "المحادثات المرئية في هذه الصفحة هي المحددة فقط.",
|
||||
"AGENT_LIST_LOADING": "جاري جلب الوكلاء",
|
||||
"UPDATE": {
|
||||
"CHANGE_STATUS": "تغيير الحالة",
|
||||
"SNOOZE_UNTIL": "تأجيل",
|
||||
@@ -33,16 +27,16 @@
|
||||
},
|
||||
"LABELS": {
|
||||
"ASSIGN_LABELS": "إضافة وسم",
|
||||
"NO_LABELS_FOUND": "لم يتم العثور على تصنيفات",
|
||||
"REMOVE_LABELS": "Remove labels",
|
||||
"ASSIGN_SELECTED_LABELS": "تعيين التسميات المحددة",
|
||||
"REMOVE_SELECTED_LABELS": "Remove selected labels",
|
||||
"ASSIGN_SUCCESFUL": "تم تعيين التسميات بنجاح.",
|
||||
"ASSIGN_FAILED": "فشل في تعيين التسميات ، الرجاء المحاولة مرة أخرى."
|
||||
"ASSIGN_FAILED": "فشل في تعيين التسميات ، الرجاء المحاولة مرة أخرى.",
|
||||
"REMOVE_SUCCESFUL": "Labels removed successfully.",
|
||||
"REMOVE_FAILED": "Failed to remove labels. Please try again."
|
||||
},
|
||||
"TEAMS": {
|
||||
"TEAM_SELECT_LABEL": "اختر فريق",
|
||||
"NONE": "لا شيء",
|
||||
"NO_TEAMS_AVAILABLE": "لا توجد فرق مضافة إلى هذا الحساب حتى الآن.",
|
||||
"ASSIGN_SELECTED_TEAMS": "تعيين فريق محدد.",
|
||||
"ASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {teamName}? | Are you sure you want to assign {n} conversations to {teamName}?",
|
||||
"UNASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?",
|
||||
"ASSIGN_SUCCESFUL": "تم تعيين الفرق بنجاح.",
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
},
|
||||
"CARD": {
|
||||
"STATUS": {
|
||||
"PROCESSING": "Processing",
|
||||
"COMPLETED": "مكتمل",
|
||||
"SCHEDULED": "Scheduled"
|
||||
},
|
||||
@@ -146,6 +147,7 @@
|
||||
},
|
||||
"CARD": {
|
||||
"STATUS": {
|
||||
"PROCESSING": "Processing",
|
||||
"COMPLETED": "مكتمل",
|
||||
"SCHEDULED": "Scheduled"
|
||||
},
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"CODE": "Code",
|
||||
"BULLET_LIST": "Bullet List",
|
||||
"ORDERED_LIST": "Ordered List",
|
||||
"TABLE": "Table"
|
||||
"TABLE": "Table",
|
||||
"IMAGE": "Image"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
"CALL": "Call",
|
||||
"CALL_INITIATED": "جار الاتصال بجهة الاتصال…",
|
||||
"CALL_FAILED": "تعذر بدء المكالمة. الرجاء المحاولة مرة أخرى.",
|
||||
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
|
||||
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
|
||||
"CLICK_TO_EDIT": "Click to edit",
|
||||
"VOICE_INBOX_PICKER": {
|
||||
"TITLE": "Choose a voice inbox"
|
||||
@@ -583,8 +585,11 @@
|
||||
},
|
||||
"CONTACTS_BULK_ACTIONS": {
|
||||
"ASSIGN_LABELS": "تعيين التسميات",
|
||||
"REMOVE_LABELS": "Remove Labels",
|
||||
"ASSIGN_LABELS_SUCCESS": "تم تعيين التسميات بنجاح.",
|
||||
"ASSIGN_LABELS_FAILED": "Failed to assign labels",
|
||||
"REMOVE_LABELS_SUCCESS": "Labels removed successfully.",
|
||||
"REMOVE_LABELS_FAILED": "Failed to remove labels",
|
||||
"DESCRIPTION": "Select the labels you want to add to the selected contacts.",
|
||||
"NO_LABELS_FOUND": "No labels available yet.",
|
||||
"SELECTED_COUNT": "{count} selected",
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
"UNSUPPORTED_MESSAGE_FACEBOOK": "هذه الرسالة غير مدعومة، يمكنك مشاهدة هذه الرسالة على تطبيق فيسبوك (Messenger).",
|
||||
"UNSUPPORTED_MESSAGE_INSTAGRAM": "هذه الرسالة غير مدعومة، يمكنك عرض هذه الرسالة على تطبيق Instagram.",
|
||||
"UNSUPPORTED_MESSAGE_TIKTOK": "هذه الرسالة غير مدعومة. يمكنك مشاهدة هذه الرسالة على تطبيق TikTok.",
|
||||
"UNSUPPORTED_MESSAGE_WHATSAPP": "This message is unsupported. You can view this message on the WhatsApp app.",
|
||||
"SUCCESS_DELETE_MESSAGE": "تم حذف الرسالة بنجاح",
|
||||
"FAIL_DELETE_MESSSAGE": "تعذر حذف الرسالة! حاول مرة أخرى",
|
||||
"NO_RESPONSE": "لا توجد استجابة",
|
||||
@@ -79,13 +80,22 @@
|
||||
"OUTGOING_CALL": "Outgoing call",
|
||||
"CALL_IN_PROGRESS": "Call in progress",
|
||||
"NO_ANSWER": "No answer",
|
||||
"NO_ANSWER_OUTBOUND_LABEL": "No answer",
|
||||
"NO_ANSWER_OUTBOUND_SUBTEXT": "Contact didn't pick up",
|
||||
"MISSED_CALL": "Missed call",
|
||||
"MISSED_CALL_INBOUND_SUBTEXT": "No agent picked up",
|
||||
"MISSED_CALL_DECLINED_BY": "Declined by {agentName}",
|
||||
"CALL_ENDED": "Call ended",
|
||||
"HANDLED_BY": "Handled by {agentName}",
|
||||
"NOT_ANSWERED_YET": "Not answered yet",
|
||||
"CALLING": "Calling…",
|
||||
"THEY_ANSWERED": "They answered",
|
||||
"YOU_ANSWERED": "You answered",
|
||||
"AGENT_ANSWERED": "{agentName} answered",
|
||||
"JOIN_CALL": "انضم إلى المكالمة"
|
||||
"JOIN_CALL": "انضم إلى المكالمة",
|
||||
"CALL_BACK": "Call back",
|
||||
"TRANSCRIPT_SHOW_MORE": "Show more",
|
||||
"TRANSCRIPT_SHOW_LESS": "Show less"
|
||||
},
|
||||
"HEADER": {
|
||||
"RESOLVE_ACTION": "حل المحادثة",
|
||||
@@ -100,6 +110,12 @@
|
||||
"SNOOZED_UNTIL_TOMORROW": "تأجيل حتى الغد",
|
||||
"SNOOZED_UNTIL_NEXT_WEEK": "تأجيل حتى الأسبوع القادم",
|
||||
"SNOOZED_UNTIL_NEXT_REPLY": "تأجيل حتى الرد التالي",
|
||||
"WHATSAPP_CALL": "Start WhatsApp call",
|
||||
"WHATSAPP_CALL_FAILED": "Could not start the WhatsApp call.",
|
||||
"VOICE_CALL": "Start call",
|
||||
"VOICE_CALL_FAILED": "Could not start the call.",
|
||||
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
|
||||
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
|
||||
"SLA_STATUS": {
|
||||
"FRT": "FRT {status}",
|
||||
"NRT": "NRT {status}",
|
||||
@@ -215,6 +231,7 @@
|
||||
"TIP_AUDIORECORDER_ICON": "تسجيل الصوت",
|
||||
"TIP_AUDIORECORDER_PERMISSION": "السماح بالوصول إلى الصوت",
|
||||
"TIP_AUDIORECORDER_ERROR": "تعذر فتح الصوت",
|
||||
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
|
||||
"DRAG_DROP": "اسحب و أسقط هنا للإرفاق",
|
||||
"START_AUDIO_RECORDING": "بدء التسجيل الصوتي",
|
||||
"STOP_AUDIO_RECORDING": "إيقاف التسجيل الصوتي",
|
||||
@@ -299,8 +316,13 @@
|
||||
"NOT_ANSWERED_YET": "لم يتم الرد بعد",
|
||||
"HANDLED_IN_ANOTHER_TAB": "يتم التعامل معها في علامة تبويب أخرى",
|
||||
"REJECT_CALL": "رفض",
|
||||
"DISMISS_CALL": "تجاهل",
|
||||
"JOIN_CALL": "انضم إلى المكالمة",
|
||||
"END_CALL": "إنهاء المكالمة"
|
||||
"END_CALL": "إنهاء المكالمة",
|
||||
"MUTE": "Mute mic",
|
||||
"UNMUTE": "Unmute mic",
|
||||
"VIEW_CHAT_HISTORY": "View chat history",
|
||||
"GO_TO_CONVERSATION": "Go to conversation thread"
|
||||
}
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
|
||||
@@ -607,9 +607,12 @@
|
||||
"DRAFT": "مسودة",
|
||||
"ARCHIVE": "Archive",
|
||||
"TRANSLATE": "ترجم",
|
||||
"MOVE_TO_CATEGORY": "الفئة",
|
||||
"DELETE": "حذف",
|
||||
"STATUS_SUCCESS": "Articles updated successfully",
|
||||
"STATUS_ERROR": "Failed to update articles",
|
||||
"CATEGORY_SUCCESS": "Articles moved successfully",
|
||||
"CATEGORY_ERROR": "Failed to move articles",
|
||||
"DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles",
|
||||
"DELETE_CONFIRM_DESCRIPTION": "This will permanently delete the selected article. This action cannot be undone. | This will permanently delete {count} selected articles. This action cannot be undone.",
|
||||
"DELETE_CONFIRM": "حذف",
|
||||
@@ -866,6 +869,28 @@
|
||||
},
|
||||
"EDIT_CONFIGURATION": "Edit configuration"
|
||||
},
|
||||
"LAYOUT_CONTENT": {
|
||||
"HEADER": "المظهر",
|
||||
"DESCRIPTION": "Pick the layout that fits how your visitors read.",
|
||||
"LAYOUT": {
|
||||
"CLASSIC": {
|
||||
"TITLE": "Classic",
|
||||
"DESCRIPTION": "A welcoming home page with search and featured topics."
|
||||
},
|
||||
"SIDEBAR": {
|
||||
"TITLE": "Documentation",
|
||||
"DESCRIPTION": "Side-by-side navigation that keeps every guide a click away."
|
||||
}
|
||||
},
|
||||
"SOCIAL_LINKS": {
|
||||
"HEADER": "Social links",
|
||||
"DESCRIPTION": "Add the handle for each network and your help center builds the full link. Shown in the documentation layout footer.",
|
||||
"PLACEHOLDER": "handle",
|
||||
"ADD": "Add social link",
|
||||
"REMOVE": "حذف"
|
||||
},
|
||||
"SAVE": "Save changes"
|
||||
},
|
||||
"API": {
|
||||
"CREATE_PORTAL": {
|
||||
"SUCCESS_MESSAGE": "تم إنشاء البوابة بنجاح",
|
||||
|
||||
@@ -308,6 +308,7 @@
|
||||
"AUTH_PROCESSING": "Authenticating with Meta",
|
||||
"WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...",
|
||||
"PROCESSING": "Setting up your WhatsApp Business Account",
|
||||
"ENABLING_CALLING": "Enabling WhatsApp Calling on your number…",
|
||||
"LOADING_SDK": "Loading Facebook SDK...",
|
||||
"CANCELLED": "WhatsApp Signup was cancelled",
|
||||
"SUCCESS_TITLE": "WhatsApp Business Account Connected!",
|
||||
@@ -317,7 +318,8 @@
|
||||
"AUTH_NOT_COMPLETED": "Authentication not completed. Please restart the process.",
|
||||
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured",
|
||||
"MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if you’re a tech provider onboarding your own number, please use the {link} flow",
|
||||
"MANUAL_LINK_TEXT": "manual setup flow"
|
||||
"MANUAL_LINK_TEXT": "manual setup flow",
|
||||
"CALLING_ENABLE_FAILED": "Your WhatsApp inbox is ready, but voice calling couldn't be turned on — this number isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then turn calling on from the inbox's Calls settings."
|
||||
},
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "لم نتمكن من حفظ قناة واتساب"
|
||||
@@ -465,6 +467,10 @@
|
||||
"TITLE": "واتساب",
|
||||
"DESCRIPTION": "Support your customers on WhatsApp"
|
||||
},
|
||||
"WHATSAPP_CALL": {
|
||||
"TITLE": "WhatsApp Call",
|
||||
"DESCRIPTION": "Take voice calls on your WhatsApp number"
|
||||
},
|
||||
"EMAIL": {
|
||||
"TITLE": "البريد الإلكتروني",
|
||||
"DESCRIPTION": "Connect with Gmail, Outlook, or other providers"
|
||||
@@ -637,7 +643,8 @@
|
||||
"BOT_CONFIGURATION": "اعدادات البوت",
|
||||
"ACCOUNT_HEALTH": "Account Health",
|
||||
"CSAT": "تقييم رضاء العملاء",
|
||||
"VOICE": "Voice"
|
||||
"VOICE": "Voice",
|
||||
"CALLS": "Calls"
|
||||
},
|
||||
"VOICE_CONFIGURATION": {
|
||||
"ENABLE_VOICE": {
|
||||
@@ -648,6 +655,26 @@
|
||||
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
|
||||
}
|
||||
},
|
||||
"WHATSAPP_CALLING": {
|
||||
"ENABLE": {
|
||||
"LABEL": "Enable WhatsApp Calling",
|
||||
"DESCRIPTION": "Allow agents to receive and place WhatsApp Cloud calls on this inbox. Customers can call this business number directly from WhatsApp."
|
||||
},
|
||||
"ENABLE_FAILED": "Voice calling couldn't be turned on for this number — it isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then try again.",
|
||||
"PHONE_NUMBER": {
|
||||
"LABEL": "Business phone number",
|
||||
"HELP_TEXT": "WhatsApp number that customers will call."
|
||||
},
|
||||
"HOW_IT_WORKS": {
|
||||
"LABEL": "How it works",
|
||||
"DESCRIPTION": "Calls are placed peer-to-peer between the agent's browser and Meta — no extra credentials are required. Make sure the agent's browser has microphone permission for this site."
|
||||
},
|
||||
"PERMISSION_REQUEST_BODY": {
|
||||
"LABEL": "Call permission request message",
|
||||
"HELP_TEXT": "Shown to the contact when they haven't yet consented to receive calls. Leave blank to use the default.",
|
||||
"PLACEHOLDER": "We would like to call you regarding your conversation."
|
||||
}
|
||||
},
|
||||
"CHANNEL_PREFERENCES": "Channel Preferences",
|
||||
"WIDGET_FEATURES": "Widget features",
|
||||
"ACCOUNT_HEALTH": {
|
||||
@@ -800,6 +827,10 @@
|
||||
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Manually sync message templates from WhatsApp to update your available templates.",
|
||||
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sync Templates",
|
||||
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Templates sync initiated successfully. It may take a couple of minutes to update.",
|
||||
"WHATSAPP_CALLING_ENABLED": {
|
||||
"LABEL": "Enable voice calling",
|
||||
"DESCRIPTION": "Allow agents to start and receive WhatsApp voice calls on this inbox. Available only on embedded-signup WhatsApp Cloud channels with calling permission granted by Meta."
|
||||
},
|
||||
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings"
|
||||
},
|
||||
"HELP_CENTER": {
|
||||
|
||||
@@ -57,7 +57,8 @@
|
||||
"CONTACT_CREATED": "Contact created",
|
||||
"CONTACT_UPDATED": "Contact updated",
|
||||
"CONVERSATION_TYPING_ON": "Conversation Typing On",
|
||||
"CONVERSATION_TYPING_OFF": "Conversation Typing Off"
|
||||
"CONVERSATION_TYPING_OFF": "Conversation Typing Off",
|
||||
"INBOX_UPDATED": "Inbox updated"
|
||||
}
|
||||
},
|
||||
"NAME": {
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
{
|
||||
"BULK_ACTION": {
|
||||
"CONVERSATIONS_SELECTED": "{conversationCount} conversations selected",
|
||||
"AGENT_SELECT_LABEL": "Select agent",
|
||||
"ASSIGN_CONFIRMATION_LABEL": "Are you sure to assign {conversationCount} {conversationLabel} to",
|
||||
"UNASSIGN_CONFIRMATION_LABEL": "Are you sure to unassign {conversationCount} {conversationLabel}?",
|
||||
"GO_BACK_LABEL": "Go back",
|
||||
"ASSIGN_LABEL": "Assign",
|
||||
"NONE": "Heç biri",
|
||||
"CLEAR_SELECTION": "Clear",
|
||||
"ASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {agentName}? | Are you sure you want to assign {n} conversations to {agentName}?",
|
||||
@@ -20,7 +15,6 @@
|
||||
"RESOLVE_SUCCESFUL": "Conversations resolved successfully.",
|
||||
"RESOLVE_FAILED": "Failed to resolve conversations. Please try again.",
|
||||
"ALL_CONVERSATIONS_SELECTED_ALERT": "Conversations visible on this page are only selected.",
|
||||
"AGENT_LIST_LOADING": "Loading agents",
|
||||
"UPDATE": {
|
||||
"CHANGE_STATUS": "Change status",
|
||||
"SNOOZE_UNTIL": "Snooze",
|
||||
@@ -33,16 +27,16 @@
|
||||
},
|
||||
"LABELS": {
|
||||
"ASSIGN_LABELS": "Assign labels",
|
||||
"NO_LABELS_FOUND": "No labels found",
|
||||
"REMOVE_LABELS": "Remove labels",
|
||||
"ASSIGN_SELECTED_LABELS": "Assign selected labels",
|
||||
"REMOVE_SELECTED_LABELS": "Remove selected labels",
|
||||
"ASSIGN_SUCCESFUL": "Labels assigned successfully.",
|
||||
"ASSIGN_FAILED": "Failed to assign labels. Please try again."
|
||||
"ASSIGN_FAILED": "Failed to assign labels. Please try again.",
|
||||
"REMOVE_SUCCESFUL": "Labels removed successfully.",
|
||||
"REMOVE_FAILED": "Failed to remove labels. Please try again."
|
||||
},
|
||||
"TEAMS": {
|
||||
"TEAM_SELECT_LABEL": "Select team",
|
||||
"NONE": "None",
|
||||
"NO_TEAMS_AVAILABLE": "There are no teams added to this account yet.",
|
||||
"ASSIGN_SELECTED_TEAMS": "Assign selected team.",
|
||||
"ASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {teamName}? | Are you sure you want to assign {n} conversations to {teamName}?",
|
||||
"UNASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?",
|
||||
"ASSIGN_SUCCESFUL": "Teams assigned successfully.",
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
},
|
||||
"CARD": {
|
||||
"STATUS": {
|
||||
"PROCESSING": "Processing",
|
||||
"COMPLETED": "Completed",
|
||||
"SCHEDULED": "Scheduled"
|
||||
},
|
||||
@@ -146,6 +147,7 @@
|
||||
},
|
||||
"CARD": {
|
||||
"STATUS": {
|
||||
"PROCESSING": "Processing",
|
||||
"COMPLETED": "Completed",
|
||||
"SCHEDULED": "Scheduled"
|
||||
},
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"CODE": "Code",
|
||||
"BULLET_LIST": "Bullet List",
|
||||
"ORDERED_LIST": "Ordered List",
|
||||
"TABLE": "Table"
|
||||
"TABLE": "Table",
|
||||
"IMAGE": "Image"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
"CALL": "Zəng et",
|
||||
"CALL_INITIATED": "Calling the contact…",
|
||||
"CALL_FAILED": "Unable to start the call. Please try again.",
|
||||
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
|
||||
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
|
||||
"CLICK_TO_EDIT": "Click to edit",
|
||||
"VOICE_INBOX_PICKER": {
|
||||
"TITLE": "Choose a voice inbox"
|
||||
@@ -583,8 +585,11 @@
|
||||
},
|
||||
"CONTACTS_BULK_ACTIONS": {
|
||||
"ASSIGN_LABELS": "Assign Labels",
|
||||
"REMOVE_LABELS": "Remove Labels",
|
||||
"ASSIGN_LABELS_SUCCESS": "Labels assigned successfully.",
|
||||
"ASSIGN_LABELS_FAILED": "Failed to assign labels",
|
||||
"REMOVE_LABELS_SUCCESS": "Labels removed successfully.",
|
||||
"REMOVE_LABELS_FAILED": "Failed to remove labels",
|
||||
"DESCRIPTION": "Select the labels you want to add to the selected contacts.",
|
||||
"NO_LABELS_FOUND": "No labels available yet.",
|
||||
"SELECTED_COUNT": "{count} seçildi",
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
"UNSUPPORTED_MESSAGE_FACEBOOK": "Bu mesaj dəstəklənmir. Bu mesajı Facebook Messenger tətbiqində görə bilərsiniz.",
|
||||
"UNSUPPORTED_MESSAGE_INSTAGRAM": "Bu mesaj dəstəklənmir. Bu mesajı Instagram tətbiqində görə bilərsiniz.",
|
||||
"UNSUPPORTED_MESSAGE_TIKTOK": "Bu mesaj dəstəklənmir. Bu mesajı TikTok tətbiqində görə bilərsiniz.",
|
||||
"UNSUPPORTED_MESSAGE_WHATSAPP": "This message is unsupported. You can view this message on the WhatsApp app.",
|
||||
"SUCCESS_DELETE_MESSAGE": "Mesaj uğurla silindi",
|
||||
"FAIL_DELETE_MESSSAGE": "Mesajı silmək mümkün olmadı! Yenidən cəhd edin",
|
||||
"NO_RESPONSE": "Cavab yoxdur",
|
||||
@@ -79,13 +80,22 @@
|
||||
"OUTGOING_CALL": "Gedən zəng",
|
||||
"CALL_IN_PROGRESS": "Zəng davam edir",
|
||||
"NO_ANSWER": "Cavab yoxdur",
|
||||
"NO_ANSWER_OUTBOUND_LABEL": "Cavab yoxdur",
|
||||
"NO_ANSWER_OUTBOUND_SUBTEXT": "Contact didn't pick up",
|
||||
"MISSED_CALL": "Qeyri-işlək zəng",
|
||||
"MISSED_CALL_INBOUND_SUBTEXT": "No agent picked up",
|
||||
"MISSED_CALL_DECLINED_BY": "Declined by {agentName}",
|
||||
"CALL_ENDED": "Zəng bitdi",
|
||||
"HANDLED_BY": "Handled by {agentName}",
|
||||
"NOT_ANSWERED_YET": "Hələ cavab verilməyib",
|
||||
"CALLING": "Calling…",
|
||||
"THEY_ANSWERED": "Onlar cavab verdi",
|
||||
"YOU_ANSWERED": "Siz cavab verdiniz",
|
||||
"AGENT_ANSWERED": "{agentName} answered",
|
||||
"JOIN_CALL": "Zəngə qoşul"
|
||||
"JOIN_CALL": "Zəngə qoşul",
|
||||
"CALL_BACK": "Call back",
|
||||
"TRANSCRIPT_SHOW_MORE": "Show more",
|
||||
"TRANSCRIPT_SHOW_LESS": "Show less"
|
||||
},
|
||||
"HEADER": {
|
||||
"RESOLVE_ACTION": "Həll et",
|
||||
@@ -100,6 +110,12 @@
|
||||
"SNOOZED_UNTIL_TOMORROW": "Sabaha qədər təxirə salındı",
|
||||
"SNOOZED_UNTIL_NEXT_WEEK": "Gələn həftəyə qədər təxirə salındı",
|
||||
"SNOOZED_UNTIL_NEXT_REPLY": "Növbəti cavaba qədər təxirə salındı",
|
||||
"WHATSAPP_CALL": "Start WhatsApp call",
|
||||
"WHATSAPP_CALL_FAILED": "Could not start the WhatsApp call.",
|
||||
"VOICE_CALL": "Start call",
|
||||
"VOICE_CALL_FAILED": "Could not start the call.",
|
||||
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
|
||||
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
|
||||
"SLA_STATUS": {
|
||||
"FRT": "FRT {status}",
|
||||
"NRT": "NRT {status}",
|
||||
@@ -215,6 +231,7 @@
|
||||
"TIP_AUDIORECORDER_ICON": "Səs yaz",
|
||||
"TIP_AUDIORECORDER_PERMISSION": "Səsə girişə icazə ver",
|
||||
"TIP_AUDIORECORDER_ERROR": "Səsi açmaq mümkün olmadı",
|
||||
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
|
||||
"DRAG_DROP": "Qoşmaq üçün buraya sürükləyin və buraxın",
|
||||
"START_AUDIO_RECORDING": "Səs yazısını başla",
|
||||
"STOP_AUDIO_RECORDING": "Səs yazısını dayandırın",
|
||||
@@ -299,8 +316,13 @@
|
||||
"NOT_ANSWERED_YET": "Hələ cavab verilməyib",
|
||||
"HANDLED_IN_ANOTHER_TAB": "Başqa sekmədə işlənir",
|
||||
"REJECT_CALL": "İmtina et",
|
||||
"DISMISS_CALL": "Dismiss",
|
||||
"JOIN_CALL": "Zəngə qoşul",
|
||||
"END_CALL": "Zəngi bitir"
|
||||
"END_CALL": "Zəngi bitir",
|
||||
"MUTE": "Mute mic",
|
||||
"UNMUTE": "Unmute mic",
|
||||
"VIEW_CHAT_HISTORY": "View chat history",
|
||||
"GO_TO_CONVERSATION": "Go to conversation thread"
|
||||
}
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
|
||||
@@ -607,9 +607,12 @@
|
||||
"DRAFT": "Qaralama",
|
||||
"ARCHIVE": "Archive",
|
||||
"TRANSLATE": "Tərcümə et",
|
||||
"MOVE_TO_CATEGORY": "Category",
|
||||
"DELETE": "Delete",
|
||||
"STATUS_SUCCESS": "Articles updated successfully",
|
||||
"STATUS_ERROR": "Failed to update articles",
|
||||
"CATEGORY_SUCCESS": "Articles moved successfully",
|
||||
"CATEGORY_ERROR": "Failed to move articles",
|
||||
"DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles",
|
||||
"DELETE_CONFIRM_DESCRIPTION": "This will permanently delete the selected article. This action cannot be undone. | This will permanently delete {count} selected articles. This action cannot be undone.",
|
||||
"DELETE_CONFIRM": "Delete",
|
||||
@@ -866,6 +869,28 @@
|
||||
},
|
||||
"EDIT_CONFIGURATION": "Edit configuration"
|
||||
},
|
||||
"LAYOUT_CONTENT": {
|
||||
"HEADER": "Appearance",
|
||||
"DESCRIPTION": "Pick the layout that fits how your visitors read.",
|
||||
"LAYOUT": {
|
||||
"CLASSIC": {
|
||||
"TITLE": "Classic",
|
||||
"DESCRIPTION": "A welcoming home page with search and featured topics."
|
||||
},
|
||||
"SIDEBAR": {
|
||||
"TITLE": "Documentation",
|
||||
"DESCRIPTION": "Side-by-side navigation that keeps every guide a click away."
|
||||
}
|
||||
},
|
||||
"SOCIAL_LINKS": {
|
||||
"HEADER": "Social links",
|
||||
"DESCRIPTION": "Add the handle for each network and your help center builds the full link. Shown in the documentation layout footer.",
|
||||
"PLACEHOLDER": "handle",
|
||||
"ADD": "Add social link",
|
||||
"REMOVE": "Sil"
|
||||
},
|
||||
"SAVE": "Save changes"
|
||||
},
|
||||
"API": {
|
||||
"CREATE_PORTAL": {
|
||||
"SUCCESS_MESSAGE": "Portal created successfully",
|
||||
|
||||
@@ -308,6 +308,7 @@
|
||||
"AUTH_PROCESSING": "Meta ilə autentifikasiya olunur",
|
||||
"WAITING_FOR_BUSINESS_INFO": "Zəhmət olmasa Meta pəncərəsində biznes quraşdırmasını tamamlayın...",
|
||||
"PROCESSING": "WhatsApp Biznes Hesabınızı quraşdırırıq",
|
||||
"ENABLING_CALLING": "Enabling WhatsApp Calling on your number…",
|
||||
"LOADING_SDK": "Facebook SDK yüklənir...",
|
||||
"CANCELLED": "WhatsApp Qeydiyyatı ləğv edildi",
|
||||
"SUCCESS_TITLE": "WhatsApp Biznes Hesabı Qoşuldu!",
|
||||
@@ -317,7 +318,8 @@
|
||||
"AUTH_NOT_COMPLETED": "Autentifikasiya tamamlanmadı. Zəhmət olmasa prosesi yenidən başladın.",
|
||||
"SUCCESS_FALLBACK": "WhatsApp Biznes Hesabı uğurla konfiqurasiya edildi",
|
||||
"MANUAL_FALLBACK": "Əgər nömrəniz artıq WhatsApp Business Platformasına (API) qoşulubsa və ya texnoloji təminatçı olaraq öz nömrənizi əlavə edirsinizsə, zəhmət olmasa {link} prosesindən istifadə edin",
|
||||
"MANUAL_LINK_TEXT": "əl ilə qurma prosesi"
|
||||
"MANUAL_LINK_TEXT": "əl ilə qurma prosesi",
|
||||
"CALLING_ENABLE_FAILED": "Your WhatsApp inbox is ready, but voice calling couldn't be turned on — this number isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then turn calling on from the inbox's Calls settings."
|
||||
},
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "WhatsApp kanalını yadda saxlaya bilmədik"
|
||||
@@ -465,6 +467,10 @@
|
||||
"TITLE": "WhatsApp",
|
||||
"DESCRIPTION": "Müştərilərinizi WhatsApp-da dəstəkləyin"
|
||||
},
|
||||
"WHATSAPP_CALL": {
|
||||
"TITLE": "WhatsApp Call",
|
||||
"DESCRIPTION": "Take voice calls on your WhatsApp number"
|
||||
},
|
||||
"EMAIL": {
|
||||
"TITLE": "Elektron Poçt",
|
||||
"DESCRIPTION": "Gmail, Outlook və ya digər təminatçılarla qoşulun"
|
||||
@@ -637,7 +643,8 @@
|
||||
"BOT_CONFIGURATION": "Bot Konfiqurasiyası",
|
||||
"ACCOUNT_HEALTH": "Hesabın sağlamlığı",
|
||||
"CSAT": "CSAT",
|
||||
"VOICE": "Səs"
|
||||
"VOICE": "Səs",
|
||||
"CALLS": "Calls"
|
||||
},
|
||||
"VOICE_CONFIGURATION": {
|
||||
"ENABLE_VOICE": {
|
||||
@@ -648,6 +655,26 @@
|
||||
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
|
||||
}
|
||||
},
|
||||
"WHATSAPP_CALLING": {
|
||||
"ENABLE": {
|
||||
"LABEL": "Enable WhatsApp Calling",
|
||||
"DESCRIPTION": "Allow agents to receive and place WhatsApp Cloud calls on this inbox. Customers can call this business number directly from WhatsApp."
|
||||
},
|
||||
"ENABLE_FAILED": "Voice calling couldn't be turned on for this number — it isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then try again.",
|
||||
"PHONE_NUMBER": {
|
||||
"LABEL": "Business phone number",
|
||||
"HELP_TEXT": "WhatsApp number that customers will call."
|
||||
},
|
||||
"HOW_IT_WORKS": {
|
||||
"LABEL": "How it works",
|
||||
"DESCRIPTION": "Calls are placed peer-to-peer between the agent's browser and Meta — no extra credentials are required. Make sure the agent's browser has microphone permission for this site."
|
||||
},
|
||||
"PERMISSION_REQUEST_BODY": {
|
||||
"LABEL": "Call permission request message",
|
||||
"HELP_TEXT": "Shown to the contact when they haven't yet consented to receive calls. Leave blank to use the default.",
|
||||
"PLACEHOLDER": "We would like to call you regarding your conversation."
|
||||
}
|
||||
},
|
||||
"CHANNEL_PREFERENCES": "Kanal üstünlükləri",
|
||||
"WIDGET_FEATURES": "Widget xüsusiyyətləri",
|
||||
"ACCOUNT_HEALTH": {
|
||||
@@ -800,6 +827,10 @@
|
||||
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Mövcud şablonlarınızı yeniləmək üçün WhatsApp-dan mesaj şablonlarını əl ilə sinxronlaşdırın.",
|
||||
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Şablonları sinxronlaşdır",
|
||||
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Şablonların sinxronizasiyası uğurla başlandı. Yenilənməsi bir neçə dəqiqə çəkə bilər.",
|
||||
"WHATSAPP_CALLING_ENABLED": {
|
||||
"LABEL": "Enable voice calling",
|
||||
"DESCRIPTION": "Allow agents to start and receive WhatsApp voice calls on this inbox. Available only on embedded-signup WhatsApp Cloud channels with calling permission granted by Meta."
|
||||
},
|
||||
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Pre Chat Form Parametrlərini Yeniləyin"
|
||||
},
|
||||
"HELP_CENTER": {
|
||||
|
||||
@@ -57,7 +57,8 @@
|
||||
"CONTACT_CREATED": "Əlaqə yaradıldı",
|
||||
"CONTACT_UPDATED": "Əlaqə yeniləndi",
|
||||
"CONVERSATION_TYPING_ON": "Söhbət Yazılır",
|
||||
"CONVERSATION_TYPING_OFF": "Söhbət Yazması Söndürülüb"
|
||||
"CONVERSATION_TYPING_OFF": "Söhbət Yazması Söndürülüb",
|
||||
"INBOX_UPDATED": "Inbox updated"
|
||||
}
|
||||
},
|
||||
"NAME": {
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
{
|
||||
"BULK_ACTION": {
|
||||
"CONVERSATIONS_SELECTED": "{conversationCount} conversations selected",
|
||||
"AGENT_SELECT_LABEL": "Изберете агент",
|
||||
"ASSIGN_CONFIRMATION_LABEL": "Are you sure to assign {conversationCount} {conversationLabel} to",
|
||||
"UNASSIGN_CONFIRMATION_LABEL": "Are you sure to unassign {conversationCount} {conversationLabel}?",
|
||||
"GO_BACK_LABEL": "Go back",
|
||||
"ASSIGN_LABEL": "Assign",
|
||||
"NONE": "None",
|
||||
"CLEAR_SELECTION": "Clear",
|
||||
"ASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {agentName}? | Are you sure you want to assign {n} conversations to {agentName}?",
|
||||
@@ -20,7 +15,6 @@
|
||||
"RESOLVE_SUCCESFUL": "Conversations resolved successfully.",
|
||||
"RESOLVE_FAILED": "Failed to resolve conversations. Please try again.",
|
||||
"ALL_CONVERSATIONS_SELECTED_ALERT": "Conversations visible on this page are only selected.",
|
||||
"AGENT_LIST_LOADING": "Loading agents",
|
||||
"UPDATE": {
|
||||
"CHANGE_STATUS": "Change status",
|
||||
"SNOOZE_UNTIL": "Snooze",
|
||||
@@ -33,16 +27,16 @@
|
||||
},
|
||||
"LABELS": {
|
||||
"ASSIGN_LABELS": "Assign labels",
|
||||
"NO_LABELS_FOUND": "Няма намерени етикети",
|
||||
"REMOVE_LABELS": "Remove labels",
|
||||
"ASSIGN_SELECTED_LABELS": "Assign selected labels",
|
||||
"REMOVE_SELECTED_LABELS": "Remove selected labels",
|
||||
"ASSIGN_SUCCESFUL": "Labels assigned successfully.",
|
||||
"ASSIGN_FAILED": "Failed to assign labels. Please try again."
|
||||
"ASSIGN_FAILED": "Failed to assign labels. Please try again.",
|
||||
"REMOVE_SUCCESFUL": "Labels removed successfully.",
|
||||
"REMOVE_FAILED": "Failed to remove labels. Please try again."
|
||||
},
|
||||
"TEAMS": {
|
||||
"TEAM_SELECT_LABEL": "Изберете екип",
|
||||
"NONE": "Нито един",
|
||||
"NO_TEAMS_AVAILABLE": "There are no teams added to this account yet.",
|
||||
"ASSIGN_SELECTED_TEAMS": "Assign selected team.",
|
||||
"ASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {teamName}? | Are you sure you want to assign {n} conversations to {teamName}?",
|
||||
"UNASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?",
|
||||
"ASSIGN_SUCCESFUL": "Teams assigned successfully.",
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
},
|
||||
"CARD": {
|
||||
"STATUS": {
|
||||
"PROCESSING": "Processing",
|
||||
"COMPLETED": "Завършено",
|
||||
"SCHEDULED": "Scheduled"
|
||||
},
|
||||
@@ -146,6 +147,7 @@
|
||||
},
|
||||
"CARD": {
|
||||
"STATUS": {
|
||||
"PROCESSING": "Processing",
|
||||
"COMPLETED": "Завършено",
|
||||
"SCHEDULED": "Scheduled"
|
||||
},
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"CODE": "Code",
|
||||
"BULLET_LIST": "Bullet List",
|
||||
"ORDERED_LIST": "Ordered List",
|
||||
"TABLE": "Table"
|
||||
"TABLE": "Table",
|
||||
"IMAGE": "Image"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
"CALL": "Call",
|
||||
"CALL_INITIATED": "Calling the contact…",
|
||||
"CALL_FAILED": "Unable to start the call. Please try again.",
|
||||
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
|
||||
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
|
||||
"CLICK_TO_EDIT": "Click to edit",
|
||||
"VOICE_INBOX_PICKER": {
|
||||
"TITLE": "Choose a voice inbox"
|
||||
@@ -583,8 +585,11 @@
|
||||
},
|
||||
"CONTACTS_BULK_ACTIONS": {
|
||||
"ASSIGN_LABELS": "Assign Labels",
|
||||
"REMOVE_LABELS": "Remove Labels",
|
||||
"ASSIGN_LABELS_SUCCESS": "Labels assigned successfully.",
|
||||
"ASSIGN_LABELS_FAILED": "Failed to assign labels",
|
||||
"REMOVE_LABELS_SUCCESS": "Labels removed successfully.",
|
||||
"REMOVE_LABELS_FAILED": "Failed to remove labels",
|
||||
"DESCRIPTION": "Select the labels you want to add to the selected contacts.",
|
||||
"NO_LABELS_FOUND": "No labels available yet.",
|
||||
"SELECTED_COUNT": "{count} selected",
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
"UNSUPPORTED_MESSAGE_FACEBOOK": "This message is unsupported. You can view this message on the Facebook Messenger app.",
|
||||
"UNSUPPORTED_MESSAGE_INSTAGRAM": "This message is unsupported. You can view this message on the Instagram app.",
|
||||
"UNSUPPORTED_MESSAGE_TIKTOK": "This message is unsupported. You can view this message on the TikTok app.",
|
||||
"UNSUPPORTED_MESSAGE_WHATSAPP": "This message is unsupported. You can view this message on the WhatsApp app.",
|
||||
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
|
||||
"FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again",
|
||||
"NO_RESPONSE": "No response",
|
||||
@@ -79,13 +80,22 @@
|
||||
"OUTGOING_CALL": "Outgoing call",
|
||||
"CALL_IN_PROGRESS": "Call in progress",
|
||||
"NO_ANSWER": "No answer",
|
||||
"NO_ANSWER_OUTBOUND_LABEL": "No answer",
|
||||
"NO_ANSWER_OUTBOUND_SUBTEXT": "Contact didn't pick up",
|
||||
"MISSED_CALL": "Missed call",
|
||||
"MISSED_CALL_INBOUND_SUBTEXT": "No agent picked up",
|
||||
"MISSED_CALL_DECLINED_BY": "Declined by {agentName}",
|
||||
"CALL_ENDED": "Call ended",
|
||||
"HANDLED_BY": "Handled by {agentName}",
|
||||
"NOT_ANSWERED_YET": "Not answered yet",
|
||||
"CALLING": "Calling…",
|
||||
"THEY_ANSWERED": "They answered",
|
||||
"YOU_ANSWERED": "You answered",
|
||||
"AGENT_ANSWERED": "{agentName} answered",
|
||||
"JOIN_CALL": "Join call"
|
||||
"JOIN_CALL": "Join call",
|
||||
"CALL_BACK": "Call back",
|
||||
"TRANSCRIPT_SHOW_MORE": "Show more",
|
||||
"TRANSCRIPT_SHOW_LESS": "Show less"
|
||||
},
|
||||
"HEADER": {
|
||||
"RESOLVE_ACTION": "Resolve",
|
||||
@@ -100,6 +110,12 @@
|
||||
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
|
||||
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week",
|
||||
"SNOOZED_UNTIL_NEXT_REPLY": "Snoozed until next reply",
|
||||
"WHATSAPP_CALL": "Start WhatsApp call",
|
||||
"WHATSAPP_CALL_FAILED": "Could not start the WhatsApp call.",
|
||||
"VOICE_CALL": "Start call",
|
||||
"VOICE_CALL_FAILED": "Could not start the call.",
|
||||
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
|
||||
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
|
||||
"SLA_STATUS": {
|
||||
"FRT": "FRT {status}",
|
||||
"NRT": "NRT {status}",
|
||||
@@ -215,6 +231,7 @@
|
||||
"TIP_AUDIORECORDER_ICON": "Record audio",
|
||||
"TIP_AUDIORECORDER_PERMISSION": "Allow access to audio",
|
||||
"TIP_AUDIORECORDER_ERROR": "Could not open the audio",
|
||||
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
|
||||
"DRAG_DROP": "Drag and drop here to attach",
|
||||
"START_AUDIO_RECORDING": "Start audio recording",
|
||||
"STOP_AUDIO_RECORDING": "Stop audio recording",
|
||||
@@ -299,8 +316,13 @@
|
||||
"NOT_ANSWERED_YET": "Not answered yet",
|
||||
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
|
||||
"REJECT_CALL": "Reject",
|
||||
"DISMISS_CALL": "Dismiss",
|
||||
"JOIN_CALL": "Join call",
|
||||
"END_CALL": "End call"
|
||||
"END_CALL": "End call",
|
||||
"MUTE": "Mute mic",
|
||||
"UNMUTE": "Unmute mic",
|
||||
"VIEW_CHAT_HISTORY": "View chat history",
|
||||
"GO_TO_CONVERSATION": "Go to conversation thread"
|
||||
}
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
|
||||
@@ -607,9 +607,12 @@
|
||||
"DRAFT": "Draft",
|
||||
"ARCHIVE": "Archive",
|
||||
"TRANSLATE": "Translate",
|
||||
"MOVE_TO_CATEGORY": "Category",
|
||||
"DELETE": "Изтрий",
|
||||
"STATUS_SUCCESS": "Articles updated successfully",
|
||||
"STATUS_ERROR": "Failed to update articles",
|
||||
"CATEGORY_SUCCESS": "Articles moved successfully",
|
||||
"CATEGORY_ERROR": "Failed to move articles",
|
||||
"DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles",
|
||||
"DELETE_CONFIRM_DESCRIPTION": "This will permanently delete the selected article. This action cannot be undone. | This will permanently delete {count} selected articles. This action cannot be undone.",
|
||||
"DELETE_CONFIRM": "Изтрий",
|
||||
@@ -866,6 +869,28 @@
|
||||
},
|
||||
"EDIT_CONFIGURATION": "Edit configuration"
|
||||
},
|
||||
"LAYOUT_CONTENT": {
|
||||
"HEADER": "Appearance",
|
||||
"DESCRIPTION": "Pick the layout that fits how your visitors read.",
|
||||
"LAYOUT": {
|
||||
"CLASSIC": {
|
||||
"TITLE": "Classic",
|
||||
"DESCRIPTION": "A welcoming home page with search and featured topics."
|
||||
},
|
||||
"SIDEBAR": {
|
||||
"TITLE": "Documentation",
|
||||
"DESCRIPTION": "Side-by-side navigation that keeps every guide a click away."
|
||||
}
|
||||
},
|
||||
"SOCIAL_LINKS": {
|
||||
"HEADER": "Social links",
|
||||
"DESCRIPTION": "Add the handle for each network and your help center builds the full link. Shown in the documentation layout footer.",
|
||||
"PLACEHOLDER": "handle",
|
||||
"ADD": "Add social link",
|
||||
"REMOVE": "Remove"
|
||||
},
|
||||
"SAVE": "Save changes"
|
||||
},
|
||||
"API": {
|
||||
"CREATE_PORTAL": {
|
||||
"SUCCESS_MESSAGE": "Portal created successfully",
|
||||
|
||||
@@ -308,6 +308,7 @@
|
||||
"AUTH_PROCESSING": "Authenticating with Meta",
|
||||
"WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...",
|
||||
"PROCESSING": "Setting up your WhatsApp Business Account",
|
||||
"ENABLING_CALLING": "Enabling WhatsApp Calling on your number…",
|
||||
"LOADING_SDK": "Loading Facebook SDK...",
|
||||
"CANCELLED": "WhatsApp Signup was cancelled",
|
||||
"SUCCESS_TITLE": "WhatsApp Business Account Connected!",
|
||||
@@ -317,7 +318,8 @@
|
||||
"AUTH_NOT_COMPLETED": "Authentication not completed. Please restart the process.",
|
||||
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured",
|
||||
"MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if you’re a tech provider onboarding your own number, please use the {link} flow",
|
||||
"MANUAL_LINK_TEXT": "manual setup flow"
|
||||
"MANUAL_LINK_TEXT": "manual setup flow",
|
||||
"CALLING_ENABLE_FAILED": "Your WhatsApp inbox is ready, but voice calling couldn't be turned on — this number isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then turn calling on from the inbox's Calls settings."
|
||||
},
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
|
||||
@@ -465,6 +467,10 @@
|
||||
"TITLE": "WhatsApp",
|
||||
"DESCRIPTION": "Support your customers on WhatsApp"
|
||||
},
|
||||
"WHATSAPP_CALL": {
|
||||
"TITLE": "WhatsApp Call",
|
||||
"DESCRIPTION": "Take voice calls on your WhatsApp number"
|
||||
},
|
||||
"EMAIL": {
|
||||
"TITLE": "Email",
|
||||
"DESCRIPTION": "Connect with Gmail, Outlook, or other providers"
|
||||
@@ -637,7 +643,8 @@
|
||||
"BOT_CONFIGURATION": "Bot Configuration",
|
||||
"ACCOUNT_HEALTH": "Account Health",
|
||||
"CSAT": "CSAT",
|
||||
"VOICE": "Voice"
|
||||
"VOICE": "Voice",
|
||||
"CALLS": "Calls"
|
||||
},
|
||||
"VOICE_CONFIGURATION": {
|
||||
"ENABLE_VOICE": {
|
||||
@@ -648,6 +655,26 @@
|
||||
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
|
||||
}
|
||||
},
|
||||
"WHATSAPP_CALLING": {
|
||||
"ENABLE": {
|
||||
"LABEL": "Enable WhatsApp Calling",
|
||||
"DESCRIPTION": "Allow agents to receive and place WhatsApp Cloud calls on this inbox. Customers can call this business number directly from WhatsApp."
|
||||
},
|
||||
"ENABLE_FAILED": "Voice calling couldn't be turned on for this number — it isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then try again.",
|
||||
"PHONE_NUMBER": {
|
||||
"LABEL": "Business phone number",
|
||||
"HELP_TEXT": "WhatsApp number that customers will call."
|
||||
},
|
||||
"HOW_IT_WORKS": {
|
||||
"LABEL": "How it works",
|
||||
"DESCRIPTION": "Calls are placed peer-to-peer between the agent's browser and Meta — no extra credentials are required. Make sure the agent's browser has microphone permission for this site."
|
||||
},
|
||||
"PERMISSION_REQUEST_BODY": {
|
||||
"LABEL": "Call permission request message",
|
||||
"HELP_TEXT": "Shown to the contact when they haven't yet consented to receive calls. Leave blank to use the default.",
|
||||
"PLACEHOLDER": "We would like to call you regarding your conversation."
|
||||
}
|
||||
},
|
||||
"CHANNEL_PREFERENCES": "Channel Preferences",
|
||||
"WIDGET_FEATURES": "Widget features",
|
||||
"ACCOUNT_HEALTH": {
|
||||
@@ -800,6 +827,10 @@
|
||||
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Manually sync message templates from WhatsApp to update your available templates.",
|
||||
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sync Templates",
|
||||
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Templates sync initiated successfully. It may take a couple of minutes to update.",
|
||||
"WHATSAPP_CALLING_ENABLED": {
|
||||
"LABEL": "Enable voice calling",
|
||||
"DESCRIPTION": "Allow agents to start and receive WhatsApp voice calls on this inbox. Available only on embedded-signup WhatsApp Cloud channels with calling permission granted by Meta."
|
||||
},
|
||||
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings"
|
||||
},
|
||||
"HELP_CENTER": {
|
||||
|
||||
@@ -57,7 +57,8 @@
|
||||
"CONTACT_CREATED": "Contact created",
|
||||
"CONTACT_UPDATED": "Contact updated",
|
||||
"CONVERSATION_TYPING_ON": "Conversation Typing On",
|
||||
"CONVERSATION_TYPING_OFF": "Conversation Typing Off"
|
||||
"CONVERSATION_TYPING_OFF": "Conversation Typing Off",
|
||||
"INBOX_UPDATED": "Inbox updated"
|
||||
}
|
||||
},
|
||||
"NAME": {
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
{
|
||||
"BULK_ACTION": {
|
||||
"CONVERSATIONS_SELECTED": "{conversationCount} conversations selected",
|
||||
"AGENT_SELECT_LABEL": "Select agent",
|
||||
"ASSIGN_CONFIRMATION_LABEL": "Are you sure to assign {conversationCount} {conversationLabel} to",
|
||||
"UNASSIGN_CONFIRMATION_LABEL": "Are you sure to unassign {conversationCount} {conversationLabel}?",
|
||||
"GO_BACK_LABEL": "Go back",
|
||||
"ASSIGN_LABEL": "Assign",
|
||||
"NONE": "কিছুই না",
|
||||
"CLEAR_SELECTION": "Clear",
|
||||
"ASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {agentName}? | Are you sure you want to assign {n} conversations to {agentName}?",
|
||||
@@ -20,7 +15,6 @@
|
||||
"RESOLVE_SUCCESFUL": "Conversations resolved successfully.",
|
||||
"RESOLVE_FAILED": "Failed to resolve conversations. Please try again.",
|
||||
"ALL_CONVERSATIONS_SELECTED_ALERT": "Conversations visible on this page are only selected.",
|
||||
"AGENT_LIST_LOADING": "Loading agents",
|
||||
"UPDATE": {
|
||||
"CHANGE_STATUS": "Change status",
|
||||
"SNOOZE_UNTIL": "Snooze",
|
||||
@@ -33,16 +27,16 @@
|
||||
},
|
||||
"LABELS": {
|
||||
"ASSIGN_LABELS": "Assign labels",
|
||||
"NO_LABELS_FOUND": "No labels found",
|
||||
"REMOVE_LABELS": "Remove labels",
|
||||
"ASSIGN_SELECTED_LABELS": "Assign selected labels",
|
||||
"REMOVE_SELECTED_LABELS": "Remove selected labels",
|
||||
"ASSIGN_SUCCESFUL": "Labels assigned successfully.",
|
||||
"ASSIGN_FAILED": "Failed to assign labels. Please try again."
|
||||
"ASSIGN_FAILED": "Failed to assign labels. Please try again.",
|
||||
"REMOVE_SUCCESFUL": "Labels removed successfully.",
|
||||
"REMOVE_FAILED": "Failed to remove labels. Please try again."
|
||||
},
|
||||
"TEAMS": {
|
||||
"TEAM_SELECT_LABEL": "Select team",
|
||||
"NONE": "None",
|
||||
"NO_TEAMS_AVAILABLE": "There are no teams added to this account yet.",
|
||||
"ASSIGN_SELECTED_TEAMS": "Assign selected team.",
|
||||
"ASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {teamName}? | Are you sure you want to assign {n} conversations to {teamName}?",
|
||||
"UNASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?",
|
||||
"ASSIGN_SUCCESFUL": "Teams assigned successfully.",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user