Compare commits

..
700 changed files with 4857 additions and 18837 deletions
-1
View File
@@ -94,4 +94,3 @@ yarn-debug.log*
.vscode
.claude/settings.local.json
.cursor
CLAUDE.local.md
+1 -18
View File
@@ -55,21 +55,4 @@
## Ruby Best Practices
- 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.
- Use compact `module/class` definitions; avoid nested styles
+2 -5
View File
@@ -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 'datadog', '~> 2.0', require: false
gem 'ddtrace', require: false
gem 'elastic-apm', require: false
gem 'newrelic_rpm', require: false
gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
@@ -179,10 +179,7 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
gem 'ai-agents', '>= 0.4.3'
# TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm-schema'
gem 'ai-agents', '>= 0.2.1'
gem 'shopify_api'
+63 -79
View File
@@ -25,35 +25,35 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (7.1.5.2)
actionpack (= 7.1.5.2)
activesupport (= 7.1.5.2)
actioncable (7.1.5.1)
actionpack (= 7.1.5.1)
activesupport (= 7.1.5.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (7.1.5.2)
actionpack (= 7.1.5.2)
activejob (= 7.1.5.2)
activerecord (= 7.1.5.2)
activestorage (= 7.1.5.2)
activesupport (= 7.1.5.2)
actionmailbox (7.1.5.1)
actionpack (= 7.1.5.1)
activejob (= 7.1.5.1)
activerecord (= 7.1.5.1)
activestorage (= 7.1.5.1)
activesupport (= 7.1.5.1)
mail (>= 2.7.1)
net-imap
net-pop
net-smtp
actionmailer (7.1.5.2)
actionpack (= 7.1.5.2)
actionview (= 7.1.5.2)
activejob (= 7.1.5.2)
activesupport (= 7.1.5.2)
actionmailer (7.1.5.1)
actionpack (= 7.1.5.1)
actionview (= 7.1.5.1)
activejob (= 7.1.5.1)
activesupport (= 7.1.5.1)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
rails-dom-testing (~> 2.2)
actionpack (7.1.5.2)
actionview (= 7.1.5.2)
activesupport (= 7.1.5.2)
actionpack (7.1.5.1)
actionview (= 7.1.5.1)
activesupport (= 7.1.5.1)
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4)
@@ -61,38 +61,38 @@ GEM
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
actiontext (7.1.5.2)
actionpack (= 7.1.5.2)
activerecord (= 7.1.5.2)
activestorage (= 7.1.5.2)
activesupport (= 7.1.5.2)
actiontext (7.1.5.1)
actionpack (= 7.1.5.1)
activerecord (= 7.1.5.1)
activestorage (= 7.1.5.1)
activesupport (= 7.1.5.1)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.1.5.2)
activesupport (= 7.1.5.2)
actionview (7.1.5.1)
activesupport (= 7.1.5.1)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
active_record_query_trace (1.8)
activejob (7.1.5.2)
activesupport (= 7.1.5.2)
activejob (7.1.5.1)
activesupport (= 7.1.5.1)
globalid (>= 0.3.6)
activemodel (7.1.5.2)
activesupport (= 7.1.5.2)
activerecord (7.1.5.2)
activemodel (= 7.1.5.2)
activesupport (= 7.1.5.2)
activemodel (7.1.5.1)
activesupport (= 7.1.5.1)
activerecord (7.1.5.1)
activemodel (= 7.1.5.1)
activesupport (= 7.1.5.1)
timeout (>= 0.4.0)
activerecord-import (2.1.0)
activerecord (>= 4.2)
activestorage (7.1.5.2)
actionpack (= 7.1.5.2)
activejob (= 7.1.5.2)
activerecord (= 7.1.5.2)
activesupport (= 7.1.5.2)
activestorage (7.1.5.1)
actionpack (= 7.1.5.1)
activejob (= 7.1.5.1)
activerecord (= 7.1.5.1)
activesupport (= 7.1.5.1)
marcel (~> 1.0)
activesupport (7.1.5.2)
activesupport (7.1.5.1)
base64
benchmark (>= 0.3)
bigdecimal
@@ -126,7 +126,7 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
ai-agents (0.4.3)
ai-agents (0.2.1)
ruby_llm (~> 1.3)
annotate (3.2.0)
activerecord (>= 3.2, < 8.0)
@@ -155,10 +155,10 @@ GEM
barnes (0.0.9)
multi_json (~> 1)
statsd-ruby (~> 1.1)
base64 (0.3.0)
base64 (0.2.0)
bcrypt (3.1.20)
benchmark (0.4.1)
bigdecimal (3.2.2)
benchmark (0.4.0)
bigdecimal (3.1.9)
bindex (0.8.1)
bootsnap (1.16.0)
msgpack (~> 1.2)
@@ -194,14 +194,10 @@ GEM
activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1)
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)
ddtrace (0.48.0)
ffi (~> 1.0)
msgpack
debug (1.8.0)
irb (>= 1.5.0)
reline (>= 0.3.1)
@@ -448,16 +444,6 @@ 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)
@@ -613,20 +599,20 @@ GEM
rackup (1.0.1)
rack (< 3)
webrick
rails (7.1.5.2)
actioncable (= 7.1.5.2)
actionmailbox (= 7.1.5.2)
actionmailer (= 7.1.5.2)
actionpack (= 7.1.5.2)
actiontext (= 7.1.5.2)
actionview (= 7.1.5.2)
activejob (= 7.1.5.2)
activemodel (= 7.1.5.2)
activerecord (= 7.1.5.2)
activestorage (= 7.1.5.2)
activesupport (= 7.1.5.2)
rails (7.1.5.1)
actioncable (= 7.1.5.1)
actionmailbox (= 7.1.5.1)
actionmailer (= 7.1.5.1)
actionpack (= 7.1.5.1)
actiontext (= 7.1.5.1)
actionview (= 7.1.5.1)
activejob (= 7.1.5.1)
activemodel (= 7.1.5.1)
activerecord (= 7.1.5.1)
activestorage (= 7.1.5.1)
activesupport (= 7.1.5.1)
bundler (>= 1.15.0)
railties (= 7.1.5.2)
railties (= 7.1.5.1)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
@@ -634,9 +620,9 @@ GEM
rails-html-sanitizer (1.6.1)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (7.1.5.2)
actionpack (= 7.1.5.2)
activesupport (= 7.1.5.2)
railties (7.1.5.1)
actionpack (= 7.1.5.1)
activesupport (= 7.1.5.1)
irb
rackup (>= 1.0.0)
rake (>= 12.2)
@@ -734,7 +720,7 @@ GEM
ruby2ruby (2.5.0)
ruby_parser (~> 3.1)
sexp_processor (~> 4.6)
ruby_llm (1.5.1)
ruby_llm (1.3.1)
base64
event_stream_parser (~> 1)
faraday (>= 1.10.0)
@@ -743,7 +729,6 @@ GEM
faraday-retry (>= 1)
marcel (~> 1.0)
zeitwerk (~> 2)
ruby_llm-schema (0.1.0)
ruby_parser (3.20.0)
sexp_processor (~> 4.16)
sass (3.7.4)
@@ -925,7 +910,7 @@ DEPENDENCIES
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
ai-agents (>= 0.4.3)
ai-agents (>= 0.2.1)
annotate
attr_extras
audited (~> 5.4, >= 5.4.1)
@@ -942,7 +927,7 @@ DEPENDENCIES
commonmarker
csv-safe
database_cleaner
datadog (~> 2.0)
ddtrace
debug (~> 1.8)
devise (>= 4.9.4)
devise-secure_password!
@@ -1019,7 +1004,6 @@ DEPENDENCIES
rubocop-rails
rubocop-rspec
ruby-openai
ruby_llm-schema
scout_apm
scss_lint
seed_dump
@@ -0,0 +1,176 @@
# frozen_string_literal: true
class Api::V1::Accounts::AgentCapacityPoliciesController < Api::V1::Accounts::BaseController
before_action :ensure_enterprise_account
before_action :fetch_agent_capacity_policy, only: [:show, :update, :destroy, :set_inbox_limit, :remove_inbox_limit, :assign_user, :remove_user]
before_action :check_authorization
def index
@agent_capacity_policies = Enterprise::AgentCapacityPolicy.where(account_id: Current.account.id).includes(:users, :inbox_capacity_limits)
render json: { agent_capacity_policies: serialize_agent_capacity_policies(@agent_capacity_policies) }
end
def show
render json: { agent_capacity_policy: serialize_agent_capacity_policy(@agent_capacity_policy) }
end
def create
@agent_capacity_policy = Enterprise::AgentCapacityPolicy.new(agent_capacity_policy_params)
@agent_capacity_policy.account_id = Current.account.id
if @agent_capacity_policy.save
render json: { agent_capacity_policy: serialize_agent_capacity_policy(@agent_capacity_policy) }, status: :created
else
render json: { errors: @agent_capacity_policy.errors.full_messages }, status: :unprocessable_entity
end
end
def update
if @agent_capacity_policy.update(agent_capacity_policy_params)
render json: { agent_capacity_policy: serialize_agent_capacity_policy(@agent_capacity_policy) }
else
render json: { errors: @agent_capacity_policy.errors.full_messages }, status: :unprocessable_entity
end
end
def destroy
if @agent_capacity_policy.destroy
head :ok
else
render json: { errors: @agent_capacity_policy.errors.full_messages }, status: :unprocessable_entity
end
end
# Set inbox capacity limit for a policy
def set_inbox_limit
inbox = Current.account.inboxes.find(params[:inbox_id])
inbox_limit = @agent_capacity_policy.inbox_capacity_limits.find_or_initialize_by(inbox: inbox)
if inbox_limit.update(conversation_limit: params[:conversation_limit])
render json: {
inbox_capacity_limit: serialize_inbox_capacity_limit(inbox_limit)
}
else
render json: { errors: inbox_limit.errors.full_messages }, status: :unprocessable_entity
end
end
# Remove inbox capacity limit from a policy
def remove_inbox_limit
inbox = Current.account.inboxes.find(params[:inbox_id])
inbox_limit = @agent_capacity_policy.inbox_capacity_limits.find_by(inbox: inbox)
if inbox_limit
if inbox_limit.destroy
head :ok
else
render json: { errors: inbox_limit.errors.full_messages }, status: :unprocessable_entity
end
else
render json: { error: 'Inbox limit not found' }, status: :not_found
end
end
# Assign a user to a capacity policy
def assign_user
user = Current.account.users.find(params[:user_id])
account_user = Current.account.account_users.find_by!(user: user)
# Update the account_user to assign to this policy
if account_user.update(agent_capacity_policy_id: @agent_capacity_policy.id)
render json: {
message: 'User assigned successfully',
user_id: user.id,
agent_capacity_policy_id: @agent_capacity_policy.id
}
else
render json: { errors: account_user.errors.full_messages }, status: :unprocessable_entity
end
end
# Remove a user from a capacity policy
def remove_user
user = Current.account.users.find(params[:user_id])
account_user = Current.account.account_users.find_by!(user: user)
if account_user.agent_capacity_policy_id == @agent_capacity_policy.id
if account_user.update(agent_capacity_policy_id: nil)
head :ok
else
render json: { errors: account_user.errors.full_messages }, status: :unprocessable_entity
end
else
render json: { error: 'User not assigned to this policy' }, status: :not_found
end
end
# Get current capacity status for an agent
def agent_capacity
user = Current.account.users.find(params[:agent_id])
inbox = params[:inbox_id] ? Current.account.inboxes.find(params[:inbox_id]) : nil
capacity_service = Enterprise::AssignmentV2::CapacityService.new
capacity_data = if inbox
capacity_service.get_agent_capacity(user, inbox)
else
capacity_service.get_agent_overall_capacity(user)
end
render json: { agent_capacity: capacity_data }
end
private
def ensure_enterprise_account
return if Current.account.feature_enabled?(:enterprise_agent_capacity)
render json: {
error: 'Agent capacity policies are only available for enterprise accounts'
}, status: :forbidden
end
def fetch_agent_capacity_policy
@agent_capacity_policy = Enterprise::AgentCapacityPolicy
.where(account_id: Current.account.id)
.includes(:users, inbox_capacity_limits: :inbox)
.find(params[:id])
end
def agent_capacity_policy_params
params.require(:agent_capacity_policy).permit(:name, :description, exclusion_rules: {})
end
def check_authorization
authorize(Enterprise::AgentCapacityPolicy) if defined?(Enterprise::AgentCapacityPolicy)
end
def serialize_agent_capacity_policy(policy)
{
id: policy.id,
name: policy.name,
description: policy.description,
exclusion_rules: policy.exclusion_rules,
user_count: policy.users.count,
inbox_limit_count: policy.inbox_capacity_limits.count,
inbox_limits: policy.inbox_capacity_limits.map { |limit| serialize_inbox_capacity_limit(limit) },
users: policy.users.map { |user| { id: user.id, name: user.name, email: user.email, avatar_url: user.avatar_url } },
created_at: policy.created_at,
updated_at: policy.updated_at
}
end
def serialize_agent_capacity_policies(policies)
policies.map { |policy| serialize_agent_capacity_policy(policy) }
end
def serialize_inbox_capacity_limit(limit)
{
id: limit.id,
inbox_id: limit.inbox_id,
inbox_name: limit.inbox.name,
conversation_limit: limit.conversation_limit,
created_at: limit.created_at,
updated_at: limit.updated_at
}
end
end
@@ -1,20 +0,0 @@
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
@@ -1,36 +0,0 @@
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
@@ -1,46 +0,0 @@
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
@@ -1,11 +1,4 @@
class Platform::Api::V1::AccountsController < PlatformController
def index
@resources = @platform_app.platform_app_permissibles
.where(permissible_type: 'Account')
.includes(:permissible)
.map(&:permissible)
end
def show; end
def create
+1 -6
View File
@@ -17,12 +17,7 @@ class SlackUploadsController < ApplicationController
end
def blob_url
# Only generate representations for images
if @blob.content_type.start_with?('image/')
url_for(@blob.representation(resize_to_fill: [250, nil]))
else
url_for(@blob)
end
url_for(@blob.representation(resize_to_fill: [250, nil]))
end
def avatar_url
+5 -18
View File
@@ -18,25 +18,12 @@ module ReportingEventHelper
end
def last_non_human_activity(conversation)
# Try to get either a handoff or reopened event first
# These will always take precedence over any other activity
# Also, any of these events can happen at any time in the course of a conversation lifecycle.
# So we pick the latest event
event = ReportingEvent.where(
conversation_id: conversation.id,
name: %w[conversation_bot_handoff conversation_opened]
).order(event_end_time: :desc).first
# check if a handoff event already exists
handoff_event = ReportingEvent.where(conversation_id: conversation.id, name: 'conversation_bot_handoff').last
return event.event_end_time if event&.event_end_time
# Fallback to bot resolved event
# Because this will be closest to the most accurate activity instead of conversation.created_at
bot_event = ReportingEvent.where(conversation_id: conversation.id, name: 'conversation_bot_resolved').last
return bot_event.event_end_time if bot_event&.event_end_time
# If no events found, return conversation creation time
conversation.created_at
# if a handoff exists, last non human activity is when the handoff ended,
# otherwise it's when the conversation was created
handoff_event&.event_end_time || conversation.created_at
end
private
+1
View File
@@ -137,6 +137,7 @@ export default {
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
id="app"
class="flex flex-col w-full h-screen min-h-0"
:class="{ 'app-rtl--wrapper': isRTL }"
:dir="isRTL ? 'rtl' : 'ltr'"
>
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
@@ -76,8 +76,8 @@ const campaignStatus = computed(() => {
const inboxName = computed(() => props.inbox?.name || '');
const inboxIcon = computed(() => {
const { medium, channel_type: type } = props.inbox;
return getInboxIconByType(type, medium);
const { phone_number: phoneNumber, channel_type: type } = props.inbox;
return getInboxIconByType(type, phoneNumber);
});
</script>
@@ -9,7 +9,6 @@ import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import TagMultiSelectComboBox from 'dashboard/components-next/combobox/TagMultiSelectComboBox.vue';
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
const emit = defineEmits(['submit', 'cancel']);
@@ -19,9 +18,7 @@ const formState = {
uiFlags: useMapGetter('campaigns/getUIFlags'),
labels: useMapGetter('labels/getLabels'),
inboxes: useMapGetter('inboxes/getWhatsAppInboxes'),
getFilteredWhatsAppTemplates: useMapGetter(
'inboxes/getFilteredWhatsAppTemplates'
),
getWhatsAppTemplates: useMapGetter('inboxes/getWhatsAppTemplates'),
};
const initialState = {
@@ -33,7 +30,7 @@ const initialState = {
};
const state = reactive({ ...initialState });
const templateParserRef = ref(null);
const processedParams = ref({});
const rules = {
title: { required, minLength: minLength(1) },
@@ -70,7 +67,7 @@ const inboxOptions = computed(() =>
const templateOptions = computed(() => {
if (!state.inboxId) return [];
const templates = formState.getFilteredWhatsAppTemplates.value(state.inboxId);
const templates = formState.getWhatsAppTemplates.value(state.inboxId);
return templates.map(template => {
// Create a more user-friendly label from template name
const friendlyName = template.name
@@ -91,6 +88,26 @@ const selectedTemplate = computed(() => {
?.template;
});
const templateString = computed(() => {
if (!selectedTemplate.value) return '';
try {
return (
selectedTemplate.value.components?.find(
component => component.type === 'BODY'
)?.text || ''
);
} catch (error) {
return '';
}
});
const processedString = computed(() => {
if (!templateString.value) return '';
return templateString.value.replace(/{{([^}]+)}}/g, (match, variable) => {
return processedParams.value[variable] || `{{${variable}}}`;
});
});
const getErrorMessage = (field, errorKey) => {
const baseKey = 'CAMPAIGN.WHATSAPP.CREATE.FORM';
return v$.value[field].$error ? t(`${baseKey}.${errorKey}.ERROR`) : '';
@@ -105,7 +122,8 @@ const formErrors = computed(() => ({
}));
const hasRequiredTemplateParams = computed(() => {
return templateParserRef.value?.v$?.$invalid === false || true;
const params = Object.values(processedParams.value);
return params.length === 0 || params.every(param => param.trim() !== '');
});
const isSubmitDisabled = computed(
@@ -117,18 +135,32 @@ const formatToUTCString = localDateTime =>
const resetState = () => {
Object.assign(state, initialState);
processedParams.value = {};
v$.value.$reset();
};
const handleCancel = () => emit('cancel');
const generateVariables = () => {
const matchedVariables = templateString.value.match(/{{([^}]+)}}/g);
if (!matchedVariables) {
processedParams.value = {};
return;
}
const finalVars = matchedVariables.map(match => match.replace(/{{|}}/g, ''));
processedParams.value = finalVars.reduce((acc, variable) => {
acc[variable] = processedParams.value[variable] || '';
return acc;
}, {});
};
const prepareCampaignDetails = () => {
// Find the selected template to get its content
const currentTemplate = selectedTemplate.value;
const parserData = templateParserRef.value;
// Extract template content - this should be the template message body
const templateContent = parserData?.renderedTemplate || '';
const templateContent = templateString.value;
// Prepare template_params object with the same structure as used in contacts
const templateParams = {
@@ -136,7 +168,7 @@ const prepareCampaignDetails = () => {
namespace: currentTemplate?.namespace || '',
category: currentTemplate?.category || 'UTILITY',
language: currentTemplate?.language || 'en_US',
processed_params: parserData?.processedParams || {},
processed_params: processedParams.value,
};
return {
@@ -166,6 +198,15 @@ watch(
() => state.inboxId,
() => {
state.templateId = null;
processedParams.value = {};
}
);
// Generate variables when template changes
watch(
() => state.templateId,
() => {
generateVariables();
}
);
</script>
@@ -213,12 +254,62 @@ watch(
</p>
</div>
<!-- Template Parser -->
<WhatsAppTemplateParser
<!-- Template Preview -->
<div
v-if="selectedTemplate"
ref="templateParserRef"
:template="selectedTemplate"
/>
class="flex flex-col gap-4 p-4 rounded-lg bg-n-alpha-black2"
>
<div class="flex justify-between items-center">
<h3 class="text-sm font-medium text-n-slate-12">
{{ selectedTemplate.name }}
</h3>
<span class="text-xs text-n-slate-11">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.LANGUAGE') }}:
{{ selectedTemplate.language || 'en' }}
</span>
</div>
<div class="flex flex-col gap-2">
<div class="rounded-md bg-n-alpha-black3">
<div class="text-sm whitespace-pre-wrap text-n-slate-12">
{{ processedString }}
</div>
</div>
</div>
<div class="text-xs text-n-slate-11">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.CATEGORY') }}:
{{ selectedTemplate.category || 'UTILITY' }}
</div>
</div>
<!-- Template Variables -->
<div
v-if="Object.keys(processedParams).length > 0"
class="flex flex-col gap-3"
>
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.VARIABLES_LABEL') }}
</label>
<div class="flex flex-col gap-2">
<div
v-for="(value, key) in processedParams"
:key="key"
class="flex gap-2 items-center"
>
<Input
v-model="processedParams[key]"
type="text"
class="flex-1"
:placeholder="
t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.VARIABLE_PLACEHOLDER', {
variable: key,
})
"
/>
</div>
</div>
</div>
<div class="flex flex-col gap-1">
<label for="audience" class="mb-0.5 text-sm font-medium text-n-slate-12">
@@ -1,13 +1,11 @@
<script setup>
import { computed, useSlots, ref } from 'vue';
import { computed, useSlots } 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: {
@@ -26,8 +24,6 @@ const { t } = useI18n();
const slots = useSlots();
const route = useRoute();
const isContactSidebarOpen = ref(false);
const contactId = computed(() => route.params.contactId);
const selectedContactName = computed(() => {
@@ -60,15 +56,6 @@ 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>
@@ -80,9 +67,7 @@ const closeMobileSidebar = () => {
>
<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 flex-col xs:flex-row items-start xs:items-center justify-between w-full py-7 gap-2"
>
<div class="flex items-center justify-between w-full h-20 gap-2">
<Breadcrumb
:items="breadcrumbItems"
@click="handleBreadcrumbClick"
@@ -100,11 +85,6 @@ const closeMobileSidebar = () => {
:disabled="isUpdating"
@click="toggleBlock"
/>
<VoiceCallButton
:phone="selectedContact?.phoneNumber"
:label="$t('CONTACT_PANEL.CALL')"
size="sm"
/>
<ComposeConversation :contact-id="contactId">
<template #trigger="{ toggle }">
<Button
@@ -125,65 +105,11 @@ const closeMobileSidebar = () => {
</main>
</div>
<!-- Desktop sidebar -->
<div
v-if="slots.sidebar"
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"
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"
>
<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>
@@ -34,13 +34,13 @@ const emit = defineEmits([
<template>
<header class="sticky top-0 z-10">
<div
class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[60rem]"
class="flex items-center justify-between w-full h-20 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-col sm:flex-row flex-shrink-0 gap-4">
<div v-if="showSearch" class="flex items-center gap-2 w-full">
<div class="flex items-center flex-shrink-0 gap-4">
<div v-if="showSearch" class="flex items-center gap-2">
<Input
:model-value="searchValue"
type="search"
@@ -48,7 +48,6 @@ 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>
@@ -59,66 +58,64 @@ const emit = defineEmits([
</template>
</Input>
</div>
<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>
<div class="flex items-center gap-2">
<div v-if="!isLabelView && !isActiveView" class="relative">
<Button
v-if="
hasActiveFilters &&
!isSegmentsView &&
!isLabelView &&
!isActiveView
id="toggleContactsFilterButton"
:icon="
isSegmentsView ? 'i-lucide-pen-line' : 'i-lucide-list-filter'
"
icon="i-lucide-save"
color="slate"
size="sm"
class="relative w-8"
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')"
/>
@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>
<div class="w-px h-4 bg-n-strong" />
<ComposeConversation>
<template #trigger="{ toggle }">
<Button :label="buttonLabel" size="sm" @click="toggle" />
</template>
</ComposeConversation>
<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>
<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>
@@ -291,20 +291,17 @@ defineExpose({
@delete-segment="openDeleteSegmentDialog"
>
<template #filter>
<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>
<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"
/>
</template>
</ContactsHeader>
@@ -105,7 +105,7 @@ const handleOrderChange = value => {
<div
v-if="isMenuOpen"
v-on-clickaway="() => (isMenuOpen = false)"
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"
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"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-n-slate-12">
@@ -96,7 +96,10 @@ const openFilter = () => {
<slot name="default" />
</div>
</main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4">
<footer
v-if="showPaginationFooter"
class="sticky bottom-0 z-10 px-4 pb-4"
>
<PaginationFooter
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage"
@@ -4,8 +4,8 @@ export default [
city: 'Los Angeles',
country: 'United States',
description:
"I'm Candice, a developer focusing on building web solutions. Currently, Im working as a Product Developer at Lumora.",
companyName: 'Lumora',
"I'm Candice, a developer focusing on building web solutions. Currently, Im working as a Product Developer at Chatwoot.",
companyName: 'Chatwoot',
countryCode: 'US',
socialProfiles: {
github: 'candice-dev',
@@ -16,7 +16,7 @@ export default [
},
},
availabilityStatus: 'offline',
email: 'candice.matherson@lumora.com',
email: 'candice.matherson@chatwoot.com',
id: 22,
name: 'Candice Matherson',
phoneNumber: '+14155552671',
@@ -1,91 +0,0 @@
<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>
@@ -47,7 +47,6 @@ 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"
@@ -96,7 +96,6 @@ defineExpose({
/>
</div>
<Avatar
v-if="assignee.name"
:name="assignee.name"
:src="assignee.thumbnail"
:size="20"
@@ -48,8 +48,8 @@ const inbox = computed(() => props.stateInbox);
const inboxName = computed(() => inbox.value?.name);
const inboxIcon = computed(() => {
const { channelType, medium } = inbox.value;
return getInboxIconByType(channelType, medium);
const { phoneNumber, channelType } = inbox.value;
return getInboxIconByType(channelType, phoneNumber);
});
const lastActivityAt = computed(() => {
@@ -315,9 +315,7 @@ const handleAvatarDelete = () => {
class="[&>div>button:not(.focused)]:!outline-n-weak"
/>
</div>
<div
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<div class="flex items-start justify-between w-full gap-2">
<label
class="text-sm font-medium whitespace-nowrap py-2.5 text-n-slate-12"
>
@@ -49,8 +49,8 @@ const isUnread = computed(() => !props.inboxItem?.readAt);
const inbox = computed(() => props.stateInbox);
const inboxIcon = computed(() => {
const { channelType, medium } = inbox.value;
return getInboxIconByType(channelType, medium);
const { phoneNumber, channelType } = inbox.value;
return getInboxIconByType(channelType, phoneNumber);
});
const hasSlaThreshold = computed(() => {
@@ -2,7 +2,6 @@
import { ref, computed, onMounted, watch } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useWindowSize } from '@vueuse/core';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { vOnClickOutside } from '@vueuse/components';
import { useAlert } from 'dashboard/composables';
@@ -16,7 +15,6 @@ import {
processContactableInboxes,
mergeInboxDetails,
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
import wootConstants from 'dashboard/constants/globals';
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
@@ -39,16 +37,9 @@ const emit = defineEmits(['close']);
const store = useStore();
const { t } = useI18n();
const { width: windowWidth } = useWindowSize();
const { fetchSignatureFlagFromUISettings } = useUISettings();
const isSmallScreen = computed(
() => windowWidth.value < wootConstants.SMALL_SCREEN_BREAKPOINT
);
const viewInModal = computed(() => props.isModal || isSmallScreen.value);
const contacts = ref([]);
const selectedContact = ref(null);
const targetInbox = ref(null);
@@ -76,7 +67,7 @@ const directUploadsEnabled = computed(
const activeContact = computed(() => contactById.value(props.contactId));
const composePopoverClass = computed(() => {
if (viewInModal.value) return '';
if (props.isModal) return '';
return props.alignPosition === 'right'
? 'absolute ltr:left-0 ltr:right-[unset] rtl:right-0 rtl:left-[unset]'
@@ -188,18 +179,14 @@ const toggle = () => {
watch(
activeContact,
(currentContact, previousContact) => {
if (currentContact && props.contactId) {
// Reset on contact change
if (currentContact?.id !== previousContact?.id) clearSelectedContact();
() => {
if (activeContact.value && props.contactId) {
const contactInboxes = activeContact.value?.contactInboxes || [];
// First process the contactable inboxes to get the right structure
const processedInboxes = processContactableInboxes(
currentContact.contactInboxes || []
);
const processedInboxes = processContactableInboxes(contactInboxes);
// Then Merge processedInboxes with the inboxes list
selectedContact.value = {
...currentContact,
...activeContact.value,
contactInboxes: mergeInboxDetails(processedInboxes, inboxesList.value),
};
}
@@ -215,7 +202,7 @@ const handleClickOutside = () => {
};
const onModalBackdropClick = () => {
if (!viewInModal.value) return;
if (!props.isModal) return;
handleClickOutside();
};
@@ -244,7 +231,7 @@ useKeyboardEvents(keyboardEvents);
]"
class="relative"
:class="{
'z-50': showComposeNewConversation && !viewInModal,
'z-40': showComposeNewConversation,
}"
>
<slot
@@ -256,12 +243,12 @@ useKeyboardEvents(keyboardEvents);
v-if="showComposeNewConversation"
:class="{
'fixed z-50 bg-n-alpha-black1 backdrop-blur-[4px] flex items-start pt-[clamp(3rem,15vh,12rem)] justify-center inset-0':
viewInModal,
isModal,
}"
@click.self="onModalBackdropClick"
>
<ComposeNewConversationForm
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
:class="[{ 'mt-2': !isModal }, composePopoverClass]"
:contacts="contacts"
:contact-id="contactId"
:is-loading="isSearching"
@@ -25,7 +25,6 @@ const props = defineProps({
hasNoInbox: { type: Boolean, default: false },
isDropdownActive: { type: Boolean, default: false },
messageSignature: { type: String, default: '' },
inboxId: { type: Number, default: null },
});
const emit = defineEmits([
@@ -151,10 +150,9 @@ useKeyboardEvents(keyboardEvents);
<div
class="flex items-center justify-between w-full h-[3.25rem] gap-2 px-4 py-3"
>
<div class="flex gap-2 items-center">
<div class="flex items-center gap-2">
<WhatsAppOptions
v-if="isWhatsappInbox"
:inbox-id="inboxId"
:message-templates="messageTemplates"
@send-message="emit('sendWhatsappMessage', $event)"
/>
@@ -172,7 +170,7 @@ useKeyboardEvents(keyboardEvents);
/>
<EmojiInput
v-if="isEmojiPickerOpen"
class="ltr:left-0 rtl:right-0 top-full mt-1.5"
class="left-0 top-full mt-1.5"
:on-click="onClickInsertEmoji"
/>
</div>
@@ -208,7 +206,7 @@ useKeyboardEvents(keyboardEvents);
/>
</div>
<div class="flex gap-2 items-center">
<div class="flex items-center gap-2">
<Button
:label="t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.DISCARD')"
variant="faded"
@@ -57,7 +57,7 @@ const removeAttachment = id => {
variant="ghost"
icon="i-lucide-trash"
color="slate"
class="absolute top-1 ltr:right-1 rtl:left-1 !w-5 !h-5 transition-opacity duration-150 ease-in-out opacity-0 group-hover/image:opacity-100"
class="absolute top-1 right-1 !w-5 !h-5 transition-opacity duration-150 ease-in-out opacity-0 group-hover/image:opacity-100"
@click="removeAttachment(attachment.resource.id)"
/>
</div>
@@ -265,7 +265,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
<template>
<div
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0"
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl"
>
<ContactSelector
:contacts="contacts"
@@ -336,7 +336,6 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
:is-loading="isCreating"
:disable-send-button="isCreating"
:has-selected-inbox="!!targetInbox"
:inbox-id="targetInbox?.id"
:has-no-inbox="showNoInboxAlert"
:is-dropdown-active="isAnyDropdownActive"
:message-signature="messageSignature"
@@ -83,7 +83,7 @@ const targetInboxLabel = computed(() => {
<DropdownMenu
v-if="contactableInboxesList?.length > 0 && showInboxesDropdown"
:menu-items="contactableInboxesList"
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-60 w-fit max-w-sm dark:!outline-n-slate-5"
class="left-0 z-[100] top-8 overflow-y-auto max-h-60 w-fit max-w-sm dark:!outline-n-slate-5"
@action="emit('handleInboxAction', $event)"
/>
</div>
@@ -1,25 +1,23 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import WhatsappTemplate from './WhatsappTemplate.vue';
import WhatsappTemplateParser from './WhatsappTemplateParser.vue';
const props = defineProps({
inboxId: {
type: Number,
required: true,
messageTemplates: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['sendMessage']);
const { t } = useI18n();
const getFilteredWhatsAppTemplates = useMapGetter(
'inboxes/getFilteredWhatsAppTemplates'
);
// TODO: Remove this when we support all formats
const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO'];
const searchQuery = ref('');
const selectedTemplate = ref(null);
@@ -27,7 +25,19 @@ const selectedTemplate = ref(null);
const showTemplatesMenu = ref(false);
const whatsAppTemplateMessages = computed(() => {
return getFilteredWhatsAppTemplates.value(props.inboxId);
// Add null check and ensure it's an array
const templates = Array.isArray(props.messageTemplates)
? props.messageTemplates
: [];
// TODO: Remove the last filter when we support all formats
return templates
.filter(template => template?.status?.toLowerCase() === 'approved')
.filter(template => {
return template?.components?.every(component => {
return !formatsToRemove.includes(component.format);
});
});
});
const filteredTemplates = computed(() => {
@@ -74,13 +84,10 @@ const handleSendMessage = template => {
/>
<div
v-if="showTemplatesMenu"
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto left-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<div class="relative w-full">
<Icon
icon="i-lucide-search"
class="absolute size-3.5 top-2 ltr:left-3 rtl:right-3"
/>
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
<input
v-model="searchQuery"
type="search"
@@ -89,13 +96,13 @@ const handleSendMessage = template => {
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.SEARCH_PLACEHOLDER'
)
"
class="w-full h-8 py-2 ltr:pl-10 rtl:pr-10 ltr:pr-2 rtl:pl-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
class="w-full h-8 py-2 pl-10 pr-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/>
</div>
<div
v-for="template in filteredTemplates"
:key="template.id"
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
class="flex flex-col w-full gap-2 p-2 rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
@click="handleTemplateClick(template)"
>
<span class="text-sm text-n-slate-12">{{ template.name }}</span>
@@ -104,12 +111,12 @@ const handleSendMessage = template => {
</p>
</div>
<template v-if="filteredTemplates.length === 0">
<p class="pt-2 w-full text-sm text-n-slate-11">
<p class="w-full pt-2 text-sm text-n-slate-11">
{{ t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.EMPTY_STATE') }}
</p>
</template>
</div>
<WhatsappTemplate
<WhatsappTemplateParser
v-if="selectedTemplate"
:template="selectedTemplate"
@send-message="handleSendMessage"
@@ -1,64 +0,0 @@
<script setup>
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import { useI18n } from 'vue-i18n';
defineProps({
template: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['sendMessage', 'back']);
const { t } = useI18n();
const handleSendMessage = payload => {
emit('sendMessage', payload);
};
const handleBack = () => {
emit('back');
};
</script>
<template>
<div
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<div class="w-full">
<WhatsAppTemplateParser
:template="template"
@send-message="handleSendMessage"
@back="handleBack"
>
<template #actions="{ sendMessage, goBack, disabled }">
<div class="flex gap-3 justify-between items-end w-full h-14">
<Button
:label="
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.BACK'
)
"
color="slate"
variant="faded"
class="w-full font-medium"
@click="goBack"
/>
<Button
:label="
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.SEND_MESSAGE'
)
"
class="w-full font-medium"
:disabled="disabled"
@click="sendMessage"
/>
</div>
</template>
</WhatsAppTemplateParser>
</div>
</div>
</template>
@@ -106,7 +106,7 @@ onMounted(() => {
<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"
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto left-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">
{{
@@ -138,8 +138,7 @@ onMounted(() => {
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"
class="flex items-center h-8 text-sm min-w-6 ltr:text-left rtl:text-right text-n-slate-10"
>
{{ key }}
</span>
@@ -36,11 +36,10 @@ const transformInbox = ({
email,
channelType,
phoneNumber,
medium,
...rest
}) => ({
id,
icon: getInboxIconByType(channelType, medium, 'line'),
icon: getInboxIconByType(channelType, phoneNumber, 'line'),
label: generateLabelForContactableInboxesList({
name,
email,
@@ -183,10 +183,7 @@ watch(
</script>
<template>
<span
class="relative inline-flex group/avatar z-0 flex-shrink-0"
:style="containerStyles"
>
<span class="relative inline-flex group/avatar z-0" :style="containerStyles">
<!-- Status Badge -->
<slot name="badge" :size="size">
<div
@@ -207,7 +204,7 @@ watch(
<!-- Delete Avatar Button -->
<div
v-if="src && allowUpload"
class="absolute z-20 flex items-center justify-center invisible w-6 h-6 transition-all duration-300 ease-in-out opacity-0 cursor-pointer outline outline-1 outline-n-container -top-2 ltr:-right-2 rtl:-left-2 rounded-xl bg-n-solid-3 group-hover/avatar:visible group-hover/avatar:opacity-100"
class="absolute z-20 flex items-center justify-center invisible w-6 h-6 transition-all duration-300 ease-in-out opacity-0 cursor-pointer outline outline-1 outline-n-container -top-2 -right-2 rounded-xl bg-n-solid-3 group-hover/avatar:visible group-hover/avatar:opacity-100"
@click="handleDismiss"
>
<Icon icon="i-lucide-x" class="text-n-slate-11 size-4" />
@@ -251,7 +248,7 @@ watch(
<!-- Fallback Icon if no name or image -->
<Icon
v-else
:title="t('THUMBNAIL.AUTHOR.NOT_AVAILABLE')"
v-tooltip.top-start="t('THUMBNAIL.AUTHOR.NOT_AVAILABLE')"
icon="i-lucide-user"
:style="iconStyles"
/>
@@ -21,17 +21,9 @@ const onClick = (item, index) => {
</script>
<template>
<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 }"
>
<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">
<Icon
v-if="index > 0"
icon="i-lucide-chevron-right"
@@ -48,7 +40,7 @@ const onClick = (item, index) => {
</button>
<!-- The last breadcrumb item is plain text -->
<span v-else class="text-sm truncate text-n-slate-12 min-w-0 block">
<span v-else class="text-sm truncate text-n-slate-12 max-w-56">
{{ item.emoji ? item.emoji : '' }} {{ item.label }}
</span>
</li>
@@ -52,10 +52,10 @@ const handleBreadcrumbClick = item => {
<template>
<section
class="px-6 flex flex-col w-full h-screen overflow-y-auto bg-n-background"
class="mt-4 px-10 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 bg-n-background pt-4 z-20">
<header class="mb-7 sticky top-0 z-10 bg-n-background">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
</header>
<main class="flex gap-16 w-full flex-1 pb-16">
@@ -57,10 +57,9 @@ const menuItems = computed(() => [
},
]);
const icon = computed(() => {
const { medium, channel_type: type } = props.inbox;
return getInboxIconByType(type, medium, 'outline');
});
const icon = computed(() =>
getInboxIconByType(props.inbox.channel_type, '', 'outline')
);
const handleAction = ({ action, value }) => {
toggleDropdown(false);
@@ -1,7 +1,7 @@
<script setup>
import { computed, h, reactive, ref } from 'vue';
import { computed, h, reactive } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle, useElementSize } from '@vueuse/core';
import { useToggle } from '@vueuse/core';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
@@ -11,7 +11,6 @@ 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: {
@@ -32,7 +31,7 @@ const props = defineProps({
},
tools: {
type: Array,
default: () => [],
required: true,
},
selectable: {
type: Boolean,
@@ -61,13 +60,7 @@ 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, {
@@ -118,7 +111,7 @@ const LINK_INSTRUCTION_CLASS =
const renderInstruction = instruction => () =>
h('p', {
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}`,
class: `text-sm text-n-slate-12 py-4 mb-0 [&_ol]:list-decimal ${LINK_INSTRUCTION_CLASS}`,
innerHTML: instruction,
});
</script>
@@ -164,38 +157,8 @@ const renderInstruction = instruction => () =>
/>
</div>
</div>
<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"
>
<component :is="renderInstruction(formatMessage(instruction, false))" />
<span 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>
@@ -35,7 +35,6 @@ const initialState = {
productName: '',
featureFaq: false,
featureMemory: false,
featureCitation: false,
};
const state = reactive({ ...initialState });
@@ -71,7 +70,6 @@ const prepareAssistantDetails = () => ({
product_name: state.productName,
feature_faq: state.featureFaq,
feature_memory: state.featureMemory,
feature_citation: state.featureCitation,
},
});
@@ -95,7 +93,6 @@ const updateStateFromAssistant = assistant => {
productName: config.product_name,
featureFaq: config.feature_faq || false,
featureMemory: config.feature_memory || false,
featureCitation: config.feature_citation || false,
});
};
@@ -154,13 +151,6 @@ watch(
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_MEMORIES') }}
</span>
</label>
<label class="flex items-center gap-2">
<input v-model="state.featureCitation" type="checkbox" />
<span class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CITATIONS') }}
</span>
</label>
</fieldset>
<div class="flex items-center justify-between w-full gap-3">
@@ -41,7 +41,6 @@ const initialState = {
features: {
conversationFaqs: false,
memories: false,
citations: false,
},
temperature: 1,
};
@@ -88,7 +87,6 @@ const updateStateFromAssistant = assistant => {
state.features = {
conversationFaqs: config.feature_faq || false,
memories: config.feature_memory || false,
citations: config.feature_citation || false,
};
state.temperature = config.temperature || 1;
};
@@ -154,7 +152,6 @@ const handleFeaturesUpdate = () => {
...props.assistant.config,
feature_faq: state.features.conversationFaqs,
feature_memory: state.features.memories,
feature_citation: state.features.citations,
},
};
@@ -303,19 +300,20 @@ watch(
<input
v-model="state.features.conversationFaqs"
type="checkbox"
class="form-checkbox"
/>
{{
t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CONVERSATION_FAQS')
}}
</label>
<label class="flex items-center gap-2">
<input v-model="state.features.memories" type="checkbox" />
<input
v-model="state.features.memories"
type="checkbox"
class="form-checkbox"
/>
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_MEMORIES') }}
</label>
<label class="flex items-center gap-2">
<input v-model="state.features.citations" type="checkbox" />
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CITATIONS') }}
</label>
</div>
</div>
@@ -26,7 +26,6 @@ const initialState = {
features: {
conversationFaqs: false,
memories: false,
citations: false,
},
};
@@ -58,7 +57,6 @@ const updateStateFromAssistant = assistant => {
state.features = {
conversationFaqs: config.feature_faq || false,
memories: config.feature_memory || false,
citations: config.feature_citation || false,
};
};
@@ -78,7 +76,6 @@ const handleBasicInfoUpdate = async () => {
product_name: state.productName,
feature_faq: state.features.conversationFaqs,
feature_memory: state.features.memories,
feature_citation: state.features.citations,
},
};
@@ -126,17 +123,21 @@ watch(
</label>
<div class="flex flex-col gap-2">
<label class="flex items-center gap-2">
<input v-model="state.features.conversationFaqs" type="checkbox" />
<input
v-model="state.features.conversationFaqs"
type="checkbox"
class="form-checkbox"
/>
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CONVERSATION_FAQS') }}
</label>
<label class="flex items-center gap-2">
<input v-model="state.features.memories" type="checkbox" />
<input
v-model="state.features.memories"
type="checkbox"
class="form-checkbox"
/>
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_MEMORIES') }}
</label>
<label class="flex items-center gap-2">
<input v-model="state.features.citations" type="checkbox" />
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CITATIONS') }}
</label>
</div>
</div>
@@ -192,7 +192,6 @@ 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 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"
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"
>
<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 class="flex-shrink-0" @click="addFilter">
<Button sm ghost blue @click="addFilter">
{{ $t('CONTACTS_LAYOUT.FILTER.BUTTONS.ADD_FILTER') }}
</Button>
<div class="flex gap-2 flex-shrink-0">
<div class="flex gap-2">
<Button sm faded slate @click="resetFilter">
{{ $t('CONTACTS_LAYOUT.FILTER.BUTTONS.CLEAR_FILTERS') }}
</Button>
@@ -509,8 +509,10 @@ const menuItems = computed(() => {
class="bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak flex flex-col text-sm pb-1 fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 transition-transform duration-200 ease-in-out md:static w-[200px] basis-[200px] md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:-translate-x-0"
:class="[
{
'shadow-lg md:shadow-none': isMobileSidebarOpen,
'ltr:-translate-x-full rtl:translate-x-full': !isMobileSidebarOpen,
'ltr:translate-x-0 rtl:-translate-x-0 shadow-lg md:shadow-none':
isMobileSidebarOpen,
'ltr:-translate-x-full rtl:translate-x-full md:translate-x-0':
!isMobileSidebarOpen,
},
]"
>
@@ -218,8 +218,8 @@ onMounted(async () => {
left: 0;
}
#app[dir='rtl'] .sidebar-group-children > .child-item:last-child::after,
#app[dir='rtl']
.app-rtl--wrapper .sidebar-group-children > .child-item:last-child::after,
.app-rtl--wrapper
.sidebar-group-children
> *:last-child
> *:last-child
@@ -86,15 +86,6 @@ 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,
@@ -123,7 +114,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 p-1 w-full text-left rounded-lg cursor-pointer hover:bg-n-alpha-1"
class="flex gap-2 items-center rounded-lg cursor-pointer text-left w-full hover:bg-n-alpha-1 p-1"
:class="{ 'bg-n-alpha-1': isOpen }"
@click="toggle"
>
@@ -136,16 +127,16 @@ const allowedMenuItems = computed(() => {
rounded-full
/>
<div class="min-w-0">
<div class="text-sm font-medium leading-4 truncate text-n-slate-12">
<div class="text-n-slate-12 text-sm leading-4 font-medium truncate">
{{ currentUser.available_name }}
</div>
<div class="text-xs truncate text-n-slate-11">
<div class="text-n-slate-11 text-xs truncate">
{{ currentUser.email }}
</div>
</div>
</button>
</template>
<DropdownBody class="bottom-12 z-50 mb-2 w-80 ltr:left-0 rtl:right-0">
<DropdownBody class="ltr:left-0 rtl:right-0 bottom-12 z-50 w-80 mb-2">
<SidebarProfileMenuStatus />
<DropdownSeparator />
<template v-for="item in allowedMenuItems" :key="item.label">
@@ -230,7 +230,7 @@ const handleBlur = e => emit('blur', e);
v-if="showDropdownMenu"
:menu-items="filteredMenuItems"
:is-searching="isLoading"
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-60 w-[inherit] max-w-md dark:!outline-n-slate-5"
class="left-0 z-[100] top-8 overflow-y-auto max-h-60 w-[inherit] max-w-md dark:!outline-n-slate-5"
@action="handleDropdownAction"
/>
</div>
@@ -1,279 +0,0 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators';
import { useI18n } from 'vue-i18n';
import Input from 'dashboard/components-next/input/Input.vue';
import {
buildTemplateParameters,
allKeysRequired,
replaceTemplateVariables,
DEFAULT_LANGUAGE,
DEFAULT_CATEGORY,
COMPONENT_TYPES,
MEDIA_FORMATS,
findComponentByType,
} from 'dashboard/helper/templateHelper';
const props = defineProps({
template: {
type: Object,
default: () => ({}),
validator: value => {
if (!value || typeof value !== 'object') return false;
if (!value.components || !Array.isArray(value.components)) return false;
return true;
},
},
});
const emit = defineEmits(['sendMessage', 'resetTemplate', 'back']);
const { t } = useI18n();
const processedParams = ref({});
const languageLabel = computed(() => {
return `${t('WHATSAPP_TEMPLATES.PARSER.LANGUAGE')}: ${props.template.language || DEFAULT_LANGUAGE}`;
});
const categoryLabel = computed(() => {
return `${t('WHATSAPP_TEMPLATES.PARSER.CATEGORY')}: ${props.template.category || DEFAULT_CATEGORY}`;
});
const headerComponent = computed(() => {
return findComponentByType(props.template, COMPONENT_TYPES.HEADER);
});
const bodyComponent = computed(() => {
return findComponentByType(props.template, COMPONENT_TYPES.BODY);
});
const bodyText = computed(() => {
return bodyComponent.value?.text || '';
});
const hasMediaHeader = computed(() =>
MEDIA_FORMATS.includes(headerComponent.value?.format)
);
const formatType = computed(() => {
const format = headerComponent.value?.format;
return format ? format.charAt(0) + format.slice(1).toLowerCase() : '';
});
const hasVariables = computed(() => {
return bodyText.value?.match(/{{([^}]+)}}/g) !== null;
});
const renderedTemplate = computed(() => {
return replaceTemplateVariables(bodyText.value, processedParams.value);
});
const isFormInvalid = computed(() => {
if (!hasVariables.value && !hasMediaHeader.value) return false;
if (hasMediaHeader.value && !processedParams.value.header?.media_url) {
return true;
}
if (hasVariables.value && processedParams.value.body) {
const hasEmptyBodyVariable = Object.values(processedParams.value.body).some(
value => !value
);
if (hasEmptyBodyVariable) return true;
}
if (processedParams.value.buttons) {
const hasEmptyButtonParameter = processedParams.value.buttons.some(
button => !button.parameter
);
if (hasEmptyButtonParameter) return true;
}
return false;
});
const v$ = useVuelidate(
{
processedParams: {
requiredIfKeysPresent: requiredIf(hasVariables),
allKeysRequired,
},
},
{ processedParams }
);
const initializeTemplateParameters = () => {
processedParams.value = buildTemplateParameters(
props.template,
hasMediaHeader.value
);
};
const updateMediaUrl = value => {
processedParams.value.header ??= {};
processedParams.value.header.media_url = value;
};
const sendMessage = () => {
v$.value.$touch();
if (v$.value.$invalid) return;
const { name, category, language, namespace } = props.template;
const payload = {
message: renderedTemplate.value,
templateParams: {
name,
category,
language,
namespace,
processed_params: processedParams.value,
},
};
emit('sendMessage', payload);
};
const resetTemplate = () => {
emit('resetTemplate');
};
const goBack = () => {
emit('back');
};
onMounted(initializeTemplateParameters);
watch(
() => props.template,
() => {
initializeTemplateParameters();
v$.value.$reset();
},
{ deep: true }
);
defineExpose({
processedParams,
hasVariables,
hasMediaHeader,
headerComponent,
renderedTemplate,
v$,
updateMediaUrl,
sendMessage,
resetTemplate,
goBack,
});
</script>
<template>
<div>
<div class="flex flex-col gap-4 p-4 mb-4 rounded-lg bg-n-alpha-black2">
<div class="flex justify-between items-center">
<h3 class="text-sm font-medium text-n-slate-12">
{{ template.name }}
</h3>
<span class="text-xs text-n-slate-11">
{{ languageLabel }}
</span>
</div>
<div class="flex flex-col gap-2">
<div class="rounded-md">
<div class="text-sm whitespace-pre-wrap text-n-slate-12">
{{ renderedTemplate }}
</div>
</div>
</div>
<div class="text-xs text-n-slate-11">
{{ categoryLabel }}
</div>
</div>
<div v-if="hasVariables || hasMediaHeader">
<div v-if="hasMediaHeader" class="mb-4">
<p class="mb-2.5 text-sm font-semibold">
{{
$t('WHATSAPP_TEMPLATES.PARSER.MEDIA_HEADER_LABEL', {
type: formatType,
}) || `${formatType} Header`
}}
</p>
<div class="flex items-center mb-2.5">
<Input
:model-value="processedParams.header?.media_url || ''"
type="url"
class="flex-1"
:placeholder="
t('WHATSAPP_TEMPLATES.PARSER.MEDIA_URL_LABEL', {
type: formatType,
})
"
@update:model-value="updateMediaUrl"
/>
</div>
</div>
<!-- Body Variables Section -->
<div v-if="processedParams.body">
<p class="mb-2.5 text-sm font-semibold">
{{ $t('WHATSAPP_TEMPLATES.PARSER.VARIABLES_LABEL') }}
</p>
<div
v-for="(variable, key) in processedParams.body"
:key="`body-${key}`"
class="flex items-center mb-2.5"
>
<Input
v-model="processedParams.body[key]"
type="text"
class="flex-1"
:placeholder="
t('WHATSAPP_TEMPLATES.PARSER.VARIABLE_PLACEHOLDER', {
variable: key,
})
"
/>
</div>
</div>
<!-- Button Variables Section -->
<div v-if="processedParams.buttons">
<p class="mb-2.5 text-sm font-semibold">
{{ t('WHATSAPP_TEMPLATES.PARSER.BUTTON_PARAMETERS') }}
</p>
<div
v-for="(button, index) in processedParams.buttons"
:key="`button-${index}`"
class="flex items-center mb-2.5"
>
<Input
v-model="processedParams.buttons[index].parameter"
type="text"
class="flex-1"
:placeholder="t('WHATSAPP_TEMPLATES.PARSER.BUTTON_PARAMETER')"
/>
</div>
</div>
<p
v-if="v$.$dirty && v$.$invalid"
class="p-2.5 text-center rounded-md bg-n-ruby-9/20 text-n-ruby-9"
>
{{ $t('WHATSAPP_TEMPLATES.PARSER.FORM_ERROR_MESSAGE') }}
</p>
</div>
<slot
name="actions"
:send-message="sendMessage"
:reset-template="resetTemplate"
:go-back="goBack"
:is-valid="!v$.$invalid"
:disabled="isFormInvalid"
/>
</div>
</template>
@@ -1,5 +1,6 @@
// [NOTE][DEPRECATED] This method is to be deprecated, please do not add new components to this file.
/* eslint no-plusplus: 0 */
import AvatarUploader from './widgets/forms/AvatarUploader.vue';
import Code from './Code.vue';
import ColorPicker from './widgets/ColorPicker.vue';
import ConfirmDeleteModal from './widgets/modal/ConfirmDeleteModal.vue';
@@ -17,9 +18,11 @@ import Modal from './Modal.vue';
import Spinner from 'shared/components/Spinner.vue';
import Tabs from './ui/Tabs/Tabs.vue';
import TabsItem from './ui/Tabs/TabsItem.vue';
import Thumbnail from './widgets/Thumbnail.vue';
import DatePicker from './ui/DatePicker/DatePicker.vue';
const WootUIKit = {
AvatarUploader,
Code,
ColorPicker,
ConfirmDeleteModal,
@@ -37,6 +40,7 @@ const WootUIKit = {
Spinner,
Tabs,
TabsItem,
Thumbnail,
DatePicker,
install(Vue) {
const keys = Object.keys(this);
@@ -0,0 +1,55 @@
<script>
export default {
name: 'Avatar',
props: {
username: {
type: String,
default: '',
},
size: {
type: Number,
default: 40,
},
},
computed: {
style() {
return {
fontSize: `${Math.floor(this.size / 2.5)}px`,
};
},
userInitial() {
const parts = this.username.split(/[ -]/);
let initials = parts.reduce((acc, curr) => acc + curr.charAt(0), '');
if (initials.length > 2 && initials.search(/[A-Z]/) !== -1) {
initials = initials.replace(/[a-z]+/g, '');
}
initials = initials.substring(0, 2).toUpperCase();
return initials;
},
},
};
</script>
<template>
<div class="avatar-container" :style="style" aria-hidden="true">
<slot>{{ userInitial }}</slot>
</div>
</template>
<style scoped>
@tailwind components;
@layer components {
.avatar-color {
background-image: linear-gradient(to top, #c2e1ff 0%, #d6ebff 100%);
}
.dark-avatar-color {
background-image: linear-gradient(to top, #135899 0%, #135899 100%);
}
}
.avatar-container {
@apply flex leading-[100%] font-medium items-center justify-center text-center cursor-default avatar-color dark:dark-avatar-color text-n-blue-text;
}
</style>
@@ -0,0 +1,48 @@
import { mount } from '@vue/test-utils';
import Avatar from './Avatar.vue';
import Thumbnail from './Thumbnail.vue';
describe('Thumbnail.vue', () => {
it('should render the agent thumbnail if valid image is passed', () => {
const wrapper = mount(Thumbnail, {
propsData: {
src: 'https://some_valid_url.com',
},
data() {
return {
hasImageLoaded: true,
imgError: false,
};
},
});
expect(wrapper.find('.user-thumbnail').exists()).toBe(true);
const avatarComponent = wrapper.findComponent(Avatar);
expect(avatarComponent.isVisible()).toBe(false);
});
it('should render the avatar component if invalid image is passed', () => {
const wrapper = mount(Thumbnail, {
propsData: {
src: 'https://some_invalid_url.com',
},
data() {
return {
hasImageLoaded: true,
imgError: true,
};
},
});
expect(wrapper.find('#image').exists()).toBe(false);
const avatarComponent = wrapper.findComponent(Avatar);
expect(avatarComponent.isVisible()).toBe(true);
});
it('should the initial of the name if no image is passed', () => {
const wrapper = mount(Avatar, {
propsData: {
username: 'Angie Rojas',
},
});
expect(wrapper.find('div').text()).toBe('AR');
});
});
@@ -0,0 +1,224 @@
<script>
/**
* Thumbnail Component
* Src - source for round image
* Size - Size of the thumbnail
* Badge - Chat source indication { fb / telegram }
* Username - Username for avatar
*/
import Avatar from './Avatar.vue';
import { removeEmoji } from 'shared/helpers/emoji';
export default {
components: {
Avatar,
},
props: {
src: {
type: String,
default: '',
},
size: {
type: String,
default: '40px',
},
badge: {
type: String,
default: '',
},
username: {
type: String,
default: '',
},
status: {
type: String,
default: '',
},
hasBorder: {
type: Boolean,
default: false,
},
shouldShowStatusAlways: {
type: Boolean,
default: false,
},
title: {
type: String,
default: '',
},
variant: {
type: String,
default: 'circle',
},
},
data() {
return {
hasImageLoaded: false,
imgError: false,
};
},
computed: {
userNameWithoutEmoji() {
return removeEmoji(this.username);
},
showStatusIndicator() {
if (this.shouldShowStatusAlways) return true;
return this.status === 'online' || this.status === 'busy';
},
avatarSize() {
return Number(this.size.replace(/\D+/g, ''));
},
badgeSrc() {
return {
instagram_direct_message: 'instagram-dm',
facebook: 'messenger',
'twitter-tweet': 'twitter-tweet',
'twitter-dm': 'twitter-dm',
whatsapp: 'whatsapp',
sms: 'sms',
'Channel::Line': 'line',
'Channel::Telegram': 'telegram',
'Channel::WebWidget': '',
}[this.badge];
},
badgeStyle() {
const size = Math.floor(this.avatarSize / 3);
const badgeSize = `${size + 2}px`;
const borderRadius = `${size / 2}px`;
return { width: badgeSize, height: badgeSize, borderRadius };
},
statusStyle() {
const statusSize = `${this.avatarSize / 4}px`;
return { width: statusSize, height: statusSize };
},
thumbnailClass() {
const className = this.hasBorder
? 'border border-solid border-white dark:border-n-weak'
: '';
const variant =
this.variant === 'circle' ? 'thumbnail-rounded' : 'thumbnail-square';
return `user-thumbnail ${className} ${variant}`;
},
thumbnailBoxClass() {
const boxClass = this.variant === 'circle' ? 'is-rounded' : '';
return `user-thumbnail-box ${boxClass}`;
},
shouldShowImage() {
if (!this.src) {
return false;
}
if (this.hasImageLoaded) {
return !this.imgError;
}
return false;
},
},
watch: {
src(value, oldValue) {
if (value !== oldValue && this.imgError) {
this.imgError = false;
}
},
},
methods: {
onImgError() {
this.imgError = true;
},
onImgLoad() {
this.hasImageLoaded = true;
},
},
};
</script>
<template>
<div
:class="thumbnailBoxClass"
:style="{ height: size, width: size }"
:title="title"
>
<!-- Using v-show instead of v-if to avoid flickering as v-if removes dom elements. -->
<slot>
<img
v-show="shouldShowImage"
:src="src"
draggable="false"
:class="thumbnailClass"
@load="onImgLoad"
@error="onImgError"
/>
<Avatar
v-show="!shouldShowImage"
:username="userNameWithoutEmoji"
:class="thumbnailClass"
:size="avatarSize"
/>
</slot>
<img
v-if="badgeSrc"
class="source-badge z-20"
:style="badgeStyle"
:src="`/integrations/channels/badges/${badgeSrc}.png`"
alt="Badge"
/>
<div
v-if="showStatusIndicator"
class="z-20"
:class="`source-badge user-online-status user-online-status--${status}`"
:style="statusStyle"
/>
</div>
</template>
<style lang="scss" scoped>
.user-thumbnail-box {
flex: 0 0 auto;
max-width: 100%;
position: relative;
&.is-rounded {
border-radius: 50%;
}
.user-thumbnail {
border-radius: 50%;
&.thumbnail-square {
border-radius: 0.5625rem;
}
height: 100%;
width: 100%;
box-sizing: border-box;
object-fit: cover;
vertical-align: initial;
}
.source-badge {
border-radius: 0.1875rem;
bottom: -0.125rem;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
position: absolute;
right: 0;
@apply bg-n-background p-0.5 size-3;
}
.user-online-status {
@apply bottom-0.5 rounded-full;
&:after {
content: ' ';
}
}
.user-online-status--online {
@apply bg-n-teal-10;
}
.user-online-status--busy {
@apply bg-n-amber-10;
}
.user-online-status--offline {
@apply bg-n-slate-10;
}
}
</style>
@@ -1,69 +1,78 @@
<script setup>
import { computed } from 'vue';
import Avatar from 'next/avatar/Avatar.vue';
<script>
import Thumbnail from './Thumbnail.vue';
const props = defineProps({
usersList: {
type: Array,
default: () => [],
export default {
components: {
Thumbnail,
},
size: {
type: Number,
default: 24,
},
showMoreThumbnailsCount: {
type: Boolean,
default: false,
},
moreThumbnailsText: {
type: String,
default: '',
},
gap: {
type: String,
default: 'normal',
validator(value) {
// The value must match one of these strings
return ['normal', 'tight'].includes(value);
props: {
usersList: {
type: Array,
default: () => [],
},
size: {
type: String,
default: '24px',
},
showMoreThumbnailsCount: {
type: Boolean,
default: false,
},
moreThumbnailsText: {
type: String,
default: '',
},
gap: {
type: String,
default: 'normal',
validator(value) {
// The value must match one of these strings
return ['normal', '', 'tight'].includes(value);
},
},
},
});
const gapClass = computed(() => {
if (props.gap === 'tight') {
return 'ltr:[&:not(:first-child)]:-ml-2 rtl:[&:not(:first-child)]:-mr-2';
}
return 'ltr:[&:not(:first-child)]:-ml-1 rtl:[&:not(:first-child)]:-mr-1';
});
const moreThumbnailsClass = computed(() => {
if (props.gap === 'tight') {
return 'ltr:-ml-2 rtl:-mr-2';
}
return 'ltr:-ml-1 rtl:-mr-1';
});
};
</script>
<template>
<div class="flex">
<Avatar
<div class="overlapping-thumbnails">
<Thumbnail
v-for="user in usersList"
:key="user.id"
v-tooltip="user.name"
:title="user.name"
:src="user.thumbnail"
:name="user.name"
:username="user.name"
has-border
:size="size"
class="[&>span]:outline [&>span]:outline-1 [&>span]:outline-n-background [&>span]:shadow"
:class="gapClass"
rounded-full
:class="`overlapping-thumbnail gap-${gap}`"
/>
<span
v-if="showMoreThumbnailsCount"
class="text-n-slate-11 bg-n-slate-4 outline outline-1 outline-n-background text-xs font-medium rounded-full px-2 inline-flex items-center shadow relative"
:class="moreThumbnailsClass"
>
<span v-if="showMoreThumbnailsCount" class="thumbnail-more-text">
{{ moreThumbnailsText }}
</span>
</div>
</template>
<style lang="scss" scoped>
.overlapping-thumbnails {
display: flex;
}
.overlapping-thumbnail {
position: relative;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
&:not(:first-child) {
margin-left: -0.25rem;
}
.gap-tight {
margin-left: -0.5rem;
}
}
.thumbnail-more-text {
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
@apply text-n-slate-11 bg-n-slate-4 border border-n-weak text-xs font-medium rounded-full px-2 ltr:-ml-2 rtl:-mr-2 inline-flex items-center relative;
}
</style>
@@ -1,5 +1,5 @@
<script setup>
import Avatar from 'next/avatar/Avatar.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
defineProps({
user: {
@@ -7,8 +7,8 @@ defineProps({
default: () => ({}),
},
size: {
type: Number,
default: 20,
type: String,
default: '20px',
},
textClass: {
type: String,
@@ -19,13 +19,11 @@ defineProps({
<template>
<div class="flex items-center gap-1.5 text-left">
<Avatar
<Thumbnail
:src="user.thumbnail"
:size="size"
:name="user.name"
:username="user.name"
:status="user.availability_status"
hide-offline-status
rounded-full
/>
<span class="my-0 truncate text-capitalize" :class="textClass">
{{ user.name }}
@@ -6,7 +6,7 @@ import { useElementSize } from '@vueuse/core';
import BackButton from '../BackButton.vue';
import InboxName from '../InboxName.vue';
import MoreActions from './MoreActions.vue';
import Avatar from 'next/avatar/Avatar.vue';
import Thumbnail from '../Thumbnail.vue';
import SLACardLabel from './components/SLACardLabel.vue';
import wootConstants from 'dashboard/constants/globals';
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
@@ -105,13 +105,12 @@ const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
:back-url="backButtonUrl"
class="ltr:mr-2 rtl:ml-2"
/>
<Avatar
:name="currentContact.name"
<Thumbnail
:src="currentContact.thumbnail"
:size="32"
:username="currentContact.name"
:status="currentContact.availability_status"
hide-offline-status
rounded-full
size="32px"
class="flex-shrink-0"
/>
<div
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2"
@@ -1,4 +1,4 @@
<script setup>
<script>
/**
* This component handles parsing and sending WhatsApp message templates.
* It works as follows:
@@ -8,51 +8,158 @@
* 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 { ref, computed, onMounted } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators';
import NextButton from 'dashboard/components-next/button/Button.vue';
defineProps({
template: {
type: Object,
default: () => ({}),
export default {
components: {
NextButton,
},
});
props: {
template: {
type: Object,
default: () => ({}),
},
},
emits: ['sendMessage', 'resetTemplate'],
setup(props, { emit }) {
const processVariable = str => {
return str.replace(/{{|}}/g, '');
};
const emit = defineEmits(['sendMessage', 'resetTemplate']);
const allKeysRequired = value => {
const keys = Object.keys(value);
return keys.every(key => value[key]);
};
const handleSendMessage = payload => {
emit('sendMessage', payload);
};
const processedParams = ref({});
const handleResetTemplate = () => {
emit('resetTemplate');
const templateString = computed(() => {
return props.template.components.find(
component => component.type === 'BODY'
).text;
});
const variables = computed(() => {
return templateString.value.match(/{{([^}]+)}}/g);
});
const processedString = computed(() => {
return templateString.value.replace(/{{([^}]+)}}/g, (match, variable) => {
const variableKey = processVariable(variable);
return processedParams.value[variableKey] || `{{${variable}}}`;
});
});
const v$ = useVuelidate(
{
processedParams: {
requiredIfKeysPresent: requiredIf(variables),
allKeysRequired,
},
},
{ processedParams }
);
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 resetTemplate = () => {
emit('resetTemplate');
};
const sendMessage = () => {
v$.value.$touch();
if (v$.value.$invalid) 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);
return {
processedParams,
variables,
templateString,
processedString,
v$,
resetTemplate,
sendMessage,
};
},
};
</script>
<template>
<div class="w-full">
<WhatsAppTemplateParser
:template="template"
@send-message="handleSendMessage"
@reset-template="handleResetTemplate"
>
<template #actions="{ sendMessage, resetTemplate, disabled }">
<footer class="flex gap-2 justify-end">
<NextButton
faded
slate
type="reset"
:label="$t('WHATSAPP_TEMPLATES.PARSER.GO_BACK_LABEL')"
@click="resetTemplate"
/>
<NextButton
type="button"
:label="$t('WHATSAPP_TEMPLATES.PARSER.SEND_MESSAGE_LABEL')"
:disabled="disabled"
@click="sendMessage"
/>
</footer>
</template>
</WhatsAppTemplateParser>
<textarea
v-model="processedString"
rows="4"
readonly
class="template-input"
/>
<div v-if="variables" class="p-2.5">
<p class="text-sm font-semibold mb-2.5">
{{ $t('WHATSAPP_TEMPLATES.PARSER.VARIABLES_LABEL') }}
</p>
<div
v-for="(variable, key) in processedParams"
:key="key"
class="items-center flex mb-2.5"
>
<span
class="bg-n-alpha-black2 text-n-slate-12 inline-block rounded-md text-xs py-2.5 px-6"
>
{{ key }}
</span>
<woot-input
v-model="processedParams[key]"
type="text"
class="flex-1 text-sm ml-2.5"
:styles="{ marginBottom: 0 }"
/>
</div>
<p
v-if="v$.$dirty && v$.$invalid"
class="bg-n-ruby-9/20 rounded-md text-n-ruby-9 p-2.5 text-center"
>
{{ $t('WHATSAPP_TEMPLATES.PARSER.FORM_ERROR_MESSAGE') }}
</p>
</div>
<footer class="flex justify-end gap-2">
<NextButton
faded
slate
type="reset"
:label="$t('WHATSAPP_TEMPLATES.PARSER.GO_BACK_LABEL')"
@click="resetTemplate"
/>
<NextButton
type="button"
:label="$t('WHATSAPP_TEMPLATES.PARSER.SEND_MESSAGE_LABEL')"
@click="sendMessage"
/>
</footer>
</div>
</template>
@@ -1,71 +1,60 @@
<script setup>
import { ref, computed, toRef } from 'vue';
<script>
import { useAlert } from 'dashboard/composables';
import { useFunctionGetter, useStore } from 'dashboard/composables/store';
import {
COMPONENT_TYPES,
MEDIA_FORMATS,
findComponentByType,
} from 'dashboard/helper/templateHelper';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { useI18n } from 'vue-i18n';
// TODO: Remove this when we support all formats
const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO'];
const props = defineProps({
inboxId: {
type: Number,
default: undefined,
export default {
components: {
Icon,
},
props: {
inboxId: {
type: Number,
default: undefined,
},
},
emits: ['onSelect'],
data() {
return {
query: '',
isRefreshing: false,
};
},
computed: {
whatsAppTemplateMessages() {
// TODO: Remove the last filter when we support all formats
return this.$store.getters['inboxes/getWhatsAppTemplates'](this.inboxId)
.filter(template => template.status.toLowerCase() === 'approved')
.filter(template => {
return template.components.every(component => {
return !formatsToRemove.includes(component.format);
});
});
},
filteredTemplateMessages() {
return this.whatsAppTemplateMessages.filter(template =>
template.name.toLowerCase().includes(this.query.toLowerCase())
);
},
},
methods: {
getTemplatebody(template) {
return template.components.find(component => component.type === 'BODY')
.text;
},
async refreshTemplates() {
this.isRefreshing = true;
try {
await this.$store.dispatch('inboxes/syncTemplates', this.inboxId);
useAlert(this.$t('WHATSAPP_TEMPLATES.PICKER.REFRESH_SUCCESS'));
} catch (error) {
useAlert(this.$t('WHATSAPP_TEMPLATES.PICKER.REFRESH_ERROR'));
} finally {
this.isRefreshing = false;
}
},
},
});
const emit = defineEmits(['onSelect']);
const { t } = useI18n();
const store = useStore();
const query = ref('');
const isRefreshing = ref(false);
const whatsAppTemplateMessages = useFunctionGetter(
'inboxes/getFilteredWhatsAppTemplates',
toRef(props, 'inboxId')
);
const filteredTemplateMessages = computed(() =>
whatsAppTemplateMessages.value.filter(template =>
template.name.toLowerCase().includes(query.value.toLowerCase())
)
);
const getTemplateBody = template => {
return findComponentByType(template, COMPONENT_TYPES.BODY)?.text || '';
};
const getTemplateHeader = template => {
return findComponentByType(template, COMPONENT_TYPES.HEADER);
};
const getTemplateFooter = template => {
return findComponentByType(template, COMPONENT_TYPES.FOOTER);
};
const getTemplateButtons = template => {
return findComponentByType(template, COMPONENT_TYPES.BUTTONS);
};
const hasMediaContent = template => {
const header = getTemplateHeader(template);
return header && MEDIA_FORMATS.includes(header.format);
};
const refreshTemplates = async () => {
isRefreshing.value = true;
try {
await store.dispatch('inboxes/syncTemplates', props.inboxId);
useAlert(t('WHATSAPP_TEMPLATES.PICKER.REFRESH_SUCCESS'));
} catch (error) {
useAlert(t('WHATSAPP_TEMPLATES.PICKER.REFRESH_ERROR'));
} finally {
isRefreshing.value = false;
}
};
</script>
@@ -79,14 +68,14 @@ const refreshTemplates = async () => {
<input
v-model="query"
type="search"
:placeholder="t('WHATSAPP_TEMPLATES.PICKER.SEARCH_PLACEHOLDER')"
:placeholder="$t('WHATSAPP_TEMPLATES.PICKER.SEARCH_PLACEHOLDER')"
class="reset-base w-full h-9 bg-transparent text-n-slate-12 !text-sm !outline-0"
/>
</div>
<button
:disabled="isRefreshing"
class="flex justify-center items-center w-9 h-9 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 hover:bg-n-alpha-2 dark:hover:bg-n-solid-2 disabled:opacity-50 disabled:cursor-not-allowed"
:title="t('WHATSAPP_TEMPLATES.PICKER.REFRESH_BUTTON')"
:title="$t('WHATSAPP_TEMPLATES.PICKER.REFRESH_BUTTON')"
@click="refreshTemplates"
>
<Icon
@@ -102,7 +91,7 @@ const refreshTemplates = async () => {
<div v-for="(template, i) in filteredTemplateMessages" :key="template.id">
<button
class="block p-2.5 w-full text-left rounded-lg cursor-pointer hover:bg-n-alpha-2 dark:hover:bg-n-solid-2"
@click="emit('onSelect', template)"
@click="$emit('onSelect', template)"
>
<div>
<div class="flex justify-between items-center mb-2.5">
@@ -112,73 +101,21 @@ const refreshTemplates = async () => {
<span
class="inline-block px-2 py-1 text-xs leading-none rounded-lg cursor-default bg-n-slate-3 text-n-slate-12"
>
{{ t('WHATSAPP_TEMPLATES.PICKER.LABELS.LANGUAGE') }}:
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.LANGUAGE') }} :
{{ template.language }}
</span>
</div>
<!-- Header -->
<div v-if="getTemplateHeader(template)" class="mb-3">
<p class="text-xs font-medium text-n-slate-11">
{{ t('WHATSAPP_TEMPLATES.PICKER.HEADER') || 'HEADER' }}
</p>
<div
v-if="getTemplateHeader(template).format === 'TEXT'"
class="text-sm label-body"
>
{{ getTemplateHeader(template).text }}
</div>
<div
v-else-if="hasMediaContent(template)"
class="text-sm italic text-n-slate-11"
>
{{
t('WHATSAPP_TEMPLATES.PICKER.MEDIA_CONTENT', {
format: getTemplateHeader(template).format,
}) ||
`${getTemplateHeader(template).format} ${t('WHATSAPP_TEMPLATES.PICKER.MEDIA_CONTENT_FALLBACK')}`
}}
</div>
</div>
<!-- Body -->
<div>
<p class="text-xs font-medium text-n-slate-11">
{{ t('WHATSAPP_TEMPLATES.PICKER.BODY') || 'BODY' }}
<p class="font-medium">
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.TEMPLATE_BODY') }}
</p>
<p class="text-sm label-body">{{ getTemplateBody(template) }}</p>
<p class="label-body">{{ getTemplatebody(template) }}</p>
</div>
<!-- Footer -->
<div v-if="getTemplateFooter(template)" class="mt-3">
<p class="text-xs font-medium text-n-slate-11">
{{ t('WHATSAPP_TEMPLATES.PICKER.FOOTER') || 'FOOTER' }}
<div class="mt-5">
<p class="font-medium">
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.CATEGORY') }}
</p>
<p class="text-sm label-body">
{{ getTemplateFooter(template).text }}
</p>
</div>
<!-- Buttons -->
<div v-if="getTemplateButtons(template)" class="mt-3">
<p class="text-xs font-medium text-n-slate-11">
{{ t('WHATSAPP_TEMPLATES.PICKER.BUTTONS') || 'BUTTONS' }}
</p>
<div class="flex flex-wrap gap-1 mt-1">
<span
v-for="button in getTemplateButtons(template).buttons"
:key="button.text"
class="px-2 py-1 text-xs rounded bg-n-slate-3 text-n-slate-12"
>
{{ button.text }}
</span>
</div>
</div>
<div class="mt-3">
<p class="text-xs font-medium text-n-slate-11">
{{ t('WHATSAPP_TEMPLATES.PICKER.CATEGORY') || 'CATEGORY' }}
</p>
<p class="text-sm">{{ template.category }}</p>
<p>{{ template.category }}</p>
</div>
</div>
</button>
@@ -191,13 +128,13 @@ const refreshTemplates = async () => {
<div v-if="!filteredTemplateMessages.length" class="py-8 text-center">
<div v-if="query && whatsAppTemplateMessages.length">
<p>
{{ t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
{{ $t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
<strong>{{ query }}</strong>
</p>
</div>
<div v-else-if="!whatsAppTemplateMessages.length" class="space-y-4">
<p class="text-n-slate-11">
{{ t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_AVAILABLE') }}
{{ $t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_AVAILABLE') }}
</p>
</div>
</div>
@@ -10,7 +10,7 @@ import { messageTimestamp } from 'shared/helpers/timeHelper';
import { downloadFile } from '@chatwoot/utils';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Avatar from 'next/avatar/Avatar.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
const props = defineProps({
@@ -186,12 +186,10 @@ onMounted(() => {
v-if="senderDetails"
class="flex items-center min-w-[15rem] shrink-0"
>
<Avatar
<Thumbnail
v-if="senderDetails.avatar"
:name="senderDetails.name"
:username="senderDetails.name"
:src="senderDetails.avatar"
:size="40"
rounded-full
class="flex-shrink-0"
/>
<div class="flex flex-col ml-2 rtl:ml-0 rtl:mr-2 overflow-hidden">
@@ -1,7 +1,7 @@
<script>
// components
import NextButton from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Avatar from '../../Avatar.vue';
// composables
import { useAI } from 'dashboard/composables/useAI';
@@ -226,16 +226,18 @@ export default {
</div>
</div>
<div class="sender--info has-tooltip" data-original-title="null">
<Avatar
<woot-thumbnail
v-tooltip.top="{
content: $t('LABEL_MGMT.SUGGESTIONS.POWERED_BY'),
delay: { show: 600, hide: 0 },
hideOnClick: true,
}"
:size="16"
name="chatwoot-ai"
icon-name="i-lucide-sparkles"
/>
size="16px"
>
<Avatar class="user-thumbnail thumbnail-rounded">
<fluent-icon class="chatwoot-ai-icon" icon="chatwoot-ai" />
</Avatar>
</woot-thumbnail>
</div>
</div>
</li>
@@ -266,6 +268,11 @@ export default {
}
}
.chatwoot-ai-icon {
height: 0.75rem;
width: 0.75rem;
}
.label-suggestion--title {
@apply text-n-slate-11 mt-0.5 text-xxs;
}
@@ -1,12 +1,12 @@
<script>
import { mapGetters } from 'vuex';
import Avatar from 'next/avatar/Avatar.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Spinner from 'shared/components/Spinner.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
Avatar,
Thumbnail,
Spinner,
NextButton,
},
@@ -123,13 +123,11 @@ export default {
</li>
<li v-for="agent in filteredAgents" :key="agent.id">
<div class="agent-list-item" @click="assignAgent(agent)">
<Avatar
:name="agent.name"
<Thumbnail
:src="agent.thumbnail"
:status="agent.availability_status"
:size="22"
hide-offline-status
rounded-full
:username="agent.name"
size="22px"
/>
<span class="my-0 text-n-slate-12">
{{ agent.name }}
@@ -0,0 +1,76 @@
<script>
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
props: {
label: {
type: String,
default: '',
},
src: {
type: String,
default: '',
},
usernameAvatar: {
type: String,
default: '',
},
deleteAvatar: {
type: Boolean,
default: false,
},
},
emits: ['onAvatarSelect', 'onAvatarDelete'],
watch: {},
methods: {
handleImageUpload(event) {
const [file] = event.target.files;
this.$emit('onAvatarSelect', {
file,
url: file ? URL.createObjectURL(file) : null,
});
},
onAvatarDelete() {
this.$refs.file.value = null;
this.$emit('onAvatarDelete');
},
},
};
</script>
<template>
<div>
<label>
<span v-if="label">{{ label }}</span>
</label>
<woot-thumbnail
v-if="src"
size="80px"
:src="src"
:username="usernameAvatar"
/>
<div v-if="src && deleteAvatar" class="my-1">
<NextButton
outline
xs
ruby
:label="$t('INBOX_MGMT.DELETE.AVATAR_DELETE_BUTTON_TEXT')"
@click="onAvatarDelete"
/>
</div>
<label>
<input
id="file"
ref="file"
type="file"
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
@change="handleImageUpload"
/>
<slot />
</label>
</div>
</template>
@@ -196,7 +196,6 @@ describe('useAutomation', () => {
automationTypes.conversation_created = { conditions: [] };
automationTypes.conversation_updated = { conditions: [] };
automationTypes.conversation_opened = { conditions: [] };
automationTypes.conversation_resolved = { conditions: [] };
automationHelper.generateCustomAttributeTypes.mockReturnValue([]);
automationHelper.generateCustomAttributes.mockReturnValue([]);
@@ -8,7 +8,7 @@ export const DEFAULT_MESSAGE_CREATED_CONDITION = [
},
];
export const DEFAULT_CONVERSATION_CONDITION = [
export const DEFAULT_CONVERSATION_OPENED_CONDITION = [
{
attribute_key: 'browser_language',
filter_operator: 'equal_to',
@@ -5,7 +5,7 @@ import {
} from 'dashboard/routes/dashboard/settings/automation/operators';
import {
DEFAULT_MESSAGE_CREATED_CONDITION,
DEFAULT_CONVERSATION_CONDITION,
DEFAULT_CONVERSATION_OPENED_CONDITION,
DEFAULT_OTHER_CONDITION,
DEFAULT_ACTIONS,
} from 'dashboard/constants/automation';
@@ -169,11 +169,8 @@ export const getDefaultConditions = eventName => {
if (eventName === 'message_created') {
return DEFAULT_MESSAGE_CREATED_CONDITION;
}
if (
eventName === 'conversation_opened' ||
eventName === 'conversation_resolved'
) {
return DEFAULT_CONVERSATION_CONDITION;
if (eventName === 'conversation_opened') {
return DEFAULT_CONVERSATION_OPENED_CONDITION;
}
return DEFAULT_OTHER_CONDITION;
};
+2 -2
View File
@@ -139,14 +139,14 @@ export const getInboxClassByType = (type, phoneNumber) => {
}
};
export const getInboxIconByType = (type, medium, variant = 'fill') => {
export const getInboxIconByType = (type, phoneNumber, variant = 'fill') => {
const iconMap =
variant === 'fill' ? INBOX_ICON_MAP_FILL : INBOX_ICON_MAP_LINE;
const defaultIcon =
variant === 'fill' ? DEFAULT_ICON_FILL : DEFAULT_ICON_LINE;
// Special case for Twilio (whatsapp and sms)
if (type === INBOX_TYPES.TWILIO && medium === 'whatsapp') {
if (type === INBOX_TYPES.TWILIO && phoneNumber?.startsWith('whatsapp')) {
return iconMap[INBOX_TYPES.WHATSAPP];
}
@@ -112,13 +112,13 @@ describe('#Inbox Helpers', () => {
describe('Twilio cases', () => {
describe('fill variant', () => {
it('returns WhatsApp icon for Twilio WhatsApp number', () => {
expect(getInboxIconByType(INBOX_TYPES.TWILIO, 'whatsapp')).toBe(
'i-ri-whatsapp-fill'
);
expect(
getInboxIconByType(INBOX_TYPES.TWILIO, 'whatsapp:+1234567890')
).toBe('i-ri-whatsapp-fill');
});
it('returns SMS icon for regular Twilio number', () => {
expect(getInboxIconByType(INBOX_TYPES.TWILIO, 'sms')).toBe(
expect(getInboxIconByType(INBOX_TYPES.TWILIO, '+1234567890')).toBe(
'i-ri-chat-1-fill'
);
});
@@ -133,14 +133,18 @@ describe('#Inbox Helpers', () => {
describe('line variant', () => {
it('returns WhatsApp line icon for Twilio WhatsApp number', () => {
expect(
getInboxIconByType(INBOX_TYPES.TWILIO, 'whatsapp', 'line')
getInboxIconByType(
INBOX_TYPES.TWILIO,
'whatsapp:+1234567890',
'line'
)
).toBe('i-ri-whatsapp-line');
});
it('returns SMS line icon for regular Twilio number', () => {
expect(getInboxIconByType(INBOX_TYPES.TWILIO, 'sms', 'line')).toBe(
'i-ri-chat-1-line'
);
expect(
getInboxIconByType(INBOX_TYPES.TWILIO, '+1234567890', 'line')
).toBe('i-ri-chat-1-line');
});
});
});
@@ -1,368 +0,0 @@
import {
replaceTemplateVariables,
buildTemplateParameters,
processVariable,
allKeysRequired,
} from '../templateHelper';
import { templates } from '../../store/modules/specs/inboxes/templateFixtures';
describe('templateHelper', () => {
const technicianTemplate = templates.find(t => t.name === 'technician_visit');
describe('processVariable', () => {
it('should remove curly braces from variables', () => {
expect(processVariable('{{name}}')).toBe('name');
expect(processVariable('{{1}}')).toBe('1');
expect(processVariable('{{customer_id}}')).toBe('customer_id');
});
});
describe('allKeysRequired', () => {
it('should return true when all keys have values', () => {
const obj = { name: 'John', age: '30' };
expect(allKeysRequired(obj)).toBe(true);
});
it('should return false when some keys are empty', () => {
const obj = { name: 'John', age: '' };
expect(allKeysRequired(obj)).toBe(false);
});
it('should return true for empty object', () => {
expect(allKeysRequired({})).toBe(true);
});
});
describe('replaceTemplateVariables', () => {
const templateText =
"Hi {{1}}, we're scheduling a technician visit to {{2}} on {{3}} between {{4}} and {{5}}. Please confirm if this time slot works for you.";
it('should replace all variables with provided values', () => {
const processedParams = {
body: {
1: 'John',
2: '123 Main St',
3: '2025-01-15',
4: '10:00 AM',
5: '2:00 PM',
},
};
const result = replaceTemplateVariables(templateText, processedParams);
expect(result).toBe(
"Hi John, we're scheduling a technician visit to 123 Main St on 2025-01-15 between 10:00 AM and 2:00 PM. Please confirm if this time slot works for you."
);
});
it('should keep original variable format when no replacement value provided', () => {
const processedParams = {
body: {
1: 'John',
3: '2025-01-15',
},
};
const result = replaceTemplateVariables(templateText, processedParams);
expect(result).toContain('John');
expect(result).toContain('2025-01-15');
expect(result).toContain('{{2}}');
expect(result).toContain('{{4}}');
expect(result).toContain('{{5}}');
});
it('should handle empty processedParams', () => {
const result = replaceTemplateVariables(templateText, {});
expect(result).toBe(templateText);
});
});
describe('buildTemplateParameters', () => {
it('should build parameters for template with body variables', () => {
const result = buildTemplateParameters(technicianTemplate, false);
expect(result.body).toEqual({
1: '',
2: '',
3: '',
4: '',
5: '',
});
});
it('should include header parameters when hasMediaHeader is true', () => {
const imageTemplate = templates.find(
t => t.name === 'order_confirmation'
);
const result = buildTemplateParameters(imageTemplate, true);
expect(result.header).toEqual({
media_url: '',
media_type: 'image',
});
});
it('should not include header parameters when hasMediaHeader is false', () => {
const result = buildTemplateParameters(technicianTemplate, false);
expect(result.header).toBeUndefined();
});
it('should handle template with no body component', () => {
const templateWithoutBody = {
components: [{ type: 'HEADER', format: 'TEXT' }],
};
const result = buildTemplateParameters(templateWithoutBody, false);
expect(result).toEqual({});
});
it('should handle template with no variables', () => {
const templateWithoutVars = templates.find(
t => t.name === 'no_variable_template'
);
const result = buildTemplateParameters(templateWithoutVars, false);
expect(result.body).toBeUndefined();
});
it('should handle URL buttons with variables for non-authentication templates', () => {
const templateWithUrlButton = {
category: 'MARKETING',
components: [
{
type: 'BODY',
text: 'Check out our website at {{site_url}}',
},
{
type: 'BUTTONS',
buttons: [
{
type: 'URL',
url: 'https://example.com/{{campaign_id}}',
text: 'Visit Site',
},
],
},
],
};
const result = buildTemplateParameters(templateWithUrlButton, false);
expect(result.buttons).toEqual([
{
type: 'url',
parameter: '',
url: 'https://example.com/{{campaign_id}}',
variables: ['campaign_id'],
},
]);
});
it('should handle templates with no variables', () => {
const emptyTemplate = templates.find(
t => t.name === 'no_variable_template'
);
const result = buildTemplateParameters(emptyTemplate, false);
expect(result).toEqual({});
});
it('should build parameters for templates with multiple component types', () => {
const complexTemplate = {
components: [
{ type: 'HEADER', format: 'IMAGE' },
{ type: 'BODY', text: 'Hi {{1}}, your order {{2}} is ready!' },
{ type: 'FOOTER', text: 'Thank you for your business' },
{
type: 'BUTTONS',
buttons: [{ type: 'URL', url: 'https://example.com/{{3}}' }],
},
],
};
const result = buildTemplateParameters(complexTemplate, true);
expect(result.header).toEqual({
media_url: '',
media_type: 'image',
});
expect(result.body).toEqual({ 1: '', 2: '' });
expect(result.buttons).toEqual([
{
type: 'url',
parameter: '',
url: 'https://example.com/{{3}}',
variables: ['3'],
},
]);
});
it('should handle copy code buttons correctly', () => {
const copyCodeTemplate = templates.find(
t => t.name === 'discount_coupon'
);
const result = buildTemplateParameters(copyCodeTemplate, false);
expect(result.body).toBeDefined();
expect(result.buttons).toEqual([
{
type: 'copy_code',
parameter: '',
},
]);
});
it('should handle templates with document headers', () => {
const documentTemplate = templates.find(
t => t.name === 'purchase_receipt'
);
const result = buildTemplateParameters(documentTemplate, true);
expect(result.header).toEqual({
media_url: '',
media_type: 'document',
});
expect(result.body).toEqual({
1: '',
2: '',
3: '',
});
});
it('should handle video header templates', () => {
const videoTemplate = templates.find(t => t.name === 'training_video');
const result = buildTemplateParameters(videoTemplate, true);
expect(result.header).toEqual({
media_url: '',
media_type: 'video',
});
expect(result.body).toEqual({
name: '',
date: '',
});
});
});
describe('enhanced format validation', () => {
it('should validate enhanced format structure', () => {
const processedParams = {
body: { 1: 'John', 2: 'Order123' },
header: {
media_url: 'https://example.com/image.jpg',
media_type: 'image',
},
buttons: [{ type: 'copy_code', parameter: 'SAVE20' }],
};
// Test that structure is properly formed
expect(processedParams.body).toBeDefined();
expect(typeof processedParams.body).toBe('object');
expect(processedParams.header).toBeDefined();
expect(Array.isArray(processedParams.buttons)).toBe(true);
});
it('should handle empty component sections', () => {
const processedParams = {
body: {},
header: {},
buttons: [],
};
expect(allKeysRequired(processedParams.body)).toBe(true);
expect(allKeysRequired(processedParams.header)).toBe(true);
expect(processedParams.buttons.length).toBe(0);
});
it('should validate parameter completeness', () => {
const incompleteParams = {
body: { 1: 'John', 2: '' },
};
expect(allKeysRequired(incompleteParams.body)).toBe(false);
});
it('should handle edge cases in processVariable', () => {
expect(processVariable('{{')).toBe('');
expect(processVariable('}}')).toBe('');
expect(processVariable('')).toBe('');
expect(processVariable('{{nested{{variable}}}}')).toBe('nestedvariable');
});
it('should handle special characters in template variables', () => {
/* eslint-disable no-template-curly-in-string */
const templateText =
'Welcome {{user_name}}, your order #{{order_id}} costs ${{amount}}';
/* eslint-enable no-template-curly-in-string */
const processedParams = {
body: {
user_name: 'John & Jane',
order_id: '12345',
amount: '99.99',
},
};
const result = replaceTemplateVariables(templateText, processedParams);
expect(result).toBe(
'Welcome John & Jane, your order #12345 costs $99.99'
);
});
it('should handle templates with mixed parameter types', () => {
const mixedTemplate = {
components: [
{ type: 'HEADER', format: 'VIDEO' },
{ type: 'BODY', text: 'Order {{order_id}} status: {{status}}' },
{ type: 'FOOTER', text: 'Thank you' },
{
type: 'BUTTONS',
buttons: [
{ type: 'URL', url: 'https://track.com/{{order_id}}' },
{ type: 'COPY_CODE' },
{ type: 'PHONE_NUMBER', phone_number: '+1234567890' },
],
},
],
};
const result = buildTemplateParameters(mixedTemplate, true);
expect(result.header).toEqual({
media_url: '',
media_type: 'video',
});
expect(result.body).toEqual({
order_id: '',
status: '',
});
expect(result.buttons).toHaveLength(2); // URL and COPY_CODE (PHONE_NUMBER doesn't need parameters)
expect(result.buttons[0].type).toBe('url');
expect(result.buttons[1].type).toBe('copy_code');
});
it('should handle templates with no processable components', () => {
const emptyTemplate = {
components: [
{ type: 'HEADER', format: 'TEXT', text: 'Static Header' },
{ type: 'BODY', text: 'Static body with no variables' },
{ type: 'FOOTER', text: 'Static footer' },
],
};
const result = buildTemplateParameters(emptyTemplate, false);
expect(result).toEqual({});
});
it('should validate that replaceTemplateVariables preserves unreplaced variables', () => {
const templateText = 'Hi {{name}}, order {{order_id}} is {{status}}';
const partialParams = {
body: {
name: 'John',
// order_id missing
status: 'ready',
},
};
const result = replaceTemplateVariables(templateText, partialParams);
expect(result).toBe('Hi John, order {{order_id}} is ready');
expect(result).toContain('{{order_id}}'); // Unreplaced variable preserved
});
});
});
@@ -1,91 +0,0 @@
// Constants
export const DEFAULT_LANGUAGE = 'en';
export const DEFAULT_CATEGORY = 'UTILITY';
export const COMPONENT_TYPES = {
HEADER: 'HEADER',
BODY: 'BODY',
BUTTONS: 'BUTTONS',
};
export const MEDIA_FORMATS = ['IMAGE', 'VIDEO', 'DOCUMENT'];
export const findComponentByType = (template, type) =>
template.components?.find(component => component.type === type);
export const processVariable = str => {
return str.replace(/{{|}}/g, '');
};
export const allKeysRequired = value => {
const keys = Object.keys(value);
return keys.every(key => value[key]);
};
export const replaceTemplateVariables = (templateText, processedParams) => {
return templateText.replace(/{{([^}]+)}}/g, (match, variable) => {
const variableKey = processVariable(variable);
return processedParams.body?.[variableKey] || `{{${variable}}}`;
});
};
export const buildTemplateParameters = (template, hasMediaHeaderValue) => {
const allVariables = {};
const bodyComponent = findComponentByType(template, COMPONENT_TYPES.BODY);
const headerComponent = findComponentByType(template, COMPONENT_TYPES.HEADER);
if (!bodyComponent) return allVariables;
const templateString = bodyComponent.text;
// Process body variables
const matchedVariables = templateString.match(/{{([^}]+)}}/g);
if (matchedVariables) {
allVariables.body = {};
matchedVariables.forEach(variable => {
const key = processVariable(variable);
allVariables.body[key] = '';
});
}
if (hasMediaHeaderValue) {
if (!allVariables.header) allVariables.header = {};
allVariables.header.media_url = '';
allVariables.header.media_type = headerComponent.format.toLowerCase();
}
// Process button variables
const buttonComponents = template.components.filter(
component => component.type === COMPONENT_TYPES.BUTTONS
);
buttonComponents.forEach(buttonComponent => {
if (buttonComponent.buttons) {
buttonComponent.buttons.forEach((button, index) => {
// Handle URL buttons with variables
if (button.type === 'URL' && button.url && button.url.includes('{{')) {
const buttonVars = button.url.match(/{{([^}]+)}}/g) || [];
if (buttonVars.length > 0) {
if (!allVariables.buttons) allVariables.buttons = [];
allVariables.buttons[index] = {
type: 'url',
parameter: '',
url: button.url,
variables: buttonVars.map(v => processVariable(v)),
};
}
}
// Handle copy code buttons
if (button.type === 'COPY_CODE') {
if (!allVariables.buttons) allVariables.buttons = [];
allVariables.buttons[index] = {
type: 'copy_code',
parameter: '',
};
}
});
}
});
return allVariables;
};
-2
View File
@@ -1,5 +1,4 @@
import ar from './locale/ar';
import bg from './locale/bg';
import ca from './locale/ca';
import cs from './locale/cs';
import da from './locale/da';
@@ -41,7 +40,6 @@ import lt from './locale/lt';
export default {
ar,
bg,
ca,
cs,
da,
@@ -131,7 +131,6 @@
"CONVERSATION_CREATED": "Conversation Created",
"CONVERSATION_UPDATED": "Conversation Updated",
"MESSAGE_CREATED": "Message Created",
"CONVERSATION_RESOLVED": "Conversation Resolved",
"CONVERSATION_OPENED": "Conversation Opened"
},
"ACTIONS": {
@@ -144,9 +144,6 @@
"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",
"API": {
"AGENT_ASSIGNMENT": {
"SUCCESFUL": "Conversation id {conversationId} assigned to \"{agentName}\"",
@@ -157,12 +157,6 @@
"DELETE_SUCCESS": "Portal deleted successfully",
"DELETE_ERROR": "Error while deleting portal"
}
},
"SEND_CNAME_INSTRUCTIONS": {
"API": {
"SUCCESS_MESSAGE": "CNAME instructions sent successfully",
"ERROR_MESSAGE": "Error while sending CNAME instructions"
}
}
},
"EDIT": {
@@ -732,7 +726,7 @@
"HOME_PAGE_LINK": {
"LABEL": "Home page link",
"PLACEHOLDER": "Portal home page link",
"ERROR": "Enter a valid URL. The Home page link must start with 'http://' or 'https://'."
"ERROR": "Invalid URL. The Home page link must start with 'http://' or 'https://'."
},
"SLUG": {
"LABEL": "Slug",
@@ -753,15 +747,9 @@
"HEADER": "Custom domain",
"LABEL": "Custom domain:",
"DESCRIPTION": "You can host your portal on a custom domain. For instance, if your website is yourdomain.com and you want your portal available at docs.yourdomain.com, simply enter that in this field.",
"STATUS_DESCRIPTION": "Your custom portal will start working as soon as it is verified.",
"PLACEHOLDER": "Portal custom domain",
"EDIT_BUTTON": "Edit",
"EDIT_BUTTON": "Edit custom domain",
"ADD_BUTTON": "Add custom domain",
"STATUS": {
"LIVE": "Live",
"PENDING": "Awaiting verification",
"ERROR": "Verification failed"
},
"DIALOG": {
"ADD_HEADER": "Add custom domain",
"EDIT_HEADER": "Edit custom domain",
@@ -769,20 +757,13 @@
"EDIT_CONFIRM_BUTTON_LABEL": "Update domain",
"LABEL": "Custom domain",
"PLACEHOLDER": "Portal custom domain",
"ERROR": "Custom domain is required",
"FORMAT_ERROR": "Please enter a valid domain URL e.g. docs.yourdomain.com"
"ERROR": "Custom domain is required"
},
"DNS_CONFIGURATION_DIALOG": {
"HEADER": "DNS configuration",
"DESCRIPTION": "Log in to the account you have with your DNS provider, and add a CNAME record for subdomain pointing to chatwoot.help",
"COPY": "Successfully copied CNAME",
"SEND_INSTRUCTIONS": {
"HEADER": "Send instructions",
"DESCRIPTION": "If you would prefer to have someone from your development team to handle this step, you can enter email address below, and we will send them the required instructions.",
"PLACEHOLDER": "Enter their email",
"ERROR": "Enter a valid email address",
"SEND_BUTTON": "Send"
}
"HELP_TEXT": "Once this is done, you can reach out to our support to request for the auto-generated SSL certificate.",
"CONFIRM_BUTTON_LABEL": "Got it!"
}
},
"DELETE_PORTAL": {
@@ -72,24 +72,6 @@
"MARK_ALL_READ": "All notifications marked as read",
"DELETE_ALL": "All notifications deleted",
"DELETE_ALL_READ": "All read notifications deleted"
},
"REAUTHORIZE": {
"TITLE": "Reauthorization Required",
"DESCRIPTION": "Your WhatsApp connection has expired. Please reconnect to continue receiving and sending messages.",
"BUTTON_TEXT": "Reconnect WhatsApp",
"LOADING_FACEBOOK": "Loading Facebook SDK...",
"SUCCESS": "WhatsApp reconnected successfully",
"ERROR": "Failed to reconnect WhatsApp. Please try again.",
"WHATSAPP_APP_ID_MISSING": "WhatsApp App ID is not configured. Please contact your administrator.",
"WHATSAPP_CONFIG_ID_MISSING": "WhatsApp Configuration ID is not configured. Please contact your administrator.",
"CONFIGURATION_ERROR": "Configuration error occurred during reauthorization.",
"FACEBOOK_LOAD_ERROR": "Failed to load Facebook SDK. Please try again.",
"TROUBLESHOOTING": {
"TITLE": "Troubleshooting",
"POPUP_BLOCKED": "Ensure pop-ups are allowed for this site",
"COOKIES": "Third-party cookies must be enabled",
"ADMIN_ACCESS": "You need admin access to the WhatsApp Business Account"
}
}
}
}
@@ -280,11 +280,6 @@
"SECURE_AUTH": "Secure OAuth based authentication",
"AUTO_CONFIG": "Automatic webhook and phone number configuration"
},
"LEARN_MORE": {
"TEXT": "To learn more about integrated signup, pricing, and limitations, visit",
"LINK_TEXT": "this link.",
"LINK_URL": "https://developers.facebook.com/docs/whatsapp/embedded-signup/custom-flows/onboarding-business-app-users#limitations"
},
"SUBMIT_BUTTON": "Connect with WhatsApp Business",
"AUTH_PROCESSING": "Authenticating with Meta",
"WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...",
@@ -603,21 +598,6 @@
"WHATSAPP_SECTION_UPDATE_TITLE": "Update API Key",
"WHATSAPP_SECTION_UPDATE_PLACEHOLDER": "Enter the new API Key here",
"WHATSAPP_SECTION_UPDATE_BUTTON": "Update",
"WHATSAPP_EMBEDDED_SIGNUP_TITLE": "WhatsApp Embedded Signup",
"WHATSAPP_EMBEDDED_SIGNUP_SUBHEADER": "This inbox is connected through WhatsApp embedded signup.",
"WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION": "You can reconfigure this inbox to update your WhatsApp Business settings.",
"WHATSAPP_RECONFIGURE_BUTTON": "Reconfigure",
"WHATSAPP_CONNECT_TITLE": "Connect to WhatsApp Business",
"WHATSAPP_CONNECT_SUBHEADER": "Upgrade to WhatsApp embedded signup for easier management.",
"WHATSAPP_CONNECT_DESCRIPTION": "Connect this inbox to WhatsApp Business for enhanced features and easier management.",
"WHATSAPP_CONNECT_BUTTON": "Connect",
"WHATSAPP_CONNECT_SUCCESS": "Successfully connected to WhatsApp Business!",
"WHATSAPP_CONNECT_ERROR": "Failed to connect to WhatsApp Business. Please try again.",
"WHATSAPP_RECONFIGURE_SUCCESS": "Successfully reconfigured WhatsApp Business!",
"WHATSAPP_RECONFIGURE_ERROR": "Failed to reconfigure WhatsApp Business. Please try again.",
"WHATSAPP_APP_ID_MISSING": "WhatsApp App ID is not configured. Please contact your administrator.",
"WHATSAPP_CONFIG_ID_MISSING": "WhatsApp Configuration ID is not configured. Please contact your administrator.",
"WHATSAPP_LOGIN_CANCELLED": "WhatsApp login was cancelled. Please try again.",
"WHATSAPP_WEBHOOK_TITLE": "Webhook Verification Token",
"WHATSAPP_WEBHOOK_SUBHEADER": "This token is used to verify the authenticity of the webhook endpoint.",
"WHATSAPP_TEMPLATES_SYNC_TITLE": "Sync Templates",
@@ -472,8 +472,7 @@
"FEATURES": {
"TITLE": "Features",
"ALLOW_CONVERSATION_FAQS": "Generate FAQs from resolved conversations",
"ALLOW_MEMORIES": "Capture key details as memories from customer interactions.",
"ALLOW_CITATIONS": "Include source citations in responses"
"ALLOW_MEMORIES": "Capture key details as memories from customer interactions."
}
},
"EDIT": {
@@ -558,7 +557,6 @@
"SEARCH_PLACEHOLDER": "Search..."
},
"EMPTY_MESSAGE": "No guardrails found. Create or add examples to begin.",
"SEARCH_EMPTY_MESSAGE": "No guardrails found for this search.",
"API": {
"ADD": {
"SUCCESS": "Guardrails added successfully",
@@ -606,7 +604,6 @@
"SEARCH_PLACEHOLDER": "Search..."
},
"EMPTY_MESSAGE": "No response guidelines found. Create or add examples to begin.",
"SEARCH_EMPTY_MESSAGE": "No response guidelines found for this search.",
"API": {
"ADD": {
"SUCCESS": "Response Guidelines added successfully",
@@ -621,73 +618,6 @@
"ERROR": "There was an error deleting response guidelines, please try again."
}
}
},
"SCENARIOS": {
"TITLE": "Scenarios",
"DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”",
"BREADCRUMB": {
"TITLE": "Scenarios"
},
"BULK_ACTION": {
"SELECTED": "{count} item selected | {count} items selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"BULK_DELETE_BUTTON": "Delete"
},
"ADD": {
"SUGGESTED": {
"TITLE": "Example scenarios",
"ADD": "Add all",
"ADD_SINGLE": "Add this",
"TOOLS_USED": "Tools used :"
},
"NEW": {
"CREATE": "Add a scenario",
"TITLE": "Create a scenario",
"FORM": {
"TITLE": {
"LABEL": "Title",
"PLACEHOLDER": "Enter a name for the scenario",
"ERROR": "Scenario name is required"
},
"DESCRIPTION": {
"LABEL": "Description",
"PLACEHOLDER": "Describe how and where this scenario will be used",
"ERROR": "Scenario description is required"
},
"INSTRUCTION": {
"LABEL": "How to handle",
"PLACEHOLDER": "Describe how and where this scenario will be handled",
"ERROR": "Scenario content is required"
},
"CREATE": "Create",
"CANCEL": "Cancel"
}
}
},
"UPDATE": {
"CANCEL": "Cancel",
"UPDATE": "Update changes"
},
"LIST": {
"SEARCH_PLACEHOLDER": "Search..."
},
"EMPTY_MESSAGE": "No scenarios found. Create or add examples to begin.",
"SEARCH_EMPTY_MESSAGE": "No scenarios found for this search.",
"API": {
"ADD": {
"SUCCESS": "Scenarios added successfully",
"ERROR": "There was an error adding scenarios, please try again."
},
"UPDATE": {
"SUCCESS": "Scenarios updated successfully",
"ERROR": "There was an error updating scenarios, please try again."
},
"DELETE": {
"SUCCESS": "Scenarios deleted successfully",
"ERROR": "There was an error deleting scenarios, please try again."
}
}
}
},
"DOCUMENTS": {
@@ -1,46 +1,29 @@
{
"WHATSAPP_TEMPLATES": {
"MODAL": {
"TITLE": "Whatsapp Templates",
"SUBTITLE": "Select the whatsapp template you want to send",
"TEMPLATE_SELECTED_SUBTITLE": "Configure template: {templateName}"
},
"PICKER": {
"SEARCH_PLACEHOLDER": "Search Templates",
"NO_TEMPLATES_FOUND": "No templates found for",
"HEADER": "Header",
"BODY": "Body",
"FOOTER": "Footer",
"BUTTONS": "Buttons",
"CATEGORY": "Category",
"MEDIA_CONTENT": "Media Content",
"MEDIA_CONTENT_FALLBACK": "media content",
"NO_TEMPLATES_AVAILABLE": "No WhatsApp templates available. Click refresh to sync templates from WhatsApp.",
"REFRESH_BUTTON": "Refresh templates",
"REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
"REFRESH_ERROR": "Failed to refresh templates. Please try again.",
"LABELS": {
"LANGUAGE": "Language",
"TEMPLATE_BODY": "Template Body",
"CATEGORY": "Category"
}
},
"PARSER": {
"VARIABLES_LABEL": "Variables",
"LANGUAGE": "Language",
"CATEGORY": "Category",
"VARIABLE_PLACEHOLDER": "Enter {variable} value",
"GO_BACK_LABEL": "Go Back",
"SEND_MESSAGE_LABEL": "Send Message",
"FORM_ERROR_MESSAGE": "Please fill all variables before sending",
"MEDIA_HEADER_LABEL": "{type} Header",
"OTP_CODE": "Enter 4-8 digit OTP",
"EXPIRY_MINUTES": "Enter expiry minutes",
"BUTTON_PARAMETERS": "Button Parameters",
"BUTTON_LABEL": "Button {index}",
"COUPON_CODE": "Enter coupon code (max 15 chars)",
"MEDIA_URL_LABEL": "Enter {type} URL",
"BUTTON_PARAMETER": "Enter button parameter"
"WHATSAPP_TEMPLATES": {
"MODAL": {
"TITLE": "Whatsapp Templates",
"SUBTITLE": "Select the whatsapp template you want to send",
"TEMPLATE_SELECTED_SUBTITLE": "Process {templateName}"
},
"PICKER": {
"SEARCH_PLACEHOLDER": "Search Templates",
"NO_TEMPLATES_FOUND": "No templates found for",
"NO_TEMPLATES_AVAILABLE": "No WhatsApp templates available. Click refresh to sync templates from WhatsApp.",
"REFRESH_BUTTON": "Refresh templates",
"REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
"REFRESH_ERROR": "Failed to refresh templates. Please try again.",
"LABELS": {
"LANGUAGE": "Language",
"TEMPLATE_BODY": "Template Body",
"CATEGORY": "Category"
}
},
"PARSER": {
"VARIABLES_LABEL": "Variables",
"VARIABLE_PLACEHOLDER": "Enter {variable} value",
"GO_BACK_LABEL": "Go Back",
"SEND_MESSAGE_LABEL": "Send Message",
"FORM_ERROR_MESSAGE": "Please fill all variables before sending"
}
}
}
}
@@ -131,7 +131,6 @@
"CONVERSATION_CREATED": "تم إنشاء المحادثة",
"CONVERSATION_UPDATED": "تم تحديث المحادثة",
"MESSAGE_CREATED": "Message Created",
"CONVERSATION_RESOLVED": "Conversation Resolved",
"CONVERSATION_OPENED": "Conversation Opened"
},
"ACTIONS": {
@@ -144,9 +144,6 @@
"AGENTS_LOADING": "جاري جلب الوكلاء...",
"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",
"API": {
"AGENT_ASSIGNMENT": {
"SUCCESFUL": "معرف المحادثة {conversationId} تم تعيينه لـ \"{agentName}\"",
@@ -29,7 +29,7 @@
"FAILURE": "Could not delete account, try again!",
"SCHEDULED_DELETION": {
"TITLE": "Account Scheduled for Deletion",
"MESSAGE_MANUAL": "هذا الحساب مجدول للحذف بتاريخ {deletionDate}. تم طلبه من قبل المسؤول. يمكنك إلغاء الحذف قبل هذا التاريخ.",
"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.",
"CLEAR_BUTTON": "Cancel Scheduled Deletion"
}
@@ -157,12 +157,6 @@
"DELETE_SUCCESS": "Portal deleted successfully",
"DELETE_ERROR": "حدث خطأ أثناء حذف البوابة"
}
},
"SEND_CNAME_INSTRUCTIONS": {
"API": {
"SUCCESS_MESSAGE": "CNAME instructions sent successfully",
"ERROR_MESSAGE": "Error while sending CNAME instructions"
}
}
},
"EDIT": {
@@ -732,7 +726,7 @@
"HOME_PAGE_LINK": {
"LABEL": "Home page link",
"PLACEHOLDER": "رابط الصفحة الرئيسية للبوابة",
"ERROR": "Enter a valid URL. The Home page link must start with 'http://' or 'https://'."
"ERROR": "Invalid URL. The Home page link must start with 'http://' or 'https://'."
},
"SLUG": {
"LABEL": "Slug",
@@ -753,15 +747,9 @@
"HEADER": "نطاق مخصص",
"LABEL": "نطاق مخصص:",
"DESCRIPTION": "You can host your portal on a custom domain. For instance, if your website is yourdomain.com and you want your portal available at docs.yourdomain.com, simply enter that in this field.",
"STATUS_DESCRIPTION": "Your custom portal will start working as soon as it is verified.",
"PLACEHOLDER": "نطاق البوابة المخصص",
"EDIT_BUTTON": "تعديل",
"EDIT_BUTTON": "Edit custom domain",
"ADD_BUTTON": "Add custom domain",
"STATUS": {
"LIVE": "مباشر",
"PENDING": "Awaiting verification",
"ERROR": "Verification failed"
},
"DIALOG": {
"ADD_HEADER": "Add custom domain",
"EDIT_HEADER": "Edit custom domain",
@@ -769,20 +757,13 @@
"EDIT_CONFIRM_BUTTON_LABEL": "Update domain",
"LABEL": "نطاق مخصص",
"PLACEHOLDER": "نطاق البوابة المخصص",
"ERROR": "Custom domain is required",
"FORMAT_ERROR": "Please enter a valid domain URL e.g. docs.yourdomain.com"
"ERROR": "Custom domain is required"
},
"DNS_CONFIGURATION_DIALOG": {
"HEADER": "DNS configuration",
"DESCRIPTION": "Log in to the account you have with your DNS provider, and add a CNAME record for subdomain pointing to chatwoot.help",
"COPY": "Successfully copied CNAME",
"SEND_INSTRUCTIONS": {
"HEADER": "Send instructions",
"DESCRIPTION": "If you would prefer to have someone from your development team to handle this step, you can enter email address below, and we will send them the required instructions.",
"PLACEHOLDER": "Enter their email",
"ERROR": "Enter a valid email address",
"SEND_BUTTON": "إرسال"
}
"HELP_TEXT": "Once this is done, you can reach out to our support to request for the auto-generated SSL certificate.",
"CONFIRM_BUTTON_LABEL": "Got it!"
}
},
"DELETE_PORTAL": {
@@ -72,24 +72,6 @@
"MARK_ALL_READ": "تم تحديد جميع الإشعارات كمقروءة",
"DELETE_ALL": "تم حذف كل الإشعارات",
"DELETE_ALL_READ": "تم حذف كل الإشعارات المقروءة"
},
"REAUTHORIZE": {
"TITLE": "Reauthorization Required",
"DESCRIPTION": "Your WhatsApp connection has expired. Please reconnect to continue receiving and sending messages.",
"BUTTON_TEXT": "Reconnect WhatsApp",
"LOADING_FACEBOOK": "Loading Facebook SDK...",
"SUCCESS": "WhatsApp reconnected successfully",
"ERROR": "Failed to reconnect WhatsApp. Please try again.",
"WHATSAPP_APP_ID_MISSING": "WhatsApp App ID is not configured. Please contact your administrator.",
"WHATSAPP_CONFIG_ID_MISSING": "WhatsApp Configuration ID is not configured. Please contact your administrator.",
"CONFIGURATION_ERROR": "Configuration error occurred during reauthorization.",
"FACEBOOK_LOAD_ERROR": "Failed to load Facebook SDK. Please try again.",
"TROUBLESHOOTING": {
"TITLE": "Troubleshooting",
"POPUP_BLOCKED": "Ensure pop-ups are allowed for this site",
"COOKIES": "Third-party cookies must be enabled",
"ADMIN_ACCESS": "You need admin access to the WhatsApp Business Account"
}
}
}
}
@@ -280,11 +280,6 @@
"SECURE_AUTH": "Secure OAuth based authentication",
"AUTO_CONFIG": "Automatic webhook and phone number configuration"
},
"LEARN_MORE": {
"TEXT": "To learn more about integrated signup, pricing, and limitations, visit",
"LINK_TEXT": "this link.",
"LINK_URL": "https://developers.facebook.com/docs/whatsapp/embedded-signup/custom-flows/onboarding-business-app-users#limitations"
},
"SUBMIT_BUTTON": "Connect with WhatsApp Business",
"AUTH_PROCESSING": "Authenticating with Meta",
"WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...",
@@ -603,21 +598,6 @@
"WHATSAPP_SECTION_UPDATE_TITLE": "Update API Key",
"WHATSAPP_SECTION_UPDATE_PLACEHOLDER": "Enter the new API Key here",
"WHATSAPP_SECTION_UPDATE_BUTTON": "تحديث",
"WHATSAPP_EMBEDDED_SIGNUP_TITLE": "WhatsApp Embedded Signup",
"WHATSAPP_EMBEDDED_SIGNUP_SUBHEADER": "This inbox is connected through WhatsApp embedded signup.",
"WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION": "You can reconfigure this inbox to update your WhatsApp Business settings.",
"WHATSAPP_RECONFIGURE_BUTTON": "Reconfigure",
"WHATSAPP_CONNECT_TITLE": "Connect to WhatsApp Business",
"WHATSAPP_CONNECT_SUBHEADER": "Upgrade to WhatsApp embedded signup for easier management.",
"WHATSAPP_CONNECT_DESCRIPTION": "Connect this inbox to WhatsApp Business for enhanced features and easier management.",
"WHATSAPP_CONNECT_BUTTON": "ربط الاتصال",
"WHATSAPP_CONNECT_SUCCESS": "Successfully connected to WhatsApp Business!",
"WHATSAPP_CONNECT_ERROR": "Failed to connect to WhatsApp Business. Please try again.",
"WHATSAPP_RECONFIGURE_SUCCESS": "Successfully reconfigured WhatsApp Business!",
"WHATSAPP_RECONFIGURE_ERROR": "Failed to reconfigure WhatsApp Business. Please try again.",
"WHATSAPP_APP_ID_MISSING": "WhatsApp App ID is not configured. Please contact your administrator.",
"WHATSAPP_CONFIG_ID_MISSING": "WhatsApp Configuration ID is not configured. Please contact your administrator.",
"WHATSAPP_LOGIN_CANCELLED": "WhatsApp login was cancelled. Please try again.",
"WHATSAPP_WEBHOOK_TITLE": "رمز التحقق من Webhook",
"WHATSAPP_WEBHOOK_SUBHEADER": "This token is used to verify the authenticity of the webhook endpoint.",
"WHATSAPP_TEMPLATES_SYNC_TITLE": "Sync Templates",
@@ -472,8 +472,7 @@
"FEATURES": {
"TITLE": "الخصائص",
"ALLOW_CONVERSATION_FAQS": "Generate FAQs from resolved conversations",
"ALLOW_MEMORIES": "Capture key details as memories from customer interactions.",
"ALLOW_CITATIONS": "Include source citations in responses"
"ALLOW_MEMORIES": "Capture key details as memories from customer interactions."
}
},
"EDIT": {
@@ -558,7 +557,6 @@
"SEARCH_PLACEHOLDER": "Search..."
},
"EMPTY_MESSAGE": "No guardrails found. Create or add examples to begin.",
"SEARCH_EMPTY_MESSAGE": "No guardrails found for this search.",
"API": {
"ADD": {
"SUCCESS": "Guardrails added successfully",
@@ -606,7 +604,6 @@
"SEARCH_PLACEHOLDER": "Search..."
},
"EMPTY_MESSAGE": "No response guidelines found. Create or add examples to begin.",
"SEARCH_EMPTY_MESSAGE": "No response guidelines found for this search.",
"API": {
"ADD": {
"SUCCESS": "Response Guidelines added successfully",
@@ -621,73 +618,6 @@
"ERROR": "There was an error deleting response guidelines, please try again."
}
}
},
"SCENARIOS": {
"TITLE": "Scenarios",
"DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”",
"BREADCRUMB": {
"TITLE": "Scenarios"
},
"BULK_ACTION": {
"SELECTED": "{count} item selected | {count} items selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"BULK_DELETE_BUTTON": "حذف"
},
"ADD": {
"SUGGESTED": {
"TITLE": "Example scenarios",
"ADD": "Add all",
"ADD_SINGLE": "Add this",
"TOOLS_USED": "Tools used :"
},
"NEW": {
"CREATE": "Add a scenario",
"TITLE": "Create a scenario",
"FORM": {
"TITLE": {
"LABEL": "العنوان",
"PLACEHOLDER": "Enter a name for the scenario",
"ERROR": "Scenario name is required"
},
"DESCRIPTION": {
"LABEL": "الوصف",
"PLACEHOLDER": "Describe how and where this scenario will be used",
"ERROR": "Scenario description is required"
},
"INSTRUCTION": {
"LABEL": "How to handle",
"PLACEHOLDER": "Describe how and where this scenario will be handled",
"ERROR": "Scenario content is required"
},
"CREATE": "إنشاء",
"CANCEL": "إلغاء"
}
}
},
"UPDATE": {
"CANCEL": "إلغاء",
"UPDATE": "Update changes"
},
"LIST": {
"SEARCH_PLACEHOLDER": "Search..."
},
"EMPTY_MESSAGE": "No scenarios found. Create or add examples to begin.",
"SEARCH_EMPTY_MESSAGE": "No scenarios found for this search.",
"API": {
"ADD": {
"SUCCESS": "Scenarios added successfully",
"ERROR": "There was an error adding scenarios, please try again."
},
"UPDATE": {
"SUCCESS": "Scenarios updated successfully",
"ERROR": "There was an error updating scenarios, please try again."
},
"DELETE": {
"SUCCESS": "Scenarios deleted successfully",
"ERROR": "There was an error deleting scenarios, please try again."
}
}
}
},
"DOCUMENTS": {
@@ -1,46 +1,29 @@
{
"WHATSAPP_TEMPLATES": {
"MODAL": {
"TITLE": "قوالب الواتساب",
"SUBTITLE": "حدد القالب الذي تريد إرساله",
"TEMPLATE_SELECTED_SUBTITLE": "Configure template: {templateName}"
},
"PICKER": {
"SEARCH_PLACEHOLDER": "نماذج البحث",
"NO_TEMPLATES_FOUND": "لم يتم العثور على قوالب",
"HEADER": "Header",
"BODY": "Body",
"FOOTER": "Footer",
"BUTTONS": "Buttons",
"CATEGORY": "الفئة",
"MEDIA_CONTENT": "Media Content",
"MEDIA_CONTENT_FALLBACK": "media content",
"NO_TEMPLATES_AVAILABLE": "No WhatsApp templates available. Click refresh to sync templates from WhatsApp.",
"REFRESH_BUTTON": "Refresh templates",
"REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
"REFRESH_ERROR": "Failed to refresh templates. Please try again.",
"LABELS": {
"LANGUAGE": "اللغة",
"TEMPLATE_BODY": "نص القالب",
"CATEGORY": "الفئة"
}
},
"PARSER": {
"VARIABLES_LABEL": "المتغيرات",
"LANGUAGE": "اللغة",
"CATEGORY": "الفئة",
"VARIABLE_PLACEHOLDER": "أدخل قيمة {variable}",
"GO_BACK_LABEL": "العودة للخلف",
"SEND_MESSAGE_LABEL": "إرسال الرسالة",
"FORM_ERROR_MESSAGE": "يرجى ملء جميع المتغيرات قبل الإرسال",
"MEDIA_HEADER_LABEL": "{type} Header",
"OTP_CODE": "Enter 4-8 digit OTP",
"EXPIRY_MINUTES": "Enter expiry minutes",
"BUTTON_PARAMETERS": "Button Parameters",
"BUTTON_LABEL": "Button {index}",
"COUPON_CODE": "Enter coupon code (max 15 chars)",
"MEDIA_URL_LABEL": "Enter {type} URL",
"BUTTON_PARAMETER": "Enter button parameter"
"WHATSAPP_TEMPLATES": {
"MODAL": {
"TITLE": "قوالب الواتساب",
"SUBTITLE": "حدد القالب الذي تريد إرساله",
"TEMPLATE_SELECTED_SUBTITLE": "معالجة {templateName}"
},
"PICKER": {
"SEARCH_PLACEHOLDER": "نماذج البحث",
"NO_TEMPLATES_FOUND": "لم يتم العثور على قوالب",
"NO_TEMPLATES_AVAILABLE": "No WhatsApp templates available. Click refresh to sync templates from WhatsApp.",
"REFRESH_BUTTON": "Refresh templates",
"REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
"REFRESH_ERROR": "Failed to refresh templates. Please try again.",
"LABELS": {
"LANGUAGE": "اللغة",
"TEMPLATE_BODY": "نص القالب",
"CATEGORY": "الفئة"
}
},
"PARSER": {
"VARIABLES_LABEL": "المتغيرات",
"VARIABLE_PLACEHOLDER": "أدخل قيمة {variable}",
"GO_BACK_LABEL": "العودة للخلف",
"SEND_MESSAGE_LABEL": "إرسال الرسالة",
"FORM_ERROR_MESSAGE": "يرجى ملء جميع المتغيرات قبل الإرسال"
}
}
}
}
@@ -131,7 +131,6 @@
"CONVERSATION_CREATED": "Conversation Created",
"CONVERSATION_UPDATED": "Conversation Updated",
"MESSAGE_CREATED": "Message Created",
"CONVERSATION_RESOLVED": "Conversation Resolved",
"CONVERSATION_OPENED": "Conversation Opened"
},
"ACTIONS": {
@@ -144,9 +144,6 @@
"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",
"API": {
"AGENT_ASSIGNMENT": {
"SUCCESFUL": "Conversation id {conversationId} assigned to \"{agentName}\"",
@@ -157,12 +157,6 @@
"DELETE_SUCCESS": "Portal deleted successfully",
"DELETE_ERROR": "Error while deleting portal"
}
},
"SEND_CNAME_INSTRUCTIONS": {
"API": {
"SUCCESS_MESSAGE": "CNAME instructions sent successfully",
"ERROR_MESSAGE": "Error while sending CNAME instructions"
}
}
},
"EDIT": {
@@ -732,7 +726,7 @@
"HOME_PAGE_LINK": {
"LABEL": "Home page link",
"PLACEHOLDER": "Portal home page link",
"ERROR": "Enter a valid URL. The Home page link must start with 'http://' or 'https://'."
"ERROR": "Invalid URL. The Home page link must start with 'http://' or 'https://'."
},
"SLUG": {
"LABEL": "Slug",
@@ -753,15 +747,9 @@
"HEADER": "Custom domain",
"LABEL": "Custom domain:",
"DESCRIPTION": "You can host your portal on a custom domain. For instance, if your website is yourdomain.com and you want your portal available at docs.yourdomain.com, simply enter that in this field.",
"STATUS_DESCRIPTION": "Your custom portal will start working as soon as it is verified.",
"PLACEHOLDER": "Portal custom domain",
"EDIT_BUTTON": "Edit",
"EDIT_BUTTON": "Edit custom domain",
"ADD_BUTTON": "Add custom domain",
"STATUS": {
"LIVE": "Live",
"PENDING": "Awaiting verification",
"ERROR": "Verification failed"
},
"DIALOG": {
"ADD_HEADER": "Add custom domain",
"EDIT_HEADER": "Edit custom domain",
@@ -769,20 +757,13 @@
"EDIT_CONFIRM_BUTTON_LABEL": "Update domain",
"LABEL": "Custom domain",
"PLACEHOLDER": "Portal custom domain",
"ERROR": "Custom domain is required",
"FORMAT_ERROR": "Please enter a valid domain URL e.g. docs.yourdomain.com"
"ERROR": "Custom domain is required"
},
"DNS_CONFIGURATION_DIALOG": {
"HEADER": "DNS configuration",
"DESCRIPTION": "Log in to the account you have with your DNS provider, and add a CNAME record for subdomain pointing to chatwoot.help",
"COPY": "Successfully copied CNAME",
"SEND_INSTRUCTIONS": {
"HEADER": "Send instructions",
"DESCRIPTION": "If you would prefer to have someone from your development team to handle this step, you can enter email address below, and we will send them the required instructions.",
"PLACEHOLDER": "Enter their email",
"ERROR": "Enter a valid email address",
"SEND_BUTTON": "Send"
}
"HELP_TEXT": "Once this is done, you can reach out to our support to request for the auto-generated SSL certificate.",
"CONFIRM_BUTTON_LABEL": "Got it!"
}
},
"DELETE_PORTAL": {
@@ -72,24 +72,6 @@
"MARK_ALL_READ": "All notifications marked as read",
"DELETE_ALL": "All notifications deleted",
"DELETE_ALL_READ": "All read notifications deleted"
},
"REAUTHORIZE": {
"TITLE": "Reauthorization Required",
"DESCRIPTION": "Your WhatsApp connection has expired. Please reconnect to continue receiving and sending messages.",
"BUTTON_TEXT": "Reconnect WhatsApp",
"LOADING_FACEBOOK": "Loading Facebook SDK...",
"SUCCESS": "WhatsApp reconnected successfully",
"ERROR": "Failed to reconnect WhatsApp. Please try again.",
"WHATSAPP_APP_ID_MISSING": "WhatsApp App ID is not configured. Please contact your administrator.",
"WHATSAPP_CONFIG_ID_MISSING": "WhatsApp Configuration ID is not configured. Please contact your administrator.",
"CONFIGURATION_ERROR": "Configuration error occurred during reauthorization.",
"FACEBOOK_LOAD_ERROR": "Failed to load Facebook SDK. Please try again.",
"TROUBLESHOOTING": {
"TITLE": "Troubleshooting",
"POPUP_BLOCKED": "Ensure pop-ups are allowed for this site",
"COOKIES": "Third-party cookies must be enabled",
"ADMIN_ACCESS": "You need admin access to the WhatsApp Business Account"
}
}
}
}
@@ -280,11 +280,6 @@
"SECURE_AUTH": "Secure OAuth based authentication",
"AUTO_CONFIG": "Automatic webhook and phone number configuration"
},
"LEARN_MORE": {
"TEXT": "To learn more about integrated signup, pricing, and limitations, visit",
"LINK_TEXT": "this link.",
"LINK_URL": "https://developers.facebook.com/docs/whatsapp/embedded-signup/custom-flows/onboarding-business-app-users#limitations"
},
"SUBMIT_BUTTON": "Connect with WhatsApp Business",
"AUTH_PROCESSING": "Authenticating with Meta",
"WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...",
@@ -603,21 +598,6 @@
"WHATSAPP_SECTION_UPDATE_TITLE": "Update API Key",
"WHATSAPP_SECTION_UPDATE_PLACEHOLDER": "Enter the new API Key here",
"WHATSAPP_SECTION_UPDATE_BUTTON": "Update",
"WHATSAPP_EMBEDDED_SIGNUP_TITLE": "WhatsApp Embedded Signup",
"WHATSAPP_EMBEDDED_SIGNUP_SUBHEADER": "This inbox is connected through WhatsApp embedded signup.",
"WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION": "You can reconfigure this inbox to update your WhatsApp Business settings.",
"WHATSAPP_RECONFIGURE_BUTTON": "Reconfigure",
"WHATSAPP_CONNECT_TITLE": "Connect to WhatsApp Business",
"WHATSAPP_CONNECT_SUBHEADER": "Upgrade to WhatsApp embedded signup for easier management.",
"WHATSAPP_CONNECT_DESCRIPTION": "Connect this inbox to WhatsApp Business for enhanced features and easier management.",
"WHATSAPP_CONNECT_BUTTON": "Connect",
"WHATSAPP_CONNECT_SUCCESS": "Successfully connected to WhatsApp Business!",
"WHATSAPP_CONNECT_ERROR": "Failed to connect to WhatsApp Business. Please try again.",
"WHATSAPP_RECONFIGURE_SUCCESS": "Successfully reconfigured WhatsApp Business!",
"WHATSAPP_RECONFIGURE_ERROR": "Failed to reconfigure WhatsApp Business. Please try again.",
"WHATSAPP_APP_ID_MISSING": "WhatsApp App ID is not configured. Please contact your administrator.",
"WHATSAPP_CONFIG_ID_MISSING": "WhatsApp Configuration ID is not configured. Please contact your administrator.",
"WHATSAPP_LOGIN_CANCELLED": "WhatsApp login was cancelled. Please try again.",
"WHATSAPP_WEBHOOK_TITLE": "Webhook Verification Token",
"WHATSAPP_WEBHOOK_SUBHEADER": "This token is used to verify the authenticity of the webhook endpoint.",
"WHATSAPP_TEMPLATES_SYNC_TITLE": "Sync Templates",
@@ -472,8 +472,7 @@
"FEATURES": {
"TITLE": "Features",
"ALLOW_CONVERSATION_FAQS": "Generate FAQs from resolved conversations",
"ALLOW_MEMORIES": "Capture key details as memories from customer interactions.",
"ALLOW_CITATIONS": "Include source citations in responses"
"ALLOW_MEMORIES": "Capture key details as memories from customer interactions."
}
},
"EDIT": {
@@ -558,7 +557,6 @@
"SEARCH_PLACEHOLDER": "Search..."
},
"EMPTY_MESSAGE": "No guardrails found. Create or add examples to begin.",
"SEARCH_EMPTY_MESSAGE": "No guardrails found for this search.",
"API": {
"ADD": {
"SUCCESS": "Guardrails added successfully",
@@ -606,7 +604,6 @@
"SEARCH_PLACEHOLDER": "Search..."
},
"EMPTY_MESSAGE": "No response guidelines found. Create or add examples to begin.",
"SEARCH_EMPTY_MESSAGE": "No response guidelines found for this search.",
"API": {
"ADD": {
"SUCCESS": "Response Guidelines added successfully",
@@ -621,73 +618,6 @@
"ERROR": "There was an error deleting response guidelines, please try again."
}
}
},
"SCENARIOS": {
"TITLE": "Scenarios",
"DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”",
"BREADCRUMB": {
"TITLE": "Scenarios"
},
"BULK_ACTION": {
"SELECTED": "{count} item selected | {count} items selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"BULK_DELETE_BUTTON": "Delete"
},
"ADD": {
"SUGGESTED": {
"TITLE": "Example scenarios",
"ADD": "Add all",
"ADD_SINGLE": "Add this",
"TOOLS_USED": "Tools used :"
},
"NEW": {
"CREATE": "Add a scenario",
"TITLE": "Create a scenario",
"FORM": {
"TITLE": {
"LABEL": "Title",
"PLACEHOLDER": "Enter a name for the scenario",
"ERROR": "Scenario name is required"
},
"DESCRIPTION": {
"LABEL": "Description",
"PLACEHOLDER": "Describe how and where this scenario will be used",
"ERROR": "Scenario description is required"
},
"INSTRUCTION": {
"LABEL": "How to handle",
"PLACEHOLDER": "Describe how and where this scenario will be handled",
"ERROR": "Scenario content is required"
},
"CREATE": "Create",
"CANCEL": "Cancel"
}
}
},
"UPDATE": {
"CANCEL": "Cancel",
"UPDATE": "Update changes"
},
"LIST": {
"SEARCH_PLACEHOLDER": "Search..."
},
"EMPTY_MESSAGE": "No scenarios found. Create or add examples to begin.",
"SEARCH_EMPTY_MESSAGE": "No scenarios found for this search.",
"API": {
"ADD": {
"SUCCESS": "Scenarios added successfully",
"ERROR": "There was an error adding scenarios, please try again."
},
"UPDATE": {
"SUCCESS": "Scenarios updated successfully",
"ERROR": "There was an error updating scenarios, please try again."
},
"DELETE": {
"SUCCESS": "Scenarios deleted successfully",
"ERROR": "There was an error deleting scenarios, please try again."
}
}
}
},
"DOCUMENTS": {
@@ -1,46 +1,29 @@
{
"WHATSAPP_TEMPLATES": {
"MODAL": {
"TITLE": "Whatsapp Templates",
"SUBTITLE": "Select the whatsapp template you want to send",
"TEMPLATE_SELECTED_SUBTITLE": "Configure template: {templateName}"
},
"PICKER": {
"SEARCH_PLACEHOLDER": "Search Templates",
"NO_TEMPLATES_FOUND": "No templates found for",
"HEADER": "Header",
"BODY": "Body",
"FOOTER": "Footer",
"BUTTONS": "Buttons",
"CATEGORY": "Category",
"MEDIA_CONTENT": "Media Content",
"MEDIA_CONTENT_FALLBACK": "media content",
"NO_TEMPLATES_AVAILABLE": "No WhatsApp templates available. Click refresh to sync templates from WhatsApp.",
"REFRESH_BUTTON": "Refresh templates",
"REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
"REFRESH_ERROR": "Failed to refresh templates. Please try again.",
"LABELS": {
"LANGUAGE": "Language",
"TEMPLATE_BODY": "Template Body",
"CATEGORY": "Category"
}
},
"PARSER": {
"VARIABLES_LABEL": "Variables",
"LANGUAGE": "Language",
"CATEGORY": "Category",
"VARIABLE_PLACEHOLDER": "Enter {variable} value",
"GO_BACK_LABEL": "Go Back",
"SEND_MESSAGE_LABEL": "Send Message",
"FORM_ERROR_MESSAGE": "Please fill all variables before sending",
"MEDIA_HEADER_LABEL": "{type} Header",
"OTP_CODE": "Enter 4-8 digit OTP",
"EXPIRY_MINUTES": "Enter expiry minutes",
"BUTTON_PARAMETERS": "Button Parameters",
"BUTTON_LABEL": "Button {index}",
"COUPON_CODE": "Enter coupon code (max 15 chars)",
"MEDIA_URL_LABEL": "Enter {type} URL",
"BUTTON_PARAMETER": "Enter button parameter"
"WHATSAPP_TEMPLATES": {
"MODAL": {
"TITLE": "Whatsapp Templates",
"SUBTITLE": "Select the whatsapp template you want to send",
"TEMPLATE_SELECTED_SUBTITLE": "Process {templateName}"
},
"PICKER": {
"SEARCH_PLACEHOLDER": "Search Templates",
"NO_TEMPLATES_FOUND": "No templates found for",
"NO_TEMPLATES_AVAILABLE": "No WhatsApp templates available. Click refresh to sync templates from WhatsApp.",
"REFRESH_BUTTON": "Refresh templates",
"REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
"REFRESH_ERROR": "Failed to refresh templates. Please try again.",
"LABELS": {
"LANGUAGE": "Language",
"TEMPLATE_BODY": "Template Body",
"CATEGORY": "Category"
}
},
"PARSER": {
"VARIABLES_LABEL": "Variables",
"VARIABLE_PLACEHOLDER": "Enter {variable} value",
"GO_BACK_LABEL": "Go Back",
"SEND_MESSAGE_LABEL": "Send Message",
"FORM_ERROR_MESSAGE": "Please fill all variables before sending"
}
}
}
}
@@ -131,7 +131,6 @@
"CONVERSATION_CREATED": "Conversation Created",
"CONVERSATION_UPDATED": "Conversation Updated",
"MESSAGE_CREATED": "Message Created",
"CONVERSATION_RESOLVED": "Conversation Resolved",
"CONVERSATION_OPENED": "Conversation Opened"
},
"ACTIONS": {
@@ -144,9 +144,6 @@
"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",
"API": {
"AGENT_ASSIGNMENT": {
"SUCCESFUL": "Conversation id {conversationId} assigned to \"{agentName}\"",
@@ -157,12 +157,6 @@
"DELETE_SUCCESS": "Portal deleted successfully",
"DELETE_ERROR": "Error while deleting portal"
}
},
"SEND_CNAME_INSTRUCTIONS": {
"API": {
"SUCCESS_MESSAGE": "CNAME instructions sent successfully",
"ERROR_MESSAGE": "Error while sending CNAME instructions"
}
}
},
"EDIT": {
@@ -732,7 +726,7 @@
"HOME_PAGE_LINK": {
"LABEL": "Home page link",
"PLACEHOLDER": "Portal home page link",
"ERROR": "Enter a valid URL. The Home page link must start with 'http://' or 'https://'."
"ERROR": "Invalid URL. The Home page link must start with 'http://' or 'https://'."
},
"SLUG": {
"LABEL": "Slug",
@@ -753,15 +747,9 @@
"HEADER": "Custom domain",
"LABEL": "Custom domain:",
"DESCRIPTION": "You can host your portal on a custom domain. For instance, if your website is yourdomain.com and you want your portal available at docs.yourdomain.com, simply enter that in this field.",
"STATUS_DESCRIPTION": "Your custom portal will start working as soon as it is verified.",
"PLACEHOLDER": "Portal custom domain",
"EDIT_BUTTON": "Редактирай",
"EDIT_BUTTON": "Edit custom domain",
"ADD_BUTTON": "Add custom domain",
"STATUS": {
"LIVE": "Live",
"PENDING": "Awaiting verification",
"ERROR": "Verification failed"
},
"DIALOG": {
"ADD_HEADER": "Add custom domain",
"EDIT_HEADER": "Edit custom domain",
@@ -769,20 +757,13 @@
"EDIT_CONFIRM_BUTTON_LABEL": "Update domain",
"LABEL": "Custom domain",
"PLACEHOLDER": "Portal custom domain",
"ERROR": "Custom domain is required",
"FORMAT_ERROR": "Please enter a valid domain URL e.g. docs.yourdomain.com"
"ERROR": "Custom domain is required"
},
"DNS_CONFIGURATION_DIALOG": {
"HEADER": "DNS configuration",
"DESCRIPTION": "Log in to the account you have with your DNS provider, and add a CNAME record for subdomain pointing to chatwoot.help",
"COPY": "Successfully copied CNAME",
"SEND_INSTRUCTIONS": {
"HEADER": "Send instructions",
"DESCRIPTION": "If you would prefer to have someone from your development team to handle this step, you can enter email address below, and we will send them the required instructions.",
"PLACEHOLDER": "Enter their email",
"ERROR": "Enter a valid email address",
"SEND_BUTTON": "Send"
}
"HELP_TEXT": "Once this is done, you can reach out to our support to request for the auto-generated SSL certificate.",
"CONFIRM_BUTTON_LABEL": "Got it!"
}
},
"DELETE_PORTAL": {
@@ -72,24 +72,6 @@
"MARK_ALL_READ": "All notifications marked as read",
"DELETE_ALL": "All notifications deleted",
"DELETE_ALL_READ": "All read notifications deleted"
},
"REAUTHORIZE": {
"TITLE": "Reauthorization Required",
"DESCRIPTION": "Your WhatsApp connection has expired. Please reconnect to continue receiving and sending messages.",
"BUTTON_TEXT": "Reconnect WhatsApp",
"LOADING_FACEBOOK": "Loading Facebook SDK...",
"SUCCESS": "WhatsApp reconnected successfully",
"ERROR": "Failed to reconnect WhatsApp. Please try again.",
"WHATSAPP_APP_ID_MISSING": "WhatsApp App ID is not configured. Please contact your administrator.",
"WHATSAPP_CONFIG_ID_MISSING": "WhatsApp Configuration ID is not configured. Please contact your administrator.",
"CONFIGURATION_ERROR": "Configuration error occurred during reauthorization.",
"FACEBOOK_LOAD_ERROR": "Failed to load Facebook SDK. Please try again.",
"TROUBLESHOOTING": {
"TITLE": "Troubleshooting",
"POPUP_BLOCKED": "Ensure pop-ups are allowed for this site",
"COOKIES": "Third-party cookies must be enabled",
"ADMIN_ACCESS": "You need admin access to the WhatsApp Business Account"
}
}
}
}
@@ -280,11 +280,6 @@
"SECURE_AUTH": "Secure OAuth based authentication",
"AUTO_CONFIG": "Automatic webhook and phone number configuration"
},
"LEARN_MORE": {
"TEXT": "To learn more about integrated signup, pricing, and limitations, visit",
"LINK_TEXT": "this link.",
"LINK_URL": "https://developers.facebook.com/docs/whatsapp/embedded-signup/custom-flows/onboarding-business-app-users#limitations"
},
"SUBMIT_BUTTON": "Connect with WhatsApp Business",
"AUTH_PROCESSING": "Authenticating with Meta",
"WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...",
@@ -603,21 +598,6 @@
"WHATSAPP_SECTION_UPDATE_TITLE": "Update API Key",
"WHATSAPP_SECTION_UPDATE_PLACEHOLDER": "Enter the new API Key here",
"WHATSAPP_SECTION_UPDATE_BUTTON": "Обновяване",
"WHATSAPP_EMBEDDED_SIGNUP_TITLE": "WhatsApp Embedded Signup",
"WHATSAPP_EMBEDDED_SIGNUP_SUBHEADER": "This inbox is connected through WhatsApp embedded signup.",
"WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION": "You can reconfigure this inbox to update your WhatsApp Business settings.",
"WHATSAPP_RECONFIGURE_BUTTON": "Reconfigure",
"WHATSAPP_CONNECT_TITLE": "Connect to WhatsApp Business",
"WHATSAPP_CONNECT_SUBHEADER": "Upgrade to WhatsApp embedded signup for easier management.",
"WHATSAPP_CONNECT_DESCRIPTION": "Connect this inbox to WhatsApp Business for enhanced features and easier management.",
"WHATSAPP_CONNECT_BUTTON": "Connect",
"WHATSAPP_CONNECT_SUCCESS": "Successfully connected to WhatsApp Business!",
"WHATSAPP_CONNECT_ERROR": "Failed to connect to WhatsApp Business. Please try again.",
"WHATSAPP_RECONFIGURE_SUCCESS": "Successfully reconfigured WhatsApp Business!",
"WHATSAPP_RECONFIGURE_ERROR": "Failed to reconfigure WhatsApp Business. Please try again.",
"WHATSAPP_APP_ID_MISSING": "WhatsApp App ID is not configured. Please contact your administrator.",
"WHATSAPP_CONFIG_ID_MISSING": "WhatsApp Configuration ID is not configured. Please contact your administrator.",
"WHATSAPP_LOGIN_CANCELLED": "WhatsApp login was cancelled. Please try again.",
"WHATSAPP_WEBHOOK_TITLE": "Webhook Verification Token",
"WHATSAPP_WEBHOOK_SUBHEADER": "This token is used to verify the authenticity of the webhook endpoint.",
"WHATSAPP_TEMPLATES_SYNC_TITLE": "Sync Templates",
@@ -472,8 +472,7 @@
"FEATURES": {
"TITLE": "Features",
"ALLOW_CONVERSATION_FAQS": "Generate FAQs from resolved conversations",
"ALLOW_MEMORIES": "Capture key details as memories from customer interactions.",
"ALLOW_CITATIONS": "Include source citations in responses"
"ALLOW_MEMORIES": "Capture key details as memories from customer interactions."
}
},
"EDIT": {
@@ -558,7 +557,6 @@
"SEARCH_PLACEHOLDER": "Search..."
},
"EMPTY_MESSAGE": "No guardrails found. Create or add examples to begin.",
"SEARCH_EMPTY_MESSAGE": "No guardrails found for this search.",
"API": {
"ADD": {
"SUCCESS": "Guardrails added successfully",
@@ -606,7 +604,6 @@
"SEARCH_PLACEHOLDER": "Search..."
},
"EMPTY_MESSAGE": "No response guidelines found. Create or add examples to begin.",
"SEARCH_EMPTY_MESSAGE": "No response guidelines found for this search.",
"API": {
"ADD": {
"SUCCESS": "Response Guidelines added successfully",
@@ -621,73 +618,6 @@
"ERROR": "There was an error deleting response guidelines, please try again."
}
}
},
"SCENARIOS": {
"TITLE": "Scenarios",
"DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”",
"BREADCRUMB": {
"TITLE": "Scenarios"
},
"BULK_ACTION": {
"SELECTED": "{count} item selected | {count} items selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"BULK_DELETE_BUTTON": "Изтрий"
},
"ADD": {
"SUGGESTED": {
"TITLE": "Example scenarios",
"ADD": "Add all",
"ADD_SINGLE": "Add this",
"TOOLS_USED": "Tools used :"
},
"NEW": {
"CREATE": "Add a scenario",
"TITLE": "Create a scenario",
"FORM": {
"TITLE": {
"LABEL": "Title",
"PLACEHOLDER": "Enter a name for the scenario",
"ERROR": "Scenario name is required"
},
"DESCRIPTION": {
"LABEL": "Описание",
"PLACEHOLDER": "Describe how and where this scenario will be used",
"ERROR": "Scenario description is required"
},
"INSTRUCTION": {
"LABEL": "How to handle",
"PLACEHOLDER": "Describe how and where this scenario will be handled",
"ERROR": "Scenario content is required"
},
"CREATE": "Create",
"CANCEL": "Отмени"
}
}
},
"UPDATE": {
"CANCEL": "Отмени",
"UPDATE": "Update changes"
},
"LIST": {
"SEARCH_PLACEHOLDER": "Search..."
},
"EMPTY_MESSAGE": "No scenarios found. Create or add examples to begin.",
"SEARCH_EMPTY_MESSAGE": "No scenarios found for this search.",
"API": {
"ADD": {
"SUCCESS": "Scenarios added successfully",
"ERROR": "There was an error adding scenarios, please try again."
},
"UPDATE": {
"SUCCESS": "Scenarios updated successfully",
"ERROR": "There was an error updating scenarios, please try again."
},
"DELETE": {
"SUCCESS": "Scenarios deleted successfully",
"ERROR": "There was an error deleting scenarios, please try again."
}
}
}
},
"DOCUMENTS": {
@@ -1,46 +1,29 @@
{
"WHATSAPP_TEMPLATES": {
"MODAL": {
"TITLE": "Whatsapp Templates",
"SUBTITLE": "Select the whatsapp template you want to send",
"TEMPLATE_SELECTED_SUBTITLE": "Configure template: {templateName}"
},
"PICKER": {
"SEARCH_PLACEHOLDER": "Search Templates",
"NO_TEMPLATES_FOUND": "No templates found for",
"HEADER": "Header",
"BODY": "Body",
"FOOTER": "Footer",
"BUTTONS": "Buttons",
"CATEGORY": "Category",
"MEDIA_CONTENT": "Media Content",
"MEDIA_CONTENT_FALLBACK": "media content",
"NO_TEMPLATES_AVAILABLE": "No WhatsApp templates available. Click refresh to sync templates from WhatsApp.",
"REFRESH_BUTTON": "Refresh templates",
"REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
"REFRESH_ERROR": "Failed to refresh templates. Please try again.",
"LABELS": {
"LANGUAGE": "Language",
"TEMPLATE_BODY": "Template Body",
"CATEGORY": "Category"
}
},
"PARSER": {
"VARIABLES_LABEL": "Variables",
"LANGUAGE": "Language",
"CATEGORY": "Category",
"VARIABLE_PLACEHOLDER": "Enter {variable} value",
"GO_BACK_LABEL": "Go Back",
"SEND_MESSAGE_LABEL": "Send Message",
"FORM_ERROR_MESSAGE": "Please fill all variables before sending",
"MEDIA_HEADER_LABEL": "{type} Header",
"OTP_CODE": "Enter 4-8 digit OTP",
"EXPIRY_MINUTES": "Enter expiry minutes",
"BUTTON_PARAMETERS": "Button Parameters",
"BUTTON_LABEL": "Button {index}",
"COUPON_CODE": "Enter coupon code (max 15 chars)",
"MEDIA_URL_LABEL": "Enter {type} URL",
"BUTTON_PARAMETER": "Enter button parameter"
"WHATSAPP_TEMPLATES": {
"MODAL": {
"TITLE": "Whatsapp Templates",
"SUBTITLE": "Select the whatsapp template you want to send",
"TEMPLATE_SELECTED_SUBTITLE": "Process {templateName}"
},
"PICKER": {
"SEARCH_PLACEHOLDER": "Search Templates",
"NO_TEMPLATES_FOUND": "No templates found for",
"NO_TEMPLATES_AVAILABLE": "No WhatsApp templates available. Click refresh to sync templates from WhatsApp.",
"REFRESH_BUTTON": "Refresh templates",
"REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
"REFRESH_ERROR": "Failed to refresh templates. Please try again.",
"LABELS": {
"LANGUAGE": "Language",
"TEMPLATE_BODY": "Template Body",
"CATEGORY": "Category"
}
},
"PARSER": {
"VARIABLES_LABEL": "Variables",
"VARIABLE_PLACEHOLDER": "Enter {variable} value",
"GO_BACK_LABEL": "Go Back",
"SEND_MESSAGE_LABEL": "Send Message",
"FORM_ERROR_MESSAGE": "Please fill all variables before sending"
}
}
}
}

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