Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68b2aa1e8c | ||
|
|
021aafac0a | ||
|
|
472493b9af | ||
|
|
f801c1928d | ||
|
|
b18341ea6e | ||
|
|
068538e3d6 | ||
|
|
ad90deb709 | ||
|
|
39dfa35229 | ||
|
|
19faa7fdfa | ||
|
|
f6062f992e | ||
|
|
be721c2b50 | ||
|
|
7d6a43fc72 | ||
|
|
655db56be9 | ||
|
|
7f56cd92f8 | ||
|
|
c07529c2b0 | ||
|
|
d48503bdcf | ||
|
|
d35b0a9465 | ||
|
|
83873d49ae | ||
|
|
6ca38e10e9 | ||
|
|
1a1dfd09cb | ||
|
|
35d0a7f1a7 | ||
|
|
47867c0b8a | ||
|
|
530125d4c5 | ||
|
|
7679004bb4 | ||
|
|
ae3ac33049 | ||
|
|
714f24de11 | ||
|
|
3038e672f8 | ||
|
|
efe5609ba4 | ||
|
|
7aad3c9c6b | ||
|
|
c6113852d7 | ||
|
|
94e930a141 | ||
|
|
7fe94dc1a2 | ||
|
|
cae097c5fa | ||
|
|
faf35738b3 | ||
|
|
341487b93e | ||
|
|
f569ed93ad | ||
|
|
eac292b11b | ||
|
|
75119d4a08 | ||
|
|
67d7ee0185 | ||
|
|
7e33e033cd | ||
|
|
02fa76f3df | ||
|
|
91d80004a6 | ||
|
|
b809cd2f15 | ||
|
|
4939508da6 | ||
|
|
c310fe2f76 | ||
|
|
693bed20df | ||
|
|
8627400e6a | ||
|
|
6063a9e8a4 | ||
|
|
634782ea65 |
@@ -55,4 +55,21 @@
|
||||
|
||||
## Ruby Best Practices
|
||||
|
||||
- Use compact `module/class` definitions; avoid nested styles
|
||||
- Use compact `module/class` definitions; avoid nested styles
|
||||
|
||||
## Enterprise Edition Notes
|
||||
|
||||
- Chatwoot has an Enterprise overlay under `enterprise/` that extends/overrides OSS code.
|
||||
- When you add or modify core functionality, always check for corresponding files in `enterprise/` and keep behavior compatible.
|
||||
- Follow the Enterprise development practices documented here:
|
||||
- https://chatwoot.help/hc/handbook/articles/developing-enterprise-edition-features-38
|
||||
|
||||
Practical checklist for any change impacting core logic or public APIs
|
||||
- Search for related files in both trees before editing (e.g., `rg -n "FooService|ControllerName|ModelName" app enterprise`).
|
||||
- If adding new endpoints, services, or models, consider whether Enterprise needs:
|
||||
- An override (e.g., `enterprise/app/...`), or
|
||||
- An extension point (e.g., `prepend_mod_with`, hooks, configuration) to avoid hard forks.
|
||||
- Avoid hardcoding instance- or plan-specific behavior in OSS; prefer configuration, feature flags, or extension points consumed by Enterprise.
|
||||
- Keep request/response contracts stable across OSS and Enterprise; update both sets of routes/controllers when introducing new APIs.
|
||||
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
|
||||
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
|
||||
|
||||
@@ -89,7 +89,7 @@ gem 'wisper', '2.0.0'
|
||||
##--- gems for channels ---##
|
||||
gem 'facebook-messenger'
|
||||
gem 'line-bot-api'
|
||||
gem 'twilio-ruby', '~> 5.66'
|
||||
gem 'twilio-ruby'
|
||||
# twitty will handle subscription of twitter account events
|
||||
# gem 'twitty', git: 'https://github.com/chatwoot/twitty'
|
||||
gem 'twitty', '~> 0.1.5'
|
||||
@@ -108,7 +108,7 @@ gem 'google-cloud-translate-v3', '>= 0.7.0'
|
||||
##-- apm and error monitoring ---#
|
||||
# loaded only when environment variables are set.
|
||||
# ref application.rb
|
||||
gem 'ddtrace', require: false
|
||||
gem 'datadog', '~> 2.0', require: false
|
||||
gem 'elastic-apm', require: false
|
||||
gem 'newrelic_rpm', require: false
|
||||
gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
|
||||
@@ -212,6 +212,8 @@ 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
|
||||
|
||||
+78
-21
@@ -194,10 +194,14 @@ GEM
|
||||
activerecord (>= 5.a)
|
||||
database_cleaner-core (~> 2.0.0)
|
||||
database_cleaner-core (2.0.1)
|
||||
date (3.4.1)
|
||||
ddtrace (0.48.0)
|
||||
ffi (~> 1.0)
|
||||
datadog (2.19.0)
|
||||
datadog-ruby_core_source (~> 3.4, >= 3.4.1)
|
||||
libdatadog (~> 18.1.0.1.0)
|
||||
libddwaf (~> 1.24.1.0.3)
|
||||
logger
|
||||
msgpack
|
||||
datadog-ruby_core_source (3.4.1)
|
||||
date (3.4.1)
|
||||
debug (1.8.0)
|
||||
irb (>= 1.5.0)
|
||||
reline (>= 0.3.1)
|
||||
@@ -226,6 +230,35 @@ 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)
|
||||
@@ -248,8 +281,10 @@ GEM
|
||||
railties (>= 5.0.0)
|
||||
faker (3.2.0)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.9.0)
|
||||
faraday-net_http (>= 2.0, < 3.2)
|
||||
faraday (2.13.1)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
faraday-follow_redirects (0.3.0)
|
||||
faraday (>= 1, < 3)
|
||||
faraday-mashify (0.1.1)
|
||||
@@ -257,13 +292,20 @@ GEM
|
||||
hashie
|
||||
faraday-multipart (1.0.4)
|
||||
multipart-post (~> 2)
|
||||
faraday-net_http (3.1.0)
|
||||
net-http
|
||||
faraday-net_http (3.4.0)
|
||||
net-http (>= 0.5.0)
|
||||
faraday-net_http_persistent (2.1.0)
|
||||
faraday (~> 2.5)
|
||||
net-http-persistent (~> 4.0)
|
||||
faraday-retry (2.2.1)
|
||||
faraday (~> 2.0)
|
||||
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)
|
||||
@@ -417,7 +459,7 @@ GEM
|
||||
judoscale-sidekiq (1.8.2)
|
||||
judoscale-ruby (= 1.8.2)
|
||||
sidekiq (>= 5.0)
|
||||
jwt (2.8.1)
|
||||
jwt (2.10.1)
|
||||
base64
|
||||
kaminari (1.2.2)
|
||||
activesupport (>= 4.1.0)
|
||||
@@ -444,6 +486,16 @@ GEM
|
||||
logger (~> 1.6)
|
||||
letter_opener (1.10.0)
|
||||
launchy (>= 2.2, < 4)
|
||||
libdatadog (18.1.0.1.0)
|
||||
libdatadog (18.1.0.1.0-x86_64-linux)
|
||||
libddwaf (1.24.1.0.3)
|
||||
ffi (~> 1.0)
|
||||
libddwaf (1.24.1.0.3-arm64-darwin)
|
||||
ffi (~> 1.0)
|
||||
libddwaf (1.24.1.0.3-x86_64-darwin)
|
||||
ffi (~> 1.0)
|
||||
libddwaf (1.24.1.0.3-x86_64-linux)
|
||||
ffi (~> 1.0)
|
||||
line-bot-api (1.28.0)
|
||||
lint_roller (1.1.0)
|
||||
liquid (5.4.0)
|
||||
@@ -489,7 +541,7 @@ GEM
|
||||
mutex_m (0.3.0)
|
||||
neighbor (0.2.3)
|
||||
activerecord (>= 5.2)
|
||||
net-http (0.4.1)
|
||||
net-http (0.6.0)
|
||||
uri
|
||||
net-http-persistent (4.0.2)
|
||||
connection_pool (~> 2.2)
|
||||
@@ -577,7 +629,7 @@ GEM
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (2.2.15)
|
||||
rack (3.2.0)
|
||||
rack-attack (6.7.0)
|
||||
rack (>= 1.0, < 4)
|
||||
rack-contrib (2.5.0)
|
||||
@@ -586,19 +638,20 @@ GEM
|
||||
rack (>= 2.0.0)
|
||||
rack-mini-profiler (3.2.0)
|
||||
rack (>= 1.2.0)
|
||||
rack-protection (3.2.0)
|
||||
rack-protection (4.1.1)
|
||||
base64 (>= 0.1.0)
|
||||
rack (~> 2.2, >= 2.2.4)
|
||||
logger (>= 1.6.0)
|
||||
rack (>= 3.0.0, < 4)
|
||||
rack-proxy (0.7.7)
|
||||
rack
|
||||
rack-session (1.0.2)
|
||||
rack (< 3)
|
||||
rack-session (2.1.1)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
rack-test (2.1.0)
|
||||
rack (>= 1.3)
|
||||
rack-timeout (0.6.3)
|
||||
rackup (1.0.1)
|
||||
rack (< 3)
|
||||
webrick
|
||||
rackup (2.2.1)
|
||||
rack (>= 3)
|
||||
rails (7.1.5.2)
|
||||
actioncable (= 7.1.5.2)
|
||||
actionmailbox (= 7.1.5.2)
|
||||
@@ -829,13 +882,17 @@ 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 (5.77.0)
|
||||
twilio-ruby (7.6.0)
|
||||
faraday (>= 0.9, < 3.0)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
nokogiri (>= 1.6, < 2.0)
|
||||
@@ -882,7 +939,6 @@ 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)
|
||||
@@ -928,7 +984,7 @@ DEPENDENCIES
|
||||
commonmarker
|
||||
csv-safe
|
||||
database_cleaner
|
||||
ddtrace
|
||||
datadog (~> 2.0)
|
||||
debug (~> 1.8)
|
||||
devise (>= 4.9.4)
|
||||
devise-secure_password!
|
||||
@@ -1026,8 +1082,9 @@ DEPENDENCIES
|
||||
stripe
|
||||
telephone_number
|
||||
test-prof
|
||||
tidewave
|
||||
time_diff
|
||||
twilio-ruby (~> 5.66)
|
||||
twilio-ruby
|
||||
twitty (~> 0.1.5)
|
||||
tzinfo-data
|
||||
uglifier
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.4.2
|
||||
3.4.3
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
class Api::V1::Accounts::AssignmentPolicies::InboxesController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_assignment_policy
|
||||
before_action -> { check_authorization(AssignmentPolicy) }
|
||||
|
||||
def index
|
||||
@inboxes = @assignment_policy.inboxes
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_assignment_policy
|
||||
@assignment_policy = Current.account.assignment_policies.find(
|
||||
params[:assignment_policy_id]
|
||||
)
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:assignment_policy_id)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,36 @@
|
||||
class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_assignment_policy, only: [:show, :update, :destroy]
|
||||
before_action :check_authorization
|
||||
|
||||
def index
|
||||
@assignment_policies = Current.account.assignment_policies
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def create
|
||||
@assignment_policy = Current.account.assignment_policies.create!(assignment_policy_params)
|
||||
end
|
||||
|
||||
def update
|
||||
@assignment_policy.update!(assignment_policy_params)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@assignment_policy.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_assignment_policy
|
||||
@assignment_policy = Current.account.assignment_policies.find(params[:id])
|
||||
end
|
||||
|
||||
def assignment_policy_params
|
||||
params.require(:assignment_policy).permit(
|
||||
:name, :description, :assignment_order, :conversation_priority,
|
||||
:fair_distribution_limit, :fair_distribution_window, :enabled
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,46 @@
|
||||
class Api::V1::Accounts::Inboxes::AssignmentPoliciesController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_inbox
|
||||
before_action :fetch_assignment_policy, only: [:create]
|
||||
before_action -> { check_authorization(AssignmentPolicy) }
|
||||
before_action :validate_assignment_policy, only: [:show, :destroy]
|
||||
|
||||
def show
|
||||
@assignment_policy = @inbox.assignment_policy
|
||||
end
|
||||
|
||||
def create
|
||||
# There should be only one assignment policy for an inbox.
|
||||
# If there is a new request to add an assignment policy, we will
|
||||
# delete the old one and attach the new policy
|
||||
remove_inbox_assignment_policy
|
||||
@inbox_assignment_policy = @inbox.create_inbox_assignment_policy!(assignment_policy: @assignment_policy)
|
||||
@assignment_policy = @inbox.assignment_policy
|
||||
end
|
||||
|
||||
def destroy
|
||||
remove_inbox_assignment_policy
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def remove_inbox_assignment_policy
|
||||
@inbox.inbox_assignment_policy&.destroy
|
||||
end
|
||||
|
||||
def fetch_inbox
|
||||
@inbox = Current.account.inboxes.find(permitted_params[:inbox_id])
|
||||
end
|
||||
|
||||
def fetch_assignment_policy
|
||||
@assignment_policy = Current.account.assignment_policies.find(permitted_params[:assignment_policy_id])
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:assignment_policy_id, :inbox_id)
|
||||
end
|
||||
|
||||
def validate_assignment_policy
|
||||
return render_not_found_error(I18n.t('errors.assignment_policy.not_found')) unless @inbox.assignment_policy
|
||||
end
|
||||
end
|
||||
@@ -70,11 +70,9 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def sync_templates
|
||||
unless @inbox.channel.is_a?(Channel::Whatsapp)
|
||||
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' }
|
||||
end
|
||||
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
|
||||
|
||||
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||
trigger_template_sync
|
||||
render status: :ok, json: { message: 'Template sync initiated successfully' }
|
||||
rescue StandardError => e
|
||||
render status: :internal_server_error, json: { error: e.message }
|
||||
@@ -185,6 +183,18 @@ 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')
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class PdfDocumentsAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('portals', { accountScoped: true });
|
||||
}
|
||||
|
||||
uploadContent(portalSlug, formData) {
|
||||
return axios.post(`${this.url}/${portalSlug}/upload_content`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getGeneratedContent(portalSlug) {
|
||||
return axios.get(`${this.url}/${portalSlug}/generated_content`);
|
||||
}
|
||||
|
||||
publishContent(portalSlug, responseIds, categoryId = null) {
|
||||
return axios.post(`${this.url}/${portalSlug}/publish_content`, {
|
||||
response_ids: responseIds,
|
||||
category_id: categoryId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default PdfDocumentsAPI;
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup>
|
||||
import { computed, useSlots } from 'vue';
|
||||
import { computed, useSlots, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
||||
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
|
||||
import VoiceCallButton from 'dashboard/components-next/Contacts/VoiceCallButton.vue';
|
||||
|
||||
const props = defineProps({
|
||||
selectedContact: {
|
||||
@@ -24,6 +26,8 @@ const { t } = useI18n();
|
||||
const slots = useSlots();
|
||||
const route = useRoute();
|
||||
|
||||
const isContactSidebarOpen = ref(false);
|
||||
|
||||
const contactId = computed(() => route.params.contactId);
|
||||
|
||||
const selectedContactName = computed(() => {
|
||||
@@ -56,6 +60,15 @@ const handleBreadcrumbClick = () => {
|
||||
const toggleBlock = () => {
|
||||
emit('toggleBlock', isContactBlocked.value);
|
||||
};
|
||||
|
||||
const handleConversationSidebarToggle = () => {
|
||||
isContactSidebarOpen.value = !isContactSidebarOpen.value;
|
||||
};
|
||||
|
||||
const closeMobileSidebar = () => {
|
||||
if (!isContactSidebarOpen.value) return;
|
||||
isContactSidebarOpen.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -67,7 +80,9 @@ const toggleBlock = () => {
|
||||
>
|
||||
<header class="sticky top-0 z-10 px-6 3xl:px-0">
|
||||
<div class="w-full mx-auto max-w-[40.625rem]">
|
||||
<div class="flex items-center justify-between w-full h-20 gap-2">
|
||||
<div
|
||||
class="flex flex-col xs:flex-row items-start xs:items-center justify-between w-full py-7 gap-2"
|
||||
>
|
||||
<Breadcrumb
|
||||
:items="breadcrumbItems"
|
||||
@click="handleBreadcrumbClick"
|
||||
@@ -85,6 +100,11 @@ const toggleBlock = () => {
|
||||
:disabled="isUpdating"
|
||||
@click="toggleBlock"
|
||||
/>
|
||||
<VoiceCallButton
|
||||
:phone="selectedContact?.phoneNumber"
|
||||
:label="$t('CONTACT_PANEL.CALL')"
|
||||
size="sm"
|
||||
/>
|
||||
<ComposeConversation :contact-id="contactId">
|
||||
<template #trigger="{ toggle }">
|
||||
<Button
|
||||
@@ -105,11 +125,65 @@ const toggleBlock = () => {
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Desktop sidebar -->
|
||||
<div
|
||||
v-if="slots.sidebar"
|
||||
class="overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
|
||||
class="hidden lg:block overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
|
||||
>
|
||||
<slot name="sidebar" />
|
||||
</div>
|
||||
|
||||
<!-- Mobile sidebar container -->
|
||||
<div
|
||||
v-if="slots.sidebar"
|
||||
class="lg:hidden fixed top-0 ltr:right-0 rtl:left-0 h-full z-50 flex justify-end transition-all duration-200 ease-in-out"
|
||||
:class="isContactSidebarOpen ? 'w-full' : 'w-16'"
|
||||
>
|
||||
<!-- Toggle button -->
|
||||
<div
|
||||
v-on-click-outside="[
|
||||
closeMobileSidebar,
|
||||
{ ignore: ['#contact-sidebar-content'] },
|
||||
]"
|
||||
class="flex items-start p-1 w-fit h-fit relative order-1 xs:top-24 top-28 transition-all bg-n-solid-2 border border-n-weak duration-500 ease-in-out"
|
||||
:class="[
|
||||
isContactSidebarOpen
|
||||
? 'justify-end ltr:rounded-l-full rtl:rounded-r-full ltr:rounded-r-none rtl:rounded-l-none'
|
||||
: 'justify-center rounded-full ltr:mr-6 rtl:ml-6',
|
||||
]"
|
||||
>
|
||||
<Button
|
||||
ghost
|
||||
slate
|
||||
sm
|
||||
class="!rounded-full rtl:rotate-180"
|
||||
:class="{ 'bg-n-alpha-2': isContactSidebarOpen }"
|
||||
:icon="
|
||||
isContactSidebarOpen
|
||||
? 'i-lucide-panel-right-close'
|
||||
: 'i-lucide-panel-right-open'
|
||||
"
|
||||
data-contact-sidebar-toggle
|
||||
@click="handleConversationSidebarToggle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-transform duration-200 ease-in-out"
|
||||
leave-active-class="transition-transform duration-200 ease-in-out"
|
||||
enter-from-class="ltr:translate-x-full rtl:-translate-x-full"
|
||||
enter-to-class="ltr:translate-x-0 rtl:-translate-x-0"
|
||||
leave-from-class="ltr:translate-x-0 rtl:-translate-x-0"
|
||||
leave-to-class="ltr:translate-x-full rtl:-translate-x-full"
|
||||
>
|
||||
<div
|
||||
v-if="isContactSidebarOpen"
|
||||
id="contact-sidebar-content"
|
||||
class="order-2 w-[85%] sm:w-[50%] bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak overflow-y-auto py-6 shadow-lg"
|
||||
>
|
||||
<slot name="sidebar" />
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
+57
-54
@@ -34,13 +34,13 @@ const emit = defineEmits([
|
||||
<template>
|
||||
<header class="sticky top-0 z-10">
|
||||
<div
|
||||
class="flex items-center justify-between w-full h-20 px-6 gap-2 mx-auto max-w-[60rem]"
|
||||
class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[60rem]"
|
||||
>
|
||||
<span class="text-xl font-medium truncate text-n-slate-12">
|
||||
{{ headerTitle }}
|
||||
</span>
|
||||
<div class="flex items-center flex-shrink-0 gap-4">
|
||||
<div v-if="showSearch" class="flex items-center gap-2">
|
||||
<div class="flex items-center flex-col sm:flex-row flex-shrink-0 gap-4">
|
||||
<div v-if="showSearch" class="flex items-center gap-2 w-full">
|
||||
<Input
|
||||
:model-value="searchValue"
|
||||
type="search"
|
||||
@@ -48,6 +48,7 @@ const emit = defineEmits([
|
||||
:custom-input-class="[
|
||||
'h-8 [&:not(.focus)]:!border-transparent bg-n-alpha-2 dark:bg-n-solid-1 ltr:!pl-8 !py-1 rtl:!pr-8',
|
||||
]"
|
||||
class="w-full"
|
||||
@input="emit('search', $event.target.value)"
|
||||
>
|
||||
<template #prefix>
|
||||
@@ -58,64 +59,66 @@ const emit = defineEmits([
|
||||
</template>
|
||||
</Input>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="!isLabelView && !isActiveView" class="relative">
|
||||
<div class="flex items-center flex-shrink-0 gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="!isLabelView && !isActiveView" class="relative">
|
||||
<Button
|
||||
id="toggleContactsFilterButton"
|
||||
:icon="
|
||||
isSegmentsView ? 'i-lucide-pen-line' : 'i-lucide-list-filter'
|
||||
"
|
||||
color="slate"
|
||||
size="sm"
|
||||
class="relative w-8"
|
||||
variant="ghost"
|
||||
@click="emit('filter')"
|
||||
>
|
||||
<div
|
||||
v-if="hasActiveFilters && !isSegmentsView"
|
||||
class="absolute top-0 right-0 w-2 h-2 rounded-full bg-n-brand"
|
||||
/>
|
||||
</Button>
|
||||
<slot name="filter" />
|
||||
</div>
|
||||
<Button
|
||||
id="toggleContactsFilterButton"
|
||||
:icon="
|
||||
isSegmentsView ? 'i-lucide-pen-line' : 'i-lucide-list-filter'
|
||||
v-if="
|
||||
hasActiveFilters &&
|
||||
!isSegmentsView &&
|
||||
!isLabelView &&
|
||||
!isActiveView
|
||||
"
|
||||
icon="i-lucide-save"
|
||||
color="slate"
|
||||
size="sm"
|
||||
class="relative w-8"
|
||||
variant="ghost"
|
||||
@click="emit('filter')"
|
||||
>
|
||||
<div
|
||||
v-if="hasActiveFilters && !isSegmentsView"
|
||||
class="absolute top-0 right-0 w-2 h-2 rounded-full bg-n-brand"
|
||||
/>
|
||||
</Button>
|
||||
<slot name="filter" />
|
||||
@click="emit('createSegment')"
|
||||
/>
|
||||
<Button
|
||||
v-if="isSegmentsView && !isLabelView && !isActiveView"
|
||||
icon="i-lucide-trash"
|
||||
color="slate"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="emit('deleteSegment')"
|
||||
/>
|
||||
<ContactSortMenu
|
||||
:active-sort="activeSort"
|
||||
:active-ordering="activeOrdering"
|
||||
@update:sort="emit('update:sort', $event)"
|
||||
/>
|
||||
<ContactMoreActions
|
||||
@add="emit('add')"
|
||||
@import="emit('import')"
|
||||
@export="emit('export')"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
v-if="
|
||||
hasActiveFilters &&
|
||||
!isSegmentsView &&
|
||||
!isLabelView &&
|
||||
!isActiveView
|
||||
"
|
||||
icon="i-lucide-save"
|
||||
color="slate"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="emit('createSegment')"
|
||||
/>
|
||||
<Button
|
||||
v-if="isSegmentsView && !isLabelView && !isActiveView"
|
||||
icon="i-lucide-trash"
|
||||
color="slate"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="emit('deleteSegment')"
|
||||
/>
|
||||
<ContactSortMenu
|
||||
:active-sort="activeSort"
|
||||
:active-ordering="activeOrdering"
|
||||
@update:sort="emit('update:sort', $event)"
|
||||
/>
|
||||
<ContactMoreActions
|
||||
@add="emit('add')"
|
||||
@import="emit('import')"
|
||||
@export="emit('export')"
|
||||
/>
|
||||
<div class="w-px h-4 bg-n-strong" />
|
||||
<ComposeConversation>
|
||||
<template #trigger="{ toggle }">
|
||||
<Button :label="buttonLabel" size="sm" @click="toggle" />
|
||||
</template>
|
||||
</ComposeConversation>
|
||||
</div>
|
||||
<div class="w-px h-4 bg-n-strong" />
|
||||
<ComposeConversation>
|
||||
<template #trigger="{ toggle }">
|
||||
<Button :label="buttonLabel" size="sm" @click="toggle" />
|
||||
</template>
|
||||
</ComposeConversation>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
+14
-11
@@ -291,17 +291,20 @@ defineExpose({
|
||||
@delete-segment="openDeleteSegmentDialog"
|
||||
>
|
||||
<template #filter>
|
||||
<ContactsFilter
|
||||
v-if="showFiltersModal"
|
||||
v-model="appliedFilter"
|
||||
:segment-name="activeSegmentName"
|
||||
:is-segment-view="hasActiveSegments"
|
||||
class="absolute mt-1 ltr:right-0 rtl:left-0 top-full"
|
||||
@apply-filter="onApplyFilter"
|
||||
@update-segment="onUpdateSegment"
|
||||
@close="closeAdvanceFiltersModal"
|
||||
@clear-filters="clearFilters"
|
||||
/>
|
||||
<div
|
||||
class="absolute mt-1 ltr:-right-52 rtl:-left-52 sm:ltr:right-0 sm:rtl:left-0 top-full"
|
||||
>
|
||||
<ContactsFilter
|
||||
v-if="showFiltersModal"
|
||||
v-model="appliedFilter"
|
||||
:segment-name="activeSegmentName"
|
||||
:is-segment-view="hasActiveSegments"
|
||||
@apply-filter="onApplyFilter"
|
||||
@update-segment="onUpdateSegment"
|
||||
@close="closeAdvanceFiltersModal"
|
||||
@clear-filters="clearFilters"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</ContactsHeader>
|
||||
|
||||
|
||||
+1
-1
@@ -105,7 +105,7 @@ const handleOrderChange = value => {
|
||||
<div
|
||||
v-if="isMenuOpen"
|
||||
v-on-clickaway="() => (isMenuOpen = false)"
|
||||
class="absolute top-full mt-1 ltr:right-0 rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
|
||||
class="absolute top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm text-n-slate-12">
|
||||
|
||||
@@ -96,10 +96,7 @@ const openFilter = () => {
|
||||
<slot name="default" />
|
||||
</div>
|
||||
</main>
|
||||
<footer
|
||||
v-if="showPaginationFooter"
|
||||
class="sticky bottom-0 z-10 px-4 pb-4"
|
||||
>
|
||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4">
|
||||
<PaginationFooter
|
||||
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
|
||||
:current-page="currentPage"
|
||||
|
||||
+3
-3
@@ -4,8 +4,8 @@ export default [
|
||||
city: 'Los Angeles',
|
||||
country: 'United States',
|
||||
description:
|
||||
"I'm Candice, a developer focusing on building web solutions. Currently, I’m working as a Product Developer at Chatwoot.",
|
||||
companyName: 'Chatwoot',
|
||||
"I'm Candice, a developer focusing on building web solutions. Currently, I’m working as a Product Developer at Lumora.",
|
||||
companyName: 'Lumora',
|
||||
countryCode: 'US',
|
||||
socialProfiles: {
|
||||
github: 'candice-dev',
|
||||
@@ -16,7 +16,7 @@ export default [
|
||||
},
|
||||
},
|
||||
availabilityStatus: 'offline',
|
||||
email: 'candice.matherson@chatwoot.com',
|
||||
email: 'candice.matherson@lumora.com',
|
||||
id: 22,
|
||||
name: 'Candice Matherson',
|
||||
phoneNumber: '+14155552671',
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup>
|
||||
import { computed, ref, useAttrs } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
phone: { type: String, default: '' },
|
||||
label: { type: String, default: '' },
|
||||
icon: { type: [String, Object, Function], default: '' },
|
||||
size: { type: String, default: 'sm' },
|
||||
tooltipLabel: { type: String, default: '' },
|
||||
});
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
const attrs = useAttrs();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const voiceInboxes = computed(() =>
|
||||
(inboxesList.value || []).filter(
|
||||
inbox => inbox.channel_type === INBOX_TYPES.VOICE
|
||||
)
|
||||
);
|
||||
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
|
||||
|
||||
// Unified behavior: hide when no phone
|
||||
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
|
||||
|
||||
const dialogRef = ref(null);
|
||||
|
||||
const onClick = () => {
|
||||
if (voiceInboxes.value.length > 1) {
|
||||
dialogRef.value?.open();
|
||||
return;
|
||||
}
|
||||
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
|
||||
};
|
||||
|
||||
const onPickInbox = () => {
|
||||
// Placeholder until actual call wiring happens
|
||||
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="contents">
|
||||
<Button
|
||||
v-if="shouldRender"
|
||||
v-tooltip.top-end="tooltipLabel || null"
|
||||
v-bind="attrs"
|
||||
:label="label"
|
||||
:icon="icon"
|
||||
:size="size"
|
||||
@click="onClick"
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
v-if="shouldRender && voiceInboxes.length > 1"
|
||||
ref="dialogRef"
|
||||
:title="$t('CONTACT_PANEL.VOICE_INBOX_PICKER.TITLE')"
|
||||
show-cancel-button
|
||||
:show-confirm-button="false"
|
||||
width="md"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<button
|
||||
v-for="inbox in voiceInboxes"
|
||||
:key="inbox.id"
|
||||
type="button"
|
||||
class="flex items-center justify-between w-full px-4 py-2 text-left rounded-lg hover:bg-n-alpha-2"
|
||||
@click="onPickInbox(inbox)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="i-ri-phone-fill text-n-slate-10" />
|
||||
<span class="text-sm text-n-slate-12">{{ inbox.name }}</span>
|
||||
</div>
|
||||
<span v-if="inbox.phone_number" class="text-xs text-n-slate-10">
|
||||
{{ inbox.phone_number }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
</span>
|
||||
</template>
|
||||
+1
@@ -47,6 +47,7 @@ const unreadMessagesCount = computed(() => {
|
||||
</p>
|
||||
<div class="flex items-center flex-shrink-0 gap-2 pb-2">
|
||||
<Avatar
|
||||
v-if="assignee.name"
|
||||
:name="assignee.name"
|
||||
:src="assignee.thumbnail"
|
||||
:size="20"
|
||||
|
||||
+1
@@ -96,6 +96,7 @@ defineExpose({
|
||||
/>
|
||||
</div>
|
||||
<Avatar
|
||||
v-if="assignee.name"
|
||||
:name="assignee.name"
|
||||
:src="assignee.thumbnail"
|
||||
:size="20"
|
||||
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-6">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">
|
||||
{{ $t('HELP_CENTER.CONTENT_GENERATION.TITLE') }}
|
||||
</h3>
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400">
|
||||
{{ $t('HELP_CENTER.CONTENT_GENERATION.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Upload Section -->
|
||||
<div class="mb-8">
|
||||
<h4 class="text-base font-medium text-slate-900 dark:text-slate-100 mb-4">
|
||||
{{ $t('HELP_CENTER.CONTENT_GENERATION.UPLOAD_TITLE') }}
|
||||
</h4>
|
||||
|
||||
<div
|
||||
class="border-2 border-dashed border-slate-300 dark:border-slate-600 rounded-lg p-6 text-center"
|
||||
:class="{
|
||||
'border-woot-500 bg-woot-25': isDragOver,
|
||||
'border-green-500 bg-green-25': uploadSuccess
|
||||
}"
|
||||
@drop="handleDrop"
|
||||
@dragover="handleDragOver"
|
||||
@dragleave="handleDragLeave"
|
||||
>
|
||||
<div v-if="!isUploading && !uploadSuccess">
|
||||
<DocumentPlusIcon class="mx-auto h-12 w-12 text-slate-400 mb-4" />
|
||||
<p class="text-sm text-slate-600 dark:text-slate-300 mb-2">
|
||||
{{ $t('HELP_CENTER.CONTENT_GENERATION.DRAG_DROP') }}
|
||||
</p>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
class="hidden"
|
||||
@change="handleFileSelect"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-woot-700 bg-woot-100 hover:bg-woot-200"
|
||||
@click="$refs.fileInput.click()"
|
||||
>
|
||||
{{ $t('HELP_CENTER.CONTENT_GENERATION.SELECT_FILE') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="isUploading" class="py-4">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-woot-500 mx-auto mb-2"></div>
|
||||
<p class="text-sm text-slate-600">{{ $t('HELP_CENTER.CONTENT_GENERATION.UPLOADING') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="uploadSuccess" class="py-4">
|
||||
<CheckCircleIcon class="mx-auto h-8 w-8 text-green-500 mb-2" />
|
||||
<p class="text-sm text-green-600">{{ $t('HELP_CENTER.CONTENT_GENERATION.UPLOAD_SUCCESS') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="uploadError" class="mt-4 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<p class="text-sm text-red-800">{{ uploadError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Generated Content Section -->
|
||||
<div v-if="generatedResponses.length > 0" class="mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h4 class="text-base font-medium text-slate-900 dark:text-slate-100">
|
||||
{{ $t('HELP_CENTER.CONTENT_GENERATION.GENERATED_CONTENT') }}
|
||||
</h4>
|
||||
<button
|
||||
v-if="selectedResponses.length > 0"
|
||||
class="inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded text-white bg-woot-500 hover:bg-woot-600"
|
||||
@click="publishSelectedContent"
|
||||
:disabled="isPublishing"
|
||||
>
|
||||
<template v-if="isPublishing">
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b border-white mr-2"></div>
|
||||
{{ $t('HELP_CENTER.CONTENT_GENERATION.PUBLISHING') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('HELP_CENTER.CONTENT_GENERATION.PUBLISH_SELECTED') }} ({{ selectedResponses.length }})
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 max-h-96 overflow-y-auto">
|
||||
<div
|
||||
v-for="response in generatedResponses"
|
||||
:key="response.id"
|
||||
class="border border-slate-200 dark:border-slate-600 rounded-lg p-4 hover:bg-slate-50 dark:hover:bg-slate-700"
|
||||
>
|
||||
<div class="flex items-start space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="response.id"
|
||||
v-model="selectedResponses"
|
||||
class="mt-1 rounded border-slate-300 text-woot-500 focus:ring-woot-500"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h5 class="text-sm font-medium text-slate-900 dark:text-slate-100 mb-1">
|
||||
{{ response.question }}
|
||||
</h5>
|
||||
<p class="text-xs text-slate-600 dark:text-slate-400 line-clamp-2">
|
||||
{{ response.answer }}
|
||||
</p>
|
||||
<div class="mt-2 text-xs text-slate-500">
|
||||
{{ $t('HELP_CENTER.CONTENT_GENERATION.FROM_DOCUMENT') }}: {{ response.documentable?.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="!isLoading" class="text-center py-8">
|
||||
<DocumentIcon class="mx-auto h-12 w-12 text-slate-400 mb-4" />
|
||||
<p class="text-slate-600 dark:text-slate-400">
|
||||
{{ $t('HELP_CENTER.CONTENT_GENERATION.NO_CONTENT') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="text-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-woot-500 mx-auto mb-2"></div>
|
||||
<p class="text-slate-600">{{ $t('HELP_CENTER.CONTENT_GENERATION.LOADING') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import {
|
||||
DocumentPlusIcon,
|
||||
DocumentIcon,
|
||||
CheckCircleIcon,
|
||||
} from '@heroicons/vue/24/outline';
|
||||
|
||||
import PdfDocumentsAPI from '../../../../api/helpCenter/pdfDocuments';
|
||||
|
||||
const props = defineProps({
|
||||
portalSlug: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['content-published']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const pdfDocumentsApi = new PdfDocumentsAPI();
|
||||
|
||||
// Reactive data
|
||||
const generatedResponses = ref([]);
|
||||
const selectedResponses = ref([]);
|
||||
const isLoading = ref(false);
|
||||
const isUploading = ref(false);
|
||||
const isPublishing = ref(false);
|
||||
const uploadSuccess = ref(false);
|
||||
const uploadError = ref('');
|
||||
const isDragOver = ref(false);
|
||||
|
||||
// Methods
|
||||
const loadGeneratedContent = async () => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
const response = await pdfDocumentsApi.getGeneratedContent(props.portalSlug);
|
||||
generatedResponses.value = response.data.responses || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to load generated content:', error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
uploadFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (event) => {
|
||||
event.preventDefault();
|
||||
isDragOver.value = false;
|
||||
|
||||
const files = event.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
uploadFile(files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (event) => {
|
||||
event.preventDefault();
|
||||
isDragOver.value = true;
|
||||
};
|
||||
|
||||
const handleDragLeave = (event) => {
|
||||
event.preventDefault();
|
||||
isDragOver.value = false;
|
||||
};
|
||||
|
||||
const uploadFile = async (file) => {
|
||||
if (!validateFile(file)) return;
|
||||
|
||||
try {
|
||||
isUploading.value = true;
|
||||
uploadError.value = '';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('pdf_file', file);
|
||||
|
||||
await pdfDocumentsApi.uploadContent(props.portalSlug, formData);
|
||||
|
||||
uploadSuccess.value = true;
|
||||
|
||||
// Wait a bit then reload content
|
||||
setTimeout(async () => {
|
||||
uploadSuccess.value = false;
|
||||
await loadGeneratedContent();
|
||||
}, 2000);
|
||||
|
||||
} catch (error) {
|
||||
uploadError.value = error.response?.data?.error || t('HELP_CENTER.CONTENT_GENERATION.UPLOAD_ERROR');
|
||||
} finally {
|
||||
isUploading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const validateFile = (file) => {
|
||||
if (file.type !== 'application/pdf') {
|
||||
uploadError.value = t('HELP_CENTER.CONTENT_GENERATION.INVALID_FILE_TYPE');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (file.size > 512 * 1024 * 1024) {
|
||||
uploadError.value = t('HELP_CENTER.CONTENT_GENERATION.FILE_TOO_LARGE');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const publishSelectedContent = async () => {
|
||||
if (selectedResponses.value.length === 0) return;
|
||||
|
||||
try {
|
||||
isPublishing.value = true;
|
||||
|
||||
await pdfDocumentsApi.publishContent(props.portalSlug, selectedResponses.value);
|
||||
|
||||
// Remove published responses from the list
|
||||
generatedResponses.value = generatedResponses.value.filter(
|
||||
response => !selectedResponses.value.includes(response.id)
|
||||
);
|
||||
|
||||
selectedResponses.value = [];
|
||||
emit('content-published');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to publish content:', error);
|
||||
} finally {
|
||||
isPublishing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadGeneratedContent();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-slate-200 dark:border-slate-700 px-6 py-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-slate-900 dark:text-slate-100">
|
||||
{{ $t('HELP_CENTER.CONTENT_GENERATION.TITLE') }}
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-slate-600 dark:text-slate-400">
|
||||
{{ $t('HELP_CENTER.CONTENT_GENERATION.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-auto px-6 py-6">
|
||||
<ContentGeneration
|
||||
:portal-slug="portalSlug"
|
||||
@content-published="handleContentPublished"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import ContentGeneration from './ContentGeneration.vue';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
// Computed properties
|
||||
const portalSlug = computed(() => route.params.portalId);
|
||||
|
||||
// Methods
|
||||
const handleContentPublished = () => {
|
||||
// Could show a success notification here
|
||||
console.log('Content has been published successfully');
|
||||
};
|
||||
</script>
|
||||
-177
@@ -1,177 +0,0 @@
|
||||
<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>
|
||||
@@ -21,9 +21,17 @@ const onClick = (item, index) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav :aria-label="t('BREADCRUMB.ARIA_LABEL')" class="flex items-center h-8">
|
||||
<ol class="flex items-center mb-0">
|
||||
<li v-for="(item, index) in items" :key="index" class="flex items-center">
|
||||
<nav
|
||||
:aria-label="t('BREADCRUMB.ARIA_LABEL')"
|
||||
class="flex items-center h-8 min-w-0"
|
||||
>
|
||||
<ol class="flex items-center mb-0 min-w-0">
|
||||
<li
|
||||
v-for="(item, index) in items"
|
||||
:key="index"
|
||||
class="flex items-center"
|
||||
:class="{ 'min-w-0 flex-1': index === items.length - 1 }"
|
||||
>
|
||||
<Icon
|
||||
v-if="index > 0"
|
||||
icon="i-lucide-chevron-right"
|
||||
@@ -40,7 +48,7 @@ const onClick = (item, index) => {
|
||||
</button>
|
||||
|
||||
<!-- The last breadcrumb item is plain text -->
|
||||
<span v-else class="text-sm truncate text-n-slate-12 max-w-56">
|
||||
<span v-else class="text-sm truncate text-n-slate-12 min-w-0 block">
|
||||
{{ item.emoji ? item.emoji : '' }} {{ item.label }}
|
||||
</span>
|
||||
</li>
|
||||
|
||||
@@ -52,10 +52,10 @@ const handleBreadcrumbClick = item => {
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="mt-4 px-10 flex flex-col w-full h-screen overflow-y-auto bg-n-background"
|
||||
class="px-6 flex flex-col w-full h-screen overflow-y-auto bg-n-background"
|
||||
>
|
||||
<div class="max-w-[60rem] mx-auto flex flex-col w-full h-full mb-4">
|
||||
<header class="mb-7 sticky top-0 z-10 bg-n-background">
|
||||
<header class="mb-7 sticky top-0 bg-n-background pt-4 z-20">
|
||||
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
|
||||
</header>
|
||||
<main class="flex gap-16 w-full flex-1 pb-16">
|
||||
|
||||
@@ -63,6 +63,20 @@ const menuItems = computed(() => {
|
||||
|
||||
const createdAt = computed(() => dynamicTime(props.createdAt));
|
||||
|
||||
const isPdfDocument = computed(() => props.externalLink?.startsWith('PDF:'));
|
||||
const displayLink = computed(() => {
|
||||
if (isPdfDocument.value) {
|
||||
// Remove 'PDF: ' prefix and timestamp suffix for display
|
||||
const fullName = props.externalLink.substring(5);
|
||||
// Remove timestamp suffix (_YYYYMMDDHHMMSS) if present
|
||||
return fullName.replace(/_\d{14}$/, '');
|
||||
}
|
||||
return props.externalLink;
|
||||
});
|
||||
const linkIcon = computed(() =>
|
||||
isPdfDocument.value ? 'i-ph-file-pdf' : 'i-ph-link-simple'
|
||||
);
|
||||
|
||||
const handleAction = ({ action, value }) => {
|
||||
toggleDropdown(false);
|
||||
emit('action', { action, value, id: props.id });
|
||||
@@ -106,8 +120,8 @@ const handleAction = ({ action, value }) => {
|
||||
<span
|
||||
class="text-n-slate-11 text-sm truncate flex justify-start flex-1 items-center gap-1"
|
||||
>
|
||||
<i class="i-ph-link-simple shrink-0" />
|
||||
<span class="truncate">{{ externalLink }}</span>
|
||||
<i :class="linkIcon" class="shrink-0" />
|
||||
<span class="truncate">{{ displayLink }}</span>
|
||||
</span>
|
||||
<div class="shrink-0 text-sm text-n-slate-11 line-clamp-1">
|
||||
{{ createdAt }}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, h, reactive } from 'vue';
|
||||
import { computed, h, reactive, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { useToggle, useElementSize } from '@vueuse/core';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength } from '@vuelidate/validators';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
@@ -11,6 +11,7 @@ import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
@@ -31,7 +32,7 @@ const props = defineProps({
|
||||
},
|
||||
tools: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => [],
|
||||
},
|
||||
selectable: {
|
||||
type: Boolean,
|
||||
@@ -60,7 +61,13 @@ const state = reactive({
|
||||
instruction: '',
|
||||
});
|
||||
|
||||
const instructionContentRef = ref();
|
||||
|
||||
const [isEditing, toggleEditing] = useToggle();
|
||||
const [isInstructionExpanded, toggleInstructionExpanded] = useToggle();
|
||||
|
||||
const { height: contentHeight } = useElementSize(instructionContentRef);
|
||||
const needsOverlay = computed(() => contentHeight.value > 160);
|
||||
|
||||
const startEdit = () => {
|
||||
Object.assign(state, {
|
||||
@@ -111,7 +118,7 @@ const LINK_INSTRUCTION_CLASS =
|
||||
|
||||
const renderInstruction = instruction => () =>
|
||||
h('p', {
|
||||
class: `text-sm text-n-slate-12 py-4 mb-0 [&_ol]:list-decimal ${LINK_INSTRUCTION_CLASS}`,
|
||||
class: `text-sm text-n-slate-12 py-4 mb-0 prose prose-sm min-w-0 break-words max-w-none ${LINK_INSTRUCTION_CLASS}`,
|
||||
innerHTML: instruction,
|
||||
});
|
||||
</script>
|
||||
@@ -157,8 +164,38 @@ const renderInstruction = instruction => () =>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<component :is="renderInstruction(formatMessage(instruction, false))" />
|
||||
<span class="text-sm text-n-slate-11 font-medium mb-1">
|
||||
|
||||
<div
|
||||
class="relative overflow-hidden transition-all duration-300 ease-in-out group/expandable"
|
||||
:class="{ 'cursor-pointer': needsOverlay }"
|
||||
:style="{
|
||||
maxHeight: isInstructionExpanded ? `${contentHeight}px` : '10rem',
|
||||
}"
|
||||
@click="needsOverlay ? toggleInstructionExpanded() : null"
|
||||
>
|
||||
<div ref="instructionContentRef">
|
||||
<component
|
||||
:is="renderInstruction(formatMessage(instruction, false))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute bottom-0 w-full flex items-end justify-center text-xs text-n-slate-11 bg-gradient-to-t h-40 from-n-solid-2 via-n-solid-2 via-10% to-transparent transition-all duration-500 ease-in-out px-2 py-1 rounded pointer-events-none"
|
||||
:class="{
|
||||
'visible opacity-100': !isInstructionExpanded,
|
||||
'invisible opacity-0': isInstructionExpanded || !needsOverlay,
|
||||
}"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-chevron-down"
|
||||
class="text-n-slate-7 mb-4 size-4 group-hover/expandable:text-n-slate-11 transition-colors duration-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="tools?.length"
|
||||
class="text-sm text-n-slate-11 font-medium mb-1"
|
||||
>
|
||||
{{ t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.SUGGESTED.TOOLS_USED') }}
|
||||
{{ tools?.map(tool => `@${tool}`).join(', ') }}
|
||||
</span>
|
||||
|
||||
+3
-1
@@ -23,7 +23,9 @@ const handleSubmit = async newDocument => {
|
||||
dialogRef.value.close();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.response?.message || t(`${i18nKey}.ERROR_MESSAGE`);
|
||||
error?.response?.data?.message ||
|
||||
error?.response?.message ||
|
||||
t(`${i18nKey}.ERROR_MESSAGE`);
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
+151
-7
@@ -1,9 +1,10 @@
|
||||
<script setup>
|
||||
import { reactive, computed } from 'vue';
|
||||
import { reactive, computed, ref, nextTick } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength, url } from '@vuelidate/validators';
|
||||
import { required, minLength, requiredIf } 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';
|
||||
@@ -20,14 +21,25 @@ const formState = {
|
||||
|
||||
const initialState = {
|
||||
name: '',
|
||||
url: '',
|
||||
assistantId: null,
|
||||
documentType: 'url',
|
||||
pdfFile: null,
|
||||
};
|
||||
|
||||
const state = reactive({ ...initialState });
|
||||
const fileInputRef = ref(null);
|
||||
|
||||
const validationRules = {
|
||||
url: { required, url, minLength: minLength(1) },
|
||||
url: {
|
||||
required: requiredIf(() => state.documentType === 'url'),
|
||||
url: requiredIf(() => state.documentType === 'url'),
|
||||
minLength: minLength(1),
|
||||
},
|
||||
assistantId: { required },
|
||||
pdfFile: {
|
||||
required: requiredIf(() => state.documentType === 'pdf'),
|
||||
},
|
||||
};
|
||||
|
||||
const assistantList = computed(() =>
|
||||
@@ -50,14 +62,57 @@ 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 prepareDocumentDetails = () => ({
|
||||
external_link: state.url,
|
||||
assistant_id: state.assistantId,
|
||||
});
|
||||
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 > 10 * 1024 * 1024) {
|
||||
// 10MB
|
||||
useAlert(t('CAPTAIN.DOCUMENTS.FORM.PDF_FILE.TOO_LARGE'));
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
state.pdfFile = file;
|
||||
state.name = file.name.replace('.pdf', '');
|
||||
}
|
||||
};
|
||||
|
||||
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 handleSubmit = async () => {
|
||||
const isFormValid = await v$.value.$validate();
|
||||
@@ -71,13 +126,102 @@ const handleSubmit = async () => {
|
||||
|
||||
<template>
|
||||
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAPTAIN.DOCUMENTS.FORM.TYPE.LABEL') }}
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-3 p-1 bg-n-slate-3 rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
class="relative flex items-center justify-center gap-2 px-4 py-2.5 rounded-md font-medium text-sm transition-all duration-200"
|
||||
:class="
|
||||
state.documentType === 'url'
|
||||
? 'bg-n-white text-n-slate-12 shadow-sm'
|
||||
: 'text-n-slate-11 hover:text-n-slate-12'
|
||||
"
|
||||
@click="state.documentType = 'url'"
|
||||
>
|
||||
<i class="i-ph-link-simple text-base" />
|
||||
<span>{{ t('CAPTAIN.DOCUMENTS.FORM.TYPE.URL') }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="relative flex items-center justify-center gap-2 px-4 py-2.5 rounded-md font-medium text-sm transition-all duration-200"
|
||||
:class="
|
||||
state.documentType === 'pdf'
|
||||
? 'bg-n-white text-n-slate-12 shadow-sm'
|
||||
: 'text-n-slate-11 hover:text-n-slate-12'
|
||||
"
|
||||
@click="state.documentType = 'pdf'"
|
||||
>
|
||||
<i class="i-ph-file-pdf text-base" />
|
||||
<span>{{ t('CAPTAIN.DOCUMENTS.FORM.TYPE.PDF') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</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"
|
||||
class="flex items-center gap-3 px-4 py-3 border border-n-slate-6 rounded-lg cursor-pointer hover:border-n-slate-8 transition-colors w-full text-left"
|
||||
:class="state.pdfFile ? 'bg-n-slate-2' : 'bg-n-white'"
|
||||
@click="openFileDialog"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center w-10 h-10 bg-n-slate-3 rounded-lg"
|
||||
>
|
||||
<i class="i-ph-file-pdf text-xl text-n-slate-11" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-n-slate-12">
|
||||
{{
|
||||
state.pdfFile
|
||||
? state.pdfFile.name
|
||||
: t('CAPTAIN.DOCUMENTS.FORM.PDF_FILE.CHOOSE_FILE')
|
||||
}}
|
||||
</p>
|
||||
<p class="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>
|
||||
<i class="i-lucide-upload text-n-slate-11" />
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="formErrors.pdfFile" class="text-sm text-n-red-11">
|
||||
{{ 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') }}
|
||||
|
||||
@@ -192,6 +192,7 @@ defineExpose({ validate });
|
||||
solid
|
||||
slate
|
||||
icon="i-lucide-trash"
|
||||
class="flex-shrink-0"
|
||||
@click.stop="emit('remove')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -104,7 +104,7 @@ const outsideClickHandler = [
|
||||
<template>
|
||||
<div
|
||||
v-on-click-outside="outsideClickHandler"
|
||||
class="z-40 max-w-3xl lg:w-[750px] overflow-visible w-full border border-n-weak bg-n-alpha-3 backdrop-blur-[100px] shadow-lg rounded-xl p-6 grid gap-6"
|
||||
class="z-40 max-w-3xl min-w-96 lg:w-[750px] overflow-visible w-full border border-n-weak bg-n-alpha-3 backdrop-blur-[100px] shadow-lg rounded-xl p-6 grid gap-6"
|
||||
>
|
||||
<h3 class="text-base font-medium leading-6 text-n-slate-12">
|
||||
{{ filterModalHeaderTitle }}
|
||||
@@ -146,10 +146,10 @@ const outsideClickHandler = [
|
||||
</template>
|
||||
</ul>
|
||||
<div class="flex justify-between gap-2">
|
||||
<Button sm ghost blue @click="addFilter">
|
||||
<Button sm ghost blue class="flex-shrink-0" @click="addFilter">
|
||||
{{ $t('CONTACTS_LAYOUT.FILTER.BUTTONS.ADD_FILTER') }}
|
||||
</Button>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex gap-2 flex-shrink-0">
|
||||
<Button sm faded slate @click="resetFilter">
|
||||
{{ $t('CONTACTS_LAYOUT.FILTER.BUTTONS.CLEAR_FILTERS') }}
|
||||
</Button>
|
||||
|
||||
@@ -41,9 +41,7 @@ const originalEmailText = computed(() => {
|
||||
});
|
||||
|
||||
const originalEmailHtml = computed(
|
||||
() =>
|
||||
contentAttributes?.value?.email?.htmlContent?.full ??
|
||||
originalEmailText.value
|
||||
() => contentAttributes?.value?.email?.htmlContent?.full || ''
|
||||
);
|
||||
|
||||
const messageContent = computed(() => {
|
||||
|
||||
@@ -86,6 +86,15 @@ const menuItems = computed(() => {
|
||||
nativeLink: true,
|
||||
target: '_blank',
|
||||
},
|
||||
{
|
||||
show: true,
|
||||
showOnCustomBrandedInstance: false,
|
||||
label: t('SIDEBAR_ITEMS.CHANGELOG'),
|
||||
icon: 'i-lucide-scroll-text',
|
||||
link: 'https://www.chatwoot.com/changelog/',
|
||||
nativeLink: true,
|
||||
target: '_blank',
|
||||
},
|
||||
{
|
||||
show: currentUser.value.type === 'SuperAdmin',
|
||||
showOnCustomBrandedInstance: true,
|
||||
@@ -114,7 +123,7 @@ const allowedMenuItems = computed(() => {
|
||||
<DropdownContainer class="relative w-full min-w-0" @close="emit('close')">
|
||||
<template #trigger="{ toggle, isOpen }">
|
||||
<button
|
||||
class="flex gap-2 items-center rounded-lg cursor-pointer text-left w-full hover:bg-n-alpha-1 p-1"
|
||||
class="flex gap-2 items-center p-1 w-full text-left rounded-lg cursor-pointer hover:bg-n-alpha-1"
|
||||
:class="{ 'bg-n-alpha-1': isOpen }"
|
||||
@click="toggle"
|
||||
>
|
||||
@@ -127,16 +136,16 @@ const allowedMenuItems = computed(() => {
|
||||
rounded-full
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="text-n-slate-12 text-sm leading-4 font-medium truncate">
|
||||
<div class="text-sm font-medium leading-4 truncate text-n-slate-12">
|
||||
{{ currentUser.available_name }}
|
||||
</div>
|
||||
<div class="text-n-slate-11 text-xs truncate">
|
||||
<div class="text-xs truncate text-n-slate-11">
|
||||
{{ currentUser.email }}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
<DropdownBody class="ltr:left-0 rtl:right-0 bottom-12 z-50 w-80 mb-2">
|
||||
<DropdownBody class="bottom-12 z-50 mb-2 w-80 ltr:left-0 rtl:right-0">
|
||||
<SidebarProfileMenuStatus />
|
||||
<DropdownSeparator />
|
||||
<template v-for="item in allowedMenuItems" :key="item.label">
|
||||
|
||||
@@ -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 { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { requiredIf } from '@vuelidate/validators';
|
||||
|
||||
@@ -6,15 +6,12 @@ import FileUpload from 'vue-upload-component';
|
||||
import * as ActiveStorage from 'activestorage';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
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 { getAllowedFileTypesByChannel } from '@chatwoot/utils';
|
||||
import { ALLOWED_FILE_TYPES } 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';
|
||||
|
||||
@@ -155,6 +152,11 @@ export default {
|
||||
uploadRef,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
ALLOWED_FILE_TYPES,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
accountId: 'getCurrentAccountId',
|
||||
@@ -196,17 +198,21 @@ export default {
|
||||
return this.conversationType === 'instagram_direct_message';
|
||||
},
|
||||
allowedFileTypes() {
|
||||
if (this.isATwilioWhatsAppChannel) {
|
||||
return ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP;
|
||||
}
|
||||
if (this.isALineChannel) {
|
||||
return ALLOWED_FILE_TYPES_FOR_LINE;
|
||||
}
|
||||
if (this.isAnInstagramChannel || this.isInstagramDM) {
|
||||
return ALLOWED_FILE_TYPES_FOR_INSTAGRAM;
|
||||
// Use default file types for private notes
|
||||
if (this.isOnPrivateNote) {
|
||||
return this.ALLOWED_FILE_TYPES;
|
||||
}
|
||||
|
||||
return ALLOWED_FILE_TYPES;
|
||||
let channelType = this.channelType || this.inbox?.channel_type;
|
||||
|
||||
if (this.isAnInstagramChannel || this.isInstagramDM) {
|
||||
channelType = INBOX_TYPES.INSTAGRAM;
|
||||
}
|
||||
|
||||
return getAllowedFileTypesByChannel({
|
||||
channelType,
|
||||
medium: this.inbox?.medium,
|
||||
});
|
||||
},
|
||||
enableDragAndDrop() {
|
||||
return !this.newConversationModalActive;
|
||||
|
||||
@@ -793,6 +793,10 @@ 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,
|
||||
|
||||
+3
-3
@@ -1,10 +1,10 @@
|
||||
<script>
|
||||
import TemplatesPicker from './TemplatesPicker.vue';
|
||||
import TemplateParser from './TemplateParser.vue';
|
||||
import WhatsAppTemplateReply from './WhatsAppTemplateReply.vue';
|
||||
export default {
|
||||
components: {
|
||||
TemplatesPicker,
|
||||
TemplateParser,
|
||||
WhatsAppTemplateReply,
|
||||
},
|
||||
props: {
|
||||
show: {
|
||||
@@ -68,7 +68,7 @@ export default {
|
||||
:inbox-id="inboxId"
|
||||
@on-select="pickTemplate"
|
||||
/>
|
||||
<TemplateParser
|
||||
<WhatsAppTemplateReply
|
||||
v-else
|
||||
:template="selectedWaTemplate"
|
||||
@reset-template="onResetTemplate"
|
||||
|
||||
-9
@@ -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 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 { MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL } from 'shared/constants/messages';
|
||||
import { getMaxUploadSizeByChannel } from '@chatwoot/utils';
|
||||
|
||||
vi.mock('dashboard/composables/store');
|
||||
vi.mock('dashboard/composables', () => ({
|
||||
@@ -13,6 +13,7 @@ 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();
|
||||
@@ -22,6 +23,11 @@ describe('useFileUpload', () => {
|
||||
file: new File(['test'], 'test.jpg', { type: 'image/jpeg' }),
|
||||
};
|
||||
|
||||
const inbox = {
|
||||
channel_type: 'Channel::WhatsApp',
|
||||
medium: 'whatsapp',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -37,11 +43,12 @@ describe('useFileUpload', () => {
|
||||
|
||||
useI18n.mockReturnValue({ t: mockTranslate });
|
||||
checkFileSizeLimit.mockReturnValue(true);
|
||||
getMaxUploadSizeByChannel.mockReturnValue(25); // default max size MB for tests
|
||||
});
|
||||
|
||||
it('should handle direct file upload when enabled', () => {
|
||||
it('handles direct file upload when direct uploads enabled', () => {
|
||||
const { onFileUpload } = useFileUpload({
|
||||
isATwilioSMSChannel: false,
|
||||
inbox,
|
||||
attachFile: mockAttachFile,
|
||||
});
|
||||
|
||||
@@ -52,6 +59,16 @@ 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',
|
||||
@@ -63,7 +80,7 @@ describe('useFileUpload', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle indirect file upload when direct upload is disabled', () => {
|
||||
it('handles indirect file upload when direct upload disabled', () => {
|
||||
useMapGetter.mockImplementation(getter => {
|
||||
const getterMap = {
|
||||
getCurrentAccountId: { value: '123' },
|
||||
@@ -75,22 +92,24 @@ describe('useFileUpload', () => {
|
||||
});
|
||||
|
||||
const { onFileUpload } = useFileUpload({
|
||||
isATwilioSMSChannel: false,
|
||||
inbox,
|
||||
attachFile: mockAttachFile,
|
||||
});
|
||||
|
||||
onFileUpload(mockFile);
|
||||
|
||||
expect(DirectUpload).not.toHaveBeenCalled();
|
||||
expect(getMaxUploadSizeByChannel).toHaveBeenCalled();
|
||||
expect(checkFileSizeLimit).toHaveBeenCalledWith(mockFile, 25);
|
||||
expect(mockAttachFile).toHaveBeenCalledWith({ file: mockFile });
|
||||
});
|
||||
|
||||
it('should show alert when file size exceeds limit', () => {
|
||||
it('shows alert when file size exceeds limit', () => {
|
||||
checkFileSizeLimit.mockReturnValue(false);
|
||||
mockTranslate.mockReturnValue('File size exceeds limit');
|
||||
|
||||
const { onFileUpload } = useFileUpload({
|
||||
isATwilioSMSChannel: false,
|
||||
inbox,
|
||||
attachFile: mockAttachFile,
|
||||
});
|
||||
|
||||
@@ -100,28 +119,37 @@ describe('useFileUpload', () => {
|
||||
expect(mockAttachFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use different max file size for Twilio SMS channel', () => {
|
||||
it('uses per-mime limits from helper', () => {
|
||||
getMaxUploadSizeByChannel.mockImplementation(({ mime }) =>
|
||||
mime.startsWith('image/') ? 10 : 50
|
||||
);
|
||||
const { onFileUpload } = useFileUpload({
|
||||
isATwilioSMSChannel: true,
|
||||
inbox,
|
||||
attachFile: mockAttachFile,
|
||||
});
|
||||
|
||||
DirectUpload.mockImplementation(() => ({
|
||||
create: cb => cb(null, { signed_id: 'blob' }),
|
||||
}));
|
||||
|
||||
onFileUpload(mockFile);
|
||||
|
||||
expect(checkFileSizeLimit).toHaveBeenCalledWith(
|
||||
mockFile,
|
||||
MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL
|
||||
);
|
||||
expect(getMaxUploadSizeByChannel).toHaveBeenCalledWith({
|
||||
channelType: inbox.channel_type,
|
||||
medium: inbox.medium,
|
||||
mime: 'image/jpeg',
|
||||
});
|
||||
expect(checkFileSizeLimit).toHaveBeenCalledWith(mockFile, 10);
|
||||
});
|
||||
|
||||
it('should handle direct upload errors', () => {
|
||||
it('handles direct upload errors', () => {
|
||||
const mockError = 'Upload failed';
|
||||
DirectUpload.mockImplementation(() => ({
|
||||
create: callback => callback(mockError, null),
|
||||
}));
|
||||
|
||||
const { onFileUpload } = useFileUpload({
|
||||
isATwilioSMSChannel: false,
|
||||
inbox,
|
||||
attachFile: mockAttachFile,
|
||||
});
|
||||
|
||||
@@ -131,15 +159,16 @@ describe('useFileUpload', () => {
|
||||
expect(mockAttachFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should do nothing when file is null', () => {
|
||||
it('does nothing when file is null', () => {
|
||||
const { onFileUpload } = useFileUpload({
|
||||
isATwilioSMSChannel: false,
|
||||
inbox,
|
||||
attachFile: mockAttachFile,
|
||||
});
|
||||
|
||||
onFileUpload(null);
|
||||
|
||||
expect(checkFileSizeLimit).not.toHaveBeenCalled();
|
||||
expect(getMaxUploadSizeByChannel).not.toHaveBeenCalled();
|
||||
expect(mockAttachFile).not.toHaveBeenCalled();
|
||||
expect(useAlert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
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,22 +1,19 @@
|
||||
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 - 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
|
||||
* @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
|
||||
*/
|
||||
export const useFileUpload = ({ isATwilioSMSChannel, attachFile }) => {
|
||||
export const useFileUpload = ({ inbox, attachFile, isPrivateNote = false }) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
@@ -24,57 +21,72 @@ export const useFileUpload = ({ isATwilioSMSChannel, attachFile }) => {
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const globalConfig = useMapGetter('globalConfig/get');
|
||||
|
||||
const maxFileSize = computed(() =>
|
||||
isATwilioSMSChannel
|
||||
? MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL
|
||||
: MAXIMUM_FILE_UPLOAD_SIZE
|
||||
);
|
||||
// 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 handleDirectFileUpload = file => {
|
||||
if (!file) return;
|
||||
|
||||
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
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
const mime = file.file?.type || file.type;
|
||||
const maxSizeMB = maxSizeFor(mime);
|
||||
|
||||
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,
|
||||
})
|
||||
);
|
||||
if (!checkFileSizeLimit(file, maxSizeMB)) {
|
||||
alertOverLimit(maxSizeMB);
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (checkFileSizeLimit(file, maxFileSize.value)) {
|
||||
attachFile({ file });
|
||||
} else {
|
||||
useAlert(
|
||||
t('CONVERSATION.FILE_SIZE_LIMIT', {
|
||||
MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE: maxFileSize.value,
|
||||
})
|
||||
);
|
||||
const mime = file.file?.type || file.type;
|
||||
const maxSizeMB = maxSizeFor(mime);
|
||||
|
||||
if (!checkFileSizeLimit(file, maxSizeMB)) {
|
||||
alertOverLimit(maxSizeMB);
|
||||
return;
|
||||
}
|
||||
|
||||
attachFile({ file });
|
||||
};
|
||||
|
||||
const onFileUpload = file => {
|
||||
@@ -85,7 +97,5 @@ export const useFileUpload = ({ isATwilioSMSChannel, attachFile }) => {
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
onFileUpload,
|
||||
};
|
||||
return { onFileUpload };
|
||||
};
|
||||
|
||||
@@ -29,21 +29,24 @@ export const INBOX_FEATURE_MAP = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Composable for handling macro-related functionality
|
||||
* @returns {Object} An object containing the getMacroDropdownValues function
|
||||
* 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
|
||||
*/
|
||||
export const useInbox = () => {
|
||||
export const useInbox = (inboxId = null) => {
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const inboxGetter = useMapGetter('inboxes/getInboxById');
|
||||
|
||||
const inbox = computed(() => {
|
||||
const inboxId = currentChat.value.inbox_id;
|
||||
const targetInboxId = inboxId || currentChat.value?.inbox_id;
|
||||
|
||||
return useCamelCase(inboxGetter.value(inboxId), { deep: true });
|
||||
if (!targetInboxId) return null;
|
||||
|
||||
return useCamelCase(inboxGetter.value(targetInboxId), { deep: true });
|
||||
});
|
||||
|
||||
const channelType = computed(() => {
|
||||
return inbox.value.channelType;
|
||||
return inbox.value?.channelType;
|
||||
});
|
||||
|
||||
const isAPIInbox = computed(() => {
|
||||
@@ -75,19 +78,19 @@ export const useInbox = () => {
|
||||
});
|
||||
|
||||
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';
|
||||
});
|
||||
|
||||
@@ -96,7 +99,7 @@ export const useInbox = () => {
|
||||
});
|
||||
|
||||
const isATwilioWhatsAppChannel = computed(() => {
|
||||
const { medium: medium = '' } = inbox.value;
|
||||
const { medium: medium = '' } = inbox.value || {};
|
||||
return isATwilioChannel.value && medium === 'whatsapp';
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AnalyticsBrowser } from '@june-so/analytics-next';
|
||||
import posthog from 'posthog-js';
|
||||
|
||||
/**
|
||||
* AnalyticsHelper class to initialize and track user analytics
|
||||
@@ -26,10 +26,12 @@ export class AnalyticsHelper {
|
||||
return;
|
||||
}
|
||||
|
||||
let [analytics] = await AnalyticsBrowser.load({
|
||||
writeKey: this.analyticsToken,
|
||||
posthog.init(this.analyticsToken, {
|
||||
api_host: 'https://app.posthog.com',
|
||||
capture_pageview: false,
|
||||
persistence: 'localStorage+cookie',
|
||||
});
|
||||
this.analytics = analytics;
|
||||
this.analytics = posthog;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,8 +45,7 @@ export class AnalyticsHelper {
|
||||
}
|
||||
|
||||
this.user = user;
|
||||
this.analytics.identify(this.user.email, {
|
||||
userId: this.user.id,
|
||||
this.analytics.identify(this.user.id.toString(), {
|
||||
email: this.user.email,
|
||||
name: this.user.name,
|
||||
avatar: this.user.avatar_url,
|
||||
@@ -55,7 +56,7 @@ export class AnalyticsHelper {
|
||||
account => account.id === accountId
|
||||
);
|
||||
if (currentAccount) {
|
||||
this.analytics.group(currentAccount.id, this.user.id, {
|
||||
this.analytics.group('company', currentAccount.id.toString(), {
|
||||
name: currentAccount.name,
|
||||
});
|
||||
}
|
||||
@@ -71,12 +72,7 @@ export class AnalyticsHelper {
|
||||
if (!this.analytics) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.analytics.track({
|
||||
userId: this.user.id,
|
||||
event: eventName,
|
||||
properties,
|
||||
});
|
||||
this.analytics.capture(eventName, properties);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,9 +85,9 @@ export class AnalyticsHelper {
|
||||
return;
|
||||
}
|
||||
|
||||
this.analytics.page(params);
|
||||
this.analytics.capture('$pageview', params);
|
||||
}
|
||||
}
|
||||
|
||||
// This object is shared across, the init is called in app/javascript/packs/application.js
|
||||
// This object is shared across, the init is called in app/javascript/entrypoints/dashboard.js
|
||||
export default new AnalyticsHelper(window.analyticsConfig);
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import helperObject, { AnalyticsHelper } from '../';
|
||||
|
||||
vi.mock('@june-so/analytics-next', () => ({
|
||||
AnalyticsBrowser: {
|
||||
load: () => [
|
||||
{
|
||||
identify: vi.fn(),
|
||||
track: vi.fn(),
|
||||
page: vi.fn(),
|
||||
group: vi.fn(),
|
||||
},
|
||||
],
|
||||
vi.mock('posthog-js', () => ({
|
||||
default: {
|
||||
init: vi.fn(),
|
||||
identify: vi.fn(),
|
||||
capture: vi.fn(),
|
||||
group: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -26,12 +22,12 @@ describe('AnalyticsHelper', () => {
|
||||
});
|
||||
|
||||
describe('init', () => {
|
||||
it('should initialize the analytics browser with the correct token', async () => {
|
||||
it('should initialize posthog with the correct token', async () => {
|
||||
await analyticsHelper.init();
|
||||
expect(analyticsHelper.analytics).not.toBe(null);
|
||||
});
|
||||
|
||||
it('should not initialize the analytics browser if token is not provided', async () => {
|
||||
it('should not initialize posthog if token is not provided', async () => {
|
||||
analyticsHelper = new AnalyticsHelper();
|
||||
await analyticsHelper.init();
|
||||
expect(analyticsHelper.analytics).toBe(null);
|
||||
@@ -43,36 +39,36 @@ describe('AnalyticsHelper', () => {
|
||||
analyticsHelper.analytics = { identify: vi.fn(), group: vi.fn() };
|
||||
});
|
||||
|
||||
it('should call identify on analytics browser with correct arguments', () => {
|
||||
it('should call identify on posthog 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(
|
||||
'test@example.com',
|
||||
{
|
||||
userId: '123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
avatar: 'avatar_url',
|
||||
}
|
||||
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.group).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call identify on analytics browser without group', () => {
|
||||
it('should call identify on posthog 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();
|
||||
@@ -87,29 +83,27 @@ describe('AnalyticsHelper', () => {
|
||||
|
||||
describe('track', () => {
|
||||
beforeEach(() => {
|
||||
analyticsHelper.analytics = { track: vi.fn() };
|
||||
analyticsHelper.user = { id: '123' };
|
||||
analyticsHelper.analytics = { capture: vi.fn() };
|
||||
analyticsHelper.user = { id: 123 };
|
||||
});
|
||||
|
||||
it('should call track on analytics browser with correct arguments', () => {
|
||||
it('should call capture on posthog with correct arguments', () => {
|
||||
analyticsHelper.track('Test Event', { prop1: 'value1', prop2: 'value2' });
|
||||
expect(analyticsHelper.analytics.track).toHaveBeenCalledWith({
|
||||
userId: '123',
|
||||
event: 'Test Event',
|
||||
properties: { prop1: 'value1', prop2: 'value2' },
|
||||
});
|
||||
expect(analyticsHelper.analytics.capture).toHaveBeenCalledWith(
|
||||
'Test Event',
|
||||
{ prop1: 'value1', prop2: 'value2' }
|
||||
);
|
||||
});
|
||||
|
||||
it('should call track on analytics browser with default properties', () => {
|
||||
it('should call capture on posthog with default properties', () => {
|
||||
analyticsHelper.track('Test Event');
|
||||
expect(analyticsHelper.analytics.track).toHaveBeenCalledWith({
|
||||
userId: '123',
|
||||
event: 'Test Event',
|
||||
properties: {},
|
||||
});
|
||||
expect(analyticsHelper.analytics.capture).toHaveBeenCalledWith(
|
||||
'Test Event',
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
it('should not call track on analytics browser if analytics is not initialized', () => {
|
||||
it('should not call capture on posthog if analytics is not initialized', () => {
|
||||
analyticsHelper.analytics = null;
|
||||
analyticsHelper.track('Test Event', { prop1: 'value1', prop2: 'value2' });
|
||||
expect(analyticsHelper.analytics).toBe(null);
|
||||
@@ -118,19 +112,22 @@ describe('AnalyticsHelper', () => {
|
||||
|
||||
describe('page', () => {
|
||||
beforeEach(() => {
|
||||
analyticsHelper.analytics = { page: vi.fn() };
|
||||
analyticsHelper.analytics = { capture: vi.fn() };
|
||||
});
|
||||
|
||||
it('should call the analytics.page method with the correct arguments', () => {
|
||||
it('should call the capture method for pageview with the correct arguments', () => {
|
||||
const params = {
|
||||
name: 'Test page',
|
||||
url: '/test',
|
||||
};
|
||||
analyticsHelper.page(params);
|
||||
expect(analyticsHelper.analytics.page).toHaveBeenCalledWith(params);
|
||||
expect(analyticsHelper.analytics.capture).toHaveBeenCalledWith(
|
||||
'$pageview',
|
||||
params
|
||||
);
|
||||
});
|
||||
|
||||
it('should not call analytics.page if analytics is null', () => {
|
||||
it('should not call analytics.capture if analytics is null', () => {
|
||||
analyticsHelper.analytics = null;
|
||||
analyticsHelper.page();
|
||||
expect(analyticsHelper.analytics).toBe(null);
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
"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"
|
||||
@@ -285,7 +290,7 @@
|
||||
"HEADER": {
|
||||
"TITLE": "Contacts",
|
||||
"SEARCH_TITLE": "Search contacts",
|
||||
"ACTIVE_TITLE": "Active contacts",
|
||||
"ACTIVE_TITLE": "ንቁ እውቂያዎች",
|
||||
"SEARCH_PLACEHOLDER": "Search...",
|
||||
"MESSAGE_BUTTON": "Message",
|
||||
"SEND_MESSAGE": "Send message",
|
||||
@@ -460,8 +465,8 @@
|
||||
}
|
||||
},
|
||||
"DELETE_CONTACT": {
|
||||
"MESSAGE": "This action is permanent and irreversible.",
|
||||
"BUTTON": "Delete now"
|
||||
"MESSAGE": "ይህ እርምጃ ቋሚ ነው እና መመለስ አይቻልም።",
|
||||
"BUTTON": "አሁን ሰርዝ"
|
||||
}
|
||||
},
|
||||
"DETAILS": {
|
||||
@@ -471,7 +476,7 @@
|
||||
"DELETE_CONTACT": "Delete contact",
|
||||
"DELETE_DIALOG": {
|
||||
"TITLE": "Confirm Deletion",
|
||||
"DESCRIPTION": "Are you sure you want to delete this contact?",
|
||||
"DESCRIPTION": "ይህን እውቂያ ማጥፋት እርግጠኛ ነዎት?",
|
||||
"CONFIRM": "Yes, Delete",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Contact deleted successfully",
|
||||
@@ -550,8 +555,8 @@
|
||||
"YOU": "You",
|
||||
"SAVE": "Save note",
|
||||
"EXPAND": "Expand",
|
||||
"COLLAPSE": "Collapse",
|
||||
"NO_NOTES": "No notes, you can add notes from the contact details page.",
|
||||
"COLLAPSE": "ሰብስብ",
|
||||
"NO_NOTES": "ማስታወሻዎች የሉም፣ ከእውቂያው ዝርዝር ገፅ ላይ ማስታወሻዎችን መጨመር ይችላሉ።",
|
||||
"EMPTY_STATE": "There are no notes associated to this contact. You can add a note by typing in the box above."
|
||||
}
|
||||
},
|
||||
@@ -561,7 +566,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": "No contacts are active at the moment 🌙"
|
||||
"ACTIVE_EMPTY_STATE_TITLE": "በአሁኑ ጊዜ ንቁ እውቂያዎች የሉም 🌙"
|
||||
}
|
||||
},
|
||||
"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": "You can only reply to this conversation within {hours} hours",
|
||||
"API_HOURS_WINDOW": "ለዚህ ውይይት መመለስ በ{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": "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.",
|
||||
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "ይህ የInstagram መለያ ወደ አዲሱ የInstagram ቻናል ገቢ ሳጥን ተዛውሯል። ሁሉም አዲስ መልዕክቶች በዚያ ይታያሉ። ከአሁን ጀምሮ ከዚህ ውይይት መልዕክቶች መላክ አትችሉም።",
|
||||
"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": "Delete conversation #{conversationId}",
|
||||
"DESCRIPTION": "Are you sure you want to delete this conversation?",
|
||||
"TITLE": "ውይይት #{conversationId}ን ሰርዝ",
|
||||
"DESCRIPTION": "ይህን ውይይት ለመሰረዝ እርግጠኛ ነዎት?",
|
||||
"CONFIRM": "Delete"
|
||||
},
|
||||
"CARD_CONTEXT_MENU": {
|
||||
@@ -143,10 +143,10 @@
|
||||
"ASSIGN_LABEL": "Assign label",
|
||||
"AGENTS_LOADING": "Loading agents...",
|
||||
"ASSIGN_TEAM": "Assign team",
|
||||
"DELETE": "Delete conversation",
|
||||
"OPEN_IN_NEW_TAB": "Open in new tab",
|
||||
"COPY_LINK": "Copy conversation link",
|
||||
"COPY_LINK_SUCCESS": "Conversation link copied to clipboard",
|
||||
"DELETE": "ውይይት ሰርዝ",
|
||||
"OPEN_IN_NEW_TAB": "በአዲስ ታብ ክፈት",
|
||||
"COPY_LINK": "የውይይት አገናኝን ኮፒ አድርግ",
|
||||
"COPY_LINK_SUCCESS": "የውይይት አገናኝ ወደ ቅጂ ሰሌዳ ኮፒ ተደረገ",
|
||||
"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": "Conversation deleted successfully",
|
||||
"FAIL_DELETE_CONVERSATION": "Couldn't delete conversation! Try again",
|
||||
"SUCCESS_DELETE_CONVERSATION": "ውይይት በተሳካ ሁኔታ ተሰርዟል",
|
||||
"FAIL_DELETE_CONVERSATION": "ውይይትን መሰረዝ አልተቻለም! እንደገና ይሞክሩ",
|
||||
"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": "Linked Linear Issues",
|
||||
"LINEAR_ISSUES": "የተገናኙ የLinear ጉዳዮች",
|
||||
"SHOPIFY_ORDERS": "Shopify Orders"
|
||||
},
|
||||
"SHOPIFY": {
|
||||
|
||||
@@ -330,13 +330,14 @@
|
||||
"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,6 +226,7 @@
|
||||
"APPEARANCE": "Change appearance",
|
||||
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
|
||||
"DOCS": "Read documentation",
|
||||
"CHANGELOG": "Changelog",
|
||||
"LOGOUT": "Log out"
|
||||
},
|
||||
"APP_GLOBAL": {
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
"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,13 +330,14 @@
|
||||
"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,6 +226,7 @@
|
||||
"APPEARANCE": "Change appearance",
|
||||
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
|
||||
"DOCS": "Read documentation",
|
||||
"CHANGELOG": "Changelog",
|
||||
"LOGOUT": "Log out"
|
||||
},
|
||||
"APP_GLOBAL": {
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
"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,13 +330,14 @@
|
||||
"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,6 +226,7 @@
|
||||
"APPEARANCE": "Change appearance",
|
||||
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
|
||||
"DOCS": "Read documentation",
|
||||
"CHANGELOG": "Changelog",
|
||||
"LOGOUT": "Log out"
|
||||
},
|
||||
"APP_GLOBAL": {
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
"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,13 +330,14 @@
|
||||
"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,6 +226,7 @@
|
||||
"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": "",
|
||||
"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.",
|
||||
"LEARN_MORE": "Rakibkazi",
|
||||
"AGENT_TYPES": {
|
||||
"ADMINISTRATOR": "Administrador/a",
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
"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,13 +330,14 @@
|
||||
"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,6 +226,7 @@
|
||||
"APPEARANCE": "Change appearance",
|
||||
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
|
||||
"DOCS": "Read documentation",
|
||||
"CHANGELOG": "Changelog",
|
||||
"LOGOUT": "Log out"
|
||||
},
|
||||
"APP_GLOBAL": {
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
"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,13 +330,14 @@
|
||||
"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,6 +226,7 @@
|
||||
"APPEARANCE": "Change appearance",
|
||||
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
|
||||
"DOCS": "Read documentation",
|
||||
"CHANGELOG": "Changelog",
|
||||
"LOGOUT": "Log out"
|
||||
},
|
||||
"APP_GLOBAL": {
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
"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,13 +330,14 @@
|
||||
"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,6 +226,7 @@
|
||||
"APPEARANCE": "Change appearance",
|
||||
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
|
||||
"DOCS": "Read documentation",
|
||||
"CHANGELOG": "Changelog",
|
||||
"LOGOUT": "Log out"
|
||||
},
|
||||
"APP_GLOBAL": {
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
"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,13 +330,14 @@
|
||||
"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,6 +226,7 @@
|
||||
"APPEARANCE": "Change appearance",
|
||||
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
|
||||
"DOCS": "Read documentation",
|
||||
"CHANGELOG": "Changelog",
|
||||
"LOGOUT": "Log out"
|
||||
},
|
||||
"APP_GLOBAL": {
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
"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,13 +330,14 @@
|
||||
"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,6 +226,7 @@
|
||||
"APPEARANCE": "Change appearance",
|
||||
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
|
||||
"DOCS": "Read documentation",
|
||||
"CHANGELOG": "Changelog",
|
||||
"LOGOUT": "Log out"
|
||||
},
|
||||
"APP_GLOBAL": {
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
"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"
|
||||
|
||||
@@ -807,6 +807,58 @@
|
||||
"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,13 +330,14 @@
|
||||
"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"
|
||||
@@ -477,7 +478,10 @@
|
||||
"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."
|
||||
"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"
|
||||
},
|
||||
"REAUTH": "Reauthorize",
|
||||
"VIEW": "View",
|
||||
|
||||
@@ -701,11 +701,28 @@
|
||||
"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",
|
||||
|
||||
@@ -226,6 +226,7 @@
|
||||
"APPEARANCE": "Change appearance",
|
||||
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
|
||||
"DOCS": "Read documentation",
|
||||
"CHANGELOG": "Changelog",
|
||||
"LOGOUT": "Log out"
|
||||
},
|
||||
"APP_GLOBAL": {
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
"AGENT_BOTS": {
|
||||
"HEADER": "Bots",
|
||||
"LOADING_EDITOR": "Cargando el editor...",
|
||||
"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.",
|
||||
"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'.",
|
||||
"LEARN_MORE": "Learn about agent bots",
|
||||
"GLOBAL_BOT": "System bot",
|
||||
"GLOBAL_BOT": "Bot del sistema",
|
||||
"GLOBAL_BOT_BADGE": "Sistema",
|
||||
"AVATAR": {
|
||||
"SUCCESS_DELETE": "Bot avatar deleted successfully",
|
||||
"ERROR_DELETE": "Error deleting bot avatar, please try again"
|
||||
"SUCCESS_DELETE": "Avatar del bot eliminado correctamente",
|
||||
"ERROR_DELETE": "Error al borrar el bot, por favor intentar más tarde"
|
||||
},
|
||||
"BOT_CONFIGURATION": {
|
||||
"TITLE": "Seleccione un bot de agente",
|
||||
@@ -22,7 +22,7 @@
|
||||
"SELECT_PLACEHOLDER": "Seleccionar bot"
|
||||
},
|
||||
"ADD": {
|
||||
"TITLE": "Add Bot",
|
||||
"TITLE": "Agregar Bot",
|
||||
"CANCEL_BUTTON_TEXT": "Cancelar",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Bot añadido correctamente.",
|
||||
@@ -30,10 +30,10 @@
|
||||
}
|
||||
},
|
||||
"LIST": {
|
||||
"404": "No bots found. You can create a bot by clicking the 'Add Bot' button.",
|
||||
"404": "No sé encontró ningún bot. Puedes crear uno haciendo clic en 'crear un Bot'.",
|
||||
"LOADING": "Obteniendo bots...",
|
||||
"TABLE_HEADER": {
|
||||
"DETAILS": "Bot Details",
|
||||
"DETAILS": "Detalles del Bot",
|
||||
"URL": "URL de Webhook"
|
||||
}
|
||||
},
|
||||
@@ -42,7 +42,7 @@
|
||||
"TITLE": "Eliminar bot",
|
||||
"CONFIRM": {
|
||||
"TITLE": "Confirmar eliminación",
|
||||
"MESSAGE": "Are you sure you want to delete {name}?",
|
||||
"MESSAGE": "¿Está seguro que desea borrar {name}?",
|
||||
"YES": "Sí, eliminar",
|
||||
"NO": "No, mantenerlo"
|
||||
},
|
||||
@@ -61,18 +61,18 @@
|
||||
},
|
||||
"ACCESS_TOKEN": {
|
||||
"TITLE": "Token de acceso",
|
||||
"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"
|
||||
"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"
|
||||
},
|
||||
"FORM": {
|
||||
"AVATAR": {
|
||||
"LABEL": "Bot avatar"
|
||||
"LABEL": "Avatar del Bot"
|
||||
},
|
||||
"NAME": {
|
||||
"LABEL": "Nombre del bot",
|
||||
"PLACEHOLDER": "Enter bot name",
|
||||
"PLACEHOLDER": "Nombre del Bot",
|
||||
"REQUIRED": "El nombre del bot es obligatorio"
|
||||
},
|
||||
"DESCRIPTION": {
|
||||
@@ -82,11 +82,11 @@
|
||||
"WEBHOOK_URL": {
|
||||
"LABEL": "URL de Webhook",
|
||||
"PLACEHOLDER": "https://example.com/webhook",
|
||||
"REQUIRED": "Webhook URL is required"
|
||||
"REQUIRED": "Dirección del webhook es requerida"
|
||||
},
|
||||
"ERRORS": {
|
||||
"NAME": "El nombre del bot es obligatorio",
|
||||
"URL": "Webhook URL is required",
|
||||
"URL": "Dirección del webhook es requerida",
|
||||
"VALID_URL": "Please enter a valid URL starting with http:// or https://"
|
||||
},
|
||||
"CANCEL": "Cancelar",
|
||||
@@ -94,7 +94,7 @@
|
||||
"UPDATE": "Update Bot"
|
||||
},
|
||||
"WEBHOOK": {
|
||||
"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."
|
||||
"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."
|
||||
},
|
||||
"TYPES": {
|
||||
"WEBHOOK": "Webhook Bot"
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"TEXT": "Texto",
|
||||
"NUMBER": "Número",
|
||||
"LINK": "Enlace",
|
||||
"DATE": "Date",
|
||||
"DATE": "Fecha",
|
||||
"LIST": "Lista",
|
||||
"CHECKBOX": "Casilla"
|
||||
},
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
"CONVERSATION_CREATED": "Conversación creada",
|
||||
"CONVERSATION_UPDATED": "Conversación actualizada",
|
||||
"MESSAGE_CREATED": "Mensaje creado",
|
||||
"CONVERSATION_RESOLVED": "Conversation Resolved",
|
||||
"CONVERSATION_RESOLVED": "Conversación Resuelta",
|
||||
"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": "Add a Private Note",
|
||||
"ADD_PRIVATE_NOTE": "Añadir una nota privada",
|
||||
"CHANGE_PRIORITY": "Cambiar prioridad",
|
||||
"ADD_SLA": "Añadir SLA",
|
||||
"OPEN_CONVERSATION": "Abrir conversación"
|
||||
},
|
||||
"MESSAGE_TYPES": {
|
||||
"INCOMING": "Incoming Message",
|
||||
"OUTGOING": "Outgoing Message"
|
||||
"INCOMING": "Mensaje entrante",
|
||||
"OUTGOING": "Mensaje saliente"
|
||||
},
|
||||
"PRIORITY_TYPES": {
|
||||
"NONE": "Ninguna",
|
||||
@@ -175,7 +175,7 @@
|
||||
"MAIL_SUBJECT": "Asunto de Email",
|
||||
"COUNTRY_NAME": "País",
|
||||
"REFERER_LINK": "Enlace de referencia",
|
||||
"ASSIGNEE_NAME": "Cesionario",
|
||||
"ASSIGNEE_NAME": "Asignado a",
|
||||
"TEAM_NAME": "Equipo",
|
||||
"PRIORITY": "Prioridad"
|
||||
}
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
"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": "Open in new tab",
|
||||
"COPY_LINK": "Copy conversation link",
|
||||
"COPY_LINK_SUCCESS": "Conversation link copied to clipboard",
|
||||
"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",
|
||||
"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": "You have exceeded the agent limit. Your plan only allows {allowedAgents} agents.",
|
||||
"AGENTS": "Has superado el límite de agentes. Tu plan solo permite {allowedAgents} agentes.",
|
||||
"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": "This account is scheduled for deletion on {deletionDate} due to account inactivity. 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.",
|
||||
"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": "List is empty",
|
||||
"NO_OPTIONS": "La lista está vacía",
|
||||
"SELECT_ONE": "Seleccione uno",
|
||||
"SELECT": "Seleccionar"
|
||||
}
|
||||
|
||||
@@ -330,13 +330,14 @@
|
||||
"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": "Basic settings",
|
||||
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
|
||||
"TITLE": "Configuraciones básicas",
|
||||
"DESCRIPTION": ""
|
||||
},
|
||||
"SYSTEM_SETTINGS": {
|
||||
"TITLE": "System settings",
|
||||
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
|
||||
"DESCRIPTION": ""
|
||||
},
|
||||
"CONTROL_ITEMS": {
|
||||
"TITLE": "The Fun Stuff",
|
||||
|
||||
@@ -226,6 +226,7 @@
|
||||
"APPEARANCE": "Cambiar apariencia",
|
||||
"SUPER_ADMIN_CONSOLE": "Consola SuperAdmin",
|
||||
"DOCS": "Leer la documentación",
|
||||
"CHANGELOG": "Changelog",
|
||||
"LOGOUT": "Cerrar sesión"
|
||||
},
|
||||
"APP_GLOBAL": {
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
"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,13 +330,14 @@
|
||||
"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,6 +226,7 @@
|
||||
"APPEARANCE": "Change appearance",
|
||||
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
|
||||
"DOCS": "Read documentation",
|
||||
"CHANGELOG": "Changelog",
|
||||
"LOGOUT": "Log out"
|
||||
},
|
||||
"APP_GLOBAL": {
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
"IP_ADDRESS": "IP-osoite",
|
||||
"CREATED_AT_LABEL": "Created",
|
||||
"NEW_MESSAGE": "Uusi viesti",
|
||||
"CALL": "Call",
|
||||
"CALL_UNDER_DEVELOPMENT": "Calling is under development",
|
||||
"VOICE_INBOX_PICKER": {
|
||||
"TITLE": "Choose a voice inbox"
|
||||
},
|
||||
"CONVERSATIONS": {
|
||||
"NO_RECORDS_FOUND": "Tähän yhteystietoon ei liity aikaisempia keskusteluja.",
|
||||
"TITLE": "Edelliset keskustelut"
|
||||
|
||||
@@ -330,13 +330,14 @@
|
||||
"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,6 +226,7 @@
|
||||
"APPEARANCE": "Change appearance",
|
||||
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
|
||||
"DOCS": "Read documentation",
|
||||
"CHANGELOG": "Changelog",
|
||||
"LOGOUT": "Log out"
|
||||
},
|
||||
"APP_GLOBAL": {
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
"IP_ADDRESS": "Adresse IP",
|
||||
"CREATED_AT_LABEL": "Créé",
|
||||
"NEW_MESSAGE": "Nouveau message",
|
||||
"CALL": "Call",
|
||||
"CALL_UNDER_DEVELOPMENT": "Calling is under development",
|
||||
"VOICE_INBOX_PICKER": {
|
||||
"TITLE": "Choose a voice inbox"
|
||||
},
|
||||
"CONVERSATIONS": {
|
||||
"NO_RECORDS_FOUND": "Il n'y a aucune conversation précédente associée à ce contact.",
|
||||
"TITLE": "Conversations précédentes"
|
||||
|
||||
@@ -315,7 +315,7 @@
|
||||
"CONTACT_ATTRIBUTES": "Attributs du contact",
|
||||
"PREVIOUS_CONVERSATION": "Conversations précédentes",
|
||||
"MACROS": "Macros",
|
||||
"LINEAR_ISSUES": "Linked Linear Issues",
|
||||
"LINEAR_ISSUES": "Problèmes Linear liés",
|
||||
"SHOPIFY_ORDERS": "Shopify Orders"
|
||||
},
|
||||
"SHOPIFY": {
|
||||
|
||||
@@ -330,13 +330,14 @@
|
||||
"LABEL": "Secret de la clé 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,6 +226,7 @@
|
||||
"APPEARANCE": "Change appearance",
|
||||
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
|
||||
"DOCS": "Read documentation",
|
||||
"CHANGELOG": "Changelog",
|
||||
"LOGOUT": "Log out"
|
||||
},
|
||||
"APP_GLOBAL": {
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
"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,13 +330,14 @@
|
||||
"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,6 +226,7 @@
|
||||
"APPEARANCE": "Change appearance",
|
||||
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
|
||||
"DOCS": "Read documentation",
|
||||
"CHANGELOG": "Changelog",
|
||||
"LOGOUT": "Log out"
|
||||
},
|
||||
"APP_GLOBAL": {
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
"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,13 +330,14 @@
|
||||
"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