Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8340bd615c | ||
|
|
75d3569f22 | ||
|
|
545453537f | ||
|
|
d75702c6b2 | ||
|
|
b76ec878f1 | ||
|
|
a954e1eaca | ||
|
|
bc5f1722e1 | ||
|
|
8f39e62570 | ||
|
|
7520ca7a99 | ||
|
|
35f4e63605 | ||
|
|
5773089865 | ||
|
|
d01c7d3fa7 | ||
|
|
959d2c0d8c | ||
|
|
622f29a0b7 |
+22
-14
@@ -1,7 +1,6 @@
|
||||
version: 2.1
|
||||
orbs:
|
||||
node: circleci/node@6.1.0
|
||||
qlty-orb: qltysh/qlty-orb@0.0
|
||||
|
||||
defaults: &defaults
|
||||
working_directory: ~/build
|
||||
@@ -90,6 +89,14 @@ jobs:
|
||||
command: |
|
||||
source ~/.rvm/scripts/rvm
|
||||
bundle install
|
||||
# pnpm install
|
||||
|
||||
- run:
|
||||
name: Download cc-test-reporter
|
||||
command: |
|
||||
mkdir -p ~/tmp
|
||||
curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter
|
||||
chmod +x ~/tmp/cc-test-reporter
|
||||
|
||||
# Swagger verification
|
||||
- run:
|
||||
@@ -101,11 +108,10 @@ jobs:
|
||||
echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p ~/tmp
|
||||
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
|
||||
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
|
||||
|
||||
# Configure environment and database
|
||||
# we remove the FRONTED_URL from the .env before running the tests
|
||||
- run:
|
||||
name: Database Setup and Configure Environment Variables
|
||||
command: |
|
||||
@@ -143,11 +149,17 @@ jobs:
|
||||
command: pnpm run eslint
|
||||
|
||||
- run:
|
||||
name: Run frontend tests (with coverage)
|
||||
name: Run frontend tests
|
||||
command: |
|
||||
mkdir -p ~/build/coverage/frontend
|
||||
~/tmp/cc-test-reporter before-build
|
||||
pnpm run test:coverage
|
||||
|
||||
- run:
|
||||
name: Code Climate Test Coverage (Frontend)
|
||||
command: |
|
||||
~/tmp/cc-test-reporter format-coverage -t lcov -o "~/build/coverage/frontend/codeclimate.frontend_$CIRCLE_NODE_INDEX.json"
|
||||
|
||||
# Run backend tests
|
||||
- run:
|
||||
name: Run backend tests
|
||||
@@ -155,18 +167,18 @@ jobs:
|
||||
mkdir -p ~/tmp/test-results/rspec
|
||||
mkdir -p ~/tmp/test-artifacts
|
||||
mkdir -p ~/build/coverage/backend
|
||||
~/tmp/cc-test-reporter before-build
|
||||
TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
|
||||
bundle exec rspec -I ./spec --require coverage_helper --require spec_helper --format progress \
|
||||
bundle exec rspec --format progress \
|
||||
--format RspecJunitFormatter \
|
||||
--out ~/tmp/test-results/rspec.xml \
|
||||
-- ${TESTFILES}
|
||||
no_output_timeout: 30m
|
||||
|
||||
# Qlty coverage publish
|
||||
- qlty-orb/coverage_publish:
|
||||
files: |
|
||||
coverage/coverage.json
|
||||
coverage/lcov.info
|
||||
- run:
|
||||
name: Code Climate Test Coverage (Backend)
|
||||
command: |
|
||||
~/tmp/cc-test-reporter format-coverage -t simplecov -o "~/build/coverage/backend/codeclimate.$CIRCLE_NODE_INDEX.json"
|
||||
|
||||
- run:
|
||||
name: List coverage directory contents
|
||||
@@ -177,7 +189,3 @@ jobs:
|
||||
root: ~/build
|
||||
paths:
|
||||
- coverage
|
||||
|
||||
- store_artifacts:
|
||||
path: coverage
|
||||
destination: coverage
|
||||
|
||||
@@ -62,10 +62,6 @@ gem 'redis-namespace'
|
||||
# super fast record imports in bulk
|
||||
gem 'activerecord-import'
|
||||
|
||||
gem 'searchkick'
|
||||
gem 'opensearch-ruby'
|
||||
gem 'faraday_middleware-aws-sigv4'
|
||||
|
||||
##--- gems for server & infra configuration ---##
|
||||
gem 'dotenv-rails', '>= 3.0.0'
|
||||
gem 'foreman'
|
||||
@@ -93,7 +89,7 @@ gem 'wisper', '2.0.0'
|
||||
##--- gems for channels ---##
|
||||
gem 'facebook-messenger'
|
||||
gem 'line-bot-api'
|
||||
gem 'twilio-ruby'
|
||||
gem 'twilio-ruby', '~> 5.66'
|
||||
# twitty will handle subscription of twitter account events
|
||||
# gem 'twitty', git: 'https://github.com/chatwoot/twitty'
|
||||
gem 'twitty', '~> 0.1.5'
|
||||
@@ -216,8 +212,6 @@ group :development do
|
||||
gem 'stackprof'
|
||||
# Should install the associated chrome extension to view query logs
|
||||
gem 'meta_request', '>= 0.8.3'
|
||||
|
||||
gem 'tidewave'
|
||||
end
|
||||
|
||||
group :test do
|
||||
@@ -227,7 +221,6 @@ group :test do
|
||||
gem 'webmock'
|
||||
# test profiling
|
||||
gem 'test-prof'
|
||||
gem 'simplecov_json_formatter', require: false
|
||||
end
|
||||
|
||||
group :development, :test do
|
||||
@@ -252,7 +245,7 @@ group :development, :test do
|
||||
gem 'rubocop-factory_bot', require: false
|
||||
gem 'seed_dump'
|
||||
gem 'shoulda-matchers'
|
||||
gem 'simplecov', '>= 0.21', require: false
|
||||
gem 'simplecov', '0.17.1', require: false
|
||||
gem 'spring'
|
||||
gem 'spring-watcher-listen'
|
||||
end
|
||||
|
||||
+24
-81
@@ -219,7 +219,7 @@ GEM
|
||||
diff-lcs (1.5.1)
|
||||
digest-crc (0.6.5)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
docile (1.4.1)
|
||||
docile (1.4.0)
|
||||
domain_name (0.5.20190701)
|
||||
unf (>= 0.0.5, < 1.0.0)
|
||||
dotenv (3.1.2)
|
||||
@@ -230,35 +230,6 @@ GEM
|
||||
addressable (~> 2.8)
|
||||
drb (2.2.3)
|
||||
dry-cli (1.1.0)
|
||||
dry-configurable (1.3.0)
|
||||
dry-core (~> 1.1)
|
||||
zeitwerk (~> 2.6)
|
||||
dry-core (1.1.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
logger
|
||||
zeitwerk (~> 2.6)
|
||||
dry-inflector (1.2.0)
|
||||
dry-initializer (3.2.0)
|
||||
dry-logic (1.6.0)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0)
|
||||
dry-core (~> 1.1)
|
||||
zeitwerk (~> 2.6)
|
||||
dry-schema (1.14.1)
|
||||
concurrent-ruby (~> 1.0)
|
||||
dry-configurable (~> 1.0, >= 1.0.1)
|
||||
dry-core (~> 1.1)
|
||||
dry-initializer (~> 3.2)
|
||||
dry-logic (~> 1.5)
|
||||
dry-types (~> 1.8)
|
||||
zeitwerk (~> 2.6)
|
||||
dry-types (1.8.3)
|
||||
bigdecimal (~> 3.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
dry-core (~> 1.0)
|
||||
dry-inflector (~> 1.0)
|
||||
dry-logic (~> 1.4)
|
||||
zeitwerk (~> 2.6)
|
||||
ecma-re-validator (0.4.0)
|
||||
regexp_parser (~> 2.2)
|
||||
elastic-apm (4.6.2)
|
||||
@@ -281,10 +252,8 @@ GEM
|
||||
railties (>= 5.0.0)
|
||||
faker (3.2.0)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.13.1)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
faraday (2.9.0)
|
||||
faraday-net_http (>= 2.0, < 3.2)
|
||||
faraday-follow_redirects (0.3.0)
|
||||
faraday (>= 1, < 3)
|
||||
faraday-mashify (0.1.1)
|
||||
@@ -292,23 +261,13 @@ GEM
|
||||
hashie
|
||||
faraday-multipart (1.0.4)
|
||||
multipart-post (~> 2)
|
||||
faraday-net_http (3.4.0)
|
||||
net-http (>= 0.5.0)
|
||||
faraday-net_http (3.1.0)
|
||||
net-http
|
||||
faraday-net_http_persistent (2.1.0)
|
||||
faraday (~> 2.5)
|
||||
net-http-persistent (~> 4.0)
|
||||
faraday-retry (2.2.1)
|
||||
faraday (~> 2.0)
|
||||
faraday_middleware-aws-sigv4 (1.0.1)
|
||||
aws-sigv4 (~> 1.0)
|
||||
faraday (>= 2.0, < 3)
|
||||
fast-mcp (1.5.0)
|
||||
addressable (~> 2.8)
|
||||
base64
|
||||
dry-schema (~> 1.14)
|
||||
json (~> 2.0)
|
||||
mime-types (~> 3.4)
|
||||
rack (~> 3.1)
|
||||
fcm (1.0.8)
|
||||
faraday (>= 1.0.0, < 3.0)
|
||||
googleauth (~> 1)
|
||||
@@ -447,7 +406,7 @@ GEM
|
||||
rails-dom-testing (>= 1, < 3)
|
||||
railties (>= 4.2.0)
|
||||
thor (>= 0.14, < 2.0)
|
||||
json (2.13.2)
|
||||
json (2.12.0)
|
||||
json_refs (0.1.8)
|
||||
hana
|
||||
json_schemer (0.2.24)
|
||||
@@ -462,7 +421,7 @@ GEM
|
||||
judoscale-sidekiq (1.8.2)
|
||||
judoscale-ruby (= 1.8.2)
|
||||
sidekiq (>= 5.0)
|
||||
jwt (2.10.1)
|
||||
jwt (2.8.1)
|
||||
base64
|
||||
kaminari (1.2.2)
|
||||
activesupport (>= 4.1.0)
|
||||
@@ -544,7 +503,7 @@ GEM
|
||||
mutex_m (0.3.0)
|
||||
neighbor (0.2.3)
|
||||
activerecord (>= 5.2)
|
||||
net-http (0.6.0)
|
||||
net-http (0.4.1)
|
||||
uri
|
||||
net-http-persistent (4.0.2)
|
||||
connection_pool (~> 2.2)
|
||||
@@ -604,9 +563,6 @@ GEM
|
||||
omniauth-rails_csrf_protection (1.0.2)
|
||||
actionpack (>= 4.2)
|
||||
omniauth (~> 2.0)
|
||||
opensearch-ruby (3.4.0)
|
||||
faraday (>= 1.0, < 3)
|
||||
multi_json (>= 1.0)
|
||||
openssl (3.2.0)
|
||||
orm_adapter (0.5.0)
|
||||
os (1.1.4)
|
||||
@@ -635,7 +591,7 @@ GEM
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.0)
|
||||
rack (2.2.15)
|
||||
rack-attack (6.7.0)
|
||||
rack (>= 1.0, < 4)
|
||||
rack-contrib (2.5.0)
|
||||
@@ -644,20 +600,19 @@ GEM
|
||||
rack (>= 2.0.0)
|
||||
rack-mini-profiler (3.2.0)
|
||||
rack (>= 1.2.0)
|
||||
rack-protection (4.1.1)
|
||||
rack-protection (3.2.0)
|
||||
base64 (>= 0.1.0)
|
||||
logger (>= 1.6.0)
|
||||
rack (>= 3.0.0, < 4)
|
||||
rack (~> 2.2, >= 2.2.4)
|
||||
rack-proxy (0.7.7)
|
||||
rack
|
||||
rack-session (2.1.1)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
rack-session (1.0.2)
|
||||
rack (< 3)
|
||||
rack-test (2.1.0)
|
||||
rack (>= 1.3)
|
||||
rack-timeout (0.6.3)
|
||||
rackup (2.2.1)
|
||||
rack (>= 3)
|
||||
rackup (1.0.1)
|
||||
rack (< 3)
|
||||
webrick
|
||||
rails (7.1.5.2)
|
||||
actioncable (= 7.1.5.2)
|
||||
actionmailbox (= 7.1.5.2)
|
||||
@@ -808,9 +763,6 @@ GEM
|
||||
parser
|
||||
scss_lint (0.60.0)
|
||||
sass (~> 3.5, >= 3.5.5)
|
||||
searchkick (5.5.2)
|
||||
activemodel (>= 7.1)
|
||||
hashie
|
||||
securerandom (0.4.1)
|
||||
seed_dump (3.3.1)
|
||||
activerecord (>= 4)
|
||||
@@ -857,12 +809,11 @@ GEM
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
multi_json (~> 1.10)
|
||||
simplecov (0.22.0)
|
||||
simplecov (0.17.1)
|
||||
docile (~> 1.1)
|
||||
simplecov-html (~> 0.11)
|
||||
simplecov_json_formatter (~> 0.1)
|
||||
simplecov-html (0.13.2)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
json (>= 1.8, < 3)
|
||||
simplecov-html (~> 0.10.0)
|
||||
simplecov-html (0.10.2)
|
||||
slack-ruby-client (2.5.2)
|
||||
faraday (>= 2.0)
|
||||
faraday-mashify
|
||||
@@ -892,17 +843,13 @@ GEM
|
||||
telephone_number (1.4.20)
|
||||
test-prof (1.2.1)
|
||||
thor (1.4.0)
|
||||
tidewave (0.2.0)
|
||||
fast-mcp (~> 1.5.0)
|
||||
rack (>= 2.0)
|
||||
rails (>= 7.1.0)
|
||||
tilt (2.3.0)
|
||||
time_diff (0.3.0)
|
||||
activesupport
|
||||
i18n
|
||||
timeout (0.4.3)
|
||||
trailblazer-option (0.1.2)
|
||||
twilio-ruby (7.6.0)
|
||||
twilio-ruby (5.77.0)
|
||||
faraday (>= 0.9, < 3.0)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
nokogiri (>= 1.6, < 2.0)
|
||||
@@ -949,6 +896,7 @@ GEM
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
webrick (1.9.1)
|
||||
websocket-driver (0.7.7)
|
||||
base64
|
||||
websocket-extensions (>= 0.1.0)
|
||||
@@ -1006,7 +954,6 @@ DEPENDENCIES
|
||||
facebook-messenger
|
||||
factory_bot_rails (>= 6.4.3)
|
||||
faker
|
||||
faraday_middleware-aws-sigv4
|
||||
fcm
|
||||
flag_shih_tzu
|
||||
foreman
|
||||
@@ -1047,7 +994,6 @@ DEPENDENCIES
|
||||
omniauth-google-oauth2 (>= 1.1.3)
|
||||
omniauth-oauth2
|
||||
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
|
||||
opensearch-ruby
|
||||
pg
|
||||
pg_search
|
||||
pgvector
|
||||
@@ -1076,7 +1022,6 @@ DEPENDENCIES
|
||||
ruby_llm-schema
|
||||
scout_apm
|
||||
scss_lint
|
||||
searchkick
|
||||
seed_dump
|
||||
sentry-rails (>= 5.19.0)
|
||||
sentry-ruby
|
||||
@@ -1086,8 +1031,7 @@ DEPENDENCIES
|
||||
sidekiq (>= 7.3.1)
|
||||
sidekiq-cron (>= 1.12.0)
|
||||
sidekiq_alive
|
||||
simplecov (>= 0.21)
|
||||
simplecov_json_formatter
|
||||
simplecov (= 0.17.1)
|
||||
slack-ruby-client (~> 2.5.2)
|
||||
spring
|
||||
spring-watcher-listen
|
||||
@@ -1096,9 +1040,8 @@ DEPENDENCIES
|
||||
stripe
|
||||
telephone_number
|
||||
test-prof
|
||||
tidewave
|
||||
time_diff
|
||||
twilio-ruby
|
||||
twilio-ruby (~> 5.66)
|
||||
twitty (~> 0.1.5)
|
||||
tzinfo-data
|
||||
uglifier
|
||||
|
||||
@@ -4,7 +4,3 @@
|
||||
require_relative 'config/application'
|
||||
|
||||
Rails.application.load_tasks
|
||||
|
||||
# Load Enterprise Edition rake tasks if they exist
|
||||
enterprise_tasks_path = Rails.root.join('enterprise/lib/tasks.rb').to_s
|
||||
require enterprise_tasks_path if File.exist?(enterprise_tasks_path)
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.4.3
|
||||
3.4.2
|
||||
|
||||
@@ -70,9 +70,11 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def sync_templates
|
||||
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
|
||||
unless @inbox.channel.is_a?(Channel::Whatsapp)
|
||||
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' }
|
||||
end
|
||||
|
||||
trigger_template_sync
|
||||
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||
render status: :ok, json: { message: 'Template sync initiated successfully' }
|
||||
rescue StandardError => e
|
||||
render status: :internal_server_error, json: { error: e.message }
|
||||
@@ -183,18 +185,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
def whatsapp_channel?
|
||||
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
|
||||
end
|
||||
|
||||
def trigger_template_sync
|
||||
if @inbox.whatsapp?
|
||||
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||
elsif @inbox.twilio? && @inbox.channel.whatsapp?
|
||||
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')
|
||||
|
||||
@@ -74,17 +74,6 @@ module PortalHelper
|
||||
end
|
||||
end
|
||||
|
||||
def generate_portal_brand_url(brand_url, referer)
|
||||
url = URI.parse(brand_url.to_s)
|
||||
query_params = Rack::Utils.parse_query(url.query)
|
||||
query_params['utm_medium'] = 'helpcenter'
|
||||
query_params['utm_campaign'] = 'branding'
|
||||
query_params['utm_source'] = URI.parse(referer).host if referer.present? && referer.match?(URI::DEFAULT_PARSER.make_regexp)
|
||||
|
||||
url.query = query_params.to_query
|
||||
url.to_s
|
||||
end
|
||||
|
||||
def render_category_content(content)
|
||||
ChatwootMarkdownRenderer.new(content).render_markdown_to_plain_text
|
||||
end
|
||||
|
||||
-2
@@ -62,7 +62,6 @@ const segmentsQuery = ref({});
|
||||
|
||||
const appliedFilters = useMapGetter('contacts/getAppliedContactFiltersV4');
|
||||
const contactAttributes = useMapGetter('attributes/getContactAttributes');
|
||||
const labels = useMapGetter('labels/getLabels');
|
||||
const hasActiveSegments = computed(
|
||||
() => props.activeSegment && props.segmentsId !== 0
|
||||
);
|
||||
@@ -216,7 +215,6 @@ const setParamsForEditSegmentModal = () => {
|
||||
countries,
|
||||
filterTypes: contactFilterItems,
|
||||
allCustomAttributes: useSnakeCase(contactAttributes.value),
|
||||
labels: labels.value || [],
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+6
-28
@@ -11,14 +11,12 @@ import { extractTextFromMarkdown } from 'dashboard/helper/editorHelper';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import WhatsAppOptions from './WhatsAppOptions.vue';
|
||||
import ContentTemplateSelector from './ContentTemplateSelector.vue';
|
||||
|
||||
const props = defineProps({
|
||||
attachedFiles: { type: Array, default: () => [] },
|
||||
isWhatsappInbox: { type: Boolean, default: false },
|
||||
isEmailOrWebWidgetInbox: { type: Boolean, default: false },
|
||||
isTwilioSmsInbox: { type: Boolean, default: false },
|
||||
isTwilioWhatsAppInbox: { type: Boolean, default: false },
|
||||
messageTemplates: { type: Array, default: () => [] },
|
||||
channelType: { type: String, default: '' },
|
||||
isLoading: { type: Boolean, default: false },
|
||||
@@ -34,7 +32,6 @@ const emit = defineEmits([
|
||||
'discard',
|
||||
'sendMessage',
|
||||
'sendWhatsappMessage',
|
||||
'sendTwilioMessage',
|
||||
'insertEmoji',
|
||||
'addSignature',
|
||||
'removeSignature',
|
||||
@@ -66,20 +63,6 @@ const sendWithSignature = computed(() => {
|
||||
return fetchSignatureFlagFromUISettings(props.channelType);
|
||||
});
|
||||
|
||||
const showTwilioContentTemplates = computed(() => {
|
||||
return props.isTwilioWhatsAppInbox && props.inboxId;
|
||||
});
|
||||
|
||||
const shouldShowEmojiButton = computed(() => {
|
||||
return (
|
||||
!props.isWhatsappInbox && !props.isTwilioWhatsAppInbox && !props.hasNoInbox
|
||||
);
|
||||
});
|
||||
|
||||
const isRegularMessageMode = computed(() => {
|
||||
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
|
||||
});
|
||||
|
||||
const setSignature = () => {
|
||||
if (signatureToApply.value) {
|
||||
if (sendWithSignature.value) {
|
||||
@@ -142,7 +125,7 @@ const keyboardEvents = {
|
||||
action: () => {
|
||||
if (
|
||||
isEditorHotKeyEnabled('enter') &&
|
||||
isRegularMessageMode.value &&
|
||||
!props.isWhatsappInbox &&
|
||||
!props.isDropdownActive
|
||||
) {
|
||||
emit('sendMessage');
|
||||
@@ -153,7 +136,7 @@ const keyboardEvents = {
|
||||
action: () => {
|
||||
if (
|
||||
isEditorHotKeyEnabled('cmd_enter') &&
|
||||
isRegularMessageMode.value &&
|
||||
!props.isWhatsappInbox &&
|
||||
!props.isDropdownActive
|
||||
) {
|
||||
emit('sendMessage');
|
||||
@@ -175,13 +158,8 @@ useKeyboardEvents(keyboardEvents);
|
||||
:message-templates="messageTemplates"
|
||||
@send-message="emit('sendWhatsappMessage', $event)"
|
||||
/>
|
||||
<ContentTemplateSelector
|
||||
v-if="showTwilioContentTemplates"
|
||||
:inbox-id="inboxId"
|
||||
@send-message="emit('sendTwilioMessage', $event)"
|
||||
/>
|
||||
<div
|
||||
v-if="shouldShowEmojiButton"
|
||||
v-if="!isWhatsappInbox && !hasNoInbox"
|
||||
v-on-click-outside="() => (isEmojiPickerOpen = false)"
|
||||
class="relative"
|
||||
>
|
||||
@@ -194,7 +172,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
/>
|
||||
<EmojiInput
|
||||
v-if="isEmojiPickerOpen"
|
||||
class="top-full mt-1.5 ltr:left-0 rtl:right-0"
|
||||
class="ltr:left-0 rtl:right-0 top-full mt-1.5"
|
||||
:on-click="onClickInsertEmoji"
|
||||
/>
|
||||
</div>
|
||||
@@ -221,7 +199,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
/>
|
||||
</FileUpload>
|
||||
<Button
|
||||
v-if="hasSelectedInbox && isRegularMessageMode"
|
||||
v-if="hasSelectedInbox && !isWhatsappInbox"
|
||||
icon="i-lucide-signature"
|
||||
color="slate"
|
||||
size="sm"
|
||||
@@ -240,7 +218,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
@click="emit('discard')"
|
||||
/>
|
||||
<Button
|
||||
v-if="isRegularMessageMode"
|
||||
v-if="!isWhatsappInbox"
|
||||
:label="sendButtonLabel"
|
||||
size="sm"
|
||||
class="!text-xs font-medium"
|
||||
|
||||
+1
-28
@@ -74,9 +74,6 @@ const inboxTypes = computed(() => ({
|
||||
isTwilioSMS:
|
||||
props.targetInbox?.channelType === INBOX_TYPES.TWILIO &&
|
||||
props.targetInbox?.medium === 'sms',
|
||||
isTwilioWhatsapp:
|
||||
props.targetInbox?.channelType === INBOX_TYPES.TWILIO &&
|
||||
props.targetInbox?.medium === 'whatsapp',
|
||||
}));
|
||||
|
||||
const whatsappMessageTemplates = computed(() =>
|
||||
@@ -264,28 +261,6 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
|
||||
isFromWhatsApp: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSendTwilioMessage = async ({ message, templateParams }) => {
|
||||
const twilioMessagePayload = prepareWhatsAppMessagePayload({
|
||||
targetInbox: props.targetInbox,
|
||||
selectedContact: props.selectedContact,
|
||||
message,
|
||||
templateParams,
|
||||
currentUser: props.currentUser,
|
||||
});
|
||||
await emit('createConversation', {
|
||||
payload: twilioMessagePayload,
|
||||
isFromWhatsApp: true,
|
||||
});
|
||||
};
|
||||
|
||||
const shouldShowMessageEditor = computed(() => {
|
||||
return (
|
||||
!inboxTypes.value.isWhatsapp &&
|
||||
!showNoInboxAlert.value &&
|
||||
!inboxTypes.value.isTwilioWhatsapp
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -336,7 +311,7 @@ const shouldShowMessageEditor = computed(() => {
|
||||
/>
|
||||
|
||||
<MessageEditor
|
||||
v-if="shouldShowMessageEditor"
|
||||
v-if="!inboxTypes.isWhatsapp && !showNoInboxAlert"
|
||||
v-model="state.message"
|
||||
:message-signature="messageSignature"
|
||||
:send-with-signature="sendWithSignature"
|
||||
@@ -356,7 +331,6 @@ const shouldShowMessageEditor = computed(() => {
|
||||
:is-whatsapp-inbox="inboxTypes.isWhatsapp"
|
||||
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
|
||||
:is-twilio-sms-inbox="inboxTypes.isTwilioSMS"
|
||||
:is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp"
|
||||
:message-templates="whatsappMessageTemplates"
|
||||
:channel-type="inboxChannelType"
|
||||
:is-loading="isCreating"
|
||||
@@ -373,7 +347,6 @@ const shouldShowMessageEditor = computed(() => {
|
||||
@discard="$emit('discard')"
|
||||
@send-message="handleSendMessage"
|
||||
@send-whatsapp-message="handleSendWhatsappMessage"
|
||||
@send-twilio-message="handleSendTwilioMessage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
<script setup>
|
||||
import ContentTemplateParser from 'dashboard/components-next/content-templates/ContentTemplateParser.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
defineProps({
|
||||
template: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['sendMessage', 'back']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const handleSendMessage = payload => {
|
||||
emit('sendMessage', payload);
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
emit('back');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
||||
>
|
||||
<div class="w-full">
|
||||
<ContentTemplateParser
|
||||
:template="template"
|
||||
@send-message="handleSendMessage"
|
||||
@back="handleBack"
|
||||
>
|
||||
<template #actions="{ sendMessage, goBack, disabled }">
|
||||
<div class="flex gap-3 justify-between items-end w-full h-14">
|
||||
<Button
|
||||
:label="t('CONTENT_TEMPLATES.FORM.BACK_BUTTON')"
|
||||
color="slate"
|
||||
variant="faded"
|
||||
class="w-full font-medium"
|
||||
@click="goBack"
|
||||
/>
|
||||
<Button
|
||||
:label="t('CONTENT_TEMPLATES.FORM.SEND_MESSAGE_BUTTON')"
|
||||
class="w-full font-medium"
|
||||
:disabled="disabled"
|
||||
@click="sendMessage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</ContentTemplateParser>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-124
@@ -1,124 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import ContentTemplateForm from './ContentTemplateForm.vue';
|
||||
|
||||
const props = defineProps({
|
||||
inboxId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['sendMessage']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const inbox = useMapGetter('inboxes/getInbox');
|
||||
|
||||
const searchQuery = ref('');
|
||||
const selectedTemplate = ref(null);
|
||||
const showTemplatesMenu = ref(false);
|
||||
|
||||
const contentTemplates = computed(() => {
|
||||
const inboxData = inbox.value(props.inboxId);
|
||||
return inboxData?.content_templates?.templates || [];
|
||||
});
|
||||
|
||||
const filteredTemplates = computed(() => {
|
||||
return contentTemplates.value.filter(
|
||||
template =>
|
||||
template.friendly_name
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.value.toLowerCase()) &&
|
||||
template.status === 'approved'
|
||||
);
|
||||
});
|
||||
|
||||
const handleTriggerClick = () => {
|
||||
searchQuery.value = '';
|
||||
showTemplatesMenu.value = !showTemplatesMenu.value;
|
||||
};
|
||||
|
||||
const handleTemplateClick = template => {
|
||||
selectedTemplate.value = template;
|
||||
showTemplatesMenu.value = false;
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
selectedTemplate.value = null;
|
||||
showTemplatesMenu.value = true;
|
||||
};
|
||||
|
||||
const handleSendMessage = template => {
|
||||
emit('sendMessage', template);
|
||||
selectedTemplate.value = null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<Button
|
||||
icon="i-ph-whatsapp-logo"
|
||||
:label="t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.LABEL')"
|
||||
color="slate"
|
||||
size="sm"
|
||||
:disabled="selectedTemplate"
|
||||
class="!text-xs font-medium"
|
||||
@click="handleTriggerClick"
|
||||
/>
|
||||
<div
|
||||
v-if="showTemplatesMenu"
|
||||
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
||||
>
|
||||
<div class="w-full">
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
:placeholder="
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.SEARCH_PLACEHOLDER')
|
||||
"
|
||||
custom-input-class="ltr:pl-10 rtl:pr-10"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="i-lucide-search"
|
||||
class="absolute top-2 size-3.5 ltr:left-3 rtl:right-3"
|
||||
/>
|
||||
</template>
|
||||
</Input>
|
||||
</div>
|
||||
<div
|
||||
v-for="template in filteredTemplates"
|
||||
:key="template.content_sid"
|
||||
tabindex="0"
|
||||
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
|
||||
@click="handleTemplateClick(template)"
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-n-slate-12">{{
|
||||
template.friendly_name
|
||||
}}</span>
|
||||
</div>
|
||||
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
|
||||
{{ template.body || t('CONTENT_TEMPLATES.PICKER.NO_CONTENT') }}
|
||||
</p>
|
||||
</div>
|
||||
<template v-if="filteredTemplates.length === 0">
|
||||
<p class="pt-2 w-full text-sm text-n-slate-11">
|
||||
{{ t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.EMPTY_STATE') }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
<ContentTemplateForm
|
||||
v-if="selectedTemplate"
|
||||
:template="selectedTemplate"
|
||||
@send-message="handleSendMessage"
|
||||
@back="handleBack"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { requiredIf } from '@vuelidate/validators';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
template: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['sendMessage', 'back']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const processedParams = ref({});
|
||||
|
||||
const templateName = computed(() => {
|
||||
return props.template?.name || '';
|
||||
});
|
||||
|
||||
const templateString = computed(() => {
|
||||
return props.template?.components?.find(
|
||||
component => component.type === 'BODY'
|
||||
).text;
|
||||
});
|
||||
|
||||
const processVariable = str => {
|
||||
return str.replace(/{{|}}/g, '');
|
||||
};
|
||||
|
||||
const processedString = computed(() => {
|
||||
return templateString.value.replace(/{{([^}]+)}}/g, (match, variable) => {
|
||||
const variableKey = processVariable(variable);
|
||||
return processedParams.value[variableKey] || `{{${variable}}}`;
|
||||
});
|
||||
});
|
||||
|
||||
const processedStringWithVariableHighlight = computed(() => {
|
||||
const variables = templateString.value.match(/{{([^}]+)}}/g) || [];
|
||||
|
||||
return variables.reduce((result, variable) => {
|
||||
const variableKey = processVariable(variable);
|
||||
const value = processedParams.value[variableKey] || variable;
|
||||
return result.replace(
|
||||
variable,
|
||||
`<span class="break-all text-n-slate-12">${value}</span>`
|
||||
);
|
||||
}, templateString.value);
|
||||
});
|
||||
|
||||
const rules = computed(() => {
|
||||
const paramRules = {};
|
||||
Object.keys(processedParams.value).forEach(key => {
|
||||
paramRules[key] = { required: requiredIf(true) };
|
||||
});
|
||||
return {
|
||||
processedParams: paramRules,
|
||||
};
|
||||
});
|
||||
|
||||
const v$ = useVuelidate(rules, { processedParams });
|
||||
|
||||
const getFieldErrorType = key => {
|
||||
if (!v$.value.processedParams[key]?.$error) return 'info';
|
||||
return 'error';
|
||||
};
|
||||
|
||||
const generateVariables = () => {
|
||||
const matchedVariables = templateString.value.match(/{{([^}]+)}}/g);
|
||||
if (!matchedVariables) return;
|
||||
|
||||
const finalVars = matchedVariables.map(i => processVariable(i));
|
||||
processedParams.value = finalVars.reduce((acc, variable) => {
|
||||
acc[variable] = '';
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const sendMessage = async () => {
|
||||
const isValid = await v$.value.$validate();
|
||||
if (!isValid) return;
|
||||
|
||||
const payload = {
|
||||
message: processedString.value,
|
||||
templateParams: {
|
||||
name: props.template.name,
|
||||
category: props.template.category,
|
||||
language: props.template.language,
|
||||
namespace: props.template.namespace,
|
||||
processed_params: processedParams.value,
|
||||
},
|
||||
};
|
||||
emit('sendMessage', payload);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
generateVariables();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
||||
>
|
||||
<span class="text-sm text-n-slate-12">
|
||||
{{
|
||||
t(
|
||||
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.TEMPLATE_NAME',
|
||||
{ templateName: templateName }
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<p
|
||||
class="mb-0 text-sm text-n-slate-11"
|
||||
v-html="processedStringWithVariableHighlight"
|
||||
/>
|
||||
|
||||
<span
|
||||
v-if="Object.keys(processedParams).length"
|
||||
class="text-sm font-medium text-n-slate-12"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.VARIABLES'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
|
||||
<div
|
||||
v-for="(variable, key) in processedParams"
|
||||
:key="key"
|
||||
class="flex items-center w-full gap-2"
|
||||
>
|
||||
<span
|
||||
class="block h-8 text-sm min-w-6 text-start truncate text-n-slate-10 leading-8"
|
||||
:title="key"
|
||||
>
|
||||
{{ key }}
|
||||
</span>
|
||||
<Input
|
||||
v-model="processedParams[key]"
|
||||
custom-input-class="!h-8 w-full !bg-transparent"
|
||||
class="w-full"
|
||||
:message-type="getFieldErrorType(key)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end justify-between w-full gap-3 h-14">
|
||||
<Button
|
||||
:label="
|
||||
t(
|
||||
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.BACK'
|
||||
)
|
||||
"
|
||||
color="slate"
|
||||
variant="faded"
|
||||
class="w-full font-medium"
|
||||
@click="emit('back')"
|
||||
/>
|
||||
<Button
|
||||
:label="
|
||||
t(
|
||||
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.SEND_MESSAGE'
|
||||
)
|
||||
"
|
||||
class="w-full font-medium"
|
||||
@click="sendMessage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+1
-2
@@ -25,7 +25,7 @@ export const generateLabelForContactableInboxesList = ({
|
||||
channelType === INBOX_TYPES.TWILIO ||
|
||||
channelType === INBOX_TYPES.WHATSAPP
|
||||
) {
|
||||
return phoneNumber ? `${name} (${phoneNumber})` : name;
|
||||
return `${name} (${phoneNumber})`;
|
||||
}
|
||||
return name;
|
||||
};
|
||||
@@ -53,7 +53,6 @@ const transformInbox = ({
|
||||
email,
|
||||
phoneNumber,
|
||||
channelType,
|
||||
medium,
|
||||
...rest,
|
||||
});
|
||||
|
||||
|
||||
@@ -4,10 +4,6 @@ import { useToggle } from '@vueuse/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
import {
|
||||
isPdfDocument,
|
||||
formatDocumentLink,
|
||||
} from 'shared/helpers/documentHelper';
|
||||
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
@@ -67,11 +63,6 @@ const menuItems = computed(() => {
|
||||
|
||||
const createdAt = computed(() => dynamicTime(props.createdAt));
|
||||
|
||||
const displayLink = computed(() => formatDocumentLink(props.externalLink));
|
||||
const linkIcon = computed(() =>
|
||||
isPdfDocument(props.externalLink) ? 'i-ph-file-pdf' : 'i-ph-link-simple'
|
||||
);
|
||||
|
||||
const handleAction = ({ action, value }) => {
|
||||
toggleDropdown(false);
|
||||
emit('action', { action, value, id: props.id });
|
||||
@@ -80,14 +71,14 @@ const handleAction = ({ action, value }) => {
|
||||
|
||||
<template>
|
||||
<CardLayout>
|
||||
<div class="flex gap-1 justify-between w-full">
|
||||
<div class="flex justify-between w-full gap-1">
|
||||
<span class="text-base text-n-slate-12 line-clamp-1">
|
||||
{{ name }}
|
||||
</span>
|
||||
<div class="flex gap-2 items-center">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
class="flex relative items-center group"
|
||||
class="relative flex items-center group"
|
||||
>
|
||||
<Button
|
||||
icon="i-lucide-ellipsis-vertical"
|
||||
@@ -99,26 +90,26 @@ const handleAction = ({ action, value }) => {
|
||||
<DropdownMenu
|
||||
v-if="showActionsDropdown"
|
||||
:menu-items="menuItems"
|
||||
class="top-full mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0"
|
||||
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full"
|
||||
@action="handleAction($event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-4 justify-between items-center w-full">
|
||||
<div class="flex items-center justify-between w-full gap-4">
|
||||
<span
|
||||
class="flex gap-1 items-center text-sm truncate shrink-0 text-n-slate-11"
|
||||
class="text-sm shrink-0 truncate text-n-slate-11 flex items-center gap-1"
|
||||
>
|
||||
<i class="i-woot-captain" />
|
||||
{{ assistant?.name || '' }}
|
||||
</span>
|
||||
<span
|
||||
class="flex flex-1 gap-1 justify-start items-center text-sm truncate text-n-slate-11"
|
||||
class="text-n-slate-11 text-sm truncate flex justify-start flex-1 items-center gap-1"
|
||||
>
|
||||
<i :class="linkIcon" class="shrink-0" />
|
||||
<span class="truncate">{{ displayLink }}</span>
|
||||
<i class="i-ph-link-simple shrink-0" />
|
||||
<span class="truncate">{{ externalLink }}</span>
|
||||
</span>
|
||||
<div class="text-sm shrink-0 text-n-slate-11 line-clamp-1">
|
||||
<div class="shrink-0 text-sm text-n-slate-11 line-clamp-1">
|
||||
{{ createdAt }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+7
-3
@@ -3,7 +3,6 @@ import { ref } from 'vue';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import DocumentForm from './DocumentForm.vue';
|
||||
@@ -13,6 +12,7 @@ const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const documentForm = ref(null);
|
||||
|
||||
const i18nKey = 'CAPTAIN.DOCUMENTS.CREATE';
|
||||
|
||||
@@ -23,7 +23,7 @@ const handleSubmit = async newDocument => {
|
||||
dialogRef.value.close();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
parseAPIErrorResponse(error) || t(`${i18nKey}.ERROR_MESSAGE`);
|
||||
error?.response?.message || t(`${i18nKey}.ERROR_MESSAGE`);
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
@@ -48,7 +48,11 @@ defineExpose({ dialogRef });
|
||||
:show-confirm-button="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<DocumentForm @submit="handleSubmit" @cancel="handleCancel" />
|
||||
<DocumentForm
|
||||
ref="documentForm"
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
<template #footer />
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
+9
-149
@@ -1,10 +1,9 @@
|
||||
<script setup>
|
||||
import { reactive, computed, ref, nextTick } from 'vue';
|
||||
import { reactive, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength, requiredIf, url } from '@vuelidate/validators';
|
||||
import { required, minLength, url } from '@vuelidate/validators';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
@@ -12,8 +11,6 @@ import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
|
||||
const emit = defineEmits(['submit', 'cancel']);
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const formState = {
|
||||
@@ -23,25 +20,14 @@ const formState = {
|
||||
|
||||
const initialState = {
|
||||
name: '',
|
||||
url: '',
|
||||
assistantId: null,
|
||||
documentType: 'url',
|
||||
pdfFile: null,
|
||||
};
|
||||
|
||||
const state = reactive({ ...initialState });
|
||||
const fileInputRef = ref(null);
|
||||
|
||||
const validationRules = {
|
||||
url: {
|
||||
required: requiredIf(() => state.documentType === 'url'),
|
||||
url: requiredIf(() => state.documentType === 'url' && url),
|
||||
minLength: requiredIf(() => state.documentType === 'url' && minLength(1)),
|
||||
},
|
||||
url: { required, url, minLength: minLength(1) },
|
||||
assistantId: { required },
|
||||
pdfFile: {
|
||||
required: requiredIf(() => state.documentType === 'pdf'),
|
||||
},
|
||||
};
|
||||
|
||||
const assistantList = computed(() =>
|
||||
@@ -51,17 +37,10 @@ const assistantList = computed(() =>
|
||||
}))
|
||||
);
|
||||
|
||||
const documentTypeOptions = [
|
||||
{ value: 'url', label: t('CAPTAIN.DOCUMENTS.FORM.TYPE.URL') },
|
||||
{ value: 'pdf', label: t('CAPTAIN.DOCUMENTS.FORM.TYPE.PDF') },
|
||||
];
|
||||
|
||||
const v$ = useVuelidate(validationRules, state);
|
||||
|
||||
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
|
||||
|
||||
const hasPdfFileError = computed(() => v$.value.pdfFile.$error);
|
||||
|
||||
const getErrorMessage = (field, errorKey) => {
|
||||
return v$.value[field].$error
|
||||
? t(`CAPTAIN.DOCUMENTS.FORM.${errorKey}.ERROR`)
|
||||
@@ -71,57 +50,14 @@ const getErrorMessage = (field, errorKey) => {
|
||||
const formErrors = computed(() => ({
|
||||
url: getErrorMessage('url', 'URL'),
|
||||
assistantId: getErrorMessage('assistantId', 'ASSISTANT'),
|
||||
pdfFile: getErrorMessage('pdfFile', 'PDF_FILE'),
|
||||
}));
|
||||
|
||||
const handleCancel = () => emit('cancel');
|
||||
|
||||
const handleFileChange = event => {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
if (file.type !== 'application/pdf') {
|
||||
useAlert(t('CAPTAIN.DOCUMENTS.FORM.PDF_FILE.INVALID_TYPE'));
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
// 10MB
|
||||
useAlert(t('CAPTAIN.DOCUMENTS.FORM.PDF_FILE.TOO_LARGE'));
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
state.pdfFile = file;
|
||||
state.name = file.name.replace(/\.pdf$/i, '');
|
||||
}
|
||||
};
|
||||
|
||||
const openFileDialog = () => {
|
||||
// Use nextTick to ensure the ref is available
|
||||
nextTick(() => {
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.click();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const prepareDocumentDetails = () => {
|
||||
const formData = new FormData();
|
||||
formData.append('document[assistant_id]', state.assistantId);
|
||||
|
||||
if (state.documentType === 'url') {
|
||||
formData.append('document[external_link]', state.url);
|
||||
formData.append('document[name]', state.name || state.url);
|
||||
} else {
|
||||
formData.append('document[pdf_file]', state.pdfFile);
|
||||
formData.append(
|
||||
'document[name]',
|
||||
state.name || state.pdfFile.name.replace('.pdf', '')
|
||||
);
|
||||
// No need to send external_link for PDF - it's auto-generated in the backend
|
||||
}
|
||||
|
||||
return formData;
|
||||
};
|
||||
const prepareDocumentDetails = () => ({
|
||||
external_link: state.url,
|
||||
assistant_id: state.assistantId,
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const isFormValid = await v$.value.$validate();
|
||||
@@ -135,89 +71,13 @@ const handleSubmit = async () => {
|
||||
|
||||
<template>
|
||||
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label
|
||||
for="documentType"
|
||||
class="mb-0.5 text-sm font-medium text-n-slate-12"
|
||||
>
|
||||
{{ t('CAPTAIN.DOCUMENTS.FORM.TYPE.LABEL') }}
|
||||
</label>
|
||||
<ComboBox
|
||||
id="documentType"
|
||||
v-model="state.documentType"
|
||||
:options="documentTypeOptions"
|
||||
class="[&>div>button]:bg-n-alpha-black2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
v-if="state.documentType === 'url'"
|
||||
v-model="state.url"
|
||||
:label="t('CAPTAIN.DOCUMENTS.FORM.URL.LABEL')"
|
||||
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.URL.PLACEHOLDER')"
|
||||
:message="formErrors.url"
|
||||
:message-type="formErrors.url ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<div v-if="state.documentType === 'pdf'" class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAPTAIN.DOCUMENTS.FORM.PDF_FILE.LABEL') }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
class="hidden"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
:color="hasPdfFileError ? 'ruby' : 'slate'"
|
||||
:variant="hasPdfFileError ? 'outline' : 'solid'"
|
||||
class="!w-full !h-auto !justify-between !py-4"
|
||||
@click="openFileDialog"
|
||||
>
|
||||
<template #default>
|
||||
<div class="flex gap-2 items-center">
|
||||
<div
|
||||
class="flex justify-center items-center w-10 h-10 rounded-lg bg-n-slate-3"
|
||||
>
|
||||
<i class="text-xl i-ph-file-pdf text-n-slate-11" />
|
||||
</div>
|
||||
<div class="flex flex-col flex-1 gap-1 items-start">
|
||||
<p class="m-0 text-sm font-medium text-n-slate-12">
|
||||
{{
|
||||
state.pdfFile
|
||||
? state.pdfFile.name
|
||||
: t('CAPTAIN.DOCUMENTS.FORM.PDF_FILE.CHOOSE_FILE')
|
||||
}}
|
||||
</p>
|
||||
<p class="m-0 text-xs text-n-slate-11">
|
||||
{{
|
||||
state.pdfFile
|
||||
? `${(state.pdfFile.size / 1024 / 1024).toFixed(2)} MB`
|
||||
: t('CAPTAIN.DOCUMENTS.FORM.PDF_FILE.HELP_TEXT')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<i class="i-lucide-upload text-n-slate-11" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<p v-if="formErrors.pdfFile" class="text-xs text-n-ruby-9">
|
||||
{{ formErrors.pdfFile }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
v-model="state.name"
|
||||
:label="t('CAPTAIN.DOCUMENTS.FORM.NAME.LABEL')"
|
||||
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.NAME.PLACEHOLDER')"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="assistant" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAPTAIN.DOCUMENTS.FORM.ASSISTANT.LABEL') }}
|
||||
@@ -228,12 +88,12 @@ const handleSubmit = async () => {
|
||||
:options="assistantList"
|
||||
:has-error="!!formErrors.assistantId"
|
||||
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.ASSISTANT.PLACEHOLDER')"
|
||||
class="[&>div>button]:bg-n-alpha-black2"
|
||||
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
|
||||
:message="formErrors.assistantId"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 justify-between items-center w-full">
|
||||
<div class="flex items-center justify-between w-full gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="faded"
|
||||
|
||||
@@ -96,12 +96,8 @@ watch(
|
||||
:label="selectedLabel"
|
||||
trailing-icon
|
||||
:disabled="disabled"
|
||||
class="justify-between w-full !px-3 !py-2.5 text-n-slate-12 font-normal group-hover/combobox:border-n-slate-6 focus:outline-n-brand"
|
||||
:class="{
|
||||
focused: open,
|
||||
'[&:not(.focused)]:dark:outline-n-weak [&:not(.focused)]:hover:enabled:outline-n-slate-6 [&:not(.focused)]:dark:hover:enabled:outline-n-slate-6':
|
||||
!hasError,
|
||||
}"
|
||||
class="justify-between w-full !px-3 !py-2.5 text-n-slate-12 font-normal group-hover/combobox:border-n-slate-6 [&:not(.focused)]:hover:enabled:outline-n-slate-6 [&:not(.focused)]:dark:hover:enabled:outline-n-slate-6 [&:not(.focused)]:dark:outline-n-weak focus:outline-n-brand"
|
||||
:class="{ focused: open }"
|
||||
:icon="open ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
@click="toggleDropdown"
|
||||
/>
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { requiredIf } from '@vuelidate/validators';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { extractFilenameFromUrl } from 'dashboard/helper/URLHelper';
|
||||
import { TWILIO_CONTENT_TEMPLATE_TYPES } from 'shared/constants/messages';
|
||||
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
|
||||
const props = defineProps({
|
||||
template: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
validator: value => {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
if (!value.friendly_name) return false;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['sendMessage', 'resetTemplate', 'back']);
|
||||
|
||||
const VARIABLE_PATTERN = /{{([^}]+)}}/g;
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const processedParams = ref({});
|
||||
|
||||
const languageLabel = computed(() => {
|
||||
return `${t('CONTENT_TEMPLATES.PARSER.LANGUAGE')}: ${props.template.language || 'en'}`;
|
||||
});
|
||||
|
||||
const categoryLabel = computed(() => {
|
||||
return `${t('CONTENT_TEMPLATES.PARSER.CATEGORY')}: ${props.template.category || 'utility'}`;
|
||||
});
|
||||
|
||||
const templateBody = computed(() => {
|
||||
return props.template.body || '';
|
||||
});
|
||||
|
||||
const hasMediaTemplate = computed(() => {
|
||||
return props.template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.MEDIA;
|
||||
});
|
||||
|
||||
const hasVariables = computed(() => {
|
||||
return templateBody.value?.match(VARIABLE_PATTERN) !== null;
|
||||
});
|
||||
|
||||
const mediaVariableKey = computed(() => {
|
||||
if (!hasMediaTemplate.value) return null;
|
||||
const mediaUrl = props.template?.types?.['twilio/media']?.media?.[0];
|
||||
if (!mediaUrl) return null;
|
||||
return mediaUrl.match(/{{(\d+)}}/)?.[1] ?? null;
|
||||
});
|
||||
|
||||
const hasMediaVariable = computed(() => {
|
||||
return hasMediaTemplate.value && mediaVariableKey.value !== null;
|
||||
});
|
||||
|
||||
const templateMediaUrl = computed(() => {
|
||||
if (!hasMediaTemplate.value) return '';
|
||||
|
||||
return props.template?.types?.['twilio/media']?.media?.[0] || '';
|
||||
});
|
||||
|
||||
const variablePattern = computed(() => {
|
||||
if (!hasVariables.value) return [];
|
||||
const matches = templateBody.value.match(VARIABLE_PATTERN) || [];
|
||||
return matches.map(match => match.replace(/[{}]/g, ''));
|
||||
});
|
||||
|
||||
const renderedTemplate = computed(() => {
|
||||
let rendered = templateBody.value;
|
||||
if (processedParams.value && Object.keys(processedParams.value).length > 0) {
|
||||
// Replace variables in the format {{1}}, {{2}}, etc.
|
||||
rendered = rendered.replace(VARIABLE_PATTERN, (match, variable) => {
|
||||
const cleanVariable = variable.trim();
|
||||
return processedParams.value[cleanVariable] || match;
|
||||
});
|
||||
}
|
||||
return rendered;
|
||||
});
|
||||
|
||||
const isFormInvalid = computed(() => {
|
||||
if (!hasVariables.value && !hasMediaVariable.value) return false;
|
||||
|
||||
if (hasVariables.value) {
|
||||
const hasEmptyVariable = variablePattern.value.some(
|
||||
variable => !processedParams.value[variable]
|
||||
);
|
||||
if (hasEmptyVariable) return true;
|
||||
}
|
||||
|
||||
if (
|
||||
hasMediaVariable.value &&
|
||||
mediaVariableKey.value &&
|
||||
!processedParams.value[mediaVariableKey.value]
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const v$ = useVuelidate(
|
||||
{
|
||||
processedParams: {
|
||||
requiredIfKeysPresent: requiredIf(
|
||||
() => hasVariables.value || hasMediaVariable.value
|
||||
),
|
||||
},
|
||||
},
|
||||
{ processedParams }
|
||||
);
|
||||
|
||||
const initializeTemplateParameters = () => {
|
||||
processedParams.value = {};
|
||||
|
||||
if (hasVariables.value) {
|
||||
variablePattern.value.forEach(variable => {
|
||||
processedParams.value[variable] = '';
|
||||
});
|
||||
}
|
||||
|
||||
if (hasMediaVariable.value && mediaVariableKey.value) {
|
||||
processedParams.value[mediaVariableKey.value] = '';
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessage = () => {
|
||||
v$.value.$touch();
|
||||
if (v$.value.$invalid || isFormInvalid.value) return;
|
||||
|
||||
const { friendly_name, language } = props.template;
|
||||
|
||||
// Process parameters and extract filename from media URL if needed
|
||||
const processedParameters = { ...processedParams.value };
|
||||
|
||||
// For media templates, extract filename from full URL
|
||||
if (
|
||||
hasMediaVariable.value &&
|
||||
mediaVariableKey.value &&
|
||||
processedParameters[mediaVariableKey.value]
|
||||
) {
|
||||
processedParameters[mediaVariableKey.value] = extractFilenameFromUrl(
|
||||
processedParameters[mediaVariableKey.value]
|
||||
);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
message: renderedTemplate.value,
|
||||
templateParams: {
|
||||
name: friendly_name,
|
||||
language,
|
||||
processed_params: processedParameters,
|
||||
},
|
||||
};
|
||||
emit('sendMessage', payload);
|
||||
};
|
||||
|
||||
const resetTemplate = () => {
|
||||
emit('resetTemplate');
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
emit('back');
|
||||
};
|
||||
|
||||
onMounted(initializeTemplateParameters);
|
||||
|
||||
watch(
|
||||
() => props.template,
|
||||
() => {
|
||||
initializeTemplateParameters();
|
||||
v$.value.$reset();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
defineExpose({
|
||||
processedParams,
|
||||
hasVariables,
|
||||
hasMediaTemplate,
|
||||
renderedTemplate,
|
||||
v$,
|
||||
sendMessage,
|
||||
resetTemplate,
|
||||
goBack,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col gap-4 p-4 mb-4 rounded-lg bg-n-alpha-black2">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-sm font-medium text-n-slate-12">
|
||||
{{ template.friendly_name }}
|
||||
</h3>
|
||||
<span class="text-xs text-n-slate-11">
|
||||
{{ languageLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="rounded-md">
|
||||
<div class="text-sm whitespace-pre-wrap text-n-slate-12">
|
||||
{{ renderedTemplate }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-n-slate-11">
|
||||
{{ categoryLabel }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasVariables || hasMediaVariable">
|
||||
<!-- Media URL for media templates -->
|
||||
<div v-if="hasMediaVariable" class="mb-4">
|
||||
<p class="mb-2.5 text-sm font-semibold">
|
||||
{{ $t('CONTENT_TEMPLATES.PARSER.MEDIA_URL_LABEL') }}
|
||||
</p>
|
||||
<div class="flex items-center mb-2.5">
|
||||
<Input
|
||||
v-model="processedParams[mediaVariableKey]"
|
||||
type="url"
|
||||
class="flex-1"
|
||||
:placeholder="
|
||||
templateMediaUrl ||
|
||||
t('CONTENT_TEMPLATES.PARSER.MEDIA_URL_PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body Variables Section -->
|
||||
<div v-if="hasVariables">
|
||||
<p class="mb-2.5 text-sm font-semibold">
|
||||
{{ $t('CONTENT_TEMPLATES.PARSER.VARIABLES_LABEL') }}
|
||||
</p>
|
||||
<div
|
||||
v-for="variable in variablePattern"
|
||||
:key="`variable-${variable}`"
|
||||
class="flex items-center mb-2.5"
|
||||
>
|
||||
<Input
|
||||
v-model="processedParams[variable]"
|
||||
type="text"
|
||||
class="flex-1"
|
||||
:placeholder="
|
||||
t('CONTENT_TEMPLATES.PARSER.VARIABLE_PLACEHOLDER', {
|
||||
variable: variable,
|
||||
})
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="v$.$dirty && (v$.$invalid || isFormInvalid)"
|
||||
class="p-2.5 text-center rounded-md bg-n-ruby-9/20 text-n-ruby-9"
|
||||
>
|
||||
{{ $t('CONTENT_TEMPLATES.PARSER.FORM_ERROR_MESSAGE') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<slot
|
||||
name="actions"
|
||||
:send-message="sendMessage"
|
||||
:reset-template="resetTemplate"
|
||||
:go-back="goBack"
|
||||
:is-valid="!v$.$invalid && !isFormInvalid"
|
||||
:disabled="isFormInvalid"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,49 +1,25 @@
|
||||
<script setup>
|
||||
import { ref, nextTick, onMounted } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const emit = defineEmits(['send']);
|
||||
const message = ref('');
|
||||
const textareaRef = ref(null);
|
||||
|
||||
const adjustHeight = () => {
|
||||
if (!textareaRef.value) return;
|
||||
|
||||
// Reset height to auto to get the correct scrollHeight
|
||||
textareaRef.value.style.height = 'auto';
|
||||
// Set the height to the scrollHeight
|
||||
textareaRef.value.style.height = `${textareaRef.value.scrollHeight}px`;
|
||||
};
|
||||
|
||||
const sendMessage = () => {
|
||||
if (message.value.trim()) {
|
||||
emit('send', message.value);
|
||||
message.value = '';
|
||||
// Reset textarea height after sending
|
||||
nextTick(() => {
|
||||
adjustHeight();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleInput = () => {
|
||||
nextTick(adjustHeight);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(adjustHeight);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="relative" @submit.prevent="sendMessage">
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
<input
|
||||
v-model="message"
|
||||
type="text"
|
||||
:placeholder="$t('CAPTAIN.COPILOT.SEND_MESSAGE')"
|
||||
class="w-full reset-base bg-n-alpha-3 ltr:pl-4 ltr:pr-12 rtl:pl-12 rtl:pr-4 py-3 text-sm border border-n-weak rounded-lg focus:outline-0 focus:outline-none focus:ring-2 focus:ring-n-blue-11 focus:border-n-blue-11 resize-none overflow-hidden max-h-[200px] mb-0 text-n-slate-12"
|
||||
rows="1"
|
||||
@input="handleInput"
|
||||
@keydown.enter.exact.prevent="sendMessage"
|
||||
class="w-full reset-base bg-n-alpha-3 ltr:pl-4 ltr:pr-12 rtl:pl-12 rtl:pr-4 py-3 text-n-slate-11 text-sm border border-n-weak rounded-lg focus:outline-none focus:ring-1 focus:ring-n-blue-11 focus:border-n-blue-11"
|
||||
@keyup.enter="sendMessage"
|
||||
/>
|
||||
<button
|
||||
class="absolute ltr:right-1 rtl:left-1 top-1/2 -translate-y-1/2 h-9 w-10 flex items-center justify-center text-n-slate-11 hover:text-n-blue-11"
|
||||
|
||||
@@ -34,17 +34,14 @@ const formatOperatorLabel = operator => {
|
||||
};
|
||||
|
||||
const formatFilterValue = value => {
|
||||
// Case 1: null, undefined, empty string
|
||||
if (!value) return '';
|
||||
|
||||
// Case 2: array → map each item, use name if present, else the item itself
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(item => item?.name ?? item).join(', ');
|
||||
return value.join(', ');
|
||||
}
|
||||
|
||||
// Case 3: object with a "name" property → return name
|
||||
// Case 4: primitive (string, number, etc.) → return as is
|
||||
return value?.name ?? value;
|
||||
if (typeof value === 'object' && value.name) {
|
||||
return value.name;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -69,7 +66,6 @@ const formatFilterValue = value => {
|
||||
</span>
|
||||
<span
|
||||
v-if="filter.values"
|
||||
:title="formatFilterValue(filter.values)"
|
||||
class="lowercase truncate text-n-slate-12"
|
||||
:class="{
|
||||
'first-letter:capitalize': shouldCapitalizeFirstLetter(
|
||||
|
||||
@@ -50,7 +50,6 @@ export function useContactFilterContext() {
|
||||
const { t } = useI18n();
|
||||
|
||||
const contactAttributes = useMapGetter('attributes/getContactAttributes');
|
||||
const labels = useMapGetter('labels/getLabels');
|
||||
|
||||
const {
|
||||
equalityOperators,
|
||||
@@ -185,20 +184,6 @@ export function useContactFilterContext() {
|
||||
filterOperators: equalityOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONTACT_ATTRIBUTES.LABELS,
|
||||
value: CONTACT_ATTRIBUTES.LABELS,
|
||||
attributeName: t('CONTACTS_FILTER.ATTRIBUTES.LABELS'),
|
||||
label: t('CONTACTS_FILTER.ATTRIBUTES.LABELS'),
|
||||
inputType: 'multiSelect',
|
||||
options: labels.value?.map(label => ({
|
||||
id: label.title,
|
||||
name: label.title,
|
||||
})),
|
||||
dataType: 'text',
|
||||
filterOperators: equalityOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
...customFilterTypes.value,
|
||||
]);
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ export const CONTACT_ATTRIBUTES = {
|
||||
LAST_ACTIVITY_AT: 'last_activity_at',
|
||||
REFERER: 'referer',
|
||||
BLOCKED: 'blocked',
|
||||
LABELS: 'labels',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,23 +2,23 @@ import { computed } from 'vue';
|
||||
|
||||
export function useChannelIcon(inbox) {
|
||||
const channelTypeIconMap = {
|
||||
'Channel::Api': 'i-woot-api',
|
||||
'Channel::Email': 'i-woot-mail',
|
||||
'Channel::FacebookPage': 'i-woot-messenger',
|
||||
'Channel::Line': 'i-woot-line',
|
||||
'Channel::Sms': 'i-woot-sms',
|
||||
'Channel::Telegram': 'i-woot-telegram',
|
||||
'Channel::TwilioSms': 'i-woot-sms',
|
||||
'Channel::Api': 'i-ri-cloudy-fill',
|
||||
'Channel::Email': 'i-ri-mail-fill',
|
||||
'Channel::FacebookPage': 'i-ri-messenger-fill',
|
||||
'Channel::Line': 'i-ri-line-fill',
|
||||
'Channel::Sms': 'i-ri-chat-1-fill',
|
||||
'Channel::Telegram': 'i-ri-telegram-fill',
|
||||
'Channel::TwilioSms': 'i-ri-chat-1-fill',
|
||||
'Channel::TwitterProfile': 'i-ri-twitter-x-fill',
|
||||
'Channel::WebWidget': 'i-woot-website',
|
||||
'Channel::Whatsapp': 'i-woot-whatsapp',
|
||||
'Channel::Instagram': 'i-woot-instagram',
|
||||
'Channel::WebWidget': 'i-ri-global-fill',
|
||||
'Channel::Whatsapp': 'i-ri-whatsapp-fill',
|
||||
'Channel::Instagram': 'i-ri-instagram-fill',
|
||||
'Channel::Voice': 'i-ri-phone-fill',
|
||||
};
|
||||
|
||||
const providerIconMap = {
|
||||
microsoft: 'i-woot-outlook',
|
||||
google: 'i-woot-gmail',
|
||||
microsoft: 'i-ri-microsoft-fill',
|
||||
google: 'i-ri-google-fill',
|
||||
};
|
||||
|
||||
const channelIcon = computed(() => {
|
||||
@@ -34,7 +34,7 @@ export function useChannelIcon(inbox) {
|
||||
|
||||
// Special case for Twilio whatsapp
|
||||
if (type === 'Channel::TwilioSms' && inboxDetails.medium === 'whatsapp') {
|
||||
icon = 'i-woot-whatsapp';
|
||||
icon = 'i-ri-whatsapp-fill';
|
||||
}
|
||||
|
||||
return icon ?? 'i-ri-global-fill';
|
||||
|
||||
@@ -4,19 +4,19 @@ describe('useChannelIcon', () => {
|
||||
it('returns correct icon for API channel', () => {
|
||||
const inbox = { channel_type: 'Channel::Api' };
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-woot-api');
|
||||
expect(icon).toBe('i-ri-cloudy-fill');
|
||||
});
|
||||
|
||||
it('returns correct icon for Facebook channel', () => {
|
||||
const inbox = { channel_type: 'Channel::FacebookPage' };
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-woot-messenger');
|
||||
expect(icon).toBe('i-ri-messenger-fill');
|
||||
});
|
||||
|
||||
it('returns correct icon for WhatsApp channel', () => {
|
||||
const inbox = { channel_type: 'Channel::Whatsapp' };
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-woot-whatsapp');
|
||||
expect(icon).toBe('i-ri-whatsapp-fill');
|
||||
});
|
||||
|
||||
it('returns correct icon for Voice channel', () => {
|
||||
@@ -28,19 +28,19 @@ describe('useChannelIcon', () => {
|
||||
it('returns correct icon for Line channel', () => {
|
||||
const inbox = { channel_type: 'Channel::Line' };
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-woot-line');
|
||||
expect(icon).toBe('i-ri-line-fill');
|
||||
});
|
||||
|
||||
it('returns correct icon for SMS channel', () => {
|
||||
const inbox = { channel_type: 'Channel::Sms' };
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-woot-sms');
|
||||
expect(icon).toBe('i-ri-chat-1-fill');
|
||||
});
|
||||
|
||||
it('returns correct icon for Telegram channel', () => {
|
||||
const inbox = { channel_type: 'Channel::Telegram' };
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-woot-telegram');
|
||||
expect(icon).toBe('i-ri-telegram-fill');
|
||||
});
|
||||
|
||||
it('returns correct icon for Twitter channel', () => {
|
||||
@@ -52,20 +52,20 @@ describe('useChannelIcon', () => {
|
||||
it('returns correct icon for WebWidget channel', () => {
|
||||
const inbox = { channel_type: 'Channel::WebWidget' };
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-woot-website');
|
||||
expect(icon).toBe('i-ri-global-fill');
|
||||
});
|
||||
|
||||
it('returns correct icon for Instagram channel', () => {
|
||||
const inbox = { channel_type: 'Channel::Instagram' };
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-woot-instagram');
|
||||
expect(icon).toBe('i-ri-instagram-fill');
|
||||
});
|
||||
|
||||
describe('TwilioSms channel', () => {
|
||||
it('returns chat icon for regular Twilio SMS channel', () => {
|
||||
const inbox = { channel_type: 'Channel::TwilioSms' };
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-woot-sms');
|
||||
expect(icon).toBe('i-ri-chat-1-fill');
|
||||
});
|
||||
|
||||
it('returns WhatsApp icon for Twilio SMS with WhatsApp medium', () => {
|
||||
@@ -74,7 +74,7 @@ describe('useChannelIcon', () => {
|
||||
medium: 'whatsapp',
|
||||
};
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-woot-whatsapp');
|
||||
expect(icon).toBe('i-ri-whatsapp-fill');
|
||||
});
|
||||
|
||||
it('returns chat icon for Twilio SMS with non-WhatsApp medium', () => {
|
||||
@@ -83,7 +83,7 @@ describe('useChannelIcon', () => {
|
||||
medium: 'sms',
|
||||
};
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-woot-sms');
|
||||
expect(icon).toBe('i-ri-chat-1-fill');
|
||||
});
|
||||
|
||||
it('returns chat icon for Twilio SMS with undefined medium', () => {
|
||||
@@ -92,7 +92,7 @@ describe('useChannelIcon', () => {
|
||||
medium: undefined,
|
||||
};
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-woot-sms');
|
||||
expect(icon).toBe('i-ri-chat-1-fill');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,7 +100,7 @@ describe('useChannelIcon', () => {
|
||||
it('returns mail icon for generic email channel', () => {
|
||||
const inbox = { channel_type: 'Channel::Email' };
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-woot-mail');
|
||||
expect(icon).toBe('i-ri-mail-fill');
|
||||
});
|
||||
|
||||
it('returns Microsoft icon for Microsoft email provider', () => {
|
||||
@@ -109,7 +109,7 @@ describe('useChannelIcon', () => {
|
||||
provider: 'microsoft',
|
||||
};
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-woot-outlook');
|
||||
expect(icon).toBe('i-ri-microsoft-fill');
|
||||
});
|
||||
|
||||
it('returns Google icon for Google email provider', () => {
|
||||
@@ -118,7 +118,7 @@ describe('useChannelIcon', () => {
|
||||
provider: 'google',
|
||||
};
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-woot-gmail');
|
||||
expect(icon).toBe('i-ri-google-fill');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -41,7 +41,9 @@ const originalEmailText = computed(() => {
|
||||
});
|
||||
|
||||
const originalEmailHtml = computed(
|
||||
() => contentAttributes?.value?.email?.htmlContent?.full || ''
|
||||
() =>
|
||||
contentAttributes?.value?.email?.htmlContent?.full ??
|
||||
originalEmailText.value
|
||||
);
|
||||
|
||||
const messageContent = computed(() => {
|
||||
|
||||
@@ -25,7 +25,7 @@ const reauthorizationRequired = computed(() => {
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="size-5 grid place-content-center rounded-full bg-n-alpha-2"
|
||||
class="size-4 grid place-content-center rounded-full bg-n-alpha-2"
|
||||
:class="{ 'bg-n-solid-blue': active }"
|
||||
>
|
||||
<ChannelIcon :inbox="inbox" class="size-3" />
|
||||
|
||||
@@ -128,7 +128,7 @@ const menuItems = computed(() => {
|
||||
to: accountScopedRoute('inbox_view'),
|
||||
activeOn: ['inbox_view', 'inbox_view_conversation'],
|
||||
getterKeys: {
|
||||
count: 'notifications/getUnreadCount',
|
||||
badge: 'notifications/getHasUnreadNotifications',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
@@ -17,16 +16,12 @@ const props = defineProps({
|
||||
const emit = defineEmits(['toggle']);
|
||||
|
||||
const showBadge = useMapGetter(props.getterKeys.badge);
|
||||
const dynamicCount = useMapGetter(props.getterKeys.count);
|
||||
const count = computed(() =>
|
||||
dynamicCount.value > 99 ? '99+' : dynamicCount.value
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="to ? 'router-link' : 'div'"
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded-lg h-8 min-w-0"
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded-lg h-8"
|
||||
role="button"
|
||||
draggable="false"
|
||||
:to="to"
|
||||
@@ -45,21 +40,9 @@ const count = computed(() =>
|
||||
class="size-2 -top-px ltr:-right-px rtl:-left-px bg-n-brand absolute rounded-full border border-n-solid-2"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 flex-grow min-w-0">
|
||||
<span class="text-sm font-medium leading-5 truncate">
|
||||
{{ label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="dynamicCount && !expandable"
|
||||
class="rounded-md capitalize text-xs leading-5 font-medium text-center outline outline-1 px-1 flex-shrink-0"
|
||||
:class="{
|
||||
'text-n-blue-text outline-n-slate-6': isActive,
|
||||
'text-n-slate-11 outline-n-strong': !isActive,
|
||||
}"
|
||||
>
|
||||
{{ count }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm font-medium leading-5 flex-grow">
|
||||
{{ label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="expandable"
|
||||
v-show="isExpanded"
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
<script setup>
|
||||
/**
|
||||
* This component handles parsing and sending WhatsApp message templates.
|
||||
* It works as follows:
|
||||
* 1. Displays the template text with variable placeholders.
|
||||
* 2. Generates input fields for each variable in the template.
|
||||
* 3. Validates that all variables are filled before sending.
|
||||
* 4. Replaces placeholders with user-provided values.
|
||||
* 5. Emits events to send the processed message or reset the template.
|
||||
*/
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { requiredIf } from '@vuelidate/validators';
|
||||
|
||||
@@ -1,57 +1,50 @@
|
||||
<script setup>
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
src: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isComingSoon: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isComingSoon: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="relative bg-n-solid-1 gap-6 cursor-pointer rounded-2xl flex flex-col justify-start transition-all duration-200 ease-in -m-px py-6 px-5 items-start border border-solid border-n-weak"
|
||||
:class="{
|
||||
'hover:enabled:border-n-blue-9 hover:enabled:shadow-md disabled:opacity-60 disabled:cursor-not-allowed':
|
||||
!isComingSoon,
|
||||
'cursor-not-allowed disabled:opacity-80': isComingSoon,
|
||||
}"
|
||||
class="relative bg-n-background cursor-pointer flex flex-col justify-end transition-all duration-200 ease-in -m-px py-4 px-0 items-center border border-solid border-n-weak hover:border-n-brand hover:shadow-md hover:z-50 disabled:opacity-60"
|
||||
>
|
||||
<div
|
||||
class="flex size-10 items-center justify-center rounded-full bg-n-alpha-2"
|
||||
>
|
||||
<Icon :icon="icon" class="text-n-slate-10 size-6" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-start gap-1.5">
|
||||
<h3 class="text-n-slate-12 text-sm text-start font-medium capitalize">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p class="text-n-slate-11 text-start text-sm">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<img :src="src" :alt="title" draggable="false" class="w-1/2 my-4 mx-auto" />
|
||||
<h3 class="text-n-slate-12 text-base text-center capitalize">
|
||||
{{ title }}
|
||||
</h3>
|
||||
|
||||
<div
|
||||
v-if="isComingSoon"
|
||||
class="absolute inset-0 flex items-center justify-center backdrop-blur-[2px] rounded-2xl bg-gradient-to-br from-n-background/90 via-n-background/70 to-n-background/95 cursor-not-allowed"
|
||||
class="absolute inset-0 flex items-center justify-center backdrop-blur-[2px] rounded-md bg-gradient-to-br from-n-background/90 via-n-background/70 to-n-background/95"
|
||||
>
|
||||
<span class="text-n-slate-12 font-medium text-sm">
|
||||
<span class="text-n-slate-12 font-medium text-base">
|
||||
{{ $t('CHANNEL_SELECTOR.COMING_SOON') }} 🚀
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.inactive {
|
||||
img {
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@apply border-n-strong shadow-none cursor-not-allowed;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import Copilot from 'dashboard/components-next/copilot/Copilot.vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
@@ -101,24 +100,20 @@ const handleReset = () => {
|
||||
};
|
||||
|
||||
const sendMessage = async message => {
|
||||
try {
|
||||
if (selectedCopilotThreadId.value) {
|
||||
await store.dispatch('copilotMessages/create', {
|
||||
assistant_id: activeAssistant.value.id,
|
||||
conversation_id: currentChat.value?.id,
|
||||
threadId: selectedCopilotThreadId.value,
|
||||
message,
|
||||
});
|
||||
} else {
|
||||
const response = await store.dispatch('copilotThreads/create', {
|
||||
assistant_id: activeAssistant.value.id,
|
||||
conversation_id: currentChat.value?.id,
|
||||
message,
|
||||
});
|
||||
selectedCopilotThreadId.value = response.id;
|
||||
}
|
||||
} catch (error) {
|
||||
useAlert(error.message);
|
||||
if (selectedCopilotThreadId.value) {
|
||||
await store.dispatch('copilotMessages/create', {
|
||||
assistant_id: activeAssistant.value.id,
|
||||
conversation_id: currentChat.value?.id,
|
||||
threadId: selectedCopilotThreadId.value,
|
||||
message,
|
||||
});
|
||||
} else {
|
||||
const response = await store.dispatch('copilotThreads/create', {
|
||||
assistant_id: activeAssistant.value.id,
|
||||
conversation_id: currentChat.value?.id,
|
||||
message,
|
||||
});
|
||||
selectedCopilotThreadId.value = response.id;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,75 +1,97 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
classObject() {
|
||||
return 'w-full';
|
||||
},
|
||||
activeIndex() {
|
||||
return this.items.findIndex(i => i.route === this.$route.name);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
isActive(item) {
|
||||
return this.items.indexOf(item) === this.activeIndex;
|
||||
},
|
||||
isOver(item) {
|
||||
return this.items.indexOf(item) < this.activeIndex;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const activeIndex = computed(() => {
|
||||
return props.items.findIndex(i => i.route === route.name);
|
||||
});
|
||||
|
||||
const isActive = item => {
|
||||
return props.items.indexOf(item) === activeIndex.value;
|
||||
};
|
||||
|
||||
const isOver = item => {
|
||||
return props.items.indexOf(item) < activeIndex.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<transition-group tag="div">
|
||||
<transition-group
|
||||
name="wizard-items"
|
||||
tag="div"
|
||||
class="wizard-box"
|
||||
:class="classObject"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in items"
|
||||
v-for="item in items"
|
||||
:key="item.route"
|
||||
class="cursor-pointer flex items-start gap-6 relative after:content-[''] after:absolute after:w-0.5 after:h-full after:top-5 ltr:after:left-4 rtl:after:right-4 before:content-[''] before:absolute before:w-0.5 before:h-4 before:top-0 before:left-4 rtl:before:right-4 last:after:hidden last:before:hidden"
|
||||
:class="
|
||||
isOver(item)
|
||||
? 'after:bg-n-blue-9 before:bg-n-blue-9'
|
||||
: 'after:bg-n-weak before:bg-n-weak'
|
||||
"
|
||||
class="item"
|
||||
:class="{ active: isActive(item), over: isOver(item) }"
|
||||
>
|
||||
<div
|
||||
class="rounded-2xl flex-shrink-0 size-8 flex items-center justify-center left-2 outline outline-2 leading-4 z-10 top-5 bg-n-background"
|
||||
:class="
|
||||
isActive(item) || isOver(item) ? 'outline-n-blue-9' : 'outline-n-weak'
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-bold"
|
||||
:class="
|
||||
isActive(item) || isOver(item)
|
||||
? 'text-n-blue-11'
|
||||
: 'text-n-slate-11'
|
||||
"
|
||||
<div class="flex items-center">
|
||||
<h3
|
||||
class="text-n-slate-12 text-base font-medium pl-6 overflow-hidden whitespace-nowrap mt-0.5 text-ellipsis leading-tight"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
{{ item.title }}
|
||||
</h3>
|
||||
<span v-if="isOver(item)" class="mx-1 mt-0.5 text-n-teal-9">
|
||||
<fluent-icon icon="checkmark" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-1.5 pb-10 pt-1">
|
||||
<div class="flex items-center">
|
||||
<h3
|
||||
class="text-sm font-medium overflow-hidden whitespace-nowrap mt-0.5 text-ellipsis leading-tight"
|
||||
:class="
|
||||
isActive(item) || isOver(item)
|
||||
? 'text-n-blue-11'
|
||||
: 'text-n-slate-12'
|
||||
"
|
||||
>
|
||||
{{ item.title }}
|
||||
</h3>
|
||||
</div>
|
||||
<p class="m-0 mt-1.5 text-sm text-n-slate-11">
|
||||
{{ item.body }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="step">
|
||||
{{ items.indexOf(item) + 1 }}
|
||||
</span>
|
||||
<p class="pl-6 m-0 mt-1.5 text-sm text-n-slate-11">
|
||||
{{ item.body }}
|
||||
</p>
|
||||
</div>
|
||||
</transition-group>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.wizard-box {
|
||||
.item {
|
||||
@apply cursor-pointer after:bg-n-slate-6 before:bg-n-slate-6 py-4 ltr:pr-4 rtl:pl-4 ltr:pl-6 rtl:pr-6 relative before:h-4 before:top-0 last:before:h-0 first:before:h-0 last:after:h-0 before:content-[''] before:absolute before:w-0.5 after:content-[''] after:h-full after:absolute after:top-5 after:w-0.5 rtl:after:left-6 rtl:before:left-6;
|
||||
|
||||
&.active {
|
||||
h3 {
|
||||
@apply text-n-blue-text dark:text-n-blue-text;
|
||||
}
|
||||
|
||||
.step {
|
||||
@apply bg-n-brand dark:bg-n-brand;
|
||||
}
|
||||
}
|
||||
|
||||
&.over {
|
||||
&::after {
|
||||
@apply bg-n-brand dark:bg-n-brand;
|
||||
}
|
||||
|
||||
.step {
|
||||
@apply bg-n-brand dark:bg-n-brand;
|
||||
}
|
||||
|
||||
& + .item {
|
||||
&::before {
|
||||
@apply bg-n-brand dark:bg-n-brand;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.step {
|
||||
@apply bg-n-slate-7 rounded-2xl font-medium w-4 left-4 leading-4 z-10 absolute text-center text-white dark:text-white text-xxs top-5;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,87 +1,91 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
<script>
|
||||
import ChannelSelector from '../ChannelSelector.vue';
|
||||
|
||||
const props = defineProps({
|
||||
channel: {
|
||||
type: Object,
|
||||
required: true,
|
||||
export default {
|
||||
components: { ChannelSelector },
|
||||
props: {
|
||||
channel: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
enabledFeatures: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
enabledFeatures: {
|
||||
type: Object,
|
||||
required: true,
|
||||
emits: ['channelItemClick'],
|
||||
computed: {
|
||||
hasFbConfigured() {
|
||||
return window.chatwootConfig?.fbAppId;
|
||||
},
|
||||
hasInstagramConfigured() {
|
||||
return window.chatwootConfig?.instagramAppId;
|
||||
},
|
||||
isActive() {
|
||||
const { key } = this.channel;
|
||||
if (Object.keys(this.enabledFeatures).length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (key === 'website') {
|
||||
return this.enabledFeatures.channel_website;
|
||||
}
|
||||
if (key === 'facebook') {
|
||||
return this.enabledFeatures.channel_facebook && this.hasFbConfigured;
|
||||
}
|
||||
if (key === 'email') {
|
||||
return this.enabledFeatures.channel_email;
|
||||
}
|
||||
|
||||
if (key === 'instagram') {
|
||||
return (
|
||||
this.enabledFeatures.channel_instagram && this.hasInstagramConfigured
|
||||
);
|
||||
}
|
||||
|
||||
if (key === 'voice') {
|
||||
return this.enabledFeatures.channel_voice;
|
||||
}
|
||||
|
||||
return [
|
||||
'website',
|
||||
'twilio',
|
||||
'api',
|
||||
'whatsapp',
|
||||
'sms',
|
||||
'telegram',
|
||||
'line',
|
||||
'instagram',
|
||||
'voice',
|
||||
].includes(key);
|
||||
},
|
||||
isComingSoon() {
|
||||
const { key } = this.channel;
|
||||
// Show "Coming Soon" only if the channel is marked as coming soon
|
||||
// and the corresponding feature flag is not enabled yet.
|
||||
return ['voice'].includes(key) && !this.isActive;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getChannelThumbnail() {
|
||||
if (this.channel.key === 'api' && this.channel.thumbnail) {
|
||||
return this.channel.thumbnail;
|
||||
}
|
||||
return `/assets/images/dashboard/channels/${this.channel.key}.png`;
|
||||
},
|
||||
onItemClick() {
|
||||
if (this.isActive) {
|
||||
this.$emit('channelItemClick', this.channel.key);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['channelItemClick']);
|
||||
|
||||
const hasFbConfigured = computed(() => {
|
||||
return window.chatwootConfig?.fbAppId;
|
||||
});
|
||||
|
||||
const hasInstagramConfigured = computed(() => {
|
||||
return window.chatwootConfig?.instagramAppId;
|
||||
});
|
||||
|
||||
const isActive = computed(() => {
|
||||
const { key } = props.channel;
|
||||
if (Object.keys(props.enabledFeatures).length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (key === 'website') {
|
||||
return props.enabledFeatures.channel_website;
|
||||
}
|
||||
if (key === 'facebook') {
|
||||
return props.enabledFeatures.channel_facebook && hasFbConfigured.value;
|
||||
}
|
||||
if (key === 'email') {
|
||||
return props.enabledFeatures.channel_email;
|
||||
}
|
||||
|
||||
if (key === 'instagram') {
|
||||
return (
|
||||
props.enabledFeatures.channel_instagram && hasInstagramConfigured.value
|
||||
);
|
||||
}
|
||||
|
||||
if (key === 'voice') {
|
||||
return props.enabledFeatures.channel_voice;
|
||||
}
|
||||
|
||||
return [
|
||||
'website',
|
||||
'twilio',
|
||||
'api',
|
||||
'whatsapp',
|
||||
'sms',
|
||||
'telegram',
|
||||
'line',
|
||||
'instagram',
|
||||
'voice',
|
||||
].includes(key);
|
||||
});
|
||||
|
||||
const isComingSoon = computed(() => {
|
||||
const { key } = props.channel;
|
||||
// Show "Coming Soon" only if the channel is marked as coming soon
|
||||
// and the corresponding feature flag is not enabled yet.
|
||||
return ['voice'].includes(key) && !isActive.value;
|
||||
});
|
||||
|
||||
const onItemClick = () => {
|
||||
if (isActive.value) {
|
||||
emit('channelItemClick', props.channel.key);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ChannelSelector
|
||||
:title="channel.title"
|
||||
:description="channel.description"
|
||||
:icon="channel.icon"
|
||||
:class="{ inactive: !isActive }"
|
||||
:title="channel.name"
|
||||
:src="getChannelThumbnail()"
|
||||
:is-coming-soon="isComingSoon"
|
||||
:disabled="!isActive"
|
||||
@click="onItemClick"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -13,7 +13,7 @@ defineProps({
|
||||
<div class="flex items-center text-n-slate-11 text-xs min-w-0">
|
||||
<ChannelIcon
|
||||
:inbox="inbox"
|
||||
class="size-3 ltr:mr-1 rtl:ml-1 flex-shrink-0"
|
||||
class="size-3 ltr:mr-0.5 rtl:ml-0.5 flex-shrink-0"
|
||||
/>
|
||||
<span class="truncate">
|
||||
{{ inbox.name }}
|
||||
|
||||
@@ -6,12 +6,15 @@ import FileUpload from 'vue-upload-component';
|
||||
import * as ActiveStorage from 'activestorage';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { getAllowedFileTypesByChannel } from '@chatwoot/utils';
|
||||
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
|
||||
import {
|
||||
ALLOWED_FILE_TYPES,
|
||||
ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP,
|
||||
ALLOWED_FILE_TYPES_FOR_LINE,
|
||||
ALLOWED_FILE_TYPES_FOR_INSTAGRAM,
|
||||
} from 'shared/constants/messages';
|
||||
import VideoCallButton from '../VideoCallButton.vue';
|
||||
import AIAssistanceButton from '../AIAssistanceButton.vue';
|
||||
import { REPLY_EDITOR_MODES } from './constants';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { mapGetters } from 'vuex';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
@@ -95,10 +98,6 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
enableContentTemplates: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
conversationId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
@@ -125,7 +124,6 @@ export default {
|
||||
'toggleInsertArticle',
|
||||
'toggleEditor',
|
||||
'selectWhatsappTemplate',
|
||||
'selectContentTemplate',
|
||||
],
|
||||
setup() {
|
||||
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
|
||||
@@ -157,11 +155,6 @@ export default {
|
||||
uploadRef,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
ALLOWED_FILE_TYPES,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
accountId: 'getCurrentAccountId',
|
||||
@@ -203,21 +196,17 @@ export default {
|
||||
return this.conversationType === 'instagram_direct_message';
|
||||
},
|
||||
allowedFileTypes() {
|
||||
// Use default file types for private notes
|
||||
if (this.isOnPrivateNote) {
|
||||
return this.ALLOWED_FILE_TYPES;
|
||||
if (this.isATwilioWhatsAppChannel) {
|
||||
return ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP;
|
||||
}
|
||||
if (this.isALineChannel) {
|
||||
return ALLOWED_FILE_TYPES_FOR_LINE;
|
||||
}
|
||||
|
||||
let channelType = this.channelType || this.inbox?.channel_type;
|
||||
|
||||
if (this.isAnInstagramChannel || this.isInstagramDM) {
|
||||
channelType = INBOX_TYPES.INSTAGRAM;
|
||||
return ALLOWED_FILE_TYPES_FOR_INSTAGRAM;
|
||||
}
|
||||
|
||||
return getAllowedFileTypesByChannel({
|
||||
channelType,
|
||||
medium: this.inbox?.medium,
|
||||
});
|
||||
return ALLOWED_FILE_TYPES;
|
||||
},
|
||||
enableDragAndDrop() {
|
||||
return !this.newConversationModalActive;
|
||||
@@ -352,15 +341,6 @@ export default {
|
||||
sm
|
||||
@click="$emit('selectWhatsappTemplate')"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="enableContentTemplates"
|
||||
v-tooltip.top-end="'Content Templates'"
|
||||
icon="i-ph-whatsapp-logo"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
@click="$emit('selectContentTemplate')"
|
||||
/>
|
||||
<VideoCallButton
|
||||
v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote"
|
||||
:conversation-id="conversationId"
|
||||
@@ -375,7 +355,7 @@ export default {
|
||||
<transition name="modal-fade">
|
||||
<div
|
||||
v-show="uploadRef && uploadRef.dropActive"
|
||||
class="flex fixed top-0 right-0 bottom-0 left-0 z-20 flex-col gap-2 justify-center items-center w-full h-full text-n-slate-12 bg-modal-backdrop-light dark:bg-modal-backdrop-dark"
|
||||
class="fixed top-0 bottom-0 left-0 right-0 z-20 flex flex-col items-center justify-center w-full h-full gap-2 text-n-slate-12 bg-modal-backdrop-light dark:bg-modal-backdrop-dark"
|
||||
>
|
||||
<fluent-icon icon="cloud-backup" size="40" />
|
||||
<h4 class="text-2xl break-words text-n-slate-12">
|
||||
|
||||
-97
@@ -1,97 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import TemplatesPicker from './ContentTemplatesPicker.vue';
|
||||
import TemplateParser from '../../../../components-next/content-templates/ContentTemplateParser.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
inboxId: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['onSend', 'cancel', 'update:show']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const selectedContentTemplate = ref(null);
|
||||
|
||||
const localShow = computed({
|
||||
get() {
|
||||
return props.show;
|
||||
},
|
||||
set(value) {
|
||||
emit('update:show', value);
|
||||
},
|
||||
});
|
||||
|
||||
const modalHeaderContent = computed(() => {
|
||||
return selectedContentTemplate.value
|
||||
? t('CONTENT_TEMPLATES.MODAL.TEMPLATE_SELECTED_SUBTITLE', {
|
||||
templateName: selectedContentTemplate.value.friendly_name,
|
||||
})
|
||||
: t('CONTENT_TEMPLATES.MODAL.SUBTITLE');
|
||||
});
|
||||
|
||||
const pickTemplate = template => {
|
||||
selectedContentTemplate.value = template;
|
||||
};
|
||||
|
||||
const onResetTemplate = () => {
|
||||
selectedContentTemplate.value = null;
|
||||
};
|
||||
|
||||
const onSendMessage = message => {
|
||||
emit('onSend', message);
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
emit('cancel');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<woot-modal v-model:show="localShow" :on-close="onClose" size="modal-big">
|
||||
<woot-modal-header
|
||||
:header-title="$t('CONTENT_TEMPLATES.MODAL.TITLE')"
|
||||
:header-content="modalHeaderContent"
|
||||
/>
|
||||
<div class="px-8 py-6 row">
|
||||
<TemplatesPicker
|
||||
v-if="!selectedContentTemplate"
|
||||
:inbox-id="inboxId"
|
||||
@on-select="pickTemplate"
|
||||
/>
|
||||
<TemplateParser
|
||||
v-else
|
||||
:template="selectedContentTemplate"
|
||||
@reset-template="onResetTemplate"
|
||||
@send-message="onSendMessage"
|
||||
>
|
||||
<template #actions="{ sendMessage, resetTemplate, disabled }">
|
||||
<div class="flex gap-2 mt-6">
|
||||
<Button
|
||||
:label="t('CONTENT_TEMPLATES.PARSER.GO_BACK_LABEL')"
|
||||
color="slate"
|
||||
variant="faded"
|
||||
class="flex-1"
|
||||
@click="resetTemplate"
|
||||
/>
|
||||
<Button
|
||||
:label="t('CONTENT_TEMPLATES.PARSER.SEND_MESSAGE_LABEL')"
|
||||
class="flex-1"
|
||||
:disabled="disabled"
|
||||
@click="sendMessage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</TemplateParser>
|
||||
</div>
|
||||
</woot-modal>
|
||||
</template>
|
||||
-169
@@ -1,169 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { TWILIO_CONTENT_TEMPLATE_TYPES } from 'shared/constants/messages';
|
||||
|
||||
const props = defineProps({
|
||||
inboxId: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['onSelect']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const query = ref('');
|
||||
const isRefreshing = ref(false);
|
||||
|
||||
const twilioTemplates = computed(() => {
|
||||
const inbox = store.getters['inboxes/getInbox'](props.inboxId);
|
||||
return inbox?.content_templates?.templates || [];
|
||||
});
|
||||
|
||||
const filteredTemplateMessages = computed(() =>
|
||||
twilioTemplates.value.filter(
|
||||
template =>
|
||||
template.friendly_name
|
||||
.toLowerCase()
|
||||
.includes(query.value.toLowerCase()) && template.status === 'approved'
|
||||
)
|
||||
);
|
||||
|
||||
const getTemplateType = template => {
|
||||
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.MEDIA) {
|
||||
return t('CONTENT_TEMPLATES.PICKER.TYPES.MEDIA');
|
||||
}
|
||||
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.QUICK_REPLY) {
|
||||
return t('CONTENT_TEMPLATES.PICKER.TYPES.QUICK_REPLY');
|
||||
}
|
||||
return t('CONTENT_TEMPLATES.PICKER.TYPES.TEXT');
|
||||
};
|
||||
|
||||
const refreshTemplates = async () => {
|
||||
isRefreshing.value = true;
|
||||
try {
|
||||
await store.dispatch('inboxes/syncTemplates', props.inboxId);
|
||||
useAlert(t('CONTENT_TEMPLATES.PICKER.REFRESH_SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(t('CONTENT_TEMPLATES.PICKER.REFRESH_ERROR'));
|
||||
} finally {
|
||||
isRefreshing.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="flex gap-2 mb-2.5">
|
||||
<div
|
||||
class="flex flex-1 gap-1 items-center px-2.5 py-0 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 focus-within:outline-n-brand dark:focus-within:outline-n-brand"
|
||||
>
|
||||
<fluent-icon icon="search" class="text-n-slate-12" size="16" />
|
||||
<input
|
||||
v-model="query"
|
||||
type="search"
|
||||
:placeholder="t('CONTENT_TEMPLATES.PICKER.SEARCH_PLACEHOLDER')"
|
||||
class="reset-base w-full h-9 bg-transparent text-n-slate-12 !text-sm !outline-0"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
:disabled="isRefreshing"
|
||||
class="flex justify-center items-center w-9 h-9 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 hover:bg-n-alpha-2 dark:hover:bg-n-solid-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:title="t('CONTENT_TEMPLATES.PICKER.REFRESH_BUTTON')"
|
||||
@click="refreshTemplates"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-refresh-ccw"
|
||||
class="text-n-slate-12 size-4"
|
||||
:class="{ 'animate-spin': isRefreshing }"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="bg-n-background outline-n-container outline outline-1 rounded-lg max-h-[18.75rem] overflow-y-auto p-2.5"
|
||||
>
|
||||
<div
|
||||
v-for="(template, i) in filteredTemplateMessages"
|
||||
:key="template.content_sid"
|
||||
>
|
||||
<button
|
||||
class="block p-2.5 w-full text-left rounded-lg cursor-pointer hover:bg-n-alpha-2 dark:hover:bg-n-solid-2"
|
||||
@click="emit('onSelect', template)"
|
||||
>
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-2.5">
|
||||
<p class="text-sm">
|
||||
{{ template.friendly_name }}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<span
|
||||
class="inline-block px-2 py-1 text-xs leading-none rounded-lg cursor-default bg-n-slate-3 text-n-slate-12"
|
||||
>
|
||||
{{ getTemplateType(template) }}
|
||||
</span>
|
||||
<span
|
||||
class="inline-block px-2 py-1 text-xs leading-none rounded-lg cursor-default bg-n-slate-3 text-n-slate-12"
|
||||
>
|
||||
{{
|
||||
`${t('CONTENT_TEMPLATES.PICKER.LABELS.LANGUAGE')}: ${template.language}`
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div>
|
||||
<p class="text-xs font-medium text-n-slate-11">
|
||||
{{ t('CONTENT_TEMPLATES.PICKER.BODY') }}
|
||||
</p>
|
||||
<p class="text-sm label-body">
|
||||
{{ template.body || t('CONTENT_TEMPLATES.PICKER.NO_CONTENT') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center mt-3">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-n-slate-11">
|
||||
{{ t('CONTENT_TEMPLATES.PICKER.LABELS.CATEGORY') }}
|
||||
</p>
|
||||
<p class="text-sm">{{ template.category || 'utility' }}</p>
|
||||
</div>
|
||||
<div class="text-xs text-n-slate-11">
|
||||
{{ new Date(template.created_at).toLocaleDateString() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<hr
|
||||
v-if="i != filteredTemplateMessages.length - 1"
|
||||
:key="`hr-${i}`"
|
||||
class="border-b border-solid border-n-weak my-2.5 mx-auto max-w-[95%]"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!filteredTemplateMessages.length" class="py-8 text-center">
|
||||
<div v-if="query && twilioTemplates.length">
|
||||
<p>
|
||||
{{ t('CONTENT_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
|
||||
<strong>{{ query }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div v-else-if="!twilioTemplates.length" class="space-y-4">
|
||||
<p class="text-n-slate-11">
|
||||
{{ t('CONTENT_TEMPLATES.PICKER.NO_TEMPLATES_AVAILABLE') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.label-body {
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
@@ -250,6 +250,7 @@ const deleteConversation = () => {
|
||||
:src="currentContact.thumbnail"
|
||||
:size="32"
|
||||
:status="currentContact.availability_status"
|
||||
:inbox="inbox"
|
||||
:class="!showInboxName ? 'mt-4' : 'mt-8'"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
|
||||
@@ -15,7 +15,7 @@ import ReplyEmailHead from './ReplyEmailHead.vue';
|
||||
import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue';
|
||||
import ArticleSearchPopover from 'dashboard/routes/dashboard/helpcenter/components/ArticleSearch/SearchPopover.vue';
|
||||
import MessageSignatureMissingAlert from './MessageSignatureMissingAlert.vue';
|
||||
import ReplyBoxBanner from './ReplyBoxBanner.vue';
|
||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||
import AudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder.vue';
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
replaceVariablesInMessage,
|
||||
} from '@chatwoot/utils';
|
||||
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
|
||||
import ContentTemplates from './ContentTemplates/ContentTemplatesModal.vue';
|
||||
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
|
||||
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
|
||||
import { trimContent, debounce, getRecipients } from '@chatwoot/utils';
|
||||
@@ -53,8 +52,8 @@ export default {
|
||||
ArticleSearchPopover,
|
||||
AttachmentPreview,
|
||||
AudioRecorder,
|
||||
Banner,
|
||||
CannedResponse,
|
||||
ReplyBoxBanner,
|
||||
EmojiInput,
|
||||
MessageSignatureMissingAlert,
|
||||
ReplyBottomPanel,
|
||||
@@ -62,7 +61,6 @@ export default {
|
||||
ReplyToMessage,
|
||||
ReplyTopPanel,
|
||||
ResizableTextArea,
|
||||
ContentTemplates,
|
||||
WhatsappTemplates,
|
||||
WootMessageEditor,
|
||||
},
|
||||
@@ -111,7 +109,6 @@ export default {
|
||||
toEmails: '',
|
||||
doAutoSaveDraft: () => {},
|
||||
showWhatsAppTemplatesModal: false,
|
||||
showContentTemplatesModal: false,
|
||||
updateEditorSelectionWith: '',
|
||||
undefinedVariableMessage: '',
|
||||
showMentions: false,
|
||||
@@ -158,12 +155,38 @@ export default {
|
||||
|
||||
return false;
|
||||
},
|
||||
assignedAgent: {
|
||||
get() {
|
||||
return this.currentChat.meta.assignee;
|
||||
},
|
||||
set(agent) {
|
||||
const agentId = agent ? agent.id : 0;
|
||||
this.$store.dispatch('setCurrentChatAssignee', agent);
|
||||
this.$store
|
||||
.dispatch('assignAgent', {
|
||||
conversationId: this.currentChat.id,
|
||||
agentId,
|
||||
})
|
||||
.then(() => {
|
||||
useAlert(this.$t('CONVERSATION.CHANGE_AGENT'));
|
||||
});
|
||||
},
|
||||
},
|
||||
showSelfAssignBanner() {
|
||||
if (this.message !== '' && !this.isOnPrivateNote) {
|
||||
if (!this.assignedAgent) {
|
||||
return true;
|
||||
}
|
||||
if (this.assignedAgent.id !== this.currentUser.id) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
showWhatsappTemplates() {
|
||||
return this.isAWhatsAppCloudChannel && !this.isPrivate;
|
||||
},
|
||||
showContentTemplates() {
|
||||
return this.isATwilioWhatsAppChannel && !this.isPrivate;
|
||||
},
|
||||
isPrivate() {
|
||||
if (this.currentChat.can_reply || this.isAWhatsAppChannel) {
|
||||
return this.isOnPrivateNote;
|
||||
@@ -636,11 +659,28 @@ export default {
|
||||
hideWhatsappTemplatesModal() {
|
||||
this.showWhatsAppTemplatesModal = false;
|
||||
},
|
||||
openContentTemplateModal() {
|
||||
this.showContentTemplatesModal = true;
|
||||
},
|
||||
hideContentTemplatesModal() {
|
||||
this.showContentTemplatesModal = false;
|
||||
onClickSelfAssign() {
|
||||
const {
|
||||
account_id,
|
||||
availability_status,
|
||||
available_name,
|
||||
email,
|
||||
id,
|
||||
name,
|
||||
role,
|
||||
avatar_url,
|
||||
} = this.currentUser;
|
||||
const selfAssign = {
|
||||
account_id,
|
||||
availability_status,
|
||||
available_name,
|
||||
email,
|
||||
id,
|
||||
name,
|
||||
role,
|
||||
thumbnail: avatar_url,
|
||||
};
|
||||
this.assignedAgent = selfAssign;
|
||||
},
|
||||
confirmOnSendReply() {
|
||||
if (this.isReplyButtonDisabled) {
|
||||
@@ -734,13 +774,6 @@ export default {
|
||||
});
|
||||
this.hideWhatsappTemplatesModal();
|
||||
},
|
||||
async onSendContentTemplateReply(messagePayload) {
|
||||
this.sendMessage({
|
||||
conversationId: this.currentChat.id,
|
||||
...messagePayload,
|
||||
});
|
||||
this.hideContentTemplatesModal();
|
||||
},
|
||||
replaceText(message) {
|
||||
if (this.sendWithSignature && !this.private) {
|
||||
// if signature is enabled, append it to the message
|
||||
@@ -760,10 +793,6 @@ export default {
|
||||
}, 100);
|
||||
},
|
||||
setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) {
|
||||
// Clear attachments when switching between private note and reply modes
|
||||
// This is to prevent from breaking the upload rules
|
||||
if (this.attachedFiles.length > 0) this.attachedFiles = [];
|
||||
|
||||
const { can_reply: canReply } = this.currentChat;
|
||||
this.$store.dispatch('draftMessages/setReplyEditorMode', {
|
||||
mode,
|
||||
@@ -1066,7 +1095,16 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ReplyBoxBanner :message="message" :is-on-private-note="isOnPrivateNote" />
|
||||
<Banner
|
||||
v-if="showSelfAssignBanner"
|
||||
action-button-variant="ghost"
|
||||
color-scheme="secondary"
|
||||
class="mx-2 mb-2 rounded-lg banner--self-assign"
|
||||
:banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')"
|
||||
has-action-button
|
||||
:action-button-label="$t('CONVERSATION.ASSIGN_TO_ME')"
|
||||
@primary-action="onClickSelfAssign"
|
||||
/>
|
||||
<div ref="replyEditor" class="reply-box" :class="replyBoxClass">
|
||||
<ReplyTopPanel
|
||||
:mode="replyType"
|
||||
@@ -1175,7 +1213,6 @@ export default {
|
||||
:conversation-id="conversationId"
|
||||
:enable-multiple-file-upload="enableMultipleFileUpload"
|
||||
:enable-whats-app-templates="showWhatsappTemplates"
|
||||
:enable-content-templates="showContentTemplates"
|
||||
:inbox="inbox"
|
||||
:is-on-private-note="isOnPrivateNote"
|
||||
:is-recording-audio="isRecordingAudio"
|
||||
@@ -1198,7 +1235,6 @@ export default {
|
||||
:portal-slug="connectedPortalSlug"
|
||||
:new-conversation-modal-active="newConversationModalActive"
|
||||
@select-whatsapp-template="openWhatsappTemplateModal"
|
||||
@select-content-template="openContentTemplateModal"
|
||||
@toggle-editor="toggleRichContentEditor"
|
||||
@replace-text="replaceText"
|
||||
@toggle-insert-article="toggleInsertArticle"
|
||||
@@ -1211,14 +1247,6 @@ export default {
|
||||
@cancel="hideWhatsappTemplatesModal"
|
||||
/>
|
||||
|
||||
<ContentTemplates
|
||||
:inbox-id="inbox.id"
|
||||
:show="showContentTemplatesModal"
|
||||
@close="hideContentTemplatesModal"
|
||||
@on-send="onSendContentTemplateReply"
|
||||
@cancel="hideContentTemplatesModal"
|
||||
/>
|
||||
|
||||
<woot-confirm-modal
|
||||
ref="confirmDialog"
|
||||
:title="$t('CONVERSATION.REPLYBOX.UNDEFINED_VARIABLES.TITLE')"
|
||||
@@ -1232,6 +1260,10 @@ export default {
|
||||
@apply mb-0;
|
||||
}
|
||||
|
||||
.banner--self-assign {
|
||||
@apply py-2;
|
||||
}
|
||||
|
||||
.attachment-preview-box {
|
||||
@apply bg-transparent py-0 px-4;
|
||||
}
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isOnPrivateNote: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
|
||||
const assignedAgent = computed({
|
||||
get() {
|
||||
return currentChat.value?.meta?.assignee;
|
||||
},
|
||||
set(agent) {
|
||||
const agentId = agent ? agent.id : 0;
|
||||
store.dispatch('setCurrentChatAssignee', agent);
|
||||
store.dispatch('assignAgent', {
|
||||
conversationId: currentChat.value?.id,
|
||||
agentId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isUserTyping = computed(
|
||||
() => props.message !== '' && !props.isOnPrivateNote
|
||||
);
|
||||
const isUnassigned = computed(() => !assignedAgent.value);
|
||||
const isAssignedToOtherAgent = computed(
|
||||
() => assignedAgent.value?.id !== currentUser.value?.id
|
||||
);
|
||||
|
||||
const showSelfAssignBanner = computed(() => {
|
||||
return (
|
||||
isUserTyping.value && (isUnassigned.value || isAssignedToOtherAgent.value)
|
||||
);
|
||||
});
|
||||
|
||||
const showBotHandoffBanner = computed(
|
||||
() =>
|
||||
isUserTyping.value &&
|
||||
currentChat.value?.status === wootConstants.STATUS_TYPE.PENDING
|
||||
);
|
||||
|
||||
const botHandoffActionLabel = computed(() => {
|
||||
return assignedAgent.value?.id === currentUser.value?.id
|
||||
? t('CONVERSATION.BOT_HANDOFF_REOPEN_ACTION')
|
||||
: t('CONVERSATION.BOT_HANDOFF_ACTION');
|
||||
});
|
||||
|
||||
const selfAssignConversation = async () => {
|
||||
const { avatar_url, ...rest } = currentUser.value || {};
|
||||
assignedAgent.value = { ...rest, thumbnail: avatar_url };
|
||||
};
|
||||
|
||||
const needsAssignmentToCurrentUser = computed(() => {
|
||||
return isUnassigned.value || isAssignedToOtherAgent.value;
|
||||
});
|
||||
|
||||
const onClickSelfAssign = async () => {
|
||||
try {
|
||||
await selfAssignConversation();
|
||||
useAlert(t('CONVERSATION.CHANGE_AGENT'));
|
||||
} catch (error) {
|
||||
useAlert(t('CONVERSATION.CHANGE_AGENT_FAILED'));
|
||||
}
|
||||
};
|
||||
|
||||
const reopenConversation = async () => {
|
||||
await store.dispatch('toggleStatus', {
|
||||
conversationId: currentChat.value?.id,
|
||||
status: wootConstants.STATUS_TYPE.OPEN,
|
||||
});
|
||||
};
|
||||
|
||||
const onClickBotHandoff = async () => {
|
||||
try {
|
||||
await reopenConversation();
|
||||
|
||||
if (needsAssignmentToCurrentUser.value) {
|
||||
await selfAssignConversation();
|
||||
}
|
||||
|
||||
useAlert(t('CONVERSATION.BOT_HANDOFF_SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(t('CONVERSATION.BOT_HANDOFF_ERROR'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Banner
|
||||
v-if="showSelfAssignBanner && !showBotHandoffBanner"
|
||||
action-button-variant="ghost"
|
||||
color-scheme="secondary"
|
||||
class="mx-2 mb-2 rounded-lg !py-2"
|
||||
:banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')"
|
||||
has-action-button
|
||||
:action-button-label="$t('CONVERSATION.ASSIGN_TO_ME')"
|
||||
@primary-action="onClickSelfAssign"
|
||||
/>
|
||||
<Banner
|
||||
v-if="showBotHandoffBanner"
|
||||
action-button-variant="ghost"
|
||||
color-scheme="secondary"
|
||||
class="mx-2 mb-2 rounded-lg !py-2"
|
||||
:banner-message="$t('CONVERSATION.BOT_HANDOFF_MESSAGE')"
|
||||
has-action-button
|
||||
:action-button-label="botHandoffActionLabel"
|
||||
@primary-action="onClickBotHandoff"
|
||||
/>
|
||||
</template>
|
||||
+3
-3
@@ -1,10 +1,10 @@
|
||||
<script>
|
||||
import TemplatesPicker from './TemplatesPicker.vue';
|
||||
import WhatsAppTemplateReply from './WhatsAppTemplateReply.vue';
|
||||
import TemplateParser from './TemplateParser.vue';
|
||||
export default {
|
||||
components: {
|
||||
TemplatesPicker,
|
||||
WhatsAppTemplateReply,
|
||||
TemplateParser,
|
||||
},
|
||||
props: {
|
||||
show: {
|
||||
@@ -68,7 +68,7 @@ export default {
|
||||
:inbox-id="inboxId"
|
||||
@on-select="pickTemplate"
|
||||
/>
|
||||
<WhatsAppTemplateReply
|
||||
<TemplateParser
|
||||
v-else
|
||||
:template="selectedWaTemplate"
|
||||
@reset-template="onResetTemplate"
|
||||
|
||||
+9
@@ -1,4 +1,13 @@
|
||||
<script setup>
|
||||
/**
|
||||
* This component handles parsing and sending WhatsApp message templates.
|
||||
* It works as follows:
|
||||
* 1. Displays the template text with variable placeholders.
|
||||
* 2. Generates input fields for each variable in the template.
|
||||
* 3. Validates that all variables are filled before sending.
|
||||
* 4. Replaces placeholders with user-provided values.
|
||||
* 5. Emits events to send the processed message or reset the template.
|
||||
*/
|
||||
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { DirectUpload } from 'activestorage';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { getMaxUploadSizeByChannel } from '@chatwoot/utils';
|
||||
import { MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL } from 'shared/constants/messages';
|
||||
|
||||
vi.mock('dashboard/composables/store');
|
||||
vi.mock('dashboard/composables', () => ({
|
||||
@@ -13,7 +13,6 @@ vi.mock('dashboard/composables', () => ({
|
||||
vi.mock('vue-i18n');
|
||||
vi.mock('activestorage');
|
||||
vi.mock('shared/helpers/FileHelper');
|
||||
vi.mock('@chatwoot/utils');
|
||||
|
||||
describe('useFileUpload', () => {
|
||||
const mockAttachFile = vi.fn();
|
||||
@@ -23,11 +22,6 @@ describe('useFileUpload', () => {
|
||||
file: new File(['test'], 'test.jpg', { type: 'image/jpeg' }),
|
||||
};
|
||||
|
||||
const inbox = {
|
||||
channel_type: 'Channel::WhatsApp',
|
||||
medium: 'whatsapp',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -43,12 +37,11 @@ describe('useFileUpload', () => {
|
||||
|
||||
useI18n.mockReturnValue({ t: mockTranslate });
|
||||
checkFileSizeLimit.mockReturnValue(true);
|
||||
getMaxUploadSizeByChannel.mockReturnValue(25); // default max size MB for tests
|
||||
});
|
||||
|
||||
it('handles direct file upload when direct uploads enabled', () => {
|
||||
it('should handle direct file upload when enabled', () => {
|
||||
const { onFileUpload } = useFileUpload({
|
||||
inbox,
|
||||
isATwilioSMSChannel: false,
|
||||
attachFile: mockAttachFile,
|
||||
});
|
||||
|
||||
@@ -59,16 +52,6 @@ describe('useFileUpload', () => {
|
||||
|
||||
onFileUpload(mockFile);
|
||||
|
||||
// size rules called with inbox + mime
|
||||
expect(getMaxUploadSizeByChannel).toHaveBeenCalledWith({
|
||||
channelType: inbox.channel_type,
|
||||
medium: inbox.medium,
|
||||
mime: 'image/jpeg',
|
||||
});
|
||||
|
||||
// size check called with max from helper
|
||||
expect(checkFileSizeLimit).toHaveBeenCalledWith(mockFile, 25);
|
||||
|
||||
expect(DirectUpload).toHaveBeenCalledWith(
|
||||
mockFile.file,
|
||||
'/api/v1/accounts/123/conversations/456/direct_uploads',
|
||||
@@ -80,7 +63,7 @@ describe('useFileUpload', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('handles indirect file upload when direct upload disabled', () => {
|
||||
it('should handle indirect file upload when direct upload is disabled', () => {
|
||||
useMapGetter.mockImplementation(getter => {
|
||||
const getterMap = {
|
||||
getCurrentAccountId: { value: '123' },
|
||||
@@ -92,24 +75,22 @@ describe('useFileUpload', () => {
|
||||
});
|
||||
|
||||
const { onFileUpload } = useFileUpload({
|
||||
inbox,
|
||||
isATwilioSMSChannel: false,
|
||||
attachFile: mockAttachFile,
|
||||
});
|
||||
|
||||
onFileUpload(mockFile);
|
||||
|
||||
expect(DirectUpload).not.toHaveBeenCalled();
|
||||
expect(getMaxUploadSizeByChannel).toHaveBeenCalled();
|
||||
expect(checkFileSizeLimit).toHaveBeenCalledWith(mockFile, 25);
|
||||
expect(mockAttachFile).toHaveBeenCalledWith({ file: mockFile });
|
||||
});
|
||||
|
||||
it('shows alert when file size exceeds limit', () => {
|
||||
it('should show alert when file size exceeds limit', () => {
|
||||
checkFileSizeLimit.mockReturnValue(false);
|
||||
mockTranslate.mockReturnValue('File size exceeds limit');
|
||||
|
||||
const { onFileUpload } = useFileUpload({
|
||||
inbox,
|
||||
isATwilioSMSChannel: false,
|
||||
attachFile: mockAttachFile,
|
||||
});
|
||||
|
||||
@@ -119,37 +100,28 @@ describe('useFileUpload', () => {
|
||||
expect(mockAttachFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses per-mime limits from helper', () => {
|
||||
getMaxUploadSizeByChannel.mockImplementation(({ mime }) =>
|
||||
mime.startsWith('image/') ? 10 : 50
|
||||
);
|
||||
it('should use different max file size for Twilio SMS channel', () => {
|
||||
const { onFileUpload } = useFileUpload({
|
||||
inbox,
|
||||
isATwilioSMSChannel: true,
|
||||
attachFile: mockAttachFile,
|
||||
});
|
||||
|
||||
DirectUpload.mockImplementation(() => ({
|
||||
create: cb => cb(null, { signed_id: 'blob' }),
|
||||
}));
|
||||
|
||||
onFileUpload(mockFile);
|
||||
|
||||
expect(getMaxUploadSizeByChannel).toHaveBeenCalledWith({
|
||||
channelType: inbox.channel_type,
|
||||
medium: inbox.medium,
|
||||
mime: 'image/jpeg',
|
||||
});
|
||||
expect(checkFileSizeLimit).toHaveBeenCalledWith(mockFile, 10);
|
||||
expect(checkFileSizeLimit).toHaveBeenCalledWith(
|
||||
mockFile,
|
||||
MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL
|
||||
);
|
||||
});
|
||||
|
||||
it('handles direct upload errors', () => {
|
||||
it('should handle direct upload errors', () => {
|
||||
const mockError = 'Upload failed';
|
||||
DirectUpload.mockImplementation(() => ({
|
||||
create: callback => callback(mockError, null),
|
||||
}));
|
||||
|
||||
const { onFileUpload } = useFileUpload({
|
||||
inbox,
|
||||
isATwilioSMSChannel: false,
|
||||
attachFile: mockAttachFile,
|
||||
});
|
||||
|
||||
@@ -159,16 +131,15 @@ describe('useFileUpload', () => {
|
||||
expect(mockAttachFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when file is null', () => {
|
||||
it('should do nothing when file is null', () => {
|
||||
const { onFileUpload } = useFileUpload({
|
||||
inbox,
|
||||
isATwilioSMSChannel: false,
|
||||
attachFile: mockAttachFile,
|
||||
});
|
||||
|
||||
onFileUpload(null);
|
||||
|
||||
expect(checkFileSizeLimit).not.toHaveBeenCalled();
|
||||
expect(getMaxUploadSizeByChannel).not.toHaveBeenCalled();
|
||||
expect(mockAttachFile).not.toHaveBeenCalled();
|
||||
expect(useAlert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { createStore } from 'vuex';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { useInbox } from '../useInbox';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
|
||||
vi.mock('dashboard/composables/store');
|
||||
vi.mock('dashboard/composables/useTransformKeys');
|
||||
|
||||
// Mock the dependencies
|
||||
const mockStore = createStore({
|
||||
modules: {
|
||||
conversations: {
|
||||
namespaced: false,
|
||||
getters: {
|
||||
getSelectedChat: () => ({ inbox_id: 1 }),
|
||||
},
|
||||
},
|
||||
inboxes: {
|
||||
namespaced: true,
|
||||
getters: {
|
||||
getInboxById: () => id => {
|
||||
const inboxes = {
|
||||
1: {
|
||||
id: 1,
|
||||
channel_type: INBOX_TYPES.WHATSAPP,
|
||||
provider: 'whatsapp_cloud',
|
||||
},
|
||||
2: { id: 2, channel_type: INBOX_TYPES.FB },
|
||||
3: { id: 3, channel_type: INBOX_TYPES.TWILIO, medium: 'sms' },
|
||||
4: { id: 4, channel_type: INBOX_TYPES.TWILIO, medium: 'whatsapp' },
|
||||
5: {
|
||||
id: 5,
|
||||
channel_type: INBOX_TYPES.EMAIL,
|
||||
provider: 'microsoft',
|
||||
},
|
||||
6: { id: 6, channel_type: INBOX_TYPES.EMAIL, provider: 'google' },
|
||||
7: {
|
||||
id: 7,
|
||||
channel_type: INBOX_TYPES.WHATSAPP,
|
||||
provider: 'default',
|
||||
},
|
||||
8: { id: 8, channel_type: INBOX_TYPES.TELEGRAM },
|
||||
9: { id: 9, channel_type: INBOX_TYPES.LINE },
|
||||
10: { id: 10, channel_type: INBOX_TYPES.WEB },
|
||||
11: { id: 11, channel_type: INBOX_TYPES.API },
|
||||
12: { id: 12, channel_type: INBOX_TYPES.SMS },
|
||||
13: { id: 13, channel_type: INBOX_TYPES.INSTAGRAM },
|
||||
14: { id: 14, channel_type: INBOX_TYPES.VOICE },
|
||||
};
|
||||
return inboxes[id] || null;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Mock useMapGetter to return mock store getters
|
||||
vi.mock('dashboard/composables/store', () => ({
|
||||
useMapGetter: vi.fn(getter => {
|
||||
if (getter === 'getSelectedChat') {
|
||||
return { value: { inbox_id: 1 } };
|
||||
}
|
||||
if (getter === 'inboxes/getInboxById') {
|
||||
return { value: mockStore.getters['inboxes/getInboxById'] };
|
||||
}
|
||||
return { value: null };
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock useCamelCase to return the data as-is for testing
|
||||
vi.mock('dashboard/composables/useTransformKeys', () => ({
|
||||
useCamelCase: vi.fn(data => ({
|
||||
...data,
|
||||
channelType: data?.channel_type,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('useInbox', () => {
|
||||
const createTestComponent = inboxId =>
|
||||
defineComponent({
|
||||
setup() {
|
||||
return useInbox(inboxId);
|
||||
},
|
||||
render() {
|
||||
return h('div');
|
||||
},
|
||||
});
|
||||
|
||||
describe('with current chat context (no inboxId provided)', () => {
|
||||
it('identifies WhatsApp Cloud channel correctly', () => {
|
||||
const wrapper = mount(createTestComponent(), {
|
||||
global: { plugins: [mockStore] },
|
||||
});
|
||||
|
||||
expect(wrapper.vm.isAWhatsAppCloudChannel).toBe(true);
|
||||
expect(wrapper.vm.isAWhatsAppChannel).toBe(true);
|
||||
});
|
||||
|
||||
it('returns correct inbox object', () => {
|
||||
const wrapper = mount(createTestComponent(), {
|
||||
global: { plugins: [mockStore] },
|
||||
});
|
||||
|
||||
expect(wrapper.vm.inbox).toEqual({
|
||||
id: 1,
|
||||
channel_type: INBOX_TYPES.WHATSAPP,
|
||||
provider: 'whatsapp_cloud',
|
||||
channelType: INBOX_TYPES.WHATSAPP,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with explicit inboxId provided', () => {
|
||||
it('identifies Facebook inbox correctly', () => {
|
||||
const wrapper = mount(createTestComponent(2), {
|
||||
global: { plugins: [mockStore] },
|
||||
});
|
||||
|
||||
expect(wrapper.vm.isAFacebookInbox).toBe(true);
|
||||
expect(wrapper.vm.isAWhatsAppChannel).toBe(false);
|
||||
});
|
||||
|
||||
it('identifies Twilio SMS channel correctly', () => {
|
||||
const wrapper = mount(createTestComponent(3), {
|
||||
global: { plugins: [mockStore] },
|
||||
});
|
||||
|
||||
expect(wrapper.vm.isATwilioChannel).toBe(true);
|
||||
expect(wrapper.vm.isASmsInbox).toBe(true);
|
||||
expect(wrapper.vm.isAWhatsAppChannel).toBe(false);
|
||||
});
|
||||
|
||||
it('identifies Twilio WhatsApp channel correctly', () => {
|
||||
const wrapper = mount(createTestComponent(4), {
|
||||
global: { plugins: [mockStore] },
|
||||
});
|
||||
|
||||
expect(wrapper.vm.isATwilioChannel).toBe(true);
|
||||
expect(wrapper.vm.isAWhatsAppChannel).toBe(true);
|
||||
expect(wrapper.vm.isATwilioWhatsAppChannel).toBe(true);
|
||||
expect(wrapper.vm.isAWhatsAppCloudChannel).toBe(false);
|
||||
});
|
||||
|
||||
it('identifies Microsoft email inbox correctly', () => {
|
||||
const wrapper = mount(createTestComponent(5), {
|
||||
global: { plugins: [mockStore] },
|
||||
});
|
||||
|
||||
expect(wrapper.vm.isAnEmailChannel).toBe(true);
|
||||
expect(wrapper.vm.isAMicrosoftInbox).toBe(true);
|
||||
expect(wrapper.vm.isAGoogleInbox).toBe(false);
|
||||
});
|
||||
|
||||
it('identifies Google email inbox correctly', () => {
|
||||
const wrapper = mount(createTestComponent(6), {
|
||||
global: { plugins: [mockStore] },
|
||||
});
|
||||
|
||||
expect(wrapper.vm.isAnEmailChannel).toBe(true);
|
||||
expect(wrapper.vm.isAGoogleInbox).toBe(true);
|
||||
expect(wrapper.vm.isAMicrosoftInbox).toBe(false);
|
||||
});
|
||||
|
||||
it('identifies 360Dialog WhatsApp channel correctly', () => {
|
||||
const wrapper = mount(createTestComponent(7), {
|
||||
global: { plugins: [mockStore] },
|
||||
});
|
||||
|
||||
expect(wrapper.vm.is360DialogWhatsAppChannel).toBe(true);
|
||||
expect(wrapper.vm.isAWhatsAppChannel).toBe(true);
|
||||
expect(wrapper.vm.isAWhatsAppCloudChannel).toBe(false);
|
||||
});
|
||||
|
||||
it('identifies all other channel types correctly', () => {
|
||||
// Test Telegram
|
||||
let wrapper = mount(createTestComponent(8), {
|
||||
global: { plugins: [mockStore] },
|
||||
});
|
||||
expect(wrapper.vm.isATelegramChannel).toBe(true);
|
||||
|
||||
// Test Line
|
||||
wrapper = mount(createTestComponent(9), {
|
||||
global: { plugins: [mockStore] },
|
||||
});
|
||||
expect(wrapper.vm.isALineChannel).toBe(true);
|
||||
|
||||
// Test Web Widget
|
||||
wrapper = mount(createTestComponent(10), {
|
||||
global: { plugins: [mockStore] },
|
||||
});
|
||||
expect(wrapper.vm.isAWebWidgetInbox).toBe(true);
|
||||
|
||||
// Test API
|
||||
wrapper = mount(createTestComponent(11), {
|
||||
global: { plugins: [mockStore] },
|
||||
});
|
||||
expect(wrapper.vm.isAPIInbox).toBe(true);
|
||||
|
||||
// Test SMS
|
||||
wrapper = mount(createTestComponent(12), {
|
||||
global: { plugins: [mockStore] },
|
||||
});
|
||||
expect(wrapper.vm.isASmsInbox).toBe(true);
|
||||
|
||||
// Test Instagram
|
||||
wrapper = mount(createTestComponent(13), {
|
||||
global: { plugins: [mockStore] },
|
||||
});
|
||||
expect(wrapper.vm.isAnInstagramChannel).toBe(true);
|
||||
|
||||
// Test Voice
|
||||
wrapper = mount(createTestComponent(14), {
|
||||
global: { plugins: [mockStore] },
|
||||
});
|
||||
expect(wrapper.vm.isAVoiceChannel).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles non-existent inbox ID gracefully', () => {
|
||||
const wrapper = mount(createTestComponent(999), {
|
||||
global: { plugins: [mockStore] },
|
||||
});
|
||||
|
||||
// useCamelCase still processes null data, so we get an object with channelType: undefined
|
||||
expect(wrapper.vm.inbox).toEqual({ channelType: undefined });
|
||||
expect(wrapper.vm.isAWhatsAppChannel).toBe(false);
|
||||
expect(wrapper.vm.isAFacebookInbox).toBe(false);
|
||||
});
|
||||
|
||||
it('handles inbox with no data correctly', () => {
|
||||
// The mock will return null for non-existent IDs, but useCamelCase processes it
|
||||
const wrapper = mount(createTestComponent(999), {
|
||||
global: { plugins: [mockStore] },
|
||||
});
|
||||
|
||||
expect(wrapper.vm.inbox.channelType).toBeUndefined();
|
||||
expect(wrapper.vm.isAWhatsAppChannel).toBe(false);
|
||||
expect(wrapper.vm.isAFacebookInbox).toBe(false);
|
||||
expect(wrapper.vm.isATelegramChannel).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('return object completeness', () => {
|
||||
it('returns all expected properties', () => {
|
||||
const wrapper = mount(createTestComponent(1), {
|
||||
global: { plugins: [mockStore] },
|
||||
});
|
||||
|
||||
const expectedProperties = [
|
||||
'inbox',
|
||||
'isAFacebookInbox',
|
||||
'isALineChannel',
|
||||
'isAPIInbox',
|
||||
'isASmsInbox',
|
||||
'isATelegramChannel',
|
||||
'isATwilioChannel',
|
||||
'isAWebWidgetInbox',
|
||||
'isAWhatsAppChannel',
|
||||
'isAMicrosoftInbox',
|
||||
'isAGoogleInbox',
|
||||
'isATwilioWhatsAppChannel',
|
||||
'isAWhatsAppCloudChannel',
|
||||
'is360DialogWhatsAppChannel',
|
||||
'isAnEmailChannel',
|
||||
'isAnInstagramChannel',
|
||||
'isAVoiceChannel',
|
||||
];
|
||||
|
||||
expectedProperties.forEach(prop => {
|
||||
expect(wrapper.vm).toHaveProperty(prop);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,22 @@
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { DirectUpload } from 'activestorage';
|
||||
import {
|
||||
MAXIMUM_FILE_UPLOAD_SIZE,
|
||||
MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL,
|
||||
} from 'shared/constants/messages';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { getMaxUploadSizeByChannel } from '@chatwoot/utils';
|
||||
import { MAXIMUM_FILE_UPLOAD_SIZE } from 'shared/constants/messages';
|
||||
|
||||
/**
|
||||
* Composable for handling file uploads in conversations
|
||||
* @param {Object} options
|
||||
* @param {Object} options.inbox - Current inbox object (has channel_type, medium, etc.)
|
||||
* @param {Function} options.attachFile - Callback to handle file attachment
|
||||
* @param {boolean} options.isPrivateNote - Whether the upload is for a private note
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {boolean} options.isATwilioSMSChannel - Whether the current channel is Twilio SMS
|
||||
* @param {Function} options.attachFile - Callback function to handle file attachment
|
||||
* @returns {Object} File upload methods and utilities
|
||||
*/
|
||||
export const useFileUpload = ({ inbox, attachFile, isPrivateNote = false }) => {
|
||||
export const useFileUpload = ({ isATwilioSMSChannel, attachFile }) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
@@ -21,72 +24,57 @@ export const useFileUpload = ({ inbox, attachFile, isPrivateNote = false }) => {
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const globalConfig = useMapGetter('globalConfig/get');
|
||||
|
||||
// helper: compute max upload size for a given file's mime
|
||||
const maxSizeFor = mime => {
|
||||
// Use default file size limit for private notes
|
||||
if (isPrivateNote) {
|
||||
return MAXIMUM_FILE_UPLOAD_SIZE;
|
||||
}
|
||||
|
||||
return getMaxUploadSizeByChannel({
|
||||
channelType: inbox?.channel_type,
|
||||
medium: inbox?.medium, // e.g. 'sms' | 'whatsapp' | etc.
|
||||
mime, // e.g. 'image/png'
|
||||
});
|
||||
};
|
||||
|
||||
const alertOverLimit = maxSizeMB =>
|
||||
useAlert(
|
||||
t('CONVERSATION.FILE_SIZE_LIMIT', {
|
||||
MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE: maxSizeMB,
|
||||
})
|
||||
);
|
||||
const maxFileSize = computed(() =>
|
||||
isATwilioSMSChannel
|
||||
? MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL
|
||||
: MAXIMUM_FILE_UPLOAD_SIZE
|
||||
);
|
||||
|
||||
const handleDirectFileUpload = file => {
|
||||
if (!file) return;
|
||||
|
||||
const mime = file.file?.type || file.type;
|
||||
const maxSizeMB = maxSizeFor(mime);
|
||||
if (checkFileSizeLimit(file, maxFileSize.value)) {
|
||||
const upload = new DirectUpload(
|
||||
file.file,
|
||||
`/api/v1/accounts/${accountId.value}/conversations/${currentChat.value.id}/direct_uploads`,
|
||||
{
|
||||
directUploadWillCreateBlobWithXHR: xhr => {
|
||||
xhr.setRequestHeader(
|
||||
'api_access_token',
|
||||
currentUser.value.access_token
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!checkFileSizeLimit(file, maxSizeMB)) {
|
||||
alertOverLimit(maxSizeMB);
|
||||
return;
|
||||
upload.create((error, blob) => {
|
||||
if (error) {
|
||||
useAlert(error);
|
||||
} else {
|
||||
attachFile({ file, blob });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
useAlert(
|
||||
t('CONVERSATION.FILE_SIZE_LIMIT', {
|
||||
MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE: maxFileSize.value,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const upload = new DirectUpload(
|
||||
file.file,
|
||||
`/api/v1/accounts/${accountId.value}/conversations/${currentChat.value.id}/direct_uploads`,
|
||||
{
|
||||
directUploadWillCreateBlobWithXHR: xhr => {
|
||||
xhr.setRequestHeader(
|
||||
'api_access_token',
|
||||
currentUser.value.access_token
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
upload.create((error, blob) => {
|
||||
if (error) {
|
||||
useAlert(error);
|
||||
} else {
|
||||
attachFile({ file, blob });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleIndirectFileUpload = file => {
|
||||
if (!file) return;
|
||||
|
||||
const mime = file.file?.type || file.type;
|
||||
const maxSizeMB = maxSizeFor(mime);
|
||||
|
||||
if (!checkFileSizeLimit(file, maxSizeMB)) {
|
||||
alertOverLimit(maxSizeMB);
|
||||
return;
|
||||
if (checkFileSizeLimit(file, maxFileSize.value)) {
|
||||
attachFile({ file });
|
||||
} else {
|
||||
useAlert(
|
||||
t('CONVERSATION.FILE_SIZE_LIMIT', {
|
||||
MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE: maxFileSize.value,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
attachFile({ file });
|
||||
};
|
||||
|
||||
const onFileUpload = file => {
|
||||
@@ -97,5 +85,7 @@ export const useFileUpload = ({ inbox, attachFile, isPrivateNote = false }) => {
|
||||
}
|
||||
};
|
||||
|
||||
return { onFileUpload };
|
||||
return {
|
||||
onFileUpload,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -29,24 +29,21 @@ export const INBOX_FEATURE_MAP = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Composable for handling inbox-related functionality
|
||||
* @param {string|null} inboxId - Optional inbox ID. If not provided, uses current chat's inbox
|
||||
* @returns {Object} An object containing inbox type checking functions
|
||||
* Composable for handling macro-related functionality
|
||||
* @returns {Object} An object containing the getMacroDropdownValues function
|
||||
*/
|
||||
export const useInbox = (inboxId = null) => {
|
||||
export const useInbox = () => {
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const inboxGetter = useMapGetter('inboxes/getInboxById');
|
||||
|
||||
const inbox = computed(() => {
|
||||
const targetInboxId = inboxId || currentChat.value?.inbox_id;
|
||||
const inboxId = currentChat.value.inbox_id;
|
||||
|
||||
if (!targetInboxId) return null;
|
||||
|
||||
return useCamelCase(inboxGetter.value(targetInboxId), { deep: true });
|
||||
return useCamelCase(inboxGetter.value(inboxId), { deep: true });
|
||||
});
|
||||
|
||||
const channelType = computed(() => {
|
||||
return inbox.value?.channelType;
|
||||
return inbox.value.channelType;
|
||||
});
|
||||
|
||||
const isAPIInbox = computed(() => {
|
||||
@@ -78,19 +75,19 @@ export const useInbox = (inboxId = null) => {
|
||||
});
|
||||
|
||||
const whatsAppAPIProvider = computed(() => {
|
||||
return inbox.value?.provider || '';
|
||||
return inbox.value.provider || '';
|
||||
});
|
||||
|
||||
const isAMicrosoftInbox = computed(() => {
|
||||
return isAnEmailChannel.value && inbox.value?.provider === 'microsoft';
|
||||
return isAnEmailChannel.value && inbox.value.provider === 'microsoft';
|
||||
});
|
||||
|
||||
const isAGoogleInbox = computed(() => {
|
||||
return isAnEmailChannel.value && inbox.value?.provider === 'google';
|
||||
return isAnEmailChannel.value && inbox.value.provider === 'google';
|
||||
});
|
||||
|
||||
const isATwilioSMSChannel = computed(() => {
|
||||
const { medium: medium = '' } = inbox.value || {};
|
||||
const { medium: medium = '' } = inbox.value;
|
||||
return isATwilioChannel.value && medium === 'sms';
|
||||
});
|
||||
|
||||
@@ -99,7 +96,7 @@ export const useInbox = (inboxId = null) => {
|
||||
});
|
||||
|
||||
const isATwilioWhatsAppChannel = computed(() => {
|
||||
const { medium: medium = '' } = inbox.value || {};
|
||||
const { medium: medium = '' } = inbox.value;
|
||||
return isATwilioChannel.value && medium === 'whatsapp';
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import posthog from 'posthog-js';
|
||||
import { AnalyticsBrowser } from '@june-so/analytics-next';
|
||||
|
||||
/**
|
||||
* AnalyticsHelper class to initialize and track user analytics
|
||||
@@ -26,12 +26,10 @@ export class AnalyticsHelper {
|
||||
return;
|
||||
}
|
||||
|
||||
posthog.init(this.analyticsToken, {
|
||||
api_host: 'https://app.posthog.com',
|
||||
capture_pageview: false,
|
||||
persistence: 'localStorage+cookie',
|
||||
let [analytics] = await AnalyticsBrowser.load({
|
||||
writeKey: this.analyticsToken,
|
||||
});
|
||||
this.analytics = posthog;
|
||||
this.analytics = analytics;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,7 +43,8 @@ export class AnalyticsHelper {
|
||||
}
|
||||
|
||||
this.user = user;
|
||||
this.analytics.identify(this.user.id.toString(), {
|
||||
this.analytics.identify(this.user.email, {
|
||||
userId: this.user.id,
|
||||
email: this.user.email,
|
||||
name: this.user.name,
|
||||
avatar: this.user.avatar_url,
|
||||
@@ -56,7 +55,7 @@ export class AnalyticsHelper {
|
||||
account => account.id === accountId
|
||||
);
|
||||
if (currentAccount) {
|
||||
this.analytics.group('company', currentAccount.id.toString(), {
|
||||
this.analytics.group(currentAccount.id, this.user.id, {
|
||||
name: currentAccount.name,
|
||||
});
|
||||
}
|
||||
@@ -72,7 +71,12 @@ export class AnalyticsHelper {
|
||||
if (!this.analytics) {
|
||||
return;
|
||||
}
|
||||
this.analytics.capture(eventName, properties);
|
||||
|
||||
this.analytics.track({
|
||||
userId: this.user.id,
|
||||
event: eventName,
|
||||
properties,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,9 +89,9 @@ export class AnalyticsHelper {
|
||||
return;
|
||||
}
|
||||
|
||||
this.analytics.capture('$pageview', params);
|
||||
this.analytics.page(params);
|
||||
}
|
||||
}
|
||||
|
||||
// This object is shared across, the init is called in app/javascript/entrypoints/dashboard.js
|
||||
// This object is shared across, the init is called in app/javascript/packs/application.js
|
||||
export default new AnalyticsHelper(window.analyticsConfig);
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import helperObject, { AnalyticsHelper } from '../';
|
||||
|
||||
vi.mock('posthog-js', () => ({
|
||||
default: {
|
||||
init: vi.fn(),
|
||||
identify: vi.fn(),
|
||||
capture: vi.fn(),
|
||||
group: vi.fn(),
|
||||
vi.mock('@june-so/analytics-next', () => ({
|
||||
AnalyticsBrowser: {
|
||||
load: () => [
|
||||
{
|
||||
identify: vi.fn(),
|
||||
track: vi.fn(),
|
||||
page: vi.fn(),
|
||||
group: vi.fn(),
|
||||
},
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -22,12 +26,12 @@ describe('AnalyticsHelper', () => {
|
||||
});
|
||||
|
||||
describe('init', () => {
|
||||
it('should initialize posthog with the correct token', async () => {
|
||||
it('should initialize the analytics browser with the correct token', async () => {
|
||||
await analyticsHelper.init();
|
||||
expect(analyticsHelper.analytics).not.toBe(null);
|
||||
});
|
||||
|
||||
it('should not initialize posthog if token is not provided', async () => {
|
||||
it('should not initialize the analytics browser if token is not provided', async () => {
|
||||
analyticsHelper = new AnalyticsHelper();
|
||||
await analyticsHelper.init();
|
||||
expect(analyticsHelper.analytics).toBe(null);
|
||||
@@ -39,36 +43,36 @@ describe('AnalyticsHelper', () => {
|
||||
analyticsHelper.analytics = { identify: vi.fn(), group: vi.fn() };
|
||||
});
|
||||
|
||||
it('should call identify on posthog with correct arguments', () => {
|
||||
it('should call identify on analytics browser with correct arguments', () => {
|
||||
analyticsHelper.identify({
|
||||
id: 123,
|
||||
id: '123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
avatar_url: 'avatar_url',
|
||||
accounts: [{ id: 1, name: 'Account 1' }],
|
||||
account_id: 1,
|
||||
accounts: [{ id: '1', name: 'Account 1' }],
|
||||
account_id: '1',
|
||||
});
|
||||
|
||||
expect(analyticsHelper.analytics.identify).toHaveBeenCalledWith('123', {
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
avatar: 'avatar_url',
|
||||
});
|
||||
expect(analyticsHelper.analytics.group).toHaveBeenCalledWith(
|
||||
'company',
|
||||
'1',
|
||||
{ name: 'Account 1' }
|
||||
expect(analyticsHelper.analytics.identify).toHaveBeenCalledWith(
|
||||
'test@example.com',
|
||||
{
|
||||
userId: '123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
avatar: 'avatar_url',
|
||||
}
|
||||
);
|
||||
expect(analyticsHelper.analytics.group).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call identify on posthog without group', () => {
|
||||
it('should call identify on analytics browser without group', () => {
|
||||
analyticsHelper.identify({
|
||||
id: 123,
|
||||
id: '123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
avatar_url: 'avatar_url',
|
||||
accounts: [{ id: 1, name: 'Account 1' }],
|
||||
account_id: 5,
|
||||
accounts: [{ id: '1', name: 'Account 1' }],
|
||||
account_id: '5',
|
||||
});
|
||||
|
||||
expect(analyticsHelper.analytics.group).not.toHaveBeenCalled();
|
||||
@@ -83,27 +87,29 @@ describe('AnalyticsHelper', () => {
|
||||
|
||||
describe('track', () => {
|
||||
beforeEach(() => {
|
||||
analyticsHelper.analytics = { capture: vi.fn() };
|
||||
analyticsHelper.user = { id: 123 };
|
||||
analyticsHelper.analytics = { track: vi.fn() };
|
||||
analyticsHelper.user = { id: '123' };
|
||||
});
|
||||
|
||||
it('should call capture on posthog with correct arguments', () => {
|
||||
it('should call track on analytics browser with correct arguments', () => {
|
||||
analyticsHelper.track('Test Event', { prop1: 'value1', prop2: 'value2' });
|
||||
expect(analyticsHelper.analytics.capture).toHaveBeenCalledWith(
|
||||
'Test Event',
|
||||
{ prop1: 'value1', prop2: 'value2' }
|
||||
);
|
||||
expect(analyticsHelper.analytics.track).toHaveBeenCalledWith({
|
||||
userId: '123',
|
||||
event: 'Test Event',
|
||||
properties: { prop1: 'value1', prop2: 'value2' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should call capture on posthog with default properties', () => {
|
||||
it('should call track on analytics browser with default properties', () => {
|
||||
analyticsHelper.track('Test Event');
|
||||
expect(analyticsHelper.analytics.capture).toHaveBeenCalledWith(
|
||||
'Test Event',
|
||||
{}
|
||||
);
|
||||
expect(analyticsHelper.analytics.track).toHaveBeenCalledWith({
|
||||
userId: '123',
|
||||
event: 'Test Event',
|
||||
properties: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not call capture on posthog if analytics is not initialized', () => {
|
||||
it('should not call track on analytics browser if analytics is not initialized', () => {
|
||||
analyticsHelper.analytics = null;
|
||||
analyticsHelper.track('Test Event', { prop1: 'value1', prop2: 'value2' });
|
||||
expect(analyticsHelper.analytics).toBe(null);
|
||||
@@ -112,22 +118,19 @@ describe('AnalyticsHelper', () => {
|
||||
|
||||
describe('page', () => {
|
||||
beforeEach(() => {
|
||||
analyticsHelper.analytics = { capture: vi.fn() };
|
||||
analyticsHelper.analytics = { page: vi.fn() };
|
||||
});
|
||||
|
||||
it('should call the capture method for pageview with the correct arguments', () => {
|
||||
it('should call the analytics.page method with the correct arguments', () => {
|
||||
const params = {
|
||||
name: 'Test page',
|
||||
url: '/test',
|
||||
};
|
||||
analyticsHelper.page(params);
|
||||
expect(analyticsHelper.analytics.capture).toHaveBeenCalledWith(
|
||||
'$pageview',
|
||||
params
|
||||
);
|
||||
expect(analyticsHelper.analytics.page).toHaveBeenCalledWith(params);
|
||||
});
|
||||
|
||||
it('should not call analytics.capture if analytics is null', () => {
|
||||
it('should not call analytics.page if analytics is null', () => {
|
||||
analyticsHelper.analytics = null;
|
||||
analyticsHelper.page();
|
||||
expect(analyticsHelper.analytics).toBe(null);
|
||||
|
||||
@@ -125,23 +125,3 @@ export const getHostNameFromURL = url => {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts filename from a URL
|
||||
* @param {string} url - The URL to extract filename from
|
||||
* @returns {string} - The extracted filename or original URL if extraction fails
|
||||
*/
|
||||
export const extractFilenameFromUrl = url => {
|
||||
if (!url || typeof url !== 'string') return url;
|
||||
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const pathname = urlObj.pathname;
|
||||
const filename = pathname.split('/').pop();
|
||||
return filename || url;
|
||||
} catch (error) {
|
||||
// If URL parsing fails, try to extract filename using regex
|
||||
const match = url.match(/\/([^/?#]+)(?:[?#]|$)/);
|
||||
return match ? match[1] : url;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
hasValidAvatarUrl,
|
||||
timeStampAppendedURL,
|
||||
getHostNameFromURL,
|
||||
extractFilenameFromUrl,
|
||||
} from '../URLHelper';
|
||||
|
||||
describe('#URL Helpers', () => {
|
||||
@@ -264,58 +263,4 @@ describe('#URL Helpers', () => {
|
||||
expect(getHostNameFromURL('https://chatwoot.help')).toBe('chatwoot.help');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractFilenameFromUrl', () => {
|
||||
it('should extract filename from a valid URL', () => {
|
||||
expect(
|
||||
extractFilenameFromUrl('https://example.com/path/to/file.jpg')
|
||||
).toBe('file.jpg');
|
||||
expect(extractFilenameFromUrl('https://example.com/image.png')).toBe(
|
||||
'image.png'
|
||||
);
|
||||
expect(
|
||||
extractFilenameFromUrl(
|
||||
'https://example.com/folder/document.pdf?query=1'
|
||||
)
|
||||
).toBe('document.pdf');
|
||||
expect(
|
||||
extractFilenameFromUrl('https://example.com/file.txt#section')
|
||||
).toBe('file.txt');
|
||||
});
|
||||
|
||||
it('should handle URLs without filename', () => {
|
||||
expect(extractFilenameFromUrl('https://example.com/')).toBe(
|
||||
'https://example.com/'
|
||||
);
|
||||
expect(extractFilenameFromUrl('https://example.com')).toBe(
|
||||
'https://example.com'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle invalid URLs gracefully', () => {
|
||||
expect(extractFilenameFromUrl('not-a-url/file.txt')).toBe('file.txt');
|
||||
expect(extractFilenameFromUrl('invalid-url')).toBe('invalid-url');
|
||||
});
|
||||
|
||||
it('should handle edge cases', () => {
|
||||
expect(extractFilenameFromUrl('')).toBe('');
|
||||
expect(extractFilenameFromUrl(null)).toBe(null);
|
||||
expect(extractFilenameFromUrl(undefined)).toBe(undefined);
|
||||
expect(extractFilenameFromUrl(123)).toBe(123);
|
||||
});
|
||||
|
||||
it('should handle URLs with query parameters and fragments', () => {
|
||||
expect(
|
||||
extractFilenameFromUrl(
|
||||
'https://example.com/file.jpg?size=large&format=png'
|
||||
)
|
||||
).toBe('file.jpg');
|
||||
expect(
|
||||
extractFilenameFromUrl('https://example.com/file.pdf#page=1')
|
||||
).toBe('file.pdf');
|
||||
expect(
|
||||
extractFilenameFromUrl('https://example.com/file.doc?v=1#section')
|
||||
).toBe('file.doc');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,11 +17,6 @@
|
||||
"IP_ADDRESS": "IP Address",
|
||||
"CREATED_AT_LABEL": "Created",
|
||||
"NEW_MESSAGE": "New message",
|
||||
"CALL": "ደውል",
|
||||
"CALL_UNDER_DEVELOPMENT": "መደወል በልማት ላይ ነው",
|
||||
"VOICE_INBOX_PICKER": {
|
||||
"TITLE": "የድምፅ ኢንቦክስ ይምረጡ"
|
||||
},
|
||||
"CONVERSATIONS": {
|
||||
"NO_RECORDS_FOUND": "There are no previous conversations associated to this contact.",
|
||||
"TITLE": "Previous Conversations"
|
||||
@@ -290,7 +285,7 @@
|
||||
"HEADER": {
|
||||
"TITLE": "Contacts",
|
||||
"SEARCH_TITLE": "Search contacts",
|
||||
"ACTIVE_TITLE": "ንቁ እውቂያዎች",
|
||||
"ACTIVE_TITLE": "Active contacts",
|
||||
"SEARCH_PLACEHOLDER": "Search...",
|
||||
"MESSAGE_BUTTON": "Message",
|
||||
"SEND_MESSAGE": "Send message",
|
||||
@@ -465,8 +460,8 @@
|
||||
}
|
||||
},
|
||||
"DELETE_CONTACT": {
|
||||
"MESSAGE": "ይህ እርምጃ ቋሚ ነው እና መመለስ አይቻልም።",
|
||||
"BUTTON": "አሁን ሰርዝ"
|
||||
"MESSAGE": "This action is permanent and irreversible.",
|
||||
"BUTTON": "Delete now"
|
||||
}
|
||||
},
|
||||
"DETAILS": {
|
||||
@@ -476,7 +471,7 @@
|
||||
"DELETE_CONTACT": "Delete contact",
|
||||
"DELETE_DIALOG": {
|
||||
"TITLE": "Confirm Deletion",
|
||||
"DESCRIPTION": "ይህን እውቂያ ማጥፋት እርግጠኛ ነዎት?",
|
||||
"DESCRIPTION": "Are you sure you want to delete this contact?",
|
||||
"CONFIRM": "Yes, Delete",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Contact deleted successfully",
|
||||
@@ -555,8 +550,8 @@
|
||||
"YOU": "You",
|
||||
"SAVE": "Save note",
|
||||
"EXPAND": "Expand",
|
||||
"COLLAPSE": "ሰብስብ",
|
||||
"NO_NOTES": "ማስታወሻዎች የሉም፣ ከእውቂያው ዝርዝር ገፅ ላይ ማስታወሻዎችን መጨመር ይችላሉ።",
|
||||
"COLLAPSE": "Collapse",
|
||||
"NO_NOTES": "No notes, you can add notes from the contact details page.",
|
||||
"EMPTY_STATE": "There are no notes associated to this contact. You can add a note by typing in the box above."
|
||||
}
|
||||
},
|
||||
@@ -566,7 +561,7 @@
|
||||
"BUTTON_LABEL": "Add contact",
|
||||
"SEARCH_EMPTY_STATE_TITLE": "No contacts matches your search 🔍",
|
||||
"LIST_EMPTY_STATE_TITLE": "No contacts available in this view 📋",
|
||||
"ACTIVE_EMPTY_STATE_TITLE": "በአሁኑ ጊዜ ንቁ እውቂያዎች የሉም 🌙"
|
||||
"ACTIVE_EMPTY_STATE_TITLE": "No contacts are active at the moment 🌙"
|
||||
}
|
||||
},
|
||||
"COMPOSE_NEW_CONVERSATION": {
|
||||
|
||||
@@ -32,12 +32,12 @@
|
||||
"LOADING_CONVERSATIONS": "Loading Conversations",
|
||||
"CANNOT_REPLY": "You cannot reply due to",
|
||||
"24_HOURS_WINDOW": "24 hour message window restriction",
|
||||
"API_HOURS_WINDOW": "ለዚህ ውይይት መመለስ በ{hours} ሰአታት ውስጥ ብቻ ይቻላል",
|
||||
"API_HOURS_WINDOW": "You can only reply to this conversation within {hours} hours",
|
||||
"NOT_ASSIGNED_TO_YOU": "This conversation is not assigned to you. Would you like to assign this conversation to yourself?",
|
||||
"ASSIGN_TO_ME": "Assign to me",
|
||||
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
|
||||
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
|
||||
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "ይህ የInstagram መለያ ወደ አዲሱ የInstagram ቻናል ገቢ ሳጥን ተዛውሯል። ሁሉም አዲስ መልዕክቶች በዚያ ይታያሉ። ከአሁን ጀምሮ ከዚህ ውይይት መልዕክቶች መላክ አትችሉም።",
|
||||
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You won’t be able to send messages from this conversation anymore.",
|
||||
"REPLYING_TO": "You are replying to:",
|
||||
"REMOVE_SELECTION": "Remove Selection",
|
||||
"DOWNLOAD": "Download",
|
||||
@@ -70,7 +70,7 @@
|
||||
"RESOLVE_ACTION": "Resolve",
|
||||
"REOPEN_ACTION": "Reopen",
|
||||
"OPEN_ACTION": "Open",
|
||||
"MORE_ACTIONS": "ተጨማሪ እርምጃዎች",
|
||||
"MORE_ACTIONS": "More actions",
|
||||
"OPEN": "More",
|
||||
"CLOSE": "Close",
|
||||
"DETAILS": "details",
|
||||
@@ -123,8 +123,8 @@
|
||||
}
|
||||
},
|
||||
"DELETE_CONVERSATION": {
|
||||
"TITLE": "ውይይት #{conversationId}ን ሰርዝ",
|
||||
"DESCRIPTION": "ይህን ውይይት ለመሰረዝ እርግጠኛ ነዎት?",
|
||||
"TITLE": "Delete conversation #{conversationId}",
|
||||
"DESCRIPTION": "Are you sure you want to delete this conversation?",
|
||||
"CONFIRM": "Delete"
|
||||
},
|
||||
"CARD_CONTEXT_MENU": {
|
||||
@@ -143,10 +143,10 @@
|
||||
"ASSIGN_LABEL": "Assign label",
|
||||
"AGENTS_LOADING": "Loading agents...",
|
||||
"ASSIGN_TEAM": "Assign team",
|
||||
"DELETE": "ውይይት ሰርዝ",
|
||||
"OPEN_IN_NEW_TAB": "በአዲስ ታብ ክፈት",
|
||||
"COPY_LINK": "የውይይት አገናኝን ኮፒ አድርግ",
|
||||
"COPY_LINK_SUCCESS": "የውይይት አገናኝ ወደ ቅጂ ሰሌዳ ኮፒ ተደረገ",
|
||||
"DELETE": "Delete conversation",
|
||||
"OPEN_IN_NEW_TAB": "Open in new tab",
|
||||
"COPY_LINK": "Copy conversation link",
|
||||
"COPY_LINK_SUCCESS": "Conversation link copied to clipboard",
|
||||
"API": {
|
||||
"AGENT_ASSIGNMENT": {
|
||||
"SUCCESFUL": "Conversation id {conversationId} assigned to \"{agentName}\"",
|
||||
@@ -221,8 +221,8 @@
|
||||
"ASSIGN_LABEL_SUCCESFUL": "Label assigned successfully",
|
||||
"ASSIGN_LABEL_FAILED": "Label assignment failed",
|
||||
"CHANGE_TEAM": "Conversation team changed",
|
||||
"SUCCESS_DELETE_CONVERSATION": "ውይይት በተሳካ ሁኔታ ተሰርዟል",
|
||||
"FAIL_DELETE_CONVERSATION": "ውይይትን መሰረዝ አልተቻለም! እንደገና ይሞክሩ",
|
||||
"SUCCESS_DELETE_CONVERSATION": "Conversation deleted successfully",
|
||||
"FAIL_DELETE_CONVERSATION": "Couldn't delete conversation! Try again",
|
||||
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE} MB attachment limit",
|
||||
"MESSAGE_ERROR": "Unable to send this message, please try again later",
|
||||
"SENT_BY": "Sent by:",
|
||||
@@ -311,11 +311,11 @@
|
||||
"CONVERSATION_ACTIONS": "Conversation Actions",
|
||||
"CONVERSATION_LABELS": "Conversation Labels",
|
||||
"CONVERSATION_INFO": "Conversation Information",
|
||||
"CONTACT_NOTES": "የእውቂያ ማስታወሻዎች",
|
||||
"CONTACT_NOTES": "Contact Notes",
|
||||
"CONTACT_ATTRIBUTES": "Contact Attributes",
|
||||
"PREVIOUS_CONVERSATION": "Previous Conversations",
|
||||
"MACROS": "Macros",
|
||||
"LINEAR_ISSUES": "የተገናኙ የLinear ጉዳዮች",
|
||||
"LINEAR_ISSUES": "Linked Linear Issues",
|
||||
"SHOPIFY_ORDERS": "Shopify Orders"
|
||||
},
|
||||
"SHOPIFY": {
|
||||
|
||||
@@ -330,14 +330,13 @@
|
||||
"LABEL": "API Key Secret",
|
||||
"PLACEHOLDER": "Enter your Twilio API Key Secret",
|
||||
"REQUIRED": "API Key Secret is required"
|
||||
},
|
||||
"TWIML_APP_SID": {
|
||||
"LABEL": "TwiML App SID",
|
||||
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
|
||||
"REQUIRED": "TwiML App SID is required"
|
||||
}
|
||||
},
|
||||
"CONFIGURATION": {
|
||||
"TWILIO_VOICE_URL_TITLE": "Twilio Voice URL",
|
||||
"TWILIO_VOICE_URL_SUBTITLE": "Configure this URL as the Voice URL on your Twilio phone number and TwiML App.",
|
||||
"TWILIO_STATUS_URL_TITLE": "Twilio Status Callback URL",
|
||||
"TWILIO_STATUS_URL_SUBTITLE": "Configure this URL as the Status Callback URL on your Twilio phone number."
|
||||
},
|
||||
"SUBMIT_BUTTON": "Create Voice Channel",
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "We were not able to create the voice channel"
|
||||
|
||||
@@ -226,7 +226,6 @@
|
||||
"APPEARANCE": "Change appearance",
|
||||
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
|
||||
"DOCS": "Read documentation",
|
||||
"CHANGELOG": "Changelog",
|
||||
"LOGOUT": "Log out"
|
||||
},
|
||||
"APP_GLOBAL": {
|
||||
|
||||
@@ -17,11 +17,6 @@
|
||||
"IP_ADDRESS": "عنوان IP",
|
||||
"CREATED_AT_LABEL": "تم إنشاؤها",
|
||||
"NEW_MESSAGE": "رسالة جديدة",
|
||||
"CALL": "Call",
|
||||
"CALL_UNDER_DEVELOPMENT": "Calling is under development",
|
||||
"VOICE_INBOX_PICKER": {
|
||||
"TITLE": "Choose a voice inbox"
|
||||
},
|
||||
"CONVERSATIONS": {
|
||||
"NO_RECORDS_FOUND": "لا توجد محادثات سابقة مرتبطة بجهة الاتصال هذه.",
|
||||
"TITLE": "المحادثات السابقة"
|
||||
|
||||
@@ -330,14 +330,13 @@
|
||||
"LABEL": "مفتاح سر API",
|
||||
"PLACEHOLDER": "Enter your Twilio API Key Secret",
|
||||
"REQUIRED": "API Key Secret is required"
|
||||
},
|
||||
"TWIML_APP_SID": {
|
||||
"LABEL": "TwiML App SID",
|
||||
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
|
||||
"REQUIRED": "TwiML App SID is required"
|
||||
}
|
||||
},
|
||||
"CONFIGURATION": {
|
||||
"TWILIO_VOICE_URL_TITLE": "Twilio Voice URL",
|
||||
"TWILIO_VOICE_URL_SUBTITLE": "Configure this URL as the Voice URL on your Twilio phone number and TwiML App.",
|
||||
"TWILIO_STATUS_URL_TITLE": "Twilio Status Callback URL",
|
||||
"TWILIO_STATUS_URL_SUBTITLE": "Configure this URL as the Status Callback URL on your Twilio phone number."
|
||||
},
|
||||
"SUBMIT_BUTTON": "Create Voice Channel",
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "We were not able to create the voice channel"
|
||||
|
||||
@@ -226,7 +226,6 @@
|
||||
"APPEARANCE": "Change appearance",
|
||||
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
|
||||
"DOCS": "Read documentation",
|
||||
"CHANGELOG": "Changelog",
|
||||
"LOGOUT": "Log out"
|
||||
},
|
||||
"APP_GLOBAL": {
|
||||
|
||||
@@ -17,11 +17,6 @@
|
||||
"IP_ADDRESS": "IP Address",
|
||||
"CREATED_AT_LABEL": "Created",
|
||||
"NEW_MESSAGE": "New message",
|
||||
"CALL": "Call",
|
||||
"CALL_UNDER_DEVELOPMENT": "Calling is under development",
|
||||
"VOICE_INBOX_PICKER": {
|
||||
"TITLE": "Choose a voice inbox"
|
||||
},
|
||||
"CONVERSATIONS": {
|
||||
"NO_RECORDS_FOUND": "There are no previous conversations associated to this contact.",
|
||||
"TITLE": "Previous Conversations"
|
||||
|
||||
@@ -330,14 +330,13 @@
|
||||
"LABEL": "API Key Secret",
|
||||
"PLACEHOLDER": "Enter your Twilio API Key Secret",
|
||||
"REQUIRED": "API Key Secret is required"
|
||||
},
|
||||
"TWIML_APP_SID": {
|
||||
"LABEL": "TwiML App SID",
|
||||
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
|
||||
"REQUIRED": "TwiML App SID is required"
|
||||
}
|
||||
},
|
||||
"CONFIGURATION": {
|
||||
"TWILIO_VOICE_URL_TITLE": "Twilio Voice URL",
|
||||
"TWILIO_VOICE_URL_SUBTITLE": "Configure this URL as the Voice URL on your Twilio phone number and TwiML App.",
|
||||
"TWILIO_STATUS_URL_TITLE": "Twilio Status Callback URL",
|
||||
"TWILIO_STATUS_URL_SUBTITLE": "Configure this URL as the Status Callback URL on your Twilio phone number."
|
||||
},
|
||||
"SUBMIT_BUTTON": "Create Voice Channel",
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "We were not able to create the voice channel"
|
||||
|
||||
@@ -226,7 +226,6 @@
|
||||
"APPEARANCE": "Change appearance",
|
||||
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
|
||||
"DOCS": "Read documentation",
|
||||
"CHANGELOG": "Changelog",
|
||||
"LOGOUT": "Log out"
|
||||
},
|
||||
"APP_GLOBAL": {
|
||||
|
||||
@@ -17,11 +17,6 @@
|
||||
"IP_ADDRESS": "IP адрес",
|
||||
"CREATED_AT_LABEL": "Created",
|
||||
"NEW_MESSAGE": "Ново съобщение",
|
||||
"CALL": "Call",
|
||||
"CALL_UNDER_DEVELOPMENT": "Calling is under development",
|
||||
"VOICE_INBOX_PICKER": {
|
||||
"TITLE": "Choose a voice inbox"
|
||||
},
|
||||
"CONVERSATIONS": {
|
||||
"NO_RECORDS_FOUND": "Няма предишни разговори асоцирани с този контакт.",
|
||||
"TITLE": "Предишни разговори"
|
||||
|
||||
@@ -330,14 +330,13 @@
|
||||
"LABEL": "API Key Secret",
|
||||
"PLACEHOLDER": "Enter your Twilio API Key Secret",
|
||||
"REQUIRED": "API Key Secret is required"
|
||||
},
|
||||
"TWIML_APP_SID": {
|
||||
"LABEL": "TwiML App SID",
|
||||
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
|
||||
"REQUIRED": "TwiML App SID is required"
|
||||
}
|
||||
},
|
||||
"CONFIGURATION": {
|
||||
"TWILIO_VOICE_URL_TITLE": "Twilio Voice URL",
|
||||
"TWILIO_VOICE_URL_SUBTITLE": "Configure this URL as the Voice URL on your Twilio phone number and TwiML App.",
|
||||
"TWILIO_STATUS_URL_TITLE": "Twilio Status Callback URL",
|
||||
"TWILIO_STATUS_URL_SUBTITLE": "Configure this URL as the Status Callback URL on your Twilio phone number."
|
||||
},
|
||||
"SUBMIT_BUTTON": "Create Voice Channel",
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "We were not able to create the voice channel"
|
||||
|
||||
@@ -226,7 +226,6 @@
|
||||
"APPEARANCE": "Change appearance",
|
||||
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
|
||||
"DOCS": "Read documentation",
|
||||
"CHANGELOG": "Changelog",
|
||||
"LOGOUT": "Log out"
|
||||
},
|
||||
"APP_GLOBAL": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"HEADER": "Agents",
|
||||
"HEADER_BTN_TXT": "Afegir Agent",
|
||||
"LOADING": "S'està recollint la llista d'agents",
|
||||
"DESCRIPTION": "Un agent és un membre del vostre equip d'atenció al client que pot veure i respondre als missatges dels usuaris. La llista següent mostra tots els agents del vostre compte.",
|
||||
"DESCRIPTION": "",
|
||||
"LEARN_MORE": "Rakibkazi",
|
||||
"AGENT_TYPES": {
|
||||
"ADMINISTRATOR": "Administrador/a",
|
||||
|
||||
@@ -17,11 +17,6 @@
|
||||
"IP_ADDRESS": "Adreça IP",
|
||||
"CREATED_AT_LABEL": "Creat",
|
||||
"NEW_MESSAGE": "Nou missatge",
|
||||
"CALL": "Call",
|
||||
"CALL_UNDER_DEVELOPMENT": "Calling is under development",
|
||||
"VOICE_INBOX_PICKER": {
|
||||
"TITLE": "Choose a voice inbox"
|
||||
},
|
||||
"CONVERSATIONS": {
|
||||
"NO_RECORDS_FOUND": "No hi han converses prèvies associades a aquest contacte.",
|
||||
"TITLE": "Converses prèvies"
|
||||
|
||||
@@ -330,14 +330,13 @@
|
||||
"LABEL": "API Key Secret",
|
||||
"PLACEHOLDER": "Enter your Twilio API Key Secret",
|
||||
"REQUIRED": "API Key Secret is required"
|
||||
},
|
||||
"TWIML_APP_SID": {
|
||||
"LABEL": "TwiML App SID",
|
||||
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
|
||||
"REQUIRED": "TwiML App SID is required"
|
||||
}
|
||||
},
|
||||
"CONFIGURATION": {
|
||||
"TWILIO_VOICE_URL_TITLE": "Twilio Voice URL",
|
||||
"TWILIO_VOICE_URL_SUBTITLE": "Configure this URL as the Voice URL on your Twilio phone number and TwiML App.",
|
||||
"TWILIO_STATUS_URL_TITLE": "Twilio Status Callback URL",
|
||||
"TWILIO_STATUS_URL_SUBTITLE": "Configure this URL as the Status Callback URL on your Twilio phone number."
|
||||
},
|
||||
"SUBMIT_BUTTON": "Create Voice Channel",
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "We were not able to create the voice channel"
|
||||
|
||||
@@ -226,7 +226,6 @@
|
||||
"APPEARANCE": "Change appearance",
|
||||
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
|
||||
"DOCS": "Read documentation",
|
||||
"CHANGELOG": "Changelog",
|
||||
"LOGOUT": "Log out"
|
||||
},
|
||||
"APP_GLOBAL": {
|
||||
|
||||
@@ -17,11 +17,6 @@
|
||||
"IP_ADDRESS": "IP adresa",
|
||||
"CREATED_AT_LABEL": "Created",
|
||||
"NEW_MESSAGE": "New message",
|
||||
"CALL": "Call",
|
||||
"CALL_UNDER_DEVELOPMENT": "Calling is under development",
|
||||
"VOICE_INBOX_PICKER": {
|
||||
"TITLE": "Choose a voice inbox"
|
||||
},
|
||||
"CONVERSATIONS": {
|
||||
"NO_RECORDS_FOUND": "K tomuto kontaktu nejsou přiřazeny žádné předchozí konverzace.",
|
||||
"TITLE": "Předchozí konverzace"
|
||||
|
||||
@@ -330,14 +330,13 @@
|
||||
"LABEL": "API Key Secret",
|
||||
"PLACEHOLDER": "Enter your Twilio API Key Secret",
|
||||
"REQUIRED": "API Key Secret is required"
|
||||
},
|
||||
"TWIML_APP_SID": {
|
||||
"LABEL": "TwiML App SID",
|
||||
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
|
||||
"REQUIRED": "TwiML App SID is required"
|
||||
}
|
||||
},
|
||||
"CONFIGURATION": {
|
||||
"TWILIO_VOICE_URL_TITLE": "Twilio Voice URL",
|
||||
"TWILIO_VOICE_URL_SUBTITLE": "Configure this URL as the Voice URL on your Twilio phone number and TwiML App.",
|
||||
"TWILIO_STATUS_URL_TITLE": "Twilio Status Callback URL",
|
||||
"TWILIO_STATUS_URL_SUBTITLE": "Configure this URL as the Status Callback URL on your Twilio phone number."
|
||||
},
|
||||
"SUBMIT_BUTTON": "Create Voice Channel",
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "We were not able to create the voice channel"
|
||||
|
||||
@@ -226,7 +226,6 @@
|
||||
"APPEARANCE": "Change appearance",
|
||||
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
|
||||
"DOCS": "Read documentation",
|
||||
"CHANGELOG": "Changelog",
|
||||
"LOGOUT": "Log out"
|
||||
},
|
||||
"APP_GLOBAL": {
|
||||
|
||||
@@ -17,11 +17,6 @@
|
||||
"IP_ADDRESS": "Ip Adresse",
|
||||
"CREATED_AT_LABEL": "Created",
|
||||
"NEW_MESSAGE": "Ny besked",
|
||||
"CALL": "Call",
|
||||
"CALL_UNDER_DEVELOPMENT": "Calling is under development",
|
||||
"VOICE_INBOX_PICKER": {
|
||||
"TITLE": "Choose a voice inbox"
|
||||
},
|
||||
"CONVERSATIONS": {
|
||||
"NO_RECORDS_FOUND": "Der er ingen tidligere samtaler tilknyttet denne kontakt.",
|
||||
"TITLE": "Tidligere Samtaler"
|
||||
|
||||
@@ -330,14 +330,13 @@
|
||||
"LABEL": "API Key Secret",
|
||||
"PLACEHOLDER": "Enter your Twilio API Key Secret",
|
||||
"REQUIRED": "API Key Secret is required"
|
||||
},
|
||||
"TWIML_APP_SID": {
|
||||
"LABEL": "TwiML App SID",
|
||||
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
|
||||
"REQUIRED": "TwiML App SID is required"
|
||||
}
|
||||
},
|
||||
"CONFIGURATION": {
|
||||
"TWILIO_VOICE_URL_TITLE": "Twilio Voice URL",
|
||||
"TWILIO_VOICE_URL_SUBTITLE": "Configure this URL as the Voice URL on your Twilio phone number and TwiML App.",
|
||||
"TWILIO_STATUS_URL_TITLE": "Twilio Status Callback URL",
|
||||
"TWILIO_STATUS_URL_SUBTITLE": "Configure this URL as the Status Callback URL on your Twilio phone number."
|
||||
},
|
||||
"SUBMIT_BUTTON": "Create Voice Channel",
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "We were not able to create the voice channel"
|
||||
|
||||
@@ -226,7 +226,6 @@
|
||||
"APPEARANCE": "Change appearance",
|
||||
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
|
||||
"DOCS": "Read documentation",
|
||||
"CHANGELOG": "Changelog",
|
||||
"LOGOUT": "Log out"
|
||||
},
|
||||
"APP_GLOBAL": {
|
||||
|
||||
@@ -17,11 +17,6 @@
|
||||
"IP_ADDRESS": "IP-Adresse",
|
||||
"CREATED_AT_LABEL": "Erstellt",
|
||||
"NEW_MESSAGE": "Neue Nachricht",
|
||||
"CALL": "Call",
|
||||
"CALL_UNDER_DEVELOPMENT": "Calling is under development",
|
||||
"VOICE_INBOX_PICKER": {
|
||||
"TITLE": "Choose a voice inbox"
|
||||
},
|
||||
"CONVERSATIONS": {
|
||||
"NO_RECORDS_FOUND": "Es sind keine vorherigen Gespräche mit diesem Kontakt verbunden.",
|
||||
"TITLE": "Vorherige Gespräche"
|
||||
|
||||
@@ -330,14 +330,13 @@
|
||||
"LABEL": "API-Schlüssel-Geheimnis",
|
||||
"PLACEHOLDER": "Enter your Twilio API Key Secret",
|
||||
"REQUIRED": "API Key Secret is required"
|
||||
},
|
||||
"TWIML_APP_SID": {
|
||||
"LABEL": "TwiML App SID",
|
||||
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
|
||||
"REQUIRED": "TwiML App SID is required"
|
||||
}
|
||||
},
|
||||
"CONFIGURATION": {
|
||||
"TWILIO_VOICE_URL_TITLE": "Twilio Voice URL",
|
||||
"TWILIO_VOICE_URL_SUBTITLE": "Configure this URL as the Voice URL on your Twilio phone number and TwiML App.",
|
||||
"TWILIO_STATUS_URL_TITLE": "Twilio Status Callback URL",
|
||||
"TWILIO_STATUS_URL_SUBTITLE": "Configure this URL as the Status Callback URL on your Twilio phone number."
|
||||
},
|
||||
"SUBMIT_BUTTON": "Create Voice Channel",
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "We were not able to create the voice channel"
|
||||
|
||||
@@ -226,7 +226,6 @@
|
||||
"APPEARANCE": "Change appearance",
|
||||
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
|
||||
"DOCS": "Read documentation",
|
||||
"CHANGELOG": "Changelog",
|
||||
"LOGOUT": "Log out"
|
||||
},
|
||||
"APP_GLOBAL": {
|
||||
|
||||
@@ -17,11 +17,6 @@
|
||||
"IP_ADDRESS": "Διεύθυνση IP",
|
||||
"CREATED_AT_LABEL": "Created",
|
||||
"NEW_MESSAGE": "Νέο Μήνυμα",
|
||||
"CALL": "Call",
|
||||
"CALL_UNDER_DEVELOPMENT": "Calling is under development",
|
||||
"VOICE_INBOX_PICKER": {
|
||||
"TITLE": "Choose a voice inbox"
|
||||
},
|
||||
"CONVERSATIONS": {
|
||||
"NO_RECORDS_FOUND": "Δεν υπάρχουν προηγούμενες συνομιλίες που σχετίζονται με αυτήν την επαφή.",
|
||||
"TITLE": "Προηγούμενες συνομιλίες"
|
||||
|
||||
@@ -330,14 +330,13 @@
|
||||
"LABEL": "API Key Secret",
|
||||
"PLACEHOLDER": "Enter your Twilio API Key Secret",
|
||||
"REQUIRED": "API Key Secret is required"
|
||||
},
|
||||
"TWIML_APP_SID": {
|
||||
"LABEL": "TwiML App SID",
|
||||
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
|
||||
"REQUIRED": "TwiML App SID is required"
|
||||
}
|
||||
},
|
||||
"CONFIGURATION": {
|
||||
"TWILIO_VOICE_URL_TITLE": "Twilio Voice URL",
|
||||
"TWILIO_VOICE_URL_SUBTITLE": "Configure this URL as the Voice URL on your Twilio phone number and TwiML App.",
|
||||
"TWILIO_STATUS_URL_TITLE": "Twilio Status Callback URL",
|
||||
"TWILIO_STATUS_URL_SUBTITLE": "Configure this URL as the Status Callback URL on your Twilio phone number."
|
||||
},
|
||||
"SUBMIT_BUTTON": "Create Voice Channel",
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "We were not able to create the voice channel"
|
||||
|
||||
@@ -226,7 +226,6 @@
|
||||
"APPEARANCE": "Change appearance",
|
||||
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
|
||||
"DOCS": "Read documentation",
|
||||
"CHANGELOG": "Changelog",
|
||||
"LOGOUT": "Log out"
|
||||
},
|
||||
"APP_GLOBAL": {
|
||||
|
||||
@@ -612,15 +612,6 @@
|
||||
"SEND_MESSAGE": "Send message"
|
||||
}
|
||||
},
|
||||
"TWILIO_OPTIONS": {
|
||||
"LABEL": "Select template",
|
||||
"SEARCH_PLACEHOLDER": "Search templates",
|
||||
"EMPTY_STATE": "No templates found",
|
||||
"TEMPLATE_PARSER": {
|
||||
"BACK": "Go back",
|
||||
"SEND_MESSAGE": "Send message"
|
||||
}
|
||||
},
|
||||
"ACTION_BUTTONS": {
|
||||
"DISCARD": "Discard",
|
||||
"SEND": "Send ({keyCode})"
|
||||
|
||||
@@ -48,8 +48,7 @@
|
||||
"CREATED_AT": "Created At",
|
||||
"LAST_ACTIVITY": "Last Activity",
|
||||
"REFERER_LINK": "Referrer link",
|
||||
"BLOCKED": "Blocked",
|
||||
"LABELS": "Labels"
|
||||
"BLOCKED": "Blocked"
|
||||
},
|
||||
"GROUPS": {
|
||||
"STANDARD_FILTERS": "Standard Filters",
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
{
|
||||
"CONTENT_TEMPLATES": {
|
||||
"MODAL": {
|
||||
"TITLE": "Twilio Templates",
|
||||
"SUBTITLE": "Select the Twilio template you want to send",
|
||||
"TEMPLATE_SELECTED_SUBTITLE": "Configure template: {templateName}"
|
||||
},
|
||||
"PICKER": {
|
||||
"SEARCH_PLACEHOLDER": "Search Templates",
|
||||
"NO_TEMPLATES_FOUND": "No templates found for",
|
||||
"NO_CONTENT": "No content",
|
||||
"HEADER": "Header",
|
||||
"BODY": "Body",
|
||||
"FOOTER": "Footer",
|
||||
"BUTTONS": "Buttons",
|
||||
"CATEGORY": "Category",
|
||||
"MEDIA_CONTENT": "Media Content",
|
||||
"MEDIA_CONTENT_FALLBACK": "media content",
|
||||
"NO_TEMPLATES_AVAILABLE": "No Twilio templates available. Click refresh to sync templates from Twilio.",
|
||||
"REFRESH_BUTTON": "Refresh templates",
|
||||
"REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
|
||||
"REFRESH_ERROR": "Failed to refresh templates. Please try again.",
|
||||
"LABELS": {
|
||||
"LANGUAGE": "Language",
|
||||
"TEMPLATE_BODY": "Template Body",
|
||||
"CATEGORY": "Category"
|
||||
},
|
||||
"TYPES": {
|
||||
"MEDIA": "Media",
|
||||
"QUICK_REPLY": "Quick Reply",
|
||||
"TEXT": "Text"
|
||||
}
|
||||
},
|
||||
"PARSER": {
|
||||
"VARIABLES_LABEL": "Variables",
|
||||
"LANGUAGE": "Language",
|
||||
"CATEGORY": "Category",
|
||||
"VARIABLE_PLACEHOLDER": "Enter {variable} value",
|
||||
"GO_BACK_LABEL": "Go Back",
|
||||
"SEND_MESSAGE_LABEL": "Send Message",
|
||||
"FORM_ERROR_MESSAGE": "Please fill all variables before sending",
|
||||
"MEDIA_HEADER_LABEL": "{type} Header",
|
||||
"MEDIA_URL_LABEL": "Enter full media URL",
|
||||
"MEDIA_URL_PLACEHOLDER": "https://example.com/image.jpg"
|
||||
},
|
||||
"FORM": {
|
||||
"BACK_BUTTON": "Back",
|
||||
"SEND_MESSAGE_BUTTON": "Send Message"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,11 +35,6 @@
|
||||
"API_HOURS_WINDOW": "You can only reply to this conversation within {hours} hours",
|
||||
"NOT_ASSIGNED_TO_YOU": "This conversation is not assigned to you. Would you like to assign this conversation to yourself?",
|
||||
"ASSIGN_TO_ME": "Assign to me",
|
||||
"BOT_HANDOFF_MESSAGE": "You are responding to a conversation which is currently handled by an assistant or a bot.",
|
||||
"BOT_HANDOFF_ACTION": "Mark open and assign to you",
|
||||
"BOT_HANDOFF_REOPEN_ACTION": "Mark conversation open",
|
||||
"BOT_HANDOFF_SUCCESS": "Conversation has been handed over to you",
|
||||
"BOT_HANDOFF_ERROR": "Failed to take over the conversation. Please try again.",
|
||||
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
|
||||
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
|
||||
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You won’t be able to send messages from this conversation anymore.",
|
||||
|
||||
@@ -807,58 +807,6 @@
|
||||
"ERROR_MESSAGE": "Unable to update portal"
|
||||
}
|
||||
}
|
||||
},
|
||||
"PDF_UPLOAD": {
|
||||
"TITLE": "Upload PDF Document",
|
||||
"DESCRIPTION": "Upload a PDF document to automatically generate FAQs using AI",
|
||||
"DRAG_DROP_TEXT": "Drag and drop your PDF file here, or click to select",
|
||||
"SELECT_FILE": "Select PDF File",
|
||||
"ADDITIONAL_CONTEXT_LABEL": "Additional Context (Optional)",
|
||||
"ADDITIONAL_CONTEXT_PLACEHOLDER": "Provide any additional context or instructions for FAQ generation...",
|
||||
"UPLOADING": "Uploading...",
|
||||
"UPLOAD": "Upload & Process",
|
||||
"CANCEL": "Cancel",
|
||||
"ERROR_INVALID_TYPE": "Please select a valid PDF file",
|
||||
"ERROR_FILE_TOO_LARGE": "File size must be less than 512MB",
|
||||
"ERROR_UPLOAD_FAILED": "Failed to upload PDF. Please try again."
|
||||
},
|
||||
"PDF_DOCUMENTS": {
|
||||
"TITLE": "PDF Documents",
|
||||
"DESCRIPTION": "Manage uploaded PDF documents and generate FAQs from them",
|
||||
"UPLOAD_PDF": "Upload PDF",
|
||||
"UPLOAD_FIRST_PDF": "Upload your first PDF",
|
||||
"UPLOADED_BY": "Uploaded by",
|
||||
"GENERATE_FAQS": "Generate FAQs",
|
||||
"GENERATING": "Generating...",
|
||||
"CONFIRM_DELETE": "Are you sure you want to delete {filename}?",
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "No PDF documents yet",
|
||||
"DESCRIPTION": "Upload PDF documents to automatically generate FAQs using AI"
|
||||
},
|
||||
"STATUS": {
|
||||
"UPLOADED": "Ready",
|
||||
"PROCESSING": "Processing",
|
||||
"PROCESSED": "Completed",
|
||||
"FAILED": "Failed"
|
||||
}
|
||||
},
|
||||
"CONTENT_GENERATION": {
|
||||
"TITLE": "Content Generation",
|
||||
"DESCRIPTION": "Upload PDF documents to automatically generate FAQ content using AI",
|
||||
"UPLOAD_TITLE": "Upload PDF Document",
|
||||
"DRAG_DROP": "Drag and drop your PDF file here, or click to select",
|
||||
"SELECT_FILE": "Select PDF File",
|
||||
"UPLOADING": "Processing document...",
|
||||
"UPLOAD_SUCCESS": "Document processed successfully!",
|
||||
"UPLOAD_ERROR": "Failed to upload document. Please try again.",
|
||||
"INVALID_FILE_TYPE": "Please select a valid PDF file",
|
||||
"FILE_TOO_LARGE": "File size must be less than 512MB",
|
||||
"GENERATED_CONTENT": "Generated FAQ Content",
|
||||
"PUBLISH_SELECTED": "Publish Selected",
|
||||
"PUBLISHING": "Publishing...",
|
||||
"FROM_DOCUMENT": "From document",
|
||||
"NO_CONTENT": "No generated content available. Upload a PDF document to get started.",
|
||||
"LOADING": "Loading generated content..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,14 +330,13 @@
|
||||
"LABEL": "API Key Secret",
|
||||
"PLACEHOLDER": "Enter your Twilio API Key Secret",
|
||||
"REQUIRED": "API Key Secret is required"
|
||||
},
|
||||
"TWIML_APP_SID": {
|
||||
"LABEL": "TwiML App SID",
|
||||
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
|
||||
"REQUIRED": "TwiML App SID is required"
|
||||
}
|
||||
},
|
||||
"CONFIGURATION": {
|
||||
"TWILIO_VOICE_URL_TITLE": "Twilio Voice URL",
|
||||
"TWILIO_VOICE_URL_SUBTITLE": "Configure this URL as the Voice URL on your Twilio phone number and TwiML App.",
|
||||
"TWILIO_STATUS_URL_TITLE": "Twilio Status Callback URL",
|
||||
"TWILIO_STATUS_URL_SUBTITLE": "Configure this URL as the Status Callback URL on your Twilio phone number."
|
||||
},
|
||||
"SUBMIT_BUTTON": "Create Voice Channel",
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "We were not able to create the voice channel"
|
||||
@@ -424,51 +423,7 @@
|
||||
},
|
||||
"AUTH": {
|
||||
"TITLE": "Choose a channel",
|
||||
"DESC": "Chatwoot supports live-chat widgets, Facebook Messenger, WhatsApp, Emails, etc., as channels. If you want to build a custom channel, you can create it using the API channel. To get started, choose one of the channels below.",
|
||||
"TITLE_NEXT": "Complete the setup",
|
||||
"TITLE_FINISH": "Voilà!",
|
||||
"CHANNEL": {
|
||||
"WEBSITE": {
|
||||
"TITLE": "Website",
|
||||
"DESCRIPTION": "Create a live-chat widget"
|
||||
},
|
||||
"FACEBOOK": {
|
||||
"TITLE": "Facebook",
|
||||
"DESCRIPTION": "Connect your Facebook page"
|
||||
},
|
||||
"WHATSAPP": {
|
||||
"TITLE": "WhatsApp",
|
||||
"DESCRIPTION": "Support your customers on WhatsApp"
|
||||
},
|
||||
"EMAIL": {
|
||||
"TITLE": "Email",
|
||||
"DESCRIPTION": "Connect with Gmail, Outlook, or other providers"
|
||||
},
|
||||
"SMS": {
|
||||
"TITLE": "SMS",
|
||||
"DESCRIPTION": "Integrate SMS channel with Twilio or bandwidth"
|
||||
},
|
||||
"API": {
|
||||
"TITLE": "API",
|
||||
"DESCRIPTION": "Make a custom channel using our API"
|
||||
},
|
||||
"TELEGRAM": {
|
||||
"TITLE": "Telegram",
|
||||
"DESCRIPTION": "Configure Telegram channel using Bot token"
|
||||
},
|
||||
"LINE": {
|
||||
"TITLE": "Line",
|
||||
"DESCRIPTION": "Integrate your Line channel"
|
||||
},
|
||||
"INSTAGRAM": {
|
||||
"TITLE": "Instagram",
|
||||
"DESCRIPTION": "Connect your instagram account"
|
||||
},
|
||||
"VOICE": {
|
||||
"TITLE": "Voice",
|
||||
"DESCRIPTION": "Integrate with Twilio Voice"
|
||||
}
|
||||
}
|
||||
"DESC": "Chatwoot supports live-chat widgets, Facebook Messenger, WhatsApp, Emails, etc., as channels. If you want to build a custom channel, you can create it using the API channel. To get started, choose one of the channels below."
|
||||
},
|
||||
"AGENTS": {
|
||||
"TITLE": "Agents",
|
||||
@@ -522,10 +477,7 @@
|
||||
"MESSAGE": "You can now engage with your customers through your new Channel. Happy supporting",
|
||||
"BUTTON_TEXT": "Take me there",
|
||||
"MORE_SETTINGS": "More settings",
|
||||
"WEBSITE_SUCCESS": "You have successfully finished creating a website channel. Copy the code shown below and paste it on your website. Next time a customer use the live chat, the conversation will automatically appear on your inbox.",
|
||||
"WHATSAPP_QR_INSTRUCTION": "Scan the QR code above to quickly test your WhatsApp inbox",
|
||||
"MESSENGER_QR_INSTRUCTION": "Scan the QR code above to quickly test your Facebook Messenger inbox",
|
||||
"TELEGRAM_QR_INSTRUCTION": "Scan the QR code above to quickly test your Telegram inbox"
|
||||
"WEBSITE_SUCCESS": "You have successfully finished creating a website channel. Copy the code shown below and paste it on your website. Next time a customer use the live chat, the conversation will automatically appear on your inbox."
|
||||
},
|
||||
"REAUTH": "Reauthorize",
|
||||
"VIEW": "View",
|
||||
@@ -915,18 +867,9 @@
|
||||
"SCRIPT_SETTINGS": "\n window.chatwootSettings = {options};"
|
||||
},
|
||||
"EMAIL_PROVIDERS": {
|
||||
"MICROSOFT": {
|
||||
"TITLE": "Microsoft",
|
||||
"DESCRIPTION": "Connect with Microsoft"
|
||||
},
|
||||
"GOOGLE": {
|
||||
"TITLE": "Google",
|
||||
"DESCRIPTION": "Connect with Google"
|
||||
},
|
||||
"OTHER_PROVIDERS": {
|
||||
"TITLE": "Other Providers",
|
||||
"DESCRIPTION": "Connect with Other Providers"
|
||||
}
|
||||
"MICROSOFT": "Microsoft",
|
||||
"GOOGLE": "Google",
|
||||
"OTHER_PROVIDERS": "Other Providers"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"MESSENGER": "Messenger",
|
||||
|
||||
@@ -35,7 +35,6 @@ import signup from './signup.json';
|
||||
import sla from './sla.json';
|
||||
import teamsSettings from './teamsSettings.json';
|
||||
import whatsappTemplates from './whatsappTemplates.json';
|
||||
import contentTemplates from './contentTemplates.json';
|
||||
|
||||
export default {
|
||||
...advancedFilters,
|
||||
@@ -75,5 +74,4 @@ export default {
|
||||
...sla,
|
||||
...teamsSettings,
|
||||
...whatsappTemplates,
|
||||
...contentTemplates,
|
||||
};
|
||||
|
||||
@@ -701,28 +701,11 @@
|
||||
"ERROR_MESSAGE": "There was an error creating the document, please try again."
|
||||
},
|
||||
"FORM": {
|
||||
"TYPE": {
|
||||
"LABEL": "Document Type",
|
||||
"URL": "URL",
|
||||
"PDF": "PDF File"
|
||||
},
|
||||
"URL": {
|
||||
"LABEL": "URL",
|
||||
"PLACEHOLDER": "Enter the URL of the document",
|
||||
"ERROR": "Please provide a valid URL for the document"
|
||||
},
|
||||
"PDF_FILE": {
|
||||
"LABEL": "PDF File",
|
||||
"CHOOSE_FILE": "Choose PDF file",
|
||||
"ERROR": "Please select a PDF file",
|
||||
"HELP_TEXT": "Maximum file size: 10MB",
|
||||
"INVALID_TYPE": "Please select a valid PDF file",
|
||||
"TOO_LARGE": "File size exceeds 10MB limit"
|
||||
},
|
||||
"NAME": {
|
||||
"LABEL": "Document Name (Optional)",
|
||||
"PLACEHOLDER": "Enter a name for the document"
|
||||
},
|
||||
"ASSISTANT": {
|
||||
"LABEL": "Assistant",
|
||||
"PLACEHOLDER": "Select the assistant",
|
||||
|
||||
@@ -266,8 +266,6 @@
|
||||
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
|
||||
"DOWNLOAD_INBOX_REPORTS": "Download inbox reports",
|
||||
"FILTER_DROPDOWN_LABEL": "Select Inbox",
|
||||
"ALL_INBOXES": "All Inboxes",
|
||||
"SEARCH_INBOX": "Search Inbox",
|
||||
"METRICS": {
|
||||
"CONVERSATIONS": {
|
||||
"NAME": "Conversations",
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
"AGENT_BOTS": {
|
||||
"HEADER": "Bots",
|
||||
"LOADING_EDITOR": "Cargando el editor...",
|
||||
"DESCRIPTION": "Los bots de agentes son como los miembros más fabulosos de tu equipo. Ellos pueden manejar las cosas pequeñas, así que usted puede centrarse en las cosas que importan. Inténtalos. Puedes administrar tus bots desde esta página o crear nuevos usando el botón 'Configurar un nuevo bot'.",
|
||||
"DESCRIPTION": "Agent Bots are like the most fabulous members of your team. They can handle the small stuff, so you can focus on the stuff that matters. Give them a try. You can manage your bots from this page or create new ones using the 'Add Bot' button.",
|
||||
"LEARN_MORE": "Learn about agent bots",
|
||||
"GLOBAL_BOT": "Bot del sistema",
|
||||
"GLOBAL_BOT": "System bot",
|
||||
"GLOBAL_BOT_BADGE": "Sistema",
|
||||
"AVATAR": {
|
||||
"SUCCESS_DELETE": "Avatar del bot eliminado correctamente",
|
||||
"ERROR_DELETE": "Error al borrar el bot, por favor intentar más tarde"
|
||||
"SUCCESS_DELETE": "Bot avatar deleted successfully",
|
||||
"ERROR_DELETE": "Error deleting bot avatar, please try again"
|
||||
},
|
||||
"BOT_CONFIGURATION": {
|
||||
"TITLE": "Seleccione un bot de agente",
|
||||
@@ -22,7 +22,7 @@
|
||||
"SELECT_PLACEHOLDER": "Seleccionar bot"
|
||||
},
|
||||
"ADD": {
|
||||
"TITLE": "Agregar Bot",
|
||||
"TITLE": "Add Bot",
|
||||
"CANCEL_BUTTON_TEXT": "Cancelar",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Bot añadido correctamente.",
|
||||
@@ -30,10 +30,10 @@
|
||||
}
|
||||
},
|
||||
"LIST": {
|
||||
"404": "No sé encontró ningún bot. Puedes crear uno haciendo clic en 'crear un Bot'.",
|
||||
"404": "No bots found. You can create a bot by clicking the 'Add Bot' button.",
|
||||
"LOADING": "Obteniendo bots...",
|
||||
"TABLE_HEADER": {
|
||||
"DETAILS": "Detalles del Bot",
|
||||
"DETAILS": "Bot Details",
|
||||
"URL": "URL de Webhook"
|
||||
}
|
||||
},
|
||||
@@ -42,7 +42,7 @@
|
||||
"TITLE": "Eliminar bot",
|
||||
"CONFIRM": {
|
||||
"TITLE": "Confirmar eliminación",
|
||||
"MESSAGE": "¿Está seguro que desea borrar {name}?",
|
||||
"MESSAGE": "Are you sure you want to delete {name}?",
|
||||
"YES": "Sí, eliminar",
|
||||
"NO": "No, mantenerlo"
|
||||
},
|
||||
@@ -61,18 +61,18 @@
|
||||
},
|
||||
"ACCESS_TOKEN": {
|
||||
"TITLE": "Token de acceso",
|
||||
"DESCRIPTION": "Copie el código de acceso y guardarlo seguro",
|
||||
"COPY_SUCCESSFUL": "Código de acceso copiado en el portapapeles",
|
||||
"RESET_SUCCESS": "Código de acceso regenerado exitosamente",
|
||||
"RESET_ERROR": "Dirección del webhook es requerida"
|
||||
"DESCRIPTION": "Copy the access token and save it securely",
|
||||
"COPY_SUCCESSFUL": "Access token copied to clipboard",
|
||||
"RESET_SUCCESS": "Access token regenerated successfully",
|
||||
"RESET_ERROR": "Unable to regenerate access token. Please try again"
|
||||
},
|
||||
"FORM": {
|
||||
"AVATAR": {
|
||||
"LABEL": "Avatar del Bot"
|
||||
"LABEL": "Bot avatar"
|
||||
},
|
||||
"NAME": {
|
||||
"LABEL": "Nombre del bot",
|
||||
"PLACEHOLDER": "Nombre del Bot",
|
||||
"PLACEHOLDER": "Enter bot name",
|
||||
"REQUIRED": "El nombre del bot es obligatorio"
|
||||
},
|
||||
"DESCRIPTION": {
|
||||
@@ -82,11 +82,11 @@
|
||||
"WEBHOOK_URL": {
|
||||
"LABEL": "URL de Webhook",
|
||||
"PLACEHOLDER": "https://example.com/webhook",
|
||||
"REQUIRED": "Dirección del webhook es requerida"
|
||||
"REQUIRED": "Webhook URL is required"
|
||||
},
|
||||
"ERRORS": {
|
||||
"NAME": "El nombre del bot es obligatorio",
|
||||
"URL": "Dirección del webhook es requerida",
|
||||
"URL": "Webhook URL is required",
|
||||
"VALID_URL": "Please enter a valid URL starting with http:// or https://"
|
||||
},
|
||||
"CANCEL": "Cancelar",
|
||||
@@ -94,7 +94,7 @@
|
||||
"UPDATE": "Update Bot"
|
||||
},
|
||||
"WEBHOOK": {
|
||||
"DESCRIPTION": "Configure el webhook del bot para integrarse con sus servicios personalizados. El bot recibirá y procesará eventos de conversaciones y podrá responder a ellos."
|
||||
"DESCRIPTION": "Configure a webhook bot to integrate with your custom services. The bot will receive and process events from conversations and can respond to them."
|
||||
},
|
||||
"TYPES": {
|
||||
"WEBHOOK": "Webhook Bot"
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"TEXT": "Texto",
|
||||
"NUMBER": "Número",
|
||||
"LINK": "Enlace",
|
||||
"DATE": "Fecha",
|
||||
"DATE": "Date",
|
||||
"LIST": "Lista",
|
||||
"CHECKBOX": "Casilla"
|
||||
},
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
"CONVERSATION_CREATED": "Conversación creada",
|
||||
"CONVERSATION_UPDATED": "Conversación actualizada",
|
||||
"MESSAGE_CREATED": "Mensaje creado",
|
||||
"CONVERSATION_RESOLVED": "Conversación Resuelta",
|
||||
"CONVERSATION_RESOLVED": "Conversation Resolved",
|
||||
"CONVERSATION_OPENED": "Conversación abierta"
|
||||
},
|
||||
"ACTIONS": {
|
||||
@@ -147,14 +147,14 @@
|
||||
"SEND_WEBHOOK_EVENT": "Enviar Evento de Webhook",
|
||||
"SEND_ATTACHMENT": "Enviar archivo adjunto",
|
||||
"SEND_MESSAGE": "Enviar mensaje",
|
||||
"ADD_PRIVATE_NOTE": "Añadir una nota privada",
|
||||
"ADD_PRIVATE_NOTE": "Add a Private Note",
|
||||
"CHANGE_PRIORITY": "Cambiar prioridad",
|
||||
"ADD_SLA": "Añadir SLA",
|
||||
"OPEN_CONVERSATION": "Abrir conversación"
|
||||
},
|
||||
"MESSAGE_TYPES": {
|
||||
"INCOMING": "Mensaje entrante",
|
||||
"OUTGOING": "Mensaje saliente"
|
||||
"INCOMING": "Incoming Message",
|
||||
"OUTGOING": "Outgoing Message"
|
||||
},
|
||||
"PRIORITY_TYPES": {
|
||||
"NONE": "Ninguna",
|
||||
@@ -175,7 +175,7 @@
|
||||
"MAIL_SUBJECT": "Asunto de Email",
|
||||
"COUNTRY_NAME": "País",
|
||||
"REFERER_LINK": "Enlace de referencia",
|
||||
"ASSIGNEE_NAME": "Asignado a",
|
||||
"ASSIGNEE_NAME": "Cesionario",
|
||||
"TEAM_NAME": "Equipo",
|
||||
"PRIORITY": "Prioridad"
|
||||
}
|
||||
|
||||
@@ -17,11 +17,6 @@
|
||||
"IP_ADDRESS": "Dirección IP",
|
||||
"CREATED_AT_LABEL": "Creado",
|
||||
"NEW_MESSAGE": "Nuevo mensaje",
|
||||
"CALL": "Llamar",
|
||||
"CALL_UNDER_DEVELOPMENT": "Llamar está bajo desarrollo",
|
||||
"VOICE_INBOX_PICKER": {
|
||||
"TITLE": "Seleccionar un buzón de entrada"
|
||||
},
|
||||
"CONVERSATIONS": {
|
||||
"NO_RECORDS_FOUND": "No hay conversaciones previas asociadas a este contacto.",
|
||||
"TITLE": "Conversaciones anteriores"
|
||||
|
||||
@@ -144,9 +144,9 @@
|
||||
"AGENTS_LOADING": "Cargando agentes...",
|
||||
"ASSIGN_TEAM": "Asignar equipo",
|
||||
"DELETE": "Eliminar conversación",
|
||||
"OPEN_IN_NEW_TAB": "Abrir en nueva pestaña",
|
||||
"COPY_LINK": "Copiar enlace de conversación",
|
||||
"COPY_LINK_SUCCESS": "Enlace de conversación copiado en portapapeles",
|
||||
"OPEN_IN_NEW_TAB": "Open in new tab",
|
||||
"COPY_LINK": "Copy conversation link",
|
||||
"COPY_LINK_SUCCESS": "Conversation link copied to clipboard",
|
||||
"API": {
|
||||
"AGENT_ASSIGNMENT": {
|
||||
"SUCCESFUL": "ID de conversación {conversationId} asignado a \"{agentName}\"",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"LIMIT_MESSAGES": {
|
||||
"CONVERSATION": "You have exceeded the conversation limit. Hacker plan allows only 500 conversations.",
|
||||
"INBOXES": "You have exceeded the inbox limit. Hacker plan only supports website live-chat. Additional inboxes like email, WhatsApp etc. require a paid plan.",
|
||||
"AGENTS": "Has superado el límite de agentes. Tu plan solo permite {allowedAgents} agentes.",
|
||||
"AGENTS": "You have exceeded the agent limit. Your plan only allows {allowedAgents} agents.",
|
||||
"NON_ADMIN": "Please contact your administrator to upgrade the plan and continue using all features."
|
||||
},
|
||||
"TITLE": "Configuración de la cuenta",
|
||||
@@ -30,7 +30,7 @@
|
||||
"SCHEDULED_DELETION": {
|
||||
"TITLE": "Account Scheduled for Deletion",
|
||||
"MESSAGE_MANUAL": "This account is scheduled for deletion on {deletionDate}. This was requested by an administrator. You can cancel the deletion before this date.",
|
||||
"MESSAGE_INACTIVITY": "Esta cuenta se eliminará el {deletionDate} debido a inactividad. Puedes cancelar la eliminación antes de esta fecha.",
|
||||
"MESSAGE_INACTIVITY": "This account is scheduled for deletion on {deletionDate} due to account inactivity. You can cancel the deletion before this date.",
|
||||
"CLEAR_BUTTON": "Cancel Scheduled Deletion"
|
||||
}
|
||||
},
|
||||
@@ -134,7 +134,7 @@
|
||||
"MULTISELECT": {
|
||||
"ENTER_TO_SELECT": "Pulse Enter para seleccionar",
|
||||
"ENTER_TO_REMOVE": "Presione Enter para eliminar",
|
||||
"NO_OPTIONS": "La lista está vacía",
|
||||
"NO_OPTIONS": "List is empty",
|
||||
"SELECT_ONE": "Seleccione uno",
|
||||
"SELECT": "Seleccionar"
|
||||
}
|
||||
|
||||
@@ -330,14 +330,13 @@
|
||||
"LABEL": "Clave API secreta",
|
||||
"PLACEHOLDER": "Enter your Twilio API Key Secret",
|
||||
"REQUIRED": "API Key Secret is required"
|
||||
},
|
||||
"TWIML_APP_SID": {
|
||||
"LABEL": "TwiML App SID",
|
||||
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
|
||||
"REQUIRED": "TwiML App SID is required"
|
||||
}
|
||||
},
|
||||
"CONFIGURATION": {
|
||||
"TWILIO_VOICE_URL_TITLE": "Twilio Voice URL",
|
||||
"TWILIO_VOICE_URL_SUBTITLE": "Configure this URL as the Voice URL on your Twilio phone number and TwiML App.",
|
||||
"TWILIO_STATUS_URL_TITLE": "Twilio Status Callback URL",
|
||||
"TWILIO_STATUS_URL_SUBTITLE": "Configure this URL as the Status Callback URL on your Twilio phone number."
|
||||
},
|
||||
"SUBMIT_BUTTON": "Create Voice Channel",
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "We were not able to create the voice channel"
|
||||
|
||||
@@ -487,12 +487,12 @@
|
||||
"ASSISTANT": "Assistant"
|
||||
},
|
||||
"BASIC_SETTINGS": {
|
||||
"TITLE": "Configuraciones básicas",
|
||||
"DESCRIPTION": ""
|
||||
"TITLE": "Basic settings",
|
||||
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
|
||||
},
|
||||
"SYSTEM_SETTINGS": {
|
||||
"TITLE": "System settings",
|
||||
"DESCRIPTION": ""
|
||||
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
|
||||
},
|
||||
"CONTROL_ITEMS": {
|
||||
"TITLE": "The Fun Stuff",
|
||||
|
||||
@@ -226,7 +226,6 @@
|
||||
"APPEARANCE": "Cambiar apariencia",
|
||||
"SUPER_ADMIN_CONSOLE": "Consola SuperAdmin",
|
||||
"DOCS": "Leer la documentación",
|
||||
"CHANGELOG": "Changelog",
|
||||
"LOGOUT": "Cerrar sesión"
|
||||
},
|
||||
"APP_GLOBAL": {
|
||||
|
||||
@@ -17,11 +17,6 @@
|
||||
"IP_ADDRESS": "آدرس آیپی",
|
||||
"CREATED_AT_LABEL": "ایجاد شده",
|
||||
"NEW_MESSAGE": "پیام جدید",
|
||||
"CALL": "Call",
|
||||
"CALL_UNDER_DEVELOPMENT": "Calling is under development",
|
||||
"VOICE_INBOX_PICKER": {
|
||||
"TITLE": "Choose a voice inbox"
|
||||
},
|
||||
"CONVERSATIONS": {
|
||||
"NO_RECORDS_FOUND": "این اولین گفتگوی این کاربر است.",
|
||||
"TITLE": "گفتگوهای قبلی"
|
||||
|
||||
@@ -330,14 +330,13 @@
|
||||
"LABEL": "API Key Secret",
|
||||
"PLACEHOLDER": "Enter your Twilio API Key Secret",
|
||||
"REQUIRED": "API Key Secret is required"
|
||||
},
|
||||
"TWIML_APP_SID": {
|
||||
"LABEL": "TwiML App SID",
|
||||
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
|
||||
"REQUIRED": "TwiML App SID is required"
|
||||
}
|
||||
},
|
||||
"CONFIGURATION": {
|
||||
"TWILIO_VOICE_URL_TITLE": "Twilio Voice URL",
|
||||
"TWILIO_VOICE_URL_SUBTITLE": "Configure this URL as the Voice URL on your Twilio phone number and TwiML App.",
|
||||
"TWILIO_STATUS_URL_TITLE": "Twilio Status Callback URL",
|
||||
"TWILIO_STATUS_URL_SUBTITLE": "Configure this URL as the Status Callback URL on your Twilio phone number."
|
||||
},
|
||||
"SUBMIT_BUTTON": "Create Voice Channel",
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "We were not able to create the voice channel"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user