Compare commits
78
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
661f3065fc | ||
|
|
11fb464501 | ||
|
|
02a4fc9e54 | ||
|
|
43395be4d4 | ||
|
|
7b237372b0 | ||
|
|
aba68158bb | ||
|
|
883d80b144 | ||
|
|
43367d57a8 | ||
|
|
250558c2f6 | ||
|
|
a35c3e4c06 | ||
|
|
344e8d5016 | ||
|
|
3e27e28848 | ||
|
|
38af08534c | ||
|
|
ee1ea9576b | ||
|
|
26ea87a6cb | ||
|
|
7e8fe78ecd | ||
|
|
f3176afc1c | ||
|
|
c4ba925ae7 | ||
|
|
1f0b56b96e | ||
|
|
e7177364d4 | ||
|
|
f726dc2419 | ||
|
|
5891fd6f49 | ||
|
|
b9864fe1f6 | ||
|
|
5f97a2fd7b | ||
|
|
74fc9c0321 | ||
|
|
9898ccee9e | ||
|
|
eabdfc8168 | ||
|
|
9bd5f15450 | ||
|
|
aea1585fa3 | ||
|
|
254d5dcf9a | ||
|
|
b4c4f328b2 | ||
|
|
a509ef826a | ||
|
|
2180edc14a | ||
|
|
de2a35b617 | ||
|
|
40577e6a50 | ||
|
|
368d7c4608 | ||
|
|
f1f1ce644c | ||
|
|
38f16ba677 | ||
|
|
e7b01d80b3 | ||
|
|
ec9a82a017 | ||
|
|
cdd3b73fc9 | ||
|
|
610495123e | ||
|
|
cb65d615ea | ||
|
|
d8da1f5bf3 | ||
|
|
6829328182 | ||
|
|
6cc69f444b | ||
|
|
f89ed56258 | ||
|
|
7c5bb343c6 | ||
|
|
170ea7691f | ||
|
|
606adffeeb | ||
|
|
e9c1c61fe4 | ||
|
|
78ebdbbbd8 | ||
|
|
978f4c431a | ||
|
|
4b2ebb8877 | ||
|
|
c4c1f3eb63 | ||
|
|
829142c808 | ||
|
|
3a71829b46 | ||
|
|
0974aea300 | ||
|
|
9fb0dfa4a7 | ||
|
|
8bbb8ba5a4 | ||
|
|
a8aefa0c73 | ||
|
|
b536f35fa7 | ||
|
|
0d721bc898 | ||
|
|
c29a08f0ca | ||
|
|
66cfef9298 | ||
|
|
109aaa2341 | ||
|
|
ecff66146a | ||
|
|
21366e1c3b | ||
|
|
406a470c81 | ||
|
|
6c6aaf573c | ||
|
|
487209574c | ||
|
|
7f1671c083 | ||
|
|
b00261d7c2 | ||
|
|
fcb91ab88a | ||
|
|
59f7c8aa55 | ||
|
|
cd2c58726f | ||
|
|
03d0688cc2 | ||
|
|
b75ea7a762 |
@@ -70,6 +70,7 @@ jobs:
|
||||
spec/services/mfa/authentication_service_spec.rb \
|
||||
spec/requests/api/v1/profile/mfa_controller_spec.rb \
|
||||
spec/controllers/devise_overrides/sessions_controller_spec.rb \
|
||||
spec/models/application_record_external_credentials_encryption_spec.rb \
|
||||
--profile=10 \
|
||||
--format documentation
|
||||
env:
|
||||
|
||||
+2
-2
@@ -23,7 +23,7 @@ Metrics/MethodLength:
|
||||
- 'enterprise/lib/captain/agent.rb'
|
||||
|
||||
RSpec/ExampleLength:
|
||||
Max: 25
|
||||
Max: 50
|
||||
|
||||
Style/Documentation:
|
||||
Enabled: false
|
||||
@@ -336,4 +336,4 @@ FactoryBot/RedundantFactoryOption:
|
||||
Enabled: false
|
||||
|
||||
FactoryBot/FactoryAssociationWithStrategy:
|
||||
Enabled: false
|
||||
Enabled: false
|
||||
|
||||
+2
-2
@@ -644,7 +644,7 @@ GEM
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.0)
|
||||
rack (3.2.3)
|
||||
rack-attack (6.7.0)
|
||||
rack (>= 1.0, < 4)
|
||||
rack-contrib (2.5.0)
|
||||
@@ -935,7 +935,7 @@ GEM
|
||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||
unicode-emoji (4.0.4)
|
||||
uniform_notifier (1.17.0)
|
||||
uri (1.0.3)
|
||||
uri (1.0.4)
|
||||
uri_template (0.7.0)
|
||||
valid_email2 (5.2.6)
|
||||
activemodel (>= 3.2)
|
||||
|
||||
@@ -7,6 +7,7 @@ class Messages::MessageBuilder
|
||||
@private = params[:private] || false
|
||||
@conversation = conversation
|
||||
@user = user
|
||||
@account = conversation.account
|
||||
@message_type = params[:message_type] || 'outgoing'
|
||||
@attachments = params[:attachments]
|
||||
@automation_rule = content_attributes&.dig(:automation_rule_id)
|
||||
@@ -20,6 +21,9 @@ class Messages::MessageBuilder
|
||||
@message = @conversation.messages.build(message_params)
|
||||
process_attachments
|
||||
process_emails
|
||||
# When the message has no quoted content, it will just be rendered as a regular message
|
||||
# The frontend is equipped to handle this case
|
||||
process_email_content if @account.feature_enabled?(:quoted_email_reply)
|
||||
@message.save!
|
||||
@message
|
||||
end
|
||||
@@ -92,6 +96,14 @@ class Messages::MessageBuilder
|
||||
@message.content_attributes[:to_emails] = to_emails
|
||||
end
|
||||
|
||||
def process_email_content
|
||||
return unless should_process_email_content?
|
||||
|
||||
@message.content_attributes ||= {}
|
||||
email_attributes = build_email_attributes
|
||||
@message.content_attributes[:email] = email_attributes
|
||||
end
|
||||
|
||||
def process_email_string(email_string)
|
||||
return [] if email_string.blank?
|
||||
|
||||
@@ -153,4 +165,71 @@ class Messages::MessageBuilder
|
||||
source_id: @params[:source_id]
|
||||
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
|
||||
end
|
||||
|
||||
def email_inbox?
|
||||
@conversation.inbox&.inbox_type == 'Email'
|
||||
end
|
||||
|
||||
def should_process_email_content?
|
||||
email_inbox? && !@private && @message.content.present?
|
||||
end
|
||||
|
||||
def build_email_attributes
|
||||
email_attributes = ensure_indifferent_access(@message.content_attributes[:email] || {})
|
||||
normalized_content = normalize_email_body(@message.content)
|
||||
|
||||
# Use custom HTML content if provided, otherwise generate from message content
|
||||
email_attributes[:html_content] = if custom_email_content_provided?
|
||||
build_custom_html_content
|
||||
else
|
||||
build_html_content(normalized_content)
|
||||
end
|
||||
|
||||
email_attributes[:text_content] = build_text_content(normalized_content)
|
||||
email_attributes
|
||||
end
|
||||
|
||||
def build_html_content(normalized_content)
|
||||
html_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :html_content) || {})
|
||||
rendered_html = render_email_html(normalized_content)
|
||||
html_content[:full] = rendered_html
|
||||
html_content[:reply] = rendered_html
|
||||
html_content
|
||||
end
|
||||
|
||||
def build_text_content(normalized_content)
|
||||
text_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :text_content) || {})
|
||||
text_content[:full] = normalized_content
|
||||
text_content[:reply] = normalized_content
|
||||
text_content
|
||||
end
|
||||
|
||||
def ensure_indifferent_access(hash)
|
||||
return {} if hash.blank?
|
||||
|
||||
hash.respond_to?(:with_indifferent_access) ? hash.with_indifferent_access : hash
|
||||
end
|
||||
|
||||
def normalize_email_body(content)
|
||||
content.to_s.gsub("\r\n", "\n")
|
||||
end
|
||||
|
||||
def render_email_html(content)
|
||||
return '' if content.blank?
|
||||
|
||||
ChatwootMarkdownRenderer.new(content).render_message.to_s
|
||||
end
|
||||
|
||||
def custom_email_content_provided?
|
||||
@params[:email_html_content].present?
|
||||
end
|
||||
|
||||
def build_custom_html_content
|
||||
html_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :html_content) || {})
|
||||
|
||||
html_content[:full] = @params[:email_html_content]
|
||||
html_content[:reply] = @params[:email_html_content]
|
||||
|
||||
html_content
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,10 +10,28 @@ class V2::Reports::BaseSummaryBuilder
|
||||
|
||||
def load_data
|
||||
@conversations_count = fetch_conversations_count
|
||||
@resolved_count = fetch_resolved_count
|
||||
@avg_resolution_time = fetch_average_time('conversation_resolved')
|
||||
@avg_first_response_time = fetch_average_time('first_response')
|
||||
@avg_reply_time = fetch_average_time('reply_time')
|
||||
load_reporting_events_data
|
||||
end
|
||||
|
||||
def load_reporting_events_data
|
||||
# Extract the column name for indexing (e.g., 'conversations.team_id' -> 'team_id')
|
||||
index_key = group_by_key.to_s.split('.').last
|
||||
|
||||
results = reporting_events
|
||||
.select(
|
||||
"#{group_by_key} as #{index_key}",
|
||||
"COUNT(CASE WHEN name = 'conversation_resolved' THEN 1 END) as resolved_count",
|
||||
"AVG(CASE WHEN name = 'conversation_resolved' THEN #{average_value_key} END) as avg_resolution_time",
|
||||
"AVG(CASE WHEN name = 'first_response' THEN #{average_value_key} END) as avg_first_response_time",
|
||||
"AVG(CASE WHEN name = 'reply_time' THEN #{average_value_key} END) as avg_reply_time"
|
||||
)
|
||||
.group(group_by_key)
|
||||
.index_by { |record| record.public_send(index_key) }
|
||||
|
||||
@resolved_count = results.transform_values(&:resolved_count)
|
||||
@avg_resolution_time = results.transform_values(&:avg_resolution_time)
|
||||
@avg_first_response_time = results.transform_values(&:avg_first_response_time)
|
||||
@avg_reply_time = results.transform_values(&:avg_reply_time)
|
||||
end
|
||||
|
||||
def reporting_events
|
||||
@@ -24,14 +42,6 @@ class V2::Reports::BaseSummaryBuilder
|
||||
# Override this method
|
||||
end
|
||||
|
||||
def fetch_average_time(event_name)
|
||||
get_grouped_average(reporting_events.where(name: event_name))
|
||||
end
|
||||
|
||||
def fetch_resolved_count
|
||||
reporting_events.where(name: 'conversation_resolved').group(group_by_key).count
|
||||
end
|
||||
|
||||
def group_by_key
|
||||
# Override this method
|
||||
end
|
||||
@@ -40,10 +50,6 @@ class V2::Reports::BaseSummaryBuilder
|
||||
# Override this method
|
||||
end
|
||||
|
||||
def get_grouped_average(events)
|
||||
events.group(group_by_key).average(average_value_key)
|
||||
end
|
||||
|
||||
def average_value_key
|
||||
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
|
||||
end
|
||||
|
||||
@@ -13,10 +13,7 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
|
||||
def load_data
|
||||
@conversations_count = fetch_conversations_count
|
||||
@resolved_count = fetch_resolved_count
|
||||
@avg_resolution_time = fetch_average_time('conversation_resolved')
|
||||
@avg_first_response_time = fetch_average_time('first_response')
|
||||
@avg_reply_time = fetch_average_time('reply_time')
|
||||
load_reporting_events_data
|
||||
end
|
||||
|
||||
def fetch_conversations_count
|
||||
|
||||
@@ -17,8 +17,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
before_action :set_include_contact_inboxes, only: [:index, :active, :search, :filter, :show, :update]
|
||||
|
||||
def index
|
||||
@contacts_count = resolved_contacts.count
|
||||
@contacts = fetch_contacts(resolved_contacts)
|
||||
@contacts_count = @contacts.total_count
|
||||
end
|
||||
|
||||
def search
|
||||
@@ -29,8 +29,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
OR contacts.additional_attributes->>\'company_name\' ILIKE :search',
|
||||
search: "%#{params[:q].strip}%"
|
||||
)
|
||||
@contacts_count = contacts.count
|
||||
@contacts = fetch_contacts(contacts)
|
||||
@contacts_count = @contacts.total_count
|
||||
end
|
||||
|
||||
def import
|
||||
@@ -55,8 +55,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
def active
|
||||
contacts = Current.account.contacts.where(id: ::OnlineStatusTracker
|
||||
.get_available_contact_ids(Current.account.id))
|
||||
@contacts_count = contacts.count
|
||||
@contacts = fetch_contacts(contacts)
|
||||
@contacts_count = @contacts.total_count
|
||||
end
|
||||
|
||||
def show; end
|
||||
@@ -133,13 +133,14 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def fetch_contacts(contacts)
|
||||
contacts_with_avatar = filtrate(contacts)
|
||||
.includes([{ avatar_attachment: [:blob] }])
|
||||
.page(@current_page).per(RESULTS_PER_PAGE)
|
||||
# Build includes hash to avoid separate query when contact_inboxes are needed
|
||||
includes_hash = { avatar_attachment: [:blob] }
|
||||
includes_hash[:contact_inboxes] = { inbox: :channel } if @include_contact_inboxes
|
||||
|
||||
return contacts_with_avatar.includes([{ contact_inboxes: [:inbox] }]) if @include_contact_inboxes
|
||||
|
||||
contacts_with_avatar
|
||||
filtrate(contacts)
|
||||
.includes(includes_hash)
|
||||
.page(@current_page)
|
||||
.per(RESULTS_PER_PAGE)
|
||||
end
|
||||
|
||||
def build_contact_inbox
|
||||
|
||||
@@ -5,6 +5,6 @@ class Api::V1::Accounts::Conversations::BaseController < Api::V1::Accounts::Base
|
||||
|
||||
def conversation
|
||||
@conversation ||= Current.account.conversations.find_by!(display_id: params[:conversation_id])
|
||||
authorize @conversation.inbox, :show?
|
||||
authorize @conversation, :show?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -160,7 +160,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
|
||||
def conversation
|
||||
@conversation ||= Current.account.conversations.find_by!(display_id: params[:id])
|
||||
authorize @conversation.inbox, :show?
|
||||
authorize @conversation, :show?
|
||||
end
|
||||
|
||||
def inbox
|
||||
|
||||
@@ -4,7 +4,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_agent_bot, only: [:set_agent_bot]
|
||||
before_action :validate_limit, only: [:create]
|
||||
# we are already handling the authorization in fetch inbox
|
||||
before_action :check_authorization, except: [:show]
|
||||
before_action :check_authorization, except: [:show, :health]
|
||||
before_action :validate_whatsapp_cloud_channel, only: [:health]
|
||||
|
||||
def index
|
||||
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
|
||||
@@ -78,6 +79,14 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
render status: :internal_server_error, json: { error: e.message }
|
||||
end
|
||||
|
||||
def health
|
||||
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
|
||||
render json: health_data
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_inbox
|
||||
@@ -89,6 +98,12 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
|
||||
end
|
||||
|
||||
def validate_whatsapp_cloud_channel
|
||||
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
|
||||
|
||||
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
|
||||
end
|
||||
|
||||
def create_channel
|
||||
return unless allowed_channel_types.include?(permitted_params[:channel][:type])
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ class Api::V1::Accounts::Integrations::DyteController < Api::V1::Accounts::BaseC
|
||||
private
|
||||
|
||||
def authorize_request
|
||||
authorize @conversation.inbox, :show?
|
||||
authorize @conversation, :show?
|
||||
end
|
||||
|
||||
def render_response(response)
|
||||
|
||||
@@ -14,6 +14,7 @@ module AccessTokenAuthHelper
|
||||
ensure_access_token
|
||||
render_unauthorized('Invalid Access Token') && return if @access_token.blank?
|
||||
|
||||
# NOTE: This ensures that current_user is set and available for the rest of the controller actions
|
||||
@resource = @access_token.owner
|
||||
Current.user = @resource if allowed_current_user_type?(@resource)
|
||||
end
|
||||
|
||||
@@ -19,6 +19,19 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
||||
redirect_to login_page_url(email: encoded_email, sso_auth_token: @resource.generate_sso_auth_token)
|
||||
end
|
||||
|
||||
def sign_in_user_on_mobile
|
||||
@resource.skip_confirmation! if confirmable_enabled?
|
||||
|
||||
# once the resource is found and verified
|
||||
# we can just send them to the login page again with the SSO params
|
||||
# that will log them in
|
||||
encoded_email = ERB::Util.url_encode(@resource.email)
|
||||
params = { email: encoded_email, sso_auth_token: @resource.generate_sso_auth_token }.to_query
|
||||
|
||||
mobile_deep_link_base = GlobalConfigService.load('MOBILE_DEEP_LINK_BASE', 'chatwootapp')
|
||||
redirect_to "#{mobile_deep_link_base}://auth/saml?#{params}", allow_other_host: true
|
||||
end
|
||||
|
||||
def sign_up_user
|
||||
return redirect_to login_page_url(error: 'no-account-found') unless account_signup_allowed?
|
||||
return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain?
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class CaptainCustomTools extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/custom_tools', { accountScoped: true });
|
||||
}
|
||||
|
||||
get({ page = 1, searchKey } = {}) {
|
||||
return axios.get(this.url, {
|
||||
params: { page, searchKey },
|
||||
});
|
||||
}
|
||||
|
||||
show(id) {
|
||||
return axios.get(`${this.url}/${id}`);
|
||||
}
|
||||
|
||||
create(data = {}) {
|
||||
return axios.post(this.url, {
|
||||
custom_tool: data,
|
||||
});
|
||||
}
|
||||
|
||||
update(id, data = {}) {
|
||||
return axios.put(`${this.url}/${id}`, {
|
||||
custom_tool: data,
|
||||
});
|
||||
}
|
||||
|
||||
delete(id) {
|
||||
return axios.delete(`${this.url}/${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainCustomTools();
|
||||
@@ -0,0 +1,16 @@
|
||||
import axios from 'axios';
|
||||
import ApiClient from './ApiClient';
|
||||
import { CHANGELOG_API_URL } from 'shared/constants/links';
|
||||
|
||||
class ChangelogApi extends ApiClient {
|
||||
constructor() {
|
||||
super('changelog', { apiVersion: 'v1' });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
fetchFromHub() {
|
||||
return axios.get(CHANGELOG_API_URL);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ChangelogApi();
|
||||
@@ -0,0 +1,14 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class InboxHealthAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('inboxes', { accountScoped: true });
|
||||
}
|
||||
|
||||
getHealthStatus(inboxId) {
|
||||
return axios.get(`${this.url}/${inboxId}/health`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new InboxHealthAPI();
|
||||
+14
-3
@@ -148,10 +148,21 @@ const isAnyDropdownActive = computed(() => {
|
||||
|
||||
const handleContactSearch = value => {
|
||||
showContactsDropdown.value = true;
|
||||
emit('searchContacts', {
|
||||
keys: ['email', 'phone_number', 'name'],
|
||||
query: value,
|
||||
const query = typeof value === 'string' ? value.trim() : '';
|
||||
const hasAlphabet = Array.from(query).some(char => {
|
||||
const lower = char.toLowerCase();
|
||||
const upper = char.toUpperCase();
|
||||
return lower !== upper;
|
||||
});
|
||||
const isEmailLike = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(query);
|
||||
|
||||
const keys = ['email', 'phone_number', 'name'].filter(key => {
|
||||
if (key === 'phone_number' && hasAlphabet) return false;
|
||||
if (key === 'name' && isEmailLike) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
emit('searchContacts', { keys, query: value });
|
||||
};
|
||||
|
||||
const handleDropdownUpdate = (type, value) => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.v
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
@@ -59,6 +60,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showActions: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action', 'navigate', 'select', 'hover']);
|
||||
@@ -159,73 +164,116 @@ const handleDocumentableClick = () => {
|
||||
<span class="text-n-slate-11 text-sm line-clamp-5">
|
||||
{{ answer }}
|
||||
</span>
|
||||
<div v-if="!compact" class="items-center justify-between hidden lg:flex">
|
||||
<div class="inline-flex items-center">
|
||||
<span
|
||||
class="text-sm shrink-0 truncate text-n-slate-11 inline-flex items-center gap-1"
|
||||
>
|
||||
<i class="i-woot-captain" />
|
||||
{{ assistant?.name || '' }}
|
||||
</span>
|
||||
<div
|
||||
v-if="documentable"
|
||||
class="shrink-0 text-sm text-n-slate-11 inline-flex line-clamp-1 gap-1 ml-3"
|
||||
>
|
||||
<div
|
||||
v-if="!compact"
|
||||
class="flex items-start justify-between flex-col-reverse md:flex-row gap-3"
|
||||
>
|
||||
<Policy v-if="showActions" :permissions="['administrator']">
|
||||
<div class="flex items-center gap-2 sm:gap-5 w-full">
|
||||
<Button
|
||||
v-if="status === 'pending'"
|
||||
:label="$t('CAPTAIN.RESPONSES.OPTIONS.APPROVE')"
|
||||
icon="i-lucide-circle-check-big"
|
||||
sm
|
||||
link
|
||||
class="hover:!no-underline"
|
||||
@click="
|
||||
handleAssistantAction({ action: 'approve', value: 'approve' })
|
||||
"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.OPTIONS.EDIT_RESPONSE')"
|
||||
icon="i-lucide-pencil-line"
|
||||
sm
|
||||
slate
|
||||
link
|
||||
class="hover:!no-underline"
|
||||
@click="
|
||||
handleAssistantAction({
|
||||
action: 'edit',
|
||||
value: 'edit',
|
||||
})
|
||||
"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.OPTIONS.DELETE_RESPONSE')"
|
||||
icon="i-lucide-trash"
|
||||
sm
|
||||
ruby
|
||||
link
|
||||
class="hover:!no-underline"
|
||||
@click="
|
||||
handleAssistantAction({ action: 'delete', value: 'delete' })
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</Policy>
|
||||
<div
|
||||
class="flex items-center gap-3"
|
||||
:class="{ 'justify-between w-full': !showActions }"
|
||||
>
|
||||
<div class="inline-flex items-center gap-3 min-w-0">
|
||||
<span
|
||||
v-if="documentable.type === 'Captain::Document'"
|
||||
class="inline-flex items-center gap-1 truncate over"
|
||||
v-if="status === 'approved'"
|
||||
class="text-sm shrink-0 truncate text-n-slate-11 inline-flex items-center gap-1"
|
||||
>
|
||||
<i class="i-ph-files-light text-base" />
|
||||
<span class="max-w-96 truncate" :title="documentable.name">
|
||||
<Icon icon="i-woot-captain" class="size-3.5" />
|
||||
{{ assistant?.name || '' }}
|
||||
</span>
|
||||
<div
|
||||
v-if="documentable"
|
||||
class="text-sm text-n-slate-11 grid grid-cols-[auto_1fr] items-center gap-1 min-w-0"
|
||||
>
|
||||
<Icon
|
||||
v-if="documentable.type === 'Captain::Document'"
|
||||
icon="i-ph-files-light"
|
||||
class="size-3.5"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="documentable.type === 'User'"
|
||||
icon="i-ph-user-circle-plus"
|
||||
class="size-3.5"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="documentable.type === 'Conversation'"
|
||||
icon="i-ph-chat-circle-dots"
|
||||
class="size-3.5"
|
||||
/>
|
||||
<span
|
||||
v-if="documentable.type === 'Captain::Document'"
|
||||
class="truncate"
|
||||
:title="documentable.name"
|
||||
>
|
||||
{{ documentable.name }}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="documentable.type === 'User'"
|
||||
class="inline-flex items-center gap-1"
|
||||
>
|
||||
<i class="i-ph-user-circle-plus text-base" />
|
||||
<span
|
||||
class="max-w-96 truncate"
|
||||
v-else-if="documentable.type === 'User'"
|
||||
class="truncate"
|
||||
:title="documentable.available_name"
|
||||
>
|
||||
{{ documentable.available_name }}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-else-if="documentable.type === 'Conversation'"
|
||||
class="inline-flex items-center gap-1 group cursor-pointer"
|
||||
role="button"
|
||||
@click="handleDocumentableClick"
|
||||
>
|
||||
<i class="i-ph-chat-circle-dots text-base" />
|
||||
<span class="group-hover:underline">
|
||||
<span
|
||||
v-else-if="documentable.type === 'Conversation'"
|
||||
class="hover:underline truncate cursor-pointer"
|
||||
role="button"
|
||||
@click="handleDocumentableClick"
|
||||
>
|
||||
{{
|
||||
t(`CAPTAIN.RESPONSES.DOCUMENTABLE.CONVERSATION`, {
|
||||
id: documentable.display_id,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
<span v-else />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="status !== 'approved'"
|
||||
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1 ml-3"
|
||||
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1"
|
||||
>
|
||||
<i
|
||||
class="i-ph-stack text-base"
|
||||
:title="t('CAPTAIN.RESPONSES.STATUS.TITLE')"
|
||||
/>
|
||||
{{ t(`CAPTAIN.RESPONSES.STATUS.${status.toUpperCase()}`) }}
|
||||
<Icon icon="i-ph-calendar-dot" class="size-3.5" />
|
||||
{{ timestamp }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1 ml-3"
|
||||
>
|
||||
<i class="i-ph-calendar-dot" />
|
||||
{{ timestamp }}
|
||||
</div>
|
||||
</div>
|
||||
</CardLayout>
|
||||
</template>
|
||||
|
||||
@@ -10,6 +10,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
translationKey: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
entity: {
|
||||
type: Object,
|
||||
required: true,
|
||||
@@ -25,7 +29,9 @@ const emit = defineEmits(['deleteSuccess']);
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const deleteDialogRef = ref(null);
|
||||
const i18nKey = computed(() => props.type.toUpperCase());
|
||||
const i18nKey = computed(() => {
|
||||
return props.translationKey || props.type.toUpperCase();
|
||||
});
|
||||
|
||||
const deleteEntity = async payload => {
|
||||
if (!payload) return;
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
<script setup>
|
||||
import { defineModel, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
|
||||
const props = defineProps({
|
||||
authType: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: value => ['none', 'bearer', 'basic', 'api_key'].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const authConfig = defineModel('authConfig', {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.authType,
|
||||
() => {
|
||||
authConfig.value = {};
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Input
|
||||
v-if="authType === 'bearer'"
|
||||
v-model="authConfig.token"
|
||||
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.BEARER_TOKEN')"
|
||||
:placeholder="
|
||||
t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.BEARER_TOKEN_PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
<template v-else-if="authType === 'basic'">
|
||||
<Input
|
||||
v-model="authConfig.username"
|
||||
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.USERNAME')"
|
||||
:placeholder="
|
||||
t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.USERNAME_PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
<Input
|
||||
v-model="authConfig.password"
|
||||
type="password"
|
||||
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.PASSWORD')"
|
||||
:placeholder="
|
||||
t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.PASSWORD_PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="authType === 'api_key'">
|
||||
<Input
|
||||
v-model="authConfig.name"
|
||||
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.API_KEY')"
|
||||
:placeholder="
|
||||
t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.API_KEY_PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
<Input
|
||||
v-model="authConfig.key"
|
||||
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.API_VALUE')"
|
||||
:placeholder="
|
||||
t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.API_VALUE_PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import CustomToolForm from './CustomToolForm.vue';
|
||||
|
||||
const props = defineProps({
|
||||
selectedTool: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'create',
|
||||
validator: value => ['create', 'edit'].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
|
||||
const updateTool = toolDetails =>
|
||||
store.dispatch('captainCustomTools/update', {
|
||||
id: props.selectedTool.id,
|
||||
...toolDetails,
|
||||
});
|
||||
|
||||
const i18nKey = computed(
|
||||
() => `CAPTAIN.CUSTOM_TOOLS.${props.type.toUpperCase()}`
|
||||
);
|
||||
|
||||
const createTool = toolDetails =>
|
||||
store.dispatch('captainCustomTools/create', toolDetails);
|
||||
|
||||
const handleSubmit = async updatedTool => {
|
||||
try {
|
||||
if (props.type === 'edit') {
|
||||
await updateTool(updatedTool);
|
||||
} else {
|
||||
await createTool(updatedTool);
|
||||
}
|
||||
useAlert(t(`${i18nKey.value}.SUCCESS_MESSAGE`));
|
||||
dialogRef.value.close();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
parseAPIErrorResponse(error) || t(`${i18nKey.value}.ERROR_MESSAGE`);
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
dialogRef.value.close();
|
||||
};
|
||||
|
||||
defineExpose({ dialogRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
width="2xl"
|
||||
:title="$t(`${i18nKey}.TITLE`)"
|
||||
:description="$t('CAPTAIN.CUSTOM_TOOLS.FORM_DESCRIPTION')"
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<CustomToolForm
|
||||
:mode="type"
|
||||
:tool="selectedTool"
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
<template #footer />
|
||||
</Dialog>
|
||||
</template>
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
authType: {
|
||||
type: String,
|
||||
default: 'none',
|
||||
},
|
||||
updatedAt: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
createdAt: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||
|
||||
const menuItems = computed(() => [
|
||||
{
|
||||
label: t('CAPTAIN.CUSTOM_TOOLS.OPTIONS.EDIT_TOOL'),
|
||||
value: 'edit',
|
||||
action: 'edit',
|
||||
icon: 'i-lucide-pencil-line',
|
||||
},
|
||||
{
|
||||
label: t('CAPTAIN.CUSTOM_TOOLS.OPTIONS.DELETE_TOOL'),
|
||||
value: 'delete',
|
||||
action: 'delete',
|
||||
icon: 'i-lucide-trash',
|
||||
},
|
||||
]);
|
||||
|
||||
const timestamp = computed(() =>
|
||||
dynamicTime(props.updatedAt || props.createdAt)
|
||||
);
|
||||
|
||||
const handleAction = ({ action, value }) => {
|
||||
toggleDropdown(false);
|
||||
emit('action', { action, value, id: props.id });
|
||||
};
|
||||
|
||||
const authTypeLabel = computed(() => {
|
||||
return t(
|
||||
`CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.${props.authType.toUpperCase()}`
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout class="relative">
|
||||
<div class="flex relative justify-between w-full gap-1">
|
||||
<span class="text-base text-n-slate-12 line-clamp-1 font-medium">
|
||||
{{ title }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<Policy
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
:permissions="['administrator']"
|
||||
class="relative flex items-center group"
|
||||
>
|
||||
<Button
|
||||
icon="i-lucide-ellipsis-vertical"
|
||||
color="slate"
|
||||
size="xs"
|
||||
class="rounded-md group-hover:bg-n-alpha-2"
|
||||
@click="toggleDropdown()"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="showActionsDropdown"
|
||||
:menu-items="menuItems"
|
||||
class="mt-1 ltr:right-0 rtl:right-0 top-full"
|
||||
@action="handleAction($event)"
|
||||
/>
|
||||
</Policy>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between w-full gap-4">
|
||||
<div class="flex items-center gap-3 flex-1">
|
||||
<span
|
||||
v-if="description"
|
||||
class="text-sm truncate text-n-slate-11 flex-1"
|
||||
>
|
||||
{{ description }}
|
||||
</span>
|
||||
<span
|
||||
v-if="authType !== 'none'"
|
||||
class="text-sm shrink-0 text-n-slate-11 inline-flex items-center gap-1"
|
||||
>
|
||||
<i class="i-lucide-lock text-base" />
|
||||
{{ authTypeLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm text-n-slate-11 line-clamp-1 shrink-0">
|
||||
{{ timestamp }}
|
||||
</span>
|
||||
</div>
|
||||
</CardLayout>
|
||||
</template>
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
<script setup>
|
||||
import { reactive, computed, useTemplateRef, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import ParamRow from './ParamRow.vue';
|
||||
import AuthConfig from './AuthConfig.vue';
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'create',
|
||||
validator: value => ['create', 'edit'].includes(value),
|
||||
},
|
||||
tool: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['submit', 'cancel']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const formState = {
|
||||
uiFlags: useMapGetter('captainCustomTools/getUIFlags'),
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
title: '',
|
||||
description: '',
|
||||
endpoint_url: '',
|
||||
http_method: 'GET',
|
||||
request_template: '',
|
||||
response_template: '',
|
||||
auth_type: 'none',
|
||||
auth_config: {},
|
||||
param_schema: [],
|
||||
};
|
||||
|
||||
const state = reactive({ ...initialState });
|
||||
|
||||
// Populate form when in edit mode
|
||||
watch(
|
||||
() => props.tool,
|
||||
newTool => {
|
||||
if (props.mode === 'edit' && newTool && newTool.id) {
|
||||
state.title = newTool.title || '';
|
||||
state.description = newTool.description || '';
|
||||
state.endpoint_url = newTool.endpoint_url || '';
|
||||
state.http_method = newTool.http_method || 'GET';
|
||||
state.request_template = newTool.request_template || '';
|
||||
state.response_template = newTool.response_template || '';
|
||||
state.auth_type = newTool.auth_type || 'none';
|
||||
state.auth_config = newTool.auth_config || {};
|
||||
state.param_schema = newTool.param_schema || [];
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const DEFAULT_PARAM = {
|
||||
name: '',
|
||||
type: 'string',
|
||||
description: '',
|
||||
required: false,
|
||||
};
|
||||
|
||||
const validationRules = {
|
||||
title: { required },
|
||||
endpoint_url: { required },
|
||||
http_method: { required },
|
||||
auth_type: { required },
|
||||
};
|
||||
|
||||
const httpMethodOptions = computed(() => [
|
||||
{ value: 'GET', label: 'GET' },
|
||||
{ value: 'POST', label: 'POST' },
|
||||
]);
|
||||
|
||||
const authTypeOptions = computed(() => [
|
||||
{ value: 'none', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.NONE') },
|
||||
{ value: 'bearer', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.BEARER') },
|
||||
{ value: 'basic', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.BASIC') },
|
||||
{
|
||||
value: 'api_key',
|
||||
label: t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.API_KEY'),
|
||||
},
|
||||
]);
|
||||
|
||||
const v$ = useVuelidate(validationRules, state);
|
||||
|
||||
const isLoading = computed(() =>
|
||||
props.mode === 'edit'
|
||||
? formState.uiFlags.value.updatingItem
|
||||
: formState.uiFlags.value.creatingItem
|
||||
);
|
||||
|
||||
const getErrorMessage = (field, errorKey) => {
|
||||
return v$.value[field].$error
|
||||
? t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.ERROR`)
|
||||
: '';
|
||||
};
|
||||
|
||||
const formErrors = computed(() => ({
|
||||
title: getErrorMessage('title', 'TITLE'),
|
||||
endpoint_url: getErrorMessage('endpoint_url', 'ENDPOINT_URL'),
|
||||
}));
|
||||
|
||||
const paramsRef = useTemplateRef('paramsRef');
|
||||
|
||||
const isParamsValid = () => {
|
||||
if (!paramsRef.value || paramsRef.value.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return paramsRef.value.every(param => param.validate());
|
||||
};
|
||||
|
||||
const removeParam = index => {
|
||||
state.param_schema.splice(index, 1);
|
||||
};
|
||||
|
||||
const addParam = () => {
|
||||
state.param_schema.push({ ...DEFAULT_PARAM });
|
||||
};
|
||||
|
||||
const handleCancel = () => emit('cancel');
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const isFormValid = await v$.value.$validate();
|
||||
if (!isFormValid || !isParamsValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit('submit', state);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
class="flex flex-col px-4 -mx-4 gap-4 max-h-[calc(100vh-200px)] overflow-y-scroll"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<Input
|
||||
v-model="state.title"
|
||||
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.TITLE.LABEL')"
|
||||
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.TITLE.PLACEHOLDER')"
|
||||
:message="formErrors.title"
|
||||
:message-type="formErrors.title ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
v-model="state.description"
|
||||
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.DESCRIPTION.LABEL')"
|
||||
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.DESCRIPTION.PLACEHOLDER')"
|
||||
:rows="2"
|
||||
/>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-col gap-1 w-28">
|
||||
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.HTTP_METHOD.LABEL') }}
|
||||
</label>
|
||||
<ComboBox
|
||||
v-model="state.http_method"
|
||||
:options="httpMethodOptions"
|
||||
class="[&>div>button]:bg-n-alpha-black2 [&_li]:font-mono [&_button]:font-mono [&>div>button]:outline-offset-[-1px]"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
v-model="state.endpoint_url"
|
||||
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.ENDPOINT_URL.LABEL')"
|
||||
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.ENDPOINT_URL.PLACEHOLDER')"
|
||||
:message="formErrors.endpoint_url"
|
||||
:message-type="formErrors.endpoint_url ? 'error' : 'info'"
|
||||
class="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPE.LABEL') }}
|
||||
</label>
|
||||
<ComboBox
|
||||
v-model="state.auth_type"
|
||||
:options="authTypeOptions"
|
||||
class="[&>div>button]:bg-n-alpha-black2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AuthConfig
|
||||
v-model:auth-config="state.auth_config"
|
||||
:auth-type="state.auth_type"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAMETERS.LABEL') }}
|
||||
</label>
|
||||
<p class="text-xs text-n-slate-11 -mt-1">
|
||||
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAMETERS.HELP_TEXT') }}
|
||||
</p>
|
||||
<ul v-if="state.param_schema.length > 0" class="grid gap-2 list-none">
|
||||
<ParamRow
|
||||
v-for="(param, index) in state.param_schema"
|
||||
:key="index"
|
||||
ref="paramsRef"
|
||||
v-model:name="param.name"
|
||||
v-model:type="param.type"
|
||||
v-model:description="param.description"
|
||||
v-model:required="param.required"
|
||||
@remove="removeParam(index)"
|
||||
/>
|
||||
</ul>
|
||||
<Button
|
||||
type="button"
|
||||
sm
|
||||
ghost
|
||||
blue
|
||||
icon="i-lucide-plus"
|
||||
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.ADD_PARAMETER')"
|
||||
@click="addParam"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextArea
|
||||
v-if="state.http_method === 'POST'"
|
||||
v-model="state.request_template"
|
||||
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.REQUEST_TEMPLATE.LABEL')"
|
||||
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.REQUEST_TEMPLATE.PLACEHOLDER')"
|
||||
:rows="4"
|
||||
class="[&_textarea]:font-mono"
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
v-model="state.response_template"
|
||||
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.RESPONSE_TEMPLATE.LABEL')"
|
||||
:placeholder="
|
||||
t('CAPTAIN.CUSTOM_TOOLS.FORM.RESPONSE_TEMPLATE.PLACEHOLDER')
|
||||
"
|
||||
:rows="4"
|
||||
class="[&_textarea]:font-mono"
|
||||
/>
|
||||
|
||||
<div class="flex gap-3 justify-between items-center w-full">
|
||||
<Button
|
||||
type="button"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:label="t('CAPTAIN.FORM.CANCEL')"
|
||||
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
:label="
|
||||
t(mode === 'edit' ? 'CAPTAIN.FORM.EDIT' : 'CAPTAIN.FORM.CREATE')
|
||||
"
|
||||
class="w-full"
|
||||
:is-loading="isLoading"
|
||||
:disabled="isLoading"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
<script setup>
|
||||
import { computed, defineModel, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
|
||||
const emit = defineEmits(['remove']);
|
||||
const { t } = useI18n();
|
||||
const showErrors = ref(false);
|
||||
|
||||
const name = defineModel('name', {
|
||||
type: String,
|
||||
required: true,
|
||||
});
|
||||
|
||||
const type = defineModel('type', {
|
||||
type: String,
|
||||
required: true,
|
||||
});
|
||||
|
||||
const description = defineModel('description', {
|
||||
type: String,
|
||||
default: '',
|
||||
});
|
||||
|
||||
const required = defineModel('required', {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
});
|
||||
|
||||
const paramTypeOptions = computed(() => [
|
||||
{ value: 'string', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.STRING') },
|
||||
{ value: 'number', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.NUMBER') },
|
||||
{
|
||||
value: 'boolean',
|
||||
label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.BOOLEAN'),
|
||||
},
|
||||
{ value: 'array', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.ARRAY') },
|
||||
{ value: 'object', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.OBJECT') },
|
||||
]);
|
||||
|
||||
const validationError = computed(() => {
|
||||
if (!name.value || name.value.trim() === '') {
|
||||
return 'PARAM_NAME_REQUIRED';
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
watch([name, type, description, required], () => {
|
||||
showErrors.value = false;
|
||||
});
|
||||
|
||||
const validate = () => {
|
||||
showErrors.value = true;
|
||||
return !validationError.value;
|
||||
};
|
||||
|
||||
defineExpose({ validate });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li class="list-none">
|
||||
<div
|
||||
class="flex items-start gap-2 p-3 rounded-lg border border-n-weak bg-n-alpha-2"
|
||||
:class="{
|
||||
'animate-wiggle border-n-ruby-9': showErrors && validationError,
|
||||
}"
|
||||
>
|
||||
<div class="flex flex-col flex-1 gap-3">
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<Input
|
||||
v-model="name"
|
||||
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_NAME.PLACEHOLDER')"
|
||||
class="col-span-2"
|
||||
/>
|
||||
<ComboBox
|
||||
v-model="type"
|
||||
:options="paramTypeOptions"
|
||||
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPE.PLACEHOLDER')"
|
||||
class="[&>div>button]:bg-n-alpha-black2"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
v-model="description"
|
||||
:placeholder="
|
||||
t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_DESCRIPTION.PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox v-model="required" />
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_REQUIRED.LABEL') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<Button
|
||||
solid
|
||||
slate
|
||||
icon="i-lucide-trash"
|
||||
class="flex-shrink-0"
|
||||
@click.stop="emit('remove')"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
v-if="showErrors && validationError"
|
||||
class="block mt-1 text-sm text-n-ruby-11"
|
||||
>
|
||||
{{ t(`CAPTAIN.CUSTOM_TOOLS.FORM.ERRORS.${validationError}`) }}
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
+3
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import AssistantCard from 'dashboard/components-next/captain/assistant/AssistantCard.vue';
|
||||
@@ -6,6 +7,7 @@ import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/Featur
|
||||
import { assistantsList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
|
||||
const onClick = () => {
|
||||
emit('click');
|
||||
@@ -20,6 +22,7 @@ const onClick = () => {
|
||||
fallback-thumbnail-dark="/assets/images/dashboard/captain/assistant-dark.svg"
|
||||
learn-more-url="https://chwt.app/captain-assistant"
|
||||
class="mb-8"
|
||||
:hide-actions="!isOnChatwootCloud"
|
||||
/>
|
||||
<EmptyStateLayout
|
||||
:title="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.TITLE')"
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
<script setup>
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
|
||||
const onClick = () => {
|
||||
emit('click');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EmptyStateLayout
|
||||
:title="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.TITLE')"
|
||||
:subtitle="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.SUBTITLE')"
|
||||
:action-perms="['administrator']"
|
||||
>
|
||||
<template #empty-state-item>
|
||||
<div class="min-h-[600px]" />
|
||||
</template>
|
||||
<template #actions>
|
||||
<Button
|
||||
:label="$t('CAPTAIN.CUSTOM_TOOLS.ADD_NEW')"
|
||||
icon="i-lucide-plus"
|
||||
@click="onClick"
|
||||
/>
|
||||
</template>
|
||||
</EmptyStateLayout>
|
||||
</template>
|
||||
+3
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue';
|
||||
@@ -6,6 +7,7 @@ import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/Featur
|
||||
import { documentsList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
|
||||
const onClick = () => {
|
||||
emit('click');
|
||||
@@ -19,6 +21,7 @@ const onClick = () => {
|
||||
fallback-thumbnail="/assets/images/dashboard/captain/document-light.svg"
|
||||
fallback-thumbnail-dark="/assets/images/dashboard/captain/document-dark.svg"
|
||||
learn-more-url="https://chwt.app/captain-document"
|
||||
:hide-actions="!isOnChatwootCloud"
|
||||
class="mb-8"
|
||||
/>
|
||||
<EmptyStateLayout
|
||||
|
||||
+3
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
|
||||
@@ -6,6 +7,7 @@ import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/Featur
|
||||
import { responsesList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
|
||||
const onClick = () => {
|
||||
emit('click');
|
||||
@@ -19,6 +21,7 @@ const onClick = () => {
|
||||
fallback-thumbnail="/assets/images/dashboard/captain/faqs-light.svg"
|
||||
fallback-thumbnail-dark="/assets/images/dashboard/captain/faqs-dark.svg"
|
||||
learn-more-url="https://chwt.app/captain-faq"
|
||||
:hide-actions="!isOnChatwootCloud"
|
||||
class="mb-8"
|
||||
/>
|
||||
<EmptyStateLayout
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import GroupedStackedChangelogCard from './GroupedStackedChangelogCard.vue';
|
||||
|
||||
const sampleCards = [
|
||||
{
|
||||
id: 'chatwoot-captain',
|
||||
title: 'Chatwoot Captain',
|
||||
meta_title: 'Chatwoot Captain',
|
||||
meta_description:
|
||||
'Watch how our latest feature can transform your workflow with powerful automation tools.',
|
||||
slug: 'chatwoot-captain',
|
||||
feature_image:
|
||||
'https://www.chatwoot.com/images/captain/captain_thumbnail.jpg',
|
||||
},
|
||||
{
|
||||
id: 'smart-routing',
|
||||
title: 'Smart Routing Forms',
|
||||
meta_title: 'Smart Routing Forms',
|
||||
meta_description:
|
||||
'Screen bookers with intelligent forms and route them to the right team member.',
|
||||
slug: 'smart-routing',
|
||||
feature_image: 'https://www.chatwoot.com/images/dashboard-dark.webp',
|
||||
},
|
||||
{
|
||||
id: 'instant-meetings',
|
||||
title: 'Instant Meetings',
|
||||
meta_title: 'Instant Meetings',
|
||||
meta_description: 'Start instant meetings directly from shared links.',
|
||||
slug: 'instant-meetings',
|
||||
feature_image:
|
||||
'https://images.unsplash.com/photo-1587614382346-4ec70e388b28?w=600',
|
||||
},
|
||||
{
|
||||
id: 'analytics',
|
||||
title: 'Advanced Analytics',
|
||||
meta_title: 'Advanced Analytics',
|
||||
meta_description:
|
||||
'Track meeting performance, conversion, and response rates in one place.',
|
||||
slug: 'analytics',
|
||||
feature_image:
|
||||
'https://images.unsplash.com/photo-1522202176988-66273c2fd55f?w=500',
|
||||
},
|
||||
{
|
||||
id: 'team-collaboration',
|
||||
title: 'Team Collaboration',
|
||||
meta_title: 'Team Collaboration',
|
||||
meta_description:
|
||||
'Coordinate with your team seamlessly using shared availability.',
|
||||
slug: 'team-collaboration',
|
||||
feature_image:
|
||||
'https://images.unsplash.com/photo-1522202176988-66273c2fd55f?w=400',
|
||||
},
|
||||
];
|
||||
|
||||
const visibleCards = ref([...sampleCards]);
|
||||
const currentIndex = ref(0);
|
||||
const dismissingCards = ref([]);
|
||||
|
||||
const handleReadMore = slug => {
|
||||
console.log(`Read more: ${slug}`);
|
||||
};
|
||||
|
||||
const handleDismiss = slug => {
|
||||
dismissingCards.value.push(slug);
|
||||
setTimeout(() => {
|
||||
const idx = visibleCards.value.findIndex(c => c.slug === slug);
|
||||
if (idx !== -1) visibleCards.value.splice(idx, 1);
|
||||
dismissingCards.value = dismissingCards.value.filter(s => s !== slug);
|
||||
if (currentIndex.value >= visibleCards.value.length) currentIndex.value = 0;
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const handleImgClick = data => {
|
||||
currentIndex.value = data.index;
|
||||
console.log(`Card clicked: ${visibleCards.value[data.index].title}`);
|
||||
};
|
||||
|
||||
const resetDemo = () => {
|
||||
visibleCards.value = [...sampleCards];
|
||||
currentIndex.value = 0;
|
||||
dismissingCards.value = [];
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/ChangelogCard/GroupedStackedChangelogCard"
|
||||
:layout="{ type: 'grid', width: '320px' }"
|
||||
>
|
||||
<Variant title="Interactive Demo">
|
||||
<div class="p-4 bg-n-solid-2 rounded-md mx-auto w-64 h-[400px]">
|
||||
<GroupedStackedChangelogCard
|
||||
:posts="visibleCards"
|
||||
:current-index="currentIndex"
|
||||
:is-active="currentIndex === 0"
|
||||
:dismissing-slugs="dismissingCards"
|
||||
class="min-h-[270px]"
|
||||
@read-more="handleReadMore"
|
||||
@dismiss="handleDismiss"
|
||||
@img-click="handleImgClick"
|
||||
/>
|
||||
|
||||
<button
|
||||
class="mt-3 px-3 py-1 text-xs font-medium bg-n-brand text-white rounded hover:bg-n-brand/80 transition"
|
||||
@click="resetDemo"
|
||||
>
|
||||
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
|
||||
{{ 'Reset Cards' }}
|
||||
</button>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import StackedChangelogCard from './StackedChangelogCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
posts: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
currentIndex: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
dismissingSlugs: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['readMore', 'dismiss', 'imgClick']);
|
||||
|
||||
const stackedPosts = computed(() => props.posts?.slice(0, 5));
|
||||
|
||||
const isPostDismissing = post => props.dismissingSlugs.includes(post.slug);
|
||||
|
||||
const handleReadMore = post => emit('readMore', post.slug);
|
||||
const handleDismiss = post => emit('dismiss', post.slug);
|
||||
const handlePostClick = (post, index) => {
|
||||
if (index === props.currentIndex && !isPostDismissing(post)) {
|
||||
emit('imgClick', { slug: post.slug, index });
|
||||
}
|
||||
};
|
||||
|
||||
const getCardClasses = index => {
|
||||
const pos =
|
||||
(index - props.currentIndex + stackedPosts.value.length) %
|
||||
stackedPosts.value.length;
|
||||
const base =
|
||||
'relative transition-all duration-500 ease-out col-start-1 row-start-1';
|
||||
|
||||
const layers = [
|
||||
'z-50 scale-100 translate-y-0 opacity-100',
|
||||
'z-40 scale-[0.95] -translate-y-3 opacity-90',
|
||||
'z-30 scale-[0.9] -translate-y-6 opacity-70',
|
||||
'z-20 scale-[0.85] -translate-y-9 opacity-50',
|
||||
'z-10 scale-[0.8] -translate-y-12 opacity-30',
|
||||
];
|
||||
|
||||
return pos < layers.length
|
||||
? `${base} ${layers[pos]}`
|
||||
: `${base} opacity-0 scale-75 -translate-y-16`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overflow-hidden">
|
||||
<div class="relative grid grid-cols-1 pt-8 pb-1 px-2">
|
||||
<div
|
||||
v-for="(post, index) in stackedPosts"
|
||||
:key="post.slug || index"
|
||||
:class="getCardClasses(index)"
|
||||
>
|
||||
<StackedChangelogCard
|
||||
:card="post"
|
||||
:is-active="index === currentIndex"
|
||||
:is-dismissing="isPostDismissing(post)"
|
||||
@read-more="handleReadMore(post)"
|
||||
@dismiss="handleDismiss(post)"
|
||||
@img-click="handlePostClick(post, index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<script setup>
|
||||
import StackedChangelogCard from './StackedChangelogCard.vue';
|
||||
|
||||
const imageCards = {
|
||||
id: 'chatwoot-captain',
|
||||
title: 'Chatwoot Captain',
|
||||
meta_title: 'Chatwoot Captain',
|
||||
meta_description:
|
||||
'Watch how our latest feature can transform your workflow with powerful automation tools.',
|
||||
slug: 'chatwoot-captain',
|
||||
feature_image:
|
||||
'https://www.chatwoot.com/images/captain/captain_thumbnail.jpg',
|
||||
};
|
||||
|
||||
const handleReadMore = () => {
|
||||
console.log(`Read more: ${imageCards.title}`);
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
console.log(`Dismissed: ${imageCards.title}`);
|
||||
};
|
||||
|
||||
const handleImgClick = () => {
|
||||
console.log(`Card clicked: ${imageCards.title}`);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/ChangelogCard/StackedChangelogCard"
|
||||
:layout="{ type: 'grid', width: '260px' }"
|
||||
>
|
||||
<Variant title="Single Card - With Image">
|
||||
<div class="p-3 bg-n-solid-2 w-56">
|
||||
<StackedChangelogCard
|
||||
:card="imageCards"
|
||||
is-active
|
||||
:is-dismissing="false"
|
||||
@read-more="handleReadMore(imageCards)"
|
||||
@dismiss="handleDismiss(imageCards)"
|
||||
@img-click="handleImgClick(imageCards)"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,119 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
card: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isDismissing: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['readMore', 'dismiss', 'imgClick']);
|
||||
|
||||
const handleReadMore = () => {
|
||||
emit('readMore');
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
emit('dismiss');
|
||||
};
|
||||
|
||||
const handleImgClick = () => {
|
||||
emit('imgClick');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-testid="changelog-card"
|
||||
class="flex flex-col justify-between p-3 w-full rounded-lg border shadow-sm transition-all duration-200 border-n-weak bg-n-background text-n-slate-12"
|
||||
:class="{
|
||||
'animate-fade-out pointer-events-none': isDismissing,
|
||||
'hover:shadow': isActive,
|
||||
}"
|
||||
>
|
||||
<div>
|
||||
<h5
|
||||
:title="card.meta_title"
|
||||
class="mb-1 text-sm font-semibold line-clamp-1 text-n-slate-12"
|
||||
>
|
||||
{{ card.meta_title }}
|
||||
</h5>
|
||||
<p
|
||||
:title="card.meta_description"
|
||||
class="mb-0 text-xs leading-relaxed text-n-slate-11 line-clamp-2"
|
||||
>
|
||||
{{ card.meta_description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="card.feature_image"
|
||||
class="block overflow-hidden my-3 rounded-md border border-n-weak/40"
|
||||
>
|
||||
<img
|
||||
:src="card.feature_image"
|
||||
:alt="`${card.title} preview image`"
|
||||
class="object-cover w-full h-24 rounded-md cursor-pointer"
|
||||
loading="lazy"
|
||||
@click.stop="handleImgClick"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="block overflow-hidden my-3 rounded-md border border-n-weak/40"
|
||||
>
|
||||
<img
|
||||
:src="card.feature_image"
|
||||
:alt="`${card.title} preview image`"
|
||||
class="object-cover w-full h-24 rounded-md cursor-pointer"
|
||||
loading="lazy"
|
||||
@click.stop="handleImgClick"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center mt-1">
|
||||
<Button
|
||||
label="Read more"
|
||||
color="slate"
|
||||
link
|
||||
sm
|
||||
class="text-xs font-normal hover:!no-underline"
|
||||
@click.stop="handleReadMore"
|
||||
/>
|
||||
<Button
|
||||
label="Dismiss"
|
||||
color="slate"
|
||||
link
|
||||
sm
|
||||
class="text-xs font-normal hover:!no-underline"
|
||||
@click.stop="handleDismiss"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@keyframes fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-out {
|
||||
animation: fade-out 0.2s ease-out forwards;
|
||||
}
|
||||
</style>
|
||||
@@ -10,6 +10,7 @@ defineProps({
|
||||
fallbackThumbnail: { type: String, default: '' },
|
||||
fallbackThumbnailDark: { type: String, default: '' },
|
||||
learnMoreUrl: { type: String, default: '' },
|
||||
hideActions: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const imageError = ref(false);
|
||||
@@ -65,7 +66,7 @@ const openLink = link => {
|
||||
<div class="flex flex-col flex-1 gap-3 ltr:pr-8 rtl:pl-8">
|
||||
<p v-if="note" class="text-n-slate-12 text-sm mb-0">{{ note }}</p>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<div v-if="!hideActions" class="flex gap-3">
|
||||
<slot name="actions">
|
||||
<Button
|
||||
v-if="videoUrl"
|
||||
|
||||
+2
-1
@@ -13,6 +13,7 @@ defineProps({
|
||||
fallbackThumbnail: { type: String, default: '' },
|
||||
fallbackThumbnailDark: { type: String, default: '' },
|
||||
learnMoreUrl: { type: String, default: '' },
|
||||
hideActions: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const imageError = ref(false);
|
||||
@@ -92,7 +93,7 @@ const openLink = link => {
|
||||
{{ note }}
|
||||
</p>
|
||||
|
||||
<div class="flex gap-3 justify-between w-full">
|
||||
<div v-if="!hideActions" class="flex gap-3 justify-between w-full">
|
||||
<slot name="actions">
|
||||
<Button
|
||||
v-if="videoUrl"
|
||||
|
||||
@@ -5,9 +5,9 @@ import { sanitizeTextForRender } from '@chatwoot/utils';
|
||||
import { allowedCssProperties } from 'lettersanitizer';
|
||||
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import { EmailQuoteExtractor } from './removeReply.js';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
import { EmailQuoteExtractor } from 'dashboard/helper/emailQuoteExtractor.js';
|
||||
import FormattedContent from 'next/message/bubbles/Text/FormattedContent.vue';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
|
||||
import EmailMeta from './EmailMeta.vue';
|
||||
import TranslationToggle from 'dashboard/components-next/message/TranslationToggle.vue';
|
||||
@@ -47,6 +47,13 @@ const originalEmailHtml = computed(
|
||||
originalEmailText.value
|
||||
);
|
||||
|
||||
const hasEmailContent = computed(() => {
|
||||
return (
|
||||
contentAttributes?.value?.email?.textContent?.full ||
|
||||
contentAttributes?.value?.email?.htmlContent?.full
|
||||
);
|
||||
});
|
||||
|
||||
const messageContent = computed(() => {
|
||||
// If translations exist and we're showing translations (not original)
|
||||
if (hasTranslations.value && !renderOriginal.value) {
|
||||
@@ -137,7 +144,7 @@ const handleSeeOriginal = () => {
|
||||
</button>
|
||||
</div>
|
||||
<FormattedContent
|
||||
v-if="isOutgoing && content"
|
||||
v-if="isOutgoing && content && !hasEmailContent"
|
||||
class="text-n-slate-12"
|
||||
:content="messageContent"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-n-slate-12 max-w-80 flex flex-col gap-2.5">
|
||||
<div class="p-3 bg-n-alpha-2 rounded-xl">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble font-medium text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button label="Call us" slate class="!text-n-blue-text w-full" />
|
||||
<Button
|
||||
label="Visit our website"
|
||||
slate
|
||||
class="!text-n-blue-text w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
|
||||
>
|
||||
<div class="px-3 py-2.5">
|
||||
<img :src="message.image_url" class="max-h-44 rounded-lg w-full" />
|
||||
<div class="pt-2.5 flex flex-col gap-2">
|
||||
<h6 class="font-semibold">{{ message.title }}</h6>
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button label="Call us to order" link class="hover:!no-underline" />
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button label="Visit our store" link class="hover:!no-underline" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
|
||||
>
|
||||
<div class="p-3">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble font-medium text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button label="See options" link class="hover:!no-underline" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 text-n-slate-12 rounded-xl flex flex-col gap-2.5 p-3 max-w-80"
|
||||
>
|
||||
<img :src="message.image_url" class="max-h-44 rounded-lg w-full" />
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble font-medium text-sm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
|
||||
>
|
||||
<div class="p-3">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble font-medium text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button label="No, that will be all" link class="hover:!no-underline">
|
||||
<template #icon>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
class="stroke-n-blue-text"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
|
||||
stroke-width="1.333"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button
|
||||
label="I want to talk to an agents"
|
||||
link
|
||||
class="hover:!no-underline"
|
||||
>
|
||||
<template #icon>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
class="stroke-n-blue-text"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
|
||||
stroke-width="1.333"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-n-alpha-2 text-n-slate-12 rounded-xl p-3 max-w-80">
|
||||
<span v-dompurify-html="message.content" class="prose prose-bubble" />
|
||||
</div>
|
||||
</template>
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import CallToAction from '../../bubbles/Template/CallToAction.vue';
|
||||
|
||||
const message = {
|
||||
content:
|
||||
'We have super cool products going live! Pre-order and customize products. Contact us for more details',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/CallToAction"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Call To Action">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<CallToAction :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
import Card from '../../bubbles/Template/Card.vue';
|
||||
|
||||
const message = {
|
||||
title: 'Two in one cake (1 pound)',
|
||||
content: 'Customize your order for special occasions',
|
||||
image_url:
|
||||
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=500&h=300&fit=crop',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/Card"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Card">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<Card :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import ListPicker from '../../bubbles/Template/ListPicker.vue';
|
||||
|
||||
const message = {
|
||||
content: `Hey there! Thanks for reaching out to us. Could you let us know
|
||||
what you need to help us better assist you? `,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/ListPicker"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="ListPicker">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<ListPicker :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
import Media from '../../bubbles/Template/Media.vue';
|
||||
|
||||
const message = {
|
||||
content:
|
||||
'Welcome to our Diwali sale! Get flat 50% off on select items. Hurry now!',
|
||||
image_url:
|
||||
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=500&h=300&fit=crop',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/Media"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Image Media">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<Media :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import QuickReply from '../../bubbles/Template/QuickReply.vue';
|
||||
|
||||
const message = {
|
||||
content: `Hey there! Thanks for reaching out to us. Could you let us know
|
||||
what you need to help us better assist you?`,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/QuickReply"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Quick Replies">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<QuickReply :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup>
|
||||
import Text from '../../bubbles/Template/Text.vue';
|
||||
|
||||
const message = {
|
||||
content: 'Hello John, how may we assist you?',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/Text"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Default Text">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<Text :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -13,6 +13,7 @@ import { vOnClickOutside } from '@vueuse/components';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import SidebarGroup from './SidebarGroup.vue';
|
||||
import SidebarProfileMenu from './SidebarProfileMenu.vue';
|
||||
import SidebarChangelogCard from './SidebarChangelogCard.vue';
|
||||
import ChannelLeaf from './ChannelLeaf.vue';
|
||||
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
|
||||
import Logo from 'next/icon/Logo.vue';
|
||||
@@ -32,11 +33,15 @@ const emit = defineEmits([
|
||||
'closeMobileSidebar',
|
||||
]);
|
||||
|
||||
const { accountScopedRoute } = useAccount();
|
||||
const { accountScopedRoute, isOnChatwootCloud } = useAccount();
|
||||
const store = useStore();
|
||||
const searchShortcut = useKbd([`$mod`, 'k']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const isACustomBrandedInstance = useMapGetter(
|
||||
'globalConfig/isACustomBrandedInstance'
|
||||
);
|
||||
|
||||
const toggleShortcutModalFn = show => {
|
||||
if (show) {
|
||||
emit('openKeyShortcutModal');
|
||||
@@ -232,6 +237,11 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.CAPTAIN_RESPONSES'),
|
||||
to: accountScopedRoute('captain_responses_index'),
|
||||
},
|
||||
{
|
||||
name: 'Tools',
|
||||
label: t('SIDEBAR.CAPTAIN_TOOLS'),
|
||||
to: accountScopedRoute('captain_tools_index'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -527,20 +537,20 @@ const menuItems = computed(() => {
|
||||
]"
|
||||
>
|
||||
<section class="grid gap-2 mt-2 mb-4">
|
||||
<div class="flex items-center min-w-0 gap-2 px-2">
|
||||
<div class="grid flex-shrink-0 size-6 place-content-center">
|
||||
<div class="flex gap-2 items-center px-2 min-w-0">
|
||||
<div class="grid flex-shrink-0 place-content-center size-6">
|
||||
<Logo class="size-4" />
|
||||
</div>
|
||||
<div class="flex-shrink-0 w-px h-3 bg-n-strong" />
|
||||
<SidebarAccountSwitcher
|
||||
class="flex-grow min-w-0 -mx-1"
|
||||
class="flex-grow -mx-1 min-w-0"
|
||||
@show-create-account-modal="emit('showCreateAccountModal')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2 px-2">
|
||||
<RouterLink
|
||||
:to="{ name: 'search' }"
|
||||
class="flex items-center w-full gap-2 px-2 py-1 rounded-lg h-7 outline outline-1 outline-n-weak bg-n-solid-3 dark:bg-n-black/30"
|
||||
class="flex gap-2 items-center px-2 py-1 w-full h-7 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3 dark:bg-n-black/30"
|
||||
>
|
||||
<span class="flex-shrink-0 i-lucide-search size-4 text-n-slate-11" />
|
||||
<span class="flex-grow text-left">
|
||||
@@ -565,7 +575,7 @@ const menuItems = computed(() => {
|
||||
</ComposeConversation>
|
||||
</div>
|
||||
</section>
|
||||
<nav class="grid flex-grow gap-2 px-2 pb-5 overflow-y-scroll no-scrollbar">
|
||||
<nav class="grid overflow-y-scroll flex-grow gap-2 px-2 pb-5 no-scrollbar">
|
||||
<ul class="flex flex-col gap-1.5 m-0 list-none">
|
||||
<SidebarGroup
|
||||
v-for="item in menuItems"
|
||||
@@ -575,11 +585,21 @@ const menuItems = computed(() => {
|
||||
</ul>
|
||||
</nav>
|
||||
<section
|
||||
class="p-1 border-t border-n-weak shadow-[0px_-2px_4px_0px_rgba(27,28,29,0.02)] flex-shrink-0 flex justify-between gap-2 items-center"
|
||||
class="flex flex-col flex-shrink-0 relative gap-1 justify-between items-center"
|
||||
>
|
||||
<SidebarProfileMenu
|
||||
@open-key-shortcut-modal="emit('openKeyShortcutModal')"
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 -top-[31px] h-8 bg-gradient-to-t from-n-solid-2 to-transparent"
|
||||
/>
|
||||
<SidebarChangelogCard
|
||||
v-if="isOnChatwootCloud && !isACustomBrandedInstance"
|
||||
/>
|
||||
<div
|
||||
class="p-1 flex-shrink-0 flex w-full justify-between z-10 gap-2 items-center border-t border-n-weak shadow-[0px_-2px_4px_0px_rgba(27,28,29,0.02)]"
|
||||
>
|
||||
<SidebarProfileMenu
|
||||
@open-key-shortcut-modal="emit('openKeyShortcutModal')"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import GroupedStackedChangelogCard from 'dashboard/components-next/changelog-card/GroupedStackedChangelogCard.vue';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import changelogAPI from 'dashboard/api/changelog';
|
||||
|
||||
const MAX_DISMISSED_SLUGS = 5;
|
||||
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
const posts = ref([]);
|
||||
const currentIndex = ref(0);
|
||||
const dismissingCards = ref([]);
|
||||
const isLoading = ref(false);
|
||||
|
||||
// Get current dismissed slugs from ui_settings
|
||||
const dismissedSlugs = computed(() => {
|
||||
return uiSettings.value.changelog_dismissed_slugs || [];
|
||||
});
|
||||
|
||||
// Get un dismissed posts - these are the changelog posts that should be shown
|
||||
const unDismissedPosts = computed(() => {
|
||||
return posts.value.filter(post => !dismissedSlugs.value.includes(post.slug));
|
||||
});
|
||||
|
||||
// Fetch changelog posts from API
|
||||
const fetchChangelog = async () => {
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
const response = await changelogAPI.fetchFromHub();
|
||||
posts.value = response.data.posts || [];
|
||||
|
||||
// Clean up dismissed slugs - remove any that are no longer in the current feed
|
||||
const currentSlugs = posts.value.map(post => post.slug);
|
||||
const cleanedDismissedSlugs = dismissedSlugs.value.filter(slug =>
|
||||
currentSlugs.includes(slug)
|
||||
);
|
||||
|
||||
// Update ui_settings if cleanup occurred
|
||||
if (cleanedDismissedSlugs.length !== dismissedSlugs.value.length) {
|
||||
updateUISettings({
|
||||
changelog_dismissed_slugs: cleanedDismissedSlugs,
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch (err) {
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Dismiss a changelog post
|
||||
const dismissPost = slug => {
|
||||
const currentDismissed = [...dismissedSlugs.value];
|
||||
|
||||
// Add new slug if not already present
|
||||
if (!currentDismissed.includes(slug)) {
|
||||
currentDismissed.push(slug);
|
||||
|
||||
// Keep only the most recent MAX_DISMISSED_SLUGS entries
|
||||
if (currentDismissed.length > MAX_DISMISSED_SLUGS) {
|
||||
currentDismissed.shift(); // Remove oldest entry
|
||||
}
|
||||
|
||||
updateUISettings({
|
||||
changelog_dismissed_slugs: currentDismissed,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismiss = slug => {
|
||||
dismissingCards.value.push(slug);
|
||||
setTimeout(() => {
|
||||
dismissPost(slug);
|
||||
dismissingCards.value = dismissingCards.value.filter(s => s !== slug);
|
||||
if (currentIndex.value >= unDismissedPosts.value.length)
|
||||
currentIndex.value = 0;
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const handleReadMore = () => {
|
||||
const currentPost = unDismissedPosts.value[currentIndex.value];
|
||||
if (currentPost?.slug) {
|
||||
window.open(`https://www.chatwoot.com/blog/${currentPost.slug}`, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
const handleImgClick = ({ index }) => {
|
||||
currentIndex.value = index;
|
||||
handleReadMore();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchChangelog();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<GroupedStackedChangelogCard
|
||||
v-if="unDismissedPosts.length > 0"
|
||||
:posts="unDismissedPosts"
|
||||
:current-index="currentIndex"
|
||||
:dismissing-slugs="dismissingCards"
|
||||
class="min-h-[240px] z-10"
|
||||
@read-more="handleReadMore"
|
||||
@dismiss="handleDismiss"
|
||||
@img-click="handleImgClick"
|
||||
/>
|
||||
<template v-else />
|
||||
</template>
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2.5 text-n-slate-12 max-w-80">
|
||||
<div class="p-3 rounded-xl bg-n-alpha-2">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="text-sm font-medium prose prose-bubble"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="message.buttons && message.buttons.length > 0"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<Button
|
||||
v-for="(button, index) in message.buttons"
|
||||
:key="index"
|
||||
:label="button.text || 'Button'"
|
||||
slate
|
||||
class="!text-n-blue-text w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import FileIcon from 'dashboard/components-next/icon/FileIcon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Determine media type based on URL or template data
|
||||
const getMediaType = () => {
|
||||
if (props.message.originalTemplate?.header?.format) {
|
||||
return props.message.originalTemplate.header.format.toLowerCase();
|
||||
}
|
||||
|
||||
const url = props.message.image_url;
|
||||
if (!url) return 'document';
|
||||
|
||||
if (url.includes('.pdf') || url.includes('pdf')) return 'document';
|
||||
if (url.includes('.mp4') || url.includes('.mov') || url.includes('video'))
|
||||
return 'video';
|
||||
return 'image';
|
||||
};
|
||||
|
||||
const mediaType = getMediaType();
|
||||
|
||||
// Get file type from URL for proper icon
|
||||
const fileType = computed(() => {
|
||||
const url = props.message.image_url;
|
||||
if (!url) return 'pdf';
|
||||
|
||||
if (url.includes('.pdf') || url.includes('pdf')) return 'pdf';
|
||||
if (url.includes('.doc')) return 'doc';
|
||||
return 'pdf'; // Default to PDF for documents
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-2.5 p-3 rounded-xl bg-n-alpha-2 text-n-slate-12 max-w-80"
|
||||
>
|
||||
<!-- Image Media -->
|
||||
<img
|
||||
v-if="mediaType === 'image'"
|
||||
:src="message.image_url"
|
||||
class="object-cover w-full max-h-44 rounded-lg"
|
||||
alt="Template media"
|
||||
/>
|
||||
|
||||
<!-- Video Media -->
|
||||
<div
|
||||
v-else-if="mediaType === 'video'"
|
||||
class="overflow-hidden relative bg-gray-100 rounded-lg"
|
||||
>
|
||||
<video
|
||||
:src="message.image_url"
|
||||
class="object-cover w-full max-h-44"
|
||||
controls
|
||||
preload="metadata"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Document Media -->
|
||||
<div v-else-if="mediaType === 'document'" class="flex items-center">
|
||||
<FileIcon :file-type="fileType" class="text-2xl text-n-slate-12" />
|
||||
</div>
|
||||
|
||||
<!-- Content Text -->
|
||||
<span
|
||||
v-if="message.content"
|
||||
v-dompurify-html="message.content"
|
||||
class="text-sm font-medium prose prose-bubble"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
|
||||
>
|
||||
<div class="p-3">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble font-medium text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-for="(action, index) in message.actions"
|
||||
:key="index"
|
||||
class="p-3 flex items-center justify-center"
|
||||
>
|
||||
<Button
|
||||
:label="action.title || action.text || 'Button'"
|
||||
link
|
||||
class="hover:!no-underline"
|
||||
>
|
||||
<template #icon>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
class="stroke-n-blue-text"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
|
||||
stroke-width="1.333"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,322 @@
|
||||
<script setup>
|
||||
import TemplatePreview from './TemplatePreview.vue';
|
||||
|
||||
// Sample template data from the JSON files
|
||||
const whatsAppTemplates = {
|
||||
greet: {
|
||||
id: '997298832221901',
|
||||
name: 'greet',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
text: 'Hey {{customer_name}} how may I help you?',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: 'John',
|
||||
param_name: 'customer_name',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
|
||||
eventInvitation: {
|
||||
id: '1381151706284063',
|
||||
name: 'event_invitation_static',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
text: "You're invited to {{event_name}} at {{location}}, Join us for an amazing experience!",
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: 'F1',
|
||||
param_name: 'event_name',
|
||||
},
|
||||
{
|
||||
example: 'Dubai',
|
||||
param_name: 'location',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
url: 'https://events.example.com/register',
|
||||
text: 'Visit website',
|
||||
type: 'URL',
|
||||
},
|
||||
{
|
||||
url: 'https://maps.app.goo.gl/YoWAzRj1GDuxs6qz8',
|
||||
text: 'Get Directions',
|
||||
type: 'URL',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
|
||||
orderConfirmation: {
|
||||
id: '1106685194739985',
|
||||
name: 'order_confirmation',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
type: 'HEADER',
|
||||
format: 'IMAGE',
|
||||
example: {
|
||||
header_handle: [
|
||||
'https://scontent.whatsapp.net/v/t61.29466-34/518466505_1106685198073318_3569250580697484416_n.jpg',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Hi your order {{1}} is confirmed. Please wait for further updates',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text: [['blue canvas shoes']],
|
||||
},
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'POSITIONAL',
|
||||
},
|
||||
|
||||
discountCoupon: {
|
||||
id: '1469258364071127',
|
||||
name: 'discount_coupon',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
text: '🎉 Special offer for you! Get {{discount_percentage}}% off your next purchase. Use the code below at checkout',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: '30',
|
||||
param_name: 'discount_percentage',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Copy offer code',
|
||||
type: 'COPY_CODE',
|
||||
example: ['SAVE30OFF'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
|
||||
trainingVideo: {
|
||||
id: '1023596726651144',
|
||||
name: 'training_video',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
type: 'HEADER',
|
||||
format: 'VIDEO',
|
||||
example: {
|
||||
header_handle: [
|
||||
'https://scontent.whatsapp.net/v/t61.29466-34/521582686_1023596729984477_1872358575355618432_n.mp4',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "Hi {{name}}, here's your training video. Please watch by {{date}}.",
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: 'john',
|
||||
param_name: 'name',
|
||||
},
|
||||
{
|
||||
example: 'July 31',
|
||||
param_name: 'date',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
};
|
||||
|
||||
const twilioTemplates = {
|
||||
greet: {
|
||||
body: 'Hey {{1}}, how may I help you?',
|
||||
types: {
|
||||
'twilio/text': {
|
||||
body: 'Hey {{1}}, how may I help you?',
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en',
|
||||
variables: {
|
||||
1: 'John',
|
||||
},
|
||||
content_sid: 'HXee240fd3a8b5045dba057feda5173e55',
|
||||
friendly_name: 'greet',
|
||||
template_type: 'text',
|
||||
},
|
||||
|
||||
shoeLaunch: {
|
||||
body: '👟 Introducing our latest release — the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
|
||||
types: {
|
||||
'twilio/media': {
|
||||
body: '👟 Introducing our latest release — the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
|
||||
media: ['https://vite-five-phi.vercel.app/jordan-shoes.jpg'],
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en',
|
||||
variables: {
|
||||
1: 'Jordan',
|
||||
2: '100$',
|
||||
},
|
||||
content_sid: 'HX4b1ff075f097ccdf7f274b6af4d7be02',
|
||||
friendly_name: 'shoe_launch',
|
||||
template_type: 'media',
|
||||
},
|
||||
|
||||
welcomeMessage: {
|
||||
body: 'Thanks for reaching out to us. Before we proceed, we would like to get some information from you to assist you better. To whom would you like to connect?',
|
||||
types: {
|
||||
'twilio/quick-reply': {
|
||||
body: 'Thanks for reaching out to us. Before we proceed, we would like to get some information from you to assist you better. To whom would you like to connect?',
|
||||
actions: [
|
||||
{
|
||||
id: 'Sales_payload',
|
||||
title: 'Sales',
|
||||
},
|
||||
{
|
||||
id: 'Support_payload',
|
||||
title: 'Support',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en_US',
|
||||
variables: {},
|
||||
content_sid: 'HX778677f5867f96175ab4b7efb9a5bee6',
|
||||
friendly_name: 'welcome_message_new',
|
||||
template_type: 'quick_reply',
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/TemplatePreview"
|
||||
:layout="{ type: 'grid', width: 400 }"
|
||||
>
|
||||
<!-- WhatsApp Text Templates -->
|
||||
<Variant title="WhatsApp - Simple Text">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates.greet"
|
||||
:variables="{ customer_name: 'John' }"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WhatsApp - Simple Text (No Variables)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates.greet"
|
||||
:variables="{}"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<!-- WhatsApp Button Templates -->
|
||||
<Variant title="WhatsApp - Call to Action Buttons">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates.eventInvitation"
|
||||
:variables="{ event_name: 'F1 Grand Prix', location: 'Dubai' }"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<!-- WhatsApp Media Templates -->
|
||||
<Variant title="WhatsApp - Image Media">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates.orderConfirmation"
|
||||
:variables="{ '1': 'Blue Canvas Shoes' }"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WhatsApp - Video Media">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates.trainingVideo"
|
||||
:variables="{ name: 'John', date: 'July 31st' }"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<!-- WhatsApp Copy Code Templates -->
|
||||
<Variant title="WhatsApp - Copy Code">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates.discountCoupon"
|
||||
:variables="{ discount_percentage: '30' }"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<!-- Twilio Templates -->
|
||||
<Variant title="Twilio - Text">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates.greet"
|
||||
:variables="{ '1': 'John' }"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio - Media">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates.shoeLaunch"
|
||||
:variables="{ '1': 'Air Jordan', '2': '$150' }"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio - Quick Reply">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates.welcomeMessage"
|
||||
:variables="{}"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,120 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { TemplateNormalizer } from 'dashboard/services/TemplateNormalizer';
|
||||
|
||||
import TextTemplate from 'dashboard/components-next/message/bubbles/Template/Text.vue';
|
||||
import CardTemplate from 'dashboard/components-next/message/bubbles/Template/Card.vue';
|
||||
import DynamicCallToActionTemplate from './DynamicCallToActionTemplate.vue';
|
||||
import DynamicMediaTemplate from './DynamicMediaTemplate.vue';
|
||||
import WhatsAppTextTemplate from './WhatsAppTextTemplate.vue';
|
||||
import DynamicQuickReplyTemplate from './DynamicQuickReplyTemplate.vue';
|
||||
|
||||
const props = defineProps({
|
||||
template: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
variables: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
platform: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: value => ['whatsapp', 'twilio'].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
// Normalize template data and apply variables
|
||||
const processedTemplate = computed(() => {
|
||||
const normalized =
|
||||
props.platform === 'whatsapp'
|
||||
? TemplateNormalizer.normalizeWhatsApp(props.template)
|
||||
: TemplateNormalizer.normalizeTwilio(props.template);
|
||||
|
||||
// Apply variable substitution to content
|
||||
const processText = text => {
|
||||
if (!text) return '';
|
||||
return text.replace(/\{\{([^}]+)\}\}/g, (match, variable) => {
|
||||
const value = props.variables[variable];
|
||||
return value !== undefined && value !== '' ? value : `[${variable}]`;
|
||||
});
|
||||
};
|
||||
|
||||
// Extract content based on platform
|
||||
let content = '';
|
||||
let imageUrl = '';
|
||||
let title = '';
|
||||
let footer = '';
|
||||
|
||||
if (props.platform === 'whatsapp') {
|
||||
// WhatsApp: get text from body component
|
||||
content = normalized.body?.text || '';
|
||||
|
||||
// Get header content for media templates
|
||||
if (normalized.header) {
|
||||
if (
|
||||
normalized.header.format === 'IMAGE' ||
|
||||
normalized.header.format === 'VIDEO' ||
|
||||
normalized.header.format === 'DOCUMENT'
|
||||
) {
|
||||
imageUrl = normalized.header.example?.header_handle?.[0] || '';
|
||||
}
|
||||
if (normalized.header.format === 'TEXT') {
|
||||
title = normalized.header.text || '';
|
||||
}
|
||||
}
|
||||
|
||||
// Get footer content
|
||||
footer = normalized.footer?.text || '';
|
||||
} else {
|
||||
// Twilio: get body directly
|
||||
content = normalized.body || '';
|
||||
|
||||
// Get media URL if available
|
||||
if (normalized.media && normalized.media.length > 0) {
|
||||
imageUrl = normalized.media[0];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...normalized,
|
||||
content: processText(content),
|
||||
title: processText(title),
|
||||
footer: processText(footer),
|
||||
image_url: imageUrl,
|
||||
buttons: normalized.buttons || [],
|
||||
actions: normalized.actions || [],
|
||||
};
|
||||
});
|
||||
|
||||
// Component selection based on template type
|
||||
const previewComponent = computed(() => {
|
||||
const type = processedTemplate.value.type;
|
||||
|
||||
const componentMap = {
|
||||
// WhatsApp components
|
||||
'whatsapp-text': WhatsAppTextTemplate,
|
||||
'whatsapp-text-header': WhatsAppTextTemplate,
|
||||
'whatsapp-media-image': DynamicMediaTemplate,
|
||||
'whatsapp-media-video': DynamicMediaTemplate,
|
||||
'whatsapp-media-document': DynamicMediaTemplate,
|
||||
'whatsapp-interactive': DynamicCallToActionTemplate,
|
||||
'whatsapp-copy-code': DynamicCallToActionTemplate,
|
||||
|
||||
// Twilio components
|
||||
'twilio-text': TextTemplate,
|
||||
'twilio-media': DynamicMediaTemplate,
|
||||
'twilio-quick-reply': DynamicQuickReplyTemplate,
|
||||
'twilio-card': CardTemplate,
|
||||
};
|
||||
|
||||
return componentMap[type] || TextTemplate;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="template-preview">
|
||||
<component :is="previewComponent" :message="processedTemplate" />
|
||||
</div>
|
||||
</template>
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
<script setup>
|
||||
import TemplatePreview from './TemplatePreview.vue';
|
||||
import {
|
||||
whatsAppTemplates,
|
||||
getWhatsAppVariables,
|
||||
} from './templates/whatsapp-templates.js';
|
||||
import { twilioTemplates } from './templates/twillio-templates.js';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/TemplatePreviewExamples"
|
||||
:layout="{ type: 'grid', width: 400 }"
|
||||
>
|
||||
<!-- WhatsApp Templates -->
|
||||
<Variant title="WA: Event Invitation (Buttons)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[0]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[0])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Purchase Receipt (Document)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[1]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[1])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Discount Coupon (Copy Code)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[2]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[2])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Support Callback (Phone)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[3]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[3])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Training Video (Video)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[4]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[4])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Order Confirmation (Image)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[5]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[5])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Product Launch (Image + Footer)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[6]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[6])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Technician Visit (Header + Buttons)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[7]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[7])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Greet (Simple Text)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[8]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[8])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Hello World (Header + Footer)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[9]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[9])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Feedback Request (Button)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[10]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[10])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Address Update (Header)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[11]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[11])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Delivery Confirmation">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[12]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[12])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<!-- Twilio Templates -->
|
||||
<Variant title="Twilio: Shoe Launch (Media)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[0]"
|
||||
:variables="twilioTemplates[0].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Product Launch Custom Price (Media)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[1]"
|
||||
:variables="twilioTemplates[1].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Product Launch (Media)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[2]"
|
||||
:variables="twilioTemplates[2].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Greet (Text)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[3]"
|
||||
:variables="twilioTemplates[3].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Order Status (Text)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[4]"
|
||||
:variables="twilioTemplates[4].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Hello World (Text)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[5]"
|
||||
:variables="twilioTemplates[5].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Welcome Message New (Quick Reply)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[6]"
|
||||
:variables="twilioTemplates[6].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: SaaS WhatsApp Question (Quick Reply)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[7]"
|
||||
:variables="twilioTemplates[7].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Welcome Message (Quick Reply)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[8]"
|
||||
:variables="twilioTemplates[8].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 text-n-slate-12 rounded-xl p-3 max-w-80 flex flex-col gap-2"
|
||||
>
|
||||
<!-- Header Text -->
|
||||
<div v-if="message.title" class="font-bold text-base">
|
||||
{{ message.title }}
|
||||
</div>
|
||||
|
||||
<!-- Body Content -->
|
||||
<div v-if="message.content" class="prose prose-bubble font-medium text-sm">
|
||||
<span v-dompurify-html="message.content" />
|
||||
</div>
|
||||
|
||||
<!-- Footer Text -->
|
||||
<div v-if="message.footer" class="text-xs text-n-slate-11 opacity-70">
|
||||
{{ message.footer }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
// Main Template Preview Component
|
||||
export { default as TemplatePreview } from './TemplatePreview.vue';
|
||||
|
||||
// Core Services
|
||||
export { TemplateTypeDetector } from 'dashboard/services/TemplateTypeDetector';
|
||||
export { TemplateNormalizer } from 'dashboard/services/TemplateNormalizer';
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
export const twilioTemplates = [
|
||||
{
|
||||
body: '=_ Introducing our latest release the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
|
||||
types: {
|
||||
'twilio/media': {
|
||||
body: '=_ Introducing our latest release the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
|
||||
media: ['https://vite-five-phi.vercel.app/jordan-shoes.jpg'],
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en',
|
||||
variables: {
|
||||
1: 'Jordan',
|
||||
2: '100$',
|
||||
},
|
||||
content_sid: 'HX4b1ff075f097ccdf7f274b6af4d7be02',
|
||||
friendly_name: 'shoe_launch',
|
||||
template_type: 'media',
|
||||
},
|
||||
{
|
||||
body: '=_ Introducing our latest release the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
|
||||
types: {
|
||||
'twilio/media': {
|
||||
body: '=_ Introducing our latest release the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
|
||||
media: ['https://vite-five-phi.vercel.app/jordan-shoes.jpg'],
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en',
|
||||
variables: {
|
||||
1: 'Jordan',
|
||||
2: '400$',
|
||||
},
|
||||
content_sid: 'HXd5c1f8f8d68976f841c440d5e4b46c2e',
|
||||
friendly_name: 'product_launch_custom_price',
|
||||
template_type: 'media',
|
||||
},
|
||||
{
|
||||
body: '=_ Introducing our latest release the Nike Air Force! Available now for just $129.99',
|
||||
types: {
|
||||
'twilio/media': {
|
||||
body: '=_ Introducing our latest release the Nike Air Force! Available now for just $129.99',
|
||||
media: ['https://vite-five-phi.vercel.app/jordan-shoes.jpg'],
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en',
|
||||
variables: {},
|
||||
content_sid: 'HX25f6e823f2416ca4b34254d98e916fae',
|
||||
friendly_name: 'product_launch',
|
||||
template_type: 'media',
|
||||
},
|
||||
{
|
||||
body: 'Hey {{1}}, how may I help you?',
|
||||
types: {
|
||||
'twilio/text': {
|
||||
body: 'Hey {{1}}, how may I help you?',
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en',
|
||||
variables: {
|
||||
1: 'John',
|
||||
},
|
||||
content_sid: 'HXee240fd3a8b5045dba057feda5173e55',
|
||||
friendly_name: 'greet',
|
||||
template_type: 'text',
|
||||
},
|
||||
{
|
||||
body: "Hi {{1}} Thanks for placing an order with us. We'll let you know once your order has been processed and delivered. Your order number is {{3}}. Thanks",
|
||||
types: {
|
||||
'twilio/text': {
|
||||
body: "Hi {{1}} Thanks for placing an order with us. We'll let you know once your order has been processed and delivered. Your order number is {{3}}. Thanks",
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en',
|
||||
variables: {
|
||||
1: 'John',
|
||||
3: '12345',
|
||||
},
|
||||
content_sid: 'HX88291ef8d30d7dcd436cbb9b21c236f4',
|
||||
friendly_name: 'order_status',
|
||||
template_type: 'text',
|
||||
},
|
||||
{
|
||||
body: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
|
||||
types: {
|
||||
'twilio/text': {
|
||||
body: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en',
|
||||
variables: {},
|
||||
content_sid: 'HXdc6da32d489ee80f67c07d5bb0e7e390',
|
||||
friendly_name: 'hello_world',
|
||||
template_type: 'text',
|
||||
},
|
||||
{
|
||||
body: 'Thanks for reaching out to us. Before we proceed, we would like to get some information from you to assist you better. To whom would you like to connect?',
|
||||
types: {
|
||||
'twilio/quick-reply': {
|
||||
body: 'Thanks for reaching out to us. Before we proceed, we would like to get some information from you to assist you better. To whom would you like to connect?',
|
||||
actions: [
|
||||
{
|
||||
id: 'Sales_payload',
|
||||
title: 'Sales',
|
||||
},
|
||||
{
|
||||
id: 'Support_payload',
|
||||
title: 'Support',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en_US',
|
||||
variables: {},
|
||||
content_sid: 'HX778677f5867f96175ab4b7efb9a5bee6',
|
||||
friendly_name: 'welcome_message_new',
|
||||
template_type: 'quick_reply',
|
||||
},
|
||||
{
|
||||
body: 'What type of Chatwoot installation are you using? Select "Chatwoot Cloud" if you are using app.chatwoot.com, otherwise select "Self-hosted Chatwoot".',
|
||||
types: {
|
||||
'twilio/quick-reply': {
|
||||
body: 'What type of Chatwoot installation are you using? Select "Chatwoot Cloud" if you are using app.chatwoot.com, otherwise select "Self-hosted Chatwoot".',
|
||||
actions: [
|
||||
{
|
||||
id: 'Chatwoot Cloud_payload',
|
||||
title: 'Chatwoot Cloud',
|
||||
},
|
||||
{
|
||||
id: 'Self-hosted Chatwoot_payload',
|
||||
title: 'Self-hosted Chatwoot',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en_US',
|
||||
variables: {},
|
||||
content_sid: 'HX3a35e5cd76529fd91d19341deb4ef685',
|
||||
friendly_name: 'saas_whatsapp_question',
|
||||
template_type: 'quick_reply',
|
||||
},
|
||||
{
|
||||
body: 'Thanks for reaching out to us. Happy to help. What are you looking for?',
|
||||
types: {
|
||||
'twilio/quick-reply': {
|
||||
body: 'Thanks for reaching out to us. Happy to help. What are you looking for?',
|
||||
actions: [
|
||||
{
|
||||
id: 'Support_payload',
|
||||
title: 'Support',
|
||||
},
|
||||
{
|
||||
id: 'Sales_payload',
|
||||
title: 'Sales',
|
||||
},
|
||||
{
|
||||
id: 'Demo_payload',
|
||||
title: 'Demo',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en_US',
|
||||
variables: {},
|
||||
content_sid: 'HX5d8e09f96cee2f7fb7bab223c03cb0a1',
|
||||
friendly_name: 'welcome_message',
|
||||
template_type: 'quick_reply',
|
||||
},
|
||||
];
|
||||
+408
@@ -0,0 +1,408 @@
|
||||
export const whatsAppTemplates = [
|
||||
{
|
||||
id: '1381151706284063',
|
||||
name: 'event_invitation_static',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
text: "You're invited to {{event_name}} at {{location}}, Join us for an amazing experience!",
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: 'F1',
|
||||
param_name: 'event_name',
|
||||
},
|
||||
{
|
||||
example: 'Dubai',
|
||||
param_name: 'location',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
url: 'https://events.example.com/register',
|
||||
text: 'Visit website',
|
||||
type: 'URL',
|
||||
},
|
||||
{
|
||||
url: 'https://maps.app.goo.gl/YoWAzRj1GDuxs6qz8',
|
||||
text: 'Get Directions',
|
||||
type: 'URL',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
{
|
||||
id: '767076159336759',
|
||||
name: 'purchase_receipt',
|
||||
status: 'APPROVED',
|
||||
category: 'UTILITY',
|
||||
language: 'en_US',
|
||||
components: [
|
||||
{
|
||||
type: 'HEADER',
|
||||
format: 'DOCUMENT',
|
||||
example: {
|
||||
header_handle: [
|
||||
'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Thank you for using your {{1}} card at {{2}}. Your {{3}} is attached as a PDF.',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text: [['credit', 'CS Mutual', 'receipt']],
|
||||
},
|
||||
},
|
||||
],
|
||||
parameter_format: 'POSITIONAL',
|
||||
},
|
||||
{
|
||||
id: '1469258364071127',
|
||||
name: 'discount_coupon',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
text: '<� Special offer for you! Get {{discount_percentage}}% off your next purchase. Use the code below at checkout',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: '30',
|
||||
param_name: 'discount_percentage',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Copy offer code',
|
||||
type: 'COPY_CODE',
|
||||
example: ['SAVE1OFF'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
{
|
||||
id: '1075221534579807',
|
||||
name: 'support_callback',
|
||||
status: 'APPROVED',
|
||||
category: 'UTILITY',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
text: 'Hello {{name}}, our support team will call you regarding ticket # {{ticket_id}}.',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: 'muhsin',
|
||||
param_name: 'name',
|
||||
},
|
||||
{
|
||||
example: '232323',
|
||||
param_name: 'ticket_id',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Call Support',
|
||||
type: 'PHONE_NUMBER',
|
||||
phone_number: '+16506677566',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
{
|
||||
id: '1023596726651144',
|
||||
name: 'training_video',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
type: 'HEADER',
|
||||
format: 'VIDEO',
|
||||
example: {
|
||||
header_handle: [
|
||||
'https://scontent.whatsapp.net/v/t61.29466-34/521582686_1023596729984477_1872358575355618432_n.mp4',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "Hi {{name}}, here's your training video. Please watch by{{date}}.",
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: 'john',
|
||||
param_name: 'name',
|
||||
},
|
||||
{
|
||||
example: 'July 31',
|
||||
param_name: 'date',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
{
|
||||
id: '1106685194739985',
|
||||
name: 'order_confirmation',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
type: 'HEADER',
|
||||
format: 'IMAGE',
|
||||
example: {
|
||||
header_handle: ['https://vite-five-phi.vercel.app/vaporfly.png'],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Hi your order {{1}} is confirmed. Please wait for further updates',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text: [['blue canvas shoes']],
|
||||
},
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'POSITIONAL',
|
||||
},
|
||||
{
|
||||
id: '1242180011253003',
|
||||
name: 'product_launch',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
type: 'HEADER',
|
||||
format: 'IMAGE',
|
||||
example: {
|
||||
header_handle: ['https://vite-five-phi.vercel.app/coat.png'],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'New arrival! Our stunning coat now available in {{color}} color.',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: 'blue',
|
||||
param_name: 'color',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Free shipping on orders over $100. Limited time offer.',
|
||||
type: 'FOOTER',
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
{
|
||||
id: '1449876326175680',
|
||||
name: 'technician_visit',
|
||||
status: 'APPROVED',
|
||||
category: 'UTILITY',
|
||||
language: 'en_US',
|
||||
components: [
|
||||
{
|
||||
text: 'Technician visit',
|
||||
type: 'HEADER',
|
||||
format: 'TEXT',
|
||||
},
|
||||
{
|
||||
text: "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.",
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text: [
|
||||
['John', '123 Maple St', '2025-12-31', '10:00 AM', '2:00 PM'],
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Confirm',
|
||||
type: 'QUICK_REPLY',
|
||||
},
|
||||
{
|
||||
text: 'Reschedule',
|
||||
type: 'QUICK_REPLY',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
parameter_format: 'POSITIONAL',
|
||||
},
|
||||
{
|
||||
id: '997298832221901',
|
||||
name: 'greet',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
text: 'Hey {{customer_name}} how may I help you?',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: 'John',
|
||||
param_name: 'customer_name',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
{
|
||||
id: '632315222954611',
|
||||
name: 'hello_world',
|
||||
status: 'APPROVED',
|
||||
category: 'UTILITY',
|
||||
language: 'en_US',
|
||||
components: [
|
||||
{
|
||||
text: 'Hello World',
|
||||
type: 'HEADER',
|
||||
format: 'TEXT',
|
||||
},
|
||||
{
|
||||
text: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
|
||||
type: 'BODY',
|
||||
},
|
||||
{
|
||||
text: 'WhatsApp Business Platform sample message',
|
||||
type: 'FOOTER',
|
||||
},
|
||||
],
|
||||
parameter_format: 'POSITIONAL',
|
||||
},
|
||||
{
|
||||
id: '787864066907971',
|
||||
name: 'feedback_request',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
text: "Hey {{name}}, how was your experience with Puma? We'd love your feedback!",
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: 'muhsin',
|
||||
param_name: 'name',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
url: 'https://feedback.example.com/survey',
|
||||
text: 'Leave Feedback',
|
||||
type: 'URL',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
{
|
||||
id: '1938057163677205',
|
||||
name: 'address_update',
|
||||
status: 'APPROVED',
|
||||
category: 'UTILITY',
|
||||
language: 'en_US',
|
||||
components: [
|
||||
{
|
||||
text: 'Address update',
|
||||
type: 'HEADER',
|
||||
format: 'TEXT',
|
||||
},
|
||||
{
|
||||
text: 'Hi {{1}}, your delivery address has been successfully updated to {{2}}. Contact {{3}} for any inquiries.',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text: [['John', '123 Main St', 'support@telco.com']],
|
||||
},
|
||||
},
|
||||
],
|
||||
parameter_format: 'POSITIONAL',
|
||||
},
|
||||
{
|
||||
id: '1644094842949394',
|
||||
name: 'delivery_confirmation',
|
||||
status: 'APPROVED',
|
||||
category: 'UTILITY',
|
||||
language: 'en_US',
|
||||
components: [
|
||||
{
|
||||
text: '{{1}}, your order was successfully delivered on {{2}}.\\n\\nThank you for your purchase.\\n',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text: [['John', 'Jan 1, 2024']],
|
||||
},
|
||||
},
|
||||
],
|
||||
parameter_format: 'POSITIONAL',
|
||||
},
|
||||
];
|
||||
|
||||
// Helper function to get variable values from examples
|
||||
export const getWhatsAppVariables = template => {
|
||||
const variables = {};
|
||||
template.components?.forEach(component => {
|
||||
if (component.example?.body_text_named_params) {
|
||||
component.example.body_text_named_params.forEach(param => {
|
||||
variables[param.param_name] = param.example;
|
||||
});
|
||||
}
|
||||
if (component.example?.body_text) {
|
||||
component.example.body_text[0]?.forEach((value, index) => {
|
||||
variables[(index + 1).toString()] = value;
|
||||
});
|
||||
}
|
||||
});
|
||||
return variables;
|
||||
};
|
||||
@@ -118,6 +118,14 @@ export default {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showQuotedReplyToggle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
quotedReplyEnabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: [
|
||||
'replaceText',
|
||||
@@ -125,6 +133,7 @@ export default {
|
||||
'toggleEditor',
|
||||
'selectWhatsappTemplate',
|
||||
'selectContentTemplate',
|
||||
'toggleQuotedReply',
|
||||
],
|
||||
setup() {
|
||||
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
|
||||
@@ -249,6 +258,11 @@ export default {
|
||||
isFetchingAppIntegrations() {
|
||||
return this.uiFlags.isFetching;
|
||||
},
|
||||
quotedReplyToggleTooltip() {
|
||||
return this.quotedReplyEnabled
|
||||
? this.$t('CONVERSATION.REPLYBOX.QUOTED_REPLY.DISABLE_TOOLTIP')
|
||||
: this.$t('CONVERSATION.REPLYBOX.QUOTED_REPLY.ENABLE_TOOLTIP');
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
ActiveStorage.start();
|
||||
@@ -339,6 +353,16 @@ export default {
|
||||
sm
|
||||
@click="toggleMessageSignature"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="showQuotedReplyToggle"
|
||||
v-tooltip.top-end="quotedReplyToggleTooltip"
|
||||
icon="i-ph-quotes"
|
||||
:variant="quotedReplyEnabled ? 'solid' : 'faded'"
|
||||
color="slate"
|
||||
sm
|
||||
:aria-pressed="quotedReplyEnabled"
|
||||
@click="$emit('toggleQuotedReply')"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="enableWhatsAppTemplates"
|
||||
v-tooltip.top-end="$t('CONVERSATION.FOOTER.WHATSAPP_TEMPLATES')"
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
quotedEmailText: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
previewText: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['toggle']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
|
||||
const isExpanded = ref(false);
|
||||
|
||||
const formattedQuotedEmailText = computed(() => {
|
||||
if (!props.quotedEmailText) {
|
||||
return '';
|
||||
}
|
||||
return formatMessage(props.quotedEmailText, false, false, true);
|
||||
});
|
||||
|
||||
const toggleExpand = () => {
|
||||
isExpanded.value = !isExpanded.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-2">
|
||||
<div
|
||||
class="relative rounded-md px-3 py-2 text-xs text-n-slate-12 bg-n-slate-3 dark:bg-n-solid-3"
|
||||
>
|
||||
<div class="absolute top-2 right-2 z-10 flex items-center gap-1">
|
||||
<NextButton
|
||||
v-tooltip="
|
||||
isExpanded
|
||||
? t('CONVERSATION.REPLYBOX.QUOTED_REPLY.COLLAPSE')
|
||||
: t('CONVERSATION.REPLYBOX.QUOTED_REPLY.EXPAND')
|
||||
"
|
||||
ghost
|
||||
slate
|
||||
xs
|
||||
:icon="isExpanded ? 'i-lucide-minimize' : 'i-lucide-maximize'"
|
||||
@click="toggleExpand"
|
||||
/>
|
||||
<NextButton
|
||||
v-tooltip="t('CONVERSATION.REPLYBOX.QUOTED_REPLY.REMOVE_PREVIEW')"
|
||||
ghost
|
||||
slate
|
||||
xs
|
||||
icon="i-lucide-x"
|
||||
@click="emit('toggle')"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-dompurify-html="formattedQuotedEmailText"
|
||||
class="w-full max-w-none break-words prose prose-sm dark:prose-invert cursor-pointer ltr:pr-8 rtl:pl-8"
|
||||
:class="{
|
||||
'line-clamp-1': !isExpanded,
|
||||
'max-h-60 overflow-y-auto': isExpanded,
|
||||
}"
|
||||
:title="previewText"
|
||||
@click="toggleExpand"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -5,6 +5,7 @@ import { useAlert } from 'dashboard/composables';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
import CannedResponse from './CannedResponse.vue';
|
||||
import ReplyToMessage from './ReplyToMessage.vue';
|
||||
@@ -16,6 +17,7 @@ import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBotto
|
||||
import ArticleSearchPopover from 'dashboard/routes/dashboard/helpcenter/components/ArticleSearch/SearchPopover.vue';
|
||||
import MessageSignatureMissingAlert from './MessageSignatureMissingAlert.vue';
|
||||
import ReplyBoxBanner from './ReplyBoxBanner.vue';
|
||||
import QuotedEmailPreview from './QuotedEmailPreview.vue';
|
||||
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||
import AudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder.vue';
|
||||
@@ -32,6 +34,12 @@ import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
|
||||
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
|
||||
import { trimContent, debounce, getRecipients } from '@chatwoot/utils';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import {
|
||||
extractQuotedEmailText,
|
||||
buildQuotedEmailHeader,
|
||||
truncatePreviewText,
|
||||
appendQuotedTextToMessage,
|
||||
} from 'dashboard/helper/quotedEmailHelper';
|
||||
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
||||
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
|
||||
import {
|
||||
@@ -65,6 +73,7 @@ export default {
|
||||
ContentTemplates,
|
||||
WhatsappTemplates,
|
||||
WootMessageEditor,
|
||||
QuotedEmailPreview,
|
||||
},
|
||||
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
|
||||
props: {
|
||||
@@ -80,6 +89,8 @@ export default {
|
||||
updateUISettings,
|
||||
isEditorHotKeyEnabled,
|
||||
fetchSignatureFlagFromUISettings,
|
||||
setQuotedReplyFlagForInbox,
|
||||
fetchQuotedReplyFlagFromUISettings,
|
||||
} = useUISettings();
|
||||
|
||||
const replyEditor = useTemplateRef('replyEditor');
|
||||
@@ -89,6 +100,8 @@ export default {
|
||||
updateUISettings,
|
||||
isEditorHotKeyEnabled,
|
||||
fetchSignatureFlagFromUISettings,
|
||||
setQuotedReplyFlagForInbox,
|
||||
fetchQuotedReplyFlagFromUISettings,
|
||||
replyEditor,
|
||||
};
|
||||
},
|
||||
@@ -130,6 +143,8 @@ export default {
|
||||
currentUser: 'getCurrentUser',
|
||||
lastEmail: 'getLastEmailInSelectedChat',
|
||||
globalConfig: 'globalConfig/get',
|
||||
accountId: 'getCurrentAccountId',
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
}),
|
||||
currentContact() {
|
||||
return this.$store.getters['contacts/getContact'](
|
||||
@@ -367,6 +382,51 @@ export default {
|
||||
const { slug = '' } = portal;
|
||||
return slug;
|
||||
},
|
||||
isQuotedEmailReplyEnabled() {
|
||||
return this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.QUOTED_EMAIL_REPLY
|
||||
);
|
||||
},
|
||||
quotedReplyPreference() {
|
||||
if (!this.isAnEmailChannel || !this.isQuotedEmailReplyEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!this.fetchQuotedReplyFlagFromUISettings(this.channelType);
|
||||
},
|
||||
lastEmailWithQuotedContent() {
|
||||
if (!this.isAnEmailChannel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lastEmail = this.lastEmail;
|
||||
if (!lastEmail || lastEmail.private) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return lastEmail;
|
||||
},
|
||||
quotedEmailText() {
|
||||
return extractQuotedEmailText(this.lastEmailWithQuotedContent);
|
||||
},
|
||||
quotedEmailPreviewText() {
|
||||
return truncatePreviewText(this.quotedEmailText, 80);
|
||||
},
|
||||
shouldShowQuotedReplyToggle() {
|
||||
return (
|
||||
this.isAnEmailChannel &&
|
||||
!this.isOnPrivateNote &&
|
||||
this.isQuotedEmailReplyEnabled
|
||||
);
|
||||
},
|
||||
shouldShowQuotedPreview() {
|
||||
return (
|
||||
this.shouldShowQuotedReplyToggle &&
|
||||
this.quotedReplyPreference &&
|
||||
!!this.quotedEmailText
|
||||
);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
currentChat(conversation, oldConversation) {
|
||||
@@ -516,6 +576,36 @@ export default {
|
||||
);
|
||||
}
|
||||
},
|
||||
toggleQuotedReply() {
|
||||
if (!this.isAnEmailChannel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextValue = !this.quotedReplyPreference;
|
||||
this.setQuotedReplyFlagForInbox(this.channelType, nextValue);
|
||||
},
|
||||
shouldIncludeQuotedEmail() {
|
||||
return (
|
||||
this.isQuotedEmailReplyEnabled &&
|
||||
this.quotedReplyPreference &&
|
||||
this.shouldShowQuotedReplyToggle &&
|
||||
!!this.quotedEmailText
|
||||
);
|
||||
},
|
||||
getMessageWithQuotedEmailText(message) {
|
||||
if (!this.shouldIncludeQuotedEmail()) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const quotedText = this.quotedEmailText || '';
|
||||
const header = buildQuotedEmailHeader(
|
||||
this.lastEmailWithQuotedContent,
|
||||
this.currentContact,
|
||||
this.inbox
|
||||
);
|
||||
|
||||
return appendQuotedTextToMessage(message, quotedText, header);
|
||||
},
|
||||
resetRecorderAndClearAttachments() {
|
||||
// Reset audio recorder UI state
|
||||
this.resetAudioRecorderInput();
|
||||
@@ -965,9 +1055,11 @@ export default {
|
||||
return multipleMessagePayload;
|
||||
},
|
||||
getMessagePayload(message) {
|
||||
const messageWithQuote = this.getMessageWithQuotedEmailText(message);
|
||||
|
||||
let messagePayload = {
|
||||
conversationId: this.currentChat.id,
|
||||
message,
|
||||
message: messageWithQuote,
|
||||
private: this.isPrivate,
|
||||
sender: this.sender,
|
||||
};
|
||||
@@ -995,7 +1087,6 @@ export default {
|
||||
if (this.toEmails && !this.isOnPrivateNote) {
|
||||
messagePayload.toEmails = this.toEmails;
|
||||
}
|
||||
|
||||
return messagePayload;
|
||||
},
|
||||
setCcEmails(value) {
|
||||
@@ -1160,6 +1251,12 @@ export default {
|
||||
@toggle-variables-menu="toggleVariablesMenu"
|
||||
@clear-selection="clearEditorSelection"
|
||||
/>
|
||||
<QuotedEmailPreview
|
||||
v-if="shouldShowQuotedPreview"
|
||||
:quoted-email-text="quotedEmailText"
|
||||
:preview-text="quotedEmailPreviewText"
|
||||
@toggle="toggleQuotedReply"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="hasAttachments && !showAudioRecorderEditor"
|
||||
@@ -1195,6 +1292,8 @@ export default {
|
||||
:show-editor-toggle="isAPIInbox && !isOnPrivateNote"
|
||||
:show-emoji-picker="showEmojiPicker"
|
||||
:show-file-upload="showFileUpload"
|
||||
:show-quoted-reply-toggle="shouldShowQuotedReplyToggle"
|
||||
:quoted-reply-enabled="quotedReplyPreference"
|
||||
:toggle-audio-recorder-play-pause="toggleAudioRecorderPlayPause"
|
||||
:toggle-audio-recorder="toggleAudioRecorder"
|
||||
:toggle-emoji-picker="toggleEmojiPicker"
|
||||
@@ -1206,6 +1305,7 @@ export default {
|
||||
@toggle-editor="toggleRichContentEditor"
|
||||
@replace-text="replaceText"
|
||||
@toggle-insert-article="toggleInsertArticle"
|
||||
@toggle-quoted-reply="toggleQuotedReply"
|
||||
/>
|
||||
<WhatsappTemplates
|
||||
:inbox-id="inbox.id"
|
||||
|
||||
@@ -13,6 +13,7 @@ const getUISettingsMock = ref({
|
||||
conversation_sidebar_items_order: DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
|
||||
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
|
||||
editor_message_key: 'enter',
|
||||
channel_email_quoted_reply_enabled: true,
|
||||
});
|
||||
|
||||
vi.mock('dashboard/composables/store', () => ({
|
||||
@@ -37,6 +38,7 @@ describe('useUISettings', () => {
|
||||
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
|
||||
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
|
||||
editor_message_key: 'enter',
|
||||
channel_email_quoted_reply_enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,6 +53,7 @@ describe('useUISettings', () => {
|
||||
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
|
||||
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
|
||||
editor_message_key: 'enter',
|
||||
channel_email_quoted_reply_enabled: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -65,6 +68,7 @@ describe('useUISettings', () => {
|
||||
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
|
||||
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
|
||||
editor_message_key: 'enter',
|
||||
channel_email_quoted_reply_enabled: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -100,6 +104,7 @@ describe('useUISettings', () => {
|
||||
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
|
||||
email_signature_enabled: true,
|
||||
editor_message_key: 'enter',
|
||||
channel_email_quoted_reply_enabled: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -109,6 +114,26 @@ describe('useUISettings', () => {
|
||||
expect(fetchSignatureFlagFromUISettings('email')).toBe(undefined);
|
||||
});
|
||||
|
||||
it('sets quoted reply flag for inbox correctly', () => {
|
||||
const { setQuotedReplyFlagForInbox } = useUISettings();
|
||||
setQuotedReplyFlagForInbox('Channel::Email', false);
|
||||
expect(mockDispatch).toHaveBeenCalledWith('updateUISettings', {
|
||||
uiSettings: {
|
||||
is_ct_labels_open: true,
|
||||
conversation_sidebar_items_order:
|
||||
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
|
||||
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
|
||||
editor_message_key: 'enter',
|
||||
channel_email_quoted_reply_enabled: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('fetches quoted reply flag from UI settings correctly', () => {
|
||||
const { fetchQuotedReplyFlagFromUISettings } = useUISettings();
|
||||
expect(fetchQuotedReplyFlagFromUISettings('Channel::Email')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns correct value for isEditorHotKeyEnabled when editor_message_key is configured', () => {
|
||||
getUISettingsMock.value.enter_to_send_enabled = false;
|
||||
const { isEditorHotKeyEnabled } = useUISettings();
|
||||
|
||||
@@ -87,6 +87,13 @@ const setSignatureFlagForInbox = (channelType, value, updateUISettings) => {
|
||||
updateUISettings({ [`${slugifiedChannel}_signature_enabled`]: value });
|
||||
};
|
||||
|
||||
const setQuotedReplyFlagForInbox = (channelType, value, updateUISettings) => {
|
||||
if (!channelType) return;
|
||||
|
||||
const slugifiedChannel = slugifyChannel(channelType);
|
||||
updateUISettings({ [`${slugifiedChannel}_quoted_reply_enabled`]: value });
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches the signature flag for a specific channel type from UI settings.
|
||||
* @param {string} channelType - The type of the channel.
|
||||
@@ -100,6 +107,13 @@ const fetchSignatureFlagFromUISettings = (channelType, uiSettings) => {
|
||||
return uiSettings.value[`${slugifiedChannel}_signature_enabled`];
|
||||
};
|
||||
|
||||
const fetchQuotedReplyFlagFromUISettings = (channelType, uiSettings) => {
|
||||
if (!channelType) return false;
|
||||
|
||||
const slugifiedChannel = slugifyChannel(channelType);
|
||||
return uiSettings.value[`${slugifiedChannel}_quoted_reply_enabled`];
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a specific editor hotkey is enabled.
|
||||
* @param {string} key - The key to check.
|
||||
@@ -147,6 +161,10 @@ export function useUISettings() {
|
||||
setSignatureFlagForInbox(channelType, value, updateUISettings),
|
||||
fetchSignatureFlagFromUISettings: channelType =>
|
||||
fetchSignatureFlagFromUISettings(channelType, uiSettings),
|
||||
setQuotedReplyFlagForInbox: (channelType, value) =>
|
||||
setQuotedReplyFlagForInbox(channelType, value, updateUISettings),
|
||||
fetchQuotedReplyFlagFromUISettings: channelType =>
|
||||
fetchQuotedReplyFlagFromUISettings(channelType, uiSettings),
|
||||
isEditorHotKeyEnabled: key => isEditorHotKeyEnabled(key, uiSettings),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ export const FEATURE_FLAGS = {
|
||||
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
|
||||
CAPTAIN_V2: 'captain_integration_v2',
|
||||
SAML: 'saml',
|
||||
QUOTED_EMAIL_REPLY: 'quoted_email_reply',
|
||||
};
|
||||
|
||||
export const PREMIUM_FEATURES = [
|
||||
@@ -48,6 +49,5 @@ export const PREMIUM_FEATURES = [
|
||||
FEATURE_FLAGS.CUSTOM_ROLES,
|
||||
FEATURE_FLAGS.AUDIT_LOGS,
|
||||
FEATURE_FLAGS.HELP_CENTER,
|
||||
FEATURE_FLAGS.CAPTAIN_V2,
|
||||
FEATURE_FLAGS.SAML,
|
||||
];
|
||||
|
||||
+30
@@ -10,6 +10,8 @@ const QUOTE_INDICATORS = [
|
||||
'[class*="Quote"]',
|
||||
];
|
||||
|
||||
const BLOCKQUOTE_FALLBACK_SELECTOR = 'blockquote';
|
||||
|
||||
// Regex patterns for quote identification
|
||||
const QUOTE_PATTERNS = [
|
||||
/On .* wrote:/i,
|
||||
@@ -36,6 +38,8 @@ export class EmailQuoteExtractor {
|
||||
});
|
||||
});
|
||||
|
||||
this.removeTrailingBlockquote(tempDiv);
|
||||
|
||||
// Remove text-based quotes
|
||||
const textNodeQuotes = this.findTextNodeQuotes(tempDiv);
|
||||
textNodeQuotes.forEach(el => {
|
||||
@@ -62,6 +66,10 @@ export class EmailQuoteExtractor {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.findTrailingBlockquote(tempDiv)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for text-based quotes
|
||||
const textNodeQuotes = this.findTextNodeQuotes(tempDiv);
|
||||
return textNodeQuotes.length > 0;
|
||||
@@ -123,4 +131,26 @@ export class EmailQuoteExtractor {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove fallback blockquote if it is the last top-level element.
|
||||
* @param {Element} rootElement - Root element containing the HTML
|
||||
*/
|
||||
static removeTrailingBlockquote(rootElement) {
|
||||
const trailingBlockquote = this.findTrailingBlockquote(rootElement);
|
||||
trailingBlockquote?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate a fallback blockquote that is the last top-level element.
|
||||
* @param {Element} rootElement - Root element containing the HTML
|
||||
* @returns {Element|null} The trailing blockquote element if present
|
||||
*/
|
||||
static findTrailingBlockquote(rootElement) {
|
||||
const lastElement = rootElement.lastElementChild;
|
||||
if (lastElement?.matches?.(BLOCKQUOTE_FALLBACK_SELECTOR)) {
|
||||
return lastElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -68,13 +68,17 @@ export const registerSubscription = (onSuccess = () => {}) => {
|
||||
.then(() => {
|
||||
onSuccess();
|
||||
})
|
||||
.catch(() => {
|
||||
.catch(error => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Push subscription registration failed:', error);
|
||||
useAlert('This browser does not support desktop notification');
|
||||
});
|
||||
};
|
||||
|
||||
export const requestPushPermissions = ({ onSuccess }) => {
|
||||
if (!('Notification' in window)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('Notification is not supported');
|
||||
useAlert('This browser does not support desktop notification');
|
||||
} else if (Notification.permission === 'granted') {
|
||||
registerSubscription(onSuccess);
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
import { format, parseISO, isValid as isValidDate } from 'date-fns';
|
||||
|
||||
/**
|
||||
* Extracts plain text from HTML content
|
||||
* @param {string} html - HTML content to convert
|
||||
* @returns {string} Plain text content
|
||||
*/
|
||||
export const extractPlainTextFromHtml = html => {
|
||||
if (!html) {
|
||||
return '';
|
||||
}
|
||||
if (typeof document === 'undefined') {
|
||||
return html.replace(/<[^>]*>/g, ' ');
|
||||
}
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = html;
|
||||
return tempDiv.textContent || tempDiv.innerText || '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts sender name from email message
|
||||
* @param {Object} lastEmail - Last email message object
|
||||
* @param {Object} contact - Contact object
|
||||
* @returns {string} Sender name
|
||||
*/
|
||||
export const getEmailSenderName = (lastEmail, contact) => {
|
||||
const senderName = lastEmail?.sender?.name;
|
||||
if (senderName && senderName.trim()) {
|
||||
return senderName.trim();
|
||||
}
|
||||
|
||||
const contactName = contact?.name;
|
||||
return contactName && contactName.trim() ? contactName.trim() : '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts sender email from email message
|
||||
* @param {Object} lastEmail - Last email message object
|
||||
* @param {Object} contact - Contact object
|
||||
* @returns {string} Sender email address
|
||||
*/
|
||||
export const getEmailSenderEmail = (lastEmail, contact) => {
|
||||
const senderEmail = lastEmail?.sender?.email;
|
||||
if (senderEmail && senderEmail.trim()) {
|
||||
return senderEmail.trim();
|
||||
}
|
||||
|
||||
const contentAttributes =
|
||||
lastEmail?.contentAttributes || lastEmail?.content_attributes || {};
|
||||
const emailMeta = contentAttributes.email || {};
|
||||
|
||||
if (Array.isArray(emailMeta.from) && emailMeta.from.length > 0) {
|
||||
const fromAddress = emailMeta.from[0];
|
||||
if (fromAddress && fromAddress.trim()) {
|
||||
return fromAddress.trim();
|
||||
}
|
||||
}
|
||||
|
||||
const contactEmail = contact?.email;
|
||||
return contactEmail && contactEmail.trim() ? contactEmail.trim() : '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts date from email message
|
||||
* @param {Object} lastEmail - Last email message object
|
||||
* @returns {Date|null} Email date
|
||||
*/
|
||||
export const getEmailDate = lastEmail => {
|
||||
const contentAttributes =
|
||||
lastEmail?.contentAttributes || lastEmail?.content_attributes || {};
|
||||
const emailMeta = contentAttributes.email || {};
|
||||
|
||||
if (emailMeta.date) {
|
||||
const parsedDate = parseISO(emailMeta.date);
|
||||
if (isValidDate(parsedDate)) {
|
||||
return parsedDate;
|
||||
}
|
||||
}
|
||||
|
||||
const createdAt = lastEmail?.created_at;
|
||||
if (createdAt) {
|
||||
const timestamp = Number(createdAt);
|
||||
if (!Number.isNaN(timestamp)) {
|
||||
const milliseconds = timestamp > 1e12 ? timestamp : timestamp * 1000;
|
||||
const derivedDate = new Date(milliseconds);
|
||||
if (!Number.isNaN(derivedDate.getTime())) {
|
||||
return derivedDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats date for quoted email header
|
||||
* @param {Date} date - Date to format
|
||||
* @returns {string} Formatted date string
|
||||
*/
|
||||
export const formatQuotedEmailDate = date => {
|
||||
try {
|
||||
return format(date, "EEE, MMM d, yyyy 'at' p");
|
||||
} catch (error) {
|
||||
const fallbackDate = new Date(date);
|
||||
if (!Number.isNaN(fallbackDate.getTime())) {
|
||||
return format(fallbackDate, "EEE, MMM d, yyyy 'at' p");
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts inbox email address from last email message
|
||||
* @param {Object} lastEmail - Last email message object
|
||||
* @param {Object} inbox - Inbox object
|
||||
* @returns {string} Inbox email address
|
||||
*/
|
||||
export const getInboxEmail = (lastEmail, inbox) => {
|
||||
const contentAttributes =
|
||||
lastEmail?.contentAttributes || lastEmail?.content_attributes || {};
|
||||
const emailMeta = contentAttributes.email || {};
|
||||
|
||||
if (Array.isArray(emailMeta.to) && emailMeta.to.length > 0) {
|
||||
const toAddress = emailMeta.to[0];
|
||||
if (toAddress && toAddress.trim()) {
|
||||
return toAddress.trim();
|
||||
}
|
||||
}
|
||||
|
||||
const inboxEmail = inbox?.email;
|
||||
return inboxEmail && inboxEmail.trim() ? inboxEmail.trim() : '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds quoted email header from contact (for incoming messages)
|
||||
* @param {Object} lastEmail - Last email message object
|
||||
* @param {Object} contact - Contact object
|
||||
* @returns {string} Formatted header string
|
||||
*/
|
||||
export const buildQuotedEmailHeaderFromContact = (lastEmail, contact) => {
|
||||
if (!lastEmail) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const quotedDate = getEmailDate(lastEmail);
|
||||
const senderEmail = getEmailSenderEmail(lastEmail, contact);
|
||||
|
||||
if (!quotedDate || !senderEmail) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const formattedDate = formatQuotedEmailDate(quotedDate);
|
||||
if (!formattedDate) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const senderName = getEmailSenderName(lastEmail, contact);
|
||||
const hasName = !!senderName;
|
||||
const contactLabel = hasName
|
||||
? `${senderName} <${senderEmail}>`
|
||||
: `<${senderEmail}>`;
|
||||
|
||||
return `On ${formattedDate} ${contactLabel} wrote:`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds quoted email header from inbox (for outgoing messages)
|
||||
* @param {Object} lastEmail - Last email message object
|
||||
* @param {Object} inbox - Inbox object
|
||||
* @returns {string} Formatted header string
|
||||
*/
|
||||
export const buildQuotedEmailHeaderFromInbox = (lastEmail, inbox) => {
|
||||
if (!lastEmail) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const quotedDate = getEmailDate(lastEmail);
|
||||
const inboxEmail = getInboxEmail(lastEmail, inbox);
|
||||
|
||||
if (!quotedDate || !inboxEmail) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const formattedDate = formatQuotedEmailDate(quotedDate);
|
||||
if (!formattedDate) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const inboxName = inbox?.name;
|
||||
const hasName = !!inboxName;
|
||||
const inboxLabel = hasName
|
||||
? `${inboxName} <${inboxEmail}>`
|
||||
: `<${inboxEmail}>`;
|
||||
|
||||
return `On ${formattedDate} ${inboxLabel} wrote:`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds quoted email header based on message type
|
||||
* @param {Object} lastEmail - Last email message object
|
||||
* @param {Object} contact - Contact object
|
||||
* @param {Object} inbox - Inbox object
|
||||
* @returns {string} Formatted header string
|
||||
*/
|
||||
export const buildQuotedEmailHeader = (lastEmail, contact, inbox) => {
|
||||
if (!lastEmail) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// MESSAGE_TYPE.OUTGOING = 1, MESSAGE_TYPE.INCOMING = 0
|
||||
const isOutgoing = lastEmail.message_type === 1;
|
||||
|
||||
if (isOutgoing) {
|
||||
return buildQuotedEmailHeaderFromInbox(lastEmail, inbox);
|
||||
}
|
||||
|
||||
return buildQuotedEmailHeaderFromContact(lastEmail, contact);
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats text as markdown blockquote
|
||||
* @param {string} text - Text to format
|
||||
* @param {string} header - Optional header to prepend
|
||||
* @returns {string} Formatted blockquote
|
||||
*/
|
||||
export const formatQuotedTextAsBlockquote = (text, header = '') => {
|
||||
const normalizedLines = text
|
||||
? String(text).replace(/\r\n/g, '\n').split('\n')
|
||||
: [];
|
||||
|
||||
if (!header && !normalizedLines.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const quotedLines = [];
|
||||
|
||||
if (header) {
|
||||
quotedLines.push(`> ${header}`);
|
||||
quotedLines.push('>');
|
||||
}
|
||||
|
||||
normalizedLines.forEach(line => {
|
||||
const trimmedLine = line.trimEnd();
|
||||
quotedLines.push(trimmedLine ? `> ${trimmedLine}` : '>');
|
||||
});
|
||||
|
||||
return quotedLines.join('\n');
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts quoted email text from last email message
|
||||
* @param {Object} lastEmail - Last email message object
|
||||
* @returns {string} Quoted email text
|
||||
*/
|
||||
export const extractQuotedEmailText = lastEmail => {
|
||||
if (!lastEmail) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const contentAttributes =
|
||||
lastEmail.contentAttributes || lastEmail.content_attributes || {};
|
||||
const emailContent = contentAttributes.email || {};
|
||||
const textContent = emailContent.textContent || emailContent.text_content;
|
||||
|
||||
if (textContent?.reply) {
|
||||
return textContent.reply;
|
||||
}
|
||||
if (textContent?.full) {
|
||||
return textContent.full;
|
||||
}
|
||||
|
||||
const htmlContent = emailContent.htmlContent || emailContent.html_content;
|
||||
if (htmlContent?.reply) {
|
||||
return extractPlainTextFromHtml(htmlContent.reply);
|
||||
}
|
||||
if (htmlContent?.full) {
|
||||
return extractPlainTextFromHtml(htmlContent.full);
|
||||
}
|
||||
|
||||
const fallbackContent =
|
||||
lastEmail.content || lastEmail.processed_message_content || '';
|
||||
|
||||
return fallbackContent;
|
||||
};
|
||||
|
||||
/**
|
||||
* Truncates text for preview display
|
||||
* @param {string} text - Text to truncate
|
||||
* @param {number} maxLength - Maximum length (default: 80)
|
||||
* @returns {string} Truncated text
|
||||
*/
|
||||
export const truncatePreviewText = (text, maxLength = 80) => {
|
||||
const preview = text.trim().replace(/\s+/g, ' ');
|
||||
if (!preview) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (preview.length <= maxLength) {
|
||||
return preview;
|
||||
}
|
||||
return `${preview.slice(0, maxLength - 3)}...`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Appends quoted text to message
|
||||
* @param {string} message - Original message
|
||||
* @param {string} quotedText - Text to quote
|
||||
* @param {string} header - Quote header
|
||||
* @returns {string} Message with quoted text appended
|
||||
*/
|
||||
export const appendQuotedTextToMessage = (message, quotedText, header) => {
|
||||
const baseMessage = message ? String(message) : '';
|
||||
const quotedBlock = formatQuotedTextAsBlockquote(quotedText, header);
|
||||
|
||||
if (!quotedBlock) {
|
||||
return baseMessage;
|
||||
}
|
||||
|
||||
if (!baseMessage) {
|
||||
return quotedBlock;
|
||||
}
|
||||
|
||||
let separator = '\n\n';
|
||||
if (baseMessage.endsWith('\n\n')) {
|
||||
separator = '';
|
||||
} else if (baseMessage.endsWith('\n')) {
|
||||
separator = '\n';
|
||||
}
|
||||
|
||||
return `${baseMessage}${separator}${quotedBlock}`;
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { EmailQuoteExtractor } from '../emailQuoteExtractor.js';
|
||||
|
||||
const SAMPLE_EMAIL_HTML = `
|
||||
<p>method</p>
|
||||
<blockquote>
|
||||
<p>On Mon, Sep 29, 2025 at 5:18 PM John <a href="mailto:shivam@chatwoot.com">shivam@chatwoot.com</a> wrote:</p>
|
||||
<p>Hi</p>
|
||||
<blockquote>
|
||||
<p>On Mon, Sep 29, 2025 at 5:17 PM Shivam Mishra <a href="mailto:shivam@chatwoot.com">shivam@chatwoot.com</a> wrote:</p>
|
||||
<p>Yes, it is.</p>
|
||||
<p>On Mon, Sep 29, 2025 at 5:16 PM John from Shaneforwoot < shaneforwoot@gmail.com> wrote:</p>
|
||||
<blockquote>
|
||||
<p>Hey</p>
|
||||
<p>On Mon, Sep 29, 2025 at 4:59 PM John shivam@chatwoot.com wrote:</p>
|
||||
<p>This is another quoted quoted text reply</p>
|
||||
<p>This is nice</p>
|
||||
<p>On Mon, Sep 29, 2025 at 4:21 PM John from Shaneforwoot < > shaneforwoot@gmail.com> wrote:</p>
|
||||
<p>Hey there, this is a reply from Chatwoot, notice the quoted text</p>
|
||||
<p>Hey there</p>
|
||||
<p>This is an email text, enjoy reading this</p>
|
||||
<p>-- Shivam Mishra, Chatwoot</p>
|
||||
</blockquote>
|
||||
</blockquote>
|
||||
</blockquote>
|
||||
`;
|
||||
|
||||
const EMAIL_WITH_SIGNATURE = `
|
||||
<p>Latest reply here.</p>
|
||||
<p>Thanks,</p>
|
||||
<p>Jane Doe</p>
|
||||
<blockquote>
|
||||
<p>On Mon, Sep 22, Someone wrote:</p>
|
||||
<p>Previous reply content</p>
|
||||
</blockquote>
|
||||
`;
|
||||
|
||||
const EMAIL_WITH_FOLLOW_UP_CONTENT = `
|
||||
<blockquote>
|
||||
<p>Inline quote that should stay</p>
|
||||
</blockquote>
|
||||
<p>Internal note follows</p>
|
||||
<p>Regards,</p>
|
||||
`;
|
||||
|
||||
describe('EmailQuoteExtractor', () => {
|
||||
it('removes blockquote-based quotes from the email body', () => {
|
||||
const cleanedHtml = EmailQuoteExtractor.extractQuotes(SAMPLE_EMAIL_HTML);
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = cleanedHtml;
|
||||
|
||||
expect(container.querySelectorAll('blockquote').length).toBe(0);
|
||||
expect(container.textContent?.trim()).toBe('method');
|
||||
expect(container.textContent).not.toContain(
|
||||
'On Mon, Sep 29, 2025 at 5:18 PM'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps blockquote fallback when it is not the last top-level element', () => {
|
||||
const cleanedHtml = EmailQuoteExtractor.extractQuotes(
|
||||
EMAIL_WITH_FOLLOW_UP_CONTENT
|
||||
);
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = cleanedHtml;
|
||||
|
||||
expect(container.querySelector('blockquote')).not.toBeNull();
|
||||
expect(container.lastElementChild?.tagName).toBe('P');
|
||||
});
|
||||
|
||||
it('detects quote indicators in nested blockquotes', () => {
|
||||
const result = EmailQuoteExtractor.hasQuotes(SAMPLE_EMAIL_HTML);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('does not flag blockquotes that are followed by other elements', () => {
|
||||
expect(EmailQuoteExtractor.hasQuotes(EMAIL_WITH_FOLLOW_UP_CONTENT)).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false when no quote indicators are present', () => {
|
||||
const html = '<p>Plain content</p>';
|
||||
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(false);
|
||||
});
|
||||
|
||||
it('removes trailing blockquotes while preserving trailing signatures', () => {
|
||||
const cleanedHtml = EmailQuoteExtractor.extractQuotes(EMAIL_WITH_SIGNATURE);
|
||||
|
||||
expect(cleanedHtml).toContain('<p>Thanks,</p>');
|
||||
expect(cleanedHtml).toContain('<p>Jane Doe</p>');
|
||||
expect(cleanedHtml).not.toContain('<blockquote');
|
||||
});
|
||||
|
||||
it('detects quotes for trailing blockquotes even when signatures follow text', () => {
|
||||
expect(EmailQuoteExtractor.hasQuotes(EMAIL_WITH_SIGNATURE)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,441 @@
|
||||
import {
|
||||
extractPlainTextFromHtml,
|
||||
getEmailSenderName,
|
||||
getEmailSenderEmail,
|
||||
getEmailDate,
|
||||
formatQuotedEmailDate,
|
||||
getInboxEmail,
|
||||
buildQuotedEmailHeader,
|
||||
buildQuotedEmailHeaderFromContact,
|
||||
buildQuotedEmailHeaderFromInbox,
|
||||
formatQuotedTextAsBlockquote,
|
||||
extractQuotedEmailText,
|
||||
truncatePreviewText,
|
||||
appendQuotedTextToMessage,
|
||||
} from '../quotedEmailHelper';
|
||||
|
||||
describe('quotedEmailHelper', () => {
|
||||
describe('extractPlainTextFromHtml', () => {
|
||||
it('returns empty string for null or undefined', () => {
|
||||
expect(extractPlainTextFromHtml(null)).toBe('');
|
||||
expect(extractPlainTextFromHtml(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('strips HTML tags and returns plain text', () => {
|
||||
const html = '<p>Hello <strong>world</strong></p>';
|
||||
const result = extractPlainTextFromHtml(html);
|
||||
expect(result).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('handles complex HTML structure', () => {
|
||||
const html = '<div><p>Line 1</p><p>Line 2</p></div>';
|
||||
const result = extractPlainTextFromHtml(html);
|
||||
expect(result).toContain('Line 1');
|
||||
expect(result).toContain('Line 2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEmailSenderName', () => {
|
||||
it('returns sender name from lastEmail', () => {
|
||||
const lastEmail = { sender: { name: 'John Doe' } };
|
||||
const result = getEmailSenderName(lastEmail, {});
|
||||
expect(result).toBe('John Doe');
|
||||
});
|
||||
|
||||
it('returns contact name if sender name not available', () => {
|
||||
const lastEmail = { sender: {} };
|
||||
const contact = { name: 'Jane Smith' };
|
||||
const result = getEmailSenderName(lastEmail, contact);
|
||||
expect(result).toBe('Jane Smith');
|
||||
});
|
||||
|
||||
it('returns empty string if neither available', () => {
|
||||
const result = getEmailSenderName({}, {});
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('trims whitespace from names', () => {
|
||||
const lastEmail = { sender: { name: ' John Doe ' } };
|
||||
const result = getEmailSenderName(lastEmail, {});
|
||||
expect(result).toBe('John Doe');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEmailSenderEmail', () => {
|
||||
it('returns sender email from lastEmail', () => {
|
||||
const lastEmail = { sender: { email: 'john@example.com' } };
|
||||
const result = getEmailSenderEmail(lastEmail, {});
|
||||
expect(result).toBe('john@example.com');
|
||||
});
|
||||
|
||||
it('returns email from contentAttributes if sender email not available', () => {
|
||||
const lastEmail = {
|
||||
contentAttributes: {
|
||||
email: { from: ['jane@example.com'] },
|
||||
},
|
||||
};
|
||||
const result = getEmailSenderEmail(lastEmail, {});
|
||||
expect(result).toBe('jane@example.com');
|
||||
});
|
||||
|
||||
it('returns contact email as fallback', () => {
|
||||
const lastEmail = {};
|
||||
const contact = { email: 'contact@example.com' };
|
||||
const result = getEmailSenderEmail(lastEmail, contact);
|
||||
expect(result).toBe('contact@example.com');
|
||||
});
|
||||
|
||||
it('trims whitespace from emails', () => {
|
||||
const lastEmail = { sender: { email: ' john@example.com ' } };
|
||||
const result = getEmailSenderEmail(lastEmail, {});
|
||||
expect(result).toBe('john@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEmailDate', () => {
|
||||
it('returns parsed date from email metadata', () => {
|
||||
const lastEmail = {
|
||||
contentAttributes: {
|
||||
email: { date: '2024-01-15T10:30:00Z' },
|
||||
},
|
||||
};
|
||||
const result = getEmailDate(lastEmail);
|
||||
expect(result).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('returns date from created_at timestamp', () => {
|
||||
const lastEmail = { created_at: 1705318200 };
|
||||
const result = getEmailDate(lastEmail);
|
||||
expect(result).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('handles millisecond timestamps', () => {
|
||||
const lastEmail = { created_at: 1705318200000 };
|
||||
const result = getEmailDate(lastEmail);
|
||||
expect(result).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('returns null if no valid date found', () => {
|
||||
const result = getEmailDate({});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatQuotedEmailDate', () => {
|
||||
it('formats date correctly', () => {
|
||||
const date = new Date('2024-01-15T10:30:00Z');
|
||||
const result = formatQuotedEmailDate(date);
|
||||
expect(result).toMatch(/Mon, Jan 15, 2024 at/);
|
||||
});
|
||||
|
||||
it('returns empty string for invalid date', () => {
|
||||
const result = formatQuotedEmailDate('invalid');
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInboxEmail', () => {
|
||||
it('returns email from contentAttributes.email.to', () => {
|
||||
const lastEmail = {
|
||||
contentAttributes: {
|
||||
email: { to: ['inbox@example.com'] },
|
||||
},
|
||||
};
|
||||
const result = getInboxEmail(lastEmail, {});
|
||||
expect(result).toBe('inbox@example.com');
|
||||
});
|
||||
|
||||
it('returns inbox email as fallback', () => {
|
||||
const lastEmail = {};
|
||||
const inbox = { email: 'support@example.com' };
|
||||
const result = getInboxEmail(lastEmail, inbox);
|
||||
expect(result).toBe('support@example.com');
|
||||
});
|
||||
|
||||
it('returns empty string if no email found', () => {
|
||||
expect(getInboxEmail({}, {})).toBe('');
|
||||
});
|
||||
|
||||
it('trims whitespace from emails', () => {
|
||||
const lastEmail = {
|
||||
contentAttributes: {
|
||||
email: { to: [' inbox@example.com '] },
|
||||
},
|
||||
};
|
||||
const result = getInboxEmail(lastEmail, {});
|
||||
expect(result).toBe('inbox@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildQuotedEmailHeaderFromContact', () => {
|
||||
it('builds complete header with name and email', () => {
|
||||
const lastEmail = {
|
||||
sender: { name: 'John Doe', email: 'john@example.com' },
|
||||
contentAttributes: {
|
||||
email: { date: '2024-01-15T10:30:00Z' },
|
||||
},
|
||||
};
|
||||
const result = buildQuotedEmailHeaderFromContact(lastEmail, {});
|
||||
expect(result).toContain('John Doe');
|
||||
expect(result).toContain('john@example.com');
|
||||
expect(result).toContain('wrote:');
|
||||
});
|
||||
|
||||
it('builds header without name if not available', () => {
|
||||
const lastEmail = {
|
||||
sender: { email: 'john@example.com' },
|
||||
contentAttributes: {
|
||||
email: { date: '2024-01-15T10:30:00Z' },
|
||||
},
|
||||
};
|
||||
const result = buildQuotedEmailHeaderFromContact(lastEmail, {});
|
||||
expect(result).toContain('<john@example.com>');
|
||||
expect(result).not.toContain('undefined');
|
||||
});
|
||||
|
||||
it('returns empty string if missing required data', () => {
|
||||
expect(buildQuotedEmailHeaderFromContact(null, {})).toBe('');
|
||||
expect(buildQuotedEmailHeaderFromContact({}, {})).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildQuotedEmailHeaderFromInbox', () => {
|
||||
it('builds complete header with inbox name and email', () => {
|
||||
const lastEmail = {
|
||||
contentAttributes: {
|
||||
email: {
|
||||
date: '2024-01-15T10:30:00Z',
|
||||
to: ['support@example.com'],
|
||||
},
|
||||
},
|
||||
};
|
||||
const inbox = { name: 'Support Team', email: 'support@example.com' };
|
||||
const result = buildQuotedEmailHeaderFromInbox(lastEmail, inbox);
|
||||
expect(result).toContain('Support Team');
|
||||
expect(result).toContain('support@example.com');
|
||||
expect(result).toContain('wrote:');
|
||||
});
|
||||
|
||||
it('builds header without name if not available', () => {
|
||||
const lastEmail = {
|
||||
contentAttributes: {
|
||||
email: {
|
||||
date: '2024-01-15T10:30:00Z',
|
||||
to: ['inbox@example.com'],
|
||||
},
|
||||
},
|
||||
};
|
||||
const inbox = { email: 'inbox@example.com' };
|
||||
const result = buildQuotedEmailHeaderFromInbox(lastEmail, inbox);
|
||||
expect(result).toContain('<inbox@example.com>');
|
||||
expect(result).not.toContain('undefined');
|
||||
});
|
||||
|
||||
it('returns empty string if missing required data', () => {
|
||||
expect(buildQuotedEmailHeaderFromInbox(null, {})).toBe('');
|
||||
expect(buildQuotedEmailHeaderFromInbox({}, {})).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildQuotedEmailHeader', () => {
|
||||
it('uses inbox email for outgoing messages (message_type: 1)', () => {
|
||||
const lastEmail = {
|
||||
message_type: 1,
|
||||
contentAttributes: {
|
||||
email: {
|
||||
date: '2024-01-15T10:30:00Z',
|
||||
to: ['support@example.com'],
|
||||
},
|
||||
},
|
||||
};
|
||||
const inbox = { name: 'Support', email: 'support@example.com' };
|
||||
const contact = { name: 'John Doe', email: 'john@example.com' };
|
||||
const result = buildQuotedEmailHeader(lastEmail, contact, inbox);
|
||||
expect(result).toContain('Support');
|
||||
expect(result).toContain('support@example.com');
|
||||
expect(result).not.toContain('John Doe');
|
||||
});
|
||||
|
||||
it('uses contact email for incoming messages (message_type: 0)', () => {
|
||||
const lastEmail = {
|
||||
message_type: 0,
|
||||
sender: { name: 'Jane Smith', email: 'jane@example.com' },
|
||||
contentAttributes: {
|
||||
email: { date: '2024-01-15T10:30:00Z' },
|
||||
},
|
||||
};
|
||||
const inbox = { name: 'Support', email: 'support@example.com' };
|
||||
const contact = { name: 'Jane Smith', email: 'jane@example.com' };
|
||||
const result = buildQuotedEmailHeader(lastEmail, contact, inbox);
|
||||
expect(result).toContain('Jane Smith');
|
||||
expect(result).toContain('jane@example.com');
|
||||
expect(result).not.toContain('Support');
|
||||
});
|
||||
|
||||
it('returns empty string if missing required data', () => {
|
||||
expect(buildQuotedEmailHeader(null, {}, {})).toBe('');
|
||||
expect(buildQuotedEmailHeader({}, {}, {})).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatQuotedTextAsBlockquote', () => {
|
||||
it('formats single line text', () => {
|
||||
const result = formatQuotedTextAsBlockquote('Hello world');
|
||||
expect(result).toBe('> Hello world');
|
||||
});
|
||||
|
||||
it('formats multi-line text', () => {
|
||||
const text = 'Line 1\nLine 2\nLine 3';
|
||||
const result = formatQuotedTextAsBlockquote(text);
|
||||
expect(result).toBe('> Line 1\n> Line 2\n> Line 3');
|
||||
});
|
||||
|
||||
it('includes header if provided', () => {
|
||||
const result = formatQuotedTextAsBlockquote('Hello', 'Header text');
|
||||
expect(result).toContain('> Header text');
|
||||
expect(result).toContain('>\n> Hello');
|
||||
});
|
||||
|
||||
it('handles empty lines correctly', () => {
|
||||
const text = 'Line 1\n\nLine 3';
|
||||
const result = formatQuotedTextAsBlockquote(text);
|
||||
expect(result).toBe('> Line 1\n>\n> Line 3');
|
||||
});
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(formatQuotedTextAsBlockquote('')).toBe('');
|
||||
expect(formatQuotedTextAsBlockquote('', '')).toBe('');
|
||||
});
|
||||
|
||||
it('handles Windows line endings', () => {
|
||||
const text = 'Line 1\r\nLine 2';
|
||||
const result = formatQuotedTextAsBlockquote(text);
|
||||
expect(result).toBe('> Line 1\n> Line 2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractQuotedEmailText', () => {
|
||||
it('extracts text from textContent.reply', () => {
|
||||
const lastEmail = {
|
||||
contentAttributes: {
|
||||
email: { textContent: { reply: 'Reply text' } },
|
||||
},
|
||||
};
|
||||
const result = extractQuotedEmailText(lastEmail);
|
||||
expect(result).toBe('Reply text');
|
||||
});
|
||||
|
||||
it('falls back to textContent.full', () => {
|
||||
const lastEmail = {
|
||||
contentAttributes: {
|
||||
email: { textContent: { full: 'Full text' } },
|
||||
},
|
||||
};
|
||||
const result = extractQuotedEmailText(lastEmail);
|
||||
expect(result).toBe('Full text');
|
||||
});
|
||||
|
||||
it('extracts from htmlContent and converts to plain text', () => {
|
||||
const lastEmail = {
|
||||
contentAttributes: {
|
||||
email: { htmlContent: { reply: '<p>HTML reply</p>' } },
|
||||
},
|
||||
};
|
||||
const result = extractQuotedEmailText(lastEmail);
|
||||
expect(result).toBe('HTML reply');
|
||||
});
|
||||
|
||||
it('uses fallback content if structured content not available', () => {
|
||||
const lastEmail = { content: 'Fallback content' };
|
||||
const result = extractQuotedEmailText(lastEmail);
|
||||
expect(result).toBe('Fallback content');
|
||||
});
|
||||
|
||||
it('returns empty string for null or missing email', () => {
|
||||
expect(extractQuotedEmailText(null)).toBe('');
|
||||
expect(extractQuotedEmailText({})).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncatePreviewText', () => {
|
||||
it('returns full text if under max length', () => {
|
||||
const text = 'Short text';
|
||||
const result = truncatePreviewText(text, 80);
|
||||
expect(result).toBe('Short text');
|
||||
});
|
||||
|
||||
it('truncates text exceeding max length', () => {
|
||||
const text = 'A'.repeat(100);
|
||||
const result = truncatePreviewText(text, 80);
|
||||
expect(result).toHaveLength(80);
|
||||
expect(result).toContain('...');
|
||||
});
|
||||
|
||||
it('collapses multiple spaces', () => {
|
||||
const text = 'Text with spaces';
|
||||
const result = truncatePreviewText(text);
|
||||
expect(result).toBe('Text with spaces');
|
||||
});
|
||||
|
||||
it('trims whitespace', () => {
|
||||
const text = ' Text with spaces ';
|
||||
const result = truncatePreviewText(text);
|
||||
expect(result).toBe('Text with spaces');
|
||||
});
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(truncatePreviewText('')).toBe('');
|
||||
expect(truncatePreviewText(' ')).toBe('');
|
||||
});
|
||||
|
||||
it('uses default max length of 80', () => {
|
||||
const text = 'A'.repeat(100);
|
||||
const result = truncatePreviewText(text);
|
||||
expect(result).toHaveLength(80);
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendQuotedTextToMessage', () => {
|
||||
it('appends quoted text to message', () => {
|
||||
const message = 'My reply';
|
||||
const quotedText = 'Original message';
|
||||
const header = 'On date sender wrote:';
|
||||
const result = appendQuotedTextToMessage(message, quotedText, header);
|
||||
|
||||
expect(result).toContain('My reply');
|
||||
expect(result).toContain('> On date sender wrote:');
|
||||
expect(result).toContain('> Original message');
|
||||
});
|
||||
|
||||
it('returns only quoted text if message is empty', () => {
|
||||
const result = appendQuotedTextToMessage('', 'Quoted', 'Header');
|
||||
expect(result).toContain('> Header');
|
||||
expect(result).toContain('> Quoted');
|
||||
expect(result).not.toContain('\n\n\n');
|
||||
});
|
||||
|
||||
it('returns message if no quoted text', () => {
|
||||
const result = appendQuotedTextToMessage('Message', '', '');
|
||||
expect(result).toBe('Message');
|
||||
});
|
||||
|
||||
it('handles proper spacing with double newline', () => {
|
||||
const result = appendQuotedTextToMessage('Message', 'Quoted', 'Header');
|
||||
expect(result).toContain('Message\n\n>');
|
||||
});
|
||||
|
||||
it('does not add extra newlines if message already ends with newlines', () => {
|
||||
const result = appendQuotedTextToMessage(
|
||||
'Message\n\n',
|
||||
'Quoted',
|
||||
'Header'
|
||||
);
|
||||
expect(result).not.toContain('\n\n\n');
|
||||
});
|
||||
|
||||
it('adds single newline if message ends with one newline', () => {
|
||||
const result = appendQuotedTextToMessage('Message\n', 'Quoted', 'Header');
|
||||
expect(result).toContain('Message\n\n>');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -227,6 +227,13 @@
|
||||
"YES": "Send",
|
||||
"CANCEL": "Cancel"
|
||||
}
|
||||
},
|
||||
"QUOTED_REPLY": {
|
||||
"ENABLE_TOOLTIP": "Include quoted email thread",
|
||||
"DISABLE_TOOLTIP": "Don't include quoted email thread",
|
||||
"REMOVE_PREVIEW": "Remove quoted email thread",
|
||||
"COLLAPSE": "Collapse preview",
|
||||
"EXPAND": "Expand preview"
|
||||
}
|
||||
},
|
||||
"VISIBLE_TO_AGENTS": "Private Note: Only visible to you and your team",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"PLACEHOLDER": "Search",
|
||||
"EMPTY_STATE": "No results found"
|
||||
},
|
||||
"CLOSE": "Close"
|
||||
"CLOSE": "Close",
|
||||
"BETA": "Beta",
|
||||
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"LEARN_MORE": "Learn more about inboxes",
|
||||
"RECONNECTION_REQUIRED": "Your inbox is disconnected. You won't receive new messages until you reauthorize it.",
|
||||
"CLICK_TO_RECONNECT": "Click here to reconnect.",
|
||||
"WHATSAPP_REGISTRATION_INCOMPLETE": "Your WhatsApp Business registration isn’t complete. Please check your display name status in Meta Business Manager before reconnecting.",
|
||||
"COMPLETE_REGISTRATION": "Complete Registration",
|
||||
"LIST": {
|
||||
"404": "There are no inboxes attached to this account."
|
||||
},
|
||||
@@ -605,8 +607,64 @@
|
||||
"BUSINESS_HOURS": "Business Hours",
|
||||
"WIDGET_BUILDER": "Widget Builder",
|
||||
"BOT_CONFIGURATION": "Bot Configuration",
|
||||
"ACCOUNT_HEALTH": "Account Health",
|
||||
"CSAT": "CSAT"
|
||||
},
|
||||
"ACCOUNT_HEALTH": {
|
||||
"TITLE": "Manage your WhatsApp account",
|
||||
"DESCRIPTION": "Review your WhatsApp account status, messaging limits, and quality. Update settings or resolve issues if needed",
|
||||
"GO_TO_SETTINGS": "Go to Meta Business Manager",
|
||||
"NO_DATA": "Health data is not available",
|
||||
"FIELDS": {
|
||||
"DISPLAY_PHONE_NUMBER": {
|
||||
"LABEL": "Display phone number",
|
||||
"TOOLTIP": "Phone number displayed to customers"
|
||||
},
|
||||
"VERIFIED_NAME": {
|
||||
"LABEL": "Business name",
|
||||
"TOOLTIP": "Business name verified by WhatsApp"
|
||||
},
|
||||
"DISPLAY_NAME_STATUS": {
|
||||
"LABEL": "Display name status",
|
||||
"TOOLTIP": "Status of your business name verification"
|
||||
},
|
||||
"QUALITY_RATING": {
|
||||
"LABEL": "Quality rating",
|
||||
"TOOLTIP": "WhatsApp quality rating for your account"
|
||||
},
|
||||
"MESSAGING_LIMIT_TIER": {
|
||||
"LABEL": "Messaging limit tier",
|
||||
"TOOLTIP": "Daily messaging limit for your account"
|
||||
},
|
||||
"ACCOUNT_MODE": {
|
||||
"LABEL": "Account mode",
|
||||
"TOOLTIP": "Current operating mode of your WhatsApp account"
|
||||
}
|
||||
},
|
||||
"VALUES": {
|
||||
"TIERS": {
|
||||
"TIER_250": "250 customers per 24h",
|
||||
"TIER_1000": "1K customers per 24h",
|
||||
"TIER_1K": "1K customers per 24h",
|
||||
"TIER_10K": "10K customers per 24h",
|
||||
"TIER_100K": "100K customers per 24h",
|
||||
"TIER_UNLIMITED": "Unlimited customers per 24h",
|
||||
"UNKNOWN": "Rating not available"
|
||||
},
|
||||
"STATUSES": {
|
||||
"APPROVED": "Approved",
|
||||
"PENDING_REVIEW": "Pending Review",
|
||||
"AVAILABLE_WITHOUT_REVIEW": "Available Without Review",
|
||||
"REJECTED": "Rejected",
|
||||
"DECLINED": "Declined",
|
||||
"NON_EXISTS": "Non exists"
|
||||
},
|
||||
"MODES": {
|
||||
"SANDBOX": "Sandbox",
|
||||
"LIVE": "Live"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SETTINGS": "Settings",
|
||||
"FEATURES": {
|
||||
"LABEL": "Features",
|
||||
@@ -618,6 +676,11 @@
|
||||
"SETTINGS_POPUP": {
|
||||
"MESSENGER_HEADING": "Messenger Script",
|
||||
"MESSENGER_SUB_HEAD": "Place this button inside your body tag",
|
||||
"ALLOWED_DOMAINS": {
|
||||
"TITLE": "Allowed Domains",
|
||||
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
|
||||
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
|
||||
},
|
||||
"INBOX_AGENTS": "Agents",
|
||||
"INBOX_AGENTS_SUB_TEXT": "Add or remove agents from this inbox",
|
||||
"AGENT_ASSIGNMENT": "Conversation Assignment",
|
||||
|
||||
@@ -752,6 +752,115 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"CUSTOM_TOOLS": {
|
||||
"HEADER": "Tools",
|
||||
"ADD_NEW": "Create a new tool",
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "No custom tools available",
|
||||
"SUBTITLE": "Create custom tools to connect your assistant with external APIs and services, enabling it to fetch data and perform actions on your behalf.",
|
||||
"FEATURE_SPOTLIGHT": {
|
||||
"TITLE": "Custom Tools",
|
||||
"NOTE": "Custom tools allow your assistant to interact with external APIs and services. Create tools to fetch data, perform actions, or integrate with your existing systems to enhance your assistant's capabilities."
|
||||
}
|
||||
},
|
||||
"FORM_DESCRIPTION": "Configure your custom tool to connect with external APIs",
|
||||
"OPTIONS": {
|
||||
"EDIT_TOOL": "Edit tool",
|
||||
"DELETE_TOOL": "Delete tool"
|
||||
},
|
||||
"CREATE": {
|
||||
"TITLE": "Create Custom Tool",
|
||||
"SUCCESS_MESSAGE": "Custom tool created successfully",
|
||||
"ERROR_MESSAGE": "Failed to create custom tool"
|
||||
},
|
||||
"EDIT": {
|
||||
"TITLE": "Edit Custom Tool",
|
||||
"SUCCESS_MESSAGE": "Custom tool updated successfully",
|
||||
"ERROR_MESSAGE": "Failed to update custom tool"
|
||||
},
|
||||
"DELETE": {
|
||||
"TITLE": "Delete Custom Tool",
|
||||
"DESCRIPTION": "Are you sure you want to delete this custom tool? This action cannot be undone.",
|
||||
"CONFIRM": "Yes, delete",
|
||||
"SUCCESS_MESSAGE": "Custom tool deleted successfully",
|
||||
"ERROR_MESSAGE": "Failed to delete custom tool"
|
||||
},
|
||||
"FORM": {
|
||||
"TITLE": {
|
||||
"LABEL": "Tool Name",
|
||||
"PLACEHOLDER": "Order Lookup",
|
||||
"ERROR": "Tool name is required"
|
||||
},
|
||||
"DESCRIPTION": {
|
||||
"LABEL": "Description",
|
||||
"PLACEHOLDER": "Looks up order details by order ID"
|
||||
},
|
||||
"HTTP_METHOD": {
|
||||
"LABEL": "Method"
|
||||
},
|
||||
"ENDPOINT_URL": {
|
||||
"LABEL": "Endpoint URL",
|
||||
"PLACEHOLDER": "https://api.example.com/orders/{'{{'} order_id {'}}'}",
|
||||
"ERROR": "Valid URL is required"
|
||||
},
|
||||
"AUTH_TYPE": {
|
||||
"LABEL": "Authentication Type"
|
||||
},
|
||||
"AUTH_TYPES": {
|
||||
"NONE": "None",
|
||||
"BEARER": "Bearer Token",
|
||||
"BASIC": "Basic Auth",
|
||||
"API_KEY": "API Key"
|
||||
},
|
||||
"AUTH_CONFIG": {
|
||||
"BEARER_TOKEN": "Bearer Token",
|
||||
"BEARER_TOKEN_PLACEHOLDER": "Enter your bearer token",
|
||||
"USERNAME": "Username",
|
||||
"USERNAME_PLACEHOLDER": "Enter username",
|
||||
"PASSWORD": "Password",
|
||||
"PASSWORD_PLACEHOLDER": "Enter password",
|
||||
"API_KEY": "Header Name",
|
||||
"API_KEY_PLACEHOLDER": "X-API-Key",
|
||||
"API_VALUE": "Header Value",
|
||||
"API_VALUE_PLACEHOLDER": "Enter API key value"
|
||||
},
|
||||
"PARAMETERS": {
|
||||
"LABEL": "Parameters",
|
||||
"HELP_TEXT": "Define the parameters that will be extracted from user queries"
|
||||
},
|
||||
"ADD_PARAMETER": "Add Parameter",
|
||||
"PARAM_NAME": {
|
||||
"PLACEHOLDER": "Parameter name (e.g., order_id)"
|
||||
},
|
||||
"PARAM_TYPE": {
|
||||
"PLACEHOLDER": "Type"
|
||||
},
|
||||
"PARAM_TYPES": {
|
||||
"STRING": "String",
|
||||
"NUMBER": "Number",
|
||||
"BOOLEAN": "Boolean",
|
||||
"ARRAY": "Array",
|
||||
"OBJECT": "Object"
|
||||
},
|
||||
"PARAM_DESCRIPTION": {
|
||||
"PLACEHOLDER": "Description of the parameter"
|
||||
},
|
||||
"PARAM_REQUIRED": {
|
||||
"LABEL": "Required"
|
||||
},
|
||||
"REQUEST_TEMPLATE": {
|
||||
"LABEL": "Request Body Template (Optional)",
|
||||
"PLACEHOLDER": "{'{'}\n \"order_id\": \"{'{{'} order_id {'}}'}\"\n{'}'}"
|
||||
},
|
||||
"RESPONSE_TEMPLATE": {
|
||||
"LABEL": "Response Template (Optional)",
|
||||
"PLACEHOLDER": "Order {'{{'} order_id {'}}'} status: {'{{'} status {'}}'}"
|
||||
},
|
||||
"ERRORS": {
|
||||
"PARAM_NAME_REQUIRED": "Parameter name is required"
|
||||
}
|
||||
}
|
||||
},
|
||||
"RESPONSES": {
|
||||
"HEADER": "FAQs",
|
||||
"ADD_NEW": "Create new FAQ",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"CREATE_NEW_ACCOUNT": "Create a new account",
|
||||
"SUBMIT": "Login",
|
||||
"SAML": {
|
||||
"LABEL": "Log in via SSO",
|
||||
"LABEL": "Login via SSO",
|
||||
"TITLE": "Initiate Single Sign-on (SSO)",
|
||||
"SUBTITLE": "Enter your work email to access your organization",
|
||||
"BACK_TO_LOGIN": "Login via Password",
|
||||
@@ -34,7 +34,7 @@
|
||||
},
|
||||
"SUBMIT": "Continue with SSO",
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "SSO authentication failed"
|
||||
"ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
},
|
||||
"DATE_RANGE_OPTIONS": {
|
||||
"LAST_7_DAYS": "Last 7 days",
|
||||
"LAST_14_DAYS": "Last 14 days",
|
||||
"LAST_30_DAYS": "Last 30 days",
|
||||
"LAST_3_MONTHS": "Last 3 months",
|
||||
"LAST_6_MONTHS": "Last 6 months",
|
||||
@@ -266,6 +267,8 @@
|
||||
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
|
||||
"DOWNLOAD_INBOX_REPORTS": "Download inbox reports",
|
||||
"FILTER_DROPDOWN_LABEL": "Select Inbox",
|
||||
"ALL_INBOXES": "All Inboxes",
|
||||
"SEARCH_INBOX": "Search Inbox",
|
||||
"METRICS": {
|
||||
"CONVERSATIONS": {
|
||||
"NAME": "Conversations",
|
||||
@@ -467,6 +470,13 @@
|
||||
"CONVERSATIONS": "{count} conversations",
|
||||
"DOWNLOAD_REPORT": "Download report"
|
||||
},
|
||||
"RESOLUTION_HEATMAP": {
|
||||
"HEADER": "Resolutions",
|
||||
"NO_CONVERSATIONS": "No conversations",
|
||||
"CONVERSATION": "{count} conversation",
|
||||
"CONVERSATIONS": "{count} conversations",
|
||||
"DOWNLOAD_REPORT": "Download report"
|
||||
},
|
||||
"AGENT_CONVERSATIONS": {
|
||||
"HEADER": "Conversations by agents",
|
||||
"LOADING_MESSAGE": "Loading agent metrics...",
|
||||
|
||||
@@ -304,6 +304,7 @@
|
||||
"CAPTAIN_ASSISTANTS": "Assistants",
|
||||
"CAPTAIN_DOCUMENTS": "Documents",
|
||||
"CAPTAIN_RESPONSES": "FAQs",
|
||||
"CAPTAIN_TOOLS": "Tools",
|
||||
"HOME": "Home",
|
||||
"AGENTS": "Agents",
|
||||
"AGENT_BOTS": "Bots",
|
||||
|
||||
@@ -27,15 +27,20 @@
|
||||
"LABEL": "Password",
|
||||
"PLACEHOLDER": "Password",
|
||||
"ERROR": "Password is too short.",
|
||||
"IS_INVALID_PASSWORD": "Password should contain atleast 1 uppercase letter, 1 lowercase letter, 1 number and 1 special character."
|
||||
"IS_INVALID_PASSWORD": "Password should contain atleast 1 uppercase letter, 1 lowercase letter, 1 number and 1 special character.",
|
||||
"REQUIREMENTS_LENGTH": "At least 6 characters long",
|
||||
"REQUIREMENTS_UPPERCASE": "At least one uppercase letter",
|
||||
"REQUIREMENTS_LOWERCASE": "At least one lowercase letter",
|
||||
"REQUIREMENTS_NUMBER": "At least one number",
|
||||
"REQUIREMENTS_SPECIAL": "At least one special character"
|
||||
},
|
||||
"CONFIRM_PASSWORD": {
|
||||
"LABEL": "Confirm password",
|
||||
"PLACEHOLDER": "Confirm password",
|
||||
"ERROR": "Password doesnot match."
|
||||
"ERROR": "Passwords do not match."
|
||||
},
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Registration Successfull",
|
||||
"SUCCESS_MESSAGE": "Registration Successful",
|
||||
"ERROR_MESSAGE": "Could not connect to Woot server. Please try again."
|
||||
},
|
||||
"SUBMIT": "Create account",
|
||||
|
||||
@@ -227,6 +227,13 @@
|
||||
"YES": "إرسال",
|
||||
"CANCEL": "إلغاء"
|
||||
}
|
||||
},
|
||||
"QUOTED_REPLY": {
|
||||
"ENABLE_TOOLTIP": "Include quoted email thread",
|
||||
"DISABLE_TOOLTIP": "Don't include quoted email thread",
|
||||
"REMOVE_PREVIEW": "Remove quoted email thread",
|
||||
"COLLAPSE": "Collapse preview",
|
||||
"EXPAND": "Expand preview"
|
||||
}
|
||||
},
|
||||
"VISIBLE_TO_AGENTS": "ملاحظة خاصة: مرئية فقط لك ولأعضاء فريقك",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"PLACEHOLDER": "بحث",
|
||||
"EMPTY_STATE": "لم يتم العثور على النتائج"
|
||||
},
|
||||
"CLOSE": "أغلق"
|
||||
"CLOSE": "أغلق",
|
||||
"BETA": "تجريبي",
|
||||
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"LEARN_MORE": "تعلم المزيد عن صناديق البريد",
|
||||
"RECONNECTION_REQUIRED": "Your inbox is disconnected. You won't receive new messages until you reauthorize it.",
|
||||
"CLICK_TO_RECONNECT": "Click here to reconnect.",
|
||||
"WHATSAPP_REGISTRATION_INCOMPLETE": "Your WhatsApp Business registration isn’t complete. Please check your display name status in Meta Business Manager before reconnecting.",
|
||||
"COMPLETE_REGISTRATION": "Complete Registration",
|
||||
"LIST": {
|
||||
"404": "لا توجد صناديق وارد لقنوات تواصل مرتبطة بهذا الحساب."
|
||||
},
|
||||
@@ -605,8 +607,64 @@
|
||||
"BUSINESS_HOURS": "ساعات العمل",
|
||||
"WIDGET_BUILDER": "منشئ اللايف شات",
|
||||
"BOT_CONFIGURATION": "اعدادات البوت",
|
||||
"ACCOUNT_HEALTH": "Account Health",
|
||||
"CSAT": "تقييم رضاء العملاء"
|
||||
},
|
||||
"ACCOUNT_HEALTH": {
|
||||
"TITLE": "Manage your WhatsApp account",
|
||||
"DESCRIPTION": "Review your WhatsApp account status, messaging limits, and quality. Update settings or resolve issues if needed",
|
||||
"GO_TO_SETTINGS": "Go to Meta Business Manager",
|
||||
"NO_DATA": "Health data is not available",
|
||||
"FIELDS": {
|
||||
"DISPLAY_PHONE_NUMBER": {
|
||||
"LABEL": "Display phone number",
|
||||
"TOOLTIP": "Phone number displayed to customers"
|
||||
},
|
||||
"VERIFIED_NAME": {
|
||||
"LABEL": "Business name",
|
||||
"TOOLTIP": "Business name verified by WhatsApp"
|
||||
},
|
||||
"DISPLAY_NAME_STATUS": {
|
||||
"LABEL": "Display name status",
|
||||
"TOOLTIP": "Status of your business name verification"
|
||||
},
|
||||
"QUALITY_RATING": {
|
||||
"LABEL": "Quality rating",
|
||||
"TOOLTIP": "WhatsApp quality rating for your account"
|
||||
},
|
||||
"MESSAGING_LIMIT_TIER": {
|
||||
"LABEL": "Messaging limit tier",
|
||||
"TOOLTIP": "Daily messaging limit for your account"
|
||||
},
|
||||
"ACCOUNT_MODE": {
|
||||
"LABEL": "Account mode",
|
||||
"TOOLTIP": "Current operating mode of your WhatsApp account"
|
||||
}
|
||||
},
|
||||
"VALUES": {
|
||||
"TIERS": {
|
||||
"TIER_250": "250 customers per 24h",
|
||||
"TIER_1000": "1K customers per 24h",
|
||||
"TIER_1K": "1K customers per 24h",
|
||||
"TIER_10K": "10K customers per 24h",
|
||||
"TIER_100K": "100K customers per 24h",
|
||||
"TIER_UNLIMITED": "Unlimited customers per 24h",
|
||||
"UNKNOWN": "Rating not available"
|
||||
},
|
||||
"STATUSES": {
|
||||
"APPROVED": "Approved",
|
||||
"PENDING_REVIEW": "Pending Review",
|
||||
"AVAILABLE_WITHOUT_REVIEW": "Available Without Review",
|
||||
"REJECTED": "Rejected",
|
||||
"DECLINED": "Declined",
|
||||
"NON_EXISTS": "Non exists"
|
||||
},
|
||||
"MODES": {
|
||||
"SANDBOX": "Sandbox",
|
||||
"LIVE": "مباشر"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SETTINGS": "الإعدادات",
|
||||
"FEATURES": {
|
||||
"LABEL": "الخصائص",
|
||||
@@ -618,6 +676,11 @@
|
||||
"SETTINGS_POPUP": {
|
||||
"MESSENGER_HEADING": "كود \"الماسنجر\"",
|
||||
"MESSENGER_SUB_HEAD": "ضع هذا الكود داخل وسم الـ body في موقعك",
|
||||
"ALLOWED_DOMAINS": {
|
||||
"TITLE": "Allowed Domains",
|
||||
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
|
||||
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
|
||||
},
|
||||
"INBOX_AGENTS": "وكيل الدعم",
|
||||
"INBOX_AGENTS_SUB_TEXT": "إضافة أو إزالة وكلاء من صندوق الوارد هذا",
|
||||
"AGENT_ASSIGNMENT": "تعيين المحادثة",
|
||||
|
||||
@@ -752,6 +752,115 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"CUSTOM_TOOLS": {
|
||||
"HEADER": "Tools",
|
||||
"ADD_NEW": "Create a new tool",
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "No custom tools available",
|
||||
"SUBTITLE": "Create custom tools to connect your assistant with external APIs and services, enabling it to fetch data and perform actions on your behalf.",
|
||||
"FEATURE_SPOTLIGHT": {
|
||||
"TITLE": "Custom Tools",
|
||||
"NOTE": "Custom tools allow your assistant to interact with external APIs and services. Create tools to fetch data, perform actions, or integrate with your existing systems to enhance your assistant's capabilities."
|
||||
}
|
||||
},
|
||||
"FORM_DESCRIPTION": "Configure your custom tool to connect with external APIs",
|
||||
"OPTIONS": {
|
||||
"EDIT_TOOL": "Edit tool",
|
||||
"DELETE_TOOL": "Delete tool"
|
||||
},
|
||||
"CREATE": {
|
||||
"TITLE": "Create Custom Tool",
|
||||
"SUCCESS_MESSAGE": "Custom tool created successfully",
|
||||
"ERROR_MESSAGE": "Failed to create custom tool"
|
||||
},
|
||||
"EDIT": {
|
||||
"TITLE": "Edit Custom Tool",
|
||||
"SUCCESS_MESSAGE": "Custom tool updated successfully",
|
||||
"ERROR_MESSAGE": "Failed to update custom tool"
|
||||
},
|
||||
"DELETE": {
|
||||
"TITLE": "Delete Custom Tool",
|
||||
"DESCRIPTION": "Are you sure you want to delete this custom tool? This action cannot be undone.",
|
||||
"CONFIRM": "نعم، احذف",
|
||||
"SUCCESS_MESSAGE": "Custom tool deleted successfully",
|
||||
"ERROR_MESSAGE": "Failed to delete custom tool"
|
||||
},
|
||||
"FORM": {
|
||||
"TITLE": {
|
||||
"LABEL": "Tool Name",
|
||||
"PLACEHOLDER": "Order Lookup",
|
||||
"ERROR": "Tool name is required"
|
||||
},
|
||||
"DESCRIPTION": {
|
||||
"LABEL": "الوصف",
|
||||
"PLACEHOLDER": "Looks up order details by order ID"
|
||||
},
|
||||
"HTTP_METHOD": {
|
||||
"LABEL": "Method"
|
||||
},
|
||||
"ENDPOINT_URL": {
|
||||
"LABEL": "Endpoint URL",
|
||||
"PLACEHOLDER": "https://api.example.com/orders/{'{{'} order_id {'}}'}",
|
||||
"ERROR": "Valid URL is required"
|
||||
},
|
||||
"AUTH_TYPE": {
|
||||
"LABEL": "Authentication Type"
|
||||
},
|
||||
"AUTH_TYPES": {
|
||||
"NONE": "لا شيء",
|
||||
"BEARER": "Bearer Token",
|
||||
"BASIC": "Basic Auth",
|
||||
"API_KEY": "مفتاح API"
|
||||
},
|
||||
"AUTH_CONFIG": {
|
||||
"BEARER_TOKEN": "Bearer Token",
|
||||
"BEARER_TOKEN_PLACEHOLDER": "Enter your bearer token",
|
||||
"USERNAME": "Username",
|
||||
"USERNAME_PLACEHOLDER": "Enter username",
|
||||
"PASSWORD": "كلمة المرور",
|
||||
"PASSWORD_PLACEHOLDER": "Enter password",
|
||||
"API_KEY": "Header Name",
|
||||
"API_KEY_PLACEHOLDER": "X-API-Key",
|
||||
"API_VALUE": "Header Value",
|
||||
"API_VALUE_PLACEHOLDER": "Enter API key value"
|
||||
},
|
||||
"PARAMETERS": {
|
||||
"LABEL": "Parameters",
|
||||
"HELP_TEXT": "Define the parameters that will be extracted from user queries"
|
||||
},
|
||||
"ADD_PARAMETER": "Add Parameter",
|
||||
"PARAM_NAME": {
|
||||
"PLACEHOLDER": "Parameter name (e.g., order_id)"
|
||||
},
|
||||
"PARAM_TYPE": {
|
||||
"PLACEHOLDER": "النوع"
|
||||
},
|
||||
"PARAM_TYPES": {
|
||||
"STRING": "String",
|
||||
"NUMBER": "العدد",
|
||||
"BOOLEAN": "Boolean",
|
||||
"ARRAY": "Array",
|
||||
"OBJECT": "Object"
|
||||
},
|
||||
"PARAM_DESCRIPTION": {
|
||||
"PLACEHOLDER": "Description of the parameter"
|
||||
},
|
||||
"PARAM_REQUIRED": {
|
||||
"LABEL": "مطلوب"
|
||||
},
|
||||
"REQUEST_TEMPLATE": {
|
||||
"LABEL": "Request Body Template (Optional)",
|
||||
"PLACEHOLDER": "{'{'}\n \"order_id\": \"{'{{'} order_id {'}}'}\"\n{'}'}"
|
||||
},
|
||||
"RESPONSE_TEMPLATE": {
|
||||
"LABEL": "Response Template (Optional)",
|
||||
"PLACEHOLDER": "Order {'{{'} order_id {'}}'} status: {'{{'} status {'}}'}"
|
||||
},
|
||||
"ERRORS": {
|
||||
"PARAM_NAME_REQUIRED": "Parameter name is required"
|
||||
}
|
||||
}
|
||||
},
|
||||
"RESPONSES": {
|
||||
"HEADER": "FAQs",
|
||||
"ADD_NEW": "Create new FAQ",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"CREATE_NEW_ACCOUNT": "إنشاء حساب جديد",
|
||||
"SUBMIT": "تسجيل الدخول",
|
||||
"SAML": {
|
||||
"LABEL": "Log in via SSO",
|
||||
"LABEL": "Login via SSO",
|
||||
"TITLE": "Initiate Single Sign-on (SSO)",
|
||||
"SUBTITLE": "Enter your work email to access your organization",
|
||||
"BACK_TO_LOGIN": "Login via Password",
|
||||
@@ -34,7 +34,7 @@
|
||||
},
|
||||
"SUBMIT": "Continue with SSO",
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "SSO authentication failed"
|
||||
"ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
},
|
||||
"DATE_RANGE_OPTIONS": {
|
||||
"LAST_7_DAYS": "آخر 7 أيام",
|
||||
"LAST_14_DAYS": "آخر 14 يوماً",
|
||||
"LAST_30_DAYS": "آخر 30 يوماً",
|
||||
"LAST_3_MONTHS": "آخر 3 أشهر",
|
||||
"LAST_6_MONTHS": "آخر 6 أشهر",
|
||||
@@ -266,6 +267,8 @@
|
||||
"NO_ENOUGH_DATA": "لم يتم جمع بيانات بقدر كافي لإنشاء التقرير، الرجاء المحاولة مرة أخرى لاحقاً.",
|
||||
"DOWNLOAD_INBOX_REPORTS": "تحميل تقارير صندوق الوارد",
|
||||
"FILTER_DROPDOWN_LABEL": "اختر صندوق الوارد",
|
||||
"ALL_INBOXES": "All Inboxes",
|
||||
"SEARCH_INBOX": "Search Inbox",
|
||||
"METRICS": {
|
||||
"CONVERSATIONS": {
|
||||
"NAME": "المحادثات",
|
||||
@@ -467,6 +470,13 @@
|
||||
"CONVERSATIONS": "{count} conversations",
|
||||
"DOWNLOAD_REPORT": "تنزيل التقرير"
|
||||
},
|
||||
"RESOLUTION_HEATMAP": {
|
||||
"HEADER": "Resolutions",
|
||||
"NO_CONVERSATIONS": "No conversations",
|
||||
"CONVERSATION": "{count} conversation",
|
||||
"CONVERSATIONS": "{count} conversations",
|
||||
"DOWNLOAD_REPORT": "تنزيل التقرير"
|
||||
},
|
||||
"AGENT_CONVERSATIONS": {
|
||||
"HEADER": "المحادثات من قبل الوكلاء",
|
||||
"LOADING_MESSAGE": "جاري تحميل مقاييس الوكيل...",
|
||||
|
||||
@@ -304,6 +304,7 @@
|
||||
"CAPTAIN_ASSISTANTS": "Assistants",
|
||||
"CAPTAIN_DOCUMENTS": "Documents",
|
||||
"CAPTAIN_RESPONSES": "FAQs",
|
||||
"CAPTAIN_TOOLS": "Tools",
|
||||
"HOME": "الرئيسية",
|
||||
"AGENTS": "وكيل الدعم",
|
||||
"AGENT_BOTS": "الروبوتات",
|
||||
|
||||
@@ -27,15 +27,20 @@
|
||||
"LABEL": "كلمة المرور",
|
||||
"PLACEHOLDER": "كلمة المرور",
|
||||
"ERROR": "كلمة المرور قصيرة جداً",
|
||||
"IS_INVALID_PASSWORD": "يجب أن تحتوي كلمة المرور على الأقل على حرف كبير واحد وحرف صغير واحد ورقم واحد وحرف خاص واحد"
|
||||
"IS_INVALID_PASSWORD": "يجب أن تحتوي كلمة المرور على الأقل على حرف كبير واحد وحرف صغير واحد ورقم واحد وحرف خاص واحد",
|
||||
"REQUIREMENTS_LENGTH": "At least 6 characters long",
|
||||
"REQUIREMENTS_UPPERCASE": "At least one uppercase letter",
|
||||
"REQUIREMENTS_LOWERCASE": "At least one lowercase letter",
|
||||
"REQUIREMENTS_NUMBER": "At least one number",
|
||||
"REQUIREMENTS_SPECIAL": "At least one special character"
|
||||
},
|
||||
"CONFIRM_PASSWORD": {
|
||||
"LABEL": "تأكيد كلمة المرور",
|
||||
"PLACEHOLDER": "تأكيد كلمة المرور",
|
||||
"ERROR": "كلمة المرور غير متطابقة"
|
||||
"ERROR": "كلمة المرور غير متطابقة."
|
||||
},
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "تم التسجيل بنجاح",
|
||||
"SUCCESS_MESSAGE": "Registration Successful",
|
||||
"ERROR_MESSAGE": "تعذر الاتصال بالخادم، الرجاء المحاولة مرة أخرى لاحقاً"
|
||||
},
|
||||
"SUBMIT": "إرسال",
|
||||
|
||||
@@ -227,6 +227,13 @@
|
||||
"YES": "Send",
|
||||
"CANCEL": "Cancel"
|
||||
}
|
||||
},
|
||||
"QUOTED_REPLY": {
|
||||
"ENABLE_TOOLTIP": "Include quoted email thread",
|
||||
"DISABLE_TOOLTIP": "Don't include quoted email thread",
|
||||
"REMOVE_PREVIEW": "Remove quoted email thread",
|
||||
"COLLAPSE": "Collapse preview",
|
||||
"EXPAND": "Expand preview"
|
||||
}
|
||||
},
|
||||
"VISIBLE_TO_AGENTS": "Private Note: Only visible to you and your team",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"PLACEHOLDER": "Search",
|
||||
"EMPTY_STATE": "No results found"
|
||||
},
|
||||
"CLOSE": "Close"
|
||||
"CLOSE": "Close",
|
||||
"BETA": "Beta",
|
||||
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"LEARN_MORE": "Learn more about inboxes",
|
||||
"RECONNECTION_REQUIRED": "Your inbox is disconnected. You won't receive new messages until you reauthorize it.",
|
||||
"CLICK_TO_RECONNECT": "Click here to reconnect.",
|
||||
"WHATSAPP_REGISTRATION_INCOMPLETE": "Your WhatsApp Business registration isn’t complete. Please check your display name status in Meta Business Manager before reconnecting.",
|
||||
"COMPLETE_REGISTRATION": "Complete Registration",
|
||||
"LIST": {
|
||||
"404": "There are no inboxes attached to this account."
|
||||
},
|
||||
@@ -605,8 +607,64 @@
|
||||
"BUSINESS_HOURS": "Business Hours",
|
||||
"WIDGET_BUILDER": "Widget Builder",
|
||||
"BOT_CONFIGURATION": "Bot Configuration",
|
||||
"ACCOUNT_HEALTH": "Account Health",
|
||||
"CSAT": "CSAT"
|
||||
},
|
||||
"ACCOUNT_HEALTH": {
|
||||
"TITLE": "Manage your WhatsApp account",
|
||||
"DESCRIPTION": "Review your WhatsApp account status, messaging limits, and quality. Update settings or resolve issues if needed",
|
||||
"GO_TO_SETTINGS": "Go to Meta Business Manager",
|
||||
"NO_DATA": "Health data is not available",
|
||||
"FIELDS": {
|
||||
"DISPLAY_PHONE_NUMBER": {
|
||||
"LABEL": "Display phone number",
|
||||
"TOOLTIP": "Phone number displayed to customers"
|
||||
},
|
||||
"VERIFIED_NAME": {
|
||||
"LABEL": "Business name",
|
||||
"TOOLTIP": "Business name verified by WhatsApp"
|
||||
},
|
||||
"DISPLAY_NAME_STATUS": {
|
||||
"LABEL": "Display name status",
|
||||
"TOOLTIP": "Status of your business name verification"
|
||||
},
|
||||
"QUALITY_RATING": {
|
||||
"LABEL": "Quality rating",
|
||||
"TOOLTIP": "WhatsApp quality rating for your account"
|
||||
},
|
||||
"MESSAGING_LIMIT_TIER": {
|
||||
"LABEL": "Messaging limit tier",
|
||||
"TOOLTIP": "Daily messaging limit for your account"
|
||||
},
|
||||
"ACCOUNT_MODE": {
|
||||
"LABEL": "Account mode",
|
||||
"TOOLTIP": "Current operating mode of your WhatsApp account"
|
||||
}
|
||||
},
|
||||
"VALUES": {
|
||||
"TIERS": {
|
||||
"TIER_250": "250 customers per 24h",
|
||||
"TIER_1000": "1K customers per 24h",
|
||||
"TIER_1K": "1K customers per 24h",
|
||||
"TIER_10K": "10K customers per 24h",
|
||||
"TIER_100K": "100K customers per 24h",
|
||||
"TIER_UNLIMITED": "Unlimited customers per 24h",
|
||||
"UNKNOWN": "Rating not available"
|
||||
},
|
||||
"STATUSES": {
|
||||
"APPROVED": "Approved",
|
||||
"PENDING_REVIEW": "Pending Review",
|
||||
"AVAILABLE_WITHOUT_REVIEW": "Available Without Review",
|
||||
"REJECTED": "Rejected",
|
||||
"DECLINED": "Declined",
|
||||
"NON_EXISTS": "Non exists"
|
||||
},
|
||||
"MODES": {
|
||||
"SANDBOX": "Sandbox",
|
||||
"LIVE": "Live"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SETTINGS": "Settings",
|
||||
"FEATURES": {
|
||||
"LABEL": "Features",
|
||||
@@ -618,6 +676,11 @@
|
||||
"SETTINGS_POPUP": {
|
||||
"MESSENGER_HEADING": "Messenger Script",
|
||||
"MESSENGER_SUB_HEAD": "Place this button inside your body tag",
|
||||
"ALLOWED_DOMAINS": {
|
||||
"TITLE": "Allowed Domains",
|
||||
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
|
||||
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
|
||||
},
|
||||
"INBOX_AGENTS": "Agents",
|
||||
"INBOX_AGENTS_SUB_TEXT": "Add or remove agents from this inbox",
|
||||
"AGENT_ASSIGNMENT": "Conversation Assignment",
|
||||
|
||||
@@ -752,6 +752,115 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"CUSTOM_TOOLS": {
|
||||
"HEADER": "Tools",
|
||||
"ADD_NEW": "Create a new tool",
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "No custom tools available",
|
||||
"SUBTITLE": "Create custom tools to connect your assistant with external APIs and services, enabling it to fetch data and perform actions on your behalf.",
|
||||
"FEATURE_SPOTLIGHT": {
|
||||
"TITLE": "Custom Tools",
|
||||
"NOTE": "Custom tools allow your assistant to interact with external APIs and services. Create tools to fetch data, perform actions, or integrate with your existing systems to enhance your assistant's capabilities."
|
||||
}
|
||||
},
|
||||
"FORM_DESCRIPTION": "Configure your custom tool to connect with external APIs",
|
||||
"OPTIONS": {
|
||||
"EDIT_TOOL": "Edit tool",
|
||||
"DELETE_TOOL": "Delete tool"
|
||||
},
|
||||
"CREATE": {
|
||||
"TITLE": "Create Custom Tool",
|
||||
"SUCCESS_MESSAGE": "Custom tool created successfully",
|
||||
"ERROR_MESSAGE": "Failed to create custom tool"
|
||||
},
|
||||
"EDIT": {
|
||||
"TITLE": "Edit Custom Tool",
|
||||
"SUCCESS_MESSAGE": "Custom tool updated successfully",
|
||||
"ERROR_MESSAGE": "Failed to update custom tool"
|
||||
},
|
||||
"DELETE": {
|
||||
"TITLE": "Delete Custom Tool",
|
||||
"DESCRIPTION": "Are you sure you want to delete this custom tool? This action cannot be undone.",
|
||||
"CONFIRM": "Yes, delete",
|
||||
"SUCCESS_MESSAGE": "Custom tool deleted successfully",
|
||||
"ERROR_MESSAGE": "Failed to delete custom tool"
|
||||
},
|
||||
"FORM": {
|
||||
"TITLE": {
|
||||
"LABEL": "Tool Name",
|
||||
"PLACEHOLDER": "Order Lookup",
|
||||
"ERROR": "Tool name is required"
|
||||
},
|
||||
"DESCRIPTION": {
|
||||
"LABEL": "Description",
|
||||
"PLACEHOLDER": "Looks up order details by order ID"
|
||||
},
|
||||
"HTTP_METHOD": {
|
||||
"LABEL": "Method"
|
||||
},
|
||||
"ENDPOINT_URL": {
|
||||
"LABEL": "Endpoint URL",
|
||||
"PLACEHOLDER": "https://api.example.com/orders/{'{{'} order_id {'}}'}",
|
||||
"ERROR": "Valid URL is required"
|
||||
},
|
||||
"AUTH_TYPE": {
|
||||
"LABEL": "Authentication Type"
|
||||
},
|
||||
"AUTH_TYPES": {
|
||||
"NONE": "None",
|
||||
"BEARER": "Bearer Token",
|
||||
"BASIC": "Basic Auth",
|
||||
"API_KEY": "API Key"
|
||||
},
|
||||
"AUTH_CONFIG": {
|
||||
"BEARER_TOKEN": "Bearer Token",
|
||||
"BEARER_TOKEN_PLACEHOLDER": "Enter your bearer token",
|
||||
"USERNAME": "Username",
|
||||
"USERNAME_PLACEHOLDER": "Enter username",
|
||||
"PASSWORD": "Password",
|
||||
"PASSWORD_PLACEHOLDER": "Enter password",
|
||||
"API_KEY": "Header Name",
|
||||
"API_KEY_PLACEHOLDER": "X-API-Key",
|
||||
"API_VALUE": "Header Value",
|
||||
"API_VALUE_PLACEHOLDER": "Enter API key value"
|
||||
},
|
||||
"PARAMETERS": {
|
||||
"LABEL": "Parameters",
|
||||
"HELP_TEXT": "Define the parameters that will be extracted from user queries"
|
||||
},
|
||||
"ADD_PARAMETER": "Add Parameter",
|
||||
"PARAM_NAME": {
|
||||
"PLACEHOLDER": "Parameter name (e.g., order_id)"
|
||||
},
|
||||
"PARAM_TYPE": {
|
||||
"PLACEHOLDER": "Type"
|
||||
},
|
||||
"PARAM_TYPES": {
|
||||
"STRING": "String",
|
||||
"NUMBER": "Number",
|
||||
"BOOLEAN": "Boolean",
|
||||
"ARRAY": "Array",
|
||||
"OBJECT": "Object"
|
||||
},
|
||||
"PARAM_DESCRIPTION": {
|
||||
"PLACEHOLDER": "Description of the parameter"
|
||||
},
|
||||
"PARAM_REQUIRED": {
|
||||
"LABEL": "Required"
|
||||
},
|
||||
"REQUEST_TEMPLATE": {
|
||||
"LABEL": "Request Body Template (Optional)",
|
||||
"PLACEHOLDER": "{'{'}\n \"order_id\": \"{'{{'} order_id {'}}'}\"\n{'}'}"
|
||||
},
|
||||
"RESPONSE_TEMPLATE": {
|
||||
"LABEL": "Response Template (Optional)",
|
||||
"PLACEHOLDER": "Order {'{{'} order_id {'}}'} status: {'{{'} status {'}}'}"
|
||||
},
|
||||
"ERRORS": {
|
||||
"PARAM_NAME_REQUIRED": "Parameter name is required"
|
||||
}
|
||||
}
|
||||
},
|
||||
"RESPONSES": {
|
||||
"HEADER": "FAQs",
|
||||
"ADD_NEW": "Create new FAQ",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"CREATE_NEW_ACCOUNT": "Create a new account",
|
||||
"SUBMIT": "Login",
|
||||
"SAML": {
|
||||
"LABEL": "Log in via SSO",
|
||||
"LABEL": "Login via SSO",
|
||||
"TITLE": "Initiate Single Sign-on (SSO)",
|
||||
"SUBTITLE": "Enter your work email to access your organization",
|
||||
"BACK_TO_LOGIN": "Login via Password",
|
||||
@@ -34,7 +34,7 @@
|
||||
},
|
||||
"SUBMIT": "Continue with SSO",
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "SSO authentication failed"
|
||||
"ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
},
|
||||
"DATE_RANGE_OPTIONS": {
|
||||
"LAST_7_DAYS": "Last 7 days",
|
||||
"LAST_14_DAYS": "Last 14 days",
|
||||
"LAST_30_DAYS": "Last 30 days",
|
||||
"LAST_3_MONTHS": "Last 3 months",
|
||||
"LAST_6_MONTHS": "Last 6 months",
|
||||
@@ -266,6 +267,8 @@
|
||||
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
|
||||
"DOWNLOAD_INBOX_REPORTS": "Download inbox reports",
|
||||
"FILTER_DROPDOWN_LABEL": "Select Inbox",
|
||||
"ALL_INBOXES": "All Inboxes",
|
||||
"SEARCH_INBOX": "Search Inbox",
|
||||
"METRICS": {
|
||||
"CONVERSATIONS": {
|
||||
"NAME": "Conversations",
|
||||
@@ -467,6 +470,13 @@
|
||||
"CONVERSATIONS": "{count} conversations",
|
||||
"DOWNLOAD_REPORT": "Download report"
|
||||
},
|
||||
"RESOLUTION_HEATMAP": {
|
||||
"HEADER": "Resolutions",
|
||||
"NO_CONVERSATIONS": "No conversations",
|
||||
"CONVERSATION": "{count} conversation",
|
||||
"CONVERSATIONS": "{count} conversations",
|
||||
"DOWNLOAD_REPORT": "Download report"
|
||||
},
|
||||
"AGENT_CONVERSATIONS": {
|
||||
"HEADER": "Conversations by agents",
|
||||
"LOADING_MESSAGE": "Loading agent metrics...",
|
||||
|
||||
@@ -304,6 +304,7 @@
|
||||
"CAPTAIN_ASSISTANTS": "Assistants",
|
||||
"CAPTAIN_DOCUMENTS": "Documents",
|
||||
"CAPTAIN_RESPONSES": "FAQs",
|
||||
"CAPTAIN_TOOLS": "Tools",
|
||||
"HOME": "Home",
|
||||
"AGENTS": "Agents",
|
||||
"AGENT_BOTS": "Bots",
|
||||
|
||||
@@ -27,15 +27,20 @@
|
||||
"LABEL": "Password",
|
||||
"PLACEHOLDER": "Password",
|
||||
"ERROR": "Password is too short.",
|
||||
"IS_INVALID_PASSWORD": "Password should contain atleast 1 uppercase letter, 1 lowercase letter, 1 number and 1 special character."
|
||||
"IS_INVALID_PASSWORD": "Password should contain atleast 1 uppercase letter, 1 lowercase letter, 1 number and 1 special character.",
|
||||
"REQUIREMENTS_LENGTH": "At least 6 characters long",
|
||||
"REQUIREMENTS_UPPERCASE": "At least one uppercase letter",
|
||||
"REQUIREMENTS_LOWERCASE": "At least one lowercase letter",
|
||||
"REQUIREMENTS_NUMBER": "At least one number",
|
||||
"REQUIREMENTS_SPECIAL": "At least one special character"
|
||||
},
|
||||
"CONFIRM_PASSWORD": {
|
||||
"LABEL": "Confirm password",
|
||||
"PLACEHOLDER": "Confirm password",
|
||||
"ERROR": "Password doesnot match."
|
||||
"ERROR": "Passwords do not match."
|
||||
},
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Registration Successfull",
|
||||
"SUCCESS_MESSAGE": "Registration Successful",
|
||||
"ERROR_MESSAGE": "Could not connect to Woot server. Please try again."
|
||||
},
|
||||
"SUBMIT": "Create account",
|
||||
|
||||
@@ -227,6 +227,13 @@
|
||||
"YES": "Send",
|
||||
"CANCEL": "Отмени"
|
||||
}
|
||||
},
|
||||
"QUOTED_REPLY": {
|
||||
"ENABLE_TOOLTIP": "Include quoted email thread",
|
||||
"DISABLE_TOOLTIP": "Don't include quoted email thread",
|
||||
"REMOVE_PREVIEW": "Remove quoted email thread",
|
||||
"COLLAPSE": "Collapse preview",
|
||||
"EXPAND": "Expand preview"
|
||||
}
|
||||
},
|
||||
"VISIBLE_TO_AGENTS": "Private Note: Only visible to you and your team",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"PLACEHOLDER": "Търсене",
|
||||
"EMPTY_STATE": "Няма намерени резултати"
|
||||
},
|
||||
"CLOSE": "Close"
|
||||
"CLOSE": "Close",
|
||||
"BETA": "Beta",
|
||||
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"LEARN_MORE": "Learn more about inboxes",
|
||||
"RECONNECTION_REQUIRED": "Your inbox is disconnected. You won't receive new messages until you reauthorize it.",
|
||||
"CLICK_TO_RECONNECT": "Click here to reconnect.",
|
||||
"WHATSAPP_REGISTRATION_INCOMPLETE": "Your WhatsApp Business registration isn’t complete. Please check your display name status in Meta Business Manager before reconnecting.",
|
||||
"COMPLETE_REGISTRATION": "Complete Registration",
|
||||
"LIST": {
|
||||
"404": "There are no inboxes attached to this account."
|
||||
},
|
||||
@@ -605,8 +607,64 @@
|
||||
"BUSINESS_HOURS": "Business Hours",
|
||||
"WIDGET_BUILDER": "Widget Builder",
|
||||
"BOT_CONFIGURATION": "Bot Configuration",
|
||||
"ACCOUNT_HEALTH": "Account Health",
|
||||
"CSAT": "CSAT"
|
||||
},
|
||||
"ACCOUNT_HEALTH": {
|
||||
"TITLE": "Manage your WhatsApp account",
|
||||
"DESCRIPTION": "Review your WhatsApp account status, messaging limits, and quality. Update settings or resolve issues if needed",
|
||||
"GO_TO_SETTINGS": "Go to Meta Business Manager",
|
||||
"NO_DATA": "Health data is not available",
|
||||
"FIELDS": {
|
||||
"DISPLAY_PHONE_NUMBER": {
|
||||
"LABEL": "Display phone number",
|
||||
"TOOLTIP": "Phone number displayed to customers"
|
||||
},
|
||||
"VERIFIED_NAME": {
|
||||
"LABEL": "Business name",
|
||||
"TOOLTIP": "Business name verified by WhatsApp"
|
||||
},
|
||||
"DISPLAY_NAME_STATUS": {
|
||||
"LABEL": "Display name status",
|
||||
"TOOLTIP": "Status of your business name verification"
|
||||
},
|
||||
"QUALITY_RATING": {
|
||||
"LABEL": "Quality rating",
|
||||
"TOOLTIP": "WhatsApp quality rating for your account"
|
||||
},
|
||||
"MESSAGING_LIMIT_TIER": {
|
||||
"LABEL": "Messaging limit tier",
|
||||
"TOOLTIP": "Daily messaging limit for your account"
|
||||
},
|
||||
"ACCOUNT_MODE": {
|
||||
"LABEL": "Account mode",
|
||||
"TOOLTIP": "Current operating mode of your WhatsApp account"
|
||||
}
|
||||
},
|
||||
"VALUES": {
|
||||
"TIERS": {
|
||||
"TIER_250": "250 customers per 24h",
|
||||
"TIER_1000": "1K customers per 24h",
|
||||
"TIER_1K": "1K customers per 24h",
|
||||
"TIER_10K": "10K customers per 24h",
|
||||
"TIER_100K": "100K customers per 24h",
|
||||
"TIER_UNLIMITED": "Unlimited customers per 24h",
|
||||
"UNKNOWN": "Rating not available"
|
||||
},
|
||||
"STATUSES": {
|
||||
"APPROVED": "Approved",
|
||||
"PENDING_REVIEW": "Pending Review",
|
||||
"AVAILABLE_WITHOUT_REVIEW": "Available Without Review",
|
||||
"REJECTED": "Rejected",
|
||||
"DECLINED": "Declined",
|
||||
"NON_EXISTS": "Non exists"
|
||||
},
|
||||
"MODES": {
|
||||
"SANDBOX": "Sandbox",
|
||||
"LIVE": "Live"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SETTINGS": "Settings",
|
||||
"FEATURES": {
|
||||
"LABEL": "Features",
|
||||
@@ -618,6 +676,11 @@
|
||||
"SETTINGS_POPUP": {
|
||||
"MESSENGER_HEADING": "Messenger Script",
|
||||
"MESSENGER_SUB_HEAD": "Place this button inside your body tag",
|
||||
"ALLOWED_DOMAINS": {
|
||||
"TITLE": "Allowed Domains",
|
||||
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
|
||||
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
|
||||
},
|
||||
"INBOX_AGENTS": "Агенти",
|
||||
"INBOX_AGENTS_SUB_TEXT": "Add or remove agents from this inbox",
|
||||
"AGENT_ASSIGNMENT": "Conversation Assignment",
|
||||
|
||||
@@ -752,6 +752,115 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"CUSTOM_TOOLS": {
|
||||
"HEADER": "Tools",
|
||||
"ADD_NEW": "Create a new tool",
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "No custom tools available",
|
||||
"SUBTITLE": "Create custom tools to connect your assistant with external APIs and services, enabling it to fetch data and perform actions on your behalf.",
|
||||
"FEATURE_SPOTLIGHT": {
|
||||
"TITLE": "Custom Tools",
|
||||
"NOTE": "Custom tools allow your assistant to interact with external APIs and services. Create tools to fetch data, perform actions, or integrate with your existing systems to enhance your assistant's capabilities."
|
||||
}
|
||||
},
|
||||
"FORM_DESCRIPTION": "Configure your custom tool to connect with external APIs",
|
||||
"OPTIONS": {
|
||||
"EDIT_TOOL": "Edit tool",
|
||||
"DELETE_TOOL": "Delete tool"
|
||||
},
|
||||
"CREATE": {
|
||||
"TITLE": "Create Custom Tool",
|
||||
"SUCCESS_MESSAGE": "Custom tool created successfully",
|
||||
"ERROR_MESSAGE": "Failed to create custom tool"
|
||||
},
|
||||
"EDIT": {
|
||||
"TITLE": "Edit Custom Tool",
|
||||
"SUCCESS_MESSAGE": "Custom tool updated successfully",
|
||||
"ERROR_MESSAGE": "Failed to update custom tool"
|
||||
},
|
||||
"DELETE": {
|
||||
"TITLE": "Delete Custom Tool",
|
||||
"DESCRIPTION": "Are you sure you want to delete this custom tool? This action cannot be undone.",
|
||||
"CONFIRM": "Yes, delete",
|
||||
"SUCCESS_MESSAGE": "Custom tool deleted successfully",
|
||||
"ERROR_MESSAGE": "Failed to delete custom tool"
|
||||
},
|
||||
"FORM": {
|
||||
"TITLE": {
|
||||
"LABEL": "Tool Name",
|
||||
"PLACEHOLDER": "Order Lookup",
|
||||
"ERROR": "Tool name is required"
|
||||
},
|
||||
"DESCRIPTION": {
|
||||
"LABEL": "Описание",
|
||||
"PLACEHOLDER": "Looks up order details by order ID"
|
||||
},
|
||||
"HTTP_METHOD": {
|
||||
"LABEL": "Method"
|
||||
},
|
||||
"ENDPOINT_URL": {
|
||||
"LABEL": "Endpoint URL",
|
||||
"PLACEHOLDER": "https://api.example.com/orders/{'{{'} order_id {'}}'}",
|
||||
"ERROR": "Valid URL is required"
|
||||
},
|
||||
"AUTH_TYPE": {
|
||||
"LABEL": "Authentication Type"
|
||||
},
|
||||
"AUTH_TYPES": {
|
||||
"NONE": "None",
|
||||
"BEARER": "Bearer Token",
|
||||
"BASIC": "Basic Auth",
|
||||
"API_KEY": "API Key"
|
||||
},
|
||||
"AUTH_CONFIG": {
|
||||
"BEARER_TOKEN": "Bearer Token",
|
||||
"BEARER_TOKEN_PLACEHOLDER": "Enter your bearer token",
|
||||
"USERNAME": "Username",
|
||||
"USERNAME_PLACEHOLDER": "Enter username",
|
||||
"PASSWORD": "Password",
|
||||
"PASSWORD_PLACEHOLDER": "Enter password",
|
||||
"API_KEY": "Header Name",
|
||||
"API_KEY_PLACEHOLDER": "X-API-Key",
|
||||
"API_VALUE": "Header Value",
|
||||
"API_VALUE_PLACEHOLDER": "Enter API key value"
|
||||
},
|
||||
"PARAMETERS": {
|
||||
"LABEL": "Parameters",
|
||||
"HELP_TEXT": "Define the parameters that will be extracted from user queries"
|
||||
},
|
||||
"ADD_PARAMETER": "Add Parameter",
|
||||
"PARAM_NAME": {
|
||||
"PLACEHOLDER": "Parameter name (e.g., order_id)"
|
||||
},
|
||||
"PARAM_TYPE": {
|
||||
"PLACEHOLDER": "Тип"
|
||||
},
|
||||
"PARAM_TYPES": {
|
||||
"STRING": "String",
|
||||
"NUMBER": "Number",
|
||||
"BOOLEAN": "Boolean",
|
||||
"ARRAY": "Array",
|
||||
"OBJECT": "Object"
|
||||
},
|
||||
"PARAM_DESCRIPTION": {
|
||||
"PLACEHOLDER": "Description of the parameter"
|
||||
},
|
||||
"PARAM_REQUIRED": {
|
||||
"LABEL": "Required"
|
||||
},
|
||||
"REQUEST_TEMPLATE": {
|
||||
"LABEL": "Request Body Template (Optional)",
|
||||
"PLACEHOLDER": "{'{'}\n \"order_id\": \"{'{{'} order_id {'}}'}\"\n{'}'}"
|
||||
},
|
||||
"RESPONSE_TEMPLATE": {
|
||||
"LABEL": "Response Template (Optional)",
|
||||
"PLACEHOLDER": "Order {'{{'} order_id {'}}'} status: {'{{'} status {'}}'}"
|
||||
},
|
||||
"ERRORS": {
|
||||
"PARAM_NAME_REQUIRED": "Parameter name is required"
|
||||
}
|
||||
}
|
||||
},
|
||||
"RESPONSES": {
|
||||
"HEADER": "FAQs",
|
||||
"ADD_NEW": "Create new FAQ",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"CREATE_NEW_ACCOUNT": "Create new account",
|
||||
"SUBMIT": "Login",
|
||||
"SAML": {
|
||||
"LABEL": "Log in via SSO",
|
||||
"LABEL": "Login via SSO",
|
||||
"TITLE": "Initiate Single Sign-on (SSO)",
|
||||
"SUBTITLE": "Enter your work email to access your organization",
|
||||
"BACK_TO_LOGIN": "Login via Password",
|
||||
@@ -34,7 +34,7 @@
|
||||
},
|
||||
"SUBMIT": "Continue with SSO",
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "SSO authentication failed"
|
||||
"ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
},
|
||||
"DATE_RANGE_OPTIONS": {
|
||||
"LAST_7_DAYS": "Last 7 days",
|
||||
"LAST_14_DAYS": "Last 14 days",
|
||||
"LAST_30_DAYS": "Last 30 days",
|
||||
"LAST_3_MONTHS": "Last 3 months",
|
||||
"LAST_6_MONTHS": "Last 6 months",
|
||||
@@ -266,6 +267,8 @@
|
||||
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
|
||||
"DOWNLOAD_INBOX_REPORTS": "Download inbox reports",
|
||||
"FILTER_DROPDOWN_LABEL": "Select Inbox",
|
||||
"ALL_INBOXES": "All Inboxes",
|
||||
"SEARCH_INBOX": "Search Inbox",
|
||||
"METRICS": {
|
||||
"CONVERSATIONS": {
|
||||
"NAME": "Разговори",
|
||||
@@ -467,6 +470,13 @@
|
||||
"CONVERSATIONS": "{count} conversations",
|
||||
"DOWNLOAD_REPORT": "Download report"
|
||||
},
|
||||
"RESOLUTION_HEATMAP": {
|
||||
"HEADER": "Resolutions",
|
||||
"NO_CONVERSATIONS": "No conversations",
|
||||
"CONVERSATION": "{count} conversation",
|
||||
"CONVERSATIONS": "{count} conversations",
|
||||
"DOWNLOAD_REPORT": "Download report"
|
||||
},
|
||||
"AGENT_CONVERSATIONS": {
|
||||
"HEADER": "Conversations by agents",
|
||||
"LOADING_MESSAGE": "Loading agent metrics...",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user