Compare commits
227
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30e4585af2 | ||
|
|
3dba5f55a3 | ||
|
|
42d14ae4d0 | ||
|
|
2ed7b53ca3 | ||
|
|
e82ba57464 | ||
|
|
adc260ab27 | ||
|
|
ad65bace3b | ||
|
|
1b39d151f4 | ||
|
|
8e1fa6e03d | ||
|
|
ba0b526009 | ||
|
|
0fa5a75302 | ||
|
|
7c8bd0ca91 | ||
|
|
c05cd06d96 | ||
|
|
07438acad5 | ||
|
|
5e591b9ceb | ||
|
|
260d16afe1 | ||
|
|
d22c1fc4b7 | ||
|
|
1f69cfbb5c | ||
|
|
8badb131b8 | ||
|
|
0e4c352571 | ||
|
|
bb4a7cf2f2 | ||
|
|
04e667b0a4 | ||
|
|
b1c97d4846 | ||
|
|
88ac480da9 | ||
|
|
7694ca0cc5 | ||
|
|
7ea9c402a1 | ||
|
|
8df10a9397 | ||
|
|
65ba5c2eae | ||
|
|
d999e955d6 | ||
|
|
6143d9dea7 | ||
|
|
854c9bcf55 | ||
|
|
19702eda8b | ||
|
|
75a51786ec | ||
|
|
3d7cfd4c85 | ||
|
|
a3bf14955c | ||
|
|
1980fdf278 | ||
|
|
0ac2be508d | ||
|
|
2fe2d04208 | ||
|
|
ccc0357fee | ||
|
|
b934297a99 | ||
|
|
63bec71927 | ||
|
|
902d268ba7 | ||
|
|
c610e288cb | ||
|
|
5cb7978997 | ||
|
|
c87cfbafbc | ||
|
|
f6e6c027cd | ||
|
|
21bc4167fa | ||
|
|
abffd343f1 | ||
|
|
b362ee3e3c | ||
|
|
b95e1b94a7 | ||
|
|
be7469f342 | ||
|
|
602af113a1 | ||
|
|
313222c52a | ||
|
|
1e80e88bb7 | ||
|
|
d3c4e95edf | ||
|
|
429fba6ddd | ||
|
|
a9d80705cb | ||
|
|
01ad16dab8 | ||
|
|
7877d6a080 | ||
|
|
734b1f5a3d | ||
|
|
38cc568bb0 | ||
|
|
ed8c24c043 | ||
|
|
9855995480 | ||
|
|
1c6e64b2f0 | ||
|
|
f8ad752c86 | ||
|
|
ef31d99431 | ||
|
|
44fc22ab5c | ||
|
|
3f05d8972e | ||
|
|
25a731eb6c | ||
|
|
063460b198 | ||
|
|
670545b317 | ||
|
|
3da8ae601d | ||
|
|
919154e26c | ||
|
|
8859cdff07 | ||
|
|
e8e4d04073 | ||
|
|
f2dd1e7806 | ||
|
|
597158c221 | ||
|
|
b276344f0c | ||
|
|
e9a882e7bc | ||
|
|
f6f757c358 | ||
|
|
92723b0d23 | ||
|
|
e1b1e1f286 | ||
|
|
6b2a4d70e8 | ||
|
|
a9767234f9 | ||
|
|
88cb9c22f0 | ||
|
|
913547c3b4 | ||
|
|
714ba2d823 | ||
|
|
a08c286871 | ||
|
|
436a63f584 | ||
|
|
e87ccfac50 | ||
|
|
c5044fce96 | ||
|
|
07e0b35529 | ||
|
|
a614a8e327 | ||
|
|
d96b79855c | ||
|
|
194bb7b317 | ||
|
|
3ed140ef80 | ||
|
|
06f2db89af | ||
|
|
582a984ea3 | ||
|
|
ced1532a31 | ||
|
|
8b7a0528aa | ||
|
|
c86a9e1236 | ||
|
|
8292d611cd | ||
|
|
7d09ab7086 | ||
|
|
956ed80cbd | ||
|
|
5a3d38b854 | ||
|
|
088f48f401 | ||
|
|
37bbde5600 | ||
|
|
d31e72254f | ||
|
|
558b1c9787 | ||
|
|
293bdbdb2e | ||
|
|
aca0e1ae6d | ||
|
|
bdc6d15e62 | ||
|
|
a640c127b2 | ||
|
|
3ee3dd0df6 | ||
|
|
75260980c5 | ||
|
|
7d46f51669 | ||
|
|
f28e1bd016 | ||
|
|
619edb5eb0 | ||
|
|
153b5675ca | ||
|
|
60c623121d | ||
|
|
eb49cbec51 | ||
|
|
e80510632c | ||
|
|
5f66f4b0a7 | ||
|
|
0fa18cddda | ||
|
|
fd6ad2b1e1 | ||
|
|
04d5279411 | ||
|
|
f7058a8af4 | ||
|
|
eecfd8132b | ||
|
|
70ed790098 | ||
|
|
6c3e667fac | ||
|
|
7494baef3c | ||
|
|
f2034c752f | ||
|
|
2d489c1c0a | ||
|
|
53ede69a73 | ||
|
|
c2e2b320ef | ||
|
|
cb05a318a7 | ||
|
|
fd9beedb68 | ||
|
|
e2061fb624 | ||
|
|
ce5803d312 | ||
|
|
cba2647b5e | ||
|
|
c3e438b365 | ||
|
|
a48fea0a7f | ||
|
|
80daea6054 | ||
|
|
cd93459320 | ||
|
|
9801137296 | ||
|
|
d4890a6e52 | ||
|
|
79c8df7c4f | ||
|
|
e204ed95ce | ||
|
|
34a3da97b2 | ||
|
|
5da34f916e | ||
|
|
4a31dd2120 | ||
|
|
6ca32faeab | ||
|
|
186d8c9462 | ||
|
|
50a46d123a | ||
|
|
ec5dee5c46 | ||
|
|
93cc1f339a | ||
|
|
3468ca48c3 | ||
|
|
47e2e4371f | ||
|
|
9f3d2d545b | ||
|
|
4e04fd3d08 | ||
|
|
e064899267 | ||
|
|
3594ce9bb5 | ||
|
|
03777d7cc1 | ||
|
|
e3e238bce8 | ||
|
|
e0e8a70720 | ||
|
|
e2d1ce7554 | ||
|
|
1dd5eb64cf | ||
|
|
0c6d2cc0b7 | ||
|
|
b6ff4f54be | ||
|
|
07c92c6097 | ||
|
|
cafa486c18 | ||
|
|
baea194f6b | ||
|
|
583ba7c2a0 | ||
|
|
1a54cc4990 | ||
|
|
60099a4d5b | ||
|
|
dcb2ab7c99 | ||
|
|
aa5735df39 | ||
|
|
81bd22d0dd | ||
|
|
f8ff3b8d74 | ||
|
|
20ccca72ae | ||
|
|
60fafc65ca | ||
|
|
f53ae6457c | ||
|
|
2bb9886c6d | ||
|
|
9b9875b847 | ||
|
|
7e5f19e2b0 | ||
|
|
0d73760dac | ||
|
|
8527170d82 | ||
|
|
044282730f | ||
|
|
d5bf7f81cb | ||
|
|
8b125efac2 | ||
|
|
69ea2dc58d | ||
|
|
5cc2004a71 | ||
|
|
92ac1c3825 | ||
|
|
bc8f528f90 | ||
|
|
bef1a6047a | ||
|
|
ab4eb0e7d7 | ||
|
|
e5b64287ac | ||
|
|
34a480e799 | ||
|
|
58b067c08f | ||
|
|
78faf125bd | ||
|
|
0d9662477d | ||
|
|
3e31f63f09 | ||
|
|
b84d98b90b | ||
|
|
3a75ec02f0 | ||
|
|
9cbe749994 | ||
|
|
e9ea5cbe45 | ||
|
|
665549d52d | ||
|
|
17db86c2fe | ||
|
|
0ace7f38a6 | ||
|
|
4a2250065c | ||
|
|
31ba25b12b | ||
|
|
bc5bd51003 | ||
|
|
6d933112e7 | ||
|
|
45d7e570b7 | ||
|
|
bb3ad70f0e | ||
|
|
c983c87537 | ||
|
|
c5def0522f | ||
|
|
b95e59ee0d | ||
|
|
f49d825e8b | ||
|
|
75a82d9ab7 | ||
|
|
0eb121df75 | ||
|
|
18e45a0da6 | ||
|
|
df14e97ff6 | ||
|
|
63c9cf3ad1 | ||
|
|
ec36442f57 | ||
|
|
b81c43a5a2 | ||
|
|
23cad33d96 |
@@ -76,7 +76,7 @@ gem 'faraday_middleware-aws-sigv4'
|
|||||||
##--- gems for server & infra configuration ---##
|
##--- gems for server & infra configuration ---##
|
||||||
gem 'dotenv-rails', '>= 3.0.0'
|
gem 'dotenv-rails', '>= 3.0.0'
|
||||||
gem 'foreman'
|
gem 'foreman'
|
||||||
gem 'puma', '~> 7.2', '>= 7.2.1'
|
gem 'puma'
|
||||||
gem 'vite_rails'
|
gem 'vite_rails'
|
||||||
# metrics on heroku
|
# metrics on heroku
|
||||||
gem 'barnes'
|
gem 'barnes'
|
||||||
|
|||||||
+23
-34
@@ -136,8 +136,6 @@ GEM
|
|||||||
audited (5.4.1)
|
audited (5.4.1)
|
||||||
activerecord (>= 5.0, < 7.7)
|
activerecord (>= 5.0, < 7.7)
|
||||||
activesupport (>= 5.0, < 7.7)
|
activesupport (>= 5.0, < 7.7)
|
||||||
auth-sanitizer (0.2.1)
|
|
||||||
version_gem (~> 1.1, >= 1.1.10)
|
|
||||||
aws-actionmailbox-ses (0.1.0)
|
aws-actionmailbox-ses (0.1.0)
|
||||||
actionmailbox (>= 7.1.0)
|
actionmailbox (>= 7.1.0)
|
||||||
aws-sdk-s3 (~> 1, >= 1.123.0)
|
aws-sdk-s3 (~> 1, >= 1.123.0)
|
||||||
@@ -170,7 +168,7 @@ GEM
|
|||||||
base64 (0.3.0)
|
base64 (0.3.0)
|
||||||
bcrypt (3.1.22)
|
bcrypt (3.1.22)
|
||||||
benchmark (0.4.1)
|
benchmark (0.4.1)
|
||||||
bigdecimal (3.3.1)
|
bigdecimal (3.2.2)
|
||||||
bindex (0.8.1)
|
bindex (0.8.1)
|
||||||
bootsnap (1.16.0)
|
bootsnap (1.16.0)
|
||||||
msgpack (~> 1.2)
|
msgpack (~> 1.2)
|
||||||
@@ -186,7 +184,6 @@ GEM
|
|||||||
bundler (>= 1.2.0, < 3)
|
bundler (>= 1.2.0, < 3)
|
||||||
thor (~> 1.0)
|
thor (~> 1.0)
|
||||||
byebug (11.1.3)
|
byebug (11.1.3)
|
||||||
cgi (0.5.1)
|
|
||||||
childprocess (5.1.0)
|
childprocess (5.1.0)
|
||||||
logger (~> 1.5)
|
logger (~> 1.5)
|
||||||
cld3 (3.7.0)
|
cld3 (3.7.0)
|
||||||
@@ -315,7 +312,7 @@ GEM
|
|||||||
hashie
|
hashie
|
||||||
faraday-multipart (1.2.0)
|
faraday-multipart (1.2.0)
|
||||||
multipart-post (~> 2.0)
|
multipart-post (~> 2.0)
|
||||||
faraday-net_http (3.4.4)
|
faraday-net_http (3.4.2)
|
||||||
net-http (~> 0.5)
|
net-http (~> 0.5)
|
||||||
faraday-net_http_persistent (2.1.0)
|
faraday-net_http_persistent (2.1.0)
|
||||||
faraday (~> 2.5)
|
faraday (~> 2.5)
|
||||||
@@ -438,8 +435,7 @@ GEM
|
|||||||
hana (1.3.7)
|
hana (1.3.7)
|
||||||
hash_diff (1.1.1)
|
hash_diff (1.1.1)
|
||||||
hashdiff (1.1.0)
|
hashdiff (1.1.0)
|
||||||
hashie (5.1.0)
|
hashie (5.0.0)
|
||||||
logger
|
|
||||||
html2text (0.4.0)
|
html2text (0.4.0)
|
||||||
nokogiri (>= 1.0, < 2.0)
|
nokogiri (>= 1.0, < 2.0)
|
||||||
http (5.1.1)
|
http (5.1.1)
|
||||||
@@ -474,7 +470,7 @@ GEM
|
|||||||
rails-dom-testing (>= 1, < 3)
|
rails-dom-testing (>= 1, < 3)
|
||||||
railties (>= 4.2.0)
|
railties (>= 4.2.0)
|
||||||
thor (>= 0.14, < 2.0)
|
thor (>= 0.14, < 2.0)
|
||||||
json (2.19.8)
|
json (2.19.5)
|
||||||
json_refs (0.1.8)
|
json_refs (0.1.8)
|
||||||
hana
|
hana
|
||||||
json_schemer (0.2.24)
|
json_schemer (0.2.24)
|
||||||
@@ -572,7 +568,7 @@ GEM
|
|||||||
ruby2_keywords
|
ruby2_keywords
|
||||||
msgpack (1.8.0)
|
msgpack (1.8.0)
|
||||||
multi_json (1.15.0)
|
multi_json (1.15.0)
|
||||||
multi_xml (0.9.1)
|
multi_xml (0.8.0)
|
||||||
bigdecimal (>= 3.1, < 5)
|
bigdecimal (>= 3.1, < 5)
|
||||||
multipart-post (2.4.1)
|
multipart-post (2.4.1)
|
||||||
mutex_m (0.3.0)
|
mutex_m (0.3.0)
|
||||||
@@ -597,7 +593,7 @@ GEM
|
|||||||
sidekiq
|
sidekiq
|
||||||
newrelic_rpm (9.6.0)
|
newrelic_rpm (9.6.0)
|
||||||
base64
|
base64
|
||||||
nio4r (2.7.5)
|
nio4r (2.7.3)
|
||||||
nokogiri (1.19.3)
|
nokogiri (1.19.3)
|
||||||
mini_portile2 (~> 2.8.2)
|
mini_portile2 (~> 2.8.2)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
@@ -607,26 +603,19 @@ GEM
|
|||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.3-x86_64-linux-gnu)
|
nokogiri (1.19.3-x86_64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
oauth (1.1.6)
|
oauth (1.1.0)
|
||||||
auth-sanitizer (~> 0.2, >= 0.2.1)
|
oauth-tty (~> 1.0, >= 1.0.1)
|
||||||
base64 (~> 0.1)
|
snaky_hash (~> 2.0)
|
||||||
cgi
|
version_gem (~> 1.1)
|
||||||
oauth-tty (~> 1.0, >= 1.0.8)
|
oauth-tty (1.0.5)
|
||||||
snaky_hash (~> 2.0, >= 2.0.5)
|
version_gem (~> 1.1, >= 1.1.1)
|
||||||
version_gem (~> 1.1, >= 1.1.11)
|
oauth2 (2.0.9)
|
||||||
oauth-tty (1.0.8)
|
faraday (>= 0.17.3, < 3.0)
|
||||||
auth-sanitizer (~> 0.1, >= 0.1.3)
|
jwt (>= 1.0, < 3.0)
|
||||||
cgi
|
|
||||||
version_gem (~> 1.1, >= 1.1.9)
|
|
||||||
oauth2 (2.0.22)
|
|
||||||
auth-sanitizer (~> 0.2, >= 0.2.1)
|
|
||||||
faraday (>= 0.17.3, < 4.0)
|
|
||||||
jwt (>= 1.0, < 4.0)
|
|
||||||
logger (~> 1.2)
|
|
||||||
multi_xml (~> 0.5)
|
multi_xml (~> 0.5)
|
||||||
rack (>= 1.2, < 4)
|
rack (>= 1.2, < 4)
|
||||||
snaky_hash (~> 2.0, >= 2.0.5)
|
snaky_hash (~> 2.0)
|
||||||
version_gem (~> 1.1, >= 1.1.11)
|
version_gem (~> 1.1)
|
||||||
oj (3.16.10)
|
oj (3.16.10)
|
||||||
bigdecimal (>= 3.0)
|
bigdecimal (>= 3.0)
|
||||||
ostruct (>= 0.2)
|
ostruct (>= 0.2)
|
||||||
@@ -693,7 +682,7 @@ GEM
|
|||||||
pry-rails (0.3.9)
|
pry-rails (0.3.9)
|
||||||
pry (>= 0.10.4)
|
pry (>= 0.10.4)
|
||||||
public_suffix (7.0.5)
|
public_suffix (7.0.5)
|
||||||
puma (7.2.1)
|
puma (6.4.3)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
pundit (2.3.0)
|
pundit (2.3.0)
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
@@ -946,9 +935,9 @@ GEM
|
|||||||
gli
|
gli
|
||||||
hashie
|
hashie
|
||||||
logger
|
logger
|
||||||
snaky_hash (2.0.5)
|
snaky_hash (2.0.1)
|
||||||
hashie (>= 0.1.0, < 6)
|
hashie
|
||||||
version_gem (>= 1.1.8, < 3)
|
version_gem (~> 1.1, >= 1.1.1)
|
||||||
sorbet-runtime (0.5.11934)
|
sorbet-runtime (0.5.11934)
|
||||||
spring (4.1.1)
|
spring (4.1.1)
|
||||||
spring-watcher-listen (2.1.0)
|
spring-watcher-listen (2.1.0)
|
||||||
@@ -1006,7 +995,7 @@ GEM
|
|||||||
valid_email2 (5.2.6)
|
valid_email2 (5.2.6)
|
||||||
activemodel (>= 3.2)
|
activemodel (>= 3.2)
|
||||||
mail (~> 2.5)
|
mail (~> 2.5)
|
||||||
version_gem (1.1.11)
|
version_gem (1.1.4)
|
||||||
vite_rails (3.10.0)
|
vite_rails (3.10.0)
|
||||||
railties (>= 5.1, < 9)
|
railties (>= 5.1, < 9)
|
||||||
vite_ruby (~> 3.0, >= 3.2.2)
|
vite_ruby (~> 3.0, >= 3.2.2)
|
||||||
@@ -1143,7 +1132,7 @@ DEPENDENCIES
|
|||||||
pgvector
|
pgvector
|
||||||
procore-sift
|
procore-sift
|
||||||
pry-rails
|
pry-rails
|
||||||
puma (~> 7.2, >= 7.2.1)
|
puma
|
||||||
pundit
|
pundit
|
||||||
rack-attack (>= 6.7.0)
|
rack-attack (>= 6.7.0)
|
||||||
rack-cors (= 2.0.0)
|
rack-cors (= 2.0.0)
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
4.14.2
|
4.14.1
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class ContactInboxWithContactBuilder
|
|||||||
|
|
||||||
def create_contact
|
def create_contact
|
||||||
account.contacts.create!(
|
account.contacts.create!(
|
||||||
name: contact_name,
|
name: contact_attributes[:name] || ::Haikunator.haikunate(1000),
|
||||||
phone_number: contact_attributes[:phone_number],
|
phone_number: contact_attributes[:phone_number],
|
||||||
email: contact_attributes[:email],
|
email: contact_attributes[:email],
|
||||||
identifier: contact_attributes[:identifier],
|
identifier: contact_attributes[:identifier],
|
||||||
@@ -59,11 +59,6 @@ class ContactInboxWithContactBuilder
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def contact_name
|
|
||||||
name = contact_attributes[:name] || ::Haikunator.haikunate(1000)
|
|
||||||
name.truncate(ApplicationRecord::MAX_STRING_COLUMN_LENGTH, omission: '')
|
|
||||||
end
|
|
||||||
|
|
||||||
def find_contact
|
def find_contact
|
||||||
contact = find_contact_by_identifier(contact_attributes[:identifier])
|
contact = find_contact_by_identifier(contact_attributes[:identifier])
|
||||||
contact ||= find_contact_by_email(contact_attributes[:email])
|
contact ||= find_contact_by_email(contact_attributes[:email])
|
||||||
|
|||||||
@@ -1,28 +1,9 @@
|
|||||||
class Api::BaseController < ApplicationController
|
class Api::BaseController < ApplicationController
|
||||||
include AccessTokenAuthHelper
|
include AccessTokenAuthHelper
|
||||||
# POST-shaped endpoints that are semantically reads and should be reachable
|
|
||||||
# with a read-only token. Keep this list minimal; expand only when a clear
|
|
||||||
# read-with-complex-body use case appears.
|
|
||||||
READ_ONLY_POST_ALLOWLIST = %w[
|
|
||||||
api/v1/accounts/conversations#filter
|
|
||||||
api/v1/accounts/contacts#filter
|
|
||||||
api/v1/accounts/contact_inboxes#filter
|
|
||||||
].freeze
|
|
||||||
|
|
||||||
# GET-shaped endpoints that mutate state and must be blocked for read-only
|
|
||||||
# tokens despite the verb being "safe". Add any new write-on-GET endpoint
|
|
||||||
# here when it ships.
|
|
||||||
READ_ONLY_BLOCKED_GET_ACTIONS = %w[
|
|
||||||
api/v1/accounts/callbacks#register_facebook_page
|
|
||||||
api/v1/accounts/portals#ssl_status
|
|
||||||
api/v1/accounts/conference#token
|
|
||||||
].freeze
|
|
||||||
|
|
||||||
respond_to :json
|
respond_to :json
|
||||||
before_action :authenticate_access_token!, if: :authenticate_by_access_token?
|
before_action :authenticate_access_token!, if: :authenticate_by_access_token?
|
||||||
before_action :validate_bot_access_token!, if: :authenticate_by_access_token?
|
before_action :validate_bot_access_token!, if: :authenticate_by_access_token?
|
||||||
before_action :authenticate_user!, unless: :authenticate_by_access_token?
|
before_action :authenticate_user!, unless: :authenticate_by_access_token?
|
||||||
before_action :enforce_read_only_token_scope
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
@@ -30,25 +11,6 @@ class Api::BaseController < ApplicationController
|
|||||||
request.headers[:api_access_token].present? || request.headers[:HTTP_API_ACCESS_TOKEN].present?
|
request.headers[:api_access_token].present? || request.headers[:HTTP_API_ACCESS_TOKEN].present?
|
||||||
end
|
end
|
||||||
|
|
||||||
def enforce_read_only_token_scope
|
|
||||||
return unless @access_token&.scope == 'read_only'
|
|
||||||
return if read_only_token_action_allowed?
|
|
||||||
|
|
||||||
render json: { error: 'This access token is read-only and cannot perform write operations.' },
|
|
||||||
status: :forbidden
|
|
||||||
end
|
|
||||||
|
|
||||||
def read_only_token_action_allowed?
|
|
||||||
action_key = "#{params[:controller]}##{params[:action]}"
|
|
||||||
return READ_ONLY_POST_ALLOWLIST.include?(action_key) unless safe_http_method?
|
|
||||||
|
|
||||||
READ_ONLY_BLOCKED_GET_ACTIONS.exclude?(action_key)
|
|
||||||
end
|
|
||||||
|
|
||||||
def safe_http_method?
|
|
||||||
request.get? || request.head? || request.options?
|
|
||||||
end
|
|
||||||
|
|
||||||
def check_authorization(model = nil)
|
def check_authorization(model = nil)
|
||||||
model ||= controller_name.classify.constantize
|
model ||= controller_name.classify.constantize
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage::DirectUploadsController
|
class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage::DirectUploadsController
|
||||||
include EnsureCurrentAccountHelper
|
include EnsureCurrentAccountHelper
|
||||||
include AccessTokenAuthHelper
|
|
||||||
include RequestExceptionHandler
|
|
||||||
before_action :authenticate_access_token!
|
|
||||||
before_action :validate_bot_access_token!
|
|
||||||
before_action :prevent_read_only_access_token!
|
|
||||||
before_action :current_account
|
before_action :current_account
|
||||||
before_action :conversation
|
before_action :conversation
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
|
|||||||
{
|
{
|
||||||
redirect_uri: "#{base_url}/microsoft/callback",
|
redirect_uri: "#{base_url}/microsoft/callback",
|
||||||
scope: scope,
|
scope: scope,
|
||||||
state: state
|
state: state,
|
||||||
|
prompt: 'consent'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if redirect_url
|
if redirect_url
|
||||||
|
|||||||
@@ -16,10 +16,6 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseControll
|
|||||||
render 'api/v1/accounts/update', format: :json
|
render 'api/v1/accounts/update', format: :json
|
||||||
end
|
end
|
||||||
|
|
||||||
def help_center_generation
|
|
||||||
render json: help_center_generation_status
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def finalizing_account_details?
|
def finalizing_account_details?
|
||||||
@@ -37,15 +33,4 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseControll
|
|||||||
def custom_attributes_params
|
def custom_attributes_params
|
||||||
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website)
|
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website)
|
||||||
end
|
end
|
||||||
|
|
||||||
def help_center_generation_status
|
|
||||||
{
|
|
||||||
generation_id: nil,
|
|
||||||
state: nil,
|
|
||||||
articles_count: 0,
|
|
||||||
categories_count: 0
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
Api::V1::Accounts::OnboardingsController.prepend_mod_with('Api::V1::Accounts::OnboardingsController')
|
|
||||||
|
|||||||
@@ -80,15 +80,10 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
|||||||
:id, :color, :custom_domain, :header_text, :homepage_link,
|
:id, :color, :custom_domain, :header_text, :homepage_link,
|
||||||
:name, :page_title, :slug, :archived,
|
:name, :page_title, :slug, :archived,
|
||||||
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
|
{ 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
|
end
|
||||||
|
|
||||||
def locale_translation_keys
|
|
||||||
params.dig(:portal, :config, :locale_translations)&.keys || []
|
|
||||||
end
|
|
||||||
|
|
||||||
def live_chat_widget_params
|
def live_chat_widget_params
|
||||||
permitted_params = params.permit(:inbox_id)
|
permitted_params = params.permit(:inbox_id)
|
||||||
return {} unless permitted_params.key?(:inbox_id)
|
return {} unless permitted_params.key?(:inbox_id)
|
||||||
|
|||||||
@@ -39,14 +39,7 @@ class Api::V1::ProfilesController < Api::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def reset_access_token
|
def reset_access_token
|
||||||
token = AccessToken.create_or_find_by!(owner: @user, scope: 'full')
|
@user.access_token.regenerate_token
|
||||||
token.regenerate_token
|
|
||||||
@user.reload
|
|
||||||
end
|
|
||||||
|
|
||||||
def reset_read_only_access_token
|
|
||||||
token = AccessToken.create_or_find_by!(owner: @user, scope: 'read_only')
|
|
||||||
token.regenerate_token
|
|
||||||
@user.reload
|
@user.reload
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
module AccessTokenAuthHelper
|
module AccessTokenAuthHelper
|
||||||
BOT_ACCESSIBLE_ENDPOINTS = {
|
BOT_ACCESSIBLE_ENDPOINTS = {
|
||||||
'api/v1/accounts/conversations' => %w[show toggle_status toggle_typing_status toggle_priority create update custom_attributes],
|
'api/v1/accounts/conversations' => %w[toggle_status toggle_typing_status toggle_priority create update custom_attributes],
|
||||||
'api/v1/accounts/conversations/messages' => ['create'],
|
'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
|
}.freeze
|
||||||
|
|
||||||
def ensure_access_token
|
def ensure_access_token
|
||||||
@@ -37,15 +36,4 @@ module AccessTokenAuthHelper
|
|||||||
def agent_bot_accessible?
|
def agent_bot_accessible?
|
||||||
BOT_ACCESSIBLE_ENDPOINTS.fetch(params[:controller], []).include?(params[:action])
|
BOT_ACCESSIBLE_ENDPOINTS.fetch(params[:controller], []).include?(params[:action])
|
||||||
end
|
end
|
||||||
|
|
||||||
# Blocks read-only access tokens from reaching write endpoints that live outside
|
|
||||||
# Api::BaseController (e.g. controllers inheriting from ActiveStorage). Resolves
|
|
||||||
# the token directly since these controllers skip the Api::BaseController chain.
|
|
||||||
def prevent_read_only_access_token!
|
|
||||||
ensure_access_token
|
|
||||||
return unless @access_token&.scope == 'read_only'
|
|
||||||
|
|
||||||
render json: { error: 'This access token is read-only and cannot perform write operations.' },
|
|
||||||
status: :forbidden
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
|
|||||||
layout 'portal'
|
layout 'portal'
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@og_image_url = helpers.set_og_image_url('', @portal.localized_value('header_text', @locale))
|
@og_image_url = helpers.set_og_image_url('', @portal.header_text)
|
||||||
end
|
end
|
||||||
|
|
||||||
def sitemap
|
def sitemap
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class ContactDrop < BaseDrop
|
|||||||
end
|
end
|
||||||
|
|
||||||
def first_name
|
def first_name
|
||||||
@obj.try(:name).try(:split).try(:first).try(:capitalize)
|
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
|
||||||
end
|
end
|
||||||
|
|
||||||
def last_name
|
def last_name
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class UserDrop < BaseDrop
|
|||||||
end
|
end
|
||||||
|
|
||||||
def first_name
|
def first_name
|
||||||
@obj.try(:name).try(:split).try(:first).try(:capitalize)
|
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size).to_i > 1
|
||||||
end
|
end
|
||||||
|
|
||||||
def last_name
|
def last_name
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class ConversationFinder
|
|||||||
def perform
|
def perform
|
||||||
set_up
|
set_up
|
||||||
|
|
||||||
mine_count, unassigned_count, all_count = set_count_for_all_conversations
|
mine_count, unassigned_count, all_count, = set_count_for_all_conversations
|
||||||
assigned_count = all_count - unassigned_count
|
assigned_count = all_count - unassigned_count
|
||||||
|
|
||||||
filter_by_assignee_type
|
filter_by_assignee_type
|
||||||
@@ -184,17 +184,6 @@ class ConversationFinder
|
|||||||
end
|
end
|
||||||
|
|
||||||
def set_count_for_all_conversations
|
def set_count_for_all_conversations
|
||||||
return legacy_count_for_all_conversations if @conversations.limit_value || @conversations.offset_value || @conversations.eager_loading?
|
|
||||||
|
|
||||||
counts = @conversations.unscope(:order).pick(
|
|
||||||
Arel.sql("COUNT(*) FILTER (WHERE assignee_id = #{current_user.id})"),
|
|
||||||
Arel.sql('COUNT(*) FILTER (WHERE assignee_id IS NULL)'),
|
|
||||||
Arel.sql('COUNT(*)')
|
|
||||||
)
|
|
||||||
counts || [0, 0, 0]
|
|
||||||
end
|
|
||||||
|
|
||||||
def legacy_count_for_all_conversations
|
|
||||||
[
|
[
|
||||||
@conversations.assigned_to(current_user).count,
|
@conversations.assigned_to(current_user).count,
|
||||||
@conversations.unassigned.count,
|
@conversations.unassigned.count,
|
||||||
|
|||||||
@@ -34,12 +34,6 @@ disable_branding:
|
|||||||
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
|
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
|
||||||
icon: 'icon-sailbot-fill'
|
icon: 'icon-sailbot-fill'
|
||||||
enterprise: true
|
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 ------- #
|
# ------- Product Features ------- #
|
||||||
help_center:
|
help_center:
|
||||||
|
|||||||
@@ -106,8 +106,4 @@ export default {
|
|||||||
const urlData = endPoints('resetAccessToken');
|
const urlData = endPoints('resetAccessToken');
|
||||||
return axios.post(urlData.url);
|
return axios.post(urlData.url);
|
||||||
},
|
},
|
||||||
resetReadOnlyAccessToken() {
|
|
||||||
const urlData = endPoints('resetReadOnlyAccessToken');
|
|
||||||
return axios.post(urlData.url);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -48,12 +48,6 @@ class TwilioVoiceClient extends EventTarget {
|
|||||||
return !!this.activeConnection;
|
return !!this.activeConnection;
|
||||||
}
|
}
|
||||||
|
|
||||||
setMuted(shouldMute) {
|
|
||||||
if (!this.activeConnection) return false;
|
|
||||||
this.activeConnection.mute(shouldMute);
|
|
||||||
return shouldMute;
|
|
||||||
}
|
|
||||||
|
|
||||||
endClientCall() {
|
endClientCall() {
|
||||||
if (this.activeConnection) {
|
if (this.activeConnection) {
|
||||||
this.activeConnection.disconnect();
|
this.activeConnection.disconnect();
|
||||||
|
|||||||
@@ -55,10 +55,6 @@ const endPoints = {
|
|||||||
resetAccessToken: {
|
resetAccessToken: {
|
||||||
url: '/api/v1/profile/reset_access_token',
|
url: '/api/v1/profile/reset_access_token',
|
||||||
},
|
},
|
||||||
|
|
||||||
resetReadOnlyAccessToken: {
|
|
||||||
url: '/api/v1/profile/reset_read_only_access_token',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default page => {
|
export default page => {
|
||||||
|
|||||||
@@ -60,12 +60,6 @@ class Inboxes extends CacheEnabledApiClient {
|
|||||||
disableWhatsappCalling(inboxId) {
|
disableWhatsappCalling(inboxId) {
|
||||||
return axios.post(`${this.url}/${inboxId}/disable_whatsapp_calling`);
|
return axios.post(`${this.url}/${inboxId}/disable_whatsapp_calling`);
|
||||||
}
|
}
|
||||||
|
|
||||||
setInboundCalls(inboxId, enabled) {
|
|
||||||
return axios.post(`${this.url}/${inboxId}/set_inbound_calls`, {
|
|
||||||
inbound_calls_enabled: enabled,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new Inboxes();
|
export default new Inboxes();
|
||||||
|
|||||||
@@ -9,10 +9,6 @@ class OnboardingAPI extends ApiClient {
|
|||||||
update(data) {
|
update(data) {
|
||||||
return axios.patch(this.url, data);
|
return axios.patch(this.url, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
getHelpCenterGeneration() {
|
|
||||||
return axios.get(`${this.url}/help_center_generation`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new OnboardingAPI();
|
export default new OnboardingAPI();
|
||||||
|
|||||||
@@ -46,8 +46,9 @@ const formattedLastActivityAt = computed(() => {
|
|||||||
:src="avatarSource"
|
:src="avatarSource"
|
||||||
class="shrink-0"
|
class="shrink-0"
|
||||||
:name="name"
|
:name="name"
|
||||||
:size="42"
|
:size="48"
|
||||||
hide-offline-status
|
hide-offline-status
|
||||||
|
rounded-full
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-col gap-0.5 flex-1 min-w-0">
|
<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">
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 min-w-0">
|
||||||
|
|||||||
+1
@@ -141,6 +141,7 @@ const handleUpdateCompany = async () => {
|
|||||||
:src="avatarSource"
|
:src="avatarSource"
|
||||||
:size="72"
|
:size="72"
|
||||||
:allow-upload="!isAvatarBusy"
|
:allow-upload="!isAvatarBusy"
|
||||||
|
rounded-full
|
||||||
hide-offline-status
|
hide-offline-status
|
||||||
@upload="handleAvatarUpload"
|
@upload="handleAvatarUpload"
|
||||||
@delete="handleAvatarDelete"
|
@delete="handleAvatarDelete"
|
||||||
|
|||||||
@@ -124,9 +124,10 @@ const handleAvatarHover = isHovered => {
|
|||||||
<Avatar
|
<Avatar
|
||||||
:name="name"
|
:name="name"
|
||||||
:src="thumbnail"
|
:src="thumbnail"
|
||||||
:size="42"
|
:size="48"
|
||||||
:status="availabilityStatus"
|
:status="availabilityStatus"
|
||||||
hide-offline-status
|
hide-offline-status
|
||||||
|
rounded-full
|
||||||
>
|
>
|
||||||
<template v-if="selectable" #overlay="{ size }">
|
<template v-if="selectable" #overlay="{ size }">
|
||||||
<label
|
<label
|
||||||
|
|||||||
@@ -53,9 +53,6 @@ const localeMenuLabels = computed(() => ({
|
|||||||
'publish-locale': t(
|
'publish-locale': t(
|
||||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.PUBLISH_LOCALE'
|
'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'),
|
delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -131,7 +128,7 @@ const handleAction = ({ action, value }) => {
|
|||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
v-if="showDropdownMenu"
|
v-if="showDropdownMenu"
|
||||||
:menu-items="localeMenuItems"
|
:menu-items="localeMenuItems"
|
||||||
class="ltr:right-0 rtl:left-0 mt-1 top-full z-60 min-w-[150px]"
|
class="ltr:right-0 rtl:left-0 mt-1 xl:ltr:left-0 xl:rtl:right-0 top-full z-60 min-w-[150px]"
|
||||||
@action="handleAction"
|
@action="handleAction"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
-98
@@ -1,98 +0,0 @@
|
|||||||
<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,7 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
|
||||||
import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.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 { useStore } from 'dashboard/composables/store';
|
||||||
import { useAlert, useTrack } from 'dashboard/composables';
|
import { useAlert, useTrack } from 'dashboard/composables';
|
||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
@@ -25,8 +23,6 @@ const { t } = useI18n();
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { uiSettings, updateUISettings } = useUISettings();
|
const { uiSettings, updateUISettings } = useUISettings();
|
||||||
|
|
||||||
const contentDialogRef = ref(null);
|
|
||||||
|
|
||||||
const isLocaleDefault = code => {
|
const isLocaleDefault = code => {
|
||||||
return props.portal?.meta?.default_locale === code;
|
return props.portal?.meta?.default_locale === code;
|
||||||
};
|
};
|
||||||
@@ -152,8 +148,6 @@ const handleAction = ({ action }, localeCode) => {
|
|||||||
moveLocaleToDraft({ localeCode: localeCode });
|
moveLocaleToDraft({ localeCode: localeCode });
|
||||||
} else if (action === 'publish-locale') {
|
} else if (action === 'publish-locale') {
|
||||||
publishLocale({ localeCode: localeCode });
|
publishLocale({ localeCode: localeCode });
|
||||||
} else if (action === 'customize-content') {
|
|
||||||
contentDialogRef.value.openForLocale(localeCode);
|
|
||||||
} else if (action === 'delete') {
|
} else if (action === 'delete') {
|
||||||
deletePortalLocale({ localeCode: localeCode });
|
deletePortalLocale({ localeCode: localeCode });
|
||||||
}
|
}
|
||||||
@@ -173,6 +167,5 @@ const handleAction = ({ action }, localeCode) => {
|
|||||||
:category-count="locale.categoriesCount || 0"
|
:category-count="locale.categoriesCount || 0"
|
||||||
@action="handleAction($event, locale.code)"
|
@action="handleAction($event, locale.code)"
|
||||||
/>
|
/>
|
||||||
<LocaleContentDialog ref="contentDialogRef" :portal="portal" />
|
|
||||||
</ul>
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+1
-6
@@ -182,10 +182,7 @@ const MIN_SEARCH_LENGTH = 2;
|
|||||||
export const createContactSearcher = () => {
|
export const createContactSearcher = () => {
|
||||||
let controller = null;
|
let controller = null;
|
||||||
|
|
||||||
return async (
|
return async (query, { skipMinLength = false } = {}) => {
|
||||||
query,
|
|
||||||
{ skipMinLength = false, reachableOnly = true } = {}
|
|
||||||
) => {
|
|
||||||
const trimmed = typeof query === 'string' ? query.trim() : '';
|
const trimmed = typeof query === 'string' ? query.trim() : '';
|
||||||
|
|
||||||
controller?.abort();
|
controller?.abort();
|
||||||
@@ -202,8 +199,6 @@ export const createContactSearcher = () => {
|
|||||||
} = await ContactAPI.search(trimmed, 1, 'name', '', { signal });
|
} = await ContactAPI.search(trimmed, 1, 'name', '', { signal });
|
||||||
|
|
||||||
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
|
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
|
||||||
if (!reachableOnly) return camelCasedPayload || [];
|
|
||||||
|
|
||||||
// Filter contacts that have either phone_number or email
|
// Filter contacts that have either phone_number or email
|
||||||
const filteredPayload = camelCasedPayload?.filter(
|
const filteredPayload = camelCasedPayload?.filter(
|
||||||
contact => contact.phoneNumber || contact.email
|
contact => contact.phoneNumber || contact.email
|
||||||
|
|||||||
@@ -197,9 +197,9 @@ const channelIcon = computed(() => {
|
|||||||
? $t('CONVERSATION.VOICE_WIDGET.END_CALL')
|
? $t('CONVERSATION.VOICE_WIDGET.END_CALL')
|
||||||
: $t('CONVERSATION.VOICE_WIDGET.REJECT_CALL')
|
: $t('CONVERSATION.VOICE_WIDGET.REJECT_CALL')
|
||||||
"
|
"
|
||||||
icon="i-ph-phone-bold"
|
icon="i-ph-phone-x-bold"
|
||||||
ruby
|
ruby
|
||||||
class="!rounded-full rotate-[135deg]"
|
class="!rounded-full rotate-[134deg]"
|
||||||
@click="isOngoing ? $emit('end') : $emit('reject')"
|
@click="isOngoing ? $emit('end') : $emit('reject')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useRoute, useRouter } from 'vue-router';
|
|||||||
import { useStore } from 'vuex';
|
import { useStore } from 'vuex';
|
||||||
import { useCallSession } from 'dashboard/composables/useCallSession';
|
import { useCallSession } from 'dashboard/composables/useCallSession';
|
||||||
import { setWhatsappCallMuted } from 'dashboard/composables/useWhatsappCallSession';
|
import { setWhatsappCallMuted } from 'dashboard/composables/useWhatsappCallSession';
|
||||||
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
|
|
||||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||||
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
|
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
|
||||||
import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants';
|
import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants';
|
||||||
@@ -12,7 +11,7 @@ import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilit
|
|||||||
import CallCard from 'dashboard/components-next/call/CallCard.vue';
|
import CallCard from 'dashboard/components-next/call/CallCard.vue';
|
||||||
import countriesList from 'shared/constants/countries.js';
|
import countriesList from 'shared/constants/countries.js';
|
||||||
|
|
||||||
const RINGTONE_URL = '/audio/dashboard/ringtone.mp3';
|
const RINGTONE_URL = '/audio/dashboard/bell.mp3';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -30,8 +29,8 @@ const {
|
|||||||
formattedCallDuration,
|
formattedCallDuration,
|
||||||
} = useCallSession();
|
} = useCallSession();
|
||||||
|
|
||||||
// Mute routes by provider: WhatsApp toggles the local mic track, Twilio uses
|
// Mute is currently WhatsApp-only — Twilio calls are mediated server-side and
|
||||||
// the Voice SDK connection's native mute. Both surface the same button.
|
// don't expose a mic track on the browser side.
|
||||||
const isMuted = ref(false);
|
const isMuted = ref(false);
|
||||||
const isWhatsappActive = computed(
|
const isWhatsappActive = computed(
|
||||||
() => activeCall.value?.provider === VOICE_CALL_PROVIDERS.WHATSAPP
|
() => activeCall.value?.provider === VOICE_CALL_PROVIDERS.WHATSAPP
|
||||||
@@ -64,11 +63,7 @@ const stackedCardState = call =>
|
|||||||
|
|
||||||
const toggleMute = () => {
|
const toggleMute = () => {
|
||||||
isMuted.value = !isMuted.value;
|
isMuted.value = !isMuted.value;
|
||||||
if (isWhatsappActive.value) {
|
setWhatsappCallMuted(isMuted.value);
|
||||||
setWhatsappCallMuted(isMuted.value);
|
|
||||||
} else {
|
|
||||||
TwilioVoiceClient.setMuted(isMuted.value);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(hasActiveCall, active => {
|
watch(hasActiveCall, active => {
|
||||||
@@ -261,7 +256,7 @@ onBeforeUnmount(stopRingtone);
|
|||||||
:call-info="getCallInfo(activeCall || primaryIncomingCall)"
|
:call-info="getCallInfo(activeCall || primaryIncomingCall)"
|
||||||
:duration="hasActiveCall ? formattedCallDuration : ''"
|
:duration="hasActiveCall ? formattedCallDuration : ''"
|
||||||
:is-muted="isMuted"
|
:is-muted="isMuted"
|
||||||
:show-mute="hasActiveCall"
|
:show-mute="hasActiveCall && isWhatsappActive"
|
||||||
@accept="handleJoinCall(primaryIncomingCall)"
|
@accept="handleJoinCall(primaryIncomingCall)"
|
||||||
@reject="rejectIncomingCall(primaryIncomingCall?.callSid)"
|
@reject="rejectIncomingCall(primaryIncomingCall?.callSid)"
|
||||||
@dismiss="dismissCall(primaryIncomingCall?.callSid)"
|
@dismiss="dismissCall(primaryIncomingCall?.callSid)"
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, h, watch, ref } from 'vue';
|
import { computed, h, watch, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { debounce } from '@chatwoot/utils';
|
|
||||||
import Button from 'next/button/Button.vue';
|
import Button from 'next/button/Button.vue';
|
||||||
import Input from 'dashboard/components-next/input/Input.vue';
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
import FilterSelect from './inputs/FilterSelect.vue';
|
import FilterSelect from './inputs/FilterSelect.vue';
|
||||||
@@ -110,34 +109,6 @@ const inputFieldType = computed(() => {
|
|||||||
return 'text';
|
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 => {
|
const resetModelOnAttributeKeyChange = newAttributeKey => {
|
||||||
/**
|
/**
|
||||||
* Resets the filter values and operator when the attribute key changes. This ensures that
|
* Resets the filter values and operator when the attribute key changes. This ensures that
|
||||||
@@ -150,16 +121,11 @@ const resetModelOnAttributeKeyChange = newAttributeKey => {
|
|||||||
const newInputType = getInputType(newOperator, filter);
|
const newInputType = getInputType(newOperator, filter);
|
||||||
if (newInputType === 'multiSelect') {
|
if (newInputType === 'multiSelect') {
|
||||||
values.value = [];
|
values.value = [];
|
||||||
} else if (
|
} else if (['searchSelect', 'booleanSelect'].includes(newInputType)) {
|
||||||
['searchSelect', 'asyncSearchSelect', 'booleanSelect'].includes(
|
|
||||||
newInputType
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
values.value = {};
|
values.value = {};
|
||||||
} else {
|
} else {
|
||||||
values.value = '';
|
values.value = '';
|
||||||
}
|
}
|
||||||
asyncOptions.value = [];
|
|
||||||
filterOperator.value = newOperator.value;
|
filterOperator.value = newOperator.value;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -219,16 +185,6 @@ defineExpose({ validate, resetValidation });
|
|||||||
:options="currentFilter.options"
|
:options="currentFilter.options"
|
||||||
dropdown-max-height="max-h-64"
|
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
|
<SingleSelect
|
||||||
v-else-if="inputType === 'booleanSelect'"
|
v-else-if="inputType === 'booleanSelect'"
|
||||||
v-model="values"
|
v-model="values"
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ export const CONVERSATION_ATTRIBUTES = {
|
|||||||
ASSIGNEE_ID: 'assignee_id',
|
ASSIGNEE_ID: 'assignee_id',
|
||||||
INBOX_ID: 'inbox_id',
|
INBOX_ID: 'inbox_id',
|
||||||
TEAM_ID: 'team_id',
|
TEAM_ID: 'team_id',
|
||||||
CONTACT_ID: 'contact_id',
|
|
||||||
DISPLAY_ID: 'display_id',
|
DISPLAY_ID: 'display_id',
|
||||||
CAMPAIGN_ID: 'campaign_id',
|
CAMPAIGN_ID: 'campaign_id',
|
||||||
LABELS: 'labels',
|
LABELS: 'labels',
|
||||||
|
|||||||
@@ -1,37 +1,9 @@
|
|||||||
import { ref } from 'vue';
|
|
||||||
import ContactAPI from 'dashboard/api/contacts';
|
|
||||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
|
||||||
import { useConversationFilterContext } from '../provider';
|
|
||||||
import {
|
import {
|
||||||
CONVERSATION_ATTRIBUTES,
|
|
||||||
getCustomAttributeInputType,
|
getCustomAttributeInputType,
|
||||||
buildAttributesFilterTypes,
|
buildAttributesFilterTypes,
|
||||||
replaceUnderscoreWithSpace,
|
replaceUnderscoreWithSpace,
|
||||||
} from './filterHelper';
|
} 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('filterHelper', () => {
|
||||||
describe('getCustomAttributeInputType', () => {
|
describe('getCustomAttributeInputType', () => {
|
||||||
it('returns date for date type', () => {
|
it('returns date for date type', () => {
|
||||||
@@ -163,64 +135,3 @@ 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,8 +11,6 @@ import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
options,
|
options,
|
||||||
asyncSearch,
|
|
||||||
isSearching,
|
|
||||||
disableSearch,
|
disableSearch,
|
||||||
disableDeselect,
|
disableDeselect,
|
||||||
placeholderIcon,
|
placeholderIcon,
|
||||||
@@ -25,14 +23,6 @@ const {
|
|||||||
type: Array,
|
type: Array,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
asyncSearch: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
isSearching: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
disableSearch: {
|
disableSearch: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
@@ -63,12 +53,6 @@ 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 { t } = useI18n();
|
||||||
const selected = defineModel({
|
const selected = defineModel({
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -76,9 +60,7 @@ const selected = defineModel({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const searchTerm = ref('');
|
const searchTerm = ref('');
|
||||||
|
|
||||||
const searchResults = computed(() => {
|
const searchResults = computed(() => {
|
||||||
if (asyncSearch) return options;
|
|
||||||
if (!options) return [];
|
if (!options) return [];
|
||||||
return picoSearch(options, searchTerm.value, ['name']);
|
return picoSearch(options, searchTerm.value, ['name']);
|
||||||
});
|
});
|
||||||
@@ -95,11 +77,7 @@ const selectedItem = computed(() => {
|
|||||||
if (!optionToSearch) return null;
|
if (!optionToSearch) return null;
|
||||||
// extract the selected item from the options array
|
// extract the selected item from the options array
|
||||||
// this ensures that options like icon is also included
|
// this ensures that options like icon is also included
|
||||||
return (
|
return options.find(option => option.id === optionToSearch.id);
|
||||||
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 => {
|
const toggleSelected = option => {
|
||||||
@@ -153,19 +131,13 @@ const toggleSelected = option => {
|
|||||||
<Icon class="absolute size-4 left-2 top-2" icon="i-lucide-search" />
|
<Icon class="absolute size-4 left-2 top-2" icon="i-lucide-search" />
|
||||||
<input
|
<input
|
||||||
v-model="searchTerm"
|
v-model="searchTerm"
|
||||||
v-focus
|
autofocus
|
||||||
class="p-1.5 pl-8 text-n-slate-11 bg-n-alpha-1 rounded-lg w-full"
|
class="p-1.5 pl-8 text-n-slate-11 bg-n-alpha-1 rounded-lg w-full"
|
||||||
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
|
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
|
||||||
@input="emit('search', $event.target.value)"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<DropdownSection :height="dropdownMaxHeight">
|
<DropdownSection :height="dropdownMaxHeight">
|
||||||
<template v-if="isSearching">
|
<template v-if="searchResults.length">
|
||||||
<DropdownItem disabled>
|
|
||||||
{{ t('DROPDOWN_MENU.SEARCHING') }}
|
|
||||||
</DropdownItem>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="searchResults.length">
|
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
v-for="option in searchResults"
|
v-for="option in searchResults"
|
||||||
:key="option.id"
|
:key="option.id"
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { useOperators } from './operators';
|
import { useOperators } from './operators';
|
||||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||||
import { useChannelIcon } from 'next/icon/provider';
|
import { useChannelIcon } from 'next/icon/provider';
|
||||||
import { createContactSearcher } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
|
|
||||||
import {
|
import {
|
||||||
buildAttributesFilterTypes,
|
buildAttributesFilterTypes,
|
||||||
CONVERSATION_ATTRIBUTES,
|
CONVERSATION_ATTRIBUTES,
|
||||||
@@ -31,7 +30,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} 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} 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 {string} label - This is a proxy for the attribute name used in FilterSelect
|
||||||
* @property {'multiSelect'|'searchSelect'|'asyncSearchSelect'|'plainText'|'date'|'booleanSelect'} inputType - The input type for the attribute
|
* @property {'multiSelect'|'searchSelect'|'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 {FilterOption[]} [options] - The options available for the attribute if it is a multiSelect or singleSelect type
|
||||||
* @property {'text'|'number'} dataType
|
* @property {'text'|'number'} dataType
|
||||||
* @property {FilterOperator[]} filterOperators - The operators available for the attribute
|
* @property {FilterOperator[]} filterOperators - The operators available for the attribute
|
||||||
@@ -69,30 +68,6 @@ export function useConversationFilterContext() {
|
|||||||
getOperatorTypes,
|
getOperatorTypes,
|
||||||
} = useOperators();
|
} = 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[]>}
|
* @type {import('vue').ComputedRef<FilterType[]>}
|
||||||
*/
|
*/
|
||||||
@@ -183,18 +158,6 @@ export function useConversationFilterContext() {
|
|||||||
filterOperators: presenceOperators.value,
|
filterOperators: presenceOperators.value,
|
||||||
attributeModel: 'standard',
|
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,
|
attributeKey: CONVERSATION_ATTRIBUTES.DISPLAY_ID,
|
||||||
value: CONVERSATION_ATTRIBUTES.DISPLAY_ID,
|
value: CONVERSATION_ATTRIBUTES.DISPLAY_ID,
|
||||||
|
|||||||
@@ -175,9 +175,6 @@ useEventListener(document, 'touchend', onResizeEnd);
|
|||||||
|
|
||||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||||
const labels = useMapGetter('labels/getLabelsOnSidebar');
|
const labels = useMapGetter('labels/getLabelsOnSidebar');
|
||||||
const allUnreadCount = useMapGetter(
|
|
||||||
'conversationUnreadCounts/getAllUnreadCount'
|
|
||||||
);
|
|
||||||
const getInboxUnreadCount = useMapGetter(
|
const getInboxUnreadCount = useMapGetter(
|
||||||
'conversationUnreadCounts/getInboxUnreadCount'
|
'conversationUnreadCounts/getInboxUnreadCount'
|
||||||
);
|
);
|
||||||
@@ -300,7 +297,6 @@ const menuItems = computed(() => {
|
|||||||
{
|
{
|
||||||
name: 'All',
|
name: 'All',
|
||||||
label: t('SIDEBAR.ALL_CONVERSATIONS'),
|
label: t('SIDEBAR.ALL_CONVERSATIONS'),
|
||||||
badgeCount: allUnreadCount.value,
|
|
||||||
activeOn: ['inbox_conversation'],
|
activeOn: ['inbox_conversation'],
|
||||||
to: accountScopedRoute('home'),
|
to: accountScopedRoute('home'),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ const allowedMenuItems = computed(() => {
|
|||||||
:src="currentUser.avatar_url"
|
:src="currentUser.avatar_url"
|
||||||
:status="currentUserAvailability"
|
:status="currentUserAvailability"
|
||||||
class="flex-shrink-0"
|
class="flex-shrink-0"
|
||||||
|
rounded-full
|
||||||
/>
|
/>
|
||||||
<div v-if="!isCollapsed" class="min-w-0">
|
<div v-if="!isCollapsed" class="min-w-0">
|
||||||
<div class="text-sm font-medium leading-4 truncate text-n-slate-12">
|
<div class="text-sm font-medium leading-4 truncate text-n-slate-12">
|
||||||
|
|||||||
@@ -151,9 +151,6 @@ const activeFolder = computed(() => {
|
|||||||
return undefined;
|
return undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
const getContact = useMapGetter('contacts/getContact');
|
|
||||||
const folderContactId = useMapGetter('customViews/getActiveFolderContactId');
|
|
||||||
|
|
||||||
const activeFolderName = computed(() => {
|
const activeFolderName = computed(() => {
|
||||||
return activeFolder.value?.name;
|
return activeFolder.value?.name;
|
||||||
});
|
});
|
||||||
@@ -459,7 +456,6 @@ function setParamsForEditFolderModal() {
|
|||||||
inboxes: inboxesList.value,
|
inboxes: inboxesList.value,
|
||||||
labels: labels.value,
|
labels: labels.value,
|
||||||
campaigns: campaigns.value,
|
campaigns: campaigns.value,
|
||||||
contacts: [getContact.value(folderContactId.value)],
|
|
||||||
languages: languages,
|
languages: languages,
|
||||||
countries: countries,
|
countries: countries,
|
||||||
priority: [
|
priority: [
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
CONVERSATION_EVENTS,
|
CONVERSATION_EVENTS,
|
||||||
CAPTAIN_EVENTS,
|
CAPTAIN_EVENTS,
|
||||||
} from 'dashboard/helper/AnalyticsHelper/events';
|
} from 'dashboard/helper/AnalyticsHelper/events';
|
||||||
|
import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
messageSchema,
|
messageSchema,
|
||||||
@@ -42,7 +43,6 @@ import {
|
|||||||
MessageMarkdownSerializer,
|
MessageMarkdownSerializer,
|
||||||
EditorState,
|
EditorState,
|
||||||
Selection,
|
Selection,
|
||||||
imageResizeView,
|
|
||||||
} from '@chatwoot/prosemirror-schema';
|
} from '@chatwoot/prosemirror-schema';
|
||||||
import {
|
import {
|
||||||
suggestionsPlugin,
|
suggestionsPlugin,
|
||||||
@@ -57,6 +57,7 @@ import {
|
|||||||
insertAtCursor,
|
insertAtCursor,
|
||||||
removeSignature as removeSignatureHelper,
|
removeSignature as removeSignatureHelper,
|
||||||
scrollCursorIntoView,
|
scrollCursorIntoView,
|
||||||
|
setURLWithQueryAndSize,
|
||||||
getFormattingForEditor,
|
getFormattingForEditor,
|
||||||
getSelectionCoords,
|
getSelectionCoords,
|
||||||
calculateMenuPosition,
|
calculateMenuPosition,
|
||||||
@@ -71,7 +72,6 @@ import {
|
|||||||
import { createTypingIndicator } from '@chatwoot/utils';
|
import { createTypingIndicator } from '@chatwoot/utils';
|
||||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||||
import { uploadFile } from 'dashboard/helper/uploadHelper';
|
import { uploadFile } from 'dashboard/helper/uploadHelper';
|
||||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: { type: String, default: '' },
|
modelValue: { type: String, default: '' },
|
||||||
@@ -93,6 +93,7 @@ const props = defineProps({
|
|||||||
channelType: { type: String, default: '' },
|
channelType: { type: String, default: '' },
|
||||||
conversationId: { type: Number, default: null },
|
conversationId: { type: Number, default: null },
|
||||||
medium: { type: String, default: '' },
|
medium: { type: String, default: '' },
|
||||||
|
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
|
||||||
focusOnMount: { type: Boolean, default: true },
|
focusOnMount: { type: Boolean, default: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -118,14 +119,6 @@ const TYPING_INDICATOR_IDLE_TIME = 4000;
|
|||||||
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
|
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
|
||||||
const DEFAULT_FORMATTING = 'Context::Default';
|
const DEFAULT_FORMATTING = 'Context::Default';
|
||||||
const PRIVATE_NOTE_FORMATTING = 'Context::PrivateNote';
|
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(() =>
|
const effectiveChannelType = computed(() =>
|
||||||
getEffectiveChannelType(props.channelType, props.medium)
|
getEffectiveChannelType(props.channelType, props.medium)
|
||||||
@@ -199,8 +192,12 @@ const cannedSearchTerm = ref('');
|
|||||||
const variableSearchTerm = ref('');
|
const variableSearchTerm = ref('');
|
||||||
const emojiSearchTerm = ref('');
|
const emojiSearchTerm = ref('');
|
||||||
const range = ref(null);
|
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 isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection
|
||||||
const showSelectionMenu = ref(false);
|
const showSelectionMenu = ref(false);
|
||||||
|
const sizes = MESSAGE_EDITOR_IMAGE_RESIZES;
|
||||||
|
|
||||||
// element ref
|
// element ref
|
||||||
const editorRoot = useTemplateRef('editorRoot');
|
const editorRoot = useTemplateRef('editorRoot');
|
||||||
@@ -478,6 +475,16 @@ function removeSignature() {
|
|||||||
reloadState(content);
|
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 } = {}) {
|
function setMenubarPosition({ selection } = {}) {
|
||||||
const wrapper = editorRoot.value;
|
const wrapper = editorRoot.value;
|
||||||
if (!selection || !wrapper) return;
|
if (!selection || !wrapper) return;
|
||||||
@@ -513,6 +520,30 @@ function checkSelection(editorState) {
|
|||||||
if (hasSelection) setMenubarPosition(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() {
|
function emitOnChange() {
|
||||||
emit('input', contentFromEditor());
|
emit('input', contentFromEditor());
|
||||||
emit('update:modelValue', contentFromEditor());
|
emit('update:modelValue', contentFromEditor());
|
||||||
@@ -532,6 +563,21 @@ function toggleSignatureInEditor(signatureEnabled) {
|
|||||||
emitOnChange();
|
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() {
|
function isEnterToSendEnabled() {
|
||||||
return isEditorHotKeyEnabled('enter');
|
return isEditorHotKeyEnabled('enter');
|
||||||
}
|
}
|
||||||
@@ -540,6 +586,17 @@ function isCmdPlusEnterToSendEnabled() {
|
|||||||
return isEditorHotKeyEnabled('cmd_enter');
|
return isEditorHotKeyEnabled('cmd_enter');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useKeyboardEvents({
|
||||||
|
'Alt+KeyP': {
|
||||||
|
action: focusEditorInputField,
|
||||||
|
allowOnFocusedInput: false,
|
||||||
|
},
|
||||||
|
'Alt+KeyL': {
|
||||||
|
action: focusEditorInputField,
|
||||||
|
allowOnFocusedInput: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
function onImageInsertInEditor(fileUrl) {
|
function onImageInsertInEditor(fileUrl) {
|
||||||
const { tr } = editorView.state;
|
const { tr } = editorView.state;
|
||||||
|
|
||||||
@@ -560,11 +617,7 @@ async function uploadImageToStorage(file) {
|
|||||||
onImageInsertInEditor(fileUrl);
|
onImageInsertInEditor(fileUrl);
|
||||||
}
|
}
|
||||||
useAlert(
|
useAlert(
|
||||||
props.channelType === MESSAGE_SIGNATURE_FORMATTING
|
t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SUCCESS')
|
||||||
? t(
|
|
||||||
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SUCCESS'
|
|
||||||
)
|
|
||||||
: t('CONVERSATION.REPLYBOX.IMAGE_UPLOAD_SUCCESS')
|
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
useAlert(
|
useAlert(
|
||||||
@@ -573,8 +626,8 @@ async function uploadImageToStorage(file) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function uploadImageIfWithinSizeLimit(file) {
|
function onFileChange() {
|
||||||
if (!file) return;
|
const file = imageUpload.value.files[0];
|
||||||
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
|
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
|
||||||
uploadImageToStorage(file);
|
uploadImageToStorage(file);
|
||||||
} else {
|
} else {
|
||||||
@@ -587,61 +640,10 @@ function uploadImageIfWithinSizeLimit(file) {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
function handleLineBreakWhenEnterToSendEnabled(event) {
|
||||||
if (
|
if (
|
||||||
hasPressedEnterAndNotCmdOrShift(event) &&
|
hasPressedEnterAndNotCmdOrShift(event) &&
|
||||||
@@ -734,9 +736,6 @@ function createEditorView() {
|
|||||||
editorView = new EditorView(editor.value, {
|
editorView = new EditorView(editor.value, {
|
||||||
state: state,
|
state: state,
|
||||||
editable: () => !props.disabled,
|
editable: () => !props.disabled,
|
||||||
nodeViews: {
|
|
||||||
image: imageResizeView,
|
|
||||||
},
|
|
||||||
dispatchTransaction: tx => {
|
dispatchTransaction: tx => {
|
||||||
state = state.apply(tx);
|
state = state.apply(tx);
|
||||||
editorView.updateState(state);
|
editorView.updateState(state);
|
||||||
@@ -749,10 +748,12 @@ function createEditorView() {
|
|||||||
keyup: () => {
|
keyup: () => {
|
||||||
if (!props.disabled) {
|
if (!props.disabled) {
|
||||||
typingIndicator.start();
|
typingIndicator.start();
|
||||||
|
updateImgToolbarOnDelete();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
keydown: (view, event) => !props.disabled && onKeydown(event),
|
keydown: (view, event) => !props.disabled && onKeydown(event),
|
||||||
focus: () => !props.disabled && emit('focus'),
|
focus: () => !props.disabled && emit('focus'),
|
||||||
|
click: () => !props.disabled && isEditorMouseFocusedOnAnImage(),
|
||||||
blur: () => {
|
blur: () => {
|
||||||
if (props.disabled) return;
|
if (props.disabled) return;
|
||||||
typingIndicator.stop();
|
typingIndicator.stop();
|
||||||
@@ -917,6 +918,23 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
|||||||
@change="onFileChange"
|
@change="onFileChange"
|
||||||
/>
|
/>
|
||||||
<div ref="editor" />
|
<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" />
|
<slot name="footer" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -979,6 +997,10 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
|||||||
@apply text-n-slate-11;
|
@apply text-n-slate-11;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ol li {
|
||||||
|
@apply list-item list-decimal;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ const copyConversationId = async () => {
|
|||||||
:size="32"
|
:size="32"
|
||||||
:status="currentContact.availability_status"
|
:status="currentContact.availability_status"
|
||||||
hide-offline-status
|
hide-offline-status
|
||||||
|
rounded-full
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2"
|
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2"
|
||||||
|
|||||||
@@ -375,10 +375,10 @@ export default {
|
|||||||
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
|
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
|
||||||
},
|
},
|
||||||
audioRecordFormat() {
|
audioRecordFormat() {
|
||||||
if (this.isAWhatsAppCloudChannel) {
|
if (this.isAWhatsAppChannel) {
|
||||||
return AUDIO_FORMATS.OGG;
|
return AUDIO_FORMATS.OGG;
|
||||||
}
|
}
|
||||||
if (this.isAWhatsAppChannel || this.isATelegramChannel) {
|
if (this.isATelegramChannel) {
|
||||||
return AUDIO_FORMATS.MP3;
|
return AUDIO_FORMATS.MP3;
|
||||||
}
|
}
|
||||||
if (this.isAPIInbox) {
|
if (this.isAPIInbox) {
|
||||||
|
|||||||
@@ -46,14 +46,6 @@ const filterTypes = [
|
|||||||
filterOperators: OPERATOR_TYPES_2,
|
filterOperators: OPERATOR_TYPES_2,
|
||||||
attributeModel: 'standard',
|
attributeModel: 'standard',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
attributeKey: 'contact_id',
|
|
||||||
attributeI18nKey: 'CONTACT',
|
|
||||||
inputType: 'search_select',
|
|
||||||
dataType: 'number',
|
|
||||||
filterOperators: OPERATOR_TYPES_1,
|
|
||||||
attributeModel: 'standard',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
attributeKey: 'display_id',
|
attributeKey: 'display_id',
|
||||||
attributeI18nKey: 'CONVERSATION_IDENTIFIER',
|
attributeI18nKey: 'CONVERSATION_IDENTIFIER',
|
||||||
@@ -141,10 +133,6 @@ export const filterAttributeGroups = [
|
|||||||
key: 'team_id',
|
key: 'team_id',
|
||||||
i18nKey: 'TEAM_NAME',
|
i18nKey: 'TEAM_NAME',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'contact_id',
|
|
||||||
i18nKey: 'CONTACT',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'display_id',
|
key: 'display_id',
|
||||||
i18nKey: 'CONVERSATION_IDENTIFIER',
|
i18nKey: 'CONVERSATION_IDENTIFIER',
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
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,7 +14,6 @@ export const FORMATTING = {
|
|||||||
'link',
|
'link',
|
||||||
'bulletList',
|
'bulletList',
|
||||||
'orderedList',
|
'orderedList',
|
||||||
'imageUpload',
|
|
||||||
'undo',
|
'undo',
|
||||||
'redo',
|
'redo',
|
||||||
],
|
],
|
||||||
@@ -31,7 +30,6 @@ export const FORMATTING = {
|
|||||||
'strike',
|
'strike',
|
||||||
'bulletList',
|
'bulletList',
|
||||||
'orderedList',
|
'orderedList',
|
||||||
'imageUpload',
|
|
||||||
'undo',
|
'undo',
|
||||||
'redo',
|
'redo',
|
||||||
],
|
],
|
||||||
@@ -265,3 +263,23 @@ 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',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|||||||
@@ -37,13 +37,6 @@ 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 => {
|
export const getValuesForStatus = values => {
|
||||||
return values.map(value => ({ id: value, name: value }));
|
return values.map(value => ({ id: value, name: value }));
|
||||||
};
|
};
|
||||||
@@ -91,7 +84,6 @@ export const getValuesForFilter = (filter, params) => {
|
|||||||
campaigns,
|
campaigns,
|
||||||
labels,
|
labels,
|
||||||
priority,
|
priority,
|
||||||
contacts,
|
|
||||||
} = params;
|
} = params;
|
||||||
switch (attribute_key) {
|
switch (attribute_key) {
|
||||||
case 'status':
|
case 'status':
|
||||||
@@ -102,8 +94,6 @@ export const getValuesForFilter = (filter, params) => {
|
|||||||
return getValuesName(values, inboxes, 'id', 'name');
|
return getValuesName(values, inboxes, 'id', 'name');
|
||||||
case 'team_id':
|
case 'team_id':
|
||||||
return getValuesName(values, teams, 'id', 'name');
|
return getValuesName(values, teams, 'id', 'name');
|
||||||
case 'contact_id':
|
|
||||||
return getValuesForContact(values, contacts);
|
|
||||||
case 'campaign_id':
|
case 'campaign_id':
|
||||||
return getValuesName(values, campaigns, 'id', 'title');
|
return getValuesName(values, campaigns, 'id', 'title');
|
||||||
case 'labels':
|
case 'labels':
|
||||||
|
|||||||
@@ -379,6 +379,31 @@ 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.
|
* Strips unsupported markdown formatting from content based on the editor schema.
|
||||||
* This ensures canned responses with rich formatting can be inserted into channels
|
* This ensures canned responses with rich formatting can be inserted into channels
|
||||||
|
|||||||
@@ -20,9 +20,6 @@ const FEATURE_HELP_URLS = {
|
|||||||
billing: 'https://chwt.app/pricing',
|
billing: 'https://chwt.app/pricing',
|
||||||
saml: 'https://chwt.app/hc/saml',
|
saml: 'https://chwt.app/hc/saml',
|
||||||
captain_billing: 'https://chwt.app/hc/captain_billing',
|
captain_billing: 'https://chwt.app/hc/captain_billing',
|
||||||
access_token_api:
|
|
||||||
'https://developers.chatwoot.com/api-reference/introduction',
|
|
||||||
chatwoot_cli: 'https://developers.chatwoot.com/cli',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getHelpUrlForFeature(featureName) {
|
export function getHelpUrlForFeature(featureName) {
|
||||||
|
|||||||
@@ -159,13 +159,6 @@ export const LOCALE_MENU_ITEMS = {
|
|||||||
value: 'publish',
|
value: 'publish',
|
||||||
icon: 'i-lucide-eye',
|
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: {
|
delete: {
|
||||||
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE',
|
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE',
|
||||||
action: 'delete',
|
action: 'delete',
|
||||||
@@ -179,28 +172,20 @@ const disableLocaleMenuItems = menuItems =>
|
|||||||
|
|
||||||
export const buildLocaleMenuItems = ({ isDefault, isDraft }) => {
|
export const buildLocaleMenuItems = ({ isDefault, isDraft }) => {
|
||||||
if (isDefault) {
|
if (isDefault) {
|
||||||
return [
|
return disableLocaleMenuItems([
|
||||||
...disableLocaleMenuItems([
|
LOCALE_MENU_ITEMS.makeDefault,
|
||||||
LOCALE_MENU_ITEMS.makeDefault,
|
LOCALE_MENU_ITEMS.moveToDraft,
|
||||||
LOCALE_MENU_ITEMS.moveToDraft,
|
LOCALE_MENU_ITEMS.delete,
|
||||||
]),
|
]);
|
||||||
LOCALE_MENU_ITEMS.customizeContent,
|
|
||||||
...disableLocaleMenuItems([LOCALE_MENU_ITEMS.delete]),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDraft) {
|
if (isDraft) {
|
||||||
return [
|
return [LOCALE_MENU_ITEMS.publishLocale, LOCALE_MENU_ITEMS.delete];
|
||||||
LOCALE_MENU_ITEMS.publishLocale,
|
|
||||||
LOCALE_MENU_ITEMS.customizeContent,
|
|
||||||
LOCALE_MENU_ITEMS.delete,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
LOCALE_MENU_ITEMS.makeDefault,
|
LOCALE_MENU_ITEMS.makeDefault,
|
||||||
LOCALE_MENU_ITEMS.moveToDraft,
|
LOCALE_MENU_ITEMS.moveToDraft,
|
||||||
LOCALE_MENU_ITEMS.customizeContent,
|
|
||||||
LOCALE_MENU_ITEMS.delete,
|
LOCALE_MENU_ITEMS.delete,
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -273,41 +273,6 @@ describe('customViewsHelper', () => {
|
|||||||
};
|
};
|
||||||
expect(generateValuesForEditCustomViews(filter, params)).toEqual('1');
|
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', () => {
|
describe('#generateCustomAttributesInputType', () => {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
insertAtCursor,
|
insertAtCursor,
|
||||||
removeSignature,
|
removeSignature,
|
||||||
replaceSignature,
|
replaceSignature,
|
||||||
|
setURLWithQueryAndSize,
|
||||||
stripInlineBase64Images,
|
stripInlineBase64Images,
|
||||||
stripUnsupportedFormatting,
|
stripUnsupportedFormatting,
|
||||||
stripUnsupportedMarkdown,
|
stripUnsupportedMarkdown,
|
||||||
@@ -652,6 +653,71 @@ 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', () => {
|
describe('getContentNode', () => {
|
||||||
let mockEditorView;
|
let mockEditorView;
|
||||||
|
|
||||||
|
|||||||
@@ -64,25 +64,4 @@ describe('#filterQueryGenerator', () => {
|
|||||||
filterQueryGenerator(testData).payload.every(i => Array.isArray(i.values))
|
filterQueryGenerator(testData).payload.every(i => Array.isArray(i.values))
|
||||||
).toBe(true);
|
).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,40 +74,37 @@ describe('PortalHelper', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('buildLocaleMenuItems', () => {
|
describe('buildLocaleMenuItems', () => {
|
||||||
it('disables other actions but keeps customize enabled for the default locale', () => {
|
it('returns disabled actions 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(
|
expect(
|
||||||
items
|
buildLocaleMenuItems({
|
||||||
.filter(item => item.action !== 'customize-content')
|
isDefault: true,
|
||||||
.every(item => item.disabled)
|
isDraft: false,
|
||||||
).toBe(true);
|
})
|
||||||
|
).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ action: 'change-default', disabled: true }),
|
||||||
|
expect.objectContaining({ action: 'move-to-draft', disabled: true }),
|
||||||
|
expect.objectContaining({ action: 'delete', disabled: true }),
|
||||||
|
])
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns publish, customize, and delete actions for draft locales', () => {
|
it('returns publish and delete actions for draft locales', () => {
|
||||||
expect(
|
expect(
|
||||||
buildLocaleMenuItems({
|
buildLocaleMenuItems({
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
isDraft: true,
|
isDraft: true,
|
||||||
}).map(({ action }) => action)
|
}).map(({ action }) => action)
|
||||||
).toEqual(['publish-locale', 'customize-content', 'delete']);
|
).toEqual(['publish-locale', 'delete']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns default, draft, customize, and delete actions for live locales', () => {
|
it('returns default, draft, and delete actions for live locales', () => {
|
||||||
expect(
|
expect(
|
||||||
buildLocaleMenuItems({
|
buildLocaleMenuItems({
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
isDraft: false,
|
isDraft: false,
|
||||||
}).map(({ action }) => action)
|
}).map(({ action }) => action)
|
||||||
).toEqual([
|
).toEqual(['change-default', 'move-to-draft', 'delete']);
|
||||||
'change-default',
|
|
||||||
'move-to-draft',
|
|
||||||
'customize-content',
|
|
||||||
'delete',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
"OR": "OR"
|
"OR": "OR"
|
||||||
},
|
},
|
||||||
"INPUT_PLACEHOLDER": "Enter value",
|
"INPUT_PLACEHOLDER": "Enter value",
|
||||||
|
"CONTACT_SEARCH_PLACEHOLDER": "እውቂያዎችን ይፈልጉ",
|
||||||
|
"CONTACT_FALLBACK": "Contact #{id}",
|
||||||
"OPERATOR_LABELS": {
|
"OPERATOR_LABELS": {
|
||||||
"equal_to": "Equal to",
|
"equal_to": "Equal to",
|
||||||
"not_equal_to": "Not equal to",
|
"not_equal_to": "Not equal to",
|
||||||
@@ -49,6 +51,7 @@
|
|||||||
"ASSIGNEE_NAME": "Assignee name",
|
"ASSIGNEE_NAME": "Assignee name",
|
||||||
"INBOX_NAME": "Inbox name",
|
"INBOX_NAME": "Inbox name",
|
||||||
"TEAM_NAME": "Team name",
|
"TEAM_NAME": "Team name",
|
||||||
|
"CONTACT": "እውቂያ",
|
||||||
"CONVERSATION_IDENTIFIER": "Conversation identifier",
|
"CONVERSATION_IDENTIFIER": "Conversation identifier",
|
||||||
"CAMPAIGN_NAME": "Campaign name",
|
"CAMPAIGN_NAME": "Campaign name",
|
||||||
"LABELS": "Labels",
|
"LABELS": "Labels",
|
||||||
|
|||||||
@@ -233,6 +233,7 @@
|
|||||||
"TIP_AUDIORECORDER_ERROR": "Could not open the audio",
|
"TIP_AUDIORECORDER_ERROR": "Could not open the audio",
|
||||||
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
|
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
|
||||||
"DRAG_DROP": "Drag and drop here to attach",
|
"DRAG_DROP": "Drag and drop here to attach",
|
||||||
|
"IMAGE_UPLOAD_SUCCESS": "ምስል በተሳካ ሁኔታ ተሰብስቧል",
|
||||||
"START_AUDIO_RECORDING": "Start audio recording",
|
"START_AUDIO_RECORDING": "Start audio recording",
|
||||||
"STOP_AUDIO_RECORDING": "Stop audio recording",
|
"STOP_AUDIO_RECORDING": "Stop audio recording",
|
||||||
"COPILOT_THINKING": "ኮፒሎት እየሰማራ ነው",
|
"COPILOT_THINKING": "ኮፒሎት እየሰማራ ነው",
|
||||||
|
|||||||
@@ -700,9 +700,27 @@
|
|||||||
"MAKE_DEFAULT": "እንደ ነባሪ አድርግ",
|
"MAKE_DEFAULT": "እንደ ነባሪ አድርግ",
|
||||||
"MOVE_TO_DRAFT": "Move to draft",
|
"MOVE_TO_DRAFT": "Move to draft",
|
||||||
"PUBLISH_LOCALE": "Publish locale",
|
"PUBLISH_LOCALE": "Publish locale",
|
||||||
|
"CUSTOMIZE_CONTENT": "Localize content",
|
||||||
"DELETE": "ሰርዝ"
|
"DELETE": "ሰርዝ"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"CONTENT_DIALOG": {
|
||||||
|
"TITLE": "Localize content",
|
||||||
|
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
|
||||||
|
"NAME": {
|
||||||
|
"LABEL": "Name"
|
||||||
|
},
|
||||||
|
"PAGE_TITLE": {
|
||||||
|
"LABEL": "የገፅ ርዕስ"
|
||||||
|
},
|
||||||
|
"HEADER_TEXT": {
|
||||||
|
"LABEL": "የራስጌ ጽሑፍ"
|
||||||
|
},
|
||||||
|
"API": {
|
||||||
|
"SUCCESS_MESSAGE": "Locale content updated successfully",
|
||||||
|
"ERROR_MESSAGE": "Unable to update locale content. Try again."
|
||||||
|
}
|
||||||
|
},
|
||||||
"ADD_LOCALE_DIALOG": {
|
"ADD_LOCALE_DIALOG": {
|
||||||
"TITLE": "አዲስ ቋንቋ አክል",
|
"TITLE": "አዲስ ቋንቋ አክል",
|
||||||
"DESCRIPTION": "ይህ ሰነድ በሚጻፍበት ቋንቋ ይምረጡ። ይህ ወደ የትርጉም ዝርዝርዎ ይጨምራል እና በኋላ ተጨማሪ ማክሰኞ ይችላሉ።.",
|
"DESCRIPTION": "ይህ ሰነድ በሚጻፍበት ቋንቋ ይምረጡ። ይህ ወደ የትርጉም ዝርዝርዎ ይጨምራል እና በኋላ ተጨማሪ ማክሰኞ ይችላሉ።.",
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
"OR": "أو"
|
"OR": "أو"
|
||||||
},
|
},
|
||||||
"INPUT_PLACEHOLDER": "أدخل القيمة",
|
"INPUT_PLACEHOLDER": "أدخل القيمة",
|
||||||
|
"CONTACT_SEARCH_PLACEHOLDER": "Search contacts",
|
||||||
|
"CONTACT_FALLBACK": "Contact #{id}",
|
||||||
"OPERATOR_LABELS": {
|
"OPERATOR_LABELS": {
|
||||||
"equal_to": "يساوي",
|
"equal_to": "يساوي",
|
||||||
"not_equal_to": "لا يساوي",
|
"not_equal_to": "لا يساوي",
|
||||||
@@ -49,6 +51,7 @@
|
|||||||
"ASSIGNEE_NAME": "اسم المكلَّف",
|
"ASSIGNEE_NAME": "اسم المكلَّف",
|
||||||
"INBOX_NAME": "اسم صندوق الوارد",
|
"INBOX_NAME": "اسم صندوق الوارد",
|
||||||
"TEAM_NAME": "اسم الفريق",
|
"TEAM_NAME": "اسم الفريق",
|
||||||
|
"CONTACT": "جهات الاتصال",
|
||||||
"CONVERSATION_IDENTIFIER": "معرف المحادثة",
|
"CONVERSATION_IDENTIFIER": "معرف المحادثة",
|
||||||
"CAMPAIGN_NAME": "اسم الحملة",
|
"CAMPAIGN_NAME": "اسم الحملة",
|
||||||
"LABELS": "الوسوم",
|
"LABELS": "الوسوم",
|
||||||
|
|||||||
@@ -233,6 +233,7 @@
|
|||||||
"TIP_AUDIORECORDER_ERROR": "تعذر فتح الصوت",
|
"TIP_AUDIORECORDER_ERROR": "تعذر فتح الصوت",
|
||||||
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
|
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
|
||||||
"DRAG_DROP": "اسحب و أسقط هنا للإرفاق",
|
"DRAG_DROP": "اسحب و أسقط هنا للإرفاق",
|
||||||
|
"IMAGE_UPLOAD_SUCCESS": "تم رفع الصورة بنجاح",
|
||||||
"START_AUDIO_RECORDING": "بدء التسجيل الصوتي",
|
"START_AUDIO_RECORDING": "بدء التسجيل الصوتي",
|
||||||
"STOP_AUDIO_RECORDING": "إيقاف التسجيل الصوتي",
|
"STOP_AUDIO_RECORDING": "إيقاف التسجيل الصوتي",
|
||||||
"COPILOT_THINKING": "Copilot يفكر",
|
"COPILOT_THINKING": "Copilot يفكر",
|
||||||
|
|||||||
@@ -700,9 +700,27 @@
|
|||||||
"MAKE_DEFAULT": "Make default",
|
"MAKE_DEFAULT": "Make default",
|
||||||
"MOVE_TO_DRAFT": "Move to draft",
|
"MOVE_TO_DRAFT": "Move to draft",
|
||||||
"PUBLISH_LOCALE": "Publish locale",
|
"PUBLISH_LOCALE": "Publish locale",
|
||||||
|
"CUSTOMIZE_CONTENT": "Localize content",
|
||||||
"DELETE": "حذف"
|
"DELETE": "حذف"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"CONTENT_DIALOG": {
|
||||||
|
"TITLE": "Localize content",
|
||||||
|
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
|
||||||
|
"NAME": {
|
||||||
|
"LABEL": "الاسم"
|
||||||
|
},
|
||||||
|
"PAGE_TITLE": {
|
||||||
|
"LABEL": "Page title"
|
||||||
|
},
|
||||||
|
"HEADER_TEXT": {
|
||||||
|
"LABEL": "Header text"
|
||||||
|
},
|
||||||
|
"API": {
|
||||||
|
"SUCCESS_MESSAGE": "Locale content updated successfully",
|
||||||
|
"ERROR_MESSAGE": "Unable to update locale content. Try again."
|
||||||
|
}
|
||||||
|
},
|
||||||
"ADD_LOCALE_DIALOG": {
|
"ADD_LOCALE_DIALOG": {
|
||||||
"TITLE": "إضافة لغة جديدة",
|
"TITLE": "إضافة لغة جديدة",
|
||||||
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
|
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
"OR": "OR"
|
"OR": "OR"
|
||||||
},
|
},
|
||||||
"INPUT_PLACEHOLDER": "Enter value",
|
"INPUT_PLACEHOLDER": "Enter value",
|
||||||
|
"CONTACT_SEARCH_PLACEHOLDER": "Əlaqələrdə axtarış",
|
||||||
|
"CONTACT_FALLBACK": "Contact #{id}",
|
||||||
"OPERATOR_LABELS": {
|
"OPERATOR_LABELS": {
|
||||||
"equal_to": "Equal to",
|
"equal_to": "Equal to",
|
||||||
"not_equal_to": "Not equal to",
|
"not_equal_to": "Not equal to",
|
||||||
@@ -49,6 +51,7 @@
|
|||||||
"ASSIGNEE_NAME": "Assignee name",
|
"ASSIGNEE_NAME": "Assignee name",
|
||||||
"INBOX_NAME": "Inbox name",
|
"INBOX_NAME": "Inbox name",
|
||||||
"TEAM_NAME": "Team name",
|
"TEAM_NAME": "Team name",
|
||||||
|
"CONTACT": "Əlaqə",
|
||||||
"CONVERSATION_IDENTIFIER": "Conversation identifier",
|
"CONVERSATION_IDENTIFIER": "Conversation identifier",
|
||||||
"CAMPAIGN_NAME": "Campaign name",
|
"CAMPAIGN_NAME": "Campaign name",
|
||||||
"LABELS": "Labels",
|
"LABELS": "Labels",
|
||||||
|
|||||||
@@ -233,6 +233,7 @@
|
|||||||
"TIP_AUDIORECORDER_ERROR": "Səsi açmaq mümkün olmadı",
|
"TIP_AUDIORECORDER_ERROR": "Səsi açmaq mümkün olmadı",
|
||||||
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
|
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
|
||||||
"DRAG_DROP": "Qoşmaq üçün buraya sürükləyin və buraxın",
|
"DRAG_DROP": "Qoşmaq üçün buraya sürükləyin və buraxın",
|
||||||
|
"IMAGE_UPLOAD_SUCCESS": "Şəkil uğurla yükləndi",
|
||||||
"START_AUDIO_RECORDING": "Səs yazısını başla",
|
"START_AUDIO_RECORDING": "Səs yazısını başla",
|
||||||
"STOP_AUDIO_RECORDING": "Səs yazısını dayandırın",
|
"STOP_AUDIO_RECORDING": "Səs yazısını dayandırın",
|
||||||
"COPILOT_THINKING": "Copilot düşünür",
|
"COPILOT_THINKING": "Copilot düşünür",
|
||||||
|
|||||||
@@ -700,9 +700,27 @@
|
|||||||
"MAKE_DEFAULT": "Make default",
|
"MAKE_DEFAULT": "Make default",
|
||||||
"MOVE_TO_DRAFT": "Move to draft",
|
"MOVE_TO_DRAFT": "Move to draft",
|
||||||
"PUBLISH_LOCALE": "Publish locale",
|
"PUBLISH_LOCALE": "Publish locale",
|
||||||
|
"CUSTOMIZE_CONTENT": "Localize content",
|
||||||
"DELETE": "Delete"
|
"DELETE": "Delete"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"CONTENT_DIALOG": {
|
||||||
|
"TITLE": "Localize content",
|
||||||
|
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
|
||||||
|
"NAME": {
|
||||||
|
"LABEL": "Name"
|
||||||
|
},
|
||||||
|
"PAGE_TITLE": {
|
||||||
|
"LABEL": "Page title"
|
||||||
|
},
|
||||||
|
"HEADER_TEXT": {
|
||||||
|
"LABEL": "Header text"
|
||||||
|
},
|
||||||
|
"API": {
|
||||||
|
"SUCCESS_MESSAGE": "Locale content updated successfully",
|
||||||
|
"ERROR_MESSAGE": "Unable to update locale content. Try again."
|
||||||
|
}
|
||||||
|
},
|
||||||
"ADD_LOCALE_DIALOG": {
|
"ADD_LOCALE_DIALOG": {
|
||||||
"TITLE": "Add a new locale",
|
"TITLE": "Add a new locale",
|
||||||
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
|
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
"OR": "ИЛИ"
|
"OR": "ИЛИ"
|
||||||
},
|
},
|
||||||
"INPUT_PLACEHOLDER": "Enter value",
|
"INPUT_PLACEHOLDER": "Enter value",
|
||||||
|
"CONTACT_SEARCH_PLACEHOLDER": "Search contacts",
|
||||||
|
"CONTACT_FALLBACK": "Contact #{id}",
|
||||||
"OPERATOR_LABELS": {
|
"OPERATOR_LABELS": {
|
||||||
"equal_to": "Равно на",
|
"equal_to": "Равно на",
|
||||||
"not_equal_to": "Различно от",
|
"not_equal_to": "Различно от",
|
||||||
@@ -49,6 +51,7 @@
|
|||||||
"ASSIGNEE_NAME": "Assignee name",
|
"ASSIGNEE_NAME": "Assignee name",
|
||||||
"INBOX_NAME": "Inbox name",
|
"INBOX_NAME": "Inbox name",
|
||||||
"TEAM_NAME": "Team name",
|
"TEAM_NAME": "Team name",
|
||||||
|
"CONTACT": "Contact",
|
||||||
"CONVERSATION_IDENTIFIER": "Conversation identifier",
|
"CONVERSATION_IDENTIFIER": "Conversation identifier",
|
||||||
"CAMPAIGN_NAME": "Campaign name",
|
"CAMPAIGN_NAME": "Campaign name",
|
||||||
"LABELS": "Етикети",
|
"LABELS": "Етикети",
|
||||||
|
|||||||
@@ -233,6 +233,7 @@
|
|||||||
"TIP_AUDIORECORDER_ERROR": "Could not open the audio",
|
"TIP_AUDIORECORDER_ERROR": "Could not open the audio",
|
||||||
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
|
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
|
||||||
"DRAG_DROP": "Drag and drop here to attach",
|
"DRAG_DROP": "Drag and drop here to attach",
|
||||||
|
"IMAGE_UPLOAD_SUCCESS": "Image uploaded successfully",
|
||||||
"START_AUDIO_RECORDING": "Start audio recording",
|
"START_AUDIO_RECORDING": "Start audio recording",
|
||||||
"STOP_AUDIO_RECORDING": "Stop audio recording",
|
"STOP_AUDIO_RECORDING": "Stop audio recording",
|
||||||
"COPILOT_THINKING": "Copilot мисли",
|
"COPILOT_THINKING": "Copilot мисли",
|
||||||
|
|||||||
@@ -700,9 +700,27 @@
|
|||||||
"MAKE_DEFAULT": "Make default",
|
"MAKE_DEFAULT": "Make default",
|
||||||
"MOVE_TO_DRAFT": "Move to draft",
|
"MOVE_TO_DRAFT": "Move to draft",
|
||||||
"PUBLISH_LOCALE": "Publish locale",
|
"PUBLISH_LOCALE": "Publish locale",
|
||||||
|
"CUSTOMIZE_CONTENT": "Localize content",
|
||||||
"DELETE": "Изтрий"
|
"DELETE": "Изтрий"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"CONTENT_DIALOG": {
|
||||||
|
"TITLE": "Localize content",
|
||||||
|
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
|
||||||
|
"NAME": {
|
||||||
|
"LABEL": "Име"
|
||||||
|
},
|
||||||
|
"PAGE_TITLE": {
|
||||||
|
"LABEL": "Page title"
|
||||||
|
},
|
||||||
|
"HEADER_TEXT": {
|
||||||
|
"LABEL": "Header text"
|
||||||
|
},
|
||||||
|
"API": {
|
||||||
|
"SUCCESS_MESSAGE": "Locale content updated successfully",
|
||||||
|
"ERROR_MESSAGE": "Unable to update locale content. Try again."
|
||||||
|
}
|
||||||
|
},
|
||||||
"ADD_LOCALE_DIALOG": {
|
"ADD_LOCALE_DIALOG": {
|
||||||
"TITLE": "Add a new locale",
|
"TITLE": "Add a new locale",
|
||||||
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
|
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
"OR": "OR"
|
"OR": "OR"
|
||||||
},
|
},
|
||||||
"INPUT_PLACEHOLDER": "Enter value",
|
"INPUT_PLACEHOLDER": "Enter value",
|
||||||
|
"CONTACT_SEARCH_PLACEHOLDER": "যোগাযোগ অনুসন্ধান করুন",
|
||||||
|
"CONTACT_FALLBACK": "Contact #{id}",
|
||||||
"OPERATOR_LABELS": {
|
"OPERATOR_LABELS": {
|
||||||
"equal_to": "Equal to",
|
"equal_to": "Equal to",
|
||||||
"not_equal_to": "Not equal to",
|
"not_equal_to": "Not equal to",
|
||||||
@@ -49,6 +51,7 @@
|
|||||||
"ASSIGNEE_NAME": "Assignee name",
|
"ASSIGNEE_NAME": "Assignee name",
|
||||||
"INBOX_NAME": "Inbox name",
|
"INBOX_NAME": "Inbox name",
|
||||||
"TEAM_NAME": "Team name",
|
"TEAM_NAME": "Team name",
|
||||||
|
"CONTACT": "যোগাযোগ",
|
||||||
"CONVERSATION_IDENTIFIER": "Conversation identifier",
|
"CONVERSATION_IDENTIFIER": "Conversation identifier",
|
||||||
"CAMPAIGN_NAME": "Campaign name",
|
"CAMPAIGN_NAME": "Campaign name",
|
||||||
"LABELS": "Labels",
|
"LABELS": "Labels",
|
||||||
|
|||||||
@@ -233,6 +233,7 @@
|
|||||||
"TIP_AUDIORECORDER_ERROR": "অডিও খোলা যায়নি",
|
"TIP_AUDIORECORDER_ERROR": "অডিও খোলা যায়নি",
|
||||||
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
|
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
|
||||||
"DRAG_DROP": "সংযুক্ত করতে এখানে ড্র্যাগ ও ড্রপ করুন",
|
"DRAG_DROP": "সংযুক্ত করতে এখানে ড্র্যাগ ও ড্রপ করুন",
|
||||||
|
"IMAGE_UPLOAD_SUCCESS": "ছবি সফলভাবে আপলোড হয়েছে",
|
||||||
"START_AUDIO_RECORDING": "অডিও রেকর্ডিং শুরু করুন",
|
"START_AUDIO_RECORDING": "অডিও রেকর্ডিং শুরু করুন",
|
||||||
"STOP_AUDIO_RECORDING": "অডিও রেকর্ডিং বন্ধ করুন",
|
"STOP_AUDIO_RECORDING": "অডিও রেকর্ডিং বন্ধ করুন",
|
||||||
"COPILOT_THINKING": "Copilot ভাবছে",
|
"COPILOT_THINKING": "Copilot ভাবছে",
|
||||||
|
|||||||
@@ -700,9 +700,27 @@
|
|||||||
"MAKE_DEFAULT": "ডিফল্ট করুন",
|
"MAKE_DEFAULT": "ডিফল্ট করুন",
|
||||||
"MOVE_TO_DRAFT": "Move to draft",
|
"MOVE_TO_DRAFT": "Move to draft",
|
||||||
"PUBLISH_LOCALE": "Publish locale",
|
"PUBLISH_LOCALE": "Publish locale",
|
||||||
|
"CUSTOMIZE_CONTENT": "Localize content",
|
||||||
"DELETE": "মুছে ফেলুন"
|
"DELETE": "মুছে ফেলুন"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"CONTENT_DIALOG": {
|
||||||
|
"TITLE": "Localize content",
|
||||||
|
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
|
||||||
|
"NAME": {
|
||||||
|
"LABEL": "নাম"
|
||||||
|
},
|
||||||
|
"PAGE_TITLE": {
|
||||||
|
"LABEL": "পৃষ্ঠার শিরোনাম"
|
||||||
|
},
|
||||||
|
"HEADER_TEXT": {
|
||||||
|
"LABEL": "হেডার টেক্সট"
|
||||||
|
},
|
||||||
|
"API": {
|
||||||
|
"SUCCESS_MESSAGE": "Locale content updated successfully",
|
||||||
|
"ERROR_MESSAGE": "Unable to update locale content. Try again."
|
||||||
|
}
|
||||||
|
},
|
||||||
"ADD_LOCALE_DIALOG": {
|
"ADD_LOCALE_DIALOG": {
|
||||||
"TITLE": "নতুন লোকেল যোগ করুন",
|
"TITLE": "নতুন লোকেল যোগ করুন",
|
||||||
"DESCRIPTION": "এই নিবন্ধটি যে ভাষায় লেখা হবে তা নির্বাচন করুন. এটি আপনার অনুবাদ তালিকায় যোগ হবে, এবং পরে আপনি আরও ভাষা যোগ করতে পারবেন.",
|
"DESCRIPTION": "এই নিবন্ধটি যে ভাষায় লেখা হবে তা নির্বাচন করুন. এটি আপনার অনুবাদ তালিকায় যোগ হবে, এবং পরে আপনি আরও ভাষা যোগ করতে পারবেন.",
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
"OR": "O"
|
"OR": "O"
|
||||||
},
|
},
|
||||||
"INPUT_PLACEHOLDER": "Enter value",
|
"INPUT_PLACEHOLDER": "Enter value",
|
||||||
|
"CONTACT_SEARCH_PLACEHOLDER": "Search contacts",
|
||||||
|
"CONTACT_FALLBACK": "Contact #{id}",
|
||||||
"OPERATOR_LABELS": {
|
"OPERATOR_LABELS": {
|
||||||
"equal_to": "Igual a",
|
"equal_to": "Igual a",
|
||||||
"not_equal_to": "No és igual a",
|
"not_equal_to": "No és igual a",
|
||||||
@@ -49,6 +51,7 @@
|
|||||||
"ASSIGNEE_NAME": "Nom assignat",
|
"ASSIGNEE_NAME": "Nom assignat",
|
||||||
"INBOX_NAME": "Nom de la safata d'entrada",
|
"INBOX_NAME": "Nom de la safata d'entrada",
|
||||||
"TEAM_NAME": "Nom de l'equip",
|
"TEAM_NAME": "Nom de l'equip",
|
||||||
|
"CONTACT": "Contacte",
|
||||||
"CONVERSATION_IDENTIFIER": "Identificador de conversa",
|
"CONVERSATION_IDENTIFIER": "Identificador de conversa",
|
||||||
"CAMPAIGN_NAME": "Nom de la campanya",
|
"CAMPAIGN_NAME": "Nom de la campanya",
|
||||||
"LABELS": "Etiquetes",
|
"LABELS": "Etiquetes",
|
||||||
|
|||||||
@@ -233,6 +233,7 @@
|
|||||||
"TIP_AUDIORECORDER_ERROR": "No s'ha pogut obrir l'àudio",
|
"TIP_AUDIORECORDER_ERROR": "No s'ha pogut obrir l'àudio",
|
||||||
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
|
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
|
||||||
"DRAG_DROP": "Arrossega i deixa anar aquí per adjuntar-lo",
|
"DRAG_DROP": "Arrossega i deixa anar aquí per adjuntar-lo",
|
||||||
|
"IMAGE_UPLOAD_SUCCESS": "La imatge s'ha carregat correctament",
|
||||||
"START_AUDIO_RECORDING": "Inicia la gravació d'àudio",
|
"START_AUDIO_RECORDING": "Inicia la gravació d'àudio",
|
||||||
"STOP_AUDIO_RECORDING": "Atura la gravació d'àudio",
|
"STOP_AUDIO_RECORDING": "Atura la gravació d'àudio",
|
||||||
"COPILOT_THINKING": "Copilot està pensant",
|
"COPILOT_THINKING": "Copilot està pensant",
|
||||||
|
|||||||
@@ -700,9 +700,27 @@
|
|||||||
"MAKE_DEFAULT": "Make default",
|
"MAKE_DEFAULT": "Make default",
|
||||||
"MOVE_TO_DRAFT": "Move to draft",
|
"MOVE_TO_DRAFT": "Move to draft",
|
||||||
"PUBLISH_LOCALE": "Publish locale",
|
"PUBLISH_LOCALE": "Publish locale",
|
||||||
|
"CUSTOMIZE_CONTENT": "Localize content",
|
||||||
"DELETE": "Esborrar"
|
"DELETE": "Esborrar"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"CONTENT_DIALOG": {
|
||||||
|
"TITLE": "Localize content",
|
||||||
|
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
|
||||||
|
"NAME": {
|
||||||
|
"LABEL": "Nom"
|
||||||
|
},
|
||||||
|
"PAGE_TITLE": {
|
||||||
|
"LABEL": "Page title"
|
||||||
|
},
|
||||||
|
"HEADER_TEXT": {
|
||||||
|
"LABEL": "Header text"
|
||||||
|
},
|
||||||
|
"API": {
|
||||||
|
"SUCCESS_MESSAGE": "Locale content updated successfully",
|
||||||
|
"ERROR_MESSAGE": "Unable to update locale content. Try again."
|
||||||
|
}
|
||||||
|
},
|
||||||
"ADD_LOCALE_DIALOG": {
|
"ADD_LOCALE_DIALOG": {
|
||||||
"TITLE": "Afegeix una nova localització",
|
"TITLE": "Afegeix una nova localització",
|
||||||
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
|
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
"OR": "OR"
|
"OR": "OR"
|
||||||
},
|
},
|
||||||
"INPUT_PLACEHOLDER": "Enter value",
|
"INPUT_PLACEHOLDER": "Enter value",
|
||||||
|
"CONTACT_SEARCH_PLACEHOLDER": "Search contacts",
|
||||||
|
"CONTACT_FALLBACK": "Contact #{id}",
|
||||||
"OPERATOR_LABELS": {
|
"OPERATOR_LABELS": {
|
||||||
"equal_to": "Equal to",
|
"equal_to": "Equal to",
|
||||||
"not_equal_to": "Not equal to",
|
"not_equal_to": "Not equal to",
|
||||||
@@ -49,6 +51,7 @@
|
|||||||
"ASSIGNEE_NAME": "Assignee name",
|
"ASSIGNEE_NAME": "Assignee name",
|
||||||
"INBOX_NAME": "Inbox name",
|
"INBOX_NAME": "Inbox name",
|
||||||
"TEAM_NAME": "Team name",
|
"TEAM_NAME": "Team name",
|
||||||
|
"CONTACT": "Contact",
|
||||||
"CONVERSATION_IDENTIFIER": "Conversation identifier",
|
"CONVERSATION_IDENTIFIER": "Conversation identifier",
|
||||||
"CAMPAIGN_NAME": "Campaign name",
|
"CAMPAIGN_NAME": "Campaign name",
|
||||||
"LABELS": "Štítky",
|
"LABELS": "Štítky",
|
||||||
|
|||||||
@@ -233,6 +233,7 @@
|
|||||||
"TIP_AUDIORECORDER_ERROR": "Zvuk se nepodařilo otevřít",
|
"TIP_AUDIORECORDER_ERROR": "Zvuk se nepodařilo otevřít",
|
||||||
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
|
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
|
||||||
"DRAG_DROP": "Přetažením sem připojíte",
|
"DRAG_DROP": "Přetažením sem připojíte",
|
||||||
|
"IMAGE_UPLOAD_SUCCESS": "Image uploaded successfully",
|
||||||
"START_AUDIO_RECORDING": "Spustit nahrávání zvuku",
|
"START_AUDIO_RECORDING": "Spustit nahrávání zvuku",
|
||||||
"STOP_AUDIO_RECORDING": "Zastavit nahrávání zvuku",
|
"STOP_AUDIO_RECORDING": "Zastavit nahrávání zvuku",
|
||||||
"COPILOT_THINKING": "Copilot přemýšlí",
|
"COPILOT_THINKING": "Copilot přemýšlí",
|
||||||
|
|||||||
@@ -700,9 +700,27 @@
|
|||||||
"MAKE_DEFAULT": "Make default",
|
"MAKE_DEFAULT": "Make default",
|
||||||
"MOVE_TO_DRAFT": "Move to draft",
|
"MOVE_TO_DRAFT": "Move to draft",
|
||||||
"PUBLISH_LOCALE": "Publish locale",
|
"PUBLISH_LOCALE": "Publish locale",
|
||||||
|
"CUSTOMIZE_CONTENT": "Localize content",
|
||||||
"DELETE": "Vymazat"
|
"DELETE": "Vymazat"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"CONTENT_DIALOG": {
|
||||||
|
"TITLE": "Localize content",
|
||||||
|
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
|
||||||
|
"NAME": {
|
||||||
|
"LABEL": "Název"
|
||||||
|
},
|
||||||
|
"PAGE_TITLE": {
|
||||||
|
"LABEL": "Page title"
|
||||||
|
},
|
||||||
|
"HEADER_TEXT": {
|
||||||
|
"LABEL": "Header text"
|
||||||
|
},
|
||||||
|
"API": {
|
||||||
|
"SUCCESS_MESSAGE": "Locale content updated successfully",
|
||||||
|
"ERROR_MESSAGE": "Unable to update locale content. Try again."
|
||||||
|
}
|
||||||
|
},
|
||||||
"ADD_LOCALE_DIALOG": {
|
"ADD_LOCALE_DIALOG": {
|
||||||
"TITLE": "Add a new locale",
|
"TITLE": "Add a new locale",
|
||||||
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
|
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
"OR": "ELLER"
|
"OR": "ELLER"
|
||||||
},
|
},
|
||||||
"INPUT_PLACEHOLDER": "Enter value",
|
"INPUT_PLACEHOLDER": "Enter value",
|
||||||
|
"CONTACT_SEARCH_PLACEHOLDER": "Search contacts",
|
||||||
|
"CONTACT_FALLBACK": "Contact #{id}",
|
||||||
"OPERATOR_LABELS": {
|
"OPERATOR_LABELS": {
|
||||||
"equal_to": "Lig med",
|
"equal_to": "Lig med",
|
||||||
"not_equal_to": "Ikke lig med",
|
"not_equal_to": "Ikke lig med",
|
||||||
@@ -49,6 +51,7 @@
|
|||||||
"ASSIGNEE_NAME": "Assignee name",
|
"ASSIGNEE_NAME": "Assignee name",
|
||||||
"INBOX_NAME": "Indbakkens navn",
|
"INBOX_NAME": "Indbakkens navn",
|
||||||
"TEAM_NAME": "Team navn",
|
"TEAM_NAME": "Team navn",
|
||||||
|
"CONTACT": "Kontakt",
|
||||||
"CONVERSATION_IDENTIFIER": "Conversation identifier",
|
"CONVERSATION_IDENTIFIER": "Conversation identifier",
|
||||||
"CAMPAIGN_NAME": "Campaign name",
|
"CAMPAIGN_NAME": "Campaign name",
|
||||||
"LABELS": "Etiketter",
|
"LABELS": "Etiketter",
|
||||||
|
|||||||
@@ -233,6 +233,7 @@
|
|||||||
"TIP_AUDIORECORDER_ERROR": "Kunne ikke åbne lyden",
|
"TIP_AUDIORECORDER_ERROR": "Kunne ikke åbne lyden",
|
||||||
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
|
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
|
||||||
"DRAG_DROP": "Træk og slip her for at vedhæfte",
|
"DRAG_DROP": "Træk og slip her for at vedhæfte",
|
||||||
|
"IMAGE_UPLOAD_SUCCESS": "Billede blev uploadet",
|
||||||
"START_AUDIO_RECORDING": "Start lydoptagelse",
|
"START_AUDIO_RECORDING": "Start lydoptagelse",
|
||||||
"STOP_AUDIO_RECORDING": "Stop lydoptagelse",
|
"STOP_AUDIO_RECORDING": "Stop lydoptagelse",
|
||||||
"COPILOT_THINKING": "Copilot tænker",
|
"COPILOT_THINKING": "Copilot tænker",
|
||||||
|
|||||||
@@ -700,9 +700,27 @@
|
|||||||
"MAKE_DEFAULT": "Make default",
|
"MAKE_DEFAULT": "Make default",
|
||||||
"MOVE_TO_DRAFT": "Move to draft",
|
"MOVE_TO_DRAFT": "Move to draft",
|
||||||
"PUBLISH_LOCALE": "Publish locale",
|
"PUBLISH_LOCALE": "Publish locale",
|
||||||
|
"CUSTOMIZE_CONTENT": "Localize content",
|
||||||
"DELETE": "Slet"
|
"DELETE": "Slet"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"CONTENT_DIALOG": {
|
||||||
|
"TITLE": "Localize content",
|
||||||
|
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
|
||||||
|
"NAME": {
|
||||||
|
"LABEL": "Navn"
|
||||||
|
},
|
||||||
|
"PAGE_TITLE": {
|
||||||
|
"LABEL": "Page title"
|
||||||
|
},
|
||||||
|
"HEADER_TEXT": {
|
||||||
|
"LABEL": "Header text"
|
||||||
|
},
|
||||||
|
"API": {
|
||||||
|
"SUCCESS_MESSAGE": "Locale content updated successfully",
|
||||||
|
"ERROR_MESSAGE": "Unable to update locale content. Try again."
|
||||||
|
}
|
||||||
|
},
|
||||||
"ADD_LOCALE_DIALOG": {
|
"ADD_LOCALE_DIALOG": {
|
||||||
"TITLE": "Tilføj en ny landestandard",
|
"TITLE": "Tilføj en ny landestandard",
|
||||||
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
|
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
"OR": "ODER"
|
"OR": "ODER"
|
||||||
},
|
},
|
||||||
"INPUT_PLACEHOLDER": "Wert eintragen",
|
"INPUT_PLACEHOLDER": "Wert eintragen",
|
||||||
|
"CONTACT_SEARCH_PLACEHOLDER": "Kontakte suchen",
|
||||||
|
"CONTACT_FALLBACK": "Contact #{id}",
|
||||||
"OPERATOR_LABELS": {
|
"OPERATOR_LABELS": {
|
||||||
"equal_to": "Gleich",
|
"equal_to": "Gleich",
|
||||||
"not_equal_to": "Nicht gleich",
|
"not_equal_to": "Nicht gleich",
|
||||||
@@ -49,6 +51,7 @@
|
|||||||
"ASSIGNEE_NAME": "Bearbeiter Name",
|
"ASSIGNEE_NAME": "Bearbeiter Name",
|
||||||
"INBOX_NAME": "Posteingangsname",
|
"INBOX_NAME": "Posteingangsname",
|
||||||
"TEAM_NAME": "Teamname",
|
"TEAM_NAME": "Teamname",
|
||||||
|
"CONTACT": "Kontakt",
|
||||||
"CONVERSATION_IDENTIFIER": "Konversation-ID",
|
"CONVERSATION_IDENTIFIER": "Konversation-ID",
|
||||||
"CAMPAIGN_NAME": "Kampagnenname",
|
"CAMPAIGN_NAME": "Kampagnenname",
|
||||||
"LABELS": "Labels",
|
"LABELS": "Labels",
|
||||||
|
|||||||
@@ -233,6 +233,7 @@
|
|||||||
"TIP_AUDIORECORDER_ERROR": "Audio konnte nicht geöffnet werden",
|
"TIP_AUDIORECORDER_ERROR": "Audio konnte nicht geöffnet werden",
|
||||||
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
|
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
|
||||||
"DRAG_DROP": "Zum Anhängen hierher ziehen und ablegen",
|
"DRAG_DROP": "Zum Anhängen hierher ziehen und ablegen",
|
||||||
|
"IMAGE_UPLOAD_SUCCESS": "Bild erfolgreich hochgeladen",
|
||||||
"START_AUDIO_RECORDING": "Audioaufzeichnung starten",
|
"START_AUDIO_RECORDING": "Audioaufzeichnung starten",
|
||||||
"STOP_AUDIO_RECORDING": "Audioaufzeichnung stoppen",
|
"STOP_AUDIO_RECORDING": "Audioaufzeichnung stoppen",
|
||||||
"COPILOT_THINKING": "Copilot denkt",
|
"COPILOT_THINKING": "Copilot denkt",
|
||||||
|
|||||||
@@ -700,9 +700,27 @@
|
|||||||
"MAKE_DEFAULT": "Make default",
|
"MAKE_DEFAULT": "Make default",
|
||||||
"MOVE_TO_DRAFT": "Move to draft",
|
"MOVE_TO_DRAFT": "Move to draft",
|
||||||
"PUBLISH_LOCALE": "Publish locale",
|
"PUBLISH_LOCALE": "Publish locale",
|
||||||
|
"CUSTOMIZE_CONTENT": "Localize content",
|
||||||
"DELETE": "Löschen"
|
"DELETE": "Löschen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"CONTENT_DIALOG": {
|
||||||
|
"TITLE": "Localize content",
|
||||||
|
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
|
||||||
|
"NAME": {
|
||||||
|
"LABEL": "Name"
|
||||||
|
},
|
||||||
|
"PAGE_TITLE": {
|
||||||
|
"LABEL": "Page title"
|
||||||
|
},
|
||||||
|
"HEADER_TEXT": {
|
||||||
|
"LABEL": "Header text"
|
||||||
|
},
|
||||||
|
"API": {
|
||||||
|
"SUCCESS_MESSAGE": "Locale content updated successfully",
|
||||||
|
"ERROR_MESSAGE": "Unable to update locale content. Try again."
|
||||||
|
}
|
||||||
|
},
|
||||||
"ADD_LOCALE_DIALOG": {
|
"ADD_LOCALE_DIALOG": {
|
||||||
"TITLE": "Neues Gebietsschema hinzufügen",
|
"TITLE": "Neues Gebietsschema hinzufügen",
|
||||||
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
|
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
"OR": "OR"
|
"OR": "OR"
|
||||||
},
|
},
|
||||||
"INPUT_PLACEHOLDER": "Enter value",
|
"INPUT_PLACEHOLDER": "Enter value",
|
||||||
|
"CONTACT_SEARCH_PLACEHOLDER": "Search contacts",
|
||||||
|
"CONTACT_FALLBACK": "Contact #{id}",
|
||||||
"OPERATOR_LABELS": {
|
"OPERATOR_LABELS": {
|
||||||
"equal_to": "Ίσο με",
|
"equal_to": "Ίσο με",
|
||||||
"not_equal_to": "Όχι ίσο με",
|
"not_equal_to": "Όχι ίσο με",
|
||||||
@@ -49,6 +51,7 @@
|
|||||||
"ASSIGNEE_NAME": "Assignee name",
|
"ASSIGNEE_NAME": "Assignee name",
|
||||||
"INBOX_NAME": "Όνομα Κιβωτίου",
|
"INBOX_NAME": "Όνομα Κιβωτίου",
|
||||||
"TEAM_NAME": "Όνομα ομάδας",
|
"TEAM_NAME": "Όνομα ομάδας",
|
||||||
|
"CONTACT": "Επαφές",
|
||||||
"CONVERSATION_IDENTIFIER": "Conversation identifier",
|
"CONVERSATION_IDENTIFIER": "Conversation identifier",
|
||||||
"CAMPAIGN_NAME": "Campaign name",
|
"CAMPAIGN_NAME": "Campaign name",
|
||||||
"LABELS": "Ετικέτες",
|
"LABELS": "Ετικέτες",
|
||||||
|
|||||||
@@ -233,6 +233,7 @@
|
|||||||
"TIP_AUDIORECORDER_ERROR": "Αδυναμία ανοίγματος ήχου",
|
"TIP_AUDIORECORDER_ERROR": "Αδυναμία ανοίγματος ήχου",
|
||||||
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
|
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
|
||||||
"DRAG_DROP": "Σύρετε και αφήστε εδώ για επισύναψη",
|
"DRAG_DROP": "Σύρετε και αφήστε εδώ για επισύναψη",
|
||||||
|
"IMAGE_UPLOAD_SUCCESS": "Image uploaded successfully",
|
||||||
"START_AUDIO_RECORDING": "Έναρξη ηχογράφησης",
|
"START_AUDIO_RECORDING": "Έναρξη ηχογράφησης",
|
||||||
"STOP_AUDIO_RECORDING": "Διακοπή ηχογράφησης",
|
"STOP_AUDIO_RECORDING": "Διακοπή ηχογράφησης",
|
||||||
"COPILOT_THINKING": "Ο Copilot σκέφτεται",
|
"COPILOT_THINKING": "Ο Copilot σκέφτεται",
|
||||||
|
|||||||
@@ -700,9 +700,27 @@
|
|||||||
"MAKE_DEFAULT": "Make default",
|
"MAKE_DEFAULT": "Make default",
|
||||||
"MOVE_TO_DRAFT": "Move to draft",
|
"MOVE_TO_DRAFT": "Move to draft",
|
||||||
"PUBLISH_LOCALE": "Publish locale",
|
"PUBLISH_LOCALE": "Publish locale",
|
||||||
|
"CUSTOMIZE_CONTENT": "Localize content",
|
||||||
"DELETE": "Διαγραφή"
|
"DELETE": "Διαγραφή"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"CONTENT_DIALOG": {
|
||||||
|
"TITLE": "Localize content",
|
||||||
|
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
|
||||||
|
"NAME": {
|
||||||
|
"LABEL": "Όνομα"
|
||||||
|
},
|
||||||
|
"PAGE_TITLE": {
|
||||||
|
"LABEL": "Page title"
|
||||||
|
},
|
||||||
|
"HEADER_TEXT": {
|
||||||
|
"LABEL": "Header text"
|
||||||
|
},
|
||||||
|
"API": {
|
||||||
|
"SUCCESS_MESSAGE": "Locale content updated successfully",
|
||||||
|
"ERROR_MESSAGE": "Unable to update locale content. Try again."
|
||||||
|
}
|
||||||
|
},
|
||||||
"ADD_LOCALE_DIALOG": {
|
"ADD_LOCALE_DIALOG": {
|
||||||
"TITLE": "Προσθέστε μια νέα γλώσσα",
|
"TITLE": "Προσθέστε μια νέα γλώσσα",
|
||||||
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
|
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
|
||||||
|
|||||||
@@ -19,8 +19,6 @@
|
|||||||
"OR": "OR"
|
"OR": "OR"
|
||||||
},
|
},
|
||||||
"INPUT_PLACEHOLDER": "Enter value",
|
"INPUT_PLACEHOLDER": "Enter value",
|
||||||
"CONTACT_SEARCH_PLACEHOLDER": "Search contacts",
|
|
||||||
"CONTACT_FALLBACK": "Contact #{id}",
|
|
||||||
"OPERATOR_LABELS": {
|
"OPERATOR_LABELS": {
|
||||||
"equal_to": "Equal to",
|
"equal_to": "Equal to",
|
||||||
"not_equal_to": "Not equal to",
|
"not_equal_to": "Not equal to",
|
||||||
@@ -51,7 +49,6 @@
|
|||||||
"ASSIGNEE_NAME": "Assignee name",
|
"ASSIGNEE_NAME": "Assignee name",
|
||||||
"INBOX_NAME": "Inbox name",
|
"INBOX_NAME": "Inbox name",
|
||||||
"TEAM_NAME": "Team name",
|
"TEAM_NAME": "Team name",
|
||||||
"CONTACT": "Contact",
|
|
||||||
"CONVERSATION_IDENTIFIER": "Conversation identifier",
|
"CONVERSATION_IDENTIFIER": "Conversation identifier",
|
||||||
"CAMPAIGN_NAME": "Campaign name",
|
"CAMPAIGN_NAME": "Campaign name",
|
||||||
"LABELS": "Labels",
|
"LABELS": "Labels",
|
||||||
|
|||||||
@@ -233,7 +233,6 @@
|
|||||||
"TIP_AUDIORECORDER_ERROR": "Could not open the audio",
|
"TIP_AUDIORECORDER_ERROR": "Could not open the audio",
|
||||||
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
|
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
|
||||||
"DRAG_DROP": "Drag and drop here to attach",
|
"DRAG_DROP": "Drag and drop here to attach",
|
||||||
"IMAGE_UPLOAD_SUCCESS": "Image uploaded successfully",
|
|
||||||
"START_AUDIO_RECORDING": "Start audio recording",
|
"START_AUDIO_RECORDING": "Start audio recording",
|
||||||
"STOP_AUDIO_RECORDING": "Stop audio recording",
|
"STOP_AUDIO_RECORDING": "Stop audio recording",
|
||||||
"COPILOT_THINKING": "Copilot is thinking",
|
"COPILOT_THINKING": "Copilot is thinking",
|
||||||
|
|||||||
@@ -700,27 +700,9 @@
|
|||||||
"MAKE_DEFAULT": "Make default",
|
"MAKE_DEFAULT": "Make default",
|
||||||
"MOVE_TO_DRAFT": "Move to draft",
|
"MOVE_TO_DRAFT": "Move to draft",
|
||||||
"PUBLISH_LOCALE": "Publish locale",
|
"PUBLISH_LOCALE": "Publish locale",
|
||||||
"CUSTOMIZE_CONTENT": "Localize content",
|
|
||||||
"DELETE": "Delete"
|
"DELETE": "Delete"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"CONTENT_DIALOG": {
|
|
||||||
"TITLE": "Localize content",
|
|
||||||
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
|
|
||||||
"NAME": {
|
|
||||||
"LABEL": "Name"
|
|
||||||
},
|
|
||||||
"PAGE_TITLE": {
|
|
||||||
"LABEL": "Page title"
|
|
||||||
},
|
|
||||||
"HEADER_TEXT": {
|
|
||||||
"LABEL": "Header text"
|
|
||||||
},
|
|
||||||
"API": {
|
|
||||||
"SUCCESS_MESSAGE": "Locale content updated successfully",
|
|
||||||
"ERROR_MESSAGE": "Unable to update locale content. Try again."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ADD_LOCALE_DIALOG": {
|
"ADD_LOCALE_DIALOG": {
|
||||||
"TITLE": "Add a new locale",
|
"TITLE": "Add a new locale",
|
||||||
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
|
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
|
||||||
|
|||||||
@@ -653,10 +653,6 @@
|
|||||||
},
|
},
|
||||||
"CREDENTIALS": {
|
"CREDENTIALS": {
|
||||||
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
|
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
|
||||||
},
|
|
||||||
"INBOUND": {
|
|
||||||
"LABEL": "Allow incoming calls",
|
|
||||||
"DESCRIPTION": "Let customers call this number. When turned off, incoming calls are declined automatically — agents aren't notified and no conversation is created. Agents can still place outgoing calls."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"WHATSAPP_CALLING": {
|
"WHATSAPP_CALLING": {
|
||||||
|
|||||||
@@ -91,33 +91,10 @@
|
|||||||
"NOTE": "This token can be used if you are building an API based integration",
|
"NOTE": "This token can be used if you are building an API based integration",
|
||||||
"COPY": "Copy",
|
"COPY": "Copy",
|
||||||
"RESET": "Reset",
|
"RESET": "Reset",
|
||||||
"GENERATE": "Generate",
|
|
||||||
"CREATE": "Create token",
|
|
||||||
"CONFIRM_RESET": "Are you sure?",
|
"CONFIRM_RESET": "Are you sure?",
|
||||||
"CONFIRM_HINT": "Click again to confirm",
|
"CONFIRM_HINT": "Click again to confirm",
|
||||||
"TABLE": {
|
|
||||||
"TOKEN": "Token",
|
|
||||||
"PERMISSION": "Permission"
|
|
||||||
},
|
|
||||||
"EMPTY": {
|
|
||||||
"TITLE": "No tokens yet",
|
|
||||||
"DESCRIPTION": "Create your first access token to start building integrations against the API."
|
|
||||||
},
|
|
||||||
"SCOPES": {
|
|
||||||
"FULL_LABEL": "Full access",
|
|
||||||
"READ_ONLY_LABEL": "Read only"
|
|
||||||
},
|
|
||||||
"LINKS": {
|
|
||||||
"API_REFERENCE": "API Reference",
|
|
||||||
"CLI": "Chatwoot CLI"
|
|
||||||
},
|
|
||||||
"RESET_SUCCESS": "Access token regenerated successfully",
|
"RESET_SUCCESS": "Access token regenerated successfully",
|
||||||
"RESET_ERROR": "Unable to regenerate access token. Please try again",
|
"RESET_ERROR": "Unable to regenerate access token. Please try again"
|
||||||
"READ_ONLY_TITLE": "Read-only Access Token",
|
|
||||||
"READ_ONLY_NOTE": "Use this token for integrations that only need to read data. It cannot create, update, or delete anything.",
|
|
||||||
"READ_ONLY_GENERATE_SUCCESS": "Read-only access token generated successfully",
|
|
||||||
"READ_ONLY_RESET_SUCCESS": "Read-only access token regenerated successfully",
|
|
||||||
"READ_ONLY_RESET_ERROR": "Unable to regenerate read-only access token. Please try again"
|
|
||||||
},
|
},
|
||||||
"AUDIO_NOTIFICATIONS_SECTION": {
|
"AUDIO_NOTIFICATIONS_SECTION": {
|
||||||
"TITLE": "Audio Alerts",
|
"TITLE": "Audio Alerts",
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
"OR": "O"
|
"OR": "O"
|
||||||
},
|
},
|
||||||
"INPUT_PLACEHOLDER": "Introducir valor",
|
"INPUT_PLACEHOLDER": "Introducir valor",
|
||||||
|
"CONTACT_SEARCH_PLACEHOLDER": "Buscar contactos",
|
||||||
|
"CONTACT_FALLBACK": "Contact #{id}",
|
||||||
"OPERATOR_LABELS": {
|
"OPERATOR_LABELS": {
|
||||||
"equal_to": "Igual a",
|
"equal_to": "Igual a",
|
||||||
"not_equal_to": "No igual a",
|
"not_equal_to": "No igual a",
|
||||||
@@ -49,6 +51,7 @@
|
|||||||
"ASSIGNEE_NAME": "Nombre Asignado",
|
"ASSIGNEE_NAME": "Nombre Asignado",
|
||||||
"INBOX_NAME": "Nombre de la bandeja de entrada",
|
"INBOX_NAME": "Nombre de la bandeja de entrada",
|
||||||
"TEAM_NAME": "Nombre del equipo",
|
"TEAM_NAME": "Nombre del equipo",
|
||||||
|
"CONTACT": "Contacto",
|
||||||
"CONVERSATION_IDENTIFIER": "Identificador de conversacion",
|
"CONVERSATION_IDENTIFIER": "Identificador de conversacion",
|
||||||
"CAMPAIGN_NAME": "Nombre de Campaña",
|
"CAMPAIGN_NAME": "Nombre de Campaña",
|
||||||
"LABELS": "Etiquetas",
|
"LABELS": "Etiquetas",
|
||||||
|
|||||||
@@ -20,8 +20,8 @@
|
|||||||
"CALL": "Llamar",
|
"CALL": "Llamar",
|
||||||
"CALL_INITIATED": "Llamando al contacto…",
|
"CALL_INITIATED": "Llamando al contacto…",
|
||||||
"CALL_FAILED": "No se puede iniciar la llamada. Por favor, inténtelo de nuevo.",
|
"CALL_FAILED": "No se puede iniciar la llamada. Por favor, inténtelo de nuevo.",
|
||||||
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
|
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Solicitud de permiso de llamada al contacto enviada. Inténtalo de nuevo una vez que acepten.",
|
||||||
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
|
"WHATSAPP_CALL_PERMISSION_PENDING": "Solicitud de permiso de llamada ya enviada recientemente. Inténtelo de nuevo una vez que el contacto acepte.",
|
||||||
"CLICK_TO_EDIT": "Click to edit",
|
"CLICK_TO_EDIT": "Click to edit",
|
||||||
"VOICE_INBOX_PICKER": {
|
"VOICE_INBOX_PICKER": {
|
||||||
"TITLE": "Seleccionar un buzón de entrada"
|
"TITLE": "Seleccionar un buzón de entrada"
|
||||||
@@ -585,11 +585,11 @@
|
|||||||
},
|
},
|
||||||
"CONTACTS_BULK_ACTIONS": {
|
"CONTACTS_BULK_ACTIONS": {
|
||||||
"ASSIGN_LABELS": "Asignar etiquetas",
|
"ASSIGN_LABELS": "Asignar etiquetas",
|
||||||
"REMOVE_LABELS": "Remove Labels",
|
"REMOVE_LABELS": "Quitar etiquetas",
|
||||||
"ASSIGN_LABELS_SUCCESS": "Etiquetas asignadas correctamente.",
|
"ASSIGN_LABELS_SUCCESS": "Etiquetas asignadas correctamente.",
|
||||||
"ASSIGN_LABELS_FAILED": "Failed to assign labels",
|
"ASSIGN_LABELS_FAILED": "Failed to assign labels",
|
||||||
"REMOVE_LABELS_SUCCESS": "Labels removed successfully.",
|
"REMOVE_LABELS_SUCCESS": "Etiquetas removidas con éxito.",
|
||||||
"REMOVE_LABELS_FAILED": "Failed to remove labels",
|
"REMOVE_LABELS_FAILED": "Error al eliminar las etiquetas",
|
||||||
"DESCRIPTION": "Select the labels you want to add to the selected contacts.",
|
"DESCRIPTION": "Select the labels you want to add to the selected contacts.",
|
||||||
"NO_LABELS_FOUND": "No labels available yet.",
|
"NO_LABELS_FOUND": "No labels available yet.",
|
||||||
"SELECTED_COUNT": "{count} selected",
|
"SELECTED_COUNT": "{count} selected",
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
"UNSUPPORTED_MESSAGE_FACEBOOK": "Este mensaje no es compatible. Puedes ver este mensaje en la aplicación de Facebook Messenger.",
|
"UNSUPPORTED_MESSAGE_FACEBOOK": "Este mensaje no es compatible. Puedes ver este mensaje en la aplicación de Facebook Messenger.",
|
||||||
"UNSUPPORTED_MESSAGE_INSTAGRAM": "Este mensaje no es compatible. Puedes ver este mensaje en la aplicación de Instagram.",
|
"UNSUPPORTED_MESSAGE_INSTAGRAM": "Este mensaje no es compatible. Puedes ver este mensaje en la aplicación de Instagram.",
|
||||||
"UNSUPPORTED_MESSAGE_TIKTOK": "This message is unsupported. You can view this message on the TikTok 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.",
|
"UNSUPPORTED_MESSAGE_WHATSAPP": "Este mensaje no es compatible. Puedes ver este mensaje en la aplicación de WhatsApp.",
|
||||||
"SUCCESS_DELETE_MESSAGE": "Mensaje eliminado correctamente",
|
"SUCCESS_DELETE_MESSAGE": "Mensaje eliminado correctamente",
|
||||||
"FAIL_DELETE_MESSSAGE": "¡No se pudo eliminar el mensaje! Inténtalo de nuevo",
|
"FAIL_DELETE_MESSSAGE": "¡No se pudo eliminar el mensaje! Inténtalo de nuevo",
|
||||||
"NO_RESPONSE": "No hay respuesta",
|
"NO_RESPONSE": "No hay respuesta",
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
"CARD": {
|
"CARD": {
|
||||||
"SHOW_LABELS": "Mostrar etiquetas",
|
"SHOW_LABELS": "Mostrar etiquetas",
|
||||||
"HIDE_LABELS": "Ocultar etiquetas",
|
"HIDE_LABELS": "Ocultar etiquetas",
|
||||||
"LABELS_COUNT": "{count} labels"
|
"LABELS_COUNT": "{count} etiquetas"
|
||||||
},
|
},
|
||||||
"VOICE_CALL": {
|
"VOICE_CALL": {
|
||||||
"INCOMING_CALL": "Incoming call",
|
"INCOMING_CALL": "Incoming call",
|
||||||
@@ -81,21 +81,21 @@
|
|||||||
"CALL_IN_PROGRESS": "Call in progress",
|
"CALL_IN_PROGRESS": "Call in progress",
|
||||||
"NO_ANSWER": "No answer",
|
"NO_ANSWER": "No answer",
|
||||||
"NO_ANSWER_OUTBOUND_LABEL": "No answer",
|
"NO_ANSWER_OUTBOUND_LABEL": "No answer",
|
||||||
"NO_ANSWER_OUTBOUND_SUBTEXT": "Contact didn't pick up",
|
"NO_ANSWER_OUTBOUND_SUBTEXT": "El contacto no ha atendido",
|
||||||
"MISSED_CALL": "Missed call",
|
"MISSED_CALL": "Missed call",
|
||||||
"MISSED_CALL_INBOUND_SUBTEXT": "No agent picked up",
|
"MISSED_CALL_INBOUND_SUBTEXT": "Ningún agente escogido",
|
||||||
"MISSED_CALL_DECLINED_BY": "Declined by {agentName}",
|
"MISSED_CALL_DECLINED_BY": "Denegado por {agentName}",
|
||||||
"CALL_ENDED": "Call ended",
|
"CALL_ENDED": "Call ended",
|
||||||
"HANDLED_BY": "Handled by {agentName}",
|
"HANDLED_BY": "Manejado por {agentName}",
|
||||||
"NOT_ANSWERED_YET": "Not answered yet",
|
"NOT_ANSWERED_YET": "Not answered yet",
|
||||||
"CALLING": "Calling…",
|
"CALLING": "Llamando…",
|
||||||
"THEY_ANSWERED": "They answered",
|
"THEY_ANSWERED": "They answered",
|
||||||
"YOU_ANSWERED": "You answered",
|
"YOU_ANSWERED": "You answered",
|
||||||
"AGENT_ANSWERED": "{agentName} answered",
|
"AGENT_ANSWERED": "{agentName} respondió",
|
||||||
"JOIN_CALL": "Unirse a la llamada",
|
"JOIN_CALL": "Unirse a la llamada",
|
||||||
"CALL_BACK": "Call back",
|
"CALL_BACK": "Llamar de vuelta",
|
||||||
"TRANSCRIPT_SHOW_MORE": "Show more",
|
"TRANSCRIPT_SHOW_MORE": "Mostrar más",
|
||||||
"TRANSCRIPT_SHOW_LESS": "Show less"
|
"TRANSCRIPT_SHOW_LESS": "Mostrar menos"
|
||||||
},
|
},
|
||||||
"HEADER": {
|
"HEADER": {
|
||||||
"RESOLVE_ACTION": "Resolver",
|
"RESOLVE_ACTION": "Resolver",
|
||||||
@@ -105,17 +105,17 @@
|
|||||||
"OPEN": "Más",
|
"OPEN": "Más",
|
||||||
"CLOSE": "Cerrar",
|
"CLOSE": "Cerrar",
|
||||||
"DETAILS": "detalles",
|
"DETAILS": "detalles",
|
||||||
"COPY_ID_SUCCESS": "Conversation ID copied to clipboard",
|
"COPY_ID_SUCCESS": "ID de conversación copiado al portapapeles",
|
||||||
"SNOOZED_UNTIL": "Posponer hasta",
|
"SNOOZED_UNTIL": "Posponer hasta",
|
||||||
"SNOOZED_UNTIL_TOMORROW": "Pospuesto hasta mañana",
|
"SNOOZED_UNTIL_TOMORROW": "Pospuesto hasta mañana",
|
||||||
"SNOOZED_UNTIL_NEXT_WEEK": "Pospuesto hasta la próxima semana",
|
"SNOOZED_UNTIL_NEXT_WEEK": "Pospuesto hasta la próxima semana",
|
||||||
"SNOOZED_UNTIL_NEXT_REPLY": "Posponer hasta la siguiente respuesta",
|
"SNOOZED_UNTIL_NEXT_REPLY": "Posponer hasta la siguiente respuesta",
|
||||||
"WHATSAPP_CALL": "Start WhatsApp call",
|
"WHATSAPP_CALL": "Iniciar llamada de WhatsApp",
|
||||||
"WHATSAPP_CALL_FAILED": "Could not start the WhatsApp call.",
|
"WHATSAPP_CALL_FAILED": "No se pudo iniciar la llamada de WhatsApp.",
|
||||||
"VOICE_CALL": "Start call",
|
"VOICE_CALL": "Iniciar llamada",
|
||||||
"VOICE_CALL_FAILED": "Could not start the call.",
|
"VOICE_CALL_FAILED": "No se pudo iniciar la llamada.",
|
||||||
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
|
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Solicitud de permiso de llamada al contacto enviada. Inténtalo de nuevo una vez que acepten.",
|
||||||
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
|
"WHATSAPP_CALL_PERMISSION_PENDING": "Solicitud de permiso de llamada ya enviada recientemente. Inténtelo de nuevo una vez que el contacto acepte.",
|
||||||
"SLA_STATUS": {
|
"SLA_STATUS": {
|
||||||
"FRT": "FRT {status}",
|
"FRT": "FRT {status}",
|
||||||
"NRT": "NRT {status}",
|
"NRT": "NRT {status}",
|
||||||
@@ -231,8 +231,9 @@
|
|||||||
"TIP_AUDIORECORDER_ICON": "Grabar audio",
|
"TIP_AUDIORECORDER_ICON": "Grabar audio",
|
||||||
"TIP_AUDIORECORDER_PERMISSION": "Permitir el acceso a audio",
|
"TIP_AUDIORECORDER_PERMISSION": "Permitir el acceso a audio",
|
||||||
"TIP_AUDIORECORDER_ERROR": "No se pudo abrir el audio",
|
"TIP_AUDIORECORDER_ERROR": "No se pudo abrir el audio",
|
||||||
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
|
"AUDIO_CONVERSION_FAILED": "Error al convertir audio. Vuelve a intentarlo.",
|
||||||
"DRAG_DROP": "Arrastra y suelta aquí para adjuntar",
|
"DRAG_DROP": "Arrastra y suelta aquí para adjuntar",
|
||||||
|
"IMAGE_UPLOAD_SUCCESS": "Imagen subida con éxito",
|
||||||
"START_AUDIO_RECORDING": "Iniciar grabación de audio",
|
"START_AUDIO_RECORDING": "Iniciar grabación de audio",
|
||||||
"STOP_AUDIO_RECORDING": "Detener grabación de audio",
|
"STOP_AUDIO_RECORDING": "Detener grabación de audio",
|
||||||
"COPILOT_THINKING": "Copilot está pensando",
|
"COPILOT_THINKING": "Copilot está pensando",
|
||||||
@@ -319,10 +320,10 @@
|
|||||||
"DISMISS_CALL": "Descartar",
|
"DISMISS_CALL": "Descartar",
|
||||||
"JOIN_CALL": "Unirse a la llamada",
|
"JOIN_CALL": "Unirse a la llamada",
|
||||||
"END_CALL": "Terminar llamada",
|
"END_CALL": "Terminar llamada",
|
||||||
"MUTE": "Mute mic",
|
"MUTE": "Silenciar micrófono",
|
||||||
"UNMUTE": "Unmute mic",
|
"UNMUTE": "Reactivar micrófono",
|
||||||
"VIEW_CHAT_HISTORY": "View chat history",
|
"VIEW_CHAT_HISTORY": "Ver historial de chat",
|
||||||
"GO_TO_CONVERSATION": "Go to conversation thread"
|
"GO_TO_CONVERSATION": "Ir al hilo de conversación"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"EMAIL_TRANSCRIPT": {
|
"EMAIL_TRANSCRIPT": {
|
||||||
@@ -389,18 +390,18 @@
|
|||||||
"MACROS": "Macros",
|
"MACROS": "Macros",
|
||||||
"LINEAR_ISSUES": "Problemas lineales vinculados",
|
"LINEAR_ISSUES": "Problemas lineales vinculados",
|
||||||
"SHOPIFY_ORDERS": "Shopify Orders",
|
"SHOPIFY_ORDERS": "Shopify Orders",
|
||||||
"SHARED_FILES": "Attachments"
|
"SHARED_FILES": "Adjuntos"
|
||||||
},
|
},
|
||||||
"SHARED_FILES": {
|
"SHARED_FILES": {
|
||||||
"EMPTY": "No attachments yet",
|
"EMPTY": "No hay archivos adjuntos aún",
|
||||||
"DOWNLOAD": "Download file",
|
"DOWNLOAD": "Descargar archivo",
|
||||||
"DOWNLOAD_ERROR": "Could not download the file. Please try again.",
|
"DOWNLOAD_ERROR": "No se pudo descargar el archivo. Por favor, inténtelo de nuevo.",
|
||||||
"MEDIA_HEADING": "Media",
|
"MEDIA_HEADING": "Media",
|
||||||
"FILES_HEADING": "Files",
|
"FILES_HEADING": "Archivos",
|
||||||
"VIEW_ALL": "Ver todo",
|
"VIEW_ALL": "Ver todo",
|
||||||
"SHOW_LESS": "Show less",
|
"SHOW_LESS": "Mostrar menos",
|
||||||
"MORE_COUNT": "+{count}",
|
"MORE_COUNT": "+{count}",
|
||||||
"UNTITLED_FILE": "Untitled file"
|
"UNTITLED_FILE": "Archivo sin título"
|
||||||
},
|
},
|
||||||
"SHOPIFY": {
|
"SHOPIFY": {
|
||||||
"ORDER_ID": "Order #{id}",
|
"ORDER_ID": "Order #{id}",
|
||||||
|
|||||||
@@ -700,9 +700,27 @@
|
|||||||
"MAKE_DEFAULT": "Make default",
|
"MAKE_DEFAULT": "Make default",
|
||||||
"MOVE_TO_DRAFT": "Move to draft",
|
"MOVE_TO_DRAFT": "Move to draft",
|
||||||
"PUBLISH_LOCALE": "Publish locale",
|
"PUBLISH_LOCALE": "Publish locale",
|
||||||
|
"CUSTOMIZE_CONTENT": "Localize content",
|
||||||
"DELETE": "Eliminar"
|
"DELETE": "Eliminar"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"CONTENT_DIALOG": {
|
||||||
|
"TITLE": "Localize content",
|
||||||
|
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
|
||||||
|
"NAME": {
|
||||||
|
"LABEL": "Nombre"
|
||||||
|
},
|
||||||
|
"PAGE_TITLE": {
|
||||||
|
"LABEL": "Page title"
|
||||||
|
},
|
||||||
|
"HEADER_TEXT": {
|
||||||
|
"LABEL": "Header text"
|
||||||
|
},
|
||||||
|
"API": {
|
||||||
|
"SUCCESS_MESSAGE": "Locale content updated successfully",
|
||||||
|
"ERROR_MESSAGE": "Unable to update locale content. Try again."
|
||||||
|
}
|
||||||
|
},
|
||||||
"ADD_LOCALE_DIALOG": {
|
"ADD_LOCALE_DIALOG": {
|
||||||
"TITLE": "Añadir un nuevo idioma",
|
"TITLE": "Añadir un nuevo idioma",
|
||||||
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
|
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
|
||||||
|
|||||||
@@ -308,7 +308,7 @@
|
|||||||
"AUTH_PROCESSING": "Authenticating with Meta",
|
"AUTH_PROCESSING": "Authenticating with Meta",
|
||||||
"WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...",
|
"WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...",
|
||||||
"PROCESSING": "Setting up your WhatsApp Business Account",
|
"PROCESSING": "Setting up your WhatsApp Business Account",
|
||||||
"ENABLING_CALLING": "Enabling WhatsApp Calling on your number…",
|
"ENABLING_CALLING": "Activando llamadas de WhatsApp en tu número…",
|
||||||
"LOADING_SDK": "Loading Facebook SDK...",
|
"LOADING_SDK": "Loading Facebook SDK...",
|
||||||
"CANCELLED": "WhatsApp Signup was cancelled",
|
"CANCELLED": "WhatsApp Signup was cancelled",
|
||||||
"SUCCESS_TITLE": "WhatsApp Business Account Connected!",
|
"SUCCESS_TITLE": "WhatsApp Business Account Connected!",
|
||||||
@@ -319,7 +319,7 @@
|
|||||||
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured",
|
"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_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."
|
"CALLING_ENABLE_FAILED": "Tu bandeja de entrada de WhatsApp está lista, pero las llamadas de voz no se pudieron activar — este número aún no está inscrito en la API de llamadas empresariales de WhatsApp. Llega a Meta o a tu proveedor de soluciones empresariales de WhatsApp a bordo, luego enciende la llamada desde la configuración de las llamadas de la bandeja de entrada."
|
||||||
},
|
},
|
||||||
"API": {
|
"API": {
|
||||||
"ERROR_MESSAGE": "No pudimos guardar el canal de WhatsApp"
|
"ERROR_MESSAGE": "No pudimos guardar el canal de WhatsApp"
|
||||||
@@ -468,8 +468,8 @@
|
|||||||
"DESCRIPTION": "Support your customers on WhatsApp"
|
"DESCRIPTION": "Support your customers on WhatsApp"
|
||||||
},
|
},
|
||||||
"WHATSAPP_CALL": {
|
"WHATSAPP_CALL": {
|
||||||
"TITLE": "WhatsApp Call",
|
"TITLE": "Llamada de WhatsApp",
|
||||||
"DESCRIPTION": "Take voice calls on your WhatsApp number"
|
"DESCRIPTION": "Responde llamadas de voz en tu número de WhatsApp"
|
||||||
},
|
},
|
||||||
"EMAIL": {
|
"EMAIL": {
|
||||||
"TITLE": "E-mail",
|
"TITLE": "E-mail",
|
||||||
@@ -644,29 +644,29 @@
|
|||||||
"ACCOUNT_HEALTH": "Account Health",
|
"ACCOUNT_HEALTH": "Account Health",
|
||||||
"CSAT": "Encuestas de Satisfacción",
|
"CSAT": "Encuestas de Satisfacción",
|
||||||
"VOICE": "Voice",
|
"VOICE": "Voice",
|
||||||
"CALLS": "Calls"
|
"CALLS": "Llamadas"
|
||||||
},
|
},
|
||||||
"VOICE_CONFIGURATION": {
|
"VOICE_CONFIGURATION": {
|
||||||
"ENABLE_VOICE": {
|
"ENABLE_VOICE": {
|
||||||
"LABEL": "Enable Voice Calling",
|
"LABEL": "Habilitar llamadas de voz",
|
||||||
"DESCRIPTION": "Enable voice calling on this inbox. Agents will be able to make and receive phone calls."
|
"DESCRIPTION": "Activar llamadas de voz en esta bandeja de entrada. Los agentes podrán hacer y recibir llamadas telefónicas."
|
||||||
},
|
},
|
||||||
"CREDENTIALS": {
|
"CREDENTIALS": {
|
||||||
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
|
"DESCRIPTION": "Llamada de voz requiere credenciales de Clave de Twilio API. Estas se utilizan para generar tokens para las conexiones de voz del agente."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"WHATSAPP_CALLING": {
|
"WHATSAPP_CALLING": {
|
||||||
"ENABLE": {
|
"ENABLE": {
|
||||||
"LABEL": "Enable WhatsApp Calling",
|
"LABEL": "Habilitar llamadas de WhatsApp",
|
||||||
"DESCRIPTION": "Allow agents to receive and place WhatsApp Cloud calls on this inbox. Customers can call this business number directly from WhatsApp."
|
"DESCRIPTION": "Permitir a los agentes recibir y hacer llamadas a WhatsApp Cloud en esta bandeja de entrada. Los clientes pueden llamar a este número de negocio directamente desde 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.",
|
"ENABLE_FAILED": "No se pudo activar la llamada de voz para este número — aún no está inscrito en la API de llamadas empresariales de WhatsApp. Llega a Meta o a tu proveedor de soluciones de negocios de WhatsApp a bordo de él, luego vuelve a intentarlo.",
|
||||||
"PHONE_NUMBER": {
|
"PHONE_NUMBER": {
|
||||||
"LABEL": "Business phone number",
|
"LABEL": "Número de teléfono empresarial",
|
||||||
"HELP_TEXT": "WhatsApp number that customers will call."
|
"HELP_TEXT": "Número de WhatsApp al que llamarán los clientes."
|
||||||
},
|
},
|
||||||
"HOW_IT_WORKS": {
|
"HOW_IT_WORKS": {
|
||||||
"LABEL": "How it works",
|
"LABEL": "Cómo funciona",
|
||||||
"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."
|
"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": {
|
"PERMISSION_REQUEST_BODY": {
|
||||||
|
|||||||
@@ -763,38 +763,38 @@
|
|||||||
},
|
},
|
||||||
"FILTERS": {
|
"FILTERS": {
|
||||||
"SOURCE": {
|
"SOURCE": {
|
||||||
"ALL": "All sources",
|
"ALL": "Todas las fuentes",
|
||||||
"WEB": "Web pages",
|
"WEB": "Páginas web",
|
||||||
"PDF": "PDFs"
|
"PDF": "PDFs"
|
||||||
},
|
},
|
||||||
"STATUS": {
|
"STATUS": {
|
||||||
"ANY": "Any status",
|
"ANY": "Cualquier estado",
|
||||||
"UPDATED": "Updated",
|
"UPDATED": "Actualizado",
|
||||||
"NEEDS_UPDATE": "Needs update",
|
"NEEDS_UPDATE": "Necesita actualización",
|
||||||
"UPDATING": "Updating",
|
"UPDATING": "Actualizando",
|
||||||
"FAILED": "Failed"
|
"FAILED": "Failed"
|
||||||
},
|
},
|
||||||
"SORT": {
|
"SORT": {
|
||||||
"RECENTLY_UPDATED": "Recently updated",
|
"RECENTLY_UPDATED": "Actualizado recientemente",
|
||||||
"RECENTLY_CREATED": "Recently created"
|
"RECENTLY_CREATED": "Creado recientemente"
|
||||||
},
|
},
|
||||||
"SEARCH_PLACEHOLDER": "Buscar..."
|
"SEARCH_PLACEHOLDER": "Buscar..."
|
||||||
},
|
},
|
||||||
"SYNC_STATUS": {
|
"SYNC_STATUS": {
|
||||||
"SYNCED": "last updated {time}",
|
"SYNCED": "última actualización {time}",
|
||||||
"SYNCING": "actualizando...",
|
"SYNCING": "actualizando...",
|
||||||
"STALE_SYNC": "update stalled",
|
"STALE_SYNC": "update stalled",
|
||||||
"FAILED": "Failed to sync",
|
"FAILED": "Fallo al sincronizar",
|
||||||
"NEVER_SYNCED": "not updated yet"
|
"NEVER_SYNCED": "no actualizado aún"
|
||||||
},
|
},
|
||||||
"SYNC_ERRORS": {
|
"SYNC_ERRORS": {
|
||||||
"NOT_FOUND": "Página no encontrada",
|
"NOT_FOUND": "Página no encontrada",
|
||||||
"ACCESS_DENIED": "Access denied",
|
"ACCESS_DENIED": "Acceso denegado",
|
||||||
"TIMEOUT": "Page took too long to respond",
|
"TIMEOUT": "La página tardó demasiado en responder",
|
||||||
"CONTENT_EMPTY": "Page returned empty content",
|
"CONTENT_EMPTY": "La página devolvió contenido vacío",
|
||||||
"FETCH_FAILED": "Could not fetch page",
|
"FETCH_FAILED": "No se pudo obtener la página",
|
||||||
"SYNC_ERROR": "Unexpected error",
|
"SYNC_ERROR": "Error inesperado",
|
||||||
"DEFAULT": "Sync error"
|
"DEFAULT": "Error de sincronización"
|
||||||
},
|
},
|
||||||
"RELATED_RESPONSES": {
|
"RELATED_RESPONSES": {
|
||||||
"TITLE": "Related FAQs",
|
"TITLE": "Related FAQs",
|
||||||
@@ -839,14 +839,14 @@
|
|||||||
},
|
},
|
||||||
"OPTIONS": {
|
"OPTIONS": {
|
||||||
"VIEW_RELATED_RESPONSES": "View Related Responses",
|
"VIEW_RELATED_RESPONSES": "View Related Responses",
|
||||||
"SYNC_NOW": "Refresh now",
|
"SYNC_NOW": "Refrescar ahora",
|
||||||
"RETRY_SYNC": "Retry refresh",
|
"RETRY_SYNC": "Reintentar refresco",
|
||||||
"DELETE_DOCUMENT": "Delete Document"
|
"DELETE_DOCUMENT": "Delete Document"
|
||||||
},
|
},
|
||||||
"EMPTY_STATE": {
|
"EMPTY_STATE": {
|
||||||
"TITLE": "No documents available",
|
"TITLE": "No documents available",
|
||||||
"SUBTITLE": "Documents are used by your assistant to generate FAQs. You can import documents to provide context for your assistant.",
|
"SUBTITLE": "Documents are used by your assistant to generate FAQs. You can import documents to provide context for your assistant.",
|
||||||
"FILTERED_TITLE": "No matching documents",
|
"FILTERED_TITLE": "No hay documentos coincidentes",
|
||||||
"FILTERED_SUBTITLE": "Try changing the source, status, or search term.",
|
"FILTERED_SUBTITLE": "Try changing the source, status, or search term.",
|
||||||
"FEATURE_SPOTLIGHT": {
|
"FEATURE_SPOTLIGHT": {
|
||||||
"TITLE": "Captain Document",
|
"TITLE": "Captain Document",
|
||||||
|
|||||||
@@ -24,11 +24,11 @@
|
|||||||
"SELECT_REFERRAL_SOURCE": "Select source"
|
"SELECT_REFERRAL_SOURCE": "Select source"
|
||||||
},
|
},
|
||||||
"EMAIL_VERIFIED": "Email verified",
|
"EMAIL_VERIFIED": "Email verified",
|
||||||
"SETTING_UP": "Setting up your account...",
|
"SETTING_UP": "Configurando tu cuenta...",
|
||||||
"CONTINUE": "Continue",
|
"CONTINUE": "Continue",
|
||||||
"SAVING": "Guardando...",
|
"SAVING": "Guardando...",
|
||||||
"VALIDATION_ERROR": "Please fill in all required fields",
|
"VALIDATION_ERROR": "Por favor, rellene todos los campos requeridos",
|
||||||
"SUCCESS": "Details saved successfully",
|
"SUCCESS": "Detalles guardados correctamente",
|
||||||
"ERROR": "Could not save details. Please try again."
|
"ERROR": "No se pudo guardar los detalles. Por favor, inténtelo de nuevo."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
"OR": "OR"
|
"OR": "OR"
|
||||||
},
|
},
|
||||||
"INPUT_PLACEHOLDER": "Enter value",
|
"INPUT_PLACEHOLDER": "Enter value",
|
||||||
|
"CONTACT_SEARCH_PLACEHOLDER": "Otsi kontakte",
|
||||||
|
"CONTACT_FALLBACK": "Contact #{id}",
|
||||||
"OPERATOR_LABELS": {
|
"OPERATOR_LABELS": {
|
||||||
"equal_to": "Equal to",
|
"equal_to": "Equal to",
|
||||||
"not_equal_to": "Not equal to",
|
"not_equal_to": "Not equal to",
|
||||||
@@ -49,6 +51,7 @@
|
|||||||
"ASSIGNEE_NAME": "Assignee name",
|
"ASSIGNEE_NAME": "Assignee name",
|
||||||
"INBOX_NAME": "Inbox name",
|
"INBOX_NAME": "Inbox name",
|
||||||
"TEAM_NAME": "Team name",
|
"TEAM_NAME": "Team name",
|
||||||
|
"CONTACT": "Kontakt",
|
||||||
"CONVERSATION_IDENTIFIER": "Conversation identifier",
|
"CONVERSATION_IDENTIFIER": "Conversation identifier",
|
||||||
"CAMPAIGN_NAME": "Campaign name",
|
"CAMPAIGN_NAME": "Campaign name",
|
||||||
"LABELS": "Labels",
|
"LABELS": "Labels",
|
||||||
|
|||||||
@@ -233,6 +233,7 @@
|
|||||||
"TIP_AUDIORECORDER_ERROR": "Heli avamine ebaõnnestus",
|
"TIP_AUDIORECORDER_ERROR": "Heli avamine ebaõnnestus",
|
||||||
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
|
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
|
||||||
"DRAG_DROP": "Lohista siia manusena lisamiseks",
|
"DRAG_DROP": "Lohista siia manusena lisamiseks",
|
||||||
|
"IMAGE_UPLOAD_SUCCESS": "Pilt edukalt üles laaditud",
|
||||||
"START_AUDIO_RECORDING": "Alusta heli salvestamist",
|
"START_AUDIO_RECORDING": "Alusta heli salvestamist",
|
||||||
"STOP_AUDIO_RECORDING": "Peata heli salvestamine",
|
"STOP_AUDIO_RECORDING": "Peata heli salvestamine",
|
||||||
"COPILOT_THINKING": "Copilot mõtleb",
|
"COPILOT_THINKING": "Copilot mõtleb",
|
||||||
|
|||||||
@@ -700,9 +700,27 @@
|
|||||||
"MAKE_DEFAULT": "Määra vaikimisi",
|
"MAKE_DEFAULT": "Määra vaikimisi",
|
||||||
"MOVE_TO_DRAFT": "Move to draft",
|
"MOVE_TO_DRAFT": "Move to draft",
|
||||||
"PUBLISH_LOCALE": "Publish locale",
|
"PUBLISH_LOCALE": "Publish locale",
|
||||||
|
"CUSTOMIZE_CONTENT": "Localize content",
|
||||||
"DELETE": "Kustuta"
|
"DELETE": "Kustuta"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"CONTENT_DIALOG": {
|
||||||
|
"TITLE": "Localize content",
|
||||||
|
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
|
||||||
|
"NAME": {
|
||||||
|
"LABEL": "Name"
|
||||||
|
},
|
||||||
|
"PAGE_TITLE": {
|
||||||
|
"LABEL": "Lehe pealkiri"
|
||||||
|
},
|
||||||
|
"HEADER_TEXT": {
|
||||||
|
"LABEL": "Päise tekst"
|
||||||
|
},
|
||||||
|
"API": {
|
||||||
|
"SUCCESS_MESSAGE": "Locale content updated successfully",
|
||||||
|
"ERROR_MESSAGE": "Unable to update locale content. Try again."
|
||||||
|
}
|
||||||
|
},
|
||||||
"ADD_LOCALE_DIALOG": {
|
"ADD_LOCALE_DIALOG": {
|
||||||
"TITLE": "Lisa uus keel",
|
"TITLE": "Lisa uus keel",
|
||||||
"DESCRIPTION": "Vali keel, milles see artikkel kirjutatakse. See lisatakse sinu tõlkelisti ja hiljem saad lisada veel.",
|
"DESCRIPTION": "Vali keel, milles see artikkel kirjutatakse. See lisatakse sinu tõlkelisti ja hiljem saad lisada veel.",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user